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>
169 lines
5.4 KiB
PHP
169 lines
5.4 KiB
PHP
<?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;
|
||
}
|
||
}
|