diff --git a/Changes.md b/Changes.md index aad5de9..6e5c054 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,36 @@ --- +## 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). 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 und Buchungsdatum teilen sich in der Belegansicht eine Spalte (Nummer oben, Datum darunter) – wie bereits bei Kategorie- und Zahlungskonto. Die Tabelle ist damit eine Spalte schmaler und ohne waagerechtes Scrollen lesbar. +- **`tests/e2e/specs/beleg-buchung.spec.js`:** Neuer Test auf die zusammengelegte Spalte; 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/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/public/index.php b/public/index.php index 2ba4d83..4c19048 100644 --- a/public/index.php +++ b/public/index.php @@ -115,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)); 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/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/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/tests/80_DashboardEuerAbgleichTest.php b/tests/80_DashboardEuerAbgleichTest.php new file mode 100644 index 0000000..7a394e8 --- /dev/null +++ b/tests/80_DashboardEuerAbgleichTest.php @@ -0,0 +1,200 @@ +findAccountId('8400'); // Betriebseinnahme + $buerobedarf = $this->findAccountId('4930'); // Betriebsausgabe + $bewirtung = $this->findAccountId('4654'); // Betriebsausgabe, euer_zeile 74 + $vorsteuer = $this->findAccountId('1576'); // Zahlungskonto + $bank = $this->findAccountId('1200'); // Zahlungskonto + + $this->assertNotEmpty($erloese, 'Erlöskonto 8400 vorhanden'); + $this->assertNotEmpty($buerobedarf, 'Aufwandskonto 4930 vorhanden'); + $this->assertNotEmpty($bewirtung, 'Bewirtungskonto 4654 vorhanden'); + $this->assertNotEmpty($vorsteuer, 'Vorsteuerkonto 1576 vorhanden'); + $this->assertNotEmpty($bank, 'Bankkonto 1200 vorhanden'); + + $vorher = $calc->summary($jahr); + + $belegId = $this->createBeleg(); + $monat = 6; + $datum = sprintf('%04d-%02d-15', $jahr, $monat); + + // Einnahme 1.000,00 – zählt in beiden Auswertungen + $this->createBooking($belegId, $datum, 'Einnahme', $erloese, $bank, 1000.00); + // Betriebsausgabe 200,00 – zählt in beiden Auswertungen + $this->createBooking($belegId, $datum, 'Ausgabe', $buerobedarf, $bank, 200.00); + // AfA 100,00 – fiel im alten Dashboard komplett unter den Tisch + $this->createBooking($belegId, $datum, 'AfA', $buerobedarf, $bank, 100.00); + // Vorsteuer 38,00 – wurde im alten Dashboard als Ausgabe mitgezählt + $this->createBooking($belegId, $datum, 'Ausgabe', $vorsteuer, $bank, 38.00); + // Nicht abzugsfähige Bewirtung 30,00 – mindert das Ergebnis nicht + $this->createBooking($belegId, $datum, 'Ausgabe', $bewirtung, $bank, 30.00); + + $nachher = $calc->summary($jahr); + + $this->assertEquals( + round($vorher['einnahmen'] + 1000.00, 2), + $nachher['einnahmen'], + 'Einnahmen steigen um genau den Erlös' + ); + $this->assertEquals( + round($vorher['ausgaben'] + 300.00, 2), + $nachher['ausgaben'], + 'Ausgaben enthalten Aufwand und AfA, aber weder Vorsteuer noch nicht abzugsfähige Kosten' + ); + $this->assertEquals( + round($vorher['nicht_abzugsfaehig'] + 30.00, 2), + $nachher['nicht_abzugsfaehig'], + 'Nicht abzugsfähige Kosten werden getrennt ausgewiesen' + ); + $this->assertEquals( + round($vorher['ergebnis'] + 700.00, 2), + $nachher['ergebnis'], + 'Ergebnis = Einnahmen − Ausgaben' + ); + + // Deckungsgleich mit der vollständigen EÜR-Berechnung + $euer = $calc->calculate($jahr); + $this->assertEquals($euer['total_einnahmen'], $nachher['einnahmen'], 'Einnahmen wie in der EÜR'); + $this->assertEquals($euer['total_ausgaben'], $nachher['ausgaben'], 'Ausgaben wie in der EÜR'); + $this->assertEquals($euer['gewinn'], $nachher['ergebnis'], 'Ergebnis wie in der EÜR'); + + // Detaillisten summieren auf den Kachelwert + foreach (['einnahmen', 'ausgaben', 'ergebnis'] as $bucket) { + $bookings = $calc->fetchBookingsForBucket($jahr, null, $bucket); + $summe = 0.0; + foreach ($bookings as $b) { + $summe += ($bucket === 'ergebnis' && $b['kontenart'] === 'Betriebsausgabe') + ? -(float)$b['betrag_netto'] + : (float)$b['betrag_netto']; + } + $this->assertEquals( + $nachher[$bucket === 'ergebnis' ? 'ergebnis' : $bucket], + round($summe, 2), + "Detailliste '{$bucket}' summiert auf den Kachelwert" + ); + } + + // Keine Vorsteuer- und keine nicht abzugsfähige Buchung in der Ausgabenliste + $kontonummern = array_column($calc->fetchBookingsForBucket($jahr, null, 'ausgaben'), 'kategorie_nr'); + $this->assert(!in_array('1576', $kontonummern, true), 'Vorsteuerkonto fehlt in der Ausgabenliste'); + $this->assert(!in_array('4654', $kontonummern, true), 'Nicht abzugsfähige Bewirtung fehlt in der Ausgabenliste'); + + // Monatsfilter greift + $juni = $calc->summary($jahr, $monat); + $this->assert($juni['einnahmen'] >= 1000.00, 'Monatsauswertung enthält die Junibuchung'); + $andererMonat = $calc->summary($jahr, $monat === 12 ? 1 : $monat + 1); + $this->assert( + $andererMonat['einnahmen'] < $juni['einnahmen'], + 'Ein anderer Monat enthält die Junibuchung nicht' + ); + + // Unbekannte Gruppe wird abgewiesen + $abgewiesen = false; + try { + $calc->fetchBookingsForBucket($jahr, null, 'unsinn'); + } catch (\InvalidArgumentException) { + $abgewiesen = true; + } + $this->assert($abgewiesen, 'Unbekannte Auswertungsgruppe löst eine Ausnahme aus'); + + // Route und Controller-Action vorhanden + $routes = (string)file_get_contents(__DIR__ . '/../public/index.php'); + $this->assert( + str_contains($routes, '/dashboard/buchungen/{bucket}/{year}') + && str_contains($routes, '/dashboard/buchungen/{bucket}/{year}/{month}'), + 'Routen für Jahres- und Monatsdetails sind registriert' + ); + + $dashboard = (string)file_get_contents(__DIR__ . '/../src/Views/dashboard.php'); + $this->assert( + substr_count($dashboard, '/dashboard/buchungen/') >= 1 && str_contains($dashboard, "\$detail("), + 'Die Kachelzahlen verlinken auf die Detailansicht' + ); + $this->assertEquals( + 9, + substr_count($dashboard, '$detail('), + 'Alle neun Zahlen der ersten drei Kacheln sind verlinkt' + ); + } + + private function createBeleg(): int + { + Database::execute( + 'INSERT INTO belege (beleg_nr, filename, storage_path, mime_type, file_hash, beleg_datum, belegtyp) + VALUES (?, ?, ?, ?, ?, ?, ?)', + [ + 'TST-DASH-' . substr(bin2hex(random_bytes(4)), 0, 8), + 'dashboard-test.pdf', + 'test/dashboard-test.pdf', + 'application/pdf', + hash('sha256', uniqid('dash', true)), + date('Y-m-d'), + 'Eingangsrechnung', + ] + ); + return (int)Database::lastInsertId(); + } + + private function createBooking( + int $belegId, + string $datum, + string $typ, + int $kategorieKonto, + int $zahlungsKonto, + float $netto + ): void { + $fy = Database::fetchOne('SELECT id FROM fiscal_years WHERE year = ?', [(int)date('Y', strtotime($datum))]); + if (!$fy) { + Database::execute('INSERT INTO fiscal_years (year) VALUES (?)', [(int)date('Y', strtotime($datum))]); + $fiscalYearId = (int)Database::lastInsertId(); + } else { + $fiscalYearId = (int)$fy['id']; + } + + Database::execute( + 'INSERT INTO bookings + (beleg_id, fiscal_year_id, buchungs_nr, buchungs_datum, buchungstyp, + kategorie_konto_id, zahlungskonto_id, ust_satz, betrag_netto, betrag_ust, + betrag_brutto, buchungstext) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)', + [ + $belegId, + $fiscalYearId, + 'TST-' . substr(bin2hex(random_bytes(5)), 0, 10), + $datum, + $typ, + $kategorieKonto, + $zahlungsKonto, + 0, + $netto, + 0, + $netto, + 'Testbuchung Dashboard-Abgleich', + ] + ); + } +} diff --git a/tests/e2e/specs/dashboard-detail.spec.js b/tests/e2e/specs/dashboard-detail.spec.js new file mode 100644 index 0000000..ce1ac5c --- /dev/null +++ b/tests/e2e/specs/dashboard-detail.spec.js @@ -0,0 +1,88 @@ +// @ts-check +// Hotspot: Detailansichten hinter den Dashboard-Kacheln (Issue #167). +// +// Die Kachelzahlen sind Links auf /dashboard/buchungen/{gruppe}/{jahr}[/{monat}]. +// Ob die verlinkte Liste tatsächlich auf den angezeigten Kachelwert summiert, +// lässt sich nur am gerenderten Dashboard prüfen — die Summenlogik selbst deckt +// tests/80_DashboardEuerAbgleichTest.php ab. +// +// Die Tests lesen ausschließlich; es werden keine Daten angelegt oder verändert. + +const { test, expect } = require('@playwright/test') +const { gotoOk, parseMoney } = require('../helpers/app') + +/** Liest Kachelwert und Ziel-URL der Jahreszahl einer Kachel. */ +async function kachel(page, ueberschrift) { + const karte = page.locator('.stat-card', { hasText: ueberschrift }).first() + await expect(karte, `Kachel "${ueberschrift}" nicht gefunden`).toHaveCount(1) + const link = karte.locator('.stat-value a').first() + return { + wert: parseMoney(await link.textContent()), + url: await link.getAttribute('href'), + } +} + +test.describe('Detailansichten der Dashboard-Kacheln', () => { + for (const [ueberschrift, gruppe] of [ + ['Umsatz', 'einnahmen'], + ['Ausgaben', 'ausgaben'], + ['Gewinn / Verlust', 'ergebnis'], + ]) { + test(`Kachel "${ueberschrift}" führt auf die Buchungen, die sie aufsummiert`, async ({ page }) => { + await gotoOk(page, '/dashboard') + + const { wert, url } = await kachel(page, ueberschrift) + expect(url).toContain(`/dashboard/buchungen/${gruppe}/`) + expect(Number.isNaN(wert)).toBe(false) + + await gotoOk(page, url) + + // Ohne Buchungen im laufenden Jahr gibt es nichts zu vergleichen. + const leer = await page.locator('text=Keine Buchungen in diesem Zeitraum.').count() + if (leer > 0) { + expect(wert).toBeCloseTo(0, 2) + return + } + + const summe = parseMoney(await page.locator('tfoot tr td').last().textContent()) + expect(summe, `Listensumme weicht vom Kachelwert ab (${ueberschrift})`).toBeCloseTo(wert, 2) + }) + } + + test('Monatszahlen verlinken auf denselben Monat', async ({ page }) => { + await gotoOk(page, '/dashboard') + + const karte = page.locator('.stat-card', { hasText: 'Umsatz' }).first() + const monatsLinks = karte.locator('.stat-meta a') + await expect(monatsLinks).toHaveCount(2) + + for (let i = 0; i < 2; i++) { + const url = await monatsLinks.nth(i).getAttribute('href') + // Vormonat und laufender Monat: /…/{jahr}/{monat} + expect(url).toMatch(/\/dashboard\/buchungen\/einnahmen\/\d{4}\/\d{1,2}$/) + const erwartet = parseMoney(await monatsLinks.nth(i).textContent()) + + await gotoOk(page, url) + const leer = await page.locator('text=Keine Buchungen in diesem Zeitraum.').count() + if (leer > 0) { + expect(erwartet).toBeCloseTo(0, 2) + } else { + const summe = parseMoney(await page.locator('tfoot tr td').last().textContent()) + expect(summe).toBeCloseTo(erwartet, 2) + } + await gotoOk(page, '/dashboard') + } + }) + + test('Unbekannte Gruppe führt zurück aufs Dashboard', async ({ page }) => { + await page.goto('/dashboard/buchungen/unsinn/2026') + expect(new URL(page.url()).pathname).toBe('/dashboard') + await expect(page.locator('body')).toContainText('Unbekannte Auswertung.') + }) + + test('Ungültiger Monat führt zurück aufs Dashboard', async ({ page }) => { + await page.goto('/dashboard/buchungen/einnahmen/2026/13') + expect(new URL(page.url()).pathname).toBe('/dashboard') + await expect(page.locator('body')).toContainText('Ungültiger Monat.') + }) +})