Finanzmanager/src/Services/Housekeeping.php
fds2610-bot 2c86e8522b Import und Export in QIF und CSV, Housekeeping für Importdateien
Aus der Kachel QIF-Import wird Import/Export mit je einem Knopf für beide
Richtungen; davor steht die Wahl des Dateiformats (QIF oder CSV), die als
Dialog und ohne Skript als eigene Seite funktioniert.

- CsvBuchungsImport/CsvTabelle: Buchungen samt Splitzeilen, Spalten über die
  Kopfzeile, dieselbe Dublettenerkennung und der Trockenlauf wie beim QIF.
- BuchungsExport/ExportController: Filter nach Zeitraum, Konto, Kategorie und
  Klasse; die Datei entsteht im Speicher, ein Exportverzeichnis gibt es nicht.
- Housekeeping: Hochgeladene Importdateien und data/tmp werden nach der
  eingestellten Frist gelöscht (Vorgabe 14 Tage, 0 schaltet ab).

Migration 013, Test 29 sowie E2E-Spec importexport.spec.js.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:01:30 +02:00

169 lines
5.4 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\Models\Einstellung;
/**
* Räumt die temporären Dateien weg, die Import und Export hinterlassen.
*
* Exportdateien entstehen im Speicher und gehen unmittelbar an den Browser –
* dort bleibt nichts liegen. Beim Import ist es umgekehrt: Die hochgeladene
* Datei bleibt absichtlich stehen, damit sich derselbe Export erst im
* Trockenlauf prüfen und danach übernehmen lässt, ohne ihn zweimal hochzuladen.
* Genau dadurch sammeln sich dort mit der Zeit Kontoauszüge an – Dateien mit
* allen Buchungen des Haushalts, die niemand mehr braucht.
*
* Deshalb dieses Aufräumen: Was älter ist als die eingestellte Frist, wird
* gelöscht. Angestoßen wird es beiläufig beim Öffnen von Import und Export
* (höchstens einmal am Tag) und zusätzlich von `scripts/housekeeping.php`, das
* sich in einen Cron hängen lässt.
*/
class Housekeeping
{
public const EINSTELLUNG_TAGE = 'housekeeping_tage';
public const EINSTELLUNG_LAUF = 'housekeeping_letzter_lauf';
/** Zwei Wochen: lang genug für einen Import, der über ein Wochenende liegt. */
public const STANDARD_TAGE = 14;
/**
* Wie viele Tage bleiben Dateien liegen? 0 schaltet das Aufräumen ab –
* dann bleibt alles stehen, bis es von Hand gelöscht wird.
*/
public static function tage(): int
{
return max(0, Einstellung::getInt(self::EINSTELLUNG_TAGE, self::STANDARD_TAGE));
}
/**
* Die überwachten Verzeichnisse, absolut. Das Importverzeichnis ist
* einstellbar; `data/tmp` ist der Platz für Arbeitsdateien, die ein Modul
* unterwegs braucht – es muss nicht existieren.
*
* @return array<string,string> Anzeigename → absoluter Pfad
*/
public static function verzeichnisse(): array
{
$wurzel = dirname(__DIR__, 2);
$import = trim((string)Einstellung::get('import_qif_verzeichnis', 'data/import'), '/');
return [
$import => $wurzel . '/' . $import,
'data/tmp' => $wurzel . '/data/tmp',
];
}
/** Verzeichnis für Arbeitsdateien; wird bei Bedarf angelegt. */
public static function tempVerzeichnis(): string
{
$pfad = dirname(__DIR__, 2) . '/data/tmp';
if (!is_dir($pfad)) {
mkdir($pfad, 0o775, true);
}
return $pfad;
}
/** Lief das Aufräumen heute schon? */
public static function faellig(?string $heute = null): bool
{
if (self::tage() === 0) {
return false;
}
$heute = $heute ?? date('Y-m-d');
$letzter = (string)Einstellung::get(self::EINSTELLUNG_LAUF, '');
return substr($letzter, 0, 10) !== $heute;
}
/**
* Beiläufiger Lauf: höchstens einmal am Tag, und ohne dass ein Fehler die
* aufrufende Seite mitreißt – wer den Import öffnet, will importieren, nicht
* eine Fehlermeldung übers Aufräumen lesen.
*
* @return array<string,mixed>|null Das Ergebnis, oder null wenn nichts anstand.
*/
public static function beiGelegenheit(): ?array
{
if (!self::faellig()) {
return null;
}
try {
return self::ausfuehren();
} catch (\Throwable $e) {
DebugLog::schreibe('housekeeping', 'Aufräumen fehlgeschlagen: ' . $e->getMessage());
return null;
}
}
/**
* @return array{tage: int, geprueft: int, geloescht: int, bytes: int,
* dateien: list<string>, trockenlauf: bool}
*/
public static function ausfuehren(bool $trockenlauf = false): array
{
$tage = self::tage();
$grenze = time() - $tage * 86400;
$ergebnis = [
'tage' => $tage,
'geprueft' => 0,
'geloescht' => 0,
'bytes' => 0,
'dateien' => [],
'trockenlauf' => $trockenlauf,
];
if ($tage === 0) {
return $ergebnis;
}
foreach (self::verzeichnisse() as $name => $pfad) {
$echt = realpath($pfad);
if ($echt === false || !is_dir($echt)) {
continue;
}
foreach (scandir($echt) ?: [] as $eintrag) {
if ($eintrag === '.' || $eintrag === '..') {
continue;
}
$datei = $echt . '/' . $eintrag;
// Nur gewöhnliche Dateien direkt im Verzeichnis: ein Symlink
// zeigte womöglich aus dem überwachten Bereich heraus, und ein
// Unterverzeichnis hat hier niemand angelegt.
if (is_link($datei) || !is_file($datei)) {
continue;
}
$ergebnis['geprueft']++;
$alter = filemtime($datei);
if ($alter === false || $alter > $grenze) {
continue;
}
$groesse = (int)filesize($datei);
if (!$trockenlauf && !@unlink($datei)) {
continue;
}
$ergebnis['geloescht']++;
$ergebnis['bytes'] += $groesse;
$ergebnis['dateien'][] = $name . '/' . $eintrag;
}
}
if (!$trockenlauf) {
Einstellung::set(self::EINSTELLUNG_LAUF, date('Y-m-d H:i:s'), 'import');
}
return $ergebnis;
}
}