From 3e55b3ba739084155923ce9204f6d977792a8755 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Wed, 12 Aug 2026 14:50:43 +0200 Subject: [PATCH] =?UTF-8?q?QIF-Import:=20nur=20ausgew=C3=A4hlte=20Konten?= =?UTF-8?q?=20=C3=BCbernehmen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Import kannte bisher nur den Zeitraum als Filter – ein Export ließ sich nur ganz einlesen. Neu ist die Option "konten": im Dialog eine Ankreuzliste, die erst nach dem Trockenlauf erscheint (vorher ist die Datei ungelesen), auf der Kommandozeile --konto=NAME und --konten. Maßgeblich ist nicht allein der !Account-Abschnitt, sondern jedes Konto, das der Eintrag berührt: das eigene, die Umbuchungsziele aus L und den Splitzeilen und beim Wertpapiergeschäft das Verrechnungskonto. Eine Umbuchung steht nur einmal in der Datei – sonst fehlte dem gewählten Konto die Gegenseite. Die Anfangssalden werden weiter über die ganze Datei gerechnet, aber nur für die gewählten Konten geschrieben; ein Teilimport überschreibt sonst fremde Salden. Die Fingerabdrücke entstehen jetzt vorab in einem eigenen Durchgang: Der laufende Zähler für gleiche Fingerabdrücke hing sonst an der Auswahl, und dieselbe Buchung wäre beim zweiten Lauf mit anderer Auswahl doppelt angelegt worden. Co-Authored-By: Claude Opus 5 --- Changes.md | 31 +++++ README.md | 4 +- docs/qif-import.md | 42 ++++++ scripts/qif-import.php | 55 +++++++- src/Controllers/BaseController.php | 22 +++ src/Controllers/ImportController.php | 17 ++- src/Services/QifBuchungsImport.php | 191 +++++++++++++++++++++++++-- src/Views/verwaltung/import.php | 53 +++++++- tests/10_QifBuchungsImportTest.php | 62 +++++++++ tests/e2e/specs/stammdaten.spec.js | 71 ++++++++++ 10 files changed, 528 insertions(+), 20 deletions(-) diff --git a/Changes.md b/Changes.md index d7fcdd0..07bbf6b 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,36 @@ # Änderungen +## QIF-Import: nur ausgewählte Konten übernehmen (12.08.2026) + +Der QIF-Import kannte bisher nur den Zeitraum als Filter – ein Export ließ sich nur ganz +einlesen. Jetzt lassen sich einzelne Konten wählen, etwa um ein Konto nachzuziehen, ohne +die übrigen anzufassen. + +**Kontoauswahl im Importeur** (`QifBuchungsImport`) +- Neue Option `konten`. Maßgeblich ist nicht allein der `!Account`-Abschnitt, sondern jedes + Konto, das der Eintrag berührt: das eigene, die Umbuchungsziele aus `L` und den + Splitzeilen und beim Wertpapiergeschäft das Verrechnungskonto. Eine Umbuchung steht nur + einmal in der Datei – sonst fehlte dem gewählten Konto die Gegenseite. +- Die Anfangssalden werden weiter über die ganze Datei gerechnet, aber nur für die + gewählten Konten geschrieben. Ein Teilimport überschreibt so keine fremden Salden. +- Die Fingerabdrücke der Buchungen entstehen vorab in einem eigenen Durchgang. Der + laufende Zähler für gleiche Fingerabdrücke hing sonst an der Auswahl, und dieselbe + Buchung wäre bei einem zweiten Lauf mit anderer Auswahl doppelt angelegt worden. +- `kontenDerDatei()` nennt die Konten eines Exports samt der Zahl der Einträge, die sie + berühren – auch die, die nur Umbuchungsziel sind und keine eigene Zeile führen. + +**Dialog** (`Views/verwaltung/import.php`, `ImportController`) +- Nach dem Trockenlauf steht unter *Konten* die Ankreuzliste der Konten dieser Datei; vor + dem ersten Lesen gibt es nichts zu wählen. Von dort geht es weiter zum erneuten + Trockenlauf mit der Auswahl oder zum Übernehmen. +- Die Vorschau zählt mit, wie viele Buchungen zu keinem der gewählten Konten gehören. +- Was im Formular ankommt, wird gegen die Konten der Datei geprüft. + +**Kommandozeile** (`scripts/qif-import.php`) +- `--konto=NAME` (mehrfach angebbar) grenzt den Import ein, `--konten` nennt die Konten der + Datei und beendet sich. Ein Name, den die Datei nicht führt, bricht ab – sonst liefe der + Import durch und legte nichts an. + ## Umbuchungen: Gegenkonto sichtbar, Zuordnung im Kontoblatt (12.08.2026) Eine Umbuchung war auf dem Gegenkonto nicht mehr zuzuordnen: Die Zeile trug die Marke diff --git a/README.md b/README.md index e1d2871..6482112 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,9 @@ docker compose exec -u www-data php php scripts/qif-import.php data/import/Expor docker compose exec -u www-data php php scripts/qif-import.php --uebernehmen data/import/Export.QIF ``` -Ohne `--uebernehmen` läuft nur der Trockenlauf. +Ohne `--uebernehmen` läuft nur der Trockenlauf. Mit `--konto=NAME` (mehrfach angebbar, +im Dialog als Ankreuzliste nach dem Trockenlauf) bleibt der Import auf einzelne Konten +beschränkt; `--konten` nennt die Konten der Datei. ## Dokumentation diff --git a/docs/qif-import.md b/docs/qif-import.md index dda9159..36fddf2 100644 --- a/docs/qif-import.md +++ b/docs/qif-import.md @@ -100,6 +100,33 @@ die der Import selbst anlegen würde, erscheinen in der Vorschau. Verrechnungskonto zu Buche (`L|[Konto]` und `$`). Ein Betrag auf dem Depot ohne Gegenwert würde die Summe der Kontostände verfälschen; Bestände folgen in Phase 6. +## Nur einzelne Konten übernehmen + +Der Import lässt sich auf einzelne Konten begrenzen (Option `konten`, im Dialog eine +Ankreuzliste, auf der Kommandozeile `--konto=NAME`). Drei Dinge sind dabei entschieden: + +1. **Maßgeblich ist jedes berührte Konto, nicht nur der `!Account`-Abschnitt.** Eine + Umbuchung steht nur einmal in der Datei, beim erfassenden Konto. Zählte allein dieser + Abschnitt, fehlte dem gewählten Konto die Gegenseite. Deshalb kommt ein Eintrag mit, + sobald sein Konto, ein Umbuchungsziel aus `L` oder `S` oder – beim Wertpapiergeschäft – + das Verrechnungskonto in der Auswahl steht. Umgekehrt heißt das: Wer nur das Zielkonto + wählt, bekommt die Buchung auch auf dem erfassenden Konto zu sehen; sie gehört dort hin + und lässt sich nicht halbieren. +2. **Anfangssalden werden weiterhin über die ganze Datei gerechnet, aber nur für die + gewählten Konten geschrieben.** Der Saldo eines Kontos steckt auch in Buchungen, die + anderswo erfasst wurden – ohne den vollen Durchlauf wäre er falsch. Und ein Teilimport + darf die Salden der übrigen Konten nicht überschreiben. +3. **Die Fingerabdrücke entstehen vorab in einem eigenen Durchgang.** Der laufende Zähler + für gleiche Fingerabdrücke (siehe unten) hinge sonst an der Auswahl: Dieselbe Buchung + bekäme im Teilimport einen anderen Hash als im Vollimport und käme ein zweites Mal in + die Datenbank. So lassen sich die Konten nacheinander einlesen, ohne dass die + Umbuchungen zwischen ihnen doppelt entstehen. + +Die auswählbaren Konten liefert `QifBuchungsImport::kontenDerDatei()` – erst nach dem +Lesen steht fest, was die Datei führt. Enthalten sind auch Konten, die selbst keine +Buchung tragen und nur Umbuchungsziel sind; sonst ließe sich genau deren Gegenseite nicht +übernehmen. + Die Dublettenerkennung nutzt einen Fingerabdruck aus Konto, Datum, Betrag, Nummer und Text. Weil es echte Doppelbuchungen am selben Tag gibt (zweimal dasselbe im selben Laden), zählt ein laufender Index gleiche Fingerabdrücke durch – stabil über Läufe hinweg, weil @@ -111,6 +138,11 @@ Im Dialog unter *Verwaltung → QIF-Import*: Datei hochladen, Trockenlauf, dann Hochgeladene Dateien bleiben im Importverzeichnis (`data/import`), damit derselbe Export erst geprüft und dann übernommen werden kann. +Nach dem Trockenlauf steht unter *Konten* die Ankreuzliste der Konten, die in der Datei +vorkommen – vorher gibt es nichts zu wählen, weil die Datei noch nicht gelesen wurde. Von +dort führen zwei Wege weiter: ein erneuter Trockenlauf mit der Auswahl oder das Übernehmen. +Ohne Häkchen zählt die ganze Datei. + Auf der Kommandozeile: ```bash @@ -125,6 +157,16 @@ sonst gilt der erste aktive Benutzer. den Einstellungen (Reiter *Import/Export*) und als Enddatum der heutige Tag. `--nur-stammdaten` lässt die Buchungen aus. +```bash +docker compose exec -u www-data php php scripts/qif-import.php --konten data/import/Export.QIF +docker compose exec -u www-data php php scripts/qif-import.php \ + --konto="DKB Girokonto" --konto="Tagesgeld" --uebernehmen data/import/Export.QIF +``` + +`--konten` nennt die Konten der Datei und beendet sich, ohne etwas anzufassen. +`--konto=` grenzt den Import ein und ist mehrfach angebbar; ein Name, den die Datei nicht +führt, bricht ab – sonst liefe der Import durch und legte nichts an. + ## Prüfung gegen den echten Export Der Vollexport (8780 Buchungen, 2017–2026) ergibt mit Stichtag 01.01.2026: diff --git a/scripts/qif-import.php b/scripts/qif-import.php index 1722609..1c8acec 100644 --- a/scripts/qif-import.php +++ b/scripts/qif-import.php @@ -21,6 +21,9 @@ declare(strict_types=1); * Einstellungen, sonst 2026-01-01) * --bis=JJJJ-MM-TT Enddatum; spätere Buchungen sind Vorausberechnungen * des FinanzManagers (Vorgabe: heute) + * --konto=NAME nur dieses Konto übernehmen; mehrfach angebbar. Ohne + * Angabe zählen alle Konten der Datei. + * --konten nennt die Konten der Datei und beendet sich * --benutzer=NAME Mandant, sonst der erste aktive Benutzer */ @@ -39,16 +42,22 @@ Config::load(); $argumente = array_slice($argv, 1); $uebernehmen = false; $nurStammdaten = false; +$nurKonten = false; $benutzer = null; $datei = null; $stichtag = null; $bis = null; +$konten = []; foreach ($argumente as $argument) { if ($argument === '--uebernehmen') { $uebernehmen = true; } elseif ($argument === '--nur-stammdaten') { $nurStammdaten = true; + } elseif ($argument === '--konten') { + $nurKonten = true; + } elseif (str_starts_with($argument, '--konto=')) { + $konten[] = substr($argument, 8); } elseif (str_starts_with($argument, '--benutzer=')) { $benutzer = substr($argument, 11); } elseif (str_starts_with($argument, '--stichtag=')) { @@ -64,8 +73,8 @@ foreach ($argumente as $argument) { } if ($datei === null) { - fwrite(STDERR, "Aufruf: qif-import.php [--uebernehmen] [--nur-stammdaten] " - . "[--stichtag=JJJJ-MM-TT] [--bis=JJJJ-MM-TT] [--benutzer=NAME] \n"); + fwrite(STDERR, "Aufruf: qif-import.php [--uebernehmen] [--nur-stammdaten] [--konten] " + . "[--konto=NAME] [--stichtag=JJJJ-MM-TT] [--bis=JJJJ-MM-TT] [--benutzer=NAME] \n"); exit(1); } @@ -91,14 +100,46 @@ if ($benutzer !== null) { $stichtag ??= (string)Einstellung::get('import_stichtag', '2026-01-01'); $bis ??= date('Y-m-d'); +// Die Konten der Datei nennen, ohne etwas anzufassen – so lässt sich +// nachsehen, welche Namen --konto= überhaupt kennt. +if ($nurKonten) { + try { + $parser = QifParser::ausDatei($datei); + } catch (\Throwable $e) { + fwrite(STDERR, 'Datei nicht lesbar: ' . $e->getMessage() . "\n"); + exit(1); + } + + echo 'Konten in ' . basename($datei) . ":\n"; + foreach (QifBuchungsImport::kontenDerDatei($parser) as $name => $anzahl) { + printf(" %-40s %6d Einträge\n", $name, $anzahl); + } + exit(0); +} + try { $parser = QifParser::ausDatei($datei); - $stamm = (new QifStammdatenImport($parser))->ausfuehren(!$uebernehmen); + + // Ein Tippfehler im Kontonamen bliebe sonst unbemerkt: Der Import liefe + // durch und legte nichts an. + $bekannt = array_keys(QifBuchungsImport::kontenDerDatei($parser)); + foreach ($konten as $name) { + if (!in_array($name, $bekannt, true)) { + fwrite(STDERR, "Konto nicht in der Datei: {$name}\n"); + fwrite(STDERR, "Vorhanden: " . implode(', ', $bekannt) . "\n"); + exit(1); + } + } + + $stamm = (new QifStammdatenImport($parser))->ausfuehren(!$uebernehmen); $buch = null; if (!$nurStammdaten) { - $buch = (new QifBuchungsImport($parser, ['stichtag' => $stichtag, 'bis' => $bis])) - ->ausfuehren(!$uebernehmen); + $buch = (new QifBuchungsImport($parser, [ + 'stichtag' => $stichtag, + 'bis' => $bis, + 'konten' => $konten, + ]))->ausfuehren(!$uebernehmen); } } catch (\Throwable $e) { fwrite(STDERR, 'Import fehlgeschlagen: ' . $e->getMessage() . "\n"); @@ -131,6 +172,10 @@ if ($buch !== null) { $buch['buchungen']['neu'], $buch['buchungen']['vorhanden'], $buch['buchungen']['fehler']); echo "\nZeitraum ab {$stichtag} bis {$bis}\n"; + if ($buch['konten'] !== null) { + echo ' nur die Konten: ' . implode(', ', $buch['konten']) . "\n"; + printf(" wegen der Kontoauswahl übergangen: %4d Buchungen\n", $buch['buchungen']['nicht_gewaehlt']); + } printf(" vor dem Stichtag verrechnet: %6d Buchungen auf %d Konten (Summe %s)\n", $buch['buchungen']['vor_stichtag'], $buch['anfangssalden']['konten'], diff --git a/src/Controllers/BaseController.php b/src/Controllers/BaseController.php index 7b8e02b..904c324 100644 --- a/src/Controllers/BaseController.php +++ b/src/Controllers/BaseController.php @@ -50,6 +50,28 @@ abstract class BaseController return array_values(array_map('intval', array_filter($werte, 'is_scalar'))); } + /** + * Mehrfachauswahl mit Textwerten (Ankreuzfelder wie „konten[]“). + * + * @return list + */ + protected function postTexte(string $feld): array + { + $werte = $_POST[$feld] ?? []; + if (!is_array($werte)) { + return []; + } + + $texte = []; + foreach ($werte as $wert) { + if (is_string($wert) && trim($wert) !== '') { + $texte[] = trim($wert); + } + } + + return $texte; + } + protected function query(string $feld, string $standard = ''): string { $wert = $_GET[$feld] ?? $standard; diff --git a/src/Controllers/ImportController.php b/src/Controllers/ImportController.php index ae1f927..6afb5ea 100644 --- a/src/Controllers/ImportController.php +++ b/src/Controllers/ImportController.php @@ -36,6 +36,8 @@ class ImportController extends BaseController 'stichtag' => (string)Einstellung::get('import_stichtag', '2026-01-01'), 'bis' => date('Y-m-d'), 'datei' => '', + 'dateikonten' => [], + 'auswahl' => [], ]); } @@ -89,14 +91,23 @@ class ImportController extends BaseController $parser = QifParser::ausDatei($pfad); $ergebnis = (new QifStammdatenImport($parser))->ausfuehren($trockenlauf); + // Erst nach dem Lesen steht fest, welche Konten die Datei führt – + // nur die stehen zur Wahl. Was im Formular sonst noch ankäme, fiele + // hier heraus. + $dateikonten = QifBuchungsImport::kontenDerDatei($parser); + $auswahl = array_values(array_intersect($this->postTexte('konten'), array_keys($dateikonten))); + // Buchungen brauchen die Stammdaten – im Trockenlauf sind die noch // nicht angelegt, deshalb bleibt es dort bei der Stammdatenvorschau, // sobald die Konten fehlen. $buchungen = null; $vorlagen = null; if (!$this->post('nur_stammdaten')) { - $buchungen = (new QifBuchungsImport($parser, ['stichtag' => $stichtag, 'bis' => $bis])) - ->ausfuehren($trockenlauf); + $buchungen = (new QifBuchungsImport($parser, [ + 'stichtag' => $stichtag, + 'bis' => $bis, + 'konten' => $auswahl, + ]))->ausfuehren($trockenlauf); // Buchungsvorlagen stehen in derselben Datei (!Type:Memorized) // und kommen gleich mit – ein eigener Durchgang wäre nur ein @@ -118,6 +129,8 @@ class ImportController extends BaseController 'stichtag' => $stichtag, 'bis' => $bis, 'datei' => $name, + 'dateikonten' => $dateikonten, + 'auswahl' => $auswahl, ]); } diff --git a/src/Services/QifBuchungsImport.php b/src/Services/QifBuchungsImport.php index 4be1616..7451db0 100644 --- a/src/Services/QifBuchungsImport.php +++ b/src/Services/QifBuchungsImport.php @@ -28,6 +28,11 @@ use Finanzmanager\Models\Wertpapier; * * Buchungen nach dem Enddatum sind Vorausberechnungen des FinanzManagers und * werden übersprungen. + * + * Über die Option `konten` lässt sich der Import auf einzelne Konten begrenzen. + * Maßgeblich ist dann nicht allein der `!Account`-Abschnitt, sondern jedes + * Konto, das die Buchung berührt – sonst fehlten die Umbuchungen, die auf einem + * anderen Konto erfasst wurden und nur über `L[Konto]` hierher zeigen. */ class QifBuchungsImport { @@ -38,6 +43,9 @@ class QifBuchungsImport private string $stichtag; private string $bis; + /** @var array|null Gewählte Kontonamen; null heißt: alle. */ + private ?array $auswahl = null; + /** @var array Kontoname → id */ private array $konten = []; /** @var array Kategoriepfad → id */ @@ -46,12 +54,14 @@ class QifBuchungsImport private array $klassen = []; /** @var array Basis-Hash → wie oft schon gesehen */ private array $hashZaehler = []; + /** @var array Position in der Datei → Fingerabdruck der Geldbuchung */ + private array $hashes = []; /** @var list */ private array $meldungen = []; /** - * @param array{stichtag?: string, bis?: string} $optionen + * @param array{stichtag?: string, bis?: string, konten?: list} $optionen */ public function __construct(QifParser $parser, array $optionen = []) { @@ -62,6 +72,44 @@ class QifBuchungsImport if (!Buchung::istDatum($this->stichtag) || !Buchung::istDatum($this->bis)) { throw new \InvalidArgumentException('Stichtag und Enddatum müssen als JJJJ-MM-TT vorliegen.'); } + + $auswahl = []; + foreach ($optionen['konten'] ?? [] as $name) { + $name = trim((string)$name); + if ($name !== '') { + $auswahl[$name] = true; + } + } + $this->auswahl = $auswahl === [] ? null : $auswahl; + } + + /** + * Welche Konten kommen in der Datei überhaupt vor? + * + * Grundlage der Kontoauswahl im Dialog: auswählbar ist nur, was der Export + * auch hergibt. Gezählt wird je Konto, wie viele Einträge es berühren – + * eigene Buchungen ebenso wie Umbuchungen, die von anderswo hierher zeigen. + * Ein Konto, das nur Umbuchungsziel ist, führt in der Datei keine eigene + * Zeile und stünde sonst nicht zur Wahl. + * + * @return array Kontoname → Zahl der Einträge, die es betreffen + */ + public static function kontenDerDatei(QifParser $parser): array + { + $konten = []; + + foreach ($parser->eintraege() as $eintrag) { + if (!in_array($eintrag['typ'], self::GELDTYPEN, true) && $eintrag['typ'] !== 'Invst') { + continue; + } + foreach (self::beteiligteKonten($eintrag) as $name) { + $konten[$name] = ($konten[$name] ?? 0) + 1; + } + } + + ksort($konten, SORT_NATURAL | SORT_FLAG_CASE); + + return $konten; } /** @@ -82,6 +130,7 @@ class QifBuchungsImport 'buchungen' => $buchungen, 'stichtag' => $this->stichtag, 'bis' => $this->bis, + 'konten' => $this->auswahl === null ? null : array_keys($this->auswahl), 'trockenlauf' => $trockenlauf, ]; }; @@ -101,6 +150,11 @@ class QifBuchungsImport * Summiert alles vor dem Stichtag je Konto auf – die Gegenseiten der * Umbuchungen eingeschlossen – und schreibt das Ergebnis als Anfangssaldo. * + * Bei einer Kontoauswahl wird trotzdem die ganze Datei durchgerechnet: Der + * Saldo eines Kontos steckt auch in Buchungen, die auf einem anderen Konto + * erfasst wurden. Nur geschrieben werden die gewählten Konten – sonst + * überschriebe ein Teilimport die Salden aller übrigen. + * * @return array{konten: int, summe: float, salden: array} */ private function anfangssalden(bool $trockenlauf): array @@ -142,8 +196,12 @@ class QifBuchungsImport } } - $gesetzt = 0; + $gesetzt = 0; + $geschrieben = []; foreach ($salden as $name => $betrag) { + if (!$this->gewaehlt($name)) { + continue; + } $kontoId = $this->kontoId($name); if ($kontoId === null) { $this->melde('fehler', 'Anfangssaldo für unbekanntes Konto „' . $name . '“ verworfen.'); @@ -155,13 +213,14 @@ class QifBuchungsImport [round($betrag, 2), $kontoId] ); } + $geschrieben[$name] = $betrag; $gesetzt++; } return [ 'konten' => $gesetzt, - 'summe' => round(array_sum($salden), 2), - 'salden' => $salden, + 'summe' => round(array_sum($geschrieben), 2), + 'salden' => $geschrieben, ]; } @@ -172,10 +231,12 @@ class QifBuchungsImport { $zaehler = [ 'neu' => 0, 'vorhanden' => 0, 'vor_stichtag' => 0, - 'zukunft' => 0, 'wertpapier' => 0, 'fehler' => 0, + 'zukunft' => 0, 'wertpapier' => 0, 'nicht_gewaehlt' => 0, 'fehler' => 0, ]; - foreach ($this->parser->eintraege() as $eintrag) { + $this->hashes = $this->hashes(); + + foreach ($this->parser->eintraege() as $nr => $eintrag) { $istGeld = $this->istGeldbuchung($eintrag); if (!$istGeld && $eintrag['typ'] !== 'Invst') { continue; @@ -195,10 +256,14 @@ class QifBuchungsImport $zaehler['zukunft']++; continue; } + if (!$this->gehoertZurAuswahl($eintrag)) { + $zaehler['nicht_gewaehlt']++; + continue; + } try { $angelegt = $istGeld - ? $this->geldbuchung($eintrag, $datum, $trockenlauf) + ? $this->geldbuchung($eintrag, $nr, $datum, $trockenlauf) : $this->wertpapierbuchung($eintrag, $datum, $trockenlauf); if ($angelegt === null) { @@ -227,9 +292,10 @@ class QifBuchungsImport /** * @param array{typ: string, konto: ?string, zeilen: list} $eintrag + * @param int $nr Position in der Datei – dort liegt der vorab vergebene Hash. * @return int|null Kennung der neuen Buchung, oder null wenn sie schon da war. */ - private function geldbuchung(array $eintrag, string $datum, bool $trockenlauf): ?int + private function geldbuchung(array $eintrag, int $nr, string $datum, bool $trockenlauf): ?int { $kontoName = (string)$eintrag['konto']; $kontoId = $this->kontoId($kontoName); @@ -241,7 +307,7 @@ class QifBuchungsImport $text = (string)QifParser::feld($eintrag, 'M'); $nummer = (string)QifParser::feld($eintrag, 'N'); - $hash = $this->eindeutigerHash($kontoId, $datum, $betrag, $nummer, $text); + $hash = $this->hashes[$nr] ?? $this->eindeutigerHash($kontoId, $datum, $betrag, $nummer, $text); if (Buchung::findeNachHash($hash) !== null) { return null; } @@ -568,6 +634,113 @@ class QifBuchungsImport return self::umbuchungsziel($strich === false ? $angabe : substr($angabe, $strich)); } + /** + * Vergibt die Fingerabdrücke aller Geldbuchungen im Zeitraum vorab. + * + * Das geschieht in einem eigenen Durchgang über die ganze Datei, weil der + * laufende Zähler für gleiche Fingerabdrücke sonst von der Kontoauswahl + * abhinge: Dieselbe Buchung bekäme im Teilimport einen anderen Hash als im + * Vollimport und würde ein zweites Mal angelegt. + * + * @return array Position in der Datei → Hash + */ + private function hashes(): array + { + $this->hashZaehler = []; + $hashes = []; + + foreach ($this->parser->eintraege() as $nr => $eintrag) { + if (!$this->istGeldbuchung($eintrag)) { + continue; + } + + $datum = QifParser::datum(QifParser::feld($eintrag, 'D'), $this->parser->datumsformat()); + if ($datum === null || $datum < $this->stichtag || $datum > $this->bis) { + continue; + } + + $kontoId = $this->kontoId((string)$eintrag['konto']); + if ($kontoId === null) { + continue; + } + + $hashes[$nr] = $this->eindeutigerHash( + $kontoId, + $datum, + QifParser::betrag(QifParser::feld($eintrag, 'U') ?? QifParser::feld($eintrag, 'T')), + (string)QifParser::feld($eintrag, 'N'), + (string)QifParser::feld($eintrag, 'M') + ); + } + + return $hashes; + } + + /** + * Berührt der Eintrag eines der gewählten Konten? + * + * Ohne Auswahl gilt alles. Sonst zählt nicht nur der `!Account`-Abschnitt, + * in dem der Eintrag steht: Eine Umbuchung steht nur einmal in der Datei, + * und wäre allein das erfassende Konto maßgeblich, fehlte dem gewählten + * Konto die Gegenseite. + */ + private function gehoertZurAuswahl(array $eintrag): bool + { + if ($this->auswahl === null) { + return true; + } + + foreach (self::beteiligteKonten($eintrag) as $name) { + if (isset($this->auswahl[$name])) { + return true; + } + } + + return false; + } + + private function gewaehlt(string $name): bool + { + return $this->auswahl === null || isset($this->auswahl[trim($name)]); + } + + /** + * Alle Konten, auf denen ein Eintrag zu Buche schlägt: das Konto des + * Abschnitts sowie die Umbuchungsziele aus `L` und den Splitzeilen. Beim + * Wertpapiergeschäft sind es Depot und Verrechnungskonto – Depot- und + * Geldbuchung entstehen immer gemeinsam. + * + * @return list + */ + private static function beteiligteKonten(array $eintrag): array + { + $namen = []; + if ($eintrag['konto'] !== null) { + $namen[] = trim((string)$eintrag['konto']); + } + + if ($eintrag['typ'] === 'Invst') { + $verrechnung = self::verrechnungskonto(QifParser::feld($eintrag, 'L')); + if ($verrechnung !== null) { + $namen[] = trim($verrechnung); + } + + return array_values(array_unique($namen)); + } + + $angaben = QifParser::felder($eintrag, 'S'); + $angaben[] = (string)QifParser::feld($eintrag, 'L'); + + foreach ($angaben as $angabe) { + $ziel = self::umbuchungsziel($angabe); + if ($ziel !== null) { + $namen[] = trim($ziel); + } + } + + return array_values(array_unique($namen)); + } + /** * Identische Buchungen am selben Tag kommen vor (zweimal dasselbe im selben * Laden). Der laufende Zähler hält sie auseinander und bleibt über Läufe diff --git a/src/Views/verwaltung/import.php b/src/Views/verwaltung/import.php index ca24a81..a764cae 100644 --- a/src/Views/verwaltung/import.php +++ b/src/Views/verwaltung/import.php @@ -10,6 +10,8 @@ use Finanzmanager\View; /** @var string $stichtag */ /** @var string $bis */ /** @var string $datei */ +/** @var array $dateikonten */ +/** @var list $auswahl */ /** @var string $csrfToken */ $bereiche = [ @@ -102,6 +104,12 @@ include __DIR__ . '/../layout/kopf.php'; darunter Wertpapiergeschäfte (Depot- und Geldbuchung) + 0): ?> + + gehört zu keinem der gewählten Konten + + + Fehler @@ -113,6 +121,12 @@ include __DIR__ . '/../layout/kopf.php';

Zeitraum bis . Alles davor ist nicht verloren: es steckt im Anfangssaldo der Konten. + + Übernommen wurden nur die Buchungen von + – einschließlich + der Umbuchungen, die auf einem anderen Konto erfasst wurden und hierher zeigen. + Die Anfangssalden der übrigen Konten bleiben unangetastet. +

@@ -168,14 +182,41 @@ include __DIR__ . '/../layout/kopf.php'; -
+ - + + +

Konten

+

+ Zur Wahl stehen die Konten, die in dieser Datei vorkommen. Ohne Häkchen + zählt die ganze Datei. Eine Umbuchung kommt mit, sobald eines der beiden + Konten gewählt ist – sie steht im Export nur einmal, beim erfassenden + Konto. Die Zahl nennt die Einträge, die das Konto berühren. +

+
+ $anzahl): ?> + + +
+ +
- + + + +
@@ -219,6 +260,12 @@ include __DIR__ . '/../layout/kopf.php'; Der Stichtag steht in den Einstellungen.

+

+ Soll nur ein Teil der Konten übernommen werden, zuerst den Trockenlauf + starten: danach steht fest, welche Konten die Datei führt, und sie stehen einzeln zur + Wahl. Importieren übernimmt die ganze Datei. +

+

Noch keine Datei in . diff --git a/tests/10_QifBuchungsImportTest.php b/tests/10_QifBuchungsImportTest.php index b3855d3..8eda16e 100644 --- a/tests/10_QifBuchungsImportTest.php +++ b/tests/10_QifBuchungsImportTest.php @@ -25,6 +25,8 @@ class QifBuchungsImportTest extends TestCase $this->importMitStichtag(); $this->zweiterLaufAendertNichts(); $this->umbuchungsziel(); + $this->kontenDerDatei(); + $this->nurEinKonto(); } private function beispiel(): string @@ -190,6 +192,66 @@ class QifBuchungsImportTest extends TestCase $this->assertEquals($giro, (float)Konto::findeNachName('Giro')['anfangssaldo'], 'Anfangssaldo bleibt gleich'); } + private function kontenDerDatei(): void + { + $konten = QifBuchungsImport::kontenDerDatei(new QifParser($this->beispiel())); + + $this->assertEquals(['Giro', 'Tagesgeld'], array_keys($konten), 'Beide Konten stehen zur Wahl'); + $this->assertEquals(7, $konten['Giro'], 'Alle Einträge des Girokontos gezählt'); + // Tagesgeld führt keine eigene Buchung: die drei Einträge zeigen von + // Giro aus hierher (zwei Umbuchungen, eine Splitzeile). + $this->assertEquals(3, $konten['Tagesgeld'], 'Ein reines Umbuchungsziel steht ebenfalls zur Wahl'); + } + + /** + * Teilimport: erst nur das Tagesgeldkonto, dann das Girokonto. Der zweite + * Lauf darf die Umbuchungen nicht ein zweites Mal anlegen – auch nicht mit + * anderer Kontoauswahl. + */ + private function nurEinKonto(): void + { + $this->mandantAnlegen(); + + $parser = new QifParser($this->beispiel()); + (new QifStammdatenImport($parser))->ausfuehren(); + + $ergebnis = (new QifBuchungsImport($parser, [ + 'stichtag' => '2026-01-01', + 'bis' => date('Y-m-d'), + 'konten' => ['Tagesgeld'], + ]))->ausfuehren(); + + $this->assertEquals(2, $ergebnis['buchungen']['neu'], 'Nur die beiden Buchungen mit Bezug zum Tagesgeld'); + $this->assertEquals(1, $ergebnis['buchungen']['nicht_gewaehlt'], 'Der Supermarkt gehört allein zum Giro'); + $this->assertEquals(0, $ergebnis['buchungen']['fehler'], 'Keine Fehler beim Teilimport'); + $this->assertEquals(1, $ergebnis['anfangssalden']['konten'], 'Nur ein Anfangssaldo geschrieben'); + + $giro = Konto::findeNachName('Giro'); + $tagesgeld = Konto::findeNachName('Tagesgeld'); + + $this->assertEquals('400.00', $tagesgeld['anfangssaldo'], 'Anfangssaldo des gewählten Kontos'); + $this->assertEquals('0.00', $giro['anfangssaldo'], 'Das nicht gewählte Konto bleibt unangetastet'); + $this->assertEquals(850.0, round(Konto::saldo((int)$tagesgeld['id']), 2), 'Tagesgeldsaldo aus den Gegensichten'); + + // Zweiter Lauf, diesmal das Girokonto: Die Umbuchungen sind schon da – + // ihr Fingerabdruck darf nicht von der Auswahl abhängen. + $ergebnis = (new QifBuchungsImport($parser, [ + 'stichtag' => '2026-01-01', + 'bis' => date('Y-m-d'), + 'konten' => ['Giro'], + ]))->ausfuehren(); + + $this->assertEquals(1, $ergebnis['buchungen']['neu'], 'Nur der Supermarkt fehlte noch'); + $this->assertEquals(2, $ergebnis['buchungen']['vorhanden'], 'Die Umbuchungen waren schon da'); + $this->assertEquals(3, Buchung::anzahl(), 'Keine Buchung doppelt angelegt'); + $this->assertEquals( + '500.00', + Konto::findeNachName('Giro')['anfangssaldo'], + 'Jetzt bekommt auch das Girokonto seinen Anfangssaldo' + ); + $this->assertEquals(-99.90, round(Konto::saldo((int)$giro['id']), 2), 'Girosaldo wie beim Vollimport'); + } + private function umbuchungsziel(): void { $this->assertEquals('DKB Girokonto', QifBuchungsImport::umbuchungsziel('[DKB Girokonto]'), 'Einfache Umbuchung'); diff --git a/tests/e2e/specs/stammdaten.spec.js b/tests/e2e/specs/stammdaten.spec.js index f1ba7f4..09a2b95 100644 --- a/tests/e2e/specs/stammdaten.spec.js +++ b/tests/e2e/specs/stammdaten.spec.js @@ -221,4 +221,75 @@ test.describe('Stammdaten', () => { .getByRole('button', { name: 'Löschen' }).click() await expect(page.locator('body')).not.toContainText(dateiname) }) + + test('Kontoauswahl steht erst nach dem Trockenlauf und grenzt den Import ein', async ({ page }) => { + const dateiname = `e2e-konten-${MARKE}.qif` + const giro = `E2E Giro ${MARKE}` + const tagesgeld = `E2E Tagesgeld ${MARKE}` + const inhalt = [ + '!Option:MDY', + '!Option:AutoSwitch', + '!Account', + `N${giro}`, + 'TBank', + '^', + `N${tagesgeld}`, + 'TOth T', + '^', + '!Clear:AutoSwitch', + '!Account', + `N${giro}`, + 'TBank', + '^', + '!Type:Bank', + 'D4.1.26', + 'T-250.00', + 'PÜbertrag', + `L[${tagesgeld}]`, + '^', + 'D5.2.26', + 'T-49.90', + 'PSupermarkt', + '^', + '', + ].join('\r\n') + + await page.goto('/verwaltung/import') + await page.setInputFiles('#qif', { + name: dateiname, + mimeType: 'application/octet-stream', + buffer: Buffer.from(inhalt, 'latin1'), + }) + await page.click('.formular button[type="submit"]') + await expect(page.locator('.meldung-erfolg')).toContainText('hochgeladen') + + // Vor dem ersten Lesen der Datei gibt es nichts zu wählen. + await expect(page.locator('input[name="konten[]"]')).toHaveCount(0) + + await page.locator('tr', { hasText: dateiname }) + .getByRole('button', { name: 'Trockenlauf' }).click() + + // Danach stehen genau die Konten der Datei zur Wahl – beide angehakt. + const kaestchen = page.locator('input[name="konten[]"]') + await expect(kaestchen).toHaveCount(2) + await expect(page.locator(`input[name="konten[]"][value="${giro}"]`)).toBeChecked() + await expect(page.locator(`input[name="konten[]"][value="${tagesgeld}"]`)).toBeChecked() + + // Nur das Tagesgeld übernehmen: Die Umbuchung zeigt hierher und bleibt, + // die reine Girobuchung fällt heraus. + await page.locator(`input[name="konten[]"][value="${giro}"]`).uncheck() + await page.getByRole('button', { name: 'Trockenlauf mit dieser Auswahl' }).click() + + await expect(page.locator('.kachel h2').first()).toContainText('Trockenlauf') + await expect(page.locator('tr', { hasText: 'gehört zu keinem der gewählten Konten' })) + .toContainText('1') + await expect(page.locator(`input[name="konten[]"][value="${giro}"]`)).not.toBeChecked() + + // Aufräumen – die Datei bleibt sonst im Importverzeichnis liegen. + await page.goto('/verwaltung/import') + page.on('dialog', async (dialog) => await dialog.accept()) + await page.locator('tr', { hasText: dateiname }) + .getByRole('button', { name: 'Löschen' }).click() + await expect(page.locator('body')).not.toContainText(dateiname) + }) })