# 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 decimal import Decimal from itertools import groupby from sql.functions import CharLength from trytond.i18n import gettext from trytond.model import ( ChatMixin, Index, ModelSQL, ModelView, Workflow, fields) from trytond.model.exceptions import AccessError from trytond.modules.company.model import CompanyValueMixin from trytond.modules.product import price_digits, round_price from trytond.pool import Pool, PoolMeta from trytond.pyson import Eval, Id from trytond.transaction import Transaction from trytond.wizard import Button, StateTransition, StateView, Wizard from .exceptions import NoShipmentWarning, SamePartiesWarning class Configuration(metaclass=PoolMeta): __name__ = 'account.configuration' shipment_cost_sequence = fields.MultiValue(fields.Many2One( 'ir.sequence', "Shipment Cost Sequence", required=True, domain=[ ('company', 'in', [Eval('context', {}).get('company', -1), None]), ('sequence_type', '=', Id('account_stock_shipment_cost', 'sequence_type_shipment_cost')), ])) @classmethod def default_shipment_cost_sequence(cls, **pattern): return cls.multivalue_model( 'shipment_cost_sequence').default_shipment_cost_sequence() class ConfigurationShipmentCostSequence(ModelSQL, CompanyValueMixin): __name__ = 'account.configuration.shipment_cost_sequence' shipment_cost_sequence = fields.Many2One( 'ir.sequence', "Shipment Cost Sequence", required=True, domain=[ ('company', 'in', [Eval('company', -1), None]), ('sequence_type', '=', Id('account_stock_shipment_cost', 'sequence_type_shipment_cost')), ]) @classmethod def default_shipment_cost_sequence(cls, **pattern): pool = Pool() ModelData = pool.get('ir.model.data') try: return ModelData.get_id( 'account_stock_shipment_cost', 'sequence_shipment_cost') except KeyError: return None class ShipmentCost(Workflow, ModelSQL, ModelView, ChatMixin): __name__ = 'account.shipment_cost' _rec_name = 'number' _states = { 'readonly': Eval('state') != 'draft', } number = fields.Char( "Number", readonly=True, help="The main identifier for the shipment cost.") company = fields.Many2One( 'company.company', "Company", required=True, states=_states, help="The company the shipment cost is associated with.") shipments = fields.Many2Many( 'account.shipment_cost-stock.shipment.out', 'shipment_cost', 'shipment', "Shipments", domain=[ ('company', '=', Eval('company', -1)), ('state', '=', 'done'), ], states=_states) shipment_returns = fields.Many2Many( 'account.shipment_cost-stock.shipment.out.return', 'shipment_cost', 'shipment', "Shipment Returns", domain=[ ('company', '=', Eval('company', -1)), ('state', 'in', ['received', 'done']), ], states=_states) invoice_lines = fields.One2Many( 'account.invoice.line', 'shipment_cost', 'Invoice Lines', add_remove=[ ('shipment_cost', '=', None), ], domain=[ ('company', '=', Eval('company', -1)), ('invoice.state', 'in', ['posted', 'paid']), ('invoice.type', '=', 'in'), ('product.shipment_cost', '=', True), ('type', '=', 'line'), ], states=_states) allocation_method = fields.Selection([ ('shipment', "By Shipment"), ], "Allocation Method", required=True, states=_states) posted_date = fields.Date("Posted Date", readonly=True) state = fields.Selection([ ('draft', "Draft"), ('posted', "Posted"), ('cancelled', "Cancelled"), ], "State", readonly=True, sort=False) factors = fields.Dict(None, "Factors", readonly=True) del _states @classmethod def __setup__(cls): cls.number.search_unaccented = False super().__setup__() t = cls.__table__() cls._sql_indexes.add( Index( t, (t.state, Index.Equality(cardinality='low')), where=t.state == 'draft')) cls._order.insert(0, ('number', 'DESC')) cls._transitions |= set(( ('draft', 'posted'), ('draft', 'cancelled'), ('posted', 'cancelled'), ('cancelled', 'draft'), )) cls._buttons.update({ 'cancel': { 'invisible': Eval('state') == 'cancelled', 'depends': ['state'], }, 'draft': { 'invisible': Eval('state') != 'cancelled', 'depends': ['state'], }, 'post_wizard': { 'invisible': Eval('state') != 'draft', 'depends': ['state'], }, 'show': { 'invisible': Eval('state').in_(['draft', 'cancelled']), 'depends': ['state'] }, }) @classmethod def order_number(cls, tables): table, _ = tables[None] return [CharLength(table.number), table.number] @classmethod def default_company(cls): return Transaction().context.get('company') @classmethod def default_allocation_method(cls): return 'shipment' @classmethod def default_state(cls): return 'draft' @classmethod @ModelView.button @Workflow.transition('cancelled') def cancel(cls, shipment_costs): for shipment_cost in shipment_costs: if shipment_cost.state == 'posted': getattr(shipment_cost, 'unallocate_cost_by_%s' % shipment_cost.allocation_method)() cls.write(shipment_costs, { 'posted_date': None, 'factors': None, 'state': 'cancelled', }) @classmethod @ModelView.button @Workflow.transition('draft') def draft(cls, shipment_costs): pass @property def cost(self): pool = Pool() Currency = pool.get('currency.currency') currency = self.company.currency cost = Decimal(0) for line in self.invoice_lines: with Transaction().set_context(date=line.invoice.currency_date): cost += Currency.compute( line.invoice.currency, line.amount, currency, round=False) return cost @property def all_shipments(self): return self.shipments + self.shipment_returns @property def parties(self): return {l.invoice.party for l in self.invoice_lines} def allocate_cost_by_shipment(self): self.factors = self._get_factors('shipment') self._allocate_cost(self.factors) def unallocate_cost_by_shipment(self): factors = self.factors or self._get_factors('shipment') self._allocate_cost(factors, sign=-1) def _get_factors(self, method=None): if method is None: method = self.allocation_method return getattr(self, '_get_%s_factors' % method)() def _get_shipment_factors(self): shipments = self.all_shipments length = Decimal(len(shipments)) factor = 1 / length return {str(shipment): factor for shipment in shipments} def _allocate_cost(self, factors, sign=1): "Allocate cost on shipments using factors" pool = Pool() Shipment = pool.get('stock.shipment.out') ShipmentReturn = pool.get('stock.shipment.out.return') assert sign in {1, -1} cost = self.cost * sign for shipments, klass in [ (list(self.shipments), Shipment), (list(self.shipment_returns), ShipmentReturn), ]: for shipment in shipments: try: factor = factors[str(shipment)] except KeyError: # Try with just id for backward compatibility factor = factors[str(shipment.id)] if (any(c.state == 'posted' for c in shipment.shipment_costs) and shipment.cost): shipment.cost += round_price(cost * factor) else: shipment.cost = round_price(cost * factor) if shipment.cost_currency != shipment.company.currency: shipment.cost_currency = shipment.company.currency klass.save(shipments) klass.set_shipment_cost(shipments) @classmethod @ModelView.button_action( 'account_stock_shipment_cost.wizard_shipment_cost_post') def post_wizard(cls, shipment_costs): pass @classmethod @ModelView.button_action( 'account_stock_shipment_cost.wizard_shipment_cost_show') def show(cls, shipment_costs): pass @classmethod @Workflow.transition('posted') def post(cls, shipment_costs): pool = Pool() Date = pool.get('ir.date') Warning = pool.get('res.user.warning') for shipment_cost in shipment_costs: parties = shipment_cost.parties all_shipments = shipment_cost.all_shipments if not all_shipments: key = Warning.format('post no shipment', [shipment_cost]) if Warning.check(key): raise NoShipmentWarning( key, gettext('account_stock_shipment_cost' '.msg_shipment_cost_post_no_shipment', shipment_cost=shipment_cost.rec_name)) for shipment in all_shipments: for other in shipment.shipment_costs: if other == shipment_cost: continue if other.parties & parties: key = Warning.format( 'post same parties', [shipment_cost]) if Warning.check(key): raise SamePartiesWarning( key, gettext('account_stock_shipment_cost' '.msg_shipment_cost_post_same_parties', shipment_cost=shipment_cost.rec_name, shipment=shipment.rec_name, other=other.rec_name)) else: break getattr(shipment_cost, 'allocate_cost_by_%s' % shipment_cost.allocation_method)() for company, c_shipment_costs in groupby( shipment_costs, key=lambda s: s.company): with Transaction().set_context(company=company.id): today = Date.today() cls.write(list(c_shipment_costs), { 'posted_date': today, 'state': 'posted', }) @classmethod def preprocess_values(cls, mode, values): pool = Pool() Config = pool.get('account.configuration') values = super().preprocess_values(mode, values) if values.get('number') is None: config = Config(1) company_id = values.get('company', cls.default_company()) if company_id is not None: if sequence := config.get_multivalue( 'shipment_cost_sequence', company=company_id): values['number'] = sequence.get() return values @classmethod def check_modification( cls, mode, shipment_costs, values=None, external=False): super().check_modification( mode, shipment_costs, values=values, external=external) if mode == 'delete': for shipment_cost in shipment_costs: if shipment_cost.state not in {'cancelled', 'draft'}: raise AccessError( gettext('account_stock_shipment_cost' '.msg_shipment_cost_delete_cancel', shipment_cost=shipment_cost.rec_name)) class ShipmentCost_Shipment(ModelSQL): __name__ = 'account.shipment_cost-stock.shipment.out' shipment_cost = fields.Many2One( 'account.shipment_cost', "Shipment Cost", required=True, ondelete='CASCADE') shipment = fields.Many2One( 'stock.shipment.out', "Shipment", required=True, ondelete='CASCADE') class ShipmentCost_ShipmentReturn(ModelSQL): __name__ = 'account.shipment_cost-stock.shipment.out.return' shipment_cost = fields.Many2One( 'account.shipment_cost', "Shipment Cost", required=True, ondelete='CASCADE') shipment = fields.Many2One( 'stock.shipment.out.return', "Shipment", required=True, ondelete='CASCADE') class ShowShipmentCostMixin(Wizard): start_state = 'show' show = StateView('account.shipment_cost.show', 'account_stock_shipment_cost.shipment_cost_show_view_form', []) @property def factors(self): return self.record._get_factors() def default_show(self, fields): shipments = [] cost = self.record.cost default = { 'cost': round_price(cost), 'shipments': shipments, } factors = self.factors for shipment in self.record.all_shipments: shipments.append({ 'shipment': str(shipment), 'cost': round_price(cost * factors[str(shipment)]), }) return default class PostShipmentCost(ShowShipmentCostMixin): __name__ = 'account.shipment_cost.post' post = StateTransition() @classmethod def __setup__(cls): super().__setup__() cls.show.buttons.extend([ Button("Cancel", 'end', 'tryton-cancel'), Button("Post", 'post', 'tryton-ok', default=True), ]) def transition_post(self): self.model.post([self.record]) return 'end' class ShowShipmentCost(ShowShipmentCostMixin): __name__ = 'account.shipment_cost.show' @classmethod def __setup__(cls): super().__setup__() cls.show.buttons.extend([ Button("Close", 'end', 'tryton-close', default=True), ]) @property def factors(self): return self.record.factors or super().factors class ShipmentCostShow(ModelView): __name__ = 'account.shipment_cost.show' cost = fields.Numeric("Cost", digits=price_digits, readonly=True) shipments = fields.One2Many( 'account.shipment_cost.show.shipment', None, "Shipments", readonly=True) class ShipmentCostShowShipment(ModelView): __name__ = 'account.shipment_cost.show.shipment' shipment = fields.Reference("Shipments", [ ('stock.shipment.out', "Shipment"), ('stock.shipment.out.return', "Shipment Return"), ], readonly=True) cost = fields.Numeric("Cost", digits=price_digits, readonly=True) class InvoiceLine(metaclass=PoolMeta): __name__ = 'account.invoice.line' shipment_cost = fields.Many2One( 'account.shipment_cost', "Shipment Cost", readonly=True, states={ 'invisible': ~Eval('shipment_cost'), }) @classmethod def __setup__(cls): super().__setup__() cls._check_modify_exclude.add('shipment_cost') @classmethod def copy(cls, lines, default=None): if default is None: default = {} else: default = default.copy() default.setdefault('shipment_cost', None) return super().copy(lines, default=default)