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>

Reply via email to