diff --git a/modules/purchase_trade/docs/business/INDEX.md b/modules/purchase_trade/docs/business/INDEX.md
index 0b6f923..0d2a858 100644
--- a/modules/purchase_trade/docs/business/INDEX.md
+++ b/modules/purchase_trade/docs/business/INDEX.md
@@ -17,7 +17,7 @@ Statut: `migration partielle`
Pricing manuel, basis, premium, linked currency: pricing.md
-Fees, freight, lots effectifs, % rate: fees.md
+Fees, freight, lots effectifs, quantités, % rate: FR / EN
Valuation, PnL, MTM, derivatives: FR / EN
@@ -70,7 +70,9 @@ Statut: `migration partielle`
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.
diff --git a/modules/purchase_trade/docs/business/fees.en.md b/modules/purchase_trade/docs/business/fees.en.md
new file mode 100644
index 0000000..32462ab
--- /dev/null
+++ b/modules/purchase_trade/docs/business/fees.en.md
@@ -0,0 +1,378 @@
+
+
+# Fees
+
+Language: `en`
+Mirror page: [fees.md](fees.md)
+Status: `partial migration`
+
+See also the historical technical page: `../fees.md`.
+
+Operational summaryA 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
+
+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:
+
+
+- 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.
+
+
diff --git a/modules/purchase_trade/docs/business/fees.md b/modules/purchase_trade/docs/business/fees.md
index 902b5b8..ba09536 100644
--- a/modules/purchase_trade/docs/business/fees.md
+++ b/modules/purchase_trade/docs/business/fees.md
@@ -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
+Résumé opérationnelUn 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`
-### Regle consultant
+
+
+
+| 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. |
+
+
+
-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
- Retrouver le lot physique depuis la facture.
@@ -28,46 +82,297 @@ shipment, pas d'un champ direct de la facture.
-## 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.
+
- Ne pas supprimer le lien virtuel : il reste le fallback.
-- Quantite
ppack: somme de lot.lot_qt des physiques.
+ - Les lots effectifs sont :
+
+- les physiques si au moins un physique est lié ;
-- Modes quantitatifs: quantites courantes converties des physiques.
+
- sinon les virtuels.
-- La meme selection s'applique au PnL fee.
+
-- Points de synchronisation: creation fee, lien
fee.lots, changement de quantity_theorical, weighing, suppression de physique.
+ - 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
+
+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.
+ - Formule :
amount = unit_price * quantity * (price / 100) * fin_int_delta / 360.
-- Source du delta: ligne
Estimated date avec trigger = bldate.
+ - 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 :
+
+
+- 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.
+
+
diff --git a/modules/purchase_trade/docs/business/sql/README.md b/modules/purchase_trade/docs/business/sql/README.md
index 66e02b9..80b63ad 100644
--- a/modules/purchase_trade/docs/business/sql/README.md
+++ b/modules/purchase_trade/docs/business/sql/README.md
@@ -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`:
+
+
+- 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:
+
+\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.
diff --git a/modules/purchase_trade/docs/business/sql/fee_quantity_consistency_checks.sql b/modules/purchase_trade/docs/business/sql/fee_quantity_consistency_checks.sql
new file mode 100644
index 0000000..65e8a46
--- /dev/null
+++ b/modules/purchase_trade/docs/business/sql/fee_quantity_consistency_checks.sql
@@ -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;
diff --git a/modules/purchase_trade/docs_source/business/INDEX.md b/modules/purchase_trade/docs_source/business/INDEX.md
index 37aa87a..dbf51ca 100644
--- a/modules/purchase_trade/docs_source/business/INDEX.md
+++ b/modules/purchase_trade/docs_source/business/INDEX.md
@@ -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.
diff --git a/modules/purchase_trade/docs_source/business/fees.en.md b/modules/purchase_trade/docs_source/business/fees.en.md
new file mode 100644
index 0000000..841f199
--- /dev/null
+++ b/modules/purchase_trade/docs_source/business/fees.en.md
@@ -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.
diff --git a/modules/purchase_trade/docs_source/business/fees.md b/modules/purchase_trade/docs_source/business/fees.md
index 47e9cbf..e6a824e 100644
--- a/modules/purchase_trade/docs_source/business/fees.md
+++ b/modules/purchase_trade/docs_source/business/fees.md
@@ -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.
diff --git a/modules/purchase_trade/docs_source/business/sql/README.md b/modules/purchase_trade/docs_source/business/sql/README.md
index 426119d..645894b 100644
--- a/modules/purchase_trade/docs_source/business/sql/README.md
+++ b/modules/purchase_trade/docs_source/business/sql/README.md
@@ -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.
diff --git a/modules/purchase_trade/fee.py b/modules/purchase_trade/fee.py
index d0b8e34..7e824e9 100755
--- a/modules/purchase_trade/fee.py
+++ b/modules/purchase_trade/fee.py
@@ -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)