f9fec3e908
Auto Tag Develop / tag (push) Successful in 12s
## ERP-149 — Récupération des codes IDTF (transport routier) > ⚠️ MR **empilée** sur `feat/erp-39-qualimat-sync` (PR #99), elle-même sur la PR #97. Ordre de merge : **#97 → #99 → celle-ci**. Les bases se recibleront automatiquement. Commande console `app:idtf:sync` : récupère l'export Excel des codes IDTF (régimes de nettoyage transport) depuis icrt-idtf.com, le parse et synchronise une table référentielle. Scope **road** ; discriminant `schema` road/water conservé pour un futur fluvial. ### Contenu - **Migration** `Version20260612160000` (namespace racine) : `idtf_product` + `idtf_sync_log`, `COMMENT ON COLUMN` sur chaque colonne, unique `(schema, idtf_number)`, `cas_numbers` JSONB, soft-delete. - **`IdtfSheetParser`** : parsing **pur** d'une matrice (sans dépendance PhpSpreadsheet) — détection **dynamique** de la ligne d'en-tête, mapping par libellé normalisé (résiste au réordonnancement), CAS split sur `;`, date `dd-mm-yyyy` → ISO + `checkdate`, skip des lignes non numériques. - **`SyncIdtfCommand`** : options `--schema` (road|water) / `--file` / `--dry-run`. POST avec les **10 `fields[]` explicites** (le piège `fields[]=all` ne sort que 6 colonnes) → export 11 colonnes ; garde-fou content-type/signature ZIP. Upsert DBAL transactionnel + soft-delete + journal. - Cible `make idtf-sync`. ### Tests - Unitaires (`IdtfSheetParser` : en-tête dynamique, mapping, CAS, date, skip, ordre de colonnes). - Fonctionnels de la commande via un `.xlsx` **généré** par PhpSpreadsheet (parsing → upsert → journal → soft-delete + schéma invalide rejeté). - Suite complète **608** verte (hors flaky JWT connu). `ColumnsHaveSqlCommentTest` ✅. - Bout-en-bout réel : sync de **687 codes IDTF** (road). ### Décisions - Migration **namespace racine** (convention réelle ; pas de FK cross-module). - **Aucun changement Composer** : `phpoffice/phpspreadsheet` était déjà une dépendance (^5.7) — le bump initial vers ^5.8 a été reverté. - Réutilise `framework.http_client` activé par la PR QUALIMAT (raison de l'empilement sur #99). --------- Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #101 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
220 lines
7.3 KiB
PHP
220 lines
7.3 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Module\Transport\Application\Idtf;
|
||
|
||
use RuntimeException;
|
||
|
||
use function array_slice;
|
||
|
||
/**
|
||
* Parsing pur d'une matrice (lignes/colonnes 0-indexees, telle que retournee
|
||
* par PhpSpreadsheet::toArray) de l'export Excel IDTF vers des lignes
|
||
* normalisees pretes a l'upsert. Sans dependance a PhpSpreadsheet : la matrice
|
||
* est un simple tableau, ce qui rend le parsing testable en isolation.
|
||
*
|
||
* Robuste au reordonnancement des colonnes (mapping par libelle normalise) et
|
||
* aux lignes de preambule (detection dynamique de la ligne d'en-tete). Voir
|
||
* ERP-149 § 2.
|
||
*/
|
||
final class IdtfSheetParser
|
||
{
|
||
/**
|
||
* @param array<int, array<int, mixed>> $matrix
|
||
*
|
||
* @return array{exportDate: null|string, rows: list<array<string, mixed>>}
|
||
*/
|
||
public static function parse(array $matrix): array
|
||
{
|
||
$exportDate = self::extractExportDate($matrix);
|
||
$headerIndex = self::findHeaderIndex($matrix);
|
||
|
||
if (null === $headerIndex) {
|
||
throw new RuntimeException("Ligne d'en-tete introuvable (colonne 'Numero IDTF').");
|
||
}
|
||
|
||
$map = self::buildColumnMap($matrix[$headerIndex]);
|
||
|
||
if (!isset($map['idtf_number'])) {
|
||
throw new RuntimeException("Colonne 'Numero IDTF' introuvable dans l'en-tete.");
|
||
}
|
||
|
||
$rows = [];
|
||
|
||
foreach (array_slice($matrix, $headerIndex + 1) as $row) {
|
||
$idtf = trim((string) ($row[$map['idtf_number']] ?? ''));
|
||
|
||
// Ligne vide / non exploitable : pas d'identifiant numerique.
|
||
if ('' === $idtf || !ctype_digit($idtf)) {
|
||
continue;
|
||
}
|
||
|
||
$rows[] = [
|
||
'idtf_number' => (int) $idtf,
|
||
'product_group' => self::val($row, $map['product_group'] ?? null),
|
||
'name' => self::val($row, $map['name'] ?? null) ?? '',
|
||
'cleaning_regime' => self::val($row, $map['cleaning_regime'] ?? null) ?? '',
|
||
'important_requirements' => self::val($row, $map['important_requirements'] ?? null),
|
||
'mandatory_date' => self::parseDate(self::val($row, $map['mandatory_date'] ?? null)),
|
||
'related_products' => self::val($row, $map['related_products'] ?? null),
|
||
'formula' => self::val($row, $map['formula'] ?? null),
|
||
'eural_code' => self::val($row, $map['eural_code'] ?? null),
|
||
'cas_numbers' => self::splitCas(self::val($row, $map['cas'] ?? null)),
|
||
'footnotes' => self::val($row, $map['footnotes'] ?? null),
|
||
];
|
||
}
|
||
|
||
return ['exportDate' => $exportDate, 'rows' => $rows];
|
||
}
|
||
|
||
/**
|
||
* Cherche une date "d-m-Y" dans les premieres lignes (preambule
|
||
* "Export date: 12-6-2026") et la convertit en "Y-m-d". Null si absente.
|
||
*
|
||
* @param array<int, array<int, mixed>> $matrix
|
||
*/
|
||
public static function extractExportDate(array $matrix): ?string
|
||
{
|
||
foreach (array_slice($matrix, 0, 5) as $row) {
|
||
$line = implode(' ', array_map(static fn (mixed $c): string => (string) $c, $row));
|
||
|
||
if (preg_match('/(\d{1,2})-(\d{1,2})-(\d{4})/', $line, $m)) {
|
||
$day = (int) $m[1];
|
||
$month = (int) $m[2];
|
||
$year = (int) $m[3];
|
||
|
||
if (checkdate($month, $day, $year)) {
|
||
return sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Index de la ligne d'en-tete : premiere ligne contenant une cellule dont
|
||
* le libelle normalise contient "numero idtf".
|
||
*
|
||
* @param array<int, array<int, mixed>> $matrix
|
||
*/
|
||
private static function findHeaderIndex(array $matrix): ?int
|
||
{
|
||
foreach ($matrix as $i => $row) {
|
||
foreach ($row as $cell) {
|
||
if (str_contains(self::normalize((string) $cell), 'numero idtf')) {
|
||
return $i;
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Construit le mapping logique -> index de colonne a partir de la ligne
|
||
* d'en-tete (resiste au reordonnancement via fields[]).
|
||
*
|
||
* @param array<int, mixed> $header
|
||
*
|
||
* @return array<string, int>
|
||
*/
|
||
private static function buildColumnMap(array $header): array
|
||
{
|
||
$map = [];
|
||
|
||
foreach ($header as $col => $label) {
|
||
$n = self::normalize((string) $label);
|
||
|
||
$key = match (true) {
|
||
str_contains($n, 'numero idtf') => 'idtf_number',
|
||
str_contains($n, 'product group'),
|
||
str_contains($n, 'groupe') => 'product_group',
|
||
str_contains($n, 'nom de la marchandise') => 'name',
|
||
str_contains($n, 'regime de nettoyage') => 'cleaning_regime',
|
||
str_contains($n, 'exigences importantes') => 'important_requirements',
|
||
str_contains($n, 'date d application') => 'mandatory_date',
|
||
str_contains($n, 'produits apparentes') => 'related_products',
|
||
str_contains($n, 'formule') => 'formula',
|
||
str_contains($n, 'code eural') => 'eural_code',
|
||
str_contains($n, 'numero cas') => 'cas',
|
||
str_contains($n, 'annotations') => 'footnotes',
|
||
default => null,
|
||
};
|
||
|
||
if (null !== $key && !isset($map[$key])) {
|
||
$map[$key] = (int) $col;
|
||
}
|
||
}
|
||
|
||
return $map;
|
||
}
|
||
|
||
/**
|
||
* Convertit une date "dd-mm-yyyy" en "yyyy-mm-dd". Null si format invalide
|
||
* ou date calendaire impossible.
|
||
*/
|
||
private static function parseDate(?string $raw): ?string
|
||
{
|
||
if (null === $raw || !preg_match('/^(\d{1,2})-(\d{1,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);
|
||
}
|
||
|
||
/**
|
||
* Eclate une cellule "Numero CAS" sur ';' en liste de chaines non vides.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
private static function splitCas(?string $raw): array
|
||
{
|
||
if (null === $raw) {
|
||
return [];
|
||
}
|
||
|
||
$parts = array_map('trim', explode(';', $raw));
|
||
|
||
return array_values(array_filter($parts, static fn (string $v): bool => '' !== $v));
|
||
}
|
||
|
||
/**
|
||
* Valeur d'une cellule par index : trim, null si absente/vide.
|
||
*
|
||
* @param array<int, mixed> $row
|
||
*/
|
||
private static function val(array $row, ?int $col): ?string
|
||
{
|
||
if (null === $col) {
|
||
return null;
|
||
}
|
||
|
||
$v = trim((string) ($row[$col] ?? ''));
|
||
|
||
return '' === $v ? null : $v;
|
||
}
|
||
|
||
/**
|
||
* Normalise un libelle d'en-tete : minuscules, sans accents ni apostrophes,
|
||
* espaces compresses (pour un matching robuste).
|
||
*/
|
||
private static function normalize(string $s): string
|
||
{
|
||
$s = str_replace(['’', "'"], ' ', $s);
|
||
$s = (string) iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
|
||
$s = mb_strtolower($s);
|
||
|
||
return trim((string) preg_replace('/\s+/', ' ', $s));
|
||
}
|
||
}
|