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 repository contains the full copyright notices and license terms.
from decimal import Decimal
from types import SimpleNamespace
from trytond.exceptions import UserError
from trytond.model import ModelView, fields
@@ -19,6 +20,11 @@ PRICE_TYPES = [
('efp', 'EFP'),
]
MIRROR_MODES = [
('duplicate_pair', 'Duplicate matched pair'),
('new_mirror', 'Create new mirror contract'),
]
class TradeCustomDuplicateStart(ModelView):
"Custom Duplicate"
@@ -46,6 +52,11 @@ class TradeCustomDuplicateStart(ModelView):
clear_fees = fields.Boolean("Clear Fees")
clear_pricing_components = fields.Boolean("Clear Pricing Components")
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",
states={
'invisible': ~Bool(Eval('create_matched_mirror')),
@@ -97,6 +108,8 @@ class TradeCustomDuplicate(Wizard):
line = self._trade_line(record)
if not line:
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 {
'source_model': source_model,
@@ -115,6 +128,10 @@ class TradeCustomDuplicate(Wizard):
'clear_fees': False,
'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),
}
@staticmethod
@@ -293,6 +310,168 @@ class TradeCustomDuplicate(Wizard):
if not LotQt.search(domain):
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
def _create_matched_mirror(record, line, options):
source_model = record.__name__
@@ -348,7 +527,11 @@ class TradeCustomDuplicate(Wizard):
TradeCustomDuplicate._ensure_open_virtual_lot(new_line)
self.start.duplicated_record_id = new_record.id
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':
return 'open_purchase'
return 'open_sale'

View File

@@ -6203,6 +6203,108 @@ class PurchaseTradeTestCase(ModuleTestCase):
self.assertEqual(virtual_parts, [
(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):
'apply matching cannot consume more than available purchase quantity'
class LotQtMock:

View File

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