From 355831c76d6ce2f0ed690bb72e457645c996e834 Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Thu, 14 May 2026 10:55:47 +0200 Subject: [PATCH] docs --- .../purchase_trade/docs/business/README.md | 25 +- .../docs/business/lots-and-quantities.en.md | 343 +++++++++++++----- .../docs/business/lots-and-quantities.md | 343 +++++++++++++----- .../docs/tools/render_business_docs.py | 275 ++++++++++++++ .../business/lots-and-quantities.en.md | 300 +++++++++++++++ .../business/lots-and-quantities.md | 298 +++++++++++++++ 6 files changed, 1408 insertions(+), 176 deletions(-) create mode 100644 modules/purchase_trade/docs/tools/render_business_docs.py create mode 100644 modules/purchase_trade/docs_source/business/lots-and-quantities.en.md create mode 100644 modules/purchase_trade/docs_source/business/lots-and-quantities.md diff --git a/modules/purchase_trade/docs/business/README.md b/modules/purchase_trade/docs/business/README.md index 96e2499..8046a59 100644 --- a/modules/purchase_trade/docs/business/README.md +++ b/modules/purchase_trade/docs/business/README.md @@ -3,8 +3,14 @@ Statut: `migration partielle` Dernière mise à jour: `2026-05-13` -Ce dossier devient la source de lecture thématique pour les règles business du -module `purchase_trade`. +Ce dossier devient la source de lecture thématique publiée dans le wiki pour les +règles business du module `purchase_trade`. + +Certaines pages peuvent être générées depuis une source de vérité plus sobre, +rangée hors du dossier wiki dans `modules/purchase_trade/docs_source/`. Dans ce +cas, la page publiée dans `modules/purchase_trade/docs/` porte un commentaire +`Generated from ...` en tête de fichier et ne doit pas être modifiée +directement. Chaque page doit rester lisible par deux publics: @@ -31,6 +37,21 @@ Toute modification d'une règle business, d'un statut, d'un champ technique ou d'un point de vigilance doit être reportée dans les deux pages au même moment. Les deux pages doivent indiquer leur page miroir en en-tête. +## Convention source / wiki + +Pour les pages qui ont besoin d'une présentation riche dans MkDocs: + +- éditer la source de vérité dans `modules/purchase_trade/docs_source/`; +- régénérer la version wiki avec: + +```bash +python modules/purchase_trade/docs/tools/render_business_docs.py +``` + +Le rendu wiki privilégie du HTML simple et portable plutôt que des extensions +MkDocs optionnelles. Cela évite d'exposer dans le wiki des marqueurs non rendus +comme `!!!` ou `:material-...:`. + ## Convention de rédaction Pour chaque règle durable, utiliser autant que possible ce format: diff --git a/modules/purchase_trade/docs/business/lots-and-quantities.en.md b/modules/purchase_trade/docs/business/lots-and-quantities.en.md index e05f08f..0d8157d 100644 --- a/modules/purchase_trade/docs/business/lots-and-quantities.en.md +++ b/modules/purchase_trade/docs/business/lots-and-quantities.en.md @@ -1,4 +1,6 @@ -# :material-scale-balance: Lots and Quantities + + +# Lots and Quantities Language: `en` Mirror page: [lots-and-quantities.md](lots-and-quantities.md) @@ -10,116 +12,287 @@ This page consolidates `BR-PT-LOT-001`, `BR-PT-LOT-002`, and Goal: drive quantity rules from a readable business definition, then secure them with Python guards and SQL diagnostics. -## :material-bookmark-check: Key Points +## Key Points -!!! abstract ":material-compass-outline: Operational Summary" - A trade line has exactly one virtual lot. The virtual lot carries the - global open balance, `lot.qt` carries the operational forecast, and - physical lots consume that forecast. +
Operational SummaryA trade line has exactly one virtual lot. The virtual lot carries the global open balance, lot.qt carries the operational forecast, and physical lots consume that forecast.
-| Marker | Topic | Short Rule | -| --- | --- | -| :material-pencil: | Entered quantity | `quantity_theorical` is the business quantity. | -| :material-counter: | Standard quantity | `quantity` is a read-only technical counter. | -| :material-timeline-clock-outline: | Before physical lots | `quantity` follows `quantity_theorical`. | -| :material-package-variant-closed: | After physical lots | `quantity` reflects physical execution. | -| :material-lock-open-variant-outline: | Open line | Line amount uses `quantity_theorical`. | -| :material-check-circle-outline: | Finished line | Line amount may switch back to physical execution. | -| :material-weight: | Weight basis | Purchase and sale may read two different states of the same lot. | -| :material-file-document-check-outline: | Invoicing | It chooses states from `lot.qt.hist`. | -| :material-shield-check-outline: | Controls | Invariants are blocked in Python and auditable with SQL. | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TopicShort Rule
Entered quantityquantity_theorical is the business quantity.
Standard quantityquantity is a read-only technical counter.
Before physical lotsquantity follows quantity_theorical.
After physical lotsquantity reflects physical execution.
Open lineLine amount uses quantity_theorical.
Finished lineLine amount may switch back to physical execution.
Weight basisPurchase and sale may read two different states of the same lot.
InvoicingIt chooses states from lot.qt.hist.
ControlsInvariants are blocked in Python and auditable with SQL.
-```text -quantity_theorical +
quantity_theorical
         |
         v
-  virtual lot P1  --->  lot.qt forecast  --->  physical lot
+  virtual lot P1  --->  lot.qt forecast  --->  physical lot
         ^                    |
         |                    v
-        +------ recalculation after consumption
-```
+        +------ recalculation after consumption
-## :material-account-tie: Consultant Rules +## Consultant Rules ### BR-PT-LOT-001 - Virtual lot / forecast / physical life cycle -| Step | Moment | Business Effect | -| --- | --- | -| :material-plus-box-outline: | Line creation | One unique virtual lot is created. | -| :material-play-circle-outline: | Initialization | The virtual lot takes `quantity_theorical`. | -| :material-map-marker-path: | Forecast | One open line is created in `lot.qt`. | -| :material-source-branch: | Planning | `lot.qt` may be split by sale, matching, transport, or shipment. | -| :material-package-variant: | Physical add | The physical lot consumes a precise `lot.qt` line. | -| :material-refresh: | After add | `lot.qt` decreases and the virtual lot is recalculated. | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MomentBusiness Effect
Line creationOne unique virtual lot is created.
InitializationThe virtual lot takes quantity_theorical.
ForecastOne open line is created in lot.qt.
Planninglot.qt may be split by sale, matching, transport, or shipment.
Physical addThe physical lot consumes a precise lot.qt line.
After addlot.qt decreases and the virtual lot is recalculated.
-!!! example ":material-source-branch: Split of an open P1 balance" - `P1S1T1`, `P1S1T2`, `P1S2T3`, `P1S2T4` +
Split of an open P1 balanceP1S1T1, P1S1T2, P1S2T3, P1S2T4
-!!! note ":material-lightbulb-outline: Key Point" - These splits do not create several virtual lots. They only describe the - forecast allocation of the open balance. +
Key PointThese splits do not create several virtual lots. They only describe the forecast allocation of the open balance.
### BR-PT-LOT-002 - Contractual quantity, counter quantity, finished line -| Marker | Situation | Reference Quantity | -| --- | --- | -| :material-pencil: | User entry | `quantity_theorical` | -| :material-package-variant-remove: | No physical lot | `quantity = quantity_theorical` | -| :material-package-variant-closed: | Physical lots exist | `quantity = sum of physical lots` | -| :material-lock-open-variant-outline: | `finished = False` | Amount based on `quantity_theorical` | -| :material-check-circle-outline: | `finished = True` | Amount based on physical execution | -| :material-weight: | Weight basis available | Amount based on the contract `wb.qt_type` state | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SituationReference Quantity
User entryquantity_theorical
No physical lotquantity = quantity_theorical
Physical lots existquantity = sum of physical lots
finished = FalseAmount based on quantity_theorical
finished = TrueAmount based on physical execution
Weight basis availableAmount based on the contract wb.qt_type state
-!!! warning ":material-alert-outline: What `finished` Does Not Do" - `finished` does not erase the contractual quantity, delete physical lots, - or hide their PnL. It only means that the open balance may be ignored for - execution calculations. +
What finished Does Not Dofinished does not erase the contractual quantity, delete physical lots, or hide their PnL. It only means that the open balance may be ignored for execution calculations.
-| Marker | Reading of the Same Physical Lot | Possible State | -| --- | --- | -| :material-cart-arrow-down: | Purchase | BL through `purchase.purchase.wb.qt_type` | -| :material-cart-arrow-up: | Sale | LR or Weight Report through `sale.sale.wb.qt_type` | -| :material-file-document-check-outline: | Invoicing | Independent choice in `lot.qt.hist` | + + + + + + + + + + + + + + + + + + + + + +
Reading of the Same Physical LotPossible State
PurchaseBL through purchase.purchase.wb.qt_type
SaleLR or Weight Report through sale.sale.wb.qt_type
InvoicingIndependent choice in lot.qt.hist
### BR-PT-LOT-003 - Quantity Invariants -| Marker | Invariant | Formula | -| --- | --- | -| :material-shield-check-outline: | Conservation | `sum(physical lots) + virtual lot = quantity_theorical` | -| :material-chart-timeline-variant: | Open forecast | `sum(non-zero lot.qt) = max(virtual lot, 0)` | + + + + + + + + + + + + + + + + + +
InvariantFormula
Conservationsum(physical lots) + virtual lot = quantity_theorical
Open forecastsum(non-zero lot.qt) = max(virtual lot, 0)
-| Marker | Case | Rule | -| --- | --- | -| :material-cart-arrow-down: | Purchase virtual lot | Sum all `lot.qt` where `lot_p = virtual lot`, with or without `lot_s`. | -| :material-cart-arrow-up: | Sale virtual lot | Sum all `lot.qt` where `lot_s = virtual lot`, with or without `lot_p`. | -| :material-numeric-0-box-outline: | `lot.qt = 0` | Ignored by checks; possible memory of a consumed forecast. | -| :material-minus-circle-outline: | Negative virtual lot | Allowed to compensate theoretical / executed gap; expected forecast = zero. | -| :material-alert-octagon-outline: | Non-zero orphan `lot.qt` | Forbidden if neither `lot_p` nor `lot_s` is set. | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CaseRule
Purchase virtual lotSum all lot.qt where lot_p = virtual lot, with or without lot_s.
Sale virtual lotSum all lot.qt where lot_s = virtual lot, with or without lot_p.
lot.qt = 0Ignored by checks; possible memory of a consumed forecast.
Negative virtual lotAllowed to compensate theoretical / executed gap; expected forecast = zero.
Non-zero orphan lot.qtForbidden if neither lot_p nor lot_s is set.
### BR-PT-LOT-004 - Quantity history and weighing -| Marker | Element | Rule | -| --- | --- | -| :material-history: | `lot.qt.hist` | Carries lot quantity states. | -| :material-form-textbox: | `lot.lot` form | No direct state entry. | -| :material-scale: | Update | Only through `Do weighing`. | -| :material-cloud-outline: | Virtual lot | No manual packing. | -| :material-lock-outline: | Virtual packing fields | `lot_qt` and `lot_unit` are not editable. | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ElementRule
lot.qt.histCarries lot quantity states.
lot.lot formNo direct state entry.
UpdateOnly through Do weighing.
Virtual lotNo manual packing.
Virtual packing fieldslot_qt and lot_unit are not editable.
### Tolerances -!!! warning ":material-alert-outline: Gap to Confirm" - Full remaining-tolerance control in `LotQt.add_physical_lots` / - `LotQt.add_physical_lot` still needs confirmation. +
Gap to ConfirmFull remaining-tolerance control in LotQt.add_physical_lots / LotQt.add_physical_lot still needs confirmation.
-| Marker | Point | Target Rule | -| --- | --- | -| :material-target: | Level | Global tolerance on line or contract. | -| :material-truck-outline: | Transport | No independent tolerance per transport. | -| :material-arrow-up-bold-outline: | Over-execution | Consumes remaining tolerance. | -| :material-arrow-down-bold-outline: | Under-execution | Restores remaining tolerance. | + + + + + + + + + + + + + + + + + + + + + + + + + +
PointTarget Rule
LevelGlobal tolerance on line or contract.
TransportNo independent tolerance per transport.
Over-executionConsumes remaining tolerance.
Under-executionRestores remaining tolerance.
-## :material-code-braces: Developer Section +## Developer Section ### Key Fields @@ -166,17 +339,13 @@ quantity_theorical - Sale: `sale.py`, `SaleLine.write` - Virtual lot target: -```text -target_quantity = quantity_theorical - sum(converted physical lots) -``` +
target_quantity = quantity_theorical - sum(converted physical lots)
- If `target_quantity < 0`: - block with `Please unlink or unmatch lot`. - Free `lot.qt` target: -```text -free_quantity = target_quantity - sum(already matched or shipped lot.qt) -``` +
free_quantity = target_quantity - sum(already matched or shipped lot.qt)
- If `free_quantity < 0`: - block with `Please unlink or unmatch lot`. diff --git a/modules/purchase_trade/docs/business/lots-and-quantities.md b/modules/purchase_trade/docs/business/lots-and-quantities.md index c771bd8..f19b83f 100644 --- a/modules/purchase_trade/docs/business/lots-and-quantities.md +++ b/modules/purchase_trade/docs/business/lots-and-quantities.md @@ -1,4 +1,6 @@ -# :material-scale-balance: Lots et quantités + + +# Lots et quantités Langue : `fr` Page miroir : [lots-and-quantities.en.md](lots-and-quantities.en.md) @@ -9,116 +11,287 @@ Cette page consolide `BR-PT-LOT-001`, `BR-PT-LOT-002` et `BR-PT-LOT-003`. Objectif : piloter les règles de quantité depuis une définition métier lisible, puis les sécuriser par des checks Python et des diagnostics SQL. -## :material-bookmark-check: À retenir +## À retenir -!!! abstract ":material-compass-outline: Résumé opérationnel" - Une ligne trade possède un seul lot virtuel. Le lot virtuel porte le solde - ouvert global, `lot.qt` porte le forecast opérationnel, et les lots - physiques consomment ce forecast. +
Résumé opérationnelUne ligne trade possède un seul lot virtuel. Le lot virtuel porte le solde ouvert global, lot.qt porte le forecast opérationnel, et les lots physiques consomment ce forecast.
-| Repère | Sujet | Règle courte | -| --- | --- | -| :material-pencil: | Quantité saisie | `quantity_theorical` est la quantité métier. | -| :material-counter: | Quantité standard | `quantity` est un compteur technique non éditable. | -| :material-timeline-clock-outline: | Avant physique | `quantity` suit `quantity_theorical`. | -| :material-package-variant-closed: | Après physique | `quantity` reflète l'exécuté physique. | -| :material-lock-open-variant-outline: | Ligne non finie | Le montant de ligne utilise `quantity_theorical`. | -| :material-check-circle-outline: | Ligne finie | Le montant peut revenir à l'exécuté physique. | -| :material-weight: | Weight basis | Achat et vente peuvent lire deux états différents du même lot. | -| :material-file-document-check-outline: | Facturation | Elle choisit ses états dans `lot.qt.hist`. | -| :material-shield-check-outline: | Contrôles | Invariants bloqués en Python et auditables en SQL. | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SujetRègle courte
Quantité saisiequantity_theorical est la quantité métier.
Quantité standardquantity est un compteur technique non éditable.
Avant physiquequantity suit quantity_theorical.
Après physiquequantity reflète l'exécuté physique.
Ligne non finieLe montant de ligne utilise quantity_theorical.
Ligne finieLe montant peut revenir à l'exécuté physique.
Weight basisAchat et vente peuvent lire deux états différents du même lot.
FacturationElle choisit ses états dans lot.qt.hist.
ContrôlesInvariants bloqués en Python et auditables en SQL.
-```text -quantity_theorical +
quantity_theorical
         |
         v
-  lot virtuel P1  --->  lot.qt forecast  --->  lot physique
+  lot virtuel P1  --->  lot.qt forecast  --->  lot physique
         ^                    |
         |                    v
-        +------ recalcul après consommation
-```
+        +------ recalcul après consommation
-## :material-account-tie: Règles consultant +## Règles consultant ### BR-PT-LOT-001 - Cycle de vie lot virtuel / forecast / physique -| Étape | Moment | Effet métier | -| --- | --- | -| :material-plus-box-outline: | Création de ligne | Création d'un lot virtuel unique. | -| :material-play-circle-outline: | Initialisation | Le lot virtuel reprend `quantity_theorical`. | -| :material-map-marker-path: | Forecast | Une ligne ouverte est créée dans `lot.qt`. | -| :material-source-branch: | Planification | `lot.qt` peut être subdivisé par vente, matching, transport ou shipment. | -| :material-package-variant: | Ajout physique | Le lot physique consomme une ligne `lot.qt` précise. | -| :material-refresh: | Après ajout | `lot.qt` diminue et le lot virtuel est recalculé. | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MomentEffet métier
Création de ligneCréation d'un lot virtuel unique.
InitialisationLe lot virtuel reprend quantity_theorical.
ForecastUne ligne ouverte est créée dans lot.qt.
Planificationlot.qt peut être subdivisé par vente, matching, transport ou shipment.
Ajout physiqueLe lot physique consomme une ligne lot.qt précise.
Après ajoutlot.qt diminue et le lot virtuel est recalculé.
-!!! example ":material-source-branch: Découpage d'un solde P1" - `P1S1T1`, `P1S1T2`, `P1S2T3`, `P1S2T4` +
Découpage d'un solde P1P1S1T1, P1S1T2, P1S2T3, P1S2T4
-!!! note ":material-lightbulb-outline: Point clé" - Ces découpages ne créent pas plusieurs lots virtuels. Ils décrivent - seulement la répartition prévisionnelle du solde ouvert. +
Point cléCes découpages ne créent pas plusieurs lots virtuels. Ils décrivent seulement la répartition prévisionnelle du solde ouvert.
### BR-PT-LOT-002 - Quantité contractuelle, compteur, ligne finie -| Repère | Situation | Quantité de référence | -| --- | --- | -| :material-pencil: | Saisie utilisateur | `quantity_theorical` | -| :material-package-variant-remove: | Aucun lot physique | `quantity = quantity_theorical` | -| :material-package-variant-closed: | Lots physiques présents | `quantity = somme des lots physiques` | -| :material-lock-open-variant-outline: | `finished = False` | Montant basé sur `quantity_theorical` | -| :material-check-circle-outline: | `finished = True` | Montant basé sur l'exécuté physique | -| :material-weight: | Weight basis disponible | Montant basé sur l'état `wb.qt_type` du contrat | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SituationQuantité de référence
Saisie utilisateurquantity_theorical
Aucun lot physiquequantity = quantity_theorical
Lots physiques présentsquantity = somme des lots physiques
finished = FalseMontant basé sur quantity_theorical
finished = TrueMontant basé sur l'exécuté physique
Weight basis disponibleMontant basé sur l'état wb.qt_type du contrat
-!!! warning ":material-alert-outline: Ce que `finished` ne fait pas" - `finished` n'efface pas la quantité contractuelle, ne supprime pas les lots - physiques et ne masque pas leur PnL. Il signifie seulement que le reliquat - ouvert peut être ignoré pour les calculs d'exécution. +
Ce que finished ne fait pasfinished n'efface pas la quantité contractuelle, ne supprime pas les lots physiques et ne masque pas leur PnL. Il signifie seulement que le reliquat ouvert peut être ignoré pour les calculs d'exécution.
-| Repère | Lecture du même lot physique | État possible | -| --- | --- | -| :material-cart-arrow-down: | Achat | BL via `purchase.purchase.wb.qt_type` | -| :material-cart-arrow-up: | Vente | LR ou Weight Report via `sale.sale.wb.qt_type` | -| :material-file-document-check-outline: | Facturation | Choix indépendant dans `lot.qt.hist` | + + + + + + + + + + + + + + + + + + + + + +
Lecture du même lot physiqueÉtat possible
AchatBL via purchase.purchase.wb.qt_type
VenteLR ou Weight Report via sale.sale.wb.qt_type
FacturationChoix indépendant dans lot.qt.hist
### BR-PT-LOT-003 - Invariants de quantité -| Repère | Invariant | Formule | -| --- | --- | -| :material-shield-check-outline: | Conservation | `somme(lots physiques) + lot virtuel = quantity_theorical` | -| :material-chart-timeline-variant: | Forecast ouvert | `somme(lot.qt non zéro) = max(lot virtuel, 0)` | + + + + + + + + + + + + + + + + + +
InvariantFormule
Conservationsomme(lots physiques) + lot virtuel = quantity_theorical
Forecast ouvertsomme(lot.qt non zéro) = max(lot virtuel, 0)
-| Repère | Cas | Règle | -| --- | --- | -| :material-cart-arrow-down: | Lot virtuel achat | Sommer tous les `lot.qt` où `lot_p = lot virtuel`, avec ou sans `lot_s`. | -| :material-cart-arrow-up: | Lot virtuel vente | Sommer tous les `lot.qt` où `lot_s = lot virtuel`, avec ou sans `lot_p`. | -| :material-numeric-0-box-outline: | `lot.qt = 0` | Ignoré par les checks ; mémoire possible d'une prévision vidée. | -| :material-minus-circle-outline: | Lot virtuel négatif | Autorisé pour compenser l'écart théorique / exécuté ; forecast attendu = zéro. | -| :material-alert-octagon-outline: | `lot.qt` non zéro orphelin | Interdit si ni `lot_p` ni `lot_s` n'est renseigné. | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CasRègle
Lot virtuel achatSommer tous les lot.qtlot_p = lot virtuel, avec ou sans lot_s.
Lot virtuel venteSommer tous les lot.qtlot_s = lot virtuel, avec ou sans lot_p.
lot.qt = 0Ignoré par les checks ; mémoire possible d'une prévision vidée.
Lot virtuel négatifAutorisé pour compenser l'écart théorique / exécuté ; forecast attendu = zéro.
lot.qt non zéro orphelinInterdit si ni lot_p ni lot_s n'est renseigné.
### BR-PT-LOT-004 - Historique de quantité et weighing -| Repère | Élément | Règle | -| --- | --- | -| :material-history: | `lot.qt.hist` | Porte les états de quantité d'un lot. | -| :material-form-textbox: | Fiche `lot.lot` | Pas de saisie directe des états. | -| :material-scale: | Modification | Uniquement via `Do weighing`. | -| :material-cloud-outline: | Lot virtuel | Pas de packing manuel. | -| :material-lock-outline: | Champs packing virtuel | `lot_qt` et `lot_unit` non éditables. | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ÉlémentRègle
lot.qt.histPorte les états de quantité d'un lot.
Fiche lot.lotPas de saisie directe des états.
ModificationUniquement via Do weighing.
Lot virtuelPas de packing manuel.
Champs packing virtuellot_qt et lot_unit non éditables.
### Tolérances -!!! warning ":material-alert-outline: Gap à confirmer" - Le contrôle complet de tolérance restante dans `LotQt.add_physical_lots` / - `LotQt.add_physical_lot` reste à confirmer. +
Gap à confirmerLe contrôle complet de tolérance restante dans LotQt.add_physical_lots / LotQt.add_physical_lot reste à confirmer.
-| Repère | Point | Règle cible | -| --- | --- | -| :material-target: | Niveau | Tolérance globale sur ligne ou contrat. | -| :material-truck-outline: | Transport | Pas de tolérance indépendante par transport. | -| :material-arrow-up-bold-outline: | Surconsommation | Consomme la tolérance restante. | -| :material-arrow-down-bold-outline: | Sous-consommation | Restitue de la tolérance restante. | + + + + + + + + + + + + + + + + + + + + + + + + + +
PointRègle cible
NiveauTolérance globale sur ligne ou contrat.
TransportPas de tolérance indépendante par transport.
SurconsommationConsomme la tolérance restante.
Sous-consommationRestitue de la tolérance restante.
-## :material-code-braces: Section développeur +## Section développeur ### Champs clés @@ -165,17 +338,13 @@ quantity_theorical - Vente : `sale.py`, `SaleLine.write` - Cible lot virtuel : -```text -target_quantity = quantity_theorical - somme(lots physiques convertis) -``` +
target_quantity = quantity_theorical - somme(lots physiques convertis)
- Si `target_quantity < 0` : - blocage : `Please unlink or unmatch lot`. - Cible `lot.qt` libre : -```text -free_quantity = target_quantity - somme(lot.qt déjà matchés ou shippés) -``` +
free_quantity = target_quantity - somme(lot.qt déjà matchés ou shippés)
- Si `free_quantity < 0` : - blocage : `Please unlink or unmatch lot`. diff --git a/modules/purchase_trade/docs/tools/render_business_docs.py b/modules/purchase_trade/docs/tools/render_business_docs.py new file mode 100644 index 0000000..0a2574a --- /dev/null +++ b/modules/purchase_trade/docs/tools/render_business_docs.py @@ -0,0 +1,275 @@ +"""Render purchase_trade business docs for the MkDocs wiki. + +The files in ``docs_source/business`` are the source of truth. The generated +files are written back to ``docs/business`` because the wiki already points to +``docs``. +""" + +from __future__ import annotations + +import argparse +import html +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +BUSINESS = ROOT / "business" +SOURCE = ROOT.parent / "docs_source" / "business" +TARGETS = ( + "lots-and-quantities.md", + "lots-and-quantities.en.md", +) + + +MATERIAL_ICON = re.compile(r":material-[a-z0-9-]+:\s*") + + +def clean_inline(text: str) -> str: + text = MATERIAL_ICON.sub("", text) + text = re.sub(r"\s{2,}", " ", text) + return text.strip() + + +def split_table_row(line: str) -> list[str]: + return [cell.strip() for cell in line.strip().strip("|").split("|")] + + +def is_separator_row(line: str) -> bool: + cells = split_table_row(line) + return bool(cells) and all(re.fullmatch(r":?-{3,}:?", cell) for cell in cells) + + +def normalize_table(lines: list[str], index: int) -> tuple[list[str], int]: + rows = [] + while index < len(lines) and lines[index].lstrip().startswith("|"): + rows.append(lines[index]) + index += 1 + if len(rows) < 2 or not is_separator_row(rows[1]): + return rows, index + + table = [split_table_row(row) for row in rows] + header = [clean_inline(cell) for cell in table[0]] + drop_first = header and header[0].lower() in { + "repere", + "repère", + "marker", + "etape", + "étape", + "step", + } + if drop_first: + table = [row[1:] for row in table] + + cleaned = [] + for row_index, row in enumerate(table): + if row_index == 1 and is_separator_row("| " + " | ".join(row) + " |"): + row = ["---"] * len(table[0]) + cleaned.append("| " + " | ".join(clean_inline(cell) for cell in row) + " |") + return cleaned, index + + +def normalize_source(text: str) -> str: + lines = text.splitlines() + out: list[str] = [] + i = 0 + in_code = False + while i < len(lines): + line = lines[i] + + if line.startswith("```"): + in_code = not in_code + out.append(line) + i += 1 + continue + + if in_code: + out.append(line) + i += 1 + continue + + if line.startswith("!!! "): + match = re.match(r"!!!\s+\w+\s+\"(.+)\"", line) + title = clean_inline(match.group(1) if match else "Note") + out.append(f"> **{title}**") + i += 1 + while i < len(lines) and ( + lines[i].startswith(" ") or not lines[i].strip() + ): + if lines[i].strip(): + out.append("> " + clean_inline(lines[i].strip())) + else: + out.append(">") + i += 1 + continue + + if line.lstrip().startswith("|"): + table, i = normalize_table(lines, i) + if len(table) > 1 and is_separator_row(table[1]): + header_len = len(split_table_row(table[0])) + table[1] = "| " + " | ".join(["---"] * header_len) + " |" + out.extend(table) + continue + + if not line.strip(): + out.append("") + elif line.startswith("#"): + out.append(clean_inline(line)) + else: + leading = re.match(r"^\s*", line).group(0) + out.append(leading + clean_inline(line.strip())) + i += 1 + + return "\n".join(out).strip() + "\n" + + +def render_inline(text: str) -> str: + placeholders: list[str] = [] + + def keep_link(match: re.Match[str]) -> str: + placeholders.append( + f'{html.escape(match.group(1))}' + ) + return f"@@LINK{len(placeholders) - 1}@@" + + text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", keep_link, text) + text = html.escape(text) + text = re.sub(r"`([^`]+)`", r"\1", text) + for idx, value in enumerate(placeholders): + text = text.replace(f"@@LINK{idx}@@", value) + return text + + +def render_table(rows: list[str]) -> str: + parsed = [split_table_row(row) for row in rows] + header = parsed[0] + body = parsed[2:] + out = [ + '', + "", + '', + ] + for cell in header: + out.append( + '" + ) + out.extend(["", "", ""]) + for row in body: + out.append('') + for cell in row: + out.append( + '" + ) + out.append("") + out.extend(["", "
' + f"{render_inline(cell)}
' + f"{render_inline(cell)}
"]) + return "\n".join(out) + + +def render_callout(lines: list[str]) -> str: + title = "" + body = [] + for line in lines: + content = line[1:].strip() + if content.startswith("**") and content.endswith("**") and not title: + title = content.strip("*") + elif content: + body.append(content) + title_html = ( + f'{render_inline(title)}' + if title + else "" + ) + body_html = " ".join(render_inline(line) for line in body) + return ( + '
' + f"{title_html}{body_html}
" + ) + + +def render_source(text: str) -> str: + lines = text.splitlines() + out = [ + "", + "", + ] + in_code = False + code_lines: list[str] = [] + i = 0 + while i < len(lines): + line = lines[i] + + if line.startswith("```"): + if in_code: + out.append( + '
'
+                    + html.escape("\n".join(code_lines))
+                    + "
" + ) + code_lines = [] + in_code = False + else: + in_code = True + i += 1 + continue + if in_code: + code_lines.append(line) + i += 1 + continue + + if line.startswith(">"): + block = [] + while i < len(lines) and lines[i].startswith(">"): + if not lines[i][1:].strip(): + i += 1 + break + block.append(lines[i]) + i += 1 + out.append(render_callout(block)) + out.append("") + continue + + if line.lstrip().startswith("|"): + table = [] + while i < len(lines) and lines[i].lstrip().startswith("|"): + table.append(lines[i]) + i += 1 + if len(table) > 1 and is_separator_row(table[1]): + out.append(render_table(table)) + else: + out.extend(table) + continue + + out.append(line) + i += 1 + + return "\n".join(out).strip() + "\n" + + +def render_all(normalize: bool = False) -> None: + SOURCE.mkdir(parents=True, exist_ok=True) + for name in TARGETS: + source = SOURCE / name + target = BUSINESS / name + if normalize: + source.write_text(normalize_source(source.read_text(encoding="utf-8")), encoding="utf-8") + target.write_text(render_source(source.read_text(encoding="utf-8")), encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--normalize-source", + action="store_true", + help="Clean existing source files from wiki-only syntax before rendering.", + ) + args = parser.parse_args() + render_all(normalize=args.normalize_source) + + +if __name__ == "__main__": + main() diff --git a/modules/purchase_trade/docs_source/business/lots-and-quantities.en.md b/modules/purchase_trade/docs_source/business/lots-and-quantities.en.md new file mode 100644 index 0000000..1cad37d --- /dev/null +++ b/modules/purchase_trade/docs_source/business/lots-and-quantities.en.md @@ -0,0 +1,300 @@ +# Lots and Quantities + +Language: `en` +Mirror page: [lots-and-quantities.md](lots-and-quantities.md) +Status: `partial migration` +Last code verification: `2026-05-13` + +This page consolidates `BR-PT-LOT-001`, `BR-PT-LOT-002`, and +`BR-PT-LOT-003`. +Goal: drive quantity rules from a readable business definition, then secure +them with Python guards and SQL diagnostics. + +## Key Points + +> **Operational Summary** +> A trade line has exactly one virtual lot. The virtual lot carries the +> global open balance, `lot.qt` carries the operational forecast, and +> physical lots consume that forecast. +> +| Topic | Short Rule | +| --- | --- | +| Entered quantity | `quantity_theorical` is the business quantity. | +| Standard quantity | `quantity` is a read-only technical counter. | +| Before physical lots | `quantity` follows `quantity_theorical`. | +| After physical lots | `quantity` reflects physical execution. | +| Open line | Line amount uses `quantity_theorical`. | +| Finished line | Line amount may switch back to physical execution. | +| Weight basis | Purchase and sale may read two different states of the same lot. | +| Invoicing | It chooses states from `lot.qt.hist`. | +| Controls | Invariants are blocked in Python and auditable with SQL. | + +```text +quantity_theorical + | + v + virtual lot P1 ---> lot.qt forecast ---> physical lot + ^ | + | v + +------ recalculation after consumption +``` + +## Consultant Rules + +### BR-PT-LOT-001 - Virtual lot / forecast / physical life cycle + +| Moment | Business Effect | +| --- | --- | +| Line creation | One unique virtual lot is created. | +| Initialization | The virtual lot takes `quantity_theorical`. | +| Forecast | One open line is created in `lot.qt`. | +| Planning | `lot.qt` may be split by sale, matching, transport, or shipment. | +| Physical add | The physical lot consumes a precise `lot.qt` line. | +| After add | `lot.qt` decreases and the virtual lot is recalculated. | + +> **Split of an open P1 balance** +> `P1S1T1`, `P1S1T2`, `P1S2T3`, `P1S2T4` +> +> **Key Point** +> These splits do not create several virtual lots. They only describe the +> forecast allocation of the open balance. +> +### BR-PT-LOT-002 - Contractual quantity, counter quantity, finished line + +| Situation | Reference Quantity | +| --- | --- | +| User entry | `quantity_theorical` | +| No physical lot | `quantity = quantity_theorical` | +| Physical lots exist | `quantity = sum of physical lots` | +| `finished = False` | Amount based on `quantity_theorical` | +| `finished = True` | Amount based on physical execution | +| Weight basis available | Amount based on the contract `wb.qt_type` state | + +> **What `finished` Does Not Do** +> `finished` does not erase the contractual quantity, delete physical lots, +> or hide their PnL. It only means that the open balance may be ignored for +> execution calculations. +> +| Reading of the Same Physical Lot | Possible State | +| --- | --- | +| Purchase | BL through `purchase.purchase.wb.qt_type` | +| Sale | LR or Weight Report through `sale.sale.wb.qt_type` | +| Invoicing | Independent choice in `lot.qt.hist` | + +### BR-PT-LOT-003 - Quantity Invariants + +| Invariant | Formula | +| --- | --- | +| Conservation | `sum(physical lots) + virtual lot = quantity_theorical` | +| Open forecast | `sum(non-zero lot.qt) = max(virtual lot, 0)` | + +| Case | Rule | +| --- | --- | +| Purchase virtual lot | Sum all `lot.qt` where `lot_p = virtual lot`, with or without `lot_s`. | +| Sale virtual lot | Sum all `lot.qt` where `lot_s = virtual lot`, with or without `lot_p`. | +| `lot.qt = 0` | Ignored by checks; possible memory of a consumed forecast. | +| Negative virtual lot | Allowed to compensate theoretical / executed gap; expected forecast = zero. | +| Non-zero orphan `lot.qt` | Forbidden if neither `lot_p` nor `lot_s` is set. | + +### BR-PT-LOT-004 - Quantity history and weighing + +| Element | Rule | +| --- | --- | +| `lot.qt.hist` | Carries lot quantity states. | +| `lot.lot` form | No direct state entry. | +| Update | Only through `Do weighing`. | +| Virtual lot | No manual packing. | +| Virtual packing fields | `lot_qt` and `lot_unit` are not editable. | + +### Tolerances + +> **Gap to Confirm** +> Full remaining-tolerance control in `LotQt.add_physical_lots` / +> `LotQt.add_physical_lot` still needs confirmation. +> +| Point | Target Rule | +| --- | --- | +| Level | Global tolerance on line or contract. | +| Transport | No independent tolerance per transport. | +| Over-execution | Consumes remaining tolerance. | +| Under-execution | Restores remaining tolerance. | + +## Developer Section + +### Key Fields + +- Purchase line: `purchase.line` +- Sale line: `sale.line` +- Lot: `lot.lot` +- Forecast: `lot.qt` +- History: `lot.qt.hist` +- Purchase business quantity: `purchase.line.quantity_theorical` +- Sale business quantity: `sale.line.quantity_theorical` +- Technical counter: `quantity` +- Finished line: `purchase.line.finished`, `sale.line.finished` +- Virtual / physical lot: `lot.lot.lot_type = virtual / physic` +- Purchase link: `lot.lot.line` +- Sale link: `lot.lot.sale_line` +- Purchase forecast: `lot.qt.lot_p` +- Sale forecast: `lot.qt.lot_s` +- Forecast quantity: `lot.qt.lot_quantity` +- Weight basis: `purchase.purchase.wb`, `sale.sale.wb` +- Weight basis state: `purchase.weight.basis.qt_type` +- Packing: `lot.lot.lot_qt`, `lot.lot.lot_unit` +- Tolerances: `tol_min`, `tol_max`, `tol_min_qt`, `tol_max_qt`, + `tol_min_v`, `tol_max_v` + +### Line / Virtual Lot Creation + +- Purchase: `purchase.py`, `Line.validate` +- Sale: `sale.py`, `SaleLine.validate` +- If `quantity_theorical` is entered and `quantity` is empty or zero: + - `quantity` is initialized from `quantity_theorical`; + - only if no physical lot exists. +- If the line is eligible: + - not `created_by_code`; + - no lot yet; + - non-service product; + - `quantity_theorical != 0`; + - create one `virtual` lot. +- The virtual lot receives a first `lot.qt.hist` entry. +- `Lot.validate` creates the open `lot.qt` through `createVirtualPart`. + +### Updating `quantity_theorical` + +- Purchase: `purchase.py`, `Line.write` +- Sale: `sale.py`, `SaleLine.write` +- Virtual lot target: + +```text +target_quantity = quantity_theorical - sum(converted physical lots) +``` + +- If `target_quantity < 0`: + - block with `Please unlink or unmatch lot`. +- Free `lot.qt` target: + +```text +free_quantity = target_quantity - sum(already matched or shipped lot.qt) +``` + +- If `free_quantity < 0`: + - block with `Please unlink or unmatch lot`. +- If a free `lot.qt` exists: + - replace its quantity. +- If no free `lot.qt` exists and `free_quantity > 0`: + - create a new `lot.qt`. +- Line fees are resynchronized. + +### Adding Physical Lots + +- Wizard: `lot.add` +- Methods: + - `LotQt.add_physical_lots` + - `LotQt.add_physical_lot` +- Mandatory source: one `lot.qt` line. +- Direct add from a physical lot is refused. +- Physical add on sale side through this wizard is refused: use + `Apply matching`. +- The physical lot inherits: + - purchase line; + - matched sale, if any; + - shipment; + - product; + - unit; + - quantities; + - premium; + - chunk key. +- After creation: + - source `lot.qt` is reduced; + - `lot.qt` cannot become negative; + - virtual lot is recalculated; + - `quantity` is recalculated; + - moves and fees are updated when needed. + +### Removing Physical Lots + +- Wizard: `lot.remove` +- Open lot: removal forbidden. +- Lot with `stock.move`: + - move must be `draft`. +- Matched or shipped lot: + - confirmable warning. +- Effects: + - draft move deletion; + - restore quantity into `lot.qt`; + - restore context through shipment, `getVlot_p()`, `getVlot_s()`; + - recalculate virtual lot, `quantity`, fees. + +### Weighing / Quantity States + +- Wizard: `lot.weighing` +- UI action: `Do weighing` +- Writes or updates `lot.qt.hist`. +- May update `lot_state`. +- Synchronizes: + - lot; + - open quantities; + - fees. +- `lot.qt.hist` views are consultative. + +### Quantity Counter `quantity` + +- Method: `Lot._recalc_line_quantity` +- Without physical lots: + - `quantity` follows the virtual lot. +- With physical lots: + - `quantity` sums physical lots only. +- `quantity` is readonly on trade lines. + +### Line Amount + +- Purchase: `purchase.line.on_change_with_amount()` +- Sale: `sale.line.on_change_with_amount()` +- Helpers: + - `_get_amount_quantity()` + - `_get_weight_basis_quantity()` +- Priorities: + - `finished = False`: `quantity_theorical` + - `finished = True` + usable Weight basis: physical sum in that state + - `finished = True` without usable Weight basis: `quantity` + - legacy fallback: `quantity` if `quantity_theorical` is empty + +### Python Guards + +- Central check: + - `lot.lot.assert_lines_quantity_consistency()` +- Non-zero orphan `lot.qt` block: + - `lot.qt.validate` +- Called after: + - `quantity_theorical` update; + - physical lot creation / deletion; + - matching / unmatching; + - shipping / unshipping; + - weighing. + +### SQL Diagnostic + +- Script: + - [sql/quantity_consistency_checks.sql](sql/quantity_consistency_checks.sql) +- Use: + - test database audit; + - historical data audit; + - qualification before repair. +- The script completely ignores `lot.qt = 0`. + +## Nearby Tests + +- `modules/purchase_trade/tests/test_module.py` +- Existing coverage: + - readonly `quantity`; + - initialization from `quantity_theorical`; + - protection when physical lots exist; + - amount on theoretical / physical / Weight basis; + - virtual lot resynchronization; + - blocking when open quantity is not enough. +- Tests to add: + - readonly `lot_hist`; + - `Do weighing` creates or updates a state; + - virtual lot without direct `lot_qt` / `lot_unit` entry; + - SQL checks replayed on inconsistent datasets. diff --git a/modules/purchase_trade/docs_source/business/lots-and-quantities.md b/modules/purchase_trade/docs_source/business/lots-and-quantities.md new file mode 100644 index 0000000..7850668 --- /dev/null +++ b/modules/purchase_trade/docs_source/business/lots-and-quantities.md @@ -0,0 +1,298 @@ +# Lots et quantités + +Langue : `fr` +Page miroir : [lots-and-quantities.en.md](lots-and-quantities.en.md) +Statut : `migration partielle` +Dernière vérification code : `2026-05-13` + +Cette page consolide `BR-PT-LOT-001`, `BR-PT-LOT-002` et `BR-PT-LOT-003`. +Objectif : piloter les règles de quantité depuis une définition métier lisible, +puis les sécuriser par des checks Python et des diagnostics SQL. + +## À retenir + +> **Résumé opérationnel** +> Une ligne trade possède un seul lot virtuel. Le lot virtuel porte le solde +> ouvert global, `lot.qt` porte le forecast opérationnel, et les lots +> physiques consomment ce forecast. +> +| Sujet | Règle courte | +| --- | --- | +| Quantité saisie | `quantity_theorical` est la quantité métier. | +| Quantité standard | `quantity` est un compteur technique non éditable. | +| Avant physique | `quantity` suit `quantity_theorical`. | +| Après physique | `quantity` reflète l'exécuté physique. | +| Ligne non finie | Le montant de ligne utilise `quantity_theorical`. | +| Ligne finie | Le montant peut revenir à l'exécuté physique. | +| Weight basis | Achat et vente peuvent lire deux états différents du même lot. | +| Facturation | Elle choisit ses états dans `lot.qt.hist`. | +| Contrôles | Invariants bloqués en Python et auditables en SQL. | + +```text +quantity_theorical + | + v + lot virtuel P1 ---> lot.qt forecast ---> lot physique + ^ | + | v + +------ recalcul après consommation +``` + +## Règles consultant + +### BR-PT-LOT-001 - Cycle de vie lot virtuel / forecast / physique + +| Moment | Effet métier | +| --- | --- | +| Création de ligne | Création d'un lot virtuel unique. | +| Initialisation | Le lot virtuel reprend `quantity_theorical`. | +| Forecast | Une ligne ouverte est créée dans `lot.qt`. | +| Planification | `lot.qt` peut être subdivisé par vente, matching, transport ou shipment. | +| Ajout physique | Le lot physique consomme une ligne `lot.qt` précise. | +| Après ajout | `lot.qt` diminue et le lot virtuel est recalculé. | + +> **Découpage d'un solde P1** +> `P1S1T1`, `P1S1T2`, `P1S2T3`, `P1S2T4` +> +> **Point clé** +> Ces découpages ne créent pas plusieurs lots virtuels. Ils décrivent +> seulement la répartition prévisionnelle du solde ouvert. +> +### BR-PT-LOT-002 - Quantité contractuelle, compteur, ligne finie + +| Situation | Quantité de référence | +| --- | --- | +| Saisie utilisateur | `quantity_theorical` | +| Aucun lot physique | `quantity = quantity_theorical` | +| Lots physiques présents | `quantity = somme des lots physiques` | +| `finished = False` | Montant basé sur `quantity_theorical` | +| `finished = True` | Montant basé sur l'exécuté physique | +| Weight basis disponible | Montant basé sur l'état `wb.qt_type` du contrat | + +> **Ce que `finished` ne fait pas** +> `finished` n'efface pas la quantité contractuelle, ne supprime pas les lots +> physiques et ne masque pas leur PnL. Il signifie seulement que le reliquat +> ouvert peut être ignoré pour les calculs d'exécution. +> +| Lecture du même lot physique | État possible | +| --- | --- | +| Achat | BL via `purchase.purchase.wb.qt_type` | +| Vente | LR ou Weight Report via `sale.sale.wb.qt_type` | +| Facturation | Choix indépendant dans `lot.qt.hist` | + +### BR-PT-LOT-003 - Invariants de quantité + +| Invariant | Formule | +| --- | --- | +| Conservation | `somme(lots physiques) + lot virtuel = quantity_theorical` | +| Forecast ouvert | `somme(lot.qt non zéro) = max(lot virtuel, 0)` | + +| Cas | Règle | +| --- | --- | +| Lot virtuel achat | Sommer tous les `lot.qt` où `lot_p = lot virtuel`, avec ou sans `lot_s`. | +| Lot virtuel vente | Sommer tous les `lot.qt` où `lot_s = lot virtuel`, avec ou sans `lot_p`. | +| `lot.qt = 0` | Ignoré par les checks ; mémoire possible d'une prévision vidée. | +| Lot virtuel négatif | Autorisé pour compenser l'écart théorique / exécuté ; forecast attendu = zéro. | +| `lot.qt` non zéro orphelin | Interdit si ni `lot_p` ni `lot_s` n'est renseigné. | + +### BR-PT-LOT-004 - Historique de quantité et weighing + +| Élément | Règle | +| --- | --- | +| `lot.qt.hist` | Porte les états de quantité d'un lot. | +| Fiche `lot.lot` | Pas de saisie directe des états. | +| Modification | Uniquement via `Do weighing`. | +| Lot virtuel | Pas de packing manuel. | +| Champs packing virtuel | `lot_qt` et `lot_unit` non éditables. | + +### Tolérances + +> **Gap à confirmer** +> Le contrôle complet de tolérance restante dans `LotQt.add_physical_lots` / +> `LotQt.add_physical_lot` reste à confirmer. +> +| Point | Règle cible | +| --- | --- | +| Niveau | Tolérance globale sur ligne ou contrat. | +| Transport | Pas de tolérance indépendante par transport. | +| Surconsommation | Consomme la tolérance restante. | +| Sous-consommation | Restitue de la tolérance restante. | + +## Section développeur + +### Champs clés + +- Ligne achat : `purchase.line` +- Ligne vente : `sale.line` +- Lot : `lot.lot` +- Forecast : `lot.qt` +- Historique : `lot.qt.hist` +- Quantité métier achat : `purchase.line.quantity_theorical` +- Quantité métier vente : `sale.line.quantity_theorical` +- Compteur technique : `quantity` +- Ligne finie : `purchase.line.finished`, `sale.line.finished` +- Lot virtuel / physique : `lot.lot.lot_type = virtual / physic` +- Lien achat : `lot.lot.line` +- Lien vente : `lot.lot.sale_line` +- Forecast achat : `lot.qt.lot_p` +- Forecast vente : `lot.qt.lot_s` +- Quantité forecast : `lot.qt.lot_quantity` +- Weight basis : `purchase.purchase.wb`, `sale.sale.wb` +- État Weight basis : `purchase.weight.basis.qt_type` +- Packing : `lot.lot.lot_qt`, `lot.lot.lot_unit` +- Tolerances : `tol_min`, `tol_max`, `tol_min_qt`, `tol_max_qt`, + `tol_min_v`, `tol_max_v` + +### Création ligne / lot virtuel + +- Achat : `purchase.py`, `Line.validate` +- Vente : `sale.py`, `SaleLine.validate` +- Si `quantity_theorical` est saisi et que `quantity` est vide ou zéro : + - `quantity` est initialisée depuis `quantity_theorical` ; + - seulement si aucun lot physique n'existe. +- Si la ligne est éligible : + - pas `created_by_code` ; + - pas encore de lot ; + - produit non service ; + - `quantity_theorical != 0` ; + - création d'un lot `virtual`. +- Le lot virtuel reçoit une première entrée `lot.qt.hist`. +- `Lot.validate` crée le `lot.qt` ouvert via `createVirtualPart`. + +### Modification de `quantity_theorical` + +- Achat : `purchase.py`, `Line.write` +- Vente : `sale.py`, `SaleLine.write` +- Cible lot virtuel : + +```text +target_quantity = quantity_theorical - somme(lots physiques convertis) +``` + +- Si `target_quantity < 0` : + - blocage : `Please unlink or unmatch lot`. +- Cible `lot.qt` libre : + +```text +free_quantity = target_quantity - somme(lot.qt déjà matchés ou shippés) +``` + +- Si `free_quantity < 0` : + - blocage : `Please unlink or unmatch lot`. +- Si un `lot.qt` libre existe : + - sa quantité est remplacée. +- Si aucun `lot.qt` libre n'existe et `free_quantity > 0` : + - création d'un nouveau `lot.qt`. +- Les fees de ligne sont resynchronisés. + +### Ajout de lots physiques + +- Wizard : `lot.add` +- Méthodes : + - `LotQt.add_physical_lots` + - `LotQt.add_physical_lot` +- Source obligatoire : une ligne `lot.qt`. +- Ajout direct depuis un lot physique refusé. +- Ajout physique côté vente par ce wizard refusé : utiliser `Apply matching`. +- Le lot physique reprend : + - ligne achat ; + - vente matchée si présente ; + - shipment ; + - produit ; + - unité ; + - quantités ; + - premium ; + - chunk key. +- Après création : + - réduction de la ligne `lot.qt` source ; + - pas de quantité `lot.qt` négative ; + - recalcul du lot virtuel ; + - recalcul de `quantity` ; + - mise à jour moves et fees si nécessaire. + +### Retrait de lots physiques + +- Wizard : `lot.remove` +- Lot ouvert : retrait interdit. +- Lot avec `stock.move` : + - move obligatoire en `draft`. +- Lot matché ou shippé : + - warning confirmable. +- Effets : + - suppression du move draft ; + - restauration de la quantité dans `lot.qt` ; + - contexte restauré via shipment, `getVlot_p()`, `getVlot_s()` ; + - recalcul lot virtuel, `quantity`, fees. + +### Weighing / états de quantité + +- Wizard : `lot.weighing` +- Action UI : `Do weighing` +- Écrit ou met à jour `lot.qt.hist`. +- Peut mettre à jour `lot_state`. +- Synchronise : + - lot ; + - quantités ouvertes ; + - fees. +- Les vues `lot.qt.hist` sont consultatives. + +### Quantité compteur `quantity` + +- Méthode : `Lot._recalc_line_quantity` +- Sans physique : + - `quantity` suit le lot virtuel. +- Avec physiques : + - `quantity` somme uniquement les lots physiques. +- `quantity` est readonly côté ligne trade. + +### Montant de ligne + +- Achat : `purchase.line.on_change_with_amount()` +- Vente : `sale.line.on_change_with_amount()` +- Helper : + - `_get_amount_quantity()` + - `_get_weight_basis_quantity()` +- Priorités : + - `finished = False` : `quantity_theorical` + - `finished = True` + Weight basis disponible : somme physique dans cet état + - `finished = True` sans Weight basis exploitable : `quantity` + - fallback legacy : `quantity` si `quantity_theorical` vide + +### Garde-fous Python + +- Check central : + - `lot.lot.assert_lines_quantity_consistency()` +- Blocage `lot.qt` orphelin non zéro : + - `lot.qt.validate` +- Appels après : + - modification `quantity_theorical` ; + - création / suppression de lots physiques ; + - matching / unmatching ; + - shipping / unshipping ; + - weighing. + +### Diagnostic SQL + +- Script : + - [sql/quantity_consistency_checks.sql](sql/quantity_consistency_checks.sql) +- Usage : + - audit des bases de test ; + - audit des données historiques ; + - qualification avant correction. +- Le script ignore totalement les `lot.qt = 0`. + +## Tests proches + +- `modules/purchase_trade/tests/test_module.py` +- Couverture existante : + - `quantity` readonly ; + - initialisation depuis `quantity_theorical` ; + - protection si lots physiques ; + - amount sur théorique / physique / Weight basis ; + - resynchronisation des lots virtuels ; + - blocages quand l'open ne suffit plus. +- Tests à ajouter : + - `lot_hist` readonly ; + - `Do weighing` crée ou met à jour un état ; + - lot virtuel sans saisie directe `lot_qt` / `lot_unit` ; + - contrôles SQL rejoués sur jeux de données incohérents.