Table des matières :
- Ce que la Directive Omnibus impose réellement (et ce que ça implique côté code)
- Pourquoi PrestaShop 8/9 n’est pas conforme “par défaut” sur le prix antérieur
- Modéliser un historique de prix exploitable (pas “joli”, mais robuste)
- Capturer les changements de prix dans PrestaShop : hooks utiles et angles morts
- Calculer le “prix antérieur conforme” : algorithme, exceptions et fiscalité
- Afficher le prix antérieur dans le front : hooks de présentation, templates et SEO
- Performance, cache et exploitation : rendre Omnibus compatible avec un site qui vend
La conformité « Directive Omnibus » sur PrestaShop se joue sur un point très concret : quand vous affichez une réduction, vous devez pouvoir prouver et afficher un prix antérieur calculé correctement. Le problème, c’est que le cœur PrestaShop (8.x/9.x) ne conserve pas l’historique nécessaire, et son champ “prix barré” (regular price) ne correspond pas à la définition Omnibus.
En pratique, ce sujet devient vite “opérationnel” : soldes, Black Friday, promos flash, imports ERP, règles catalogue… tout ce qui modifie un prix peut vous mettre en défaut si vous n’avez pas une trace exploitable. Et côté performance, un calcul “au fil de l’eau” sur les listings peut coûter très cher.
Ce que la Directive Omnibus impose réellement (et ce que ça implique côté code)
La Directive (UE) 2019/2161 (« Omnibus ») modifie notamment la directive 98/6/CE sur l’indication des prix. Le point critique, souvent mal compris côté e-commerce : lorsqu’il y a annonce d’une réduction de prix, le “prix de référence” à afficher n’est pas “le prix catalogue” ni “le prix avant la promo courante”, mais le prix le plus bas pratiqué sur une période d’au moins 30 jours avant l’application de la réduction (principe introduit via l’article 6 bis/6a selon les versions linguistiques et consolidations).
Conséquence directe : beaucoup d’affichages standards du type “prix barré = prix de base” sont insuffisants, notamment si vous avez déjà fait une baisse de prix dans les 30 jours (même si vous remontez ensuite le prix, ou si vous enchaînez plusieurs promos).
Techniquement, ça veut dire que l’affichage conforme n’est pas une question de “front”, mais de traçabilité :
- vous devez être capable de retrouver ce “plus bas prix 30 jours” à partir d’un historique fiable ;
- vous devez le faire dans le même contexte d’affichage que le prix remisé (devise, taxes/pays, groupe client, boutique) ;
- vous devez pouvoir justifier le chiffre si on vous demande “comment vous l’avez calculé ?”.
Sur un mono-pays / mono-devise, c’est déjà non trivial (arrondis, taxes, combinaisons, règles panier…). En multi-boutique/multi-devise, sans persistance dédiée, c’est juste impossible à garantir de manière robuste.
Références (textes) :
Pourquoi PrestaShop 8/9 n’est pas conforme “par défaut” sur le prix antérieur
Dans PrestaShop, le “prix barré” affiché dans les thèmes est généralement dérivé de la logique “prix de base vs prix remisé” (ex : regular_price vs price dans le ProductLazyArray, et/ou calculs Product::getPriceStatic()). Sauf que cette valeur ne représente pas “le plus bas prix des 30 derniers jours” : c’est souvent le prix catalogue (ou le prix avant application d’un specific price), ce qui est insuffisant au regard d’Omnibus.
Pourquoi le cœur ne peut pas “deviner” le bon prix antérieur :
- PrestaShop ne stocke pas l’historique des prix réellement affichés (prix effectifs) : il conserve un état courant (
ps_product_shop.price) et des règles (ps_specific_price,ps_specific_price_rule, etc.), mais pas un journal horodaté du résultat final. - Reconstituer l’historique depuis les règles est fragile :
- chevauchements de règles (catalogue + spécifique + panier),
- imports qui écrasent des valeurs (prix, dates, remises),
- changements de contexte (taxes par pays, groupes clients, devise et arrondis).
- La définition Omnibus vise le “prix appliqué” au consommateur. Or, en boutique, ce que “voit” un client dépend souvent de plusieurs couches (tax rules, devise, groupes, arrondis, écotaxe, etc.). Si vous ne figez pas un prix effectif, vous ne figez pas une preuve.
Enfin, PrestaShop est très “chatty” côté calcul de prix : le prix est recalculé en boucle sur les listings, le panier, la fiche produit, les produits associés, etc. Injecter une requête “MIN(last30days)” à chaque appel est une mauvaise idée : vous allez créer du N+1, exploser le TTFB et finir par activer du cache “agressif” qui… peut afficher des prix antérieurs obsolètes.
Si vous êtes déjà en mode cache multi-niveaux (Redis/Varnish), l’invalidation au bon moment devient critique (voir : Cache PrestaShop : Varnish, Redis, Memcached et OPcache côté serveur et Configurer Redis pour PrestaShop sur VPS ou dédié).
Modéliser un historique de prix exploitable (pas “joli”, mais robuste)
Pré-requis : exemples ci-dessous pensés pour PrestaShop 8.1–9.1 et PHP 8.1+. Si vous êtes en multi-boutique, vous devez intégrer id_shop dès le départ. Et si vous êtes en multi-devise / multi-pays, vous devez décider quel niveau de granularité vous visez (conformité locale stricte vs approximation).
Le modèle le plus pragmatique consiste à stocker, à chaque “événement prix”, un enregistrement du prix effectif (celui que vous affichez au client) avec timestamp. Exemple de table (MySQL/MariaDB) :
CREATE TABLE ps_omnibus_price_event (
id_omnibus_price_event BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
id_shop INT UNSIGNED NOT NULL,
id_product INT UNSIGNED NOT NULL,
id_product_attribute INT UNSIGNED NOT NULL DEFAULT 0,
id_currency INT UNSIGNED NOT NULL,
id_country INT UNSIGNED NOT NULL,
id_group INT UNSIGNED NOT NULL,
price_tax_incl DECIMAL(20,6) NOT NULL,
created_at DATETIME(6) NOT NULL,
PRIMARY KEY (id_omnibus_price_event),
KEY idx_lookup (id_shop, id_product, id_product_attribute, id_currency, id_country, id_group, created_at),
KEY idx_window (created_at)
) ENGINE=InnoDB;
Pourquoi autant de dimensions ? Parce qu’en pratique, le “prix effectif” varie :
- Taxes : le prix TTC dépend du pays (ex : différence FR/BE, DOM, etc.), et parfois de règles de taxe spécifiques.
- Devise : conversion + arrondis = un minimum sur 30 jours peut diverger (ex : 19,99 € peut devenir 20,01 $ selon paramétrage et arrondis).
- Groupes : en B2B, les prix affichés peuvent être HT et/ou distincts ; dans ce cas, votre “prix antérieur” doit être cohérent avec ce que vous affichez au même public.
Si vous n’avez qu’un pays, une devise, un groupe (cas fréquent), vous pouvez réduire la table (et donc les index). Mais faites-le explicitement, et documentez le périmètre : Omnibus, c’est aussi “être capable de justifier”.
Deux recommandations “terrain” pour éviter une table ingérable :
- Concentrez-vous sur les contextes réellement exposés : par exemple “FR + EUR + groupe visiteurs” si c’est votre affichage public principal. Vous pouvez garder une stratégie “stricte” pour le B2C public, et une autre (interne) pour le reste.
- Fixez une politique de rétention (ex : 400 jours) : Omnibus parle de 30 jours, mais garder un peu plus aide à investiguer (erreurs d’import, périodes promotionnelles). Une purge planifiée limite la croissance.
Mini-ordre de grandeur : avec 10 000 produits actifs, une snapshot quotidienne sur 60 jours = 600 000 lignes (sans déclinaisons). Avec déclinaisons, ça grimpe vite : d’où l’intérêt de choisir vos dimensions et d’agréger.
Capturer les changements de prix dans PrestaShop : hooks utiles et angles morts
Le piège classique : vous captez actionObjectProductUpdateAfter et vous pensez que c’est fini. Sauf que la plupart des baisses de prix viennent des specific prices (promotions datées, prix par groupe, etc.), et elles ne passent pas nécessairement par une mise à jour du produit.
Sur PrestaShop 8/9, en module, vous allez généralement couvrir :
actionObjectProductAddAfter/actionObjectProductUpdateAfteractionObjectSpecificPriceAddAfter/actionObjectSpecificPriceUpdateAfter/actionObjectSpecificPriceDeleteAfter- éventuellement
actionObjectSpecificPriceRuleAddAfter/ update / delete (selon votre usage des règles catalogues)
Pour ne pas rater les cas “réels” en production, pensez aussi à ces déclencheurs indirects (selon votre stack) :
- changements de taxe (tax rules group) si vous affichez TTC public ;
- activation/désactivation produit (un prix “non affiché” n’a pas le même enjeu, mais attention aux retours en ligne) ;
- création/suppression de déclinaisons (
id_product_attribute) si votre thème affiche des prix par déclinaison.
À chaque événement, vous snapshotez le prix effectif pour les contextes que vous supportez (ex : boutique X, FR, EUR, groupe “visiteurs”). Concrètement, vous recalculerez via Product::getPriceStatic() en forçant le contexte, puis vous insérez un event.
Un format simple de décision (utile en audit) est de documenter “quoi snapshot et pourquoi” :
| Source du changement | Exemple courant | Ce qu’on enregistre |
|---|---|---|
Prix produit (ps_product_shop.price) |
import ERP, mise à jour BO | prix effectif recalculé |
| Specific price | promo datée -20% | prix effectif recalculé + timestamp |
| Règle catalogue | remise catégorie pendant 10 jours | prix effectif recalculé (souvent en batch) |
| Arrondis/devise | changement de règle d’arrondi | idéalement re-snapshot batch |
Angle mort n°1 : les imports (CSV, ERP, scripts) qui écrasent des tables en masse. Angle mort n°2 : les jobs qui régénèrent des specific prices (règles catalogues) sans déclencher vos hooks comme vous l’attendez.
La seule protection sérieuse : ajouter une snapshot quotidienne (cron/CLI) qui enregistre le prix effectif des produits “actifs” au moins une fois par 24h. Ça coûte du CPU/SQL, mais ça vous donne une base de calcul stable et auditable.
Pour industrialiser ce type de job, vous pouvez vous appuyer sur les commandes CLI natives (voir : Commandes CLI PrestaShop : liste, catégories et options d’aide) et intégrer une commande Symfony omnibus:snapshot --shop=1 --country=FR --currency=EUR.
Scénario concret (FR, e-commerce mode) : vous planifiez une promo “-30%” pour un produit du 10 au 15. Le 9 au soir, un import met à jour le prix de base. Si vous ne snapshottez que lors de la création de la promo, vous risquez d’afficher un “prix antérieur” basé sur un état incomplet. Une snapshot quotidienne (ou au minimum avant les grosses opérations promotionnelles) limite ce type de divergence.
Calculer le “prix antérieur conforme” : algorithme, exceptions et fiscalité
L’algorithme Omnibus “minimal” est : minimum du prix effectif sur une fenêtre glissante de 30 jours avant le début de la réduction (ou “avant maintenant”, selon votre stratégie et votre lecture des textes locaux). En pratique, on retient souvent : created_at >= NOW() - INTERVAL 30 DAY et on calcule MIN(price_tax_incl).
Exemple SQL (pour un contexte figé) :
SELECT MIN(price_tax_incl) AS min_30d
FROM ps_omnibus_price_event
WHERE id_shop = :id_shop
AND id_product = :id_product
AND id_product_attribute = :id_pa
AND id_currency = :id_currency
AND id_country = :id_country
AND id_group = :id_group
AND created_at >= (NOW() - INTERVAL 30 DAY);
Deux précisions importantes (qui font souvent la différence entre “ça marche” et “c’est défendable”) :
- Fenêtre par rapport à quoi ? Idéalement, par rapport au début de la réduction (date “from” d’un
specific_price). Si vous calculez “par rapport à maintenant”, un produit en promo depuis 10 jours peut voir son minimum “contaminé” par le prix déjà réduit (selon comment vous snapshottez). Une solution robuste consiste à passer une date de référence:as_of(début promo) et à calculercreated_at >= (:as_of - INTERVAL 30 DAY) AND created_at < :as_of. - Fuseau horaire : si votre boutique est en Europe/Paris mais que votre DB est en UTC, standardisez (ou stockez en UTC) pour éviter des bords de fenêtre incohérents à J-30.
Cas limites que vous devez trancher (et idéalement rendre configurables) :
1) Produit mis en vente depuis moins de 30 jours : vous ne pouvez pas avoir 30 jours d’historique. Le principe appliqué le plus souvent est d’utiliser le plus bas prix sur l’historique disponible (depuis la mise en vente / depuis la première date observée). Implémentation simple : si moins de N événements, min sur tout.
2) Réductions successives (progressives) : certains régimes et pratiques considèrent qu’en cas de remises successives sur une courte période, le prix de référence doit rester celui avant la première réduction, pour éviter le “rebasage” artificiel. Ne codez pas ça en dur : ajoutez un mode progressive_discounts=on/off ou une durée “anti-rebase” (ex : 30 jours depuis la première remise).
3) Taxes et groupes : si vous calculez en HT mais affichez du TTC, vous fabriquez des incohérences (et potentiellement un “min” incorrect si un taux change). Pour du B2C, stocker directement price_tax_incl dans le contexte cible est le plus simple. Pour du multi-pays, stocker un historique TTC par pays est le plus “propre”.
4) Déclinaisons : si votre boutique vend majoritairement via déclinaisons (tailles/couleurs) avec impacts de prix, calculez le min par id_product_attribute. Afficher un “prix antérieur” du produit parent alors que la déclinaison a un prix différent est une source classique de non-conformité perçue (et de litiges clients).
Afficher le prix antérieur dans le front : hooks de présentation, templates et SEO
Côté front, l’objectif est d’injecter une info “omnibus” dans l’objet présenté plutôt que de patcher Smarty en direct avec des requêtes SQL. Sur PrestaShop 1.7+ (donc 8/9), les hooks actionPresentProduct et actionPresentProductListing sont faits pour ça : vous récupérez le produit présenté (lazy array), vous calculez/récupérez omnibus_previous_price, et vous l’ajoutez dans le tableau.
Schéma d’approche :
- Dans le hook, si le produit n’a pas de réduction (
has_discountfaux), vous ne faites rien. - Sinon, vous récupérez le “min 30j” depuis votre table (ou un cache applicatif), vous formatez avec
PriceFormatter, et vous exposezomnibus.previous_price+ un libellé. - Dans le thème (ex :
templates/catalog/_partials/product-prices.tpldu Classic), vous affichez une ligne dédiée : « Prix le plus bas 30 jours : XX,XX € ».
Deux conseils UX utiles (et généralement “safe” côté thème) :
- Libellé stable et explicite : évitez un label ambigu (“prix de référence”), préférez une formulation opérationnelle (“Prix le plus bas des 30 derniers jours”). En cas de traduction, gardez le même sens.
- Ne pas l’afficher si vous n’avez pas de donnée : si votre table est vide (nouveau produit, bug de snapshot), afficher un nombre “estimé” est pire que ne rien afficher. Mieux vaut un mode “fail closed” (ne pas afficher la mention) + alerte back-office/log.
Le point SEO : n’essayez pas de bricoler des microdonnées Schema.org “ancien prix” n’importe comment. Offer connaît price et priceValidUntil, mais pas un champ standard universel pour “lowest price last 30 days”. Gardez l’information en HTML (visible), et assurez-vous que vos pages restent stables (pas de CLS) : une ligne de prix qui apparaît/disparaît en JS après coup est un classique des thèmes bricolés.
Sur les boutiques avec cache et forte charge, évitez l’hydratation tardive et les recalculs côté navigateur (voir aussi : Core Web Vitals : actions concrètes pour améliorer LCP, INP et CLS).
Performance, cache et exploitation : rendre Omnibus compatible avec un site qui vend
Si vous calculez le minimum 30 jours “à la demande” sur chaque carte produit d’une catégorie, vous allez créer une charge SQL qui monte vite. Sur un listing de 48 produits, c’est 48 requêtes MIN si vous ne batcher pas. La solution praticable : pré-calculer.
Deux patterns qui fonctionnent en prod :
- Table de matérialisation
ps_omnibus_price_min30mise à jour via cron/CLI (ou au moment des événements), avec une ligne par (shop, product, pa, country, currency, group). Le front ne fait qu’un lookup simple (ou un chargement batch par liste d’IDs). - Cache Redis clé/valeur sur le min30, TTL court + invalidation sur événement prix. Attention : l’invalidation est plus difficile que le TTL. Si vous partez sur Redis, faites-le proprement (voir : Redis PHP : activer l’extension, choisir les bases et surveiller l’usage).
Une approche hybride est souvent la plus stable :
- une matérialisation “source de vérité” (DB) recalculée en batch (ex : toutes les nuits + à chaque gros import),
- un cache Redis en frontal avec TTL court (ex : 15–60 minutes) pour absorber les pics.
Avant de déployer, activez l’observabilité SQL. Une requête MIN mal indexée sur une table d’événements qui grossit va se transformer en “full scan” sous charge. Mettez en place le slow query log et vérifiez vos plans avec EXPLAIN (voir : Activer le slow query log MySQL pour diagnostiquer PrestaShop).
Checklist “exploitation” (courte, mais utile) avant une période promo :
- vérifier que la snapshot quotidienne tourne (dernier
created_atrécent) ; - vérifier que vos index sont utilisés (
EXPLAIN) sur 2–3 produits représentatifs ; - tester un produit avec plusieurs promos dans les 30 jours (cas le plus risqué) ;
- tester 1 listing catégorie + 1 page produit en mode cache ON (Varnish/Redis) ;
- vérifier le rendu mobile (pas de saut de mise en page lié à la ligne Omnibus).
Dernier point : la conformité se casse souvent après une “petite modif” de thème, une mise à jour module, ou une règle promo ajoutée en urgence pour les soldes. Traitez ça comme un changement sensible : staging, checklist tunnel + prix, et tests de non-régression (voir : Checklist de contrôle PrestaShop après modification : tunnel & règles panier et, côté charge, Audit performance PrestaShop : méthode en 6 étapes reproductibles).
Avertissement opérationnel (volontairement brutal) : si vous ne stockez pas un historique de prix, vous ne “corrigez” pas Omnibus, vous improvisez un prix antérieur. Et si vous le calculez à la volée sans stratégie de cache/agrégation, vous dégradez le site.
La bonne approche est donc : persister → matérialiser → exposer via hooks de présentation → tester et monitorer.




