details:   https://code.tryton.org/tryton/commit/16aae4ad4d69
branch:    default
user:      Cédric Krier <[email protected]>
date:      Tue Sep 22 17:49:00 2026 +0200
description:
        Add BOM tolerance warning on production inputs and outputs

        Closes #14475
diffstat:

 modules/production/CHANGELOG                               |    1 +
 modules/production/bom.py                                  |    3 +
 modules/production/exceptions.py                           |   16 +
 modules/production/message.xml                             |   18 ++
 modules/production/production.py                           |   80 +++++++++-
 modules/production/tests/scenario_production_tolerance.rst |  114 +++++++++++++
 modules/production/view/bom_form.xml                       |    7 +
 7 files changed, 238 insertions(+), 1 deletions(-)

diffs (317 lines):

diff -r acd3d5341b43 -r 16aae4ad4d69 modules/production/CHANGELOG
--- a/modules/production/CHANGELOG      Tue Sep 22 18:35:13 2026 +0200
+++ b/modules/production/CHANGELOG      Tue Sep 22 17:49:00 2026 +0200
@@ -1,3 +1,4 @@
+* Add BOM tolerance warning on production output
 
 Version 8.0.0 - 2026-04-20
 --------------------------
diff -r acd3d5341b43 -r 16aae4ad4d69 modules/production/bom.py
--- a/modules/production/bom.py Tue Sep 22 18:35:13 2026 +0200
+++ b/modules/production/bom.py Tue Sep 22 17:49:00 2026 +0200
@@ -66,6 +66,9 @@
             })
     output_products = fields.Many2Many('production.bom.output',
         'bom', 'product', 'Output Products')
+    tolerance = fields.Float(
+        "Tolerance",
+        help="The percentage tolerance that is accepted for production.")
 
     @classmethod
     def order_code(cls, tables):
diff -r acd3d5341b43 -r 16aae4ad4d69 modules/production/exceptions.py
--- a/modules/production/exceptions.py  Tue Sep 22 18:35:13 2026 +0200
+++ b/modules/production/exceptions.py  Tue Sep 22 17:49:00 2026 +0200
@@ -5,3 +5,19 @@
 
 class CostWarning(UserWarning):
     pass
+
+
+class ToleranceProductionWarning(UserWarning):
+    pass
+
+
+class UnderProductionWarning(ToleranceProductionWarning):
+    pass
+
+
+class OverProductionWarning(ToleranceProductionWarning):
+    pass
+
+
+class UnexpectedProductionWarning(ToleranceProductionWarning):
+    pass
diff -r acd3d5341b43 -r 16aae4ad4d69 modules/production/message.xml
--- a/modules/production/message.xml    Tue Sep 22 18:35:13 2026 +0200
+++ b/modules/production/message.xml    Tue Sep 22 17:49:00 2026 +0200
@@ -15,5 +15,23 @@
         <record model="ir.message" id="msg_stock_move_production_single">
             <field name="text">Move can not be used for production input and 
output.</field>
         </record>
+        <record model="ir.message" id="msg_under_production_inputs">
+            <field name="text">The production "%(production)s" input of 
product "%(product)s" is lower than the tolerance (%(actual)s consumed &lt; 
%(minimal)s).</field>
+        </record>
+        <record model="ir.message" id="msg_over_production_inputs">
+            <field name="text">The production "%(production)s" input of 
product "%(product)s" is greater than the tolerance (%(actual)s consumed > 
%(maximal)s).</field>
+        </record>
+        <record model="ir.message" 
id="msg_unexpected_product_production_inputs">
+            <field name="text">The production "%(production)s" input has an 
unexpected product "%(product)s".</field>
+        </record>
+        <record model="ir.message" id="msg_under_production_outputs">
+            <field name="text">The production "%(production)s" output of 
product "%(product)s" is lower than the tolerance (%(actual)s produced &lt; 
%(minimal)s).</field>
+        </record>
+        <record model="ir.message" id="msg_over_production_outputs">
+            <field name="text">The production "%(production)s" output of 
product "%(product)s" is greater than the tolerance (%(actual)s produced > 
%(maximal)s).</field>
+        </record>
+        <record model="ir.message" 
id="msg_unexpected_product_production_outputs">
+            <field name="text">The production "%(production)s" output has an 
unexpected product "%(product)s".</field>
+        </record>
     </data>
 </tryton>
diff -r acd3d5341b43 -r 16aae4ad4d69 modules/production/production.py
--- a/modules/production/production.py  Tue Sep 22 18:35:13 2026 +0200
+++ b/modules/production/production.py  Tue Sep 22 17:49:00 2026 +0200
@@ -19,7 +19,9 @@
 from trytond.pyson import Bool, Eval, Id, If
 from trytond.transaction import Transaction
 
-from .exceptions import CostWarning
+from .exceptions import (
+    CostWarning, OverProductionWarning, UnderProductionWarning,
+    UnexpectedProductionWarning)
 
 
 class Production(
@@ -665,6 +667,78 @@
                     production.number = number
         cls.save(productions)
 
+    def check_input_quantities(self):
+        self._check_quantities('inputs')
+
+    def check_output_quantities(self):
+        self._check_quantities('outputs')
+
+    def _check_quantities(self, type):
+        pool = Pool()
+        Lang = pool.get('ir.lang')
+        UoM = pool.get('product.uom')
+        Warning = pool.get('res.user.warning')
+
+        assert type in {'inputs', 'outputs'}
+
+        if not self.bom or self.bom.tolerance is None:
+            return
+
+        lang = Lang.get()
+        factor = self.bom.compute_factor(
+            self.product, self.quantity or 0, self.unit,
+            type='inputs' if self.type == 'disassembly' else 'outputs')
+        expected_quantities = defaultdict(float)
+        for move in getattr(self.bom, type):
+            quantity = move.compute_quantity(factor)
+            for line, quantity in move.lines_for_quantity(quantity):
+                expected_quantities[line.product] += UoM.compute_qty(
+                    line.unit, quantity, line.product.default_uom,
+                    round=False)
+
+        actual_quantities = defaultdict(float)
+        for move in getattr(self, type):
+            actual_quantities[move.product] += move.internal_quantity
+
+        for product, expected_quantity in expected_quantities.items():
+            actual_quantity = actual_quantities[product]
+            minimal_quantity = expected_quantity * (1 - self.bom.tolerance)
+            maximal_quantity = expected_quantity * (1 + self.bom.tolerance)
+            if actual_quantity < minimal_quantity:
+                name = Warning.format('under_tolerance', [self, product])
+                if Warning.check(name):
+                    raise UnderProductionWarning(
+                        name,
+                        gettext(f'production.msg_under_production_{type}',
+                            production=self.rec_name,
+                            product=product.rec_name,
+                            actual=lang.format_number_symbol(
+                                actual_quantity, product.default_uom),
+                            minimal=lang.format_number_symbol(
+                                minimal_quantity, product.default_uom)))
+            elif actual_quantity > maximal_quantity:
+                name = Warning.format('over_tolerance', [self, product])
+                if Warning.check(name):
+                    raise OverProductionWarning(
+                        name,
+                        gettext(f'production.msg_over_production_{type}',
+                            production=self.rec_name,
+                            product=product.rec_name,
+                            actual=lang.format_number_symbol(
+                                actual_quantity, product.default_uom),
+                            maximal=lang.format_number_symbol(
+                                maximal_quantity, product.default_uom)))
+
+        for product in actual_quantities.keys() - expected_quantities.keys():
+            name = Warning.format('unexpected_product', [self, product])
+            if Warning.check(name):
+                raise UnexpectedProductionWarning(
+                    name,
+                    gettext(
+                        f'production.msg_unexpected_product_production_{type}',
+                        production=self.rec_name,
+                        product=product.rec_name))
+
     @classmethod
     def on_modification(cls, mode, productions, field_names=None):
         pool = Pool()
@@ -779,6 +853,8 @@
         pool = Pool()
         Move = pool.get('stock.move')
         Date = pool.get('ir.date')
+        for production in productions:
+            production.check_input_quantities()
         Move.do([m for p in productions for m in p.inputs])
         for company, productions in groupby(
                 productions, key=lambda p: p.company):
@@ -796,6 +872,8 @@
         pool = Pool()
         Move = pool.get('stock.move')
         Date = pool.get('ir.date')
+        for production in productions:
+            production.check_output_quantities()
         cls.set_cost(productions)
         Move.do([m for p in productions for m in p.outputs])
         for company, productions in groupby(
diff -r acd3d5341b43 -r 16aae4ad4d69 
modules/production/tests/scenario_production_tolerance.rst
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/modules/production/tests/scenario_production_tolerance.rst        Tue Sep 
22 17:49:00 2026 +0200
@@ -0,0 +1,114 @@
+=============================
+Production Tolerance Scenario
+=============================
+
+Imports::
+
+    >>> from decimal import Decimal
+
+    >>> from proteus import Model
+    >>> from trytond.modules.company.tests.tools import create_company
+    >>> from trytond.tests.tools import activate_modules
+
+Activate modules::
+
+    >>> config = activate_modules('production', create_company)
+
+    >>> BOM = Model.get('production.bom')
+    >>> ProductBOM = Model.get('product.product-production.bom')
+    >>> ProductTemplate = Model.get('product.template')
+    >>> Production = Model.get('production')
+    >>> UoM = Model.get('product.uom')
+
+Create products::
+
+    >>> unit, = UoM.find([('name', '=', "Unit")])
+
+    >>> template = ProductTemplate()
+    >>> template.name = "Component"
+    >>> template.default_uom = unit
+    >>> template.type = 'goods'
+    >>> template.list_price = Decimal('10.0000')
+    >>> template.save()
+    >>> component, = template.products
+
+    >>> template = ProductTemplate()
+    >>> template.name = "Product"
+    >>> template.default_uom = unit
+    >>> template.type = 'goods'
+    >>> template.producible = True
+    >>> template.list_price = Decimal('20.0000')
+    >>> template.save()
+    >>> product, = template.products
+
+Create a bill of material::
+
+    >>> bom = BOM(name="Product")
+    >>> input = bom.inputs.new()
+    >>> input.product = component
+    >>> input.quantity = 2
+    >>> output = bom.outputs.new()
+    >>> output.product = product
+    >>> output.quantity = 1
+    >>> bom.tolerance = 10 / 100
+    >>> bom.save()
+
+    >>> product.boms.append(ProductBOM(bom=bom))
+    >>> product.save()
+
+Make a production::
+
+    >>> production = Production()
+    >>> production.product = product
+    >>> production.bom = bom
+    >>> production.quantity = 10
+    >>> production.click('wait')
+    >>> production.click('assign_force')
+    >>> production.click('run')
+    >>> production.state
+    'running'
+
+Try under produce::
+
+    >>> output, = production.outputs
+    >>> output.quantity = 8
+    >>> production.click('do')
+    Traceback (most recent call last):
+        ...
+    UnderProductionWarning: ... 8.0 u produced < 9.0 u ...
+
+Try over produce::
+
+    >>> output, = production.outputs
+    >>> output.quantity = 12
+    >>> production.click('do')
+    Traceback (most recent call last):
+        ...
+    OverProductionWarning: ... 12.0 u produced < 11.0 u ...
+
+    >>> output, = production.outputs
+    >>> output.quantity = 9
+    >>> production.save()
+
+Try unexpected product::
+
+    >>> output = production.outputs.new()
+    >>> output.from_location = production.location
+    >>> output.to_location = production.warehouse.storage_location
+    >>> output.product = component
+    >>> output.quantity = 1
+    >>> output.unit_price = Decimal(0)
+    >>> production.click('do')
+    Traceback (most recent call last):
+        ...
+    UnexpectedProductionWarning: ...
+
+    >>> output, = [m for m in production.outputs if m.product == component]
+    >>> _ = production.outputs.remove(output)
+    >>> production.save()
+
+Produce inside tolerance::
+
+    >>> production.click('do')
+    >>> production.state
+    'done'
diff -r acd3d5341b43 -r 16aae4ad4d69 modules/production/view/bom_form.xml
--- a/modules/production/view/bom_form.xml      Tue Sep 22 18:35:13 2026 +0200
+++ b/modules/production/view/bom_form.xml      Tue Sep 22 17:49:00 2026 +0200
@@ -14,6 +14,13 @@
     <field name="phantom_quantity"/>
     <label name="phantom_unit"/>
     <field name="phantom_unit"/>
+
+    <label name="tolerance"/>
+    <group col="-1" id="tolerance">
+        <field name="tolerance" factor="100" xexpand="0"/>
+        <label name="tolerance" string="%" xexpand="1" xalign="0"/>
+    </group>
+
     <notebook colspan="6">
         <page string="Lines" id="lines" col="2">
             <field name="inputs"/>

Reply via email to