Duplicate custom

This commit is contained in:
2026-06-18 12:59:16 +02:00
parent f8d53a27fa
commit 903b42f730
3 changed files with 288 additions and 1 deletions

View File

@@ -1,6 +1,7 @@
# This file is part of Tryton. The COPYRIGHT file at the top level of # This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms. # this repository contains the full copyright notices and license terms.
from decimal import Decimal from decimal import Decimal
from types import SimpleNamespace
from trytond.exceptions import UserError from trytond.exceptions import UserError
from trytond.model import ModelView, fields from trytond.model import ModelView, fields
@@ -19,6 +20,11 @@ PRICE_TYPES = [
('efp', 'EFP'), ('efp', 'EFP'),
] ]
MIRROR_MODES = [
('duplicate_pair', 'Duplicate matched pair'),
('new_mirror', 'Create new mirror contract'),
]
class TradeCustomDuplicateStart(ModelView): class TradeCustomDuplicateStart(ModelView):
"Custom Duplicate" "Custom Duplicate"
@@ -46,6 +52,11 @@ class TradeCustomDuplicateStart(ModelView):
clear_fees = fields.Boolean("Clear Fees") clear_fees = fields.Boolean("Clear Fees")
clear_pricing_components = fields.Boolean("Clear Pricing Components") clear_pricing_components = fields.Boolean("Clear Pricing Components")
create_matched_mirror = fields.Boolean("Create matched mirror contract") create_matched_mirror = fields.Boolean("Create matched mirror contract")
mirror_mode = fields.Selection(MIRROR_MODES, "Mirror Mode",
states={
'invisible': ~Bool(Eval('create_matched_mirror')),
'required': Bool(Eval('create_matched_mirror')),
})
mirror_party = fields.Many2One('party.party', "Mirror Counterparty", mirror_party = fields.Many2One('party.party', "Mirror Counterparty",
states={ states={
'invisible': ~Bool(Eval('create_matched_mirror')), 'invisible': ~Bool(Eval('create_matched_mirror')),
@@ -97,6 +108,8 @@ class TradeCustomDuplicate(Wizard):
line = self._trade_line(record) line = self._trade_line(record)
if not line: if not line:
raise UserError("The selected contract has no trade line to duplicate.") raise UserError("The selected contract has no trade line to duplicate.")
matched_record = self._matched_counterpart_record(line, strict=False)
mirror_mode = 'duplicate_pair' if matched_record else 'new_mirror'
return { return {
'source_model': source_model, 'source_model': source_model,
@@ -115,6 +128,10 @@ class TradeCustomDuplicate(Wizard):
'clear_fees': False, 'clear_fees': False,
'clear_pricing_components': False, 'clear_pricing_components': False,
'create_matched_mirror': False, 'create_matched_mirror': False,
'mirror_mode': mirror_mode,
'mirror_party': (
matched_record.party.id
if matched_record and matched_record.party else None),
} }
@staticmethod @staticmethod
@@ -293,6 +310,168 @@ class TradeCustomDuplicate(Wizard):
if not LotQt.search(domain): if not LotQt.search(domain):
create() create()
@staticmethod
def _open_lot_qt(lot, side):
LotQt = Pool().get('lot.qt')
if side == 'purchase':
domain = [
('lot_p', '=', lot.id),
('lot_s', '=', None),
('lot_shipment_in', '=', None),
('lot_shipment_internal', '=', None),
('lot_shipment_out', '=', None),
]
else:
domain = [
('lot_p', '=', None),
('lot_s', '=', lot.id),
('lot_shipment_in', '=', None),
('lot_shipment_internal', '=', None),
('lot_shipment_out', '=', None),
]
records = LotQt.search(domain)
return records[0] if records else None
@staticmethod
def _matched_counterpart_line(line, strict=True):
side = TradeCustomDuplicate._line_side(line)
if side not in {'purchase', 'sale'}:
if strict:
raise UserError(
"Custom duplicate cannot determine the contract side.")
return None
lot_ids = [lot.id for lot in (getattr(line, 'lots', None) or [])]
if not lot_ids:
if strict:
raise UserError(
"Duplicate matched pair requires an existing matched lot.")
return None
LotQt = Pool().get('lot.qt')
if side == 'purchase':
lot_qts = LotQt.search([
('lot_p', 'in', lot_ids),
('lot_s', '!=', None),
('lot_quantity', '>', 0),
])
lines = [
lqt.lot_s.sale_line for lqt in lot_qts
if getattr(getattr(lqt, 'lot_s', None), 'sale_line', None)]
else:
lot_qts = LotQt.search([
('lot_s', 'in', lot_ids),
('lot_p', '!=', None),
('lot_quantity', '>', 0),
])
lines = [
lqt.lot_p.line for lqt in lot_qts
if getattr(getattr(lqt, 'lot_p', None), 'line', None)]
unique = []
seen = set()
for matched_line in lines:
matched_id = getattr(matched_line, 'id', id(matched_line))
if matched_id in seen:
continue
seen.add(matched_id)
unique.append(matched_line)
if len(unique) == 1:
return unique[0]
if strict:
raise UserError(
"Duplicate matched pair requires exactly one matched "
"counterpart.")
return None
@staticmethod
def _matched_counterpart_record(line, strict=True):
matched_line = TradeCustomDuplicate._matched_counterpart_line(
line, strict=strict)
if not matched_line:
return None
return (
getattr(matched_line, 'sale', None)
or getattr(matched_line, 'purchase', None))
@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,
)
@staticmethod
def _match_duplicate_pair_lots(source_line, source_lot, counterpart_line,
counterpart_lot, quantity):
LotQt = Pool().get('lot.qt')
Lot = Pool().get('lot.lot')
source_side = TradeCustomDuplicate._line_side(source_line)
if source_side not in {'purchase', 'sale'}:
raise UserError(
"Custom duplicate cannot determine the contract side.")
if source_side == 'purchase':
purchase_line = source_line
purchase_lot = source_lot
sale_line = counterpart_line
sale_lot = counterpart_lot
else:
purchase_line = counterpart_line
purchase_lot = counterpart_lot
sale_line = source_line
sale_lot = source_lot
purchase_lqt = TradeCustomDuplicate._open_lot_qt(
purchase_lot, 'purchase')
sale_lqt = TradeCustomDuplicate._open_lot_qt(sale_lot, 'sale')
if not purchase_lqt or not sale_lqt:
raise UserError(
"Duplicate matched pair could not find the new open lot "
"quantities to match.")
purchase_lqt.lot_s = sale_lot.id
purchase_lqt.lot_quantity = Decimal(str(quantity)).quantize(
Decimal("0.00001"))
LotQt.save([purchase_lqt])
LotQt.delete([sale_lqt])
Lot.assert_lines_quantity_consistency([purchase_line, sale_line])
@staticmethod
def _duplicate_matched_pair(record, line, new_line, options):
matched_line = TradeCustomDuplicate._matched_counterpart_line(line)
matched_record = (
getattr(matched_line, 'sale', None)
or getattr(matched_line, 'purchase', None))
if not matched_record:
raise UserError(
"Duplicate matched pair could not find the matched contract.")
mirror_options = TradeCustomDuplicate._pair_duplicate_options(
matched_record, matched_line, options)
new_matched_record = TradeCustomDuplicate._copy_contract(
matched_record, mirror_options)
new_matched_line = TradeCustomDuplicate._apply_line_options(
new_matched_record, mirror_options)
source_lot = TradeCustomDuplicate._ensure_open_virtual_lot(new_line)
counterpart_lot = TradeCustomDuplicate._ensure_open_virtual_lot(
new_matched_line)
if source_lot and counterpart_lot:
TradeCustomDuplicate._match_duplicate_pair_lots(
new_line, source_lot, new_matched_line, counterpart_lot,
options.quantity)
return new_matched_record
@staticmethod @staticmethod
def _create_matched_mirror(record, line, options): def _create_matched_mirror(record, line, options):
source_model = record.__name__ source_model = record.__name__
@@ -348,7 +527,11 @@ class TradeCustomDuplicate(Wizard):
TradeCustomDuplicate._ensure_open_virtual_lot(new_line) TradeCustomDuplicate._ensure_open_virtual_lot(new_line)
self.start.duplicated_record_id = new_record.id self.start.duplicated_record_id = new_record.id
if options.create_matched_mirror: if options.create_matched_mirror:
self._create_matched_mirror(new_record, new_line, options) if options.mirror_mode == 'duplicate_pair':
self._duplicate_matched_pair(record, self._trade_line(record),
new_line, options)
else:
self._create_matched_mirror(new_record, new_line, options)
if new_record.__name__ == 'purchase.purchase': if new_record.__name__ == 'purchase.purchase':
return 'open_purchase' return 'open_purchase'
return 'open_sale' return 'open_sale'

View File

@@ -6203,6 +6203,108 @@ class PurchaseTradeTestCase(ModuleTestCase):
self.assertEqual(virtual_parts, [ self.assertEqual(virtual_parts, [
(43, Decimal('50.00000'), None, 43, 'only sale')]) (43, Decimal('50.00000'), None, 43, 'only sale')])
def test_custom_duplicate_defaults_mirror_from_matched_sale(self):
'custom duplicate can default mirror party from the matched sale'
purchase_party = SimpleNamespace(id=1)
sale_party = SimpleNamespace(id=2)
purchase_lot = SimpleNamespace(id=10)
sale = SimpleNamespace(party=sale_party)
sale_line = SimpleNamespace(id=20, sale=sale)
lot_qt = SimpleNamespace(lot_s=SimpleNamespace(sale_line=sale_line))
class FakeLotQt:
@classmethod
def search(cls, domain):
return [lot_qt]
pool = Mock()
pool.get.side_effect = lambda name: {'lot.qt': FakeLotQt}[name]
line = SimpleNamespace(
__name__='purchase.line',
id=11,
type='line',
product=Mock(),
lots=[purchase_lot],
unit=SimpleNamespace(id=3),
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=SimpleNamespace(id=4),
payment_term=SimpleNamespace(id=5),
incoterm=SimpleNamespace(id=6),
)
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'], 'duplicate_pair')
self.assertEqual(values['mirror_party'], sale_party.id)
def test_custom_duplicate_match_pair_lots_links_new_quantities(self):
'custom duplicate matches the new purchase and sale lot.qt rows'
saved = []
deleted = []
checked_lines = []
purchase_lqt = SimpleNamespace(
lot_s=None,
lot_quantity=Decimal('0'),
)
sale_lqt = SimpleNamespace(
lot_p=None,
lot_quantity=Decimal('100'),
)
class FakeLotQt:
@classmethod
def search(cls, domain):
if ('lot_p', '=', 101) in domain:
return [purchase_lqt]
if ('lot_s', '=', 202) in domain:
return [sale_lqt]
return []
@classmethod
def save(cls, records):
saved.extend(records)
@classmethod
def delete(cls, records):
deleted.extend(records)
class FakeLot:
@classmethod
def assert_lines_quantity_consistency(cls, lines):
checked_lines.extend(lines)
pool = Mock()
pool.get.side_effect = lambda name: {
'lot.qt': FakeLotQt,
'lot.lot': FakeLot,
}[name]
purchase_line = SimpleNamespace(__name__='purchase.line')
sale_line = SimpleNamespace(__name__='sale.line')
with patch.object(duplicate_module, 'Pool', return_value=pool):
duplicate_module.TradeCustomDuplicate._match_duplicate_pair_lots(
purchase_line, SimpleNamespace(id=101),
sale_line, SimpleNamespace(id=202),
Decimal('100'))
self.assertEqual(purchase_lqt.lot_s, 202)
self.assertEqual(purchase_lqt.lot_quantity, Decimal('100.00000'))
self.assertEqual(saved, [purchase_lqt])
self.assertEqual(deleted, [sale_lqt])
self.assertEqual(checked_lines, [purchase_line, sale_line])
def test_lot_matching_rejects_purchase_quantity_above_available(self): def test_lot_matching_rejects_purchase_quantity_above_available(self):
'apply matching cannot consume more than available purchase quantity' 'apply matching cannot consume more than available purchase quantity'
class LotQtMock: class LotQtMock:

View File

@@ -24,6 +24,8 @@
<field name="clear_pricing_components"/> <field name="clear_pricing_components"/>
<label name="create_matched_mirror"/> <label name="create_matched_mirror"/>
<field name="create_matched_mirror"/> <field name="create_matched_mirror"/>
<label name="mirror_mode"/>
<field name="mirror_mode"/>
<label name="mirror_party"/> <label name="mirror_party"/>
<field name="mirror_party"/> <field name="mirror_party"/>
<field name="source" invisible="1"/> <field name="source" invisible="1"/>