From 71076256649c3e623b4d101a950c0b74cba42f96 Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Fri, 12 Jun 2026 19:39:16 +0200 Subject: [PATCH] Valuation fee --- modules/purchase_trade/fee.py | 9 ++-- modules/purchase_trade/purchase.py | 14 ++--- modules/purchase_trade/sale.py | 4 +- modules/purchase_trade/tests/test_module.py | 52 ++++++++++++++++++- modules/purchase_trade/valuation.py | 41 +++++++++++++-- .../view/valuation_tree_sequence4.xml | 1 - 6 files changed, 101 insertions(+), 20 deletions(-) diff --git a/modules/purchase_trade/fee.py b/modules/purchase_trade/fee.py index 90756b2..3342ac3 100755 --- a/modules/purchase_trade/fee.py +++ b/modules/purchase_trade/fee.py @@ -35,12 +35,13 @@ class Fee(ModelSQL,ModelView): "Fee" __name__ = 'fee.fee' - line = fields.Many2One('purchase.line',"Line") + line = fields.Many2One('purchase.line',"Line", ondelete='CASCADE') source_rule = fields.Many2One('fee.rule', "Source Rule", readonly=True) generated_by_rule = fields.Boolean("Generated by Rule", readonly=True) - shipment_in = fields.Many2One('stock.shipment.in') - shipment_out = fields.Many2One('stock.shipment.out') - shipment_internal = fields.Many2One('stock.shipment.internal') + shipment_in = fields.Many2One('stock.shipment.in', ondelete='CASCADE') + shipment_out = fields.Many2One('stock.shipment.out', ondelete='CASCADE') + shipment_internal = fields.Many2One( + 'stock.shipment.internal', ondelete='CASCADE') currency = fields.Many2One('currency.currency',"Currency", required=True) supplier = fields.Many2One('party.party',"Supplier", required=True) type = fields.Selection([ diff --git a/modules/purchase_trade/purchase.py b/modules/purchase_trade/purchase.py index b156e86..c10790e 100755 --- a/modules/purchase_trade/purchase.py +++ b/modules/purchase_trade/purchase.py @@ -81,7 +81,7 @@ class Estimated(metaclass=PoolMeta): shipment_out = fields.Many2One('stock.shipment.out') shipment_internal = fields.Many2One('stock.shipment.internal') purchase = fields.Many2One('purchase.purchase',"Purchase") - line = fields.Many2One('purchase.line',"Line") + line = fields.Many2One('purchase.line',"Line") class Currency(metaclass=PoolMeta): "Currency" @@ -103,12 +103,12 @@ class Unit(metaclass=PoolMeta): def default_concatenate(cls): return False -class FeeLots(metaclass=PoolMeta): - - "Fee lots" - __name__ = 'fee.lots' - - line = fields.Many2One('purchase.line',"Line") +class FeeLots(metaclass=PoolMeta): + + "Fee lots" + __name__ = 'fee.lots' + + line = fields.Many2One('purchase.line',"Line", ondelete='CASCADE') class Component(metaclass=PoolMeta): "Component" diff --git a/modules/purchase_trade/sale.py b/modules/purchase_trade/sale.py index 2d75e41..34b2f9c 100755 --- a/modules/purchase_trade/sale.py +++ b/modules/purchase_trade/sale.py @@ -51,7 +51,7 @@ class FeeLots(metaclass=PoolMeta): "Fee lots" __name__ = 'fee.lots' - sale_line = fields.Many2One('sale.line',"Line") + sale_line = fields.Many2One('sale.line',"Line", ondelete='CASCADE') class Backtoback(metaclass=PoolMeta): 'Back To Back' @@ -2802,7 +2802,7 @@ class Fee(metaclass=PoolMeta): "Fee" __name__ = 'fee.fee' - sale_line = fields.Many2One('sale.line',"Line") + sale_line = fields.Many2One('sale.line',"Line", ondelete='CASCADE') class SaleAllocationsWizard(Wizard): 'Open Allocations report from Sale without modal' diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index 612008a..7c2c951 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -1218,6 +1218,8 @@ class PurchaseTradeTestCase(ModuleTestCase): currency=currency, shipment_in=None, sale_line=None, + shipment_out=None, + shipment_internal=None, unit=unit, ) fee.get_amount.return_value = None @@ -1228,6 +1230,44 @@ class PurchaseTradeTestCase(ModuleTestCase): purchase=Mock(id=7, currency=currency), unit=unit, ) + fee.line = line + 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, + 'lot.qt': Mock(), + }[name] + + values = Valuation.create_pnl_fee_from_line(line) + + self.assertEqual(values[0]['amount'], Decimal('0')) + self.assertEqual(values[0]['fee'], fee.id) + + def test_create_pnl_fee_from_line_ignores_orphan_fee_lot(self): + 'purchase fee valuation ignores fees without business attachment' + Valuation = Pool().get('valuation.valuation') + currency = Mock(id=1) + unit = Mock(id=2) + lot = Mock(id=5, sale_line=None, lot_type='virtual') + lot.get_current_quantity_converted.return_value = Decimal('10') + fee = Mock( + line=None, + sale_line=None, + shipment_in=None, + shipment_out=None, + shipment_internal=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)] @@ -1241,8 +1281,7 @@ class PurchaseTradeTestCase(ModuleTestCase): values = Valuation.create_pnl_fee_from_line(line) - self.assertEqual(values[0]['amount'], Decimal('0')) - self.assertEqual(values[0]['fee'], fee.id) + self.assertEqual(values, []) def test_fee_quantity_sync_uses_physical_lots_when_present(self): 'fee quantity sync ignores virtual lot once physical lots exist' @@ -1356,6 +1395,8 @@ class PurchaseTradeTestCase(ModuleTestCase): currency=currency, shipment_in=None, sale_line=None, + shipment_out=None, + shipment_internal=None, unit=unit, ) fee.is_effective_fee_lot.side_effect = lambda lot: lot is physical @@ -1367,6 +1408,7 @@ class PurchaseTradeTestCase(ModuleTestCase): purchase=Mock(id=8, currency=currency), unit=unit, ) + fee.line = line fee_lots = Mock() fee_lots.search.return_value = [Mock(fee=fee)] @@ -1670,6 +1712,8 @@ class PurchaseTradeTestCase(ModuleTestCase): currency=currency, shipment_in=None, sale_line=None, + shipment_out=None, + shipment_internal=None, unit=unit, ) line = Mock( @@ -1680,6 +1724,7 @@ class PurchaseTradeTestCase(ModuleTestCase): purchase=Mock(id=10, currency=currency), unit=unit, ) + fee.line = line lot_qt_model = Mock() lot_qt_model.search.return_value = [ Mock( @@ -1774,6 +1819,9 @@ class PurchaseTradeTestCase(ModuleTestCase): currency=currency, shipment_in=None, sale_line=sale_line, + line=None, + shipment_out=None, + shipment_internal=None, unit=unit, ) fee.is_effective_fee_lot.return_value = True diff --git a/modules/purchase_trade/valuation.py b/modules/purchase_trade/valuation.py index e6e91b8..cd28954 100644 --- a/modules/purchase_trade/valuation.py +++ b/modules/purchase_trade/valuation.py @@ -1181,6 +1181,37 @@ class ValuationBase(ModelSQL): if product: raise UserError("Currency is required on fee %s." % product) raise UserError("Currency is required on fee.") + + @classmethod + def _fee_has_business_anchor(cls, fee): + return any( + getattr(fee, field_name, None) + for field_name in ( + 'line', 'sale_line', 'shipment_in', + 'shipment_out', 'shipment_internal')) + + @classmethod + def _fee_matches_purchase_context(cls, fee, line, matched_sale_line=None): + if not cls._fee_has_business_anchor(fee): + return False + fee_line = getattr(fee, 'line', None) + if fee_line and cls._record_id(fee_line) == cls._record_id(line): + return True + fee_sale_line = getattr(fee, 'sale_line', None) + if (fee_sale_line and matched_sale_line + and cls._record_id(fee_sale_line) + == cls._record_id(matched_sale_line)): + return True + return bool(getattr(fee, 'shipment_in', None)) + + @classmethod + def _fee_matches_sale_context(cls, fee, sale_line): + if not cls._fee_has_business_anchor(fee): + return False + fee_sale_line = getattr(fee, 'sale_line', None) + if fee_sale_line: + return cls._record_id(fee_sale_line) == cls._record_id(sale_line) + return bool(getattr(fee, 'shipment_in', None)) @classmethod def create_pnl_fee_from_line(cls, line): @@ -1211,7 +1242,10 @@ class ValuationBase(ModelSQL): fees = [ e.fee for e in fl - if e.fee and ( + if e.fee + and cls._fee_matches_purchase_context( + e.fee, line, matched_sale_line) + and ( not hasattr(e.fee, 'is_effective_fee_lot') or e.fee.is_effective_fee_lot(lot)) ] @@ -1273,7 +1307,8 @@ class ValuationBase(ModelSQL): fees = [ e.fee for e in fl - if e.fee and (not e.fee.sale_line or e.fee.sale_line.id == sale_line.id) + if e.fee + and cls._fee_matches_sale_context(e.fee, sale_line) and ( not hasattr(e.fee, 'is_effective_fee_lot') or e.fee.is_effective_fee_lot(lot)) @@ -1525,7 +1560,6 @@ class ValuationDyn(ModelSQL,ModelView): r_strategy = fields.Many2One('mtm.strategy',"Strategy") r_lot = fields.Many2One('lot.lot',"Lot") r_shipment_in = fields.Many2One('stock.shipment.in', "Shipment In") - r_fee = fields.Many2One('fee.fee', "Fee") @classmethod def table_query(cls): @@ -1564,7 +1598,6 @@ class ValuationDyn(ModelSQL,ModelView): Max(val.strategy).as_('r_strategy'), Max(val.lot).as_('r_lot'), Max(val.shipment_in).as_('r_shipment_in'), - Max(val.fee).as_('r_fee'), where=wh, group_by=[val.type, val.counterparty, val.state, val.mtm_curve]) diff --git a/modules/purchase_trade/view/valuation_tree_sequence4.xml b/modules/purchase_trade/view/valuation_tree_sequence4.xml index 6febbdb..0070c03 100755 --- a/modules/purchase_trade/view/valuation_tree_sequence4.xml +++ b/modules/purchase_trade/view/valuation_tree_sequence4.xml @@ -4,7 +4,6 @@ this repository contains the full copyright notices and license terms. --> -