Fix #137: Spalte und Filter „abgeglichen“ im Buchungsbericht

Der Buchungsbericht zeigt vor der Spalte „Bar“ des Bargeld-Tools die
Spalte „✓“ (abgeglichen) und lässt sich nach abgeglichenen bzw. nicht
abgeglichenen Buchungen filtern. „Buchungen nicht abgeglichen“ unter den
offenen Vorgängen im Cockpit öffnet jetzt diesen Bericht mit dem Filter
„nicht abgeglichen“ statt der Kontenübersicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-10-12 22:03:54 +02:00
parent a5989eb262
commit fef2e67248
12 changed files with 208 additions and 15 deletions

View file

@ -8,6 +8,10 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
### Added
- Buchungsbericht: Spalte „✓“ (abgeglichen) vor der Spalte „Bar“ des Bargeld-Tools und
Filter „Abgeglichen“ (alle / abgeglichen / nicht abgeglichen); „Buchungen nicht
abgeglichen“ unter den offenen Vorgängen im Cockpit öffnet den Buchungsbericht mit
diesem Filter statt der Kontenübersicht (#137)
- 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)

View file

@ -1,5 +1,13 @@
# Änderungen
## Buchungsbericht: Spalte und Filter „abgeglichen“ (12.10.2026)
- Anlass (#137): Der Buchungsbericht zeigte nicht, ob eine Buchung abgeglichen ist, und ließ sich nicht danach filtern.
- Neue Spalte „✓“ (Tooltip „abgeglichen“) vor der Spalte „Bar“ des Bargeld-Tools – wie im Kontoblatt. Splitzeilen erben den Haken ihrer Buchung.
- Neuer Filter „Abgeglichen“ (alle / abgeglichen / nicht abgeglichen), Filterfeld `abgeglichen` mit den Werten `ja`/`nein`; wie der Bargeld-Filter wirkt er nur im Buchungsbericht und wird mit gespeicherten Berichten gesichert.
- Cockpit, *Offene Vorgänge*: „Buchungen nicht abgeglichen“ führt jetzt in den Buchungsbericht mit dem Filter „nicht abgeglichen“ statt in die Kontenübersicht (`Cockpit::nichtAbgeglichenBericht()`). Zeitraum von der ersten betroffenen Buchung bis heute, Umbuchungen eingeschlossen; liegen nicht abgeglichene Buchungen auf aufgelösten Konten, beschränkt sich der Bericht auf die aktiven Konten – so zeigt er genau die gezählten Buchungen.
- Tests: `tests/14_AuswertungTest.php`, `tests/15_CockpitTest.php`, `tests/e2e/specs/auswertungen.spec.js`.
## 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.

View file

@ -48,7 +48,7 @@ Zwei Ausnahmen führen das Vorzeichen mit, weil sie keine Abschnitte haben: der
| Typ | Zeilen | Spalten |
|---|---|---|
| **Summenbericht** | Zeilen-Summe, nach Einnahmen und Ausgaben getrennt | Zeitraum, je nach Spalten-Aufteilung |
| **Buchungsbericht** | Einzelbuchungen, gruppiert nach Zeilen-Summe; bei eingerichtetem Bargeld-Tool mit Spalte „Bar“ und Filter „Bargeld-Tool“ | feste Buchungsspalten |
| **Buchungsbericht** | Einzelbuchungen, gruppiert nach Zeilen-Summe; Spalte „✓“ (abgeglichen) mit Filter „Abgeglichen“ (#137); bei eingerichtetem Bargeld-Tool mit Spalte „Bar“ und Filter „Bargeld-Tool“ | feste Buchungsspalten |
| **Vergleichsbericht** | wie Summenbericht | Zeitraum, Vorjahreszeitraum, Differenz, Veränderung |
| **Kontostandsbericht** | Konten | Salden zu den Enden der Spaltenzeiträume |
| **Budgetbericht** | Kategoriebaum | Budget, Ist, Saldo |

View file

@ -179,6 +179,9 @@ Prüfungen, die gerade nichts melden und daher nicht in der Liste stehen:
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.
Führt in den Buchungsbericht mit dem Filter „Abgeglichen: nicht abgeglichen“, von der
ersten betroffenen Buchung bis heute und mit Umbuchungen; liegen auch auf aufgelösten
Konten solche Buchungen, ist der Bericht auf die aktiven Konten eingeschränkt (#137).
* **Bargeld-Tool-Prüfung fehlgeschlagen** – Buchungen mit `bargeld_status = 'fehler'`, also
einem gescheiterten Abruf beim Bargeld-Tool (siehe `docs/buchungen.md`), nicht zu
verwechseln mit „im Bargeld-Tool nicht gefunden“. Führt in den Buchungsbericht über den

View file

@ -1412,17 +1412,21 @@ table.kostentabelle select { max-width: 16rem; width: 100%; }
table.kostentabelle th[scope="row"] { font-weight: 400; text-align: left; }
table.kostentabelle .gruppenzeile th { padding-top: .7rem; font-weight: 600; }
/* Die Abgleichspalte zwischen Klasse und Betrag: so schmal wie ihr Zeichen.
Sie erscheint nur, wenn ein Bargeld-Tool eingerichtet ist. */
/* Die Abgleichspalten zwischen Klasse und Betrag: so schmal wie ihr Zeichen.
„✓“ (abgeglichen) steht immer davor, „Bar“ erscheint nur, wenn ein
Bargeld-Tool eingerichtet ist. */
table.liste th.bargeldspalte,
table.liste td.bargeldspalte {
table.liste td.bargeldspalte,
table.liste th.abgleichspalte,
table.liste td.abgleichspalte {
width: 1.8rem;
padding-left: .2rem;
padding-right: .2rem;
text-align: center;
}
table.liste th.bargeldspalte {
table.liste th.bargeldspalte,
table.liste th.abgleichspalte {
font-size: .78rem;
color: var(--text-gedaempft);
}

View file

@ -56,6 +56,9 @@ class Auswertung
/** Zulässige Werte des Bargeld-Filters; 'keiner' = nicht abgeglichen. */
public const BARGELD_FILTER = ['gefunden', 'fehlt', 'fehler', 'keiner'];
/** Zulässige Werte des Abgleich-Filters im Buchungsbericht (#137). */
public const ABGEGLICHEN_FILTER = ['ja', 'nein'];
/** Wonach die Zeilen zusammengefasst werden. */
public const ZEILENSUMMEN = [
'kategorie' => 'Kategorie',
@ -127,6 +130,10 @@ class Auswertung
'bargeld' => $typ === 'buchungsbericht' && in_array($roh['bargeld'] ?? '', self::BARGELD_FILTER, true)
? (string)$roh['bargeld']
: '',
// Ebenso nur dort: der Haken „abgeglichen“ der Buchung (#137).
'abgeglichen' => $typ === 'buchungsbericht' && in_array($roh['abgeglichen'] ?? '', self::ABGEGLICHEN_FILTER, true)
? (string)$roh['abgeglichen']
: '',
];
}
@ -842,6 +849,7 @@ class Auswertung
'bargeld_status' => $zeile['bargeld_status'] ?? null,
'bargeld_hinweis' => $zeile['bargeld_hinweis'] ?? null,
'ohne_bargeld_abgleich' => !empty($zeile['ohne_bargeld_abgleich']),
'abgeglichen' => !empty($zeile['abgeglichen']),
];
}
@ -1195,14 +1203,14 @@ class Auswertung
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.buchungstext AS text, b.bargeld_status, b.bargeld_hinweis,
b.ohne_bargeld_abgleich
b.ohne_bargeld_abgleich, b.abgeglichen
FROM buchungen b
WHERE b.benutzer_id = ? AND b.gesplittet = 0 AND b.datum BETWEEN ? AND ?
UNION ALL
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.notiz AS text, b.bargeld_status, b.bargeld_hinweis,
b.ohne_bargeld_abgleich
b.ohne_bargeld_abgleich, b.abgeglichen
FROM buchung_splits s
JOIN buchungen b ON b.id = s.buchung_id
WHERE b.benutzer_id = ? AND b.gesplittet = 1 AND b.datum BETWEEN ? AND ?
@ -1258,6 +1266,11 @@ class Auswertung
$params[] = $bargeld;
}
$abgeglichen = (string)($filter['abgeglichen'] ?? '');
if ($abgeglichen !== '') {
$wo[] = $abgeglichen === 'ja' ? 'abgeglichen = 1' : 'abgeglichen = 0';
}
if (($filter['suche'] ?? '') !== '') {
$wo[] = '(empfaenger LIKE ? OR text LIKE ?)';
$muster = '%' . $filter['suche'] . '%';

View file

@ -261,19 +261,19 @@ class Cockpit
];
}
$nichtAbgeglichen = (int)Database::fetchValue(
'SELECT COUNT(*) FROM buchungen b
$stichtag = $heute ?? date('Y-m-d');
$nichtAbgeglichen = Database::fetchOne(
'SELECT COUNT(*) AS anzahl, MIN(b.datum) AS erste FROM buchungen b
JOIN konten k ON k.id = b.konto_id
WHERE b.benutzer_id = ? AND b.abgeglichen = 0 AND k.aktiv = 1 AND b.datum <= ?',
[$benutzer, $heute ?? date('Y-m-d')],
0
[$benutzer, $stichtag]
);
if ($nichtAbgeglichen > 0) {
if ((int)($nichtAbgeglichen['anzahl'] ?? 0) > 0) {
$vorgaenge[] = [
'schluessel' => 'nicht_abgeglichen',
'text' => 'nicht abgeglichen',
'anzahl' => $nichtAbgeglichen,
'pfad' => '/konten',
'anzahl' => (int)$nichtAbgeglichen['anzahl'],
'pfad' => self::nichtAbgeglichenBericht((string)$nichtAbgeglichen['erste'], $stichtag),
];
}
@ -387,4 +387,39 @@ class Cockpit
'angepasst' => '1',
]);
}
/**
* Der Buchungsbericht, gefiltert auf nicht abgeglichene Buchungen (#137) –
* mit denselben Grenzen wie der offene Vorgang: von der ersten betroffenen
* Buchung bis heute, Umbuchungen eingeschlossen. Liegen auch auf
* aufgelösten Konten nicht abgeglichene Buchungen, beschränkt sich der
* Bericht auf die aktiven Konten, denn nur die zählt der Vorgang.
*/
private static function nichtAbgeglichenBericht(string $erste, string $stichtag): string
{
$parameter = [
'typ' => 'buchungsbericht',
'zeitraum' => 'benutzerdefiniert',
'von' => $erste,
'bis' => $stichtag,
'umbuchungen' => '1',
'abgeglichen' => 'nein',
];
$inaktiv = (int)Database::fetchValue(
'SELECT COUNT(*) FROM buchungen b
JOIN konten k ON k.id = b.konto_id
WHERE b.benutzer_id = ? AND b.abgeglichen = 0 AND k.aktiv = 0 AND b.datum BETWEEN ? AND ?',
[Mandant::id(), $erste, $stichtag],
0
);
if ($inaktiv > 0) {
$parameter['konten'] = array_map('intval', array_column(Database::fetchAll(
'SELECT id FROM konten WHERE benutzer_id = ? AND aktiv = 1 ORDER BY id',
[Mandant::id()]
), 'id'));
}
return '/auswertungen/bericht?' . http_build_query($parameter + ['angepasst' => '1']);
}
}

View file

@ -175,6 +175,18 @@ include __DIR__ . '/../layout/kopf.php';
</label>
<?php endif; ?>
<?php if ($bericht['typ'] === 'buchungsbericht'): ?>
<label>Abgeglichen
<select name="abgeglichen">
<?php foreach (['' => 'alle', 'ja' => 'abgeglichen', 'nein' => 'nicht abgeglichen'] as $wert => $text): ?>
<option value="<?= View::h($wert) ?>" <?= $filter['abgeglichen'] === $wert ? 'selected' : '' ?>>
<?= View::h($text) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<?php endif; ?>
<?php if ($bericht['typ'] === 'buchungsbericht' && ($bericht['bargeld'] ?? false)): ?>
<label>Bargeld-Tool
<select name="bargeld">

View file

@ -10,7 +10,7 @@ use Finanzmanager\View;
*/
?>
<?php $spalten = $bericht['bargeld'] ? 8 : 7; ?>
<?php $spalten = $bericht['bargeld'] ? 9 : 8; ?>
<table class="liste bericht buchungsgruppen">
<thead>
<tr>
@ -20,6 +20,7 @@ use Finanzmanager\View;
<th>Buchungstext</th>
<th>Kategorie</th>
<th>Klasse</th>
<th class="abgleichspalte" title="abgeglichen">✓</th>
<?php if ($bericht['bargeld']): ?>
<th class="bargeldspalte" title="Abgleich mit dem Bargeld-Tool">Bar</th>
<?php endif; ?>
@ -52,6 +53,7 @@ use Finanzmanager\View;
<?php endif; ?>
</td>
<td><?= View::h($buchung['klasse']) ?></td>
<td class="abgleichspalte nebentext"><?= $buchung['abgeglichen'] ? '✓' : '' ?></td>
<?php if ($bericht['bargeld']): ?>
<td class="bargeldspalte"><?= View::bargeldMarke($buchung['bargeld_status'], $buchung['bargeld_hinweis'], !empty($buchung['ohne_bargeld_abgleich'])) ?></td>
<?php endif; ?>
@ -89,6 +91,9 @@ use Finanzmanager\View;
dem Summenbericht überein.
<?php endif; ?>
Der Empfänger führt in die Buchungsmaske.
<?php if ($bericht['filter']['abgeglichen'] === 'nein'): ?>
Den Haken „abgeglichen“ setzt die Buchungsmaske oder der Abgleich im Kontoblatt.
<?php endif; ?>
<?php if (in_array($bericht['filter']['bargeld'], ['fehlt', 'fehler'], true)): ?>
<?php if ($bericht['filter']['bargeld'] === 'fehlt'): ?>
Zu diesen Buchungen wurde im Bargeld-Tool keine Ausgabe mit demselben Betrag gefunden.

View file

@ -53,6 +53,7 @@ class AuswertungTest extends TestCase
$this->buchungsbericht();
$this->buchungsberichtBargeld();
$this->buchungsberichtAbgeglichen();
$this->vergleichsbericht();
$this->kontostandsbericht();
$this->budgetbericht();
@ -505,6 +506,42 @@ class AuswertungTest extends TestCase
Einstellung::set('bargeld_url', (string)$vorher);
}
/** Spalte und Filter „abgeglichen“ im Buchungsbericht (#137). */
private function buchungsberichtAbgeglichen(): void
{
Database::execute(
"UPDATE buchungen SET abgeglichen = CASE WHEN empfaenger IN ('Edeka', 'Rewe') THEN 1 ELSE 0 END
WHERE konto_id = ?",
[$this->giro]
);
$alle = $this->bericht(['typ' => 'buchungsbericht', 'zeilensumme' => 'konto']);
$markiert = [];
foreach ($alle['gruppen'][0]['buchungen'] as $buchung) {
$markiert[$buchung['empfaenger']] = $buchung['abgeglichen'];
}
$this->assert($markiert['Edeka'] && $markiert['Rewe'], 'Abgeglichene Buchungen tragen den Haken');
$this->assert(!$markiert['Kino'], 'Nicht abgeglichene Buchungen tragen keinen');
$ja = $this->bericht(['typ' => 'buchungsbericht', 'zeilensumme' => 'konto', 'abgeglichen' => 'ja']);
$this->assertEquals(2, $ja['anzahl'], 'Der Filter „abgeglichen“ lässt Edeka und die Splitbuchung übrig');
$nein = $this->bericht(['typ' => 'buchungsbericht', 'zeilensumme' => 'konto', 'abgeglichen' => 'nein']);
$this->assertEquals($alle['anzahl'] - 2, $nein['anzahl'], 'Der Filter „nicht abgeglichen“ lässt beide weg');
// Splitzeilen erben den Haken ihrer Buchung.
$nachKategorie = $this->bericht(['typ' => 'buchungsbericht', 'abgeglichen' => 'ja']);
$this->assertEquals(3, $nachKategorie['anzahl'], 'Nach Kategorie: Edeka und beide Splitzeilen');
$unbekannt = $this->bericht(['typ' => 'buchungsbericht', 'abgeglichen' => 'vielleicht']);
$this->assertEquals('', $unbekannt['filter']['abgeglichen'], 'Ein unbekannter Wert filtert nicht');
$summe = $this->bericht(['typ' => 'summenbericht', 'abgeglichen' => 'nein']);
$this->assertEquals('', $summe['filter']['abgeglichen'], 'Außerhalb des Buchungsberichts wirkt der Filter nicht');
Database::execute('UPDATE buchungen SET abgeglichen = 0 WHERE konto_id = ?', [$this->giro]);
}
private function buchungsbericht(): void
{
$bericht = $this->bericht(['typ' => 'buchungsbericht', 'zeilensumme' => 'konto']);

View file

@ -37,6 +37,7 @@ class CockpitTest extends TestCase
$this->verteilungKuerztAufDieGrenze();
$this->offeneVorgaengeZaehlenWasWartet();
$this->bargeldFehlerErscheintAlsOffenerVorgang();
$this->nichtAbgeglichenFuehrtInDenBuchungsbericht();
$this->legendenFuehrenZuDenBuchungen();
$this->vorgangspruefungenKennenJedenVorgang();
}
@ -325,6 +326,56 @@ class CockpitTest extends TestCase
Database::execute("UPDATE buchungen SET bargeld_status = 'fehler' WHERE id = ?", [$id]);
}
/**
* „Buchungen nicht abgeglichen“ öffnet den Buchungsbericht, gefiltert auf
* nicht abgeglichene Buchungen – und der zeigt genau die gezählten (#137).
*/
private function nichtAbgeglichenFuehrtInDenBuchungsbericht(): void
{
$pruefen = function (string $fall): void {
$vorgang = null;
foreach (Cockpit::offeneVorgaenge(self::HEUTE) as $eintrag) {
if ($eintrag['schluessel'] === 'nicht_abgeglichen') {
$vorgang = $eintrag;
}
}
$this->assertNotEmpty($vorgang, $fall . ': Der Vorgang erscheint');
$this->assert(str_starts_with($vorgang['pfad'], '/auswertungen/bericht?'), $fall . ': Der Weg führt in den Buchungsbericht');
parse_str((string)parse_url($vorgang['pfad'], PHP_URL_QUERY), $filter);
$this->assertEquals('buchungsbericht', $filter['typ'] ?? '', $fall . ': Typ Buchungsbericht');
$this->assertEquals('nein', $filter['abgeglichen'] ?? '', $fall . ': Gefiltert auf nicht abgeglichen');
$bericht = Auswertung::bericht($filter, self::HEUTE);
$ids = [];
foreach ($bericht['gruppen'] as $gruppe) {
foreach ($gruppe['buchungen'] as $buchung) {
$this->assert(!$buchung['abgeglichen'], $fall . ': Nur nicht abgeglichene Buchungen');
$ids[$buchung['buchung_id']] = true;
}
}
$this->assertEquals($vorgang['anzahl'], count($ids), $fall . ': Der Bericht zeigt genau die gezählten Buchungen');
};
// Eine künftige Buchung zählt der Vorgang nicht – der Bericht auch nicht.
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-12-24', 'betrag' => -40,
'empfaenger' => 'Zukunft', 'kategorie_id' => $this->freizeit,
]);
$pruefen('Aktive Konten');
// Ein aufgelöstes Konto zählt nicht mit – der Bericht beschränkt sich
// dann auf die aktiven Konten.
$alt = Konto::anlegen([
'name' => 'Cockpitaltkonto', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 0,
]);
Buchung::anlegen([
'konto_id' => $alt, 'datum' => '2026-08-01', 'betrag' => -5,
'empfaenger' => 'Altlast', 'kategorie_id' => $this->freizeit,
]);
$pruefen('Mit aufgelöstem Konto');
}
/**
* Jeder Legendeneintrag führt zu einer Buchungsliste, deren Summe genau
* seinen Wert trifft – auch „Nicht kategorisiert“ und das Sammelsegment.

View file

@ -67,6 +67,27 @@ test.describe('Auswertungen', () => {
await expect(page.locator('thead th', { hasText: 'Saldo' })).toHaveCount(1)
})
test('Buchungsbericht führt die Spalte „abgeglichen“ und filtert danach (#137)', async ({ page }) => {
await vorlage(page, 'buchungsbericht')
await expect(page.locator('table.bericht thead th.abgleichspalte')).toHaveCount(1)
await page.selectOption('select[name="abgeglichen"]', 'nein')
await page.click('button:has-text("Anzeigen")')
await expect(page).toHaveURL(/abgeglichen=nein/)
await expect(page.locator('select[name="abgeglichen"]')).toHaveValue('nein')
await expect(page.locator('table.bericht tbody td.abgleichspalte', { hasText: '✓' })).toHaveCount(0)
})
test('Cockpit: „nicht abgeglichen“ öffnet den gefilterten Buchungsbericht (#137)', async ({ page }) => {
await page.goto('/')
const eintrag = page.locator('.vorgangsliste a', { hasText: 'nicht abgeglichen' })
test.skip(await eintrag.count() === 0, 'Keine nicht abgeglichenen Buchungen auf dieser Instanz')
await eintrag.first().click()
await expect(page).toHaveURL(/typ=buchungsbericht/)
await expect(page.locator('select[name="abgeglichen"]')).toHaveValue('nein')
})
test('Spalten-Aufteilung macht aus einer Spalte zwölf Monate', async ({ page }) => {
await vorlage(page, 'summenbericht')
await expect(page.locator('table.bericht thead th')).toHaveCount(2)