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.
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
Userassociés). - Orders — upsert clé par
invoiceNumber = String(id_order)(dupliqué danscustomInputs.prestashop_id). Le modèleOrdern'a pas de champexternalId— la clé de correspondance estinvoiceNumber, 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.
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 unstock ≠ 0sans ligne de ledger (une violation I5).RESET_STOCK_ON_SYNC=true: la sync possède l'écriture du stock, et chaque écriture émet leStockActioncorrespondant (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)]
fetchVendorsLookup— pagine les endpointsmanufacturersetsuppliersde PrestaShop dans une structure typée{ supplier: Map, manufacturer: Map }. Deux maps séparées, pas une seule : voir l'avertissement ci-dessous.refreshVendorStatus— pour chaque fournisseur local, compare leactivedistant auvendor.data.activestocké. En cas de dérive, met à jour ce seul champ par un merge partiel ({ ...currentData, active }). Le matching est type-aware : un fournisseur localsupplierest cherché dans la mapsupplier, jamais dansmanufacturer(fallback sur les deux uniquement pour les lignes legacy sansvendor_type).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.linkProductsToVendors— sélectionne les produits candidats avec le filtreid_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.
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.
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.
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.
products → syncSpecificProduct → importPrestashopProduct (donc les mêmes écritures
intégrées au ledger que ci-dessus) ; orders → syncSpecificOrder →
processOrderFromPrestashop ; customers → syncSpecificCustomer. 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 parprestashop-webhook-secret.test.ts) et est résolu contreSiteSettingssur deux clés :webhook.secret(canonique) etprestashop.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, avecsessionIdet 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+ drapeauxdeleted_from_prestashopdansProduct.data. - Commande —
invoiceStatus: "CANCELED"+ annulation desStockActionPENDING de prévision de la commande (jamais les COMPLETED — un mouvement appliqué ne se renverse pas ici, règle 17). - Client — drapeaux
deleted_from_prestashopdanscustomInputs.
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 viaProduct.externalId. result.orderIdest bi-forme : UUID interne pour une commande existante,id_orderPrestaShop pour une nouvelle.resolveRecalcOrderIdgè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
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
- Vue d'ensemble — le modèle stock + réappro en une page
- Le ledger de stock — autorité signée, miroir bridé, invariants
- Chemins d'écriture — qui a le droit d'écrire le stock, et comment
- Commandes fournisseurs — le réappro événementiel alimenté par ces syncs
- Timeline quotidienne — la journée type des crons
- Filets de sécurité — audit I1…I5, replay, tests épinglés
- Configuration —
BEXT_SUPPLIER_RECALCet les autres gates