Files
Cédric Krier 4fe5697559 Do not copy origin of stock inputs and outputs when duplicate productions
(grafted from d3cb3fa5c777be0e83f45eb2297ce7aaec83caaf)

--HG--
branch : 6.0
2024-09-06 13:32:08 +02:00

820 lines
29 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
from sql import Null
from sql.conditionals import Coalesce
from trytond.model import ModelView, ModelSQL, Workflow, fields, dualmethod
from trytond.pyson import Eval, Bool, If
from trytond.pool import Pool
from trytond.transaction import Transaction
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
BOM_CHANGES = ['bom', 'product', 'quantity', 'uom', 'warehouse', 'location',
'company', 'inputs', 'outputs']
class Production(ShipmentAssignMixin, Workflow, ModelSQL, ModelView):
"Production"
__name__ = 'production'
_assign_moves_field = 'inputs'
number = fields.Char('Number', select=True, readonly=True)
reference = fields.Char('Reference', select=1,
states={
'readonly': ~Eval('state').in_(['request', 'draft']),
},
depends=['state'])
planned_date = fields.Date('Planned Date',
states={
'readonly': ~Eval('state').in_(['request', 'draft']),
},
depends=['state'])
effective_date = fields.Date('Effective Date',
states={
'readonly': Eval('state').in_(['cancelled', 'done']),
},
depends=['state'])
planned_start_date = fields.Date('Planned Start Date',
states={
'readonly': ~Eval('state').in_(['request', 'draft']),
'required': Bool(Eval('planned_date')),
},
depends=['state', 'planned_date'])
effective_start_date = fields.Date('Effective Start Date',
states={
'readonly': Eval('state').in_(['cancelled', 'running', 'done']),
},
depends=['state'])
company = fields.Many2One('company.company', 'Company', required=True,
states={
'readonly': ~Eval('state').in_(['request', 'draft']),
},
depends=['state'])
warehouse = fields.Many2One('stock.location', 'Warehouse', required=True,
domain=[
('type', '=', 'warehouse'),
],
states={
'readonly': (~Eval('state').in_(['request', 'draft'])
| Eval('inputs', [-1]) | Eval('outputs', [-1])),
},
depends=['state'])
location = fields.Many2One('stock.location', 'Location', required=True,
domain=[
('type', '=', 'production'),
],
states={
'readonly': (~Eval('state').in_(['request', 'draft'])
| Eval('inputs', [-1]) | Eval('outputs', [-1])),
},
depends=['state'])
product = fields.Many2One('product.product', 'Product',
domain=[
('producible', '=', True),
],
states={
'readonly': ~Eval('state').in_(['request', 'draft']),
},
context={
'company': Eval('company', -1),
},
depends=['company'])
bom = fields.Many2One('production.bom', 'BOM',
domain=[
('output_products', '=', Eval('product', 0)),
],
states={
'readonly': (~Eval('state').in_(['request', 'draft'])
| ~Eval('warehouse', 0) | ~Eval('location', 0)),
'invisible': ~Eval('product'),
},
depends=['product'])
uom_category = fields.Function(fields.Many2One(
'product.uom.category', 'Uom Category'),
'on_change_with_uom_category')
uom = fields.Many2One('product.uom', 'Uom',
domain=[
('category', '=', Eval('uom_category')),
],
states={
'readonly': ~Eval('state').in_(['request', 'draft']),
'required': Bool(Eval('bom')),
'invisible': ~Eval('product'),
},
depends=['uom_category'])
unit_digits = fields.Function(fields.Integer('Unit Digits'),
'on_change_with_unit_digits')
quantity = fields.Float('Quantity',
digits=(16, Eval('unit_digits', 2)),
states={
'readonly': ~Eval('state').in_(['request', 'draft']),
'required': Bool(Eval('bom')),
'invisible': ~Eval('product'),
},
depends=['unit_digits'])
cost = fields.Function(fields.Numeric('Cost', digits=price_digits,
readonly=True), 'get_cost')
inputs = fields.One2Many('stock.move', 'production_input', 'Inputs',
domain=[
('shipment', '=', None),
('from_location', 'child_of', [Eval('warehouse')], 'parent'),
('to_location', '=', Eval('location')),
('company', '=', Eval('company', -1)),
],
states={
'readonly': (~Eval('state').in_(['request', 'draft', 'waiting'])
| ~Eval('warehouse') | ~Eval('location')),
},
depends=['warehouse', 'location', 'company'])
outputs = fields.One2Many('stock.move', 'production_output', 'Outputs',
domain=[
('shipment', '=', None),
('from_location', '=', Eval('location')),
['OR',
('to_location', 'child_of', [Eval('warehouse')], 'parent'),
('to_location.waste_warehouses', '=', Eval('warehouse')),
],
('company', '=', Eval('company', -1)),
],
states={
'readonly': (Eval('state').in_(['done', 'cancelled'])
| ~Eval('warehouse') | ~Eval('location')),
},
depends=['warehouse', 'location', 'company'])
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)
origin = fields.Reference(
"Origin", selection='get_origin', select=True,
states={
'readonly': ~Eval('state').in_(['request', 'draft']),
},
depends=['state'])
@classmethod
def __setup__(cls):
super(Production, cls).__setup__()
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'),
))
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'],
},
'done': {
'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)
table = cls.__table__()
# Migration from 3.8: rename code into number
if table_h.column_exist('code'):
table_h.column_rename('code', 'number')
super(Production, cls).__register__(module_name)
# Migration from 4.0: fill planned_start_date
cursor = Transaction().connection.cursor()
cursor.execute(*table.update(
[table.planned_start_date],
[table.planned_date],
where=(table.planned_start_date == Null)
& (table.planned_date != Null)))
# Migration from 5.6: rename state cancel to cancelled
cursor.execute(*table.update(
[table.state], ['cancelled'],
where=table.state == 'cancel'))
@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
@staticmethod
def default_company():
return Transaction().context.get('company')
@fields.depends('product', 'bom')
def compute_lead_time(self, pattern=None):
if pattern is None:
pattern = {}
if self.product:
pattern.setdefault('bom', self.bom.id if self.bom else None)
for line in self.product.lead_times:
if line.match(pattern):
return line.lead_time or timedelta()
return timedelta()
@fields.depends('planned_date', methods=['compute_lead_time'])
def on_change_with_planned_start_date(self, pattern=None):
if self.planned_date and self.product:
return self.planned_date - self.compute_lead_time()
return self.planned_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]
def _move(self, from_location, to_location, company, product, uom,
quantity):
Move = Pool().get('stock.move')
move = Move(
product=product,
uom=uom,
quantity=quantity,
from_location=from_location,
to_location=to_location,
company=company,
currency=company.currency if company else None,
state='draft',
)
return move
def _explode_move_values(self, from_location, to_location, company,
bom_io, quantity):
move = self._move(from_location, to_location, company,
bom_io.product, bom_io.uom, quantity)
move.from_location = from_location.id if from_location else None
move.to_location = to_location.id if to_location else None
move.unit_price_required = move.on_change_with_unit_price_required()
return move
@fields.depends(*BOM_CHANGES)
def explode_bom(self):
pool = Pool()
Uom = pool.get('product.uom')
if not (self.bom and self.product and self.uom):
return
factor = self.bom.compute_factor(self.product, self.quantity or 0,
self.uom)
inputs = []
for input_ in self.bom.inputs:
quantity = input_.compute_quantity(factor)
move = self._explode_move_values(
self.picking_location, self.location, self.company,
input_, quantity)
if move:
inputs.append(move)
quantity = Uom.compute_qty(input_.uom, quantity,
input_.product.default_uom, round=False)
self.inputs = inputs
outputs = []
for output in self.bom.outputs:
quantity = output.compute_quantity(factor)
move = self._explode_move_values(
self.location, self.output_location, self.company, output,
quantity)
if move:
move.unit_price = Decimal(0)
outputs.append(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', 'uom', methods=['explode_bom'])
def on_change_product(self):
if self.product:
category = self.product.default_uom.category
if not self.uom or self.uom.category != category:
self.uom = self.product.default_uom
self.unit_digits = self.product.default_uom.digits
else:
self.bom = None
self.uom = None
self.unit_digits = 2
self.explode_bom()
@fields.depends('product')
def on_change_with_uom_category(self, name=None):
if self.product:
return self.product.default_uom.category.id
@fields.depends('uom')
def on_change_with_unit_digits(self, name=None):
if self.uom:
return self.uom.digits
return 2
@fields.depends(methods=['explode_bom'])
def on_change_bom(self):
self.explode_bom()
@fields.depends(methods=['explode_bom'])
def on_change_uom(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
if input_.cost_price is not None:
cost_price = input_.cost_price
else:
cost_price = input_.product.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_.uom is None
or input_.quantity is None
or input_.state == 'cancelled'):
continue
product = input_.product
quantity = Uom.compute_qty(input_.uom, 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:
location = production.location
company = production.company
if not production.bom:
if production.product:
move = production._move(
location, production.output_location, company,
production.product, production.uom,
production.quantity)
if move:
move.production_output = production
move.unit_price = Decimal(0)
to_save.append(move)
continue
factor = production.bom.compute_factor(
production.product, production.quantity, production.uom)
for input_ in production.bom.inputs:
quantity = input_.compute_quantity(factor)
product = input_.product
move = production._move(
production.picking_location, location, company, product,
input_.uom, quantity)
if move:
move.production_input = production
to_save.append(move)
for output in production.bom.outputs:
quantity = output.compute_quantity(factor)
product = output.product
move = production._move(location, production.output_location,
company, product, output.uom, quantity)
if move:
move.production_output = production
move.unit_price = Decimal(0)
to_save.append(move)
Move.save(to_save)
cls._set_move_planned_date(productions)
@property
def _list_price_context(self):
return {
'company': self.company.id,
}
@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')
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
if input_.cost_price is not None:
cost_price = input_.cost_price
else:
cost_price = input_.product.cost_price
input_quantities[input_.product] += (
Decimal(str(input_.internal_quantity)))
input_costs[input_.product] += (
Decimal(str(input_.internal_quantity)) * cost_price)
outputs = []
for output in production.outputs:
if (output.to_location.type == 'lost_found'
or output.state == 'cancelled'):
continue
product = output.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.uom))
if output.unit_price != unit_price:
output.unit_price = unit_price
moves.append(output)
cost -= min(
unit_price * Decimal(str(output.quantity)), cost)
else:
outputs.append(output)
for output in outputs:
product = output.product
with Transaction().set_context(production._list_price_context):
list_price = product.list_price_used
product_price = (Decimal(str(output.quantity))
* Uom.compute_price(
product.default_uom, list_price, output.uom))
prices[output] = product_price
sum_ += product_price
if not sum_ and production.product:
prices.clear()
for output in outputs:
if output.product == production.product:
quantity = Uom.compute_qty(
output.uom, 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:
output.unit_price = unit_price
moves.append(output)
Move.save(moves)
@classmethod
def create(cls, vlist):
Config = Pool().get('production.configuration')
vlist = [x.copy() for x in vlist]
config = Config(1)
default_company = cls.default_company()
for values in vlist:
if values.get('number') is None:
values['number'] = config.get_multivalue(
'production_sequence',
company=values.get('company', default_company)).get()
productions = super(Production, cls).create(vlist)
for production in productions:
production._set_move_planned_date()
return productions
@classmethod
def write(cls, *args):
super(Production, cls).write(*args)
for production in sum(args[::2], []):
production._set_move_planned_date()
@classmethod
def copy(cls, productions, default=None):
if default is None:
default = {}
else:
default = default.copy()
default.setdefault('number', None)
default.setdefault('assigned_by')
default.setdefault('run_by')
default.setdefault('done_by')
default.setdefault('inputs.origin', None)
default.setdefault('outputs.origin', None)
return super(Production, cls).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'}]
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'}]
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()
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])
cls.write([p for p in productions if not p.effective_start_date], {
'effective_start_date': Date.today(),
})
@classmethod
@ModelView.button
@Workflow.transition('done')
@set_employee('done_by')
def done(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])
cls.write([p for p in productions if not p.effective_date], {
'effective_date': 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')
if Move.assign_try(
[m for p in productions for m in p.assign_moves]):
cls.assign(productions)
return True
else:
return False
@classmethod
def _get_reschedule_domain(cls, date):
context = Transaction().context
return [
('company', '=', context.get('company')),
('state', '=', 'waiting'),
('planned_start_date', '<', date),
]
@classmethod
def reschedule(cls, date=None):
pool = Pool()
Date = pool.get('ir.date')
if date is None:
date = Date.today()
productions = cls.search(cls._get_reschedule_domain(date))
for production in productions:
production.planned_start_date = date
production.on_change_planned_start_date()
cls.save(productions)
@property
def picking_location(self):
if self.warehouse:
return (self.warehouse.production_picking_location
or self.warehouse.storage_location)
@property
def output_location(self):
if self.warehouse:
return (self.warehouse.production_output_location
or self.warehouse.storage_location)