details: https://code.tryton.org/tryton/commit/51805c42f099
branch: default
user: Maxime Richez <[email protected]>
date: Mon Oct 13 09:41:41 2025 +0200
description:
Add phantom BOM
Closes #11160
diffstat:
modules/production/CHANGELOG | 1 +
modules/production/bom.py | 159 +++++++++-
modules/production/message.xml | 5 +-
modules/production/product.py | 9 +-
modules/production/production.py | 12 +-
modules/production/tests/scenario_production_phantom_bom.rst | 149 ++++++++++
modules/production/view/bom_form.xml | 6 +
modules/production/view/bom_input_form.xml | 5 +
modules/production/view/bom_input_list.xml | 2 +-
modules/production/view/bom_list.xml | 1 +
modules/production/view/bom_output_form.xml | 5 +
modules/production/view/bom_output_list.xml | 2 +-
12 files changed, 331 insertions(+), 25 deletions(-)
diffs (560 lines):
diff -r caee544d2261 -r 51805c42f099 modules/production/CHANGELOG
--- a/modules/production/CHANGELOG Sun Sep 14 12:23:08 2025 +0200
+++ b/modules/production/CHANGELOG Mon Oct 13 09:41:41 2025 +0200
@@ -1,3 +1,4 @@
+* Add phantom BOM
* Add support for disassembly production
Version 7.6.0 - 2025-04-28
diff -r caee544d2261 -r 51805c42f099 modules/production/bom.py
--- a/modules/production/bom.py Sun Sep 14 12:23:08 2025 +0200
+++ b/modules/production/bom.py Mon Oct 13 09:41:41 2025 +0200
@@ -3,9 +3,11 @@
from sql.functions import CharLength
+from trytond.i18n import gettext
from trytond.model import DeactivableMixin, ModelSQL, ModelView, fields
+from trytond.model.exceptions import RecursionError
from trytond.pool import Pool
-from trytond.pyson import Eval
+from trytond.pyson import Bool, Eval, If
from trytond.tools import is_full_text, lstrip_wildcard
from trytond.wizard import Button, StateView, Wizard
@@ -21,11 +23,47 @@
})
code_readonly = fields.Function(
fields.Boolean("Code Readonly"), 'get_code_readonly')
- inputs = fields.One2Many('production.bom.input', 'bom', "Input Materials")
+ phantom = fields.Boolean(
+ "Phantom",
+ help="If checked, the BoM can be used in another BoM.")
+ phantom_unit = fields.Many2One(
+ 'product.uom', "Unit",
+ states={
+ 'invisible': ~Eval('phantom', False),
+ 'required': Eval('phantom', False),
+ },
+ help="The Unit of Measure of the Phantom BoM")
+ phantom_quantity = fields.Float(
+ "Quantity", digits='phantom_unit',
+ domain=['OR',
+ ('phantom_quantity', '>=', 0),
+ ('phantom_quantity', '=', None),
+ ],
+ states={
+ 'invisible': ~Eval('phantom', False),
+ 'required': Eval('phantom', False),
+ },
+ help="The quantity of the Phantom BoM")
+ inputs = fields.One2Many(
+ 'production.bom.input', 'bom', "Input Materials",
+ domain=[If(Eval('phantom') & Eval('outputs', None),
+ ('id', '=', None),
+ ()),
+ ],
+ states={
+ 'invisible': Eval('phantom') & Bool(Eval('outputs')),
+ })
input_products = fields.Many2Many(
'production.bom.input', 'bom', 'product', "Input Products")
outputs = fields.One2Many(
- 'production.bom.output', 'bom', "Output Materials")
+ 'production.bom.output', 'bom', "Output Materials",
+ domain=[If(Eval('phantom') & Eval('inputs', None),
+ ('id', '=', None),
+ ()),
+ ],
+ states={
+ 'invisible': Eval('phantom') & Bool(Eval('inputs')),
+ })
output_products = fields.Many2Many('production.bom.output',
'bom', 'product', 'Output Products')
@@ -74,15 +112,18 @@
]
def compute_factor(self, product, quantity, unit, type='outputs'):
- "Compute factor for a product from the type"
pool = Pool()
Uom = pool.get('product.uom')
assert type in {'inputs', 'outputs'}, f"Invalid {type}"
total = 0
- for line in getattr(self, type):
- if line.product == product:
- total += Uom.compute_qty(
- line.unit, line.quantity, unit, round=False)
+ if self.phantom:
+ total = Uom.compute_qty(
+ self.phantom_unit, self.phantom_quantity, unit, round=False)
+ else:
+ for line in getattr(self, type):
+ if line.product == product:
+ total += Uom.compute_qty(
+ line.unit, line.quantity, unit, round=False)
if total:
return quantity / total
else:
@@ -120,7 +161,26 @@
bom = fields.Many2One(
'production.bom', "BOM", required=True, ondelete='CASCADE')
- product = fields.Many2One('product.product', 'Product', required=True)
+ product = fields.Many2One(
+ 'product.product', "Product",
+ domain=[If(Eval('phantom_bom', None),
+ ('id', '=', None),
+ ()),
+ ],
+ states={
+ 'invisible': Bool(Eval('phantom_bom')),
+ 'required': ~Bool(Eval('phantom_bom')),
+ })
+ phantom_bom = fields.Many2One(
+ 'production.bom', "Phantom BOM",
+ domain=[If(Eval('product', None),
+ ('id', '=', None),
+ ()),
+ ],
+ states={
+ 'invisible': Bool(Eval('product')),
+ 'required': ~Bool(Eval('product')),
+ })
uom_category = fields.Function(fields.Many2One(
'product.uom.category', 'Uom Category'), 'on_change_with_uom_category')
unit = fields.Many2One(
@@ -138,6 +198,10 @@
@classmethod
def __setup__(cls):
super().__setup__()
+ cls.phantom_bom.domain = [
+ ('phantom', '=', True),
+ ('inputs', '!=', None),
+ ]
cls.product.domain = [('type', 'in', cls.get_product_types())]
cls.__access__.add('bom')
@@ -155,11 +219,22 @@
# Migration from 6.0: remove unique constraint
table_h.drop_constraint('product_bom_uniq')
+ # Migration from 7.6: remove required on product
+ table_h.not_null_action('product', 'remove')
@classmethod
def get_product_types(cls):
return ['goods', 'assets']
+ @fields.depends('phantom_bom', 'unit')
+ def on_change_phantom_bom(self):
+ if self.phantom_bom:
+ category = self.phantom_bom.phantom_unit.category
+ if not self.unit or self.unit.category != category:
+ self.unit = self.phantom_bom.phantom_unit
+ else:
+ self.unit = None
+
@fields.depends('product', 'unit')
def on_change_product(self):
if self.product:
@@ -169,16 +244,33 @@
else:
self.unit = None
- @fields.depends('product')
+ @fields.depends('product', 'phantom_bom')
def on_change_with_uom_category(self, name=None):
- return self.product.default_uom.category if self.product else None
+ uom_category = None
+ if self.product:
+ uom_category = self.product.default_uom.category
+ elif self.phantom_bom:
+ uom_category = self.phantom_bom.phantom_unit.category
+ return uom_category
def get_rec_name(self, name):
- return self.product.rec_name
+ if self.product:
+ return self.product.rec_name
+ elif self.phantom_bom:
+ return self.phantom_bom.rec_name
@classmethod
def search_rec_name(cls, name, clause):
- return [('product.rec_name',) + tuple(clause[1:])]
+ _, operator, value = clause
+ if operator.startswith('!') or operator.startswith('not '):
+ bool_op = 'AND'
+ else:
+ bool_op = 'OR'
+
+ return [bool_op,
+ ('product.rec_name', operator, value),
+ ('phantom_bom.rec_name', operator, value),
+ ]
@classmethod
def validate(cls, boms):
@@ -186,11 +278,21 @@
for bom in boms:
bom.check_bom_recursion()
- def check_bom_recursion(self):
+ def check_bom_recursion(self, bom=None):
'''
Check BOM recursion
'''
- self.product.check_bom_recursion()
+ if bom is None:
+ bom = self.bom
+ if self.product:
+ self.product.check_bom_recursion()
+ else:
+ for line_ in self._phantom_lines:
+ if line_.phantom_bom and (line_.phantom_bom == bom
+ or line_.check_bom_recursion(bom=bom)):
+ raise RecursionError(gettext(
+ 'production.msg_recursive_bom_bom',
+ bom=bom.rec_name))
def compute_quantity(self, factor):
return self.unit.ceil(self.quantity * factor)
@@ -199,15 +301,42 @@
"Update stock move for the production"
return move
+ @property
+ def _phantom_lines(self):
+ if self.phantom_bom:
+ return self.phantom_bom.inputs
+
+ def lines_for_quantity(self, quantity):
+ if self.phantom_bom:
+ factor = self.phantom_bom.compute_factor(
+ None, quantity, self.unit)
+ for line in self._phantom_lines:
+ yield line, line.compute_quantity(factor)
+ else:
+ yield self, quantity
+
class BOMOutput(BOMInput):
__name__ = 'production.bom.output'
__string__ = None
_table = None # Needed to override BOMInput._table
+ @classmethod
+ def __setup__(cls):
+ super().__setup__()
+ cls.phantom_bom.domain = [
+ ('phantom', '=', True),
+ ('outputs', '!=', None),
+ ]
+
def compute_quantity(self, factor):
return self.unit.floor(self.quantity * factor)
+ @property
+ def _phantom_lines(self):
+ if self.phantom_bom:
+ return self.phantom_bom.outputs
+
@classmethod
def on_delete(cls, outputs):
pool = Pool()
diff -r caee544d2261 -r 51805c42f099 modules/production/message.xml
--- a/modules/production/message.xml Sun Sep 14 12:23:08 2025 +0200
+++ b/modules/production/message.xml Mon Oct 13 09:41:41 2025 +0200
@@ -3,9 +3,12 @@
this repository contains the full copyright notices and license terms. -->
<tryton>
<data grouped="1">
- <record model="ir.message" id="msg_recursive_bom">
+ <record model="ir.message" id="msg_recursive_bom_product">
<field name="text">You cannot create a recursive BOM for product
"%(product)s".</field>
</record>
+ <record model="ir.message" id="msg_recursive_bom_bom">
+ <field name="text">You cannot create a recursive BOM for BOM
"%(bom)s".</field>
+ </record>
<record model="ir.message" id="msg_missing_product_list_price">
<field name="text">The product "%(product)s" on production
"%(production)s" does not have any list price defined.</field>
</record>
diff -r caee544d2261 -r 51805c42f099 modules/production/product.py
--- a/modules/production/product.py Sun Sep 14 12:23:08 2025 +0200
+++ b/modules/production/product.py Mon Oct 13 09:41:41 2025 +0200
@@ -59,11 +59,14 @@
product = self
for product_bom in self.boms:
for input_ in product_bom.bom.inputs:
- if (input_.product == product
+ if input_.phantom_bom:
+ for i in input_.phantom_bom.inputs:
+ i.check_bom_recursion()
+ if input_.product and (input_.product == product
or input_.product.check_bom_recursion(
product=product)):
- raise RecursionError(
- gettext('production.msg_recursive_bom',
+ raise RecursionError(gettext(
+ 'production.msg_recursive_bom_product',
product=product.rec_name))
@classmethod
diff -r caee544d2261 -r 51805c42f099 modules/production/production.py
--- a/modules/production/production.py Sun Sep 14 12:23:08 2025 +0200
+++ b/modules/production/production.py Mon Oct 13 09:41:41 2025 +0200
@@ -412,15 +412,19 @@
inputs = []
for input_ in self.bom.inputs:
quantity = input_.compute_quantity(factor)
- move = self._move('input', input_.product, input_.unit, quantity)
- inputs.append(input_.prepare_move(self, move))
+ for line, quantity in input_.lines_for_quantity(quantity):
+ move = self._move(
+ 'input', line.product, line.unit, quantity)
+ inputs.append(input_.prepare_move(self, move))
self.inputs = inputs
outputs = []
for output in self.bom.outputs:
quantity = output.compute_quantity(factor)
- move = self._move('output', output.product, output.unit, quantity)
- outputs.append(output.prepare_move(self, move))
+ for line, quantity in output.lines_for_quantity(quantity):
+ move = self._move(
+ 'output', line.product, line.unit, quantity)
+ outputs.append(output.prepare_move(self, move))
self.outputs = outputs
@fields.depends('warehouse')
diff -r caee544d2261 -r 51805c42f099
modules/production/tests/scenario_production_phantom_bom.rst
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/modules/production/tests/scenario_production_phantom_bom.rst Mon Oct
13 09:41:41 2025 +0200
@@ -0,0 +1,149 @@
+===============================
+Production Phantom BOM Scenario
+===============================
+
+Imports::
+
+ >>> from decimal import Decimal
+
+ >>> from proteus import Model
+ >>> from trytond.modules.company.tests.tools import create_company,
get_company
+ >>> from trytond.tests.tools import activate_modules
+
+Activate modules::
+
+ >>> config = activate_modules('production')
+
+ >>> ProductUom = Model.get('product.uom')
+ >>> ProductTemplate = Model.get('product.template')
+ >>> BOM = Model.get('production.bom')
+ >>> BOMInput = Model.get('production.bom.input')
+ >>> BOMOutput = Model.get('production.bom.output')
+ >>> ProductBom = Model.get('product.product-production.bom')
+ >>> Production = Model.get('production')
+
+Create company::
+
+ >>> _ = create_company()
+ >>> company = get_company()
+
+Create product::
+
+ >>> unit, = ProductUom.find([('name', '=', 'Unit')])
+
+ >>> template_table = ProductTemplate()
+ >>> template_table.name = 'table'
+ >>> template_table.default_uom = unit
+ >>> template_table.type = 'goods'
+ >>> template_table.producible = True
+ >>> template_table.list_price = Decimal(30)
+ >>> table, = template_table.products
+ >>> table.cost_price = Decimal(20)
+ >>> template_table.save()
+ >>> table, = template_table.products
+
+Create Components::
+
+ >>> template_top = ProductTemplate()
+ >>> template_top.name = 'top'
+ >>> template_top.default_uom = unit
+ >>> template_top.type = 'goods'
+ >>> template_top.list_price = Decimal(5)
+ >>> top, = template_top.products
+ >>> top.cost_price = Decimal(1)
+ >>> template_top.save()
+ >>> top, = template_top.products
+
+ >>> template_leg = ProductTemplate()
+ >>> template_leg.name = 'leg'
+ >>> template_leg.default_uom = unit
+ >>> template_leg.type = 'goods'
+ >>> template_leg.producible = True
+ >>> template_leg.list_price = Decimal(7)
+ >>> template_leg.producible = True
+ >>> leg, = template_leg.products
+ >>> leg.cost_price = Decimal(5)
+ >>> template_leg.save()
+ >>> leg, = template_leg.products
+
+ >>> template_foot = ProductTemplate()
+ >>> template_foot.name = 'foot'
+ >>> template_foot.default_uom = unit
+ >>> template_foot.type = 'goods'
+ >>> template_foot.list_price = Decimal(5)
+ >>> foot, = template_foot.products
+ >>> foot.cost_price = Decimal(3)
+ >>> template_foot.save()
+ >>> foot, = template_foot.products
+
+ >>> template_extension = ProductTemplate()
+ >>> template_extension.name = 'extension'
+ >>> template_extension.default_uom = unit
+ >>> template_extension.type = 'goods'
+ >>> template_extension.list_price = Decimal(5)
+ >>> extension, = template_extension.products
+ >>> extension.cost_price = Decimal(4)
+ >>> template_extension.save()
+ >>> extension, = template_extension.products
+
+ >>> template_hook = ProductTemplate()
+ >>> template_hook.name = 'hook'
+ >>> template_hook.default_uom = unit
+ >>> template_hook.type = 'goods'
+ >>> template_hook.list_price = Decimal(7)
+ >>> hook, = template_hook.products
+ >>> hook.cost_price = Decimal(9)
+ >>> template_hook.save()
+ >>> hook, = template_hook.products
+
+Create Phantom Bill of Material with input products::
+
+ >>> phantom_bom_input = BOM(name='Leg Foot Input')
+ >>> phantom_bom_input.phantom = True
+ >>> phantom_bom_input.phantom_quantity = 1
+ >>> phantom_bom_input.phantom_unit = unit
+ >>> phantom_input1 = phantom_bom_input.inputs.new()
+ >>> phantom_input1.product = leg
+ >>> phantom_input1.quantity = 1
+ >>> phantom_input2 = phantom_bom_input.inputs.new()
+ >>> phantom_input2.product = foot
+ >>> phantom_input2.quantity = 1
+ >>> phantom_bom_input.save()
+
+Create Phantom Bill of Material with output products::
+
+ >>> phantom_bom_output = BOM(name='Extension Hook Ouput')
+ >>> phantom_bom_output.phantom = True
+ >>> phantom_bom_output.phantom_quantity = 1
+ >>> phantom_bom_output.phantom_unit = unit
+ >>> phantom_output1 = phantom_bom_output.outputs.new()
+ >>> phantom_output1.product = extension
+ >>> phantom_output1.quantity = 1
+ >>> phantom_output2 = phantom_bom_output.outputs.new()
+ >>> phantom_output2.product = hook
+ >>> phantom_output2.quantity = 2
+ >>> phantom_bom_output.save()
+ >>> phantom_bom_output.outputs[0].product.name
+ 'extension'
+ >>> phantom_bom_output.outputs[1].product.name
+ 'hook'
+
+Create Bill of Material using Phantom BoM::
+
+ >>> bom = BOM(name='product with Phantom BoM')
+ >>> input1 = bom.inputs.new()
+ >>> input1.product = top
+ >>> input1.quantity = 1
+ >>> input2 = bom.inputs.new()
+ >>> input2.phantom_bom = phantom_bom_input
+ >>> input2.quantity = 4
+ >>> output = bom.outputs.new()
+ >>> output.product = table
+ >>> output.quantity = 1
+ >>> output2 = bom.outputs.new()
+ >>> output2.phantom_bom = phantom_bom_output
+ >>> output2.quantity = 2
+ >>> bom.save()
+
+ >>> table.boms.append(ProductBom(bom=bom))
+ >>> table.save()
diff -r caee544d2261 -r 51805c42f099 modules/production/view/bom_form.xml
--- a/modules/production/view/bom_form.xml Sun Sep 14 12:23:08 2025 +0200
+++ b/modules/production/view/bom_form.xml Mon Oct 13 09:41:41 2025 +0200
@@ -8,6 +8,12 @@
<field name="code"/>
<label name="active"/>
<field name="active" xexpand="0" width="100"/>
+ <label name="phantom"/>
+ <field name="phantom"/>
+ <label name="phantom_quantity"/>
+ <field name="phantom_quantity"/>
+ <label name="phantom_unit"/>
+ <field name="phantom_unit"/>
<notebook colspan="6">
<page string="Lines" id="lines" col="2">
<field name="inputs"/>
diff -r caee544d2261 -r 51805c42f099 modules/production/view/bom_input_form.xml
--- a/modules/production/view/bom_input_form.xml Sun Sep 14 12:23:08
2025 +0200
+++ b/modules/production/view/bom_input_form.xml Mon Oct 13 09:41:41
2025 +0200
@@ -8,6 +8,11 @@
<label name="product"/>
<field name="product"/>
<newline/>
+
+ <label name="phantom_bom"/>
+ <field name="phantom_bom"/>
+ <newline/>
+
<label name="quantity"/>
<field name="quantity"/>
<label name="unit"/>
diff -r caee544d2261 -r 51805c42f099 modules/production/view/bom_input_list.xml
--- a/modules/production/view/bom_input_list.xml Sun Sep 14 12:23:08
2025 +0200
+++ b/modules/production/view/bom_input_list.xml Mon Oct 13 09:41:41
2025 +0200
@@ -3,6 +3,6 @@
this repository contains the full copyright notices and license terms. -->
<tree>
<field name="bom" expand="1"/>
- <field name="product" expand="1"/>
+ <field name="rec_name" string="Material" expand="1"/>
<field name="quantity" symbol="unit"/>
</tree>
diff -r caee544d2261 -r 51805c42f099 modules/production/view/bom_list.xml
--- a/modules/production/view/bom_list.xml Sun Sep 14 12:23:08 2025 +0200
+++ b/modules/production/view/bom_list.xml Mon Oct 13 09:41:41 2025 +0200
@@ -4,4 +4,5 @@
<tree>
<field name="code" expand="1"/>
<field name="name" expand="2"/>
+ <field name="phantom" expand="1" optional="1"/>
</tree>
diff -r caee544d2261 -r 51805c42f099 modules/production/view/bom_output_form.xml
--- a/modules/production/view/bom_output_form.xml Sun Sep 14 12:23:08
2025 +0200
+++ b/modules/production/view/bom_output_form.xml Mon Oct 13 09:41:41
2025 +0200
@@ -8,6 +8,11 @@
<label name="product"/>
<field name="product"/>
<newline/>
+
+ <label name="phantom_bom"/>
+ <field name="phantom_bom"/>
+ <newline/>
+
<label name="quantity"/>
<field name="quantity"/>
<label name="unit"/>
diff -r caee544d2261 -r 51805c42f099 modules/production/view/bom_output_list.xml
--- a/modules/production/view/bom_output_list.xml Sun Sep 14 12:23:08
2025 +0200
+++ b/modules/production/view/bom_output_list.xml Mon Oct 13 09:41:41
2025 +0200
@@ -3,6 +3,6 @@
this repository contains the full copyright notices and license terms. -->
<tree>
<field name="bom" expand="1"/>
- <field name="product" expand="1"/>
+ <field name="rec_name" string="Material" expand="1"/>
<field name="quantity" symbol="unit"/>
</tree>