QIF-Import: nur ausgewählte Konten übernehmen

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 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-08-12 14:50:43 +02:00
parent fe40a29f35
commit 3e55b3ba73
10 changed files with 528 additions and 20 deletions

View file

@ -1,5 +1,36 @@
# Änderungen # Ä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) ## Umbuchungen: Gegenkonto sichtbar, Zuordnung im Kontoblatt (12.08.2026)
Eine Umbuchung war auf dem Gegenkonto nicht mehr zuzuordnen: Die Zeile trug die Marke Eine Umbuchung war auf dem Gegenkonto nicht mehr zuzuordnen: Die Zeile trug die Marke

View file

@ -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 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 ## Dokumentation

View file

@ -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 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. 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 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), 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 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 Hochgeladene Dateien bleiben im Importverzeichnis (`data/import`), damit derselbe Export
erst geprüft und dann übernommen werden kann. 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: Auf der Kommandozeile:
```bash ```bash
@ -125,6 +157,16 @@ sonst gilt der erste aktive Benutzer.
den Einstellungen (Reiter *Import/Export*) und als Enddatum der heutige Tag. den Einstellungen (Reiter *Import/Export*) und als Enddatum der heutige Tag.
`--nur-stammdaten` lässt die Buchungen aus. `--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 ## Prüfung gegen den echten Export
Der Vollexport (8780 Buchungen, 2017–2026) ergibt mit Stichtag 01.01.2026: Der Vollexport (8780 Buchungen, 2017–2026) ergibt mit Stichtag 01.01.2026:

View file

@ -21,6 +21,9 @@ declare(strict_types=1);
* Einstellungen, sonst 2026-01-01) * Einstellungen, sonst 2026-01-01)
* --bis=JJJJ-MM-TT Enddatum; spätere Buchungen sind Vorausberechnungen * --bis=JJJJ-MM-TT Enddatum; spätere Buchungen sind Vorausberechnungen
* des FinanzManagers (Vorgabe: heute) * 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 * --benutzer=NAME Mandant, sonst der erste aktive Benutzer
*/ */
@ -39,16 +42,22 @@ Config::load();
$argumente = array_slice($argv, 1); $argumente = array_slice($argv, 1);
$uebernehmen = false; $uebernehmen = false;
$nurStammdaten = false; $nurStammdaten = false;
$nurKonten = false;
$benutzer = null; $benutzer = null;
$datei = null; $datei = null;
$stichtag = null; $stichtag = null;
$bis = null; $bis = null;
$konten = [];
foreach ($argumente as $argument) { foreach ($argumente as $argument) {
if ($argument === '--uebernehmen') { if ($argument === '--uebernehmen') {
$uebernehmen = true; $uebernehmen = true;
} elseif ($argument === '--nur-stammdaten') { } elseif ($argument === '--nur-stammdaten') {
$nurStammdaten = true; $nurStammdaten = true;
} elseif ($argument === '--konten') {
$nurKonten = true;
} elseif (str_starts_with($argument, '--konto=')) {
$konten[] = substr($argument, 8);
} elseif (str_starts_with($argument, '--benutzer=')) { } elseif (str_starts_with($argument, '--benutzer=')) {
$benutzer = substr($argument, 11); $benutzer = substr($argument, 11);
} elseif (str_starts_with($argument, '--stichtag=')) { } elseif (str_starts_with($argument, '--stichtag=')) {
@ -64,8 +73,8 @@ foreach ($argumente as $argument) {
} }
if ($datei === null) { if ($datei === null) {
fwrite(STDERR, "Aufruf: qif-import.php [--uebernehmen] [--nur-stammdaten] " fwrite(STDERR, "Aufruf: qif-import.php [--uebernehmen] [--nur-stammdaten] [--konten] "
. "[--stichtag=JJJJ-MM-TT] [--bis=JJJJ-MM-TT] [--benutzer=NAME] <datei.qif>\n"); . "[--konto=NAME] [--stichtag=JJJJ-MM-TT] [--bis=JJJJ-MM-TT] [--benutzer=NAME] <datei.qif>\n");
exit(1); exit(1);
} }
@ -91,14 +100,46 @@ if ($benutzer !== null) {
$stichtag ??= (string)Einstellung::get('import_stichtag', '2026-01-01'); $stichtag ??= (string)Einstellung::get('import_stichtag', '2026-01-01');
$bis ??= date('Y-m-d'); $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 { try {
$parser = QifParser::ausDatei($datei); $parser = QifParser::ausDatei($datei);
// 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); $stamm = (new QifStammdatenImport($parser))->ausfuehren(!$uebernehmen);
$buch = null; $buch = null;
if (!$nurStammdaten) { if (!$nurStammdaten) {
$buch = (new QifBuchungsImport($parser, ['stichtag' => $stichtag, 'bis' => $bis])) $buch = (new QifBuchungsImport($parser, [
->ausfuehren(!$uebernehmen); 'stichtag' => $stichtag,
'bis' => $bis,
'konten' => $konten,
]))->ausfuehren(!$uebernehmen);
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
fwrite(STDERR, 'Import fehlgeschlagen: ' . $e->getMessage() . "\n"); fwrite(STDERR, 'Import fehlgeschlagen: ' . $e->getMessage() . "\n");
@ -131,6 +172,10 @@ if ($buch !== null) {
$buch['buchungen']['neu'], $buch['buchungen']['vorhanden'], $buch['buchungen']['fehler']); $buch['buchungen']['neu'], $buch['buchungen']['vorhanden'], $buch['buchungen']['fehler']);
echo "\nZeitraum ab {$stichtag} bis {$bis}\n"; 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", printf(" vor dem Stichtag verrechnet: %6d Buchungen auf %d Konten (Summe %s)\n",
$buch['buchungen']['vor_stichtag'], $buch['buchungen']['vor_stichtag'],
$buch['anfangssalden']['konten'], $buch['anfangssalden']['konten'],

View file

@ -50,6 +50,28 @@ abstract class BaseController
return array_values(array_map('intval', array_filter($werte, 'is_scalar'))); return array_values(array_map('intval', array_filter($werte, 'is_scalar')));
} }
/**
* Mehrfachauswahl mit Textwerten (Ankreuzfelder wie „konten[]“).
*
* @return list<string>
*/
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 protected function query(string $feld, string $standard = ''): string
{ {
$wert = $_GET[$feld] ?? $standard; $wert = $_GET[$feld] ?? $standard;

View file

@ -36,6 +36,8 @@ class ImportController extends BaseController
'stichtag' => (string)Einstellung::get('import_stichtag', '2026-01-01'), 'stichtag' => (string)Einstellung::get('import_stichtag', '2026-01-01'),
'bis' => date('Y-m-d'), 'bis' => date('Y-m-d'),
'datei' => '', 'datei' => '',
'dateikonten' => [],
'auswahl' => [],
]); ]);
} }
@ -89,14 +91,23 @@ class ImportController extends BaseController
$parser = QifParser::ausDatei($pfad); $parser = QifParser::ausDatei($pfad);
$ergebnis = (new QifStammdatenImport($parser))->ausfuehren($trockenlauf); $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 // Buchungen brauchen die Stammdaten – im Trockenlauf sind die noch
// nicht angelegt, deshalb bleibt es dort bei der Stammdatenvorschau, // nicht angelegt, deshalb bleibt es dort bei der Stammdatenvorschau,
// sobald die Konten fehlen. // sobald die Konten fehlen.
$buchungen = null; $buchungen = null;
$vorlagen = null; $vorlagen = null;
if (!$this->post('nur_stammdaten')) { if (!$this->post('nur_stammdaten')) {
$buchungen = (new QifBuchungsImport($parser, ['stichtag' => $stichtag, 'bis' => $bis])) $buchungen = (new QifBuchungsImport($parser, [
->ausfuehren($trockenlauf); 'stichtag' => $stichtag,
'bis' => $bis,
'konten' => $auswahl,
]))->ausfuehren($trockenlauf);
// Buchungsvorlagen stehen in derselben Datei (!Type:Memorized) // Buchungsvorlagen stehen in derselben Datei (!Type:Memorized)
// und kommen gleich mit – ein eigener Durchgang wäre nur ein // und kommen gleich mit – ein eigener Durchgang wäre nur ein
@ -118,6 +129,8 @@ class ImportController extends BaseController
'stichtag' => $stichtag, 'stichtag' => $stichtag,
'bis' => $bis, 'bis' => $bis,
'datei' => $name, 'datei' => $name,
'dateikonten' => $dateikonten,
'auswahl' => $auswahl,
]); ]);
} }

View file

@ -28,6 +28,11 @@ use Finanzmanager\Models\Wertpapier;
* *
* Buchungen nach dem Enddatum sind Vorausberechnungen des FinanzManagers und * Buchungen nach dem Enddatum sind Vorausberechnungen des FinanzManagers und
* werden übersprungen. * 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 class QifBuchungsImport
{ {
@ -38,6 +43,9 @@ class QifBuchungsImport
private string $stichtag; private string $stichtag;
private string $bis; private string $bis;
/** @var array<string,true>|null Gewählte Kontonamen; null heißt: alle. */
private ?array $auswahl = null;
/** @var array<string,int> Kontoname → id */ /** @var array<string,int> Kontoname → id */
private array $konten = []; private array $konten = [];
/** @var array<string,int> Kategoriepfad → id */ /** @var array<string,int> Kategoriepfad → id */
@ -46,12 +54,14 @@ class QifBuchungsImport
private array $klassen = []; private array $klassen = [];
/** @var array<string,int> Basis-Hash → wie oft schon gesehen */ /** @var array<string,int> Basis-Hash → wie oft schon gesehen */
private array $hashZaehler = []; private array $hashZaehler = [];
/** @var array<int,string> Position in der Datei → Fingerabdruck der Geldbuchung */
private array $hashes = [];
/** @var list<array{typ: string, text: string}> */ /** @var list<array{typ: string, text: string}> */
private array $meldungen = []; private array $meldungen = [];
/** /**
* @param array{stichtag?: string, bis?: string} $optionen * @param array{stichtag?: string, bis?: string, konten?: list<string>} $optionen
*/ */
public function __construct(QifParser $parser, array $optionen = []) public function __construct(QifParser $parser, array $optionen = [])
{ {
@ -62,6 +72,44 @@ class QifBuchungsImport
if (!Buchung::istDatum($this->stichtag) || !Buchung::istDatum($this->bis)) { if (!Buchung::istDatum($this->stichtag) || !Buchung::istDatum($this->bis)) {
throw new \InvalidArgumentException('Stichtag und Enddatum müssen als JJJJ-MM-TT vorliegen.'); 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<string,int> 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, 'buchungen' => $buchungen,
'stichtag' => $this->stichtag, 'stichtag' => $this->stichtag,
'bis' => $this->bis, 'bis' => $this->bis,
'konten' => $this->auswahl === null ? null : array_keys($this->auswahl),
'trockenlauf' => $trockenlauf, 'trockenlauf' => $trockenlauf,
]; ];
}; };
@ -101,6 +150,11 @@ class QifBuchungsImport
* Summiert alles vor dem Stichtag je Konto auf – die Gegenseiten der * Summiert alles vor dem Stichtag je Konto auf – die Gegenseiten der
* Umbuchungen eingeschlossen – und schreibt das Ergebnis als Anfangssaldo. * 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<string,float>} * @return array{konten: int, summe: float, salden: array<string,float>}
*/ */
private function anfangssalden(bool $trockenlauf): array private function anfangssalden(bool $trockenlauf): array
@ -143,7 +197,11 @@ class QifBuchungsImport
} }
$gesetzt = 0; $gesetzt = 0;
$geschrieben = [];
foreach ($salden as $name => $betrag) { foreach ($salden as $name => $betrag) {
if (!$this->gewaehlt($name)) {
continue;
}
$kontoId = $this->kontoId($name); $kontoId = $this->kontoId($name);
if ($kontoId === null) { if ($kontoId === null) {
$this->melde('fehler', 'Anfangssaldo für unbekanntes Konto „' . $name . '“ verworfen.'); $this->melde('fehler', 'Anfangssaldo für unbekanntes Konto „' . $name . '“ verworfen.');
@ -155,13 +213,14 @@ class QifBuchungsImport
[round($betrag, 2), $kontoId] [round($betrag, 2), $kontoId]
); );
} }
$geschrieben[$name] = $betrag;
$gesetzt++; $gesetzt++;
} }
return [ return [
'konten' => $gesetzt, 'konten' => $gesetzt,
'summe' => round(array_sum($salden), 2), 'summe' => round(array_sum($geschrieben), 2),
'salden' => $salden, 'salden' => $geschrieben,
]; ];
} }
@ -172,10 +231,12 @@ class QifBuchungsImport
{ {
$zaehler = [ $zaehler = [
'neu' => 0, 'vorhanden' => 0, 'vor_stichtag' => 0, '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); $istGeld = $this->istGeldbuchung($eintrag);
if (!$istGeld && $eintrag['typ'] !== 'Invst') { if (!$istGeld && $eintrag['typ'] !== 'Invst') {
continue; continue;
@ -195,10 +256,14 @@ class QifBuchungsImport
$zaehler['zukunft']++; $zaehler['zukunft']++;
continue; continue;
} }
if (!$this->gehoertZurAuswahl($eintrag)) {
$zaehler['nicht_gewaehlt']++;
continue;
}
try { try {
$angelegt = $istGeld $angelegt = $istGeld
? $this->geldbuchung($eintrag, $datum, $trockenlauf) ? $this->geldbuchung($eintrag, $nr, $datum, $trockenlauf)
: $this->wertpapierbuchung($eintrag, $datum, $trockenlauf); : $this->wertpapierbuchung($eintrag, $datum, $trockenlauf);
if ($angelegt === null) { if ($angelegt === null) {
@ -227,9 +292,10 @@ class QifBuchungsImport
/** /**
* @param array{typ: string, konto: ?string, zeilen: list<array{0: string, 1: string}>} $eintrag * @param array{typ: string, konto: ?string, zeilen: list<array{0: string, 1: string}>} $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. * @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']; $kontoName = (string)$eintrag['konto'];
$kontoId = $this->kontoId($kontoName); $kontoId = $this->kontoId($kontoName);
@ -241,7 +307,7 @@ class QifBuchungsImport
$text = (string)QifParser::feld($eintrag, 'M'); $text = (string)QifParser::feld($eintrag, 'M');
$nummer = (string)QifParser::feld($eintrag, 'N'); $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) { if (Buchung::findeNachHash($hash) !== null) {
return null; return null;
} }
@ -568,6 +634,113 @@ class QifBuchungsImport
return self::umbuchungsziel($strich === false ? $angabe : substr($angabe, $strich)); 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<int,string> 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<string>
*/
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 * 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 * Laden). Der laufende Zähler hält sie auseinander und bleibt über Läufe

View file

@ -10,6 +10,8 @@ use Finanzmanager\View;
/** @var string $stichtag */ /** @var string $stichtag */
/** @var string $bis */ /** @var string $bis */
/** @var string $datei */ /** @var string $datei */
/** @var array<string,int> $dateikonten */
/** @var list<string> $auswahl */
/** @var string $csrfToken */ /** @var string $csrfToken */
$bereiche = [ $bereiche = [
@ -102,6 +104,12 @@ include __DIR__ . '/../layout/kopf.php';
<td class="nebentext">darunter Wertpapiergeschäfte (Depot- und Geldbuchung)</td> <td class="nebentext">darunter Wertpapiergeschäfte (Depot- und Geldbuchung)</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['wertpapier'] ?></td> <td class="rechts nebentext"><?= (int)$buchungen['buchungen']['wertpapier'] ?></td>
</tr> </tr>
<?php if ((int)($buchungen['buchungen']['nicht_gewaehlt'] ?? 0) > 0): ?>
<tr>
<td class="nebentext">gehört zu keinem der gewählten Konten</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['nicht_gewaehlt'] ?></td>
</tr>
<?php endif; ?>
<tr> <tr>
<td>Fehler</td> <td>Fehler</td>
<td class="rechts <?= (int)$buchungen['buchungen']['fehler'] > 0 ? 'betrag negativ' : 'nebentext' ?>"> <td class="rechts <?= (int)$buchungen['buchungen']['fehler'] > 0 ? 'betrag negativ' : 'nebentext' ?>">
@ -113,6 +121,12 @@ include __DIR__ . '/../layout/kopf.php';
<p class="hinweis"> <p class="hinweis">
Zeitraum <?= View::datum($buchungen['stichtag']) ?> bis <?= View::datum($buchungen['bis']) ?>. Zeitraum <?= View::datum($buchungen['stichtag']) ?> bis <?= View::datum($buchungen['bis']) ?>.
Alles davor ist nicht verloren: es steckt im Anfangssaldo der Konten. Alles davor ist nicht verloren: es steckt im Anfangssaldo der Konten.
<?php if (($buchungen['konten'] ?? null) !== null): ?>
Übernommen wurden nur die Buchungen von
<strong><?= View::h(implode(', ', $buchungen['konten'])) ?></strong> – einschließlich
der Umbuchungen, die auf einem anderen Konto erfasst wurden und hierher zeigen.
Die Anfangssalden der übrigen Konten bleiben unangetastet.
<?php endif; ?>
</p> </p>
<?php endif; ?> <?php endif; ?>
@ -168,14 +182,41 @@ include __DIR__ . '/../layout/kopf.php';
<?php endif; ?> <?php endif; ?>
<?php if ($ergebnis['trockenlauf']): ?> <?php if ($ergebnis['trockenlauf']): ?>
<form method="post" action="/verwaltung/import/ausfuehren" class="inline"> <form method="post" action="/verwaltung/import/ausfuehren" class="formular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>"> <input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="datei" value="<?= View::h($datei) ?>"> <input type="hidden" name="datei" value="<?= View::h($datei) ?>">
<input type="hidden" name="modus" value="import">
<input type="hidden" name="stichtag" value="<?= View::h($stichtag) ?>"> <input type="hidden" name="stichtag" value="<?= View::h($stichtag) ?>">
<input type="hidden" name="bis" value="<?= View::h($bis) ?>"> <input type="hidden" name="bis" value="<?= View::h($bis) ?>">
<?php if ($dateikonten !== []): ?>
<h3>Konten</h3>
<p class="feldhinweis">
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.
</p>
<div class="auswahlliste">
<?php foreach ($dateikonten as $name => $anzahl): ?>
<label class="ankreuzfeld">
<input type="checkbox" name="konten[]" value="<?= View::h((string)$name) ?>"
<?= $auswahl === [] || in_array((string)$name, $auswahl, true) ? 'checked' : '' ?>>
<span>
<?= View::h((string)$name) ?>
<span class="nebentext"><?= (int)$anzahl ?></span>
</span>
</label>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="knopfleiste"> <div class="knopfleiste">
<button type="submit" class="knopf primaer">Jetzt übernehmen</button> <button type="submit" name="modus" value="import" class="knopf primaer">Jetzt übernehmen</button>
<?php if ($dateikonten !== []): ?>
<button type="submit" name="modus" value="vorschau" class="knopf">
Trockenlauf mit dieser Auswahl
</button>
<?php endif; ?>
</div> </div>
</form> </form>
<?php else: ?> <?php else: ?>
@ -219,6 +260,12 @@ include __DIR__ . '/../layout/kopf.php';
Der Stichtag steht in den <a href="/einstellungen?bereich=import">Einstellungen</a>. Der Stichtag steht in den <a href="/einstellungen?bereich=import">Einstellungen</a>.
</p> </p>
<p class="hinweis">
Soll nur ein Teil der Konten übernommen werden, zuerst den <strong>Trockenlauf</strong>
starten: danach steht fest, welche Konten die Datei führt, und sie stehen einzeln zur
Wahl. <strong>Importieren</strong> übernimmt die ganze Datei.
</p>
<?php if ($dateien === []): ?> <?php if ($dateien === []): ?>
<p class="platzhalter"> <p class="platzhalter">
Noch keine Datei in <code><?= View::h($verzeichnis) ?></code>. Noch keine Datei in <code><?= View::h($verzeichnis) ?></code>.

View file

@ -25,6 +25,8 @@ class QifBuchungsImportTest extends TestCase
$this->importMitStichtag(); $this->importMitStichtag();
$this->zweiterLaufAendertNichts(); $this->zweiterLaufAendertNichts();
$this->umbuchungsziel(); $this->umbuchungsziel();
$this->kontenDerDatei();
$this->nurEinKonto();
} }
private function beispiel(): string private function beispiel(): string
@ -190,6 +192,66 @@ class QifBuchungsImportTest extends TestCase
$this->assertEquals($giro, (float)Konto::findeNachName('Giro')['anfangssaldo'], 'Anfangssaldo bleibt gleich'); $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 private function umbuchungsziel(): void
{ {
$this->assertEquals('DKB Girokonto', QifBuchungsImport::umbuchungsziel('[DKB Girokonto]'), 'Einfache Umbuchung'); $this->assertEquals('DKB Girokonto', QifBuchungsImport::umbuchungsziel('[DKB Girokonto]'), 'Einfache Umbuchung');

View file

@ -221,4 +221,75 @@ test.describe('Stammdaten', () => {
.getByRole('button', { name: 'Löschen' }).click() .getByRole('button', { name: 'Löschen' }).click()
await expect(page.locator('body')).not.toContainText(dateiname) 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)
})
}) })