f057866e75
## 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>
131 lines
4.1 KiB
PHP
131 lines
4.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Module\Transport\Application\Qualimat;
|
|
|
|
/**
|
|
* Mapping pur d'un item brut de l'API QUALIMAT vers une ligne normalisee
|
|
* prete a l'upsert dans `qualimat_carrier`. Sans dependance (testable en
|
|
* isolation). Voir ERP-39 § 2 pour les pieges qualite de la source.
|
|
*/
|
|
final class QualimatRowMapper
|
|
{
|
|
/**
|
|
* Mappe un lot d'items. Les items sans SIRET exploitable sont ignores et
|
|
* comptes a part (cf. `rows_skipped` du journal). Les doublons de SIRET
|
|
* (source "sale" : memes chiffres a separateurs pres) sont fusionnes,
|
|
* derniere occurrence gagnante — l'upsert ne verrait qu'une ligne de toute
|
|
* facon, et le compte `rows_upserted` reflete ainsi les transporteurs
|
|
* distincts.
|
|
*
|
|
* @param array<int, array<string, mixed>> $items
|
|
*
|
|
* @return array{rows: list<array<string, mixed>>, skipped: int}
|
|
*/
|
|
public static function mapMany(array $items): array
|
|
{
|
|
$bySiret = [];
|
|
$skipped = 0;
|
|
|
|
foreach ($items as $item) {
|
|
$row = self::mapOne($item);
|
|
|
|
if (null === $row) {
|
|
++$skipped;
|
|
|
|
continue;
|
|
}
|
|
|
|
// Cle = SIRET normalise : une occurrence ulterieure ecrase la
|
|
// precedente (derniere gagnante).
|
|
$bySiret[$row['siret']] = $row;
|
|
}
|
|
|
|
return ['rows' => array_values($bySiret), 'skipped' => $skipped];
|
|
}
|
|
|
|
/**
|
|
* Mappe un item unique. Retourne null si le SIRET est absent ou vide
|
|
* (ligne inexploitable : pas de cle naturelle pour l'upsert).
|
|
*
|
|
* @param array<string, mixed> $item
|
|
*
|
|
* @return null|array<string, mixed>
|
|
*/
|
|
public static function mapOne(array $item): ?array
|
|
{
|
|
$siret = self::normalizeSiret(self::str($item['Siret'] ?? null));
|
|
|
|
if (null === $siret) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'siret' => $siret,
|
|
// Nom et Societe sont identiques a la source : une seule colonne.
|
|
'name' => self::str($item['Nom'] ?? null) ?? '',
|
|
'address' => self::str($item['Adresse'] ?? null),
|
|
'postal_code' => self::str($item['CodePostal'] ?? null),
|
|
'city' => self::str($item['Ville'] ?? null),
|
|
'phone' => self::str($item['Telephone_1'] ?? null),
|
|
'department' => self::str($item['Departement'] ?? null),
|
|
// Statut conserve brut (feed externe, valeurs non contraintes).
|
|
'status' => self::str($item['Statut'] ?? null) ?? '',
|
|
'validity_date' => self::parseDate(self::str($item['Validite'] ?? null)),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Normalise un SIRET : ne conserve que les chiffres. Null si vide.
|
|
* La source est "sale" (longueurs variables 7 a 14) : aucune contrainte
|
|
* de longueur, on stocke les chiffres tels quels.
|
|
*/
|
|
public static function normalizeSiret(?string $raw): ?string
|
|
{
|
|
if (null === $raw) {
|
|
return null;
|
|
}
|
|
|
|
$digits = preg_replace('/\D+/', '', $raw) ?? '';
|
|
|
|
return '' === $digits ? null : $digits;
|
|
}
|
|
|
|
/**
|
|
* Convertit une date "dd/mm/yyyy" en "yyyy-mm-dd". Null si le format ne
|
|
* correspond pas ou si la date n'est pas un jour calendaire valide
|
|
* (garde-fou : evite un INSERT en erreur sur une date impossible).
|
|
*/
|
|
public static function parseDate(?string $raw): ?string
|
|
{
|
|
if (null === $raw || !preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $raw, $m)) {
|
|
return null;
|
|
}
|
|
|
|
$day = (int) $m[1];
|
|
$month = (int) $m[2];
|
|
$year = (int) $m[3];
|
|
|
|
if (!checkdate($month, $day, $year)) {
|
|
return null;
|
|
}
|
|
|
|
return sprintf('%04d-%02d-%02d', $year, $month, $day);
|
|
}
|
|
|
|
/**
|
|
* Trim d'une valeur scalaire ; null si la chaine resultante est vide.
|
|
*/
|
|
private static function str(mixed $value): ?string
|
|
{
|
|
if (null === $value) {
|
|
return null;
|
|
}
|
|
|
|
$trimmed = trim((string) $value);
|
|
|
|
return '' === $trimmed ? null : $trimmed;
|
|
}
|
|
}
|