Splitbuchungen in den Regeln für den Abgleich

Eine Regel kann jetzt eine Splitbuchung vorgeben: mehrere Kategorien mit einem
Anteil in Prozent. Greift sie, wird der Umsatz in diesem Verhältnis aufgeteilt –
bei jedem Betrag neu gerechnet, sodass eine Abrechnung über 300 € und eine über
450 € nach demselben Schlüssel gebucht werden.

Das Schema lag bereits im Modell und in der Datenbank, war über die Oberfläche
aber nicht erreichbar; die Dokumentation beschrieb ein Feld, das es nirgends zu
sehen gab.

- Unter jeder Regel klappt „Splitbuchung“ die Zeilen auf: Kategorie, Klasse,
  Notiz, Anteil. Dasselbe Formular steht bei „Neue Regel“ – beide binden
  src/Views/banking/regelsplits.php ein.
- Die Anteile gelten zueinander, nicht absolut: 60 zu 30 sind zwei Drittel zu
  einem Drittel. Bisher hätte die letzte Zeile als Rest 40 statt der
  eingetragenen 30 Prozent bekommen, und der Fehler wäre in der fertigen
  Buchung nicht mehr zu sehen gewesen.
- Im Abgleichdialog zeigt der Vorschlag die errechneten Zeilen und schickt sie
  mit. Zuvor gingen sie beim Knopf „Als neue Buchung“ verloren: Das Formular
  kannte nur Kategorie und Klasse, sodass von Hand übernommene Umsätze
  ungeteilt im Kontoblatt landeten. Der automatische Lauf war nie betroffen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-08-13 16:02:17 +02:00
parent eb08cd6b83
commit a7bfbe39e0
9 changed files with 423 additions and 30 deletions

View file

@ -1,5 +1,27 @@
# Änderungen # Änderungen
## Splitbuchungen in den Abgleichregeln (13.08.2026)
Eine Regel für den Abgleich kann jetzt eine **Splitbuchung** vorgeben: mehrere Kategorien mit
einem Anteil in Prozent. Greift die Regel, wird der Umsatz in diesem Verhältnis aufgeteilt –
bei jedem Betrag neu gerechnet, sodass eine Nebenkostenabrechnung über 300 € und eine über
450 € nach demselben Schlüssel gebucht werden.
Das Schema lag bereits im Modell und in der Datenbank, war aber über die Oberfläche nicht
erreichbar; die Dokumentation beschrieb ein Feld, das es nirgends zu sehen gab.
- Unter jeder Regel klappt *Splitbuchung* die Zeilen auf: Kategorie, Klasse, Notiz, Anteil.
Dasselbe Formular steht bei *Neue Regel* (`src/Views/banking/regelsplits.php`, in beiden
Ansichten eingebunden). Zugeklappt, solange eine Regel keine Splitzeilen hat.
- Die Anteile gelten **zueinander**, nicht absolut: 60 zu 30 sind zwei Drittel zu einem
Drittel. Bisher hätte die letzte Zeile als Rest 40 statt der eingetragenen 30 Prozent
bekommen – ein Fehler, der in der fertigen Buchung nicht mehr zu sehen gewesen wäre.
Deshalb ist der Anteil auch nicht länger auf 1 gedeckelt.
- Im Abgleichdialog zeigt der Vorschlag die errechneten Zeilen mit ihren Beträgen und schickt
sie mit. Zuvor gingen sie beim Knopf *Als neue Buchung* verloren: Das Formular kannte nur
Kategorie und Klasse, sodass von Hand übernommene Umsätze ungeteilt im Kontoblatt landeten.
Der automatische Lauf war davon nie betroffen.
## Kurse von Yahoo Finanzen (13.08.2026) ## Kurse von Yahoo Finanzen (13.08.2026)
Dritter Anbieter im Kursabruf: **Yahoo Finanzen**, ohne Schlüssel und ohne Tageskontingent. Dritter Anbieter im Kursabruf: **Yahoo Finanzen**, ohne Schlüssel und ohne Tageskontingent.

View file

@ -251,9 +251,30 @@ Eine Regel prüft ein Muster gegen Empfänger und/oder Verwendungszweck:
so. so.
* **Betragsgrenzen** rechnen mit Vorzeichen: „von −60 bis −40“ trifft Lastschriften * **Betragsgrenzen** rechnen mit Vorzeichen: „von −60 bis −40“ trifft Lastschriften
zwischen 40 und 60 Euro. zwischen 40 und 60 Euro.
* **Wirkung**: Kategorie, Klasse, Gegenkonto (macht daraus eine Umbuchung) oder ein festes * **Wirkung**: Kategorie, Klasse, Gegenkonto (macht daraus eine Umbuchung) oder eine
**Split-Schema** in Prozent. Die letzte Splitzeile bekommt den Rest, damit die Summe auf **Splitbuchung**.
den Cent zum Buchungsbetrag passt.
### Splitbuchung in einer Regel
Unter jeder Regel klappt *Splitbuchung* die Zeilen auf: je Zeile eine Kategorie, wahlweise
Klasse und Notiz, dazu ein Anteil in Prozent. Greift die Regel, entsteht keine Buchung mit
einer Kategorie, sondern eine Splitbuchung mit diesen Zeilen.
Die Anteile gelten **zueinander**, nicht als feste Beträge – darin liegt der ganze Zweck:
Dieselbe Regel trifft bei jeder Rechnungshöhe dasselbe Verhältnis. Eine Nebenkostenabrechnung
über 300 € wird bei 60 zu 30 in 200 € und 100 € geteilt, eine über 450 € in 300 € und 150 €.
Ob dort 60 und 30 steht oder 2 und 1, ändert nichts.
Dass die Anteile aufeinander bezogen werden und nicht absolut gelten, ist Absicht: Sonst
bekäme die letzte Zeile als Rest plötzlich 40 statt der eingetragenen 30 Prozent, und der
Fehler wäre in der fertigen Buchung nicht mehr zu sehen. Die letzte Zeile bekommt weiterhin
den Rundungsrest, damit die Summe auf den Cent zum Buchungsbetrag passt – bei einem Drittel
zu zwei Dritteln ginge sonst ein Cent verloren, und die Buchung ließe sich nicht speichern.
Eine Zeile ohne Kategorie oder ohne Anteil fällt weg; ohne jede Splitzeile bucht die Regel
wie zuvor auf ihre Kategorie. Im Abgleichdialog zeigt der Vorschlag die errechneten Zeilen
mit ihren Beträgen an und übernimmt sie unverändert – geändert wird eine Aufteilung im
Kontoblatt, wo die ganze Buchung samt Summenprüfung vorliegt.
Die **erste passende Regel gewinnt**, sortiert nach Priorität (kleinere Zahl zuerst). Kein Die **erste passende Regel gewinnt**, sortiert nach Priorität (kleinere Zahl zuerst). Kein
Sammeln über mehrere Regeln: Wenn zwei verschiedene Kategorien setzen wollten, wäre am Ende Sammeln über mehrere Regeln: Wenn zwei verschiedene Kategorien setzen wollten, wäre am Ende

View file

@ -1688,6 +1688,40 @@ table.bericht .wertlink:hover {
.regeltabelle select, .regeltabelle select,
.regeltabelle input[type="text"] { max-width: 12rem; } .regeltabelle input[type="text"] { max-width: 12rem; }
/* Die Splitzeilen einer Regel hängen unter ihrer Regel und sind zugeklappt,
solange keine da sind – sonst stünde unter jeder Regel eine leere Tabelle. */
.regelsplitzeile > td {
padding: 0 .4rem .6rem;
border-top: 0;
}
.regelsplitzeile summary {
cursor: pointer;
font-size: .85rem;
color: var(--text-gedaempft);
}
.regelsplitzeile .splittabelle { margin-top: .5rem; }
.regelsplitzeile .feldhinweis { margin-bottom: 0; }
/* Die Aufteilung, die eine Regel vorschlägt: reine Anzeige, die Felder daneben
sind versteckt. */
.vorschlagssplits {
flex: 1 1 100%;
margin: 0;
padding: 0;
list-style: none;
font-size: .85rem;
}
.vorschlagssplits li {
display: flex;
gap: .5rem;
justify-content: space-between;
max-width: 28rem;
padding: .1rem 0;
}
/* ── Wertpapiere ─────────────────────────────────────────────────────────── */ /* ── Wertpapiere ─────────────────────────────────────────────────────────── */
/* Die Kennzahlen über der Depotübersicht: Wert, Einstand, Gewinn, Erträge – /* Die Kennzahlen über der Depotübersicht: Wert, Einstand, Gewinn, Erträge –

View file

@ -685,11 +685,50 @@ class BankingController extends BaseController
'kategorie_id' => $this->post('kategorie_id'), 'kategorie_id' => $this->post('kategorie_id'),
'klasse_id' => $this->post('klasse_id'), 'klasse_id' => $this->post('klasse_id'),
'gegenkonto_id' => $this->post('gegenkonto_id'), 'gegenkonto_id' => $this->post('gegenkonto_id'),
'splits' => $this->regelsplits(),
'prioritaet' => $this->post('prioritaet', '100'), 'prioritaet' => $this->post('prioritaet', '100'),
'aktiv' => isset($_POST['aktiv']) ? 1 : 0, 'aktiv' => isset($_POST['aktiv']) ? 1 : 0,
]; ];
} }
/**
* Das Split-Schema einer Regel aus dem Formular.
*
* Eingegeben wird in Prozent, gespeichert wird als Anteil: Die Regel soll ja
* gerade **nicht** an einem Betrag hängen, sondern bei jeder Rechnungshöhe
* dasselbe Verhältnis treffen.
*
* @return list<array<string,mixed>>
*/
private function regelsplits(): array
{
$roh = $_POST['split'] ?? [];
if (!is_array($roh)) {
return [];
}
$splits = [];
foreach ($roh as $zeile) {
if (!is_array($zeile)) {
continue;
}
$prozent = KontenController::zahl((string)($zeile['prozent'] ?? ''));
if (($zeile['kategorie_id'] ?? '') === '' || $prozent <= 0.0) {
continue;
}
$splits[] = [
'kategorie_id' => (int)$zeile['kategorie_id'],
'klasse_id' => ($zeile['klasse_id'] ?? '') === '' ? null : (int)$zeile['klasse_id'],
'anteil' => $prozent / 100,
'notiz' => (string)($zeile['notiz'] ?? ''),
];
}
return $splits;
}
/** /**
* Die Zuordnung Bankkonto → eigenes Konto aus dem Formular. * Die Zuordnung Bankkonto → eigenes Konto aus dem Formular.
* *

View file

@ -154,6 +154,11 @@ class AbgleichRegel
* Prozentschema in Beträge umgerechnet – dieselbe Regel passt so auf * Prozentschema in Beträge umgerechnet – dieselbe Regel passt so auf
* Rechnungen jeder Höhe. * Rechnungen jeder Höhe.
* *
* Die Anteile gelten dabei **zueinander**, nicht absolut: Sie werden auf
* ihre eigene Summe bezogen. Wer 60 zu 30 einträgt, meint zwei Drittel zu
* einem Drittel – bei absoluter Rechnung bekäme die letzte Zeile als Rest
* plötzlich 40 statt 30 Prozent, und niemand fände den Fehler wieder.
*
* @param array<string,mixed> $regel * @param array<string,mixed> $regel
* @param array<string,mixed> $umsatz * @param array<string,mixed> $umsatz
* @return array{kategorie_id: ?int, klasse_id: ?int, gegenkonto_id: ?int, splits: list<array<string,mixed>>} * @return array{kategorie_id: ?int, klasse_id: ?int, gegenkonto_id: ?int, splits: list<array<string,mixed>>}
@ -164,11 +169,18 @@ class AbgleichRegel
$splits = []; $splits = [];
$schema = json_decode((string)($regel['splits'] ?? ''), true); $schema = json_decode((string)($regel['splits'] ?? ''), true);
if (is_array($schema) && $schema !== []) { $schema = is_array($schema) ? $schema : [];
$gesamt = 0.0;
foreach ($schema as $zeile) {
$gesamt += max(0.0, (float)($zeile['anteil'] ?? 0));
}
if ($gesamt > 0.0) {
$summe = 0.0; $summe = 0.0;
foreach ($schema as $nummer => $zeile) { foreach ($schema as $nummer => $zeile) {
$anteil = (float)($zeile['anteil'] ?? 0); $anteil = max(0.0, (float)($zeile['anteil'] ?? 0));
$teil = round($betrag * $anteil, 2); $teil = round($betrag * $anteil / $gesamt, 2);
// Die letzte Zeile bekommt den Rest, damit die Summe der Splits // Die letzte Zeile bekommt den Rest, damit die Summe der Splits
// auf den Cent zum Buchungsbetrag passt – bei 1/3 zu 2/3 ginge // auf den Cent zum Buchungsbetrag passt – bei 1/3 zu 2/3 ginge
@ -196,6 +208,37 @@ class AbgleichRegel
]; ];
} }
/**
* Das gespeicherte Schema zurück in Formularzeilen – Anteile als Prozent,
* denn in Prozent wird auch eingegeben.
*
* @param array<string,mixed> $regel
* @return list<array<string,mixed>>
*/
public static function splitzeilen(array $regel): array
{
$schema = json_decode((string)($regel['splits'] ?? ''), true);
if (!is_array($schema)) {
return [];
}
$zeilen = [];
foreach ($schema as $zeile) {
if (!is_array($zeile)) {
continue;
}
$zeilen[] = [
'kategorie_id' => (int)($zeile['kategorie_id'] ?? 0) ?: null,
'klasse_id' => (int)($zeile['klasse_id'] ?? 0) ?: null,
'prozent' => round((float)($zeile['anteil'] ?? 0) * 100, 2),
'notiz' => (string)($zeile['notiz'] ?? ''),
];
}
return $zeilen;
}
public static function zaehleTreffer(int $id): void public static function zaehleTreffer(int $id): void
{ {
Database::execute('UPDATE abgleich_regeln SET treffer = treffer + 1 WHERE id = ?', [$id]); Database::execute('UPDATE abgleich_regeln SET treffer = treffer + 1 WHERE id = ?', [$id]);
@ -312,8 +355,12 @@ class AbgleichRegel
} }
/** /**
* Bringt ein Split-Schema in Form: je Zeile eine Kategorie und ein Anteil * Bringt ein Split-Schema in Form: je Zeile eine Kategorie und ein Anteil.
* zwischen 0 und 1. Zeilen ohne Kategorie oder ohne Anteil fallen weg. * Zeilen ohne Kategorie oder ohne Anteil fallen weg.
*
* Der Anteil wird nicht auf 1 gedeckelt: Er gilt im Verhältnis zu den
* übrigen Zeilen (siehe `anwenden()`), 100 Prozent sind also keine Grenze,
* sondern nur der bequemste Weg, ein Verhältnis aufzuschreiben.
* *
* @param array<int|string, mixed> $roh * @param array<int|string, mixed> $roh
*/ */
@ -334,7 +381,7 @@ class AbgleichRegel
$zeilen[] = [ $zeilen[] = [
'kategorie_id' => $kategorie, 'kategorie_id' => $kategorie,
'klasse_id' => (int)($zeile['klasse_id'] ?? 0) ?: null, 'klasse_id' => (int)($zeile['klasse_id'] ?? 0) ?: null,
'anteil' => round(min($anteil, 1.0), 4), 'anteil' => round($anteil, 4),
'notiz' => mb_substr(trim((string)($zeile['notiz'] ?? '')), 0, 255), 'notiz' => mb_substr(trim((string)($zeile['notiz'] ?? '')), 0, 255),
]; ];
} }

View file

@ -22,8 +22,8 @@ include __DIR__ . '/../layout/kopf.php';
<p class="hinweis"> <p class="hinweis">
Eine Regel prüft ein Muster gegen Empfänger und Verwendungszweck und hängt bei einem Eine Regel prüft ein Muster gegen Empfänger und Verwendungszweck und hängt bei einem
Treffer Kategorie, Klasse oder ein Gegenkonto an. Die erste passende Regel gewinnt – Treffer Kategorie, Klasse, ein Gegenkonto oder eine Splitbuchung an. Die erste passende
sortiert nach Priorität, kleinere Zahl zuerst. Regel gewinnt – sortiert nach Priorität, kleinere Zahl zuerst.
</p> </p>
<div class="seitenaktionen"> <div class="seitenaktionen">
@ -122,6 +122,23 @@ include __DIR__ . '/../layout/kopf.php';
class="knopf klein gefahr">Löschen</button> class="knopf klein gefahr">Löschen</button>
</td> </td>
</tr> </tr>
<?php $splitZeilen = AbgleichRegel::splitzeilen($regel); ?>
<tr class="regelsplitzeile<?= (int)$regel['aktiv'] === 1 ? '' : ' inaktiv' ?>">
<td colspan="10">
<?php /* Zugeklappt, solange die Regel keine Splitbuchung macht –
sonst stünde unter jeder Regel eine leere Tabelle. */ ?>
<details<?= $splitZeilen === [] ? '' : ' open' ?>>
<summary>
Splitbuchung
<?php if ($splitZeilen !== []): ?>
<span class="nebentext">(<?= count($splitZeilen) ?> Zeilen)</span>
<?php endif; ?>
</summary>
<?php $splitForm = $f; ?>
<?php include __DIR__ . '/regelsplits.php'; ?>
</details>
</td>
</tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
</table> </table>
@ -189,6 +206,10 @@ include __DIR__ . '/../layout/kopf.php';
<?php endforeach; ?> <?php endforeach; ?>
</select> </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> <label for="prioritaet">Priorität</label>
<input type="number" id="prioritaet" name="prioritaet" value="100"> <input type="number" id="prioritaet" name="prioritaet" value="100">

View file

@ -0,0 +1,77 @@
<?php
use Finanzmanager\View;
/**
* Die Splitzeilen einer Abgleichregel – einmal in der Regeltabelle, einmal im
* Formular für die neue Regel. Erwartet vom Aufrufer:
*
* @var list<array<string,mixed>> $splitZeilen
* @var string $splitForm Kennung des zugehörigen Formulars; leer, wenn die
* Felder ohnehin im umgebenden Formular stehen
* @var list<array<string,mixed>> $kategorien
* @var list<array<string,mixed>> $klassen
*/
// Ein paar leere Zeilen mehr, als belegt sind: So lässt sich eine Kategorie
// nachtragen, ohne dass ein Knopf und ein Stück JavaScript dafür nötig wären.
$anzahl = max(3, count($splitZeilen) + 2);
for ($i = count($splitZeilen); $i < $anzahl; $i++) {
$splitZeilen[] = ['kategorie_id' => null, 'klasse_id' => null, 'prozent' => '', 'notiz' => ''];
}
$fuerFormular = $splitForm === '' ? '' : ' form="' . View::h($splitForm) . '"';
?>
<table class="liste splittabelle">
<thead>
<tr>
<th>Kategorie</th><th>Klasse</th><th>Notiz</th><th class="rechts">Anteil %</th>
</tr>
</thead>
<tbody>
<?php foreach ($splitZeilen as $nr => $zeile): ?>
<tr>
<td>
<select<?= $fuerFormular ?> name="split[<?= (int)$nr ?>][kategorie_id]">
<option value="">– keine –</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"
<?= (int)($zeile['kategorie_id'] ?? 0) === (int)$kategorie['id'] ? 'selected' : '' ?>>
<?= View::h($kategorie['pfad']) ?>
</option>
<?php endforeach; ?>
</select>
</td>
<td>
<select<?= $fuerFormular ?> name="split[<?= (int)$nr ?>][klasse_id]">
<option value="">– keine –</option>
<?php foreach ($klassen as $klasse): ?>
<option value="<?= (int)$klasse['id'] ?>"
<?= (int)($zeile['klasse_id'] ?? 0) === (int)$klasse['id'] ? 'selected' : '' ?>>
<?= View::h($klasse['name']) ?>
</option>
<?php endforeach; ?>
</select>
</td>
<td>
<input type="text"<?= $fuerFormular ?> name="split[<?= (int)$nr ?>][notiz]"
value="<?= View::h((string)($zeile['notiz'] ?? '')) ?>">
</td>
<td>
<input type="text" class="rechts schmal" inputmode="decimal"<?= $fuerFormular ?>
name="split[<?= (int)$nr ?>][prozent]"
value="<?= $zeile['prozent'] === '' ? '' : View::h(number_format((float)$zeile['prozent'], 2, ',', '')) ?>">
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p class="feldhinweis">
Die Anteile gelten zueinander, nicht als feste Beträge: Bei jedem Buchungsbetrag
werden die Kategorien im selben Verhältnis bebucht. Ob dort 60 und 40 steht oder
3 und 2, macht deshalb keinen Unterschied. Die letzte Zeile bekommt den Rest, damit
die Summe auf den Cent zum Buchungsbetrag passt. Eine Zeile ohne Kategorie oder ohne
Anteil fällt weg; ohne jede Splitzeile bucht die Regel wie bisher auf ihre Kategorie.
</p>

View file

@ -17,6 +17,13 @@ use Finanzmanager\View;
* @var string $csrfToken * @var string $csrfToken
*/ */
// Für die Splitzeilen einer Regel: Die Vorschläge tragen nur Kennungen, hier
// soll der Pfad der Kategorie stehen.
$kategoriepfad = [];
foreach ($kategorien as $eintrag) {
$kategoriepfad[(int)$eintrag['id']] = (string)$eintrag['pfad'];
}
include __DIR__ . '/../layout/kopf.php'; include __DIR__ . '/../layout/kopf.php';
?> ?>
@ -116,28 +123,54 @@ include __DIR__ . '/../layout/kopf.php';
</select> </select>
<?php endif; ?> <?php endif; ?>
<select name="kategorie_id"> <?php $regelsplits = $zuordnung['splits'] ?? []; ?>
<option value="">– ohne Kategorie –</option> <?php if ($regelsplits !== []): ?>
<?php foreach ($kategorien as $kategorie): ?> <?php /* Die Regel teilt den Umsatz auf. Die Zeilen stehen hier
<option value="<?= (int)$kategorie['id'] ?>" nur zum Nachlesen und wandern unverändert mit – geändert
<?= (int)($zuordnung['kategorie_id'] ?? 0) === (int)$kategorie['id'] ? 'selected' : '' ?>> wird eine Aufteilung im Kontoblatt, wo die ganze Buchung
<?= View::h($kategorie['pfad']) ?> samt Summenprüfung vor einem liegt. */ ?>
</option> <ul class="vorschlagssplits">
<?php endforeach; ?> <?php foreach ($regelsplits as $nr => $zeile): ?>
</select> <li>
<span><?= View::h($kategoriepfad[(int)($zeile['kategorie_id'] ?? 0)] ?? 'ohne Kategorie') ?></span>
<span class="betrag <?= View::betragKlasse($zeile['betrag']) ?>">
<?= View::betrag($zeile['betrag']) ?>
</span>
<input type="hidden" name="split[<?= (int)$nr ?>][kategorie_id]"
value="<?= (int)($zeile['kategorie_id'] ?? 0) ?>">
<input type="hidden" name="split[<?= (int)$nr ?>][klasse_id]"
value="<?= (int)($zeile['klasse_id'] ?? 0) ?: '' ?>">
<input type="hidden" name="split[<?= (int)$nr ?>][notiz]"
value="<?= View::h((string)($zeile['notiz'] ?? '')) ?>">
<input type="hidden" name="split[<?= (int)$nr ?>][betrag]"
value="<?= View::h(number_format((float)$zeile['betrag'], 2, '.', '')) ?>">
</li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<select name="kategorie_id">
<option value="">– ohne Kategorie –</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"
<?= (int)($zuordnung['kategorie_id'] ?? 0) === (int)$kategorie['id'] ? 'selected' : '' ?>>
<?= View::h($kategorie['pfad']) ?>
</option>
<?php endforeach; ?>
</select>
<select name="klasse_id"> <select name="klasse_id">
<option value="">– ohne Klasse –</option> <option value="">– ohne Klasse –</option>
<?php foreach ($klassen as $klasse): ?> <?php foreach ($klassen as $klasse): ?>
<option value="<?= (int)$klasse['id'] ?>" <option value="<?= (int)$klasse['id'] ?>"
<?= (int)($zuordnung['klasse_id'] ?? 0) === (int)$klasse['id'] ? 'selected' : '' ?>> <?= (int)($zuordnung['klasse_id'] ?? 0) === (int)$klasse['id'] ? 'selected' : '' ?>>
<?= View::h($klasse['name']) ?> <?= View::h($klasse['name']) ?>
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<?php endif; ?>
<button type="submit" class="knopf klein <?= $buchung === null ? 'primaer' : '' ?>"> <button type="submit" class="knopf klein <?= $buchung === null ? 'primaer' : '' ?>">
Als neue Buchung <?= $regelsplits === [] ? 'Als neue Buchung' : 'Als Splitbuchung' ?>
</button> </button>
</form> </form>

View file

@ -43,9 +43,11 @@ class BankingTest extends TestCase
$this->umsaetzeKommenNurEinmalHerein(); $this->umsaetzeKommenNurEinmalHerein();
$this->regelnGreifenNachPrioritaet(); $this->regelnGreifenNachPrioritaet();
$this->regelnRechnenSplitsAus(); $this->regelnRechnenSplitsAus();
$this->splitsHaltenDasVerhaeltnis();
$this->ungueltigesMusterWirdAbgewiesen(); $this->ungueltigesMusterWirdAbgewiesen();
$this->abgleichFindetVorhandeneBuchung(); $this->abgleichFindetVorhandeneBuchung();
$this->uebernehmenErzeugtEineBuchung(); $this->uebernehmenErzeugtEineBuchung();
$this->regelmitSplitsErzeugtEineSplitbuchung();
$this->verwerfenUndZuruecksetzen(); $this->verwerfenUndZuruecksetzen();
$this->abrufprotokollHaeltDenVerlauf(); $this->abrufprotokollHaeltDenVerlauf();
} }
@ -529,6 +531,55 @@ class BankingTest extends TestCase
AbgleichRegel::loeschen($id); AbgleichRegel::loeschen($id);
} }
/**
* Der eigentliche Zweck des Schemas: Dieselbe Regel trifft bei jedem
* Buchungsbetrag dasselbe Verhältnis. Und die Anteile gelten zueinander –
* 60 zu 30 sind zwei Drittel zu einem Drittel, nicht 60 Prozent und ein
* Rest von 40.
*/
private function splitsHaltenDasVerhaeltnis(): void
{
$id = AbgleichRegel::anlegen([
'name' => 'Nebenkosten geteilt',
'feld' => 'empfaenger',
'vergleich' => 'enthaelt',
'muster' => 'Hausverwaltung',
'splits' => [
['kategorie_id' => $this->miete, 'anteil' => 0.60],
['kategorie_id' => $this->strom, 'anteil' => 0.30],
],
'aktiv' => 1,
]);
$regel = AbgleichRegel::find($id);
$klein = AbgleichRegel::anwenden($regel, ['empfaenger' => 'Hausverwaltung', 'betrag' => -300.00]);
$this->assertEquals(-200.00, $klein['splits'][0]['betrag'], 'Zwei Drittel von 300');
$this->assertEquals(-100.00, $klein['splits'][1]['betrag'], 'Ein Drittel von 300');
// Derselbe Schlüssel bei anderer Rechnungshöhe: Das Verhältnis bleibt.
$gross = AbgleichRegel::anwenden($regel, ['empfaenger' => 'Hausverwaltung', 'betrag' => -450.00]);
$this->assertEquals(-300.00, $gross['splits'][0]['betrag'], 'Zwei Drittel von 450');
$this->assertEquals(-150.00, $gross['splits'][1]['betrag'], 'Ein Drittel von 450');
// Auch ein Guthaben – eine Rückzahlung der Nebenkosten – wird geteilt.
$zurueck = AbgleichRegel::anwenden($regel, ['empfaenger' => 'Hausverwaltung', 'betrag' => 90.00]);
$this->assertEquals(60.00, $zurueck['splits'][0]['betrag'], 'Erstattung nach demselben Schlüssel');
$this->assertEquals(
90.00,
round(array_sum(array_column($zurueck['splits'], 'betrag')), 2),
'Auch hier ergeben die Zeilen den Buchungsbetrag'
);
// Was die Oberfläche zum Bearbeiten zurückbekommt, ist wieder Prozent.
$zeilen = AbgleichRegel::splitzeilen($regel);
$this->assertEquals(2, count($zeilen), 'Zwei Zeilen für das Formular');
$this->assertEquals(60.0, $zeilen[0]['prozent'], 'Der Anteil steht als Prozentwert');
$this->assertEquals($this->strom, $zeilen[1]['kategorie_id'], 'Mit seiner Kategorie');
AbgleichRegel::loeschen($id);
}
private function ungueltigesMusterWirdAbgewiesen(): void private function ungueltigesMusterWirdAbgewiesen(): void
{ {
$this->assert( $this->assert(
@ -678,6 +729,54 @@ class BankingTest extends TestCase
); );
} }
/**
* Der ganze Weg: Eine Regel mit Splitschema trifft einen offenen Umsatz, und
* was der Vorschlag ausrechnet, wird auch so gebucht. Der Abgleichdialog
* reicht genau diese Zeilen weiter – tat er das nicht, landete der Umsatz
* ungeteilt im Kontoblatt, ohne dass jemand etwas davon merkte.
*/
private function regelmitSplitsErzeugtEineSplitbuchung(): void
{
$regelId = AbgleichRegel::anlegen([
'name' => 'Abschlag geteilt',
'feld' => 'empfaenger',
'vergleich' => 'enthaelt',
'muster' => 'Stadtwerke Nord',
'splits' => [
['kategorie_id' => $this->strom, 'anteil' => 0.75],
['kategorie_id' => $this->miete, 'anteil' => 0.25],
],
'prioritaet' => 1,
'aktiv' => 1,
]);
$umsatzId = $this->umsatzAnlegen([
'konto_id' => $this->giro, 'buchungstag' => '2026-08-07', 'betrag' => -240.00,
'empfaenger' => 'Stadtwerke Nord AG', 'verwendungszweck' => 'Abschlag August',
]);
$umsatz = Bankumsatz::find($umsatzId);
$vorschlag = Bankumsatz::vorschlag($umsatz);
$this->assertEquals('uebernehmen', $vorschlag['art'], 'Ohne passende Buchung wird übernommen');
$this->assertNotEmpty($vorschlag['regel'], 'Die Regel greift');
$this->assertEquals(2, count($vorschlag['zuordnung']['splits']), 'Der Vorschlag bringt zwei Zeilen mit');
$buchungId = Bankumsatz::uebernehmen($umsatzId, $vorschlag['zuordnung']);
$buchung = Buchung::find($buchungId);
$this->assertEquals(1, (int)$buchung['gesplittet'], 'Es entsteht eine Splitbuchung');
$this->assertNull($buchung['kategorie_id'], 'Die Zuordnung steckt in den Zeilen, nicht in der Buchung');
$splits = Buchung::splits($buchungId);
$this->assertEquals(2, count($splits), 'Zwei Splitzeilen im Kontoblatt');
$this->assertEquals(-180.00, (float)$splits[0]['betrag'], 'Drei Viertel von 240');
$this->assertEquals(-60.00, (float)$splits[1]['betrag'], 'Ein Viertel von 240');
$this->assertEquals($this->strom, (int)$splits[0]['kategorie_id'], 'Mit der Kategorie aus der Regel');
AbgleichRegel::loeschen($regelId);
}
private function verwerfenUndZuruecksetzen(): void private function verwerfenUndZuruecksetzen(): void
{ {
$id = $this->umsatzAnlegen([ $id = $this->umsatzAnlegen([