From b82603d258ab291b9f8f44de79c407823478973d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Tue, 22 Sep 2026 17:49:00 +0200 Subject: [PATCH] Add BOM tolerance warning on production inputs and outputs Closes #14475 --- modules/production/CHANGELOG | 1 + modules/production/bom.py | 3 + modules/production/exceptions.py | 16 +++ modules/production/message.xml | 18 +++ modules/production/production.py | 80 +++++++++++- .../tests/scenario_production_tolerance.rst | 114 ++++++++++++++++++ modules/production/view/bom_form.xml | 7 ++ 7 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 modules/production/tests/scenario_production_tolerance.rst diff --git a/modules/production/CHANGELOG b/modules/production/CHANGELOG index 07c3d230be..dae16f0a9c 100644 --- a/modules/production/CHANGELOG +++ b/modules/production/CHANGELOG @@ -1,3 +1,4 @@ +* Add BOM tolerance warning on production output Version 8.0.0 - 2026-04-20 -------------------------- diff --git a/modules/production/bom.py b/modules/production/bom.py index 659b0c46a6..3a3a0899ee 100644 --- a/modules/production/bom.py +++ b/modules/production/bom.py @@ -66,6 +66,9 @@ class BOM(DeactivableMixin, ModelSQL, ModelView): }) 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 --git a/modules/production/exceptions.py b/modules/production/exceptions.py index 5d7320bf67..6df0f31189 100644 --- a/modules/production/exceptions.py +++ b/modules/production/exceptions.py @@ -5,3 +5,19 @@ from trytond.exceptions import UserWarning class CostWarning(UserWarning): pass + + +class ToleranceProductionWarning(UserWarning): + pass + + +class UnderProductionWarning(ToleranceProductionWarning): + pass + + +class OverProductionWarning(ToleranceProductionWarning): + pass + + +class UnexpectedProductionWarning(ToleranceProductionWarning): + pass diff --git a/modules/production/message.xml b/modules/production/message.xml index 82dd7dbc03..9192d230dc 100644 --- a/modules/production/message.xml +++ b/modules/production/message.xml @@ -15,5 +15,23 @@ this repository contains the full copyright notices and license terms. --> Move can not be used for production input and output. + + The production "%(production)s" input of product "%(product)s" is lower than the tolerance (%(actual)s consumed < %(minimal)s). + + + The production "%(production)s" input of product "%(product)s" is greater than the tolerance (%(actual)s consumed > %(maximal)s). + + + The production "%(production)s" input has an unexpected product "%(product)s". + + + The production "%(production)s" output of product "%(product)s" is lower than the tolerance (%(actual)s produced < %(minimal)s). + + + The production "%(production)s" output of product "%(product)s" is greater than the tolerance (%(actual)s produced > %(maximal)s). + + + The production "%(production)s" output has an unexpected product "%(product)s". + diff --git a/modules/production/production.py b/modules/production/production.py index 1c4015ee69..2fe597edf1 100644 --- a/modules/production/production.py +++ b/modules/production/production.py @@ -19,7 +19,9 @@ from trytond.pool import Pool, PoolMeta 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 @@ class Production( 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 @@ class Production( 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 @@ class Production( 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 --git a/modules/production/tests/scenario_production_tolerance.rst b/modules/production/tests/scenario_production_tolerance.rst new file mode 100644 index 0000000000..5b6bec7751 --- /dev/null +++ b/modules/production/tests/scenario_production_tolerance.rst @@ -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 --git a/modules/production/view/bom_form.xml b/modules/production/view/bom_form.xml index c3f324b150..2d1b593eac 100644 --- a/modules/production/view/bom_form.xml +++ b/modules/production/view/bom_form.xml @@ -14,6 +14,13 @@ this repository contains the full copyright notices and license terms. -->