diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/website_sale/models/sale_order.py | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/website_sale/models/sale_order.py')
| -rw-r--r-- | addons/website_sale/models/sale_order.py | 385 |
1 files changed, 385 insertions, 0 deletions
diff --git a/addons/website_sale/models/sale_order.py b/addons/website_sale/models/sale_order.py new file mode 100644 index 00000000..d270c517 --- /dev/null +++ b/addons/website_sale/models/sale_order.py @@ -0,0 +1,385 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import logging +import random +from datetime import datetime +from dateutil.relativedelta import relativedelta + +from odoo import api, models, fields, _ +from odoo.http import request +from odoo.osv import expression +from odoo.exceptions import UserError, ValidationError + +_logger = logging.getLogger(__name__) + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + website_order_line = fields.One2many( + 'sale.order.line', + compute='_compute_website_order_line', + string='Order Lines displayed on Website', + help='Order Lines to be displayed on the website. They should not be used for computation purpose.', + ) + cart_quantity = fields.Integer(compute='_compute_cart_info', string='Cart Quantity') + only_services = fields.Boolean(compute='_compute_cart_info', string='Only Services') + is_abandoned_cart = fields.Boolean('Abandoned Cart', compute='_compute_abandoned_cart', search='_search_abandoned_cart') + cart_recovery_email_sent = fields.Boolean('Cart recovery email already sent') + website_id = fields.Many2one('website', string='Website', readonly=True, + help='Website through which this order was placed.') + + @api.depends('order_line') + def _compute_website_order_line(self): + for order in self: + order.website_order_line = order.order_line + + @api.depends('order_line.product_uom_qty', 'order_line.product_id') + def _compute_cart_info(self): + for order in self: + order.cart_quantity = int(sum(order.mapped('website_order_line.product_uom_qty'))) + order.only_services = all(l.product_id.type in ('service', 'digital') for l in order.website_order_line) + + @api.depends('website_id', 'date_order', 'order_line', 'state', 'partner_id') + def _compute_abandoned_cart(self): + for order in self: + # a quotation can be considered as an abandonned cart if it is linked to a website, + # is in the 'draft' state and has an expiration date + if order.website_id and order.state == 'draft' and order.date_order: + public_partner_id = order.website_id.user_id.partner_id + # by default the expiration date is 1 hour if not specified on the website configuration + abandoned_delay = order.website_id.cart_abandoned_delay or 1.0 + abandoned_datetime = datetime.utcnow() - relativedelta(hours=abandoned_delay) + order.is_abandoned_cart = bool(order.date_order <= abandoned_datetime and order.partner_id != public_partner_id and order.order_line) + else: + order.is_abandoned_cart = False + + def _search_abandoned_cart(self, operator, value): + abandoned_delay = self.website_id and self.website_id.cart_abandoned_delay or 1.0 + abandoned_datetime = fields.Datetime.to_string(datetime.utcnow() - relativedelta(hours=abandoned_delay)) + abandoned_domain = expression.normalize_domain([ + ('date_order', '<=', abandoned_datetime), + ('website_id', '!=', False), + ('state', '=', 'draft'), + ('partner_id', '!=', self.env.ref('base.public_partner').id), + ('order_line', '!=', False) + ]) + # is_abandoned domain possibilities + if (operator not in expression.NEGATIVE_TERM_OPERATORS and value) or (operator in expression.NEGATIVE_TERM_OPERATORS and not value): + return abandoned_domain + return expression.distribute_not(['!'] + abandoned_domain) # negative domain + + def _cart_find_product_line(self, product_id=None, line_id=None, **kwargs): + """Find the cart line matching the given parameters. + + If a product_id is given, the line will match the product only if the + line also has the same special attributes: `no_variant` attributes and + `is_custom` values. + """ + self.ensure_one() + product = self.env['product.product'].browse(product_id) + + # split lines with the same product if it has untracked attributes + if product and (product.product_tmpl_id.has_dynamic_attributes() or product.product_tmpl_id._has_no_variant_attributes()) and not line_id: + return self.env['sale.order.line'] + + domain = [('order_id', '=', self.id), ('product_id', '=', product_id)] + if line_id: + domain += [('id', '=', line_id)] + else: + domain += [('product_custom_attribute_value_ids', '=', False)] + + return self.env['sale.order.line'].sudo().search(domain) + + def _website_product_id_change(self, order_id, product_id, qty=0): + order = self.sudo().browse(order_id) + product_context = dict(self.env.context) + product_context.setdefault('lang', order.partner_id.lang) + product_context.update({ + 'partner': order.partner_id, + 'quantity': qty, + 'date': order.date_order, + 'pricelist': order.pricelist_id.id, + }) + product = self.env['product.product'].with_context(product_context).with_company(order.company_id.id).browse(product_id) + discount = 0 + + if order.pricelist_id.discount_policy == 'without_discount': + # This part is pretty much a copy-paste of the method '_onchange_discount' of + # 'sale.order.line'. + price, rule_id = order.pricelist_id.with_context(product_context).get_product_price_rule(product, qty or 1.0, order.partner_id) + pu, currency = request.env['sale.order.line'].with_context(product_context)._get_real_price_currency(product, rule_id, qty, product.uom_id, order.pricelist_id.id) + if pu != 0: + if order.pricelist_id.currency_id != currency: + # we need new_list_price in the same currency as price, which is in the SO's pricelist's currency + date = order.date_order or fields.Date.today() + pu = currency._convert(pu, order.pricelist_id.currency_id, order.company_id, date) + discount = (pu - price) / pu * 100 + if discount < 0: + # In case the discount is negative, we don't want to show it to the customer, + # but we still want to use the price defined on the pricelist + discount = 0 + pu = price + else: + pu = product.price + if order.pricelist_id and order.partner_id: + order_line = order._cart_find_product_line(product.id) + if order_line: + pu = self.env['account.tax']._fix_tax_included_price_company(pu, product.taxes_id, order_line[0].tax_id, self.company_id) + + return { + 'product_id': product_id, + 'product_uom_qty': qty, + 'order_id': order_id, + 'product_uom': product.uom_id.id, + 'price_unit': pu, + 'discount': discount, + } + + def _cart_update(self, product_id=None, line_id=None, add_qty=0, set_qty=0, **kwargs): + """ Add or set product quantity, add_qty can be negative """ + self.ensure_one() + product_context = dict(self.env.context) + product_context.setdefault('lang', self.sudo().partner_id.lang) + SaleOrderLineSudo = self.env['sale.order.line'].sudo().with_context(product_context) + # change lang to get correct name of attributes/values + product_with_context = self.env['product.product'].with_context(product_context) + product = product_with_context.browse(int(product_id)) + + try: + if add_qty: + add_qty = int(add_qty) + except ValueError: + add_qty = 1 + try: + if set_qty: + set_qty = int(set_qty) + except ValueError: + set_qty = 0 + quantity = 0 + order_line = False + if self.state != 'draft': + request.session['sale_order_id'] = None + raise UserError(_('It is forbidden to modify a sales order which is not in draft status.')) + if line_id is not False: + order_line = self._cart_find_product_line(product_id, line_id, **kwargs)[:1] + + # Create line if no line with product_id can be located + if not order_line: + if not product: + raise UserError(_("The given product does not exist therefore it cannot be added to cart.")) + + no_variant_attribute_values = kwargs.get('no_variant_attribute_values') or [] + received_no_variant_values = product.env['product.template.attribute.value'].browse([int(ptav['value']) for ptav in no_variant_attribute_values]) + received_combination = product.product_template_attribute_value_ids | received_no_variant_values + product_template = product.product_tmpl_id + + # handle all cases where incorrect or incomplete data are received + combination = product_template._get_closest_possible_combination(received_combination) + + # get or create (if dynamic) the correct variant + product = product_template._create_product_variant(combination) + + if not product: + raise UserError(_("The given combination does not exist therefore it cannot be added to cart.")) + + product_id = product.id + + values = self._website_product_id_change(self.id, product_id, qty=1) + + # add no_variant attributes that were not received + for ptav in combination.filtered(lambda ptav: ptav.attribute_id.create_variant == 'no_variant' and ptav not in received_no_variant_values): + no_variant_attribute_values.append({ + 'value': ptav.id, + }) + + # save no_variant attributes values + if no_variant_attribute_values: + values['product_no_variant_attribute_value_ids'] = [ + (6, 0, [int(attribute['value']) for attribute in no_variant_attribute_values]) + ] + + # add is_custom attribute values that were not received + custom_values = kwargs.get('product_custom_attribute_values') or [] + received_custom_values = product.env['product.template.attribute.value'].browse([int(ptav['custom_product_template_attribute_value_id']) for ptav in custom_values]) + + for ptav in combination.filtered(lambda ptav: ptav.is_custom and ptav not in received_custom_values): + custom_values.append({ + 'custom_product_template_attribute_value_id': ptav.id, + 'custom_value': '', + }) + + # save is_custom attributes values + if custom_values: + values['product_custom_attribute_value_ids'] = [(0, 0, { + 'custom_product_template_attribute_value_id': custom_value['custom_product_template_attribute_value_id'], + 'custom_value': custom_value['custom_value'] + }) for custom_value in custom_values] + + # create the line + order_line = SaleOrderLineSudo.create(values) + + try: + order_line._compute_tax_id() + except ValidationError as e: + # The validation may occur in backend (eg: taxcloud) but should fail silently in frontend + _logger.debug("ValidationError occurs during tax compute. %s" % (e)) + if add_qty: + add_qty -= 1 + + # compute new quantity + if set_qty: + quantity = set_qty + elif add_qty is not None: + quantity = order_line.product_uom_qty + (add_qty or 0) + + # Remove zero of negative lines + if quantity <= 0: + linked_line = order_line.linked_line_id + order_line.unlink() + if linked_line: + # update description of the parent + linked_product = product_with_context.browse(linked_line.product_id.id) + linked_line.name = linked_line.get_sale_order_line_multiline_description_sale(linked_product) + else: + # update line + no_variant_attributes_price_extra = [ptav.price_extra for ptav in order_line.product_no_variant_attribute_value_ids] + values = self.with_context(no_variant_attributes_price_extra=tuple(no_variant_attributes_price_extra))._website_product_id_change(self.id, product_id, qty=quantity) + order = self.sudo().browse(self.id) + if self.pricelist_id.discount_policy == 'with_discount' and not self.env.context.get('fixed_price'): + product_context.update({ + 'partner': order.partner_id, + 'quantity': quantity, + 'date': order.date_order, + 'pricelist': order.pricelist_id.id, + }) + product_with_context = self.env['product.product'].with_context(product_context).with_company(order.company_id.id) + product = product_with_context.browse(product_id) + values['price_unit'] = self.env['account.tax']._fix_tax_included_price_company( + order_line._get_display_price(product), + order_line.product_id.taxes_id, + order_line.tax_id, + self.company_id + ) + + order_line.write(values) + + # link a product to the sales order + if kwargs.get('linked_line_id'): + linked_line = SaleOrderLineSudo.browse(kwargs['linked_line_id']) + order_line.write({ + 'linked_line_id': linked_line.id, + }) + linked_product = product_with_context.browse(linked_line.product_id.id) + linked_line.name = linked_line.get_sale_order_line_multiline_description_sale(linked_product) + # Generate the description with everything. This is done after + # creating because the following related fields have to be set: + # - product_no_variant_attribute_value_ids + # - product_custom_attribute_value_ids + # - linked_line_id + order_line.name = order_line.get_sale_order_line_multiline_description_sale(product) + + option_lines = self.order_line.filtered(lambda l: l.linked_line_id.id == order_line.id) + + return {'line_id': order_line.id, 'quantity': quantity, 'option_ids': list(set(option_lines.ids))} + + def _cart_accessories(self): + """ Suggest accessories based on 'Accessory Products' of products in cart """ + for order in self: + products = order.website_order_line.mapped('product_id') + accessory_products = self.env['product.product'] + for line in order.website_order_line.filtered(lambda l: l.product_id): + combination = line.product_id.product_template_attribute_value_ids + line.product_no_variant_attribute_value_ids + accessory_products |= line.product_id.accessory_product_ids.filtered(lambda product: + product.website_published and + product not in products and + product._is_variant_possible(parent_combination=combination) and + (product.company_id == line.company_id or not product.company_id) + ) + + return random.sample(accessory_products, len(accessory_products)) + + def action_recovery_email_send(self): + for order in self: + order._portal_ensure_token() + composer_form_view_id = self.env.ref('mail.email_compose_message_wizard_form').id + + template_id = self._get_cart_recovery_template().id + + return { + 'type': 'ir.actions.act_window', + 'view_mode': 'form', + 'res_model': 'mail.compose.message', + 'view_id': composer_form_view_id, + 'target': 'new', + 'context': { + 'default_composition_mode': 'mass_mail' if len(self.ids) > 1 else 'comment', + 'default_res_id': self.ids[0], + 'default_model': 'sale.order', + 'default_use_template': bool(template_id), + 'default_template_id': template_id, + 'website_sale_send_recovery_email': True, + 'active_ids': self.ids, + }, + } + + def _get_cart_recovery_template(self): + """ + Return the cart recovery template record for a set of orders. + If they all belong to the same website, we return the website-specific template; + otherwise we return the default template. + If the default is not found, the empty ['mail.template'] is returned. + """ + websites = self.mapped('website_id') + template = websites.cart_recovery_mail_template_id if len(websites) == 1 else False + template = template or self.env.ref('website_sale.mail_template_sale_cart_recovery', raise_if_not_found=False) + return template or self.env['mail.template'] + + def _cart_recovery_email_send(self): + """Send the cart recovery email on the current recordset, + making sure that the portal token exists to avoid broken links, and marking the email as sent. + Similar method to action_recovery_email_send, made to be called in automated actions. + Contrary to the former, it will use the website-specific template for each order.""" + sent_orders = self.env['sale.order'] + for order in self: + template = order._get_cart_recovery_template() + if template: + order._portal_ensure_token() + template.send_mail(order.id) + sent_orders |= order + sent_orders.write({'cart_recovery_email_sent': True}) + + def action_confirm(self): + res = super(SaleOrder, self).action_confirm() + for order in self: + if not order.transaction_ids and not order.amount_total and self._context.get('send_email'): + order._send_order_confirmation_mail() + return res + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + name_short = fields.Char(compute="_compute_name_short") + + linked_line_id = fields.Many2one('sale.order.line', string='Linked Order Line', domain="[('order_id', '!=', order_id)]", ondelete='cascade') + option_line_ids = fields.One2many('sale.order.line', 'linked_line_id', string='Options Linked') + + def get_sale_order_line_multiline_description_sale(self, product): + description = super(SaleOrderLine, self).get_sale_order_line_multiline_description_sale(product) + if self.linked_line_id: + description += "\n" + _("Option for: %s", self.linked_line_id.product_id.display_name) + if self.option_line_ids: + description += "\n" + '\n'.join([_("Option: %s", option_line.product_id.display_name) for option_line in self.option_line_ids]) + return description + + @api.depends('product_id.display_name') + def _compute_name_short(self): + """ Compute a short name for this sale order line, to be used on the website where we don't have much space. + To keep it short, instead of using the first line of the description, we take the product name without the internal reference. + """ + for record in self: + record.name_short = record.product_id.with_context(display_default_code=False).display_name + + def get_description_following_lines(self): + return self.name.splitlines()[1:] |
