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 <
%(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 <
%(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"/>