From 057075368cec796929631f4f4b550a75e426f221 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 29 Sep 2026 23:11:06 +0200 Subject: [PATCH 1/3] Fix #94: Regel direkt aus einer Buchung anlegen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toggle „Regel anlegen“ in der Buchungsmaske (Fix #94); beim Speichern entsteht daraus eine Regel (AbgleichRegel::ausBuchung()), die das Kontoblatt danach in einem modalen Dialog zum Prüfen und Anpassen zeigt. Abbruch (auch per Escape) verwirft die Regel wieder, Speichern übernimmt Änderungen. Die Regelfelder sind aus regeln.php in regelfelder.php ausgelagert, damit beide Formulare dieselbe Bauart teilen. Co-Authored-By: Claude Sonnet 5 --- Changes.md | 18 +++ docs/banking.md | 14 +++ public/static/app.css | 7 +- public/static/regeldialog.js | 27 ++++ src/Controllers/BankingController.php | 23 +++- src/Controllers/BuchungenController.php | 29 ++++- src/Models/AbgleichRegel.php | 63 ++++++++++ src/Views/banking/regelfelder.php | 112 +++++++++++++++++ src/Views/banking/regeln.php | 70 +---------- src/Views/buchungen/formular.php | 8 ++ src/Views/buchungen/kontoblatt.php | 32 +++++ tests/51_RegelAusBuchungTest.php | 161 ++++++++++++++++++++++++ tests/e2e/specs/regelausbuchung.spec.js | 138 ++++++++++++++++++++ 13 files changed, 628 insertions(+), 74 deletions(-) create mode 100644 public/static/regeldialog.js create mode 100644 src/Views/banking/regelfelder.php create mode 100644 tests/51_RegelAusBuchungTest.php create mode 100644 tests/e2e/specs/regelausbuchung.spec.js diff --git a/Changes.md b/Changes.md index 3308071..1528a81 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,23 @@ # Änderungen +## Regel direkt aus einer Buchung anlegen (29.09.2026) + +- Neuer Toggle „Regel anlegen“ in der Buchungsmaske, standardmäßig aus; das bisherige + Verhalten bleibt unverändert, solange er aus ist. +- Ist er beim Speichern an, entsteht aus Empfänger (ersatzweise Buchungstext), Kategorie, + Klasse, Gegenkonto und Splits der Buchung eine neue Regel (`AbgleichRegel::ausBuchung()`). + Ohne Empfänger und Buchungstext gibt es kein sinnvolles Muster – dann bleibt es beim + gewöhnlichen Speichern ohne Regel. +- Das Kontoblatt zeigt die neue Regel danach in einem modalen Dialog (`regeldialog.js`, + natives `` wie beim Splitdialog) zum Prüfen und Anpassen – mit denselben Feldern + wie „Neue Regel“ in den Bankregeln (ausgelagert nach `regelfelder.php`). +- „Abbruch“ löscht die gerade erst angelegte Regel wieder (über das bestehende + Lösch-Formular der Regelliste, nur mit Rücksprung zum Kontoblatt statt zur Regelliste); + die Buchung bleibt gespeichert. Die Escape-Taste verhält sich genauso, statt den Dialog + nur zu schließen und eine verwaiste Regel zurückzulassen. +- „Speichern“ übernimmt Änderungen und lässt die Regel in der Liste stehen. +- Neu: Test 51 (PHP) und `tests/e2e/specs/regelausbuchung.spec.js`. + ## Umbuchungen: beide Seiten finden zueinander (29.09.2026) - Eine Umbuchung erscheint auf zwei Kontoauszügen, wurde aber nicht als eine Buchung erkannt: diff --git a/docs/banking.md b/docs/banking.md index e4958aa..6ec9b9a 100644 --- a/docs/banking.md +++ b/docs/banking.md @@ -474,6 +474,20 @@ Abruf zum Stehen. Eingegeben wird ohne Trennzeichen: `Miete|Nebenkosten`, nicht Der Zähler *Treffer* zeigt, welche Regeln im Alltag überhaupt greifen. +### Regel direkt aus einer Buchung anlegen + +In der Buchungsmaske schaltet der Toggle *Regel anlegen* (standardmäßig aus) eine +Kurzstrecke frei: Beim Speichern entsteht aus Empfänger (ersatzweise Buchungstext), +Kategorie, Klasse, Gegenkonto und einer eventuellen Splitbuchung eine neue Regel +(`AbgleichRegel::ausBuchung()`) – Feld *Empfänger*, Vergleich *enthält*, das Muster ist der +Empfängertext. Fehlen sowohl Empfänger als auch Buchungstext, gibt es kein sinnvolles Muster +und keine Regel entsteht; die Buchung wird trotzdem ganz normal gespeichert. + +Das Kontoblatt zeigt die neue Regel danach in einem modalen Dialog mit denselben Feldern wie +*Neue Regel* oben auf dieser Seite. *Abbruch* (auch über Escape) löscht die gerade erst +angelegte Regel wieder – die Buchung bleibt gespeichert, keine ungewollte Regel bleibt +zurück. *Speichern* übernimmt Änderungen und belässt die Regel in der Liste. + ## Protokoll und Diagnose Jeder Abruf schreibt eine Zeile nach `bank_abrufe` mit Status, Zahlen und Verlauf – ohne das diff --git a/public/static/app.css b/public/static/app.css index 88770c0..6ad60c7 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -1320,11 +1320,16 @@ table.splittabelle input.rechts { text-align: right; } .formatname { font-weight: 600; font-size: 1.05rem; } /* Der Wertpapierdialog trägt ein Formular und wird dadurch länger als der - Splitdialog; bei kleinen Fenstern scrollt er. */ + Splitdialog; bei kleinen Fenstern scrollt er. Der Regeldialog übernimmt + dieselben Regelfelder wie die Regelliste – aus demselben Grund dieselbe + Bauart. */ .wertpapierdialog { max-height: 88vh; overflow-y: auto; } .wertpapierdialog .formular { max-width: none; } .wertpapierdialog h2 { margin: 0 0 .3rem; font-size: 1.05rem; } +.regeldialog { max-height: 88vh; overflow-y: auto; } +.regeldialog .formular { max-width: none; } + /* Die Kostentabelle: Betrag rechts, Kategorie als stiller Hinweis daneben. */ table.kostentabelle { margin-top: .6rem; } table.kostentabelle td { padding: .25rem .4rem; } diff --git a/public/static/regeldialog.js b/public/static/regeldialog.js new file mode 100644 index 0000000..5e57b92 --- /dev/null +++ b/public/static/regeldialog.js @@ -0,0 +1,27 @@ +/* Regeldialog: öffnet sich von selbst, wenn die Buchungsmaske mit gesetztem + * Toggle „Regel anlegen“ gespeichert hat. + * + * Die Regel steht zu diesem Zeitpunkt schon in der Datenbank – der Dialog + * zeigt sie nur zum Prüfen. Ein Abbruch über die Escape-Taste darf deshalb + * nicht bloß den Dialog schließen, sonst bliebe die Regel ungewollt stehen + * (Fix #94). Er löst stattdessen denselben Knopf aus wie ein Klick auf + * „Abbruch“. + */ + +(function () { + 'use strict'; + + const dialog = document.getElementById('regeldialog'); + const formular = document.getElementById('regelformular'); + const abbrechen = document.getElementById('regelabbrechen'); + if (!dialog || !formular || !abbrechen) { + return; + } + + dialog.showModal(); + + dialog.addEventListener('cancel', function (ereignis) { + ereignis.preventDefault(); + formular.requestSubmit(abbrechen); + }); +})(); diff --git a/src/Controllers/BankingController.php b/src/Controllers/BankingController.php index ee00bc1..a9eb329 100644 --- a/src/Controllers/BankingController.php +++ b/src/Controllers/BankingController.php @@ -941,11 +941,11 @@ class BankingController extends BaseController try { AbgleichRegel::speichern((int)$params['id'], $this->regeldaten()); } catch (\Throwable $e) { - View::redirect('/banking/regeln', $e->getMessage(), 'fehler'); + View::redirect($this->regelZiel(), $e->getMessage(), 'fehler'); return; } - View::redirect('/banking/regeln', 'Regel gespeichert.'); + View::redirect($this->regelZiel(), 'Regel gespeichert.'); } /** @param array $params */ @@ -955,7 +955,24 @@ class BankingController extends BaseController AbgleichRegel::loeschen((int)$params['id']); - View::redirect('/banking/regeln', 'Regel gelöscht.'); + View::redirect($this->regelZiel(), 'Regel gelöscht.'); + } + + /** + * Wohin es nach dem Speichern oder Löschen einer Regel geht: normalerweise + * die Regelliste. Der Regeldialog der Buchungsmaske trägt hier aber das + * Konto ein, von dem er kam – die Regel entstand aus einer Buchung, zu deren + * Kontoblatt soll es danach zurückgehen, ohne dass diese Ansicht selbst + * etwas von der Buchung wissen müsste. + */ + private function regelZiel(): string + { + $kontoId = (int)$this->post('rueckziel_konto_id'); + if ($kontoId > 0 && Konto::find($kontoId) !== null) { + return '/konten/' . $kontoId; + } + + return '/banking/regeln'; } /** diff --git a/src/Controllers/BuchungenController.php b/src/Controllers/BuchungenController.php index d2ce1ff..67ef088 100644 --- a/src/Controllers/BuchungenController.php +++ b/src/Controllers/BuchungenController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Finanzmanager\Controllers; +use Finanzmanager\Models\AbgleichRegel; use Finanzmanager\Models\Buchung; use Finanzmanager\Models\Depotbuchung; use Finanzmanager\Models\Einstellung; @@ -82,6 +83,9 @@ class BuchungenController extends BaseController // Die Abgleichspalte erscheint nur, wenn es ein Bargeld-Tool gibt – // sonst stünde dort in jeder Zeile derselbe graue Kreis. 'bargeld' => BargeldAbgleich::eingerichtet(), + // Kommt die Seite vom Toggle „Regel anlegen“ in der Buchungsmaske, + // steht hier die frisch erzeugte Regel zum Prüfen im Dialog. + 'neueRegel' => AbgleichRegel::find((int)$this->query('neue_regel')), ]); } @@ -126,7 +130,10 @@ class BuchungenController extends BaseController $abgleich = BargeldAbgleich::pruefe($id); - View::redirect('/konten/' . $kontoId, 'Buchung angelegt.' . BargeldAbgleich::rueckmeldung($abgleich)); + View::redirect( + $this->kontoblattZiel($kontoId, $id), + 'Buchung angelegt.' . BargeldAbgleich::rueckmeldung($abgleich) + ); } /** @param array $params */ @@ -175,7 +182,7 @@ class BuchungenController extends BaseController $abgleich = BargeldAbgleich::pruefe($id); View::redirect( - '/konten/' . $buchung['konto_id'], + $this->kontoblattZiel((int)$buchung['konto_id'], $id), 'Buchung gespeichert.' . BargeldAbgleich::rueckmeldung($abgleich) ); } @@ -557,6 +564,24 @@ class BuchungenController extends BaseController return '/konten/' . $kontoId . ($query === [] ? '' : '?' . http_build_query($query)); } + /** + * Ziel nach dem Speichern einer Buchung: das Kontoblatt – mit angehängter + * neuer Regel, wenn der Toggle „Regel anlegen“ gesetzt war. Ohne Muster + * (kein Empfänger, kein Buchungstext) entsteht keine Regel; das Ziel bleibt + * dann das gewöhnliche. + */ + private function kontoblattZiel(int $kontoId, int $buchungId): string + { + $ziel = '/konten/' . $kontoId; + + if (!isset($_POST['regel_anlegen'])) { + return $ziel; + } + + $regelId = AbgleichRegel::ausBuchung($buchungId); + return $regelId === null ? $ziel : $ziel . '?neue_regel=' . $regelId; + } + /** @param array|null $konto */ private function maske(?array $konto, ?array $buchung): void { diff --git a/src/Models/AbgleichRegel.php b/src/Models/AbgleichRegel.php index cf6ea49..05d7bfa 100644 --- a/src/Models/AbgleichRegel.php +++ b/src/Models/AbgleichRegel.php @@ -100,6 +100,69 @@ class AbgleichRegel Database::execute('DELETE FROM abgleich_regeln WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]); } + /** + * Baut aus einer bestehenden Buchung eine neue Regel – der Ausgangspunkt für + * die Buchungsmaske, die eine Buchung direkt in eine Regel verwandeln kann. + * Das Muster kommt vom Empfänger, ersatzweise vom Buchungstext; ist beides + * leer, gibt es kein sinnvolles Muster und keine Regel entsteht. + * + * @return int|null Die id der neuen Regel, oder null ohne Muster + */ + public static function ausBuchung(int $buchungId): ?int + { + $buchung = Buchung::find($buchungId); + if ($buchung === null) { + throw new \RuntimeException('Die Buchung gibt es nicht.'); + } + + $empfaenger = trim((string)($buchung['empfaenger'] ?? '')); + $zweck = trim((string)($buchung['buchungstext'] ?? '')); + + if ($empfaenger !== '') { + $feld = 'empfaenger'; + } elseif ($zweck !== '') { + $feld = 'verwendungszweck'; + } else { + return null; + } + + $splitzeilen = array_values(array_filter( + Buchung::splits($buchungId), + static fn(array $zeile): bool => (int)($zeile['kategorie_id'] ?? 0) !== 0 + )); + + // Anteile am Buchungsbetrag statt roher Beträge: So zeigt der Dialog + // gleich sinnvolle Prozentwerte statt der Centbeträge der Buchung – + // splitzeilen() liest den Anteil als Bruchteil von 1. + $gesamt = array_sum(array_map( + static fn(array $zeile): float => abs((float)$zeile['betrag']), + $splitzeilen + )); + + $splits = []; + foreach ($splitzeilen as $zeile) { + $splits[] = [ + 'kategorie_id' => (int)$zeile['kategorie_id'], + 'klasse_id' => $zeile['klasse_id'], + 'anteil' => $gesamt > 0.0 ? round(abs((float)$zeile['betrag']) / $gesamt, 4) : 0, + 'notiz' => (string)($zeile['notiz'] ?? ''), + ]; + } + + return self::anlegen([ + 'name' => $empfaenger !== '' ? $empfaenger : $zweck, + 'feld' => $feld, + 'vergleich' => 'enthaelt', + 'muster' => $empfaenger !== '' ? $empfaenger : $zweck, + 'kategorie_id' => $buchung['kategorie_id'], + 'klasse_id' => $buchung['klasse_id'], + 'gegenkonto_id' => $buchung['gegenkonto_id'], + 'splits' => $splits === [] ? null : $splits, + 'prioritaet' => 100, + 'aktiv' => 1, + ]); + } + /** * Die erste Regel, die auf den Umsatz passt. * diff --git a/src/Views/banking/regelfelder.php b/src/Views/banking/regelfelder.php new file mode 100644 index 0000000..c98dc7e --- /dev/null +++ b/src/Views/banking/regelfelder.php @@ -0,0 +1,112 @@ + $regel Vorbelegung; leeres Array für eine neue Regel + * @var list> $kategorien + * @var list> $klassen + * @var list> $konten + * @var string $idPrefix Vorsatz für die Feld-ids, damit mehrere solcher + * Formulare auf einer Seite nicht kollidieren + */ + +$id = static fn(string $name): string => $idPrefix . $name; + +$betragsfeld = static function (?string $feld) use ($regel): string { + $wert = $regel[$feld] ?? null; + return $wert === null || $wert === '' ? '' : View::h(View::betrag((float)$wert, false)); +}; +?> + + + + + + + + + + + + +

+ Groß- und Kleinschreibung spielt keine Rolle. Bei regulärer Ausdruck ohne + Trennzeichen eingeben, also Miete|Nebenkosten statt /Miete/. +

+ + +
+ + +
+

+ Mit Vorzeichen: Ausgaben sind negativ, „von −60 bis −40“ trifft Lastschriften + zwischen 40 und 60 Euro. +

+ + + + + + + + + + +

Splitbuchung (statt einer einzelnen Kategorie)

+ + + + + + + diff --git a/src/Views/banking/regeln.php b/src/Views/banking/regeln.php index f8a4129..3c33a53 100644 --- a/src/Views/banking/regeln.php +++ b/src/Views/banking/regeln.php @@ -151,74 +151,8 @@ include __DIR__ . '/../layout/kopf.php';

Neue Regel

- - - - - - - - - - - -

- Groß- und Kleinschreibung spielt keine Rolle. Bei regulärer Ausdruck ohne - Trennzeichen eingeben, also Miete|Nebenkosten statt /Miete/. -

- - -
- - -
-

- Mit Vorzeichen: Ausgaben sind negativ, „von −60 bis −40“ trifft Lastschriften - zwischen 40 und 60 Euro. -

- - - - - - - - - - -

Splitbuchung (statt einer einzelnen Kategorie)

- - - - - - - + 1]; $idPrefix = 'neu_'; ?> +
diff --git a/src/Views/buchungen/formular.php b/src/Views/buchungen/formular.php index a5c71d2..31995f2 100644 --- a/src/Views/buchungen/formular.php +++ b/src/Views/buchungen/formular.php @@ -144,6 +144,14 @@ include __DIR__ . '/../layout/kopf.php';
+
+ +
+ > $konten */ /** @var array>> $splits */ /** @var bool $bargeld Ist ein Bargeld-Tool eingerichtet? */ +/** @var array|null $neueRegel Aus der Buchungsmaske erzeugte Regel, zum Prüfen im Dialog */ $kategorieNamen = []; foreach ($kategorien as $k) { @@ -584,4 +585,35 @@ include __DIR__ . '/../layout/kopf.php'; + + + +
+ + +

Regel anlegen

+

+ Aus dieser Buchung wurde diese Regel erzeugt. Vor dem Speichern lässt sie + sich noch anpassen; „Abbruch“ verwirft sie wieder. +

+ + + + +
+ + +
+
+
+ + + + diff --git a/tests/51_RegelAusBuchungTest.php b/tests/51_RegelAusBuchungTest.php new file mode 100644 index 0000000..fc231c0 --- /dev/null +++ b/tests/51_RegelAusBuchungTest.php @@ -0,0 +1,161 @@ +mandantAnlegen(); + $this->stammdaten(); + + $this->uebernimmtEmpfaengerKategorieUndKlasse(); + $this->weichtOhneEmpfaengerAufDenBuchungstextAus(); + $this->ohneEmpfaengerUndBuchungstextEntstehtKeineRegel(); + $this->splitbuchungBekommtVerhaeltnisgleicheAnteile(); + $this->unbekannteBuchungWirdAbgewiesen(); + } + + private function stammdaten(): void + { + $this->giro = Konto::anlegen([ + 'name' => 'Regeltest Giro', 'kontoart' => 'bank', 'anfangssaldo' => 1000, 'aktiv' => 1, + ]); + $this->strom = Kategorie::anlegen(['name' => 'Regeltest Strom', 'art' => 'ausgabe']); + $this->versicherung = Kategorie::anlegen(['name' => 'Regeltest Versicherung', 'art' => 'ausgabe']); + $this->klasse = Klasse::anlegen('Regeltest Privat'); + } + + private function uebernimmtEmpfaengerKategorieUndKlasse(): void + { + $buchungId = Buchung::anlegen([ + 'konto_id' => $this->giro, + 'datum' => '2026-05-01', + 'betrag' => -42.50, + 'empfaenger' => 'Stadtwerke Strom', + 'buchungstext' => 'Abschlag Mai', + 'kategorie_id' => $this->strom, + 'klasse_id' => $this->klasse, + ]); + + $regelId = AbgleichRegel::ausBuchung($buchungId); + $this->assertNotEmpty($regelId, 'Die Regel wird angelegt'); + + $regel = AbgleichRegel::find((int)$regelId); + $this->assertNotEmpty($regel, 'Sie lässt sich wiederfinden'); + $this->assertEquals('empfaenger', $regel['feld'], 'Geprüft wird der Empfänger'); + $this->assertEquals('Stadtwerke Strom', $regel['muster'], 'Das Muster ist der Empfänger'); + $this->assertEquals('enthaelt', $regel['vergleich'], 'Der Vergleich ist „enthält“'); + $this->assertEquals($this->strom, (int)$regel['kategorie_id'], 'Die Kategorie wird übernommen'); + $this->assertEquals($this->klasse, (int)$regel['klasse_id'], 'Die Klasse wird übernommen'); + $this->assertEquals(1, (int)$regel['aktiv'], 'Die Regel ist gleich aktiv'); + + // Und sie taucht in der Liste der bestehenden Regeln auf – ergänzt, nicht + // ersetzt. + $gefunden = false; + foreach (AbgleichRegel::alle() as $eintrag) { + $gefunden = $gefunden || (int)$eintrag['id'] === (int)$regelId; + } + $this->assert($gefunden, 'Die neue Regel steht in der Liste der bestehenden Regeln'); + + // Und sie trifft tatsächlich auf einen passenden Umsatz. + $this->assert( + AbgleichRegel::passt($regel, ['betrag' => -42.50, 'empfaenger' => 'Stadtwerke Strom AG']), + 'Die Regel trifft einen ähnlichen Umsatz' + ); + } + + private function weichtOhneEmpfaengerAufDenBuchungstextAus(): void + { + $buchungId = Buchung::anlegen([ + 'konto_id' => $this->giro, + 'datum' => '2026-05-02', + 'betrag' => -15.00, + 'buchungstext' => 'Parkgebühr Innenstadt', + ]); + + $regelId = AbgleichRegel::ausBuchung($buchungId); + $regel = AbgleichRegel::find((int)$regelId); + + $this->assertEquals('verwendungszweck', $regel['feld'], 'Ohne Empfänger zählt der Buchungstext'); + $this->assertEquals('Parkgebühr Innenstadt', $regel['muster'], 'Das Muster ist der Buchungstext'); + } + + private function ohneEmpfaengerUndBuchungstextEntstehtKeineRegel(): void + { + $buchungId = Buchung::anlegen([ + 'konto_id' => $this->giro, + 'datum' => '2026-05-03', + 'betrag' => -5.00, + ]); + + $this->assertNull(AbgleichRegel::ausBuchung($buchungId), 'Ohne Muster entsteht keine Regel'); + } + + private function splitbuchungBekommtVerhaeltnisgleicheAnteile(): void + { + $buchungId = Buchung::anlegen( + [ + 'konto_id' => $this->giro, + 'datum' => '2026-05-04', + 'betrag' => -80.00, + 'empfaenger' => 'Sammelrechnung Versicherung', + ], + [ + ['kategorie_id' => $this->versicherung, 'betrag' => -50.00, 'notiz' => 'Kfz'], + ['kategorie_id' => $this->versicherung, 'betrag' => -30.00, 'notiz' => 'Haftpflicht'], + ] + ); + + $regelId = AbgleichRegel::ausBuchung($buchungId); + $regel = AbgleichRegel::find((int)$regelId); + + $zeilen = AbgleichRegel::splitzeilen($regel); + $this->assertEquals(2, count($zeilen), 'Beide Splitzeilen kommen in der Regel an'); + $this->assertEquals(62.5, $zeilen[0]['prozent'], 'Anteil der ersten Zeile im selben Verhältnis wie die Buchung'); + $this->assertEquals(37.5, $zeilen[1]['prozent'], 'Anteil der zweiten Zeile ebenso'); + + // anwenden() rechnet die Anteile auf jeden Buchungsbetrag um – hier zur + // Probe auf einen doppelt so hohen Umsatz. + $angewendet = AbgleichRegel::anwenden($regel, ['betrag' => -160.00]); + $this->assertEquals(-100.0, $angewendet['splits'][0]['betrag'], 'Erste Zeile bleibt im Verhältnis 62,5 %'); + $this->assertEquals(-60.0, $angewendet['splits'][1]['betrag'], 'Zweite Zeile bleibt im Verhältnis 37,5 %'); + } + + private function unbekannteBuchungWirdAbgewiesen(): void + { + $this->assert( + $this->wirftFehler(fn() => AbgleichRegel::ausBuchung(PHP_INT_MAX)), + 'Eine nicht vorhandene Buchung wird abgewiesen' + ); + } + + /** Hilfsmittel: Wirft der Aufruf eine Ausnahme? */ + private function wirftFehler(callable $aufruf): bool + { + try { + $aufruf(); + } catch (\Throwable) { + return true; + } + + return false; + } +} diff --git a/tests/e2e/specs/regelausbuchung.spec.js b/tests/e2e/specs/regelausbuchung.spec.js new file mode 100644 index 0000000..e5d1a1b --- /dev/null +++ b/tests/e2e/specs/regelausbuchung.spec.js @@ -0,0 +1,138 @@ +// @ts-check +const { test, expect } = require('@playwright/test') + +const BENUTZER = process.env.E2E_USER || 'e2e' +const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort' + +const MARKE = Date.now().toString().slice(-6) +const GIRO = `E2E RegelBuchung ${MARKE}` +const KATEGORIE = `E2E RegelBuchung Kategorie ${MARKE}` + +async function anmelden(page) { + await page.goto('/login') + await page.fill('#benutzername', BENUTZER) + await page.fill('#passwort', PASSWORT) + await page.click('button[type="submit"]') + await expect(page).toHaveURL(/\/$/) +} + +// Fix #94: Aus der Buchungsmaske heraus lässt sich mit dem Toggle +// „Regel anlegen“ direkt eine Regel aus der gerade gespeicherten Buchung +// erzeugen; ein Dialog zeigt sie danach zum Prüfen. +test.describe('Regel aus einer Buchung anlegen', () => { + let giroId = null + + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage() + await anmelden(page) + + await page.goto('/konten/neu') + await page.fill('#name', GIRO) + await page.fill('#anfangssaldo', '1.000,00') + await page.click('.formular button[type="submit"]') + await expect(page).toHaveURL(/\/konten\/\d+\/bearbeiten/) + giroId = page.url().match(/\/konten\/(\d+)\//)[1] + + await page.goto('/verwaltung/kategorien/neu') + await page.fill('#name', KATEGORIE) + await page.selectOption('#art', 'ausgabe') + await page.click('.formular button[type="submit"]') + + await page.close() + }) + + test.beforeEach(async ({ page }) => { + await anmelden(page) + }) + + test('Toggle ist standardmäßig aus – ohne ihn ändert sich nichts am bisherigen Ablauf', async ({ page }) => { + await page.goto(`/konten/${giroId}/buchung`) + await expect(page.locator('input[name="regel_anlegen"]')).not.toBeChecked() + + await page.fill('#datum', '2026-05-01') + await page.fill('#betrag', '12,00') + await page.fill('#empfaenger', `Ohne Regel ${MARKE}`) + await page.click('.buchungsmaske button[type="submit"]') + + await expect(page.locator('.meldung-erfolg')).toBeVisible() + await expect(page.locator('#regeldialog')).toHaveCount(0) + }) + + test('Regel anlegen öffnet den Dialog mit den Buchungsdaten; Abbruch verwirft die Regel wieder', async ({ page }) => { + const empfaenger = `Abbruchregel ${MARKE}` + + await page.goto(`/konten/${giroId}/buchung`) + await page.fill('#datum', '2026-05-02') + await page.fill('#betrag', '20,00') + await page.fill('#empfaenger', empfaenger) + await page.selectOption('#zuordnung', { label: KATEGORIE }) + await page.check('input[name="regel_anlegen"]') + await page.click('.buchungsmaske button[type="submit"]') + + await expect(page.locator('.meldung-erfolg')).toBeVisible() + + const dialog = page.locator('#regeldialog') + await expect(dialog).toBeVisible() + await expect(dialog.locator('input[name="name"]')).toHaveValue(empfaenger) + await expect(dialog.locator('input[name="muster"]')).toHaveValue(empfaenger) + await expect(dialog.locator('select[name="kategorie_id"]')).toHaveValue(/^\d+$/) + + await dialog.getByRole('button', { name: 'Abbruch' }).click() + + await expect(page).toHaveURL(new RegExp(`/konten/${giroId}$`)) + await expect(page.locator('#regeldialog')).toHaveCount(0) + + // Es darf keine neu angelegte Regel zurückbleiben. Der Name steht im + // Wert eines Eingabefelds, nicht im Text der Zeile – toContainText + // träfe ihn also gar nicht erst. + await page.goto('/banking/regeln') + await expect(page.locator(`input[name="name"][value="${empfaenger}"]`)).toHaveCount(0) + }) + + test('Speichern im Dialog übernimmt Änderungen und behält die Regel', async ({ page }) => { + const empfaenger = `Speicherregel ${MARKE}` + const angepasstesMuster = `Speicherregel ${MARKE} angepasst` + + await page.goto(`/konten/${giroId}/buchung`) + await page.fill('#datum', '2026-05-03') + await page.fill('#betrag', '30,00') + await page.fill('#empfaenger', empfaenger) + await page.selectOption('#zuordnung', { label: KATEGORIE }) + await page.check('input[name="regel_anlegen"]') + await page.click('.buchungsmaske button[type="submit"]') + + const dialog = page.locator('#regeldialog') + await expect(dialog).toBeVisible() + + await dialog.locator('input[name="muster"]').fill(angepasstesMuster) + await dialog.getByRole('button', { name: 'Speichern' }).click() + + await expect(page).toHaveURL(new RegExp(`/konten/${giroId}$`)) + await expect(page.locator('#regeldialog')).toHaveCount(0) + + // Der Name blieb unverändert; über ihn findet sich die Zeile wieder – + // das Muster steckt wie der Name im Wert eines Eingabefelds. + await page.goto('/banking/regeln') + const zeile = page.locator('tr').filter({ has: page.locator(`input[name="name"][value="${empfaenger}"]`) }) + await expect(zeile).toBeVisible() + await expect(zeile.locator('input[name="muster"]')).toHaveValue(angepasstesMuster) + }) + + test('Escape im Dialog verwirft die Regel wie ein Klick auf Abbruch', async ({ page }) => { + const empfaenger = `Escaperegel ${MARKE}` + + await page.goto(`/konten/${giroId}/buchung`) + await page.fill('#datum', '2026-05-04') + await page.fill('#betrag', '40,00') + await page.fill('#empfaenger', empfaenger) + await page.check('input[name="regel_anlegen"]') + await page.click('.buchungsmaske button[type="submit"]') + + await expect(page.locator('#regeldialog')).toBeVisible() + await page.keyboard.press('Escape') + + await expect(page).toHaveURL(new RegExp(`/konten/${giroId}$`)) + await page.goto('/banking/regeln') + await expect(page.locator(`input[name="name"][value="${empfaenger}"]`)).toHaveCount(0) + }) +}) From 619e427a3087759ad52814c1e39674a6ac7820d8 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 29 Sep 2026 23:34:27 +0200 Subject: [PATCH 2/3] Fix #96: Splitbuchungen zeigen Restbetrag, Speichern nur bei Null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Summenzeile der Splitbuchungen (Buchungsmaske und Splitdialog im Kontoblatt) zeigt jetzt den noch offenen Restbetrag zum Buchungswert statt der Summe der Zeilen. „Speichern" bleibt gesperrt, solange Splitzeilen befüllt sind und der Restbetrag nicht Null ist; ohne Splitzeilen (auch mit direkter Kategorie/Gegenkonto) bleibt der Knopf wie bisher an. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 3 +++ Changes.md | 14 +++++++++++ public/static/betrag.js | 6 +++-- public/static/buchungsmaske.js | 12 ++++++++++ public/static/kontoblatt.js | 7 ++++++ src/Views/buchungen/formular.php | 2 +- src/Views/buchungen/kontoblatt.php | 2 +- tests/e2e/specs/buchungen.spec.js | 37 ++++++++++++++++++++++++------ 8 files changed, 72 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8b280d..aae8dea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). - Regeln: Die Splittabellen zeigen die Spalte „Anteil“ ohne Prozentzeichen und darunter die laufend berechnete Summe der Anteile +- Buchungsmaske und Splitdialog im Kontoblatt: Die Summenzeile der Splitbuchungen zeigt den + noch offenen Restbetrag zum Buchungswert statt der Summe der Zeilen; „Speichern“ bleibt + gesperrt, solange Splitzeilen befüllt sind und der Restbetrag nicht Null ist (#96) ### Fixed diff --git a/Changes.md b/Changes.md index 1528a81..b570754 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,19 @@ # Änderungen +## Splitbuchungen: Summenzeile zeigt den Restbetrag, Speichern nur bei Null (29.09.2026) + +- Die Summenzeile der Splitbuchungen (Buchungsmaske und Splitdialog im Kontoblatt) zeigte + bisher die Summe der erfassten Zeilen, mit „(Rest X)“ als Zusatz. Jetzt steht dort direkt + der noch offene Restbetrag zum Buchungswert (`Buchungswert - Summe der Zeilen`) – die + gemeinsame Rechenfunktion `FmBetrag.splitsumme()` in `betrag.js` liefert ihn beiden Masken. +- „Speichern“ (bzw. „Splitbuchung speichern“) bleibt gesperrt, solange mindestens eine + Splitzeile einen Betrag trägt und der Restbetrag nicht Null ist. Ohne Splitzeilen – egal ob + unbelegt oder mit direkter Kategorie/Gegenkonto zugeordnet – bleibt der Knopf wie bisher an; + das Model prüft die Summe der Splitzeilen ohnehin schon serverseitig. +- Der bisherige E2E-Test, der eine unstimmige Splitsumme über eine serverseitige Fehlermeldung + prüfte, prüft jetzt die neue Sperre im Browser; die serverseitige Prüfung selbst deckt + weiterhin Test 09 (PHP) ab. + ## Regel direkt aus einer Buchung anlegen (29.09.2026) - Neuer Toggle „Regel anlegen“ in der Buchungsmaske, standardmäßig aus; das bisherige diff --git a/public/static/betrag.js b/public/static/betrag.js index d6e08e7..dfb7ca3 100644 --- a/public/static/betrag.js +++ b/public/static/betrag.js @@ -31,7 +31,9 @@ } /* Die Summe der Splitzeilen unter `felder` gegen `ziel` – das Ergebnis geht - * so in die Anzeige, wie beide Masken es brauchen. */ + * so in die Anzeige, wie beide Masken es brauchen. Angezeigt wird der noch + * offene Restbetrag (ziel - summe), nicht die Summe der Zeilen selbst: Nur + * so sieht man auf einen Blick, was noch fehlt. */ function splitsumme(felder, ziel) { let summe = 0; let belegt = false; @@ -50,7 +52,7 @@ belegt: belegt, summe: summe, rest: rest, - text: belegt ? formatiere(summe) + (rest === 0 ? '' : ' (Rest ' + formatiere(rest) + ')') : '0,00' + text: formatiere(rest) }; } diff --git a/public/static/buchungsmaske.js b/public/static/buchungsmaske.js index ae14dbd..99510cb 100644 --- a/public/static/buchungsmaske.js +++ b/public/static/buchungsmaske.js @@ -1,6 +1,10 @@ /* Buchungsmaske: Restbetrag der Splitzeilen mitrechnen und direkte Zuordnung * und Splitbuchung gegeneinander sperren. * + * Solange Splitzeilen befüllt sind, aber noch ein Restbetrag zum + * Buchungswert offen ist, bleibt „Speichern“ gesperrt – das Model wiese die + * Buchung sonst ohnehin ab, hier spart es die Rundreise zum Server. + * * Nur Komfort – die Maske kommt fertig vom Server. Ohne JavaScript bleibt alles * bedienbar: Die Splitsumme prüft ohnehin das Model. * @@ -26,6 +30,7 @@ const summenfeld = document.getElementById('splitsumme'); const betragsfeld = document.getElementById('betrag'); + const speichernknopf = maske.querySelector('button[type="submit"]'); function rechneSumme() { if (!summenfeld) { @@ -46,6 +51,13 @@ summenfeld.textContent = stand.text; summenfeld.classList.toggle('negativ', stand.belegt && stand.rest !== 0); + + // Gesperrt nur, solange tatsächlich Splitzeilen mit Beträgen stehen und + // deren Summe den Buchungswert verfehlt. Eine direkte Zuordnung (oder + // gar keine Eingabe) deckt den ganzen Betrag stets ab. + if (speichernknopf) { + speichernknopf.disabled = stand.belegt && stand.rest !== 0; + } } const direktfelder = ['zuordnung', 'klasse_id'] diff --git a/public/static/kontoblatt.js b/public/static/kontoblatt.js index fe3e5cf..3a79f72 100644 --- a/public/static/kontoblatt.js +++ b/public/static/kontoblatt.js @@ -335,6 +335,7 @@ const zeilenkasten = document.getElementById('splitdialogzeilen'); const summenfeld = document.getElementById('splitdialogsumme'); const loeschknopf = document.getElementById('splitloeschen'); + const speichernknopf = dialog.querySelector('button[type="submit"].primaer'); const vorlage = zeilenkasten.innerHTML; let buchungsbetrag = 0; @@ -342,10 +343,16 @@ return Array.from(zeilenkasten.querySelectorAll('.splitbetrag')); } + // Gesperrt nur, solange Beträge stehen, deren Summe den Buchungswert + // verfehlt. Bleiben alle Zeilen leer, hebt „Splitbuchung speichern“ die + // Aufteilung auf – das bleibt erlaubt. function rechneSumme() { const stand = window.FmBetrag.splitsumme(felder(), buchungsbetrag); summenfeld.textContent = stand.text; summenfeld.classList.toggle('negativ', stand.belegt && stand.rest !== 0); + if (speichernknopf) { + speichernknopf.disabled = stand.belegt && stand.rest !== 0; + } } /* Eine weitere Zeile: die letzte kopieren, leeren und neu durchnummerieren. diff --git a/src/Views/buchungen/formular.php b/src/Views/buchungen/formular.php index 31995f2..53681a0 100644 --- a/src/Views/buchungen/formular.php +++ b/src/Views/buchungen/formular.php @@ -205,7 +205,7 @@ include __DIR__ . '/../layout/kopf.php'; - Summe der Zeilen / Restbetrag + Restbetrag 0,00 diff --git a/src/Views/buchungen/kontoblatt.php b/src/Views/buchungen/kontoblatt.php index 630b675..2dc500a 100644 --- a/src/Views/buchungen/kontoblatt.php +++ b/src/Views/buchungen/kontoblatt.php @@ -507,7 +507,7 @@ include __DIR__ . '/../layout/kopf.php'; - Summe der Zeilen / Restbetrag + Restbetrag 0,00 diff --git a/tests/e2e/specs/buchungen.spec.js b/tests/e2e/specs/buchungen.spec.js index 86e5f3e..464046d 100644 --- a/tests/e2e/specs/buchungen.spec.js +++ b/tests/e2e/specs/buchungen.spec.js @@ -286,16 +286,25 @@ test.describe('Buchungen', () => { await expect(dialog).toBeVisible() await expect(dialog).toContainText('-90,00') + const dialogSpeichern = dialog.locator('button[type="submit"].primaer') + await dialog.locator('select[name="split[0][zuordnung]"]').selectOption({ label: KATEGORIE }) await dialog.locator('input[name="split[0][betrag]"]').fill('-50') + + // Solange der Restbetrag offen ist, zeigt die Summenzeile ihn an und + // „Splitbuchung speichern“ bleibt gesperrt. + await expect(page.locator('#splitdialogsumme')).toHaveText('-40,00') + await expect(dialogSpeichern).toBeDisabled() + await dialog.locator('select[name="split[1][zuordnung]"]').selectOption({ label: `[${SPAR}]` }) await dialog.locator('input[name="split[1][betrag]"]').fill('-40') // Der Dialog rechnet den Restbetrag mit; er muss aufgehen. - await expect(page.locator('#splitdialogsumme')).not.toContainText('Rest') + await expect(page.locator('#splitdialogsumme')).toHaveText('0,00') + await expect(dialogSpeichern).toBeEnabled() // Der Dialog hat zwei Submit-Knöpfe: Speichern und Löschen. - await dialog.locator('button[type="submit"].primaer').click() + await dialogSpeichern.click() await expect(page.locator('.meldung-erfolg').last()).toContainText('Splitbuchung') await expect(page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` }).first()) @@ -494,21 +503,35 @@ test.describe('Buchungen', () => { .not.toHaveAttribute('title') }) - test('Splitzeilen, die nicht aufgehen, werden abgewiesen', async ({ page }) => { + test('Splitzeilen, die nicht aufgehen, sperren das Speichern', async ({ page }) => { await page.goto(`/konten/${giroId}/buchung`) + const speichern = page.locator('.buchungsmaske button[type="submit"]') await page.fill('#datum', '2026-05-03') await page.fill('#betrag', '100') await page.fill('#empfaenger', `Krumm ${MARKE}`) + + // Ohne jede Splitzeile bleibt der Button an – die Buchung ist noch keine + // Splitbuchung, auch wenn die Summenzeile schon den vollen Betrag zeigt. + await expect(page.locator('#splitsumme')).toHaveText('-100,00') + await expect(speichern).toBeEnabled() + await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE }) await page.fill('input[name="split[0][betrag]"]', '-60') - await expect(page.locator('#splitsumme')).toContainText('Rest') + // Die Summenzeile zeigt den noch offenen Restbetrag, nicht die Summe der + // Zeilen – und „Speichern“ ist gesperrt, solange er nicht Null ist. + await expect(page.locator('#splitsumme')).toHaveText('-40,00') + await expect(speichern).toBeDisabled() - await page.click('.buchungsmaske button[type="submit"]') + await page.selectOption('select[name="split[1][zuordnung]"]', { label: KATEGORIE }) + await page.fill('input[name="split[1][betrag]"]', '-40') - await expect(page.locator('.meldung-fehler')).toBeVisible() - await expect(page.locator('.meldung-fehler')).toContainText('Splitzeilen') + await expect(page.locator('#splitsumme')).toHaveText('0,00') + await expect(speichern).toBeEnabled() + + await speichern.click() + await expect(page.locator('.meldung-erfolg')).toBeVisible() }) test('Kontoblatt lässt sich durchsuchen', async ({ page }) => { From 860e71d1e773639fc14284f4027cc0ea35131232 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 29 Sep 2026 23:55:18 +0200 Subject: [PATCH 3/3] Fix #95: Splitzeilen bleiben bei falscher Summe erhalten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eine falsche Splitsumme zeigte zwar die Fehlermeldung, leitete aber auf eine frische Buchungsmaske weiter und riss dabei alle gerade erfassten Splitzeilen (und übrigen Feldwerte) heraus, obwohl in der Datenbank nichts geändert wurde. anlegen()/speichern() zeichnen die Maske bei einem Validierungsfehler jetzt direkt mit den zuletzt abgeschickten Werten neu, statt wegzuleiten. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 3 + Changes.md | 17 +++++ src/Controllers/BuchungenController.php | 31 +++++++-- src/View.php | 5 +- src/Views/buchungen/formular.php | 89 ++++++++++++++++++++----- tests/e2e/specs/buchungen.spec.js | 47 +++++++++++++ 6 files changed, 168 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aae8dea..9ffff5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). ### Fixed +- Buchungsmaske: Eine falsche Splitsumme zeigte zwar die Fehlermeldung, riss aber über die + Weiterleitung zur leeren Maske alle gerade erfassten Splitzeilen heraus; jetzt bleibt die + Maske mit den eigenen Eingaben stehen, bis die Summe stimmt (#95) - Echtzeitüberweisung scheiterte an Banken, die nur das ISO-Format pain.001.001.09 führen; ein Zusammentreffen von Namensabgleich-Volltreffer und TAN-Anfrage in einer Antwort ließ auch die gewöhnliche Überweisung mit einer rohen Bibliotheksmeldung scheitern (#89) diff --git a/Changes.md b/Changes.md index b570754..9abfcab 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,22 @@ # Änderungen +## Buchungsmaske: Splitzeilen bleiben bei falscher Summe erhalten (29.09.2026) + +- Scheiterte das Speichern einer Buchung an der Summenprüfung der Splitzeilen (Fehlermeldung + „Die Splitzeilen ergeben …, die Buchung lautet über …“), leitete `BuchungenController` bisher + mit einer Flash-Meldung auf eine frische Maske weiter – die gerade erfassten Splitzeilen (und + alle anderen Feldwerte) waren damit weg, obwohl in der Datenbank nichts geändert wurde: Die + Validierung läuft schon vor der Transaktion, es geht nichts kaputt, nur die Eingabe. +- `BuchungenController::anlegen()`/`speichern()` zeichnen die Maske jetzt bei einem + Validierungsfehler direkt neu, mit den zuletzt abgeschickten Werten (`$_POST`) statt der + gespeicherten – die Fehlermeldung erscheint wie gewohnt über `View::flash()`, die + eigenen Eingaben bleiben stehen und lassen sich korrigieren. `View::render()` erlaubt dafür + jetzt, dass ein Aufruf zusätzlich zur vorgemerkten Flash-Meldung eine eigene mitgibt, statt + sie zu überschreiben. +- Neu: `tests/e2e/specs/buchungen.spec.js` prüft, dass eine unstimmige Splitsumme die Eingaben + (Datum, Betrag, Empfänger, beide Splitzeilen) unverändert lässt und sich nach einer Korrektur + wie gewohnt speichern lässt. + ## Splitbuchungen: Summenzeile zeigt den Restbetrag, Speichern nur bei Null (29.09.2026) - Die Summenzeile der Splitbuchungen (Buchungsmaske und Splitdialog im Kontoblatt) zeigte diff --git a/src/Controllers/BuchungenController.php b/src/Controllers/BuchungenController.php index 67ef088..59d0cb7 100644 --- a/src/Controllers/BuchungenController.php +++ b/src/Controllers/BuchungenController.php @@ -118,7 +118,11 @@ class BuchungenController extends BaseController [$daten, $splits] = $this->formulardaten($kontoId); $id = Buchung::anlegen($daten, $splits); } catch (\Throwable $e) { - View::redirect('/konten/' . $kontoId . '/buchung', $e->getMessage(), 'fehler'); + // Keine Weiterleitung: Die schickte den Browser auf eine neue, + // leere Maske und riss damit die gerade eingegebenen Splitzeilen + // heraus. Stattdessen zeichnet dieselbe Anfrage die Maske mit den + // eigenen Eingaben neu (Fix #95). + $this->maske(Konto::find($kontoId), null, $_POST, $e->getMessage()); return; } @@ -166,7 +170,9 @@ class BuchungenController extends BaseController [$daten, $splits] = $this->formulardaten((int)$buchung['konto_id']); Buchung::speichern($id, $daten, $splits); } catch (\Throwable $e) { - View::redirect('/buchungen/' . $id, $e->getMessage(), 'fehler'); + // Wie in anlegen(): die Maske bleibt mit den eigenen Eingaben + // stehen, statt sie über eine Weiterleitung zu verlieren (Fix #95). + $this->maske(Konto::find((int)$buchung['konto_id']), $buchung, $_POST, $e->getMessage()); return; } @@ -582,23 +588,36 @@ class BuchungenController extends BaseController return $regelId === null ? $ziel : $ziel . '?neue_regel=' . $regelId; } - /** @param array|null $konto */ - private function maske(?array $konto, ?array $buchung): void + /** + * @param array|null $konto + * @param array|null $buchung + * @param array|null $eingaben die zuletzt abgeschickten + * Formularwerte ($_POST), sobald das Speichern gerade an einer + * Validierung gescheitert ist – die Maske zeigt dann diese statt der + * gespeicherten Werte, damit nichts verloren geht (Fix #95). + */ + private function maske(?array $konto, ?array $buchung, ?array $eingaben = null, string $fehler = ''): void { if ($konto === null) { $this->nichtGefunden('/konten'); return; } - $this->render('buchungen/formular.php', [ + $daten = [ 'titel' => $buchung === null ? 'Neue Buchung' : 'Buchung bearbeiten', 'konto' => $konto, 'buchung' => $buchung, 'splits' => $buchung === null ? [] : Buchung::splits((int)$buchung['id']), + 'eingaben' => $eingaben, 'kategorien' => Kategorie::baumFlach(), 'klassen' => Klasse::alle(), 'konten' => Konto::alle(), - ]); + ]; + if ($fehler !== '') { + $daten['flash'] = [['typ' => 'fehler', 'meldung' => $fehler]]; + } + + $this->render('buchungen/formular.php', $daten); } /** diff --git a/src/View.php b/src/View.php index cdb171d..63fb638 100644 --- a/src/View.php +++ b/src/View.php @@ -22,7 +22,10 @@ class View public function render(string $template, array $daten = []): void { - $daten['flash'] = $this->flash; + // Eine Meldung, die der Aufrufer selbst mitgibt (statt über die Session), + // zeigt sich zusätzlich zu vorgemerkten – etwa ein Validierungsfehler, der + // die Maske ohne Weiterleitung neu zeichnet und dabei die Eingaben behält. + $daten['flash'] = array_merge($this->flash, $daten['flash'] ?? []); $daten['aktuellerPfad'] = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); extract($daten, EXTR_SKIP); diff --git a/src/Views/buchungen/formular.php b/src/Views/buchungen/formular.php index 53681a0..054d2bf 100644 --- a/src/Views/buchungen/formular.php +++ b/src/Views/buchungen/formular.php @@ -5,14 +5,17 @@ use Finanzmanager\View; /** @var array $konto */ /** @var array|null $buchung */ /** @var list> $splits */ +/** @var array|null $eingaben die zuletzt abgeschickten Formularwerte + * ($_POST), sobald das Speichern gerade an einer Validierung gescheitert ist */ /** @var list> $kategorien */ /** @var list> $klassen */ /** @var list> $konten */ /** @var string $csrfToken */ -$neu = $buchung === null; -$ziel = $neu ? '/konten/' . (int)$konto['id'] . '/buchung' : '/buchungen/' . (int)$buchung['id']; -$betrag = $neu ? 0.0 : (float)$buchung['betrag']; +$neu = $buchung === null; +$ziel = $neu ? '/konten/' . (int)$konto['id'] . '/buchung' : '/buchungen/' . (int)$buchung['id']; +$betrag = $neu ? 0.0 : (float)$buchung['betrag']; +$eingaben = $eingaben ?? null; // Die Art bestimmt nur noch die Richtung. Ob die Buchung eine Umbuchung ist, // steht im Feld Kategorie / Gegenkonto. @@ -29,6 +32,32 @@ $zuordnungswert = static function (?array $zeile): string { return ''; }; +// Die Werte, die die Felder zeigen: normalerweise die gespeicherte Buchung +// (oder eine leere Maske), nach einem gescheiterten Speichern aber die zuletzt +// abgeschickten Eingaben – unverändert, auch wenn sie noch fehlerhaft sind +// (Fix #95). Roh übernommen statt neu formatiert, damit z. B. ein angefangener +// Betrag wie „12,“ stehen bleibt, wie ihn die Person zuletzt getippt hat. +$datumWert = $neu ? date('Y-m-d') : substr((string)$buchung['datum'], 0, 10); +$betragWert = $neu ? '' : View::betrag(abs($betrag), false); +$empfaengerWert = (string)($buchung['empfaenger'] ?? ''); +$zuordnungWert = $zuordnungswert($buchung); +$klasseWert = (int)($buchung['klasse_id'] ?? 0); +$buchungstextWert = (string)($buchung['buchungstext'] ?? ''); +$nummerWert = (string)($buchung['nummer'] ?? ''); +$abgeglichenWert = !empty($buchung['abgeglichen']); + +if ($eingaben !== null) { + $art = ($eingaben['art'] ?? '') === 'einnahme' ? 'einnahme' : 'ausgabe'; + $datumWert = (string)($eingaben['datum'] ?? ''); + $betragWert = (string)($eingaben['betrag'] ?? ''); + $empfaengerWert = (string)($eingaben['empfaenger'] ?? ''); + $zuordnungWert = (string)($eingaben['zuordnung'] ?? ''); + $klasseWert = (int)($eingaben['klasse_id'] ?? 0); + $buchungstextWert = (string)($eingaben['buchungstext'] ?? ''); + $nummerWert = (string)($eingaben['nummer'] ?? ''); + $abgeglichenWert = isset($eingaben['abgeglichen']); +} + $eigenesKonto = (int)$konto['id']; $mitSplit = false; $mitDepot = true; @@ -42,10 +71,37 @@ $kontozusatz = trim((string)($konto['iban'] ?? '')) !== '' trim((string)($konto['kontonummer'] ?? '')), ]))); +// Jede Zeile auf dieselbe Form bringen (Zuordnung als fertiger Auswahlwert, +// Betrag als anzuzeigender Text) – so braucht es unten nur eine Fallunterscheidung, +// egal ob die Zeilen aus der Datenbank oder aus gescheiterten Eingaben stammen. +if ($eingaben !== null) { + $splitPosten = is_array($eingaben['split'] ?? null) ? $eingaben['split'] : []; + $splitZeilen = []; + foreach ($splitPosten as $zeile) { + if (!is_array($zeile)) { + continue; + } + $splitZeilen[] = [ + 'zuordnung' => (string)($zeile['zuordnung'] ?? ''), + 'klasse_id' => $zeile['klasse_id'] ?? null, + 'notiz' => (string)($zeile['notiz'] ?? ''), + 'betrag' => (string)($zeile['betrag'] ?? ''), + ]; + } +} else { + $splitZeilen = array_map(static function (array $zeile) use ($zuordnungswert): array { + return [ + 'zuordnung' => $zuordnungswert($zeile), + 'klasse_id' => $zeile['klasse_id'] ?? null, + 'notiz' => (string)($zeile['notiz'] ?? ''), + 'betrag' => ($zeile['betrag'] ?? '') === '' ? '' : View::betrag($zeile['betrag'], false), + ]; + }, $splits); +} + // Immer ein paar leere Splitzeilen anbieten; weitere fügt der Knopf hinzu. -$splitZeilen = $splits; for ($i = count($splitZeilen); $i < 4; $i++) { - $splitZeilen[] = ['kategorie_id' => null, 'klasse_id' => null, 'betrag' => '', 'notiz' => '']; + $splitZeilen[] = ['zuordnung' => '', 'klasse_id' => null, 'betrag' => '', 'notiz' => '']; } include __DIR__ . '/../layout/kopf.php'; @@ -89,25 +145,25 @@ include __DIR__ . '/../layout/kopf.php';
+ value="">
+ value="">
+ value="">
@@ -118,7 +174,7 @@ include __DIR__ . '/../layout/kopf.php'; @@ -128,18 +184,18 @@ include __DIR__ . '/../layout/kopf.php';
+ value="">
- +
@@ -179,7 +235,7 @@ include __DIR__ . '/../layout/kopf.php'; - + @@ -194,11 +250,10 @@ include __DIR__ . '/../layout/kopf.php'; - + + name="split[][betrag]" value=""> diff --git a/tests/e2e/specs/buchungen.spec.js b/tests/e2e/specs/buchungen.spec.js index 464046d..cb99409 100644 --- a/tests/e2e/specs/buchungen.spec.js +++ b/tests/e2e/specs/buchungen.spec.js @@ -381,6 +381,53 @@ test.describe('Buchungen', () => { expect(titel).toContain(`${KATEGORIE}: -97,98`) }) + test('Falsche Splitsumme behält die eingegebenen Splitzeilen (Fix #95)', async ({ page }) => { + await page.goto(`/konten/${giroId}/buchung`) + + await page.fill('#datum', '2026-06-01') + await page.fill('#betrag', '100') + await page.fill('#empfaenger', `Falschsumme ${MARKE}`) + + await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE }) + await page.fill('input[name="split[0][notiz]"]', 'Erster Teil') + await page.fill('input[name="split[0][betrag]"]', '-30') + + await page.selectOption('select[name="split[1][zuordnung]"]', { label: KATEGORIE }) + await page.fill('input[name="split[1][notiz]"]', 'Zweiter Teil') + await page.fill('input[name="split[1][betrag]"]', '-50') + + const zuordnungWert = await page.locator('select[name="split[0][zuordnung]"]').inputValue() + + // -30 und -50 ergeben nicht die -100 der Buchung: buchungsmaske.js sperrt + // den Knopf deshalb schon clientseitig. Das Formular geht hier trotzdem + // direkt ab, um die serverseitige Prüfung zu erreichen – wie es auch ohne + // JavaScript oder bei einer manipulierten Anfrage passieren würde. + await page.locator('.buchungsmaske').evaluate((formular) => formular.submit()) + + await expect(page.locator('.meldung-fehler')).toContainText('Splitzeilen ergeben') + + // Der Kern von Fix #95: Nichts muss neu erfasst werden, alle Eingaben + // stehen nach der Fehlermeldung unverändert da. + await expect(page.locator('#datum')).toHaveValue('2026-06-01') + await expect(page.locator('#betrag')).toHaveValue('100') + await expect(page.locator('#empfaenger')).toHaveValue(`Falschsumme ${MARKE}`) + await expect(page.locator('select[name="split[0][zuordnung]"]')).toHaveValue(zuordnungWert) + await expect(page.locator('input[name="split[0][notiz]"]')).toHaveValue('Erster Teil') + await expect(page.locator('input[name="split[0][betrag]"]')).toHaveValue('-30') + await expect(page.locator('select[name="split[1][zuordnung]"]')).toHaveValue(zuordnungWert) + await expect(page.locator('input[name="split[1][notiz]"]')).toHaveValue('Zweiter Teil') + await expect(page.locator('input[name="split[1][betrag]"]')).toHaveValue('-50') + + // Nach einer Korrektur lässt sich die Buchung wie gewohnt speichern. + await page.fill('input[name="split[1][betrag]"]', '-70') + await page.click('.buchungsmaske button[type="submit"]') + await expect(page.locator('.meldung-erfolg')).toBeVisible() + + const zeile = page.locator('tr', { hasText: `Falschsumme ${MARKE}` }) + await expect(zeile).toContainText('Splitbuchung') + await expect(zeile).toContainText('-100,00') + }) + test('Splitzeilen und direkte Zuordnung sperren sich gegenseitig', async ({ page }) => { await page.goto(`/konten/${giroId}/buchung`) const kategorie = page.locator('#zuordnung')