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
|
||||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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] <datei.qif>\n");
|
||||
fwrite(STDERR, "Aufruf: qif-import.php [--uebernehmen] [--nur-stammdaten] [--konten] "
|
||||
. "[--konto=NAME] [--stichtag=JJJJ-MM-TT] [--bis=JJJJ-MM-TT] [--benutzer=NAME] <datei.qif>\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'],
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
{
|
||||
$wert = $_GET[$feld] ?? $standard;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string,true>|null Gewählte Kontonamen; null heißt: alle. */
|
||||
private ?array $auswahl = null;
|
||||
|
||||
/** @var array<string,int> Kontoname → id */
|
||||
private array $konten = [];
|
||||
/** @var array<string,int> Kategoriepfad → id */
|
||||
|
|
@ -46,12 +54,14 @@ class QifBuchungsImport
|
|||
private array $klassen = [];
|
||||
/** @var array<string,int> Basis-Hash → wie oft schon gesehen */
|
||||
private array $hashZaehler = [];
|
||||
/** @var array<int,string> Position in der Datei → Fingerabdruck der Geldbuchung */
|
||||
private array $hashes = [];
|
||||
|
||||
/** @var list<array{typ: string, text: string}> */
|
||||
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 = [])
|
||||
{
|
||||
|
|
@ -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<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,
|
||||
'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<string,float>}
|
||||
*/
|
||||
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<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.
|
||||
*/
|
||||
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<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
|
||||
* 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 $bis */
|
||||
/** @var string $datei */
|
||||
/** @var array<string,int> $dateikonten */
|
||||
/** @var list<string> $auswahl */
|
||||
/** @var string $csrfToken */
|
||||
|
||||
$bereiche = [
|
||||
|
|
@ -102,6 +104,12 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<td class="nebentext">darunter Wertpapiergeschäfte (Depot- und Geldbuchung)</td>
|
||||
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['wertpapier'] ?></td>
|
||||
</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>
|
||||
<td>Fehler</td>
|
||||
<td class="rechts <?= (int)$buchungen['buchungen']['fehler'] > 0 ? 'betrag negativ' : 'nebentext' ?>">
|
||||
|
|
@ -113,6 +121,12 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<p class="hinweis">
|
||||
Zeitraum <?= View::datum($buchungen['stichtag']) ?> bis <?= View::datum($buchungen['bis']) ?>.
|
||||
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>
|
||||
<?php endif; ?>
|
||||
|
||||
|
|
@ -168,14 +182,41 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<?php endif; ?>
|
||||
|
||||
<?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="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="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">
|
||||
<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>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
|
|
@ -219,6 +260,12 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
Der Stichtag steht in den <a href="/einstellungen?bereich=import">Einstellungen</a>.
|
||||
</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 === []): ?>
|
||||
<p class="platzhalter">
|
||||
Noch keine Datei in <code><?= View::h($verzeichnis) ?></code>.
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue