Add BOM tolerance warning on production inputs and outputs

Closes #14475
This commit is contained in:
Cédric Krier
2026-09-22 17:49:00 +02:00
parent d4fddeb5a1
commit b82603d258
7 changed files with 238 additions and 1 deletions
+1
View File
@@ -1,3 +1,4 @@
* Add BOM tolerance warning on production output
Version 8.0.0 - 2026-04-20
--------------------------
+3
View File
@@ -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):
+16
View File
@@ -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
+18
View File
@@ -15,5 +15,23 @@ this repository contains the full copyright notices and license terms. -->
<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>
+79 -1
View File
@@ -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(
@@ -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'
+7
View File
@@ -14,6 +14,13 @@ this repository contains the full copyright notices and license terms. -->
<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"/>