From 5a7b5845b65a53a664078d986b0c1a7d8444b425 Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Wed, 29 Apr 2026 20:00:42 +0200 Subject: [PATCH] Pnl sale side open --- modules/purchase_trade/docs/business-rules.md | 6 + modules/purchase_trade/tests/test_module.py | 113 +++++++++++++++++- modules/purchase_trade/valuation.py | 56 +++++++-- 3 files changed, 164 insertions(+), 11 deletions(-) diff --git a/modules/purchase_trade/docs/business-rules.md b/modules/purchase_trade/docs/business-rules.md index f642591..3d896cb 100644 --- a/modules/purchase_trade/docs/business-rules.md +++ b/modules/purchase_trade/docs/business-rules.md @@ -122,6 +122,12 @@ Owner technique: `a completer` - une sale matchee doit donc voir: - ses lignes `sale *` - les lignes purchase portees par le lot physique partage + - avant creation du lot physique, si le matching existe seulement via + `lot.qt` (`lot_p` purchase ouvert -> `lot_s` sale ouvert), les lignes PnL + purchase-side doivent aussi renseigner `sale` et `sale_line` afin + d'apparaitre dans l'onglet PnL de la sale matchee + - si plusieurs sales differentes sont matchees au meme lot ouvert, ne pas + attacher arbitrairement une sale unique aux lignes purchase-side - Priorite: - `structurante` diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index 1c893b2..3bb2b4c 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -358,11 +358,122 @@ class PurchaseTradeTestCase(ModuleTestCase): self.assertEqual(len(values), 2) self.assertEqual(values[0]['type'], 'pur. priced') - self.assertNotIn('sale_line', values[0]) + self.assertEqual(values[0]['sale_line'], finished_sale_line.id) self.assertEqual(values[1]['type'], 'sale priced') self.assertEqual(values[1]['sale_line'], finished_sale_line.id) self.assertEqual(values[1]['reference'], 'Sale/Physic') + def test_purchase_open_pnl_price_links_sale_from_lot_qt(self): + 'purchase-side open price pnl is linked to the matched sale via lot.qt' + Valuation = Pool().get('valuation.valuation') + currency = Mock(id=1) + company = Mock(currency=currency.id) + unit = Mock(id=1) + product = Mock(id=1) + purchase_party = Mock(id=1) + sale_party = Mock(id=2) + sale = Mock(id=3, currency=currency, company=company, party=sale_party) + sale_line = Mock( + id=4, + finished=False, + price_type='priced', + mtm=[], + sale=sale, + unit=unit, + product=product, + ) + sale_lot = Mock( + id=5, + sale_line=sale_line, + lot_price_sale=Decimal('20'), + lot_type='virtual', + ) + sale_lot.get_current_quantity_converted.return_value = Decimal('2') + purchase_lot = Mock( + id=6, + sale_line=None, + lot_price=Decimal('10'), + lot_type='virtual', + ) + purchase_lot.get_current_quantity_converted.return_value = Decimal('2') + line = Mock( + finished=False, + lots=[purchase_lot], + price_type='priced', + mtm=[], + purchase=Mock( + id=7, currency=currency, company=company, party=purchase_party), + unit=unit, + product=product, + ) + + lot_qt_model = Mock() + lot_qt_model.search.return_value = [Mock(lot_s=sale_lot)] + + with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock: + PoolMock.return_value.get.side_effect = lambda name: { + 'lot.qt': lot_qt_model, + 'currency.currency': Mock(), + 'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 29))), + }[name] + + values = Valuation.create_pnl_price_from_line(line) + + purchase_values = [v for v in values if v['type'] == 'pur. priced'] + self.assertEqual(purchase_values[0]['sale'], sale.id) + self.assertEqual(purchase_values[0]['sale_line'], sale_line.id) + + def test_purchase_open_pnl_fee_links_sale_from_lot_qt(self): + 'purchase-side open fee pnl is linked to the matched sale via lot.qt' + Valuation = Pool().get('valuation.valuation') + currency = Mock(id=1) + unit = Mock(id=2) + product = Mock(id=3, name='Financing interests') + supplier = Mock(id=4) + sale = Mock(id=5) + sale_line = Mock(id=6, sale=sale, finished=False) + sale_lot = Mock(id=7, sale_line=sale_line, lot_type='virtual') + purchase_lot = Mock(id=8, sale_line=None, lot_type='virtual') + purchase_lot.get_current_quantity_converted.return_value = Decimal('2') + fee = Mock( + product=product, + supplier=supplier, + type='budgeted', + p_r='pay', + mode='lumpsum', + price=Decimal('10'), + currency=currency, + shipment_in=None, + sale_line=None, + unit=unit, + ) + line = Mock( + id=9, + finished=False, + lots=[purchase_lot], + get_matched_lines=Mock(return_value=[]), + purchase=Mock(id=10, currency=currency), + unit=unit, + ) + lot_qt_model = Mock() + lot_qt_model.search.return_value = [Mock(lot_s=sale_lot)] + fee_lots = Mock() + fee_lots.search.return_value = [Mock(fee=fee)] + + with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock: + PoolMock.return_value.get.side_effect = lambda name: { + 'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 29))), + 'currency.currency': Mock(), + 'fee.lots': fee_lots, + 'lot.qt': lot_qt_model, + }[name] + + values = Valuation.create_pnl_fee_from_line(line) + + self.assertEqual(values[0]['type'], 'pur. fee') + self.assertEqual(values[0]['sale'], sale.id) + self.assertEqual(values[0]['sale_line'], sale_line.id) + def test_sale_report_crop_name_handles_missing_crop(self): 'sale report crop name returns an empty string when crop is missing' Sale = Pool().get('sale.sale') diff --git a/modules/purchase_trade/valuation.py b/modules/purchase_trade/valuation.py index 193fc9b..001e714 100644 --- a/modules/purchase_trade/valuation.py +++ b/modules/purchase_trade/valuation.py @@ -91,6 +91,43 @@ class ValuationBase(ModelSQL): def _ignore_finished_open_lot(cls, line, lot): return cls._is_finished(line) and lot.lot_type != 'physic' + @classmethod + def _get_matched_sale_line_from_purchase_lot(cls, lot, LotQt=None): + sale_line = getattr(lot, 'sale_line', None) + if sale_line: + return sale_line + + lot_id = getattr(lot, 'id', None) + if not lot_id: + return None + + if LotQt is None: + LotQt = Pool().get('lot.qt') + + sale_lines = [] + seen = set() + for lqt in LotQt.search([ + ('lot_p', '=', lot_id), + ('lot_s', '>', 0), + ('lot_quantity', '>', 0), + ]): + lot_s = getattr(lqt, 'lot_s', None) + sale_line = getattr(lot_s, 'sale_line', None) + sale_line_id = getattr(sale_line, 'id', None) + if sale_line and sale_line_id not in seen: + sale_lines.append(sale_line) + seen.add(sale_line_id) + + if len(sale_lines) == 1: + return sale_lines[0] + return None + + @classmethod + def _set_matched_sale_values(cls, values, sale_line): + if values and sale_line: + values['sale'] = sale_line.sale.id + values['sale_line'] = sale_line.id + @classmethod def _filter_values_by_types(cls, values, selected_types): if selected_types is None: @@ -335,6 +372,8 @@ class ValuationBase(ModelSQL): LotQt = Pool().get('lot.qt') for lot in cls._valuation_lots(line): + matched_sale_line = cls._get_matched_sale_line_from_purchase_lot( + lot, LotQt=LotQt) if line.price_type == 'basis': premium_delta = cls._get_basis_premium_delta(line) @@ -349,9 +388,7 @@ class ValuationBase(ModelSQL): sign=-1, pnl_type='pur. priced' ) - if values and lot.sale_line: - values['sale'] = lot.sale_line.sale.id - values['sale_line'] = lot.sale_line.id + cls._set_matched_sale_values(values, matched_sale_line) if line.mtm and cls._supports_strategy_mtm(values): for strat in line.mtm: values['mtm_price'] = cls._get_strategy_mtm_price(strat, line) @@ -369,9 +406,7 @@ class ValuationBase(ModelSQL): values = cls._build_basis_pnl( line=line, lot=lot, sale_line=None, pc=pc, sign=-1, extra_price=premium_delta) - if values and lot.sale_line: - values['sale'] = lot.sale_line.sale.id - values['sale_line'] = lot.sale_line.id + cls._set_matched_sale_values(values, matched_sale_line) if line.mtm and cls._supports_strategy_mtm(values): for strat in line.mtm: values['mtm_price'] = cls._get_strategy_mtm_price(strat, line) @@ -394,9 +429,7 @@ class ValuationBase(ModelSQL): sign=-1, pnl_type=f'pur. {line.price_type}' ) - if values and lot.sale_line: - values['sale'] = lot.sale_line.sale.id - values['sale_line'] = lot.sale_line.id + cls._set_matched_sale_values(values, matched_sale_line) if line.mtm and cls._supports_strategy_mtm(values): for strat in line.mtm: values['mtm_price'] = cls._get_strategy_mtm_price(strat, line) @@ -675,6 +708,8 @@ class ValuationBase(ModelSQL): for lot in all_lots: if lot.sale_line and cls._ignore_finished_open_lot(lot.sale_line, lot): continue + matched_sale_line = cls._get_matched_sale_line_from_purchase_lot( + lot, LotQt=LotQt) fl = FeeLots.search([('lot', '=', lot.id)]) if not fl: continue @@ -698,7 +733,8 @@ class ValuationBase(ModelSQL): price = Currency.compute(sf.currency, price, line.purchase.currency) fee_lines.append({ 'lot': lot.id, - 'sale': lot.sale_line.sale.id if lot.sale_line else None, + 'sale': matched_sale_line.sale.id if matched_sale_line else None, + 'sale_line': matched_sale_line.id if matched_sale_line else None, 'purchase': line.purchase.id, 'line': line.id, 'type': (