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

913 lines
33 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 collections import defaultdict
from datetime import timedelta
from decimal import Decimal
from itertools import chain, groupby
from sql import Null
from sql.conditionals import Coalesce
from sql.functions import CharLength
from trytond.i18n import gettext
from trytond.model import (
ChatMixin, Index, ModelSQL, ModelView, Workflow, dualmethod, fields)
from trytond.modules.company.model import employee_field, set_employee
from trytond.modules.product import price_digits, round_price
from trytond.modules.stock.shipment import ShipmentAssignMixin
from trytond.pool import Pool, PoolMeta
from trytond.pyson import Bool, Eval, If
from trytond.transaction import Transaction
from .exceptions import CostWarning
class Production(
ShipmentAssignMixin, Workflow, ModelSQL, ModelView, ChatMixin):
__name__ = 'production'
_rec_name = 'number'
_assign_moves_field = 'inputs'
number = fields.Char("Number", readonly=True)
reference = fields.Char(
"Reference",
states={
'readonly': ~Eval('state').in_(['request', 'draft']),
})
planned_date = fields.Date('Planned Date',
states={
'readonly': Eval('state').in_(['cancelled', 'done']),
})
effective_date = fields.Date('Effective Date',
states={
'readonly': Eval('state').in_(['cancelled', 'done']),
})
planned_start_date = fields.Date('Planned Start Date',
states={
'readonly': ~Eval('state').in_(['request', 'draft']),
'required': Bool(Eval('planned_date')),
})
effective_start_date = fields.Date('Effective Start Date',
states={
'readonly': Eval('state').in_(['cancelled', 'running', 'done']),
})
company = fields.Many2One('company.company', 'Company', required=True,
states={
'readonly': ~Eval('state').in_(['request', 'draft']),
})
warehouse = fields.Many2One('stock.location', 'Warehouse', required=True,
domain=[
('type', '=', 'warehouse'),
],
states={
'readonly': (~Eval('state').in_(['request', 'draft'])
| Eval('inputs', [-1]) | Eval('outputs', [-1])),
})
location = fields.Many2One('stock.location', 'Location', required=True,
domain=[
('type', '=', 'production'),
],
states={
'readonly': (~Eval('state').in_(['request', 'draft'])
| Eval('inputs', [-1]) | Eval('outputs', [-1])),
})
type = fields.Selection([
('assembly', "Assembly"),
('disassembly', "Disassembly"),
], "Type", required=True,
states={
'readonly': ~Eval('state').in_(['request', 'draft']),
})
product = fields.Many2One('product.product', 'Product',
domain=[
If(Eval('type') == 'assembly',
('producible', '=', True),
()),
],
states={
'readonly': ~Eval('state').in_(['request', 'draft']),
},
context={
'company': Eval('company', -1),
},
depends={'company'})
bom = fields.Many2One('production.bom', 'BOM',
domain=[
('phantom', '!=', True),
If(Eval('type') == 'disassembly',
('input_products', '=', Eval('product', -1)),
('output_products', '=', Eval('product', -1)),
),
],
states={
'readonly': (~Eval('state').in_(['request', 'draft'])
| ~Eval('warehouse', 0) | ~Eval('location', 0)),
'invisible': ~Eval('product'),
})
uom_category = fields.Function(fields.Many2One(
'product.uom.category', "UoM Category",
help="The category of Unit of Measure."),
'on_change_with_uom_category')
unit = fields.Many2One(
'product.uom', "Unit",
domain=[
('category', '=', Eval('uom_category', -1)),
],
states={
'readonly': ~Eval('state').in_(['request', 'draft']),
'required': Bool(Eval('bom')),
'invisible': ~Eval('product'),
})
quantity = fields.Float(
"Quantity", digits='unit',
states={
'readonly': ~Eval('state').in_(['request', 'draft']),
'required': Bool(Eval('bom')),
'invisible': ~Eval('product'),
})
cost = fields.Function(fields.Numeric('Cost', digits=price_digits,
readonly=True), 'get_cost')
inputs = fields.One2Many(
'stock.move', 'production_input', "Input Materials",
domain=[
('shipment', '=', None),
('from_location', 'child_of', [Eval('warehouse', -1)], 'parent'),
('to_location', '=', Eval('location', -1)),
('company', '=', Eval('company', -1)),
],
states={
'readonly': (~Eval('state').in_(['request', 'draft', 'waiting'])
| ~Eval('warehouse') | ~Eval('location')),
})
outputs = fields.One2Many(
'stock.move', 'production_output', "Output Materials",
domain=[
('shipment', '=', None),
('from_location', '=', Eval('location', -1)),
['OR',
('to_location', 'child_of', [Eval('warehouse', -1)], 'parent'),
('to_location.waste_warehouses', '=', Eval('warehouse', -1)),
],
('company', '=', Eval('company', -1)),
],
states={
'readonly': (Eval('state').in_(['done', 'cancelled'])
| ~Eval('warehouse') | ~Eval('location')),
})
assigned_by = employee_field("Assigned By")
run_by = employee_field("Run By")
done_by = employee_field("Done By")
state = fields.Selection([
('request', 'Request'),
('draft', 'Draft'),
('waiting', 'Waiting'),
('assigned', 'Assigned'),
('running', 'Running'),
('done', 'Done'),
('cancelled', 'Cancelled'),
], 'State', readonly=True, sort=False)
origin = fields.Reference(
"Origin", selection='get_origin',
states={
'readonly': ~Eval('state').in_(['request', 'draft']),
})
@classmethod
def __setup__(cls):
cls.number.search_unaccented = False
cls.reference.search_unaccented = False
super().__setup__()
t = cls.__table__()
cls._sql_indexes.update({
Index(t, (t.reference, Index.Similarity())),
Index(
t,
(t.state, Index.Equality(cardinality='low')),
where=t.state.in_([
'request', 'draft', 'waiting', 'assigned',
'running'])),
})
cls._order = [
('effective_date', 'ASC NULLS LAST'),
('id', 'ASC'),
]
cls._transitions |= set((
('request', 'draft'),
('draft', 'waiting'),
('waiting', 'assigned'),
('assigned', 'running'),
('running', 'done'),
('running', 'waiting'),
('assigned', 'waiting'),
('waiting', 'waiting'),
('waiting', 'draft'),
('request', 'cancelled'),
('draft', 'cancelled'),
('waiting', 'cancelled'),
('assigned', 'cancelled'),
('cancelled', 'draft'),
('done', 'cancelled'),
))
cls._buttons.update({
'cancel': {
'invisible': ~Eval('state').in_(['request', 'draft',
'assigned']),
'depends': ['state'],
},
'draft': {
'invisible': ~Eval('state').in_(['request', 'waiting',
'cancelled']),
'icon': If(Eval('state') == 'cancelled',
'tryton-clear',
If(Eval('state') == 'request',
'tryton-forward',
'tryton-back')),
'depends': ['state'],
},
'reset_bom': {
'invisible': (~Eval('bom')
| ~Eval('state').in_(['request', 'draft', 'waiting'])),
'depends': ['state', 'bom'],
},
'wait': {
'invisible': ~Eval('state').in_(['draft', 'assigned',
'waiting', 'running']),
'icon': If(Eval('state').in_(['assigned', 'running']),
'tryton-back',
If(Eval('state') == 'waiting',
'tryton-clear',
'tryton-forward')),
'depends': ['state'],
},
'run': {
'invisible': Eval('state') != 'assigned',
'depends': ['state'],
},
'do': {
'invisible': Eval('state') != 'running',
'depends': ['state'],
},
'assign_wizard': {
'invisible': Eval('state') != 'waiting',
'depends': ['state'],
},
'assign_try': {},
'assign_force': {},
})
def get_rec_name(self, name):
items = []
if self.number:
items.append(self.number)
if self.reference:
items.append('[%s]' % self.reference)
if not items:
items.append('(%s)' % self.id)
return ' '.join(items)
@classmethod
def search_rec_name(cls, name, clause):
if clause[1].startswith('!') or clause[1].startswith('not '):
bool_op = 'AND'
else:
bool_op = 'OR'
return [bool_op,
('number',) + tuple(clause[1:]),
('reference',) + tuple(clause[1:]),
]
@classmethod
def __register__(cls, module_name):
table_h = cls.__table_handler__(module_name)
# 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_name)
@classmethod
def order_number(cls, tables):
table, _ = tables[None]
return [
~((table.state == 'cancelled') & (table.number == Null)),
CharLength(table.number), table.number]
@classmethod
def order_effective_date(cls, tables):
table, _ = tables[None]
return [Coalesce(
table.effective_start_date, table.effective_date,
table.planned_start_date, table.planned_date)]
@staticmethod
def default_state():
return 'draft'
@classmethod
def default_warehouse(cls):
Location = Pool().get('stock.location')
return Location.get_default_warehouse()
@classmethod
def default_location(cls):
Location = Pool().get('stock.location')
warehouse_id = cls.default_warehouse()
if warehouse_id:
warehouse = Location(warehouse_id)
return warehouse.production_location.id
@classmethod
def default_type(cls):
return 'assembly'
@staticmethod
def default_company():
return Transaction().context.get('company')
@fields.depends('product', 'bom')
def compute_lead_time(self, pattern=None):
pattern = pattern.copy() if pattern is not None else {}
if self.product and self.product.producible:
pattern.setdefault('bom', self.bom.id if self.bom else None)
for line in self.product.production_lead_times:
if line.match(pattern):
return line.lead_time or timedelta()
return timedelta()
@fields.depends(
'planned_date', 'state', 'product', methods=['compute_lead_time'])
def set_planned_start_date(self):
if self.state in {'request', 'draft'}:
if self.planned_date and self.product:
self.planned_start_date = (
self.planned_date - self.compute_lead_time())
else:
self.planned_start_date = self.planned_date
@fields.depends(methods=['set_planned_start_date'])
def on_change_planned_date(self):
self.set_planned_start_date()
@fields.depends(
'planned_date', 'planned_start_date', methods=['compute_lead_time'])
def on_change_planned_start_date(self, pattern=None):
if self.planned_start_date and self.product:
planned_date = self.planned_start_date + self.compute_lead_time()
if (not self.planned_date
or self.planned_date < planned_date):
self.planned_date = planned_date
@classmethod
def _get_origin(cls):
'Return list of Model names for origin Reference'
return set()
@classmethod
def get_origin(cls):
Model = Pool().get('ir.model')
get_name = Model.get_name
models = cls._get_origin()
return [(None, '')] + [(m, get_name(m)) for m in models]
@fields.depends(
'company', 'location', methods=['picking_location', 'output_location'])
def _move(self, type, product, unit, quantity):
pool = Pool()
Move = pool.get('stock.move')
assert type in {'input', 'output'}
move = Move(**Move.default_get(with_rec_name=False))
move.product = product
move.unit = unit
move.quantity = quantity
move.company = self.company
if type == 'input':
move.from_location = self.picking_location
move.to_location = self.location
move.production_input = self
else:
move.from_location = self.location
move.to_location = self.output_location
move.production_output = self
move.unit_price_required = move.on_change_with_unit_price_required()
if move.unit_price_required:
move.unit_price = Decimal(0)
if self.company:
move.currency = self.company.currency
else:
move.unit_price = None
move.currency = None
return move
@fields.depends(
'type', 'bom', 'product', 'unit', 'quantity', 'inputs', 'outputs',
methods=['_move'])
def explode_bom(self):
if not (self.bom and self.product and self.unit):
return
factor = self.bom.compute_factor(
self.product, self.quantity or 0, self.unit,
type='inputs' if self.type == 'disassembly' else 'outputs')
inputs = []
for input_ in self.bom.inputs:
quantity = input_.compute_quantity(factor)
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)
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')
def on_change_warehouse(self):
self.location = None
if self.warehouse:
self.location = self.warehouse.production_location
@fields.depends(
'product', 'unit', methods=['explode_bom', 'set_planned_start_date'])
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.bom = None
self.unit = None
self.explode_bom()
self.set_planned_start_date()
@fields.depends('product')
def on_change_with_uom_category(self, name=None):
return self.product.default_uom.category if self.product else None
@fields.depends(methods=['explode_bom', 'set_planned_start_date'])
def on_change_bom(self):
self.explode_bom()
# Product's production lead time depends on bom
self.set_planned_start_date()
@fields.depends(methods=['explode_bom'])
def on_change_unit(self):
self.explode_bom()
@fields.depends(methods=['explode_bom'])
def on_change_quantity(self):
self.explode_bom()
@ModelView.button_change(methods=['explode_bom'])
def reset_bom(self):
self.explode_bom()
def get_cost(self, name):
cost = Decimal(0)
for input_ in self.inputs:
if input_.state == 'cancelled':
continue
cost_price = input_.get_cost_price()
cost += (Decimal(str(input_.internal_quantity)) * cost_price)
return round_price(cost)
@fields.depends('inputs')
def on_change_with_cost(self):
Uom = Pool().get('product.uom')
cost = Decimal(0)
if not self.inputs:
return cost
for input_ in self.inputs:
if (input_.product is None
or input_.unit is None
or input_.quantity is None
or input_.state == 'cancelled'):
continue
product = input_.product
quantity = Uom.compute_qty(
input_.unit, input_.quantity, product.default_uom)
cost += Decimal(str(quantity)) * product.cost_price
return cost
@dualmethod
def set_moves(cls, productions):
pool = Pool()
Move = pool.get('stock.move')
to_save = []
for production in productions:
dates = production._get_move_planned_date()
input_date, output_date = dates
if not production.bom:
if production.product:
move = production._move(
'output', production.product, production.unit,
production.quantity)
move.planned_date = output_date
to_save.append(move)
continue
factor = production.bom.compute_factor(
production.product, production.quantity, production.unit)
for input_ in production.bom.inputs:
quantity = input_.compute_quantity(factor)
product = input_.product
move = production._move(
'input', product, input_.unit, quantity)
move.planned_date = input_date
to_save.append(input_.prepare_move(production, move))
for output in production.bom.outputs:
quantity = output.compute_quantity(factor)
product = output.product
move = production._move(
'output', product, output.unit, quantity)
move.planned_date = output_date
to_save.append(output.prepare_move(production, move))
Move.save(to_save)
@classmethod
def set_cost_from_moves(cls):
pool = Pool()
Move = pool.get('stock.move')
productions = set()
moves = Move.search([
('production_cost_price_updated', '=', True),
('production_input', '!=', None),
],
order=[('effective_date', 'ASC')])
for move in moves:
if move.production_input not in productions:
cls.__queue__.set_cost([move.production_input])
productions.add(move.production_input)
Move.write(moves, {'production_cost_price_updated': False})
@classmethod
def set_cost(cls, productions):
pool = Pool()
Uom = pool.get('product.uom')
Move = pool.get('stock.move')
Warning = pool.get('res.user.warning')
moves = []
for production in productions:
sum_ = Decimal(0)
prices = {}
cost = production.cost
input_quantities = defaultdict(Decimal)
input_costs = defaultdict(Decimal)
for input_ in production.inputs:
if input_.state == 'cancelled':
continue
cost_price = input_.get_cost_price()
input_quantities[input_.product] += (
Decimal(str(input_.internal_quantity)))
input_costs[input_.product] += (
Decimal(str(input_.internal_quantity)) * cost_price)
outputs = []
output_products = set()
for output in production.outputs:
if (output.to_location.type == 'lost_found'
or output.state == 'cancelled'):
continue
product = output.product
output_products.add(product)
if input_quantities.get(output.product):
cost_price = (
input_costs[product] / input_quantities[product])
unit_price = round_price(Uom.compute_price(
product.default_uom, cost_price, output.unit))
if (output.unit_price != unit_price
or output.currency != production.company.currency):
output.unit_price = unit_price
output.currency = production.company.currency
moves.append(output)
cost -= min(
unit_price * Decimal(str(output.quantity)), cost)
else:
outputs.append(output)
if not (unique_product := len(output_products) == 1):
for output in outputs:
product = output.product
list_price = product.list_price_used
if list_price is None:
warning_name = Warning.format(
'production_missing_list_price', [product])
if Warning.check(warning_name):
raise CostWarning(warning_name,
gettext(
'production.'
'msg_missing_product_list_price',
product=product.rec_name,
production=production.rec_name))
continue
product_price = (Decimal(str(output.quantity))
* Uom.compute_price(
product.default_uom, list_price, output.unit))
prices[output] = product_price
sum_ += product_price
if not sum_ and (unique_product or production.product):
prices.clear()
for output in outputs:
if unique_product or output.product == production.product:
quantity = Uom.compute_qty(
output.unit, output.quantity,
output.product.default_uom, round=False)
quantity = Decimal(str(quantity))
prices[output] = quantity
sum_ += quantity
for output in outputs:
if sum_:
ratio = prices.get(output, 0) / sum_
else:
ratio = Decimal(1) / len(outputs)
if not output.quantity:
unit_price = Decimal(0)
else:
quantity = Decimal(str(output.quantity))
unit_price = round_price(cost * ratio / quantity)
if (output.unit_price != unit_price
or output.currency != production.company.currency):
output.unit_price = unit_price
output.currency = production.company.currency
moves.append(output)
Move.save(moves)
@classmethod
def set_number(cls, productions):
'''
Fill the number field with the production sequence
'''
pool = Pool()
Config = pool.get('production.configuration')
config = Config(1)
for company, c_productions in groupby(
productions, key=lambda p: p.company):
c_productions = [p for p in c_productions if not p.number]
if c_productions:
sequence = config.get_multivalue(
'production_sequence', company=company.id)
for production, number in zip(
c_productions, sequence.get_many(len(c_productions))):
production.number = number
cls.save(productions)
@classmethod
def on_modification(cls, mode, productions, field_names=None):
pool = Pool()
Move = pool.get('stock.move')
super().on_modification(mode, productions, field_names=field_names)
if mode in {'create', 'write'}:
cls._set_move_planned_date(productions)
elif mode == 'delete':
moves = []
for production in productions:
moves.extend(production.inputs)
moves.extend(production.outputs)
Move.delete(moves)
@classmethod
def copy(cls, productions, default=None):
if default is None:
default = {}
else:
default = default.copy()
default.setdefault('number', None)
default.setdefault('reference')
default.setdefault('assigned_by')
default.setdefault('run_by')
default.setdefault('done_by')
default.setdefault('inputs.origin', None)
default.setdefault('outputs.origin', None)
return super().copy(productions, default=default)
def _get_move_planned_date(self):
"Return the planned dates for input and output moves"
return self.planned_start_date, self.planned_date
@dualmethod
def _set_move_planned_date(cls, productions):
"Set planned date of moves for the shipments"
pool = Pool()
Move = pool.get('stock.move')
to_write = []
for production in productions:
dates = production._get_move_planned_date()
input_date, output_date = dates
inputs = [
m for m in production.inputs
if m.state not in {'done', 'cancelled'}
and m.planned_date != input_date]
if inputs:
to_write.append(inputs)
to_write.append({
'planned_date': input_date,
})
outputs = [
m for m in production.outputs
if m.state not in {'done', 'cancelled'}
and m.planned_date != output_date]
if outputs:
to_write.append(outputs)
to_write.append({
'planned_date': output_date,
})
if to_write:
Move.write(*to_write)
@classmethod
@ModelView.button
@Workflow.transition('cancelled')
def cancel(cls, productions):
pool = Pool()
Move = pool.get('stock.move')
Move.cancel([m for p in productions
for m in p.inputs + p.outputs])
@classmethod
@ModelView.button
@Workflow.transition('draft')
def draft(cls, productions):
pool = Pool()
Move = pool.get('stock.move')
to_draft, to_delete = [], []
for production in productions:
for move in chain(production.inputs, production.outputs):
if move.state != 'cancelled':
to_draft.append(move)
else:
to_delete.append(move)
Move.draft(to_draft)
Move.delete(to_delete)
@classmethod
@ModelView.button
@Workflow.transition('waiting')
def wait(cls, productions):
pool = Pool()
cls.set_number(productions)
Move = pool.get('stock.move')
Move.draft([m for p in productions
for m in p.inputs + p.outputs])
@classmethod
@Workflow.transition('assigned')
@set_employee('assigned_by')
def assign(cls, productions):
pool = Pool()
Move = pool.get('stock.move')
Move.assign([m for p in productions for m in p.assign_moves])
@classmethod
@ModelView.button
@Workflow.transition('running')
@set_employee('run_by')
def run(cls, productions):
pool = Pool()
Move = pool.get('stock.move')
Date = pool.get('ir.date')
Move.do([m for p in productions for m in p.inputs])
for company, productions in groupby(
productions, key=lambda p: p.company):
with Transaction().set_context(company=company.id):
today = Date.today()
cls.write([p for p in productions if not p.effective_start_date], {
'effective_start_date': today,
})
@classmethod
@ModelView.button
@Workflow.transition('done')
@set_employee('done_by')
def do(cls, productions):
pool = Pool()
Move = pool.get('stock.move')
Date = pool.get('ir.date')
cls.set_cost(productions)
Move.do([m for p in productions for m in p.outputs])
for company, productions in groupby(
productions, key=lambda p: p.company):
with Transaction().set_context(company=company.id):
today = Date.today()
cls.write([p for p in productions if not p.effective_date], {
'effective_date': today,
})
@classmethod
@ModelView.button_action('production.wizard_production_assign')
def assign_wizard(cls, productions):
pass
@dualmethod
@ModelView.button
def assign_try(cls, productions):
pool = Pool()
Move = pool.get('stock.move')
to_assign = [
m for p in productions for m in p.assign_moves
if m.assignation_required]
if Move.assign_try(to_assign):
cls.assign(productions)
else:
to_assign = []
for production in productions:
if any(
m.state in {'staging', 'draft'}
for m in production.assign_moves
if m.assignation_required):
continue
to_assign.append(production)
if to_assign:
cls.assign(to_assign)
@classmethod
def _get_reschedule_planned_start_dates_domain(cls, date):
context = Transaction().context
return [
('company', '=', context.get('company')),
('state', '=', 'waiting'),
('planned_start_date', '<', date),
]
@classmethod
def _get_reschedule_planned_dates_domain(cls, date):
context = Transaction().context
return [
('company', '=', context.get('company')),
('state', '=', 'running'),
('planned_date', '<', date),
]
@classmethod
def reschedule(cls, date=None):
pool = Pool()
Date = pool.get('ir.date')
if date is None:
date = Date.today()
to_reschedule_start_date = cls.search(
cls._get_reschedule_planned_start_dates_domain(date))
to_reschedule_planned_date = cls.search(
cls._get_reschedule_planned_dates_domain(date))
for production in to_reschedule_start_date:
production.planned_start_date = date
production.on_change_planned_start_date()
for production in to_reschedule_planned_date:
production.planned_date = date
cls.save(to_reschedule_start_date + to_reschedule_planned_date)
@property
@fields.depends('warehouse')
def picking_location(self):
if self.warehouse:
return (self.warehouse.production_picking_location
or self.warehouse.storage_location)
@property
@fields.depends('warehouse')
def output_location(self):
if self.warehouse:
return (self.warehouse.production_output_location
or self.warehouse.storage_location)
class Production_Lot(metaclass=PoolMeta):
__name__ = 'production'
@classmethod
@ModelView.button
@Workflow.transition('done')
def do(cls, productions):
pool = Pool()
Lot = pool.get('stock.lot')
Move = pool.get('stock.move')
lots, moves = [], []
for production in productions:
for move in production.outputs:
if not move.lot and move.product.lot_is_required(
move.from_location, move.to_location):
move.add_lot()
if move.lot:
lots.append(move.lot)
moves.append(move)
Lot.save(lots)
Move.save(moves)
super().do(productions)