Buchungen: Gegenkonto sichtbar, Zuordnung direkt im Kontoblatt
Eine Umbuchung war auf dem Gegenkonto nicht mehr zuzuordnen: Die Zeile trug die
Marke „Umbuchung“, die Kategoriespalte daneben blieb leer. Die Daten waren
richtig, die Anzeige verschwieg sie. In der Kategoriespalte steht jetzt auch in
der Gegensicht das Konto in eckigen Klammern, von dem die Umbuchung kommt – die
Abfrage lieferte es längst mit.
Kategorie und Gegenkonto stehen in einem Feld (`Views/buchungen/zuordnungsliste.php`):
Unter den Kategorien stehen die eigenen Konten in eckigen Klammern, wie im
FinanzManager, dessen Kategoriefeld `[Konto]` annimmt. Eines davon macht die
Buchung zur Umbuchung; die Auswahl „Ausgabe / Einnahme“ bestimmt seither nur
noch die Richtung. Dieselbe Liste steht in den Splitzeilen – eine Splitzeile mit
Gegenkonto ließ sich bisher nur im Import anlegen und ging beim Bearbeiten der
Buchung stillschweigend verloren.
Schnellzuordnung: Ein Klick auf die Kategoriespalte öffnet die Liste in der
Zelle, die Auswahl speichert sofort (POST /buchungen/{id}/zuordnung) und kommt
in dieselbe Ansicht zurück. Die Liste gibt es einmal und wandert in die
angeklickte Zelle – eine Liste je Zeile wären bei hundert Zeilen ebenso viele
vollständige Kategoriebäume im HTML. Feld und Liste sind gleich breit und gleich
hoch, sonst rückte beim Anklicken die halbe Zeile. Ohne JavaScript führt
derselbe Klick in die Buchungsmaske: Die Zelle ist ein Link. Die Gegenseite
einer fremden Umbuchung führt zu ihrer Buchung auf dem anderen Konto.
Der Eintrag „Splitbuchung“ öffnet ein modales Fenster mit Zeilen für Kategorie
oder Gegenkonto, Klasse, Notiz und Betrag; der Restbetrag wird mitgerechnet. Ein
Klick auf eine bereits gesplittete Buchung öffnet direkt den Dialog. „Splitbuchung
löschen“ verwirft alle Zeilen samt ihren Zuordnungen – auch die Gegenseite einer
Splitzeile mit Gegenkonto –, die Buchung steht danach unzugeordnet im Kontoblatt.
Beide Wege gehen über `Buchung::zuordnen()` und `Buchung::splitsErsetzen()`,
achten den Monatsabschluss und lassen den Rest der Buchung unberührt.
`public/static/betrag.js` bündelt das Lesen deutscher Beträge und die
Restbetragsrechnung für Maske und Kontoblatt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
4d62529e2c
commit
fe40a29f35
15 changed files with 1423 additions and 198 deletions
51
Changes.md
51
Changes.md
|
|
@ -1,5 +1,56 @@
|
|||
# Änderungen
|
||||
|
||||
## Umbuchungen: Gegenkonto sichtbar, Zuordnung im Kontoblatt (12.08.2026)
|
||||
|
||||
Eine Umbuchung war auf dem Gegenkonto nicht mehr zuzuordnen: Die Zeile trug die Marke
|
||||
*Umbuchung*, die Kategoriespalte daneben blieb leer. Zu sehen etwa bei den Buchungen
|
||||
zwischen DKB Girokonto und Paypal – die Daten waren richtig, die Anzeige verschwieg sie.
|
||||
|
||||
**Beide Sichten nennen das Gegenüber** (`Views/buchungen/kontoblatt.php`)
|
||||
- In der Kategoriespalte steht jetzt auch in der Gegensicht das Konto in eckigen Klammern,
|
||||
von dem die Umbuchung kommt. Die Abfrage lieferte das Konto längst mit; die Ansicht hat
|
||||
es nur nicht gezeigt.
|
||||
|
||||
**Kategorie und Gegenkonto in einem Feld** (`Views/buchungen/zuordnungsliste.php`,
|
||||
`BuchungenController`)
|
||||
- Unter den Kategorien stehen die eigenen Konten in eckigen Klammern – wie im
|
||||
FinanzManager, dessen Kategoriefeld `[Konto]` annimmt. Eines davon macht die Buchung zur
|
||||
Umbuchung. Die Auswahl *Ausgabe / Einnahme* bestimmt seither nur noch die Richtung; das
|
||||
eigene Konto fehlt in der Liste.
|
||||
- Dieselbe Liste steht in den Splitzeilen. Eine Splitzeile mit Gegenkonto ließ sich bisher
|
||||
nur im Import anlegen und ging beim Bearbeiten der Buchung stillschweigend verloren.
|
||||
|
||||
**Schnellzuordnung im Kontoblatt** (`public/static/kontoblatt.js`, `Buchung::zuordnen()`)
|
||||
- Ein Klick auf die Kategoriespalte öffnet die Liste in der Zelle: leerer Eintrag,
|
||||
*Splitbuchung*, Kategorien, Konten. Die Auswahl speichert sofort
|
||||
(`POST /buchungen/{id}/zuordnung`) und kommt in dieselbe Ansicht zurück – Filter und
|
||||
Seite hängen am Ziel.
|
||||
- Die Liste gibt es einmal und wandert in die angeklickte Zelle. Eine Liste je Zeile wären
|
||||
bei hundert Zeilen ebenso viele vollständige Kategoriebäume im HTML.
|
||||
- Feld und Liste sind gleich breit und gleich hoch, die Zelle zeigt nur, was auch in der
|
||||
Liste steht – sonst rückte beim Anklicken die halbe Zeile.
|
||||
- Ohne JavaScript führt derselbe Klick in die Buchungsmaske: Die Zelle ist ein Link.
|
||||
- Auch die Gegenseite einer fremden Umbuchung ist anklickbar; sie führt zu ihrer Buchung
|
||||
auf dem anderen Konto, wo sie geändert wird.
|
||||
|
||||
**Splitdialog** (`Views/buchungen/kontoblatt.php`, `Buchung::splitsErsetzen()`)
|
||||
- Der Eintrag *Splitbuchung* öffnet ein modales Fenster mit Zeilen für Kategorie oder
|
||||
Gegenkonto, Klasse, Notiz und Betrag; der Restbetrag wird mitgerechnet, weitere Zeilen
|
||||
kommen auf Knopfdruck. Eine bestehende Aufteilung steht schon drin.
|
||||
- Ein Klick auf eine bereits gesplittete Buchung öffnet direkt den Dialog statt der
|
||||
Auswahlliste: Für sie hatte die Liste nichts anzubieten außer dem Weg dorthin.
|
||||
- Rechts unten im Dialog steht *Splitbuchung löschen*. Der Knopf schickt dasselbe
|
||||
Formular mit `aktion=loeschen`, verwirft alle Zeilen samt ihren Zuordnungen – auch die
|
||||
Gegenseite einer Splitzeile mit Gegenkonto – und die Buchung steht danach unzugeordnet
|
||||
im Kontoblatt. Er erscheint nur dort, wo es etwas aufzuheben gibt, und steht abseits
|
||||
von *Speichern* und *Abbrechen*.
|
||||
- Bleiben alle Zeilen leer, wird die Splitbuchung ebenso aufgehoben.
|
||||
- Beide Wege achten den Monatsabschluss und lassen den Rest der Buchung unberührt.
|
||||
|
||||
`public/static/betrag.js` bündelt das Lesen deutscher Beträge und die Restbetragsrechnung
|
||||
für Maske und Kontoblatt. `tests/23_ZuordnungTest.php` prüft Zuordnung, Splitzeilen und
|
||||
Gegensicht; `tests/e2e/specs/buchungen.spec.js` die drei Wege im Browser.
|
||||
|
||||
## Wertpapiere: Depotbuchungen sammelweise löschen (12.08.2026)
|
||||
|
||||
Die Liste der Depotbuchungen räumt jetzt so auf wie das Kontoblatt – nach einem Import,
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@ Haushalt wird beides parallel genutzt.
|
|||
|
||||
**Alle sieben Phasen aus `PLAN.md` sind umgesetzt.**
|
||||
|
||||
* **Stammdaten und Buchungen** – Konten, Kontengruppen, Kategorien, Klassen; Kontoblatt,
|
||||
Buchungsmaske mit Splitbuchungen und Umbuchungen; QIF-Import mit Stichtag.
|
||||
* **Stammdaten und Buchungen** – Konten, Kontengruppen, Kategorien, Klassen; Kontoblatt
|
||||
mit Schnellzuordnung und Splitdialog, Buchungsmaske mit Splitbuchungen und Umbuchungen;
|
||||
QIF-Import mit Stichtag.
|
||||
* **Planung** – Budgetplaner mit Monatswerten je Kategorie, Monatsabschluss mit Prüflauf,
|
||||
Snapshot und Sperre.
|
||||
* **Auswertungen** – Summen-, Buchungs-, Vergleichs-, Kontostands- und Budgetbericht mit
|
||||
|
|
|
|||
|
|
@ -21,6 +21,13 @@ Zeilen aus 2 und 3 tragen `gegensicht = true`. Sie sind nicht bearbeitbar – da
|
|||
dem Ursprungskonto –, damit eine Umbuchung nicht aus zwei Richtungen widersprüchlich
|
||||
geändert wird.
|
||||
|
||||
**Beide Sichten nennen das Gegenüber.** In der Kategoriespalte steht das jeweils andere
|
||||
Konto in eckigen Klammern: auf dem Ausgangskonto das Ziel, auf dem Gegenkonto das
|
||||
Ursprungskonto. In der Gegensicht liefert die Abfrage dafür `b.konto_id` als
|
||||
`gegenkonto_id` – dieselbe Spalte, aus der anderen Richtung gelesen. Ohne diese Angabe
|
||||
stünde auf dem Gegenkonto eine Zeile, die sich „Umbuchung“ nennt und verschweigt, woher
|
||||
sie kommt.
|
||||
|
||||
Dieselbe Dreiteilung gilt für die Salden (`Buchung::saldenJeKonto()`), die alle Konten mit
|
||||
sechs Abfragen auf einmal berechnet statt je Konto einzeln.
|
||||
|
||||
|
|
@ -43,6 +50,56 @@ Filter und Seite hängen an der Abfragezeichenfolge des Formularziels. Der Contr
|
|||
den Weg zurück aus den bekannten Feldern neu zusammen, statt ein mitgeschicktes Ziel zu
|
||||
übernehmen; so lässt sich kein fremder Weiterleitungsort einschleusen.
|
||||
|
||||
## Kategorie und Gegenkonto in einem Feld
|
||||
|
||||
Eine Buchung ist entweder kategorisiert **oder** eine Umbuchung – beides zugleich gibt es
|
||||
nicht. Deshalb stehen Kategorien und Konten in derselben Auswahlliste; die Konten unten,
|
||||
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:
|
||||
|
||||
| Wert | Bedeutung |
|
||||
|---|---|
|
||||
| `` (leer) | keine Zuordnung |
|
||||
| `kategorie:12` | Kategorie 12 |
|
||||
| `konto:7` | Umbuchung mit Gegenkonto 7 |
|
||||
| `split` | Splitdialog öffnen (nur im Kontoblatt) |
|
||||
|
||||
Die Auswahl *Ausgabe / Einnahme* bestimmt seither nur noch die Richtung des Betrags.
|
||||
|
||||
## Schnellzuordnung im Kontoblatt
|
||||
|
||||
Ein Klick auf die Kategoriespalte öffnet die Liste in der Zelle; die Auswahl geht an
|
||||
`POST /buchungen/{id}/zuordnung`, der Eintrag *Splitbuchung* öffnet stattdessen den
|
||||
Splitdialog (`POST /buchungen/{id}/splitbuchung`). Beide Wege gehen über
|
||||
`Buchung::zuordnen()` und `Buchung::splitsErsetzen()`, die den Rest der Buchung unberührt
|
||||
lassen und den Monatsabschluss achten.
|
||||
|
||||
Drei Punkte, die sich nicht ändern dürfen:
|
||||
|
||||
* Die Auswahlliste gibt es **einmal**; `kontoblatt.js` hängt sie in die angeklickte Zelle
|
||||
und danach zurück. Eine Liste je Zeile wären bei hundert Zeilen ebenso viele
|
||||
vollständige Kategoriebäume im HTML.
|
||||
* Ohne JavaScript führt derselbe Klick in die Buchungsmaske – die Zelle ist ein Link, kein
|
||||
Knopf.
|
||||
* Zeilen mit `gegensicht = true` führen zu ihrer Buchung, statt die Auswahl zu öffnen: Sie
|
||||
werden auf ihrem Ursprungskonto geändert, wie beim Bearbeiten und Löschen.
|
||||
`kontoblatt.js` erkennt sie an der Klasse `gegenseite`.
|
||||
* Feld und Auswahlliste sind **gleich breit und gleich hoch** (`13rem` / `1.35rem`), und
|
||||
die Zelle zeigt nichts an, was nicht auch in der Liste stünde – kein „zuordnen …“. Sonst
|
||||
rückt beim Anklicken die halbe Zeile: Die Spalte wüchse auf die Breite der längsten
|
||||
Kategorie, und der Empfänger daneben bräche um.
|
||||
|
||||
Eine schon gesplittete Buchung öffnet auf Klick **direkt den Splitdialog**, nicht die
|
||||
Auswahlliste: Deren einziger sinnvoller Eintrag wäre für sie ohnehin *Splitbuchung*
|
||||
gewesen. Wer eine Aufteilung wieder in eine einzelne Zuordnung wandeln will, löscht sie
|
||||
im Dialog und ordnet die dann unzugeordnete Buchung anschließend zu – oder geht über die
|
||||
Buchungsmaske.
|
||||
|
||||
## Splitbuchungen
|
||||
|
||||
Eine Buchung ist entweder einfach zugeordnet **oder** gesplittet. Sobald Splitzeilen
|
||||
|
|
@ -50,9 +107,17 @@ vorliegen, räumt das Model `kategorie_id` und `gegenkonto_id` der Buchung; die
|
|||
steckt dann ausschließlich in den Zeilen.
|
||||
|
||||
Die Summe der Zeilen muss dem Buchungsbetrag entsprechen – geprüft im Model (Toleranz ein
|
||||
halber Cent, für Rundungen aus der Betragsumrechnung) und in der Maske als
|
||||
Restbetragsanzeige. Eine Splitzeile darf ein Gegenkonto tragen; dann erscheint auf jenem
|
||||
Konto nur der Betrag dieser Zeile.
|
||||
halber Cent, für Rundungen aus der Betragsumrechnung) und in beiden Masken als
|
||||
Restbetragsanzeige. Beide rechnen mit `public/static/betrag.js`; die Regel für das
|
||||
deutsche Zahlenformat steht damit einmal im Browser und einmal in PHP, nicht dreimal. Eine
|
||||
Splitzeile darf ein Gegenkonto tragen; dann erscheint auf jenem Konto nur der Betrag dieser
|
||||
Zeile. Auf das eigene Konto darf sie nicht zeigen – die Buchung zählte dort doppelt.
|
||||
|
||||
Der Knopf *Splitbuchung löschen* im Dialog schickt `POST /buchungen/{id}/splitbuchung` mit
|
||||
`aktion=loeschen`; der Controller ruft dann `Buchung::splitsErsetzen($id, [])`, ganz gleich
|
||||
was in den Feldern steht. Gelöscht werden die Zeilen samt Zuordnung, nicht die Buchung –
|
||||
sie steht danach mit ihrem Betrag und ohne Kategorie im Kontoblatt, und die Gegenseite
|
||||
einer Zeile mit Gegenkonto verschwindet von jenem Konto.
|
||||
|
||||
## Salden
|
||||
|
||||
|
|
@ -70,7 +135,7 @@ nicht; es wird im Kontoformular gepflegt.
|
|||
## Beträge in der Oberfläche
|
||||
|
||||
Eingegeben wird immer ein **positiver** Betrag; die Richtung bestimmt die Auswahl
|
||||
*Ausgabe / Einnahme / Umbuchung*. `KontenController::zahl()` liest das deutsche Format und
|
||||
*Ausgabe / Einnahme*. `KontenController::zahl()` liest das deutsche Format und
|
||||
entscheidet bei einem Punkt ohne Komma nach der Stellenzahl: drei Ziffern dahinter heißt
|
||||
Tausendertrenner (`1.500`), sonst Dezimaltrenner (`1234.56`).
|
||||
|
||||
|
|
|
|||
|
|
@ -85,6 +85,11 @@ $router->post('/konten/{id}/buchungen/loeschen',
|
|||
$router->get('/buchungen/{id}', fn(array $p) => (new BuchungenController())->bearbeiten($p));
|
||||
$router->post('/buchungen/{id}', fn(array $p) => (new BuchungenController())->speichern($p));
|
||||
$router->post('/buchungen/{id}/loeschen', fn(array $p) => (new BuchungenController())->loeschen($p));
|
||||
// Schnellzuordnung und Splitdialog aus dem Kontoblatt.
|
||||
$router->post('/buchungen/{id}/zuordnung',
|
||||
fn(array $p) => (new BuchungenController())->zuordnung($p));
|
||||
$router->post('/buchungen/{id}/splitbuchung',
|
||||
fn(array $p) => (new BuchungenController())->splitbuchung($p));
|
||||
|
||||
// Verwaltung: Kategorien, Klassen, Kontengruppen, QIF-Import.
|
||||
$router->get('/verwaltung', fn() => (new VerwaltungController())->index());
|
||||
|
|
|
|||
|
|
@ -999,6 +999,47 @@ table.kontoblatt tr.gegensicht td { background: color-mix(in srgb, var(--akzent)
|
|||
margin-top: .8rem;
|
||||
}
|
||||
|
||||
/* Schnellzuordnung: Die Kategoriespalte ist anklickbar wie ein Eingabefeld,
|
||||
sieht aber nicht danach aus – sonst flimmerte die ganze Spalte. Der
|
||||
gestrichelte Unterstrich erscheint erst beim Überfahren.
|
||||
|
||||
Feld und Auswahlliste sind gleich breit und gleich hoch. Sonst rückte beim
|
||||
Anklicken die halbe Zeile: Die Spalte wüchse auf die Breite der längsten
|
||||
Kategorie, und der Empfänger daneben bräche um. */
|
||||
td.zuordnungszelle { width: 13rem; }
|
||||
|
||||
a.zuordnungsfeld {
|
||||
display: block;
|
||||
max-width: 13rem;
|
||||
min-height: 1.35rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dashed transparent;
|
||||
}
|
||||
|
||||
a.zuordnungsfeld:hover,
|
||||
a.zuordnungsfeld:focus-visible { border-bottom-color: var(--rand-kraeftig); }
|
||||
|
||||
.zuordnungsauswahl { display: flex; gap: .3rem; }
|
||||
.zuordnungsauswahl[hidden] { display: none; }
|
||||
|
||||
.zuordnungsauswahl select {
|
||||
width: 100%;
|
||||
max-width: 13rem;
|
||||
height: 1.35rem;
|
||||
padding: 0 .2rem;
|
||||
border: 1px solid var(--akzent);
|
||||
border-radius: 4px;
|
||||
background: var(--flaeche);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-family: var(--schrift);
|
||||
font-size: .85rem;
|
||||
}
|
||||
|
||||
/* Die Splittabelle sitzt im Formular und braucht schmalere Zellen. */
|
||||
table.splittabelle td { padding: .3rem .4rem; }
|
||||
table.splittabelle select { max-width: 14rem; width: 100%; }
|
||||
|
|
@ -1006,6 +1047,28 @@ table.splittabelle input.rechts { text-align: right; }
|
|||
|
||||
.buchungsmaske [hidden] { display: none; }
|
||||
|
||||
/* Der Splitdialog des Kontoblatts. Als <dialog> und nicht als eigene Seite:
|
||||
Die Aufteilung gehört zur Zeile, aus der sie geöffnet wurde. */
|
||||
.splitdialog {
|
||||
width: min(46rem, 92vw);
|
||||
padding: 1.1rem 1.25rem 1.25rem;
|
||||
border: 1px solid var(--rand-kraeftig);
|
||||
border-radius: var(--radius);
|
||||
background: var(--flaeche);
|
||||
color: var(--text);
|
||||
box-shadow: 0 10px 30px rgb(0 0 0 / 25%);
|
||||
}
|
||||
|
||||
.splitdialog::backdrop { background: rgb(0 0 0 / 35%); }
|
||||
|
||||
.splitdialog h2 { margin: 0 0 .3rem; font-size: 1.05rem; }
|
||||
.splitdialog .knopfleiste { margin-top: .9rem; }
|
||||
.splitdialog .splittabelle { margin-top: .6rem; }
|
||||
|
||||
/* Das Löschen der ganzen Aufteilung steht rechts außen – weit weg von
|
||||
„Speichern“ und „Abbrechen“, damit es nicht im Vorbeigehen getroffen wird. */
|
||||
.splitdialog .knopfleiste .knopf.gefahr { margin-left: auto; }
|
||||
|
||||
/* ── Formulare ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.formular {
|
||||
|
|
|
|||
58
public/static/betrag.js
Normal file
58
public/static/betrag.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/* Beträge im deutschen Format lesen und schreiben.
|
||||
*
|
||||
* Dieselbe Regel wie KontenController::zahl(): ohne Komma entscheidet die
|
||||
* Stellenzahl, ob der Punkt Tausender- oder Dezimaltrenner ist. Die Maske und
|
||||
* das Kontoblatt rechnen beide Restbeträge mit – deshalb steht das hier und
|
||||
* nicht zweimal.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function zahl(text) {
|
||||
const bereinigt = String(text).replace(/[\s€]/g, '');
|
||||
if (bereinigt === '') {
|
||||
return 0;
|
||||
}
|
||||
if (bereinigt.indexOf(',') !== -1) {
|
||||
return parseFloat(bereinigt.replace(/\./g, '').replace(',', '.')) || 0;
|
||||
}
|
||||
|
||||
const punkte = (bereinigt.match(/\./g) || []).length;
|
||||
const letzter = bereinigt.split('.').pop();
|
||||
if (punkte > 1 || (punkte === 1 && letzter.length === 3)) {
|
||||
return parseFloat(bereinigt.replace(/\./g, '')) || 0;
|
||||
}
|
||||
return parseFloat(bereinigt) || 0;
|
||||
}
|
||||
|
||||
function formatiere(wert) {
|
||||
return wert.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
/* Die Summe der Splitzeilen unter `felder` gegen `ziel` – das Ergebnis geht
|
||||
* so in die Anzeige, wie beide Masken es brauchen. */
|
||||
function splitsumme(felder, ziel) {
|
||||
let summe = 0;
|
||||
let belegt = false;
|
||||
|
||||
felder.forEach(function (feld) {
|
||||
if (feld.value.trim() === '') {
|
||||
return;
|
||||
}
|
||||
belegt = true;
|
||||
summe += zahl(feld.value);
|
||||
});
|
||||
|
||||
const rest = Math.round((ziel - summe) * 100) / 100;
|
||||
|
||||
return {
|
||||
belegt: belegt,
|
||||
summe: summe,
|
||||
rest: rest,
|
||||
text: belegt ? formatiere(summe) + (rest === 0 ? '' : ' (Rest ' + formatiere(rest) + ')') : '0,00'
|
||||
};
|
||||
}
|
||||
|
||||
window.FmBetrag = { zahl: zahl, formatiere: formatiere, splitsumme: splitsumme };
|
||||
})();
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
/* Buchungsmaske: Felder je nach Art ein- und ausblenden, Restbetrag der
|
||||
* Splitzeilen mitrechnen.
|
||||
/* Buchungsmaske: Restbetrag der Splitzeilen mitrechnen.
|
||||
*
|
||||
* Nur Komfort – die Maske kommt fertig vom Server. Ohne JavaScript bleibt alles
|
||||
* bedienbar: dann sind sämtliche Felder sichtbar, und die Splitsumme prüft
|
||||
* ohnehin das Model.
|
||||
* bedienbar: Die Splitsumme prüft ohnehin das Model.
|
||||
*
|
||||
* Kategorie und Gegenkonto stehen in einem Feld, deshalb gibt es hier nichts
|
||||
* mehr ein- und auszublenden; die Art bestimmt nur noch die Richtung.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
|
|
@ -11,90 +12,35 @@
|
|||
|
||||
const art = document.getElementById('art');
|
||||
const maske = document.querySelector('.buchungsmaske');
|
||||
if (!art || !maske) {
|
||||
if (!art || !maske || !window.FmBetrag) {
|
||||
return;
|
||||
}
|
||||
|
||||
const kategorie = maske.querySelector('.nur-kategorie');
|
||||
const umbuchung = maske.querySelector('.nur-umbuchung');
|
||||
const gegenkonto = document.getElementById('gegenkonto_id');
|
||||
const summenfeld = document.getElementById('splitsumme');
|
||||
const betragsfeld = document.getElementById('betrag');
|
||||
|
||||
function zeigePassendeFelder() {
|
||||
const istUmbuchung = art.value.indexOf('umbuchung') === 0;
|
||||
if (kategorie) {
|
||||
kategorie.hidden = istUmbuchung;
|
||||
}
|
||||
if (umbuchung) {
|
||||
umbuchung.hidden = !istUmbuchung;
|
||||
}
|
||||
if (gegenkonto) {
|
||||
gegenkonto.required = istUmbuchung;
|
||||
}
|
||||
}
|
||||
|
||||
/* Beträge stehen im deutschen Format im Feld. Dieselbe Regel wie
|
||||
* KontenController::zahl(): ohne Komma entscheidet die Stellenzahl, ob der
|
||||
* Punkt Tausender- oder Dezimaltrenner ist. */
|
||||
function zahl(text) {
|
||||
const bereinigt = String(text).replace(/[\s€]/g, '');
|
||||
if (bereinigt === '') {
|
||||
return 0;
|
||||
}
|
||||
if (bereinigt.indexOf(',') !== -1) {
|
||||
return parseFloat(bereinigt.replace(/\./g, '').replace(',', '.')) || 0;
|
||||
}
|
||||
|
||||
const punkte = (bereinigt.match(/\./g) || []).length;
|
||||
const letzter = bereinigt.split('.').pop();
|
||||
if (punkte > 1 || (punkte === 1 && letzter.length === 3)) {
|
||||
return parseFloat(bereinigt.replace(/\./g, '')) || 0;
|
||||
}
|
||||
return parseFloat(bereinigt) || 0;
|
||||
}
|
||||
|
||||
function formatiere(wert) {
|
||||
return wert.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function rechneSumme() {
|
||||
if (!summenfeld) {
|
||||
return;
|
||||
}
|
||||
|
||||
let summe = 0;
|
||||
let belegt = false;
|
||||
maske.querySelectorAll('.splitbetrag').forEach(function (feld) {
|
||||
if (feld.value.trim() === '') {
|
||||
return;
|
||||
}
|
||||
belegt = true;
|
||||
summe += zahl(feld.value);
|
||||
});
|
||||
|
||||
if (!belegt) {
|
||||
summenfeld.textContent = '0,00';
|
||||
summenfeld.classList.remove('negativ');
|
||||
return;
|
||||
}
|
||||
|
||||
// Der Buchungsbetrag wird positiv eingegeben; das Vorzeichen ergibt
|
||||
// sich aus der Art. Für den Vergleich zählt der Betrag mit Richtung.
|
||||
let ziel = zahl(betragsfeld ? betragsfeld.value : '0');
|
||||
if (art.value === 'ausgabe' || art.value === 'umbuchung_ab') {
|
||||
let ziel = window.FmBetrag.zahl(betragsfeld ? betragsfeld.value : '0');
|
||||
if (art.value !== 'einnahme') {
|
||||
ziel = -ziel;
|
||||
}
|
||||
|
||||
const rest = Math.round((ziel - summe) * 100) / 100;
|
||||
summenfeld.textContent = formatiere(summe) + (rest === 0 ? '' : ' (Rest ' + formatiere(rest) + ')');
|
||||
summenfeld.classList.toggle('negativ', rest !== 0);
|
||||
const stand = window.FmBetrag.splitsumme(
|
||||
Array.from(maske.querySelectorAll('.splitbetrag')),
|
||||
ziel
|
||||
);
|
||||
|
||||
summenfeld.textContent = stand.text;
|
||||
summenfeld.classList.toggle('negativ', stand.belegt && stand.rest !== 0);
|
||||
}
|
||||
|
||||
art.addEventListener('change', function () {
|
||||
zeigePassendeFelder();
|
||||
rechneSumme();
|
||||
});
|
||||
art.addEventListener('change', rechneSumme);
|
||||
|
||||
maske.addEventListener('input', function (ereignis) {
|
||||
if (ereignis.target.classList.contains('splitbetrag') || ereignis.target.id === 'betrag') {
|
||||
|
|
@ -102,6 +48,5 @@
|
|||
}
|
||||
});
|
||||
|
||||
zeigePassendeFelder();
|
||||
rechneSumme();
|
||||
})();
|
||||
|
|
|
|||
218
public/static/kontoblatt.js
Normal file
218
public/static/kontoblatt.js
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
/* Kontoblatt: Kategorie oder Gegenkonto an Ort und Stelle zuordnen.
|
||||
*
|
||||
* Ein Klick auf die Kategoriespalte öffnet die Auswahlliste in der Zelle. Sie
|
||||
* gibt es genau einmal und wandert in die angeklickte Zelle – wie das
|
||||
* Zahnradmenü im Budgetplaner. Eine Liste je Zeile wären bei hundert Zeilen
|
||||
* ebenso viele vollständige Kategoriebäume im HTML.
|
||||
*
|
||||
* Ohne JavaScript führt derselbe Klick in die Buchungsmaske; die Zuordnung ist
|
||||
* dort wie bisher zu ändern.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const tabelle = document.querySelector('table.kontoblatt');
|
||||
const auswahl = document.getElementById('zuordnungsauswahl');
|
||||
if (!tabelle || !auswahl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const abfrage = tabelle.dataset.abfrage || '';
|
||||
const liste = auswahl.querySelector('select');
|
||||
const dialog = document.getElementById('splitdialog');
|
||||
const splitformular = document.getElementById('splitformular');
|
||||
|
||||
// Das Feld, das gerade durch die Auswahlliste ersetzt ist.
|
||||
let offenesFeld = null;
|
||||
|
||||
function ziel(feld, aktion) {
|
||||
return '/buchungen/' + feld.dataset.buchung + '/' + aktion + abfrage;
|
||||
}
|
||||
|
||||
function schliessen() {
|
||||
if (!offenesFeld) {
|
||||
return;
|
||||
}
|
||||
offenesFeld.hidden = false;
|
||||
offenesFeld = null;
|
||||
|
||||
auswahl.hidden = true;
|
||||
document.body.appendChild(auswahl);
|
||||
}
|
||||
|
||||
/* Der Splitdialog zu einer Zeile – oder, wo es kein <dialog> gibt, die
|
||||
* Buchungsmaske. Die Weiche steht hier oben, weil beide Wege hierher
|
||||
* führen: der Klick auf eine Splitbuchung und die Wahl „Splitbuchung“. */
|
||||
function splitOeffnen(feld) {
|
||||
if (dialog && splitformular && window.FmBetrag) {
|
||||
splitdialogOeffnen(feld);
|
||||
} else {
|
||||
window.location.href = '/buchungen/' + feld.dataset.buchung;
|
||||
}
|
||||
}
|
||||
|
||||
function oeffnen(feld) {
|
||||
schliessen();
|
||||
|
||||
offenesFeld = feld;
|
||||
feld.hidden = true;
|
||||
|
||||
auswahl.action = ziel(feld, 'zuordnung');
|
||||
liste.value = feld.dataset.wert || '';
|
||||
feld.parentNode.appendChild(auswahl);
|
||||
auswahl.hidden = false;
|
||||
|
||||
liste.focus();
|
||||
// Die Liste soll sofort aufklappen; showPicker gibt es nicht überall.
|
||||
try {
|
||||
liste.showPicker();
|
||||
} catch (fehler) {
|
||||
/* Dann klappt sie beim ersten Klick auf – kein Verlust. */
|
||||
}
|
||||
}
|
||||
|
||||
tabelle.addEventListener('click', function (ereignis) {
|
||||
// Die Gegenseite einer fremden Umbuchung führt zu ihrer Buchung, statt
|
||||
// hier die Auswahl zu öffnen: Geändert wird sie auf ihrem Konto.
|
||||
const feld = ereignis.target.closest('a.zuordnungsfeld:not(.gegenseite)');
|
||||
if (!feld || ereignis.metaKey || ereignis.ctrlKey || ereignis.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
ereignis.preventDefault();
|
||||
|
||||
// Eine schon aufgeteilte Buchung führt geradewegs in den Splitdialog:
|
||||
// Die Auswahlliste hätte für sie nichts anzubieten außer dem Weg dorthin.
|
||||
if (feld.dataset.wert === 'split') {
|
||||
splitOeffnen(feld);
|
||||
return;
|
||||
}
|
||||
|
||||
oeffnen(feld);
|
||||
});
|
||||
|
||||
liste.addEventListener('change', function () {
|
||||
if (!offenesFeld) {
|
||||
return;
|
||||
}
|
||||
|
||||
const feld = offenesFeld;
|
||||
|
||||
if (liste.value === 'split') {
|
||||
schliessen();
|
||||
splitOeffnen(feld);
|
||||
return;
|
||||
}
|
||||
|
||||
// Eine Rückfrage wegen verlorener Splitzeilen braucht es hier nicht mehr:
|
||||
// Für eine aufgeteilte Buchung öffnet sich diese Liste gar nicht erst.
|
||||
auswahl.requestSubmit();
|
||||
});
|
||||
|
||||
// Escape und der Klick daneben verwerfen die Auswahl.
|
||||
document.addEventListener('keydown', function (ereignis) {
|
||||
if (ereignis.key === 'Escape' && offenesFeld) {
|
||||
schliessen();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (ereignis) {
|
||||
if (!(ereignis.target instanceof Element) || !offenesFeld) {
|
||||
return;
|
||||
}
|
||||
if (!ereignis.target.closest('.zuordnungszelle')) {
|
||||
schliessen();
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Splitdialog ─────────────────────────────────────────────────────── */
|
||||
|
||||
if (!dialog || !splitformular || !window.FmBetrag) {
|
||||
return;
|
||||
}
|
||||
|
||||
const zeilenkasten = document.getElementById('splitdialogzeilen');
|
||||
const summenfeld = document.getElementById('splitdialogsumme');
|
||||
const loeschknopf = document.getElementById('splitloeschen');
|
||||
const vorlage = zeilenkasten.innerHTML;
|
||||
let buchungsbetrag = 0;
|
||||
|
||||
function felder() {
|
||||
return Array.from(zeilenkasten.querySelectorAll('.splitbetrag'));
|
||||
}
|
||||
|
||||
function rechneSumme() {
|
||||
const stand = window.FmBetrag.splitsumme(felder(), buchungsbetrag);
|
||||
summenfeld.textContent = stand.text;
|
||||
summenfeld.classList.toggle('negativ', stand.belegt && stand.rest !== 0);
|
||||
}
|
||||
|
||||
/* Eine weitere Zeile: die letzte kopieren, leeren und neu durchnummerieren.
|
||||
* Die Namen tragen den Index (`split[3][betrag]`) – ohne Umnummerierung
|
||||
* überschriebe die Kopie ihre Vorlage. */
|
||||
function zeileAnhaengen() {
|
||||
const letzte = zeilenkasten.lastElementChild;
|
||||
const kopie = letzte.cloneNode(true);
|
||||
const nr = zeilenkasten.children.length;
|
||||
|
||||
kopie.querySelectorAll('[name]').forEach(function (feld) {
|
||||
feld.name = feld.name.replace(/^split\[\d+\]/, 'split[' + nr + ']');
|
||||
if (feld.tagName === 'SELECT') {
|
||||
feld.selectedIndex = 0;
|
||||
} else {
|
||||
feld.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
zeilenkasten.appendChild(kopie);
|
||||
return kopie;
|
||||
}
|
||||
|
||||
function splitdialogOeffnen(feld) {
|
||||
splitformular.action = ziel(feld, 'splitbuchung');
|
||||
buchungsbetrag = Number(feld.dataset.betrag || '0');
|
||||
|
||||
document.getElementById('splitbezeichnung').textContent = feld.dataset.bezeichnung || '';
|
||||
document.getElementById('splitbuchungsbetrag').textContent =
|
||||
window.FmBetrag.formatiere(buchungsbetrag);
|
||||
|
||||
// Immer mit leeren Zeilen beginnen: Der Dialog gilt für alle Buchungen
|
||||
// der Seite, die Zeilen der vorigen dürfen nicht stehen bleiben.
|
||||
zeilenkasten.innerHTML = vorlage;
|
||||
|
||||
const vorhandene = JSON.parse(feld.dataset.splits || '[]');
|
||||
|
||||
// Zu löschen gibt es nur, was schon aufgeteilt ist. Beim erstmaligen
|
||||
// Aufteilen wäre der Knopf ohne Wirkung – und ohne Sinn.
|
||||
loeschknopf.hidden = vorhandene.length === 0;
|
||||
|
||||
vorhandene.forEach(function (zeile, nr) {
|
||||
const tr = zeilenkasten.children[nr] || zeileAnhaengen();
|
||||
|
||||
tr.querySelector('select[name$="[zuordnung]"]').value = zeile.zuordnung || '';
|
||||
tr.querySelector('select[name$="[klasse_id]"]').value = zeile.klasse_id
|
||||
? String(zeile.klasse_id)
|
||||
: '';
|
||||
tr.querySelector('input[name$="[notiz]"]').value = zeile.notiz || '';
|
||||
tr.querySelector('input[name$="[betrag]"]').value = zeile.betrag || '';
|
||||
});
|
||||
|
||||
rechneSumme();
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
zeilenkasten.addEventListener('input', function (ereignis) {
|
||||
if (ereignis.target.classList.contains('splitbetrag')) {
|
||||
rechneSumme();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('splitzeileMehr').addEventListener('click', function () {
|
||||
zeileAnhaengen().querySelector('select').focus();
|
||||
});
|
||||
|
||||
document.getElementById('splitabbrechen').addEventListener('click', function () {
|
||||
dialog.close();
|
||||
});
|
||||
})();
|
||||
|
|
@ -50,6 +50,16 @@ class BuchungenController extends BaseController
|
|||
$seite = max(1, (int)$this->query('seite', '1'));
|
||||
$zeilen = array_slice($zeilen, ($seite - 1) * $grenze, $grenze);
|
||||
|
||||
// Die Splitzeilen der angezeigten Buchungen in einer Abfrage – der
|
||||
// Splitdialog zeigt damit eine bestehende Aufteilung, ohne sie
|
||||
// nachzuladen.
|
||||
$gesplittete = [];
|
||||
foreach ($zeilen as $zeile) {
|
||||
if ((int)$zeile['gesplittet'] === 1 && !$zeile['gegensicht']) {
|
||||
$gesplittete[] = (int)$zeile['id'];
|
||||
}
|
||||
}
|
||||
|
||||
$this->render('buchungen/kontoblatt.php', [
|
||||
'titel' => 'Kontoblatt: ' . $konto['name'],
|
||||
'konto' => Konto::mitSalden($konto),
|
||||
|
|
@ -60,8 +70,8 @@ class BuchungenController extends BaseController
|
|||
'filter' => $filter,
|
||||
'kategorien' => Kategorie::baumFlach(),
|
||||
'klassen' => Klasse::alle(),
|
||||
// Namen vorab, damit die Tabelle nicht je Zeile nachfragt.
|
||||
'kontenNamen' => array_column(Konto::alle(), 'name', 'id'),
|
||||
'konten' => Konto::alle(),
|
||||
'splits' => Buchung::splitsJeBuchung($gesplittete),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -153,6 +163,72 @@ class BuchungenController extends BaseController
|
|||
View::redirect('/konten/' . $buchung['konto_id'], 'Buchung gelöscht.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Schnellzuordnung aus dem Kontoblatt: Kategorie oder Gegenkonto einer
|
||||
* Buchung setzen, ohne die Maske zu öffnen.
|
||||
*
|
||||
* @param array<string,string> $params
|
||||
*/
|
||||
public function zuordnung(array $params): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$buchung = Buchung::find((int)$params['id']);
|
||||
if ($buchung === null) {
|
||||
$this->nichtGefunden('/buchungen/' . $params['id']);
|
||||
return;
|
||||
}
|
||||
|
||||
$ziel = $this->kontoblattPfad((int)$buchung['konto_id']);
|
||||
|
||||
try {
|
||||
$zuordnung = self::zuordnungAus($this->post('zuordnung'));
|
||||
Buchung::zuordnen(
|
||||
(int)$buchung['id'],
|
||||
$zuordnung['kategorie_id'],
|
||||
$zuordnung['gegenkonto_id']
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
View::redirect($ziel, $e->getMessage(), 'fehler');
|
||||
return;
|
||||
}
|
||||
|
||||
View::redirect($ziel, 'Zuordnung gespeichert.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Splitdialog des Kontoblatts: die Zeilen einer Buchung ersetzen.
|
||||
* Ohne Zeilen wird die Splitbuchung aufgehoben.
|
||||
*
|
||||
* Der Knopf „Splitbuchung löschen“ schickt dasselbe Formular mit
|
||||
* `aktion=loeschen`. Seine Zeilen zählen dann nicht: Gelöscht wird die
|
||||
* Aufteilung, und die Buchung steht danach ohne Zuordnung da.
|
||||
*
|
||||
* @param array<string,string> $params
|
||||
*/
|
||||
public function splitbuchung(array $params): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$buchung = Buchung::find((int)$params['id']);
|
||||
if ($buchung === null) {
|
||||
$this->nichtGefunden('/buchungen/' . $params['id']);
|
||||
return;
|
||||
}
|
||||
|
||||
$ziel = $this->kontoblattPfad((int)$buchung['konto_id']);
|
||||
$loeschen = $this->post('aktion') === 'loeschen';
|
||||
|
||||
try {
|
||||
Buchung::splitsErsetzen((int)$buchung['id'], $loeschen ? [] : $this->splitzeilen());
|
||||
} catch (\Throwable $e) {
|
||||
View::redirect($ziel, $e->getMessage(), 'fehler');
|
||||
return;
|
||||
}
|
||||
|
||||
View::redirect($ziel, $loeschen ? 'Splitbuchung gelöscht.' : 'Splitbuchung gespeichert.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mehrere im Kontoblatt angekreuzte Buchungen auf einmal löschen.
|
||||
*
|
||||
|
|
@ -245,19 +321,17 @@ class BuchungenController extends BaseController
|
|||
*/
|
||||
private function formulardaten(int $kontoId): array
|
||||
{
|
||||
$art = $this->post('art', 'ausgabe');
|
||||
$betrag = KontenController::zahl($this->post('betrag', '0'));
|
||||
|
||||
// In der Maske wird ein positiver Betrag eingegeben; die Richtung
|
||||
// bestimmt die Auswahl Einnahme / Ausgabe / Umbuchung.
|
||||
$betrag = abs($betrag);
|
||||
if ($art === 'ausgabe' || $art === 'umbuchung_ab') {
|
||||
// bestimmt die Auswahl Einnahme / Ausgabe.
|
||||
$betrag = abs(KontenController::zahl($this->post('betrag', '0')));
|
||||
if ($this->post('art', 'ausgabe') !== 'einnahme') {
|
||||
$betrag = -$betrag;
|
||||
}
|
||||
|
||||
$gegenkonto = in_array($art, ['umbuchung_ab', 'umbuchung_zu'], true)
|
||||
? $this->post('gegenkonto_id')
|
||||
: null;
|
||||
// Kategorie und Gegenkonto stehen in einem Feld: Ein Konto in der Liste
|
||||
// macht die Buchung zur Umbuchung – wie das Kategoriefeld im
|
||||
// FinanzManager, das `[Konto]` annimmt.
|
||||
$zuordnung = self::zuordnungAus($this->post('zuordnung'));
|
||||
|
||||
$daten = [
|
||||
'konto_id' => $kontoId,
|
||||
|
|
@ -268,15 +342,29 @@ class BuchungenController extends BaseController
|
|||
'buchungstext' => $this->post('buchungstext'),
|
||||
'nummer' => $this->post('nummer'),
|
||||
'abgeglichen' => isset($_POST['abgeglichen']),
|
||||
'kategorie_id' => $gegenkonto === null ? $this->post('kategorie_id') : null,
|
||||
'kategorie_id' => $zuordnung['kategorie_id'],
|
||||
'klasse_id' => $this->post('klasse_id'),
|
||||
'gegenkonto_id' => $gegenkonto,
|
||||
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
|
||||
'quelle' => 'manuell',
|
||||
];
|
||||
|
||||
$splits = [];
|
||||
return [$daten, $this->splitzeilen()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Die ausgefüllten Splitzeilen eines Formulars. Zeilen ohne Betrag zählen
|
||||
* nicht – so bleiben die leeren Zeilen der Maske folgenlos.
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private function splitzeilen(): array
|
||||
{
|
||||
$zeilen = $_POST['split'] ?? [];
|
||||
if (is_array($zeilen)) {
|
||||
if (!is_array($zeilen)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$splits = [];
|
||||
foreach ($zeilen as $zeile) {
|
||||
if (!is_array($zeile)) {
|
||||
continue;
|
||||
|
|
@ -285,16 +373,40 @@ class BuchungenController extends BaseController
|
|||
if ($wert === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$zuordnung = self::zuordnungAus((string)($zeile['zuordnung'] ?? ''));
|
||||
$splits[] = [
|
||||
'kategorie_id' => $zeile['kategorie_id'] ?? null,
|
||||
'kategorie_id' => $zuordnung['kategorie_id'],
|
||||
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
|
||||
'klasse_id' => $zeile['klasse_id'] ?? null,
|
||||
'betrag' => KontenController::zahl($wert),
|
||||
'notiz' => (string)($zeile['notiz'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $splits;
|
||||
}
|
||||
|
||||
return [$daten, $splits];
|
||||
/**
|
||||
* Ein Auswahlwert aus der gemeinsamen Liste „Kategorie oder Gegenkonto“:
|
||||
* `kategorie:12`, `konto:7` oder leer.
|
||||
*
|
||||
* @return array{kategorie_id: ?int, gegenkonto_id: ?int}
|
||||
*/
|
||||
public static function zuordnungAus(string $wert): array
|
||||
{
|
||||
$leer = ['kategorie_id' => null, 'gegenkonto_id' => null];
|
||||
|
||||
[$art, $id] = array_pad(explode(':', $wert, 2), 2, '');
|
||||
if ((int)$id <= 0) {
|
||||
return $leer;
|
||||
}
|
||||
|
||||
return match ($art) {
|
||||
'kategorie' => ['kategorie_id' => (int)$id, 'gegenkonto_id' => null],
|
||||
'konto' => ['kategorie_id' => null, 'gegenkonto_id' => (int)$id],
|
||||
default => $leer,
|
||||
};
|
||||
}
|
||||
|
||||
private function nichtGefunden(string $pfad): void
|
||||
|
|
|
|||
|
|
@ -46,6 +46,36 @@ class Buchung
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Splitzeilen mehrerer Buchungen in einer Abfrage – das Kontoblatt gibt
|
||||
* sie dem Splitdialog mit, damit er eine bestehende Aufteilung zeigt, ohne
|
||||
* je Zeile nachzufragen.
|
||||
*
|
||||
* @param list<int> $buchungIds
|
||||
* @return array<int, list<array<string,mixed>>> buchung_id => Zeilen
|
||||
*/
|
||||
public static function splitsJeBuchung(array $buchungIds): array
|
||||
{
|
||||
$ids = array_values(array_unique(array_map('intval', $buchungIds)));
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$zeilen = Database::fetchAll(
|
||||
'SELECT * FROM buchung_splits
|
||||
WHERE buchung_id IN (' . implode(',', array_fill(0, count($ids), '?')) . ')
|
||||
ORDER BY buchung_id, sortierung, id',
|
||||
$ids
|
||||
);
|
||||
|
||||
$jeBuchung = [];
|
||||
foreach ($zeilen as $zeile) {
|
||||
$jeBuchung[(int)$zeile['buchung_id']][] = $zeile;
|
||||
}
|
||||
|
||||
return $jeBuchung;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function adressen(int $buchungId): array
|
||||
{
|
||||
|
|
@ -130,6 +160,68 @@ class Buchung
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Kategorie oder Gegenkonto einer Buchung setzen – die Schnellzuordnung aus
|
||||
* dem Kontoblatt. Alles andere an der Buchung bleibt unberührt.
|
||||
*
|
||||
* Beides zugleich gibt es nicht: Eine Buchung ist entweder kategorisiert
|
||||
* oder eine Umbuchung. War sie gesplittet, fallen die Zeilen weg – die
|
||||
* Zuordnung steckt danach wieder oben.
|
||||
*/
|
||||
public static function zuordnen(int $id, ?int $kategorieId, ?int $gegenkontoId): void
|
||||
{
|
||||
$buchung = self::find($id);
|
||||
if ($buchung === null) {
|
||||
throw new \RuntimeException('Die Buchung gibt es nicht.');
|
||||
}
|
||||
Monatsabschluss::verlangeOffen((string)$buchung['datum']);
|
||||
|
||||
if ($gegenkontoId !== null) {
|
||||
$kategorieId = null;
|
||||
self::pruefeGegenkonto($gegenkontoId, (int)$buchung['konto_id']);
|
||||
}
|
||||
if ($kategorieId !== null && Kategorie::find($kategorieId) === null) {
|
||||
throw new \InvalidArgumentException('Die Kategorie gibt es nicht.');
|
||||
}
|
||||
|
||||
Database::transaktion(static function () use ($id, $kategorieId, $gegenkontoId): void {
|
||||
Database::execute('DELETE FROM buchung_splits WHERE buchung_id = ?', [$id]);
|
||||
Database::execute(
|
||||
'UPDATE buchungen SET kategorie_id = ?, gegenkonto_id = ?, gesplittet = 0
|
||||
WHERE id = ? AND benutzer_id = ?',
|
||||
[$kategorieId, $gegenkontoId, $id, Mandant::id()]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Splitzeilen einer Buchung ersetzen – der Splitdialog des Kontoblatts.
|
||||
* Eine leere Liste hebt die Splitbuchung auf; die Buchung steht danach ohne
|
||||
* Zuordnung da.
|
||||
*
|
||||
* @param list<array<string,mixed>> $splits
|
||||
*/
|
||||
public static function splitsErsetzen(int $id, array $splits): void
|
||||
{
|
||||
$buchung = self::find($id);
|
||||
if ($buchung === null) {
|
||||
throw new \RuntimeException('Die Buchung gibt es nicht.');
|
||||
}
|
||||
Monatsabschluss::verlangeOffen((string)$buchung['datum']);
|
||||
|
||||
self::pruefeSplits($splits, (float)$buchung['betrag'], (int)$buchung['konto_id']);
|
||||
|
||||
Database::transaktion(static function () use ($id, $splits): void {
|
||||
Database::execute('DELETE FROM buchung_splits WHERE buchung_id = ?', [$id]);
|
||||
Database::execute(
|
||||
'UPDATE buchungen SET kategorie_id = NULL, gegenkonto_id = NULL, gesplittet = ?
|
||||
WHERE id = ? AND benutzer_id = ?',
|
||||
[$splits === [] ? 0 : 1, $id, Mandant::id()]
|
||||
);
|
||||
self::speichereSplits($id, $splits);
|
||||
});
|
||||
}
|
||||
|
||||
public static function loeschen(int $id): void
|
||||
{
|
||||
$buchung = self::find($id);
|
||||
|
|
@ -430,27 +522,14 @@ class Buchung
|
|||
$kategorie = self::id($daten['kategorie_id'] ?? null);
|
||||
$gesplittet = $splits !== [];
|
||||
|
||||
if ($gegenkonto !== null && $gegenkonto === $kontoId) {
|
||||
throw new \InvalidArgumentException('Eine Umbuchung braucht zwei verschiedene Konten.');
|
||||
if ($gegenkonto !== null) {
|
||||
self::pruefeGegenkonto($gegenkonto, $kontoId);
|
||||
}
|
||||
if ($gesplittet && ($kategorie !== null || $gegenkonto !== null)) {
|
||||
if ($gesplittet) {
|
||||
// Bei einer Splitbuchung steckt die Zuordnung in den Zeilen.
|
||||
$kategorie = null;
|
||||
$gegenkonto = null;
|
||||
}
|
||||
|
||||
if ($gesplittet) {
|
||||
$summe = 0.0;
|
||||
foreach ($splits as $split) {
|
||||
$summe += round((float)($split['betrag'] ?? 0), 2);
|
||||
}
|
||||
if (abs($summe - $betrag) > self::CENT) {
|
||||
throw new \InvalidArgumentException(sprintf(
|
||||
'Die Splitzeilen ergeben %s, die Buchung lautet über %s.',
|
||||
number_format($summe, 2, ',', '.'),
|
||||
number_format($betrag, 2, ',', '.')
|
||||
));
|
||||
}
|
||||
self::pruefeSplits($splits, $betrag, $kontoId);
|
||||
}
|
||||
|
||||
$valuta = trim((string)($daten['valuta'] ?? ''));
|
||||
|
|
@ -474,6 +553,48 @@ class Buchung
|
|||
];
|
||||
}
|
||||
|
||||
/** Ein Konto kann nicht sein eigenes Gegenkonto sein. */
|
||||
private static function pruefeGegenkonto(int $gegenkontoId, int $kontoId): void
|
||||
{
|
||||
if ($gegenkontoId === $kontoId) {
|
||||
throw new \InvalidArgumentException('Eine Umbuchung braucht zwei verschiedene Konten.');
|
||||
}
|
||||
if (Konto::find($gegenkontoId) === null) {
|
||||
throw new \InvalidArgumentException('Das Gegenkonto gibt es nicht.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Summe der Zeilen muss den Buchungsbetrag ergeben, und keine Zeile darf
|
||||
* auf das eigene Konto zeigen – sonst zählte die Buchung dort doppelt.
|
||||
*
|
||||
* @param list<array<string,mixed>> $splits
|
||||
*/
|
||||
private static function pruefeSplits(array $splits, float $betrag, int $kontoId): void
|
||||
{
|
||||
if ($splits === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$summe = 0.0;
|
||||
foreach ($splits as $split) {
|
||||
$summe += round((float)($split['betrag'] ?? 0), 2);
|
||||
|
||||
$gegenkonto = self::id($split['gegenkonto_id'] ?? null);
|
||||
if ($gegenkonto !== null) {
|
||||
self::pruefeGegenkonto($gegenkonto, $kontoId);
|
||||
}
|
||||
}
|
||||
|
||||
if (abs($summe - $betrag) > self::CENT) {
|
||||
throw new \InvalidArgumentException(sprintf(
|
||||
'Die Splitzeilen ergeben %s, die Buchung lautet über %s.',
|
||||
number_format($summe, 2, ',', '.'),
|
||||
number_format($betrag, 2, ',', '.')
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $splits */
|
||||
private static function speichereSplits(int $buchungId, array $splits): void
|
||||
{
|
||||
|
|
|
|||
|
|
@ -14,14 +14,23 @@ $neu = $buchung === null;
|
|||
$ziel = $neu ? '/konten/' . (int)$konto['id'] . '/buchung' : '/buchungen/' . (int)$buchung['id'];
|
||||
$betrag = $neu ? 0.0 : (float)$buchung['betrag'];
|
||||
|
||||
$art = 'ausgabe';
|
||||
if (!$neu) {
|
||||
if (!empty($buchung['gegenkonto_id'])) {
|
||||
$art = $betrag < 0 ? 'umbuchung_ab' : 'umbuchung_zu';
|
||||
} else {
|
||||
$art = $betrag >= 0 ? 'einnahme' : 'ausgabe';
|
||||
// Die Art bestimmt nur noch die Richtung. Ob die Buchung eine Umbuchung ist,
|
||||
// steht im Feld Kategorie / Gegenkonto.
|
||||
$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 '';
|
||||
};
|
||||
|
||||
$eigenesKonto = (int)$konto['id'];
|
||||
$mitSplit = false;
|
||||
|
||||
// Immer ein paar leere Splitzeilen anbieten; weitere fügt der Knopf hinzu.
|
||||
$splitZeilen = $splits;
|
||||
|
|
@ -46,8 +55,6 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<?php foreach ([
|
||||
'ausgabe' => 'Ausgabe',
|
||||
'einnahme' => 'Einnahme',
|
||||
'umbuchung_ab' => 'Umbuchung auf ein anderes Konto',
|
||||
'umbuchung_zu' => 'Umbuchung von einem anderen Konto',
|
||||
] as $schluessel => $beschriftung): ?>
|
||||
<option value="<?= View::h($schluessel) ?>" <?= $art === $schluessel ? 'selected' : '' ?>>
|
||||
<?= View::h($beschriftung) ?>
|
||||
|
|
@ -68,36 +75,16 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<input type="text" id="empfaenger" name="empfaenger"
|
||||
value="<?= View::h((string)($buchung['empfaenger'] ?? '')) ?>">
|
||||
|
||||
<div class="nur-kategorie">
|
||||
<label for="kategorie_id">Kategorie</label>
|
||||
<select id="kategorie_id" name="kategorie_id">
|
||||
<option value="">— keine —</option>
|
||||
<?php foreach ($kategorien as $k): ?>
|
||||
<option value="<?= (int)$k['id'] ?>"
|
||||
<?= (int)($buchung['kategorie_id'] ?? 0) === (int)$k['id'] ? 'selected' : '' ?>>
|
||||
<?= View::h($k['pfad']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="nur-umbuchung">
|
||||
<label for="gegenkonto_id">Gegenkonto</label>
|
||||
<select id="gegenkonto_id" name="gegenkonto_id">
|
||||
<option value="">— bitte wählen —</option>
|
||||
<?php foreach ($konten as $k): ?>
|
||||
<?php if ((int)$k['id'] === (int)$konto['id']) { continue; } ?>
|
||||
<option value="<?= (int)$k['id'] ?>"
|
||||
<?= (int)($buchung['gegenkonto_id'] ?? 0) === (int)$k['id'] ? 'selected' : '' ?>>
|
||||
<?= View::h($k['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
<label for="zuordnung">Kategorie / Gegenkonto</label>
|
||||
<select id="zuordnung" name="zuordnung">
|
||||
<?php $gewaehlt = $zuordnungswert($buchung); ?>
|
||||
<?php include __DIR__ . '/zuordnungsliste.php'; ?>
|
||||
</select>
|
||||
<p class="feldhinweis">
|
||||
Die Umbuchung wird einmal gespeichert und erscheint auf beiden Kontoblättern –
|
||||
auf dem Gegenkonto mit umgekehrtem Vorzeichen.
|
||||
Unter den Kategorien stehen die eigenen Konten in eckigen Klammern. Eines davon
|
||||
macht die Buchung zur Umbuchung: Sie wird einmal gespeichert und erscheint auf
|
||||
beiden Kontoblättern – auf dem Gegenkonto mit umgekehrtem Vorzeichen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label for="klasse_id">Klasse</label>
|
||||
<select id="klasse_id" name="klasse_id">
|
||||
|
|
@ -132,20 +119,17 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
|
||||
<table class="liste splittabelle">
|
||||
<thead>
|
||||
<tr><th>Kategorie</th><th>Klasse</th><th>Notiz</th><th class="rechts">Betrag</th></tr>
|
||||
<tr>
|
||||
<th>Kategorie / Gegenkonto</th><th>Klasse</th><th>Notiz</th><th class="rechts">Betrag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="splitzeilen">
|
||||
<?php foreach ($splitZeilen as $nr => $zeile): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<select name="split[<?= $nr ?>][kategorie_id]">
|
||||
<option value="">— keine —</option>
|
||||
<?php foreach ($kategorien as $k): ?>
|
||||
<option value="<?= (int)$k['id'] ?>"
|
||||
<?= (int)($zeile['kategorie_id'] ?? 0) === (int)$k['id'] ? 'selected' : '' ?>>
|
||||
<?= View::h($k['pfad']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
<select name="split[<?= $nr ?>][zuordnung]">
|
||||
<?php $gewaehlt = $zuordnungswert($zeile); ?>
|
||||
<?php include __DIR__ . '/zuordnungsliste.php'; ?>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
|
|
@ -193,6 +177,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<script src="/static/betrag.js" defer></script>
|
||||
<script src="/static/buchungsmaske.js" defer></script>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ use Finanzmanager\View;
|
|||
/** @var array<string,mixed> $filter */
|
||||
/** @var list<array<string,mixed>> $kategorien */
|
||||
/** @var list<array<string,mixed>> $klassen */
|
||||
/** @var array<int,string> $kontenNamen */
|
||||
/** @var list<array<string,mixed>> $konten */
|
||||
/** @var array<int, list<array<string,mixed>>> $splits */
|
||||
|
||||
$kategorieNamen = [];
|
||||
foreach ($kategorien as $k) {
|
||||
|
|
@ -20,6 +21,9 @@ $klassenNamen = [];
|
|||
foreach ($klassen as $k) {
|
||||
$klassenNamen[(int)$k['id']] = $k['name'];
|
||||
}
|
||||
$kontenNamen = array_column($konten, 'name', 'id');
|
||||
|
||||
$eigenesKonto = (int)$konto['id'];
|
||||
|
||||
$gefiltert = ($filter['suche'] ?? '') !== '' || !empty($filter['von']) || !empty($filter['bis'])
|
||||
|| !empty($filter['kategorie_id']) || !empty($filter['klasse_id']) || !empty($filter['nur_offene']);
|
||||
|
|
@ -39,8 +43,44 @@ $seitenPfad = static function (int $nr) use ($filter): string {
|
|||
};
|
||||
|
||||
// Nach dem Löschen soll dieselbe Ansicht wieder erscheinen – Filter und Seite
|
||||
// hängen deshalb am Ziel des Löschformulars.
|
||||
$loeschZiel = '/konten/' . (int)$konto['id'] . '/buchungen/loeschen' . $seitenPfad($seite);
|
||||
// hängen deshalb am Ziel des Löschformulars. Dasselbe gilt für die
|
||||
// Schnellzuordnung: Ihre Ziele setzt kontoblatt.js aus dieser Abfrage zusammen.
|
||||
$abfrage = $seitenPfad($seite);
|
||||
$loeschZiel = '/konten/' . (int)$konto['id'] . '/buchungen/loeschen' . $abfrage;
|
||||
|
||||
/**
|
||||
* Die Zuordnung einer Zeile als Wert der gemeinsamen Auswahlliste – dieselben
|
||||
* Präfixe wie in der Buchungsmaske.
|
||||
*/
|
||||
$zuordnungswert = static function (array $zeile): string {
|
||||
if ((int)($zeile['gesplittet'] ?? 0) === 1) {
|
||||
return 'split';
|
||||
}
|
||||
if (!empty($zeile['gegenkonto_id'])) {
|
||||
return 'konto:' . (int)$zeile['gegenkonto_id'];
|
||||
}
|
||||
if (!empty($zeile['kategorie_id'])) {
|
||||
return 'kategorie:' . (int)$zeile['kategorie_id'];
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Die Splitzeilen einer Buchung, wie der Dialog sie braucht. Sie hängen als
|
||||
* JSON an der Zelle statt nachgeladen zu werden: Es sind die Zeilen der wenigen
|
||||
* aufgeteilten Buchungen dieser Seite, und ein zweiter Weg zu denselben Daten
|
||||
* wäre auch ein zweiter Weg, sie falsch zu lesen.
|
||||
*
|
||||
* @param list<array<string,mixed>> $splitzeilen
|
||||
*/
|
||||
$splitdaten = static function (array $splitzeilen) use ($zuordnungswert): string {
|
||||
return (string)json_encode(array_map(static fn(array $zeile): array => [
|
||||
'zuordnung' => $zuordnungswert($zeile),
|
||||
'klasse_id' => (int)($zeile['klasse_id'] ?? 0),
|
||||
'notiz' => (string)$zeile['notiz'],
|
||||
'betrag' => View::betrag($zeile['betrag'], false),
|
||||
], $splitzeilen), JSON_UNESCAPED_UNICODE);
|
||||
};
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
|
@ -124,7 +164,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<?= $gefiltert ? 'Keine Buchung passt zum Filter.' : 'Für dieses Konto ist noch nichts gebucht.' ?>
|
||||
</p>
|
||||
<?php else: ?>
|
||||
<table class="liste kontoblatt">
|
||||
<table class="liste kontoblatt" data-abfrage="<?= View::h($abfrage) ?>">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="wahlspalte">
|
||||
|
|
@ -165,13 +205,46 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="nebentext gekuerzt"><?= View::h(mb_substr((string)$zeile['buchungstext'], 0, 90)) ?></td>
|
||||
<td>
|
||||
<?php if ((int)$zeile['gesplittet'] === 1): ?>
|
||||
<span class="marke">Splitbuchung</span>
|
||||
<?php elseif (!empty($zeile['gegenkonto_id']) && !$zeile['gegensicht']): ?>
|
||||
[<?= View::h($kontenNamen[(int)$zeile['gegenkonto_id']] ?? '?') ?>]
|
||||
<?php
|
||||
// Die Gegenseite einer fremden Umbuchung nennt das Konto, von dem
|
||||
// sie kommt – und zwar dort, wo sonst die Kategorie steht. Ohne
|
||||
// diese Angabe stünde auf dem Gegenkonto eine Umbuchung ohne
|
||||
// erkennbares Gegenüber.
|
||||
$gegenkonto = empty($zeile['gegenkonto_id'])
|
||||
? ''
|
||||
: '[' . ($kontenNamen[(int)$zeile['gegenkonto_id']] ?? '?') . ']';
|
||||
$beschriftung = match (true) {
|
||||
(int)$zeile['gesplittet'] === 1 => 'Splitbuchung',
|
||||
$gegenkonto !== '' => $gegenkonto,
|
||||
default => (string)($kategorieNamen[(int)$zeile['kategorie_id']] ?? ''),
|
||||
};
|
||||
?>
|
||||
<td class="zuordnungszelle">
|
||||
<?php if ($zeile['gegensicht']): ?>
|
||||
<?php /* Auch die Gegenseite führt zu ihrer Buchung – sie liegt
|
||||
auf dem anderen Konto und wird nur dort geändert. */ ?>
|
||||
<a href="/buchungen/<?= (int)$zeile['id'] ?>" class="zuordnungsfeld gegenseite"
|
||||
title="Umbuchung von <?= View::h($kontenNamen[(int)$zeile['gegenkonto_id']] ?? '?') ?> – dort zu ändern">
|
||||
<?= View::h($beschriftung) ?>
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<?= View::h($kategorieNamen[(int)$zeile['kategorie_id']] ?? '') ?>
|
||||
<?php /* Ohne JavaScript führt der Klick in die Buchungsmaske;
|
||||
kontoblatt.js macht daraus die Auswahl an Ort und Stelle. */ ?>
|
||||
<a href="/buchungen/<?= (int)$zeile['id'] ?>" class="zuordnungsfeld"
|
||||
data-buchung="<?= (int)$zeile['id'] ?>"
|
||||
data-wert="<?= View::h($zuordnungswert($zeile)) ?>"
|
||||
data-betrag="<?= View::h(number_format((float)$zeile['betrag'], 2, '.', '')) ?>"
|
||||
data-bezeichnung="<?= View::h(View::datum($zeile['datum']) . ' · ' . ($zeile['empfaenger'] ?: '(ohne Empfänger)')) ?>"
|
||||
<?php if (isset($splits[(int)$zeile['id']])): ?>
|
||||
data-splits="<?= View::h($splitdaten($splits[(int)$zeile['id']])) ?>"
|
||||
<?php endif; ?>
|
||||
title="<?= View::h($beschriftung !== '' ? $beschriftung : 'Kategorie oder Gegenkonto zuordnen') ?>">
|
||||
<?php if ($beschriftung === 'Splitbuchung'): ?>
|
||||
<span class="marke">Splitbuchung</span>
|
||||
<?php else: ?>
|
||||
<?= View::h($beschriftung) ?>
|
||||
<?php endif; ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="nebentext"><?= View::h($klassenNamen[(int)$zeile['klasse_id']] ?? '') ?></td>
|
||||
|
|
@ -197,6 +270,90 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php /* Die Auswahlliste gibt es einmal; kontoblatt.js hängt sie in die
|
||||
angeklickte Zelle. Eine Liste je Zeile wären bei hundert Zeilen
|
||||
ebenso viele vollständige Kategoriebäume. */ ?>
|
||||
<form method="post" action="" id="zuordnungsauswahl" class="zuordnungsauswahl" hidden>
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<select name="zuordnung" aria-label="Kategorie oder Gegenkonto">
|
||||
<?php $gewaehlt = ''; $mitSplit = true; ?>
|
||||
<?php include __DIR__ . '/zuordnungsliste.php'; ?>
|
||||
</select>
|
||||
<noscript><button type="submit" class="knopf klein">Übernehmen</button></noscript>
|
||||
</form>
|
||||
|
||||
<dialog id="splitdialog" class="splitdialog">
|
||||
<form method="post" action="" id="splitformular">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<h2>Splitbuchung</h2>
|
||||
<p class="feldhinweis">
|
||||
<span id="splitbezeichnung"></span> über
|
||||
<output id="splitbuchungsbetrag" class="betrag">0,00</output>
|
||||
</p>
|
||||
|
||||
<table class="liste splittabelle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kategorie / Gegenkonto</th><th>Klasse</th><th>Notiz</th>
|
||||
<th class="rechts">Betrag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="splitdialogzeilen">
|
||||
<?php for ($nr = 0; $nr < 4; $nr++): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<select name="split[<?= $nr ?>][zuordnung]">
|
||||
<?php $gewaehlt = ''; $mitSplit = false; ?>
|
||||
<?php include __DIR__ . '/zuordnungsliste.php'; ?>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select name="split[<?= $nr ?>][klasse_id]">
|
||||
<option value="">— keine —</option>
|
||||
<?php foreach ($klassen as $k): ?>
|
||||
<option value="<?= (int)$k['id'] ?>"><?= View::h($k['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="text" name="split[<?= $nr ?>][notiz]" value=""></td>
|
||||
<td>
|
||||
<input type="text" class="splitbetrag rechts" inputmode="decimal"
|
||||
name="split[<?= $nr ?>][betrag]" value="">
|
||||
</td>
|
||||
</tr>
|
||||
<?php endfor; ?>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3" class="rechts">Summe der Zeilen / Restbetrag</td>
|
||||
<td class="rechts"><output id="splitdialogsumme" class="betrag">0,00</output></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<p class="feldhinweis">
|
||||
Die Summe der Zeilen muss dem Buchungsbetrag entsprechen. Bleiben alle
|
||||
Zeilen leer, wird die Splitbuchung aufgehoben.
|
||||
</p>
|
||||
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">Splitbuchung speichern</button>
|
||||
<button type="button" class="knopf" id="splitzeileMehr">Zeile hinzufügen</button>
|
||||
<button type="button" class="knopf" id="splitabbrechen">Abbrechen</button>
|
||||
<?php /* Rechts außen, abseits der übrigen Knöpfe: hebt die Aufteilung
|
||||
samt aller Zuordnungen auf. Nur sichtbar, wenn es etwas
|
||||
aufzuheben gibt – kontoblatt.js blendet ihn ein. */ ?>
|
||||
<button type="submit" class="knopf gefahr" id="splitloeschen"
|
||||
name="aktion" value="loeschen" formnovalidate hidden>
|
||||
Splitbuchung löschen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="/static/betrag.js" defer></script>
|
||||
<script src="/static/kontoblatt.js" defer></script>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
|
|
|
|||
42
src/Views/buchungen/zuordnungsliste.php
Normal file
42
src/Views/buchungen/zuordnungsliste.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\View;
|
||||
|
||||
/**
|
||||
* Die Einträge der Liste „Kategorie oder Gegenkonto“ – ohne das umgebende
|
||||
* <select>, damit dieselbe Liste in der Buchungsmaske, in den Splitzeilen und
|
||||
* in der Schnellzuordnung des Kontoblatts steht.
|
||||
*
|
||||
* Kategorie und Gegenkonto teilen sich ein Feld, weil sie sich ausschließen:
|
||||
* Eine Buchung ist entweder kategorisiert oder eine Umbuchung. Die Werte tragen
|
||||
* deshalb ein Präfix (`kategorie:12`, `konto:7`), das der Controller in
|
||||
* BuchungenController::zuordnungAus() wieder auseinandernimmt.
|
||||
*
|
||||
* @var list<array<string,mixed>> $kategorien Kategoriebaum, flach mit Pfad
|
||||
* @var list<array<string,mixed>> $konten alle Konten des Mandanten
|
||||
* @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)
|
||||
*/
|
||||
?>
|
||||
<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']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</optgroup>
|
||||
<optgroup label="Umbuchung auf ein Konto">
|
||||
<?php foreach ($konten as $eintrag): ?>
|
||||
<?php if ((int)$eintrag['id'] === (int)$eigenesKonto) { continue; } ?>
|
||||
<?php $wert = 'konto:' . (int)$eintrag['id']; ?>
|
||||
<option value="<?= View::h($wert) ?>"<?= ($gewaehlt ?? '') === $wert ? ' selected' : '' ?>>
|
||||
[<?= View::h($eintrag['name']) ?>]
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</optgroup>
|
||||
275
tests/23_ZuordnungTest.php
Normal file
275
tests/23_ZuordnungTest.php
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Finanzmanager\Controllers\BuchungenController;
|
||||
use Finanzmanager\Models\Buchung;
|
||||
use Finanzmanager\Models\Kategorie;
|
||||
use Finanzmanager\Models\Klasse;
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\Models\Monatsabschluss;
|
||||
|
||||
/**
|
||||
* Die Schnellzuordnung aus dem Kontoblatt: Kategorie oder Gegenkonto in einem
|
||||
* Feld, Splitzeilen aus dem Dialog.
|
||||
*/
|
||||
class ZuordnungTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 23: Zuordnung im Kontoblatt';
|
||||
|
||||
private int $giro = 0;
|
||||
private int $spar = 0;
|
||||
private int $kategorie = 0;
|
||||
private int $klasse = 0;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->mandantAnlegen();
|
||||
$this->stammdaten();
|
||||
|
||||
$this->auswahlwerteLesen();
|
||||
$this->kategorieUndGegenkontoWechseln();
|
||||
$this->gegensichtNenntIhrKonto();
|
||||
$this->splitzeilenErsetzen();
|
||||
$this->splitbuchungLoeschen();
|
||||
$this->splitzeilenJeBuchung();
|
||||
$this->pruefungen();
|
||||
}
|
||||
|
||||
private function stammdaten(): void
|
||||
{
|
||||
$this->giro = Konto::anlegen([
|
||||
'name' => 'Zuordnung Giro', 'kontoart' => 'bank', 'anfangssaldo' => 1000, 'aktiv' => 1,
|
||||
]);
|
||||
$this->spar = Konto::anlegen([
|
||||
'name' => 'Zuordnung Spar', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||||
]);
|
||||
$this->kategorie = Kategorie::anlegen(['name' => 'Zuordnung Einkauf', 'art' => 'ausgabe']);
|
||||
$this->klasse = Klasse::anlegen('Zuordnung Privat');
|
||||
}
|
||||
|
||||
private function buchung(float $betrag = -50.0, string $datum = '2026-03-10'): int
|
||||
{
|
||||
return Buchung::anlegen([
|
||||
'konto_id' => $this->giro,
|
||||
'datum' => $datum,
|
||||
'betrag' => $betrag,
|
||||
'empfaenger' => 'Noch zuzuordnen',
|
||||
]);
|
||||
}
|
||||
|
||||
/** Kategorie und Gegenkonto stehen in einem Feld – hier wird es gelesen. */
|
||||
private function auswahlwerteLesen(): void
|
||||
{
|
||||
$this->assertEquals(
|
||||
['kategorie_id' => 12, 'gegenkonto_id' => null],
|
||||
BuchungenController::zuordnungAus('kategorie:12'),
|
||||
'Eine Kategorie wird erkannt'
|
||||
);
|
||||
$this->assertEquals(
|
||||
['kategorie_id' => null, 'gegenkonto_id' => 7],
|
||||
BuchungenController::zuordnungAus('konto:7'),
|
||||
'Ein Gegenkonto wird erkannt'
|
||||
);
|
||||
|
||||
$leer = ['kategorie_id' => null, 'gegenkonto_id' => null];
|
||||
$this->assertEquals($leer, BuchungenController::zuordnungAus(''), 'Leer heißt keine Zuordnung');
|
||||
$this->assertEquals($leer, BuchungenController::zuordnungAus('split'), 'Splitbuchung ist keine Zuordnung');
|
||||
$this->assertEquals($leer, BuchungenController::zuordnungAus('konto:0'), 'Kennung 0 zählt nicht');
|
||||
$this->assertEquals($leer, BuchungenController::zuordnungAus('unfug:3'), 'Unbekanntes Präfix zählt nicht');
|
||||
}
|
||||
|
||||
private function kategorieUndGegenkontoWechseln(): void
|
||||
{
|
||||
$id = $this->buchung();
|
||||
|
||||
Buchung::zuordnen($id, $this->kategorie, null);
|
||||
$buchung = Buchung::find($id);
|
||||
$this->assertEquals($this->kategorie, (int)$buchung['kategorie_id'], 'Die Kategorie steht in der Buchung');
|
||||
$this->assertNull($buchung['gegenkonto_id'], 'Ohne Gegenkonto');
|
||||
|
||||
// Aus der Kategorie wird eine Umbuchung: beides zugleich gibt es nicht.
|
||||
Buchung::zuordnen($id, $this->kategorie, $this->spar);
|
||||
$buchung = Buchung::find($id);
|
||||
$this->assertEquals($this->spar, (int)$buchung['gegenkonto_id'], 'Das Gegenkonto steht in der Buchung');
|
||||
$this->assertNull($buchung['kategorie_id'], 'Die Kategorie weicht dem Gegenkonto');
|
||||
|
||||
// Die Umbuchung wirkt sofort auf beiden Seiten.
|
||||
$this->assertEquals(50.0, Konto::saldo($this->spar), 'Das Gegenkonto bekommt die Gegenseite');
|
||||
|
||||
Buchung::zuordnen($id, null, null);
|
||||
$buchung = Buchung::find($id);
|
||||
$this->assertNull($buchung['kategorie_id'], 'Die Zuordnung lässt sich wieder leeren');
|
||||
$this->assertNull($buchung['gegenkonto_id'], 'Auch das Gegenkonto');
|
||||
$this->assertEquals(0.0, Konto::saldo($this->spar), 'Und das Gegenkonto ist den Betrag wieder los');
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Anzeigefehler, um den es ging: Auf dem Gegenkonto stand eine Umbuchung
|
||||
* ohne erkennbares Gegenüber. Die Zeile muss das Ursprungskonto nennen.
|
||||
*/
|
||||
private function gegensichtNenntIhrKonto(): void
|
||||
{
|
||||
$id = $this->buchung(-120.0, '2026-03-11');
|
||||
Buchung::zuordnen($id, null, $this->spar);
|
||||
|
||||
$zeilen = array_values(array_filter(
|
||||
Buchung::kontoblatt($this->spar),
|
||||
static fn(array $zeile): bool => (int)$zeile['id'] === $id
|
||||
));
|
||||
|
||||
$this->assertEquals(1, count($zeilen), 'Die Umbuchung erscheint auf dem Gegenkonto');
|
||||
$this->assert($zeilen[0]['gegensicht'], 'Und zwar als Gegensicht');
|
||||
$this->assertEquals(
|
||||
$this->giro,
|
||||
(int)$zeilen[0]['gegenkonto_id'],
|
||||
'Die Gegensicht nennt das Konto, von dem die Umbuchung kommt'
|
||||
);
|
||||
$this->assertEquals(120.0, (float)$zeilen[0]['betrag'], 'Mit umgekehrtem Vorzeichen');
|
||||
|
||||
Buchung::loeschen($id);
|
||||
}
|
||||
|
||||
private function splitzeilenErsetzen(): void
|
||||
{
|
||||
$id = $this->buchung(-100.0, '2026-03-12');
|
||||
Buchung::zuordnen($id, $this->kategorie, null);
|
||||
|
||||
Buchung::splitsErsetzen($id, [
|
||||
['kategorie_id' => $this->kategorie, 'klasse_id' => $this->klasse, 'betrag' => -60.0, 'notiz' => 'Einkauf'],
|
||||
['gegenkonto_id' => $this->spar, 'betrag' => -40.0, 'notiz' => 'Rücklage'],
|
||||
]);
|
||||
|
||||
$buchung = Buchung::find($id);
|
||||
$this->assertEquals(1, (int)$buchung['gesplittet'], 'Die Buchung ist jetzt gesplittet');
|
||||
$this->assertNull($buchung['kategorie_id'], 'Die Kategorie von oben weicht den Zeilen');
|
||||
|
||||
$splits = Buchung::splits($id);
|
||||
$this->assertEquals(2, count($splits), 'Zwei Zeilen sind gespeichert');
|
||||
$this->assertEquals($this->klasse, (int)$splits[0]['klasse_id'], 'Die Klasse der ersten Zeile');
|
||||
$this->assertEquals($this->spar, (int)$splits[1]['gegenkonto_id'], 'Das Gegenkonto der zweiten Zeile');
|
||||
$this->assertEquals(40.0, Konto::saldo($this->spar), 'Nur der Betrag dieser Zeile geht aufs Gegenkonto');
|
||||
|
||||
// Eine einzelne Zuordnung hebt die Aufteilung auf.
|
||||
Buchung::zuordnen($id, $this->kategorie, null);
|
||||
$this->assertEquals(0, (int)Buchung::find($id)['gesplittet'], 'Die Splitbuchung ist aufgehoben');
|
||||
$this->assertEquals([], Buchung::splits($id), 'Und die Zeilen sind weg');
|
||||
$this->assertEquals(0.0, Konto::saldo($this->spar), 'Das Gegenkonto ist den Anteil wieder los');
|
||||
|
||||
// Ohne Zeilen bleibt eine Buchung ohne Zuordnung stehen.
|
||||
Buchung::splitsErsetzen($id, [
|
||||
['kategorie_id' => $this->kategorie, 'betrag' => -100.0, 'notiz' => ''],
|
||||
]);
|
||||
Buchung::splitsErsetzen($id, []);
|
||||
$buchung = Buchung::find($id);
|
||||
$this->assertEquals(0, (int)$buchung['gesplittet'], 'Leere Zeilen heben die Splitbuchung auf');
|
||||
$this->assertNull($buchung['kategorie_id'], 'Die Buchung steht danach ohne Zuordnung da');
|
||||
$this->assertEquals(-100.0, (float)$buchung['betrag'], 'Der Buchungsbetrag bleibt unberührt');
|
||||
|
||||
Buchung::loeschen($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* „Splitbuchung löschen“ aus dem Splitdialog: Die Aufteilung fällt weg und
|
||||
* mit ihr jede Zuordnung – auch die einer Zeile mit Gegenkonto, deren
|
||||
* Gegenseite auf dem anderen Konto zu verschwinden hat.
|
||||
*/
|
||||
private function splitbuchungLoeschen(): void
|
||||
{
|
||||
$id = $this->buchung(-90.0, '2026-03-16');
|
||||
|
||||
Buchung::splitsErsetzen($id, [
|
||||
['kategorie_id' => $this->kategorie, 'klasse_id' => $this->klasse, 'betrag' => -55.0, 'notiz' => 'Teil A'],
|
||||
['gegenkonto_id' => $this->spar, 'betrag' => -35.0, 'notiz' => 'Teil B'],
|
||||
]);
|
||||
$this->assertEquals(35.0, Konto::saldo($this->spar), 'Die Zeile mit Gegenkonto wirkt zunächst');
|
||||
|
||||
Buchung::splitsErsetzen($id, []);
|
||||
|
||||
$buchung = Buchung::find($id);
|
||||
$this->assertEquals([], Buchung::splits($id), 'Alle Splitzeilen sind gelöscht');
|
||||
$this->assertEquals(0, (int)$buchung['gesplittet'], 'Die Buchung gilt nicht mehr als gesplittet');
|
||||
$this->assertNull($buchung['kategorie_id'], 'Sie hat keine Kategorie');
|
||||
$this->assertNull($buchung['gegenkonto_id'], 'Und kein Gegenkonto');
|
||||
$this->assertEquals(-90.0, (float)$buchung['betrag'], 'Die Buchung selbst bleibt stehen');
|
||||
$this->assertEquals(0.0, Konto::saldo($this->spar), 'Das Gegenkonto ist seinen Anteil wieder los');
|
||||
|
||||
Buchung::loeschen($id);
|
||||
}
|
||||
|
||||
private function splitzeilenJeBuchung(): void
|
||||
{
|
||||
$eine = $this->buchung(-30.0, '2026-03-13');
|
||||
$keine = $this->buchung(-20.0, '2026-03-14');
|
||||
|
||||
Buchung::splitsErsetzen($eine, [
|
||||
['kategorie_id' => $this->kategorie, 'betrag' => -10.0, 'notiz' => 'A'],
|
||||
['kategorie_id' => $this->kategorie, 'betrag' => -20.0, 'notiz' => 'B'],
|
||||
]);
|
||||
|
||||
$jeBuchung = Buchung::splitsJeBuchung([$eine, $keine]);
|
||||
$this->assertEquals(2, count($jeBuchung[$eine]), 'Die Zeilen kommen der Buchung zugeordnet zurück');
|
||||
$this->assert(!isset($jeBuchung[$keine]), 'Eine Buchung ohne Zeilen fehlt in der Liste');
|
||||
$this->assertEquals([], Buchung::splitsJeBuchung([]), 'Ohne Kennungen wird nicht abgefragt');
|
||||
|
||||
Buchung::loeschen($eine);
|
||||
Buchung::loeschen($keine);
|
||||
}
|
||||
|
||||
private function pruefungen(): void
|
||||
{
|
||||
$id = $this->buchung(-80.0, '2026-03-15');
|
||||
|
||||
$aufSichSelbst = false;
|
||||
try {
|
||||
Buchung::zuordnen($id, null, $this->giro);
|
||||
} catch (\Throwable) {
|
||||
$aufSichSelbst = true;
|
||||
}
|
||||
$this->assert($aufSichSelbst, 'Ein Konto kann nicht sein eigenes Gegenkonto sein');
|
||||
|
||||
$splitAufSichSelbst = false;
|
||||
try {
|
||||
Buchung::splitsErsetzen($id, [['gegenkonto_id' => $this->giro, 'betrag' => -80.0]]);
|
||||
} catch (\Throwable) {
|
||||
$splitAufSichSelbst = true;
|
||||
}
|
||||
$this->assert($splitAufSichSelbst, 'Auch eine Splitzeile nicht');
|
||||
|
||||
$krummeSumme = false;
|
||||
try {
|
||||
Buchung::splitsErsetzen($id, [['kategorie_id' => $this->kategorie, 'betrag' => -30.0]]);
|
||||
} catch (\Throwable) {
|
||||
$krummeSumme = true;
|
||||
}
|
||||
$this->assert($krummeSumme, 'Zeilen, die nicht aufgehen, werden abgewiesen');
|
||||
$this->assertEquals(0, (int)Buchung::find($id)['gesplittet'], 'Und die Buchung bleibt ungesplittet');
|
||||
|
||||
$fremdeKategorie = false;
|
||||
try {
|
||||
Buchung::zuordnen($id, 999999, null);
|
||||
} catch (\Throwable) {
|
||||
$fremdeKategorie = true;
|
||||
}
|
||||
$this->assert($fremdeKategorie, 'Eine Kategorie, die es nicht gibt, wird abgewiesen');
|
||||
|
||||
// Ein abgeschlossener Monat ist auch für die Schnellzuordnung zu.
|
||||
Monatsabschluss::abschliessen(2026, 3);
|
||||
|
||||
$zu = false;
|
||||
try {
|
||||
Buchung::zuordnen($id, $this->kategorie, null);
|
||||
} catch (\Throwable) {
|
||||
$zu = true;
|
||||
}
|
||||
$this->assert($zu, 'Im abgeschlossenen Monat wird nichts mehr zugeordnet');
|
||||
|
||||
$zuFuerSplits = false;
|
||||
try {
|
||||
Buchung::splitsErsetzen($id, []);
|
||||
} catch (\Throwable) {
|
||||
$zuFuerSplits = true;
|
||||
}
|
||||
$this->assert($zuFuerSplits, 'Auch die Splitzeilen bleiben, wie sie sind');
|
||||
}
|
||||
}
|
||||
|
|
@ -72,7 +72,7 @@ test.describe('Buchungen', () => {
|
|||
await page.fill('#datum', '2026-03-18')
|
||||
await page.fill('#betrag', '49,90')
|
||||
await page.fill('#empfaenger', 'Supermarkt')
|
||||
await page.selectOption('#kategorie_id', { label: KATEGORIE })
|
||||
await page.selectOption('#zuordnung', { label: KATEGORIE })
|
||||
await page.click('.buchungsmaske button[type="submit"]')
|
||||
|
||||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||
|
|
@ -86,11 +86,13 @@ test.describe('Buchungen', () => {
|
|||
test('Umbuchung erscheint auf beiden Kontoblättern', async ({ page }) => {
|
||||
await page.goto(`/konten/${giroId}/buchung`)
|
||||
|
||||
await page.selectOption('#art', 'umbuchung_ab')
|
||||
// Die Umbuchung entsteht im Kategoriefeld: Unter den Kategorien stehen
|
||||
// die eigenen Konten in eckigen Klammern.
|
||||
await page.selectOption('#art', 'ausgabe')
|
||||
await page.fill('#datum', '2026-04-01')
|
||||
await page.fill('#betrag', '500')
|
||||
await page.fill('#empfaenger', `Übertrag ${MARKE}`)
|
||||
await page.selectOption('#gegenkonto_id', { label: SPAR })
|
||||
await page.selectOption('#zuordnung', { label: `[${SPAR}]` })
|
||||
await page.click('.buchungsmaske button[type="submit"]')
|
||||
|
||||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||
|
|
@ -100,12 +102,135 @@ test.describe('Buchungen', () => {
|
|||
await expect(hin).toContainText('-500,00')
|
||||
await expect(hin).toContainText(`[${SPAR}]`)
|
||||
|
||||
// Gegenkonto: derselbe Vorgang mit umgekehrtem Vorzeichen.
|
||||
// Gegenkonto: derselbe Vorgang mit umgekehrtem Vorzeichen – und mit dem
|
||||
// Konto, von dem er kommt. Ohne diese Angabe stünde dort eine Umbuchung
|
||||
// ohne erkennbares Gegenüber.
|
||||
await page.goto(`/konten/${sparId}`)
|
||||
const her = page.locator('tr', { hasText: `Übertrag ${MARKE}` })
|
||||
await expect(her).toContainText('500,00')
|
||||
await expect(her).toContainText('Umbuchung')
|
||||
await expect(her).toContainText(`[${GIRO}]`)
|
||||
await expect(page.locator('.saldoanzeige').first()).toContainText('5.500,00')
|
||||
|
||||
// Auch die Gegenseite ist anklickbar: Sie führt zu ihrer Buchung, die
|
||||
// auf dem anderen Konto liegt und nur dort geändert wird.
|
||||
await her.locator('a.zuordnungsfeld.gegenseite').click()
|
||||
await expect(page).toHaveURL(/\/buchungen\/\d+/)
|
||||
await expect(page.locator('h1')).toContainText('Buchung bearbeiten')
|
||||
await expect(page.locator('#zuordnung')).toHaveValue(/^konto:\d+$/)
|
||||
})
|
||||
|
||||
test('Kategorie im Kontoblatt zuordnen', async ({ page }) => {
|
||||
await page.goto(`/konten/${giroId}/buchung`)
|
||||
await page.fill('#datum', '2026-04-03')
|
||||
await page.fill('#betrag', '12,30')
|
||||
await page.fill('#empfaenger', `Ohne Kategorie ${MARKE}`)
|
||||
await page.click('.buchungsmaske button[type="submit"]')
|
||||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||
|
||||
const zeile = page.locator('tr', { hasText: `Ohne Kategorie ${MARKE}` })
|
||||
await zeile.locator('a.zuordnungsfeld').click()
|
||||
|
||||
// Die Auswahlliste gibt es einmal und wandert in die angeklickte Zelle.
|
||||
const liste = zeile.locator('#zuordnungsauswahl select')
|
||||
await expect(liste).toBeVisible()
|
||||
await liste.selectOption({ label: KATEGORIE })
|
||||
|
||||
await expect(page.locator('.meldung-erfolg')).toContainText('Zuordnung')
|
||||
await expect(page.locator('tr', { hasText: `Ohne Kategorie ${MARKE}` }))
|
||||
.toContainText(KATEGORIE)
|
||||
})
|
||||
|
||||
test('Umbuchung im Kontoblatt zuordnen', async ({ page }) => {
|
||||
await page.goto(`/konten/${giroId}/buchung`)
|
||||
await page.fill('#datum', '2026-04-04')
|
||||
await page.fill('#betrag', '75')
|
||||
await page.fill('#empfaenger', `Nachträglich ${MARKE}`)
|
||||
await page.click('.buchungsmaske button[type="submit"]')
|
||||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||
|
||||
const zeile = page.locator('tr', { hasText: `Nachträglich ${MARKE}` })
|
||||
await zeile.locator('a.zuordnungsfeld').click()
|
||||
await zeile.locator('#zuordnungsauswahl select').selectOption({ label: `[${SPAR}]` })
|
||||
|
||||
await expect(page.locator('.meldung-erfolg')).toContainText('Zuordnung')
|
||||
await expect(page.locator('tr', { hasText: `Nachträglich ${MARKE}` }))
|
||||
.toContainText(`[${SPAR}]`)
|
||||
|
||||
// Und die Gegenseite steht sofort auf dem anderen Konto.
|
||||
await page.goto(`/konten/${sparId}`)
|
||||
await expect(page.locator('tr', { hasText: `Nachträglich ${MARKE}` }))
|
||||
.toContainText(`[${GIRO}]`)
|
||||
})
|
||||
|
||||
test('Splitbuchung aus dem Kontoblatt heraus aufteilen', async ({ page }) => {
|
||||
await page.goto(`/konten/${giroId}/buchung`)
|
||||
await page.fill('#datum', '2026-04-05')
|
||||
await page.fill('#betrag', '90')
|
||||
await page.fill('#empfaenger', `Aufzuteilen ${MARKE}`)
|
||||
await page.click('.buchungsmaske button[type="submit"]')
|
||||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||
|
||||
const zeile = page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` })
|
||||
await zeile.locator('a.zuordnungsfeld').click()
|
||||
await zeile.locator('#zuordnungsauswahl select').selectOption('split')
|
||||
|
||||
const dialog = page.locator('#splitdialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog).toContainText('-90,00')
|
||||
|
||||
await dialog.locator('select[name="split[0][zuordnung]"]').selectOption({ label: KATEGORIE })
|
||||
await dialog.locator('input[name="split[0][betrag]"]').fill('-50')
|
||||
await dialog.locator('select[name="split[1][zuordnung]"]').selectOption({ label: `[${SPAR}]` })
|
||||
await dialog.locator('input[name="split[1][betrag]"]').fill('-40')
|
||||
|
||||
// Der Dialog rechnet den Restbetrag mit; er muss aufgehen.
|
||||
await expect(page.locator('#splitdialogsumme')).not.toContainText('Rest')
|
||||
|
||||
// Der Dialog hat zwei Submit-Knöpfe: Speichern und Löschen.
|
||||
await dialog.locator('button[type="submit"].primaer').click()
|
||||
|
||||
await expect(page.locator('.meldung-erfolg')).toContainText('Splitbuchung')
|
||||
await expect(page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` }).first())
|
||||
.toContainText('Splitbuchung')
|
||||
|
||||
// Die Zeile mit Gegenkonto erscheint anteilig auf dem anderen Konto.
|
||||
await page.goto(`/konten/${sparId}`)
|
||||
await expect(page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` })).toContainText('40,00')
|
||||
})
|
||||
|
||||
test('Splitbuchung öffnet den Dialog und lässt sich dort löschen', async ({ page }) => {
|
||||
await page.goto(`/konten/${giroId}/buchung`)
|
||||
await page.fill('#datum', '2026-04-06')
|
||||
await page.fill('#betrag', '70')
|
||||
await page.fill('#empfaenger', `Wieder einfach ${MARKE}`)
|
||||
await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE })
|
||||
await page.fill('input[name="split[0][betrag]"]', '-30')
|
||||
await page.selectOption('select[name="split[1][zuordnung]"]', { label: KATEGORIE })
|
||||
await page.fill('input[name="split[1][betrag]"]', '-40')
|
||||
await page.click('.buchungsmaske button[type="submit"]')
|
||||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||
|
||||
// Der Klick führt nicht in die Auswahlliste, sondern in den Dialog –
|
||||
// mit den vorhandenen Zeilen darin.
|
||||
const zeile = page.locator('tr', { hasText: `Wieder einfach ${MARKE}` })
|
||||
await zeile.locator('a.zuordnungsfeld').click()
|
||||
|
||||
const dialog = page.locator('#splitdialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(page.locator('#zuordnungsauswahl select')).toBeHidden()
|
||||
await expect(dialog.locator('input[name="split[0][betrag]"]')).toHaveValue('-30,00')
|
||||
|
||||
await dialog.locator('#splitloeschen').click()
|
||||
|
||||
await expect(page.locator('.meldung-erfolg')).toContainText('Splitbuchung gelöscht')
|
||||
const danach = page.locator('tr', { hasText: `Wieder einfach ${MARKE}` })
|
||||
await expect(danach).not.toContainText('Splitbuchung')
|
||||
await expect(danach).not.toContainText(KATEGORIE)
|
||||
|
||||
// Und die Zuordnung geht jetzt wieder über die Auswahlliste.
|
||||
await danach.locator('a.zuordnungsfeld').click()
|
||||
await expect(danach.locator('#zuordnungsauswahl select')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Splitbuchung mit zwei Zeilen', async ({ page }) => {
|
||||
|
|
@ -115,11 +240,11 @@ test.describe('Buchungen', () => {
|
|||
await page.fill('#betrag', '260,49')
|
||||
await page.fill('#empfaenger', `Versicherung ${MARKE}`)
|
||||
|
||||
await page.selectOption('select[name="split[0][kategorie_id]"]', { label: KATEGORIE })
|
||||
await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE })
|
||||
await page.fill('input[name="split[0][notiz]"]', 'Kasko')
|
||||
await page.fill('input[name="split[0][betrag]"]', '-162,51')
|
||||
|
||||
await page.selectOption('select[name="split[1][kategorie_id]"]', { label: KATEGORIE })
|
||||
await page.selectOption('select[name="split[1][zuordnung]"]', { label: KATEGORIE })
|
||||
await page.fill('input[name="split[1][notiz]"]', 'Haftpflicht')
|
||||
await page.fill('input[name="split[1][betrag]"]', '-97,98')
|
||||
|
||||
|
|
@ -140,7 +265,7 @@ test.describe('Buchungen', () => {
|
|||
await page.fill('#datum', '2026-05-03')
|
||||
await page.fill('#betrag', '100')
|
||||
await page.fill('#empfaenger', `Krumm ${MARKE}`)
|
||||
await page.selectOption('select[name="split[0][kategorie_id]"]', { label: KATEGORIE })
|
||||
await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE })
|
||||
await page.fill('input[name="split[0][betrag]"]', '-60')
|
||||
|
||||
await expect(page.locator('#splitsumme')).toContainText('Rest')
|
||||
|
|
@ -157,11 +282,13 @@ test.describe('Buchungen', () => {
|
|||
await page.fill('input[name="suche"]', 'Supermarkt')
|
||||
await page.click('.filterleiste button[type="submit"]')
|
||||
|
||||
await expect(page.locator('tbody tr')).toHaveCount(1)
|
||||
await expect(page.locator('tbody')).toContainText('Supermarkt')
|
||||
// Auf der Seite steht noch eine zweite Tabelle: die leeren Zeilen des
|
||||
// Splitdialogs. Gemeint ist hier das Kontoblatt.
|
||||
await expect(page.locator('table.kontoblatt tbody tr')).toHaveCount(1)
|
||||
await expect(page.locator('table.kontoblatt tbody')).toContainText('Supermarkt')
|
||||
// Mit Filter ist die Saldospalte leer – ein laufender Saldo wäre gelogen.
|
||||
// Spalte 0 ist das Ankreuzfeld der Mehrfachauswahl.
|
||||
await expect(page.locator('tbody tr td').nth(8)).toContainText('–')
|
||||
await expect(page.locator('table.kontoblatt tbody tr td').nth(8)).toContainText('–')
|
||||
})
|
||||
|
||||
test('Eine Buchung ohne Kategorie setzt ihr Konto in der Übersicht fett', async ({ page }) => {
|
||||
|
|
@ -206,7 +333,7 @@ test.describe('Buchungen', () => {
|
|||
expect(gefragt.join(' ')).toContain('löschen')
|
||||
|
||||
await expect(page.locator('.meldung-erfolg')).toContainText('gelöscht')
|
||||
await expect(page.locator('tbody')).not.toContainText('Supermarkt')
|
||||
await expect(page.locator('table.kontoblatt tbody')).not.toContainText('Supermarkt')
|
||||
})
|
||||
|
||||
test('Mehrere Buchungen über die Auswahl im Kontoblatt löschen', async ({ page }) => {
|
||||
|
|
@ -227,7 +354,7 @@ test.describe('Buchungen', () => {
|
|||
await expect(knopf).toBeDisabled()
|
||||
|
||||
// Das Ankreuzfeld in der Titelzeile wählt alle angezeigten Buchungen.
|
||||
const anzahl = await page.locator('tbody input[name="buchungen[]"]').count()
|
||||
const anzahl = await page.locator('table.kontoblatt tbody input[name="buchungen[]"]').count()
|
||||
await page.check('.alle-waehlen')
|
||||
await expect(knopf).toContainText(`(${anzahl})`)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue