diff --git a/sale_order_blanket_order/README.rst b/sale_order_blanket_order/README.rst new file mode 100644 index 00000000000..7af5d748fd1 --- /dev/null +++ b/sale_order_blanket_order/README.rst @@ -0,0 +1,214 @@ +============== +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_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_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 + :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. 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 +--------- + +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 `__. + +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 +* 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/__init__.py b/sale_order_blanket_order/__init__.py new file mode 100644 index 00000000000..6d58305f5dd --- /dev/null +++ b/sale_order_blanket_order/__init__.py @@ -0,0 +1,2 @@ +from . import models +from .hooks import pre_init_hook diff --git a/sale_order_blanket_order/__manifest__.py b/sale_order_blanket_order/__manifest__.py new file mode 100644 index 00000000000..6909c57d7ac --- /dev/null +++ b/sale_order_blanket_order/__manifest__.py @@ -0,0 +1,21 @@ +# 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,BCIM,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/sale-workflow", + "depends": [ + "sale_manual_delivery", + ], + "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/hooks.py b/sale_order_blanket_order/hooks.py new file mode 100644 index 00000000000..b1f752a62ff --- /dev/null +++ b/sale_order_blanket_order/hooks.py @@ -0,0 +1,21 @@ +import logging + +_logger = logging.getLogger(__name__) + + +def pre_init_hook(cr): + _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/__init__.py b/sale_order_blanket_order/models/__init__.py new file mode 100644 index 00000000000..9da8d9e6dd7 --- /dev/null +++ b/sale_order_blanket_order/models/__init__.py @@ -0,0 +1,6 @@ +from . import res_company +from . import res_config_settings +from . import sale_order +from . import sale_order_line +from . import stock_move +from . import stock_rule 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 new file mode 100644 index 00000000000..94303741ee3 --- /dev/null +++ b/sale_order_blanket_order/models/sale_order.py @@ -0,0 +1,409 @@ +# 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, + 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="btree_not_null", + ) + 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.", + ) + blanket_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_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.", + store=True, + precompute=True, + ) + 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.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: + if order.state != "draft": + continue + if order.order_type == "blanket" and not order.blanket_reservation_strategy: + order.blanket_reservation_strategy = "at_call_off" + + @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_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 + # 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 + if blanket_orders: + blanket_orders._on_blanket_order_confirm() + + call_off_orders |= normal_orders._split_for_blanket_order() + 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 _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. + + 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.blanket_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. + 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._link_lines_to_blanket_order_line() + + def _blanket_order_reserve_stock(self): + """Reserve the stock for the blanket order.""" + to_reserve_at_call_off = self.browse() + for order in self: + if 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_call_off._delay_delivery() + + 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. + """ + # 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. + """ + 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 new file mode 100644 index 00000000000..5af4e64b6e3 --- /dev/null +++ b/sale_order_blanket_order/models/sale_order_line.py @@ -0,0 +1,506 @@ +# 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.osv.expression import expression +from odoo.tools import float_compare, float_is_zero + + +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", + 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.", + index="btree_not_null", + ) + 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", + "order_id.blanket_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. + """ + 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=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_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=call_of_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=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, + ) + ) + + @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_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, + ) + ) + + @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.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, + ( + order.blanket_validity_start_date, + order.blanket_validity_end_date, + rec.order_id.id, + *matching_field_values, + ), + ) + 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, + ) + ) + + @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. + + 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_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"] + 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", "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"]) + for line in order_lines: + if line.display_type: + continue + if line.state == "cancel": + continue + 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 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 { + 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_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.blanket_validity_start_date + values["date_deadline"] = self.order_id.blanket_validity_start_date + return values + + def _get_display_price(self): + if self.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_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): + # 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_type == "call_off") + other_lines = self - call_off_lines + 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( + 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_type == "call_off") + other_lines = self - call_off_lines + 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 + 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_type == "call_off") + other_lines = self - call_off_lines + res = super(SaleOrderLine, 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_type == "call_off") + other_lines = self - call_off_lines + res = super(SaleOrderLine, 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.browse() + if not self.env.context.get("disable_call_off_stock_rule"): + 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 + ) + 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) + 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_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_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_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({}) + ) + wizard.line_ids.quantity = qty_to_deliver + wizard.confirm() diff --git a/sale_order_blanket_order/models/stock_move.py b/sale_order_blanket_order/models/stock_move.py new file mode 100644 index 00000000000..5e2d1e0b5a0 --- /dev/null +++ b/sale_order_blanket_order/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_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_off_sale_line_id") + return distinct_fields diff --git a/sale_order_blanket_order/models/stock_rule.py b/sale_order_blanket_order/models/stock_rule.py new file mode 100644 index 00000000000..b3cb3d52f47 --- /dev/null +++ b/sale_order_blanket_order/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_off_sale_line_id"] + return fields diff --git a/sale_order_blanket_order/readme/CONTEXT.md b/sale_order_blanket_order/readme/CONTEXT.md new file mode 100644 index 00000000000..8729a87a442 --- /dev/null +++ b/sale_order_blanket_order/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_order_blanket_order/readme/CONTRIBUTORS.md b/sale_order_blanket_order/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..ede6aa0ae20 --- /dev/null +++ b/sale_order_blanket_order/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Laurent Mignon (https://www.acsone.eu) +- Jacques-Etienne Baudoux (BCIM) diff --git a/sale_order_blanket_order/readme/CREDITS.md b/sale_order_blanket_order/readme/CREDITS.md new file mode 100644 index 00000000000..e7c5a535490 --- /dev/null +++ b/sale_order_blanket_order/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/readme/DESCRIPTION.md b/sale_order_blanket_order/readme/DESCRIPTION.md new file mode 100644 index 00000000000..de8e97785aa --- /dev/null +++ b/sale_order_blanket_order/readme/DESCRIPTION.md @@ -0,0 +1,56 @@ +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. +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. + +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/). + +## 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_order_blanket_order/static/description/icon.png b/sale_order_blanket_order/static/description/icon.png new file mode 100644 index 00000000000..3a0328b516c Binary files /dev/null and b/sale_order_blanket_order/static/description/icon.png differ diff --git a/sale_order_blanket_order/static/description/index.html b/sale_order_blanket_order/static/description/index.html new file mode 100644 index 00000000000..b703378f9b3 --- /dev/null +++ b/sale_order_blanket_order/static/description/index.html @@ -0,0 +1,538 @@ + + + + + +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. 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

+

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.

+
+
+

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
  • +
  • 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/tests/__init__.py b/sale_order_blanket_order/tests/__init__.py new file mode 100644 index 00000000000..f7b4d47dc79 --- /dev/null +++ b/sale_order_blanket_order/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/tests/common.py b/sale_order_blanket_order/tests/common.py new file mode 100644 index 00000000000..7b01d177db6 --- /dev/null +++ b/sale_order_blanket_order/tests/common.py @@ -0,0 +1,102 @@ +# 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 SaleOrderBlanketOrderCase(BaseCommon): + @classmethod + def setUpClass(cls): + """Setup the test + + - Create a partner + - 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 + - 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.product_3 = cls.env["product.product"].create( + {"name": "Product 3", "type": "product"} + ) + cls._set_qty_in_loc_only(cls.product_1, 1000) + cls._set_qty_in_loc_only(cls.product_2, 2000) + cls.blanket_so = 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_order_blanket_order/tests/test_sale_blanket_order.py b/sale_order_blanket_order/tests/test_sale_blanket_order.py new file mode 100644 index 00000000000..40c21721842 --- /dev/null +++ b/sale_order_blanket_order/tests/test_sale_blanket_order.py @@ -0,0 +1,86 @@ +# 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 SaleOrderBlanketOrderCase + + +class TestSaleBlanketOrder(SaleOrderBlanketOrderCase): + def test_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_no_product_overlap(self): + # Create a blanket order with a product that is already in the blanket order + with self.assertRaisesRegex( + ValidationError, + ( + "The product 'Product 1' is already part of another blanket order " + f"{self.blanket_so.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_reservation(self): + # Confirm the blanket order with reservation at call off + 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.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 new file mode 100644 index 00000000000..de314dd95cc --- /dev/null +++ b/sale_order_blanket_order/tests/test_sale_call_off_order.py @@ -0,0 +1,307 @@ +# 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 SaleOrderBlanketOrderCase + + +class TestSaleCallOffOrder(SaleOrderBlanketOrderCase): + 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.id, + } + ) + + self.blanket_so.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.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.id, + } + ) + self.assertTrue(order) + + def test_order_line_constrains(self): + 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.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.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.id, + "order_line": [ + Command.create( + { + "product_id": self.product_1.id, + "product_uom_qty": 10.0, + } + ), + ], + } + ) + blanket_line = self.blanket_so.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, + } + ], + ) + + +@freezegun.freeze_time("2025-02-01") +class TestSaleCallOffOrderProcessing(SaleOrderBlanketOrderCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.blanket_so.action_confirm() + + 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.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) + self.assertEqual(line.blanket_line_id, sale_line) + + # 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() + + # 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.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/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. +
+
+
+
+
+
+ +
diff --git a/sale_order_blanket_order/views/sale_order.xml b/sale_order_blanket_order/views/sale_order.xml new file mode 100644 index 00000000000..dc7f3967f0a --- /dev/null +++ b/sale_order_blanket_order/views/sale_order.xml @@ -0,0 +1,302 @@ + + + + + + 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 + + + + + + + + + + + + + + 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 00000000000..3a0328b516c Binary files /dev/null and b/sale_order_blanket_order_stock_prebook/static/description/icon.png differ 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/odoo/addons/sale_framework b/setup/sale_order_blanket_order/odoo/addons/sale_framework new file mode 120000 index 00000000000..56bd52e379f --- /dev/null +++ b/setup/sale_order_blanket_order/odoo/addons/sale_framework @@ -0,0 +1 @@ +../../../../sale_framework \ No newline at end of file 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 diff --git a/setup/sale_order_blanket_order/setup.py b/setup/sale_order_blanket_order/setup.py new file mode 100644 index 00000000000..28c57bb6403 --- /dev/null +++ b/setup/sale_order_blanket_order/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) 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, +) diff --git a/test-requirements.txt b/test-requirements.txt index 66bc2cbae3f..906db9a6694 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1 +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