From 4b5e49f264e242caf516d208c8a30509a71f0e61 Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Thu, 18 Jun 2026 11:34:19 +0200 Subject: [PATCH] Duplicate custom --- modules/purchase_trade/duplicate.py | 122 ++++++++++++++++++++ modules/purchase_trade/tests/test_module.py | 121 +++++++++++++++++++ 2 files changed, 243 insertions(+) diff --git a/modules/purchase_trade/duplicate.py b/modules/purchase_trade/duplicate.py index f5a99c3..26ad0ba 100644 --- a/modules/purchase_trade/duplicate.py +++ b/modules/purchase_trade/duplicate.py @@ -170,6 +170,127 @@ class TradeCustomDuplicate(Wizard): return line.getVirtualLot() return None + @staticmethod + def _line_side(line): + if getattr(line, '__name__', None) == 'purchase.line': + return 'purchase' + if getattr(line, '__name__', None) == 'sale.line': + return 'sale' + if getattr(line, 'purchase', None): + return 'purchase' + if getattr(line, 'sale', None): + return 'sale' + + @staticmethod + def _line_quantity(line): + quantity = ( + getattr(line, 'quantity_theorical', None) + or getattr(line, 'quantity', None) + or Decimal(0)) + return Decimal(str(quantity)).quantize(Decimal("0.00001")) + + @staticmethod + def _ensure_open_virtual_lot(line): + product = getattr(line, 'product', None) + if not product or getattr(product, 'type', None) == 'service': + return None + + quantity = TradeCustomDuplicate._line_quantity(line) + if quantity <= 0: + return None + + side = TradeCustomDuplicate._line_side(line) + if side not in {'purchase', 'sale'}: + return None + + lot = TradeCustomDuplicate._first_virtual_lot(line) + if not lot: + lot = TradeCustomDuplicate._create_open_virtual_lot( + line, side, quantity) + + TradeCustomDuplicate._ensure_open_lot_qt(lot, side, quantity) + return lot + + @staticmethod + def _create_open_virtual_lot(line, side, quantity): + pool = Pool() + Lot = pool.get('lot.lot') + LotQtHist = pool.get('lot.qt.hist') + LotQtType = pool.get('lot.qt.type') + + lot = Lot() + if side == 'purchase': + lot.line = line.id + lot.lot_qt = None + lot.lot_unit = None + else: + lot.sale_line = line.id + lot.lot_qt = quantity + lot.lot_unit_line = line.unit + lot.lot_quantity = quantity + lot.lot_gross_quantity = None + lot.lot_status = 'forecast' + lot.lot_type = 'virtual' + lot.lot_product = line.product + + lqtt = LotQtType.search([('sequence', '=', 1)]) + if lqtt: + lqh = LotQtHist() + lqh.quantity_type = lqtt[0] + lqh.quantity = quantity + lqh.gross_quantity = quantity + lot.lot_hist = [lqh] + Lot.save([lot]) + + lot = Lot(lot.id) + TradeCustomDuplicate._sync_fee_lots(line, lot, side) + return lot + + @staticmethod + def _sync_fee_lots(line, lot, side): + fees = list(getattr(line, 'fees', None) or []) + if not fees: + return + FeeLots = Pool().get('fee.lots') + line_field = 'line' if side == 'purchase' else 'sale_line' + for fee in fees: + domain = [ + ('fee', '=', fee.id), + ('lot', '=', lot.id), + (line_field, '=', line.id), + ] + if not FeeLots.search(domain): + fee_lot = FeeLots() + fee_lot.fee = fee.id + fee_lot.lot = lot.id + setattr(fee_lot, line_field, line.id) + FeeLots.save([fee_lot]) + + @staticmethod + def _ensure_open_lot_qt(lot, side, quantity): + 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), + ] + create = lambda: lot.createVirtualPart(quantity, None, None) + else: + domain = [ + ('lot_p', '=', None), + ('lot_s', '=', lot.id), + ('lot_shipment_in', '=', None), + ('lot_shipment_internal', '=', None), + ('lot_shipment_out', '=', None), + ] + create = lambda: lot.createVirtualPart( + quantity, None, lot.id, 'only sale') + if not LotQt.search(domain): + create() + @staticmethod def _create_matched_mirror(record, line, options): source_model = record.__name__ @@ -222,6 +343,7 @@ class TradeCustomDuplicate(Wizard): options = self.start new_record = self._copy_contract(record, options) new_line = self._apply_line_options(new_record, options) + 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) diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index 6eeaaa2..70953ca 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -21,6 +21,7 @@ from trytond.modules.purchase_trade import sale as sale_module from trytond.modules.purchase_trade import stock as stock_module from trytond.modules.purchase_trade import fee as fee_module from trytond.modules.purchase_trade import pricing as pricing_module +from trytond.modules.purchase_trade import duplicate as duplicate_module from trytond.modules.purchase_trade import company_defaults from trytond.modules.purchase_trade.service import ContractFactory @@ -6053,6 +6054,126 @@ class PurchaseTradeTestCase(ModuleTestCase): self.assertIsNone( ContractFactory._get_shipment_origin(ContractsStartMock())) + def test_custom_duplicate_creates_purchase_open_virtual_lot(self): + 'custom duplicate creates the purchase opening lot.qt explicitly' + saved_lots = [] + virtual_parts = [] + + class FakeLot: + def __init__(self, id=None): + self.id = id + + @classmethod + def save(cls, lots): + for lot in lots: + lot.id = 42 + saved_lots.append(lot) + + def createVirtualPart(self, quantity, shipment, sale_lot): + virtual_parts.append((self.id, quantity, shipment, sale_lot)) + + class FakeLotQt: + @classmethod + def search(cls, domain): + return [] + + class FakeLotQtHist: + pass + + class FakeLotQtType: + @classmethod + def search(cls, domain): + return [Mock()] + + pool = Mock() + pool.get.side_effect = lambda name: { + 'lot.lot': FakeLot, + 'lot.qt': FakeLotQt, + 'lot.qt.hist': FakeLotQtHist, + 'lot.qt.type': FakeLotQtType, + }[name] + line = SimpleNamespace( + __name__='purchase.line', + id=10, + lots=[], + product=Mock(type='goods'), + unit=Mock(), + quantity=Decimal('0'), + quantity_theorical=Decimal('125'), + fees=[], + purchase=Mock(), + ) + + with patch.object(duplicate_module, 'Pool', return_value=pool): + lot = duplicate_module.TradeCustomDuplicate._ensure_open_virtual_lot( + line) + + self.assertEqual(lot.id, 42) + self.assertEqual(saved_lots[0].line, 10) + self.assertEqual(saved_lots[0].lot_quantity, Decimal('125.00000')) + self.assertEqual(virtual_parts, [ + (42, Decimal('125.00000'), None, None)]) + + def test_custom_duplicate_creates_sale_open_virtual_lot(self): + 'custom duplicate creates the sale opening lot.qt explicitly' + saved_lots = [] + virtual_parts = [] + + class FakeLot: + def __init__(self, id=None): + self.id = id + + @classmethod + def save(cls, lots): + for lot in lots: + lot.id = 43 + saved_lots.append(lot) + + def createVirtualPart(self, quantity, shipment, lot_s, mode): + virtual_parts.append((self.id, quantity, shipment, lot_s, mode)) + + class FakeLotQt: + @classmethod + def search(cls, domain): + return [] + + class FakeLotQtHist: + pass + + class FakeLotQtType: + @classmethod + def search(cls, domain): + return [] + + pool = Mock() + pool.get.side_effect = lambda name: { + 'lot.lot': FakeLot, + 'lot.qt': FakeLotQt, + 'lot.qt.hist': FakeLotQtHist, + 'lot.qt.type': FakeLotQtType, + }[name] + line = SimpleNamespace( + __name__='sale.line', + id=11, + lots=[], + product=Mock(type='goods'), + unit=Mock(), + quantity=Decimal('0'), + quantity_theorical=Decimal('50'), + fees=[], + sale=Mock(), + ) + + with patch.object(duplicate_module, 'Pool', return_value=pool): + lot = duplicate_module.TradeCustomDuplicate._ensure_open_virtual_lot( + line) + + self.assertEqual(lot.id, 43) + self.assertEqual(saved_lots[0].sale_line, 11) + self.assertEqual(saved_lots[0].lot_quantity, Decimal('50.00000')) + self.assertEqual(virtual_parts, [ + (43, Decimal('50.00000'), None, 43, 'only sale')]) + def test_lot_matching_rejects_purchase_quantity_above_available(self): 'apply matching cannot consume more than available purchase quantity' class LotQtMock: