Finanzmanager/src/Services/QifBuchungsImport.php
fds2610-bot 3e55b3ba73 QIF-Import: nur ausgewählte Konten übernehmen
Der Import kannte bisher nur den Zeitraum als Filter – ein Export ließ sich
nur ganz einlesen. Neu ist die Option "konten": im Dialog eine Ankreuzliste,
die erst nach dem Trockenlauf erscheint (vorher ist die Datei ungelesen), auf
der Kommandozeile --konto=NAME und --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 sonst fremde
Salden. Die Fingerabdrücke entstehen jetzt vorab in einem eigenen Durchgang:
Der laufende Zähler für gleiche Fingerabdrücke hing sonst an der Auswahl, und
dieselbe Buchung wäre beim zweiten Lauf mit anderer Auswahl doppelt angelegt
worden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 14:50:43 +02:00

825 lines
30 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\Buchung;
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Wertpapier;
/**
* Übernimmt die Buchungen eines QIF-Exports.
*
* Zwei Eigenheiten des Formats bestimmen den Ablauf:
*
* 1. **Umbuchungen stehen nur einmal in der Datei** – in dem Konto, in dem sie
* erfasst wurden; die Gegenseite ist allein durch `L[Zielkonto]` benannt.
* Hier wird daraus eine Buchung mit `gegenkonto_id`, die auf beiden Konten
* erscheint (auf dem Gegenkonto mit umgekehrtem Vorzeichen).
* 2. **Der Export reicht weiter zurück, als gebraucht wird.** Alles vor dem
* Stichtag fließt nicht als Buchung ein, sondern wird je Konto zum
* Anfangssaldo aufsummiert – einschließlich der Gegenseiten. Die Salden
* stimmen dadurch exakt, ohne die ganze Historie mitzuschleppen.
*
* 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
{
/** Sektionen mit Geldbuchungen. Invst wird gesondert behandelt. */
private const GELDTYPEN = ['Bank', 'CCard', 'Cash', 'Oth A', 'Oth L', 'Oth T', 'Oth P', 'Oth E', 'Oth F'];
private QifParser $parser;
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 */
private array $kategorien = [];
/** @var array<string,int> Klassenname → id */
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, konten?: list<string>} $optionen
*/
public function __construct(QifParser $parser, array $optionen = [])
{
$this->parser = $parser;
$this->stichtag = $optionen['stichtag'] ?? '2026-01-01';
$this->bis = $optionen['bis'] ?? date('Y-m-d');
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;
}
/**
* @return array<string,mixed>
*/
public function ausfuehren(bool $trockenlauf = false): array
{
$this->meldungen = [];
$this->hashZaehler = [];
$this->ladeStammdaten();
$lauf = function () use ($trockenlauf): array {
$anfang = $this->anfangssalden($trockenlauf);
$buchungen = $this->buchungen($trockenlauf);
return [
'anfangssalden' => $anfang,
'buchungen' => $buchungen,
'stichtag' => $this->stichtag,
'bis' => $this->bis,
'konten' => $this->auswahl === null ? null : array_keys($this->auswahl),
'trockenlauf' => $trockenlauf,
];
};
$ergebnis = $trockenlauf ? $lauf() : Database::transaktion($lauf);
$ergebnis['meldungen'] = $this->meldungen;
return $ergebnis;
}
public function vorschau(): array
{
return $this->ausfuehren(true);
}
/**
* 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
{
$salden = [];
$bucheAuf = static function (string $konto, float $betrag) use (&$salden): void {
$salden[$konto] = ($salden[$konto] ?? 0.0) + $betrag;
};
foreach ($this->parser->eintraege() as $eintrag) {
if (!$this->istGeldbuchung($eintrag) && $eintrag['typ'] !== 'Invst') {
continue;
}
$datum = QifParser::datum(QifParser::feld($eintrag, 'D'), $this->parser->datumsformat());
if ($datum === null || $datum >= $this->stichtag) {
continue;
}
$konto = (string)$eintrag['konto'];
if ($eintrag['typ'] === 'Invst') {
// Vor dem Stichtag zählt vom Wertpapiergeschäft nur der
// Geldfluss auf dem Verrechnungskonto. Die Bestände davor
// stecken im Depot, nicht im Anfangssaldo.
$ziel = self::verrechnungskonto(QifParser::feld($eintrag, 'L'));
if ($ziel !== null) {
$bucheAuf($ziel, self::geldflussInvst($eintrag));
}
continue;
}
$betrag = QifParser::betrag(QifParser::feld($eintrag, 'U') ?? QifParser::feld($eintrag, 'T'));
$bucheAuf($konto, $betrag);
foreach ($this->gegenbuchungen($eintrag, $konto, $betrag) as [$zielKonto, $zielBetrag]) {
$bucheAuf($zielKonto, $zielBetrag);
}
}
$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.');
continue;
}
if (!$trockenlauf) {
Database::execute(
'UPDATE konten SET anfangssaldo = ? WHERE id = ?',
[round($betrag, 2), $kontoId]
);
}
$geschrieben[$name] = $betrag;
$gesetzt++;
}
return [
'konten' => $gesetzt,
'summe' => round(array_sum($geschrieben), 2),
'salden' => $geschrieben,
];
}
/**
* @return array<string,int>
*/
private function buchungen(bool $trockenlauf): array
{
$zaehler = [
'neu' => 0, 'vorhanden' => 0, 'vor_stichtag' => 0,
'zukunft' => 0, 'wertpapier' => 0, 'nicht_gewaehlt' => 0, 'fehler' => 0,
];
$this->hashes = $this->hashes();
foreach ($this->parser->eintraege() as $nr => $eintrag) {
$istGeld = $this->istGeldbuchung($eintrag);
if (!$istGeld && $eintrag['typ'] !== 'Invst') {
continue;
}
$datum = QifParser::datum(QifParser::feld($eintrag, 'D'), $this->parser->datumsformat());
if ($datum === null) {
$zaehler['fehler']++;
$this->melde('fehler', 'Buchung ohne lesbares Datum übersprungen (' . $eintrag['konto'] . ').');
continue;
}
if ($datum < $this->stichtag) {
$zaehler['vor_stichtag']++;
continue;
}
if ($datum > $this->bis) {
$zaehler['zukunft']++;
continue;
}
if (!$this->gehoertZurAuswahl($eintrag)) {
$zaehler['nicht_gewaehlt']++;
continue;
}
try {
$angelegt = $istGeld
? $this->geldbuchung($eintrag, $nr, $datum, $trockenlauf)
: $this->wertpapierbuchung($eintrag, $datum, $trockenlauf);
if ($angelegt === null) {
$zaehler['vorhanden']++;
continue;
}
$zaehler['neu']++;
if (!$istGeld) {
$zaehler['wertpapier']++;
}
} catch (\Throwable $e) {
$zaehler['fehler']++;
$this->melde('fehler', $datum . ' ' . $eintrag['konto'] . ': ' . $e->getMessage());
}
}
if ($zaehler['wertpapier'] > 0) {
$this->melde('hinweis', $zaehler['wertpapier'] . ' Wertpapiergeschäfte übernommen – '
. 'je Geschäft eine Depotbuchung mit Stück und Kurs sowie die Geldbuchung auf dem '
. 'Verrechnungskonto. Kurse zur Bewertung holt der Kursabruf.');
}
return $zaehler;
}
/**
* @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, int $nr, string $datum, bool $trockenlauf): ?int
{
$kontoName = (string)$eintrag['konto'];
$kontoId = $this->kontoId($kontoName);
if ($kontoId === null) {
throw new \RuntimeException('Unbekanntes Konto „' . $kontoName . '“.');
}
$betrag = QifParser::betrag(QifParser::feld($eintrag, 'U') ?? QifParser::feld($eintrag, 'T'));
$text = (string)QifParser::feld($eintrag, 'M');
$nummer = (string)QifParser::feld($eintrag, 'N');
$hash = $this->hashes[$nr] ?? $this->eindeutigerHash($kontoId, $datum, $betrag, $nummer, $text);
if (Buchung::findeNachHash($hash) !== null) {
return null;
}
$zuordnung = $this->zuordnung((string)QifParser::feld($eintrag, 'L'), $kontoId);
$splits = $this->splitzeilen($eintrag, $kontoId);
$daten = [
'konto_id' => $kontoId,
'datum' => $datum,
'valuta' => QifParser::datum(QifParser::feld($eintrag, 'V'), $this->parser->datumsformat()),
'betrag' => $betrag,
'empfaenger' => (string)QifParser::feld($eintrag, 'P'),
'buchungstext' => $text,
'nummer' => $nummer,
'abgeglichen' => QifParser::feld($eintrag, 'C') !== null,
'kategorie_id' => $zuordnung['kategorie_id'],
'klasse_id' => $zuordnung['klasse_id'],
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'quelle' => 'qif',
'import_hash' => $hash,
];
if ($trockenlauf) {
return 0;
}
return Buchung::anlegen($daten, $splits, QifParser::felder($eintrag, 'A'));
}
/**
* Wertpapiergeschäfte schlagen in Phase 2 nur auf dem Verrechnungskonto zu
* Buche. Bestand, Einstandswert und Kurse folgen in Phase 6 – bis dahin
* bliebe ein Betrag auf dem Depot ohne Gegenwert und würde die Summe der
* Kontostände verfälschen.
*/
/**
* Ein Wertpapiergeschäft (`!Type:Invst`).
*
* Es entstehen zwei Datensätze: die Depotbuchung mit Stück und Kurs und –
* wenn ein Verrechnungskonto genannt ist – die Geldbuchung dort. Beide legt
* `Depotbuchung::anlegen()` in einem Zug an.
*
* Das Depot ist der `!Account`-Abschnitt, in dem der Eintrag steht; das
* Verrechnungskonto steht in `L|[Konto]` (dem eine Kategorie vorangehen
* kann, etwa `LKursgewinne:Realisierte Gewinne|[Verrechnungskonto]`).
*/
private function wertpapierbuchung(array $eintrag, string $datum, bool $trockenlauf): ?int
{
$depotName = (string)$eintrag['konto'];
$depotId = $this->kontoId($depotName);
if ($depotId === null) {
throw new \RuntimeException('Unbekanntes Depot „' . $depotName . '“.');
}
$vorgangQif = trim((string)QifParser::feld($eintrag, 'N'));
$vorgang = Depotbuchung::vorgangAusQif($vorgangQif);
$name = trim((string)QifParser::feld($eintrag, 'Y'));
if ($name === '') {
throw new \RuntimeException('Wertpapiergeschäft ohne Wertpapiernamen.');
}
$isin = trim((string)QifParser::feld($eintrag, '@'));
$stueck = QifParser::betrag(QifParser::feld($eintrag, 'Q'));
$kurs = QifParser::betrag(QifParser::feld($eintrag, 'I'));
[$gebuehren, $steuern] = self::nebenkosten(QifParser::feld($eintrag, 'O'));
// Der Bruttowert ist Stück mal Kurs. Fehlt der Kurs – bei Dividenden
// immer –, wird er aus dem gebuchten Betrag zurückgerechnet: Feld U ist
// dort die Gutschrift nach Abzug der Steuern.
$gebucht = QifParser::betrag(QifParser::feld($eintrag, 'U'));
$betrag = $stueck > 0.0 && $kurs > 0.0
? round($stueck * $kurs, 2)
: round(abs($gebucht) + $gebuehren + $steuern, 2);
$verrechnung = self::verrechnungskonto(QifParser::feld($eintrag, 'L'));
$verrechnungId = $verrechnung === null ? null : $this->kontoId($verrechnung);
if ($verrechnung !== null && $verrechnungId === null) {
throw new \RuntimeException('Unbekanntes Verrechnungskonto „' . $verrechnung . '“.');
}
$geld = QifParser::feld($eintrag, '$');
$geld = $geld === null ? 0.0 : QifParser::betrag($geld);
$hash = Depotbuchung::hash($depotId, $datum, $vorgang, $stueck, $betrag, $isin ?: $name);
if (Depotbuchung::findeNachHash($hash) !== null) {
return null;
}
if ($trockenlauf) {
return 0;
}
$text = trim((string)QifParser::feld($eintrag, 'M'));
$wertpapierId = Wertpapier::findenOderAnlegen([
'name' => $name,
'wkn' => (string)QifParser::feld($eintrag, '~'),
'isin' => $isin,
'art' => Wertpapier::artAusQif((string)QifParser::feld($eintrag, '&')),
'waehrung' => (string)QifParser::feld($eintrag, 'F') ?: 'EUR',
]);
return Depotbuchung::anlegen([
'konto_id' => $depotId,
'wertpapier_id' => $wertpapierId,
'datum' => $datum,
'valuta' => QifParser::datum(QifParser::feld($eintrag, 'V'), $this->parser->datumsformat()),
'vorgang' => $vorgang,
'vorgang_qif' => $vorgangQif,
'stueck' => $stueck,
'kurs' => $kurs > 0.0 ? $kurs : null,
'betrag' => $betrag,
'gebuehren' => $gebuehren,
'steuern' => $steuern,
'waehrung' => (string)QifParser::feld($eintrag, 'F') ?: 'EUR',
'verrechnungskonto_id' => $verrechnungId,
'geldbetrag' => $geld,
'notiz' => $text,
'wertpapiername' => $name,
'abgeglichen' => QifParser::feld($eintrag, 'C') !== null,
'quelle' => 'qif',
'import_hash' => $hash,
// Der Hash der Geldseite entspricht dem, den frühere Importe ohne
// Depotmodul vergeben haben – so entsteht im Kontoblatt keine
// zweite Buchung für dasselbe Geschäft.
'buchung_hash' => $verrechnungId === null ? null : $this->eindeutigerHash(
$verrechnungId,
$datum,
-$geld,
$vorgangQif,
$text
),
]);
}
/**
* Der Geldfluss eines Wertpapiergeschäfts auf dem Verrechnungskonto.
*
* Das QIF nennt im Feld `$` immer einen positiven Betrag – die Richtung
* steckt allein im Vorgang: Ein Kauf nimmt Geld weg, Verkauf und Dividende
* bringen welches. Ein- und Auslieferungen rühren kein Konto an.
*/
private static function geldflussInvst(array $eintrag): float
{
$wert = QifParser::feld($eintrag, '$') ?? QifParser::feld($eintrag, 'U');
if ($wert === null) {
return 0.0;
}
$betrag = abs(QifParser::betrag($wert));
$vorgang = Depotbuchung::vorgangAusQif(trim((string)QifParser::feld($eintrag, 'N')));
return match ($vorgang) {
'kauf' => -$betrag,
'verkauf', 'dividende' => $betrag,
default => 0.0,
};
}
/**
* Das QIF-Feld `O` führt zwölf durch „|“ getrennte Beträge.
*
* Sicher zugeordnet sind die vierte (Provision) und die zehnte Position
* (fremde Spesen) – beides Gebühren. Alles Übrige sind Abzüge des Fiskus
* (Kapitalertragsteuer, Solidaritätszuschlag, Quellensteuer). Statt jede
* Position einzeln zu deuten, werden sie zu „Steuern“ zusammengefasst: Für
* Einstand und Erlös zählt die Summe, und keine Position fällt heraus.
*
* @return array{0: float, 1: float} Gebühren und Steuern
*/
private static function nebenkosten(?string $feld): array
{
if ($feld === null || trim($feld) === '') {
return [0.0, 0.0];
}
$werte = array_map(
static fn(string $wert): float => QifParser::betrag($wert),
explode('|', $feld)
);
$gebuehren = ($werte[3] ?? 0.0) + ($werte[9] ?? 0.0);
$steuern = 0.0;
foreach ($werte as $nummer => $wert) {
if ($nummer !== 3 && $nummer !== 9) {
$steuern += $wert;
}
}
return [round($gebuehren, 2), round($steuern, 2)];
}
/**
* Welche Konten bekommen aus dieser Buchung eine Gegenbewegung, und mit
* welchem Betrag? Ohne Splits genau eines, mit Splits je Zeile eines.
*
* @return list<array{0: string, 1: float}>
*/
private function gegenbuchungen(array $eintrag, string $konto, float $betrag): array
{
$ziele = [];
$splits = QifParser::felder($eintrag, 'S');
if ($splits !== []) {
$betraege = QifParser::felder($eintrag, '$');
foreach ($splits as $nr => $angabe) {
$ziel = self::umbuchungsziel($angabe);
if ($ziel !== null && $ziel !== $konto) {
$ziele[] = [$ziel, -QifParser::betrag($betraege[$nr] ?? '0')];
}
}
return $ziele;
}
$ziel = self::umbuchungsziel(QifParser::feld($eintrag, 'L'));
if ($ziel !== null && $ziel !== $konto) {
$ziele[] = [$ziel, -$betrag];
}
return $ziele;
}
/**
* @return list<array<string,mixed>>
*/
private function splitzeilen(array $eintrag, int $kontoId): array
{
$kategorien = QifParser::felder($eintrag, 'S');
if ($kategorien === []) {
return [];
}
$betraege = QifParser::felder($eintrag, '$');
$notizen = QifParser::felder($eintrag, 'E');
$zeilen = [];
foreach ($kategorien as $nr => $angabe) {
$zuordnung = $this->zuordnung($angabe, $kontoId);
$zeilen[] = [
'kategorie_id' => $zuordnung['kategorie_id'],
'klasse_id' => $zuordnung['klasse_id'],
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'betrag' => QifParser::betrag($betraege[$nr] ?? '0'),
'notiz' => $notizen[$nr] ?? '',
];
}
return $zeilen;
}
/**
* Löst eine L- oder S-Angabe auf: Kategorie und Klasse, oder ein Gegenkonto.
*
* Verweist die Buchung auf ihr eigenes Konto, ist es die Eröffnungsbuchung
* des FinanzManagers – daraus wird keine Umbuchung, sonst hübe sie sich auf.
*
* @return array{kategorie_id: ?int, klasse_id: ?int, gegenkonto_id: ?int}
*/
private function zuordnung(string $angabe, int $kontoId): array
{
$leer = ['kategorie_id' => null, 'klasse_id' => null, 'gegenkonto_id' => null];
if (trim($angabe) === '') {
return $leer;
}
$teile = Kategorie::zerlegeQifAngabe($angabe);
$klasseId = $teile['klasse'] !== '' ? $this->klasseId($teile['klasse']) : null;
$ziel = self::umbuchungsziel($teile['kategorie']);
if ($ziel !== null) {
$gegen = $this->kontoId($ziel);
if ($gegen === null) {
$this->melde('fehler', 'Umbuchung auf unbekanntes Konto „' . $ziel . '“ – ohne Gegenkonto übernommen.');
return ['kategorie_id' => null, 'klasse_id' => $klasseId, 'gegenkonto_id' => null];
}
return [
'kategorie_id' => null,
'klasse_id' => $klasseId,
'gegenkonto_id' => $gegen === $kontoId ? null : $gegen,
];
}
return [
'kategorie_id' => $teile['kategorie'] !== '' ? $this->kategorieId($teile['kategorie']) : null,
'klasse_id' => $klasseId,
'gegenkonto_id' => null,
];
}
/** `[Konto]` oder `|[Konto]` (Wertpapiere) → Kontoname, sonst null. */
public static function umbuchungsziel(?string $angabe): ?string
{
if ($angabe === null) {
return null;
}
$angabe = ltrim(trim($angabe), '|');
if (!str_starts_with($angabe, '[')) {
return null;
}
$ende = strpos($angabe, ']');
return $ende === false ? null : substr($angabe, 1, $ende - 1);
}
/**
* Das Verrechnungskonto eines Wertpapiergeschäfts.
*
* Bei `!Type:Invst` steht in `L` mehr als bei einer gewöhnlichen Buchung:
* Dem Konto kann eine Kategorie vorangehen, getrennt durch einen senkrechten
* Strich – ein Verkauf trägt dort etwa
* `LKursgewinne:Realisierte Gewinne|[Verrechnungskonto]`. Deshalb zählt hier
* allein der Teil hinter dem letzten Strich.
*/
public static function verrechnungskonto(?string $angabe): ?string
{
if ($angabe === null) {
return null;
}
$strich = strrpos($angabe, '|');
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
* hinweg stabil, weil die Datei immer dieselbe Reihenfolge hat.
*/
private function eindeutigerHash(int $kontoId, string $datum, float $betrag, string $nummer, string $text): string
{
$basis = Buchung::hash($kontoId, $datum, $betrag, $nummer, $text);
$n = $this->hashZaehler[$basis] = ($this->hashZaehler[$basis] ?? 0) + 1;
return $n === 1 ? $basis : sha1($basis . '#' . $n);
}
private function ladeStammdaten(): void
{
$this->konten = [];
$this->kategorien = [];
$this->klassen = [];
foreach (Konto::alle() as $konto) {
$this->konten[$konto['name']] = (int)$konto['id'];
}
foreach (Kategorie::alle() as $kategorie) {
$this->kategorien[$kategorie['pfad']] = (int)$kategorie['id'];
}
foreach (Klasse::alle() as $klasse) {
$this->klassen[$klasse['name']] = (int)$klasse['id'];
}
}
private function kontoId(string $name): ?int
{
return $this->konten[trim($name)] ?? null;
}
/** Fehlt die Kategorie, wird sie angelegt – der Export nennt nicht immer alle. */
private function kategorieId(string $pfad): ?int
{
$pfad = Kategorie::normalisierePfad($pfad);
if ($pfad === '') {
return null;
}
if (isset($this->kategorien[$pfad])) {
return $this->kategorien[$pfad];
}
$id = Kategorie::findenOderAnlegenNachPfad($pfad);
$this->kategorien[$pfad] = $id;
$this->melde('hinweis', 'Kategorie „' . $pfad . '“ war nicht angelegt und wurde ergänzt.');
return $id;
}
private function klasseId(string $name): ?int
{
$name = trim($name);
if ($name === '') {
return null;
}
if (!isset($this->klassen[$name])) {
$this->klassen[$name] = Klasse::findenOderAnlegen($name);
}
return $this->klassen[$name];
}
private function istGeldbuchung(array $eintrag): bool
{
return in_array($eintrag['typ'], self::GELDTYPEN, true) && $eintrag['konto'] !== null;
}
private function melde(string $typ, string $text): void
{
// Dieselbe Meldung nicht hundertfach wiederholen.
foreach ($this->meldungen as $vorhanden) {
if ($vorhanden['text'] === $text) {
return;
}
}
$this->meldungen[] = ['typ' => $typ, 'text' => $text];
}
}