'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> */ private array $zeilen = []; private string $erstellt = ''; private string $depotnummer = ''; /** @var list */ 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 $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>, * zaehler: array, einstand: float, wert: float, * meldungen: list * } */ 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 */ 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 $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 $ueberschriften * @return array 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 $felder * @param array $spalten * @return array|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]; } }