Fee check & rules

This commit is contained in:
2026-05-14 19:32:17 +02:00
parent 160ec1544b
commit 4395884754
10 changed files with 1496 additions and 68 deletions

View File

@@ -17,7 +17,7 @@ Statut: `migration partielle`
</li>
<li style="margin:0.38rem 0;">Pricing manuel, basis, premium, linked currency: <a href="pricing.md">pricing.md</a>
</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, quantités, <code>% rate</code>: <a href="fees.md">FR</a> / <a href="fees.en.md">EN</a>
</li>
<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>
@@ -70,7 +70,9 @@ Statut: `migration partielle`
</li>
<li style="margin:0.38rem 0;"><code>BR-PT-FEE-002</code>: lots effectifs des fees.
</li>
<li style="margin:0.38rem 0;"><code>BR-PT-FEE-003</code>: <code>% rate</code> via delta de financement.
<li style="margin:0.38rem 0;"><code>BR-PT-FEE-003</code>: quantité du fee alignée sur les lots effectifs.
</li>
<li style="margin:0.38rem 0;"><code>BR-PT-FEE-004</code>: <code>% rate</code> via delta de financement.
</li>
<li style="margin:0.38rem 0;"><code>BR-PT-VAL-001</code>: valuation achat/vente et sale-first.
</li>

View File

@@ -0,0 +1,378 @@
<!-- Generated from docs_source/business by docs/tools/render_business_docs.py. -->
# Fees
Language: `en`
Mirror page: [fees.md](fees.md)
Status: `partial migration`
See also the historical technical page: `../fees.md`.
<div style="border-left:0.28rem solid #4051b5; background:#f5f7ff; padding:0.85rem 1rem; margin:1rem 0 1.4rem 0; border-radius:0.35rem;"><strong style="display:block; margin-bottom:0.35rem;">Operational summary</strong><span>A fee carries an amount, but its quantity must remain aligned with the lots it applies to. <code>Per packing</code> is the only exception: it follows packing units, not lot weight.</span></div>
<table style="width:100%; border-collapse:collapse; margin:1rem 0 1.5rem 0; font-size:0.95rem;">
<thead>
<tr style="background:#eef3ff;">
<th style="text-align:left; padding:0.55rem 0.7rem; border-bottom:2px solid #4051b5;">Marker</th>
<th style="text-align:left; padding:0.55rem 0.7rem; border-bottom:2px solid #4051b5;">Topic</th>
<th style="text-align:left; padding:0.55rem 0.7rem; border-bottom:2px solid #4051b5;">Short rule</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Quantity</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;"><code>fee.quantity</code></td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Sum of linked lots in <code>fee.lots</code>.</td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">State</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">empty <code>fee.qt_state</code></td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Uses the contract <code>weight basis</code>.</td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">State</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">filled <code>fee.qt_state</code></td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Uses that state, capped by the <code>weight basis</code>.</td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Fallback</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">state missing on lot</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Uses the closest previous state by <code>sequence</code>.</td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Net / gross</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;"><code>fee.weight_type</code></td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;"><code>net</code> reads <code>quantity</code>, <code>brut</code> reads <code>gross_quantity</code>.</td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Packing</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;"><code>ppack</code></td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Packing quantity, decoupled from weight.</td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Control</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Python + SQL</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Application guard and SQL diagnostic.</td>
</tr>
</tbody>
</table>
## Consultant Rules
### BR-PT-FEE-001 - Freight value from shipment fee
Source: `BR-PT-003`
#### Consultant Rule
The freight value printed on invoice documents comes from the maritime freight
fee on the shipment, not from a direct invoice field.
#### Developer Notes
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">Find the physical lot from the invoice.
</li>
<li style="margin:0.38rem 0;">Find its <code>shipment_in</code>.
</li>
<li style="margin:0.38rem 0;">Search the <code>fee.fee</code> with <code>product.name = &#x27;Maritime freight&#x27;</code>.
</li>
<li style="margin:0.38rem 0;">Use <code>fee.get_amount()</code>.
</li>
</ul>
### BR-PT-FEE-002 - Effective lots for fees
Source: `BR-PT-021`
#### Consultant Rule
A fee follows the virtual lot until a physical lot is linked. As soon as a
physical lot is linked, physical lots become the calculation basis for the fee.
#### Developer Notes
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">Do not remove the virtual lot link: it remains the fallback.
</li>
<li style="margin:0.38rem 0;">Effective lots are:
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;">physical lots if at least one physical lot is linked;
</li>
<li style="margin:0.38rem 0;">otherwise virtual lots.
</li>
</ul>
</li>
<li style="margin:0.38rem 0;">The same selection applies to fee PnL.
</li>
<li style="margin:0.38rem 0;">Synchronization points:
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;">fee creation;
</li>
<li style="margin:0.38rem 0;"><code>fee.lots</code> link;
</li>
<li style="margin:0.38rem 0;"><code>quantity_theorical</code> change;
</li>
<li style="margin:0.38rem 0;">weighing;
</li>
<li style="margin:0.38rem 0;">physical lot removal.
</li>
</ul>
</li>
</ul>
### BR-PT-FEE-003 - Fee quantity
#### Consultant Rule
The quantity of a fee follows the quantity life cycle of its linked lots. It
must represent the sum of those lots in the authorized contractual quantity
state.
#### Short Rule
<pre style="background:#263238; color:#eef7ff; padding:1rem; border-radius:0.35rem; overflow:auto;"><code>fee.quantity = sum(applicable quantity of effective fee.lots)</code></pre>
#### Quantity State Selection
<table style="width:100%; border-collapse:collapse; margin:1rem 0 1.5rem 0; font-size:0.95rem;">
<thead>
<tr style="background:#eef3ff;">
<th style="text-align:left; padding:0.55rem 0.7rem; border-bottom:2px solid #4051b5;">Case</th>
<th style="text-align:left; padding:0.55rem 0.7rem; border-bottom:2px solid #4051b5;">Target state</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">empty <code>fee.qt_state</code></td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;"><code>weight basis</code> of the fee carrier contract</td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">filled <code>fee.qt_state</code></td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;"><code>fee.qt_state</code>, unless later than the <code>weight basis</code></td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;"><code>fee.qt_state</code> later than <code>weight basis</code></td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;"><code>weight basis</code></td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">target state missing on lot</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">closest previous state by <code>lot.qt.type.sequence</code></td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">no target state available</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">current lot weight, only when no <code>weight basis</code> applies</td>
</tr>
</tbody>
</table>
#### Weight Basis Source
<table style="width:100%; border-collapse:collapse; margin:1rem 0 1.5rem 0; font-size:0.95rem;">
<thead>
<tr style="background:#eef3ff;">
<th style="text-align:left; padding:0.55rem 0.7rem; border-bottom:2px solid #4051b5;">Fee</th>
<th style="text-align:left; padding:0.55rem 0.7rem; border-bottom:2px solid #4051b5;"><code>weight basis</code></th>
</tr>
</thead>
<tbody>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Purchase fee</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;"><code>fee.line.purchase.wb.qt_type</code></td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Sale fee</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;"><code>fee.sale_line.sale.wb.qt_type</code></td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Shipment fee</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">purchase weight basis of the lot when present</td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Shipment fee without purchase</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">sale weight basis of the lot when present</td>
</tr>
</tbody>
</table>
#### Net / Gross
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">If <code>fee.weight_type = net</code>:
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;">read <code>lot.qt.hist.quantity</code>.
</li>
</ul>
</li>
<li style="margin:0.38rem 0;">If <code>fee.weight_type = brut</code>:
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;">read <code>lot.qt.hist.gross_quantity</code>.
</li>
</ul>
</li>
</ul>
#### Per Packing
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;"><code>mode = ppack</code> is excluded from the weight rule.
</li>
<li style="margin:0.38rem 0;"><code>fee.quantity</code> represents a packing quantity.
</li>
<li style="margin:0.38rem 0;">This quantity may be decoupled from net or gross weight.
</li>
</ul>
#### Lump Sum
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;"><code>mode = lumpsum</code> also follows the quantity rule.
</li>
<li style="margin:0.38rem 0;">The amount remains fixed.
</li>
<li style="margin:0.38rem 0;">The quantity gives a more accurate per-ton price.
</li>
</ul>
### BR-PT-FEE-004 - `% rate` fees from financing delta
Source: historical `BR-PT-016` and `2026-04-30` notes
#### Consultant Rule
Percentage financial fees are calculated with the financing delta from the
`BL date` estimated line, not with the current date.
#### Developer Notes
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">Formula: <code>amount = unit_price * quantity * (price / 100) * fin_int_delta / 360</code>.
</li>
<li style="margin:0.38rem 0;">Delta source: <code>Estimated date</code> line with <code>trigger = bldate</code>.
</li>
<li style="margin:0.38rem 0;">If no <code>bldate</code> line exists, do not calculate a <code>% rate</code> amount.
</li>
</ul>
## Developer Section
### Key Fields
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">Fee: <code>fee.fee</code>
</li>
<li style="margin:0.38rem 0;">Fee lots: <code>fee.lots</code>
</li>
<li style="margin:0.38rem 0;">Fee quantity: <code>fee.fee.quantity</code>
</li>
<li style="margin:0.38rem 0;">Mode: <code>fee.fee.mode</code>
</li>
<li style="margin:0.38rem 0;">Optional quantity state: <code>fee.fee.qt_state</code>
</li>
<li style="margin:0.38rem 0;">Net / gross: <code>fee.fee.weight_type</code>
</li>
<li style="margin:0.38rem 0;">Lot quantity state: <code>lot.qt.hist.quantity_type</code>
</li>
<li style="margin:0.38rem 0;">Net quantity: <code>lot.qt.hist.quantity</code>
</li>
<li style="margin:0.38rem 0;">Gross quantity: <code>lot.qt.hist.gross_quantity</code>
</li>
<li style="margin:0.38rem 0;">State order: <code>lot.qt.type.sequence</code>
</li>
<li style="margin:0.38rem 0;">Purchase / sale weight basis: <code>purchase.weight.basis.qt_type</code>
</li>
</ul>
### Calculation Functions
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;"><code>Fee._get_effective_fee_lots()</code>:
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;">selects physical lots, then virtual lots.
</li>
</ul>
</li>
<li style="margin:0.38rem 0;"><code>Fee._target_qt_type_for_lot()</code>:
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;">chooses <code>fee.qt_state</code> or the <code>weight basis</code>;
</li>
<li style="margin:0.38rem 0;">caps the state at the <code>weight basis</code>.
</li>
</ul>
</li>
<li style="margin:0.38rem 0;"><code>Fee._select_lot_qt_type()</code>:
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;">takes the exact state when it exists;
</li>
<li style="margin:0.38rem 0;">otherwise takes the closest previous state by <code>sequence</code>.
</li>
</ul>
</li>
<li style="margin:0.38rem 0;"><code>Fee._get_lot_fee_quantity()</code>:
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;">reads net or gross depending on <code>weight_type</code>.
</li>
</ul>
</li>
<li style="margin:0.38rem 0;"><code>Fee.sync_quantity_from_lots()</code>:
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;">resynchronizes <code>fee.quantity</code>.
</li>
</ul>
</li>
</ul>
### Python Guards
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">Central check:
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;"><code>fee.fee.assert_quantity_consistency()</code>
</li>
<li style="margin:0.38rem 0;"><code>fee.fee.assert_quantities_consistency()</code>
</li>
</ul>
</li>
<li style="margin:0.38rem 0;">Trigger points:
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;">fee creation;
</li>
<li style="margin:0.38rem 0;">fee modification;
</li>
<li style="margin:0.38rem 0;"><code>fee.lots</code> creation / modification / deletion;
</li>
<li style="margin:0.38rem 0;">weighing through linked fee resynchronization.
</li>
</ul>
</li>
<li style="margin:0.38rem 0;">Exception:
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;"><code>mode = ppack</code> is not controlled as weight.
</li>
</ul>
</li>
</ul>
### SQL Diagnostic
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">Script:
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;"><a href="sql/fee_quantity_consistency_checks.sql">sql/fee_quantity_consistency_checks.sql</a>
</li>
</ul>
</li>
<li style="margin:0.38rem 0;">Usage:
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;">audit test databases;
</li>
<li style="margin:0.38rem 0;">audit historical data;
</li>
<li style="margin:0.38rem 0;">qualify data before correction.
</li>
</ul>
</li>
<li style="margin:0.38rem 0;">The script returns non-<code>ppack</code> fees where <code>fee.quantity</code> does not match the sum of effective lots according to the applicable quantity state.
</li>
</ul>

View File

@@ -2,20 +2,74 @@
# Fees
Statut: `migration partielle`
Langue : `fr`
Page miroir : [fees.en.md](fees.en.md)
Statut : `migration partielle`
Voir aussi la page technique historique: `../fees.md`.
Voir aussi la page technique historique : `../fees.md`.
## BR-PT-FEE-001 - Freight value depuis fee shipment
<div style="border-left:0.28rem solid #4051b5; background:#f5f7ff; padding:0.85rem 1rem; margin:1rem 0 1.4rem 0; border-radius:0.35rem;"><strong style="display:block; margin-bottom:0.35rem;">Résumé opérationnel</strong><span>Un fee porte un montant, mais sa quantité doit rester alignée avec les lots qui le composent. Le mode <code>Per packing</code> est la seule exception : il suit une quantité de colisage, pas le poids du lot.</span></div>
Source: `BR-PT-003`
### Regle consultant
<table style="width:100%; border-collapse:collapse; margin:1rem 0 1.5rem 0; font-size:0.95rem;">
<thead>
<tr style="background:#eef3ff;">
<th style="text-align:left; padding:0.55rem 0.7rem; border-bottom:2px solid #4051b5;">Repère</th>
<th style="text-align:left; padding:0.55rem 0.7rem; border-bottom:2px solid #4051b5;">Sujet</th>
<th style="text-align:left; padding:0.55rem 0.7rem; border-bottom:2px solid #4051b5;">Règle courte</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Quantité</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;"><code>fee.quantity</code></td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Somme des lots liés dans <code>fee.lots</code>.</td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">État</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;"><code>fee.qt_state</code> vide</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Utilise le <code>weight basis</code> du contrat.</td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">État</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;"><code>fee.qt_state</code> renseigné</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Utilise cet état, plafonné au <code>weight basis</code>.</td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Fallback</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">état absent sur le lot</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Prend l&#x27;état antérieur le plus proche par <code>sequence</code>.</td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Net / brut</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;"><code>fee.weight_type</code></td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;"><code>net</code> lit <code>quantity</code>, <code>brut</code> lit <code>gross_quantity</code>.</td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Packing</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;"><code>ppack</code></td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Quantité de packing, décorrélée du poids.</td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Contrôle</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Python + SQL</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Check applicatif et diagnostic SQL.</td>
</tr>
</tbody>
</table>
La valeur de fret affichee sur les documents facture vient du fee maritime du
## Règles consultant
### BR-PT-FEE-001 - Freight value depuis fee shipment
Source : `BR-PT-003`
#### Règle consultant
La valeur de fret affichée sur les documents facture vient du fee maritime du
shipment, pas d'un champ direct de la facture.
### Notes developpeur
#### 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;">Retrouver le lot physique depuis la facture.
@@ -28,46 +82,297 @@ shipment, pas d'un champ direct de la facture.
</li>
</ul>
## BR-PT-FEE-002 - Les fees lies aux lots privilegient les physiques
### BR-PT-FEE-002 - Lots effectifs des fees
Source: `BR-PT-021`
Source : `BR-PT-021`
### Regle consultant
#### Règle consultant
Un fee suit le lot virtuel tant qu'aucun lot physique n'est lie. Des qu'un lot
physique est lie, les lots physiques deviennent la base de calcul du fee.
Un fee suit le lot virtuel tant qu'aucun lot physique n'est lié. Dès qu'un lot
physique est lié, les lots physiques deviennent la base de calcul du fee.
### Notes developpeur
#### 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;">Ne pas supprimer le lien virtuel: il reste le fallback.
<li style="margin:0.38rem 0;">Ne pas supprimer le lien virtuel : il reste le fallback.
</li>
<li style="margin:0.38rem 0;">Quantite <code>ppack</code>: somme de <code>lot.lot_qt</code> des physiques.
<li style="margin:0.38rem 0;">Les lots effectifs sont :
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;">les physiques si au moins un physique est lié ;
</li>
<li style="margin:0.38rem 0;">Modes quantitatifs: quantites courantes converties des physiques.
<li style="margin:0.38rem 0;">sinon les virtuels.
</li>
<li style="margin:0.38rem 0;">La meme selection s&#x27;applique au PnL fee.
</ul>
</li>
<li style="margin:0.38rem 0;">Points de synchronisation: creation fee, lien <code>fee.lots</code>, changement de <code>quantity_theorical</code>, weighing, suppression de physique.
<li style="margin:0.38rem 0;">La même sélection s&#x27;applique au PnL fee.
</li>
<li style="margin:0.38rem 0;">Points de synchronisation :
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;">création fee ;
</li>
<li style="margin:0.38rem 0;">lien <code>fee.lots</code> ;
</li>
<li style="margin:0.38rem 0;">changement de <code>quantity_theorical</code> ;
</li>
<li style="margin:0.38rem 0;">weighing ;
</li>
<li style="margin:0.38rem 0;">suppression de physique.
</li>
</ul>
</li>
</ul>
## BR-PT-FEE-003 - Fees `% rate` via delta de financement
### BR-PT-FEE-003 - Quantité du fee
Source: `BR-PT-016` historique et notes `2026-04-30`
#### Règle consultant
### Regle consultant
La quantité d'un fee suit la vie de la quantité des lots qui lui sont liés. Elle
doit représenter la somme des quantités de ces lots dans l'état contractuel
autorisé.
#### Règle courte
<pre style="background:#263238; color:#eef7ff; padding:1rem; border-radius:0.35rem; overflow:auto;"><code>fee.quantity = somme(quantité applicable des lots effectifs fee.lots)</code></pre>
#### Sélection de l'état de quantité
<table style="width:100%; border-collapse:collapse; margin:1rem 0 1.5rem 0; font-size:0.95rem;">
<thead>
<tr style="background:#eef3ff;">
<th style="text-align:left; padding:0.55rem 0.7rem; border-bottom:2px solid #4051b5;">Cas</th>
<th style="text-align:left; padding:0.55rem 0.7rem; border-bottom:2px solid #4051b5;">État cible</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;"><code>fee.qt_state</code> vide</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;"><code>weight basis</code> du contrat porteur du fee</td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;"><code>fee.qt_state</code> renseigné</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;"><code>fee.qt_state</code>, sauf s&#x27;il est postérieur au <code>weight basis</code></td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;"><code>fee.qt_state</code> postérieur au <code>weight basis</code></td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;"><code>weight basis</code></td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">état cible absent du lot</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">état antérieur le plus proche par <code>lot.qt.type.sequence</code></td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">aucun état cible disponible</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">poids courant du lot, seulement si aucun <code>weight basis</code> ne s&#x27;applique</td>
</tr>
</tbody>
</table>
#### Source du `weight basis`
<table style="width:100%; border-collapse:collapse; margin:1rem 0 1.5rem 0; font-size:0.95rem;">
<thead>
<tr style="background:#eef3ff;">
<th style="text-align:left; padding:0.55rem 0.7rem; border-bottom:2px solid #4051b5;">Fee</th>
<th style="text-align:left; padding:0.55rem 0.7rem; border-bottom:2px solid #4051b5;"><code>weight basis</code></th>
</tr>
</thead>
<tbody>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Fee achat</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;"><code>fee.line.purchase.wb.qt_type</code></td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Fee vente</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;"><code>fee.sale_line.sale.wb.qt_type</code></td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Fee shipment</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">weight basis achat du lot si présent</td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Fee shipment sans achat</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">weight basis vente du lot si présent</td>
</tr>
</tbody>
</table>
#### Net / brut
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">Si <code>fee.weight_type = net</code> :
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;">lire <code>lot.qt.hist.quantity</code>.
</li>
</ul>
</li>
<li style="margin:0.38rem 0;">Si <code>fee.weight_type = brut</code> :
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;">lire <code>lot.qt.hist.gross_quantity</code>.
</li>
</ul>
</li>
</ul>
#### Per packing
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;"><code>mode = ppack</code> est exclu de la règle poids.
</li>
<li style="margin:0.38rem 0;"><code>fee.quantity</code> représente une quantité de packing.
</li>
<li style="margin:0.38rem 0;">Cette quantité peut être décorrélée du poids net ou brut.
</li>
</ul>
#### Lump sum
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;"><code>mode = lumpsum</code> suit aussi la règle de quantité.
</li>
<li style="margin:0.38rem 0;">Le montant reste forfaitaire.
</li>
<li style="margin:0.38rem 0;">La quantité permet de calculer un prix par tonne plus précis.
</li>
</ul>
### BR-PT-FEE-004 - Fees `% rate` via delta de financement
Source : `BR-PT-016` historique et notes `2026-04-30`
#### Règle consultant
Les frais financiers en pourcentage se calculent avec le delta de financement
de la ligne d'estimation `BL date`, pas avec la date du jour.
### Notes developpeur
#### 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;">Formule: <code>amount = unit_price * quantity * (price / 100) * fin_int_delta / 360</code>.
<li style="margin:0.38rem 0;">Formule : <code>amount = unit_price * quantity * (price / 100) * fin_int_delta / 360</code>.
</li>
<li style="margin:0.38rem 0;">Source du delta: ligne <code>Estimated date</code> avec <code>trigger = bldate</code>.
<li style="margin:0.38rem 0;">Source du delta : ligne <code>Estimated date</code> avec <code>trigger = bldate</code>.
</li>
<li style="margin:0.38rem 0;">Si aucune ligne <code>bldate</code> n&#x27;existe, ne pas calculer de montant <code>% rate</code>.
</li>
</ul>
## Section développeur
### Champs clés
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">Fee : <code>fee.fee</code>
</li>
<li style="margin:0.38rem 0;">Lots du fee : <code>fee.lots</code>
</li>
<li style="margin:0.38rem 0;">Quantité du fee : <code>fee.fee.quantity</code>
</li>
<li style="margin:0.38rem 0;">Mode : <code>fee.fee.mode</code>
</li>
<li style="margin:0.38rem 0;">État de quantité optionnel : <code>fee.fee.qt_state</code>
</li>
<li style="margin:0.38rem 0;">Net / brut : <code>fee.fee.weight_type</code>
</li>
<li style="margin:0.38rem 0;">État de quantité lot : <code>lot.qt.hist.quantity_type</code>
</li>
<li style="margin:0.38rem 0;">Quantité nette : <code>lot.qt.hist.quantity</code>
</li>
<li style="margin:0.38rem 0;">Quantité brute : <code>lot.qt.hist.gross_quantity</code>
</li>
<li style="margin:0.38rem 0;">Ordre des états : <code>lot.qt.type.sequence</code>
</li>
<li style="margin:0.38rem 0;">Weight basis achat / vente : <code>purchase.weight.basis.qt_type</code>
</li>
</ul>
### Fonctions de calcul
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;"><code>Fee._get_effective_fee_lots()</code> :
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;">sélectionne physiques puis virtuels.
</li>
</ul>
</li>
<li style="margin:0.38rem 0;"><code>Fee._target_qt_type_for_lot()</code> :
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;">choisit <code>fee.qt_state</code> ou le <code>weight basis</code> ;
</li>
<li style="margin:0.38rem 0;">plafonne l&#x27;état au <code>weight basis</code>.
</li>
</ul>
</li>
<li style="margin:0.38rem 0;"><code>Fee._select_lot_qt_type()</code> :
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;">prend l&#x27;état exact s&#x27;il existe ;
</li>
<li style="margin:0.38rem 0;">sinon prend l&#x27;état antérieur le plus proche par <code>sequence</code>.
</li>
</ul>
</li>
<li style="margin:0.38rem 0;"><code>Fee._get_lot_fee_quantity()</code> :
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;">lit net ou brut selon <code>weight_type</code>.
</li>
</ul>
</li>
<li style="margin:0.38rem 0;"><code>Fee.sync_quantity_from_lots()</code> :
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;">resynchronise <code>fee.quantity</code>.
</li>
</ul>
</li>
</ul>
### Garde-fous Python
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">Check central :
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;"><code>fee.fee.assert_quantity_consistency()</code>
</li>
<li style="margin:0.38rem 0;"><code>fee.fee.assert_quantities_consistency()</code>
</li>
</ul>
</li>
<li style="margin:0.38rem 0;">Points de déclenchement :
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;">création de fee ;
</li>
<li style="margin:0.38rem 0;">modification de fee ;
</li>
<li style="margin:0.38rem 0;">création / modification / suppression de <code>fee.lots</code> ;
</li>
<li style="margin:0.38rem 0;">weighing via resynchronisation des fees liés.
</li>
</ul>
</li>
<li style="margin:0.38rem 0;">Exception :
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;"><code>mode = ppack</code> n&#x27;est pas contrôlé comme un poids.
</li>
</ul>
</li>
</ul>
### Diagnostic SQL
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">Script :
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;"><a href="sql/fee_quantity_consistency_checks.sql">sql/fee_quantity_consistency_checks.sql</a>
</li>
</ul>
</li>
<li style="margin:0.38rem 0;">Usage :
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;">audit des bases de test ;
</li>
<li style="margin:0.38rem 0;">audit des données historiques ;
</li>
<li style="margin:0.38rem 0;">qualification avant correction.
</li>
</ul>
</li>
<li style="margin:0.38rem 0;">Le script remonte les fees non <code>ppack</code> dont <code>fee.quantity</code> ne correspond pas à la somme des lots effectifs selon l&#x27;état de quantité applicable.
</li>
</ul>

View File

@@ -57,3 +57,58 @@ Main columns:
Cross-category UoM rows are reported as manual-review diagnostics because the
Python code may pass explicit conversion factors that cannot be inferred safely
from SQL alone.
## fee_quantity_consistency_checks.sql
Checks the fee quantity invariant documented in `fees.md` and `fees.en.md`.
The script ignores `mode = 'ppack'` because Per packing follows a packing
quantity, not the lot net/gross weight.
For every other fee, it recomputes the expected quantity from the effective
`fee.lots`:
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">physical lots if at least one physical lot is linked;
</li>
<li style="margin:0.38rem 0;">otherwise virtual lots;
</li>
<li style="margin:0.38rem 0;"><code>fee.qt_state</code> when filled, capped by the contract <code>weight basis</code>;
</li>
<li style="margin:0.38rem 0;">otherwise the contract <code>weight basis</code>;
</li>
<li style="margin:0.38rem 0;">closest previous <code>lot.qt.hist</code> state by <code>lot.qt.type.sequence</code> when the exact state is missing;
</li>
<li style="margin:0.38rem 0;"><code>lot.qt.hist.quantity</code> for net fees and <code>lot.qt.hist.gross_quantity</code> for gross fees.
</li>
</ul>
Run it on a restored test database:
<pre style="background:#263238; color:#eef7ff; padding:1rem; border-radius:0.35rem; overflow:auto;"><code>\i modules/purchase_trade/docs/business/sql/fee_quantity_consistency_checks.sql</code></pre>
The script returns rows only when it finds a potential issue.
Main columns:
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;"><code>check_name</code>: failed invariant or manual-review diagnostic.
</li>
<li style="margin:0.38rem 0;"><code>fee_id</code>: <code>fee.fee</code> id.
</li>
<li style="margin:0.38rem 0;"><code>purchase_id</code> / <code>purchase_number</code>: related purchase contract when available.
</li>
<li style="margin:0.38rem 0;"><code>sale_id</code> / <code>sale_number</code>: related sale contract when available.
</li>
<li style="margin:0.38rem 0;"><code>observed_value</code>: current <code>fee.quantity</code>.
</li>
<li style="margin:0.38rem 0;"><code>expected_value</code>: recomputed quantity from effective lots.
</li>
<li style="margin:0.38rem 0;"><code>diff</code>: observed minus expected.
</li>
<li style="margin:0.38rem 0;"><code>detail</code>: human-readable explanation.
</li>
</ul>
Rows reported as `fee_quantity_uom_manual_review` need manual analysis because
SQL cannot safely infer cross-category conversion factors.

View File

@@ -0,0 +1,218 @@
/*
Read-only diagnostic for fee quantity consistency.
Business rule:
For every fee except Per packing, fee.quantity must equal the sum of the
applicable net/gross quantities of its effective lots.
Effective lots:
- physical lots if at least one physical lot is linked to the fee;
- otherwise virtual lots.
Quantity state:
- fee.qt_state when filled;
- otherwise the purchase/sale weight basis linked to the fee or lot;
- capped at the weight basis when fee.qt_state is later by sequence;
- fallback to the closest previous lot.qt.hist state by sequence.
*/
WITH linked_lots AS (
SELECT
f.id AS fee_id,
f.mode,
f.quantity AS fee_quantity,
f.unit AS fee_unit,
f.qt_state AS fee_qt_state,
f.weight_type,
f.line AS fee_purchase_line_id,
f.sale_line AS fee_sale_line_id,
f.shipment_in,
fl.lot AS lot_id,
l.lot_type,
l.lot_state,
l.lot_unit_line,
l.line AS lot_purchase_line_id,
l.sale_line AS lot_sale_line_id
FROM fee_fee f
JOIN fee_lots fl ON fl.fee = f.id
JOIN lot_lot l ON l.id = fl.lot
WHERE f.mode IS DISTINCT FROM 'ppack'
),
ranked_lots AS (
SELECT
ll.*,
bool_or(ll.lot_type = 'physic') OVER (PARTITION BY ll.fee_id)
AS has_physical_lot
FROM linked_lots ll
),
effective_lots AS (
SELECT *
FROM ranked_lots
WHERE
(has_physical_lot AND lot_type = 'physic')
OR (NOT has_physical_lot AND lot_type = 'virtual')
),
fee_context AS (
SELECT
el.*,
COALESCE(fpl.purchase, lpl.purchase) AS purchase_id,
COALESCE(fsl.sale, lsl.sale) AS sale_id,
pp.number AS purchase_number,
ss.number AS sale_number,
COALESCE(fpl.unit, fsl.unit, lpl.unit, lsl.unit) AS fallback_unit,
COALESCE(fpwb.qt_type, fswb.qt_type, lpwb.qt_type, lswb.qt_type)
AS weight_basis_qt_state
FROM effective_lots el
LEFT JOIN purchase_line fpl ON fpl.id = el.fee_purchase_line_id
LEFT JOIN sale_line fsl ON fsl.id = el.fee_sale_line_id
LEFT JOIN purchase_line lpl ON lpl.id = el.lot_purchase_line_id
LEFT JOIN sale_line lsl ON lsl.id = el.lot_sale_line_id
LEFT JOIN purchase_purchase pp ON pp.id = COALESCE(fpl.purchase, lpl.purchase)
LEFT JOIN sale_sale ss ON ss.id = COALESCE(fsl.sale, lsl.sale)
LEFT JOIN purchase_weight_basis fpwb ON fpwb.id = (
SELECT wb FROM purchase_purchase WHERE id = fpl.purchase)
LEFT JOIN purchase_weight_basis fswb ON fswb.id = (
SELECT wb FROM sale_sale WHERE id = fsl.sale)
LEFT JOIN purchase_weight_basis lpwb ON lpwb.id = pp.wb
LEFT JOIN purchase_weight_basis lswb ON lswb.id = ss.wb
),
target_state AS (
SELECT
fc.*,
CASE
WHEN fc.fee_qt_state IS NULL THEN fc.weight_basis_qt_state
WHEN fc.weight_basis_qt_state IS NULL THEN fc.fee_qt_state
WHEN fqts.sequence IS NOT NULL
AND wbqts.sequence IS NOT NULL
AND fqts.sequence > wbqts.sequence
THEN fc.weight_basis_qt_state
ELSE fc.fee_qt_state
END AS target_qt_state
FROM fee_context fc
LEFT JOIN lot_qt_type fqts ON fqts.id = fc.fee_qt_state
LEFT JOIN lot_qt_type wbqts ON wbqts.id = fc.weight_basis_qt_state
),
selected_quantities AS (
SELECT
ts.*,
qh.quantity_type AS selected_qt_state,
qh.quantity AS selected_net_quantity,
qh.gross_quantity AS selected_gross_quantity,
lu.category AS lot_unit_category,
tu.category AS fee_unit_category,
lu.factor AS lot_unit_factor,
tu.factor AS fee_unit_factor
FROM target_state ts
LEFT JOIN product_uom lu ON lu.id = ts.lot_unit_line
LEFT JOIN product_uom tu ON tu.id = COALESCE(ts.fee_unit, ts.fallback_unit)
LEFT JOIN LATERAL (
SELECT
hist.quantity_type,
hist.quantity,
hist.gross_quantity
FROM lot_qt_hist hist
LEFT JOIN lot_qt_type hist_type ON hist_type.id = hist.quantity_type
LEFT JOIN lot_qt_type target_type ON target_type.id = ts.target_qt_state
WHERE
hist.lot = ts.lot_id
AND (
(ts.target_qt_state IS NULL AND hist.quantity_type = ts.lot_state)
OR (ts.target_qt_state IS NOT NULL AND (
hist.quantity_type = ts.target_qt_state
OR (
hist_type.sequence IS NOT NULL
AND target_type.sequence IS NOT NULL
AND hist_type.sequence <= target_type.sequence)))
)
ORDER BY
CASE WHEN hist.quantity_type = ts.target_qt_state THEN 0 ELSE 1 END,
hist_type.sequence DESC NULLS LAST
LIMIT 1
) qh ON TRUE
),
computed_lot_quantities AS (
SELECT
*,
CASE
WHEN selected_qt_state IS NULL THEN NULL
WHEN lot_unit_category IS NULL OR fee_unit_category IS NULL THEN NULL
WHEN lot_unit_category <> fee_unit_category THEN NULL
WHEN fee_unit_factor IS NULL OR fee_unit_factor = 0 THEN NULL
WHEN weight_type = 'net'
THEN selected_net_quantity * lot_unit_factor / fee_unit_factor
ELSE selected_gross_quantity * lot_unit_factor / fee_unit_factor
END AS computed_quantity
FROM selected_quantities
),
computed_fees AS (
SELECT
fee_id,
mode,
fee_quantity,
purchase_id,
purchase_number,
sale_id,
sale_number,
bool_or(selected_qt_state IS NULL) AS has_missing_state,
bool_or(
lot_unit_category IS NULL
OR fee_unit_category IS NULL
OR lot_unit_category <> fee_unit_category
OR fee_unit_factor IS NULL
OR fee_unit_factor = 0) AS has_uom_issue,
round(sum(computed_quantity)::numeric, 5) AS expected_quantity
FROM computed_lot_quantities
GROUP BY
fee_id, mode, fee_quantity,
purchase_id, purchase_number, sale_id, sale_number
)
SELECT
'fee_quantity_mismatch' AS check_name,
fee_id,
purchase_id,
purchase_number,
sale_id,
sale_number,
round(COALESCE(fee_quantity, 0)::numeric, 5) AS observed_value,
expected_quantity AS expected_value,
round(COALESCE(fee_quantity, 0)::numeric - expected_quantity, 5) AS diff,
'fee.quantity must equal the sum of effective fee.lots quantities' AS detail
FROM computed_fees
WHERE
NOT has_missing_state
AND NOT has_uom_issue
AND round(COALESCE(fee_quantity, 0)::numeric, 5) <> expected_quantity
UNION ALL
SELECT
'fee_quantity_missing_state' AS check_name,
fee_id,
purchase_id,
purchase_number,
sale_id,
sale_number,
round(COALESCE(fee_quantity, 0)::numeric, 5) AS observed_value,
NULL::numeric AS expected_value,
NULL::numeric AS diff,
'no usable lot.qt.hist state was found for at least one effective fee lot' AS detail
FROM computed_fees
WHERE has_missing_state
UNION ALL
SELECT
'fee_quantity_uom_manual_review' AS check_name,
fee_id,
purchase_id,
purchase_number,
sale_id,
sale_number,
round(COALESCE(fee_quantity, 0)::numeric, 5) AS observed_value,
NULL::numeric AS expected_value,
NULL::numeric AS diff,
'UoM conversion cannot be inferred safely by SQL for at least one lot' AS detail
FROM computed_fees
WHERE has_uom_issue
ORDER BY check_name, fee_id;

View File

@@ -9,7 +9,7 @@ Statut: `migration partielle`
- Matching, Create Contracts, back-to-back: [matching.md](matching.md)
- Shipments, controllers, SLA, weight reports: [shipments-execution.md](shipments-execution.md)
- Pricing manuel, basis, premium, linked currency: [pricing.md](pricing.md)
- Fees, freight, lots effectifs, `% rate`: [fees.md](fees.md)
- Fees, freight, lots effectifs, quantités, `% rate`: [FR](fees.md) / [EN](fees.en.md)
- Valuation, PnL, MTM, derivatives: [FR](valuation-pnl-mtm.md) / [EN](valuation-pnl-mtm.en.md)
- Factures provisoires/finales, padding: [invoicing.md](invoicing.md)
- Impacts `account.move`, validate/post: [accounting-bridge.md](accounting-bridge.md)
@@ -37,7 +37,8 @@ Statut: `migration partielle`
- `BR-PT-PRI-003`: pricing manuel.
- `BR-PT-FEE-001`: maritime freight depuis fee shipment.
- `BR-PT-FEE-002`: lots effectifs des fees.
- `BR-PT-FEE-003`: `% rate` via delta de financement.
- `BR-PT-FEE-003`: quantité du fee alignée sur les lots effectifs.
- `BR-PT-FEE-004`: `% rate` via delta de financement.
- `BR-PT-VAL-001`: valuation achat/vente et sale-first.
- `BR-PT-VAL-002`: references de valuation.
- `BR-PT-VAL-003`: MTM hors fees.

View File

@@ -0,0 +1,185 @@
# Fees
Language: `en`
Mirror page: [fees.md](fees.md)
Status: `partial migration`
See also the historical technical page: `../fees.md`.
> **Operational summary**
> A fee carries an amount, but its quantity must remain aligned with the lots
> it applies to. `Per packing` is the only exception: it follows packing units,
> not lot weight.
| Marker | Topic | Short rule |
| --- | --- | --- |
| Quantity | `fee.quantity` | Sum of linked lots in `fee.lots`. |
| State | empty `fee.qt_state` | Uses the contract `weight basis`. |
| State | filled `fee.qt_state` | Uses that state, capped by the `weight basis`. |
| Fallback | state missing on lot | Uses the closest previous state by `sequence`. |
| Net / gross | `fee.weight_type` | `net` reads `quantity`, `brut` reads `gross_quantity`. |
| Packing | `ppack` | Packing quantity, decoupled from weight. |
| Control | Python + SQL | Application guard and SQL diagnostic. |
## Consultant Rules
### BR-PT-FEE-001 - Freight value from shipment fee
Source: `BR-PT-003`
#### Consultant Rule
The freight value printed on invoice documents comes from the maritime freight
fee on the shipment, not from a direct invoice field.
#### Developer Notes
- Find the physical lot from the invoice.
- Find its `shipment_in`.
- Search the `fee.fee` with `product.name = 'Maritime freight'`.
- Use `fee.get_amount()`.
### BR-PT-FEE-002 - Effective lots for fees
Source: `BR-PT-021`
#### Consultant Rule
A fee follows the virtual lot until a physical lot is linked. As soon as a
physical lot is linked, physical lots become the calculation basis for the fee.
#### Developer Notes
- Do not remove the virtual lot link: it remains the fallback.
- Effective lots are:
- physical lots if at least one physical lot is linked;
- otherwise virtual lots.
- The same selection applies to fee PnL.
- Synchronization points:
- fee creation;
- `fee.lots` link;
- `quantity_theorical` change;
- weighing;
- physical lot removal.
### BR-PT-FEE-003 - Fee quantity
#### Consultant Rule
The quantity of a fee follows the quantity life cycle of its linked lots. It
must represent the sum of those lots in the authorized contractual quantity
state.
#### Short Rule
```text
fee.quantity = sum(applicable quantity of effective fee.lots)
```
#### Quantity State Selection
| Case | Target state |
| --- | --- |
| empty `fee.qt_state` | `weight basis` of the fee carrier contract |
| filled `fee.qt_state` | `fee.qt_state`, unless later than the `weight basis` |
| `fee.qt_state` later than `weight basis` | `weight basis` |
| target state missing on lot | closest previous state by `lot.qt.type.sequence` |
| no target state available | current lot weight, only when no `weight basis` applies |
#### Weight Basis Source
| Fee | `weight basis` |
| --- | --- |
| Purchase fee | `fee.line.purchase.wb.qt_type` |
| Sale fee | `fee.sale_line.sale.wb.qt_type` |
| Shipment fee | purchase weight basis of the lot when present |
| Shipment fee without purchase | sale weight basis of the lot when present |
#### Net / Gross
- If `fee.weight_type = net`:
- read `lot.qt.hist.quantity`.
- If `fee.weight_type = brut`:
- read `lot.qt.hist.gross_quantity`.
#### Per Packing
- `mode = ppack` is excluded from the weight rule.
- `fee.quantity` represents a packing quantity.
- This quantity may be decoupled from net or gross weight.
#### Lump Sum
- `mode = lumpsum` also follows the quantity rule.
- The amount remains fixed.
- The quantity gives a more accurate per-ton price.
### BR-PT-FEE-004 - `% rate` fees from financing delta
Source: historical `BR-PT-016` and `2026-04-30` notes
#### Consultant Rule
Percentage financial fees are calculated with the financing delta from the
`BL date` estimated line, not with the current date.
#### Developer Notes
- Formula: `amount = unit_price * quantity * (price / 100) * fin_int_delta / 360`.
- Delta source: `Estimated date` line with `trigger = bldate`.
- If no `bldate` line exists, do not calculate a `% rate` amount.
## Developer Section
### Key Fields
- Fee: `fee.fee`
- Fee lots: `fee.lots`
- Fee quantity: `fee.fee.quantity`
- Mode: `fee.fee.mode`
- Optional quantity state: `fee.fee.qt_state`
- Net / gross: `fee.fee.weight_type`
- Lot quantity state: `lot.qt.hist.quantity_type`
- Net quantity: `lot.qt.hist.quantity`
- Gross quantity: `lot.qt.hist.gross_quantity`
- State order: `lot.qt.type.sequence`
- Purchase / sale weight basis: `purchase.weight.basis.qt_type`
### Calculation Functions
- `Fee._get_effective_fee_lots()`:
- selects physical lots, then virtual lots.
- `Fee._target_qt_type_for_lot()`:
- chooses `fee.qt_state` or the `weight basis`;
- caps the state at the `weight basis`.
- `Fee._select_lot_qt_type()`:
- takes the exact state when it exists;
- otherwise takes the closest previous state by `sequence`.
- `Fee._get_lot_fee_quantity()`:
- reads net or gross depending on `weight_type`.
- `Fee.sync_quantity_from_lots()`:
- resynchronizes `fee.quantity`.
### Python Guards
- Central check:
- `fee.fee.assert_quantity_consistency()`
- `fee.fee.assert_quantities_consistency()`
- Trigger points:
- fee creation;
- fee modification;
- `fee.lots` creation / modification / deletion;
- weighing through linked fee resynchronization.
- Exception:
- `mode = ppack` is not controlled as weight.
### SQL Diagnostic
- Script:
- [sql/fee_quantity_consistency_checks.sql](sql/fee_quantity_consistency_checks.sql)
- Usage:
- audit test databases;
- audit historical data;
- qualify data before correction.
- The script returns non-`ppack` fees where `fee.quantity` does not match the
sum of effective lots according to the applicable quantity state.

View File

@@ -1,55 +1,185 @@
# Fees
Statut: `migration partielle`
Langue : `fr`
Page miroir : [fees.en.md](fees.en.md)
Statut : `migration partielle`
Voir aussi la page technique historique: `../fees.md`.
Voir aussi la page technique historique : `../fees.md`.
## BR-PT-FEE-001 - Freight value depuis fee shipment
> **Résumé opérationnel**
> Un fee porte un montant, mais sa quantité doit rester alignée avec les lots
> qui le composent. Le mode `Per packing` est la seule exception : il suit une
> quantité de colisage, pas le poids du lot.
Source: `BR-PT-003`
| Repère | Sujet | Règle courte |
| --- | --- | --- |
| Quantité | `fee.quantity` | Somme des lots liés dans `fee.lots`. |
| État | `fee.qt_state` vide | Utilise le `weight basis` du contrat. |
| État | `fee.qt_state` renseigné | Utilise cet état, plafonné au `weight basis`. |
| Fallback | état absent sur le lot | Prend l'état antérieur le plus proche par `sequence`. |
| Net / brut | `fee.weight_type` | `net` lit `quantity`, `brut` lit `gross_quantity`. |
| Packing | `ppack` | Quantité de packing, décorrélée du poids. |
| Contrôle | Python + SQL | Check applicatif et diagnostic SQL. |
### Regle consultant
## Règles consultant
La valeur de fret affichee sur les documents facture vient du fee maritime du
### BR-PT-FEE-001 - Freight value depuis fee shipment
Source : `BR-PT-003`
#### Règle consultant
La valeur de fret affichée sur les documents facture vient du fee maritime du
shipment, pas d'un champ direct de la facture.
### Notes developpeur
#### Notes développeur
- Retrouver le lot physique depuis la facture.
- Retrouver son `shipment_in`.
- Chercher le `fee.fee` avec `product.name = 'Maritime freight'`.
- Utiliser `fee.get_amount()`.
## BR-PT-FEE-002 - Les fees lies aux lots privilegient les physiques
### BR-PT-FEE-002 - Lots effectifs des fees
Source: `BR-PT-021`
Source : `BR-PT-021`
### Regle consultant
#### Règle consultant
Un fee suit le lot virtuel tant qu'aucun lot physique n'est lie. Des qu'un lot
physique est lie, les lots physiques deviennent la base de calcul du fee.
Un fee suit le lot virtuel tant qu'aucun lot physique n'est lié. Dès qu'un lot
physique est lié, les lots physiques deviennent la base de calcul du fee.
### Notes developpeur
#### Notes développeur
- Ne pas supprimer le lien virtuel: il reste le fallback.
- Quantite `ppack`: somme de `lot.lot_qt` des physiques.
- Modes quantitatifs: quantites courantes converties des physiques.
- La meme selection s'applique au PnL fee.
- Points de synchronisation: creation fee, lien `fee.lots`, changement de
`quantity_theorical`, weighing, suppression de physique.
- Ne pas supprimer le lien virtuel : il reste le fallback.
- Les lots effectifs sont :
- les physiques si au moins un physique est lié ;
- sinon les virtuels.
- La même sélection s'applique au PnL fee.
- Points de synchronisation :
- création fee ;
- lien `fee.lots` ;
- changement de `quantity_theorical` ;
- weighing ;
- suppression de physique.
## BR-PT-FEE-003 - Fees `% rate` via delta de financement
### BR-PT-FEE-003 - Quantité du fee
Source: `BR-PT-016` historique et notes `2026-04-30`
#### Règle consultant
### Regle consultant
La quantité d'un fee suit la vie de la quantité des lots qui lui sont liés. Elle
doit représenter la somme des quantités de ces lots dans l'état contractuel
autorisé.
#### Règle courte
```text
fee.quantity = somme(quantité applicable des lots effectifs fee.lots)
```
#### Sélection de l'état de quantité
| Cas | État cible |
| --- | --- |
| `fee.qt_state` vide | `weight basis` du contrat porteur du fee |
| `fee.qt_state` renseigné | `fee.qt_state`, sauf s'il est postérieur au `weight basis` |
| `fee.qt_state` postérieur au `weight basis` | `weight basis` |
| état cible absent du lot | état antérieur le plus proche par `lot.qt.type.sequence` |
| aucun état cible disponible | poids courant du lot, seulement si aucun `weight basis` ne s'applique |
#### Source du `weight basis`
| Fee | `weight basis` |
| --- | --- |
| Fee achat | `fee.line.purchase.wb.qt_type` |
| Fee vente | `fee.sale_line.sale.wb.qt_type` |
| Fee shipment | weight basis achat du lot si présent |
| Fee shipment sans achat | weight basis vente du lot si présent |
#### Net / brut
- Si `fee.weight_type = net` :
- lire `lot.qt.hist.quantity`.
- Si `fee.weight_type = brut` :
- lire `lot.qt.hist.gross_quantity`.
#### Per packing
- `mode = ppack` est exclu de la règle poids.
- `fee.quantity` représente une quantité de packing.
- Cette quantité peut être décorrélée du poids net ou brut.
#### Lump sum
- `mode = lumpsum` suit aussi la règle de quantité.
- Le montant reste forfaitaire.
- La quantité permet de calculer un prix par tonne plus précis.
### BR-PT-FEE-004 - Fees `% rate` via delta de financement
Source : `BR-PT-016` historique et notes `2026-04-30`
#### Règle consultant
Les frais financiers en pourcentage se calculent avec le delta de financement
de la ligne d'estimation `BL date`, pas avec la date du jour.
### Notes developpeur
#### Notes développeur
- Formule: `amount = unit_price * quantity * (price / 100) * fin_int_delta / 360`.
- Source du delta: ligne `Estimated date` avec `trigger = bldate`.
- Formule : `amount = unit_price * quantity * (price / 100) * fin_int_delta / 360`.
- Source du delta : ligne `Estimated date` avec `trigger = bldate`.
- Si aucune ligne `bldate` n'existe, ne pas calculer de montant `% rate`.
## Section développeur
### Champs clés
- Fee : `fee.fee`
- Lots du fee : `fee.lots`
- Quantité du fee : `fee.fee.quantity`
- Mode : `fee.fee.mode`
- État de quantité optionnel : `fee.fee.qt_state`
- Net / brut : `fee.fee.weight_type`
- État de quantité lot : `lot.qt.hist.quantity_type`
- Quantité nette : `lot.qt.hist.quantity`
- Quantité brute : `lot.qt.hist.gross_quantity`
- Ordre des états : `lot.qt.type.sequence`
- Weight basis achat / vente : `purchase.weight.basis.qt_type`
### Fonctions de calcul
- `Fee._get_effective_fee_lots()` :
- sélectionne physiques puis virtuels.
- `Fee._target_qt_type_for_lot()` :
- choisit `fee.qt_state` ou le `weight basis` ;
- plafonne l'état au `weight basis`.
- `Fee._select_lot_qt_type()` :
- prend l'état exact s'il existe ;
- sinon prend l'état antérieur le plus proche par `sequence`.
- `Fee._get_lot_fee_quantity()` :
- lit net ou brut selon `weight_type`.
- `Fee.sync_quantity_from_lots()` :
- resynchronise `fee.quantity`.
### Garde-fous Python
- Check central :
- `fee.fee.assert_quantity_consistency()`
- `fee.fee.assert_quantities_consistency()`
- Points de déclenchement :
- création de fee ;
- modification de fee ;
- création / modification / suppression de `fee.lots` ;
- weighing via resynchronisation des fees liés.
- Exception :
- `mode = ppack` n'est pas contrôlé comme un poids.
### Diagnostic SQL
- Script :
- [sql/fee_quantity_consistency_checks.sql](sql/fee_quantity_consistency_checks.sql)
- Usage :
- audit des bases de test ;
- audit des données historiques ;
- qualification avant correction.
- Le script remonte les fees non `ppack` dont `fee.quantity` ne correspond pas
à la somme des lots effectifs selon l'état de quantité applicable.

View File

@@ -45,3 +45,44 @@ Main columns:
Cross-category UoM rows are reported as manual-review diagnostics because the
Python code may pass explicit conversion factors that cannot be inferred safely
from SQL alone.
## fee_quantity_consistency_checks.sql
Checks the fee quantity invariant documented in `fees.md` and `fees.en.md`.
The script ignores `mode = 'ppack'` because Per packing follows a packing
quantity, not the lot net/gross weight.
For every other fee, it recomputes the expected quantity from the effective
`fee.lots`:
- physical lots if at least one physical lot is linked;
- otherwise virtual lots;
- `fee.qt_state` when filled, capped by the contract `weight basis`;
- otherwise the contract `weight basis`;
- closest previous `lot.qt.hist` state by `lot.qt.type.sequence` when the exact
state is missing;
- `lot.qt.hist.quantity` for net fees and `lot.qt.hist.gross_quantity` for gross
fees.
Run it on a restored test database:
```sql
\i modules/purchase_trade/docs/business/sql/fee_quantity_consistency_checks.sql
```
The script returns rows only when it finds a potential issue.
Main columns:
- `check_name`: failed invariant or manual-review diagnostic.
- `fee_id`: `fee.fee` id.
- `purchase_id` / `purchase_number`: related purchase contract when available.
- `sale_id` / `sale_number`: related sale contract when available.
- `observed_value`: current `fee.quantity`.
- `expected_value`: recomputed quantity from effective lots.
- `diff`: observed minus expected.
- `detail`: human-readable explanation.
Rows reported as `fee_quantity_uom_manual_review` need manual analysis because
SQL cannot safely infer cross-category conversion factors.

View File

@@ -103,12 +103,9 @@ class Fee(ModelSQL,ModelView):
Date = Pool().get('ir.date')
return Date.today()
@classmethod
def default_qt_state(cls):
LotQtType = Pool().get('lot.qt.type')
lqt = LotQtType.search([('name','=','BL')])
if lqt:
return lqt[0].id
@classmethod
def default_qt_state(cls):
return None
@fields.depends('mode','unit')
def on_change_with_packing_category(self, name=None):
@@ -349,6 +346,81 @@ class Fee(ModelSQL,ModelView):
def is_effective_fee_lot(self, lot):
return bool(lot and lot in self._get_effective_fee_lots())
def _get_weight_basis_qt_type(self, lot=None):
if self.line and self.line.purchase:
return getattr(self.line.purchase.wb, 'qt_type', None)
if self.sale_line and self.sale_line.sale:
return getattr(self.sale_line.sale.wb, 'qt_type', None)
if lot and lot.line and lot.line.purchase:
return getattr(lot.line.purchase.wb, 'qt_type', None)
if lot and lot.sale_line and lot.sale_line.sale:
return getattr(lot.sale_line.sale.wb, 'qt_type', None)
@staticmethod
def _qt_type_sequence(qt_type):
sequence = getattr(qt_type, 'sequence', None)
if sequence is None:
return None
return Decimal(str(sequence))
@staticmethod
def _same_qt_type(left, right):
return (
getattr(left, 'id', left)
and getattr(left, 'id', left) == getattr(right, 'id', right))
def _target_qt_type_for_lot(self, lot):
weight_basis_qt_type = self._get_weight_basis_qt_type(lot)
target_qt_type = self.qt_state or weight_basis_qt_type
if not target_qt_type or not weight_basis_qt_type:
return target_qt_type
target_sequence = self._qt_type_sequence(target_qt_type)
weight_basis_sequence = self._qt_type_sequence(weight_basis_qt_type)
if (
target_sequence is not None
and weight_basis_sequence is not None
and target_sequence > weight_basis_sequence):
return weight_basis_qt_type
return target_qt_type
def _select_lot_qt_type(self, lot):
target_qt_type = self._target_qt_type_for_lot(lot)
if not target_qt_type:
return None
lot_hist = list(getattr(lot, 'lot_hist', []) or [])
for hist in lot_hist:
if self._same_qt_type(hist.quantity_type, target_qt_type):
return hist.quantity_type
target_sequence = self._qt_type_sequence(target_qt_type)
if target_sequence is None:
return None
previous = []
for hist in lot_hist:
hist_sequence = self._qt_type_sequence(hist.quantity_type)
if hist_sequence is not None and hist_sequence <= target_sequence:
previous.append((hist_sequence, hist.quantity_type))
if previous:
previous.sort(key=lambda item: item[0])
return previous[-1][1]
return None
def _get_lot_fee_quantity(self, lot):
state = self._select_lot_qt_type(lot)
target_qt_type = self._target_qt_type_for_lot(lot)
if target_qt_type and not state:
return None
state_id = getattr(state, 'id', 0) if state else 0
if self.weight_type == 'brut':
return Decimal(
lot.get_current_gross_quantity_converted(state_id, self.unit)
or 0)
return Decimal(
lot.get_current_quantity_converted(state_id, self.unit) or 0)
def _get_effective_fee_lots_quantity(self):
lots = self._get_effective_fee_lots()
if not lots:
@@ -360,22 +432,50 @@ class Fee(ModelSQL,ModelView):
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)
quantities = [self._get_lot_fee_quantity(lot) for lot in lots]
if any(quantity is None for quantity in quantities):
return None
return sum(quantities)
def sync_quantity_from_lots(self):
if self.mode == 'lumpsum':
return
quantity = self._get_effective_fee_lots_quantity()
if quantity is None:
self.assert_quantity_consistency()
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])
with Transaction().set_context(_purchase_trade_skip_fee_sync=True):
self.__class__.save([self])
self.assert_quantity_consistency()
def assert_quantity_consistency(self):
if self.mode == 'ppack':
return
expected = self._get_effective_fee_lots_quantity()
if expected is None:
if self._get_effective_fee_lots():
raise UserError(
"Fee quantity consistency error on fee %s: no usable "
"quantity state found on linked lots" % (
getattr(self, 'id', None),))
return
expected = round(Decimal(expected), 5)
observed = round(Decimal(self.quantity or 0), 5)
diff = observed - expected
if diff:
raise UserError(
"Fee quantity consistency error on fee %s: fee quantity "
"must equal linked lots quantity (observed=%s, expected=%s, "
"diff=%s)" % (
getattr(self, 'id', None), observed, expected, diff))
@classmethod
def assert_quantities_consistency(cls, fees):
for fee in fees:
fee.assert_quantity_consistency()
def _get_amount_quantity(self):
quantity = self.quantity
@@ -465,9 +565,12 @@ class Fee(ModelSQL,ModelView):
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])
if state_id:
qt = sum([
e.get_current_quantity_converted(state_id,self.unit)
for e in lots])
else:
qt = self._get_effective_fee_lots_quantity() or Decimal(0)
logger.info("GET_FEE_LOTS_QT:%s",qt)
return qt
@@ -706,6 +809,9 @@ class Fee(ModelSQL,ModelView):
cls._save_fee_accrual_or_warn(
account_move, fee, fl.lot)
for fee in fees:
fee.sync_quantity_from_lots()
cls.assert_quantities_consistency(fees)
cls._regenerate_fee_pnl(fees=fees)
return fees
@@ -716,9 +822,13 @@ class Fee(ModelSQL,ModelView):
old_purchase_lines = list(old_purchase_lines)
old_sale_lines = list(old_sale_lines)
super().write(*args)
if Transaction().context.get('_purchase_trade_skip_fee_sync'):
return
fees = sum(args[::2], [])
for fee in fees:
fee.sync_quantity_from_lots()
fee.adjust_purchase_values()
cls.assert_quantities_consistency(fees)
purchase_lines, sale_lines = cls._collect_pnl_lines(fees=fees)
cls._regenerate_fee_pnl_for_lines(
old_purchase_lines + list(purchase_lines),
@@ -835,6 +945,7 @@ class FeeLots(ModelSQL,ModelView):
for fee in Fee.browse(list(fee_ids)):
fee.sync_quantity_from_lots()
fee.adjust_purchase_values()
Fee.assert_quantities_consistency(Fee.browse(list(fee_ids)))
Fee._regenerate_fee_pnl(
fees=Fee.browse(list(fee_ids)),
lots=[record.lot for record in records if getattr(record, 'lot', None)])
@@ -853,6 +964,7 @@ class FeeLots(ModelSQL,ModelView):
for fee in Fee.browse(list(fee_ids)):
fee.sync_quantity_from_lots()
fee.adjust_purchase_values()
Fee.assert_quantities_consistency(Fee.browse(list(fee_ids)))
Fee._regenerate_fee_pnl(
fees=Fee.browse(list(fee_ids)),
lots=old_lots + [
@@ -869,6 +981,7 @@ class FeeLots(ModelSQL,ModelView):
for fee in Fee.browse(list(fee_ids)):
fee.sync_quantity_from_lots()
fee.adjust_purchase_values()
Fee.assert_quantities_consistency(Fee.browse(list(fee_ids)))
Fee._regenerate_fee_pnl(
fees=Fee.browse(list(fee_ids)), lots=lots)