Steuerkram/src/Services/BankImportParser.php

240 lines
7.6 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;
class BankImportParser
{
/**
* Parst eine hochgeladene Bank-Datei (CSV/TXT/XLSX) anhand des Formats.
* Gibt ein Array von Zeilen zurück, jede Zeile als assoc array.
*
* @throws \RuntimeException bei Parsefehlern
*/
public function parse(string $filePath, string $extension, array $format): array
{
$ext = strtolower($extension);
if ($ext === 'xlsx') {
$lines = $this->readXlsx($filePath);
} else {
$lines = $this->readCsv($filePath, $format['delimiter'], $format['enclosure']);
}
// Übersprungene Zeilen und optionale Headerzeile entfernen
$skipRows = (int)$format['skip_rows'];
$lines = array_slice($lines, $skipRows);
if (!empty($format['has_header_row'])) {
$lines = array_slice($lines, 1);
}
$colBuchungstag = (int)$format['col_buchungstag'];
$colWertstellung = (int)$format['col_wertstellung'];
$colVorgang = (int)$format['col_vorgang'];
$colBuchungstext = (int)$format['col_buchungstext'];
$colWert = (int)$format['col_wert'];
$result = [];
foreach ($lines as $i => $cells) {
// Leerzeilen überspringen
$joined = implode('', $cells);
if (trim($joined) === '') {
continue;
}
$buchungstag = $this->parseDate($cells[$colBuchungstag] ?? '', $format['date_format']);
$wertstellung = $this->parseDate($cells[$colWertstellung] ?? '', $format['date_format']);
$wert = $this->parseDecimal(
$cells[$colWert] ?? '0',
$format['decimal_separator'],
$format['thousands_separator']
);
if ($buchungstag === null || $wertstellung === null) {
continue;
}
$result[] = [
'buchungstag' => $buchungstag,
'wertstellung' => $wertstellung,
'vorgang' => trim($cells[$colVorgang] ?? ''),
'buchungstext' => trim($cells[$colBuchungstext] ?? ''),
'wert' => $wert,
'raw_line' => implode($format['delimiter'], $cells),
];
}
return $result;
}
private function readCsv(string $filePath, string $delimiter, string $enclosure): array
{
$content = file_get_contents($filePath);
if ($content === false) {
throw new \RuntimeException('Datei konnte nicht gelesen werden.');
}
// BOM entfernen
$content = preg_replace('/^\xEF\xBB\xBF/', '', $content);
// Verschiedene Zeilenenden normalisieren
$content = str_replace(["\r\n", "\r"], "\n", $content);
$lines = explode("\n", $content);
$result = [];
foreach ($lines as $line) {
if (trim($line) === '') {
$result[] = [];
continue;
}
$result[] = $this->parseCsvLine($line, $delimiter, $enclosure);
}
return $result;
}
private function parseCsvLine(string $line, string $delimiter, string $enclosure): array
{
$cells = [];
$len = strlen($line);
$i = 0;
while ($i < $len) {
if ($line[$i] === $enclosure) {
// Quoted cell
$i++;
$cell = '';
while ($i < $len) {
if ($line[$i] === $enclosure) {
if ($i + 1 < $len && $line[$i + 1] === $enclosure) {
$cell .= $enclosure;
$i += 2;
} else {
$i++;
break;
}
} else {
$cell .= $line[$i++];
}
}
$cells[] = $cell;
// Delimiter überspringen
if ($i < $len && $line[$i] === $delimiter) {
$i++;
}
} else {
// Unquoted cell
$end = strpos($line, $delimiter, $i);
if ($end === false) {
$cells[] = substr($line, $i);
break;
}
$cells[] = substr($line, $i, $end - $i);
$i = $end + 1;
}
}
return $cells;
}
private function readXlsx(string $filePath): array
{
if (!class_exists('ZipArchive')) {
throw new \RuntimeException('ZipArchive nicht verfügbar – XLSX kann nicht gelesen werden.');
}
$zip = new \ZipArchive();
if ($zip->open($filePath) !== true) {
throw new \RuntimeException('XLSX-Datei konnte nicht geöffnet werden.');
}
// Shared Strings laden (optional)
$sharedStrings = [];
$ssXml = $zip->getFromName('xl/sharedStrings.xml');
if ($ssXml !== false) {
$ss = simplexml_load_string($ssXml);
foreach ($ss->si ?? [] as $si) {
$text = '';
if (isset($si->t)) {
$text = (string)$si->t;
} else {
foreach ($si->r ?? [] as $r) {
$text .= (string)($r->t ?? '');
}
}
$sharedStrings[] = $text;
}
}
// Erstes Worksheet lesen
$sheetXml = $zip->getFromName('xl/worksheets/sheet1.xml');
$zip->close();
if ($sheetXml === false) {
throw new \RuntimeException('Kein Arbeitsblatt in der XLSX-Datei gefunden.');
}
$sheet = simplexml_load_string($sheetXml);
$rows = [];
$maxCol = 0;
foreach ($sheet->sheetData->row ?? [] as $row) {
$rowArr = [];
foreach ($row->c as $cell) {
[$colIdx] = $this->xlsxCellRef((string)$cell['r']);
if ($colIdx > $maxCol) $maxCol = $colIdx;
$type = (string)($cell['t'] ?? '');
$value = (string)($cell->v ?? '');
if ($type === 's') {
$value = $sharedStrings[(int)$value] ?? '';
} elseif ($type === 'inlineStr') {
$value = (string)($cell->is->t ?? '');
}
$rowArr[$colIdx] = $value;
}
// Lücken auffüllen
$filled = [];
for ($c = 0; $c <= $maxCol; $c++) {
$filled[] = $rowArr[$c] ?? '';
}
$rows[] = $filled;
}
return $rows;
}
private function xlsxCellRef(string $ref): array
{
preg_match('/^([A-Z]+)(\d+)$/', $ref, $m);
$col = 0;
foreach (str_split($m[1] ?? 'A') as $char) {
$col = $col * 26 + (ord($char) - ord('A') + 1);
}
return [$col - 1, (int)($m[2] ?? 1) - 1];
}
private function parseDate(string $value, string $format): ?string
{
$value = trim($value);
if ($value === '') return null;
$d = \DateTime::createFromFormat($format, $value);
if (!$d) return null;
return $d->format('Y-m-d');
}
private function parseDecimal(string $value, string $decimal, string $thousands): float
{
$value = trim($value);
// Tausendertrennzeichen entfernen, Dezimaltrennzeichen → Punkt
$value = str_replace($thousands, '', $value);
$value = str_replace($decimal, '.', $value);
$value = preg_replace('/[^\d.\-]/', '', $value);
return (float)$value;
}
}