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>
182 lines
6 KiB
PHP
182 lines
6 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Finanzmanager\Controllers;
|
||
|
||
use Finanzmanager\Models\Einstellung;
|
||
use Finanzmanager\Services\QifParser;
|
||
use Finanzmanager\Services\QifStammdatenImport;
|
||
use Finanzmanager\View;
|
||
|
||
/**
|
||
* QIF-Import der Stammdaten (Klassen, Kategorien, Konten).
|
||
*
|
||
* Hochgeladene Dateien landen im Importverzeichnis und bleiben dort – so lässt
|
||
* sich derselbe Export erst im Trockenlauf prüfen und danach übernehmen, ohne
|
||
* ihn zweimal hochzuladen. Buchungen folgen in Phase 2.
|
||
*/
|
||
class ImportController extends BaseController
|
||
{
|
||
private const ERLAUBTE_ENDUNGEN = ['qif'];
|
||
|
||
public function index(): void
|
||
{
|
||
$this->verlangeAnmeldung();
|
||
|
||
$this->render('verwaltung/import.php', [
|
||
'titel' => 'QIF-Import',
|
||
'dateien' => $this->dateien(),
|
||
'verzeichnis' => $this->verzeichnisAnzeige(),
|
||
'ergebnis' => null,
|
||
'datei' => '',
|
||
]);
|
||
}
|
||
|
||
public function hochladen(): void
|
||
{
|
||
$this->verlangeAnmeldung();
|
||
|
||
$datei = $_FILES['qif'] ?? null;
|
||
if (!is_array($datei) || ($datei['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||
View::redirect('/verwaltung/import', $this->uploadFehler($datei['error'] ?? UPLOAD_ERR_NO_FILE), 'fehler');
|
||
return;
|
||
}
|
||
|
||
$name = self::sichererDateiname((string)$datei['name']);
|
||
if ($name === null) {
|
||
View::redirect('/verwaltung/import', 'Nur .QIF-Dateien können hochgeladen werden.', 'fehler');
|
||
return;
|
||
}
|
||
|
||
$ziel = $this->verzeichnis() . '/' . $name;
|
||
if (!is_uploaded_file((string)$datei['tmp_name']) || !move_uploaded_file((string)$datei['tmp_name'], $ziel)) {
|
||
View::redirect('/verwaltung/import', 'Die Datei konnte nicht abgelegt werden.', 'fehler');
|
||
return;
|
||
}
|
||
|
||
View::redirect('/verwaltung/import', 'Datei „' . $name . '“ hochgeladen.');
|
||
}
|
||
|
||
public function ausfuehren(): void
|
||
{
|
||
$this->verlangeAnmeldung();
|
||
|
||
$name = self::sichererDateiname($this->post('datei'));
|
||
if ($name === null) {
|
||
View::redirect('/verwaltung/import', 'Keine Datei ausgewählt.', 'fehler');
|
||
return;
|
||
}
|
||
|
||
$pfad = $this->verzeichnis() . '/' . $name;
|
||
if (!is_file($pfad)) {
|
||
View::redirect('/verwaltung/import', 'Die Datei „' . $name . '“ gibt es nicht mehr.', 'fehler');
|
||
return;
|
||
}
|
||
|
||
$trockenlauf = $this->post('modus') !== 'import';
|
||
|
||
try {
|
||
$parser = QifParser::ausDatei($pfad);
|
||
$importer = new QifStammdatenImport($parser);
|
||
$ergebnis = $importer->ausfuehren($trockenlauf);
|
||
} catch (\Throwable $e) {
|
||
View::redirect('/verwaltung/import', 'Import fehlgeschlagen: ' . $e->getMessage(), 'fehler');
|
||
return;
|
||
}
|
||
|
||
$this->render('verwaltung/import.php', [
|
||
'titel' => 'QIF-Import',
|
||
'dateien' => $this->dateien(),
|
||
'verzeichnis' => $this->verzeichnisAnzeige(),
|
||
'ergebnis' => $ergebnis + ['abschnitte' => $parser->zusammenfassung()],
|
||
'datei' => $name,
|
||
]);
|
||
}
|
||
|
||
/** @param array<string,string> $params */
|
||
public function loeschen(array $params): void
|
||
{
|
||
$this->verlangeAnmeldung();
|
||
|
||
$name = self::sichererDateiname($params['datei'] ?? '');
|
||
if ($name !== null) {
|
||
$pfad = $this->verzeichnis() . '/' . $name;
|
||
if (is_file($pfad)) {
|
||
unlink($pfad);
|
||
}
|
||
}
|
||
|
||
View::redirect('/verwaltung/import', 'Datei gelöscht.');
|
||
}
|
||
|
||
/**
|
||
* Nimmt nur den Dateinamen ohne Pfadanteile und lässt allein die erlaubten
|
||
* Endungen durch – der Name landet ungeprüft in einer Pfadangabe.
|
||
*/
|
||
public static function sichererDateiname(string $eingabe): ?string
|
||
{
|
||
$name = basename(trim($eingabe));
|
||
if ($name === '' || $name === '.' || $name === '..' || str_contains($name, "\0")) {
|
||
return null;
|
||
}
|
||
|
||
$endung = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||
if (!in_array($endung, self::ERLAUBTE_ENDUNGEN, true)) {
|
||
return null;
|
||
}
|
||
|
||
// Alles außer Buchstaben, Ziffern, Punkt, Strich und Unterstrich fällt
|
||
// weg; damit bleiben keine Sonderzeichen für die Shell oder das
|
||
// Dateisystem übrig.
|
||
$name = preg_replace('/[^A-Za-z0-9._-]/', '_', $name) ?? '';
|
||
|
||
return $name === '' ? null : $name;
|
||
}
|
||
|
||
/** @return list<array{name: string, groesse: int, geaendert: int}> */
|
||
private function dateien(): array
|
||
{
|
||
$verzeichnis = $this->verzeichnis();
|
||
$dateien = [];
|
||
|
||
foreach (glob($verzeichnis . '/*.[qQ][iI][fF]') ?: [] as $pfad) {
|
||
$dateien[] = [
|
||
'name' => basename($pfad),
|
||
'groesse' => (int)filesize($pfad),
|
||
'geaendert' => (int)filemtime($pfad),
|
||
];
|
||
}
|
||
|
||
usort($dateien, static fn(array $a, array $b): int => $b['geaendert'] <=> $a['geaendert']);
|
||
return $dateien;
|
||
}
|
||
|
||
private function verzeichnis(): 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;
|
||
}
|
||
|
||
private function verzeichnisAnzeige(): string
|
||
{
|
||
return (string)Einstellung::get('import_qif_verzeichnis', 'data/import');
|
||
}
|
||
|
||
private function uploadFehler(int $code): string
|
||
{
|
||
return match ($code) {
|
||
UPLOAD_ERR_NO_FILE => 'Es wurde keine Datei ausgewählt.',
|
||
UPLOAD_ERR_INI_SIZE,
|
||
UPLOAD_ERR_FORM_SIZE => 'Die Datei ist zu groß.',
|
||
UPLOAD_ERR_PARTIAL => 'Die Datei wurde nur teilweise übertragen.',
|
||
default => 'Der Upload ist fehlgeschlagen (Code ' . $code . ').',
|
||
};
|
||
}
|
||
}
|