From 5df6c2c9dc411d77b5d5837c32580c2a3af91976 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sun, 2 Aug 2026 16:49:54 +0200 Subject: [PATCH 1/5] Fix #166: Buchungsnummer und Datum teilen sich eine Spalte in der Belegansicht --- src/Views/belege/detail.php | 11 +++++---- tests/e2e/specs/beleg-buchung.spec.js | 34 ++++++++++++++++++++++++--- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/Views/belege/detail.php b/src/Views/belege/detail.php index 723f7c8..f67d976 100644 --- a/src/Views/belege/detail.php +++ b/src/Views/belege/detail.php @@ -218,8 +218,7 @@ $suppliers = $suppliers ?? []; - - + @@ -235,8 +234,10 @@ $suppliers = $suppliers ?? []; $isReversal = (bool)$bk['is_reversal']; ?> - - + @@ -299,7 +300,7 @@ $suppliers = $suppliers ?? []; - +
Buchungsnr.DatumBuchungsnr.
Datum
Typ Kategorie Zahlungskonto
+
+ +


Noch keine Buchungen.
Noch keine Buchungen.
diff --git a/tests/e2e/specs/beleg-buchung.spec.js b/tests/e2e/specs/beleg-buchung.spec.js index ed9c12b..ccc0856 100644 --- a/tests/e2e/specs/beleg-buchung.spec.js +++ b/tests/e2e/specs/beleg-buchung.spec.js @@ -73,7 +73,7 @@ test.describe('Beleg-Upload und Buchung', () => { const zeile = page.locator('table tbody tr', { hasText: tag }).first() await expect(zeile).toHaveCount(1) - expect(parseMoney(await zeile.locator('td').nth(5).textContent())).toBeCloseTo(100.0, 2) + expect(parseMoney(await zeile.locator('td').nth(4).textContent())).toBeCloseTo(100.0, 2) }) test('Buchung im BRUTTO-Modus wird vor dem Absenden in Netto umgerechnet', async ({ page }) => { @@ -98,7 +98,7 @@ test.describe('Beleg-Upload und Buchung', () => { // Kern der Regression: Gespeichert werden muss der Nettobetrag 100,00 — // nicht die eingegebenen 119,00. - const gespeichert = parseMoney(await zeile.locator('td').nth(5).textContent()) + const gespeichert = parseMoney(await zeile.locator('td').nth(4).textContent()) expect(gespeichert, 'Bruttoeingabe wurde nicht in Netto umgerechnet').toBeCloseTo(100.0, 2) }) @@ -131,7 +131,35 @@ test.describe('Beleg-Upload und Buchung', () => { const zeile = page.locator('table tbody tr', { hasText: tag }).first() await expect(zeile).toHaveCount(1) - expect(parseMoney(await zeile.locator('td').nth(5).textContent())).toBeCloseTo(100.0, 2) + expect(parseMoney(await zeile.locator('td').nth(4).textContent())).toBeCloseTo(100.0, 2) + }) + + // Issue #166: Buchungsnummer und Buchungsdatum teilen sich eine Spalte, + // damit die Tabelle ohne waagerechtes Scrollen lesbar bleibt. Prüfbar nur + // am gerenderten DOM — die Zellaufteilung existiert nur in der View. + test('Buchungsnummer und Datum stehen in einer gemeinsamen Spalte', async ({ page }) => { + const { id, tag } = await uploadBeleg(page) + belegId = id + + await buchungsmaskeVorbereiten(page, { buchungstext: tag }) + await page.fill('#betrag_netto_input', '100.00') + await page.click('#buchung_form button[type="submit"]') + await page.waitForLoadState('networkidle') + + const zeile = page.locator('table tbody tr', { hasText: tag }).first() + await expect(zeile).toHaveCount(1) + + // Erste Zelle trägt beides: Buchungsnummer (BU-JAHR-NNNNNN) und Datum. + const ersteZelle = await zeile.locator('td').nth(0).textContent() + expect(ersteZelle).toMatch(/BU-\d{4}-\d+/) + expect(ersteZelle).toMatch(/\d{2}\.\d{2}\.\d{4}/) + + // Die Kopfzeile führt beide Bezeichnungen in einer einzigen Spalte. + const kopf = page.locator('table thead tr').first() + const ersteKopfzelle = await kopf.locator('th').nth(0).textContent() + expect(ersteKopfzelle).toContain('Buchungsnr.') + expect(ersteKopfzelle).toContain('Datum') + await expect(kopf.locator('th')).toHaveCount(8) }) // Hinweis: Dass ein Beleg mit Buchungen nicht löschbar ist, ist reine From c9afc3e33f8f90aa11b457da0cdf640f4f78416f Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sun, 2 Aug 2026 16:50:18 +0200 Subject: [PATCH 2/5] Fix #165: Ereignisse zeigen Fehler und Warnungen aus dem ganzen System --- migrations/091_system_events_level.sql | 18 +++ public/index.php | 32 ++++- src/Controllers/BelegController.php | 10 ++ src/Controllers/EventController.php | 61 ++++++++-- src/Models/EventLog.php | 87 ++++++++++++-- src/View.php | 27 +++++ src/Views/ereignisse/_body.php | 37 ++++++ .../ereignisse/_nextcloud_import_body.php | 28 ----- .../{nextcloud_import.php => index.php} | 37 +++++- src/Views/layout/nav.php | 6 +- tests/58_EreignisAutoReloadTest.php | 34 ++++-- tests/81_EreignisseAlleFehlerTest.php | 109 ++++++++++++++++++ tests/e2e/specs/ereignisse.spec.js | 63 ++++++++++ 13 files changed, 485 insertions(+), 64 deletions(-) create mode 100644 migrations/091_system_events_level.sql create mode 100644 src/Views/ereignisse/_body.php delete mode 100644 src/Views/ereignisse/_nextcloud_import_body.php rename src/Views/ereignisse/{nextcloud_import.php => index.php} (63%) create mode 100644 tests/81_EreignisseAlleFehlerTest.php create mode 100644 tests/e2e/specs/ereignisse.spec.js diff --git a/migrations/091_system_events_level.sql b/migrations/091_system_events_level.sql new file mode 100644 index 0000000..5e7fffd --- /dev/null +++ b/migrations/091_system_events_level.sql @@ -0,0 +1,18 @@ +-- Ereignisse führen künftig nicht nur den Nextcloud-Import, sondern auch +-- Fehlermeldungen und Warnungen aus dem übrigen System (Issue #165). +-- Dafür bekommt jeder Eintrag einen Schweregrad. + +ALTER TABLE system_events + ADD COLUMN IF NOT EXISTS level ENUM('info','warning','error') NOT NULL DEFAULT 'info' AFTER channel; + +ALTER TABLE system_events + ADD INDEX IF NOT EXISTS idx_created (created_at); + +ALTER TABLE system_events + ADD INDEX IF NOT EXISTS idx_level_created (level, created_at); + +-- Bestehende Nextcloud-Einträge nachträglich einstufen. Bis hierher stand der +-- Schweregrad nur im Text; die Zuordnung folgt denselben Präfixen, nach denen +-- die Ereignisliste die Zeilen bisher eingefärbt hat. +UPDATE system_events SET level = 'error' WHERE level = 'info' AND message LIKE 'FEHLER%'; +UPDATE system_events SET level = 'warning' WHERE level = 'info' AND message LIKE 'Duplikat%'; diff --git a/public/index.php b/public/index.php index 7bd028c..2ba4d83 100644 --- a/public/index.php +++ b/public/index.php @@ -26,7 +26,17 @@ set_error_handler(static function (int $severity, string $message, string $file, throw new \ErrorException($message, 0, $severity, $file, $line); }); -set_exception_handler(function (\Throwable $e) use ($steuerkramShowGenericError): void { +// Fehler landen zusätzlich in der Ereignisliste, damit sie ohne Zugriff auf +// das Server-Log sichtbar sind (Issue #165). Der Aufruf darf nie selbst +// scheitern – ist die Datenbank die Fehlerursache, bleibt es beim error_log. +$steuerkramLogEvent = static function (string $channel, string $message, string $level): void { + if (!class_exists(\Steuerkram\Models\EventLog::class, true)) { + return; + } + \Steuerkram\Models\EventLog::tryAdd($channel, $message, $level); +}; + +set_exception_handler(function (\Throwable $e) use ($steuerkramShowGenericError, $steuerkramLogEvent): void { error_log(sprintf( 'Unhandled exception: %s in %s:%d%s%s', $e->getMessage(), @@ -35,13 +45,30 @@ set_exception_handler(function (\Throwable $e) use ($steuerkramShowGenericError) PHP_EOL, $e->getTraceAsString() )); + $steuerkramLogEvent('system', sprintf( + '%s bei %s %s: %s (%s:%d)', + (new \ReflectionClass($e))->getShortName(), + $_SERVER['REQUEST_METHOD'] ?? 'CLI', + parse_url($_SERVER['REQUEST_URI'] ?? '-', PHP_URL_PATH) ?: '-', + $e->getMessage(), + basename($e->getFile()), + $e->getLine() + ), \Steuerkram\Models\EventLog::LEVEL_ERROR); $steuerkramShowGenericError(); }); -register_shutdown_function(function () use ($steuerkramShowGenericError): void { +register_shutdown_function(function () use ($steuerkramShowGenericError, $steuerkramLogEvent): void { $error = error_get_last(); if ($error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) { error_log(sprintf('Fatal error: %s in %s:%d', $error['message'], $error['file'], $error['line'])); + $steuerkramLogEvent('system', sprintf( + 'Schwerer Fehler bei %s %s: %s (%s:%d)', + $_SERVER['REQUEST_METHOD'] ?? 'CLI', + parse_url($_SERVER['REQUEST_URI'] ?? '-', PHP_URL_PATH) ?: '-', + $error['message'], + basename($error['file']), + $error['line'] + ), \Steuerkram\Models\EventLog::LEVEL_ERROR); $steuerkramShowGenericError(); } }); @@ -235,6 +262,7 @@ $router->post('/belege/{belegId}/vorlage/{vorlageId}/buchen', fn($p) = // ── Ereignisse ─────────────────────────────────────────────── $router->get('/ereignisse', fn($p) => (new EventController())->index($p)); +$router->get('/ereignisse/data', fn($p) => (new EventController())->indexData($p)); $router->get('/ereignisse/nextcloud-import', fn($p) => (new EventController())->nextcloudImport($p)); $router->get('/ereignisse/nextcloud-import/data', fn($p) => (new EventController())->nextcloudImportData($p)); diff --git a/src/Controllers/BelegController.php b/src/Controllers/BelegController.php index aa6a92f..f7a17dc 100644 --- a/src/Controllers/BelegController.php +++ b/src/Controllers/BelegController.php @@ -9,6 +9,7 @@ use Steuerkram\Models\Beleg; use Steuerkram\Models\Booking; use Steuerkram\Models\Account; use Steuerkram\Models\Asset; +use Steuerkram\Models\EventLog; use Steuerkram\Models\Supplier; use Steuerkram\View; use Steuerkram\Services\ImageCropService; @@ -216,6 +217,15 @@ class BelegController extends BaseController */ private function renderUploadErrors(array $errors): void { + $dateiname = isset($_FILES['beleg_file']['name']) && $_FILES['beleg_file']['name'] !== '' + ? pathinfo((string)$_FILES['beleg_file']['name'], PATHINFO_BASENAME) + : 'ohne Dateiname'; + EventLog::tryAdd( + 'beleg-upload', + "Upload fehlgeschlagen ({$dateiname}): " . implode(' ', $errors), + EventLog::LEVEL_ERROR + ); + if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') { http_response_code(422); header('Content-Type: application/json'); diff --git a/src/Controllers/EventController.php b/src/Controllers/EventController.php index c077783..08eadbc 100644 --- a/src/Controllers/EventController.php +++ b/src/Controllers/EventController.php @@ -9,37 +9,76 @@ use Steuerkram\View; class EventController extends BaseController { + private const LIMIT = 500; + public function index(array $params): void { - View::redirect('/ereignisse/nextcloud-import'); - } - - public function nextcloudImport(array $params): void - { - $events = EventLog::findByChannel('nextcloud-import', 500); + $filter = $this->filterFromRequest(); + $events = EventLog::find($filter, self::LIMIT); $view = new View(); $view->render('layout/base.php', [ - 'pageTitle' => 'Ereignisse – Nextcloud-Import', - 'content' => $this->renderView('ereignisse/nextcloud_import', [ + 'pageTitle' => 'Ereignisse', + 'content' => $this->renderView('ereignisse/index', [ 'events' => $events, - 'eventsHtml' => $this->renderView('ereignisse/_nextcloud_import_body', [ + 'filter' => $filter, + 'channels' => EventLog::channels(), + 'eventsHtml' => $this->renderView('ereignisse/_body', [ 'events' => $events, ]), ]), ]); } + public function indexData(array $params): void + { + $this->respondWithEvents($this->filterFromRequest()); + } + + public function nextcloudImport(array $params): void + { + View::redirect('/ereignisse?channel=nextcloud-import'); + } + + /** Datenquelle der bis Issue #165 eigenständigen Nextcloud-Ereignisseite. */ public function nextcloudImportData(array $params): void { - $events = EventLog::findByChannel('nextcloud-import', 500); + $this->respondWithEvents(['channel' => 'nextcloud-import']); + } + + /** + * @param array{channel?:string,level?:string} $filter + */ + private function respondWithEvents(array $filter): void + { + $events = EventLog::find($filter, self::LIMIT); header('Content-Type: application/json'); echo json_encode([ 'count' => count($events), - 'html' => $this->renderView('ereignisse/_nextcloud_import_body', [ + 'html' => $this->renderView('ereignisse/_body', [ 'events' => $events, ]), ], JSON_UNESCAPED_UNICODE); } + + /** + * @return array{channel?:string,level?:string} + */ + private function filterFromRequest(): array + { + $filter = []; + + $channel = trim((string)($_GET['channel'] ?? '')); + if ($channel !== '') { + $filter['channel'] = $channel; + } + + $level = trim((string)($_GET['level'] ?? '')); + if (in_array($level, EventLog::LEVELS, true)) { + $filter['level'] = $level; + } + + return $filter; + } } diff --git a/src/Models/EventLog.php b/src/Models/EventLog.php index 6899012..dabb8ec 100644 --- a/src/Models/EventLog.php +++ b/src/Models/EventLog.php @@ -8,23 +8,94 @@ use Steuerkram\Database; class EventLog { - public static function add(string $channel, string $message): void + public const LEVEL_INFO = 'info'; + public const LEVEL_WARNING = 'warning'; + public const LEVEL_ERROR = 'error'; + + public const LEVELS = [self::LEVEL_INFO, self::LEVEL_WARNING, self::LEVEL_ERROR]; + + public static function add(string $channel, string $message, string $level = self::LEVEL_INFO): void { + if (!in_array($level, self::LEVELS, true)) { + $level = self::LEVEL_INFO; + } + Database::execute( - 'INSERT INTO system_events (channel, message) VALUES (?, ?)', - [$channel, $message] + 'INSERT INTO system_events (channel, level, message) VALUES (?, ?, ?)', + [$channel, $level, $message] ); } + public static function warning(string $channel, string $message): void + { + self::add($channel, $message, self::LEVEL_WARNING); + } + + public static function error(string $channel, string $message): void + { + self::add($channel, $message, self::LEVEL_ERROR); + } + + /** + * Schreibt ein Ereignis, ohne selbst einen Fehler auszulösen. Für + * Aufrufer, die bereits einen Fehler behandeln – dort darf ein nicht + * erreichbares Datenbankprotokoll die eigentliche Fehlerausgabe nicht + * verdrängen. + */ + public static function tryAdd(string $channel, string $message, string $level = self::LEVEL_INFO): void + { + try { + self::add($channel, $message, $level); + } catch (\Throwable $e) { + error_log('EventLog: Ereignis konnte nicht gespeichert werden: ' . $e->getMessage()); + } + } + public static function findByChannel(string $channel, int $limit = 500): array { + return self::find(['channel' => $channel], $limit); + } + + /** + * @param array{channel?:string,level?:string} $filter + */ + public static function find(array $filter = [], int $limit = 500): array + { + $where = []; + $params = []; + + if (!empty($filter['channel'])) { + $where[] = 'channel = ?'; + $params[] = $filter['channel']; + } + + if (!empty($filter['level']) && in_array($filter['level'], self::LEVELS, true)) { + $where[] = 'level = ?'; + $params[] = $filter['level']; + } + + $params[] = $limit; + return Database::fetchAll( - 'SELECT id, channel, message, created_at - FROM system_events - WHERE channel = ? - ORDER BY created_at DESC, id DESC + 'SELECT id, channel, level, message, created_at + FROM system_events' + . ($where ? ' WHERE ' . implode(' AND ', $where) : '') + . ' ORDER BY created_at DESC, id DESC LIMIT ?', - [$channel, $limit] + $params + ); + } + + /** + * Alle bisher benutzten Kanäle – Grundlage für den Filter in der Ansicht. + * + * @return string[] + */ + public static function channels(): array + { + return array_column( + Database::fetchAll('SELECT DISTINCT channel FROM system_events ORDER BY channel'), + 'channel' ); } } diff --git a/src/View.php b/src/View.php index 0ee0849..ec0d51c 100644 --- a/src/View.php +++ b/src/View.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Steuerkram; use Steuerkram\Auth; +use Steuerkram\Models\EventLog; class View { @@ -37,11 +38,37 @@ class View Auth::startSession(); if ($message !== '') { $_SESSION['flash'][] = ['type' => $type, 'message' => $message]; + self::logFlash($type, $message); } header('Location: ' . $path); exit; } + /** + * Fehler- und Warnhinweise, die dem Nutzer angezeigt werden, wandern + * zusätzlich in die Ereignisliste – dort sind sie auch dann noch + * nachvollziehbar, wenn die Meldung längst weggeklickt ist (Issue #165). + */ + private static function logFlash(string $type, string $message): void + { + $level = match ($type) { + 'error' => EventLog::LEVEL_ERROR, + 'warning' => EventLog::LEVEL_WARNING, + default => null, + }; + + if ($level === null) { + return; + } + + $pfad = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH); + EventLog::tryAdd( + 'meldungen', + $pfad ? "{$message} ({$pfad})" : $message, + $level + ); + } + public static function h(mixed $value): string { return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); diff --git a/src/Views/ereignisse/_body.php b/src/Views/ereignisse/_body.php new file mode 100644 index 0000000..025a26b --- /dev/null +++ b/src/Views/ereignisse/_body.php @@ -0,0 +1,37 @@ + View::h($v); + +$levelStyles = [ + EventLog::LEVEL_ERROR => ['Fehler', 'var(--color-danger,#dc2626)'], + EventLog::LEVEL_WARNING => ['Warnung', 'var(--color-warning,#d97706)'], + EventLog::LEVEL_INFO => ['Info', 'var(--color-muted,#64748b)'], +]; +?> + +
Keine Ereignisse vorhanden.
+ +
+ + + + + + + + + + + + +
[]
+
+ diff --git a/src/Views/ereignisse/_nextcloud_import_body.php b/src/Views/ereignisse/_nextcloud_import_body.php deleted file mode 100644 index 2e19645..0000000 --- a/src/Views/ereignisse/_nextcloud_import_body.php +++ /dev/null @@ -1,28 +0,0 @@ - View::h($v); ?> - -
Keine Ereignisse vorhanden. Einträge entstehen, sobald der Nextcloud-Import Dateien importiert, überspringt oder auf Fehler stößt.
- -
- - - - - - - - - - - -
[]
-
- diff --git a/src/Views/ereignisse/nextcloud_import.php b/src/Views/ereignisse/index.php similarity index 63% rename from src/Views/ereignisse/nextcloud_import.php rename to src/Views/ereignisse/index.php index e5a6048..e9d078c 100644 --- a/src/Views/ereignisse/nextcloud_import.php +++ b/src/Views/ereignisse/index.php @@ -1,3 +1,17 @@ + View::h($v); + +$aktiverKanal = $filter['channel'] ?? ''; +$aktivesLevel = $filter['level'] ?? ''; + +$levelLabels = [ + EventLog::LEVEL_ERROR => 'Nur Fehler', + EventLog::LEVEL_WARNING => 'Nur Warnungen', + EventLog::LEVEL_INFO => 'Nur Infos', +]; +?>
-
- Nextcloud-Import +
+
+ + + + Filter zurücksetzen + +
Einträge (neueste zuerst)