491 lines
16 KiB
Python
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)
|