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>
155 lines
5.1 KiB
PHP
155 lines
5.1 KiB
PHP
<?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 : '';
|
||
}
|
||
}
|