Garde-fous & runbook
Le moteur stock / réappro écrit des mouvements qui engagent de l'argent réel : un ledger qui dérive produit des bons fournisseurs sous-commandés, des ruptures ou des sur-commandes silencieuses. La plateforme superpose donc trois couches de défense (invariants audités, tests épinglés, replay-diff en production) plus une sonde de santé quotidienne. Cette page est la référence opérationnelle : ce que chaque couche garantit, les commandes exactes à lancer, et les arbres de décision pour les trois incidents les plus fréquents.
Les trois couches en un coup d'œil
| Couche | Outil | Quand l'utiliser |
|---|---|---|
| 1. Invariants sur les données | tâche CLI stock-ledger-audit (I1…I7) |
Sonde quotidienne, gate CI (--strict), réparation (--apply) |
| 2. Régression sur le code | tests unitaires épinglés (packages/tests) |
À chaque commit touchant le ledger ; chaque nouveau bug ajoute un cas |
| 3. Dérive de comportement | replay-supplier-orders.ts (diff read-only en prod) |
Avant de merger tout changement dans le blast radius réappro |
Les couches sont complémentaires : la couche 1 attrape les données cassées (quel que soit le code qui les a cassées), la couche 2 attrape le code qui régresse (avant qu'il ne touche les données), la couche 3 attrape les changements de comportement que ni un invariant ni un test unitaire ne formulent — « ce bon fournisseur aurait 12 lignes de plus avec ma branche ».
Couche 1 — Invariants : stock-ledger-audit
La tâche codifie les invariants que le ledger DOIT satisfaire. Elle est née de l'investigation « Bio Rennes » de mai 2026 (stock fantôme sur des produits non suivis, bons fournisseurs silencieusement sous-commandés, cron de réconciliation en échec muet pendant des semaines) et s'est enrichie à chaque incident depuis.
Les trois modes
# Rapport (lecture seule, exit 0 quoi qu'il arrive)
cd apps/app && bun run cli --task stock-ledger-audit \
--tenantId <tenantId> --siteId <siteId>
# Gate CI / monitoring — exit non-zéro à la moindre violation
cd apps/app && bun run cli --task stock-ledger-audit \
--tenantId <tenantId> --siteId <siteId> --strict
# Réparation — n'écrit QUE sur les lignes identifiées comme cassées
cd apps/app && bun run cli --task stock-ledger-audit \
--tenantId <tenantId> --siteId <siteId> --apply
Les invariants
| ID | Invariant | --apply répare ? |
|---|---|---|
| I1 | Aucun produit avec Product.stock < 0 (le miroir affiché est bridé à 0 — voir le ledger) |
oui |
| I2 | Aucun produit manageStock=false avec stock ≠ 0 (le cas Bio Rennes) |
oui |
| I3 | Aucune projection PENDING SUPPLIER_DELIVERY périmée — reliquats de commandes annulées, ou bons SENT/CONFIRMED sans réception depuis ≥ stalePendingDays (défaut 14 j) |
oui (annulées + envois en retard) |
| I4 | Aucune StockAction dont le referenceId pointe vers un bon fournisseur supprimé |
oui |
| I5 | Aucun produit suivi où Product.stock ≠ Σ StockActions COMPLETED — sous STOCK_LEDGER_ALLOW_BACKORDER, l'égalité devient stock == max(0, Σ) |
oui — en prod (backorder ON) : resync du seul miroir Product.stock, aucune SA (le cache stockLedger est le job d'I5b) ; en legacy (backorder OFF) : SA de réconciliation « Backfill » |
| I5b | Product.stockLedger (la colonne cache que trustedStock lit réellement) ≠ Σ COMPLETED — attrape la dérive cache-seule que I5 ne voit pas |
oui (recale le cache) |
| I6 | Aucun DECREMENT de fulfillment « signature clamp » (quantityChange=0 sur une livraison) — la consommation perdue qui fabriquait du stock fantôme avant #2886 |
non (le fix est le code, pas les données) |
| I7 (info-only) | ProductVariation.stock ≠ Σ des SA COMPLETED portées par la variation — dérive au niveau variation |
non — jamais (voir ci-dessous) |
Quatre subtilités qui évitent des fausses alertes :
- I3 ne touche jamais les brouillons. Les projections
PENDINGde bonsDRAFTsont la file de relecture du marchand (le cron met en scène des brouillons, le marchand relit avant envoi). Elles sont rapportées en info, jamais auto-annulées. - I5 sous backorder. Depuis le cutover
STOCK_LEDGER_ALLOW_BACKORDER(2026-07-09), un Σ négatif est légitime (précommande) ; I5 compare le miroir bridé, I5b épingle le cache brut. - I6 est gaté par le cutover. La dette historique (clamps antérieurs) est rapportée en
info-only ; seuls les clamps postérieurs à
STOCK_LEDGER_BACKORDER_CUTOVERfont échouer--strict— tout nouveau clamp est une régression du code, à traiter comme telle. - I7 est purement informatif — jamais gaté, jamais réparé. Le stock des variations est
hors du modèle ledger : la frontière de confiance et le calcul de réappro lisent le
produit uniquement, I5/I5b se restreignent délibérément à
productVariationId: null, et aucun cron n'écrit de ligne variation. I7 mesure donc simplement l'ampleur de la dérive qui se cache à ce niveau (nombre de variations concernées + |dérive| totale). Il ne fait jamais échouer--strictet--applyne le répare jamais : recaler un compteur de variation sur un Σ que rien ne maintient serait le mauvais correctif.
Limites de portée de l'audit — les angles morts, et ce qui les couvre désormais :
- Le stock des variations n'est plus un angle mort silencieux : I7 le rapporte depuis 2026-07 — mais en info-only (voir ci-dessus). L'audit gate reste au niveau produit.
- Les produits inactifs sont hors périmètre : I5/I5b filtrent
active: true. - Historiquement, la colonne cache
stockLedgern'était pas vérifiée du tout ; I5b comble ce trou depuis 2026-07 — mais uniquement pour les produits actifs et suivis.
Si un incident implique une variation ou un produit inactif, un audit « vert » ne prouve rien : vérifiez à la main via la timeline du produit.
Couche 2 — Tests de régression épinglés
Chaque bug de ledger découvert en production devient un cas de test épinglé : le test rejoue les données réelles de l'incident (anonymisées via un snapshot) et échoue si le code re-dérive un jour vers le comportement fautif. Les quatre piliers :
Fichier (packages/tests/tests/unit/) |
Ce qui est épinglé |
|---|---|
supplier-order/stock-ledger-trust-boundary.test.ts |
77 cas — la frontière de confiance trustedStock : stock fantôme Bio Rennes réel, négatifs #2725, kill-switch pessimiste #2728, plafond 8 semaines, table de bordure (stock, manageStock) → trustedStock |
stock/stock-write-math.test.ts |
La math pure de recordPhysicalMovement (packages/services/src/stock/rolling-stock-math.ts) : signe du ledger, miroir bridé max(0, ledger), backorder |
stock/inventory-count-finalize.test.ts |
La finalisation d'un comptage physique calcule ses deltas contre le ledger signé et saute manageStock=false |
stock/bulk-delete-stock-actions.test.ts |
La suppression en masse d'actions reverse le ledger et restaure stock = max(0, ledger reversé) sous backorder |
# Toute la suite stock (rapide, < 5 s)
cd packages/tests && npm test -- run tests/unit/stock/
# La frontière de confiance seule
cd packages/tests && npm test -- run tests/unit/supplier-order/stock-ledger-trust-boundary.test.ts
La règle : un bug = un cas épinglé. Quand vous corrigez un bug de ledger, capturez d'abord la tranche de données affectée (PII retirées au snapshot), committez la fixture à côté du test qui la consomme, puis écrivez le cas qui échoue sans le fix :
bun packages/scripts/src/dev/snapshot-tenant-stock.ts \
--tenant-id <tenantId> --site-id <siteId> [--vendor-id <uuid>] \
--days 30 --out packages/tests/fixtures/stock-ledger/<nom>.json
Couche 3 — Replay-diff en production (read-only)
replay-supplier-orders.ts exécute les chemins de production computeExpectedOrders() et
getCurrentOrders() avec le code de la branche courante contre la base live, puis imprime
la dérive par fournisseur : bons nouveaux / modifiés / orphelins, lignes ajoutées / changées /
retirées. Zéro écriture — utilisable contre la prod à tout moment.
bun packages/scripts/src/dev/replay-supplier-orders.ts \
--tenant-id <tenantId> --site-id <siteId> \
[--vendor-id <uuid>] \
[--from 2026-07-01] [--to 2026-07-31]
Lancez-le avant de merger tout changement dans le blast radius réappro :
packages/services-supplier-order/src/utils/auto-supplier-order-utils-new.tspackages/services-supplier-order/src/utils/supplier-order-utils-new.ts(la frontièretrustedStock)packages/services-supplier-order/src/utils/supplier-order-reconciliation.tspackages/services-supplier-order/src/utils/auto-supplier-orders-regeneration.tspackages/services/src/stock/stock-actions.service.ts- tout code qui touche
Product.stockou le cycle de vie d'uneStockAction
L'interprétation est simple : si votre branche ne change pas la logique métier, le diff doit
être identique à celui de master au même instant. Un delta inattendu (ex. « −33 lignes
sur ce fournisseur ») est exactement la classe de régression qu'aucun test unitaire ne
formulait — le reconcile de 07:15 l'aurait appliquée en silence le lendemain du merge.
La sonde quotidienne (06:30)
La tâche supplier-stock-health tourne chaque matin à 06:30 (drop-in
/etc/cron.d/supplier-stock-health, lecture seule) — juste avant le
reconcile de 07:15, pour photographier l'état avant la
passe corrective du jour. Son wrapper est le modèle du genre : il exécute le fichier de la
tâche directement et termine par exit $EXIT_CODE — l'échec remonte réellement à l'alerting.
Ce qu'elle évalue (verdict global ✅ SAIN / 🛑 ANOMALIE, persisté en KV pour la tour de
contrôle) :
| Signal | Attendu | Sens |
|---|---|---|
reconcile.gateAccuracyPct |
élevé, stable | Part des lignes fournisseur déjà conformes à l'état recalculé, net des orphelins à créneau passé. Une chute brutale (85 % → 6 % observé sur #2806) = un changement de logique vient de réécrire l'order-book |
| Réceptions fantômes | ≈ 0 (seuil maxPhantoms, défaut 50) |
Projections PENDING de brouillons à créneau passé jamais envoyés — le sweep doit les drainer |
File supplier-orders-recalc |
ok |
Santé de la chaîne événementielle (producteurs → queue bext → recalc sous write-lock) |
backdatedDraftsCreated7d |
0 depuis la garde | Brouillons créés sur créneau passé — doit rester nul sous SUPPLIER_NO_PAST_SLOTS |
autoValidateSealed24h |
tend vers 0 | Voir ci-dessous |
| Précision reconcile | ≥ minAccuracy (défaut 50 %) |
Exit non-zéro en dessous — la sonde sert aussi de gate pré-merge |
cd apps/app && bun run cli --task supplier-stock-health \
--tenantId <tenantId> --siteId <siteId> --json
autoValidateSealed24h — le compteur d'oublis
Depuis #2886, le geste primaire de décrément est le scellé du marchand au moment physique
(clic de livraison dans l'UI, ou « Valider la tournée du jour ») ; le cron auto-validate
(12:01 + balayage toutes les 2 h) n'est plus qu'un filet de sécurité qui scelle ce qui a
été oublié — voir les chemins d'écriture.
autoValidateSealed24h compte les livraisons que le filet a dû sceller dans les dernières
24 h. Un non-zéro ponctuel est normal ; un non-zéro persistant signifie que les opérateurs
ne scellent pas dans l'UI — le ledger reste juste, mais avec de la latence : une fois la grâce
AUTO_VALIDATE_GRACE_HOURS=12 franchie, le balayage 2-horaire scelle l'oubli dans les ~2 h,
et comme la passe de ~06:01 tourne avant le reconcile de 07:15, le réappro du matin voit
normalement bien les livraisons de la veille.
Runbook
Trois arbres de décision pour les incidents récurrents. Dans chaque cas : diagnostiquer en lecture seule d'abord, réparer ensuite, épingler un test à la fin si un chemin de code est fautif.
« Le stock d'un produit semble faux »
flowchart TD
A["Stock affiché suspect"] --> B{"manageStock ?"}
B -->|non| C["Convention : produit non suivi ⇒ 0<br/>trustedStock renvoie 0, rien à réparer"]
B -->|oui| D["Ouvrir la timeline produit<br/>/manage/ecommerce/inventory/stock-ledger"]
D --> E{"Ledger négatif ?"}
E -->|oui| F["Backorder réel — la prochaine<br/>réception le nette : normal"]
E -->|non| G["stock-ledger-audit en mode rapport"]
G --> H{"I5 / I5b en dérive ?"}
H -->|oui| I["--apply — prod (backorder ON) : resync du miroir stock, aucune SA<br/>legacy (backorder OFF) : SA Backfill + recale stockLedger"]
H -->|non| J{"Mouvement fautif visible<br/>dans la timeline ?"}
J -->|oui| K["Chemin d'écriture hors ledger :<br/>violation règle 17 → fix code + test épinglé"]
J -->|non| L["Comptage physique<br/>/manage/ecommerce/inventory/counts"]
Pas à pas :
- Timeline d'abord.
/manage/ecommerce/inventory/stock-ledger→ produit → timeline. Le mouvement divergent se voit presque toujours à l'œil (un delta sans réception en face, un ADJUSTMENT inattendu, une réception en double). - Audit en rapport (lecture seule) pour qualifier : dérive I5 (compteur ≠ Σ) ou I5b (cache seul) ?
--applyne réécrit que les lignes cassées. En prod (backorder ON), I5 resynchronise le seul miroirProduct.stock— aucune SA (le recalage du cachestockLedgerrevient à I5b) ; en legacy (backorder OFF), il émet une SA de réconciliation « Backfill ». Le stock redevient cohérent avec l'historique — il ne devient pas physiquement juste pour autant : si le doute physique persiste, faites un comptage.- Si un chemin de code a contourné le ledger (écriture de
Product.stocksansStockActiondans la même transaction), c'est une violation de la règle d'or : corrigez le code et ajoutez un cas àstock-write-math.test.tsou à la frontière de confiance.
« Une commande fournisseur manque des lignes »
Rappel du modèle : une ligne « manquante » signifie que le moteur croit la demande déjà couverte — soit par du stock (trop) crédité, soit parce que le produit est hors du périmètre du cron qui a généré le bon. Le kill-switch pessimiste produit l'effet inverse (sur-commande) : ce n'est jamais lui la cause de lignes manquantes.
flowchart TD
A["Lignes manquantes dans un bon fournisseur"] --> B{"Produit manageStock=false ?"}
B -->|oui| C["Seul le reconcile de 07:15 remplit<br/>ces produits — vérifier son dernier run"]
B -->|non| D{"Le stock crédité couvre la demande ?"}
D -->|stock élevé| E{"Stock fantôme ?"}
E -->|oui| F["Plafond STOCK_TRUST_MAX_COVER_WEEKS = 8<br/>+ page /manage/ecommerce/vendors/orders/phantom-stock"]
E -->|non| G["Audit I5 / I5b : ledger gonflé ?<br/>→ --apply puis recalc"]
D -->|stock plausible| H["replay-supplier-orders<br/>diff read-only sur le fournisseur"]
H --> I{"Le diff montre des items added ?"}
I -->|oui| J["Le moteur les voit : le reconcile 07:15<br/>les ajoutera — ou déclencher un recalc"]
I -->|non| K["Demande réellement couverte —<br/>un lot 100 % quantité 0 est bénin"]
Pas à pas :
manageStock=false? Le cron auto-supplier (toutes les 30 min) exclut ces produits ; seul le reconcile quotidien de 07:15 (--apply --removeOrphans) les remplit. Si le bon a été regardé avant 07:15, ou si le reconcile a échoué, les lignes n'existent pas encore.- Stock fantôme ? La frontière de confiance plafonne
le stock cru à 8 semaines de consommation récente (
STOCK_TRUST_MAX_COVER_WEEKS=8) — mais sous ce plafond, un ledger gonflé zère quand même des lignes. La page/manage/ecommerce/vendors/orders/phantom-stockliste les suspects (couverture en semaines par produit). - Replay-diff ciblé (
--vendor-id) : si les lignes apparaissent enaddeddans l'attendu, le moteur est d'accord avec vous — c'est un problème de timing (le prochain reconcile/recalc les posera). Si le diff est vide, la demande est réellement couverte. - Lot entièrement à quantité 0 : ce n'est pas une erreur — c'est la demande couverte, matérialisée à titre informatif.
« Un cron échoue silencieusement »
Le piège structurel — désormais fermé, mais à connaître : bun run cli --task X sort en
exit 0 sauf si la tâche throw, donc une tâche qui retourne {success: false} est
avalée ; et un wrapper shell sans set -e ni exit $EXIT_CODE final transforme même un vrai
exit non-zéro en 0. Résultat historique : l'ordonnanceur n'alertait jamais, et les crons
pouvaient « tourner » (ligne de log présente) tout en échouant depuis des semaines.
✅ Les deux moitiés sont corrigées : les trois tâches-gates (auto-validate,
auto-supplier-orders-service, supplier-order-email) throw maintenant — sur échec
global et dès qu'il reste des échecs par item (après le travail sûr) — et les 24 wrappers
propagent leur code de sortie. Le contrat est épinglé par cron-wrappers.shape.test.ts, qui
fait échouer tout wrapper avalant son exit code. Le diagnostic ci-dessous reste valable pour
les pannes qui ne passent pas par un code de sortie (cron désinstallé, verrou bloqué, box
éteinte).
flowchart TD
A["Soupçon : un cron ne produit plus rien"] --> B["Chip cron-5 sur la page stock-ledger<br/>(heartbeat) + sonde 06:30 en KV"]
B --> C{"État du chip ?"}
C -->|vert ≤ 5h| D["Le cron tourne — chercher ailleurs<br/>(regarder « dernière activité »)"]
C -->|ambre ≤ 12h| E["Plusieurs passes 2-horaires manquées"]
C -->|rouge > 12h| F["Le cron n'a pas tourné :<br/>panne probable"]
E --> G["tail des logs ~/.ploi/scheduled-*.log"]
F --> G
G --> H{"Erreur dans le log<br/>mais exit code 0 ?"}
H -->|oui| I["Masquage d'exit-code (régression) :<br/>wrapper sans exit $EXIT_CODE,<br/>ou tâche qui return au lieu de throw"]
H -->|non| J["Verrou bloqué ? fuser -v sur le lockfile<br/>+ rejouer en dry-run<br/>+ vérifier l'entrée /etc/crontab"]
I --> K["Pattern correct : wrapper exit $EXIT_CODE<br/>+ tâche-gate qui THROW"]
Pas à pas :
- Les chips d'abord. La page
/manage/ecommerce/inventory/stock-ledgerporte un chip de santé du cron auto-validate. Son titre est la liveness (« le cron a-t-il tourné ? »), lue sur le heartbeat écrit à chaque run : ≤ 5 h vert, ≤ 12 h ambre, > 12 h rouge. L'âge du dernier scellé (réception / livraison) est rétrogradé en info secondaire (« dernière activité ») : un jour calme ne produit aucune ligne, ce n'est pas une panne. Si le chip est rouge alors que la box tourne, penser au verrou orphelin (unbunbloqué qui garde leflockfait sauter toutes les passes suivantes) :fuser -v /tmp/auto-validate-stock-actions.<TENANT_ID>.lock. - Les logs. Chaque wrapper redirige vers
~/.ploi/scheduled-*.logsur la box tenant :
ls -lt ~/.ploi/scheduled-*.log | head
tail -100 ~/.ploi/scheduled-<id>.log
- Erreur visible + exit 0 = masquage. Vérifiez le wrapper (
scripts/cron/*.sh) : il doit se terminer parexit $EXIT_CODE(modèle :supplier-stock-health.sh), et la tâche utilisée comme gate doit throw en cas d'échec (modèle :reconcile-supplier-orders.ts, qui throw sur toute erreur Prisma par ligne au lieu de l'avaler). - Rejouer à la main en dry-run pour reproduire — chaque cron a une invocation de smoke test documentée dans la timeline quotidienne.
Ne « réparez » jamais un cron muet en relançant sa version --apply à l'aveugle. D'abord le
dry-run, puis l'audit (stock-ledger-audit), puis seulement l'apply — dans cet ordre. Un cron
qui a raté trois jours peut avoir accumulé un backlog que le --apply traiterait d'un bloc.
Les commandements (règle 17, condensée)
La liste NEVER/ALWAYS issue des incidents réels — chaque ligne a coûté au moins un incident de production avant d'être écrite.
Ne jamais :
- Écrire
Product.stocksans (a) vérifiermanageStocket (b) émettre laStockActioncorrespondante dans la même transaction — sinonstockLedgerdécroche (I5/I5b). - Réintroduire un décrément de stock à la synchro des commandes. Le chemin a été supprimé le 2026-06-14 : le stock décrémente à la livraison uniquement (scellé UI, filet auto-validate). Un décrément à la synchro double-compterait sans signal d'idempotence partagé.
- Créer une
StockActionPENDINGpour un produitmanageStock=false— ça pollue le ledger (l'origine du cas Bio Rennes). - Passer une SA de
PENDING/COMPLETEDàCANCELLEDsans reverser le mouvement deProduct.stockcorrespondant pour les produits suivis. - Stubber
quantityBefore=0à la création d'une SA — lire le vraiProduct.stockd'abord. - Avaler les erreurs Prisma par item dans une tâche cron — throw, et laisser le monitoring attraper (c'est tout le sujet de l'arbre 3 ci-dessus).
Toujours :
- Appliquer la frontière de confiance
manageStock ? max(0, stock) : 0(plus plafond consommation) en lisant le stock pour la math de réappro. - Ajouter un cas épinglé à
stock-ledger-trust-boundary.test.ts(ou au test du chemin d'écriture concerné) pour chaque nouveau cas de bord découvert. - Lancer le replay-diff avant de merger un changement du blast radius réappro.
- Enregistrer tout nouveau point d'entrée de tâche ledger dans le smoke test CLI
(
packages/tests/tests/unit/cli/cli-smoke.test.ts).
Voir aussi
- Vue d'ensemble — le modèle complet stock → réappro → email
- Le ledger de stock — autorité signée, miroir bridé, backorder
- Chemins d'écriture —
recordPhysicalMovementet le scellé à l'événement physique - Commandes fournisseurs — recalc événementiel, frontière de confiance, gates
- Timeline quotidienne — les crons, leurs heures, leurs smoke tests
- Synchronisation PrestaShop — écritures de sync intégrées au ledger
- Back-office — timeline, comptages, journal des mouvements, page phantom-stock
- Configuration — tous les flags (
STOCK_LEDGER_*,SUPPLIER_*,STOCK_TRUST_*)