diff --git a/sale_restricted_qty/README.rst b/sale_restricted_qty/README.rst new file mode 100644 index 00000000000..b97485a3525 --- /dev/null +++ b/sale_restricted_qty/README.rst @@ -0,0 +1,143 @@ +===================================================== +Sale order restricted quantity: min, max, multiple-of +===================================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:1c96b9ab4a5f52f04233a4d0d47e6c5b19d7871794cec15c15d9a8821109c2dc + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/18.0/sale_restricted_qty + :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-18-0/sale-workflow-18-0-sale_restricted_qty + :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=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows to set mininal, maximal, and multiple-of quantity +constraints on product categories and products, and to check and +optionally enforce these constraints on sale orders (either as strict +blocking or soft warnings). + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +To set quantity constraints on a product: navigate to **Sales > Products +> Products**, open the product, and on the **Sales** tab in the **Qty +Constraints** section set corresponding values in the *Min Qty*, *Max +Qty*, or *Multiple-Of Qty* fields. + +**Constraint Types:** + +- **Min Qty**: Minimum quantity required for a sale. +- **Max Qty**: Maximum quantity allowed for a sale. +- **Multiple-Of Qty**: Quantity must be a multiple of this value. + +**Enforcement Levels (Restrict):** For each constraint, you can choose +the enforcement level: + +- **Blocking**: Strictly enforces the rule. The user cannot confirm the + line with an invalid quantity. +- **Warning**: Displays a warning (yellow/orange indication) but allows + the user to proceed. + + - *Use Case*: Use **Warning** when you want to allow flexibility, such + as selling **samples** (below min qty) or clearing out **leftover + stock** (remainder not matching multiple-of qty). + +**Auto-Suggest:** When you select a product in a Sales Order line, if a +Minimum Quantity is strictly enforced (**Blocking**) and the current +quantity is not set (or is 0/1), the system will automatically populate +the quantity with the Minimum Quantity. + +To set quantity constraints on a product variant: navigate to **Sales > +Products > Product Variants**, open the product variant, and on the +**Sales** tab in the **Qty Constraints** section set corresponding +values. + +To set quantity constraints on a product category: navigate to **Sales > +Configuration > Product Categories**, open the product category, and in +the **Sales Qty Constraints** section set corresponding values. + +The settings are inherited from the product category to the product, and +from the product to the product variant. To override the inherited +settings, check the checkbox next to the corresponding value and set the +value in the product or product variant. + +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 +------- + +* Akretion + +Contributors +------------ + +- Mourad EL HADJ MIMOUN +- `Ooops `__: + + - Ashish Hirpara + +- `Aion Tech `__: + + - Simone Rubino + +- `CorporateHub `__ + + - Alexey Pelykh + +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. + +.. |maintainer-ashishhirapara| image:: https://github.com/ashishhirapara.png?size=40px + :target: https://github.com/ashishhirapara + :alt: ashishhirapara + +Current `maintainer `__: + +|maintainer-ashishhirapara| + +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_restricted_qty/__init__.py b/sale_restricted_qty/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/sale_restricted_qty/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/sale_restricted_qty/__manifest__.py b/sale_restricted_qty/__manifest__.py new file mode 100644 index 00000000000..089cc8a9fd8 --- /dev/null +++ b/sale_restricted_qty/__manifest__.py @@ -0,0 +1,22 @@ +# Copyright 2019 Akretion () +# Copyright 2023 Simone Rubino - Aion Tech +# Copyright 2024 CorporateHub +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Sale order restricted quantity: min, max, multiple-of", + "version": "18.0.1.0.0", + "category": "Sales Management", + "author": "Akretion, Odoo Community Association (OCA)", + "contributors": ["Ashish Hirpara"], + "maintainers": ["ashishhirapara"], + "website": "https://github.com/OCA/sale-workflow", + "license": "AGPL-3", + "depends": ["sale_management"], + "data": [ + "views/product_category_views.xml", + "views/product_template_views.xml", + "views/sale_order_views.xml", + ], + "installable": True, +} diff --git a/sale_restricted_qty/i18n/de.po b/sale_restricted_qty/i18n/de.po new file mode 100644 index 00000000000..5214dfdf9b3 --- /dev/null +++ b/sale_restricted_qty/i18n/de.po @@ -0,0 +1,134 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * sale_restricted_qty +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-03-03 12:00+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: sale_restricted_qty +msgid "%(order_name)s - Product \"%(product_name)s\": %(failed_constraints)s" +msgstr "%(order_name)s - Produkt \"%(product_name)s\": %(failed_constraints)s" + +#. module: sale_restricted_qty +msgid "Blocking" +msgstr "Sperrend" + +#. module: sale_restricted_qty +msgid "Check quantity for these products:\n" +msgstr "Prüfen Sie die Menge für diese Produkte:\n" + +#. module: sale_restricted_qty +msgid "" +"Decide if the maximum quantity constraint is strictly enforced (Blocking) or if it only triggers a warning (Warning).\n" +"Use 'Warning' if you want to allow large orders that exceed strict policies under special conditions." +msgstr "" + +#. module: sale_restricted_qty +msgid "" +"Decide if the minimum quantity constraint is strictly enforced (Blocking) or if it only triggers a warning (Warning).\n" +"Use 'Warning' if you want to allow exceptions like selling samples or leftover stock." +msgstr "" + +#. module: sale_restricted_qty +msgid "" +"Decide if the multiple-of quantity constraint is strictly enforced (Blocking) or if it only triggers a warning (Warning).\n" +"Use 'Warning' to allow selling non-standard quantities for special cases like clearing leftover stock." +msgstr "" + +#. module: sale_restricted_qty +msgid "Max Qty" +msgstr "" + +#. module: sale_restricted_qty +msgid "Max Quantity Exceeded" +msgstr "" + +#. module: sale_restricted_qty +msgid "Max Quantity Recommended" +msgstr "" + +#. module: sale_restricted_qty +msgid "Min Qty" +msgstr "" + +#. module: sale_restricted_qty +msgid "Min Quantity Recommended" +msgstr "" + +#. module: sale_restricted_qty +msgid "Min Quantity Required" +msgstr "" + +#. module: sale_restricted_qty +msgid "Multiple Quantity Required" +msgstr "" + +#. module: sale_restricted_qty +msgid "Multiple-Of Qty" +msgstr "" + +#. module: sale_restricted_qty +msgid "Restrict" +msgstr "" + +#. module: sale_restricted_qty +msgid "Sale Max qty" +msgstr "" + +#. module: sale_restricted_qty +msgid "Sale Miltiple qty" +msgstr "" + +#. module: sale_restricted_qty +msgid "Sale Min qty" +msgstr "" + +#. module: sale_restricted_qty +msgid "Sale Restricted Qty" +msgstr "" + +#. module: sale_restricted_qty +msgid "Sale restricted qty" +msgstr "" + +#. module: sale_restricted_qty +msgid "The maximum quantity of product that can be sold." +msgstr "" + +#. module: sale_restricted_qty +msgid "The minimum quantity of product that can be sold." +msgstr "" + +#. module: sale_restricted_qty +msgid "The multiple-of quantity of product that can be sold." +msgstr "" + +#. module: sale_restricted_qty +msgid "Value" +msgstr "" + +#. module: sale_restricted_qty +msgid "Warning" +msgstr "Warnung" + +#. module: sale_restricted_qty +msgid "maximal quantity is %(max_qty)s" +msgstr "Höchstmenge ist %(max_qty)s" + +#. module: sale_restricted_qty +msgid "minimal quantity is %(min_qty)s" +msgstr "Mindestmenge ist %(min_qty)s" + +#. module: sale_restricted_qty +msgid "quantity should be multiple of %(multiple_of_qty)s" +msgstr "Menge sollte ein Vielfaches von %(multiple_of_qty)s sein" diff --git a/sale_restricted_qty/i18n/es.po b/sale_restricted_qty/i18n/es.po new file mode 100644 index 00000000000..45a1a355cee --- /dev/null +++ b/sale_restricted_qty/i18n/es.po @@ -0,0 +1,315 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * sale_restricted_qty +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-03-03 12:00+0000\n" +"PO-Revision-Date: 2024-01-16 18:36+0000\n" +"Last-Translator: Ivorra78 \n" +"Language-Team: none\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.17\n" + +#. module: sale_restricted_qty +msgid "%(order_name)s - Product \"%(product_name)s\": %(failed_constraints)s" +msgstr "%(order_name)s - Producto \"%(product_name)s\": %(failed_constraints)s" + +#. module: sale_restricted_qty +msgid "Blocking" +msgstr "Bloqueo" + +#. module: sale_restricted_qty +msgid "Check quantity for these products:\n" +msgstr "Verifique la cantidad de estos productos:\n" + +#. module: sale_restricted_qty +msgid "" +"Decide if the maximum quantity constraint is strictly enforced (Blocking) or if it only triggers a warning (Warning).\n" +"Use 'Warning' if you want to allow large orders that exceed strict policies under special conditions." +msgstr "" + +#. module: sale_restricted_qty +msgid "" +"Decide if the minimum quantity constraint is strictly enforced (Blocking) or if it only triggers a warning (Warning).\n" +"Use 'Warning' if you want to allow exceptions like selling samples or leftover stock." +msgstr "" + +#. module: sale_restricted_qty +msgid "" +"Decide if the multiple-of quantity constraint is strictly enforced (Blocking) or if it only triggers a warning (Warning).\n" +"Use 'Warning' to allow selling non-standard quantities for special cases like clearing leftover stock." +msgstr "" + +#. module: sale_restricted_qty +msgid "Max Qty" +msgstr "Ctd Máx" + +#. module: sale_restricted_qty +msgid "Max Quantity Exceeded" +msgstr "Cantidad Máxima Excedida" + +#. module: sale_restricted_qty +msgid "Max Quantity Recommended" +msgstr "Cantidad Máx. Recomendada" + +#. module: sale_restricted_qty +msgid "Min Qty" +msgstr "Ctd Mín" + +#. module: sale_restricted_qty +msgid "Min Quantity Recommended" +msgstr "Cantidad Mín Recomendada" + +#. module: sale_restricted_qty +msgid "Min Quantity Required" +msgstr "Cantidad Mín Requerida" + +#. module: sale_restricted_qty +msgid "Multiple Quantity Required" +msgstr "Cantidad Múltiple Requerida" + +#. module: sale_restricted_qty +#, fuzzy +msgid "Multiple-Of Qty" +msgstr "Ctd Múltiple" + +#. module: sale_restricted_qty +msgid "Restrict" +msgstr "" + +#. module: sale_restricted_qty +msgid "Sale Max qty" +msgstr "Venta ctd. Máx" + +#. module: sale_restricted_qty +#, fuzzy +msgid "Sale Miltiple qty" +msgstr "Venta cant. Múltiple" + +#. module: sale_restricted_qty +msgid "Sale Min qty" +msgstr "Venta ctd Min" + +#. module: sale_restricted_qty +#, fuzzy +msgid "Sale Restricted Qty" +msgstr "Ctd. de venta restringida" + +#. module: sale_restricted_qty +msgid "Sale restricted qty" +msgstr "Ctd. de venta restringida" + +#. module: sale_restricted_qty +#, fuzzy +msgid "The maximum quantity of product that can be sold." +msgstr "Compruebe la cantidad máxima de pedido para estos productos: * \n" + +#. module: sale_restricted_qty +#, fuzzy +msgid "The minimum quantity of product that can be sold." +msgstr "Verifique la cantidad mínima de pedido para estos productos: * \n" + +#. module: sale_restricted_qty +#, fuzzy +msgid "The multiple-of quantity of product that can be sold." +msgstr "Compruebe la cantidad de pedidos múltiples para estos productos: * \n" + +#. module: sale_restricted_qty +msgid "Value" +msgstr "" + +#. module: sale_restricted_qty +msgid "Warning" +msgstr "Advertencia" + +#. module: sale_restricted_qty +msgid "maximal quantity is %(max_qty)s" +msgstr "la cantidad máxima es %(max_qty)s" + +#. module: sale_restricted_qty +msgid "minimal quantity is %(min_qty)s" +msgstr "la cantidad mínima es %(min_qty)s" + +#. module: sale_restricted_qty +msgid "quantity should be multiple of %(multiple_of_qty)s" +msgstr "la cantidad debe ser múltiplo de %(multiple_of_qty)s" + +#, python-format +#~ msgid "" +#~ "\n" +#~ "* If you want sell quantity bigger than max Quantity,Check \"force max quatity\" on product" +#~ msgstr "" +#~ "\n" +#~ "* Si desea vender una cantidad superior a la cantidad máxima, marque \"forzar cantidad máxima\" en el producto" + +#, python-format +#~ msgid "" +#~ "\n" +#~ "* If you want sell quantity less than Min Quantity,Check \"force min quatity\" on product" +#~ msgstr "" +#~ "\n" +#~ "* Si desea vender una cantidad inferior a la cantidad mínima, marque la \"cantidad mínima forzada\" en el producto" + +#, python-format +#~ msgid "" +#~ "\n" +#~ "* If you want sell quantity not multiple Quantity,Set multiple quantity to 0 on product or product template or product category" +#~ msgstr "" +#~ "\n" +#~ "* Si usted quiere vender cantidad no múltiple Cantidad, Establecer cantidad múltiple a 0 en el producto o plantilla de producto o categoría de producto" + +#~ msgid "" +#~ "Define if user can force sale max qty 'If not set', Odoo will use the value " +#~ "defined in the parent object.Hierarchy is in this order :Product/product " +#~ "Template/product category/parent categories " +#~ msgstr "" +#~ "Defina si el usuario puede forzar la cantidad máxima de venta 'Si no se " +#~ "establece', Odoo usará el valor definido en el objeto parental. La jerarquía" +#~ " es en este orden :Producto/Plantilla de producto/Categoría de " +#~ "producto/Categorías parentales " + +#~ msgid "" +#~ "Define if user can force sale min qty 'If not set', Odoo will use the value " +#~ "defined in the parent object.Hierarchy is in this order :Product/product " +#~ "Template/product category/parent categories " +#~ msgstr "" +#~ "Definir si el usuario puede forzar la venta de la cantidad min. 'Si no se " +#~ "establece', Odoo utilizará el valor definido en el objeto parental .La " +#~ "jerarquía es en este orden :Producto/Plantilla de producto/Categoría de " +#~ "producto/Categorías parentales " + +#~ msgid "" +#~ "Define sale max qty 'If not set, Odoo will use the value defined in the " +#~ "parent object.Hierarchy is in this order :Product/product Template/product " +#~ "category/parent categories " +#~ msgstr "" +#~ "Definir cantidad máxima de venta 'Si no se establece, Odoo utilizará el " +#~ "valor definido en el objeto parental. la jerarquía es en este orden " +#~ ":Producto/Plantilla de producto/Categoría de producto/Categorías parentales " + +#~ msgid "" +#~ "Define sale min qty 'If not set, Odoo will use the value defined in the " +#~ "parent object.Hierarchy is in this order :Product/product Template/product " +#~ "category/parent categories " +#~ msgstr "" +#~ "Definir venta min cant. 'Si no se establece, Odoo utilizará el valor " +#~ "definido en el objeto principal. La jerarquía es en este orden " +#~ ":Producto/plantilla de producto/categoría de producto/categorías principales" +#~ " " + +#~ msgid "" +#~ "Define sale multiple qty 'If not set', Odoo will use the value defined in " +#~ "the parent object.Hierarchy is in this order :Product/product " +#~ "Template/product category/parent categories " +#~ msgstr "" +#~ "Definir cantidad de venta múltiple 'Si no se establece', Odoo utilizará el " +#~ "valor definido en el objeto primario. La jerarquía es en este orden: " +#~ "Producto / Plantilla de producto / categoría de producto / categorías " +#~ "primarias. " + +#~ msgid "Display Name" +#~ msgstr "Mostrar Nombre" + +#~ msgid "Force Max Qty" +#~ msgstr "Forzar Cant Máx" + +#~ msgid "Force Min Qty" +#~ msgstr "Forzar Cntd Mín" + +#~ msgid "Force Sale Max Qty" +#~ msgstr "Forzar Venta Cntd Máx" + +#~ msgid "Force Sale Min Qty" +#~ msgstr "Forzar Vender Cntd Min" + +#~ msgid "ID" +#~ msgstr "" + +#~ msgid "" +#~ "If force max qty is checked, the max quantity is only indicative value.If is" +#~ " not test we check parent value" +#~ msgstr "" +#~ "Si se marca la forzar cantidad máxima, la cantidad máxima es solo un valor " +#~ "indicativo. Si no es una prueba, verificamos el valor principal" + +#~ msgid "" +#~ "If force min qty is checked, the min quantity is only indicative value.If is" +#~ " not test we check parent value" +#~ msgstr "" +#~ "Si se marca forzar cantidad mínima , la cantidad mínima es solo un valor " +#~ "indicativo. Si no es una prueba, verificamos el valor principal" + +#~ msgid "Last Modified on" +#~ msgstr "Última Modificación el" + +#~ msgid "Manual Force Max Qty" +#~ msgstr "Forzar Manualmente Ctd. Máx" + +#~ msgid "Manual Force Min Qty" +#~ msgstr "Forzar Manualmente Ctd. Min" + +#~ msgid "Max Sale Qty" +#~ msgstr "Cantidad Máx. Venta" + +#~ msgid "Min Sale Qty" +#~ msgstr "Cantidad Mín de Venta" + +#~ msgid "Multiple Sale Qty" +#~ msgstr "Ctd. de Venta Múltiple" + +#~ msgid "No" +#~ msgstr "" + +#~ msgid "Not Multiple Qty" +#~ msgstr "No Ctd. Múltiple" + +#~ msgid "Product" +#~ msgstr "Producto" + +#, python-format +#~ msgid "Product \"%(product_name)s\": Max Quantity %(sale_own_max_qty)s." +#~ msgstr "Producto \"%(product_name)s\": Cantidad Máx %(sale_own_max_qty)s." + +#, python-format +#~ msgid "Product \"%(product_name)s\": multiple Quantity %(sale_multiple_qty)s." +#~ msgstr "Producto \"%(product_name)s\": Cantidad múltiple %(sale_multiple_qty)s." + +#~ msgid "Product Category" +#~ msgstr "Categoría de Producto" + +#~ msgid "Product Restrict Qty Mixin" +#~ msgstr "Mezcla de Cntd Restringida de Productos" + +#~ msgid "Product Template" +#~ msgstr "Plantilla Producto" + +#~ msgid "Qty < Min Qty" +#~ msgstr "Cant. < Cant. Mín" + +#~ msgid "Qty > max Qty" +#~ msgstr "Ctd. > Ctd. máx" + +#~ msgid "Sale Max Qty" +#~ msgstr "Venta Ctd. Máx" + +#~ msgid "Sale Min Qty" +#~ msgstr "Venta Ctd Min" + +#~ msgid "Sale Multiple Qty" +#~ msgstr "Venta Cant. Múltiple" + +#~ msgid "Sales Order Line" +#~ msgstr "Línea de Orden de Venta" + +#~ msgid "Use Parent Setting" +#~ msgstr "Usar Configuración Parental" + +#~ msgid "Yes" +#~ msgstr "Si" diff --git a/sale_restricted_qty/i18n/fr.po b/sale_restricted_qty/i18n/fr.po new file mode 100644 index 00000000000..4a987dbeb1b --- /dev/null +++ b/sale_restricted_qty/i18n/fr.po @@ -0,0 +1,315 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * sale_restricted_qty +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-03-03 12:00+0000\n" +"PO-Revision-Date: 2023-06-17 11:09+0000\n" +"Last-Translator: Claude R Perrin \n" +"Language-Team: none\n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" +"X-Generator: Weblate 4.17\n" + +#. module: sale_restricted_qty +msgid "%(order_name)s - Product \"%(product_name)s\": %(failed_constraints)s" +msgstr "%(order_name)s - Produit \"%(product_name)s\" : %(failed_constraints)s" + +#. module: sale_restricted_qty +msgid "Blocking" +msgstr "Bloquant" + +#. module: sale_restricted_qty +msgid "Check quantity for these products:\n" +msgstr "Vérifiez la quantité pour ces produits :\n" + +#. module: sale_restricted_qty +msgid "" +"Decide if the maximum quantity constraint is strictly enforced (Blocking) or if it only triggers a warning (Warning).\n" +"Use 'Warning' if you want to allow large orders that exceed strict policies under special conditions." +msgstr "" + +#. module: sale_restricted_qty +msgid "" +"Decide if the minimum quantity constraint is strictly enforced (Blocking) or if it only triggers a warning (Warning).\n" +"Use 'Warning' if you want to allow exceptions like selling samples or leftover stock." +msgstr "" + +#. module: sale_restricted_qty +msgid "" +"Decide if the multiple-of quantity constraint is strictly enforced (Blocking) or if it only triggers a warning (Warning).\n" +"Use 'Warning' to allow selling non-standard quantities for special cases like clearing leftover stock." +msgstr "" + +#. module: sale_restricted_qty +msgid "Max Qty" +msgstr "Qté max" + +#. module: sale_restricted_qty +msgid "Max Quantity Exceeded" +msgstr "Quantité maximale dépassée" + +#. module: sale_restricted_qty +msgid "Max Quantity Recommended" +msgstr "Quantité maximale recommandée" + +#. module: sale_restricted_qty +msgid "Min Qty" +msgstr "Qté Min" + +#. module: sale_restricted_qty +msgid "Min Quantity Recommended" +msgstr "Quantité minimale recommandée" + +#. module: sale_restricted_qty +msgid "Min Quantity Required" +msgstr "Quantité minimale requise" + +#. module: sale_restricted_qty +msgid "Multiple Quantity Required" +msgstr "Quantité multiple requise" + +#. module: sale_restricted_qty +#, fuzzy +msgid "Multiple-Of Qty" +msgstr "Quantité multiple" + +#. module: sale_restricted_qty +msgid "Restrict" +msgstr "" + +#. module: sale_restricted_qty +msgid "Sale Max qty" +msgstr "Qté max vendable" + +#. module: sale_restricted_qty +#, fuzzy +msgid "Sale Miltiple qty" +msgstr "Qté multiple par commande" + +#. module: sale_restricted_qty +msgid "Sale Min qty" +msgstr "Qté min vendable" + +#. module: sale_restricted_qty +#, fuzzy +msgid "Sale Restricted Qty" +msgstr "Limitations des quantités vendables" + +#. module: sale_restricted_qty +msgid "Sale restricted qty" +msgstr "Limitations des quantités vendables" + +#. module: sale_restricted_qty +#, fuzzy +msgid "The maximum quantity of product that can be sold." +msgstr "Vérifiez la quantité maximale de commande pour ces articles : * \n" + +#. module: sale_restricted_qty +#, fuzzy +msgid "The minimum quantity of product that can be sold." +msgstr "Vérifiez la quantité minimale de commande pour ces articles : * \n" + +#. module: sale_restricted_qty +#, fuzzy +msgid "The multiple-of quantity of product that can be sold." +msgstr "Vérifiez la quantité de commande multiple pour ces articles : * \n" + +#. module: sale_restricted_qty +msgid "Value" +msgstr "" + +#. module: sale_restricted_qty +msgid "Warning" +msgstr "Avertissement" + +#. module: sale_restricted_qty +msgid "maximal quantity is %(max_qty)s" +msgstr "la quantité maximale est %(max_qty)s" + +#. module: sale_restricted_qty +msgid "minimal quantity is %(min_qty)s" +msgstr "la quantité minimale est %(min_qty)s" + +#. module: sale_restricted_qty +msgid "quantity should be multiple of %(multiple_of_qty)s" +msgstr "la quantité doit être un multiple de %(multiple_of_qty)s" + +#, python-format +#~ msgid "" +#~ "\n" +#~ "* If you want sell quantity bigger than max Quantity,Check \"force max quatity\" on product" +#~ msgstr "" +#~ "\n" +#~ "* Si vous voulez vendre une quantité supérieure à la quantité maximale, cochez « Forcer Qté Max » sur l'article" + +#, python-format +#~ msgid "" +#~ "\n" +#~ "* If you want sell quantity less than Min Quantity,Check \"force min quatity\" on product" +#~ msgstr "" +#~ "\n" +#~ "* Si vous souhaitez vendre une quantité inférieure à la quantité minimale, cochez « Forcer Qté Min » sur l'article" + +#, python-format +#~ msgid "" +#~ "\n" +#~ "* If you want sell quantity not multiple Quantity,Set multiple quantity to 0 on product or product template or product category" +#~ msgstr "" +#~ "\n" +#~ "* Si vous souhaitez vendre la quantité et non plusieurs, définissez la quantité multiple sur 0 sur l'article ou le modèle d'article ou la catégorie d'article" + +#~ msgid "" +#~ "Define if user can force sale max qty 'If not set', Odoo will use the value " +#~ "defined in the parent object.Hierarchy is in this order :Product/product " +#~ "Template/product category/parent categories " +#~ msgstr "" +#~ "Définir si l’utilisateur peut forcer la qté max de commande 'Si ce n’est pas" +#~ " défini', Odoo utilisera la valeur définie dans l’objet parent. La " +#~ "hiérarchie suit cet ordre :Produit/Modèle de produit/catégorie de " +#~ "produit/catégories parentes " + +#~ msgid "" +#~ "Define if user can force sale min qty 'If not set', Odoo will use the value " +#~ "defined in the parent object.Hierarchy is in this order :Product/product " +#~ "Template/product category/parent categories " +#~ msgstr "" +#~ "Définir si l’utilisateur peut forcer la qté min de commande 'Si ce n’est pas" +#~ " défini', Odoo utilisera la valeur définie dans l’objet parent. La " +#~ "hiérarchie suit cet ordre :Produit/Modèle de produit/catégorie de " +#~ "produit/catégories parentes " + +#~ msgid "" +#~ "Define sale max qty 'If not set, Odoo will use the value defined in the " +#~ "parent object.Hierarchy is in this order :Product/product Template/product " +#~ "category/parent categories " +#~ msgstr "" +#~ "Définir la vente max qté 'S’elle n’est pas définie, Odoo utilisera la valeur" +#~ " définie dans l’objet parent. La hiérarchie suite cet ordre :Produit/Modèle " +#~ "de produit/catégorie de produit/catégories parentes " + +#~ msgid "" +#~ "Define sale min qty 'If not set, Odoo will use the value defined in the " +#~ "parent object.Hierarchy is in this order :Product/product Template/product " +#~ "category/parent categories " +#~ msgstr "" +#~ "Définir la vente min qté 'Si elle n’est pas définie, Odoo utilisera la " +#~ "valeur définie dans l’objet parent. La hiérarchie suit cet ordre " +#~ ":Produit/Modèle de produit/catégorie de produit/catégories parentes " + +#~ msgid "" +#~ "Define sale multiple qty 'If not set', Odoo will use the value defined in " +#~ "the parent object.Hierarchy is in this order :Product/product " +#~ "Template/product category/parent categories " +#~ msgstr "" +#~ "Définir la vente multiple 'Si elle n’est pas défini', Odoo utilisera la " +#~ "valeur définie dans l’objet parent. La hiérarchie suit cet ordre " +#~ ":Produit/Modèle de produit/catégorie de produit/catégories parentes " + +#~ msgid "Display Name" +#~ msgstr "Nom affiché" + +#~ msgid "Force Max Qty" +#~ msgstr "Forcer qté Max" + +#~ msgid "Force Min Qty" +#~ msgstr "Forcer qté Min" + +#~ msgid "Force Sale Max Qty" +#~ msgstr "Forcer qté max vendable" + +#~ msgid "Force Sale Min Qty" +#~ msgstr "Forcer qté min vendable" + +#~ msgid "ID" +#~ msgstr "" + +#~ msgid "" +#~ "If force max qty is checked, the max quantity is only indicative value.If is" +#~ " not test we check parent value" +#~ msgstr "" +#~ "Si forcer qté max est cochée, la quantité max n’est qu’une valeur " +#~ "indicative. Si c’est pas coché, nous vérifions la valeur parente" + +#~ msgid "" +#~ "If force min qty is checked, the min quantity is only indicative value.If is" +#~ " not test we check parent value" +#~ msgstr "" +#~ "Si forcer qté min est cochée, la quantité min n’est qu’indicative. Si ce " +#~ "n’est pas coché, nous vérifions la valeur parente" + +#~ msgid "Last Modified on" +#~ msgstr "Dernière modification le" + +#~ msgid "Manual Force Max Qty" +#~ msgstr "Force manuellement Qté max" + +#~ msgid "Manual Force Min Qty" +#~ msgstr "Force manuellement Qté min" + +#~ msgid "Max Sale Qty" +#~ msgstr "Qté max vendable" + +#~ msgid "Min Sale Qty" +#~ msgstr "Qty min vendable" + +#~ msgid "Multiple Sale Qty" +#~ msgstr "Qty vente multiple" + +#~ msgid "No" +#~ msgstr "Non" + +#~ msgid "Not Multiple Qty" +#~ msgstr "Pas de qté multiples" + +#~ msgid "Product" +#~ msgstr "Article" + +#, python-format +#~ msgid "Product \"%(product_name)s\": Max Quantity %(sale_own_max_qty)s." +#~ msgstr "" +#~ "Article « %(product_name)s » : quantité maximale %(sale_own_max_qty)s." + +#, python-format +#~ msgid "Product \"%(product_name)s\": multiple Quantity %(sale_multiple_qty)s." +#~ msgstr "" +#~ "Articles « %(product_name)s » : quantités multiples %(sale_multiple_qty)s." + +#~ msgid "Product Category" +#~ msgstr "Catégorie d'articles" + +#~ msgid "Product Restrict Qty Mixin" +#~ msgstr "Mixin limitations quantités vendables" + +#~ msgid "Product Template" +#~ msgstr "Modèle d'article" + +#~ msgid "Qty < Min Qty" +#~ msgstr "Qté < Qté min" + +#~ msgid "Qty > max Qty" +#~ msgstr "Qté > Qté max" + +#~ msgid "Sale Max Qty" +#~ msgstr "Qté max vendable" + +#~ msgid "Sale Min Qty" +#~ msgstr "Qté min vendable" + +#~ msgid "Sale Multiple Qty" +#~ msgstr "Qté multiple vendable" + +#~ msgid "Sales Order Line" +#~ msgstr "Ligne de bon de commande" + +#~ msgid "Use Parent Setting" +#~ msgstr "Utiliser le paramètre parent" + +#~ msgid "Yes" +#~ msgstr "Oui" diff --git a/sale_restricted_qty/i18n/it.po b/sale_restricted_qty/i18n/it.po new file mode 100644 index 00000000000..b9645f5bce6 --- /dev/null +++ b/sale_restricted_qty/i18n/it.po @@ -0,0 +1,313 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * sale_restricted_qty +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 12.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-03-03 12:00+0000\n" +"PO-Revision-Date: 2023-11-03 13:40+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.17\n" + +#. module: sale_restricted_qty +msgid "%(order_name)s - Product \"%(product_name)s\": %(failed_constraints)s" +msgstr "%(order_name)s - Prodotto \"%(product_name)s\": %(failed_constraints)s" + +#. module: sale_restricted_qty +msgid "Blocking" +msgstr "Bloccante" + +#. module: sale_restricted_qty +msgid "Check quantity for these products:\n" +msgstr "Controlla la quantità per questi prodotti:\n" + +#. module: sale_restricted_qty +msgid "" +"Decide if the maximum quantity constraint is strictly enforced (Blocking) or if it only triggers a warning (Warning).\n" +"Use 'Warning' if you want to allow large orders that exceed strict policies under special conditions." +msgstr "" + +#. module: sale_restricted_qty +msgid "" +"Decide if the minimum quantity constraint is strictly enforced (Blocking) or if it only triggers a warning (Warning).\n" +"Use 'Warning' if you want to allow exceptions like selling samples or leftover stock." +msgstr "" + +#. module: sale_restricted_qty +msgid "" +"Decide if the multiple-of quantity constraint is strictly enforced (Blocking) or if it only triggers a warning (Warning).\n" +"Use 'Warning' to allow selling non-standard quantities for special cases like clearing leftover stock." +msgstr "" + +#. module: sale_restricted_qty +msgid "Max Qty" +msgstr "Quantità Massima" + +#. module: sale_restricted_qty +msgid "Max Quantity Exceeded" +msgstr "Quantità Massima Superata" + +#. module: sale_restricted_qty +msgid "Max Quantity Recommended" +msgstr "Q.tà massima consigliata" + +#. module: sale_restricted_qty +msgid "Min Qty" +msgstr "Q.tà minima" + +#. module: sale_restricted_qty +msgid "Min Quantity Recommended" +msgstr "Q.tà minima consigliata" + +#. module: sale_restricted_qty +msgid "Min Quantity Required" +msgstr "Q.tà minima richiesta" + +#. module: sale_restricted_qty +msgid "Multiple Quantity Required" +msgstr "Quantità Multipla Richiesta" + +#. module: sale_restricted_qty +#, fuzzy +msgid "Multiple-Of Qty" +msgstr "Q.tà multiple" + +#. module: sale_restricted_qty +msgid "Restrict" +msgstr "" + +#. module: sale_restricted_qty +msgid "Sale Max qty" +msgstr "Q.tà massima di vendita" + +#. module: sale_restricted_qty +#, fuzzy +msgid "Sale Miltiple qty" +msgstr "Q.tà multipla di vendita" + +#. module: sale_restricted_qty +msgid "Sale Min qty" +msgstr "Q.tà minima di vendita" + +#. module: sale_restricted_qty +#, fuzzy +msgid "Sale Restricted Qty" +msgstr "Quantità di vendita limitata" + +#. module: sale_restricted_qty +msgid "Sale restricted qty" +msgstr "Quantità di vendita limitata" + +#. module: sale_restricted_qty +#, fuzzy +msgid "The maximum quantity of product that can be sold." +msgstr "Controlla la quantità di vendita massima per questi prodotti: * \n" + +#. module: sale_restricted_qty +#, fuzzy +msgid "The minimum quantity of product that can be sold." +msgstr "Controlla la quantità di vendita minima per questi prodotti: * \n" + +#. module: sale_restricted_qty +#, fuzzy +msgid "The multiple-of quantity of product that can be sold." +msgstr "Controlla la quantità di vendita multipla per questi prodotti: * \n" + +#. module: sale_restricted_qty +msgid "Value" +msgstr "" + +#. module: sale_restricted_qty +msgid "Warning" +msgstr "Avvertimento" + +#. module: sale_restricted_qty +msgid "maximal quantity is %(max_qty)s" +msgstr "la quantità massima è %(max_qty)s" + +#. module: sale_restricted_qty +msgid "minimal quantity is %(min_qty)s" +msgstr "la quantità minima è %(min_qty)s" + +#. module: sale_restricted_qty +msgid "quantity should be multiple of %(multiple_of_qty)s" +msgstr "la quantità deve essere un multiplo di %(multiple_of_qty)s" + +#, python-format +#~ msgid "" +#~ "\n" +#~ "* If you want sell quantity bigger than max Quantity,Check \"force max quatity\" on product" +#~ msgstr "" +#~ "\n" +#~ "* se vuoi vendere una quantità maggiore della Quantità massima, Seleziona \"forza quantità massima\" nel prodotto" + +#, python-format +#~ msgid "" +#~ "\n" +#~ "* If you want sell quantity less than Min Quantity,Check \"force min quatity\" on product" +#~ msgstr "" +#~ "\n" +#~ "* se vuoi vendere una quantità inferiore alla Quantità Minima, Seleziona \"forza quantità minima\" nel prodotto" + +#, python-format +#~ msgid "" +#~ "\n" +#~ "* If you want sell quantity not multiple Quantity,Set multiple quantity to 0 on product or product template or product category" +#~ msgstr "" +#~ "\n" +#~ "* se vuoi vendere quantità non multiple, Imposta quantità multiple a 0 nella Prodotto o Modello Prodotto o Categoria Prodotto" + +#~ msgid "" +#~ "Define if user can force sale max qty 'If not set', Odoo will use the value " +#~ "defined in the parent object.Hierarchy is in this order :Product/product " +#~ "Template/product category/parent categories " +#~ msgstr "" +#~ "Definisci se l'utente può forzare la quantità massima di vendita 'Se non " +#~ "impostato', Odoo utilizzerà il valore definito nell'oggetto padre. La " +#~ "gerarchia è in questo ordine: Prodotto/Modello prodotto/categoria " +#~ "prodotto/categorie padre " + +#~ msgid "" +#~ "Define if user can force sale min qty 'If not set', Odoo will use the value " +#~ "defined in the parent object.Hierarchy is in this order :Product/product " +#~ "Template/product category/parent categories " +#~ msgstr "" +#~ "Definisci se l'utente può forzare la quantità minima di vendita 'Se non " +#~ "impostato', Odoo utilizzerà il valore definito nell'oggetto padre. La " +#~ "gerarchia è in questo ordine: Prodotto/Modello prodotto/categoria " +#~ "prodotto/categorie padre " + +#~ msgid "" +#~ "Define sale max qty 'If not set, Odoo will use the value defined in the " +#~ "parent object.Hierarchy is in this order :Product/product Template/product " +#~ "category/parent categories " +#~ msgstr "" +#~ "Definisci la q.tà massima di vendita 'Se non impostato', Odoo utilizzerà il " +#~ "valore definito nell'oggetto padre. La gerarchia è in questo ordine: " +#~ "Prodotto/Modello prodotto/categoria prodotto/categorie padre " + +#~ msgid "" +#~ "Define sale min qty 'If not set, Odoo will use the value defined in the " +#~ "parent object.Hierarchy is in this order :Product/product Template/product " +#~ "category/parent categories " +#~ msgstr "" +#~ "Definisci la q.tà minima di vendita 'Se non impostato', Odoo utilizzerà il " +#~ "valore definito nell'oggetto padre. La gerarchia è in questo ordine: " +#~ "Prodotto/Modello prodotto/categoria prodotto/categorie padre " + +#~ msgid "" +#~ "Define sale multiple qty 'If not set', Odoo will use the value defined in " +#~ "the parent object.Hierarchy is in this order :Product/product " +#~ "Template/product category/parent categories " +#~ msgstr "" +#~ "Definisci la q.tà multipla di vendita 'Se non impostato', Odoo utilizzerà il" +#~ " valore definito nell'oggetto padre. La gerarchia è in questo ordine: " +#~ "Prodotto/Modello prodotto/categoria prodotto/categorie padre " + +#~ msgid "Display Name" +#~ msgstr "Nome visualizzato" + +#~ msgid "Force Max Qty" +#~ msgstr "Forza q.tà massima" + +#~ msgid "Force Min Qty" +#~ msgstr "Forza q.tà minima" + +#~ msgid "Force Sale Max Qty" +#~ msgstr "Forza q.tà minima di vendita" + +#~ msgid "Force Sale Min Qty" +#~ msgstr "Forza q.tà massima di vendita" + +#~ msgid "ID" +#~ msgstr "" + +#~ msgid "" +#~ "If force max qty is checked, the max quantity is only indicative value.If is" +#~ " not test we check parent value" +#~ msgstr "" +#~ "Se forza q.tà massima è selezionata, la quantità massima è solo un valore " +#~ "indicativo. Se non è test controlliamo il valore genitore" + +#~ msgid "" +#~ "If force min qty is checked, the min quantity is only indicative value.If is" +#~ " not test we check parent value" +#~ msgstr "" +#~ "Se forza q.tà minima è selezionata, la quantità minima è solo un valore " +#~ "indicativo. Se non è test controlliamo il valore genitore" + +#~ msgid "Last Modified on" +#~ msgstr "Ultima modifica il" + +#~ msgid "Manual Force Max Qty" +#~ msgstr "Forza manualmente q.tà massima" + +#~ msgid "Manual Force Min Qty" +#~ msgstr "Forza manualmente q.tà minima" + +#~ msgid "Max Sale Qty" +#~ msgstr "Q.tà di vendita massima" + +#~ msgid "Min Sale Qty" +#~ msgstr "Q.tà di vendita minima" + +#~ msgid "Multiple Sale Qty" +#~ msgstr "Quantità di Vendita Multipla" + +#~ msgid "No" +#~ msgstr "" + +#~ msgid "Not Multiple Qty" +#~ msgstr "Q.tà non multipla" + +#~ msgid "Product" +#~ msgstr "Prodotto" + +#, python-format +#~ msgid "Product \"%(product_name)s\": Max Quantity %(sale_own_max_qty)s." +#~ msgstr "Prodotto \"%(product_name)s\": Quantità Massima %(sale_own_max_qty)s." + +#, python-format +#~ msgid "Product \"%(product_name)s\": multiple Quantity %(sale_multiple_qty)s." +#~ msgstr "prodotto \"%(product_name)s\": Quantità Multipla %(sale_multiple_qty)s." + +#~ msgid "Product Category" +#~ msgstr "Categoria prodotto" + +#~ msgid "Product Restrict Qty Mixin" +#~ msgstr "Mixin Limite di Quantità Prodotto" + +#~ msgid "Product Template" +#~ msgstr "Modello prodotto" + +#~ msgid "Qty < Min Qty" +#~ msgstr "Q.tà < q.tà minima" + +#~ msgid "Qty > max Qty" +#~ msgstr "Q.tà > q.tà massima" + +#~ msgid "Sale Max Qty" +#~ msgstr "Q.tà massima di vendita" + +#~ msgid "Sale Min Qty" +#~ msgstr "Q.tà minima di vendita" + +#~ msgid "Sale Multiple Qty" +#~ msgstr "Q.tà multipla di vendita" + +#~ msgid "Sales Order Line" +#~ msgstr "Riga ordine di vendita" + +#~ msgid "Use Parent Setting" +#~ msgstr "Usa Impostazioni Genitore" + +#~ msgid "Yes" +#~ msgstr "Sì" diff --git a/sale_restricted_qty/i18n/nl.po b/sale_restricted_qty/i18n/nl.po new file mode 100644 index 00000000000..115b77d0729 --- /dev/null +++ b/sale_restricted_qty/i18n/nl.po @@ -0,0 +1,134 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * sale_restricted_qty +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-03-03 12:00+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: nl\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: sale_restricted_qty +msgid "%(order_name)s - Product \"%(product_name)s\": %(failed_constraints)s" +msgstr "" + +#. module: sale_restricted_qty +msgid "Blocking" +msgstr "Blokkerend" + +#. module: sale_restricted_qty +msgid "Check quantity for these products:\n" +msgstr "Controleer het aantal voor deze producten:\n" + +#. module: sale_restricted_qty +msgid "" +"Decide if the maximum quantity constraint is strictly enforced (Blocking) or if it only triggers a warning (Warning).\n" +"Use 'Warning' if you want to allow large orders that exceed strict policies under special conditions." +msgstr "" + +#. module: sale_restricted_qty +msgid "" +"Decide if the minimum quantity constraint is strictly enforced (Blocking) or if it only triggers a warning (Warning).\n" +"Use 'Warning' if you want to allow exceptions like selling samples or leftover stock." +msgstr "" + +#. module: sale_restricted_qty +msgid "" +"Decide if the multiple-of quantity constraint is strictly enforced (Blocking) or if it only triggers a warning (Warning).\n" +"Use 'Warning' to allow selling non-standard quantities for special cases like clearing leftover stock." +msgstr "" + +#. module: sale_restricted_qty +msgid "Max Qty" +msgstr "" + +#. module: sale_restricted_qty +msgid "Max Quantity Exceeded" +msgstr "" + +#. module: sale_restricted_qty +msgid "Max Quantity Recommended" +msgstr "" + +#. module: sale_restricted_qty +msgid "Min Qty" +msgstr "" + +#. module: sale_restricted_qty +msgid "Min Quantity Recommended" +msgstr "" + +#. module: sale_restricted_qty +msgid "Min Quantity Required" +msgstr "" + +#. module: sale_restricted_qty +msgid "Multiple Quantity Required" +msgstr "" + +#. module: sale_restricted_qty +msgid "Multiple-Of Qty" +msgstr "" + +#. module: sale_restricted_qty +msgid "Restrict" +msgstr "" + +#. module: sale_restricted_qty +msgid "Sale Max qty" +msgstr "" + +#. module: sale_restricted_qty +msgid "Sale Miltiple qty" +msgstr "" + +#. module: sale_restricted_qty +msgid "Sale Min qty" +msgstr "" + +#. module: sale_restricted_qty +msgid "Sale Restricted Qty" +msgstr "" + +#. module: sale_restricted_qty +msgid "Sale restricted qty" +msgstr "" + +#. module: sale_restricted_qty +msgid "The maximum quantity of product that can be sold." +msgstr "" + +#. module: sale_restricted_qty +msgid "The minimum quantity of product that can be sold." +msgstr "" + +#. module: sale_restricted_qty +msgid "The multiple-of quantity of product that can be sold." +msgstr "" + +#. module: sale_restricted_qty +msgid "Value" +msgstr "" + +#. module: sale_restricted_qty +msgid "Warning" +msgstr "Waarschuwing" + +#. module: sale_restricted_qty +msgid "maximal quantity is %(max_qty)s" +msgstr "maximale hoeveelheid is %(max_qty)s" + +#. module: sale_restricted_qty +msgid "minimal quantity is %(min_qty)s" +msgstr "minimale hoeveelheid is %(min_qty)s" + +#. module: sale_restricted_qty +msgid "quantity should be multiple of %(multiple_of_qty)s" +msgstr "hoeveelheid moet een veelvoud zijn van %(multiple_of_qty)s" diff --git a/sale_restricted_qty/i18n/sale_restricted_qty.pot b/sale_restricted_qty/i18n/sale_restricted_qty.pot new file mode 100644 index 00000000000..6f2510dda93 --- /dev/null +++ b/sale_restricted_qty/i18n/sale_restricted_qty.pot @@ -0,0 +1,128 @@ +# +msgid "" +msgstr "" +"Project-Id-Version: 18.0\n" +"POT-Creation-Date: 2025-03-03 12:00+0000\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language-Team: : \n" +"Last-Translator: : \n" + +#. module: sale_restricted_qty +msgid "%(order_name)s - Product \"%(product_name)s\": %(failed_constraints)s" +msgstr "" + +#. module: sale_restricted_qty +msgid "Blocking" +msgstr "" + +#. module: sale_restricted_qty +msgid "Check quantity for these products:\n" +msgstr "" + +#. module: sale_restricted_qty +msgid "" +"Decide if the maximum quantity constraint is strictly enforced (Blocking) or if it only triggers a warning (Warning).\n" +"Use 'Warning' if you want to allow large orders that exceed strict policies under special conditions." +msgstr "" + +#. module: sale_restricted_qty +msgid "" +"Decide if the minimum quantity constraint is strictly enforced (Blocking) or if it only triggers a warning (Warning).\n" +"Use 'Warning' if you want to allow exceptions like selling samples or leftover stock." +msgstr "" + +#. module: sale_restricted_qty +msgid "" +"Decide if the multiple-of quantity constraint is strictly enforced (Blocking) or if it only triggers a warning (Warning).\n" +"Use 'Warning' to allow selling non-standard quantities for special cases like clearing leftover stock." +msgstr "" + +#. module: sale_restricted_qty +msgid "Max Qty" +msgstr "" + +#. module: sale_restricted_qty +msgid "Max Quantity Exceeded" +msgstr "" + +#. module: sale_restricted_qty +msgid "Max Quantity Recommended" +msgstr "" + +#. module: sale_restricted_qty +msgid "Min Qty" +msgstr "" + +#. module: sale_restricted_qty +msgid "Min Quantity Recommended" +msgstr "" + +#. module: sale_restricted_qty +msgid "Min Quantity Required" +msgstr "" + +#. module: sale_restricted_qty +msgid "Multiple Quantity Required" +msgstr "" + +#. module: sale_restricted_qty +msgid "Multiple-Of Qty" +msgstr "" + +#. module: sale_restricted_qty +msgid "Restrict" +msgstr "" + +#. module: sale_restricted_qty +msgid "Sale Max qty" +msgstr "" + +#. module: sale_restricted_qty +msgid "Sale Miltiple qty" +msgstr "" + +#. module: sale_restricted_qty +msgid "Sale Min qty" +msgstr "" + +#. module: sale_restricted_qty +msgid "Sale Restricted Qty" +msgstr "" + +#. module: sale_restricted_qty +msgid "Sale restricted qty" +msgstr "" + +#. module: sale_restricted_qty +msgid "The maximum quantity of product that can be sold." +msgstr "" + +#. module: sale_restricted_qty +msgid "The minimum quantity of product that can be sold." +msgstr "" + +#. module: sale_restricted_qty +msgid "The multiple-of quantity of product that can be sold." +msgstr "" + +#. module: sale_restricted_qty +msgid "Value" +msgstr "" + +#. module: sale_restricted_qty +msgid "Warning" +msgstr "" + +#. module: sale_restricted_qty +msgid "maximal quantity is %(max_qty)s" +msgstr "" + +#. module: sale_restricted_qty +msgid "minimal quantity is %(min_qty)s" +msgstr "" + +#. module: sale_restricted_qty +msgid "quantity should be multiple of %(multiple_of_qty)s" +msgstr "" diff --git a/sale_restricted_qty/migrations/18.0.1.0.0/pre-migration.py b/sale_restricted_qty/migrations/18.0.1.0.0/pre-migration.py new file mode 100644 index 00000000000..e425e03d7bc --- /dev/null +++ b/sale_restricted_qty/migrations/18.0.1.0.0/pre-migration.py @@ -0,0 +1,97 @@ +# Copyright 2025 Akretion +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from openupgradelib import openupgrade +from psycopg2.extensions import AsIs + +RENAME_MAP = { + "product_category": [ + ("manual_sale_min_qty", "sale_own_min_qty"), + ("manual_sale_max_qty", "sale_own_max_qty"), + ("manual_sale_multiple_qty", "sale_own_multiple_of_qty"), + ], + "product_template": [ + ("manual_sale_min_qty", "sale_own_min_qty"), + ("manual_sale_max_qty", "sale_own_max_qty"), + ("manual_sale_multiple_qty", "sale_own_multiple_of_qty"), + ], + "product_product": [ + ("manual_sale_min_qty", "sale_own_min_qty"), + ("manual_sale_max_qty", "sale_own_max_qty"), + ("manual_sale_multiple_qty", "sale_own_multiple_of_qty"), + ], +} + + +def migrate_selection_and_flags(cr, table): + """ + Map legacy 'manual_force_*' selections to new Blocking/Warning selections + and set the new 'is_sale_own_*_set' flags. + """ + for field in ["min_qty", "max_qty"]: + old_col = f"manual_force_sale_{field}" + new_col = f"sale_own_restrict_{field}" + flag_col = f"is_sale_own_restrict_{field}_set" + + if openupgrade.column_exists(cr, table, old_col): + # 1. Create the new columns if they don't exist yet + # (Odoo usually creates them in post, but we need them for data move) + if not openupgrade.column_exists(cr, table, new_col): + cr.execute( + "ALTER TABLE %s ADD COLUMN %s varchar", (AsIs(table), AsIs(new_col)) + ) + if not openupgrade.column_exists(cr, table, flag_col): + cr.execute( + "ALTER TABLE %s ADD COLUMN %s boolean DEFAULT false", + (AsIs(table), AsIs(flag_col)), + ) + + # 2. Map data + # 'force' -> '1' (Blocking), is_set = True + cr.execute( + "UPDATE %s SET %s = '1', %s = True " "WHERE %s = 'force'", + (AsIs(table), AsIs(new_col), AsIs(flag_col), AsIs(old_col)), + ) + # 'not_force' -> '0' (Warning), is_set = True + cr.execute( + "UPDATE %s SET %s = '0', %s = True " "WHERE %s = 'not_force'", + (AsIs(table), AsIs(new_col), AsIs(flag_col), AsIs(old_col)), + ) + # 'use_parent' (or null) -> is_set = False + cr.execute( + "UPDATE %s SET %s = False " "WHERE %s = 'use_parent' OR %s IS NULL", + (AsIs(table), AsIs(flag_col), AsIs(old_col), AsIs(old_col)), + ) + + # Handle is_sale_own_*_set for value fields + for field in ["min_qty", "max_qty", "multiple_of_qty"]: + flag_col = f"is_sale_own_{field}_set" + + # Multiple-of was renamed from manual_sale_multiple_qty + if field == "multiple_of_qty": + old_val_col = "manual_sale_multiple_qty" + else: + old_val_col = f"manual_sale_{field}" + + if openupgrade.column_exists(cr, table, old_val_col): + if not openupgrade.column_exists(cr, table, flag_col): + cr.execute( + "ALTER TABLE %s ADD COLUMN %s boolean DEFAULT false", + (AsIs(table), AsIs(flag_col)), + ) + + # If a manual value was set (> 0), mark it as 'own value set' + cr.execute( + "UPDATE %s SET %s = True WHERE %s > 0", + (AsIs(table), AsIs(flag_col), AsIs(old_val_col)), + ) + + +@openupgrade.migrate() +def migrate(cr, version): + # 1. Rename simple value columns + openupgrade.rename_columns(cr, RENAME_MAP) + + # 2. Migrate selections and flags for each affected table + for table in ["product_category", "product_template", "product_product"]: + migrate_selection_and_flags(cr, table) diff --git a/sale_restricted_qty/models/__init__.py b/sale_restricted_qty/models/__init__.py new file mode 100644 index 00000000000..af78627a889 --- /dev/null +++ b/sale_restricted_qty/models/__init__.py @@ -0,0 +1,2 @@ +from . import product_restricted_qty_mixin # noqa +from . import product_category, product_product, product_template, sale diff --git a/sale_restricted_qty/models/product_category.py b/sale_restricted_qty/models/product_category.py new file mode 100644 index 00000000000..4a49a20321d --- /dev/null +++ b/sale_restricted_qty/models/product_category.py @@ -0,0 +1,12 @@ +# Copyright 2019 Akretion +# Copyright 2024 CorporateHub +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models + + +class ProductCategory(models.Model): + _name = "product.category" + _inherit = ["product.category", "sale.product.restricted.qty.mixin"] + + _sale_restricted_qty_parent_field = "parent_id" diff --git a/sale_restricted_qty/models/product_product.py b/sale_restricted_qty/models/product_product.py new file mode 100644 index 00000000000..6103b8c489c --- /dev/null +++ b/sale_restricted_qty/models/product_product.py @@ -0,0 +1,13 @@ +# Copyright 2019 Akretion +# @author Mourad EL HADJ MIMOUNE +# Copyright 2024 CorporateHub +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models + + +class ProductProduct(models.Model): + _name = "product.product" + _inherit = ["product.product", "sale.product.restricted.qty.mixin"] + + _sale_restricted_qty_parent_field = "product_tmpl_id" diff --git a/sale_restricted_qty/models/product_restricted_qty_mixin.py b/sale_restricted_qty/models/product_restricted_qty_mixin.py new file mode 100644 index 00000000000..38e2286a359 --- /dev/null +++ b/sale_restricted_qty/models/product_restricted_qty_mixin.py @@ -0,0 +1,710 @@ +# Copyright 2019 Akretion +# Copyright 2024 CorporateHub +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + +RESTRICTION_ENABLED = "1" +RESTRICTION_DISABLED = "0" +RESTRICTION_SELECTION = [ + (RESTRICTION_ENABLED, "Blocking"), + (RESTRICTION_DISABLED, "Warning"), +] + + +class ProductRestrictedQtyMixin(models.AbstractModel): + _name = "sale.product.restricted.qty.mixin" + _description = "Product Restricted Qty Mixin" + + _sale_restricted_qty_parent_field = None + + is_sale_own_min_qty_set = fields.Boolean() + is_sale_inherited_min_qty_set = fields.Boolean( + compute="_compute_is_sale_inherited_min_qty_set", + recursive=True, + ) + is_sale_min_qty_set = fields.Boolean( + compute="_compute_is_sale_min_qty_set", + recursive=True, + ) + sale_own_min_qty = fields.Float( + digits="Product Unit of Measure", + ) + sale_inherited_min_qty = fields.Float( + compute="_compute_sale_inherited_min_qty", + digits="Product Unit of Measure", + store=True, + recursive=True, + ) + sale_min_qty = fields.Float( + help="The minimum quantity of product that can be sold.", + compute="_compute_sale_min_qty", + inverse="_inverse_sale_min_qty", + store=True, + recursive=True, + digits="Product Unit of Measure", + ) + + is_sale_own_restrict_min_qty_set = fields.Boolean( + compute="_compute_is_sale_own_restrict_min_qty_set", + inverse="_inverse_is_sale_own_restrict_min_qty_set", + ) + is_sale_inherited_restrict_min_qty_set = fields.Boolean( + compute="_compute_is_sale_inherited_restrict_min_qty_set", + recursive=True, + ) + is_sale_restrict_min_qty_set = fields.Boolean( + compute="_compute_is_sale_restrict_min_qty_set", + recursive=True, + ) + sale_own_restrict_min_qty = fields.Selection( + selection=RESTRICTION_SELECTION, + ) + sale_inherited_restrict_min_qty = fields.Selection( + selection=RESTRICTION_SELECTION, + compute="_compute_sale_inherited_restrict_min_qty", + store=True, + recursive=True, + ) + sale_restrict_min_qty = fields.Selection( + help="Decide if the minimum quantity constraint is strictly enforced " + "(Blocking) or if it only triggers a warning (Warning).\n" + "Use 'Warning' if you want to allow exceptions like selling samples " + "or leftover stock.", + selection=RESTRICTION_SELECTION, + compute="_compute_sale_restrict_min_qty", + inverse="_inverse_sale_restrict_min_qty", + store=True, + recursive=True, + ) + + is_sale_own_max_qty_set = fields.Boolean() + is_sale_inherited_max_qty_set = fields.Boolean( + compute="_compute_is_sale_inherited_max_qty_set", + recursive=True, + ) + is_sale_max_qty_set = fields.Boolean( + compute="_compute_is_sale_max_qty_set", + recursive=True, + ) + sale_own_max_qty = fields.Float( + digits="Product Unit of Measure", + ) + sale_inherited_max_qty = fields.Float( + compute="_compute_sale_inherited_max_qty", + digits="Product Unit of Measure", + store=True, + recursive=True, + ) + sale_max_qty = fields.Float( + help="The maximum quantity of product that can be sold.", + compute="_compute_sale_max_qty", + inverse="_inverse_sale_max_qty", + store=True, + recursive=True, + digits="Product Unit of Measure", + ) + + is_sale_own_restrict_max_qty_set = fields.Boolean( + compute="_compute_is_sale_own_restrict_max_qty_set", + inverse="_inverse_is_sale_own_restrict_max_qty_set", + ) + is_sale_inherited_restrict_max_qty_set = fields.Boolean( + compute="_compute_is_sale_inherited_restrict_max_qty_set", + recursive=True, + ) + is_sale_restrict_max_qty_set = fields.Boolean( + compute="_compute_is_sale_restrict_max_qty_set", + recursive=True, + ) + sale_own_restrict_max_qty = fields.Selection( + selection=RESTRICTION_SELECTION, + ) + sale_inherited_restrict_max_qty = fields.Selection( + selection=RESTRICTION_SELECTION, + compute="_compute_sale_inherited_restrict_max_qty", + store=True, + recursive=True, + ) + sale_restrict_max_qty = fields.Selection( + help="Decide if the maximum quantity constraint is strictly enforced " + "(Blocking) or if it only triggers a warning (Warning).\n" + "Use 'Warning' if you want to allow large orders that exceed strict " + "policies under special conditions.", + selection=RESTRICTION_SELECTION, + compute="_compute_sale_restrict_max_qty", + inverse="_inverse_sale_restrict_max_qty", + store=True, + recursive=True, + ) + + is_sale_own_multiple_of_qty_set = fields.Boolean() + is_sale_inherited_multiple_of_qty_set = fields.Boolean( + compute="_compute_is_sale_inherited_multiple_of_qty_set", + recursive=True, + ) + is_sale_multiple_of_qty_set = fields.Boolean( + compute="_compute_is_sale_multiple_of_qty_set", + recursive=True, + ) + sale_own_multiple_of_qty = fields.Float( + digits="Product Unit of Measure", + ) + sale_inherited_multiple_of_qty = fields.Float( + compute="_compute_sale_inherited_multiple_of_qty", + digits="Product Unit of Measure", + store=True, + recursive=True, + ) + sale_multiple_of_qty = fields.Float( + help="The multiple-of quantity of product that can be sold.", + compute="_compute_sale_multiple_of_qty", + inverse="_inverse_sale_multiple_of_qty", + store=True, + recursive=True, + digits="Product Unit of Measure", + ) + + is_sale_own_restrict_multiple_of_qty_set = fields.Boolean( + compute="_compute_is_sale_own_restrict_multiple_of_qty_set", + inverse="_inverse_is_sale_own_restrict_multiple_of_qty_set", + ) + is_sale_inherited_restrict_multiple_of_qty_set = fields.Boolean( + compute="_compute_is_sale_inherited_restrict_multiple_of_qty_set", + recursive=True, + ) + is_sale_restrict_multiple_of_qty_set = fields.Boolean( + compute="_compute_is_sale_restrict_multiple_of_qty_set", + recursive=True, + ) + sale_own_restrict_multiple_of_qty = fields.Selection( + selection=RESTRICTION_SELECTION, + ) + sale_inherited_restrict_multiple_of_qty = fields.Selection( + selection=RESTRICTION_SELECTION, + compute="_compute_sale_inherited_restrict_multiple_of_qty", + store=True, + recursive=True, + ) + sale_restrict_multiple_of_qty = fields.Selection( + help="Decide if the multiple-of quantity constraint is strictly enforced " + "(Blocking) or if it only triggers a warning (Warning).\n" + "Use 'Warning' to allow selling non-standard quantities for special " + "cases like clearing leftover stock.", + selection=RESTRICTION_SELECTION, + compute="_compute_sale_restrict_multiple_of_qty", + inverse="_inverse_sale_restrict_multiple_of_qty", + store=True, + recursive=True, + ) + + # --- min_qty --- + + @api.onchange("is_sale_own_min_qty_set") + def _onchange_is_sale_min_qty_set(self): + if self.is_sale_own_min_qty_set: + self.sale_own_min_qty = self.sale_inherited_min_qty + else: + self.sale_own_min_qty = 0.0 + + def _get_is_sale_inherited_min_qty_set(self): + self.ensure_one() + parent_field = self._sale_restricted_qty_parent_field + if not parent_field or not self[parent_field]: + return False + return self[parent_field].is_sale_min_qty_set + + @api.depends( + lambda self: ( + [f"{self._sale_restricted_qty_parent_field}.is_sale_min_qty_set"] + if self._sale_restricted_qty_parent_field + else [] + ) + ) + def _compute_is_sale_inherited_min_qty_set(self): + for rec in self: + rec.is_sale_inherited_min_qty_set = rec._get_is_sale_inherited_min_qty_set() + + @api.depends("is_sale_own_min_qty_set", "is_sale_inherited_min_qty_set") + def _compute_is_sale_min_qty_set(self): + for rec in self: + rec.is_sale_min_qty_set = ( + rec.is_sale_own_min_qty_set or rec.is_sale_inherited_min_qty_set + ) + + def _get_sale_inherited_min_qty(self): + self.ensure_one() + parent_field = self._sale_restricted_qty_parent_field + if not parent_field or not self[parent_field]: + return 0.0 + return self[parent_field].sale_min_qty + + @api.depends( + lambda self: ( + [f"{self._sale_restricted_qty_parent_field}.sale_min_qty"] + if self._sale_restricted_qty_parent_field + else [] + ) + ) + def _compute_sale_inherited_min_qty(self): + for rec in self: + rec.sale_inherited_min_qty = rec._get_sale_inherited_min_qty() + + @api.depends( + "is_sale_own_min_qty_set", "sale_own_min_qty", "sale_inherited_min_qty" + ) + def _compute_sale_min_qty(self): + for rec in self: + if rec.is_sale_own_min_qty_set: + rec.sale_min_qty = rec.sale_own_min_qty + else: + rec.sale_min_qty = rec.sale_inherited_min_qty + + def _inverse_sale_min_qty(self): + for rec in self: + if rec.sale_min_qty and rec.sale_min_qty != rec.sale_inherited_min_qty: + rec.sale_own_min_qty = rec.sale_min_qty + rec.is_sale_own_min_qty_set = True + else: + rec.sale_own_min_qty = 0.0 + rec.is_sale_own_min_qty_set = False + + # --- restrict_min_qty --- + + @api.onchange("is_sale_own_restrict_min_qty_set") + def _onchange_is_sale_restrict_min_qty_set(self): + if self.is_sale_own_restrict_min_qty_set: + self.sale_own_restrict_min_qty = self.sale_inherited_restrict_min_qty + else: + self.sale_own_restrict_min_qty = False + + def _compute_is_sale_own_restrict_min_qty_set(self): + for rec in self: + rec.is_sale_own_restrict_min_qty_set = bool(rec.sale_own_restrict_min_qty) + + def _inverse_is_sale_own_restrict_min_qty_set(self): + if self.env.context.get("skip_sale_own_restrict_min_qty"): + return + for rec in self: + if rec.is_sale_own_restrict_min_qty_set: + rec.sale_own_restrict_min_qty = rec.sale_inherited_restrict_min_qty + else: + rec.sale_own_restrict_min_qty = None + + def _get_is_sale_inherited_restrict_min_qty_set(self): + self.ensure_one() + parent_field = self._sale_restricted_qty_parent_field + if not parent_field or not self[parent_field]: + return False + return self[parent_field].is_sale_restrict_min_qty_set + + @api.depends( + lambda self: ( + [ + f"{self._sale_restricted_qty_parent_field}" + f".is_sale_restrict_min_qty_set" + ] + if self._sale_restricted_qty_parent_field + else [] + ) + ) + def _compute_is_sale_inherited_restrict_min_qty_set(self): + for rec in self: + rec.is_sale_inherited_restrict_min_qty_set = ( + rec._get_is_sale_inherited_restrict_min_qty_set() + ) + + @api.depends( + "sale_own_restrict_min_qty", + "sale_inherited_restrict_min_qty", + ) + def _compute_is_sale_restrict_min_qty_set(self): + for rec in self: + rec.is_sale_restrict_min_qty_set = ( + rec.is_sale_own_restrict_min_qty_set + or rec.is_sale_inherited_restrict_min_qty_set + ) + + def _get_sale_inherited_restrict_min_qty(self): + self.ensure_one() + parent_field = self._sale_restricted_qty_parent_field + if not parent_field or not self[parent_field]: + return RESTRICTION_DISABLED + return self[parent_field].sale_restrict_min_qty + + @api.depends( + lambda self: ( + [f"{self._sale_restricted_qty_parent_field}.sale_restrict_min_qty"] + if self._sale_restricted_qty_parent_field + else [] + ) + ) + def _compute_sale_inherited_restrict_min_qty(self): + for rec in self: + rec.sale_inherited_restrict_min_qty = ( + rec._get_sale_inherited_restrict_min_qty() + ) + + @api.depends( + "sale_own_restrict_min_qty", + "sale_inherited_restrict_min_qty", + ) + def _compute_sale_restrict_min_qty(self): + for rec in self: + rec.sale_restrict_min_qty = ( + rec.sale_own_restrict_min_qty or rec.sale_inherited_restrict_min_qty + ) + + def _inverse_sale_restrict_min_qty(self): + for rec in self.with_context(skip_sale_own_restrict_min_qty=True): + rec.is_sale_own_restrict_min_qty_set = True + rec.sale_own_restrict_min_qty = rec.sale_restrict_min_qty + + # --- max_qty --- + + @api.onchange("is_sale_own_max_qty_set") + def _onchange_is_sale_max_qty_set(self): + if self.is_sale_own_max_qty_set: + self.sale_own_max_qty = self.sale_inherited_max_qty + else: + self.sale_own_max_qty = 0.0 + + def _get_is_sale_inherited_max_qty_set(self): + self.ensure_one() + parent_field = self._sale_restricted_qty_parent_field + if not parent_field or not self[parent_field]: + return False + return self[parent_field].is_sale_max_qty_set + + @api.depends( + lambda self: ( + [f"{self._sale_restricted_qty_parent_field}.is_sale_max_qty_set"] + if self._sale_restricted_qty_parent_field + else [] + ) + ) + def _compute_is_sale_inherited_max_qty_set(self): + for rec in self: + rec.is_sale_inherited_max_qty_set = rec._get_is_sale_inherited_max_qty_set() + + @api.depends("is_sale_own_max_qty_set", "is_sale_inherited_max_qty_set") + def _compute_is_sale_max_qty_set(self): + for rec in self: + rec.is_sale_max_qty_set = ( + rec.is_sale_own_max_qty_set or rec.is_sale_inherited_max_qty_set + ) + + def _get_sale_inherited_max_qty(self): + self.ensure_one() + parent_field = self._sale_restricted_qty_parent_field + if not parent_field or not self[parent_field]: + return 0.0 + return self[parent_field].sale_max_qty + + @api.depends( + lambda self: ( + [f"{self._sale_restricted_qty_parent_field}.sale_max_qty"] + if self._sale_restricted_qty_parent_field + else [] + ) + ) + def _compute_sale_inherited_max_qty(self): + for rec in self: + rec.sale_inherited_max_qty = rec._get_sale_inherited_max_qty() + + @api.depends( + "is_sale_own_max_qty_set", "sale_own_max_qty", "sale_inherited_max_qty" + ) + def _compute_sale_max_qty(self): + for rec in self: + if rec.is_sale_own_max_qty_set: + rec.sale_max_qty = rec.sale_own_max_qty + else: + rec.sale_max_qty = rec.sale_inherited_max_qty + + def _inverse_sale_max_qty(self): + for rec in self: + if rec.sale_max_qty and rec.sale_max_qty != rec.sale_inherited_max_qty: + rec.sale_own_max_qty = rec.sale_max_qty + rec.is_sale_own_max_qty_set = True + else: + rec.sale_own_max_qty = 0.0 + rec.is_sale_own_max_qty_set = False + + # --- restrict_max_qty --- + + @api.onchange("is_sale_own_restrict_max_qty_set") + def _onchange_is_sale_restrict_max_qty_set(self): + if self.is_sale_own_restrict_max_qty_set: + self.sale_own_restrict_max_qty = self.sale_inherited_restrict_max_qty + else: + self.sale_own_restrict_max_qty = False + + def _compute_is_sale_own_restrict_max_qty_set(self): + for rec in self: + rec.is_sale_own_restrict_max_qty_set = bool(rec.sale_own_restrict_max_qty) + + def _inverse_is_sale_own_restrict_max_qty_set(self): + if self.env.context.get("skip_sale_own_restrict_max_qty"): + return + for rec in self: + if rec.is_sale_own_restrict_max_qty_set: + rec.sale_own_restrict_max_qty = rec.sale_inherited_restrict_max_qty + else: + rec.sale_own_restrict_max_qty = None + + def _get_is_sale_inherited_restrict_max_qty_set(self): + self.ensure_one() + parent_field = self._sale_restricted_qty_parent_field + if not parent_field or not self[parent_field]: + return False + return self[parent_field].is_sale_restrict_max_qty_set + + @api.depends( + lambda self: ( + [ + f"{self._sale_restricted_qty_parent_field}" + f".is_sale_restrict_max_qty_set" + ] + if self._sale_restricted_qty_parent_field + else [] + ) + ) + def _compute_is_sale_inherited_restrict_max_qty_set(self): + for rec in self: + rec.is_sale_inherited_restrict_max_qty_set = ( + rec._get_is_sale_inherited_restrict_max_qty_set() + ) + + @api.depends( + "sale_own_restrict_max_qty", + "sale_inherited_restrict_max_qty", + ) + def _compute_is_sale_restrict_max_qty_set(self): + for rec in self: + rec.is_sale_restrict_max_qty_set = ( + rec.is_sale_own_restrict_max_qty_set + or rec.is_sale_inherited_restrict_max_qty_set + ) + + def _get_sale_inherited_restrict_max_qty(self): + self.ensure_one() + parent_field = self._sale_restricted_qty_parent_field + if not parent_field or not self[parent_field]: + return RESTRICTION_DISABLED + return self[parent_field].sale_restrict_max_qty + + @api.depends( + lambda self: ( + [f"{self._sale_restricted_qty_parent_field}.sale_restrict_max_qty"] + if self._sale_restricted_qty_parent_field + else [] + ) + ) + def _compute_sale_inherited_restrict_max_qty(self): + for rec in self: + rec.sale_inherited_restrict_max_qty = ( + rec._get_sale_inherited_restrict_max_qty() + ) + + @api.depends( + "sale_own_restrict_max_qty", + "sale_inherited_restrict_max_qty", + ) + def _compute_sale_restrict_max_qty(self): + for rec in self: + rec.sale_restrict_max_qty = ( + rec.sale_own_restrict_max_qty or rec.sale_inherited_restrict_max_qty + ) + + def _inverse_sale_restrict_max_qty(self): + for rec in self.with_context(skip_sale_own_restrict_max_qty=True): + rec.is_sale_own_restrict_max_qty_set = True + rec.sale_own_restrict_max_qty = rec.sale_restrict_max_qty + + # --- multiple_of_qty --- + + @api.onchange("is_sale_own_multiple_of_qty_set") + def _onchange_is_sale_multiple_of_qty_set(self): + if self.is_sale_own_multiple_of_qty_set: + self.sale_own_multiple_of_qty = self.sale_inherited_multiple_of_qty + else: + self.sale_own_multiple_of_qty = 0.0 + + def _get_is_sale_inherited_multiple_of_qty_set(self): + self.ensure_one() + parent_field = self._sale_restricted_qty_parent_field + if not parent_field or not self[parent_field]: + return False + return self[parent_field].is_sale_multiple_of_qty_set + + @api.depends( + lambda self: ( + [ + f"{self._sale_restricted_qty_parent_field}" + f".is_sale_multiple_of_qty_set" + ] + if self._sale_restricted_qty_parent_field + else [] + ) + ) + def _compute_is_sale_inherited_multiple_of_qty_set(self): + for rec in self: + rec.is_sale_inherited_multiple_of_qty_set = ( + rec._get_is_sale_inherited_multiple_of_qty_set() + ) + + @api.depends( + "is_sale_own_multiple_of_qty_set", "is_sale_inherited_multiple_of_qty_set" + ) + def _compute_is_sale_multiple_of_qty_set(self): + for rec in self: + rec.is_sale_multiple_of_qty_set = ( + rec.is_sale_own_multiple_of_qty_set + or rec.is_sale_inherited_multiple_of_qty_set + ) + + def _get_sale_inherited_multiple_of_qty(self): + self.ensure_one() + parent_field = self._sale_restricted_qty_parent_field + if not parent_field or not self[parent_field]: + return 0.0 + return self[parent_field].sale_multiple_of_qty + + @api.depends( + lambda self: ( + [f"{self._sale_restricted_qty_parent_field}.sale_multiple_of_qty"] + if self._sale_restricted_qty_parent_field + else [] + ) + ) + def _compute_sale_inherited_multiple_of_qty(self): + for rec in self: + rec.sale_inherited_multiple_of_qty = ( + rec._get_sale_inherited_multiple_of_qty() + ) + + @api.depends( + "is_sale_own_multiple_of_qty_set", + "sale_own_multiple_of_qty", + "sale_inherited_multiple_of_qty", + ) + def _compute_sale_multiple_of_qty(self): + for rec in self: + if rec.is_sale_own_multiple_of_qty_set: + rec.sale_multiple_of_qty = rec.sale_own_multiple_of_qty + else: + rec.sale_multiple_of_qty = rec.sale_inherited_multiple_of_qty + + def _inverse_sale_multiple_of_qty(self): + for rec in self: + if ( + rec.sale_multiple_of_qty + and rec.sale_multiple_of_qty != rec.sale_inherited_multiple_of_qty + ): + rec.sale_own_multiple_of_qty = rec.sale_multiple_of_qty + rec.is_sale_own_multiple_of_qty_set = True + else: + rec.sale_own_multiple_of_qty = 0.0 + rec.is_sale_own_multiple_of_qty_set = False + + # --- restrict_multiple_of_qty --- + + @api.onchange("is_sale_own_restrict_multiple_of_qty_set") + def _onchange_is_sale_restrict_multiple_of_qty_set(self): + if self.is_sale_own_restrict_multiple_of_qty_set: + self.sale_own_restrict_multiple_of_qty = ( + self.sale_inherited_restrict_multiple_of_qty + ) + else: + self.sale_own_restrict_multiple_of_qty = None + + def _compute_is_sale_own_restrict_multiple_of_qty_set(self): + for rec in self: + rec.is_sale_own_restrict_multiple_of_qty_set = bool( + rec.sale_own_restrict_multiple_of_qty + ) + + def _inverse_is_sale_own_restrict_multiple_of_qty_set(self): + if self.env.context.get("skip_sale_own_restrict_multiple_of_qty"): + return + for rec in self: + if rec.is_sale_own_restrict_multiple_of_qty_set: + rec.sale_own_restrict_multiple_of_qty = ( + rec.sale_inherited_restrict_multiple_of_qty + ) + else: + rec.sale_own_restrict_multiple_of_qty = None + + def _get_is_sale_inherited_restrict_multiple_of_qty_set(self): + self.ensure_one() + parent_field = self._sale_restricted_qty_parent_field + if not parent_field or not self[parent_field]: + return False + return self[parent_field].is_sale_restrict_multiple_of_qty_set + + @api.depends( + lambda self: ( + [ + f"{self._sale_restricted_qty_parent_field}" + f".is_sale_restrict_multiple_of_qty_set" + ] + if self._sale_restricted_qty_parent_field + else [] + ) + ) + def _compute_is_sale_inherited_restrict_multiple_of_qty_set(self): + for rec in self: + rec.is_sale_inherited_restrict_multiple_of_qty_set = ( + rec._get_is_sale_inherited_restrict_multiple_of_qty_set() + ) + + @api.depends( + "sale_own_restrict_multiple_of_qty", + "sale_inherited_restrict_multiple_of_qty", + ) + def _compute_is_sale_restrict_multiple_of_qty_set(self): + for rec in self: + rec.is_sale_restrict_multiple_of_qty_set = ( + rec.is_sale_own_restrict_multiple_of_qty_set + or rec.is_sale_inherited_restrict_multiple_of_qty_set + ) + + def _get_sale_inherited_restrict_multiple_of_qty(self): + self.ensure_one() + parent_field = self._sale_restricted_qty_parent_field + if not parent_field or not self[parent_field]: + return RESTRICTION_DISABLED + return self[parent_field].sale_restrict_multiple_of_qty + + @api.depends( + lambda self: ( + [ + f"{self._sale_restricted_qty_parent_field}" + f".sale_restrict_multiple_of_qty" + ] + if self._sale_restricted_qty_parent_field + else [] + ) + ) + def _compute_sale_inherited_restrict_multiple_of_qty(self): + for rec in self: + rec.sale_inherited_restrict_multiple_of_qty = ( + rec._get_sale_inherited_restrict_multiple_of_qty() + ) + + @api.depends( + "sale_own_restrict_multiple_of_qty", + "sale_inherited_restrict_multiple_of_qty", + ) + def _compute_sale_restrict_multiple_of_qty(self): + for rec in self: + rec.sale_restrict_multiple_of_qty = ( + rec.sale_own_restrict_multiple_of_qty + or rec.sale_inherited_restrict_multiple_of_qty + ) + + def _inverse_sale_restrict_multiple_of_qty(self): + for rec in self.with_context(skip_sale_own_restrict_multiple_of_qty=True): + rec.is_sale_own_restrict_multiple_of_qty_set = True + rec.sale_own_restrict_multiple_of_qty = rec.sale_restrict_multiple_of_qty diff --git a/sale_restricted_qty/models/product_template.py b/sale_restricted_qty/models/product_template.py new file mode 100644 index 00000000000..e0f2a88fea7 --- /dev/null +++ b/sale_restricted_qty/models/product_template.py @@ -0,0 +1,13 @@ +# Copyright 2019 Akretion +# @author Mourad EL HADJ MIMOUNE +# Copyright 2024 CorporateHub +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models + + +class ProductTemplate(models.Model): + _name = "product.template" + _inherit = ["product.template", "sale.product.restricted.qty.mixin"] + + _sale_restricted_qty_parent_field = "categ_id" diff --git a/sale_restricted_qty/models/sale.py b/sale_restricted_qty/models/sale.py new file mode 100644 index 00000000000..5227a8720cc --- /dev/null +++ b/sale_restricted_qty/models/sale.py @@ -0,0 +1,217 @@ +# Copyright 2019 Akretion +# Copyright 2023 Simone Rubino - Aion Tech +# Copyright 2024 CorporateHub +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools import float_is_zero + +from .product_restricted_qty_mixin import RESTRICTION_ENABLED + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + is_min_qty_set = fields.Boolean( + compute="_compute_restricted_qty_from_product", + store=True, + ) + min_qty = fields.Float( + help="The minimum quantity of product that can be sold.", + compute="_compute_restricted_qty_from_product", + store=True, + digits="Product Unit of Measure", + ) + restrict_min_qty = fields.Boolean( + compute="_compute_restricted_qty_from_product", + store=True, + ) + is_below_min_qty = fields.Boolean( + compute="_compute_restricted_qty_constraints", + ) + + is_max_qty_set = fields.Boolean( + compute="_compute_restricted_qty_from_product", + store=True, + ) + max_qty = fields.Float( + help="The maximum quantity of product that can be sold.", + compute="_compute_restricted_qty_from_product", + store=True, + digits="Product Unit of Measure", + ) + restrict_max_qty = fields.Boolean( + compute="_compute_restricted_qty_from_product", + store=True, + ) + is_above_max_qty = fields.Boolean( + compute="_compute_restricted_qty_constraints", + ) + + is_multiple_of_qty_set = fields.Boolean( + compute="_compute_restricted_qty_from_product", + store=True, + ) + multiple_of_qty = fields.Float( + string="Multiple-Of Qty", + help="The multiple-of quantity of product that can be sold.", + compute="_compute_restricted_qty_from_product", + store=True, + digits="Product Unit of Measure", + ) + restrict_multiple_of_qty = fields.Boolean( + compute="_compute_restricted_qty_from_product", + store=True, + ) + is_not_multiple_of_qty = fields.Boolean( + compute="_compute_restricted_qty_constraints", + ) + + @api.depends( + "product_id.is_sale_min_qty_set", + "product_id.sale_min_qty", + "product_id.sale_restrict_min_qty", + "product_id.is_sale_max_qty_set", + "product_id.sale_max_qty", + "product_id.sale_restrict_max_qty", + "product_id.is_sale_multiple_of_qty_set", + "product_id.sale_multiple_of_qty", + "product_id.sale_restrict_multiple_of_qty", + ) + def _compute_restricted_qty_from_product(self): + for line in self: + line.is_min_qty_set = line.product_id.is_sale_min_qty_set + line.min_qty = line.product_id.sale_min_qty + line.restrict_min_qty = ( + line.product_id.sale_restrict_min_qty == RESTRICTION_ENABLED + ) + + line.is_max_qty_set = line.product_id.is_sale_max_qty_set + line.max_qty = line.product_id.sale_max_qty + line.restrict_max_qty = ( + line.product_id.sale_restrict_max_qty == RESTRICTION_ENABLED + ) + + line.is_multiple_of_qty_set = line.product_id.is_sale_multiple_of_qty_set + line.multiple_of_qty = line.product_id.sale_multiple_of_qty + line.restrict_multiple_of_qty = ( + line.product_id.sale_restrict_multiple_of_qty == RESTRICTION_ENABLED + ) + + @api.depends( + "product_id.uom_id", + "product_uom", + "product_uom_qty", + "is_min_qty_set", + "min_qty", + "is_max_qty_set", + "max_qty", + "is_multiple_of_qty_set", + "multiple_of_qty", + ) + def _compute_restricted_qty_constraints(self): + for line in self: + qty = line.product_uom._compute_quantity( + line.product_uom_qty, line.product_id.uom_id + ) + line.is_below_min_qty = line.is_min_qty_set and qty < line.min_qty + line.is_above_max_qty = line.is_max_qty_set and qty > line.max_qty + rounding = line.product_id.uom_id.rounding + line.is_not_multiple_of_qty = line.is_multiple_of_qty_set and ( + line.multiple_of_qty != 0 + and not float_is_zero( + qty % line.multiple_of_qty, precision_rounding=rounding + ) + ) + + @api.constrains( + "product_id", + "product_uom", + "product_uom_qty", + "is_min_qty_set", + "min_qty", + "restrict_min_qty", + "is_max_qty_set", + "max_qty", + "restrict_max_qty", + "is_multiple_of_qty_set", + "multiple_of_qty", + "restrict_multiple_of_qty", + ) + def check_restricted_qty(self): + failed_lines = [] + for line in self: + if line.state not in ("draft", "sent"): + continue + + qty = line.product_uom._compute_quantity( + line.product_uom_qty, line.product_id.uom_id + ) + + failed_constraints = [] + + if line.is_min_qty_set and line.restrict_min_qty and qty < line.min_qty: + failed_constraints.append( + _("minimal quantity is %(min_qty)s") + % { + "min_qty": line.min_qty, + } + ) + + if line.is_max_qty_set and line.restrict_max_qty and qty > line.max_qty: + failed_constraints.append( + _("maximal quantity is %(max_qty)s") + % { + "max_qty": line.max_qty, + } + ) + + rounding = line.product_id.uom_id.rounding + if ( + line.is_multiple_of_qty_set + and line.restrict_multiple_of_qty + and line.multiple_of_qty != 0 + and not float_is_zero( + qty % line.multiple_of_qty, precision_rounding=rounding + ) + ): + failed_constraints.append( + _("quantity should be multiple of %(multiple_of_qty)s") + % { + "multiple_of_qty": line.multiple_of_qty, + } + ) + + if failed_constraints: + failed_lines.append( + _( + '%(order_name)s - Product "%(product_name)s": ' + "%(failed_constraints)s" + ) + % { + "order_name": line.order_id.name, + "product_name": line.product_id.name, + "failed_constraints": ", ".join(failed_constraints), + } + ) + + if failed_lines: + msg = _("Check quantity for these products:\n") + "\n".join(failed_lines) + raise ValidationError(msg) + + @api.onchange("product_id") + def _onchange_product_id_set_min_qty(self): + """Set default quantity to minimum quantity when enforced.""" + # Only auto-populate if product is set and quantity is not + # meaningfully set by user + # We auto-populate when quantity is 0 or when it's the default value of 1.0 + # but only if it hasn't been explicitly set by the user to a different value + if ( + self.product_id + and self.is_min_qty_set + and self.restrict_min_qty + and (not self.product_uom_qty or self.product_uom_qty in (0.0, 1.0)) + ): + self.product_uom_qty = self.min_qty diff --git a/sale_restricted_qty/pyproject.toml b/sale_restricted_qty/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/sale_restricted_qty/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/sale_restricted_qty/readme/CONTRIBUTORS.md b/sale_restricted_qty/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..0fdd1212850 --- /dev/null +++ b/sale_restricted_qty/readme/CONTRIBUTORS.md @@ -0,0 +1,7 @@ +- Mourad EL HADJ MIMOUN \<\> +- [Ooops](https://www.ooops404.com/): + - Ashish Hirpara \<\> +- [Aion Tech](https://aiontech.company/): + - Simone Rubino \<\> +- [CorporateHub](https://corporatehub.eu/) + - Alexey Pelykh \<\> diff --git a/sale_restricted_qty/readme/DESCRIPTION.md b/sale_restricted_qty/readme/DESCRIPTION.md new file mode 100644 index 00000000000..c50580818a9 --- /dev/null +++ b/sale_restricted_qty/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +This module allows to set mininal, maximal, and multiple-of quantity constraints on product categories and products, and +to check and optionally enforce these constraints on sale orders (either as strict blocking or soft warnings). diff --git a/sale_restricted_qty/readme/USAGE.md b/sale_restricted_qty/readme/USAGE.md new file mode 100644 index 00000000000..807efb9abb3 --- /dev/null +++ b/sale_restricted_qty/readme/USAGE.md @@ -0,0 +1,27 @@ +To set quantity constraints on a product: navigate to **Sales \> Products \> Products**, open the product, and on the +**Sales** tab in the **Qty Constraints** section set corresponding values in the *Min Qty*, *Max Qty*, or +*Multiple-Of Qty* fields. + +**Constraint Types:** +* **Min Qty**: Minimum quantity required for a sale. +* **Max Qty**: Maximum quantity allowed for a sale. +* **Multiple-Of Qty**: Quantity must be a multiple of this value. + +**Enforcement Levels (Restrict):** +For each constraint, you can choose the enforcement level: +* **Blocking**: Strictly enforces the rule. The user cannot confirm the line with an invalid quantity. +* **Warning**: Displays a warning (yellow/orange indication) but allows the user to proceed. + * *Use Case*: Use **Warning** when you want to allow flexibility, such as selling **samples** (below min qty) or clearing out **leftover stock** (remainder not matching multiple-of qty). + +**Auto-Suggest:** +When you select a product in a Sales Order line, if a Minimum Quantity is strictly enforced (**Blocking**) and the current quantity is not set (or is 0/1), the system will automatically populate the quantity with the Minimum Quantity. + +To set quantity constraints on a product variant: navigate to **Sales \> Products \> Product Variants**, open the +product variant, and on the **Sales** tab in the **Qty Constraints** section set corresponding values. + +To set quantity constraints on a product category: navigate to **Sales \> Configuration \> Product Categories**, open +the product category, and in the **Sales Qty Constraints** section set corresponding values. + +The settings are inherited from the product category to the product, and from the product to the product variant. +To override the inherited settings, check the checkbox next to the corresponding value and set the value in the product +or product variant. diff --git a/sale_restricted_qty/static/description/icon.png b/sale_restricted_qty/static/description/icon.png new file mode 100644 index 00000000000..1dcc49c24f3 Binary files /dev/null and b/sale_restricted_qty/static/description/icon.png differ diff --git a/sale_restricted_qty/static/description/icon.svg b/sale_restricted_qty/static/description/icon.svg new file mode 100644 index 00000000000..ed6aaa04e42 --- /dev/null +++ b/sale_restricted_qty/static/description/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/sale_restricted_qty/static/description/index.html b/sale_restricted_qty/static/description/index.html new file mode 100644 index 00000000000..ffe1c3eb464 --- /dev/null +++ b/sale_restricted_qty/static/description/index.html @@ -0,0 +1,482 @@ + + + + + +Sale order restricted quantity: min, max, multiple-of + + + +
+

Sale order restricted quantity: min, max, multiple-of

+ + +

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

+

This module allows to set mininal, maximal, and multiple-of quantity +constraints on product categories and products, and to check and +optionally enforce these constraints on sale orders (either as strict +blocking or soft warnings).

+

Table of contents

+ +
+

Usage

+

To set quantity constraints on a product: navigate to Sales > Products +> Products, open the product, and on the Sales tab in the Qty +Constraints section set corresponding values in the Min Qty, Max +Qty, or Multiple-Of Qty fields.

+

Constraint Types:

+
    +
  • Min Qty: Minimum quantity required for a sale.
  • +
  • Max Qty: Maximum quantity allowed for a sale.
  • +
  • Multiple-Of Qty: Quantity must be a multiple of this value.
  • +
+

Enforcement Levels (Restrict): For each constraint, you can choose +the enforcement level:

+
    +
  • Blocking: Strictly enforces the rule. The user cannot confirm the +line with an invalid quantity.
  • +
  • Warning: Displays a warning (yellow/orange indication) but allows +the user to proceed.
      +
    • Use Case: Use Warning when you want to allow flexibility, such +as selling samples (below min qty) or clearing out leftover +stock (remainder not matching multiple-of qty).
    • +
    +
  • +
+

Auto-Suggest: When you select a product in a Sales Order line, if a +Minimum Quantity is strictly enforced (Blocking) and the current +quantity is not set (or is 0/1), the system will automatically populate +the quantity with the Minimum Quantity.

+

To set quantity constraints on a product variant: navigate to Sales > +Products > Product Variants, open the product variant, and on the +Sales tab in the Qty Constraints section set corresponding +values.

+

To set quantity constraints on a product category: navigate to Sales > +Configuration > Product Categories, open the product category, and in +the Sales Qty Constraints section set corresponding values.

+

The settings are inherited from the product category to the product, and +from the product to the product variant. To override the inherited +settings, check the checkbox next to the corresponding value and set the +value in the product or product variant.

+
+
+

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

+
    +
  • Akretion
  • +
+
+
+

Contributors

+ +
+
+

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.

+

Current maintainer:

+

ashishhirapara

+

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_restricted_qty/tests/__init__.py b/sale_restricted_qty/tests/__init__.py new file mode 100644 index 00000000000..b72f08549d4 --- /dev/null +++ b/sale_restricted_qty/tests/__init__.py @@ -0,0 +1,7 @@ +from . import ( + test_product_category, + test_product_product, + test_product_template, + test_sale_order_line, + test_coverage_deep, +) diff --git a/sale_restricted_qty/tests/test_coverage_deep.py b/sale_restricted_qty/tests/test_coverage_deep.py new file mode 100644 index 00000000000..9ff55dc72ea --- /dev/null +++ b/sale_restricted_qty/tests/test_coverage_deep.py @@ -0,0 +1,181 @@ +# Copyright 2024 CorporateHub +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.tests import common, tagged + + +@tagged("post_install", "-at_install") +class TestCoverageDeep(common.TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.ProductCategory = cls.env["product.category"] + cls.ProductTemplate = cls.env["product.template"] + cls.Product = cls.env["product.product"] + + def test_exhaustive_mixin_paths(self): + """Hit all branches in the mixin using a template.""" + template = self.ProductTemplate.create({"name": "Test Template"}) + + for field_prefix in ["min_qty", "max_qty", "multiple_of_qty"]: + # 1. Test Value logic + val_field = f"sale_{field_prefix}" + own_val_field = f"sale_own_{field_prefix}" + own_set_field = f"is_sale_own_{field_prefix}_set" + onchange_val = f"_onchange_is_sale_{field_prefix}_set" + + # Inverse: Set value + setattr(template, val_field, 10.0) + self.assertTrue(getattr(template, own_set_field)) + self.assertEqual(getattr(template, own_val_field), 10.0) + + # Inverse: Reset to 0 (or inherited) -> unsets + setattr(template, val_field, 0.0) + self.assertFalse(getattr(template, own_set_field)) + self.assertEqual(getattr(template, own_val_field), 0.0) + + # Onchange: Set + setattr(template, own_set_field, True) + getattr(template, onchange_val)() + # Onchange: Unset + setattr(template, own_set_field, False) + getattr(template, onchange_val)() + + # 2. Test Restrict logic + restrict_field = f"sale_restrict_{field_prefix}" + own_restrict_field = f"sale_own_restrict_{field_prefix}" + own_restrict_set_field = f"is_sale_own_restrict_{field_prefix}_set" + onchange_restrict = f"_onchange_is_sale_restrict_{field_prefix}_set" + inverse_restrict_set = f"_inverse_is_sale_own_restrict_{field_prefix}_set" + + # Inverse: Set restriction + setattr(template, restrict_field, "1") + self.assertTrue(getattr(template, own_restrict_set_field)) + self.assertEqual(getattr(template, own_restrict_field), "1") + + # Test the boolean flag inverse explicitly + setattr(template, own_restrict_set_field, True) + getattr(template, inverse_restrict_set)() + + setattr(template, own_restrict_set_field, False) + getattr(template, inverse_restrict_set)() + + # Onchange: Set + setattr(template, own_restrict_set_field, True) + getattr(template, onchange_restrict)() + # Onchange: Unset + setattr(template, own_restrict_set_field, False) + getattr(template, onchange_restrict)() + + def test_model_overrides_coverage(self): + """Hit the 12 compute methods in each model by changing hierarchy.""" + # 1. Category hierarchy + parent = self.ProductCategory.create({"name": "Parent"}) + child = self.ProductCategory.create({"name": "Child", "parent_id": parent.id}) + + # Trigger all 12 computes on child by modifying parent + parent.write( + { + "sale_min_qty": 1.0, + "sale_restrict_min_qty": "1", + "sale_max_qty": 2.0, + "sale_restrict_max_qty": "1", + "sale_multiple_of_qty": 3.0, + "sale_restrict_multiple_of_qty": "1", + } + ) + self.assertEqual(child.sale_min_qty, 1.0) + self.assertEqual(child.sale_max_qty, 2.0) + self.assertEqual(child.sale_multiple_of_qty, 3.0) + + # Test the "not parent_id" branch for all types (hits super()) + for pf in ["min", "max", "multiple_of"]: + self.assertFalse(getattr(parent, f"is_sale_inherited_{pf}_qty_set")) + self.assertEqual(getattr(parent, f"sale_inherited_{pf}_qty"), 0.0) + + # 2. Template / Product variants + template = self.ProductTemplate.create( + { + "name": "Template", + "categ_id": child.id, + } + ) + product = template.product_variant_id + + # Trigger computes by changing template + template.write({"sale_min_qty": 5.0}) + self.assertEqual(product.sale_min_qty, 5.0) + + # Clear parent values to stop inheritance (all fields) + parent.write( + { + "sale_min_qty": 0.0, + "sale_restrict_min_qty": "0", + "sale_max_qty": 0.0, + "sale_restrict_max_qty": "0", + "sale_multiple_of_qty": 0.0, + "sale_restrict_multiple_of_qty": "0", + } + ) + # Also clear the template's own value, otherwise product + # still inherits from template + template.write({"sale_min_qty": 0.0}) + template.invalidate_recordset() + + # Test "no parent" cases for Template and Product for all types + for pf in ["min", "max", "multiple_of"]: + self.assertFalse(getattr(template, f"is_sale_inherited_{pf}_qty_set")) + self.assertFalse(getattr(product, f"is_sale_inherited_{pf}_qty_set")) + + # 3. Test edge case: no product_tmpl_id + # (should not normally happen, but for coverage) + product.product_tmpl_id = False + for pf in ["min", "max", "multiple_of"]: + self.assertFalse(getattr(product, f"is_sale_inherited_{pf}_qty_set")) + self.assertEqual(getattr(product, f"sale_inherited_{pf}_qty"), 0.0) + + def test_sale_order_line_onchanges_deep(self): + """Cover all branches of SO line onchanges.""" + product = self.Product.create( + { + "name": "Product", + "sale_min_qty": 10.0, + "sale_restrict_min_qty": "1", + } + ) + line = self.env["sale.order.line"].new({"product_id": product.id}) + + # Hits the "1.0" branch + line.product_uom_qty = 1.0 + line._onchange_product_id_set_min_qty() + self.assertEqual(line.product_uom_qty, 10.0) + + # Hits the "0.0" branch + line.product_uom_qty = 0.0 + line._onchange_product_id_set_min_qty() + self.assertEqual(line.product_uom_qty, 10.0) + + # Hits the "already set" branch (no overwrite) + line.product_uom_qty = 5.0 + line._onchange_product_id_set_min_qty() + self.assertEqual(line.product_uom_qty, 5.0) + + # Hits the "not enforced" branch + product.sale_restrict_min_qty = "0" + + # New line to pick up the change + line2 = self.env["sale.order.line"].new({"product_id": product.id}) + # Force recompute of line fields from product + line2._compute_restricted_qty_from_product() + + line2.product_uom_qty = 1.0 + line2._onchange_product_id_set_min_qty() + self.assertEqual(line2.product_uom_qty, 1.0) + + def test_onchange_no_product(self): + """Test onchange with no product set (coverage edge case).""" + # Initialize with 0.0 to ensure it doesn't default to 1.0 (Odoo default) + line = self.env["sale.order.line"].new({"product_uom_qty": 0.0}) + line._onchange_product_id_set_min_qty() + # Should not crash and do nothing + self.assertFalse(line.product_uom_qty) diff --git a/sale_restricted_qty/tests/test_product_category.py b/sale_restricted_qty/tests/test_product_category.py new file mode 100644 index 00000000000..3c178b7f834 --- /dev/null +++ b/sale_restricted_qty/tests/test_product_category.py @@ -0,0 +1,126 @@ +# Copyright 2024 CorporateHub +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import odoo.tests.common as common +from odoo.tests import tagged + + +@tagged("post_install", "-at_install") +class TestProductCategory(common.TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.ProductCategory = cls.env["product.category"] + + def test_inheritance(self): + parent = self.ProductCategory.create( + { + "name": "Parent", + } + ) + self.assertEqual(parent.is_sale_min_qty_set, False) + self.assertEqual(parent.sale_min_qty, 0.0) + self.assertEqual(parent.is_sale_restrict_min_qty_set, False) + self.assertEqual(parent.sale_restrict_min_qty, "0") + self.assertEqual(parent.is_sale_max_qty_set, False) + self.assertEqual(parent.sale_max_qty, 0.0) + self.assertEqual(parent.is_sale_restrict_max_qty_set, False) + self.assertEqual(parent.sale_restrict_max_qty, "0") + self.assertEqual(parent.is_sale_multiple_of_qty_set, False) + self.assertEqual(parent.sale_multiple_of_qty, 0.0) + self.assertEqual(parent.is_sale_restrict_multiple_of_qty_set, False) + self.assertEqual(parent.sale_restrict_multiple_of_qty, "0") + + child = self.ProductCategory.create( + { + "name": "Child", + "parent_id": parent.id, + } + ) + self.assertEqual(child.sale_min_qty, 0.0) + self.assertEqual(child.sale_restrict_min_qty, "0") + self.assertEqual(child.sale_max_qty, 0.0) + self.assertEqual(child.sale_restrict_max_qty, "0") + self.assertEqual(child.sale_multiple_of_qty, 0.0) + self.assertEqual(child.sale_restrict_multiple_of_qty, "0") + + parent.update( + { + "sale_min_qty": 10.0, + "sale_restrict_min_qty": "1", + "sale_max_qty": 100.0, + "sale_restrict_max_qty": "1", + "sale_multiple_of_qty": 5.0, + "sale_restrict_multiple_of_qty": "1", + } + ) + self.assertTrue(child.is_sale_min_qty_set) + self.assertTrue(child.is_sale_restrict_min_qty_set) + self.assertTrue(child.is_sale_max_qty_set) + self.assertTrue(child.is_sale_restrict_max_qty_set) + self.assertTrue(child.is_sale_multiple_of_qty_set) + self.assertTrue(child.is_sale_restrict_multiple_of_qty_set) + self.assertFalse(child.is_sale_own_min_qty_set) + self.assertFalse(child.is_sale_own_restrict_min_qty_set) + self.assertFalse(child.is_sale_own_max_qty_set) + self.assertFalse(child.is_sale_own_restrict_max_qty_set) + self.assertFalse(child.is_sale_own_multiple_of_qty_set) + self.assertFalse(child.is_sale_own_restrict_multiple_of_qty_set) + self.assertEqual(child.sale_min_qty, 10.0) + self.assertEqual(child.sale_restrict_min_qty, "1") + self.assertEqual(child.sale_max_qty, 100.0) + self.assertEqual(child.sale_restrict_max_qty, "1") + self.assertEqual(child.sale_multiple_of_qty, 5.0) + self.assertEqual(child.sale_restrict_multiple_of_qty, "1") + + child.sale_min_qty = 20.0 + self.assertTrue(child.is_sale_own_min_qty_set) + self.assertEqual(child.sale_own_min_qty, 20.0) + + child.sale_restrict_min_qty = "0" + self.assertTrue(child.is_sale_own_restrict_min_qty_set) + self.assertEqual(child.sale_own_restrict_min_qty, "0") + + child.sale_max_qty = 200.0 + self.assertTrue(child.is_sale_own_max_qty_set) + self.assertEqual(child.sale_own_max_qty, 200.0) + + child.sale_restrict_max_qty = "0" + self.assertTrue(child.is_sale_own_restrict_max_qty_set) + self.assertEqual(child.sale_own_restrict_max_qty, "0") + + child.sale_multiple_of_qty = 10.0 + self.assertTrue(child.is_sale_own_multiple_of_qty_set) + self.assertEqual(child.sale_own_multiple_of_qty, 10.0) + + child.sale_restrict_multiple_of_qty = "0" + self.assertTrue(child.is_sale_own_restrict_multiple_of_qty_set) + self.assertEqual(child.sale_own_restrict_multiple_of_qty, "0") + + child.is_sale_own_min_qty_set = False + child._onchange_is_sale_min_qty_set() + self.assertEqual(child.sale_min_qty, 10.0) + self.assertEqual(child.sale_own_min_qty, 0.0) + + child.is_sale_own_restrict_min_qty_set = False + self.assertEqual(child.sale_restrict_min_qty, "1") + self.assertFalse(child.sale_own_restrict_min_qty) + + child.is_sale_own_max_qty_set = False + child._onchange_is_sale_max_qty_set() + self.assertEqual(child.sale_max_qty, 100.0) + self.assertEqual(child.sale_own_max_qty, 0.0) + + child.is_sale_own_restrict_max_qty_set = False + self.assertEqual(child.sale_restrict_max_qty, "1") + self.assertFalse(child.sale_own_restrict_max_qty) + + child.is_sale_own_multiple_of_qty_set = False + child._onchange_is_sale_multiple_of_qty_set() + self.assertEqual(child.sale_multiple_of_qty, 5.0) + self.assertEqual(child.sale_own_multiple_of_qty, 0.0) + + child.is_sale_own_restrict_multiple_of_qty_set = False + self.assertEqual(child.sale_restrict_multiple_of_qty, "1") + self.assertFalse(child.sale_own_restrict_multiple_of_qty) diff --git a/sale_restricted_qty/tests/test_product_product.py b/sale_restricted_qty/tests/test_product_product.py new file mode 100644 index 00000000000..a17c55dcb11 --- /dev/null +++ b/sale_restricted_qty/tests/test_product_product.py @@ -0,0 +1,127 @@ +# Copyright 2024 CorporateHub +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import odoo.tests.common as common +from odoo.tests import tagged + + +@tagged("post_install", "-at_install") +class TestProductTemplate(common.TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.ProductTemplate = cls.env["product.template"] + cls.Product = cls.env["product.product"] + + def test_inheritance(self): + template = self.ProductTemplate.create( + { + "name": "Template", + } + ) + self.assertEqual(template.is_sale_min_qty_set, False) + self.assertEqual(template.sale_min_qty, 0.0) + self.assertEqual(template.is_sale_restrict_min_qty_set, False) + self.assertEqual(template.sale_restrict_min_qty, "0") + self.assertEqual(template.is_sale_max_qty_set, False) + self.assertEqual(template.sale_max_qty, 0.0) + self.assertEqual(template.is_sale_restrict_max_qty_set, False) + self.assertEqual(template.sale_restrict_max_qty, "0") + self.assertEqual(template.is_sale_multiple_of_qty_set, False) + self.assertEqual(template.sale_multiple_of_qty, 0.0) + self.assertEqual(template.is_sale_restrict_multiple_of_qty_set, False) + self.assertEqual(template.sale_restrict_multiple_of_qty, "0") + + product = template.product_variant_id + product.write( + { + "name": "Product", + } + ) + self.assertEqual(product.sale_min_qty, 0.0) + self.assertEqual(product.sale_restrict_min_qty, "0") + self.assertEqual(product.sale_max_qty, 0.0) + self.assertEqual(product.sale_restrict_max_qty, "0") + self.assertEqual(product.sale_multiple_of_qty, 0.0) + self.assertEqual(product.sale_restrict_multiple_of_qty, "0") + + template.update( + { + "sale_min_qty": 10.0, + "sale_restrict_min_qty": "1", + "sale_max_qty": 100.0, + "sale_restrict_max_qty": "1", + "sale_multiple_of_qty": 5.0, + "sale_restrict_multiple_of_qty": "1", + } + ) + self.assertTrue(product.is_sale_min_qty_set) + self.assertTrue(product.is_sale_restrict_min_qty_set) + self.assertTrue(product.is_sale_max_qty_set) + self.assertTrue(product.is_sale_restrict_max_qty_set) + self.assertTrue(product.is_sale_multiple_of_qty_set) + self.assertTrue(product.is_sale_restrict_multiple_of_qty_set) + self.assertFalse(product.is_sale_own_min_qty_set) + self.assertFalse(product.is_sale_own_restrict_min_qty_set) + self.assertFalse(product.is_sale_own_max_qty_set) + self.assertFalse(product.is_sale_own_restrict_max_qty_set) + self.assertFalse(product.is_sale_own_multiple_of_qty_set) + self.assertFalse(product.is_sale_own_restrict_multiple_of_qty_set) + self.assertEqual(product.sale_min_qty, 10.0) + self.assertEqual(product.sale_restrict_min_qty, "1") + self.assertEqual(product.sale_max_qty, 100.0) + self.assertEqual(product.sale_restrict_max_qty, "1") + self.assertEqual(product.sale_multiple_of_qty, 5.0) + self.assertEqual(product.sale_restrict_multiple_of_qty, "1") + + product.sale_min_qty = 20.0 + self.assertTrue(product.is_sale_own_min_qty_set) + self.assertEqual(product.sale_own_min_qty, 20.0) + + product.sale_restrict_min_qty = "0" + self.assertTrue(product.is_sale_own_restrict_min_qty_set) + self.assertEqual(product.sale_own_restrict_min_qty, "0") + + product.sale_max_qty = 200.0 + self.assertTrue(product.is_sale_own_max_qty_set) + self.assertEqual(product.sale_own_max_qty, 200.0) + + product.sale_restrict_max_qty = "0" + self.assertTrue(product.is_sale_own_restrict_max_qty_set) + self.assertEqual(product.sale_own_restrict_max_qty, "0") + + product.sale_multiple_of_qty = 10.0 + self.assertTrue(product.is_sale_own_multiple_of_qty_set) + self.assertEqual(product.sale_own_multiple_of_qty, 10.0) + + product.sale_restrict_multiple_of_qty = "0" + self.assertTrue(product.is_sale_own_restrict_multiple_of_qty_set) + self.assertEqual(product.sale_own_restrict_multiple_of_qty, "0") + + product.is_sale_own_min_qty_set = False + product._onchange_is_sale_min_qty_set() + self.assertEqual(product.sale_min_qty, 10.0) + self.assertEqual(product.sale_own_min_qty, 0.0) + + product.is_sale_own_restrict_min_qty_set = False + self.assertEqual(product.sale_restrict_min_qty, "1") + self.assertFalse(product.sale_own_restrict_min_qty) + + product.is_sale_own_max_qty_set = False + product._onchange_is_sale_max_qty_set() + self.assertEqual(product.sale_max_qty, 100.0) + self.assertEqual(product.sale_own_max_qty, 0.0) + + product.is_sale_own_restrict_max_qty_set = False + self.assertEqual(product.sale_restrict_max_qty, "1") + self.assertFalse(product.sale_own_restrict_max_qty) + + product.is_sale_own_multiple_of_qty_set = False + product._onchange_is_sale_multiple_of_qty_set() + self.assertEqual(product.sale_multiple_of_qty, 5.0) + self.assertEqual(product.sale_own_multiple_of_qty, 0.0) + + product.is_sale_own_restrict_multiple_of_qty_set = False + self.assertEqual(product.sale_restrict_multiple_of_qty, "1") + self.assertFalse(product.sale_own_restrict_multiple_of_qty) diff --git a/sale_restricted_qty/tests/test_product_template.py b/sale_restricted_qty/tests/test_product_template.py new file mode 100644 index 00000000000..69d496f57e7 --- /dev/null +++ b/sale_restricted_qty/tests/test_product_template.py @@ -0,0 +1,150 @@ +# Copyright 2024 CorporateHub +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import odoo.tests.common as common +from odoo.tests import tagged + + +@tagged("post_install", "-at_install") +class TestProductTemplate(common.TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.ProductCategory = cls.env["product.category"] + cls.ProductTemplate = cls.env["product.template"] + + def test_inheritance(self): + category = self.ProductCategory.create( + { + "name": "Category", + } + ) + self.assertEqual(category.is_sale_min_qty_set, False) + self.assertEqual(category.sale_min_qty, 0.0) + self.assertEqual(category.is_sale_restrict_min_qty_set, False) + self.assertEqual(category.sale_restrict_min_qty, "0") + self.assertEqual(category.is_sale_max_qty_set, False) + self.assertEqual(category.sale_max_qty, 0.0) + self.assertEqual(category.is_sale_restrict_max_qty_set, False) + self.assertEqual(category.sale_restrict_max_qty, "0") + self.assertEqual(category.is_sale_multiple_of_qty_set, False) + self.assertEqual(category.sale_multiple_of_qty, 0.0) + self.assertEqual(category.is_sale_restrict_multiple_of_qty_set, False) + self.assertEqual(category.sale_restrict_multiple_of_qty, "0") + + template = self.ProductTemplate.create( + { + "name": "Child", + } + ) + self.assertEqual(template.is_sale_min_qty_set, False) + self.assertEqual(template.sale_min_qty, 0.0) + self.assertEqual(template.is_sale_restrict_min_qty_set, False) + self.assertEqual(template.sale_restrict_min_qty, "0") + self.assertEqual(template.is_sale_max_qty_set, False) + self.assertEqual(template.sale_max_qty, 0.0) + self.assertEqual(template.is_sale_restrict_max_qty_set, False) + self.assertEqual(template.sale_restrict_max_qty, "0") + self.assertEqual(template.is_sale_multiple_of_qty_set, False) + self.assertEqual(template.sale_multiple_of_qty, 0.0) + self.assertEqual(template.is_sale_restrict_multiple_of_qty_set, False) + self.assertEqual(template.sale_restrict_multiple_of_qty, "0") + + template.update( + { + "categ_id": category.id, + } + ) + self.assertEqual(template.is_sale_min_qty_set, False) + self.assertEqual(template.sale_min_qty, 0.0) + self.assertEqual(template.is_sale_restrict_min_qty_set, False) + self.assertEqual(template.sale_restrict_min_qty, "0") + self.assertEqual(template.is_sale_max_qty_set, False) + self.assertEqual(template.sale_max_qty, 0.0) + self.assertEqual(template.is_sale_restrict_max_qty_set, False) + self.assertEqual(template.sale_restrict_max_qty, "0") + self.assertEqual(template.is_sale_multiple_of_qty_set, False) + self.assertEqual(template.sale_multiple_of_qty, 0.0) + self.assertEqual(template.is_sale_restrict_multiple_of_qty_set, False) + self.assertEqual(template.sale_restrict_multiple_of_qty, "0") + + category.update( + { + "sale_min_qty": 10.0, + "sale_restrict_min_qty": "1", + "sale_max_qty": 100.0, + "sale_restrict_max_qty": "1", + "sale_multiple_of_qty": 5.0, + "sale_restrict_multiple_of_qty": "1", + } + ) + self.assertTrue(template.is_sale_min_qty_set) + self.assertTrue(template.is_sale_restrict_min_qty_set) + self.assertTrue(template.is_sale_max_qty_set) + self.assertTrue(template.is_sale_restrict_max_qty_set) + self.assertTrue(template.is_sale_multiple_of_qty_set) + self.assertTrue(template.is_sale_restrict_multiple_of_qty_set) + self.assertFalse(template.is_sale_own_min_qty_set) + self.assertFalse(template.is_sale_own_restrict_min_qty_set) + self.assertFalse(template.is_sale_own_max_qty_set) + self.assertFalse(template.is_sale_own_restrict_max_qty_set) + self.assertFalse(template.is_sale_own_multiple_of_qty_set) + self.assertFalse(template.is_sale_own_restrict_multiple_of_qty_set) + self.assertEqual(template.sale_min_qty, 10.0) + self.assertEqual(template.sale_restrict_min_qty, "1") + self.assertEqual(template.sale_max_qty, 100.0) + self.assertEqual(template.sale_restrict_max_qty, "1") + self.assertEqual(template.sale_multiple_of_qty, 5.0) + self.assertEqual(template.sale_restrict_multiple_of_qty, "1") + + template.sale_min_qty = 20.0 + self.assertTrue(template.is_sale_own_min_qty_set) + self.assertEqual(template.sale_own_min_qty, 20.0) + + template.sale_restrict_min_qty = "0" + self.assertTrue(template.is_sale_own_restrict_min_qty_set) + self.assertEqual(template.sale_own_restrict_min_qty, "0") + + template.sale_max_qty = 200.0 + self.assertTrue(template.is_sale_own_max_qty_set) + self.assertEqual(template.sale_own_max_qty, 200.0) + + template.sale_restrict_max_qty = "0" + self.assertTrue(template.is_sale_own_restrict_max_qty_set) + self.assertEqual(template.sale_own_restrict_max_qty, "0") + + template.sale_multiple_of_qty = 10.0 + self.assertTrue(template.is_sale_own_multiple_of_qty_set) + self.assertEqual(template.sale_own_multiple_of_qty, 10.0) + + template.sale_restrict_multiple_of_qty = "0" + self.assertTrue(template.is_sale_own_restrict_multiple_of_qty_set) + self.assertEqual(template.sale_own_restrict_multiple_of_qty, "0") + + template.is_sale_own_min_qty_set = False + template._onchange_is_sale_min_qty_set() + self.assertEqual(template.sale_min_qty, 10.0) + self.assertEqual(template.sale_own_min_qty, 0.0) + + template.is_sale_own_restrict_min_qty_set = False + self.assertEqual(template.sale_restrict_min_qty, "1") + self.assertFalse(template.sale_own_restrict_min_qty) + + template.is_sale_own_max_qty_set = False + template._onchange_is_sale_max_qty_set() + self.assertEqual(template.sale_max_qty, 100.0) + self.assertEqual(template.sale_own_max_qty, 0.0) + + template.is_sale_own_restrict_max_qty_set = False + self.assertEqual(template.sale_restrict_max_qty, "1") + self.assertFalse(template.sale_own_restrict_max_qty) + + template.is_sale_own_multiple_of_qty_set = False + template._onchange_is_sale_multiple_of_qty_set() + self.assertEqual(template.sale_multiple_of_qty, 5.0) + self.assertEqual(template.sale_own_multiple_of_qty, 0.0) + + template.is_sale_own_restrict_multiple_of_qty_set = False + self.assertEqual(template.sale_restrict_multiple_of_qty, "1") + self.assertFalse(template.sale_own_restrict_multiple_of_qty) diff --git a/sale_restricted_qty/tests/test_sale_order_line.py b/sale_restricted_qty/tests/test_sale_order_line.py new file mode 100644 index 00000000000..8781f87d609 --- /dev/null +++ b/sale_restricted_qty/tests/test_sale_order_line.py @@ -0,0 +1,324 @@ +# Copyright 2024 CorporateHub +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.exceptions import ValidationError +from odoo.tests import common, tagged + + +@tagged("post_install", "-at_install") +class TestSaleOrderLine(common.TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.Partner = cls.env["res.partner"] + cls.Product = cls.env["product.product"] + cls.ProductTemplate = cls.env["product.template"] + cls.ProductCategory = cls.env["product.category"] + cls.SaleOrder = cls.env["sale.order"] + cls.Uom = cls.env["uom.uom"] + + cls.partner = cls.Partner.create({"name": "Partner"}) + cls.uom_unit = cls.env.ref("uom.product_uom_unit") + cls.uom_dozen = cls.env.ref("uom.product_uom_dozen") + + def test_min_qty_blocking_vs_warning(self): + """Test the difference between Blocking and Warning for Min Qty.""" + product = self.Product.create( + { + "name": "Product", + "sale_min_qty": 10.0, + "sale_restrict_min_qty": "1", # Blocking + } + ) + + # 1. Blocking: Should raise ValidationError + with self.assertRaises(ValidationError): + self.SaleOrder.create( + { + "partner_id": self.partner.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": product.id, + "product_uom_qty": 5.0, + }, + ) + ], + } + ) + + # 2. Warning: Should NOT raise ValidationError + product.sale_restrict_min_qty = "0" # Warning + so = self.SaleOrder.create( + { + "partner_id": self.partner.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": product.id, + "product_uom_qty": 5.0, + }, + ) + ], + } + ) + self.assertTrue(so.order_line.is_below_min_qty) + + def test_max_qty_blocking_vs_warning(self): + """Test the difference between Blocking and Warning for Max Qty.""" + product = self.Product.create( + { + "name": "Product", + "sale_max_qty": 10.0, + "sale_restrict_max_qty": "1", # Blocking + } + ) + + # 1. Blocking + with self.assertRaises(ValidationError): + self.SaleOrder.create( + { + "partner_id": self.partner.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": product.id, + "product_uom_qty": 15.0, + }, + ) + ], + } + ) + + # 2. Warning + product.sale_restrict_max_qty = "0" + so = self.SaleOrder.create( + { + "partner_id": self.partner.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": product.id, + "product_uom_qty": 15.0, + }, + ) + ], + } + ) + self.assertTrue(so.order_line.is_above_max_qty) + + def test_multiple_of_qty_blocking_vs_warning(self): + """Test the difference between Blocking and Warning for Multiple-of.""" + product = self.Product.create( + { + "name": "Product", + "sale_multiple_of_qty": 5.0, + "sale_restrict_multiple_of_qty": "1", # Blocking + } + ) + + # 1. Blocking + with self.assertRaises(ValidationError): + self.SaleOrder.create( + { + "partner_id": self.partner.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": product.id, + "product_uom_qty": 7.0, + }, + ) + ], + } + ) + + # 2. Warning + product.sale_restrict_multiple_of_qty = "0" + so = self.SaleOrder.create( + { + "partner_id": self.partner.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": product.id, + "product_uom_qty": 7.0, + }, + ) + ], + } + ) + self.assertTrue(so.order_line.is_not_multiple_of_qty) + + def test_multi_level_inheritance(self): + """Test inheritance from Category -> Template -> Product.""" + parent_categ = self.ProductCategory.create( + { + "name": "Parent Categ", + "sale_min_qty": 100.0, + "sale_restrict_min_qty": "1", + } + ) + child_categ = self.ProductCategory.create( + { + "name": "Child Categ", + "parent_id": parent_categ.id, + } + ) + template = self.ProductTemplate.create( + { + "name": "Template", + "categ_id": child_categ.id, + } + ) + product = template.product_variant_id + + # Verify initial inheritance + self.assertEqual(product.sale_min_qty, 100.0) + self.assertEqual(product.sale_restrict_min_qty, "1") + + # Override at Template level + template.sale_min_qty = 50.0 + self.assertEqual(product.sale_min_qty, 50.0) + + # Setting Template level back to inherited should restore category value + template.is_sale_own_min_qty_set = False + self.assertEqual(template.sale_min_qty, 100.0) + self.assertEqual(product.sale_min_qty, 100.0) + + def test_auto_populate_logic(self): + """Exhaustive test of auto-population onchanges.""" + product = self.Product.create( + { + "name": "Product", + "sale_min_qty": 10.0, + "sale_restrict_min_qty": "1", + } + ) + + line = self.env["sale.order.line"].new( + { + "product_id": product.id, + } + ) + # Simulate UI trigger + line._onchange_product_id() + line._onchange_product_id_set_min_qty() + self.assertEqual(line.product_uom_qty, 10.0) + + # Test that it DOES NOT overwrite if quantity is already set manually + line.product_uom_qty = 25.0 + line._onchange_product_id_set_min_qty() + self.assertEqual(line.product_uom_qty, 25.0) + + def test_uom_logic(self): + """Test that constraints handle UoM conversions correctly.""" + product = self.Product.create( + { + "name": "Product", + "uom_id": self.uom_unit.id, + "sale_min_qty": 24.0, # 2 Dozen + "sale_restrict_min_qty": "1", + } + ) + + # 1.5 Dozen = 18 Units (Fails) + with self.assertRaises(ValidationError): + self.SaleOrder.create( + { + "partner_id": self.partner.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": product.id, + "product_uom_qty": 1.5, + "product_uom": self.uom_dozen.id, + }, + ) + ], + } + ) + + # 2.5 Dozen = 30 Units (Success) + so = self.SaleOrder.create( + { + "partner_id": self.partner.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": product.id, + "product_uom_qty": 2.5, + "product_uom": self.uom_dozen.id, + }, + ) + ], + } + ) + self.assertFalse(so.order_line.is_below_min_qty) + + def test_inverses_and_onchanges_mixin(self): + """Test all logic branches in the mixin manually.""" + product = self.Product.create({"name": "Product"}) + + # Test sale_min_qty inverse + product.sale_min_qty = 12.3 + self.assertTrue(product.is_sale_own_min_qty_set) + self.assertEqual(product.sale_own_min_qty, 12.3) + + # Reset via is_sale_own_min_qty_set + product.is_sale_own_min_qty_set = False + product._onchange_is_sale_min_qty_set() + self.assertEqual(product.sale_min_qty, 0.0) + + # Test restriction selection inverse + product.sale_restrict_min_qty = "1" + self.assertTrue(product.is_sale_own_restrict_min_qty_set) + self.assertEqual(product.sale_own_restrict_min_qty, "1") + + def test_historical_skip(self): + """Ensure confirmed orders skip constraints.""" + product = self.Product.create({"name": "Product"}) + so = self.SaleOrder.create( + { + "partner_id": self.partner.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": product.id, + "product_uom_qty": 1.0, + }, + ) + ], + } + ) + so.action_confirm() + + # Enable restriction after confirmation + product.write( + { + "sale_min_qty": 10.0, + "sale_restrict_min_qty": "1", + } + ) + # This shouldn't crash or fail validation on re-read/write + so.name = "Updated SO" + self.assertEqual(so.order_line.product_uom_qty, 1.0) diff --git a/sale_restricted_qty/views/product_category_views.xml b/sale_restricted_qty/views/product_category_views.xml new file mode 100644 index 00000000000..21294be0104 --- /dev/null +++ b/sale_restricted_qty/views/product_category_views.xml @@ -0,0 +1,150 @@ + + + + + product.category + + + + + + + + + + + + + + + + + + + + diff --git a/sale_restricted_qty/views/product_template_views.xml b/sale_restricted_qty/views/product_template_views.xml new file mode 100644 index 00000000000..5b4fb99617c --- /dev/null +++ b/sale_restricted_qty/views/product_template_views.xml @@ -0,0 +1,150 @@ + + + + + product.template + + + + + + + + + + + + + + + + + + + + diff --git a/sale_restricted_qty/views/sale_order_views.xml b/sale_restricted_qty/views/sale_order_views.xml new file mode 100644 index 00000000000..c610fd44443 --- /dev/null +++ b/sale_restricted_qty/views/sale_order_views.xml @@ -0,0 +1,111 @@ + + + + + sale.order + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +