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:
parent
fe40a29f35
commit
3e55b3ba73
10 changed files with 528 additions and 20 deletions
31
Changes.md
31
Changes.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>.
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue