From 280cff5fdb57c0b746a9f3301769c20d34a1af00 Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Tue, 21 Apr 2026 13:22:41 +0200 Subject: [PATCH] Pnl exclude Mark to finished & price on change --- modules/purchase_trade/sale.py | 8 ++ modules/purchase_trade/tests/test_module.py | 87 +++++++++++++++++++++ modules/purchase_trade/valuation.py | 16 +++- 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/modules/purchase_trade/sale.py b/modules/purchase_trade/sale.py index 2cca027..1379bbc 100755 --- a/modules/purchase_trade/sale.py +++ b/modules/purchase_trade/sale.py @@ -1278,6 +1278,14 @@ class SaleLine(metaclass=PoolMeta): + Decimal(lot_premium or 0)), 4) + @fields.depends('product', 'quantity', 'unit_price', + methods=['_get_context_sale_price']) + def compute_unit_price(self): + unit_price = super().compute_unit_price() + if unit_price is None: + unit_price = self.unit_price + return unit_price + @fields.depends('id','unit','quantity','unit_price','price_pricing','price_type','price_components','estimated_date','lots','fees','enable_linked_currency','linked_price','linked_currency','linked_unit') def on_change_with_unit_price(self, name=None): Date = Pool().get('ir.date') diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index 8d2fe54..ed0ede8 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -145,6 +145,80 @@ 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' + 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( + '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() + + def test_generate_skips_finished_sale_line(self): + 'sale valuation generation keeps deleting old rows but skips finished sale lines' + 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( + '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() + + 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' + Valuation = Pool().get('valuation.valuation') + finished_sale_line = Mock( + finished=True, + price_type='priced', + mtm=[], + ) + sale_lot = Mock(sale_line=finished_sale_line) + purchase_lot = Mock( + sale_line=None, + lot_price=Decimal('10'), + lot_type='physic', + ) + purchase_lot.get_current_quantity_converted.return_value = Decimal('2') + line = Mock( + finished=False, + 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), + ) + + 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.return_value = lot_qt_model + + values = Valuation.create_pnl_price_from_line(line) + + self.assertEqual(len(values), 1) + self.assertEqual(values[0]['type'], 'pur. priced') + self.assertNotIn('sale_line', values[0]) + 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') @@ -171,6 +245,19 @@ class PurchaseTradeTestCase(ModuleTestCase): self.assertEqual( SaleLine.default_pricing_rule(), 'Default pricing rule') + def test_sale_line_compute_unit_price_keeps_existing_value_when_base_returns_none(self): + 'sale line quantity changes keep the manual unit price when no sale price is found' + SaleLine = Pool().get('sale.line') + line = SaleLine() + line.unit_price = Decimal('123.45') + + with patch('trytond.modules.purchase_trade.sale.super') as super_mock: + super_mock.return_value.compute_unit_price.return_value = None + + self.assertEqual( + line.compute_unit_price(), + Decimal('123.45')) + def test_purchase_line_default_pricing_rule_comes_from_configuration(self): 'purchase line pricing_rule defaults to the purchase_trade singleton value' PurchaseLine = Pool().get('purchase.line') diff --git a/modules/purchase_trade/valuation.py b/modules/purchase_trade/valuation.py index 8fd65bc..1b74510 100644 --- a/modules/purchase_trade/valuation.py +++ b/modules/purchase_trade/valuation.py @@ -76,6 +76,10 @@ class ValuationBase(ModelSQL): } return type_map.get(valuation_type, None) + @classmethod + def _is_finished(cls, record): + return bool(getattr(record, 'finished', False)) + @classmethod def _filter_values_by_types(cls, values, selected_types): if selected_types is None: @@ -406,6 +410,8 @@ class ValuationBase(ModelSQL): sl_line = sl.sale_line if not sl_line: continue + if cls._is_finished(sl_line): + continue if sl_line.price_type == 'basis': premium_delta = cls._get_basis_premium_delta(sl_line) @@ -641,7 +647,11 @@ class ValuationBase(ModelSQL): FeeLots = Pool().get('fee.lots') #if line is matched with sale_line we should add the open sale side sale_lines = line.get_matched_lines() or [] - sale_open_lots = tuple(s.lot_s for s in sale_lines if s.lot_s) + 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) + ) all_lots = (line.lots or ()) + sale_open_lots for lot in all_lots: fl = FeeLots.search([('lot', '=', lot.id)]) @@ -821,6 +831,8 @@ 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)) @@ -837,6 +849,8 @@ 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))