From a647a3323de53d94a402dbfcb80f644762fe5b59 Mon Sep 17 00:00:00 2001 From: Victor Feyens Date: Mon, 17 Jan 2022 16:11:28 +0000 Subject: [PATCH] [REF] website_sale*: cart lines creation & update Clean whole flow of cart updates (new lines, modified quantities, ...) * Use clean hooks to differentiate line update and creation, quantity verification, ... * Avoid write calls with unmodified values, useless recomputations, queries, ... * Create new lines directly with the correct quantity and values * Delegate fields computation logic to sale as much as possible (price, discount, name, ...) Part-of: odoo/odoo#82374 --- .../controllers/event_booth.py | 10 +- .../models/sale_order.py | 100 +++--- addons/website_event_sale/controllers/main.py | 32 +- .../website_event_sale/models/sale_order.py | 153 ++++---- .../static/tests/tours/website_event_sale.js | 11 +- addons/website_sale/models/sale_order.py | 337 +++++++++--------- .../tests/test_website_sale_pricelist.py | 4 +- ...ite_sale_product_attribute_value_config.py | 8 +- .../website_sale_stock/models/sale_order.py | 48 ++- 9 files changed, 367 insertions(+), 336 deletions(-) diff --git a/addons/website_event_booth_sale/controllers/event_booth.py b/addons/website_event_booth_sale/controllers/event_booth.py index 9c06f02e68c..3978aacb454 100644 --- a/addons/website_event_booth_sale/controllers/event_booth.py +++ b/addons/website_event_booth_sale/controllers/event_booth.py @@ -29,17 +29,17 @@ class WebsiteEventBoothController(WebsiteEventController): return json.dumps({'error': 'boothCategoryError'}) booth_values = self._prepare_booth_registration_values(event, kwargs) - order = request.website.sale_get_order(force_create=True) - order._cart_update( + order_sudo = request.website.sale_get_order(force_create=True) + order_sudo._cart_update( product_id=booth_category.product_id.id, set_qty=1, event_booth_pending_ids=booths.ids, registration_values=booth_values, ) - if order.amount_total: + if order_sudo.amount_total: return json.dumps({'redirect': '/shop/checkout'}) - elif order: - order.action_confirm() + elif order_sudo: + order_sudo.action_confirm() request.website.sale_reset() return self._prepare_booth_registration_success_values(event.name, booth_values) diff --git a/addons/website_event_booth_sale/models/sale_order.py b/addons/website_event_booth_sale/models/sale_order.py index 3324112df0d..2ce2c400686 100644 --- a/addons/website_event_booth_sale/models/sale_order.py +++ b/addons/website_event_booth_sale/models/sale_order.py @@ -8,51 +8,69 @@ from odoo import models, _ class SaleOrder(models.Model): _inherit = 'sale.order' - def _cart_find_product_line(self, product_id=None, line_id=None, **kwargs): + def _cart_find_product_line( + self, product_id=None, line_id=None, + event_booth_pending_ids=None, **kwargs + ): """Check if there is another sale order line which already contains the requested event_booth_pending_ids to overwrite it with the newly requested booths to avoid having multiple so_line related to the same booths""" - self.ensure_one() - lines = super(SaleOrder, self)._cart_find_product_line(product_id, line_id) - if line_id: + lines = super()._cart_find_product_line(product_id, line_id, **kwargs) + + if not event_booth_pending_ids or line_id: return lines - event_booth_pending_ids = kwargs.get('event_booth_pending_ids') - if event_booth_pending_ids: - lines = lines.filtered( - lambda line: any(booth.id in event_booth_pending_ids for booth in line.event_booth_pending_ids) - ) - return lines - def _website_product_id_change(self, order_id, product_id, qty=0, **kwargs): - values = super(SaleOrder, self)._website_product_id_change(order_id, product_id, qty=qty, **kwargs) - event_booth_pending_ids = kwargs.get('event_booth_pending_ids') - if event_booth_pending_ids: - order_line = self.env['sale.order.line'].sudo().search([ - ('id', 'in', self.order_line.ids), - ('event_booth_pending_ids', 'in', event_booth_pending_ids)]) - booths = self.env['event.booth'].browse(event_booth_pending_ids) - new_registration_ids = [Command.create({ - 'event_booth_id': booth.id, - **kwargs.get('registration_values'), - }) for booth in booths] - if order_line: - event_booth_registration_ids = [Command.delete(reg.id) - for reg in order_line.event_booth_registration_ids] + new_registration_ids - else: - event_booth_registration_ids = new_registration_ids + return lines.filtered( + lambda line: any(booth.id in event_booth_pending_ids for booth in line.event_booth_pending_ids) + ) - values.update( - event_id=booths.event_id.id, - event_booth_registration_ids=event_booth_registration_ids, - name=booths._get_booth_multiline_description, - ) - - return values - - def _cart_update(self, product_id=None, line_id=None, add_qty=0, set_qty=0, **kwargs): - values = {} + def _verify_updated_quantity(self, order_line, product_id, new_qty, **kwargs): + """Forbid quantity updates on event booth lines.""" product = self.env['product.product'].browse(product_id) - if product.detailed_type == 'event_booth' and set_qty > 1: - set_qty = 1 - values['warning'] = _('You cannot manually change the quantity of an Event Booth product.') - values.update(super(SaleOrder, self)._cart_update(product_id, line_id, add_qty, set_qty, **kwargs)) + if product.detailed_type == 'event_booth' and new_qty > 1: + return 1, _('You cannot manually change the quantity of an Event Booth product.') + return super()._verify_updated_quantity(order_line, product_id, new_qty, **kwargs) + + def _prepare_order_line_values( + self, product_id, quantity, event_booth_pending_ids=False, registration_values=None, + **kwargs + ): + """Add corresponding event to the SOline creation values (if booths are provided).""" + values = super()._prepare_order_line_values(product_id, quantity, **kwargs) + + if not event_booth_pending_ids: + return values + + booths = self.env['event.booth'].browse(event_booth_pending_ids) + + values['event_id'] = booths.event_id.id + values['event_booth_registration_ids'] = [ + Command.create({ + 'event_booth_id': booth.id, + **registration_values, + }) for booth in booths + ] + return values + + # FIXME VFE investigate if it ever happens. + # Probably not + def _prepare_order_line_update_values( + self, order_line, quantity, event_booth_pending_ids=False, registration_values=None, + **kwargs + ): + """Delete existing booth registrations and create new ones with the update values.""" + values = super()._prepare_order_line_update_values(order_line, quantity, **kwargs) + + if not event_booth_pending_ids: + return values + + booths = self.env['event.booth'].browse(event_booth_pending_ids) + values['event_booth_registration_ids'] = [ + Command.delete(registration.id) + for registration in order_line.event_booth_registration_ids + ] + [ + Command.create({ + 'event_booth_id': booth.id, + **registration_values, + }) for booth in booths + ] diff --git a/addons/website_event_sale/controllers/main.py b/addons/website_event_sale/controllers/main.py index f61810934ab..900b0b66c7b 100644 --- a/addons/website_event_sale/controllers/main.py +++ b/addons/website_event_sale/controllers/main.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from collections import defaultdict from odoo import _ from odoo.http import request, route @@ -27,15 +28,32 @@ class WebsiteEventSaleController(WebsiteEventController): def _create_attendees_from_registration_post(self, event, registration_data): # we have at least one registration linked to a ticket -> sale mode activate - if any(info.get('event_ticket_id') for info in registration_data): - order = request.website.sale_get_order(force_create=True) + if not any(info.get('event_ticket_id') for info in registration_data): + return super()._create_attendees_from_registration_post(event, registration_data) + order_sudo = request.website.sale_get_order(force_create=True) - for info in [r for r in registration_data if r.get('event_ticket_id')]: - ticket = request.env['event.event.ticket'].sudo().browse(info['event_ticket_id']) - cart_values = order.with_context(event_ticket_id=ticket.id, fixed_price=True)._cart_update(product_id=ticket.product_id.id, add_qty=1) - info['sale_order_id'] = order.id - info['sale_order_line_id'] = cart_values.get('line_id') + tickets_data = defaultdict(int) + for data in registration_data: + event_ticket_id = data.get('event_ticket_id') + if event_ticket_id: + tickets_data[event_ticket_id] += 1 + + cart_data = {} + for ticket_id, count in tickets_data.items(): + ticket_sudo = request.env['event.event.ticket'].sudo().browse(ticket_id) + cart_values = order_sudo._cart_update( + product_id=ticket_sudo.product_id.id, + add_qty=count, + event_ticket_id=ticket_id, + ) + cart_data[ticket_id] = cart_values['line_id'] + + for data in registration_data: + event_ticket_id = data.get('event_ticket_id') + if event_ticket_id: + data['sale_order_id'] = order_sudo.id + data['sale_order_line_id'] = cart_data[event_ticket_id] return super()._create_attendees_from_registration_post(event, registration_data) diff --git a/addons/website_event_sale/models/sale_order.py b/addons/website_event_sale/models/sale_order.py index 7978bfdff26..1acdda232cf 100644 --- a/addons/website_event_sale/models/sale_order.py +++ b/addons/website_event_sale/models/sale_order.py @@ -7,104 +7,91 @@ from odoo.exceptions import UserError class SaleOrder(models.Model): _inherit = "sale.order" - def _cart_find_product_line(self, product_id=None, line_id=None, **kwargs): - self.ensure_one() - lines = super(SaleOrder, self)._cart_find_product_line(product_id, line_id) - if line_id: + def _cart_find_product_line(self, product_id=None, line_id=None, event_ticket_id=False, **kwargs): + lines = super()._cart_find_product_line(product_id, line_id, **kwargs) + if line_id or not event_ticket_id: return lines - domain = [('id', 'in', lines.ids)] - if self.env.context.get("event_ticket_id"): - domain.append(('event_ticket_id', '=', self.env.context.get("event_ticket_id"))) - return self.env['sale.order.line'].sudo().search(domain) - def _website_product_id_change(self, order_id, product_id, qty=0, **kwargs): - order = self.env['sale.order'].sudo().browse(order_id) - if self._context.get('pricelist') != order.pricelist_id.id: - self = self.with_context(pricelist=order.pricelist_id.id) + return lines.filtered( + lambda line: line.event_ticket_id.id == event_ticket_id + ) - values = super(SaleOrder, self)._website_product_id_change(order_id, product_id, qty=qty, **kwargs) - event_ticket_id = None - if self.env.context.get("event_ticket_id"): - event_ticket_id = self.env.context.get("event_ticket_id") - else: - product = self.env['product.product'].browse(product_id) - if product.event_ticket_ids: - event_ticket_id = product.event_ticket_ids[0].id + def _verify_updated_quantity(self, order_line, product_id, new_qty, event_ticket_id=False, **kwargs): + """Restrict quantity updates for event tickets according to available seats.""" + new_qty, warning = super()._verify_updated_quantity(order_line, product_id, new_qty, **kwargs) - if event_ticket_id: - ticket = self.env['event.event.ticket'].browse(event_ticket_id) - if product_id != ticket.product_id.id: - raise UserError(_("The ticket doesn't match with this product.")) - - values['product_id'] = ticket.product_id.id - values['event_id'] = ticket.event_id.id - values['event_ticket_id'] = ticket.id - if order.pricelist_id.discount_policy == 'without_discount': - values['price_unit'] = ticket.price + if not event_ticket_id: + if not order_line.event_ticket_id or new_qty < order_line.product_uom_qty: + return new_qty, warning else: - values['price_unit'] = ticket.price_reduce - values['name'] = ticket._get_ticket_multiline_description() + return order_line.product_uom_qty, _("You cannot raise manually the event ticket quantity in your cart") - # avoid writing related values that end up locking the product record - values.pop('event_ok', None) + # Adding new ticket to the cart (might be automatically linked to an existing line) + ticket = self.env['event.event.ticket'].browse(event_ticket_id).exists() + if not ticket: + raise UserError(_("The provided ticket doesn't exist")) + + # TODO TDE consider full cart qty and not only added qty + # if event seats are not auto confirmed. + # Since created registrations are automatically reserved + # We should only consider new added qty and not full quantity + # when checking for seat availability + existing_qty = order_line.product_uom_qty if order_line else 0 + qty_added = new_qty - existing_qty + warning = '' + if ticket.seats_limited and ticket.seats_available <= 0: + # Remove existing line if exists and do not add a new one + # if no ticket is available anymore + new_qty = existing_qty + warning = _( + 'Sorry, The %(ticket)s tickets for the %(event)s event are sold out.', + ticket=ticket.name, + event=ticket.event_id.name, + ) + elif ticket.seats_limited and qty_added > ticket.seats_available: + new_qty = existing_qty + ticket.seats_available + warning = _( + 'Sorry, only %(remaining_seats)d seats are still available for the %(ticket)s ticket for the %(event)s event.', + remaining_seats=ticket.seats_available, + ticket=ticket.name, + event=ticket.event_id.name, + ) + + return new_qty, warning + + def _prepare_order_line_values(self, product_id, quantity, event_ticket_id=False, **kwargs): + """Add corresponding event to the SOline creation values (if ticket is provided).""" + values = super()._prepare_order_line_values(product_id, quantity, **kwargs) + + if not event_ticket_id: + return values + + ticket = self.env['event.event.ticket'].browse(event_ticket_id) + + if ticket.product_id.id != product_id: + raise UserError(_("The ticket doesn't match with this product.")) + + values['event_id'] = ticket.event_id.id + values['event_ticket_id'] = ticket.id return values - def _cart_update(self, product_id=None, line_id=None, add_qty=0, set_qty=0, **kwargs): - OrderLine = self.env['sale.order.line'] + def _update_cart_line_values(self, order_line, update_values): + """Remove event registrations on quantity decrease.""" + old_qty = order_line.product_uom_qty - try: - if add_qty: - add_qty = float(add_qty) - except ValueError: - add_qty = 1 - try: - if set_qty: - set_qty = float(set_qty) - except ValueError: - set_qty = 0 + super()._update_cart_line_values(order_line, update_values) + if not order_line.event_ticket_id: + return - if line_id: - line = OrderLine.browse(line_id) - ticket = line.event_ticket_id - old_qty = int(line.product_uom_qty) - if ticket.id: - self = self.with_context(event_ticket_id=ticket.id, fixed_price=1) - else: - line = None - ticket = self.env['event.event.ticket'].search([('product_id', '=', product_id)], limit=1) - old_qty = 0 - new_qty = set_qty if set_qty else (add_qty or 0 + old_qty) - - # case: buying tickets for a sold out ticket - values = {} - if ticket and ticket.seats_limited and ticket.seats_available <= 0: - values['warning'] = _('Sorry, The %(ticket)s tickets for the %(event)s event are sold out.') % { - 'ticket': ticket.name, - 'event': ticket.event_id.name} - new_qty, set_qty, add_qty = 0, 0, -old_qty - # case: buying tickets, too much attendees - elif ticket and ticket.seats_limited and new_qty > ticket.seats_available: - values['warning'] = _('Sorry, only %(remaining_seats)d seats are still available for the %(ticket)s ticket for the %(event)s event.') % { - 'remaining_seats': ticket.seats_available, - 'ticket': ticket.name, - 'event': ticket.event_id.name} - new_qty, set_qty, add_qty = ticket.seats_available, ticket.seats_available, 0 - values.update(super(SaleOrder, self)._cart_update(product_id, line_id, add_qty, set_qty, **kwargs)) - - # removing attendees - if ticket and new_qty < old_qty: + new_qty = order_line.product_uom_qty + if new_qty < old_qty: attendees = self.env['event.registration'].search([ ('state', '!=', 'cancel'), - ('sale_order_id', 'in', self.ids), # To avoid break on multi record set - ('event_ticket_id', '=', ticket.id), + ('sale_order_id', '=', self.id), + ('event_ticket_id', '=', order_line.event_ticket_id.id), ], offset=new_qty, limit=(old_qty - new_qty), order='create_date asc') attendees.action_cancel() - # adding attendees - elif ticket and new_qty > old_qty: - # do not do anything, attendees will be created at SO confirmation if not given previously - pass - return values class SaleOrderLine(models.Model): diff --git a/addons/website_event_sale/static/tests/tours/website_event_sale.js b/addons/website_event_sale/static/tests/tours/website_event_sale.js index ffd98358989..ec71be690ab 100644 --- a/addons/website_event_sale/static/tests/tours/website_event_sale.js +++ b/addons/website_event_sale/static/tests/tours/website_event_sale.js @@ -58,19 +58,14 @@ tour.register('event_buy_tickets', { content: "go to cart", trigger: 'a:contains(Return to Cart)', }, - { - content: "Modify the cart to add 1 unit of `VIP` ticket type", - extra_trigger: "#cart_products:contains(Standard):contains(VIP)", - trigger: "#cart_products tr:contains(VIP) .fa-plus", - }, { content: "Now click on `Process Checkout`", - extra_trigger: 'a:has(.my_cart_quantity):contains(4),#cart_products input.js_quantity[value="3"]', + extra_trigger: 'a:has(.my_cart_quantity):contains(3),#cart_products input.js_quantity[value="3"]', trigger: '.btn-primary:contains("Process Checkout")' }, { - content: "Check that the subtotal is 5,500.00 USD", // this test will fail if the currency of the main company is not USD - trigger: '#order_total_untaxed .oe_currency_value:contains("5,500.00")', + content: "Check that the subtotal is 4,000.00 USD", // this test will fail if the currency of the main company is not USD + trigger: '#order_total_untaxed .oe_currency_value:contains("4,000.00")', run: function () {}, // it's a check }, { diff --git a/addons/website_sale/models/sale_order.py b/addons/website_sale/models/sale_order.py index 6acc3e42f55..07bb7474fbe 100644 --- a/addons/website_sale/models/sale_order.py +++ b/addons/website_sale/models/sale_order.py @@ -1,16 +1,14 @@ # -*- 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 import api, fields, models, _ +from odoo.exceptions import UserError from odoo.http import request from odoo.osv import expression -from odoo.exceptions import UserError, ValidationError - -_logger = logging.getLogger(__name__) class SaleOrder(models.Model): @@ -80,78 +78,6 @@ class SaleOrder(models.Model): 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, **kwargs): - order = self.sudo().browse(order_id) - order = order.with_company(order.company_id) - product = order.env['product.product'].browse(product_id) - - discount = 0 - order_date = order.date_order or fields.Date.today() - qty = qty or 1.0 - - pricelist = order.pricelist_id - pricelist_price, pricelist_rule_id = pricelist._get_product_price_rule(product, qty, date=order_date) - # TODO VFE factorize discount computation or delegate discount computation to sale computes - if pricelist.discount_policy == 'without_discount': - # This part is pretty much a copy-paste of the method '_onchange_discount' of - # 'sale.order.line'. - pricelist_base_price, currency = request.env['sale.order.line']._get_real_price_currency( - product, pricelist_rule_id, qty, product.uom_id, date=order_date) - if pricelist_base_price != 0: - if order.currency_id != currency: - # we need new_list_price in the same currency as price, which is in the SO's pricelist's currency - pricelist_base_price = currency._convert( - pricelist_base_price, order.currency_id, order.company_id, order_date) - - price = pricelist_base_price - discount = (pricelist_base_price - pricelist_price) / pricelist_base_price * 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 - price = pricelist_price - else: - # In case the price_unit equal 0 and therefore not able to calculate the discount, - # we fallback on the price defined on the pricelist. - price = pricelist_price - else: - price = pricelist_price - - if order.pricelist_id and order.partner_id: - order_line = order._cart_find_product_line(product.id) - if order_line: - price = self.env['account.tax']._fix_tax_included_price_company( - price, product.taxes_id, order_line[0].tax_id, order.company_id) - - return { - 'product_id': product_id, - 'product_uom_qty': qty, - 'order_id': order_id, - 'price_unit': price, - 'discount': discount, - } def _cart_update_pricelist(self, pricelist_id=None, update_pricelist=False): self.ensure_one() @@ -166,122 +92,195 @@ class SaleOrder(models.Model): if update_pricelist or previous_pricelist_id != self.pricelist_id.id: self.update_prices() - def _cart_update(self, product_id=None, line_id=None, add_qty=0, set_qty=0, **kwargs): + def _cart_update(self, product_id, line_id=None, add_qty=0, set_qty=0, **kwargs): """ Add or set product quantity, add_qty can be negative """ self.ensure_one() - self = self.with_company(self.company_id) - SaleOrderLineSudo = self.env['sale.order.line'].sudo() - product = self.env['product.product'].browse(int(product_id)) + + 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.')) + + product = self.env['product.product'].browse(product_id).exists() + if not product: + raise UserError(_("The given product does not exist therefore it cannot be added to cart.")) + + if line_id is not False: + order_line = self._cart_find_product_line(product_id, line_id, **kwargs)[:1] + else: + order_line = self.env['sale.order.line'] 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, **kwargs) - - # 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) + if order_line: + quantity = order_line.product_uom_qty + (add_qty or 0) + else: + quantity = add_qty or 0 - # Remove zero of negative lines - if quantity <= 0: - linked_line = order_line.linked_line_id - order_line.unlink() + if quantity > 0: + quantity, warning = self._verify_updated_quantity( + order_line, + product_id, + quantity, + **kwargs, + ) 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, **kwargs) + # If the line will be removed anyway, there is no need to verify + # the requested quantity update. + warning = '' - order_line.write(values) + if order_line and quantity <= 0: + # Remove zero or negative lines + order_line.unlink() + order_line = self.env['sale.order.line'] + elif order_line: + # Update existing line + update_values = self._prepare_order_line_update_values(order_line, quantity, **kwargs) + if update_values: + self._update_cart_line_values(order_line, update_values) + elif quantity >= 0: + # Create new line + order_line_values = self._prepare_order_line_values(product_id, quantity, **kwargs) + order_line = self.env['sale.order.line'].sudo().create(order_line_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, - }) + return { + 'line_id': order_line.id, + 'quantity': quantity, + 'option_ids': list(set(order_line.option_line_ids.filtered(lambda l: l.order_id == order_line.order_id).ids)), + 'warning': warning, + } - option_lines = self.order_line.filtered(lambda l: l.linked_line_id.id == order_line.id) + def _cart_find_product_line(self, product_id, line_id=None, **kwargs): + """Find the cart line matching the given parameters. - return {'line_id': order_line.id, 'quantity': quantity, 'option_ids': list(set(option_lines.ids))} + 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() + SaleOrderLine = self.env['sale.order.line'] + + if not self.order_line: + return SaleOrderLine + + product = self.env['product.product'].browse(product_id) + if not line_id and ( + product.product_tmpl_id.has_dynamic_attributes() + or product.product_tmpl_id._has_no_variant_attributes() + ): + return SaleOrderLine + + 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 SaleOrderLine.search(domain) + + # hook to be overridden + def _verify_updated_quantity(self, order_line, product_id, new_qty, **kwargs): + return new_qty, '' + + def _prepare_order_line_values( + self, product_id, quantity, linked_line_id=False, + no_variant_attribute_values=None, product_custom_attribute_values=None, + **kwargs + ): + self.ensure_one() + product = self.env['product.product'].browse(product_id) + + no_variant_attribute_values = 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.")) + + values = { + 'product_id': product.id, + 'product_uom_qty': quantity, + 'order_id': self.id, + 'linked_line_id': linked_line_id, + } + + # 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, + }) + + if no_variant_attribute_values: + values['product_no_variant_attribute_value_ids'] = [ + fields.Command.set([int(attribute['value']) for attribute in no_variant_attribute_values]) + ] + + # add is_custom attribute values that were not received + custom_values = 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': '', + }) + + if custom_values: + values['product_custom_attribute_value_ids'] = [ + fields.Command.create({ + '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 + ] + + return values + + def _prepare_order_line_update_values( + self, order_line, quantity, linked_line_id=False, **kwargs + ): + self.ensure_one() + values = {} + + if quantity != order_line.product_uom_qty: + values['product_uom_qty'] = quantity + if linked_line_id and linked_line_id != order_line.linked_line_id.id: + values['linked_line_id'] = linked_line_id + + return values + + # hook to be overridden + def _update_cart_line_values(self, order_line, update_values): + self.ensure_one() + order_line.write(update_values) def _cart_accessories(self): """ Suggest accessories based on 'Accessory Products' of products in cart """ diff --git a/addons/website_sale/tests/test_website_sale_pricelist.py b/addons/website_sale/tests/test_website_sale_pricelist.py index 34d5eb271e1..391e882829c 100644 --- a/addons/website_sale/tests/test_website_sale_pricelist.py +++ b/addons/website_sale/tests/test_website_sale_pricelist.py @@ -274,10 +274,10 @@ class TestWebsitePriceList(TransactionCase): self.assertEqual(sol.price_total, 0) so.pricelist_id = website_pricelist with MockRequest(self.env, website=current_website, sale_order_id=so.id): - so._cart_update(product_id=product.id, line_id=sol.id, set_qty=5) + so._cart_update(product_id=product.id, line_id=sol.id, set_qty=6) self.assertEqual(sol.price_unit, 10.0, 'Pricelist price should be applied') self.assertEqual(sol.price_reduce, 10.0, 'Pricelist price should be applied') - self.assertEqual(sol.price_total, 50.0) + self.assertEqual(sol.price_total, 60.0) def simulate_frontend_context(self, website_id=1): diff --git a/addons/website_sale/tests/test_website_sale_product_attribute_value_config.py b/addons/website_sale/tests/test_website_sale_product_attribute_value_config.py index 6641009a836..4c9697f99eb 100644 --- a/addons/website_sale/tests/test_website_sale_product_attribute_value_config.py +++ b/addons/website_sale/tests/test_website_sale_product_attribute_value_config.py @@ -191,12 +191,12 @@ class TestWebsiteSaleProductPricelist(TestSaleProductAttributeValueCommon): sol = self.env['sale.order.line'].create({ 'product_id': test_product.product_variant_id.id, 'order_id': so.id, - 'tax_id': [(6, 0, [tax10.id])], }) - self.assertEqual(round(sol.price_total), 110.0, "110$ with 10% included tax") + self.assertEqual(round(sol.price_total), 55.0, "110$ with 50% discount 10% included tax") + self.assertEqual(round(sol.price_tax), 5.0, "110$ with 50% discount 10% included tax") so.pricelist_id = pricelist so.fiscal_position_id = fpos sol._compute_tax_id() with MockRequest(self.env, website=current_website, sale_order_id=so.id): - so._cart_update(product_id=test_product.product_variant_id.id, line_id=sol.id, set_qty=1) - self.assertEqual(round(sol.price_total), 50, "100$ with 50% discount + 0% tax (mapped from fp 10% -> 0%)") + so._cart_update(product_id=test_product.product_variant_id.id, line_id=sol.id, set_qty=2) + self.assertEqual(round(sol.price_total), 100, "100$ with 50% discount + 0% tax (mapped from fp 10% -> 0%)") diff --git a/addons/website_sale_stock/models/sale_order.py b/addons/website_sale_stock/models/sale_order.py index 115f28b00fd..aabf1c59efd 100644 --- a/addons/website_sale_stock/models/sale_order.py +++ b/addons/website_sale_stock/models/sale_order.py @@ -9,26 +9,40 @@ class SaleOrder(models.Model): warning_stock = fields.Char('Warning') - def _cart_update(self, product_id=None, line_id=None, add_qty=0, set_qty=0, **kwargs): - values = super(SaleOrder, self)._cart_update(product_id, line_id, add_qty, set_qty, **kwargs) - line_id = values.get('line_id') + def _verify_updated_quantity(self, order_line, product_id, new_qty, **kwargs): + self.ensure_one() + product = self.env['product.product'].browse(product_id) + if product.type == 'product' and not product.allow_out_of_stock_order: + available_qty = product.with_context(warehouse=self.warehouse_id.id).free_qty + product_qty_in_cart = sum( + self.order_line.filtered( + lambda p: p.product_id.id == product_id + ).mapped('product_uom_qty') + ) - for line in self.order_line: - if line.product_id.type == 'product' and not line.product_id.allow_out_of_stock_order: - cart_qty = sum(self.order_line.filtered(lambda p: p.product_id.id == line.product_id.id).mapped('product_uom_qty')) - if cart_qty > line.product_id.with_context(warehouse=self.warehouse_id.id).free_qty and (line_id == line.id): - qty = line.product_id.with_context(warehouse=self.warehouse_id.id).free_qty - cart_qty - new_val = super(SaleOrder, self)._cart_update(line.product_id.id, line.id, qty, 0, **kwargs) - values.update(new_val) + old_qty = order_line.product_uom_qty if order_line else 0 + added_qty = new_qty - old_qty - # Make sure line still exists, it may have been deleted in super()_cartupdate because qty can be <= 0 - if line.exists() and new_val['quantity']: - line.warning_stock = _('You ask for %s products but only %s is available') % (cart_qty, new_val['quantity']) - values['warning'] = line.warning_stock + if available_qty < (product_qty_in_cart + added_qty): + allowed_line_qty = available_qty - (product_qty_in_cart - old_qty) + if allowed_line_qty > 0: + warning = _( + 'You ask for %s products but only %s is available', + product_qty_in_cart + added_qty, + available_qty, + ) + if order_line: + order_line.warning_stock = warning else: - self.warning_stock = _("Some products became unavailable and your cart has been updated. We're sorry for the inconvenience.") - values['warning'] = self.warning_stock - return values + self.warning_stock = warning + else: # 0 or negative allowed_qty + # if existing line: it will be deleted + # if no existing line: no line will be created + self.warning_stock = _( + "Some products became unavailable and your cart has been updated. We're sorry for the inconvenience.") + return allowed_line_qty, order_line.warning_stock or self.warning_stock + + return super()._verify_updated_quantity(order_line, product_id, new_qty, **kwargs) def _get_stock_warning(self, clear=True): self.ensure_one()