Duplicate custom
This commit is contained in:
@@ -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'
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"/>
|
||||||
|
|||||||
Reference in New Issue
Block a user