Webservice PrestaShop 9.2 : le champ client qui disparaît de /api/customers

Par Yannick Liebnau

13 min de lecture

Webservice PrestaShop 9.2 : le champ client qui disparaît de /api/customers

Une boutique B2B passe sur PrestaShop 9.2. Les comptes sont là, les commandes aussi, le front fonctionne. Mais l’ERP, qui synchronise les comptes professionnels par le webservice PrestaShop natif (pas de module, juste une clé d’API), perdrait sa clé de rapprochement : un code client stocké dans un champ custom, que /api/customers exposait sur le legacy. Le champ n’apparaît plus dans les réponses, et ce que l’ERP y écrirait n’irait nulle part, sans une seule erreur. Plusieurs milliers de comptes sont concernés.

La cause tient en une ligne : en 9.2, le champ est devenu une extra property, et le webservice legacy ne sait pas les publier. Le correctif passe par le hook addWebserviceResources, sans override du core, avec un changement de syntaxe pour les filtres. Les extraits de code viennent du tag 9.2.0-rc.1 du dépôt PrestaShop, le tag 9.2 le plus avancé au moment d’écrire (après 9.2.0-beta.1). Le correctif tourne en préprod : au moment d’écrire, la boutique n’est pas encore en production sur PrestaShop 9.

Le webservice étant éteint sur la préprod, l’ERP ne pouvait pas encore l’appeler : le trou a été repéré pendant la migration, avant toute mise en ligne. Deux autres comportements du legacy avaient d’ailleurs disparu avec le champ : la connexion par code client et la fermeture des inscriptions sur la boutique B2B.

Un XML valide, un code 200, et le champ en moins

Rien ne casse bruyamment. Un GET /api/customers/42 répond, avec un XML valide et tous les champs natifs. Le code client n’y est simplement plus.

Côté écriture, c’est pire. Sur le legacy, le connecteur ne faisait en pratique que lire : sur 30 jours de logs, 2 585 lectures de /api/customers et aucune écriture, le code étant saisi à la main dans l’ERP puis recopié dans PrestaShop. Mais le jour où il écrit, voici ce qui se passe. WebserviceRequest::saveEntityFromXml() ne parcourt pas les nœuds du XML reçu, il parcourt les champs de la configuration de la ressource et va chercher chacun dans le XML. Extrait de la boucle :

php
// classes/webservice/WebserviceRequest.php, PrestaShop 9.2.0-rc.1 (extrait)
foreach ($this->resourceConfiguration['fields'] as $fieldName => $fieldProperties) {
    // only process fields actually in the input XML
    if ($this->method == 'PATCH' && !isset($attributes->$fieldName)) {
        continue;
    }
    $sqlId = $fieldProperties['sqlId'];
    // ...
    if (isset($attributes->$fieldName, $fieldProperties['sqlId']) && (!isset($fieldProperties['i18n']) || !$fieldProperties['i18n'])) {
        if (isset($fieldProperties['setter'])) {
            // ...
            $object->{$fieldProperties['setter']}((string) $attributes->$fieldName);
        } elseif (property_exists($object, $sqlId)) {
            $object->$sqlId = (string) $attributes->$fieldName;
        }
        // ...
    }
}

Un nœud <code_erp> que la configuration ne connaît pas n’est donc jamais lu. Pas de 400, pas de log : la requête passe, le compte est enregistré, la valeur est perdue. Aucun monitoring basé sur les codes HTTP ne le verra, il faut aller le chercher.

Ce qu’on pourrait soupçonner, et pourquoi ce n’est pas ça

Le premier suspect serait la clé d’API. Les droits sont par ressource et par méthode, et une migration peut les perdre. Sauf qu’un problème de droits renvoie une erreur explicite dans WebserviceRequest : un 401 Resource of type "customers" is not allowed with this authentication key (code 26) si la ressource n’est pas cochée, un 405 (code 25) si c’est la méthode. Jamais un champ manquant.

Deuxième suspect, l’environnement : sur une préprod protégée par un basic auth au niveau de l’ingress, le webservice est inaccessible, et on pourrait croire que le souci vient de là. Ce blocage existe bien (on y revient plus bas), mais il ferme tout l’endpoint, il ne fait pas disparaître un champ.

Troisième suspect, la reprise de données : le code n’a peut-être pas été migré. Les codes existants doivent effectivement être repris (voir plus bas), mais ça n’expliquerait pas pourquoi un code écrit à neuf par le webservice ne ressort pas à la relecture.

Pourquoi le webservice PrestaShop 9.2 ne voit plus le champ

Le legacy tournait en PrestaShop 1.7.8.3. Le code client y était ajouté par un override de la classe Customer, qui l’inscrivait dans $definition au constructeur, et donc dans une colonne de la table customer :

php
// override/classes/Customer.php, legacy 1.7.8.3 (extrait)
class Customer extends CustomerCore
{
    /** @var string Code Client */
    public $code_erp;

    public function __construct($id = null)
    {
        self::$definition['fields']['code_erp'] = ['type' => self::TYPE_STRING, 'size' => 32];
        parent::__construct($id);
    }
    // ...
}

Le même override portait la connexion par code client, avec une méthode statique qui retrouvait l’e-mail du compte par une requête sur cette colonne. Le webservice construit sa liste de champs à partir de cette définition. Dans ObjectModel::getWebserviceParameters(), chaque entrée de $this->def['fields'] devient un champ publiable :

php
// classes/ObjectModel.php, PrestaShop 9.2.0-rc.1 (extrait)
foreach ($this->def['fields'] as $field_name => $details) {
    // ...
    $current_field['sqlId'] = $field_name;
    // ...
    $resource_parameters['fields'][$field_name] = array_merge($resource_parameters['fields'][$field_name], $current_field);
}

PrestaShop 9.2 introduit un mécanisme natif pour ce genre de champ : les extra properties. C’est bien du core et pas une invention de module : le namespace PrestaShop\PrestaShop\Core\ExtraProperty existe sur le tag 9.2.0-rc.1 et pas sur 9.1.5. Un module déclare sa propriété via Module::registerExtraProperty(), le core crée la colonne dans une table dédiée, et la valeur se lit par $customer->extra_properties['nom_du_module']['code_erp'].

Le stockage est décrit en tête de ExtraPropertySchemaManager :

text
entity scope → {entity}_extra          (e.g. product_extra)
lang scope   → {entity}_extra_lang     (e.g. product_extra_lang)
shop scope   → {entity}_extra_shop     (e.g. product_extra_shop)

Pour un client, c’est donc customer_extra, avec id_customer comme clé. Et la colonne ne s’appelle pas code_erp : ExtraPropertyDefinition::buildStorageColumnName() la préfixe du nom du module ({module}_{propriété}).

Voilà le trou. Le champ vit maintenant hors de $definition, et le webservice legacy (WebserviceRequest, WebserviceOutputBuilder) ne contient aucune référence aux extra properties. La définition prévoit bien un paramètre associatedApis, mais il cible des opérations par gabarit d’URI et méthode HTTP (filterByApi($uriTemplate, $method)), c’est-à-dire l’Admin API, la nouvelle API, pas /api/customers. Rien n’est cassé côté modèle : le champ n’a juste plus de chemin vers le webservice historique.

Comment vérifier si votre webservice PrestaShop a perdu un champ

Quand un champ manque dans une réponse du webservice, la question utile n’est pas « la valeur est-elle en base ? » mais « le champ est-il dans la configuration de la ressource ? ». Deux vérifications suffisent :

bash
# Le schéma que le webservice annonce pour la ressource
curl -s -u "$WS_KEY:" "https://boutique.example/api/customers?schema=synopsis" | grep -c code_erp

# Où vit réellement la valeur (adaptez le nom de la base et le préfixe ps_)
mysql nom_de_la_base -e "SHOW COLUMNS FROM ps_customer LIKE '%code%'; SHOW TABLES LIKE 'ps_customer_extra';"

Si le champ est absent du schéma synopsis, aucune écriture ne l’atteindra, quel que soit le XML envoyé. Si la valeur est dans customer_extra, c’est une extra property, et le webservice legacy ne la publiera pas tout seul.

Le réflexe à retenir. Après une migration, interrogez chaque ressource utilisée par une intégration avec ?schema=synopsis et comparez la liste des champs avec celle du legacy. Le webservice ignore silencieusement les nœuds XML qu’il ne connaît pas : un champ disparu ne se voit jamais dans les codes de retour, seulement dans le schéma.

Réexposer le champ dans /api/customers avec addWebserviceResources

Pas d’override du core, c’était la contrainte. Le webservice offre un point d’entrée prévu pour ça : le hook addWebserviceResources. Dans WebserviceRequest::getResources(), son retour est fusionné avec la liste native par array_merge, donc une clé customers renvoyée par un module remplace celle du core :

php
// classes/webservice/WebserviceRequest.php, PrestaShop 9.2.0-rc.1 (extrait)
$extra_resources = Hook::exec('addWebserviceResources', ['resources' => $resources], null, true, false);
if (is_countable($extra_resources) && count($extra_resources)) {
    foreach ($extra_resources as $new_resources) {
        if (is_countable($new_resources) && count($new_resources)) {
            $resources = array_merge($resources, $new_resources);
        }
    }
}

Le module déclare donc la ressource customers avec sa propre classe, une sous-classe de Customer. Voici un montage type, simplifié, qui n’est pas le code exact du projet :

php
// Montage type simplifié (méthode de la classe du module)
public function hookAddWebserviceResources(array $params): array
{
    require_once __DIR__ . '/classes/WsCustomer.php';

    return [
        'customers' => [
            'description' => 'The e-shop\'s customers',
            'class' => 'WsCustomer',
        ],
    ];
}
php
<?php
// Montage type simplifié
declare(strict_types=1);

class WsCustomer extends Customer
{
    private const MODULE = 'customfields';

    public function __construct($id = null)
    {
        parent::__construct($id);

        $this->webserviceParameters['fields']['code_erp'] = [
            'sqlId' => 'code_erp',
            'getter' => 'getWsCodeErp',
            'setter' => 'setWsCodeErp',
            'required' => false,
        ];
        $this->webserviceParameters['linked_tables']['customer_extra'] = [
            'table' => 'customer_extra',
            'fields' => ['code_erp' => ['sqlId' => self::MODULE . '_code_erp']],
        ];
    }

    public function getWsCodeErp(): string
    {
        return (string) ($this->extra_properties[self::MODULE]['code_erp'] ?? '');
    }

    public function setWsCodeErp(string $value): bool
    {
        $this->extra_properties[self::MODULE]['code_erp'] = $value !== '' ? $value : null;

        return true;
    }
}

Trois détails de la source rendent ce montage viable :

  • le sac d’extra properties est résolu par le nom de table (ObjectModelCore::getDefinition() puis filterByTableName()), et la sous-classe hérite de $definition : WsCustomer lit donc bien les valeurs de customer ;
  • l’écriture passe par addWs() et updateWs() (les objectMethods de Customer), qui délèguent à add() et update(), lesquels appellent persistExtraProperties() après l’enregistrement de l’objet ;
  • le filtre « front-office » du sac (displayFront) ne s’applique pas ici, car Context::isFrontOfficeContext() teste le type de contrôleur, absent dans le dispatcher du webservice, puis la constante _PS_FRONT_DIR_, que seul le point d’entrée front (index.php) définit.

Un point de vigilance lu dans ExtraPropertiesBag::createForEntity() : si les services du conteneur ne sont pas disponibles, le chargeur attrape l’exception et renvoie un tableau vide, sans log. C’est exactement la classe de panne silencieuse dont parle cet article : il faut un test qui vérifie une valeur réelle, pas seulement la présence du nœud.

Le champ revient sous son nom d’origine, sur le même endpoint, avec la même clé d’API, et l’URL ne change pas à la mise en ligne. Côté ERP, rien à changer pour lire et écrire.

Filtrer /api/customers sur un champ custom : la syntaxe change

La recherche par code, elle, change. Le legacy acceptait :

text
GET /api/customers?filter[code_erp]=[C01234]

Avec le montage ci-dessus, il faut passer par la table liée :

text
GET /api/customers?filter[customer_extra][code_erp]=[C01234]

Ce n’est pas un choix de confort, c’est le core qui l’impose. Dans WebserviceRequest::manageFilters(), un champ doté d’un getter ne peut pas servir de filtre et renvoie une erreur 400 explicite, code 34 :

text
The field "code_erp" is dynamic. It is not possible to filter GET query with this field.

Le seul chemin est linked_tables, qui génère un LEFT JOIN sur la table indiquée en joignant sur la clé primaire, id_customer ici. Si le nom passé entre crochets ne correspond à aucune entrée de fields dans linked_tables, le core répond encore en 400, cette fois avec This filter does not exist for this linked table (code 29) : c’est l’erreur que l’on verra si le connecteur envoie filter[customer_extra][nom_de_colonne] au lieu du nom exposé.

La question à poser à l’éditeur du connecteur tient en une ligne : filtrez-vous sur le code client ? Ici, il l’a confirmé, et la nouvelle syntaxe de filtre est le seul changement de son côté.

Reprendre les codes existants sans accès direct à la base legacy

Les codes existants devaient passer de la colonne legacy à customer_extra. Plusieurs milliers de comptes, et pas d’accès direct à la base legacy depuis l’environnement cible : la reprise s’est faite par un script en deux temps, export d’un côté, import en SQL directement dans customer_extra de l’autre. Tous les codes sont repris en préprod.

En SQL, attention au nom de colonne préfixé par le module : écrire dans code_erp échoue, la colonne s’appelle {module}_code_erp. La méthode générale (écrite pour un 1.6, elle vaut aussi depuis un 1.7) est détaillée dans notre article sur la reprise de données PrestaShop 1.6 vers 9 par script.

Les cinq cas à tester avant de valider la synchro

Le webservice a plusieurs chemins de code, et chacun peut perdre le champ à sa façon. Nous avons testé sur la stack locale, avec l’interrupteur du module allumé puis éteint sur la même boutique, pour vérifier aussi le retour au comportement natif :

  • POST d’un compte avec code, puis relecture ;
  • GET /api/customers/{id} ;
  • recherche par code avec la nouvelle syntaxe de filtre ;
  • listing en display=full et en affichage minimal ;
  • compte sans aucune ligne dans customer_extra, pour vérifier qu’il remonte avec un code vide au lieu de disparaître de la liste ou de lever une erreur.

Le dernier cas compte : un compte qui n’a jamais reçu de code n’a pas de ligne dans customer_extra, et le LEFT JOIN comme le getter doivent le supporter.

Quatre pièges de mise en production

Le webservice désactivé. Sur notre préprod il était éteint, et WebserviceRequest répond alors 503 (The PrestaShop webservice is disabled. Please activate it in the PrestaShop Back Office, code 22). À cocher dans la procédure, pas à découvrir le jour J.

Le basic auth de l’ingress devant /api. Le dispatcher du webservice lit la clé d’API dans l’en-tête Authorization: Basic (repli sur ws_key en query string, à éviter). Un basic auth posé devant la préprod consomme le même en-tête : les deux se battent et l’ERP ne passe jamais. Il faut une exception pour /api avant toute recette avec l’éditeur. Pour les autres pièges d’ingress et de volumes, voir notre retour sur les erreurs classiques d’une migration PrestaShop vers Kubernetes.

Le groupe client par défaut, boutique par boutique. Le constructeur de Customer initialise id_default_group avec Configuration::get('PS_CUSTOMER_GROUP'). Sur le legacy, la boutique B2B avait sa propre valeur, et cette surcharge par boutique n’avait pas été migrée : un compte créé hors synchro tombait dans le groupe client grand public, avec ses prix TTC. Rien ne le signale, il faut aller le regarder.

Le module livré mais pas migré. Le manifeste qui pilote les modules ne portait qu’une ligne install, ignorée sur un module déjà présent. Sans ligne upgrade, les migrations du module ne tournent pas et la fonctionnalité arrive inerte. C’est le sujet de notre article sur le déploiement déclaratif des modules.

Ce que ce montage engage pour la suite

Toutes les requêtes /api/customers passent désormais par la sous-classe du module, pour toutes les clés qui y ont accès. Dix clés d’API actives y ont accès : une seule en écriture, celle de l’ERP, et neuf en lecture pour des outils de flux produits et de marketing. Ajouter un champ reste rétrocompatible, mais toucher à cette classe touche toutes ces intégrations.

Autre effet de bord de la sous-classe, lu dans ObjectModel : les hooks dynamiques prennent le nom de la classe (getFullyQualifiedName()). Une écriture par le webservice déclenche actionObjectWsCustomerUpdateAfter, et non plus actionObjectCustomerUpdateAfter. Seuls les hooks génériques (actionObjectUpdateAfter) restent communs. De même, les champs rendus obligatoires en back-office sont indexés par nom de classe (getObjectName()) et ne s’appliquent plus aux écritures webservice. Un module qui écoute les hooks Customer ne voit donc plus passer les comptes créés ou modifiés par l’ERP. Sur ce projet, c’est le cas du module lui-même : sur la boutique B2B, il écoute actionObjectCustomerAddBefore pour créer les demandes de compte pro inactives, puis actionObjectCustomerUpdateBefore et actionObjectCustomerUpdateAfter pour exiger le code client à l’activation et envoyer l’e-mail de validation. Ces garde-fous couvrent le back-office et le front, pas les écritures de l’ERP. Tant que le connecteur se contente de lire, c’est sans effet ; s’il se met un jour à créer ou activer des comptes, il faudra les brancher aussi sur actionObjectWsCustomer*.

La limite elle-même mériterait d’être documentée côté PrestaShop : un champ déclaré proprement en extra property existe partout, sauf dans le webservice legacy.

Si votre synchro ERP repose plutôt sur des appels sortants déclenchés par PrestaShop, les pièges sont d’une autre nature : nous les avons décrits dans notre article sur la fiabilisation d’un webhook produit vers un ERP. Pour une vue d’ensemble de ce que 9.2 change sur nos projets, voir PrestaShop 9.2 vu depuis nos projets. Et si une synchro ERP repose sur le webservice natif, c’est typiquement le point que nous vérifions avant une migration.

Partager cet article
Articles

Des articles pour faire pousser vos idées

D'autres retours d'expérience et méthodes à mettre en pratique sur votre site.

Bon, on en discute ?

Demandez votre diagnostic de performance gratuit : un échange de 30 minutes, vos Core Web Vitals relevés sur mobile et une synthèse écrite de ce qui mérite d’être corrigé en premier.

Discuter de mes besoins