From 2958e1fb9e874d67199902ef264bf70a65bda772 Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Thu, 2 Apr 2026 11:33:49 +0200 Subject: [PATCH] 02.04.26 --- modules/purchase_trade/lot.py | 74 +++++---- modules/purchase_trade/service.py | 174 ++++++++++++++++---- modules/purchase_trade/tests/test_module.py | 32 ++++ 3 files changed, 219 insertions(+), 61 deletions(-) diff --git a/modules/purchase_trade/lot.py b/modules/purchase_trade/lot.py index 964734d..c96f975 100755 --- a/modules/purchase_trade/lot.py +++ b/modules/purchase_trade/lot.py @@ -3154,37 +3154,55 @@ class CreateContracts(Wizard): def transition_start(self): return 'ct' - def default_ct(self, fields): - LotQt = Pool().get('lot.qt') - Lot = Pool().get('lot.lot') - context = Transaction().context - ids = context.get('active_ids') + def default_ct(self, fields): + LotQt = Pool().get('lot.qt') + Lot = Pool().get('lot.lot') + context = Transaction().context + ids = context.get('active_ids') unit = None product = None sh_in = None sh_int = None - sh_out = None - lot = None - qt = None - type = None - for i in ids: - val = {} - if i < 10000000: - raise UserError("You must create contract from an open quantity !") - l = LotQt(i - 10000000) - ll = Lot(l.lot_p if l.lot_p else l.lot_s) - type = "Sale" if l.lot_p else "Purchase" - unit = l.lot_unit.id - qt = l.lot_quantity - product = ll.lot_product.id - sh_in = l.lot_shipment_in.id if l.lot_shipment_in else None - sh_int = l.lot_shipment_internal.id if l.lot_shipment_internal else None - sh_out = l.lot_shipment_out.id if l.lot_shipment_out else None - lot = ll.id - - return { - 'quantity': qt, - 'unit': unit, + sh_out = None + lot = None + qt = Decimal(0) + type = None + shipment_in_values = set() + shipment_internal_values = set() + shipment_out_values = set() + for i in ids: + if i < 10000000: + raise UserError("You must create contract from an open quantity !") + l = LotQt(i - 10000000) + ll = Lot(l.lot_p if l.lot_p else l.lot_s) + current_type = "Sale" if l.lot_p else "Purchase" + if type and current_type != type: + raise UserError("You must select open quantities from the same side.") + type = current_type + if product and ll.lot_product.id != product: + raise UserError("You must select open quantities with the same product.") + if unit and l.lot_unit.id != unit: + raise UserError("You must select open quantities with the same unit.") + unit = l.lot_unit.id + qt += abs(Decimal(str(l.lot_quantity or 0))) + product = ll.lot_product.id + shipment_in_values.add(l.lot_shipment_in.id if l.lot_shipment_in else None) + shipment_internal_values.add( + l.lot_shipment_internal.id if l.lot_shipment_internal else None) + shipment_out_values.add(l.lot_shipment_out.id if l.lot_shipment_out else None) + if lot is None: + lot = ll.id + + if len(shipment_in_values) == 1: + sh_in = next(iter(shipment_in_values)) + if len(shipment_internal_values) == 1: + sh_int = next(iter(shipment_internal_values)) + if len(shipment_out_values) == 1: + sh_out = next(iter(shipment_out_values)) + + return { + 'quantity': qt, + 'unit': unit, 'product': product, 'shipment_in': sh_in, 'shipment_internal': sh_int, @@ -3359,4 +3377,4 @@ class ContractDetail(ModelView): if self.del_period: self.from_del = self.del_period.beg_date self.to_del = self.del_period.end_date - \ No newline at end of file + diff --git a/modules/purchase_trade/service.py b/modules/purchase_trade/service.py index 9fe79c8..a0c3c5f 100644 --- a/modules/purchase_trade/service.py +++ b/modules/purchase_trade/service.py @@ -4,6 +4,7 @@ import logging from trytond.pool import Pool from trytond.transaction import Transaction +from trytond.exceptions import UserError logger = logging.getLogger(__name__) @@ -35,16 +36,11 @@ class ContractFactory: Date = pool.get('ir.date') created = [] - - base_contract = ( - ct.lot.sale_line.sale - if type_ == 'Purchase' - else ct.lot.line.purchase - ) + sources = cls._get_sources(ct, type_) + base_contract = cls._get_base_contract(sources, ct, type_) for c in contracts: contract = Purchase() if type_ == 'Purchase' else Sale() - line = PurchaseLine() if type_ == 'Purchase' else SaleLine() # ---------- CONTRACT ---------- parts = c.currency_unit.split("_") @@ -79,33 +75,34 @@ class ContractFactory: contract.save() - # ---------- LINE ---------- - line.quantity = c.quantity - line.quantity_theorical = c.quantity - line.product = ct.product - line.unit = ct.unit - line.price_type = c.price_type - line.created_by_code = ct.matched - line.premium = Decimal(0) + line_sources = cls._get_line_sources(c, sources, ct) + for source in line_sources: + line = PurchaseLine() if type_ == 'Purchase' else SaleLine() - if type_ == 'Purchase': - line.purchase = contract.id - else: - line.sale = contract.id + # ---------- LINE ---------- + line.quantity = source['quantity'] + line.quantity_theorical = source['quantity'] + line.product = ct.product + line.unit = ct.unit + line.price_type = c.price_type + line.created_by_code = ct.matched + line.premium = Decimal(0) - cls._apply_price(line, c, parts) + if type_ == 'Purchase': + line.purchase = contract.id + else: + line.sale = contract.id - line.del_period = c.del_period - line.from_del = c.from_del - line.to_del = c.to_del + cls._apply_price(line, c, parts) + cls._apply_delivery(line, c, source) - line.save() + line.save() - logger.info("CREATE_ID:%s", contract.id) - logger.info("CREATE_LINE_ID:%s", line.id) + logger.info("CREATE_ID:%s", contract.id) + logger.info("CREATE_LINE_ID:%s", line.id) - if ct.matched: - cls._create_lot(line, c, ct, type_) + if ct.matched: + cls._create_lot(line, c, source, type_) created.append(contract) @@ -155,12 +152,124 @@ class ContractFactory: else: line.unit_price = c.price if c.price else Decimal(0) + @staticmethod + def _apply_delivery(line, c, source): + source_line = source.get('trade_line') + if source.get('use_source_delivery') and source_line: + line.del_period = getattr(source_line, 'del_period', None) + line.from_del = getattr(source_line, 'from_del', None) + line.to_del = getattr(source_line, 'to_del', None) + return + line.del_period = c.del_period + line.from_del = c.from_del + line.to_del = c.to_del + + @staticmethod + def _normalize_quantity(quantity): + return abs(Decimal(str(quantity or 0))).quantize(Decimal('0.00001')) + + @classmethod + def _get_base_contract(cls, sources, ct, type_): + if sources: + source_lot = sources[0]['lot'] + return ( + source_lot.sale_line.sale + if type_ == 'Purchase' + else source_lot.line.purchase + ) + return ( + ct.lot.sale_line.sale + if type_ == 'Purchase' + else ct.lot.line.purchase + ) + + @classmethod + def _get_sources(cls, ct, type_): + pool = Pool() + LotQt = pool.get('lot.qt') + context = Transaction().context or {} + active_ids = context.get('active_ids') or [] + sources = [] + if active_ids: + for record_id in active_ids: + if record_id < 10000000: + continue + lqt = LotQt(record_id - 10000000) + lot = lqt.lot_p or lqt.lot_s + if not lot: + continue + trade_line = ( + lot.sale_line if type_ == 'Purchase' else lot.line + ) + sources.append({ + 'lqt': lqt, + 'lot': lot, + 'trade_line': trade_line, + 'quantity': cls._normalize_quantity(lqt.lot_quantity), + 'shipment_origin': lqt.lot_shipment_origin, + }) + elif getattr(ct, 'lot', None): + lot = ct.lot + trade_line = ( + lot.sale_line if type_ == 'Purchase' else lot.line + ) + sources.append({ + 'lqt': None, + 'lot': lot, + 'trade_line': trade_line, + 'quantity': cls._normalize_quantity(getattr(ct, 'quantity', 0)), + 'shipment_origin': cls._get_shipment_origin(ct), + }) + + cls._validate_sources(sources, type_) + return sources + + @classmethod + def _validate_sources(cls, sources, type_): + if not sources: + return + first_line = sources[0]['trade_line'] + for source in sources[1:]: + line = source['trade_line'] + if bool(getattr(line, 'sale', None)) != bool(getattr(first_line, 'sale', None)): + raise UserError('Selected lots must all come from the same side.') + if getattr(line.product, 'id', None) != getattr(first_line.product, 'id', None): + raise UserError('Selected lots must share the same product.') + if getattr(line.unit, 'id', None) != getattr(first_line.unit, 'id', None): + raise UserError('Selected lots must share the same unit.') + + @classmethod + def _get_line_sources(cls, contract_detail, sources, ct): + if not ct.matched or len(sources) <= 1: + quantity = cls._normalize_quantity(contract_detail.quantity) + source = sources[0] if sources else { + 'lot': getattr(ct, 'lot', None), + 'trade_line': None, + 'shipment_origin': cls._get_shipment_origin(ct), + } + return [{ + **source, + 'quantity': quantity, + 'use_source_delivery': False, + }] + + selected_total = sum(source['quantity'] for source in sources) + requested = cls._normalize_quantity(contract_detail.quantity) + if requested != selected_total: + raise UserError( + 'For multi-lot matched creation, quantity must equal the total selected open quantity.' + ) + return [{ + **source, + 'use_source_delivery': True, + } for source in sources] + # ------------------------------------------------------------------------- # LOT / MATCHING (repris tel quel du wizard) # ------------------------------------------------------------------------- @classmethod - def _create_lot(cls, line, c, ct, type_): + def _create_lot(cls, line, c, source, type_): pool = Pool() Lot = pool.get('lot.lot') LotQtHist = pool.get('lot.qt.hist') @@ -192,10 +301,9 @@ class ContractFactory: lot.save() - vlot = ct.lot - shipment_origin = cls._get_shipment_origin(ct) - - qt = c.quantity + vlot = source['lot'] + shipment_origin = source.get('shipment_origin') + qt = source['quantity'] if type_ == 'Purchase': if not lot.updateVirtualPart(qt, shipment_origin, vlot): diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index 4c8c929..9fbbe1a 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -6,7 +6,9 @@ from unittest.mock import Mock, patch from trytond.pool import Pool from trytond.tests.test_tryton import ModuleTestCase, with_transaction +from trytond.exceptions import UserError from trytond.modules.purchase_trade import valuation as valuation_module +from trytond.modules.purchase_trade.service import ContractFactory class PurchaseTradeTestCase(ModuleTestCase): @@ -201,5 +203,35 @@ class PurchaseTradeTestCase(ModuleTestCase): 'USC 70.2500 PER POUND (SEVENTY USC AND TWENTY FIVE CENTS) ON ICE Cotton #2 MAY 2026', ]) + def test_contract_factory_uses_one_line_per_selected_source(self): + 'matched multi-lot contract creation keeps one generated line per source lot' + contract_detail = Mock(quantity=Decimal('2000')) + ct = Mock(matched=True) + line_a = Mock() + line_b = Mock() + sources = [ + {'lot': Mock(), 'trade_line': line_a, 'quantity': Decimal('1000')}, + {'lot': Mock(), 'trade_line': line_b, 'quantity': Decimal('1000')}, + ] + + result = ContractFactory._get_line_sources(contract_detail, sources, ct) + + self.assertEqual(len(result), 2) + self.assertEqual([r['quantity'] for r in result], [ + Decimal('1000'), Decimal('1000')]) + self.assertTrue(all(r['use_source_delivery'] for r in result)) + + def test_contract_factory_rejects_multi_lot_quantity_mismatch(self): + 'matched multi-lot contract creation rejects totals that do not match the selection' + contract_detail = Mock(quantity=Decimal('1500')) + ct = Mock(matched=True) + sources = [ + {'lot': Mock(), 'trade_line': Mock(), 'quantity': Decimal('1000')}, + {'lot': Mock(), 'trade_line': Mock(), 'quantity': Decimal('1000')}, + ] + + with self.assertRaises(UserError): + ContractFactory._get_line_sources(contract_detail, sources, ct) + del ModuleTestCase