summaryrefslogtreecommitdiff
path: root/addons/website_sale/models/sale_order.py
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/website_sale/models/sale_order.py
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/website_sale/models/sale_order.py')
-rw-r--r--addons/website_sale/models/sale_order.py385
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:]