Pipeline commandes fournisseurs

La plateforme génère les commandes fournisseurs (SupplierOrder) à partir de la demande client réelle, en flux tendu : chaque envoi fournisseur couvre une fenêtre de dates de livraison client, et le stock physique peut légitimement rester à zéro entre deux réceptions. Depuis la campagne #2886, le recalcul est événementiel — chaque mutation de commande client déclenche un recalcul ciblé via une file bext — et l'envoi des emails fournisseurs passe par une file de review opérateur (« review == envoyé »).

Vue d'ensemble

flowchart LR
  subgraph PROD["Producteurs d'événements"]
    CO["Commande client<br/>créer / modifier / annuler /<br/>statut / supprimer"]
    PS["Webhooks PrestaShop"]
    RC["Réception ou annulation<br/>d'un bon fournisseur"]
  end
  CO --> Q[["File bext<br/>supplier-orders-recalc"]]
  PS --> Q
  RC --> Q
  Q -->|"verrou d'écriture<br/>par site"| W["recalculateVendorOrdersUnified"]
  C30["Cron auto-supplier<br/>toutes les 30 min"] --> W
  C715["Reconcile quotidien<br/>07:15"] --> W
  W --> SO["SupplierOrder DRAFT<br/>+ projections PENDING"]
  SO -->|"stage 06:00<br/>(render-only)"| REV["Review opérateur<br/>emailPreview"]
  REV -->|"envoi"| SENT["SENT → CONFIRMED"]
  SENT --> LIV["Réception →<br/>ledger de stock"]

Trois moteurs alimentent le même recalcul, sérialisés par le verrou d'écriture fournisseur par site (aucun ne peut écrire en même temps qu'un autre) :

Moteur Cadence Rôle
File supplier-orders-recalc temps réel (événementiel) porte la charge nominale depuis #2886
Cron auto-supplier-orders-service toutes les 30 min filet de sécurité de génération
Cron reconcile-supplier-orders quotidien 07:15 réconciliation complète (ajoute, corrige et retire)

Déclencheurs événementiels

La file bext supplier-orders-recalc (gatée par BEXT_SUPPLIER_RECALC=true) reçoit un message à chaque événement qui change la demande ou l'offre :

  • Commande client : création, modification, annulation, changement de statut, suppression (packages/services-order/src/order/lifecycle.ts + service.ts) ;
  • Bon fournisseur : annulation, réception (packages/services-supplier-order/src/supplier-order/service.ts) ;
  • Webhooks PrestaShop : apps/app/src/app/api/webhooks/prestashop/[entity]/route.ts (y compris les updates — voir Synchronisation PrestaShop) ;
  • Ajustement manuel de stock (packages/services/src/stock/manual-stock-adjustment.ts).

Tous passent par enqueueSupplierOrderRecalc (packages/common/src/bext-sdk.ts), avec un payload { tenantId, siteId, vendorIds?, productIds?, orderIds? }.

Le worker (apps/app/src/app/api/queue/supplier-orders-recalc/route.ts) :

  1. authentifie le POST bext via ?token=<BEXT_QUEUE_TOKEN> ;
  2. résout les productIds vers leurs fournisseurs par défaut, unionne avec les vendorIds explicites ;
  3. prend le verrou d'écriture fournisseur du site (le même que celui des crons — sérialisation totale) ;
  4. lance une régénération ciblée sur ces fournisseurs, qui réconcilie les items des brouillons et resynchronise leurs projections « Entrée virtuelle » PENDING.

Le contrat de réponse suit la sémantique de la file bext : 2xx = ack, 5xx/408/429 = retry, autre 4xx = dead-letter.

Note

Un appel manuel depuis le back-office (recalculateVendorOrdersUnified, bouton de recalcul) prend le même verrou. Si le verrou est tenu, l'UI remonte un CONFLICT neutre (« réessayez ») plutôt que d'écrire en concurrence.

Le modèle de créneaux (slots)

Chaque fournisseur porte des sendSchedules — des créneaux d'envoi récurrents (par exemple lundi 12:15 + mercredi 22:15) — et un deliveryDays (délai de livraison, défaut 1 jour). Une commande client est affectée au créneau d'envoi qui permet une livraison à temps :

minSendDate = deliveryDate − (deliveryDays + 1 jour de marge)   // début de journée
maxSendDate = deliveryDate − deliveryDays                        // fin de journée

L'affectation (assignClientOrderToEmailScheduleStrict dans packages/services-supplier-order/src/utils/supplier-order-utils-new.ts) cherche un créneau dans cette fenêtre, puis applique deux replis :

  1. Repli à temps : aucun créneau dans la fenêtre idéale → prendre un créneau plus tôt qui permet quand même la livraison à la date requise (livraison en avance mais à l'heure).
  2. Demande trop tardive : plus aucun créneau ne peut livrer à temps. Deux politiques, selon le flag SUPPLIER_SKIP_LATE_DEMAND :
    • Amis de la Ferme (production, =true) : la demande est exclue des prochains emails fournisseurs (continue, log ⚠️ [SKIP-LATE-DEMAND]). On ne réapprovisionne jamais une commande qu'on ne pourra plus livrer à temps — une marchandise qui arrive après le besoin du client ne sert à rien. Elle est laissée au marchand, à traiter directement. La cadence (deux jours de commande/semaine, livraison le lendemain) laisse assez de marge pour que ce cas reste rare.
    • Autres tenants (=false) — roll-forward, #2832 : la demande roule vers le prochain créneau futur. La livraison sera tardive mais le brouillon reste envoyable — l'alternative (le poser sur un créneau passé que l'expéditeur planifié ne traitera jamais) revenait à perdre la demande.

Lire un ⚠️ [SKIP-LATE-DEMAND] : ce n'est pas une demande perdue

Attention

Le warning est ré-émis rétroactivement, à chaque passe, pour des commandes déjà servies. C'est le piège de diagnostic n°1 de ce moteur : le log donne l'impression d'une demande jamais approvisionnée, alors qu'il décrit dans l'immense majorité des cas le comportement voulu.

L'affectation de créneau est recalculée à chaque passe (file événementielle, cron 30 min, reconcile de 07:15), pour chaque couple (produit, commande client) encore dans la fenêtre de recalcul, soit plusieurs jours après la livraison. Le déroulé typique :

  1. La commande client entre avant son créneau couvrant, elle y est affectée, elle part dans l'email fournisseur. Rien d'anormal.
  2. Le créneau passe. La commande fournisseur est SENT, verrouillée.
  3. Chaque passe suivante ré-évalue la même commande client. Le plancher est désormais now, donc son créneau est dans le passé et plus aucun créneau futur ne livre à temps : SKIP-LATE-DEMAND, continue.
  4. Cette exclusion est correcte : la demande est déjà couverte par l'envoi parti. La remettre dans l'email suivant reviendrait à commander deux fois.

Le message (« excluded from future supplier emails ») décrit donc l'issue souhaitée, pas une perte. Le code ne conditionne pas le console.warn à l'absence de commande fournisseur SENT rattachée, il ne sait donc pas distinguer les deux cas.

Le seul cas réellement problématique est une commande client créée après son dernier créneau utile. Pour trancher, comparez order."createdAt" à l'heure du créneau couvrant (pour amisferme : lundi 12:15 pour les livraisons mercredi/jeudi, mercredi 22:15 pour vendredi/samedi). Procédure complète dans le runbook : « Le log déborde de SKIP-LATE-DEMAND ».

Note

Audit de production du 2026-07-21 (6 jours après l'activation du flag chez amisferme) : 7 423 lignes de log pour 67 commandes distinctes, soit ~110 ré-émissions par commande. Les 67 avaient toutes été créées avant leur créneau couvrant (cohorte du 22/07 : dernière création le 20/07 à 11:39, créneau du 20/07 à 12:15). Les 35 commandes dont la livraison était passée affichaient 754 unités commandées, 754 unités livrées, zéro reliquat. Aucune demande réellement tardive en 6 jours : la mention « ce cas reste rare » ci-dessus est vérifiée.

Exemple : une livraison du mercredi

Prenons un fournisseur avec deliveryDays = 2 (la valeur de production amisferme) et deux créneaux d'envoi (lundi 12:15, mercredi 22:15). Un client commande pour une livraison le mercredi :

deliveryDate = mercredi
minSendDate  = mercredi − (2 + 1) = dimanche   // début de journée
maxSendDate  = mercredi − 2       = lundi      // fin de journée

Le seul créneau d'envoi dans la fenêtre [dimanche, lundi] est lundi 12:15 — la demande y atterrit. (Une livraison du jeudi tomberait aussi sur lundi ; vendredi/samedi basculeraient sur le créneau du mercredi 22:15.) Avec deux créneaux hebdomadaires, 2 est précisément la valeur qui met les livraisons de mercredi/jeudi sur l'email du lundi et celles de vendredi/samedi sur celui du mercredi.

Attention

deliveryDays est à double usage. Il pilote la fenêtre d'affectation ci-dessus et servait aussi d'estimation de réception (expectedDeliveryDate). Depuis le 2026-07-16, la date de réception attendue vit dans un champ séparéreceptionLeadDays (computeExpectedDeliveryDate, avec repli sur deliveryDays s'il est vide) — pour qu'on puisse rapprocher les réceptions supposées de l'arrivée réelle sans dérégler l'affectation des créneaux. Ne jamais réduire deliveryDays de 2 à 1 pour corriger un décalage de réception : cela enverrait les commandes du jeudi sur le mauvais email et laisserait celles du samedi sans créneau. Réglez receptionLeadDays à la place — voir La semaine type.

La garde SUPPLIER_NO_PAST_SLOTS

En production (SUPPLIER_NO_PAST_SLOTS=true), le chemin automatique appelle calculateOrderQuantities({ allowPastSlots: false }) avec trois contraintes :

  • Plancher temporel : aucun créneau avant now (+ grâce éventuelle) — jamais de brouillon antidaté ;
  • Causalité : la date d'envoi fournisseur ne peut jamais précéder le createdAt de la commande client qui a créé la demande ;
  • Demande trop tardive : selon SUPPLIER_SKIP_LATE_DEMAND, soit exclue des prochains emails (Amis de la Ferme, production — on ne commande jamais l'inlivrable à temps), soit roulée vers le prochain créneau futur (roll-forward #2832, autres tenants). Voir repli 2 ci-dessus.

Le validateur de créneau (time-slot/service.ts) accepte le placement tardif quand l'envoi optimal est déjà passé — sinon la création échouait et la demande était silencieusement abandonnée.

Astuce

Le replay historique (outillage d'audit) épingle allowPastSlots: true pour conserver la sémantique rétrospective : on peut rejouer ce que le moteur aurait décidé à une date passée sans que la garde ne réécrive l'histoire.

Calcul des quantités

Pour chaque produit et chaque créneau, le moteur (packages/services-supplier-order/src/utils/auto-supplier-order-utils-new.ts) calcule :

besoinBrut   = demande client du créneau (+ majoration % éventuelle)
suggéré      = max(0, besoinBrut − stockApplied)
colis        = ceil(suggéré / packageSize)        // conditionnement
displayUnits = colis × packageSize                 // unités réellement commandées

Trois points importants :

  • stockApplied vient de trustedStock, la frontière de confiance du ledger de stock : stock non géré → 0, kill-switch pessimiste → 0, fournisseur made-to-order → 0, négatif bridé à 0 (sauf gate STOCK_TRUST_ALLOW_NEGATIVE), et plafonné à STOCK_TRUST_MAX_COVER_WEEKS (8 en prod) semaines de consommation récente (anti stock fantôme).
  • Consommation chronologique (#2866) : quand la demande d'un produit s'étale sur plusieurs créneaux, le stock disponible est consommé créneau par créneau dans l'ordre — le premier créneau absorbe le stock, les suivants commandent le reste. L'égalité demande − stock = suggéré reste exacte par créneau.
  • Arrondi au colis supérieur : on ne perd jamais de quantité par arrondi. Un item est créé même à quantité 0 — il documente les commandes client couvertes (sourceOrderIds). Un lot 100 % quantité-0 signifie « demande déjà couverte par le stock », c'est un résultat sain, pas une erreur de cron.

Candidats : cron 30 min vs reconcile 07:15

Cron auto-supplier (*/30) Reconcile (07:15)
Mode force=false force=true
Produits candidats manageStock=true OU approvisionnement>0 OU générique tous les produits du fournisseur
manageStock=false exclu inclus — c'est le seul moteur qui remplit ces commandes
Opérations ajoute / met à jour ajoute / met à jour / retire
Orphelins --removeOrphans : annule + tague (cancellationReason="reconcile-orphan")
Produits inactifs inclus s'ils ont de la demande en attente dans la fenêtre (#2933) idem

Le reconcile (packages/scripts/src/tasks/reconcile-supplier-orders.ts + utils/supplier-order-reconciliation.ts) calcule l'état attendu des commandes fournisseurs depuis les commandes client courantes, diffe contre la base, et applique des corrections minimales. Le sweep d'orphelins annule et tague au lieu de supprimer — la provenance (cancellationReason, cancelledAt, cancelledBy) reste auditable.

Attention

Le reconcile tourne --apply --removeOrphans tous les matins en production. Toute modification de applyDiff ou du calcul de quantités doit être validée avec le replay-diff en lecture seule avant merge — voir Filets de sécurité.

Regroupement : un produit → un fournisseur → un créneau

Chaque produit route vers un seul fournisseur d'achat — son defaultVendor. Un produit appartient à un unique fournisseur par défaut pour la commande ; les fournisseurs sont des espaces de noms distincts (un même produit ne se commande pas chez deux fournisseurs à la fois).

Le moteur crée ou met à jour exactement une SupplierOrder par couple (fournisseur, créneau d'envoi) — jamais deux brouillons pour le même fournisseur au même créneau. La réconciliation clé les commandes sur vendorId_date_heure (orderKey dans packages/services-supplier-order/src/utils/supplier-order-reconciliation.ts), si bien qu'un recalcul retombe toujours sur la commande existante au lieu d'en fragmenter une nouvelle (l'audit live mesure 0 fragmentation).

Quand la demande d'un produit s'étale sur plusieurs créneaux, elle produit une ligne par créneau sur les commandes respectives du fournisseur — chaque SupplierOrderItem porte les sourceOrderIds des commandes client qu'elle couvre.

Produits génériques

Un produit générique (isGeneric=true, #2836) est un produit d'achat virtuel qui agrège plusieurs références boutique (ses « enfants ») sous une seule ligne fournisseur. Cas typique : plusieurs formats vendus au client mais commandés au fournisseur dans une unité commune (le kilo, par exemple).

Le mapping enfant → générique

Les enfants sont reliés au parent par des lignes ProductGenericLink (packages/db/prisma/schema/product.prisma) :

Champ Rôle
genericProductId le produit générique parent (côté achat)
externalProductId la clé de l'enfant — l'externalId PrestaShop ou, à défaut, l'UUID interne product.id (#2823)
conversionFactor (Float, défaut 1) quantité_générique = quantité_enfant × conversionFactor
conversionUnit (défaut "kg") l'unité d'achat du générique

⚠️ Subtilité #2823 : externalProductId stocke l'externalId PrestaShop, ou l'UUID product.id quand l'enfant n'a pas d'externalId (l'UI écrit product.externalId || product.id — cas des composants « Panier - … »). Le guard doit matcher les deux clés, sinon un enfant mappé par UUID s'échappe et réapparaît en double dans le mail fournisseur (une ligne enfant distincte en plus du générique parent).

Les enfants ne portent jamais de ligne fournisseur

Seul le parent porte la chaîne d'achat. À chaque point de création d'un SupplierOrderItem (extendExistingOrder, createSupplierOrder, applyDiff du reconcile), getMappedShopChildProductIds (packages/services-supplier-order/src/utils/supplier-order-utils-new.ts) exclut les enfants mappés — c'est le guard défensif qui bloque la violation même quand un filtre amont a échoué.

Agrégation de la demande

resolveOrderItemsViaGenericMapping (packages/services-supplier-order/src/utils/generic-mapping-utils.ts) résout la demande client de chaque enfant, la multiplie par son conversionFactor, et la somme sur la demande du parent. La demande convertie est arrondie à l'entier supérieur puis, comme tout produit, au colis — une fraction d'unité de demande réelle commande donc un colis entier :

Bananes : 2 commandes enfant × 1 × 0,05 = 0,10  →  ⌈0,10⌉ = 1  →  1 colis

Ce n'est pas un bug : c'est l'arrondi normal au conditionnement (⌈⌈x⌉/n⌉ = ⌈x/n⌉, l'arrondi intermédiaire est neutre). Le chemin manuel / bouton « Recalculer » utilise l'équivalent computeGenericConvertedDemandFromSourceOrders (supplier-order-utils-new.ts) pour ne pas écraser la quantité convertie posée par la génération auto (#2836).

Nettage du stock

Un générique est virtuel (manageStock=false) — le stock réel est porté par ses enfants. Le moteur remplace donc le trustedStock du parent par genericChildrenStock = Σ(trustedStock enfant × conversionFactor) (computeGenericChildrenStock / aggregateGenericChildrenStock, #2871). La demande du générique nette ainsi contre le vrai stock de ses enfants, dans la même conversion que la demande.

Note

Le stock enfant est évalué par genericChildTrustedStock, une variante de trustedStock qui saute le flag madeToOrder du fournisseur : le stock d'un enfant suivi est réel même si le fournisseur parent est en flux tendu. L'ignorer sous-préviendrait le générique et commanderait des colis déjà couverts (faux positif « poireaux »). Ne pas « corriger » ce comportement.

Autres règles

Majoration (overage)

La majoration est désactivée par défaut (enableOverage=false) : commande à l'exact, besoinBrut = demande. Quand elle est activée, le pourcentage est product.specificOveragePercent (repli 5 % si non renseigné) et besoinBrut = demande × (1 + majoration%) avant soustraction du stock (calculateProductQuantities).

Conditionnement (colis)

Le diviseur packageSize de l'arrondi au colis suit la précédence packagingType.orderByQuantity || product.packagingQuantity || 1, et displayUnits = colis × packageSize — les unités réellement commandées.

Cycle de vie d'une commande fournisseur

Le statut (ORDER_STATUS) reste volontairement simple ; l'état « stagée » n'est pas un statut mais la présence d'un emailPreview non nul sur un DRAFT. La livraison est un champ séparé (supplierDeliveryStatus, enum SUPPLIER_DELIVERY_STATUS).

stateDiagram-v2
    [*] --> DRAFT : génération (file / cron / manuel)
    DRAFT --> DRAFT : recalcul + stage 06h00 (emailPreview)
    DRAFT --> SENT : envoi email (review == envoyé)
    DRAFT --> CANCELLED : annulation (marchand / reconcile-orphan / sweep)
    SENT --> CONFIRMED : confirmation fournisseur
    SENT --> CANCELLED : annulation
    state "Livraison — supplierDeliveryStatus" as LIV {
        [*] --> PENDING
        PENDING --> PARTIAL : réception partielle
        PARTIAL --> COMPLETE : réception complétée
        PENDING --> COMPLETE : réception totale
        PENDING --> ISSUE : problème signalé
    }
    SENT --> LIV : confirmDelivery
    CONFIRMED --> LIV : confirmDelivery

Projections PENDING (« Entrée virtuelle »)

À la création d'un item de commande fournisseur, le service crée une StockAction PENDING de source SUPPLIER_DELIVERY — la prévision d'entrée. Ces lignes ne touchent jamais Product.stock (règle 17) ; elles alimentent les projections du back-office.

  • Le recalcul resynchronise le jeu de PENDING d'une commande sur ses items courants (annule l'ancien jeu, recrée depuis les items).
  • L'annulation d'une commande démonte ses PENDING — pas de projection zombie.
  • La réception (confirmDelivery) enregistre l'entrée physique en COMPLETED (via StockLedgerService.recordPhysicalMovement, qui nette un éventuel backorder du ledger signé) puis annule les PENDING de la commande — le stock est devenu réel.
  • Le undo de réception (undoSupplierOrderDelivery) reverse le mouvement et restaure exactement les PENDING annulés par ce confirmDelivery.

Emails fournisseurs : stage → review → send

Aucun email fournisseur ne part sans qu'un opérateur ait vu les quantités. Le pipeline (détaillé côté monorepo dans docs/supplier-order-staged-outbox.md) :

  1. Stage (cron 06:00, render-only) : pour chaque DRAFT éligible sans preview, l'email est rendu et persisté dans SupplierOrder.emailPreview ({subject, html, recipients, renderedAt, deferUntil, overrides, itemsFingerprint}). Rien n'est envoyé.
  2. Review (back-office) : la file de review montre les valeurs effectives (overrides fusionnés), permet d'éditer sujet/destinataires, de reporter (deferUntil), de re-préparer, ou d'annuler — voir Back-office.
  3. Send : l'envoi re-rend toujours depuis les items courants (jamais de quantités périmées), mais bloque sur dérive — l'itemsFingerprint (SHA-256 des items triés {productId, productVariationId, quantity, packageSize}) du stage est recomparé aux items courants. S'ils diffèrent : CONFLICT « Re-préparez puis renvoyez ». C'est la garantie review == envoyé.

Résolution des destinataires : test > overrides.recipients > vendor.sendEmail > vendor.email.

Attention

Garde CANCELLED : une commande annulée ne s'envoie jamais (garde précoce + re-check TOCTOU dans le seal et l'auto-send). L'ancien cron « send-all » sans review est neutralisé (il exige ALLOW_LEGACY_SEND_ALL=1), et l'auto-send des previews vieillies est un opt-in par tenant (siteSettings.supplierOrders.autoSendAfterHours), désactivé par défaut.

Note

Les previews stagées avant l'introduction de l'empreinte n'en portent pas : elles passent en fail-open (envoi autorisé) pour que la file existante se draine. Seules les previews avec empreinte peuvent bloquer.

Après l'envoi : réception et filet auto-validate

Après SENT, la commande attend sa réception (expectedDeliveryDate ≈ scheduledSendDate + deliveryDays). Deux chemins scellent l'entrée physique :

  • Le geste du marchand (primaire depuis #2886) : confirmation de réception dans l'UI → confirmDeliverySupplierOrderDelivery + StockAction COMPLETED INCREMENT (quantité en unités = colis × packageSize).
  • Le cron auto-validate (12:01, filet de sécurité) : pour chaque commande SENT/CONFIRMED sans livraison dont l'expectedDeliveryDate (repli scheduledSendDate) est passée d'au moins AUTO_VALIDATE_GRACE_HOURS (12 h), il crée la livraison oubliée. La grâce garantit qu'on ne valide que des livraisons assez anciennes pour avoir réellement eu lieu.

Le détail des horaires vit dans la timeline quotidienne ; le contrat d'écriture ledger dans Chemins d'écriture.

La vie d'une commande client

Un client commande sur la boutique (PrestaShop ou commande native) avec une date de livraison souhaitée. C'est cette date — pas la date d'achat — qui pilotera le créneau d'envoi fournisseur.

La commande arrive par webhook PrestaShop (temps réel) ou par la synchronisation toutes les 3 h. Une projection PENDING DECREMENT (« Sortie virtuelle ») est posée sur le ledger — le stock physique n'est pas décrémenté à ce stade (le décrément à la synchro a été supprimé le 2026-06-14).

Le producteur pousse { tenantId, siteId, orderIds, productIds } dans la file bext supplier-orders-recalc. Le worker prend le verrou d'écriture fournisseur du site — aucun cron ne peut écrire en même temps.

La demande est affectée au créneau d'envoi du fournisseur qui permet une livraison à temps (deliveryDate − deliveryDays − 1 jour de marge). Si plus aucun créneau ne peut livrer à temps, la demande est exclue des prochains emails (SUPPLIER_SKIP_LATE_DEMAND=true, la politique Amis de la Ferme — on ne commande jamais ce qu'on ne pourra plus livrer à temps), ou, pour les tenants sans ce flag, elle roule vers le prochain créneau futur en livraison tardive (garde SUPPLIER_NO_PAST_SLOTS). La quantité du créneau = demande − trustedStock, arrondie au colis (packageSize) supérieur. L'item DRAFT porte les sourceOrderIds et une projection PENDING INCREMENT (« Entrée virtuelle »).

Le cron de stage rend l'email fournisseur dans emailPreview — sujet, HTML, destinataires, et l'empreinte des items (itemsFingerprint). Render-only : rien ne part.

L'opérateur relit la file de review, ajuste si besoin, et envoie. L'envoi re-rend depuis les items courants mais bloque si l'empreinte a dérivé depuis la review (CONFLICT — re-préparer puis renvoyer). La commande passe SENT.

La marchandise arrive. Le marchand confirme la réception : SupplierOrderDelivery + StockAction COMPLETED INCREMENT (colis × packageSize unités) via recordPhysicalMovement — un backorder du ledger signé est netté, les projections PENDING de la commande sont annulées. Oubli ? Le cron auto-validate de 12:01 rattrape après 12 h de grâce.

Le marchand scelle la livraison client (UI ou « Valider la tournée du jour ») → StockAction COMPLETED DECREMENT sur le ledger signé (stockLedger peut passer négatif = backorder ; Product.stock reste le miroir bridé max(0, ledger)). La boucle est bouclée : la prochaine demande repartira de ce ledger via trustedStock.

Commandes utiles

Dry-run des deux moteurs cron (aucune écriture) :

# Auto supplier orders — vérifie que la tâche charge et calcule
cd apps/app && bun run cli --task auto-supplier-orders-service \
  --tenantId "<tenant>" --siteId "<site>" \
  --fromDate "2026-01-01" --toDate "2026-01-02" --verbose

# Reconcile — montre le diff attendu vs base, sans l'appliquer
cd apps/app && bun run cli --task reconcile-supplier-orders \
  --tenantId "<tenant>" --siteId "<site>" \
  --fromDate "2026-01-01" --toDate "2026-01-15" --verbose

Replay-diff en lecture seule avant tout merge touchant le calcul (règle 17) :

bun packages/scripts/src/dev/replay-supplier-orders.ts \
  --tenant-id "<tenant>" --site-id "<site>" \
  [--vendor-id <uuid>] [--from 2026-05-01] [--to 2026-05-15]
Attention

Ne jamais déclencher un vrai send pour « vérifier » — cela envoie un email à un fournisseur réel. En live, on ne vérifie que le stage (render-only), la sonde de santé et l'audit.

Voir aussi