Inventaire unifié : synchronisation temps réel et routage par canal

Conception et mise en œuvre d’un inventaire unifié pour PrestaShop : ATP par canal, réservations TTL, transactional outbox, idempotence et routage pour prévenir l’oversell.

Un bureau informatique avec des écrans affichant des interfaces de gestion de l'inventaire et une horloge numérique.

Table des matières :

  1. Inventaire unifié : définition opérationnelle et limites structurelles de PrestaShop
  2. Modéliser le stock pour router par canal : disponible, réservé, safety stock, ATP
  3. Synchronisation temps réel : événements, idempotence, et “transactional outbox”
  4. Routage par canal : règles, priorités, et anti-oversell “by design”
  5. Implémentation côté PrestaShop 9.x : points d’ancrage, API, et cohérence DB
  6. Performance, observabilité, et hygiène de données : ce qui évite les nuits blanches
  7. Checklist de mise en production : cohérence, tests, et plan de reprise

Inventaire unifié : définition opérationnelle et limites structurelles de PrestaShop

Un inventaire unifié (souvent appelé single source of truth côté stock) n’est pas « un stock central + une synchronisation ». C’est un modèle où toute décision d’allocation (vendre, réserver, expédier, router vers un entrepôt, exposer un stock à un canal) converge vers un référentiel unique, avec des règles explicites et auditables. En pratique, cela implique de distinguer :

  • la source de vérité (inventory ledger / OMS / WMS / service stock), où l’on enregistre des mouvements horodatés et traçables ;
  • les vues/projections consommées par les canaux (PrestaShop, marketplaces, POS, PIM…), qui ne sont que des lectures optimisées (et parfois éventuellement cohérentes).

Une bonne “définition opérationnelle” tient en une question : si demain vous devez expliquer pourquoi une commande marketplace a été annulée pour rupture, pouvez-vous reconstruire la timeline exacte (réception → réservation → allocation → expédition → annulation) avec des identifiants corrélables ? Si la réponse est non, vous n’avez pas encore un inventaire unifié : vous avez des quantités qui bougent.

Le cœur de PrestaShop (8.x / 9.x) reste centré sur un stock “disponible” dans ps_stock_available (par produit et déclinaison, par boutique/groupe), et des décrémentations liées à la validation de commande. Ça fonctionne tant que vous avez : un seul canal, peu de concurrence, pas de réservation, pas de latence inter-systèmes. Dès que vous ajoutez un POS, une marketplace, du click&collect, ou un entrepôt externe (3PL), vous vous heurtez aux limites classiques :

  • absence native de réservation temporisée (TTL) réellement centralisée,
  • manque de garde-fous pour une idempotence stricte sur les flux entrants (retries, doublons),
  • difficulté à reconstruire une timeline fiable des mouvements (audit, “pourquoi ce -1 ?”).

Mini-scénario très concret (typique en France) : un marchand a un site PrestaShop, deux boutiques (POS) et vend aussi sur ManoMano. Le matin, une unité de SKU ABC-42 est physiquement en boutique Paris. À 10:01, un vendeur la met de côté pour un client (réservation POS). À 10:02, le site vend la même unité (checkout web). À 10:03, ManoMano passe une commande. Si la décision “qui a droit à cette unité” n’est pas centralisée et auditable, vous êtes mathématiquement condamné à l’oversell ou à des annulations coûteuses.

On retrouve ici une réalité de systèmes distribués : l’“instantanéité” n’est pas une garantie de cohérence forte, c’est une optimisation de latence. L’objectif réaliste est de minimiser la fenêtre d’incohérence et de rendre le système résilient (replay, déduplication, reconciliation). Pour cadrer cette notion de cohérence éventuelle, la référence de fond reste le billet de Werner Vogels (AWS) sur l’eventual consistency (2008) : Eventual consistency — Werner Vogels (2008).

Conséquence directe côté PrestaShop : si vous visez un inventaire unifié, PrestaShop doit progressivement devenir un consommateur de projections (ATP, indicateurs de promesse), pas l’endroit où se “discute” la vérité du stock.

Modéliser le stock pour router par canal : disponible, réservé, safety stock, ATP

Avant de parler bus de messages, il faut nommer les états (et surtout les transitions). Un inventaire unifié sérieux modélise au minimum :

  • On-hand : stock physiquement disponible (par lieu si multi-entrepôts / magasins).
  • Reserved : stock engagé (panier, commande non payée, commande payée non préparée, allocation magasin pour click&collect…).
  • In-transit : en réception / transfert (utile dès qu’il y a des mouvements inter-sites).
  • Safety stock : stock tampon non exposé (dégradé), souvent différent par canal ou par site.
  • ATP (Available To Promise) : ce que vous autorisez réellement à vendre (la seule valeur qui devrait alimenter l’affichage “en stock”).

Un point souvent sous-estimé : Reserved n’est pas un état unique, c’est une famille d’engagements. Dans un même système, vous pouvez avoir des réservations :

  • souples (panier web) avec TTL court,
  • dures (commande payée) sans TTL,
  • conditionnelles (click&collect) qui dépendent d’une préparation magasin,
  • externes (marketplace) où vous n’avez pas de “panier” mais une commande déjà actée.

Pour du routage par canal, ce qui compte n’est pas “on-hand”, mais ATP par canal, et idéalement ATP par lieu (entrepôt, magasin). Exemple simple :

ATP_web = on_hand - reserved - safety_stock_web

ATP_marketplace = min(on_hand - reserved - safety_stock_mp, quota_mp)

Le quota est une règle métier (ex : n’exposer que 20 unités sur marketplace pour éviter les annulations). Le safety stock peut être plus agressif sur un canal à forte pénalité (marketplaces type Amazon/ManoMano) et plus permissif sur votre site.

En pratique, pour éviter que ces règles ne deviennent “magiques”, il est utile de formaliser les entrées et sorties de votre calcul ATP. Une petite grille (adaptable) permet de rendre les hypothèses discutables :

Élément Exemple de source Remarques d’exploitation
on_hand (par site) WMS / inventaire magasin nécessite une granularité SKU + localisation
reserved (par type) OMS (panier/commande), POS important : TTL et cause (orderid, cartid)
safety_stock (par canal) règle métier souvent fixe au départ (ex. 2 u.), puis optimisée
quota (par canal) règle métier utile pour marketplaces et flash sales
promesse (délai) WMS/transport influence “vendable” vs “précommandable”

Côté PrestaShop, vous pouvez afficher une disponibilité via StockAvailable::getQuantityAvailableByProduct() mais ça reste un chiffre unique, et souvent mis à jour par décrémentations synchrones. Si votre inventaire est unifié ailleurs, PrestaShop doit devenir un consommateur d’un ATP calculé (par SKU + déclinaison + shop si multi-boutique). Autrement dit : évitez de “faire la vérité” dans PrestaShop, et utilisez-le comme projection.

Le point qui casse le plus vite : la réservation. Sans réservation, deux canaux peuvent vendre la même unité (oversell). Avec réservation, vous devez gérer expiration, relâchement, et concurrence. Ici, le choix architectural (centraliser la réservation dans un service stock vs la disperser) a un impact direct sur le routage. Si vous laissez chaque canal “réserver localement”, vous perdez l’unification et vous vous retrouvez à “réconcilier” des contradictions.

Une règle simple, très opérationnelle : si la décision de vendre dépend d’un chiffre (ATP), alors la création d’engagement (reservation) doit dépendre du même référentiel. Sinon, vous aurez des fenêtres où chaque canal “croit” être prioritaire.

Synchronisation temps réel : événements, idempotence, et “transactional outbox”

La synchronisation “temps réel” se décline en trois patterns :

  1. Polling (cron) : simple, robuste, mais latence incompressible + charge inutile.
  2. Webhooks : bon compromis, mais nécessite retry, signature, et gestion d’ordonnancement.
  3. Event-driven (message broker) : le plus robuste à l’échelle, mais exige discipline (idempotence, DLQ, observabilité).

Si vous routez par canal, vous avez déjà un problème de systèmes distribués. La question n’est pas “Kafka ou RabbitMQ”, c’est : quel est le contrat des messages, comment vous gérez l’ordre quand il compte, et comment vous rejouez l’historique sans casser le stock.

Deux recommandations qui évitent beaucoup de dérives :

  • Versionnez vos événements (ex. StockReserved.v1) : un changement de payload sans version finit en incidents silencieux.
  • Ajoutez une clé d’agrégat (ex. sku ou productId+combinationId) et, si nécessaire, un numéro de séquence par agrégat. Sans cela, certains consommateurs vont traiter “Release” avant “Reserve” après un redémarrage ou une congestion.

L’idempotence n’est pas optionnelle. Deux sources la rendent indispensable : retries réseau et doubles émissions (bugs, redémarrages, partitions). La définition d’idempotence côté HTTP est cadrée par la RFC 7231 §4.2.2. Côté intégrations, Stripe documente aussi l’usage des idempotency keys pour rendre les retries sûrs : Stripe — idempotency docs.

Pour le stock, traduisez-le en règle d’or : un événement = un identifiant immuable, et votre consommateur doit pouvoir recevoir cet événement N fois sans changer l’état final au-delà de la première application.

Le pattern pragmatique pour éviter les pertes d’événements est le transactional outbox : vous écrivez le mouvement de stock et l’événement à publier dans la même transaction DB, puis un worker publie vers le broker. Ça évite le classique “DB commit OK mais publish KO”.

Dans un contexte PrestaShop (MySQL/MariaDB), une implémentation minimaliste (à adapter à vos conventions) ressemble à :

CREATE TABLE ps_inventory_outbox (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  event_id CHAR(36) NOT NULL,
  type VARCHAR(64) NOT NULL,
  payload JSON NOT NULL,
  occurred_at DATETIME(6) NOT NULL,
  published_at DATETIME(6) NULL,
  attempts INT UNSIGNED NOT NULL DEFAULT 0,
  last_error TEXT NULL,
  UNIQUE KEY uniq_event_id (event_id),
  KEY idx_published_at (published_at),
  KEY idx_type (type)
) ENGINE=InnoDB;

Points d’attention qui font la différence en production :

  • UNIQUE sur event_id : c’est votre garde-fou principal.
  • occurred_at vs published_at : indispensable pour mesurer le lag (temps de convergence).
  • Index sur published_at : MySQL/MariaDB ne supportent pas d’index partiel IS NULL, donc indexez published_at (ou ajoutez un champ booléen published indexé) pour éviter que le worker ne scanne la table.
  • Taille des payloads : gardez le payload utile, pas toute la commande PrestaShop.

Enfin, soyez explicite sur votre sémantique : la plupart des pipelines sont “at-least-once” (au moins une fois), rarement “exactly-once”. Le “exactly-once” apparent se construit via idempotence + déduplication, pas via une promesse magique du broker.

Routage par canal : règles, priorités, et anti-oversell “by design”

Le routage par canal n’est pas seulement “pousser le stock vers Amazon”. C’est un moteur de décision : quel canal a le droit de consommer l’ATP, dans quel ordre, avec quelles exceptions. Les règles typiques :

  • Priorité : ex. B2B (contrats) > Web > Marketplace.
  • Réservation : ex. panier web réserve 10 min ; POS réserve 2 min ; marketplace = pas de réservation, mais quota.
  • Sources : ex. click&collect autorisé seulement si stock magasin A > seuil.
  • Backorder : ex. web autorise précommande si lead time < 7 jours ; marketplace interdit.
  • Split (optionnel) : autoriser ou non l’expédition multi-sources (entrepôt + magasin).

Dans une implémentation propre, ces règles vivent dans le service d’inventaire/OMS, pas dans PrestaShop. PrestaShop consomme un résultat : sellable_quantity + éventuellement channel_flags (vendable, précommandable, délai). Pour un affichage produit, vous mappez ça sur la disponibilité et/ou des messages. Pour le panier et la commande, vous devez revalider au moment critique (checkout) : l’ATP peut changer entre affichage et paiement.

Un format de retour très praticable (à décliner par canal) :

  • sellable_quantity: entier
  • is_sellable: bool
  • is_preorder: bool
  • promise_days_min / promise_days_max: entier (si vous affichez un délai)
  • fulfillment_nodes: liste (entrepôt, magasin) si vous routez l’expédition

Le piège fréquent est de gérer l’anti-oversell uniquement “au moment du décrément”. Ça ne suffit pas quand vous avez plusieurs canaux concurrents. La bonne stratégie :

  1. Réserver avant paiement (ou à défaut à la création de commande) avec TTL.
  2. Capturer la réservation à paiement/validation.
  3. Relâcher à annulation/expiration.

Le routage intervient dès la réservation : si ATP_web est nul mais ATP_marketplace est positif (quota), vous ne voulez pas vendre sur le web. Inversement, si vous avez une vente web prioritaire, vous devez pouvoir réduire l’exposition marketplace immédiatement (ce qui implique un mécanisme de mise à jour rapide des projections marketplace).

Exemple de table de règles (simplifiée) :

Canal Exposition Réservation Safety stock Quota Backorder
Web ATP_web 10 min 2 ∞ Oui (LT<7j)
POS ATP_pos 2 min 1 ∞ Non
Marketplace ATP_mp Non 5 20 Non

Le point “non sexy” mais critique : les pénalités et le coût opérationnel. Sur marketplace, la rupture après commande se paye en annulations, messages clients, support, et parfois restrictions de visibilité. Le routage par canal sert d’abord à réduire le risque et à rendre le comportement explicable (audit + règles écrites), ensuite à optimiser le chiffre.

Implémentation côté PrestaShop 9.x : points d’ancrage, API, et cohérence DB

Contexte versions (à adapter) : les exemples ci-dessous visent PrestaShop 9.0–9.1 (cœur Symfony 6.4) et PHP 8.2+. Pour un rappel des changements côté API/perf et du socle Symfony, voir l’article interne : PrestaShop 9 : nouveautés techniques Symfony 6.4, API et performances.

Où accrocher les événements stock/commande

Dans un module, vous captez les transitions clés via hooks (selon votre besoin) :

  • actionValidateOrder : commande créée/validée → émission d’un événement “OrderCreated/Reserved”.
  • actionOrderStatusPostUpdate : changements d’état → capture/relâchement de réservation.
  • actionUpdateQuantity / actionProductUpdate (selon versions) : changements manuels de stock → émission “StockAdjusted”.

Deux précisions utiles en contexte multi-canal :

  • Évitez de faire des appels réseau (OMS, broker, marketplace) dans le thread du hook : en cas de timeout, vous bloquez la validation de commande. Préférez : écriture locale (outbox) → worker asynchrone.
  • Corrélez vos événements : mettez systématiquement order_id, cart_id (si présent), employee_id (modif BO), channel, et un event_id UUID. C’est ce qui rend les incidents débogables.

Le choix important : ne pas écrire directement dans ps_stock_available depuis un flux externe sans stratégie. Si votre inventaire unifié est ailleurs, deux approches existent :

  • Projection : vous poussez périodiquement ou en event-driven le sellable_quantity vers PrestaShop (et vous acceptez que PrestaShop soit “read-only” sur le stock).
  • Hybrid : PrestaShop reste maître sur certaines gammes (ex. web-only), le service unifié réconcilie.

En projection, vous devez neutraliser les modifications humaines (back-office) ou au minimum les capturer pour les remonter au référentiel (sinon vous créez de la dérive). Concrètement, beaucoup d’équipes commencent par interdire la modification stock BO sur les produits “pilotés” par l’OMS, et n’autorisent que des “ajustements” via un flux contrôlé.

Messenger/Workers et isolation technique

PrestaShop 9 ayant un socle Symfony plus propre, vous pouvez structurer le module avec services + handlers. Si vous utilisez Doctrine côté module, appuyez-vous sur les patterns décrits dans : développer des modules robustes avec Doctrine.

Un squelette de message (pseudo-code) :

final class InventoryEvent
{
    public function __construct(
        public readonly string $eventId,      // UUID
        public readonly string $type,         // StockReserved, StockReleased...
        public readonly int $productId,
        public readonly ?int $combinationId,
        public readonly int $delta,
        public readonly string $channel,
        public readonly string $occurredAt,   // RFC3339
    ) {}
}

Dans le handler :

  • Vérifiez déduplication (event_id unique en DB).
  • Appliquez l’effet sur votre projection PrestaShop (ou appelez votre service inventory).
  • Publiez dans l’outbox, puis laissez un worker faire l’I/O externe.

Ajoutez une règle d’implémentation très pratique : un handler ne doit pas “deviner”. Si un événement arrive avec une clé produit inconnue (SKU non mappé, déclinaison supprimée), le bon comportement est de mettre en quarantaine (DLQ/erreur) avec un message exploitable, pas de “skipper” silencieusement. En stock, le silence est plus dangereux que l’échec.

Ce découplage est la différence entre “ça marche en dev” et “ça ne corrompt pas le stock sous charge”.

Performance, observabilité, et hygiène de données : ce qui évite les nuits blanches

Sur un inventaire unifié, la performance ne se mesure pas qu’en ms de réponse. Elle se mesure en : temps de convergence, taux de retry, âge moyen de la DLQ, taux d’oversell, différence entre ledger et projections. Sans métriques, vous pilotez au ressenti.

Une base de métriques “minimum viable” (même sans grosse stack) :

Signal Pourquoi c’est vital Exemple de seuil d’alerte (à adapter)
Lag outbox (now - occurred_at non publié) mesure la convergence > 60 s
Taux de doublons (event_id déjà vu) révèle retries/bugs hausse brutale
DLQ size / âge max révèle erreurs non traitées > 100 ou > 1 h
Diff ledger vs projection détecte dérive écart non nul persistant
Oversell (annulations pour rupture) impact business direct > baseline hebdo

Côté base de données PrestaShop, les updates stock peuvent générer contention et tables qui grossissent vite (logs, outbox, tables de mapping). Une routine de maintenance (index, purge, archivage) devient nécessaire ; à ce sujet, l’article interne routine de maintenance et nettoyage automatisé est un bon point de départ.

Quelques pratiques simples mais rentables :

  • Purger l’outbox publiée (ex. conserver 7–30 jours selon besoin d’audit) et archiver ailleurs si nécessaire.
  • Mettre des index alignés avec vos requêtes réelles (ex. “à publier”, “par type”, “par date”).
  • Éviter les transactions longues autour de la commande si vous faites beaucoup d’écritures (réservations, logs, mapping).

Pour la partie Symfony/Doctrine, surveillez la régression “silencieuse” : N+1, memory bloat sur workers, transactions trop longues. L’article Scout Monitoring Symfony : détection N+1 Doctrine et memory bloat est directement exploitable pour instrumenter.

Enfin, n’oubliez pas que le stock est une surface d’attaque (fraude, DDoS applicatif via endpoints d’update, empoisonnement de webhooks). Pour durcir l’exécution (authn/authz, signatures HMAC, rate limiting, séparation des secrets, logs immuables), recoupez avec Sécurité PrestaShop 2026 : risques majeurs et protections professionnelles. Dans un contexte multi-canal, une bonne hygiène consiste aussi à scoper les accès : le POS ne devrait pas pouvoir appeler les mêmes endpoints que la marketplace, et inversement.

Checklist de mise en production : cohérence, tests, et plan de reprise

Une mise en prod “inventaire unifié + synchronisation temps réel + routage par canal” échoue rarement sur le code “business”. Elle échoue sur les détails : retries, doubles événements, timeouts, migrations de schéma, et absence de runbook. Avant d’ouvrir le flux vers un canal externe, imposez-vous un contrat de cohérence :

  • Qu’est-ce qui est strictement cohérent ? (ex. réservation centralisée)
  • Qu’est-ce qui est éventuellement cohérent ? (ex. affichage stock PrestaShop)
  • Quelle est la fenêtre cible ? (ex. < 2 s pour exposer un décrément marketplace)
  • Quel est le comportement en mode dégradé ? (ex. si l’OMS est indisponible : bloquer le checkout ? basculer en “rupture” ?)

Tests indispensables (automatisés) :

  • Idempotence : rejouer le même event_id 10 fois = même état final.
  • Concurrence : 50 checkouts simultanés sur le même SKU = pas d’ATP négatif.
  • Pannes partielles : broker down / API marketplace down = outbox qui tamponne, pas de perte.
  • Ordonnancement (si vous avez des séquences) : traitement hors ordre = état final correct ou mise en quarantaine explicite.

Ajoutez un volet “déploiement” que beaucoup oublient :

  • Feature flag : activer le routage par canal progressivement (par catégorie, par marque, par boutique).
  • Shadow mode : pendant quelques jours, calculez l’ATP “nouveau” en parallèle sans l’appliquer, et mesurez les écarts.
  • Backfill : si vous introduisez une outbox, prévoyez comment initialiser l’état (photo de stock + réservations en cours).

Pour industrialiser, alignez la QA sur vos standards PHP : analyse statique, refactoring contrôlé, CI. L’article interne PHPStan et Rector : industrialiser la qualité du code PHP est pertinent si votre module devient un mini-système. Et pour cadrer l’architecture (DDD/CQRS, tests, pipelines), vous pouvez recouper avec Développement PHP critique : architecture DDD, tests et CI/CD.

Enfin, prévoyez un plan de reprise (reconciliation). Concrètement : un job capable de recalculer l’ATP à partir du ledger (ou de l’état WMS) + de resynchroniser PrestaShop et les canaux. Une procédure simple (mais qui sauve des journées) :

  • recalculer on_hand (photo WMS/magasins),
  • recalculer reserved (commandes non expédiées + paniers actifs selon TTL),
  • recalculer ATP par canal (règles + safety stock + quotas),
  • reprojeter vers PrestaShop puis vers les marketplaces,
  • générer un rapport d’écarts (SKU, delta, cause).

Un inventaire unifié sans mécanisme de reconciliation, c’est juste une intégration “temps réel” qui dérive en silence jusqu’à l’incident. Si vous devez choisir où investir : mettez d’abord l’argent technique dans l’idempotence, l’outbox, la DLQ et les métriques — c’est ce qui fait tenir le routage par canal en conditions réelles.


À lire aussi