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>
283 lines
11 KiB
PHP
283 lines
11 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Finanzmanager\Services;
|
||
|
||
use Finanzmanager\View;
|
||
|
||
/**
|
||
* Der Zeitraum eines Berichts und seine Aufteilung in Spalten.
|
||
*
|
||
* Die Vorgaben („Letzter Monat“, „Aktuelles Jahr“ …) rechnen sich bei jedem
|
||
* Aufruf neu aus dem Tagesdatum aus – ein gespeicherter Bericht mit der Vorgabe
|
||
* „Letzter Monat“ zeigt im August den Juli, nicht für immer den Monat seiner
|
||
* Entstehung. Nur „Benutzerdefiniert“ hält von und bis fest.
|
||
*
|
||
* Alles hier ist reine Kalenderrechnung ohne Datenbankzugriff: gut testbar und
|
||
* mit `$heute` von außen steuerbar, damit Tests nicht am echten Datum hängen.
|
||
*/
|
||
class Zeitraum
|
||
{
|
||
/** Vorgaben der Filterleiste, in Anzeigereihenfolge. */
|
||
public const VORGABEN = [
|
||
'aktueller_monat' => 'Aktueller Monat',
|
||
'letzter_monat' => 'Letzter Monat',
|
||
'aktuelles_quartal' => 'Aktuelles Quartal',
|
||
'letztes_quartal' => 'Letztes Quartal',
|
||
'aktuelles_jahr' => 'Aktuelles Jahr',
|
||
'letztes_jahr' => 'Letztes Jahr',
|
||
'letzte_12_monate' => 'Letzte zwölf Monate',
|
||
'alles' => 'Gesamter Zeitraum',
|
||
'benutzerdefiniert' => 'Benutzerdefiniert',
|
||
];
|
||
|
||
/** Spalten-Aufteilung der Berichte. */
|
||
public const AUFTEILUNGEN = [
|
||
'keine' => 'Keine',
|
||
'monat' => 'Monat',
|
||
'quartal' => 'Quartal',
|
||
'jahr' => 'Jahr',
|
||
];
|
||
|
||
/** Untergrenze für „Gesamter Zeitraum“, falls es noch keine Buchung gibt. */
|
||
private const FRUEHESTENS = '2000-01-01';
|
||
|
||
/**
|
||
* Von und bis zu einer Vorgabe. `$von`/`$bis` gelten nur bei
|
||
* „Benutzerdefiniert“ und werden sonst überschrieben – sie bleiben im
|
||
* Formular stehen, damit ein Wechsel zurück nichts verliert.
|
||
*
|
||
* @param array{von: ?string, bis: ?string} $grenzen Datumsgrenzen der
|
||
* Buchungen, nur für „Gesamter Zeitraum“ gebraucht.
|
||
* @return array{vorgabe: string, von: string, bis: string}
|
||
*/
|
||
public static function berechne(
|
||
string $vorgabe,
|
||
string $von = '',
|
||
string $bis = '',
|
||
?string $heute = null,
|
||
array $grenzen = ['von' => null, 'bis' => null]
|
||
): array {
|
||
$heute = $heute ?? date('Y-m-d');
|
||
$zeit = strtotime($heute) ?: time();
|
||
|
||
if (!array_key_exists($vorgabe, self::VORGABEN)) {
|
||
$vorgabe = 'aktuelles_jahr';
|
||
}
|
||
|
||
[$neuVon, $neuBis] = match ($vorgabe) {
|
||
'aktueller_monat' => self::monat($zeit),
|
||
'letzter_monat' => self::monat(strtotime('first day of last month', $zeit) ?: $zeit),
|
||
'aktuelles_quartal' => self::quartal($zeit),
|
||
'letztes_quartal' => self::quartal(strtotime('-3 month', self::quartalsBeginn($zeit)) ?: $zeit),
|
||
'aktuelles_jahr' => self::jahr((int)date('Y', $zeit)),
|
||
'letztes_jahr' => self::jahr((int)date('Y', $zeit) - 1),
|
||
// Elf Monate zurück plus der laufende: dieselbe Zählweise wie die
|
||
// Cockpit-Kachel, damit beide denselben Ausschnitt meinen.
|
||
'letzte_12_monate' => [
|
||
date('Y-m-01', strtotime('-11 month', strtotime(date('Y-m-01', $zeit))) ?: $zeit),
|
||
date('Y-m-t', $zeit),
|
||
],
|
||
'alles' => [
|
||
$grenzen['von'] ?? self::FRUEHESTENS,
|
||
// Ohne Buchungen endet „alles“ heute – ein Zeitraum, der in der
|
||
// Zukunft endet, brächte nur leere Spalten.
|
||
self::spaeter($grenzen['bis'] ?? $heute, $heute),
|
||
],
|
||
default => [self::datum($von, date('Y-01-01', $zeit)), self::datum($bis, $heute)],
|
||
};
|
||
|
||
// Verdrehte Eingaben tauschen statt abweisen: ein leerer Bericht wäre
|
||
// schwerer zu verstehen als der offensichtlich gemeinte Zeitraum.
|
||
if ($neuVon > $neuBis) {
|
||
[$neuVon, $neuBis] = [$neuBis, $neuVon];
|
||
}
|
||
|
||
return ['vorgabe' => $vorgabe, 'von' => $neuVon, 'bis' => $neuBis];
|
||
}
|
||
|
||
/**
|
||
* Derselbe Zeitraum ein Jahr früher – die Vergleichsspalte des
|
||
* Vergleichsberichts.
|
||
*
|
||
* @return array{von: string, bis: string}
|
||
*/
|
||
public static function vorjahr(string $von, string $bis): array
|
||
{
|
||
return [
|
||
'von' => date('Y-m-d', strtotime($von . ' -1 year') ?: time()),
|
||
// Über den Monatsletzten: Der 29.02. eines Schaltjahrs landete sonst
|
||
// im Vorjahr auf dem 01.03.
|
||
'bis' => self::monatsletzterWennEnde($bis),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Zerlegt den Zeitraum in Spalten. Die erste und die letzte Spalte sind an
|
||
* den Zeitraum geschnitten: Ein Bericht vom 15.01. bis 20.03. hat im
|
||
* Monatsraster eine halbe Januar-, eine volle Februar- und eine
|
||
* angebrochene Märzspalte.
|
||
*
|
||
* @return list<array{schluessel: string, titel: string, kurz: string, von: string, bis: string}>
|
||
*/
|
||
public static function spalten(string $aufteilung, string $von, string $bis): array
|
||
{
|
||
if (!array_key_exists($aufteilung, self::AUFTEILUNGEN) || $aufteilung === 'keine') {
|
||
return [[
|
||
'schluessel' => 'gesamt',
|
||
'titel' => View::datum($von) . ' – ' . View::datum($bis),
|
||
'kurz' => View::datum($von) . '–' . View::datum($bis),
|
||
'von' => $von,
|
||
'bis' => $bis,
|
||
]];
|
||
}
|
||
|
||
$spalten = [];
|
||
$zeiger = strtotime($von) ?: time();
|
||
$ende = strtotime($bis) ?: $zeiger;
|
||
|
||
while ($zeiger <= $ende) {
|
||
$spalte = match ($aufteilung) {
|
||
'monat' => self::monatsSpalte($zeiger),
|
||
'quartal' => self::quartalsSpalte($zeiger),
|
||
default => self::jahresSpalte($zeiger),
|
||
};
|
||
|
||
// An den Rändern auf den Berichtszeitraum stutzen.
|
||
$spalte['von'] = self::spaeter($spalte['von'], $von);
|
||
$spalte['bis'] = $spalte['bis'] > $bis ? $bis : $spalte['bis'];
|
||
|
||
$spalten[] = $spalte;
|
||
$zeiger = strtotime($spalte['bis'] . ' +1 day') ?: $ende + 1;
|
||
}
|
||
|
||
return $spalten;
|
||
}
|
||
|
||
/**
|
||
* In welche Spalte ein Datum fällt – der Schlüssel, unter dem summiert wird.
|
||
* Gerechnet statt gesucht: Bei hundert Spalten und zehntausend Buchungen
|
||
* wäre eine Suche je Buchung spürbar.
|
||
*/
|
||
public static function spaltenschluessel(string $aufteilung, string $datum): string
|
||
{
|
||
return match ($aufteilung) {
|
||
'monat' => substr($datum, 0, 7),
|
||
'quartal' => substr($datum, 0, 4) . '-Q' . (int)ceil((int)substr($datum, 5, 2) / 3),
|
||
'jahr' => substr($datum, 0, 4),
|
||
default => 'gesamt',
|
||
};
|
||
}
|
||
|
||
/** Die Monate, die ein Zeitraum berührt – der Budgetbericht plant monatsweise. */
|
||
public static function monate(string $von, string $bis): array
|
||
{
|
||
$monate = [];
|
||
$zeiger = strtotime(substr($von, 0, 8) . '01') ?: time();
|
||
$ende = strtotime(substr($bis, 0, 8) . '01') ?: $zeiger;
|
||
|
||
while ($zeiger <= $ende) {
|
||
$monate[] = ['jahr' => (int)date('Y', $zeiger), 'monat' => (int)date('n', $zeiger)];
|
||
$zeiger = strtotime('+1 month', $zeiger) ?: $ende + 1;
|
||
}
|
||
|
||
return $monate;
|
||
}
|
||
|
||
/** „01.07.2026 – 31.07.2026“ für den Berichtskopf. */
|
||
public static function beschriftung(string $von, string $bis): string
|
||
{
|
||
return View::datum($von) . ' bis ' . View::datum($bis);
|
||
}
|
||
|
||
// ── Kalenderrechnung ─────────────────────────────────────────────────────
|
||
|
||
/** @return array{0: string, 1: string} */
|
||
private static function monat(int $zeit): array
|
||
{
|
||
return [date('Y-m-01', $zeit), date('Y-m-t', $zeit)];
|
||
}
|
||
|
||
/** @return array{0: string, 1: string} */
|
||
private static function quartal(int $zeit): array
|
||
{
|
||
$beginn = self::quartalsBeginn($zeit);
|
||
return [date('Y-m-01', $beginn), date('Y-m-t', strtotime('+2 month', $beginn) ?: $beginn)];
|
||
}
|
||
|
||
/** @return array{0: string, 1: string} */
|
||
private static function jahr(int $jahr): array
|
||
{
|
||
return [sprintf('%04d-01-01', $jahr), sprintf('%04d-12-31', $jahr)];
|
||
}
|
||
|
||
private static function quartalsBeginn(int $zeit): int
|
||
{
|
||
$monat = (int)(floor(((int)date('n', $zeit) - 1) / 3) * 3) + 1;
|
||
return strtotime(sprintf('%s-%02d-01', date('Y', $zeit), $monat)) ?: $zeit;
|
||
}
|
||
|
||
/** @return array{schluessel: string, titel: string, kurz: string, von: string, bis: string} */
|
||
private static function monatsSpalte(int $zeit): array
|
||
{
|
||
$nr = (int)date('n', $zeit);
|
||
return [
|
||
'schluessel' => date('Y-m', $zeit),
|
||
'titel' => View::monat($nr) . ' ' . date('Y', $zeit),
|
||
'kurz' => View::monat($nr, true) . ' ' . date('y', $zeit),
|
||
'von' => date('Y-m-01', $zeit),
|
||
'bis' => date('Y-m-t', $zeit),
|
||
];
|
||
}
|
||
|
||
/** @return array{schluessel: string, titel: string, kurz: string, von: string, bis: string} */
|
||
private static function quartalsSpalte(int $zeit): array
|
||
{
|
||
$beginn = self::quartalsBeginn($zeit);
|
||
$nummer = (int)ceil((int)date('n', $zeit) / 3);
|
||
|
||
return [
|
||
'schluessel' => date('Y', $zeit) . '-Q' . $nummer,
|
||
'titel' => $nummer . '. Quartal ' . date('Y', $zeit),
|
||
'kurz' => 'Q' . $nummer . ' ' . date('y', $zeit),
|
||
'von' => date('Y-m-01', $beginn),
|
||
'bis' => date('Y-m-t', strtotime('+2 month', $beginn) ?: $beginn),
|
||
];
|
||
}
|
||
|
||
/** @return array{schluessel: string, titel: string, kurz: string, von: string, bis: string} */
|
||
private static function jahresSpalte(int $zeit): array
|
||
{
|
||
return [
|
||
'schluessel' => date('Y', $zeit),
|
||
'titel' => date('Y', $zeit),
|
||
'kurz' => date('Y', $zeit),
|
||
'von' => date('Y-01-01', $zeit),
|
||
'bis' => date('Y-12-31', $zeit),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Ein Jahr zurück, aber ein Monatsende bleibt ein Monatsende – sonst rutschte
|
||
* der 29.02. auf den 01.03. und der Vergleichszeitraum wäre einen Tag zu lang.
|
||
*/
|
||
private static function monatsletzterWennEnde(string $datum): string
|
||
{
|
||
$zeit = strtotime($datum . ' -1 year') ?: time();
|
||
|
||
return $datum === date('Y-m-t', strtotime($datum) ?: $zeit)
|
||
? date('Y-m-t', $zeit)
|
||
: date('Y-m-d', $zeit);
|
||
}
|
||
|
||
private static function spaeter(string $a, string $b): string
|
||
{
|
||
return $a > $b ? $a : $b;
|
||
}
|
||
|
||
private static function datum(string $wert, string $ersatz): string
|
||
{
|
||
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $wert) === 1 ? $wert : $ersatz;
|
||
}
|
||
}
|