From 275acd3285e812b447fcafaa9ba6e2f83991e29f Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Mon, 12 Oct 2026 16:22:22 +0200 Subject: [PATCH] =?UTF-8?q?Fix=20#134:=20Buchungen=20bewusst=20ohne=20Kate?= =?UTF-8?q?gorie=20erm=C3=B6glichen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 6 + Changes.md | 9 ++ README.md | 4 +- docs/buchungen.md | 24 +++ docs/cockpit.md | 3 +- docs/planung.md | 2 +- docs/stammdaten.md | 3 +- migrations/028_bewusst_ohne_kategorie.sql | 12 ++ public/static/buchungsmaske.js | 18 +++ src/Controllers/BuchungenController.php | 1 + src/Models/Bankumsatz.php | 9 +- src/Models/Buchung.php | 45 ++++-- src/Models/Monatsabschluss.php | 1 + src/Services/Cockpit.php | 3 +- src/Views/buchungen/formular.php | 15 +- src/Views/buchungen/kontoblatt.php | 6 +- tests/59_BewusstOhneKategorieTest.php | 179 ++++++++++++++++++++++ tests/e2e/specs/buchungen.spec.js | 44 ++++++ 18 files changed, 361 insertions(+), 23 deletions(-) create mode 100644 migrations/028_bewusst_ohne_kategorie.sql create mode 100644 tests/59_BewusstOhneKategorieTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ffff5c..a2b38cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). ## [Unreleased] +### Added + +- Buchungsmaske: Schalter „Bewusst ohne Kategorie“ für Abschreibungen und rein rechnerische + Erträge; solche Buchungen gelten im Cockpit, im Monatsabschluss und in der + Kontenübersicht nicht als offen (#134) + ### Changed - Regeln: Die Splittabellen zeigen die Spalte „Anteil“ ohne Prozentzeichen und darunter die diff --git a/Changes.md b/Changes.md index 8ecde5d..a5deecc 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,14 @@ # Änderungen +## Buchungen: bewusst ohne Kategorie (12.10.2026) + +- Anlass (#134): Abschreibungen oder rein rechnerische Solarerträge sollen ohne Kategorie gebucht werden können, ohne immer wieder als offen angemahnt zu werden. Mit Kategorie belastete eine Abschreibung die Auswertung zusätzlich zum gesunkenen Kontowert. +- Neuer Schalter „Bewusst ohne Kategorie“ in der Buchungsmaske (neben „Abgeglichen“). Gesetzt sperrt er Kategorie / Gegenkonto und die Splitzeilen; mit Zuordnung lässt er sich nicht setzen. +- 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, werden von „Regeln auf Buchungen ohne Kategorie anwenden“ ausgelassen und nicht automatisch zu Umbuchungen zusammengeführt. Das Kontoblatt zeigt „bewusst ohne Kategorie“. +- Bekommt die Buchung später doch eine Kategorie, ein Gegenkonto oder Splitzeilen (auch über die Schnellzuordnung), entfällt die Markierung. +- Migration `028_bewusst_ohne_kategorie.sql`. +- Tests: `tests/59_BewusstOhneKategorieTest.php`, `tests/e2e/specs/buchungen.spec.js`. + ## Kontoumsätze: Empfänger bei Kartenverfügungen (06.10.2026) - Anlass (#119): Bei Zahlungen mit der comdirect Visa-Debitkarte (Kartenverfügung, GVC 700) blieb der Empfänger leer – die Bank liefert dafür kein Namensfeld (?32/?33), der Händler steht nur im Verwendungszweck zwischen „KARTENVERFÜGUNG“ und „KARTE NR.“. diff --git a/README.md b/README.md index 97edaaf..be868a5 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ Haushalt wird beides parallel genutzt. **Alle sieben Phasen aus `PLAN.md` sind umgesetzt.** * **Stammdaten und Buchungen** – Konten, Kontengruppen, Kategorien, Klassen; Kontoblatt - mit Schnellzuordnung und Splitdialog, Buchungsmaske mit Splitbuchungen und Umbuchungen; - QIF-Import mit Stichtag. + mit Schnellzuordnung und Splitdialog, Buchungsmaske mit Splitbuchungen und Umbuchungen + sowie Buchungen bewusst ohne Kategorie (etwa Abschreibungen); QIF-Import mit Stichtag. * **Import und Export** – QIF und CSV in beide Richtungen: Stammdaten und Buchungen übernehmen, Buchungen nach Zeitraum, Konto, Kategorie und Klasse gefiltert ausgeben. Exportdateien werden nicht vorgehalten; das Housekeeping räumt hochgeladene Dateien weg. diff --git a/docs/buchungen.md b/docs/buchungen.md index ee91556..c8c51a8 100644 --- a/docs/buchungen.md +++ b/docs/buchungen.md @@ -199,6 +199,30 @@ was in den Feldern steht. Gelöscht werden die Zeilen samt Zuordnung, nicht die sie steht danach mit ihrem Betrag und ohne Kategorie im Kontoblatt, und die Gegenseite einer Zeile mit Gegenkonto verschwindet von jenem Konto. +## Bewusst ohne Kategorie + +Manche Buchungen sollen absichtlich keine Kategorie bekommen (#134): Abschreibungen etwa – +als Ausgabe kategorisiert belasteten sie die Auswertung ein zweites Mal neben dem +gesunkenen Wert des Kontos – oder Solarerträge, die nur rechnerisch existieren. Der +Schalter **Bewusst ohne Kategorie** in der Buchungsmaske setzt `buchungen.bewusst_ohne_kategorie` +(Migration `028_bewusst_ohne_kategorie.sql`). + +Solche Buchungen gelten nicht als offene Arbeit: Sie fehlen unter den offenen Vorgängen +im Cockpit, im Prüflauf des Monatsabschlusses, im Fettdruck der Kontenübersicht +(`Buchung::unkategorisierteJeKonto()`), bei *Regeln auf Buchungen ohne Kategorie anwenden* +(`Buchung::ohneKategorie()`) und als Kandidat für das automatische Zusammenführen zu einer +Umbuchung. Das Kontoblatt zeigt in der Zuordnungsspalte „bewusst ohne Kategorie“. Saldo +und Auswertungen bleiben unverändert – im Buchungsbericht stehen sie wie bisher unter +„Nicht kategorisiert“. + +Die Markierung gilt nur, solange die Buchung nichts zugeordnet hat. Das Model +(`Buchung::normalisiere()`) lässt sie fallen, sobald eine Kategorie, ein Gegenkonto oder +Splitzeilen dazukommen – eine Zuordnung geht nie verloren. Dasselbe gilt für die +Schnellzuordnung im Kontoblatt (`Buchung::zuordnen()`); wird dort die Zuordnung nur +geleert, bleibt die Markierung stehen. In der Maske sperrt der gesetzte Schalter +*Kategorie / Gegenkonto* und die Splitzeilen, umgekehrt lässt er sich nur setzen, solange +dort nichts steht. Die Klasse bleibt frei. + ## Salden | Spalte | Bedeutung | diff --git a/docs/cockpit.md b/docs/cockpit.md index a1b9d8b..a3e11fd 100644 --- a/docs/cockpit.md +++ b/docs/cockpit.md @@ -176,7 +176,8 @@ Was auf Bearbeitung wartet, mit Zahl und Weg dorthin. Der Tooltip der Überschri Prüfungen, die gerade nichts melden und daher nicht in der Liste stehen: * **ohne Kategorie** – Buchungen ohne Kategorie, die auch keine Umbuchung und nicht - gesplittet sind. Umbuchungen brauchen keine Kategorie und zählen deshalb nicht mit. + gesplittet sind. Umbuchungen brauchen keine Kategorie und zählen deshalb nicht mit, + ebenso wenig Buchungen, die bewusst ohne Kategorie gebucht sind (siehe `docs/buchungen.md`). * **nicht abgeglichen** – Buchungen aktiver Konten bis heute ohne Abgleich-Kennzeichen. * **Bargeld-Tool-Prüfung fehlgeschlagen** – Buchungen mit `bargeld_status = 'fehler'`, also einem gescheiterten Abruf beim Bargeld-Tool (siehe `docs/buchungen.md`), nicht zu diff --git a/docs/planung.md b/docs/planung.md index 1f961d4..606139d 100644 --- a/docs/planung.md +++ b/docs/planung.md @@ -93,7 +93,7 @@ Der Abschluss friert einen Monat ein (`monatsabschluesse`). **Prüflauf** (`Monatsabschluss::pruefen()`) zeigt, was im Zeitraum noch offen ist: -* Buchungen ohne Kategorie und ohne Gegenkonto +* Buchungen ohne Kategorie und ohne Gegenkonto (außer bewusst ohne Kategorie gebuchten) * nicht abgeglichene Buchungen * Splitbuchungen, deren Zeilen nicht auf den Buchungsbetrag aufgehen diff --git a/docs/stammdaten.md b/docs/stammdaten.md index de51b6e..fdd097c 100644 --- a/docs/stammdaten.md +++ b/docs/stammdaten.md @@ -47,7 +47,8 @@ dazu, ohne dass die Aufrufer sich ändern. **Fett gedruckt** steht ein Konto, in dem Buchungen aus dem laufenden Jahr ohne Kategorie liegen (`Buchung::unkategorisierteJeKonto()`, Zahl im Titel des Kontonamens). Gezählt wird wie im Cockpit: keine Kategorie, kein Gegenkonto, nicht gesplittet – eine Umbuchung und eine -Splitbuchung sind zugeordnet. Das ist vor allem die Gegenprobe zum Online-Banking, das +Splitbuchung sind zugeordnet, eine bewusst ohne Kategorie gebuchte Buchung liegt nicht als +Arbeit an. Das ist vor allem die Gegenprobe zum Online-Banking, das abgerufene Umsätze selbst bucht (siehe `docs/banking.md`). **Anfangssalden** stehen nicht im QIF-Export. Sie werden nach dem Import je Konto im diff --git a/migrations/028_bewusst_ohne_kategorie.sql b/migrations/028_bewusst_ohne_kategorie.sql new file mode 100644 index 0000000..2b9b0b3 --- /dev/null +++ b/migrations/028_bewusst_ohne_kategorie.sql @@ -0,0 +1,12 @@ +-- Bewusst ohne Kategorie buchen (#134). +-- +-- Abschreibungen oder rein rechnerische Solarerträge sollen ohne Kategorie +-- gebucht werden können – mit Kategorie zählten sie in der Auswertung als +-- Ausgabe bzw. Einnahme zusätzlich zur Wertänderung des Kontos. Der Schalter +-- in der Buchungsmaske markiert solche Buchungen; Cockpit, Kontenübersicht, +-- Monatsabschluss und Regelanwendung mahnen sie dann nicht mehr als offen an. +-- +-- Idempotent über IF NOT EXISTS. + +ALTER TABLE buchungen + ADD COLUMN IF NOT EXISTS bewusst_ohne_kategorie TINYINT(1) NOT NULL DEFAULT 0 AFTER gesplittet; diff --git a/public/static/buchungsmaske.js b/public/static/buchungsmaske.js index 99510cb..0a7cb36 100644 --- a/public/static/buchungsmaske.js +++ b/public/static/buchungsmaske.js @@ -15,6 +15,10 @@ * noch stehen mag, geht also nicht in die Buchung ein. Der Knopf * „Splitbuchungen löschen“ leert alle Splitzeilen und gibt oben wieder frei. * + * Der Schalter „Bewusst ohne Kategorie“ (#134) sperrt Kategorie / Gegenkonto + * und die Splitzeilen; umgekehrt lässt er sich nur setzen, solange dort nichts + * steht. Die Klasse bleibt frei – sie ist keine Zuordnung. + * * Kategorie und Gegenkonto stehen in einem Feld, deshalb gibt es hier nichts * mehr ein- und auszublenden; die Art bestimmt nur noch die Richtung. */ @@ -65,6 +69,8 @@ .filter(Boolean); const splitkasten = document.getElementById('splitzeilen'); const leerenknopf = document.getElementById('splitsleeren'); + const ohneKategorie = document.getElementById('bewusst_ohne_kategorie'); + const zuordnungsfeld = document.getElementById('zuordnung'); function splitfelder() { return splitkasten ? Array.from(splitkasten.querySelectorAll('input, select')) : []; @@ -84,6 +90,18 @@ direktfelder.forEach(function (feld) { feld.disabled = splitBelegt; }); splits.forEach(function (feld) { feld.disabled = direktBelegt; }); + if (ohneKategorie) { + const ohne = ohneKategorie.checked; + if (ohne) { + splits.forEach(function (feld) { feld.disabled = true; }); + if (zuordnungsfeld) { + zuordnungsfeld.disabled = true; + } + } + ohneKategorie.disabled = !ohne + && (splitBelegt || (zuordnungsfeld !== null && zuordnungsfeld.value !== '')); + } + if (leerenknopf) { leerenknopf.hidden = !splitBelegt; } diff --git a/src/Controllers/BuchungenController.php b/src/Controllers/BuchungenController.php index 59d0cb7..5e0041c 100644 --- a/src/Controllers/BuchungenController.php +++ b/src/Controllers/BuchungenController.php @@ -650,6 +650,7 @@ class BuchungenController extends BaseController 'klasse_id' => $this->post('klasse_id'), 'gegenkonto_id' => $zuordnung['gegenkonto_id'], 'quelle' => 'manuell', + 'bewusst_ohne_kategorie' => isset($_POST['bewusst_ohne_kategorie']), ]; return [$daten, $this->splitzeilen()]; diff --git a/src/Models/Bankumsatz.php b/src/Models/Bankumsatz.php index e3072e1..85fe931 100644 --- a/src/Models/Bankumsatz.php +++ b/src/Models/Bankumsatz.php @@ -353,6 +353,7 @@ class Bankumsatz WHERE b.benutzer_id = ? AND b.konto_id <> ? AND b.gegenkonto_id IS NULL AND b.gesplittet = 0 AND b.kategorie_id IS NULL + AND b.bewusst_ohne_kategorie = 0 AND ABS(b.betrag + ?) < 0.005 AND ABS(DATEDIFF(b.datum, ?)) <= ? AND u2.id <> ? @@ -401,7 +402,7 @@ class Bankumsatz * Kontoblatt des Gegenkontos steht, und legt beide zusammen. * * Die Gegenseite ist entweder eine einzeln erfasste Buchung (ohne Kategorie und - * Klasse) oder eine Umbuchung in umgekehrter Richtung, an der auf diesem Konto + * Klasse, nicht bewusst ohne Kategorie gebucht) oder eine Umbuchung in umgekehrter Richtung, an der auf diesem Konto * noch kein Umsatz hängt. Betrag mit umgekehrtem Vorzeichen, Buchungstag * höchstens `banking_abgleich_tage` entfernt; widersprechen sich die IBANs der * Umsätze, scheidet der Kandidat aus. Bestätigte IBANs gehen vor, dann gewinnt @@ -443,7 +444,8 @@ class Bankumsatz WHERE b.benutzer_id = ? AND b.konto_id = ? AND b.id <> ? AND b.gesplittet = 0 AND ( - (b.gegenkonto_id IS NULL AND b.kategorie_id IS NULL AND b.klasse_id IS NULL) + (b.gegenkonto_id IS NULL AND b.kategorie_id IS NULL AND b.klasse_id IS NULL + AND b.bewusst_ohne_kategorie = 0) OR b.gegenkonto_id = ? ) AND ABS(b.betrag + ?) < 0.005 @@ -506,7 +508,7 @@ class Bankumsatz * sie als zusammengehörig erkannte. * * Kriterien wie bei gegenbuchung(); beide Buchungen ohne Kategorie und Klasse, - * ungesplittet, mit genau einem Umsatz. Bestätigen die IBANs ein Paar, gewinnt + * nicht bewusst ohne Kategorie gebucht, ungesplittet, mit genau einem Umsatz. Bestätigen die IBANs ein Paar, gewinnt * der zeitlich nächste Partner; ohne Bestätigung nur ein Paar, in dem beide * einander als einziger Kandidat haben. Die ältere Buchung wird zur Umbuchung, * die jüngere fällt weg und ihr Umsatz hängt sich an die ältere. @@ -531,6 +533,7 @@ class Bankumsatz AND a.gesplittet = 0 AND b.gesplittet = 0 AND a.kategorie_id IS NULL AND b.kategorie_id IS NULL AND a.klasse_id IS NULL AND b.klasse_id IS NULL + AND a.bewusst_ohne_kategorie = 0 AND b.bewusst_ohne_kategorie = 0 AND ABS(a.betrag) >= 0.005 AND ABS(a.betrag + b.betrag) < 0.005 AND ABS(DATEDIFF(a.datum, b.datum)) <= ? AND (SELECT COUNT(*) FROM bank_umsaetze x WHERE x.buchung_id = a.id) = 1 diff --git a/src/Models/Buchung.php b/src/Models/Buchung.php index a80d4e7..9f82ef1 100644 --- a/src/Models/Buchung.php +++ b/src/Models/Buchung.php @@ -105,14 +105,16 @@ class Buchung Database::execute( 'INSERT INTO buchungen (benutzer_id, konto_id, datum, valuta, betrag, empfaenger, iban, bankname, buchungstext, - nummer, abgeglichen, kategorie_id, klasse_id, gegenkonto_id, gesplittet, quelle, import_hash) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + nummer, abgeglichen, kategorie_id, klasse_id, gegenkonto_id, gesplittet, + bewusst_ohne_kategorie, quelle, import_hash) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ Mandant::id(), $felder['konto_id'], $felder['datum'], $felder['valuta'], $felder['betrag'], $felder['empfaenger'], $felder['iban'], $felder['bankname'], $felder['buchungstext'], $felder['nummer'], $felder['abgeglichen'], $felder['kategorie_id'], $felder['klasse_id'], - $felder['gegenkonto_id'], $felder['gesplittet'], $felder['quelle'], $felder['import_hash'], + $felder['gegenkonto_id'], $felder['gesplittet'], $felder['bewusst_ohne_kategorie'], + $felder['quelle'], $felder['import_hash'], ] ); $id = Database::lastInsertId(); @@ -163,13 +165,14 @@ class Buchung Database::execute( 'UPDATE buchungen SET konto_id = ?, datum = ?, valuta = ?, betrag = ?, empfaenger = ?, buchungstext = ?, nummer = ?, abgeglichen = ?, kategorie_id = ?, klasse_id = ?, - gegenkonto_id = ?, gesplittet = ? + gegenkonto_id = ?, gesplittet = ?, bewusst_ohne_kategorie = ? WHERE id = ? AND benutzer_id = ?', [ $felder['konto_id'], $felder['datum'], $felder['valuta'], $felder['betrag'], $felder['empfaenger'], $felder['buchungstext'], $felder['nummer'], $felder['abgeglichen'], $felder['kategorie_id'], $felder['klasse_id'], - $felder['gegenkonto_id'], $felder['gesplittet'], $id, Mandant::id(), + $felder['gegenkonto_id'], $felder['gesplittet'], $felder['bewusst_ohne_kategorie'], + $id, Mandant::id(), ] ); @@ -215,6 +218,10 @@ class Buchung * Beides zugleich gibt es nicht: Eine Buchung ist entweder kategorisiert * oder eine Umbuchung. War sie gesplittet, fallen die Zeilen weg – die * Zuordnung steckt danach wieder oben. + * + * Bekommt eine Buchung, die bewusst ohne Kategorie gebucht war, doch eine + * Zuordnung, verliert sie diese Markierung (#134). Wird die Zuordnung nur + * geleert, bleibt sie stehen. */ public static function zuordnen(int $id, ?int $kategorieId, ?int $gegenkontoId, bool $gegenseiteSuchen = true): void { @@ -235,9 +242,10 @@ class Buchung Database::transaktion(static function () use ($id, $kategorieId, $gegenkontoId): void { Database::execute('DELETE FROM buchung_splits WHERE buchung_id = ?', [$id]); Database::execute( - 'UPDATE buchungen SET kategorie_id = ?, gegenkonto_id = ?, gesplittet = 0 + 'UPDATE buchungen SET kategorie_id = ?, gegenkonto_id = ?, gesplittet = 0, + bewusst_ohne_kategorie = IF(? IS NULL AND ? IS NULL, bewusst_ohne_kategorie, 0) WHERE id = ? AND benutzer_id = ?', - [$kategorieId, $gegenkontoId, $id, Mandant::id()] + [$kategorieId, $gegenkontoId, $kategorieId, $gegenkontoId, $id, Mandant::id()] ); }); @@ -429,7 +437,7 @@ class Buchung $sql = 'SELECT * FROM ( SELECT b.id, b.datum, b.valuta, b.betrag, b.empfaenger, b.buchungstext, b.nummer, b.abgeglichen, b.kategorie_id, b.klasse_id, b.gesplittet, b.quelle, - b.bargeld_status, b.bargeld_hinweis, + b.bargeld_status, b.bargeld_hinweis, b.bewusst_ohne_kategorie, b.gegenkonto_id, b.konto_id, 0 AS gegensicht, NULL AS split_id, b.iban, b.bankname FROM buchungen b @@ -437,7 +445,7 @@ class Buchung UNION ALL SELECT b.id, b.datum, b.valuta, -b.betrag, b.empfaenger, b.buchungstext, b.nummer, b.abgeglichen, b.kategorie_id, b.klasse_id, b.gesplittet, b.quelle, - NULL AS bargeld_status, NULL AS bargeld_hinweis, + NULL AS bargeld_status, NULL AS bargeld_hinweis, 0 AS bewusst_ohne_kategorie, b.konto_id AS gegenkonto_id, b.konto_id, 1 AS gegensicht, NULL AS split_id, \'\' AS iban, \'\' AS bankname FROM buchungen b @@ -445,7 +453,7 @@ class Buchung UNION ALL SELECT b.id, b.datum, b.valuta, -s.betrag, b.empfaenger, s.notiz, b.nummer, b.abgeglichen, s.kategorie_id, s.klasse_id, 0 AS gesplittet, b.quelle, - NULL AS bargeld_status, NULL AS bargeld_hinweis, + NULL AS bargeld_status, NULL AS bargeld_hinweis, 0 AS bewusst_ohne_kategorie, b.konto_id AS gegenkonto_id, b.konto_id, 1 AS gegensicht, s.id AS split_id, \'\' AS iban, \'\' AS bankname FROM buchung_splits s @@ -635,7 +643,8 @@ class Buchung * * Unkategorisiert heißt hier dasselbe wie im Cockpit: keine Kategorie, kein * Gegenkonto (eine Umbuchung ist zugeordnet) und nicht gesplittet (dann - * steckt die Zuordnung in den Zeilen). Gezählt wird nur die eigene Sicht: + * steckt die Zuordnung in den Zeilen). Was bewusst ohne Kategorie gebucht + * ist (#134), liegt nicht als Arbeit an. Gezählt wird nur die eigene Sicht: * Die Gegenseite einer Umbuchung hat per Definition ein Gegenkonto. * * @return array konto_id => Anzahl @@ -649,6 +658,7 @@ class Buchung FROM buchungen WHERE benutzer_id = ? AND YEAR(datum) = ? AND gesplittet = 0 AND kategorie_id IS NULL AND gegenkonto_id IS NULL + AND bewusst_ohne_kategorie = 0 GROUP BY konto_id', [Mandant::id(), $jahr] ); @@ -669,7 +679,8 @@ class Buchung * * Kein Gegenkonto und nicht gesplittet ohne jede kategorisierte Zeile: * dieselbe Definition wie bei {@see unkategorisierteJeKonto()}, nur als - * Liste statt als Zählung je Konto. + * Liste statt als Zählung je Konto. Bewusst ohne Kategorie gebuchte + * Buchungen (#134) sind keine Lücke, die eine Regel schließen sollte. * * @return list> */ @@ -678,6 +689,7 @@ class Buchung return Database::fetchAll( 'SELECT b.* FROM buchungen b WHERE b.benutzer_id = ? AND b.gegenkonto_id IS NULL + AND b.bewusst_ohne_kategorie = 0 AND ( (b.gesplittet = 0 AND b.kategorie_id IS NULL) OR (b.gesplittet = 1 AND NOT EXISTS ( @@ -779,6 +791,7 @@ class Buchung $gegenkonto = self::id($daten['gegenkonto_id'] ?? null); $kategorie = self::id($daten['kategorie_id'] ?? null); $gesplittet = $splits !== []; + $bewusstOhne = !empty($daten['bewusst_ohne_kategorie']); if ($gegenkonto !== null) { self::pruefeGegenkonto($gegenkonto, $kontoId); @@ -790,6 +803,13 @@ class Buchung self::pruefeSplits($splits, $betrag, $kontoId); } + // „Bewusst ohne Kategorie“ (#134) gilt nur, solange die Buchung auch + // wirklich nichts zugeordnet hat. Eine Zuordnung geht nie verloren – + // im Zweifel entfällt die Markierung. + if ($kategorie !== null || $gegenkonto !== null || $gesplittet) { + $bewusstOhne = false; + } + $valuta = trim((string)($daten['valuta'] ?? '')); return [ @@ -807,6 +827,7 @@ class Buchung 'klasse_id' => self::id($daten['klasse_id'] ?? null), 'gegenkonto_id' => $gegenkonto, 'gesplittet' => $gesplittet ? 1 : 0, + 'bewusst_ohne_kategorie' => $bewusstOhne ? 1 : 0, 'quelle' => isset(self::QUELLEN[(string)($daten['quelle'] ?? '')]) ? (string)$daten['quelle'] : 'manuell', 'import_hash' => ($daten['import_hash'] ?? null) ?: null, diff --git a/src/Models/Monatsabschluss.php b/src/Models/Monatsabschluss.php index 8ad3a24..8be8f79 100644 --- a/src/Models/Monatsabschluss.php +++ b/src/Models/Monatsabschluss.php @@ -106,6 +106,7 @@ class Monatsabschluss 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 ); diff --git a/src/Services/Cockpit.php b/src/Services/Cockpit.php index 7a7620c..a70fcfb 100644 --- a/src/Services/Cockpit.php +++ b/src/Services/Cockpit.php @@ -245,7 +245,8 @@ class Cockpit $ohneKategorie = (int)Database::fetchValue( 'SELECT COUNT(*) FROM buchungen WHERE benutzer_id = ? AND gesplittet = 0 - AND kategorie_id IS NULL AND gegenkonto_id IS NULL', + AND kategorie_id IS NULL AND gegenkonto_id IS NULL + AND bewusst_ohne_kategorie = 0', [$benutzer], 0 ); diff --git a/src/Views/buchungen/formular.php b/src/Views/buchungen/formular.php index 054d2bf..0d5c6de 100644 --- a/src/Views/buchungen/formular.php +++ b/src/Views/buchungen/formular.php @@ -45,6 +45,7 @@ $klasseWert = (int)($buchung['klasse_id'] ?? 0); $buchungstextWert = (string)($buchung['buchungstext'] ?? ''); $nummerWert = (string)($buchung['nummer'] ?? ''); $abgeglichenWert = !empty($buchung['abgeglichen']); +$ohneKategorieWert = !empty($buchung['bewusst_ohne_kategorie']); if ($eingaben !== null) { $art = ($eingaben['art'] ?? '') === 'einnahme' ? 'einnahme' : 'ausgabe'; @@ -56,6 +57,7 @@ if ($eingaben !== null) { $buchungstextWert = (string)($eingaben['buchungstext'] ?? ''); $nummerWert = (string)($eingaben['nummer'] ?? ''); $abgeglichenWert = isset($eingaben['abgeglichen']); + $ohneKategorieWert = isset($eingaben['bewusst_ohne_kategorie']); } $eigenesKonto = (int)$konto['id']; @@ -192,7 +194,9 @@ include __DIR__ . '/../layout/kopf.php'; -
+ +
+
+ +
+