diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 94169c43a55..22a6c83c8b6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,10 +43,17 @@ jobs: name: test with OCB makepot: "true" - container: ghcr.io/oca/oca-ci/py3.10-odoo16.0:latest - exclude: "sale_packaging_default,sale_order_product_recommendation,sale_order_product_recommendation_packaging_default,sale_order_product_recommendation_elaboration" + exclude: "sale_packaging_default,sale_order_product_recommendation,sale_order_product_recommendation_packaging_default,sale_order_product_recommendation_elaboration,sale_order_blanket_order,sale_order_blanket_order_stock_prebook" name: test with Odoo - container: ghcr.io/oca/oca-ci/py3.10-ocb16.0:latest - exclude: "sale_packaging_default,sale_order_product_recommendation,sale_order_product_recommendation_packaging_default,sale_order_product_recommendation_elaboration" + exclude: "sale_packaging_default,sale_order_product_recommendation,sale_order_product_recommendation_packaging_default,sale_order_product_recommendation_elaboration,sale_order_blanket_order,sale_order_blanket_order_stock_prebook" + name: test with OCB + makepot: "true" + - container: ghcr.io/oca/oca-ci/py3.10-odoo16.0:latest + include: "sale_order_blanket_order,sale_order_blanket_order_stock_prebook" + name: test with Odoo + - container: ghcr.io/oca/oca-ci/py3.10-ocb16.0:latest + include: "sale_order_blanket_order,sale_order_blanket_order_stock_prebook" name: test with OCB makepot: "true" services: diff --git a/sale_order_blanket_order/README.rst b/sale_order_blanket_order/README.rst new file mode 100644 index 00000000000..85e750ed001 --- /dev/null +++ b/sale_order_blanket_order/README.rst @@ -0,0 +1,223 @@ +======================== +Sale Order Blanket Order +======================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! 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-off 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 blanket order +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 +order blanket order module is the right choice. + +This module introduces 2 new kinds of sales orders: + +1. Blanket Order: This is a sales order that defines the terms and + conditions of the sales, the price, the payment terms, the delivery + terms, and secures the quantities of the products to be delivered. + +2. Call of order: This is a sales order linked to a blanket order that + is created to trigger the delivery of quantities of the products + secured in the 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 order blanket order module +extends the sale order model to add the sale blanket order and the call +off 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, +...). + +Usage +===== + +By default, the automatic creation of call-off orders from normal sale +orders containing products part of a blanket order is disabled. To +enable this feature, you need to go into the sales settings and enable +the option "Create Call-Off from SO if possible". + +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..322e4c56a95 --- /dev/null +++ b/sale_order_blanket_order/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Sale Order Blanket Order", + "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", + ], + "excludes": ["sale_blanket_order"], + "data": [ + "views/sale_order.xml", + "views/sale_order_line.xml", + "views/res_config_settings.xml", + "data/ir_cron.xml", + ], + "demo": [], + "pre_init_hook": "pre_init_hook", +} diff --git a/sale_order_blanket_order/data/ir_cron.xml b/sale_order_blanket_order/data/ir_cron.xml new file mode 100644 index 00000000000..39faeba467a --- /dev/null +++ b/sale_order_blanket_order/data/ir_cron.xml @@ -0,0 +1,25 @@ + + + + + Finalize expired sale blanket orders + + + code + model._cron_manage_blanket_order_eol() + + 1 + days + -1 + + + + diff --git a/sale_order_blanket_order/hooks.py b/sale_order_blanket_order/hooks.py new file mode 100644 index 00000000000..50c6941a37f --- /dev/null +++ b/sale_order_blanket_order/hooks.py @@ -0,0 +1,42 @@ +import logging + +_logger = logging.getLogger(__name__) + + +def pre_init_hook(cr): + _logger.info("Create column order_type in sale_order with default value 'order'") + cr.execute( + "ALTER TABLE sale_order ADD COLUMN order_type varchar(255) DEFAULT 'order'" + ) + # 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 'order'" + ) + cr.execute( + "ALTER TABLE sale_order_line ADD COLUMN order_type varchar(255) DEFAULT 'order'" + ) + # 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") + + _logger.info( + "Create columns for blanket order in sale_order and " + "sale_order_line to avoid computing the field for all records at module install" + ) + # avoid computing the field for all records at module install + cr.execute( + "ALTER TABLE sale_order_line ADD COLUMN call_off_remaining_qty double precision" + ) + cr.execute( + "ALTER TABLE sale_order_line ADD COLUMN blanket_validity_start_date date" + ) + cr.execute("ALTER TABLE sale_order_line ADD COLUMN blanket_validity_end_date date") + cr.execute("ALTER TABLE sale_order_line ADD COLUMN blanket_order_id integer") + cr.execute( + "ALTER TABLE sale_order ADD COLUMN blanket_reservation_strategy varchar(255)" + ) + + cr.execute( + "ALTER TABLE sale_order ADD COLUMN create_call_off_from_so_if_possible BOOLEAN" + ) diff --git a/sale_order_blanket_order/i18n/fr.po b/sale_order_blanket_order/i18n/fr.po new file mode 100644 index 00000000000..281b56c2a7a --- /dev/null +++ b/sale_order_blanket_order/i18n/fr.po @@ -0,0 +1,629 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * sale_order_blanket_order +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-12-19 09:27+0000\n" +"PO-Revision-Date: 2024-12-19 09:27+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: sale_order_blanket_order +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_form_view +msgid "" +"" +msgstr "" + +#. module: sale_order_blanket_order +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.res_config_settings_form_view +msgid "" +"" +msgstr "" +"" + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "A blanket order cannot have a blanket order." +msgstr "A une commande-cadre ne peut pas référencer une commande-cadre." + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "A call-off order must have a blanket order." +msgstr "Une commande d'appel doit avoir une commande-cadre." + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "An order cannot have a blanket order." +msgstr "Une commande ne peut pas référencer une commande-cadre." + +#. module: sale_order_blanket_order +#: model:ir.model.fields.selection,name:sale_order_blanket_order.selection__sale_order__blanket_reservation_strategy__at_call_off +msgid "At Call-off" +msgstr "A l'appel" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order__blanket_eol_strategy +msgid "Blanket Eol Strategy" +msgstr "Stratégie de fin de vie de la commande-cadre" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order__blanket_order_id +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order_line__blanket_order_id +#: model:ir.model.fields.selection,name:sale_order_blanket_order.selection__sale_order__order_type__blanket +msgid "Blanket Order" +msgstr "Commande-cadre" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order__blanket_order_id_domain +msgid "Blanket Order Domain" +msgstr "Domaine de la commande-cadre" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order_line__blanket_line_id +msgid "Blanket Order Line" +msgstr "Ligne de commande-cadre" + +#. module: sale_order_blanket_order +#: model:ir.actions.act_window,name:sale_order_blanket_order.action_blankets +#: model:ir.ui.menu,name:sale_order_blanket_order.blanket_order_menu +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_line_search_view +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_search_view +msgid "Blanket Orders" +msgstr "Commandes-cadre" + +#. module: sale_order_blanket_order +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_line_search_view +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_search_view +msgid "Call Off Orders" +msgstr "Commandes d'appel" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_stock_move__call_off_sale_line_id +msgid "Call Off Sale Line" +msgstr "Ligne de commande d'appel" + +#. module: sale_order_blanket_order +#: model:ir.model.fields.selection,name:sale_order_blanket_order.selection__sale_order__order_type__call_off +msgid "Call-off Order" +msgstr "Commande d'appel" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order__call_off_order_count +msgid "Call-off Order Count" +msgstr "Nbr de commandes d'appel" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order_line__call_off_line_ids +msgid "Call-off Order Lines" +msgstr "Lignes de commande d'appel" + +#. module: sale_order_blanket_order +#: model:ir.actions.act_window,name:sale_order_blanket_order.action_calloffs +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order__call_off_order_ids +#: model:ir.ui.menu,name:sale_order_blanket_order.calloff_order_menu +msgid "Call-off Orders" +msgstr "Commandes d'appel" + +#. module: sale_order_blanket_order +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_form_view +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_line_form_view +msgid "Call-off lines" +msgstr "Lignes d'appel" + +#. module: sale_order_blanket_order +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_form_view +msgid "Call-offs" +msgstr "Commandes d'appel" + +#. module: sale_order_blanket_order +#: model:ir.model,name:sale_order_blanket_order.model_res_company +msgid "Companies" +msgstr "Sociétés" + +#. module: sale_order_blanket_order +#: model:ir.model,name:sale_order_blanket_order.model_res_config_settings +msgid "Config Settings" +msgstr "Configuration" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order__create_call_off_from_so_if_possible +msgid "Create Call Off From So If Possible" +msgstr "Créer une commande d'appel à partir si possible" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_res_company__create_call_off_from_so_if_possible +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_res_config_settings__create_call_off_from_so_if_possible +msgid "Create Call-Off from SO if possible" +msgstr "Créer une commande d'appel à partir d'une commande client si possible" + +#. module: sale_order_blanket_order +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_search_view +msgid "Current Blanket Orders" +msgstr "Commandes-cadre actuelles" + +#. module: sale_order_blanket_order +#: model:ir.model.fields.selection,name:sale_order_blanket_order.selection__sale_order__blanket_eol_strategy__deliver +msgid "Deliver Remaining Quantity" +msgstr "Livrer les quantités restantes" + +#. module: sale_order_blanket_order +#: model:ir.actions.server,name:sale_order_blanket_order.ir_cron_sale_order_blanket_order_finalizer_ir_actions_server +#: model:ir.cron,cron_name:sale_order_blanket_order.ir_cron_sale_order_blanket_order_finalizer +msgid "Finalize expired sale blanket orders" +msgstr "Finaliser les commandes-cadre expirées" + +#. module: sale_order_blanket_order +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_form_view +msgid "From" +msgstr "De" + +#. module: sale_order_blanket_order +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.res_config_settings_form_view +msgid "" +"Generate a call-off order from a sales order if possible when the sales " +"order is confirmed." +msgstr "" +"Générer une commande d'appel à partir d'une commande client si possible " +"lorsque la commande client est confirmée." + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_res_company__create_call_off_from_so_if_possible +#: model:ir.model.fields,help:sale_order_blanket_order.field_res_config_settings__create_call_off_from_so_if_possible +msgid "" +"If checked, when a sales order is confirmed and some lines refer to a " +"blanket order, these lines will be automatically moved to a new call-off " +"order." +msgstr "" +"Si coché, lorsqu'une commande client est confirmée et que certaines lignes " +"référencent une commande-cadre, ces lignes seront automatiquement déplacées " +"vers une nouvelle commande d'appel." + +#. module: sale_order_blanket_order +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.res_config_settings_form_view +msgid "" +"If this option is enabled, a call-off order will be generated from a sale order if the sale order contains products\n" +" that are part of an open blanket order. The call-off order will be generated with the same products and quantities as the\n" +" sale order provided that the blanket order has enough available quantity. If the blanket order does not have enough available\n" +" quantity, the call-off order will be generated with the available quantity and the original sale order line will be\n" +" split into two lines: one line with the available quantity and one line with the remaining quantity. All the lines related to\n" +" a blanket order will be moved to a new call-off order and the new call-off order will be confirmed." +msgstr "" +"Si cette option est activée, une commande d'appel sera générée à partir d'une commande client si la commande client contient des produits\n" +" qui font partie d'une commande-cadre ouverte. La commande d'appel sera générée avec les mêmes produits et quantités que la\n" +" commande client à condition que la commande-cadre ait une quantité disponible suffisante. Si la commande-cadre n'a pas une quantité\n" +" disponible suffisante, la commande d'appel sera générée avec la quantité disponible et la ligne de commande client d'origine sera\n" +" divisée en deux lignes : une ligne avec la quantité disponible et une ligne avec la quantité restante. Toutes les lignes liées à\n" +" une commande-cadre seront déplacées vers une nouvelle commande d'appel et la nouvelle commande d'appel sera confirmée." + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order__blanket_need_to_be_finalized +msgid "" +"Indicates if the blanket order needs to be finalized. This field is a " +"technical field used to manage the end-of-life of the blanket orders. To " +"avoid costly operations to determine if the blanket order needs to be " +"finalized once the validity period is reached, the system will set this " +"field to True when a blanket order is confirmed. To know if a blanket order " +"needs to be finalized, the system will search for the blanket orders with " +"this field set to True and the validity period reached.Once the blanket " +"order is finalized, the system will set this field to False. In this way, " +"the DB will always contain less records to search with this field set to " +"True." +msgstr "" +"Indique si la commande-cadre doit être finalisée. Ce champ est un champ " +"technique utilisé pour gérer la fin de vie des commandes-cadre. Pour éviter " +"des opérations coûteuses pour déterminer si la commande-cadre doit être " +"finalisée une fois la période de validité atteinte, le système définira ce " +"champ à True lorsqu'une commande-cadre est confirmée. Pour savoir si une " +"commande-cadre doit être finalisée, le système recherchera les commandes-" +"cadre avec ce champ défini à True et la période de validité atteinte. Une " +"fois la commande-cadre finalisée, le système définira ce champ à False. De " +"cette manière, la base de données contiendra toujours moins " +"d'enregistrements à rechercher avec ce champ défini à True." + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order__is_blanket_eol_strategy_editable +msgid "" +"Indicates if the end-of-life strategy can be edited. By default, the end-of-" +"life strategy can be edited while the blanket order is not finalized." +msgstr "" +"Indique si la stratégie de fin de vie peut être modifiée. Par défaut, la " +"stratégie de fin de vie peut être modifiée tant que la commande-cadre n'est " +"pas finalisée." + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order__is_blanket_reservation_strategy_editable +msgid "Indicates if the reservation strategy can be edited." +msgstr "Indique si la stratégie de réservation peut être modifiée." + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order__is_blanket_eol_strategy_editable +msgid "Is Blanket Eol Strategy Editable" +msgstr "La stratégie de fin de vie de la commande-cadre est-elle modifiable" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order__is_blanket_reservation_strategy_editable +msgid "Is Blanket Reservation Strategy Editable" +msgstr "La stratégie de réservation de la commande-cadre est-elle modifiable" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order__blanket_need_to_be_finalized +msgid "Need to be Finalized" +msgstr "A finaliser" + +#. module: sale_order_blanket_order +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_line_search_view +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_search_view +msgid "Normal Orders" +msgstr "Commandes normales" + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "Only blanket orders can be confirmed as blanket orders." +msgstr "" +"Seules les commandes-cadre peuvent être confirmées en tant que commandes-" +"cadre." + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "Only call-off orders can be confirmed as call-off orders." +msgstr "" +"Seules les commandes d'appel peuvent être confirmées en tant que commandes " +"d'appel." + +#. module: sale_order_blanket_order +#: model:ir.model.fields.selection,name:sale_order_blanket_order.selection__sale_order__order_type__order +msgid "Order" +msgstr "Commande" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order__order_type +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order_line__order_type +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_line_search_view +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_search_view +msgid "Order Type" +msgstr "Type de commande" + +#. module: sale_order_blanket_order +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_form_view +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_line_form_view +msgid "Picking moves" +msgstr "Mouvements de préparation" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order_line__call_off_remaining_qty +msgid "Quantity remaining for Call-off" +msgstr "Quantité restante à l'appel" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order__blanket_reservation_strategy +msgid "Reservation Strategy" +msgstr "Stratégie de réservation" + +#. module: sale_order_blanket_order +#: model:ir.model,name:sale_order_blanket_order.model_sale_order +msgid "Sales Order" +msgstr "Bon de commande" + +#. module: sale_order_blanket_order +#: model:ir.model,name:sale_order_blanket_order.model_sale_order_line +msgid "Sales Order Line" +msgstr "Ligne de bons de commande" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order__blanket_eol_strategy +msgid "" +"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." +msgstr "" +"Spécifie la stratégie de fin de vie de la commande-cadre. A la fin de la " +"période de validité, dans tous les cas si une quantité réservée reste, le " +"système libérera la réservation. Si la stratégie est 'Livrer les quantités " +"restantes', le système créera automatiquement un bon de livraison pour les " +"quantités restantes." + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order__order_type +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order_line__order_type +msgid "Specifies the type of sale order: Order, Blanket, or Call-off." +msgstr "" +"Spécifie le type de commande client : Commande, Commande-cadre ou Commande " +"d'appel." + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order__blanket_reservation_strategy +msgid "" +"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." +msgstr "" +"Spécifie quand le stock doit être réservé pour la commande-cadre. Lorsque la" +" stratégie est 'A la confirmation de la commande', le stock est réservé " +"lorsque la commande-cadre est confirmée. Lorsque la stratégie est 'A " +"l'appel', le stock est réservé lorsque la commande d'appel est confirmée." + +#. module: sale_order_blanket_order +#: model:ir.model,name:sale_order_blanket_order.model_stock_move +msgid "Stock Move" +msgstr "Mouvement de stock" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order_line__blanket_move_ids +msgid "Stock Moves on Blanket Order" +msgstr "Mouvements de stock sur la commande-cadre" + +#. module: sale_order_blanket_order +#: model:ir.model,name:sale_order_blanket_order.model_stock_rule +msgid "Stock Rule" +msgstr "Règle de stock minimum" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order_line__blanket_line_id +msgid "The blanket order line corresponding to this call-off order line." +msgstr "" +"La ligne de commande-cadre correspondant à cette ligne de commande d'appel." + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "The blanket order must be confirmed before creating a call-off order." +msgstr "" +"La commande-cadre doit être confirmée avant de créer une commande d'appel." + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order__blanket_order_id +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order_line__blanket_order_id +msgid "The blanket order that this call-off order is related to." +msgstr "La commande-cadre à laquelle cette commande d'appel est liée." + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order_line__call_off_line_ids +msgid "The call-off order lines linked to this blanket order line." +msgstr "Les lignes de commande d'appel liées à cette ligne de commande-cadre." + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "" +"The call-off order must be within the validity period of the blanket order." +msgstr "" +"La commande d'appel doit être dans la période de validité de la commande-" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order__call_off_order_ids +msgid "The call-off orders related to this blanket order." +msgstr "Les commandes d'appel liées à cette commande-cadre." + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order__blanket_order_id_domain +msgid "The domain to search for the blanket order candidates." +msgstr "Le domaine pour rechercher les commandes-cadre candidates." + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order__blanket_validity_end_date +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order_line__blanket_validity_end_date +msgid "The end date of the validity period for the blanket order." +msgstr "La date de fin de la période de validité de la commande-cadre." + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "The end-of-life strategy cannot be modified on order %(order)s." +msgstr "" +"La stratégie de fin de vie ne peut pas être modifiée sur la commande " +"%(order)s." + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "" +"The line %(line)s has been created from order %(order)s. (Qty moved: " +"%(qty_deliverable)s)" +msgstr "" +"La ligne %(line)s a été créée à partir de la commande %(order)s. (Qté " +"déplacée: %(qty_deliverable)s)" + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "The line %(line)s has been moved from order %(order)s." +msgstr "La ligne %(line)s a été déplacée de la commande %(order)s." + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "The line %(line)s has been moved to a new call-off order %(call_off)s." +msgstr "" +"La ligne %(line)s a été déplacée vers une nouvelle commande d'appel " +"%(call_off)s." + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "" +"The line %(line)s has been partially moved to a new call-off order " +"%(call_off)s. (Qty moved: %(qty_deliverable)s)" +msgstr "" +"La ligne %(line)s a été partiellement déplacée vers une nouvelle commande " +"d'appel %(call_off)s. (Qté déplacée: %(qty_deliverable)s)" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order__call_off_order_count +msgid "The number of call-off orders related to this blanket order." +msgstr "Le nombre de commandes d'appel liées à cette commande-cadre." + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order_line.py:0 +#, python-format +msgid "" +"The price of a call-off order line must be 0.0. (Order: '%(order)s', " +"Product: '%(product)s')" +msgstr "" +"Le prix d'une ligne de commande d'appel doit être de 0,0. (Commande: " +"'%(order)s', Produit: '%(product)s')" + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order_line.py:0 +#, python-format +msgid "" +"The product '%(product_name)s' is already part of another blanket order " +"%(order_name)s." +msgstr "" +"Le produit '%(product_name)s' fait déjà partie d'une autre commande-cadre " +"%(order_name)s." + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order_line.py:0 +#, python-format +msgid "" +"The product is not part of linked blanket order. (Product: '%(product)s', " +"Order: '%(order)s', Blanket Order: '%(blanket_order)s')" +msgstr "" +"Le produit ne fait pas partie de la commande-cadre liée. (Produit: " +"'%(product)s', Commande: '%(order)s', Commande-cadre: '%(blanket_order)s')" + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order_line.py:0 +#, python-format +msgid "" +"The forecasted quantity cannot be less than the quantity already called by " +"call-off orders." +msgstr "" +"La quantité prévue ne peut pas être inférieure à la quantité déjà appelée par " +"les commandes d'appel." + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order_line__call_off_remaining_qty +msgid "" +"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." +msgstr "" +"La quantité restante à consommer par les commandes d'appel en cas de " +"commande-cadre. Cette quantité est la différence entre la quantité non " +"encore livrée ou faisant partie d'une livraison en attente et la quantité " +"commandée." + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order_line.py:0 +#, python-format +msgid "" +"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')" +msgstr "" +"La quantité à acheter est supérieure à la quantité restante à livrer dans la" +" commande-cadre liée pour ce produit. (Produit: '%(product)s', Commande: " +"'%(order)s', Commande-cadre: '%(blanket_order)s')" + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "The reservation strategy cannot be modified on order %(order)s." +msgstr "" +"La stratégie de réservation ne peut pas être modifiée sur la commande " +"%(order)s." + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order__blanket_validity_start_date +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order_line__blanket_validity_start_date +msgid "The start date of the validity period for the blanket order." +msgstr "La date de début de la période de validité de la commande-cadre." + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "The validity end date is required for a blanket order." +msgstr "La date de fin de validité est requise pour une commande-cadre." + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#: code:addons/sale_order_blanket_order/models/sale_order_line.py:0 +#, python-format +msgid "The validity end date must be greater than the validity start date." +msgstr "" +"La date de fin de validité doit être supérieure à la date de début de " +"validité." + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "The validity start date is required for a blanket order." +msgstr "La date de début de validité est requise pour une commande-cadre." + +#. module: sale_order_blanket_order +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_form_view +msgid "To" +msgstr "A" + +#. module: sale_order_blanket_order +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_form_view +msgid "To Call-off" +msgstr "A l'appel" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order__blanket_validity_end_date +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order_line__blanket_validity_end_date +msgid "Validity End Date" +msgstr "Date de fin de validité" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order__blanket_validity_start_date +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order_line__blanket_validity_start_date +msgid "Validity Start Date" +msgstr "Date de début de validité" + +#. module: sale_order_blanket_order +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_form_view +msgid "Validity period" +msgstr "Période de validité" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order__create_call_off_from_so_if_possible +msgid "" +"When this option is enabled, the system will automatically create call-off " +"orders when a sales order is confirmed and some lines refer to a blanket " +"order." +msgstr "" +"Lorsque cette option est activée, le système créera automatiquement des " +"commandes d'appel lorsque une commande client est confirmée et que certaines" +" lignes font référence à une commande-cadre." \ No newline at end of file diff --git a/sale_order_blanket_order/i18n/sale_order_blanket_order.pot b/sale_order_blanket_order/i18n/sale_order_blanket_order.pot new file mode 100644 index 00000000000..4a461dbe1fe --- /dev/null +++ b/sale_order_blanket_order/i18n/sale_order_blanket_order.pot @@ -0,0 +1,555 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * sale_order_blanket_order +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-12-19 09:27+0000\n" +"PO-Revision-Date: 2024-12-19 09:27+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: sale_order_blanket_order +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_form_view +msgid "" +"" +msgstr "" + +#. module: sale_order_blanket_order +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.res_config_settings_form_view +msgid "" +"" +msgstr "" + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "A blanket order cannot have a blanket order." +msgstr "" + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "A call-off order must have a blanket order." +msgstr "" + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "An order cannot have a blanket order." +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields.selection,name:sale_order_blanket_order.selection__sale_order__blanket_reservation_strategy__at_call_off +msgid "At Call-off" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order__blanket_eol_strategy +msgid "Blanket Eol Strategy" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order__blanket_order_id +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order_line__blanket_order_id +#: model:ir.model.fields.selection,name:sale_order_blanket_order.selection__sale_order__order_type__blanket +msgid "Blanket Order" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order__blanket_order_id_domain +msgid "Blanket Order Domain" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order_line__blanket_line_id +msgid "Blanket Order Line" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.actions.act_window,name:sale_order_blanket_order.action_blankets +#: model:ir.ui.menu,name:sale_order_blanket_order.blanket_order_menu +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_line_search_view +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_search_view +msgid "Blanket Orders" +msgstr "" + +#. module: sale_order_blanket_order +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_line_search_view +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_search_view +msgid "Call Off Orders" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_stock_move__call_off_sale_line_id +msgid "Call Off Sale Line" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields.selection,name:sale_order_blanket_order.selection__sale_order__order_type__call_off +msgid "Call-off Order" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order__call_off_order_count +msgid "Call-off Order Count" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order_line__call_off_line_ids +msgid "Call-off Order Lines" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.actions.act_window,name:sale_order_blanket_order.action_calloffs +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order__call_off_order_ids +#: model:ir.ui.menu,name:sale_order_blanket_order.calloff_order_menu +msgid "Call-off Orders" +msgstr "" + +#. module: sale_order_blanket_order +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_form_view +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_line_form_view +msgid "Call-off lines" +msgstr "" + +#. module: sale_order_blanket_order +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_form_view +msgid "Call-offs" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model,name:sale_order_blanket_order.model_res_company +msgid "Companies" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model,name:sale_order_blanket_order.model_res_config_settings +msgid "Config Settings" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order__create_call_off_from_so_if_possible +msgid "Create Call Off From So If Possible" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_res_company__create_call_off_from_so_if_possible +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_res_config_settings__create_call_off_from_so_if_possible +msgid "Create Call-Off from SO if possible" +msgstr "" + +#. module: sale_order_blanket_order +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_search_view +msgid "Current Blanket Orders" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields.selection,name:sale_order_blanket_order.selection__sale_order__blanket_eol_strategy__deliver +msgid "Deliver Remaining Quantity" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.actions.server,name:sale_order_blanket_order.ir_cron_sale_order_blanket_order_finalizer_ir_actions_server +#: model:ir.cron,cron_name:sale_order_blanket_order.ir_cron_sale_order_blanket_order_finalizer +msgid "Finalize expired sale blanket orders" +msgstr "" + +#. module: sale_order_blanket_order +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_form_view +msgid "From" +msgstr "" + +#. module: sale_order_blanket_order +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.res_config_settings_form_view +msgid "" +"Generate a call-off order from a sales order if possible when the sales " +"order is confirmed." +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_res_company__create_call_off_from_so_if_possible +#: model:ir.model.fields,help:sale_order_blanket_order.field_res_config_settings__create_call_off_from_so_if_possible +msgid "" +"If checked, when a sales order is confirmed and some lines refer to a " +"blanket order, these lines will be automatically moved to a new call-off " +"order." +msgstr "" + +#. module: sale_order_blanket_order +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.res_config_settings_form_view +msgid "" +"If this option is enabled, a call-off order will be generated from a sale order if the sale order contains products\n" +" that are part of an open blanket order. The call-off order will be generated with the same products and quantities as the\n" +" sale order provided that the blanket order has enough available quantity. If the blanket order does not have enough available\n" +" quantity, the call-off order will be generated with the available quantity and the original sale order line will be\n" +" split into two lines: one line with the available quantity and one line with the remaining quantity. All the lines related to\n" +" a blanket order will be moved to a new call-off order and the new call-off order will be confirmed." +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order__blanket_need_to_be_finalized +msgid "" +"Indicates if the blanket order needs to be finalized. This field is a " +"technical field used to manage the end-of-life of the blanket orders. To " +"avoid costly operations to determine if the blanket order needs to be " +"finalized once the validity period is reached, the system will set this " +"field to True when a blanket order is confirmed. To know if a blanket order " +"needs to be finalized, the system will search for the blanket orders with " +"this field set to True and the validity period reached.Once the blanket " +"order is finalized, the system will set this field to False. In this way, " +"the DB will always contain less records to search with this field set to " +"True." +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order__is_blanket_eol_strategy_editable +msgid "" +"Indicates if the end-of-life strategy can be edited. By default, the end-of-" +"life strategy can be edited while the blanket order is not finalized." +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order__is_blanket_reservation_strategy_editable +msgid "Indicates if the reservation strategy can be edited." +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order__is_blanket_eol_strategy_editable +msgid "Is Blanket Eol Strategy Editable" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order__is_blanket_reservation_strategy_editable +msgid "Is Blanket Reservation Strategy Editable" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order__blanket_need_to_be_finalized +msgid "Need to be Finalized" +msgstr "" + +#. module: sale_order_blanket_order +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_line_search_view +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_search_view +msgid "Normal Orders" +msgstr "" + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "Only blanket orders can be confirmed as blanket orders." +msgstr "" + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "Only call-off orders can be confirmed as call-off orders." +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields.selection,name:sale_order_blanket_order.selection__sale_order__order_type__order +msgid "Order" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order__order_type +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order_line__order_type +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_line_search_view +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_search_view +msgid "Order Type" +msgstr "" + +#. module: sale_order_blanket_order +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_form_view +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_line_form_view +msgid "Picking moves" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order_line__call_off_remaining_qty +msgid "Quantity remaining for Call-off" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order__blanket_reservation_strategy +msgid "Reservation Strategy" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model,name:sale_order_blanket_order.model_sale_order +msgid "Sales Order" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model,name:sale_order_blanket_order.model_sale_order_line +msgid "Sales Order Line" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order__blanket_eol_strategy +msgid "" +"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." +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order__order_type +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order_line__order_type +msgid "Specifies the type of sale order: Order, Blanket, or Call-off." +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order__blanket_reservation_strategy +msgid "" +"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." +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model,name:sale_order_blanket_order.model_stock_move +msgid "Stock Move" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order_line__blanket_move_ids +msgid "Stock Moves on Blanket Order" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model,name:sale_order_blanket_order.model_stock_rule +msgid "Stock Rule" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order_line__blanket_line_id +msgid "The blanket order line corresponding to this call-off order line." +msgstr "" + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "The blanket order must be confirmed before creating a call-off order." +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order__blanket_order_id +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order_line__blanket_order_id +msgid "The blanket order that this call-off order is related to." +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order_line__call_off_line_ids +msgid "The call-off order lines linked to this blanket order line." +msgstr "" + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "" +"The call-off order must be within the validity period of the blanket order." +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order__call_off_order_ids +msgid "The call-off orders related to this blanket order." +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order__blanket_order_id_domain +msgid "The domain to search for the blanket order candidates." +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order__blanket_validity_end_date +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order_line__blanket_validity_end_date +msgid "The end date of the validity period for the blanket order." +msgstr "" + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "The end-of-life strategy cannot be modified on order %(order)s." +msgstr "" + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "" +"The line %(line)s has been created from order %(order)s. (Qty moved: " +"%(qty_deliverable)s)" +msgstr "" + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "The line %(line)s has been moved from order %(order)s." +msgstr "" + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "The line %(line)s has been moved to a new call-off order %(call_off)s." +msgstr "" + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "" +"The line %(line)s has been partially moved to a new call-off order " +"%(call_off)s. (Qty moved: %(qty_deliverable)s)" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order__call_off_order_count +msgid "The number of call-off orders related to this blanket order." +msgstr "" + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order_line.py:0 +#, python-format +msgid "" +"The price of a call-off order line must be 0.0. (Order: '%(order)s', " +"Product: '%(product)s')" +msgstr "" + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order_line.py:0 +#, python-format +msgid "" +"The product '%(product_name)s' is already part of another blanket order " +"%(order_name)s." +msgstr "" + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order_line.py:0 +#, python-format +msgid "" +"The product is not part of linked blanket order. (Product: '%(product)s', " +"Order: '%(order)s', Blanket Order: '%(blanket_order)s')" +msgstr "" + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order_line.py:0 +#, python-format +msgid "" +"The forecasted quantity cannot be less than the quantity already called by " +"call-off orders." +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order_line__call_off_remaining_qty +msgid "" +"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." +msgstr "" + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order_line.py:0 +#, python-format +msgid "" +"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')" +msgstr "" + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "The reservation strategy cannot be modified on order %(order)s." +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order__blanket_validity_start_date +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order_line__blanket_validity_start_date +msgid "The start date of the validity period for the blanket order." +msgstr "" + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "The validity end date is required for a blanket order." +msgstr "" + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#: code:addons/sale_order_blanket_order/models/sale_order_line.py:0 +#, python-format +msgid "The validity end date must be greater than the validity start date." +msgstr "" + +#. module: sale_order_blanket_order +#. odoo-python +#: code:addons/sale_order_blanket_order/models/sale_order.py:0 +#, python-format +msgid "The validity start date is required for a blanket order." +msgstr "" + +#. module: sale_order_blanket_order +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_form_view +msgid "To" +msgstr "" + +#. module: sale_order_blanket_order +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_form_view +msgid "To Call-off" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order__blanket_validity_end_date +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order_line__blanket_validity_end_date +msgid "Validity End Date" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order__blanket_validity_start_date +#: model:ir.model.fields,field_description:sale_order_blanket_order.field_sale_order_line__blanket_validity_start_date +msgid "Validity Start Date" +msgstr "" + +#. module: sale_order_blanket_order +#: model_terms:ir.ui.view,arch_db:sale_order_blanket_order.sale_order_form_view +msgid "Validity period" +msgstr "" + +#. module: sale_order_blanket_order +#: model:ir.model.fields,help:sale_order_blanket_order.field_sale_order__create_call_off_from_so_if_possible +msgid "" +"When this option is enabled, the system will automatically create call-off " +"orders when a sales order is confirmed and some lines refer to a blanket " +"order." +msgstr "" \ No newline at end of file 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..af128f26399 --- /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 sales 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..25f96a0bf7e --- /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 sales 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..a76baca845a --- /dev/null +++ b/sale_order_blanket_order/models/sale_order.py @@ -0,0 +1,729 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from collections import defaultdict +from contextlib import contextmanager +from datetime import datetime + +from odoo import Command, _, api, fields, models +from odoo.exceptions import ValidationError +from odoo.osv import expression +from odoo.osv.expression import FALSE_DOMAIN +from odoo.tools import float_compare + +from odoo.addons.sale.models.sale_order import READONLY_FIELD_STATES + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + order_type = fields.Selection( + [ + ("order", "Order"), + ("blanket", "Blanket Order"), + ("call_off", "Call-off Order"), + ], + default="order", + required=True, + help="Specifies the type of sale order: Order, Blanket, or Call-off.", + states=READONLY_FIELD_STATES, + index=True, + ) + blanket_order_id = fields.Many2one( + "sale.order", + string="Blanket Order", + help="The blanket order that this call-off order is related to.", + states=READONLY_FIELD_STATES, + index="btree_not_null", + ) + blanket_order_id_domain = fields.Binary( + string="Blanket Order Domain", + compute="_compute_blanket_order_id_domain", + help="The domain to search for the blanket order candidates.", + ) + 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, + 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, + ) + is_blanket_reservation_strategy_editable = fields.Boolean( + compute="_compute_is_blanket_reservation_strategy_editable", + help="Indicates if the reservation strategy can be edited.", + ) + blanket_eol_strategy = fields.Selection( + [ + ("deliver", "Deliver Remaining Quantity"), + ], + 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.", + ) + is_blanket_eol_strategy_editable = fields.Boolean( + compute="_compute_is_blanket_eol_strategy_editable", + help="Indicates if the end-of-life strategy can be edited. By default, the " + "end-of-life strategy can be edited while the blanket order is not finalized.", + ) + blanket_need_to_be_finalized = fields.Boolean( + string="Need to be Finalized", + help="Indicates if the blanket order needs to be finalized. This field is " + "a technical field used to manage the end-of-life of the blanket orders. " + "To avoid costly operations to determine if the blanket order needs to be " + "finalized once the validity period is reached, the system will set this " + "field to True when a blanket order is confirmed. To know if a blanket " + "order needs to be finalized, the system will search for the blanket " + "orders with this field set to True and the validity period reached." + "Once the blanket order is finalized, the system will set this field to " + "False. In this way, the DB will always contain less records to search " + "with this field set to True.", + default=False, + ) + + create_call_off_from_so_if_possible = fields.Boolean( + default=lambda self: self.env.company.create_call_off_from_so_if_possible, + help="When this option is enabled, the system will automatically create " + "call-off orders when a sales order is confirmed and some lines refer to a " + "blanket order.", + states=READONLY_FIELD_STATES, + ) + + def init(self): + self._cr.execute( + """ + CREATE INDEX IF NOT EXISTS + sale_order_blanket_order_to_finalize_index + ON + sale_order (blanket_need_to_be_finalized) + WHERE + blanket_need_to_be_finalized IS TRUE; + """ + ) + + @api.constrains("order_type", "blanket_order_id", "state") + def _check_order_type(self): + for order in self: + if order.state != "sale": + continue + 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 == "order" and order.blanket_order_id: + raise ValidationError(_("An order cannot have a blanket order.")) + + @api.constrains( + "order_type", + "blanket_validity_start_date", + "blanket_validity_end_date", + "state", + ) + def _check_validity_dates(self): + for order in self: + if order.state != "sale": + continue + 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", "state" + ) + def _check_call_of_link_to_valid_blanket(self): + for rec in self: + if rec.state != "sale": + continue + 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 not in ("sale", "done") + ): + continue + expected_delivery_date = rec.commitment_date or rec.date_order + 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.state != "sale": + continue + 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 not in ( + "sale", + "done", + ): + raise ValidationError( + _( + "The blanket order must be confirmed before creating a call-off order." + ) + ) + + @api.depends("order_type", "commitment_date", "partner_id") + def _compute_blanket_order_id_domain(self): + for order in self: + if order.order_type == "call_off" and self.partner_id: + domain = order._get_single_blanket_order_candidates_domain() + else: + domain = FALSE_DOMAIN + + order.blanket_order_id_domain = domain + + @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) + + @api.depends("blanket_need_to_be_finalized", "state", "order_type") + def _compute_is_blanket_reservation_strategy_editable(self): + for order in self: + order.is_blanket_reservation_strategy_editable = ( + order.state not in ("cancel", "sent") + and (order.blanket_need_to_be_finalized or order.state == "draft") + and order.order_type == "blanket" + ) + + @api.depends("blanket_need_to_be_finalized", "state", "order_type") + def _compute_is_blanket_eol_strategy_editable(self): + for order in self: + order.is_blanket_eol_strategy_editable = ( + order.state not in ("cancel", "sent") + and (order.blanket_need_to_be_finalized or order.state == "draft") + and order.order_type == "blanket" + ) + + def _check_blanket_reservation_strategy_editable(self, vals): + if "blanket_reservation_strategy" in vals: + for order in self: + if order.is_blanket_reservation_strategy_editable: + continue + raise ValidationError( + _( + "The reservation strategy cannot be modified on order %(order)s.", + order=order.name, + ) + ) + + def _check_blanket_eol_strategy_editable(self, vals): + if "blanket_eol_strategy" in vals: + for order in self: + if order.is_blanket_eol_strategy_editable: + continue + raise ValidationError( + _( + "The end-of-life strategy cannot be modified on order %(order)s.", + order=order.name, + ) + ) + + def action_view_call_off_orders(self): + self.ensure_one() + action = self.env["ir.actions.act_window"]._for_xml_id("sale.action_orders") + 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() + 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 + else: + orders |= order + if blanket_orders: + blanket_orders._on_blanket_order_confirm() + + 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.") + ) + # trigger validation on sale order lines for constrains + # _validate_fields We force the validation to be done here + # even if it will be done later at flush time. This is to + # ensure that the data are correct before performing any others + # operations that could use the data. + self.order_line._check_blanket_product_not_overlapping() + for order in self: + order.commitment_date = order.blanket_validity_start_date + self.blanket_need_to_be_finalized = True + self._blanket_order_reserve_call_off_remaining_qty() + + 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 _ensure_reservation_strategy(self, strategy): + """Ensure the reservation strategy is the expected one.""" + invalid_orders = self.filtered( + lambda order: order.blanket_reservation_strategy != strategy + ) + if invalid_orders: + ref = ", ".join(invalid_orders.mapped("name")) + raise ValueError( + f"Invalid reservation strategy {strategy} for the blanket orders {ref}." + ) + + def _blanket_order_reserve_call_off_remaining_qty(self): + """Reserve the stock for the blanket order. + + This method should only take care of the potentiel stock reservation + for the qty available to call off. + """ + self._ensure_reservation_strategy("at_call_off") + + # By setting the manual delivery flag to True, the delivery will not be + # created at confirmation time. The delivery process will be triggered by + # the system when a call-off order is confirmed. + self._set_manual_delivery(True) + + def _blanket_order_release_call_off_remaining_qty(self): + """Release the stock reservation for the blanket order. + + This method should only take care of the potentiel stock reservation + for the qty available to call off. + """ + self._ensure_reservation_strategy("at_call_off") + # reset the manual delivery flag to False + self._set_manual_delivery(False) + + def _set_manual_delivery(self, value): + """Set manual delivery.""" + # the manual delivery can oly be set on draft orders. Unfortunatly, the + # state could be set to sale or done 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 = value + order.state = old_state + + def _split_for_blanket_order(self): + """Split the orders for the blanket order. + + This method is called for 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 != "order")): + raise ValueError("Only orders can be split.") + + splitable_orders = self.filtered( + lambda order: order.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 + original_order = line.order_id + if ( + float_compare( + qty_deliverable, + line.product_uom_qty, + precision_rounding=rounding, + ) + >= 0 + ): + # The blanket line has enough remaining quantity for the line + # We move the line to the call-off order + line.price_unit = 0 + line.order_id = call_off + self._log_line_moved_to_call_off(line, call_off, original_order) + break + # The blanket line does not have enough remaining quantity for the line + # We split the line and move the part deliverable to the call-off order + new_line = line.copy( + default={ + "product_uom_qty": qty_deliverable, + "order_id": call_off.id, + "price_unit": 0, + } + ) + # set the order_id again to ensure that the computed fields are recomputes + new_line.order_id = call_off + line.product_uom_qty -= qty_deliverable + self._log_line_partially_moved_to_call_off( + new_line, line, call_off, original_order, qty_deliverable + ) + # values() is a generator of sets of values. We want to concatenate all the sets + # into a single set of values. + new_call_off_orders = self.env["sale.order"] + for call_off in new_call_off_by_order.values(): + new_call_off_orders |= call_off + new_call_off_orders.action_confirm() + return new_call_off_orders + + def _log_line_moved_to_call_off(self, line, call_off, original_order): + """Log the line movement to the call-off order.""" + original_order.message_post( + body=_( + _( + "The line %(line)s has been moved to a new call-off order %(call_off)s." + ), + line=line.display_name, + call_off=call_off._get_html_link(), + ) + ) + call_off.message_post( + body=_( + _("The line %(line)s has been moved from order %(order)s."), + line=line.display_name, + order=original_order._get_html_link(), + ) + ) + + def _log_line_partially_moved_to_call_off( + self, new_line, line, call_off, original_order, qty_deliverable + ): + """Log the line partial movement to the call-off order.""" + call_off.message_post( + body=_( + _( + "The line %(line)s has been created from order %(order)s. " + "(Qty moved: %(qty_deliverable)s)" + ), + line=new_line.display_name, + order=original_order._get_html_link(), + qty_deliverable=qty_deliverable, + ) + ) + original_order.message_post( + body=_( + _( + "The line %(line)s has been partially moved to a new call-off " + "order %(call_off)s. (Qty moved: %(qty_deliverable)s)" + ), + line=line.display_name, + call_off=call_off._get_html_link(), + qty_deliverable=qty_deliverable, + ) + ) + + def _get_default_call_off_order_values(self, blanket_order_id): + """Get the default values to create a new call-off order.""" + self.ensure_one() + vals = { + "partner_id": self.partner_id.id, + "order_type": "call_off", + "blanket_order_id": blanket_order_id.id, + "order_line": False, + } + if self.commitment_date: + vals["commitment_date"] = self.commitment_date + return vals + + 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() + validity_date = self.commitment_date or self.date_order or fields.Date.today() + validity_date = fields.Date.to_string(validity_date) + return [ + ("partner_id", "=", self.partner_id.id), + ("partner_shipping_id", "=", self.partner_shipping_id.id), + ("order_type", "=", "blanket"), + ("state", "in", ("sale", "done")), + ("blanket_validity_start_date", "<=", validity_date), + ("blanket_validity_end_date", ">=", validity_date), + ] + + 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" + ) + + def _cron_manage_blanket_order_eol(self): + """Manage the end-of-life of the blanket orders.""" + blanket_orders = self.search( + [ + ("order_type", "=", "blanket"), + ("state", "in", ("sale", "done")), + ("blanket_validity_end_date", "<", fields.Date.today()), + ("blanket_need_to_be_finalized", "=", True), + ] + ) + blanket_orders._blanket_order_eol() + + def _blanket_order_eol(self): + """End-of-life process for the blanket orders.""" + self.filtered( + lambda order: order.blanket_eol_strategy == "deliver" + )._blanket_order_deliver_remaining_qty() + self.write({"blanket_need_to_be_finalized": False}) + + def _blanket_order_deliver_remaining_qty(self): + """Deliver the remaining quantity for the blanket orders. + + We will create a call-off order for the remaining quantity of the blanket order. + and confirm it. + """ + for record in self: + order_lines = [] + for line in record.order_line: + if ( + float_compare( + line.call_off_remaining_qty, + 0, + precision_rounding=line.product_uom.rounding, + ) + > 0 + ): + order_lines.append( + Command.create( + line._prepare_call_of_vals_to_deliver_blanket_remaining_qty() + ) + ) + if order_lines: + call_off_order = self.env["sale.order"].create( + record._prepare_call_of_vals_to_deliver_blanket_remaining_qty() + ) + call_off_order.order_line = order_lines + call_off_order.action_confirm() + + def _prepare_call_of_vals_to_deliver_blanket_remaining_qty(self): + """Prepare the values to create a call-off order for the remaining quantity.""" + self.ensure_one() + vals = self._get_default_call_off_order_values(self) + vals["commitment_date"] = self.blanket_validity_end_date + return vals + + def _split_recrodset_for_reservation_strategy(self, strategy): + """Split the orders for the reservation strategy. + + This method will return a tuple where the first element is + the recordset with the expected reservation strategy and the + second element is the recordset without the expected reservation + strategy. + """ + other_orders = self.browse() + orders_with_strategy = self.browse() + for order in self: + if order.blanket_reservation_strategy == strategy: + orders_with_strategy |= order + else: + other_orders |= order + return orders_with_strategy, other_orders + + def _before_reservation_strategy_changed(self, old_value, new_value): + """Method called when the reservation strategy is modified.""" + self.ensure_one() + self._blanket_order_release_call_off_remaining_qty() + + def _after_reservation_strategy_changed(self, old_value, new_value): + self.ensure_one() + if self.state in ("sale", "done"): + self._blanket_order_reserve_call_off_remaining_qty() + + @contextmanager + def _notify_reservation_strategy_changed(self, values): + """Notify the reservation strategy change""" + strategy_by_record = {} + new_strategy = values.get("blanket_reservation_strategy") + if "blanket_reservation_strategy" in values: + strategy_by_record = { + record: record.blanket_reservation_strategy + for record in self + if record.blanket_reservation_strategy != new_strategy + } + for record, old_strategy in strategy_by_record.items(): + record._before_reservation_strategy_changed(old_strategy, new_strategy) + yield + if new_strategy: + for record, old_strategy in strategy_by_record.items(): + record._after_reservation_strategy_changed(old_strategy, new_strategy) + + def write(self, values): + self._check_blanket_reservation_strategy_editable(values) + self._check_blanket_eol_strategy_editable(values) + with self._notify_reservation_strategy_changed(values): + return super().write(values) + + def _action_cancel(self): + self.filtered( + lambda so: so.order_type == "blanket" + and so.blanket_reservation_strategy == "at_call_off" + )._blanket_order_release_call_off_remaining_qty() + return super()._action_cancel() 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..05d455598a0 --- /dev/null +++ b/sale_order_blanket_order/models/sale_order_line.py @@ -0,0 +1,658 @@ +# 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( + 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 on Blanket Order", + ) + 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.", + ) + blanket_validity_start_date = fields.Date( + string="Validity Start Date", + related="order_id.blanket_validity_start_date", + readonly=True, + store=True, + copy=False, + precompute=True, + ) + blanket_validity_end_date = fields.Date( + string="Validity End Date", + related="order_id.blanket_validity_end_date", + readonly=True, + store=True, + copy=False, + precompute=True, + ) + blanket_order_id = fields.Many2one( + "sale.order", + related="order_id.blanket_order_id", + readonly=True, + store=True, + copy=False, + precompute=True, + ) + + def init(self): + self._cr.execute( + """ + CREATE INDEX IF NOT EXISTS + ale_order_line_blanket_validity_range_index + ON + sale_order_line + USING + gist (daterange(blanket_validity_start_date, blanket_validity_end_date, '[]')) + WHERE + blanket_validity_end_date IS NOT NULL + AND blanket_validity_start_date IS NOT NULL + AND blanket_validity_start_date <= blanket_validity_end_date + """ + ) + + @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( + "blanket_validity_start_date", + "blanket_validity_end_date", + "product_id", + "product_packaging_id", + "order_partner_id", + "state", + ) + 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( + [ + "blanket_validity_start_date", + "blanket_validity_end_date", + "order_id", + "order_type", + "product_id", + "product_packaging_id", + "order_partner_id", + "state", + ] + ) + for rec in self: + order = rec.order_id + if ( + order.order_type != "blanket" + or not rec.blanket_validity_start_date + or not rec.blanket_validity_end_date + or rec.state != "sale" + ): + continue + if rec.blanket_validity_end_date < rec.blanket_validity_start_date: + raise ValidationError( + _( + "The validity end date must be greater than the validity start date." + ) + ) + # 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 + WHERE + sol.blanket_validity_start_date is not null + AND sol.blanket_validity_end_date is not null + AND DATERANGE( + sol.blanket_validity_start_date, + sol.blanket_validity_end_date, + '[]' + ) && DATERANGE( + %s::date, + %s::date, + '[]' + ) + """ + domain = [ + ("call_off_remaining_qty", ">", 0), + ("order_id", "!=", order.id), + ("state", "not in", ["draft", "cancel"]), + ("order_type", "=", "blanket"), + ] + 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, + ( + rec.blanket_validity_start_date, + rec.blanket_validity_end_date, + *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", "product_uom_qty" + ) + 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 used to know how much + quantity is still available to deliver by a call-off order lines. + """ + self.flush_model(["product_uom_qty", "order_type", "blanket_line_id", "state"]) + 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 or 1.0, + ): + line.call_off_remaining_qty = new_call_off_remaining_qty + + def _validate_blanket_lines_for_call_off_lines_dict(self, matching_dict): + """Validate the matching between call-off order lines and blanket 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. + """ + 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.blanket_order_id.name, + ) + ) + + qty_remaining_to_procure = sum( + blanket_lines.mapped("call_off_remaining_qty") + ) + qty_to_procure = sum(call_of_lines.mapped("product_uom_qty")) + 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].blanket_order_id.name, + ) + ) + + def _get_call_off_line_to_blanked_line_matching_fields(self): + """Get the fields used to match call-off order lines to blanket order lines. + + Be careful to override this method if you want to add new fields to the matching + key. You must most probably extend the list of fields triggering the constraint + `_check_blanket_product_not_overlapping` since these fields are used to check + that a product is not part of multiple blanket orders with overlapping validity + periods. + """ + return ["product_id", "product_packaging_id", "order_partner_id"] + + def _get_blanket_lines_for_call_off_lines_dict(self, validate=True): + """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" + and not l.display_type + and l.state != "cancel" + ) + blanket_lines = self.blanket_order_id.order_line + matching_dict = self._match_lines_to_blanket(call_off_lines, blanket_lines) + if validate: + self._validate_blanket_lines_for_call_off_lines_dict(matching_dict) + return matching_dict + + 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. Where the key is the matching key and the value is a list + of 2 recordsets. The first element is a set of call-off order lines and the + second element is a set of blanket order lines that match the call-off order + lines (based on the matching key). All the order lines are included in the + result even if no matching line is found into the blanket lines + """ + result = defaultdict( + lambda: [self.env["sale.order.line"], self.env["sale.order.line"]] + ) + for line in order_lines: + if line.display_type or line.state == "cancel": + continue + key = line._to_blanket_line_matching_key() + result[key][0] |= 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() + if key in result: + result[key][1] |= line + return result + + 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.blanket_validity_start_date + values["date_deadline"] = self.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_tax_id(self): + # Overload to consider the call-off order lines in the computation + # For these lines we don't want to apply taxes. If we don't enforce + # the tax_id to False, we could end up with an amount to invoice + # if a fixed price is set on linked taxes. All the invoicing is done + # on the blanket order line including the taxes. + call_off_lines = self.filtered(lambda l: l.order_type == "call_off") + other_lines = self - call_off_lines + call_off_lines.tax_id = False + return super(SaleOrderLine, other_lines)._compute_tax_id() + + 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.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 = line.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._forward_stock_rule_to_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 that 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 _forward_stock_rule_to_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.blanket_order_id + if not blanket_order: + raise ValueError("A call-off order must have a blanket order.") + line.blanket_line_id._launch_stock_rule_for_call_off_line( + line, previous_product_uom_qty + ) + + def _launch_stock_rule_for_call_off_line( + self, call_off_line, 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() + if self.order_type != "blanket": + raise ValueError("This method is only valid for blanket order lines.") + qty_to_deliver = call_off_line.product_uom_qty + old_state = self.state + if old_state == "done": + # We must unlock the line to manually deliver the quantity + self.state = "sale" + self._launch_stock_rule_for_call_off_line_qty( + qty_to_deliver, previous_product_uom_qty + ) + if old_state == "done": + self.state = "done" + + def _launch_stock_rule_for_call_off_line_qty( + self, qty_to_deliver, 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() + reservation_strategy = self.order_id.blanket_reservation_strategy + if reservation_strategy != "at_call_off": + raise ValueError( + f"Invalid blanket reservation strategy: {reservation_strategy}." + ) + wizard = ( + self.env["manual.delivery"] + .with_context( + active_id=self.id, + active_model="sale.order.line", + active_ids=self.ids, + ) + .create({}) + ) + wizard.line_ids.quantity = qty_to_deliver + wizard.confirm() + + def _prepare_call_of_vals_to_deliver_quantity(self, product_uom_qty): + """Prepare the values to create a new call-off order line for the quantity + to deliver on the blanket order line. + """ + self.ensure_one() + return { + "product_id": self.product_id.id, + "product_uom_qty": product_uom_qty, + "product_uom": self.product_uom.id, + "product_packaging_id": self.product_packaging_id.id, + } + + def _prepare_call_of_vals_to_deliver_blanket_remaining_qty(self): + """Prepare the values to create a new call-off order for the remaining quantity + to deliver on the blanket order line. + """ + self.ensure_one() + if self.order_type != "blanket": + raise ValueError("This method is only valid for blanket order lines.") + return self._prepare_call_of_vals_to_deliver_quantity( + self.call_off_remaining_qty + ) + + def _blanket_check_update_product_uom_qty(self, values): + if "product_uom_qty" not in values: + return + new_qty = values.get("product_uom_qty") + for line in self: + if line.order_type != "blanket" or line.state != "sale" or line.is_expense: + continue + called_qty = line.product_uom_qty - line.call_off_remaining_qty + if ( + float_compare( + new_qty, called_qty, precision_rounding=line.product_uom.rounding + ) + < 0 + ): + raise ValidationError( + _( + "The forecasted quantity cannot be less than the quantity " + "already called by call-off orders." + ) + ) + + def write(self, values): + self._blanket_check_update_product_uom_qty(values) + return super(SaleOrderLine, self).write(values) 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..8b09da11d21 --- /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 blanket order 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 order blanket order module is the right choice. + +This module introduces 2 new kinds of sales orders: + +1. Blanket Order: This is a sales order that defines the terms and conditions of the sales, the price, the payment terms, the delivery terms, and secures the quantities of the products to be delivered. + +2. Call of order: This is a sales order linked to a blanket order that is created to trigger the delivery of quantities of the products secured in the 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 order blanket order module extends the sale order model to add the sale blanket order and the call off 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..be10262ed4d --- /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-off 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/readme/USAGE.md b/sale_order_blanket_order/readme/USAGE.md new file mode 100644 index 00000000000..4e7a745370c --- /dev/null +++ b/sale_order_blanket_order/readme/USAGE.md @@ -0,0 +1,3 @@ +By default, the automatic creation of call-off orders from normal sale orders containing products +part of a blanket order is disabled. To enable this feature, you need to go into the sales settings +and enable the option "Create Call-Off from SO if possible". \ No newline at end of file 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..759e0e23e38 --- /dev/null +++ b/sale_order_blanket_order/static/description/index.html @@ -0,0 +1,546 @@ + + + + + +Sale Order Blanket Order + + + +
+

Sale Order Blanket Order

+ + +

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-off 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 blanket order +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 +order blanket order module is the right choice.

+

This module introduces 2 new kinds of sales orders:

+
    +
  1. Blanket Order: This is a sales order that defines the terms and +conditions of the sales, the price, the payment terms, the delivery +terms, and secures the quantities of the products to be delivered.
  2. +
  3. Call of order: This is a sales order linked to a blanket order that +is created to trigger the delivery of quantities of the products +secured in the 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 order blanket order module +extends the sale order model to add the sale blanket order and the call +off 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, +…).

+
+
+

Usage

+

By default, the automatic creation of call-off orders from normal sale +orders containing products part of a blanket order is disabled. To +enable this feature, you need to go into the sales settings and enable +the option “Create Call-Off from SO if possible”.

+
+
+

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..110f3d9afda --- /dev/null +++ b/sale_order_blanket_order/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_sale_blanket_order +from . import test_sale_call_off_order +from . import test_sale_normal_order diff --git a/sale_order_blanket_order/tests/common.py b/sale_order_blanket_order/tests/common.py new file mode 100644 index 00000000000..3fa5dc23940 --- /dev/null +++ b/sale_order_blanket_order/tests/common.py @@ -0,0 +1,163 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo_test_helper import FakeModelLoader + +from odoo import Command, fields, models + +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() + # create a flat tax + cls.tax_fixed = cls.env["account.tax"].create( + { + "sequence": 10, + "name": "Tax 10.0 (Fixed)", + "amount": 10.0, + "amount_type": "fixed", + "include_base_amount": True, + } + ) + cls.product_1 = cls.env["product.product"].create( + { + "name": "Product 1", + "type": "product", + "taxes_id": [Command.link(cls.tax_fixed.id)], + } + ) + cls.product_2 = cls.env["product.product"].create( + { + "name": "Product 2", + "type": "product", + "taxes_id": [Command.link(cls.tax_fixed.id)], + } + ) + cls.product_3 = cls.env["product.product"].create( + { + "name": "Product 3", + "type": "product", + "taxes_id": [Command.link(cls.tax_fixed.id)], + } + ) + 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, + } + ), + ], + } + ) + cls.so_model = cls.env["sale.order"] + cls.call_off_domain = [("order_type", "=", "call_off")] + + # create a fake model to declare another reservation strategy + cls.loader = FakeModelLoader(cls.env, cls.__module__) + cls.loader.backup_registry() + cls.addClassCleanup(cls.loader.restore_registry) + + # pylint: disable=consider-merging-classes-inherited + class SO(models.Model): + _inherit = "sale.order" + + blanket_reservation_strategy = fields.Selection( + selection_add=[("fake", "For tests")], + ondelete={"fake": "cascade"}, + ) + + def _blanket_order_reserve_call_off_remaining_qty(self): + # we need to override since our strategy is fake + ( + _to_reserve, + other_orders, + ) = self._split_recrodset_for_reservation_strategy("fake") + return super( + SO, other_orders + )._blanket_order_reserve_call_off_remaining_qty() + + def _blanket_order_release_call_off_remaining_qty(self): + # we need to override since our strategy is fake + ( + _to_release, + other_orders, + ) = self._split_recrodset_for_reservation_strategy("fake") + return super( + SO, other_orders + )._blanket_order_release_call_off_remaining_qty() + + cls.loader.update_registry([SO]) + + @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..84088c2ee78 --- /dev/null +++ b/sale_order_blanket_order/tests/test_sale_blanket_order.py @@ -0,0 +1,271 @@ +# 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 odoo.tests.common import RecordCapturer + +from .common import SaleOrderBlanketOrderCase + + +class TestSaleBlanketOrder(SaleOrderBlanketOrderCase): + def test_confirm_start_date_required(self): + order = self.env["sale.order"].create( + { + "order_type": "blanket", + "partner_id": self.partner.id, + } + ) + # Create a call-off order + with self.assertRaisesRegex( + ValidationError, "The validity start date is required" + ): + order.action_confirm() + + def test_confirm_end_date_required(self): + order = 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 is required" + ): + order.action_confirm() + + def test_confrim_end_date_greater_than_start_date(self): + order = 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, "The validity end date must be greater than" + ): + order.action_confirm() + + def test_confirm_no_blanket_order(self): + 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, + } + ) + with self.assertRaisesRegex( + ValidationError, "A blanket order cannot have a blanket order." + ): + order.action_confirm() + + def test_no_product_overlap(self): + self.blanket_so.action_confirm() + order = 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} + ), + ], + } + ) + # Validate 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}." + ), + ): + order.action_confirm() + + def test_reservation(self): + # Confirm the blanket order with reservation at call off + self.blanket_so.action_confirm() + self.assertTrue(self.blanket_so.manual_delivery) + 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) + + def test_reset_reservation_at_cancel(self): + self.blanket_so.action_confirm() + self.assertTrue(self.blanket_so.manual_delivery) + self.blanket_so._action_cancel() + self.assertFalse(self.blanket_so.manual_delivery) + + def test_eol(self): + # Confirm the blanket order with reservation at call off + self.assertFalse(self.blanket_so.blanket_need_to_be_finalized) + self.blanket_so.blanket_eol_strategy = "deliver" + self.blanket_so.action_confirm() + self.assertTrue(self.blanket_so.blanket_need_to_be_finalized) + self.blanket_so.flush_recordset() + with RecordCapturer( + self.so_model, self.call_off_domain + ) as captured, freezegun.freeze_time("2026-12-31"): + self.so_model._cron_manage_blanket_order_eol() + self.assertFalse(self.blanket_so.blanket_need_to_be_finalized) + self.assertEqual(len(captured.records), 1) + for line in self.blanket_so.order_line: + self.assertEqual(line.call_off_remaining_qty, 0.0) + call_off = line.call_off_line_ids + self.assertEqual(len(call_off), 1) + self.assertEqual(call_off.product_uom_qty, line.product_uom_qty) + self.assertTrue(line.move_ids) + + def test_eol_with_call_off_in_progress(self): + self.assertFalse(self.blanket_so.blanket_need_to_be_finalized) + self.blanket_so.blanket_eol_strategy = "deliver" + self.blanket_so.action_confirm() + self.assertTrue(self.blanket_so.blanket_need_to_be_finalized) + self.blanket_so.flush_recordset() + # we create a call-of order for part of the quantity of + # the product 1 + 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_2.id, + "product_uom_qty": 5.0, + } + ), + ], + } + ) + with freezegun.freeze_time("2025-11-12"): + order.action_confirm() + + self.assertEqual(self.blanket_so.blanket_need_to_be_finalized, True) + with RecordCapturer( + self.so_model, self.call_off_domain + ) as captured, freezegun.freeze_time("2026-12-31"): + self.so_model._cron_manage_blanket_order_eol() + self.assertFalse(self.blanket_so.blanket_need_to_be_finalized) + self.assertEqual(len(captured.records), 1) + new_call_off = captured.records[0] + for line in self.blanket_so.order_line: + self.assertEqual(line.call_off_remaining_qty, 0.0) + call_off = line.call_off_line_ids + if line.product_id == self.product_2: + # 2 call-off lines should exist + # one for the call-off order created in the past + # and one for the new call-off order created by the cron + self.assertEqual(len(call_off), 2) + self.assertEqual(call_off.order_id, order | new_call_off) + self.assertEqual( + set(call_off.mapped("product_uom_qty")), + {5, line.product_uom_qty - 5}, + ) + else: + self.assertEqual(len(call_off), 1) + self.assertEqual(call_off.product_uom_qty, line.product_uom_qty) + self.assertTrue(line.move_ids) + + def test_reservation_strategy_editable(self): + # change is allowed in draft state + self.blanket_so.blanket_reservation_strategy = "fake" + self.blanket_so.blanket_reservation_strategy = "at_call_off" + self.blanket_so.action_confirm() + # change is allowed after confirmation while the blanket order + # is not finalized + self.blanket_so.blanket_reservation_strategy = "fake" + self.blanket_so._action_cancel() + with self.assertRaisesRegex( + ValidationError, "The reservation strategy cannot be modified" + ), self.env.cr.savepoint(): + # change is not allowed on canceled order + self.blanket_so.blanket_reservation_strategy = "at_call_off" + self.blanket_so.action_draft() + # change is allowed in draft state + self.blanket_so.blanket_reservation_strategy = "at_call_off" + self.blanket_so.action_confirm() + with freezegun.freeze_time("2026-12-31"): + self.so_model._cron_manage_blanket_order_eol() + + self.assertFalse(self.blanket_so.blanket_need_to_be_finalized) + with self.assertRaisesRegex( + ValidationError, "The reservation strategy cannot be modified" + ), self.env.cr.savepoint(): + # change is not allowed on finalized order + self.blanket_so.blanket_reservation_strategy = "fake" + + def test_eol_strategy_editable(self): + # change is allowed in draft state + self.blanket_so.blanket_eol_strategy = "deliver" + self.blanket_so.blanket_eol_strategy = False + self.blanket_so.action_confirm() + # change is allowed after confirmation while the blanket order + # is not finalized + self.blanket_so.blanket_eol_strategy = "deliver" + self.blanket_so._action_cancel() + with self.assertRaisesRegex( + ValidationError, "The end-of-life strategy cannot be modified" + ), self.env.cr.savepoint(): + # change is not allowed on canceled order + self.blanket_so.blanket_eol_strategy = False + self.blanket_so.action_draft() + # change is allowed in draft state + self.blanket_so.blanket_eol_strategy = False + self.blanket_so.action_confirm() + with freezegun.freeze_time("2026-12-31"): + self.so_model._cron_manage_blanket_order_eol() + + self.assertFalse(self.blanket_so.blanket_need_to_be_finalized) + with self.assertRaisesRegex( + ValidationError, "The end-of-life strategy cannot be modified" + ), self.env.cr.savepoint(): + # change is not allowed on finalized order + self.blanket_so.blanket_eol_strategy = "deliver" + + def test_update_qty(self): + self.blanket_so.action_confirm() + so_line_product_2 = self.blanket_so.order_line.filtered( + lambda l: l.product_id == self.product_2 + ) + self.assertEqual(so_line_product_2.product_uom_qty, 10) + so_line_product_2.product_uom_qty = 5 + self.assertEqual(so_line_product_2.product_uom_qty, 5) + self.assertEqual(so_line_product_2.call_off_remaining_qty, 5) + # if we deliver 3, we should not update the qty under the remaining qty + 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_2.id, + "product_uom_qty": 3.0, + } + ), + ], + } + ) + with freezegun.freeze_time("2025-02-01"): + order.action_confirm() + self.assertEqual(so_line_product_2.call_off_remaining_qty, 2) + with self.assertRaisesRegex( + ValidationError, "The forecasted quantity cannot be less than the quantity" + ): + so_line_product_2.product_uom_qty = 1 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..20dea3608a6 --- /dev/null +++ b/sale_order_blanket_order/tests/test_sale_call_off_order.py @@ -0,0 +1,335 @@ +# 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_confirm_no_blanket_id(self): + 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." + ): + order.action_confirm() + + def test_confirm_blanket_id_not_blanket(self): + 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, "A call-off order must have a blanket order." + ): + order.action_confirm() + + def test_confirm_blanket_id_not_confirmed(self): + order = self.env["sale.order"].create( + { + "order_type": "call_off", + "partner_id": self.partner.id, + "blanket_order_id": self.blanket_so.id, + } + ) + + with self.assertRaisesRegex( + ValidationError, "The blanket order must be confirmed" + ): + + order.action_confirm() + + def test_confirm_blanket_id_validity_period(self): + self.blanket_so.action_confirm() + 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, + } + ) + with freezegun.freeze_time("2024-01-01"), self.assertRaisesRegex( + ValidationError, + ( + "The call-off order must be within the " + "validity period of the blanket order." + ), + ): + order.action_confirm() + + @freezegun.freeze_time("2025-02-01") + def test_confirm_ok(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.action_confirm() + self.assertIn(order.state, ["sale", "done"]) + + @freezegun.freeze_time("2025-02-01") + def test_order_line_constrains(self): + self.blanket_so.action_confirm() + + 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_3.id, + "product_uom_qty": 10.0, + } + ), + ], + } + ) + with self.assertRaisesRegex( + ValidationError, + ("The product is not part of linked blanket order"), + ): + order.action_confirm() + + 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_2.id, + "product_uom_qty": 100.0, + } + ), + ], + } + ) + with self.assertRaisesRegex( + ValidationError, + ( + "The quantity to procure is greater than the quantity remaining " + "to deliver" + ), + ): + order.action_confirm() + + 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, + "price_tax": 0.0, + "price_total": 0.0, + "tax_id": [], + } + ], + ) + # once confirmed, the quantity to deliver should become 0 and the + # display_qty_widget should be False + with freezegun.freeze_time("2025-02-01"): + order.action_confirm() + self.assertRecordValues( + order.order_line, + [ + { + "product_uom_qty": 10.0, + "price_unit": 0.0, + "qty_to_deliver": 0.0, + "qty_to_invoice": 0.0, + "qty_delivered": 0.0, + "display_qty_widget": False, + } + ], + ) + + +class TestSaleCallOffOrderProcessing(SaleOrderBlanketOrderCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.blanket_so.action_confirm() + + @freezegun.freeze_time("2025-02-01") + 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.assertIn(order.state, ["sale", "done"]) + 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, + ) + + @freezegun.freeze_time("2025-02-01") + 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.assertIn(order.state, ["sale", "done"]) + + # 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, + ) + + +class TestSaleAutoDoneCallOffOrderProcessing(TestSaleCallOffOrderProcessing): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env.user.groups_id += cls.env.ref("sale.group_auto_done_setting") diff --git a/sale_order_blanket_order/tests/test_sale_normal_order.py b/sale_order_blanket_order/tests/test_sale_normal_order.py new file mode 100644 index 00000000000..2976e586cd1 --- /dev/null +++ b/sale_order_blanket_order/tests/test_sale_normal_order.py @@ -0,0 +1,301 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import freezegun + +from odoo import Command +from odoo.tests.common import RecordCapturer + +from .common import SaleOrderBlanketOrderCase + + +class TestSaleNormalOrder(SaleOrderBlanketOrderCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.blanket_so.action_confirm() + + @classmethod + def _set_call_off_auto_create_mode(cls, value): + # Enable the auto create mode + cls.env["res.config.settings"].create( + {"create_call_off_from_so_if_possible": True} + ).execute() + + @freezegun.freeze_time("2025-02-01") + def test_normal_order(self): + # ensure that the original sale order process + # works as expected + # We use product_3 since it is not part of a blanket order + order = self.env["sale.order"].create( + { + "partner_id": self.partner.id, + "order_line": [ + Command.create( + { + "product_id": self.product_3.id, + "product_uom_qty": 1, + "price_unit": 100, + }, + ) + ], + } + ) + order.action_confirm() + + @freezegun.freeze_time("2025-02-01") + def test_call_off_auto_create_mode(self): + order = self.env["sale.order"].create( + { + "partner_id": self.partner.id, + "order_line": [ + Command.create( + { + "product_id": self.product_1.id, + "product_uom_qty": 1, + "price_unit": 100, + }, + ) + ], + } + ) + with RecordCapturer(self.so_model, self.call_off_domain) as captured: + order.action_confirm() + new_order = captured.records + # By default the auto create mode is disabled + self.assertEqual(len(new_order), 0) + + # Enable the auto create mode + self._set_call_off_auto_create_mode(True) + order = self.env["sale.order"].create( + { + "partner_id": self.partner.id, + "order_line": [ + Command.create( + { + "product_id": self.product_1.id, + "product_uom_qty": 1, + "price_unit": 100, + }, + ) + ], + } + ) + with RecordCapturer(self.so_model, self.call_off_domain) as captured: + order.action_confirm() + new_order = captured.records + self.assertEqual(len(new_order), 1) + self.assertEqual(new_order.partner_id, order.partner_id) + self.assertEqual(new_order.state, "sale") + self.assertEqual(new_order.order_type, "call_off") + self.assertEqual(new_order.blanket_order_id, self.blanket_so) + + @freezegun.freeze_time("2025-02-01") + def test_call_off_auto_create(self): + # A test where we've a SO with 2 products, + # one of which is part of a blanket order + # and the other is not + # The quantity of the product that is part of the blanket order + # is less than the quantity in the blanket order + self._set_call_off_auto_create_mode(True) + order = self.env["sale.order"].create( + { + "partner_id": self.partner.id, + "order_line": [ + Command.create( + { + "product_id": self.product_1.id, + "product_uom_qty": 1, + "price_unit": 100, + }, + ), + Command.create( + { + "product_id": self.product_3.id, + "product_uom_qty": 1, + "price_unit": 100, + }, + ), + ], + } + ) + with RecordCapturer(self.so_model, self.call_off_domain) as captured: + order.action_confirm() + new_order = captured.records + self.assertEqual(len(new_order), 1) + self.assertEqual(len(order.order_line), 1) + self.assertEqual(order.order_line.product_id, self.product_3) + self.assertEqual(len(new_order.order_line), 1) + self.assertRecordValues( + new_order.order_line, + [ + { + "product_id": self.product_1.id, + "product_uom_qty": 1.0, + "price_unit": 0.0, + "qty_to_deliver": 0.0, + "qty_to_invoice": 0.0, + "qty_delivered": 0.0, + "price_tax": 0.0, + "price_total": 0.0, + "tax_id": [], + } + ], + ) + + self.assertEqual(new_order.order_line.product_id, self.product_1) + self.assertEqual(new_order.order_line.product_uom_qty, 1) + blanket_lines = self.blanket_so.order_line.filtered( + lambda x: x.product_id == self.product_1 + ) + blanquet_product_qty = sum(blanket_lines.mapped("product_uom_qty")) + remaining_qty = sum(blanket_lines.mapped("call_off_remaining_qty")) + self.assertEqual(blanquet_product_qty, remaining_qty + 1) + + @freezegun.freeze_time("2025-02-01") + def test_call_off_auto_create_qty_multi_blanket_line(self): + # A test where we've a SO with 1 product for which we have 2 blanket lines + # The quantity of the product that is part of the normal order is less + # than the total quantity in the blanket order lines but greater than the + # quantity in each line. + # The system should create a call off order with 2 lines for the same product + # where each line corresponds to a blanket line and one of the lines + # fulfills the remaining quantity of the first blanket line. + # product_1 is part of the blanket order with 2 lines each with a quantity + # of 10 + self._set_call_off_auto_create_mode(True) + order = self.env["sale.order"].create( + { + "partner_id": self.partner.id, + "order_line": [ + Command.create( + { + "product_id": self.product_1.id, + "product_uom_qty": 15, + "price_unit": 100, + }, + ), + ], + } + ) + with RecordCapturer(self.so_model, self.call_off_domain) as captured: + order.action_confirm() + new_order = captured.records + self.assertEqual(len(new_order), 1) + self.assertEqual( + len(order.order_line), 0 + ) # All lines are moved to the call off order + self.assertEqual(len(new_order.order_line), 2) + blanket_lines = self.blanket_so.order_line.filtered( + lambda x: x.product_id == self.product_1 + ) + blanquet_product_qty = sum(blanket_lines.mapped("product_uom_qty")) + remaining_qty = sum(blanket_lines.mapped("call_off_remaining_qty")) + self.assertEqual(blanquet_product_qty, remaining_qty + 15) + + @freezegun.freeze_time("2025-02-01") + def test_call_off_auto_create_qty_multi_blanket_line_overflow(self): + # A test where we've a SO with 1 product for which we have 2 blanket lines + # The quantity of the product that is part of the normal order is greater + # than the total quantity in the blanket order lines. + # The system should create a call off order with 2 lines for the same product + # where each line corresponds to a blanket line and fulfill the quantity + # of the blanket lines. The original order should have one line with the + # remaining quantity. + # product_1 is part of the blanket order with 2 lines each with a quantity + # of 10 + self._set_call_off_auto_create_mode(True) + order = self.env["sale.order"].create( + { + "partner_id": self.partner.id, + "order_line": [ + Command.create( + { + "product_id": self.product_1.id, + "product_uom_qty": 25, + "price_unit": 100, + }, + ), + ], + } + ) + with RecordCapturer(self.so_model, self.call_off_domain) as captured: + order.action_confirm() + new_order = captured.records + self.assertEqual(len(new_order), 1) + self.assertEqual( + len(order.order_line), 1 + ) # All lines are moved to the call off order + self.assertEqual(len(new_order.order_line), 2) + blanket_lines = self.blanket_so.order_line.filtered( + lambda x: x.product_id == self.product_1 + ) + remaining_qty = sum(blanket_lines.mapped("call_off_remaining_qty")) + self.assertEqual(remaining_qty, 0) + self.assertEqual(order.order_line.product_uom_qty, 5) + + def test_cancel_normal(self): + # ensure non regression on cancel of a normal order + order = self.env["sale.order"].create( + { + "partner_id": self.partner.id, + "order_line": [ + Command.create( + { + "product_id": self.product_3.id, + "product_uom_qty": 1, + "price_unit": 100, + }, + ) + ], + } + ) + order.action_confirm() + order._action_cancel() + + @freezegun.freeze_time("2025-02-01") + def test_call_off_auto_create_line_attributes(self): + # we create an oder for more qty (1000) than the blanket order (200) + # and check that the line attributes into the call off order + self._set_call_off_auto_create_mode(True) + order = self.env["sale.order"].create( + { + "partner_id": self.partner.id, + "order_line": [ + Command.create( + { + "product_id": self.product_2.id, + "product_uom_qty": 1000, + "price_unit": 100, + }, + ), + ], + } + ) + with RecordCapturer(self.so_model, self.call_off_domain) as captured: + order.action_confirm() + new_order = captured.records + blanket_line = self.blanket_so.order_line.filtered( + lambda l: l.product_id == self.product_2 + )[0] + self.assertRecordValues( + new_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, + "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, + "price_tax": 0.0, + "price_total": 0.0, + "tax_id": [], + } + ], + ) 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..4bea7b93307 --- /dev/null +++ b/sale_order_blanket_order/views/sale_order.xml @@ -0,0 +1,366 @@ + + + + + + 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 + + [ + ('order_type', '=', 'blanket')] + + {'default_order_type': 'blanket', 'invisible_call_off_remaining_qty': 0, 'disable_order_type_filters': '1'} + + + + + tree + + + + + + + kanban + + + + + + + form + + + + + + + calendar + + + + + + + pivot + + + + + + + graph + + + + + + Call-off Orders + ir.actions.act_window + sale.order + tree,kanban,form,calendar,pivot,graph,activity + + [ + ('order_type', '=', 'call_off')] + {'default_order_type': 'call_off', 'disable_order_type_filters': '1'} + + + + + 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..7fa805dcc71 --- /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/i18n/fr.po b/sale_order_blanket_order_stock_prebook/i18n/fr.po new file mode 100644 index 00000000000..5960827eafa --- /dev/null +++ b/sale_order_blanket_order_stock_prebook/i18n/fr.po @@ -0,0 +1,49 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * sale_order_blanket_order_stock_prebook +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-12-05 14:49+0000\n" +"PO-Revision-Date: 2024-12-05 14:49+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: sale_order_blanket_order_stock_prebook +#: model:ir.model.fields.selection,name:sale_order_blanket_order_stock_prebook.selection__sale_order__blanket_reservation_strategy__at_confirm +msgid "At Order Confirmation" +msgstr "A la confirmation de la commande-cadre" + +#. module: sale_order_blanket_order_stock_prebook +#: model:ir.model.fields,field_description:sale_order_blanket_order_stock_prebook.field_sale_order__blanket_reservation_strategy +msgid "Reservation Strategy" +msgstr "Stratégie de réservation" + +#. module: sale_order_blanket_order_stock_prebook +#: model:ir.model,name:sale_order_blanket_order_stock_prebook.model_sale_order +msgid "Sales Order" +msgstr "Bon de commande" + +#. module: sale_order_blanket_order_stock_prebook +#: model:ir.model,name:sale_order_blanket_order_stock_prebook.model_sale_order_line +msgid "Sales Order Line" +msgstr "Ligne de bons de commande" + +#. module: sale_order_blanket_order_stock_prebook +#: model:ir.model.fields,help:sale_order_blanket_order_stock_prebook.field_sale_order__blanket_reservation_strategy +msgid "" +"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." +msgstr "" +"Spécifie quand le stock doit être réservé pour la commande-cadre. Lorsque la" +" stratégie est 'A la confirmation de la commande', le stock est réservé " +"lorsque la commande-cadre est confirmée. Lorsque la stratégie est 'A " +"l'appel', le stock est réservé lorsque la commande d'appel est confirmée." diff --git a/sale_order_blanket_order_stock_prebook/i18n/sale_order_blanket_order_stock_prebook.pot b/sale_order_blanket_order_stock_prebook/i18n/sale_order_blanket_order_stock_prebook.pot new file mode 100644 index 00000000000..6ecf0cd89af --- /dev/null +++ b/sale_order_blanket_order_stock_prebook/i18n/sale_order_blanket_order_stock_prebook.pot @@ -0,0 +1,45 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * sale_order_blanket_order_stock_prebook +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-12-05 14:49+0000\n" +"PO-Revision-Date: 2024-12-05 14:49+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: sale_order_blanket_order_stock_prebook +#: model:ir.model.fields.selection,name:sale_order_blanket_order_stock_prebook.selection__sale_order__blanket_reservation_strategy__at_confirm +msgid "At Order Confirmation" +msgstr "" + +#. module: sale_order_blanket_order_stock_prebook +#: model:ir.model.fields,field_description:sale_order_blanket_order_stock_prebook.field_sale_order__blanket_reservation_strategy +msgid "Reservation Strategy" +msgstr "" + +#. module: sale_order_blanket_order_stock_prebook +#: model:ir.model,name:sale_order_blanket_order_stock_prebook.model_sale_order +msgid "Sales Order" +msgstr "" + +#. module: sale_order_blanket_order_stock_prebook +#: model:ir.model,name:sale_order_blanket_order_stock_prebook.model_sale_order_line +msgid "Sales Order Line" +msgstr "" + +#. module: sale_order_blanket_order_stock_prebook +#: model:ir.model.fields,help:sale_order_blanket_order_stock_prebook.field_sale_order__blanket_reservation_strategy +msgid "" +"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." +msgstr "" 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..84b54231d08 --- /dev/null +++ b/sale_order_blanket_order_stock_prebook/models/sale_order.py @@ -0,0 +1,50 @@ +# 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 _get_or_create_reserve_procurement_group(self): + """Get or create the procurement group for the reservation.""" + self.ensure_one() + picking_reservations = self._get_reservation_pickings().filtered( + lambda p: p.state in ("assigned", "confirmed") + ) + if picking_reservations: + return picking_reservations[0].group_id + return self._create_reserve_procurement_group() + + def _blanket_order_reserve_call_off_remaining_qty(self): + """Reserve the stock for the blanket order.""" + to_reserve, other_orders = self._split_recrodset_for_reservation_strategy( + "at_confirm" + ) + to_reserve._prebook_stock_for_call_off_remaining_qty() + return super( + SaleOrder, other_orders + )._blanket_order_reserve_call_off_remaining_qty() + + def _blanket_order_release_call_off_remaining_qty(self): + to_release, other_orders = self._split_recrodset_for_reservation_strategy( + "at_confirm" + ) + to_release._release_prebooked_stock() + return super( + SaleOrder, other_orders + )._blanket_order_release_call_off_remaining_qty() + + def _prebook_stock_for_call_off_remaining_qty(self): + """Prebook the stock for the order.""" + self.order_line._prebook_stock_for_call_off_remaining_qty() + + def _release_prebooked_stock(self): + """Release the prebooked stock for the order.""" + self.order_line._release_reservation() 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..e50f998a67b --- /dev/null +++ b/sale_order_blanket_order_stock_prebook/models/sale_order_line.py @@ -0,0 +1,101 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from collections import defaultdict + +from odoo import models +from odoo.tools import float_compare + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + 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_for_call_off_remaining_qty(self, previous_product_uom_qty=None): + """Prebook the stock for qty remaining to call off.""" + previous_product_uom_qty = previous_product_uom_qty or {} + self = self.with_context(sale_stock_prebook_stop_proc_run=True) + procurements = [] + lines_by_order = defaultdict(self.browse) + for line in self: + lines_by_order[line.order_id] |= line + for order, lines in lines_by_order.items(): + group = order._get_or_create_reserve_procurement_group() + for line in lines: + if line.id in previous_product_uom_qty: + line._release_reservation() + remaining_qty = line.call_off_remaining_qty + if ( + float_compare( + remaining_qty, 0, precision_rounding=self.product_uom.rounding + ) + > 0 + ): + procurements += line.with_context( + force_qty=remaining_qty + )._prepare_reserve_procurements(group) + if procurements: + self.env["procurement.group"].run(procurements) + return procurements + + def _launch_stock_rule_for_call_off_line_qty( + self, qty_to_deliver, previous_product_uom_qty + ): # pylint: disable=missing-return + self.ensure_one() + reservation_strategy = self.order_id.blanket_reservation_strategy + if reservation_strategy == "at_confirm": + self._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. + self._prebook_stock_for_call_off_remaining_qty(previous_product_uom_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. + self.with_context( + disable_call_off_stock_rule=True + )._action_launch_stock_rule(previous_product_uom_qty) + else: + super()._launch_stock_rule_for_call_off_line_qty( + qty_to_deliver, previous_product_uom_qty + ) + + def _action_launch_stock_rule(self, previous_product_uom_qty=None): + previous_product_uom_qty = previous_product_uom_qty or {} + lines_to_update_reservation = self.filtered( + lambda l: l.order_type == "blanket" + and l.id in previous_product_uom_qty + and l.order_id.blanket_reservation_strategy == "at_confirm" + ) + lines_to_update_reservation._prebook_stock_for_call_off_remaining_qty( + previous_product_uom_qty + ) + others_lines = self - lines_to_update_reservation + return super(SaleOrderLine, others_lines)._action_launch_stock_rule( + previous_product_uom_qty=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..54b37b5ce46 --- /dev/null +++ b/sale_order_blanket_order_stock_prebook/tests/test_sale_blanket_order.py @@ -0,0 +1,174 @@ +# 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 + + +class TestSaleBlanketOrder(SaleOrderBlanketOrderCase): + def _get_current_moves(self, so, product=False, only_reservation=False): + moves = so.order_line.move_ids.filtered(lambda m: m.state == "confirmed") + if product: + moves = moves.filtered(lambda m: m.product_id == product) + if only_reservation: + moves = moves.filtered("used_for_sale_reservation") + return moves + + def test_reservation_at_confirm(self): + # Confirm the blanket order with reservation at confirm + self.assertFalse(self.blanket_so.order_line.move_ids) + 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")) + ) + + def test_change_reservation_mode(self): + self.blanket_so.action_confirm() + self.assertTrue( + all(self.blanket_so.order_line.move_ids.mapped("used_for_sale_reservation")) + ) + self.blanket_so.blanket_reservation_strategy = "at_call_off" + moves = self._get_current_moves(self.blanket_so, only_reservation=True) + self.assertFalse(moves) + self.blanket_so.blanket_reservation_strategy = "at_confirm" + moves = self._get_current_moves(self.blanket_so, only_reservation=True) + self.assertTrue(moves) + + def test_update_reservation(self): + self.blanket_so.action_confirm() + so_line_product_2 = self.blanket_so.order_line.filtered( + lambda l: l.product_id == self.product_2 + ) + moves = self._get_current_moves( + self.blanket_so, self.product_2, only_reservation=True + ) + reserved_qty = sum(moves.mapped("product_uom_qty")) + self.assertEqual(reserved_qty, 10) + so_line_product_2.product_uom_qty = 5.0 + moves = self._get_current_moves( + self.blanket_so, self.product_2, only_reservation=True + ) + reserved_qty = sum(moves.mapped("product_uom_qty")) + self.assertEqual(reserved_qty, 5) + + so_line_product_2.product_uom_qty = 15.0 + moves = self._get_current_moves( + self.blanket_so, self.product_2, only_reservation=True + ) + reserved_qty = sum(moves.mapped("product_uom_qty")) + self.assertEqual(reserved_qty, 15) + + def test_update_current_reservation_with_delivered_qty(self): + self.blanket_so.action_confirm() + so_line_product_2 = self.blanket_so.order_line.filtered( + lambda l: l.product_id == self.product_2 + ) + moves = self._get_current_moves( + self.blanket_so, self.product_2, only_reservation=True + ) + reserved_qty = sum(moves.mapped("product_uom_qty")) + self.assertEqual(reserved_qty, 10) + so_line_product_2.product_uom_qty = 5.0 + moves = self._get_current_moves( + self.blanket_so, self.product_2, only_reservation=True + ) + reserved_qty = sum(moves.mapped("product_uom_qty")) + self.assertEqual(reserved_qty, 5) + + # create and process a call-off + with freezegun.freeze_time("2025-02-01"): + 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_2.id, + "product_uom_qty": 5.0, + } + ), + ], + } + ) + order.action_confirm() + self.assertIn(order.state, ["sale", "done"]) + + # update the quantity of the blanket order + so_line_product_2.product_uom_qty = 15.0 + moves = self._get_current_moves( + self.blanket_so, self.product_2, only_reservation=True + ) + reserved_qty = sum(moves.mapped("product_uom_qty")) + self.assertEqual(reserved_qty, 10) + + @freezegun.freeze_time("2025-02-01") + def test_change_reservation_mode_partially_processed(self): + # in this test we create a call-off to partially deliver + # the product. + # We change the reservaltion mode from at_conffirm to + # at_call_off and back. + # At the end the reserved qty should be the remaining for + # call off order + self.blanket_so.action_confirm() + moves = self._get_current_moves( + self.blanket_so, self.product_1, only_reservation=True + ) + reserved_qty = sum(moves.mapped("product_uom_qty")) + self.assertEqual(reserved_qty, 20) + + # create and process to the delivery of a call off + 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": 5.0, + } + ), + ], + } + ) + order.action_confirm() + self.assertIn(order.state, ["sale", "done"]) + + # at this stage we must still have a reservation for 15 + moves = self._get_current_moves( + self.blanket_so, self.product_1, only_reservation=True + ) + reserved_qty = sum(moves.mapped("product_uom_qty")) + self.assertEqual(reserved_qty, 15) + + # 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() + + # change reservation mode + self.blanket_so.blanket_reservation_strategy = "at_call_off" + moves = self._get_current_moves( + self.blanket_so, self.product_1, only_reservation=True + ) + self.assertFalse(moves) + + # back to at_confirm + self.blanket_so.blanket_reservation_strategy = "at_confirm" + moves = self._get_current_moves( + self.blanket_so, self.product_1, only_reservation=True + ) + reserved_qty = sum(moves.mapped("product_uom_qty")) + self.assertEqual(reserved_qty, 15) 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..b885ce3f49b --- /dev/null +++ b/sale_order_blanket_order_stock_prebook/tests/test_sale_call_off_order.py @@ -0,0 +1,141 @@ +# 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 + + +class TestSaleCallOffOrderProcessing(SaleOrderBlanketOrderCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.blanket_so.action_confirm() + + @freezegun.freeze_time("2025-02-01") + def test_no_reservation_processing(self): + # Create a call-off order without reservation + order = self.env["sale.order"].create( + { + "order_type": "call_off", + "partner_id": self.partner.id, + "blanket_order_id": self.blanket_so.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.assertIn(order.state, ["sale", "done"]) + 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, + ) + + @freezegun.freeze_time("2025-02-01") + 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.assertIn(order.state, ["sale", "done"]) + + # 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, + ) + + +class TestSaleAutoDoneCallOffOrderProcessing(TestSaleCallOffOrderProcessing): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env.user.groups_id += cls.env.ref("sale.group_auto_done_setting") 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..044f5775789 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1 +1,3 @@ odoo_test_helper +odoo-addon-sale-stock-prebook @ git+https://github.com/OCA/sale-workflow.git@refs/pull/3423/head#subdirectory=setup/sale_stock_prebook +odoo-addon-sale-order-product-picker @ git+https://github.com/OCA/sale-workflow.git@refs/pull/3457/head#subdirectory=setup/sale_order_product_picker