Finanzmanager/src/Services/BerichtExport.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

189 lines
6.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\Services;
use Finanzmanager\View;
/**
* Ein fertiger Bericht als CSV – dieselben Zeilen, dieselben Zahlen, dieselben
* Vorzeichen wie am Bildschirm. Wer den Export mit der Anzeige vergleicht, soll
* nicht rechnen müssen.
*
* Das Dateiformat selbst – Trenner, Maskierung, Zahlenschreibweise – kommt aus
* CsvTabelle; hier steht allein, welche Zeilen ein Bericht hergibt.
*/
class BerichtExport
{
/** @param array<string,mixed> $bericht Rückgabe von Auswertung::bericht() */
public static function csv(array $bericht): string
{
$zeilen = match ($bericht['darstellung']) {
'buchungen' => self::buchungen($bericht),
'kontostaende' => self::kontostaende($bericht),
'budget' => self::budget($bericht),
default => self::summen($bericht),
};
$kopf = [
[$bericht['name'] ?? $bericht['typname']],
[$bericht['beschriftung'] . ' (in Euro)'],
[],
];
return CsvTabelle::schreibe(array_merge($kopf, $zeilen));
}
/** Dateiname ohne Pfad, aus Berichtsname und Zeitraum. */
public static function dateiname(array $bericht): string
{
$roh = ($bericht['name'] ?? $bericht['typname']) . '-' . ($bericht['zeitraum']['von'] ?? '');
$sauber = preg_replace('/[^A-Za-z0-9_.-]+/', '-', self::umlaute($roh)) ?? 'bericht';
return trim($sauber, '-') . '.csv';
}
/**
* @param array<string,mixed> $bericht
* @return list<list<string>>
*/
private static function summen(array $bericht): array
{
$spalten = array_column($bericht['spalten'], 'titel');
$extra = empty($bericht['vergleich']) ? [] : ['Differenz', 'Veränderung in %'];
$zeilen = [array_merge(['Bezeichnung'], $spalten, $extra, ['Summe'])];
foreach ($bericht['abschnitte'] as $abschnitt) {
$zeilen[] = [$abschnitt['name']];
foreach ($abschnitt['zeilen'] as $zeile) {
$zeilen[] = self::wertzeile(
// Die Einrückung des Baums als führende Leerzeichen: In einer
// Tabellenkalkulation ist die Hierarchie sonst verloren.
str_repeat(' ', (int)$zeile['ebene']) . $zeile['name'],
$zeile,
$bericht
);
}
$zeilen[] = self::wertzeile('Gesamt ' . $abschnitt['name'], $abschnitt, $bericht);
$zeilen[] = [];
}
$zeilen[] = self::wertzeile($bericht['gesamt']['name'], $bericht['gesamt'], $bericht);
return $zeilen;
}
/**
* @param array<string,mixed> $zeile
* @param array<string,mixed> $bericht
* @return list<string>
*/
private static function wertzeile(string $name, array $zeile, array $bericht): array
{
$werte = array_map(self::zahl(...), array_values($zeile['werte']));
if (!empty($bericht['vergleich'])) {
$werte[] = self::zahl((float)($zeile['differenz'] ?? 0));
$werte[] = $zeile['prozent'] === null ? '' : self::zahl((float)$zeile['prozent'], 1);
}
$werte[] = self::zahl((float)$zeile['summe']);
return array_merge([$name], $werte);
}
/**
* @param array<string,mixed> $bericht
* @return list<list<string>>
*/
private static function buchungen(array $bericht): array
{
$zeilen = [['Gruppe', 'Datum', 'Konto', 'Empfänger', 'Buchungstext', 'Kategorie', 'Klasse', 'Betrag']];
foreach ($bericht['gruppen'] as $gruppe) {
foreach ($gruppe['buchungen'] as $buchung) {
$zeilen[] = [
$gruppe['name'],
View::datum($buchung['datum']),
$buchung['konto'],
$buchung['empfaenger'],
$buchung['text'],
$buchung['kategorie'],
$buchung['klasse'],
self::zahl($buchung['betrag']),
];
}
$zeilen[] = ['Gesamt ' . $gruppe['name'], '', '', '', '', '', '', self::zahl($gruppe['summe'])];
}
$zeilen[] = [];
$zeilen[] = ['Gesamt', '', '', '', '', '', '', self::zahl($bericht['summe'])];
return $zeilen;
}
/**
* @param array<string,mixed> $bericht
* @return list<list<string>>
*/
private static function kontostaende(array $bericht): array
{
$zeilen = [array_merge(['Konto', 'Kontengruppe'], array_column($bericht['spalten'], 'titel'))];
foreach ($bericht['zeilen'] as $zeile) {
$zeilen[] = array_merge(
[$zeile['name'], $zeile['gruppe']],
array_map(self::zahl(...), array_values($zeile['werte']))
);
}
$zeilen[] = array_merge(
[$bericht['gesamt']['name'], ''],
array_map(self::zahl(...), array_values($bericht['gesamt']['werte']))
);
return $zeilen;
}
/**
* @param array<string,mixed> $bericht
* @return list<list<string>>
*/
private static function budget(array $bericht): array
{
$zeilen = [['Kategorie', 'Budget', 'Ist', 'Saldo']];
foreach ($bericht['zeilen'] as $zeile) {
$zeilen[] = [
str_repeat(' ', (int)$zeile['ebene']) . $zeile['name'],
self::zahl($zeile['budget']),
self::zahl($zeile['ist']),
self::zahl($zeile['saldo']),
];
}
$gesamt = $bericht['gesamt'];
$zeilen[] = [$gesamt['name'], self::zahl($gesamt['budget']), self::zahl($gesamt['ist']), self::zahl($gesamt['saldo'])];
return $zeilen;
}
private static function zahl(float $wert, int $stellen = 2): string
{
return CsvTabelle::zahl($wert, $stellen);
}
/** Umlaute im Dateinamen ausschreiben, statt sie zu Bindestrichen zu zermahlen. */
private static function umlaute(string $text): string
{
return strtr($text, [
'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue',
'Ä' => 'Ae', 'Ö' => 'Oe', 'Ü' => 'Ue', 'ß' => 'ss',
]);
}
}