feat(transport) : synchronisation du référentiel transporteurs QUALIMAT (ERP-39) #99

Merged
matthieu merged 9 commits from feat/erp-39-qualimat-sync into develop 2026-06-15 14:40:17 +00:00
Owner

ERP-39 — Intégration QUALIMAT (transporteurs)

⚠️ MR empilée sur feat/erp-150-module-transport (PR #97). À merger après #97 (la base se recible automatiquement sur develop).

Commande console app:qualimat:sync : récupère les opérateurs de transport agréés depuis l'API publique qualimat.org, normalise et synchronise une table référentielle. Idempotente (refresh complet), prévue pour un cron quotidien.

Contenu

  • Migration Version20260612150000 (namespace racine) : tables qualimat_carrier + qualimat_sync_log, COMMENT ON COLUMN sur chaque colonne, unique sur siret, index is_active.
  • QualimatRowMapper : normalisation pure — SIRET sans espaces (clé naturelle, source "sale" non contrainte à 14), dd/mm/yyyy → ISO avec checkdate, skip des items sans SIRET, Nom=Societe → une colonne.
  • SyncQualimatCommand : options --file / --ppp / --dry-run, fetch via http-client, upsert DBAL transactionnel (ON CONFLICT (siret)) + soft-delete des absents + journal, garde-fou troncature (count == ppp).
  • Activation de framework.http_client (l'alias HttpClientInterface n'était pas enregistré).

Tests

  • Unitaires (QualimatRowMapper) + fonctionnels de la commande via --file (upsert, normalisation, journal, soft-delete).
  • Suite complète 598/598 verte. ColumnsHaveSqlCommentTest .
  • Bout-en-bout réel : sync de 2332 transporteurs (1 ignoré sans SIRET, 0 désactivé, 1 journal).

Décisions

  • Migration au namespace racine migrations/ (convention réelle M2/M3 ; pas de FK cross-module ; évite le tri FQCN) — écart assumé vs le mot "modulaire" du ticket.
  • status sans CHECK contraignant (feed externe), siret non contraint à 14 (source incomplète).
## ERP-39 — Intégration QUALIMAT (transporteurs) > ⚠️ MR **empilée** sur `feat/erp-150-module-transport` (PR #97). À merger après #97 (la base se recible automatiquement sur `develop`). Commande console `app:qualimat:sync` : récupère les opérateurs de transport agréés depuis l'API publique qualimat.org, normalise et synchronise une table référentielle. Idempotente (refresh complet), prévue pour un **cron quotidien**. ### Contenu - **Migration** `Version20260612150000` (namespace racine) : tables `qualimat_carrier` + `qualimat_sync_log`, `COMMENT ON COLUMN` sur chaque colonne, unique sur `siret`, index `is_active`. - **`QualimatRowMapper`** : normalisation pure — SIRET sans espaces (clé naturelle, source "sale" non contrainte à 14), `dd/mm/yyyy` → ISO avec `checkdate`, skip des items sans SIRET, `Nom`=`Societe` → une colonne. - **`SyncQualimatCommand`** : options `--file` / `--ppp` / `--dry-run`, fetch via http-client, upsert DBAL transactionnel (`ON CONFLICT (siret)`) + soft-delete des absents + journal, garde-fou troncature (`count == ppp`). - Activation de `framework.http_client` (l'alias `HttpClientInterface` n'était pas enregistré). ### Tests - Unitaires (`QualimatRowMapper`) + fonctionnels de la commande via `--file` (upsert, normalisation, journal, soft-delete). - Suite complète **598/598** verte. `ColumnsHaveSqlCommentTest` ✅. - Bout-en-bout réel : sync de **2332 transporteurs** (1 ignoré sans SIRET, 0 désactivé, 1 journal). ### Décisions - Migration au **namespace racine** `migrations/` (convention réelle M2/M3 ; pas de FK cross-module ; évite le tri FQCN) — écart assumé vs le mot "modulaire" du ticket. - `status` sans CHECK contraignant (feed externe), `siret` non contraint à 14 (source incomplète).
tristan added the type/featbackdb labels 2026-06-12 13:06:06 +00:00
tristan marked the pull request as work in progress 2026-06-12 13:14:47 +00:00
tristan reviewed 2026-06-15 08:01:01 +00:00
tristan left a comment
Author
Owner

Revue de code ERP-39 (synchro QUALIMAT). 1 point bloquant (perte de référentiel sur réponse API vide/malformée) + 4 points secondaires. Détails en commentaires inline.

Revue de code ERP-39 (synchro QUALIMAT). 1 point bloquant (perte de référentiel sur réponse API vide/malformée) + 4 points secondaires. Détails en commentaires inline.
@@ -0,0 +2,4 @@
declare(strict_types=1);
namespace DoctrineMigrations;
Author
Owner

🔵 Création de tables de module au namespace racine DoctrineMigrations — à confirmer avec l'équipe.

architecture.md + règle n°11 réservent la racine aux migrations d'init (user/RBAC/seed de base) et cantonnent le namespace modulaire aux « évolutions post-schema (ajout de colonnes, index) ». Une création de tables de module ne tombe dans aucun des deux libellés. Le docblock justifie par « comme les autres migrations de création de tables » (pratique de fait non écrite). L'argument « pas de FK cross-module » est valable mais n'est pas le critère énoncé.

➡️ Trancher avec l'équipe et, le cas échéant, documenter le critère dans les règles.

🔵 **Création de tables de module au namespace racine `DoctrineMigrations` — à confirmer avec l'équipe.** `architecture.md` + règle n°11 réservent la racine aux migrations d'init (user/RBAC/seed de base) et cantonnent le namespace modulaire aux « évolutions post-schema (ajout de colonnes, index) ». Une création de tables de module ne tombe dans aucun des deux libellés. Le docblock justifie par « comme les autres migrations de création de tables » (pratique de fait non écrite). L'argument « pas de FK cross-module » est valable mais n'est pas le critère énoncé. ➡️ Trancher avec l'équipe et, le cas échéant, documenter le critère dans les règles.
@@ -0,0 +129,4 @@
// toArray() leve une exception sur un statut non-2xx ou un corps non-JSON.
$data = $response->toArray();
return array_is_list($data) ? $data : [];
Author
Owner

🔴 Perte de données — réponse 2xx vide/malformée désactive tout le référentiel.

toArray() lève bien sur un non-2xx (chemin sûr). Mais un 2xx au corps inattendu ({}, {"error":...}, enveloppe {"data":[...]}, ou liste légitimement vide lors d'un incident amont) retourne silencieusement []. Il n'y a aucun garde-fou « zéro ligne » avant la partie destructive : upsertAll([]) ne tamponne rien, puis deactivateMissing() (l. 102) matche tous les transporteurs actifs (last_synced_at < :run), bascule tout en inactif et commit().

➡️ Faire échouer fetchRemote (exception) sur un payload non-list au lieu de retourner [], et abort FAILURE si $total === 0 avant la transaction. À corriger avant merge.

🔴 **Perte de données — réponse 2xx vide/malformée désactive tout le référentiel.** `toArray()` lève bien sur un non-2xx (chemin sûr). Mais un **2xx au corps inattendu** (`{}`, `{"error":...}`, enveloppe `{"data":[...]}`, ou liste légitimement vide lors d'un incident amont) retourne silencieusement `[]`. Il n'y a **aucun garde-fou « zéro ligne »** avant la partie destructive : `upsertAll([])` ne tamponne rien, puis `deactivateMissing()` (l. 102) matche **tous** les transporteurs actifs (`last_synced_at < :run`), bascule tout en inactif et `commit()`. ➡️ Faire échouer `fetchRemote` (exception) sur un payload non-list au lieu de retourner `[]`, et abort `FAILURE` si `$total === 0` avant la transaction. À corriger avant merge.
@@ -0,0 +182,4 @@
$count = 0;
foreach ($rows as $r) {
Author
Owner

🟡 Upsert ligne à ligne — ~10 000 allers-retours/run.

Avec --ppp=10000 par défaut, jusqu'à ~10k executeStatement paramétrés séparés par run. C'est le coût dominant d'un refresh quotidien.

➡️ INSERT ... VALUES (...),(...) ON CONFLICT par paquets (ou au moins un prepare() réutilisé hors boucle). Non bloquant.

🟡 **Upsert ligne à ligne — ~10 000 allers-retours/run.** Avec `--ppp=10000` par défaut, jusqu'à ~10k `executeStatement` paramétrés séparés par run. C'est le coût dominant d'un refresh quotidien. ➡️ `INSERT ... VALUES (...),(...) ON CONFLICT` par paquets (ou au moins un `prepare()` réutilisé hors boucle). Non bloquant.
@@ -0,0 +195,4 @@
'validity_date' => $r['validity_date'],
'run' => $run,
]);
++$count;
Author
Owner

🟡 rows_upserted surcompte les SIRET dupliqués du même lot.

++$count compte par executeStatement, pas par carrier distinct. La source est « sale » : deux SIRET bruts distincts peuvent se normaliser aux mêmes chiffres et fusionner via ON CONFLICT, tout en comptant 2. Le journal rows_upserted surestime alors le nombre réel de transporteurs.

➡️ Dédupliquer par SIRET en amont (dernier gagnant) — ça corrige aussi le compteur naturellement.

🟡 **`rows_upserted` surcompte les SIRET dupliqués du même lot.** `++$count` compte par `executeStatement`, pas par carrier distinct. La source est « sale » : deux SIRET bruts distincts peuvent se normaliser aux mêmes chiffres et fusionner via `ON CONFLICT`, tout en comptant 2. Le journal `rows_upserted` surestime alors le nombre réel de transporteurs. ➡️ Dédupliquer par SIRET en amont (dernier gagnant) — ça corrige aussi le compteur naturellement.
@@ -0,0 +208,4 @@
private function deactivateMissing(string $run): int
{
return (int) $this->connection->executeStatement(
'UPDATE qualimat_carrier SET is_active = FALSE WHERE is_active = TRUE AND last_synced_at < :run',
Author
Owner

🟠 Runs concurrents : le run le plus tardif désactive ce que le précédent vient d'insérer.

$run est un timestamp par processus. Deux exécutions qui se chevauchent (cron qui déborde >24 h, ou invocation manuelle parallèle) → ce UPDATE ... WHERE last_synced_at < :run du run B flippe toutes les lignes tamponnées par le run A (run_A < run_B). Aucun verrou.

➡️ pg_try_advisory_lock (ou lock fichier) en tête de commande, abort si déjà tenu.

🟠 **Runs concurrents : le run le plus tardif désactive ce que le précédent vient d'insérer.** `$run` est un timestamp par processus. Deux exécutions qui se chevauchent (cron qui déborde >24 h, ou invocation manuelle parallèle) → ce `UPDATE ... WHERE last_synced_at < :run` du run B flippe toutes les lignes tamponnées par le run A (`run_A < run_B`). Aucun verrou. ➡️ `pg_try_advisory_lock` (ou lock fichier) en tête de commande, abort si déjà tenu.
matthieu marked the pull request as ready for review 2026-06-15 13:58:24 +00:00
matthieu changed target branch from feat/erp-150-module-transport to develop 2026-06-15 14:03:36 +00:00
matthieu added 6 commits 2026-06-15 14:03:36 +00:00
feat(transport) : créer le module Transport (ERP-150)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m29s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m41s
5f3da7022b
Module Transport (ID transport, non requis) destiné à héberger les référentiels externes synchronisés par commandes console (codes IDTF ERP-149, transporteurs QUALIMAT ERP-39).

- TransportModule.php avec permissions() vide à ce stade
- activation dans config/modules.php
- layer Nuxt front minimal (pas d'écran ni d'item sidebar)
Commande console app:qualimat:sync : récupère les opérateurs de transport agréés depuis l'API publique qualimat.org, normalise et synchronise une table référentielle (upsert sur le SIRET + soft-delete des absents + journal). Prévue pour un cron quotidien.

- migration : tables qualimat_carrier + qualimat_sync_log (COMMENT ON COLUMN sur chaque colonne)
- QualimatRowMapper : normalisation pure (SIRET sans espaces, date dd/mm/yyyy -> ISO, skip sans SIRET) + tests unitaires
- SyncQualimatCommand : options --file / --ppp / --dry-run, upsert DBAL transactionnel
- activation de framework.http_client
- tests fonctionnels de la commande (upsert/normalisation/journal/soft-delete)
- garde-fou anti-desactivation de masse : fetchRemote leve sur un payload
  non-list (2xx inattendu) et la commande abandonne sans ecriture si aucune
  ligne exploitable, au lieu de soft-delete tout le referentiel
- verrou consultatif pg_try_advisory_lock pour serialiser les runs (anti-overlap)
- deduplication par SIRET dans le mapper (rows_upserted = transporteurs distincts)
- upsert par paquets (INSERT groupe) au lieu d'un aller-retour par ligne
- migration des tables qualimat deplacee vers le namespace modulaire Transport
  (+ enregistrement du path dans doctrine_migrations.yaml)
- tests : deduplication + abandon sur source vide
Merge remote-tracking branch 'origin/develop' into feat/erp-150-module-transport
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m42s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m28s
5def89c7f3
# Conflicts:
#	config/modules.php
matthieu added 1 commit 2026-06-15 14:03:46 +00:00
Merge branch 'develop' into feat/erp-39-qualimat-sync
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 2m41s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m28s
52152efecb
matthieu added 1 commit 2026-06-15 14:35:48 +00:00
fix(transport) : exclure les tables qualimat du schema_filter
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m38s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m23s
2324dee57e
Les tables `qualimat_carrier` et `qualimat_sync_log` sont des tables DBAL
brutes (referentiel synchronise par `app:qualimat:sync`, hors ORM). Sans
exclusion du `schema_filter`, `doctrine:schema:update --force` (lance par le
bootstrap CI / make test-db-setup juste apres les migrations) les considere
comme orphelines et genere un DROP TABLE -> la base de test perd les tables et
SyncQualimatCommandTest casse (relation qualimat_carrier does not exist).

Meme traitement que `audit_log` : creation/suppression pilotees par migration,
schema_filter les ignore.
matthieu added 1 commit 2026-06-15 14:40:09 +00:00
Merge branch 'develop' into feat/erp-39-qualimat-sync
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m36s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m20s
4a3d903838
matthieu merged commit f4bbc79550 into develop 2026-06-15 14:40:17 +00:00
matthieu deleted branch feat/erp-39-qualimat-sync 2026-06-15 14:40:17 +00:00
Sign in to join this conversation.