'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 */ 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; } }