Duplicate custom

This commit is contained in:
2026-06-18 13:33:41 +02:00
parent 6c2061e779
commit 1c20181d06
3 changed files with 314 additions and 32 deletions

View File

@@ -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=[]):

View File

@@ -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'

View File

@@ -28,6 +28,24 @@
<field name="mirror_mode"/>
<label name="mirror_party"/>
<field name="mirror_party"/>
<label name="mirror_payment_term"/>
<field name="mirror_payment_term"/>
<label name="mirror_incoterm"/>
<field name="mirror_incoterm"/>
<label name="mirror_currency"/>
<field name="mirror_currency"/>
<label name="mirror_quantity"/>
<field name="mirror_quantity"/>
<label name="mirror_unit"/>
<field name="mirror_unit"/>
<label name="mirror_unit_price"/>
<field name="mirror_unit_price"/>
<label name="mirror_price_type"/>
<field name="mirror_price_type"/>
<label name="mirror_clear_fees"/>
<field name="mirror_clear_fees"/>
<label name="mirror_clear_pricing_components"/>
<field name="mirror_clear_pricing_components"/>
<field name="source" invisible="1"/>
<field name="duplicated_record_id" invisible="1"/>
</form>