Fee adj when th qt change

This commit is contained in:
2026-05-01 15:12:22 +02:00
parent e03dee7def
commit 0f218374a7
10 changed files with 396 additions and 63 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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`

View File

@@ -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):

View File

@@ -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'

View File

@@ -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):

View File

@@ -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):

View File

@@ -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')

View File

@@ -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

View File

@@ -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.