# 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