Add BOM tolerance warning on production inputs and outputs
Closes #14475
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
* Add BOM tolerance warning on production output
|
||||
|
||||
Version 8.0.0 - 2026-04-20
|
||||
--------------------------
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 < %(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>
|
||||
|
||||
@@ -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'
|
||||
@@ -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"/>
|
||||
|
||||
Reference in New Issue
Block a user