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:
parent
30b46a4561
commit
057075368c
13 changed files with 628 additions and 74 deletions
18
Changes.md
18
Changes.md
|
|
@ -1,5 +1,23 @@
|
|||
# Änderungen
|
||||
|
||||
## Regel direkt aus einer Buchung anlegen (29.09.2026)
|
||||
|
||||
- Neuer Toggle „Regel anlegen“ in der Buchungsmaske, standardmäßig aus; das bisherige
|
||||
Verhalten bleibt unverändert, solange er aus ist.
|
||||
- Ist er beim Speichern an, entsteht aus Empfänger (ersatzweise Buchungstext), Kategorie,
|
||||
Klasse, Gegenkonto und Splits der Buchung eine neue Regel (`AbgleichRegel::ausBuchung()`).
|
||||
Ohne Empfänger und Buchungstext gibt es kein sinnvolles Muster – dann bleibt es beim
|
||||
gewöhnlichen Speichern ohne Regel.
|
||||
- Das Kontoblatt zeigt die neue Regel danach in einem modalen Dialog (`regeldialog.js`,
|
||||
natives `<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)
|
||||
|
||||
- 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.
|
||||
|
||||
### Regel direkt aus einer Buchung anlegen
|
||||
|
||||
In der Buchungsmaske schaltet der Toggle *Regel anlegen* (standardmäßig aus) eine
|
||||
Kurzstrecke frei: Beim Speichern entsteht aus Empfänger (ersatzweise Buchungstext),
|
||||
Kategorie, Klasse, Gegenkonto und einer eventuellen Splitbuchung eine neue Regel
|
||||
(`AbgleichRegel::ausBuchung()`) – Feld *Empfänger*, Vergleich *enthält*, das Muster ist der
|
||||
Empfängertext. Fehlen sowohl Empfänger als auch Buchungstext, gibt es kein sinnvolles Muster
|
||||
und keine Regel entsteht; die Buchung wird trotzdem ganz normal gespeichert.
|
||||
|
||||
Das Kontoblatt zeigt die neue Regel danach in einem modalen Dialog mit denselben Feldern wie
|
||||
*Neue Regel* oben auf dieser Seite. *Abbruch* (auch über Escape) löscht die gerade erst
|
||||
angelegte Regel wieder – die Buchung bleibt gespeichert, keine ungewollte Regel bleibt
|
||||
zurück. *Speichern* übernimmt Änderungen und belässt die Regel in der Liste.
|
||||
|
||||
## Protokoll und Diagnose
|
||||
|
||||
Jeder Abruf schreibt eine Zeile nach `bank_abrufe` mit Status, Zahlen und Verlauf – ohne das
|
||||
|
|
|
|||
|
|
@ -1320,11 +1320,16 @@ table.splittabelle input.rechts { text-align: right; }
|
|||
.formatname { font-weight: 600; font-size: 1.05rem; }
|
||||
|
||||
/* Der Wertpapierdialog trägt ein Formular und wird dadurch länger als der
|
||||
Splitdialog; bei kleinen Fenstern scrollt er. */
|
||||
Splitdialog; bei kleinen Fenstern scrollt er. Der Regeldialog übernimmt
|
||||
dieselben Regelfelder wie die Regelliste – aus demselben Grund dieselbe
|
||||
Bauart. */
|
||||
.wertpapierdialog { max-height: 88vh; overflow-y: auto; }
|
||||
.wertpapierdialog .formular { max-width: none; }
|
||||
.wertpapierdialog h2 { margin: 0 0 .3rem; font-size: 1.05rem; }
|
||||
|
||||
.regeldialog { max-height: 88vh; overflow-y: auto; }
|
||||
.regeldialog .formular { max-width: none; }
|
||||
|
||||
/* Die Kostentabelle: Betrag rechts, Kategorie als stiller Hinweis daneben. */
|
||||
table.kostentabelle { margin-top: .6rem; }
|
||||
table.kostentabelle td { padding: .25rem .4rem; }
|
||||
|
|
|
|||
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 {
|
||||
AbgleichRegel::speichern((int)$params['id'], $this->regeldaten());
|
||||
} catch (\Throwable $e) {
|
||||
View::redirect('/banking/regeln', $e->getMessage(), 'fehler');
|
||||
View::redirect($this->regelZiel(), $e->getMessage(), 'fehler');
|
||||
return;
|
||||
}
|
||||
|
||||
View::redirect('/banking/regeln', 'Regel gespeichert.');
|
||||
View::redirect($this->regelZiel(), 'Regel gespeichert.');
|
||||
}
|
||||
|
||||
/** @param array<string,string> $params */
|
||||
|
|
@ -955,7 +955,24 @@ class BankingController extends BaseController
|
|||
|
||||
AbgleichRegel::loeschen((int)$params['id']);
|
||||
|
||||
View::redirect('/banking/regeln', 'Regel gelöscht.');
|
||||
View::redirect($this->regelZiel(), 'Regel gelöscht.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wohin es nach dem Speichern oder Löschen einer Regel geht: normalerweise
|
||||
* die Regelliste. Der Regeldialog der Buchungsmaske trägt hier aber das
|
||||
* Konto ein, von dem er kam – die Regel entstand aus einer Buchung, zu deren
|
||||
* Kontoblatt soll es danach zurückgehen, ohne dass diese Ansicht selbst
|
||||
* etwas von der Buchung wissen müsste.
|
||||
*/
|
||||
private function regelZiel(): string
|
||||
{
|
||||
$kontoId = (int)$this->post('rueckziel_konto_id');
|
||||
if ($kontoId > 0 && Konto::find($kontoId) !== null) {
|
||||
return '/konten/' . $kontoId;
|
||||
}
|
||||
|
||||
return '/banking/regeln';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Finanzmanager\Controllers;
|
||||
|
||||
use Finanzmanager\Models\AbgleichRegel;
|
||||
use Finanzmanager\Models\Buchung;
|
||||
use Finanzmanager\Models\Depotbuchung;
|
||||
use Finanzmanager\Models\Einstellung;
|
||||
|
|
@ -82,6 +83,9 @@ class BuchungenController extends BaseController
|
|||
// Die Abgleichspalte erscheint nur, wenn es ein Bargeld-Tool gibt –
|
||||
// sonst stünde dort in jeder Zeile derselbe graue Kreis.
|
||||
'bargeld' => BargeldAbgleich::eingerichtet(),
|
||||
// Kommt die Seite vom Toggle „Regel anlegen“ in der Buchungsmaske,
|
||||
// steht hier die frisch erzeugte Regel zum Prüfen im Dialog.
|
||||
'neueRegel' => AbgleichRegel::find((int)$this->query('neue_regel')),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -126,7 +130,10 @@ class BuchungenController extends BaseController
|
|||
|
||||
$abgleich = BargeldAbgleich::pruefe($id);
|
||||
|
||||
View::redirect('/konten/' . $kontoId, 'Buchung angelegt.' . BargeldAbgleich::rueckmeldung($abgleich));
|
||||
View::redirect(
|
||||
$this->kontoblattZiel($kontoId, $id),
|
||||
'Buchung angelegt.' . BargeldAbgleich::rueckmeldung($abgleich)
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string,string> $params */
|
||||
|
|
@ -175,7 +182,7 @@ class BuchungenController extends BaseController
|
|||
$abgleich = BargeldAbgleich::pruefe($id);
|
||||
|
||||
View::redirect(
|
||||
'/konten/' . $buchung['konto_id'],
|
||||
$this->kontoblattZiel((int)$buchung['konto_id'], $id),
|
||||
'Buchung gespeichert.' . BargeldAbgleich::rueckmeldung($abgleich)
|
||||
);
|
||||
}
|
||||
|
|
@ -557,6 +564,24 @@ class BuchungenController extends BaseController
|
|||
return '/konten/' . $kontoId . ($query === [] ? '' : '?' . http_build_query($query));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ziel nach dem Speichern einer Buchung: das Kontoblatt – mit angehängter
|
||||
* neuer Regel, wenn der Toggle „Regel anlegen“ gesetzt war. Ohne Muster
|
||||
* (kein Empfänger, kein Buchungstext) entsteht keine Regel; das Ziel bleibt
|
||||
* dann das gewöhnliche.
|
||||
*/
|
||||
private function kontoblattZiel(int $kontoId, int $buchungId): string
|
||||
{
|
||||
$ziel = '/konten/' . $kontoId;
|
||||
|
||||
if (!isset($_POST['regel_anlegen'])) {
|
||||
return $ziel;
|
||||
}
|
||||
|
||||
$regelId = AbgleichRegel::ausBuchung($buchungId);
|
||||
return $regelId === null ? $ziel : $ziel . '?neue_regel=' . $regelId;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed>|null $konto */
|
||||
private function maske(?array $konto, ?array $buchung): void
|
||||
{
|
||||
|
|
|
|||
|
|
@ -100,6 +100,69 @@ class AbgleichRegel
|
|||
Database::execute('DELETE FROM abgleich_regeln WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut aus einer bestehenden Buchung eine neue Regel – der Ausgangspunkt für
|
||||
* die Buchungsmaske, die eine Buchung direkt in eine Regel verwandeln kann.
|
||||
* Das Muster kommt vom Empfänger, ersatzweise vom Buchungstext; ist beides
|
||||
* leer, gibt es kein sinnvolles Muster und keine Regel entsteht.
|
||||
*
|
||||
* @return int|null Die id der neuen Regel, oder null ohne Muster
|
||||
*/
|
||||
public static function ausBuchung(int $buchungId): ?int
|
||||
{
|
||||
$buchung = Buchung::find($buchungId);
|
||||
if ($buchung === null) {
|
||||
throw new \RuntimeException('Die Buchung gibt es nicht.');
|
||||
}
|
||||
|
||||
$empfaenger = trim((string)($buchung['empfaenger'] ?? ''));
|
||||
$zweck = trim((string)($buchung['buchungstext'] ?? ''));
|
||||
|
||||
if ($empfaenger !== '') {
|
||||
$feld = 'empfaenger';
|
||||
} elseif ($zweck !== '') {
|
||||
$feld = 'verwendungszweck';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
$splitzeilen = array_values(array_filter(
|
||||
Buchung::splits($buchungId),
|
||||
static fn(array $zeile): bool => (int)($zeile['kategorie_id'] ?? 0) !== 0
|
||||
));
|
||||
|
||||
// Anteile am Buchungsbetrag statt roher Beträge: So zeigt der Dialog
|
||||
// gleich sinnvolle Prozentwerte statt der Centbeträge der Buchung –
|
||||
// splitzeilen() liest den Anteil als Bruchteil von 1.
|
||||
$gesamt = array_sum(array_map(
|
||||
static fn(array $zeile): float => abs((float)$zeile['betrag']),
|
||||
$splitzeilen
|
||||
));
|
||||
|
||||
$splits = [];
|
||||
foreach ($splitzeilen as $zeile) {
|
||||
$splits[] = [
|
||||
'kategorie_id' => (int)$zeile['kategorie_id'],
|
||||
'klasse_id' => $zeile['klasse_id'],
|
||||
'anteil' => $gesamt > 0.0 ? round(abs((float)$zeile['betrag']) / $gesamt, 4) : 0,
|
||||
'notiz' => (string)($zeile['notiz'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return self::anlegen([
|
||||
'name' => $empfaenger !== '' ? $empfaenger : $zweck,
|
||||
'feld' => $feld,
|
||||
'vergleich' => 'enthaelt',
|
||||
'muster' => $empfaenger !== '' ? $empfaenger : $zweck,
|
||||
'kategorie_id' => $buchung['kategorie_id'],
|
||||
'klasse_id' => $buchung['klasse_id'],
|
||||
'gegenkonto_id' => $buchung['gegenkonto_id'],
|
||||
'splits' => $splits === [] ? null : $splits,
|
||||
'prioritaet' => 100,
|
||||
'aktiv' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die erste Regel, die auf den Umsatz passt.
|
||||
*
|
||||
|
|
|
|||
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) ?>">
|
||||
<h3>Neue Regel</h3>
|
||||
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" required placeholder="z. B. Stadtwerke Strom">
|
||||
|
||||
<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>
|
||||
<?php $regel = ['aktiv' => 1]; $idPrefix = 'neu_'; ?>
|
||||
<?php include __DIR__ . '/regelfelder.php'; ?>
|
||||
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">Regel anlegen</button>
|
||||
|
|
|
|||
|
|
@ -144,6 +144,14 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</label>
|
||||
</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
|
||||
sperrt jeweils die andere Seite, sobald eine davon belegt ist. Der
|
||||
Knopf rechts leert alle Splitzeilen und gibt Kategorie und Klasse
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ use Finanzmanager\View;
|
|||
/** @var list<array<string,mixed>> $konten */
|
||||
/** @var array<int, list<array<string,mixed>>> $splits */
|
||||
/** @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 = [];
|
||||
foreach ($kategorien as $k) {
|
||||
|
|
@ -584,4 +585,35 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<script src="/static/kontoblatt.js" defer></script>
|
||||
<?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'; ?>
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
}
|
||||
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