Wertpapiere: Depotbestand aus einer CSV-Datei übernehmen
Ein Depot, das nie im FinanzManager geführt wurde, ließ sich bisher nur Buchung für Buchung nachtragen. `Services\DepotbestandImport` liest stattdessen die Bestandsliste, die die Bank zum Herunterladen anbietet (erprobt mit der DKB): Kopfzeile mit 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 liest er, wie sie kommen. Der Export kennt keine Historie – er beschreibt 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 und lassen sich nachtragen. Der Bewertungskurs wird als Kurs des Stichtags hinterlegt, sonst 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. Der Ablauf ist zweistufig – hochladen, Vorschau prüfen, übernehmen. 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. `Buchung::QUELLEN` kennt dafür jetzt die Herkunft `csv`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
78f1b91bc0
commit
4484ddd90d
10 changed files with 1167 additions and 4 deletions
38
Changes.md
38
Changes.md
|
|
@ -1,5 +1,43 @@
|
||||||
# Änderungen
|
# Änderungen
|
||||||
|
|
||||||
|
## 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)
|
## 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:
|
Drei Fehler, die zusammen dazu führten, dass abgerufene Umsätze spurlos verschwanden:
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@ Haushalt wird beides parallel genutzt.
|
||||||
abgerufene Umsätze buchen sich auf Wunsch selbst, Abgleich gegen vorhandene Buchungen,
|
abgerufene Umsätze buchen sich auf Wunsch selbst, Abgleich gegen vorhandene Buchungen,
|
||||||
Regeln für die automatische Kategorisierung.
|
Regeln für die automatische Kategorisierung.
|
||||||
* **Wertpapiere** – Depotbestände mit Einstand und Bewertung, Depotbuchungen, Kursabruf,
|
* **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
|
* **Nebenmodule** – Buchungsvorlagen, regelmäßige Buchungen mit Fälligkeitslauf und
|
||||||
Finanzkalender, Erinnerungen, Adressen, Verträge, Sparziele, Kredite mit Tilgungsplan.
|
Finanzkalender, Erinnerungen, Adressen, Verträge, Sparziele, Kredite mit Tilgungsplan.
|
||||||
* **Zahlungsverkehr** – Überweisungen und Terminüberweisungen über FinTS.
|
* **Zahlungsverkehr** – Überweisungen und Terminüberweisungen über FinTS.
|
||||||
|
|
@ -110,6 +111,6 @@ Ohne `--uebernehmen` läuft nur der Trockenlauf.
|
||||||
- `docs/auswertungen.md` – Berichtsmodul, Filterleiste, Drilldown, CSV
|
- `docs/auswertungen.md` – Berichtsmodul, Filterleiste, Drilldown, CSV
|
||||||
- `docs/cockpit.md` – Startseite und ihre Kacheln
|
- `docs/cockpit.md` – Startseite und ihre Kacheln
|
||||||
- `docs/banking.md` – FinTS-Abruf, TAN-Dialog, Abgleich und Regeln
|
- `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/nebenmodule.md` – Vorlagen, Termine, Verträge, Sparziele, Kredite, Zahlungsverkehr
|
||||||
- `docs/qif-import.md` – Aufbau des QIF-Exports und Arbeitsweise des Importeurs
|
- `docs/qif-import.md` – Aufbau des QIF-Exports und Arbeitsweise des Importeurs
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,44 @@ Wertpapiergeschäfts als Abgang – auch bei Verkäufen und Dividenden. Das QIF
|
||||||
`$` immer einen positiven Betrag; die Richtung steckt allein im Vorgang. Verrechnungskonten
|
`$` immer einen positiven Betrag; die Richtung steckt allein im Vorgang. Verrechnungskonten
|
||||||
von Depots wiesen dadurch zu niedrige Salden aus.
|
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.
|
||||||
|
|
||||||
## Kontenübersicht
|
## Kontenübersicht
|
||||||
|
|
||||||
Depots erscheinen dort mit ihrem **bewerteten Bestand**, nicht mit einem Buchungssaldo. Der
|
Depots erscheinen dort mit ihrem **bewerteten Bestand**, nicht mit einem Buchungssaldo. Der
|
||||||
|
|
@ -127,3 +165,9 @@ Vorzeichen; Verkauf mit anteiligem Einstand und realisiertem Gewinn; Dividende o
|
||||||
Bestandsänderung; fehlende Kurse; Veräußerungsrechnung über die Historie hinweg; der
|
Bestandsänderung; fehlende Kurse; Veräußerungsrechnung über die Historie hinweg; der
|
||||||
QIF-Import eines echten Ausschnitts (Kauf, Dividende, Verkauf) inklusive Idempotenz;
|
QIF-Import eines echten Ausschnitts (Kauf, Dividende, Verkauf) inklusive Idempotenz;
|
||||||
Löschen samt Geldbuchung.
|
Löschen samt Geldbuchung.
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,9 @@ $router->post('/wertpapiere/stammdaten/{id}/kurs',
|
||||||
fn(array $p) => (new WertpapiereController())->kursSetzen($p));
|
fn(array $p) => (new WertpapiereController())->kursSetzen($p));
|
||||||
$router->post('/wertpapiere/kurse', fn() => (new WertpapiereController())->kurseAbrufen());
|
$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 ────────────────────────
|
// ── Online-Banking: Zugänge, Abruf, Abgleich, Regeln ────────────────────────
|
||||||
$router->get('/banking', fn() => (new BankingController())->index());
|
$router->get('/banking', fn() => (new BankingController())->index());
|
||||||
$router->get('/banking/neu', fn() => (new BankingController())->neu());
|
$router->get('/banking/neu', fn() => (new BankingController())->neu());
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use Finanzmanager\Models\Einstellung;
|
||||||
use Finanzmanager\Models\Konto;
|
use Finanzmanager\Models\Konto;
|
||||||
use Finanzmanager\Models\Wertpapier;
|
use Finanzmanager\Models\Wertpapier;
|
||||||
use Finanzmanager\Services\Depot;
|
use Finanzmanager\Services\Depot;
|
||||||
|
use Finanzmanager\Services\DepotbestandImport;
|
||||||
use Finanzmanager\Services\Kursabruf;
|
use Finanzmanager\Services\Kursabruf;
|
||||||
use Finanzmanager\Services\Zeitraum;
|
use Finanzmanager\Services\Zeitraum;
|
||||||
use Finanzmanager\View;
|
use Finanzmanager\View;
|
||||||
|
|
@ -130,6 +131,158 @@ class WertpapiereController extends BaseController
|
||||||
View::redirect('/wertpapiere/buchungen', 'Depotbuchung gelöscht – samt ihrer Geldbuchung.');
|
View::redirect('/wertpapiere/buchungen', 'Depotbuchung gelöscht – samt ihrer Geldbuchung.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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 ─────────────────────────────────────────────────
|
// ── Stammdaten und Kurse ─────────────────────────────────────────────────
|
||||||
|
|
||||||
public function stammdaten(): void
|
public function stammdaten(): void
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,12 @@ use Finanzmanager\Mandant;
|
||||||
*/
|
*/
|
||||||
class Buchung
|
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. */
|
/** Beträge gelten als gleich, wenn sie sich um weniger als einen halben Cent unterscheiden. */
|
||||||
private const CENT = 0.005;
|
private const CENT = 0.005;
|
||||||
|
|
|
||||||
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
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>
|
</select>
|
||||||
<button type="submit" class="knopf klein">Anzeigen</button>
|
<button type="submit" class="knopf klein">Anzeigen</button>
|
||||||
</form>
|
</form>
|
||||||
|
<a href="/wertpapiere/import" class="knopf">Import</a>
|
||||||
<a href="/wertpapiere/buchungen/neu" class="knopf primaer">Depotbuchung erfassen</a>
|
<a href="/wertpapiere/buchungen/neu" class="knopf primaer">Depotbuchung erfassen</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -93,7 +94,8 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
|
|
||||||
<?php if ($bestand['positionen'] === []): ?>
|
<?php if ($bestand['positionen'] === []): ?>
|
||||||
<p class="platzhalter">
|
<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>
|
</p>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<table class="liste zahlen">
|
<table class="liste zahlen">
|
||||||
|
|
|
||||||
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue