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) :
- authentifie le POST bext via
?token=<BEXT_QUEUE_TOKEN>; - résout les
productIdsvers leurs fournisseurs par défaut, unionne avec lesvendorIdsexplicites ; - prend le verrou d'écriture fournisseur du site (le même que celui des crons — sérialisation totale) ;
- 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.
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 :
- 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).
- 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.
- Amis de la Ferme (production,
Lire un ⚠️ [SKIP-LATE-DEMAND] : ce n'est pas une demande perdue
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 :
- La commande client entre avant son créneau couvrant, elle y est affectée, elle part dans l'email fournisseur. Rien d'anormal.
- Le créneau passe. La commande fournisseur est
SENT, verrouillée. - 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. - 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 ».
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.
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
createdAtde 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.
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 :
stockAppliedvient detrustedStock, 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 gateSTOCK_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.
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.
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 (viaStockLedgerService.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 ceconfirmDelivery.
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) :
- 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é. - 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. - 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.
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.
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 →
confirmDelivery→SupplierOrderDelivery+ 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(replischeduledSendDate) est passée d'au moinsAUTO_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]
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
- Vue d'ensemble stock & réappro
- Le ledger de stock —
trustedStock, backorder, miroir bridé - Chemins d'écriture —
recordPhysicalMovementet le contrat règle 17 - Timeline quotidienne — tous les crons sur 24 h
- La semaine type — créneaux,
receptionLeadDays, quand compter le stock - Synchronisation PrestaShop — webhooks et syncs 3 h
- Back-office — file de review email, pages stock
- Configuration — gates
SUPPLIER_NO_PAST_SLOTS,BEXT_SUPPLIER_RECALC,STOCK_TRUST_* - Filets de sécurité — audit, replay-diff, sonde quotidienne