Steuerkram/src/Services/FintsInstituteImport.php

251 lines
8.5 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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;
}
}