Finanzmanager/src/Services/QifParser.php
fds2610-bot 1e3227ce83 Phase 1: Stammdaten und QIF-Stammdatenimport
Konten, Kontengruppen, Kategorien und Klassen samt Übernahme aus dem
QIF-Export des Lexware FinanzManagers.

Kontenübersicht nach Kontengruppen gebündelt, mit Gruppensummen und
Gesamtsumme; die drei Saldospalten (Online-Saldo, aktueller und erwarteter
Saldo) sind angelegt und werden bis Phase 2 aus dem Anfangssaldo gespeist.

Kategorien sind hierarchisch mit ":" als Trenner. Der vollständige Pfad
steht redundant in der Tabelle, weil jede Anzeige und der gesamte Import
darüber laufen; Umbenennen und Verschieben schreiben den Teilbaum neu. Die
Art (Einnahme/Ausgabe) gilt für den ganzen Ast. Register wie im Original:
Alle, Einnahmen, Ausgaben, Umbuchungskonten.

QifParser liest zustandsbehaftet und sequenziell, wandelt ISO-8859-1 nach
UTF-8 und versteht US-Beträge sowie das Datumsformat aus der Kopfzeile.
QifStammdatenImport ist idempotent und bietet Trockenlauf und Protokoll -
im Dialog unter Verwaltung sowie über scripts/qif-import.php. Am echten
Export geprüft: 17 Klassen, 105 Kategorien, 37 Konten.

Enthält außerdem die Korrektur der englischen Monatsnamen im
Einstellungsdialog: date('F') ist sprachlich nicht umschaltbar, deshalb
liegen die Namen jetzt zentral als View::MONATE vor. Die Änderung ließ
sich nicht als eigener Commit trennen, weil sie dieselben Dateien berührt
wie Phase 1.

Tests: 227 PHP-Prüfungen und 14 Playwright-Tests, alle grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:33:40 +02:00

280 lines
8.8 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 Finanzmanager\Services;
/**
* Parser für das erweiterte QIF des Lexware FinanzManagers.
*
* Die Datei ist zustandsbehaftet und muss in der vorliegenden Reihenfolge
* gelesen werden – der Kontobezug einer Buchung steckt allein darin, welcher
* "!Account"-Abschnitt zuletzt kam. Deshalb liefert der Parser eine flache
* Liste von Einträgen, jeweils mit Sektionstyp und aktuellem Konto, statt die
* Datei nach Typen zu gruppieren.
*
* Aufbau:
* !Option:MDY Datumsformat für die gesamte Datei
* !Type:Class Klassen
* !Type:Cat Kategorien
* !Option:AutoSwitch Beginn der Kontenliste
* !Account ein Konto je Eintrag
* !Clear:AutoSwitch Ende der Kontenliste
* !Account / !Type:… ab hier abwechselnd Konto und dessen Buchungen
*
* Ein Eintrag endet mit "^". Jede Zeile beginnt mit einem Feldbuchstaben,
* der Rest ist der Wert; Felder dürfen mehrfach vorkommen (Splitzeilen,
* Adresszeilen), deshalb wird jeder Eintrag als Liste von Paaren gehalten.
*/
class QifParser
{
/** Feldbuchstabe → Wert(e). */
public const TYP_KLASSE = 'Class';
public const TYP_KATEGORIE = 'Cat';
public const TYP_KONTO = 'Account';
/** Sektionen, die Buchungen enthalten. */
public const BUCHUNGSTYPEN = ['Bank', 'CCard', 'Cash', 'Oth A', 'Oth L', 'Oth T', 'Oth P',
'Oth E', 'Oth F', 'Invst', 'Port', 'Memorized'];
private string $datumsformat = 'MDY';
/** @var list<array{typ: string, konto: ?string, zeilen: list<array{0: string, 1: string}>}> */
private array $eintraege = [];
public function __construct(string $inhalt)
{
$this->lies($inhalt);
}
public static function ausDatei(string $pfad): self
{
if (!is_readable($pfad)) {
throw new \RuntimeException('Die Datei ist nicht lesbar: ' . $pfad);
}
$inhalt = file_get_contents($pfad);
if ($inhalt === false) {
throw new \RuntimeException('Die Datei konnte nicht gelesen werden: ' . $pfad);
}
return new self($inhalt);
}
/**
* @return list<array{typ: string, konto: ?string, zeilen: list<array{0: string, 1: string}>}>
*/
public function eintraege(?string $typ = null): array
{
if ($typ === null) {
return $this->eintraege;
}
return array_values(array_filter(
$this->eintraege,
static fn(array $e): bool => $e['typ'] === $typ
));
}
public function datumsformat(): string
{
return $this->datumsformat;
}
/** Wie viele Einträge je Sektionstyp? Grundlage der Import-Vorschau. */
public function zusammenfassung(): array
{
$zaehler = [];
foreach ($this->eintraege as $eintrag) {
$zaehler[$eintrag['typ']] = ($zaehler[$eintrag['typ']] ?? 0) + 1;
}
return $zaehler;
}
/**
* Erster Wert eines Feldes, oder null.
*
* @param array{zeilen: list<array{0: string, 1: string}>} $eintrag
*/
public static function feld(array $eintrag, string $code): ?string
{
foreach ($eintrag['zeilen'] as [$c, $wert]) {
if ($c === $code) {
return $wert;
}
}
return null;
}
/**
* Alle Werte eines Feldes – Splitzeilen und Adressen kommen mehrfach vor.
*
* @param array{zeilen: list<array{0: string, 1: string}>} $eintrag
* @return list<string>
*/
public static function felder(array $eintrag, string $code): array
{
$werte = [];
foreach ($eintrag['zeilen'] as [$c, $wert]) {
if ($c === $code) {
$werte[] = $wert;
}
}
return $werte;
}
/** Ist das Feld vorhanden? Bei Kategorien sind "I" und "E" reine Marker ohne Wert. */
public static function hatFeld(array $eintrag, string $code): bool
{
foreach ($eintrag['zeilen'] as [$c, $_]) {
if ($c === $code) {
return true;
}
}
return false;
}
/**
* Beträge stehen im US-Format: "-1,241.04". Das Komma ist Tausendertrenner,
* der Punkt trennt die Nachkommastellen.
*/
public static function betrag(?string $wert): float
{
if ($wert === null) {
return 0.0;
}
$bereinigt = str_replace([',', ' ', "\u{00A0}"], '', trim($wert));
return $bereinigt === '' ? 0.0 : (float)$bereinigt;
}
/**
* Datum nach ISO. Der Export nennt das Format im Kopf ("!Option:MDY"), die
* Jahreszahl ist zweistellig: "D3.18.26" ist der 18.03.2026. Zweistellige
* Jahre ab 70 gelten als 19xx – Quicken-Konvention, und private Buchungen
* aus den Sechzigern gibt es nicht.
*/
public static function datum(?string $wert, string $format = 'MDY'): ?string
{
if ($wert === null) {
return null;
}
$teile = preg_split('/[.\/\-]/', trim($wert));
if ($teile === false || count($teile) !== 3) {
return null;
}
[$a, $b, $c] = array_map(static fn(string $t): int => (int)preg_replace('/\D/', '', $t), $teile);
if ($format === 'DMY') {
[$tag, $monat, $jahr] = [$a, $b, $c];
} else {
[$monat, $tag, $jahr] = [$a, $b, $c];
}
if ($jahr < 100) {
$jahr += $jahr >= 70 ? 1900 : 2000;
}
if (!checkdate($monat, $tag, $jahr)) {
return null;
}
return sprintf('%04d-%02d-%02d', $jahr, $monat, $tag);
}
private function lies(string $inhalt): void
{
$inhalt = self::nachUtf8($inhalt);
$zeilen = explode("\n", str_replace(["\r\n", "\r"], "\n", $inhalt));
$typ = '';
$aktuellesKonto = null;
$zeilenPuffer = [];
foreach ($zeilen as $zeile) {
$zeile = rtrim($zeile);
if ($zeile === '') {
continue;
}
if ($zeile[0] === '!') {
$neuerTyp = $this->steuerzeile($zeile);
if ($neuerTyp !== null) {
$typ = $neuerTyp;
}
continue;
}
if ($zeile[0] === '^') {
if ($zeilenPuffer !== []) {
// Jeder Kontoeintrag setzt zugleich das aktuelle Konto. In
// der AutoSwitch-Liste folgen mehrere aufeinander, ohne dass
// Buchungen dazwischenstehen – das schadet nicht, deckt aber
// die zweite Schreibweise mit ab, bei der ein einzelner
// "!Account" den Kontowechsel bedeutet.
if ($typ === self::TYP_KONTO) {
$aktuellesKonto = self::feld(['zeilen' => $zeilenPuffer], 'N');
}
$this->eintraege[] = [
'typ' => $typ,
'konto' => $typ === self::TYP_KONTO ? null : $aktuellesKonto,
'zeilen' => $zeilenPuffer,
];
$zeilenPuffer = [];
}
continue;
}
$zeilenPuffer[] = [$zeile[0], substr($zeile, 1)];
}
// Ein fehlendes abschließendes "^" darf den letzten Eintrag nicht schlucken.
if ($zeilenPuffer !== []) {
$this->eintraege[] = [
'typ' => $typ,
'konto' => $typ === self::TYP_KONTO ? null : $aktuellesKonto,
'zeilen' => $zeilenPuffer,
];
}
}
/** Wertet eine "!"-Zeile aus und liefert den neuen Sektionstyp, falls einer folgt. */
private function steuerzeile(string $zeile): ?string
{
$zeile = trim($zeile);
if (str_starts_with($zeile, '!Option:')) {
$option = trim(substr($zeile, 8));
if (in_array($option, ['MDY', 'DMY', 'YMD'], true)) {
$this->datumsformat = $option;
}
return null; // AutoSwitch ändert den Sektionstyp nicht.
}
if (str_starts_with($zeile, '!Clear:')) {
return null;
}
if (str_starts_with($zeile, '!Type:')) {
return trim(substr($zeile, 6));
}
if (str_starts_with($zeile, '!Account')) {
return self::TYP_KONTO;
}
return null;
}
/**
* Der Export ist ISO-8859-1. Läge schon UTF-8 vor (etwa weil die Datei
* unterwegs konvertiert wurde), würde eine zweite Konvertierung die Umlaute
* zerstören – deshalb erst prüfen.
*/
public static function nachUtf8(string $inhalt): string
{
if (mb_check_encoding($inhalt, 'UTF-8')) {
return $inhalt;
}
return mb_convert_encoding($inhalt, 'UTF-8', 'ISO-8859-1');
}
}