diff --git a/modules/purchase_trade/duplicate.py b/modules/purchase_trade/duplicate.py index 7e7d9a2..16a71e0 100644 --- a/modules/purchase_trade/duplicate.py +++ b/modules/purchase_trade/duplicate.py @@ -64,6 +64,66 @@ class TradeCustomDuplicateStart(ModelView): Bool(Eval('create_matched_mirror')) & (Eval('mirror_mode') == 'new_mirror')), }) + mirror_currency = fields.Many2One('currency.currency', "Mirror Currency", + states={ + 'invisible': ~Bool(Eval('create_matched_mirror')), + 'required': Bool(Eval('create_matched_mirror')), + }) + mirror_payment_term = fields.Many2One( + 'account.invoice.payment_term', "Mirror Payment Term", + states={ + 'invisible': ~Bool(Eval('create_matched_mirror')), + 'required': Bool(Eval('create_matched_mirror')), + }) + mirror_incoterm = fields.Many2One('incoterm.incoterm', "Mirror Incoterm", + states={ + 'invisible': ~Bool(Eval('create_matched_mirror')), + 'required': Bool(Eval('create_matched_mirror')), + }) + mirror_quantity = fields.Numeric("Mirror Contractual Quantity", + digits='unit', + states={ + 'invisible': ~Bool(Eval('create_matched_mirror')), + 'required': Bool(Eval('create_matched_mirror')), + }) + mirror_unit = fields.Many2One('product.uom', "Mirror Unit", + readonly=True, + states={ + 'invisible': ~Bool(Eval('create_matched_mirror')), + 'required': Bool(Eval('create_matched_mirror')), + }) + mirror_unit_price = fields.Numeric("Mirror Price", digits=(16, 4), + states={ + 'invisible': ~Bool(Eval('create_matched_mirror')), + 'required': Bool(Eval('create_matched_mirror')), + }) + mirror_price_type = fields.Selection(PRICE_TYPES, "Mirror Price Type", + states={ + 'invisible': ~Bool(Eval('create_matched_mirror')), + 'required': Bool(Eval('create_matched_mirror')), + }) + mirror_clear_fees = fields.Boolean("Mirror Clear Fees", + states={ + 'invisible': ~Bool(Eval('create_matched_mirror')), + }) + mirror_clear_pricing_components = fields.Boolean( + "Mirror Clear Pricing Components", + states={ + 'invisible': ~Bool(Eval('create_matched_mirror')), + }) + + @fields.depends('source', 'mirror_mode') + def on_change_mirror_mode(self): + record = TradeCustomDuplicate._reference_record(self.source) + if not record or not self.mirror_mode: + return + line = TradeCustomDuplicate._trade_line(record) + if not line: + return + defaults = TradeCustomDuplicate._mirror_defaults( + record, line, self.mirror_mode, strict=False, as_ids=False) + for field, value in defaults.items(): + setattr(self, field, value) class TradeCustomDuplicate(Wizard): @@ -101,6 +161,15 @@ class TradeCustomDuplicate(Wizard): def _opposite_type(source_model): return 'Sale' if source_model == 'purchase.purchase' else 'Purchase' + @staticmethod + def _reference_record(reference): + if not reference: + return None + if isinstance(reference, str): + model, record_id = reference.split(',', 1) + return Pool().get(model)(int(record_id)) + return reference + def default_start(self, fields): if len(self.records) != 1: raise UserError("Please select a single purchase or sale.") @@ -113,6 +182,8 @@ class TradeCustomDuplicate(Wizard): matched_record = self._matched_counterpart_record( record, line, strict=False) mirror_mode = 'duplicate_pair' if matched_record else 'new_mirror' + mirror_defaults = self._mirror_defaults( + record, line, mirror_mode, strict=False) return { 'source_model': source_model, @@ -132,9 +203,57 @@ class TradeCustomDuplicate(Wizard): 'clear_pricing_components': False, 'create_matched_mirror': False, 'mirror_mode': mirror_mode, - 'mirror_party': ( - matched_record.party.id - if matched_record and matched_record.party else None), + **mirror_defaults, + } + + @staticmethod + def _field_id(record, field): + value = getattr(record, field, None) + return value.id if value else None + + @staticmethod + def _field_value(record, field, as_ids=True): + value = getattr(record, field, None) + if as_ids: + return value.id if value else None + return value + + @staticmethod + def _line_quantity_value(line): + return ( + getattr(line, 'quantity_theorical', None) + or getattr(line, 'quantity', None)) + + @staticmethod + def _mirror_defaults(record, line, mirror_mode, strict=True, as_ids=True): + mirror_record = record + mirror_line = line + if mirror_mode == 'duplicate_pair': + mirror_line, mirror_record = TradeCustomDuplicate._matched_counterpart( + line, record=record, strict=strict) + if not mirror_line or not mirror_record: + mirror_record = record + mirror_line = line + return { + 'mirror_party': TradeCustomDuplicate._field_value( + mirror_record, 'party', as_ids=as_ids), + 'mirror_currency': TradeCustomDuplicate._field_value( + mirror_record, 'currency', as_ids=as_ids), + 'mirror_payment_term': TradeCustomDuplicate._field_value( + mirror_record, 'payment_term', as_ids=as_ids), + 'mirror_incoterm': TradeCustomDuplicate._field_value( + mirror_record, 'incoterm', as_ids=as_ids), + 'mirror_quantity': TradeCustomDuplicate._line_quantity_value( + mirror_line), + 'mirror_unit': ( + mirror_line.unit.id if ( + as_ids and getattr(mirror_line, 'unit', None)) + else getattr(mirror_line, 'unit', None)), + 'mirror_unit_price': getattr(mirror_line, 'unit_price', None), + 'mirror_price_type': ( + getattr(mirror_line, 'price_type', None) or 'priced'), + 'mirror_clear_fees': False, + 'mirror_clear_pricing_components': False, } @staticmethod @@ -152,6 +271,40 @@ class TradeCustomDuplicate(Wizard): new_record.save() return Model(new_record.id) + @staticmethod + def _mirror_options(options, fallback_record=None, fallback_line=None): + return SimpleNamespace( + party=( + options.mirror_party + or getattr(fallback_record, 'party', None)), + currency=( + options.mirror_currency + or getattr(fallback_record, 'currency', None)), + payment_term=( + options.mirror_payment_term + or getattr(fallback_record, 'payment_term', None)), + incoterm=( + options.mirror_incoterm + or getattr(fallback_record, 'incoterm', None)), + quantity=( + options.mirror_quantity + or TradeCustomDuplicate._line_quantity_value(fallback_line)), + unit=( + options.mirror_unit + or getattr(fallback_line, 'unit', None)), + unit_price=( + options.mirror_unit_price + if options.mirror_unit_price is not None + else getattr(fallback_line, 'unit_price', None)), + price_type=( + options.mirror_price_type + or getattr(fallback_line, 'price_type', None) + or 'priced'), + clear_fees=options.mirror_clear_fees, + clear_pricing_components=( + options.mirror_clear_pricing_components), + ) + @staticmethod def _apply_party_addresses(record, party): addresses = list(getattr(party, 'addresses', None) or []) @@ -482,20 +635,9 @@ class TradeCustomDuplicate(Wizard): @staticmethod def _pair_duplicate_options(matched_record, matched_line, options): - return SimpleNamespace( - party=options.mirror_party or matched_record.party, - currency=matched_record.currency or options.currency, - payment_term=( - matched_record.payment_term or options.payment_term), - incoterm=matched_record.incoterm or options.incoterm, - quantity=options.quantity, - unit_price=getattr(matched_line, 'unit_price', None), - price_type=( - getattr(matched_line, 'price_type', None) - or options.price_type), - clear_fees=options.clear_fees, - clear_pricing_components=options.clear_pricing_components, - ) + return TradeCustomDuplicate._mirror_options( + options, fallback_record=matched_record, + fallback_line=matched_line) @staticmethod def _match_duplicate_pair_lots(source_line, source_lot, counterpart_line, @@ -552,18 +694,20 @@ class TradeCustomDuplicate(Wizard): if source_lot and counterpart_lot: TradeCustomDuplicate._match_duplicate_pair_lots( new_line, source_lot, new_matched_line, counterpart_lot, - options.quantity) + mirror_options.quantity) return new_matched_record @staticmethod def _create_matched_mirror(record, line, options): + mirror_options = TradeCustomDuplicate._mirror_options( + options, fallback_record=record, fallback_line=line) source_model = record.__name__ mirror_type = TradeCustomDuplicate._opposite_type(source_model) source_lot = TradeCustomDuplicate._first_virtual_lot(line) if not source_lot: raise UserError( "The duplicated line has no virtual lot to match.") - if not options.unit: + if not mirror_options.unit: raise UserError( "The duplicated line has no unit for mirror contract creation.") @@ -574,15 +718,16 @@ class TradeCustomDuplicate(Wizard): record, mirror_type) detail = ContractDetail() - detail.party = options.mirror_party - detail.currency = options.currency - detail.currency_unit = "%s_%s" % (options.currency.id, options.unit.id) - detail.incoterm = options.incoterm - detail.payment_term = options.payment_term - detail.quantity = options.quantity - detail.unit = options.unit - detail.price = options.unit_price - detail.price_type = options.price_type + detail.party = mirror_options.party + detail.currency = mirror_options.currency + detail.currency_unit = "%s_%s" % ( + mirror_options.currency.id, mirror_options.unit.id) + detail.incoterm = mirror_options.incoterm + detail.payment_term = mirror_options.payment_term + detail.quantity = mirror_options.quantity + detail.unit = mirror_options.unit + detail.price = mirror_options.unit_price + detail.price_type = mirror_options.price_type detail.reference = getattr(record, 'reference', None) detail.from_location = from_location detail.to_location = to_location @@ -595,8 +740,8 @@ class TradeCustomDuplicate(Wizard): ct.type = mirror_type ct.matched = True ct.product = line.product - ct.unit = line.unit - ct.quantity = options.quantity + ct.unit = mirror_options.unit + ct.quantity = mirror_options.quantity ct.lot = source_lot with Transaction().set_context(active_ids=[]): diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index f3da85b..6009c52 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -6207,9 +6207,24 @@ class PurchaseTradeTestCase(ModuleTestCase): 'custom duplicate can default mirror party from the matched sale' purchase_party = SimpleNamespace(id=1) sale_party = SimpleNamespace(id=2) + sale_currency = SimpleNamespace(id=30) + sale_payment_term = SimpleNamespace(id=31) + sale_incoterm = SimpleNamespace(id=32) + sale_unit = SimpleNamespace(id=33) purchase_lot = SimpleNamespace(id=10) - sale = SimpleNamespace(party=sale_party) - sale_line = SimpleNamespace(id=20, sale=sale) + sale = SimpleNamespace( + party=sale_party, + currency=sale_currency, + payment_term=sale_payment_term, + incoterm=sale_incoterm) + sale_line = SimpleNamespace( + id=20, + sale=sale, + unit=sale_unit, + quantity=Decimal('99'), + quantity_theorical=Decimal('99'), + unit_price=Decimal('52'), + price_type='basis') lot_qt = SimpleNamespace(lot_s=SimpleNamespace(sale_line=sale_line)) class FakeLotQt: @@ -6248,6 +6263,110 @@ class PurchaseTradeTestCase(ModuleTestCase): self.assertEqual(values['mirror_mode'], 'duplicate_pair') self.assertEqual(values['mirror_party'], sale_party.id) + self.assertEqual(values['mirror_currency'], sale_currency.id) + self.assertEqual(values['mirror_payment_term'], sale_payment_term.id) + self.assertEqual(values['mirror_incoterm'], sale_incoterm.id) + self.assertEqual(values['mirror_quantity'], Decimal('99')) + self.assertEqual(values['mirror_unit'], sale_unit.id) + self.assertEqual(values['mirror_unit_price'], Decimal('52')) + self.assertEqual(values['mirror_price_type'], 'basis') + + def test_custom_duplicate_defaults_new_mirror_from_source_purchase(self): + 'new mirror options default from the duplicated source contract' + purchase_party = SimpleNamespace(id=1) + purchase_currency = SimpleNamespace(id=4) + purchase_payment_term = SimpleNamespace(id=5) + purchase_incoterm = SimpleNamespace(id=6) + unit = SimpleNamespace(id=3) + + class FakeLotQt: + @classmethod + def search(cls, domain): + return [] + + pool = Mock() + pool.get.side_effect = lambda name: {'lot.qt': FakeLotQt}[name] + line = SimpleNamespace( + __name__='purchase.line', + id=11, + type='line', + product=Mock(), + lots=[], + unit=unit, + quantity=Decimal('100'), + quantity_theorical=Decimal('100'), + unit_price=Decimal('25'), + price_type='priced', + ) + record = SimpleNamespace( + __name__='purchase.purchase', + id=12, + lines=[line], + party=purchase_party, + currency=purchase_currency, + payment_term=purchase_payment_term, + incoterm=purchase_incoterm, + btb=None, + ) + wizard = duplicate_module.TradeCustomDuplicate() + wizard.records = [record] + + with patch.object(duplicate_module, 'Pool', return_value=pool): + values = wizard.default_start(None) + + self.assertEqual(values['mirror_mode'], 'new_mirror') + self.assertEqual(values['mirror_party'], purchase_party.id) + self.assertEqual(values['mirror_currency'], purchase_currency.id) + self.assertEqual(values['mirror_payment_term'], purchase_payment_term.id) + self.assertEqual(values['mirror_incoterm'], purchase_incoterm.id) + self.assertEqual(values['mirror_quantity'], Decimal('100')) + self.assertEqual(values['mirror_unit'], unit.id) + self.assertEqual(values['mirror_unit_price'], Decimal('25')) + self.assertEqual(values['mirror_price_type'], 'priced') + + def test_custom_duplicate_mirror_mode_change_refreshes_source_defaults(self): + 'changing mirror mode refreshes mirror fields from the source contract' + purchase_party = SimpleNamespace(id=1) + purchase_currency = SimpleNamespace(id=4) + purchase_payment_term = SimpleNamespace(id=5) + purchase_incoterm = SimpleNamespace(id=6) + unit = SimpleNamespace(id=3) + line = SimpleNamespace( + __name__='purchase.line', + id=11, + type='line', + product=Mock(), + lots=[], + unit=unit, + quantity=Decimal('100'), + quantity_theorical=Decimal('100'), + unit_price=Decimal('25'), + price_type='priced', + ) + record = SimpleNamespace( + __name__='purchase.purchase', + id=12, + lines=[line], + party=purchase_party, + currency=purchase_currency, + payment_term=purchase_payment_term, + incoterm=purchase_incoterm, + ) + start = duplicate_module.TradeCustomDuplicateStart() + start.source = record + start.mirror_mode = 'new_mirror' + start.mirror_party = SimpleNamespace(id=99) + + start.on_change_mirror_mode() + + self.assertIs(start.mirror_party, purchase_party) + self.assertIs(start.mirror_currency, purchase_currency) + self.assertIs(start.mirror_payment_term, purchase_payment_term) + self.assertIs(start.mirror_incoterm, purchase_incoterm) + self.assertEqual(start.mirror_quantity, Decimal('100')) + self.assertIs(start.mirror_unit, unit) + self.assertEqual(start.mirror_unit_price, Decimal('25')) + self.assertEqual(start.mirror_price_type, 'priced') def test_custom_duplicate_defaults_mirror_from_back_to_back_sale(self): 'custom duplicate can use the back-to-back sale as matched pair' diff --git a/modules/purchase_trade/view/trade_custom_duplicate_start_form.xml b/modules/purchase_trade/view/trade_custom_duplicate_start_form.xml index e74d4fc..9261d63 100644 --- a/modules/purchase_trade/view/trade_custom_duplicate_start_form.xml +++ b/modules/purchase_trade/view/trade_custom_duplicate_start_form.xml @@ -28,6 +28,24 @@