Aus der Kachel QIF-Import wird Import/Export mit je einem Knopf für beide Richtungen; davor steht die Wahl des Dateiformats (QIF oder CSV), die als Dialog und ohne Skript als eigene Seite funktioniert. - CsvBuchungsImport/CsvTabelle: Buchungen samt Splitzeilen, Spalten über die Kopfzeile, dieselbe Dublettenerkennung und der Trockenlauf wie beim QIF. - BuchungsExport/ExportController: Filter nach Zeitraum, Konto, Kategorie und Klasse; die Datei entsteht im Speicher, ein Exportverzeichnis gibt es nicht. - Housekeeping: Hochgeladene Importdateien und data/tmp werden nach der eingestellten Frist gelöscht (Vorgabe 14 Tage, 0 schaltet ab). Migration 013, Test 29 sowie E2E-Spec importexport.spec.js. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
559 lines
27 KiB
PHP
559 lines
27 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use Finanzmanager\Models\Buchung;
|
||
use Finanzmanager\Models\Einstellung;
|
||
use Finanzmanager\Models\Kategorie;
|
||
use Finanzmanager\Models\Klasse;
|
||
use Finanzmanager\Models\Konto;
|
||
use Finanzmanager\Services\BuchungsExport;
|
||
use Finanzmanager\Services\CsvBuchungsImport;
|
||
use Finanzmanager\Services\CsvTabelle;
|
||
use Finanzmanager\Services\Dateiformat;
|
||
use Finanzmanager\Services\Housekeeping;
|
||
use Finanzmanager\Services\QifBuchungsImport;
|
||
use Finanzmanager\Services\QifParser;
|
||
use Finanzmanager\Services\QifStammdatenImport;
|
||
use Finanzmanager\Controllers\ImportController;
|
||
|
||
/**
|
||
* Import und Export in beiden Formaten.
|
||
*
|
||
* Der Prüfstein ist der Rundlauf: Was der Export schreibt, muss der eigene
|
||
* Import wieder einlesen können – und dabei dieselben Buchungen ergeben, nicht
|
||
* Dubletten. Dazu die Filter (Zeitraum, Konto, Kategorie, Klasse), die
|
||
* Formatwahl vor beiden Dialogen und das Housekeeping, das die hochgeladenen
|
||
* Dateien wieder wegräumt.
|
||
*/
|
||
class ImportExportTest extends TestCase
|
||
{
|
||
protected string $testName = 'Test 29: Import und Export (QIF und CSV)';
|
||
|
||
private int $giro = 0;
|
||
private int $tagesgeld = 0;
|
||
private int $strom = 0;
|
||
private int $wasser = 0;
|
||
private int $haus = 0;
|
||
private int $privat = 0;
|
||
|
||
public function run(): void
|
||
{
|
||
$this->mandantAnlegen();
|
||
$this->stammdaten();
|
||
$this->buchungen();
|
||
|
||
$this->csvTabelleLiestUndSchreibt();
|
||
$this->csvExportFuehrtSplitsUndUmbuchungen();
|
||
$this->filterGreifenEinzelnUndGemeinsam();
|
||
$this->qifExportTraegtDenDialektDesFinanzManagers();
|
||
$this->qifRundlaufUeberEinenZweitenMandanten();
|
||
$this->csvRundlaufUeberEinenDrittenMandanten();
|
||
$this->csvImportErkenntDublettenUndFehler();
|
||
$this->housekeepingRaeumtAlteDateienWeg();
|
||
$this->dateinamenUndFormate();
|
||
$this->exportLegtKeineDateiAb();
|
||
$this->verwaltungFuehrtImportUndExport();
|
||
}
|
||
|
||
// ── Aufbau ───────────────────────────────────────────────────────────────
|
||
|
||
private function stammdaten(): void
|
||
{
|
||
$this->giro = Konto::anlegen([
|
||
'name' => 'Exportgiro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||
]);
|
||
$this->tagesgeld = Konto::anlegen([
|
||
'name' => 'Exportrücklage', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||
]);
|
||
|
||
$this->strom = Kategorie::findenOderAnlegenNachPfad('Wohnen:Strom');
|
||
$this->wasser = Kategorie::findenOderAnlegenNachPfad('Wohnen:Wasser');
|
||
|
||
$this->haus = Klasse::findenOderAnlegen('Haus 1');
|
||
$this->privat = Klasse::findenOderAnlegen('Privat');
|
||
}
|
||
|
||
private function buchungen(): void
|
||
{
|
||
// Eine gewöhnliche Buchung mit Kategorie und Klasse …
|
||
Buchung::anlegen([
|
||
'konto_id' => $this->giro, 'datum' => '2026-03-18', 'betrag' => -84.00,
|
||
'empfaenger' => 'Stadtwerke', 'buchungstext' => 'Abschlag März', 'nummer' => '042',
|
||
'kategorie_id' => $this->strom, 'klasse_id' => $this->haus, 'abgeglichen' => 1,
|
||
]);
|
||
|
||
// … eine Umbuchung auf das zweite Konto …
|
||
Buchung::anlegen([
|
||
'konto_id' => $this->giro, 'datum' => '2026-03-20', 'betrag' => -500.00,
|
||
'empfaenger' => 'Eigenübertrag', 'buchungstext' => 'Rücklage',
|
||
'gegenkonto_id' => $this->tagesgeld,
|
||
]);
|
||
|
||
// … und eine Splitbuchung, deren Zeilen zwei Kategorien tragen.
|
||
Buchung::anlegen(
|
||
[
|
||
'konto_id' => $this->giro, 'datum' => '2026-04-02', 'betrag' => -110.00,
|
||
'empfaenger' => 'Versorger', 'buchungstext' => 'Jahresabrechnung',
|
||
],
|
||
[
|
||
['kategorie_id' => $this->strom, 'klasse_id' => $this->haus, 'betrag' => -80.00, 'notiz' => 'Strom'],
|
||
['kategorie_id' => $this->wasser, 'klasse_id' => $this->privat, 'betrag' => -30.00, 'notiz' => 'Wasser'],
|
||
]
|
||
);
|
||
}
|
||
|
||
// ── Das Dateiformat selbst ───────────────────────────────────────────────
|
||
|
||
private function csvTabelleLiestUndSchreibt(): void
|
||
{
|
||
$text = CsvTabelle::schreibe([
|
||
['Feld', 'Mit;Trenner', 'Mit"Zitat'],
|
||
['Wert', 'zwei', 'drei'],
|
||
]);
|
||
|
||
$this->assert(str_starts_with($text, CsvTabelle::BOM), 'Die Datei beginnt mit einem BOM');
|
||
$this->assert(str_contains($text, '"Mit;Trenner"'), 'Ein Feld mit Trenner wird maskiert');
|
||
$this->assert(str_contains($text, '"Mit""Zitat"'), 'Anführungszeichen werden verdoppelt');
|
||
$this->assert(str_contains($text, "\r\n"), 'Zeilen enden mit CRLF');
|
||
|
||
$zeilen = CsvTabelle::lies($text);
|
||
$this->assertEquals(2, count($zeilen), 'Die geschriebene Datei liest sich wieder ein');
|
||
$this->assertEquals('Mit;Trenner', $zeilen[0][1], 'Der maskierte Trenner überlebt den Rundlauf');
|
||
$this->assertEquals('Mit"Zitat', $zeilen[0][2], 'Das Anführungszeichen überlebt den Rundlauf');
|
||
|
||
// Fremde Dateien: Komma als Trenner, Punkt als Dezimalzeichen.
|
||
$fremd = CsvTabelle::datensaetze("Datum,Konto,Betrag\r\n2026-03-18,Giro,\"1,234.56\"\r\n");
|
||
$this->assertEquals(1, count($fremd), 'Auch eine Datei mit Komma-Trenner wird gelesen');
|
||
$this->assertEquals('2026-03-18', $fremd[0]['datum'], 'Die Kopfzeile wird zum Schlüssel');
|
||
|
||
$this->assertEquals(1234.56, CsvTabelle::betrag('1,234.56'), 'Betrag im englischen Format');
|
||
$this->assertEquals(-1234.56, CsvTabelle::betrag('-1.234,56'), 'Betrag im deutschen Format');
|
||
$this->assertEquals(-84.0, CsvTabelle::betrag('-84,00 €'), 'Währungszeichen stört nicht');
|
||
$this->assertEquals(0.0, CsvTabelle::betrag(''), 'Ein leeres Feld ist null');
|
||
|
||
$this->assertEquals('2026-03-18', CsvTabelle::datum('18.03.2026'), 'Datum als TT.MM.JJJJ');
|
||
$this->assertEquals('2026-03-18', CsvTabelle::datum('2026-03-18'), 'Datum als ISO');
|
||
$this->assertEquals('1998-03-18', CsvTabelle::datum('18.03.98'), 'Zweistellige Jahre ab 70 sind 19xx');
|
||
$this->assertNull(CsvTabelle::datum('31.02.2026'), 'Ein Datum, das es nicht gibt, fällt heraus');
|
||
$this->assertNull(CsvTabelle::datum('irgendwann'), 'Text ist kein Datum');
|
||
}
|
||
|
||
// ── Export ───────────────────────────────────────────────────────────────
|
||
|
||
private function csvExportFuehrtSplitsUndUmbuchungen(): void
|
||
{
|
||
$csv = BuchungsExport::csv(BuchungsExport::buchungen([]));
|
||
$zeilen = CsvTabelle::lies($csv);
|
||
|
||
$this->assertEquals(BuchungsExport::SPALTEN, $zeilen[0], 'Die Kopfzeile nennt alle Spalten');
|
||
|
||
// Drei Buchungen, dazu zwei Splitzeilen – plus Kopfzeile.
|
||
$arten = array_count_values(array_column($zeilen, 0));
|
||
$this->assertEquals(3, $arten[BuchungsExport::ART_BUCHUNG] ?? 0, 'Drei Buchungen im Export');
|
||
$this->assertEquals(2, $arten[BuchungsExport::ART_SPLIT] ?? 0, 'Zwei Splitzeilen im Export');
|
||
|
||
$this->assert(str_contains($csv, '18.03.2026'), 'Das Datum steht deutsch in der Datei');
|
||
$this->assert(str_contains($csv, '-84,00'), 'Der Betrag steht mit Komma in der Datei');
|
||
$this->assert(str_contains($csv, 'Wohnen:Strom'), 'Die Kategorie steht als Pfad in der Datei');
|
||
$this->assert(str_contains($csv, 'Exportrücklage'), 'Die Umbuchung nennt ihr Gegenkonto');
|
||
|
||
// Die Splitzeilen folgen unmittelbar auf ihre Buchung.
|
||
$split = null;
|
||
foreach ($zeilen as $nr => $zeile) {
|
||
if ($zeile[0] === BuchungsExport::ART_SPLIT) {
|
||
$split = $nr;
|
||
break;
|
||
}
|
||
}
|
||
$this->assertNotEmpty($split, 'Es gibt eine Splitzeile');
|
||
$this->assertEquals(
|
||
BuchungsExport::ART_BUCHUNG,
|
||
$zeilen[$split - 1][0],
|
||
'Über der ersten Splitzeile steht ihre Buchung'
|
||
);
|
||
}
|
||
|
||
private function filterGreifenEinzelnUndGemeinsam(): void
|
||
{
|
||
// Gezählt wird über COUNT, geladen über dieselbe Bedingung – geprüft
|
||
// wird deshalb beides gegeneinander.
|
||
$anzahl = function (array $filter): int {
|
||
$geladen = count(BuchungsExport::buchungen($filter));
|
||
$this->assertEquals($geladen, BuchungsExport::anzahl($filter), 'Zählen und Laden sind sich einig');
|
||
|
||
return $geladen;
|
||
};
|
||
|
||
$this->assertEquals(3, $anzahl([]), 'Ohne Filter kommt alles mit');
|
||
$this->assertEquals(2, $anzahl(['von' => '2026-03-19']), 'Der Zeitraum grenzt nach vorn ab');
|
||
$this->assertEquals(2, $anzahl(['bis' => '2026-03-31']), 'Der Zeitraum grenzt nach hinten ab');
|
||
$this->assertEquals(1, $anzahl(['von' => '2026-03-19', 'bis' => '2026-03-31']), 'Beide Grenzen zusammen');
|
||
|
||
// Das Tagesgeld führt selbst keine Buchung – die Umbuchung steht beim
|
||
// Girokonto. Sie muss trotzdem kommen, sonst fehlte dem Auszug die
|
||
// einzige Bewegung des Kontos.
|
||
$this->assertEquals(
|
||
1,
|
||
$anzahl(['konten' => [$this->tagesgeld]]),
|
||
'Der Kontofilter holt die Umbuchung von der anderen Seite'
|
||
);
|
||
$this->assertEquals(3, $anzahl(['konten' => [$this->giro]]), 'Das erfassende Konto führt alle drei');
|
||
|
||
// Die Kategorie steckt bei der Splitbuchung allein in den Zeilen.
|
||
$this->assertEquals(2, $anzahl(['kategorien' => [$this->strom]]), 'Kategorie: Buchung und Splitzeile');
|
||
$this->assertEquals(1, $anzahl(['kategorien' => [$this->wasser]]), 'Kategorie nur in einer Splitzeile');
|
||
$this->assertEquals(2, $anzahl(['klassen' => [$this->haus]]), 'Klasse: Buchung und Splitzeile');
|
||
$this->assertEquals(1, $anzahl(['klassen' => [$this->privat]]), 'Klasse nur in einer Splitzeile');
|
||
|
||
$this->assertEquals(
|
||
1,
|
||
$anzahl(['kategorien' => [$this->strom], 'bis' => '2026-03-31']),
|
||
'Kategorie und Zeitraum wirken zusammen'
|
||
);
|
||
$this->assertEquals(0, $anzahl(['von' => '2027-01-01']), 'Ein leerer Zeitraum liefert nichts');
|
||
}
|
||
|
||
private function qifExportTraegtDenDialektDesFinanzManagers(): void
|
||
{
|
||
$qif = BuchungsExport::qif(BuchungsExport::buchungen([]));
|
||
|
||
$this->assert(str_contains($qif, '!Type:Class'), 'Das QIF führt die Klassen');
|
||
$this->assert(str_contains($qif, '!Type:Cat'), 'Das QIF führt die Kategorien');
|
||
$this->assert(str_contains($qif, '!Option:AutoSwitch'), 'Das QIF führt die Kontenliste');
|
||
$this->assert(str_contains($qif, 'T-84.00'), 'Beträge stehen im QIF im US-Format');
|
||
$this->assert(str_contains($qif, 'D18.03.2026'), 'Das Datum folgt der Angabe !Option:DMY');
|
||
$this->assert(str_contains($qif, 'LWohnen:Strom/Haus 1'), 'Kategorie und Klasse stehen in einer L-Zeile');
|
||
$this->assert(str_contains($qif, 'L[Exportrücklage]'), 'Die Umbuchung nennt ihr Gegenkonto in Klammern');
|
||
$this->assert(str_contains($qif, 'SWohnen:Wasser/Privat'), 'Die Splitzeile trägt Kategorie und Klasse');
|
||
$this->assert(str_contains($qif, '$-30.00'), 'Die Splitzeile trägt ihren Betrag');
|
||
|
||
$parser = new QifParser($qif);
|
||
$konten = QifBuchungsImport::kontenDerDatei($parser);
|
||
|
||
$this->assertEquals(3, $parser->zusammenfassung()['Bank'] ?? 0, 'Der eigene Parser findet drei Buchungen');
|
||
$this->assert(isset($konten['Exportgiro']), 'Der Parser erkennt das Girokonto');
|
||
$this->assert(
|
||
isset($konten['Exportrücklage']),
|
||
'Auch das Umbuchungsziel steht in der Kontenliste des Parsers'
|
||
);
|
||
}
|
||
|
||
// ── Import ───────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Der Rundlauf über einen frischen Mandanten: Stammdaten und Buchungen
|
||
* müssen sich aus der Exportdatei allein wiederherstellen lassen.
|
||
*/
|
||
private function qifRundlaufUeberEinenZweitenMandanten(): void
|
||
{
|
||
$qif = BuchungsExport::qif(BuchungsExport::buchungen([]));
|
||
|
||
$this->mandantAnlegen();
|
||
|
||
$stammdaten = (new QifStammdatenImport(new QifParser($qif)))->ausfuehren();
|
||
$this->assertEquals(2, $stammdaten['konten']['neu'], 'Beide Konten entstehen aus der Datei');
|
||
$this->assertEquals(2, $stammdaten['klassen']['neu'], 'Beide Klassen entstehen aus der Datei');
|
||
$this->assertGreaterThan(2, $stammdaten['kategorien']['neu'], 'Der Kategoriebaum entsteht mit');
|
||
|
||
$lauf = ['stichtag' => '2026-01-01', 'bis' => '2026-12-31'];
|
||
|
||
$ergebnis = (new QifBuchungsImport(new QifParser($qif), $lauf))->ausfuehren();
|
||
$this->assertEquals(3, $ergebnis['buchungen']['neu'], 'Alle drei Buchungen kommen an');
|
||
$this->assertEquals(0, $ergebnis['buchungen']['fehler'], 'Der Rundlauf läuft fehlerfrei');
|
||
$this->assertEquals(3, Buchung::anzahl(), 'Drei Buchungen stehen in der Datenbank');
|
||
|
||
// Der zweite Lauf derselben Datei darf nichts verdoppeln.
|
||
$zweiter = (new QifBuchungsImport(new QifParser($qif), $lauf))->ausfuehren();
|
||
$this->assertEquals(0, $zweiter['buchungen']['neu'], 'Der zweite Lauf legt nichts an');
|
||
$this->assertEquals(3, $zweiter['buchungen']['vorhanden'], 'Alle drei gelten als vorhanden');
|
||
|
||
// Umbuchung und Splitbuchung müssen den Weg heil überstanden haben.
|
||
[$umbuchung, $split] = $this->umbuchungUndSplit();
|
||
$this->assertNotEmpty($umbuchung, 'Die Umbuchung kam als Umbuchung an');
|
||
$this->assertEquals(2, count($split['splits'] ?? []), 'Die Splitbuchung hat wieder zwei Zeilen');
|
||
$this->assertEquals(
|
||
'Wohnen:Wasser',
|
||
$split['splits'][1]['kategoriepfad'],
|
||
'Die zweite Splitzeile trägt ihre Kategorie'
|
||
);
|
||
}
|
||
|
||
private function csvRundlaufUeberEinenDrittenMandanten(): void
|
||
{
|
||
$csv = BuchungsExport::csv(BuchungsExport::buchungen([]));
|
||
|
||
// Ein weiterer Mandant mit gleichnamigen Konten: So lässt sich prüfen,
|
||
// dass der Import wirklich anlegt, statt nur Dubletten zu zählen.
|
||
$this->mandantAnlegen();
|
||
Konto::anlegen(['name' => 'Exportgiro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1]);
|
||
Konto::anlegen(['name' => 'Exportrücklage', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1]);
|
||
|
||
$vorschau = (new CsvBuchungsImport($csv))->vorschau();
|
||
$this->assertEquals(3, $vorschau['buchungen']['neu'], 'Der Trockenlauf zählt drei neue Buchungen');
|
||
$this->assertEquals(0, Buchung::anzahl(), 'Der Trockenlauf hat nichts gespeichert');
|
||
|
||
$ergebnis = (new CsvBuchungsImport($csv))->ausfuehren();
|
||
$this->assertEquals(3, $ergebnis['buchungen']['neu'], 'Der echte Lauf legt drei Buchungen an');
|
||
$this->assertEquals(2, $ergebnis['buchungen']['splitzeilen'], 'Beide Splitzeilen kommen mit');
|
||
$this->assertEquals(0, $ergebnis['buchungen']['fehler'], 'Der Import läuft fehlerfrei');
|
||
$this->assertEquals(3, Buchung::anzahl(), 'Drei Buchungen stehen in der Datenbank');
|
||
|
||
// Kategorien und Klassen bringt der Import mit, Konten nicht.
|
||
$this->assertNotEmpty(Kategorie::findeNachPfad('Wohnen:Strom'), 'Die Kategorie wurde ergänzt');
|
||
$this->assertNotEmpty(Klasse::findeNachName('Haus 1'), 'Die Klasse wurde ergänzt');
|
||
|
||
$wieder = BuchungsExport::csv(BuchungsExport::buchungen([]));
|
||
$this->assertEquals(
|
||
count(CsvTabelle::lies($csv)),
|
||
count(CsvTabelle::lies($wieder)),
|
||
'Der erneute Export hat genauso viele Zeilen wie der erste'
|
||
);
|
||
|
||
// Umbuchung und Splitbuchung müssen den Weg heil überstanden haben.
|
||
[$umbuchung, $split] = $this->umbuchungUndSplit();
|
||
|
||
$this->assertNotEmpty($umbuchung, 'Die Umbuchung kam als Umbuchung an');
|
||
$this->assertNotEmpty($split, 'Die Splitbuchung kam mit Zeilen an');
|
||
$this->assertEquals(2, count($split['splits'] ?? []), 'Die Splitbuchung hat wieder zwei Zeilen');
|
||
$this->assertEquals('Wohnen:Wasser', $split['splits'][1]['kategoriepfad'], 'Die zweite Zeile trägt ihre Kategorie');
|
||
$this->assertEquals('Privat', $split['splits'][1]['klassenname'], 'Die zweite Zeile trägt ihre Klasse');
|
||
}
|
||
|
||
private function csvImportErkenntDublettenUndFehler(): void
|
||
{
|
||
$csv = BuchungsExport::csv(BuchungsExport::buchungen([]));
|
||
|
||
$zweiter = (new CsvBuchungsImport($csv))->ausfuehren();
|
||
$this->assertEquals(0, $zweiter['buchungen']['neu'], 'Der zweite Lauf legt nichts an');
|
||
$this->assertEquals(3, $zweiter['buchungen']['vorhanden'], 'Alle drei gelten als vorhanden');
|
||
$this->assertEquals(3, Buchung::anzahl(), 'Die Zahl der Buchungen bleibt gleich');
|
||
|
||
// Ein unbekanntes Konto legt der Import nicht an – die Zeile fällt aus.
|
||
$fremd = "Art;Datum;Konto;Betrag\r\nBuchung;01.05.2026;Gibtesnicht;-1,00\r\n";
|
||
$lauf = (new CsvBuchungsImport($fremd))->vorschau();
|
||
$this->assertEquals(1, $lauf['buchungen']['fehler'], 'Ein unbekanntes Konto ist ein Fehler');
|
||
$this->assertEquals(0, $lauf['buchungen']['neu'], 'Aus der Zeile wird keine Buchung');
|
||
$this->assertNotEmpty($lauf['meldungen'], 'Das Protokoll nennt den Grund');
|
||
|
||
// Ohne Pflichtspalte lässt sich gar nichts einlesen.
|
||
$ohneKonto = "Art;Datum;Betrag\r\nBuchung;01.05.2026;-1,00\r\n";
|
||
$meldung = '';
|
||
try {
|
||
(new CsvBuchungsImport($ohneKonto))->vorschau();
|
||
} catch (\Throwable $e) {
|
||
$meldung = $e->getMessage();
|
||
}
|
||
$this->assert(str_contains($meldung, 'Spalten'), 'Eine fehlende Pflichtspalte bricht den Lauf ab');
|
||
|
||
// Eine Splitzeile ohne Buchung davor ist ein Fehler, kein stiller Verlust.
|
||
$verwaist = "Art;Datum;Konto;Betrag\r\nSplitzeile;;;-1,00\r\n";
|
||
$lauf = (new CsvBuchungsImport($verwaist))->vorschau();
|
||
$this->assertEquals(1, $lauf['buchungen']['fehler'], 'Eine verwaiste Splitzeile wird gemeldet');
|
||
}
|
||
|
||
// ── Housekeeping ─────────────────────────────────────────────────────────
|
||
|
||
private function housekeepingRaeumtAlteDateienWeg(): void
|
||
{
|
||
// Ein eigenes Verzeichnis unterhalb des Importverzeichnisses: Housekeeping
|
||
// arbeitet auf einem Pfad relativ zum Projekt, und der Aufräumer der
|
||
// Testbasis löscht nur dort (und im Temp-Verzeichnis) etwas.
|
||
$name = 'hk_' . bin2hex(random_bytes(4));
|
||
$verzeichnis = $this->registriereTempPfad(\Finanzmanager\Config::get('import_dir') . '/' . $name);
|
||
mkdir($verzeichnis, 0o775, true);
|
||
|
||
$alt = $this->registriereTempPfad($verzeichnis . '/alt.qif');
|
||
$neu = $this->registriereTempPfad($verzeichnis . '/neu.qif');
|
||
file_put_contents($alt, '!Type:Bank');
|
||
file_put_contents($neu, '!Type:Bank');
|
||
touch($alt, time() - 30 * 86400);
|
||
|
||
// Das überwachte Verzeichnis kommt aus den Einstellungen; für den Test
|
||
// zeigt es auf den frisch angelegten Pfad. Der Rollback dreht die
|
||
// Einstellung wieder zurück.
|
||
Einstellung::set('import_qif_verzeichnis', 'data/import/' . $name, 'import');
|
||
Einstellung::set(Housekeeping::EINSTELLUNG_TAGE, '14', 'import');
|
||
Einstellung::set(Housekeeping::EINSTELLUNG_LAUF, '', 'import');
|
||
|
||
$this->assertEquals(14, Housekeeping::tage(), 'Die Frist kommt aus den Einstellungen');
|
||
$this->assert(Housekeeping::faellig(), 'Ohne vorherigen Lauf ist das Aufräumen fällig');
|
||
|
||
$probe = Housekeeping::ausfuehren(true);
|
||
$this->assertEquals(1, $probe['geloescht'], 'Der Probelauf zählt die alte Datei');
|
||
$this->assert(is_file($alt), 'Der Probelauf löscht nichts');
|
||
|
||
$ergebnis = Housekeeping::ausfuehren();
|
||
$this->assertEquals(1, $ergebnis['geloescht'], 'Der echte Lauf löscht die alte Datei');
|
||
$this->assertGreaterThan(0, $ergebnis['bytes'], 'Der Lauf meldet den freigewordenen Platz');
|
||
$this->assert(!is_file($alt), 'Die alte Datei ist weg');
|
||
$this->assert(is_file($neu), 'Die frische Datei bleibt liegen');
|
||
|
||
$this->assert(!Housekeeping::faellig(), 'Nach dem Lauf steht heute nichts mehr an');
|
||
$this->assertNull(Housekeeping::beiGelegenheit(), 'Der beiläufige Lauf hält sich daran');
|
||
|
||
// Frist 0 schaltet das Aufräumen ab.
|
||
Einstellung::set(Housekeeping::EINSTELLUNG_TAGE, '0', 'import');
|
||
touch($neu, time() - 400 * 86400);
|
||
$aus = Housekeeping::ausfuehren();
|
||
$this->assertEquals(0, $aus['geloescht'], 'Mit Frist 0 wird nichts gelöscht');
|
||
$this->assert(is_file($neu), 'Auch eine uralte Datei bleibt dann liegen');
|
||
$this->assert(!Housekeeping::faellig(), 'Abgeschaltet ist nie etwas fällig');
|
||
}
|
||
|
||
// ── Formate, Dateinamen und Oberfläche ───────────────────────────────────
|
||
|
||
private function dateinamenUndFormate(): void
|
||
{
|
||
$this->assertEquals(['qif', 'csv'], array_keys(Dateiformat::ALLE), 'Zwei Formate, QIF zuerst');
|
||
$this->assertEquals('qif', Dateiformat::pruefe('QIF'), 'Die Prüfung ist unempfindlich für Großschreibung');
|
||
$this->assertNull(Dateiformat::pruefe('xlsx'), 'Ein fremdes Format fällt heraus');
|
||
$this->assertEquals('csv', Dateiformat::ausDateiname('Export 2026.CSV'), 'Das Format steckt in der Endung');
|
||
$this->assertNull(Dateiformat::ausDateiname('böse.php'), 'Eine fremde Endung ergibt kein Format');
|
||
|
||
$this->assertEquals(
|
||
'buchungen-2026-01-01-bis-2026-12-31.csv',
|
||
BuchungsExport::dateiname('csv', ['von' => '2026-01-01', 'bis' => '2026-12-31']),
|
||
'Der Dateiname nennt Format und Zeitraum'
|
||
);
|
||
$this->assert(
|
||
str_ends_with(BuchungsExport::dateiname('qif', []), '.qif'),
|
||
'Ohne Zeitraum bleibt wenigstens die Endung richtig'
|
||
);
|
||
|
||
// Der Upload nimmt jetzt beide Formate an – und nur die.
|
||
$this->assertEquals('Export.CSV', ImportController::sichererDateiname('Export.CSV'), 'CSV wird angenommen');
|
||
$this->assertEquals('Export.QIF', ImportController::sichererDateiname('Export.QIF'), 'QIF bleibt erlaubt');
|
||
$this->assertNull(ImportController::sichererDateiname('böse.php'), 'Fremde Endung wird abgewiesen');
|
||
$this->assertEquals(
|
||
'passwd.csv',
|
||
ImportController::sichererDateiname('../../etc/passwd.csv'),
|
||
'Pfadanteile fallen auch beim CSV weg'
|
||
);
|
||
}
|
||
|
||
private function exportLegtKeineDateiAb(): void
|
||
{
|
||
$vorher = $this->dateienImImportverzeichnis();
|
||
|
||
$datei = BuchungsExport::datei('csv', []);
|
||
|
||
$this->assertNotEmpty($datei['inhalt'], 'Die Datei entsteht im Speicher');
|
||
$this->assertEquals(3, $datei['anzahl'], 'Sie meldet, wie viele Buchungen sie führt');
|
||
$this->assert(str_contains($datei['typ'], 'text/csv'), 'Der Inhaltstyp passt zum Format');
|
||
$this->assertEquals(
|
||
$vorher,
|
||
$this->dateienImImportverzeichnis(),
|
||
'Der Export hinterlässt keine Datei auf der Platte'
|
||
);
|
||
|
||
$meldung = '';
|
||
try {
|
||
BuchungsExport::datei('xlsx', []);
|
||
} catch (\Throwable $e) {
|
||
$meldung = $e->getMessage();
|
||
}
|
||
$this->assertNotEmpty($meldung, 'Ein unbekanntes Format wird abgewiesen');
|
||
}
|
||
|
||
private function verwaltungFuehrtImportUndExport(): void
|
||
{
|
||
$seite = $this->verwaltungsseite();
|
||
|
||
$this->assert(str_contains($seite, '<h2>Import/Export</h2>'), 'Die Kachel heißt Import/Export');
|
||
$this->assert(!str_contains($seite, '<h2>QIF-Import</h2>'), 'Die alte Beschriftung ist weg');
|
||
$this->assert(
|
||
(bool)preg_match('#href="/verwaltung/import" class="knopf klein[^"]*" data-formatwahl="Import"#', $seite),
|
||
'Der Importknopf öffnet die Formatwahl'
|
||
);
|
||
$this->assert(
|
||
(bool)preg_match('#href="/verwaltung/export" class="knopf klein[^"]*" data-formatwahl="Export"#', $seite),
|
||
'Der Exportknopf öffnet die Formatwahl'
|
||
);
|
||
|
||
// Die Karten stehen im Markup, damit das Skript ohne Formatliste auskommt.
|
||
foreach (array_keys(Dateiformat::ALLE) as $format) {
|
||
$this->assert(
|
||
str_contains($seite, 'data-format="' . $format . '"'),
|
||
'Der Auswahldialog führt eine Karte für ' . strtoupper($format)
|
||
);
|
||
}
|
||
|
||
$skript = (string)file_get_contents(dirname(__DIR__) . '/public/static/app.js');
|
||
$this->assert(str_contains($skript, 'data-formatwahl'), 'Das Skript kennt die Formatknöpfe');
|
||
$this->assert(str_contains($skript, 'showModal'), 'Es öffnet den Dialog');
|
||
|
||
// Ohne Skript führt derselbe Knopf auf die Auswahl als eigene Seite.
|
||
$wahl = $this->rendere('verwaltung/formatwahl.php', [
|
||
'richtung' => 'Export',
|
||
'ziel' => '/verwaltung/export',
|
||
'einleitung' => 'Welches Format?',
|
||
]);
|
||
$this->assert(str_contains($wahl, 'href="/verwaltung/export?format=qif"'), 'Die Seite führt zum QIF-Export');
|
||
$this->assert(str_contains($wahl, 'href="/verwaltung/export?format=csv"'), 'Die Seite führt zum CSV-Export');
|
||
}
|
||
|
||
// ── Hilfen ───────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Die Umbuchung und die Splitbuchung des aktuellen Mandanten.
|
||
*
|
||
* @return array{0: array<string,mixed>|null, 1: array<string,mixed>|null}
|
||
*/
|
||
private function umbuchungUndSplit(): array
|
||
{
|
||
$umbuchung = null;
|
||
$split = null;
|
||
|
||
foreach (BuchungsExport::buchungen([]) as $buchung) {
|
||
if (($buchung['gegenkontoname'] ?? '') === 'Exportrücklage') {
|
||
$umbuchung = $buchung;
|
||
}
|
||
if ($buchung['splits'] !== []) {
|
||
$split = $buchung;
|
||
}
|
||
}
|
||
|
||
return [$umbuchung, $split];
|
||
}
|
||
|
||
/** @return list<string> */
|
||
private function dateienImImportverzeichnis(): array
|
||
{
|
||
$pfad = dirname(__DIR__) . '/' . trim(
|
||
(string)Einstellung::get('import_qif_verzeichnis', 'data/import'),
|
||
'/'
|
||
);
|
||
|
||
$dateien = glob($pfad . '/*') ?: [];
|
||
sort($dateien);
|
||
|
||
return $dateien;
|
||
}
|
||
|
||
private function verwaltungsseite(): string
|
||
{
|
||
$seite = $this->rendere('verwaltung/index.php', [
|
||
'aktuellerPfad' => '/verwaltung',
|
||
'zahlen' => [
|
||
'kategorien' => 0, 'einnahmen' => 0, 'ausgaben' => 0, 'klassen' => 0,
|
||
'konten' => 0, 'kontengruppen' => 0, 'reihen' => 0, 'erinnerungen' => 0,
|
||
'vertraege' => 0, 'fristen' => 0,
|
||
],
|
||
]);
|
||
|
||
return substr($seite, (int)strpos($seite, '<main'));
|
||
}
|
||
|
||
/** @param array<string,mixed> $daten */
|
||
private function rendere(string $view, array $daten): string
|
||
{
|
||
$daten += ['titel' => 'Test', 'flash' => [], 'benutzer' => null, 'csrfToken' => 'x'];
|
||
|
||
extract($daten, EXTR_SKIP);
|
||
|
||
ob_start();
|
||
include dirname(__DIR__) . '/src/Views/' . $view;
|
||
return (string)ob_get_clean();
|
||
}
|
||
}
|