Files
tradon/modules/purchase_trade/docs/business/lots-and-quantities.md
2026-05-13 18:39:54 +02:00

18 KiB

Lots et quantités

Langue: fr Page miroir: lots-and-quantities.en.md Statut: migration partielle Dernière vérification code: 2026-05-13

Cette page consolide les anciennes règles BR-PT-LOT-001, BR-PT-LOT-002 et BR-PT-LOT-003 autour d'une règle fonctionnelle unique : le cycle de vie des quantités ouvertes, forecastées et physiques.

Règle business consultant

BR-PT-LOT-001 - Cycle de vie des lots et des quantités

Lors de la création d'une ligne d'achat ou de vente, le système crée un lot virtuel associé à cette ligne. Ce lot virtuel représente la quantité encore ouverte du contrat. Sa quantité initiale reprend la quantité contractuelle ou théorique de la ligne.

En parallèle, le système ajoute une ligne ouverte dans lot.qt. Cette ligne sert de base aux prévisions commerciales et logistiques : vente prévisionnelle, matching futur, transport planifié, shipment, etc. lot.qt porte donc le forecast opérationnel, tandis que le lot virtuel reste la représentation globale du solde ouvert dans lot.lot.

Exemple : une quantité ouverte P1 peut être progressivement subdivisée dans lot.qt pour prévoir plusieurs ventes ou transports :

  • P1S1T1
  • P1S1T2
  • P1S2T3
  • P1S2T4

Ces subdivisions ne créent pas plusieurs lots virtuels pour P1. Elles décrivent seulement la répartition prévisionnelle de la quantité ouverte.

Quand des lots physiques sont ajoutés, ils sont créés depuis une ligne précise de Lots Management, donc depuis une ligne lot.qt. Si l'utilisateur choisit P1S1T1, le lot physique créé consomme ce forecast particulier. La ligne lot.qt choisie est réduite, puis le lot virtuel de la ligne est recalculé pour représenter seulement le reliquat encore ouvert.

La première règle immuable de cohérence est :

somme des lots physiques de la ligne + lot virtuel de la ligne
= quantité contractuelle/théorique de la ligne

La deuxième règle immuable de cohérence est :

somme des lot.qt.lot_quantity pour le lot_p virtuel de la ligne
= lot.qt.hist.quantity du lot virtuel de la ligne

Chaque ajout de lot physique doit retrancher la même quantité des deux côtés de cette égalité : la ligne lot.qt forecastée diminue, et l'historique de quantité du lot virtuel diminue aussi.

Cette cohérence doit rester vraie même si la quantité contractuelle est ajustée en cours de route. La quantité contractuelle/théorique est la seule quantité saisie par l'utilisateur sur la ligne. La quantité standard de la ligne est un compteur technique en lecture seule : elle est initialisée depuis la quantité contractuelle tant qu'il n'y a pas de lot physique, puis elle reflète la somme des lots physiques exécutés.

Le retrait d'un lot physique est autorisé seulement tant que son mouvement stock n'est pas finalisé. Si ce lot était déjà matché ou rattaché à un shipment, l'utilisateur doit confirmer l'action. Le retrait restaure la quantité dans la ligne lot.qt qui portait le contexte forecast, matching ou transport ayant permis de créer le lot physique.

L'historique des quantités d'un lot n'est jamais une zone de saisie directe. Les différents états de quantité d'un lot doivent être modifiés uniquement par une action métier dédiée, aujourd'hui Do weighing. Cette règle garantit que les recalculs de quantité ouverte, de lot virtuel, de fees et de mouvements stock restent synchronisés.

BR-PT-LOT-002 - Quantité contractuelle, exécuté physique et ligne finie

La quantité contractuelle/théorique reste la base du contrat tant que la ligne n'est pas marquée comme finie. Elle sert donc de base au montant commercial de la ligne, même si des lots physiques ont déjà été ajoutés et que la quantité technique quantity reflète seulement la somme de ces lots physiques.

Le champ quantity n'est pas une saisie métier. Il est un compteur technique :

  • avant tout lot physique, il est initialisé depuis la quantité contractuelle/théorique ;
  • dès qu'il existe des lots physiques, il reflète la somme des lots physiques exécutés ;
  • il ne doit pas piloter le montant contractuel tant que la ligne n'est pas marquée comme finie.

La coche Mark as finished indique que l'utilisateur accepte d'ignorer le reliquat ouvert encore porté par le lot virtuel. Elle ne modifie pas la quantité contractuelle historique. Elle ne supprime pas les lots physiques. Elle signifie seulement que les calculs qui ne doivent plus tenir compte du reliquat ouvert peuvent basculer sur l'exécuté physique.

Règles strictes :

  • tant que finished = False, le montant commercial de la ligne doit être calculé sur quantity_theorical ;
  • quand finished = True, le reliquat ouvert/virtuel restant est ignoré pour les calculs d'exécution et le montant de ligne peut donc revenir au compteur physique quantity ;
  • si la ligne finie possède des lots physiques, le montant de ligne doit utiliser l'état de quantité défini par le Weight basis du contrat concerné, quand cet état existe sur les lots ;
  • finished ne doit jamais effacer la trace du contrat initial ;
  • finished ne doit jamais masquer ou supprimer le PnL des lots physiques.

Cette règle concerne le montant porté par la ligne de contrat. La facturation reste un flux distinct : elle peut choisir un état de quantité précis dans lot_qt_hist selon le contrat, par exemple un poids BL à l'achat et un Weight Report à la vente. Le montant de ligne ne doit donc pas essayer de remplacer la logique de sélection des quantités de facturation.

Le même lot physique peut donc avoir deux lectures commerciales différentes : sur le contrat d'achat, il peut être valorisé avec l'état BL ; sur le contrat de vente, une fois matché, il peut être valorisé avec un autre état, par exemple LR pour Landing Report. L'état utilisé dépend toujours du contrat affiché, pas d'un état global unique du lot.

Tolérance et quantités physiques

Un lot physique peut avoir une quantité différente de la prévision initiale, dans les limites de tolérance du contrat. La tolérance doit se lire comme une tolérance globale sur la ligne ou le contrat, pas comme une tolérance indépendante par transport.

Si un lot physique consomme plus ou moins que son forecast, cela doit réduire ou augmenter dynamiquement la tolérance restante pour les prochains lots physiques de la même ligne. Aucune action d'ajout, de weighing, de suppression ou d'ajustement contractuel ne doit permettre de sortir de la cohérence globale des quantités.

Section développeur

Modèles et champs principaux

  • Ligne achat : purchase.line.
  • Ligne vente : sale.line.
  • Lot : lot.lot.
  • Forecast / quantité ouverte : lot.qt.
  • Historique des quantités de lot : lot.qt.hist.
  • Quantité contractuelle achat : purchase.line.quantity_theorical (Contractual Qt).
  • Quantité contractuelle vente : sale.line.quantity_theorical (Th. quantity).
  • Quantité compteur achat/vente : quantity, non éditable par l'utilisateur dans les lignes trade.
  • Type de lot : lot.lot.lot_type, valeurs virtual et physic.
  • Lien achat : lot.lot.line.
  • Lien vente : lot.lot.sale_line.
  • Liens shipment : lot_shipment_in, lot_shipment_internal, lot_shipment_out.
  • Matching forecast achat/vente : lot.qt.lot_p, lot.qt.lot_s.
  • Quantité forecast : lot.qt.lot_quantity.
  • Tolérances contrat/ligne : tol_min, tol_max, tol_min_qt, tol_max_qt, tol_min_v, tol_max_v.
  • Ligne finie : purchase.line.finished, sale.line.finished.
  • Weight basis achat/vente : purchase.purchase.wb, sale.sale.wb.
  • État de quantité final associé au Weight basis : purchase.weight.basis.qt_type.
  • Historique des états de quantité : lot.lot.lot_hist vers lot.qt.hist.
  • Champs d'historique : lot.qt.hist.quantity_type, lot.qt.hist.quantity, lot.qt.hist.gross_quantity.
  • Champs de packing du lot : lot.lot.lot_qt, lot.lot.lot_unit.

Création de la ligne et du lot virtuel

  • Achat : modules/purchase_trade/purchase.py, Line.validate.
  • Vente : modules/purchase_trade/sale.py, SaleLine.validate.
  • À la création et lors de la saisie de quantity_theorical, si quantity est vide ou égale à zéro et qu'aucun lot physique n'existe, quantity est initialisée depuis quantity_theorical.
  • Si la ligne n'est pas created_by_code, qu'elle n'a pas encore de lot, que le produit n'est pas un service et que quantity_theorical != 0, un lot virtual est créé.
  • Le lot virtuel reçoit une première entrée lot.qt.hist.
  • La création du lot.qt ouvert est déclenchée par modules/purchase_trade/lot.py, Lot.validate, via createVirtualPart quand aucun lot.qt n'existe encore pour le lot virtuel.
  • Pour l'achat, le lot virtuel alimente lot.qt.lot_p.
  • Pour la vente, le lot virtuel alimente lot.qt.lot_s avec lot_p = None.
  • Les anciens flux qui n'ont pas encore quantity_theorical peuvent encore utiliser quantity comme fallback legacy, mais ce n'est plus le champ de saisie métier.

Modification de la quantité contractuelle

État du code vérifié :

  • Achat : purchase.py, Line.write.
  • Vente : sale.py, SaleLine.write.
  • La modification de quantity_theorical recalcule une quantité virtuelle cible :
target_quantity = quantity_theorical - somme(lots physiques convertis)
  • Si cette quantité cible devient négative, le code bloque avec Please unlink or unmatch lot.
  • Le lot.qt libre est ensuite resynchronisé en tenant compte des lignes lot.qt déjà allouées, c'est-à-dire déjà matchées ou rattachées à un shipment :
free_quantity = target_quantity - somme(lot.qt déjà matchés ou shippés)
  • Si free_quantity devient négative, le code bloque avec Please unlink or unmatch lot.
  • Si le lot virtuel ne porte pas déjà la quantité cible, le code appelle vlot.set_current_quantity(target_quantity, target_quantity, 1).
  • Si un lot.qt libre existe, sa quantité est remplacée par free_quantity.
  • Si aucun lot.qt libre n'existe et que free_quantity > 0, un nouveau lot.qt libre est créé.
  • Les fees de la ligne sont resynchronisés après modification.

Invariants à préserver après chaque modification :

  • sum(physical lots) + virtual lot current quantity = quantity_theorical ;
  • sum(lot.qt.lot_quantity where lot_p = virtual lot) = virtual lot current quantity.
  • Pour un lot virtuel achat, le second invariant somme toutes les lignes lot.qtlot_p = virtual lot, qu'elles soient matchées à un lot_s ou non.
  • Pour un lot virtuel vente, le second invariant somme toutes les lignes lot.qtlot_s = virtual lot, qu'elles soient liées à un lot_p ou non.
  • Le garde-fou technique est centralisé dans lot.lot.assert_lines_quantity_consistency() et doit être appelé à la fin des flux qui modifient des lots, du matching, du transport, du weighing ou la quantité contractuelle.

Point spécifique achat :

  • Si quantity_theorical était vide au moment de l'initialisation, le code prend comme baseline la quantité courante du lot virtuel pour éviter de doubler le lot.qt ouvert.

Point spécifique vente :

  • Si l'ancienne quantity_theorical est vide, SaleLine.write ne lance pas encore cette resynchronisation.

Ajout de lots physiques

  • Wizard : modules/purchase_trade/lot.py, lot.add.
  • Méthode principale : LotQt.add_physical_lots.
  • Création unitaire : LotQt.add_physical_lot.
  • L'ajout part obligatoirement d'une ligne lot.qt issue de Lots Management.
  • Le code refuse l'ajout direct depuis un lot physique.
  • Le code refuse l'ajout physique côté vente par ce wizard et demande d'utiliser Apply matching.
  • Le nouveau lot physique reprend le contexte de la ligne lot.qt : ligne achat, vente matchée si présente, shipment, produit, unité, quantités, premium et chunk key.
  • Après création, le code réduit la quantité de la ligne lot.qt source du total physique créé et ne laisse pas la ligne descendre sous zéro.
  • La sauvegarde/validation du lot physique recalcule :
    • le lot virtuel de la ligne via _recompute_virtual_lot ;
    • la quantité compteur de la ligne via _recalc_line_quantity ;
    • les mouvements stock liés si nécessaire ;
    • les fees rattachés au lot, à la ligne ou au shipment.

Retrait de lots physiques

  • Wizard : modules/purchase_trade/lot.py, lot.remove.
  • Le retrait d'un lot ouvert est interdit.
  • Si le lot physique possède un stock.move, ce move doit être en état draft.
  • Si le lot est matché ou shippé, un warning confirmable est affiché.
  • Le code supprime d'abord le move draft si présent.
  • Le code restaure la quantité physique dans lot.qt en réutilisant le contexte :
    • shipment d'origine via lot.lot_shipment_origin ;
    • lot virtuel sale via getVlot_s() si le lot était matché ;
    • lot virtuel purchase via getVlot_p() dans updateVirtualPart.
  • Si une ligne lot.qt compatible existe déjà, elle est incrémentée.
  • Sinon une nouvelle ligne lot.qt est créée.
  • La suppression du lot physique déclenche ensuite le recalcul du lot virtuel, de la quantité compteur de ligne et des fees.

Historique de quantité et weighing

  • L'historique est porté par lot.qt.hist.
  • lot.qt.hist représente les différents états de quantité d'un lot.
  • Les quantités historiques ne doivent pas être modifiées directement depuis la fiche lot.lot.
  • La vue lot.lot affiche lot_hist en lecture seule.
  • Les vues lot.qt.hist sont consultatives: pas d'édition directe depuis l'arbre ou le formulaire.
  • Le chemin fonctionnel de modification est le wizard lot.weighing, exposé par l'action Do weighing.
  • Le wizard écrit ou met à jour l'entrée lot.qt.hist correspondant au lot_state choisi, puis synchronise le lot, les quantités ouvertes et les fees.
  • Un lot virtuel ne doit pas recevoir de saisie de packing depuis la fiche lot: les champs lot_qt et lot_unit sont non éditables pour lot_type = virtual.

Quantité compteur de ligne

  • Méthode : lot.py, Lot._recalc_line_quantity.
  • Si la ligne n'a qu'un seul lot, quantity reprend la quantité courante de ce lot.
  • Si la ligne a plusieurs lots, quantity somme uniquement les lots physiques.
  • Cette logique correspond à la règle fonctionnelle :
    • sans physique : quantity suit le virtuel ;
    • avec physiques : quantity reflète l'exécuté physique.

Montant de ligne

  • Achat : purchase.line.on_change_with_amount().
  • Vente : sale.line.on_change_with_amount().
  • Tant que finished = False, la quantité de base du montant est quantity_theorical.
  • Quand finished = True, la quantité de base du montant peut revenir à l'exécuté physique, car l'utilisateur a accepté d'ignorer le reliquat virtuel ouvert.
  • Si la ligne finie possède des lots physiques et que le contrat porte un Weight basis avec qt_type, le montant somme les quantités physiques dans cet état lot.qt.hist, converties dans l'unité de la ligne.
  • L'achat résout cet état via purchase.purchase.wb.qt_type.
  • La vente résout cet état via sale.sale.wb.qt_type.
  • Le même lot physique matché peut donc contribuer à un montant achat en état BL et à un montant vente en état LR.
  • Si quantity_theorical est vide dans un ancien flux, le code conserve un fallback sur quantity.
  • Si quantity est vide sur une ligne finie, le code conserve un fallback sur quantity_theorical.
  • Si l'état du Weight basis n'existe pas encore sur un lot, le code conserve le fallback précédent au lieu de considérer le montant comme final.
  • La facturation reste indépendante de cette règle de montant de ligne : les lignes de facture peuvent utiliser un état de quantité choisi dans lot.qt.hist selon la règle contractuelle de facturation.

Tolérance : état actuel et point à confirmer

Le code expose déjà les tolérances sur contrats et lignes :

  • Achat : purchase.purchase.tol_min, purchase.purchase.tol_max, purchase.line.tol_min, purchase.line.tol_max.
  • Vente : sale.sale.tol_min, sale.sale.tol_max, sale.line.tol_min, sale.line.tol_max.
  • Fonctions d'affichage : get_tol_min, get_tol_max.
  • Wizard d'ajout : lot.add.line.tol_min, lot.add.line.tol_max, avec défauts depuis le contrat source.

À confirmer / gap potentiel :

  • Dans LotQt.add_physical_lots et LotQt.add_physical_lot, je ne vois pas encore de contrôle complet qui calcule une tolérance restante globale en tenant compte des lots physiques déjà créés.
  • La règle consultant ci-dessus de tolérance globale dynamique doit donc être considérée comme cible métier à vérifier/implémenter avant de la marquer active.

Tests proches

  • modules/purchase_trade/tests/test_module.py
    • test_trade_line_quantity_is_readonly
    • test_purchase_line_initial_quantity_uses_theoretical_quantity
    • test_sale_line_initial_quantity_uses_theoretical_quantity
    • test_trade_line_initial_quantity_does_not_override_existing_counter
    • test_trade_line_initial_quantity_does_not_run_with_physical_lot
    • test_purchase_line_amount_uses_theoretical_quantity_until_finished
    • test_purchase_line_amount_uses_physical_counter_when_finished
    • test_purchase_line_amount_uses_purchase_weight_basis_when_finished
    • test_sale_line_amount_uses_theoretical_quantity_until_finished
    • test_sale_line_amount_uses_physical_counter_when_finished
    • test_sale_line_amount_uses_sale_weight_basis_when_finished
    • test_sale_line_write_updates_virtual_lot_when_theorical_qty_increases
    • test_sale_line_write_blocks_theorical_qty_decrease_when_no_open_quantity
    • test_purchase_line_write_syncs_open_lot_qt_with_physical_lots
    • test_purchase_line_write_syncs_virtual_fee_quantity
    • test_purchase_line_write_initial_theorical_qty_does_not_double_open_lot

À ajouter si cette règle devient sensible côté régression:

  • vérifier que lot_hist est en lecture seule sur la fiche lot.lot;
  • vérifier que Do weighing reste capable de créer ou mettre à jour un état de quantité;
  • vérifier qu'un lot virtuel ne permet pas la saisie directe de lot_qt et lot_unit.