634 lines
22 KiB
Python
634 lines
22 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 itertools import groupby
|
|
|
|
from sql.conditionals import Coalesce
|
|
|
|
from trytond.i18n import gettext
|
|
from trytond.model import Index, ModelSQL, ModelView, Workflow, fields
|
|
from trytond.model.exceptions import AccessError
|
|
from trytond.modules.product import round_price
|
|
from trytond.modules.purchase.stock import process_purchase
|
|
from trytond.modules.sale.stock import process_sale
|
|
from trytond.modules.stock.shipment import ShipmentCheckQuantity, ShipmentMixin
|
|
from trytond.pool import Pool, PoolMeta
|
|
from trytond.pyson import Eval, Id, If
|
|
from trytond.transaction import Transaction
|
|
|
|
|
|
class Configuration(metaclass=PoolMeta):
|
|
__name__ = 'stock.configuration'
|
|
|
|
shipment_drop_sequence = fields.MultiValue(fields.Many2One(
|
|
'ir.sequence', "Drop Shipment Sequence", required=True,
|
|
domain=[
|
|
('company', 'in',
|
|
[Eval('context', {}).get('company', -1), None]),
|
|
('sequence_type', '=',
|
|
Id('sale_supply_drop_shipment',
|
|
'sequence_type_shipment_drop')),
|
|
]))
|
|
|
|
@classmethod
|
|
def multivalue_model(cls, field):
|
|
pool = Pool()
|
|
if field == 'shipment_drop_sequence':
|
|
return pool.get('stock.configuration.sequence')
|
|
return super().multivalue_model(field)
|
|
|
|
@classmethod
|
|
def default_shipment_drop_sequence(cls, **pattern):
|
|
return cls.multivalue_model(
|
|
'shipment_drop_sequence').default_shipment_drop_sequence()
|
|
|
|
|
|
class ConfigurationSequence(metaclass=PoolMeta):
|
|
__name__ = 'stock.configuration.sequence'
|
|
shipment_drop_sequence = fields.Many2One(
|
|
'ir.sequence', "Drop Shipment Sequence", required=True,
|
|
domain=[
|
|
('company', 'in', [Eval('company', -1), None]),
|
|
('sequence_type', '=',
|
|
Id('sale_supply_drop_shipment',
|
|
'sequence_type_shipment_drop')),
|
|
])
|
|
|
|
@classmethod
|
|
def default_shipment_drop_sequence(cls):
|
|
pool = Pool()
|
|
ModelData = pool.get('ir.model.data')
|
|
try:
|
|
return ModelData.get_id(
|
|
'sale_supply_drop_shipment', 'sequence_shipment_drop')
|
|
except KeyError:
|
|
return None
|
|
|
|
|
|
class ShipmentDrop(
|
|
ShipmentCheckQuantity, ShipmentMixin, Workflow, ModelSQL, ModelView):
|
|
__name__ = 'stock.shipment.drop'
|
|
|
|
company = fields.Many2One('company.company', 'Company', required=True,
|
|
states={
|
|
'readonly': Eval('state') != 'draft',
|
|
})
|
|
supplier = fields.Many2One('party.party', 'Supplier', required=True,
|
|
states={
|
|
'readonly': (((Eval('state') != 'draft')
|
|
| Eval('supplier_moves', [0]))
|
|
& Eval('supplier')),
|
|
},
|
|
context={
|
|
'company': Eval('company', -1),
|
|
},
|
|
depends={'company'})
|
|
contact_address = fields.Many2One('party.address', 'Contact Address',
|
|
states={
|
|
'readonly': Eval('state') != 'draft',
|
|
},
|
|
domain=[('party', '=', Eval('supplier', -1))])
|
|
customer = fields.Many2One('party.party', 'Customer', required=True,
|
|
states={
|
|
'readonly': (((Eval('state') != 'draft')
|
|
| Eval('customer_moves', [0]))
|
|
& Eval('customer')),
|
|
},
|
|
context={
|
|
'company': Eval('company', -1),
|
|
},
|
|
depends={'company'})
|
|
delivery_address = fields.Many2One('party.address', 'Delivery Address',
|
|
required=True,
|
|
states={
|
|
'readonly': Eval('state') != 'draft',
|
|
},
|
|
domain=[('party', '=', Eval('customer', -1))])
|
|
moves = fields.One2Many('stock.move', 'shipment', 'Moves',
|
|
domain=[
|
|
('company', '=', Eval('company', -1)),
|
|
['OR',
|
|
[
|
|
('from_location.type', '=', 'supplier'),
|
|
('to_location.type', '=', 'drop'),
|
|
],
|
|
[
|
|
('from_location.type', '=', 'drop'),
|
|
('to_location.type', '=', 'customer'),
|
|
],
|
|
],
|
|
],
|
|
readonly=True)
|
|
supplier_moves = fields.One2Many('stock.move', 'shipment',
|
|
'Supplier Moves',
|
|
filter=[('to_location.type', '=', 'drop')],
|
|
states={
|
|
'readonly': Eval('state').in_(['shipped', 'done', 'cancelled']),
|
|
},
|
|
depends={'supplier'})
|
|
customer_moves = fields.One2Many('stock.move', 'shipment',
|
|
'Customer Moves',
|
|
filter=[('from_location.type', '=', 'drop')],
|
|
states={
|
|
'readonly': Eval('state') != 'shipped',
|
|
},
|
|
depends={'customer'})
|
|
state = fields.Selection([
|
|
('draft', 'Draft'),
|
|
('waiting', 'Waiting'),
|
|
('shipped', 'Shipped'),
|
|
('done', 'Done'),
|
|
('cancelled', 'Cancelled'),
|
|
], "State", readonly=True, sort=False)
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super().__setup__()
|
|
t = cls.__table__()
|
|
cls._sql_indexes.update({
|
|
Index(
|
|
t, (t.state, Index.Equality(cardinality='low')),
|
|
where=t.state.in_(['draft', 'waiting', 'shipped'])),
|
|
})
|
|
cls._order = [
|
|
('effective_date', 'ASC NULLS LAST'),
|
|
('id', 'DESC'),
|
|
]
|
|
cls._transitions |= set((
|
|
('draft', 'waiting'),
|
|
('waiting', 'shipped'),
|
|
('draft', 'cancelled'),
|
|
('waiting', 'cancelled'),
|
|
('waiting', 'draft'),
|
|
('waiting', 'waiting'),
|
|
('cancelled', 'draft'),
|
|
('shipped', 'done'),
|
|
('shipped', 'cancelled'),
|
|
('done', 'cancelled'),
|
|
))
|
|
cls._buttons.update({
|
|
'cancel': {
|
|
'invisible': Eval('state').in_(['cancelled', 'done']),
|
|
'depends': ['state'],
|
|
},
|
|
'draft': {
|
|
'invisible': ~Eval('state').in_(['cancelled', 'draft',
|
|
'waiting']),
|
|
'icon': If(Eval('state') == 'cancelled',
|
|
'tryton-undo', 'tryton-back'),
|
|
'depends': ['state'],
|
|
},
|
|
'wait': {
|
|
'invisible': Eval('state') != 'draft',
|
|
'depends': ['state'],
|
|
},
|
|
'ship': {
|
|
'invisible': Eval('state') != 'waiting',
|
|
'depends': ['state'],
|
|
},
|
|
'do': {
|
|
'invisible': Eval('state') != 'shipped',
|
|
'depends': ['state'],
|
|
},
|
|
})
|
|
|
|
@classmethod
|
|
def order_effective_date(cls, tables):
|
|
table, _ = tables[None]
|
|
return [Coalesce(table.effective_date, table.planned_date)]
|
|
|
|
@staticmethod
|
|
def default_state():
|
|
return 'draft'
|
|
|
|
@staticmethod
|
|
def default_company():
|
|
return Transaction().context.get('company')
|
|
|
|
@fields.depends('supplier')
|
|
def on_change_supplier(self):
|
|
if self.supplier:
|
|
self.contact_address = self.supplier.address_get()
|
|
else:
|
|
self.contact_address = None
|
|
|
|
@fields.depends('customer')
|
|
def on_change_customer(self):
|
|
if self.customer:
|
|
self.delivery_address = self.customer.address_get(type='delivery')
|
|
else:
|
|
self.delivery_address = None
|
|
|
|
def _get_move_planned_date(self):
|
|
'''
|
|
Return the planned date for moves
|
|
'''
|
|
return self.planned_date
|
|
|
|
@classmethod
|
|
def _set_move_planned_date(cls, shipments):
|
|
'''
|
|
Set planned date of moves for the shipments
|
|
'''
|
|
Move = Pool().get('stock.move')
|
|
to_write = []
|
|
for shipment in shipments:
|
|
planned_date = shipment._get_move_planned_date()
|
|
to_write.extend(([m for m in shipment.moves
|
|
if m.state not in ('assigned', 'done', 'cancelled')
|
|
], {
|
|
'planned_date': planned_date,
|
|
}))
|
|
if to_write:
|
|
Move.write(*to_write)
|
|
|
|
@classmethod
|
|
def preprocess_values(cls, mode, values):
|
|
pool = Pool()
|
|
Configuration = pool.get('stock.configuration')
|
|
values = super().preprocess_values(mode, values)
|
|
if mode == 'create' and not values.get('number'):
|
|
company_id = values.get('company', cls.default_company())
|
|
if company_id is not None:
|
|
configuration = Configuration(1)
|
|
if sequence := configuration.get_multivalue(
|
|
'shipment_drop_sequence', company=company_id):
|
|
values['number'] = sequence.get()
|
|
return values
|
|
|
|
@classmethod
|
|
def copy(cls, shipments, default=None):
|
|
if default is None:
|
|
default = {}
|
|
else:
|
|
default = default.copy()
|
|
default.setdefault('moves', None)
|
|
default.setdefault('supplier_moves', None)
|
|
default.setdefault('customer_moves', None)
|
|
return super().copy(shipments, default=default)
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('cancelled')
|
|
@process_sale('customer_moves')
|
|
@process_purchase('supplier_moves')
|
|
def cancel(cls, shipments):
|
|
Move = Pool().get('stock.move')
|
|
Move.cancel([
|
|
m for s in shipments
|
|
for m in s.supplier_moves + s.customer_moves])
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('draft')
|
|
def draft(cls, shipments):
|
|
pool = Pool()
|
|
Move = pool.get('stock.move')
|
|
PurchaseLine = pool.get('purchase.line')
|
|
SaleLine = pool.get('sale.line')
|
|
for shipment in shipments:
|
|
for move in shipment.moves:
|
|
if (move.state == 'cancelled'
|
|
and isinstance(move.origin, (PurchaseLine, SaleLine))):
|
|
raise AccessError(
|
|
gettext('sale_supply_drop_shipment.msg_reset_move',
|
|
move=move.rec_name))
|
|
Move.draft([m for s in shipments for m in s.moves
|
|
if m.state != 'staging'])
|
|
|
|
@classmethod
|
|
def _synchronize_moves(cls, shipments):
|
|
pool = Pool()
|
|
Uom = pool.get('product.uom')
|
|
Move = pool.get('stock.move')
|
|
|
|
def active(move):
|
|
return move.state != 'cancelled'
|
|
|
|
moves = []
|
|
for shipment in shipments:
|
|
customer_moves = {m: m for m in shipment.customer_moves}
|
|
supplier_qty = defaultdict(lambda: defaultdict(int))
|
|
|
|
for move in filter(active, shipment.customer_moves):
|
|
key = shipment._sync_move_key(move)
|
|
supplier_qty[move][key] = 0
|
|
for move in filter(active, shipment.supplier_moves):
|
|
key = shipment._sync_move_key(move)
|
|
qty_default_uom = Uom.compute_qty(
|
|
move.unit, move.quantity,
|
|
move.product.default_uom, round=False)
|
|
for customer_move in move.moves_drop:
|
|
customer_move = customer_moves.get(customer_move)
|
|
if customer_move.unit.category != move.unit.category:
|
|
continue
|
|
c_qty_default_uom = Uom.compute_qty(
|
|
customer_move.unit, customer_move.quantity,
|
|
customer_move.product.default_uom, round=False)
|
|
qty = min(qty_default_uom, c_qty_default_uom)
|
|
supplier_qty[customer_move][key] += qty
|
|
qty_default_uom -= qty
|
|
if qty_default_uom <= 0:
|
|
break
|
|
else:
|
|
supplier_qty[None][key] += qty_default_uom
|
|
|
|
for customer_move in supplier_qty:
|
|
if customer_move:
|
|
customer_key = shipment._sync_move_key(customer_move)
|
|
for key, qty in supplier_qty[customer_move].items():
|
|
if customer_move and key == customer_key:
|
|
move = customer_move
|
|
else:
|
|
move = shipment._sync_customer_move(customer_move)
|
|
for name, value in key:
|
|
setattr(move, name, value)
|
|
qty = Uom.compute_qty(
|
|
move.product.default_uom, qty, move.unit)
|
|
if move.quantity != qty:
|
|
move.quantity = qty
|
|
moves.append(move)
|
|
Move.save(moves)
|
|
|
|
def _sync_move_key(self, move):
|
|
return (
|
|
('product', move.product),
|
|
('unit', move.unit),
|
|
)
|
|
|
|
def _sync_customer_move(self, template=None):
|
|
pool = Pool()
|
|
Move = pool.get('stock.move')
|
|
Purchase = pool.get('purchase.purchase')
|
|
move = Move(
|
|
from_location=Purchase.default_drop_location(),
|
|
to_location=self.customer.customer_location,
|
|
quantity=0,
|
|
shipment=self,
|
|
planned_date=self.planned_date,
|
|
company=self.company,
|
|
)
|
|
if template:
|
|
move.from_location = template.from_location
|
|
move.to_location = template.to_location
|
|
move.origin = template.origin
|
|
move.origin_drop = template.origin_drop
|
|
if move.on_change_with_unit_price_required():
|
|
if template:
|
|
move.unit_price = template.unit_price
|
|
move.currency = template.currency
|
|
else:
|
|
move.unit_price = 0
|
|
move.currency = self.company.currency
|
|
else:
|
|
move.unit_price = None
|
|
move.currency = None
|
|
return move
|
|
|
|
@classmethod
|
|
def set_cost(cls, shipments):
|
|
pool = Pool()
|
|
Move = pool.get('stock.move')
|
|
UoM = pool.get('product.uom')
|
|
|
|
to_save = []
|
|
for shipment in shipments:
|
|
product_cost = defaultdict(int)
|
|
s_product_qty = defaultdict(int)
|
|
for s_move in shipment.supplier_moves:
|
|
if s_move.state == 'cancelled':
|
|
continue
|
|
internal_quantity = Decimal(str(s_move.internal_quantity))
|
|
product_cost[s_move.product] += (
|
|
s_move.get_cost_price() * internal_quantity)
|
|
|
|
quantity = UoM.compute_qty(
|
|
s_move.unit, s_move.quantity, s_move.product.default_uom,
|
|
round=False)
|
|
s_product_qty[s_move.product] += quantity
|
|
|
|
for product, cost in product_cost.items():
|
|
qty = Decimal(str(s_product_qty[product]))
|
|
if qty:
|
|
product_cost[product] = round_price(cost / qty)
|
|
|
|
for move in shipment.moves:
|
|
cost_price = product_cost[move.product]
|
|
if cost_price != move.cost_price:
|
|
move.cost_price = cost_price
|
|
to_save.append(move)
|
|
if to_save:
|
|
Move.save(to_save)
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('waiting')
|
|
def wait(cls, shipments):
|
|
pool = Pool()
|
|
PurchaseLine = pool.get('purchase.line')
|
|
Move = pool.get('stock.move')
|
|
|
|
to_save = []
|
|
for shipment in shipments:
|
|
for s_move in shipment.supplier_moves:
|
|
if not isinstance(s_move.origin, PurchaseLine):
|
|
continue
|
|
p_line = s_move.origin
|
|
for request in p_line.requests:
|
|
for sale_line in request.sale_lines:
|
|
for c_move in sale_line.moves:
|
|
if (c_move.state not in {'cancelled', 'done'}
|
|
and not c_move.shipment
|
|
and c_move.from_location.type == 'drop'):
|
|
c_move.shipment = shipment
|
|
c_move.origin_drop = s_move
|
|
to_save.append(c_move)
|
|
Move.save(to_save)
|
|
Move.draft(to_save)
|
|
cls._synchronize_moves(shipments)
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('shipped')
|
|
@process_sale('customer_moves')
|
|
@process_purchase('supplier_moves')
|
|
def ship(cls, shipments):
|
|
pool = Pool()
|
|
Move = pool.get('stock.move')
|
|
Move.do([m for s in shipments for m in s.supplier_moves])
|
|
cls._synchronize_moves(shipments)
|
|
to_assign, to_delete = [], []
|
|
for shipment in shipments:
|
|
for move in shipment.customer_moves:
|
|
if move.quantity:
|
|
to_assign.append(move)
|
|
else:
|
|
to_delete.append(move)
|
|
Move.delete(to_delete)
|
|
Move.assign(to_assign)
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('done')
|
|
@process_sale('customer_moves')
|
|
def do(cls, shipments):
|
|
pool = Pool()
|
|
Move = pool.get('stock.move')
|
|
Date = pool.get('ir.date')
|
|
cls.set_cost(shipments)
|
|
customer_moves, to_delete = [], []
|
|
for shipment in shipments:
|
|
shipment.check_quantity()
|
|
for move in shipment.customer_moves:
|
|
if move.quantity:
|
|
customer_moves.append(move)
|
|
else:
|
|
to_delete.append(move)
|
|
Move.delete(to_delete)
|
|
Move.do(customer_moves)
|
|
for company, shipments in groupby(shipments, key=lambda s: s.company):
|
|
with Transaction().set_context(company=company.id):
|
|
today = Date.today()
|
|
cls.write([s for s in shipments if not s.effective_date], {
|
|
'effective_date': today,
|
|
})
|
|
|
|
@property
|
|
def _check_quantity_source_moves(self):
|
|
return self.supplier_moves
|
|
|
|
@property
|
|
def _check_quantity_target_moves(self):
|
|
return self.customer_moves
|
|
|
|
|
|
class ShipmentDropSplit(metaclass=PoolMeta):
|
|
__name__ = 'stock.shipment.drop'
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super().__setup__()
|
|
cls._buttons.update({
|
|
'split_wizard': {
|
|
'readonly': Eval('state') != 'draft',
|
|
'invisible': Eval('state') != 'draft',
|
|
'depends': ['state'],
|
|
},
|
|
})
|
|
|
|
@classmethod
|
|
@ModelView.button_action('stock_split.wizard_split_shipment')
|
|
def split_wizard(cls, shipments):
|
|
pass
|
|
|
|
|
|
class SplitShipment(metaclass=PoolMeta):
|
|
__name__ = 'stock.shipment.split'
|
|
|
|
def get_moves(self, shipment):
|
|
moves = super().get_moves(shipment)
|
|
if shipment.__name__ == 'stock.shipment.drop':
|
|
moves = shipment.supplier_moves
|
|
return moves
|
|
|
|
def transition_split(self):
|
|
shipment = self.record
|
|
if shipment.__name__ == 'stock.shipment.drop':
|
|
customer_moves = []
|
|
for move in self.start.moves:
|
|
customer_moves.extend(move.moves_drop)
|
|
self.start.moves = [*self.start.moves, *customer_moves]
|
|
self.start.domain_moves = [
|
|
*self.start.domain_moves, *customer_moves]
|
|
return super().transition_split()
|
|
|
|
|
|
class Move(metaclass=PoolMeta):
|
|
__name__ = 'stock.move'
|
|
|
|
origin_drop = fields.Many2One(
|
|
'stock.move', "Drop Origin", readonly=True,
|
|
domain=[
|
|
('shipment', '=', Eval('shipment', -1)),
|
|
],
|
|
states={
|
|
'invisible': ~Eval('origin_drop'),
|
|
})
|
|
moves_drop = fields.One2Many(
|
|
'stock.move', 'origin_drop', "Drop Moves", readonly=True,
|
|
states={
|
|
'invisible': ~Eval('moves_drop'),
|
|
})
|
|
customer_drop = fields.Function(fields.Many2One(
|
|
'party.party', "Drop Customer",
|
|
context={
|
|
'company': Eval('company', -1),
|
|
},
|
|
depends={'company'}),
|
|
'get_customer_drop',
|
|
searcher='search_customer_drop')
|
|
|
|
@classmethod
|
|
def _get_shipment(cls):
|
|
models = super()._get_shipment()
|
|
models.append('stock.shipment.drop')
|
|
return models
|
|
|
|
def get_customer_drop(self, name):
|
|
pool = Pool()
|
|
SaleLine = pool.get('sale.line')
|
|
PurchaseLine = pool.get('purchase.line')
|
|
|
|
if isinstance(self.origin, SaleLine):
|
|
return self.origin.sale.party.id
|
|
elif isinstance(self.origin, PurchaseLine):
|
|
if self.origin.purchase.customer:
|
|
return self.origin.purchase.customer.id
|
|
|
|
@classmethod
|
|
def search_customer_drop(cls, name, clause):
|
|
return ['OR',
|
|
('origin.sale.party' + clause[0][len(name):],
|
|
*clause[1:3], 'sale.line', *clause[3:]),
|
|
('origin.purchase.customer' + clause[0][len(name):],
|
|
*clause[1:3], 'purchase.line', *clause[3:]),
|
|
]
|
|
|
|
@classmethod
|
|
def copy(cls, moves, default=None):
|
|
context = Transaction().context
|
|
if (context.get('_stock_move_split')
|
|
and not context.get('_stock_move_split_drop')):
|
|
for move in moves:
|
|
if move.moves_drop:
|
|
raise AccessError(
|
|
gettext('sale_supply_drop_shipment'
|
|
'.msg_move_split_drop'))
|
|
default = default.copy() if default is not None else {}
|
|
default.setdefault('moves_drop')
|
|
return super().copy(moves, default=default)
|
|
|
|
|
|
class MoveSplit(metaclass=PoolMeta):
|
|
__name__ = 'stock.move'
|
|
|
|
def split(self, quantity, unit, count=None):
|
|
with Transaction().set_context(_stock_move_split_drop=True):
|
|
moves = super().split(quantity, unit, count=count)
|
|
if self.moves_drop:
|
|
to_save = []
|
|
moves_drop = list(self.moves_drop)
|
|
for move in moves:
|
|
remainder = move.quantity
|
|
while remainder > 0 and moves_drop:
|
|
move_drop = moves_drop.pop(0)
|
|
splits = move_drop.split(remainder, move.unit, count=1)
|
|
move_drop.origin_drop = move
|
|
remainder -= move_drop.quantity
|
|
to_save.append(move_drop)
|
|
splits.remove(move_drop)
|
|
moves_drop.extend(splits)
|
|
self.__class__.save(to_save)
|
|
return moves
|