diff --git a/Changes.md b/Changes.md index 73d46b2..1fa7536 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,26 @@ --- +## 2026-06-29 – Feature: Ereignisse-Log für Nextcloud-Import (Issue #109) + +- **`migrations/083_system_events.sql`:** Neue Tabelle `system_events` (id, channel, message, created_at) +- **`src/Models/EventLog.php`:** Neues Model mit `add()` und `findByChannel()` +- **`src/Services/NextcloudImportService.php`:** Persistiert relevante Ereignisse (Import, Duplikat, Fehler) automatisch; stille Läufe ohne Dateiaktivität erzeugen keine Einträge; Zusammenfassung wird nur bei tatsächlicher Aktivität geloggt +- **`src/Controllers/EventController.php`:** Neuer Controller für `/ereignisse` und `/ereignisse/nextcloud-import` +- **`src/Views/ereignisse/nextcloud_import.php`:** Log-Ansicht mit Zeitstempel, Kanal und farbiger Meldung +- **`src/Views/layout/nav.php`:** Neuer Menüpunkt „Ereignisse" unter Verwaltung mit aufklappbarem Unterpunkt „Nextcloud-Import" + +--- + +## 2026-06-29 – Feature: Lieferant in Belegliste inline bearbeiten (Issue #108) + +- **`src/Views/belege/list.php`:** Lieferant-Spalte ist direkt in der Liste klickbar; zeigt ein Inline-Textfeld zur Eingabe, speichert mit Enter, bricht mit Escape ab; andere Spalten öffnen weiterhin die Vorschau +- **`src/Models/Beleg.php`:** Neue Methode `updateSupplier()` aktualisiert nur die `supplier_id` ohne andere Metadaten zu verändern +- **`src/Controllers/BelegController.php`:** Neue Methode `updateSupplier()` als AJAX-Endpunkt +- **`public/index.php`:** Route `POST /belege/{id}/supplier` registriert + +--- + ## 2026-06-11 – Fix: Undefined-Array-Key-Warning bei Buchungsvorlagen ohne Standard-Lieferant - **`src/Models/Buchungsvorlage.php`:** `create()`/`update()` prüften `$data['standard_lieferant']` direkt ohne Null-Coalescing, was bei fehlendem Schlüssel eine PHP-Warning auslöste diff --git a/migrations/083_system_events.sql b/migrations/083_system_events.sql new file mode 100644 index 0000000..cc6d149 --- /dev/null +++ b/migrations/083_system_events.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS system_events ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + channel VARCHAR(50) NOT NULL, + message TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_channel_created (channel, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/public/index.php b/public/index.php index 8a9ef53..09b54ef 100644 --- a/public/index.php +++ b/public/index.php @@ -28,6 +28,7 @@ use Steuerkram\Controllers\AssetController; use Steuerkram\Controllers\UserAdminController; use Steuerkram\Controllers\BankController; use Steuerkram\Controllers\BuchungsvorlageController; +use Steuerkram\Controllers\EventController; Config::load(); @@ -60,8 +61,9 @@ $router->post('/belege/upload', fn($p) => (new BelegController())->upload($p)); $router->get('/belege/{id}', fn($p) => (new BelegController())->show($p)); $router->get('/belege/{id}/file', fn($p) => (new BelegController())->serveFile($p)); $router->post('/belege/{id}/delete', fn($p) => (new BelegController())->destroy($p)); -$router->post('/belege/{id}/meta', fn($p) => (new BelegController())->updateMeta($p)); -$router->post('/belege/{id}/crop', fn($p) => (new BelegController())->cropImage($p)); +$router->post('/belege/{id}/meta', fn($p) => (new BelegController())->updateMeta($p)); +$router->post('/belege/{id}/supplier', fn($p) => (new BelegController())->updateSupplier($p)); +$router->post('/belege/{id}/crop', fn($p) => (new BelegController())->cropImage($p)); $router->post('/belege/{id}/vorschlag/generate', fn($p) => (new BelegController())->generateVorschlag($p)); $router->post('/belege/{id}/vorschlag/reset', fn($p) => (new BelegController())->resetVorschlag($p)); @@ -183,6 +185,10 @@ $router->post('/einstellungen/buchungsvorlagen/{id}/loeschen', fn($p) = $router->get('/api/buchungsvorlagen/{id}/modal', fn($p) => (new BuchungsvorlageController())->modal($p)); $router->post('/belege/{belegId}/vorlage/{vorlageId}/buchen', fn($p) => (new BuchungsvorlageController())->buchen($p)); +// ── Ereignisse ─────────────────────────────────────────────── +$router->get('/ereignisse', fn($p) => (new EventController())->index($p)); +$router->get('/ereignisse/nextcloud-import', fn($p) => (new EventController())->nextcloudImport($p)); + // ── Login / Logout ──────────────────────────────────────────── $router->get('/login', fn($p) => (new LoginController())->showLogin($p)); $router->post('/login', fn($p) => (new LoginController())->handleLogin($p)); diff --git a/src/Controllers/BelegController.php b/src/Controllers/BelegController.php index 4d145f9..eb98f58 100644 --- a/src/Controllers/BelegController.php +++ b/src/Controllers/BelegController.php @@ -366,6 +366,30 @@ class BelegController extends BaseController View::redirect("/belege/{$id}", 'Metadaten gespeichert.'); } + public function updateSupplier(array $params): void + { + header('Content-Type: application/json'); + $id = (int)$params['id']; + $beleg = Beleg::find($id); + + if (!$beleg) { + http_response_code(404); + echo json_encode(['error' => 'Nicht gefunden.']); + return; + } + if ($beleg['locked']) { + http_response_code(403); + echo json_encode(['error' => 'GoBD: Gesperrter Beleg.']); + return; + } + + $supplierName = trim($_POST['supplier_name'] ?? ''); + $supplierId = $supplierName !== '' ? Supplier::findOrCreate($supplierName) : null; + Beleg::updateSupplier($id, $supplierId); + + echo json_encode(['ok' => true, 'supplier_name' => $supplierName !== '' ? $supplierName : null]); + } + public function generateVorschlag(array $params): void { header('Content-Type: application/json'); diff --git a/src/Controllers/EventController.php b/src/Controllers/EventController.php new file mode 100644 index 0000000..c989236 --- /dev/null +++ b/src/Controllers/EventController.php @@ -0,0 +1,29 @@ +render('layout/base.php', [ + 'pageTitle' => 'Ereignisse – Nextcloud-Import', + 'content' => $this->renderView('ereignisse/nextcloud_import', [ + 'events' => $events, + ]), + ]); + } +} diff --git a/src/Models/Beleg.php b/src/Models/Beleg.php index aea9bc0..56781c7 100644 --- a/src/Models/Beleg.php +++ b/src/Models/Beleg.php @@ -231,6 +231,14 @@ class Beleg ); } + public static function updateSupplier(int $id, ?int $supplierId): void + { + Database::execute( + 'UPDATE belege SET supplier_id=?, updated_at=NOW() WHERE id=? AND locked=0', + [$supplierId, $id] + ); + } + public static function delete(int $id): bool { $beleg = self::find($id); diff --git a/src/Models/EventLog.php b/src/Models/EventLog.php new file mode 100644 index 0000000..6899012 --- /dev/null +++ b/src/Models/EventLog.php @@ -0,0 +1,30 @@ + 0, 'skipped' => 0, 'errors' => 0, 'details' => [], 'imported_ids' => []]; if (!$this->baseUrl || !$this->user || !$this->pass) { - $result['details'][] = 'FEHLER: NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_PASS müssen gesetzt sein.'; + $msg = 'FEHLER: NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_PASS müssen gesetzt sein.'; + $result['details'][] = $msg; $result['errors']++; + $this->persistEvent($msg); return $result; } $files = $this->listFiles(); if ($files === null) { - $result['details'][] = 'FEHLER: PROPFIND fehlgeschlagen. URL/Zugangsdaten prüfen.'; + $msg = 'FEHLER: PROPFIND fehlgeschlagen. URL/Zugangsdaten prüfen.'; + $result['details'][] = $msg; $result['errors']++; + $this->persistEvent($msg); return $result; } @@ -90,9 +97,24 @@ class NextcloudImportService $this->processFile($file, $result); } + if ($result['imported'] + $result['skipped'] + $result['errors'] > 0) { + $elapsed = round(microtime(true) - $start, 2); + $summary = sprintf( + 'Fertig in %ss – importiert: %d, übersprungen: %d, Fehler: %d', + $elapsed, $result['imported'], $result['skipped'], $result['errors'] + ); + $this->persistEvent($summary); + $result['details'][] = $summary; + } + return $result; } + private function persistEvent(string $message): void + { + EventLog::add('nextcloud-import', $message); + } + /** * Listet alle Dateien im konfigurierten Ordner via PROPFIND. * Gibt null zurück bei Verbindungsfehler. @@ -198,7 +220,9 @@ class NextcloudImportService // MIME prüfen if (!isset(self::ALLOWED_MIME[$contentType])) { - $result['details'][] = "Übersprungen (unbekannter Typ «{$contentType}»): {$filename}"; + $msg = "Übersprungen (unbekannter Typ «{$contentType}»): {$filename}"; + $result['details'][] = $msg; + $this->persistEvent($msg); $result['skipped']++; return; } @@ -210,7 +234,9 @@ class NextcloudImportService $tmpPath = sys_get_temp_dir() . '/nextcloud_import_' . uniqid() . '.' . $ext; if (!$this->downloadFile($fileUrl, $tmpPath)) { - $result['details'][] = "FEHLER beim Download: {$filename}"; + $msg = "FEHLER beim Download: {$filename}"; + $result['details'][] = $msg; + $this->persistEvent($msg); $result['errors']++; return; } @@ -222,7 +248,9 @@ class NextcloudImportService [$rawHash] ); if ($existing) { - $result['details'][] = "Duplikat (Beleg {$existing['beleg_nr']}): {$filename}"; + $msg = "Duplikat (Beleg {$existing['beleg_nr']}): {$filename}"; + $result['details'][] = $msg; + $this->persistEvent($msg); $result['skipped']++; unlink($tmpPath); $this->moveFile($file['href'], $filename); @@ -236,7 +264,9 @@ class NextcloudImportService $absDir = dirname(__DIR__, 2) . "/public/{$relDir}"; if (!is_dir($absDir) && !mkdir($absDir, 0755, true)) { - $result['details'][] = "FEHLER: Verzeichnis konnte nicht erstellt werden: {$absDir}"; + $msg = "FEHLER: Verzeichnis konnte nicht erstellt werden: {$absDir}"; + $result['details'][] = $msg; + $this->persistEvent($msg); $result['errors']++; unlink($tmpPath); return; @@ -246,7 +276,9 @@ class NextcloudImportService $absPath = dirname(__DIR__, 2) . "/public/{$storagePath}"; if (!rename($tmpPath, $absPath)) { - $result['details'][] = "FEHLER: Datei konnte nicht verschoben werden: {$filename}"; + $msg = "FEHLER: Datei konnte nicht verschoben werden: {$filename}"; + $result['details'][] = $msg; + $this->persistEvent($msg); $result['errors']++; @unlink($tmpPath); return; @@ -278,7 +310,9 @@ class NextcloudImportService // Datei in Nextcloud verschieben/löschen $this->moveFile($file['href'], $filename); - $result['details'][] = "Importiert: {$filename} → Beleg {$belegNr}"; + $msg = "Importiert: {$filename} → Beleg {$belegNr}"; + $result['details'][] = $msg; + $this->persistEvent($msg); $result['imported']++; $result['imported_ids'][] = $belegId; } diff --git a/src/Views/belege/list.php b/src/Views/belege/list.php index 3487c6d..b7f1600 100644 --- a/src/Views/belege/list.php +++ b/src/Views/belege/list.php @@ -162,7 +162,13 @@ - + @@ -245,4 +251,49 @@ }); }); })(); + +function blInlineSupplier(td) { + if (td.querySelector('input')) return; + + const current = td.dataset.supplier || ''; + const belegId = td.dataset.belegId; + let committed = false; + + td.textContent = ''; + const input = document.createElement('input'); + input.type = 'text'; + input.value = current; + input.style.cssText = 'width:100%;border:1px solid var(--color-primary,#3b82f6);border-radius:3px;padding:.1rem .3rem;font-size:inherit;background:#fff;box-sizing:border-box;color:inherit'; + + function commit(val) { + if (committed) return; + committed = true; + const fd = new FormData(); + fd.append('supplier_name', val); + fetch('/belege/' + belegId + '/supplier', { + method: 'POST', + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + body: fd, + }) + .then(r => r.json()) + .then(data => { + td.dataset.supplier = val; + td.textContent = val || '–'; + }) + .catch(() => { td.textContent = current || '–'; }); + } + + input.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { e.preventDefault(); commit(input.value.trim()); } + if (e.key === 'Escape') { committed = true; td.textContent = current || '–'; } + }); + + input.addEventListener('blur', function () { + commit(input.value.trim()); + }); + + td.appendChild(input); + input.focus(); + input.select(); +} diff --git a/src/Views/ereignisse/nextcloud_import.php b/src/Views/ereignisse/nextcloud_import.php new file mode 100644 index 0000000..3923423 --- /dev/null +++ b/src/Views/ereignisse/nextcloud_import.php @@ -0,0 +1,35 @@ + View::h($v); ?> + +
+
+ Nextcloud-Import + Einträge (neueste zuerst) +
+ +
Keine Ereignisse vorhanden. Einträge entstehen, sobald der Nextcloud-Import Dateien importiert, überspringt oder auf Fehler stößt.
+ +
+ + + + + + + + + + + +
[]
+
+ +
diff --git a/src/Views/layout/nav.php b/src/Views/layout/nav.php index a236fdd..baf2dae 100644 --- a/src/Views/layout/nav.php +++ b/src/Views/layout/nav.php @@ -84,6 +84,16 @@ function navActive(string $prefix, string $current): string { Benutzer + + + Ereignisse + + + + + Nextcloud-Import + + Einstellungen diff --git a/src/version.txt b/src/version.txt index 161bbd1..08f076b 100644 --- a/src/version.txt +++ b/src/version.txt @@ -1 +1 @@ -1-260611 +1-260629 diff --git a/tests/46_BelegLieferantInlineTest.php b/tests/46_BelegLieferantInlineTest.php new file mode 100644 index 0000000..1174fd3 --- /dev/null +++ b/tests/46_BelegLieferantInlineTest.php @@ -0,0 +1,55 @@ + 'TEST-SUPP-' . uniqid(), + 'filename' => 'inline-supplier-test.pdf', + 'storage_path' => '', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', 'inline-supplier-' . uniqid()), + 'beleg_datum' => date('Y-m-d'), + 'belegtyp' => 'Eingangsrechnung', + ]); + $this->assertGreaterThan(0, $belegId, 'Beleg angelegt'); + + // Kein Lieferant initial + $beleg = Beleg::find($belegId); + $this->assert($beleg['supplier_name'] === null || $beleg['supplier_name'] === '', 'Kein Lieferant initial'); + + // Lieferant setzen + $supplierId = Supplier::findOrCreate('Testfirma GmbH'); + $this->assertGreaterThan(0, $supplierId, 'Supplier angelegt oder gefunden'); + Beleg::updateSupplier($belegId, $supplierId); + + $beleg = Beleg::find($belegId); + $this->assertEquals('Testfirma GmbH', $beleg['supplier_name'], 'Lieferant nach updateSupplier gesetzt'); + + // Lieferant auf null zurücksetzen (löschen) + Beleg::updateSupplier($belegId, null); + $beleg = Beleg::find($belegId); + $this->assert( + $beleg['supplier_name'] === null || $beleg['supplier_name'] === '', + 'Lieferant nach Nullsetzung leer' + ); + + // updateSupplier darf bei gesperrtem Beleg nichts ändern + \Steuerkram\Database::execute('UPDATE belege SET locked=1 WHERE id=?', [$belegId]); + $supplierId2 = Supplier::findOrCreate('Gesperrte Firma'); + Beleg::updateSupplier($belegId, $supplierId2); + $beleg = Beleg::find($belegId); + $this->assert( + $beleg['supplier_name'] === null || $beleg['supplier_name'] === '', + 'Lieferant bei gesperrtem Beleg unverändert' + ); + } +} diff --git a/tests/47_SystemEventsTest.php b/tests/47_SystemEventsTest.php new file mode 100644 index 0000000..9dbb221 --- /dev/null +++ b/tests/47_SystemEventsTest.php @@ -0,0 +1,43 @@ +assertEquals(0, count($before), 'Kanal initial leer'); + + // Eintrag anlegen + EventLog::add($channel, 'Importiert: test.pdf → Beleg TST-0001'); + EventLog::add($channel, 'Duplikat (Beleg TST-0001): test.pdf'); + EventLog::add($channel, 'FEHLER beim Download: fehler.pdf'); + + $entries = EventLog::findByChannel($channel); + $this->assertEquals(3, count($entries), 'Drei Einträge vorhanden'); + + // Neueste zuerst (höchste id zuletzt hinzugefügt) + $this->assertEquals('FEHLER beim Download: fehler.pdf', $entries[0]['message'], 'Neuester Eintrag zuerst'); + $this->assertEquals($channel, $entries[0]['channel'], 'Kanal korrekt gesetzt'); + + // created_at vorhanden und parseable + $ts = strtotime($entries[0]['created_at']); + $this->assert($ts > 0, 'created_at ist ein gültiges Datum'); + + // Limit wird eingehalten + $limited = EventLog::findByChannel($channel, 2); + $this->assertEquals(2, count($limited), 'Limit 2 wird eingehalten'); + + // Anderer Kanal bleibt unberührt + $other = EventLog::findByChannel('other-channel-' . uniqid()); + $this->assertEquals(0, count($other), 'Anderer Kanal ist leer'); + } +}