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>
190 lines
6.3 KiB
PHP
190 lines
6.3 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Finanzmanager\Models;
|
||
|
||
use Finanzmanager\Database;
|
||
use Finanzmanager\Mandant;
|
||
use Finanzmanager\Services\Auswertung;
|
||
|
||
/**
|
||
* Ein gespeicherter Bericht: Name, Berichtstyp und die Einstellungen der
|
||
* Filterleiste. Mehr ist er nicht – die Zahlen rechnet `Auswertung` bei jedem
|
||
* Aufruf frisch aus den Buchungen, gespeichert wird ausschließlich die Frage.
|
||
*
|
||
* Der Filter liegt als JSON in einer Spalte. Gelesen wird er nie roh, sondern
|
||
* immer durch `Auswertung::normalisiere()`: Was dort nicht vorgesehen ist,
|
||
* fällt weg. So kann ein alter Bericht nach einer Programmänderung höchstens
|
||
* eine Einstellung verlieren, aber nichts Unerwartetes in die Abfrage tragen.
|
||
*/
|
||
class Bericht
|
||
{
|
||
/** @return list<array<string,mixed>> */
|
||
public static function alle(): array
|
||
{
|
||
return Database::fetchAll(
|
||
'SELECT * FROM berichte WHERE benutzer_id = ? ORDER BY sortierung, name',
|
||
[Mandant::id()]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Alle Berichte nach Gruppe, in der Reihenfolge der Übersicht. Berichte ohne
|
||
* Gruppe stehen unter dem Schlüssel 0.
|
||
*
|
||
* @return array<int, array{gruppe: array<string,mixed>|null, berichte: list<array<string,mixed>>}>
|
||
*/
|
||
public static function nachGruppe(): array
|
||
{
|
||
$gruppen = [];
|
||
foreach (Berichtsgruppe::alle() as $gruppe) {
|
||
$gruppen[(int)$gruppe['id']] = ['gruppe' => $gruppe, 'berichte' => []];
|
||
}
|
||
|
||
foreach (self::alle() as $bericht) {
|
||
$id = $bericht['gruppe_id'] === null ? 0 : (int)$bericht['gruppe_id'];
|
||
|
||
// Eine gelöschte Gruppe kann noch in keinem Eintrag stehen; der
|
||
// Bericht landet dann bei „Nicht gruppiert“ statt zu verschwinden.
|
||
if (!isset($gruppen[$id])) {
|
||
$gruppen[$id] = ['gruppe' => null, 'berichte' => []];
|
||
}
|
||
$gruppen[$id]['berichte'][] = $bericht;
|
||
}
|
||
|
||
// Leere Gruppen bleiben stehen – sie sind angelegt worden, um befüllt zu
|
||
// werden, und ohne Kachel gäbe es keinen Ort, an den ein Bericht könnte.
|
||
return $gruppen;
|
||
}
|
||
|
||
public static function find(int $id): ?array
|
||
{
|
||
$zeile = Database::fetchOne(
|
||
'SELECT * FROM berichte WHERE id = ? AND benutzer_id = ?',
|
||
[$id, Mandant::id()]
|
||
);
|
||
return $zeile === false ? null : $zeile;
|
||
}
|
||
|
||
public static function findeNachName(string $name): ?array
|
||
{
|
||
$zeile = Database::fetchOne(
|
||
'SELECT * FROM berichte WHERE name = ? AND benutzer_id = ?',
|
||
[trim($name), Mandant::id()]
|
||
);
|
||
return $zeile === false ? null : $zeile;
|
||
}
|
||
|
||
/**
|
||
* Die zuletzt geöffneten Berichte – das Menü „Letzte Berichte“.
|
||
*
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
public static function letzte(int $anzahl = 5): array
|
||
{
|
||
return Database::fetchAll(
|
||
'SELECT * FROM berichte WHERE benutzer_id = ? AND zuletzt_am IS NOT NULL
|
||
ORDER BY zuletzt_am DESC LIMIT ' . max(1, $anzahl),
|
||
[Mandant::id()]
|
||
);
|
||
}
|
||
|
||
/** @param array<string,mixed> $filter */
|
||
public static function anlegen(string $name, array $filter, ?int $gruppeId = null): int
|
||
{
|
||
$name = self::pruefeName($name);
|
||
$filter = Auswertung::normalisiere($filter);
|
||
|
||
if (self::findeNachName($name) !== null) {
|
||
throw new \RuntimeException('Den Bericht „' . $name . '“ gibt es bereits.');
|
||
}
|
||
|
||
Database::execute(
|
||
'INSERT INTO berichte (benutzer_id, gruppe_id, name, typ, filter) VALUES (?, ?, ?, ?, ?)',
|
||
[Mandant::id(), self::gruppe($gruppeId), $name, $filter['typ'], self::alsJson($filter)]
|
||
);
|
||
|
||
return Database::lastInsertId();
|
||
}
|
||
|
||
/** @param array<string,mixed> $filter */
|
||
public static function speichern(int $id, string $name, array $filter, ?int $gruppeId = null): void
|
||
{
|
||
$name = self::pruefeName($name);
|
||
$filter = Auswertung::normalisiere($filter);
|
||
|
||
$andere = self::findeNachName($name);
|
||
if ($andere !== null && (int)$andere['id'] !== $id) {
|
||
throw new \RuntimeException('Den Bericht „' . $name . '“ gibt es bereits.');
|
||
}
|
||
|
||
Database::execute(
|
||
'UPDATE berichte SET gruppe_id = ?, name = ?, typ = ?, filter = ?
|
||
WHERE id = ? AND benutzer_id = ?',
|
||
[self::gruppe($gruppeId), $name, $filter['typ'], self::alsJson($filter), $id, Mandant::id()]
|
||
);
|
||
}
|
||
|
||
public static function loeschen(int $id): void
|
||
{
|
||
Database::execute('DELETE FROM berichte WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
|
||
}
|
||
|
||
/** Merkt sich das Öffnen für die Liste „Letzte Berichte“. */
|
||
public static function benutzt(int $id): void
|
||
{
|
||
Database::execute(
|
||
'UPDATE berichte SET zuletzt_am = NOW() WHERE id = ? AND benutzer_id = ?',
|
||
[$id, Mandant::id()]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Der geprüfte Filter eines gespeicherten Berichts.
|
||
*
|
||
* @param array<string,mixed> $bericht
|
||
* @return array<string,mixed>
|
||
*/
|
||
public static function filter(array $bericht): array
|
||
{
|
||
$roh = json_decode((string)($bericht['filter'] ?? ''), true);
|
||
|
||
return Auswertung::normalisiere(
|
||
(is_array($roh) ? $roh : []) + ['typ' => (string)($bericht['typ'] ?? '')]
|
||
);
|
||
}
|
||
|
||
public static function anzahl(): int
|
||
{
|
||
return (int)Database::fetchValue(
|
||
'SELECT COUNT(*) FROM berichte WHERE benutzer_id = ?',
|
||
[Mandant::id()],
|
||
0
|
||
);
|
||
}
|
||
|
||
/** Prüft, dass die Gruppe dem Benutzer gehört – sonst gilt „nicht gruppiert“. */
|
||
private static function gruppe(?int $gruppeId): ?int
|
||
{
|
||
if ($gruppeId === null || $gruppeId === 0) {
|
||
return null;
|
||
}
|
||
return Berichtsgruppe::find($gruppeId) === null ? null : $gruppeId;
|
||
}
|
||
|
||
/** @param array<string,mixed> $filter */
|
||
private static function alsJson(array $filter): string
|
||
{
|
||
return (string)json_encode($filter, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
}
|
||
|
||
private static function pruefeName(string $name): string
|
||
{
|
||
$name = trim($name);
|
||
if ($name === '') {
|
||
throw new \InvalidArgumentException('Der Bericht braucht einen Namen.');
|
||
}
|
||
return mb_substr($name, 0, 120);
|
||
}
|
||
}
|