From abe663d355818a20766643e020ebb186f6a3c495 Mon Sep 17 00:00:00 2001 From: tristan Date: Fri, 12 Jun 2026 15:49:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(transport)=20:=20synchronisation=20du=20r?= =?UTF-8?q?=C3=A9f=C3=A9rentiel=20codes=20IDTF=20(ERP-149)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (upsert sur (schema, idtf_number) + soft-delete + journal). Scope road ; discriminant schema road/water conservé. - migration : tables idtf_product + idtf_sync_log (COMMENT ON COLUMN sur chaque colonne, unique (schema, idtf_number), cas_numbers JSONB) - IdtfSheetParser : parsing pur d'une matrice (détection dynamique de l'en-tête, mapping par libellé, CAS split, date dd-mm-yyyy -> ISO) + tests unitaires - SyncIdtfCommand : options --schema / --file / --dry-run, POST avec fields[] explicites (export 11 colonnes), upsert DBAL transactionnel - cible make idtf-sync - tests fonctionnels via .xlsx généré (parsing/upsert/journal/soft-delete) Réutilise framework.http_client (activé pour QUALIMAT, ERP-39). phpoffice/phpspreadsheet déjà présent. --- makefile | 8 + migrations/Version20260612160000.php | 120 +++++++ .../Application/Idtf/IdtfSheetParser.php | 219 ++++++++++++ .../Console/SyncIdtfCommand.php | 317 ++++++++++++++++++ .../Application/Idtf/IdtfSheetParserTest.php | 110 ++++++ .../Console/SyncIdtfCommandTest.php | 163 +++++++++ 6 files changed, 937 insertions(+) create mode 100644 migrations/Version20260612160000.php create mode 100644 src/Module/Transport/Application/Idtf/IdtfSheetParser.php create mode 100644 src/Module/Transport/Infrastructure/Console/SyncIdtfCommand.php create mode 100644 tests/Module/Transport/Application/Idtf/IdtfSheetParserTest.php create mode 100644 tests/Module/Transport/Infrastructure/Console/SyncIdtfCommandTest.php diff --git a/makefile b/makefile index f07ca38..8cac32b 100644 --- a/makefile +++ b/makefile @@ -257,6 +257,14 @@ seed-rbac: qualimat-sync: $(SYMFONY_CONSOLE) --no-interaction app:qualimat:sync +# Synchronise le referentiel des codes IDTF (ERP-149) depuis l'export Excel +# icrt-idtf.com : upsert sur (schema, idtf_number) + soft-delete + journal. +# Idempotent (refresh complet). +# Options : --schema=road|water (defaut road), --dry-run (analyse sans +# ecriture), --file= (source locale au lieu du telechargement). +idtf-sync: + $(SYMFONY_CONSOLE) --no-interaction app:idtf:sync + # Attention, supprime votre bdd local db-reset: $(DOCKER_COMPOSE) down -v diff --git a/migrations/Version20260612160000.php b/migrations/Version20260612160000.php new file mode 100644 index 0000000..6385a3e --- /dev/null +++ b/migrations/Version20260612160000.php @@ -0,0 +1,120 @@ +addSql(<<<'SQL' + CREATE TABLE idtf_product ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + idtf_number INTEGER NOT NULL, + schema VARCHAR(8) NOT NULL, + product_group VARCHAR(255) DEFAULT NULL, + name TEXT NOT NULL, + cleaning_regime VARCHAR(64) NOT NULL, + important_requirements TEXT DEFAULT NULL, + mandatory_date DATE DEFAULT NULL, + related_products TEXT DEFAULT NULL, + formula VARCHAR(255) DEFAULT NULL, + eural_code VARCHAR(64) DEFAULT NULL, + cas_numbers JSONB DEFAULT '[]' NOT NULL, + footnotes TEXT DEFAULT NULL, + source_export_date DATE NOT NULL, + is_active BOOLEAN DEFAULT TRUE NOT NULL, + last_synced_at TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uq_idtf_product_schema_number UNIQUE (schema, idtf_number), + CONSTRAINT chk_idtf_product_schema CHECK (schema IN ('road', 'water')) + ) + SQL); + $this->addSql('CREATE INDEX idx_idtf_product_active ON idtf_product (schema, is_active)'); + + $this->comment('idtf_product', '_table', "Referentiel des codes IDTF (marchandise + regime de nettoyage transport), synchronise depuis l'export Excel icrt-idtf.com."); + $this->comment('idtf_product', 'id', 'Cle technique auto-incrementee.'); + $this->comment('idtf_product', 'idtf_number', 'Numero IDTF de la marchandise (identifiant metier source). Unique par schema.'); + $this->comment('idtf_product', 'schema', "Mode de transport / schema IDTF : 'road' (routier) ou 'water' (fluvial). Discriminant d'unicite avec idtf_number."); + $this->comment('idtf_product', 'product_group', "Groupe de produit (colonne Product Group de l'export). Nullable."); + $this->comment('idtf_product', 'name', "Nom de la marchandise (libelle FR de l'export)."); + $this->comment('idtf_product', 'cleaning_regime', 'Regime de nettoyage minimal exige (A, B, C, Interdit, ...).'); + $this->comment('idtf_product', 'important_requirements', 'Exigences importantes associees. Nullable.'); + $this->comment('idtf_product', 'mandatory_date', "Date d'application obligatoire du regime (convertie depuis dd-mm-yyyy). Nullable."); + $this->comment('idtf_product', 'related_products', 'Produits apparentes (texte libre). Nullable.'); + $this->comment('idtf_product', 'formula', 'Formule chimique de la marchandise. Nullable.'); + $this->comment('idtf_product', 'eural_code', 'Code EURAL (dechet) associe. Nullable.'); + $this->comment('idtf_product', 'cas_numbers', 'Liste des numeros CAS (JSONB), eclatee depuis la cellule "Numero CAS" separee par ";". Tableau vide si absent.'); + $this->comment('idtf_product', 'footnotes', "Annotations / notes de bas de page de l'export. Nullable."); + $this->comment('idtf_product', 'source_export_date', 'Date d\'export du fichier source (preambule "Export date:").'); + $this->comment('idtf_product', 'is_active', 'Faux = ligne absente du dernier export (soft-delete). Toute ligne non revue par le dernier run passe a FALSE.'); + $this->comment('idtf_product', 'last_synced_at', 'Horodatage du run de synchro ayant vu cette ligne en dernier (soft-delete : last_synced_at < run courant).'); + + $this->addSql(<<<'SQL' + CREATE TABLE idtf_sync_log ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + schema VARCHAR(8) NOT NULL, + export_date DATE NOT NULL, + rows_total INT NOT NULL, + rows_upserted INT NOT NULL, + rows_deactivated INT NOT NULL, + created_at TIMESTAMP(6) WITHOUT TIME ZONE DEFAULT NOW() NOT NULL, + PRIMARY KEY (id) + ) + SQL); + + $this->comment('idtf_sync_log', '_table', 'Journal des synchronisations IDTF (une ligne par run de la commande app:idtf:sync).'); + $this->comment('idtf_sync_log', 'id', 'Cle technique auto-incrementee.'); + $this->comment('idtf_sync_log', 'schema', "Mode de transport synchronise : 'road' ou 'water'."); + $this->comment('idtf_sync_log', 'export_date', "Date d'export du fichier source traite par ce run."); + $this->comment('idtf_sync_log', 'rows_total', 'Nombre de lignes exploitables lues dans le fichier.'); + $this->comment('idtf_sync_log', 'rows_upserted', 'Nombre de lignes inserees ou mises a jour.'); + $this->comment('idtf_sync_log', 'rows_deactivated', 'Nombre de lignes passees a is_active=false (absentes de cet export).'); + $this->comment('idtf_sync_log', 'created_at', 'Horodatage de fin du run (insertion du journal).'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE IF EXISTS idtf_sync_log'); + $this->addSql('DROP TABLE IF EXISTS idtf_product'); + } + + /** + * Pose un COMMENT ON TABLE/COLUMN en dollar-quoting Postgres ($_$...$_$) + * pour eviter tout echappement d'apostrophes dans les descriptions. + */ + private function comment(string $table, string $column, string $description): void + { + $quotedTable = '"'.str_replace('"', '""', $table).'"'; + + if ('_table' === $column) { + $this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description)); + + return; + } + + $this->addSql(sprintf( + 'COMMENT ON COLUMN %s.%s IS $_$%s$_$', + $quotedTable, + '"'.str_replace('"', '""', $column).'"', + $description, + )); + } +} diff --git a/src/Module/Transport/Application/Idtf/IdtfSheetParser.php b/src/Module/Transport/Application/Idtf/IdtfSheetParser.php new file mode 100644 index 0000000..6ff36a0 --- /dev/null +++ b/src/Module/Transport/Application/Idtf/IdtfSheetParser.php @@ -0,0 +1,219 @@ +> $matrix + * + * @return array{exportDate: null|string, rows: list>} + */ + 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> $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> $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 $header + * + * @return array + */ + 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 + */ + 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 $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)); + } +} diff --git a/src/Module/Transport/Infrastructure/Console/SyncIdtfCommand.php b/src/Module/Transport/Infrastructure/Console/SyncIdtfCommand.php new file mode 100644 index 0000000..54400b4 --- /dev/null +++ b/src/Module/Transport/Infrastructure/Console/SyncIdtfCommand.php @@ -0,0 +1,317 @@ + + */ + private const array EXPORT_FIELDS = [ + 'product_number_idtf', + 'product_name', + 'minimum_cleaning_regime', + 'important_requirements', + 'date_mandatory', + 'related_products', + 'formula', + 'product_number_eural', + 'product_number_cas', + 'footnotes', + ]; + + public function __construct( + private readonly Connection $connection, + private readonly HttpClientInterface $httpClient, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addOption('schema', null, InputOption::VALUE_REQUIRED, "Module IDTF : 'road' (routier) ou 'water' (fluvial).", 'road') + ->addOption('file', null, InputOption::VALUE_REQUIRED, "Chemin d'un .xlsx local (court-circuite le telechargement, utile pour tests/rejeu).") + ->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); + $schema = (string) $input->getOption('schema'); + $dryRun = (bool) $input->getOption('dry-run'); + $file = $input->getOption('file'); + + if (!in_array($schema, ['road', 'water'], true)) { + $io->error("--schema doit valoir 'road' ou 'water'."); + + return Command::INVALID; + } + + // 1. Recuperation du binaire xlsx (local ou via POST). + try { + $xlsx = null !== $file ? $this->readLocal((string) $file) : $this->downloadExport($schema); + } catch (Throwable $e) { + $io->error('Telechargement/lecture impossible : '.$e->getMessage()); + + return Command::FAILURE; + } + + // 2. Parsing (xlsx -> matrice -> lignes normalisees). + try { + $parsed = IdtfSheetParser::parse($this->toMatrix($xlsx)); + } catch (Throwable $e) { + $io->error('Parsing impossible : '.$e->getMessage()); + + return Command::FAILURE; + } + + $rows = $parsed['rows']; + $exportDate = $parsed['exportDate'] ?? new DateTimeImmutable()->format('Y-m-d'); + + $io->section(sprintf('IDTF %s — export du %s', mb_strtoupper($schema), $exportDate)); + $io->writeln(sprintf('%d lignes exploitables lues.', count($rows))); + + if ($dryRun) { + $this->renderPreview($io, $rows); + $io->note(sprintf('Dry-run : aucune ecriture. (%d lignes au total)', count($rows))); + + return Command::SUCCESS; + } + + // 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($schema, $exportDate, $rows, $run); + $deactivated = $this->deactivateMissing($schema, $run); + $this->log($schema, $exportDate, count($rows), $upserted, $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 desactive(s).', $upserted, $deactivated)); + + return Command::SUCCESS; + } + + /** + * Rejoue le POST du generateur pour recuperer le binaire xlsx complet. + * Le formulaire poste sur lui-meme ; pas besoin de GET/cookies prealables. + */ + private function downloadExport(string $schema): string + { + // Corps construit a la main : http-client encoderait fields[] en + // indices numerotes, on veut bien des "fields[]=..." repetes. + $pairs = [ + 'schema='.$schema, + 'type%5B%5D='.$schema, + 'roadRegime%5B%5D=all', + 'waterRegime%5B%5D=all', + 'groups%5B%5D=all', + 'products%5B%5D=all', + ]; + + foreach (self::EXPORT_FIELDS as $field) { + $pairs[] = 'fields%5B%5D='.$field; + } + + $pairs[] = 'generateExcel='; + + $response = $this->httpClient->request('POST', self::GENERATOR_URL, [ + 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'], + 'body' => implode('&', $pairs), + 'timeout' => 90, + ]); + + $content = $response->getContent(); + $contentType = $response->getHeaders(false)['content-type'][0] ?? ''; + + // Garde-fou : un HTML signifie un POST rejete (filtres/payload). + if (!str_contains($contentType, 'spreadsheet') && !str_starts_with($content, "PK\x03\x04")) { + throw new RuntimeException(sprintf('Reponse non-xlsx (content-type: %s). Verifie le payload.', $contentType)); + } + + return $content; + } + + private function readLocal(string $path): string + { + $raw = @file_get_contents($path); + + if (false === $raw) { + throw new RuntimeException(sprintf('Fichier illisible : %s', $path)); + } + + return $raw; + } + + /** + * Charge le binaire xlsx via PhpSpreadsheet et retourne la feuille active + * sous forme de matrice 0-indexee (lignes/colonnes). + * + * @return array> + */ + private function toMatrix(string $xlsx): array + { + $tmp = tempnam(sys_get_temp_dir(), 'idtf_').'.xlsx'; + file_put_contents($tmp, $xlsx); + + try { + // toArray(null, true, true, false) : colonnes 0-indexees. + return IOFactory::load($tmp)->getActiveSheet()->toArray(null, true, true, false); + } finally { + @unlink($tmp); + } + } + + /** + * Upsert de toutes les lignes (cle naturelle = schema + idtf_number). + * + * @param list> $rows + */ + private function upsertAll(string $schema, string $exportDate, array $rows, string $run): int + { + $sql = <<<'SQL' + INSERT INTO idtf_product + (idtf_number, schema, product_group, name, cleaning_regime, important_requirements, + mandatory_date, related_products, formula, eural_code, cas_numbers, footnotes, + source_export_date, is_active, last_synced_at) + VALUES + (:idtf, :schema, :grp, :name, :regime, :req, :mdate, :related, :formula, :eural, + CAST(:cas AS JSONB), :foot, :export, TRUE, :run) + ON CONFLICT (schema, idtf_number) DO UPDATE SET + product_group = EXCLUDED.product_group, + name = EXCLUDED.name, + cleaning_regime = EXCLUDED.cleaning_regime, + important_requirements = EXCLUDED.important_requirements, + mandatory_date = EXCLUDED.mandatory_date, + related_products = EXCLUDED.related_products, + formula = EXCLUDED.formula, + eural_code = EXCLUDED.eural_code, + cas_numbers = EXCLUDED.cas_numbers, + footnotes = EXCLUDED.footnotes, + source_export_date = EXCLUDED.source_export_date, + is_active = TRUE, + last_synced_at = EXCLUDED.last_synced_at + SQL; + + $count = 0; + + foreach ($rows as $r) { + $this->connection->executeStatement($sql, [ + 'idtf' => $r['idtf_number'], + 'schema' => $schema, + 'grp' => $r['product_group'], + 'name' => $r['name'], + 'regime' => $r['cleaning_regime'], + 'req' => $r['important_requirements'], + 'mdate' => $r['mandatory_date'], + 'related' => $r['related_products'], + 'formula' => $r['formula'], + 'eural' => $r['eural_code'], + 'cas' => json_encode($r['cas_numbers'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + 'foot' => $r['footnotes'], + 'export' => $exportDate, + 'run' => $run, + ]); + ++$count; + } + + return $count; + } + + /** + * Soft-delete : toute ligne du schema active non revue par ce run passe a + * is_active=false. + */ + private function deactivateMissing(string $schema, string $run): int + { + return (int) $this->connection->executeStatement( + 'UPDATE idtf_product SET is_active = FALSE WHERE schema = :schema AND is_active = TRUE AND last_synced_at < :run', + ['schema' => $schema, 'run' => $run], + ); + } + + private function log(string $schema, string $exportDate, int $total, int $upserted, int $deactivated): void + { + $this->connection->executeStatement( + <<<'SQL' + INSERT INTO idtf_sync_log (schema, export_date, rows_total, rows_upserted, rows_deactivated) + VALUES (:schema, :export, :total, :upserted, :deactivated) + SQL, + [ + 'schema' => $schema, + 'export' => $exportDate, + 'total' => $total, + 'upserted' => $upserted, + 'deactivated' => $deactivated, + ], + ); + } + + /** + * @param list> $rows + */ + private function renderPreview(SymfonyStyle $io, array $rows): void + { + $io->table( + ['IDTF', 'Nom', 'Regime', 'CAS'], + array_map(static fn (array $r): array => [ + (string) $r['idtf_number'], + mb_strimwidth((string) $r['name'], 0, 50, '…'), + (string) $r['cleaning_regime'], + implode(', ', $r['cas_numbers']), + ], array_slice($rows, 0, 15)), + ); + } +} diff --git a/tests/Module/Transport/Application/Idtf/IdtfSheetParserTest.php b/tests/Module/Transport/Application/Idtf/IdtfSheetParserTest.php new file mode 100644 index 0000000..2348eaa --- /dev/null +++ b/tests/Module/Transport/Application/Idtf/IdtfSheetParserTest.php @@ -0,0 +1,110 @@ +sampleMatrix()); + self::assertSame('2026-06-12', $parsed['exportDate']); + } + + public function testParsesAndNormalizesFirstRow(): void + { + $parsed = IdtfSheetParser::parse($this->sampleMatrix()); + $row = $parsed['rows'][0]; + + self::assertSame(30748, $row['idtf_number']); + self::assertSame('Argiles avec régime de nettoyage C', $row['name']); + self::assertSame('C', $row['cleaning_regime']); + self::assertSame('2026-04-02', $row['mandatory_date']); + self::assertSame('Al2O3', $row['formula']); + self::assertSame('01 01 01', $row['eural_code']); + self::assertSame(['7631-86-9', '1344-28-1'], $row['cas_numbers']); + self::assertSame('Note 1', $row['footnotes']); + } + + public function testSkipsEmptyAndNonNumericRows(): void + { + $parsed = IdtfSheetParser::parse($this->sampleMatrix()); + + // 2 lignes exploitables (30748 et 30744) ; vide + "abc" ignorees. + self::assertCount(2, $parsed['rows']); + self::assertSame(30744, $parsed['rows'][1]['idtf_number']); + } + + public function testEmptyOptionalCellsBecomeNullAndCasEmpty(): void + { + $parsed = IdtfSheetParser::parse($this->sampleMatrix()); + $row = $parsed['rows'][1]; // 30744 + + self::assertNull($row['mandatory_date']); + self::assertNull($row['formula']); + self::assertNull($row['product_group']); + self::assertSame([], $row['cas_numbers']); + } + + public function testColumnOrderIsResolvedByLabel(): void + { + // En-tete dans un ordre different : le mapping doit suivre les libelles. + $matrix = [ + ['Export date: 1-1-2026'], + ['Numéro CAS', 'Numéro IDTF', 'Nom de la marchandise', 'Régime de nettoyage'], + ['7440-44-0', '99', 'Carbone', 'B'], + ]; + + $parsed = IdtfSheetParser::parse($matrix); + $row = $parsed['rows'][0]; + + self::assertSame(99, $row['idtf_number']); + self::assertSame('Carbone', $row['name']); + self::assertSame('B', $row['cleaning_regime']); + self::assertSame(['7440-44-0'], $row['cas_numbers']); + } + + public function testThrowsWhenHeaderMissing(): void + { + $this->expectException(RuntimeException::class); + IdtfSheetParser::parse([['foo', 'bar'], ['1', '2']]); + } + + public function testExportDateNullWhenAbsent(): void + { + $matrix = [ + ['Numéro IDTF', 'Nom de la marchandise', 'Régime de nettoyage'], + ['1', 'X', 'A'], + ]; + + self::assertNull(IdtfSheetParser::parse($matrix)['exportDate']); + } + + /** + * Matrice representative de l'export reel : preambule (lignes 0-1), ligne + * vide (2), en-tete (3) puis donnees. + * + * @return array> + */ + private function sampleMatrix(): array + { + return [ + ['Export date: 12-6-2026'], + ['Changes in the database after this date...'], + [], + ['Numéro IDTF', 'Product Group', 'Nom de la marchandise', 'Régime de nettoyage', 'Exigences importantes', 'Date d’application obligatoire', 'Produits apparentés', 'Formule', 'Code EURAL', 'Numéro CAS', 'Annotations'], + ['30748', 'Substances inorganiques', 'Argiles avec régime de nettoyage C', 'C', 'Exigence X', '02-04-2026', 'Poudre argile', 'Al2O3', '01 01 01', '7631-86-9 ; 1344-28-1', 'Note 1'], + ['', '', '', '', '', '', '', '', '', '', ''], + ['abc', 'ligne non numerique a ignorer', '', '', '', '', '', '', '', '', ''], + ['30744', '', 'Additifs alimentaires', 'A', '', '', '', '', '', '', ''], + ]; + } +} diff --git a/tests/Module/Transport/Infrastructure/Console/SyncIdtfCommandTest.php b/tests/Module/Transport/Infrastructure/Console/SyncIdtfCommandTest.php new file mode 100644 index 0000000..c01e530 --- /dev/null +++ b/tests/Module/Transport/Infrastructure/Console/SyncIdtfCommandTest.php @@ -0,0 +1,163 @@ + parser -> DBAL). + * + * @internal + */ +final class SyncIdtfCommandTest extends KernelTestCase +{ + private Connection $connection; + + protected function setUp(): void + { + self::bootKernel(); + + /** @var Connection $connection */ + $connection = self::getContainer()->get('doctrine.dbal.default_connection'); + $this->connection = $connection; + $this->purge(); + } + + protected function tearDown(): void + { + $this->purge(); + parent::tearDown(); + } + + public function testSyncParsesXlsxUpsertsAndLogs(): void + { + $path = $this->makeXlsx([ + ['Export date: 12-6-2026'], + ['Avertissement preambule'], + [], + ['Numéro IDTF', 'Product Group', 'Nom de la marchandise', 'Régime de nettoyage', 'Exigences importantes', 'Date d’application obligatoire', 'Produits apparentés', 'Formule', 'Code EURAL', 'Numéro CAS', 'Annotations'], + ['30748', 'Inorganiques', 'Argiles régime C', 'C', 'Exig X', '02-04-2026', 'Poudre', 'Al2O3', '01 01 01', '7631-86-9 ; 1344-28-1', 'Note'], + ['', '', '', '', '', '', '', '', '', '', ''], + ['30744', '', 'Additifs', 'A', '', '', '', '', '', '', ''], + ]); + + $tester = $this->runSync($path); + $tester->assertCommandIsSuccessful(); + + self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM idtf_product')); + + $row = $this->connection->fetchAssociative("SELECT * FROM idtf_product WHERE idtf_number = 30748 AND schema = 'road'"); + self::assertNotFalse($row); + self::assertSame('Argiles régime C', $row['name']); + self::assertSame('C', $row['cleaning_regime']); + self::assertSame('2026-04-02', $row['mandatory_date']); + self::assertSame('2026-06-12', $row['source_export_date']); + self::assertSame(['7631-86-9', '1344-28-1'], json_decode((string) $row['cas_numbers'], true)); + + $log = $this->connection->fetchAssociative('SELECT * FROM idtf_sync_log ORDER BY id DESC LIMIT 1'); + self::assertNotFalse($log); + self::assertSame('road', $log['schema']); + self::assertSame('2026-06-12', $log['export_date']); + self::assertSame(2, (int) $log['rows_total']); + self::assertSame(2, (int) $log['rows_upserted']); + self::assertSame(0, (int) $log['rows_deactivated']); + } + + public function testSecondSyncSoftDeletesMissing(): void + { + $header = ['Numéro IDTF', 'Nom de la marchandise', 'Régime de nettoyage']; + + $this->runSync($this->makeXlsx([ + ['Export date: 1-6-2026'], + $header, + ['100', 'Produit 100', 'A'], + ['200', 'Produit 200', 'B'], + ]))->assertCommandIsSuccessful(); + self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM idtf_product WHERE is_active = TRUE')); + + // 2e export sans 200 -> soft-delete de 200, mise a jour de 100. + $tester = $this->runSync($this->makeXlsx([ + ['Export date: 2-6-2026'], + $header, + ['100', 'Produit 100 maj', 'C'], + ])); + $tester->assertCommandIsSuccessful(); + + self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM idtf_product')); + self::assertSame(1, $this->countRows('SELECT COUNT(*) FROM idtf_product WHERE is_active = TRUE')); + self::assertSame(1, $this->countRows('SELECT COUNT(*) FROM idtf_product WHERE idtf_number = 200 AND is_active = FALSE')); + + $row100 = $this->connection->fetchAssociative('SELECT * FROM idtf_product WHERE idtf_number = 100'); + self::assertNotFalse($row100); + self::assertSame('Produit 100 maj', $row100['name']); + self::assertSame('C', $row100['cleaning_regime']); + + $log = $this->connection->fetchAssociative('SELECT * FROM idtf_sync_log ORDER BY id DESC LIMIT 1'); + self::assertNotFalse($log); + self::assertSame(1, (int) $log['rows_upserted']); + self::assertSame(1, (int) $log['rows_deactivated']); + } + + public function testInvalidSchemaIsRejected(): void + { + $path = $this->makeXlsx([ + ['Numéro IDTF', 'Nom de la marchandise', 'Régime de nettoyage'], + ['1', 'X', 'A'], + ]); + + $application = new Application(self::$kernel); + $tester = new CommandTester($application->find('app:idtf:sync')); + $exitCode = $tester->execute(['--file' => $path, '--schema' => 'air']); + + @unlink($path); + + self::assertSame(2, $exitCode); // Command::INVALID + self::assertSame(0, $this->countRows('SELECT COUNT(*) FROM idtf_product')); + } + + /** + * @param array> $matrix + */ + private function makeXlsx(array $matrix): string + { + $spreadsheet = new Spreadsheet(); + $spreadsheet->getActiveSheet()->fromArray($matrix, null, 'A1', true); + + $path = tempnam(sys_get_temp_dir(), 'idtf_').'.xlsx'; + new Xlsx($spreadsheet)->save($path); + $spreadsheet->disconnectWorksheets(); + + return $path; + } + + private function runSync(string $path): CommandTester + { + $application = new Application(self::$kernel); + $tester = new CommandTester($application->find('app:idtf:sync')); + $tester->execute(['--file' => $path, '--schema' => 'road']); + + @unlink($path); + + return $tester; + } + + private function countRows(string $sql): int + { + return (int) $this->connection->fetchOne($sql); + } + + private function purge(): void + { + $this->connection->executeStatement('DELETE FROM idtf_product'); + $this->connection->executeStatement('DELETE FROM idtf_sync_log'); + } +}