Merge pull request 'Fix #123: Regelmäßige Buchungen automatisch abgleichen' (#139) from fds2610-bot/Finanzmanager:main into main
Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/139
This commit is contained in:
commit
57c6253997
77 changed files with 2450 additions and 433 deletions
22
CHANGELOG.md
22
CHANGELOG.md
|
|
@ -8,6 +8,22 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
|
|||
|
||||
### Added
|
||||
|
||||
- Buchungsbericht: Spalte „✓“ (abgeglichen) vor der Spalte „Bar“ des Bargeld-Tools und
|
||||
Filter „Abgeglichen“ (alle / abgeglichen / nicht abgeglichen); „Buchungen nicht
|
||||
abgeglichen“ unter den offenen Vorgängen im Cockpit öffnet den Buchungsbericht mit
|
||||
diesem Filter statt der Kontenübersicht (#137)
|
||||
- Buchungsmaske: Schalter „Ohne Bargeld-Abgleich“ für Sammelbuchungen und
|
||||
Differenzausgleiche; solche Buchungen werden nicht mit dem Bargeld-Tool abgeglichen und
|
||||
erscheinen nicht mehr unter den offenen Vorgängen im Cockpit (#136)
|
||||
- Depotbuchung erfassen: Bei Kauf und Verkauf rechnet sich aus zwei der drei Felder
|
||||
Betrag, Stück und Kurs automatisch das dritte (Betrag = Stück × Kurs) (#129)
|
||||
- Wertpapiere: Beim Buchen eines Wertpapiergeschäfts (Dialog im Kontoblatt und
|
||||
„Depotbuchung erfassen“) lässt sich eine Klasse wählen; sie gilt für die Buchung samt
|
||||
aller Splitzeilen der Nebenkosten (#125)
|
||||
- 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)
|
||||
|
|
@ -22,6 +38,12 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
|
|||
|
||||
### Fixed
|
||||
|
||||
- Offene Vorgänge: „Im Bargeld-Tool nicht gefunden“ und „Bargeld-Tool-Prüfung fehlgeschlagen“
|
||||
führen jetzt in den Buchungsbericht mit dem passenden Filter „Bargeld-Tool“ – dort steht die
|
||||
Spalte Kategorie (bzw. das Gegenkonto) mit; die eigene Liste unter
|
||||
`/verwaltung/bargeld/fehler` entfällt (#131)
|
||||
- Anmeldeseite: Die Marke zeigt wie die Seitenleiste nach dem Anmelden das Coinstack-Symbol
|
||||
und den fetten, zweifarbigen Schriftzug „Finanzmanager“ statt einer grauen Statusmarke (#130)
|
||||
- Buchungsmaske: Eine falsche Splitsumme zeigte zwar die Fehlermeldung, riss aber über die
|
||||
Weiterleitung zur leeren Maske alle gerade erfassten Splitzeilen heraus; jetzt bleibt die
|
||||
Maske mit den eigenen Eingaben stehen, bis die Summe stimmt (#95)
|
||||
|
|
|
|||
66
Changes.md
66
Changes.md
|
|
@ -1,5 +1,71 @@
|
|||
# Änderungen
|
||||
|
||||
## Buchungsbericht: Spalte und Filter „abgeglichen“ (12.10.2026)
|
||||
|
||||
- Anlass (#137): Der Buchungsbericht zeigte nicht, ob eine Buchung abgeglichen ist, und ließ sich nicht danach filtern.
|
||||
- Neue Spalte „✓“ (Tooltip „abgeglichen“) vor der Spalte „Bar“ des Bargeld-Tools – wie im Kontoblatt. Splitzeilen erben den Haken ihrer Buchung.
|
||||
- Neuer Filter „Abgeglichen“ (alle / abgeglichen / nicht abgeglichen), Filterfeld `abgeglichen` mit den Werten `ja`/`nein`; wie der Bargeld-Filter wirkt er nur im Buchungsbericht und wird mit gespeicherten Berichten gesichert.
|
||||
- Cockpit, *Offene Vorgänge*: „Buchungen nicht abgeglichen“ führt jetzt in den Buchungsbericht mit dem Filter „nicht abgeglichen“ statt in die Kontenübersicht (`Cockpit::nichtAbgeglichenBericht()`). Zeitraum von der ersten betroffenen Buchung bis heute, Umbuchungen eingeschlossen; liegen nicht abgeglichene Buchungen auf aufgelösten Konten, beschränkt sich der Bericht auf die aktiven Konten – so zeigt er genau die gezählten Buchungen.
|
||||
- Tests: `tests/14_AuswertungTest.php`, `tests/15_CockpitTest.php`, `tests/e2e/specs/auswertungen.spec.js`.
|
||||
|
||||
## Buchen ohne Bargeld-Abgleich (12.10.2026)
|
||||
|
||||
- Anlass (#136): Sammelbuchungen und Differenzausgleiche tragen eine Kategorie, die gegen das Bargeld-Tool geprüft wird, stehen dort aber absichtlich nicht – und wurden deshalb immer wieder als offener Vorgang angemahnt.
|
||||
- Die Buchungsmaske hat den Schalter „Ohne Bargeld-Abgleich“ (`buchungen.ohne_bargeld_abgleich`, Migration `031_ohne_bargeld_abgleich.sql`). Er erscheint nur, wenn ein Bargeld-Tool eingerichtet ist; sonst trägt die Maske einen gesetzten Wert unsichtbar weiter.
|
||||
- `BargeldAbgleich::pruefe()` prüft solche Buchungen nicht und löscht einen früheren Status. Sie erscheinen damit weder unter „Bargeld-Tool-Prüfung fehlgeschlagen“ noch unter „im Bargeld-Tool nicht gefunden“. Die Schnellzuordnung im Kontoblatt und die Regelanwendung lassen den Schalter stehen.
|
||||
- Kontoblatt und Buchungsbericht zeigen in der Spalte „Bar“ einen leeren grauen Kreis mit dem Tooltip „Bewusst ohne Abgleich mit dem Bargeld-Tool“.
|
||||
- Tests: `tests/65_OhneBargeldAbgleichTest.php`, `tests/e2e/specs/bargeld.spec.js`.
|
||||
|
||||
## Offene Vorgänge des Bargeld-Tools im Buchungsbericht (12.10.2026)
|
||||
|
||||
- Anlass (#131): Der Liste „Im Bargeld-Tool nicht gefunden“, aufgerufen aus *Offene Vorgänge* im Cockpit, fehlte die Spalte Kategorie/Gegenkonto. Gewünscht war statt einer eigenen Liste ein Filter im Buchungsbericht.
|
||||
- Den Filter „Bargeld-Tool“ hat der Buchungsbericht schon; die beiden offenen Vorgänge „Bargeld-Tool-Prüfung fehlgeschlagen“ und „im Bargeld-Tool nicht gefunden“ führen jetzt dorthin (`Cockpit::bargeldBericht()`): gesamter Zeitraum, Umbuchungen eingeschlossen, Filter „Abruf gescheitert“ bzw. „nicht gefunden“. Der Bericht zeigt Konto, Kategorie bzw. Gegenkonto in eckigen Klammern und Klasse; die Meldung der Gegenstelle steht im Tooltip der Spalte „Bar“.
|
||||
- Ist einer dieser beiden Filter gesetzt, erklärt der Hinweis unter dem Bericht den Status und dass eine erneute Zuordnung der Kategorie die Prüfung erneut anstößt – der Text der bisherigen Liste.
|
||||
- Entfernt: die Seite `/verwaltung/bargeld/fehler` (`VerwaltungController::bargeldFehler()`, `Views/verwaltung/bargeld_fehler.php`) und `Buchung::mitBargeldFehler()`.
|
||||
- Tests: `tests/34_BargeldAbgleichTest.php`, `tests/15_CockpitTest.php`, `tests/e2e/specs/bargeld.spec.js`.
|
||||
|
||||
## Anmeldeseite: Marke wie in der Seitenleiste (12.10.2026)
|
||||
|
||||
- Anlass (#130): Icon und Schrift des Logos auf der Anmeldeseite wichen von der Marke im angemeldeten Zustand ab.
|
||||
- Der schlichte Rahmen (`src/Views/layout/schlicht.php` – Anmeldung, Ersteinrichtung, Fehlerseite) zeigt jetzt das Coinstack-Symbol und den Schriftzug „Finanz**manager**“ im Schriftschnitt der Seitenleiste. Bisher erbte der Schriftzug die Regeln der kleinen Statusmarke (`.marke`) und stand grau mit Rahmen da.
|
||||
- Weil die Karte hell ist, trägt die erste Worthälfte die Textfarbe und die zweite die lesbare Akzentfarbe (`--akzent-schrift`); im dunklen Thema entspricht das dem Blau der Seitenleiste.
|
||||
- Tests: `tests/64_AnmeldeMarkeTest.php`, `tests/e2e/specs/smoke.spec.js`.
|
||||
|
||||
## Depotbuchung erfassen: Stück, Kurs und Betrag rechnen sich (12.10.2026)
|
||||
|
||||
- Anlass (#129): Bei Kauf und Verkauf sollten sich Betrag, Stück und Kurs gegenseitig ergeben – Betrag = Stück × Kurs.
|
||||
- Die Maske *Depotbuchung erfassen* rechnet bei Kauf und Verkauf das dritte Feld aus, sobald zwei eingetragen sind (`public/static/depotbuchung.js`): den Betrag auf zwei, Kurs und Stück auf sechs Nachkommastellen. Der ausgerechnete Wert ist farblich hinterlegt und zieht nach, wenn sich eines der anderen Felder ändert; selbst umgetippt gilt er als eingetragen, beim Leeren eines der anderen Felder verschwindet er. Bei Dividende, Ein- und Auslieferung wird nicht gerechnet; wechselt der Vorgang auf Kauf oder Verkauf, rechnet die Maske nach.
|
||||
- Serverseitig dieselbe Regel für den Weg ohne JavaScript (`Depotbuchung::dritterWert()`): Neben dem Betrag ergeben sich jetzt auch Kurs bzw. Stück bei Kauf und Verkauf.
|
||||
- Der Dialog des Wertpapiergeschäfts im Kontoblatt bleibt unverändert: Dort ist der Buchungsbetrag die feste Größe.
|
||||
- Tests: `tests/63_DepotbuchungDritterWertTest.php`, `tests/e2e/specs/depotbuchung.spec.js`.
|
||||
|
||||
## Wertpapiere: Klasse beim Buchen wählen (12.10.2026)
|
||||
|
||||
- Anlass (#125): Beim Buchen von Wertpapieren fehlte die Auswahl einer Klasse.
|
||||
- Die Wertpapiermaske (Dialog im Kontoblatt und Seite `/buchungen/{id}/wertpapier`) hat ein Feld „Klasse“, vorbelegt mit der bisherigen Klasse der Buchung. Die Klasse steht danach an der Buchung und an jeder Splitzeile der Nebenkosten – das Kontoblatt zeigt so die eine Klasse statt „Splitbuchung“. Eine leere Auswahl entfernt die Klasse.
|
||||
- Auch „Depotbuchung erfassen“ unter *Wertpapiere* bietet die Klasse an; sie gilt der dort erzeugten Geldbuchung auf dem Verrechnungskonto.
|
||||
- Eine unbekannte Klasse wird abgewiesen, ohne dass ein Geschäft entsteht.
|
||||
- Tests: `tests/62_WertpapierKlasseTest.php`, `tests/e2e/specs/wertpapiergeschaeft.spec.js`.
|
||||
|
||||
## 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.
|
||||
- Neuer Schalter „automatisch abgleichen“ unter „automatisch buchen“ im Dialog „Neue regelmäßige Buchung“, in der Vorgabe eingeschaltet. Gesetzt markiert er die entstandene Buchung als abgeglichen – beim Fälligkeitslauf ebenso wie beim Buchen von Hand.
|
||||
- Die Übersicht der regelmäßigen Buchungen hat eine neue Spalte „Abgleichen“ zwischen „Auto“ und „Aktiv“, per Klick umschaltbar.
|
||||
- Migration `029_regelmaessig_abgleichen.sql`; bestehende Reihen übernehmen die Vorgabe (eingeschaltet).
|
||||
- Tests: `tests/60_RegelmaessigAbgleichenTest.php`, `tests/e2e/specs/module.spec.js`.
|
||||
|
||||
## Buchungen: bewusst ohne Kategorie (12.10.2026)
|
||||
|
||||
- Anlass (#134): Abschreibungen oder rein rechnerische Solarerträge sollen ohne Kategorie gebucht werden können, ohne immer wieder als offen angemahnt zu werden. Mit Kategorie belastete eine Abschreibung die Auswertung zusätzlich zum gesunkenen Kontowert.
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ Haushalt wird beides parallel genutzt.
|
|||
erfasst, sieht beim Zuweisen einer Kategorie sofort, ob die Buchung dort schon steht:
|
||||
grüner Haken, rotes X oder Ausrufezeichen in einer schmalen Spalte des Kontoblatts.
|
||||
Adresse, Zugangsdaten und die abzugleichenden Kategorien stehen unter
|
||||
*Verwaltung → Bargeld-Tool*.
|
||||
*Verwaltung → Bargeld-Tool*. Sammelbuchungen und Differenzausgleiche lassen sich in der
|
||||
Buchungsmaske bewusst ohne Bargeld-Abgleich buchen.
|
||||
|
||||
Einzelheiten je Modul in `docs/`, die Entwicklung in `Changes.md`.
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ Zwei Ausnahmen führen das Vorzeichen mit, weil sie keine Abschnitte haben: der
|
|||
| Typ | Zeilen | Spalten |
|
||||
|---|---|---|
|
||||
| **Summenbericht** | Zeilen-Summe, nach Einnahmen und Ausgaben getrennt | Zeitraum, je nach Spalten-Aufteilung |
|
||||
| **Buchungsbericht** | Einzelbuchungen, gruppiert nach Zeilen-Summe; bei eingerichtetem Bargeld-Tool mit Spalte „Bar“ und Filter „Bargeld-Tool“ | feste Buchungsspalten |
|
||||
| **Buchungsbericht** | Einzelbuchungen, gruppiert nach Zeilen-Summe; Spalte „✓“ (abgeglichen) mit Filter „Abgeglichen“ (#137); bei eingerichtetem Bargeld-Tool mit Spalte „Bar“ und Filter „Bargeld-Tool“ | feste Buchungsspalten |
|
||||
| **Vergleichsbericht** | wie Summenbericht | Zeitraum, Vorjahreszeitraum, Differenz, Veränderung |
|
||||
| **Kontostandsbericht** | Konten | Salden zu den Enden der Spaltenzeiträume |
|
||||
| **Budgetbericht** | Kategoriebaum | Budget, Ist, Saldo |
|
||||
|
|
@ -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
|
||||
|
|
@ -271,9 +286,9 @@ Einstellungsbereich `bargeld`, der bewusst keinen Reiter im Einstellungsdialog h
|
|||
dafür in `Einstellung::EIGENE_SEITEN` (Test 02 prüft, dass jeder Bereich erreichbar bleibt).
|
||||
|
||||
**Geprüft wird beim Zuweisen einer Kategorie** – aus der Schnellzuordnung des Kontoblatts
|
||||
ebenso wie aus der Buchungsmaske. `Services\BargeldAbgleich::pruefe()` verlangt drei Dinge:
|
||||
ebenso wie aus der Buchungsmaske. `Services\BargeldAbgleich::pruefe()` verlangt vier Dinge:
|
||||
eine Kategorie an der Buchung (keine Umbuchung, keine Splitbuchung), diese Kategorie
|
||||
vorgemerkt, eine Adresse konfiguriert. Fällt eines weg, wird der Status gelöscht – auch beim
|
||||
vorgemerkt, die Buchung nicht als „Ohne Bargeld-Abgleich“ markiert, eine Adresse konfiguriert. Fällt eines weg, wird der Status gelöscht – auch beim
|
||||
Umkategorisieren, sonst bliebe ein alter Haken an einer Buchung stehen, die niemand mehr
|
||||
abgleicht. Gesucht wird über `Services\BargeldTool` mit dem Betrag der Buchung, rückwärts ab
|
||||
ihrem Datum: Bargeld wird ausgegeben, bevor der Umsatz auf dem Konto ankommt, nie danach.
|
||||
|
|
@ -285,6 +300,7 @@ Netz. In der schmalen Spalte zwischen Klasse und Betrag steht:
|
|||
| Symbol | Status | Bedeutung |
|
||||
|---|---|---|
|
||||
| ● grau | `NULL` | Die Buchung wird nicht abgeglichen |
|
||||
| ○ grau | `NULL` | Die Buchung ist bewusst ohne Bargeld-Abgleich gebucht (#136) |
|
||||
| ✓ grün | `gefunden` | Im Bargeld-Tool steht eine Buchung mit demselben Betrag |
|
||||
| ✗ rot | `fehlt` | Dort steht keine |
|
||||
| ! | `fehler` | Der Abruf ist gescheitert |
|
||||
|
|
@ -296,11 +312,20 @@ Adresse hinterlegt, entfällt die Spalte ganz; sie zeigte sonst in jeder Zeile d
|
|||
grauen Kreis.
|
||||
|
||||
Buchungen mit dem Status `fehler` zählen im Cockpit als offener Vorgang „Bargeld-Tool-Prüfung
|
||||
fehlgeschlagen“ (siehe `docs/cockpit.md`) – ein Klick führt auf
|
||||
`/verwaltung/bargeld/fehler`, die kontoübergreifende Liste dieser Buchungen
|
||||
(`Buchung::mitBargeldFehler()`). Buchungen mit dem Status `fehlt` zählen als eigener offener
|
||||
Vorgang „im Bargeld-Tool nicht gefunden“ und führen auf dieselbe Seite mit `?status=fehlt`
|
||||
(`Buchung::mitBargeldFehler('fehlt')`).
|
||||
fehlgeschlagen“ (siehe `docs/cockpit.md`) – ein Klick führt in den Buchungsbericht über den
|
||||
gesamten Zeitraum, gefiltert auf „Bargeld-Tool: Abruf gescheitert“. Buchungen mit dem Status
|
||||
`fehlt` zählen als eigener offener Vorgang „im Bargeld-Tool nicht gefunden“ und führen in
|
||||
denselben Bericht mit dem Filter „nicht gefunden“. Der Bericht zeigt Konto, Kategorie bzw.
|
||||
Gegenkonto und Klasse; die Meldung der Gegenstelle steht im Tooltip der Spalte „Bar“. Eine
|
||||
eigene Liste dafür gibt es nicht mehr (#131).
|
||||
|
||||
**Ohne Bargeld-Abgleich (#136).** Sammelbuchungen und Differenzausgleiche tragen eine
|
||||
vorgemerkte Kategorie, stehen aber absichtlich nicht im Bargeld-Tool. Der Schalter **Ohne
|
||||
Bargeld-Abgleich** in der Buchungsmaske setzt `buchungen.ohne_bargeld_abgleich`; die Buchung
|
||||
wird dann nicht geprüft, ein früherer Status (`fehlt`, `fehler`) wird beim Speichern
|
||||
gelöscht, und sie erscheint nicht mehr unter den offenen Vorgängen im Cockpit. Die
|
||||
Schnellzuordnung lässt den Schalter stehen. Er erscheint nur, wenn eine Adresse hinterlegt
|
||||
ist; ohne Bargeld-Tool trägt die Maske einen gesetzten Wert unsichtbar weiter.
|
||||
|
||||
**Ein Fehlschlag ist ein Ergebnis, kein Abbruch.** Weder ein abgeschaltetes Bargeld-Tool
|
||||
noch ein Netzfehler darf verhindern, dass die Kategorie gespeichert wird; der Abruf bricht
|
||||
|
|
|
|||
|
|
@ -179,13 +179,19 @@ Prüfungen, die gerade nichts melden und daher nicht in der Liste stehen:
|
|||
gesplittet sind. Umbuchungen brauchen keine Kategorie und zählen deshalb nicht mit,
|
||||
ebenso wenig Buchungen, die bewusst ohne Kategorie gebucht sind (siehe `docs/buchungen.md`).
|
||||
* **nicht abgeglichen** – Buchungen aktiver Konten bis heute ohne Abgleich-Kennzeichen.
|
||||
Führt in den Buchungsbericht mit dem Filter „Abgeglichen: nicht abgeglichen“, von der
|
||||
ersten betroffenen Buchung bis heute und mit Umbuchungen; liegen auch auf aufgelösten
|
||||
Konten solche Buchungen, ist der Bericht auf die aktiven Konten eingeschränkt (#137).
|
||||
* **Bargeld-Tool-Prüfung fehlgeschlagen** – Buchungen mit `bargeld_status = 'fehler'`, also
|
||||
einem gescheiterten Abruf beim Bargeld-Tool (siehe `docs/buchungen.md`), nicht zu
|
||||
verwechseln mit „im Bargeld-Tool nicht gefunden“. Führt auf
|
||||
`/verwaltung/bargeld/fehler`, die Liste dieser Buchungen kontoübergreifend
|
||||
(`Buchung::mitBargeldFehler()`), denn die Prüfung hängt an der Kategorie, nicht am Konto.
|
||||
* **Im Bargeld-Tool nicht gefunden** – Buchungen mit `bargeld_status = 'fehlt'`. Führt auf
|
||||
`/verwaltung/bargeld/fehler?status=fehlt` (`Buchung::mitBargeldFehler('fehlt')`).
|
||||
verwechseln mit „im Bargeld-Tool nicht gefunden“. Führt in den Buchungsbericht über den
|
||||
gesamten Zeitraum mit dem Filter „Bargeld-Tool: Abruf gescheitert“ – kontoübergreifend,
|
||||
denn die Prüfung hängt an der Kategorie, nicht am Konto, und samt Spalte Kategorie (#131).
|
||||
* **Im Bargeld-Tool nicht gefunden** – Buchungen mit `bargeld_status = 'fehlt'`. Führt in
|
||||
denselben Buchungsbericht mit dem Filter „Bargeld-Tool: nicht gefunden“
|
||||
(`Cockpit::bargeldBericht()`).
|
||||
Buchungen mit dem Schalter „Ohne Bargeld-Abgleich“ werden nicht geprüft und erscheinen
|
||||
deshalb in keinem der beiden Vorgänge (#136).
|
||||
* **Monatsabschluss steht aus** – der Vorschlag aus `Monatsabschluss::naechsterOffener()`,
|
||||
aber nur, wenn dieser Monat wirklich noch offen ist und Buchungen trägt.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ Zwei Betriebsarten:
|
|||
* **auf Bestätigung** – wird nur gemeldet und wartet auf Knopfdruck. Für alles, dessen
|
||||
Betrag schwankt.
|
||||
|
||||
Unabhängig davon legt **automatisch abgleichen** (Vorgabe: eingeschaltet) fest, ob die
|
||||
entstandene Buchung gleich als abgeglichen markiert wird. Die Übersicht zeigt den Schalter
|
||||
in der Spalte „Abgleichen“.
|
||||
|
||||
Der Lauf ist gutmütig gegenüber langen Pausen: Liegen mehrere Termine zurück, wird **jeder
|
||||
einzeln** gebucht, nicht einer für alle – nur so stimmen die Monatssummen. Nach dem Buchen
|
||||
rückt die Fälligkeit so weit vor, bis sie in der Zukunft liegt.
|
||||
|
|
@ -91,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
|
||||
|
|
@ -131,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
|
||||
|
|
|
|||
|
|
@ -59,6 +59,13 @@ ihrer Kategorie, der Rest als Umbuchung ins Depot. Die Zeilen ergeben genau den
|
|||
Buchungsbetrag. Ohne Nebenkosten bleibt es bei der schlichten Umbuchung – eine
|
||||
Splitbuchung mit einer einzigen Zeile wäre nur umständlicher zu lesen.
|
||||
|
||||
**Klasse.** Die Maske bietet eine Klasse an, vorbelegt mit der bisherigen Klasse der
|
||||
Buchung. Sie gilt für das ganze Geschäft: an der Buchung und an jeder Splitzeile, damit
|
||||
das Kontoblatt keine gemischten Klassen zeigt und die Nebenkosten in der Auswertung nach
|
||||
Klassen nicht fehlen. Eine leere Auswahl entfernt die Klasse. Auch die Maske *Depotbuchung
|
||||
erfassen* unter *Wertpapiere* hat das Feld; dort steht die Klasse an der Geldbuchung auf
|
||||
dem Verrechnungskonto.
|
||||
|
||||
### Kostenarten und ihre Kategorien
|
||||
|
||||
`Services\Wertpapierkosten` kennt sechs Gebühren- und sechs Steuerarten – dieselben, die
|
||||
|
|
@ -101,6 +108,13 @@ enthält.
|
|||
|
||||
`stueck` trägt die Richtung: Zugänge positiv, Abgänge negativ, Dividenden null.
|
||||
|
||||
In der Maske *Depotbuchung erfassen* genügen bei Kauf und Verkauf zwei der drei Angaben
|
||||
Stück, Kurs und Betrag: Sobald zwei eingetragen sind, rechnet `public/static/depotbuchung.js`
|
||||
das dritte aus (Betrag auf zwei, Stück und Kurs auf sechs Nachkommastellen) und hebt es
|
||||
farblich hervor. Ein ausgerechneter Wert wird nachgezogen, wenn sich eines der anderen
|
||||
Felder ändert; tippt man ihn selbst um, gilt er als eingetragen. Ohne JavaScript rechnet
|
||||
`Depotbuchung::dritterWert()` dieselbe Regel beim Speichern.
|
||||
|
||||
## Einstand nach gleitendem Durchschnitt
|
||||
|
||||
Bei jedem Kauf steigt der Einstand um Kaufpreis samt Gebühren, bei jedem Verkauf sinkt er
|
||||
|
|
@ -309,7 +323,8 @@ eigener Wert der Zuordnungsliste, ISIN und WKN aus dem Verwendungszweck, Kauf mi
|
|||
Kauf ohne Kosten als schlichte Umbuchung mit errechnetem Kurs, Verkauf mit Gebühren und
|
||||
Steuern, das Löschen, das die Bankbuchung stehen lässt, sowie die Abweisungen: doppeltes
|
||||
Geschäft, Kosten in Höhe der Buchung, Konto statt Depot, Kauf ohne Stückzahl. Die beiden
|
||||
Dialoge prüft `tests/e2e/specs/wertpapiergeschaeft.spec.js`.
|
||||
Dialoge prüft `tests/e2e/specs/wertpapiergeschaeft.spec.js`, die Klasse des Geschäfts
|
||||
`tests/62_WertpapierKlasseTest.php`.
|
||||
|
||||
`tests/26_WertpapierDetailTest.php` – die Detailseite: der auf ein Papier eingegrenzte
|
||||
Bestand (fremde Papiere bleiben draußen, mehrere Depots werden je Position geführt, Depot
|
||||
|
|
|
|||
11
migrations/029_regelmaessig_abgleichen.sql
Normal file
11
migrations/029_regelmaessig_abgleichen.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
-- Regelmäßige Buchungen: automatisch abgleichen (#123).
|
||||
--
|
||||
-- Neben „automatisch buchen“ bekommt jede Reihe den Schalter „automatisch
|
||||
-- abgleichen“. Ist er gesetzt (Vorgabe), wird die aus der Reihe entstandene
|
||||
-- Buchung gleich als abgeglichen markiert. Bestehende Reihen übernehmen die
|
||||
-- Vorgabe.
|
||||
--
|
||||
-- Idempotent über IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE regelmaessige_buchungen
|
||||
ADD COLUMN IF NOT EXISTS abgleichen TINYINT(1) NOT NULL DEFAULT 1 AFTER automatisch;
|
||||
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;
|
||||
11
migrations/031_ohne_bargeld_abgleich.sql
Normal file
11
migrations/031_ohne_bargeld_abgleich.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
-- Bewusst ohne Abgleich mit dem Bargeld-Tool buchen (#136).
|
||||
--
|
||||
-- Sammelbuchungen oder Differenzausgleiche tragen eine Kategorie, die gegen
|
||||
-- das Bargeld-Tool geprüft wird, stehen dort aber absichtlich nicht. Der
|
||||
-- Schalter in der Buchungsmaske nimmt solche Buchungen aus dem Abgleich; sie
|
||||
-- erscheinen danach nicht mehr als offener Vorgang im Cockpit.
|
||||
--
|
||||
-- Idempotent über IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE buchungen
|
||||
ADD COLUMN IF NOT EXISTS ohne_bargeld_abgleich TINYINT(1) NOT NULL DEFAULT 0 AFTER bewusst_ohne_kategorie;
|
||||
|
|
@ -139,7 +139,6 @@ $router->post('/verwaltung/kontengruppen/{id}/loeschen',
|
|||
$router->get('/verwaltung/bargeld', fn() => (new VerwaltungController())->bargeld());
|
||||
$router->post('/verwaltung/bargeld', fn() => (new VerwaltungController())->bargeldSpeichern());
|
||||
$router->post('/verwaltung/bargeld/test', fn() => (new VerwaltungController())->bargeldTest());
|
||||
$router->get('/verwaltung/bargeld/fehler', fn() => (new VerwaltungController())->bargeldFehler());
|
||||
|
||||
// Das Bankleitzahlen-Verzeichnis: nachschlagen und die Institutsliste einlesen.
|
||||
$router->get('/verwaltung/bankinstitute', fn() => (new BankinstituteController())->index());
|
||||
|
|
|
|||
|
|
@ -1412,17 +1412,21 @@ table.kostentabelle select { max-width: 16rem; width: 100%; }
|
|||
table.kostentabelle th[scope="row"] { font-weight: 400; text-align: left; }
|
||||
table.kostentabelle .gruppenzeile th { padding-top: .7rem; font-weight: 600; }
|
||||
|
||||
/* Die Abgleichspalte zwischen Klasse und Betrag: so schmal wie ihr Zeichen.
|
||||
Sie erscheint nur, wenn ein Bargeld-Tool eingerichtet ist. */
|
||||
/* Die Abgleichspalten zwischen Klasse und Betrag: so schmal wie ihr Zeichen.
|
||||
„✓“ (abgeglichen) steht immer davor, „Bar“ erscheint nur, wenn ein
|
||||
Bargeld-Tool eingerichtet ist. */
|
||||
table.liste th.bargeldspalte,
|
||||
table.liste td.bargeldspalte {
|
||||
table.liste td.bargeldspalte,
|
||||
table.liste th.abgleichspalte,
|
||||
table.liste td.abgleichspalte {
|
||||
width: 1.8rem;
|
||||
padding-left: .2rem;
|
||||
padding-right: .2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
table.liste th.bargeldspalte {
|
||||
table.liste th.bargeldspalte,
|
||||
table.liste th.abgleichspalte {
|
||||
font-size: .78rem;
|
||||
color: var(--text-gedaempft);
|
||||
}
|
||||
|
|
@ -1622,6 +1626,10 @@ table.liste th.bargeldspalte {
|
|||
margin: .15rem 0 0;
|
||||
}
|
||||
|
||||
/* Ein Feld, dessen Wert die Maske aus den anderen ausgerechnet hat
|
||||
* (Stück, Kurs, Betrag in „Depotbuchung erfassen“). */
|
||||
input.berechnet { background: var(--akzent-schwach); }
|
||||
|
||||
/* Der Hinweis hinter einem Ankreuzfeld statt darunter – bei einer Liste
|
||||
gleichartiger Schalter hielte eine eigene Zeile je Hinweis sie zu weit
|
||||
auseinander. */
|
||||
|
|
@ -1778,12 +1786,23 @@ body.schlicht {
|
|||
max-width: 400px;
|
||||
}
|
||||
|
||||
.marke.gross {
|
||||
/* Die Marke der Anmeldung gleicht der Seitenleiste: Symbol, Schriftschnitt und
|
||||
zweite Worthälfte in Akzentfarbe. Weiß auf der hellen Karte wäre unsichtbar,
|
||||
deshalb trägt die erste Hälfte die Textfarbe und die zweite die lesbare
|
||||
Variante des Akzents. */
|
||||
.anmelde-marke {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .6rem;
|
||||
padding: 0 0 1.25rem;
|
||||
font-size: 1.3rem;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
letter-spacing: .01em;
|
||||
}
|
||||
|
||||
.anmelde-marke .marken-text { color: var(--text); }
|
||||
.anmelde-marke .marken-text span { color: var(--akzent-schrift); }
|
||||
|
||||
/* ── Auswertungen ────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Aufklappmenü ohne JavaScript: <details> in der Aktionsleiste. Ein eigenes
|
||||
|
|
|
|||
111
public/static/depotbuchung.js
Normal file
111
public/static/depotbuchung.js
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/* Stück, Kurs und Betrag in der Maske „Depotbuchung erfassen“.
|
||||
*
|
||||
* Bei Kauf und Verkauf gilt Betrag = Stück × Kurs: Sobald zwei der drei Felder
|
||||
* eingetragen sind, ergibt sich das dritte. Der ausgerechnete Wert bleibt
|
||||
* ausgerechnet – ändert man danach eines der beiden anderen Felder, wird er
|
||||
* nachgezogen. Tippt man ihn selbst um, gilt er als eingetragen; leert man eines
|
||||
* der beiden anderen Felder, verschwindet er wieder, weil nichts mehr dahinter
|
||||
* steht.
|
||||
*
|
||||
* Der Server rechnet dieselbe Regel noch einmal (Depotbuchung::dritterWert) –
|
||||
* für den Weg ohne JavaScript.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const formular = document.getElementById('depotbuchungsformular');
|
||||
if (!formular || !window.FmBetrag) {
|
||||
return;
|
||||
}
|
||||
|
||||
const vorgang = document.getElementById('vorgang');
|
||||
const felder = {
|
||||
stueck: document.getElementById('stueck'),
|
||||
kurs: document.getElementById('kurs'),
|
||||
betrag: document.getElementById('betrag')
|
||||
};
|
||||
if (!vorgang || !felder.stueck || !felder.kurs || !felder.betrag) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Welches Feld zuletzt ausgerechnet wurde – null, solange alle eingetragen sind.
|
||||
let berechnet = null;
|
||||
|
||||
function wert(name) {
|
||||
return window.FmBetrag.zahl(felder[name].value);
|
||||
}
|
||||
|
||||
function leer(name) {
|
||||
return felder[name].value.trim() === '';
|
||||
}
|
||||
|
||||
function formatiere(name, zahl) {
|
||||
if (name === 'betrag') {
|
||||
return window.FmBetrag.formatiere(zahl);
|
||||
}
|
||||
// Stück und Kurs führt die Datenbank mit sechs Nachkommastellen.
|
||||
return zahl.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 6 });
|
||||
}
|
||||
|
||||
function setze(name, zahl) {
|
||||
felder[name].value = zahl === null ? '' : formatiere(name, zahl);
|
||||
felder[name].classList.toggle('berechnet', zahl !== null);
|
||||
berechnet = zahl === null ? null : name;
|
||||
}
|
||||
|
||||
function ausrechnen(name) {
|
||||
const stueck = wert('stueck');
|
||||
const kurs = wert('kurs');
|
||||
const betrag = wert('betrag');
|
||||
|
||||
switch (name) {
|
||||
case 'betrag':
|
||||
return stueck > 0 && kurs > 0 ? Math.round(stueck * kurs * 100) / 100 : null;
|
||||
case 'kurs':
|
||||
return stueck > 0 && betrag > 0 ? Math.round(betrag / stueck * 1e6) / 1e6 : null;
|
||||
default:
|
||||
return kurs > 0 && betrag > 0 ? Math.round(betrag / kurs * 1e6) / 1e6 : null;
|
||||
}
|
||||
}
|
||||
|
||||
function rechne(geaendert) {
|
||||
if (geaendert !== null && geaendert === berechnet) {
|
||||
felder[geaendert].classList.remove('berechnet');
|
||||
berechnet = null;
|
||||
}
|
||||
if (vorgang.value !== 'kauf' && vorgang.value !== 'verkauf') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (geaendert !== null && leer(geaendert) && berechnet !== null) {
|
||||
setze(berechnet, null);
|
||||
return;
|
||||
}
|
||||
|
||||
let ziel = berechnet;
|
||||
if (ziel === null) {
|
||||
const offen = Object.keys(felder).filter(leer);
|
||||
if (offen.length !== 1) {
|
||||
return;
|
||||
}
|
||||
ziel = offen[0];
|
||||
}
|
||||
|
||||
const ergebnis = ausrechnen(ziel);
|
||||
if (ergebnis !== null) {
|
||||
setze(ziel, ergebnis);
|
||||
} else if (ziel === berechnet) {
|
||||
setze(ziel, null);
|
||||
}
|
||||
}
|
||||
|
||||
Object.keys(felder).forEach(function (name) {
|
||||
felder[name].addEventListener('input', function () {
|
||||
rechne(name);
|
||||
});
|
||||
});
|
||||
vorgang.addEventListener('change', function () {
|
||||
rechne(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;
|
||||
|
|
|
|||
|
|
@ -168,6 +168,13 @@
|
|||
document.getElementById('wp_isin').value = isin;
|
||||
document.getElementById('wp_wkn').value = wkn;
|
||||
|
||||
// Die Klasse der Buchung steht schon in ihrer Zeile – sie ist die
|
||||
// Vorgabe für das Geschäft.
|
||||
const zeile = feld.closest('tr');
|
||||
const klassenfeld = zeile && zeile.querySelector('a.klassenfeld');
|
||||
const klasse = klassenfeld ? (klassenfeld.dataset.klasse || '') : '';
|
||||
document.getElementById('wp_klasse').value = klasse === '0' ? '' : klasse;
|
||||
|
||||
papierWaehlen(isin, wkn);
|
||||
vorgaengeStellen();
|
||||
rechne();
|
||||
|
|
|
|||
|
|
@ -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'] ?? ''),
|
||||
|
|
|
|||
|
|
@ -257,7 +257,8 @@ class BuchungenController extends BaseController
|
|||
$frisch = Buchung::find((int)$buchung['id']);
|
||||
$bargeldHtml = View::bargeldMarke(
|
||||
$frisch['bargeld_status'] ?? null,
|
||||
$frisch['bargeld_hinweis'] ?? null
|
||||
$frisch['bargeld_hinweis'] ?? null,
|
||||
!empty($frisch['ohne_bargeld_abgleich'])
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -392,6 +393,7 @@ class BuchungenController extends BaseController
|
|||
'depot' => $depot,
|
||||
'wertpapiere' => Wertpapier::alle(true),
|
||||
'kategorien' => Kategorie::baumFlach(),
|
||||
'klassen' => Klasse::alle(),
|
||||
'vorschlag' => Wertpapier::ausText((string)$buchung['buchungstext'] . ' ' . (string)$buchung['empfaenger']),
|
||||
]);
|
||||
}
|
||||
|
|
@ -464,6 +466,7 @@ class BuchungenController extends BaseController
|
|||
'notiz' => $this->post('notiz'),
|
||||
'kosten' => $kosten,
|
||||
'kosten_sonstige_kategorie' => (int)$this->post('kosten_sonstige_kategorie') ?: null,
|
||||
'klasse_id' => (int)$this->post('klasse_id') ?: null,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -612,6 +615,9 @@ class BuchungenController extends BaseController
|
|||
'kategorien' => Kategorie::baumFlach(),
|
||||
'klassen' => Klasse::alle(),
|
||||
'konten' => Konto::alle(),
|
||||
// Der Schalter „Ohne Bargeld-Abgleich“ erscheint nur, wenn es ein
|
||||
// Bargeld-Tool gibt (#136).
|
||||
'bargeld' => BargeldAbgleich::eingerichtet(),
|
||||
];
|
||||
if ($fehler !== '') {
|
||||
$daten['flash'] = [['typ' => 'fehler', 'meldung' => $fehler]];
|
||||
|
|
@ -651,6 +657,7 @@ class BuchungenController extends BaseController
|
|||
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
|
||||
'quelle' => 'manuell',
|
||||
'bewusst_ohne_kategorie' => isset($_POST['bewusst_ohne_kategorie']),
|
||||
'ohne_bargeld_abgleich' => isset($_POST['ohne_bargeld_abgleich']),
|
||||
];
|
||||
|
||||
return [$daten, $this->splitzeilen()];
|
||||
|
|
@ -692,6 +699,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,21 +187,26 @@ 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'),
|
||||
'ende' => $this->post('ende'),
|
||||
'naechste_faelligkeit' => $this->post('naechste_faelligkeit'),
|
||||
'automatisch' => isset($_POST['automatisch']) ? 1 : 0,
|
||||
'abgleichen' => isset($_POST['abgleichen']) ? 1 : 0,
|
||||
'vorlauf_tage' => $this->post('vorlauf_tage', '7'),
|
||||
'aktiv' => isset($_POST['aktiv']) ? 1 : 0,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ namespace Finanzmanager\Controllers;
|
|||
|
||||
use Finanzmanager\Models\Adresse;
|
||||
use Finanzmanager\Models\Bankinstitut;
|
||||
use Finanzmanager\Models\Buchung;
|
||||
use Finanzmanager\Models\Einstellung;
|
||||
use Finanzmanager\Models\Erinnerung;
|
||||
use Finanzmanager\Models\Kategorie;
|
||||
|
|
@ -278,26 +277,6 @@ class VerwaltungController extends BaseController
|
|||
View::redirect('/verwaltung/bargeld', $ergebnis['meldung'], $ergebnis['erfolg'] ? 'erfolg' : 'fehler');
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Buchungen, an denen die Bargeld-Tool-Prüfung zuletzt einen Fehler
|
||||
* festgestellt hat – der Weg, den die Kachel „Offene Vorgänge“ des
|
||||
* Cockpit anbietet, sobald es solche Buchungen gibt.
|
||||
*/
|
||||
public function bargeldFehler(): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$fehlt = ($_GET['status'] ?? '') === 'fehlt';
|
||||
|
||||
$this->render('verwaltung/bargeld_fehler.php', [
|
||||
'titel' => $fehlt
|
||||
? 'Bargeld-Tool: nicht gefundene Buchungen'
|
||||
: 'Bargeld-Tool: fehlgeschlagene Prüfungen',
|
||||
'fehlt' => $fehlt,
|
||||
'buchungen' => Buchung::mitBargeldFehler($fehlt ? 'fehlt' : 'fehler'),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Die gesamte Datenbank als Download. */
|
||||
public function sicherungHerunterladen(): void
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ namespace Finanzmanager\Controllers;
|
|||
use Finanzmanager\Models\Bankzugang;
|
||||
use Finanzmanager\Models\Depotbuchung;
|
||||
use Finanzmanager\Models\Einstellung;
|
||||
use Finanzmanager\Models\Klasse;
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\Models\Wertpapier;
|
||||
use Finanzmanager\Services\Depot;
|
||||
|
|
@ -117,6 +118,7 @@ class WertpapiereController extends BaseController
|
|||
'depots' => Depot::depots(true),
|
||||
'wertpapiere' => Wertpapier::alle(true),
|
||||
'konten' => Konto::alle(true),
|
||||
'klassen' => Klasse::alle(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -139,27 +141,32 @@ class WertpapiereController extends BaseController
|
|||
]);
|
||||
}
|
||||
|
||||
$stueck = $this->zahl($this->post('stueck'));
|
||||
$kurs = $this->zahl($this->post('kurs'));
|
||||
$betrag = $this->zahl($this->post('betrag'));
|
||||
|
||||
// Zwei von drei Angaben genügen: Der dritte Wert ergibt sich.
|
||||
if ($betrag <= 0.0 && $stueck > 0.0 && $kurs > 0.0) {
|
||||
$betrag = round($stueck * $kurs, 2);
|
||||
}
|
||||
$vorgang = $this->post('vorgang', 'kauf');
|
||||
[
|
||||
'stueck' => $stueck,
|
||||
'kurs' => $kurs,
|
||||
'betrag' => $betrag,
|
||||
] = Depotbuchung::dritterWert(
|
||||
$vorgang,
|
||||
$this->zahl($this->post('stueck')),
|
||||
$this->zahl($this->post('kurs')),
|
||||
$this->zahl($this->post('betrag'))
|
||||
);
|
||||
|
||||
Depotbuchung::anlegen([
|
||||
'konto_id' => (int)$this->post('konto_id'),
|
||||
'wertpapier_id' => $wertpapierId,
|
||||
'datum' => $this->post('datum'),
|
||||
'valuta' => $this->post('valuta'),
|
||||
'vorgang' => $this->post('vorgang', 'kauf'),
|
||||
'vorgang' => $vorgang,
|
||||
'stueck' => $stueck,
|
||||
'kurs' => $kurs > 0.0 ? $kurs : null,
|
||||
'betrag' => $betrag,
|
||||
'gebuehren' => $this->zahl($this->post('gebuehren')),
|
||||
'steuern' => $this->zahl($this->post('steuern')),
|
||||
'verrechnungskonto_id' => $this->post('verrechnungskonto_id'),
|
||||
'klasse_id' => (int)$this->post('klasse_id') ?: null,
|
||||
'notiz' => $this->post('notiz'),
|
||||
'wertpapiername' => $this->post('wertpapier_name') ?: null,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -106,15 +106,15 @@ class Buchung
|
|||
'INSERT INTO buchungen
|
||||
(benutzer_id, konto_id, datum, valuta, betrag, empfaenger, iban, bankname, buchungstext,
|
||||
nummer, abgeglichen, kategorie_id, klasse_id, gegenkonto_id, gesplittet,
|
||||
bewusst_ohne_kategorie, quelle, import_hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
bewusst_ohne_kategorie, ohne_bargeld_abgleich, quelle, import_hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
Mandant::id(), $felder['konto_id'], $felder['datum'], $felder['valuta'],
|
||||
$felder['betrag'], $felder['empfaenger'], $felder['iban'], $felder['bankname'],
|
||||
$felder['buchungstext'], $felder['nummer'],
|
||||
$felder['abgeglichen'], $felder['kategorie_id'], $felder['klasse_id'],
|
||||
$felder['gegenkonto_id'], $felder['gesplittet'], $felder['bewusst_ohne_kategorie'],
|
||||
$felder['quelle'], $felder['import_hash'],
|
||||
$felder['ohne_bargeld_abgleich'], $felder['quelle'], $felder['import_hash'],
|
||||
]
|
||||
);
|
||||
$id = Database::lastInsertId();
|
||||
|
|
@ -165,14 +165,15 @@ class Buchung
|
|||
Database::execute(
|
||||
'UPDATE buchungen SET konto_id = ?, datum = ?, valuta = ?, betrag = ?, empfaenger = ?,
|
||||
buchungstext = ?, nummer = ?, abgeglichen = ?, kategorie_id = ?, klasse_id = ?,
|
||||
gegenkonto_id = ?, gesplittet = ?, bewusst_ohne_kategorie = ?
|
||||
gegenkonto_id = ?, gesplittet = ?, bewusst_ohne_kategorie = ?,
|
||||
ohne_bargeld_abgleich = ?
|
||||
WHERE id = ? AND benutzer_id = ?',
|
||||
[
|
||||
$felder['konto_id'], $felder['datum'], $felder['valuta'], $felder['betrag'],
|
||||
$felder['empfaenger'], $felder['buchungstext'], $felder['nummer'],
|
||||
$felder['abgeglichen'], $felder['kategorie_id'], $felder['klasse_id'],
|
||||
$felder['gegenkonto_id'], $felder['gesplittet'], $felder['bewusst_ohne_kategorie'],
|
||||
$id, Mandant::id(),
|
||||
$felder['ohne_bargeld_abgleich'], $id, Mandant::id(),
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -437,7 +438,7 @@ class Buchung
|
|||
$sql = 'SELECT * FROM (
|
||||
SELECT b.id, b.datum, b.valuta, b.betrag, b.empfaenger, b.buchungstext, b.nummer,
|
||||
b.abgeglichen, b.kategorie_id, b.klasse_id, b.gesplittet, b.quelle,
|
||||
b.bargeld_status, b.bargeld_hinweis, b.bewusst_ohne_kategorie,
|
||||
b.bargeld_status, b.bargeld_hinweis, b.bewusst_ohne_kategorie, b.ohne_bargeld_abgleich,
|
||||
b.gegenkonto_id, b.konto_id, 0 AS gegensicht, NULL AS split_id,
|
||||
b.iban, b.bankname
|
||||
FROM buchungen b
|
||||
|
|
@ -445,7 +446,7 @@ class Buchung
|
|||
UNION ALL
|
||||
SELECT b.id, b.datum, b.valuta, -b.betrag, b.empfaenger, b.buchungstext, b.nummer,
|
||||
b.abgeglichen, b.kategorie_id, b.klasse_id, b.gesplittet, b.quelle,
|
||||
NULL AS bargeld_status, NULL AS bargeld_hinweis, 0 AS bewusst_ohne_kategorie,
|
||||
NULL AS bargeld_status, NULL AS bargeld_hinweis, 0 AS bewusst_ohne_kategorie, 0 AS ohne_bargeld_abgleich,
|
||||
b.konto_id AS gegenkonto_id, b.konto_id, 1 AS gegensicht, NULL AS split_id,
|
||||
\'\' AS iban, \'\' AS bankname
|
||||
FROM buchungen b
|
||||
|
|
@ -453,7 +454,7 @@ class Buchung
|
|||
UNION ALL
|
||||
SELECT b.id, b.datum, b.valuta, -s.betrag, b.empfaenger, s.notiz, b.nummer,
|
||||
b.abgeglichen, s.kategorie_id, s.klasse_id, 0 AS gesplittet, b.quelle,
|
||||
NULL AS bargeld_status, NULL AS bargeld_hinweis, 0 AS bewusst_ohne_kategorie,
|
||||
NULL AS bargeld_status, NULL AS bargeld_hinweis, 0 AS bewusst_ohne_kategorie, 0 AS ohne_bargeld_abgleich,
|
||||
b.konto_id AS gegenkonto_id, b.konto_id, 1 AS gegensicht, s.id AS split_id,
|
||||
\'\' AS iban, \'\' AS bankname
|
||||
FROM buchung_splits s
|
||||
|
|
@ -510,28 +511,6 @@ class Buchung
|
|||
return $zeilen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Buchungen, an denen die Bargeld-Tool-Prüfung zuletzt einen Fehler
|
||||
* ('fehler') oder keinen Treffer ('fehlt') ergeben hat – kontoübergreifend, denn die Prüfung hängt an der
|
||||
* Kategorie der Buchung, nicht an ihrem Konto. Der Status 'fehler' steht
|
||||
* für einen gescheiterten Abruf (siehe Services\BargeldAbgleich::FEHLER),
|
||||
* 'fehlt' für eine im Bargeld-Tool nicht gefundene Buchung.
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public static function mitBargeldFehler(string $status = 'fehler'): array
|
||||
{
|
||||
return Database::fetchAll(
|
||||
'SELECT b.id, b.datum, b.betrag, b.empfaenger, b.buchungstext,
|
||||
b.bargeld_hinweis, b.bargeld_geprueft_am, b.konto_id, k.name AS konto_name
|
||||
FROM buchungen b
|
||||
JOIN konten k ON k.id = b.konto_id
|
||||
WHERE b.benutzer_id = ? AND b.bargeld_status = ?
|
||||
ORDER BY b.datum DESC, b.id DESC',
|
||||
[Mandant::id(), $status === 'fehlt' ? 'fehlt' : 'fehler']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Salden aller Konten in einem Rutsch: Anfangssaldo plus Buchungen, plus die
|
||||
* Gegenseiten fremder Umbuchungen. Ohne Stichtag zählt alles (erwarteter
|
||||
|
|
@ -828,6 +807,9 @@ class Buchung
|
|||
'gegenkonto_id' => $gegenkonto,
|
||||
'gesplittet' => $gesplittet ? 1 : 0,
|
||||
'bewusst_ohne_kategorie' => $bewusstOhne ? 1 : 0,
|
||||
// Ohne Bargeld-Abgleich (#136): Die Buchung wird nicht gegen das
|
||||
// Bargeld-Tool geprüft und deshalb auch nicht als offen angemahnt.
|
||||
'ohne_bargeld_abgleich' => !empty($daten['ohne_bargeld_abgleich']) ? 1 : 0,
|
||||
'quelle' => isset(self::QUELLEN[(string)($daten['quelle'] ?? '')])
|
||||
? (string)$daten['quelle'] : 'manuell',
|
||||
'import_hash' => ($daten['import_hash'] ?? null) ?: null,
|
||||
|
|
|
|||
|
|
@ -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 !== '') {
|
||||
|
|
|
|||
|
|
@ -69,6 +69,32 @@ class Depotbuchung
|
|||
return self::QIF_VORGAENGE[trim($vorgang)] ?? 'kauf';
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwei von drei Angaben genügen: Betrag = Stück × Kurs.
|
||||
*
|
||||
* Die Maske „Depotbuchung erfassen“ rechnet den dritten Wert schon im
|
||||
* Browser aus (`depotbuchung.js`); hier steht dieselbe Regel für den Weg
|
||||
* ohne JavaScript. Der Betrag ergibt sich bei jedem Vorgang, Kurs und Stück
|
||||
* nur bei Kauf und Verkauf – nur dort hängen die drei sicher zusammen.
|
||||
* Eine fehlende Angabe ist 0.
|
||||
*
|
||||
* @return array{stueck:float,kurs:float,betrag:float}
|
||||
*/
|
||||
public static function dritterWert(string $vorgang, float $stueck, float $kurs, float $betrag): array
|
||||
{
|
||||
if ($betrag <= 0.0 && $stueck > 0.0 && $kurs > 0.0) {
|
||||
$betrag = round($stueck * $kurs, 2);
|
||||
} elseif (in_array($vorgang, ['kauf', 'verkauf'], true) && $betrag > 0.0) {
|
||||
if ($kurs <= 0.0 && $stueck > 0.0) {
|
||||
$kurs = round($betrag / $stueck, 6);
|
||||
} elseif ($stueck <= 0.0 && $kurs > 0.0) {
|
||||
$stueck = round($betrag / $kurs, 6);
|
||||
}
|
||||
}
|
||||
|
||||
return ['stueck' => $stueck, 'kurs' => $kurs, 'betrag' => $betrag];
|
||||
}
|
||||
|
||||
public static function find(int $id): ?array
|
||||
{
|
||||
$zeile = Database::fetchOne(
|
||||
|
|
@ -172,7 +198,14 @@ class Depotbuchung
|
|||
{
|
||||
$f = self::normalisiere($daten);
|
||||
|
||||
return (int)Database::transaktion(static function () use ($f, $daten): int {
|
||||
// Die Klasse gilt der Geldbuchung – sie ist die Seite, die in den
|
||||
// Auswertungen nach Klassen auftaucht.
|
||||
$klasseId = (int)($daten['klasse_id'] ?? 0) ?: null;
|
||||
if ($klasseId !== null && Klasse::find($klasseId) === null) {
|
||||
throw new \InvalidArgumentException('Die Klasse gibt es nicht.');
|
||||
}
|
||||
|
||||
return (int)Database::transaktion(static function () use ($f, $daten, $klasseId): int {
|
||||
$buchungId = $f['buchung_id'];
|
||||
$erzeugt = 0;
|
||||
|
||||
|
|
@ -186,6 +219,7 @@ class Depotbuchung
|
|||
'empfaenger' => mb_substr((string)($daten['wertpapiername'] ?? 'Wertpapiergeschäft'), 0, 255),
|
||||
'buchungstext' => (string)$f['notiz'],
|
||||
'kategorie_id' => ($daten['kategorie_id'] ?? null) ?: null,
|
||||
'klasse_id' => $klasseId,
|
||||
'quelle' => (string)$f['quelle'],
|
||||
'abgeglichen' => !empty($daten['abgeglichen']) ? 1 : 0,
|
||||
// Eigener Hash für die Geldseite: Er erkennt Buchungen
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ use Finanzmanager\Mandant;
|
|||
* ansteht, bucht es und schreibt das Datum fort. So braucht es keine Tabelle
|
||||
* vorausberechneter Termine, die bei jeder Änderung neu zu erzeugen wäre – der
|
||||
* Finanzkalender rechnet die kommenden Termine bei Bedarf aus.
|
||||
*
|
||||
* Mit „automatisch abgleichen“ (Vorgabe) ist die entstandene Buchung gleich
|
||||
* als abgeglichen markiert.
|
||||
*/
|
||||
class RegelmaessigeBuchung
|
||||
{
|
||||
|
|
@ -95,13 +98,13 @@ class RegelmaessigeBuchung
|
|||
'INSERT INTO regelmaessige_buchungen
|
||||
(benutzer_id, name, konto_id, betrag, empfaenger, buchungstext, kategorie_id,
|
||||
klasse_id, gegenkonto_id, splits, intervall, faktor, beginn, ende,
|
||||
naechste_faelligkeit, automatisch, vorlauf_tage, aktiv)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
naechste_faelligkeit, automatisch, abgleichen, vorlauf_tage, aktiv)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
Mandant::id(), $f['name'], $f['konto_id'], $f['betrag'], $f['empfaenger'],
|
||||
$f['buchungstext'], $f['kategorie_id'], $f['klasse_id'], $f['gegenkonto_id'],
|
||||
$f['splits'], $f['intervall'], $f['faktor'], $f['beginn'], $f['ende'],
|
||||
$f['beginn'], $f['automatisch'], $f['vorlauf_tage'], $f['aktiv'],
|
||||
$f['beginn'], $f['automatisch'], $f['abgleichen'], $f['vorlauf_tage'], $f['aktiv'],
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -137,13 +140,13 @@ class RegelmaessigeBuchung
|
|||
'UPDATE regelmaessige_buchungen SET name = ?, konto_id = ?, betrag = ?, empfaenger = ?,
|
||||
buchungstext = ?, kategorie_id = ?, klasse_id = ?, gegenkonto_id = ?, splits = ?,
|
||||
intervall = ?, faktor = ?, beginn = ?, ende = ?, naechste_faelligkeit = ?,
|
||||
automatisch = ?, vorlauf_tage = ?, aktiv = ?
|
||||
automatisch = ?, abgleichen = ?, vorlauf_tage = ?, aktiv = ?
|
||||
WHERE id = ? AND benutzer_id = ?',
|
||||
[
|
||||
$f['name'], $f['konto_id'], $f['betrag'], $f['empfaenger'], $f['buchungstext'],
|
||||
$f['kategorie_id'], $f['klasse_id'], $f['gegenkonto_id'], $f['splits'],
|
||||
$f['intervall'], $f['faktor'], $f['beginn'], $f['ende'], $naechste,
|
||||
$f['automatisch'], $f['vorlauf_tage'], $f['aktiv'], $id, Mandant::id(),
|
||||
$f['automatisch'], $f['abgleichen'], $f['vorlauf_tage'], $f['aktiv'], $id, Mandant::id(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
@ -194,6 +197,7 @@ class RegelmaessigeBuchung
|
|||
'kategorie_id' => $splits === [] ? $reihe['kategorie_id'] : null,
|
||||
'klasse_id' => $reihe['klasse_id'],
|
||||
'gegenkonto_id' => $splits === [] ? $reihe['gegenkonto_id'] : null,
|
||||
'abgeglichen' => (int)$reihe['abgleichen'] === 1 ? 1 : 0,
|
||||
],
|
||||
$splits
|
||||
);
|
||||
|
|
@ -383,6 +387,8 @@ class RegelmaessigeBuchung
|
|||
'beginn' => $beginn,
|
||||
'ende' => $ende,
|
||||
'automatisch' => !empty($daten['automatisch']) ? 1 : 0,
|
||||
// Vorgabe eingeschaltet: Fehlt der Schlüssel, wird abgeglichen.
|
||||
'abgleichen' => array_key_exists('abgleichen', $daten) && empty($daten['abgleichen']) ? 0 : 1,
|
||||
'vorlauf_tage' => max(0, min(365, (int)($daten['vorlauf_tage'] ?? 7))),
|
||||
'aktiv' => array_key_exists('aktiv', $daten) && empty($daten['aktiv']) ? 0 : 1,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,9 @@ class Auswertung
|
|||
/** Zulässige Werte des Bargeld-Filters; 'keiner' = nicht abgeglichen. */
|
||||
public const BARGELD_FILTER = ['gefunden', 'fehlt', 'fehler', 'keiner'];
|
||||
|
||||
/** Zulässige Werte des Abgleich-Filters im Buchungsbericht (#137). */
|
||||
public const ABGEGLICHEN_FILTER = ['ja', 'nein'];
|
||||
|
||||
/** Wonach die Zeilen zusammengefasst werden. */
|
||||
public const ZEILENSUMMEN = [
|
||||
'kategorie' => 'Kategorie',
|
||||
|
|
@ -115,12 +118,22 @@ 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.
|
||||
'bargeld' => $typ === 'buchungsbericht' && in_array($roh['bargeld'] ?? '', self::BARGELD_FILTER, true)
|
||||
? (string)$roh['bargeld']
|
||||
: '',
|
||||
// Ebenso nur dort: der Haken „abgeglichen“ der Buchung (#137).
|
||||
'abgeglichen' => $typ === 'buchungsbericht' && in_array($roh['abgeglichen'] ?? '', self::ABGEGLICHEN_FILTER, true)
|
||||
? (string)$roh['abgeglichen']
|
||||
: '',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -835,6 +848,8 @@ class Auswertung
|
|||
'betrag' => round((float)$zeile['betrag'], 2),
|
||||
'bargeld_status' => $zeile['bargeld_status'] ?? null,
|
||||
'bargeld_hinweis' => $zeile['bargeld_hinweis'] ?? null,
|
||||
'ohne_bargeld_abgleich' => !empty($zeile['ohne_bargeld_abgleich']),
|
||||
'abgeglichen' => !empty($zeile['abgeglichen']),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -1187,13 +1202,15 @@ class Auswertung
|
|||
$sql = 'SELECT * FROM (
|
||||
SELECT b.id AS buchung_id, NULL AS split_id, b.datum, b.betrag, b.konto_id,
|
||||
b.kategorie_id, b.klasse_id, b.gegenkonto_id, b.empfaenger,
|
||||
b.buchungstext AS text, b.bargeld_status, b.bargeld_hinweis
|
||||
b.buchungstext AS text, b.bargeld_status, b.bargeld_hinweis,
|
||||
b.ohne_bargeld_abgleich, b.abgeglichen
|
||||
FROM buchungen b
|
||||
WHERE b.benutzer_id = ? AND b.gesplittet = 0 AND b.datum BETWEEN ? AND ?
|
||||
UNION ALL
|
||||
SELECT b.id AS buchung_id, s.id AS split_id, b.datum, s.betrag, b.konto_id,
|
||||
s.kategorie_id, s.klasse_id, s.gegenkonto_id, b.empfaenger,
|
||||
s.notiz AS text, b.bargeld_status, b.bargeld_hinweis
|
||||
s.notiz AS text, b.bargeld_status, b.bargeld_hinweis,
|
||||
b.ohne_bargeld_abgleich, b.abgeglichen
|
||||
FROM buchung_splits s
|
||||
JOIN buchungen b ON b.id = s.buchung_id
|
||||
WHERE b.benutzer_id = ? AND b.gesplittet = 1 AND b.datum BETWEEN ? AND ?
|
||||
|
|
@ -1203,8 +1220,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 +1237,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']) . ')';
|
||||
|
|
@ -1231,6 +1266,11 @@ class Auswertung
|
|||
$params[] = $bargeld;
|
||||
}
|
||||
|
||||
$abgeglichen = (string)($filter['abgeglichen'] ?? '');
|
||||
if ($abgeglichen !== '') {
|
||||
$wo[] = $abgeglichen === 'ja' ? 'abgeglichen = 1' : 'abgeglichen = 0';
|
||||
}
|
||||
|
||||
if (($filter['suche'] ?? '') !== '') {
|
||||
$wo[] = '(empfaenger LIKE ? OR text LIKE ?)';
|
||||
$muster = '%' . $filter['suche'] . '%';
|
||||
|
|
@ -1443,6 +1483,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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -200,6 +200,7 @@ class Bankabgleich
|
|||
'kategorie_id' => $zuordnung['kategorie_id'],
|
||||
'klasse_id' => $zuordnung['klasse_id'],
|
||||
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
|
||||
'ohne_bargeld_abgleich' => $buchung['ohne_bargeld_abgleich'] ?? 0,
|
||||
],
|
||||
$zuordnung['splits']
|
||||
);
|
||||
|
|
|
|||
|
|
@ -23,9 +23,12 @@ use Finanzmanager\Models\Kategorie;
|
|||
*
|
||||
* 1. Die Buchung trägt eine Kategorie (keine Umbuchung, keine Splitbuchung).
|
||||
* 2. Diese Kategorie ist in `Verwaltung → Bargeld-Tool` vorgemerkt.
|
||||
* 3. Es ist eine Adresse konfiguriert.
|
||||
* 3. Die Buchung ist nicht als „Ohne Bargeld-Abgleich“ markiert (#136) –
|
||||
* Sammelbuchungen und Differenzausgleiche stehen absichtlich nicht im
|
||||
* Bargeld-Tool und sollen nicht dauerhaft als offen angemahnt werden.
|
||||
* 4. Es ist eine Adresse konfiguriert.
|
||||
*
|
||||
* Fällt eine der drei Bedingungen weg, wird der Status gelöscht: Die Buchung
|
||||
* Fällt eine der Bedingungen weg, wird der Status gelöscht: Die Buchung
|
||||
* hat mit dem Bargeld-Tool nichts zu tun und zeigt im Kontoblatt den grauen
|
||||
* Kreis. Das gilt ausdrücklich auch für eine Umkategorisierung – sonst bliebe
|
||||
* ein alter Haken an einer Buchung stehen, die niemand mehr abgleicht.
|
||||
|
|
@ -141,7 +144,11 @@ class BargeldAbgleich
|
|||
/** @param array<string,mixed> $buchung */
|
||||
private static function istZuPruefen(array $buchung): bool
|
||||
{
|
||||
if ((int)($buchung['gesplittet'] ?? 0) === 1 || !empty($buchung['gegenkonto_id'])) {
|
||||
if (
|
||||
(int)($buchung['gesplittet'] ?? 0) === 1
|
||||
|| !empty($buchung['gegenkonto_id'])
|
||||
|| !empty($buchung['ohne_bargeld_abgleich'])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -261,19 +261,19 @@ class Cockpit
|
|||
];
|
||||
}
|
||||
|
||||
$nichtAbgeglichen = (int)Database::fetchValue(
|
||||
'SELECT COUNT(*) FROM buchungen b
|
||||
$stichtag = $heute ?? date('Y-m-d');
|
||||
$nichtAbgeglichen = Database::fetchOne(
|
||||
'SELECT COUNT(*) AS anzahl, MIN(b.datum) AS erste FROM buchungen b
|
||||
JOIN konten k ON k.id = b.konto_id
|
||||
WHERE b.benutzer_id = ? AND b.abgeglichen = 0 AND k.aktiv = 1 AND b.datum <= ?',
|
||||
[$benutzer, $heute ?? date('Y-m-d')],
|
||||
0
|
||||
[$benutzer, $stichtag]
|
||||
);
|
||||
if ($nichtAbgeglichen > 0) {
|
||||
if ((int)($nichtAbgeglichen['anzahl'] ?? 0) > 0) {
|
||||
$vorgaenge[] = [
|
||||
'schluessel' => 'nicht_abgeglichen',
|
||||
'text' => 'nicht abgeglichen',
|
||||
'anzahl' => $nichtAbgeglichen,
|
||||
'pfad' => '/konten',
|
||||
'anzahl' => (int)$nichtAbgeglichen['anzahl'],
|
||||
'pfad' => self::nichtAbgeglichenBericht((string)$nichtAbgeglichen['erste'], $stichtag),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -301,7 +301,7 @@ class Cockpit
|
|||
'schluessel' => 'bargeld_fehler',
|
||||
'text' => 'Bargeld-Tool-Prüfung fehlgeschlagen',
|
||||
'anzahl' => $bargeldFehler,
|
||||
'pfad' => '/verwaltung/bargeld/fehler',
|
||||
'pfad' => self::bargeldBericht(BargeldAbgleich::FEHLER),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -317,7 +317,7 @@ class Cockpit
|
|||
'schluessel' => 'bargeld_fehlt',
|
||||
'text' => 'im Bargeld-Tool nicht gefunden',
|
||||
'anzahl' => $bargeldFehlt,
|
||||
'pfad' => '/verwaltung/bargeld/fehler?status=fehlt',
|
||||
'pfad' => self::bargeldBericht(BargeldAbgleich::FEHLT),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -370,4 +370,56 @@ class Cockpit
|
|||
|
||||
return $vorgaenge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Buchungsbericht über den gesamten Zeitraum, gefiltert auf einen
|
||||
* Status des Bargeld-Tools – mit Kategorie bzw. Gegenkonto in eckigen
|
||||
* Klammern (#131). Umbuchungen zählen mit, damit die Liste genau die
|
||||
* Buchungen zeigt, die der offene Vorgang zählt.
|
||||
*/
|
||||
private static function bargeldBericht(string $status): string
|
||||
{
|
||||
return '/auswertungen/bericht?' . http_build_query([
|
||||
'typ' => 'buchungsbericht',
|
||||
'zeitraum' => 'alles',
|
||||
'umbuchungen' => '1',
|
||||
'bargeld' => $status,
|
||||
'angepasst' => '1',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Buchungsbericht, gefiltert auf nicht abgeglichene Buchungen (#137) –
|
||||
* mit denselben Grenzen wie der offene Vorgang: von der ersten betroffenen
|
||||
* Buchung bis heute, Umbuchungen eingeschlossen. Liegen auch auf
|
||||
* aufgelösten Konten nicht abgeglichene Buchungen, beschränkt sich der
|
||||
* Bericht auf die aktiven Konten, denn nur die zählt der Vorgang.
|
||||
*/
|
||||
private static function nichtAbgeglichenBericht(string $erste, string $stichtag): string
|
||||
{
|
||||
$parameter = [
|
||||
'typ' => 'buchungsbericht',
|
||||
'zeitraum' => 'benutzerdefiniert',
|
||||
'von' => $erste,
|
||||
'bis' => $stichtag,
|
||||
'umbuchungen' => '1',
|
||||
'abgeglichen' => 'nein',
|
||||
];
|
||||
|
||||
$inaktiv = (int)Database::fetchValue(
|
||||
'SELECT COUNT(*) FROM buchungen b
|
||||
JOIN konten k ON k.id = b.konto_id
|
||||
WHERE b.benutzer_id = ? AND b.abgeglichen = 0 AND k.aktiv = 0 AND b.datum BETWEEN ? AND ?',
|
||||
[Mandant::id(), $erste, $stichtag],
|
||||
0
|
||||
);
|
||||
if ($inaktiv > 0) {
|
||||
$parameter['konten'] = array_map('intval', array_column(Database::fetchAll(
|
||||
'SELECT id FROM konten WHERE benutzer_id = ? AND aktiv = 1 ORDER BY id',
|
||||
[Mandant::id()]
|
||||
), 'id'));
|
||||
}
|
||||
|
||||
return '/auswertungen/bericht?' . http_build_query($parameter + ['angepasst' => '1']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ namespace Finanzmanager\Services;
|
|||
use Finanzmanager\Database;
|
||||
use Finanzmanager\Models\Buchung;
|
||||
use Finanzmanager\Models\Depotbuchung;
|
||||
use Finanzmanager\Models\Klasse;
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\Models\Wertpapier;
|
||||
|
||||
|
|
@ -35,6 +36,10 @@ use Finanzmanager\Models\Wertpapier;
|
|||
* Kategorie, der Rest als Umbuchung ins Depot. Ohne Nebenkosten bleibt es bei
|
||||
* der schlichten Umbuchung – eine Splitbuchung mit einer einzigen Zeile wäre nur
|
||||
* umständlicher zu lesen.
|
||||
*
|
||||
* **Die Klasse gilt für das ganze Geschäft.** Sie steht an der Buchung und an
|
||||
* jeder ihrer Splitzeilen – sonst zeigte das Kontoblatt eine Splitbuchung mit
|
||||
* gemischten Klassen, und die Auswertung fände die Nebenkosten ohne Klasse.
|
||||
*/
|
||||
class Wertpapiergeschaeft
|
||||
{
|
||||
|
|
@ -84,7 +89,7 @@ class Wertpapiergeschaeft
|
|||
* @param array<string,mixed> $buchung die Buchung des Verrechnungskontos
|
||||
* @param array<string,mixed> $eingaben depot_id, wertpapier_id, wertpapier_name, isin,
|
||||
* wkn, art, vorgang, datum, valuta, stueck, kurs,
|
||||
* notiz, kosten, kosten_sonstige_kategorie
|
||||
* notiz, kosten, kosten_sonstige_kategorie, klasse_id
|
||||
* @return array{depotbuchung_id:int,vorgang:string,betrag:float,gesplittet:bool}
|
||||
*/
|
||||
public static function buchen(array $buchung, array $eingaben): array
|
||||
|
|
@ -132,8 +137,17 @@ class Wertpapiergeschaeft
|
|||
$kurs = round(abs($kurswert) / $stueck, 6);
|
||||
}
|
||||
|
||||
$klasseId = (int)($eingaben['klasse_id'] ?? 0) ?: null;
|
||||
if (!array_key_exists('klasse_id', $eingaben)) {
|
||||
// Splitzeilen erben dann die Klasse, die schon an der Buchung steht.
|
||||
$klasseId = ($buchung['klasse_id'] ?? null) !== null ? (int)$buchung['klasse_id'] : null;
|
||||
}
|
||||
if ($klasseId !== null && Klasse::find($klasseId) === null) {
|
||||
throw new \InvalidArgumentException('Die Klasse gibt es nicht.');
|
||||
}
|
||||
|
||||
return Database::transaktion(static function () use (
|
||||
$buchung, $depot, $eingaben, $aufteilung, $kurswert, $vorgang, $stueck, $kurs
|
||||
$buchung, $depot, $eingaben, $aufteilung, $kurswert, $vorgang, $stueck, $kurs, $klasseId
|
||||
): array {
|
||||
$wertpapierId = (int)($eingaben['wertpapier_id'] ?? 0);
|
||||
$name = trim((string)($eingaben['wertpapier_name'] ?? ''));
|
||||
|
|
@ -178,12 +192,14 @@ class Wertpapiergeschaeft
|
|||
} else {
|
||||
$splits = [[
|
||||
'gegenkonto_id' => (int)$depot['id'],
|
||||
'klasse_id' => $klasseId,
|
||||
'betrag' => $kurswert,
|
||||
'notiz' => $bezeichnung,
|
||||
]];
|
||||
foreach ($aufteilung['kosten'] as $zeile) {
|
||||
$splits[] = [
|
||||
'kategorie_id' => $zeile['kategorie_id'],
|
||||
'klasse_id' => $klasseId,
|
||||
// Nebenkosten sind Aufwand – beim Kauf wie beim Verkauf.
|
||||
'betrag' => -$zeile['betrag'],
|
||||
'notiz' => $zeile['name'],
|
||||
|
|
@ -193,6 +209,13 @@ class Wertpapiergeschaeft
|
|||
Buchung::splitsErsetzen((int)$buchung['id'], $splits);
|
||||
}
|
||||
|
||||
// Die Auswahl der Maske gilt, auch wenn sie leer ist: Sie war mit
|
||||
// der bisherigen Klasse der Buchung vorbelegt. Wer gar keine
|
||||
// Klasse mitgibt, lässt die vorhandene stehen.
|
||||
if (array_key_exists('klasse_id', $eingaben)) {
|
||||
Buchung::klasseSetzen((int)$buchung['id'], $klasseId);
|
||||
}
|
||||
|
||||
return [
|
||||
'depotbuchung_id' => $depotbuchungId,
|
||||
'vorgang' => $vorgang,
|
||||
|
|
|
|||
|
|
@ -135,6 +135,8 @@ class View
|
|||
* Das Symbol der Abgleichspalte im Kontoblatt. Vier Zustände, in dieser
|
||||
* Reihenfolge zu lesen: gefunden, nicht gefunden, Abruf gescheitert – und
|
||||
* der graue Kreis für alles, was mit dem Bargeld-Tool nichts zu tun hat.
|
||||
* Eine Buchung, die bewusst ohne Bargeld-Abgleich gebucht ist (#136),
|
||||
* zeigt einen leeren grauen Kreis: nicht geprüft, aber absichtlich.
|
||||
*
|
||||
* Die Zeichen stehen als Text und nicht als Grafik da: Sie erben damit
|
||||
* Schriftgröße und – beim Ausrufezeichen ausdrücklich gewünscht – die
|
||||
|
|
@ -144,15 +146,18 @@ class View
|
|||
* Schnellzuordnung (kontoblatt.js) dasselbe Symbol braucht, ohne die Seite
|
||||
* neu zu laden.
|
||||
*/
|
||||
public static function bargeldMarke(?string $status, ?string $hinweis): string
|
||||
public static function bargeldMarke(?string $status, ?string $hinweis, bool $ohneAbgleich = false): string
|
||||
{
|
||||
$hinweis = trim((string)$hinweis);
|
||||
|
||||
[$zeichen, $klasse, $standardtitel] = match ($status) {
|
||||
[$zeichen, $klasse, $standardtitel] = match (true) {
|
||||
$ohneAbgleich && $status === null => ['○', 'neutral', 'Bewusst ohne Abgleich mit dem Bargeld-Tool'],
|
||||
default => match ($status) {
|
||||
'gefunden' => ['✓', 'gefunden', 'Im Bargeld-Tool gefunden'],
|
||||
'fehlt' => ['✗', 'fehlt', 'Im Bargeld-Tool nicht gefunden'],
|
||||
'fehler' => ['!', 'fehler', 'Der Abruf beim Bargeld-Tool ist gescheitert'],
|
||||
default => ['●', 'neutral', 'Wird nicht mit dem Bargeld-Tool abgeglichen'],
|
||||
},
|
||||
};
|
||||
|
||||
return '<span class="bargeldmarke bargeld-' . $klasse . '" title="'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/** @var array<string,mixed> $bericht */
|
||||
|
|
@ -174,6 +175,18 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</label>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($bericht['typ'] === 'buchungsbericht'): ?>
|
||||
<label>Abgeglichen
|
||||
<select name="abgeglichen">
|
||||
<?php foreach (['' => 'alle', 'ja' => 'abgeglichen', 'nein' => 'nicht abgeglichen'] as $wert => $text): ?>
|
||||
<option value="<?= View::h($wert) ?>" <?= $filter['abgeglichen'] === $wert ? 'selected' : '' ?>>
|
||||
<?= View::h($text) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($bericht['typ'] === 'buchungsbericht' && ($bericht['bargeld'] ?? false)): ?>
|
||||
<label>Bargeld-Tool
|
||||
<select name="bargeld">
|
||||
|
|
@ -195,7 +208,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 +230,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 +261,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>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ use Finanzmanager\View;
|
|||
*/
|
||||
?>
|
||||
|
||||
<?php $spalten = $bericht['bargeld'] ? 8 : 7; ?>
|
||||
<?php $spalten = $bericht['bargeld'] ? 9 : 8; ?>
|
||||
<table class="liste bericht buchungsgruppen">
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
@ -20,6 +20,7 @@ use Finanzmanager\View;
|
|||
<th>Buchungstext</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Klasse</th>
|
||||
<th class="abgleichspalte" title="abgeglichen">✓</th>
|
||||
<?php if ($bericht['bargeld']): ?>
|
||||
<th class="bargeldspalte" title="Abgleich mit dem Bargeld-Tool">Bar</th>
|
||||
<?php endif; ?>
|
||||
|
|
@ -52,8 +53,9 @@ use Finanzmanager\View;
|
|||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= View::h($buchung['klasse']) ?></td>
|
||||
<td class="abgleichspalte nebentext"><?= $buchung['abgeglichen'] ? '✓' : '' ?></td>
|
||||
<?php if ($bericht['bargeld']): ?>
|
||||
<td class="bargeldspalte"><?= View::bargeldMarke($buchung['bargeld_status'], $buchung['bargeld_hinweis']) ?></td>
|
||||
<td class="bargeldspalte"><?= View::bargeldMarke($buchung['bargeld_status'], $buchung['bargeld_hinweis'], !empty($buchung['ohne_bargeld_abgleich'])) ?></td>
|
||||
<?php endif; ?>
|
||||
<td class="rechts <?= View::betragKlasse($buchung['betrag']) ?>">
|
||||
<?= View::betrag($buchung['betrag'], false) ?>
|
||||
|
|
@ -89,4 +91,17 @@ use Finanzmanager\View;
|
|||
dem Summenbericht überein.
|
||||
<?php endif; ?>
|
||||
Der Empfänger führt in die Buchungsmaske.
|
||||
<?php if ($bericht['filter']['abgeglichen'] === 'nein'): ?>
|
||||
Den Haken „abgeglichen“ setzt die Buchungsmaske oder der Abgleich im Kontoblatt.
|
||||
<?php endif; ?>
|
||||
<?php if (in_array($bericht['filter']['bargeld'], ['fehlt', 'fehler'], true)): ?>
|
||||
<?php if ($bericht['filter']['bargeld'] === 'fehlt'): ?>
|
||||
Zu diesen Buchungen wurde im Bargeld-Tool keine Ausgabe mit demselben Betrag gefunden.
|
||||
<?php else: ?>
|
||||
Bei diesen Buchungen ist der letzte Abruf beim Bargeld-Tool gescheitert – ein
|
||||
Abrufproblem, keine Aussage darüber, ob die Buchung dort steht.
|
||||
<?php endif; ?>
|
||||
Die Meldung steht am Symbol in der Spalte „Bar“; eine erneute Zuordnung der Kategorie
|
||||
in der Buchungsmaske oder im Kontoblatt stößt die Prüfung erneut an.
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
@ -10,27 +11,21 @@ use Finanzmanager\View;
|
|||
/** @var list<array<string,mixed>> $kategorien */
|
||||
/** @var list<array<string,mixed>> $klassen */
|
||||
/** @var list<array<string,mixed>> $konten */
|
||||
/** @var bool $bargeld Ist ein Bargeld-Tool eingerichtet? */
|
||||
/** @var string $csrfToken */
|
||||
|
||||
$neu = $buchung === null;
|
||||
$ziel = $neu ? '/konten/' . (int)$konto['id'] . '/buchung' : '/buchungen/' . (int)$buchung['id'];
|
||||
$betrag = $neu ? 0.0 : (float)$buchung['betrag'];
|
||||
$eingaben = $eingaben ?? null;
|
||||
$bargeld = $bargeld ?? false;
|
||||
|
||||
// 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 '';
|
||||
};
|
||||
$zuordnungswert = BuchungenController::zuordnungswert(...);
|
||||
|
||||
// Die Werte, die die Felder zeigen: normalerweise die gespeicherte Buchung
|
||||
// (oder eine leere Maske), nach einem gescheiterten Speichern aber die zuletzt
|
||||
|
|
@ -46,6 +41,7 @@ $buchungstextWert = (string)($buchung['buchungstext'] ?? '');
|
|||
$nummerWert = (string)($buchung['nummer'] ?? '');
|
||||
$abgeglichenWert = !empty($buchung['abgeglichen']);
|
||||
$ohneKategorieWert = !empty($buchung['bewusst_ohne_kategorie']);
|
||||
$ohneBargeldWert = !empty($buchung['ohne_bargeld_abgleich']);
|
||||
|
||||
if ($eingaben !== null) {
|
||||
$art = ($eingaben['art'] ?? '') === 'einnahme' ? 'einnahme' : 'ausgabe';
|
||||
|
|
@ -58,6 +54,7 @@ if ($eingaben !== null) {
|
|||
$nummerWert = (string)($eingaben['nummer'] ?? '');
|
||||
$abgeglichenWert = isset($eingaben['abgeglichen']);
|
||||
$ohneKategorieWert = isset($eingaben['bewusst_ohne_kategorie']);
|
||||
$ohneBargeldWert = isset($eingaben['ohne_bargeld_abgleich']);
|
||||
}
|
||||
|
||||
$eigenesKonto = (int)$konto['id'];
|
||||
|
|
@ -213,6 +210,22 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</label>
|
||||
</div>
|
||||
|
||||
<?php /* Ohne Bargeld-Tool hat der Schalter keine Bedeutung und bleibt
|
||||
unsichtbar; ein gesetzter Wert geht beim Speichern trotzdem nicht
|
||||
verloren (#136). */ ?>
|
||||
<?php if ($bargeld): ?>
|
||||
<div class="feld">
|
||||
<label class="ankreuzfeld"
|
||||
title="Für Buchungen, die absichtlich nicht im Bargeld-Tool stehen – etwa Sammelbuchungen oder Differenzausgleiche. Sie werden nicht mit dem Bargeld-Tool abgeglichen und erscheinen nicht unter den offenen Vorgängen im Cockpit.">
|
||||
<input type="checkbox" id="ohne_bargeld_abgleich" name="ohne_bargeld_abgleich" value="1"
|
||||
<?= $ohneBargeldWert ? 'checked' : '' ?>>
|
||||
Ohne Bargeld-Abgleich
|
||||
</label>
|
||||
</div>
|
||||
<?php elseif ($ohneBargeldWert): ?>
|
||||
<input type="hidden" name="ohne_bargeld_abgleich" value="1">
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="feld ganz">
|
||||
<label class="ankreuzfeld"
|
||||
title="Erzeugt beim Speichern zusätzlich eine Regel für den automatischen Abgleich aus dieser Buchung. Der nächste Bildschirm zeigt die neue Regel zum Prüfen an.">
|
||||
|
|
|
|||
|
|
@ -134,7 +134,11 @@ $splitklassen = static function (array $splitzeilen) use ($splitname, $klassenNa
|
|||
* @param array<string,mixed> $zeile
|
||||
*/
|
||||
$bargeldmarke = static fn(array $zeile): string
|
||||
=> View::bargeldMarke($zeile['bargeld_status'] ?? null, $zeile['bargeld_hinweis'] ?? null);
|
||||
=> View::bargeldMarke(
|
||||
$zeile['bargeld_status'] ?? null,
|
||||
$zeile['bargeld_hinweis'] ?? null,
|
||||
!empty($zeile['ohne_bargeld_abgleich'])
|
||||
);
|
||||
|
||||
/**
|
||||
* IBAN und Bank der Gegenseite, soweit der Bankabruf sie geliefert hat – der
|
||||
|
|
@ -555,6 +559,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
|
||||
<?php $datumsvorgabe = date('Y-m-d'); ?>
|
||||
<?php $vorschlag = ['isin' => '', 'wkn' => '']; ?>
|
||||
<?php $klassenvorgabe = 0; ?>
|
||||
<?php include __DIR__ . '/wertpapierfelder.php'; ?>
|
||||
|
||||
<div class="knopfleiste">
|
||||
|
|
|
|||
|
|
@ -14,12 +14,14 @@ use Finanzmanager\View;
|
|||
* @var array<string,mixed> $depot
|
||||
* @var list<array<string,mixed>> $wertpapiere
|
||||
* @var list<array<string,mixed>> $kategorien
|
||||
* @var list<array<string,mixed>> $klassen
|
||||
* @var array{isin:string,wkn:string} $vorschlag
|
||||
* @var string $csrfToken
|
||||
*/
|
||||
|
||||
$betrag = (float)$buchung['betrag'];
|
||||
$datumsvorgabe = substr((string)$buchung['datum'], 0, 10);
|
||||
$klassenvorgabe = (int)($buchung['klasse_id'] ?? 0);
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ use Finanzmanager\View;
|
|||
* rechnet ihn vor, gespeichert wird er aus denselben Zahlen noch einmal.
|
||||
*
|
||||
* @var list<array<string,mixed>> $wertpapiere
|
||||
* @var list<array<string,mixed>> $klassen
|
||||
* @var int $klassenvorgabe die bisherige Klasse der Buchung, 0 für keine
|
||||
* @var array{isin:string,wkn:string} $vorschlag aus dem Verwendungszweck gelesen
|
||||
* @var string $datumsvorgabe
|
||||
*/
|
||||
|
|
@ -102,5 +104,18 @@ use Finanzmanager\View;
|
|||
das nicht: Der Betrag der Buchung steht fest, wie die Bank ihn geliefert hat.
|
||||
</p>
|
||||
|
||||
<label for="wp_klasse">Klasse <span class="optional">(optional)</span></label>
|
||||
<select id="wp_klasse" name="klasse_id">
|
||||
<option value="">— keine —</option>
|
||||
<?php foreach ($klassen as $k): ?>
|
||||
<option value="<?= (int)$k['id'] ?>" <?= (int)($klassenvorgabe ?? 0) === (int)$k['id'] ? 'selected' : '' ?>>
|
||||
<?= View::h($k['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="feldhinweis">
|
||||
Die Klasse gilt für das ganze Geschäft – auch für die Splitzeilen der Kosten.
|
||||
</p>
|
||||
|
||||
<label for="wp_notiz">Notiz</label>
|
||||
<input type="text" id="wp_notiz" name="notiz">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,12 @@ use Finanzmanager\View;
|
|||
<body class="schlicht">
|
||||
<?php include __DIR__ . '/meldungen.php'; ?>
|
||||
<div class="karte-mitte">
|
||||
<div class="marke gross">Finanzmanager</div>
|
||||
<!-- Dieselbe Marke wie oben in der Seitenleiste: Coinstack-Symbol und
|
||||
zweifarbiger Schriftzug. -->
|
||||
<div class="anmelde-marke">
|
||||
<img src="/static/coinstack.png" alt="" class="marken-symbol">
|
||||
<span class="marken-text">Finanz<span>manager</span></span>
|
||||
</div>
|
||||
<?= $inhalt ?>
|
||||
</div>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<thead>
|
||||
<tr>
|
||||
<th>Name und Buchungstext</th><th>Konto und Klasse</th><th class="rechts">Betrag</th><th>Rhythmus</th>
|
||||
<th>Nächster Termin</th><th>Ende</th><th>Auto</th><th>Aktiv</th><th class="rechts">Aktion</th>
|
||||
<th>Nächster Termin</th><th>Ende</th><th>Auto</th><th>Abgleichen</th><th>Aktiv</th><th class="rechts">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -212,6 +212,13 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<?= (int)$reihe['automatisch'] === 1 ? 'checked' : '' ?>>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="feld" data-schalter>
|
||||
<span class="anzeige"><?= (int)$reihe['abgleichen'] === 1 ? 'Ja' : 'Nein' ?></span>
|
||||
<input type="checkbox" form="<?= $f ?>" name="abgleichen" value="1" hidden
|
||||
<?= (int)$reihe['abgleichen'] === 1 ? 'checked' : '' ?>>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="feld" data-schalter>
|
||||
<span class="anzeige"><?= (int)$reihe['aktiv'] === 1 ? 'Ja' : 'Nein' ?></span>
|
||||
|
|
@ -257,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>
|
||||
|
|
@ -297,6 +303,14 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
dort, wo der Betrag schwankt.
|
||||
</p>
|
||||
|
||||
<label class="ankreuzfeld">
|
||||
<input type="checkbox" name="abgleichen" value="1" checked>
|
||||
automatisch abgleichen
|
||||
</label>
|
||||
<p class="feldhinweis">
|
||||
Mit Haken ist die entstandene Buchung gleich als abgeglichen markiert.
|
||||
</p>
|
||||
|
||||
<label class="ankreuzfeld">
|
||||
<input type="checkbox" name="aktiv" value="1" checked> aktiv
|
||||
</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,69 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\View;
|
||||
|
||||
/**
|
||||
* Alle Buchungen, an denen die Bargeld-Tool-Prüfung zuletzt einen Fehler
|
||||
* festgestellt hat – der Ziel der Kachel „Offene Vorgänge“ im Cockpit.
|
||||
*
|
||||
* @var bool $fehlt true: im Bargeld-Tool nicht gefunden, sonst Abruf gescheitert
|
||||
* @var list<array<string,mixed>> $buchungen
|
||||
*/
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
||||
<div class="seitenaktionen">
|
||||
<a href="/verwaltung/bargeld" class="knopf">Bargeld-Tool</a>
|
||||
<a href="/" class="knopf">Cockpit</a>
|
||||
</div>
|
||||
|
||||
<p class="feldhinweis">
|
||||
<?php if ($fehlt): ?>
|
||||
Zu diesen Buchungen wurde im Bargeld-Tool keine Ausgabe mit demselben Betrag
|
||||
gefunden. Eine erneute Zuordnung der Kategorie in der Buchungsmaske oder im
|
||||
Kontoblatt stößt die Prüfung erneut an.
|
||||
<?php else: ?>
|
||||
Bei diesen Buchungen ist der letzte Abruf beim Bargeld-Tool gescheitert – ein
|
||||
Abrufproblem, keine Aussage darüber, ob die Buchung dort steht. Eine erneute
|
||||
Zuordnung der Kategorie in der Buchungsmaske oder im Kontoblatt stößt die
|
||||
Prüfung erneut an.
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
|
||||
<?php if ($buchungen === []): ?>
|
||||
<p class="platzhalter"><?= $fehlt ? 'Keine nicht gefundenen Buchungen.' : 'Keine offenen Fehler.' ?></p>
|
||||
<?php else: ?>
|
||||
<table class="liste">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Konto</th>
|
||||
<th>Empfänger / Buchungstext</th>
|
||||
<th class="rechts">Betrag</th>
|
||||
<th>Meldung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($buchungen as $buchung): ?>
|
||||
<tr>
|
||||
<td><?= View::h(View::datum($buchung['datum'])) ?></td>
|
||||
<td>
|
||||
<a href="/konten/<?= (int)$buchung['konto_id'] ?>">
|
||||
<?= View::h($buchung['konto_name']) ?>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<?= View::h($buchung['empfaenger'] !== '' ? $buchung['empfaenger'] : $buchung['buchungstext']) ?>
|
||||
</td>
|
||||
<td class="rechts <?= View::betragKlasse($buchung['betrag']) ?>">
|
||||
<?= View::betrag($buchung['betrag']) ?>
|
||||
</td>
|
||||
<td class="klein"><?= View::h($buchung['bargeld_hinweis'] ?? '') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use Finanzmanager\View;
|
|||
* @var list<array<string,mixed>> $depots
|
||||
* @var list<array<string,mixed>> $wertpapiere
|
||||
* @var list<array<string,mixed>> $konten
|
||||
* @var list<array<string,mixed>> $klassen
|
||||
* @var string $csrfToken
|
||||
*/
|
||||
|
||||
|
|
@ -18,7 +19,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<a href="/wertpapiere/buchungen" class="knopf">Zurück zu den Depotbuchungen</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/wertpapiere/buchungen/neu" class="formular breit">
|
||||
<form method="post" action="/wertpapiere/buchungen/neu" class="formular breit" id="depotbuchungsformular">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
|
||||
<label for="konto_id">Depot</label>
|
||||
|
|
@ -82,7 +83,8 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
|
||||
<h3>Beträge</h3>
|
||||
<p class="feldhinweis">
|
||||
Zwei von drei Angaben genügen: Fehlt der Betrag, ergibt er sich aus Stück mal Kurs.
|
||||
Zwei von drei Angaben genügen: Betrag = Stück × Kurs. Bei Kauf und Verkauf rechnet
|
||||
die Maske den dritten Wert aus, sobald zwei eingetragen sind.
|
||||
Gebühren und Steuern stehen <em>neben</em> dem Betrag – auf dem Verrechnungskonto
|
||||
wird bei einem Kauf die Summe belastet, bei einem Verkauf die Differenz gutgeschrieben.
|
||||
</p>
|
||||
|
|
@ -125,6 +127,15 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
Depotbuchung – richtig etwa bei einer Ein- oder Auslieferung.
|
||||
</p>
|
||||
|
||||
<label for="klasse_id">Klasse <span class="optional">(optional)</span></label>
|
||||
<select id="klasse_id" name="klasse_id">
|
||||
<option value="">— keine —</option>
|
||||
<?php foreach ($klassen as $k): ?>
|
||||
<option value="<?= (int)$k['id'] ?>"><?= View::h($k['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="feldhinweis">Die Klasse steht an der Geldbuchung auf dem Verrechnungskonto.</p>
|
||||
|
||||
<label for="notiz">Notiz</label>
|
||||
<input type="text" id="notiz" name="notiz">
|
||||
|
||||
|
|
@ -133,4 +144,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</div>
|
||||
</form>
|
||||
|
||||
<script src="/static/betrag.js" defer></script>
|
||||
<script src="/static/depotbuchung.js" defer></script>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ class AuswertungTest extends TestCase
|
|||
|
||||
$this->buchungsbericht();
|
||||
$this->buchungsberichtBargeld();
|
||||
$this->buchungsberichtAbgeglichen();
|
||||
$this->vergleichsbericht();
|
||||
$this->kontostandsbericht();
|
||||
$this->budgetbericht();
|
||||
|
|
@ -505,6 +506,42 @@ class AuswertungTest extends TestCase
|
|||
Einstellung::set('bargeld_url', (string)$vorher);
|
||||
}
|
||||
|
||||
/** Spalte und Filter „abgeglichen“ im Buchungsbericht (#137). */
|
||||
private function buchungsberichtAbgeglichen(): void
|
||||
{
|
||||
Database::execute(
|
||||
"UPDATE buchungen SET abgeglichen = CASE WHEN empfaenger IN ('Edeka', 'Rewe') THEN 1 ELSE 0 END
|
||||
WHERE konto_id = ?",
|
||||
[$this->giro]
|
||||
);
|
||||
|
||||
$alle = $this->bericht(['typ' => 'buchungsbericht', 'zeilensumme' => 'konto']);
|
||||
$markiert = [];
|
||||
foreach ($alle['gruppen'][0]['buchungen'] as $buchung) {
|
||||
$markiert[$buchung['empfaenger']] = $buchung['abgeglichen'];
|
||||
}
|
||||
$this->assert($markiert['Edeka'] && $markiert['Rewe'], 'Abgeglichene Buchungen tragen den Haken');
|
||||
$this->assert(!$markiert['Kino'], 'Nicht abgeglichene Buchungen tragen keinen');
|
||||
|
||||
$ja = $this->bericht(['typ' => 'buchungsbericht', 'zeilensumme' => 'konto', 'abgeglichen' => 'ja']);
|
||||
$this->assertEquals(2, $ja['anzahl'], 'Der Filter „abgeglichen“ lässt Edeka und die Splitbuchung übrig');
|
||||
|
||||
$nein = $this->bericht(['typ' => 'buchungsbericht', 'zeilensumme' => 'konto', 'abgeglichen' => 'nein']);
|
||||
$this->assertEquals($alle['anzahl'] - 2, $nein['anzahl'], 'Der Filter „nicht abgeglichen“ lässt beide weg');
|
||||
|
||||
// Splitzeilen erben den Haken ihrer Buchung.
|
||||
$nachKategorie = $this->bericht(['typ' => 'buchungsbericht', 'abgeglichen' => 'ja']);
|
||||
$this->assertEquals(3, $nachKategorie['anzahl'], 'Nach Kategorie: Edeka und beide Splitzeilen');
|
||||
|
||||
$unbekannt = $this->bericht(['typ' => 'buchungsbericht', 'abgeglichen' => 'vielleicht']);
|
||||
$this->assertEquals('', $unbekannt['filter']['abgeglichen'], 'Ein unbekannter Wert filtert nicht');
|
||||
|
||||
$summe = $this->bericht(['typ' => 'summenbericht', 'abgeglichen' => 'nein']);
|
||||
$this->assertEquals('', $summe['filter']['abgeglichen'], 'Außerhalb des Buchungsberichts wirkt der Filter nicht');
|
||||
|
||||
Database::execute('UPDATE buchungen SET abgeglichen = 0 WHERE konto_id = ?', [$this->giro]);
|
||||
}
|
||||
|
||||
private function buchungsbericht(): void
|
||||
{
|
||||
$bericht = $this->bericht(['typ' => 'buchungsbericht', 'zeilensumme' => 'konto']);
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ class CockpitTest extends TestCase
|
|||
$this->verteilungKuerztAufDieGrenze();
|
||||
$this->offeneVorgaengeZaehlenWasWartet();
|
||||
$this->bargeldFehlerErscheintAlsOffenerVorgang();
|
||||
$this->nichtAbgeglichenFuehrtInDenBuchungsbericht();
|
||||
$this->legendenFuehrenZuDenBuchungen();
|
||||
$this->vorgangspruefungenKennenJedenVorgang();
|
||||
}
|
||||
|
|
@ -301,19 +302,9 @@ class CockpitTest extends TestCase
|
|||
$this->assertNotEmpty($eintrag, 'Der offene Vorgang erscheint jetzt');
|
||||
$this->assertEquals(1, $eintrag['anzahl'], 'Genau die eine betroffene Buchung wird gezählt');
|
||||
$this->assertEquals(
|
||||
'/verwaltung/bargeld/fehler',
|
||||
'/auswertungen/bericht?typ=buchungsbericht&zeitraum=alles&umbuchungen=1&bargeld=fehler&angepasst=1',
|
||||
$eintrag['pfad'],
|
||||
'Der Weg führt zur Liste der fehlgeschlagenen Prüfungen'
|
||||
);
|
||||
|
||||
$liste = Buchung::mitBargeldFehler();
|
||||
$this->assertEquals(1, count($liste), 'Die Liste enthält genau diese Buchung');
|
||||
$this->assertEquals($id, $liste[0]['id'], 'Es ist dieselbe Buchung');
|
||||
$this->assertEquals('Cockpitgiro', $liste[0]['konto_name'], 'Der Kontoname kommt mit');
|
||||
$this->assertEquals(
|
||||
'Kasse nicht erreichbar.',
|
||||
$liste[0]['bargeld_hinweis'],
|
||||
'Die Meldung der Gegenstelle steht dabei'
|
||||
'Der Weg führt in den Buchungsbericht, gefiltert auf fehlgeschlagene Prüfungen (#131)'
|
||||
);
|
||||
|
||||
// Ein Status ungleich 'fehler' (z. B. 'gefunden') zählt nicht mit.
|
||||
|
|
@ -321,17 +312,70 @@ class CockpitTest extends TestCase
|
|||
$ohne = $schluessel(Cockpit::offeneVorgaenge(self::HEUTE));
|
||||
$this->assert(!isset($ohne['bargeld_fehler']), 'Ein gefundener Treffer ist kein offener Vorgang mehr');
|
||||
|
||||
// Nicht gefunden: eigener Vorgang mit eigener Liste.
|
||||
// Nicht gefunden: eigener Vorgang mit eigenem Filter im Buchungsbericht.
|
||||
Database::execute("UPDATE buchungen SET bargeld_status = 'fehlt' WHERE id = ?", [$id]);
|
||||
$fehlt = $schluessel(Cockpit::offeneVorgaenge(self::HEUTE));
|
||||
$this->assertEquals(1, $fehlt['bargeld_fehlt'] ?? 0, 'Eine im Bargeld-Tool nicht gefundene Buchung ist ein offener Vorgang');
|
||||
$this->assert(!isset($fehlt['bargeld_fehler']), 'Nicht gefunden zählt nicht als gescheiterter Abruf');
|
||||
$this->assertEquals(1, count(Buchung::mitBargeldFehler('fehlt')), 'Die Liste „nicht gefunden“ enthält die Buchung');
|
||||
$this->assertEquals(0, count(Buchung::mitBargeldFehler()), 'Die Fehlerliste enthält sie nicht');
|
||||
$pfade = array_column(Cockpit::offeneVorgaenge(self::HEUTE), 'pfad', 'schluessel');
|
||||
$this->assert(
|
||||
str_contains($pfade['bargeld_fehlt'] ?? '', 'typ=buchungsbericht') && str_contains($pfade['bargeld_fehlt'] ?? '', 'bargeld=fehlt'),
|
||||
'Der Weg führt in den Buchungsbericht, gefiltert auf „nicht gefunden“ (#131)'
|
||||
);
|
||||
|
||||
Database::execute("UPDATE buchungen SET bargeld_status = 'fehler' WHERE id = ?", [$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* „Buchungen nicht abgeglichen“ öffnet den Buchungsbericht, gefiltert auf
|
||||
* nicht abgeglichene Buchungen – und der zeigt genau die gezählten (#137).
|
||||
*/
|
||||
private function nichtAbgeglichenFuehrtInDenBuchungsbericht(): void
|
||||
{
|
||||
$pruefen = function (string $fall): void {
|
||||
$vorgang = null;
|
||||
foreach (Cockpit::offeneVorgaenge(self::HEUTE) as $eintrag) {
|
||||
if ($eintrag['schluessel'] === 'nicht_abgeglichen') {
|
||||
$vorgang = $eintrag;
|
||||
}
|
||||
}
|
||||
$this->assertNotEmpty($vorgang, $fall . ': Der Vorgang erscheint');
|
||||
|
||||
$this->assert(str_starts_with($vorgang['pfad'], '/auswertungen/bericht?'), $fall . ': Der Weg führt in den Buchungsbericht');
|
||||
parse_str((string)parse_url($vorgang['pfad'], PHP_URL_QUERY), $filter);
|
||||
$this->assertEquals('buchungsbericht', $filter['typ'] ?? '', $fall . ': Typ Buchungsbericht');
|
||||
$this->assertEquals('nein', $filter['abgeglichen'] ?? '', $fall . ': Gefiltert auf nicht abgeglichen');
|
||||
|
||||
$bericht = Auswertung::bericht($filter, self::HEUTE);
|
||||
$ids = [];
|
||||
foreach ($bericht['gruppen'] as $gruppe) {
|
||||
foreach ($gruppe['buchungen'] as $buchung) {
|
||||
$this->assert(!$buchung['abgeglichen'], $fall . ': Nur nicht abgeglichene Buchungen');
|
||||
$ids[$buchung['buchung_id']] = true;
|
||||
}
|
||||
}
|
||||
$this->assertEquals($vorgang['anzahl'], count($ids), $fall . ': Der Bericht zeigt genau die gezählten Buchungen');
|
||||
};
|
||||
|
||||
// Eine künftige Buchung zählt der Vorgang nicht – der Bericht auch nicht.
|
||||
Buchung::anlegen([
|
||||
'konto_id' => $this->giro, 'datum' => '2026-12-24', 'betrag' => -40,
|
||||
'empfaenger' => 'Zukunft', 'kategorie_id' => $this->freizeit,
|
||||
]);
|
||||
$pruefen('Aktive Konten');
|
||||
|
||||
// Ein aufgelöstes Konto zählt nicht mit – der Bericht beschränkt sich
|
||||
// dann auf die aktiven Konten.
|
||||
$alt = Konto::anlegen([
|
||||
'name' => 'Cockpitaltkonto', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 0,
|
||||
]);
|
||||
Buchung::anlegen([
|
||||
'konto_id' => $alt, 'datum' => '2026-08-01', 'betrag' => -5,
|
||||
'empfaenger' => 'Altlast', 'kategorie_id' => $this->freizeit,
|
||||
]);
|
||||
$pruefen('Mit aufgelöstem Konto');
|
||||
}
|
||||
|
||||
/**
|
||||
* Jeder Legendeneintrag führt zu einer Buchungsliste, deren Summe genau
|
||||
* seinen Wert trifft – auch „Nicht kategorisiert“ und das Sammelsegment.
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ use Finanzmanager\Models\Buchung;
|
|||
use Finanzmanager\Models\Einstellung;
|
||||
use Finanzmanager\Models\Kategorie;
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\Services\Auswertung;
|
||||
use Finanzmanager\Services\BargeldAbgleich;
|
||||
use Finanzmanager\Services\BargeldTool;
|
||||
use Finanzmanager\Services\Cockpit;
|
||||
use Finanzmanager\Services\Crypto;
|
||||
|
||||
/**
|
||||
|
|
@ -52,7 +54,7 @@ class BargeldAbgleichTest extends TestCase
|
|||
$this->kontoblattFuehrtDenStatusMit();
|
||||
$this->kontoblattZeigtDieViervierSymbole();
|
||||
$this->konfigurationsseiteZeigtDieAuswahl();
|
||||
$this->fehlerlisteZeigtDieBuchungen();
|
||||
$this->buchungsberichtFiltertNachBargeldStatus();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
|
|
@ -512,42 +514,72 @@ class BargeldAbgleichTest extends TestCase
|
|||
}
|
||||
|
||||
/**
|
||||
* Die Liste unter „Verwaltung → Bargeld-Tool → fehlgeschlagene Prüfungen“,
|
||||
* das Ziel der Kachel „Offene Vorgänge“ im Cockpit. Buchung::mitBargeldFehler()
|
||||
* ist kontoübergreifend – geprüft am Datenmodell, nicht am Kontoblatt.
|
||||
* Statt einer eigenen Liste führt der offene Vorgang des Cockpit in den
|
||||
* Buchungsbericht, gefiltert auf den Status des Bargeld-Tools (#131).
|
||||
* Dort steht – anders als in der früheren Liste – die Kategorie dabei.
|
||||
*/
|
||||
private function fehlerlisteZeigtDieBuchungen(): void
|
||||
private function buchungsberichtFiltertNachBargeldStatus(): void
|
||||
{
|
||||
$id = $this->buchung(-27.30, '2026-06-01');
|
||||
Buchung::zuordnen($id, $this->bargeldKategorie, null);
|
||||
Einstellung::set('bargeld_url', 'https://bargeld.example.org', 'bargeld');
|
||||
|
||||
$fehler = $this->buchung(-27.30, '2026-06-01');
|
||||
Buchung::zuordnen($fehler, $this->bargeldKategorie, null);
|
||||
$fehlt = $this->buchung(-8.40, '2026-06-02');
|
||||
Buchung::zuordnen($fehlt, $this->bargeldKategorie, null);
|
||||
Database::execute(
|
||||
"UPDATE buchungen SET bargeld_status = 'fehler', bargeld_hinweis = 'Kasse steht still.' WHERE id = ?",
|
||||
[$id]
|
||||
[$fehler]
|
||||
);
|
||||
Database::execute(
|
||||
"UPDATE buchungen SET bargeld_status = 'fehlt', bargeld_hinweis = 'Kein Treffer.' WHERE id = ?",
|
||||
[$fehlt]
|
||||
);
|
||||
|
||||
$liste = Buchung::mitBargeldFehler();
|
||||
$treffer = null;
|
||||
foreach ($liste as $zeile) {
|
||||
if ((int)$zeile['id'] === $id) {
|
||||
$treffer = $zeile;
|
||||
}
|
||||
}
|
||||
$this->assertNotEmpty($treffer, 'Die Buchung mit Status „fehler“ steht in der Liste');
|
||||
$this->assertEquals('Bargeld Giro', $treffer['konto_name'], 'Der Kontoname kommt mit – die Liste ist kontoübergreifend');
|
||||
$this->assertEquals('Kasse steht still.', $treffer['bargeld_hinweis'], 'Die Meldung steht dabei');
|
||||
$pfade = array_column(Cockpit::offeneVorgaenge('2026-06-30'), 'pfad', 'schluessel');
|
||||
foreach (['bargeld_fehler' => 'fehler', 'bargeld_fehlt' => 'fehlt'] as $schluessel => $status) {
|
||||
$this->assertNotEmpty($pfade[$schluessel] ?? '', "Der offene Vorgang $schluessel erscheint");
|
||||
parse_str((string)parse_url($pfade[$schluessel] ?? '', PHP_URL_QUERY), $query);
|
||||
$this->assertEquals(
|
||||
'/auswertungen/bericht',
|
||||
parse_url($pfade[$schluessel] ?? '', PHP_URL_PATH),
|
||||
"Der Vorgang $schluessel führt in die Auswertung"
|
||||
);
|
||||
$this->assertEquals('buchungsbericht', $query['typ'] ?? '', "Der Vorgang $schluessel öffnet den Buchungsbericht");
|
||||
$this->assertEquals('alles', $query['zeitraum'] ?? '', 'Über den gesamten Zeitraum');
|
||||
$this->assertEquals($status, $query['bargeld'] ?? '', "Gefiltert auf den Status $status");
|
||||
|
||||
$html = $this->rendere('verwaltung/bargeld_fehler.php', ['buchungen' => $liste]);
|
||||
|
||||
$this->assert(str_contains($html, 'Bargeld Giro'), 'Der Kontoname steht in der Liste');
|
||||
$this->assert(str_contains($html, 'Bargeldabhebung'), 'Der Empfänger steht in der Liste');
|
||||
$this->assert(str_contains($html, 'Kasse steht still.'), 'Die Meldung der Gegenstelle steht dabei');
|
||||
$bericht = Auswertung::bericht(Auswertung::normalisiere($query), '2026-06-30');
|
||||
$buchungen = array_merge(...array_column($bericht['gruppen'], 'buchungen'));
|
||||
$ids = array_column($buchungen, 'buchung_id');
|
||||
$this->assert(
|
||||
str_contains($html, '/konten/' . $this->konto),
|
||||
'Der Kontoname führt ins Kontoblatt'
|
||||
in_array($status === 'fehler' ? $fehler : $fehlt, $ids, true),
|
||||
"Der Bericht enthält die Buchung mit Status $status"
|
||||
);
|
||||
$this->assert(
|
||||
!in_array($status === 'fehler' ? $fehlt : $fehler, $ids, true),
|
||||
'Die Buchung mit dem anderen Status fehlt'
|
||||
);
|
||||
$this->assertEquals(
|
||||
['Bargeld Haushalt'],
|
||||
array_values(array_unique(array_column($buchungen, 'kategorie'))),
|
||||
'Die Kategorie steht an jeder Buchung'
|
||||
);
|
||||
|
||||
$leer = $this->rendere('verwaltung/bargeld_fehler.php', ['buchungen' => []]);
|
||||
$this->assert(str_contains($leer, 'Keine offenen Fehler.'), 'Eine leere Liste sagt das ausdrücklich');
|
||||
$html = $this->rendere('auswertungen/teil-buchungen.php', ['bericht' => $bericht]);
|
||||
$this->assert(str_contains($html, '<th>Kategorie</th>'), 'Die Spalte Kategorie steht im Bericht');
|
||||
$this->assert(str_contains($html, 'Bargeld Haushalt'), 'Die Kategorie der Buchung steht im Bericht');
|
||||
$this->assert(str_contains($html, 'Bargeld Giro'), 'Das Konto steht im Bericht');
|
||||
$this->assert(
|
||||
str_contains($html, $status === 'fehler' ? 'Kasse steht still.' : 'Kein Treffer.'),
|
||||
'Die Meldung der Gegenstelle steht am Symbol'
|
||||
);
|
||||
$this->assert(str_contains($html, 'stößt die Prüfung erneut an'), 'Der Hinweis zur erneuten Prüfung steht dabei');
|
||||
}
|
||||
|
||||
$this->assert(
|
||||
!is_file(dirname(__DIR__) . '/src/Views/verwaltung/bargeld_fehler.php'),
|
||||
'Die frühere eigene Liste ist entfernt'
|
||||
);
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $zeilen */
|
||||
|
|
|
|||
105
tests/60_RegelmaessigAbgleichenTest.php
Normal file
105
tests/60_RegelmaessigAbgleichenTest.php
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Finanzmanager\Database;
|
||||
use Finanzmanager\Mandant;
|
||||
use Finanzmanager\Models\Buchung;
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\Models\RegelmaessigeBuchung;
|
||||
use Finanzmanager\Services\Faelligkeitslauf;
|
||||
|
||||
/**
|
||||
* Regelmäßige Buchungen: automatisch abgleichen (#123).
|
||||
*
|
||||
* Der Schalter ist in der Vorgabe eingeschaltet; die aus der Reihe entstandene
|
||||
* Buchung ist dann gleich abgeglichen – beim Fälligkeitslauf wie beim Buchen
|
||||
* von Hand. Ausgeschaltet bleibt sie offen.
|
||||
*/
|
||||
class RegelmaessigAbgleichenTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 60: Regelmäßige Buchungen automatisch abgleichen';
|
||||
|
||||
private int $giro = 0;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->mandantAnlegen();
|
||||
|
||||
$this->giro = Konto::anlegen([
|
||||
'name' => 'Abgleichgiro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||||
]);
|
||||
|
||||
$this->vorgabeIstEingeschaltet();
|
||||
$this->schalterLaesstSichSpeichern();
|
||||
$this->buchenGleichtAb();
|
||||
$this->ohneSchalterBleibtOffen();
|
||||
$this->faelligkeitslaufGleichtAb();
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $zusatz */
|
||||
private function reihe(string $name, array $zusatz = []): int
|
||||
{
|
||||
return RegelmaessigeBuchung::anlegen($zusatz + [
|
||||
'name' => $name, 'konto_id' => $this->giro, 'betrag' => -25,
|
||||
'intervall' => 'monatlich', 'beginn' => '2026-06-01', 'aktiv' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
private function vorgabeIstEingeschaltet(): void
|
||||
{
|
||||
$id = $this->reihe('Ohne Angabe');
|
||||
$this->assertEquals(1, (int)RegelmaessigeBuchung::find($id)['abgleichen'], 'Ohne Angabe wird abgeglichen');
|
||||
|
||||
Database::execute(
|
||||
'INSERT INTO regelmaessige_buchungen (benutzer_id, name, konto_id, beginn) VALUES (?, ?, ?, ?)',
|
||||
[Mandant::id(), 'Spaltenvorgabe', $this->giro, '2026-06-01']
|
||||
);
|
||||
$this->assertEquals(
|
||||
1,
|
||||
(int)RegelmaessigeBuchung::find(Database::lastInsertId())['abgleichen'],
|
||||
'Die Spalte steht in der Vorgabe auf eingeschaltet'
|
||||
);
|
||||
}
|
||||
|
||||
private function schalterLaesstSichSpeichern(): void
|
||||
{
|
||||
$daten = [
|
||||
'name' => 'Umschalten', 'konto_id' => $this->giro, 'betrag' => -5,
|
||||
'intervall' => 'monatlich', 'beginn' => '2026-06-01', 'aktiv' => 1, 'abgleichen' => 0,
|
||||
];
|
||||
$id = RegelmaessigeBuchung::anlegen($daten);
|
||||
$this->assertEquals(0, (int)RegelmaessigeBuchung::find($id)['abgleichen'], 'Ausgeschaltet wird gespeichert');
|
||||
|
||||
RegelmaessigeBuchung::speichern($id, ['abgleichen' => 1] + $daten);
|
||||
$this->assertEquals(1, (int)RegelmaessigeBuchung::find($id)['abgleichen'], 'Einschalten wird gespeichert');
|
||||
}
|
||||
|
||||
private function buchenGleichtAb(): void
|
||||
{
|
||||
$buchung = RegelmaessigeBuchung::buchen($this->reihe('Miete', ['abgleichen' => 1]));
|
||||
$this->assertEquals(1, (int)Buchung::find($buchung)['abgeglichen'], 'Die Buchung ist abgeglichen');
|
||||
}
|
||||
|
||||
private function ohneSchalterBleibtOffen(): void
|
||||
{
|
||||
$buchung = RegelmaessigeBuchung::buchen($this->reihe('Strom', ['abgleichen' => 0]));
|
||||
$this->assertEquals(0, (int)Buchung::find($buchung)['abgeglichen'], 'Ohne Schalter bleibt sie offen');
|
||||
}
|
||||
|
||||
private function faelligkeitslaufGleichtAb(): void
|
||||
{
|
||||
$mit = $this->reihe('Lauf mit', ['automatisch' => 1, 'abgleichen' => 1, 'beginn' => date('Y-m-d')]);
|
||||
$ohne = $this->reihe('Lauf ohne', ['automatisch' => 1, 'abgleichen' => 0, 'beginn' => date('Y-m-d')]);
|
||||
|
||||
Faelligkeitslauf::ausfuehren();
|
||||
|
||||
$status = static fn(int $reihe): array => array_map('intval', array_column(Database::fetchAll(
|
||||
'SELECT abgeglichen FROM buchungen WHERE regelmaessige_id = ? AND benutzer_id = ?',
|
||||
[$reihe, Mandant::id()]
|
||||
), 'abgeglichen'));
|
||||
|
||||
$this->assertEquals([1], $status($mit), 'Der Fälligkeitslauf bucht abgeglichen');
|
||||
$this->assertEquals([0], $status($ohne), 'Ohne Schalter bucht der Lauf offen');
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
202
tests/62_WertpapierKlasseTest.php
Normal file
202
tests/62_WertpapierKlasseTest.php
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Finanzmanager\Models\Buchung;
|
||||
use Finanzmanager\Models\Depotbuchung;
|
||||
use Finanzmanager\Models\Einstellung;
|
||||
use Finanzmanager\Models\Kategorie;
|
||||
use Finanzmanager\Models\Klasse;
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\Models\Wertpapier;
|
||||
use Finanzmanager\Services\Wertpapiergeschaeft;
|
||||
use Finanzmanager\Services\Wertpapierkosten;
|
||||
|
||||
/**
|
||||
* Fix #125: Beim Buchen von Wertpapieren lässt sich eine Klasse wählen – im
|
||||
* Wertpapiergeschäft aus dem Kontoblatt wie in der Depotbuchung mit eigener
|
||||
* Geldbuchung.
|
||||
*/
|
||||
class WertpapierKlasseTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 62: Klasse beim Buchen von Wertpapieren';
|
||||
|
||||
private int $giro = 0;
|
||||
private int $depot = 0;
|
||||
private int $klasse = 0;
|
||||
private int $andereKlasse = 0;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->mandantAnlegen();
|
||||
|
||||
$this->giro = Konto::anlegen([
|
||||
'name' => 'KL Giro', 'kontoart' => 'bank', 'anfangssaldo' => 10000, 'aktiv' => 1,
|
||||
]);
|
||||
$this->depot = Konto::anlegen([
|
||||
'name' => 'KL Depot', 'kontoart' => 'depot', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||||
]);
|
||||
$this->klasse = Klasse::anlegen('KL Altersvorsorge');
|
||||
$this->andereKlasse = Klasse::anlegen('KL Haushalt');
|
||||
|
||||
Einstellung::set(
|
||||
Wertpapierkosten::schluessel('bankprovision'),
|
||||
(string)Kategorie::anlegen(['name' => 'KL Bankprovision', 'art' => 'ausgabe']),
|
||||
'wertpapiere'
|
||||
);
|
||||
|
||||
$this->geschaeftOhneKosten();
|
||||
$this->geschaeftMitKosten();
|
||||
$this->leereAuswahlLoeschtDieKlasse();
|
||||
$this->ohneAngabeBleibtDieKlasse();
|
||||
$this->unbekannteKlasse();
|
||||
$this->depotbuchungMitGeldbuchung();
|
||||
}
|
||||
|
||||
private function buchung(float $betrag, string $datum, ?int $klasse = null): int
|
||||
{
|
||||
return Buchung::anlegen([
|
||||
'konto_id' => $this->giro,
|
||||
'datum' => $datum,
|
||||
'betrag' => $betrag,
|
||||
'klasse_id' => $klasse,
|
||||
'quelle' => 'banking',
|
||||
]);
|
||||
}
|
||||
|
||||
private function geschaeftOhneKosten(): void
|
||||
{
|
||||
$id = $this->buchung(-500.0, '2026-09-01');
|
||||
|
||||
Wertpapiergeschaeft::buchen(Buchung::find($id), [
|
||||
'depot_id' => $this->depot,
|
||||
'wertpapier_name' => 'KL Ohne Kosten AG',
|
||||
'stueck' => 5,
|
||||
'klasse_id' => $this->klasse,
|
||||
]);
|
||||
|
||||
$buchung = Buchung::find($id);
|
||||
$this->assertEquals($this->depot, (int)$buchung['gegenkonto_id'], 'Die Umbuchung ins Depot entsteht');
|
||||
$this->assertEquals($this->klasse, (int)$buchung['klasse_id'], 'Die gewählte Klasse steht an der Buchung');
|
||||
}
|
||||
|
||||
private function geschaeftMitKosten(): void
|
||||
{
|
||||
$id = $this->buchung(-255.0, '2026-09-02');
|
||||
|
||||
Wertpapiergeschaeft::buchen(Buchung::find($id), [
|
||||
'depot_id' => $this->depot,
|
||||
'wertpapier_name' => 'KL Mit Kosten AG',
|
||||
'stueck' => 2,
|
||||
'kosten' => ['bankprovision' => 5.0],
|
||||
'klasse_id' => $this->klasse,
|
||||
]);
|
||||
|
||||
$buchung = Buchung::find($id);
|
||||
$this->assertEquals($this->klasse, (int)$buchung['klasse_id'], 'Auch die Splitbuchung trägt die Klasse');
|
||||
|
||||
$splits = Buchung::splits($id);
|
||||
$this->assertEquals(2, count($splits), 'Depot und Bankprovision');
|
||||
foreach ($splits as $nr => $zeile) {
|
||||
$this->assertEquals($this->klasse, (int)$zeile['klasse_id'], 'Splitzeile ' . $nr . ' trägt die Klasse');
|
||||
}
|
||||
|
||||
$anzeige = Buchung::splitKlasse($splits, (int)$buchung['klasse_id']);
|
||||
$this->assert(!$anzeige['gemischt'], 'Das Kontoblatt zeigt keine gemischten Klassen');
|
||||
$this->assertEquals($this->klasse, $anzeige['klasse_id'], 'Sondern die eine gewählte Klasse');
|
||||
}
|
||||
|
||||
/** Die Maske ist mit der Klasse der Buchung vorbelegt – leer heißt: keine. */
|
||||
private function leereAuswahlLoeschtDieKlasse(): void
|
||||
{
|
||||
$id = $this->buchung(-100.0, '2026-09-03', $this->andereKlasse);
|
||||
|
||||
Wertpapiergeschaeft::buchen(Buchung::find($id), [
|
||||
'depot_id' => $this->depot,
|
||||
'wertpapier_name' => 'KL Leer AG',
|
||||
'stueck' => 1,
|
||||
'klasse_id' => null,
|
||||
]);
|
||||
|
||||
$this->assertNull(Buchung::find($id)['klasse_id'], 'Die leere Auswahl entfernt die Klasse');
|
||||
}
|
||||
|
||||
private function ohneAngabeBleibtDieKlasse(): void
|
||||
{
|
||||
$id = $this->buchung(-110.0, '2026-09-04', $this->andereKlasse);
|
||||
|
||||
Wertpapiergeschaeft::buchen(Buchung::find($id), [
|
||||
'depot_id' => $this->depot,
|
||||
'wertpapier_name' => 'KL Ohne Angabe AG',
|
||||
'stueck' => 1,
|
||||
'kosten' => ['bankprovision' => 10.0],
|
||||
]);
|
||||
|
||||
$this->assertEquals(
|
||||
$this->andereKlasse,
|
||||
(int)Buchung::find($id)['klasse_id'],
|
||||
'Ohne Angabe bleibt die Klasse der Buchung'
|
||||
);
|
||||
foreach (Buchung::splits($id) as $zeile) {
|
||||
$this->assertEquals($this->andereKlasse, (int)$zeile['klasse_id'], 'Und die Splitzeilen übernehmen sie');
|
||||
}
|
||||
}
|
||||
|
||||
private function unbekannteKlasse(): void
|
||||
{
|
||||
$id = $this->buchung(-120.0, '2026-09-05');
|
||||
|
||||
$abgewiesen = false;
|
||||
try {
|
||||
Wertpapiergeschaeft::buchen(Buchung::find($id), [
|
||||
'depot_id' => $this->depot,
|
||||
'wertpapier_name' => 'KL Unbekannt AG',
|
||||
'stueck' => 1,
|
||||
'klasse_id' => 999999999,
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$abgewiesen = true;
|
||||
}
|
||||
$this->assert($abgewiesen, 'Eine unbekannte Klasse wird abgewiesen');
|
||||
$this->assertNull(Depotbuchung::findeZuBuchung($id), 'Und es entsteht kein Geschäft');
|
||||
}
|
||||
|
||||
/** Die Depotbuchung unter Wertpapiere legt die Geldbuchung selbst an. */
|
||||
private function depotbuchungMitGeldbuchung(): void
|
||||
{
|
||||
$papier = Wertpapier::findenOderAnlegen(['name' => 'KL Depot AG', 'isin' => 'DE000KL00001']);
|
||||
|
||||
$depotbuchungId = Depotbuchung::anlegen([
|
||||
'konto_id' => $this->depot,
|
||||
'wertpapier_id' => $papier,
|
||||
'datum' => '2026-09-06',
|
||||
'vorgang' => 'kauf',
|
||||
'stueck' => 10,
|
||||
'kurs' => 20,
|
||||
'betrag' => 200,
|
||||
'verrechnungskonto_id' => $this->giro,
|
||||
'klasse_id' => $this->klasse,
|
||||
]);
|
||||
|
||||
$geldbuchung = Buchung::find((int)Depotbuchung::find($depotbuchungId)['buchung_id']);
|
||||
$this->assertNotEmpty($geldbuchung, 'Die Geldbuchung entsteht');
|
||||
$this->assertEquals($this->klasse, (int)$geldbuchung['klasse_id'], 'Mit der gewählten Klasse');
|
||||
|
||||
$abgewiesen = false;
|
||||
try {
|
||||
Depotbuchung::anlegen([
|
||||
'konto_id' => $this->depot,
|
||||
'wertpapier_id' => $papier,
|
||||
'datum' => '2026-09-07',
|
||||
'vorgang' => 'kauf',
|
||||
'stueck' => 1,
|
||||
'betrag' => 20,
|
||||
'verrechnungskonto_id' => $this->giro,
|
||||
'klasse_id' => 999999999,
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$abgewiesen = true;
|
||||
}
|
||||
$this->assert($abgewiesen, 'Auch hier wird eine unbekannte Klasse abgewiesen');
|
||||
}
|
||||
}
|
||||
52
tests/63_DepotbuchungDritterWertTest.php
Normal file
52
tests/63_DepotbuchungDritterWertTest.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Finanzmanager\Models\Depotbuchung;
|
||||
|
||||
/**
|
||||
* Fix #129: Bei Kauf und Verkauf ergibt sich aus zwei der drei Angaben Stück,
|
||||
* Kurs und Betrag die dritte – Betrag = Stück × Kurs. Die Maske rechnet im
|
||||
* Browser, der Server dieselbe Regel für den Weg ohne JavaScript.
|
||||
*/
|
||||
class DepotbuchungDritterWertTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 63: Depotbuchung – dritten Wert aus Stück, Kurs und Betrag';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$werte = Depotbuchung::dritterWert('kauf', 12.0, 45.67, 0.0);
|
||||
$this->assertEquals(548.04, $werte['betrag'], 'Kauf: Betrag = Stück × Kurs');
|
||||
$this->assertEquals(12.0, $werte['stueck'], 'Kauf: Stück bleibt');
|
||||
$this->assertEquals(45.67, $werte['kurs'], 'Kauf: Kurs bleibt');
|
||||
|
||||
$werte = Depotbuchung::dritterWert('verkauf', 8.0, 0.0, 1000.0);
|
||||
$this->assertEquals(125.0, $werte['kurs'], 'Verkauf: Kurs = Betrag / Stück');
|
||||
|
||||
$werte = Depotbuchung::dritterWert('kauf', 0.0, 3.0, 100.0);
|
||||
$this->assertEquals(33.333333, $werte['stueck'], 'Kauf: Stück = Betrag / Kurs, sechs Nachkommastellen');
|
||||
|
||||
$werte = Depotbuchung::dritterWert('kauf', 10.0, 20.0, 150.0);
|
||||
$this->assertEquals(
|
||||
['stueck' => 10.0, 'kurs' => 20.0, 'betrag' => 150.0],
|
||||
$werte,
|
||||
'Alle drei angegeben: nichts wird überschrieben'
|
||||
);
|
||||
|
||||
$werte = Depotbuchung::dritterWert('kauf', 0.0, 0.0, 150.0);
|
||||
$this->assertEquals(
|
||||
['stueck' => 0.0, 'kurs' => 0.0, 'betrag' => 150.0],
|
||||
$werte,
|
||||
'Nur eine Angabe: nichts zu rechnen'
|
||||
);
|
||||
|
||||
$werte = Depotbuchung::dritterWert('dividende', 0.0, 2.0, 50.0);
|
||||
$this->assertEquals(0.0, $werte['stueck'], 'Dividende: keine Stückzahl aus Betrag und Kurs');
|
||||
|
||||
$werte = Depotbuchung::dritterWert('einlage', 5.0, 0.0, 500.0);
|
||||
$this->assertEquals(0.0, $werte['kurs'], 'Einlieferung: kein Kurs aus Betrag und Stück');
|
||||
|
||||
$werte = Depotbuchung::dritterWert('einlage', 5.0, 10.0, 0.0);
|
||||
$this->assertEquals(50.0, $werte['betrag'], 'Einlieferung: Betrag aus Stück × Kurs wie bisher');
|
||||
}
|
||||
}
|
||||
78
tests/64_AnmeldeMarkeTest.php
Normal file
78
tests/64_AnmeldeMarkeTest.php
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Die Anmeldeseite (und jede andere Seite im schlichten Rahmen) trägt dieselbe
|
||||
* Marke wie die Seitenleiste nach dem Anmelden: Coinstack-Symbol und
|
||||
* zweifarbiger Schriftzug.
|
||||
*/
|
||||
class AnmeldeMarkeTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 64: Marke der Anmeldeseite';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->anmeldungZeigtSymbolUndSchriftzug();
|
||||
$this->stilblattGleichtDerSeitenleiste();
|
||||
}
|
||||
|
||||
private function anmeldungZeigtSymbolUndSchriftzug(): void
|
||||
{
|
||||
$seite = $this->rendereAnmeldung();
|
||||
|
||||
$this->assert(
|
||||
str_contains($seite, '<div class="anmelde-marke">'),
|
||||
'Die Anmeldeseite hat eine eigene Marke'
|
||||
);
|
||||
$this->assert(
|
||||
str_contains($seite, '<img src="/static/coinstack.png" alt="" class="marken-symbol">'),
|
||||
'Die Marke führt das Coinstack-Symbol wie die Seitenleiste'
|
||||
);
|
||||
$this->assert(
|
||||
str_contains($seite, '<span class="marken-text">Finanz<span>manager</span></span>'),
|
||||
'Der Schriftzug ist wie in der Seitenleiste zweigeteilt'
|
||||
);
|
||||
$this->assert(
|
||||
!str_contains($seite, 'class="marke gross"'),
|
||||
'Die alte Marke im Stil einer Statusmarke ist verschwunden'
|
||||
);
|
||||
}
|
||||
|
||||
private function stilblattGleichtDerSeitenleiste(): void
|
||||
{
|
||||
$css = (string)file_get_contents(dirname(__DIR__) . '/public/static/app.css');
|
||||
|
||||
$this->assert(
|
||||
(bool)preg_match('/\.anmelde-marke\s*\{[^}]*font-weight:\s*700;/', $css),
|
||||
'Der Schriftzug ist fett wie in der Seitenleiste'
|
||||
);
|
||||
$this->assert(
|
||||
(bool)preg_match('/\.anmelde-marke\s*\{[^}]*display:\s*flex;[^}]*align-items:\s*center;/', $css),
|
||||
'Symbol und Schriftzug stehen nebeneinander auf einer Linie'
|
||||
);
|
||||
$this->assert(
|
||||
(bool)preg_match('/\.anmelde-marke \.marken-text span\s*\{\s*color:\s*var\(--akzent-schrift\);/', $css),
|
||||
'Die zweite Worthälfte trägt die Akzentfarbe'
|
||||
);
|
||||
$this->assert(
|
||||
!preg_match('/\.marke\.gross\s*\{/', $css),
|
||||
'Die alte Regel für die große Statusmarke ist entfernt'
|
||||
);
|
||||
}
|
||||
|
||||
private function rendereAnmeldung(): string
|
||||
{
|
||||
$csrfToken = 'test';
|
||||
$titel = 'Anmeldung';
|
||||
$flash = [];
|
||||
|
||||
ob_start();
|
||||
try {
|
||||
include dirname(__DIR__) . '/src/Views/auth/login.php';
|
||||
} finally {
|
||||
$html = (string)ob_get_clean();
|
||||
}
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
194
tests/65_OhneBargeldAbgleichTest.php
Normal file
194
tests/65_OhneBargeldAbgleichTest.php
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Finanzmanager\Database;
|
||||
use Finanzmanager\Models\Buchung;
|
||||
use Finanzmanager\Models\Einstellung;
|
||||
use Finanzmanager\Models\Kategorie;
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\Services\Auswertung;
|
||||
use Finanzmanager\Services\BargeldAbgleich;
|
||||
use Finanzmanager\Services\Cockpit;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/**
|
||||
* Bewusst ohne Abgleich mit dem Bargeld-Tool buchen (#136).
|
||||
*
|
||||
* Sammelbuchungen und Differenzausgleiche tragen eine Kategorie, die gegen
|
||||
* das Bargeld-Tool geprüft wird, stehen dort aber absichtlich nicht. Geprüft
|
||||
* wird, dass der Schalter gespeichert wird, den Abgleich unterbindet und die
|
||||
* Buchung damit aus den offenen Vorgängen im Cockpit nimmt – auch dann, wenn
|
||||
* sie vorher schon als „nicht gefunden“ angemahnt war.
|
||||
*/
|
||||
class OhneBargeldAbgleichTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 65: Ohne Bargeld-Abgleich';
|
||||
|
||||
private int $konto = 0;
|
||||
private int $kategorie = 0;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->mandantAnlegen();
|
||||
$this->konto = Konto::anlegen([
|
||||
'name' => 'Haushaltsgiro', 'kontoart' => 'bank', 'anfangssaldo' => 500, 'aktiv' => 1,
|
||||
]);
|
||||
$this->kategorie = Kategorie::anlegen(['name' => 'Haushalt bar', 'art' => 'ausgabe']);
|
||||
Kategorie::bargeldAuswahlSetzen([$this->kategorie]);
|
||||
|
||||
// Eine Adresse, unter der niemand antwortet: Jeder echte Abruf endet
|
||||
// als Fehler – ein Abruf, der gar nicht erst stattfindet, nicht.
|
||||
Einstellung::set('bargeld_url', 'http://127.0.0.1:9', 'bargeld');
|
||||
Einstellung::cacheLeeren();
|
||||
|
||||
$this->schalterWirdGespeichert();
|
||||
$this->keinAbgleichUndKeinOffenerVorgang();
|
||||
$this->nachtraeglichSetzenRaeumtDenStatusWeg();
|
||||
$this->schnellzuordnungBehaeltDenSchalter();
|
||||
$this->anzeigeImKontoblattUndBericht();
|
||||
$this->maskeZeigtDenSchalter();
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $zusatz */
|
||||
private function buchung(string $datum, array $zusatz = []): int
|
||||
{
|
||||
return Buchung::anlegen($zusatz + [
|
||||
'konto_id' => $this->konto,
|
||||
'datum' => $datum,
|
||||
'betrag' => -45.10,
|
||||
'empfaenger' => 'Sammelbuchung Bargeld',
|
||||
'kategorie_id' => $this->kategorie,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string,int> */
|
||||
private function vorgaenge(): array
|
||||
{
|
||||
return array_column(Cockpit::offeneVorgaenge('2026-09-30'), 'anzahl', 'schluessel');
|
||||
}
|
||||
|
||||
private function schalterWirdGespeichert(): void
|
||||
{
|
||||
$id = $this->buchung('2026-09-01', ['ohne_bargeld_abgleich' => true]);
|
||||
$this->assertEquals(1, (int)Buchung::find($id)['ohne_bargeld_abgleich'], 'Der Schalter wird gespeichert');
|
||||
|
||||
$normal = $this->buchung('2026-09-02');
|
||||
$this->assertEquals(0, (int)Buchung::find($normal)['ohne_bargeld_abgleich'], 'Ohne Schalter bleibt er aus');
|
||||
}
|
||||
|
||||
private function keinAbgleichUndKeinOffenerVorgang(): void
|
||||
{
|
||||
$vorher = $this->vorgaenge()['bargeld_fehler'] ?? 0;
|
||||
|
||||
$ohne = $this->buchung('2026-09-05', ['ohne_bargeld_abgleich' => true]);
|
||||
$this->assertNull(BargeldAbgleich::pruefe($ohne), 'Eine Buchung ohne Bargeld-Abgleich wird nicht geprüft');
|
||||
$this->assertNull(Buchung::find($ohne)['bargeld_status'], 'Sie trägt keinen Abgleichstatus');
|
||||
$this->assertEquals($vorher, $this->vorgaenge()['bargeld_fehler'] ?? 0, 'Sie erscheint nicht unter den offenen Vorgängen');
|
||||
|
||||
// Gegenprobe: Dieselbe Buchung ohne Schalter wird geprüft und angemahnt.
|
||||
$mit = $this->buchung('2026-09-06');
|
||||
$this->assertEquals(BargeldAbgleich::FEHLER, BargeldAbgleich::pruefe($mit), 'Ohne Schalter wird abgeglichen');
|
||||
$this->assertEquals($vorher + 1, $this->vorgaenge()['bargeld_fehler'] ?? 0, 'Und als offener Vorgang angemahnt');
|
||||
}
|
||||
|
||||
private function nachtraeglichSetzenRaeumtDenStatusWeg(): void
|
||||
{
|
||||
$id = $this->buchung('2026-09-10');
|
||||
Database::execute("UPDATE buchungen SET bargeld_status = 'fehlt', bargeld_hinweis = 'Kein Treffer.' WHERE id = ?", [$id]);
|
||||
$vorher = $this->vorgaenge()['bargeld_fehlt'] ?? 0;
|
||||
$this->assertGreaterThan(0, $vorher, 'Die nicht gefundene Buchung wird angemahnt');
|
||||
|
||||
$buchung = Buchung::find($id);
|
||||
Buchung::speichern($id, ['ohne_bargeld_abgleich' => 1] + $buchung);
|
||||
BargeldAbgleich::pruefe($id);
|
||||
|
||||
$frisch = Buchung::find($id);
|
||||
$this->assertEquals(1, (int)$frisch['ohne_bargeld_abgleich'], 'Der Schalter lässt sich nachträglich setzen');
|
||||
$this->assertNull($frisch['bargeld_status'], 'Der alte Abgleichstatus verschwindet');
|
||||
$this->assertEquals($vorher - 1, $this->vorgaenge()['bargeld_fehlt'] ?? 0, 'Die Buchung wird nicht mehr angemahnt');
|
||||
|
||||
Buchung::speichern($id, ['ohne_bargeld_abgleich' => 0] + $frisch);
|
||||
$this->assertEquals(0, (int)Buchung::find($id)['ohne_bargeld_abgleich'], 'Abgeschaltet gilt der Abgleich wieder');
|
||||
}
|
||||
|
||||
private function schnellzuordnungBehaeltDenSchalter(): void
|
||||
{
|
||||
$id = $this->buchung('2026-09-12', ['ohne_bargeld_abgleich' => true, 'kategorie_id' => null]);
|
||||
Buchung::zuordnen($id, $this->kategorie, null);
|
||||
$this->assertNull(BargeldAbgleich::pruefe($id), 'Auch nach der Schnellzuordnung wird nicht abgeglichen');
|
||||
$this->assertEquals(
|
||||
1,
|
||||
(int)Buchung::find($id)['ohne_bargeld_abgleich'],
|
||||
'Die Schnellzuordnung lässt den Schalter stehen'
|
||||
);
|
||||
}
|
||||
|
||||
private function anzeigeImKontoblattUndBericht(): void
|
||||
{
|
||||
$marke = View::bargeldMarke(null, null, true);
|
||||
$this->assert(str_contains($marke, 'Bewusst ohne Abgleich mit dem Bargeld-Tool'), 'Die Marke nennt den Grund');
|
||||
$this->assert(str_contains($marke, '○'), 'Die Marke ist ein leerer Kreis');
|
||||
$this->assert(str_contains(View::bargeldMarke(null, null), '●'), 'Ohne Schalter bleibt der volle Kreis');
|
||||
|
||||
$zeilen = Buchung::kontoblatt($this->konto, ['von' => '2026-09-01', 'bis' => '2026-09-01']);
|
||||
$this->assertEquals(1, (int)($zeilen[0]['ohne_bargeld_abgleich'] ?? 0), 'Das Kontoblatt liefert den Schalter mit');
|
||||
|
||||
$bericht = Auswertung::bericht(Auswertung::normalisiere([
|
||||
'typ' => 'buchungsbericht', 'zeitraum' => 'alles',
|
||||
]), '2026-09-30');
|
||||
$buchungen = array_merge(...array_column($bericht['gruppen'], 'buchungen'));
|
||||
$treffer = array_values(array_filter(
|
||||
$buchungen,
|
||||
static fn(array $b): bool => $b['datum'] === '2026-09-01' && $b['ohne_bargeld_abgleich']
|
||||
));
|
||||
$this->assertEquals(1, count($treffer), 'Der Buchungsbericht liefert den Schalter mit');
|
||||
}
|
||||
|
||||
private function maskeZeigtDenSchalter(): void
|
||||
{
|
||||
$mit = $this->maske(true, ['ohne_bargeld_abgleich' => 1]);
|
||||
$this->assert(
|
||||
(bool)preg_match('#name="ohne_bargeld_abgleich" value="1"\s*checked#', $mit),
|
||||
'Mit Bargeld-Tool steht der Schalter gesetzt in der Maske'
|
||||
);
|
||||
$this->assert(str_contains($mit, 'Ohne Bargeld-Abgleich'), 'Er ist beschriftet');
|
||||
|
||||
$ohne = $this->maske(false, ['ohne_bargeld_abgleich' => 1]);
|
||||
$this->assert(!str_contains($ohne, 'Ohne Bargeld-Abgleich'), 'Ohne Bargeld-Tool ist der Schalter unsichtbar');
|
||||
$this->assert(
|
||||
str_contains($ohne, '<input type="hidden" name="ohne_bargeld_abgleich" value="1">'),
|
||||
'Ein gesetzter Wert geht trotzdem nicht verloren'
|
||||
);
|
||||
|
||||
$leer = $this->maske(false, []);
|
||||
$this->assert(!str_contains($leer, 'ohne_bargeld_abgleich'), 'Ohne Bargeld-Tool und ohne Wert steht nichts in der Maske');
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $buchung */
|
||||
private function maske(bool $bargeld, array $buchung): string
|
||||
{
|
||||
$daten = [
|
||||
'titel' => 'Buchung bearbeiten',
|
||||
'konto' => Konto::find($this->konto),
|
||||
'buchung' => $buchung + [
|
||||
'id' => 1, 'konto_id' => $this->konto, 'datum' => '2026-09-01', 'betrag' => -10.0,
|
||||
],
|
||||
'splits' => [],
|
||||
'eingaben' => null,
|
||||
'kategorien' => Kategorie::baumFlach(),
|
||||
'klassen' => [],
|
||||
'konten' => Konto::alle(),
|
||||
'bargeld' => $bargeld,
|
||||
'flash' => [],
|
||||
'benutzer' => null,
|
||||
'csrfToken' => 'x',
|
||||
'aktuellerPfad' => '/konten',
|
||||
];
|
||||
extract($daten, EXTR_SKIP);
|
||||
|
||||
ob_start();
|
||||
include dirname(__DIR__) . '/src/Views/buchungen/formular.php';
|
||||
return (string)ob_get_clean();
|
||||
}
|
||||
}
|
||||
|
|
@ -67,6 +67,27 @@ test.describe('Auswertungen', () => {
|
|||
await expect(page.locator('thead th', { hasText: 'Saldo' })).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Buchungsbericht führt die Spalte „abgeglichen“ und filtert danach (#137)', async ({ page }) => {
|
||||
await vorlage(page, 'buchungsbericht')
|
||||
await expect(page.locator('table.bericht thead th.abgleichspalte')).toHaveCount(1)
|
||||
|
||||
await page.selectOption('select[name="abgeglichen"]', 'nein')
|
||||
await page.click('button:has-text("Anzeigen")')
|
||||
await expect(page).toHaveURL(/abgeglichen=nein/)
|
||||
await expect(page.locator('select[name="abgeglichen"]')).toHaveValue('nein')
|
||||
await expect(page.locator('table.bericht tbody td.abgleichspalte', { hasText: '✓' })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Cockpit: „nicht abgeglichen“ öffnet den gefilterten Buchungsbericht (#137)', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const eintrag = page.locator('.vorgangsliste a', { hasText: 'nicht abgeglichen' })
|
||||
test.skip(await eintrag.count() === 0, 'Keine nicht abgeglichenen Buchungen auf dieser Instanz')
|
||||
|
||||
await eintrag.first().click()
|
||||
await expect(page).toHaveURL(/typ=buchungsbericht/)
|
||||
await expect(page.locator('select[name="abgeglichen"]')).toHaveValue('nein')
|
||||
})
|
||||
|
||||
test('Spalten-Aufteilung macht aus einer Spalte zwölf Monate', async ({ page }) => {
|
||||
await vorlage(page, 'summenbericht')
|
||||
await expect(page.locator('table.bericht thead th')).toHaveCount(2)
|
||||
|
|
|
|||
|
|
@ -183,5 +183,60 @@ test.describe('Bargeld-Tool', () => {
|
|||
const danach = page.locator('tr', { hasText: empfaenger })
|
||||
await expect(danach).toContainText(KATEGORIE)
|
||||
await expect(danach.locator('td.bargeldspalte .bargeld-fehler')).toBeVisible()
|
||||
|
||||
// Der offene Vorgang im Cockpit führt in den Buchungsbericht, gefiltert
|
||||
// auf fehlgeschlagene Prüfungen – samt Kategorie (#131).
|
||||
await page.goto('/')
|
||||
await page.locator('.vorgangsliste a', { hasText: 'Bargeld-Tool-Prüfung fehlgeschlagen' }).click()
|
||||
await expect(page).toHaveURL(/\/auswertungen\/bericht\?.*typ=buchungsbericht/)
|
||||
await expect(page.locator('select[name="bargeld"]')).toHaveValue('fehler')
|
||||
await expect(page.locator('table.buchungsgruppen thead')).toContainText('Kategorie')
|
||||
const imBericht = page.locator('table.buchungsgruppen tr', { hasText: empfaenger })
|
||||
await expect(imBericht).toContainText(KATEGORIE)
|
||||
await expect(imBericht).toContainText(GIRO)
|
||||
await expect(imBericht.locator('td.bargeldspalte .bargeld-fehler')).toBeVisible()
|
||||
})
|
||||
|
||||
// Sammelbuchungen und Differenzausgleiche stehen absichtlich nicht im
|
||||
// Bargeld-Tool (#136): Der Schalter nimmt sie aus dem Abgleich.
|
||||
test('Ohne Bargeld-Abgleich wird nicht geprüft und nicht angemahnt', async ({ page }) => {
|
||||
const empfaenger = `Sammelbuchung ${MARKE}`
|
||||
|
||||
// Ohne Adresse gibt es den Schalter nicht.
|
||||
await page.goto(`/konten/${giroId}/buchung`)
|
||||
await expect(page.locator('#ohne_bargeld_abgleich')).toHaveCount(0)
|
||||
|
||||
await page.goto('/verwaltung/bargeld')
|
||||
await page.fill('#bargeld_url', ADRESSE)
|
||||
await page.getByRole('checkbox', { name: KATEGORIE }).check()
|
||||
await page.getByRole('button', { name: 'Speichern' }).click()
|
||||
await expect(page.locator('.meldung')).toContainText('gespeichert')
|
||||
|
||||
await page.goto(`/konten/${giroId}/buchung`)
|
||||
const schalter = page.locator('#ohne_bargeld_abgleich')
|
||||
await expect(schalter).toBeVisible()
|
||||
await expect(schalter).not.toBeChecked()
|
||||
|
||||
await page.selectOption('#art', 'ausgabe')
|
||||
await page.fill('#datum', '2026-04-05')
|
||||
await page.fill('#betrag', '73,20')
|
||||
await page.fill('#empfaenger', empfaenger)
|
||||
await page.selectOption('#zuordnung', { label: KATEGORIE })
|
||||
await schalter.check()
|
||||
await page.click('.buchungsmaske button[type="submit"]')
|
||||
|
||||
// Kein Abruf – also auch keine Meldung über einen gescheiterten.
|
||||
await expect(page.locator('.meldung-erfolg')).toContainText('Buchung angelegt')
|
||||
await expect(page.locator('.meldung-erfolg')).not.toContainText('Bargeld-Tool')
|
||||
|
||||
const zeile = page.locator('table.kontoblatt tbody tr', { hasText: empfaenger })
|
||||
await expect(zeile).toContainText(KATEGORIE)
|
||||
const marke = zeile.locator('td.bargeldspalte .bargeld-neutral')
|
||||
await expect(marke).toHaveText('○')
|
||||
await expect(marke).toHaveAttribute('title', 'Bewusst ohne Abgleich mit dem Bargeld-Tool')
|
||||
|
||||
// Beim Bearbeiten steht der Schalter wieder gesetzt da.
|
||||
await zeile.getByRole('link', { name: empfaenger }).click()
|
||||
await expect(page.locator('#ohne_bargeld_abgleich')).toBeChecked()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
79
tests/e2e/specs/depotbuchung.spec.js
Normal file
79
tests/e2e/specs/depotbuchung.spec.js
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// @ts-check
|
||||
const { test, expect } = require('@playwright/test')
|
||||
|
||||
/*
|
||||
* Fix #129: In „Depotbuchung erfassen“ hängen bei Kauf und Verkauf Stück, Kurs
|
||||
* und Betrag zusammen – Betrag = Stück × Kurs. Sobald zwei Felder eingetragen
|
||||
* sind, rechnet die Maske das dritte aus.
|
||||
*/
|
||||
|
||||
const BENUTZER = process.env.E2E_USER || 'e2e'
|
||||
const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort'
|
||||
|
||||
async function anmelden(page) {
|
||||
await page.goto('/login')
|
||||
if (page.url().includes('/einrichtung')) {
|
||||
throw new Error(
|
||||
'Instanz ist noch nicht eingerichtet: ' +
|
||||
'docker compose exec -u www-data php php scripts/e2e-benutzer.php'
|
||||
)
|
||||
}
|
||||
await page.fill('#benutzername', BENUTZER)
|
||||
await page.fill('#passwort', PASSWORT)
|
||||
await page.click('button[type="submit"]')
|
||||
await expect(page).toHaveURL(/\/$/)
|
||||
}
|
||||
|
||||
test.describe('Depotbuchung erfassen: Stück, Kurs und Betrag', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await anmelden(page)
|
||||
await page.goto('/wertpapiere/buchungen/neu')
|
||||
})
|
||||
|
||||
test('Aus Stück und Kurs ergibt sich der Betrag', async ({ page }) => {
|
||||
await page.selectOption('#vorgang', 'kauf')
|
||||
await page.fill('#stueck', '12')
|
||||
await page.fill('#kurs', '45,67')
|
||||
await expect(page.locator('#betrag')).toHaveValue('548,04')
|
||||
await expect(page.locator('#betrag')).toHaveClass(/berechnet/)
|
||||
|
||||
// Der ausgerechnete Betrag zieht nach, wenn sich ein Feld ändert.
|
||||
await page.fill('#stueck', '10')
|
||||
await expect(page.locator('#betrag')).toHaveValue('456,70')
|
||||
|
||||
// Wird ein Feld geleert, steht hinter dem Betrag nichts mehr.
|
||||
await page.fill('#kurs', '')
|
||||
await expect(page.locator('#betrag')).toHaveValue('')
|
||||
})
|
||||
|
||||
test('Aus Betrag und Stück ergibt sich der Kurs', async ({ page }) => {
|
||||
await page.selectOption('#vorgang', 'verkauf')
|
||||
await page.fill('#betrag', '1.000,00')
|
||||
await page.fill('#stueck', '8')
|
||||
await expect(page.locator('#kurs')).toHaveValue('125')
|
||||
})
|
||||
|
||||
test('Aus Betrag und Kurs ergibt sich die Stückzahl', async ({ page }) => {
|
||||
await page.selectOption('#vorgang', 'kauf')
|
||||
await page.fill('#kurs', '3')
|
||||
await page.fill('#betrag', '100')
|
||||
await expect(page.locator('#stueck')).toHaveValue('33,333333')
|
||||
|
||||
// Selbst eingetragen gilt der Wert als fest und wird nicht mehr überschrieben.
|
||||
await page.fill('#stueck', '30')
|
||||
await page.fill('#kurs', '4')
|
||||
await expect(page.locator('#stueck')).toHaveValue('30')
|
||||
await expect(page.locator('#betrag')).toHaveValue('100')
|
||||
})
|
||||
|
||||
test('Bei einer Dividende wird nichts gerechnet', async ({ page }) => {
|
||||
await page.selectOption('#vorgang', 'dividende')
|
||||
await page.fill('#stueck', '12')
|
||||
await page.fill('#kurs', '2')
|
||||
await expect(page.locator('#betrag')).toHaveValue('')
|
||||
|
||||
// Wechselt der Vorgang auf Kauf, rechnet die Maske nach.
|
||||
await page.selectOption('#vorgang', 'kauf')
|
||||
await expect(page.locator('#betrag')).toHaveValue('24,00')
|
||||
})
|
||||
})
|
||||
|
|
@ -320,9 +320,19 @@ test.describe('Module der Phasen 4 bis 7', () => {
|
|||
await page.fill('#name', 'E2E-Reihe')
|
||||
await page.fill('#neu_betrag', '-12,50')
|
||||
await page.fill('#neu_buchungstext', 'E2E-Text')
|
||||
await expect(page.locator('#neudialog input[name="abgleichen"]')).toBeChecked()
|
||||
await page.click('form[action="/termine/neu"] button.primaer')
|
||||
|
||||
const zeile = page.locator('table.reihen tr', { hasText: 'E2E-Reihe' })
|
||||
await expect(page.locator('table.reihen thead th').nth(7)).toHaveText('Abgleichen')
|
||||
const abgleichen = zeile.locator('.feld:has(input[name="abgleichen"])')
|
||||
await expect(abgleichen.locator('.anzeige')).toHaveText('Ja')
|
||||
await abgleichen.locator('.anzeige').click()
|
||||
await expect(abgleichen.locator('.anzeige')).toHaveText('Nein')
|
||||
await zeile.locator('[data-speichern]').click()
|
||||
await expect(page.locator('table.reihen tr', { hasText: 'E2E-Reihe' })
|
||||
.locator('.feld:has(input[name="abgleichen"]) .anzeige')).toHaveText('Nein')
|
||||
|
||||
await expect(zeile.locator('input[name="betrag"]')).toBeHidden()
|
||||
await expect(zeile.locator('[data-speichern]')).toBeHidden()
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
||||
|
|
|
|||
|
|
@ -27,10 +27,33 @@ async function anmelden(page) {
|
|||
test.describe('Grundgerüst', () => {
|
||||
test('Anmeldeseite ist erreichbar', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
await expect(page.locator('h1, .marke')).toContainText(/Finanzmanager|Anmeldung/)
|
||||
await expect(page.locator('h1, .anmelde-marke')).toContainText(/Finanzmanager|Anmeldung/)
|
||||
await expect(page.locator('#benutzername')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Anmeldeseite zeigt dieselbe Marke wie die Seitenleiste', async ({ page }) => {
|
||||
const stil = loc => loc.evaluate(k => {
|
||||
const s = getComputedStyle(k)
|
||||
return { familie: s.fontFamily, gewicht: s.fontWeight }
|
||||
})
|
||||
|
||||
await page.goto('/login')
|
||||
const marke = page.locator('.anmelde-marke')
|
||||
await expect(marke.locator('img.marken-symbol')).toHaveAttribute('src', '/static/coinstack.png')
|
||||
await expect(marke.locator('img.marken-symbol')).toBeVisible()
|
||||
await expect(marke.locator('.marken-text')).toHaveText('Finanzmanager')
|
||||
const anmeldung = await stil(marke.locator('.marken-text'))
|
||||
// Die zweite Worthälfte hebt sich farblich ab, wie in der Seitenleiste.
|
||||
const farben = await marke.locator('.marken-text').evaluate(k => [
|
||||
getComputedStyle(k).color, getComputedStyle(k.querySelector('span')).color,
|
||||
])
|
||||
expect(farben[0]).not.toBe(farben[1])
|
||||
|
||||
await anmelden(page)
|
||||
const leiste = await stil(page.locator('.seitenleisten-marke .marken-text'))
|
||||
expect(anmeldung).toEqual(leiste)
|
||||
})
|
||||
|
||||
test('Ohne Anmeldung führt jeder Weg zur Anmeldung', async ({ page }) => {
|
||||
await page.goto('/einstellungen')
|
||||
await expect(page).toHaveURL(/\/(login|einrichtung)/)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const GIRO = `E2E WP Giro ${MARKE}`
|
|||
const DEPOT = `E2E WP Depot ${MARKE}`
|
||||
const KAT_GEBUEHR = `E2E Bankprovision ${MARKE}`
|
||||
const KAT_STEUER = `E2E Kapitalertragsteuer ${MARKE}`
|
||||
const KLASSE = `E2E WP Klasse ${MARKE}`
|
||||
|
||||
// ISIN und WKN tragen die Marke des Laufs: Ein Papier wird über seine Kennung
|
||||
// wiedergefunden, nicht über den Namen. Feste Kennungen würden den Test beim
|
||||
|
|
@ -87,6 +88,11 @@ test.describe('Wertpapiergeschäft aus dem Kontoblatt', () => {
|
|||
await kategorieAnlegen(page, KAT_GEBUEHR)
|
||||
await kategorieAnlegen(page, KAT_STEUER)
|
||||
|
||||
await page.goto('/verwaltung/klassen')
|
||||
await page.click('#neu-knopf')
|
||||
await page.fill('form[action="/verwaltung/klassen/neu"] #name', KLASSE)
|
||||
await page.click('form[action="/verwaltung/klassen/neu"] button.primaer')
|
||||
|
||||
// Die Kostenarten bekommen ihre feste Kategorie – danach fragt der
|
||||
// Kostendialog nur noch Beträge ab.
|
||||
await page.goto('/einstellungen?bereich=wertpapiere')
|
||||
|
|
@ -173,12 +179,17 @@ test.describe('Wertpapiergeschäft aus dem Kontoblatt', () => {
|
|||
await page.fill('#wp_name', `Sparplan-Fonds ${MARKE}`)
|
||||
await page.fill('#wp_stueck', '10')
|
||||
|
||||
// Die Klasse wird im Dialog gleich mitgewählt (#125).
|
||||
await expect(page.locator('#wp_klasse')).toHaveValue('')
|
||||
await page.selectOption('#wp_klasse', { label: KLASSE })
|
||||
|
||||
await page.locator('#wertpapierdialog button[type="submit"]').click()
|
||||
await expect(page.locator('.meldung-erfolg')).toContainText('Einstandspreis 1.000,00')
|
||||
|
||||
// Keine Splitbuchung mit einer einzigen Zeile, sondern eine Umbuchung.
|
||||
await expect(page.locator('tr', { hasText: empfaenger }).first())
|
||||
.toContainText(`[${DEPOT}]`)
|
||||
const danach = page.locator('tr', { hasText: empfaenger }).first()
|
||||
await expect(danach).toContainText(`[${DEPOT}]`)
|
||||
await expect(danach.locator('a.klassenfeld')).toHaveText(KLASSE)
|
||||
|
||||
// Ohne Kennung im Verwendungszweck entsteht das Papier unter dem
|
||||
// Namen, der in der Maske steht.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue