Crons & chronologie quotidienne
Cette page est la vue temporelle du moteur stock/réappro : quelles tâches planifiées tournent en production, à quelle heure, dans quel ordre, et pourquoi cet ordre est délibéré. Depuis le passage au réappro événementiel, les crons ne portent plus la charge principale — ils sont des filets de sécurité et des passes de consolidation autour d'un flux temps réel. Pour l'échelle au-dessus — comment ces journées s'assemblent en vagues fournisseurs, tournées client et fenêtres de comptage — voir La semaine type.
Toutes les heures de cette page sont des heures serveur (UTC) — c'est ce que lit
/etc/crontab. En été, 12:01 UTC = 14:01 à Paris.
Inventaire des tâches planifiées
Vérifié sur la box de production (/etc/crontab géré par Ploi + drop-ins /etc/cron.d/) :
| Tâche | Cadence | Écrit en base ? | Rôle |
|---|---|---|---|
| Sync commandes PrestaShop | toutes les 3 h (0 */3) |
oui (commandes) | Importe les commandes client des 40 derniers jours. Ne décrémente jamais le stock (ce chemin a été supprimé le 2026-06-14). |
| Sync produits PrestaShop | toutes les 3 h (0 */3) |
oui (produits + ledger) | Stock, prix, et réconciliation fournisseurs (--link_vendors). Chaque écriture de Product.stock émet une StockAction COMPLETED (#2749). |
| Auto-supplier-orders | toutes les 30 min (*/30) |
oui (brouillons SO) | Génère/ajuste les commandes fournisseurs. Filet : l'événementiel (file supplier-orders-recalc) porte la charge. |
| Stage des emails fournisseur | 06:00 | oui (emailPreview) |
Rend l'email de chaque brouillon sans l'envoyer (render-only) + persiste une empreinte des items (itemsFingerprint). |
| Sonde supplier-stock-health | 06:30 | non (lecture seule) | Instantané des invariants I1–I5, précision du reconcile, santé de la file recalc → KV. |
| Reconcile-supplier-orders | 07:15 | oui (--apply --removeOrphans) |
Recalcule l'état attendu depuis les commandes client, applique le diff minimal. Seul cron qui remplit les SO des produits manageStock=false. |
| Auto-validate-stock-actions | 12:01 (canonique) + toutes les 2 h à :01 (/etc/cron.d) |
oui (livraisons + réceptions + SA) | Scelle ce que le marchand a oublié de confirmer. Filet de décrément — le geste primaire est le scellé UI au moment physique (#2886). |
Deux précisions sur cet inventaire :
- Le sync commandes a en plus des créneaux ponctuels hebdomadaires posés par Ploi (lundi 10:00–10:30, mercredi 19:45–20:15) — des renforts, pas un rythme différent.
- L'auto-validate a deux entrées qui se recouvrent : la ligne quotidienne 12:01 de
/etc/crontabet le drop-in1 */2 * * *de/etc/cron.d/auto-validate-stock-actions-extra(ajouté avec #2886, quand le cron est passé de moteur primaire à filet). Les deux tirent à 12:01 le même jour. Le chevauchement est neutralisé sur deux plans : dans la tâche, l'idempotence (elle saute les lignes déjà scellées) et un scellé par transaction ; dans le wrapper, unflockqui sérialise les deux passes — le run perdant sort en 0 (un recouvrement attendu n'est pas une alerte). Le verrou ferme la fenêtre TOCTOU que l'idempotence seule laissait ouverte : les deux runs pouvaient lire « pas encore scellé » et sceller tous les deux.
La journée en un coup d'œil
Le rythme de fond (30 min + 3 h) tourne en continu ; les événements uniques du matin (06:00 → 07:15) forment la séquence de consolidation détaillée plus bas ; l'auto-validate balaie toutes les 2 heures avec sa passe canonique à 12:01.
Une journée type en Gantt
Les durées sont illustratives (quelques minutes chacune) — c'est le séquencement qui compte :
gantt
title Journée type (heures serveur UTC)
dateFormat HH:mm
axisFormat %H:%M
section Sync PrestaShop
Commandes + produits (passe 06:00) : 06:00, 20m
Commandes + produits (passe 09:00) : 09:00, 20m
section Réappro
Auto-supplier 06:00 : 06:00, 5m
Auto-supplier 06:30 : 06:30, 5m
Auto-supplier 07:00 : 07:00, 5m
section Emails
Stage des previews (render-only) : milestone, 06:00, 0m
section Contrôles
Sonde stock-health (lecture seule) : 06:30, 10m
Reconcile --apply --removeOrphans : 07:15, 15m
section Filet stock
Auto-validate (passe canonique) : 12:01, 10m
Le séquencement du matin — pourquoi cet ordre
La fenêtre 06:00 → 07:15 n'est pas arbitraire. Trois raisons s'emboîtent :
- Stage (06:00) avant tout le reste. Le stage rend les emails fournisseur dans
SupplierOrder.emailPreviewet fige une empreinte des items (itemsFingerprint, sha256 des lignes triées). L'opérateur relit ces previews dans la file de review du back-office avant l'envoi. Stager tôt donne à l'opérateur toute la matinée pour relire. - Sonde (06:30) après le stage. La sonde est en lecture seule ; elle photographie
l'état après le stage et après les passes auto-supplier de 06:00 et 06:30, ce qui
rend ses métriques (précision du reconcile, brouillons rétro-datés,
autoValidateSealed24h) comparables jour après jour. - Reconcile (07:15) en dernier. Le reconcile recalcule l'état attendu des commandes
fournisseurs depuis les commandes client et applique le diff. Il tourne après les
passes auto-supplier de 06:00 et 06:30 exprès : il attrape la dérive que celles-ci ont
manquée, et il est le seul à remplir les commandes fournisseurs des produits non
suivis en stock (
manageStock=false), que le cron auto-supplier exclut de ses candidats.
L'interaction stage ↔ reconcile est couverte par l'empreinte : si le reconcile de 07:15
modifie un brouillon déjà stagé à 06:00, l'empreinte courante ne correspond plus à celle du
stage. L'envoi bloque alors sur drift (CONFLICT — « Re-préparez puis renvoyez ») et la
file de review affiche un chip de drift avec un bouton « Re-préparer ». Ce que l'opérateur a
relu est ce qui part — jamais des quantités périmées.
Une matinée heure par heure
Trois choses partent en même temps :
- La passe auto-supplier de 06:00 ajuste les brouillons fournisseurs (produits
manageStock=trueou avec approvisionnement). - Le stage des emails rend les previews des brouillons DRAFT sans preview et fige
l'
itemsFingerprintde chacun. - Les syncs PrestaShop (commandes + produits) tournent — c'est une des passes
0 */3. Le sync produits passe par le même chemin ledger-intégré que les webhooks temps réel.
La sonde supplier-stock-health (lecture seule, /etc/cron.d/supplier-stock-health)
capture les invariants I1–I5, la précision du reconcile et la santé de la file
supplier-orders-recalc, puis pousse le tout en KV pour la tour de contrôle. La passe
auto-supplier de 06:30 tourne en parallèle.
La sonde est le modèle du bon wrapper cron : elle exécute le fichier de tâche
directement et termine par exit $EXIT_CODE — un échec remonte réellement. Voir la
section alerting plus bas.
reconcile-supplier-orders --apply --removeOrphans recalcule l'état attendu et applique le
diff minimal : ajouts, mises à jour, et retraits (le sweep d'orphelins annule + tague
cancellationReason, il ne supprime pas). C'est la passe qui rattrape ce que les runs
auto-supplier de 06:00/06:30 ont manqué et qui couvre les produits manageStock=false.
Si un brouillon stagé à 06:00 est modifié ici, son empreinte dérive → l'envoi de cet email sera bloqué jusqu'à « Re-préparer ».
Le rythme de fond : la file événementielle supplier-orders-recalc traite chaque création /
modification / annulation de commande en ~30 s, la passe auto-supplier repasse toutes les
30 min derrière elle, et les syncs PrestaShop repassent à 09:00. L'opérateur relit la file
d'emails stagés et déclenche les envois — le send re-rend toujours depuis les items
courants et vérifie l'empreinte.
L'auto-validate tire (deux fois — crontab quotidien + drop-in 2-horaire, idempotent) et
scelle ce que le marchand a oublié : réceptions fournisseur et livraisons client dont la
date prévue est passée d'au moins AUTO_VALIDATE_GRACE_HOURS (12 h). Chaque scellé écrit
via StockLedgerService.createStockAction/createManyStockActions avec l'aide de calcul
partagée computeReplenishStockWrite (rolling-stock-math) — même contrat d'écriture net que
le recordPhysicalMovement du clic UI.
Pour visualiser la densité de la fenêtre du matin (le rythme 30 min + 3 h autour des événements uniques) :
Sémantique de grâce de l'auto-validate
Les dates deliveryDate / expectedDeliveryDate sont des dates prévues — pas la preuve
qu'une livraison a physiquement eu lieu. L'auto-validate ne scelle donc que ce qui est
suffisamment ancien pour avoir réellement eu lieu :
| Paramètre | Valeur prod | Effet |
|---|---|---|
AUTO_VALIDATE_GRACE_HOURS |
12 (défaut) |
Une réception/livraison n'est éligible que si sa date prévue est passée d'au moins 12 h. Rend le cron sûr à n'importe quelle heure et fréquence. |
--daysBack |
12 |
Borne basse : rien de plus vieux que 12 jours n'est rattrapé (anti « rattrapage de toute la base » — l'historique 2018-2024 contenait 187 commandes jamais scellées). |
--maxItems |
200 |
Cap par run, anti-emballement. |
--force |
passé par le cron | La tâche refuse un --apply touchant > 10 SO/commandes sans --force — le cap vise le run manuel accidentel, pas le cron planifié. |
L'incident du 2026-06-10 est la raison d'être de la grâce. L'ancienne éligibilité
date prévue <= now faisait que le run de midi confirmait les livraisons du jour même…
pas encore effectuées : 30 fulfillments et 20 réceptions fantômes, −156/+95 unités de
stock faux. Avec la grâce de 12 h, une livraison prévue aujourd'hui à 09:00 n'est éligible
qu'à partir de 21:00 — et le marchand qui livre en avance scelle simplement via l'UI (la
tâche saute alors la ligne grâce à ses gardes d'idempotence).
Éligibilité côté réceptions : les SupplierOrder SENT/CONFIRMed sans livraison, sur
expectedDeliveryDate avec repli sur scheduledSendDate quand elle est absente. Côté
livraisons client : les commandes dont les items ne sont pas tous fulfilled. Dans les deux
cas le scellé crée les enregistrements réels (SupplierOrderDelivery + SA
SUPPLIER_DELIVERY, ou OrderFulfillment + SA DECREMENT) via le même contrat d'écriture
que l'UI — voir Chemins d'écriture.
Scénarios concrets
Le marchand corrige un stock à 10:00. L'écriture passe par
recordPhysicalMovement (compteur + StockAction dans la même transaction), puis un job
supplier-orders-recalc (raison manual-stock) est mis en file avec ~30 s de délai/dédup.
Les brouillons fournisseurs du produit sont recalculés dans la minute — pas besoin
d'attendre la passe cron de 10:30, qui ne fera que confirmer l'état.
Une commande client arrive à 11:59 vs 12:02. Quasiment aucune différence. Dans les deux cas la file événementielle recalcule le réappro en ~30 s. Et l'auto-validate de 12:01 est indifférent à une commande neuve : sa date de livraison prévue est dans le futur, donc à plus de 12 h de toute éligibilité. La seule chose qui change, c'est quelle passe du filet finira par sceller la livraison si le marchand l'oublie — un décalage d'au plus 2 h vu le sweep 2-horaire.
Une livraison est prévue aujourd'hui à 09:00. Le geste attendu est le scellé au moment
physique : le bouton de fulfillment ou « Valider la tournée du jour » dans le back-office.
Si personne ne le fait, la ligne devient éligible à 21:00 (09:00 + 12 h de grâce) et la
première passe auto-validate suivante — 22:01 — la scelle. Sous l'ancien planning quotidien
seul, elle aurait attendu le lendemain 12:01. La métrique autoValidateSealed24h de la
sonde compte précisément ces oublis.
Alerting : la réalité des codes de sortie
Le monitoring des crons repose sur le code de sortie du wrapper. Deux pièges historiques se cumulaient — les deux sont désormais fermés :
bun run cli --task Xsort toujours en 0 sauf si la tâche throw. Une tâche qui signalait l'échec en retournant{success: false}était avalée — le cron « réussissait ». ✅ 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 — le throw survient après tout le travail sûr (un item cassé ne bloque pas le balayage, mais le run échoue bruyamment à la fin).- Plusieurs wrappers
scripts/cron/*.shn'avaient niset -eniexit $EXIT_CODEfinal : même un vrai code non-zéro était écrasé en 0 avant d'atteindre Ploi. ✅ Les 24 wrappers propagent aujourd'hui leur code de sortie (exit $EXIT_CODEexplicite,exec, ou commande finale sousset -e).
Un cron qui échoue sort donc réellement en non-zéro et Ploi alerte. Le pattern correct, à reproduire pour tout nouveau wrapper :
# Bon : exécuter le fichier de tâche directement + propager le code
# (modèle : scripts/cron/supplier-stock-health.sh)
bun packages/scripts/src/tasks/ma-tache.ts "$@"
EXIT_CODE=$?
exit $EXIT_CODE
et côté tâche : throw en cas d'échec (modèle : reconcile-supplier-orders.ts, qui
throw sur toute erreur Prisma par item au lieu de l'avaler — c'est ce silence qui avait
masqué des semaines de reconcile cassé en 2026-05).
Le contrat est verrouillé par des tests : cron-wrappers.shape.test.ts fait échouer tout
wrapper nouveau ou modifié qui avale son code de sortie (il retire les commentaires #
d'abord — de la prose à propos des codes de sortie ne peut pas satisfaire l'assertion), et
auto-validate-stock-actions.shape.test.ts épingle le contrat de gate.
Le chip « Cron 5 » du back-office
En complément de l'alerting par exit-code, le back-office porte un détecteur de panne
silencieuse : la page Stock ledger
(/manage/ecommerce/inventory/stock-ledger) affiche un chip qui distingue deux questions
délibérément séparées.
1. Liveness (le titre du chip) — le cron a-t-il TOURNÉ ? Lue sur le heartbeat que la
tâche estampille dans bext-KV à la fin de chaque run (clé
health:auto-validate:{tenantId}:{siteId}, TTL 7 jours ; l'écriture est best-effort — une
panne de bext ne fait jamais échouer le run) :
| Âge du heartbeat | Chip | Lecture |
|---|---|---|
| ≤ 5 h | vert | le cron tourne (cadence 2 h + marge) |
| ≤ 12 h | ambre | plusieurs passes 2-horaires manquées — à surveiller |
| > 12 h | rouge | le cron n'a pas tourné — probablement en panne |
2. Activité (secondaire, « dernière activité ») — le cron a-t-il ÉCRIT ? L'âge de la dernière validation système, par côté (⇣ réception fournisseur / ⇡ livraison client).
Pourquoi cette séparation : la dernière StockAction mesure l'activité, pas la liveness. Une
semaine calme n'écrit aucune ligne — l'ancien chip la lisait comme « cron mort » ; et à
l'inverse un cron mort juste après une journée chargée s'affichait vert. Le heartbeat répond
directement à « a-t-il tourné ? ». Si bext est injoignable (page ouverte hors de la box du
tenant), le chip retombe sur l'ancien statut par âge d'activité et le signale dans le
popover — jamais un faux « mort ».
C'est la classe de panne rencontrée le 2026-05-26 : un cron qui « tourne » (exit 0) mais ne produit plus rien. Le chip la rend visible sans attendre l'alerting.
Voir aussi
- Vue d'ensemble — le modèle stock/réappro en une page
- Le ledger de stock — autorité signée, backorder, invariants
- Chemins d'écriture —
recordPhysicalMovementet le contrat transactionnel - Commandes fournisseurs — le réappro événementiel et ses gates
- Sync PrestaShop — syncs 3-horaires et webhooks temps réel
- Back-office — les surfaces
/manage, dont le chip Cron 5 - Filets de sécurité — audit
--strict, replay-diff, sonde quotidienne - Configuration — les flags (
AUTO_VALIDATE_GRACE_HOURS, gates, etc.)