Synchronisation PrestaShop

PrestaShop est le canal de vente ; la plateforme est l'autorité sur le stock. Trois points d'entrée alimentent la plateforme depuis la boutique : la sync commandes (cron toutes les 3 h, mode bulk), la sync produits (cron toutes les 3 h, incrémentale, avec réconciliation des fournisseurs) et les webhooks temps réel — qui exécutent exactement le même chemin d'import, événement par événement. Cette page décrit ce que chaque flux écrit, ce qu'il n'écrit délibérément pas (le stock, pour la sync commandes), et comment les écritures de stock restent intégrées au ledger.

Vue d'ensemble

Flux Cadence Commande / route Écrit
Sync commandes 0 */3 * * * (toutes les 3 h) bun cli -c sync-prestashop --entities orders --bulk_mode --last_days 40 Orders, OrderItems, Clients, Users
Sync produits 0 */3 * * * (toutes les 3 h) bun cli -c sync-prestashop --entities products --incremental --link_vendors Products (prix, réfs, liens fournisseur), Vendors, catégories
Webhooks par événement POST /api/webhooks/prestashop/{customers,products,orders} même chemin que les crons, à l'unité

Les deux crons vivent sur la box du tenant (/etc/crontab, géré par Ploi) et appellent les wrappers scripts/cron/sync-prestashop-orders-latest.sh et scripts/cron/sync-prestashop-products.sh (timeout dur de 2 h chacun). La cadence complète de la journée est sur Timeline quotidienne.

Note

Le flux de données va de PrestaShop vers la plateforme. Le stock, lui, appartient à la plateforme : aucun des deux crons ne prend PrestaShop comme source de vérité du stock en production (voir plus bas RESET_STOCK_ON_SYNC).

Sync commandes — toutes les 3 h, bulk, 40 derniers jours

Wrapper : scripts/cron/sync-prestashop-orders-latest.sh

bun cli -c sync-prestashop \
  -t <tenantId> -s <siteId> \
  --bulk_mode --last_days 40 \
  --entities orders --verbose -y

Le mode bulk (packages/services-prestashop/src/prestashop/orderSyncBulk.ts) traite chaque lot dans une transaction PostgreSQL (BEGIN … COMMIT, rollback en cas d'échec) et écrit :

  • Clients — upsert en masse des clients PrestaShop (puis upsert des User associés).
  • Orders — upsert clé par invoiceNumber = String(id_order) (dupliqué dans customInputs.prestashop_id). Le modèle Order n'a pas de champ externalId — la clé de correspondance est invoiceNumber, indexé.
  • OrderItems — upsert avec des clés externes stables par ligne (deriveOrderItemExternalKeys, orderSync.ts) pour qu'une re-synchronisation mette à jour la ligne existante au lieu de créer un doublon.

Ce que la sync commandes ne fait PAS : décrémenter le stock

C'est le point le plus important de cette page. L'ancien chemin AUTO_STOCK_DECREMENT (décrément à l'import des commandes PAID/livrées) a été désactivé le 2026-06-13 puis supprimé le 2026-06-14 — le code n'existe plus. Le commentaire dans orderSyncBulk.ts est explicite :

// Step 8 (REMOVED 2026-06-14): order-sync no longer decrements stock.
// Stock is decremented on DELIVERY by the auto-validate cron — the SINGLE
// decrement engine.

Le stock se décrémente à la livraison uniquement : le scellé du marchand dans l'UI au moment de l'événement physique est le chemin primaire, et le cron auto-validate (12:01) n'est plus qu'un filet de sécurité qui scelle ce qui a été oublié. Détail complet sur Chemins d'écriture.

Attention

Ne réintroduisez jamais un décrément à l'import des commandes. L'ancien chemin double-comptait face à l'auto-validate (aucun signal d'idempotence partagé — il écrivait un StockAction mais pas d'OrderFulfillmentItem) et ne mettait jamais à jour Product.stockLedger. Si un décrément à la sync devenait un jour nécessaire, il devrait émettre OrderFulfillment/OrderFulfillmentItem et les entrées ledger, en coordination avec l'auto-validate.

Une commande importée par le cron entre dans le calcul de réappro au prochain passage du cron auto-supplier-orders (toutes les 30 min) ; les webhooks, eux, poussent un recalcul ciblé immédiat (voir plus bas). Voir Commandes fournisseurs.

Sync produits — toutes les 3 h, incrémentale

Wrapper : scripts/cron/sync-prestashop-products.sh

bun cli -c sync-prestashop \
  -t <tenantId> -s <siteId> \
  --entities products --incremental --last_days 1 \
  --link_vendors --verbose -y

importPrestashopProduct (packages/services-prestashop/src/prestashop/productSync.ts) importe pour chaque produit : nom, description, référence, price, purchasePrice (le coût — #2796, pas le prix de vente), poids/dimensions, SKU, taux de TVA, fabricant, lien defaultVendorId, catégories, composants de panier (bundles) et classification. Le JSON Product.data est fusionné (merge), jamais remplacé en bloc — même précaution que pour vendor.data ci-dessous.

Le stock : qui a la main ?

Le comportement est gouverné par la constante RESET_STOCK_ON_SYNC (packages/common/src/utils/constants.ts) — false en production :

  • RESET_STOCK_ON_SYNC=false (prod) : la sync ne touche pas à Product.stock. Le ledger du back-office est l'autorité (LEDGER_AS_SOURCE_OF_TRUTH=true). Un produit nouveau démarre à stock = 0 — le marchand saisit le stock réel côté plateforme, ce qui évite de créer un stock ≠ 0 sans ligne de ledger (une violation I5).
  • RESET_STOCK_ON_SYNC=true : la sync possède l'écriture du stock, et chaque écriture émet le StockAction correspondant (section suivante).

Écritures intégrées au ledger (#2749 / #2798)

Historiquement, la sync écrivait Product.stock brut, sans StockAction — d'où une dérive entre Product.stock et Product.stockLedger (Σ des mouvements COMPLETED). Depuis #2749, chaque écriture de stock passe son avant/après par buildPsStockLedgerEntry (packages/services-prestashop/src/lib/prestashop/stock-ledger-sync.ts, fonction pure, épinglée par ps-sync-stock-ledger.test.ts) :

Cas Résultat
manageStock = false null — produit non suivi, aucune entrée (règle 17)
delta (après − avant) = 0 null — pas de mouvement
Produit nouveau entrée OPENING_BALANCE (avant = 0)
Produit existant entrée delta PRESTASHOP_SYNC (INCREMENT ou DECREMENT)

L'entrée non nulle est émise en StockAction COMPLETED via StockLedgerService, ce qui maintient Product.stock et Product.stockLedger en parfaite correspondance (invariant I5 de l'audit). L'émission est best-effort : un échec ledger est loggé mais ne casse jamais la sync.

Le raffinement #2798 : l'émission n'a lieu que si la sync possède réellement l'écriture du stock (RESET_STOCK_ON_SYNC=true). Quand RESET est off, la sync n'émet rien — l'ancienne garde « delta = 0 → no-op naturel » était piégeuse : le stock relu après l'update captait les écritures concurrentes (cron auto-validate, éditions manuelles), et la sync se les attribuait en fantômes « Sync stock PrestaShop », double-comptant les commandes client dans le ledger.

sequenceDiagram
  participant PS as PrestaShop
  participant Sync as ProductSyncService
  participant P as Product (Prisma)
  participant L as StockLedgerService

  PS->>Sync: produit (id_product, prix, stock…)
  Sync->>P: upsert Product (prix, réfs, fournisseur, data fusionné)
  alt RESET_STOCK_ON_SYNC = true (la sync possède le stock)
    Sync->>P: écrit Product.stock
    Sync->>Sync: buildPsStockLedgerEntry(avant, après)
    alt manageStock et delta différent de 0
      Sync->>L: createStockAction COMPLETED (OPENING_BALANCE ou PRESTASHOP_SYNC)
      L->>P: stockLedger aligné (invariant I5)
    else non suivi ou delta = 0
      Sync-->>Sync: null — aucune entrée ledger
    end
  else RESET off (production)
    Sync-->>P: ne touche PAS au stock, n'émet RIEN
  end

Réconciliation des fournisseurs (--link_vendors)

--link_vendors fait bien plus que lier : depuis le correctif #2693, il exécute une réconciliation en quatre passes avant la liaison produit → fournisseur proprement dite. Tout vit dans packages/services-prestashop/src/lib/prestashop/vendors.ts (épinglé par prestashop-reconcile.test.ts), sauf la passe 4 (apps/app/src/scripts/commands/sync-prestashop.ts).

flowchart TD
  A["--link_vendors"] --> B["1. fetchVendorsLookup<br/>fabricants + fournisseurs → Map typée"]
  B --> C["2. refreshVendorStatus<br/>diff active distant vs vendor.data.active"]
  C -->|dérive détectée| C1["MERGE partiel de vendor.data<br/>jamais de remplacement en bloc"]
  C --> D["3. upsertMissingVendors<br/>clé composite externalId + vendor_type"]
  D -->|absent localement| D1["createVendorFromPrestashop"]
  D --> E["4. linkProductsToVendors<br/>filtre sentinelle id NOT IN 0 ou vide"]
  E -->|fournisseur inconnu| E1["gap-fill : création à la volée"]
  E --> F[(Produits liés aux fournisseurs)]
  1. fetchVendorsLookup — pagine les endpoints manufacturers et suppliers de PrestaShop dans une structure typée { supplier: Map, manufacturer: Map }. Deux maps séparées, pas une seule : voir l'avertissement ci-dessous.
  2. refreshVendorStatus — pour chaque fournisseur local, compare le active distant au vendor.data.active stocké. En cas de dérive, met à jour ce seul champ par un merge partiel ({ ...currentData, active }). Le matching est type-aware : un fournisseur local supplier est cherché dans la map supplier, jamais dans manufacturer (fallback sur les deux uniquement pour les lignes legacy sans vendor_type).
  3. upsertMissingVendors — crée tout fournisseur PrestaShop absent localement, clé composite (externalId, vendor_type). Un dédoublonnage par nom normalisé évite de créer deux lignes quand la même entreprise réelle est enregistrée à la fois comme supplier et comme manufacturer dans PrestaShop ; les paires réellement distinctes (noms différents) sont bien créées toutes les deux.
  4. linkProductsToVendors — sélectionne les produits candidats avec le filtre id_supplier NOT IN ('0','') OR id_manufacturer NOT IN ('0',''), puis lie chaque produit à son fournisseur. Un produit référençant un fournisseur inconnu déclenche une création à la volée (gap-fill) avec le même dédoublonnage type-aware.
Attention

Fabricants et fournisseurs sont deux espaces de noms d'ID distincts dans PrestaShop. id_manufacturer = 110 et id_supplier = 110 peuvent désigner deux entités totalement différentes — observé en production : manufacturer 110 = MILKY BREIZH, supplier 110 = TERRE HADENN. Ne réduisez jamais la lookup à une Map<externalId, vendor> plate, et ne retirez jamais le filtre vendor_type des recherches findFirst : les collisions d'ID se croisent silencieusement et les produits sont mal liés.

Attention

Ne remplacez jamais vendor.data en bloc pendant la réconciliation — fusionnez ({ ...existant, ...frais }). Ce JSON héberge aussi de l'état non-PrestaShop, notamment magicLinkToken / magicLinkExpires utilisés par l'authentification du portail fournisseur : un remplacement complet les efface et casse les liens magiques en cours.

Astuce

Le filtre sentinelle de la passe 4 a une histoire : PrestaShop utilise '0' pour « pas de fournisseur/fabricant ». L'ancien filtre IS NOT NULL comptait ces produits comme « à lier », échouait à résoudre l'ID 0, et gonflait le compteur d'erreurs (380 fausses erreurs sur un tenant réel). Ne relâchez pas le filtre vers IS NOT NULL.

Webhooks temps réel

Route : POST /api/webhooks/prestashop/{entity} avec entity ∈ {customers, products, orders} (apps/app/src/app/api/webhooks/prestashop/[entity]/route.ts). PrestaShop envoie un lot d'événements { entity, action: create|update|delete, id }.

Le point clé : les webhooks exécutent le même chemin d'import que les crons. productssyncSpecificProductimportPrestashopProduct (donc les mêmes écritures intégrées au ledger que ci-dessus) ; orderssyncSpecificOrderprocessOrderFromPrestashop ; customerssyncSpecificCustomer. Les webhooks changent la fréquence (par événement au lieu de toutes les 3 h), pas la correction — ils ne réintroduisent aucune dérive de ledger.

Authentification et garde-fous

  • Le secret arrive en clair dans l'en-tête (X-Webhook-Signature, précédence épinglée par prestashop-webhook-secret.test.ts) et est résolu contre SiteSettings sur deux clés : webhook.secret (canonique) et prestashop.webhooks.secret (écrite par une autre page de réglages — historiquement un espace de noms déconnecté que le récepteur ne lisait pas ; les deux sont acceptées désormais). Secret invalide → 401.
  • Rate limiting avec backoff exponentiel par tenant/site (429 + Retry-After).
  • Chaque événement est journalisé dans WebhookLog (received → processing → completed|failed, avec sessionId et temps de traitement) — consultable dans le back-office, page /manage/integrations/prestashop/webhooks.

Suppressions

Les delete ne suppriment jamais de lignes :

  • Produit — marqué active: false + drapeaux deleted_from_prestashop dans Product.data.
  • CommandeinvoiceStatus: "CANCELED" + annulation des StockAction PENDING de prévision de la commande (jamais les COMPLETED — un mouvement appliqué ne se renverse pas ici, règle 17).
  • Client — drapeaux deleted_from_prestashop dans customInputs.

Enchaînement sur le réappro

Après chaque événement traité, la route enfile un recalcul ciblé dans la file bext supplier-orders-recalc (best-effort, no-op si BEXT_SUPPLIER_RECALC n'est pas actif — voir Configuration). Deux subtilités de résolution d'ID, apprises à la dure :

  • Les services de sync renvoient l'ID externe PrestaShop (result.productId, result.orderId), pas l'UUID interne. Produits : résolution via Product.externalId.
  • result.orderId est bi-forme : UUID interne pour une commande existante, id_order PrestaShop pour une nouvelle. resolveRecalcOrderId gère les deux formes (#812) — avant ce correctif, les webhooks d'update de commande (le cas courant) n'enfilaient jamais de recalcul.
  • Une commande supprimée côté PrestaShop enfile aussi son recalcul : la demande libérée doit se recalculer tout de suite, pas au prochain cron filet.

Voir Commandes fournisseurs pour la suite du pipeline (file, verrou d'écriture, gates).

Commandes de vérification (smoke tests)

Après toute modification dans le rayon d'impact de ces syncs, valider le chemin complet en lecture quasi nulle :

# La CLI charge sans crash (erreurs de niveau module, init Prisma…)
cd apps/app && bun run cli --help

# Sync commandes : chemin complet, 1 enregistrement
cd apps/app && bun run cli -c sync-prestashop \
  -t <tenantId> -s <siteId> \
  --entities orders --last_days 1 --max_records 1 --verbose -y

# Sync produits : incrémentale, 1 enregistrement
cd apps/app && bun run cli -c sync-prestashop \
  -t <tenantId> -s <siteId> \
  --entities products --incremental --max_records 1 --verbose -y
Astuce

Toujours tester avec --max_records 1 avant de lancer une sync complète après un changement de code. Les tests de fumée CLI (cli-smoke.test.ts) couvrent le chargement du graphe de modules — l'une des classes de régression qui cassent silencieusement ces crons.

Voir aussi