Finanzmanager/tests/10_QifBuchungsImportTest.php
fds2610-bot 3e55b3ba73 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>
2026-08-12 14:50:43 +02:00

263 lines
10 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\QifBuchungsImport;
use Finanzmanager\Services\QifParser;
use Finanzmanager\Services\QifStammdatenImport;
/**
* Der Ausschnitt bildet die Eigenheiten des echten Exports nach: eine Umbuchung
* steht nur einmal in der Datei, eine Eröffnungsbuchung verweist auf ihr eigenes
* Konto, ein Teil der Buchungen liegt vor dem Stichtag, eine dahinter.
*/
class QifBuchungsImportTest extends TestCase
{
protected string $testName = 'Test 10: QIF-Buchungsimport mit Stichtag';
public function run(): void
{
$this->mandantAnlegen();
$this->importMitStichtag();
$this->zweiterLaufAendertNichts();
$this->umbuchungsziel();
$this->kontenDerDatei();
$this->nurEinKonto();
}
private function beispiel(): string
{
$zukunft = date('m.d.y', strtotime('+40 days'));
$zeilen = [
'!Option:MDY',
'!Type:Cat',
'N400 - Leben:Essen',
'E',
'^',
'!Option:AutoSwitch',
'!Account',
'NGiro',
'TBank',
'^',
'NTagesgeld',
'TOth T',
'^',
'!Clear:AutoSwitch',
// Girokonto
'!Account',
'NGiro',
'TBank',
'^',
'!Type:Bank ',
// Eröffnungsbuchung: verweist auf das eigene Konto
'D1.2.18',
'T1,000.00',
'PEröffnungssaldo',
'L[Giro]',
'CX',
'^',
// vor dem Stichtag: fließt in den Anfangssaldo
'D6.15.25',
'T-100.00',
'PBäcker',
'L400 - Leben:Essen',
'CX',
'^',
// Umbuchung vor dem Stichtag – die Gegenseite steht nirgends
'D7.1.25',
'T-400.00',
'PÜbertrag',
'L[Tagesgeld]',
'CX',
'^',
// im Zeitraum
'D3.18.26',
'U-49.90',
'T-49.90',
'PSupermarkt',
'MWocheneinkauf',
'L400 - Leben:Essen/Privat',
'CX',
'N77',
'^',
// Umbuchung im Zeitraum
'D4.1.26',
'T-250.00',
'PÜbertrag',
'L[Tagesgeld]',
'^',
// Splitbuchung im Zeitraum
'D5.2.26',
'T-300.00',
'PGemischt',
'L400 - Leben:Essen',
'S400 - Leben:Essen',
'EEinkauf',
'$-100.00',
'S[Tagesgeld]',
'ERücklage',
'$-200.00',
'^',
// nach dem Enddatum: vorausberechnet
'D' . $zukunft,
'T-999.00',
'PVorausschau',
'L400 - Leben:Essen',
'^',
];
return implode("\r\n", $zeilen) . "\r\n";
}
private function importMitStichtag(): void
{
$parser = new QifParser($this->beispiel());
(new QifStammdatenImport($parser))->ausfuehren();
$ergebnis = (new QifBuchungsImport($parser, [
'stichtag' => '2026-01-01',
'bis' => date('Y-m-d'),
]))->ausfuehren();
$this->assertEquals(3, $ergebnis['buchungen']['neu'], 'Drei Buchungen im Zeitraum');
$this->assertEquals(3, $ergebnis['buchungen']['vor_stichtag'], 'Drei Buchungen davor verrechnet');
$this->assertEquals(1, $ergebnis['buchungen']['zukunft'], 'Eine vorausberechnete Buchung verworfen');
$this->assertEquals(0, $ergebnis['buchungen']['fehler'], 'Keine Fehler');
$giro = Konto::findeNachName('Giro');
$tagesgeld = Konto::findeNachName('Tagesgeld');
// Anfangssaldo Giro: 1000 (Eröffnung) - 100 (Bäcker) - 400 (Übertrag)
$this->assertEquals('500.00', $giro['anfangssaldo'], 'Anfangssaldo aus der Vorgeschichte');
// Die Gegenseite der Umbuchung steht nirgends in der Datei und muss
// trotzdem auf dem Tagesgeldkonto ankommen.
$this->assertEquals('400.00', $tagesgeld['anfangssaldo'], 'Erzeugte Gegenbuchung im Anfangssaldo');
// Saldo Giro: 500 - 49,90 - 250 - 300
$this->assertEquals(-99.90, round(Konto::saldo((int)$giro['id']), 2), 'Girosaldo nach dem Import');
// Tagesgeld: 400 + 250 (Umbuchung) + 200 (Splitzeile)
$this->assertEquals(850.0, round(Konto::saldo((int)$tagesgeld['id']), 2), 'Tagesgeldsaldo nach dem Import');
$blatt = Buchung::kontoblatt((int)$tagesgeld['id']);
$this->assertEquals(2, count($blatt), 'Beide Gegensichten stehen im Kontoblatt');
foreach ($blatt as $zeile) {
$this->assert($zeile['gegensicht'], 'Es sind Gegensichten, keine eigenen Buchungen');
}
$supermarkt = null;
foreach (Buchung::kontoblatt((int)$giro['id']) as $zeile) {
if ($zeile['empfaenger'] === 'Supermarkt') {
$supermarkt = $zeile;
}
}
$this->assertNotEmpty($supermarkt, 'Die Buchung im Zeitraum ist da');
$this->assertEquals('Wocheneinkauf', $supermarkt['buchungstext'], 'Buchungstext übernommen');
$this->assertEquals('77', $supermarkt['nummer'], 'Nummer übernommen');
$this->assertEquals(1, (int)$supermarkt['abgeglichen'], 'Abgleichstatus aus dem C-Feld');
$this->assertNotEmpty($supermarkt['klasse_id'], 'Die Klasse hinter dem Schrägstrich wurde erkannt');
$this->assertEquals(
'400 - Leben:Essen',
Kategorie::find((int)$supermarkt['kategorie_id'])['pfad'],
'Kategorie zugeordnet'
);
// Die Eröffnungsbuchung verweist auf ihr eigenes Konto und darf keine
// Umbuchung geworden sein – sonst hübe sie sich selbst auf.
$this->assertEquals(
'500.00',
Konto::findeNachName('Giro')['anfangssaldo'],
'Die Eröffnungsbuchung zählt einfach, nicht doppelt'
);
}
private function zweiterLaufAendertNichts(): void
{
$vorher = Buchung::anzahl();
$giro = (float)Konto::findeNachName('Giro')['anfangssaldo'];
$parser = new QifParser($this->beispiel());
$ergebnis = (new QifBuchungsImport($parser, [
'stichtag' => '2026-01-01', 'bis' => date('Y-m-d'),
]))->ausfuehren();
$this->assertEquals(0, $ergebnis['buchungen']['neu'], 'Zweiter Lauf legt nichts an');
$this->assertEquals(3, $ergebnis['buchungen']['vorhanden'], 'Alle drei als vorhanden erkannt');
$this->assertEquals($vorher, Buchung::anzahl(), 'Die Zahl der Buchungen ist unverändert');
$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');
$this->assertEquals('DKB Girokonto', QifBuchungsImport::umbuchungsziel('|[DKB Girokonto]'), 'Wertpapierschreibweise');
$this->assertNull(QifBuchungsImport::umbuchungsziel('400 - Leben:Essen'), 'Kategorie ist kein Konto');
$this->assertNull(QifBuchungsImport::umbuchungsziel(null), 'Fehlende Angabe');
$this->assertNull(QifBuchungsImport::umbuchungsziel('[unvollständig'), 'Fehlende schließende Klammer');
}
}