Merge pull request 'Fix #94: Regel direkt aus einer Buchung anlegen' (#97) from fds2610-bot/Finanzmanager:main into main
Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/97
This commit is contained in:
commit
19c64dc594
19 changed files with 868 additions and 109 deletions
|
|
@ -10,9 +10,15 @@ 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
|
- Regeln: Die Splittabellen zeigen die Spalte „Anteil“ ohne Prozentzeichen und darunter die
|
||||||
laufend berechnete Summe der Anteile
|
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
|
### 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;
|
- 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ß
|
ein Zusammentreffen von Namensabgleich-Volltreffer und TAN-Anfrage in einer Antwort ließ
|
||||||
auch die gewöhnliche Überweisung mit einer rohen Bibliotheksmeldung scheitern (#89)
|
auch die gewöhnliche Überweisung mit einer rohen Bibliotheksmeldung scheitern (#89)
|
||||||
|
|
|
||||||
49
Changes.md
49
Changes.md
|
|
@ -1,5 +1,54 @@
|
||||||
# Änderungen
|
# Ä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
|
||||||
|
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
|
||||||
|
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 `<dialog>` 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)
|
## Umbuchungen: beide Seiten finden zueinander (29.09.2026)
|
||||||
|
|
||||||
- Eine Umbuchung erscheint auf zwei Kontoauszügen, wurde aber nicht als eine Buchung erkannt:
|
- Eine Umbuchung erscheint auf zwei Kontoauszügen, wurde aber nicht als eine Buchung erkannt:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
## Protokoll und Diagnose
|
||||||
|
|
||||||
Jeder Abruf schreibt eine Zeile nach `bank_abrufe` mit Status, Zahlen und Verlauf – ohne das
|
Jeder Abruf schreibt eine Zeile nach `bank_abrufe` mit Status, Zahlen und Verlauf – ohne das
|
||||||
|
|
|
||||||
|
|
@ -1320,11 +1320,16 @@ table.splittabelle input.rechts { text-align: right; }
|
||||||
.formatname { font-weight: 600; font-size: 1.05rem; }
|
.formatname { font-weight: 600; font-size: 1.05rem; }
|
||||||
|
|
||||||
/* Der Wertpapierdialog trägt ein Formular und wird dadurch länger als der
|
/* 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 { max-height: 88vh; overflow-y: auto; }
|
||||||
.wertpapierdialog .formular { max-width: none; }
|
.wertpapierdialog .formular { max-width: none; }
|
||||||
.wertpapierdialog h2 { margin: 0 0 .3rem; font-size: 1.05rem; }
|
.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. */
|
/* Die Kostentabelle: Betrag rechts, Kategorie als stiller Hinweis daneben. */
|
||||||
table.kostentabelle { margin-top: .6rem; }
|
table.kostentabelle { margin-top: .6rem; }
|
||||||
table.kostentabelle td { padding: .25rem .4rem; }
|
table.kostentabelle td { padding: .25rem .4rem; }
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Die Summe der Splitzeilen unter `felder` gegen `ziel` – das Ergebnis geht
|
/* 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) {
|
function splitsumme(felder, ziel) {
|
||||||
let summe = 0;
|
let summe = 0;
|
||||||
let belegt = false;
|
let belegt = false;
|
||||||
|
|
@ -50,7 +52,7 @@
|
||||||
belegt: belegt,
|
belegt: belegt,
|
||||||
summe: summe,
|
summe: summe,
|
||||||
rest: rest,
|
rest: rest,
|
||||||
text: belegt ? formatiere(summe) + (rest === 0 ? '' : ' (Rest ' + formatiere(rest) + ')') : '0,00'
|
text: formatiere(rest)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
/* Buchungsmaske: Restbetrag der Splitzeilen mitrechnen und direkte Zuordnung
|
/* Buchungsmaske: Restbetrag der Splitzeilen mitrechnen und direkte Zuordnung
|
||||||
* und Splitbuchung gegeneinander sperren.
|
* 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
|
* Nur Komfort – die Maske kommt fertig vom Server. Ohne JavaScript bleibt alles
|
||||||
* bedienbar: Die Splitsumme prüft ohnehin das Model.
|
* bedienbar: Die Splitsumme prüft ohnehin das Model.
|
||||||
*
|
*
|
||||||
|
|
@ -26,6 +30,7 @@
|
||||||
|
|
||||||
const summenfeld = document.getElementById('splitsumme');
|
const summenfeld = document.getElementById('splitsumme');
|
||||||
const betragsfeld = document.getElementById('betrag');
|
const betragsfeld = document.getElementById('betrag');
|
||||||
|
const speichernknopf = maske.querySelector('button[type="submit"]');
|
||||||
|
|
||||||
function rechneSumme() {
|
function rechneSumme() {
|
||||||
if (!summenfeld) {
|
if (!summenfeld) {
|
||||||
|
|
@ -46,6 +51,13 @@
|
||||||
|
|
||||||
summenfeld.textContent = stand.text;
|
summenfeld.textContent = stand.text;
|
||||||
summenfeld.classList.toggle('negativ', stand.belegt && stand.rest !== 0);
|
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']
|
const direktfelder = ['zuordnung', 'klasse_id']
|
||||||
|
|
|
||||||
|
|
@ -335,6 +335,7 @@
|
||||||
const zeilenkasten = document.getElementById('splitdialogzeilen');
|
const zeilenkasten = document.getElementById('splitdialogzeilen');
|
||||||
const summenfeld = document.getElementById('splitdialogsumme');
|
const summenfeld = document.getElementById('splitdialogsumme');
|
||||||
const loeschknopf = document.getElementById('splitloeschen');
|
const loeschknopf = document.getElementById('splitloeschen');
|
||||||
|
const speichernknopf = dialog.querySelector('button[type="submit"].primaer');
|
||||||
const vorlage = zeilenkasten.innerHTML;
|
const vorlage = zeilenkasten.innerHTML;
|
||||||
let buchungsbetrag = 0;
|
let buchungsbetrag = 0;
|
||||||
|
|
||||||
|
|
@ -342,10 +343,16 @@
|
||||||
return Array.from(zeilenkasten.querySelectorAll('.splitbetrag'));
|
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() {
|
function rechneSumme() {
|
||||||
const stand = window.FmBetrag.splitsumme(felder(), buchungsbetrag);
|
const stand = window.FmBetrag.splitsumme(felder(), buchungsbetrag);
|
||||||
summenfeld.textContent = stand.text;
|
summenfeld.textContent = stand.text;
|
||||||
summenfeld.classList.toggle('negativ', stand.belegt && stand.rest !== 0);
|
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.
|
/* Eine weitere Zeile: die letzte kopieren, leeren und neu durchnummerieren.
|
||||||
|
|
|
||||||
27
public/static/regeldialog.js
Normal file
27
public/static/regeldialog.js
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -941,11 +941,11 @@ class BankingController extends BaseController
|
||||||
try {
|
try {
|
||||||
AbgleichRegel::speichern((int)$params['id'], $this->regeldaten());
|
AbgleichRegel::speichern((int)$params['id'], $this->regeldaten());
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
View::redirect('/banking/regeln', $e->getMessage(), 'fehler');
|
View::redirect($this->regelZiel(), $e->getMessage(), 'fehler');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
View::redirect('/banking/regeln', 'Regel gespeichert.');
|
View::redirect($this->regelZiel(), 'Regel gespeichert.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string,string> $params */
|
/** @param array<string,string> $params */
|
||||||
|
|
@ -955,7 +955,24 @@ class BankingController extends BaseController
|
||||||
|
|
||||||
AbgleichRegel::loeschen((int)$params['id']);
|
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';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Finanzmanager\Controllers;
|
namespace Finanzmanager\Controllers;
|
||||||
|
|
||||||
|
use Finanzmanager\Models\AbgleichRegel;
|
||||||
use Finanzmanager\Models\Buchung;
|
use Finanzmanager\Models\Buchung;
|
||||||
use Finanzmanager\Models\Depotbuchung;
|
use Finanzmanager\Models\Depotbuchung;
|
||||||
use Finanzmanager\Models\Einstellung;
|
use Finanzmanager\Models\Einstellung;
|
||||||
|
|
@ -82,6 +83,9 @@ class BuchungenController extends BaseController
|
||||||
// Die Abgleichspalte erscheint nur, wenn es ein Bargeld-Tool gibt –
|
// Die Abgleichspalte erscheint nur, wenn es ein Bargeld-Tool gibt –
|
||||||
// sonst stünde dort in jeder Zeile derselbe graue Kreis.
|
// sonst stünde dort in jeder Zeile derselbe graue Kreis.
|
||||||
'bargeld' => BargeldAbgleich::eingerichtet(),
|
'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')),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,7 +118,11 @@ class BuchungenController extends BaseController
|
||||||
[$daten, $splits] = $this->formulardaten($kontoId);
|
[$daten, $splits] = $this->formulardaten($kontoId);
|
||||||
$id = Buchung::anlegen($daten, $splits);
|
$id = Buchung::anlegen($daten, $splits);
|
||||||
} catch (\Throwable $e) {
|
} 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,7 +134,10 @@ class BuchungenController extends BaseController
|
||||||
|
|
||||||
$abgleich = BargeldAbgleich::pruefe($id);
|
$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<string,string> $params */
|
/** @param array<string,string> $params */
|
||||||
|
|
@ -159,7 +170,9 @@ class BuchungenController extends BaseController
|
||||||
[$daten, $splits] = $this->formulardaten((int)$buchung['konto_id']);
|
[$daten, $splits] = $this->formulardaten((int)$buchung['konto_id']);
|
||||||
Buchung::speichern($id, $daten, $splits);
|
Buchung::speichern($id, $daten, $splits);
|
||||||
} catch (\Throwable $e) {
|
} 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,7 +188,7 @@ class BuchungenController extends BaseController
|
||||||
$abgleich = BargeldAbgleich::pruefe($id);
|
$abgleich = BargeldAbgleich::pruefe($id);
|
||||||
|
|
||||||
View::redirect(
|
View::redirect(
|
||||||
'/konten/' . $buchung['konto_id'],
|
$this->kontoblattZiel((int)$buchung['konto_id'], $id),
|
||||||
'Buchung gespeichert.' . BargeldAbgleich::rueckmeldung($abgleich)
|
'Buchung gespeichert.' . BargeldAbgleich::rueckmeldung($abgleich)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -557,23 +570,54 @@ class BuchungenController extends BaseController
|
||||||
return '/konten/' . $kontoId . ($query === [] ? '' : '?' . http_build_query($query));
|
return '/konten/' . $kontoId . ($query === [] ? '' : '?' . http_build_query($query));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string,mixed>|null $konto */
|
/**
|
||||||
private function maske(?array $konto, ?array $buchung): void
|
* 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<string,mixed>|null $konto
|
||||||
|
* @param array<string,mixed>|null $buchung
|
||||||
|
* @param array<string,mixed>|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) {
|
if ($konto === null) {
|
||||||
$this->nichtGefunden('/konten');
|
$this->nichtGefunden('/konten');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->render('buchungen/formular.php', [
|
$daten = [
|
||||||
'titel' => $buchung === null ? 'Neue Buchung' : 'Buchung bearbeiten',
|
'titel' => $buchung === null ? 'Neue Buchung' : 'Buchung bearbeiten',
|
||||||
'konto' => $konto,
|
'konto' => $konto,
|
||||||
'buchung' => $buchung,
|
'buchung' => $buchung,
|
||||||
'splits' => $buchung === null ? [] : Buchung::splits((int)$buchung['id']),
|
'splits' => $buchung === null ? [] : Buchung::splits((int)$buchung['id']),
|
||||||
|
'eingaben' => $eingaben,
|
||||||
'kategorien' => Kategorie::baumFlach(),
|
'kategorien' => Kategorie::baumFlach(),
|
||||||
'klassen' => Klasse::alle(),
|
'klassen' => Klasse::alle(),
|
||||||
'konten' => Konto::alle(),
|
'konten' => Konto::alle(),
|
||||||
]);
|
];
|
||||||
|
if ($fehler !== '') {
|
||||||
|
$daten['flash'] = [['typ' => 'fehler', 'meldung' => $fehler]];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->render('buchungen/formular.php', $daten);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,69 @@ class AbgleichRegel
|
||||||
Database::execute('DELETE FROM abgleich_regeln WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
|
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.
|
* Die erste Regel, die auf den Umsatz passt.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,10 @@ class View
|
||||||
|
|
||||||
public function render(string $template, array $daten = []): void
|
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);
|
$daten['aktuellerPfad'] = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||||
|
|
||||||
extract($daten, EXTR_SKIP);
|
extract($daten, EXTR_SKIP);
|
||||||
|
|
|
||||||
112
src/Views/banking/regelfelder.php
Normal file
112
src/Views/banking/regelfelder.php
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Finanzmanager\Models\AbgleichRegel;
|
||||||
|
use Finanzmanager\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Eingabefelder einer Regel – einmal für die neue Regel in regeln.php,
|
||||||
|
* einmal für den Regeldialog, den die Buchungsmaske nach dem Speichern öffnet.
|
||||||
|
* Erwartet vom Aufrufer:
|
||||||
|
*
|
||||||
|
* @var array<string,mixed> $regel Vorbelegung; leeres Array für eine neue Regel
|
||||||
|
* @var list<array<string,mixed>> $kategorien
|
||||||
|
* @var list<array<string,mixed>> $klassen
|
||||||
|
* @var list<array<string,mixed>> $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));
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<label for="<?= $id('name') ?>">Name</label>
|
||||||
|
<input type="text" id="<?= $id('name') ?>" name="name" required placeholder="z. B. Stadtwerke Strom"
|
||||||
|
value="<?= View::h((string)($regel['name'] ?? '')) ?>">
|
||||||
|
|
||||||
|
<label for="<?= $id('feld') ?>">Prüfen auf</label>
|
||||||
|
<select id="<?= $id('feld') ?>" name="feld">
|
||||||
|
<?php foreach (AbgleichRegel::FELDER as $k => $v): ?>
|
||||||
|
<option value="<?= View::h($k) ?>" <?= ($regel['feld'] ?? 'beides') === $k ? 'selected' : '' ?>>
|
||||||
|
<?= View::h($v) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="<?= $id('vergleich') ?>">Vergleich</label>
|
||||||
|
<select id="<?= $id('vergleich') ?>" name="vergleich">
|
||||||
|
<?php foreach (AbgleichRegel::VERGLEICHE as $k => $v): ?>
|
||||||
|
<option value="<?= View::h($k) ?>" <?= ($regel['vergleich'] ?? 'enthaelt') === $k ? 'selected' : '' ?>>
|
||||||
|
<?= View::h($v) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="<?= $id('muster') ?>">Muster</label>
|
||||||
|
<input type="text" id="<?= $id('muster') ?>" name="muster" required
|
||||||
|
value="<?= View::h((string)($regel['muster'] ?? '')) ?>">
|
||||||
|
<p class="feldhinweis">
|
||||||
|
Groß- und Kleinschreibung spielt keine Rolle. Bei <em>regulärer Ausdruck</em> ohne
|
||||||
|
Trennzeichen eingeben, also <code>Miete|Nebenkosten</code> statt <code>/Miete/</code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label for="<?= $id('betrag_von') ?>">Betrag von / bis <span class="optional">(optional)</span></label>
|
||||||
|
<div class="feldpaar">
|
||||||
|
<input type="text" id="<?= $id('betrag_von') ?>" name="betrag_von" inputmode="decimal"
|
||||||
|
placeholder="z. B. -60" value="<?= $betragsfeld('betrag_von') ?>">
|
||||||
|
<input type="text" name="betrag_bis" inputmode="decimal"
|
||||||
|
placeholder="z. B. -40" value="<?= $betragsfeld('betrag_bis') ?>">
|
||||||
|
</div>
|
||||||
|
<p class="feldhinweis">
|
||||||
|
Mit Vorzeichen: Ausgaben sind negativ, „von −60 bis −40“ trifft Lastschriften
|
||||||
|
zwischen 40 und 60 Euro.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label for="<?= $id('kategorie_id') ?>">Kategorie</label>
|
||||||
|
<select id="<?= $id('kategorie_id') ?>" name="kategorie_id">
|
||||||
|
<option value="">– keine –</option>
|
||||||
|
<?php foreach ($kategorien as $kategorie): ?>
|
||||||
|
<option value="<?= (int)$kategorie['id'] ?>"
|
||||||
|
<?= (int)($regel['kategorie_id'] ?? 0) === (int)$kategorie['id'] ? 'selected' : '' ?>>
|
||||||
|
<?= View::h($kategorie['pfad']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="<?= $id('klasse_id') ?>">Klasse</label>
|
||||||
|
<select id="<?= $id('klasse_id') ?>" name="klasse_id">
|
||||||
|
<option value="">– keine –</option>
|
||||||
|
<?php foreach ($klassen as $klasse): ?>
|
||||||
|
<option value="<?= (int)$klasse['id'] ?>"
|
||||||
|
<?= (int)($regel['klasse_id'] ?? 0) === (int)$klasse['id'] ? 'selected' : '' ?>>
|
||||||
|
<?= View::h($klasse['name']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="<?= $id('gegenkonto_id') ?>">Gegenkonto <span class="optional">(macht daraus eine Umbuchung)</span></label>
|
||||||
|
<select id="<?= $id('gegenkonto_id') ?>" name="gegenkonto_id">
|
||||||
|
<option value="">– keines –</option>
|
||||||
|
<?php foreach ($konten as $konto): ?>
|
||||||
|
<option value="<?= (int)$konto['id'] ?>"
|
||||||
|
<?= (int)($regel['gegenkonto_id'] ?? 0) === (int)$konto['id'] ? 'selected' : '' ?>>
|
||||||
|
<?= View::h($konto['name']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<h4>Splitbuchung <span class="optional">(statt einer einzelnen Kategorie)</span></h4>
|
||||||
|
<?php $splitZeilen = AbgleichRegel::splitzeilen($regel); $splitForm = ''; ?>
|
||||||
|
<?php include __DIR__ . '/regelsplits.php'; ?>
|
||||||
|
|
||||||
|
<label for="<?= $id('prioritaet') ?>">Priorität</label>
|
||||||
|
<input type="number" id="<?= $id('prioritaet') ?>" name="prioritaet" value="<?= (int)($regel['prioritaet'] ?? 100) ?>">
|
||||||
|
|
||||||
|
<label class="ankreuzfeld">
|
||||||
|
<input type="checkbox" name="aktiv" value="1" <?= (int)($regel['aktiv'] ?? 1) === 1 ? 'checked' : '' ?>>
|
||||||
|
aktiv
|
||||||
|
</label>
|
||||||
|
|
@ -151,74 +151,8 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||||
<h3>Neue Regel</h3>
|
<h3>Neue Regel</h3>
|
||||||
|
|
||||||
<label for="name">Name</label>
|
<?php $regel = ['aktiv' => 1]; $idPrefix = 'neu_'; ?>
|
||||||
<input type="text" id="name" name="name" required placeholder="z. B. Stadtwerke Strom">
|
<?php include __DIR__ . '/regelfelder.php'; ?>
|
||||||
|
|
||||||
<label for="feld">Prüfen auf</label>
|
|
||||||
<select id="feld" name="feld">
|
|
||||||
<?php foreach (AbgleichRegel::FELDER as $k => $v): ?>
|
|
||||||
<option value="<?= View::h($k) ?>"><?= View::h($v) ?></option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label for="vergleich">Vergleich</label>
|
|
||||||
<select id="vergleich" name="vergleich">
|
|
||||||
<?php foreach (AbgleichRegel::VERGLEICHE as $k => $v): ?>
|
|
||||||
<option value="<?= View::h($k) ?>"><?= View::h($v) ?></option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label for="muster">Muster</label>
|
|
||||||
<input type="text" id="muster" name="muster" required>
|
|
||||||
<p class="feldhinweis">
|
|
||||||
Groß- und Kleinschreibung spielt keine Rolle. Bei <em>regulärer Ausdruck</em> ohne
|
|
||||||
Trennzeichen eingeben, also <code>Miete|Nebenkosten</code> statt <code>/Miete/</code>.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<label for="betrag_von">Betrag von / bis <span class="optional">(optional)</span></label>
|
|
||||||
<div class="feldpaar">
|
|
||||||
<input type="text" id="betrag_von" name="betrag_von" inputmode="decimal" placeholder="z. B. -60">
|
|
||||||
<input type="text" name="betrag_bis" inputmode="decimal" placeholder="z. B. -40">
|
|
||||||
</div>
|
|
||||||
<p class="feldhinweis">
|
|
||||||
Mit Vorzeichen: Ausgaben sind negativ, „von −60 bis −40“ trifft Lastschriften
|
|
||||||
zwischen 40 und 60 Euro.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<label for="neu_kategorie">Kategorie</label>
|
|
||||||
<select id="neu_kategorie" name="kategorie_id">
|
|
||||||
<option value="">– keine –</option>
|
|
||||||
<?php foreach ($kategorien as $kategorie): ?>
|
|
||||||
<option value="<?= (int)$kategorie['id'] ?>"><?= View::h($kategorie['pfad']) ?></option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label for="neu_klasse">Klasse</label>
|
|
||||||
<select id="neu_klasse" name="klasse_id">
|
|
||||||
<option value="">– keine –</option>
|
|
||||||
<?php foreach ($klassen as $klasse): ?>
|
|
||||||
<option value="<?= (int)$klasse['id'] ?>"><?= View::h($klasse['name']) ?></option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label for="neu_gegenkonto">Gegenkonto <span class="optional">(macht daraus eine Umbuchung)</span></label>
|
|
||||||
<select id="neu_gegenkonto" name="gegenkonto_id">
|
|
||||||
<option value="">– keines –</option>
|
|
||||||
<?php foreach ($konten as $konto): ?>
|
|
||||||
<option value="<?= (int)$konto['id'] ?>"><?= View::h($konto['name']) ?></option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<h4>Splitbuchung <span class="optional">(statt einer einzelnen Kategorie)</span></h4>
|
|
||||||
<?php $splitZeilen = []; $splitForm = ''; ?>
|
|
||||||
<?php include __DIR__ . '/regelsplits.php'; ?>
|
|
||||||
|
|
||||||
<label for="prioritaet">Priorität</label>
|
|
||||||
<input type="number" id="prioritaet" name="prioritaet" value="100">
|
|
||||||
|
|
||||||
<label class="ankreuzfeld">
|
|
||||||
<input type="checkbox" name="aktiv" value="1" checked> aktiv
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="knopfleiste">
|
<div class="knopfleiste">
|
||||||
<button type="submit" class="knopf primaer">Regel anlegen</button>
|
<button type="submit" class="knopf primaer">Regel anlegen</button>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ use Finanzmanager\View;
|
||||||
/** @var array<string,mixed> $konto */
|
/** @var array<string,mixed> $konto */
|
||||||
/** @var array<string,mixed>|null $buchung */
|
/** @var array<string,mixed>|null $buchung */
|
||||||
/** @var list<array<string,mixed>> $splits */
|
/** @var list<array<string,mixed>> $splits */
|
||||||
|
/** @var array<string,mixed>|null $eingaben die zuletzt abgeschickten Formularwerte
|
||||||
|
* ($_POST), sobald das Speichern gerade an einer Validierung gescheitert ist */
|
||||||
/** @var list<array<string,mixed>> $kategorien */
|
/** @var list<array<string,mixed>> $kategorien */
|
||||||
/** @var list<array<string,mixed>> $klassen */
|
/** @var list<array<string,mixed>> $klassen */
|
||||||
/** @var list<array<string,mixed>> $konten */
|
/** @var list<array<string,mixed>> $konten */
|
||||||
|
|
@ -13,6 +15,7 @@ use Finanzmanager\View;
|
||||||
$neu = $buchung === null;
|
$neu = $buchung === null;
|
||||||
$ziel = $neu ? '/konten/' . (int)$konto['id'] . '/buchung' : '/buchungen/' . (int)$buchung['id'];
|
$ziel = $neu ? '/konten/' . (int)$konto['id'] . '/buchung' : '/buchungen/' . (int)$buchung['id'];
|
||||||
$betrag = $neu ? 0.0 : (float)$buchung['betrag'];
|
$betrag = $neu ? 0.0 : (float)$buchung['betrag'];
|
||||||
|
$eingaben = $eingaben ?? null;
|
||||||
|
|
||||||
// Die Art bestimmt nur noch die Richtung. Ob die Buchung eine Umbuchung ist,
|
// Die Art bestimmt nur noch die Richtung. Ob die Buchung eine Umbuchung ist,
|
||||||
// steht im Feld Kategorie / Gegenkonto.
|
// steht im Feld Kategorie / Gegenkonto.
|
||||||
|
|
@ -29,6 +32,32 @@ $zuordnungswert = static function (?array $zeile): string {
|
||||||
return '';
|
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'];
|
$eigenesKonto = (int)$konto['id'];
|
||||||
$mitSplit = false;
|
$mitSplit = false;
|
||||||
$mitDepot = true;
|
$mitDepot = true;
|
||||||
|
|
@ -42,10 +71,37 @@ $kontozusatz = trim((string)($konto['iban'] ?? '')) !== ''
|
||||||
trim((string)($konto['kontonummer'] ?? '')),
|
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.
|
// Immer ein paar leere Splitzeilen anbieten; weitere fügt der Knopf hinzu.
|
||||||
$splitZeilen = $splits;
|
|
||||||
for ($i = count($splitZeilen); $i < 4; $i++) {
|
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';
|
include __DIR__ . '/../layout/kopf.php';
|
||||||
|
|
@ -89,25 +145,25 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<div class="feld">
|
<div class="feld">
|
||||||
<label for="datum">Datum</label>
|
<label for="datum">Datum</label>
|
||||||
<input type="date" id="datum" name="datum" required
|
<input type="date" id="datum" name="datum" required
|
||||||
value="<?= View::h($neu ? date('Y-m-d') : substr((string)$buchung['datum'], 0, 10)) ?>">
|
value="<?= View::h($datumWert) ?>">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="feld">
|
<div class="feld">
|
||||||
<label for="betrag" title="Immer positiv eingeben – die Richtung bestimmt die Art.">Betrag</label>
|
<label for="betrag" title="Immer positiv eingeben – die Richtung bestimmt die Art.">Betrag</label>
|
||||||
<input type="text" id="betrag" name="betrag" inputmode="decimal" required
|
<input type="text" id="betrag" name="betrag" inputmode="decimal" required
|
||||||
value="<?= View::h($neu ? '' : View::betrag(abs($betrag), false)) ?>">
|
value="<?= View::h($betragWert) ?>">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="feld">
|
<div class="feld">
|
||||||
<label for="empfaenger">Empfänger</label>
|
<label for="empfaenger">Empfänger</label>
|
||||||
<input type="text" id="empfaenger" name="empfaenger"
|
<input type="text" id="empfaenger" name="empfaenger"
|
||||||
value="<?= View::h((string)($buchung['empfaenger'] ?? '')) ?>">
|
value="<?= View::h($empfaengerWert) ?>">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="feld">
|
<div class="feld">
|
||||||
<label for="zuordnung" title="Unter den Kategorien stehen die eigenen Konten in eckigen Klammern. Eines davon macht die Buchung zur Umbuchung: Sie wird einmal gespeichert und erscheint auf beiden Kontoblättern – auf dem Gegenkonto mit umgekehrtem Vorzeichen.">Kategorie / Gegenkonto</label>
|
<label for="zuordnung" title="Unter den Kategorien stehen die eigenen Konten in eckigen Klammern. Eines davon macht die Buchung zur Umbuchung: Sie wird einmal gespeichert und erscheint auf beiden Kontoblättern – auf dem Gegenkonto mit umgekehrtem Vorzeichen.">Kategorie / Gegenkonto</label>
|
||||||
<select id="zuordnung" name="zuordnung">
|
<select id="zuordnung" name="zuordnung">
|
||||||
<?php $gewaehlt = $zuordnungswert($buchung); ?>
|
<?php $gewaehlt = $zuordnungWert; ?>
|
||||||
<?php include __DIR__ . '/zuordnungsliste.php'; ?>
|
<?php include __DIR__ . '/zuordnungsliste.php'; ?>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -118,7 +174,7 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<option value="">— keine —</option>
|
<option value="">— keine —</option>
|
||||||
<?php foreach ($klassen as $k): ?>
|
<?php foreach ($klassen as $k): ?>
|
||||||
<option value="<?= (int)$k['id'] ?>"
|
<option value="<?= (int)$k['id'] ?>"
|
||||||
<?= (int)($buchung['klasse_id'] ?? 0) === (int)$k['id'] ? 'selected' : '' ?>>
|
<?= $klasseWert === (int)$k['id'] ? 'selected' : '' ?>>
|
||||||
<?= View::h($k['name']) ?>
|
<?= View::h($k['name']) ?>
|
||||||
</option>
|
</option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|
@ -128,22 +184,30 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<div class="feld">
|
<div class="feld">
|
||||||
<label for="buchungstext">Buchungstext</label>
|
<label for="buchungstext">Buchungstext</label>
|
||||||
<input type="text" id="buchungstext" name="buchungstext"
|
<input type="text" id="buchungstext" name="buchungstext"
|
||||||
value="<?= View::h((string)($buchung['buchungstext'] ?? '')) ?>">
|
value="<?= View::h($buchungstextWert) ?>">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="feld">
|
<div class="feld">
|
||||||
<label for="nummer">Nummer <span class="optional">(optional)</span></label>
|
<label for="nummer">Nummer <span class="optional">(optional)</span></label>
|
||||||
<input type="text" id="nummer" name="nummer" value="<?= View::h((string)($buchung['nummer'] ?? '')) ?>">
|
<input type="text" id="nummer" name="nummer" value="<?= View::h($nummerWert) ?>">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="feld ganz">
|
<div class="feld ganz">
|
||||||
<label class="ankreuzfeld">
|
<label class="ankreuzfeld">
|
||||||
<input type="checkbox" name="abgeglichen" value="1"
|
<input type="checkbox" name="abgeglichen" value="1"
|
||||||
<?= !empty($buchung['abgeglichen']) ? 'checked' : '' ?>>
|
<?= $abgeglichenWert ? 'checked' : '' ?>>
|
||||||
Abgeglichen
|
Abgeglichen
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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.">
|
||||||
|
<input type="checkbox" name="regel_anlegen" value="1">
|
||||||
|
Regel anlegen
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<?php /* Direkte Zuordnung und Splitzeilen schließen sich aus: buchungsmaske.js
|
<?php /* Direkte Zuordnung und Splitzeilen schließen sich aus: buchungsmaske.js
|
||||||
sperrt jeweils die andere Seite, sobald eine davon belegt ist. Der
|
sperrt jeweils die andere Seite, sobald eine davon belegt ist. Der
|
||||||
Knopf rechts leert alle Splitzeilen und gibt Kategorie und Klasse
|
Knopf rechts leert alle Splitzeilen und gibt Kategorie und Klasse
|
||||||
|
|
@ -171,7 +235,7 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<?php /* In einer Splitzeile ist das Depot genau das, was
|
<?php /* In einer Splitzeile ist das Depot genau das, was
|
||||||
es zu sein hat: ein Gegenkonto. Die Zeile steht
|
es zu sein hat: ein Gegenkonto. Die Zeile steht
|
||||||
ja schon für einen bestimmten Betrag. */ ?>
|
ja schon für einen bestimmten Betrag. */ ?>
|
||||||
<?php $gewaehlt = $zuordnungswert($zeile); $mitDepot = false; ?>
|
<?php $gewaehlt = $zeile['zuordnung']; $mitDepot = false; ?>
|
||||||
<?php include __DIR__ . '/zuordnungsliste.php'; ?>
|
<?php include __DIR__ . '/zuordnungsliste.php'; ?>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -186,18 +250,17 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td><input type="text" name="split[<?= $nr ?>][notiz]" value="<?= View::h((string)($zeile['notiz'] ?? '')) ?>"></td>
|
<td><input type="text" name="split[<?= $nr ?>][notiz]" value="<?= View::h($zeile['notiz']) ?>"></td>
|
||||||
<td>
|
<td>
|
||||||
<input type="text" class="splitbetrag rechts" inputmode="decimal"
|
<input type="text" class="splitbetrag rechts" inputmode="decimal"
|
||||||
name="split[<?= $nr ?>][betrag]"
|
name="split[<?= $nr ?>][betrag]" value="<?= View::h($zeile['betrag']) ?>">
|
||||||
value="<?= $zeile['betrag'] === '' ? '' : View::h(View::betrag($zeile['betrag'], false)) ?>">
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3" class="rechts">Summe der Zeilen / Restbetrag</td>
|
<td colspan="3" class="rechts">Restbetrag</td>
|
||||||
<td class="rechts"><output id="splitsumme" class="betrag">0,00</output></td>
|
<td class="rechts"><output id="splitsumme" class="betrag">0,00</output></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ use Finanzmanager\View;
|
||||||
/** @var list<array<string,mixed>> $konten */
|
/** @var list<array<string,mixed>> $konten */
|
||||||
/** @var array<int, list<array<string,mixed>>> $splits */
|
/** @var array<int, list<array<string,mixed>>> $splits */
|
||||||
/** @var bool $bargeld Ist ein Bargeld-Tool eingerichtet? */
|
/** @var bool $bargeld Ist ein Bargeld-Tool eingerichtet? */
|
||||||
|
/** @var array<string,mixed>|null $neueRegel Aus der Buchungsmaske erzeugte Regel, zum Prüfen im Dialog */
|
||||||
|
|
||||||
$kategorieNamen = [];
|
$kategorieNamen = [];
|
||||||
foreach ($kategorien as $k) {
|
foreach ($kategorien as $k) {
|
||||||
|
|
@ -506,7 +507,7 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3" class="rechts">Summe der Zeilen / Restbetrag</td>
|
<td colspan="3" class="rechts">Restbetrag</td>
|
||||||
<td class="rechts"><output id="splitdialogsumme" class="betrag">0,00</output></td>
|
<td class="rechts"><output id="splitdialogsumme" class="betrag">0,00</output></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
|
|
@ -584,4 +585,35 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<script src="/static/kontoblatt.js" defer></script>
|
<script src="/static/kontoblatt.js" defer></script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($neueRegel !== null): ?>
|
||||||
|
<?php /* Öffnet sich automatisch (regeldialog.js) nach dem Speichern einer
|
||||||
|
Buchung mit gesetztem Toggle „Regel anlegen“. „Abbruch“ entfernt die
|
||||||
|
gerade erst angelegte Regel wieder – über dasselbe Löschformular wie
|
||||||
|
die Regelliste, nur mit Rücksprung hierher statt dorthin. */ ?>
|
||||||
|
<dialog id="regeldialog" class="splitdialog regeldialog">
|
||||||
|
<form method="post" action="/banking/regeln/<?= (int)$neueRegel['id'] ?>" id="regelformular" class="formular">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||||
|
<input type="hidden" name="rueckziel_konto_id" value="<?= (int)$konto['id'] ?>">
|
||||||
|
<h2>Regel anlegen</h2>
|
||||||
|
<p class="feldhinweis">
|
||||||
|
Aus dieser Buchung wurde diese Regel erzeugt. Vor dem Speichern lässt sie
|
||||||
|
sich noch anpassen; „Abbruch“ verwirft sie wieder.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<?php $regel = $neueRegel; $idPrefix = 'regeldialog_'; ?>
|
||||||
|
<?php include __DIR__ . '/../banking/regelfelder.php'; ?>
|
||||||
|
|
||||||
|
<div class="knopfleiste">
|
||||||
|
<button type="submit" class="knopf primaer">Speichern</button>
|
||||||
|
<button type="submit" class="knopf" id="regelabbrechen"
|
||||||
|
formaction="/banking/regeln/<?= (int)$neueRegel['id'] ?>/loeschen" formnovalidate>
|
||||||
|
Abbruch
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script src="/static/regeldialog.js" defer></script>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||||
|
|
|
||||||
161
tests/51_RegelAusBuchungTest.php
Normal file
161
tests/51_RegelAusBuchungTest.php
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Finanzmanager\Models\AbgleichRegel;
|
||||||
|
use Finanzmanager\Models\Buchung;
|
||||||
|
use Finanzmanager\Models\Kategorie;
|
||||||
|
use Finanzmanager\Models\Klasse;
|
||||||
|
use Finanzmanager\Models\Konto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix #94: Regel direkt aus einer Buchung anlegen – die Buchungsmaske
|
||||||
|
* erzeugt dafür `AbgleichRegel::ausBuchung()`.
|
||||||
|
*/
|
||||||
|
class RegelAusBuchungTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 51: Regel aus einer Buchung anlegen';
|
||||||
|
|
||||||
|
private int $giro = 0;
|
||||||
|
private int $strom = 0;
|
||||||
|
private int $versicherung = 0;
|
||||||
|
private int $klasse = 0;
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -286,16 +286,25 @@ test.describe('Buchungen', () => {
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
await expect(dialog).toContainText('-90,00')
|
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('select[name="split[0][zuordnung]"]').selectOption({ label: KATEGORIE })
|
||||||
await dialog.locator('input[name="split[0][betrag]"]').fill('-50')
|
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('select[name="split[1][zuordnung]"]').selectOption({ label: `[${SPAR}]` })
|
||||||
await dialog.locator('input[name="split[1][betrag]"]').fill('-40')
|
await dialog.locator('input[name="split[1][betrag]"]').fill('-40')
|
||||||
|
|
||||||
// Der Dialog rechnet den Restbetrag mit; er muss aufgehen.
|
// 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.
|
// 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('.meldung-erfolg').last()).toContainText('Splitbuchung')
|
||||||
await expect(page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` }).first())
|
await expect(page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` }).first())
|
||||||
|
|
@ -372,6 +381,53 @@ test.describe('Buchungen', () => {
|
||||||
expect(titel).toContain(`${KATEGORIE}: -97,98`)
|
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 }) => {
|
test('Splitzeilen und direkte Zuordnung sperren sich gegenseitig', async ({ page }) => {
|
||||||
await page.goto(`/konten/${giroId}/buchung`)
|
await page.goto(`/konten/${giroId}/buchung`)
|
||||||
const kategorie = page.locator('#zuordnung')
|
const kategorie = page.locator('#zuordnung')
|
||||||
|
|
@ -494,21 +550,35 @@ test.describe('Buchungen', () => {
|
||||||
.not.toHaveAttribute('title')
|
.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`)
|
await page.goto(`/konten/${giroId}/buchung`)
|
||||||
|
const speichern = page.locator('.buchungsmaske button[type="submit"]')
|
||||||
|
|
||||||
await page.fill('#datum', '2026-05-03')
|
await page.fill('#datum', '2026-05-03')
|
||||||
await page.fill('#betrag', '100')
|
await page.fill('#betrag', '100')
|
||||||
await page.fill('#empfaenger', `Krumm ${MARKE}`)
|
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.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE })
|
||||||
await page.fill('input[name="split[0][betrag]"]', '-60')
|
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('#splitsumme')).toHaveText('0,00')
|
||||||
await expect(page.locator('.meldung-fehler')).toContainText('Splitzeilen')
|
await expect(speichern).toBeEnabled()
|
||||||
|
|
||||||
|
await speichern.click()
|
||||||
|
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Kontoblatt lässt sich durchsuchen', async ({ page }) => {
|
test('Kontoblatt lässt sich durchsuchen', async ({ page }) => {
|
||||||
|
|
|
||||||
138
tests/e2e/specs/regelausbuchung.spec.js
Normal file
138
tests/e2e/specs/regelausbuchung.spec.js
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Reference in a new issue