Merge pull request 'Dashboard-Detailansichten (#167), Ereignisse für alle Fehler (#165), kompaktere Buchungszeilen (#166)' (#174) from fds2610-bot/Steuerkram:main into main

Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/174
This commit is contained in:
fds2610 2026-08-02 20:25:09 +02:00
commit f73db4e033
25 changed files with 1236 additions and 148 deletions

View file

@ -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.

View file

@ -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.
---

View file

@ -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)

View file

@ -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
---

View file

@ -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%';

View file

@ -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));

View file

@ -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;

View file

@ -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');

View file

@ -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;
}
}

View file

@ -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'
);
}
}

View file

@ -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<int,int>}
*/
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(

View file

@ -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

View file

@ -218,14 +218,11 @@ $suppliers = $suppliers ?? [];
<table>
<thead>
<tr>
<th>Buchungsnr.</th>
<th>Datum</th>
<th>Typ</th>
<th>Buchungsnr.<br>Datum<br>Typ</th>
<th>Kategorie</th>
<th>Zahlungskonto</th>
<th class="td-right">Wert</th>
<th>Text</th>
<th>Anlage</th>
<th></th>
</tr>
</thead>
@ -235,9 +232,11 @@ $suppliers = $suppliers ?? [];
$isReversal = (bool)$bk['is_reversal'];
?>
<tr style="<?= ($isReversed || $isReversal) ? 'opacity:.55;background:#fff5f5' : '' ?>">
<td class="td-mono text-sm"><?= $h($bk['buchungs_nr']) ?></td>
<td class="td-muted"><?= View::formatDate($bk['buchungs_datum']) ?></td>
<td><span class="badge <?= $bk['buchungstyp'] === 'Einnahme' ? 'badge-green' : 'badge-red' ?>"><?= $h($bk['buchungstyp']) ?></span></td>
<td class="td-mono text-sm">
<?= $h($bk['buchungs_nr']) ?><br>
<span class="td-muted"><?= View::formatDate($bk['buchungs_datum']) ?></span><br>
<span class="badge <?= $bk['buchungstyp'] === 'Einnahme' ? 'badge-green' : 'badge-red' ?>"><?= $h($bk['buchungstyp']) ?></span>
</td>
<td class="td-mono text-sm"><?= $h($bk['kategorie_nr']) ?><br><span class="td-muted"><?= $h($bk['kategorie_name']) ?></span></td>
<td class="td-mono text-sm"><?= $h($bk['zahlungs_nr']) ?><br><span class="td-muted"><?= $h($bk['zahlungs_name']) ?></span></td>
<td class="td-right td-mono"><?= $fmt($bk['betrag_netto']) ?></td>
@ -249,15 +248,6 @@ $suppliers = $suppliers ?? [];
<span class="badge badge-red">Storniert</span>
<?php endif; ?>
</td>
<td class="td-mono text-sm">
<?php if (!empty($bk['anlage_nr'])): ?>
<a href="/assets/<?= (int)$bk['asset_id'] ?>/edit" title="<?= $h($bk['anlage_bezeichnung']) ?>"><?= $h($bk['anlage_nr']) ?></a>
<?php elseif (!empty($bk['anlage_bezeichnung'])): ?>
<a href="/assets/<?= (int)$bk['asset_id'] ?>/edit"><?= $h($bk['anlage_bezeichnung']) ?></a>
<?php else: ?>
<span class="text-muted">–</span>
<?php endif; ?>
</td>
<td>
<?php if (!$bk['locked'] && !$isReversal && !$isReversed): ?>
<div style="display:flex;gap:.35rem">
@ -299,7 +289,7 @@ $suppliers = $suppliers ?? [];
</tr>
<?php endforeach; ?>
<?php if (empty($bookings)): ?>
<tr><td colspan="9" class="text-center text-muted" style="padding:1rem">Noch keine Buchungen.</td></tr>
<tr><td colspan="6" class="text-center text-muted" style="padding:1rem">Noch keine Buchungen.</td></tr>
<?php endif; ?>
</tbody>
</table>

View file

@ -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;
};
?>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Umsatz <?= $h($stats['year']) ?></div>
<div class="stat-value" style="color:var(--color-success)"><?= $fmt($stats['umsatz']) ?></div>
<div class="stat-value">
<a href="<?= $h($detail('einnahmen')) ?>" style="color:var(--color-success)"
title="Einzelbuchungen anzeigen"><?= $fmt($stats['umsatz']) ?></a>
</div>
<div class="stat-meta">Betriebseinnahmen (netto)</div>
<div class="stat-meta" style="font-size:.75rem;margin-top:.25rem;color:var(--color-muted)">
<?= $h($stats['vormonat_label']) ?>: <?= $fmt($stats['umsatz_vormonat']) ?>
<?= $h($stats['vormonat_label']) ?>: <a href="<?= $h($detail('einnahmen', $stats['prev_month'])) ?>" style="color:inherit"><?= $fmt($stats['umsatz_vormonat']) ?></a>
&nbsp;·&nbsp;
<?= $h($stats['month_label']) ?>: <?= $fmt($stats['umsatz_monat']) ?>
<?= $h($stats['month_label']) ?>: <a href="<?= $h($detail('einnahmen', $stats['month'])) ?>" style="color:inherit"><?= $fmt($stats['umsatz_monat']) ?></a>
</div>
</div>
<div class="stat-card">
<div class="stat-label">Ausgaben <?= $h($stats['year']) ?></div>
<div class="stat-value" style="color:var(--color-danger)"><?= $fmt($stats['ausgaben']) ?></div>
<div class="stat-value">
<a href="<?= $h($detail('ausgaben')) ?>" style="color:var(--color-danger)"
title="Einzelbuchungen anzeigen"><?= $fmt($stats['ausgaben']) ?></a>
</div>
<div class="stat-meta">Betriebsausgaben (netto)</div>
<div class="stat-meta" style="font-size:.75rem;margin-top:.25rem;color:var(--color-muted)">
<?= $h($stats['vormonat_label']) ?>: <?= $fmt($stats['ausgaben_vormonat']) ?>
<?= $h($stats['vormonat_label']) ?>: <a href="<?= $h($detail('ausgaben', $stats['prev_month'])) ?>" style="color:inherit"><?= $fmt($stats['ausgaben_vormonat']) ?></a>
&nbsp;·&nbsp;
<?= $h($stats['month_label']) ?>: <?= $fmt($stats['ausgaben_monat']) ?>
<?= $h($stats['month_label']) ?>: <a href="<?= $h($detail('ausgaben', $stats['month'])) ?>" style="color:inherit"><?= $fmt($stats['ausgaben_monat']) ?></a>
</div>
</div>
<div class="stat-card">
<div class="stat-label">Gewinn / Verlust <?= $h($stats['year']) ?></div>
<div class="stat-value" style="color:<?= $stats['gewinn'] >= 0 ? 'var(--color-success)' : 'var(--color-danger)' ?>">
<div class="stat-value">
<a href="<?= $h($detail('ergebnis')) ?>" title="Einzelbuchungen anzeigen"
style="color:<?= $stats['gewinn'] >= 0 ? 'var(--color-success)' : 'var(--color-danger)' ?>">
<?= $fmt($stats['gewinn']) ?>
</a>
</div>
<div class="stat-meta">EÜR-Ergebnis</div>
<div class="stat-meta" style="font-size:.75rem;margin-top:.25rem;color:var(--color-muted)">
<?= $h($stats['vormonat_label']) ?>: <span style="color:<?= $stats['gewinn_vormonat'] >= 0 ? 'var(--color-success)' : 'var(--color-danger)' ?>"><?= $fmt($stats['gewinn_vormonat']) ?></span>
<?= $h($stats['vormonat_label']) ?>: <a href="<?= $h($detail('ergebnis', $stats['prev_month'])) ?>" style="color:<?= $stats['gewinn_vormonat'] >= 0 ? 'var(--color-success)' : 'var(--color-danger)' ?>"><?= $fmt($stats['gewinn_vormonat']) ?></a>
&nbsp;·&nbsp;
<?= $h($stats['month_label']) ?>: <span style="color:<?= $stats['gewinn_monat'] >= 0 ? 'var(--color-success)' : 'var(--color-danger)' ?>"><?= $fmt($stats['gewinn_monat']) ?></span>
<?= $h($stats['month_label']) ?>: <a href="<?= $h($detail('ergebnis', $stats['month'])) ?>" style="color:<?= $stats['gewinn_monat'] >= 0 ? 'var(--color-success)' : 'var(--color-danger)' ?>"><?= $fmt($stats['gewinn_monat']) ?></a>
</div>
</div>
<div class="stat-card">

View file

@ -0,0 +1,113 @@
<?php
use Steuerkram\View;
$h = fn($v) => 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'],
};
?>
<div class="card">
<div class="card-header">
<div>
<div class="card-title"><?= $h($label) ?> – <?= $h($zeitraum) ?></div>
<div class="text-muted text-sm">
Zuordnung nach Kontenart wie in der EÜR<?= $month === null ? '' : ' · Geschäftsjahr ' . $h($year) ?>
</div>
</div>
<div style="display:flex;gap:.5rem">
<a href="/tax/euer/<?= $h($year) ?>" class="btn btn-secondary btn-sm">EÜR <?= $h($year) ?></a>
<a href="/dashboard" class="btn btn-secondary btn-sm">← Zurück zum Dashboard</a>
</div>
</div>
<?php if (empty($bookings)): ?>
<div class="card-body">
<p class="text-muted">Keine Buchungen in diesem Zeitraum.</p>
</div>
<?php else: ?>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Datum</th>
<th>Buchungsnr.</th>
<th>Beleg</th>
<th>Buchungstext</th>
<th>Kategorie</th>
<th>Zahlungskonto</th>
<th class="td-right">Netto</th>
</tr>
</thead>
<tbody>
<?php
$summe = 0.0;
foreach ($bookings as $b):
$wert = $beitrag($b);
$summe += $wert;
$isReversal = (bool)$b['is_reversal'];
$isReversed = !empty($b['reversed_by']);
?>
<tr<?= ($isReversal || $isReversed) ? ' style="opacity:.6;font-style:italic"' : '' ?>>
<td class="td-mono text-sm"><?= View::formatDate($b['buchungs_datum']) ?></td>
<td class="td-mono text-sm"><?= $h($b['buchungs_nr']) ?></td>
<td>
<?php if ($b['beleg_id']): ?>
<a href="/belege/<?= (int)$b['beleg_id'] ?>"><?= $h($b['beleg_nr'] ?? '#' . $b['beleg_id']) ?></a>
<?php elseif (!empty($b['anlage_nr'])): ?>
<a href="/assets/<?= (int)$b['asset_id'] ?>/edit" title="<?= $h($b['anlage_bezeichnung']) ?>"><?= $h($b['anlage_nr']) ?></a>
<?php else: ?>
<span class="text-muted">–</span>
<?php endif; ?>
</td>
<td class="text-sm">
<?= $h($b['buchungstext']) ?>
<?php if ($isReversal): ?>
<span class="badge badge-red">Storno</span>
<?php elseif ($isReversed): ?>
<span class="badge badge-red">Storniert</span>
<?php endif; ?>
</td>
<td class="td-mono text-sm">
<a href="/tax/euer/<?= $h($year) ?>/konto/<?= (int)$b['kategorie_id'] ?>"><?= $h($b['kategorie_nr']) ?></a>
<span class="text-muted">– <?= $h($b['kategorie_name']) ?></span>
</td>
<td class="td-mono text-sm"><?= $h($b['zahlungs_nr']) ?> <span class="text-muted">– <?= $h($b['zahlungs_name']) ?></span></td>
<td class="td-right td-mono"><?= $fmt($wert) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr style="font-weight:700;border-top:2px solid var(--color-border)">
<td colspan="6">Summe (<?= count($bookings) ?> Buchungen)</td>
<td class="td-right td-mono"><?= $fmt($summe) ?></td>
</tr>
</tfoot>
</table>
</div>
<?php endif; ?>
</div>
<?php if ($bucket !== 'einnahmen' && $summary['nicht_abzugsfaehig'] != 0.0): ?>
<div class="card mt-2">
<div class="card-body text-sm text-muted">
Nicht enthalten: <strong><?= $fmt($summary['nicht_abzugsfaehig']) ?></strong> nicht abzugsfähige
Betriebsausgaben (EÜR-Zeile 74, z.&nbsp;B. der nicht abziehbare Anteil der Bewirtungskosten).
Sie mindern das steuerliche Ergebnis nicht und bleiben deshalb – wie in der EÜR – außen vor.
</div>
</div>
<?php endif; ?>

View file

@ -0,0 +1,37 @@
<?php
use Steuerkram\View;
use Steuerkram\Models\EventLog;
$h = fn($v) => 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)'],
];
?>
<?php if (empty($events)): ?>
<div style="padding:2rem;text-align:center" class="text-muted">Keine Ereignisse vorhanden.</div>
<?php else: ?>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:.875rem">
<tbody>
<?php foreach ($events as $e): ?>
<?php
$level = $e['level'] ?? EventLog::LEVEL_INFO;
[$levelLabel, $color] = $levelStyles[$level] ?? $levelStyles[EventLog::LEVEL_INFO];
// Erfolgsmeldungen des Imports bleiben grün wie bisher.
if ($level === EventLog::LEVEL_INFO && str_starts_with($e['message'], 'Importiert')) {
$color = 'var(--color-success,#16a34a)';
}
?>
<tr style="border-bottom:1px solid var(--color-border,#e2e8f0)">
<td style="padding:.35rem 1rem;white-space:nowrap;color:var(--color-muted);font-family:monospace"><?= $h(date('Y-m-d H:i:s', strtotime($e['created_at']))) ?></td>
<td style="padding:.35rem .5rem;white-space:nowrap;font-family:monospace;color:<?= $color ?>"><?= $h($levelLabel) ?></td>
<td style="padding:.35rem .5rem;white-space:nowrap;color:var(--color-muted);font-family:monospace">[<?= $h($e['channel']) ?>]</td>
<td style="padding:.35rem 1rem;color:<?= $color ?>;word-break:break-word"><?= $h($e['message']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>

View file

@ -1,28 +0,0 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<?php if (empty($events)): ?>
<div style="padding:2rem;text-align:center" class="text-muted">Keine Ereignisse vorhanden. Einträge entstehen, sobald der Nextcloud-Import Dateien importiert, überspringt oder auf Fehler stößt.</div>
<?php else: ?>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:.875rem">
<tbody>
<?php foreach ($events as $e): ?>
<?php
$msg = $e['message'];
$isError = str_starts_with($msg, 'FEHLER');
$isDup = str_starts_with($msg, 'Duplikat');
$isOk = str_starts_with($msg, 'Importiert');
$color = $isError ? 'var(--color-danger,#dc2626)'
: ($isDup ? 'var(--color-warning,#d97706)'
: ($isOk ? 'var(--color-success,#16a34a)'
: 'inherit'));
?>
<tr style="border-bottom:1px solid var(--color-border,#e2e8f0)">
<td style="padding:.35rem 1rem;white-space:nowrap;color:var(--color-muted);font-family:monospace"><?= $h(date('Y-m-d H:i:s', strtotime($e['created_at']))) ?></td>
<td style="padding:.35rem .5rem;white-space:nowrap;color:var(--color-muted);font-family:monospace">[<?= $h($e['channel']) ?>]</td>
<td style="padding:.35rem 1rem;color:<?= $color ?>;word-break:break-word"><?= $h($msg) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>

View file

@ -1,3 +1,17 @@
<?php
use Steuerkram\View;
use Steuerkram\Models\EventLog;
$h = fn($v) => 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',
];
?>
<style>
.switch { position: relative; display: inline-block; width: 42px; height: 24px; flex: 0 0 auto; }
.switch input { opacity: 0; width: 0; height: 0; }
@ -15,8 +29,24 @@
</style>
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;padding:.75rem 1rem;border-bottom:1px solid var(--color-border)">
<strong>Nextcloud-Import</strong>
<div style="display:flex;justify-content:space-between;align-items:center;gap:1rem;flex-wrap:wrap;padding:.75rem 1rem;border-bottom:1px solid var(--color-border)">
<form method="get" action="/ereignisse" id="events-filter" style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">
<select name="channel" onchange="this.form.submit()" style="width:auto;min-width:11rem">
<option value="">Alle Bereiche</option>
<?php foreach ($channels as $c): ?>
<option value="<?= $h($c) ?>"<?= $c === $aktiverKanal ? ' selected' : '' ?>><?= $h($c) ?></option>
<?php endforeach; ?>
</select>
<select name="level" onchange="this.form.submit()" style="width:auto;min-width:11rem">
<option value="">Alle Schweregrade</option>
<?php foreach ($levelLabels as $value => $label): ?>
<option value="<?= $h($value) ?>"<?= $value === $aktivesLevel ? ' selected' : '' ?>><?= $h($label) ?></option>
<?php endforeach; ?>
</select>
<?php if ($aktiverKanal !== '' || $aktivesLevel !== ''): ?>
<a href="/ereignisse" class="btn btn-secondary btn-sm">Filter zurücksetzen</a>
<?php endif; ?>
</form>
<div style="display:flex;align-items:center;gap:.75rem">
<span id="events-count" class="text-muted text-sm"><?= count($events) ?> Einträge (neueste zuerst)</span>
<label class="switch" title="Automatische Aktualisierung">
@ -39,6 +69,7 @@
var count = document.getElementById('events-count');
var timer = null;
var lastHtml = body.innerHTML;
var dataUrl = '/ereignisse/data' + window.location.search;
function isEnabled() {
return localStorage.getItem(STORAGE_KEY) !== 'off';
@ -49,7 +80,7 @@
}
function poll() {
fetch('/ereignisse/nextcloud-import/data', { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
fetch(dataUrl, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (res) { return res.json(); })
.then(function (data) {
if (data.html !== lastHtml) {

View file

@ -89,7 +89,11 @@ function navActive(string $prefix, string $current): string {
Ereignisse
</a>
<?php if (str_starts_with($path, '/ereignisse')): ?>
<a href="/ereignisse/nextcloud-import" class="<?= navActive('/ereignisse/nextcloud-import', $path) ?>" style="padding-left:2.25rem;font-size:.875rem">
<a href="/ereignisse?level=error" class="<?= ($_GET['level'] ?? '') === 'error' ? 'active' : '' ?>" style="padding-left:2.25rem;font-size:.875rem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Nur Fehler
</a>
<a href="/ereignisse?channel=nextcloud-import" class="<?= ($_GET['channel'] ?? '') === 'nextcloud-import' ? 'active' : '' ?>" style="padding-left:2.25rem;font-size:.875rem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92V20a2 2 0 0 1-2.18 2A19.79 19.79 0 0 1 11.39 19a19.5 19.5 0 0 1-4.9-4.9 19.79 19.79 0 0 1-3-9.43A2 2 0 0 1 5.32 3H8.4a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L9.4 10.9a16 16 0 0 0 3.71 3.71l1.26-1.26a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
Nextcloud-Import
</a>

View file

@ -3,11 +3,14 @@
declare(strict_types=1);
/**
* Test 58: Auto-Reload der Ereignisanzeige (Nextcloud-Import)
* Test 58: Auto-Reload der Ereignisanzeige
*
* Prüft, dass die Ereignisanzeige einen Ein-/Aus-Button für automatisches
* Neuladen besitzt, per JavaScript periodisch aktualisiert wird und
* dass der zugehörige JSON-Endpunkt sowie die Route existieren.
*
* Seit Issue #165 gibt es statt der eigenen Nextcloud-Seite eine Übersicht
* über alle Bereiche; der Nextcloud-Import ist dort ein Filter.
*/
class EreignisAutoReloadTest extends TestCase
{
@ -15,9 +18,9 @@ class EreignisAutoReloadTest extends TestCase
public function run(): void
{
$viewFile = __DIR__ . '/../src/Views/ereignisse/nextcloud_import.php';
$viewFile = __DIR__ . '/../src/Views/ereignisse/index.php';
$view = file_get_contents($viewFile);
$this->assert($view !== false, 'View-Datei ereignisse/nextcloud_import.php ist lesbar');
$this->assert($view !== false, 'View-Datei ereignisse/index.php ist lesbar');
$this->assert(
str_contains($view, 'id="events-autoreload-toggle"'),
@ -30,10 +33,15 @@ class EreignisAutoReloadTest extends TestCase
);
$this->assert(
str_contains($view, 'setInterval') && str_contains($view, "'/ereignisse/nextcloud-import/data'"),
str_contains($view, 'setInterval') && str_contains($view, "'/ereignisse/data'"),
'JavaScript pollt periodisch den Daten-Endpunkt'
);
$this->assert(
str_contains($view, 'window.location.search'),
'Der Poll übernimmt den eingestellten Filter'
);
$this->assert(
str_contains($view, "toggle.addEventListener('change'"),
'Toggle-Switch schaltet Auto-Reload per Umlegen ein/aus'
@ -46,25 +54,29 @@ class EreignisAutoReloadTest extends TestCase
// Partial für Tabellen-Body existiert und wird sowohl beim initialen
// Laden als auch für den Daten-Endpunkt verwendet
$partialFile = __DIR__ . '/../src/Views/ereignisse/_nextcloud_import_body.php';
$this->assert(file_exists($partialFile), 'Partial _nextcloud_import_body.php existiert');
$partialFile = __DIR__ . '/../src/Views/ereignisse/_body.php';
$this->assert(file_exists($partialFile), 'Partial _body.php existiert');
$controllerFile = __DIR__ . '/../src/Controllers/EventController.php';
$controller = file_get_contents($controllerFile);
$this->assert(
str_contains($controller, 'function nextcloudImportData'),
'EventController::nextcloudImportData() ist implementiert'
str_contains($controller, 'function indexData'),
'EventController::indexData() ist implementiert'
);
$this->assert(
str_contains($controller, "'ereignisse/_nextcloud_import_body'"),
substr_count($controller, "'ereignisse/_body'") >= 2,
'Daten-Endpunkt rendert dasselbe Partial wie die initiale Seite'
);
$routesFile = __DIR__ . '/../public/index.php';
$routes = file_get_contents($routesFile);
$this->assert(
str_contains($routes, "'/ereignisse/nextcloud-import/data'") && str_contains($routes, 'nextcloudImportData'),
'Route /ereignisse/nextcloud-import/data ist registriert'
str_contains($routes, "'/ereignisse/data'") && str_contains($routes, 'indexData'),
'Route /ereignisse/data ist registriert'
);
$this->assert(
str_contains($routes, "'/ereignisse/nextcloud-import/data'"),
'Der bisherige Nextcloud-Endpunkt bleibt erreichbar'
);
}
}

View file

@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
use Steuerkram\Database;
use Steuerkram\Services\EuerCalculator;
/**
* Test 80: Dashboard-Kacheln stimmen mit der EÜR überein (Issue #167)
*
* Das Dashboard summierte bisher nach bookings.buchungstyp, die EÜR nach
* accounts.kontenart. Dadurch zählten Vorsteuer- und Anlagenbuchungen als
* Betriebsausgabe, AfA-Buchungen fielen ganz heraus und nicht abzugsfähige
* Kosten minderten das angezeigte Ergebnis. Geprüft wird, dass beide
* Auswertungen jetzt dieselbe Zuordnung verwenden und dass die Detailliste
* hinter einer Kachel exakt auf deren Summe kommt.
*/
class DashboardEuerAbgleichTest extends TestCase
{
protected string $testName = 'Test 80: Dashboard-Kacheln und EÜR liefern dieselben Summen';
public function run(): void
{
$calc = new EuerCalculator();
$jahr = (int)date('Y');
$erloese = $this->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',
]
);
}
}

View file

@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
use Steuerkram\Database;
use Steuerkram\Models\EventLog;
/**
* Test 81: Ereignisse umfassen alle Fehler und Warnungen (Issue #165)
*
* Bis dahin protokollierte nur der Nextcloud-Import in die Ereignisliste. Jetzt
* hat jeder Eintrag einen Schweregrad, die Liste lässt sich nach Bereich und
* Schweregrad filtern, und Fehlermeldungen aus dem übrigen System – etwa ein
* fehlgeschlagener Beleg-Upload – landen ebenfalls dort.
*/
class EreignisseAlleFehlerTest extends TestCase
{
protected string $testName = 'Test 81: Ereignisliste zeigt Fehler und Warnungen aus dem ganzen System';
public function run(): void
{
// ── Schweregrad wird gespeichert ─────────────────────────────
$spalte = Database::fetchOne("SHOW COLUMNS FROM system_events LIKE 'level'");
$this->assertNotEmpty($spalte, 'Tabelle system_events hat eine Spalte level');
$kanal = 'test-' . uniqid();
EventLog::add($kanal, 'Alles in Ordnung');
EventLog::warning($kanal, 'Etwas ist ungewöhnlich');
EventLog::error($kanal, 'Etwas ist schiefgelaufen');
$alle = EventLog::findByChannel($kanal);
$this->assertEquals(3, count($alle), 'Drei Einträge im Testkanal');
$this->assertEquals('error', $alle[0]['level'], 'Fehler wird als error gespeichert');
$this->assertEquals('warning', $alle[1]['level'], 'Warnung wird als warning gespeichert');
$this->assertEquals('info', $alle[2]['level'], 'Ohne Angabe gilt info');
// Ungültiger Schweregrad fällt auf info zurück
EventLog::add($kanal, 'Unbekannter Grad', 'katastrophe');
$this->assertEquals('info', EventLog::findByChannel($kanal)[0]['level'], 'Ungültiger Schweregrad wird zu info');
// ── Filter nach Schweregrad und Bereich ──────────────────────
$nurFehler = EventLog::find(['channel' => $kanal, 'level' => 'error']);
$this->assertEquals(1, count($nurFehler), 'Filter nach Schweregrad liefert nur den Fehler');
$this->assertEquals('Etwas ist schiefgelaufen', $nurFehler[0]['message'], 'Der richtige Eintrag wird geliefert');
$andererKanal = 'test-' . uniqid();
EventLog::error($andererKanal, 'Fehler im anderen Bereich');
$kanalgefiltert = EventLog::find(['channel' => $kanal]);
$this->assertEquals(4, count($kanalgefiltert), 'Bereichsfilter blendet fremde Kanäle aus');
$ohneFilter = EventLog::find([], 500);
$this->assert(count($ohneFilter) >= 5, 'Ohne Filter erscheinen die Einträge aller Bereiche');
$kanaele = EventLog::channels();
$this->assert(in_array($kanal, $kanaele, true), 'Der Bereich taucht in der Kanalliste auf');
$this->assert(in_array($andererKanal, $kanaele, true), 'Auch der zweite Bereich wird gelistet');
// Ein unbekannter Schweregrad im Filter wird ignoriert statt zu filtern
$this->assertEquals(
4,
count(EventLog::find(['channel' => $kanal, 'level' => 'quatsch'])),
'Unbekannter Schweregrad im Filter wird ignoriert'
);
// ── tryAdd schluckt Fehler statt sie weiterzureichen ─────────
EventLog::tryAdd($kanal, str_repeat('x', 100), EventLog::LEVEL_ERROR);
$this->assertEquals(5, count(EventLog::findByChannel($kanal)), 'tryAdd schreibt den Eintrag');
// ── Fehlerquellen sind angebunden ────────────────────────────
$view = (string)file_get_contents(__DIR__ . '/../src/View.php');
$this->assert(
str_contains($view, 'logFlash') && str_contains($view, 'EventLog::tryAdd'),
'Fehler- und Warnhinweise aus View::redirect() werden protokolliert'
);
$beleg = (string)file_get_contents(__DIR__ . '/../src/Controllers/BelegController.php');
$this->assert(
str_contains($beleg, "EventLog::tryAdd(\n 'beleg-upload'"),
'Fehlgeschlagene Beleg-Uploads werden protokolliert'
);
$front = (string)file_get_contents(__DIR__ . '/../public/index.php');
$this->assert(
str_contains($front, 'steuerkramLogEvent') && substr_count($front, "'system'") >= 2,
'Unbehandelte Ausnahmen und schwere Fehler landen im Bereich system'
);
// ── Ansicht und Routen ───────────────────────────────────────
$controller = (string)file_get_contents(__DIR__ . '/../src/Controllers/EventController.php');
$this->assert(str_contains($controller, 'function indexData'), 'EventController::indexData() existiert');
$this->assert(
str_contains($controller, 'filterFromRequest'),
'Bereich und Schweregrad werden aus der Anfrage übernommen'
);
$routes = (string)file_get_contents(__DIR__ . '/../public/index.php');
$this->assert(str_contains($routes, "'/ereignisse/data'"), 'Route /ereignisse/data ist registriert');
$index = (string)file_get_contents(__DIR__ . '/../src/Views/ereignisse/index.php');
$this->assert(
str_contains($index, 'name="channel"') && str_contains($index, 'name="level"'),
'Die Ansicht bietet Filter für Bereich und Schweregrad'
);
$this->assert(
str_contains($index, 'id="events-autoreload-toggle"') && str_contains($index, 'setInterval'),
'Der automatische Neuaufbau bleibt erhalten'
);
}
}

View file

@ -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(3).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(3).textContent())
expect(gespeichert, 'Bruttoeingabe wurde nicht in Netto umgerechnet').toBeCloseTo(100.0, 2)
})
@ -131,7 +131,66 @@ 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(3).textContent())).toBeCloseTo(100.0, 2)
})
// Issue #166: Buchungsnummer, Buchungsdatum und Typ teilen sich eine Spalte,
// die Spalte „Anlage" entfällt — damit die Tabelle ohne waagerechtes Scrollen
// lesbar bleibt. Prüfbar nur am gerenderten DOM: die Zellaufteilung existiert
// nur in der View.
test('Buchungsnummer, Datum und Typ 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 alle drei Angaben: Buchungsnummer (BU-JAHR-NNNNNN),
// Datum und Buchungstyp.
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}/)
expect(ersteZelle).toMatch(/Einnahme|Ausgabe/)
// Die Kopfzeile führt alle drei Bezeichnungen in einer einzigen Spalte;
// „Anlage" gibt es nicht mehr.
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')
expect(ersteKopfzelle).toContain('Typ')
await expect(kopf.locator('th')).toHaveCount(6)
await expect(kopf.locator('th', { hasText: 'Anlage' })).toHaveCount(0)
})
// Der Betrag darf nicht zwischen Zahl und Währungszeichen umbrechen.
test('Betrag und Währungszeichen bleiben zusammen', async ({ page }) => {
const { id, tag } = await uploadBeleg(page)
belegId = id
await buchungsmaskeVorbereiten(page, { buchungstext: tag })
await page.fill('#betrag_netto_input', '1234.56')
await page.click('#buchung_form button[type="submit"]')
await page.waitForLoadState('networkidle')
const wert = page.locator('table tbody tr', { hasText: tag }).first().locator('td').nth(3)
const text = await wert.textContent()
// Geschütztes Leerzeichen (U+00A0) statt eines gewöhnlichen Leerzeichens.
expect(text).toBe('1.234,56\u00A0€')
// Und der Betrag steht auch gerendert auf einer einzigen Zeile — ein
// Umbruch zwischen Zahl und Währungszeichen ergäbe zwei Rechtecke.
const fragmente = await wert.evaluate((el) => {
const r = document.createRange()
r.selectNodeContents(el)
return r.getClientRects().length
})
expect(fragmente).toBe(1)
})
// Hinweis: Dass ein Beleg mit Buchungen nicht löschbar ist, ist reine

View file

@ -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.')
})
})

View file

@ -0,0 +1,76 @@
// @ts-check
// Hotspot: Ereignisliste über alle Bereiche (Issue #165).
//
// Die Liste zeigt nicht mehr nur den Nextcloud-Import, sondern auch Fehler und
// Warnungen aus dem übrigen System. Der Weg „Fehlermeldung im Browser → Eintrag
// in der Ereignisliste“ führt über eine Weiterleitung mit Flash-Meldung und ist
// deshalb nur mit Browser prüfbar; die Speicherlogik selbst deckt
// tests/81_EreignisseAlleFehlerTest.php ab.
//
// Die Tests legen keine Geschäftsdaten an. Ein Aufruf einer ungültigen URL
// erzeugt einen Protokolleintrag – genau das ist der geprüfte Effekt.
const { test, expect } = require('@playwright/test')
const { gotoOk } = require('../helpers/app')
test.describe('Ereignisse', () => {
test('Übersicht listet alle Bereiche und bietet Filter', async ({ page }) => {
await gotoOk(page, '/ereignisse')
await expect(page.locator('select[name="channel"]')).toHaveCount(1)
await expect(page.locator('select[name="level"]')).toHaveCount(1)
await expect(page.locator('#events-autoreload-toggle')).toHaveCount(1)
await expect(page.locator('select[name="channel"] option[value=""]')).toContainText('Alle Bereiche')
})
test('Beide Auswahlfelder stehen nebeneinander', async ({ page }) => {
// Global gilt `select { width: 100% }` – ohne eigene Breite bricht das
// zweite Feld im Flex-Formular in die nächste Zeile um.
await page.setViewportSize({ width: 1280, height: 800 })
await gotoOk(page, '/ereignisse')
const bereich = await page.locator('select[name="channel"]').boundingBox()
const grad = await page.locator('select[name="level"]').boundingBox()
expect(Math.abs(bereich.y - grad.y)).toBeLessThan(5)
expect(grad.x).toBeGreaterThan(bereich.x + bereich.width - 1)
})
test('Der alte Nextcloud-Pfad landet auf der gefilterten Übersicht', async ({ page }) => {
await page.goto('/ereignisse/nextcloud-import')
const url = new URL(page.url())
expect(url.pathname).toBe('/ereignisse')
expect(url.searchParams.get('channel')).toBe('nextcloud-import')
await expect(page.locator('select[name="channel"]')).toHaveValue('nextcloud-import')
})
test('Eine Fehlermeldung aus dem Browser erscheint in der Ereignisliste', async ({ page }) => {
// Erzeugt garantiert eine Fehlermeldung samt Weiterleitung (Issue #167).
await page.goto('/dashboard/buchungen/unsinn/2026')
await expect(page.locator('body')).toContainText('Unbekannte Auswertung.')
await gotoOk(page, '/ereignisse?level=error')
await expect(page.locator('body')).toContainText('Unbekannte Auswertung.')
await expect(page.locator('body')).toContainText('[meldungen]')
})
test('Filter nach Schweregrad blendet Infos aus', async ({ page }) => {
await gotoOk(page, '/ereignisse?level=error')
await expect(page.locator('select[name="level"]')).toHaveValue('error')
const zeilen = page.locator('#events-body table tbody tr')
const anzahl = await zeilen.count()
for (let i = 0; i < Math.min(anzahl, 10); i++) {
await expect(zeilen.nth(i)).toContainText('Fehler')
}
})
test('Der Daten-Endpunkt liefert dieselbe gefilterte Liste als JSON', async ({ page }) => {
const antwort = await page.request.get('/ereignisse/data?level=error')
expect(antwort.status()).toBe(200)
const daten = await antwort.json()
expect(typeof daten.count).toBe('number')
expect(typeof daten.html).toBe('string')
})
})