Merge pull request 'Splitbuchungen in den Regeln für den Abgleich' (#16) from fds2610-bot/Finanzmanager:main into main
Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/16
This commit is contained in:
commit
930584aa04
9 changed files with 423 additions and 30 deletions
22
Changes.md
22
Changes.md
|
|
@ -1,5 +1,27 @@
|
|||
# Ä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)
|
||||
|
||||
Dritter Anbieter im Kursabruf: **Yahoo Finanzen**, ohne Schlüssel und ohne Tageskontingent.
|
||||
|
|
|
|||
|
|
@ -251,9 +251,30 @@ Eine Regel prüft ein Muster gegen Empfänger und/oder Verwendungszweck:
|
|||
so.
|
||||
* **Betragsgrenzen** rechnen mit Vorzeichen: „von −60 bis −40“ trifft Lastschriften
|
||||
zwischen 40 und 60 Euro.
|
||||
* **Wirkung**: Kategorie, Klasse, Gegenkonto (macht daraus eine Umbuchung) oder ein festes
|
||||
**Split-Schema** in Prozent. Die letzte Splitzeile bekommt den Rest, damit die Summe auf
|
||||
den Cent zum Buchungsbetrag passt.
|
||||
* **Wirkung**: Kategorie, Klasse, Gegenkonto (macht daraus eine Umbuchung) oder eine
|
||||
**Splitbuchung**.
|
||||
|
||||
### 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
|
||||
Sammeln über mehrere Regeln: Wenn zwei verschiedene Kategorien setzen wollten, wäre am Ende
|
||||
|
|
|
|||
|
|
@ -1688,6 +1688,40 @@ table.bericht .wertlink:hover {
|
|||
.regeltabelle select,
|
||||
.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 ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Die Kennzahlen über der Depotübersicht: Wert, Einstand, Gewinn, Erträge –
|
||||
|
|
|
|||
|
|
@ -685,11 +685,50 @@ class BankingController extends BaseController
|
|||
'kategorie_id' => $this->post('kategorie_id'),
|
||||
'klasse_id' => $this->post('klasse_id'),
|
||||
'gegenkonto_id' => $this->post('gegenkonto_id'),
|
||||
'splits' => $this->regelsplits(),
|
||||
'prioritaet' => $this->post('prioritaet', '100'),
|
||||
'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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -154,6 +154,11 @@ class AbgleichRegel
|
|||
* Prozentschema in Beträge umgerechnet – dieselbe Regel passt so auf
|
||||
* 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> $umsatz
|
||||
* @return array{kategorie_id: ?int, klasse_id: ?int, gegenkonto_id: ?int, splits: list<array<string,mixed>>}
|
||||
|
|
@ -164,11 +169,18 @@ class AbgleichRegel
|
|||
$splits = [];
|
||||
|
||||
$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;
|
||||
foreach ($schema as $nummer => $zeile) {
|
||||
$anteil = (float)($zeile['anteil'] ?? 0);
|
||||
$teil = round($betrag * $anteil, 2);
|
||||
$anteil = max(0.0, (float)($zeile['anteil'] ?? 0));
|
||||
$teil = round($betrag * $anteil / $gesamt, 2);
|
||||
|
||||
// Die letzte Zeile bekommt den Rest, damit die Summe der Splits
|
||||
// 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
|
||||
{
|
||||
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
|
||||
* zwischen 0 und 1. Zeilen ohne Kategorie oder ohne Anteil fallen weg.
|
||||
* Bringt ein Split-Schema in Form: je Zeile eine Kategorie und ein Anteil.
|
||||
* 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
|
||||
*/
|
||||
|
|
@ -334,7 +381,7 @@ class AbgleichRegel
|
|||
$zeilen[] = [
|
||||
'kategorie_id' => $kategorie,
|
||||
'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),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
|
||||
<p class="hinweis">
|
||||
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 –
|
||||
sortiert nach Priorität, kleinere Zahl zuerst.
|
||||
Treffer Kategorie, Klasse, ein Gegenkonto oder eine Splitbuchung an. Die erste passende
|
||||
Regel gewinnt – sortiert nach Priorität, kleinere Zahl zuerst.
|
||||
</p>
|
||||
|
||||
<div class="seitenaktionen">
|
||||
|
|
@ -122,6 +122,23 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
class="knopf klein gefahr">Löschen</button>
|
||||
</td>
|
||||
</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; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -189,6 +206,10 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<?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">
|
||||
|
||||
|
|
|
|||
77
src/Views/banking/regelsplits.php
Normal file
77
src/Views/banking/regelsplits.php
Normal 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>
|
||||
|
|
@ -17,6 +17,13 @@ use Finanzmanager\View;
|
|||
* @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';
|
||||
?>
|
||||
|
||||
|
|
@ -116,28 +123,54 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</select>
|
||||
<?php endif; ?>
|
||||
|
||||
<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>
|
||||
<?php $regelsplits = $zuordnung['splits'] ?? []; ?>
|
||||
<?php if ($regelsplits !== []): ?>
|
||||
<?php /* Die Regel teilt den Umsatz auf. Die Zeilen stehen hier
|
||||
nur zum Nachlesen und wandern unverändert mit – geändert
|
||||
wird eine Aufteilung im Kontoblatt, wo die ganze Buchung
|
||||
samt Summenprüfung vor einem liegt. */ ?>
|
||||
<ul class="vorschlagssplits">
|
||||
<?php foreach ($regelsplits as $nr => $zeile): ?>
|
||||
<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">
|
||||
<option value="">– ohne Klasse –</option>
|
||||
<?php foreach ($klassen as $klasse): ?>
|
||||
<option value="<?= (int)$klasse['id'] ?>"
|
||||
<?= (int)($zuordnung['klasse_id'] ?? 0) === (int)$klasse['id'] ? 'selected' : '' ?>>
|
||||
<?= View::h($klasse['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<select name="klasse_id">
|
||||
<option value="">– ohne Klasse –</option>
|
||||
<?php foreach ($klassen as $klasse): ?>
|
||||
<option value="<?= (int)$klasse['id'] ?>"
|
||||
<?= (int)($zuordnung['klasse_id'] ?? 0) === (int)$klasse['id'] ? 'selected' : '' ?>>
|
||||
<?= View::h($klasse['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php endif; ?>
|
||||
|
||||
<button type="submit" class="knopf klein <?= $buchung === null ? 'primaer' : '' ?>">
|
||||
Als neue Buchung
|
||||
<?= $regelsplits === [] ? 'Als neue Buchung' : 'Als Splitbuchung' ?>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
|
|
|||
|
|
@ -43,9 +43,11 @@ class BankingTest extends TestCase
|
|||
$this->umsaetzeKommenNurEinmalHerein();
|
||||
$this->regelnGreifenNachPrioritaet();
|
||||
$this->regelnRechnenSplitsAus();
|
||||
$this->splitsHaltenDasVerhaeltnis();
|
||||
$this->ungueltigesMusterWirdAbgewiesen();
|
||||
$this->abgleichFindetVorhandeneBuchung();
|
||||
$this->uebernehmenErzeugtEineBuchung();
|
||||
$this->regelmitSplitsErzeugtEineSplitbuchung();
|
||||
$this->verwerfenUndZuruecksetzen();
|
||||
$this->abrufprotokollHaeltDenVerlauf();
|
||||
}
|
||||
|
|
@ -529,6 +531,55 @@ class BankingTest extends TestCase
|
|||
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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$id = $this->umsatzAnlegen([
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue