feat(transport) : synchronisation du référentiel transporteurs QUALIMAT (ERP-39) (#99)
## 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). --------- Co-authored-by: Matthieu <contact@malio.fr> Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Reviewed-on: #99 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\Console;
|
||||
|
||||
use App\Module\Transport\Application\Qualimat\QualimatRowMapper;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Throwable;
|
||||
|
||||
use function array_slice;
|
||||
use function count;
|
||||
use function is_array;
|
||||
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
|
||||
/**
|
||||
* ERP-39 : synchronise le referentiel des transporteurs QUALIMAT.
|
||||
*
|
||||
* Recupere la liste des operateurs de transport depuis l'API publique (ou un
|
||||
* fichier local), normalise chaque ligne et synchronise `qualimat_carrier` de
|
||||
* facon transactionnelle : upsert sur le SIRET, soft-delete des absents,
|
||||
* journal dans `qualimat_sync_log`. Idempotente (refresh complet) : prevue
|
||||
* pour un cron quotidien.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:qualimat:sync',
|
||||
description: 'Synchronise le referentiel des transporteurs QUALIMAT (upsert + soft-delete + journal).',
|
||||
)]
|
||||
final class SyncQualimatCommand extends Command
|
||||
{
|
||||
private const string API_URL = 'https://www.qualimat.org/wp-json/qualimat/v1/getOperateurs';
|
||||
private const int DEFAULT_PPP = 10000;
|
||||
|
||||
// Cle arbitraire (mais stable) du verrou consultatif Postgres serialisant
|
||||
// les runs de `app:qualimat:sync` entre eux. Propre a cette commande.
|
||||
private const int ADVISORY_LOCK_KEY = 3_900_000_039;
|
||||
|
||||
// Nombre de lignes par INSERT groupe. 10 parametres/ligne, large marge sous
|
||||
// la limite Postgres de 65535 parametres par requete.
|
||||
private const int UPSERT_CHUNK = 1000;
|
||||
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('file', null, InputOption::VALUE_REQUIRED, "Chemin d'un JSON local (court-circuite l'appel HTTP, utile pour tests/rejeu).")
|
||||
->addOption('ppp', null, InputOption::VALUE_REQUIRED, "Taille de page demandee a l'API.", (string) self::DEFAULT_PPP)
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Analyse sans ecriture en base.')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$ppp = max(1, (int) $input->getOption('ppp'));
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
$file = $input->getOption('file');
|
||||
|
||||
// Verrou consultatif (session) : empeche deux runs de se chevaucher
|
||||
// (cron qui deborde, invocation manuelle parallele). Sans lui, le run le
|
||||
// plus tardif desactiverait les lignes que l'autre vient d'inserer.
|
||||
if (!$this->acquireLock()) {
|
||||
$io->error('Une synchronisation QUALIMAT est deja en cours (verrou non disponible).');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->doSync($io, $ppp, $dryRun, $file);
|
||||
} finally {
|
||||
$this->releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Coeur de la synchronisation, execute sous verrou consultatif.
|
||||
*/
|
||||
private function doSync(SymfonyStyle $io, int $ppp, bool $dryRun, ?string $file): int
|
||||
{
|
||||
// 1. Recuperation des items (fichier local ou API).
|
||||
try {
|
||||
$items = null !== $file ? $this->readLocal($file) : $this->fetchRemote($ppp);
|
||||
} catch (Throwable $e) {
|
||||
$io->error('Recuperation impossible : '.$e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$total = count($items);
|
||||
$io->section(sprintf('QUALIMAT — %d items recus', $total));
|
||||
|
||||
// Garde-fou troncature : un retour egal a ppp signale un dataset coupe.
|
||||
if (null === $file && $total === $ppp) {
|
||||
$io->warning(sprintf("Le nombre d'items recus (%d) egale --ppp : resultat potentiellement tronque, augmente --ppp.", $ppp));
|
||||
}
|
||||
|
||||
// 2. Mapping / normalisation (les items sans SIRET sont ignores, les
|
||||
// doublons de SIRET sont fusionnes : derniere occurrence gagnante).
|
||||
['rows' => $rows, 'skipped' => $skipped] = QualimatRowMapper::mapMany($items);
|
||||
$io->writeln(sprintf('%d lignes exploitables, %d ignorees (sans SIRET).', count($rows), $skipped));
|
||||
|
||||
if ($dryRun) {
|
||||
$this->renderPreview($io, $rows);
|
||||
$io->note(sprintf('Dry-run : aucune ecriture. (%d lignes au total)', count($rows)));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// Garde-fou « zero ligne » : une source vide (incident amont, liste []
|
||||
// legitime) ne doit JAMAIS atteindre le soft-delete, qui desactiverait
|
||||
// tout le referentiel. On abandonne sans rien ecrire.
|
||||
if ([] === $rows) {
|
||||
$io->error('Aucune ligne exploitable : synchronisation abandonnee (desactivation de masse evitee).');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// 3. Sync transactionnelle : upsert -> soft-delete -> journal.
|
||||
$run = new DateTimeImmutable()->format('Y-m-d H:i:s.u');
|
||||
|
||||
$this->connection->beginTransaction();
|
||||
|
||||
try {
|
||||
$upserted = $this->upsertAll($rows, $run);
|
||||
$deactivated = $this->deactivateMissing($run);
|
||||
$this->log($run, $total, $upserted, $skipped, $deactivated);
|
||||
$this->connection->commit();
|
||||
} catch (Throwable $e) {
|
||||
$this->connection->rollBack();
|
||||
$io->error('Sync annulee (rollback) : '.$e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->success(sprintf('%d upsert, %d ignore(s), %d desactive(s).', $upserted, $skipped, $deactivated));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tente de prendre le verrou consultatif de session. Retourne false si un
|
||||
* autre run le detient deja (Postgres `pg_try_advisory_lock`, non bloquant).
|
||||
*/
|
||||
private function acquireLock(): bool
|
||||
{
|
||||
return (bool) $this->connection->fetchOne('SELECT pg_try_advisory_lock(:key)', ['key' => self::ADVISORY_LOCK_KEY]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relache le verrou consultatif pris par acquireLock().
|
||||
*/
|
||||
private function releaseLock(): void
|
||||
{
|
||||
$this->connection->executeStatement('SELECT pg_advisory_unlock(:key)', ['key' => self::ADVISORY_LOCK_KEY]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejoue l'appel GET de l'API QUALIMAT et retourne le tableau d'items.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function fetchRemote(int $ppp): array
|
||||
{
|
||||
$response = $this->httpClient->request('GET', self::API_URL, [
|
||||
'query' => ['type' => 'operateur_transport', 'ppp' => $ppp],
|
||||
'timeout' => 60,
|
||||
]);
|
||||
|
||||
// toArray() leve une exception sur un statut non-2xx ou un corps non-JSON.
|
||||
$data = $response->toArray();
|
||||
|
||||
// Un 2xx au corps inattendu (objet d'erreur, enveloppe {"data":[...]}, etc.)
|
||||
// ne doit PAS etre interprete comme « 0 transporteur » : ce serait masquer
|
||||
// un changement de contrat de l'API et declencher la desactivation de masse
|
||||
// (cf. garde-fou « zero ligne » dans execute()). On echoue franchement.
|
||||
if (!array_is_list($data)) {
|
||||
throw new RuntimeException("Reponse inattendue de l'API QUALIMAT : un tableau d'items etait attendu.");
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit un export JSON local (tableau d'objets).
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function readLocal(string $path): array
|
||||
{
|
||||
$raw = @file_get_contents($path);
|
||||
|
||||
if (false === $raw) {
|
||||
throw new RuntimeException(sprintf('Fichier illisible : %s', $path));
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
if (!is_array($data) || !array_is_list($data)) {
|
||||
throw new RuntimeException("Le JSON doit etre un tableau d'objets.");
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert de toutes les lignes valides (cle naturelle = siret) par paquets
|
||||
* (INSERT groupe), au lieu d'un aller-retour par ligne. Marque is_active=TRUE
|
||||
* et tamponne last_synced_at avec le run courant. Les lignes etant deja
|
||||
* dedoublonnees par SIRET en amont, le compte retourne = transporteurs
|
||||
* distincts effectivement synchronises.
|
||||
*
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*/
|
||||
private function upsertAll(array $rows, string $run): int
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
foreach (array_chunk($rows, self::UPSERT_CHUNK) as $chunk) {
|
||||
$placeholders = [];
|
||||
$params = [];
|
||||
|
||||
foreach ($chunk as $r) {
|
||||
// 10 valeurs liees + is_active force a TRUE (litteral).
|
||||
$placeholders[] = '(?, ?, ?, ?, ?, ?, ?, ?, ?, TRUE, ?)';
|
||||
$params[] = $r['siret'];
|
||||
$params[] = $r['name'];
|
||||
$params[] = $r['address'];
|
||||
$params[] = $r['postal_code'];
|
||||
$params[] = $r['city'];
|
||||
$params[] = $r['phone'];
|
||||
$params[] = $r['department'];
|
||||
$params[] = $r['status'];
|
||||
$params[] = $r['validity_date'];
|
||||
$params[] = $run;
|
||||
}
|
||||
|
||||
$sql = sprintf(
|
||||
<<<'SQL'
|
||||
INSERT INTO qualimat_carrier
|
||||
(siret, name, address, postal_code, city, phone, department, status, validity_date, is_active, last_synced_at)
|
||||
VALUES
|
||||
%s
|
||||
ON CONFLICT (siret) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
address = EXCLUDED.address,
|
||||
postal_code = EXCLUDED.postal_code,
|
||||
city = EXCLUDED.city,
|
||||
phone = EXCLUDED.phone,
|
||||
department = EXCLUDED.department,
|
||||
status = EXCLUDED.status,
|
||||
validity_date = EXCLUDED.validity_date,
|
||||
is_active = TRUE,
|
||||
last_synced_at = EXCLUDED.last_synced_at
|
||||
SQL,
|
||||
implode(",\n ", $placeholders),
|
||||
);
|
||||
|
||||
$this->connection->executeStatement($sql, $params);
|
||||
$count += count($chunk);
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete : toute ligne active non revue par ce run (tampon anterieur)
|
||||
* passe a is_active=false.
|
||||
*/
|
||||
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',
|
||||
['run' => $run],
|
||||
);
|
||||
}
|
||||
|
||||
private function log(string $run, int $total, int $upserted, int $skipped, int $deactivated): void
|
||||
{
|
||||
$this->connection->executeStatement(
|
||||
<<<'SQL'
|
||||
INSERT INTO qualimat_sync_log (fetched_at, rows_total, rows_upserted, rows_skipped, rows_deactivated)
|
||||
VALUES (:run, :total, :upserted, :skipped, :deactivated)
|
||||
SQL,
|
||||
[
|
||||
'run' => $run,
|
||||
'total' => $total,
|
||||
'upserted' => $upserted,
|
||||
'skipped' => $skipped,
|
||||
'deactivated' => $deactivated,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*/
|
||||
private function renderPreview(SymfonyStyle $io, array $rows): void
|
||||
{
|
||||
$io->table(
|
||||
['SIRET', 'Nom', 'CP', 'Ville', 'Statut', 'Validite'],
|
||||
array_map(static fn (array $r): array => [
|
||||
(string) $r['siret'],
|
||||
mb_strimwidth((string) $r['name'], 0, 40, '…'),
|
||||
(string) ($r['postal_code'] ?? ''),
|
||||
mb_strimwidth((string) ($r['city'] ?? ''), 0, 25, '…'),
|
||||
(string) $r['status'],
|
||||
(string) ($r['validity_date'] ?? ''),
|
||||
], array_slice($rows, 0, 15)),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user