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.stockLedger est 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.stock est le miroir d'affichage bridé : invariant stock == max(0, stockLedger).
  • Une StockAction a un statut PENDING (projection, sans effet ledger), COMPLETED (appliquée) ou CANCELLED. Seules les SA COMPLETED de type INCREMENT/DECREMENT bougent le ledger ; les ADJUSTMENT et les PENDING n'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)
Note

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.updateProductStockplanManualStockEdit + 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.confirmDeliveryrecordPhysicalMovement 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.undoSupplierOrderDeliveryreverseCompleted 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.createFulfillmentrecordPhysicalMovement 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 importPrestashopProductbuildPsStockLedgerEntry 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/stocks ou 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) :
    • planManualStockEdit refuse 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 SIMPLE non suivi auto-active manageStock (opt-in implicite).
    • Le delta ledger est calculé contre Product.stockLedger, jamais contre le Product.stock affiché (qui peut porter des valeurs PrestaShop fantômes — incident 2026-06-08).
    • recordManualStockLedgerAdjustment émet la SA MANUAL COMPLETED (raison canonique « Ajustement de stock », referenceType=manual_adjustment), pose data.stockTrusted (une saisie marchande est une validation d'on-hand : exemption du plafond anti-stock-fantôme STOCK_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 miroir ProductStockAvailable est 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/stockQuantity que si le marchand a réellement modifié la valeur. Sans valeur soumise, ni Product.stock ni 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 manageStock explicite : « activer le suivi + saisir un stock » dans la même sauvegarde émet bien la SA (le gate teste le manageStock effectif, pas l'ancien).
  • Tout se passe dans une seule $transaction : update produit, SA MANUAL (via le helper canonique, tx transmise), miroir ProductStockAvailable (#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.stockTrusted et réaligne le miroir PS. SA INVENTORY_ADJUSTMENT COMPLETED, le tout dans une $transaction unique.

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 countedQty non nul,
    • les produits manageStock=false sont ignorés entièrement (compteur skippedUntracked — ni écriture, ni SA) ;
    • le delta du mouvement = counted − stockLedger (signé), et les deux colonnes atterrissent sur counted (backorder ledger −5 / stock 0, compté 3 → SA +8, ledger 3, stock 3 — l'invariant stock == max(0, stockLedger) est garanti car counted ≥ 0) ;
    • si le ledger vaut déjà counted mais que le miroir est désaligné, le miroir est réparé sans SA à delta nul ;
    • la SA INVENTORY_ADJUSTMENT COMPLETED (referenceType=inventory_count) est reliée à l'item via inventoryCountItem.stockActionId, et data.stockTrusted est posé (#2802).
  • 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 FINALIZED ou CANCELLED ne 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, source SUPPLIER_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 PENDING du 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 INCREMENT estampillées referenceData.deliveryId par le confirm — c'est le signal d'idempotence — puis, dans une $transaction unique :
    1. reverseCompleted(id) par SA (suppression + stockLedger -= quantityChange) ;
    2. 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) ;
    3. restauration des SA PENDING annulées par ce confirm (uniquement si c'était la dernière livraison du bon) ;
    4. 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), puis recordPhysicalMovement (delta = −qté, source ORDER_UPDATE, referenceType=fulfillment, referenceId = l'id du fulfillment créé). Les réservations de stock sont consommées dans la même passe.
  • Les SA PENDING de la commande sont annulées — les deux casings order/ORDER sont couverts (héritage du sync legacy).
Attention

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

  • cancelFulfillment relit la SA DECREMENT d'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), via recordPhysicalMovement (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 : sans userId la 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/CONFIRMED sans livraison dont la date prévue est passée d'au moins AUTO_VALIDATE_GRACE_HOURS (12 h — une date prévue n'est pas une preuve de livraison) → crée la SupplierOrderDelivery + les SA SUPPLIER_DELIVERY INCREMENT via computeReplenishStockWrite + createManyStockActions (referenceData.autoValidated=true). Les items flux tendu (made-to-order) sont sautés sauf si le produit est stockTrusted (#2932). Gaté par AUTO_VALIDATE_WRITES_STOCK.
  • PART B — livraisons client oubliées : commandes avec items non scellés dont deliveryDate est passée du même grace → crée l'OrderFulfillment + une SA ORDER_UPDATE DECREMENT par item via computeFulfillmentStockWrite.
  • 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 12 et --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ême importPrestashopProduct (voir Sync PrestaShop).
  • Chemin : packages/services-prestashop/src/prestashop/productSync.tsbuildPsStockLedgerEntry (packages/services-prestashop/src/lib/prestashop/stock-ledger-sync.ts), depuis #2749.
  • Helper pur : retourne null quand manageStock=false ou quand le delta est nul (le cas RESET_STOCK_ON_SYNC=off devient un no-op naturel). Produit créé par le sync → SA OPENING_BALANCE (before 0) ; produit existant → SA PRESTASHOP_SYNC portant le delta.
  • L'émission via StockLedgerService est 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 $transaction unique.
  • Sous Option B (backorder actif), quantityBefore/After sont 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é), pas max(0, 0−(−5)) = 5. D'où la séquence : reverseCompleted soustrait quantityChange du ledger, et Product.stock est réécrit comme max(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 via reverseCompleted.
  • Les SA PENDING/CANCELLED/non signées sont simplement supprimées (aucun effet ledger).
  • deleteWithOptionalStockEffect(id, affectStock) : affectStock=true délègue au chemin ci-dessus ; affectStock=false supprime la ligne sans toucher le compteur physique mais réverse quand même le cache ledger d'une SA signée COMPLETED — l'invariant stockLedger == Σ 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), projections PENDING SUPPLIER_DELIVERY périmées annulées (I3), SA orphelines (I4), désynchronisation stock vs Σ COMPLETED (I5). Détail dans Filets de sécurité.
  • reconcile-clamped-stock corrige le phantom accumulé par les anciens décréments clampés : recordPhysicalMovement avec un delta négatif, source SYSTEM, referenceType=reconciliation, et allowBackorder: true forcé (la correction elle-même ne doit jamais être clampée, quel que soit l'env). Dry-run par défaut, safetyCap + --force obligatoire au-delà, buckets small/large.

Anti-patterns — ce que la règle 17 interdit

Attention

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.stock sans StockAction dans le même mouvement — le ledger dérive silencieusement (cas Marie et Ludovic, −8470).
  • quantityBefore=0 en stub — toujours lire le vrai ledger d'abord (planManualStockEdit, recordPhysicalMovement le font pour vous).
  • Delta calculé contre le stock affiché 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 COMPLETED sans réversal du ledger (et du compteur si applicable) — passer par reverseCompleted / deleteLedgerMovement.
  • SA (même PENDING) sur un produit manageStock=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 --task sort en 0 sinon) et le wrapper shell doit propager exit $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