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:
parent
eb08cd6b83
commit
a7bfbe39e0
9 changed files with 423 additions and 30 deletions
22
Changes.md
22
Changes.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 –
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
||||||
|
|
|
||||||
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
|
* @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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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([
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue