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.
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    MarkerTopicShort rule
    Quantityfee.quantitySum of linked lots in fee.lots.
    Stateempty fee.qt_stateUses the contract weight basis.
    Statefilled fee.qt_stateUses that state, capped by the weight basis.
    Fallbackstate missing on lotUses the closest previous state by sequence.
    Net / grossfee.weight_typenet reads quantity, brut reads gross_quantity.
    PackingppackPacking quantity, decoupled from weight.
    ControlPython + SQLApplication 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 + + + +### 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 + + + +### 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    CaseTarget state
    empty fee.qt_stateweight basis of the fee carrier contract
    filled fee.qt_statefee.qt_state, unless later than the weight basis
    fee.qt_state later than weight basisweight basis
    target state missing on lotclosest previous state by lot.qt.type.sequence
    no target state availablecurrent lot weight, only when no weight basis applies
    + +#### Weight Basis Source + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Feeweight basis
    Purchase feefee.line.purchase.wb.qt_type
    Sale feefee.sale_line.sale.wb.qt_type
    Shipment feepurchase weight basis of the lot when present
    Shipment fee without purchasesale weight basis of the lot when present
    + +#### Net / Gross + + + +#### Per Packing + + + +#### Lump Sum + + + +### 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 + + + +## Developer Section + +### Key Fields + + + +### Calculation Functions + + + +### Python Guards + + + +### SQL Diagnostic + + 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èreSujetRègle courte
    Quantitéfee.quantitySomme des lots liés dans fee.lots.
    Étatfee.qt_state videUtilise le weight basis du contrat.
    Étatfee.qt_state renseignéUtilise cet état, plafonné au weight basis.
    Fallbackétat absent sur le lotPrend l'état antérieur le plus proche par sequence.
    Net / brutfee.weight_typenet lit quantity, brut lit gross_quantity.
    PackingppackQuantité de packing, décorrélée du poids.
    ContrôlePython + SQLCheck 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 -## 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 -## 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 videweight 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 basisweight basis
    état cible absent du lotétat antérieur le plus proche par lot.qt.type.sequence
    aucun état cible disponiblepoids courant du lot, seulement si aucun weight basis ne s'applique
    + +#### Source du `weight basis` + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Feeweight basis
    Fee achatfee.line.purchase.wb.qt_type
    Fee ventefee.sale_line.sale.wb.qt_type
    Fee shipmentweight basis achat du lot si présent
    Fee shipment sans achatweight basis vente du lot si présent
    + +#### Net / brut + + + +#### Per packing + + + +#### Lump sum + + + +### 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 + +## Section développeur + +### Champs clés + + + +### Fonctions de calcul + + + +### Garde-fous Python + + + +### Diagnostic SQL + + 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`: + + + +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: + + + +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)