Konten, Kontengruppen, Kategorien und Klassen samt Übernahme aus dem
QIF-Export des Lexware FinanzManagers.
Kontenübersicht nach Kontengruppen gebündelt, mit Gruppensummen und
Gesamtsumme; die drei Saldospalten (Online-Saldo, aktueller und erwarteter
Saldo) sind angelegt und werden bis Phase 2 aus dem Anfangssaldo gespeist.
Kategorien sind hierarchisch mit ":" als Trenner. Der vollständige Pfad
steht redundant in der Tabelle, weil jede Anzeige und der gesamte Import
darüber laufen; Umbenennen und Verschieben schreiben den Teilbaum neu. Die
Art (Einnahme/Ausgabe) gilt für den ganzen Ast. Register wie im Original:
Alle, Einnahmen, Ausgaben, Umbuchungskonten.
QifParser liest zustandsbehaftet und sequenziell, wandelt ISO-8859-1 nach
UTF-8 und versteht US-Beträge sowie das Datumsformat aus der Kopfzeile.
QifStammdatenImport ist idempotent und bietet Trockenlauf und Protokoll -
im Dialog unter Verwaltung sowie über scripts/qif-import.php. Am echten
Export geprüft: 17 Klassen, 105 Kategorien, 37 Konten.
Enthält außerdem die Korrektur der englischen Monatsnamen im
Einstellungsdialog: date('F') ist sprachlich nicht umschaltbar, deshalb
liegen die Namen jetzt zentral als View::MONATE vor. Die Änderung ließ
sich nicht als eigener Commit trennen, weil sie dieselben Dateien berührt
wie Phase 1.
Tests: 227 PHP-Prüfungen und 14 Playwright-Tests, alle grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
229 lines
7.8 KiB
PHP
229 lines
7.8 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Finanzmanager\Services;
|
||
|
||
use Finanzmanager\Database;
|
||
use Finanzmanager\Models\Kategorie;
|
||
use Finanzmanager\Models\Klasse;
|
||
use Finanzmanager\Models\Konto;
|
||
|
||
/**
|
||
* Übernimmt Klassen, Kategorien und Konten aus einem QIF-Export.
|
||
*
|
||
* Buchungen bleiben Phase 2 vorbehalten; dieser Importeur ist bewusst
|
||
* idempotent: was es schon gibt, wird gezählt und übersprungen, nie doppelt
|
||
* angelegt. Ein zweiter Lauf derselben Datei ändert also nichts.
|
||
*/
|
||
class QifStammdatenImport
|
||
{
|
||
/** Beschreibungen der Form "Kontonr. 1234567" tragen die Kontonummer. */
|
||
private const KONTONUMMER_MUSTER = '/^Kontonr\.?\s*(\S+)$/u';
|
||
|
||
private QifParser $parser;
|
||
|
||
/** @var list<array{typ: string, text: string}> */
|
||
private array $meldungen = [];
|
||
|
||
public function __construct(QifParser $parser)
|
||
{
|
||
$this->parser = $parser;
|
||
}
|
||
|
||
public static function ausDatei(string $pfad): self
|
||
{
|
||
return new self(QifParser::ausDatei($pfad));
|
||
}
|
||
|
||
/**
|
||
* @return array{klassen: array<string,int>, kategorien: array<string,int>,
|
||
* konten: array<string,int>, meldungen: list<array{typ: string, text: string}>,
|
||
* trockenlauf: bool}
|
||
*/
|
||
public function ausfuehren(bool $trockenlauf = false): array
|
||
{
|
||
$this->meldungen = [];
|
||
|
||
$lauf = function () use ($trockenlauf): array {
|
||
return [
|
||
'klassen' => $this->klassen($trockenlauf),
|
||
'kategorien' => $this->kategorien($trockenlauf),
|
||
'konten' => $this->konten($trockenlauf),
|
||
'trockenlauf' => $trockenlauf,
|
||
];
|
||
};
|
||
|
||
// Beim echten Lauf gilt alles oder nichts – ein Abbruch mitten in der
|
||
// Kategorieliste hinterließe sonst einen halben Baum.
|
||
$ergebnis = $trockenlauf ? $lauf() : Database::transaktion($lauf);
|
||
|
||
$ergebnis['meldungen'] = $this->meldungen;
|
||
return $ergebnis;
|
||
}
|
||
|
||
/** @return array<string,int> */
|
||
private function klassen(bool $trockenlauf): array
|
||
{
|
||
$zaehler = ['neu' => 0, 'vorhanden' => 0, 'fehler' => 0];
|
||
$bekannte = array_column(Klasse::alle(), 'name');
|
||
|
||
foreach ($this->parser->eintraege(QifParser::TYP_KLASSE) as $eintrag) {
|
||
$name = trim((string)QifParser::feld($eintrag, 'N'));
|
||
if ($name === '') {
|
||
$zaehler['fehler']++;
|
||
$this->melde('fehler', 'Klasse ohne Namen übersprungen.');
|
||
continue;
|
||
}
|
||
|
||
if (in_array($name, $bekannte, true)) {
|
||
$zaehler['vorhanden']++;
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
if (!$trockenlauf) {
|
||
Klasse::anlegen($name, (string)QifParser::feld($eintrag, 'D'));
|
||
}
|
||
$bekannte[] = $name;
|
||
$zaehler['neu']++;
|
||
} catch (\Throwable $e) {
|
||
$zaehler['fehler']++;
|
||
$this->melde('fehler', 'Klasse „' . $name . '“: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
return $zaehler;
|
||
}
|
||
|
||
/** @return array<string,int> */
|
||
private function kategorien(bool $trockenlauf): array
|
||
{
|
||
$zaehler = ['neu' => 0, 'vorhanden' => 0, 'fehler' => 0];
|
||
|
||
// Im Trockenlauf werden auch die Elternstufen mitgezählt, die der
|
||
// Import selbst anlegen würde – sonst stimmte die Vorschau nicht.
|
||
$bekannte = array_flip(array_column(Kategorie::alle(), 'pfad'));
|
||
|
||
foreach ($this->parser->eintraege(QifParser::TYP_KATEGORIE) as $eintrag) {
|
||
$pfad = Kategorie::normalisierePfad((string)QifParser::feld($eintrag, 'N'));
|
||
if ($pfad === '') {
|
||
$zaehler['fehler']++;
|
||
$this->melde('fehler', 'Kategorie ohne Namen übersprungen.');
|
||
continue;
|
||
}
|
||
|
||
if (isset($bekannte[$pfad])) {
|
||
$zaehler['vorhanden']++;
|
||
continue;
|
||
}
|
||
|
||
$art = QifParser::hatFeld($eintrag, 'I') ? 'einnahme' : 'ausgabe';
|
||
$daten = [
|
||
'beschreibung' => (string)QifParser::feld($eintrag, 'D'),
|
||
'steuerrelevant' => QifParser::hatFeld($eintrag, 'T'),
|
||
'steuerformular' => (string)QifParser::feld($eintrag, 'R'),
|
||
];
|
||
|
||
try {
|
||
if (!$trockenlauf) {
|
||
Kategorie::findenOderAnlegenNachPfad($pfad, $art, $daten);
|
||
}
|
||
|
||
// Fehlende Zwischenstufen zählen als eigene neue Kategorien.
|
||
$teile = explode(Kategorie::TRENNER, $pfad);
|
||
$bisher = '';
|
||
foreach ($teile as $teil) {
|
||
$bisher = $bisher === '' ? $teil : $bisher . Kategorie::TRENNER . $teil;
|
||
if (!isset($bekannte[$bisher])) {
|
||
$bekannte[$bisher] = true;
|
||
$zaehler['neu']++;
|
||
}
|
||
}
|
||
} catch (\Throwable $e) {
|
||
$zaehler['fehler']++;
|
||
$this->melde('fehler', 'Kategorie „' . $pfad . '“: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
return $zaehler;
|
||
}
|
||
|
||
/** @return array<string,int> */
|
||
private function konten(bool $trockenlauf): array
|
||
{
|
||
$zaehler = ['neu' => 0, 'vorhanden' => 0, 'fehler' => 0];
|
||
$bekannte = array_flip(array_column(Konto::alle(), 'name'));
|
||
|
||
foreach ($this->parser->eintraege(QifParser::TYP_KONTO) as $eintrag) {
|
||
$name = trim((string)QifParser::feld($eintrag, 'N'));
|
||
if ($name === '') {
|
||
$zaehler['fehler']++;
|
||
$this->melde('fehler', 'Konto ohne Namen übersprungen.');
|
||
continue;
|
||
}
|
||
|
||
// In der Kontenliste steht jedes Konto einmal, danach leitet
|
||
// derselbe Eintrag noch einmal die Buchungen ein – der zweite
|
||
// Treffer ist keine Dublette, sondern derselbe Datensatz.
|
||
if (isset($bekannte[$name])) {
|
||
$zaehler['vorhanden']++;
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
if (!$trockenlauf) {
|
||
Konto::anlegen($this->kontoDaten($eintrag, $name));
|
||
}
|
||
$bekannte[$name] = true;
|
||
$zaehler['neu']++;
|
||
} catch (\Throwable $e) {
|
||
$zaehler['fehler']++;
|
||
$this->melde('fehler', 'Konto „' . $name . '“: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
return $zaehler;
|
||
}
|
||
|
||
/**
|
||
* @param array{zeilen: list<array{0: string, 1: string}>} $eintrag
|
||
* @return array<string,mixed>
|
||
*/
|
||
private function kontoDaten(array $eintrag, string $name): array
|
||
{
|
||
$qifTyp = (string)QifParser::feld($eintrag, 'T');
|
||
$beschreibung = trim((string)QifParser::feld($eintrag, 'D'));
|
||
$kontonummer = '';
|
||
|
||
if ($beschreibung !== '' && preg_match(self::KONTONUMMER_MUSTER, $beschreibung, $treffer) === 1) {
|
||
$kontonummer = $treffer[1];
|
||
$beschreibung = '';
|
||
}
|
||
|
||
$limit = QifParser::feld($eintrag, 'L');
|
||
|
||
return [
|
||
'name' => $name,
|
||
'kontoart' => Konto::artAusQif($qifTyp),
|
||
'beschreibung' => $beschreibung,
|
||
'kontonummer' => $kontonummer,
|
||
'kreditlimit' => $limit === null ? null : QifParser::betrag($limit),
|
||
// Anfangssalden stehen nicht im Export; sie werden nach dem Import
|
||
// je Konto nachgetragen (PLAN.md, Kapitel 4).
|
||
'anfangssaldo' => 0,
|
||
'aktiv' => 1,
|
||
];
|
||
}
|
||
|
||
private function melde(string $typ, string $text): void
|
||
{
|
||
$this->meldungen[] = ['typ' => $typ, 'text' => $text];
|
||
}
|
||
|
||
/** Vorschau ohne Schreibzugriff. */
|
||
public function vorschau(): array
|
||
{
|
||
return $this->ausfuehren(true);
|
||
}
|
||
}
|