Compare commits

...

3 Commits

Author SHA1 Message Date
Matthieu e607cccf08 feat(transport) : permissions carriers + sidebar (ERP-153)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m50s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m36s
Socle RBAC du module Transport (M4 § 5) :
- TransportModule::permissions() declare transport.carriers.{view,manage,archive}
- RbacSeeder::MATRIX (§ 5.2) : Bureau (view+manage), Commerciale (view) ;
  Compta/Usine aucun acces ; archive admin seul
- config/sidebar.php : section Transport + item /carriers (gate transport.carriers.view)
- i18n sidebar.transport.{section,carriers}
- 3 miroirs RBAC alignes : sidebar.php, personas.ts (user-full), SeedE2ECommand.php
- TransportModuleTest : garde-fou sur le jeu de permissions
2026-06-15 18:11:15 +02:00
gitea-actions 8b8fb8c2aa chore: bump version to v0.1.126
Auto Tag Develop / tag (push) Successful in 11s
Build & Push Docker Image / build (push) Successful in 24s
2026-06-15 15:45:36 +00:00
tristan f9fec3e908 feat(transport) : synchronisation du référentiel codes IDTF (ERP-149) (#101)
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>
2026-06-15 15:45:23 +00:00
16 changed files with 1057 additions and 13 deletions
+4 -2
View File
@@ -17,13 +17,15 @@ doctrine:
# - `qualimat_carrier` / `qualimat_sync_log` : referentiel
# transporteurs synchronise en DBAL brut (upsert `ON CONFLICT`)
# par `app:qualimat:sync`, hors ORM.
# - `idtf_product` / `idtf_sync_log` : referentiel codes IDTF
# synchronise en DBAL brut par `app:idtf:sync`, hors ORM.
# Sans ce filtre, schema:update les considere comme "orphelines" et
# genere un `DROP TABLE` qui casse la base de test apres chaque
# `make test-db-setup` (la migration les a creees, schema:update les
# supprime juste apres). Creation / suppression restent pilotees par
# les migrations (audit_log : Version20260420202749 ; qualimat :
# Version20260612150000).
schema_filter: '~^(?!(?:audit_log|qualimat_carrier|qualimat_sync_log)$).+~'
# Version20260612150000 ; idtf : Version20260612160000).
schema_filter: '~^(?!(?:audit_log|qualimat_carrier|qualimat_sync_log|idtf_product|idtf_sync_log)$).+~'
audit:
url: '%env(resolve:DATABASE_URL)%'
orm:
+5 -1
View File
@@ -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'
+17
View File
@@ -78,6 +78,23 @@ return [
],
],
],
// Section "Transport" (M4, ERP-153) : pole logistique, porte le repertoire
// transporteurs. L'item est gate par `transport.carriers.view` ; la section
// disparait automatiquement (SidebarProvider) si le module `transport` est
// desactive ou si l'user n'a pas la permission (Compta / Usine).
[
'label' => 'sidebar.transport.section',
'icon' => 'mdi:truck-outline',
'items' => [
[
'label' => 'sidebar.transport.carriers',
'to' => '/carriers',
'icon' => 'mdi:truck-outline',
'module' => 'transport',
'permission' => 'transport.carriers.view',
],
],
],
// Section "Administration" : regroupe toutes les pages de configuration
// applicative (RBAC, users, sites, audit log).
//
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.125'
app.version: '0.1.126'
+4
View File
@@ -34,6 +34,10 @@
"section": "Technique",
"providers": "Répertoire prestataires"
},
"transport": {
"section": "Transport",
"carriers": "Répertoire transporteurs"
},
"core": {
"roles": "Gestion des rôles",
"users": "Utilisateurs",
+7
View File
@@ -95,6 +95,13 @@ export const personas: Record<PersonaKey, Persona> = {
'technique.providers.accounting.view',
'technique.providers.accounting.manage',
'technique.providers.archive',
// Transport — Repertoire transporteurs (M4, ERP-153). Meme logique :
// mappe sur le persona "tout", pas de nouveau persona (regle ABSOLUE
// n°7). transport.carriers.view n'ajoute pas de lien dans la section
// Administration, donc expectedAdminLinks reste inchange.
'transport.carriers.view',
'transport.carriers.manage',
'transport.carriers.archive',
],
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
},
+8
View File
@@ -258,6 +258,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
+120
View File
@@ -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,
));
}
}
@@ -51,9 +51,9 @@ final class RbacSeeder
* Definition unique des 4 roles + matrice § 2.7. La cle est le code du role,
* `label` le libelle FR affichable, `permissions` la liste des codes RBAC a
* attacher (admin n'apparait pas car il bypass tout via isAdmin ;
* `commercial.clients.archive`, `commercial.suppliers.archive` et
* `technique.providers.archive` ne sont attaches a aucun role metier —
* admin seul).
* `commercial.clients.archive`, `commercial.suppliers.archive`,
* `technique.providers.archive` et `transport.carriers.archive` ne sont
* attaches a aucun role metier — admin seul).
*
* Cloisonnement par site des prestataires (M3 § 2.13) : la permission
* `sites.bypass_scope` est attribuee par defaut a Bureau / Compta /
@@ -77,6 +77,9 @@ final class RbacSeeder
// Prestataires (M3 § 2.9, ERP-138) : view + manage (hors Comptabilite).
'technique.providers.view',
'technique.providers.manage',
// Transporteurs (M4 § 5.2, ERP-153) : view + manage (PAS archive -> admin seul).
'transport.carriers.view',
'transport.carriers.manage',
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
'sites.bypass_scope',
// Lecture des referentiels transverses pour les selects client (ERP-102).
@@ -120,6 +123,9 @@ final class RbacSeeder
// (onglet Comptabilite masque/filtre pour la Commerciale).
'technique.providers.view',
'technique.providers.manage',
// Transporteurs (M4 § 5.2, ERP-153) : view seul (consultation « Tout »,
// ni manage ni archive pour la Commerciale).
'transport.carriers.view',
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
'sites.bypass_scope',
// Lecture des referentiels transverses pour les selects client (ERP-102).
@@ -212,6 +212,11 @@ final class SeedE2ECommand extends Command
'technique.providers.accounting.view',
'technique.providers.accounting.manage',
'technique.providers.archive',
// Transport — Repertoire transporteurs (M4, ERP-153). Meme
// logique : mappe sur le persona "tout". Miroir de personas.ts.
'transport.carriers.view',
'transport.carriers.manage',
'transport.carriers.archive',
],
],
[
@@ -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)),
);
}
}
+11 -6
View File
@@ -13,17 +13,22 @@ final class TransportModule
/**
* Liste declarative des permissions RBAC exposees par le module Transport.
*
* Vide a ce stade : le module ne porte que des referentiels externes
* synchronises par commandes console (codes IDTF - ERP-149, transporteurs
* QUALIMAT - ERP-39), sans ecran ni action protegee. Les permissions seront
* ajoutees quand une page de consultation sera exposee.
* Socle du repertoire transporteurs (M4 § 5.1, ERP-153) :
* - `view` : consultation de la liste / fiche transporteur ;
* - `manage` : creation / modification (hors archivage) ;
* - `archive` : archivage / restauration (admin seul, cf. matrice § 5.2).
*
* Consommee par `app:sync-permissions` (un tableau vide est valide).
* Consommee par `app:sync-permissions`. Matrice role -> permissions dans
* `RbacSeeder::MATRIX` (§ 5.2).
*
* @return array<int, array{code: string, label: string}>
*/
public static function permissions(): array
{
return [];
return [
['code' => 'transport.carriers.view', 'label' => 'Voir les transporteurs'],
['code' => 'transport.carriers.manage', 'label' => 'Créer / modifier les transporteurs'],
['code' => 'transport.carriers.archive', 'label' => 'Archiver / restaurer un transporteur'],
];
}
}
@@ -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 dapplication 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 dapplication 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');
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport;
use App\Module\Transport\TransportModule;
use PHPUnit\Framework\TestCase;
/**
* Tests structurels du module Transport (M4) : identite et contrat
* `permissions()` (socle RBAC, ERP-153).
*
* @internal
*/
final class TransportModuleTest extends TestCase
{
public function testModuleIdentity(): void
{
self::assertSame('transport', TransportModule::ID);
self::assertSame('Transport', TransportModule::LABEL);
self::assertFalse(TransportModule::REQUIRED);
}
public function testPermissionsSetContainsExactlyThreeCodes(): void
{
// Garde-fou : le jeu de permissions du module est fige par ce test. Si
// quelqu'un ajoute / retire une permission sans ajuster la spec (§ 5.1)
// ni la matrice RBAC (§ 5.2), le test casse explicitement.
$codes = array_column(TransportModule::permissions(), 'code');
sort($codes);
self::assertSame(
[
'transport.carriers.archive',
'transport.carriers.manage',
'transport.carriers.view',
],
$codes,
);
}
public function testEveryPermissionCodeIsPrefixedByModuleId(): void
{
// Convention de nommage `module.resource[.sub].action` : le prefixe doit
// correspondre exactement a l'ID du module (verifie aussi par
// app:sync-permissions).
foreach (TransportModule::permissions() as $permission) {
self::assertStringStartsWith(
TransportModule::ID.'.',
$permission['code'],
'Chaque code de permission doit etre prefixe par l\'ID du module.',
);
self::assertNotSame('', $permission['label'], 'Chaque permission doit porter un label.');
}
}
}