Finanzmanager/src/Controllers/ExportController.php
fds2610-bot 2c86e8522b Import und Export in QIF und CSV, Housekeeping für Importdateien
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>
2026-08-17 23:01:30 +02:00

155 lines
5.1 KiB
PHP
Raw 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);
namespace Finanzmanager\Controllers;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\BuchungsExport;
use Finanzmanager\Services\Dateiformat;
use Finanzmanager\Services\Housekeeping;
use Finanzmanager\View;
/**
* Export der Buchungen als QIF oder CSV.
*
* Wie beim Import steht die Formatwahl vorweg; ohne `format` in der Adresse
* erscheint sie als Seite. Danach folgt der Filter nach Zeitraum, Konto,
* Kategorie und Klasse, und der Knopf liefert die Datei unmittelbar aus.
*
* Die Datei wird nirgends zwischengespeichert – sie entsteht im Speicher und
* geht direkt an den Browser. Ein Exportverzeichnis gäbe es sonst zu pflegen,
* und darin lägen Auszüge der eigenen Finanzen herum.
*/
class ExportController extends BaseController
{
public function index(): void
{
$this->verlangeAnmeldung();
Housekeeping::beiGelegenheit();
$format = Dateiformat::pruefe($this->text('format'));
if ($format === null) {
$this->render('verwaltung/formatwahl.php', [
'titel' => 'Export',
'richtung' => 'Export',
'ziel' => '/verwaltung/export',
'einleitung' => 'In welchem Format soll die Datei entstehen? Danach folgt der Filter '
. 'nach Zeitraum, Konto, Kategorie und Klasse.',
]);
return;
}
$filter = $this->filter();
$this->render('verwaltung/export.php', [
'titel' => 'Export (' . Dateiformat::name($format) . ')',
'format' => $format,
'filter' => $filter,
'konten' => Konto::alle(),
'kategorien' => Kategorie::baumFlach(),
'klassen' => Klasse::alle(),
// Wie viele Buchungen der Filter trifft, steht schon vor dem
// Herunterladen da – sonst lädt man eine leere Datei und merkt es
// erst in der Tabellenkalkulation.
'anzahl' => BuchungsExport::anzahl($filter),
]);
}
/** Erzeugt die Datei und schickt sie als Download. */
public function datei(): void
{
$this->verlangeAnmeldung();
$format = Dateiformat::pruefe($this->text('format'));
if ($format === null) {
View::redirect('/verwaltung/export', 'Kein Format gewählt.', 'fehler');
return;
}
try {
$datei = BuchungsExport::datei($format, $this->filter());
} catch (\Throwable $e) {
View::redirect(
'/verwaltung/export?format=' . $format,
'Export fehlgeschlagen: ' . $e->getMessage(),
'fehler'
);
return;
}
header('Content-Type: ' . $datei['typ']);
header('Content-Disposition: attachment; filename="' . $datei['name'] . '"');
header('Content-Length: ' . strlen($datei['inhalt']));
// Ein Auszug der eigenen Finanzen gehört in keinen Zwischenspeicher.
header('Cache-Control: no-store');
echo $datei['inhalt'];
}
/**
* Filterwerte, gleichgültig ob sie aus der Adresse oder aus dem Formular
* kommen.
*
* Das Formular sendet als POST: Bei vielen angehakten Kategorien wächst
* eine GET-Adresse über das, was der Webserver annimmt – nginx antwortet
* dann mit „414 Request-URI Too Large“ statt mit der Datei. Ein kurzer
* Aufruf mit wenigen Parametern bleibt trotzdem als Adresse möglich,
* deshalb zählen beide Quellen; POST sticht, weil es die frischere Eingabe
* ist.
*
* @return array<string,mixed>
*/
private function eingabe(): array
{
return $_POST + $_GET;
}
/**
* @return array{von: string, bis: string, konten: list<int>,
* kategorien: list<int>, klassen: list<int>}
*/
private function filter(): array
{
$eingabe = $this->eingabe();
return [
'von' => $this->datum($this->text('von')),
'bis' => $this->datum($this->text('bis')),
'konten' => $this->ids($eingabe['konten'] ?? null),
'kategorien' => $this->ids($eingabe['kategorien'] ?? null),
'klassen' => $this->ids($eingabe['klassen'] ?? null),
];
}
/** Ein einzelner Textwert; ein Feld, das als Liste ankommt, gilt als leer. */
private function text(string $feld): string
{
$wert = $this->eingabe()[$feld] ?? '';
return is_string($wert) ? trim($wert) : '';
}
/** @return list<int> */
private function ids(mixed $werte): array
{
if (!is_array($werte)) {
return [];
}
$ids = [];
foreach ($werte as $wert) {
if (is_scalar($wert) && (int)$wert > 0) {
$ids[] = (int)$wert;
}
}
return array_values(array_unique($ids));
}
private function datum(string $wert): string
{
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $wert) === 1 ? $wert : '';
}
}