Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c888a45cc4 | |||
| abe663d355 |
@@ -1,9 +1,13 @@
|
||||
# Active le composant HTTP Client (symfony/http-client) et enregistre
|
||||
# l'autowiring de HttpClientInterface. Utilise par les commandes de
|
||||
# synchronisation de referentiels externes (QUALIMAT, IDTF...).
|
||||
#
|
||||
# User-Agent navigateur neutre : les sources (qualimat.org sous WordPress/WAF,
|
||||
# icrt-idtf.com) filtrent souvent les UA de bibliotheque/vides ; un UA de type
|
||||
# navigateur evite les blocages anti-bot sans reveler l'application.
|
||||
framework:
|
||||
http_client:
|
||||
default_options:
|
||||
timeout: 30
|
||||
headers:
|
||||
User-Agent: 'Starseed-ERP (referentiel-sync)'
|
||||
User-Agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
|
||||
|
||||
@@ -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=<chemin.xlsx> (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
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* ERP-149 (Module Transport) : referentiel des codes IDTF (regimes de nettoyage
|
||||
* transport).
|
||||
*
|
||||
* Tables alimentees par la commande `app:idtf:sync` (parsing de l'export Excel
|
||||
* icrt-idtf.com, upsert sur (schema, idtf_number) + soft-delete + journal).
|
||||
* Aucune FK cross-module : migration au namespace racine `DoctrineMigrations`.
|
||||
*/
|
||||
final class Version20260612160000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-149 : tables idtf_product + idtf_sync_log (referentiel codes IDTF, synchro console depuis l\'export Excel).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\Console;
|
||||
|
||||
use App\Module\Transport\Application\Idtf\IdtfSheetParser;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
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 in_array;
|
||||
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
use const JSON_UNESCAPED_UNICODE;
|
||||
|
||||
/**
|
||||
* ERP-149 : synchronise le referentiel des codes IDTF (regimes de nettoyage
|
||||
* transport).
|
||||
*
|
||||
* Recupere l'export Excel depuis le generateur icrt-idtf.com (ou un fichier
|
||||
* local), le parse et synchronise `idtf_product` de facon transactionnelle :
|
||||
* upsert sur (schema, idtf_number), soft-delete des absents, journal dans
|
||||
* `idtf_sync_log`. Idempotente (refresh complet).
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:idtf:sync',
|
||||
description: 'Synchronise le referentiel des codes IDTF depuis l\'export Excel icrt-idtf.com (upsert + soft-delete + journal).',
|
||||
)]
|
||||
final class SyncIdtfCommand extends Command
|
||||
{
|
||||
private const string GENERATOR_URL = 'https://www.icrt-idtf.com/fr/excel-generator/';
|
||||
|
||||
/**
|
||||
* Champs a cocher explicitement : `fields[]=all` ne deplie PAS les colonnes
|
||||
* cote serveur (6 colonnes seulement). Cette liste donne l'export complet
|
||||
* (11 colonnes). Cf. ERP-149 § 1.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
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<int, array<int, mixed>>
|
||||
*/
|
||||
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<array<string, mixed>> $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<array<string, mixed>> $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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Application\Idtf;
|
||||
|
||||
use App\Module\Transport\Application\Idtf\IdtfSheetParser;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class IdtfSheetParserTest extends TestCase
|
||||
{
|
||||
public function testExtractsExportDate(): void
|
||||
{
|
||||
$parsed = IdtfSheetParser::parse($this->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<int, array<int, mixed>>
|
||||
*/
|
||||
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', '', '', '', '', '', '', ''],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Infrastructure\Console;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
/**
|
||||
* Test fonctionnel de `app:idtf:sync` via --file : genere un vrai .xlsx, le
|
||||
* passe a la commande et verifie le parsing, l'upsert, le journal et le
|
||||
* soft-delete (chemin complet IOFactory -> 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<int, array<int, mixed>> $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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user