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.
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 :
- Mai 2026 — le phantom stock originel. Des
StockActionPENDING virtuelles étaient créées pour des produitsmanageStock=false, avecquantityBefore=0bouché 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+). - #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. - #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, puisquestock == 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 eststockLedgeretProduct.stockreç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(dansstock-actions.service.ts) : le ledger est décrémenté duquantityChangeexact, puisProduct.stockest 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=0bouché. Lire le ledger réel d'abord (incident mai 2026). - Les statuts
PROCESSING,FAILED,PARTIAL,VALIDATED,REJECTEDexistent 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) |
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 legacyProduct.stockreste toujours bridé. - Le plafond anti-fantôme —
STOCK_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 parattachRecentWeeklyConsumption). 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).
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) |
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
- Vue d'ensemble stock & réappro — le flux complet en une page
- Les chemins d'écriture — chaque site qui écrit le ledger
- Commandes fournisseurs — le réappro événementiel qui lit
trustedStock - La journée type — crons 06:00 / 06:30 / 07:15 / 12:01
- Synchronisation PrestaShop — écritures sync intégrées au ledger (#2749)
- Filets de sécurité — audit, replay-diff, tests épinglés, sonde santé
- Back-office — timeline ledger, mouvements manuels, journal des StockActions
- Configuration — la matrice complète des flags