From 7a123b1af6e54e649700cda30d1a0b80bb009ee2 Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Thu, 23 Apr 2026 14:07:42 +0200 Subject: [PATCH] bug PL qt double * fee rate --- modules/purchase_trade/purchase.py | 49 +++++++------ modules/purchase_trade/tests/test_module.py | 79 +++++++++++++++++++++ modules/purchase_trade/valuation.py | 9 ++- 3 files changed, 112 insertions(+), 25 deletions(-) diff --git a/modules/purchase_trade/purchase.py b/modules/purchase_trade/purchase.py index 91cdd8e..363d0e7 100755 --- a/modules/purchase_trade/purchase.py +++ b/modules/purchase_trade/purchase.py @@ -1465,20 +1465,27 @@ class Line(metaclass=PoolMeta): super().write(*args) - lines = sum(args[::2], []) - for line in lines: - if line.id in old_values: - old = Decimal(old_values[line.id] or 0) - new = Decimal(line.quantity_theorical or 0) - delta = new - old - if delta > 0: - virtual_lots = [lot for lot in (line.lots or []) if lot.lot_type == 'virtual'] - if not virtual_lots: - continue - vlot = virtual_lots[0] - new_qty = round(Decimal(vlot.get_current_quantity_converted() or 0) + delta, 5) - vlot.set_current_quantity(new_qty, new_qty, 1) - Lot.save([vlot]) + lines = sum(args[::2], []) + for line in lines: + if line.id in old_values: + new = Decimal(line.quantity_theorical or 0) + virtual_lots = [lot for lot in (line.lots or []) if lot.lot_type == 'virtual'] + if not virtual_lots: + continue + vlot = virtual_lots[0] + # Initializing quantity_theorical after virtual-lot creation + # must not add the line quantity a second time. + old = old_values[line.id] + baseline = ( + Decimal(old) + if old is not None + else Decimal(vlot.get_current_quantity_converted() or 0) + ) + delta = new - baseline + if delta > 0: + new_qty = round(Decimal(vlot.get_current_quantity_converted() or 0) + delta, 5) + vlot.set_current_quantity(new_qty, new_qty, 1) + Lot.save([vlot]) lqts = LotQt.search([ ('lot_p', '=', vlot.id), ('lot_s', '=', None), @@ -1495,15 +1502,11 @@ class Line(metaclass=PoolMeta): lqt.lot_p = vlot.id lqt.lot_s = None lqt.lot_quantity = round(delta, 5) - lqt.lot_unit = line.unit - LotQt.save([lqt]) - elif delta < 0: - virtual_lots = [lot for lot in (line.lots or []) if lot.lot_type == 'virtual'] - if not virtual_lots: - continue - vlot = virtual_lots[0] - decrease = abs(delta) - lqts = LotQt.search([ + lqt.lot_unit = line.unit + LotQt.save([lqt]) + elif delta < 0: + decrease = abs(delta) + lqts = LotQt.search([ ('lot_p', '=', vlot.id), ('lot_s', '=', None), ('lot_shipment_in', '=', None), diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index 3afdcbc..9e1a428 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -181,6 +181,49 @@ class PurchaseTradeTestCase(ModuleTestCase): create_derivatives.assert_not_called() PoolMock.return_value.get.assert_not_called() + def test_create_pnl_fee_from_line_accepts_missing_rate_amount(self): + 'purchase fee valuation treats an uncomputed rate amount as zero' + Valuation = Pool().get('valuation.valuation') + currency = Mock(id=1) + unit = Mock(id=2) + product = Mock(id=3, name='Financing fees') + supplier = Mock(id=4) + lot = Mock(id=5, sale_line=None, lot_type='virtual') + lot.get_current_quantity_converted.return_value = Decimal('10') + fee = Mock( + product=product, + supplier=supplier, + type='budgeted', + p_r='pay', + mode='rate', + price=Decimal('10'), + currency=currency, + shipment_in=None, + sale_line=None, + unit=unit, + ) + fee.get_amount.return_value = None + line = Mock( + id=6, + lots=[lot], + get_matched_lines=Mock(return_value=[]), + purchase=Mock(id=7, currency=currency), + unit=unit, + ) + 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, 23))), + 'currency.currency': Mock(), + 'fee.lots': fee_lots, + }[name] + + values = Valuation.create_pnl_fee_from_line(line) + + self.assertEqual(values[0]['amount'], Decimal('0')) + 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') @@ -693,6 +736,42 @@ class PurchaseTradeTestCase(ModuleTestCase): with self.assertRaises(UserError): SaleLine.write([line], {'quantity_theorical': Decimal('8')}) + def test_purchase_line_write_initial_theorical_qty_does_not_double_open_lot(self): + 'purchase line write does not re-add quantity when initializing contractual qty' + PurchaseLine = Pool().get('purchase.line') + line = Mock(id=3, quantity_theorical=None) + vlot = Mock(id=101, lot_type='virtual') + vlot.get_current_quantity_converted.return_value = Decimal('10') + line.lots = [vlot] + + lot_model = Mock() + lotqt_model = Mock() + + with patch( + 'trytond.modules.purchase_trade.purchase.Pool' + ) as PoolMock, patch( + 'trytond.modules.purchase_trade.purchase.super' + ) as super_mock: + PoolMock.return_value.get.side_effect = lambda name: { + 'lot.lot': lot_model, + 'lot.qt': lotqt_model, + }[name] + + def fake_super_write(*args): + for records, values in zip(args[::2], args[1::2]): + if 'quantity_theorical' in values: + for record in records: + record.quantity_theorical = values['quantity_theorical'] + + super_mock.return_value.write.side_effect = fake_super_write + + PurchaseLine.write( + [line], {'quantity_theorical': Decimal('10')}) + + vlot.set_current_quantity.assert_not_called() + lot_model.save.assert_not_called() + lotqt_model.save.assert_not_called() + def test_party_execution_achieved_percent_uses_real_area_statistics(self): 'party execution achieved percent reflects the controller share in its area' PartyExecution = Pool().get('party.execution') diff --git a/modules/purchase_trade/valuation.py b/modules/purchase_trade/valuation.py index 1b74510..a4d3fb0 100644 --- a/modules/purchase_trade/valuation.py +++ b/modules/purchase_trade/valuation.py @@ -638,6 +638,11 @@ class ValuationBase(ModelSQL): budgeted_fees = [f for f in fee_list if f.type == 'budgeted'] result.extend(budgeted_fees) return result + + @classmethod + def _fee_amount_or_zero(cls, fee): + amount = fee.get_amount() if hasattr(fee, 'get_amount') else fee.amount + return Decimal(amount or 0) @classmethod def create_pnl_fee_from_line(cls, line): @@ -664,7 +669,7 @@ class ValuationBase(ModelSQL): qty = round(lot.get_current_quantity_converted(), 5) if sf.mode == 'ppack' or sf.mode == 'rate': price = sf.price - amount = sf.amount * sign + amount = cls._fee_amount_or_zero(sf) * sign elif sf.mode == 'lumpsum': price = sf.price amount = sf.price * sign @@ -723,7 +728,7 @@ class ValuationBase(ModelSQL): qty = round(lot.get_current_quantity_converted(), 5) if sf.mode == 'ppack' or sf.mode == 'rate': price = sf.price - amount = sf.amount * sign + amount = cls._fee_amount_or_zero(sf) * sign elif sf.mode == 'lumpsum': price = sf.price amount = sf.price * sign