240 lines
7.6 KiB
PHP
240 lines
7.6 KiB
PHP
<?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;
|
||
}
|
||
}
|