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>
189 lines
6.1 KiB
PHP
189 lines
6.1 KiB
PHP
<?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',
|
||
]);
|
||
}
|
||
}
|