Bug pnl fee

This commit is contained in:
2026-05-14 14:31:23 +02:00
parent 82a30f246d
commit 20a4e20e91
8 changed files with 603 additions and 36 deletions

View File

@@ -19,7 +19,7 @@ Statut: `migration partielle`
</li> </li>
<li style="margin:0.38rem 0;">Fees, freight, lots effectifs, <code>% rate</code>: <a href="fees.md">fees.md</a> <li style="margin:0.38rem 0;">Fees, freight, lots effectifs, <code>% rate</code>: <a href="fees.md">fees.md</a>
</li> </li>
<li style="margin:0.38rem 0;">Valuation, PnL, MTM, derivatives: <a href="valuation-pnl-mtm.md">valuation-pnl-mtm.md</a> <li style="margin:0.38rem 0;">Valuation, PnL, MTM, derivatives: <a href="valuation-pnl-mtm.md">FR</a> / <a href="valuation-pnl-mtm.en.md">EN</a>
</li> </li>
<li style="margin:0.38rem 0;">Factures provisoires/finales, padding: <a href="invoicing.md">invoicing.md</a> <li style="margin:0.38rem 0;">Factures provisoires/finales, padding: <a href="invoicing.md">invoicing.md</a>
</li> </li>
@@ -78,6 +78,8 @@ Statut: `migration partielle`
</li> </li>
<li style="margin:0.38rem 0;"><code>BR-PT-VAL-003</code>: MTM hors fees. <li style="margin:0.38rem 0;"><code>BR-PT-VAL-003</code>: MTM hors fees.
</li> </li>
<li style="margin:0.38rem 0;"><code>BR-PT-VAL-004</code>: snapshot courant PnL et identite economique.
</li>
<li style="margin:0.38rem 0;"><code>BR-PT-INV-001</code>: padding facture provisoire vente. <li style="margin:0.38rem 0;"><code>BR-PT-INV-001</code>: padding facture provisoire vente.
</li> </li>
<li style="margin:0.38rem 0;"><code>BR-PT-ACC-001</code>: Validate facture client attribue le numero. <li style="margin:0.38rem 0;"><code>BR-PT-ACC-001</code>: Validate facture client attribue le numero.

View File

@@ -0,0 +1,132 @@
<!-- Generated from docs_source/business by docs/tools/render_business_docs.py. -->
# Valuation, PnL, MTM
Status: `partial migration`
Language: `en`<br>
Mirror page: [valuation-pnl-mtm.md](valuation-pnl-mtm.md)
## BR-PT-VAL-001 - Valuation covers purchase, sale, and sale-first flows
Source: `BR-PT-004`, `BR-PT-006`, `BR-PT-011`
### Consultant Rule
PnL must exist for purchases and for sales, even when a sale is not yet matched
to a purchase.
### Developer Notes
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">An unmatched <code>sale.line</code> must generate at least <code>sale priced</code>, <code>sale fee</code>, and <code>derivative</code> when applicable.
</li>
<li style="margin:0.38rem 0;">A basis sale with no price detail must still produce a zero line or the economic fallback price according to the applicable rule.
</li>
<li style="margin:0.38rem 0;">Do not arbitrarily attach a single sale when several sales are matched to the same open balance.
</li>
</ul>
## BR-PT-VAL-002 - Valuation references
Source: `BR-PT-005`
### Consultant Rule
The PnL reference must describe the nature of the valued line: purchase or sale,
open or physical.
### Developer Notes
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">Allowed references: <code>Purchase/Open</code>, <code>Purchase/Physic</code>, <code>Sale/Open</code>, <code>Sale/Physic</code>.
</li>
<li style="margin:0.38rem 0;">A virtual lot must not be output with a physical reference.
</li>
</ul>
## BR-PT-VAL-003 - MTM excludes fees
Source: `BR-PT-007`
### Consultant Rule
Mark-to-market applies to prices and derivatives, not to fees.
### Developer Notes
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">MTM is allowed for <code>pur. priced</code>, <code>sale priced</code>, <code>derivative</code>.
</li>
<li style="margin:0.38rem 0;">Fees are outside MTM: <code>pur. fee</code>, <code>sale fee</code>, <code>shipment fee</code>, <code>line fee</code>.
</li>
<li style="margin:0.38rem 0;">For fees: <code>mtm_price</code>, <code>mtm</code>, <code>strategy</code> must stay empty.
</li>
</ul>
## BR-PT-VAL-004 - Current snapshot and economic identity
Source: session `2026-05-14`
### Consultant Rule
`valuation_valuation_line` represents the latest known PnL image.
It is not a history table.
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">A new generation replaces the previous generation for the same economic reality.
</li>
<li style="margin:0.38rem 0;">An unmatched sale owns its own PnL.
</li>
<li style="margin:0.38rem 0;">A matched sale is owned by the linked purchase line.
</li>
<li style="margin:0.38rem 0;">The PnL of a matched sale must not be generated twice: once from the sale side and once from the purchase side.
</li>
</ul>
### Developer Notes
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">Before creating rows in <code>valuation.valuation.line</code>, delete the current snapshot with the same economic identity.
</li>
<li style="margin:0.38rem 0;">Economic identity:
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;"><code>sale_line</code> when present, otherwise <code>line</code>;
</li>
<li style="margin:0.38rem 0;"><code>lot</code>;
</li>
<li style="margin:0.38rem 0;"><code>type</code>;
</li>
<li style="margin:0.38rem 0;"><code>reference</code>;
</li>
<li style="margin:0.38rem 0;"><code>counterparty</code>;
</li>
<li style="margin:0.38rem 0;"><code>product</code>;
</li>
<li style="margin:0.38rem 0;"><code>state</code>;
</li>
<li style="margin:0.38rem 0;"><code>strategy</code>.
</li>
</ul>
</li>
<li style="margin:0.38rem 0;">Do not include in the identity:
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;"><code>date</code>;
</li>
<li style="margin:0.38rem 0;"><code>price</code>;
</li>
<li style="margin:0.38rem 0;"><code>quantity</code>;
</li>
<li style="margin:0.38rem 0;"><code>amount</code>;
</li>
<li style="margin:0.38rem 0;"><code>mtm_price</code>;
</li>
<li style="margin:0.38rem 0;"><code>mtm</code>.
</li>
</ul>
</li>
<li style="margin:0.38rem 0;">These fields are recalculated results and must be replaced by the latest generation.
</li>
<li style="margin:0.38rem 0;"><code>generate_from_sale_line()</code> does not create a sale snapshot when the <code>sale.line</code> is already matched to a <code>purchase.line</code>; it redirects to the owner purchase line generation.
</li>
</ul>

View File

@@ -4,39 +4,42 @@
Statut: `migration partielle` Statut: `migration partielle`
Langue: `fr`<br>
Page miroir: [valuation-pnl-mtm.en.md](valuation-pnl-mtm.en.md)
## BR-PT-VAL-001 - La valuation couvre achat, vente et sale-first ## BR-PT-VAL-001 - La valuation couvre achat, vente et sale-first
Source: `BR-PT-004`, `BR-PT-006`, `BR-PT-011` Source: `BR-PT-004`, `BR-PT-006`, `BR-PT-011`
### Regle consultant ### Règle consultant
Le PnL doit exister pour les achats et pour les ventes, meme quand une vente Le PnL doit exister pour les achats et pour les ventes, même quand une vente
n'est pas encore matchee a un achat. n'est pas encore matchée à un achat.
### Notes developpeur ### Notes développeur
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;"> <ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">Une <code>sale.line</code> non matchee doit generer au minimum <code>sale priced</code>, <code>sale fee</code> et <code>derivative</code> si applicable. <li style="margin:0.38rem 0;">Une <code>sale.line</code> non matchée doit générer au minimum <code>sale priced</code>, <code>sale fee</code> et <code>derivative</code> si applicable.
</li> </li>
<li style="margin:0.38rem 0;">Une sale basis sans detail de prix doit quand meme produire une ligne a zero ou au prix economique fallback selon la regle applicable. <li style="margin:0.38rem 0;">Une sale basis sans détail de prix doit quand même produire une ligne à zéro ou au prix économique fallback selon la règle applicable.
</li> </li>
<li style="margin:0.38rem 0;">Ne pas attacher arbitrairement une sale unique si plusieurs sales sont matchees au meme ouvert. <li style="margin:0.38rem 0;">Ne pas attacher arbitrairement une sale unique si plusieurs sales sont matchées au même ouvert.
</li> </li>
</ul> </ul>
## BR-PT-VAL-002 - References de valuation ## BR-PT-VAL-002 - Références de valuation
Source: `BR-PT-005` Source: `BR-PT-005`
### Regle consultant ### Règle consultant
La reference de PnL doit decrire la nature de la ligne valorisee: achat ou La référence de PnL doit décrire la nature de la ligne valorisée: achat ou
vente, ouverte ou physique. vente, ouverte ou physique.
### Notes developpeur ### Notes développeur
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;"> <ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">References autorisees: <code>Purchase/Open</code>, <code>Purchase/Physic</code>, <code>Sale/Open</code>, <code>Sale/Physic</code>. <li style="margin:0.38rem 0;">Références autorisées: <code>Purchase/Open</code>, <code>Purchase/Physic</code>, <code>Sale/Open</code>, <code>Sale/Physic</code>.
</li> </li>
<li style="margin:0.38rem 0;">Un lot virtuel ne doit pas sortir avec une reference physique. <li style="margin:0.38rem 0;">Un lot virtuel ne doit pas sortir avec une reference physique.
</li> </li>
@@ -46,17 +49,84 @@ vente, ouverte ou physique.
Source: `BR-PT-007` Source: `BR-PT-007`
### Regle consultant ### Règle consultant
Le mark-to-market s'applique aux prix et aux derives, pas aux frais. Le mark-to-market s'applique aux prix et aux dérivés, pas aux frais.
### Notes developpeur ### Notes développeur
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;"> <ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">MTM autorise pour <code>pur. priced</code>, <code>sale priced</code>, <code>derivative</code>. <li style="margin:0.38rem 0;">MTM autorisé pour <code>pur. priced</code>, <code>sale priced</code>, <code>derivative</code>.
</li> </li>
<li style="margin:0.38rem 0;">Fees hors MTM: <code>pur. fee</code>, <code>sale fee</code>, <code>shipment fee</code>, <code>line fee</code>. <li style="margin:0.38rem 0;">Fees hors MTM: <code>pur. fee</code>, <code>sale fee</code>, <code>shipment fee</code>, <code>line fee</code>.
</li> </li>
<li style="margin:0.38rem 0;">Pour les fees: <code>mtm_price</code>, <code>mtm</code>, <code>strategy</code> doivent rester vides. <li style="margin:0.38rem 0;">Pour les fees: <code>mtm_price</code>, <code>mtm</code>, <code>strategy</code> doivent rester vides.
</li> </li>
</ul> </ul>
## BR-PT-VAL-004 - Snapshot courant et identité économique
Source: session `2026-05-14`
### Règle consultant
`valuation_valuation_line` représente la dernière image connue du PnL.
Elle n'est pas un historique.
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">Une nouvelle génération remplace la génération précédente pour la même réalité économique.
</li>
<li style="margin:0.38rem 0;">Une vente non matchée porte son propre PnL.
</li>
<li style="margin:0.38rem 0;">Une vente matchée est portée par la ligne d&#x27;achat liée.
</li>
<li style="margin:0.38rem 0;">Le PnL d&#x27;une vente matchée ne doit pas être généré deux fois: une fois côté vente et une fois côté achat.
</li>
</ul>
### Notes développeur
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">Avant création dans <code>valuation.valuation.line</code>, supprimer le snapshot courant ayant la même identité économique.
</li>
<li style="margin:0.38rem 0;">Identité économique:
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;"><code>sale_line</code> si elle existe, sinon <code>line</code>;
</li>
<li style="margin:0.38rem 0;"><code>lot</code>;
</li>
<li style="margin:0.38rem 0;"><code>type</code>;
</li>
<li style="margin:0.38rem 0;"><code>reference</code>;
</li>
<li style="margin:0.38rem 0;"><code>counterparty</code>;
</li>
<li style="margin:0.38rem 0;"><code>product</code>;
</li>
<li style="margin:0.38rem 0;"><code>state</code>;
</li>
<li style="margin:0.38rem 0;"><code>strategy</code>.
</li>
</ul>
</li>
<li style="margin:0.38rem 0;">Ne pas inclure dans l&#x27;identité:
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;"><code>date</code>;
</li>
<li style="margin:0.38rem 0;"><code>price</code>;
</li>
<li style="margin:0.38rem 0;"><code>quantity</code>;
</li>
<li style="margin:0.38rem 0;"><code>amount</code>;
</li>
<li style="margin:0.38rem 0;"><code>mtm_price</code>;
</li>
<li style="margin:0.38rem 0;"><code>mtm</code>.
</li>
</ul>
</li>
<li style="margin:0.38rem 0;">Ces champs sont des résultats recalculés et doivent être remplacés par la dernière génération.
</li>
<li style="margin:0.38rem 0;"><code>generate_from_sale_line()</code> ne crée pas de snapshot vente si la <code>sale.line</code> est déjà matchée à une <code>purchase.line</code>; il redirige vers la génération de la ligne d&#x27;achat propriétaire.
</li>
</ul>

View File

@@ -10,7 +10,7 @@ Statut: `migration partielle`
- Shipments, controllers, SLA, weight reports: [shipments-execution.md](shipments-execution.md) - Shipments, controllers, SLA, weight reports: [shipments-execution.md](shipments-execution.md)
- Pricing manuel, basis, premium, linked currency: [pricing.md](pricing.md) - Pricing manuel, basis, premium, linked currency: [pricing.md](pricing.md)
- Fees, freight, lots effectifs, `% rate`: [fees.md](fees.md) - Fees, freight, lots effectifs, `% rate`: [fees.md](fees.md)
- Valuation, PnL, MTM, derivatives: [valuation-pnl-mtm.md](valuation-pnl-mtm.md) - Valuation, PnL, MTM, derivatives: [FR](valuation-pnl-mtm.md) / [EN](valuation-pnl-mtm.en.md)
- Factures provisoires/finales, padding: [invoicing.md](invoicing.md) - Factures provisoires/finales, padding: [invoicing.md](invoicing.md)
- Impacts `account.move`, validate/post: [accounting-bridge.md](accounting-bridge.md) - Impacts `account.move`, validate/post: [accounting-bridge.md](accounting-bridge.md)
- Comptes bancaires, payment terms, payment orders: [payments-banking.md](payments-banking.md) - Comptes bancaires, payment terms, payment orders: [payments-banking.md](payments-banking.md)
@@ -41,6 +41,7 @@ Statut: `migration partielle`
- `BR-PT-VAL-001`: valuation achat/vente et sale-first. - `BR-PT-VAL-001`: valuation achat/vente et sale-first.
- `BR-PT-VAL-002`: references de valuation. - `BR-PT-VAL-002`: references de valuation.
- `BR-PT-VAL-003`: MTM hors fees. - `BR-PT-VAL-003`: MTM hors fees.
- `BR-PT-VAL-004`: snapshot courant PnL et identite economique.
- `BR-PT-INV-001`: padding facture provisoire vente. - `BR-PT-INV-001`: padding facture provisoire vente.
- `BR-PT-ACC-001`: Validate facture client attribue le numero. - `BR-PT-ACC-001`: Validate facture client attribue le numero.
- `BR-PT-PAY-001`: comptes bancaires tiers vs compagnie. - `BR-PT-PAY-001`: comptes bancaires tiers vs compagnie.

View File

@@ -0,0 +1,95 @@
# Valuation, PnL, MTM
Status: `partial migration`
Language: `en`<br>
Mirror page: [valuation-pnl-mtm.md](valuation-pnl-mtm.md)
## BR-PT-VAL-001 - Valuation covers purchase, sale, and sale-first flows
Source: `BR-PT-004`, `BR-PT-006`, `BR-PT-011`
### Consultant Rule
PnL must exist for purchases and for sales, even when a sale is not yet matched
to a purchase.
### Developer Notes
- An unmatched `sale.line` must generate at least `sale priced`, `sale fee`,
and `derivative` when applicable.
- A basis sale with no price detail must still produce a zero line or the
economic fallback price according to the applicable rule.
- Do not arbitrarily attach a single sale when several sales are matched to the
same open balance.
## BR-PT-VAL-002 - Valuation references
Source: `BR-PT-005`
### Consultant Rule
The PnL reference must describe the nature of the valued line: purchase or sale,
open or physical.
### Developer Notes
- Allowed references: `Purchase/Open`, `Purchase/Physic`, `Sale/Open`,
`Sale/Physic`.
- A virtual lot must not be output with a physical reference.
## BR-PT-VAL-003 - MTM excludes fees
Source: `BR-PT-007`
### Consultant Rule
Mark-to-market applies to prices and derivatives, not to fees.
### Developer Notes
- MTM is allowed for `pur. priced`, `sale priced`, `derivative`.
- Fees are outside MTM: `pur. fee`, `sale fee`, `shipment fee`, `line fee`.
- For fees: `mtm_price`, `mtm`, `strategy` must stay empty.
## BR-PT-VAL-004 - Current snapshot and economic identity
Source: session `2026-05-14`
### Consultant Rule
`valuation_valuation_line` represents the latest known PnL image.
It is not a history table.
- A new generation replaces the previous generation for the same economic
reality.
- An unmatched sale owns its own PnL.
- A matched sale is owned by the linked purchase line.
- The PnL of a matched sale must not be generated twice: once from the sale
side and once from the purchase side.
### Developer Notes
- Before creating rows in `valuation.valuation.line`, delete the current
snapshot with the same economic identity.
- Economic identity:
- `sale_line` when present, otherwise `line`;
- `lot`;
- `type`;
- `reference`;
- `counterparty`;
- `product`;
- `state`;
- `strategy`.
- Do not include in the identity:
- `date`;
- `price`;
- `quantity`;
- `amount`;
- `mtm_price`;
- `mtm`.
- These fields are recalculated results and must be replaced by the latest
generation.
- `generate_from_sale_line()` does not create a sale snapshot when the
`sale.line` is already matched to a `purchase.line`; it redirects to the
owner purchase line generation.

View File

@@ -2,36 +2,39 @@
Statut: `migration partielle` Statut: `migration partielle`
Langue: `fr`<br>
Page miroir: [valuation-pnl-mtm.en.md](valuation-pnl-mtm.en.md)
## BR-PT-VAL-001 - La valuation couvre achat, vente et sale-first ## BR-PT-VAL-001 - La valuation couvre achat, vente et sale-first
Source: `BR-PT-004`, `BR-PT-006`, `BR-PT-011` Source: `BR-PT-004`, `BR-PT-006`, `BR-PT-011`
### Regle consultant ### Règle consultant
Le PnL doit exister pour les achats et pour les ventes, meme quand une vente Le PnL doit exister pour les achats et pour les ventes, même quand une vente
n'est pas encore matchee a un achat. n'est pas encore matchée à un achat.
### Notes developpeur ### Notes développeur
- Une `sale.line` non matchee doit generer au minimum `sale priced`, `sale fee` - Une `sale.line` non matchée doit générer au minimum `sale priced`, `sale fee`
et `derivative` si applicable. et `derivative` si applicable.
- Une sale basis sans detail de prix doit quand meme produire une ligne a zero - Une sale basis sans détail de prix doit quand même produire une ligne à zéro
ou au prix economique fallback selon la regle applicable. ou au prix économique fallback selon la règle applicable.
- Ne pas attacher arbitrairement une sale unique si plusieurs sales sont - Ne pas attacher arbitrairement une sale unique si plusieurs sales sont
matchees au meme ouvert. matchées au même ouvert.
## BR-PT-VAL-002 - References de valuation ## BR-PT-VAL-002 - Références de valuation
Source: `BR-PT-005` Source: `BR-PT-005`
### Regle consultant ### Règle consultant
La reference de PnL doit decrire la nature de la ligne valorisee: achat ou La référence de PnL doit décrire la nature de la ligne valorisée: achat ou
vente, ouverte ou physique. vente, ouverte ou physique.
### Notes developpeur ### Notes développeur
- References autorisees: `Purchase/Open`, `Purchase/Physic`, `Sale/Open`, - Références autorisées: `Purchase/Open`, `Purchase/Physic`, `Sale/Open`,
`Sale/Physic`. `Sale/Physic`.
- Un lot virtuel ne doit pas sortir avec une reference physique. - Un lot virtuel ne doit pas sortir avec une reference physique.
@@ -39,13 +42,54 @@ vente, ouverte ou physique.
Source: `BR-PT-007` Source: `BR-PT-007`
### Regle consultant ### Règle consultant
Le mark-to-market s'applique aux prix et aux derives, pas aux frais. Le mark-to-market s'applique aux prix et aux dérivés, pas aux frais.
### Notes developpeur ### Notes développeur
- MTM autorise pour `pur. priced`, `sale priced`, `derivative`. - MTM autorisé pour `pur. priced`, `sale priced`, `derivative`.
- Fees hors MTM: `pur. fee`, `sale fee`, `shipment fee`, `line fee`. - Fees hors MTM: `pur. fee`, `sale fee`, `shipment fee`, `line fee`.
- Pour les fees: `mtm_price`, `mtm`, `strategy` doivent rester vides. - Pour les fees: `mtm_price`, `mtm`, `strategy` doivent rester vides.
## BR-PT-VAL-004 - Snapshot courant et identité économique
Source: session `2026-05-14`
### Règle consultant
`valuation_valuation_line` représente la dernière image connue du PnL.
Elle n'est pas un historique.
- Une nouvelle génération remplace la génération précédente pour la même
réalité économique.
- Une vente non matchée porte son propre PnL.
- Une vente matchée est portée par la ligne d'achat liée.
- Le PnL d'une vente matchée ne doit pas être généré deux fois: une fois côté
vente et une fois côté achat.
### Notes développeur
- Avant création dans `valuation.valuation.line`, supprimer le snapshot courant
ayant la même identité économique.
- Identité économique:
- `sale_line` si elle existe, sinon `line`;
- `lot`;
- `type`;
- `reference`;
- `counterparty`;
- `product`;
- `state`;
- `strategy`.
- Ne pas inclure dans l'identité:
- `date`;
- `price`;
- `quantity`;
- `amount`;
- `mtm_price`;
- `mtm`.
- Ces champs sont des résultats recalculés et doivent être remplacés par la
dernière génération.
- `generate_from_sale_line()` ne crée pas de snapshot vente si la `sale.line`
est déjà matchée à une `purchase.line`; il redirige vers la génération de la
ligne d'achat propriétaire.

View File

@@ -586,6 +586,103 @@ class PurchaseTradeTestCase(ModuleTestCase):
self.assertEqual(values[0]['sale'], sale.id) self.assertEqual(values[0]['sale'], sale.id)
self.assertEqual(values[0]['sale_line'], sale_line.id) self.assertEqual(values[0]['sale_line'], sale_line.id)
def test_purchase_pnl_fee_deduplicates_sale_lot_seen_twice(self):
'purchase-side fee pnl does not duplicate a sale lot already in matching'
Valuation = Pool().get('valuation.valuation')
currency = Mock(id=1)
unit = Mock(id=2)
product = Mock(id=3, name='Broker commission')
supplier = Mock(id=4)
sale = Mock(id=5)
sale_line = Mock(id=6, sale=sale, finished=False)
sale_lot = Mock(id=7, sale_line=sale_line, lot_type='virtual')
sale_lot.get_current_quantity_converted.return_value = Decimal('2')
fee = Mock(
product=product,
supplier=supplier,
type='budgeted',
p_r='pay',
mode='lumpsum',
price=Decimal('10'),
currency=currency,
shipment_in=None,
sale_line=sale_line,
unit=unit,
)
line = Mock(
id=9,
finished=False,
lots=[sale_lot],
get_matched_lines=Mock(return_value=[Mock(lot_s=sale_lot)]),
purchase=Mock(id=10, 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, 5, 14))),
'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]['type'], 'sale fee')
self.assertEqual(values[0]['lot'], sale_lot.id)
def test_snapshot_identity_prefers_sale_line_over_purchase_line(self):
'valuation snapshot identity ignores purchase line when sale line exists'
Valuation = Pool().get('valuation.valuation')
base = {
'purchase': 1,
'line': 2,
'sale': 3,
'sale_line': 4,
'lot': 5,
'type': 'sale fee',
'reference': 'Broker/Open',
'counterparty': 6,
'product': 7,
'state': 'budgeted',
'strategy': None,
'price': Decimal('10'),
'quantity': Decimal('2'),
'amount': Decimal('-20'),
}
regenerated = base.copy()
regenerated.update({
'purchase': None,
'line': None,
'price': Decimal('12'),
'quantity': Decimal('3'),
'amount': Decimal('-36'),
})
self.assertEqual(
Valuation._value_key(base),
Valuation._value_key(regenerated))
def test_generate_from_matched_sale_line_does_not_create_snapshot(self):
'matched sale line pnl is owned by the matched purchase line'
Valuation = Pool().get('valuation.valuation')
purchase_line = Mock(id=10)
sale_line = Mock()
sale_line.get_matched_lines.return_value = [
Mock(lot_p=Mock(line=purchase_line))]
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, 'generate') as generate:
Valuation.generate_from_sale_line(sale_line, valuation_type='fees')
generate.assert_called_once_with(purchase_line, valuation_type='fees')
delete_existing.assert_not_called()
create_fees.assert_not_called()
def test_purchase_open_pnl_fee_skips_zero_virtual_lot(self): def test_purchase_open_pnl_fee_skips_zero_virtual_lot(self):
'purchase-side fee pnl ignores open virtual lots with no quantity' 'purchase-side fee pnl ignores open virtual lots with no quantity'
Valuation = Pool().get('valuation.valuation') Valuation = Pool().get('valuation.valuation')

View File

@@ -136,6 +136,22 @@ class ValuationBase(ModelSQL):
values['sale'] = sale_line.sale.id values['sale'] = sale_line.sale.id
values['sale_line'] = sale_line.id values['sale_line'] = sale_line.id
@staticmethod
def _record_id(record):
return getattr(record, 'id', record)
@classmethod
def _unique_lots(cls, lots):
unique = []
seen = set()
for lot in lots:
lot_id = cls._record_id(lot)
if not lot or lot_id in seen:
continue
unique.append(lot)
seen.add(lot_id)
return tuple(unique)
@classmethod @classmethod
def _filter_values_by_types(cls, values, selected_types): def _filter_values_by_types(cls, values, selected_types):
if selected_types is None: if selected_types is None:
@@ -166,6 +182,105 @@ class ValuationBase(ModelSQL):
if valuation_lines: if valuation_lines:
ValuationLine.delete(valuation_lines) ValuationLine.delete(valuation_lines)
@classmethod
def _value_key(cls, value):
return (
value.get('sale_line') or None,
None if value.get('sale_line') else value.get('line'),
value.get('lot'),
value.get('type'),
value.get('reference'),
value.get('counterparty'),
value.get('product'),
value.get('state'),
value.get('strategy'),
)
@classmethod
def _dedupe_values(cls, values):
unique = []
seen = set()
for value in values:
key = cls._value_key(value)
if key in seen:
continue
unique.append(value)
seen.add(key)
return unique
@classmethod
def _snapshot_identity_domain(cls, value):
domain = [
('lot', '=', value.get('lot')),
('type', '=', value.get('type')),
('reference', '=', value.get('reference')),
('counterparty', '=', value.get('counterparty')),
('product', '=', value.get('product')),
('state', '=', value.get('state')),
('strategy', '=', value.get('strategy')),
]
if value.get('sale_line'):
domain.append(('sale_line', '=', value.get('sale_line')))
elif value.get('line'):
domain.append(('line', '=', value.get('line')))
elif value.get('sale'):
domain.append(('sale', '=', value.get('sale')))
elif value.get('purchase'):
domain.append(('purchase', '=', value.get('purchase')))
return domain
@classmethod
def _delete_existing_snapshot_values(cls, values):
if not values:
return
Date = Pool().get('ir.date')
Valuation = Pool().get('valuation.valuation')
ValuationLine = Pool().get('valuation.valuation.line')
seen = set()
for value in values:
if not value.get('lot') or not value.get('type'):
continue
key = cls._value_key(value)
if key in seen:
continue
seen.add(key)
valuation_line_domain = cls._snapshot_identity_domain(value)
valuation_domain = list(valuation_line_domain)
valuation_domain.append(('date', '=', Date.today()))
valuations = Valuation.search(valuation_domain)
if valuations:
Valuation.delete(valuations)
valuation_lines = ValuationLine.search(valuation_line_domain)
if valuation_lines:
ValuationLine.delete(valuation_lines)
@classmethod
def _matched_purchase_lines_from_sale_line(cls, sale_line):
getter = getattr(sale_line, 'get_matched_lines', None)
if not callable(getter):
return []
purchase_lines = []
seen = set()
matched_lines = getter() or []
try:
iterator = iter(matched_lines)
except TypeError:
return []
for matched_line in iterator:
lot_p = getattr(matched_line, 'lot_p', None)
line = getattr(lot_p, 'line', None)
line_id = cls._record_id(line)
if line and line_id not in seen:
purchase_lines.append(line)
seen.add(line_id)
return purchase_lines
@classmethod @classmethod
def _delete_existing_sale_line(cls, sale_line, selected_types=None): def _delete_existing_sale_line(cls, sale_line, selected_types=None):
Date = Pool().get('ir.date') Date = Pool().get('ir.date')
@@ -748,7 +863,7 @@ class ValuationBase(ModelSQL):
and not cls._ignore_finished_open_lot(line, s.lot_s) and not cls._ignore_finished_open_lot(line, s.lot_s)
and not cls._ignore_finished_open_lot(s.lot_s.sale_line, s.lot_s) and not cls._ignore_finished_open_lot(s.lot_s.sale_line, s.lot_s)
) )
all_lots = cls._valuation_lots(line) + sale_open_lots all_lots = cls._unique_lots(cls._valuation_lots(line) + sale_open_lots)
for lot in all_lots: for lot in all_lots:
if cls._ignore_empty_open_fee_lot(lot): if cls._ignore_empty_open_fee_lot(lot):
continue continue
@@ -955,6 +1070,8 @@ class ValuationBase(ModelSQL):
values.extend(cls.create_pnl_price_from_line(line)) values.extend(cls.create_pnl_price_from_line(line))
values.extend(cls.create_pnl_der_from_line(line)) values.extend(cls.create_pnl_der_from_line(line))
values = cls._filter_values_by_types(values, selected_types) values = cls._filter_values_by_types(values, selected_types)
values = cls._dedupe_values(values)
cls._delete_existing_snapshot_values(values)
if values: if values:
Valuation = Pool().get('valuation.valuation') Valuation = Pool().get('valuation.valuation')
@@ -964,6 +1081,13 @@ class ValuationBase(ModelSQL):
@classmethod @classmethod
def generate_from_sale_line(cls, sale_line, valuation_type='all'): def generate_from_sale_line(cls, sale_line, valuation_type='all'):
matched_purchase_lines = cls._matched_purchase_lines_from_sale_line(
sale_line)
if matched_purchase_lines:
for line in matched_purchase_lines:
cls.generate(line, valuation_type=valuation_type)
return
selected_types = cls._get_generate_types(valuation_type) selected_types = cls._get_generate_types(valuation_type)
cls._delete_existing_sale_line(sale_line, selected_types=selected_types) cls._delete_existing_sale_line(sale_line, selected_types=selected_types)
values = [] values = []
@@ -971,6 +1095,8 @@ class ValuationBase(ModelSQL):
values.extend(cls.create_pnl_price_from_sale_line(sale_line)) values.extend(cls.create_pnl_price_from_sale_line(sale_line))
values.extend(cls.create_pnl_der_from_sale_line(sale_line)) values.extend(cls.create_pnl_der_from_sale_line(sale_line))
values = cls._filter_values_by_types(values, selected_types) values = cls._filter_values_by_types(values, selected_types)
values = cls._dedupe_values(values)
cls._delete_existing_snapshot_values(values)
if values: if values:
Valuation = Pool().get('valuation.valuation') Valuation = Pool().get('valuation.valuation')