['blz', 'bankleitzahl'], 'name' => ['institut', 'kreditinstitut', 'institutsname', 'bankname', 'name'], 'ort' => ['ort'], 'bic' => ['bic', 'swift-bic', 'swift'], 'pin_tan_url' => [ 'pin/tan-zugang url', 'pin/tan-adresse', 'pin/tan-url', 'pin/tan url', 'pin/tan-zugang', 'pin/tan zugang', 'url', ], ]; /** * Analysiert eine hochgeladene Datei vollständig, ohne die Datenbank * anzufassen – Basis sowohl für die reine Prüfung als auch für den * eigentlichen Import (der dasselbe Ergebnis anschließend übernimmt). * * @throws \RuntimeException wenn die Datei nicht lesbar ist oder keine * Headerzeile mit den Spalten „BLZ“ und „Institut“ gefunden wird */ public function analyze(string $filePath, string $origFilename): array { $content = file_get_contents($filePath); if ($content === false) { throw new \RuntimeException('Datei konnte nicht gelesen werden.'); } $content = preg_replace('/^\xEF\xBB\xBF/', '', $content); if (!mb_check_encoding($content, 'UTF-8')) { $content = mb_convert_encoding($content, 'UTF-8', 'ISO-8859-1'); } $delimiter = $this->detectDelimiter($content); $stream = fopen('php://temp', 'r+'); fwrite($stream, $content); rewind($stream); try { return $this->parseStream($stream, $delimiter, $origFilename); } finally { fclose($stream); } } /** * @param resource $stream */ private function parseStream($stream, string $delimiter, string $origFilename): array { $preambleLines = []; $header = null; $columns = []; while (($cells = fgetcsv($stream, 0, $delimiter, '"')) !== false) { if ($this->rowIsEmpty($cells)) { continue; } $normalized = array_map(fn($c) => $this->normalizeHeader((string)$c), $cells); $candidate = $this->detectColumns($normalized); if ($candidate['blz'] !== null && $candidate['name'] !== null) { $header = $cells; $columns = $candidate; break; } $preambleLines[] = implode(' ', $cells); if (count($preambleLines) > self::MAX_HEADER_SCAN) { break; } } if ($header === null) { throw new \RuntimeException( 'Kein Header mit den Spalten „BLZ“ und „Institut“ gefunden – Import abgebrochen.' ); } $rows = []; $preview = []; $blzSeen = []; $mitZugangspunkt = 0; while (($cells = fgetcsv($stream, 0, $delimiter, '"')) !== false) { if ($this->rowIsEmpty($cells)) { continue; } $blz = trim((string)($cells[$columns['blz']] ?? '')); $name = trim((string)($cells[$columns['name']] ?? '')); if ($blz === '' || $name === '') { // Trailing Leerzeilen mit laufender Nummer, oder sonstiger Datenmüll. continue; } $pinTanUrl = $columns['pin_tan_url'] !== null ? $this->normalizeUrl((string)($cells[$columns['pin_tan_url']] ?? '')) : null; $row = [ 'blz' => $blz, 'name' => $name, 'ort' => $columns['ort'] !== null ? trim((string)($cells[$columns['ort']] ?? '')) : '', 'bic' => $columns['bic'] !== null ? trim((string)($cells[$columns['bic']] ?? '')) : '', 'pin_tan_url' => $pinTanUrl, ]; $rows[] = $row; $blzSeen[$blz] = true; if ($pinTanUrl !== null) { $mitZugangspunkt++; } if (count($preview) < self::PREVIEW_ROWS) { $preview[] = $row; } } return [ 'header' => $header, 'columns' => $columns, 'delimiter' => $delimiter, 'stand' => $this->detectStand($preambleLines, $origFilename), 'zeilen' => count($rows), 'blz_anzahl' => count($blzSeen), 'mit_zugangspunkt' => $mitZugangspunkt, 'rows' => $rows, 'preview' => $preview, ]; } private function rowIsEmpty(array $cells): bool { foreach ($cells as $cell) { if (trim((string)$cell) !== '') { return false; } } return true; } private function detectDelimiter(string $content): string { $firstLine = strtok($content, "\n") ?: ''; $firstLine = rtrim($firstLine, "\r"); $semiCount = count(str_getcsv($firstLine, ';', '"')); $commaCount = count(str_getcsv($firstLine, ',', '"')); return $commaCount > $semiCount ? ',' : ';'; } private function normalizeHeader(string $value): string { return mb_strtolower(trim($value)); } /** @return array */ private function detectColumns(array $normalizedHeader): array { $columns = []; foreach (self::FIELD_ALIASES as $field => $aliases) { $columns[$field] = $this->findColumn($normalizedHeader, $aliases); } return $columns; } private function findColumn(array $normalizedHeader, array $aliases): ?int { foreach ($aliases as $alias) { $idx = array_search($alias, $normalizedHeader, true); if ($idx !== false) { return $idx; } } foreach ($aliases as $alias) { foreach ($normalizedHeader as $idx => $cell) { if ($cell !== '' && str_contains($cell, $alias)) { return $idx; } } } return null; } /** Nur Werte, die wie eine http(s)-Adresse aussehen, gelten als Zugangspunkt. */ private function normalizeUrl(string $value): ?string { $value = trim($value); if ($value === '' || !preg_match('#^https?://#i', $value)) { return null; } return $value; } /** * Sucht ein Datum (TT.MM.JJJJ) zuerst in den Zeilen vor der Headerzeile, * dann im Dateinamen. Beides ist reine Heuristik: die Quelldatei hat kein * festes Feld für den Datenstand, DK-Exporte tragen ihn aber häufig als * Titelzeile oder im Dateinamen. */ private function detectStand(array $preambleLines, string $origFilename): ?string { $pattern = '/(\d{2}\.\d{2}\.\d{4})/'; foreach ($preambleLines as $line) { if (preg_match($pattern, $line, $m)) { return $m[1]; } } if (preg_match($pattern, $origFilename, $m)) { return $m[1]; } if (preg_match('/(\d{4})[-_]?(\d{2})[-_]?(\d{2})/', $origFilename, $m)) { return "{$m[3]}.{$m[2]}.{$m[1]}"; } return null; } }