251 lines
8.5 KiB
PHP
251 lines
8.5 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Steuerkram\Services;
|
||
|
||
/**
|
||
* Liest die CSV-Institutsliste der Deutschen Kreditwirtschaft (hbci-zka.de)
|
||
* für das Bankleitzahlen-Verzeichnis ein (Fix #187).
|
||
*
|
||
* Fallstricke der Quelldatei, die hier absichtlich berücksichtigt sind:
|
||
* - Kodierung ist ISO-8859-1, nicht UTF-8.
|
||
* - Ein Institutsname kann über zwei Zeilen laufen (in Anführungszeichen,
|
||
* z.B. Oldenburgische Landesbank) – deshalb fgetcsv() auf einem Stream
|
||
* statt zeilenweisem Einlesen, das daran scheitern würde.
|
||
* - Dieselbe Bankleitzahl steht mehrfach darin (eine Zeile je Filiale, mit
|
||
* identischem Zugangspunkt) – bewusst kein Unique-Check hier.
|
||
* - Am Ende hängen hunderte leere Zeilen mit laufender Nummer – Zeilen ohne
|
||
* BLZ oder Institut werden übersprungen, unabhängig davon, ob andere
|
||
* Spalten (z.B. eine laufende Nummer) gefüllt sind.
|
||
* - Wo es keinen PIN/TAN-Zugang gibt, steht Text statt einer Adresse in der
|
||
* entsprechenden Spalte – nur Werte, die wie eine http(s)-URL aussehen,
|
||
* werden als Zugangspunkt übernommen.
|
||
* - Spalten werden über ihre Überschrift angesprochen, nicht über ihre
|
||
* Position. Die Chipkartenverfahren-Spalten (DDV, RDH-1…10, RAH-7/9/10)
|
||
* werden schlicht nicht gelesen.
|
||
*/
|
||
class FintsInstituteImport
|
||
{
|
||
private const MAX_HEADER_SCAN = 15;
|
||
private const PREVIEW_ROWS = 5;
|
||
|
||
private const FIELD_ALIASES = [
|
||
'blz' => ['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<string, int|null> */
|
||
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;
|
||
}
|
||
}
|