Rien de pire qu’un panier prêt à payer, stoppé net par le message annonçant qu’aucun transporteur ne peut être appliqué. Ce blocage a beau sembler unique, PrestaShop enchaîne derrière, dans son cœur, toute une série de conditions, et une seule qui lâche bloque votre client.
Tout est écrit dans la classe Carrier, aussi bien en 1.7 qu’en 8.x et en 9.x, et quelques requêtes suffisent à les passer en revue, chacune son tour (sur une boutique inconnue, tablez sur une heure). Et si le diagnostic s’arrête en route, confiez-moi l’audit de votre configuration logistique.
Les conditions que le cœur applique réellement
Le back-office attendra, je commence par poser la liste des tests que font Carrier::getCarriers et Carrier::getCarriersForOrder, six points en tout, pas un de plus, chacun pointant vers une table bien identifiée.
Six vérifications en cascade
Côté transporteur, il faut deleted sur 0 avec active sur 1, sa ligne de ps_carrier_shop côté boutique en cours, sa ligne dans ps_carrier_zone braquée sur la zone qui abrite votre client, cette zone devant être active elle aussi, sa ligne dans ps_carrier_group pour l’un des groupes auxquels il appartient, et pour finir un tarif renseigné dans ps_delivery pour cette zone (celle-là, on l’oublie plus que les autres). Six points, six occasions de tomber.
Contrôler l’ensemble en une requête
Une seule lecture, et elle vous désigne du premier coup d’œil l’association manquante, tout en vous épargnant un aller-retour sans fin entre les onglets du back-office. Personnellement, je commence toujours par elle, bien avant d’aller voir les réglages de tranches (toujours dans cet ordre). En quelques secondes, la moitié des pistes s’écroule.
/* Préfixe ps_ par défaut : remplacez-le par le préfixe réel de votre boutique.
Remplacez 8 par l'identifiant de la zone du client (International > Zones)
et 1 par l'identifiant de la boutique concernée. */
SELECT c.id_carrier,
c.name,
c.active,
c.deleted,
c.is_module,
c.need_range,
c.shipping_method, /* 0 réglage boutique, 1 au poids, 2 au prix, 3 gratuit */
c.range_behavior, /* 0 prendre la tranche la plus haute, 1 désactiver le transporteur */
cs.id_shop,
cz.id_zone,
z.active AS zone_active
FROM ps_carrier c
LEFT JOIN ps_carrier_shop cs ON cs.id_carrier = c.id_carrier AND cs.id_shop = 1
LEFT JOIN ps_carrier_zone cz ON cz.id_carrier = c.id_carrier AND cz.id_zone = 8
LEFT JOIN ps_zone z ON z.id_zone = cz.id_zone
WHERE c.deleted = 0
ORDER BY c.position;
/* Un NULL dans cs.id_shop, cz.id_zone ou zone_active suffit à faire
disparaître le transporteur du tunnel : le coeur exige la présence
de la ligne d'association et une zone active. */
Les groupes clients, cause silencieuse
Voilà le coupable que je débusque le plus souvent, et aussi le plus silencieux, parce qu’aucun écran ne vous prévient jamais qu’un transporteur n’est ouvert à aucun groupe d’acheteurs.
Le cas du visiteur non identifié
Client connecté, ce sont ses vrais groupes qui sont lus. Visiteur non identifié, le cœur s’en tient à un seul, celui que désigne la clé de configuration PS_UNIDENTIFIED_GROUP. Du coup, un transporteur ouvert au seul groupe Client s’évapore dès que votre tunnel accepte la commande invité, et jamais votre propre compte ne vous le montrera (jamais).
Lire les autorisations en base
Tout passe par la table de liaison ps_carrier_group. Absent de cette table pour chacun des groupes, un transporteur devient invisible pour tout le monde, administrateur inclus quand il teste depuis son propre compte à lui. Je l’ai vue se vider en silence après un simple import de configuration (l’import écrase ce qui existait). Allez la regarder en priorité.
/* Groupes clients autorisés pour chaque transporteur, id_lang à adapter. */
SELECT c.id_carrier, c.name, cg.id_group, gl.name AS groupe
FROM ps_carrier c
LEFT JOIN ps_carrier_group cg ON cg.id_carrier = c.id_carrier
LEFT JOIN ps_group_lang gl ON gl.id_group = cg.id_group AND gl.id_lang = 1
WHERE c.deleted = 0
ORDER BY c.name, gl.name;
/* Un transporteur sans aucune ligne dans ps_carrier_group n'est proposé
à personne. Pour un visiteur non connecté, le tunnel n'interroge qu'un
seul groupe, celui désigné par PS_UNIDENTIFIED_GROUP. */
SELECT name, value
FROM ps_configuration
WHERE name IN ('PS_UNIDENTIFIED_GROUP', 'PS_GUEST_GROUP', 'PS_CUSTOMER_GROUP');
Tranches de poids et grille de prix
Sous le mot tranche se cachent deux mécanismes différents, et je vous préviens, les mélanger coûte des heures.
La grille avant la tranche
Facturation au poids ? Le cœur se met alors en quête d’une ligne de prix, n’importe laquelle, sur la zone de votre client. Aucune ligne, et il jette le transporteur sur-le-champ, sans même regarder le poids du panier. Typiquement, vous avez coché une zone sans jamais la tarifer (j’en vois souvent).
Le comportement hors tranches
La colonne range_behavior de ps_carrier n’entre en scène qu’après. Valeur 0, vous héritez du tarif de la dernière tranche, valeur 1, le transporteur s’éteint. Du côté droit, la borne est stricte, donc une tranche allant de 0 à 5 refuse un panier qui pèse exactement 5 kg, il faut qu’une autre tranche démarre pile à cette valeur. Repassez chaque borne, l’une après l’autre (fastidieux, je sais).
/* Remplacez 2 par l'identifiant du transporteur à auditer. */
/* Tranches de poids. Le coeur retient une tranche quand
poids >= delimiter1 ET poids < delimiter2 : une tranche 0-1 suivie
d'une tranche 2-5 laisse un trou réel entre 1 et 2. */
SELECT id_range_weight, delimiter1, delimiter2
FROM ps_range_weight
WHERE id_carrier = 2
ORDER BY delimiter1;
/* Grille de prix effectivement saisie, zone par zone. Un transporteur
facturé au poids et dépourvu de toute ligne ps_delivery pour la zone
du client est écarté avant même le calcul de la tranche. */
SELECT d.id_zone,
z.name AS zone,
COUNT(*) AS lignes_de_prix,
MIN(rw.delimiter1) AS poids_min,
MAX(rw.delimiter2) AS poids_max
FROM ps_delivery d
INNER JOIN ps_range_weight rw ON rw.id_range_weight = d.id_range_weight
INNER JOIN ps_zone z ON z.id_zone = d.id_zone
WHERE d.id_carrier = 2
GROUP BY d.id_zone, z.name
ORDER BY z.name;
Ce que fait vraiment un produit sans poids
On raconte un peu partout qu’un article sans poids empêche la livraison. Je suis allé lire le code, c’est plus nuancé que ça, et cette nuance déplace la correction à appliquer.
Un poids nul n’exclut personne directement
Le transporteur ne vérifie son gabarit maximal qu’à partir du moment où un poids strictement positif, ou une dimension, est déclaré côté produit ou côté panier. Un catalogue intégralement à zéro franchit ces tests sans jamais se faire filtrer. Votre catalogue est entièrement dématérialisé ? Oubliez les poids bidon et allez plutôt supprimer carrément l’étape de livraison.
Le vrai effet, par ricochet
Là où ça coince, c’est que votre panier pèse alors zéro, et quand la première tranche démarre plus haut avec range_behavior à 1, le panier passe à côté de la grille en emportant le transporteur avec lui. D’ailleurs, une déclinaison empile son poids sur celui du produit plutôt que de s’y substituer, d’où ces catalogues dans lesquels seules certaines combinaisons déraillent. Ce cumul-là, vous le retrouvez derrière le poids du colis qui part à l’expédition, et c’est justement par là que vous contrôlerez sans effort ce qui est calculé pour de bon sur une commande déjà enregistrée.
/* Produits actifs au poids nul, id_lang et id_shop à adapter. */
SELECT p.id_product, pl.name, p.weight, p.width, p.height, p.depth
FROM ps_product p
INNER JOIN ps_product_lang pl
ON pl.id_product = p.id_product AND pl.id_lang = 1 AND pl.id_shop = 1
WHERE p.active = 1 AND p.weight = 0
ORDER BY pl.name
LIMIT 200;
/* Le poids d'une déclinaison s'ajoute à celui du produit, il ne le remplace
pas : un produit à zéro reste à zéro si sa déclinaison est aussi à zéro. */
SELECT pa.id_product, pa.id_product_attribute, pa.reference, pa.weight
FROM ps_product_attribute pa
WHERE pa.weight = 0
LIMIT 200;
/* Correction en masse, à n'exécuter qu'après sauvegarde et sur une
sélection maîtrisée. 0.5 kg est ici une valeur d'exemple. */
-- UPDATE ps_product SET weight = 0.5 WHERE active = 1 AND weight = 0;
Entrepôts, multiboutique et restriction par produit
Trois réglages plus confidentiels recrachent mot pour mot ce même message, et aucun des trois ne s’affiche sur la fiche du transporteur en cause. En général, je ne les vois frapper qu’une partie du catalogue, jamais tout.
Association entrepôt et transporteur
Gestion avancée des stocks activée, je vais lire ps_warehouse_carrier, qui dit quels transporteurs ont le droit d’expédier depuis tel ou tel entrepôt. Mettez des articles dans un entrepôt auquel nul transporteur n’est rattaché, et plus rien n’en sortira, sans que la fiche transporteur vous souffle quoi que ce soit.
Transporteurs autorisés par produit
Dans une fiche produit, l’onglet Livraison vient alimenter ps_product_carrier, et à partir du moment où un produit y inscrit des transporteurs, la liste devient exclusive pour lui. Le piège, c’est que tout se raccroche à sa référence et pas à son identifiant, sachant qu’un enregistrement depuis le back-office fabrique un identifiant tout neuf sans toucher à la référence de départ (oui, vous avez bien lu). Ça pique.
/* Gestion avancée des stocks : chaque entrepôt doit être associé aux
transporteurs autorisés à en expédier la marchandise. */
SELECT w.id_warehouse,
w.name AS entrepot,
wc.id_carrier,
c.name AS transporteur
FROM ps_warehouse w
LEFT JOIN ps_warehouse_carrier wc ON wc.id_warehouse = w.id_warehouse
LEFT JOIN ps_carrier c ON c.id_carrier = wc.id_carrier
WHERE w.deleted = 0
ORDER BY w.name;
/* Restriction posée au niveau du produit. Attention, la jointure se fait
sur id_carrier_reference et non sur id_carrier : un transporteur modifié
depuis le back-office change d'identifiant mais garde sa référence. */
SELECT pc.id_product, pc.id_shop, pc.id_carrier_reference, c.name
FROM ps_product_carrier pc
LEFT JOIN ps_carrier c ON c.id_reference = pc.id_carrier_reference
AND c.deleted = 0 AND c.active = 1
ORDER BY pc.id_product
LIMIT 200;
Surcharges et modules de transport
Associations toutes propres ? Alors je vais voir ce qu’on a empilé par-dessus le cœur. Je démarre toujours par les modules de points relais, puis par les fichiers de surcharge, mes suspects attitrés.
Repérer ce qui réécrit la logique
Un fichier posé dans override/classes/Carrier.php ou dans override/classes/Cart.php a de quoi réécrire la sélection de bout en bout (une reprise de site, et c’est la première chose que j’ouvre). Quant aux modules transporteurs, ils calculent leur prix chacun de leur côté, et un prix rendu invalide fait sauter le transporteur à la toute dernière seconde, une fois toutes les associations validées. Rien ne déroute davantage. Votre configuration a pourtant l’air impeccable.
# Depuis la racine de la boutique. # Surcharges déposées dans override/ : une seule redéfinition de Carrier # ou de Cart suffit à réécrire la sélection des transporteurs. find override/ -type f -name "*.php" ! -name "index.php" -print # Contenu des surcharges qui touchent directement la logique de livraison grep -rn --include="*.php" \ -e "getCarriersForOrder" \ -e "getAvailableCarrierList" \ -e "getDeliveryOptionList" \ -e "getPackageShippingCost" \ override/ modules/ # Désactiver un module de transport pour isoler le coupable php bin/console prestashop:module disable nom_du_module # Purge des caches après chaque désactivation, sinon le test ne vaut rien rm -rf var/cache/prod/* var/cache/dev/*
Rejouer la sélection sur un panier réel
Arrêtez de deviner. Faites plutôt rejouer la sélection par le cœur lui-même, sur un vrai panier abandonné, et ce qui remonte vous liste chaque transporteur recalé, motif compris.
<?php
/**
* Diagnostic d'un panier réel.
* Déposez ce fichier à la racine de la boutique sous le nom diag-transporteurs.php
* puis lancez-le en SSH : php diag-transporteurs.php 1234
* où 1234 est l'identifiant du panier (table ps_cart, colonne id_cart).
* Supprimez le fichier une fois le diagnostic terminé.
*/
require dirname(__FILE__) . '/config/config.inc.php';
$idCart = isset($argv[1]) ? (int) $argv[1] : 0;
$cart = new Cart($idCart);
if (!Validate::isLoadedObject($cart)) {
exit("Panier introuvable\n");
}
$context = Context::getContext();
$context->cart = $cart;
$idAdresse = (int) $cart->id_address_delivery;
$idZone = $idAdresse
? (int) Address::getZoneById($idAdresse)
: (int) (new Country((int) Configuration::get('PS_COUNTRY_DEFAULT')))->id_zone;
$customer = new Customer((int) $cart->id_customer);
$groupes = $customer->id
? $customer->getGroups()
: [(int) Configuration::get('PS_UNIDENTIFIED_GROUP')];
printf("Panier : %d\n", $cart->id);
printf("Adresse livraison : %d\n", $idAdresse);
printf("Zone : %d\n", $idZone);
printf("Pays actif : %s\n", $idAdresse && Address::isCountryActiveById($idAdresse) ? 'oui' : 'non');
printf("Groupes client : %s\n", implode(', ', $groupes));
printf("Poids total : %s\n", $cart->getTotalWeight());
/* getCarriersForOrder remplit le tableau d'erreurs avec l'identifiant du
transporteur écarté et le motif : 1 pour une tranche de poids non couverte,
2 pour une tranche de prix non couverte. */
$erreurs = [];
$transporteurs = Carrier::getCarriersForOrder($idZone, $groupes, $cart, $erreurs);
echo "\nTransporteurs retenus :\n";
foreach ($transporteurs as $transporteur) {
printf(" %d %s\n", $transporteur['id_carrier'], $transporteur['name']);
}
echo "\nTransporteurs écartés :\n";
foreach ($erreurs as $idCarrier => $motif) {
printf(" %d motif %d\n", $idCarrier, $motif);
}
Valider la correction sans se mentir
Vos changements en base resteront invisibles aussi longtemps que les caches tiendront, et la moitié des mauvais diagnostics qui me passent entre les mains partent précisément de là.
Purger avant de conclure
Le temps d’une requête, PrestaShop retient quel transporteur va avec quelle zone et quel panier, pendant que Smarty, lui, garde les gabarits du tunnel déjà compilés. Je les balaye tous les deux avant chaque essai (les deux, pas un), sans quoi c’est l’état d’avant que vous mesurez.
# Purge complète des caches, depuis la racine de la boutique. # Smarty est compilé sous var/cache/<environnement>/smarty/ rm -rf var/cache/prod/* var/cache/dev/* # Relance de la compilation et vérification que les fichiers se régénèrent php bin/console cache:warmup --env=prod 2>/dev/null || true ls -la var/cache/prod/ | head # Contrôle du choix de livraison réellement enregistré sur un panier de test. # delivery_option contient le tableau sérialisé des options retenues. mysql -u UTILISATEUR -p NOM_BASE -e \ "SELECT id_cart, id_carrier, delivery_option FROM ps_cart ORDER BY id_cart DESC LIMIT 5;"
Tester dans les conditions du client
Puis refaites passer une commande entière en fenêtre privée, d’abord anonyme, puis connecté sur le groupe qui pose problème. C’est aussi là que les points de friction du parcours sautent aux yeux, et que se dessinent des chantiers plus vastes, comme améliorer le parcours d’achat. Un bémol pour finir. Ce que je décris, c’est le fonctionnement du cœur, et pas les choix que prend, dans son coin, un module de points relais. Pour celui-là, il faudra aller lire son code (chiffré parfois, et vous voilà coincé). Ces recettes de tunnel reviennent souvent dans ce que j’écris sur le blog, et une logistique franchement compliquée finit parfois en développement sur mesure.
Questions fréquentes
Pourquoi le message apparaît-il alors que mes transporteurs sont bien créés ?
Parce qu’il ne suffit pas de les créer. Il réclame en prime un rattachement côté boutique, un deuxième côté zone de livraison de l’acheteur, un troisième vers l’un des groupes où celui-ci est rangé, et par-dessus un tarif renseigné sur cette même zone. Qu’il en manque une seule et le transporteur saute, sans message particulier. Ma toute première requête les contrôle d’une seule traite.
Comment vérifier qu’un pays est bien rattaché à une zone desservie ?
Je commence par ps_country et ses colonnes active et id_zone. Pays inactif, aucune livraison. Il part ensuite relever dans ps_carrier_zone quels transporteurs desservent la zone à laquelle ce pays appartient, à condition que cette zone soit elle-même active du côté de ps_zone. Cochez-la sur la fiche transporteur tout en la laissant éteinte dans International, et le résultat sera identique à celui d’une zone jamais cochée.
Pourquoi certains clients ne voient-ils pas les mêmes options de livraison ?
Ces écarts viennent presque tous du filtrage par groupe. Le client connecté est jugé sur ses groupes réels, le visiteur anonyme sur l’unique groupe que pointe la clé PS_UNIDENTIFIED_GROUP dans la configuration. Avec un compte d’administration rangé dans un groupe à part, l’affichage que reçoit l’acheteur ordinaire vous échappera toujours. Faites vos essais en fenêtre privée, sur un compte du même profil que vos acheteurs.
Un trou entre deux tranches de poids suffit-il à faire disparaître le transporteur ?
Oui, à condition que le comportement hors tranches soit calé sur la désactivation, soit la valeur 1 dans la colonne range_behavior. Une tranche s’applique dès que le poids atteint ou dépasse delimiter1 tout en restant strictement sous delimiter2. Du coup, enchaîner une tranche de 0 à 5 puis une de 5 à 10 ne laisse aucun trou, alors qu’un panier à 10 kg pile se retrouve hors grille dès que rien ne monte plus haut.
Un produit sans poids bloque-t-il la commande ?
Pas directement. Le gabarit n’est examiné qu’en présence d’un poids ou d’une dimension strictement positifs, donc le produit resté à zéro file au travers sans jamais se faire filtrer. L’effet est indirect : votre panier affiche alors zéro, et quand la première tranche commence plus haut avec le hors-tranches réglé sur désactivation, votre transporteur dégage. Remplissez vos poids quand même, c’est plus sain, mais la cause de fond est à chercher ailleurs.
Que vérifier en multiboutique et en gestion avancée des stocks ?
En multiboutique, impossible de faire l’impasse sur la ligne de ps_carrier_shop correspondant à votre boutique. Côté gestion avancée des stocks, ps_warehouse_carrier dit quels transporteurs desservent tel entrepôt, et ce qui dort dans un entrepôt privé de transporteur ne partira jamais. Jetez aussi un œil à ps_product_carrier, puisqu’un produit qui y pose sa liste de transporteurs la rend exclusive pour lui.