Finanzmanager/src/Services/DepotbestandImport.php
fds2610-bot 4484ddd90d 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>
2026-08-12 14:26:50 +02:00

494 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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];
}
}