Le ledger de stock

Le stock de la plateforme est tenu par un ledger signé : Product.stockLedger est l'autorité (il peut être négatif — une précommande réelle), et Product.stock n'en est que le miroir d'affichage bridé (stock == max(0, stockLedger)). Cette page est la référence du modèle : pourquoi il existe, l'anatomie d'une StockAction, la frontière de confiance trustedStock lue par le réapprovisionnement, et les invariants qui gardent le tout honnête.

Pour la vue d'ensemble du flux complet (stock → réappro → email fournisseur), voir la vue d'ensemble ; pour le détail de chaque site d'écriture, voir les chemins d'écriture.

Le modèle Option B (#2886)

Deux colonnes sur Product, un seul contrat :

Colonne Rôle Peut être négative ?
Product.stockLedger Autorité signée : ouverture + Σ réceptions − Σ livraisons ± ajustements. Un négatif = une précommande (livré au client avant que la réception couvrante soit enregistrée). Oui
Product.stock Miroir d'affichage pour la boutique et le back-office : max(0, stockLedger). Non

Chaque mouvement physique enregistre le vrai delta signé dans le ledger, même quand il le fait passer sous zéro. La réception suivante nette la précommande au lieu d'empiler du stock libre par-dessus un compteur bridé à 0. Le calcul est une fonction pure, partagée par tous les sites d'écriture :

packages/services/src/stock/rolling-stock-math.ts   → computeStockWrite / displayStock
packages/services/src/stock/stock-ledger.service.ts → recordPhysicalMovement

Le comportement signé est gaté par STOCK_LEDGER_ALLOW_BACKORDER (true en production depuis le 2026-07-09T20:00Z). Flag OFF, le calcul retombe octet pour octet sur l'ancien modèle bridé — le correctif n'existe que sous le flag.

Attention

Règle cardinale (rule 17) : aucune écriture de Product.stock sans (a) vérifier manageStock ET (b) émettre la StockAction correspondante dans la même transaction. Tout nouveau site d'écriture doit passer par StockLedgerService — jamais un product.update({ stock }) nu.

Pourquoi un ledger signé — trois générations du même bug

Le modèle actuel est la réponse à une classe d'incidents bien documentée :

  1. Mai 2026 — le phantom stock originel. Des StockAction PENDING virtuelles étaient créées pour des produits manageStock=false, avec quantityBefore=0 bouché en dur ; les régénérations de bons fournisseurs annulaient la source sans reverser les incréments COMPLETED déjà appliqués. Résultat : ~912 unités fantômes sur ~57 produits, et des bons fournisseurs sous-commandés (Bio Rennes 2026-05-06 : 6 lignes au lieu de 39+).
  2. #2728 — la dérive multi-sources. Product.stock, la quantité synchronisée depuis PrestaShop et Σ(stockAction) divergeaient silencieusement : trois « vérités » incompatibles, aucune fiable.
  3. #2886 — le clamp en flux tendu. En rolling sans stock tampon, le stock physique vit à 0 entre deux réceptions. Une livraison client validée à stock 0 était bridée (max(0, 0 − qté) = 0) : la consommation était silencieusement perdue, la réception suivante créait du stock fantôme, et la commande fournisseur suivante était sous-dimensionnée (cas mesuré : Baron 31 → 26). Le piège : ce fantôme passait l'audit, puisque stock == stockLedger == la valeur fictive — cohérent en interne, faux physiquement.

Le ledger signé rend le fantôme par clamp structurellement impossible : le −5 d'une livraison à stock 2 est enregistré en entier (ledger −3), et la réception de 10 atterrit sur 7, pas sur 10.

Anatomie d'une StockAction

Chaque mouvement — réel ou projeté — est une ligne StockAction (packages/db/prisma/schema/order.prisma) :

Champ Rôle
actionType INCREMENT (réception, retour), DECREMENT (livraison, vente), ADJUSTMENT (défini mais inerte pour le ledger — les corrections réelles émettent des INCREMENT/DECREMENT signés) — plus des types périphériques (DAMAGED, LOST, FOUND, …)
source Provenance : MANUAL, ORDER_UPDATE, SUPPLIER_DELIVERY, INVENTORY_ADJUSTMENT, PRESTASHOP_SYNC, OPENING_BALANCE (le solde d'ouverture à T0), SYSTEM, API
status PENDING (projection), COMPLETED (appliqué au ledger), CANCELLED (abandonné)
quantityBefore Ledger réel au moment de l'application — jamais un stub
quantityChange Delta signé ; invariant : quantityChange = quantityAfter − quantityBefore
quantityAfter Ledger après application (signé sous Option B)
referenceType / referenceId L'entité métier source (order, fulfillment, bon fournisseur, …)
processedAt Date métier de l'application (≠ createdAt)

Seules les lignes COMPLETED comptent dans le ledger : stockLedger est le cache de Σ quantityChange des SA COMPLETED du produit. Les PENDING sont des projections — le réservé-entrant (« Entrée virtuelle » d'un bon fournisseur émis) et le réservé-sortant (« Sortie virtuelle » d'une commande client) — et ne touchent ni stock ni stockLedger.

Cycle de vie et règles de réversibilité

stateDiagram-v2
    [*] --> PENDING : projection créée (bon fournisseur émis, commande client)
    PENDING --> COMPLETED : confirmation au mouvement physique
    PENDING --> CANCELLED : source annulée ou régénérée (aucun effet stock)
    COMPLETED --> [*] : suppression = reverseCompleted (ledger -= quantityChange)
    CANCELLED --> [*]
    note right of COMPLETED
        Seul état qui écrit le ledger.
        quantityBefore/After recalculés
        contre le ledger réel au confirm.
    end note

Les règles, dans l'ordre où elles ont été apprises :

  • PENDING → COMPLETED (confirmStockAction / StockLedgerService.transitionToCompleted) : les quantités sont recalculées contre le ledger courant au moment du confirm — on ne fait jamais confiance aux valeurs bouchées de la projection. Sous Option B, la base est stockLedger et Product.stock reçoit le miroir bridé.
  • PENDING → CANCELLED : sans effet stock. C'est le sort normal d'une projection dont la source est annulée ou régénérée.
  • COMPLETED ne s'annule jamais silencieusement. Supprimer un mouvement passe par reverseCompleted (dans stock-actions.service.ts) : le ledger est décrémenté du quantityChange exact, puis Product.stock est réécrit comme miroir bridé du ledger reversé. La reversion « par delta appliqué » est fausse pour les mouvements qui ont traversé zéro — c'est précisément le bug historique.
  • Jamais de quantityBefore=0 bouché. Lire le ledger réel d'abord (incident mai 2026).
  • Les statuts PROCESSING, FAILED, PARTIAL, VALIDATED, REJECTED existent dans l'enum mais les trois états ci-dessus portent toute la sémantique du ledger.

La sémantique de manageStock

manageStock sépare les produits suivis des produits non suivis :

manageStock=true manageStock=false
Écritures de stock Obligatoires, atomiques, avec SA Interdites — le confirm marque la SA COMPLETED pour l'audit mais saute l'écriture Product.stock
Lecture réappro (trustedStock) Valeur du ledger (voir ci-dessous) 0 — la commande fournisseur couvre la demande client complète
Projections PENDING virtuelles Oui Jamais — c'est la pollution de ledger de mai 2026
Réappro par cron File événementielle + cron 30 min Uniquement le cron reconcile-supplier-orders (07:15)
Attention

Ne créez jamais de StockAction PENDING virtuelle pour un produit manageStock=false, et ne « corrigez » jamais son stock à autre chose que 0 (invariant I2). Un produit non suivi n'a pas de vérité stock — le réappro le traite en demande pleine.

La frontière de confiance : trustedStock

Tout le calcul de réapprovisionnement lit le stock à travers une seule fonction, trustedStock() (packages/services-supplier-order/src/utils/supplier-order-utils-new.ts). Elle décide combien d'unités en main sont crédibles pour dimensionner un bon fournisseur :

flowchart TD
    A[Produit] --> B{STOCK_LEDGER_PESSIMISTIC ?}
    B -->|true| Z0["retourne 0 (kill switch)"]
    B -->|false| C{manageStock ?}
    C -->|false| Z0
    C -->|true| D{"fournisseur flux tendu<br/>ET stock non validé marchand ?"}
    D -->|oui| Z0
    D -->|non| E{vendor.useStockLedger ?}
    E -->|oui| F{STOCK_TRUST_ALLOW_NEGATIVE ?}
    F -->|oui| G["brut = stockLedger (signé)"]
    F -->|non| H["brut = max(0, stockLedger)"]
    E -->|non| I["brut = max(0, stock)"]
    G --> J{"data.stockTrusted<br/>(comptage validé) ?"}
    H --> J
    I --> J
    J -->|oui| K[retourne brut]
    J -->|non| L["plafond = conso hebdo × STOCK_TRUST_MAX_COVER_WEEKS<br/>retourne min(brut, plafond)"]

Les gardes, une par une :

  • STOCK_LEDGER_PESSIMISTIC — le kill switch (#2728). true ⇒ le stock est ignoré partout (retour 0, sur-commande assumée, les brouillons sont relus avant envoi). OFF en production : le ledger est de confiance depuis que les écritures sont auditées.
  • manageStock=false ⇒ 0 — un produit non suivi n'apporte aucune couverture.
  • Flux tendu (madeToOrder) ⇒ 0 — un fournisseur sans inventaire n'a pas de stock réel en rayon ; son « en main » est un artefact de validation. Exception #2932 : si le marchand a explicitement validé un comptage (data.stockTrusted), la valeur compte.
  • Négatif ⇒ bridé à 0 (#2725) — sauf sous le gate STOCK_TRUST_ALLOW_NEGATIVE (OFF en prod) : une fois activé, un ledger négatif traverse tel quel, et la précommande grossit la commande suivante. Le flip est prévu après ≥ 4 semaines d'invariant I6 vert. Le chemin legacy Product.stock reste toujours bridé.
  • Le plafond anti-fantômeSTOCK_TRUST_MAX_COVER_WEEKS (8 en prod) : on ne fait jamais confiance à plus de N semaines de consommation récente (Σ des DECREMENT COMPLETED sur les 8 dernières semaines, attachée par attachRecentWeeklyConsumption). Un produit à 991 en main mais ~1 vente/semaine est presque sûrement du stock PrestaShop gonflé — le plafond l'écrête. Consommation nulle ⇒ plafond 0 ⇒ stock totalement défié : on commande la demande pleine, la direction conservatrice (sur-commander, jamais de rupture).
Astuce

La frontière est épinglée par les tests packages/tests/tests/unit/supplier-order/stock-ledger-trust-boundary.test.ts (77 cas, données réelles Bio Rennes / #2725 / plafond). Tout nouveau cas limite découvert doit y ajouter un test — c'est le filet le moins cher du système.

Les invariants I1–I7

La tâche stock-ledger-audit (packages/scripts/src/tasks/stock-ledger-audit.ts) vérifie le ledger en continu :

# Rapport (lecture seule)
cd apps/app && bun run cli --task stock-ledger-audit --tenantId <uuid> --siteId <uuid>

# Gate CI / sonde — lève une exception à la moindre violation
cd apps/app && bun run cli --task stock-ledger-audit --tenantId <uuid> --siteId <uuid> --strict

# Réparation (annule les orphelins, resynchronise les miroirs, …)
cd apps/app && bun run cli --task stock-ledger-audit --tenantId <uuid> --siteId <uuid> --apply
Invariant Énoncé Ce qu'il attrape Limites de portée
I1 Aucun Product.stock < 0 Un miroir d'affichage qui aurait fui le clamp
I2 Aucun manageStock=false avec stock != 0 Écritures sur produits non suivis (cas Bio Rennes)
I3 Aucune projection PENDING SUPPLIER_DELIVERY périmée Projections dont le bon parent est annulé, ou envoyé et en retard Les brouillons en file de relecture sont un état normal (info, pas violation)
I4 Aucune SA référençant un bon fournisseur supprimé Orphelins de suppression
I5 stock == max(0, Σ COMPLETED) (Option B ; égalité stricte legacy) Tout chemin qui bouge Product.stock sans entrée de ledger Produits actifs uniquement ; niveau produit (pas les variations)
I5b stockLedger == Σ COMPLETED Dérive du cache que trustedStock lit réellement — I5 peut rester vert pendant que le réappro lit une valeur fausse --apply resynchronise le cache sur Σ (Σ est la vérité, aucune SA de correction)
I6 Aucun DECREMENT de livraison « signature clamp » (quantityChange=0) depuis le cutover Une livraison qui bride encore sa consommation = régression Option B Détecte le clamp total uniquement (pas le clamp partiel) ; sans STOCK_LEDGER_BACKORDER_CUTOVER, compte la dette historique en info seule
I7 (info seule) ProductVariation.stock == Σ COMPLETED des SA portées par la variation La dérive au niveau variation — l'angle mort de I5/I5b, qui se limitent à productVariationId: null Le stock de variation est hors du modèle ledger (trustedStock et le réappro lisent le produit) : jamais gaté par --strict, jamais réparé par --apply — pure visibilité

Sous Option B, la réparation I5 ne resynchronise que le miroir d'affichage — Σ des SA COMPLETED est l'autorité, on n'écrit jamais une SA de remblai pour faire coller le compteur.

Exemple chiffré : la précommande, pas à pas

Le scénario canonique du flux tendu — une livraison client part avant que la réception couvrante soit enregistrée. Suivez les deux colonnes.

Le produit est suivi (manageStock=true), le fournisseur lit le ledger (useStockLedger=true).

stockLedger Product.stock
2 2 (= max(0, 2))

Σ des StockAction COMPLETED = +2 (solde d'ouverture OPENING_BALANCE). I5 et I5b verts.

Le marchand scelle la livraison (ou l'auto-validate la rattrape). Le calcul (computeFulfillmentStockWrite, delta = −5) :

quantityBefore = 2        (ledger réel, jamais un stub)
quantityChange = −5       (le VRAI delta — pas de clamp)
quantityAfter  = −3       (ledger signé)
Product.stock  = max(0, −3) = 0
stockLedger Product.stock
−3 0

Le −3 est une précommande : 3 unités livrées que nous devons encore couvrir. Sous l'ancien modèle bridé, la SA aurait porté quantityChange = −2 et 3 unités de consommation auraient disparu — le germe du stock fantôme.

Avec le gate STOCK_TRUST_ALLOW_NEGATIVE OFF (état prod actuel), le −3 est bridé : trustedStock = 0. La demande client non couverte continue de tirer la commande fournisseur — rien n'est perdu, mais la précommande elle-même n'agrandit pas encore le bon.

Gate ON (prévu après ≥ 4 semaines de I6 vert) : le −3 traverse, et la commande suivante grossit de 3 unités supplémentaires. C'est le dernier raffinement du modèle.

Le bon fournisseur arrive ; la réception est confirmée (computeReplenishStockWrite, delta = +10) :

quantityBefore = −3
quantityChange = +10
quantityAfter  = 7
Product.stock  = max(0, 7) = 7
stockLedger Product.stock
7 7

La réception nette le −3 au lieu d'empiler +10 sur un compteur bridé à 0 (qui aurait donné 10 — soit +3 de stock fantôme, exactement le mécanisme Baron 31 → 26).

Σ COMPLETED = +2 − 5 + 10 = 7 = stockLedger (I5b ✓). Product.stock = 7 = max(0, 7) (I5 ✓). Aucune SA à quantityChange = 0 (I6 ✓). Chaque unité physique est tracée ; la prochaine commande fournisseur part d'une vérité, pas d'une fiction.

Flags de production

État vérifié sur le tenant de production (voir Configuration pour la matrice complète) :

Variable Prod Effet
STOCK_LEDGER_ALLOW_BACKORDER true (depuis 2026-07-09) Ledger signé, stock = miroir bridé
STOCK_LEDGER_PESSIMISTIC false Kill switch OFF — le stock est lu (avec plafond)
LEDGER_AS_SOURCE_OF_TRUTH true Le ledger local est l'autorité stock
STOCK_TRUST_MAX_COVER_WEEKS 8 Plafond anti-fantôme (semaines de conso)
STOCK_TRUST_ALLOW_NEGATIVE OFF Passthrough du négatif au réappro (futur)
STOCK_LEDGER_BACKORDER_CUTOVER 2026-07-09T20:00:18Z Borne du compteur I6 (gate dur depuis)
Note

apps/app bake l'environnement au build : un flip de gate exige un rebuild, pas seulement un restart. Les crons, eux, relisent l'environnement à chaque exécution.

Voir aussi