> $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)); } }