*/ 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)), ); } }