Fix #94: Regel direkt aus einer Buchung anlegen

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 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-09-29 23:11:06 +02:00
parent 30b46a4561
commit 057075368c
13 changed files with 628 additions and 74 deletions

View file

@ -1,5 +1,23 @@
# Änderungen # Ä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 `<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:

View file

@ -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

View file

@ -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; }

View 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);
});
})();

View file

@ -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';
} }
/** /**

View file

@ -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')),
]); ]);
} }
@ -126,7 +130,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 */
@ -175,7 +182,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,6 +564,24 @@ class BuchungenController extends BaseController
return '/konten/' . $kontoId . ($query === [] ? '' : '?' . http_build_query($query)); 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<string,mixed>|null $konto */ /** @param array<string,mixed>|null $konto */
private function maske(?array $konto, ?array $buchung): void private function maske(?array $konto, ?array $buchung): void
{ {

View file

@ -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.
* *

View 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>

View file

@ -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>

View file

@ -144,6 +144,14 @@ include __DIR__ . '/../layout/kopf.php';
</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

View file

@ -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) {
@ -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'; ?>

View 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;
}
}

View 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)
})
})