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>
280 lines
8.8 KiB
PHP
280 lines
8.8 KiB
PHP
<?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');
|
||
}
|
||
}
|