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.

Note

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/crontab et le drop-in 1 */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, un flock qui 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 :

  1. Stage (06:00) avant tout le reste. Le stage rend les emails fournisseur dans SupplierOrder.emailPreview et 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.
  2. 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.
  3. 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.
Astuce

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=true ou avec approvisionnement).
  • Le stage des emails rend les previews des brouillons DRAFT sans preview et fige l'itemsFingerprint de 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.

Note

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é.
Attention

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 :

  1. bun run cli --task X sort 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).
  2. Plusieurs wrappers scripts/cron/*.sh n'avaient ni set -e ni exit $EXIT_CODE final : 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_CODE explicite, exec, ou commande finale sous set -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