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>
218 lines
7.1 KiB
PHP
218 lines
7.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.
|
||
*
|
||
* 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',
|
||
]);
|
||
}
|
||
}
|