Merge pull request 'Kontoblatt-Zuordnung, CSV-Bestandsimport, Sammellöschen und Kontoauswahl beim QIF-Import' (#11) from fds2610-bot/Finanzmanager:main into main
Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/11
This commit is contained in:
commit
dd7025805c
32 changed files with 3520 additions and 224 deletions
143
Changes.md
143
Changes.md
|
|
@ -1,5 +1,148 @@
|
|||
# Änderungen
|
||||
|
||||
## QIF-Import: nur ausgewählte Konten übernehmen (12.08.2026)
|
||||
|
||||
Der QIF-Import kannte bisher nur den Zeitraum als Filter – ein Export ließ sich nur ganz
|
||||
einlesen. Jetzt lassen sich einzelne Konten wählen, etwa um ein Konto nachzuziehen, ohne
|
||||
die übrigen anzufassen.
|
||||
|
||||
**Kontoauswahl im Importeur** (`QifBuchungsImport`)
|
||||
- Neue Option `konten`. Maßgeblich ist nicht allein der `!Account`-Abschnitt, sondern jedes
|
||||
Konto, das der Eintrag berührt: das eigene, die Umbuchungsziele aus `L` und den
|
||||
Splitzeilen und beim Wertpapiergeschäft das Verrechnungskonto. Eine Umbuchung steht nur
|
||||
einmal in der Datei – sonst fehlte dem gewählten Konto die Gegenseite.
|
||||
- Die Anfangssalden werden weiter über die ganze Datei gerechnet, aber nur für die
|
||||
gewählten Konten geschrieben. Ein Teilimport überschreibt so keine fremden Salden.
|
||||
- Die Fingerabdrücke der Buchungen entstehen vorab in einem eigenen Durchgang. Der
|
||||
laufende Zähler für gleiche Fingerabdrücke hing sonst an der Auswahl, und dieselbe
|
||||
Buchung wäre bei einem zweiten Lauf mit anderer Auswahl doppelt angelegt worden.
|
||||
- `kontenDerDatei()` nennt die Konten eines Exports samt der Zahl der Einträge, die sie
|
||||
berühren – auch die, die nur Umbuchungsziel sind und keine eigene Zeile führen.
|
||||
|
||||
**Dialog** (`Views/verwaltung/import.php`, `ImportController`)
|
||||
- Nach dem Trockenlauf steht unter *Konten* die Ankreuzliste der Konten dieser Datei; vor
|
||||
dem ersten Lesen gibt es nichts zu wählen. Von dort geht es weiter zum erneuten
|
||||
Trockenlauf mit der Auswahl oder zum Übernehmen.
|
||||
- Die Vorschau zählt mit, wie viele Buchungen zu keinem der gewählten Konten gehören.
|
||||
- Was im Formular ankommt, wird gegen die Konten der Datei geprüft.
|
||||
|
||||
**Kommandozeile** (`scripts/qif-import.php`)
|
||||
- `--konto=NAME` (mehrfach angebbar) grenzt den Import ein, `--konten` nennt die Konten der
|
||||
Datei und beendet sich. Ein Name, den die Datei nicht führt, bricht ab – sonst liefe der
|
||||
Import durch und legte nichts an.
|
||||
|
||||
## Umbuchungen: Gegenkonto sichtbar, Zuordnung im Kontoblatt (12.08.2026)
|
||||
|
||||
Eine Umbuchung war auf dem Gegenkonto nicht mehr zuzuordnen: Die Zeile trug die Marke
|
||||
*Umbuchung*, die Kategoriespalte daneben blieb leer. Zu sehen etwa bei den Buchungen
|
||||
zwischen DKB Girokonto und Paypal – die Daten waren richtig, die Anzeige verschwieg sie.
|
||||
|
||||
**Beide Sichten nennen das Gegenüber** (`Views/buchungen/kontoblatt.php`)
|
||||
- In der Kategoriespalte steht jetzt auch in der Gegensicht das Konto in eckigen Klammern,
|
||||
von dem die Umbuchung kommt. Die Abfrage lieferte das Konto längst mit; die Ansicht hat
|
||||
es nur nicht gezeigt.
|
||||
|
||||
**Kategorie und Gegenkonto in einem Feld** (`Views/buchungen/zuordnungsliste.php`,
|
||||
`BuchungenController`)
|
||||
- Unter den Kategorien stehen die eigenen Konten in eckigen Klammern – wie im
|
||||
FinanzManager, dessen Kategoriefeld `[Konto]` annimmt. Eines davon macht die Buchung zur
|
||||
Umbuchung. Die Auswahl *Ausgabe / Einnahme* bestimmt seither nur noch die Richtung; das
|
||||
eigene Konto fehlt in der Liste.
|
||||
- Dieselbe Liste steht in den Splitzeilen. Eine Splitzeile mit Gegenkonto ließ sich bisher
|
||||
nur im Import anlegen und ging beim Bearbeiten der Buchung stillschweigend verloren.
|
||||
|
||||
**Schnellzuordnung im Kontoblatt** (`public/static/kontoblatt.js`, `Buchung::zuordnen()`)
|
||||
- Ein Klick auf die Kategoriespalte öffnet die Liste in der Zelle: leerer Eintrag,
|
||||
*Splitbuchung*, Kategorien, Konten. Die Auswahl speichert sofort
|
||||
(`POST /buchungen/{id}/zuordnung`) und kommt in dieselbe Ansicht zurück – Filter und
|
||||
Seite hängen am Ziel.
|
||||
- Die Liste gibt es einmal und wandert in die angeklickte Zelle. Eine Liste je Zeile wären
|
||||
bei hundert Zeilen ebenso viele vollständige Kategoriebäume im HTML.
|
||||
- Feld und Liste sind gleich breit und gleich hoch, die Zelle zeigt nur, was auch in der
|
||||
Liste steht – sonst rückte beim Anklicken die halbe Zeile.
|
||||
- Ohne JavaScript führt derselbe Klick in die Buchungsmaske: Die Zelle ist ein Link.
|
||||
- Auch die Gegenseite einer fremden Umbuchung ist anklickbar; sie führt zu ihrer Buchung
|
||||
auf dem anderen Konto, wo sie geändert wird.
|
||||
|
||||
**Splitdialog** (`Views/buchungen/kontoblatt.php`, `Buchung::splitsErsetzen()`)
|
||||
- Der Eintrag *Splitbuchung* öffnet ein modales Fenster mit Zeilen für Kategorie oder
|
||||
Gegenkonto, Klasse, Notiz und Betrag; der Restbetrag wird mitgerechnet, weitere Zeilen
|
||||
kommen auf Knopfdruck. Eine bestehende Aufteilung steht schon drin.
|
||||
- Ein Klick auf eine bereits gesplittete Buchung öffnet direkt den Dialog statt der
|
||||
Auswahlliste: Für sie hatte die Liste nichts anzubieten außer dem Weg dorthin.
|
||||
- Rechts unten im Dialog steht *Splitbuchung löschen*. Der Knopf schickt dasselbe
|
||||
Formular mit `aktion=loeschen`, verwirft alle Zeilen samt ihren Zuordnungen – auch die
|
||||
Gegenseite einer Splitzeile mit Gegenkonto – und die Buchung steht danach unzugeordnet
|
||||
im Kontoblatt. Er erscheint nur dort, wo es etwas aufzuheben gibt, und steht abseits
|
||||
von *Speichern* und *Abbrechen*.
|
||||
- Bleiben alle Zeilen leer, wird die Splitbuchung ebenso aufgehoben.
|
||||
- Beide Wege achten den Monatsabschluss und lassen den Rest der Buchung unberührt.
|
||||
|
||||
`public/static/betrag.js` bündelt das Lesen deutscher Beträge und die Restbetragsrechnung
|
||||
für Maske und Kontoblatt. `tests/23_ZuordnungTest.php` prüft Zuordnung, Splitzeilen und
|
||||
Gegensicht; `tests/e2e/specs/buchungen.spec.js` die drei Wege im Browser.
|
||||
|
||||
## Wertpapiere: Depotbuchungen sammelweise löschen (12.08.2026)
|
||||
|
||||
Die Liste der Depotbuchungen räumt jetzt so auf wie das Kontoblatt – nach einem Import,
|
||||
der schiefging, war das Löschen Zeile für Zeile mühsam.
|
||||
|
||||
**Auswahl löschen** (`Views/wertpapiere/buchungen.php`, `WertpapiereController`,
|
||||
`public/index.php`)
|
||||
- Ankreuzfeld je Zeile, das Feld in der Titelzeile wählt alle angezeigten Buchungen. Der
|
||||
Knopf steht oben in der Aktionsleiste, nennt die Anzahl und bleibt ohne Auswahl
|
||||
gesperrt; die Rückfrage nennt sie ebenfalls. Ohne JavaScript funktioniert alles weiter:
|
||||
Dann ist der Knopf offen und `POST /wertpapiere/buchungen/loeschen` weist eine leere
|
||||
Auswahl ab.
|
||||
- Mit jeder Depotbuchung geht ihre Geldbuchung – wie beim Löschen einer einzelnen.
|
||||
- **Am Bestand ist nichts nachzuziehen.** Er wird bei jedem Aufruf aus den Depotbuchungen
|
||||
gerechnet und gibt die Löschung von selbst wieder her, ebenso realisierter Gewinn,
|
||||
Veräußerungen und Erträge.
|
||||
- Der eingestellte Filter (Depot, Wertpapier, Vorgang) hängt am Ziel des Formulars,
|
||||
deshalb steht danach wieder dieselbe Ansicht da. Das gilt jetzt auch für das *Löschen*
|
||||
je Zeile, das bisher zur ungefilterten Liste zurücksprang.
|
||||
|
||||
`tests/22_DepotbuchungenLoeschenTest.php` prüft Geldseite, Bestand und Auswertungen nach
|
||||
dem Löschen; `tests/e2e/specs/module.spec.js` den Ablauf im Browser.
|
||||
|
||||
## Wertpapiere: Depotbestand aus einer CSV-Datei übernehmen (12.08.2026)
|
||||
|
||||
Ein Depot, das nie im FinanzManager geführt wurde, lässt sich jetzt in einem Zug
|
||||
einlesen – aus der Bestandsliste, die die Bank zum Herunterladen anbietet.
|
||||
|
||||
**Neuer Knopf *Import*** (`Views/wertpapiere/index.php`, `Views/wertpapiere/import.php`)
|
||||
- Steht auf der Bestandsseite neben *Depotbuchung erfassen*. Der Ablauf ist zweistufig:
|
||||
hochladen, Vorschau prüfen, übernehmen. Die Vorschau zeigt je Position Stück, beide
|
||||
Kurse, Einstand, Wert und ob sie neu ist oder schon einmal importiert wurde.
|
||||
- Vor der Übernahme lassen sich Depot, Buchungsdatum und Verrechnungskonto noch ändern.
|
||||
|
||||
**Der Import** (`Services\DepotbestandImport`, `WertpapiereController`)
|
||||
- Erwartet die Spalten Wertpapierbezeichnung, WKN, ISIN, Einstiegskurs, Bewertungskurs,
|
||||
Stückzahl und Assetklasse; Trennzeichen und Zeichensatz erkennt er selbst. Beträge wie
|
||||
`"1.471,88 €"` und Bruchstückzahlen wie `55,6863` werden gelesen, wie sie kommen.
|
||||
- **Die Vergangenheit fehlt im Export.** Er beschreibt nur einen Stichtag. Je Position
|
||||
entsteht deshalb *eine* Depotbuchung: ein Kauf zum Stichtag über die volle Stückzahl zum
|
||||
Einstiegskurs. Der Einstand stimmt damit auf den Cent; frühere Käufe, Verkäufe und
|
||||
Dividenden bleiben leer.
|
||||
- **Der Bewertungskurs wird als Kurs des Stichtags hinterlegt.** Ohne ihn stünde der
|
||||
frisch übernommene Bestand sofort wieder ohne Bewertung da. Am echten Export
|
||||
nachgerechnet: 33.176,12 € Einstand, 47.120,25 € Wert – die Bank weist zehn Cent
|
||||
weniger Gewinn aus, was an ihren auf zwei Stellen gerundeten Kursen liegt.
|
||||
- **Kein Geldfluss.** Ein Verrechnungskonto ist wählbar, bleibt aber vorbelegt leer: Das
|
||||
Geld ist vor Jahren geflossen, eine Belastung zum heutigen Tag würde das Girokonto
|
||||
verfälschen.
|
||||
- Ohne ausdrückliche Wahl findet der Import das Depot über die **Kontonummer**, die zur
|
||||
Depotnummer der Datei passt. Ein `import_hash` je Position verhindert, dass dieselbe
|
||||
Datei zweimal denselben Bestand anlegt.
|
||||
- Assetklassen werden übersetzt: *Aktien* → Aktie, *ETFs* und *Fonds* → Fonds,
|
||||
*Anleihen* → Anleihe, *Zertifikate* → Zertifikat.
|
||||
|
||||
**Neue Herkunft „CSV-Import“** (`Models\Buchung`)
|
||||
- `Buchung::QUELLEN` kennt jetzt `csv`; Depot- und Geldbuchungen aus dem Bestandsimport
|
||||
sind damit als solche erkennbar und fallen nicht mehr auf „manuell erfasst“ zurück.
|
||||
|
||||
`tests/21_DepotbestandImportTest.php` prüft das an einem Ausschnitt des echten Exports.
|
||||
|
||||
## Online-Banking: Umsätze, die niemand mehr zu sehen bekam (12.08.2026)
|
||||
|
||||
Drei Fehler, die zusammen dazu führten, dass abgerufene Umsätze spurlos verschwanden:
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -12,8 +12,9 @@ Haushalt wird beides parallel genutzt.
|
|||
|
||||
**Alle sieben Phasen aus `PLAN.md` sind umgesetzt.**
|
||||
|
||||
* **Stammdaten und Buchungen** – Konten, Kontengruppen, Kategorien, Klassen; Kontoblatt,
|
||||
Buchungsmaske mit Splitbuchungen und Umbuchungen; QIF-Import mit Stichtag.
|
||||
* **Stammdaten und Buchungen** – Konten, Kontengruppen, Kategorien, Klassen; Kontoblatt
|
||||
mit Schnellzuordnung und Splitdialog, Buchungsmaske mit Splitbuchungen und Umbuchungen;
|
||||
QIF-Import mit Stichtag.
|
||||
* **Planung** – Budgetplaner mit Monatswerten je Kategorie, Monatsabschluss mit Prüflauf,
|
||||
Snapshot und Sperre.
|
||||
* **Auswertungen** – Summen-, Buchungs-, Vergleichs-, Kontostands- und Budgetbericht mit
|
||||
|
|
@ -26,7 +27,8 @@ Haushalt wird beides parallel genutzt.
|
|||
abgerufene Umsätze buchen sich auf Wunsch selbst, Abgleich gegen vorhandene Buchungen,
|
||||
Regeln für die automatische Kategorisierung.
|
||||
* **Wertpapiere** – Depotbestände mit Einstand und Bewertung, Depotbuchungen, Kursabruf,
|
||||
Veräußerungsgeschäfte und Erträge.
|
||||
Veräußerungsgeschäfte und Erträge; Bestandslisten der Bank lassen sich als CSV
|
||||
übernehmen.
|
||||
* **Nebenmodule** – Buchungsvorlagen, regelmäßige Buchungen mit Fälligkeitslauf und
|
||||
Finanzkalender, Erinnerungen, Adressen, Verträge, Sparziele, Kredite mit Tilgungsplan.
|
||||
* **Zahlungsverkehr** – Überweisungen und Terminüberweisungen über FinTS.
|
||||
|
|
@ -97,7 +99,9 @@ docker compose exec -u www-data php php scripts/qif-import.php data/import/Expor
|
|||
docker compose exec -u www-data php php scripts/qif-import.php --uebernehmen data/import/Export.QIF
|
||||
```
|
||||
|
||||
Ohne `--uebernehmen` läuft nur der Trockenlauf.
|
||||
Ohne `--uebernehmen` läuft nur der Trockenlauf. Mit `--konto=NAME` (mehrfach angebbar,
|
||||
im Dialog als Ankreuzliste nach dem Trockenlauf) bleibt der Import auf einzelne Konten
|
||||
beschränkt; `--konten` nennt die Konten der Datei.
|
||||
|
||||
## Dokumentation
|
||||
|
||||
|
|
@ -110,6 +114,6 @@ Ohne `--uebernehmen` läuft nur der Trockenlauf.
|
|||
- `docs/auswertungen.md` – Berichtsmodul, Filterleiste, Drilldown, CSV
|
||||
- `docs/cockpit.md` – Startseite und ihre Kacheln
|
||||
- `docs/banking.md` – FinTS-Abruf, TAN-Dialog, Abgleich und Regeln
|
||||
- `docs/wertpapiere.md` – Depots, Einstand, Kurse und der Invst-Import
|
||||
- `docs/wertpapiere.md` – Depots, Einstand, Kurse, Invst- und CSV-Bestandsimport
|
||||
- `docs/nebenmodule.md` – Vorlagen, Termine, Verträge, Sparziele, Kredite, Zahlungsverkehr
|
||||
- `docs/qif-import.md` – Aufbau des QIF-Exports und Arbeitsweise des Importeurs
|
||||
|
|
|
|||
|
|
@ -21,6 +21,13 @@ Zeilen aus 2 und 3 tragen `gegensicht = true`. Sie sind nicht bearbeitbar – da
|
|||
dem Ursprungskonto –, damit eine Umbuchung nicht aus zwei Richtungen widersprüchlich
|
||||
geändert wird.
|
||||
|
||||
**Beide Sichten nennen das Gegenüber.** In der Kategoriespalte steht das jeweils andere
|
||||
Konto in eckigen Klammern: auf dem Ausgangskonto das Ziel, auf dem Gegenkonto das
|
||||
Ursprungskonto. In der Gegensicht liefert die Abfrage dafür `b.konto_id` als
|
||||
`gegenkonto_id` – dieselbe Spalte, aus der anderen Richtung gelesen. Ohne diese Angabe
|
||||
stünde auf dem Gegenkonto eine Zeile, die sich „Umbuchung“ nennt und verschweigt, woher
|
||||
sie kommt.
|
||||
|
||||
Dieselbe Dreiteilung gilt für die Salden (`Buchung::saldenJeKonto()`), die alle Konten mit
|
||||
sechs Abfragen auf einmal berechnet statt je Konto einzeln.
|
||||
|
||||
|
|
@ -43,6 +50,56 @@ Filter und Seite hängen an der Abfragezeichenfolge des Formularziels. Der Contr
|
|||
den Weg zurück aus den bekannten Feldern neu zusammen, statt ein mitgeschicktes Ziel zu
|
||||
übernehmen; so lässt sich kein fremder Weiterleitungsort einschleusen.
|
||||
|
||||
## Kategorie und Gegenkonto in einem Feld
|
||||
|
||||
Eine Buchung ist entweder kategorisiert **oder** eine Umbuchung – beides zugleich gibt es
|
||||
nicht. Deshalb stehen Kategorien und Konten in derselben Auswahlliste; die Konten unten,
|
||||
in eckigen Klammern, wie das Kategoriefeld im FinanzManager sie schreibt. Das eigene Konto
|
||||
fehlt in der Liste.
|
||||
|
||||
Die Liste steht in `Views/buchungen/zuordnungsliste.php` und wird an vier Stellen
|
||||
eingebunden: Buchungsmaske, deren Splitzeilen, Schnellzuordnung im Kontoblatt und
|
||||
Splitdialog. Die Werte tragen ein Präfix, das `BuchungenController::zuordnungAus()` wieder
|
||||
auseinandernimmt:
|
||||
|
||||
| Wert | Bedeutung |
|
||||
|---|---|
|
||||
| `` (leer) | keine Zuordnung |
|
||||
| `kategorie:12` | Kategorie 12 |
|
||||
| `konto:7` | Umbuchung mit Gegenkonto 7 |
|
||||
| `split` | Splitdialog öffnen (nur im Kontoblatt) |
|
||||
|
||||
Die Auswahl *Ausgabe / Einnahme* bestimmt seither nur noch die Richtung des Betrags.
|
||||
|
||||
## Schnellzuordnung im Kontoblatt
|
||||
|
||||
Ein Klick auf die Kategoriespalte öffnet die Liste in der Zelle; die Auswahl geht an
|
||||
`POST /buchungen/{id}/zuordnung`, der Eintrag *Splitbuchung* öffnet stattdessen den
|
||||
Splitdialog (`POST /buchungen/{id}/splitbuchung`). Beide Wege gehen über
|
||||
`Buchung::zuordnen()` und `Buchung::splitsErsetzen()`, die den Rest der Buchung unberührt
|
||||
lassen und den Monatsabschluss achten.
|
||||
|
||||
Drei Punkte, die sich nicht ändern dürfen:
|
||||
|
||||
* Die Auswahlliste gibt es **einmal**; `kontoblatt.js` hängt sie in die angeklickte Zelle
|
||||
und danach zurück. Eine Liste je Zeile wären bei hundert Zeilen ebenso viele
|
||||
vollständige Kategoriebäume im HTML.
|
||||
* Ohne JavaScript führt derselbe Klick in die Buchungsmaske – die Zelle ist ein Link, kein
|
||||
Knopf.
|
||||
* Zeilen mit `gegensicht = true` führen zu ihrer Buchung, statt die Auswahl zu öffnen: Sie
|
||||
werden auf ihrem Ursprungskonto geändert, wie beim Bearbeiten und Löschen.
|
||||
`kontoblatt.js` erkennt sie an der Klasse `gegenseite`.
|
||||
* Feld und Auswahlliste sind **gleich breit und gleich hoch** (`13rem` / `1.35rem`), und
|
||||
die Zelle zeigt nichts an, was nicht auch in der Liste stünde – kein „zuordnen …“. Sonst
|
||||
rückt beim Anklicken die halbe Zeile: Die Spalte wüchse auf die Breite der längsten
|
||||
Kategorie, und der Empfänger daneben bräche um.
|
||||
|
||||
Eine schon gesplittete Buchung öffnet auf Klick **direkt den Splitdialog**, nicht die
|
||||
Auswahlliste: Deren einziger sinnvoller Eintrag wäre für sie ohnehin *Splitbuchung*
|
||||
gewesen. Wer eine Aufteilung wieder in eine einzelne Zuordnung wandeln will, löscht sie
|
||||
im Dialog und ordnet die dann unzugeordnete Buchung anschließend zu – oder geht über die
|
||||
Buchungsmaske.
|
||||
|
||||
## Splitbuchungen
|
||||
|
||||
Eine Buchung ist entweder einfach zugeordnet **oder** gesplittet. Sobald Splitzeilen
|
||||
|
|
@ -50,9 +107,17 @@ vorliegen, räumt das Model `kategorie_id` und `gegenkonto_id` der Buchung; die
|
|||
steckt dann ausschließlich in den Zeilen.
|
||||
|
||||
Die Summe der Zeilen muss dem Buchungsbetrag entsprechen – geprüft im Model (Toleranz ein
|
||||
halber Cent, für Rundungen aus der Betragsumrechnung) und in der Maske als
|
||||
Restbetragsanzeige. Eine Splitzeile darf ein Gegenkonto tragen; dann erscheint auf jenem
|
||||
Konto nur der Betrag dieser Zeile.
|
||||
halber Cent, für Rundungen aus der Betragsumrechnung) und in beiden Masken als
|
||||
Restbetragsanzeige. Beide rechnen mit `public/static/betrag.js`; die Regel für das
|
||||
deutsche Zahlenformat steht damit einmal im Browser und einmal in PHP, nicht dreimal. Eine
|
||||
Splitzeile darf ein Gegenkonto tragen; dann erscheint auf jenem Konto nur der Betrag dieser
|
||||
Zeile. Auf das eigene Konto darf sie nicht zeigen – die Buchung zählte dort doppelt.
|
||||
|
||||
Der Knopf *Splitbuchung löschen* im Dialog schickt `POST /buchungen/{id}/splitbuchung` mit
|
||||
`aktion=loeschen`; der Controller ruft dann `Buchung::splitsErsetzen($id, [])`, ganz gleich
|
||||
was in den Feldern steht. Gelöscht werden die Zeilen samt Zuordnung, nicht die Buchung –
|
||||
sie steht danach mit ihrem Betrag und ohne Kategorie im Kontoblatt, und die Gegenseite
|
||||
einer Zeile mit Gegenkonto verschwindet von jenem Konto.
|
||||
|
||||
## Salden
|
||||
|
||||
|
|
@ -70,7 +135,7 @@ nicht; es wird im Kontoformular gepflegt.
|
|||
## Beträge in der Oberfläche
|
||||
|
||||
Eingegeben wird immer ein **positiver** Betrag; die Richtung bestimmt die Auswahl
|
||||
*Ausgabe / Einnahme / Umbuchung*. `KontenController::zahl()` liest das deutsche Format und
|
||||
*Ausgabe / Einnahme*. `KontenController::zahl()` liest das deutsche Format und
|
||||
entscheidet bei einem Punkt ohne Komma nach der Stellenzahl: drei Ziffern dahinter heißt
|
||||
Tausendertrenner (`1.500`), sonst Dezimaltrenner (`1234.56`).
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,33 @@ die der Import selbst anlegen würde, erscheinen in der Vorschau.
|
|||
Verrechnungskonto zu Buche (`L|[Konto]` und `$`). Ein Betrag auf dem Depot ohne
|
||||
Gegenwert würde die Summe der Kontostände verfälschen; Bestände folgen in Phase 6.
|
||||
|
||||
## Nur einzelne Konten übernehmen
|
||||
|
||||
Der Import lässt sich auf einzelne Konten begrenzen (Option `konten`, im Dialog eine
|
||||
Ankreuzliste, auf der Kommandozeile `--konto=NAME`). Drei Dinge sind dabei entschieden:
|
||||
|
||||
1. **Maßgeblich ist jedes berührte Konto, nicht nur der `!Account`-Abschnitt.** Eine
|
||||
Umbuchung steht nur einmal in der Datei, beim erfassenden Konto. Zählte allein dieser
|
||||
Abschnitt, fehlte dem gewählten Konto die Gegenseite. Deshalb kommt ein Eintrag mit,
|
||||
sobald sein Konto, ein Umbuchungsziel aus `L` oder `S` oder – beim Wertpapiergeschäft –
|
||||
das Verrechnungskonto in der Auswahl steht. Umgekehrt heißt das: Wer nur das Zielkonto
|
||||
wählt, bekommt die Buchung auch auf dem erfassenden Konto zu sehen; sie gehört dort hin
|
||||
und lässt sich nicht halbieren.
|
||||
2. **Anfangssalden werden weiterhin über die ganze Datei gerechnet, aber nur für die
|
||||
gewählten Konten geschrieben.** Der Saldo eines Kontos steckt auch in Buchungen, die
|
||||
anderswo erfasst wurden – ohne den vollen Durchlauf wäre er falsch. Und ein Teilimport
|
||||
darf die Salden der übrigen Konten nicht überschreiben.
|
||||
3. **Die Fingerabdrücke entstehen vorab in einem eigenen Durchgang.** Der laufende Zähler
|
||||
für gleiche Fingerabdrücke (siehe unten) hinge sonst an der Auswahl: Dieselbe Buchung
|
||||
bekäme im Teilimport einen anderen Hash als im Vollimport und käme ein zweites Mal in
|
||||
die Datenbank. So lassen sich die Konten nacheinander einlesen, ohne dass die
|
||||
Umbuchungen zwischen ihnen doppelt entstehen.
|
||||
|
||||
Die auswählbaren Konten liefert `QifBuchungsImport::kontenDerDatei()` – erst nach dem
|
||||
Lesen steht fest, was die Datei führt. Enthalten sind auch Konten, die selbst keine
|
||||
Buchung tragen und nur Umbuchungsziel sind; sonst ließe sich genau deren Gegenseite nicht
|
||||
übernehmen.
|
||||
|
||||
Die Dublettenerkennung nutzt einen Fingerabdruck aus Konto, Datum, Betrag, Nummer und
|
||||
Text. Weil es echte Doppelbuchungen am selben Tag gibt (zweimal dasselbe im selben Laden),
|
||||
zählt ein laufender Index gleiche Fingerabdrücke durch – stabil über Läufe hinweg, weil
|
||||
|
|
@ -111,6 +138,11 @@ Im Dialog unter *Verwaltung → QIF-Import*: Datei hochladen, Trockenlauf, dann
|
|||
Hochgeladene Dateien bleiben im Importverzeichnis (`data/import`), damit derselbe Export
|
||||
erst geprüft und dann übernommen werden kann.
|
||||
|
||||
Nach dem Trockenlauf steht unter *Konten* die Ankreuzliste der Konten, die in der Datei
|
||||
vorkommen – vorher gibt es nichts zu wählen, weil die Datei noch nicht gelesen wurde. Von
|
||||
dort führen zwei Wege weiter: ein erneuter Trockenlauf mit der Auswahl oder das Übernehmen.
|
||||
Ohne Häkchen zählt die ganze Datei.
|
||||
|
||||
Auf der Kommandozeile:
|
||||
|
||||
```bash
|
||||
|
|
@ -125,6 +157,16 @@ sonst gilt der erste aktive Benutzer.
|
|||
den Einstellungen (Reiter *Import/Export*) und als Enddatum der heutige Tag.
|
||||
`--nur-stammdaten` lässt die Buchungen aus.
|
||||
|
||||
```bash
|
||||
docker compose exec -u www-data php php scripts/qif-import.php --konten data/import/Export.QIF
|
||||
docker compose exec -u www-data php php scripts/qif-import.php \
|
||||
--konto="DKB Girokonto" --konto="Tagesgeld" --uebernehmen data/import/Export.QIF
|
||||
```
|
||||
|
||||
`--konten` nennt die Konten der Datei und beendet sich, ohne etwas anzufassen.
|
||||
`--konto=` grenzt den Import ein und ist mehrfach angebbar; ein Name, den die Datei nicht
|
||||
führt, bricht ab – sonst liefe der Import durch und legte nichts an.
|
||||
|
||||
## Prüfung gegen den echten Export
|
||||
|
||||
Der Vollexport (8780 Buchungen, 2017–2026) ergibt mit Stichtag 01.01.2026:
|
||||
|
|
|
|||
|
|
@ -111,6 +111,62 @@ Wertpapiergeschäfts als Abgang – auch bei Verkäufen und Dividenden. Das QIF
|
|||
`$` immer einen positiven Betrag; die Richtung steckt allein im Vorgang. Verrechnungskonten
|
||||
von Depots wiesen dadurch zu niedrige Salden aus.
|
||||
|
||||
## CSV-Import einer Bestandsliste
|
||||
|
||||
*Wertpapiere → Bestände → Import* übernimmt die Depotbestandsliste, wie Banken sie zum
|
||||
Herunterladen anbieten (erprobt mit der DKB). Erwartet wird eine Kopfzeile mit den Spalten
|
||||
*Wertpapierbezeichnung*, *WKN*, *ISIN*, *Einstiegskurs*, *Bewertungskurs*, *Stückzahl* und
|
||||
*Assetklasse*; *Datum der Erstellung* und *Depotnummer* werden mitgelesen, wenn vorhanden.
|
||||
Trennzeichen (`;`, `,`, Tabulator) und Zeichensatz (UTF-8 mit oder ohne BOM,
|
||||
Windows-1252) erkennt der Import selbst.
|
||||
|
||||
**Der Export kennt keine Historie.** Er beschreibt einen Stichtag: was im Depot liegt und
|
||||
was es im Schnitt gekostet hat. Die Geschäfte, die dorthin geführt haben, fehlen. Der
|
||||
Import legt deshalb je Position **eine** Depotbuchung an – einen Kauf zum Stichtag über
|
||||
die volle Stückzahl zum Einstiegskurs. Der Einstand stimmt damit auf den Cent, frühere
|
||||
Käufe, Verkäufe und Dividenden bleiben naturgemäß leer und lassen sich später von Hand
|
||||
nachtragen.
|
||||
|
||||
**Zwei Kurse, zwei Rollen.** Der *Einstiegskurs* ist der durchschnittliche Kaufpreis und
|
||||
wird zum Kurs der Buchung; der *Bewertungskurs* ist der aktuelle Kurs und landet als Kurs
|
||||
des Stichtags in `kurse`. Erst dadurch ist der frisch übernommene Bestand sofort bewertbar
|
||||
und zeigt denselben Gewinn wie die Bank – bis auf wenige Cent, die in den auf zwei Stellen
|
||||
gerundeten Kursen des Exports stecken.
|
||||
|
||||
**Kein Geldfluss.** Ein Verrechnungskonto ist wählbar, bleibt aber in aller Regel leer:
|
||||
Das Geld ist vor Jahren geflossen, eine Belastung zum heutigen Tag würde das Girokonto
|
||||
verfälschen. Ohne Verrechnungskonto entsteht keine Geldbuchung.
|
||||
|
||||
**Assetklasse → Art**: *Aktien* → Aktie, *ETFs* und *Fonds* → Fonds, *Anleihen* →
|
||||
Anleihe, *Zertifikate* → Zertifikat, alles Übrige → Sonstiges.
|
||||
|
||||
Das Zieldepot lässt sich wählen; ohne Auswahl sucht der Import das Depot, dessen
|
||||
**Kontonummer** der Depotnummer aus der Datei entspricht. Jede Position bekommt einen
|
||||
`import_hash` aus Depot, Datum, Vorgang, Stück, Betrag und ISIN – dieselbe Datei zweimal
|
||||
eingelesen legt nichts doppelt an. Ein *anderer* Stichtag gilt dagegen als neuer Vorgang.
|
||||
|
||||
Der Ablauf ist zweistufig: Der Upload zeigt erst eine Vorschau mit allen Positionen,
|
||||
Einstand, Wert und dem Status je Zeile; erst *Jetzt übernehmen* schreibt. Die Datei bleibt
|
||||
im Importverzeichnis liegen.
|
||||
|
||||
## Depotbuchungen löschen
|
||||
|
||||
Die Liste der Depotbuchungen löscht einzeln – *Löschen* je Zeile – oder mehrere auf
|
||||
einmal, genau wie das Kontoblatt: Ankreuzfeld je Zeile, das Feld in der Titelzeile wählt
|
||||
alle angezeigten, *Auswahl löschen* in der Aktionsleiste schickt sie an
|
||||
`POST /wertpapiere/buchungen/loeschen`. Der Knopf nennt die Anzahl und bleibt ohne
|
||||
Auswahl gesperrt; die Rückfrage kommt vor dem Absenden. Ohne JavaScript funktioniert
|
||||
alles weiter – dann ist der Knopf offen und der Controller weist eine leere Auswahl ab.
|
||||
|
||||
Der eingestellte Filter hängt am Ziel beider Formulare, deshalb steht danach wieder
|
||||
dieselbe Ansicht da. Zusammengesetzt wird er im Controller neu, damit kein fremdes Ziel
|
||||
eingeschleust werden kann.
|
||||
|
||||
Mit jeder Depotbuchung geht ihre Geldbuchung (siehe *Zwei Seiten je Geschäft*). Am
|
||||
Bestand ist nichts nachzuziehen: Er wird bei jedem Aufruf aus den Depotbuchungen
|
||||
gerechnet und gibt die Löschung von selbst wieder her – ebenso realisierter Gewinn,
|
||||
Veräußerungen und Erträge. Das Wertpapier selbst bleibt in den Stammdaten stehen.
|
||||
|
||||
## Kontenübersicht
|
||||
|
||||
Depots erscheinen dort mit ihrem **bewerteten Bestand**, nicht mit einem Buchungssaldo. Der
|
||||
|
|
@ -127,3 +183,14 @@ Vorzeichen; Verkauf mit anteiligem Einstand und realisiertem Gewinn; Dividende o
|
|||
Bestandsänderung; fehlende Kurse; Veräußerungsrechnung über die Historie hinweg; der
|
||||
QIF-Import eines echten Ausschnitts (Kauf, Dividende, Verkauf) inklusive Idempotenz;
|
||||
Löschen samt Geldbuchung.
|
||||
|
||||
`tests/22_DepotbuchungenLoeschenTest.php` – das Sammellöschen: die Geldbuchungen gehen
|
||||
mit, das Verrechnungskonto stimmt danach wieder, Bestand, Einstand, realisierter Gewinn,
|
||||
Erträge und die Veräußerungsauswertung geben die Löschung her, unbekannte Nummern in der
|
||||
Auswahl bleiben folgenlos. Den Ablauf im Browser prüft `tests/e2e/specs/module.spec.js`.
|
||||
|
||||
`tests/21_DepotbestandImportTest.php` – der CSV-Bestandsimport an einem Ausschnitt des
|
||||
echten DKB-Exports: Vorschau ohne Nebenwirkung, Einstand und Wert aus beiden Kursen,
|
||||
Bruchstückzahlen, Assetklasse → Art, hinterlegte Bewertungskurse, Idempotenz beim zweiten
|
||||
Lauf, Zuordnung des Depots über die Depotnummer, Geldseite mit und ohne
|
||||
Verrechnungskonto, sowie die Abwehr einer fremden oder leeren Datei.
|
||||
|
|
|
|||
|
|
@ -85,6 +85,11 @@ $router->post('/konten/{id}/buchungen/loeschen',
|
|||
$router->get('/buchungen/{id}', fn(array $p) => (new BuchungenController())->bearbeiten($p));
|
||||
$router->post('/buchungen/{id}', fn(array $p) => (new BuchungenController())->speichern($p));
|
||||
$router->post('/buchungen/{id}/loeschen', fn(array $p) => (new BuchungenController())->loeschen($p));
|
||||
// Schnellzuordnung und Splitdialog aus dem Kontoblatt.
|
||||
$router->post('/buchungen/{id}/zuordnung',
|
||||
fn(array $p) => (new BuchungenController())->zuordnung($p));
|
||||
$router->post('/buchungen/{id}/splitbuchung',
|
||||
fn(array $p) => (new BuchungenController())->splitbuchung($p));
|
||||
|
||||
// Verwaltung: Kategorien, Klassen, Kontengruppen, QIF-Import.
|
||||
$router->get('/verwaltung', fn() => (new VerwaltungController())->index());
|
||||
|
|
@ -240,6 +245,8 @@ $router->get('/wertpapiere/auswertung', fn() => (new WertpapiereController())->a
|
|||
$router->get('/wertpapiere/buchungen', fn() => (new WertpapiereController())->buchungen());
|
||||
$router->get('/wertpapiere/buchungen/neu', fn() => (new WertpapiereController())->neu());
|
||||
$router->post('/wertpapiere/buchungen/neu', fn() => (new WertpapiereController())->anlegen());
|
||||
$router->post('/wertpapiere/buchungen/loeschen',
|
||||
fn() => (new WertpapiereController())->auswahlLoeschen());
|
||||
$router->post('/wertpapiere/buchungen/{id}/loeschen',
|
||||
fn(array $p) => (new WertpapiereController())->loeschen($p));
|
||||
|
||||
|
|
@ -250,6 +257,9 @@ $router->post('/wertpapiere/stammdaten/{id}/kurs',
|
|||
fn(array $p) => (new WertpapiereController())->kursSetzen($p));
|
||||
$router->post('/wertpapiere/kurse', fn() => (new WertpapiereController())->kurseAbrufen());
|
||||
|
||||
$router->get('/wertpapiere/import', fn() => (new WertpapiereController())->importFormular());
|
||||
$router->post('/wertpapiere/import', fn() => (new WertpapiereController())->importAusfuehren());
|
||||
|
||||
// ── Online-Banking: Zugänge, Abruf, Abgleich, Regeln ────────────────────────
|
||||
$router->get('/banking', fn() => (new BankingController())->index());
|
||||
$router->get('/banking/neu', fn() => (new BankingController())->neu());
|
||||
|
|
|
|||
|
|
@ -999,6 +999,47 @@ table.kontoblatt tr.gegensicht td { background: color-mix(in srgb, var(--akzent)
|
|||
margin-top: .8rem;
|
||||
}
|
||||
|
||||
/* Schnellzuordnung: Die Kategoriespalte ist anklickbar wie ein Eingabefeld,
|
||||
sieht aber nicht danach aus – sonst flimmerte die ganze Spalte. Der
|
||||
gestrichelte Unterstrich erscheint erst beim Überfahren.
|
||||
|
||||
Feld und Auswahlliste sind gleich breit und gleich hoch. Sonst rückte beim
|
||||
Anklicken die halbe Zeile: Die Spalte wüchse auf die Breite der längsten
|
||||
Kategorie, und der Empfänger daneben bräche um. */
|
||||
td.zuordnungszelle { width: 13rem; }
|
||||
|
||||
a.zuordnungsfeld {
|
||||
display: block;
|
||||
max-width: 13rem;
|
||||
min-height: 1.35rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dashed transparent;
|
||||
}
|
||||
|
||||
a.zuordnungsfeld:hover,
|
||||
a.zuordnungsfeld:focus-visible { border-bottom-color: var(--rand-kraeftig); }
|
||||
|
||||
.zuordnungsauswahl { display: flex; gap: .3rem; }
|
||||
.zuordnungsauswahl[hidden] { display: none; }
|
||||
|
||||
.zuordnungsauswahl select {
|
||||
width: 100%;
|
||||
max-width: 13rem;
|
||||
height: 1.35rem;
|
||||
padding: 0 .2rem;
|
||||
border: 1px solid var(--akzent);
|
||||
border-radius: 4px;
|
||||
background: var(--flaeche);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-family: var(--schrift);
|
||||
font-size: .85rem;
|
||||
}
|
||||
|
||||
/* Die Splittabelle sitzt im Formular und braucht schmalere Zellen. */
|
||||
table.splittabelle td { padding: .3rem .4rem; }
|
||||
table.splittabelle select { max-width: 14rem; width: 100%; }
|
||||
|
|
@ -1006,6 +1047,28 @@ table.splittabelle input.rechts { text-align: right; }
|
|||
|
||||
.buchungsmaske [hidden] { display: none; }
|
||||
|
||||
/* Der Splitdialog des Kontoblatts. Als <dialog> und nicht als eigene Seite:
|
||||
Die Aufteilung gehört zur Zeile, aus der sie geöffnet wurde. */
|
||||
.splitdialog {
|
||||
width: min(46rem, 92vw);
|
||||
padding: 1.1rem 1.25rem 1.25rem;
|
||||
border: 1px solid var(--rand-kraeftig);
|
||||
border-radius: var(--radius);
|
||||
background: var(--flaeche);
|
||||
color: var(--text);
|
||||
box-shadow: 0 10px 30px rgb(0 0 0 / 25%);
|
||||
}
|
||||
|
||||
.splitdialog::backdrop { background: rgb(0 0 0 / 35%); }
|
||||
|
||||
.splitdialog h2 { margin: 0 0 .3rem; font-size: 1.05rem; }
|
||||
.splitdialog .knopfleiste { margin-top: .9rem; }
|
||||
.splitdialog .splittabelle { margin-top: .6rem; }
|
||||
|
||||
/* Das Löschen der ganzen Aufteilung steht rechts außen – weit weg von
|
||||
„Speichern“ und „Abbrechen“, damit es nicht im Vorbeigehen getroffen wird. */
|
||||
.splitdialog .knopfleiste .knopf.gefahr { margin-left: auto; }
|
||||
|
||||
/* ── Formulare ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.formular {
|
||||
|
|
|
|||
58
public/static/betrag.js
Normal file
58
public/static/betrag.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/* Beträge im deutschen Format lesen und schreiben.
|
||||
*
|
||||
* Dieselbe Regel wie KontenController::zahl(): ohne Komma entscheidet die
|
||||
* Stellenzahl, ob der Punkt Tausender- oder Dezimaltrenner ist. Die Maske und
|
||||
* das Kontoblatt rechnen beide Restbeträge mit – deshalb steht das hier und
|
||||
* nicht zweimal.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function zahl(text) {
|
||||
const bereinigt = String(text).replace(/[\s€]/g, '');
|
||||
if (bereinigt === '') {
|
||||
return 0;
|
||||
}
|
||||
if (bereinigt.indexOf(',') !== -1) {
|
||||
return parseFloat(bereinigt.replace(/\./g, '').replace(',', '.')) || 0;
|
||||
}
|
||||
|
||||
const punkte = (bereinigt.match(/\./g) || []).length;
|
||||
const letzter = bereinigt.split('.').pop();
|
||||
if (punkte > 1 || (punkte === 1 && letzter.length === 3)) {
|
||||
return parseFloat(bereinigt.replace(/\./g, '')) || 0;
|
||||
}
|
||||
return parseFloat(bereinigt) || 0;
|
||||
}
|
||||
|
||||
function formatiere(wert) {
|
||||
return wert.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
/* Die Summe der Splitzeilen unter `felder` gegen `ziel` – das Ergebnis geht
|
||||
* so in die Anzeige, wie beide Masken es brauchen. */
|
||||
function splitsumme(felder, ziel) {
|
||||
let summe = 0;
|
||||
let belegt = false;
|
||||
|
||||
felder.forEach(function (feld) {
|
||||
if (feld.value.trim() === '') {
|
||||
return;
|
||||
}
|
||||
belegt = true;
|
||||
summe += zahl(feld.value);
|
||||
});
|
||||
|
||||
const rest = Math.round((ziel - summe) * 100) / 100;
|
||||
|
||||
return {
|
||||
belegt: belegt,
|
||||
summe: summe,
|
||||
rest: rest,
|
||||
text: belegt ? formatiere(summe) + (rest === 0 ? '' : ' (Rest ' + formatiere(rest) + ')') : '0,00'
|
||||
};
|
||||
}
|
||||
|
||||
window.FmBetrag = { zahl: zahl, formatiere: formatiere, splitsumme: splitsumme };
|
||||
})();
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
/* Buchungsmaske: Felder je nach Art ein- und ausblenden, Restbetrag der
|
||||
* Splitzeilen mitrechnen.
|
||||
/* Buchungsmaske: Restbetrag der Splitzeilen mitrechnen.
|
||||
*
|
||||
* Nur Komfort – die Maske kommt fertig vom Server. Ohne JavaScript bleibt alles
|
||||
* bedienbar: dann sind sämtliche Felder sichtbar, und die Splitsumme prüft
|
||||
* ohnehin das Model.
|
||||
* bedienbar: Die Splitsumme prüft ohnehin das Model.
|
||||
*
|
||||
* Kategorie und Gegenkonto stehen in einem Feld, deshalb gibt es hier nichts
|
||||
* mehr ein- und auszublenden; die Art bestimmt nur noch die Richtung.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
|
|
@ -11,90 +12,35 @@
|
|||
|
||||
const art = document.getElementById('art');
|
||||
const maske = document.querySelector('.buchungsmaske');
|
||||
if (!art || !maske) {
|
||||
if (!art || !maske || !window.FmBetrag) {
|
||||
return;
|
||||
}
|
||||
|
||||
const kategorie = maske.querySelector('.nur-kategorie');
|
||||
const umbuchung = maske.querySelector('.nur-umbuchung');
|
||||
const gegenkonto = document.getElementById('gegenkonto_id');
|
||||
const summenfeld = document.getElementById('splitsumme');
|
||||
const betragsfeld = document.getElementById('betrag');
|
||||
|
||||
function zeigePassendeFelder() {
|
||||
const istUmbuchung = art.value.indexOf('umbuchung') === 0;
|
||||
if (kategorie) {
|
||||
kategorie.hidden = istUmbuchung;
|
||||
}
|
||||
if (umbuchung) {
|
||||
umbuchung.hidden = !istUmbuchung;
|
||||
}
|
||||
if (gegenkonto) {
|
||||
gegenkonto.required = istUmbuchung;
|
||||
}
|
||||
}
|
||||
|
||||
/* Beträge stehen im deutschen Format im Feld. Dieselbe Regel wie
|
||||
* KontenController::zahl(): ohne Komma entscheidet die Stellenzahl, ob der
|
||||
* Punkt Tausender- oder Dezimaltrenner ist. */
|
||||
function zahl(text) {
|
||||
const bereinigt = String(text).replace(/[\s€]/g, '');
|
||||
if (bereinigt === '') {
|
||||
return 0;
|
||||
}
|
||||
if (bereinigt.indexOf(',') !== -1) {
|
||||
return parseFloat(bereinigt.replace(/\./g, '').replace(',', '.')) || 0;
|
||||
}
|
||||
|
||||
const punkte = (bereinigt.match(/\./g) || []).length;
|
||||
const letzter = bereinigt.split('.').pop();
|
||||
if (punkte > 1 || (punkte === 1 && letzter.length === 3)) {
|
||||
return parseFloat(bereinigt.replace(/\./g, '')) || 0;
|
||||
}
|
||||
return parseFloat(bereinigt) || 0;
|
||||
}
|
||||
|
||||
function formatiere(wert) {
|
||||
return wert.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function rechneSumme() {
|
||||
if (!summenfeld) {
|
||||
return;
|
||||
}
|
||||
|
||||
let summe = 0;
|
||||
let belegt = false;
|
||||
maske.querySelectorAll('.splitbetrag').forEach(function (feld) {
|
||||
if (feld.value.trim() === '') {
|
||||
return;
|
||||
}
|
||||
belegt = true;
|
||||
summe += zahl(feld.value);
|
||||
});
|
||||
|
||||
if (!belegt) {
|
||||
summenfeld.textContent = '0,00';
|
||||
summenfeld.classList.remove('negativ');
|
||||
return;
|
||||
}
|
||||
|
||||
// Der Buchungsbetrag wird positiv eingegeben; das Vorzeichen ergibt
|
||||
// sich aus der Art. Für den Vergleich zählt der Betrag mit Richtung.
|
||||
let ziel = zahl(betragsfeld ? betragsfeld.value : '0');
|
||||
if (art.value === 'ausgabe' || art.value === 'umbuchung_ab') {
|
||||
let ziel = window.FmBetrag.zahl(betragsfeld ? betragsfeld.value : '0');
|
||||
if (art.value !== 'einnahme') {
|
||||
ziel = -ziel;
|
||||
}
|
||||
|
||||
const rest = Math.round((ziel - summe) * 100) / 100;
|
||||
summenfeld.textContent = formatiere(summe) + (rest === 0 ? '' : ' (Rest ' + formatiere(rest) + ')');
|
||||
summenfeld.classList.toggle('negativ', rest !== 0);
|
||||
const stand = window.FmBetrag.splitsumme(
|
||||
Array.from(maske.querySelectorAll('.splitbetrag')),
|
||||
ziel
|
||||
);
|
||||
|
||||
summenfeld.textContent = stand.text;
|
||||
summenfeld.classList.toggle('negativ', stand.belegt && stand.rest !== 0);
|
||||
}
|
||||
|
||||
art.addEventListener('change', function () {
|
||||
zeigePassendeFelder();
|
||||
rechneSumme();
|
||||
});
|
||||
art.addEventListener('change', rechneSumme);
|
||||
|
||||
maske.addEventListener('input', function (ereignis) {
|
||||
if (ereignis.target.classList.contains('splitbetrag') || ereignis.target.id === 'betrag') {
|
||||
|
|
@ -102,6 +48,5 @@
|
|||
}
|
||||
});
|
||||
|
||||
zeigePassendeFelder();
|
||||
rechneSumme();
|
||||
})();
|
||||
|
|
|
|||
218
public/static/kontoblatt.js
Normal file
218
public/static/kontoblatt.js
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
/* Kontoblatt: Kategorie oder Gegenkonto an Ort und Stelle zuordnen.
|
||||
*
|
||||
* Ein Klick auf die Kategoriespalte öffnet die Auswahlliste in der Zelle. Sie
|
||||
* gibt es genau einmal und wandert in die angeklickte Zelle – wie das
|
||||
* Zahnradmenü im Budgetplaner. Eine Liste je Zeile wären bei hundert Zeilen
|
||||
* ebenso viele vollständige Kategoriebäume im HTML.
|
||||
*
|
||||
* Ohne JavaScript führt derselbe Klick in die Buchungsmaske; die Zuordnung ist
|
||||
* dort wie bisher zu ändern.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const tabelle = document.querySelector('table.kontoblatt');
|
||||
const auswahl = document.getElementById('zuordnungsauswahl');
|
||||
if (!tabelle || !auswahl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const abfrage = tabelle.dataset.abfrage || '';
|
||||
const liste = auswahl.querySelector('select');
|
||||
const dialog = document.getElementById('splitdialog');
|
||||
const splitformular = document.getElementById('splitformular');
|
||||
|
||||
// Das Feld, das gerade durch die Auswahlliste ersetzt ist.
|
||||
let offenesFeld = null;
|
||||
|
||||
function ziel(feld, aktion) {
|
||||
return '/buchungen/' + feld.dataset.buchung + '/' + aktion + abfrage;
|
||||
}
|
||||
|
||||
function schliessen() {
|
||||
if (!offenesFeld) {
|
||||
return;
|
||||
}
|
||||
offenesFeld.hidden = false;
|
||||
offenesFeld = null;
|
||||
|
||||
auswahl.hidden = true;
|
||||
document.body.appendChild(auswahl);
|
||||
}
|
||||
|
||||
/* Der Splitdialog zu einer Zeile – oder, wo es kein <dialog> gibt, die
|
||||
* Buchungsmaske. Die Weiche steht hier oben, weil beide Wege hierher
|
||||
* führen: der Klick auf eine Splitbuchung und die Wahl „Splitbuchung“. */
|
||||
function splitOeffnen(feld) {
|
||||
if (dialog && splitformular && window.FmBetrag) {
|
||||
splitdialogOeffnen(feld);
|
||||
} else {
|
||||
window.location.href = '/buchungen/' + feld.dataset.buchung;
|
||||
}
|
||||
}
|
||||
|
||||
function oeffnen(feld) {
|
||||
schliessen();
|
||||
|
||||
offenesFeld = feld;
|
||||
feld.hidden = true;
|
||||
|
||||
auswahl.action = ziel(feld, 'zuordnung');
|
||||
liste.value = feld.dataset.wert || '';
|
||||
feld.parentNode.appendChild(auswahl);
|
||||
auswahl.hidden = false;
|
||||
|
||||
liste.focus();
|
||||
// Die Liste soll sofort aufklappen; showPicker gibt es nicht überall.
|
||||
try {
|
||||
liste.showPicker();
|
||||
} catch (fehler) {
|
||||
/* Dann klappt sie beim ersten Klick auf – kein Verlust. */
|
||||
}
|
||||
}
|
||||
|
||||
tabelle.addEventListener('click', function (ereignis) {
|
||||
// Die Gegenseite einer fremden Umbuchung führt zu ihrer Buchung, statt
|
||||
// hier die Auswahl zu öffnen: Geändert wird sie auf ihrem Konto.
|
||||
const feld = ereignis.target.closest('a.zuordnungsfeld:not(.gegenseite)');
|
||||
if (!feld || ereignis.metaKey || ereignis.ctrlKey || ereignis.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
ereignis.preventDefault();
|
||||
|
||||
// Eine schon aufgeteilte Buchung führt geradewegs in den Splitdialog:
|
||||
// Die Auswahlliste hätte für sie nichts anzubieten außer dem Weg dorthin.
|
||||
if (feld.dataset.wert === 'split') {
|
||||
splitOeffnen(feld);
|
||||
return;
|
||||
}
|
||||
|
||||
oeffnen(feld);
|
||||
});
|
||||
|
||||
liste.addEventListener('change', function () {
|
||||
if (!offenesFeld) {
|
||||
return;
|
||||
}
|
||||
|
||||
const feld = offenesFeld;
|
||||
|
||||
if (liste.value === 'split') {
|
||||
schliessen();
|
||||
splitOeffnen(feld);
|
||||
return;
|
||||
}
|
||||
|
||||
// Eine Rückfrage wegen verlorener Splitzeilen braucht es hier nicht mehr:
|
||||
// Für eine aufgeteilte Buchung öffnet sich diese Liste gar nicht erst.
|
||||
auswahl.requestSubmit();
|
||||
});
|
||||
|
||||
// Escape und der Klick daneben verwerfen die Auswahl.
|
||||
document.addEventListener('keydown', function (ereignis) {
|
||||
if (ereignis.key === 'Escape' && offenesFeld) {
|
||||
schliessen();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (ereignis) {
|
||||
if (!(ereignis.target instanceof Element) || !offenesFeld) {
|
||||
return;
|
||||
}
|
||||
if (!ereignis.target.closest('.zuordnungszelle')) {
|
||||
schliessen();
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Splitdialog ─────────────────────────────────────────────────────── */
|
||||
|
||||
if (!dialog || !splitformular || !window.FmBetrag) {
|
||||
return;
|
||||
}
|
||||
|
||||
const zeilenkasten = document.getElementById('splitdialogzeilen');
|
||||
const summenfeld = document.getElementById('splitdialogsumme');
|
||||
const loeschknopf = document.getElementById('splitloeschen');
|
||||
const vorlage = zeilenkasten.innerHTML;
|
||||
let buchungsbetrag = 0;
|
||||
|
||||
function felder() {
|
||||
return Array.from(zeilenkasten.querySelectorAll('.splitbetrag'));
|
||||
}
|
||||
|
||||
function rechneSumme() {
|
||||
const stand = window.FmBetrag.splitsumme(felder(), buchungsbetrag);
|
||||
summenfeld.textContent = stand.text;
|
||||
summenfeld.classList.toggle('negativ', stand.belegt && stand.rest !== 0);
|
||||
}
|
||||
|
||||
/* Eine weitere Zeile: die letzte kopieren, leeren und neu durchnummerieren.
|
||||
* Die Namen tragen den Index (`split[3][betrag]`) – ohne Umnummerierung
|
||||
* überschriebe die Kopie ihre Vorlage. */
|
||||
function zeileAnhaengen() {
|
||||
const letzte = zeilenkasten.lastElementChild;
|
||||
const kopie = letzte.cloneNode(true);
|
||||
const nr = zeilenkasten.children.length;
|
||||
|
||||
kopie.querySelectorAll('[name]').forEach(function (feld) {
|
||||
feld.name = feld.name.replace(/^split\[\d+\]/, 'split[' + nr + ']');
|
||||
if (feld.tagName === 'SELECT') {
|
||||
feld.selectedIndex = 0;
|
||||
} else {
|
||||
feld.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
zeilenkasten.appendChild(kopie);
|
||||
return kopie;
|
||||
}
|
||||
|
||||
function splitdialogOeffnen(feld) {
|
||||
splitformular.action = ziel(feld, 'splitbuchung');
|
||||
buchungsbetrag = Number(feld.dataset.betrag || '0');
|
||||
|
||||
document.getElementById('splitbezeichnung').textContent = feld.dataset.bezeichnung || '';
|
||||
document.getElementById('splitbuchungsbetrag').textContent =
|
||||
window.FmBetrag.formatiere(buchungsbetrag);
|
||||
|
||||
// Immer mit leeren Zeilen beginnen: Der Dialog gilt für alle Buchungen
|
||||
// der Seite, die Zeilen der vorigen dürfen nicht stehen bleiben.
|
||||
zeilenkasten.innerHTML = vorlage;
|
||||
|
||||
const vorhandene = JSON.parse(feld.dataset.splits || '[]');
|
||||
|
||||
// Zu löschen gibt es nur, was schon aufgeteilt ist. Beim erstmaligen
|
||||
// Aufteilen wäre der Knopf ohne Wirkung – und ohne Sinn.
|
||||
loeschknopf.hidden = vorhandene.length === 0;
|
||||
|
||||
vorhandene.forEach(function (zeile, nr) {
|
||||
const tr = zeilenkasten.children[nr] || zeileAnhaengen();
|
||||
|
||||
tr.querySelector('select[name$="[zuordnung]"]').value = zeile.zuordnung || '';
|
||||
tr.querySelector('select[name$="[klasse_id]"]').value = zeile.klasse_id
|
||||
? String(zeile.klasse_id)
|
||||
: '';
|
||||
tr.querySelector('input[name$="[notiz]"]').value = zeile.notiz || '';
|
||||
tr.querySelector('input[name$="[betrag]"]').value = zeile.betrag || '';
|
||||
});
|
||||
|
||||
rechneSumme();
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
zeilenkasten.addEventListener('input', function (ereignis) {
|
||||
if (ereignis.target.classList.contains('splitbetrag')) {
|
||||
rechneSumme();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('splitzeileMehr').addEventListener('click', function () {
|
||||
zeileAnhaengen().querySelector('select').focus();
|
||||
});
|
||||
|
||||
document.getElementById('splitabbrechen').addEventListener('click', function () {
|
||||
dialog.close();
|
||||
});
|
||||
})();
|
||||
|
|
@ -21,6 +21,9 @@ declare(strict_types=1);
|
|||
* Einstellungen, sonst 2026-01-01)
|
||||
* --bis=JJJJ-MM-TT Enddatum; spätere Buchungen sind Vorausberechnungen
|
||||
* des FinanzManagers (Vorgabe: heute)
|
||||
* --konto=NAME nur dieses Konto übernehmen; mehrfach angebbar. Ohne
|
||||
* Angabe zählen alle Konten der Datei.
|
||||
* --konten nennt die Konten der Datei und beendet sich
|
||||
* --benutzer=NAME Mandant, sonst der erste aktive Benutzer
|
||||
*/
|
||||
|
||||
|
|
@ -39,16 +42,22 @@ Config::load();
|
|||
$argumente = array_slice($argv, 1);
|
||||
$uebernehmen = false;
|
||||
$nurStammdaten = false;
|
||||
$nurKonten = false;
|
||||
$benutzer = null;
|
||||
$datei = null;
|
||||
$stichtag = null;
|
||||
$bis = null;
|
||||
$konten = [];
|
||||
|
||||
foreach ($argumente as $argument) {
|
||||
if ($argument === '--uebernehmen') {
|
||||
$uebernehmen = true;
|
||||
} elseif ($argument === '--nur-stammdaten') {
|
||||
$nurStammdaten = true;
|
||||
} elseif ($argument === '--konten') {
|
||||
$nurKonten = true;
|
||||
} elseif (str_starts_with($argument, '--konto=')) {
|
||||
$konten[] = substr($argument, 8);
|
||||
} elseif (str_starts_with($argument, '--benutzer=')) {
|
||||
$benutzer = substr($argument, 11);
|
||||
} elseif (str_starts_with($argument, '--stichtag=')) {
|
||||
|
|
@ -64,8 +73,8 @@ foreach ($argumente as $argument) {
|
|||
}
|
||||
|
||||
if ($datei === null) {
|
||||
fwrite(STDERR, "Aufruf: qif-import.php [--uebernehmen] [--nur-stammdaten] "
|
||||
. "[--stichtag=JJJJ-MM-TT] [--bis=JJJJ-MM-TT] [--benutzer=NAME] <datei.qif>\n");
|
||||
fwrite(STDERR, "Aufruf: qif-import.php [--uebernehmen] [--nur-stammdaten] [--konten] "
|
||||
. "[--konto=NAME] [--stichtag=JJJJ-MM-TT] [--bis=JJJJ-MM-TT] [--benutzer=NAME] <datei.qif>\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
|
|
@ -91,14 +100,46 @@ if ($benutzer !== null) {
|
|||
$stichtag ??= (string)Einstellung::get('import_stichtag', '2026-01-01');
|
||||
$bis ??= date('Y-m-d');
|
||||
|
||||
// Die Konten der Datei nennen, ohne etwas anzufassen – so lässt sich
|
||||
// nachsehen, welche Namen --konto= überhaupt kennt.
|
||||
if ($nurKonten) {
|
||||
try {
|
||||
$parser = QifParser::ausDatei($datei);
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, 'Datei nicht lesbar: ' . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo 'Konten in ' . basename($datei) . ":\n";
|
||||
foreach (QifBuchungsImport::kontenDerDatei($parser) as $name => $anzahl) {
|
||||
printf(" %-40s %6d Einträge\n", $name, $anzahl);
|
||||
}
|
||||
exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
$parser = QifParser::ausDatei($datei);
|
||||
$stamm = (new QifStammdatenImport($parser))->ausfuehren(!$uebernehmen);
|
||||
|
||||
// Ein Tippfehler im Kontonamen bliebe sonst unbemerkt: Der Import liefe
|
||||
// durch und legte nichts an.
|
||||
$bekannt = array_keys(QifBuchungsImport::kontenDerDatei($parser));
|
||||
foreach ($konten as $name) {
|
||||
if (!in_array($name, $bekannt, true)) {
|
||||
fwrite(STDERR, "Konto nicht in der Datei: {$name}\n");
|
||||
fwrite(STDERR, "Vorhanden: " . implode(', ', $bekannt) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
$stamm = (new QifStammdatenImport($parser))->ausfuehren(!$uebernehmen);
|
||||
|
||||
$buch = null;
|
||||
if (!$nurStammdaten) {
|
||||
$buch = (new QifBuchungsImport($parser, ['stichtag' => $stichtag, 'bis' => $bis]))
|
||||
->ausfuehren(!$uebernehmen);
|
||||
$buch = (new QifBuchungsImport($parser, [
|
||||
'stichtag' => $stichtag,
|
||||
'bis' => $bis,
|
||||
'konten' => $konten,
|
||||
]))->ausfuehren(!$uebernehmen);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, 'Import fehlgeschlagen: ' . $e->getMessage() . "\n");
|
||||
|
|
@ -131,6 +172,10 @@ if ($buch !== null) {
|
|||
$buch['buchungen']['neu'], $buch['buchungen']['vorhanden'], $buch['buchungen']['fehler']);
|
||||
|
||||
echo "\nZeitraum ab {$stichtag} bis {$bis}\n";
|
||||
if ($buch['konten'] !== null) {
|
||||
echo ' nur die Konten: ' . implode(', ', $buch['konten']) . "\n";
|
||||
printf(" wegen der Kontoauswahl übergangen: %4d Buchungen\n", $buch['buchungen']['nicht_gewaehlt']);
|
||||
}
|
||||
printf(" vor dem Stichtag verrechnet: %6d Buchungen auf %d Konten (Summe %s)\n",
|
||||
$buch['buchungen']['vor_stichtag'],
|
||||
$buch['anfangssalden']['konten'],
|
||||
|
|
|
|||
|
|
@ -50,6 +50,28 @@ abstract class BaseController
|
|||
return array_values(array_map('intval', array_filter($werte, 'is_scalar')));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mehrfachauswahl mit Textwerten (Ankreuzfelder wie „konten[]“).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
protected function postTexte(string $feld): array
|
||||
{
|
||||
$werte = $_POST[$feld] ?? [];
|
||||
if (!is_array($werte)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$texte = [];
|
||||
foreach ($werte as $wert) {
|
||||
if (is_string($wert) && trim($wert) !== '') {
|
||||
$texte[] = trim($wert);
|
||||
}
|
||||
}
|
||||
|
||||
return $texte;
|
||||
}
|
||||
|
||||
protected function query(string $feld, string $standard = ''): string
|
||||
{
|
||||
$wert = $_GET[$feld] ?? $standard;
|
||||
|
|
|
|||
|
|
@ -50,6 +50,16 @@ class BuchungenController extends BaseController
|
|||
$seite = max(1, (int)$this->query('seite', '1'));
|
||||
$zeilen = array_slice($zeilen, ($seite - 1) * $grenze, $grenze);
|
||||
|
||||
// Die Splitzeilen der angezeigten Buchungen in einer Abfrage – der
|
||||
// Splitdialog zeigt damit eine bestehende Aufteilung, ohne sie
|
||||
// nachzuladen.
|
||||
$gesplittete = [];
|
||||
foreach ($zeilen as $zeile) {
|
||||
if ((int)$zeile['gesplittet'] === 1 && !$zeile['gegensicht']) {
|
||||
$gesplittete[] = (int)$zeile['id'];
|
||||
}
|
||||
}
|
||||
|
||||
$this->render('buchungen/kontoblatt.php', [
|
||||
'titel' => 'Kontoblatt: ' . $konto['name'],
|
||||
'konto' => Konto::mitSalden($konto),
|
||||
|
|
@ -60,8 +70,8 @@ class BuchungenController extends BaseController
|
|||
'filter' => $filter,
|
||||
'kategorien' => Kategorie::baumFlach(),
|
||||
'klassen' => Klasse::alle(),
|
||||
// Namen vorab, damit die Tabelle nicht je Zeile nachfragt.
|
||||
'kontenNamen' => array_column(Konto::alle(), 'name', 'id'),
|
||||
'konten' => Konto::alle(),
|
||||
'splits' => Buchung::splitsJeBuchung($gesplittete),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -153,6 +163,72 @@ class BuchungenController extends BaseController
|
|||
View::redirect('/konten/' . $buchung['konto_id'], 'Buchung gelöscht.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Schnellzuordnung aus dem Kontoblatt: Kategorie oder Gegenkonto einer
|
||||
* Buchung setzen, ohne die Maske zu öffnen.
|
||||
*
|
||||
* @param array<string,string> $params
|
||||
*/
|
||||
public function zuordnung(array $params): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$buchung = Buchung::find((int)$params['id']);
|
||||
if ($buchung === null) {
|
||||
$this->nichtGefunden('/buchungen/' . $params['id']);
|
||||
return;
|
||||
}
|
||||
|
||||
$ziel = $this->kontoblattPfad((int)$buchung['konto_id']);
|
||||
|
||||
try {
|
||||
$zuordnung = self::zuordnungAus($this->post('zuordnung'));
|
||||
Buchung::zuordnen(
|
||||
(int)$buchung['id'],
|
||||
$zuordnung['kategorie_id'],
|
||||
$zuordnung['gegenkonto_id']
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
View::redirect($ziel, $e->getMessage(), 'fehler');
|
||||
return;
|
||||
}
|
||||
|
||||
View::redirect($ziel, 'Zuordnung gespeichert.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Splitdialog des Kontoblatts: die Zeilen einer Buchung ersetzen.
|
||||
* Ohne Zeilen wird die Splitbuchung aufgehoben.
|
||||
*
|
||||
* Der Knopf „Splitbuchung löschen“ schickt dasselbe Formular mit
|
||||
* `aktion=loeschen`. Seine Zeilen zählen dann nicht: Gelöscht wird die
|
||||
* Aufteilung, und die Buchung steht danach ohne Zuordnung da.
|
||||
*
|
||||
* @param array<string,string> $params
|
||||
*/
|
||||
public function splitbuchung(array $params): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$buchung = Buchung::find((int)$params['id']);
|
||||
if ($buchung === null) {
|
||||
$this->nichtGefunden('/buchungen/' . $params['id']);
|
||||
return;
|
||||
}
|
||||
|
||||
$ziel = $this->kontoblattPfad((int)$buchung['konto_id']);
|
||||
$loeschen = $this->post('aktion') === 'loeschen';
|
||||
|
||||
try {
|
||||
Buchung::splitsErsetzen((int)$buchung['id'], $loeschen ? [] : $this->splitzeilen());
|
||||
} catch (\Throwable $e) {
|
||||
View::redirect($ziel, $e->getMessage(), 'fehler');
|
||||
return;
|
||||
}
|
||||
|
||||
View::redirect($ziel, $loeschen ? 'Splitbuchung gelöscht.' : 'Splitbuchung gespeichert.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mehrere im Kontoblatt angekreuzte Buchungen auf einmal löschen.
|
||||
*
|
||||
|
|
@ -245,19 +321,17 @@ class BuchungenController extends BaseController
|
|||
*/
|
||||
private function formulardaten(int $kontoId): array
|
||||
{
|
||||
$art = $this->post('art', 'ausgabe');
|
||||
$betrag = KontenController::zahl($this->post('betrag', '0'));
|
||||
|
||||
// In der Maske wird ein positiver Betrag eingegeben; die Richtung
|
||||
// bestimmt die Auswahl Einnahme / Ausgabe / Umbuchung.
|
||||
$betrag = abs($betrag);
|
||||
if ($art === 'ausgabe' || $art === 'umbuchung_ab') {
|
||||
// bestimmt die Auswahl Einnahme / Ausgabe.
|
||||
$betrag = abs(KontenController::zahl($this->post('betrag', '0')));
|
||||
if ($this->post('art', 'ausgabe') !== 'einnahme') {
|
||||
$betrag = -$betrag;
|
||||
}
|
||||
|
||||
$gegenkonto = in_array($art, ['umbuchung_ab', 'umbuchung_zu'], true)
|
||||
? $this->post('gegenkonto_id')
|
||||
: null;
|
||||
// Kategorie und Gegenkonto stehen in einem Feld: Ein Konto in der Liste
|
||||
// macht die Buchung zur Umbuchung – wie das Kategoriefeld im
|
||||
// FinanzManager, das `[Konto]` annimmt.
|
||||
$zuordnung = self::zuordnungAus($this->post('zuordnung'));
|
||||
|
||||
$daten = [
|
||||
'konto_id' => $kontoId,
|
||||
|
|
@ -268,33 +342,71 @@ class BuchungenController extends BaseController
|
|||
'buchungstext' => $this->post('buchungstext'),
|
||||
'nummer' => $this->post('nummer'),
|
||||
'abgeglichen' => isset($_POST['abgeglichen']),
|
||||
'kategorie_id' => $gegenkonto === null ? $this->post('kategorie_id') : null,
|
||||
'kategorie_id' => $zuordnung['kategorie_id'],
|
||||
'klasse_id' => $this->post('klasse_id'),
|
||||
'gegenkonto_id' => $gegenkonto,
|
||||
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
|
||||
'quelle' => 'manuell',
|
||||
];
|
||||
|
||||
$splits = [];
|
||||
$zeilen = $_POST['split'] ?? [];
|
||||
if (is_array($zeilen)) {
|
||||
foreach ($zeilen as $zeile) {
|
||||
if (!is_array($zeile)) {
|
||||
continue;
|
||||
}
|
||||
$wert = trim((string)($zeile['betrag'] ?? ''));
|
||||
if ($wert === '') {
|
||||
continue;
|
||||
}
|
||||
$splits[] = [
|
||||
'kategorie_id' => $zeile['kategorie_id'] ?? null,
|
||||
'klasse_id' => $zeile['klasse_id'] ?? null,
|
||||
'betrag' => KontenController::zahl($wert),
|
||||
'notiz' => (string)($zeile['notiz'] ?? ''),
|
||||
];
|
||||
}
|
||||
return [$daten, $this->splitzeilen()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Die ausgefüllten Splitzeilen eines Formulars. Zeilen ohne Betrag zählen
|
||||
* nicht – so bleiben die leeren Zeilen der Maske folgenlos.
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private function splitzeilen(): array
|
||||
{
|
||||
$zeilen = $_POST['split'] ?? [];
|
||||
if (!is_array($zeilen)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [$daten, $splits];
|
||||
$splits = [];
|
||||
foreach ($zeilen as $zeile) {
|
||||
if (!is_array($zeile)) {
|
||||
continue;
|
||||
}
|
||||
$wert = trim((string)($zeile['betrag'] ?? ''));
|
||||
if ($wert === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$zuordnung = self::zuordnungAus((string)($zeile['zuordnung'] ?? ''));
|
||||
$splits[] = [
|
||||
'kategorie_id' => $zuordnung['kategorie_id'],
|
||||
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
|
||||
'klasse_id' => $zeile['klasse_id'] ?? null,
|
||||
'betrag' => KontenController::zahl($wert),
|
||||
'notiz' => (string)($zeile['notiz'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $splits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Auswahlwert aus der gemeinsamen Liste „Kategorie oder Gegenkonto“:
|
||||
* `kategorie:12`, `konto:7` oder leer.
|
||||
*
|
||||
* @return array{kategorie_id: ?int, gegenkonto_id: ?int}
|
||||
*/
|
||||
public static function zuordnungAus(string $wert): array
|
||||
{
|
||||
$leer = ['kategorie_id' => null, 'gegenkonto_id' => null];
|
||||
|
||||
[$art, $id] = array_pad(explode(':', $wert, 2), 2, '');
|
||||
if ((int)$id <= 0) {
|
||||
return $leer;
|
||||
}
|
||||
|
||||
return match ($art) {
|
||||
'kategorie' => ['kategorie_id' => (int)$id, 'gegenkonto_id' => null],
|
||||
'konto' => ['kategorie_id' => null, 'gegenkonto_id' => (int)$id],
|
||||
default => $leer,
|
||||
};
|
||||
}
|
||||
|
||||
private function nichtGefunden(string $pfad): void
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ class ImportController extends BaseController
|
|||
'stichtag' => (string)Einstellung::get('import_stichtag', '2026-01-01'),
|
||||
'bis' => date('Y-m-d'),
|
||||
'datei' => '',
|
||||
'dateikonten' => [],
|
||||
'auswahl' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -89,14 +91,23 @@ class ImportController extends BaseController
|
|||
$parser = QifParser::ausDatei($pfad);
|
||||
$ergebnis = (new QifStammdatenImport($parser))->ausfuehren($trockenlauf);
|
||||
|
||||
// Erst nach dem Lesen steht fest, welche Konten die Datei führt –
|
||||
// nur die stehen zur Wahl. Was im Formular sonst noch ankäme, fiele
|
||||
// hier heraus.
|
||||
$dateikonten = QifBuchungsImport::kontenDerDatei($parser);
|
||||
$auswahl = array_values(array_intersect($this->postTexte('konten'), array_keys($dateikonten)));
|
||||
|
||||
// Buchungen brauchen die Stammdaten – im Trockenlauf sind die noch
|
||||
// nicht angelegt, deshalb bleibt es dort bei der Stammdatenvorschau,
|
||||
// sobald die Konten fehlen.
|
||||
$buchungen = null;
|
||||
$vorlagen = null;
|
||||
if (!$this->post('nur_stammdaten')) {
|
||||
$buchungen = (new QifBuchungsImport($parser, ['stichtag' => $stichtag, 'bis' => $bis]))
|
||||
->ausfuehren($trockenlauf);
|
||||
$buchungen = (new QifBuchungsImport($parser, [
|
||||
'stichtag' => $stichtag,
|
||||
'bis' => $bis,
|
||||
'konten' => $auswahl,
|
||||
]))->ausfuehren($trockenlauf);
|
||||
|
||||
// Buchungsvorlagen stehen in derselben Datei (!Type:Memorized)
|
||||
// und kommen gleich mit – ein eigener Durchgang wäre nur ein
|
||||
|
|
@ -118,6 +129,8 @@ class ImportController extends BaseController
|
|||
'stichtag' => $stichtag,
|
||||
'bis' => $bis,
|
||||
'datei' => $name,
|
||||
'dateikonten' => $dateikonten,
|
||||
'auswahl' => $auswahl,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use Finanzmanager\Models\Einstellung;
|
|||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\Models\Wertpapier;
|
||||
use Finanzmanager\Services\Depot;
|
||||
use Finanzmanager\Services\DepotbestandImport;
|
||||
use Finanzmanager\Services\Kursabruf;
|
||||
use Finanzmanager\Services\Zeitraum;
|
||||
use Finanzmanager\View;
|
||||
|
|
@ -127,7 +128,217 @@ class WertpapiereController extends BaseController
|
|||
|
||||
Depotbuchung::loeschen((int)$params['id']);
|
||||
|
||||
View::redirect('/wertpapiere/buchungen', 'Depotbuchung gelöscht – samt ihrer Geldbuchung.');
|
||||
View::redirect($this->buchungenPfad(), 'Depotbuchung gelöscht – samt ihrer Geldbuchung.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sammellöschen aus der Liste der Depotbuchungen – wie im Kontoblatt.
|
||||
*
|
||||
* Der Bestand wird bei jedem Aufruf aus den Depotbuchungen gerechnet; er
|
||||
* gibt die Löschung deshalb von selbst wieder her, ebenso Veräußerungen und
|
||||
* Erträge. Fortgeschrieben wird nichts.
|
||||
*/
|
||||
public function auswahlLoeschen(): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$ziel = $this->buchungenPfad();
|
||||
$ids = $this->postIds('buchungen');
|
||||
if ($ids === []) {
|
||||
View::redirect($ziel, 'Keine Depotbuchung ausgewählt.', 'warnung');
|
||||
return;
|
||||
}
|
||||
|
||||
$anzahl = 0;
|
||||
foreach ($ids as $id) {
|
||||
// Fremde oder längst gelöschte Nummern still übergehen – der Rest
|
||||
// der Auswahl soll trotzdem verschwinden.
|
||||
if (Depotbuchung::find($id) === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
Depotbuchung::loeschen($id);
|
||||
} catch (\Throwable $e) {
|
||||
View::redirect($ziel, $e->getMessage(), 'fehler');
|
||||
return;
|
||||
}
|
||||
$anzahl++;
|
||||
}
|
||||
|
||||
View::redirect($ziel, $anzahl === 1
|
||||
? 'Depotbuchung gelöscht – samt ihrer Geldbuchung.'
|
||||
: $anzahl . ' Depotbuchungen gelöscht – samt ihrer Geldbuchungen.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Zurück in die Liste, mit demselben Filter.
|
||||
*
|
||||
* Die Werte kommen aus der Abfragezeichenfolge des Formularziels und werden
|
||||
* hier neu zusammengesetzt statt übernommen: So kann kein fremdes Ziel
|
||||
* eingeschleust werden.
|
||||
*/
|
||||
private function buchungenPfad(): string
|
||||
{
|
||||
$query = array_filter([
|
||||
'depot' => $this->query('depot'),
|
||||
'wertpapier' => $this->query('wertpapier'),
|
||||
'vorgang' => $this->query('vorgang'),
|
||||
], static fn(string $wert): bool => $wert !== '');
|
||||
|
||||
return '/wertpapiere/buchungen' . ($query === [] ? '' : '?' . http_build_query($query));
|
||||
}
|
||||
|
||||
// ── Bestandsimport aus einer CSV-Datei ───────────────────────────────────
|
||||
|
||||
public function importFormular(): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$this->render('wertpapiere/import.php', [
|
||||
'titel' => 'Depotbestand importieren',
|
||||
'depots' => Depot::depots(true),
|
||||
'geldkonten' => $this->geldkonten(),
|
||||
'ergebnis' => null,
|
||||
'datei' => '',
|
||||
'depot' => (int)$this->query('depot'),
|
||||
'verrechnungskonto' => 0,
|
||||
'datum' => '',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vorschau und Übernahme laufen durch dieselbe Tür: Der erste Aufruf bringt
|
||||
* die hochgeladene Datei mit, der zweite nur noch ihren Namen.
|
||||
*/
|
||||
public function importAusfuehren(): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
try {
|
||||
$datei = $this->importDatei();
|
||||
} catch (\Throwable $e) {
|
||||
View::redirect('/wertpapiere/import', $e->getMessage(), 'fehler');
|
||||
return;
|
||||
}
|
||||
|
||||
$trockenlauf = $this->post('modus') !== 'import';
|
||||
$depotId = (int)$this->post('konto_id');
|
||||
$verrechnung = (int)$this->post('verrechnungskonto_id');
|
||||
|
||||
try {
|
||||
$import = DepotbestandImport::ausDatei($this->importVerzeichnis() . '/' . $datei, [
|
||||
'konto_id' => $depotId,
|
||||
'verrechnungskonto_id' => $verrechnung,
|
||||
'datum' => $this->post('datum'),
|
||||
]);
|
||||
|
||||
// Ohne ausdrückliche Wahl entscheidet die Depotnummer aus der Datei.
|
||||
if ($depotId === 0) {
|
||||
$passend = DepotbestandImport::passendesDepot($import->depotnummer());
|
||||
$depotId = (int)($passend['id'] ?? 0);
|
||||
$import->depot($depotId);
|
||||
}
|
||||
|
||||
$ergebnis = $import->ausfuehren($trockenlauf);
|
||||
} catch (\Throwable $e) {
|
||||
View::redirect('/wertpapiere/import', 'Import fehlgeschlagen: ' . $e->getMessage(), 'fehler');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$trockenlauf) {
|
||||
View::redirect('/wertpapiere?depot=' . $depotId, sprintf(
|
||||
'%d Positionen übernommen, %d waren schon da%s.',
|
||||
$ergebnis['zaehler']['neu'],
|
||||
$ergebnis['zaehler']['vorhanden'],
|
||||
$ergebnis['zaehler']['fehler'] > 0
|
||||
? ', ' . $ergebnis['zaehler']['fehler'] . ' fehlgeschlagen' : ''
|
||||
), $ergebnis['zaehler']['fehler'] > 0 ? 'fehler' : 'erfolg');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->render('wertpapiere/import.php', [
|
||||
'titel' => 'Depotbestand importieren',
|
||||
'depots' => Depot::depots(true),
|
||||
'geldkonten' => $this->geldkonten(),
|
||||
'ergebnis' => $ergebnis,
|
||||
'datei' => $datei,
|
||||
'depot' => $depotId,
|
||||
'verrechnungskonto' => $verrechnung,
|
||||
'datum' => $ergebnis['datum'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt die hochgeladene Datei entgegen und legt sie ab – oder greift auf
|
||||
* die aus der Vorschau zurück.
|
||||
*/
|
||||
private function importDatei(): string
|
||||
{
|
||||
$hochgeladen = $_FILES['csv'] ?? null;
|
||||
|
||||
if (is_array($hochgeladen) && ($hochgeladen['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK) {
|
||||
$name = self::sichererCsvName((string)$hochgeladen['name']);
|
||||
if ($name === null) {
|
||||
throw new \RuntimeException('Nur .CSV-Dateien können hochgeladen werden.');
|
||||
}
|
||||
|
||||
$ziel = $this->importVerzeichnis() . '/' . $name;
|
||||
if (
|
||||
!is_uploaded_file((string)$hochgeladen['tmp_name'])
|
||||
|| !move_uploaded_file((string)$hochgeladen['tmp_name'], $ziel)
|
||||
) {
|
||||
throw new \RuntimeException('Die Datei konnte nicht abgelegt werden.');
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
$name = self::sichererCsvName($this->post('datei'));
|
||||
if ($name === null || !is_file($this->importVerzeichnis() . '/' . $name)) {
|
||||
throw new \RuntimeException('Es wurde keine CSV-Datei ausgewählt.');
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
/** Wie beim QIF-Import: kein Pfadanteil, keine fremde Endung. */
|
||||
public static function sichererCsvName(string $eingabe): ?string
|
||||
{
|
||||
$name = basename(trim($eingabe));
|
||||
if ($name === '' || $name === '.' || $name === '..' || str_contains($name, "\0")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (strtolower(pathinfo($name, PATHINFO_EXTENSION)) !== 'csv') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = preg_replace('/[^A-Za-z0-9._-]/', '_', $name) ?? '';
|
||||
|
||||
return $name === '' ? null : $name;
|
||||
}
|
||||
|
||||
/** Dasselbe Verzeichnis wie beim QIF-Import – es ist das Importverzeichnis. */
|
||||
private function importVerzeichnis(): string
|
||||
{
|
||||
$relativ = (string)Einstellung::get('import_qif_verzeichnis', 'data/import');
|
||||
$pfad = dirname(__DIR__, 2) . '/' . trim($relativ, '/');
|
||||
|
||||
if (!is_dir($pfad)) {
|
||||
mkdir($pfad, 0o775, true);
|
||||
}
|
||||
|
||||
return $pfad;
|
||||
}
|
||||
|
||||
/** Konten, die als Verrechnungskonto in Frage kommen – alles außer Depots. */
|
||||
private function geldkonten(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
Konto::alle(true),
|
||||
static fn(array $konto): bool => (string)$konto['kontoart'] !== 'depot'
|
||||
));
|
||||
}
|
||||
|
||||
// ── Stammdaten und Kurse ─────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -18,7 +18,12 @@ use Finanzmanager\Mandant;
|
|||
*/
|
||||
class Buchung
|
||||
{
|
||||
public const QUELLEN = ['manuell' => 'manuell erfasst', 'qif' => 'QIF-Import', 'fints' => 'Bankabruf'];
|
||||
public const QUELLEN = [
|
||||
'manuell' => 'manuell erfasst',
|
||||
'qif' => 'QIF-Import',
|
||||
'fints' => 'Bankabruf',
|
||||
'csv' => 'CSV-Import',
|
||||
];
|
||||
|
||||
/** Beträge gelten als gleich, wenn sie sich um weniger als einen halben Cent unterscheiden. */
|
||||
private const CENT = 0.005;
|
||||
|
|
@ -41,6 +46,36 @@ class Buchung
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Splitzeilen mehrerer Buchungen in einer Abfrage – das Kontoblatt gibt
|
||||
* sie dem Splitdialog mit, damit er eine bestehende Aufteilung zeigt, ohne
|
||||
* je Zeile nachzufragen.
|
||||
*
|
||||
* @param list<int> $buchungIds
|
||||
* @return array<int, list<array<string,mixed>>> buchung_id => Zeilen
|
||||
*/
|
||||
public static function splitsJeBuchung(array $buchungIds): array
|
||||
{
|
||||
$ids = array_values(array_unique(array_map('intval', $buchungIds)));
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$zeilen = Database::fetchAll(
|
||||
'SELECT * FROM buchung_splits
|
||||
WHERE buchung_id IN (' . implode(',', array_fill(0, count($ids), '?')) . ')
|
||||
ORDER BY buchung_id, sortierung, id',
|
||||
$ids
|
||||
);
|
||||
|
||||
$jeBuchung = [];
|
||||
foreach ($zeilen as $zeile) {
|
||||
$jeBuchung[(int)$zeile['buchung_id']][] = $zeile;
|
||||
}
|
||||
|
||||
return $jeBuchung;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function adressen(int $buchungId): array
|
||||
{
|
||||
|
|
@ -125,6 +160,68 @@ class Buchung
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Kategorie oder Gegenkonto einer Buchung setzen – die Schnellzuordnung aus
|
||||
* dem Kontoblatt. Alles andere an der Buchung bleibt unberührt.
|
||||
*
|
||||
* Beides zugleich gibt es nicht: Eine Buchung ist entweder kategorisiert
|
||||
* oder eine Umbuchung. War sie gesplittet, fallen die Zeilen weg – die
|
||||
* Zuordnung steckt danach wieder oben.
|
||||
*/
|
||||
public static function zuordnen(int $id, ?int $kategorieId, ?int $gegenkontoId): void
|
||||
{
|
||||
$buchung = self::find($id);
|
||||
if ($buchung === null) {
|
||||
throw new \RuntimeException('Die Buchung gibt es nicht.');
|
||||
}
|
||||
Monatsabschluss::verlangeOffen((string)$buchung['datum']);
|
||||
|
||||
if ($gegenkontoId !== null) {
|
||||
$kategorieId = null;
|
||||
self::pruefeGegenkonto($gegenkontoId, (int)$buchung['konto_id']);
|
||||
}
|
||||
if ($kategorieId !== null && Kategorie::find($kategorieId) === null) {
|
||||
throw new \InvalidArgumentException('Die Kategorie gibt es nicht.');
|
||||
}
|
||||
|
||||
Database::transaktion(static function () use ($id, $kategorieId, $gegenkontoId): void {
|
||||
Database::execute('DELETE FROM buchung_splits WHERE buchung_id = ?', [$id]);
|
||||
Database::execute(
|
||||
'UPDATE buchungen SET kategorie_id = ?, gegenkonto_id = ?, gesplittet = 0
|
||||
WHERE id = ? AND benutzer_id = ?',
|
||||
[$kategorieId, $gegenkontoId, $id, Mandant::id()]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Splitzeilen einer Buchung ersetzen – der Splitdialog des Kontoblatts.
|
||||
* Eine leere Liste hebt die Splitbuchung auf; die Buchung steht danach ohne
|
||||
* Zuordnung da.
|
||||
*
|
||||
* @param list<array<string,mixed>> $splits
|
||||
*/
|
||||
public static function splitsErsetzen(int $id, array $splits): void
|
||||
{
|
||||
$buchung = self::find($id);
|
||||
if ($buchung === null) {
|
||||
throw new \RuntimeException('Die Buchung gibt es nicht.');
|
||||
}
|
||||
Monatsabschluss::verlangeOffen((string)$buchung['datum']);
|
||||
|
||||
self::pruefeSplits($splits, (float)$buchung['betrag'], (int)$buchung['konto_id']);
|
||||
|
||||
Database::transaktion(static function () use ($id, $splits): void {
|
||||
Database::execute('DELETE FROM buchung_splits WHERE buchung_id = ?', [$id]);
|
||||
Database::execute(
|
||||
'UPDATE buchungen SET kategorie_id = NULL, gegenkonto_id = NULL, gesplittet = ?
|
||||
WHERE id = ? AND benutzer_id = ?',
|
||||
[$splits === [] ? 0 : 1, $id, Mandant::id()]
|
||||
);
|
||||
self::speichereSplits($id, $splits);
|
||||
});
|
||||
}
|
||||
|
||||
public static function loeschen(int $id): void
|
||||
{
|
||||
$buchung = self::find($id);
|
||||
|
|
@ -425,27 +522,14 @@ class Buchung
|
|||
$kategorie = self::id($daten['kategorie_id'] ?? null);
|
||||
$gesplittet = $splits !== [];
|
||||
|
||||
if ($gegenkonto !== null && $gegenkonto === $kontoId) {
|
||||
throw new \InvalidArgumentException('Eine Umbuchung braucht zwei verschiedene Konten.');
|
||||
if ($gegenkonto !== null) {
|
||||
self::pruefeGegenkonto($gegenkonto, $kontoId);
|
||||
}
|
||||
if ($gesplittet && ($kategorie !== null || $gegenkonto !== null)) {
|
||||
if ($gesplittet) {
|
||||
// Bei einer Splitbuchung steckt die Zuordnung in den Zeilen.
|
||||
$kategorie = null;
|
||||
$gegenkonto = null;
|
||||
}
|
||||
|
||||
if ($gesplittet) {
|
||||
$summe = 0.0;
|
||||
foreach ($splits as $split) {
|
||||
$summe += round((float)($split['betrag'] ?? 0), 2);
|
||||
}
|
||||
if (abs($summe - $betrag) > self::CENT) {
|
||||
throw new \InvalidArgumentException(sprintf(
|
||||
'Die Splitzeilen ergeben %s, die Buchung lautet über %s.',
|
||||
number_format($summe, 2, ',', '.'),
|
||||
number_format($betrag, 2, ',', '.')
|
||||
));
|
||||
}
|
||||
self::pruefeSplits($splits, $betrag, $kontoId);
|
||||
}
|
||||
|
||||
$valuta = trim((string)($daten['valuta'] ?? ''));
|
||||
|
|
@ -469,6 +553,48 @@ class Buchung
|
|||
];
|
||||
}
|
||||
|
||||
/** Ein Konto kann nicht sein eigenes Gegenkonto sein. */
|
||||
private static function pruefeGegenkonto(int $gegenkontoId, int $kontoId): void
|
||||
{
|
||||
if ($gegenkontoId === $kontoId) {
|
||||
throw new \InvalidArgumentException('Eine Umbuchung braucht zwei verschiedene Konten.');
|
||||
}
|
||||
if (Konto::find($gegenkontoId) === null) {
|
||||
throw new \InvalidArgumentException('Das Gegenkonto gibt es nicht.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Summe der Zeilen muss den Buchungsbetrag ergeben, und keine Zeile darf
|
||||
* auf das eigene Konto zeigen – sonst zählte die Buchung dort doppelt.
|
||||
*
|
||||
* @param list<array<string,mixed>> $splits
|
||||
*/
|
||||
private static function pruefeSplits(array $splits, float $betrag, int $kontoId): void
|
||||
{
|
||||
if ($splits === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$summe = 0.0;
|
||||
foreach ($splits as $split) {
|
||||
$summe += round((float)($split['betrag'] ?? 0), 2);
|
||||
|
||||
$gegenkonto = self::id($split['gegenkonto_id'] ?? null);
|
||||
if ($gegenkonto !== null) {
|
||||
self::pruefeGegenkonto($gegenkonto, $kontoId);
|
||||
}
|
||||
}
|
||||
|
||||
if (abs($summe - $betrag) > self::CENT) {
|
||||
throw new \InvalidArgumentException(sprintf(
|
||||
'Die Splitzeilen ergeben %s, die Buchung lautet über %s.',
|
||||
number_format($summe, 2, ',', '.'),
|
||||
number_format($betrag, 2, ',', '.')
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $splits */
|
||||
private static function speichereSplits(int $buchungId, array $splits): void
|
||||
{
|
||||
|
|
|
|||
494
src/Services/DepotbestandImport.php
Normal file
494
src/Services/DepotbestandImport.php
Normal file
|
|
@ -0,0 +1,494 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Finanzmanager\Services;
|
||||
|
||||
use Finanzmanager\Database;
|
||||
use Finanzmanager\Models\Depotbuchung;
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\Models\Wertpapier;
|
||||
|
||||
/**
|
||||
* Übernimmt eine Depotbestandsliste im CSV-Format der DKB.
|
||||
*
|
||||
* **Was die Datei enthält – und was nicht.** Der Export beschreibt nur den
|
||||
* Bestand eines Stichtags: je Position Stückzahl, Einstiegskurs und
|
||||
* Bewertungskurs. Die Geschäfte, die zu diesem Bestand geführt haben, fehlen
|
||||
* vollständig. Der Import baut deshalb je Position *eine* Depotbuchung –
|
||||
* einen Kauf zum Stichtag über die volle Stückzahl zum Einstiegskurs. Der
|
||||
* Einstand stimmt damit auf den Cent, die Historie bleibt naturgemäß leer.
|
||||
*
|
||||
* **Zwei Kurse, zwei Rollen.** Der Einstiegskurs ist der durchschnittliche
|
||||
* Kaufpreis und wird zum Kurs der Buchung; der Bewertungskurs ist der aktuelle
|
||||
* Kurs und landet als Kurs des Stichtags in der Kurstabelle. Erst dadurch zeigt
|
||||
* die Depotübersicht denselben Gewinn wie die Bank.
|
||||
*
|
||||
* **Kein Geldfluss.** Ein Verrechnungskonto ist optional und bleibt in aller
|
||||
* Regel leer: Das Geld ist irgendwann in der Vergangenheit geflossen, eine
|
||||
* Buchung zum heutigen Tag würde das Girokonto verfälschen.
|
||||
*
|
||||
* **Zweiter Lauf.** Jede Position bekommt einen `import_hash` aus Depot, Datum,
|
||||
* Vorgang, Stück, Betrag und ISIN. Derselbe Export legt deshalb nichts doppelt
|
||||
* an.
|
||||
*/
|
||||
class DepotbestandImport
|
||||
{
|
||||
/** Spaltenüberschrift (klein geschrieben) → eigenes Feld. */
|
||||
private const SPALTEN = [
|
||||
'datum der erstellung' => 'erstellt',
|
||||
'depotnummer' => 'depotnummer',
|
||||
'wertpapierbezeichnung' => 'name',
|
||||
'wertpapier' => 'name',
|
||||
'bezeichnung' => 'name',
|
||||
'wkn' => 'wkn',
|
||||
'isin' => 'isin',
|
||||
'einstiegskurs' => 'einstiegskurs',
|
||||
'bewertungskurs' => 'bewertungskurs',
|
||||
'stückzahl' => 'stueck',
|
||||
'stueckzahl' => 'stueck',
|
||||
'nominal' => 'stueck',
|
||||
'assetklasse' => 'assetklasse',
|
||||
];
|
||||
|
||||
/** Assetklasse der Bank → eigene Wertpapierart. */
|
||||
private const ASSETKLASSEN = [
|
||||
'aktien' => 'aktie',
|
||||
'aktie' => 'aktie',
|
||||
'etfs' => 'fonds',
|
||||
'etf' => 'fonds',
|
||||
'fonds' => 'fonds',
|
||||
'investmentfonds' => 'fonds',
|
||||
'anleihen' => 'anleihe',
|
||||
'anleihe' => 'anleihe',
|
||||
'renten' => 'anleihe',
|
||||
'zertifikate' => 'zertifikat',
|
||||
'zertifikat' => 'zertifikat',
|
||||
];
|
||||
|
||||
private const TRENNZEICHEN = [';', ',', "\t"];
|
||||
|
||||
/** @var list<array<string,mixed>> */
|
||||
private array $zeilen = [];
|
||||
|
||||
private string $erstellt = '';
|
||||
private string $depotnummer = '';
|
||||
|
||||
/** @var list<array{typ: string, text: string}> */
|
||||
private array $meldungen = [];
|
||||
|
||||
private int $kontoId;
|
||||
private ?int $verrechnungskontoId;
|
||||
private string $datum;
|
||||
|
||||
/**
|
||||
* @param string $inhalt Der rohe Dateiinhalt
|
||||
* @param array{konto_id?: int, verrechnungskonto_id?: int|null, datum?: string} $optionen
|
||||
*/
|
||||
public function __construct(string $inhalt, array $optionen = [])
|
||||
{
|
||||
$this->lies($inhalt);
|
||||
|
||||
$this->kontoId = (int)($optionen['konto_id'] ?? 0);
|
||||
|
||||
$verrechnung = $optionen['verrechnungskonto_id'] ?? null;
|
||||
$this->verrechnungskontoId = ($verrechnung === null || (int)$verrechnung === 0)
|
||||
? null : (int)$verrechnung;
|
||||
|
||||
// Ohne Vorgabe gilt der Stichtag der Datei – er passt zum
|
||||
// Bewertungskurs. Erst wenn auch der fehlt, wird es der heutige Tag.
|
||||
$datum = trim((string)($optionen['datum'] ?? ''));
|
||||
$this->datum = preg_match('/^\d{4}-\d{2}-\d{2}$/', $datum) === 1
|
||||
? $datum
|
||||
: ($this->erstellt !== '' ? $this->erstellt : date('Y-m-d'));
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $optionen */
|
||||
public static function ausDatei(string $pfad, array $optionen = []): self
|
||||
{
|
||||
$inhalt = @file_get_contents($pfad);
|
||||
if ($inhalt === false) {
|
||||
throw new \RuntimeException('Die Datei „' . basename($pfad) . '“ lässt sich nicht lesen.');
|
||||
}
|
||||
|
||||
return new self($inhalt, $optionen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt das Zieldepot nachträglich – die Depotnummer steht erst fest,
|
||||
* wenn die Datei gelesen ist.
|
||||
*/
|
||||
public function depot(int $kontoId): self
|
||||
{
|
||||
$this->kontoId = $kontoId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function stichtag(): string
|
||||
{
|
||||
return $this->datum;
|
||||
}
|
||||
|
||||
public function depotnummer(): string
|
||||
{
|
||||
return $this->depotnummer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Depot, dessen Kontonummer zur Depotnummer der Datei passt – als
|
||||
* Vorauswahl im Formular, damit niemand die Nummern vergleichen muss.
|
||||
*/
|
||||
public static function passendesDepot(string $depotnummer): ?array
|
||||
{
|
||||
$nummer = trim($depotnummer);
|
||||
if ($nummer === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (Depot::depots() as $depot) {
|
||||
if (trim((string)$depot['kontonummer']) === $nummer) {
|
||||
return $depot;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* trockenlauf: bool, datum: string, depotnummer: string,
|
||||
* positionen: list<array<string,mixed>>,
|
||||
* zaehler: array<string,int>, einstand: float, wert: float,
|
||||
* meldungen: list<array{typ: string, text: string}>
|
||||
* }
|
||||
*/
|
||||
public function ausfuehren(bool $trockenlauf = false): array
|
||||
{
|
||||
$this->meldungen = [];
|
||||
|
||||
if ($this->kontoId === 0 || Konto::find($this->kontoId) === null) {
|
||||
throw new \InvalidArgumentException('Der Import braucht ein gültiges Depot.');
|
||||
}
|
||||
|
||||
$lauf = fn(): array => $this->uebernimm($trockenlauf);
|
||||
|
||||
// Beim echten Lauf gilt alles oder nichts: Ein Abbruch in der Mitte
|
||||
// hinterließe einen halben Bestand.
|
||||
$ergebnis = $trockenlauf ? $lauf() : Database::transaktion($lauf);
|
||||
|
||||
$ergebnis['trockenlauf'] = $trockenlauf;
|
||||
$ergebnis['datum'] = $this->datum;
|
||||
$ergebnis['depotnummer'] = $this->depotnummer;
|
||||
$ergebnis['meldungen'] = $this->meldungen;
|
||||
|
||||
return $ergebnis;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function uebernimm(bool $trockenlauf): array
|
||||
{
|
||||
$zaehler = [
|
||||
'positionen' => count($this->zeilen),
|
||||
'neu' => 0,
|
||||
'vorhanden' => 0,
|
||||
'fehler' => 0,
|
||||
'wertpapiereNeu' => 0,
|
||||
'kurse' => 0,
|
||||
];
|
||||
|
||||
$positionen = [];
|
||||
$einstand = 0.0;
|
||||
$wert = 0.0;
|
||||
|
||||
foreach ($this->zeilen as $zeile) {
|
||||
$position = $zeile;
|
||||
|
||||
$betrag = round($zeile['stueck'] * $zeile['einstiegskurs'], 2);
|
||||
$position['einstand'] = $betrag;
|
||||
$position['wert'] = $zeile['bewertungskurs'] > 0.0
|
||||
? round($zeile['stueck'] * $zeile['bewertungskurs'], 2)
|
||||
: null;
|
||||
$position['gewinn'] = $position['wert'] === null
|
||||
? null : round($position['wert'] - $betrag, 2);
|
||||
|
||||
$hash = Depotbuchung::hash(
|
||||
$this->kontoId,
|
||||
$this->datum,
|
||||
'kauf',
|
||||
$zeile['stueck'],
|
||||
$betrag,
|
||||
$zeile['isin']
|
||||
);
|
||||
|
||||
if (Depotbuchung::findeNachHash($hash) !== null) {
|
||||
$position['status'] = 'vorhanden';
|
||||
$zaehler['vorhanden']++;
|
||||
$positionen[] = $position;
|
||||
$einstand += $betrag;
|
||||
$wert += $position['wert'] ?? 0.0;
|
||||
continue;
|
||||
}
|
||||
|
||||
$vorhandenesPapier = Wertpapier::finde($zeile['isin'], $zeile['wkn'], $zeile['name']);
|
||||
$position['wertpapier_neu'] = $vorhandenesPapier === null;
|
||||
if ($vorhandenesPapier === null) {
|
||||
$zaehler['wertpapiereNeu']++;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!$trockenlauf) {
|
||||
$this->buche($zeile, $betrag, $hash);
|
||||
$zaehler['kurse'] += $zeile['bewertungskurs'] > 0.0 ? 1 : 0;
|
||||
} elseif ($zeile['bewertungskurs'] > 0.0) {
|
||||
$zaehler['kurse']++;
|
||||
}
|
||||
|
||||
$position['status'] = 'neu';
|
||||
$zaehler['neu']++;
|
||||
} catch (\Throwable $e) {
|
||||
$position['status'] = 'fehler';
|
||||
$position['meldung'] = $e->getMessage();
|
||||
$zaehler['fehler']++;
|
||||
$this->melde('fehler', $zeile['name'] . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$positionen[] = $position;
|
||||
$einstand += $betrag;
|
||||
$wert += $position['wert'] ?? 0.0;
|
||||
}
|
||||
|
||||
return [
|
||||
'positionen' => $positionen,
|
||||
'zaehler' => $zaehler,
|
||||
'einstand' => round($einstand, 2),
|
||||
'wert' => round($wert, 2),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $zeile */
|
||||
private function buche(array $zeile, float $betrag, string $hash): void
|
||||
{
|
||||
$wertpapierId = Wertpapier::findenOderAnlegen([
|
||||
'name' => $zeile['name'],
|
||||
'isin' => $zeile['isin'],
|
||||
'wkn' => $zeile['wkn'],
|
||||
'art' => $zeile['art'],
|
||||
'waehrung' => 'EUR',
|
||||
]);
|
||||
|
||||
Depotbuchung::anlegen([
|
||||
'konto_id' => $this->kontoId,
|
||||
'wertpapier_id' => $wertpapierId,
|
||||
'datum' => $this->datum,
|
||||
'vorgang' => 'kauf',
|
||||
'stueck' => $zeile['stueck'],
|
||||
'kurs' => $zeile['einstiegskurs'],
|
||||
'betrag' => $betrag,
|
||||
'verrechnungskonto_id' => $this->verrechnungskontoId,
|
||||
'notiz' => 'Bestandsübernahme ' . date('d.m.Y', strtotime($this->datum)),
|
||||
'wertpapiername' => $zeile['name'],
|
||||
'quelle' => 'csv',
|
||||
'import_hash' => $hash,
|
||||
// Die Geldseite braucht einen eigenen Hash: Beide Tabellen führen
|
||||
// ihre Kennung in CHAR(40), derselbe Wert zweimal wäre verwirrend.
|
||||
'buchung_hash' => sha1($hash . '|geld'),
|
||||
]);
|
||||
|
||||
// Der Bewertungskurs ist der Kurs des Stichtags – ohne ihn stünde die
|
||||
// frisch übernommene Position gleich wieder ohne Bewertung da.
|
||||
if ($zeile['bewertungskurs'] > 0.0) {
|
||||
Wertpapier::kursSetzen($wertpapierId, $this->datum, $zeile['bewertungskurs'], 'csv');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Datei lesen ──────────────────────────────────────────────────────────
|
||||
|
||||
private function lies(string $inhalt): void
|
||||
{
|
||||
$inhalt = trim($this->zuUtf8($inhalt));
|
||||
if ($inhalt === '') {
|
||||
throw new \RuntimeException('Die Datei ist leer.');
|
||||
}
|
||||
|
||||
$trenner = $this->trennzeichen((string)strtok($inhalt, "\r\n"));
|
||||
|
||||
// Gelesen wird über einen Strom statt zeilenweise: Ein Wertpapiername
|
||||
// in Anführungszeichen darf einen Zeilenumbruch enthalten.
|
||||
$strom = fopen('php://memory', 'r+');
|
||||
if ($strom === false) {
|
||||
throw new \RuntimeException('Die Datei lässt sich nicht verarbeiten.');
|
||||
}
|
||||
|
||||
try {
|
||||
fwrite($strom, $inhalt);
|
||||
rewind($strom);
|
||||
|
||||
$spalten = null;
|
||||
$nummer = 0;
|
||||
|
||||
while (($felder = fgetcsv($strom, 0, $trenner, '"', '\\')) !== false) {
|
||||
$nummer++;
|
||||
|
||||
if ($felder === [null]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($spalten === null) {
|
||||
$spalten = $this->kopfzeile(array_map(strval(...), $felder));
|
||||
continue;
|
||||
}
|
||||
|
||||
$zeile = $this->zeile(array_map(strval(...), $felder), $spalten);
|
||||
if ($zeile !== null) {
|
||||
$this->zeilen[] = $zeile + ['zeile' => $nummer];
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fclose($strom);
|
||||
}
|
||||
|
||||
if ($this->zeilen === []) {
|
||||
throw new \RuntimeException('Die Datei enthält keine Positionen.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $ueberschriften
|
||||
* @return array<string,int> eigenes Feld → Spaltennummer
|
||||
*/
|
||||
private function kopfzeile(array $ueberschriften): array
|
||||
{
|
||||
$spalten = [];
|
||||
foreach ($ueberschriften as $nummer => $ueberschrift) {
|
||||
$name = mb_strtolower(trim((string)$ueberschrift));
|
||||
if (isset(self::SPALTEN[$name]) && !isset($spalten[self::SPALTEN[$name]])) {
|
||||
$spalten[self::SPALTEN[$name]] = $nummer;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['name', 'einstiegskurs', 'stueck'] as $pflicht) {
|
||||
if (!isset($spalten[$pflicht])) {
|
||||
throw new \RuntimeException(
|
||||
'Die Datei sieht nicht wie eine Depotbestandsliste aus – '
|
||||
. 'es fehlen die Spalten Wertpapierbezeichnung, Einstiegskurs und Stückzahl.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $spalten;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $felder
|
||||
* @param array<string,int> $spalten
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function zeile(array $felder, array $spalten): ?array
|
||||
{
|
||||
$wert = static fn(string $feld): string => isset($spalten[$feld])
|
||||
? trim((string)($felder[$spalten[$feld]] ?? '')) : '';
|
||||
|
||||
$name = $wert('name');
|
||||
$isin = strtoupper($wert('isin'));
|
||||
$stueck = $this->zahl($wert('stueck'));
|
||||
|
||||
// Summenzeilen und leere Zeilen am Dateiende fallen hier heraus.
|
||||
if ($name === '' || $stueck <= 0.0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->erstellt === '') {
|
||||
$this->erstellt = $this->datumAusCsv($wert('erstellt'));
|
||||
}
|
||||
if ($this->depotnummer === '') {
|
||||
$this->depotnummer = $wert('depotnummer');
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'isin' => $isin,
|
||||
'wkn' => strtoupper($wert('wkn')),
|
||||
'assetklasse' => $wert('assetklasse'),
|
||||
'art' => self::ASSETKLASSEN[mb_strtolower($wert('assetklasse'))] ?? 'sonstiges',
|
||||
'stueck' => $stueck,
|
||||
'einstiegskurs' => $this->zahl($wert('einstiegskurs')),
|
||||
'bewertungskurs' => $this->zahl($wert('bewertungskurs')),
|
||||
'status' => 'neu',
|
||||
'wertpapier_neu' => false,
|
||||
'meldung' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Beträge kommen als „1.471,88 €“ – mit Tausenderpunkt, Komma und einem
|
||||
* geschützten Leerzeichen vor dem Währungszeichen.
|
||||
*/
|
||||
private function zahl(string $wert): float
|
||||
{
|
||||
$wert = preg_replace('/[^0-9,.\-]/u', '', $wert) ?? '';
|
||||
if ($wert === '') {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$wert = str_replace('.', '', $wert);
|
||||
|
||||
return (float)str_replace(',', '.', $wert);
|
||||
}
|
||||
|
||||
/** „12.08.2026“ → „2026-08-12“. */
|
||||
private function datumAusCsv(string $wert): string
|
||||
{
|
||||
if (preg_match('/(\d{1,2})\.(\d{1,2})\.(\d{4})/', $wert, $treffer) === 1) {
|
||||
return sprintf('%04d-%02d-%02d', (int)$treffer[3], (int)$treffer[2], (int)$treffer[1]);
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}/', $wert, $treffer) === 1) {
|
||||
return $treffer[0];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Die DKB trennt mit Semikolon; andere Banken nehmen Komma oder Tabulator.
|
||||
* Gewählt wird das Zeichen, das in der Kopfzeile am häufigsten vorkommt.
|
||||
*/
|
||||
private function trennzeichen(string $kopfzeile): string
|
||||
{
|
||||
$bestes = ';';
|
||||
$treffer = 0;
|
||||
|
||||
foreach (self::TRENNZEICHEN as $zeichen) {
|
||||
$anzahl = substr_count($kopfzeile, $zeichen);
|
||||
if ($anzahl > $treffer) {
|
||||
$bestes = $zeichen;
|
||||
$treffer = $anzahl;
|
||||
}
|
||||
}
|
||||
|
||||
return $bestes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Banken exportieren mal UTF-8 (gern mit BOM), mal Windows-1252. Ohne
|
||||
* Umwandlung stünden Umlaute später als Fragezeichen in den Stammdaten.
|
||||
*/
|
||||
private function zuUtf8(string $inhalt): string
|
||||
{
|
||||
if (str_starts_with($inhalt, "\xEF\xBB\xBF")) {
|
||||
return substr($inhalt, 3);
|
||||
}
|
||||
|
||||
if (!mb_check_encoding($inhalt, 'UTF-8')) {
|
||||
return mb_convert_encoding($inhalt, 'UTF-8', 'Windows-1252');
|
||||
}
|
||||
|
||||
return $inhalt;
|
||||
}
|
||||
|
||||
private function melde(string $typ, string $text): void
|
||||
{
|
||||
$this->meldungen[] = ['typ' => $typ, 'text' => $text];
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,11 @@ use Finanzmanager\Models\Wertpapier;
|
|||
*
|
||||
* Buchungen nach dem Enddatum sind Vorausberechnungen des FinanzManagers und
|
||||
* werden übersprungen.
|
||||
*
|
||||
* Über die Option `konten` lässt sich der Import auf einzelne Konten begrenzen.
|
||||
* Maßgeblich ist dann nicht allein der `!Account`-Abschnitt, sondern jedes
|
||||
* Konto, das die Buchung berührt – sonst fehlten die Umbuchungen, die auf einem
|
||||
* anderen Konto erfasst wurden und nur über `L[Konto]` hierher zeigen.
|
||||
*/
|
||||
class QifBuchungsImport
|
||||
{
|
||||
|
|
@ -38,6 +43,9 @@ class QifBuchungsImport
|
|||
private string $stichtag;
|
||||
private string $bis;
|
||||
|
||||
/** @var array<string,true>|null Gewählte Kontonamen; null heißt: alle. */
|
||||
private ?array $auswahl = null;
|
||||
|
||||
/** @var array<string,int> Kontoname → id */
|
||||
private array $konten = [];
|
||||
/** @var array<string,int> Kategoriepfad → id */
|
||||
|
|
@ -46,12 +54,14 @@ class QifBuchungsImport
|
|||
private array $klassen = [];
|
||||
/** @var array<string,int> Basis-Hash → wie oft schon gesehen */
|
||||
private array $hashZaehler = [];
|
||||
/** @var array<int,string> Position in der Datei → Fingerabdruck der Geldbuchung */
|
||||
private array $hashes = [];
|
||||
|
||||
/** @var list<array{typ: string, text: string}> */
|
||||
private array $meldungen = [];
|
||||
|
||||
/**
|
||||
* @param array{stichtag?: string, bis?: string} $optionen
|
||||
* @param array{stichtag?: string, bis?: string, konten?: list<string>} $optionen
|
||||
*/
|
||||
public function __construct(QifParser $parser, array $optionen = [])
|
||||
{
|
||||
|
|
@ -62,6 +72,44 @@ class QifBuchungsImport
|
|||
if (!Buchung::istDatum($this->stichtag) || !Buchung::istDatum($this->bis)) {
|
||||
throw new \InvalidArgumentException('Stichtag und Enddatum müssen als JJJJ-MM-TT vorliegen.');
|
||||
}
|
||||
|
||||
$auswahl = [];
|
||||
foreach ($optionen['konten'] ?? [] as $name) {
|
||||
$name = trim((string)$name);
|
||||
if ($name !== '') {
|
||||
$auswahl[$name] = true;
|
||||
}
|
||||
}
|
||||
$this->auswahl = $auswahl === [] ? null : $auswahl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Welche Konten kommen in der Datei überhaupt vor?
|
||||
*
|
||||
* Grundlage der Kontoauswahl im Dialog: auswählbar ist nur, was der Export
|
||||
* auch hergibt. Gezählt wird je Konto, wie viele Einträge es berühren –
|
||||
* eigene Buchungen ebenso wie Umbuchungen, die von anderswo hierher zeigen.
|
||||
* Ein Konto, das nur Umbuchungsziel ist, führt in der Datei keine eigene
|
||||
* Zeile und stünde sonst nicht zur Wahl.
|
||||
*
|
||||
* @return array<string,int> Kontoname → Zahl der Einträge, die es betreffen
|
||||
*/
|
||||
public static function kontenDerDatei(QifParser $parser): array
|
||||
{
|
||||
$konten = [];
|
||||
|
||||
foreach ($parser->eintraege() as $eintrag) {
|
||||
if (!in_array($eintrag['typ'], self::GELDTYPEN, true) && $eintrag['typ'] !== 'Invst') {
|
||||
continue;
|
||||
}
|
||||
foreach (self::beteiligteKonten($eintrag) as $name) {
|
||||
$konten[$name] = ($konten[$name] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($konten, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
|
||||
return $konten;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -82,6 +130,7 @@ class QifBuchungsImport
|
|||
'buchungen' => $buchungen,
|
||||
'stichtag' => $this->stichtag,
|
||||
'bis' => $this->bis,
|
||||
'konten' => $this->auswahl === null ? null : array_keys($this->auswahl),
|
||||
'trockenlauf' => $trockenlauf,
|
||||
];
|
||||
};
|
||||
|
|
@ -101,6 +150,11 @@ class QifBuchungsImport
|
|||
* Summiert alles vor dem Stichtag je Konto auf – die Gegenseiten der
|
||||
* Umbuchungen eingeschlossen – und schreibt das Ergebnis als Anfangssaldo.
|
||||
*
|
||||
* Bei einer Kontoauswahl wird trotzdem die ganze Datei durchgerechnet: Der
|
||||
* Saldo eines Kontos steckt auch in Buchungen, die auf einem anderen Konto
|
||||
* erfasst wurden. Nur geschrieben werden die gewählten Konten – sonst
|
||||
* überschriebe ein Teilimport die Salden aller übrigen.
|
||||
*
|
||||
* @return array{konten: int, summe: float, salden: array<string,float>}
|
||||
*/
|
||||
private function anfangssalden(bool $trockenlauf): array
|
||||
|
|
@ -142,8 +196,12 @@ class QifBuchungsImport
|
|||
}
|
||||
}
|
||||
|
||||
$gesetzt = 0;
|
||||
$gesetzt = 0;
|
||||
$geschrieben = [];
|
||||
foreach ($salden as $name => $betrag) {
|
||||
if (!$this->gewaehlt($name)) {
|
||||
continue;
|
||||
}
|
||||
$kontoId = $this->kontoId($name);
|
||||
if ($kontoId === null) {
|
||||
$this->melde('fehler', 'Anfangssaldo für unbekanntes Konto „' . $name . '“ verworfen.');
|
||||
|
|
@ -155,13 +213,14 @@ class QifBuchungsImport
|
|||
[round($betrag, 2), $kontoId]
|
||||
);
|
||||
}
|
||||
$geschrieben[$name] = $betrag;
|
||||
$gesetzt++;
|
||||
}
|
||||
|
||||
return [
|
||||
'konten' => $gesetzt,
|
||||
'summe' => round(array_sum($salden), 2),
|
||||
'salden' => $salden,
|
||||
'summe' => round(array_sum($geschrieben), 2),
|
||||
'salden' => $geschrieben,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -172,10 +231,12 @@ class QifBuchungsImport
|
|||
{
|
||||
$zaehler = [
|
||||
'neu' => 0, 'vorhanden' => 0, 'vor_stichtag' => 0,
|
||||
'zukunft' => 0, 'wertpapier' => 0, 'fehler' => 0,
|
||||
'zukunft' => 0, 'wertpapier' => 0, 'nicht_gewaehlt' => 0, 'fehler' => 0,
|
||||
];
|
||||
|
||||
foreach ($this->parser->eintraege() as $eintrag) {
|
||||
$this->hashes = $this->hashes();
|
||||
|
||||
foreach ($this->parser->eintraege() as $nr => $eintrag) {
|
||||
$istGeld = $this->istGeldbuchung($eintrag);
|
||||
if (!$istGeld && $eintrag['typ'] !== 'Invst') {
|
||||
continue;
|
||||
|
|
@ -195,10 +256,14 @@ class QifBuchungsImport
|
|||
$zaehler['zukunft']++;
|
||||
continue;
|
||||
}
|
||||
if (!$this->gehoertZurAuswahl($eintrag)) {
|
||||
$zaehler['nicht_gewaehlt']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$angelegt = $istGeld
|
||||
? $this->geldbuchung($eintrag, $datum, $trockenlauf)
|
||||
? $this->geldbuchung($eintrag, $nr, $datum, $trockenlauf)
|
||||
: $this->wertpapierbuchung($eintrag, $datum, $trockenlauf);
|
||||
|
||||
if ($angelegt === null) {
|
||||
|
|
@ -227,9 +292,10 @@ class QifBuchungsImport
|
|||
|
||||
/**
|
||||
* @param array{typ: string, konto: ?string, zeilen: list<array{0: string, 1: string}>} $eintrag
|
||||
* @param int $nr Position in der Datei – dort liegt der vorab vergebene Hash.
|
||||
* @return int|null Kennung der neuen Buchung, oder null wenn sie schon da war.
|
||||
*/
|
||||
private function geldbuchung(array $eintrag, string $datum, bool $trockenlauf): ?int
|
||||
private function geldbuchung(array $eintrag, int $nr, string $datum, bool $trockenlauf): ?int
|
||||
{
|
||||
$kontoName = (string)$eintrag['konto'];
|
||||
$kontoId = $this->kontoId($kontoName);
|
||||
|
|
@ -241,7 +307,7 @@ class QifBuchungsImport
|
|||
$text = (string)QifParser::feld($eintrag, 'M');
|
||||
$nummer = (string)QifParser::feld($eintrag, 'N');
|
||||
|
||||
$hash = $this->eindeutigerHash($kontoId, $datum, $betrag, $nummer, $text);
|
||||
$hash = $this->hashes[$nr] ?? $this->eindeutigerHash($kontoId, $datum, $betrag, $nummer, $text);
|
||||
if (Buchung::findeNachHash($hash) !== null) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -568,6 +634,113 @@ class QifBuchungsImport
|
|||
return self::umbuchungsziel($strich === false ? $angabe : substr($angabe, $strich));
|
||||
}
|
||||
|
||||
/**
|
||||
* Vergibt die Fingerabdrücke aller Geldbuchungen im Zeitraum vorab.
|
||||
*
|
||||
* Das geschieht in einem eigenen Durchgang über die ganze Datei, weil der
|
||||
* laufende Zähler für gleiche Fingerabdrücke sonst von der Kontoauswahl
|
||||
* abhinge: Dieselbe Buchung bekäme im Teilimport einen anderen Hash als im
|
||||
* Vollimport und würde ein zweites Mal angelegt.
|
||||
*
|
||||
* @return array<int,string> Position in der Datei → Hash
|
||||
*/
|
||||
private function hashes(): array
|
||||
{
|
||||
$this->hashZaehler = [];
|
||||
$hashes = [];
|
||||
|
||||
foreach ($this->parser->eintraege() as $nr => $eintrag) {
|
||||
if (!$this->istGeldbuchung($eintrag)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$datum = QifParser::datum(QifParser::feld($eintrag, 'D'), $this->parser->datumsformat());
|
||||
if ($datum === null || $datum < $this->stichtag || $datum > $this->bis) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$kontoId = $this->kontoId((string)$eintrag['konto']);
|
||||
if ($kontoId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hashes[$nr] = $this->eindeutigerHash(
|
||||
$kontoId,
|
||||
$datum,
|
||||
QifParser::betrag(QifParser::feld($eintrag, 'U') ?? QifParser::feld($eintrag, 'T')),
|
||||
(string)QifParser::feld($eintrag, 'N'),
|
||||
(string)QifParser::feld($eintrag, 'M')
|
||||
);
|
||||
}
|
||||
|
||||
return $hashes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Berührt der Eintrag eines der gewählten Konten?
|
||||
*
|
||||
* Ohne Auswahl gilt alles. Sonst zählt nicht nur der `!Account`-Abschnitt,
|
||||
* in dem der Eintrag steht: Eine Umbuchung steht nur einmal in der Datei,
|
||||
* und wäre allein das erfassende Konto maßgeblich, fehlte dem gewählten
|
||||
* Konto die Gegenseite.
|
||||
*/
|
||||
private function gehoertZurAuswahl(array $eintrag): bool
|
||||
{
|
||||
if ($this->auswahl === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (self::beteiligteKonten($eintrag) as $name) {
|
||||
if (isset($this->auswahl[$name])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function gewaehlt(string $name): bool
|
||||
{
|
||||
return $this->auswahl === null || isset($this->auswahl[trim($name)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Konten, auf denen ein Eintrag zu Buche schlägt: das Konto des
|
||||
* Abschnitts sowie die Umbuchungsziele aus `L` und den Splitzeilen. Beim
|
||||
* Wertpapiergeschäft sind es Depot und Verrechnungskonto – Depot- und
|
||||
* Geldbuchung entstehen immer gemeinsam.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function beteiligteKonten(array $eintrag): array
|
||||
{
|
||||
$namen = [];
|
||||
if ($eintrag['konto'] !== null) {
|
||||
$namen[] = trim((string)$eintrag['konto']);
|
||||
}
|
||||
|
||||
if ($eintrag['typ'] === 'Invst') {
|
||||
$verrechnung = self::verrechnungskonto(QifParser::feld($eintrag, 'L'));
|
||||
if ($verrechnung !== null) {
|
||||
$namen[] = trim($verrechnung);
|
||||
}
|
||||
|
||||
return array_values(array_unique($namen));
|
||||
}
|
||||
|
||||
$angaben = QifParser::felder($eintrag, 'S');
|
||||
$angaben[] = (string)QifParser::feld($eintrag, 'L');
|
||||
|
||||
foreach ($angaben as $angabe) {
|
||||
$ziel = self::umbuchungsziel($angabe);
|
||||
if ($ziel !== null) {
|
||||
$namen[] = trim($ziel);
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($namen));
|
||||
}
|
||||
|
||||
/**
|
||||
* Identische Buchungen am selben Tag kommen vor (zweimal dasselbe im selben
|
||||
* Laden). Der laufende Zähler hält sie auseinander und bleibt über Läufe
|
||||
|
|
|
|||
|
|
@ -14,14 +14,23 @@ $neu = $buchung === null;
|
|||
$ziel = $neu ? '/konten/' . (int)$konto['id'] . '/buchung' : '/buchungen/' . (int)$buchung['id'];
|
||||
$betrag = $neu ? 0.0 : (float)$buchung['betrag'];
|
||||
|
||||
$art = 'ausgabe';
|
||||
if (!$neu) {
|
||||
if (!empty($buchung['gegenkonto_id'])) {
|
||||
$art = $betrag < 0 ? 'umbuchung_ab' : 'umbuchung_zu';
|
||||
} else {
|
||||
$art = $betrag >= 0 ? 'einnahme' : 'ausgabe';
|
||||
// Die Art bestimmt nur noch die Richtung. Ob die Buchung eine Umbuchung ist,
|
||||
// steht im Feld Kategorie / Gegenkonto.
|
||||
$art = !$neu && $betrag >= 0 ? 'einnahme' : 'ausgabe';
|
||||
|
||||
/** Vorauswahl der gemeinsamen Liste Kategorie / Gegenkonto. */
|
||||
$zuordnungswert = static function (?array $zeile): string {
|
||||
if (!empty($zeile['gegenkonto_id'])) {
|
||||
return 'konto:' . (int)$zeile['gegenkonto_id'];
|
||||
}
|
||||
}
|
||||
if (!empty($zeile['kategorie_id'])) {
|
||||
return 'kategorie:' . (int)$zeile['kategorie_id'];
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
$eigenesKonto = (int)$konto['id'];
|
||||
$mitSplit = false;
|
||||
|
||||
// Immer ein paar leere Splitzeilen anbieten; weitere fügt der Knopf hinzu.
|
||||
$splitZeilen = $splits;
|
||||
|
|
@ -44,10 +53,8 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<label for="art">Art</label>
|
||||
<select id="art" name="art">
|
||||
<?php foreach ([
|
||||
'ausgabe' => 'Ausgabe',
|
||||
'einnahme' => 'Einnahme',
|
||||
'umbuchung_ab' => 'Umbuchung auf ein anderes Konto',
|
||||
'umbuchung_zu' => 'Umbuchung von einem anderen Konto',
|
||||
'ausgabe' => 'Ausgabe',
|
||||
'einnahme' => 'Einnahme',
|
||||
] as $schluessel => $beschriftung): ?>
|
||||
<option value="<?= View::h($schluessel) ?>" <?= $art === $schluessel ? 'selected' : '' ?>>
|
||||
<?= View::h($beschriftung) ?>
|
||||
|
|
@ -68,36 +75,16 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<input type="text" id="empfaenger" name="empfaenger"
|
||||
value="<?= View::h((string)($buchung['empfaenger'] ?? '')) ?>">
|
||||
|
||||
<div class="nur-kategorie">
|
||||
<label for="kategorie_id">Kategorie</label>
|
||||
<select id="kategorie_id" name="kategorie_id">
|
||||
<option value="">— keine —</option>
|
||||
<?php foreach ($kategorien as $k): ?>
|
||||
<option value="<?= (int)$k['id'] ?>"
|
||||
<?= (int)($buchung['kategorie_id'] ?? 0) === (int)$k['id'] ? 'selected' : '' ?>>
|
||||
<?= View::h($k['pfad']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="nur-umbuchung">
|
||||
<label for="gegenkonto_id">Gegenkonto</label>
|
||||
<select id="gegenkonto_id" name="gegenkonto_id">
|
||||
<option value="">— bitte wählen —</option>
|
||||
<?php foreach ($konten as $k): ?>
|
||||
<?php if ((int)$k['id'] === (int)$konto['id']) { continue; } ?>
|
||||
<option value="<?= (int)$k['id'] ?>"
|
||||
<?= (int)($buchung['gegenkonto_id'] ?? 0) === (int)$k['id'] ? 'selected' : '' ?>>
|
||||
<?= View::h($k['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="feldhinweis">
|
||||
Die Umbuchung wird einmal gespeichert und erscheint auf beiden Kontoblättern –
|
||||
auf dem Gegenkonto mit umgekehrtem Vorzeichen.
|
||||
</p>
|
||||
</div>
|
||||
<label for="zuordnung">Kategorie / Gegenkonto</label>
|
||||
<select id="zuordnung" name="zuordnung">
|
||||
<?php $gewaehlt = $zuordnungswert($buchung); ?>
|
||||
<?php include __DIR__ . '/zuordnungsliste.php'; ?>
|
||||
</select>
|
||||
<p class="feldhinweis">
|
||||
Unter den Kategorien stehen die eigenen Konten in eckigen Klammern. Eines davon
|
||||
macht die Buchung zur Umbuchung: Sie wird einmal gespeichert und erscheint auf
|
||||
beiden Kontoblättern – auf dem Gegenkonto mit umgekehrtem Vorzeichen.
|
||||
</p>
|
||||
|
||||
<label for="klasse_id">Klasse</label>
|
||||
<select id="klasse_id" name="klasse_id">
|
||||
|
|
@ -132,20 +119,17 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
|
||||
<table class="liste splittabelle">
|
||||
<thead>
|
||||
<tr><th>Kategorie</th><th>Klasse</th><th>Notiz</th><th class="rechts">Betrag</th></tr>
|
||||
<tr>
|
||||
<th>Kategorie / Gegenkonto</th><th>Klasse</th><th>Notiz</th><th class="rechts">Betrag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="splitzeilen">
|
||||
<?php foreach ($splitZeilen as $nr => $zeile): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<select name="split[<?= $nr ?>][kategorie_id]">
|
||||
<option value="">— keine —</option>
|
||||
<?php foreach ($kategorien as $k): ?>
|
||||
<option value="<?= (int)$k['id'] ?>"
|
||||
<?= (int)($zeile['kategorie_id'] ?? 0) === (int)$k['id'] ? 'selected' : '' ?>>
|
||||
<?= View::h($k['pfad']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
<select name="split[<?= $nr ?>][zuordnung]">
|
||||
<?php $gewaehlt = $zuordnungswert($zeile); ?>
|
||||
<?php include __DIR__ . '/zuordnungsliste.php'; ?>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
|
|
@ -193,6 +177,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<script src="/static/betrag.js" defer></script>
|
||||
<script src="/static/buchungsmaske.js" defer></script>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ use Finanzmanager\View;
|
|||
/** @var array<string,mixed> $filter */
|
||||
/** @var list<array<string,mixed>> $kategorien */
|
||||
/** @var list<array<string,mixed>> $klassen */
|
||||
/** @var array<int,string> $kontenNamen */
|
||||
/** @var list<array<string,mixed>> $konten */
|
||||
/** @var array<int, list<array<string,mixed>>> $splits */
|
||||
|
||||
$kategorieNamen = [];
|
||||
foreach ($kategorien as $k) {
|
||||
|
|
@ -20,6 +21,9 @@ $klassenNamen = [];
|
|||
foreach ($klassen as $k) {
|
||||
$klassenNamen[(int)$k['id']] = $k['name'];
|
||||
}
|
||||
$kontenNamen = array_column($konten, 'name', 'id');
|
||||
|
||||
$eigenesKonto = (int)$konto['id'];
|
||||
|
||||
$gefiltert = ($filter['suche'] ?? '') !== '' || !empty($filter['von']) || !empty($filter['bis'])
|
||||
|| !empty($filter['kategorie_id']) || !empty($filter['klasse_id']) || !empty($filter['nur_offene']);
|
||||
|
|
@ -39,8 +43,44 @@ $seitenPfad = static function (int $nr) use ($filter): string {
|
|||
};
|
||||
|
||||
// Nach dem Löschen soll dieselbe Ansicht wieder erscheinen – Filter und Seite
|
||||
// hängen deshalb am Ziel des Löschformulars.
|
||||
$loeschZiel = '/konten/' . (int)$konto['id'] . '/buchungen/loeschen' . $seitenPfad($seite);
|
||||
// hängen deshalb am Ziel des Löschformulars. Dasselbe gilt für die
|
||||
// Schnellzuordnung: Ihre Ziele setzt kontoblatt.js aus dieser Abfrage zusammen.
|
||||
$abfrage = $seitenPfad($seite);
|
||||
$loeschZiel = '/konten/' . (int)$konto['id'] . '/buchungen/loeschen' . $abfrage;
|
||||
|
||||
/**
|
||||
* Die Zuordnung einer Zeile als Wert der gemeinsamen Auswahlliste – dieselben
|
||||
* Präfixe wie in der Buchungsmaske.
|
||||
*/
|
||||
$zuordnungswert = static function (array $zeile): string {
|
||||
if ((int)($zeile['gesplittet'] ?? 0) === 1) {
|
||||
return 'split';
|
||||
}
|
||||
if (!empty($zeile['gegenkonto_id'])) {
|
||||
return 'konto:' . (int)$zeile['gegenkonto_id'];
|
||||
}
|
||||
if (!empty($zeile['kategorie_id'])) {
|
||||
return 'kategorie:' . (int)$zeile['kategorie_id'];
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Die Splitzeilen einer Buchung, wie der Dialog sie braucht. Sie hängen als
|
||||
* JSON an der Zelle statt nachgeladen zu werden: Es sind die Zeilen der wenigen
|
||||
* aufgeteilten Buchungen dieser Seite, und ein zweiter Weg zu denselben Daten
|
||||
* wäre auch ein zweiter Weg, sie falsch zu lesen.
|
||||
*
|
||||
* @param list<array<string,mixed>> $splitzeilen
|
||||
*/
|
||||
$splitdaten = static function (array $splitzeilen) use ($zuordnungswert): string {
|
||||
return (string)json_encode(array_map(static fn(array $zeile): array => [
|
||||
'zuordnung' => $zuordnungswert($zeile),
|
||||
'klasse_id' => (int)($zeile['klasse_id'] ?? 0),
|
||||
'notiz' => (string)$zeile['notiz'],
|
||||
'betrag' => View::betrag($zeile['betrag'], false),
|
||||
], $splitzeilen), JSON_UNESCAPED_UNICODE);
|
||||
};
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
|
@ -124,7 +164,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<?= $gefiltert ? 'Keine Buchung passt zum Filter.' : 'Für dieses Konto ist noch nichts gebucht.' ?>
|
||||
</p>
|
||||
<?php else: ?>
|
||||
<table class="liste kontoblatt">
|
||||
<table class="liste kontoblatt" data-abfrage="<?= View::h($abfrage) ?>">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="wahlspalte">
|
||||
|
|
@ -165,13 +205,46 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="nebentext gekuerzt"><?= View::h(mb_substr((string)$zeile['buchungstext'], 0, 90)) ?></td>
|
||||
<td>
|
||||
<?php if ((int)$zeile['gesplittet'] === 1): ?>
|
||||
<span class="marke">Splitbuchung</span>
|
||||
<?php elseif (!empty($zeile['gegenkonto_id']) && !$zeile['gegensicht']): ?>
|
||||
[<?= View::h($kontenNamen[(int)$zeile['gegenkonto_id']] ?? '?') ?>]
|
||||
<?php
|
||||
// Die Gegenseite einer fremden Umbuchung nennt das Konto, von dem
|
||||
// sie kommt – und zwar dort, wo sonst die Kategorie steht. Ohne
|
||||
// diese Angabe stünde auf dem Gegenkonto eine Umbuchung ohne
|
||||
// erkennbares Gegenüber.
|
||||
$gegenkonto = empty($zeile['gegenkonto_id'])
|
||||
? ''
|
||||
: '[' . ($kontenNamen[(int)$zeile['gegenkonto_id']] ?? '?') . ']';
|
||||
$beschriftung = match (true) {
|
||||
(int)$zeile['gesplittet'] === 1 => 'Splitbuchung',
|
||||
$gegenkonto !== '' => $gegenkonto,
|
||||
default => (string)($kategorieNamen[(int)$zeile['kategorie_id']] ?? ''),
|
||||
};
|
||||
?>
|
||||
<td class="zuordnungszelle">
|
||||
<?php if ($zeile['gegensicht']): ?>
|
||||
<?php /* Auch die Gegenseite führt zu ihrer Buchung – sie liegt
|
||||
auf dem anderen Konto und wird nur dort geändert. */ ?>
|
||||
<a href="/buchungen/<?= (int)$zeile['id'] ?>" class="zuordnungsfeld gegenseite"
|
||||
title="Umbuchung von <?= View::h($kontenNamen[(int)$zeile['gegenkonto_id']] ?? '?') ?> – dort zu ändern">
|
||||
<?= View::h($beschriftung) ?>
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<?= View::h($kategorieNamen[(int)$zeile['kategorie_id']] ?? '') ?>
|
||||
<?php /* Ohne JavaScript führt der Klick in die Buchungsmaske;
|
||||
kontoblatt.js macht daraus die Auswahl an Ort und Stelle. */ ?>
|
||||
<a href="/buchungen/<?= (int)$zeile['id'] ?>" class="zuordnungsfeld"
|
||||
data-buchung="<?= (int)$zeile['id'] ?>"
|
||||
data-wert="<?= View::h($zuordnungswert($zeile)) ?>"
|
||||
data-betrag="<?= View::h(number_format((float)$zeile['betrag'], 2, '.', '')) ?>"
|
||||
data-bezeichnung="<?= View::h(View::datum($zeile['datum']) . ' · ' . ($zeile['empfaenger'] ?: '(ohne Empfänger)')) ?>"
|
||||
<?php if (isset($splits[(int)$zeile['id']])): ?>
|
||||
data-splits="<?= View::h($splitdaten($splits[(int)$zeile['id']])) ?>"
|
||||
<?php endif; ?>
|
||||
title="<?= View::h($beschriftung !== '' ? $beschriftung : 'Kategorie oder Gegenkonto zuordnen') ?>">
|
||||
<?php if ($beschriftung === 'Splitbuchung'): ?>
|
||||
<span class="marke">Splitbuchung</span>
|
||||
<?php else: ?>
|
||||
<?= View::h($beschriftung) ?>
|
||||
<?php endif; ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="nebentext"><?= View::h($klassenNamen[(int)$zeile['klasse_id']] ?? '') ?></td>
|
||||
|
|
@ -197,6 +270,90 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php /* Die Auswahlliste gibt es einmal; kontoblatt.js hängt sie in die
|
||||
angeklickte Zelle. Eine Liste je Zeile wären bei hundert Zeilen
|
||||
ebenso viele vollständige Kategoriebäume. */ ?>
|
||||
<form method="post" action="" id="zuordnungsauswahl" class="zuordnungsauswahl" hidden>
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<select name="zuordnung" aria-label="Kategorie oder Gegenkonto">
|
||||
<?php $gewaehlt = ''; $mitSplit = true; ?>
|
||||
<?php include __DIR__ . '/zuordnungsliste.php'; ?>
|
||||
</select>
|
||||
<noscript><button type="submit" class="knopf klein">Übernehmen</button></noscript>
|
||||
</form>
|
||||
|
||||
<dialog id="splitdialog" class="splitdialog">
|
||||
<form method="post" action="" id="splitformular">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<h2>Splitbuchung</h2>
|
||||
<p class="feldhinweis">
|
||||
<span id="splitbezeichnung"></span> über
|
||||
<output id="splitbuchungsbetrag" class="betrag">0,00</output>
|
||||
</p>
|
||||
|
||||
<table class="liste splittabelle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kategorie / Gegenkonto</th><th>Klasse</th><th>Notiz</th>
|
||||
<th class="rechts">Betrag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="splitdialogzeilen">
|
||||
<?php for ($nr = 0; $nr < 4; $nr++): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<select name="split[<?= $nr ?>][zuordnung]">
|
||||
<?php $gewaehlt = ''; $mitSplit = false; ?>
|
||||
<?php include __DIR__ . '/zuordnungsliste.php'; ?>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select name="split[<?= $nr ?>][klasse_id]">
|
||||
<option value="">— keine —</option>
|
||||
<?php foreach ($klassen as $k): ?>
|
||||
<option value="<?= (int)$k['id'] ?>"><?= View::h($k['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="text" name="split[<?= $nr ?>][notiz]" value=""></td>
|
||||
<td>
|
||||
<input type="text" class="splitbetrag rechts" inputmode="decimal"
|
||||
name="split[<?= $nr ?>][betrag]" value="">
|
||||
</td>
|
||||
</tr>
|
||||
<?php endfor; ?>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3" class="rechts">Summe der Zeilen / Restbetrag</td>
|
||||
<td class="rechts"><output id="splitdialogsumme" class="betrag">0,00</output></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<p class="feldhinweis">
|
||||
Die Summe der Zeilen muss dem Buchungsbetrag entsprechen. Bleiben alle
|
||||
Zeilen leer, wird die Splitbuchung aufgehoben.
|
||||
</p>
|
||||
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">Splitbuchung speichern</button>
|
||||
<button type="button" class="knopf" id="splitzeileMehr">Zeile hinzufügen</button>
|
||||
<button type="button" class="knopf" id="splitabbrechen">Abbrechen</button>
|
||||
<?php /* Rechts außen, abseits der übrigen Knöpfe: hebt die Aufteilung
|
||||
samt aller Zuordnungen auf. Nur sichtbar, wenn es etwas
|
||||
aufzuheben gibt – kontoblatt.js blendet ihn ein. */ ?>
|
||||
<button type="submit" class="knopf gefahr" id="splitloeschen"
|
||||
name="aktion" value="loeschen" formnovalidate hidden>
|
||||
Splitbuchung löschen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="/static/betrag.js" defer></script>
|
||||
<script src="/static/kontoblatt.js" defer></script>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
|
|
|
|||
42
src/Views/buchungen/zuordnungsliste.php
Normal file
42
src/Views/buchungen/zuordnungsliste.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\View;
|
||||
|
||||
/**
|
||||
* Die Einträge der Liste „Kategorie oder Gegenkonto“ – ohne das umgebende
|
||||
* <select>, damit dieselbe Liste in der Buchungsmaske, in den Splitzeilen und
|
||||
* in der Schnellzuordnung des Kontoblatts steht.
|
||||
*
|
||||
* Kategorie und Gegenkonto teilen sich ein Feld, weil sie sich ausschließen:
|
||||
* Eine Buchung ist entweder kategorisiert oder eine Umbuchung. Die Werte tragen
|
||||
* deshalb ein Präfix (`kategorie:12`, `konto:7`), das der Controller in
|
||||
* BuchungenController::zuordnungAus() wieder auseinandernimmt.
|
||||
*
|
||||
* @var list<array<string,mixed>> $kategorien Kategoriebaum, flach mit Pfad
|
||||
* @var list<array<string,mixed>> $konten alle Konten des Mandanten
|
||||
* @var int $eigenesKonto Konto der Buchung – kein Gegenkonto seiner selbst
|
||||
* @var string $gewaehlt vorausgewählter Wert, '' für keine Zuordnung
|
||||
* @var bool $mitSplit Eintrag „Splitbuchung“ anbieten (nur im Kontoblatt)
|
||||
*/
|
||||
?>
|
||||
<option value=""<?= ($gewaehlt ?? '') === '' ? ' selected' : '' ?>>— keine —</option>
|
||||
<?php if (!empty($mitSplit)): ?>
|
||||
<option value="split"<?= ($gewaehlt ?? '') === 'split' ? ' selected' : '' ?>>Splitbuchung …</option>
|
||||
<?php endif; ?>
|
||||
<optgroup label="Kategorien">
|
||||
<?php foreach ($kategorien as $eintrag): ?>
|
||||
<?php $wert = 'kategorie:' . (int)$eintrag['id']; ?>
|
||||
<option value="<?= View::h($wert) ?>"<?= ($gewaehlt ?? '') === $wert ? ' selected' : '' ?>>
|
||||
<?= View::h($eintrag['pfad']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</optgroup>
|
||||
<optgroup label="Umbuchung auf ein Konto">
|
||||
<?php foreach ($konten as $eintrag): ?>
|
||||
<?php if ((int)$eintrag['id'] === (int)$eigenesKonto) { continue; } ?>
|
||||
<?php $wert = 'konto:' . (int)$eintrag['id']; ?>
|
||||
<option value="<?= View::h($wert) ?>"<?= ($gewaehlt ?? '') === $wert ? ' selected' : '' ?>>
|
||||
[<?= View::h($eintrag['name']) ?>]
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</optgroup>
|
||||
|
|
@ -10,6 +10,8 @@ use Finanzmanager\View;
|
|||
/** @var string $stichtag */
|
||||
/** @var string $bis */
|
||||
/** @var string $datei */
|
||||
/** @var array<string,int> $dateikonten */
|
||||
/** @var list<string> $auswahl */
|
||||
/** @var string $csrfToken */
|
||||
|
||||
$bereiche = [
|
||||
|
|
@ -102,6 +104,12 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<td class="nebentext">darunter Wertpapiergeschäfte (Depot- und Geldbuchung)</td>
|
||||
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['wertpapier'] ?></td>
|
||||
</tr>
|
||||
<?php if ((int)($buchungen['buchungen']['nicht_gewaehlt'] ?? 0) > 0): ?>
|
||||
<tr>
|
||||
<td class="nebentext">gehört zu keinem der gewählten Konten</td>
|
||||
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['nicht_gewaehlt'] ?></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<tr>
|
||||
<td>Fehler</td>
|
||||
<td class="rechts <?= (int)$buchungen['buchungen']['fehler'] > 0 ? 'betrag negativ' : 'nebentext' ?>">
|
||||
|
|
@ -113,6 +121,12 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<p class="hinweis">
|
||||
Zeitraum <?= View::datum($buchungen['stichtag']) ?> bis <?= View::datum($buchungen['bis']) ?>.
|
||||
Alles davor ist nicht verloren: es steckt im Anfangssaldo der Konten.
|
||||
<?php if (($buchungen['konten'] ?? null) !== null): ?>
|
||||
Übernommen wurden nur die Buchungen von
|
||||
<strong><?= View::h(implode(', ', $buchungen['konten'])) ?></strong> – einschließlich
|
||||
der Umbuchungen, die auf einem anderen Konto erfasst wurden und hierher zeigen.
|
||||
Die Anfangssalden der übrigen Konten bleiben unangetastet.
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
|
|
@ -168,14 +182,41 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<?php endif; ?>
|
||||
|
||||
<?php if ($ergebnis['trockenlauf']): ?>
|
||||
<form method="post" action="/verwaltung/import/ausfuehren" class="inline">
|
||||
<form method="post" action="/verwaltung/import/ausfuehren" class="formular">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<input type="hidden" name="datei" value="<?= View::h($datei) ?>">
|
||||
<input type="hidden" name="modus" value="import">
|
||||
<input type="hidden" name="stichtag" value="<?= View::h($stichtag) ?>">
|
||||
<input type="hidden" name="bis" value="<?= View::h($bis) ?>">
|
||||
|
||||
<?php if ($dateikonten !== []): ?>
|
||||
<h3>Konten</h3>
|
||||
<p class="feldhinweis">
|
||||
Zur Wahl stehen die Konten, die in dieser Datei vorkommen. Ohne Häkchen
|
||||
zählt die ganze Datei. Eine Umbuchung kommt mit, sobald eines der beiden
|
||||
Konten gewählt ist – sie steht im Export nur einmal, beim erfassenden
|
||||
Konto. Die Zahl nennt die Einträge, die das Konto berühren.
|
||||
</p>
|
||||
<div class="auswahlliste">
|
||||
<?php foreach ($dateikonten as $name => $anzahl): ?>
|
||||
<label class="ankreuzfeld">
|
||||
<input type="checkbox" name="konten[]" value="<?= View::h((string)$name) ?>"
|
||||
<?= $auswahl === [] || in_array((string)$name, $auswahl, true) ? 'checked' : '' ?>>
|
||||
<span>
|
||||
<?= View::h((string)$name) ?>
|
||||
<span class="nebentext"><?= (int)$anzahl ?></span>
|
||||
</span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">Jetzt übernehmen</button>
|
||||
<button type="submit" name="modus" value="import" class="knopf primaer">Jetzt übernehmen</button>
|
||||
<?php if ($dateikonten !== []): ?>
|
||||
<button type="submit" name="modus" value="vorschau" class="knopf">
|
||||
Trockenlauf mit dieser Auswahl
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
|
|
@ -219,6 +260,12 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
Der Stichtag steht in den <a href="/einstellungen?bereich=import">Einstellungen</a>.
|
||||
</p>
|
||||
|
||||
<p class="hinweis">
|
||||
Soll nur ein Teil der Konten übernommen werden, zuerst den <strong>Trockenlauf</strong>
|
||||
starten: danach steht fest, welche Konten die Datei führt, und sie stehen einzeln zur
|
||||
Wahl. <strong>Importieren</strong> übernimmt die ganze Datei.
|
||||
</p>
|
||||
|
||||
<?php if ($dateien === []): ?>
|
||||
<p class="platzhalter">
|
||||
Noch keine Datei in <code><?= View::h($verzeichnis) ?></code>.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,18 @@ use Finanzmanager\View;
|
|||
* @var string $csrfToken
|
||||
*/
|
||||
|
||||
// Nach dem Löschen soll dieselbe Ansicht wieder erscheinen – der Filter hängt
|
||||
// deshalb am Ziel der Löschformulare.
|
||||
$filterPfad = static function () use ($filter): string {
|
||||
$query = array_filter([
|
||||
'depot' => (string)($filter['konto_id'] ?? ''),
|
||||
'wertpapier' => (string)($filter['wertpapier_id'] ?? ''),
|
||||
'vorgang' => (string)($filter['vorgang'] ?? ''),
|
||||
], static fn(string $wert): bool => $wert !== '');
|
||||
|
||||
return $query === [] ? '' : '?' . http_build_query($query);
|
||||
};
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
||||
|
|
@ -53,6 +65,20 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</select>
|
||||
<button type="submit" class="knopf klein">Filtern</button>
|
||||
</form>
|
||||
<?php if ($buchungen !== []): ?>
|
||||
<!-- Wie im Kontoblatt: Das Formular steht hier, seine Ankreuzfelder
|
||||
stehen in der Tabelle; verbunden sind sie über das form-Attribut.
|
||||
Ein Formular um die ganze Tabelle würde das Filterformular
|
||||
verschachteln. -->
|
||||
<form method="post" action="/wertpapiere/buchungen/loeschen<?= View::h($filterPfad()) ?>"
|
||||
id="depotbuchungsauswahl" class="inline"
|
||||
data-bestaetigen="Die ausgewählten Depotbuchungen wirklich löschen? Die zugehörigen Geldbuchungen werden mitgelöscht."
|
||||
data-frage-eine="Die ausgewählte Depotbuchung wirklich löschen? Die zugehörige Geldbuchung wird mitgelöscht."
|
||||
data-frage-mehrere="{anzahl} ausgewählte Depotbuchungen wirklich löschen? Die zugehörigen Geldbuchungen werden mitgelöscht.">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<button type="submit" class="knopf gefahr">Auswahl löschen</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<a href="/wertpapiere/buchungen/neu" class="knopf primaer">Depotbuchung erfassen</a>
|
||||
</div>
|
||||
|
||||
|
|
@ -62,6 +88,10 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<table class="liste zahlen">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="wahlspalte">
|
||||
<input type="checkbox" class="alle-waehlen" data-waehlt="depotbuchungsauswahl"
|
||||
aria-label="Alle angezeigten Depotbuchungen auswählen">
|
||||
</th>
|
||||
<th>Datum</th><th>Vorgang</th><th>Wertpapier</th><th>Depot</th>
|
||||
<th class="rechts">Stück</th><th class="rechts">Kurs</th>
|
||||
<th class="rechts">Betrag</th><th class="rechts">Kosten</th>
|
||||
|
|
@ -71,6 +101,11 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<tbody>
|
||||
<?php foreach ($buchungen as $buchung): ?>
|
||||
<tr>
|
||||
<td class="wahlspalte">
|
||||
<input type="checkbox" name="buchungen[]" value="<?= (int)$buchung['id'] ?>"
|
||||
form="depotbuchungsauswahl"
|
||||
aria-label="Depotbuchung vom <?= View::h(View::datum($buchung['datum'])) ?> auswählen">
|
||||
</td>
|
||||
<td><?= View::h(View::datum($buchung['datum'])) ?></td>
|
||||
<td>
|
||||
<?= View::h(Depotbuchung::VORGAENGE[$buchung['vorgang']] ?? $buchung['vorgang']) ?>
|
||||
|
|
@ -108,7 +143,8 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="rechts">
|
||||
<form method="post" action="/wertpapiere/buchungen/<?= (int)$buchung['id'] ?>/loeschen"
|
||||
<form method="post"
|
||||
action="/wertpapiere/buchungen/<?= (int)$buchung['id'] ?>/loeschen<?= View::h($filterPfad()) ?>"
|
||||
class="inline"
|
||||
data-bestaetigen="Depotbuchung löschen? Die zugehörige Geldbuchung wird mitgelöscht.">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
|
|
|
|||
221
src/Views/wertpapiere/import.php
Normal file
221
src/Views/wertpapiere/import.php
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\View;
|
||||
|
||||
/**
|
||||
* Import einer Depotbestandsliste (CSV der Bank): erst Vorschau, dann Übernahme.
|
||||
*
|
||||
* @var list<array<string,mixed>> $depots
|
||||
* @var list<array<string,mixed>> $geldkonten
|
||||
* @var array<string,mixed>|null $ergebnis
|
||||
* @var string $datei
|
||||
* @var int $depot
|
||||
* @var int $verrechnungskonto
|
||||
* @var string $datum
|
||||
* @var string $csrfToken
|
||||
*/
|
||||
|
||||
$stueckAnzeige = static fn(float $wert): string
|
||||
=> rtrim(rtrim(number_format($wert, 6, ',', '.'), '0'), ',');
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
||||
<div class="tableiste">
|
||||
<a href="/wertpapiere" class="tab">Bestände</a>
|
||||
<a href="/wertpapiere/buchungen" class="tab">Depotbuchungen</a>
|
||||
<a href="/wertpapiere/stammdaten" class="tab">Wertpapiere</a>
|
||||
<a href="/wertpapiere/auswertung?art=veraeusserungen" class="tab">Veräußerungen</a>
|
||||
<a href="/wertpapiere/auswertung?art=ertraege" class="tab">Erträge</a>
|
||||
</div>
|
||||
|
||||
<div class="seitenaktionen">
|
||||
<a href="/wertpapiere" class="knopf">Zurück zu den Beständen</a>
|
||||
</div>
|
||||
|
||||
<?php if ($ergebnis !== null): ?>
|
||||
<div class="kachel">
|
||||
<header>
|
||||
<h2>Vorschau</h2>
|
||||
<span class="phasenmarke"><?= View::h($datei) ?></span>
|
||||
</header>
|
||||
|
||||
<p class="hinweis">
|
||||
Nichts wurde gespeichert. Übernommen würde je Position ein <em>Kauf</em> zum
|
||||
<strong><?= View::datum($ergebnis['datum']) ?></strong> über die volle Stückzahl
|
||||
zum Einstiegskurs; der Bewertungskurs wird als Kurs dieses Tages hinterlegt.
|
||||
<?php if ($ergebnis['depotnummer'] !== ''): ?>
|
||||
Die Datei nennt die Depotnummer
|
||||
<code><?= View::h($ergebnis['depotnummer']) ?></code>.
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
|
||||
<table class="liste zahlen">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Wertpapier</th>
|
||||
<th>Art</th>
|
||||
<th class="rechts">Stück</th>
|
||||
<th class="rechts">Einstiegskurs</th>
|
||||
<th class="rechts">Bewertungskurs</th>
|
||||
<th class="rechts">Einstand</th>
|
||||
<th class="rechts">Wert</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($ergebnis['positionen'] as $position): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<?= View::h($position['name']) ?>
|
||||
<span class="nebentext">
|
||||
<?= View::h(trim($position['isin'] . ' · ' . $position['wkn'], ' ·')) ?>
|
||||
</span>
|
||||
</td>
|
||||
<td><?= View::h($position['assetklasse']) ?></td>
|
||||
<td class="rechts"><?= View::h($stueckAnzeige((float)$position['stueck'])) ?></td>
|
||||
<td class="rechts"><?= View::h(number_format((float)$position['einstiegskurs'], 2, ',', '.')) ?></td>
|
||||
<td class="rechts">
|
||||
<?= (float)$position['bewertungskurs'] > 0.0
|
||||
? View::h(number_format((float)$position['bewertungskurs'], 2, ',', '.'))
|
||||
: '–' ?>
|
||||
</td>
|
||||
<td class="rechts"><?= View::betrag($position['einstand']) ?></td>
|
||||
<td class="rechts"><?= $position['wert'] === null ? '–' : View::betrag($position['wert']) ?></td>
|
||||
<td>
|
||||
<?php if ($position['status'] === 'vorhanden'): ?>
|
||||
<span class="nebentext">schon importiert</span>
|
||||
<?php elseif ($position['status'] === 'fehler'): ?>
|
||||
<span class="betrag negativ"><?= View::h($position['meldung']) ?></span>
|
||||
<?php elseif ($position['wertpapier_neu']): ?>
|
||||
neu – Wertpapier wird angelegt
|
||||
<?php else: ?>
|
||||
neu
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="summenzeile">
|
||||
<td colspan="5">
|
||||
<?= (int)$ergebnis['zaehler']['neu'] ?> neu ·
|
||||
<?= (int)$ergebnis['zaehler']['vorhanden'] ?> schon vorhanden ·
|
||||
<?= (int)$ergebnis['zaehler']['wertpapiereNeu'] ?> neue Wertpapiere ·
|
||||
<?= (int)$ergebnis['zaehler']['kurse'] ?> Kurse
|
||||
</td>
|
||||
<td class="rechts"><?= View::betrag($ergebnis['einstand']) ?></td>
|
||||
<td class="rechts"><?= View::betrag($ergebnis['wert']) ?></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<?php if (!empty($ergebnis['meldungen'])): ?>
|
||||
<h3>Protokoll</h3>
|
||||
<ul class="protokoll">
|
||||
<?php foreach ($ergebnis['meldungen'] as $meldung): ?>
|
||||
<li class="<?= View::h($meldung['typ']) ?>"><?= View::h($meldung['text']) ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="/wertpapiere/import" class="formular">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<input type="hidden" name="datei" value="<?= View::h($datei) ?>">
|
||||
|
||||
<label for="konto_id">Depot</label>
|
||||
<select id="konto_id" name="konto_id" required>
|
||||
<?php foreach ($depots as $eintrag): ?>
|
||||
<option value="<?= (int)$eintrag['id'] ?>" <?= $depot === (int)$eintrag['id'] ? 'selected' : '' ?>>
|
||||
<?= View::h($eintrag['name']) ?><?= trim((string)$eintrag['kontonummer']) !== ''
|
||||
? ' (' . View::h($eintrag['kontonummer']) . ')' : '' ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
|
||||
<label for="datum">Buchungsdatum</label>
|
||||
<input type="date" id="datum" name="datum" value="<?= View::h($datum) ?>" required>
|
||||
|
||||
<label for="verrechnungskonto_id">Verrechnungskonto <span class="optional">(optional)</span></label>
|
||||
<select id="verrechnungskonto_id" name="verrechnungskonto_id">
|
||||
<option value="">– keines, nur der Bestand –</option>
|
||||
<?php foreach ($geldkonten as $konto): ?>
|
||||
<option value="<?= (int)$konto['id'] ?>"
|
||||
<?= $verrechnungskonto === (int)$konto['id'] ? 'selected' : '' ?>>
|
||||
<?= View::h($konto['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="feldhinweis">
|
||||
Ohne Verrechnungskonto entsteht keine Geldbuchung – das Geld ist vor Jahren
|
||||
geflossen, eine Belastung zum heutigen Tag würde das Konto verfälschen.
|
||||
</p>
|
||||
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" name="modus" value="import" class="knopf primaer">
|
||||
Jetzt übernehmen
|
||||
</button>
|
||||
<button type="submit" name="modus" value="vorschau" class="knopf">
|
||||
Vorschau neu berechnen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="kachel">
|
||||
<header><h2>Bestandsliste hochladen</h2></header>
|
||||
|
||||
<?php if ($depots === []): ?>
|
||||
<p class="platzhalter">
|
||||
Kein Depot vorhanden. Ein Depot ist ein Konto der Kontoart <em>Depot</em> –
|
||||
anzulegen in der Kontenverwaltung.
|
||||
</p>
|
||||
<div class="knopfleiste">
|
||||
<a href="/konten/neu" class="knopf primaer">Konto anlegen</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<form method="post" action="/wertpapiere/import" enctype="multipart/form-data" class="formular">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<input type="hidden" name="modus" value="vorschau">
|
||||
|
||||
<label for="csv">CSV-Datei</label>
|
||||
<input type="file" id="csv" name="csv" accept=".csv,.CSV" required>
|
||||
<p class="feldhinweis">
|
||||
Erwartet wird der Depotexport der Bank mit den Spalten Wertpapierbezeichnung,
|
||||
WKN, ISIN, Einstiegskurs, Bewertungskurs, Stückzahl und Assetklasse.
|
||||
</p>
|
||||
|
||||
<label for="konto_id_neu">Depot</label>
|
||||
<select id="konto_id_neu" name="konto_id">
|
||||
<option value="">– nach Depotnummer aus der Datei –</option>
|
||||
<?php foreach ($depots as $eintrag): ?>
|
||||
<option value="<?= (int)$eintrag['id'] ?>" <?= $depot === (int)$eintrag['id'] ? 'selected' : '' ?>>
|
||||
<?= View::h($eintrag['name']) ?><?= trim((string)$eintrag['kontonummer']) !== ''
|
||||
? ' (' . View::h($eintrag['kontonummer']) . ')' : '' ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="feldhinweis">
|
||||
Ohne Auswahl sucht der Import das Depot, dessen Kontonummer der Depotnummer
|
||||
in der Datei entspricht.
|
||||
</p>
|
||||
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">Vorschau</button>
|
||||
<a href="/wertpapiere" class="knopf">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<p class="hinweis">
|
||||
Der Export enthält nur den Bestand eines Stichtags, keine Historie. Der Import legt
|
||||
deshalb je Position einen Kauf an: Stückzahl und Einstiegskurs ergeben den Einstand,
|
||||
der Bewertungskurs den aktuellen Wert. Frühere Käufe, Verkäufe und Dividenden fehlen
|
||||
naturgemäß – sie lassen sich später über <a href="/wertpapiere/buchungen/neu">Depotbuchungen</a>
|
||||
nachtragen. Ein zweiter Import derselben Datei legt nichts doppelt an.
|
||||
</p>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
|
|
@ -47,6 +47,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</select>
|
||||
<button type="submit" class="knopf klein">Anzeigen</button>
|
||||
</form>
|
||||
<a href="/wertpapiere/import" class="knopf">Import</a>
|
||||
<a href="/wertpapiere/buchungen/neu" class="knopf primaer">Depotbuchung erfassen</a>
|
||||
</div>
|
||||
|
||||
|
|
@ -93,7 +94,8 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
|
||||
<?php if ($bestand['positionen'] === []): ?>
|
||||
<p class="platzhalter">
|
||||
Keine Bestände. Depotbuchungen kommen aus dem QIF-Import oder werden hier erfasst.
|
||||
Keine Bestände. Depotbuchungen kommen aus dem QIF-Import, aus einer
|
||||
<a href="/wertpapiere/import">Bestandsliste der Bank</a> oder werden hier erfasst.
|
||||
</p>
|
||||
<?php else: ?>
|
||||
<table class="liste zahlen">
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ class QifBuchungsImportTest extends TestCase
|
|||
$this->importMitStichtag();
|
||||
$this->zweiterLaufAendertNichts();
|
||||
$this->umbuchungsziel();
|
||||
$this->kontenDerDatei();
|
||||
$this->nurEinKonto();
|
||||
}
|
||||
|
||||
private function beispiel(): string
|
||||
|
|
@ -190,6 +192,66 @@ class QifBuchungsImportTest extends TestCase
|
|||
$this->assertEquals($giro, (float)Konto::findeNachName('Giro')['anfangssaldo'], 'Anfangssaldo bleibt gleich');
|
||||
}
|
||||
|
||||
private function kontenDerDatei(): void
|
||||
{
|
||||
$konten = QifBuchungsImport::kontenDerDatei(new QifParser($this->beispiel()));
|
||||
|
||||
$this->assertEquals(['Giro', 'Tagesgeld'], array_keys($konten), 'Beide Konten stehen zur Wahl');
|
||||
$this->assertEquals(7, $konten['Giro'], 'Alle Einträge des Girokontos gezählt');
|
||||
// Tagesgeld führt keine eigene Buchung: die drei Einträge zeigen von
|
||||
// Giro aus hierher (zwei Umbuchungen, eine Splitzeile).
|
||||
$this->assertEquals(3, $konten['Tagesgeld'], 'Ein reines Umbuchungsziel steht ebenfalls zur Wahl');
|
||||
}
|
||||
|
||||
/**
|
||||
* Teilimport: erst nur das Tagesgeldkonto, dann das Girokonto. Der zweite
|
||||
* Lauf darf die Umbuchungen nicht ein zweites Mal anlegen – auch nicht mit
|
||||
* anderer Kontoauswahl.
|
||||
*/
|
||||
private function nurEinKonto(): void
|
||||
{
|
||||
$this->mandantAnlegen();
|
||||
|
||||
$parser = new QifParser($this->beispiel());
|
||||
(new QifStammdatenImport($parser))->ausfuehren();
|
||||
|
||||
$ergebnis = (new QifBuchungsImport($parser, [
|
||||
'stichtag' => '2026-01-01',
|
||||
'bis' => date('Y-m-d'),
|
||||
'konten' => ['Tagesgeld'],
|
||||
]))->ausfuehren();
|
||||
|
||||
$this->assertEquals(2, $ergebnis['buchungen']['neu'], 'Nur die beiden Buchungen mit Bezug zum Tagesgeld');
|
||||
$this->assertEquals(1, $ergebnis['buchungen']['nicht_gewaehlt'], 'Der Supermarkt gehört allein zum Giro');
|
||||
$this->assertEquals(0, $ergebnis['buchungen']['fehler'], 'Keine Fehler beim Teilimport');
|
||||
$this->assertEquals(1, $ergebnis['anfangssalden']['konten'], 'Nur ein Anfangssaldo geschrieben');
|
||||
|
||||
$giro = Konto::findeNachName('Giro');
|
||||
$tagesgeld = Konto::findeNachName('Tagesgeld');
|
||||
|
||||
$this->assertEquals('400.00', $tagesgeld['anfangssaldo'], 'Anfangssaldo des gewählten Kontos');
|
||||
$this->assertEquals('0.00', $giro['anfangssaldo'], 'Das nicht gewählte Konto bleibt unangetastet');
|
||||
$this->assertEquals(850.0, round(Konto::saldo((int)$tagesgeld['id']), 2), 'Tagesgeldsaldo aus den Gegensichten');
|
||||
|
||||
// Zweiter Lauf, diesmal das Girokonto: Die Umbuchungen sind schon da –
|
||||
// ihr Fingerabdruck darf nicht von der Auswahl abhängen.
|
||||
$ergebnis = (new QifBuchungsImport($parser, [
|
||||
'stichtag' => '2026-01-01',
|
||||
'bis' => date('Y-m-d'),
|
||||
'konten' => ['Giro'],
|
||||
]))->ausfuehren();
|
||||
|
||||
$this->assertEquals(1, $ergebnis['buchungen']['neu'], 'Nur der Supermarkt fehlte noch');
|
||||
$this->assertEquals(2, $ergebnis['buchungen']['vorhanden'], 'Die Umbuchungen waren schon da');
|
||||
$this->assertEquals(3, Buchung::anzahl(), 'Keine Buchung doppelt angelegt');
|
||||
$this->assertEquals(
|
||||
'500.00',
|
||||
Konto::findeNachName('Giro')['anfangssaldo'],
|
||||
'Jetzt bekommt auch das Girokonto seinen Anfangssaldo'
|
||||
);
|
||||
$this->assertEquals(-99.90, round(Konto::saldo((int)$giro['id']), 2), 'Girosaldo wie beim Vollimport');
|
||||
}
|
||||
|
||||
private function umbuchungsziel(): void
|
||||
{
|
||||
$this->assertEquals('DKB Girokonto', QifBuchungsImport::umbuchungsziel('[DKB Girokonto]'), 'Einfache Umbuchung');
|
||||
|
|
|
|||
202
tests/21_DepotbestandImportTest.php
Normal file
202
tests/21_DepotbestandImportTest.php
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Finanzmanager\Models\Depotbuchung;
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\Models\Wertpapier;
|
||||
use Finanzmanager\Services\Depot;
|
||||
use Finanzmanager\Services\DepotbestandImport;
|
||||
|
||||
/**
|
||||
* Import einer Depotbestandsliste im CSV-Format der Bank: Kurse, Einstand,
|
||||
* Dublettenschutz und die Zuordnung des Depots über die Depotnummer.
|
||||
*/
|
||||
class DepotbestandImportTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 21: Depotbestand aus CSV';
|
||||
|
||||
private int $depot = 0;
|
||||
private int $verrechnung = 0;
|
||||
|
||||
/** Ein Ausschnitt im Format des echten DKB-Exports – samt BOM. */
|
||||
private const CSV = "\xEF\xBB\xBF"
|
||||
. "Datum der Erstellung;Depotnummer;Wertpapierbezeichnung;WKN;ISIN;Einstiegskurs;"
|
||||
. "Bewertungskurs;Stückzahl;Absoluter Gewinn;Relativer Gewinn;Assetklasse\r\n"
|
||||
. "12.08.2026;502022353;ALLIANZ SE NA O.N.;840400;DE0008404005;\"224,60 €\";"
|
||||
. "\"437,30 €\";60;\"12.762,10 €\";94.7%;Aktien\r\n"
|
||||
. "12.08.2026;502022353;MUL-AMUN ST600 BANK ETF A;LYX01W;LU1834983477;\"58,36 €\";"
|
||||
. "\"75,19 €\";\"55,6863\";\"937,05 €\";28.83%;ETFs\r\n"
|
||||
. "12.08.2026;502022353;\"META PLATF. A DL-,000006\";A1JWVX;US30303M1027;\"648,33 €\";"
|
||||
. "\"522,50 €\";3;\"-377,50 €\";-19.41%;Aktien\r\n";
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->mandantAnlegen();
|
||||
$this->stammdaten();
|
||||
|
||||
$this->vorschauLegtNichtsAn();
|
||||
$this->importLegtBestandUndKurseAn();
|
||||
$this->zweiterLaufLegtNichtsDoppeltAn();
|
||||
$this->depotWirdUeberDieDepotnummerGefunden();
|
||||
$this->verrechnungskontoIstFreiwillig();
|
||||
$this->fremdeDateiWirdAbgewiesen();
|
||||
$this->ausDateiLiestVomDatentraeger();
|
||||
}
|
||||
|
||||
private function stammdaten(): void
|
||||
{
|
||||
$this->depot = Konto::anlegen([
|
||||
'name' => 'CSV-Depot', 'kontoart' => 'depot', 'kontonummer' => '502022353',
|
||||
'anfangssaldo' => 0, 'aktiv' => 1,
|
||||
]);
|
||||
$this->verrechnung = Konto::anlegen([
|
||||
'name' => 'CSV-Verrechnung', 'kontoart' => 'bank', 'anfangssaldo' => 5000, 'aktiv' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
private function import(array $optionen = []): DepotbestandImport
|
||||
{
|
||||
return new DepotbestandImport(self::CSV, $optionen + ['konto_id' => $this->depot]);
|
||||
}
|
||||
|
||||
private function vorschauLegtNichtsAn(): void
|
||||
{
|
||||
$ergebnis = $this->import()->ausfuehren(true);
|
||||
|
||||
$this->assertEquals(3, $ergebnis['zaehler']['neu'], 'Drei Positionen in der Vorschau');
|
||||
$this->assertEquals(3, $ergebnis['zaehler']['wertpapiereNeu'], 'Drei Wertpapiere wären neu');
|
||||
$this->assertEquals('2026-08-12', $ergebnis['datum'], 'Der Stichtag kommt aus der Datei');
|
||||
$this->assertEquals('502022353', $ergebnis['depotnummer'], 'Die Depotnummer wird gelesen');
|
||||
$this->assertEquals(0, Depotbuchung::anzahl(), 'Der Trockenlauf hat nichts gespeichert');
|
||||
$this->assertEquals(0, Wertpapier::anzahl(), 'Und auch kein Wertpapier angelegt');
|
||||
|
||||
// 60 × 224,60 + 55,6863 × 58,36 + 3 × 648,33 = 13.476,00 + 3.249,85 + 1.944,99.
|
||||
$this->assertEquals(18670.84, $ergebnis['einstand'], 'Einstand aus Stück × Einstiegskurs');
|
||||
// 60 × 437,30 + 55,6863 × 75,19 + 3 × 522,50 = 26.238,00 + 4.187,05 + 1.567,50.
|
||||
$this->assertEquals(31992.55, $ergebnis['wert'], 'Wert aus Stück × Bewertungskurs');
|
||||
}
|
||||
|
||||
private function importLegtBestandUndKurseAn(): void
|
||||
{
|
||||
$ergebnis = $this->import()->ausfuehren();
|
||||
|
||||
$this->assertEquals(3, $ergebnis['zaehler']['neu'], 'Drei Positionen übernommen');
|
||||
$this->assertEquals(3, $ergebnis['zaehler']['kurse'], 'Drei Bewertungskurse hinterlegt');
|
||||
$this->assertEquals(0, $ergebnis['zaehler']['fehler'], 'Keine Fehler');
|
||||
|
||||
$buchungen = Depotbuchung::liste(['konto_id' => $this->depot]);
|
||||
$this->assertEquals(3, count($buchungen), 'Drei Depotbuchungen');
|
||||
|
||||
foreach ($buchungen as $buchung) {
|
||||
$this->assertEquals('kauf', (string)$buchung['vorgang'], 'Alles wird als Kauf gebucht');
|
||||
$this->assertEquals('2026-08-12', (string)$buchung['datum'], 'Und zwar zum Stichtag');
|
||||
$this->assertNull($buchung['buchung_id'], 'Ohne Verrechnungskonto keine Geldbuchung');
|
||||
}
|
||||
|
||||
// Bruchstückzahlen kommen mit Komma und dürfen nicht auf ganze Stücke fallen.
|
||||
$etf = Wertpapier::finde('LU1834983477');
|
||||
$this->assertNotEmpty($etf, 'Der ETF wurde angelegt');
|
||||
$this->assertEquals('fonds', (string)$etf['art'], 'Assetklasse ETFs wird zur Art Fonds');
|
||||
|
||||
$bestand = Depot::bestand($this->depot, '2026-08-12');
|
||||
$this->assertEquals(3, count($bestand['positionen']), 'Drei Positionen im Bestand');
|
||||
$this->assert($bestand['bewertbar'], 'Der Bestand ist sofort bewertbar');
|
||||
$this->assertEquals(18670.84, $bestand['einstand'], 'Der Einstand entspricht dem Export');
|
||||
$this->assertEquals(31992.55, $bestand['wert'], 'Der Wert ebenso');
|
||||
// Die Bank weist in Summe 13.321,65 € aus – die Differenz von sechs Cent
|
||||
// steckt in den auf zwei Stellen gerundeten Kursen des Exports.
|
||||
$this->assertEquals(13321.71, $bestand['gewinn'], 'Und damit auch der Gewinn');
|
||||
|
||||
$allianz = Wertpapier::finde('DE0008404005');
|
||||
$kurs = Wertpapier::letzterKurs((int)$allianz['id'], '2026-08-12');
|
||||
$this->assertEquals(437.30, (float)$kurs['kurs'], 'Der Bewertungskurs steht als Kurs des Stichtags');
|
||||
|
||||
$position = null;
|
||||
foreach ($bestand['positionen'] as $eintrag) {
|
||||
$position = $eintrag['isin'] === 'LU1834983477' ? $eintrag : $position;
|
||||
}
|
||||
$this->assertEquals(55.6863, (float)$position['stueck'], 'Bruchstücke bleiben erhalten');
|
||||
}
|
||||
|
||||
private function zweiterLaufLegtNichtsDoppeltAn(): void
|
||||
{
|
||||
$ergebnis = $this->import()->ausfuehren();
|
||||
|
||||
$this->assertEquals(0, $ergebnis['zaehler']['neu'], 'Der zweite Lauf legt nichts an');
|
||||
$this->assertEquals(3, $ergebnis['zaehler']['vorhanden'], 'Alle drei sind schon da');
|
||||
$this->assertEquals(3, count(Depotbuchung::liste(['konto_id' => $this->depot])), 'Es bleiben drei');
|
||||
|
||||
// Ein anderer Stichtag ist ein anderer Vorgang – dann darf gebucht werden.
|
||||
$spaeter = $this->import(['datum' => '2026-08-13'])->ausfuehren(true);
|
||||
$this->assertEquals(3, $spaeter['zaehler']['neu'], 'Zu einem anderen Datum wäre alles neu');
|
||||
$this->assertEquals(0, $spaeter['zaehler']['wertpapiereNeu'], 'Die Wertpapiere gibt es aber schon');
|
||||
}
|
||||
|
||||
private function depotWirdUeberDieDepotnummerGefunden(): void
|
||||
{
|
||||
$passend = DepotbestandImport::passendesDepot('502022353');
|
||||
$this->assertEquals($this->depot, (int)$passend['id'], 'Die Kontonummer führt zum Depot');
|
||||
$this->assertNull(DepotbestandImport::passendesDepot('999'), 'Eine fremde Nummer findet nichts');
|
||||
$this->assertNull(DepotbestandImport::passendesDepot(''), 'Ohne Nummer wird nicht geraten');
|
||||
}
|
||||
|
||||
private function verrechnungskontoIstFreiwillig(): void
|
||||
{
|
||||
$zweitesDepot = Konto::anlegen([
|
||||
'name' => 'CSV-Depot mit Geldseite', 'kontoart' => 'depot', 'aktiv' => 1,
|
||||
]);
|
||||
|
||||
(new DepotbestandImport(self::CSV, [
|
||||
'konto_id' => $zweitesDepot,
|
||||
'verrechnungskonto_id' => $this->verrechnung,
|
||||
]))->ausfuehren();
|
||||
|
||||
$buchungen = Depotbuchung::liste(['konto_id' => $zweitesDepot]);
|
||||
$this->assertEquals(3, count($buchungen), 'Auch hier drei Depotbuchungen');
|
||||
$this->assertNotEmpty($buchungen[0]['buchung_id'], 'Diesmal mit Geldbuchung');
|
||||
|
||||
// Der volle Einstand belastet das Verrechnungskonto.
|
||||
$saldo = Konto::saldo($this->verrechnung, '2026-08-12');
|
||||
$this->assertEquals(5000.0 - 18670.84, round($saldo, 2), 'Der Einstand wurde abgebucht');
|
||||
}
|
||||
|
||||
private function fremdeDateiWirdAbgewiesen(): void
|
||||
{
|
||||
$fehler = '';
|
||||
try {
|
||||
new DepotbestandImport("Datum;Empfänger;Betrag\n01.01.2026;Miete;-800,00\n", [
|
||||
'konto_id' => $this->depot,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$fehler = $e->getMessage();
|
||||
}
|
||||
$this->assert(str_contains($fehler, 'Depotbestandsliste'), 'Eine Umsatzliste wird erkannt');
|
||||
|
||||
$fehler = '';
|
||||
try {
|
||||
new DepotbestandImport('', ['konto_id' => $this->depot]);
|
||||
} catch (\Throwable $e) {
|
||||
$fehler = $e->getMessage();
|
||||
}
|
||||
$this->assert($fehler !== '', 'Eine leere Datei ebenso');
|
||||
|
||||
// Ohne gültiges Depot wird gar nicht erst gelesen.
|
||||
$fehler = '';
|
||||
try {
|
||||
(new DepotbestandImport(self::CSV, ['konto_id' => 0]))->ausfuehren(true);
|
||||
} catch (\Throwable $e) {
|
||||
$fehler = $e->getMessage();
|
||||
}
|
||||
$this->assert(str_contains($fehler, 'Depot'), 'Ohne Depot bricht der Import ab');
|
||||
}
|
||||
|
||||
private function ausDateiLiestVomDatentraeger(): void
|
||||
{
|
||||
$pfad = $this->tempPfad('.csv');
|
||||
file_put_contents($pfad, self::CSV);
|
||||
|
||||
$ergebnis = DepotbestandImport::ausDatei($pfad, ['konto_id' => $this->depot])->ausfuehren(true);
|
||||
$this->assertEquals(3, count($ergebnis['positionen']), 'Auch aus der Datei kommen drei Positionen');
|
||||
}
|
||||
}
|
||||
177
tests/22_DepotbuchungenLoeschenTest.php
Normal file
177
tests/22_DepotbuchungenLoeschenTest.php
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Finanzmanager\Models\Buchung;
|
||||
use Finanzmanager\Models\Depotbuchung;
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\Models\Wertpapier;
|
||||
use Finanzmanager\Services\Depot;
|
||||
|
||||
/**
|
||||
* Sammellöschen in der Liste der Depotbuchungen.
|
||||
*
|
||||
* Geprüft wird, was hinter dem Knopf *Auswahl löschen* passiert: Jede gewählte
|
||||
* Depotbuchung geht samt ihrer Geldbuchung, und Bestand, realisierter Gewinn
|
||||
* und Erträge stehen danach wieder auf dem Stand, den sie ohne die Buchungen
|
||||
* hätten – der Bestand wird gerechnet, nicht fortgeschrieben.
|
||||
*/
|
||||
class DepotbuchungenLoeschenTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 22: Depotbuchungen sammelweise löschen';
|
||||
|
||||
private int $depot = 0;
|
||||
private int $verrechnung = 0;
|
||||
private int $papier = 0;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->mandantAnlegen();
|
||||
$this->stammdaten();
|
||||
|
||||
$this->auswahlNimmtDieGeldbuchungenMit();
|
||||
$this->bestandGibtDieLoeschungWiederHer();
|
||||
$this->unbekannteNummernBleibenFolgenlos();
|
||||
}
|
||||
|
||||
private function stammdaten(): void
|
||||
{
|
||||
$this->depot = Konto::anlegen([
|
||||
'name' => 'Sammeldepot', 'kontoart' => 'depot', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||||
]);
|
||||
$this->verrechnung = Konto::anlegen([
|
||||
'name' => 'Sammelverrechnungskonto', 'kontoart' => 'bank',
|
||||
'anfangssaldo' => 10000, 'aktiv' => 1,
|
||||
]);
|
||||
$this->papier = Wertpapier::findenOderAnlegen([
|
||||
'name' => 'Sammelaktie AG', 'isin' => 'DE0007654321', 'art' => 'aktie',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwei Käufe, ein Verkauf, eine Dividende – wie eine Auswahl in der Liste.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function vierBuchungen(): array
|
||||
{
|
||||
return [
|
||||
Depotbuchung::anlegen([
|
||||
'konto_id' => $this->depot, 'wertpapier_id' => $this->papier,
|
||||
'datum' => '2026-01-15', 'vorgang' => 'kauf',
|
||||
'stueck' => 10, 'kurs' => 100, 'betrag' => 1000, 'gebuehren' => 10,
|
||||
'verrechnungskonto_id' => $this->verrechnung,
|
||||
]),
|
||||
Depotbuchung::anlegen([
|
||||
'konto_id' => $this->depot, 'wertpapier_id' => $this->papier,
|
||||
'datum' => '2026-02-15', 'vorgang' => 'kauf',
|
||||
'stueck' => 10, 'kurs' => 120, 'betrag' => 1200, 'gebuehren' => 10,
|
||||
'verrechnungskonto_id' => $this->verrechnung,
|
||||
]),
|
||||
Depotbuchung::anlegen([
|
||||
'konto_id' => $this->depot, 'wertpapier_id' => $this->papier,
|
||||
'datum' => '2026-03-15', 'vorgang' => 'verkauf',
|
||||
'stueck' => 5, 'kurs' => 150, 'betrag' => 750,
|
||||
'gebuehren' => 15, 'steuern' => 20,
|
||||
'verrechnungskonto_id' => $this->verrechnung,
|
||||
]),
|
||||
Depotbuchung::anlegen([
|
||||
'konto_id' => $this->depot, 'wertpapier_id' => $this->papier,
|
||||
'datum' => '2026-04-01', 'vorgang' => 'dividende',
|
||||
'betrag' => 50, 'steuern' => 13.19,
|
||||
'verrechnungskonto_id' => $this->verrechnung,
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
/** So arbeitet der Controller die Auswahl ab: Nummer für Nummer. */
|
||||
private function auswahlLoeschen(array $ids): int
|
||||
{
|
||||
$anzahl = 0;
|
||||
foreach ($ids as $id) {
|
||||
if (Depotbuchung::find((int)$id) === null) {
|
||||
continue;
|
||||
}
|
||||
Depotbuchung::loeschen((int)$id);
|
||||
$anzahl++;
|
||||
}
|
||||
|
||||
return $anzahl;
|
||||
}
|
||||
|
||||
private function auswahlNimmtDieGeldbuchungenMit(): void
|
||||
{
|
||||
$ids = $this->vierBuchungen();
|
||||
|
||||
$geldIds = [];
|
||||
foreach ($ids as $id) {
|
||||
$geldIds[] = (int)Depotbuchung::find($id)['buchung_id'];
|
||||
}
|
||||
$this->assertEquals(4, count(array_filter($geldIds)), 'Jede Depotbuchung hat ihre Geldbuchung');
|
||||
|
||||
// Die beiden Käufe auswählen – der Rest bleibt stehen.
|
||||
$this->assertEquals(2, $this->auswahlLoeschen([$ids[0], $ids[1]]), 'Zwei Buchungen gelöscht');
|
||||
|
||||
$this->assertNull(Depotbuchung::find($ids[0]), 'Der erste Kauf ist weg');
|
||||
$this->assertNull(Depotbuchung::find($ids[1]), 'Der zweite Kauf auch');
|
||||
$this->assertNotEmpty(Depotbuchung::find($ids[2]), 'Der Verkauf steht noch');
|
||||
$this->assertNotEmpty(Depotbuchung::find($ids[3]), 'Die Dividende ebenso');
|
||||
|
||||
$this->assertNull(Buchung::find($geldIds[0]), 'Die Geldbuchung des ersten Kaufs ging mit');
|
||||
$this->assertNull(Buchung::find($geldIds[1]), 'Die des zweiten Kaufs ebenso');
|
||||
$this->assertNotEmpty(Buchung::find($geldIds[2]), 'Die des Verkaufs blieb unberührt');
|
||||
|
||||
// 10000 − 1010 − 1210 + 715 + 36,81; nach dem Löschen der Käufe fehlen
|
||||
// deren Belastungen wieder.
|
||||
$this->assertEquals(
|
||||
10000.0 + 715.0 + 36.81,
|
||||
Konto::saldo($this->verrechnung, '2026-12-31'),
|
||||
'Das Verrechnungskonto trägt nur noch Verkauf und Dividende'
|
||||
);
|
||||
|
||||
// Aufräumen für den nächsten Abschnitt.
|
||||
$this->auswahlLoeschen([$ids[2], $ids[3]]);
|
||||
}
|
||||
|
||||
private function bestandGibtDieLoeschungWiederHer(): void
|
||||
{
|
||||
Wertpapier::kursSetzen($this->papier, '2026-06-30', 130.0, 'test');
|
||||
|
||||
$leer = Depot::bestand($this->depot, '2026-12-31');
|
||||
$this->assertEquals(0, count($leer['positionen']), 'Vorher ist das Depot leer');
|
||||
|
||||
$ids = $this->vierBuchungen();
|
||||
|
||||
$voll = Depot::bestand($this->depot, '2026-12-31');
|
||||
$this->assertEquals(15.0, $voll['positionen'][0]['stueck'], 'Fünfzehn Stück nach zwei Käufen und einem Verkauf');
|
||||
$this->assertGreaterThan(0.0, $voll['realisiert'], 'Der Verkauf hat etwas realisiert');
|
||||
$this->assertEquals(36.81, $voll['ertraege'], 'Die Dividende zählt als Ertrag');
|
||||
|
||||
// Verkauf und Dividende zurücknehmen: Der Bestand steht wieder auf den
|
||||
// beiden Käufen, realisierter Gewinn und Erträge fallen auf null.
|
||||
$this->assertEquals(2, $this->auswahlLoeschen([$ids[2], $ids[3]]), 'Zwei Buchungen gelöscht');
|
||||
|
||||
$nachher = Depot::bestand($this->depot, '2026-12-31');
|
||||
$this->assertEquals(20.0, $nachher['positionen'][0]['stueck'], 'Die verkauften Stücke sind zurück');
|
||||
$this->assertEquals(2220.0, $nachher['positionen'][0]['einstand'], 'Und der volle Einstand beider Käufe');
|
||||
$this->assertEquals(2600.0, $nachher['wert'], 'Zwanzig Stück zu 130 €');
|
||||
$this->assertEquals(0.0, $nachher['realisiert'], 'Ohne Verkauf ist nichts realisiert');
|
||||
$this->assertEquals(0.0, $nachher['ertraege'], 'Ohne Dividende gibt es keinen Ertrag');
|
||||
|
||||
$veraeusserungen = Depot::veraeusserungen('2026-01-01', '2026-12-31', $this->depot);
|
||||
$this->assertEquals(0, count($veraeusserungen['geschaefte']), 'Kein Veräußerungsgeschäft mehr');
|
||||
$this->assertEquals(0.0, $veraeusserungen['gewinn'], 'Und kein Gewinn in der Auswertung');
|
||||
|
||||
$this->auswahlLoeschen([$ids[0], $ids[1]]);
|
||||
}
|
||||
|
||||
private function unbekannteNummernBleibenFolgenlos(): void
|
||||
{
|
||||
$id = $this->vierBuchungen()[0];
|
||||
|
||||
// Eine Nummer, die es nicht (mehr) gibt, wird übergangen – der Rest der
|
||||
// Auswahl verschwindet trotzdem.
|
||||
$this->assertEquals(1, $this->auswahlLoeschen([999999, $id]), 'Nur die vorhandene Buchung zählt');
|
||||
$this->assertNull(Depotbuchung::find($id), 'Und sie ist gelöscht');
|
||||
}
|
||||
}
|
||||
275
tests/23_ZuordnungTest.php
Normal file
275
tests/23_ZuordnungTest.php
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Finanzmanager\Controllers\BuchungenController;
|
||||
use Finanzmanager\Models\Buchung;
|
||||
use Finanzmanager\Models\Kategorie;
|
||||
use Finanzmanager\Models\Klasse;
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\Models\Monatsabschluss;
|
||||
|
||||
/**
|
||||
* Die Schnellzuordnung aus dem Kontoblatt: Kategorie oder Gegenkonto in einem
|
||||
* Feld, Splitzeilen aus dem Dialog.
|
||||
*/
|
||||
class ZuordnungTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 23: Zuordnung im Kontoblatt';
|
||||
|
||||
private int $giro = 0;
|
||||
private int $spar = 0;
|
||||
private int $kategorie = 0;
|
||||
private int $klasse = 0;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->mandantAnlegen();
|
||||
$this->stammdaten();
|
||||
|
||||
$this->auswahlwerteLesen();
|
||||
$this->kategorieUndGegenkontoWechseln();
|
||||
$this->gegensichtNenntIhrKonto();
|
||||
$this->splitzeilenErsetzen();
|
||||
$this->splitbuchungLoeschen();
|
||||
$this->splitzeilenJeBuchung();
|
||||
$this->pruefungen();
|
||||
}
|
||||
|
||||
private function stammdaten(): void
|
||||
{
|
||||
$this->giro = Konto::anlegen([
|
||||
'name' => 'Zuordnung Giro', 'kontoart' => 'bank', 'anfangssaldo' => 1000, 'aktiv' => 1,
|
||||
]);
|
||||
$this->spar = Konto::anlegen([
|
||||
'name' => 'Zuordnung Spar', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||||
]);
|
||||
$this->kategorie = Kategorie::anlegen(['name' => 'Zuordnung Einkauf', 'art' => 'ausgabe']);
|
||||
$this->klasse = Klasse::anlegen('Zuordnung Privat');
|
||||
}
|
||||
|
||||
private function buchung(float $betrag = -50.0, string $datum = '2026-03-10'): int
|
||||
{
|
||||
return Buchung::anlegen([
|
||||
'konto_id' => $this->giro,
|
||||
'datum' => $datum,
|
||||
'betrag' => $betrag,
|
||||
'empfaenger' => 'Noch zuzuordnen',
|
||||
]);
|
||||
}
|
||||
|
||||
/** Kategorie und Gegenkonto stehen in einem Feld – hier wird es gelesen. */
|
||||
private function auswahlwerteLesen(): void
|
||||
{
|
||||
$this->assertEquals(
|
||||
['kategorie_id' => 12, 'gegenkonto_id' => null],
|
||||
BuchungenController::zuordnungAus('kategorie:12'),
|
||||
'Eine Kategorie wird erkannt'
|
||||
);
|
||||
$this->assertEquals(
|
||||
['kategorie_id' => null, 'gegenkonto_id' => 7],
|
||||
BuchungenController::zuordnungAus('konto:7'),
|
||||
'Ein Gegenkonto wird erkannt'
|
||||
);
|
||||
|
||||
$leer = ['kategorie_id' => null, 'gegenkonto_id' => null];
|
||||
$this->assertEquals($leer, BuchungenController::zuordnungAus(''), 'Leer heißt keine Zuordnung');
|
||||
$this->assertEquals($leer, BuchungenController::zuordnungAus('split'), 'Splitbuchung ist keine Zuordnung');
|
||||
$this->assertEquals($leer, BuchungenController::zuordnungAus('konto:0'), 'Kennung 0 zählt nicht');
|
||||
$this->assertEquals($leer, BuchungenController::zuordnungAus('unfug:3'), 'Unbekanntes Präfix zählt nicht');
|
||||
}
|
||||
|
||||
private function kategorieUndGegenkontoWechseln(): void
|
||||
{
|
||||
$id = $this->buchung();
|
||||
|
||||
Buchung::zuordnen($id, $this->kategorie, null);
|
||||
$buchung = Buchung::find($id);
|
||||
$this->assertEquals($this->kategorie, (int)$buchung['kategorie_id'], 'Die Kategorie steht in der Buchung');
|
||||
$this->assertNull($buchung['gegenkonto_id'], 'Ohne Gegenkonto');
|
||||
|
||||
// Aus der Kategorie wird eine Umbuchung: beides zugleich gibt es nicht.
|
||||
Buchung::zuordnen($id, $this->kategorie, $this->spar);
|
||||
$buchung = Buchung::find($id);
|
||||
$this->assertEquals($this->spar, (int)$buchung['gegenkonto_id'], 'Das Gegenkonto steht in der Buchung');
|
||||
$this->assertNull($buchung['kategorie_id'], 'Die Kategorie weicht dem Gegenkonto');
|
||||
|
||||
// Die Umbuchung wirkt sofort auf beiden Seiten.
|
||||
$this->assertEquals(50.0, Konto::saldo($this->spar), 'Das Gegenkonto bekommt die Gegenseite');
|
||||
|
||||
Buchung::zuordnen($id, null, null);
|
||||
$buchung = Buchung::find($id);
|
||||
$this->assertNull($buchung['kategorie_id'], 'Die Zuordnung lässt sich wieder leeren');
|
||||
$this->assertNull($buchung['gegenkonto_id'], 'Auch das Gegenkonto');
|
||||
$this->assertEquals(0.0, Konto::saldo($this->spar), 'Und das Gegenkonto ist den Betrag wieder los');
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Anzeigefehler, um den es ging: Auf dem Gegenkonto stand eine Umbuchung
|
||||
* ohne erkennbares Gegenüber. Die Zeile muss das Ursprungskonto nennen.
|
||||
*/
|
||||
private function gegensichtNenntIhrKonto(): void
|
||||
{
|
||||
$id = $this->buchung(-120.0, '2026-03-11');
|
||||
Buchung::zuordnen($id, null, $this->spar);
|
||||
|
||||
$zeilen = array_values(array_filter(
|
||||
Buchung::kontoblatt($this->spar),
|
||||
static fn(array $zeile): bool => (int)$zeile['id'] === $id
|
||||
));
|
||||
|
||||
$this->assertEquals(1, count($zeilen), 'Die Umbuchung erscheint auf dem Gegenkonto');
|
||||
$this->assert($zeilen[0]['gegensicht'], 'Und zwar als Gegensicht');
|
||||
$this->assertEquals(
|
||||
$this->giro,
|
||||
(int)$zeilen[0]['gegenkonto_id'],
|
||||
'Die Gegensicht nennt das Konto, von dem die Umbuchung kommt'
|
||||
);
|
||||
$this->assertEquals(120.0, (float)$zeilen[0]['betrag'], 'Mit umgekehrtem Vorzeichen');
|
||||
|
||||
Buchung::loeschen($id);
|
||||
}
|
||||
|
||||
private function splitzeilenErsetzen(): void
|
||||
{
|
||||
$id = $this->buchung(-100.0, '2026-03-12');
|
||||
Buchung::zuordnen($id, $this->kategorie, null);
|
||||
|
||||
Buchung::splitsErsetzen($id, [
|
||||
['kategorie_id' => $this->kategorie, 'klasse_id' => $this->klasse, 'betrag' => -60.0, 'notiz' => 'Einkauf'],
|
||||
['gegenkonto_id' => $this->spar, 'betrag' => -40.0, 'notiz' => 'Rücklage'],
|
||||
]);
|
||||
|
||||
$buchung = Buchung::find($id);
|
||||
$this->assertEquals(1, (int)$buchung['gesplittet'], 'Die Buchung ist jetzt gesplittet');
|
||||
$this->assertNull($buchung['kategorie_id'], 'Die Kategorie von oben weicht den Zeilen');
|
||||
|
||||
$splits = Buchung::splits($id);
|
||||
$this->assertEquals(2, count($splits), 'Zwei Zeilen sind gespeichert');
|
||||
$this->assertEquals($this->klasse, (int)$splits[0]['klasse_id'], 'Die Klasse der ersten Zeile');
|
||||
$this->assertEquals($this->spar, (int)$splits[1]['gegenkonto_id'], 'Das Gegenkonto der zweiten Zeile');
|
||||
$this->assertEquals(40.0, Konto::saldo($this->spar), 'Nur der Betrag dieser Zeile geht aufs Gegenkonto');
|
||||
|
||||
// Eine einzelne Zuordnung hebt die Aufteilung auf.
|
||||
Buchung::zuordnen($id, $this->kategorie, null);
|
||||
$this->assertEquals(0, (int)Buchung::find($id)['gesplittet'], 'Die Splitbuchung ist aufgehoben');
|
||||
$this->assertEquals([], Buchung::splits($id), 'Und die Zeilen sind weg');
|
||||
$this->assertEquals(0.0, Konto::saldo($this->spar), 'Das Gegenkonto ist den Anteil wieder los');
|
||||
|
||||
// Ohne Zeilen bleibt eine Buchung ohne Zuordnung stehen.
|
||||
Buchung::splitsErsetzen($id, [
|
||||
['kategorie_id' => $this->kategorie, 'betrag' => -100.0, 'notiz' => ''],
|
||||
]);
|
||||
Buchung::splitsErsetzen($id, []);
|
||||
$buchung = Buchung::find($id);
|
||||
$this->assertEquals(0, (int)$buchung['gesplittet'], 'Leere Zeilen heben die Splitbuchung auf');
|
||||
$this->assertNull($buchung['kategorie_id'], 'Die Buchung steht danach ohne Zuordnung da');
|
||||
$this->assertEquals(-100.0, (float)$buchung['betrag'], 'Der Buchungsbetrag bleibt unberührt');
|
||||
|
||||
Buchung::loeschen($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* „Splitbuchung löschen“ aus dem Splitdialog: Die Aufteilung fällt weg und
|
||||
* mit ihr jede Zuordnung – auch die einer Zeile mit Gegenkonto, deren
|
||||
* Gegenseite auf dem anderen Konto zu verschwinden hat.
|
||||
*/
|
||||
private function splitbuchungLoeschen(): void
|
||||
{
|
||||
$id = $this->buchung(-90.0, '2026-03-16');
|
||||
|
||||
Buchung::splitsErsetzen($id, [
|
||||
['kategorie_id' => $this->kategorie, 'klasse_id' => $this->klasse, 'betrag' => -55.0, 'notiz' => 'Teil A'],
|
||||
['gegenkonto_id' => $this->spar, 'betrag' => -35.0, 'notiz' => 'Teil B'],
|
||||
]);
|
||||
$this->assertEquals(35.0, Konto::saldo($this->spar), 'Die Zeile mit Gegenkonto wirkt zunächst');
|
||||
|
||||
Buchung::splitsErsetzen($id, []);
|
||||
|
||||
$buchung = Buchung::find($id);
|
||||
$this->assertEquals([], Buchung::splits($id), 'Alle Splitzeilen sind gelöscht');
|
||||
$this->assertEquals(0, (int)$buchung['gesplittet'], 'Die Buchung gilt nicht mehr als gesplittet');
|
||||
$this->assertNull($buchung['kategorie_id'], 'Sie hat keine Kategorie');
|
||||
$this->assertNull($buchung['gegenkonto_id'], 'Und kein Gegenkonto');
|
||||
$this->assertEquals(-90.0, (float)$buchung['betrag'], 'Die Buchung selbst bleibt stehen');
|
||||
$this->assertEquals(0.0, Konto::saldo($this->spar), 'Das Gegenkonto ist seinen Anteil wieder los');
|
||||
|
||||
Buchung::loeschen($id);
|
||||
}
|
||||
|
||||
private function splitzeilenJeBuchung(): void
|
||||
{
|
||||
$eine = $this->buchung(-30.0, '2026-03-13');
|
||||
$keine = $this->buchung(-20.0, '2026-03-14');
|
||||
|
||||
Buchung::splitsErsetzen($eine, [
|
||||
['kategorie_id' => $this->kategorie, 'betrag' => -10.0, 'notiz' => 'A'],
|
||||
['kategorie_id' => $this->kategorie, 'betrag' => -20.0, 'notiz' => 'B'],
|
||||
]);
|
||||
|
||||
$jeBuchung = Buchung::splitsJeBuchung([$eine, $keine]);
|
||||
$this->assertEquals(2, count($jeBuchung[$eine]), 'Die Zeilen kommen der Buchung zugeordnet zurück');
|
||||
$this->assert(!isset($jeBuchung[$keine]), 'Eine Buchung ohne Zeilen fehlt in der Liste');
|
||||
$this->assertEquals([], Buchung::splitsJeBuchung([]), 'Ohne Kennungen wird nicht abgefragt');
|
||||
|
||||
Buchung::loeschen($eine);
|
||||
Buchung::loeschen($keine);
|
||||
}
|
||||
|
||||
private function pruefungen(): void
|
||||
{
|
||||
$id = $this->buchung(-80.0, '2026-03-15');
|
||||
|
||||
$aufSichSelbst = false;
|
||||
try {
|
||||
Buchung::zuordnen($id, null, $this->giro);
|
||||
} catch (\Throwable) {
|
||||
$aufSichSelbst = true;
|
||||
}
|
||||
$this->assert($aufSichSelbst, 'Ein Konto kann nicht sein eigenes Gegenkonto sein');
|
||||
|
||||
$splitAufSichSelbst = false;
|
||||
try {
|
||||
Buchung::splitsErsetzen($id, [['gegenkonto_id' => $this->giro, 'betrag' => -80.0]]);
|
||||
} catch (\Throwable) {
|
||||
$splitAufSichSelbst = true;
|
||||
}
|
||||
$this->assert($splitAufSichSelbst, 'Auch eine Splitzeile nicht');
|
||||
|
||||
$krummeSumme = false;
|
||||
try {
|
||||
Buchung::splitsErsetzen($id, [['kategorie_id' => $this->kategorie, 'betrag' => -30.0]]);
|
||||
} catch (\Throwable) {
|
||||
$krummeSumme = true;
|
||||
}
|
||||
$this->assert($krummeSumme, 'Zeilen, die nicht aufgehen, werden abgewiesen');
|
||||
$this->assertEquals(0, (int)Buchung::find($id)['gesplittet'], 'Und die Buchung bleibt ungesplittet');
|
||||
|
||||
$fremdeKategorie = false;
|
||||
try {
|
||||
Buchung::zuordnen($id, 999999, null);
|
||||
} catch (\Throwable) {
|
||||
$fremdeKategorie = true;
|
||||
}
|
||||
$this->assert($fremdeKategorie, 'Eine Kategorie, die es nicht gibt, wird abgewiesen');
|
||||
|
||||
// Ein abgeschlossener Monat ist auch für die Schnellzuordnung zu.
|
||||
Monatsabschluss::abschliessen(2026, 3);
|
||||
|
||||
$zu = false;
|
||||
try {
|
||||
Buchung::zuordnen($id, $this->kategorie, null);
|
||||
} catch (\Throwable) {
|
||||
$zu = true;
|
||||
}
|
||||
$this->assert($zu, 'Im abgeschlossenen Monat wird nichts mehr zugeordnet');
|
||||
|
||||
$zuFuerSplits = false;
|
||||
try {
|
||||
Buchung::splitsErsetzen($id, []);
|
||||
} catch (\Throwable) {
|
||||
$zuFuerSplits = true;
|
||||
}
|
||||
$this->assert($zuFuerSplits, 'Auch die Splitzeilen bleiben, wie sie sind');
|
||||
}
|
||||
}
|
||||
|
|
@ -72,7 +72,7 @@ test.describe('Buchungen', () => {
|
|||
await page.fill('#datum', '2026-03-18')
|
||||
await page.fill('#betrag', '49,90')
|
||||
await page.fill('#empfaenger', 'Supermarkt')
|
||||
await page.selectOption('#kategorie_id', { label: KATEGORIE })
|
||||
await page.selectOption('#zuordnung', { label: KATEGORIE })
|
||||
await page.click('.buchungsmaske button[type="submit"]')
|
||||
|
||||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||
|
|
@ -86,11 +86,13 @@ test.describe('Buchungen', () => {
|
|||
test('Umbuchung erscheint auf beiden Kontoblättern', async ({ page }) => {
|
||||
await page.goto(`/konten/${giroId}/buchung`)
|
||||
|
||||
await page.selectOption('#art', 'umbuchung_ab')
|
||||
// Die Umbuchung entsteht im Kategoriefeld: Unter den Kategorien stehen
|
||||
// die eigenen Konten in eckigen Klammern.
|
||||
await page.selectOption('#art', 'ausgabe')
|
||||
await page.fill('#datum', '2026-04-01')
|
||||
await page.fill('#betrag', '500')
|
||||
await page.fill('#empfaenger', `Übertrag ${MARKE}`)
|
||||
await page.selectOption('#gegenkonto_id', { label: SPAR })
|
||||
await page.selectOption('#zuordnung', { label: `[${SPAR}]` })
|
||||
await page.click('.buchungsmaske button[type="submit"]')
|
||||
|
||||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||
|
|
@ -100,12 +102,135 @@ test.describe('Buchungen', () => {
|
|||
await expect(hin).toContainText('-500,00')
|
||||
await expect(hin).toContainText(`[${SPAR}]`)
|
||||
|
||||
// Gegenkonto: derselbe Vorgang mit umgekehrtem Vorzeichen.
|
||||
// Gegenkonto: derselbe Vorgang mit umgekehrtem Vorzeichen – und mit dem
|
||||
// Konto, von dem er kommt. Ohne diese Angabe stünde dort eine Umbuchung
|
||||
// ohne erkennbares Gegenüber.
|
||||
await page.goto(`/konten/${sparId}`)
|
||||
const her = page.locator('tr', { hasText: `Übertrag ${MARKE}` })
|
||||
await expect(her).toContainText('500,00')
|
||||
await expect(her).toContainText('Umbuchung')
|
||||
await expect(her).toContainText(`[${GIRO}]`)
|
||||
await expect(page.locator('.saldoanzeige').first()).toContainText('5.500,00')
|
||||
|
||||
// Auch die Gegenseite ist anklickbar: Sie führt zu ihrer Buchung, die
|
||||
// auf dem anderen Konto liegt und nur dort geändert wird.
|
||||
await her.locator('a.zuordnungsfeld.gegenseite').click()
|
||||
await expect(page).toHaveURL(/\/buchungen\/\d+/)
|
||||
await expect(page.locator('h1')).toContainText('Buchung bearbeiten')
|
||||
await expect(page.locator('#zuordnung')).toHaveValue(/^konto:\d+$/)
|
||||
})
|
||||
|
||||
test('Kategorie im Kontoblatt zuordnen', async ({ page }) => {
|
||||
await page.goto(`/konten/${giroId}/buchung`)
|
||||
await page.fill('#datum', '2026-04-03')
|
||||
await page.fill('#betrag', '12,30')
|
||||
await page.fill('#empfaenger', `Ohne Kategorie ${MARKE}`)
|
||||
await page.click('.buchungsmaske button[type="submit"]')
|
||||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||
|
||||
const zeile = page.locator('tr', { hasText: `Ohne Kategorie ${MARKE}` })
|
||||
await zeile.locator('a.zuordnungsfeld').click()
|
||||
|
||||
// Die Auswahlliste gibt es einmal und wandert in die angeklickte Zelle.
|
||||
const liste = zeile.locator('#zuordnungsauswahl select')
|
||||
await expect(liste).toBeVisible()
|
||||
await liste.selectOption({ label: KATEGORIE })
|
||||
|
||||
await expect(page.locator('.meldung-erfolg')).toContainText('Zuordnung')
|
||||
await expect(page.locator('tr', { hasText: `Ohne Kategorie ${MARKE}` }))
|
||||
.toContainText(KATEGORIE)
|
||||
})
|
||||
|
||||
test('Umbuchung im Kontoblatt zuordnen', async ({ page }) => {
|
||||
await page.goto(`/konten/${giroId}/buchung`)
|
||||
await page.fill('#datum', '2026-04-04')
|
||||
await page.fill('#betrag', '75')
|
||||
await page.fill('#empfaenger', `Nachträglich ${MARKE}`)
|
||||
await page.click('.buchungsmaske button[type="submit"]')
|
||||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||
|
||||
const zeile = page.locator('tr', { hasText: `Nachträglich ${MARKE}` })
|
||||
await zeile.locator('a.zuordnungsfeld').click()
|
||||
await zeile.locator('#zuordnungsauswahl select').selectOption({ label: `[${SPAR}]` })
|
||||
|
||||
await expect(page.locator('.meldung-erfolg')).toContainText('Zuordnung')
|
||||
await expect(page.locator('tr', { hasText: `Nachträglich ${MARKE}` }))
|
||||
.toContainText(`[${SPAR}]`)
|
||||
|
||||
// Und die Gegenseite steht sofort auf dem anderen Konto.
|
||||
await page.goto(`/konten/${sparId}`)
|
||||
await expect(page.locator('tr', { hasText: `Nachträglich ${MARKE}` }))
|
||||
.toContainText(`[${GIRO}]`)
|
||||
})
|
||||
|
||||
test('Splitbuchung aus dem Kontoblatt heraus aufteilen', async ({ page }) => {
|
||||
await page.goto(`/konten/${giroId}/buchung`)
|
||||
await page.fill('#datum', '2026-04-05')
|
||||
await page.fill('#betrag', '90')
|
||||
await page.fill('#empfaenger', `Aufzuteilen ${MARKE}`)
|
||||
await page.click('.buchungsmaske button[type="submit"]')
|
||||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||
|
||||
const zeile = page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` })
|
||||
await zeile.locator('a.zuordnungsfeld').click()
|
||||
await zeile.locator('#zuordnungsauswahl select').selectOption('split')
|
||||
|
||||
const dialog = page.locator('#splitdialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog).toContainText('-90,00')
|
||||
|
||||
await dialog.locator('select[name="split[0][zuordnung]"]').selectOption({ label: KATEGORIE })
|
||||
await dialog.locator('input[name="split[0][betrag]"]').fill('-50')
|
||||
await dialog.locator('select[name="split[1][zuordnung]"]').selectOption({ label: `[${SPAR}]` })
|
||||
await dialog.locator('input[name="split[1][betrag]"]').fill('-40')
|
||||
|
||||
// Der Dialog rechnet den Restbetrag mit; er muss aufgehen.
|
||||
await expect(page.locator('#splitdialogsumme')).not.toContainText('Rest')
|
||||
|
||||
// Der Dialog hat zwei Submit-Knöpfe: Speichern und Löschen.
|
||||
await dialog.locator('button[type="submit"].primaer').click()
|
||||
|
||||
await expect(page.locator('.meldung-erfolg')).toContainText('Splitbuchung')
|
||||
await expect(page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` }).first())
|
||||
.toContainText('Splitbuchung')
|
||||
|
||||
// Die Zeile mit Gegenkonto erscheint anteilig auf dem anderen Konto.
|
||||
await page.goto(`/konten/${sparId}`)
|
||||
await expect(page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` })).toContainText('40,00')
|
||||
})
|
||||
|
||||
test('Splitbuchung öffnet den Dialog und lässt sich dort löschen', async ({ page }) => {
|
||||
await page.goto(`/konten/${giroId}/buchung`)
|
||||
await page.fill('#datum', '2026-04-06')
|
||||
await page.fill('#betrag', '70')
|
||||
await page.fill('#empfaenger', `Wieder einfach ${MARKE}`)
|
||||
await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE })
|
||||
await page.fill('input[name="split[0][betrag]"]', '-30')
|
||||
await page.selectOption('select[name="split[1][zuordnung]"]', { label: KATEGORIE })
|
||||
await page.fill('input[name="split[1][betrag]"]', '-40')
|
||||
await page.click('.buchungsmaske button[type="submit"]')
|
||||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||
|
||||
// Der Klick führt nicht in die Auswahlliste, sondern in den Dialog –
|
||||
// mit den vorhandenen Zeilen darin.
|
||||
const zeile = page.locator('tr', { hasText: `Wieder einfach ${MARKE}` })
|
||||
await zeile.locator('a.zuordnungsfeld').click()
|
||||
|
||||
const dialog = page.locator('#splitdialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(page.locator('#zuordnungsauswahl select')).toBeHidden()
|
||||
await expect(dialog.locator('input[name="split[0][betrag]"]')).toHaveValue('-30,00')
|
||||
|
||||
await dialog.locator('#splitloeschen').click()
|
||||
|
||||
await expect(page.locator('.meldung-erfolg')).toContainText('Splitbuchung gelöscht')
|
||||
const danach = page.locator('tr', { hasText: `Wieder einfach ${MARKE}` })
|
||||
await expect(danach).not.toContainText('Splitbuchung')
|
||||
await expect(danach).not.toContainText(KATEGORIE)
|
||||
|
||||
// Und die Zuordnung geht jetzt wieder über die Auswahlliste.
|
||||
await danach.locator('a.zuordnungsfeld').click()
|
||||
await expect(danach.locator('#zuordnungsauswahl select')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Splitbuchung mit zwei Zeilen', async ({ page }) => {
|
||||
|
|
@ -115,11 +240,11 @@ test.describe('Buchungen', () => {
|
|||
await page.fill('#betrag', '260,49')
|
||||
await page.fill('#empfaenger', `Versicherung ${MARKE}`)
|
||||
|
||||
await page.selectOption('select[name="split[0][kategorie_id]"]', { label: KATEGORIE })
|
||||
await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE })
|
||||
await page.fill('input[name="split[0][notiz]"]', 'Kasko')
|
||||
await page.fill('input[name="split[0][betrag]"]', '-162,51')
|
||||
|
||||
await page.selectOption('select[name="split[1][kategorie_id]"]', { label: KATEGORIE })
|
||||
await page.selectOption('select[name="split[1][zuordnung]"]', { label: KATEGORIE })
|
||||
await page.fill('input[name="split[1][notiz]"]', 'Haftpflicht')
|
||||
await page.fill('input[name="split[1][betrag]"]', '-97,98')
|
||||
|
||||
|
|
@ -140,7 +265,7 @@ test.describe('Buchungen', () => {
|
|||
await page.fill('#datum', '2026-05-03')
|
||||
await page.fill('#betrag', '100')
|
||||
await page.fill('#empfaenger', `Krumm ${MARKE}`)
|
||||
await page.selectOption('select[name="split[0][kategorie_id]"]', { label: KATEGORIE })
|
||||
await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE })
|
||||
await page.fill('input[name="split[0][betrag]"]', '-60')
|
||||
|
||||
await expect(page.locator('#splitsumme')).toContainText('Rest')
|
||||
|
|
@ -157,11 +282,13 @@ test.describe('Buchungen', () => {
|
|||
await page.fill('input[name="suche"]', 'Supermarkt')
|
||||
await page.click('.filterleiste button[type="submit"]')
|
||||
|
||||
await expect(page.locator('tbody tr')).toHaveCount(1)
|
||||
await expect(page.locator('tbody')).toContainText('Supermarkt')
|
||||
// Auf der Seite steht noch eine zweite Tabelle: die leeren Zeilen des
|
||||
// Splitdialogs. Gemeint ist hier das Kontoblatt.
|
||||
await expect(page.locator('table.kontoblatt tbody tr')).toHaveCount(1)
|
||||
await expect(page.locator('table.kontoblatt tbody')).toContainText('Supermarkt')
|
||||
// Mit Filter ist die Saldospalte leer – ein laufender Saldo wäre gelogen.
|
||||
// Spalte 0 ist das Ankreuzfeld der Mehrfachauswahl.
|
||||
await expect(page.locator('tbody tr td').nth(8)).toContainText('–')
|
||||
await expect(page.locator('table.kontoblatt tbody tr td').nth(8)).toContainText('–')
|
||||
})
|
||||
|
||||
test('Eine Buchung ohne Kategorie setzt ihr Konto in der Übersicht fett', async ({ page }) => {
|
||||
|
|
@ -206,7 +333,7 @@ test.describe('Buchungen', () => {
|
|||
expect(gefragt.join(' ')).toContain('löschen')
|
||||
|
||||
await expect(page.locator('.meldung-erfolg')).toContainText('gelöscht')
|
||||
await expect(page.locator('tbody')).not.toContainText('Supermarkt')
|
||||
await expect(page.locator('table.kontoblatt tbody')).not.toContainText('Supermarkt')
|
||||
})
|
||||
|
||||
test('Mehrere Buchungen über die Auswahl im Kontoblatt löschen', async ({ page }) => {
|
||||
|
|
@ -227,7 +354,7 @@ test.describe('Buchungen', () => {
|
|||
await expect(knopf).toBeDisabled()
|
||||
|
||||
// Das Ankreuzfeld in der Titelzeile wählt alle angezeigten Buchungen.
|
||||
const anzahl = await page.locator('tbody input[name="buchungen[]"]').count()
|
||||
const anzahl = await page.locator('table.kontoblatt tbody input[name="buchungen[]"]').count()
|
||||
await page.check('.alle-waehlen')
|
||||
await expect(knopf).toContainText(`(${anzahl})`)
|
||||
|
||||
|
|
|
|||
|
|
@ -170,6 +170,87 @@ test.describe('Module der Phasen 4 bis 7', () => {
|
|||
await expect(page.locator('.meldung')).toContainText('gelöscht')
|
||||
})
|
||||
|
||||
test('Depotbuchungen lassen sich über die Auswahl sammelweise löschen', async ({ page }) => {
|
||||
const marke = Date.now().toString().slice(-6)
|
||||
const depot = `E2E Depot ${marke}`
|
||||
const papier = `E2E Papier ${marke}`
|
||||
|
||||
await page.goto('/konten/neu')
|
||||
await page.fill('#name', depot)
|
||||
await page.selectOption('#kontoart', 'depot')
|
||||
await page.click('.formular button[type="submit"]')
|
||||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||
|
||||
// Zwei Käufe desselben Papiers – der zweite über die Auswahlliste, damit
|
||||
// kein zweites Wertpapier entsteht.
|
||||
for (const [nr, stueck] of [[1, '10'], [2, '5']]) {
|
||||
await page.goto('/wertpapiere/buchungen/neu')
|
||||
await page.selectOption('#konto_id', { label: depot })
|
||||
await page.fill('#datum', '2026-05-0' + nr)
|
||||
if (nr === 1) {
|
||||
await page.fill('#wertpapier_name', papier)
|
||||
} else {
|
||||
await page.selectOption('#wertpapier_id', { label: papier })
|
||||
}
|
||||
await page.fill('#stueck', stueck)
|
||||
await page.fill('#kurs', '100')
|
||||
await page.click('.formular button[type="submit"]')
|
||||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||
}
|
||||
|
||||
// Der Filter bleibt über das Löschen hinweg stehen – das Formularziel
|
||||
// trägt ihn mit.
|
||||
await page.goto('/wertpapiere/buchungen')
|
||||
await page.selectOption('select[name="depot"]', { label: depot })
|
||||
await page.click('button:has-text("Filtern")')
|
||||
await expect(page.locator('tbody tr')).toHaveCount(2)
|
||||
|
||||
// Der Bestand kennt beide Käufe.
|
||||
await page.goto('/wertpapiere')
|
||||
await page.selectOption('select[name="depot"]', { label: depot })
|
||||
await page.click('button:has-text("Anzeigen")')
|
||||
await expect(page.locator('tbody tr', { hasText: papier })).toContainText('15')
|
||||
|
||||
await page.goBack()
|
||||
await page.goBack()
|
||||
|
||||
// Ohne Auswahl bleibt der Knopf gesperrt.
|
||||
const knopf = page.locator('#depotbuchungsauswahl button')
|
||||
await expect(knopf).toBeDisabled()
|
||||
|
||||
await page.check('.alle-waehlen')
|
||||
await expect(knopf).toContainText('(2)')
|
||||
|
||||
const gefragt = []
|
||||
page.on('dialog', async (dialog) => {
|
||||
gefragt.push(dialog.message())
|
||||
await dialog.accept()
|
||||
})
|
||||
|
||||
await knopf.click()
|
||||
|
||||
expect(gefragt.join(' ')).toContain('löschen')
|
||||
await expect(page.locator('.meldung-erfolg')).toContainText('gelöscht')
|
||||
await expect(page).toHaveURL(/depot=/)
|
||||
// Der Name steht weiter im Filterfeld – gemeint ist die Liste.
|
||||
await expect(page.locator('table.liste')).toHaveCount(0)
|
||||
await expect(page.locator('.platzhalter')).toContainText('Keine Depotbuchungen')
|
||||
|
||||
// Und der Bestand ist die Buchungen los – er wird gerechnet.
|
||||
await page.goto('/wertpapiere')
|
||||
await page.selectOption('select[name="depot"]', { label: depot })
|
||||
await page.click('button:has-text("Anzeigen")')
|
||||
await expect(page.locator('.platzhalter')).toContainText('Keine Bestände')
|
||||
|
||||
// Aufräumen: Das Testdepot wieder entfernen. Die Rückfrage übernimmt der
|
||||
// Dialoghandler von oben.
|
||||
await page.goto('/konten')
|
||||
const pfad = await page.locator('a', { hasText: depot }).first().getAttribute('href')
|
||||
await page.goto(pfad + '/bearbeiten')
|
||||
await page.locator('button:has-text("Konto löschen")').click()
|
||||
await expect(page.locator('.meldung')).toContainText('gelöscht')
|
||||
})
|
||||
|
||||
test('Der Finanzkalender ist erreichbar und lässt sich einstellen', async ({ page }) => {
|
||||
await page.goto('/termine/kalender')
|
||||
await expect(page.locator('h1')).toContainText('Finanzkalender')
|
||||
|
|
|
|||
|
|
@ -221,4 +221,75 @@ test.describe('Stammdaten', () => {
|
|||
.getByRole('button', { name: 'Löschen' }).click()
|
||||
await expect(page.locator('body')).not.toContainText(dateiname)
|
||||
})
|
||||
|
||||
test('Kontoauswahl steht erst nach dem Trockenlauf und grenzt den Import ein', async ({ page }) => {
|
||||
const dateiname = `e2e-konten-${MARKE}.qif`
|
||||
const giro = `E2E Giro ${MARKE}`
|
||||
const tagesgeld = `E2E Tagesgeld ${MARKE}`
|
||||
const inhalt = [
|
||||
'!Option:MDY',
|
||||
'!Option:AutoSwitch',
|
||||
'!Account',
|
||||
`N${giro}`,
|
||||
'TBank',
|
||||
'^',
|
||||
`N${tagesgeld}`,
|
||||
'TOth T',
|
||||
'^',
|
||||
'!Clear:AutoSwitch',
|
||||
'!Account',
|
||||
`N${giro}`,
|
||||
'TBank',
|
||||
'^',
|
||||
'!Type:Bank',
|
||||
'D4.1.26',
|
||||
'T-250.00',
|
||||
'PÜbertrag',
|
||||
`L[${tagesgeld}]`,
|
||||
'^',
|
||||
'D5.2.26',
|
||||
'T-49.90',
|
||||
'PSupermarkt',
|
||||
'^',
|
||||
'',
|
||||
].join('\r\n')
|
||||
|
||||
await page.goto('/verwaltung/import')
|
||||
await page.setInputFiles('#qif', {
|
||||
name: dateiname,
|
||||
mimeType: 'application/octet-stream',
|
||||
buffer: Buffer.from(inhalt, 'latin1'),
|
||||
})
|
||||
await page.click('.formular button[type="submit"]')
|
||||
await expect(page.locator('.meldung-erfolg')).toContainText('hochgeladen')
|
||||
|
||||
// Vor dem ersten Lesen der Datei gibt es nichts zu wählen.
|
||||
await expect(page.locator('input[name="konten[]"]')).toHaveCount(0)
|
||||
|
||||
await page.locator('tr', { hasText: dateiname })
|
||||
.getByRole('button', { name: 'Trockenlauf' }).click()
|
||||
|
||||
// Danach stehen genau die Konten der Datei zur Wahl – beide angehakt.
|
||||
const kaestchen = page.locator('input[name="konten[]"]')
|
||||
await expect(kaestchen).toHaveCount(2)
|
||||
await expect(page.locator(`input[name="konten[]"][value="${giro}"]`)).toBeChecked()
|
||||
await expect(page.locator(`input[name="konten[]"][value="${tagesgeld}"]`)).toBeChecked()
|
||||
|
||||
// Nur das Tagesgeld übernehmen: Die Umbuchung zeigt hierher und bleibt,
|
||||
// die reine Girobuchung fällt heraus.
|
||||
await page.locator(`input[name="konten[]"][value="${giro}"]`).uncheck()
|
||||
await page.getByRole('button', { name: 'Trockenlauf mit dieser Auswahl' }).click()
|
||||
|
||||
await expect(page.locator('.kachel h2').first()).toContainText('Trockenlauf')
|
||||
await expect(page.locator('tr', { hasText: 'gehört zu keinem der gewählten Konten' }))
|
||||
.toContainText('1')
|
||||
await expect(page.locator(`input[name="konten[]"][value="${giro}"]`)).not.toBeChecked()
|
||||
|
||||
// Aufräumen – die Datei bleibt sonst im Importverzeichnis liegen.
|
||||
await page.goto('/verwaltung/import')
|
||||
page.on('dialog', async (dialog) => await dialog.accept())
|
||||
await page.locator('tr', { hasText: dateiname })
|
||||
.getByRole('button', { name: 'Löschen' }).click()
|
||||
await expect(page.locator('body')).not.toContainText(dateiname)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue