479 lines
17 KiB
Python
479 lines
17 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 decimal import Decimal
|
|
from functools import wraps
|
|
|
|
from trytond.model import ModelStorage, ModelView, Workflow, fields
|
|
from trytond.pool import Pool
|
|
from trytond.pyson import Eval
|
|
|
|
|
|
def get_shipments_returns(model_name):
|
|
def _get_shipments_returns(func):
|
|
@wraps(func)
|
|
def wrapper(self, name):
|
|
pool = Pool()
|
|
Model = pool.get(model_name)
|
|
shipments = set(func(self, name))
|
|
for line in self.lines:
|
|
for component in line.components:
|
|
for move in component.moves:
|
|
if isinstance(move.shipment, Model):
|
|
shipments.add(move.shipment.id)
|
|
return list(shipments)
|
|
return wrapper
|
|
return _get_shipments_returns
|
|
|
|
|
|
def search_shipments_returns(model_name):
|
|
def _search_shipments_returns(func):
|
|
@wraps(func)
|
|
def wrapper(cls, name, clause):
|
|
domain = func(cls, name, clause)
|
|
_, operator, operand, *extra = clause
|
|
nested = clause[0][len(name):]
|
|
if not nested:
|
|
if isinstance(operand, str):
|
|
nested = '.rec_name'
|
|
else:
|
|
nested = '.id'
|
|
return ['OR',
|
|
domain,
|
|
('lines.components.moves.shipment' + nested,
|
|
operator, operand, model_name, *extra),
|
|
]
|
|
return wrapper
|
|
return _search_shipments_returns
|
|
|
|
|
|
def get_moves(func):
|
|
@wraps(func)
|
|
def wrapper(self, name):
|
|
return func(self, name) + [
|
|
m.id for l in self.lines for c in l.components for m in c.moves]
|
|
return wrapper
|
|
|
|
|
|
def search_moves(func):
|
|
@wraps(func)
|
|
def wrapper(cls, name, clause):
|
|
return ['OR',
|
|
func(cls, name, clause),
|
|
('lines.components.' + clause[0], *clause[1:]),
|
|
]
|
|
return wrapper
|
|
|
|
|
|
def order_mixin(prefix):
|
|
class OrderMixin(ModelStorage):
|
|
|
|
@classmethod
|
|
def copy(cls, records, default=None):
|
|
pool = Pool()
|
|
Line = pool.get(prefix + '.line')
|
|
if default is None:
|
|
default = {}
|
|
else:
|
|
default = default.copy()
|
|
default.setdefault(
|
|
'lines.component_parent',
|
|
lambda data: data['component_parent'])
|
|
records = super().copy(records, default=default)
|
|
lines = []
|
|
for record in records:
|
|
for line in record.lines:
|
|
if line.component_parent:
|
|
lines.append(line)
|
|
Line.delete(lines)
|
|
return records
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('draft')
|
|
def draft(cls, records):
|
|
pool = Pool()
|
|
Line = pool.get(prefix + '.line')
|
|
to_delete = []
|
|
to_save = []
|
|
for record in records:
|
|
for line in record.lines:
|
|
if line.component_parent:
|
|
to_delete.append(line)
|
|
elif line.components:
|
|
line.components = None
|
|
to_save.append(line)
|
|
Line.save(to_save)
|
|
super().draft(records)
|
|
Line.delete(to_delete)
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('quotation')
|
|
def quote(cls, records):
|
|
pool = Pool()
|
|
Line = pool.get(prefix + '.line')
|
|
removed = []
|
|
for record in records:
|
|
removed.extend(record.set_components())
|
|
Line.delete(removed)
|
|
cls.save(records)
|
|
super().quote(records)
|
|
|
|
def set_components(self):
|
|
pool = Pool()
|
|
Component = pool.get(prefix + '.line.component')
|
|
removed = []
|
|
lines = []
|
|
sequence = 0
|
|
for line in self.lines:
|
|
if line.component_parent:
|
|
removed.append(line)
|
|
continue
|
|
sequence += 1
|
|
line.sequence = sequence
|
|
lines.append(line)
|
|
if line.product and line.product.components_used:
|
|
if line.product.type == 'kit':
|
|
components = []
|
|
for component in line.product.components_used:
|
|
components.append(line.get_component(component))
|
|
Component.set_price_ratio(components, line.quantity)
|
|
line.components = components
|
|
else:
|
|
for component in line.product.components_used:
|
|
order_line = line.get_component_order_line(
|
|
component)
|
|
sequence += 1
|
|
order_line.sequence = sequence
|
|
order_line.component_parent = line
|
|
lines.append(order_line)
|
|
self.lines = lines
|
|
return removed
|
|
return OrderMixin
|
|
|
|
|
|
def order_line_mixin(prefix):
|
|
class OrderLineMixin(ModelStorage, ModelView):
|
|
|
|
component_parent = fields.Many2One(
|
|
prefix + '.line', "Component Parent",
|
|
readonly=True,
|
|
states={
|
|
'invisible': ~Eval('component_parent'),
|
|
})
|
|
component_children = fields.One2Many(
|
|
prefix + '.line', 'component_parent', "Component Children",
|
|
readonly=True,
|
|
states={
|
|
'invisible': ~Eval('component_children'),
|
|
})
|
|
|
|
components = fields.One2Many(
|
|
prefix + '.line.component', 'line', "Components", readonly=True,
|
|
states={
|
|
'invisible': ~Eval('components', []),
|
|
})
|
|
|
|
@classmethod
|
|
def view_attributes(cls):
|
|
return super().view_attributes() + [
|
|
('//page[@id="components"]', 'states', {
|
|
'invisible': (
|
|
~Eval('components', [])
|
|
& ~Eval('component_children', [])),
|
|
}),
|
|
]
|
|
|
|
@classmethod
|
|
def copy(cls, lines, default=None):
|
|
if default is None:
|
|
default = {}
|
|
else:
|
|
default = default.copy()
|
|
default.setdefault('component_parent')
|
|
default.setdefault('component_children')
|
|
default.setdefault('components')
|
|
return super().copy(lines, default=default)
|
|
|
|
def get_component(self, component, **kwargs):
|
|
pool = Pool()
|
|
Component = pool.get(prefix + '.line.component')
|
|
line = component.get_line(
|
|
Component, self.quantity, self.unit, **kwargs)
|
|
line.fixed = component.fixed
|
|
if not line.fixed:
|
|
line.quantity_ratio = component.quantity
|
|
return line
|
|
|
|
def get_component_order_line(self, component, **values):
|
|
Line = self.__class__
|
|
line = component.get_line(
|
|
Line, self.quantity, self.unit, **values)
|
|
line.type = 'line'
|
|
line.on_change_product()
|
|
return line
|
|
|
|
def get_move(self, type_):
|
|
move = super().get_move(type_)
|
|
if self.components:
|
|
move = None
|
|
return move
|
|
|
|
def get_moves_exception(self, name):
|
|
exception = super().get_moves_exception(name)
|
|
if self.components:
|
|
exception = any(c.moves_exception for c in self.components)
|
|
return exception
|
|
|
|
def get_moves_progress(self, name):
|
|
progress = super().get_moves_progress(name)
|
|
if self.components:
|
|
if any(c.moves_progress is not None for c in self.components):
|
|
progress = 0.
|
|
n = 0
|
|
for component in self.components:
|
|
if component.moves_progress is not None:
|
|
progress += component.moves_progress
|
|
n += 1
|
|
progress = round(progress / n, 4)
|
|
else:
|
|
progress = None
|
|
return progress
|
|
|
|
def _get_invoice_line_quantity(self):
|
|
quantity = super()._get_invoice_line_quantity()
|
|
if (getattr(self, prefix).invoice_method == 'shipment'
|
|
and self.components):
|
|
ratio = min(c.get_moved_ratio() for c in self.components)
|
|
quantity = self.unit.round(self.quantity * ratio)
|
|
return quantity
|
|
return OrderLineMixin
|
|
|
|
|
|
def order_line_component_mixin(prefix):
|
|
class OrderLineComponentMixin(ModelStorage):
|
|
|
|
line = fields.Many2One(
|
|
prefix + '.line', "Line", required=True, ondelete='CASCADE',
|
|
domain=[
|
|
('product.type', '=', 'kit'),
|
|
])
|
|
line_state = fields.Function(
|
|
fields.Selection('get_line_states', "Line State"),
|
|
'on_change_with_line_state', searcher='search_line_state')
|
|
moves = fields.One2Many('stock.move', 'origin', 'Moves', readonly=True)
|
|
moves_ignored = fields.Many2Many(
|
|
prefix + '.line.component-ignored-stock.move',
|
|
'component', 'move', "Ignored Moves", readonly=True)
|
|
moves_recreated = fields.Many2Many(
|
|
prefix + '.line.component-recreated-stock.move',
|
|
'component', 'move', "Recreated Moves", readonly=True)
|
|
moves_exception = fields.Function(
|
|
fields.Boolean('Moves Exception'), 'get_moves_exception')
|
|
moves_progress = fields.Function(
|
|
fields.Float("Moves Progress", digits=(1, 4)),
|
|
'get_moves_progress')
|
|
quantity_ratio = fields.Float(
|
|
"Quantity Ratio", readonly=True,
|
|
states={
|
|
'required': ~Eval('fixed', False),
|
|
})
|
|
price_ratio = fields.Numeric(
|
|
"Price Ratio", readonly=True, required=True)
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super().__setup__()
|
|
cls.__access__.add('line')
|
|
|
|
@classmethod
|
|
def get_line_states(cls):
|
|
pool = Pool()
|
|
Line = pool.get(f'{prefix}.line')
|
|
return getattr(Line, f'get_{prefix}_states')()
|
|
|
|
@fields.depends('line', f'_parent_line.{prefix}_state')
|
|
def on_change_with_line_state(self, name=None):
|
|
if self.line:
|
|
return getattr(self.line, f'{prefix}_state')
|
|
|
|
@classmethod
|
|
def search_line_state(cls, name, clause):
|
|
return [(f'line.{prefix}_state', *clause[1:])]
|
|
|
|
@fields.depends('line', '_parent_line.product')
|
|
def on_change_with_parent_type(self, name=None):
|
|
if self.line and self.line.product:
|
|
return self.line.product.type
|
|
|
|
@classmethod
|
|
def set_price_ratio(cls, components, quantity):
|
|
"Set price ratio between components"
|
|
pool = Pool()
|
|
Uom = pool.get('product.uom')
|
|
list_prices = defaultdict(Decimal)
|
|
sum_ = 0
|
|
for component in components:
|
|
product = component.product
|
|
list_price = Uom.compute_price(
|
|
product.default_uom, product.list_price_used or 0,
|
|
component.unit)
|
|
if component.fixed:
|
|
list_price *= Decimal(str(component.quantity / quantity))
|
|
else:
|
|
list_price *= Decimal(str(component.quantity_ratio))
|
|
list_prices[component] = list_price
|
|
sum_ += list_price
|
|
|
|
for component in components:
|
|
if sum_:
|
|
ratio = list_prices[component] / sum_
|
|
else:
|
|
ratio = Decimal(1) / len(components)
|
|
if component.fixed:
|
|
ratio /= Decimal(str(component.quantity / quantity))
|
|
else:
|
|
ratio /= Decimal(str(component.quantity_ratio))
|
|
component.price_ratio = ratio
|
|
|
|
def get_move(self, type_):
|
|
raise NotImplementedError
|
|
|
|
def _get_shipped_quantity(self, shipment_type):
|
|
'Return the quantity already shipped'
|
|
pool = Pool()
|
|
Uom = pool.get('product.uom')
|
|
|
|
quantity = 0
|
|
skips = set(self.moves_recreated)
|
|
for move in self.moves:
|
|
if move not in skips:
|
|
quantity += Uom.compute_qty(
|
|
move.unit, move.quantity, self.unit)
|
|
return quantity
|
|
|
|
@property
|
|
def _move_remaining_quantity(self):
|
|
"Compute the remaining quantity to ship"
|
|
pool = Pool()
|
|
Uom = pool.get('product.uom')
|
|
ignored = set(self.moves_ignored)
|
|
quantity = abs(self.quantity)
|
|
for move in self.moves:
|
|
if move.state == 'done' or move in ignored:
|
|
quantity -= Uom.compute_qty(
|
|
move.unit, move.quantity, self.unit)
|
|
return quantity
|
|
|
|
def get_moves_exception(self, name):
|
|
skips = set(self.moves_ignored)
|
|
skips.update(self.moves_recreated)
|
|
return any(
|
|
m.state == 'cancelled' for m in self.moves if m not in skips)
|
|
|
|
def get_moves_progress(self, name):
|
|
progress = None
|
|
quantity = self._move_remaining_quantity
|
|
if quantity is not None and self.quantity:
|
|
progress = round(
|
|
(abs(self.quantity) - quantity) / abs(self.quantity), 4)
|
|
progress = max(0., min(1., progress))
|
|
return progress
|
|
|
|
def get_moved_ratio(self):
|
|
pool = Pool()
|
|
Uom = pool.get('product.uom')
|
|
|
|
quantity = 0
|
|
for move in self.moves:
|
|
if move.state != 'done':
|
|
continue
|
|
qty = Uom.compute_qty(move.unit, move.quantity, self.unit)
|
|
dest_type = self.line.to_location.type
|
|
if (move.to_location.type == dest_type
|
|
and move.from_location.type != dest_type):
|
|
quantity += qty
|
|
elif (move.from_location.type == dest_type
|
|
and move.to_location.type != dest_type):
|
|
quantity -= qty
|
|
if self.quantity < 0:
|
|
quantity *= -1
|
|
return quantity / self.quantity
|
|
|
|
def get_rec_name(self, name):
|
|
return super().get_rec_name(name) + (
|
|
' @ %s' % self.line.rec_name)
|
|
|
|
@classmethod
|
|
def search_rec_name(cls, name, clause):
|
|
return super().search_rec_name(name, clause) + [
|
|
('line.rec_name',) + tuple(clause[1:]),
|
|
]
|
|
return OrderLineComponentMixin
|
|
|
|
|
|
def handle_shipment_exception_mixin(prefix):
|
|
class HandleShipmentExceptionMixin:
|
|
def default_ask(self, fields):
|
|
values = super().default_ask(fields)
|
|
moves = values['domain_moves']
|
|
for line in self.record.lines:
|
|
for component in line.components:
|
|
skips = set(component.moves_ignored)
|
|
skips.update(component.moves_recreated)
|
|
for move in component.moves:
|
|
if move.state == 'cancelled' and move not in skips:
|
|
moves.append(move.id)
|
|
return values
|
|
|
|
def transition_handle(self):
|
|
pool = Pool()
|
|
Component = pool.get(prefix + '.line.component')
|
|
|
|
result = super().transition_handle()
|
|
|
|
to_write = []
|
|
for line in self.record.lines:
|
|
for component in line.components:
|
|
moves_ignored = []
|
|
moves_recreated = []
|
|
skips = set(component.moves_ignored)
|
|
skips.update(component.moves_recreated)
|
|
for move in component.moves:
|
|
if move not in self.ask.domain_moves or move in skips:
|
|
continue
|
|
if move in self.ask.recreate_moves:
|
|
moves_recreated.append(move.id)
|
|
else:
|
|
moves_ignored.append(move.id)
|
|
|
|
if moves_ignored or moves_recreated:
|
|
to_write.append([component])
|
|
to_write.append({
|
|
'moves_ignored': [('add', moves_ignored)],
|
|
'moves_recreated': [('add', moves_recreated)],
|
|
})
|
|
if to_write:
|
|
Component.write(*to_write)
|
|
return result
|
|
return HandleShipmentExceptionMixin
|
|
|
|
|
|
class AmendmentLineMixin:
|
|
__slots__ = ()
|
|
|
|
def _apply_line(self, record, line):
|
|
pool = Pool()
|
|
Uom = pool.get('product.uom')
|
|
super()._apply_line(record, line)
|
|
if line.components:
|
|
quantity = Uom.compute_qty(
|
|
line.unit, line.quantity,
|
|
line.product.default_uom, round=False)
|
|
for component in line.components:
|
|
if not component.fixed:
|
|
component.quantity = component.unit.round(
|
|
quantity * component.quantity_ratio)
|
|
line.components = line.components
|