From b92d9b505f2c976b1ab488d714dca01bd0d2a4f5 Mon Sep 17 00:00:00 2001 From: "Laurent Mignonn (ACSONE)" Date: Mon, 25 Nov 2024 13:19:02 +0100 Subject: [PATCH 01/17] [DO NOT MERGE] tests dependency --- test-requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test-requirements.txt b/test-requirements.txt index 66bc2cbae3f..bb3956d56b2 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1 +1,3 @@ odoo_test_helper +odoo-addon-sale-stock-prebook @ git+https://github.com/OCA/sale-workflow.git@refs/pull/3423/head#subdirectory=setup/sale_stock_prebook +odoo-addon-sale-stock-prebook-stock-available-to_promise-elease @ git+https://github.com/OCA/sale-workflow.git@refs/pull/3424/head#subdirectory=setup/sale_stock_prebook_stock_available_to_promise_release From 141b981b61a7151204a416d8bf84fcd72f7df8dd Mon Sep 17 00:00:00 2001 From: "Laurent Mignonn (ACSONE)" Date: Tue, 26 Nov 2024 17:50:12 +0100 Subject: [PATCH 02/17] wip --- sale_framework/__init__.py | 2 + sale_framework/__manifest__.py | 20 ++ sale_framework/hooks.py | 12 + sale_framework/models/__init__.py | 3 + sale_framework/models/sale_order.py | 226 ++++++++++++++ sale_framework/models/sale_order_line.py | 275 ++++++++++++++++++ sale_framework/models/stock_move.py | 19 ++ sale_framework/readme/CONTEXT.md | 16 + sale_framework/readme/CONTRIBUTORS.md | 2 + sale_framework/readme/CREDITS.md | 3 + sale_framework/readme/DESCRIPTION.md | 55 ++++ sale_framework/static/description/icon.png | Bin 0 -> 9455 bytes sale_framework/views/sale_order.xml | 30 ++ .../sale_framework/odoo/addons/sale_framework | 1 + setup/sale_framework/setup.py | 6 + test-requirements.txt | 1 - 16 files changed, 670 insertions(+), 1 deletion(-) create mode 100644 sale_framework/__init__.py create mode 100644 sale_framework/__manifest__.py create mode 100644 sale_framework/hooks.py create mode 100644 sale_framework/models/__init__.py create mode 100644 sale_framework/models/sale_order.py create mode 100644 sale_framework/models/sale_order_line.py create mode 100644 sale_framework/models/stock_move.py create mode 100644 sale_framework/readme/CONTEXT.md create mode 100644 sale_framework/readme/CONTRIBUTORS.md create mode 100644 sale_framework/readme/CREDITS.md create mode 100644 sale_framework/readme/DESCRIPTION.md create mode 100644 sale_framework/static/description/icon.png create mode 100644 sale_framework/views/sale_order.xml create mode 120000 setup/sale_framework/odoo/addons/sale_framework create mode 100644 setup/sale_framework/setup.py diff --git a/sale_framework/__init__.py b/sale_framework/__init__.py new file mode 100644 index 00000000000..6d58305f5dd --- /dev/null +++ b/sale_framework/__init__.py @@ -0,0 +1,2 @@ +from . import models +from .hooks import pre_init_hook diff --git a/sale_framework/__manifest__.py b/sale_framework/__manifest__.py new file mode 100644 index 00000000000..dbf23e1bb82 --- /dev/null +++ b/sale_framework/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Sale Framework", + "summary": """Manage blanket order and call of ordr""", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "ACSONE SA/NV,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/sale-workflow", + "depends": [ + "sale_manual_delivery", + "sale_stock_prebook", + ], + "data": [ + #'views/sale_order.xml', + ], + "demo": [], + "pre_init_hook": "pre_init_hook", +} diff --git a/sale_framework/hooks.py b/sale_framework/hooks.py new file mode 100644 index 00000000000..364822c445d --- /dev/null +++ b/sale_framework/hooks.py @@ -0,0 +1,12 @@ +import logging + +_logger = logging.getLogger(__name__) + + +def pre_init_hook(cr): + _logger.info("Create column order_type in sale_order wiht default value 'normal'") + cr.execute( + "ALTER TABLE sale_order ADD COLUMN order_type varchar(255) DEFAULT 'normal'" + ) + # drop the default value since it was only used to fill the column in existing records + cr.execute("ALTER TABLE sale_order ALTER COLUMN order_type DROP DEFAULT") diff --git a/sale_framework/models/__init__.py b/sale_framework/models/__init__.py new file mode 100644 index 00000000000..227d28fc098 --- /dev/null +++ b/sale_framework/models/__init__.py @@ -0,0 +1,3 @@ +from . import sale_order +from . import sale_order_line +from . import stock_move diff --git a/sale_framework/models/sale_order.py b/sale_framework/models/sale_order.py new file mode 100644 index 00000000000..484cfc2f598 --- /dev/null +++ b/sale_framework/models/sale_order.py @@ -0,0 +1,226 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +from odoo.addons.sale.models.sale_order import ( + LOCKED_FIELD_STATES, + READONLY_FIELD_STATES, +) + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + order_type = fields.Selection( + [ + ("normal", "Normal Sale Order"), + ("blanket", "Blanket Order"), + ("call_off", "Call-off Order"), + ], + default="normal", + required=True, + help="Specifies the type of sale order: Normal, Blanket, or Call-off.", + states=READONLY_FIELD_STATES, + ) + blanket_order_id = fields.Many2one( + "sale.order", + string="Blanket Order", + readonly=True, + help="The blanket order that this call-off order is related to.", + index=True, + ) + call_off_order_ids = fields.One2many( + "sale.order", + "blanket_order_id", + string="Call-off Orders", + help="The call-off orders related to this blanket order.", + ) + call_off_order_count = fields.Integer( + compute="_compute_call_off_order_count", + string="Call-off Order Count", + help="The number of call-off orders related to this blanket order.", + ) + blanked_validity_start_date = fields.Date( + string="Validity Start Date", + help="The start date of the validity period for the blanket order.", + ) + blanket_validity_end_date = fields.Date( + string="Validity End Date", + help="The end date of the validity period for the blanket order.", + ) + blanket_reservation_strategy = fields.Selection( + [ + ("at_confirm", "At Order Confirmation"), + ("at_call_off", "At Call-off"), + ], + string="Reservation Strategy", + compute="_compute_blanket_reservation_strategy", + readonly=False, + states=READONLY_FIELD_STATES, + help="Specifies when the stock should be reserved for the blanket order. " + " When the strategy is 'At Order Confirmation', the stock is reserved " + "when the blanket order is confirmed. When the strategy is 'At Call-off', " + "the stock is reserved when the call-off order is confirmed.", + ) + blanket_eol_strategy = fields.Selection( + [ + ("deliver", "Deliver Remaining Quantity"), + ], + states=LOCKED_FIELD_STATES, + help="Specifies the end-of-life strategy for the blanket order. At the end " + "of the validity period, in any case if a reserved quantity remains, the " + "system will release the reservation. If the strategy is 'Deliver " + "Remaining Quantity', the system will automaticaly create a delivery order " + "for the remaining quantity.", + ) + + @api.constrains("order_type", "blanket_order_id") + def _check_order_type(self): + for order in self: + if order.order_type == "blanket" and order.blanket_order_id: + raise ValidationError(_("A blanket order cannot have a blanket order.")) + if ( + order.order_type == "call_off" + and order.blanket_order_id.order_type != "blanket" + ): + raise ValidationError(_("A call-off order must have a blanket order.")) + if order.order_type == "normal" and order.blanket_order_id: + raise ValidationError(_("A normal order cannot have a blanket order.")) + + @api.constrains( + "order_type", "blanket_validity_start_date", "blanket_validity_end_date" + ) + def _check_validity_dates(self): + for order in self: + if order.order_type == "blanket": + if not order.blanket_validity_start_date: + raise ValidationError( + _("The validity start date is required for a blanket order.") + ) + if not order.blanket_validity_end_date: + raise ValidationError( + _("The validity end date is required for a blanket order.") + ) + if order.blanket_validity_end_date < order.blanket_validity_start_date: + raise ValidationError( + _( + "The validity end date must be greater than the validity start date." + ) + ) + + @api.depends("order_type", "state", "blanket_reservation_strategy") + def _compute_blanket_reservation_strategy(self): + for order in self: + if order.state != "draft": + continue + if order.order_type == "blanket" and not order.blanket_reservation_strategy: + order.blanket_reservation_strategy = "at_confirm" + if order.order_type != "blanket" and order.blanket_reservation_strategy: + order.blanket_reservation_strategy = False + + @api.depends("call_off_order_ids") + def _compute_call_off_order_count(self): + if not any(self.call_off_order_ids._ids): + for order in self: + order.call_off_order_count = len(order.call_off_order_ids) + else: + count_by_blanket_order_id = { + group["blanket_order_id"][0]: group["blanket_order_id_count"] + for group in self.env["sale.order"].read_group( + domain=[("blanket_order_id", "in", self._ids)], + fields=["blanket_order_id:count"], + groupby=["blanket_order_id"], + orderby="blanket_order_id.id", + ) + } + for order in self: + order.call_off_order_count = count_by_blanket_order_id.get(order.id, 0) + + def _action_confirm(self): + # The confirmation process is different for each type of order + # so we need to split the orders by type before processing them + # normally. + blanket_orders = self.browse() + call_off_orders = self.browse() + normal_orders = self.browse() + for order in self: + if order.order_type == "blanket": + blanket_orders |= order + elif order.order_type == "call_off": + call_off_orders |= order + blanket_orders._on_blanket_order_confirm() + call_off_orders |= normal_orders._split_for_blanket_order() + call_off_orders._on_call_off_order_confirm() + return super()._action_confirm() + + def _on_blanked_order_confirm(self): + """This method is called when a blanket order is confirmed. + + It's responsible to implement the specific behavior of a blanket order. + By default, it will call the method responsible of the reservation + strategy implementation and set the commitment date at the start of the + validity period. It can be overriden to implement additional behavior. + """ + invalid_orders = self.filtered(lambda order: order.order_type != "blanket") + if invalid_orders: + raise ValidationError( + _("Only blanket orders can be confirmed as blanket orders.") + ) + for order in self: + order.commitment_date = order.blanked_validity_start_date + self._blanket_order_reserve_stock() + + def _on_call_off_order_confirm(self): + """This method is called when a call-off order is confirmed. + + It's responsible to implement the specific behavior of a call-off order. + By default, it will call the method responsible of the reservation + strategy implementation. It can be overriden to implement additional + behavior. + """ + invalid_orders = self.filtered(lambda order: order.order_type != "call_off") + if invalid_orders: + raise ValidationError( + _("Only call-off orders can be confirmed as call-off orders.") + ) + self._call_off_order_reserve_stock() + + def _blanket_order_reserve_stock(self): + """Reserve the stock for the blanket order.""" + to_reserve_at_confirm = self.browse() + to_reserve_at_call_off = self.browse() + for order in self: + if order.blanket_reservation_strategy == "at_confirm": + to_reserve_at_confirm |= order + elif order.blanket_reservation_strategy == "at_call_off": + to_reserve_at_call_off |= order + else: + raise ValidationError( + _( + "Invalid reservation strategy for the blanket order %(ref)s.", + ref=order.name, + ) + ) + to_reserve_at_confirm._prebook_stock() + to_reserve_at_call_off._delay_delivery() + + def _prebook_stock(self): + """Prebook the stock for the order.""" + self = self.with_context(sale_stock_prebook_stop_proc_run=True) + procurements = [] + for order in self: + group = order._create_reserve_procurement_group() + procurements += order.order_line._prepare_reserve_procurements(group) + if procurements: + self.env["procurement.group"].run(procurements) + + def _delay_delivery(self): + """Delay the delivery of the order. + + By setting the manual delivery flag to True, the delivery will not be + created at confirmation time. The delivery process will be triggered by + the system when a call-off order is confirmed. + """ + self.manual_delivery = True diff --git a/sale_framework/models/sale_order_line.py b/sale_framework/models/sale_order_line.py new file mode 100644 index 00000000000..80fbb2f2184 --- /dev/null +++ b/sale_framework/models/sale_order_line.py @@ -0,0 +1,275 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from collections import defaultdict + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools import float_compare, float_is_zero + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + call_off_move_ids = fields.One2many( + "stock.move", + "call_of_sale_line_id", + string="Stock Moves", + ) + + @api.constrains( + "order_id.blanket_order_id", + "order_id.order_type", + "product_id", + "product_uom_qty", + "display_type", + ) + def _check_call_off_order_line(self): + """Check the constraints for call-off order lines. + + The constraints are: + - The product must be part of the linked blanket order. + - The quantity to procure must be less than or equal to the quantity + remaining to deliver in the linked blanket order for this product. + - The blanket order must still be valid (not expired), not canceled or draft. + """ + lines_by_product_and_so = defaultdict(lambda: self.env["sale.order.line"]) + for line in self: + if line.order_id.order_type != "call_off": + continue + if line.display_type: + continue + lines_by_product_and_so[(line.product_id, line.order_id)] |= line + for (product, order), lines in lines_by_product_and_so.items(): + blanked_order = order.blanket_order_id + if blanked_order.state in ("draft", "cancel"): + raise ValidationError( + _( + "The linked blanket order is not valid. " + "Only confirmed orders are allowed." + "(Order: '%(order)s', Blanket Order: '%(blanket_order)s')", + order=order.name, + blanket_order=blanked_order.name, + ) + ) + if blanked_order.blanket_validity_end_date < order.date_order: + raise ValidationError( + _( + "The linked blanket order is expired at order " + "date. (Order: '%(order)s', Blanket Order: '%(blanket_order)s')", + order=order.name, + blanket_order=blanked_order.name, + ) + ) + blanked_order_lines = blanked_order.order_line.filtered( + lambda x: x.product_id == product + ) + if blanked_order_lines: + raise ValidationError( + _( + "The product is not part of linked blanket order." + "(Product: '%(product)s', Order: " + "'%(order)s', Blanket Order: '%(blanket_order)s')", + product=product.display_name, + order=order.name, + blanket_order=blanked_order.name, + ), + ) + qty_remaining_to_procure = sum(blanked_order_lines.mapped("qty_to_procure")) + qty_to_procure = sum(lines.mapped("product_uom_qty")) + if ( + float_compare( + qty_to_procure, + qty_remaining_to_procure, + precision_rounding=lines[0].product_uom.rounding, + ) + > 0 + ): + raise ValidationError( + _( + "The quantity to procure is greater than the quantity " + "remaining to deliver in the linked blanket order for " + "this product. (Product: '%(product)s', Order: " + "'%(order)s', Blanket Order: '%(blanket_order)s')", + product=product.display_name, + order=order.name, + blanket_order=blanked_order.name, + ) + ) + + @api.contrains("order_id.order_type", "price_unit") + def _check_call_off_order_line_price(self): + price_precision = self.env["decimal.precision"].precision_get("Product Price") + for line in self: + if line.order_id.order_type == "call_off" and not float_is_zero( + line.price_unit, precision_digits=price_precision + ): + raise ValidationError( + _( + "The price of a call-off order line must be 0.0. " + "(Order: '%(order)s', Product: '%(product)s')", + order=line.order_id.name, + product=line.product_id.display_name, + ) + ) + + def _prepare_reserve_procurement_values(self, group_id=None): + if self.order_id.order_type == "blanket": + return self._prepare_reserve_procurement_values_blanket(group_id) + else: + return super()._prepare_reserve_procurement_values(group_id) + + def _prepare_reserve_procurement_values_blanket(self, group_id=None): + """Prepare the values for the procurement to reserve the stock for a + blanket order line. + + In the case of a blanket order, the procurement date_planned and date_deadline + should be set to the validity start date of the blanket order. This is because + the stock should be reserved for the blanket order at the start of the validity + period, not at the time of the call-off order. + """ + values = super()._prepare_reserve_procurement_values(group_id) + values["date_planned"] = self.order_id.blanked_validity_start_date + values["date_deadline"] = self.order_id.blanked_validity_start_date + return values + + def _get_display_price(self): + if self.order_id.order_type == "call_off": + return 0.0 + return super()._get_display_price() + + def _prepare_procurement_values(self, group_id=False): + res = super()._prepare_procurement_values(group_id=group_id) + call_of_sale_line_id = self.env.context.get("call_of_sale_line_id") + res["call_of_sale_line_id"] = call_of_sale_line_id + return res + + def _compute_qty_at_date(self): + # Overload to consider the call-off order lines in the computation + # For these lines we take the values computed on the corresponding + # blanket order line + call_off_lines = self.filtered(lambda l: l.order_id.order_type == "call_off") + other_lines = self - call_off_lines + res = other_lines._compute_qty_at_date() + for line in call_off_lines: + blanket_line = fields.first( + line.order_id.blanket_order_id.order_line.filtered( + lambda l: l.product_id == line.product_id + ) + ) + line.virtual_available_at_date = blanket_line.virtual_available_at_date + line.scheduled_date = blanket_line.scheduled_date + line.forecast_expected_date = blanket_line.forecast_expected_date + line.free_qty_today = blanket_line.free_qty_today + line.qty_available_today = blanket_line.qty_available_today + return res + + def _compute_qty_to_deliver(self): + # Overload to consider the call-off order lines in the computation + # For these lines the qty to deliver is the same as the product_uom_qty + # while the order is not confirmed or done. Otherwise it is 0 as the + # delivery is done on the blanket order line. + call_off_lines = self.filtered(lambda l: l.order_id.order_type == "call_off") + other_lines = self - call_off_lines + res = other_lines._compute_qty_to_deliver() + for line in call_off_lines: + if line.state in ("sale", "done", "cancel"): + line.display_qty_widget = False + line.qty_to_deliver = 0.0 + else: + line.display_qty_widget = True + line.qty_to_deliver = self.product_uom_qty + return res + + def _compute_qty_delivered(self): + # Overload to consider the call-off order lines in the computation + # For these lines the qty delivered is always 0 as the delivery is + # done on the blanket order line. + call_off_lines = self.filtered(lambda l: l.order_id.order_type == "call_off") + other_lines = self - call_off_lines + res = other_lines._compute_qty_delivered() + for line in call_off_lines: + line.qty_delivered = 0 + return res + + def _compute_qty_to_invoice(self): + # Overload to consider the call-off order lines in the computation + # For these lines the qty to invoice is always 0 as the invoicing is + # done on the blanket order line. + call_off_lines = self.filtered(lambda l: l.order_id.order_type == "call_off") + other_lines = self - call_off_lines + res = other_lines._compute_qty_to_invoice() + for line in call_off_lines: + line.qty_to_invoice = 0 + return res + + def _action_launch_stock_rule(self, previous_product_uom_qty=False): + # Overload to consider the call-off order lines in the computation + # The launch of the stock rule is done on the blanket order lines. + # In case of multiple lines for the same product, we must ensure that + # the stock rule is launched on a single blanket order line for the + # quantity still to deliver on this line. + # We must also take care of the reservation strategy of the blanket order. + call_off_lines = self.filtered(lambda l: l.order_id.order_type == "call_off") + other_lines = self - call_off_lines + res = other_lines._action_launch_stock_rule(previous_product_uom_qty) + call_off_lines._launch_stock_rule_on_blanket_order(previous_product_uom_qty) + return res + + def _launch_stock_rule_on_blanket_order(self, previous_product_uom_qty): + for line in self: + line = line.with_context(call_of_sale_line_id=line.id) + blanket_order = line.order_id.blanket_order_id + if not blanket_order: + raise ValueError("A call-off order must have a blanket order.") + if blanket_order.blanket_reservation_strategy == "at_confirm": + self._stock_rule_on_blanket_with_reservation_at_confirm() + elif blanket_order.blanket_reservation_strategy == "at_call_off": + continue + else: + raise ValueError("Invalid blanket reservation strategy.") + + def _stock_rule_on_blanket_with_reservation_at_confirm( + self, previous_product_uom_qty + ): + """In case of a blanket order with reservation at confirm, we use the manual delivery + wizard to launch the stock rule on the blanket order lines. + """ + self.ensure_one() + qty_to_deliver = self.product_uom_qty + blanket_lines = self.order_id.blanket_order_id.order_line.filtered( + lambda l: l.product_id == self.product_id + ) + for blanket_line in blanket_lines: + if ( + float_compare( + qty_to_deliver, + blanket_line.qty_to_procure, + precision_rounding=blanket_line.product_uom.rounding, + ) + <= 0 + ): + wizard = ( + self.env["sale.manual.delivery"] + .with_context( + active_id=blanket_line.id, + active_model="sale.order.line", + active_ids=blanket_line.ids, + ) + .create() + ) + wizard.line_ids.qty_to_procure = qty_to_deliver + wizard.confirm() + break + else: + wizard = ( + self.env["sale.manual.delivery"] + .with_context( + active_id=blanket_line.id, + active_model="sale.order.line", + active_ids=blanket_line.ids, + ) + .create() + ) + wizard.line_ids.qty_to_procure = blanket_line.qty_to_procure + wizard.confirm() + qty_to_deliver -= blanket_line.qty_to_procure diff --git a/sale_framework/models/stock_move.py b/sale_framework/models/stock_move.py new file mode 100644 index 00000000000..511ec7aa8ec --- /dev/null +++ b/sale_framework/models/stock_move.py @@ -0,0 +1,19 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class StockMove(models.Model): + + _inherit = "stock.move" + + call_of_sale_line_id = fields.Many2one( + "sale.order.line", "Call Off Sale Line", index="btree_not_null" + ) + + @api.model + def _prepare_merge_moves_distinct_fields(self): + distinct_fields = super()._prepare_merge_moves_distinct_fields() + distinct_fields.append("call_of_sale_line_id") + return distinct_fields diff --git a/sale_framework/readme/CONTEXT.md b/sale_framework/readme/CONTEXT.md new file mode 100644 index 00000000000..8729a87a442 --- /dev/null +++ b/sale_framework/readme/CONTEXT.md @@ -0,0 +1,16 @@ +When a company sells the same products to the same customers on a regular basis, it's a common business practice to create a sale framework that defines the terms and conditions of the sales. + +If you need a way to define: +* the terms and conditions of the sales, +* the payment terms, +* the delivery terms, + +and also secure the quantities of the products to be delivered, the sale framework module is the right choice. + +This module introduces 2 new kinkds of sale orders: + +1. Sale Blanket Order: This is a sale order that defines the terms and conditions of the sales, the payment terms, the delivery terms, and secures the quantities of the products to be delivered. It is used to create sale orders that will be delivered in the future. + +2. Call of order: This is a sale order that is created to consume the quantities of the products secured in the sale blanket order. + +Others modules can be used to provide the same kind of features. For example, the module (sale_blanket_order)[https://pypi.org/project/odoo-addon-sale-blanket-order] also defines the concept of sale blanket order. The main difference between the two modules is that the sale framework module extends the sale order model to add the sale blanket order and the call of order. This allows to keep the benefits of all the extensions made on the sale order model by other modules without having to adapt them to the sale blanket order model (discount, invoicing; inventory process, ...). diff --git a/sale_framework/readme/CONTRIBUTORS.md b/sale_framework/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..ede6aa0ae20 --- /dev/null +++ b/sale_framework/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Laurent Mignon (https://www.acsone.eu) +- Jacques-Etienne Baudoux (BCIM) diff --git a/sale_framework/readme/CREDITS.md b/sale_framework/readme/CREDITS.md new file mode 100644 index 00000000000..e7c5a535490 --- /dev/null +++ b/sale_framework/readme/CREDITS.md @@ -0,0 +1,3 @@ +The development of this module has been financially supported by: + +- ALCYON Belux diff --git a/sale_framework/readme/DESCRIPTION.md b/sale_framework/readme/DESCRIPTION.md new file mode 100644 index 00000000000..327caee073b --- /dev/null +++ b/sale_framework/readme/DESCRIPTION.md @@ -0,0 +1,55 @@ +This module extends the functionality of Sale Order to support Blanket Order and Call of Order. + +# Blanket Order + +A Blanket Order is a standard sales order with the following specific features: + +* Type: Classified as "Blanket Order". +* Defined Duration: Includes a validity period (end date). +* Payment Terms: Allows selection of preferred terms (e.g., 90 days end of month, upon delivery, etc.). +* Invoicing Policy: Can be based on product settings or the order itself. +* Stock Reservation: Allows advance reservation of sold quantities. +* Handling Unfulfilled Quantities: Provides options for dealing with undelivered quantities upon order expiration. +* Prices are calculated based on existing rules since it is a standard sales order type. + +The blanket order serves as the central element triggering stock management and invoicing mechanisms. + +## Stock Management +Delivered quantities are tracked on the sales order lines as with regular sales orders. +The goods are not delivered upon confirmation of the blanket order, but stock can be reserved for the customer using OCA modules: + +- **sale_manual_delivery:** Marks sales orders as "manual delivery," preventing stock reservation or delivery preparation. +- **sale_stock_prebook:** Marks sales orders as "to reserve," triggering stock reservation but blocking delivery preparation for such cases. + +## Invoicing + +Standard invoicing policies apply (e.g., invoice on order or on delivery). Payment terms are configurable per order. Prepayment can be enforced by configuring the invoicing policy at the order level using the OCA module (sale_invoice_policy)[https://pypi.org/project/odoo-addon-sale-invoice-policy/]. + +## Consumption Management + +A wizard will be available on the blanket order to initiate a delivery. It allows users to select products and quantities for delivery. This action creates a Call-off Order linked to the blanket order. + +# Call-off Order + +A Call-off Order is a standard sales order with these specific characteristics: + +* Type: Classified as "Call-off Order". +* Linked to Blanket Order: Only includes products from the blanket order. +* Delivery Release: Enables the release of reserved stock for delivery. +* No Invoicing or Stock Management: These are handled via the linked blanket order. + +## Stock Management + +No delivery is generated directly from the call-off order. + +It triggers: +* Release of the reserved quantity in the blanket order. +* Adjustment of stock reservations for the remaining quantities. + + +# Standard Sales Orders + +To support existing workflows (e.g., e-commerce), call-off orders can be generated transparently from standard sales orders based on product and availability: + +Entire orders may be converted into call-off orders if all products are linked to a blanket order. +Mixed orders split call-off items into a new call-off order, with both confirmed within the available quantities of the blanket order. diff --git a/sale_framework/static/description/icon.png b/sale_framework/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 diff --git a/sale_framework/views/sale_order.xml b/sale_framework/views/sale_order.xml new file mode 100644 index 00000000000..afc2c1def64 --- /dev/null +++ b/sale_framework/views/sale_order.xml @@ -0,0 +1,30 @@ + + + + + + sale.order + + + + + + + + sale.order + + + + + + + + sale.order + + + + + + + diff --git a/setup/sale_framework/odoo/addons/sale_framework b/setup/sale_framework/odoo/addons/sale_framework new file mode 120000 index 00000000000..56bd52e379f --- /dev/null +++ b/setup/sale_framework/odoo/addons/sale_framework @@ -0,0 +1 @@ +../../../../sale_framework \ No newline at end of file diff --git a/setup/sale_framework/setup.py b/setup/sale_framework/setup.py new file mode 100644 index 00000000000..28c57bb6403 --- /dev/null +++ b/setup/sale_framework/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/test-requirements.txt b/test-requirements.txt index bb3956d56b2..906db9a6694 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,3 +1,2 @@ odoo_test_helper odoo-addon-sale-stock-prebook @ git+https://github.com/OCA/sale-workflow.git@refs/pull/3423/head#subdirectory=setup/sale_stock_prebook -odoo-addon-sale-stock-prebook-stock-available-to_promise-elease @ git+https://github.com/OCA/sale-workflow.git@refs/pull/3424/head#subdirectory=setup/sale_stock_prebook_stock_available_to_promise_release From 9c9f61b49b21bfd0bb0b2489754bfb3a611b821b Mon Sep 17 00:00:00 2001 From: "Laurent Mignonn (ACSONE)" Date: Wed, 27 Nov 2024 13:10:14 +0100 Subject: [PATCH 03/17] wip --- sale_framework/README.rst | 206 +++++++ sale_framework/models/sale_order.py | 6 +- sale_framework/models/sale_order_line.py | 157 +++++- sale_framework/static/description/index.html | 532 ++++++++++++++++++ sale_framework/tests/__init__.py | 1 + sale_framework/tests/common.py | 131 +++++ .../tests/test_sale_blanket_order.py | 95 ++++ 7 files changed, 1106 insertions(+), 22 deletions(-) create mode 100644 sale_framework/README.rst create mode 100644 sale_framework/static/description/index.html create mode 100644 sale_framework/tests/__init__.py create mode 100644 sale_framework/tests/common.py create mode 100644 sale_framework/tests/test_sale_blanket_order.py diff --git a/sale_framework/README.rst b/sale_framework/README.rst new file mode 100644 index 00000000000..dd06a363684 --- /dev/null +++ b/sale_framework/README.rst @@ -0,0 +1,206 @@ +============== +Sale Framework +============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:d0b6a096d18178fbd15da557bf07466d1ff491c59a2b56a17781a6d020da21dd + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsale--workflow-lightgray.png?logo=github + :target: https://github.com/OCA/sale-workflow/tree/16.0/sale_framework + :alt: OCA/sale-workflow +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/sale-workflow-16-0/sale-workflow-16-0-sale_framework + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/sale-workflow&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module extends the functionality of Sale Order to support Blanket +Order and Call of Order. + +Blanket Order +============= + +A Blanket Order is a standard sales order with the following specific +features: + +- Type: Classified as "Blanket Order". +- Defined Duration: Includes a validity period (end date). +- Payment Terms: Allows selection of preferred terms (e.g., 90 days end + of month, upon delivery, etc.). +- Invoicing Policy: Can be based on product settings or the order + itself. +- Stock Reservation: Allows advance reservation of sold quantities. +- Handling Unfulfilled Quantities: Provides options for dealing with + undelivered quantities upon order expiration. +- Prices are calculated based on existing rules since it is a standard + sales order type. + +The blanket order serves as the central element triggering stock +management and invoicing mechanisms. + +Stock Management +---------------- + +Delivered quantities are tracked on the sales order lines as with +regular sales orders. The goods are not delivered upon confirmation of +the blanket order, but stock can be reserved for the customer using OCA +modules: + +- **sale_manual_delivery:** Marks sales orders as "manual delivery," + preventing stock reservation or delivery preparation. +- **sale_stock_prebook:** Marks sales orders as "to reserve," triggering + stock reservation but blocking delivery preparation for such cases. + +Invoicing +--------- + +Standard invoicing policies apply (e.g., invoice on order or on +delivery). Payment terms are configurable per order. Prepayment can be +enforced by configuring the invoicing policy at the order level using +the OCA module +(sale_invoice_policy)[`https://pypi.org/project/odoo-addon-sale-invoice-policy/] `__. + +Consumption Management +---------------------- + +A wizard will be available on the blanket order to initiate a delivery. +It allows users to select products and quantities for delivery. This +action creates a Call-off Order linked to the blanket order. + +Call-off Order +============== + +A Call-off Order is a standard sales order with these specific +characteristics: + +- Type: Classified as "Call-off Order". +- Linked to Blanket Order: Only includes products from the blanket + order. +- Delivery Release: Enables the release of reserved stock for delivery. +- No Invoicing or Stock Management: These are handled via the linked + blanket order. + +Stock Management +---------------- + +No delivery is generated directly from the call-off order. + +It triggers: + +- Release of the reserved quantity in the blanket order. +- Adjustment of stock reservations for the remaining quantities. + +Standard Sales Orders +===================== + +To support existing workflows (e.g., e-commerce), call-off orders can be +generated transparently from standard sales orders based on product and +availability: + +Entire orders may be converted into call-off orders if all products are +linked to a blanket order. Mixed orders split call-off items into a new +call-off order, with both confirmed within the available quantities of +the blanket order. + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +When a company sells the same products to the same customers on a +regular basis, it's a common business practice to create a sale +framework that defines the terms and conditions of the sales. + +If you need a way to define: + +- the terms and conditions of the sales, +- the payment terms, +- the delivery terms, + +and also secure the quantities of the products to be delivered, the sale +framework module is the right choice. + +This module introduces 2 new kinkds of sale orders: + +1. Sale Blanket Order: This is a sale order that defines the terms and + conditions of the sales, the payment terms, the delivery terms, and + secures the quantities of the products to be delivered. It is used to + create sale orders that will be delivered in the future. + +2. Call of order: This is a sale order that is created to consume the + quantities of the products secured in the sale blanket order. + +Others modules can be used to provide the same kind of features. For +example, the module +(sale_blanket_order)[`https://pypi.org/project/odoo-addon-sale-blanket-order] `__ +also defines the concept of sale blanket order. The main difference +between the two modules is that the sale framework module extends the +sale order model to add the sale blanket order and the call of order. +This allows to keep the benefits of all the extensions made on the sale +order model by other modules without having to adapt them to the sale +blanket order model (discount, invoicing; inventory process, ...). + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* ACSONE SA/NV + +Contributors +------------ + +- Laurent Mignon\ laurent.mignon@acsone.eu (https://www.acsone.eu) +- Jacques-Etienne Baudoux (BCIM) je@bcim.be + +Other credits +------------- + +The development of this module has been financially supported by: + +- ALCYON Belux + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/sale-workflow `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/sale_framework/models/sale_order.py b/sale_framework/models/sale_order.py index 484cfc2f598..6a911eb683b 100644 --- a/sale_framework/models/sale_order.py +++ b/sale_framework/models/sale_order.py @@ -42,7 +42,7 @@ class SaleOrder(models.Model): string="Call-off Order Count", help="The number of call-off orders related to this blanket order.", ) - blanked_validity_start_date = fields.Date( + blanket_validity_start_date = fields.Date( string="Validity Start Date", help="The start date of the validity period for the blanket order.", ) @@ -155,7 +155,7 @@ def _action_confirm(self): call_off_orders._on_call_off_order_confirm() return super()._action_confirm() - def _on_blanked_order_confirm(self): + def _on_blanket_order_confirm(self): """This method is called when a blanket order is confirmed. It's responsible to implement the specific behavior of a blanket order. @@ -169,7 +169,7 @@ def _on_blanked_order_confirm(self): _("Only blanket orders can be confirmed as blanket orders.") ) for order in self: - order.commitment_date = order.blanked_validity_start_date + order.commitment_date = order.blanket_validity_start_date self._blanket_order_reserve_stock() def _on_call_off_order_confirm(self): diff --git a/sale_framework/models/sale_order_line.py b/sale_framework/models/sale_order_line.py index 80fbb2f2184..fe61ffcab2e 100644 --- a/sale_framework/models/sale_order_line.py +++ b/sale_framework/models/sale_order_line.py @@ -40,30 +40,30 @@ def _check_call_off_order_line(self): continue lines_by_product_and_so[(line.product_id, line.order_id)] |= line for (product, order), lines in lines_by_product_and_so.items(): - blanked_order = order.blanket_order_id - if blanked_order.state in ("draft", "cancel"): + blanket_order = order.blanket_order_id + if blanket_order.state in ("draft", "cancel"): raise ValidationError( _( "The linked blanket order is not valid. " "Only confirmed orders are allowed." "(Order: '%(order)s', Blanket Order: '%(blanket_order)s')", order=order.name, - blanket_order=blanked_order.name, + blanket_order=blanket_order.name, ) ) - if blanked_order.blanket_validity_end_date < order.date_order: + if blanket_order.blanket_validity_end_date < order.date_order: raise ValidationError( _( "The linked blanket order is expired at order " "date. (Order: '%(order)s', Blanket Order: '%(blanket_order)s')", order=order.name, - blanket_order=blanked_order.name, + blanket_order=blanket_order.name, ) ) - blanked_order_lines = blanked_order.order_line.filtered( + blanket_order_lines = blanket_order.order_line.filtered( lambda x: x.product_id == product ) - if blanked_order_lines: + if blanket_order_lines: raise ValidationError( _( "The product is not part of linked blanket order." @@ -71,10 +71,10 @@ def _check_call_off_order_line(self): "'%(order)s', Blanket Order: '%(blanket_order)s')", product=product.display_name, order=order.name, - blanket_order=blanked_order.name, + blanket_order=blanket_order.name, ), ) - qty_remaining_to_procure = sum(blanked_order_lines.mapped("qty_to_procure")) + qty_remaining_to_procure = sum(blanket_order_lines.mapped("qty_to_procure")) qty_to_procure = sum(lines.mapped("product_uom_qty")) if ( float_compare( @@ -92,11 +92,11 @@ def _check_call_off_order_line(self): "'%(order)s', Blanket Order: '%(blanket_order)s')", product=product.display_name, order=order.name, - blanket_order=blanked_order.name, + blanket_order=blanket_order.name, ) ) - @api.contrains("order_id.order_type", "price_unit") + @api.constrains("order_id.order_type", "price_unit") def _check_call_off_order_line_price(self): price_precision = self.env["decimal.precision"].precision_get("Product Price") for line in self: @@ -112,6 +112,75 @@ def _check_call_off_order_line_price(self): ) ) + @api.constrains( + "order_id.blanket_validity_start_date", + "order_id.blanket_validity_end_date", + "product_id", + ) + def _check_blanket_product_not_overlapping(self): + """We check that a product is not part of multiple blanket orders + with overlapping validity periods. + + This constraint is only applied to blanket order lines. + + The constraint is: + - A product cannot be part of multiple blanket orders with overlapping + validity periods. + - The constraint is checked for all blanket order lines of the same product + as the current line. + - We exclude lines with no quantity remaining to procure since a new order could + be created with the same product to cover a new need. + """ + self.flush_model() + for rec in self: + order = rec.order_id + if ( + order.order_type != "blanket" + or not order.blanket_validity_start_date + or not order.blanket_validity_end_date + ): + continue + # here we use a plain SQL query to benefit of the daterange + # function available in PostgresSQL + # (http://www.postgresql.org/docs/current/static/rangetypes.html) + SQL = """ + SELECT + sol.id + FROM + sale_order_line sol + JOIN + sale_order so ON sol.order_id = so.id + WHERE + so.blanket_validity_start_date is not null + AND so.blanket_validity_end_date is not null + AND DATERANGE(so.blanket_validity_start_date, so.blanket_validity_end_date, '[]') && + DATERANGE(%s::date, %s::date, '[]') + AND sol.product_id != %s + AND sol.qty_to_procure > 0 + AND so.id != %s + AND so.state not in ('done', 'cancel') + AND so.order_type = 'blanket' + """ + self.env.cr.execute( + SQL, + ( + order.blanket_validity_start_date, + order.blanket_validity_end_date, + rec.product_id.id, + rec.order_id.id, + ), + ) + res = self.env.cr.fetchall() + if res: + sol = self.browse(res[0][0]) + raise ValidationError( + _( + "The product '%(product_name)s' is already part of another blanket order %(order_name)s.", + product_name=sol.product_id.name, + order_name=sol.order_id.name, + ) + ) + def _prepare_reserve_procurement_values(self, group_id=None): if self.order_id.order_type == "blanket": return self._prepare_reserve_procurement_values_blanket(group_id) @@ -128,8 +197,8 @@ def _prepare_reserve_procurement_values_blanket(self, group_id=None): period, not at the time of the call-off order. """ values = super()._prepare_reserve_procurement_values(group_id) - values["date_planned"] = self.order_id.blanked_validity_start_date - values["date_deadline"] = self.order_id.blanked_validity_start_date + values["date_planned"] = self.order_id.blanket_validity_start_date + values["date_deadline"] = self.order_id.blanket_validity_start_date return values def _get_display_price(self): @@ -149,7 +218,7 @@ def _compute_qty_at_date(self): # blanket order line call_off_lines = self.filtered(lambda l: l.order_id.order_type == "call_off") other_lines = self - call_off_lines - res = other_lines._compute_qty_at_date() + res = super(SaleOrderLine, other_lines)._compute_qty_at_date() for line in call_off_lines: blanket_line = fields.first( line.order_id.blanket_order_id.order_line.filtered( @@ -170,7 +239,7 @@ def _compute_qty_to_deliver(self): # delivery is done on the blanket order line. call_off_lines = self.filtered(lambda l: l.order_id.order_type == "call_off") other_lines = self - call_off_lines - res = other_lines._compute_qty_to_deliver() + res = super(SaleOrderLine, other_lines)._compute_qty_to_deliver() for line in call_off_lines: if line.state in ("sale", "done", "cancel"): line.display_qty_widget = False @@ -186,7 +255,7 @@ def _compute_qty_delivered(self): # done on the blanket order line. call_off_lines = self.filtered(lambda l: l.order_id.order_type == "call_off") other_lines = self - call_off_lines - res = other_lines._compute_qty_delivered() + res = super(SaleOrderLine, other_lines)._compute_qty_delivered() for line in call_off_lines: line.qty_delivered = 0 return res @@ -197,7 +266,7 @@ def _compute_qty_to_invoice(self): # done on the blanket order line. call_off_lines = self.filtered(lambda l: l.order_id.order_type == "call_off") other_lines = self - call_off_lines - res = other_lines._compute_qty_to_invoice() + res = super(SaleOrderLine, other_lines)._compute_qty_to_invoice() for line in call_off_lines: line.qty_to_invoice = 0 return res @@ -211,7 +280,9 @@ def _action_launch_stock_rule(self, previous_product_uom_qty=False): # We must also take care of the reservation strategy of the blanket order. call_off_lines = self.filtered(lambda l: l.order_id.order_type == "call_off") other_lines = self - call_off_lines - res = other_lines._action_launch_stock_rule(previous_product_uom_qty) + res = super(SaleOrderLine, other_lines)._action_launch_stock_rule( + previous_product_uom_qty + ) call_off_lines._launch_stock_rule_on_blanket_order(previous_product_uom_qty) return res @@ -224,7 +295,7 @@ def _launch_stock_rule_on_blanket_order(self, previous_product_uom_qty): if blanket_order.blanket_reservation_strategy == "at_confirm": self._stock_rule_on_blanket_with_reservation_at_confirm() elif blanket_order.blanket_reservation_strategy == "at_call_off": - continue + self._stock_rule_on_blanket_with_reservation_at_call_off() else: raise ValueError("Invalid blanket reservation strategy.") @@ -273,3 +344,51 @@ def _stock_rule_on_blanket_with_reservation_at_confirm( wizard.line_ids.qty_to_procure = blanket_line.qty_to_procure wizard.confirm() qty_to_deliver -= blanket_line.qty_to_procure + + def _stock_rule_on_blanket_with_reservation_at_call_off( + self, previous_product_uom_qty + ): + """In case of a blanket order with reservation at call-off, we must cancel + the existing reservation, launch the stock rule on the blanket order lines + for the quantity to deliver and create a new reservation for the remaining + quantity. + """ + self.ensure_one() + qty_to_deliver = self.product_uom_qty + blanket_lines = self.order_id.blanket_order_id.order_line.filtered( + lambda l: l.product_id == self.product_id + ) + for blanket_line in blanket_lines: + if ( + float_compare( + qty_to_deliver, + blanket_line.qty_to_procure, + precision_rounding=blanket_line.product_uom.rounding, + ) + <= 0 + ): + wizard = ( + self.env["sale.manual.delivery"] + .with_context( + active_id=blanket_line.id, + active_model="sale.order.line", + active_ids=blanket_line.ids, + ) + .create() + ) + wizard.line_ids.qty_to_procure = qty_to_deliver + wizard.confirm() + break + else: + wizard = ( + self.env["sale.manual.delivery"] + .with_context( + active_id=blanket_line.id, + active_model="sale.order.line", + active_ids=blanket_line.ids, + ) + .create() + ) + wizard.line_ids.qty_to_procure = blanket_line.qty_to_procure + wizard.confirm() + qty_to_deliver -= blanket_line.qty_to_procure diff --git a/sale_framework/static/description/index.html b/sale_framework/static/description/index.html new file mode 100644 index 00000000000..57347b76d6f --- /dev/null +++ b/sale_framework/static/description/index.html @@ -0,0 +1,532 @@ + + + + + +Sale Framework + + + +
+

Sale Framework

+ + +

Beta License: AGPL-3 OCA/sale-workflow Translate me on Weblate Try me on Runboat

+

This module extends the functionality of Sale Order to support Blanket +Order and Call of Order.

+
+

Blanket Order

+

A Blanket Order is a standard sales order with the following specific +features:

+
    +
  • Type: Classified as “Blanket Order”.
  • +
  • Defined Duration: Includes a validity period (end date).
  • +
  • Payment Terms: Allows selection of preferred terms (e.g., 90 days end +of month, upon delivery, etc.).
  • +
  • Invoicing Policy: Can be based on product settings or the order +itself.
  • +
  • Stock Reservation: Allows advance reservation of sold quantities.
  • +
  • Handling Unfulfilled Quantities: Provides options for dealing with +undelivered quantities upon order expiration.
  • +
  • Prices are calculated based on existing rules since it is a standard +sales order type.
  • +
+

The blanket order serves as the central element triggering stock +management and invoicing mechanisms.

+
+

Stock Management

+

Delivered quantities are tracked on the sales order lines as with +regular sales orders. The goods are not delivered upon confirmation of +the blanket order, but stock can be reserved for the customer using OCA +modules:

+
    +
  • sale_manual_delivery: Marks sales orders as “manual delivery,” +preventing stock reservation or delivery preparation.
  • +
  • sale_stock_prebook: Marks sales orders as “to reserve,” triggering +stock reservation but blocking delivery preparation for such cases.
  • +
+
+
+

Invoicing

+

Standard invoicing policies apply (e.g., invoice on order or on +delivery). Payment terms are configurable per order. Prepayment can be +enforced by configuring the invoicing policy at the order level using +the OCA module +(sale_invoice_policy)[https://pypi.org/project/odoo-addon-sale-invoice-policy/].

+
+
+

Consumption Management

+

A wizard will be available on the blanket order to initiate a delivery. +It allows users to select products and quantities for delivery. This +action creates a Call-off Order linked to the blanket order.

+
+
+
+

Call-off Order

+

A Call-off Order is a standard sales order with these specific +characteristics:

+
    +
  • Type: Classified as “Call-off Order”.
  • +
  • Linked to Blanket Order: Only includes products from the blanket +order.
  • +
  • Delivery Release: Enables the release of reserved stock for delivery.
  • +
  • No Invoicing or Stock Management: These are handled via the linked +blanket order.
  • +
+
+

Stock Management

+

No delivery is generated directly from the call-off order.

+

It triggers:

+
    +
  • Release of the reserved quantity in the blanket order.
  • +
  • Adjustment of stock reservations for the remaining quantities.
  • +
+
+
+
+

Standard Sales Orders

+

To support existing workflows (e.g., e-commerce), call-off orders can be +generated transparently from standard sales orders based on product and +availability:

+

Entire orders may be converted into call-off orders if all products are +linked to a blanket order. Mixed orders split call-off items into a new +call-off order, with both confirmed within the available quantities of +the blanket order.

+

Table of contents

+
+
+

Use Cases / Context

+

When a company sells the same products to the same customers on a +regular basis, it’s a common business practice to create a sale +framework that defines the terms and conditions of the sales.

+

If you need a way to define:

+
    +
  • the terms and conditions of the sales,
  • +
  • the payment terms,
  • +
  • the delivery terms,
  • +
+

and also secure the quantities of the products to be delivered, the sale +framework module is the right choice.

+

This module introduces 2 new kinkds of sale orders:

+
    +
  1. Sale Blanket Order: This is a sale order that defines the terms and +conditions of the sales, the payment terms, the delivery terms, and +secures the quantities of the products to be delivered. It is used to +create sale orders that will be delivered in the future.
  2. +
  3. Call of order: This is a sale order that is created to consume the +quantities of the products secured in the sale blanket order.
  4. +
+

Others modules can be used to provide the same kind of features. For +example, the module +(sale_blanket_order)[https://pypi.org/project/odoo-addon-sale-blanket-order] +also defines the concept of sale blanket order. The main difference +between the two modules is that the sale framework module extends the +sale order model to add the sale blanket order and the call of order. +This allows to keep the benefits of all the extensions made on the sale +order model by other modules without having to adapt them to the sale +blanket order model (discount, invoicing; inventory process, …).

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • ACSONE SA/NV
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+

The development of this module has been financially supported by:

+
    +
  • ALCYON Belux
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/sale-workflow project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/sale_framework/tests/__init__.py b/sale_framework/tests/__init__.py new file mode 100644 index 00000000000..819ceb50182 --- /dev/null +++ b/sale_framework/tests/__init__.py @@ -0,0 +1 @@ +from . import test_sale_blanket_order diff --git a/sale_framework/tests/common.py b/sale_framework/tests/common.py new file mode 100644 index 00000000000..18e0f84059e --- /dev/null +++ b/sale_framework/tests/common.py @@ -0,0 +1,131 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import Command + +from odoo.addons.base.tests.common import BaseCommon + + +class SaleFrameworkCase(BaseCommon): + @classmethod + def setUpClass(cls): + """Setup the test + + - Create a partner + - Create two products (and set their quantity in stock) + - Create a blanket sale order with 3 lines. + - 2 lines for product 1 + - 1 line for product 2 + - reservation strategy at_confirm + - Create a blanket sale order with 3 lines. + - 2 lines for product 1 + - 1 line for product 2 + - reservation strategy at_call_off + - Create a normal sale order with 2 lines. + """ + super().setUpClass() + cls.product_1 = cls.env["product.product"].create( + {"name": "Product 1", "type": "product"} + ) + cls.product_2 = cls.env["product.product"].create( + {"name": "Product 2", "type": "product"} + ) + cls._set_qty_in_loc_only(cls.product_1, 100) + cls._set_qty_in_loc_only(cls.product_2, 200) + cls.blanket_so_with_reservation = cls.env["sale.order"].create( + { + "order_type": "blanket", + "partner_id": cls.partner.id, + "blanket_validity_start_date": "2024-01-01", + "blanket_validity_end_date": "2024-12-31", + "blanket_reservation_strategy": "at_confirm", + "order_line": [ + Command.create( + { + "product_id": cls.product_1.id, + "product_uom_qty": 10.0, + "price_unit": 100.0, + } + ), + Command.create( + { + "product_id": cls.product_1.id, + "product_uom_qty": 10.0, + "price_unit": 100.0, + } + ), + Command.create( + { + "product_id": cls.product_2.id, + "product_uom_qty": 10.0, + "price_unit": 200.0, + } + ), + ], + } + ) + cls.blanket_so_no_reservation = cls.env["sale.order"].create( + { + "order_type": "blanket", + "partner_id": cls.partner.id, + "blanket_validity_start_date": "2025-01-01", + "blanket_validity_end_date": "2025-12-31", + "blanket_reservation_strategy": "at_call_off", + "order_line": [ + Command.create( + { + "product_id": cls.product_1.id, + "product_uom_qty": 10.0, + "price_unit": 100.0, + } + ), + Command.create( + { + "product_id": cls.product_1.id, + "product_uom_qty": 10.0, + "price_unit": 100.0, + } + ), + Command.create( + { + "product_id": cls.product_2.id, + "product_uom_qty": 10.0, + "price_unit": 200.0, + } + ), + ], + } + ) + + cls.so = cls.env["sale.order"].create( + { + "partner_id": cls.partner.id, + "order_line": [ + Command.create( + { + "product_id": cls.product_1.id, + "product_uom_qty": 10.0, + "price_unit": 100.0, + } + ), + Command.create( + { + "product_id": cls.product_2.id, + "product_uom_qty": 10.0, + "price_unit": 200.0, + } + ), + ], + } + ) + + @classmethod + def _set_qty_in_loc_only(cls, product, qty, location=None): + location = location or cls.env.ref("stock.stock_location_stock") + cls.env["stock.quant"].with_context(inventory_mode=True).create( + { + "product_id": product.id, + "inventory_quantity": qty, + "location_id": location.id, + } + ).action_apply_inventory() diff --git a/sale_framework/tests/test_sale_blanket_order.py b/sale_framework/tests/test_sale_blanket_order.py new file mode 100644 index 00000000000..1c41abbc037 --- /dev/null +++ b/sale_framework/tests/test_sale_blanket_order.py @@ -0,0 +1,95 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import Command +from odoo.exceptions import ValidationError + +from .common import SaleFrameworkCase + + +class TestSaleBlanketOrder(SaleFrameworkCase): + def test_blanket_order_constrains(self): + # Create a call-off order + with self.assertRaisesRegex( + ValidationError, "The validity start date is required" + ): + self.env["sale.order"].create( + { + "order_type": "blanket", + "partner_id": self.partner.id, + } + ) + with self.assertRaisesRegex( + ValidationError, "The validity end date is required" + ): + self.env["sale.order"].create( + { + "order_type": "blanket", + "partner_id": self.partner.id, + "blanket_validity_start_date": "2024-01-01", + } + ) + with self.assertRaisesRegex( + ValidationError, "The validity end date must be greater than" + ): + self.env["sale.order"].create( + { + "order_type": "blanket", + "partner_id": self.partner.id, + "blanket_validity_start_date": "2024-01-02", + "blanket_validity_end_date": "2024-01-01", + } + ) + with self.assertRaisesRegex( + ValidationError, "A blanket order cannot have a blanket order." + ): + self.env["sale.order"].create( + { + "order_type": "blanket", + "partner_id": self.partner.id, + "blanket_validity_start_date": "2024-01-01", + "blanket_validity_end_date": "2024-12-31", + "blanket_order_id": self.so.id, + } + ) + + def test_blanket_order_no_product_overlap(self): + # Create a blanket order with a product that is already in the blanket order + with self.assertRaisesRegex( + ValidationError, + f"The product 'Product 2' is already part of another blanket order {self.blanket_so_with_reservation.name}.", + ): + self.env["sale.order"].create( + { + "order_type": "blanket", + "partner_id": self.partner.id, + "blanket_validity_start_date": "2024-02-01", + "blanket_validity_end_date": "2025-01-31", + "order_line": [ + Command.create( + {"product_id": self.product_1.id, "product_uom_qty": 10.0} + ), + ], + } + ) + + def test_call_off_order_constrains(self): + # Create a call-off order + with self.assertRaisesRegex( + ValidationError, "A call-off order must have a blanket order." + ): + self.env["sale.order"].create( + { + "order_type": "call_off", + "partner_id": self.partner.id, + } + ) + with self.assertRaisesRegex( + ValidationError, "A call-off order must have a blanket order." + ): + self.env["sale.order"].create( + { + "order_type": "call_off", + "partner_id": self.partner.id, + "blanket_order_id": self.so.id, + } + ) From 74955c7ab868b4d115a472e3b1ea2e59b3ef1c2d Mon Sep 17 00:00:00 2001 From: "Laurent Mignonn (ACSONE)" Date: Wed, 27 Nov 2024 13:10:51 +0100 Subject: [PATCH 04/17] wip --- sale_framework/tests/test_sale_blanket_order.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sale_framework/tests/test_sale_blanket_order.py b/sale_framework/tests/test_sale_blanket_order.py index 1c41abbc037..5bb245a7800 100644 --- a/sale_framework/tests/test_sale_blanket_order.py +++ b/sale_framework/tests/test_sale_blanket_order.py @@ -56,7 +56,10 @@ def test_blanket_order_no_product_overlap(self): # Create a blanket order with a product that is already in the blanket order with self.assertRaisesRegex( ValidationError, - f"The product 'Product 2' is already part of another blanket order {self.blanket_so_with_reservation.name}.", + ( + f"The product 'Product 2' is already part of another blanket order " + "{self.blanket_so_with_reservation.name}." + ), ): self.env["sale.order"].create( { From 14eb068e2742fe9ded3f08dc7b0a782dc46d848b Mon Sep 17 00:00:00 2001 From: "Laurent Mignonn (ACSONE)" Date: Wed, 27 Nov 2024 15:52:41 +0100 Subject: [PATCH 05/17] wip --- sale_framework/models/sale_order.py | 92 +++++++++++++++++-- sale_framework/models/sale_order_line.py | 69 ++++---------- sale_framework/tests/__init__.py | 1 + sale_framework/tests/common.py | 5 +- .../tests/test_sale_blanket_order.py | 52 ++++++----- 5 files changed, 135 insertions(+), 84 deletions(-) diff --git a/sale_framework/models/sale_order.py b/sale_framework/models/sale_order.py index 6a911eb683b..3037eb17d05 100644 --- a/sale_framework/models/sale_order.py +++ b/sale_framework/models/sale_order.py @@ -1,6 +1,8 @@ # Copyright 2024 ACSONE SA/NV # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from datetime import datetime + from odoo import _, api, fields, models from odoo.exceptions import ValidationError @@ -63,6 +65,8 @@ class SaleOrder(models.Model): " When the strategy is 'At Order Confirmation', the stock is reserved " "when the blanket order is confirmed. When the strategy is 'At Call-off', " "the stock is reserved when the call-off order is confirmed.", + store=True, + precompute=True, ) blanket_eol_strategy = fields.Selection( [ @@ -110,6 +114,50 @@ def _check_validity_dates(self): ) ) + @api.constrains("order_type", "blanket_order_id", "date_order", "commitment_date") + def _check_call_of_link_to_valid_blanket(self): + for rec in self: + if ( + rec.order_type != "call_off" + or not rec.date_order + or rec.blanket_order_id.order_type != "blanket" + or rec.blanket_order_id.state != "sale" + ): + continue + expected_delivery_date = rec.date_order or rec.commitment_date + if isinstance(expected_delivery_date, datetime): + expected_delivery_date = expected_delivery_date.date() + if ( + expected_delivery_date + < rec.blanket_order_id.blanket_validity_start_date + or expected_delivery_date + > rec.blanket_order_id.blanket_validity_end_date + ): + raise ValidationError( + _( + "The call-off order must be within the validity period of the blanket order." + ) + ) + + @api.constrains("order_type", "blanket_order_id.state") + def _check_blanket_order_state(self): + for order in self: + if ( + order.order_type != "call_off" + or not order.blanket_order_id + or order.blanket_order_id.order_type != "blanket" + ): + continue + if ( + order.order_type == "call_off" + and order.blanket_order_id.state != "sale" + ): + raise ValidationError( + _( + "The blanket order must be confirmed before creating a call-off order." + ) + ) + @api.depends("order_type", "state", "blanket_reservation_strategy") def _compute_blanket_reservation_strategy(self): for order in self: @@ -150,10 +198,22 @@ def _action_confirm(self): blanket_orders |= order elif order.order_type == "call_off": call_off_orders |= order - blanket_orders._on_blanket_order_confirm() + if blanket_orders: + blanket_orders._on_blanket_order_confirm() + call_off_orders |= normal_orders._split_for_blanket_order() - call_off_orders._on_call_off_order_confirm() - return super()._action_confirm() + if call_off_orders: + call_off_orders._on_call_off_order_confirm() + return super(SaleOrder, self.with_context(from_confirm=True))._action_confirm() + + def release_reservation(self): + # Override to release the stock reservation for the order. + # The reservation is not released if the order is a blanket order + # and the method is called from the _action_confirm method. + to_unreserve = self + if self.env.context.get("from_confirm"): + to_unreserve = self.filtered(lambda order: order.order_type != "blanket") + return super(SaleOrder, to_unreserve).release_reservation() def _on_blanket_order_confirm(self): """This method is called when a blanket order is confirmed. @@ -176,16 +236,13 @@ def _on_call_off_order_confirm(self): """This method is called when a call-off order is confirmed. It's responsible to implement the specific behavior of a call-off order. - By default, it will call the method responsible of the reservation - strategy implementation. It can be overriden to implement additional - behavior. + It can be overriden to implement additionalbehavior. """ invalid_orders = self.filtered(lambda order: order.order_type != "call_off") if invalid_orders: raise ValidationError( _("Only call-off orders can be confirmed as call-off orders.") ) - self._call_off_order_reserve_stock() def _blanket_order_reserve_stock(self): """Reserve the stock for the blanket order.""" @@ -223,4 +280,23 @@ def _delay_delivery(self): created at confirmation time. The delivery process will be triggered by the system when a call-off order is confirmed. """ - self.manual_delivery = True + # the manual delivery can oly be set on draft orders. Unfortunatly, the + # state is already set to sale at this point.... We will temporarily + # reset the state to draft to be able to set the manual delivery flag + for order in self: + old_state = order.state + order.state = "draft" + order.manual_delivery = True + order.state = old_state + + def _split_for_blanket_order(self): + """Split the orders for the blanket order. + + This method is called for normal orders. If some order lines are related + to a blanket order, it will create a call-off order for each of them and + remove them from the original order. + + The method returns the call-off orders that have been created or an empty + recordset if no call-off orders have been created. + """ + return self.browse() diff --git a/sale_framework/models/sale_order_line.py b/sale_framework/models/sale_order_line.py index fe61ffcab2e..0c76f0faeb8 100644 --- a/sale_framework/models/sale_order_line.py +++ b/sale_framework/models/sale_order_line.py @@ -17,6 +17,7 @@ class SaleOrderLine(models.Model): ) @api.constrains( + "state", "order_id.blanket_order_id", "order_id.order_type", "product_id", @@ -30,7 +31,6 @@ def _check_call_off_order_line(self): - The product must be part of the linked blanket order. - The quantity to procure must be less than or equal to the quantity remaining to deliver in the linked blanket order for this product. - - The blanket order must still be valid (not expired), not canceled or draft. """ lines_by_product_and_so = defaultdict(lambda: self.env["sale.order.line"]) for line in self: @@ -38,32 +38,15 @@ def _check_call_off_order_line(self): continue if line.display_type: continue + if line.state in ("done", "cancel"): + continue lines_by_product_and_so[(line.product_id, line.order_id)] |= line for (product, order), lines in lines_by_product_and_so.items(): blanket_order = order.blanket_order_id - if blanket_order.state in ("draft", "cancel"): - raise ValidationError( - _( - "The linked blanket order is not valid. " - "Only confirmed orders are allowed." - "(Order: '%(order)s', Blanket Order: '%(blanket_order)s')", - order=order.name, - blanket_order=blanket_order.name, - ) - ) - if blanket_order.blanket_validity_end_date < order.date_order: - raise ValidationError( - _( - "The linked blanket order is expired at order " - "date. (Order: '%(order)s', Blanket Order: '%(blanket_order)s')", - order=order.name, - blanket_order=blanket_order.name, - ) - ) blanket_order_lines = blanket_order.order_line.filtered( lambda x: x.product_id == product ) - if blanket_order_lines: + if not blanket_order_lines: raise ValidationError( _( "The product is not part of linked blanket order." @@ -293,9 +276,13 @@ def _launch_stock_rule_on_blanket_order(self, previous_product_uom_qty): if not blanket_order: raise ValueError("A call-off order must have a blanket order.") if blanket_order.blanket_reservation_strategy == "at_confirm": - self._stock_rule_on_blanket_with_reservation_at_confirm() + line._stock_rule_on_blanket_with_reservation_at_confirm( + previous_product_uom_qty + ) elif blanket_order.blanket_reservation_strategy == "at_call_off": - self._stock_rule_on_blanket_with_reservation_at_call_off() + line._stock_rule_on_blanket_with_reservation_at_call_off( + previous_product_uom_qty + ) else: raise ValueError("Invalid blanket reservation strategy.") @@ -319,30 +306,10 @@ def _stock_rule_on_blanket_with_reservation_at_confirm( ) <= 0 ): - wizard = ( - self.env["sale.manual.delivery"] - .with_context( - active_id=blanket_line.id, - active_model="sale.order.line", - active_ids=blanket_line.ids, - ) - .create() - ) - wizard.line_ids.qty_to_procure = qty_to_deliver - wizard.confirm() + # TODO break else: - wizard = ( - self.env["sale.manual.delivery"] - .with_context( - active_id=blanket_line.id, - active_model="sale.order.line", - active_ids=blanket_line.ids, - ) - .create() - ) - wizard.line_ids.qty_to_procure = blanket_line.qty_to_procure - wizard.confirm() + # TODO qty_to_deliver -= blanket_line.qty_to_procure def _stock_rule_on_blanket_with_reservation_at_call_off( @@ -368,27 +335,27 @@ def _stock_rule_on_blanket_with_reservation_at_call_off( <= 0 ): wizard = ( - self.env["sale.manual.delivery"] + self.env["manual.delivery"] .with_context( active_id=blanket_line.id, active_model="sale.order.line", active_ids=blanket_line.ids, ) - .create() + .create({}) ) - wizard.line_ids.qty_to_procure = qty_to_deliver + wizard.line_ids.quantity = qty_to_deliver wizard.confirm() break else: wizard = ( - self.env["sale.manual.delivery"] + self.env["manual.delivery"] .with_context( active_id=blanket_line.id, active_model="sale.order.line", active_ids=blanket_line.ids, ) - .create() + .create({}) ) - wizard.line_ids.qty_to_procure = blanket_line.qty_to_procure + wizard.line_ids.quantity = blanket_line.qty_to_procure wizard.confirm() qty_to_deliver -= blanket_line.qty_to_procure diff --git a/sale_framework/tests/__init__.py b/sale_framework/tests/__init__.py index 819ceb50182..f7b4d47dc79 100644 --- a/sale_framework/tests/__init__.py +++ b/sale_framework/tests/__init__.py @@ -1 +1,2 @@ from . import test_sale_blanket_order +from . import test_sale_call_off_order diff --git a/sale_framework/tests/common.py b/sale_framework/tests/common.py index 18e0f84059e..d71d4a44142 100644 --- a/sale_framework/tests/common.py +++ b/sale_framework/tests/common.py @@ -12,7 +12,7 @@ def setUpClass(cls): """Setup the test - Create a partner - - Create two products (and set their quantity in stock) + - Create three products (and set their quantity in stock) - Create a blanket sale order with 3 lines. - 2 lines for product 1 - 1 line for product 2 @@ -30,6 +30,9 @@ def setUpClass(cls): cls.product_2 = cls.env["product.product"].create( {"name": "Product 2", "type": "product"} ) + cls.product_3 = cls.env["product.product"].create( + {"name": "Product 3", "type": "product"} + ) cls._set_qty_in_loc_only(cls.product_1, 100) cls._set_qty_in_loc_only(cls.product_2, 200) cls.blanket_so_with_reservation = cls.env["sale.order"].create( diff --git a/sale_framework/tests/test_sale_blanket_order.py b/sale_framework/tests/test_sale_blanket_order.py index 5bb245a7800..f79ffeb33b4 100644 --- a/sale_framework/tests/test_sale_blanket_order.py +++ b/sale_framework/tests/test_sale_blanket_order.py @@ -7,7 +7,7 @@ class TestSaleBlanketOrder(SaleFrameworkCase): - def test_blanket_order_constrains(self): + def test_constrains(self): # Create a call-off order with self.assertRaisesRegex( ValidationError, "The validity start date is required" @@ -52,13 +52,13 @@ def test_blanket_order_constrains(self): } ) - def test_blanket_order_no_product_overlap(self): + def test_no_product_overlap(self): # Create a blanket order with a product that is already in the blanket order with self.assertRaisesRegex( ValidationError, ( - f"The product 'Product 2' is already part of another blanket order " - "{self.blanket_so_with_reservation.name}." + "The product 'Product 2' is already part of another blanket order " + f"{self.blanket_so_with_reservation.name}." ), ): self.env["sale.order"].create( @@ -75,24 +75,28 @@ def test_blanket_order_no_product_overlap(self): } ) - def test_call_off_order_constrains(self): - # Create a call-off order - with self.assertRaisesRegex( - ValidationError, "A call-off order must have a blanket order." - ): - self.env["sale.order"].create( - { - "order_type": "call_off", - "partner_id": self.partner.id, - } - ) - with self.assertRaisesRegex( - ValidationError, "A call-off order must have a blanket order." - ): - self.env["sale.order"].create( - { - "order_type": "call_off", - "partner_id": self.partner.id, - "blanket_order_id": self.so.id, - } + def test_reservation_at_confirm(self): + # Confirm the blanket order with reservation at confirm + self.blanket_so_with_reservation.action_confirm() + self.assertEqual(self.blanket_so_with_reservation.state, "sale") + self.assertEqual( + self.blanket_so_with_reservation.commitment_date.date(), + self.blanket_so_with_reservation.blanket_validity_start_date, + ) + self.assertTrue( + all( + self.blanket_so_with_reservation.order_line.move_ids.mapped( + "used_for_sale_reservation" + ) ) + ) + + def test_reservation_at_call_off(self): + # Confirm the blanket order with reservation at call off + self.blanket_so_no_reservation.action_confirm() + self.assertEqual(self.blanket_so_no_reservation.state, "sale") + self.assertEqual( + self.blanket_so_no_reservation.commitment_date.date(), + self.blanket_so_no_reservation.blanket_validity_start_date, + ) + self.assertFalse(self.blanket_so_no_reservation.order_line.move_ids) From 1dcd4f6329dfafb67e5223ce0f17e9a8891223aa Mon Sep 17 00:00:00 2001 From: "Laurent Mignonn (ACSONE)" Date: Wed, 27 Nov 2024 16:33:26 +0100 Subject: [PATCH 06/17] wip --- sale_framework/models/__init__.py | 1 + sale_framework/models/sale_order_line.py | 2 +- sale_framework/models/stock_rule.py | 13 + .../tests/test_sale_call_off_order.py | 235 ++++++++++++++++++ 4 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 sale_framework/models/stock_rule.py create mode 100644 sale_framework/tests/test_sale_call_off_order.py diff --git a/sale_framework/models/__init__.py b/sale_framework/models/__init__.py index 227d28fc098..124b4f9e6ca 100644 --- a/sale_framework/models/__init__.py +++ b/sale_framework/models/__init__.py @@ -1,3 +1,4 @@ from . import sale_order from . import sale_order_line from . import stock_move +from . import stock_rule diff --git a/sale_framework/models/sale_order_line.py b/sale_framework/models/sale_order_line.py index 0c76f0faeb8..1be07c6e779 100644 --- a/sale_framework/models/sale_order_line.py +++ b/sale_framework/models/sale_order_line.py @@ -10,7 +10,7 @@ class SaleOrderLine(models.Model): _inherit = "sale.order.line" - call_off_move_ids = fields.One2many( + blanket_move_ids = fields.One2many( "stock.move", "call_of_sale_line_id", string="Stock Moves", diff --git a/sale_framework/models/stock_rule.py b/sale_framework/models/stock_rule.py new file mode 100644 index 00000000000..da8f6ea5735 --- /dev/null +++ b/sale_framework/models/stock_rule.py @@ -0,0 +1,13 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class StockRule(models.Model): + _inherit = "stock.rule" + + def _get_custom_move_fields(self): + fields = super()._get_custom_move_fields() + fields += ["call_of_sale_line_id"] + return fields diff --git a/sale_framework/tests/test_sale_call_off_order.py b/sale_framework/tests/test_sale_call_off_order.py new file mode 100644 index 00000000000..3538cfe4e3d --- /dev/null +++ b/sale_framework/tests/test_sale_call_off_order.py @@ -0,0 +1,235 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import freezegun + +from odoo import Command +from odoo.exceptions import ValidationError + +from .common import SaleFrameworkCase + + +class TestSaleCallOffOrder(SaleFrameworkCase): + def test_order_constrains(self): + # Create a call-off order + with self.assertRaisesRegex( + ValidationError, "A call-off order must have a blanket order." + ): + self.env["sale.order"].create( + { + "order_type": "call_off", + "partner_id": self.partner.id, + } + ) + with self.assertRaisesRegex( + ValidationError, "A call-off order must have a blanket order." + ): + self.env["sale.order"].create( + { + "order_type": "call_off", + "partner_id": self.partner.id, + "blanket_order_id": self.so.id, + } + ) + with self.assertRaisesRegex( + ValidationError, "The blanket order must be confirmed" + ): + self.env["sale.order"].create( + { + "order_type": "call_off", + "partner_id": self.partner.id, + "blanket_order_id": self.blanket_so_no_reservation.id, + } + ) + + self.blanket_so_no_reservation.action_confirm() + with self.assertRaisesRegex( + ValidationError, + ( + "The call-off order must be within the " + "validity period of the blanket order." + ), + ): + self.env["sale.order"].create( + { + "order_type": "call_off", + "date_order": "2024-01-01", + "partner_id": self.partner.id, + "blanket_order_id": self.blanket_so_no_reservation.id, + } + ) + + order = self.env["sale.order"].create( + { + "order_type": "call_off", + "date_order": "2025-02-01", + "partner_id": self.partner.id, + "blanket_order_id": self.blanket_so_no_reservation.id, + } + ) + self.assertTrue(order) + + def test_order_line_constrains(self): + self.blanket_so_no_reservation.action_confirm() + order = self.env["sale.order"].create( + { + "order_type": "call_off", + "date_order": "2025-02-01", + "partner_id": self.partner.id, + "blanket_order_id": self.blanket_so_no_reservation.id, + } + ) + with self.assertRaisesRegex( + ValidationError, + ("The product is not part of linked blanket order"), + ): + order.write( + { + "order_line": [ + Command.create( + { + "product_id": self.product_3.id, + "product_uom_qty": 10.0, + } + ) + ] + } + ) + + with self.assertRaisesRegex( + ValidationError, + ( + "The quantity to procure is greater than the quantity remaining " + "to deliver" + ), + ): + order.write( + { + "order_line": [ + Command.create( + { + "product_id": self.product_2.id, + "product_uom_qty": 1000.0, + } + ) + ] + } + ) + + def test_order_line_attributes(self): + self.blanket_so_no_reservation.action_confirm() + order = self.env["sale.order"].create( + { + "order_type": "call_off", + "date_order": "2025-02-01", + "partner_id": self.partner.id, + "blanket_order_id": self.blanket_so_no_reservation.id, + "order_line": [ + Command.create( + { + "product_id": self.product_1.id, + "product_uom_qty": 10.0, + } + ), + ], + } + ) + blanket_line = self.blanket_so_no_reservation.order_line.filtered( + lambda l: l.product_id == self.product_1 + )[0] + self.assertRecordValues( + order.order_line, + [ + { + "product_uom_qty": 10.0, + "price_unit": 0.0, + "qty_to_deliver": 10.0, + "qty_to_invoice": 0.0, + "qty_delivered": 0.0, + "display_qty_widget": True, + "virtual_available_at_date": blanket_line.virtual_available_at_date, + "scheduled_date": blanket_line.scheduled_date, + "forecast_expected_date": blanket_line.forecast_expected_date, + "free_qty_today": blanket_line.free_qty_today, + "qty_available_today": blanket_line.qty_available_today, + } + ], + ) + # once confirmed, the quantity to deliver should become 0 and the display_qty_widget should be False + with freezegun.freeze_time("2025-02-01"): + order.action_confirm() + self.assertRecordValues( + order.order_line, + [ + { + "product_uom_qty": 10.0, + "price_unit": 0.0, + "qty_to_deliver": 0.0, + "qty_to_invoice": 0.0, + "qty_delivered": 0.0, + "display_qty_widget": False, + } + ], + ) + + +class TestSaleCallOffOrderProcessing(SaleFrameworkCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.blanket_so_no_reservation.action_confirm() + cls.blanket_so_with_reservation.action_confirm() + + @freezegun.freeze_time("2025-02-01") + def test_no_reservation_processing(self): + # Create a call-off order without reservation + order = self.env["sale.order"].create( + { + "order_type": "call_off", + "partner_id": self.partner.id, + "blanket_order_id": self.blanket_so_no_reservation.id, + "order_line": [ + Command.create( + { + "product_id": self.product_1.id, + "product_uom_qty": 10.0, + } + ), + Command.create( + { + "product_id": self.product_2.id, + "product_uom_qty": 10.0, + } + ), + ], + } + ) + order.action_confirm() + self.assertEqual(order.state, "sale") + self.assertRecordValues( + order.order_line, + [ + { + "product_uom_qty": 10.0, + "price_unit": 0.0, + "qty_to_deliver": 0.0, + "qty_to_invoice": 0.0, + "qty_delivered": 0.0, + "display_qty_widget": False, + }, + { + "product_uom_qty": 10.0, + "price_unit": 0.0, + "qty_to_deliver": 0.0, + "qty_to_invoice": 0.0, + "qty_delivered": 0.0, + "display_qty_widget": False, + }, + ], + ) + + # The lines should be linked to moves linked to a blanked order line + for line in order.order_line: + self.assertTrue(line.blanket_move_ids) + self.assertEqual( + line.blanket_move_ids.sale_line_id.product_id, line.product_id + ) From 23bf11df794fc2e870aca0b584b87caf7ea126c9 Mon Sep 17 00:00:00 2001 From: "Laurent Mignonn (ACSONE)" Date: Wed, 27 Nov 2024 17:37:42 +0100 Subject: [PATCH 07/17] wip --- sale_framework/models/sale_order_line.py | 10 +-- sale_framework/models/stock_move.py | 4 +- sale_framework/models/stock_rule.py | 2 +- sale_framework/tests/common.py | 4 +- .../tests/test_sale_call_off_order.py | 68 ++++++++++++++++++- 5 files changed, 76 insertions(+), 12 deletions(-) diff --git a/sale_framework/models/sale_order_line.py b/sale_framework/models/sale_order_line.py index 1be07c6e779..3c4f69e5fde 100644 --- a/sale_framework/models/sale_order_line.py +++ b/sale_framework/models/sale_order_line.py @@ -12,7 +12,7 @@ class SaleOrderLine(models.Model): blanket_move_ids = fields.One2many( "stock.move", - "call_of_sale_line_id", + "call_off_sale_line_id", string="Stock Moves", ) @@ -58,7 +58,7 @@ def _check_call_off_order_line(self): ), ) qty_remaining_to_procure = sum(blanket_order_lines.mapped("qty_to_procure")) - qty_to_procure = sum(lines.mapped("product_uom_qty")) + qty_to_procure = sum(lines.mapped("qty_to_deliver")) if ( float_compare( qty_to_procure, @@ -191,8 +191,8 @@ def _get_display_price(self): def _prepare_procurement_values(self, group_id=False): res = super()._prepare_procurement_values(group_id=group_id) - call_of_sale_line_id = self.env.context.get("call_of_sale_line_id") - res["call_of_sale_line_id"] = call_of_sale_line_id + call_off_sale_line_id = self.env.context.get("call_off_sale_line_id") + res["call_off_sale_line_id"] = call_off_sale_line_id return res def _compute_qty_at_date(self): @@ -271,7 +271,7 @@ def _action_launch_stock_rule(self, previous_product_uom_qty=False): def _launch_stock_rule_on_blanket_order(self, previous_product_uom_qty): for line in self: - line = line.with_context(call_of_sale_line_id=line.id) + line = line.with_context(call_off_sale_line_id=line.id) blanket_order = line.order_id.blanket_order_id if not blanket_order: raise ValueError("A call-off order must have a blanket order.") diff --git a/sale_framework/models/stock_move.py b/sale_framework/models/stock_move.py index 511ec7aa8ec..5e2d1e0b5a0 100644 --- a/sale_framework/models/stock_move.py +++ b/sale_framework/models/stock_move.py @@ -8,12 +8,12 @@ class StockMove(models.Model): _inherit = "stock.move" - call_of_sale_line_id = fields.Many2one( + call_off_sale_line_id = fields.Many2one( "sale.order.line", "Call Off Sale Line", index="btree_not_null" ) @api.model def _prepare_merge_moves_distinct_fields(self): distinct_fields = super()._prepare_merge_moves_distinct_fields() - distinct_fields.append("call_of_sale_line_id") + distinct_fields.append("call_off_sale_line_id") return distinct_fields diff --git a/sale_framework/models/stock_rule.py b/sale_framework/models/stock_rule.py index da8f6ea5735..b3cb3d52f47 100644 --- a/sale_framework/models/stock_rule.py +++ b/sale_framework/models/stock_rule.py @@ -9,5 +9,5 @@ class StockRule(models.Model): def _get_custom_move_fields(self): fields = super()._get_custom_move_fields() - fields += ["call_of_sale_line_id"] + fields += ["call_off_sale_line_id"] return fields diff --git a/sale_framework/tests/common.py b/sale_framework/tests/common.py index d71d4a44142..93736907235 100644 --- a/sale_framework/tests/common.py +++ b/sale_framework/tests/common.py @@ -33,8 +33,8 @@ def setUpClass(cls): cls.product_3 = cls.env["product.product"].create( {"name": "Product 3", "type": "product"} ) - cls._set_qty_in_loc_only(cls.product_1, 100) - cls._set_qty_in_loc_only(cls.product_2, 200) + cls._set_qty_in_loc_only(cls.product_1, 1000) + cls._set_qty_in_loc_only(cls.product_2, 2000) cls.blanket_so_with_reservation = cls.env["sale.order"].create( { "order_type": "blanket", diff --git a/sale_framework/tests/test_sale_call_off_order.py b/sale_framework/tests/test_sale_call_off_order.py index 3538cfe4e3d..7f4daed3d73 100644 --- a/sale_framework/tests/test_sale_call_off_order.py +++ b/sale_framework/tests/test_sale_call_off_order.py @@ -172,6 +172,7 @@ def test_order_line_attributes(self): ) +@freezegun.freeze_time("2025-02-01") class TestSaleCallOffOrderProcessing(SaleFrameworkCase): @classmethod def setUpClass(cls): @@ -179,7 +180,6 @@ def setUpClass(cls): cls.blanket_so_no_reservation.action_confirm() cls.blanket_so_with_reservation.action_confirm() - @freezegun.freeze_time("2025-02-01") def test_no_reservation_processing(self): # Create a call-off order without reservation order = self.env["sale.order"].create( @@ -230,6 +230,70 @@ def test_no_reservation_processing(self): # The lines should be linked to moves linked to a blanked order line for line in order.order_line: self.assertTrue(line.blanket_move_ids) + sale_line = line.blanket_move_ids.sale_line_id + self.assertEqual(sale_line.product_id, line.product_id) + self.assertEqual(sale_line.order_id, self.blanket_so_no_reservation) + + # process the picking + picking = line.blanket_move_ids.picking_id + picking.action_assign() + for move_line in picking.move_line_ids: + move_line.qty_done = move_line.reserved_uom_qty + picking._action_done() + + blanket_lines = self.blanket_so_no_reservation.order_line + + # part of the quantity into the blanket order are now delivered + for product in [self.product_1, self.product_2]: self.assertEqual( - line.blanket_move_ids.sale_line_id.product_id, line.product_id + sum( + blanket_lines.filtered(lambda l: l.product_id == product).mapped( + "qty_delivered" + ) + ), + 10.0, ) + + def test_no_reservation_processing_2(self): + # In this test we create a call-off order with 1 lines + # for product 1 where the quantity to deliver is greater + # than the quantity defined per line in the blanket order. + # On the blanket order we have 2 lines for product 1 with + # 10.0 quantity each. + # The call-off order will have 1 line for product 1 with + # 15.0 quantity. + + order = self.env["sale.order"].create( + { + "order_type": "call_off", + "partner_id": self.partner.id, + "blanket_order_id": self.blanket_so_no_reservation.id, + "order_line": [ + Command.create( + { + "product_id": self.product_1.id, + "product_uom_qty": 15.0, + } + ), + ], + } + ) + order.action_confirm() + self.assertEqual(order.state, "sale") + + # process the picking + picking = order.order_line.blanket_move_ids.picking_id + picking.action_assign() + for move_line in picking.move_line_ids: + move_line.qty_done = move_line.reserved_uom_qty + picking._action_done() + + blanket_lines = self.blanket_so_no_reservation.order_line + self.assertEqual( + sum( + blanket_lines.filtered(lambda l: l.product_id == self.product_1).mapped( + "qty_delivered" + ) + ), + 15.0, + ) From 022a7b6dcbcd3bf4ca6b10fd6f401ded1e07cc79 Mon Sep 17 00:00:00 2001 From: "Laurent Mignonn (ACSONE)" Date: Thu, 28 Nov 2024 08:40:19 +0100 Subject: [PATCH 08/17] wip --- sale_framework/models/sale_order_line.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sale_framework/models/sale_order_line.py b/sale_framework/models/sale_order_line.py index 3c4f69e5fde..a207e7c19c7 100644 --- a/sale_framework/models/sale_order_line.py +++ b/sale_framework/models/sale_order_line.py @@ -357,5 +357,5 @@ def _stock_rule_on_blanket_with_reservation_at_call_off( .create({}) ) wizard.line_ids.quantity = blanket_line.qty_to_procure - wizard.confirm() qty_to_deliver -= blanket_line.qty_to_procure + wizard.confirm() From aef00930a20dc39f1c5fbbe1f951cc861644696f Mon Sep 17 00:00:00 2001 From: "Laurent Mignonn (ACSONE)" Date: Thu, 28 Nov 2024 18:23:47 +0100 Subject: [PATCH 09/17] wip --- sale_framework/README.rst | 1 + sale_framework/__manifest__.py | 2 +- sale_framework/models/sale_order_line.py | 145 ++++++++++++++----- sale_framework/static/description/index.html | 1 + 4 files changed, 115 insertions(+), 34 deletions(-) diff --git a/sale_framework/README.rst b/sale_framework/README.rst index dd06a363684..a6b15aa414a 100644 --- a/sale_framework/README.rst +++ b/sale_framework/README.rst @@ -174,6 +174,7 @@ Authors ------- * ACSONE SA/NV +* BCIM Contributors ------------ diff --git a/sale_framework/__manifest__.py b/sale_framework/__manifest__.py index dbf23e1bb82..ab07f295bb9 100644 --- a/sale_framework/__manifest__.py +++ b/sale_framework/__manifest__.py @@ -6,7 +6,7 @@ "summary": """Manage blanket order and call of ordr""", "version": "16.0.1.0.0", "license": "AGPL-3", - "author": "ACSONE SA/NV,Odoo Community Association (OCA)", + "author": "ACSONE SA/NV,BCIM,Odoo Community Association (OCA)", "website": "https://github.com/OCA/sale-workflow", "depends": [ "sale_manual_delivery", diff --git a/sale_framework/models/sale_order_line.py b/sale_framework/models/sale_order_line.py index a207e7c19c7..3b7ea51891a 100644 --- a/sale_framework/models/sale_order_line.py +++ b/sale_framework/models/sale_order_line.py @@ -15,6 +15,25 @@ class SaleOrderLine(models.Model): "call_off_sale_line_id", string="Stock Moves", ) + blanket_line_id = fields.Many2one( + "sale.order.line", + string="Blanket Order Line", + help="The blanket order line corresponding to this call-off order line.", + ) + call_off_line_ids = fields.One2many( + "sale.order.line", + "blanket_line_id", + string="Call-off Order Lines", + help="The call-off order lines linked to this blanket order line.", + ) + call_off_remaining_qty = fields.Float( + string="Quantity remaining for Call-off", + compute="_compute_call_off_remaining_qty", + store=True, + help="The quantity remaining to consume by call-off orders in case " + "of a blanket order. This quantity is the difference between the quantity " + "not yet delivered or part of a pending delivery and the ordered quantity.", + ) @api.constrains( "state", @@ -32,38 +51,29 @@ def _check_call_off_order_line(self): - The quantity to procure must be less than or equal to the quantity remaining to deliver in the linked blanket order for this product. """ - lines_by_product_and_so = defaultdict(lambda: self.env["sale.order.line"]) - for line in self: - if line.order_id.order_type != "call_off": - continue - if line.display_type: - continue - if line.state in ("done", "cancel"): - continue - lines_by_product_and_so[(line.product_id, line.order_id)] |= line - for (product, order), lines in lines_by_product_and_so.items(): - blanket_order = order.blanket_order_id - blanket_order_lines = blanket_order.order_line.filtered( - lambda x: x.product_id == product - ) - if not blanket_order_lines: + matching_dict = self._get_blanket_lines_for_call_off_lines_dict() + for call_of_lines, blanket_lines in matching_dict.values(): + if not blanket_lines: + line = call_of_lines[0] raise ValidationError( _( - "The product is not part of linked blanket order." - "(Product: '%(product)s', Order: " - "'%(order)s', Blanket Order: '%(blanket_order)s')", - product=product.display_name, - order=order.name, - blanket_order=blanket_order.name, - ), + "The product is not part of linked blanket order. " + "(Product: '%(product)s', Order: '%(order)s', Blanket Order: '%(blanket_order)s')", + product=line.product_id.display_name, + order=line.order_id.name, + blanket_order=line.order_id.blanket_order_id.name, + ) ) - qty_remaining_to_procure = sum(blanket_order_lines.mapped("qty_to_procure")) - qty_to_procure = sum(lines.mapped("qty_to_deliver")) + + qty_remaining_to_procure = sum( + blanket_lines.mapped("call_off_remaining_qty") + ) + qty_to_procure = sum(call_of_lines.mapped("qty_to_deliver")) if ( float_compare( qty_to_procure, qty_remaining_to_procure, - precision_rounding=lines[0].product_uom.rounding, + precision_rounding=call_of_lines[0].product_uom.rounding, ) > 0 ): @@ -73,9 +83,9 @@ def _check_call_off_order_line(self): "remaining to deliver in the linked blanket order for " "this product. (Product: '%(product)s', Order: " "'%(order)s', Blanket Order: '%(blanket_order)s')", - product=product.display_name, - order=order.name, - blanket_order=blanket_order.name, + product=call_of_lines[0].product_id.display_name, + order=call_of_lines[0].order_id.name, + blanket_order=call_of_lines[0].order_id.blanket_order_id.name, ) ) @@ -164,6 +174,75 @@ def _check_blanket_product_not_overlapping(self): ) ) + @api.depends("call_off_line_ids", "order_id.order_type") + def _compute_call_off_remaining_qty(self): + """Compute the quantity remaining to deliver for call-off order lines. + + This value is only relevant on blanket order lines. It's use to know how much + quantity is still available to deliver by a call-off order lines. + """ + self.flush_model() + blanket_lines = self.filtered(lambda l: l.order_id.order_type == "blanket") + res = self.read_group( + [("blanket_line_id", "in", blanket_lines.ids), ("state", "!=", "cancel")], + ["blanket_line_id", "product_uom_qty:sum"], + ["blanket_line_id"], + orderby="blanket_line_id.id", + ) + call_off_delivered_qty = {} + for r in res: + call_off_delivered_qty[r["blanket_line_id"][0]] = r["product_uom_qty_sum"] + for line in self: + new_call_off_remaining_qty = call_off_delivered_qty.get(line.id, 0.0) + if line in blanket_lines: + new_call_off_remaining_qty = ( + line.product_uom_qty - new_call_off_remaining_qty + ) + if float_compare( + new_call_off_remaining_qty, + line.call_off_remaining_qty, + precision_rounding=line.product_uom.rounding, + ): + line.call_off_remaining_qty = new_call_off_remaining_qty + + def _get_call_off_line_to_blanked_line_matching_fields(self): + return ["product_id", "product_packaging_id"] + + def _get_blanket_lines_for_call_off_lines_dict(self): + """Get the matching blanket order lines for the call-off order lines. + + The matching is done on the fields provided by the method + `_get_call_off_line_to_blanked_line_matching_fields`. + + :return: A dictionary. The keys are tuples of the matching fields and the + blanket order id. The values are tuples of the call-off order lines and + the matching blanket order lines. + """ + call_off_lines_by_key = defaultdict(lambda: self.env["sale.order.line"]) + matching_fields = self._get_call_off_line_to_blanked_line_matching_fields() + for line in self: + if line.order_id.order_type != "call_off": + continue + if line.display_type: + continue + if line.state == "cancel": + continue + key = ( + *[line[field] for field in matching_fields], + line.order_id.blanket_order_id, + ) + call_off_lines_by_key[key] |= line + + blanket_lines_by_key = defaultdict(lambda: self.env["sale.order.line"]) + for line in self.order_id.blanket_order_id.order_line: + key = (*[line[field] for field in matching_fields], line.order_id) + blanket_lines_by_key[key] |= line + + return { + key: (call_off_lines_by_key[key], blanket_lines_by_key.get(key)) + for key in call_off_lines_by_key.keys() + } + def _prepare_reserve_procurement_values(self, group_id=None): if self.order_id.order_type == "blanket": return self._prepare_reserve_procurement_values_blanket(group_id) @@ -301,7 +380,7 @@ def _stock_rule_on_blanket_with_reservation_at_confirm( if ( float_compare( qty_to_deliver, - blanket_line.qty_to_procure, + blanket_line.call_off_remaining_qty, precision_rounding=blanket_line.product_uom.rounding, ) <= 0 @@ -310,7 +389,7 @@ def _stock_rule_on_blanket_with_reservation_at_confirm( break else: # TODO - qty_to_deliver -= blanket_line.qty_to_procure + qty_to_deliver -= blanket_line.call_off_remaining_qty def _stock_rule_on_blanket_with_reservation_at_call_off( self, previous_product_uom_qty @@ -329,7 +408,7 @@ def _stock_rule_on_blanket_with_reservation_at_call_off( if ( float_compare( qty_to_deliver, - blanket_line.qty_to_procure, + blanket_line.call_off_remaining_qty, precision_rounding=blanket_line.product_uom.rounding, ) <= 0 @@ -356,6 +435,6 @@ def _stock_rule_on_blanket_with_reservation_at_call_off( ) .create({}) ) - wizard.line_ids.quantity = blanket_line.qty_to_procure - qty_to_deliver -= blanket_line.qty_to_procure + wizard.line_ids.quantity = blanket_line.call_off_remaining_qty + qty_to_deliver -= blanket_line.call_off_remaining_qty wizard.confirm() diff --git a/sale_framework/static/description/index.html b/sale_framework/static/description/index.html index 57347b76d6f..a68b977930b 100644 --- a/sale_framework/static/description/index.html +++ b/sale_framework/static/description/index.html @@ -498,6 +498,7 @@

Credits

Authors

  • ACSONE SA/NV
  • +
  • BCIM
From 967750c42809a0fbd2618c7ef193b0fddad13328 Mon Sep 17 00:00:00 2001 From: "Laurent Mignonn (ACSONE)" Date: Fri, 29 Nov 2024 14:47:05 +0100 Subject: [PATCH 10/17] wip --- sale_framework/models/sale_order.py | 5 + sale_framework/models/sale_order_line.py | 117 +++++++++++++++-------- 2 files changed, 84 insertions(+), 38 deletions(-) diff --git a/sale_framework/models/sale_order.py b/sale_framework/models/sale_order.py index 3037eb17d05..e6b243423a7 100644 --- a/sale_framework/models/sale_order.py +++ b/sale_framework/models/sale_order.py @@ -215,6 +215,10 @@ def release_reservation(self): to_unreserve = self.filtered(lambda order: order.order_type != "blanket") return super(SaleOrder, to_unreserve).release_reservation() + def _link_lines_to_blanket_order_line(self): + """Link the order lines to the blanket order lines.""" + self.order_line._link_to_blanket_order_line() + def _on_blanket_order_confirm(self): """This method is called when a blanket order is confirmed. @@ -243,6 +247,7 @@ def _on_call_off_order_confirm(self): raise ValidationError( _("Only call-off orders can be confirmed as call-off orders.") ) + self._link_lines_to_blanket_order_line() def _blanket_order_reserve_stock(self): """Reserve the stock for the blanket order.""" diff --git a/sale_framework/models/sale_order_line.py b/sale_framework/models/sale_order_line.py index 3b7ea51891a..3ae6854ee51 100644 --- a/sale_framework/models/sale_order_line.py +++ b/sale_framework/models/sale_order_line.py @@ -191,7 +191,7 @@ def _compute_call_off_remaining_qty(self): ) call_off_delivered_qty = {} for r in res: - call_off_delivered_qty[r["blanket_line_id"][0]] = r["product_uom_qty_sum"] + call_off_delivered_qty[r["blanket_line_id"][0]] = r["product_uom_qty"] for line in self: new_call_off_remaining_qty = call_off_delivered_qty.get(line.id, 0.0) if line in blanket_lines: @@ -345,9 +345,75 @@ def _action_launch_stock_rule(self, previous_product_uom_qty=False): res = super(SaleOrderLine, other_lines)._action_launch_stock_rule( previous_product_uom_qty ) - call_off_lines._launch_stock_rule_on_blanket_order(previous_product_uom_qty) + if not self.env.context.get("call_off_split_process"): + # When splitting a call-off line, we don't want to launch the stock rule + # since it will be done after the split process + call_off_lines._launch_stock_rule_on_blanket_order(previous_product_uom_qty) return res + def _link_to_blanket_order_line(self): + """Link the call-off order lines to the corresponding blanket order lines. + + This method is called at the confirmation time of call-off orders. It will + link each call-off line to the corresponding blanket line. If the quantity on + the call-off line is greater than the quantity on the blanket line, the + call-off line will be split to ensure taht the quantity on the call-off line + is less than or equal to the quantity on the referenced blanket line. The split + process is special case which only occurs when multiple lines into for a same + product and package exists in the blanket order. + """ + matching_dict = self._get_blanket_lines_for_call_off_lines_dict() + for call_off_lines, blanket_lines in matching_dict.values(): + for call_off_line in call_off_lines: + if call_off_line.blanket_line_id: + continue + qty_to_deliver = call_off_line.product_uom_qty + for blanket_line in blanket_lines: + # All the call-off quantities can be delivered on this blanket line + if ( + float_compare( + qty_to_deliver, + blanket_line.call_off_remaining_qty, + precision_rounding=blanket_line.product_uom.rounding, + ) + <= 0 + ): + call_off_line.blanket_line_id = blanket_line + qty_to_deliver = 0 + break + # The quantity to deliver is greater than the remaining quantity + # on this blanket line. We split the call-off line into a new line + # which will consume the remaining quantity on this blanket line. + # The remaining quantity will be consumed by the next blanket line. + qty_deliverable = blanket_line.call_off_remaining_qty + if not float_is_zero( + qty_deliverable, + precision_rounding=call_off_line.product_uom.rounding, + ): + call_off_line = call_off_line.with_context( + call_off_split_process=True + ) + qty_to_deliver -= qty_deliverable + call_off_line.product_uom_qty -= qty_deliverable + # we force the state to draft to avoid the launch of the stock rule at copy + new_call_off_line = call_off_line.copy( + default={ + "product_uom_qty": qty_deliverable, + "order_id": call_off_line.order_id.id, + } + ) + new_call_off_line.blanket_line_id = blanket_line + new_call_off_line.state = call_off_line.state + if not float_is_zero( + qty_to_deliver, + precision_rounding=call_off_line.product_uom.rounding, + ): + raise ValueError( + "The quantity to deliver on the call-off order line " + "is greater than the quantity remaining to deliver on " + "the blanket order line." + ) + def _launch_stock_rule_on_blanket_order(self, previous_product_uom_qty): for line in self: line = line.with_context(call_off_sale_line_id=line.id) @@ -401,40 +467,15 @@ def _stock_rule_on_blanket_with_reservation_at_call_off( """ self.ensure_one() qty_to_deliver = self.product_uom_qty - blanket_lines = self.order_id.blanket_order_id.order_line.filtered( - lambda l: l.product_id == self.product_id + blanket_line = self.blanket_line_id + wizard = ( + self.env["manual.delivery"] + .with_context( + active_id=blanket_line.id, + active_model="sale.order.line", + active_ids=blanket_line.ids, + ) + .create({}) ) - for blanket_line in blanket_lines: - if ( - float_compare( - qty_to_deliver, - blanket_line.call_off_remaining_qty, - precision_rounding=blanket_line.product_uom.rounding, - ) - <= 0 - ): - wizard = ( - self.env["manual.delivery"] - .with_context( - active_id=blanket_line.id, - active_model="sale.order.line", - active_ids=blanket_line.ids, - ) - .create({}) - ) - wizard.line_ids.quantity = qty_to_deliver - wizard.confirm() - break - else: - wizard = ( - self.env["manual.delivery"] - .with_context( - active_id=blanket_line.id, - active_model="sale.order.line", - active_ids=blanket_line.ids, - ) - .create({}) - ) - wizard.line_ids.quantity = blanket_line.call_off_remaining_qty - qty_to_deliver -= blanket_line.call_off_remaining_qty - wizard.confirm() + wizard.line_ids.quantity = qty_to_deliver + wizard.confirm() From 4ee59878c921b4702352a901e5cf8a897d867924 Mon Sep 17 00:00:00 2001 From: "Laurent Mignonn (ACSONE)" Date: Fri, 29 Nov 2024 14:50:09 +0100 Subject: [PATCH 11/17] rename --- .../README.rst | 8 ++++---- .../__init__.py | 0 .../__manifest__.py | 0 .../hooks.py | 0 .../models/__init__.py | 0 .../models/sale_order.py | 0 .../models/sale_order_line.py | 0 .../models/stock_move.py | 0 .../models/stock_rule.py | 0 .../readme/CONTEXT.md | 0 .../readme/CONTRIBUTORS.md | 0 .../readme/CREDITS.md | 0 .../readme/DESCRIPTION.md | 0 .../static/description/icon.png | Bin .../static/description/index.html | 6 +++--- .../tests/__init__.py | 0 .../tests/common.py | 0 .../tests/test_sale_blanket_order.py | 0 .../tests/test_sale_call_off_order.py | 0 .../views/sale_order.xml | 0 .../odoo/addons/sale_framework | 0 .../setup.py | 0 22 files changed, 7 insertions(+), 7 deletions(-) rename {sale_framework => sale_order_blanket_order}/README.rst (95%) rename {sale_framework => sale_order_blanket_order}/__init__.py (100%) rename {sale_framework => sale_order_blanket_order}/__manifest__.py (100%) rename {sale_framework => sale_order_blanket_order}/hooks.py (100%) rename {sale_framework => sale_order_blanket_order}/models/__init__.py (100%) rename {sale_framework => sale_order_blanket_order}/models/sale_order.py (100%) rename {sale_framework => sale_order_blanket_order}/models/sale_order_line.py (100%) rename {sale_framework => sale_order_blanket_order}/models/stock_move.py (100%) rename {sale_framework => sale_order_blanket_order}/models/stock_rule.py (100%) rename {sale_framework => sale_order_blanket_order}/readme/CONTEXT.md (100%) rename {sale_framework => sale_order_blanket_order}/readme/CONTRIBUTORS.md (100%) rename {sale_framework => sale_order_blanket_order}/readme/CREDITS.md (100%) rename {sale_framework => sale_order_blanket_order}/readme/DESCRIPTION.md (100%) rename {sale_framework => sale_order_blanket_order}/static/description/icon.png (100%) rename {sale_framework => sale_order_blanket_order}/static/description/index.html (94%) rename {sale_framework => sale_order_blanket_order}/tests/__init__.py (100%) rename {sale_framework => sale_order_blanket_order}/tests/common.py (100%) rename {sale_framework => sale_order_blanket_order}/tests/test_sale_blanket_order.py (100%) rename {sale_framework => sale_order_blanket_order}/tests/test_sale_call_off_order.py (100%) rename {sale_framework => sale_order_blanket_order}/views/sale_order.xml (100%) rename setup/{sale_framework => sale_order_blanket_order}/odoo/addons/sale_framework (100%) rename setup/{sale_framework => sale_order_blanket_order}/setup.py (100%) diff --git a/sale_framework/README.rst b/sale_order_blanket_order/README.rst similarity index 95% rename from sale_framework/README.rst rename to sale_order_blanket_order/README.rst index a6b15aa414a..7ef4e10a447 100644 --- a/sale_framework/README.rst +++ b/sale_order_blanket_order/README.rst @@ -17,10 +17,10 @@ Sale Framework :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsale--workflow-lightgray.png?logo=github - :target: https://github.com/OCA/sale-workflow/tree/16.0/sale_framework + :target: https://github.com/OCA/sale-workflow/tree/16.0/sale_order_blanket_order :alt: OCA/sale-workflow .. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png - :target: https://translation.odoo-community.org/projects/sale-workflow-16-0/sale-workflow-16-0-sale_framework + :target: https://translation.odoo-community.org/projects/sale-workflow-16-0/sale-workflow-16-0-sale_order_blanket_order :alt: Translate me on Weblate .. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png :target: https://runboat.odoo-community.org/builds?repo=OCA/sale-workflow&target_branch=16.0 @@ -163,7 +163,7 @@ Bug Tracker Bugs are tracked on `GitHub Issues `_. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -`feedback `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -202,6 +202,6 @@ OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use. -This module is part of the `OCA/sale-workflow `_ project on GitHub. +This module is part of the `OCA/sale-workflow `_ project on GitHub. You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/sale_framework/__init__.py b/sale_order_blanket_order/__init__.py similarity index 100% rename from sale_framework/__init__.py rename to sale_order_blanket_order/__init__.py diff --git a/sale_framework/__manifest__.py b/sale_order_blanket_order/__manifest__.py similarity index 100% rename from sale_framework/__manifest__.py rename to sale_order_blanket_order/__manifest__.py diff --git a/sale_framework/hooks.py b/sale_order_blanket_order/hooks.py similarity index 100% rename from sale_framework/hooks.py rename to sale_order_blanket_order/hooks.py diff --git a/sale_framework/models/__init__.py b/sale_order_blanket_order/models/__init__.py similarity index 100% rename from sale_framework/models/__init__.py rename to sale_order_blanket_order/models/__init__.py diff --git a/sale_framework/models/sale_order.py b/sale_order_blanket_order/models/sale_order.py similarity index 100% rename from sale_framework/models/sale_order.py rename to sale_order_blanket_order/models/sale_order.py diff --git a/sale_framework/models/sale_order_line.py b/sale_order_blanket_order/models/sale_order_line.py similarity index 100% rename from sale_framework/models/sale_order_line.py rename to sale_order_blanket_order/models/sale_order_line.py diff --git a/sale_framework/models/stock_move.py b/sale_order_blanket_order/models/stock_move.py similarity index 100% rename from sale_framework/models/stock_move.py rename to sale_order_blanket_order/models/stock_move.py diff --git a/sale_framework/models/stock_rule.py b/sale_order_blanket_order/models/stock_rule.py similarity index 100% rename from sale_framework/models/stock_rule.py rename to sale_order_blanket_order/models/stock_rule.py diff --git a/sale_framework/readme/CONTEXT.md b/sale_order_blanket_order/readme/CONTEXT.md similarity index 100% rename from sale_framework/readme/CONTEXT.md rename to sale_order_blanket_order/readme/CONTEXT.md diff --git a/sale_framework/readme/CONTRIBUTORS.md b/sale_order_blanket_order/readme/CONTRIBUTORS.md similarity index 100% rename from sale_framework/readme/CONTRIBUTORS.md rename to sale_order_blanket_order/readme/CONTRIBUTORS.md diff --git a/sale_framework/readme/CREDITS.md b/sale_order_blanket_order/readme/CREDITS.md similarity index 100% rename from sale_framework/readme/CREDITS.md rename to sale_order_blanket_order/readme/CREDITS.md diff --git a/sale_framework/readme/DESCRIPTION.md b/sale_order_blanket_order/readme/DESCRIPTION.md similarity index 100% rename from sale_framework/readme/DESCRIPTION.md rename to sale_order_blanket_order/readme/DESCRIPTION.md diff --git a/sale_framework/static/description/icon.png b/sale_order_blanket_order/static/description/icon.png similarity index 100% rename from sale_framework/static/description/icon.png rename to sale_order_blanket_order/static/description/icon.png diff --git a/sale_framework/static/description/index.html b/sale_order_blanket_order/static/description/index.html similarity index 94% rename from sale_framework/static/description/index.html rename to sale_order_blanket_order/static/description/index.html index a68b977930b..5a23e125bfe 100644 --- a/sale_framework/static/description/index.html +++ b/sale_order_blanket_order/static/description/index.html @@ -369,7 +369,7 @@

Sale Framework

!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! source digest: sha256:d0b6a096d18178fbd15da557bf07466d1ff491c59a2b56a17781a6d020da21dd !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

Beta License: AGPL-3 OCA/sale-workflow Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/sale-workflow Translate me on Weblate Try me on Runboat

This module extends the functionality of Sale Order to support Blanket Order and Call of Order.

@@ -489,7 +489,7 @@

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -feedback.

+feedback.

Do not contact contributors directly about support or help with technical issues.

@@ -524,7 +524,7 @@

Maintainers

OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.

-

This module is part of the OCA/sale-workflow project on GitHub.

+

This module is part of the OCA/sale-workflow project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

diff --git a/sale_framework/tests/__init__.py b/sale_order_blanket_order/tests/__init__.py similarity index 100% rename from sale_framework/tests/__init__.py rename to sale_order_blanket_order/tests/__init__.py diff --git a/sale_framework/tests/common.py b/sale_order_blanket_order/tests/common.py similarity index 100% rename from sale_framework/tests/common.py rename to sale_order_blanket_order/tests/common.py diff --git a/sale_framework/tests/test_sale_blanket_order.py b/sale_order_blanket_order/tests/test_sale_blanket_order.py similarity index 100% rename from sale_framework/tests/test_sale_blanket_order.py rename to sale_order_blanket_order/tests/test_sale_blanket_order.py diff --git a/sale_framework/tests/test_sale_call_off_order.py b/sale_order_blanket_order/tests/test_sale_call_off_order.py similarity index 100% rename from sale_framework/tests/test_sale_call_off_order.py rename to sale_order_blanket_order/tests/test_sale_call_off_order.py diff --git a/sale_framework/views/sale_order.xml b/sale_order_blanket_order/views/sale_order.xml similarity index 100% rename from sale_framework/views/sale_order.xml rename to sale_order_blanket_order/views/sale_order.xml diff --git a/setup/sale_framework/odoo/addons/sale_framework b/setup/sale_order_blanket_order/odoo/addons/sale_framework similarity index 100% rename from setup/sale_framework/odoo/addons/sale_framework rename to setup/sale_order_blanket_order/odoo/addons/sale_framework diff --git a/setup/sale_framework/setup.py b/setup/sale_order_blanket_order/setup.py similarity index 100% rename from setup/sale_framework/setup.py rename to setup/sale_order_blanket_order/setup.py From 2977f3d14766118087b984ed899c6d7fe4081eb4 Mon Sep 17 00:00:00 2001 From: "Laurent Mignonn (ACSONE)" Date: Fri, 29 Nov 2024 15:56:56 +0100 Subject: [PATCH 12/17] wip --- sale_order_blanket_order/__manifest__.py | 1 - sale_order_blanket_order/models/sale_order.py | 21 +------- .../models/sale_order_line.py | 32 +----------- sale_order_blanket_order/tests/common.py | 34 +----------- .../tests/test_sale_blanket_order.py | 30 +++-------- .../tests/test_sale_call_off_order.py | 52 +++++++++++-------- .../odoo/addons/sale_order_blanket_order | 1 + 7 files changed, 42 insertions(+), 129 deletions(-) create mode 120000 setup/sale_order_blanket_order/odoo/addons/sale_order_blanket_order diff --git a/sale_order_blanket_order/__manifest__.py b/sale_order_blanket_order/__manifest__.py index ab07f295bb9..479b1dd01e0 100644 --- a/sale_order_blanket_order/__manifest__.py +++ b/sale_order_blanket_order/__manifest__.py @@ -10,7 +10,6 @@ "website": "https://github.com/OCA/sale-workflow", "depends": [ "sale_manual_delivery", - "sale_stock_prebook", ], "data": [ #'views/sale_order.xml', diff --git a/sale_order_blanket_order/models/sale_order.py b/sale_order_blanket_order/models/sale_order.py index e6b243423a7..3a53a13febe 100644 --- a/sale_order_blanket_order/models/sale_order.py +++ b/sale_order_blanket_order/models/sale_order.py @@ -54,7 +54,6 @@ class SaleOrder(models.Model): ) blanket_reservation_strategy = fields.Selection( [ - ("at_confirm", "At Order Confirmation"), ("at_call_off", "At Call-off"), ], string="Reservation Strategy", @@ -164,9 +163,7 @@ def _compute_blanket_reservation_strategy(self): if order.state != "draft": continue if order.order_type == "blanket" and not order.blanket_reservation_strategy: - order.blanket_reservation_strategy = "at_confirm" - if order.order_type != "blanket" and order.blanket_reservation_strategy: - order.blanket_reservation_strategy = False + order.blanket_reservation_strategy = "at_call_off" @api.depends("call_off_order_ids") def _compute_call_off_order_count(self): @@ -251,12 +248,9 @@ def _on_call_off_order_confirm(self): def _blanket_order_reserve_stock(self): """Reserve the stock for the blanket order.""" - to_reserve_at_confirm = self.browse() to_reserve_at_call_off = self.browse() for order in self: - if order.blanket_reservation_strategy == "at_confirm": - to_reserve_at_confirm |= order - elif order.blanket_reservation_strategy == "at_call_off": + if order.blanket_reservation_strategy == "at_call_off": to_reserve_at_call_off |= order else: raise ValidationError( @@ -265,19 +259,8 @@ def _blanket_order_reserve_stock(self): ref=order.name, ) ) - to_reserve_at_confirm._prebook_stock() to_reserve_at_call_off._delay_delivery() - def _prebook_stock(self): - """Prebook the stock for the order.""" - self = self.with_context(sale_stock_prebook_stop_proc_run=True) - procurements = [] - for order in self: - group = order._create_reserve_procurement_group() - procurements += order.order_line._prepare_reserve_procurements(group) - if procurements: - self.env["procurement.group"].run(procurements) - def _delay_delivery(self): """Delay the delivery of the order. diff --git a/sale_order_blanket_order/models/sale_order_line.py b/sale_order_blanket_order/models/sale_order_line.py index 3ae6854ee51..a361175a584 100644 --- a/sale_order_blanket_order/models/sale_order_line.py +++ b/sale_order_blanket_order/models/sale_order_line.py @@ -420,43 +420,13 @@ def _launch_stock_rule_on_blanket_order(self, previous_product_uom_qty): blanket_order = line.order_id.blanket_order_id if not blanket_order: raise ValueError("A call-off order must have a blanket order.") - if blanket_order.blanket_reservation_strategy == "at_confirm": - line._stock_rule_on_blanket_with_reservation_at_confirm( - previous_product_uom_qty - ) - elif blanket_order.blanket_reservation_strategy == "at_call_off": + if blanket_order.blanket_reservation_strategy == "at_call_off": line._stock_rule_on_blanket_with_reservation_at_call_off( previous_product_uom_qty ) else: raise ValueError("Invalid blanket reservation strategy.") - def _stock_rule_on_blanket_with_reservation_at_confirm( - self, previous_product_uom_qty - ): - """In case of a blanket order with reservation at confirm, we use the manual delivery - wizard to launch the stock rule on the blanket order lines. - """ - self.ensure_one() - qty_to_deliver = self.product_uom_qty - blanket_lines = self.order_id.blanket_order_id.order_line.filtered( - lambda l: l.product_id == self.product_id - ) - for blanket_line in blanket_lines: - if ( - float_compare( - qty_to_deliver, - blanket_line.call_off_remaining_qty, - precision_rounding=blanket_line.product_uom.rounding, - ) - <= 0 - ): - # TODO - break - else: - # TODO - qty_to_deliver -= blanket_line.call_off_remaining_qty - def _stock_rule_on_blanket_with_reservation_at_call_off( self, previous_product_uom_qty ): diff --git a/sale_order_blanket_order/tests/common.py b/sale_order_blanket_order/tests/common.py index 93736907235..616b2e22e2d 100644 --- a/sale_order_blanket_order/tests/common.py +++ b/sale_order_blanket_order/tests/common.py @@ -35,39 +35,7 @@ def setUpClass(cls): ) cls._set_qty_in_loc_only(cls.product_1, 1000) cls._set_qty_in_loc_only(cls.product_2, 2000) - cls.blanket_so_with_reservation = cls.env["sale.order"].create( - { - "order_type": "blanket", - "partner_id": cls.partner.id, - "blanket_validity_start_date": "2024-01-01", - "blanket_validity_end_date": "2024-12-31", - "blanket_reservation_strategy": "at_confirm", - "order_line": [ - Command.create( - { - "product_id": cls.product_1.id, - "product_uom_qty": 10.0, - "price_unit": 100.0, - } - ), - Command.create( - { - "product_id": cls.product_1.id, - "product_uom_qty": 10.0, - "price_unit": 100.0, - } - ), - Command.create( - { - "product_id": cls.product_2.id, - "product_uom_qty": 10.0, - "price_unit": 200.0, - } - ), - ], - } - ) - cls.blanket_so_no_reservation = cls.env["sale.order"].create( + cls.blanket_so = cls.env["sale.order"].create( { "order_type": "blanket", "partner_id": cls.partner.id, diff --git a/sale_order_blanket_order/tests/test_sale_blanket_order.py b/sale_order_blanket_order/tests/test_sale_blanket_order.py index f79ffeb33b4..f663f0e1479 100644 --- a/sale_order_blanket_order/tests/test_sale_blanket_order.py +++ b/sale_order_blanket_order/tests/test_sale_blanket_order.py @@ -58,7 +58,7 @@ def test_no_product_overlap(self): ValidationError, ( "The product 'Product 2' is already part of another blanket order " - f"{self.blanket_so_with_reservation.name}." + f"{self.blanket_so.name}." ), ): self.env["sale.order"].create( @@ -75,28 +75,12 @@ def test_no_product_overlap(self): } ) - def test_reservation_at_confirm(self): - # Confirm the blanket order with reservation at confirm - self.blanket_so_with_reservation.action_confirm() - self.assertEqual(self.blanket_so_with_reservation.state, "sale") - self.assertEqual( - self.blanket_so_with_reservation.commitment_date.date(), - self.blanket_so_with_reservation.blanket_validity_start_date, - ) - self.assertTrue( - all( - self.blanket_so_with_reservation.order_line.move_ids.mapped( - "used_for_sale_reservation" - ) - ) - ) - - def test_reservation_at_call_off(self): + def test_reservation(self): # Confirm the blanket order with reservation at call off - self.blanket_so_no_reservation.action_confirm() - self.assertEqual(self.blanket_so_no_reservation.state, "sale") + self.blanket_so.action_confirm() + self.assertEqual(self.blanket_so.state, "sale") self.assertEqual( - self.blanket_so_no_reservation.commitment_date.date(), - self.blanket_so_no_reservation.blanket_validity_start_date, + self.blanket_so.commitment_date.date(), + self.blanket_so.blanket_validity_start_date, ) - self.assertFalse(self.blanket_so_no_reservation.order_line.move_ids) + self.assertFalse(self.blanket_so.order_line.move_ids) diff --git a/sale_order_blanket_order/tests/test_sale_call_off_order.py b/sale_order_blanket_order/tests/test_sale_call_off_order.py index 7f4daed3d73..dcab4351457 100644 --- a/sale_order_blanket_order/tests/test_sale_call_off_order.py +++ b/sale_order_blanket_order/tests/test_sale_call_off_order.py @@ -37,11 +37,11 @@ def test_order_constrains(self): { "order_type": "call_off", "partner_id": self.partner.id, - "blanket_order_id": self.blanket_so_no_reservation.id, + "blanket_order_id": self.blanket_so.id, } ) - self.blanket_so_no_reservation.action_confirm() + self.blanket_so.action_confirm() with self.assertRaisesRegex( ValidationError, ( @@ -54,7 +54,7 @@ def test_order_constrains(self): "order_type": "call_off", "date_order": "2024-01-01", "partner_id": self.partner.id, - "blanket_order_id": self.blanket_so_no_reservation.id, + "blanket_order_id": self.blanket_so.id, } ) @@ -63,19 +63,19 @@ def test_order_constrains(self): "order_type": "call_off", "date_order": "2025-02-01", "partner_id": self.partner.id, - "blanket_order_id": self.blanket_so_no_reservation.id, + "blanket_order_id": self.blanket_so.id, } ) self.assertTrue(order) def test_order_line_constrains(self): - self.blanket_so_no_reservation.action_confirm() + self.blanket_so.action_confirm() order = self.env["sale.order"].create( { "order_type": "call_off", "date_order": "2025-02-01", "partner_id": self.partner.id, - "blanket_order_id": self.blanket_so_no_reservation.id, + "blanket_order_id": self.blanket_so.id, } ) with self.assertRaisesRegex( @@ -116,13 +116,13 @@ def test_order_line_constrains(self): ) def test_order_line_attributes(self): - self.blanket_so_no_reservation.action_confirm() + self.blanket_so.action_confirm() order = self.env["sale.order"].create( { "order_type": "call_off", "date_order": "2025-02-01", "partner_id": self.partner.id, - "blanket_order_id": self.blanket_so_no_reservation.id, + "blanket_order_id": self.blanket_so.id, "order_line": [ Command.create( { @@ -133,7 +133,7 @@ def test_order_line_attributes(self): ], } ) - blanket_line = self.blanket_so_no_reservation.order_line.filtered( + blanket_line = self.blanket_so.order_line.filtered( lambda l: l.product_id == self.product_1 )[0] self.assertRecordValues( @@ -177,16 +177,15 @@ class TestSaleCallOffOrderProcessing(SaleFrameworkCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.blanket_so_no_reservation.action_confirm() - cls.blanket_so_with_reservation.action_confirm() + cls.blanket_so.action_confirm() - def test_no_reservation_processing(self): + def test_processing(self): # Create a call-off order without reservation order = self.env["sale.order"].create( { "order_type": "call_off", "partner_id": self.partner.id, - "blanket_order_id": self.blanket_so_no_reservation.id, + "blanket_order_id": self.blanket_so.id, "order_line": [ Command.create( { @@ -232,7 +231,8 @@ def test_no_reservation_processing(self): self.assertTrue(line.blanket_move_ids) sale_line = line.blanket_move_ids.sale_line_id self.assertEqual(sale_line.product_id, line.product_id) - self.assertEqual(sale_line.order_id, self.blanket_so_no_reservation) + self.assertEqual(sale_line.order_id, self.blanket_so) + self.assertEqual(line.blanket_line_id, sale_line) # process the picking picking = line.blanket_move_ids.picking_id @@ -241,7 +241,7 @@ def test_no_reservation_processing(self): move_line.qty_done = move_line.reserved_uom_qty picking._action_done() - blanket_lines = self.blanket_so_no_reservation.order_line + blanket_lines = self.blanket_so.order_line # part of the quantity into the blanket order are now delivered for product in [self.product_1, self.product_2]: @@ -267,7 +267,7 @@ def test_no_reservation_processing_2(self): { "order_type": "call_off", "partner_id": self.partner.id, - "blanket_order_id": self.blanket_so_no_reservation.id, + "blanket_order_id": self.blanket_so.id, "order_line": [ Command.create( { @@ -288,12 +288,20 @@ def test_no_reservation_processing_2(self): move_line.qty_done = move_line.reserved_uom_qty picking._action_done() - blanket_lines = self.blanket_so_no_reservation.order_line + # part of the quantity into the blanket order are now delivered + blanket_lines = self.blanket_so.order_line.filtered( + lambda l: l.product_id == self.product_1 + ) + self.assertEqual(len(blanket_lines), 2) self.assertEqual( - sum( - blanket_lines.filtered(lambda l: l.product_id == self.product_1).mapped( - "qty_delivered" - ) - ), + sum(blanket_lines.mapped("qty_delivered")), 15.0, ) + + # the call-off order line has been split into 2 lines, each one linked to + # a different blanket order line + self.assertEqual(len(order.order_line), 2) + self.assertEqual( + order.order_line.blanket_line_id, + blanket_lines, + ) diff --git a/setup/sale_order_blanket_order/odoo/addons/sale_order_blanket_order b/setup/sale_order_blanket_order/odoo/addons/sale_order_blanket_order new file mode 120000 index 00000000000..dbc0f388e1c --- /dev/null +++ b/setup/sale_order_blanket_order/odoo/addons/sale_order_blanket_order @@ -0,0 +1 @@ +../../../../sale_order_blanket_order \ No newline at end of file From 2ffc23fc96ce0615a16a4302502c0b494d222dfd Mon Sep 17 00:00:00 2001 From: "Laurent Mignonn (ACSONE)" Date: Mon, 2 Dec 2024 10:20:18 +0100 Subject: [PATCH 13/17] wip --- sale_order_blanket_order/README.rst | 25 +- .../models/sale_order_line.py | 8 +- .../readme/DESCRIPTION.md | 9 +- .../static/description/index.html | 25 +- sale_order_blanket_order/tests/common.py | 2 +- .../tests/test_sale_blanket_order.py | 4 +- .../tests/test_sale_call_off_order.py | 6 +- .../README.rst | 108 +++++ .../__init__.py | 1 + .../__manifest__.py | 17 + .../models/__init__.py | 2 + .../models/sale_order.py | 35 ++ .../models/sale_order_line.py | 85 ++++ .../readme/CONTEXT.md | 4 + .../readme/CONTRIBUTORS.md | 2 + .../readme/CREDITS.md | 3 + .../readme/DESCRIPTION.md | 2 + .../readme/USAGE.md | 2 + .../static/description/icon.png | Bin 0 -> 9455 bytes .../static/description/index.html | 455 ++++++++++++++++++ .../tests/__init__.py | 2 + .../tests/common.py | 12 + .../tests/test_sale_blanket_order.py | 19 + .../tests/test_sale_call_off_order.py | 133 +++++ .../sale_order_blanket_order_stock_prebook | 1 + .../setup.py | 6 + 26 files changed, 937 insertions(+), 31 deletions(-) create mode 100644 sale_order_blanket_order_stock_prebook/README.rst create mode 100644 sale_order_blanket_order_stock_prebook/__init__.py create mode 100644 sale_order_blanket_order_stock_prebook/__manifest__.py create mode 100644 sale_order_blanket_order_stock_prebook/models/__init__.py create mode 100644 sale_order_blanket_order_stock_prebook/models/sale_order.py create mode 100644 sale_order_blanket_order_stock_prebook/models/sale_order_line.py create mode 100644 sale_order_blanket_order_stock_prebook/readme/CONTEXT.md create mode 100644 sale_order_blanket_order_stock_prebook/readme/CONTRIBUTORS.md create mode 100644 sale_order_blanket_order_stock_prebook/readme/CREDITS.md create mode 100644 sale_order_blanket_order_stock_prebook/readme/DESCRIPTION.md create mode 100644 sale_order_blanket_order_stock_prebook/readme/USAGE.md create mode 100644 sale_order_blanket_order_stock_prebook/static/description/icon.png create mode 100644 sale_order_blanket_order_stock_prebook/static/description/index.html create mode 100644 sale_order_blanket_order_stock_prebook/tests/__init__.py create mode 100644 sale_order_blanket_order_stock_prebook/tests/common.py create mode 100644 sale_order_blanket_order_stock_prebook/tests/test_sale_blanket_order.py create mode 100644 sale_order_blanket_order_stock_prebook/tests/test_sale_call_off_order.py create mode 120000 setup/sale_order_blanket_order_stock_prebook/odoo/addons/sale_order_blanket_order_stock_prebook create mode 100644 setup/sale_order_blanket_order_stock_prebook/setup.py diff --git a/sale_order_blanket_order/README.rst b/sale_order_blanket_order/README.rst index 7ef4e10a447..7af5d748fd1 100644 --- a/sale_order_blanket_order/README.rst +++ b/sale_order_blanket_order/README.rst @@ -56,14 +56,21 @@ Stock Management ---------------- Delivered quantities are tracked on the sales order lines as with -regular sales orders. The goods are not delivered upon confirmation of -the blanket order, but stock can be reserved for the customer using OCA -modules: - -- **sale_manual_delivery:** Marks sales orders as "manual delivery," - preventing stock reservation or delivery preparation. -- **sale_stock_prebook:** Marks sales orders as "to reserve," triggering - stock reservation but blocking delivery preparation for such cases. +regular sales orders. By default, the stock is not reserved upon +confirmation of the blanket order. This is achieved by using the OCA +module +`sale_manual_delivery `__. +As a result, the stock will be reserved only when a call-off order is +created for the quantity to be delivered. + +In some cases, you may want to reserve stock upon confirmation of the +blanket order. This can be achieved by using the OCA module +`sale_order_blanket_order_stock_prebook `__. +This module extends the functionality of Sale Blanket Order to support +the reservation of stock for future consumption by call-off orders. The +reservation is done at the time of the blanket order confirmation for a +consumption starting at the validity start date of the blanket order. +This behavior can be configured on the blanket order. Invoicing --------- @@ -72,7 +79,7 @@ Standard invoicing policies apply (e.g., invoice on order or on delivery). Payment terms are configurable per order. Prepayment can be enforced by configuring the invoicing policy at the order level using the OCA module -(sale_invoice_policy)[`https://pypi.org/project/odoo-addon-sale-invoice-policy/] `__. +`sale_invoice_policy `__. Consumption Management ---------------------- diff --git a/sale_order_blanket_order/models/sale_order_line.py b/sale_order_blanket_order/models/sale_order_line.py index a361175a584..a9f51f75555 100644 --- a/sale_order_blanket_order/models/sale_order_line.py +++ b/sale_order_blanket_order/models/sale_order_line.py @@ -174,7 +174,7 @@ def _check_blanket_product_not_overlapping(self): ) ) - @api.depends("call_off_line_ids", "order_id.order_type") + @api.depends("call_off_line_ids", "order_id.order_type", "call_off_line_ids.state") def _compute_call_off_remaining_qty(self): """Compute the quantity remaining to deliver for call-off order lines. @@ -340,7 +340,11 @@ def _action_launch_stock_rule(self, previous_product_uom_qty=False): # the stock rule is launched on a single blanket order line for the # quantity still to deliver on this line. # We must also take care of the reservation strategy of the blanket order. - call_off_lines = self.filtered(lambda l: l.order_id.order_type == "call_off") + call_off_lines = self.browse() + if not self.env.context.get("disable_call_off_stock_rule"): + call_off_lines = self.filtered( + lambda l: l.order_id.order_type == "call_off" + ) other_lines = self - call_off_lines res = super(SaleOrderLine, other_lines)._action_launch_stock_rule( previous_product_uom_qty diff --git a/sale_order_blanket_order/readme/DESCRIPTION.md b/sale_order_blanket_order/readme/DESCRIPTION.md index 327caee073b..de8e97785aa 100644 --- a/sale_order_blanket_order/readme/DESCRIPTION.md +++ b/sale_order_blanket_order/readme/DESCRIPTION.md @@ -16,14 +16,15 @@ The blanket order serves as the central element triggering stock management and ## Stock Management Delivered quantities are tracked on the sales order lines as with regular sales orders. -The goods are not delivered upon confirmation of the blanket order, but stock can be reserved for the customer using OCA modules: +By default, the stock is not reserved upon confirmation of the blanket order. This is +achieved by using the OCA module [sale_manual_delivery](https://pypi.org/project/odoo-addon-sale-manual-delivery/). As a result, the stock will be reserved only when a call-off order is created for the quantity to be delivered. -- **sale_manual_delivery:** Marks sales orders as "manual delivery," preventing stock reservation or delivery preparation. -- **sale_stock_prebook:** Marks sales orders as "to reserve," triggering stock reservation but blocking delivery preparation for such cases. +In some cases, you may want to reserve stock upon confirmation of the blanket order. This can be achieved by using the OCA module [sale_order_blanket_order_stock_prebook](https://pypi.org/project/odoo-addon-sale-order-blanket-order-stock-prebook/). This module extends the functionality of Sale Blanket Order to support the reservation of stock for future consumption by call-off orders. The reservation is done at the time of the blanket order confirmation for a consumption starting at the validity start date of the blanket order. +This behavior can be configured on the blanket order. ## Invoicing -Standard invoicing policies apply (e.g., invoice on order or on delivery). Payment terms are configurable per order. Prepayment can be enforced by configuring the invoicing policy at the order level using the OCA module (sale_invoice_policy)[https://pypi.org/project/odoo-addon-sale-invoice-policy/]. +Standard invoicing policies apply (e.g., invoice on order or on delivery). Payment terms are configurable per order. Prepayment can be enforced by configuring the invoicing policy at the order level using the OCA module [sale_invoice_policy](https://pypi.org/project/odoo-addon-sale-invoice-policy/). ## Consumption Management diff --git a/sale_order_blanket_order/static/description/index.html b/sale_order_blanket_order/static/description/index.html index 5a23e125bfe..b703378f9b3 100644 --- a/sale_order_blanket_order/static/description/index.html +++ b/sale_order_blanket_order/static/description/index.html @@ -394,15 +394,20 @@

Blanket Order

Stock Management

Delivered quantities are tracked on the sales order lines as with -regular sales orders. The goods are not delivered upon confirmation of -the blanket order, but stock can be reserved for the customer using OCA -modules:

-
    -
  • sale_manual_delivery: Marks sales orders as “manual delivery,” -preventing stock reservation or delivery preparation.
  • -
  • sale_stock_prebook: Marks sales orders as “to reserve,” triggering -stock reservation but blocking delivery preparation for such cases.
  • -
+regular sales orders. By default, the stock is not reserved upon +confirmation of the blanket order. This is achieved by using the OCA +module +sale_manual_delivery. +As a result, the stock will be reserved only when a call-off order is +created for the quantity to be delivered.

+

In some cases, you may want to reserve stock upon confirmation of the +blanket order. This can be achieved by using the OCA module +sale_order_blanket_order_stock_prebook. +This module extends the functionality of Sale Blanket Order to support +the reservation of stock for future consumption by call-off orders. The +reservation is done at the time of the blanket order confirmation for a +consumption starting at the validity start date of the blanket order. +This behavior can be configured on the blanket order.

Invoicing

@@ -410,7 +415,7 @@

Invoicing

delivery). Payment terms are configurable per order. Prepayment can be enforced by configuring the invoicing policy at the order level using the OCA module -(sale_invoice_policy)[https://pypi.org/project/odoo-addon-sale-invoice-policy/].

+sale_invoice_policy.

Consumption Management

diff --git a/sale_order_blanket_order/tests/common.py b/sale_order_blanket_order/tests/common.py index 616b2e22e2d..7b01d177db6 100644 --- a/sale_order_blanket_order/tests/common.py +++ b/sale_order_blanket_order/tests/common.py @@ -6,7 +6,7 @@ from odoo.addons.base.tests.common import BaseCommon -class SaleFrameworkCase(BaseCommon): +class SaleOrderBlanketOrderCase(BaseCommon): @classmethod def setUpClass(cls): """Setup the test diff --git a/sale_order_blanket_order/tests/test_sale_blanket_order.py b/sale_order_blanket_order/tests/test_sale_blanket_order.py index f663f0e1479..8d44b8d2aaa 100644 --- a/sale_order_blanket_order/tests/test_sale_blanket_order.py +++ b/sale_order_blanket_order/tests/test_sale_blanket_order.py @@ -3,10 +3,10 @@ from odoo import Command from odoo.exceptions import ValidationError -from .common import SaleFrameworkCase +from .common import SaleOrderBlanketOrderCase -class TestSaleBlanketOrder(SaleFrameworkCase): +class TestSaleBlanketOrder(SaleOrderBlanketOrderCase): def test_constrains(self): # Create a call-off order with self.assertRaisesRegex( diff --git a/sale_order_blanket_order/tests/test_sale_call_off_order.py b/sale_order_blanket_order/tests/test_sale_call_off_order.py index dcab4351457..de314dd95cc 100644 --- a/sale_order_blanket_order/tests/test_sale_call_off_order.py +++ b/sale_order_blanket_order/tests/test_sale_call_off_order.py @@ -5,10 +5,10 @@ from odoo import Command from odoo.exceptions import ValidationError -from .common import SaleFrameworkCase +from .common import SaleOrderBlanketOrderCase -class TestSaleCallOffOrder(SaleFrameworkCase): +class TestSaleCallOffOrder(SaleOrderBlanketOrderCase): def test_order_constrains(self): # Create a call-off order with self.assertRaisesRegex( @@ -173,7 +173,7 @@ def test_order_line_attributes(self): @freezegun.freeze_time("2025-02-01") -class TestSaleCallOffOrderProcessing(SaleFrameworkCase): +class TestSaleCallOffOrderProcessing(SaleOrderBlanketOrderCase): @classmethod def setUpClass(cls): super().setUpClass() diff --git a/sale_order_blanket_order_stock_prebook/README.rst b/sale_order_blanket_order_stock_prebook/README.rst new file mode 100644 index 00000000000..4c89b6a13f9 --- /dev/null +++ b/sale_order_blanket_order_stock_prebook/README.rst @@ -0,0 +1,108 @@ +================================ +Sale Blanket Order prebook stock +================================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:d0b6a096d18178fbd15da557bf07466d1ff491c59a2b56a17781a6d020da21dd + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsale--workflow-lightgray.png?logo=github + :target: https://github.com/OCA/sale-workflow/tree/16.0/sale_order_blanket_order_stock_prebook + :alt: OCA/sale-workflow +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/sale-workflow-16-0/sale-workflow-16-0-sale_order_blanket_order_stock_prebook + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/sale-workflow&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module extends the functionality of Sale Blanket Order to support +the reservation of stock for future consumption by call-off orders and +therefore ensures that the quantities of the products to be delivered +are available when needed. + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +Blanket orders are useful to manage the sales of the same products to +the same customers under the same conditions over a period of time. In +some cases, such a contract may also include the securement of the +quantities of the products to be delivered. + +This is achieved by using the OCA module +`sale_stock_prebook `__. + +Usage +===== + +When you create a blanket order, you can choose a reservation strategy +to apply to the products of the order. With this addon installed, a new +strategy is available: "At Confirm". If you choose this strategy, the +stock will be reserved at the time of the blanket order confirmation for +a consumption starting at the validity start date of the blanket order. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* ACSONE SA/NV +* BCIM + +Contributors +------------ + +- Laurent Mignon\ laurent.mignon@acsone.eu (https://www.acsone.eu) +- Jacques-Etienne Baudoux (BCIM) je@bcim.be + +Other credits +------------- + +The development of this module has been financially supported by: + +- ALCYON Belux + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/sale-workflow `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/sale_order_blanket_order_stock_prebook/__init__.py b/sale_order_blanket_order_stock_prebook/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/sale_order_blanket_order_stock_prebook/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/sale_order_blanket_order_stock_prebook/__manifest__.py b/sale_order_blanket_order_stock_prebook/__manifest__.py new file mode 100644 index 00000000000..b9f9ffa2e85 --- /dev/null +++ b/sale_order_blanket_order_stock_prebook/__manifest__.py @@ -0,0 +1,17 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Sale Blanket Order prebook stock", + "summary": """Allow to prebook stock for blanket order""", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "ACSONE SA/NV,BCIM,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/sale-workflow", + "depends": [ + "sale_order_blanket_order", + "sale_stock_prebook", + ], + "data": [], + "demo": [], +} diff --git a/sale_order_blanket_order_stock_prebook/models/__init__.py b/sale_order_blanket_order_stock_prebook/models/__init__.py new file mode 100644 index 00000000000..2d7ee6c3dc7 --- /dev/null +++ b/sale_order_blanket_order_stock_prebook/models/__init__.py @@ -0,0 +1,2 @@ +from . import sale_order +from . import sale_order_line diff --git a/sale_order_blanket_order_stock_prebook/models/sale_order.py b/sale_order_blanket_order_stock_prebook/models/sale_order.py new file mode 100644 index 00000000000..3ec0cd0bde0 --- /dev/null +++ b/sale_order_blanket_order_stock_prebook/models/sale_order.py @@ -0,0 +1,35 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + blanket_reservation_strategy = fields.Selection( + selection_add=[("at_confirm", "At Order Confirmation")], + ondelete={"at_confirm": "cascade"}, + ) + + def _blanket_order_reserve_stock(self): + """Reserve the stock for the blanket order.""" + other_orders = self.browse() + to_reserve_at_confirm = self.browse() + for order in self: + if order.blanket_reservation_strategy == "at_confirm": + to_reserve_at_confirm |= order + else: + other_orders |= order + to_reserve_at_confirm._prebook_stock() + return super(SaleOrder, other_orders)._blanket_order_reserve_stock() + + def _prebook_stock(self): + """Prebook the stock for the order.""" + self = self.with_context(sale_stock_prebook_stop_proc_run=True) + procurements = [] + for order in self: + group = order._create_reserve_procurement_group() + procurements += order.order_line._prepare_reserve_procurements(group) + if procurements: + self.env["procurement.group"].run(procurements) diff --git a/sale_order_blanket_order_stock_prebook/models/sale_order_line.py b/sale_order_blanket_order_stock_prebook/models/sale_order_line.py new file mode 100644 index 00000000000..9e8aa541daa --- /dev/null +++ b/sale_order_blanket_order_stock_prebook/models/sale_order_line.py @@ -0,0 +1,85 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models +from odoo.tools import float_compare + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + def _launch_stock_rule_on_blanket_order(self, previous_product_uom_qty): + other_lines = self.browse() + for line in self: + line = line.with_context(call_off_sale_line_id=line.id) + blanket_order = line.order_id.blanket_order_id + if blanket_order.blanket_reservation_strategy == "at_confirm": + line._stock_rule_on_blanket_with_reservation_at_confirm( + previous_product_uom_qty + ) + else: + other_lines |= line + return super(SaleOrderLine, other_lines)._launch_stock_rule_on_blanket_order( + previous_product_uom_qty + ) + + def _release_reservation(self): + """Release the reservation of the stock for the order.""" + self.move_ids.filtered(lambda m: m.used_for_sale_reservation)._action_cancel() + + def _prepare_reserve_procurements(self, group): + procurements = super()._prepare_reserve_procurements(group) + forced_qty = self.env.context.get("force_qty") + if forced_qty: + self.ensure_one() + proc = procurements[0] + proc = self.env["procurement.group"].Procurement( + proc.product_id, + forced_qty, + proc.product_uom, + proc.location_id, + proc.name, + proc.origin, + proc.company_id, + values=proc.values, + ) + procurements = [proc] + return procurements + + def _prebook_stock(self, qty): + """Prebook the stock for the order.""" + self = self.with_context(sale_stock_prebook_stop_proc_run=True) + procurements = [] + for line in self: + group = line.order_id._create_reserve_procurement_group() + procurements += line.with_context( + force_qty=qty + )._prepare_reserve_procurements(group) + if procurements: + self.env["procurement.group"].run(procurements) + return procurements + + def _stock_rule_on_blanket_with_reservation_at_confirm( + self, previous_product_uom_qty + ): + self.ensure_one() + blanket_line = self.blanket_line_id + blanket_line._release_reservation() + # Create a new reservation for the remaining quantity on the blanket order + # Since the call_off_remaining qty is computed from the qty consumed by the call off order, + # and the current line is part of this qty, it represents the real remaining qty to consume + # and therefore the qty to reserve on the blanket order. + remaining_qty = blanket_line.call_off_remaining_qty + if ( + float_compare( + remaining_qty, 0, precision_rounding=self.product_uom.rounding + ) + > 0 + ): + blanket_line._prebook_stock(remaining_qty) + + # run normal delivery rule on the blanket order. This will create the move on the call off + # order for the qty not reserved IOW the qty to deliver. + blanket_line.with_context( + disable_call_off_stock_rule=True + )._action_launch_stock_rule(previous_product_uom_qty) diff --git a/sale_order_blanket_order_stock_prebook/readme/CONTEXT.md b/sale_order_blanket_order_stock_prebook/readme/CONTEXT.md new file mode 100644 index 00000000000..cfa18de301c --- /dev/null +++ b/sale_order_blanket_order_stock_prebook/readme/CONTEXT.md @@ -0,0 +1,4 @@ +Blanket orders are useful to manage the sales of the same products to the same customers under the same conditions over a period of time. In some cases, such a contract may also +include the securement of the quantities of the products to be delivered. + +This is achieved by using the OCA module [sale_stock_prebook](https://pypi.org/project/odoo-addon-sale--stock-prebook/). diff --git a/sale_order_blanket_order_stock_prebook/readme/CONTRIBUTORS.md b/sale_order_blanket_order_stock_prebook/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..ede6aa0ae20 --- /dev/null +++ b/sale_order_blanket_order_stock_prebook/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Laurent Mignon (https://www.acsone.eu) +- Jacques-Etienne Baudoux (BCIM) diff --git a/sale_order_blanket_order_stock_prebook/readme/CREDITS.md b/sale_order_blanket_order_stock_prebook/readme/CREDITS.md new file mode 100644 index 00000000000..e7c5a535490 --- /dev/null +++ b/sale_order_blanket_order_stock_prebook/readme/CREDITS.md @@ -0,0 +1,3 @@ +The development of this module has been financially supported by: + +- ALCYON Belux diff --git a/sale_order_blanket_order_stock_prebook/readme/DESCRIPTION.md b/sale_order_blanket_order_stock_prebook/readme/DESCRIPTION.md new file mode 100644 index 00000000000..97486fcb01e --- /dev/null +++ b/sale_order_blanket_order_stock_prebook/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +This module extends the functionality of Sale Blanket Order to support the reservation of stock for future consumption by call-off orders and therefore ensures that the quantities of the products to be delivered are available when needed. + diff --git a/sale_order_blanket_order_stock_prebook/readme/USAGE.md b/sale_order_blanket_order_stock_prebook/readme/USAGE.md new file mode 100644 index 00000000000..336936df81c --- /dev/null +++ b/sale_order_blanket_order_stock_prebook/readme/USAGE.md @@ -0,0 +1,2 @@ +When you create a blanket order, you can choose a reservation strategy to apply to the products of the order. With this addon installed, a new strategy is available: "At Confirm". +If you choose this strategy, the stock will be reserved at the time of the blanket order confirmation for a consumption starting at the validity start date of the blanket order. \ No newline at end of file diff --git a/sale_order_blanket_order_stock_prebook/static/description/icon.png b/sale_order_blanket_order_stock_prebook/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z z?n?eq1XP>p-IM$Z^C;2L3itnbJZAip*Zo0aw2bs8@(s^~*8T9go!%dHcAz2lM;`yp zD=7&xjFV$S&5uDaiScyD?B-i1ze`+CoRtz`Wn+Zl&#s4&}MO{@N!ufrzjG$B79)Y2d3tBk&)TxUTw@QS0TEL_?njX|@vq?Uz(nBFK5Pq7*xj#u*R&i|?7+6# z+|r_n#SW&LXhtheZdah{ZVoqwyT{D>MC3nkFF#N)xLi{p7J1jXlmVeb;cP5?e(=f# zuT7fvjSbjS781v?7{)-X3*?>tq?)Yd)~|1{BDS(pqC zC}~H#WXlkUW*H5CDOo<)#x7%RY)A;ShGhI5s*#cRDA8YgqG(HeKDx+#(ZQ?386dv! zlXCO)w91~Vw4AmOcATuV653fa9R$fyK8ul%rG z-wfS zihugoZyr38Im?Zuh6@RcF~t1anQu7>#lPpb#}4cOA!EM11`%f*07RqOVkmX{p~KJ9 z^zP;K#|)$`^Rb{rnHGH{~>1(fawV0*Z#)}M`m8-?ZJV<+e}s9wE# z)l&az?w^5{)`S(%MRzxdNqrs1n*-=jS^_jqE*5XDrA0+VE`5^*p3CuM<&dZEeCjoz zR;uu_H9ZPZV|fQq`Cyw4nscrVwi!fE6ciMmX$!_hN7uF;jjKG)d2@aC4ropY)8etW=xJvni)8eHi`H$%#zn^WJ5NLc-rqk|u&&4Z6fD_m&JfSI1Bvb?b<*n&sfl0^t z=HnmRl`XrFvMKB%9}>PaA`m-fK6a0(8=qPkWS5bb4=v?XcWi&hRY?O5HdulRi4?fN zlsJ*N-0Qw+Yic@s0(2uy%F@ib;GjXt01Fmx5XbRo6+n|pP(&nodMoap^z{~q ziEeaUT@Mxe3vJSfI6?uLND(CNr=#^W<1b}jzW58bIfyWTDle$mmS(|x-0|2UlX+9k zQ^EX7Nw}?EzVoBfT(-LT|=9N@^hcn-_p&sqG z&*oVs2JSU+N4ZD`FhCAWaS;>|wH2G*Id|?pa#@>tyxX`+4HyIArWDvVrX)2WAOQff z0qyHu&-S@i^MS-+j--!pr4fPBj~_8({~e1bfcl0wI1kaoN>mJL6KUPQm5N7lB(ui1 zE-o%kq)&djzWJ}ob<-GfDlkB;F31j-VHKvQUGQ3sp`CwyGJk_i!y^sD0fqC@$9|jO zOqN!r!8-p==F@ZVP=U$qSpY(gQ0)59P1&t@y?5rvg<}E+GB}26NYPp4f2YFQrQtot5mn3wu_qprZ=>Ig-$ zbW26Ws~IgY>}^5w`vTB(G`PTZaDiGBo5o(tp)qli|NeV( z@H_=R8V39rt5J5YB2Ky?4eJJ#b`_iBe2ot~6%7mLt5t8Vwi^Jy7|jWXqa3amOIoRb zOr}WVFP--DsS`1WpN%~)t3R!arKF^Q$e12KEqU36AWwnCBICpH4XCsfnyrHr>$I$4 z!DpKX$OKLWarN7nv@!uIA+~RNO)l$$w}p(;b>mx8pwYvu;dD_unryX_NhT8*Tj>BTrTTL&!?O+%Rv;b?B??gSzdp?6Uug9{ zd@V08Z$BdI?fpoCS$)t4mg4rT8Q_I}h`0d-vYZ^|dOB*Q^S|xqTV*vIg?@fVFSmMpaw0qtTRbx} z({Pg?#{2`sc9)M5N$*N|4;^t$+QP?#mov zGVC@I*lBVrOU-%2y!7%)fAKjpEFsgQc4{amtiHb95KQEwvf<(3T<9-Zm$xIew#P22 zc2Ix|App^>v6(3L_MCU0d3W##AB0M~3D00EWoKZqsJYT(#@w$Y_H7G22M~ApVFTRHMI_3be)Lkn#0F*V8Pq zc}`Cjy$bE;FJ6H7p=0y#R>`}-m4(0F>%@P|?7fx{=R^uFdISRnZ2W_xQhD{YuR3t< z{6yxu=4~JkeA;|(J6_nv#>Nvs&FuLA&PW^he@t(UwFFE8)|a!R{`E`K`i^ZnyE4$k z;(749Ix|oi$c3QbEJ3b~D_kQsPz~fIUKym($a_7dJ?o+40*OLl^{=&oq$<#Q(yyrp z{J-FAniyAw9tPbe&IhQ|a`DqFTVQGQ&Gq3!C2==4x{6EJwiPZ8zub-iXoUtkJiG{} zPaR&}_fn8_z~(=;5lD-aPWD3z8PZS@AaUiomF!G8I}Mf>e~0g#BelA-5#`cj;O5>N Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 diff --git a/sale_order_blanket_order_stock_prebook/static/description/index.html b/sale_order_blanket_order_stock_prebook/static/description/index.html new file mode 100644 index 00000000000..7fa9c7d8f07 --- /dev/null +++ b/sale_order_blanket_order_stock_prebook/static/description/index.html @@ -0,0 +1,455 @@ + + + + + +Sale Blanket Order prebook stock + + + +
+

Sale Blanket Order prebook stock

+ + +

Beta License: AGPL-3 OCA/sale-workflow Translate me on Weblate Try me on Runboat

+

This module extends the functionality of Sale Blanket Order to support +the reservation of stock for future consumption by call-off orders and +therefore ensures that the quantities of the products to be delivered +are available when needed.

+

Table of contents

+ +
+

Use Cases / Context

+

Blanket orders are useful to manage the sales of the same products to +the same customers under the same conditions over a period of time. In +some cases, such a contract may also include the securement of the +quantities of the products to be delivered.

+

This is achieved by using the OCA module +sale_stock_prebook.

+
+
+

Usage

+

When you create a blanket order, you can choose a reservation strategy +to apply to the products of the order. With this addon installed, a new +strategy is available: “At Confirm”. If you choose this strategy, the +stock will be reserved at the time of the blanket order confirmation for +a consumption starting at the validity start date of the blanket order.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • ACSONE SA/NV
  • +
  • BCIM
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+

The development of this module has been financially supported by:

+
    +
  • ALCYON Belux
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/sale-workflow project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/sale_order_blanket_order_stock_prebook/tests/__init__.py b/sale_order_blanket_order_stock_prebook/tests/__init__.py new file mode 100644 index 00000000000..f7b4d47dc79 --- /dev/null +++ b/sale_order_blanket_order_stock_prebook/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_sale_blanket_order +from . import test_sale_call_off_order diff --git a/sale_order_blanket_order_stock_prebook/tests/common.py b/sale_order_blanket_order_stock_prebook/tests/common.py new file mode 100644 index 00000000000..60de477f8c2 --- /dev/null +++ b/sale_order_blanket_order_stock_prebook/tests/common.py @@ -0,0 +1,12 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from odoo.addons.sale_order_blanket_order.tests import common + + +class SaleOrderBlanketOrderCase(common.SaleOrderBlanketOrderCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.blanket_so.blanket_reservation_strategy = "at_confirm" diff --git a/sale_order_blanket_order_stock_prebook/tests/test_sale_blanket_order.py b/sale_order_blanket_order_stock_prebook/tests/test_sale_blanket_order.py new file mode 100644 index 00000000000..a35902e122c --- /dev/null +++ b/sale_order_blanket_order_stock_prebook/tests/test_sale_blanket_order.py @@ -0,0 +1,19 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from .common import SaleOrderBlanketOrderCase + + +class TestSaleBlanketOrder(SaleOrderBlanketOrderCase): + def test_reservation_at_confirm(self): + # Confirm the blanket order with reservation at confirm + self.blanket_so.action_confirm() + self.assertEqual(self.blanket_so.state, "sale") + self.assertEqual( + self.blanket_so.commitment_date.date(), + self.blanket_so.blanket_validity_start_date, + ) + self.assertTrue( + all(self.blanket_so.order_line.move_ids.mapped("used_for_sale_reservation")) + ) diff --git a/sale_order_blanket_order_stock_prebook/tests/test_sale_call_off_order.py b/sale_order_blanket_order_stock_prebook/tests/test_sale_call_off_order.py new file mode 100644 index 00000000000..4d72b209078 --- /dev/null +++ b/sale_order_blanket_order_stock_prebook/tests/test_sale_call_off_order.py @@ -0,0 +1,133 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import freezegun + +from odoo import Command + +from .common import SaleOrderBlanketOrderCase + + +@freezegun.freeze_time("2025-02-01") +class TestSaleCallOffOrderProcessing(SaleOrderBlanketOrderCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.blanket_so.action_confirm() + + def test_no_reservation_processing(self): + # Create a call-off order without reservation + order = self.env["sale.order"].create( + { + "order_type": "call_off", + "partner_id": self.partner.id, + "blanket_order_id": self.blanket_so.id, + "order_line": [ + Command.create( + { + "product_id": self.product_1.id, + "product_uom_qty": 10.0, + } + ), + Command.create( + { + "product_id": self.product_2.id, + "product_uom_qty": 10.0, + } + ), + ], + } + ) + order.action_confirm() + self.assertEqual(order.state, "sale") + self.assertRecordValues( + order.order_line, + [ + { + "product_uom_qty": 10.0, + "price_unit": 0.0, + "qty_to_deliver": 0.0, + "qty_to_invoice": 0.0, + "qty_delivered": 0.0, + "display_qty_widget": False, + }, + { + "product_uom_qty": 10.0, + "price_unit": 0.0, + "qty_to_deliver": 0.0, + "qty_to_invoice": 0.0, + "qty_delivered": 0.0, + "display_qty_widget": False, + }, + ], + ) + + # The lines should be linked to moves linked to a blanked order line + for line in order.order_line: + self.assertTrue(line.blanket_move_ids) + sale_line = line.blanket_move_ids.sale_line_id + self.assertEqual(sale_line.product_id, line.product_id) + self.assertEqual(sale_line.order_id, self.blanket_so) + + # process the picking + picking = line.blanket_move_ids.picking_id + picking.action_assign() + for move_line in picking.move_line_ids: + move_line.qty_done = move_line.reserved_uom_qty + picking._action_done() + + blanket_lines = self.blanket_so.order_line + + # part of the quantity into the blanket order are now delivered + for product in [self.product_1, self.product_2]: + self.assertEqual( + sum( + blanket_lines.filtered(lambda l: l.product_id == product).mapped( + "qty_delivered" + ) + ), + 10.0, + ) + + def test_no_reservation_processing_2(self): + # In this test we create a call-off order with 1 lines + # for product 1 where the quantity to deliver is greater + # than the quantity defined per line in the blanket order. + # On the blanket order we have 2 lines for product 1 with + # 10.0 quantity each. + # The call-off order will have 1 line for product 1 with + # 15.0 quantity. + + order = self.env["sale.order"].create( + { + "order_type": "call_off", + "partner_id": self.partner.id, + "blanket_order_id": self.blanket_so.id, + "order_line": [ + Command.create( + { + "product_id": self.product_1.id, + "product_uom_qty": 15.0, + } + ), + ], + } + ) + order.action_confirm() + self.assertEqual(order.state, "sale") + + # process the picking + picking = order.order_line.blanket_move_ids.picking_id + picking.action_assign() + for move_line in picking.move_line_ids: + move_line.qty_done = move_line.reserved_uom_qty + picking._action_done() + + blanket_lines = self.blanket_so.order_line + self.assertEqual( + sum( + blanket_lines.filtered(lambda l: l.product_id == self.product_1).mapped( + "qty_delivered" + ) + ), + 15.0, + ) diff --git a/setup/sale_order_blanket_order_stock_prebook/odoo/addons/sale_order_blanket_order_stock_prebook b/setup/sale_order_blanket_order_stock_prebook/odoo/addons/sale_order_blanket_order_stock_prebook new file mode 120000 index 00000000000..5ccadda80f4 --- /dev/null +++ b/setup/sale_order_blanket_order_stock_prebook/odoo/addons/sale_order_blanket_order_stock_prebook @@ -0,0 +1 @@ +../../../../sale_order_blanket_order_stock_prebook \ No newline at end of file diff --git a/setup/sale_order_blanket_order_stock_prebook/setup.py b/setup/sale_order_blanket_order_stock_prebook/setup.py new file mode 100644 index 00000000000..28c57bb6403 --- /dev/null +++ b/setup/sale_order_blanket_order_stock_prebook/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) From ec8284eaf053ec2bfbc27cc1f82b8c135b9d1c77 Mon Sep 17 00:00:00 2001 From: "Laurent Mignonn (ACSONE)" Date: Mon, 2 Dec 2024 14:32:52 +0100 Subject: [PATCH 14/17] wip --- sale_order_blanket_order/__manifest__.py | 3 +- sale_order_blanket_order/hooks.py | 11 +- sale_order_blanket_order/models/sale_order.py | 16 +- .../models/sale_order_line.py | 40 ++- sale_order_blanket_order/views/sale_order.xml | 284 +++++++++++++++++- .../views/sale_order_line.xml | 87 ++++++ 6 files changed, 417 insertions(+), 24 deletions(-) create mode 100644 sale_order_blanket_order/views/sale_order_line.xml diff --git a/sale_order_blanket_order/__manifest__.py b/sale_order_blanket_order/__manifest__.py index 479b1dd01e0..749ccb82780 100644 --- a/sale_order_blanket_order/__manifest__.py +++ b/sale_order_blanket_order/__manifest__.py @@ -12,7 +12,8 @@ "sale_manual_delivery", ], "data": [ - #'views/sale_order.xml', + "views/sale_order.xml", + "views/sale_order_line.xml", ], "demo": [], "pre_init_hook": "pre_init_hook", diff --git a/sale_order_blanket_order/hooks.py b/sale_order_blanket_order/hooks.py index 364822c445d..b1f752a62ff 100644 --- a/sale_order_blanket_order/hooks.py +++ b/sale_order_blanket_order/hooks.py @@ -4,9 +4,18 @@ def pre_init_hook(cr): - _logger.info("Create column order_type in sale_order wiht default value 'normal'") + _logger.info("Create column order_type in sale_order with default value 'normal'") cr.execute( "ALTER TABLE sale_order ADD COLUMN order_type varchar(255) DEFAULT 'normal'" ) # drop the default value since it was only used to fill the column in existing records cr.execute("ALTER TABLE sale_order ALTER COLUMN order_type DROP DEFAULT") + + _logger.info( + "Create column order_type in sale_order_line with default value 'normal'" + ) + cr.execute( + "ALTER TABLE sale_order_line ADD COLUMN order_type varchar(255) DEFAULT 'normal'" + ) + # drop the default value since it was only used to fill the column in existing records + cr.execute("ALTER TABLE sale_order_line ALTER COLUMN order_type DROP DEFAULT") diff --git a/sale_order_blanket_order/models/sale_order.py b/sale_order_blanket_order/models/sale_order.py index 3a53a13febe..8ae7633d16f 100644 --- a/sale_order_blanket_order/models/sale_order.py +++ b/sale_order_blanket_order/models/sale_order.py @@ -31,7 +31,7 @@ class SaleOrder(models.Model): string="Blanket Order", readonly=True, help="The blanket order that this call-off order is related to.", - index=True, + index="btree_not_null", ) call_off_order_ids = fields.One2many( "sale.order", @@ -183,6 +183,20 @@ def _compute_call_off_order_count(self): for order in self: order.call_off_order_count = count_by_blanket_order_id.get(order.id, 0) + def action_view_call_off_orders(self): + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "sale.sale_order_action_view_order_tree" + ) + action["domain"] = [("blanket_order_id", "=", self.id)] + action["context"] = dict( + self._context, + default_blanket_order_id=self.id, + default_partner_id=self.partner_id.id, + default_order_type="call_off", + ) + return action + def _action_confirm(self): # The confirmation process is different for each type of order # so we need to split the orders by type before processing them diff --git a/sale_order_blanket_order/models/sale_order_line.py b/sale_order_blanket_order/models/sale_order_line.py index a9f51f75555..bdbbfe6b3cd 100644 --- a/sale_order_blanket_order/models/sale_order_line.py +++ b/sale_order_blanket_order/models/sale_order_line.py @@ -10,6 +10,17 @@ class SaleOrderLine(models.Model): _inherit = "sale.order.line" + order_type = fields.Selection( + [ + ("normal", "Normal Sale Order"), + ("blanket", "Blanket Order"), + ("call_off", "Call-off Order"), + ], + related="order_id.order_type", + store=True, + precompute=True, + readonly=True, + ) blanket_move_ids = fields.One2many( "stock.move", "call_off_sale_line_id", @@ -19,6 +30,7 @@ class SaleOrderLine(models.Model): "sale.order.line", string="Blanket Order Line", help="The blanket order line corresponding to this call-off order line.", + index="btree_not_null", ) call_off_line_ids = fields.One2many( "sale.order.line", @@ -38,7 +50,7 @@ class SaleOrderLine(models.Model): @api.constrains( "state", "order_id.blanket_order_id", - "order_id.order_type", + "order_type", "product_id", "product_uom_qty", "display_type", @@ -89,11 +101,11 @@ def _check_call_off_order_line(self): ) ) - @api.constrains("order_id.order_type", "price_unit") + @api.constrains("order_type", "price_unit") def _check_call_off_order_line_price(self): price_precision = self.env["decimal.precision"].precision_get("Product Price") for line in self: - if line.order_id.order_type == "call_off" and not float_is_zero( + if line.order_type == "call_off" and not float_is_zero( line.price_unit, precision_digits=price_precision ): raise ValidationError( @@ -174,7 +186,7 @@ def _check_blanket_product_not_overlapping(self): ) ) - @api.depends("call_off_line_ids", "order_id.order_type", "call_off_line_ids.state") + @api.depends("call_off_line_ids", "order_type", "call_off_line_ids.state") def _compute_call_off_remaining_qty(self): """Compute the quantity remaining to deliver for call-off order lines. @@ -182,7 +194,7 @@ def _compute_call_off_remaining_qty(self): quantity is still available to deliver by a call-off order lines. """ self.flush_model() - blanket_lines = self.filtered(lambda l: l.order_id.order_type == "blanket") + blanket_lines = self.filtered(lambda l: l.order_type == "blanket") res = self.read_group( [("blanket_line_id", "in", blanket_lines.ids), ("state", "!=", "cancel")], ["blanket_line_id", "product_uom_qty:sum"], @@ -221,7 +233,7 @@ def _get_blanket_lines_for_call_off_lines_dict(self): call_off_lines_by_key = defaultdict(lambda: self.env["sale.order.line"]) matching_fields = self._get_call_off_line_to_blanked_line_matching_fields() for line in self: - if line.order_id.order_type != "call_off": + if line.order_type != "call_off": continue if line.display_type: continue @@ -244,7 +256,7 @@ def _get_blanket_lines_for_call_off_lines_dict(self): } def _prepare_reserve_procurement_values(self, group_id=None): - if self.order_id.order_type == "blanket": + if self.order_type == "blanket": return self._prepare_reserve_procurement_values_blanket(group_id) else: return super()._prepare_reserve_procurement_values(group_id) @@ -264,7 +276,7 @@ def _prepare_reserve_procurement_values_blanket(self, group_id=None): return values def _get_display_price(self): - if self.order_id.order_type == "call_off": + if self.order_type == "call_off": return 0.0 return super()._get_display_price() @@ -278,7 +290,7 @@ def _compute_qty_at_date(self): # Overload to consider the call-off order lines in the computation # For these lines we take the values computed on the corresponding # blanket order line - call_off_lines = self.filtered(lambda l: l.order_id.order_type == "call_off") + call_off_lines = self.filtered(lambda l: l.order_type == "call_off") other_lines = self - call_off_lines res = super(SaleOrderLine, other_lines)._compute_qty_at_date() for line in call_off_lines: @@ -299,7 +311,7 @@ def _compute_qty_to_deliver(self): # For these lines the qty to deliver is the same as the product_uom_qty # while the order is not confirmed or done. Otherwise it is 0 as the # delivery is done on the blanket order line. - call_off_lines = self.filtered(lambda l: l.order_id.order_type == "call_off") + call_off_lines = self.filtered(lambda l: l.order_type == "call_off") other_lines = self - call_off_lines res = super(SaleOrderLine, other_lines)._compute_qty_to_deliver() for line in call_off_lines: @@ -315,7 +327,7 @@ def _compute_qty_delivered(self): # Overload to consider the call-off order lines in the computation # For these lines the qty delivered is always 0 as the delivery is # done on the blanket order line. - call_off_lines = self.filtered(lambda l: l.order_id.order_type == "call_off") + call_off_lines = self.filtered(lambda l: l.order_type == "call_off") other_lines = self - call_off_lines res = super(SaleOrderLine, other_lines)._compute_qty_delivered() for line in call_off_lines: @@ -326,7 +338,7 @@ def _compute_qty_to_invoice(self): # Overload to consider the call-off order lines in the computation # For these lines the qty to invoice is always 0 as the invoicing is # done on the blanket order line. - call_off_lines = self.filtered(lambda l: l.order_id.order_type == "call_off") + call_off_lines = self.filtered(lambda l: l.order_type == "call_off") other_lines = self - call_off_lines res = super(SaleOrderLine, other_lines)._compute_qty_to_invoice() for line in call_off_lines: @@ -342,9 +354,7 @@ def _action_launch_stock_rule(self, previous_product_uom_qty=False): # We must also take care of the reservation strategy of the blanket order. call_off_lines = self.browse() if not self.env.context.get("disable_call_off_stock_rule"): - call_off_lines = self.filtered( - lambda l: l.order_id.order_type == "call_off" - ) + call_off_lines = self.filtered(lambda l: l.order_type == "call_off") other_lines = self - call_off_lines res = super(SaleOrderLine, other_lines)._action_launch_stock_rule( previous_product_uom_qty diff --git a/sale_order_blanket_order/views/sale_order.xml b/sale_order_blanket_order/views/sale_order.xml index afc2c1def64..dc7f3967f0a 100644 --- a/sale_order_blanket_order/views/sale_order.xml +++ b/sale_order_blanket_order/views/sale_order.xml @@ -5,26 +5,298 @@ sale.order - + - + + + + + + + + sale.order - + - + + + + + + + + + + sale.order - + - + + + + + sale.order + + + + + + + + + + sale.order + + primary + + + + + + + + + + Blanket Orders + ir.actions.act_window + sale.order + tree,kanban,form,calendar,pivot,graph,activity + + {'search_default_blanket_orders': 1, 'default_order_type': 'blanket'} + + + + + tree + + + + + + + kanban + + + + + + + form + + + + + + + calendar + + + + + + + pivot + + + + + + + graph + + + + + + Call-off Orders + ir.actions.act_window + sale.order + tree,kanban,form,calendar,pivot,graph,activity + + {'search_default_calloff_orders': 1, 'default_order_type': 'call_off'} + + + + + tree + + + + + + + kanban + + + + + + + form + + + + + + + calendar + + + + + + + pivot + + + + + + + graph + + + + + + Blanket Orders + + + + + + + + Call-off Orders + + + + + diff --git a/sale_order_blanket_order/views/sale_order_line.xml b/sale_order_blanket_order/views/sale_order_line.xml new file mode 100644 index 00000000000..c3ab6d26172 --- /dev/null +++ b/sale_order_blanket_order/views/sale_order_line.xml @@ -0,0 +1,87 @@ + + + + + + sale.order.line + + + + + + + + + + + + + + + + + + + + + + + + sale.order.line + + + + + + + + + + sale.order.line + + + + + + + + + + + + + + From f9706fa4aa573d506dc3afbe60ae3841e08b8f22 Mon Sep 17 00:00:00 2001 From: "Laurent Mignonn (ACSONE)" Date: Mon, 2 Dec 2024 18:12:19 +0100 Subject: [PATCH 15/17] wip --- sale_order_blanket_order/__manifest__.py | 1 + sale_order_blanket_order/models/__init__.py | 2 + .../models/res_company.py | 15 +++ .../models/res_config_settings.py | 17 +++ sale_order_blanket_order/models/sale_order.py | 107 +++++++++++++++++- .../models/sale_order_line.py | 73 +++++++++--- .../tests/test_sale_blanket_order.py | 2 +- .../views/res_config_settings.xml | 42 +++++++ 8 files changed, 241 insertions(+), 18 deletions(-) create mode 100644 sale_order_blanket_order/models/res_company.py create mode 100644 sale_order_blanket_order/models/res_config_settings.py create mode 100644 sale_order_blanket_order/views/res_config_settings.xml diff --git a/sale_order_blanket_order/__manifest__.py b/sale_order_blanket_order/__manifest__.py index 749ccb82780..6909c57d7ac 100644 --- a/sale_order_blanket_order/__manifest__.py +++ b/sale_order_blanket_order/__manifest__.py @@ -14,6 +14,7 @@ "data": [ "views/sale_order.xml", "views/sale_order_line.xml", + "views/res_config_settings.xml", ], "demo": [], "pre_init_hook": "pre_init_hook", diff --git a/sale_order_blanket_order/models/__init__.py b/sale_order_blanket_order/models/__init__.py index 124b4f9e6ca..9da8d9e6dd7 100644 --- a/sale_order_blanket_order/models/__init__.py +++ b/sale_order_blanket_order/models/__init__.py @@ -1,3 +1,5 @@ +from . import res_company +from . import res_config_settings from . import sale_order from . import sale_order_line from . import stock_move diff --git a/sale_order_blanket_order/models/res_company.py b/sale_order_blanket_order/models/res_company.py new file mode 100644 index 00000000000..0e8ca21d36f --- /dev/null +++ b/sale_order_blanket_order/models/res_company.py @@ -0,0 +1,15 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + create_call_off_from_so_if_possible = fields.Boolean( + string="Create Call-Off from SO if possible", + help="If checked, when a sale order is confirmed and some lines refer to a " + "blanket order, these lines will be automatically moved to a new call-off " + "order.", + ) diff --git a/sale_order_blanket_order/models/res_config_settings.py b/sale_order_blanket_order/models/res_config_settings.py new file mode 100644 index 00000000000..e30f70e4e6a --- /dev/null +++ b/sale_order_blanket_order/models/res_config_settings.py @@ -0,0 +1,17 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + create_call_off_from_so_if_possible = fields.Boolean( + related="company_id.create_call_off_from_so_if_possible", + readonly=False, + string="Create Call-Off from SO if possible", + help="If checked, when a sale order is confirmed and some lines refer to a " + "blanket order, these lines will be automatically moved to a new call-off " + "order.", + ) diff --git a/sale_order_blanket_order/models/sale_order.py b/sale_order_blanket_order/models/sale_order.py index 8ae7633d16f..94303741ee3 100644 --- a/sale_order_blanket_order/models/sale_order.py +++ b/sale_order_blanket_order/models/sale_order.py @@ -1,10 +1,13 @@ # Copyright 2024 ACSONE SA/NV # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from collections import defaultdict from datetime import datetime from odoo import _, api, fields, models from odoo.exceptions import ValidationError +from odoo.osv import expression +from odoo.tools import float_compare from odoo.addons.sale.models.sale_order import ( LOCKED_FIELD_STATES, @@ -301,4 +304,106 @@ def _split_for_blanket_order(self): The method returns the call-off orders that have been created or an empty recordset if no call-off orders have been created. """ - return self.browse() + if any(self.filtered(lambda order: order.order_type != "normal")): + raise ValidationError(_("Only normal orders can be split.")) + + splitable_orders = self.filtered( + lambda order: order.company_id.sudo().create_call_off_from_so_if_possible + ) + if not splitable_orders: + return self.browse() + blanket_order_candidates = splitable_orders._get_blanket_order_candidates() + if not blanket_order_candidates: + return self.browse() + matchings_dict = self.env["sale.order.line"]._match_lines_to_blanket( + splitable_orders.order_line, blanket_order_candidates.order_line + ) + new_call_off_by_order = defaultdict(lambda: self.env["sale.order"]) + # From here, we will create the call-off orders for the matched lines + # For each line, we will look to the matching blanket lines + # If the blanket line has enough remaining quantity for the line, + # We move the line to a new call-off order created for the blanket order + # Otherwise, we split order line: one line with the quantity of the blanket line + # and another line with the remaining quantity. The first line is moved to a new + # call-off order created for the blanket order and the second line is kept in the + # original order but we will try to match it with another blanket line if one exists. + # We will repeat this process until all the lines are processed. + for lines, blanket_lines in matchings_dict.values(): + if not blanket_lines: + continue + remaining_qty = sum(blanket_lines.mapped("call_off_remaining_qty")) + rounding = blanket_lines[0].product_uom.rounding + if ( + float_compare( + remaining_qty, + 0.0, + precision_rounding=blanket_lines[0].product_uom.rounding, + ) + <= 0 + ): + continue + for line in lines: + for blanket_line in blanket_lines: + blanket_order = blanket_line.order_id + call_off = new_call_off_by_order[line.order_id] + if not call_off: + call_off = line.order_id._create_call_off_order(blanket_order) + new_call_off_by_order[line.order_id] = call_off + qty_deliverable = blanket_line.call_off_remaining_qty + if ( + float_compare( + blanket_line.call_off_remaining_qty, + line.product_uom_qty, + precision_rounding=rounding, + ) + >= 0 + ): + line.order_id = call_off + break + line.copy( + default={ + "product_uom_qty": qty_deliverable, + "order_id": call_off.id, + } + ) + line.product_uom_qty -= qty_deliverable + return new_call_off_by_order.values() + + def _get_default_call_off_order_values(self, blanket_order_id): + """Get the default values to create a new call-off order.""" + self.ensure_one() + return { + "order_type": "call_off", + "blanket_order_id": blanket_order_id.id, + "order_line": False, + } + + def _create_call_off_order(self, blanket_order_id): + """Get the values to create a new call-off order from the current order.""" + self.ensure_one() + return self.copy(self._get_default_call_off_order_values(blanket_order_id)) + + def _get_single_blanket_order_candidates_domain(self): + """Get the domain to search for a blanket order candidates.""" + self.ensure_one() + return [ + ("partner_id", "=", self.partner_id.id), + ("order_type", "=", "blanket"), + ("state", "=", "sale"), + ("blanket_validity_start_date", "<=", self.date_order), + ("blanket_validity_end_date", ">=", self.date_order), + ] + + def _get_blanket_order_candidates_domain(self): + """Get the domain to search for the blanket order candidates.""" + domains = [] + for order in self: + order_domain = order._get_single_blanket_order_candidates_domain() + domains.append(order_domain) + return expression.OR(domains) + + def _get_blanket_order_candidates(self): + """Get the blanket order candidates for the order lines.""" + return self.env["sale.order"].search( + self._get_blanket_order_candidates_domain(), order="id" + ) diff --git a/sale_order_blanket_order/models/sale_order_line.py b/sale_order_blanket_order/models/sale_order_line.py index bdbbfe6b3cd..5af4e64b6e3 100644 --- a/sale_order_blanket_order/models/sale_order_line.py +++ b/sale_order_blanket_order/models/sale_order_line.py @@ -4,6 +4,7 @@ from odoo import _, api, fields, models from odoo.exceptions import ValidationError +from odoo.osv.expression import expression from odoo.tools import float_compare, float_is_zero @@ -148,7 +149,7 @@ def _check_blanket_product_not_overlapping(self): # here we use a plain SQL query to benefit of the daterange # function available in PostgresSQL # (http://www.postgresql.org/docs/current/static/rangetypes.html) - SQL = """ + sql = """ SELECT sol.id FROM @@ -160,19 +161,30 @@ def _check_blanket_product_not_overlapping(self): AND so.blanket_validity_end_date is not null AND DATERANGE(so.blanket_validity_start_date, so.blanket_validity_end_date, '[]') && DATERANGE(%s::date, %s::date, '[]') - AND sol.product_id != %s - AND sol.qty_to_procure > 0 + AND sol.call_off_remaining_qty > 0 AND so.id != %s AND so.state not in ('done', 'cancel') AND so.order_type = 'blanket' """ + domain = [] + for ( + matching_field + ) in self._get_call_off_line_to_blanked_line_matching_fields(): + value = rec[matching_field] + if isinstance(value, models.BaseModel): + value = value.id + domain.append((matching_field, "=", value)) + _t, where, matching_field_values = expression( + domain, self, alias="sol" + ).query.get_sql() + sql += f"AND {where}" self.env.cr.execute( - SQL, + sql, ( order.blanket_validity_start_date, order.blanket_validity_end_date, - rec.product_id.id, rec.order_id.id, + *matching_field_values, ), ) res = self.env.cr.fetchall() @@ -218,36 +230,65 @@ def _compute_call_off_remaining_qty(self): line.call_off_remaining_qty = new_call_off_remaining_qty def _get_call_off_line_to_blanked_line_matching_fields(self): - return ["product_id", "product_packaging_id"] + return ["product_id", "product_packaging_id", "order_partner_id"] def _get_blanket_lines_for_call_off_lines_dict(self): """Get the matching blanket order lines for the call-off order lines. + see `_match_lines_to_blanket` for more details. + """ + call_off_lines = self.filtered(lambda l: l.order_type == "call_off") + blanket_lines = self.order_id.blanket_order_id.order_line + return self._match_lines_to_blanket(call_off_lines, blanket_lines) + + def _to_blanket_line_matching_key(self): + """Compute the matching key for the blanket order line. + + The key is a tuple of the fields provided by the method + `_get_call_off_line_to_blanked_line_matching_fields`. + + :return: A tuple of the matching fields. + """ + return ( + *[ + self[field] + for field in self._get_call_off_line_to_blanked_line_matching_fields() + ], + ) + + @api.model + def _match_lines_to_blanket(self, order_lines, blanket_lines): + """Compute the matching between given order lines and the blanket order lines. + The matching is done on the fields provided by the method `_get_call_off_line_to_blanked_line_matching_fields`. :return: A dictionary. The keys are tuples of the matching fields and the blanket order id. The values are tuples of the call-off order lines and the matching blanket order lines. + """ call_off_lines_by_key = defaultdict(lambda: self.env["sale.order.line"]) - matching_fields = self._get_call_off_line_to_blanked_line_matching_fields() - for line in self: - if line.order_type != "call_off": - continue + for line in order_lines: if line.display_type: continue if line.state == "cancel": continue - key = ( - *[line[field] for field in matching_fields], - line.order_id.blanket_order_id, - ) + key = line._to_blanket_line_matching_key() call_off_lines_by_key[key] |= line blanket_lines_by_key = defaultdict(lambda: self.env["sale.order.line"]) - for line in self.order_id.blanket_order_id.order_line: - key = (*[line[field] for field in matching_fields], line.order_id) + for line in blanket_lines: + if ( + float_compare( + line.call_off_remaining_qty, + 0.0, + precision_rounding=line.product_uom.rounding, + ) + <= 0 + ): + continue + key = line._to_blanket_line_matching_key() blanket_lines_by_key[key] |= line return { diff --git a/sale_order_blanket_order/tests/test_sale_blanket_order.py b/sale_order_blanket_order/tests/test_sale_blanket_order.py index 8d44b8d2aaa..40c21721842 100644 --- a/sale_order_blanket_order/tests/test_sale_blanket_order.py +++ b/sale_order_blanket_order/tests/test_sale_blanket_order.py @@ -57,7 +57,7 @@ def test_no_product_overlap(self): with self.assertRaisesRegex( ValidationError, ( - "The product 'Product 2' is already part of another blanket order " + "The product 'Product 1' is already part of another blanket order " f"{self.blanket_so.name}." ), ): diff --git a/sale_order_blanket_order/views/res_config_settings.xml b/sale_order_blanket_order/views/res_config_settings.xml new file mode 100644 index 00000000000..5686e5ef04e --- /dev/null +++ b/sale_order_blanket_order/views/res_config_settings.xml @@ -0,0 +1,42 @@ + + + + + + res.config.settings + + + +
+
+
+ +
+
+ + +
+ If this option is enabled, a call-off order will be generated from a sale order if the sale order contains products + that are part of an open blanket order. The call-off order will be generated with the same products and quantities as the + sale order provided that the blanket order has enough available quantity. If the blanket order does not have enough available + quantity, the call-off order will be generated with the available quantity and the original sale order line will be + split into two lines: one line with the available quantity and one line with the remaining quantity. All the lines related to + a blanket order will be moved to a new call-off order and the new call-off order will be confirmed. +
+
+
+
+
+
+ +
From 34f1e2dd7f649431a2cbd0d85bf42ab6df451f86 Mon Sep 17 00:00:00 2001 From: "Laurent Mignonn (ACSONE)" Date: Tue, 3 Dec 2024 10:48:42 +0100 Subject: [PATCH 16/17] wip --- sale_order_blanket_order/models/sale_order.py | 57 ++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/sale_order_blanket_order/models/sale_order.py b/sale_order_blanket_order/models/sale_order.py index 94303741ee3..0df9b56209e 100644 --- a/sale_order_blanket_order/models/sale_order.py +++ b/sale_order_blanket_order/models/sale_order.py @@ -350,25 +350,78 @@ def _split_for_blanket_order(self): call_off = line.order_id._create_call_off_order(blanket_order) new_call_off_by_order[line.order_id] = call_off qty_deliverable = blanket_line.call_off_remaining_qty + original_order = line.order_id if ( float_compare( - blanket_line.call_off_remaining_qty, + qty_deliverable, line.product_uom_qty, precision_rounding=rounding, ) >= 0 ): + # The blanket line has enough remaining quantity for the line + # We move the line to the call-off order line.order_id = call_off + self._log_line_moved_to_call_off(line, call_off, original_order) break - line.copy( + # The blanket line does not have enough remaining quantity for the line + # We split the line and move the part deliverable to the call-off order + new_line = line.copy( default={ "product_uom_qty": qty_deliverable, "order_id": call_off.id, } ) line.product_uom_qty -= qty_deliverable + self._log_line_partially_moved_to_call_off( + new_line, line, call_off, original_order, qty_deliverable + ) return new_call_off_by_order.values() + def _log_line_moved_to_call_off(self, line, call_off, original_order): + """Log the line movement to the call-off order.""" + original_order.message_post( + body=_( + _( + "The line %(line)s has been moved to a new call-off order %(call_off)s." + ), + line=line.display_name, + call_off=call_off._get_html_link(), + ) + ) + call_off.message_post( + body=_( + _("The line %(line)s has been moved from order %(order)s."), + line=line.display_name, + order=original_order._get_html_link(), + ) + ) + + def _log_line_partially_moved_to_call_off( + self, new_line, line, call_off, original_order, qty_deliverable + ): + """Log the line partial movement to the call-off order.""" + call_off.message_post( + body=_( + _( + "The line %(line)s has been created from order %(order)s. (Qty moved: %(qty_deliverable)s)" + ), + line=new_line.display_name, + order=original_order._get_html_link(), + qty_deliverable=qty_deliverable, + ) + ) + original_order.message_post( + body=_( + _( + "The line %(line)s has been partially moved to a new call-off order %(call_off)s. (Qty moved: %(qty_deliverable)s)" + ), + line=line.display_name, + call_off=call_off._get_html_link(), + qty_deliverable=qty_deliverable, + ) + ) + def _get_default_call_off_order_values(self, blanket_order_id): """Get the default values to create a new call-off order.""" self.ensure_one() From aca27aca7aea3fec6c736ed00d12146c561d1fd2 Mon Sep 17 00:00:00 2001 From: "Laurent Mignonn (ACSONE)" Date: Tue, 3 Dec 2024 15:25:56 +0100 Subject: [PATCH 17/17] add auto create mode of call-off orders on normal order confirmation --- sale_order_blanket_order/models/sale_order.py | 12 +- sale_order_blanket_order/tests/__init__.py | 1 + sale_order_blanket_order/tests/common.py | 2 + .../tests/test_sale_normal_order.py | 201 ++++++++++++++++++ 4 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 sale_order_blanket_order/tests/test_sale_normal_order.py diff --git a/sale_order_blanket_order/models/sale_order.py b/sale_order_blanket_order/models/sale_order.py index 0df9b56209e..ca34aa39b5c 100644 --- a/sale_order_blanket_order/models/sale_order.py +++ b/sale_order_blanket_order/models/sale_order.py @@ -212,6 +212,8 @@ def _action_confirm(self): blanket_orders |= order elif order.order_type == "call_off": call_off_orders |= order + else: + normal_orders |= order if blanket_orders: blanket_orders._on_blanket_order_confirm() @@ -361,6 +363,7 @@ def _split_for_blanket_order(self): ): # The blanket line has enough remaining quantity for the line # We move the line to the call-off order + line.price_unit = 0 line.order_id = call_off self._log_line_moved_to_call_off(line, call_off, original_order) break @@ -370,13 +373,20 @@ def _split_for_blanket_order(self): default={ "product_uom_qty": qty_deliverable, "order_id": call_off.id, + "price_unit": 0, } ) line.product_uom_qty -= qty_deliverable self._log_line_partially_moved_to_call_off( new_line, line, call_off, original_order, qty_deliverable ) - return new_call_off_by_order.values() + # values() is a generator of sets of values. We want to concatenate all the sets + # into a single set of values. + new_call_off_orders = self.env["sale.order"] + for call_off in new_call_off_by_order.values(): + new_call_off_orders |= call_off + new_call_off_orders.action_confirm() + return new_call_off_orders def _log_line_moved_to_call_off(self, line, call_off, original_order): """Log the line movement to the call-off order.""" diff --git a/sale_order_blanket_order/tests/__init__.py b/sale_order_blanket_order/tests/__init__.py index f7b4d47dc79..110f3d9afda 100644 --- a/sale_order_blanket_order/tests/__init__.py +++ b/sale_order_blanket_order/tests/__init__.py @@ -1,2 +1,3 @@ from . import test_sale_blanket_order from . import test_sale_call_off_order +from . import test_sale_normal_order diff --git a/sale_order_blanket_order/tests/common.py b/sale_order_blanket_order/tests/common.py index 7b01d177db6..7e51016661a 100644 --- a/sale_order_blanket_order/tests/common.py +++ b/sale_order_blanket_order/tests/common.py @@ -89,6 +89,8 @@ def setUpClass(cls): ], } ) + cls.so_model = cls.env["sale.order"] + cls.call_off_domain = [("order_type", "=", "call_off")] @classmethod def _set_qty_in_loc_only(cls, product, qty, location=None): diff --git a/sale_order_blanket_order/tests/test_sale_normal_order.py b/sale_order_blanket_order/tests/test_sale_normal_order.py new file mode 100644 index 00000000000..a8b550013ce --- /dev/null +++ b/sale_order_blanket_order/tests/test_sale_normal_order.py @@ -0,0 +1,201 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import freezegun + +from odoo import Command +from odoo.tests.common import RecordCapturer + +from .common import SaleOrderBlanketOrderCase + + +@freezegun.freeze_time("2025-02-01") +class TestSaleNormalOrder(SaleOrderBlanketOrderCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.blanket_so.action_confirm() + + @classmethod + def _set_cut_off_auto_create_mode(cls, value): + # Enable the auto create mode + cls.env["res.config.settings"].create( + {"create_call_off_from_so_if_possible": True} + ).execute() + + def test_normal_order(self): + # ensure that the original sale order process + # works as expected + # We use product_3 since it is not part of a blanket order + order = self.env["sale.order"].create( + { + "partner_id": self.partner.id, + "order_line": [ + Command.create( + { + "product_id": self.product_3.id, + "product_uom_qty": 1, + "price_unit": 100, + }, + ) + ], + } + ) + order.action_confirm() + + def test_call_off_auto_create_mode(self): + order = self.env["sale.order"].create( + { + "partner_id": self.partner.id, + "order_line": [ + Command.create( + { + "product_id": self.product_1.id, + "product_uom_qty": 1, + "price_unit": 100, + }, + ) + ], + } + ) + with RecordCapturer(self.so_model, self.call_off_domain) as captured: + order.action_confirm() + new_order = captured.records + # By default the auto create mode is disabled + self.assertEqual(len(new_order), 0) + + # Enable the auto create mode + self._set_cut_off_auto_create_mode(True) + order.action_draft() + with RecordCapturer(self.so_model, self.call_off_domain) as captured: + order.action_confirm() + new_order = captured.records + self.assertEqual(len(new_order), 1) + self.assertEqual(new_order.partner_id, order.partner_id) + self.assertEqual(new_order.state, "sale") + self.assertEqual(new_order.order_type, "call_off") + self.assertEqual(new_order.blanket_order_id, self.blanket_so) + + def test_call_off_auto_create(self): + # A test where we've a SO with 2 products, + # one of which is part of a blanket order + # and the other is not + # The quantity of the product that is part of the blanket order + # is less than the quantity in the blanket order + self._set_cut_off_auto_create_mode(True) + order = self.env["sale.order"].create( + { + "partner_id": self.partner.id, + "order_line": [ + Command.create( + { + "product_id": self.product_1.id, + "product_uom_qty": 1, + "price_unit": 100, + }, + ), + Command.create( + { + "product_id": self.product_3.id, + "product_uom_qty": 1, + "price_unit": 100, + }, + ), + ], + } + ) + with RecordCapturer(self.so_model, self.call_off_domain) as captured: + order.action_confirm() + new_order = captured.records + self.assertEqual(len(new_order), 1) + self.assertEqual(len(order.order_line), 1) + self.assertEqual(order.order_line.product_id, self.product_3) + self.assertEqual(len(new_order.order_line), 1) + self.assertEqual(new_order.order_line.product_id, self.product_1) + self.assertEqual(new_order.order_line.product_uom_qty, 1) + blanket_lines = self.blanket_so.order_line.filtered( + lambda x: x.product_id == self.product_1 + ) + blanquet_product_qty = sum(blanket_lines.mapped("product_uom_qty")) + remaining_qty = sum(blanket_lines.mapped("call_off_remaining_qty")) + self.assertEqual(blanquet_product_qty, remaining_qty + 1) + + def test_call_off_auto_create_qty_multi_blanket_line(self): + # A test where we've a SO with 1 product for which we have 2 blanket lines + # The quantity of the product that is part of the normal order is less + # than the total quantity in the blanket order lines but greater than the + # quantity in each line. + # The system should create a call off order with 2 lines for the same product + # where each line corresponds to a blanket line and one of the lines + # fulfills the remaining quantity of the first blanket line. + # product_1 is part of the blanket order with 2 lines each with a quantity + # of 10 + self._set_cut_off_auto_create_mode(True) + order = self.env["sale.order"].create( + { + "partner_id": self.partner.id, + "order_line": [ + Command.create( + { + "product_id": self.product_1.id, + "product_uom_qty": 15, + "price_unit": 100, + }, + ), + ], + } + ) + with RecordCapturer(self.so_model, self.call_off_domain) as captured: + order.action_confirm() + new_order = captured.records + self.assertEqual(len(new_order), 1) + self.assertEqual( + len(order.order_line), 0 + ) # All lines are moved to the call off order + self.assertEqual(len(new_order.order_line), 2) + blanket_lines = self.blanket_so.order_line.filtered( + lambda x: x.product_id == self.product_1 + ) + blanquet_product_qty = sum(blanket_lines.mapped("product_uom_qty")) + remaining_qty = sum(blanket_lines.mapped("call_off_remaining_qty")) + self.assertEqual(blanquet_product_qty, remaining_qty + 15) + + def test_call_off_auto_create_qty_multi_blanket_line_overflow(self): + # A test where we've a SO with 1 product for which we have 2 blanket lines + # The quantity of the product that is part of the normal order is greater + # than the total quantity in the blanket order lines. + # The system should create a call off order with 2 lines for the same product + # where each line corresponds to a blanket line and fulfill the quantity + # of the blanket lines. The original order should have one line with the + # remaining quantity. + # product_1 is part of the blanket order with 2 lines each with a quantity + # of 10 + self._set_cut_off_auto_create_mode(True) + order = self.env["sale.order"].create( + { + "partner_id": self.partner.id, + "order_line": [ + Command.create( + { + "product_id": self.product_1.id, + "product_uom_qty": 25, + "price_unit": 100, + }, + ), + ], + } + ) + with RecordCapturer(self.so_model, self.call_off_domain) as captured: + order.action_confirm() + new_order = captured.records + self.assertEqual(len(new_order), 1) + self.assertEqual( + len(order.order_line), 1 + ) # All lines are moved to the call off order + self.assertEqual(len(new_order.order_line), 2) + blanket_lines = self.blanket_so.order_line.filtered( + lambda x: x.product_id == self.product_1 + ) + remaining_qty = sum(blanket_lines.mapped("call_off_remaining_qty")) + self.assertEqual(remaining_qty, 0) + self.assertEqual(order.order_line.product_uom_qty, 5)