diff --git a/modules/purchase_trade/docs/business-rules.md b/modules/purchase_trade/docs/business-rules.md index 8811ec0..b23a8ea 100644 --- a/modules/purchase_trade/docs/business-rules.md +++ b/modules/purchase_trade/docs/business-rules.md @@ -191,6 +191,26 @@ Owner technique: `a completer` - Priorite: - `structurante` +### BR-PT-007-bis - Mark as finished ignore seulement le reliquat ouvert + +- Intent: conserver le PnL reel des lots executes tout en masquant le reliquat + ouvert d'une ligne terminee. +- Description: + - Sur une `purchase.line` ou une `sale.line`, le champ `finished` + (`Mark as finished`) ne signifie pas que la ligne ne doit plus etre + valorisee. + - Il signifie seulement que les lots ouverts / virtuels restants ne doivent + plus alimenter la valuation. +- Resultat attendu: + - les lots physiques continuent de produire: + - PnL prix + - PnL fees + - les derivatives continuent de produire du PnL meme si la ligne est + marquee finie + - les lots virtuels / ouverts d'une ligne finie sont ignores +- Priorite: + - `structurante` + ### BR-PT-008 - Le premium fait partie du prix contractuel en `priced` et en `basis` - Intent: garantir que le montant total valorise et facture reflete toujours le diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index 2869a2a..777eb1d 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -147,41 +147,57 @@ class PurchaseTradeTestCase(ModuleTestCase): {'type': 'derivative', 'amount': Decimal('30')}, ]) - def test_generate_skips_finished_purchase_line(self): - 'valuation generation keeps deleting old rows but skips finished purchase lines' + def test_generate_keeps_finished_purchase_line_physical_pnl(self): + 'finished purchase lines still generate physical lot valuation' Valuation = Pool().get('valuation.valuation') line = Mock(finished=True) with patch.object(Valuation, '_delete_existing') as delete_existing, patch.object( - Valuation, 'create_pnl_fee_from_line') as create_fees, patch.object( - Valuation, 'create_pnl_price_from_line') as create_prices, patch.object( - Valuation, 'create_pnl_der_from_line') as create_derivatives, patch( + Valuation, 'create_pnl_fee_from_line', + return_value=[{'type': 'pur. fee'}]) as create_fees, patch.object( + Valuation, 'create_pnl_price_from_line', + return_value=[{'type': 'pur. priced'}]) as create_prices, patch.object( + Valuation, 'create_pnl_der_from_line', + return_value=[{'type': 'derivative'}]) as create_derivatives, patch( 'trytond.modules.purchase_trade.valuation.Pool') as PoolMock: Valuation.generate(line) delete_existing.assert_called_once_with(line, selected_types=None) - create_fees.assert_not_called() - create_prices.assert_not_called() - create_derivatives.assert_not_called() - PoolMock.return_value.get.assert_not_called() + create_fees.assert_called_once_with(line) + create_prices.assert_called_once_with(line) + create_derivatives.assert_called_once_with(line) + valuation_model = PoolMock.return_value.get.return_value + valuation_model.create.assert_any_call([ + {'type': 'pur. fee'}, + {'type': 'pur. priced'}, + {'type': 'derivative'}, + ]) - def test_generate_skips_finished_sale_line(self): - 'sale valuation generation keeps deleting old rows but skips finished sale lines' + def test_generate_keeps_finished_sale_line_physical_pnl(self): + 'finished sale lines still generate physical lot valuation' Valuation = Pool().get('valuation.valuation') sale_line = Mock(finished=True) with patch.object(Valuation, '_delete_existing_sale_line') as delete_existing, patch.object( - Valuation, 'create_pnl_fee_from_sale_line') as create_fees, patch.object( - Valuation, 'create_pnl_price_from_sale_line') as create_prices, patch.object( - Valuation, 'create_pnl_der_from_sale_line') as create_derivatives, patch( + Valuation, 'create_pnl_fee_from_sale_line', + return_value=[{'type': 'sale fee'}]) as create_fees, patch.object( + Valuation, 'create_pnl_price_from_sale_line', + return_value=[{'type': 'sale priced'}]) as create_prices, patch.object( + Valuation, 'create_pnl_der_from_sale_line', + return_value=[{'type': 'derivative'}]) as create_derivatives, patch( 'trytond.modules.purchase_trade.valuation.Pool') as PoolMock: Valuation.generate_from_sale_line(sale_line) delete_existing.assert_called_once_with(sale_line, selected_types=None) - create_fees.assert_not_called() - create_prices.assert_not_called() - create_derivatives.assert_not_called() - PoolMock.return_value.get.assert_not_called() + create_fees.assert_called_once_with(sale_line) + create_prices.assert_called_once_with(sale_line) + create_derivatives.assert_called_once_with(sale_line) + valuation_model = PoolMock.return_value.get.return_value + valuation_model.create.assert_any_call([ + {'type': 'sale fee'}, + {'type': 'sale priced'}, + {'type': 'derivative'}, + ]) def test_create_pnl_fee_from_line_accepts_missing_rate_amount(self): 'purchase fee valuation treats an uncomputed rate amount as zero' @@ -257,16 +273,32 @@ class PurchaseTradeTestCase(ModuleTestCase): self.assertEqual(fee.get_amount(), Decimal('13.33')) - def test_create_pnl_price_from_line_ignores_finished_matched_sale_line(self): - 'purchase valuation does not add sale-side pnl when the matched sale line is finished' + def test_create_pnl_price_from_line_keeps_finished_physical_sale_line(self): + 'purchase valuation keeps finished sale-side pnl on physical lots' Valuation = Pool().get('valuation.valuation') + currency = Mock(id=1) + company = Mock(currency=currency.id) + unit = Mock(id=1) + product = Mock(id=1) + party = Mock(id=1) + sale = Mock(id=2, currency=currency, company=company, party=party) finished_sale_line = Mock( finished=True, price_type='priced', mtm=[], + sale=sale, + unit=unit, + product=product, ) - sale_lot = Mock(sale_line=finished_sale_line) + sale_lot = Mock( + id=3, + sale_line=finished_sale_line, + lot_price_sale=Decimal('20'), + lot_type='physic', + ) + sale_lot.get_current_quantity_converted.return_value = Decimal('2') purchase_lot = Mock( + id=4, sale_line=None, lot_price=Decimal('10'), lot_type='physic', @@ -277,9 +309,9 @@ class PurchaseTradeTestCase(ModuleTestCase): lots=[purchase_lot], price_type='priced', mtm=[], - purchase=Mock(id=1, currency=Mock(id=1), company=Mock(currency=Mock(id=1)), party=Mock(id=1)), - unit=Mock(id=1), - product=Mock(id=1), + purchase=Mock(id=1, currency=currency, company=company, party=party), + unit=unit, + product=product, ) lot_qt_model = Mock() @@ -287,13 +319,20 @@ class PurchaseTradeTestCase(ModuleTestCase): with patch( 'trytond.modules.purchase_trade.valuation.Pool') as PoolMock: - PoolMock.return_value.get.return_value = lot_qt_model + 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) - self.assertEqual(len(values), 1) + self.assertEqual(len(values), 2) self.assertEqual(values[0]['type'], 'pur. priced') self.assertNotIn('sale_line', values[0]) + 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_sale_report_crop_name_handles_missing_crop(self): 'sale report crop name returns an empty string when crop is missing' diff --git a/modules/purchase_trade/valuation.py b/modules/purchase_trade/valuation.py index a4d3fb0..193fc9b 100644 --- a/modules/purchase_trade/valuation.py +++ b/modules/purchase_trade/valuation.py @@ -80,6 +80,17 @@ class ValuationBase(ModelSQL): def _is_finished(cls, record): return bool(getattr(record, 'finished', False)) + @classmethod + def _valuation_lots(cls, record): + lots = tuple(record.lots or ()) + if cls._is_finished(record): + return tuple(lot for lot in lots if lot.lot_type == 'physic') + return lots + + @classmethod + def _ignore_finished_open_lot(cls, line, lot): + return cls._is_finished(line) and lot.lot_type != 'physic' + @classmethod def _filter_values_by_types(cls, values, selected_types): if selected_types is None: @@ -323,7 +334,7 @@ class ValuationBase(ModelSQL): price_lines = [] LotQt = Pool().get('lot.qt') - for lot in line.lots: + for lot in cls._valuation_lots(line): if line.price_type == 'basis': premium_delta = cls._get_basis_premium_delta(line) @@ -410,7 +421,9 @@ class ValuationBase(ModelSQL): sl_line = sl.sale_line if not sl_line: continue - if cls._is_finished(sl_line): + if cls._ignore_finished_open_lot(line, sl): + continue + if cls._ignore_finished_open_lot(sl_line, sl): continue if sl_line.price_type == 'basis': @@ -555,7 +568,7 @@ class ValuationBase(ModelSQL): def create_pnl_price_from_sale_line(cls, sale_line): price_lines = [] - for lot in sale_line.lots or []: + for lot in cls._valuation_lots(sale_line): if sale_line.price_type == 'basis': summaries = sale_line.price_summary or [] premium_delta = cls._get_basis_premium_delta(sale_line) @@ -655,10 +668,13 @@ class ValuationBase(ModelSQL): sale_open_lots = tuple( s.lot_s for s in sale_lines if s.lot_s and s.lot_s.sale_line - and not cls._is_finished(s.lot_s.sale_line) + and not cls._ignore_finished_open_lot(line, s.lot_s) + and not cls._ignore_finished_open_lot(s.lot_s.sale_line, s.lot_s) ) - all_lots = (line.lots or ()) + sale_open_lots + all_lots = cls._valuation_lots(line) + sale_open_lots for lot in all_lots: + if lot.sale_line and cls._ignore_finished_open_lot(lot.sale_line, lot): + continue fl = FeeLots.search([('lot', '=', lot.id)]) if not fl: continue @@ -714,7 +730,7 @@ class ValuationBase(ModelSQL): Currency = Pool().get('currency.currency') FeeLots = Pool().get('fee.lots') - for lot in sale_line.lots or (): + for lot in cls._valuation_lots(sale_line): fl = FeeLots.search([('lot', '=', lot.id)]) if not fl: continue @@ -836,8 +852,6 @@ class ValuationBase(ModelSQL): def generate(cls, line, valuation_type='all'): selected_types = cls._get_generate_types(valuation_type) cls._delete_existing(line, selected_types=selected_types) - if cls._is_finished(line): - return values = [] values.extend(cls.create_pnl_fee_from_line(line)) values.extend(cls.create_pnl_price_from_line(line)) @@ -854,8 +868,6 @@ class ValuationBase(ModelSQL): def generate_from_sale_line(cls, sale_line, valuation_type='all'): selected_types = cls._get_generate_types(valuation_type) cls._delete_existing_sale_line(sale_line, selected_types=selected_types) - if cls._is_finished(sale_line): - return values = [] values.extend(cls.create_pnl_fee_from_sale_line(sale_line)) values.extend(cls.create_pnl_price_from_sale_line(sale_line))