From 0f218374a74692421739ef7d03feb6ed846c8c09 Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Fri, 1 May 2026 15:12:22 +0200 Subject: [PATCH] Fee adj when th qt change --- AGENTS.md | 5 + modules/purchase_trade/AGENTS.md | 15 +++ modules/purchase_trade/docs/business-rules.md | 45 +++++++ modules/purchase_trade/fee.py | 105 ++++++++++++++-- modules/purchase_trade/lot.py | 106 +++++++++------- modules/purchase_trade/purchase.py | 3 + modules/purchase_trade/sale.py | 3 + modules/purchase_trade/tests/test_module.py | 119 ++++++++++++++++++ modules/purchase_trade/valuation.py | 44 ++++++- notes/business_rules.md | 14 +++ 10 files changed, 396 insertions(+), 63 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 580f77a..509c700 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,6 +71,11 @@ Guide rapide pour les agents qui codent dans ce repository. - `modules/purchase_trade/docs/business-rules.md` - Decisions templates / reports: - `notes/template_business_rules.md` +- Regles sensibles `purchase_trade` a relire avant de toucher lots, quantites + ou fees: + - `modules/purchase_trade/AGENTS.md` + - `modules/purchase_trade/docs/business-rules.md` BR-PT-020 / BR-PT-021 + (`quantity_theorical`, `lot.qt`, lots physiques, fees et PnL fee). ## 5) Workflow de modification (obligatoire) diff --git a/modules/purchase_trade/AGENTS.md b/modules/purchase_trade/AGENTS.md index 6ee3094..c27ee59 100644 --- a/modules/purchase_trade/AGENTS.md +++ b/modules/purchase_trade/AGENTS.md @@ -151,6 +151,21 @@ de negoce physique: taux de la provisoire - le wizard final doit calculer son delta depuis la quantite provisoire hors padding: `sale_invoice_line_prov.quantity - sale_invoice_padding` +- En modification de quantite contractuelle: + - `quantity_theorical` est la source contractuelle; `quantity` peut refleter + l'execute physique. + - le `lot.qt` libre doit etre resynchronise sur le solde ouvert reel: + `quantity_theorical - lots physiques - lot.qt deja matches/shippes`. + - ne jamais ajuster le `lot.qt` libre uniquement par delta si des lots + physiques existent deja. + - les fees suivent les lots effectifs: physiques s'il en existe dans + `fee.lots`, sinon virtuel. + - conserver le lien `fee.lots` vers le virtuel comme fallback; ne pas le + supprimer lors de la creation d'un physique. + - le PnL fee applique la meme regle: full open tant qu'il n'y a que le + virtuel, puis uniquement les physiques. + - detail durable: + `modules/purchase_trade/docs/business-rules.md` BR-PT-020 / BR-PT-021. ## 5) Conventions de modification diff --git a/modules/purchase_trade/docs/business-rules.md b/modules/purchase_trade/docs/business-rules.md index e5be24c..ed10264 100644 --- a/modules/purchase_trade/docs/business-rules.md +++ b/modules/purchase_trade/docs/business-rules.md @@ -587,6 +587,51 @@ Owner technique: `a completer` - exemple: une ligne achat passee de `10000` a `20000` avec deja `10000` physiques doit afficher `10000` ouverts et `10000` physiques, pas `20000` ouverts plus `10000` physiques. +- Impacts fees: + - apres toute modification de `quantity_theorical`, les fees de la ligne sont + resynchronises avec la regle BR-PT-021. + - si le fee est encore uniquement porte par le lot virtuel, sa quantite suit + donc la nouvelle quantite virtuelle. + - si le fee possede deja des lots physiques, une hausse ou baisse uniquement + ouverte n'impacte pas sa quantite. +- Priorite: + - `structurante` + +### BR-PT-021 - Les fees lies aux lots privilegient les physiques + +- Intent: eviter qu'un fee cree sur un lot virtuel reste calcule sur la + quantite contractuelle totale apres creation de lots physiques. +- Description: + - Le lien `fee.lots` avec le lot virtuel est conserve comme fallback. + - Des qu'un fee possede au moins un lot physique dans `fee.lots`, les lots + physiques deviennent la base effective du fee et le virtuel est ignore pour + la quantite. +- Resultat attendu: + - si aucun physique n'existe, `fee.quantity` suit le lot virtuel rattache au + fee. + - si des physiques existent, `fee.quantity` suit uniquement la somme des lots + physiques rattaches au fee. + - pour `ppack`, la somme se fait sur `lot.lot_qt` des physiques. + - pour les modes quantitatifs (`perqt`, `rate`, `pprice`, `pcost`), la somme + se fait sur les quantites courantes converties des lots physiques. + - supprimer le dernier physique fait retomber le fee sur son lot virtuel, + puisque le lien virtuel est conserve. + - le PnL fee applique la meme selection: full open tant qu'il n'y a que le + virtuel, puis uniquement les lots physiques effectifs. +- Points de synchronisation obligatoires: + - creation d'un fee lie a une ligne ou a un shipment + - ajout d'un lot physique dans `fee.lots` + - modification de `purchase.line.quantity_theorical` ou + `sale.line.quantity_theorical` + - weighing / modification de quantite d'un lot physique + - suppression d'un lot physique ou suppression d'un lien `fee.lots` +- Regle de conception: + - ne pas supprimer le lien `fee.lots` vers le lot virtuel lors de la creation + de physiques. + - le virtuel reste le fallback permettant de revenir au cas ouvert si tous + les physiques sont supprimes. + - toute logique metier, comptable ou PnL doit utiliser les lots effectifs du + fee: physiques s'il y en a, sinon virtuels. - Priorite: - `structurante` diff --git a/modules/purchase_trade/fee.py b/modules/purchase_trade/fee.py index 990813b..4aac271 100755 --- a/modules/purchase_trade/fee.py +++ b/modules/purchase_trade/fee.py @@ -331,6 +331,52 @@ class Fee(ModelSQL,ModelView): if lqts: return Decimal(lqts[0].lot_quantity) + def _get_effective_fee_lots(self): + FeeLots = Pool().get('fee.lots') + fee_lots = FeeLots.search([('fee', '=', self.id)]) + lots = [fee_lot.lot for fee_lot in fee_lots if fee_lot.lot] + physical_lots = [ + lot for lot in lots + if getattr(lot, 'lot_type', None) == 'physic' + ] + if physical_lots: + return physical_lots + return [ + lot for lot in lots + if getattr(lot, 'lot_type', None) == 'virtual' + ] + + def is_effective_fee_lot(self, lot): + return bool(lot and lot in self._get_effective_fee_lots()) + + def _get_effective_fee_lots_quantity(self): + lots = self._get_effective_fee_lots() + if not lots: + return None + if self.mode == 'ppack': + packing_qty = sum( + Decimal(str(getattr(lot, 'lot_qt', 0) or 0)) + for lot in lots) + if packing_qty: + return packing_qty + return self.quantity + return sum( + Decimal(lot.get_current_quantity_converted(0, self.unit) or 0) + for lot in lots) + + def sync_quantity_from_lots(self): + if self.mode == 'lumpsum': + return + quantity = self._get_effective_fee_lots_quantity() + if quantity is None: + return + quantity = round(Decimal(quantity), 5) + if self.mode == 'ppack': + quantity = quantity.to_integral_value(rounding=ROUND_UP) + if Decimal(self.quantity or 0) != quantity: + self.quantity = quantity + self.__class__.save([self]) + def _get_amount_quantity(self): quantity = self.quantity if quantity is None: @@ -423,16 +469,17 @@ class Fee(ModelSQL,ModelView): return super().copy(fees, default=default) - def get_fee_lots_qt(self,state_id=0): - qt = Decimal(0) - FeeLots = Pool().get('fee.lots') - fee_lots = FeeLots.search([('fee', '=', self.id)]) - if fee_lots: - qt = sum([e.lot.get_current_quantity_converted(state_id,self.unit) for e in fee_lots]) - logger.info("GET_FEE_LOTS_QT:%s",qt) - return qt - - def adjust_purchase_values(self): + def get_fee_lots_qt(self,state_id=0): + qt = Decimal(0) + lots = self._get_effective_fee_lots() + if lots: + qt = sum([ + e.get_current_quantity_converted(state_id,self.unit) + for e in lots]) + logger.info("GET_FEE_LOTS_QT:%s",qt) + return qt + + def adjust_purchase_values(self): Purchase = Pool().get('purchase.purchase') PurchaseLine = Pool().get('purchase.line') logger.info("ADJUST_PURCHASE_VALUES:%s",self) @@ -662,13 +709,45 @@ class Fee(ModelSQL,ModelView): lines=[move_line,move_line_], ) -class FeeLots(ModelSQL,ModelView): +class FeeLots(ModelSQL,ModelView): "Fee lots" __name__ = 'fee.lots' - fee = fields.Many2One('fee.fee',"Fee",required=True, ondelete='CASCADE') - lot = fields.Many2One('lot.lot',"Lot",required=True, ondelete='CASCADE') + fee = fields.Many2One('fee.fee',"Fee",required=True, ondelete='CASCADE') + lot = fields.Many2One('lot.lot',"Lot",required=True, ondelete='CASCADE') + + @classmethod + def create(cls, vlist): + records = super().create(vlist) + Fee = Pool().get('fee.fee') + fee_ids = { + record.fee.id for record in records if getattr(record, 'fee', None)} + for fee in Fee.browse(list(fee_ids)): + fee.sync_quantity_from_lots() + fee.adjust_purchase_values() + return records + + @classmethod + def write(cls, *args): + super().write(*args) + records = sum(args[::2], []) + Fee = Pool().get('fee.fee') + fee_ids = { + record.fee.id for record in records if getattr(record, 'fee', None)} + for fee in Fee.browse(list(fee_ids)): + fee.sync_quantity_from_lots() + fee.adjust_purchase_values() + + @classmethod + def delete(cls, records): + Fee = Pool().get('fee.fee') + fee_ids = { + record.fee.id for record in records if getattr(record, 'fee', None)} + super().delete(records) + for fee in Fee.browse(list(fee_ids)): + fee.sync_quantity_from_lots() + fee.adjust_purchase_values() class FeeReport( ModelSQL, ModelView): diff --git a/modules/purchase_trade/lot.py b/modules/purchase_trade/lot.py index 4d00b54..7f4e387 100755 --- a/modules/purchase_trade/lot.py +++ b/modules/purchase_trade/lot.py @@ -869,14 +869,15 @@ class Lot(metaclass=PoolMeta): def delete(cls, lots): pool = Pool() LL = pool.get('lot.lot') - FeeLots = pool.get('fee.lots') - lines_to_update = set() - sale_lines_to_update = set() - logger.info("DELETE_FROM_LOTS:%s",lots) - for lot in lots: - if lot.lot_type == 'physic': - if lot.line: - lines_to_update.add(lot.line.id) + FeeLots = pool.get('fee.lots') + lines_to_update = set() + sale_lines_to_update = set() + fees_to_sync = set() + logger.info("DELETE_FROM_LOTS:%s",lots) + for lot in lots: + if lot.lot_type == 'physic': + if lot.line: + lines_to_update.add(lot.line.id) if lot.sale_line: sale_lines_to_update.add(lot.sale_line.id) if lot.lot_parent: @@ -886,20 +887,27 @@ class Lot(metaclass=PoolMeta): pa.lot_quantity += lot.lot_quantity pa.lot_gross_quantity += lot.lot_gross_quantity LL.save([pa]) - logger.info("DELETE_FROM_LOTS2:%s",lot) - fls = FeeLots.search(['lot','=',lot.id]) - if fls: - FeeLots.delete(fls) - - super(Lot, cls).delete(lots) + logger.info("DELETE_FROM_LOTS2:%s",lot) + fls = FeeLots.search(['lot','=',lot.id]) + if fls: + fees_to_sync.update( + fl.fee.id for fl in fls if getattr(fl, 'fee', None)) + FeeLots.delete(fls) + + super(Lot, cls).delete(lots) for line_id in lines_to_update: cls._recompute_virtual_lot(Pool().get('purchase.line')(line_id)) cls._recalc_line_quantity(line_id,True) - for line_id in sale_lines_to_update: - cls._recompute_virtual_lot(Pool().get('sale.line')(line_id)) - cls._recalc_line_quantity(line_id,False) + for line_id in sale_lines_to_update: + cls._recompute_virtual_lot(Pool().get('sale.line')(line_id)) + cls._recalc_line_quantity(line_id,False) + + Fee = Pool().get('fee.fee') + for fee in Fee.browse(list(fees_to_sync)): + fee.sync_quantity_from_lots() + fee.adjust_purchase_values() @classmethod def _recalc_line_quantity(cls, line_id,purchase=True): @@ -1117,37 +1125,40 @@ class LotQt( if newlot.line.fees: for f in newlot.line.fees: exist = FeeLots.search([('fee','=',f.id),('lot','=',newlot.id)]) - if not exist: - fl =FeeLots() - fl.fee = f - fl.lot = newlot - fl.line = newlot.line - FeeLots.save([fl]) - #update ordered fee with physical lot quantity - f.adjust_purchase_values() + if not exist: + fl =FeeLots() + fl.fee = f + fl.lot = newlot + fl.line = newlot.line + FeeLots.save([fl]) + #update ordered fee with physical lot quantity + f.sync_quantity_from_lots() + f.adjust_purchase_values() if newlot.sale_line and newlot.sale_line.fees: for f in newlot.sale_line.fees: exist = FeeLots.search([('fee','=',f.id),('lot','=',newlot.id)]) - if not exist: - fl =FeeLots() - fl.fee = f - fl.lot = newlot - fl.sale_line = newlot.sale_line - FeeLots.save([fl]) - #update ordered fee with physical lot quantity - f.adjust_purchase_values() + if not exist: + fl =FeeLots() + fl.fee = f + fl.lot = newlot + fl.sale_line = newlot.sale_line + FeeLots.save([fl]) + #update ordered fee with physical lot quantity + f.sync_quantity_from_lots() + f.adjust_purchase_values() if newlot.lot_shipment_in and newlot.lot_shipment_in.fees: for f in newlot.lot_shipment_in.fees: exist = FeeLots.search([('fee','=',f.id),('lot','=',newlot.id)]) - if not exist: - fl =FeeLots() - fl.fee = f - fl.lot = newlot - FeeLots.save([fl]) - #update ordered fee with physical lot quantity - f.adjust_purchase_values() + if not exist: + fl =FeeLots() + fl.fee = f + fl.lot = newlot + FeeLots.save([fl]) + #update ordered fee with physical lot quantity + f.sync_quantity_from_lots() + f.adjust_purchase_values() return newlot, newlot.get_current_quantity_converted() @@ -3139,13 +3150,14 @@ class LotWeighing(Wizard): l.lot.lot_state = self.w.lot_state Lot.save([l.lot]) diff = round(Decimal(l.lot.get_current_quantity_converted() - quantity),5) - if diff != 0 : - #need to update virtual part - l.lot.updateVirtualPart(-diff,l.lot.lot_shipment_origin,l.lot.getVlot_s()) - #adjuts fee ordered with new quantity - fees = FeeLots.search(['lot','=',l.id]) - for f in fees: - f.fee.adjust_purchase_values() + if diff != 0 : + #need to update virtual part + l.lot.updateVirtualPart(-diff,l.lot.lot_shipment_origin,l.lot.getVlot_s()) + #adjuts fee ordered with new quantity + fees = FeeLots.search(['lot','=',l.lot.id]) + for f in fees: + f.fee.sync_quantity_from_lots() + f.fee.adjust_purchase_values() return 'end' diff --git a/modules/purchase_trade/purchase.py b/modules/purchase_trade/purchase.py index 500be7c..dfcd4a7 100755 --- a/modules/purchase_trade/purchase.py +++ b/modules/purchase_trade/purchase.py @@ -1565,6 +1565,9 @@ class Line(metaclass=PoolMeta): if target_quantity < 0: raise UserError("Please unlink or unmatch lot") cls._sync_open_lot_quantity(line, vlot, target_quantity) + for fee in line.fees or []: + fee.sync_quantity_from_lots() + fee.adjust_purchase_values() @classmethod def _sync_open_lot_quantity(cls, line, vlot, target_quantity): diff --git a/modules/purchase_trade/sale.py b/modules/purchase_trade/sale.py index f605d61..74df158 100755 --- a/modules/purchase_trade/sale.py +++ b/modules/purchase_trade/sale.py @@ -1681,6 +1681,9 @@ class SaleLine(metaclass=PoolMeta): if target_quantity < 0: raise UserError("Please unlink or unmatch lot") cls._sync_open_lot_quantity(line, vlot, target_quantity) + for fee in line.fees or []: + fee.sync_quantity_from_lots() + fee.adjust_purchase_values() @classmethod def _sync_open_lot_quantity(cls, line, vlot, target_quantity): diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index ba45613..fef2bd1 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -242,6 +242,84 @@ class PurchaseTradeTestCase(ModuleTestCase): self.assertEqual(values[0]['amount'], Decimal('0')) + def test_fee_quantity_sync_uses_physical_lots_when_present(self): + 'fee quantity sync ignores virtual lot once physical lots exist' + Fee = Pool().get('fee.fee') + fee = Fee() + fee.id = 1 + fee.mode = 'perqt' + fee.quantity = Decimal('100') + fee.unit = Mock() + virtual = Mock(lot_type='virtual') + virtual.get_current_quantity_converted.return_value = Decimal('100') + physical = Mock(lot_type='physic') + physical.get_current_quantity_converted.return_value = Decimal('40') + fee_lots = Mock() + fee_lots.search.return_value = [ + Mock(lot=virtual), + Mock(lot=physical), + ] + + with patch( + 'trytond.modules.purchase_trade.fee.Pool' + ) as PoolMock, patch.object(Fee, 'save') as save: + PoolMock.return_value.get.return_value = fee_lots + + fee.sync_quantity_from_lots() + + self.assertEqual(fee.quantity, Decimal('40.00000')) + save.assert_called_once_with([fee]) + + def test_purchase_fee_pnl_ignores_virtual_fee_lot_when_physical_exists(self): + 'purchase fee pnl uses physical fee lots instead of the residual virtual' + Valuation = Pool().get('valuation.valuation') + currency = Mock(id=1) + unit = Mock(id=2) + product = Mock(id=3, name='Handling') + supplier = Mock(id=4) + virtual = Mock(id=5, sale_line=None, lot_type='virtual') + virtual.get_current_quantity_converted.return_value = Decimal('60') + physical = Mock(id=6, sale_line=None, lot_type='physic') + physical.get_current_quantity_converted.return_value = Decimal('40') + fee = Mock( + product=product, + supplier=supplier, + type='budgeted', + p_r='pay', + mode='perqt', + price=Decimal('2'), + currency=currency, + shipment_in=None, + sale_line=None, + unit=unit, + ) + fee.is_effective_fee_lot.side_effect = lambda lot: lot is physical + fee.get_price_per_qt.return_value = Decimal('2') + line = Mock( + id=7, + lots=[virtual, physical], + get_matched_lines=Mock(return_value=[]), + purchase=Mock(id=8, 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, + 'lot.qt': Mock(), + }[name] + + values = Valuation.create_pnl_fee_from_line(line) + + self.assertEqual(len(values), 1) + self.assertEqual(values[0]['lot'], physical.id) + self.assertEqual(values[0]['quantity'], Decimal('40.00000')) + self.assertEqual(values[0]['amount'], Decimal('-80.00')) + def test_purchase_rate_fee_amount_uses_virtual_lot_quantity(self): 'purchase rate fee amount uses the financing delta as absolute period' Fee = Pool().get('fee.fee') @@ -1166,6 +1244,47 @@ class PurchaseTradeTestCase(ModuleTestCase): lot_model.save.assert_not_called() lotqt_model.save.assert_not_called() + def test_purchase_line_write_syncs_virtual_fee_quantity(self): + 'purchase line write updates fee quantity when only virtual lot exists' + PurchaseLine = Pool().get('purchase.line') + fee = Mock() + line = Mock(id=5, quantity_theorical=Decimal('20000')) + line.unit = Mock() + line.fees = [fee] + vlot = Mock(id=103, lot_type='virtual') + vlot.get_current_quantity_converted.return_value = Decimal('20000') + line.lots = [vlot] + lqt = Mock(lot_quantity=Decimal('20000')) + + lot_model = Mock() + lotqt_model = Mock() + lotqt_model.search.side_effect = [[lqt], []] + + 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('10000')}) + + self.assertEqual(lqt.lot_quantity, Decimal('10000.00000')) + fee.sync_quantity_from_lots.assert_called_once_with() + fee.adjust_purchase_values.assert_called_once_with() + 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') diff --git a/modules/purchase_trade/valuation.py b/modules/purchase_trade/valuation.py index 6999f53..e9bbdfc 100644 --- a/modules/purchase_trade/valuation.py +++ b/modules/purchase_trade/valuation.py @@ -697,6 +697,32 @@ class ValuationBase(ModelSQL): 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 _fee_amount_for_lot_or_zero(cls, fee, lot): + if fee.mode == 'ppack' and getattr(lot, 'lot_qt', None): + return round( + Decimal(fee.price or 0) + * Decimal(str(lot.lot_qt or 0)), + 2) + if fee.mode == 'rate': + line = fee.line or getattr(fee, 'sale_line', None) + if line and line.estimated_date: + est_lines = [ + dd for dd in line.estimated_date + if dd.trigger == 'bldate'] + est_line = est_lines[0] if est_lines else None + if est_line and est_line.fin_int_delta: + factor = ( + Decimal(fee.price or 0) / Decimal(100) + * Decimal(est_line.fin_int_delta) / Decimal(360)) + quantity = Decimal( + lot.get_current_quantity_converted(0, fee.unit) or 0) + return round( + factor * Decimal(line.unit_price or 0) * quantity, + 2) + return Decimal(0) + return cls._fee_amount_or_zero(fee) @classmethod def create_pnl_fee_from_line(cls, line): @@ -725,13 +751,20 @@ class ValuationBase(ModelSQL): if not fl: continue - fees = [e.fee for e in fl] + fees = [ + e.fee for e in fl + if e.fee and ( + not hasattr(e.fee, 'is_effective_fee_lot') + or e.fee.is_effective_fee_lot(lot)) + ] for sf in cls.group_fees_by_type_supplier(line, fees): sign = -1 if sf.p_r == 'pay' else 1 qty = round(lot.get_current_quantity_converted(), 5) + if sf.mode == 'ppack' and getattr(lot, 'lot_qt', None): + qty = Decimal(str(lot.lot_qt or 0)) if sf.mode == 'ppack' or sf.mode == 'rate': price = sf.price - amount = cls._fee_amount_or_zero(sf) * sign + amount = cls._fee_amount_for_lot_or_zero(sf, lot) * sign elif sf.mode == 'lumpsum': price = sf.price amount = sf.price * sign @@ -787,13 +820,18 @@ 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) + and ( + not hasattr(e.fee, 'is_effective_fee_lot') + or e.fee.is_effective_fee_lot(lot)) ] for sf in cls.group_fees_by_type_supplier(sale_line, fees): sign = -1 if sf.p_r == 'pay' else 1 qty = round(lot.get_current_quantity_converted(), 5) + if sf.mode == 'ppack' and getattr(lot, 'lot_qt', None): + qty = Decimal(str(lot.lot_qt or 0)) if sf.mode == 'ppack' or sf.mode == 'rate': price = sf.price - amount = cls._fee_amount_or_zero(sf) * sign + amount = cls._fee_amount_for_lot_or_zero(sf, lot) * sign elif sf.mode == 'lumpsum': price = sf.price amount = sf.price * sign diff --git a/notes/business_rules.md b/notes/business_rules.md index 839ce93..f15a6bc 100644 --- a/notes/business_rules.md +++ b/notes/business_rules.md @@ -180,3 +180,17 @@ elle existe, par exemple: `Please unlink or unmatch lot`. - Cas de reference: une ligne achat passee de `10000` a `20000` avec deja `10000` physiques doit rester a `10000` ouverts et `10000` physiques. + +### `fee.fee` / `fee.lots` + +- Le lien entre un fee et son lot virtuel est conserve comme fallback. +- Des qu'un fee possede au moins un lot physique dans `fee.lots`, les lots + physiques deviennent la base effective de `fee.quantity`. +- Une hausse purement ouverte de `quantity_theorical` n'impacte donc pas un fee + deja porte par des lots physiques. +- En `ppack`, `fee.quantity` suit la somme des `lot_qt` physiques; dans les + autres modes quantitatifs, elle suit la somme des quantites courantes des + lots physiques. +- A la suppression du dernier physique, le fee retombe sur son lot virtuel. +- Le PnL fee suit la meme logique: full open tant que seul le virtuel existe, + puis uniquement les lots physiques effectifs.