Neuer Schalter „Bewusst ohne Kategorie“ in der Buchungsmaske für Abschreibungen und rein rechnerische Solarerträge. Solche Buchungen erscheinen nicht unter den offenen Vorgängen im Cockpit, nicht im Prüflauf des Monatsabschlusses, setzen das Konto in der Kontenübersicht nicht fett und werden von der Regelanwendung sowie dem automatischen Zusammenführen zu Umbuchungen ausgelassen. Kommt eine Kategorie, ein Gegenkonto oder eine Splitbuchung hinzu, entfällt die Markierung. Migration 028_bewusst_ohne_kategorie.sql, Test 59 und E2E-Test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
313 lines
11 KiB
PHP
313 lines
11 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Finanzmanager\Models;
|
||
|
||
use Finanzmanager\Database;
|
||
use Finanzmanager\Mandant;
|
||
|
||
/**
|
||
* Der Monatsabschluss friert einen Monat ein: Er hält Kontosalden und
|
||
* Kategoriesummen als Snapshot fest und sperrt Buchungen in diesem Zeitraum.
|
||
*
|
||
* Die Sperre ist eine fachliche, keine technische: Sie greift in
|
||
* Buchung::anlegen/speichern/loeschen und lässt sich durch Wiedereröffnen des
|
||
* Monats aufheben – der Vorgang bleibt in der Zeile vermerkt.
|
||
*/
|
||
class Monatsabschluss
|
||
{
|
||
public const STATUS_ABGESCHLOSSEN = 'abgeschlossen';
|
||
public const STATUS_WIEDEREROEFFNET = 'wiedereroeffnet';
|
||
|
||
/** @return list<array<string,mixed>> */
|
||
public static function alle(?int $jahr = null): array
|
||
{
|
||
$sql = 'SELECT * FROM monatsabschluesse WHERE benutzer_id = ?';
|
||
$params = [Mandant::id()];
|
||
|
||
if ($jahr !== null) {
|
||
$sql .= ' AND jahr = ?';
|
||
$params[] = $jahr;
|
||
}
|
||
|
||
return Database::fetchAll($sql . ' ORDER BY jahr DESC, monat DESC', $params);
|
||
}
|
||
|
||
public static function find(int $id): ?array
|
||
{
|
||
$zeile = Database::fetchOne(
|
||
'SELECT * FROM monatsabschluesse WHERE id = ? AND benutzer_id = ?',
|
||
[$id, Mandant::id()]
|
||
);
|
||
return $zeile === false ? null : $zeile;
|
||
}
|
||
|
||
public static function fuerMonat(int $jahr, int $monat): ?array
|
||
{
|
||
$zeile = Database::fetchOne(
|
||
'SELECT * FROM monatsabschluesse WHERE benutzer_id = ? AND jahr = ? AND monat = ?',
|
||
[Mandant::id(), $jahr, $monat]
|
||
);
|
||
return $zeile === false ? null : $zeile;
|
||
}
|
||
|
||
/** Liegt das Datum in einem abgeschlossenen Monat? */
|
||
public static function istGesperrt(string $datum): bool
|
||
{
|
||
if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $datum, $treffer)) {
|
||
return false;
|
||
}
|
||
|
||
return (int)Database::fetchValue(
|
||
'SELECT COUNT(*) FROM monatsabschluesse
|
||
WHERE benutzer_id = ? AND jahr = ? AND monat = ? AND status = ?',
|
||
[Mandant::id(), (int)$treffer[1], (int)$treffer[2], self::STATUS_ABGESCHLOSSEN],
|
||
0
|
||
) > 0;
|
||
}
|
||
|
||
/**
|
||
* Wirft, wenn in einem abgeschlossenen Monat gebucht werden soll. Die
|
||
* Prüfung lässt sich in den Einstellungen abschalten – etwa während eines
|
||
* Imports, der ältere Zeiträume nachträgt.
|
||
*/
|
||
public static function verlangeOffen(string $datum): void
|
||
{
|
||
if (Einstellung::get('monatsabschluss_sperrt_buchungen', '1') !== '1') {
|
||
return;
|
||
}
|
||
if (!self::istGesperrt($datum)) {
|
||
return;
|
||
}
|
||
|
||
throw new \RuntimeException(
|
||
'Der Monat ' . substr($datum, 5, 2) . '/' . substr($datum, 0, 4)
|
||
. ' ist abgeschlossen. Zum Buchen muss er wiedereröffnet werden.'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Prüflauf vor dem Abschluss: Was im Monat noch klemmt. Jeder Punkt ist ein
|
||
* Hinweis, kein Hindernis – abschließen lässt sich trotzdem, der
|
||
* FinanzManager hält es genauso.
|
||
*
|
||
* @return array{buchungen: int, ohne_kategorie: list<array<string,mixed>>,
|
||
* nicht_abgeglichen: int, splitdifferenzen: list<array<string,mixed>>}
|
||
*/
|
||
public static function pruefen(int $jahr, int $monat): array
|
||
{
|
||
[$von, $bis] = self::zeitraum($jahr, $monat);
|
||
$p = [Mandant::id(), $von, $bis];
|
||
|
||
$ohneKategorie = Database::fetchAll(
|
||
'SELECT b.id, b.datum, b.betrag, b.empfaenger, k.name AS konto
|
||
FROM buchungen b
|
||
JOIN konten k ON k.id = b.konto_id
|
||
WHERE b.benutzer_id = ? AND b.datum BETWEEN ? AND ?
|
||
AND b.gesplittet = 0 AND b.kategorie_id IS NULL AND b.gegenkonto_id IS NULL
|
||
AND b.bewusst_ohne_kategorie = 0
|
||
ORDER BY b.datum, b.id',
|
||
$p
|
||
);
|
||
|
||
$splitdifferenzen = Database::fetchAll(
|
||
'SELECT b.id, b.datum, b.betrag, b.empfaenger, k.name AS konto,
|
||
COALESCE(SUM(s.betrag), 0) AS summe_splits
|
||
FROM buchungen b
|
||
JOIN konten k ON k.id = b.konto_id
|
||
LEFT JOIN buchung_splits s ON s.buchung_id = b.id
|
||
WHERE b.benutzer_id = ? AND b.datum BETWEEN ? AND ? AND b.gesplittet = 1
|
||
GROUP BY b.id
|
||
HAVING ABS(COALESCE(SUM(s.betrag), 0) - b.betrag) > 0.004
|
||
ORDER BY b.datum, b.id',
|
||
$p
|
||
);
|
||
|
||
return [
|
||
'buchungen' => (int)Database::fetchValue(
|
||
'SELECT COUNT(*) FROM buchungen WHERE benutzer_id = ? AND datum BETWEEN ? AND ?',
|
||
$p,
|
||
0
|
||
),
|
||
'ohne_kategorie' => $ohneKategorie,
|
||
'nicht_abgeglichen' => (int)Database::fetchValue(
|
||
'SELECT COUNT(*) FROM buchungen
|
||
WHERE benutzer_id = ? AND datum BETWEEN ? AND ? AND abgeglichen = 0',
|
||
$p,
|
||
0
|
||
),
|
||
'splitdifferenzen' => $splitdifferenzen,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Schließt den Monat ab. Der Snapshot hält die Kontosalden zum Monatsende
|
||
* und die Kategoriesummen des Monats fest.
|
||
*/
|
||
public static function abschliessen(int $jahr, int $monat, string $notiz = '', string $von = ''): int
|
||
{
|
||
self::pruefeMonat($jahr, $monat);
|
||
|
||
$vorhanden = self::fuerMonat($jahr, $monat);
|
||
if ($vorhanden !== null && $vorhanden['status'] === self::STATUS_ABGESCHLOSSEN) {
|
||
throw new \RuntimeException('Der Monat ist bereits abgeschlossen.');
|
||
}
|
||
|
||
$snapshot = json_encode(self::snapshot($jahr, $monat), JSON_UNESCAPED_UNICODE);
|
||
|
||
Database::execute(
|
||
'INSERT INTO monatsabschluesse
|
||
(benutzer_id, jahr, monat, status, notiz, snapshot, abgeschlossen_am, abgeschlossen_von)
|
||
VALUES (?, ?, ?, ?, ?, ?, NOW(), ?)
|
||
ON DUPLICATE KEY UPDATE
|
||
status = VALUES(status), notiz = VALUES(notiz), snapshot = VALUES(snapshot),
|
||
abgeschlossen_am = NOW(), abgeschlossen_von = VALUES(abgeschlossen_von),
|
||
wiedereroeffnet_am = NULL',
|
||
[Mandant::id(), $jahr, $monat, self::STATUS_ABGESCHLOSSEN, $notiz, $snapshot, $von]
|
||
);
|
||
|
||
$zeile = self::fuerMonat($jahr, $monat);
|
||
return $zeile === null ? 0 : (int)$zeile['id'];
|
||
}
|
||
|
||
/**
|
||
* Hebt die Sperre wieder auf. Der Snapshot bleibt stehen: Er zeigt, mit
|
||
* welchen Zahlen der Monat einmal abgeschlossen wurde.
|
||
*/
|
||
public static function wiedereroeffnen(int $id): void
|
||
{
|
||
Database::execute(
|
||
'UPDATE monatsabschluesse
|
||
SET status = ?, wiedereroeffnet_am = NOW()
|
||
WHERE id = ? AND benutzer_id = ?',
|
||
[self::STATUS_WIEDEREROEFFNET, $id, Mandant::id()]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Vorschlag für den nächsten Abschluss: der älteste Monat mit Buchungen,
|
||
* der noch offen ist. Abgeschlossen wird der Reihe nach, deshalb läuft die
|
||
* Suche vom Vormonat rückwärts und endet am ersten bereits abgeschlossenen
|
||
* Monat. Der laufende Monat kommt nie infrage – er ist noch nicht vorbei.
|
||
*
|
||
* @return array{jahr: int, monat: int}
|
||
*/
|
||
public static function naechsterOffener(?string $heute = null): array
|
||
{
|
||
$heute = $heute ?? date('Y-m-d');
|
||
$jahr = (int)substr($heute, 0, 4);
|
||
$monat = (int)substr($heute, 5, 2);
|
||
|
||
// Vom Vormonat aus rückwärts bis zu zwei Jahre suchen.
|
||
[$jahr, $monat] = self::vormonat($jahr, $monat);
|
||
|
||
$kandidat = ['jahr' => $jahr, 'monat' => $monat];
|
||
for ($i = 0; $i < 24; $i++) {
|
||
$abschluss = self::fuerMonat($jahr, $monat);
|
||
if ($abschluss !== null && $abschluss['status'] === self::STATUS_ABGESCHLOSSEN) {
|
||
return $kandidat;
|
||
}
|
||
|
||
$hatBuchungen = (int)Database::fetchValue(
|
||
'SELECT COUNT(*) FROM buchungen WHERE benutzer_id = ? AND datum BETWEEN ? AND ?',
|
||
array_merge([Mandant::id()], self::zeitraum($jahr, $monat)),
|
||
0
|
||
) > 0;
|
||
|
||
if ($hatBuchungen) {
|
||
$kandidat = ['jahr' => $jahr, 'monat' => $monat];
|
||
}
|
||
|
||
[$jahr, $monat] = self::vormonat($jahr, $monat);
|
||
}
|
||
|
||
return $kandidat;
|
||
}
|
||
|
||
/**
|
||
* Kontosalden zum Monatsende und Kategoriesummen des Monats.
|
||
*
|
||
* @return array<string,mixed>
|
||
*/
|
||
public static function snapshot(int $jahr, int $monat): array
|
||
{
|
||
[$von, $bis] = self::zeitraum($jahr, $monat);
|
||
|
||
$salden = Buchung::saldenJeKonto($bis);
|
||
$konten = [];
|
||
foreach (Konto::alle() as $konto) {
|
||
$id = (int)$konto['id'];
|
||
$konten[] = [
|
||
'id' => $id,
|
||
'name' => $konto['name'],
|
||
'in_summe' => (int)$konto['in_summe'],
|
||
'saldo' => round((float)$konto['anfangssaldo'] + ($salden[$id] ?? 0.0), 2),
|
||
];
|
||
}
|
||
|
||
$kategorien = Database::fetchAll(
|
||
'SELECT kategorie_id, SUM(betrag) AS betrag FROM (
|
||
SELECT b.kategorie_id AS kategorie_id, b.betrag AS betrag
|
||
FROM buchungen b
|
||
WHERE b.benutzer_id = ? AND b.datum BETWEEN ? AND ?
|
||
AND b.gesplittet = 0 AND b.kategorie_id IS NOT NULL
|
||
UNION ALL
|
||
SELECT s.kategorie_id AS kategorie_id, s.betrag AS betrag
|
||
FROM buchung_splits s
|
||
JOIN buchungen b ON b.id = s.buchung_id
|
||
WHERE b.benutzer_id = ? AND b.datum BETWEEN ? AND ?
|
||
AND b.gesplittet = 1 AND s.kategorie_id IS NOT NULL
|
||
) AS bewegungen
|
||
GROUP BY kategorie_id',
|
||
[Mandant::id(), $von, $bis, Mandant::id(), $von, $bis]
|
||
);
|
||
|
||
$summen = [];
|
||
foreach ($kategorien as $zeile) {
|
||
$summen[] = ['kategorie_id' => (int)$zeile['kategorie_id'], 'betrag' => round((float)$zeile['betrag'], 2)];
|
||
}
|
||
|
||
$summeKonten = 0.0;
|
||
foreach ($konten as $konto) {
|
||
if ($konto['in_summe'] === 1) {
|
||
$summeKonten += $konto['saldo'];
|
||
}
|
||
}
|
||
|
||
return [
|
||
'erstellt_am' => date('c'),
|
||
'zeitraum' => ['von' => $von, 'bis' => $bis],
|
||
'konten' => $konten,
|
||
'summe_kontostand' => round($summeKonten, 2),
|
||
'kategorien' => $summen,
|
||
];
|
||
}
|
||
|
||
/** @return array{0: string, 1: string} erster und letzter Tag des Monats */
|
||
public static function zeitraum(int $jahr, int $monat): array
|
||
{
|
||
self::pruefeMonat($jahr, $monat);
|
||
|
||
$von = sprintf('%04d-%02d-01', $jahr, $monat);
|
||
$bis = date('Y-m-t', (int)strtotime($von));
|
||
|
||
return [$von, $bis];
|
||
}
|
||
|
||
/** @return array{0: int, 1: int} */
|
||
private static function vormonat(int $jahr, int $monat): array
|
||
{
|
||
return $monat === 1 ? [$jahr - 1, 12] : [$jahr, $monat - 1];
|
||
}
|
||
|
||
private static function pruefeMonat(int $jahr, int $monat): void
|
||
{
|
||
if ($monat < 1 || $monat > 12) {
|
||
throw new \InvalidArgumentException('Monat muss zwischen 1 und 12 liegen.');
|
||
}
|
||
if ($jahr < 1900 || $jahr > 2200) {
|
||
throw new \InvalidArgumentException('Das Jahr ist ungültig.');
|
||
}
|
||
}
|
||
}
|