Chemins d'écriture du stock
Sur la plateforme, aucune écriture de stock physique n'est libre : chaque mutation de
Product.stock passe par un contrat unique — vérifier manageStock, puis émettre une
StockAction dans le même mouvement pour que Product.stockLedger (l'autorité signée)
reste égal à la somme des mouvements. Cette page inventorie tous les chemins d'écriture,
avec pour chacun le déclencheur, le code, le contrat transactionnel et l'idempotence. Pour le
modèle de données lui-même, voir Le ledger de stock.
Le contrat commun
Depuis le modèle Option B (#2886, actif en production depuis le 2026-07-09) :
Product.stockLedgerest l'autorité signée : elle peut passer en négatif — un négatif est une précommande réelle (livraison scellée avant la réception couvrante).Product.stockest le miroir d'affichage bridé : invariantstock == max(0, stockLedger).- Une
StockActiona un statutPENDING(projection, sans effet ledger),COMPLETED(appliquée) ouCANCELLED. Seules les SACOMPLETEDde typeINCREMENT/DECREMENTbougent le ledger ; lesADJUSTMENTet lesPENDINGn'ont aucun effet.
Le point d'entrée partagé est StockLedgerService
(packages/services/src/stock/stock-ledger.service.ts) :
| Méthode | Rôle |
|---|---|
recordPhysicalMovement(tx, {delta signé, source, référence}) |
Le contrat de mouvement physique : lit stock + stockLedger, calcule l'écriture, écrit Product.stock, crée la SA COMPLETED et incrémente stockLedger — le tout dans la transaction fournie |
createStockAction(data, tx?) |
Crée une SA ; si COMPLETED signée, bump du stockLedger dans la même transaction |
transitionToCompleted(saId) |
PENDING → COMPLETED + bump ledger (idempotent : déjà COMPLETED = no-op) |
reverseCompleted(saId, tx?) |
Réverse une SA COMPLETED : supprime la ligne + stockLedger -= quantityChange. Un mode "cancel" (passage à CANCELLED plutôt que suppression) a existé mais n'était appelé par personne — supprimé, pour ne garder qu'un seul contrat de réversal |
cancelPendingByReference({referenceId}) |
Annulation en masse de projections PENDING (aucun effet ledger) |
Le calcul lui-même est de la math pure, sans I/O, dans
packages/services/src/stock/rolling-stock-math.ts (computeStockWrite,
computeFulfillmentStockWrite, computeReplenishStockWrite,
computeManualStockAdjustment). Sous STOCK_LEDGER_ALLOW_BACKORDER=true, la base du calcul
est le ledger signé et le quantityChange porte le vrai delta (une livraison de 10
sur un stock 0 enregistre −10, pas 0) ; flag OFF, le comportement est octet-pour-octet le
clamp historique. Le actionType suit le signe du delta demandé, pas du delta appliqué.
Le mouvement canonique, de l'UI à la base :
sequenceDiagram
autonumber
participant UI as Back-office (/manage)
participant TRPC as Routeur TRPC
participant SVC as Service métier
participant LED as StockLedgerService
participant DB as PostgreSQL
UI->>TRPC: mutation (scellé, réception, édition…)
TRPC->>SVC: getService(...) + garde permission
SVC->>DB: BEGIN $transaction
SVC->>SVC: garde manageStock (non suivi = aucun mouvement)
SVC->>LED: recordPhysicalMovement(tx, delta signé)
LED->>DB: SELECT stock, stockLedger
LED->>LED: computeStockWrite(ledgerBefore, delta)
LED->>DB: UPDATE product SET stock = max(0, ledgerAfter)
LED->>DB: INSERT StockAction (COMPLETED, quantityChange signé)
LED->>DB: UPDATE product SET stockLedger += quantityChange
SVC->>DB: COMMIT
SVC-->>UI: résultat (before / after)
Le contrat de la règle 17 tient en une phrase : jamais d'écriture de Product.stock sans
(a) vérification de manageStock et (b) émission de la StockAction correspondante dans le
même mouvement. Chaque chemin ci-dessous est une déclinaison de ce contrat.
Tableau récapitulatif
| Chemin | Déclencheur | Code (service · méthode) | Garde | SA émise (source · type) | Transaction |
|---|---|---|---|---|---|
| Crayon stock (inline + fiche) | Édition rapide dans /manage/ecommerce/vendors/stocks |
VendorService.updateProductStock → planManualStockEdit + recordManualStockLedgerAdjustment |
Produit « sans stock propre » refusé ; auto-active manageStock |
MANUAL · ± (COMPLETED) |
Écriture stock puis SA + ledger atomiques (tx du service ledger) |
| Édition en masse | Multi-sélection de la grille stocks | VendorService.bulkUpdateProductStock (mêmes helpers) |
idem crayon | MANUAL · ± |
idem crayon |
| Éditeur produit (save) | Sauvegarde de la fiche produit complète | ProductService.updateWithAllNeeds |
Dirty-gated (valeur soumise seulement si modifiée) ; manageStock effectif du formulaire |
MANUAL · ± |
$transaction unique (SA dans la tx) |
| Recomptage « stock fantôme » | Page /manage/ecommerce/vendors/orders/phantom-stock |
SupplierOrderService.setProductStockManual |
manageStock=false → erreur |
INVENTORY_ADJUSTMENT · ± |
$transaction unique |
| Inventaire physique (finalize) | /manage/ecommerce/inventory/counts |
InventoryCountService.finalize |
manageStock=false ignoré (ni écriture, ni SA) |
INVENTORY_ADJUSTMENT · ± (referenceType=inventory_count) |
$transaction unique pour tout le comptage |
| Ajustement / réappro API | InventoryService.adjustStock / restockProduct |
packages/services/src/inventory/service.ts |
non suivi → warn + no-op | INVENTORY_ADJUSTMENT · ± / SUPPLIER_DELIVERY · INCREMENT |
$transaction unique (+ miroir PS) |
| Réception fournisseur | « Confirmer la livraison » sur un bon | SupplierOrderService.confirmDelivery → recordPhysicalMovement |
manageStock par item ; génériques ré-alloués |
SUPPLIER_DELIVERY · INCREMENT |
tx par item + annulation des PENDING |
| Annulation de réception | « Annuler la livraison » | SupplierOrderService.undoSupplierOrderDelivery → reverseCompleted |
SAs estampillées deliveryId |
suppression des SA + réversal ledger | $transaction unique |
| Scellé de livraison client | Clic marchand / « Valider la tournée du jour » | OrderFulfillmentService.createFulfillment → recordPhysicalMovement |
manageStock (la variation hérite du parent) |
ORDER_UPDATE · DECREMENT (referenceType=fulfillment) |
tx englobante ; écriture stock best-effort |
| Annulation de scellé | Annulation d'un fulfillment | OrderFulfillmentService.cancelFulfillment |
restaure |quantityChange| de la SA d'origine |
ORDER_UPDATE · INCREMENT |
tx englobante |
| Annulation de commande | Commande annulée avec items livrés | lifecycle.ts cancelOrder |
seules les qty réellement livrées ; sans userId → écriture brute sans SA (quirk hérité) |
ORDER_UPDATE · INCREMENT |
tx par produit |
| Auto-validate (cron) | 12:01 + toutes les 2 h (filet de sécurité) | auto-validate-stock-actions.ts |
grace 12 h ; AUTO_VALIDATE_WRITES_STOCK ; flux tendu sauté sauf stockTrusted |
SUPPLIER_DELIVERY · INCREMENT / ORDER_UPDATE · DECREMENT |
tx par bon / par commande |
| Sync PrestaShop + webhooks | Cron 3 h + /api/webhooks/prestashop/products |
importPrestashopProduct → buildPsStockLedgerEntry |
null si manageStock=false ou delta 0 |
PRESTASHOP_SYNC / OPENING_BALANCE · ± |
best-effort (n'échoue jamais le sync) |
| Suppression de mouvement | Journal /manage/ecommerce/inventory/stock-actions |
StockActionsService.deleteLedgerMovement |
réversal seulement si COMPLETED signée + suivi |
suppression + réversal ledger | $transaction unique |
| Heal / backfill | stock-ledger-audit --apply, reconcile-clamped-stock |
packages/scripts/src/tasks/ |
dry-run par défaut, safety cap | SYSTEM · ± |
tx par produit |
Les sections suivantes détaillent chaque famille.
Éditions manuelles
Crayon stock (liste + fiche)
- Déclencheur : l'édition inline dans la grille
/manage/ecommerce/vendors/stocksou le crayon de l'onglet Fiche. - Chemin : TRPC
vendors.updateProductStock(packages/trpc-routers/src/vendors/router.ts) →VendorService.updateProductStock(packages/services-vendor/src/vendor/service.ts). - Règles (centralisées dans
packages/services/src/stock/manual-stock-adjustment.ts) :planManualStockEditrefuse les produits « sans stock propre » (génériques,COMPOSITE/FORMULE/MENU) — leur stock dérive des produits liés, une SA les polluerait.- Éditer le stock d'un produit
SIMPLEnon suivi auto-activemanageStock(opt-in implicite). - Le delta ledger est calculé contre
Product.stockLedger, jamais contre leProduct.stockaffiché (qui peut porter des valeurs PrestaShop fantômes — incident 2026-06-08). recordManualStockLedgerAdjustmentémet la SAMANUALCOMPLETED(raison canonique « Ajustement de stock »,referenceType=manual_adjustment), posedata.stockTrusted(une saisie marchande est une validation d'on-hand : exemption du plafond anti-stock-fantômeSTOCK_TRUST_MAX_COVER_WEEKS, #2802) et enfile un recalcul fournisseur ciblé (fire-and-forget, jamais bloquant dans la tx).
- Idempotence :
newStock == valeur affichée→ retour immédiat, aucune écriture, aucune SA. Le miroirProductStockAvailableest upserté avec la même valeur.
Éditeur produit complet (dirty-gated)
- Déclencheur : la sauvegarde de la fiche produit (
products.updateWithAllNeeds). - Chemin :
ProductService.updateWithAllNeeds(packages/services/src/product/local/product.service.ts). - Garde-fou clé (2026-07-11) : le client n'envoie
stock/stockQuantityque si le marchand a réellement modifié la valeur. Sans valeur soumise, niProduct.stockni le ledger ne sont touchés — sinon le snapshot du chargement de page écraserait un mouvement survenu entre-temps (cron, réception, scellé) et la SA post-update bookerait fidèlement ce delta fantôme. - L'onglet Inventaire porte un choix
manageStockexplicite : « activer le suivi + saisir un stock » dans la même sauvegarde émet bien la SA (le gate teste lemanageStockeffectif, pas l'ancien). - Tout se passe dans une seule
$transaction: update produit, SAMANUAL(via le helper canonique,txtransmise), miroirProductStockAvailable(#2744).
Recomptage « stock fantôme »
- Déclencheur : la page
/manage/ecommerce/vendors/orders/phantom-stock(« corriger le stock »). - Chemin :
SupplierOrderService.setProductStockManual(packages/services-supplier-order/src/supplier-order/service.ts). manageStock=false→ erreur (BAD_REQUEST), pas de silencieux.- Le delta est calculé contre le ledger signé via
computeManualStockAdjustment: un produit à ledger −256 / affichage 0 recompté à 12 émet une SA de +268 pour que le ledger atterrisse sur 12 (baser le delta sur le stock bridé laisserait le ledger à −244 → désynchronisation). - Pose
data.stockPin+data.stockTrustedet réaligne le miroir PS. SAINVENTORY_ADJUSTMENTCOMPLETED, le tout dans une$transactionunique.
Inventaire physique (finalize)
- Déclencheur : la finalisation d'un comptage dans
/manage/ecommerce/inventory/counts. - Chemin :
InventoryCountService.finalize(packages/services/src/stock/inventory-count.service.ts). - Basé ledger depuis le 2026-07-11 : pour chaque item avec
countedQtynon nul,- les produits
manageStock=falsesont ignorés entièrement (compteurskippedUntracked— ni écriture, ni SA) ; - le delta du mouvement =
counted − stockLedger(signé), et les deux colonnes atterrissent surcounted(backorder ledger −5 / stock 0, compté 3 → SA +8, ledger 3, stock 3 — l'invariantstock == max(0, stockLedger)est garanti carcounted ≥ 0) ; - si le ledger vaut déjà
countedmais que le miroir est désaligné, le miroir est réparé sans SA à delta nul ; - la SA
INVENTORY_ADJUSTMENTCOMPLETED(referenceType=inventory_count) est reliée à l'item viainventoryCountItem.stockActionId, etdata.stockTrustedest posé (#2802).
- les produits
- Transaction : tout le comptage dans une
$transaction, avec relecture du stock sous la transaction pour que des mutations concurrentes entre le début du comptage et le finalize ne soient pas écrasées. - Idempotence : un comptage
FINALIZEDouCANCELLEDne peut pas être re-finalisé. - Les lignes par variation gardent le comportement historique (écriture vs
ProductVariation.stock, hors périmètre du contrat ledger produit).
Réception fournisseur
Confirmer une livraison
- Déclencheur : « Confirmer la livraison » sur un bon fournisseur.
- Chemin :
SupplierOrderService.confirmDelivery(packages/services-supplier-order/src/supplier-order/service.ts). - Les quantités du bon sont en conditionnements ; la conversion en unités de
consommation est
quantityReceived × packageSize. - Chaque item suivi passe par
recordPhysicalMovement(delta positif, sourceSUPPLIER_DELIVERY,referenceType=supplier_order,referenceData.deliveryId) dans une transaction par item. Les produits génériques sont ré-alloués vers leurs produits liés réels avant l'écriture. Sous Option B, une réception nette d'abord la précommande (ledger −5 +10 → 5, affichage 5) au lieu d'empiler sur le compteur bridé. - Ensuite : annulation en masse des SA
PENDINGdu bon (les projections sont devenues réelles) + recalcul fournisseur ciblé (reason: "reception") — voir Commandes fournisseurs.
Annuler une livraison (undo)
- Chemin :
SupplierOrderService.undoSupplierOrderDelivery. - L'undo retrouve exactement les SA
COMPLETED INCREMENTestampilléesreferenceData.deliveryIdpar le confirm — c'est le signal d'idempotence — puis, dans une$transactionunique :reverseCompleted(id)par SA (suppression +stockLedger -= quantityChange) ;- sous Option B, réécriture du miroir
Product.stock = max(0, stockLedger)après réversal (un undo brut du compteur pousserait l'affichage en négatif si la livraison avait netté une précommande) ; - restauration des SA
PENDINGannulées par ce confirm (uniquement si c'était la dernière livraison du bon) ; - suppression de l'enregistrement de livraison.
Scellé de livraison client (fulfillment)
Le décrément de stock a lieu à la livraison uniquement — le sync de commandes ne décrémente jamais (chemin supprimé le 2026-06-14). Le scellé primaire est le geste du marchand ; le cron auto-validate n'est que le filet.
- Déclencheur : le clic marchand dans le back-office ou le bouton en masse « Valider la tournée du jour ».
- Chemin :
OrderFulfillmentService.createFulfillment(packages/services-order/src/order/fulfillment.ts). - Par item : lecture de
product.manageStock(la variation hérite du réglage parent), puisrecordPhysicalMovement(delta = −qté, sourceORDER_UPDATE,referenceType=fulfillment,referenceId= l'id du fulfillment créé). Les réservations de stock sont consommées dans la même passe. - Les SA
PENDINGde la commande sont annulées — les deux casingsorder/ORDERsont couverts (héritage du sync legacy).
L'écriture stock du scellé est best-effort : une erreur de stock est loggée mais
n'échoue pas le fulfillment (le geste métier prime). C'est l'audit quotidien
stock-ledger-audit qui rattrape un éventuel manque — voir
Filets de sécurité.
Annulation de scellé et de commande
cancelFulfillmentrelit la SADECREMENTd'origine du fulfillment et restaure |quantityChange| (ce qui a réellement été décrémenté, pas la quantité nominale — un décrément clampé à 0 sous flag OFF ne restaure donc rien de fantôme), viarecordPhysicalMovement(delta positif).cancelOrder(packages/services-order/src/order/lifecycle.ts) ne restaure que les quantités réellement livrées (Σ des items de fulfillment non annulés). Quirk hérité, conservé sciemment : sansuserIdla FK de la SA ne peut pas être satisfaite → écriture brute du compteur sans SA ; l'audit quotidien (I5) est le filet.
Auto-validate — le filet de sécurité
- Déclencheur : cron quotidien 12:01 + passe 2-horaire
(
auto-validate-stock-actions.sh), voir la chronologie quotidienne. - Chemin :
packages/scripts/src/tasks/auto-validate-stock-actions.ts. - Depuis #2886, ce cron n'est plus le moteur primaire : il scelle ce que le marchand a
oublié (la sonde santé suit
autoValidateSealed24h— idéalement proche de 0). - PART A — réceptions oubliées : bons
SENT/CONFIRMEDsans livraison dont la date prévue est passée d'au moinsAUTO_VALIDATE_GRACE_HOURS(12 h — une date prévue n'est pas une preuve de livraison) → crée laSupplierOrderDelivery+ les SASUPPLIER_DELIVERY INCREMENTviacomputeReplenishStockWrite+createManyStockActions(referenceData.autoValidated=true). Les items flux tendu (made-to-order) sont sautés sauf si le produit eststockTrusted(#2932). Gaté parAUTO_VALIDATE_WRITES_STOCK. - PART B — livraisons client oubliées : commandes avec items non scellés dont
deliveryDateest passée du même grace → crée l'OrderFulfillment+ une SAORDER_UPDATE DECREMENTpar item viacomputeFulfillmentStockWrite. - Idempotence :
remaining = qté − Σ items de fulfillment existants— un item déjà scellé (par le marchand ou une passe précédente) n'est jamais re-décrémenté. Bornes--daysBack 12et--maxItems 200, utilisateur système dédié.
Sync PrestaShop et webhooks
- Déclencheur : le sync produits (cron 3 h) et les webhooks temps réel
/api/webhooks/prestashop/products— les deux exécutent le mêmeimportPrestashopProduct(voir Sync PrestaShop). - Chemin :
packages/services-prestashop/src/prestashop/productSync.ts→buildPsStockLedgerEntry(packages/services-prestashop/src/lib/prestashop/stock-ledger-sync.ts), depuis #2749. - Helper pur : retourne
nullquandmanageStock=falseou quand le delta est nul (le casRESET_STOCK_ON_SYNC=offdevient un no-op naturel). Produit créé par le sync → SAOPENING_BALANCE(before 0) ; produit existant → SAPRESTASHOP_SYNCportant le delta. - L'émission via
StockLedgerServiceest best-effort : un échec ledger ne casse jamais le sync. - Rappel : le sync de commandes PrestaShop ne touche pas le stock du tout.
Suppression et réversal de mouvements
Supprimer un mouvement du journal (/manage/ecommerce/inventory/stock-actions) doit
défaire exactement ce qu'il a fait — sur les deux colonnes.
sequenceDiagram
autonumber
participant UI as Journal des mouvements
participant SAS as StockActionsService
participant LED as StockLedgerService
participant DB as PostgreSQL
UI->>SAS: deleteLedgerMovement(id)
SAS->>DB: BEGIN $transaction
SAS->>DB: SELECT StockAction (statut, quantityChange)
alt COMPLETED signée et produit suivi
SAS->>DB: UPDATE product SET stock = max(0, ledger - quantityChange)
SAS->>LED: reverseCompleted(id, tx)
LED->>DB: DELETE StockAction
LED->>DB: UPDATE product SET stockLedger -= quantityChange
else PENDING / CANCELLED / non signée
SAS->>DB: DELETE StockAction (aucun effet ledger)
end
SAS->>DB: COMMIT
- Chemin :
StockActionsService.deleteLedgerMovement(packages/services/src/stock/stock-actions.service.ts), une$transactionunique. - Sous Option B (backorder actif),
quantityBefore/Aftersont des valeurs ledger signées : réverser le « delta appliqué » serait faux pour un mouvement qui a traversé zéro. Exemple pinné : ledger 2 → manuel −5 → ledger −3, stock 0. Supprimer ce mouvement doit restaurer stock 2 (le ledger réversé), pasmax(0, 0−(−5)) = 5. D'où la séquence :reverseCompletedsoustraitquantityChangedu ledger, etProduct.stockest réécrit commemax(0, ledger réversé)— la même math (computeStockWrite) que les écritures. - Flag OFF (legacy) : le compteur physique est réversé du delta appliqué
(
quantityAfter − quantityBefore, clampé ≥ 0), le ledger viareverseCompleted. - Les SA
PENDING/CANCELLED/non signées sont simplement supprimées (aucun effet ledger). deleteWithOptionalStockEffect(id, affectStock):affectStock=truedélègue au chemin ci-dessus ;affectStock=falsesupprime la ligne sans toucher le compteur physique mais réverse quand même le cache ledger d'une SA signéeCOMPLETED— l'invariantstockLedger == Σtient dans les deux modes.- La suppression en masse (
bulkDeleteWithOptionalStockEffect) rejoue le chemin unitaire séquentiellement (chaque id dans sa propre transaction) : des suppressions affectant le même produit ne doivent pas se courser, et un batch partiel est acceptable + rapporté.
Chemins de réparation (heal)
Deux tâches CLI écrivent du stock en correction, jamais en flux normal :
# Audit des invariants (lecture seule) ; --apply répare, --strict jette
bun run cli --task stock-ledger-audit --tenantId <t> --siteId <s> --strict
# Backfill des pertes de clamp historiques (#2886) — dry-run par défaut
bun run cli --task reconcile-clamped-stock --tenantId <t> --siteId <s>
stock-ledger-audit --apply(packages/scripts/src/tasks/stock-ledger-audit.ts) répare les violations d'invariants : stocks négatifs remis à 0 (I1), stock non nul sur produit non suivi (I2), projectionsPENDING SUPPLIER_DELIVERYpérimées annulées (I3), SA orphelines (I4), désynchronisationstockvs ΣCOMPLETED(I5). Détail dans Filets de sécurité.reconcile-clamped-stockcorrige le phantom accumulé par les anciens décréments clampés :recordPhysicalMovementavec un delta négatif, sourceSYSTEM,referenceType=reconciliation, etallowBackorder: trueforcé (la correction elle-même ne doit jamais être clampée, quel que soit l'env). Dry-run par défaut,safetyCap+--forceobligatoire au-delà, buckets small/large.
Anti-patterns — ce que la règle 17 interdit
Ces patterns ont chacun causé un incident réel (voir l'historique dans Le ledger de stock). Ils sont interdits dans tout nouveau code :
- Écriture brute de
Product.stocksansStockActiondans le même mouvement — le ledger dérive silencieusement (cas Marie et Ludovic, −8470). quantityBefore=0en stub — toujours lire le vrai ledger d'abord (planManualStockEdit,recordPhysicalMovementle font pour vous).- Delta calculé contre le
stockaffiché au lieu du ledger signé — c'est le mécanisme du phantom-decrement (incident 2026-06-08) et du désync post-backorder. - Annuler ou supprimer une SA
COMPLETEDsans réversal du ledger (et du compteur si applicable) — passer parreverseCompleted/deleteLedgerMovement. - SA (même
PENDING) sur un produitmanageStock=false— pollue le ledger (cas Bio Rennes, 912 unités fantômes). - Avaler les erreurs Prisma par item dans un cron — une tâche utilisée comme gate doit
jeter (
bun run cli --tasksort en 0 sinon) et le wrapper shell doit propagerexit $EXIT_CODE.
Chaque chemin de cette page est épinglé par des tests unitaires
(packages/tests/tests/unit/ : stock-write-math, inventory-count-finalize,
bulk-delete, stock-ledger-trust-boundary — 77 tests sur la frontière de confiance).
Tout nouveau cas limite découvert doit y ajouter son test.
Voir aussi
- Le ledger de stock — le modèle Option B, colonnes et invariants
- Commandes fournisseurs — le réappro événementiel qui lit ces écritures
- Chronologie quotidienne — quand chaque cron écrit
- Sync PrestaShop — l'intégration ledger du sync (#2749)
- Filets de sécurité — audit, replay-diff, sondes
- Back-office — les pages
/managequi déclenchent ces chemins - Configuration — les flags (
STOCK_LEDGER_ALLOW_BACKORDER, …)