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:
parent
c6d5babb41
commit
a5989eb262
17 changed files with 341 additions and 21 deletions
|
|
@ -8,6 +8,9 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
|
|||
|
||||
### 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
|
||||
Betrag, Stück und Kurs automatisch das dritte (Betrag = Stück × Kurs) (#129)
|
||||
- Wertpapiere: Beim Buchen eines Wertpapiergeschäfts (Dialog im Kontoblatt und
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
# Ä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)
|
||||
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ Haushalt wird beides parallel genutzt.
|
|||
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.
|
||||
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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
**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
|
||||
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
|
||||
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.
|
||||
|
|
@ -300,6 +300,7 @@ Netz. In der schmalen Spalte zwischen Klasse und Betrag steht:
|
|||
| Symbol | Status | Bedeutung |
|
||||
|---|---|---|
|
||||
| ● 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 |
|
||||
| ✗ rot | `fehlt` | Dort steht keine |
|
||||
| ! | `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
|
||||
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
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
denselben Buchungsbericht mit dem Filter „Bargeld-Tool: nicht gefunden“
|
||||
(`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()`,
|
||||
aber nur, wenn dieser Monat wirklich noch offen ist und Buchungen trägt.
|
||||
|
||||
|
|
|
|||
11
migrations/031_ohne_bargeld_abgleich.sql
Normal file
11
migrations/031_ohne_bargeld_abgleich.sql
Normal 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;
|
||||
|
|
@ -257,7 +257,8 @@ class BuchungenController extends BaseController
|
|||
$frisch = Buchung::find((int)$buchung['id']);
|
||||
$bargeldHtml = View::bargeldMarke(
|
||||
$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(),
|
||||
'klassen' => Klasse::alle(),
|
||||
'konten' => Konto::alle(),
|
||||
// Der Schalter „Ohne Bargeld-Abgleich“ erscheint nur, wenn es ein
|
||||
// Bargeld-Tool gibt (#136).
|
||||
'bargeld' => BargeldAbgleich::eingerichtet(),
|
||||
];
|
||||
if ($fehler !== '') {
|
||||
$daten['flash'] = [['typ' => 'fehler', 'meldung' => $fehler]];
|
||||
|
|
@ -653,6 +657,7 @@ class BuchungenController extends BaseController
|
|||
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
|
||||
'quelle' => 'manuell',
|
||||
'bewusst_ohne_kategorie' => isset($_POST['bewusst_ohne_kategorie']),
|
||||
'ohne_bargeld_abgleich' => isset($_POST['ohne_bargeld_abgleich']),
|
||||
];
|
||||
|
||||
return [$daten, $this->splitzeilen()];
|
||||
|
|
|
|||
|
|
@ -106,15 +106,15 @@ class Buchung
|
|||
'INSERT INTO buchungen
|
||||
(benutzer_id, konto_id, datum, valuta, betrag, empfaenger, iban, bankname, buchungstext,
|
||||
nummer, abgeglichen, kategorie_id, klasse_id, gegenkonto_id, gesplittet,
|
||||
bewusst_ohne_kategorie, quelle, import_hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
bewusst_ohne_kategorie, ohne_bargeld_abgleich, 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['bewusst_ohne_kategorie'],
|
||||
$felder['quelle'], $felder['import_hash'],
|
||||
$felder['ohne_bargeld_abgleich'], $felder['quelle'], $felder['import_hash'],
|
||||
]
|
||||
);
|
||||
$id = Database::lastInsertId();
|
||||
|
|
@ -165,14 +165,15 @@ class Buchung
|
|||
Database::execute(
|
||||
'UPDATE buchungen SET konto_id = ?, datum = ?, valuta = ?, betrag = ?, empfaenger = ?,
|
||||
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 = ?',
|
||||
[
|
||||
$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'], $felder['bewusst_ohne_kategorie'],
|
||||
$id, Mandant::id(),
|
||||
$felder['ohne_bargeld_abgleich'], $id, Mandant::id(),
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -437,7 +438,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.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.iban, b.bankname
|
||||
FROM buchungen b
|
||||
|
|
@ -445,7 +446,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, 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,
|
||||
\'\' AS iban, \'\' AS bankname
|
||||
FROM buchungen b
|
||||
|
|
@ -453,7 +454,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, 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,
|
||||
\'\' AS iban, \'\' AS bankname
|
||||
FROM buchung_splits s
|
||||
|
|
@ -806,6 +807,9 @@ class Buchung
|
|||
'gegenkonto_id' => $gegenkonto,
|
||||
'gesplittet' => $gesplittet ? 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'] ?? '')])
|
||||
? (string)$daten['quelle'] : 'manuell',
|
||||
'import_hash' => ($daten['import_hash'] ?? null) ?: null,
|
||||
|
|
|
|||
|
|
@ -841,6 +841,7 @@ class Auswertung
|
|||
'betrag' => round((float)$zeile['betrag'], 2),
|
||||
'bargeld_status' => $zeile['bargeld_status'] ?? null,
|
||||
'bargeld_hinweis' => $zeile['bargeld_hinweis'] ?? null,
|
||||
'ohne_bargeld_abgleich' => !empty($zeile['ohne_bargeld_abgleich']),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -1193,13 +1194,15 @@ class Auswertung
|
|||
$sql = 'SELECT * FROM (
|
||||
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.buchungstext AS text, b.bargeld_status, b.bargeld_hinweis,
|
||||
b.ohne_bargeld_abgleich
|
||||
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
|
||||
s.notiz AS text, b.bargeld_status, b.bargeld_hinweis,
|
||||
b.ohne_bargeld_abgleich
|
||||
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 ?
|
||||
|
|
|
|||
|
|
@ -200,6 +200,7 @@ class Bankabgleich
|
|||
'kategorie_id' => $zuordnung['kategorie_id'],
|
||||
'klasse_id' => $zuordnung['klasse_id'],
|
||||
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
|
||||
'ohne_bargeld_abgleich' => $buchung['ohne_bargeld_abgleich'] ?? 0,
|
||||
],
|
||||
$zuordnung['splits']
|
||||
);
|
||||
|
|
|
|||
|
|
@ -23,9 +23,12 @@ use Finanzmanager\Models\Kategorie;
|
|||
*
|
||||
* 1. Die Buchung trägt eine Kategorie (keine Umbuchung, keine Splitbuchung).
|
||||
* 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
|
||||
* Kreis. Das gilt ausdrücklich auch für eine Umkategorisierung – sonst bliebe
|
||||
* ein alter Haken an einer Buchung stehen, die niemand mehr abgleicht.
|
||||
|
|
@ -141,7 +144,11 @@ class BargeldAbgleich
|
|||
/** @param array<string,mixed> $buchung */
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -135,6 +135,8 @@ class View
|
|||
* Das Symbol der Abgleichspalte im Kontoblatt. Vier Zustände, in dieser
|
||||
* Reihenfolge zu lesen: gefunden, nicht gefunden, Abruf gescheitert – und
|
||||
* 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
|
||||
* 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
|
||||
* 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);
|
||||
|
||||
[$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'],
|
||||
'fehlt' => ['✗', 'fehlt', 'Im Bargeld-Tool nicht gefunden'],
|
||||
'fehler' => ['!', 'fehler', 'Der Abruf beim Bargeld-Tool ist gescheitert'],
|
||||
default => ['●', 'neutral', 'Wird nicht mit dem Bargeld-Tool abgeglichen'],
|
||||
},
|
||||
};
|
||||
|
||||
return '<span class="bargeldmarke bargeld-' . $klasse . '" title="'
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ use Finanzmanager\View;
|
|||
</td>
|
||||
<td><?= View::h($buchung['klasse']) ?></td>
|
||||
<?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; ?>
|
||||
<td class="rechts <?= View::betragKlasse($buchung['betrag']) ?>">
|
||||
<?= View::betrag($buchung['betrag'], false) ?>
|
||||
|
|
|
|||
|
|
@ -11,12 +11,14 @@ use Finanzmanager\View;
|
|||
/** @var list<array<string,mixed>> $kategorien */
|
||||
/** @var list<array<string,mixed>> $klassen */
|
||||
/** @var list<array<string,mixed>> $konten */
|
||||
/** @var bool $bargeld Ist ein Bargeld-Tool eingerichtet? */
|
||||
/** @var string $csrfToken */
|
||||
|
||||
$neu = $buchung === null;
|
||||
$ziel = $neu ? '/konten/' . (int)$konto['id'] . '/buchung' : '/buchungen/' . (int)$buchung['id'];
|
||||
$betrag = $neu ? 0.0 : (float)$buchung['betrag'];
|
||||
$eingaben = $eingaben ?? null;
|
||||
$bargeld = $bargeld ?? false;
|
||||
|
||||
// Die Art bestimmt nur noch die Richtung. Ob die Buchung eine Umbuchung ist,
|
||||
// steht im Feld Kategorie / Gegenkonto.
|
||||
|
|
@ -39,6 +41,7 @@ $buchungstextWert = (string)($buchung['buchungstext'] ?? '');
|
|||
$nummerWert = (string)($buchung['nummer'] ?? '');
|
||||
$abgeglichenWert = !empty($buchung['abgeglichen']);
|
||||
$ohneKategorieWert = !empty($buchung['bewusst_ohne_kategorie']);
|
||||
$ohneBargeldWert = !empty($buchung['ohne_bargeld_abgleich']);
|
||||
|
||||
if ($eingaben !== null) {
|
||||
$art = ($eingaben['art'] ?? '') === 'einnahme' ? 'einnahme' : 'ausgabe';
|
||||
|
|
@ -51,6 +54,7 @@ if ($eingaben !== null) {
|
|||
$nummerWert = (string)($eingaben['nummer'] ?? '');
|
||||
$abgeglichenWert = isset($eingaben['abgeglichen']);
|
||||
$ohneKategorieWert = isset($eingaben['bewusst_ohne_kategorie']);
|
||||
$ohneBargeldWert = isset($eingaben['ohne_bargeld_abgleich']);
|
||||
}
|
||||
|
||||
$eigenesKonto = (int)$konto['id'];
|
||||
|
|
@ -206,6 +210,22 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</label>
|
||||
</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">
|
||||
<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.">
|
||||
|
|
|
|||
|
|
@ -134,7 +134,11 @@ $splitklassen = static function (array $splitzeilen) use ($splitname, $klassenNa
|
|||
* @param array<string,mixed> $zeile
|
||||
*/
|
||||
$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
|
||||
|
|
|
|||
194
tests/65_OhneBargeldAbgleichTest.php
Normal file
194
tests/65_OhneBargeldAbgleichTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -196,4 +196,47 @@ test.describe('Bargeld-Tool', () => {
|
|||
await expect(imBericht).toContainText(GIRO)
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue