Finanzmanager/src/Services/BerichtExport.php
fds2610-bot 90bba06585 Phase 4: Auswertungen mit Berichtsmodul
Fünf Berichtstypen wie im FinanzManager – Summen-, Buchungs-, Vergleichs-,
Kontostands- und Budgetbericht – mit Filterleiste, gespeicherten Berichten in
Berichtsgruppen, Drilldown in die Buchungen und CSV-Export.

Ein Bericht speichert nur seine Frage; die Zahlen entstehen bei jedem Aufruf neu
aus den Buchungen. Gezählt werden Bewegungen (gesplittete Buchungen über ihre
Splitzeilen), Umbuchungen bleiben außen vor, Unkategorisiertes bekommt eine
eigene Zeile. Im Ausgabenabschnitt steht der Betrag ohne Minus wie im Original,
die Schlusszeile rechnet mit den echten Vorzeichen.

Einzelheiten in docs/auswertungen.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:38:30 +02:00

218 lines
7.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.
*
* Deutsches Tabellenkalkulations-CSV: Semikolon als Trenner, Komma als
* Dezimalzeichen, kein Tausenderpunkt (der macht aus der Zahl einen Text).
* Vorangestellt ein BOM, sonst zeigen Excel und LibreOffice beim Doppelklick
* „Übrige“ statt „Übrige“.
*/
class BerichtExport
{
private const BOM = "\u{FEFF}";
/** @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)'],
[],
];
$text = '';
foreach (array_merge($kopf, $zeilen) as $zeile) {
$text .= self::zeile($zeile);
}
return self::BOM . $text;
}
/** 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;
}
/** @param list<string> $felder */
private static function zeile(array $felder): string
{
$maskiert = array_map(
// Nur maskieren, wo es sein muss: Ein Feld in Anführungszeichen
// liest sich schlechter, wenn es keinen Trenner enthält.
static function (string $feld): string {
if (preg_match('/[;"\r\n]/', $feld) !== 1) {
return $feld;
}
return '"' . str_replace('"', '""', $feld) . '"';
},
array_map('strval', $felder)
);
// CRLF: Das erwarten Tabellenkalkulationen unter Windows, und der Rest
// kommt damit ebenfalls zurecht.
return implode(';', $maskiert) . "\r\n";
}
private static function zahl(float $wert, int $stellen = 2): string
{
return number_format($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',
]);
}
}