Fix #124: Umbuchungskonten in jeder Kategorieauswahl
Unter den Kategorien stehen überall die aktiven Umbuchungskonten, je Kontengruppe gruppiert und in deren Reihenfolge (Konto::umbuchungsgruppen()): Regeln samt Splitzeilen, Umsatzabgleich, Buchungsvorlagen, Regelmäßige Buchungen, Zahlungsverkehr, Verträge sowie die Filter von Buchungsbericht und Export. Die Masken nutzen dafür die gemeinsame Liste Kategorie / Gegenkonto der Buchungsmaske; das getrennte Gegenkonto-Feld der Regel entfällt. Regel-Splitzeilen können auf ein Umbuchungskonto zeigen. Zahlungsaufträge und Verträge bekommen ein Gegenkonto (Migration 030). Ein schon gewähltes inaktives Konto bleibt sichtbar, damit keine Zuordnung verloren geht. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0e8000e3dc
commit
c58837dee8
37 changed files with 859 additions and 206 deletions
|
|
@ -8,6 +8,10 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
|
|||
|
||||
### Added
|
||||
|
||||
- Kategorieauswahl: Unter den Kategorien stehen überall die aktiven Umbuchungskonten,
|
||||
sortiert und gruppiert nach Kontengruppe – in Regeln samt Splitzeilen, Umsatzabgleich,
|
||||
Buchungsvorlagen, Regelmäßigen Buchungen, Zahlungsverkehr, Verträgen sowie in den
|
||||
Filtern von Buchungsbericht und Export (#124)
|
||||
- Buchungsmaske: Schalter „Bewusst ohne Kategorie“ für Abschreibungen und rein rechnerische
|
||||
Erträge; solche Buchungen gelten im Cockpit, im Monatsabschluss und in der
|
||||
Kontenübersicht nicht als offen (#134)
|
||||
|
|
|
|||
11
Changes.md
11
Changes.md
|
|
@ -1,5 +1,16 @@
|
|||
# Änderungen
|
||||
|
||||
## Umbuchungskonten in jeder Kategorieauswahl (12.10.2026)
|
||||
|
||||
- Anlass (#124): In den Splitzeilen der Regeln, beim Anlegen einer Regel, im Filter des Buchungsberichts und weiteren Listen fehlten die Umbuchungskonten unter den Kategorien.
|
||||
- Die gemeinsame Liste *Kategorie / Gegenkonto* der Buchungsmaske (`Views/buchungen/zuordnungsliste.php`) steht jetzt überall, wo eine Kategorie gewählt wird: Regeln (Neue Regel, Regeltabelle, Regeldialog im Kontoblatt) samt Splitzeilen, Umsatzabgleich, Buchungsvorlagen, Regelmäßige Buchungen, Zahlungsverkehr und Verträge. Das getrennte Feld „Gegenkonto“ der Regel entfällt; die Regeltabelle verlor bisher beim Speichern ein Gegenkonto, weil sie es gar nicht mitschickte.
|
||||
- Angeboten werden nur aktive Umbuchungskonten, in eigenen Gruppen je Kontengruppe und in deren Reihenfolge (`Konto::umbuchungsgruppen()`). Ein schon gewähltes inaktives Konto bleibt sichtbar; die übrigen inaktiven stehen verborgen in der Liste, damit bestehende Splitzeilen beim Bearbeiten nicht verloren gehen (`kombobox.js` blendet sie aus).
|
||||
- Regel-Splitzeilen können auf ein Umbuchungskonto zeigen; daraus wird eine Splitzeile mit Gegenkonto. Auch „Regel anlegen“ aus einer Buchung übernimmt solche Zeilen.
|
||||
- Buchungsbericht: Umbuchungskonten stehen unter den Kategorien im Filter und bringen ihre Umbuchungen auch ohne „Umbuchungen mitzählen“ mit. Export: Umbuchungskonten unter den Kategorien (`gegenkonten[]`).
|
||||
- Zahlungsaufträge und Verträge bekommen ein Gegenkonto (Migration `030_gegenkonto_auftrag_vertrag.sql`); eine Überweisung aufs eigene Tagesgeld wird beim Senden als Umbuchung gebucht.
|
||||
- Nicht betroffen: Auswahllisten, die keine Buchung zuordnen (Wertpapierkosten in den Einstellungen, Bargeld-Tool, Ober-/Zielkategorie in der Kategorieverwaltung, Budgetplaner).
|
||||
- Tests: `tests/61_UmbuchungskontenAuswahlTest.php`, `tests/e2e/specs/regelausbuchung.spec.js`.
|
||||
|
||||
## Regelmäßige Buchungen: automatisch abgleichen (12.10.2026)
|
||||
|
||||
- Anlass (#123): Buchungen aus regelmäßigen Buchungen mussten nach dem Buchen einzeln abgeglichen werden.
|
||||
|
|
|
|||
|
|
@ -113,6 +113,13 @@ Kategorien zeigen die Summe ihres Astes.
|
|||
Eine gewählte Kategorie **schließt ihre Unterkategorien ein** – wer „400 - Leben“ filtert,
|
||||
meint den ganzen Ast. Nichts ausgewählt heißt alles.
|
||||
|
||||
Im **Buchungsbericht** stehen unter den Kategorien zusätzlich die aktiven Umbuchungskonten,
|
||||
je Kontengruppe (#124). Ihr Wert `konto:7` landet im Filter als `gegenkonten`; eine
|
||||
Bewegung passt, wenn sie zu einer gewählten Kategorie **oder** einem gewählten
|
||||
Umbuchungskonto gehört. Ein gewähltes Umbuchungskonto bringt seine Umbuchungen auch ohne
|
||||
„Umbuchungen mitzählen“ mit – sonst bliebe die Auswahl leer. Die übrigen Berichte zählen
|
||||
Umbuchungen nicht und bieten die Konten deshalb nicht an.
|
||||
|
||||
Zwei Berichte bestimmen ihre Zeilen selbst und sperren das Feld: der Kontostandsbericht
|
||||
führt immer Konten, der Budgetbericht immer Kategorien. Zwei bestimmen ihre Spalten
|
||||
selbst: Vergleichs- und Budgetbericht.
|
||||
|
|
|
|||
|
|
@ -453,13 +453,17 @@ 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 eine
|
||||
**Splitbuchung**.
|
||||
* **Wirkung**: Kategorie oder Gegenkonto (macht daraus eine Umbuchung) aus der
|
||||
gemeinsamen Liste *Kategorie / Gegenkonto*, dazu eine Klasse – oder eine
|
||||
**Splitbuchung**. Die Liste ist dieselbe wie in der Buchungsmaske: unter den Kategorien
|
||||
die aktiven Umbuchungskonten je Kontengruppe (#124).
|
||||
|
||||
### 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
|
||||
Unter jeder Regel klappt *Splitbuchung* die Zeilen auf: je Zeile eine Kategorie oder ein
|
||||
Umbuchungskonto, wahlweise Klasse und Notiz, dazu ein Anteil in Prozent. Eine Zeile mit
|
||||
Umbuchungskonto wird zur Splitzeile mit Gegenkonto – etwa der Sparanteil einer Überweisung,
|
||||
die sonst Lebensmittel bezahlt. 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:
|
||||
|
|
@ -473,7 +477,7 @@ Fehler wäre in der fertigen Buchung nicht mehr zu sehen. Die letzte Zeile bekom
|
|||
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
|
||||
Eine Zeile ohne Kategorie bzw. Gegenkonto 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.
|
||||
|
|
|
|||
|
|
@ -57,10 +57,14 @@ nicht. Deshalb stehen Kategorien und Konten in derselben Auswahlliste; die Konte
|
|||
in eckigen Klammern, wie das Kategoriefeld im FinanzManager sie schreibt. Das eigene Konto
|
||||
fehlt in der Liste.
|
||||
|
||||
Die Liste steht in `Views/buchungen/zuordnungsliste.php` und wird an vier Stellen
|
||||
eingebunden: Buchungsmaske, deren Splitzeilen, Schnellzuordnung im Kontoblatt und
|
||||
Splitdialog. Die Werte tragen ein Präfix, das `BuchungenController::zuordnungAus()` wieder
|
||||
auseinandernimmt:
|
||||
Die Liste steht in `Views/buchungen/zuordnungsliste.php`. Sie wird nicht nur in der
|
||||
Buchungsmaske, deren Splitzeilen, der Schnellzuordnung im Kontoblatt und im Splitdialog
|
||||
eingebunden, sondern überall, wo eine Kategorie gewählt wird (#124): Regeln samt ihren
|
||||
Splitzeilen, Umsatzabgleich, Buchungsvorlagen, Regelmäßige Buchungen, Zahlungsverkehr und
|
||||
Verträge. Die Werte tragen ein Präfix, das `BuchungenController::zuordnungAus()` wieder
|
||||
auseinandernimmt (`zuordnungAusFormular()` nimmt ersatzweise die versteckten Einzelfelder
|
||||
`kategorie_id`/`gegenkonto_id` einer Tabellenzeile, `zuordnungswert()` baut den Wert für die
|
||||
Vorauswahl):
|
||||
|
||||
| Wert | Bedeutung |
|
||||
|---|---|
|
||||
|
|
@ -72,6 +76,17 @@ auseinandernimmt:
|
|||
|
||||
Die Auswahl *Ausgabe / Einnahme* bestimmt seither nur noch die Richtung des Betrags.
|
||||
|
||||
Angeboten werden nur **aktive** Umbuchungskonten, je Kontengruppe in einer eigenen Gruppe
|
||||
(„Umbuchungskonten – Liquide Mittel“, ohne Kontengruppen schlicht „Umbuchungskonten“) und
|
||||
in der Reihenfolge der Kontengruppen (`Konto::umbuchungsgruppen()`). Ein inaktives Konto,
|
||||
das schon gewählt ist, bleibt sichtbar stehen – sonst ginge die Zuordnung beim nächsten
|
||||
Speichern verloren. Alle übrigen inaktiven Konten stehen als verborgene Einträge
|
||||
(`<option hidden>`) in der Liste, damit `kontoblatt.js` eine bestehende Splitzeile damit
|
||||
vorbelegen kann; die Kombobox zeigt sie nur an, solange sie gewählt sind.
|
||||
|
||||
Die Filter von Buchungsbericht und Export führen die Umbuchungskonten ebenfalls unter den
|
||||
Kategorien (siehe `docs/auswertungen.md` und `docs/import-export.md`).
|
||||
|
||||
Ein Depot steht **zweimal** in der Liste: als gewöhnliche Umbuchung (`konto:7`) und in der
|
||||
Gruppe *Wertpapiergeschäft im Depot* (`depot:7`). Beides gibt es wirklich – auf ein Depot
|
||||
lässt sich Geld überweisen, ohne dass etwas gekauft wird. `depot:7` speichert keine
|
||||
|
|
|
|||
|
|
@ -259,6 +259,9 @@ Zwei Eigenheiten des Datenmodells schlagen auf den Filter durch:
|
|||
Auszug des Gegenkontos seine einzige Bewegung.
|
||||
* **Kategorie und Klasse zählen auch in den Splitzeilen.** Sonst fiele jede Splitbuchung
|
||||
aus dem Filter, obwohl sie die gesuchte Kategorie führt.
|
||||
* **Unter den Kategorien stehen die aktiven Umbuchungskonten** (#124, `gegenkonten[]`).
|
||||
Ein gewähltes Konto nimmt jede Buchung oder Splitzeile mit diesem Gegenkonto mit;
|
||||
Kategorien und Umbuchungskonten verbinden sich als „oder“, wie in einer Auswahlliste.
|
||||
|
||||
Die Kontenliste der Datei enthält zusätzlich jedes Konto, das nur Umbuchungsziel ist. Ohne
|
||||
seinen Eintrag bezöge sich die Datei auf ein Konto, das der Import nicht kennt, und die
|
||||
|
|
|
|||
|
|
@ -95,6 +95,10 @@ vierteljährlich × 4 und so fort.
|
|||
|
||||
Ein Vertrag kann mit einer regelmäßigen Buchung verbunden sein, die seine Zahlung erledigt.
|
||||
|
||||
Statt einer Kategorie kann ein Vertrag auf ein Umbuchungskonto zeigen – ein Bauspar- oder
|
||||
Sparvertrag etwa (#124, Spalte `vertraege.gegenkonto_id`). Die Auswahl ist dieselbe Liste
|
||||
*Kategorie / Gegenkonto* wie in der Buchungsmaske.
|
||||
|
||||
## Sparziele
|
||||
|
||||
Der erreichte Stand kommt **aus dem Kontosaldo**, nicht aus einem eigenen Zähler – ein
|
||||
|
|
@ -135,6 +139,12 @@ Fehlgeschlagene tragen die Meldung der Bank daneben.
|
|||
Ohne Ausführungstermin geht die Überweisung sofort hinaus, mit Termin als
|
||||
Terminüberweisung; sie erscheint dann im Finanzkalender.
|
||||
|
||||
**Kategorie / Gegenkonto.** Wie in der Buchungsmaske stehen unter den Kategorien die
|
||||
aktiven Umbuchungskonten (#124, Spalte `zahlungsauftraege.gegenkonto_id`). Geht die
|
||||
Überweisung auf ein eigenes Konto – etwa aufs Tagesgeld –, wird sie beim Senden gleich als
|
||||
Umbuchung gebucht. Das Auftragskonto selbst als Gegenkonto weist schon das Erfassen ab;
|
||||
erst beim Senden fiele es sonst auf, wenn die Bank das Geld längst hat.
|
||||
|
||||
**Echtzeitüberweisung.** Das Häkchen „Echtzeitüberweisung“ im Überweisungsdialog
|
||||
(Spalte `zahlungsauftraege.echtzeit`) macht aus dem Auftrag eine SEPA-Echtzeitüberweisung
|
||||
(SCT Inst): Das Geld ist binnen Sekunden beim Empfänger. SEPA behandelt sie als eigenes
|
||||
|
|
|
|||
19
migrations/030_gegenkonto_auftrag_vertrag.sql
Normal file
19
migrations/030_gegenkonto_auftrag_vertrag.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
-- Umbuchungskonten in jeder Kategorieauswahl (#124).
|
||||
--
|
||||
-- Überall, wo eine Kategorie gewählt wird, stehen darunter jetzt auch die
|
||||
-- aktiven Umbuchungskonten. Buchungen, Splitzeilen, Vorlagen, regelmäßige
|
||||
-- Buchungen und Regeln kennen das Gegenkonto schon; Zahlungsaufträge und
|
||||
-- Verträge bekommen es hier. Eine Überweisung auf das eigene Tagesgeldkonto
|
||||
-- wird so beim Senden gleich als Umbuchung gebucht.
|
||||
--
|
||||
-- Idempotent über IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE zahlungsauftraege ADD COLUMN IF NOT EXISTS
|
||||
gegenkonto_id INT UNSIGNED NULL AFTER kategorie_id;
|
||||
ALTER TABLE zahlungsauftraege ADD CONSTRAINT fk_auftrag_gegenkonto FOREIGN KEY IF NOT EXISTS (gegenkonto_id)
|
||||
REFERENCES konten (id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE vertraege ADD COLUMN IF NOT EXISTS
|
||||
gegenkonto_id INT UNSIGNED NULL AFTER kategorie_id;
|
||||
ALTER TABLE vertraege ADD CONSTRAINT fk_vertrag_gegenkonto FOREIGN KEY IF NOT EXISTS (gegenkonto_id)
|
||||
REFERENCES konten (id) ON DELETE SET NULL;
|
||||
|
|
@ -43,8 +43,10 @@
|
|||
return gruppen;
|
||||
}
|
||||
|
||||
/* Ein verborgener Eintrag (etwa ein inaktives Umbuchungskonto) bleibt als
|
||||
* Wert gültig, erscheint in der Liste aber nur, solange er gewählt ist. */
|
||||
function eintragAus(option) {
|
||||
return { wert: option.value, text: option.textContent.trim() };
|
||||
return { wert: option.value, text: option.textContent.trim(), verborgen: option.hidden };
|
||||
}
|
||||
|
||||
function passt(text, filter) {
|
||||
|
|
@ -130,7 +132,8 @@
|
|||
|
||||
gruppen.forEach(function (gruppe) {
|
||||
const treffer = gruppe.optionen.filter(function (eintrag) {
|
||||
return passt(eintrag.text, filterText);
|
||||
return (!eintrag.verborgen || eintrag.wert === select.value)
|
||||
&& passt(eintrag.text, filterText);
|
||||
});
|
||||
if (treffer.length === 0) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -825,7 +825,7 @@ class BankingController extends BaseController
|
|||
'umsaetze' => $umsaetze,
|
||||
'kategorien' => Kategorie::baumFlach(null, true),
|
||||
'klassen' => Klasse::alle(),
|
||||
'konten' => Konto::alle(true),
|
||||
'konten' => Konto::alle(),
|
||||
'offen' => Bankumsatz::anzahlOffen(),
|
||||
]);
|
||||
}
|
||||
|
|
@ -844,11 +844,12 @@ class BankingController extends BaseController
|
|||
if ($buchungId > 0) {
|
||||
Bankumsatz::verbinden($id, $buchungId);
|
||||
} else {
|
||||
$zuordnung = BuchungenController::zuordnungAusFormular($_POST);
|
||||
Bankumsatz::uebernehmen($id, [
|
||||
'konto_id' => $this->post('konto_id') !== '' ? (int)$this->post('konto_id') : null,
|
||||
'kategorie_id' => $this->post('kategorie_id') !== '' ? (int)$this->post('kategorie_id') : null,
|
||||
'kategorie_id' => $zuordnung['kategorie_id'],
|
||||
'klasse_id' => $this->post('klasse_id') !== '' ? (int)$this->post('klasse_id') : null,
|
||||
'gegenkonto_id' => $this->post('gegenkonto_id') !== '' ? (int)$this->post('gegenkonto_id') : null,
|
||||
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
|
||||
'splits' => $this->splitsAusFormular(),
|
||||
]);
|
||||
}
|
||||
|
|
@ -938,7 +939,7 @@ class BankingController extends BaseController
|
|||
'regeln' => AbgleichRegel::alle(),
|
||||
'kategorien' => Kategorie::baumFlach(null, true),
|
||||
'klassen' => Klasse::alle(),
|
||||
'konten' => Konto::alle(true),
|
||||
'konten' => Konto::alle(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -1122,6 +1123,8 @@ class BankingController extends BaseController
|
|||
/** @return array<string,mixed> */
|
||||
private function regeldaten(): array
|
||||
{
|
||||
$zuordnung = BuchungenController::zuordnungAusFormular($_POST);
|
||||
|
||||
return [
|
||||
'name' => $this->post('name'),
|
||||
'feld' => $this->post('feld'),
|
||||
|
|
@ -1129,9 +1132,9 @@ class BankingController extends BaseController
|
|||
'muster' => $this->post('muster'),
|
||||
'betrag_von' => $this->post('betrag_von'),
|
||||
'betrag_bis' => $this->post('betrag_bis'),
|
||||
'kategorie_id' => $this->post('kategorie_id'),
|
||||
'kategorie_id' => $zuordnung['kategorie_id'],
|
||||
'klasse_id' => $this->post('klasse_id'),
|
||||
'gegenkonto_id' => $this->post('gegenkonto_id'),
|
||||
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
|
||||
'splits' => $this->regelsplits(),
|
||||
'prioritaet' => $this->post('prioritaet', '100'),
|
||||
'aktiv' => isset($_POST['aktiv']) ? 1 : 0,
|
||||
|
|
@ -1161,12 +1164,14 @@ class BankingController extends BaseController
|
|||
}
|
||||
|
||||
$prozent = KontenController::zahl((string)($zeile['prozent'] ?? ''));
|
||||
if (($zeile['kategorie_id'] ?? '') === '' || $prozent <= 0.0) {
|
||||
$zuordnung = BuchungenController::zuordnungAusFormular($zeile);
|
||||
if (($zuordnung['kategorie_id'] === null && $zuordnung['gegenkonto_id'] === null) || $prozent <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$splits[] = [
|
||||
'kategorie_id' => (int)$zeile['kategorie_id'],
|
||||
'kategorie_id' => $zuordnung['kategorie_id'],
|
||||
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
|
||||
'klasse_id' => ($zeile['klasse_id'] ?? '') === '' ? null : (int)$zeile['klasse_id'],
|
||||
'anteil' => $prozent / 100,
|
||||
'notiz' => (string)($zeile['notiz'] ?? ''),
|
||||
|
|
@ -1280,8 +1285,10 @@ class BankingController extends BaseController
|
|||
continue;
|
||||
}
|
||||
|
||||
$zuordnung = BuchungenController::zuordnungAusFormular($zeile);
|
||||
$splits[] = [
|
||||
'kategorie_id' => ($zeile['kategorie_id'] ?? '') === '' ? null : (int)$zeile['kategorie_id'],
|
||||
'kategorie_id' => $zuordnung['kategorie_id'],
|
||||
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
|
||||
'klasse_id' => ($zeile['klasse_id'] ?? '') === '' ? null : (int)$zeile['klasse_id'],
|
||||
'betrag' => (float)str_replace(',', '.', (string)$zeile['betrag']),
|
||||
'notiz' => (string)($zeile['notiz'] ?? ''),
|
||||
|
|
|
|||
|
|
@ -692,6 +692,46 @@ class BuchungenController extends BaseController
|
|||
return $splits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Der umgekehrte Weg zu zuordnungAus(): der Auswahlwert für eine Zeile mit
|
||||
* `kategorie_id` und `gegenkonto_id` – das Gegenkonto sticht, wie in der
|
||||
* Buchungsmaske.
|
||||
*
|
||||
* @param array<string,mixed>|null $zeile
|
||||
*/
|
||||
public static function zuordnungswert(?array $zeile): string
|
||||
{
|
||||
if (!empty($zeile['gegenkonto_id'])) {
|
||||
return 'konto:' . (int)$zeile['gegenkonto_id'];
|
||||
}
|
||||
if (!empty($zeile['kategorie_id'])) {
|
||||
return 'kategorie:' . (int)$zeile['kategorie_id'];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Kategorie und Gegenkonto aus einem Formular, das entweder die gemeinsame
|
||||
* Liste `zuordnung` schickt oder – etwa aus versteckten Feldern einer
|
||||
* Tabellenzeile – die beiden Kennungen einzeln.
|
||||
*
|
||||
* @param array<string,mixed> $daten meist $_POST oder eine Splitzeile daraus
|
||||
* @return array{kategorie_id: ?int, gegenkonto_id: ?int}
|
||||
*/
|
||||
public static function zuordnungAusFormular(array $daten): array
|
||||
{
|
||||
if (array_key_exists('zuordnung', $daten)) {
|
||||
return self::zuordnungAus((string)$daten['zuordnung']);
|
||||
}
|
||||
|
||||
$id = static fn(mixed $wert): ?int => is_scalar($wert) && (int)$wert > 0 ? (int)$wert : null;
|
||||
|
||||
return [
|
||||
'kategorie_id' => $id($daten['kategorie_id'] ?? null),
|
||||
'gegenkonto_id' => $id($daten['gegenkonto_id'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Auswahlwert aus der gemeinsamen Liste „Kategorie oder Gegenkonto“:
|
||||
* `kategorie:12`, `konto:7` oder leer.
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ class ExportController extends BaseController
|
|||
|
||||
/**
|
||||
* @return array{von: string, bis: string, konten: list<int>,
|
||||
* kategorien: list<int>, klassen: list<int>}
|
||||
* kategorien: list<int>, gegenkonten: list<int>, klassen: list<int>}
|
||||
*/
|
||||
private function filter(): array
|
||||
{
|
||||
|
|
@ -119,6 +119,7 @@ class ExportController extends BaseController
|
|||
'bis' => $this->datum($this->text('bis')),
|
||||
'konten' => $this->ids($eingabe['konten'] ?? null),
|
||||
'kategorien' => $this->ids($eingabe['kategorien'] ?? null),
|
||||
'gegenkonten' => $this->ids($eingabe['gegenkonten'] ?? null),
|
||||
'klassen' => $this->ids($eingabe['klassen'] ?? null),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -187,15 +187,19 @@ class TermineController extends BaseController
|
|||
/** @return array<string,mixed> */
|
||||
private function formulardaten(): array
|
||||
{
|
||||
// Der Dialog „Neue Reihe“ schickt die gemeinsame Liste, die Tabellenzeilen
|
||||
// Kategorie und Gegenkonto als versteckte Felder.
|
||||
$zuordnung = BuchungenController::zuordnungAusFormular($_POST);
|
||||
|
||||
return [
|
||||
'name' => $this->post('name'),
|
||||
'konto_id' => $this->post('konto_id'),
|
||||
'betrag' => str_replace(',', '.', $this->post('betrag')),
|
||||
'empfaenger' => $this->post('empfaenger'),
|
||||
'buchungstext' => $this->post('buchungstext'),
|
||||
'kategorie_id' => $this->post('kategorie_id'),
|
||||
'kategorie_id' => $zuordnung['kategorie_id'],
|
||||
'klasse_id' => $this->post('klasse_id'),
|
||||
'gegenkonto_id' => $this->post('gegenkonto_id'),
|
||||
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
|
||||
'intervall' => $this->post('intervall'),
|
||||
'faktor' => $this->post('faktor', '1'),
|
||||
'beginn' => $this->post('beginn'),
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ class VertraegeController extends BaseController
|
|||
'fristen' => Vertrag::fristenLaufenAb(90),
|
||||
'adressen' => Adresse::alle(),
|
||||
'kategorien' => Kategorie::baumFlach(null, true),
|
||||
'konten' => Konto::alle(true),
|
||||
'reihen' => RegelmaessigeBuchung::alle(),
|
||||
'intervalle' => RegelmaessigeBuchung::INTERVALLE,
|
||||
]);
|
||||
|
|
@ -281,12 +282,15 @@ class VertraegeController extends BaseController
|
|||
/** @return array<string,mixed> */
|
||||
private function vertragsdaten(): array
|
||||
{
|
||||
$zuordnung = BuchungenController::zuordnungAusFormular($_POST);
|
||||
|
||||
return [
|
||||
'name' => $this->post('name'),
|
||||
'partner' => $this->post('partner'),
|
||||
'adresse_id' => $this->post('adresse_id'),
|
||||
'vertragsnummer' => $this->post('vertragsnummer'),
|
||||
'kategorie_id' => $this->post('kategorie_id'),
|
||||
'kategorie_id' => $zuordnung['kategorie_id'],
|
||||
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
|
||||
'betrag' => str_replace(',', '.', $this->post('betrag')),
|
||||
'intervall' => $this->post('intervall'),
|
||||
'beginn' => $this->post('beginn'),
|
||||
|
|
|
|||
|
|
@ -110,6 +110,8 @@ class VorlagenController extends BaseController
|
|||
/** @return array<string,mixed> */
|
||||
private function formulardaten(): array
|
||||
{
|
||||
$zuordnung = BuchungenController::zuordnungAusFormular($_POST);
|
||||
|
||||
return [
|
||||
'name' => $this->post('name'),
|
||||
'konto_id' => $this->post('konto_id'),
|
||||
|
|
@ -117,9 +119,9 @@ class VorlagenController extends BaseController
|
|||
'empfaenger' => $this->post('empfaenger'),
|
||||
'buchungstext' => $this->post('buchungstext'),
|
||||
'nummer' => $this->post('nummer'),
|
||||
'kategorie_id' => $this->post('kategorie_id'),
|
||||
'kategorie_id' => $zuordnung['kategorie_id'],
|
||||
'klasse_id' => $this->post('klasse_id'),
|
||||
'gegenkonto_id' => $this->post('gegenkonto_id'),
|
||||
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ class ZahlungsverkehrController extends BaseController
|
|||
'konten' => $this->kontenMitBank(),
|
||||
'adressen' => Adresse::alle(),
|
||||
'kategorien' => Kategorie::baumFlach(null, true),
|
||||
'umbuchungskonten' => Konto::alle(),
|
||||
'klassen' => Klasse::alle(),
|
||||
]);
|
||||
}
|
||||
|
|
@ -158,6 +159,7 @@ class ZahlungsverkehrController extends BaseController
|
|||
'konten' => $konten,
|
||||
'adressen' => Adresse::alle(),
|
||||
'kategorien' => Kategorie::baumFlach(null, true),
|
||||
'umbuchungskonten' => Konto::alle(),
|
||||
'klassen' => Klasse::alle(),
|
||||
]);
|
||||
}
|
||||
|
|
@ -322,6 +324,7 @@ class ZahlungsverkehrController extends BaseController
|
|||
private function formulardaten(): array
|
||||
{
|
||||
$kontoId = (int)$this->post('konto_id');
|
||||
$zuordnung = BuchungenController::zuordnungAusFormular($_POST);
|
||||
|
||||
return [
|
||||
'konto_id' => $kontoId,
|
||||
|
|
@ -336,7 +339,8 @@ class ZahlungsverkehrController extends BaseController
|
|||
'verwendungszweck' => $this->post('verwendungszweck'),
|
||||
'ausfuehrung_am' => $this->post('ausfuehrung_am'),
|
||||
'echtzeit' => isset($_POST['echtzeit']),
|
||||
'kategorie_id' => $this->post('kategorie_id'),
|
||||
'kategorie_id' => $zuordnung['kategorie_id'],
|
||||
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
|
||||
'klasse_id' => $this->post('klasse_id'),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ class AbgleichRegel
|
|||
$splitzeilen = array_values(array_filter(
|
||||
Buchung::splits($buchungId),
|
||||
static fn(array $zeile): bool => (int)($zeile['kategorie_id'] ?? 0) !== 0
|
||||
|| (int)($zeile['gegenkonto_id'] ?? 0) !== 0
|
||||
));
|
||||
|
||||
// Anteile am Buchungsbetrag statt roher Beträge: So zeigt der Dialog
|
||||
|
|
@ -142,7 +143,8 @@ class AbgleichRegel
|
|||
$splits = [];
|
||||
foreach ($splitzeilen as $zeile) {
|
||||
$splits[] = [
|
||||
'kategorie_id' => (int)$zeile['kategorie_id'],
|
||||
'kategorie_id' => $zeile['kategorie_id'],
|
||||
'gegenkonto_id' => $zeile['gegenkonto_id'] ?? null,
|
||||
'klasse_id' => $zeile['klasse_id'],
|
||||
'anteil' => $gesamt > 0.0 ? round(abs((float)$zeile['betrag']) / $gesamt, 4) : 0,
|
||||
'notiz' => (string)($zeile['notiz'] ?? ''),
|
||||
|
|
@ -256,6 +258,7 @@ class AbgleichRegel
|
|||
|
||||
$splits[] = [
|
||||
'kategorie_id' => ($zeile['kategorie_id'] ?? null) ?: null,
|
||||
'gegenkonto_id' => ($zeile['gegenkonto_id'] ?? null) ?: null,
|
||||
'klasse_id' => ($zeile['klasse_id'] ?? null) ?: null,
|
||||
'betrag' => $teil,
|
||||
'notiz' => (string)($zeile['notiz'] ?? ''),
|
||||
|
|
@ -293,6 +296,7 @@ class AbgleichRegel
|
|||
|
||||
$zeilen[] = [
|
||||
'kategorie_id' => (int)($zeile['kategorie_id'] ?? 0) ?: null,
|
||||
'gegenkonto_id' => (int)($zeile['gegenkonto_id'] ?? 0) ?: null,
|
||||
'klasse_id' => (int)($zeile['klasse_id'] ?? 0) ?: null,
|
||||
'prozent' => round((float)($zeile['anteil'] ?? 0) * 100, 2),
|
||||
'notiz' => (string)($zeile['notiz'] ?? ''),
|
||||
|
|
@ -418,8 +422,10 @@ class AbgleichRegel
|
|||
}
|
||||
|
||||
/**
|
||||
* Bringt ein Split-Schema in Form: je Zeile eine Kategorie und ein Anteil.
|
||||
* Zeilen ohne Kategorie oder ohne Anteil fallen weg.
|
||||
* Bringt ein Split-Schema in Form: je Zeile eine Kategorie oder ein
|
||||
* Gegenkonto (Umbuchung) und ein Anteil. Zeilen ohne beides oder ohne
|
||||
* Anteil fallen weg; steht beides da, sticht das Gegenkonto – wie in der
|
||||
* gemeinsamen Auswahlliste der Buchungsmaske.
|
||||
*
|
||||
* Der Anteil wird nicht auf 1 gedeckelt: Er gilt im Verhältnis zu den
|
||||
* übrigen Zeilen (siehe `anwenden()`), 100 Prozent sind also keine Grenze,
|
||||
|
|
@ -435,14 +441,16 @@ class AbgleichRegel
|
|||
continue;
|
||||
}
|
||||
$kategorie = (int)($zeile['kategorie_id'] ?? 0);
|
||||
$gegenkonto = (int)($zeile['gegenkonto_id'] ?? 0);
|
||||
$anteil = (float)($zeile['anteil'] ?? 0);
|
||||
|
||||
if ($kategorie === 0 || $anteil <= 0.0) {
|
||||
if (($kategorie === 0 && $gegenkonto === 0) || $anteil <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$zeilen[] = [
|
||||
'kategorie_id' => $kategorie,
|
||||
'kategorie_id' => $gegenkonto === 0 ? $kategorie : null,
|
||||
'gegenkonto_id' => $gegenkonto === 0 ? null : $gegenkonto,
|
||||
'klasse_id' => (int)($zeile['klasse_id'] ?? 0) ?: null,
|
||||
'anteil' => round($anteil, 4),
|
||||
'notiz' => mb_substr(trim((string)($zeile['notiz'] ?? '')), 0, 255),
|
||||
|
|
|
|||
|
|
@ -22,10 +22,11 @@ class Buchungsvorlage
|
|||
public static function alle(string $suche = ''): array
|
||||
{
|
||||
$params = [Mandant::id()];
|
||||
$sql = 'SELECT v.*, k.name AS kontoname, kat.pfad AS kategorie_pfad
|
||||
$sql = 'SELECT v.*, k.name AS kontoname, kat.pfad AS kategorie_pfad, g.name AS gegenkontoname
|
||||
FROM buchungsvorlagen v
|
||||
LEFT JOIN konten k ON k.id = v.konto_id
|
||||
LEFT JOIN kategorien kat ON kat.id = v.kategorie_id
|
||||
LEFT JOIN konten g ON g.id = v.gegenkonto_id
|
||||
WHERE v.benutzer_id = ?';
|
||||
|
||||
if ($suche !== '') {
|
||||
|
|
|
|||
|
|
@ -71,6 +71,45 @@ class Konto
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Umbuchungskonten, wie sie unter den Kategorien jeder Auswahlliste
|
||||
* stehen (#124): nur aktive Konten, je Kontengruppe beschriftet und in der
|
||||
* Reihenfolge von alle(). Ein inaktives Konto erscheint nur, wenn es schon
|
||||
* gewählt ist – sonst ginge eine bestehende Zuordnung beim Speichern
|
||||
* verloren.
|
||||
*
|
||||
* @param list<array<string,mixed>> $konten meist alle()
|
||||
* @param list<int> $gewaehlt Kennungen, die immer bleiben
|
||||
* @return array<string, list<array<string,mixed>>> Beschriftung => Konten
|
||||
*/
|
||||
public static function umbuchungsgruppen(array $konten, array $gewaehlt = []): array
|
||||
{
|
||||
$gewaehlt = array_flip(array_map('intval', $gewaehlt));
|
||||
$gruppen = [];
|
||||
$mitGruppen = false;
|
||||
|
||||
foreach ($konten as $konto) {
|
||||
if ((int)($konto['aktiv'] ?? 1) !== 1 && !isset($gewaehlt[(int)$konto['id']])) {
|
||||
continue;
|
||||
}
|
||||
$gruppe = $konto['gruppenname'] ?? null;
|
||||
$mitGruppen = $mitGruppen || $gruppe !== null;
|
||||
$gruppen[(string)$gruppe][] = $konto;
|
||||
}
|
||||
|
||||
// Ohne eine einzige Kontengruppe reicht eine Überschrift.
|
||||
if (!$mitGruppen) {
|
||||
return $gruppen === [] ? [] : ['Umbuchungskonten' => $gruppen['']];
|
||||
}
|
||||
|
||||
$beschriftet = [];
|
||||
foreach ($gruppen as $gruppe => $eintraege) {
|
||||
$beschriftet['Umbuchungskonten – ' . ($gruppe === '' ? 'nicht gruppiert' : $gruppe)] = $eintraege;
|
||||
}
|
||||
|
||||
return $beschriftet;
|
||||
}
|
||||
|
||||
public static function find(int $id): ?array
|
||||
{
|
||||
$zeile = Database::fetchOne(
|
||||
|
|
|
|||
|
|
@ -50,12 +50,12 @@ class Vertrag
|
|||
|
||||
Database::execute(
|
||||
'INSERT INTO vertraege
|
||||
(benutzer_id, name, partner, adresse_id, vertragsnummer, kategorie_id, betrag,
|
||||
intervall, beginn, ende, kuendigungsfrist_tage, regelmaessige_id, notiz, aktiv)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
(benutzer_id, name, partner, adresse_id, vertragsnummer, kategorie_id, gegenkonto_id,
|
||||
betrag, intervall, beginn, ende, kuendigungsfrist_tage, regelmaessige_id, notiz, aktiv)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
Mandant::id(), $f['name'], $f['partner'], $f['adresse_id'], $f['vertragsnummer'],
|
||||
$f['kategorie_id'], $f['betrag'], $f['intervall'], $f['beginn'], $f['ende'],
|
||||
$f['kategorie_id'], $f['gegenkonto_id'], $f['betrag'], $f['intervall'], $f['beginn'], $f['ende'],
|
||||
$f['kuendigungsfrist_tage'], $f['regelmaessige_id'], $f['notiz'], $f['aktiv'],
|
||||
]
|
||||
);
|
||||
|
|
@ -70,12 +70,12 @@ class Vertrag
|
|||
|
||||
Database::execute(
|
||||
'UPDATE vertraege SET name = ?, partner = ?, adresse_id = ?, vertragsnummer = ?,
|
||||
kategorie_id = ?, betrag = ?, intervall = ?, beginn = ?, ende = ?,
|
||||
kategorie_id = ?, gegenkonto_id = ?, betrag = ?, intervall = ?, beginn = ?, ende = ?,
|
||||
kuendigungsfrist_tage = ?, regelmaessige_id = ?, notiz = ?, aktiv = ?
|
||||
WHERE id = ? AND benutzer_id = ?',
|
||||
[
|
||||
$f['name'], $f['partner'], $f['adresse_id'], $f['vertragsnummer'], $f['kategorie_id'],
|
||||
$f['betrag'], $f['intervall'], $f['beginn'], $f['ende'], $f['kuendigungsfrist_tage'],
|
||||
$f['gegenkonto_id'], $f['betrag'], $f['intervall'], $f['beginn'], $f['ende'], $f['kuendigungsfrist_tage'],
|
||||
$f['regelmaessige_id'], $f['notiz'], $f['aktiv'], $id, Mandant::id(),
|
||||
]
|
||||
);
|
||||
|
|
@ -172,7 +172,9 @@ class Vertrag
|
|||
'partner' => mb_substr(trim((string)($daten['partner'] ?? '')), 0, 255),
|
||||
'adresse_id' => $id($daten['adresse_id'] ?? null),
|
||||
'vertragsnummer' => mb_substr(trim((string)($daten['vertragsnummer'] ?? '')), 0, 64),
|
||||
'kategorie_id' => $id($daten['kategorie_id'] ?? null),
|
||||
// Kategorie oder Umbuchungskonto (etwa ein Bausparvertrag) – nie beides.
|
||||
'kategorie_id' => $id($daten['gegenkonto_id'] ?? null) === null ? $id($daten['kategorie_id'] ?? null) : null,
|
||||
'gegenkonto_id' => $id($daten['gegenkonto_id'] ?? null),
|
||||
'betrag' => round((float)($daten['betrag'] ?? 0), 2),
|
||||
'intervall' => array_key_exists($intervall, RegelmaessigeBuchung::INTERVALLE)
|
||||
? $intervall : 'monatlich',
|
||||
|
|
|
|||
|
|
@ -30,11 +30,12 @@ class Zahlungsauftrag
|
|||
public static function alle(string $status = ''): array
|
||||
{
|
||||
$params = [Mandant::id()];
|
||||
$sql = 'SELECT z.*, k.name AS kontoname, kat.pfad AS kategorie_pfad,
|
||||
$sql = 'SELECT z.*, k.name AS kontoname, kat.pfad AS kategorie_pfad, g.name AS gegenkontoname,
|
||||
(SELECT COUNT(*) FROM bank_protokolle p WHERE p.auftrag_id = z.id) AS protokolle
|
||||
FROM zahlungsauftraege z
|
||||
JOIN konten k ON k.id = z.konto_id
|
||||
LEFT JOIN kategorien kat ON kat.id = z.kategorie_id
|
||||
LEFT JOIN konten g ON g.id = z.gegenkonto_id
|
||||
WHERE z.benutzer_id = ?';
|
||||
|
||||
if ($status !== '' && array_key_exists($status, self::STATUS)) {
|
||||
|
|
@ -66,12 +67,13 @@ class Zahlungsauftrag
|
|||
Database::execute(
|
||||
'INSERT INTO zahlungsauftraege
|
||||
(benutzer_id, konto_id, zugang_id, empfaenger, iban, bic, betrag, waehrung,
|
||||
verwendungszweck, ausfuehrung_am, echtzeit, kategorie_id, klasse_id, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
verwendungszweck, ausfuehrung_am, echtzeit, kategorie_id, gegenkonto_id, klasse_id, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
Mandant::id(), $f['konto_id'], $f['zugang_id'], $f['empfaenger'], $f['iban'],
|
||||
$f['bic'], $f['betrag'], $f['waehrung'], $f['verwendungszweck'],
|
||||
$f['ausfuehrung_am'], $f['echtzeit'], $f['kategorie_id'], $f['klasse_id'], 'entwurf',
|
||||
$f['ausfuehrung_am'], $f['echtzeit'], $f['kategorie_id'], $f['gegenkonto_id'],
|
||||
$f['klasse_id'], 'entwurf',
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -94,13 +96,13 @@ class Zahlungsauftrag
|
|||
Database::execute(
|
||||
'UPDATE zahlungsauftraege SET konto_id = ?, zugang_id = ?, empfaenger = ?, iban = ?,
|
||||
bic = ?, betrag = ?, waehrung = ?, verwendungszweck = ?, ausfuehrung_am = ?,
|
||||
echtzeit = ?, kategorie_id = ?, klasse_id = ?, status = \'entwurf\', meldung = NULL,
|
||||
echtzeit = ?, kategorie_id = ?, gegenkonto_id = ?, klasse_id = ?, status = \'entwurf\', meldung = NULL,
|
||||
bank_auftrag_id = NULL, echtzeit_status = NULL, status_abfrage_ab = NULL
|
||||
WHERE id = ? AND benutzer_id = ?',
|
||||
[
|
||||
$f['konto_id'], $f['zugang_id'], $f['empfaenger'], $f['iban'], $f['bic'],
|
||||
$f['betrag'], $f['waehrung'], $f['verwendungszweck'], $f['ausfuehrung_am'],
|
||||
$f['echtzeit'], $f['kategorie_id'], $f['klasse_id'], $id, Mandant::id(),
|
||||
$f['echtzeit'], $f['kategorie_id'], $f['gegenkonto_id'], $f['klasse_id'], $id, Mandant::id(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
@ -154,6 +156,7 @@ class Zahlungsauftrag
|
|||
'empfaenger' => (string)$auftrag['empfaenger'],
|
||||
'buchungstext' => (string)$auftrag['verwendungszweck'],
|
||||
'kategorie_id' => $auftrag['kategorie_id'],
|
||||
'gegenkonto_id' => $auftrag['gegenkonto_id'] ?? null,
|
||||
'klasse_id' => $auftrag['klasse_id'],
|
||||
'quelle' => 'fints',
|
||||
]);
|
||||
|
|
@ -380,6 +383,16 @@ class Zahlungsauftrag
|
|||
$id = static fn(mixed $wert): ?int =>
|
||||
($wert === null || $wert === '' || (int)$wert === 0) ? null : (int)$wert;
|
||||
|
||||
// Erst beim Senden entsteht die Buchung – ein Gegenkonto, das sie
|
||||
// ablehnen würde, fällt besser schon beim Erfassen auf.
|
||||
$gegenkonto = $id($daten['gegenkonto_id'] ?? null);
|
||||
if ($gegenkonto !== null && $gegenkonto === $kontoId) {
|
||||
throw new \InvalidArgumentException('Eine Umbuchung braucht zwei verschiedene Konten.');
|
||||
}
|
||||
if ($gegenkonto !== null && Konto::find($gegenkonto) === null) {
|
||||
throw new \InvalidArgumentException('Das Gegenkonto gibt es nicht.');
|
||||
}
|
||||
|
||||
return [
|
||||
'konto_id' => $kontoId,
|
||||
'zugang_id' => $id($daten['zugang_id'] ?? null),
|
||||
|
|
@ -393,7 +406,9 @@ class Zahlungsauftrag
|
|||
'verwendungszweck' => mb_substr(trim((string)($daten['verwendungszweck'] ?? '')), 0, 140),
|
||||
'ausfuehrung_am' => $termin !== '' && !$echtzeit ? $termin : null,
|
||||
'echtzeit' => $echtzeit ? 1 : 0,
|
||||
'kategorie_id' => $id($daten['kategorie_id'] ?? null),
|
||||
// Kategorie oder Gegenkonto – nie beides, wie bei der Buchung.
|
||||
'kategorie_id' => $gegenkonto === null ? $id($daten['kategorie_id'] ?? null) : null,
|
||||
'gegenkonto_id' => $gegenkonto,
|
||||
'klasse_id' => $id($daten['klasse_id'] ?? null),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,6 +115,12 @@ class Auswertung
|
|||
'umbuchungen' => self::bool($roh['umbuchungen'] ?? false),
|
||||
'konten' => self::ids($roh['konten'] ?? []),
|
||||
'kategorien' => self::ids($roh['kategorien'] ?? []),
|
||||
// Umbuchungskonten stehen im Buchungsbericht mit in der
|
||||
// Kategorieauswahl (`konto:7`, #124) – nur dort werden Umbuchungen
|
||||
// überhaupt aufgeführt.
|
||||
'gegenkonten' => $typ === 'buchungsbericht'
|
||||
? self::gegenkonten($roh['gegenkonten'] ?? [], $roh['kategorien'] ?? [])
|
||||
: [],
|
||||
'klassen' => self::ids($roh['klassen'] ?? []),
|
||||
'suche' => mb_substr(trim((string)($roh['suche'] ?? '')), 0, 120),
|
||||
// Nur der Buchungsbericht führt die Abgleichspalte und filtert danach.
|
||||
|
|
@ -1203,8 +1209,16 @@ class Auswertung
|
|||
|
||||
// Umbuchungen sind Vermögensverschiebungen, keine Einnahmen oder
|
||||
// Ausgaben – nur der Buchungsbericht darf sie auf Wunsch mitführen.
|
||||
// Ein ausdrücklich gewähltes Umbuchungskonto holt seine Umbuchungen
|
||||
// auch ohne diesen Schalter herein.
|
||||
$gegenkonten = $filter['gegenkonten'] ?? [];
|
||||
if (empty($filter['umbuchungen'])) {
|
||||
if ($gegenkonten === []) {
|
||||
$wo[] = 'gegenkonto_id IS NULL';
|
||||
} else {
|
||||
$wo[] = '(gegenkonto_id IS NULL OR gegenkonto_id IN (' . self::platzhalter($gegenkonten) . '))';
|
||||
$params = array_merge($params, $gegenkonten);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($filter['konten'])) {
|
||||
|
|
@ -1212,11 +1226,21 @@ class Auswertung
|
|||
$params = array_merge($params, $filter['konten']);
|
||||
}
|
||||
|
||||
// Kategorien und Umbuchungskonten stammen aus derselben Auswahl: Eine
|
||||
// Bewegung passt, wenn sie zu einem der beiden gehört.
|
||||
$zuordnung = [];
|
||||
if (!empty($filter['kategorien'])) {
|
||||
$ids = self::mitNachfahren($filter['kategorien']);
|
||||
$wo[] = 'kategorie_id IN (' . self::platzhalter($ids) . ')';
|
||||
$zuordnung[] = 'kategorie_id IN (' . self::platzhalter($ids) . ')';
|
||||
$params = array_merge($params, $ids);
|
||||
}
|
||||
if ($gegenkonten !== []) {
|
||||
$zuordnung[] = 'gegenkonto_id IN (' . self::platzhalter($gegenkonten) . ')';
|
||||
$params = array_merge($params, $gegenkonten);
|
||||
}
|
||||
if ($zuordnung !== []) {
|
||||
$wo[] = '(' . implode(' OR ', $zuordnung) . ')';
|
||||
}
|
||||
|
||||
if (!empty($filter['klassen'])) {
|
||||
$wo[] = 'klasse_id IN (' . self::platzhalter($filter['klassen']) . ')';
|
||||
|
|
@ -1443,6 +1467,24 @@ class Auswertung
|
|||
return implode(', ', array_fill(0, count($werte), '?'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Die gewählten Umbuchungskonten: als eigene Liste (aus der Adresse oder
|
||||
* einem gespeicherten Bericht) oder als `konto:7` aus der Kategorieauswahl.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private static function gegenkonten(mixed $liste, mixed $auswahl): array
|
||||
{
|
||||
$ids = self::ids($liste);
|
||||
foreach (is_array($auswahl) ? $auswahl : [] as $eintrag) {
|
||||
if (is_string($eintrag) && preg_match('/^konto:(\d+)$/', $eintrag, $treffer) === 1 && (int)$treffer[1] > 0) {
|
||||
$ids[] = (int)$treffer[1];
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
private static function ids(mixed $wert): array
|
||||
{
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ class BuchungsExport
|
|||
/**
|
||||
* Die gefilterten Buchungen samt Splitzeilen und Adressen.
|
||||
*
|
||||
* @param array<string,mixed> $filter von, bis, konten[], kategorien[], klassen[]
|
||||
* @param array<string,mixed> $filter von, bis, konten[], kategorien[], gegenkonten[], klassen[]
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public static function buchungen(array $filter): array
|
||||
|
|
@ -151,18 +151,30 @@ class BuchungsExport
|
|||
|
||||
// Kategorie und Klasse können in der Buchung oder in einer ihrer
|
||||
// Splitzeilen stehen – beides zählt, sonst fiele jede Splitbuchung aus
|
||||
// dem Filter, obwohl sie die gesuchte Kategorie führt.
|
||||
foreach ([['kategorien', 'kategorie_id'], ['klassen', 'klasse_id']] as [$schluessel, $spalte]) {
|
||||
// dem Filter, obwohl sie die gesuchte Kategorie führt. Kategorien und
|
||||
// Umbuchungskonten stammen aus derselben Auswahl: Eine Buchung passt,
|
||||
// wenn sie zu einem von beiden gehört.
|
||||
$gruppen = [
|
||||
[['kategorien', 'kategorie_id'], ['gegenkonten', 'gegenkonto_id']],
|
||||
[['klassen', 'klasse_id']],
|
||||
];
|
||||
foreach ($gruppen as $gruppe) {
|
||||
$oder = [];
|
||||
foreach ($gruppe as [$schluessel, $spalte]) {
|
||||
$ids = self::ids($filter[$schluessel] ?? []);
|
||||
if ($ids === []) {
|
||||
continue;
|
||||
}
|
||||
$platzhalter = self::platzhalter($ids);
|
||||
$wo[] = '(b.' . $spalte . ' IN (' . $platzhalter . ')
|
||||
$oder[] = 'b.' . $spalte . ' IN (' . $platzhalter . ')
|
||||
OR EXISTS (SELECT 1 FROM buchung_splits s WHERE s.buchung_id = b.id
|
||||
AND s.' . $spalte . ' IN (' . $platzhalter . ')))';
|
||||
AND s.' . $spalte . ' IN (' . $platzhalter . '))';
|
||||
$params = array_merge($params, $ids, $ids);
|
||||
}
|
||||
if ($oder !== []) {
|
||||
$wo[] = '(' . implode(' OR ', $oder) . ')';
|
||||
}
|
||||
}
|
||||
|
||||
return [implode(' AND ', $wo), $params];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/** @var array<string,mixed> $bericht */
|
||||
|
|
@ -195,7 +196,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<button type="submit" class="knopf primaer">Anzeigen</button>
|
||||
</div>
|
||||
|
||||
<details class="einschraenkung" <?= $filter['konten'] || $filter['kategorien'] || $filter['klassen'] ? 'open' : '' ?>>
|
||||
<details class="einschraenkung" <?= $filter['konten'] || $filter['kategorien'] || $filter['gegenkonten'] || $filter['klassen'] ? 'open' : '' ?>>
|
||||
<summary>Einschränken auf Konten, Kategorien und Klassen</summary>
|
||||
<div class="filterzeile">
|
||||
<label>Konten
|
||||
|
|
@ -217,6 +218,20 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<?= View::h(str_repeat(' ', (int)$kategorie['ebene']) . $kategorie['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
<?php /* Nur der Buchungsbericht führt Umbuchungen – dort stehen
|
||||
die Umbuchungskonten unter den Kategorien (#124). */ ?>
|
||||
<?php if ($bericht['typ'] === 'buchungsbericht'): ?>
|
||||
<?php foreach (Konto::umbuchungsgruppen($konten, $filter['gegenkonten']) as $kontengruppe => $gruppenkonten): ?>
|
||||
<optgroup label="<?= View::h($kontengruppe) ?>">
|
||||
<?php foreach ($gruppenkonten as $umbuchungskonto): ?>
|
||||
<option value="konto:<?= (int)$umbuchungskonto['id'] ?>"
|
||||
<?= in_array((int)$umbuchungskonto['id'], $filter['gegenkonten'], true) ? 'selected' : '' ?>>
|
||||
[<?= View::h($umbuchungskonto['name']) ?>]
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</optgroup>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
|
|
@ -234,6 +249,10 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<p class="hinweis klein">
|
||||
Eine gewählte Kategorie schließt ihre Unterkategorien ein. Mehrfachauswahl mit
|
||||
gedrückter Strg-Taste; nichts ausgewählt heißt alles.
|
||||
<?php if ($bericht['typ'] === 'buchungsbericht'): ?>
|
||||
Ein Umbuchungskonto in eckigen Klammern zeigt die Umbuchungen auf dieses Konto,
|
||||
auch ohne „Umbuchungen mitzählen“.
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</details>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\Controllers\BuchungenController;
|
||||
use Finanzmanager\Models\AbgleichRegel;
|
||||
use Finanzmanager\View;
|
||||
|
||||
|
|
@ -11,7 +12,7 @@ use Finanzmanager\View;
|
|||
* @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 list<array<string,mixed>> $konten alle Konten; die Liste zeigt nur die aktiven
|
||||
* @var string $idPrefix Vorsatz für die Feld-ids, damit mehrere solcher
|
||||
* Formulare auf einer Seite nicht kollidieren
|
||||
*/
|
||||
|
|
@ -66,15 +67,12 @@ $betragsfeld = static function (?string $feld) use ($regel): string {
|
|||
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; ?>
|
||||
<label for="<?= $id('zuordnung') ?>"
|
||||
title="Unter den Kategorien stehen die aktiven Umbuchungskonten in eckigen Klammern. Eines davon macht aus der Buchung eine Umbuchung.">Kategorie / Gegenkonto</label>
|
||||
<select id="<?= $id('zuordnung') ?>" name="zuordnung">
|
||||
<?php $gewaehlt = BuchungenController::zuordnungswert($regel); ?>
|
||||
<?php $eigenesKonto = 0; $mitSplit = false; $mitDepot = false; ?>
|
||||
<?php include __DIR__ . '/../buchungen/zuordnungsliste.php'; ?>
|
||||
</select>
|
||||
|
||||
<label for="<?= $id('klasse_id') ?>">Klasse</label>
|
||||
|
|
@ -88,17 +86,6 @@ $betragsfeld = static function (?string $feld) use ($regel): string {
|
|||
<?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'; ?>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\Controllers\BuchungenController;
|
||||
use Finanzmanager\Models\AbgleichRegel;
|
||||
use Finanzmanager\View;
|
||||
|
||||
|
|
@ -47,7 +48,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<thead>
|
||||
<tr>
|
||||
<th>Name</th><th>Feld</th><th>Vergleich</th><th>Muster</th>
|
||||
<th>Kategorie</th><th>Klasse</th>
|
||||
<th>Kategorie / Gegenkonto</th><th>Klasse</th>
|
||||
<th class="rechts">Prio</th><th class="rechts">Treffer</th>
|
||||
<th>Aktiv</th><th class="rechts">Aktion</th>
|
||||
</tr>
|
||||
|
|
@ -80,14 +81,10 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</td>
|
||||
<td><input type="text" form="<?= $f ?>" name="muster" value="<?= View::h($regel['muster']) ?>" required></td>
|
||||
<td>
|
||||
<select form="<?= $f ?>" name="kategorie_id">
|
||||
<option value="">–</option>
|
||||
<?php foreach ($kategorien as $kategorie): ?>
|
||||
<option value="<?= (int)$kategorie['id'] ?>"
|
||||
<?= (int)$regel['kategorie_id'] === (int)$kategorie['id'] ? 'selected' : '' ?>>
|
||||
<?= View::h($kategorie['pfad']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
<select form="<?= $f ?>" name="zuordnung">
|
||||
<?php $gewaehlt = BuchungenController::zuordnungswert($regel); ?>
|
||||
<?php $eigenesKonto = 0; $mitSplit = false; $mitDepot = false; ?>
|
||||
<?php include __DIR__ . '/../buchungen/zuordnungsliste.php'; ?>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\Controllers\BuchungenController;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/**
|
||||
|
|
@ -11,13 +12,14 @@ use Finanzmanager\View;
|
|||
* Felder ohnehin im umgebenden Formular stehen
|
||||
* @var list<array<string,mixed>> $kategorien
|
||||
* @var list<array<string,mixed>> $klassen
|
||||
* @var list<array<string,mixed>> $konten
|
||||
*/
|
||||
|
||||
// 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' => ''];
|
||||
$splitZeilen[] = ['kategorie_id' => null, 'gegenkonto_id' => null, 'klasse_id' => null, 'prozent' => '', 'notiz' => ''];
|
||||
}
|
||||
|
||||
$fuerFormular = $splitForm === '' ? '' : ' form="' . View::h($splitForm) . '"';
|
||||
|
|
@ -26,21 +28,17 @@ $fuerFormular = $splitForm === '' ? '' : ' form="' . View::h($splitForm) . '"';
|
|||
<table class="liste splittabelle" data-anteilssumme>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kategorie</th><th>Klasse</th><th>Notiz</th><th class="rechts">Anteil</th>
|
||||
<th>Kategorie / Gegenkonto</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<?= $fuerFormular ?> name="split[<?= (int)$nr ?>][zuordnung]">
|
||||
<?php $gewaehlt = BuchungenController::zuordnungswert($zeile); ?>
|
||||
<?php $eigenesKonto = 0; $mitSplit = false; $mitDepot = false; ?>
|
||||
<?php include __DIR__ . '/../buchungen/zuordnungsliste.php'; ?>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
|
|
@ -76,8 +74,9 @@ $fuerFormular = $splitForm === '' ? '' : ' form="' . View::h($splitForm) . '"';
|
|||
|
||||
<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.
|
||||
werden die Kategorien und Umbuchungskonten 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 bzw. Gegenkonto oder ohne Anteil fällt weg; ohne jede Splitzeile bucht die
|
||||
Regel wie bisher auf ihre Kategorie.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\Controllers\BuchungenController;
|
||||
use Finanzmanager\Models\Bankumsatz;
|
||||
use Finanzmanager\View;
|
||||
|
||||
|
|
@ -18,11 +19,19 @@ use Finanzmanager\View;
|
|||
*/
|
||||
|
||||
// Für die Splitzeilen einer Regel: Die Vorschläge tragen nur Kennungen, hier
|
||||
// soll der Pfad der Kategorie stehen.
|
||||
// soll der Pfad der Kategorie stehen – bei einer Umbuchung das Gegenkonto in
|
||||
// eckigen Klammern.
|
||||
$kategoriepfad = [];
|
||||
foreach ($kategorien as $eintrag) {
|
||||
$kategoriepfad[(int)$eintrag['id']] = (string)$eintrag['pfad'];
|
||||
}
|
||||
$kontoname = array_column($konten, 'name', 'id');
|
||||
$splitbezeichnung = static function (array $zeile) use ($kategoriepfad, $kontoname): string {
|
||||
if (!empty($zeile['gegenkonto_id'])) {
|
||||
return '[' . ($kontoname[(int)$zeile['gegenkonto_id']] ?? '?') . ']';
|
||||
}
|
||||
return $kategoriepfad[(int)($zeile['kategorie_id'] ?? 0)] ?? 'ohne Kategorie';
|
||||
};
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
|
@ -148,6 +157,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<select name="konto_id" required>
|
||||
<option value="">– Konto wählen –</option>
|
||||
<?php foreach ($konten as $konto): ?>
|
||||
<?php if ((int)$konto['aktiv'] !== 1) { continue; } ?>
|
||||
<option value="<?= (int)$konto['id'] ?>"><?= View::h($konto['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
|
|
@ -162,12 +172,12 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<ul class="vorschlagssplits">
|
||||
<?php foreach ($regelsplits as $nr => $zeile): ?>
|
||||
<li>
|
||||
<span><?= View::h($kategoriepfad[(int)($zeile['kategorie_id'] ?? 0)] ?? 'ohne Kategorie') ?></span>
|
||||
<span><?= View::h($splitbezeichnung($zeile)) ?></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 ?>][zuordnung]"
|
||||
value="<?= View::h(BuchungenController::zuordnungswert($zeile)) ?>">
|
||||
<input type="hidden" name="split[<?= (int)$nr ?>][klasse_id]"
|
||||
value="<?= (int)($zeile['klasse_id'] ?? 0) ?: '' ?>">
|
||||
<input type="hidden" name="split[<?= (int)$nr ?>][notiz]"
|
||||
|
|
@ -178,14 +188,10 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<?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 name="zuordnung" aria-label="Kategorie oder Gegenkonto">
|
||||
<?php $gewaehlt = BuchungenController::zuordnungswert($zuordnung); ?>
|
||||
<?php $eigenesKonto = (int)$umsatz['konto_id']; $mitSplit = false; $mitDepot = false; ?>
|
||||
<?php include __DIR__ . '/../buchungen/zuordnungsliste.php'; ?>
|
||||
</select>
|
||||
|
||||
<select name="klasse_id">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\Controllers\BuchungenController;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/** @var array<string,mixed> $konto */
|
||||
|
|
@ -22,15 +23,7 @@ $eingaben = $eingaben ?? null;
|
|||
$art = !$neu && $betrag >= 0 ? 'einnahme' : 'ausgabe';
|
||||
|
||||
/** Vorauswahl der gemeinsamen Liste Kategorie / Gegenkonto. */
|
||||
$zuordnungswert = static function (?array $zeile): string {
|
||||
if (!empty($zeile['gegenkonto_id'])) {
|
||||
return 'konto:' . (int)$zeile['gegenkonto_id'];
|
||||
}
|
||||
if (!empty($zeile['kategorie_id'])) {
|
||||
return 'kategorie:' . (int)$zeile['kategorie_id'];
|
||||
}
|
||||
return '';
|
||||
};
|
||||
$zuordnungswert = BuchungenController::zuordnungswert(...);
|
||||
|
||||
// Die Werte, die die Felder zeigen: normalerweise die gespeicherte Buchung
|
||||
// (oder eine leere Maske), nach einem gescheiterten Speichern aber die zuletzt
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/**
|
||||
|
|
@ -14,6 +15,8 @@ use Finanzmanager\View;
|
|||
*
|
||||
* @var list<array<string,mixed>> $kategorien Kategoriebaum, flach mit Pfad
|
||||
* @var list<array<string,mixed>> $konten alle Konten des Mandanten
|
||||
* @var list<array<string,mixed>>|null $umbuchungskonten stattdessen, wo `$konten`
|
||||
* in der Seite etwas anderes meint
|
||||
* @var int $eigenesKonto Konto der Buchung – kein Gegenkonto seiner selbst
|
||||
* @var string $gewaehlt vorausgewählter Wert, '' für keine Zuordnung
|
||||
* @var bool $mitSplit Eintrag „Splitbuchung“ anbieten (nur im Kontoblatt)
|
||||
|
|
@ -29,40 +32,68 @@ use Finanzmanager\View;
|
|||
*
|
||||
* In den Splitzeilen fehlt die Gruppe. Dort steht der Betrag der Zeile schon
|
||||
* fest, und die Umbuchung ins Depot ist genau das, was gebraucht wird.
|
||||
*
|
||||
* Die Umbuchungskonten stehen unter den Kategorien, je Kontengruppe in einer
|
||||
* eigenen Gruppe und in deren Reihenfolge (Konto::umbuchungsgruppen()).
|
||||
* Angeboten werden nur aktive Konten (#124). Ein inaktives Konto bleibt aber
|
||||
* als verborgener Eintrag in der Liste: Ist es schon gewählt, steht es
|
||||
* sichtbar da – sonst ginge die Zuordnung beim nächsten Speichern verloren –,
|
||||
* und kontoblatt.js kann eine bestehende Splitzeile damit vorbelegen.
|
||||
*
|
||||
* Außerhalb der Buchungsmaske (Regeln, Vorlagen, Zahlungsverkehr …) wird die
|
||||
* Liste mit `$eigenesKonto = 0` und ohne Split- und Depotgruppe eingebunden.
|
||||
*/
|
||||
|
||||
$kontenliste = $umbuchungskonten ?? $konten;
|
||||
$istDepot = static fn(array $konto): bool => (string)($konto['kontoart'] ?? '') === 'depot';
|
||||
$depots = !empty($mitDepot) ? array_filter($konten, $istDepot) : [];
|
||||
$depots = !empty($mitDepot) ? array_filter($kontenliste, $istDepot) : [];
|
||||
|
||||
// Umbuchungskonten je Kontengruppe (Konto::umbuchungsgruppen()); inaktive,
|
||||
// nicht gewählte Konten getrennt und verborgen.
|
||||
$umbuchungsziele = array_values(array_filter(
|
||||
$kontenliste,
|
||||
static fn(array $k): bool => (int)$k['id'] !== (int)$eigenesKonto && !($depots !== [] && $istDepot($k))
|
||||
));
|
||||
$gewaehltesKonto = str_starts_with((string)($gewaehlt ?? ''), 'konto:') ? (int)substr((string)$gewaehlt, 6) : 0;
|
||||
$umbuchungsgruppen = Konto::umbuchungsgruppen($umbuchungsziele, [$gewaehltesKonto]);
|
||||
$verborgeneKonten = array_filter(
|
||||
$umbuchungsziele,
|
||||
static fn(array $k): bool => (int)($k['aktiv'] ?? 1) !== 1 && (int)$k['id'] !== $gewaehltesKonto
|
||||
);
|
||||
?>
|
||||
<option value=""<?= ($gewaehlt ?? '') === '' ? ' selected' : '' ?>>— keine —</option>
|
||||
<?php if (!empty($mitSplit)): ?>
|
||||
<option value="split"<?= ($gewaehlt ?? '') === 'split' ? ' selected' : '' ?>>Splitbuchung …</option>
|
||||
<?php endif; ?>
|
||||
<optgroup label="Kategorien">
|
||||
<?php foreach ($kategorien as $eintrag): ?>
|
||||
<?php $wert = 'kategorie:' . (int)$eintrag['id']; ?>
|
||||
<option value="<?= View::h($wert) ?>"<?= ($gewaehlt ?? '') === $wert ? ' selected' : '' ?>>
|
||||
<?= View::h($eintrag['pfad']) ?>
|
||||
<?php foreach ($kategorien as $listeneintrag): ?>
|
||||
<?php $optionswert = 'kategorie:' . (int)$listeneintrag['id']; ?>
|
||||
<option value="<?= View::h($optionswert) ?>"<?= ($gewaehlt ?? '') === $optionswert ? ' selected' : '' ?>>
|
||||
<?= View::h($listeneintrag['pfad']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</optgroup>
|
||||
<optgroup label="Umbuchung auf ein Konto">
|
||||
<?php foreach ($konten as $eintrag): ?>
|
||||
<?php if ((int)$eintrag['id'] === (int)$eigenesKonto) { continue; } ?>
|
||||
<?php if ($depots !== [] && $istDepot($eintrag)) { continue; } ?>
|
||||
<?php $wert = 'konto:' . (int)$eintrag['id']; ?>
|
||||
<option value="<?= View::h($wert) ?>"<?= ($gewaehlt ?? '') === $wert ? ' selected' : '' ?>>
|
||||
[<?= View::h($eintrag['name']) ?>]
|
||||
<?php foreach ($umbuchungsgruppen as $kontengruppe => $gruppeneintraege): ?>
|
||||
<optgroup label="<?= View::h($kontengruppe) ?>">
|
||||
<?php foreach ($gruppeneintraege as $listeneintrag): ?>
|
||||
<?php $optionswert = 'konto:' . (int)$listeneintrag['id']; ?>
|
||||
<option value="<?= View::h($optionswert) ?>"<?= ($gewaehlt ?? '') === $optionswert ? ' selected' : '' ?>>
|
||||
[<?= View::h($listeneintrag['name']) ?>]
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</optgroup>
|
||||
<?php endforeach; ?>
|
||||
<?php foreach ($verborgeneKonten as $listeneintrag): ?>
|
||||
<option value="konto:<?= (int)$listeneintrag['id'] ?>" hidden>[<?= View::h($listeneintrag['name']) ?>]</option>
|
||||
<?php endforeach; ?>
|
||||
<?php if ($depots !== []): ?>
|
||||
<optgroup label="Wertpapiergeschäft im Depot">
|
||||
<?php foreach ($depots as $eintrag): ?>
|
||||
<?php if ((int)$eintrag['id'] === (int)$eigenesKonto) { continue; } ?>
|
||||
<?php $wert = 'depot:' . (int)$eintrag['id']; ?>
|
||||
<option value="<?= View::h($wert) ?>"<?= ($gewaehlt ?? '') === $wert ? ' selected' : '' ?>>
|
||||
[<?= View::h($eintrag['name']) ?>] …
|
||||
<?php foreach ($depots as $listeneintrag): ?>
|
||||
<?php if ((int)$listeneintrag['id'] === (int)$eigenesKonto) { continue; } ?>
|
||||
<?php $optionswert = 'depot:' . (int)$listeneintrag['id']; ?>
|
||||
<?php if ((int)($listeneintrag['aktiv'] ?? 1) !== 1 && ($gewaehlt ?? '') !== $optionswert) { continue; } ?>
|
||||
<option value="<?= View::h($optionswert) ?>"<?= ($gewaehlt ?? '') === $optionswert ? ' selected' : '' ?>>
|
||||
[<?= View::h($listeneintrag['name']) ?>] …
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</optgroup>
|
||||
|
|
|
|||
|
|
@ -264,12 +264,11 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<label for="neu_buchungstext">Buchungstext</label>
|
||||
<input type="text" id="neu_buchungstext" name="buchungstext">
|
||||
|
||||
<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; ?>
|
||||
<label for="neu_zuordnung"
|
||||
title="Unter den Kategorien stehen die aktiven Umbuchungskonten in eckigen Klammern. Eines davon macht aus der Buchung eine Umbuchung.">Kategorie / Gegenkonto</label>
|
||||
<select id="neu_zuordnung" name="zuordnung">
|
||||
<?php $gewaehlt = ''; $eigenesKonto = 0; $mitSplit = false; $mitDepot = false; ?>
|
||||
<?php include __DIR__ . '/../buchungen/zuordnungsliste.php'; ?>
|
||||
</select>
|
||||
|
||||
<label for="neu_klasse">Klasse</label>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use Finanzmanager\View;
|
|||
* @var list<array<string,mixed>> $fristen
|
||||
* @var list<array<string,mixed>> $adressen
|
||||
* @var list<array<string,mixed>> $kategorien
|
||||
* @var list<array<string,mixed>> $konten
|
||||
* @var list<array<string,mixed>> $reihen
|
||||
* @var array<string,string> $intervalle
|
||||
* @var string $csrfToken
|
||||
|
|
@ -74,6 +75,8 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
value="<?= View::h((string)$vertrag['adresse_id']) ?>">
|
||||
<input type="hidden" form="<?= $f ?>" name="kategorie_id"
|
||||
value="<?= View::h((string)$vertrag['kategorie_id']) ?>">
|
||||
<input type="hidden" form="<?= $f ?>" name="gegenkonto_id"
|
||||
value="<?= View::h((string)$vertrag['gegenkonto_id']) ?>">
|
||||
<input type="hidden" form="<?= $f ?>" name="regelmaessige_id"
|
||||
value="<?= View::h((string)$vertrag['regelmaessige_id']) ?>">
|
||||
<input type="hidden" form="<?= $f ?>" name="beginn"
|
||||
|
|
@ -163,12 +166,11 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<?php endforeach; ?>
|
||||
</select>
|
||||
|
||||
<label for="kategorie_id">Kategorie</label>
|
||||
<select id="kategorie_id" name="kategorie_id">
|
||||
<option value="">– keine –</option>
|
||||
<?php foreach ($kategorien as $kategorie): ?>
|
||||
<option value="<?= (int)$kategorie['id'] ?>"><?= View::h($kategorie['pfad']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
<label for="zuordnung"
|
||||
title="Unter den Kategorien stehen die aktiven Umbuchungskonten in eckigen Klammern – etwa für einen Bauspar- oder Sparvertrag.">Kategorie / Gegenkonto</label>
|
||||
<select id="zuordnung" name="zuordnung">
|
||||
<?php $gewaehlt = ''; $eigenesKonto = 0; $mitSplit = false; $mitDepot = false; ?>
|
||||
<?php include __DIR__ . '/../buchungen/zuordnungsliste.php'; ?>
|
||||
</select>
|
||||
|
||||
<div class="feldpaar">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\Services\Dateiformat;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/** @var string $format */
|
||||
/** @var array{von: string, bis: string, konten: list<int>, kategorien: list<int>, klassen: list<int>} $filter */
|
||||
/** @var array{von: string, bis: string, konten: list<int>, kategorien: list<int>, gegenkonten: list<int>, klassen: list<int>} $filter */
|
||||
/** @var list<array<string,mixed>> $konten */
|
||||
/** @var list<array<string,mixed>> $kategorien */
|
||||
/** @var list<array<string,mixed>> $klassen */
|
||||
|
|
@ -87,6 +88,21 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php /* Unter den Kategorien die aktiven Umbuchungskonten (#124): Ein
|
||||
gewähltes Konto nimmt die Umbuchungen darauf mit. */ ?>
|
||||
<?php foreach (Konto::umbuchungsgruppen($konten, $filter['gegenkonten']) as $kontengruppe => $gruppenkonten): ?>
|
||||
<h4><?= View::h($kontengruppe) ?></h4>
|
||||
<div class="auswahlliste">
|
||||
<?php foreach ($gruppenkonten as $umbuchungskonto): ?>
|
||||
<label class="ankreuzfeld">
|
||||
<input type="checkbox" name="gegenkonten[]" value="<?= (int)$umbuchungskonto['id'] ?>"
|
||||
<?= $gewaehlt($filter['gegenkonten'], (int)$umbuchungskonto['id']) ? 'checked' : '' ?>>
|
||||
<span>[<?= View::h((string)$umbuchungskonto['name']) ?>]</span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<h3>Klassen</h3>
|
||||
<?php if ($klassen === []): ?>
|
||||
<p class="platzhalter">Noch keine Klassen angelegt.</p>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\Controllers\BuchungenController;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/**
|
||||
|
|
@ -54,7 +55,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<table class="liste">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Vorlage</th><th>Kategorie</th><th>Konto</th>
|
||||
<th>Vorlage</th><th>Kategorie / Gegenkonto</th><th>Konto</th>
|
||||
<th class="rechts">Betrag</th><th>Buchen am</th>
|
||||
<th class="rechts">Genutzt</th><th class="rechts">Aktion</th>
|
||||
</tr>
|
||||
|
|
@ -69,7 +70,9 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<span class="marke">Split</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= View::h($vorlage['kategorie_pfad'] ?? '–') ?></td>
|
||||
<td><?= View::h($vorlage['gegenkontoname'] !== null
|
||||
? '[' . $vorlage['gegenkontoname'] . ']'
|
||||
: ($vorlage['kategorie_pfad'] ?? '–')) ?></td>
|
||||
<td>
|
||||
<input type="hidden" form="<?= $f ?>" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<?php if ($vorlage['konto_id'] === null): ?>
|
||||
|
|
@ -126,12 +129,11 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<label for="betrag">Betrag</label>
|
||||
<input type="text" id="betrag" name="betrag" inputmode="decimal" value="0,00">
|
||||
|
||||
<label for="kategorie_id">Kategorie</label>
|
||||
<select id="kategorie_id" name="kategorie_id">
|
||||
<option value="">– keine –</option>
|
||||
<?php foreach ($kategorien as $kategorie): ?>
|
||||
<option value="<?= (int)$kategorie['id'] ?>"><?= View::h($kategorie['pfad']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
<label for="zuordnung"
|
||||
title="Unter den Kategorien stehen die aktiven Umbuchungskonten in eckigen Klammern. Eines davon macht aus der Buchung eine Umbuchung.">Kategorie / Gegenkonto</label>
|
||||
<select id="zuordnung" name="zuordnung">
|
||||
<?php $gewaehlt = ''; $eigenesKonto = 0; $mitSplit = false; $mitDepot = false; ?>
|
||||
<?php include __DIR__ . '/../buchungen/zuordnungsliste.php'; ?>
|
||||
</select>
|
||||
|
||||
<label for="klasse_id">Klasse</label>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\Controllers\BuchungenController;
|
||||
use Finanzmanager\Models\Adresse;
|
||||
use Finanzmanager\View;
|
||||
|
||||
|
|
@ -8,6 +9,7 @@ use Finanzmanager\View;
|
|||
* @var list<array<string,mixed>> $konten
|
||||
* @var list<array<string,mixed>> $adressen
|
||||
* @var list<array<string,mixed>> $kategorien
|
||||
* @var list<array<string,mixed>> $umbuchungskonten
|
||||
* @var list<array<string,mixed>> $klassen
|
||||
* @var string $csrfToken
|
||||
* @var bool|null $hatProtokoll
|
||||
|
|
@ -141,15 +143,12 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
Auftrag abgewiesen und bleibt als Entwurf stehen.
|
||||
</p>
|
||||
|
||||
<label for="kategorie_id">Kategorie</label>
|
||||
<select id="kategorie_id" name="kategorie_id">
|
||||
<option value="">– keine –</option>
|
||||
<?php foreach ($kategorien as $kategorie): ?>
|
||||
<option value="<?= (int)$kategorie['id'] ?>"
|
||||
<?= (int)$wert('kategorie_id') === (int)$kategorie['id'] ? 'selected' : '' ?>>
|
||||
<?= View::h($kategorie['pfad']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
<label for="zuordnung"
|
||||
title="Unter den Kategorien stehen die aktiven Umbuchungskonten in eckigen Klammern. Geht die Überweisung auf ein eigenes Konto, wird sie damit als Umbuchung gebucht.">Kategorie / Gegenkonto</label>
|
||||
<select id="zuordnung" name="zuordnung">
|
||||
<?php $gewaehlt = BuchungenController::zuordnungswert($auftrag); ?>
|
||||
<?php $eigenesKonto = (int)$wert('konto_id'); $mitSplit = false; $mitDepot = false; ?>
|
||||
<?php include __DIR__ . '/../buchungen/zuordnungsliste.php'; ?>
|
||||
</select>
|
||||
<p class="feldhinweis">Wird auf die Buchung übertragen, sobald die Bank den Auftrag annimmt.</p>
|
||||
|
||||
|
|
|
|||
313
tests/61_UmbuchungskontenAuswahlTest.php
Normal file
313
tests/61_UmbuchungskontenAuswahlTest.php
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Finanzmanager\Controllers\BuchungenController;
|
||||
use Finanzmanager\Models\AbgleichRegel;
|
||||
use Finanzmanager\Models\Buchung;
|
||||
use Finanzmanager\Models\Buchungsvorlage;
|
||||
use Finanzmanager\Models\Kategorie;
|
||||
use Finanzmanager\Models\Klasse;
|
||||
use Finanzmanager\Models\Kontengruppe;
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\Models\Vertrag;
|
||||
use Finanzmanager\Models\Zahlungsauftrag;
|
||||
use Finanzmanager\Services\Auswertung;
|
||||
use Finanzmanager\Services\BuchungsExport;
|
||||
|
||||
/**
|
||||
* Umbuchungskonten in jeder Kategorieauswahl (#124).
|
||||
*
|
||||
* Unter den Kategorien stehen überall die aktiven Umbuchungskonten, sortiert
|
||||
* und beschriftet nach Kontengruppe – in der Buchungsmaske, bei den Regeln
|
||||
* samt ihren Splitzeilen, im Umsatzabgleich, bei Vorlagen, regelmäßigen
|
||||
* Buchungen, Überweisungen und Verträgen sowie in den Filtern von
|
||||
* Buchungsbericht und Export.
|
||||
*/
|
||||
class UmbuchungskontenAuswahlTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 61: Umbuchungskonten in der Kategorieauswahl';
|
||||
|
||||
private int $giro = 0;
|
||||
private int $tagesgeld = 0;
|
||||
private int $bauspar = 0;
|
||||
private int $alt = 0;
|
||||
private int $lebensmittel = 0;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->mandantAnlegen();
|
||||
|
||||
// Die Gruppen in umgekehrter Namensreihenfolge sortiert: Die Liste muss
|
||||
// der Sortierung der Kontengruppen folgen, nicht dem Alphabet.
|
||||
$sparen = Kontengruppe::anlegen('Sparen', '', 1);
|
||||
$liquide = Kontengruppe::anlegen('Liquide Mittel', '', 2);
|
||||
|
||||
$this->giro = Konto::anlegen([
|
||||
'name' => 'Auswahl Giro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||||
'kontengruppe_id' => $liquide,
|
||||
]);
|
||||
$this->tagesgeld = Konto::anlegen([
|
||||
'name' => 'Auswahl Tagesgeld', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||||
'kontengruppe_id' => $sparen,
|
||||
]);
|
||||
$this->bauspar = Konto::anlegen([
|
||||
'name' => 'Auswahl Bausparen', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||||
]);
|
||||
$this->alt = Konto::anlegen([
|
||||
'name' => 'Auswahl Altkonto', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 0,
|
||||
'kontengruppe_id' => $sparen,
|
||||
]);
|
||||
$this->lebensmittel = Kategorie::anlegen(['name' => 'Auswahl Lebensmittel', 'art' => 'ausgabe']);
|
||||
|
||||
$this->gruppenNurAktivNachKontengruppe();
|
||||
$this->auswahllisteZeigtUmbuchungskonten();
|
||||
$this->formularwerteHinUndZurueck();
|
||||
$this->regelsplitsMitGegenkonto();
|
||||
$this->regelmaskeHatGemeinsameListe();
|
||||
$this->buchungsberichtFiltertNachUmbuchungskonto();
|
||||
$this->exportFiltertNachUmbuchungskonto();
|
||||
$this->ueberweisungAufEigenesKonto();
|
||||
$this->vertragUndVorlageMitGegenkonto();
|
||||
}
|
||||
|
||||
private function gruppenNurAktivNachKontengruppe(): void
|
||||
{
|
||||
$gruppen = Konto::umbuchungsgruppen(Konto::alle());
|
||||
|
||||
$this->assertEquals(
|
||||
['Umbuchungskonten – Sparen', 'Umbuchungskonten – Liquide Mittel', 'Umbuchungskonten – nicht gruppiert'],
|
||||
array_keys($gruppen),
|
||||
'Die Umbuchungskonten stehen je Kontengruppe in deren Reihenfolge'
|
||||
);
|
||||
$namen = array_merge(...array_map(
|
||||
static fn(array $konten): array => array_column($konten, 'name'),
|
||||
array_values($gruppen)
|
||||
));
|
||||
$this->assert(!in_array('Auswahl Altkonto', $namen, true), 'Ein inaktives Konto wird nicht angeboten');
|
||||
|
||||
$mitAltem = Konto::umbuchungsgruppen(Konto::alle(), [$this->alt]);
|
||||
$this->assertEquals(
|
||||
['Auswahl Altkonto', 'Auswahl Tagesgeld'],
|
||||
array_column($mitAltem['Umbuchungskonten – Sparen'], 'name'),
|
||||
'Ein schon gewähltes inaktives Konto bleibt in seiner Gruppe stehen'
|
||||
);
|
||||
|
||||
$ohneGruppen = Konto::umbuchungsgruppen([
|
||||
['id' => 1, 'name' => 'A', 'aktiv' => 1, 'gruppenname' => null],
|
||||
]);
|
||||
$this->assertEquals(['Umbuchungskonten'], array_keys($ohneGruppen), 'Ohne Kontengruppen eine einzige Überschrift');
|
||||
}
|
||||
|
||||
private function auswahllisteZeigtUmbuchungskonten(): void
|
||||
{
|
||||
$html = $this->auswahlliste('kategorie:' . $this->lebensmittel, $this->giro);
|
||||
|
||||
$this->assert(str_contains($html, '<optgroup label="Kategorien">'), 'Die Kategorien stehen oben');
|
||||
$kategorien = strpos($html, 'Auswahl Lebensmittel');
|
||||
$tagesgeld = strpos($html, '[Auswahl Tagesgeld]');
|
||||
$this->assert(
|
||||
$kategorien !== false && $tagesgeld !== false && $kategorien < $tagesgeld,
|
||||
'Die Umbuchungskonten stehen unter den Kategorien'
|
||||
);
|
||||
$this->assert(
|
||||
str_contains($html, '<optgroup label="Umbuchungskonten – Sparen">'),
|
||||
'Die Umbuchungskonten sind nach Kontengruppe beschriftet'
|
||||
);
|
||||
$this->assert(!str_contains($html, '[Auswahl Giro]'), 'Das eigene Konto wird nicht angeboten');
|
||||
$this->assert(
|
||||
(bool)preg_match('#<option value="konto:' . $this->alt . '" hidden>#', $html),
|
||||
'Ein inaktives Konto bleibt nur als verborgener Eintrag für bestehende Zuordnungen'
|
||||
);
|
||||
|
||||
$gewaehlt = $this->auswahlliste('konto:' . $this->alt, 0);
|
||||
$this->assert(
|
||||
(bool)preg_match('#<option value="konto:' . $this->alt . '" selected>#', $gewaehlt),
|
||||
'Ein schon gewähltes inaktives Konto steht sichtbar und gewählt da'
|
||||
);
|
||||
}
|
||||
|
||||
private function formularwerteHinUndZurueck(): void
|
||||
{
|
||||
$this->assertEquals('konto:7', BuchungenController::zuordnungswert(['kategorie_id' => 3, 'gegenkonto_id' => 7]),
|
||||
'Das Gegenkonto sticht die Kategorie');
|
||||
$this->assertEquals('kategorie:3', BuchungenController::zuordnungswert(['kategorie_id' => 3, 'gegenkonto_id' => null]),
|
||||
'Ohne Gegenkonto die Kategorie');
|
||||
$this->assertEquals('', BuchungenController::zuordnungswert(null), 'Ohne Zeile kein Wert');
|
||||
|
||||
$this->assertEquals(
|
||||
['kategorie_id' => null, 'gegenkonto_id' => 7],
|
||||
BuchungenController::zuordnungAusFormular(['zuordnung' => 'konto:7', 'kategorie_id' => '3']),
|
||||
'Die gemeinsame Liste hat Vorrang vor einzelnen Feldern'
|
||||
);
|
||||
$this->assertEquals(
|
||||
['kategorie_id' => 3, 'gegenkonto_id' => null],
|
||||
BuchungenController::zuordnungAusFormular(['kategorie_id' => '3', 'gegenkonto_id' => '']),
|
||||
'Ohne Liste gelten die versteckten Einzelfelder einer Tabellenzeile'
|
||||
);
|
||||
}
|
||||
|
||||
private function regelsplitsMitGegenkonto(): void
|
||||
{
|
||||
$id = AbgleichRegel::anlegen([
|
||||
'name' => 'Sparplan aufteilen', 'feld' => 'empfaenger', 'vergleich' => 'enthaelt', 'muster' => 'Sparplan',
|
||||
'splits' => [
|
||||
['kategorie_id' => $this->lebensmittel, 'anteil' => 0.25],
|
||||
['gegenkonto_id' => $this->tagesgeld, 'anteil' => 0.75],
|
||||
['kategorie_id' => $this->lebensmittel, 'gegenkonto_id' => $this->bauspar, 'anteil' => 0.0],
|
||||
],
|
||||
]);
|
||||
$regel = AbgleichRegel::find($id);
|
||||
|
||||
$zeilen = AbgleichRegel::splitzeilen($regel);
|
||||
$this->assertEquals(2, count($zeilen), 'Eine Zeile ohne Anteil fällt weg, eine mit Gegenkonto bleibt');
|
||||
$this->assertEquals($this->tagesgeld, $zeilen[1]['gegenkonto_id'], 'Die Splitzeile behält ihr Gegenkonto');
|
||||
$this->assertNull($zeilen[1]['kategorie_id'], 'Die Umbuchungszeile führt keine Kategorie');
|
||||
|
||||
$zuordnung = AbgleichRegel::anwenden($regel, ['betrag' => -100.0]);
|
||||
$this->assertEquals($this->tagesgeld, $zuordnung['splits'][1]['gegenkonto_id'], 'anwenden() reicht das Gegenkonto weiter');
|
||||
$this->assertEquals(-75.0, $zuordnung['splits'][1]['betrag'], 'Der Anteil wird auch für die Umbuchung umgerechnet');
|
||||
|
||||
$buchung = Buchung::anlegen(
|
||||
['konto_id' => $this->giro, 'datum' => date('Y-m-d'), 'betrag' => -100.0, 'empfaenger' => 'Sparplan'],
|
||||
$zuordnung['splits']
|
||||
);
|
||||
$splits = Buchung::splits($buchung);
|
||||
$this->assertEquals($this->tagesgeld, (int)$splits[1]['gegenkonto_id'], 'Die Splitbuchung aus der Regel bucht auf das Umbuchungskonto');
|
||||
|
||||
$neu = AbgleichRegel::find((int)AbgleichRegel::ausBuchung($buchung));
|
||||
$this->assertEquals(
|
||||
$this->tagesgeld,
|
||||
AbgleichRegel::splitzeilen($neu)[1]['gegenkonto_id'],
|
||||
'Eine Regel aus der Buchung übernimmt die Umbuchungszeile'
|
||||
);
|
||||
}
|
||||
|
||||
private function regelmaskeHatGemeinsameListe(): void
|
||||
{
|
||||
$regel = AbgleichRegel::find(AbgleichRegel::anlegen([
|
||||
'name' => 'Umbuchung', 'muster' => 'Tagesgeld', 'gegenkonto_id' => $this->tagesgeld,
|
||||
]));
|
||||
|
||||
$daten = [
|
||||
'regel' => $regel,
|
||||
'kategorien' => Kategorie::baumFlach(null, true),
|
||||
'klassen' => Klasse::alle(),
|
||||
'konten' => Konto::alle(),
|
||||
'idPrefix' => 'test-',
|
||||
];
|
||||
extract($daten, EXTR_SKIP);
|
||||
ob_start();
|
||||
include dirname(__DIR__) . '/src/Views/banking/regelfelder.php';
|
||||
$html = (string)ob_get_clean();
|
||||
|
||||
$this->assert(str_contains($html, 'name="zuordnung"'), 'Regel anlegen: eine Liste Kategorie / Gegenkonto');
|
||||
$this->assert(!str_contains($html, 'name="gegenkonto_id"'), 'Das getrennte Gegenkonto-Feld entfällt');
|
||||
$this->assert(
|
||||
(bool)preg_match('#<option value="konto:' . $this->tagesgeld . '" selected>#', $html),
|
||||
'Das Gegenkonto der Regel ist vorausgewählt'
|
||||
);
|
||||
$this->assert(
|
||||
str_contains($html, 'name="split[0][zuordnung]"'),
|
||||
'Die Splitzeilen der Regel bieten ebenfalls Umbuchungskonten an'
|
||||
);
|
||||
}
|
||||
|
||||
private function buchungsberichtFiltertNachUmbuchungskonto(): void
|
||||
{
|
||||
$datum = '2026-03-10';
|
||||
Buchung::anlegen(['konto_id' => $this->giro, 'datum' => $datum, 'betrag' => -50.0,
|
||||
'empfaenger' => 'Sparrate', 'gegenkonto_id' => $this->tagesgeld]);
|
||||
Buchung::anlegen(['konto_id' => $this->giro, 'datum' => $datum, 'betrag' => -20.0,
|
||||
'empfaenger' => 'Markt', 'kategorie_id' => $this->lebensmittel]);
|
||||
Buchung::anlegen(['konto_id' => $this->giro, 'datum' => $datum, 'betrag' => -30.0,
|
||||
'empfaenger' => 'Bausparrate', 'gegenkonto_id' => $this->bauspar]);
|
||||
|
||||
$filter = Auswertung::normalisiere([
|
||||
'typ' => 'buchungsbericht', 'kategorien' => ['konto:' . $this->tagesgeld, (string)$this->lebensmittel],
|
||||
]);
|
||||
$this->assertEquals([$this->tagesgeld], $filter['gegenkonten'], 'konto:… aus der Kategorieauswahl wird zum Umbuchungskonto');
|
||||
$this->assertEquals([$this->lebensmittel], $filter['kategorien'], 'Die Kategorien bleiben daneben erhalten');
|
||||
|
||||
$summe = Auswertung::normalisiere(['typ' => 'summenbericht', 'kategorien' => ['konto:' . $this->tagesgeld]]);
|
||||
$this->assertEquals([], $summe['gegenkonten'], 'Nur der Buchungsbericht kennt Umbuchungskonten im Filter');
|
||||
|
||||
$bericht = Auswertung::bericht(['zeitraum' => 'benutzerdefiniert', 'von' => $datum, 'bis' => $datum] + $filter);
|
||||
$empfaenger = [];
|
||||
foreach ($bericht['gruppen'] as $gruppe) {
|
||||
$empfaenger = array_merge($empfaenger, array_column($gruppe['buchungen'], 'empfaenger'));
|
||||
}
|
||||
sort($empfaenger);
|
||||
$this->assertEquals(
|
||||
['Markt', 'Sparrate'],
|
||||
$empfaenger,
|
||||
'Der Buchungsbericht zeigt Kategorie und gewähltes Umbuchungskonto – auch ohne „Umbuchungen mitzählen“'
|
||||
);
|
||||
}
|
||||
|
||||
private function exportFiltertNachUmbuchungskonto(): void
|
||||
{
|
||||
$basis = ['von' => '2026-03-10', 'bis' => '2026-03-10'];
|
||||
|
||||
$this->assertEquals(1, BuchungsExport::anzahl($basis + ['gegenkonten' => [$this->bauspar]]),
|
||||
'Export: ein Umbuchungskonto filtert seine Umbuchungen');
|
||||
$this->assertEquals(
|
||||
2,
|
||||
BuchungsExport::anzahl($basis + ['kategorien' => [$this->lebensmittel], 'gegenkonten' => [$this->bauspar]]),
|
||||
'Export: Kategorie und Umbuchungskonto aus derselben Auswahl verbinden sich mit „oder“'
|
||||
);
|
||||
}
|
||||
|
||||
private function ueberweisungAufEigenesKonto(): void
|
||||
{
|
||||
$id = Zahlungsauftrag::anlegen([
|
||||
'konto_id' => $this->giro, 'empfaenger' => 'Ich selbst', 'iban' => 'DE02120300000000202051',
|
||||
'betrag' => 100.0, 'kategorie_id' => $this->lebensmittel, 'gegenkonto_id' => $this->tagesgeld,
|
||||
]);
|
||||
$auftrag = Zahlungsauftrag::find($id);
|
||||
$this->assertEquals($this->tagesgeld, (int)$auftrag['gegenkonto_id'], 'Die Überweisung merkt sich das Umbuchungskonto');
|
||||
$this->assertNull($auftrag['kategorie_id'], 'Kategorie und Gegenkonto schließen sich aus');
|
||||
|
||||
Zahlungsauftrag::alsGesendetVermerken($id, 'Angenommen.');
|
||||
$buchung = Buchung::find((int)Zahlungsauftrag::find($id)['buchung_id']);
|
||||
$this->assertEquals($this->tagesgeld, (int)$buchung['gegenkonto_id'], 'Gesendet wird sie als Umbuchung gebucht');
|
||||
|
||||
$abgewiesen = false;
|
||||
try {
|
||||
Zahlungsauftrag::anlegen([
|
||||
'konto_id' => $this->giro, 'empfaenger' => 'Ich selbst', 'iban' => 'DE02120300000000202051',
|
||||
'betrag' => 1.0, 'gegenkonto_id' => $this->giro,
|
||||
]);
|
||||
} catch (\InvalidArgumentException) {
|
||||
$abgewiesen = true;
|
||||
}
|
||||
$this->assert($abgewiesen, 'Eine Überweisung auf das Auftragskonto selbst wird schon beim Erfassen abgewiesen');
|
||||
}
|
||||
|
||||
private function vertragUndVorlageMitGegenkonto(): void
|
||||
{
|
||||
$vertrag = Vertrag::find(Vertrag::anlegen([
|
||||
'name' => 'Bausparvertrag', 'betrag' => 50, 'gegenkonto_id' => $this->bauspar,
|
||||
]));
|
||||
$this->assertEquals($this->bauspar, (int)$vertrag['gegenkonto_id'], 'Ein Vertrag kann auf ein Umbuchungskonto zeigen');
|
||||
|
||||
Buchungsvorlage::anlegen(['name' => 'Auswahl Sparrate', 'konto_id' => $this->giro, 'betrag' => -50,
|
||||
'gegenkonto_id' => $this->tagesgeld]);
|
||||
$vorlagen = array_column(Buchungsvorlage::alle('Auswahl Sparrate'), 'gegenkontoname', 'name');
|
||||
$this->assertEquals('Auswahl Tagesgeld', $vorlagen['Auswahl Sparrate'] ?? null,
|
||||
'Die Vorlagenliste kennt den Namen des Umbuchungskontos');
|
||||
}
|
||||
|
||||
/** Die Auswahlliste rendern, wie sie jede Maske einbindet. */
|
||||
private function auswahlliste(string $gewaehlt, int $eigenesKonto): string
|
||||
{
|
||||
$kategorien = Kategorie::baumFlach(null, true);
|
||||
$konten = Konto::alle();
|
||||
$mitSplit = false;
|
||||
$mitDepot = false;
|
||||
|
||||
ob_start();
|
||||
include dirname(__DIR__) . '/src/Views/buchungen/zuordnungsliste.php';
|
||||
return (string)ob_get_clean();
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ 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}`
|
||||
const TAGESGELD = `E2E RegelBuchung Tagesgeld ${MARKE}`
|
||||
|
||||
async function anmelden(page) {
|
||||
await page.goto('/login')
|
||||
|
|
@ -21,6 +22,7 @@ async function anmelden(page) {
|
|||
// erzeugen; ein Dialog zeigt sie danach zum Prüfen.
|
||||
test.describe('Regel aus einer Buchung anlegen', () => {
|
||||
let giroId = null
|
||||
let tagesgeldId = null
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const page = await browser.newPage()
|
||||
|
|
@ -38,6 +40,12 @@ test.describe('Regel aus einer Buchung anlegen', () => {
|
|||
await page.selectOption('#art', 'ausgabe')
|
||||
await page.click('.formular button[type="submit"]')
|
||||
|
||||
await page.goto('/konten/neu')
|
||||
await page.fill('#name', TAGESGELD)
|
||||
await page.click('.formular button[type="submit"]')
|
||||
await expect(page).toHaveURL(/\/konten\/\d+\/bearbeiten/)
|
||||
tagesgeldId = page.url().match(/\/konten\/(\d+)\//)[1]
|
||||
|
||||
await page.close()
|
||||
})
|
||||
|
||||
|
|
@ -75,7 +83,7 @@ test.describe('Regel aus einer Buchung anlegen', () => {
|
|||
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 expect(dialog.locator('select[name="zuordnung"]')).toHaveValue(/^kategorie:\d+$/)
|
||||
|
||||
await dialog.getByRole('button', { name: 'Abbruch' }).click()
|
||||
|
||||
|
|
@ -118,6 +126,31 @@ test.describe('Regel aus einer Buchung anlegen', () => {
|
|||
await expect(zeile.locator('input[name="muster"]')).toHaveValue(angepasstesMuster)
|
||||
})
|
||||
|
||||
// #124: Unter den Kategorien stehen auch die Umbuchungskonten – in der
|
||||
// Regel selbst wie in ihren Splitzeilen.
|
||||
test('Regel-Splitzeile bucht auf ein Umbuchungskonto', async ({ page }) => {
|
||||
const name = `Sparregel ${MARKE}`
|
||||
|
||||
await page.goto('/banking/regeln')
|
||||
await page.click('#neu-knopf')
|
||||
const dialog = page.locator('#neudialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await expect(dialog.locator('#neu_zuordnung option', { hasText: `[${TAGESGELD}]` })).toHaveCount(1)
|
||||
await dialog.locator('#neu_name').fill(name)
|
||||
await dialog.locator('#neu_muster').fill(name)
|
||||
await dialog.locator('select[name="split[0][zuordnung]"]').selectOption({ label: KATEGORIE })
|
||||
await dialog.locator('input[name="split[0][prozent]"]').fill('25')
|
||||
await dialog.locator('select[name="split[1][zuordnung]"]').selectOption({ label: `[${TAGESGELD}]` })
|
||||
await dialog.locator('input[name="split[1][prozent]"]').fill('75')
|
||||
await dialog.getByRole('button', { name: 'Regel anlegen' }).click()
|
||||
|
||||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||
const zeile = page.locator('tr').filter({ has: page.locator(`input[name="name"][value="${name}"]`) })
|
||||
const splits = zeile.locator('xpath=following-sibling::tr[1]')
|
||||
await expect(splits.locator('select[name="split[1][zuordnung]"]')).toHaveValue(`konto:${tagesgeldId}`)
|
||||
})
|
||||
|
||||
test('Escape im Dialog verwirft die Regel wie ein Klick auf Abbruch', async ({ page }) => {
|
||||
const empfaenger = `Escaperegel ${MARKE}`
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue