diff --git a/Changes.md b/Changes.md index 59dc7a0..e067190 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,18 @@ --- +## 2026-09-20 – Analyse prüft das Importskript (#209) + +- **`scripts/analyse-nextcloud.sh`:** neuer Abschnitt 6 prüft, ob `scripts/container/nextcloud-import.php` im Container existiert und syntaktisch ladbar ist (`php -l`, ohne Ausführung), und nennt den Pfad, den der Host-Cronjob verwenden muss. Fehlt das Skript, endet die Analyse mit Exit 1 – auch bei erfolgreicher Verbindung. Ein `--dry-run`-Schalter für den Import wurde nicht ergänzt. +- **`tests/75_AnalyseNextcloudSkriptTest.php`:** prüft die Existenzabfrage und dass das Importskript nie ausgeführt wird. + +## 2026-09-20 – Überwachung des Nextcloud-Imports (#208) + +- **`migrations/096_nextcloud_watchdog.sql`** (neu): `company_settings.nextcloud_last_run` (Zeitstempel des letzten Laufs) und `nextcloud_alarm_stunden` (Schwelle, Default 24, 0 = aus). +- **`src/Services/NextcloudWatchdog.php`** (neu): Totmannschalter. `NextcloudImportService::import()` setzt nach erfolgreichem PROPFIND den Zeitstempel – auch bei stillen Läufen ohne Ereignis. Ist er älter als die Schwelle (oder nie gesetzt, bei konfigurierter Nextcloud), erscheint eine Warnung oben auf `/ereignisse` und ein ⚠ am Nav-Eintrag „Ereignisse". +- **Einstellungen:** Feld „Import-Überwachung" im Nextcloud-Abschnitt. +- **`tests/101_NextcloudWatchdogTest.php`** (neu): Schwellenlogik gegen künstlich alte Zeitstempel. + ## 2026-09-20 – Gezahlte Vorsteuer als Betriebsausgabe in der EÜR (EÜR-Zeile 57 / Kz 185) - **`migrations/095_vorsteuerkonten_betriebsausgabe.sql`** (neu): Die Konten 1571/1574/1576 (abziehbare Vorsteuer) lagen als `Zahlungskonto` im Kontenrahmen und wurden vom `EuerCalculator` daher ignoriert – die auf ihnen gebuchte Vorsteuer tauchte in der EÜR nirgends auf. Da die vereinnahmte USt seit Migration 076 als Betriebseinnahme (Zeile 17) und die ans Finanzamt gezahlte USt seit Migration 077 als Betriebsausgabe (Zeile 58) geführt werden, war die Bruttomethode unvollständig und der ausgewiesene Gewinn um die gezahlte Vorsteuer zu hoch. Alle drei Konten sind jetzt `Betriebsausgabe` mit `euer_zeile = '57'`. Auch 1574 (Vorsteuer aus ig. Erwerb) muss mit: sein Gegenkonto 1774 ist seit Migration 076 Betriebseinnahme, bei Reverse-Charge zählte also nur die Erwerbsteuer als Einnahme – die Buchung war entgegen der Absicht nicht ergebnisneutral. diff --git a/docs/funktionen.md b/docs/funktionen.md index 323ccc7..6b0e39f 100644 --- a/docs/funktionen.md +++ b/docs/funktionen.md @@ -14,7 +14,7 @@ Vollständige Kontenliste: [kontenrahmen.md](kontenrahmen.md) - SHA-256 Integritätsprüfung je Datei - Automatischer Weißrand-Zuschnitt bei JPEG/PNG via GD - KI-gestützter Kontierungsvorschlag (optional, siehe [konfiguration.md](konfiguration.md)) -- Nextcloud-Import via WebDAV (optional) +- Nextcloud-Import via WebDAV (optional); Überwachung: Warnung in Ereignisse/Nav, wenn kein Lauf mehr stattfand (Schwelle einstellbar, Standard 24 Std.) - Belege können nicht gelöscht werden, solange Buchungen darauf verweisen (GoBD) --- diff --git a/docs/konfiguration.md b/docs/konfiguration.md index 9710ce4..e0b50d0 100644 --- a/docs/konfiguration.md +++ b/docs/konfiguration.md @@ -96,7 +96,7 @@ sudo usermod -aG docker $USER ### Verbindung analysieren -Meldet der Import „PROPFIND fehlgeschlagen" oder findet er keine Dateien, grenzt `scripts/analyse-nextcloud.sh` die Ursache ein. Das Skript wird auf dem Host aufgerufen (nicht im Container) und arbeitet rein lesend – es importiert, verschiebt und löscht nichts. Das Passwort wird nie im Klartext ausgegeben. +Meldet der Import „PROPFIND fehlgeschlagen" oder findet er keine Dateien, grenzt `scripts/analyse-nextcloud.sh` die Ursache ein. Das Skript wird auf dem Host aufgerufen (nicht im Container) und arbeitet rein lesend – es importiert, verschiebt und löscht nichts. Das Passwort wird nie im Klartext ausgegeben. Zum Schluss prüft die Analyse, ob `scripts/container/nextcloud-import.php` im Container vorhanden und ladbar ist (ohne es auszuführen), und nennt den Pfad, den der Host-Cronjob verwenden muss. ```bash ./scripts/analyse-nextcloud.sh # Konfiguration, DNS, Verbindungsversuch diff --git a/migrations/096_nextcloud_watchdog.sql b/migrations/096_nextcloud_watchdog.sql new file mode 100644 index 0000000..d6b5f44 --- /dev/null +++ b/migrations/096_nextcloud_watchdog.sql @@ -0,0 +1,6 @@ +-- Totmannschalter für den Nextcloud-Import (Issue #208). +-- nextcloud_last_run: Zeitstempel des letzten Importlaufs (auch ohne Dateiaktivität) +-- nextcloud_alarm_stunden: Schwelle in Stunden, 0 = Überwachung aus +ALTER TABLE company_settings + ADD COLUMN IF NOT EXISTS nextcloud_last_run DATETIME NULL, + ADD COLUMN IF NOT EXISTS nextcloud_alarm_stunden SMALLINT UNSIGNED NOT NULL DEFAULT 24; diff --git a/scripts/analyse-nextcloud.sh b/scripts/analyse-nextcloud.sh index 49acc5d..6a1b154 100755 --- a/scripts/analyse-nextcloud.sh +++ b/scripts/analyse-nextcloud.sh @@ -7,13 +7,16 @@ # Schlägt der fehl, grenzt das Script die Ursache über Gegenproben ein: # ohne IP-Pinning, nur IPv4, nur IPv6. # +# Zuletzt wird geprüft, ob das Importskript im Container existiert und ladbar +# ist (der Cronjob muss denselben Pfad verwenden). +# # Rein lesend – es werden keine Belege importiert, verschoben oder gelöscht. # Das Passwort wird nie im Klartext ausgegeben. # # Usage: # ./analyse-nextcloud.sh [--verbose] # -# Exit-Code: 0 = Verbindung steht, 1 = Konfigurations- oder Verbindungsfehler +# Exit-Code: 0 = Verbindung steht und Importskript vorhanden, 1 = Konfigurations- oder Verbindungsfehler # ════════════════════════════════════════════════════════════════════════════ set -euo pipefail @@ -145,6 +148,42 @@ function isOk(array $r): bool return $r['errno'] === 0 && $r['code'] >= 200 && $r['code'] < 300; } +/** + * Prüft, ob der Importlauf selbst startbar ist. Die Analyse läuft über die + * Standardeingabe und braucht das Importskript nicht – ein verschobenes oder + * fehlendes Skript bliebe sonst unbemerkt, obwohl der Cronjob ins Leere läuft. + * Das Skript wird nur auf Syntax geprüft (php -l), nicht ausgeführt. + */ +$checkImportScript = static function () use ($section, $green, $red, $bold): bool { + $section('6. Importskript (Cronjob-Ziel)'); + + $rel = 'scripts/container/nextcloud-import.php'; + $path = '/var/www/html/' . $rel; + + if (!is_file($path)) { + echo ' ' . $red('✗') . ' ' . $rel . ' fehlt im Container.' . PHP_EOL; + echo ' Der Cronjob findet das Skript nicht und der Import läuft nie.' . PHP_EOL; + echo ' Auch prüfen, ob der Bind-Mount ./scripts:/var/www/html/scripts den Image-Inhalt verdeckt.' . PHP_EOL; + return false; + } + + echo ' ' . $green('✓') . ' ' . $rel . ' vorhanden' . PHP_EOL; + + $output = []; + exec(escapeshellarg(PHP_BINARY) . ' -l ' . escapeshellarg($path) . ' 2>&1', $output, $code); + if ($code !== 0) { + echo ' ' . $red('✗') . ' Das Skript ist nicht ladbar: ' . implode(' ', $output) . PHP_EOL; + return false; + } + echo ' ' . $green('✓') . ' Syntax gültig' . PHP_EOL; + + echo PHP_EOL . ' ' . $bold('Hinweis:') . ' Der Host-Cronjob muss genau diesen Pfad verwenden:' . PHP_EOL; + echo ' docker compose exec -T web php ' . $rel . PHP_EOL; + echo ' Mit `crontab -l` auf dem Host abgleichen.' . PHP_EOL; + + return true; +}; + // ── 1. Konfiguration ──────────────────────────────────────────────────────── $section('1. Konfiguration (Einstellungen → Nextcloud-Belegimport)'); @@ -272,6 +311,7 @@ if (!isOk($result)) { echo ' Netzwerkverbindung, Firewall und TLS-Zertifikat der Nextcloud prüfen.' . PHP_EOL; } + $checkImportScript(); exit(1); } @@ -328,6 +368,13 @@ if ($done !== null) { } } +$importOk = $checkImportScript(); + +if (!$importOk) { + echo PHP_EOL . ' ' . $red('Die Verbindung steht, aber der Importlauf ist nicht startbar.') . PHP_EOL . PHP_EOL; + exit(1); +} + echo PHP_EOL . ' ' . $green('Die Verbindung zur Nextcloud funktioniert.') . PHP_EOL . PHP_EOL; exit(0); PHPEOF diff --git a/src/Controllers/EventController.php b/src/Controllers/EventController.php index 08eadbc..385368f 100644 --- a/src/Controllers/EventController.php +++ b/src/Controllers/EventController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Steuerkram\Controllers; use Steuerkram\Models\EventLog; +use Steuerkram\Services\NextcloudWatchdog; use Steuerkram\View; class EventController extends BaseController @@ -23,6 +24,7 @@ class EventController extends BaseController 'events' => $events, 'filter' => $filter, 'channels' => EventLog::channels(), + 'watchdog' => NextcloudWatchdog::warning(), 'eventsHtml' => $this->renderView('ereignisse/_body', [ 'events' => $events, ]), diff --git a/src/Models/Setting.php b/src/Models/Setting.php index 1d24851..30e6cde 100644 --- a/src/Models/Setting.php +++ b/src/Models/Setting.php @@ -42,7 +42,7 @@ class Setting invoice_nr_prefix=?, beleg_nr_prefix=?, anlage_nr_prefix=?, customer_nr_prefix=?, payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=?, nextcloud_url=?, nextcloud_user=?, nextcloud_pass=?, - nextcloud_folder=?, nextcloud_done=?, + nextcloud_folder=?, nextcloud_done=?, nextcloud_alarm_stunden=?, mail_smtp_host=?, mail_smtp_port=?, mail_smtp_encryption=?, mail_smtp_user=?, mail_smtp_pass=?, mail_from_name=?, mail_from_email=?, @@ -80,6 +80,7 @@ class Setting !empty($data['nextcloud_pass']) ? Crypto::encrypt($data['nextcloud_pass']) : null, ($data['nextcloud_folder'] ?? '') ?: '/Belege/Import', ($data['nextcloud_done'] ?? '') ?: null, + max(0, min(8760, (int)($data['nextcloud_alarm_stunden'] ?? 24))), ($data['mail_smtp_host'] ?? '') ?: '', (int)($data['mail_smtp_port'] ?? 587), in_array($data['mail_smtp_encryption'] ?? '', ['tls', 'ssl', 'none']) ? $data['mail_smtp_encryption'] : 'tls', diff --git a/src/Services/NextcloudImportService.php b/src/Services/NextcloudImportService.php index 68166ac..02cc8b1 100644 --- a/src/Services/NextcloudImportService.php +++ b/src/Services/NextcloudImportService.php @@ -115,6 +115,8 @@ class NextcloudImportService return $result; } + NextcloudWatchdog::markRun(); + $result['details'][] = count($files) . ' Datei(en) im Import-Ordner gefunden.'; foreach ($files as $file) { diff --git a/src/Services/NextcloudWatchdog.php b/src/Services/NextcloudWatchdog.php new file mode 100644 index 0000000..597622d --- /dev/null +++ b/src/Services/NextcloudWatchdog.php @@ -0,0 +1,72 @@ + + +
docker compose exec web php scripts/container/nextcloud-import.php
diff --git a/tests/101_NextcloudWatchdogTest.php b/tests/101_NextcloudWatchdogTest.php
new file mode 100644
index 0000000..323ea9b
--- /dev/null
+++ b/tests/101_NextcloudWatchdogTest.php
@@ -0,0 +1,51 @@
+assert(
+ NextcloudWatchdog::evaluate($url, '2026-09-20 11:55:00', 24, $now) === null,
+ 'Frischer Lauf: keine Warnung'
+ );
+ $this->assert(
+ NextcloudWatchdog::evaluate($url, '2026-09-19 12:00:00', 24, $now) === null,
+ 'Genau an der Schwelle: keine Warnung'
+ );
+ $alt = NextcloudWatchdog::evaluate($url, '2026-08-30 12:00:00', 24, $now);
+ $this->assertNotEmpty($alt, 'Drei Wochen alter Lauf: Warnung');
+ $this->assert(str_contains((string)$alt, '30.08.2026'), 'Warnung nennt den letzten Lauf');
+ $this->assert(
+ NextcloudWatchdog::evaluate($url, '2026-08-30 12:00:00', 0, $now) === null,
+ 'Schwelle 0 schaltet die Überwachung ab'
+ );
+ $this->assert(
+ NextcloudWatchdog::evaluate('', null, 24, $now) === null,
+ 'Ohne konfigurierte Nextcloud keine Warnung'
+ );
+ $this->assertNotEmpty(
+ NextcloudWatchdog::evaluate($url, null, 24, $now),
+ 'Konfiguriert, aber nie gelaufen: Warnung'
+ );
+
+ // Zeitstempel in der Datenbank
+ \Steuerkram\Database::execute("UPDATE company_settings SET nextcloud_url = ?, nextcloud_alarm_stunden = 24, nextcloud_last_run = NULL WHERE id = 1", [$url]);
+ $this->assertNotEmpty(NextcloudWatchdog::warning(), 'DB: ohne Zeitstempel Warnung');
+ NextcloudWatchdog::markRun();
+ $this->assert(NextcloudWatchdog::warning() === null, 'DB: nach markRun() keine Warnung');
+ \Steuerkram\Database::execute("UPDATE company_settings SET nextcloud_last_run = NOW() - INTERVAL 3 DAY WHERE id = 1");
+ $this->assertNotEmpty(NextcloudWatchdog::warning(), 'DB: 3 Tage alter Zeitstempel löst Warnung aus');
+ }
+}
diff --git a/tests/75_AnalyseNextcloudSkriptTest.php b/tests/75_AnalyseNextcloudSkriptTest.php
index 6a96331..2a11b63 100644
--- a/tests/75_AnalyseNextcloudSkriptTest.php
+++ b/tests/75_AnalyseNextcloudSkriptTest.php
@@ -37,9 +37,21 @@ class AnalyseNextcloudSkriptTest extends TestCase
// Verweise auf NextcloudImportService im Kommentar sind erwünscht (die cURL-Optionen
// müssen dort synchron gehalten werden) – ausgeführt werden darf der Import nicht.
$this->assert(
- !str_contains($content, 'nextcloud-import.php') && !str_contains($content, 'new NextcloudImportService'),
+ !str_contains($content, 'new NextcloudImportService'),
'Skript startet keinen Importlauf'
);
+ // Das Importskript wird nie ausgeführt – nur per Syntaxprüfung (php -l) angefasst.
+ $this->assert(
+ !preg_match('/\b(passthru|shell_exec|system|proc_open|include|require)\b[^;\n]*nextcloud-import/', $content)
+ && str_contains($content, "' -l '"),
+ 'Skript führt das Importskript nicht aus, sondern prüft nur die Syntax'
+ );
+
+ // Die Analyse muss die Existenz des Importskripts im Container prüfen.
+ $this->assert(
+ str_contains($content, 'scripts/container/nextcloud-import.php') && str_contains($content, 'is_file($path)'),
+ 'Skript prüft, ob scripts/container/nextcloud-import.php im Container existiert'
+ );
// Das Passwort dient ausschließlich als cURL-Zugangsdatum. In Ausgaben darf
// es nur als Länge (strlen) auftauchen, nie im Klartext.