WordPress
19 août 2026

Reprendre les données d’une PrestaShop 1.6 dans une 9 sans écraser le catalogue reconstruit

Hand holding a smartphone displaying a shopping app with a large emoji explosion icon in the center of the screen.

La plupart des articles sur la migration PrestaShop supposent qu’on veut tout emporter. On lance l’outil, on attend, on récupère une copie de l’ancienne boutique dans une version plus récente.

Ce n’est pas toujours ce qu’on veut, et c’est même rarement ce qu’on veut lors d’une refonte.

Sur le projet Micro Mobility, la boutique 1.6 avait huit ans. Le catalogue a été entièrement reconstruit sur la nouvelle version PrestaShop 9 : nouvelles fiches, nouvelle arborescence, nouvelles caractéristiques, nouvelle direction artistique. Reprendre les descriptions de l’ancienne base aurait détruit plusieurs semaines de travail éditorial.

Mais on ne pouvait évidemment pas repartir de zéro sur le reste. Les clients, leurs commandes, leur historique de facturation, les prix et les stocks devaient traverser. Le tout un lundi matin, avec une fenêtre de bascule courte et l’obligation que les clients puissent se reconnecter avec leur mot de passe existant, ce qui suppose de reprendre la bonne clé de chiffrement et d’en mesurer les conséquences.

Cet article décrit l’outil qu’on a écrit pour ça, et surtout les décisions qui ont compté. Il tourne en production.

Une reprise sélective, pas une migration

La règle qu’on s’est fixée tient en une phrase : le script ne touche jamais au contenu éditorial.

Concrètement, il reprend quatre domaines et rien d’autre :

  • les clients, leurs adresses et leurs groupes
  • les stocks
  • les prix, et uniquement les colonnes de prix
  • les commandes, avec toute leur famille (facturation, avoirs, retours, service après-vente)

Les noms de produits, les descriptions, les catégories et les caractéristiques ne sont jamais lus côté source. Sur les tables produit, seules les colonnes de prix sont mises à jour :

private const PRICE_COLUMNS = ['price', 'wholesale_price', 'ecotax', 'unit_price_ratio'];

C’est une liste blanche, pas une liste noire. La différence est fondamentale : avec une liste noire, toute colonne oubliée passe. Avec une liste blanche, toute colonne oubliée est ignorée. Quand l’enjeu est de ne pas écraser un catalogue reconstruit, on veut se tromper dans ce sens là.

Cette exigence est aussi la raison pour laquelle les outils de migration par paliers existants ne convenaient pas. Ils migrent tout, très bien, mais sans le niveau de finesse qu’impose une refonte où les deux catalogues n’ont plus rien à voir.

Le moteur : du SQL entre deux bases, rien de plus

Le script est un CLI PHP, mais l’essentiel du travail se fait en SQL. Les deux bases doivent être sur le même serveur MySQL, ce qui permet des INSERT ... SELECT et des UPDATE ... JOIN inter-bases avec une connexion unique.

C’est une contrainte d’infrastructure réelle : le jour de la bascule, il faut pouvoir importer le dump 1.6 dans une base de staging à côté de la base PrestaShop 9. En échange, on gagne un ordre de grandeur en performance. Sur les données réelles, l’import complet représente environ 1,5 million de lignes traitées en 33 secondes, pour 97 000 clients, 110 000 commandes et 2 182 produits.

Un moteur qui aurait fait transiter les lignes par PHP aurait mis des heures, et une fenêtre de bascule d’une heure ne se négocie pas.

Le vrai problème : huit ans de dérive de schéma

Entre une 1.6 et une 9, les tables ne se ressemblent plus. Des colonnes sont apparues, d’autres ont disparu, certaines ont changé de type. Ajoutez à ça les colonnes ajoutées par des modules tiers au fil des ans, présentes dans le dump et inconnues de la nouvelle base.

Écrire à la main la liste des colonnes de chaque table serait un travail de bénédictin, et surtout une bombe à retardement : la moindre différence entre le dump de test et le dump du jour J casse tout.

La réponse tient dans une intersection de colonnes, calculée à l’exécution en interrogeant le schéma des deux bases :

foreach ($dstMeta as $name => $meta) {
    // Generated/virtual columns cannot be inserted into: MySQL rejects any value for them.
    if ($meta['generated']) {
        continue;
    }
    if (in_array($name, $srcNames, true)) {
        $insertCols[] = "`$name`";
        $selectExprs[] = $switches[$name] ?? "`$name`";
    } elseif ($meta['nullable'] === 'NO' && $meta['default'] === null) {
        // Target-only NOT NULL column without default: provide a typed placeholder.
        $insertCols[] = "`$name`";
        $selectExprs[] = $this->typedDefault($meta['type']);
    }
    // Otherwise omit: the column takes its own DEFAULT on INSERT.
}

Trois cas, trois traitements. Une colonne présente des deux côtés est copiée. Une colonne qui n’existe que sur la cible est simplement omise, ce qui lui laisse prendre sa valeur par défaut. Et le cas pénible, la colonne qui n’existe que sur la cible, NOT NULL, et sans valeur par défaut : elle reçoit un littéral typé, sinon l’insertion échoue.

Le troisième cas mérite un mot, parce que c’est celui qu’on découvre en production :

private function typedDefault(string $dataType): string
{
    return match ($dataType) {
        'tinyint', 'smallint', 'mediumint', 'int', 'bigint', 'decimal', 'float', 'double', 'bit' => '0',
        'date' => "'2000-01-01'",
        'datetime', 'timestamp' => "'2000-01-01 00:00:00'",
        default => "''",
    };
}

Le saut des colonnes générées est arrivé plus tard, après une erreur MySQL sèche : on ne peut pas insérer de valeur dans une colonne calculée, même la bonne.

Dry-run par défaut, et ce n’est pas négociable

Le script ne s’exécute pas. Il faut le lui demander explicitement avec --apply. Sans ce drapeau, il compte les lignes qu’il copierait et affiche son rapport, sans écrire une seule ligne.

$count = (int) $this->scalar("SELECT COUNT(*) FROM $src$whereSql");
if (!$this->apply) {
    return $count;
}

Ce choix a l’air anodin, il a changé la façon dont l’outil a été utilisé. Comme le dry-run est gratuit et sans risque, il a été lancé des dizaines de fois pendant la préparation, sur des dumps successifs, et chaque écart de volume a été vu avant le jour J plutôt que pendant.

L’inversion par défaut compte autant que la fonctionnalité : un dry-run qu’il faut demander n’est jamais utilisé, un dry-run qu’il faut désactiver l’est toujours.

Le préflight : échouer avant d’écrire

Les commandes sont la partie la plus fragile de la reprise, parce qu’elles référencent des données que la nouvelle boutique doit déjà posséder : des transporteurs, des statuts, des devises. Si un transporteur de 2019 n’existe plus sur la PrestaShop 9, l’insertion passera quand même (les contraintes sont désactivées pendant l’import) et vous obtiendrez des commandes qui pointent dans le vide.

D’où un contrôle qui s’exécute avant tout, et qui bloque :

$blockers = $missingCarriers !== [] || $missingStates !== [] || $missingCurrencies !== [];
if ($blockers && !$this->forceOrders) {
    return false;
}

Trois familles bloquantes, transporteurs, statuts de commande et devises. Une quatrième, les groupes de règles de taxe, est signalée sans bloquer parce que l’impact est cosmétique sur des commandes historiques.

Le déblocage est explicite, jamais implicite : soit on fournit une table de correspondance des transporteurs (--carrier-map=ancien:nouveau), soit on assume avec --force-orders, et le script le journalise en clair.

Les décisions qui ne sont pas techniques

C’est la partie la plus intéressante d’une reprise de données, et celle qu’aucun outil générique ne peut prendre à votre place.

Faut-il importer les commandes des clients supprimés ? La réponse intuitive est non : ces clients n’existent plus, leurs commandes n’intéressent personne.

C’est la mauvaise réponse. Ces commandes portent des numéros de facture, et les numéros de facture forment une séquence légalement continue. Les écarter créait 48 trous dans la numérotation. On importe donc toutes les commandes, et on élargit la reprise des adresses aux clients supprimés pour que les commandes conservent leurs liens.

Une question qui ressemblait à un filtre technique était en fait une question comptable.

Que faire des promotions contradictoires ? PrestaShop 9 impose sur les prix spécifiques un index unique que la 1.6 n’avait pas. Deux promotions concurrentes sur le même produit et la même période, par exemple une remise en euros et une remise en pourcentage, ne peuvent donc plus coexister.

Il a fallu regarder ce que faisait la 1.6 dans ce cas. Réponse : elle les départageait arbitrairement, tous les critères de tri étant à égalité. Le comportement n’était donc pas déterministe avant non plus, et la contrainte de la 9 ne fait que rendre visible une ambiguïté qui existait déjà. Le script journalise la liste des produits concernés pour vérification le jour J, plutôt que de choisir en silence.

Deux détails qui évitent des catastrophes discrètes

Le mode SQL strict. Pendant l’import, la session tourne avec :

$this->db->exec("SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_AUTO_VALUE_ON_ZERO,ALLOW_INVALID_DATES'");

Sans STRICT_TRANS_TABLES, MySQL tronque les valeurs trop longues et convertit ce qu’il ne comprend pas, sans erreur. Vous obtenez une migration qui se termine avec un rapport vert et des données silencieusement abîmées, dans la famille des pannes muettes qu’on affectionne particulièrement. NO_AUTO_VALUE_ON_ZERO préserve les identifiants valant zéro, et ALLOW_INVALID_DATES laisse passer les dates 0000-00-00 héritées de la 1.6, nettoyées ensuite.

Les identifiants préservés. Le script fait le pari que les identifiants produit sont identiques des deux côtés, ce qui permet un rapprochement direct sans table de correspondance. Ce pari doit être vérifié, pas supposé : sur les données réelles, le taux de correspondance était de 100 %.

Sur les tests

La suite d’intégration compte 25 tests et 72 assertions, et elle exige un vrai serveur MySQL. C’est un choix assumé : du SQL inter-bases ne se simule pas. Un test qui mockerait la couche d’accès validerait la construction de chaînes de caractères, pas le comportement de MySQL face à une colonne générée ou à une collation différente entre deux tables.

Le moteur a donc été séparé de son point d’entrée CLI dès le départ, uniquement pour être testable, avec un flux de journalisation configurable pour que les tests puissent lire ce que le script raconte.

Un enseignement au passage : la remise à niveau des compteurs AUTO_INCREMENT en fin d’import s’est révélée le plus souvent redondante, l’insertion d’identifiants explicites faisant déjà avancer le compteur sur InnoDB. On l’a gardée comme filet, mais elle ne sert presque jamais.

Ce qu’on en retire

Une reprise de données réussie ressemble beaucoup plus à un travail de comptable qu’à un travail de développeur. Le SQL est la partie facile.

Les vraies questions sont ailleurs : qu’est-ce qui doit absolument traverser, qu’est-ce qui ne doit surtout pas traverser, et qu’est-ce qui a l’air d’un détail technique alors que c’est une obligation légale. Un outil générique ne peut pas y répondre à votre place, parce que les réponses dépendent de ce que vous avez décidé de reconstruire.

Et si vous ne deviez retenir qu’une chose de tout ce qui précède : rendez le dry-run obligatoire par défaut. C’est trois lignes de code, et c’est ce qui transforme un script qu’on lance en tremblant en un outil qu’on interroge tranquillement autant de fois qu’on veut.

Nous vous recommandons aussi