Fix #136: Buchungen ohne Bargeld-Tool-Abgleich ermöglichen

Neuer Schalter „Ohne Bargeld-Abgleich“ in der Buchungsmaske für
Sammelbuchungen und Differenzausgleiche. Solche Buchungen werden nicht
gegen das Bargeld-Tool geprüft, ein früherer Status wird gelöscht, und sie
erscheinen nicht mehr unter den offenen Vorgängen im Cockpit. Kontoblatt
und Buchungsbericht zeigen einen leeren grauen Kreis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-10-12 21:58:17 +02:00
parent c6d5babb41
commit a5989eb262
17 changed files with 341 additions and 21 deletions

View file

@ -8,6 +8,9 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
### Added ### Added
- Buchungsmaske: Schalter „Ohne Bargeld-Abgleich“ für Sammelbuchungen und
Differenzausgleiche; solche Buchungen werden nicht mit dem Bargeld-Tool abgeglichen und
erscheinen nicht mehr unter den offenen Vorgängen im Cockpit (#136)
- Depotbuchung erfassen: Bei Kauf und Verkauf rechnet sich aus zwei der drei Felder - Depotbuchung erfassen: Bei Kauf und Verkauf rechnet sich aus zwei der drei Felder
Betrag, Stück und Kurs automatisch das dritte (Betrag = Stück × Kurs) (#129) Betrag, Stück und Kurs automatisch das dritte (Betrag = Stück × Kurs) (#129)
- Wertpapiere: Beim Buchen eines Wertpapiergeschäfts (Dialog im Kontoblatt und - Wertpapiere: Beim Buchen eines Wertpapiergeschäfts (Dialog im Kontoblatt und

View file

@ -1,5 +1,13 @@
# Änderungen # Änderungen
## Buchen ohne Bargeld-Abgleich (12.10.2026)
- Anlass (#136): Sammelbuchungen und Differenzausgleiche tragen eine Kategorie, die gegen das Bargeld-Tool geprüft wird, stehen dort aber absichtlich nicht – und wurden deshalb immer wieder als offener Vorgang angemahnt.
- Die Buchungsmaske hat den Schalter „Ohne Bargeld-Abgleich“ (`buchungen.ohne_bargeld_abgleich`, Migration `031_ohne_bargeld_abgleich.sql`). Er erscheint nur, wenn ein Bargeld-Tool eingerichtet ist; sonst trägt die Maske einen gesetzten Wert unsichtbar weiter.
- `BargeldAbgleich::pruefe()` prüft solche Buchungen nicht und löscht einen früheren Status. Sie erscheinen damit weder unter „Bargeld-Tool-Prüfung fehlgeschlagen“ noch unter „im Bargeld-Tool nicht gefunden“. Die Schnellzuordnung im Kontoblatt und die Regelanwendung lassen den Schalter stehen.
- Kontoblatt und Buchungsbericht zeigen in der Spalte „Bar“ einen leeren grauen Kreis mit dem Tooltip „Bewusst ohne Abgleich mit dem Bargeld-Tool“.
- Tests: `tests/65_OhneBargeldAbgleichTest.php`, `tests/e2e/specs/bargeld.spec.js`.
## Offene Vorgänge des Bargeld-Tools im Buchungsbericht (12.10.2026) ## Offene Vorgänge des Bargeld-Tools im Buchungsbericht (12.10.2026)
- Anlass (#131): Der Liste „Im Bargeld-Tool nicht gefunden“, aufgerufen aus *Offene Vorgänge* im Cockpit, fehlte die Spalte Kategorie/Gegenkonto. Gewünscht war statt einer eigenen Liste ein Filter im Buchungsbericht. - Anlass (#131): Der Liste „Im Bargeld-Tool nicht gefunden“, aufgerufen aus *Offene Vorgänge* im Cockpit, fehlte die Spalte Kategorie/Gegenkonto. Gewünscht war statt einer eigenen Liste ein Filter im Buchungsbericht.

View file

@ -50,7 +50,8 @@ Haushalt wird beides parallel genutzt.
erfasst, sieht beim Zuweisen einer Kategorie sofort, ob die Buchung dort schon steht: erfasst, sieht beim Zuweisen einer Kategorie sofort, ob die Buchung dort schon steht:
grüner Haken, rotes X oder Ausrufezeichen in einer schmalen Spalte des Kontoblatts. grüner Haken, rotes X oder Ausrufezeichen in einer schmalen Spalte des Kontoblatts.
Adresse, Zugangsdaten und die abzugleichenden Kategorien stehen unter Adresse, Zugangsdaten und die abzugleichenden Kategorien stehen unter
*Verwaltung → Bargeld-Tool*. *Verwaltung → Bargeld-Tool*. Sammelbuchungen und Differenzausgleiche lassen sich in der
Buchungsmaske bewusst ohne Bargeld-Abgleich buchen.
Einzelheiten je Modul in `docs/`, die Entwicklung in `Changes.md`. Einzelheiten je Modul in `docs/`, die Entwicklung in `Changes.md`.

View file

@ -286,9 +286,9 @@ Einstellungsbereich `bargeld`, der bewusst keinen Reiter im Einstellungsdialog h
dafür in `Einstellung::EIGENE_SEITEN` (Test 02 prüft, dass jeder Bereich erreichbar bleibt). dafür in `Einstellung::EIGENE_SEITEN` (Test 02 prüft, dass jeder Bereich erreichbar bleibt).
**Geprüft wird beim Zuweisen einer Kategorie** – aus der Schnellzuordnung des Kontoblatts **Geprüft wird beim Zuweisen einer Kategorie** – aus der Schnellzuordnung des Kontoblatts
ebenso wie aus der Buchungsmaske. `Services\BargeldAbgleich::pruefe()` verlangt drei Dinge: ebenso wie aus der Buchungsmaske. `Services\BargeldAbgleich::pruefe()` verlangt vier Dinge:
eine Kategorie an der Buchung (keine Umbuchung, keine Splitbuchung), diese Kategorie eine Kategorie an der Buchung (keine Umbuchung, keine Splitbuchung), diese Kategorie
vorgemerkt, eine Adresse konfiguriert. Fällt eines weg, wird der Status gelöscht – auch beim vorgemerkt, die Buchung nicht als „Ohne Bargeld-Abgleich“ markiert, eine Adresse konfiguriert. Fällt eines weg, wird der Status gelöscht – auch beim
Umkategorisieren, sonst bliebe ein alter Haken an einer Buchung stehen, die niemand mehr Umkategorisieren, sonst bliebe ein alter Haken an einer Buchung stehen, die niemand mehr
abgleicht. Gesucht wird über `Services\BargeldTool` mit dem Betrag der Buchung, rückwärts ab abgleicht. Gesucht wird über `Services\BargeldTool` mit dem Betrag der Buchung, rückwärts ab
ihrem Datum: Bargeld wird ausgegeben, bevor der Umsatz auf dem Konto ankommt, nie danach. ihrem Datum: Bargeld wird ausgegeben, bevor der Umsatz auf dem Konto ankommt, nie danach.
@ -300,6 +300,7 @@ Netz. In der schmalen Spalte zwischen Klasse und Betrag steht:
| Symbol | Status | Bedeutung | | Symbol | Status | Bedeutung |
|---|---|---| |---|---|---|
| ● grau | `NULL` | Die Buchung wird nicht abgeglichen | | ● grau | `NULL` | Die Buchung wird nicht abgeglichen |
| ○ grau | `NULL` | Die Buchung ist bewusst ohne Bargeld-Abgleich gebucht (#136) |
| ✓ grün | `gefunden` | Im Bargeld-Tool steht eine Buchung mit demselben Betrag | | ✓ grün | `gefunden` | Im Bargeld-Tool steht eine Buchung mit demselben Betrag |
| ✗ rot | `fehlt` | Dort steht keine | | ✗ rot | `fehlt` | Dort steht keine |
| ! | `fehler` | Der Abruf ist gescheitert | | ! | `fehler` | Der Abruf ist gescheitert |
@ -318,6 +319,14 @@ denselben Bericht mit dem Filter „nicht gefunden“. Der Bericht zeigt Konto,
Gegenkonto und Klasse; die Meldung der Gegenstelle steht im Tooltip der Spalte „Bar“. Eine Gegenkonto und Klasse; die Meldung der Gegenstelle steht im Tooltip der Spalte „Bar“. Eine
eigene Liste dafür gibt es nicht mehr (#131). eigene Liste dafür gibt es nicht mehr (#131).
**Ohne Bargeld-Abgleich (#136).** Sammelbuchungen und Differenzausgleiche tragen eine
vorgemerkte Kategorie, stehen aber absichtlich nicht im Bargeld-Tool. Der Schalter **Ohne
Bargeld-Abgleich** in der Buchungsmaske setzt `buchungen.ohne_bargeld_abgleich`; die Buchung
wird dann nicht geprüft, ein früherer Status (`fehlt`, `fehler`) wird beim Speichern
gelöscht, und sie erscheint nicht mehr unter den offenen Vorgängen im Cockpit. Die
Schnellzuordnung lässt den Schalter stehen. Er erscheint nur, wenn eine Adresse hinterlegt
ist; ohne Bargeld-Tool trägt die Maske einen gesetzten Wert unsichtbar weiter.
**Ein Fehlschlag ist ein Ergebnis, kein Abbruch.** Weder ein abgeschaltetes Bargeld-Tool **Ein Fehlschlag ist ein Ergebnis, kein Abbruch.** Weder ein abgeschaltetes Bargeld-Tool
noch ein Netzfehler darf verhindern, dass die Kategorie gespeichert wird; der Abruf bricht noch ein Netzfehler darf verhindern, dass die Kategorie gespeichert wird; der Abruf bricht
nach acht Sekunden ab. Null Treffer sind ausdrücklich kein Fehler – ein 404 dagegen schon, nach acht Sekunden ab. Null Treffer sind ausdrücklich kein Fehler – ein 404 dagegen schon,

View file

@ -187,6 +187,8 @@ Prüfungen, die gerade nichts melden und daher nicht in der Liste stehen:
* **Im Bargeld-Tool nicht gefunden** – Buchungen mit `bargeld_status = 'fehlt'`. Führt in * **Im Bargeld-Tool nicht gefunden** – Buchungen mit `bargeld_status = 'fehlt'`. Führt in
denselben Buchungsbericht mit dem Filter „Bargeld-Tool: nicht gefunden“ denselben Buchungsbericht mit dem Filter „Bargeld-Tool: nicht gefunden“
(`Cockpit::bargeldBericht()`). (`Cockpit::bargeldBericht()`).
Buchungen mit dem Schalter „Ohne Bargeld-Abgleich“ werden nicht geprüft und erscheinen
deshalb in keinem der beiden Vorgänge (#136).
* **Monatsabschluss steht aus** – der Vorschlag aus `Monatsabschluss::naechsterOffener()`, * **Monatsabschluss steht aus** – der Vorschlag aus `Monatsabschluss::naechsterOffener()`,
aber nur, wenn dieser Monat wirklich noch offen ist und Buchungen trägt. aber nur, wenn dieser Monat wirklich noch offen ist und Buchungen trägt.

View file

@ -0,0 +1,11 @@
-- Bewusst ohne Abgleich mit dem Bargeld-Tool buchen (#136).
--
-- Sammelbuchungen oder Differenzausgleiche tragen eine Kategorie, die gegen
-- das Bargeld-Tool geprüft wird, stehen dort aber absichtlich nicht. Der
-- Schalter in der Buchungsmaske nimmt solche Buchungen aus dem Abgleich; sie
-- erscheinen danach nicht mehr als offener Vorgang im Cockpit.
--
-- Idempotent über IF NOT EXISTS.
ALTER TABLE buchungen
ADD COLUMN IF NOT EXISTS ohne_bargeld_abgleich TINYINT(1) NOT NULL DEFAULT 0 AFTER bewusst_ohne_kategorie;

View file

@ -257,7 +257,8 @@ class BuchungenController extends BaseController
$frisch = Buchung::find((int)$buchung['id']); $frisch = Buchung::find((int)$buchung['id']);
$bargeldHtml = View::bargeldMarke( $bargeldHtml = View::bargeldMarke(
$frisch['bargeld_status'] ?? null, $frisch['bargeld_status'] ?? null,
$frisch['bargeld_hinweis'] ?? null $frisch['bargeld_hinweis'] ?? null,
!empty($frisch['ohne_bargeld_abgleich'])
); );
} }
@ -614,6 +615,9 @@ class BuchungenController extends BaseController
'kategorien' => Kategorie::baumFlach(), 'kategorien' => Kategorie::baumFlach(),
'klassen' => Klasse::alle(), 'klassen' => Klasse::alle(),
'konten' => Konto::alle(), 'konten' => Konto::alle(),
// Der Schalter „Ohne Bargeld-Abgleich“ erscheint nur, wenn es ein
// Bargeld-Tool gibt (#136).
'bargeld' => BargeldAbgleich::eingerichtet(),
]; ];
if ($fehler !== '') { if ($fehler !== '') {
$daten['flash'] = [['typ' => 'fehler', 'meldung' => $fehler]]; $daten['flash'] = [['typ' => 'fehler', 'meldung' => $fehler]];
@ -653,6 +657,7 @@ class BuchungenController extends BaseController
'gegenkonto_id' => $zuordnung['gegenkonto_id'], 'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'quelle' => 'manuell', 'quelle' => 'manuell',
'bewusst_ohne_kategorie' => isset($_POST['bewusst_ohne_kategorie']), 'bewusst_ohne_kategorie' => isset($_POST['bewusst_ohne_kategorie']),
'ohne_bargeld_abgleich' => isset($_POST['ohne_bargeld_abgleich']),
]; ];
return [$daten, $this->splitzeilen()]; return [$daten, $this->splitzeilen()];

View file

@ -106,15 +106,15 @@ class Buchung
'INSERT INTO buchungen 'INSERT INTO buchungen
(benutzer_id, konto_id, datum, valuta, betrag, empfaenger, iban, bankname, buchungstext, (benutzer_id, konto_id, datum, valuta, betrag, empfaenger, iban, bankname, buchungstext,
nummer, abgeglichen, kategorie_id, klasse_id, gegenkonto_id, gesplittet, nummer, abgeglichen, kategorie_id, klasse_id, gegenkonto_id, gesplittet,
bewusst_ohne_kategorie, quelle, import_hash) bewusst_ohne_kategorie, ohne_bargeld_abgleich, quelle, import_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[ [
Mandant::id(), $felder['konto_id'], $felder['datum'], $felder['valuta'], Mandant::id(), $felder['konto_id'], $felder['datum'], $felder['valuta'],
$felder['betrag'], $felder['empfaenger'], $felder['iban'], $felder['bankname'], $felder['betrag'], $felder['empfaenger'], $felder['iban'], $felder['bankname'],
$felder['buchungstext'], $felder['nummer'], $felder['buchungstext'], $felder['nummer'],
$felder['abgeglichen'], $felder['kategorie_id'], $felder['klasse_id'], $felder['abgeglichen'], $felder['kategorie_id'], $felder['klasse_id'],
$felder['gegenkonto_id'], $felder['gesplittet'], $felder['bewusst_ohne_kategorie'], $felder['gegenkonto_id'], $felder['gesplittet'], $felder['bewusst_ohne_kategorie'],
$felder['quelle'], $felder['import_hash'], $felder['ohne_bargeld_abgleich'], $felder['quelle'], $felder['import_hash'],
] ]
); );
$id = Database::lastInsertId(); $id = Database::lastInsertId();
@ -165,14 +165,15 @@ class Buchung
Database::execute( Database::execute(
'UPDATE buchungen SET konto_id = ?, datum = ?, valuta = ?, betrag = ?, empfaenger = ?, 'UPDATE buchungen SET konto_id = ?, datum = ?, valuta = ?, betrag = ?, empfaenger = ?,
buchungstext = ?, nummer = ?, abgeglichen = ?, kategorie_id = ?, klasse_id = ?, buchungstext = ?, nummer = ?, abgeglichen = ?, kategorie_id = ?, klasse_id = ?,
gegenkonto_id = ?, gesplittet = ?, bewusst_ohne_kategorie = ? gegenkonto_id = ?, gesplittet = ?, bewusst_ohne_kategorie = ?,
ohne_bargeld_abgleich = ?
WHERE id = ? AND benutzer_id = ?', WHERE id = ? AND benutzer_id = ?',
[ [
$felder['konto_id'], $felder['datum'], $felder['valuta'], $felder['betrag'], $felder['konto_id'], $felder['datum'], $felder['valuta'], $felder['betrag'],
$felder['empfaenger'], $felder['buchungstext'], $felder['nummer'], $felder['empfaenger'], $felder['buchungstext'], $felder['nummer'],
$felder['abgeglichen'], $felder['kategorie_id'], $felder['klasse_id'], $felder['abgeglichen'], $felder['kategorie_id'], $felder['klasse_id'],
$felder['gegenkonto_id'], $felder['gesplittet'], $felder['bewusst_ohne_kategorie'], $felder['gegenkonto_id'], $felder['gesplittet'], $felder['bewusst_ohne_kategorie'],
$id, Mandant::id(), $felder['ohne_bargeld_abgleich'], $id, Mandant::id(),
] ]
); );
@ -437,7 +438,7 @@ class Buchung
$sql = 'SELECT * FROM ( $sql = 'SELECT * FROM (
SELECT b.id, b.datum, b.valuta, b.betrag, b.empfaenger, b.buchungstext, b.nummer, 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.abgeglichen, b.kategorie_id, b.klasse_id, b.gesplittet, b.quelle,
b.bargeld_status, b.bargeld_hinweis, b.bewusst_ohne_kategorie, b.bargeld_status, b.bargeld_hinweis, b.bewusst_ohne_kategorie, b.ohne_bargeld_abgleich,
b.gegenkonto_id, b.konto_id, 0 AS gegensicht, NULL AS split_id, b.gegenkonto_id, b.konto_id, 0 AS gegensicht, NULL AS split_id,
b.iban, b.bankname b.iban, b.bankname
FROM buchungen b FROM buchungen b
@ -445,7 +446,7 @@ class Buchung
UNION ALL UNION ALL
SELECT b.id, b.datum, b.valuta, -b.betrag, b.empfaenger, b.buchungstext, b.nummer, 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.abgeglichen, b.kategorie_id, b.klasse_id, b.gesplittet, b.quelle,
NULL AS bargeld_status, NULL AS bargeld_hinweis, 0 AS bewusst_ohne_kategorie, NULL AS bargeld_status, NULL AS bargeld_hinweis, 0 AS bewusst_ohne_kategorie, 0 AS ohne_bargeld_abgleich,
b.konto_id AS gegenkonto_id, b.konto_id, 1 AS gegensicht, NULL AS split_id, b.konto_id AS gegenkonto_id, b.konto_id, 1 AS gegensicht, NULL AS split_id,
\'\' AS iban, \'\' AS bankname \'\' AS iban, \'\' AS bankname
FROM buchungen b FROM buchungen b
@ -453,7 +454,7 @@ class Buchung
UNION ALL UNION ALL
SELECT b.id, b.datum, b.valuta, -s.betrag, b.empfaenger, s.notiz, b.nummer, 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, b.abgeglichen, s.kategorie_id, s.klasse_id, 0 AS gesplittet, b.quelle,
NULL AS bargeld_status, NULL AS bargeld_hinweis, 0 AS bewusst_ohne_kategorie, NULL AS bargeld_status, NULL AS bargeld_hinweis, 0 AS bewusst_ohne_kategorie, 0 AS ohne_bargeld_abgleich,
b.konto_id AS gegenkonto_id, b.konto_id, 1 AS gegensicht, s.id AS split_id, b.konto_id AS gegenkonto_id, b.konto_id, 1 AS gegensicht, s.id AS split_id,
\'\' AS iban, \'\' AS bankname \'\' AS iban, \'\' AS bankname
FROM buchung_splits s FROM buchung_splits s
@ -806,6 +807,9 @@ class Buchung
'gegenkonto_id' => $gegenkonto, 'gegenkonto_id' => $gegenkonto,
'gesplittet' => $gesplittet ? 1 : 0, 'gesplittet' => $gesplittet ? 1 : 0,
'bewusst_ohne_kategorie' => $bewusstOhne ? 1 : 0, 'bewusst_ohne_kategorie' => $bewusstOhne ? 1 : 0,
// Ohne Bargeld-Abgleich (#136): Die Buchung wird nicht gegen das
// Bargeld-Tool geprüft und deshalb auch nicht als offen angemahnt.
'ohne_bargeld_abgleich' => !empty($daten['ohne_bargeld_abgleich']) ? 1 : 0,
'quelle' => isset(self::QUELLEN[(string)($daten['quelle'] ?? '')]) 'quelle' => isset(self::QUELLEN[(string)($daten['quelle'] ?? '')])
? (string)$daten['quelle'] : 'manuell', ? (string)$daten['quelle'] : 'manuell',
'import_hash' => ($daten['import_hash'] ?? null) ?: null, 'import_hash' => ($daten['import_hash'] ?? null) ?: null,

View file

@ -841,6 +841,7 @@ class Auswertung
'betrag' => round((float)$zeile['betrag'], 2), 'betrag' => round((float)$zeile['betrag'], 2),
'bargeld_status' => $zeile['bargeld_status'] ?? null, 'bargeld_status' => $zeile['bargeld_status'] ?? null,
'bargeld_hinweis' => $zeile['bargeld_hinweis'] ?? null, 'bargeld_hinweis' => $zeile['bargeld_hinweis'] ?? null,
'ohne_bargeld_abgleich' => !empty($zeile['ohne_bargeld_abgleich']),
]; ];
} }
@ -1193,13 +1194,15 @@ class Auswertung
$sql = 'SELECT * FROM ( $sql = 'SELECT * FROM (
SELECT b.id AS buchung_id, NULL AS split_id, b.datum, b.betrag, b.konto_id, SELECT b.id AS buchung_id, NULL AS split_id, b.datum, b.betrag, b.konto_id,
b.kategorie_id, b.klasse_id, b.gegenkonto_id, b.empfaenger, b.kategorie_id, b.klasse_id, b.gegenkonto_id, b.empfaenger,
b.buchungstext AS text, b.bargeld_status, b.bargeld_hinweis b.buchungstext AS text, b.bargeld_status, b.bargeld_hinweis,
b.ohne_bargeld_abgleich
FROM buchungen b FROM buchungen b
WHERE b.benutzer_id = ? AND b.gesplittet = 0 AND b.datum BETWEEN ? AND ? WHERE b.benutzer_id = ? AND b.gesplittet = 0 AND b.datum BETWEEN ? AND ?
UNION ALL UNION ALL
SELECT b.id AS buchung_id, s.id AS split_id, b.datum, s.betrag, b.konto_id, SELECT b.id AS buchung_id, s.id AS split_id, b.datum, s.betrag, b.konto_id,
s.kategorie_id, s.klasse_id, s.gegenkonto_id, b.empfaenger, s.kategorie_id, s.klasse_id, s.gegenkonto_id, b.empfaenger,
s.notiz AS text, b.bargeld_status, b.bargeld_hinweis s.notiz AS text, b.bargeld_status, b.bargeld_hinweis,
b.ohne_bargeld_abgleich
FROM buchung_splits s FROM buchung_splits s
JOIN buchungen b ON b.id = s.buchung_id JOIN buchungen b ON b.id = s.buchung_id
WHERE b.benutzer_id = ? AND b.gesplittet = 1 AND b.datum BETWEEN ? AND ? WHERE b.benutzer_id = ? AND b.gesplittet = 1 AND b.datum BETWEEN ? AND ?

View file

@ -200,6 +200,7 @@ class Bankabgleich
'kategorie_id' => $zuordnung['kategorie_id'], 'kategorie_id' => $zuordnung['kategorie_id'],
'klasse_id' => $zuordnung['klasse_id'], 'klasse_id' => $zuordnung['klasse_id'],
'gegenkonto_id' => $zuordnung['gegenkonto_id'], 'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'ohne_bargeld_abgleich' => $buchung['ohne_bargeld_abgleich'] ?? 0,
], ],
$zuordnung['splits'] $zuordnung['splits']
); );

View file

@ -23,9 +23,12 @@ use Finanzmanager\Models\Kategorie;
* *
* 1. Die Buchung trägt eine Kategorie (keine Umbuchung, keine Splitbuchung). * 1. Die Buchung trägt eine Kategorie (keine Umbuchung, keine Splitbuchung).
* 2. Diese Kategorie ist in `Verwaltung → Bargeld-Tool` vorgemerkt. * 2. Diese Kategorie ist in `Verwaltung → Bargeld-Tool` vorgemerkt.
* 3. Es ist eine Adresse konfiguriert. * 3. Die Buchung ist nicht als „Ohne Bargeld-Abgleich“ markiert (#136) –
* Sammelbuchungen und Differenzausgleiche stehen absichtlich nicht im
* Bargeld-Tool und sollen nicht dauerhaft als offen angemahnt werden.
* 4. Es ist eine Adresse konfiguriert.
* *
* Fällt eine der drei Bedingungen weg, wird der Status gelöscht: Die Buchung * Fällt eine der Bedingungen weg, wird der Status gelöscht: Die Buchung
* hat mit dem Bargeld-Tool nichts zu tun und zeigt im Kontoblatt den grauen * hat mit dem Bargeld-Tool nichts zu tun und zeigt im Kontoblatt den grauen
* Kreis. Das gilt ausdrücklich auch für eine Umkategorisierung – sonst bliebe * Kreis. Das gilt ausdrücklich auch für eine Umkategorisierung – sonst bliebe
* ein alter Haken an einer Buchung stehen, die niemand mehr abgleicht. * ein alter Haken an einer Buchung stehen, die niemand mehr abgleicht.
@ -141,7 +144,11 @@ class BargeldAbgleich
/** @param array<string,mixed> $buchung */ /** @param array<string,mixed> $buchung */
private static function istZuPruefen(array $buchung): bool private static function istZuPruefen(array $buchung): bool
{ {
if ((int)($buchung['gesplittet'] ?? 0) === 1 || !empty($buchung['gegenkonto_id'])) { if (
(int)($buchung['gesplittet'] ?? 0) === 1
|| !empty($buchung['gegenkonto_id'])
|| !empty($buchung['ohne_bargeld_abgleich'])
) {
return false; return false;
} }

View file

@ -135,6 +135,8 @@ class View
* Das Symbol der Abgleichspalte im Kontoblatt. Vier Zustände, in dieser * Das Symbol der Abgleichspalte im Kontoblatt. Vier Zustände, in dieser
* Reihenfolge zu lesen: gefunden, nicht gefunden, Abruf gescheitert – und * Reihenfolge zu lesen: gefunden, nicht gefunden, Abruf gescheitert – und
* der graue Kreis für alles, was mit dem Bargeld-Tool nichts zu tun hat. * der graue Kreis für alles, was mit dem Bargeld-Tool nichts zu tun hat.
* Eine Buchung, die bewusst ohne Bargeld-Abgleich gebucht ist (#136),
* zeigt einen leeren grauen Kreis: nicht geprüft, aber absichtlich.
* *
* Die Zeichen stehen als Text und nicht als Grafik da: Sie erben damit * Die Zeichen stehen als Text und nicht als Grafik da: Sie erben damit
* Schriftgröße und – beim Ausrufezeichen ausdrücklich gewünscht – die * Schriftgröße und – beim Ausrufezeichen ausdrücklich gewünscht – die
@ -144,15 +146,18 @@ class View
* Schnellzuordnung (kontoblatt.js) dasselbe Symbol braucht, ohne die Seite * Schnellzuordnung (kontoblatt.js) dasselbe Symbol braucht, ohne die Seite
* neu zu laden. * neu zu laden.
*/ */
public static function bargeldMarke(?string $status, ?string $hinweis): string public static function bargeldMarke(?string $status, ?string $hinweis, bool $ohneAbgleich = false): string
{ {
$hinweis = trim((string)$hinweis); $hinweis = trim((string)$hinweis);
[$zeichen, $klasse, $standardtitel] = match ($status) { [$zeichen, $klasse, $standardtitel] = match (true) {
$ohneAbgleich && $status === null => ['○', 'neutral', 'Bewusst ohne Abgleich mit dem Bargeld-Tool'],
default => match ($status) {
'gefunden' => ['✓', 'gefunden', 'Im Bargeld-Tool gefunden'], 'gefunden' => ['✓', 'gefunden', 'Im Bargeld-Tool gefunden'],
'fehlt' => ['✗', 'fehlt', 'Im Bargeld-Tool nicht gefunden'], 'fehlt' => ['✗', 'fehlt', 'Im Bargeld-Tool nicht gefunden'],
'fehler' => ['!', 'fehler', 'Der Abruf beim Bargeld-Tool ist gescheitert'], 'fehler' => ['!', 'fehler', 'Der Abruf beim Bargeld-Tool ist gescheitert'],
default => ['●', 'neutral', 'Wird nicht mit dem Bargeld-Tool abgeglichen'], default => ['●', 'neutral', 'Wird nicht mit dem Bargeld-Tool abgeglichen'],
},
}; };
return '<span class="bargeldmarke bargeld-' . $klasse . '" title="' return '<span class="bargeldmarke bargeld-' . $klasse . '" title="'

View file

@ -53,7 +53,7 @@ use Finanzmanager\View;
</td> </td>
<td><?= View::h($buchung['klasse']) ?></td> <td><?= View::h($buchung['klasse']) ?></td>
<?php if ($bericht['bargeld']): ?> <?php if ($bericht['bargeld']): ?>
<td class="bargeldspalte"><?= View::bargeldMarke($buchung['bargeld_status'], $buchung['bargeld_hinweis']) ?></td> <td class="bargeldspalte"><?= View::bargeldMarke($buchung['bargeld_status'], $buchung['bargeld_hinweis'], !empty($buchung['ohne_bargeld_abgleich'])) ?></td>
<?php endif; ?> <?php endif; ?>
<td class="rechts <?= View::betragKlasse($buchung['betrag']) ?>"> <td class="rechts <?= View::betragKlasse($buchung['betrag']) ?>">
<?= View::betrag($buchung['betrag'], false) ?> <?= View::betrag($buchung['betrag'], false) ?>

View file

@ -11,12 +11,14 @@ use Finanzmanager\View;
/** @var list<array<string,mixed>> $kategorien */ /** @var list<array<string,mixed>> $kategorien */
/** @var list<array<string,mixed>> $klassen */ /** @var list<array<string,mixed>> $klassen */
/** @var list<array<string,mixed>> $konten */ /** @var list<array<string,mixed>> $konten */
/** @var bool $bargeld Ist ein Bargeld-Tool eingerichtet? */
/** @var string $csrfToken */ /** @var string $csrfToken */
$neu = $buchung === null; $neu = $buchung === null;
$ziel = $neu ? '/konten/' . (int)$konto['id'] . '/buchung' : '/buchungen/' . (int)$buchung['id']; $ziel = $neu ? '/konten/' . (int)$konto['id'] . '/buchung' : '/buchungen/' . (int)$buchung['id'];
$betrag = $neu ? 0.0 : (float)$buchung['betrag']; $betrag = $neu ? 0.0 : (float)$buchung['betrag'];
$eingaben = $eingaben ?? null; $eingaben = $eingaben ?? null;
$bargeld = $bargeld ?? false;
// Die Art bestimmt nur noch die Richtung. Ob die Buchung eine Umbuchung ist, // Die Art bestimmt nur noch die Richtung. Ob die Buchung eine Umbuchung ist,
// steht im Feld Kategorie / Gegenkonto. // steht im Feld Kategorie / Gegenkonto.
@ -39,6 +41,7 @@ $buchungstextWert = (string)($buchung['buchungstext'] ?? '');
$nummerWert = (string)($buchung['nummer'] ?? ''); $nummerWert = (string)($buchung['nummer'] ?? '');
$abgeglichenWert = !empty($buchung['abgeglichen']); $abgeglichenWert = !empty($buchung['abgeglichen']);
$ohneKategorieWert = !empty($buchung['bewusst_ohne_kategorie']); $ohneKategorieWert = !empty($buchung['bewusst_ohne_kategorie']);
$ohneBargeldWert = !empty($buchung['ohne_bargeld_abgleich']);
if ($eingaben !== null) { if ($eingaben !== null) {
$art = ($eingaben['art'] ?? '') === 'einnahme' ? 'einnahme' : 'ausgabe'; $art = ($eingaben['art'] ?? '') === 'einnahme' ? 'einnahme' : 'ausgabe';
@ -51,6 +54,7 @@ if ($eingaben !== null) {
$nummerWert = (string)($eingaben['nummer'] ?? ''); $nummerWert = (string)($eingaben['nummer'] ?? '');
$abgeglichenWert = isset($eingaben['abgeglichen']); $abgeglichenWert = isset($eingaben['abgeglichen']);
$ohneKategorieWert = isset($eingaben['bewusst_ohne_kategorie']); $ohneKategorieWert = isset($eingaben['bewusst_ohne_kategorie']);
$ohneBargeldWert = isset($eingaben['ohne_bargeld_abgleich']);
} }
$eigenesKonto = (int)$konto['id']; $eigenesKonto = (int)$konto['id'];
@ -206,6 +210,22 @@ include __DIR__ . '/../layout/kopf.php';
</label> </label>
</div> </div>
<?php /* Ohne Bargeld-Tool hat der Schalter keine Bedeutung und bleibt
unsichtbar; ein gesetzter Wert geht beim Speichern trotzdem nicht
verloren (#136). */ ?>
<?php if ($bargeld): ?>
<div class="feld">
<label class="ankreuzfeld"
title="Für Buchungen, die absichtlich nicht im Bargeld-Tool stehen – etwa Sammelbuchungen oder Differenzausgleiche. Sie werden nicht mit dem Bargeld-Tool abgeglichen und erscheinen nicht unter den offenen Vorgängen im Cockpit.">
<input type="checkbox" id="ohne_bargeld_abgleich" name="ohne_bargeld_abgleich" value="1"
<?= $ohneBargeldWert ? 'checked' : '' ?>>
Ohne Bargeld-Abgleich
</label>
</div>
<?php elseif ($ohneBargeldWert): ?>
<input type="hidden" name="ohne_bargeld_abgleich" value="1">
<?php endif; ?>
<div class="feld ganz"> <div class="feld ganz">
<label class="ankreuzfeld" <label class="ankreuzfeld"
title="Erzeugt beim Speichern zusätzlich eine Regel für den automatischen Abgleich aus dieser Buchung. Der nächste Bildschirm zeigt die neue Regel zum Prüfen an."> title="Erzeugt beim Speichern zusätzlich eine Regel für den automatischen Abgleich aus dieser Buchung. Der nächste Bildschirm zeigt die neue Regel zum Prüfen an.">

View file

@ -134,7 +134,11 @@ $splitklassen = static function (array $splitzeilen) use ($splitname, $klassenNa
* @param array<string,mixed> $zeile * @param array<string,mixed> $zeile
*/ */
$bargeldmarke = static fn(array $zeile): string $bargeldmarke = static fn(array $zeile): string
=> View::bargeldMarke($zeile['bargeld_status'] ?? null, $zeile['bargeld_hinweis'] ?? null); => View::bargeldMarke(
$zeile['bargeld_status'] ?? null,
$zeile['bargeld_hinweis'] ?? null,
!empty($zeile['ohne_bargeld_abgleich'])
);
/** /**
* IBAN und Bank der Gegenseite, soweit der Bankabruf sie geliefert hat – der * IBAN und Bank der Gegenseite, soweit der Bankabruf sie geliefert hat – der

View file

@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
use Finanzmanager\Database;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\Auswertung;
use Finanzmanager\Services\BargeldAbgleich;
use Finanzmanager\Services\Cockpit;
use Finanzmanager\View;
/**
* Bewusst ohne Abgleich mit dem Bargeld-Tool buchen (#136).
*
* Sammelbuchungen und Differenzausgleiche tragen eine Kategorie, die gegen
* das Bargeld-Tool geprüft wird, stehen dort aber absichtlich nicht. Geprüft
* wird, dass der Schalter gespeichert wird, den Abgleich unterbindet und die
* Buchung damit aus den offenen Vorgängen im Cockpit nimmt – auch dann, wenn
* sie vorher schon als „nicht gefunden“ angemahnt war.
*/
class OhneBargeldAbgleichTest extends TestCase
{
protected string $testName = 'Test 65: Ohne Bargeld-Abgleich';
private int $konto = 0;
private int $kategorie = 0;
public function run(): void
{
$this->mandantAnlegen();
$this->konto = Konto::anlegen([
'name' => 'Haushaltsgiro', 'kontoart' => 'bank', 'anfangssaldo' => 500, 'aktiv' => 1,
]);
$this->kategorie = Kategorie::anlegen(['name' => 'Haushalt bar', 'art' => 'ausgabe']);
Kategorie::bargeldAuswahlSetzen([$this->kategorie]);
// Eine Adresse, unter der niemand antwortet: Jeder echte Abruf endet
// als Fehler – ein Abruf, der gar nicht erst stattfindet, nicht.
Einstellung::set('bargeld_url', 'http://127.0.0.1:9', 'bargeld');
Einstellung::cacheLeeren();
$this->schalterWirdGespeichert();
$this->keinAbgleichUndKeinOffenerVorgang();
$this->nachtraeglichSetzenRaeumtDenStatusWeg();
$this->schnellzuordnungBehaeltDenSchalter();
$this->anzeigeImKontoblattUndBericht();
$this->maskeZeigtDenSchalter();
}
/** @param array<string,mixed> $zusatz */
private function buchung(string $datum, array $zusatz = []): int
{
return Buchung::anlegen($zusatz + [
'konto_id' => $this->konto,
'datum' => $datum,
'betrag' => -45.10,
'empfaenger' => 'Sammelbuchung Bargeld',
'kategorie_id' => $this->kategorie,
]);
}
/** @return array<string,int> */
private function vorgaenge(): array
{
return array_column(Cockpit::offeneVorgaenge('2026-09-30'), 'anzahl', 'schluessel');
}
private function schalterWirdGespeichert(): void
{
$id = $this->buchung('2026-09-01', ['ohne_bargeld_abgleich' => true]);
$this->assertEquals(1, (int)Buchung::find($id)['ohne_bargeld_abgleich'], 'Der Schalter wird gespeichert');
$normal = $this->buchung('2026-09-02');
$this->assertEquals(0, (int)Buchung::find($normal)['ohne_bargeld_abgleich'], 'Ohne Schalter bleibt er aus');
}
private function keinAbgleichUndKeinOffenerVorgang(): void
{
$vorher = $this->vorgaenge()['bargeld_fehler'] ?? 0;
$ohne = $this->buchung('2026-09-05', ['ohne_bargeld_abgleich' => true]);
$this->assertNull(BargeldAbgleich::pruefe($ohne), 'Eine Buchung ohne Bargeld-Abgleich wird nicht geprüft');
$this->assertNull(Buchung::find($ohne)['bargeld_status'], 'Sie trägt keinen Abgleichstatus');
$this->assertEquals($vorher, $this->vorgaenge()['bargeld_fehler'] ?? 0, 'Sie erscheint nicht unter den offenen Vorgängen');
// Gegenprobe: Dieselbe Buchung ohne Schalter wird geprüft und angemahnt.
$mit = $this->buchung('2026-09-06');
$this->assertEquals(BargeldAbgleich::FEHLER, BargeldAbgleich::pruefe($mit), 'Ohne Schalter wird abgeglichen');
$this->assertEquals($vorher + 1, $this->vorgaenge()['bargeld_fehler'] ?? 0, 'Und als offener Vorgang angemahnt');
}
private function nachtraeglichSetzenRaeumtDenStatusWeg(): void
{
$id = $this->buchung('2026-09-10');
Database::execute("UPDATE buchungen SET bargeld_status = 'fehlt', bargeld_hinweis = 'Kein Treffer.' WHERE id = ?", [$id]);
$vorher = $this->vorgaenge()['bargeld_fehlt'] ?? 0;
$this->assertGreaterThan(0, $vorher, 'Die nicht gefundene Buchung wird angemahnt');
$buchung = Buchung::find($id);
Buchung::speichern($id, ['ohne_bargeld_abgleich' => 1] + $buchung);
BargeldAbgleich::pruefe($id);
$frisch = Buchung::find($id);
$this->assertEquals(1, (int)$frisch['ohne_bargeld_abgleich'], 'Der Schalter lässt sich nachträglich setzen');
$this->assertNull($frisch['bargeld_status'], 'Der alte Abgleichstatus verschwindet');
$this->assertEquals($vorher - 1, $this->vorgaenge()['bargeld_fehlt'] ?? 0, 'Die Buchung wird nicht mehr angemahnt');
Buchung::speichern($id, ['ohne_bargeld_abgleich' => 0] + $frisch);
$this->assertEquals(0, (int)Buchung::find($id)['ohne_bargeld_abgleich'], 'Abgeschaltet gilt der Abgleich wieder');
}
private function schnellzuordnungBehaeltDenSchalter(): void
{
$id = $this->buchung('2026-09-12', ['ohne_bargeld_abgleich' => true, 'kategorie_id' => null]);
Buchung::zuordnen($id, $this->kategorie, null);
$this->assertNull(BargeldAbgleich::pruefe($id), 'Auch nach der Schnellzuordnung wird nicht abgeglichen');
$this->assertEquals(
1,
(int)Buchung::find($id)['ohne_bargeld_abgleich'],
'Die Schnellzuordnung lässt den Schalter stehen'
);
}
private function anzeigeImKontoblattUndBericht(): void
{
$marke = View::bargeldMarke(null, null, true);
$this->assert(str_contains($marke, 'Bewusst ohne Abgleich mit dem Bargeld-Tool'), 'Die Marke nennt den Grund');
$this->assert(str_contains($marke, '○'), 'Die Marke ist ein leerer Kreis');
$this->assert(str_contains(View::bargeldMarke(null, null), '●'), 'Ohne Schalter bleibt der volle Kreis');
$zeilen = Buchung::kontoblatt($this->konto, ['von' => '2026-09-01', 'bis' => '2026-09-01']);
$this->assertEquals(1, (int)($zeilen[0]['ohne_bargeld_abgleich'] ?? 0), 'Das Kontoblatt liefert den Schalter mit');
$bericht = Auswertung::bericht(Auswertung::normalisiere([
'typ' => 'buchungsbericht', 'zeitraum' => 'alles',
]), '2026-09-30');
$buchungen = array_merge(...array_column($bericht['gruppen'], 'buchungen'));
$treffer = array_values(array_filter(
$buchungen,
static fn(array $b): bool => $b['datum'] === '2026-09-01' && $b['ohne_bargeld_abgleich']
));
$this->assertEquals(1, count($treffer), 'Der Buchungsbericht liefert den Schalter mit');
}
private function maskeZeigtDenSchalter(): void
{
$mit = $this->maske(true, ['ohne_bargeld_abgleich' => 1]);
$this->assert(
(bool)preg_match('#name="ohne_bargeld_abgleich" value="1"\s*checked#', $mit),
'Mit Bargeld-Tool steht der Schalter gesetzt in der Maske'
);
$this->assert(str_contains($mit, 'Ohne Bargeld-Abgleich'), 'Er ist beschriftet');
$ohne = $this->maske(false, ['ohne_bargeld_abgleich' => 1]);
$this->assert(!str_contains($ohne, 'Ohne Bargeld-Abgleich'), 'Ohne Bargeld-Tool ist der Schalter unsichtbar');
$this->assert(
str_contains($ohne, '<input type="hidden" name="ohne_bargeld_abgleich" value="1">'),
'Ein gesetzter Wert geht trotzdem nicht verloren'
);
$leer = $this->maske(false, []);
$this->assert(!str_contains($leer, 'ohne_bargeld_abgleich'), 'Ohne Bargeld-Tool und ohne Wert steht nichts in der Maske');
}
/** @param array<string,mixed> $buchung */
private function maske(bool $bargeld, array $buchung): string
{
$daten = [
'titel' => 'Buchung bearbeiten',
'konto' => Konto::find($this->konto),
'buchung' => $buchung + [
'id' => 1, 'konto_id' => $this->konto, 'datum' => '2026-09-01', 'betrag' => -10.0,
],
'splits' => [],
'eingaben' => null,
'kategorien' => Kategorie::baumFlach(),
'klassen' => [],
'konten' => Konto::alle(),
'bargeld' => $bargeld,
'flash' => [],
'benutzer' => null,
'csrfToken' => 'x',
'aktuellerPfad' => '/konten',
];
extract($daten, EXTR_SKIP);
ob_start();
include dirname(__DIR__) . '/src/Views/buchungen/formular.php';
return (string)ob_get_clean();
}
}

View file

@ -196,4 +196,47 @@ test.describe('Bargeld-Tool', () => {
await expect(imBericht).toContainText(GIRO) await expect(imBericht).toContainText(GIRO)
await expect(imBericht.locator('td.bargeldspalte .bargeld-fehler')).toBeVisible() await expect(imBericht.locator('td.bargeldspalte .bargeld-fehler')).toBeVisible()
}) })
// Sammelbuchungen und Differenzausgleiche stehen absichtlich nicht im
// Bargeld-Tool (#136): Der Schalter nimmt sie aus dem Abgleich.
test('Ohne Bargeld-Abgleich wird nicht geprüft und nicht angemahnt', async ({ page }) => {
const empfaenger = `Sammelbuchung ${MARKE}`
// Ohne Adresse gibt es den Schalter nicht.
await page.goto(`/konten/${giroId}/buchung`)
await expect(page.locator('#ohne_bargeld_abgleich')).toHaveCount(0)
await page.goto('/verwaltung/bargeld')
await page.fill('#bargeld_url', ADRESSE)
await page.getByRole('checkbox', { name: KATEGORIE }).check()
await page.getByRole('button', { name: 'Speichern' }).click()
await expect(page.locator('.meldung')).toContainText('gespeichert')
await page.goto(`/konten/${giroId}/buchung`)
const schalter = page.locator('#ohne_bargeld_abgleich')
await expect(schalter).toBeVisible()
await expect(schalter).not.toBeChecked()
await page.selectOption('#art', 'ausgabe')
await page.fill('#datum', '2026-04-05')
await page.fill('#betrag', '73,20')
await page.fill('#empfaenger', empfaenger)
await page.selectOption('#zuordnung', { label: KATEGORIE })
await schalter.check()
await page.click('.buchungsmaske button[type="submit"]')
// Kein Abruf – also auch keine Meldung über einen gescheiterten.
await expect(page.locator('.meldung-erfolg')).toContainText('Buchung angelegt')
await expect(page.locator('.meldung-erfolg')).not.toContainText('Bargeld-Tool')
const zeile = page.locator('table.kontoblatt tbody tr', { hasText: empfaenger })
await expect(zeile).toContainText(KATEGORIE)
const marke = zeile.locator('td.bargeldspalte .bargeld-neutral')
await expect(marke).toHaveText('○')
await expect(marke).toHaveAttribute('title', 'Bewusst ohne Abgleich mit dem Bargeld-Tool')
// Beim Bearbeiten steht der Schalter wieder gesetzt da.
await zeile.getByRole('link', { name: empfaenger }).click()
await expect(page.locator('#ohne_bargeld_abgleich')).toBeChecked()
})
}) })