diff --git a/Changes.md b/Changes.md index aad5de9..255e002 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,38 @@ --- +## 2026-08-02 – Dashboard rechnet wie die EÜR, Ereignisse zeigen alle Fehler, kompaktere Buchungszeilen + +### Dashboard-Zahlen stimmen mit der EÜR überein und lassen sich aufschlüsseln (#167) + +- Das Dashboard summierte nach `bookings.buchungstyp`, die EÜR nach `accounts.kontenart` – für 2026 wich das Ergebnis um 1.241,17 € ab (16.903,50 € statt 18.144,67 €). Im Einzelnen zählte das Dashboard 818,79 € Vorsteuer (Konten 1571/1576/1574, Kontenart „Zahlungskonto") und 350,00 € Anlagenzugang als Betriebsausgabe, ließ AfA-Buchungen komplett aus (`buchungstyp='AfA'` kam in keiner der beiden Abfragen vor) und minderte das Ergebnis um 389,42 € nicht abzugsfähige Bewirtungskosten, die die EÜR getrennt ausweist. +- **`src/Services/EuerCalculator.php`:** Neue Methoden `summary($jahr, $monat = null)` und `fetchBookingsForBucket($jahr, $monat, $gruppe)`. Beide leiten ihre Bedingung aus denselben Konstanten ab wie `calculate()` – Zuordnungsregeln stehen damit an genau einer Stelle. +- **`src/Controllers/AccountController.php`:** `dashboard()` bezieht Umsatz, Ausgaben und Ergebnis für Jahr, Vormonat und laufenden Monat aus `summary()`; die sechs handgeschriebenen SQL-Abfragen entfallen. Neue Action `bucketBookings()` für die Detailansicht. +- **`src/Views/dashboard.php`, `src/Views/dashboard_bucket.php`** (neu): Alle neun Zahlen der ersten drei Kacheln verlinken auf `/dashboard/buchungen/{gruppe}/{jahr}[/{monat}]`. Die Liste zeigt Datum, Buchungsnummer, Beleg, Text und Konten mit Summenzeile; in der Ergebnisansicht stehen Betriebsausgaben mit negativem Vorzeichen, sodass die Spaltensumme dem Kachelwert entspricht. Nicht abzugsfähige Ausgaben werden unterhalb der Liste beziffert statt stillschweigend weggelassen. +- **`tests/80_DashboardEuerAbgleichTest.php`** (neu), **`tests/e2e/specs/dashboard-detail.spec.js`** (neu): Der PHP-Test bucht Aufwand, AfA, Vorsteuer und nicht abzugsfähige Bewirtung und prüft, was davon wo landet; der E2E-Test vergleicht den angezeigten Kachelwert mit der Summe der verlinkten Liste. + +### Ereignisse umfassen alle Fehler und Warnungen (#165) + +- Die Ereignisliste kannte nur den Nextcloud-Import. Fehler an anderer Stelle – etwa ein gescheiterter Beleg-Upload – waren nur als kurz sichtbare Meldung im Browser oder im Server-Log nachvollziehbar. +- **`migrations/091_system_events_level.sql`** (neu): Spalte `level` (info/warning/error) samt Indizes. Bestehende Einträge werden anhand ihres Textpräfixes eingestuft, nach dem die Liste sie bisher eingefärbt hat. +- **`src/Models/EventLog.php`:** `add()` nimmt einen Schweregrad entgegen, dazu `warning()`, `error()`, `find()` mit Filter nach Bereich und Schweregrad sowie `channels()`. `tryAdd()` schreibt das Ereignis, ohne selbst scheitern zu können – nötig für Aufrufer, die bereits einen Fehler behandeln. +- **Neue Fehlerquellen:** `View::redirect()` protokolliert jede Meldung vom Typ `error` oder `warning` im Bereich `meldungen` – damit ist jede dem Nutzer angezeigte Fehlermeldung erfasst, ohne sie einzeln anbinden zu müssen. `BelegController::renderUploadErrors()` schreibt zusätzlich im Bereich `beleg-upload` mit Dateiname, `public/index.php` protokolliert unbehandelte Ausnahmen und schwere PHP-Fehler im Bereich `system`. +- **`src/Controllers/EventController.php`, `src/Views/ereignisse/index.php`, `src/Views/ereignisse/_body.php`:** Aus der Nextcloud-Seite wird eine Übersicht über alle Bereiche mit zwei Auswahlfeldern (Bereich, Schweregrad), die nebeneinander stehen – beide setzen `width:auto` gegen das globale `select { width: 100% }`, das sie sonst im Flex-Formular untereinander schiebt. Der automatische Neuaufbau alle 60 Sekunden behält den eingestellten Filter. `/ereignisse/nextcloud-import` leitet auf die gefilterte Übersicht weiter, der bisherige Daten-Endpunkt bleibt erreichbar. +- **`tests/81_EreignisseAlleFehlerTest.php`** (neu), **`tests/e2e/specs/ereignisse.spec.js`** (neu), **`tests/58_EreignisAutoReloadTest.php`** (angepasst). + +### Buchungszeilen brauchen weniger Breite (#166) + +- **`src/Views/belege/detail.php`:** Buchungsnummer, Buchungsdatum und Buchungstyp teilen sich in der Belegansicht eine Spalte (Nummer oben, Datum darunter, Typ-Badge zuunterst) – wie bereits bei Kategorie- und Zahlungskonto. Die Spalte **Anlage** entfällt ersatzlos; das Wirtschaftsgut ist weiterhin über Journal, UStVA- und EÜR-Drill-down sowie die Anlagenliste erreichbar. Von acht Spalten bleiben sechs, die Tabelle ist ohne waagerechtes Scrollen lesbar. +- **`src/View.php`:** `formatMoney()` setzt zwischen Betrag und `€` ein geschütztes Leerzeichen. In schmalen Spalten rutschte das Währungszeichen sonst allein in die nächste Zeile. Wirkt in allen HTML-Ansichten; die PDF-Dienste haben eigene Formatierer und bleiben unberührt. +- **`docs/anlagevermögen.md`:** Der Abschnitt „Anzeige in Buchungstabellen" nennt die Beleg-Detailansicht nicht mehr unter den Übersichten mit Anlage-Spalte. +- **`tests/e2e/specs/beleg-buchung.spec.js`:** Tests auf die zusammengelegte Spalte (inklusive Typ und entfallener Anlage-Spalte) und darauf, dass Betrag und Währungszeichen in einer Zeile bleiben; die Betragsprüfungen der bestehenden Tests greifen jetzt auf die verschobene Zelle zu. + +### Nebenbei + +- **`docs/berechnungslogik.md`:** Die Tabelle der EÜR-Zuordnung nannte für nicht abzugsfähige Ausgaben noch `euer_zeile='51'`; seit Migration 075 ist es `'74'`, und `51` ist eine gewöhnliche Ausgabenzeile (u.a. Bürobedarf 4930). Korrigiert und um Anlagevermögen, Vorsteuerkonten und die Dashboard-Kacheln ergänzt. + +--- + ## 2026-08-02 – Testläufe hinterlassen keine Verzeichnisse mehr, die echte Uploads blockieren - Die Testsuite lief über `docker compose exec` als *root*, Apache/PHP dagegen als `www-data`. Legte ein Test unterhalb von `data/` ein Verzeichnis an, gehörte es root und war für die Anwendung nicht beschreibbar. Weil der Rollback nur die Datenbank zurückdreht, wurde die im Test vergebene Belegnummer danach erneut vergeben – der nächste echte Upload traf auf das bestehende Verzeichnis, übersprang das `mkdir` und scheiterte in `move_uploaded_file()` mit HTTP 500. In #168 war nur der konkrete Verursacher entschärft worden, nicht die Ursache. diff --git a/docs/anlagevermögen.md b/docs/anlagevermögen.md index 6ab83ba..00f7750 100644 --- a/docs/anlagevermögen.md +++ b/docs/anlagevermögen.md @@ -52,7 +52,9 @@ In beiden Fällen wird die `asset_id` in der Buchungszeile gespeichert. ## Anzeige in Buchungstabellen -In allen Buchungsübersichten (Beleg-Detail, Journal, UStVA-Drill-down, EÜR-Drill-down) erscheint eine **Anlage**-Spalte. Sie zeigt die Anlagen-Nummer als direkten Link zur Bearbeitungsmaske des Wirtschaftsguts. Falls für ein älteres Wirtschaftsgut keine Anlagen-Nummer hinterlegt ist, wird stattdessen die Bezeichnung angezeigt. +In den Buchungsübersichten Journal, UStVA-Drill-down und EÜR-Drill-down erscheint eine **Anlage**-Spalte. Sie zeigt die Anlagen-Nummer als direkten Link zur Bearbeitungsmaske des Wirtschaftsguts. Falls für ein älteres Wirtschaftsgut keine Anlagen-Nummer hinterlegt ist, wird stattdessen die Bezeichnung angezeigt. + +In der **Beleg-Detailansicht** entfällt die Spalte zugunsten einer schmaleren Tabelle; das zugehörige Wirtschaftsgut ist dort über das Journal oder die Anlagenliste erreichbar. --- diff --git a/docs/berechnungslogik.md b/docs/berechnungslogik.md index 5045956..b4f083f 100644 --- a/docs/berechnungslogik.md +++ b/docs/berechnungslogik.md @@ -16,12 +16,13 @@ Die EÜR basiert auf dem Feld `accounts.kontenart` sowie `accounts.euer_zeile` d | kontenart | euer_zeile | EÜR-Abschnitt | |---|---|---| | `Betriebseinnahme` | beliebig | Betriebseinnahmen | -| `Betriebsausgabe` | `≠ '51'` | Betriebsausgaben (fließen in `total_ausgaben` und `gewinn` ein) | -| `Betriebsausgabe` | `'51'` | Nicht abzugsfähige Betriebsausgaben (KZ 165) – eigener Abschnitt, **nicht** in `total_ausgaben` oder `gewinn` | +| `Betriebsausgabe` | `≠ '74'` | Betriebsausgaben (fließen in `total_ausgaben` und `gewinn` ein) | +| `Betriebsausgabe` | `'74'` | Nicht abzugsfähige Betriebsausgaben – eigener Abschnitt, **nicht** in `total_ausgaben` oder `gewinn` | +| `Anlagevermögen` | beliebig | nicht in EÜR (die Anschaffung wirkt nur über die AfA) | | `Privat` | beliebig | nicht in EÜR (Privatentnahmen/-einlagen beeinflussen den Gewinn nicht) | -| `Zahlungskonto`, `Verbindlichkeit` | beliebig | nicht in EÜR | +| `Zahlungskonto`, `Verbindlichkeit` | beliebig | nicht in EÜR (auch Vorsteuerkonten wie 1571/1576) | -**EÜR-Zeile 51 / KZ 165** entspricht den „Nicht abziehbaren Betriebsausgaben nach § 4 Abs. 5 EStG" (z.B. nicht abzugsfähige Bewirtungskosten auf Konten 4654, 4656; nicht abzugsfähige Geschenke auf 4635, 4636; nicht abzugsfähiger Arbeitszimmeranteil auf 4289). Diese Konten werden im EÜR-Bericht unterhalb der Betriebsausgaben in einem separaten Abschnitt angezeigt. +**EÜR-Zeile 74** entspricht den „Nicht abziehbaren Betriebsausgaben nach § 4 Abs. 5 EStG" (z.B. nicht abzugsfähige Bewirtungskosten auf Konten 4654, 4656, 4657; nicht abzugsfähige Geschenke auf 4635, 4636; nicht abzugsfähiger Arbeitszimmeranteil auf 4289; seit Migration 075). Diese Konten werden im EÜR-Bericht unterhalb der Betriebsausgaben in einem separaten Abschnitt angezeigt. ### Vorzeichen @@ -35,6 +36,14 @@ Die EÜR basiert auf dem Feld `accounts.kontenart` sowie `accounts.euer_zeile` d - Sortierung: nach `accounts.kontonummer` aufsteigend - Ergebnis: `total_einnahmen − total_ausgaben = gewinn` +### Dashboard-Kacheln + +Die Kacheln „Umsatz", „Ausgaben" und „Gewinn / Verlust" verwenden dieselben Regeln. `EuerCalculator::summary($jahr, $monat = null)` liefert die Summen, `fetchBookingsForBucket()` die Einzelbuchungen für die Detailansicht dahinter. Beide leiten ihre Bedingung aus denselben Konstanten ab wie `calculate()`, damit Dashboard und EÜR nicht auseinanderlaufen (Issue #167 – zuvor summierte das Dashboard nach `bookings.buchungstyp` und wich dadurch von der EÜR ab). + +- Zeitraum: das Jahr immer über `fiscal_years.year`, ein Monat zusätzlich über `MONTH(bookings.buchungs_datum)` +- Gruppen: `einnahmen`, `ausgaben`, `ergebnis` (`ergebnis` = Einnahmen und abzugsfähige Ausgaben zusammen, Ausgaben in der Anzeige mit negativem Vorzeichen) +- Der Buchungstyp `AfA` zählt wie jede andere Buchung nach der Kontenart des Kategorie-Kontos, also als Betriebsausgabe + --- ## UStVA (Umsatzsteuervoranmeldung) diff --git a/docs/funktionen.md b/docs/funktionen.md index d5898ce..af54efb 100644 --- a/docs/funktionen.md +++ b/docs/funktionen.md @@ -16,7 +16,26 @@ Vollständige Kontenliste: [kontenrahmen.md](kontenrahmen.md) - KI-gestützter Kontierungsvorschlag (optional, siehe [konfiguration.md](konfiguration.md)) - Nextcloud-Import via WebDAV (optional) - Belege können nicht gelöscht werden, solange Buchungen darauf verweisen (GoBD) -- **Ereignisanzeige** (`/ereignisse/nextcloud-import`): Log der letzten 500 Import-Ereignisse (importiert/übersprungen/Fehler); aktualisiert sich per Ein-/Aus-Button oben rechts automatisch alle 60 Sekunden (Zustand wird im Browser gemerkt) + +--- + +## Dashboard + +Fünf Kacheln: Umsatz, Ausgaben, Gewinn/Verlust (jeweils Jahr, Vormonat und laufender Monat), offene Rechnungen und ungebuchte Belege. Darunter eine Ablagefläche für Belege per Drag & Drop sowie die zehn zuletzt erfassten Buchungen. + +- **Zuordnung wie in der EÜR**: Umsatz, Ausgaben und Ergebnis richten sich nach der Kontenart des Kategorie-Kontos, nicht nach dem Buchungstyp. AfA-Buchungen zählen als Betriebsausgabe; Vorsteuer-, Anlage- und Privatkonten bleiben außen vor, nicht abzugsfähige Ausgaben (EÜR-Zeile 74) mindern das Ergebnis nicht. Die Kacheln stimmen damit mit der EÜR desselben Jahres überein. +- **Detailansicht je Zahl** (`/dashboard/buchungen/{einnahmen|ausgaben|ergebnis}/{jahr}[/{monat}]`): Jede der neun Zahlen der ersten drei Kacheln führt auf die Einzelbuchungen, aus denen sie besteht – mit Belegverweis, Konten und Summenzeile. In der Ergebnisansicht werden Betriebsausgaben mit negativem Vorzeichen ausgewiesen, damit die Spaltensumme dem Kachelwert entspricht. + +--- + +## Ereignisse + +Systemprotokoll unter `/ereignisse` – die letzten 500 Einträge, neueste zuerst. + +- **Alle Bereiche**: Nextcloud-Import (importiert/übersprungen/Fehler), fehlgeschlagene Beleg-Uploads (`beleg-upload`), jede dem Nutzer angezeigte Fehler- oder Warnmeldung (`meldungen`) sowie unbehandelte Ausnahmen und schwere PHP-Fehler (`system`) +- **Schweregrad** je Eintrag (Info, Warnung, Fehler); filterbar zusammen mit dem Bereich über die beiden Auswahlfelder oben links +- Aktualisiert sich per Ein-/Aus-Button oben rechts automatisch alle 60 Sekunden unter Beibehaltung des Filters (Zustand wird im Browser gemerkt) +- Der frühere Pfad `/ereignisse/nextcloud-import` leitet auf die nach diesem Bereich gefilterte Übersicht weiter --- 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..4c19048 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(); } }); @@ -88,6 +115,8 @@ $router = new Router(); // ── Dashboard ──────────────────────────────────────────────── $router->get('/', fn($p) => (new AccountController())->dashboard($p)); $router->get('/dashboard', fn($p) => (new AccountController())->dashboard($p)); +$router->get('/dashboard/buchungen/{bucket}/{year}', fn($p) => (new AccountController())->bucketBookings($p)); +$router->get('/dashboard/buchungen/{bucket}/{year}/{month}', fn($p) => (new AccountController())->bucketBookings($p)); // ── Kontenrahmen ───────────────────────────────────────────── $router->get('/accounts', fn($p) => (new AccountController())->index($p)); @@ -235,6 +264,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/AccountController.php b/src/Controllers/AccountController.php index 77e2b28..02d10d1 100644 --- a/src/Controllers/AccountController.php +++ b/src/Controllers/AccountController.php @@ -7,6 +7,7 @@ namespace Steuerkram\Controllers; use Steuerkram\Models\Account; use Steuerkram\Models\Booking; use Steuerkram\Models\Beleg; +use Steuerkram\Services\EuerCalculator; use Steuerkram\View; class AccountController extends BaseController @@ -22,57 +23,29 @@ class AccountController extends BaseController // Quick stats $stats = []; - // Einnahmen - $umsatz = \Steuerkram\Database::fetchOne( - 'SELECT COALESCE(SUM(bk.betrag_netto),0) as total - FROM bookings bk - JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id - WHERE bk.buchungstyp = \'Einnahme\' AND bk.is_reversal = 0 AND fy.year = ?', - [$year] - ); - $stats['umsatz'] = (float)($umsatz['total'] ?? 0); + // Einnahmen, Ausgaben und Ergebnis nach denselben Regeln wie die EÜR + // (maßgeblich ist accounts.kontenart, nicht bookings.buchungstyp) – + // sonst weichen Dashboard und EÜR voneinander ab, siehe Issue #167. + $calc = new EuerCalculator(); + $jahr = $calc->summary($year); + $monat = $calc->summary($year, $month); + $vormonat = $calc->summary($prevYear, $prevMonth); - $r = \Steuerkram\Database::fetchOne( - 'SELECT COALESCE(SUM(betrag_netto),0) as total FROM bookings - WHERE buchungstyp=\'Einnahme\' AND is_reversal=0 AND YEAR(buchungs_datum)=? AND MONTH(buchungs_datum)=?', - [$year, $month] - ); - $stats['umsatz_monat'] = (float)($r['total'] ?? 0); + $stats['umsatz'] = $jahr['einnahmen']; + $stats['umsatz_monat'] = $monat['einnahmen']; + $stats['umsatz_vormonat'] = $vormonat['einnahmen']; - $r = \Steuerkram\Database::fetchOne( - 'SELECT COALESCE(SUM(betrag_netto),0) as total FROM bookings - WHERE buchungstyp=\'Einnahme\' AND is_reversal=0 AND YEAR(buchungs_datum)=? AND MONTH(buchungs_datum)=?', - [$prevYear, $prevMonth] - ); - $stats['umsatz_vormonat'] = (float)($r['total'] ?? 0); + $stats['ausgaben'] = $jahr['ausgaben']; + $stats['ausgaben_monat'] = $monat['ausgaben']; + $stats['ausgaben_vormonat'] = $vormonat['ausgaben']; - // Ausgaben - $ausgaben = \Steuerkram\Database::fetchOne( - 'SELECT COALESCE(SUM(bk.betrag_netto),0) as total - FROM bookings bk - JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id - WHERE bk.buchungstyp = \'Ausgabe\' AND bk.is_reversal = 0 AND fy.year = ?', - [$year] - ); - $stats['ausgaben'] = (float)($ausgaben['total'] ?? 0); + $stats['gewinn'] = $jahr['ergebnis']; + $stats['gewinn_monat'] = $monat['ergebnis']; + $stats['gewinn_vormonat'] = $vormonat['ergebnis']; - $r = \Steuerkram\Database::fetchOne( - 'SELECT COALESCE(SUM(betrag_netto),0) as total FROM bookings - WHERE buchungstyp=\'Ausgabe\' AND is_reversal=0 AND YEAR(buchungs_datum)=? AND MONTH(buchungs_datum)=?', - [$year, $month] - ); - $stats['ausgaben_monat'] = (float)($r['total'] ?? 0); - - $r = \Steuerkram\Database::fetchOne( - 'SELECT COALESCE(SUM(betrag_netto),0) as total FROM bookings - WHERE buchungstyp=\'Ausgabe\' AND is_reversal=0 AND YEAR(buchungs_datum)=? AND MONTH(buchungs_datum)=?', - [$prevYear, $prevMonth] - ); - $stats['ausgaben_vormonat'] = (float)($r['total'] ?? 0); - - $stats['gewinn'] = $stats['umsatz'] - $stats['ausgaben']; - $stats['gewinn_monat'] = $stats['umsatz_monat'] - $stats['ausgaben_monat']; - $stats['gewinn_vormonat'] = $stats['umsatz_vormonat'] - $stats['ausgaben_vormonat']; + $stats['prev_year'] = $prevYear; + $stats['prev_month'] = $prevMonth; + $stats['month'] = $month; $stats['year'] = $year; $stats['month_label'] = $monthNames[$month - 1]; $stats['vormonat_label'] = $monthNames[$prevMonth - 1]; @@ -119,6 +92,50 @@ class AccountController extends BaseController ]); } + /** + * Einzelbuchungen hinter einer Dashboard-Kachel (Issue #167). Ohne Monat + * gilt das ganze Geschäftsjahr. + */ + public function bucketBookings(array $params): void + { + $bucket = (string)($params['bucket'] ?? ''); + $year = (int)($params['year'] ?? 0); + $month = isset($params['month']) ? (int)$params['month'] : null; + + if (!in_array($bucket, EuerCalculator::BUCKETS, true)) { + View::redirect('/dashboard', 'Unbekannte Auswertung.', 'error'); + return; + } + + if ($month !== null && ($month < 1 || $month > 12)) { + View::redirect('/dashboard', 'Ungültiger Monat.', 'error'); + return; + } + + $calc = new EuerCalculator(); + $summary = $calc->summary($year, $month); + $bookings = $calc->fetchBookingsForBucket($year, $month, $bucket); + + $monthNames = ['Januar','Februar','März','April','Mai','Juni', + 'Juli','August','September','Oktober','November','Dezember']; + $label = EuerCalculator::BUCKET_LABELS[$bucket]; + $zeitraum = $month === null ? (string)$year : $monthNames[$month - 1] . ' ' . $year; + + $view = new View(); + $view->render('layout/base.php', [ + 'pageTitle' => "{$label} – {$zeitraum}", + 'content' => $this->renderView('dashboard_bucket', [ + 'bucket' => $bucket, + 'label' => $label, + 'zeitraum' => $zeitraum, + 'year' => $year, + 'month' => $month, + 'summary' => $summary, + 'bookings' => $bookings, + ]), + ]); + } + public function index(array $params): void { $filter = $_GET; 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/Services/EuerCalculator.php b/src/Services/EuerCalculator.php index fbcf4b7..240b2a9 100644 --- a/src/Services/EuerCalculator.php +++ b/src/Services/EuerCalculator.php @@ -20,6 +20,26 @@ use Steuerkram\Database; */ class EuerCalculator { + /** Auswertbare Gruppen für die Detailansichten des Dashboards. */ + public const BUCKETS = ['einnahmen', 'ausgaben', 'ergebnis']; + + /** Bezeichnung der Gruppe für Seitentitel und Überschriften. */ + public const BUCKET_LABELS = [ + 'einnahmen' => 'Betriebseinnahmen', + 'ausgaben' => 'Betriebsausgaben', + 'ergebnis' => 'EÜR-Ergebnis', + ]; + + /** + * SQL-Bedingung, die eine Buchung einer Gruppe zuordnet – identisch zu der + * Aufteilung in calculate(): maßgeblich ist accounts.kontenart des + * Kategorie-Kontos, nicht bookings.buchungstyp. Nicht abzugsfähige + * Ausgaben (euer_zeile 74) bleiben außen vor, weil sie das Ergebnis nicht + * mindern. + */ + private const SQL_EINNAHME = "k.kontenart = 'Betriebseinnahme'"; + private const SQL_AUSGABE = "k.kontenart = 'Betriebsausgabe' AND (k.euer_zeile <> '74' OR k.euer_zeile IS NULL)"; + public function calculate(int $year): array { $rows = Database::fetchAll( @@ -75,6 +95,91 @@ class EuerCalculator ]; } + /** + * Summen für die Dashboard-Kacheln – nach denselben Regeln wie calculate(), + * wahlweise für ein ganzes Geschäftsjahr oder einen einzelnen Monat. + * + * @return array{einnahmen:float,ausgaben:float,nicht_abzugsfaehig:float,ergebnis:float} + */ + public function summary(int $year, ?int $month = null): array + { + [$where, $params] = $this->periodCondition($year, $month); + + $row = Database::fetchOne( + 'SELECT + COALESCE(SUM(CASE WHEN ' . self::SQL_EINNAHME . ' THEN bk.betrag_netto END), 0) AS einnahmen, + COALESCE(SUM(CASE WHEN ' . self::SQL_AUSGABE . ' THEN bk.betrag_netto END), 0) AS ausgaben, + COALESCE(SUM(CASE WHEN k.kontenart = \'Betriebsausgabe\' AND k.euer_zeile = \'74\' + THEN bk.betrag_netto END), 0) AS nicht_abzugsfaehig + FROM bookings bk + JOIN accounts k ON bk.kategorie_konto_id = k.id + JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id + WHERE ' . $where, + $params + ); + + $einnahmen = round((float)($row['einnahmen'] ?? 0), 2); + $ausgaben = round((float)($row['ausgaben'] ?? 0), 2); + + return [ + 'einnahmen' => $einnahmen, + 'ausgaben' => $ausgaben, + 'nicht_abzugsfaehig' => round((float)($row['nicht_abzugsfaehig'] ?? 0), 2), + 'ergebnis' => round($einnahmen - $ausgaben, 2), + ]; + } + + /** + * Einzelbuchungen hinter einer Dashboard-Kachel. + * + * @param string $bucket einnahmen | ausgaben | ergebnis + */ + public function fetchBookingsForBucket(int $year, ?int $month, string $bucket): array + { + $bucketCondition = match ($bucket) { + 'einnahmen' => '(' . self::SQL_EINNAHME . ')', + 'ausgaben' => '(' . self::SQL_AUSGABE . ')', + 'ergebnis' => '((' . self::SQL_EINNAHME . ') OR (' . self::SQL_AUSGABE . '))', + default => throw new \InvalidArgumentException("Unbekannte Gruppe: {$bucket}"), + }; + + [$where, $params] = $this->periodCondition($year, $month); + + return Database::fetchAll( + 'SELECT bk.id, bk.buchungs_nr, bk.buchungs_datum, bk.buchungstyp, bk.ust_satz, + bk.betrag_netto, bk.betrag_ust, bk.betrag_brutto, bk.buchungstext, + bk.is_reversal, bk.reversed_by, bk.beleg_id, bk.asset_id, bl.beleg_nr, + k.id AS kategorie_id, k.kontenart, + k.kontonummer AS kategorie_nr, k.bezeichnung AS kategorie_name, + z.kontonummer AS zahlungs_nr, z.bezeichnung AS zahlungs_name, + a.anlage_nr, a.bezeichnung AS anlage_bezeichnung + FROM bookings bk + JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id + LEFT JOIN belege bl ON bk.beleg_id = bl.id + JOIN accounts k ON bk.kategorie_konto_id = k.id + JOIN accounts z ON bk.zahlungskonto_id = z.id + LEFT JOIN assets a ON bk.asset_id = a.id + WHERE ' . $where . ' AND ' . $bucketCondition . ' + ORDER BY bk.buchungs_datum, bk.id', + $params + ); + } + + /** + * Zeitraumfilter: Jahr immer über das Geschäftsjahr, Monat zusätzlich über + * das Buchungsdatum. + * + * @return array{0:string,1:array} + */ + private function periodCondition(int $year, ?int $month): array + { + if ($month === null) { + return ['fy.year = ?', [$year]]; + } + + return ['fy.year = ? AND MONTH(bk.buchungs_datum) = ?', [$year, $month]]; + } + public function fetchBookingsForKonto(int $year, int $kontoId): array { return Database::fetchAll( diff --git a/src/View.php b/src/View.php index 0ee0849..94207a4 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'); @@ -49,7 +76,9 @@ class View public static function formatMoney(mixed $value): string { - return number_format((float)$value, 2, ',', '.') . ' €'; + // Geschütztes Leerzeichen: in schmalen Tabellenspalten rutscht das + // Währungszeichen sonst allein in die nächste Zeile. + return number_format((float)$value, 2, ',', '.') . "\u{00A0}€"; } public static function formatDate(?string $date): string diff --git a/src/Views/belege/detail.php b/src/Views/belege/detail.php index 723f7c8..ad8cb62 100644 --- a/src/Views/belege/detail.php +++ b/src/Views/belege/detail.php @@ -218,14 +218,11 @@ $suppliers = $suppliers ?? []; - - - + - @@ -235,9 +232,11 @@ $suppliers = $suppliers ?? []; $isReversal = (bool)$bk['is_reversal']; ?> - - - + @@ -249,15 +248,6 @@ $suppliers = $suppliers ?? []; Storniert - - +
Buchungsnr.DatumTypBuchungsnr.
Datum
Typ
Kategorie Zahlungskonto Wert TextAnlage
+
+
+ +


- - - - - - – - -
@@ -299,7 +289,7 @@ $suppliers = $suppliers ?? [];
Noch keine Buchungen.
Noch keine Buchungen.
diff --git a/src/Views/dashboard.php b/src/Views/dashboard.php index 11fe810..1bf763b 100644 --- a/src/Views/dashboard.php +++ b/src/Views/dashboard.php @@ -2,39 +2,55 @@ use Steuerkram\View; $h = fn($v) => View::h($v); $fmt = fn($v) => View::formatMoney($v); + +// Jede Kachelzahl führt auf die Einzelbuchungen, aus denen sie besteht (#167). +$detail = function (string $bucket, ?int $month = null) use ($stats): string { + $url = '/dashboard/buchungen/' . $bucket . '/' + . ($month === null ? $stats['year'] : ($month === $stats['month'] ? $stats['year'] : $stats['prev_year'])); + return $month === null ? $url : $url . '/' . $month; +}; ?>
Umsatz
-
+
+ +
Betriebseinnahmen (netto)
- : + :  ·  - : + :
Ausgaben
-
+
+ +
Betriebsausgaben (netto)
- : + :  ·  - : + :
Gewinn / Verlust
-
- +
EÜR-Ergebnis
- : + :  ·  - : + :
diff --git a/src/Views/dashboard_bucket.php b/src/Views/dashboard_bucket.php new file mode 100644 index 0000000..ab6334d --- /dev/null +++ b/src/Views/dashboard_bucket.php @@ -0,0 +1,113 @@ + View::h($v); +$fmt = fn($v) => number_format((float)$v, 2, ',', '.') . ' €'; + +// Beitrag der Buchung zur Kachelsumme. In der Ergebnis-Ansicht mindern +// Betriebsausgaben den Gewinn, in den beiden anderen Ansichten wird die +// Spaltensumme selbst gebildet. +$beitrag = function (array $b) use ($bucket): float { + $netto = (float)$b['betrag_netto']; + if ($bucket === 'ergebnis' && $b['kontenart'] === 'Betriebsausgabe') { + return -$netto; + } + return $netto; +}; + +$summenwert = match ($bucket) { + 'einnahmen' => $summary['einnahmen'], + 'ausgaben' => $summary['ausgaben'], + default => $summary['ergebnis'], +}; +?> + +
+
+
+
–
+
+ Zuordnung nach Kontenart wie in der EÜR +
+
+ +
+ + +
+

Keine Buchungen in diesem Zeitraum.

+
+ +
+ + + + + + + + + + + + + + + > + + + + + + + + + + + + + + + + +
DatumBuchungsnr.BelegBuchungstextKategorieZahlungskontoNetto
+ + + + + + – + + + + + Storno + + Storniert + + + + – + –
Summe ( Buchungen)
+
+ +
+ + +
+
+ Nicht enthalten: nicht abzugsfähige + Betriebsausgaben (EÜR-Zeile 74, z. B. der nicht abziehbare Anteil der Bewirtungskosten). + Sie mindern das steuerliche Ergebnis nicht und bleiben deshalb – wie in der EÜR – außen vor. +
+
+ 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 62% rename from src/Views/ereignisse/nextcloud_import.php rename to src/Views/ereignisse/index.php index e5a6048..f58f351 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)