Files
tradon/modules/production/bom.py
2026-03-14 09:42:12 +00:00

491 lines
16 KiB
Python

# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
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 Bool, Eval, If
from trytond.tools import is_full_text, lstrip_wildcard
from trytond.wizard import Button, StateView, Wizard
class BOM(DeactivableMixin, ModelSQL, ModelView):
__name__ = 'production.bom'
name = fields.Char('Name', required=True, translate=True)
code = fields.Char(
"Code",
states={
'readonly': Eval('code_readonly', False),
})
code_readonly = fields.Function(
fields.Boolean("Code Readonly"), 'get_code_readonly')
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",
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')
@classmethod
def order_code(cls, tables):
table, _ = tables[None]
if cls.default_code_readonly():
return [CharLength(table.code), table.code]
else:
return [table.code]
@classmethod
def default_code_readonly(cls):
pool = Pool()
Configuration = pool.get('production.configuration')
config = Configuration(1)
return bool(config.bom_sequence)
def get_code_readonly(self, name):
return self.default_code_readonly()
@classmethod
def order_rec_name(cls, tables):
table, _ = tables[None]
return cls.order_code(tables) + [table.name]
def get_rec_name(self, name):
if self.code:
return '[' + self.code + '] ' + self.name
else:
return self.name
@classmethod
def search_rec_name(cls, name, clause):
_, operator, operand, *extra = clause
if operator.startswith('!') or operator.startswith('not '):
bool_op = 'AND'
else:
bool_op = 'OR'
code_value = operand
if operator.endswith('like') and is_full_text(operand):
code_value = lstrip_wildcard(operand)
return [bool_op,
('name', operator, operand, *extra),
('code', operator, code_value, *extra),
]
def compute_factor(self, product, quantity, unit, type='outputs'):
pool = Pool()
Uom = pool.get('product.uom')
assert type in {'inputs', 'outputs'}, f"Invalid {type}"
total = 0
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:
return 0
@classmethod
def _code_sequence(cls):
pool = Pool()
Configuration = pool.get('production.configuration')
config = Configuration(1)
return config.bom_sequence
@classmethod
def preprocess_values(cls, mode, values):
values = super().preprocess_values(mode, values)
if mode == 'create' and not values.get('code'):
if sequence := cls._code_sequence():
values['code'] = sequence.get()
return values
@classmethod
def copy(cls, records, default=None):
if default is None:
default = {}
else:
default = default.copy()
default.setdefault('code', None)
default.setdefault('input_products', None)
default.setdefault('output_products', None)
return super().copy(records, default=default)
class BOMInput(ModelSQL, ModelView):
__name__ = 'production.bom.input'
bom = fields.Many2One(
'production.bom', "BOM", required=True, ondelete='CASCADE')
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",
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(
'product.uom', "Unit", required=True,
domain=[
('category', '=', Eval('uom_category', -1)),
])
quantity = fields.Float(
"Quantity", digits='unit', required=True,
domain=['OR',
('quantity', '>=', 0),
('quantity', '=', None),
])
@classmethod
def __setup__(cls):
super().__setup__()
cls.phantom_bom.domain = [
If(Eval('product', None),
('id', '=', None),
()),
('phantom', '=', True),
('inputs', '!=', None),
]
cls.product.domain = [('type', 'in', cls.get_product_types())]
cls.__access__.add('bom')
@classmethod
def __register__(cls, module):
table_h = cls.__table_handler__(module)
# Migration from 6.8: rename uom to unit
if (table_h.column_exist('uom')
and not table_h.column_exist('unit')):
table_h.column_rename('uom', 'unit')
super().__register__(module)
table_h = cls.__table_handler__(module)
# 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:
category = self.product.default_uom.category
if not self.unit or self.unit.category != category:
self.unit = self.product.default_uom
else:
self.unit = None
@fields.depends('product', 'phantom_bom')
def on_change_with_uom_category(self, name=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):
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):
_, 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):
super().validate(boms)
for bom in boms:
bom.check_bom_recursion()
def check_bom_recursion(self, bom=None):
'''
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)
def prepare_move(self, production, move):
"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 = [
If(Eval('product', None),
('id', '=', None),
()),
('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()
ProductBOM = pool.get('product.product-production.bom')
callback = super().on_delete(outputs)
bom_products = [b for o in outputs for b in o.product.boms]
# Validate that output_products domain on bom is still valid
callback.append(lambda: ProductBOM._validate(bom_products, ['bom']))
return callback
@classmethod
def on_write(cls, outputs, values):
pool = Pool()
ProductBOM = pool.get('product.product-production.bom')
callback = super().on_write(outputs, values)
bom_products = [b for o in outputs for b in o.product.boms]
# Validate that output_products domain on bom is still valid
callback.append(lambda: ProductBOM._validate(bom_products, ['bom']))
return callback
class BOMTree(ModelView):
__name__ = 'production.bom.tree'
product = fields.Many2One('product.product', 'Product')
quantity = fields.Float('Quantity', digits='unit')
unit = fields.Many2One('product.uom', "Unit")
childs = fields.One2Many('production.bom.tree', None, 'Childs')
@classmethod
def tree(cls, product, quantity, unit, bom=None):
Input = Pool().get('production.bom.input')
result = []
if bom is None:
pbom = product.get_bom()
if pbom is None:
return result
bom = pbom.bom
factor = bom.compute_factor(product, quantity, unit)
for input_ in bom.inputs:
quantity = Input.compute_quantity(input_, factor)
childs = cls.tree(input_.product, quantity, input_.unit)
values = {
'product': input_.product.id,
'product.': {
'rec_name': input_.product.rec_name,
},
'quantity': quantity,
'unit': input_.unit.id,
'unit.': {
'rec_name': input_.unit.rec_name,
},
'childs': childs,
}
result.append(values)
return result
class OpenBOMTreeStart(ModelView):
__name__ = 'production.bom.tree.open.start'
quantity = fields.Float('Quantity', digits='unit', required=True)
unit = fields.Many2One(
'product.uom', "Unit", required=True,
domain=[
('category', '=', Eval('category', -1)),
])
category = fields.Many2One('product.uom.category', 'Category',
readonly=True)
bom = fields.Many2One('product.product-production.bom',
'BOM', required=True, domain=[
('product', '=', Eval('product', -1)),
])
product = fields.Many2One('product.product', 'Product', readonly=True)
class OpenBOMTreeTree(ModelView):
__name__ = 'production.bom.tree.open.tree'
bom_tree = fields.One2Many('production.bom.tree', None, 'BOM Tree',
readonly=True)
@classmethod
def tree(cls, bom, product, quantity, unit):
pool = Pool()
Tree = pool.get('production.bom.tree')
childs = Tree.tree(product, quantity, unit, bom=bom)
bom_tree = [{
'product': product.id,
'product.': {
'rec_name': product.rec_name,
},
'quantity': quantity,
'unit': unit.id,
'unit.': {
'rec_name': unit.rec_name,
},
'childs': childs,
}]
return {
'bom_tree': bom_tree,
}
class OpenBOMTree(Wizard):
__name__ = 'production.bom.tree.open'
_readonly = True
start = StateView('production.bom.tree.open.start',
'production.bom_tree_open_start_view_form', [
Button('Cancel', 'end', 'tryton-cancel'),
Button('OK', 'tree', 'tryton-ok', True),
])
tree = StateView('production.bom.tree.open.tree',
'production.bom_tree_open_tree_view_form', [
Button('Change', 'start', 'tryton-back'),
Button('Close', 'end', 'tryton-close'),
])
def default_start(self, fields):
defaults = {}
product = self.record
defaults['category'] = product.default_uom.category.id
if self.start.unit:
defaults['unit'] = self.start.unit.id
else:
defaults['unit'] = product.default_uom.id
defaults['product'] = product.id
if self.start.bom:
defaults['bom'] = self.start.bom.id
else:
bom = product.get_bom()
if bom:
defaults['bom'] = bom.id
defaults['quantity'] = self.start.quantity
return defaults
def default_tree(self, fields):
pool = Pool()
BomTree = pool.get('production.bom.tree.open.tree')
return BomTree.tree(self.start.bom.bom, self.start.product,
self.start.quantity, self.start.unit)