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:
commit
f73db4e033
25 changed files with 1236 additions and 148 deletions
32
Changes.md
32
Changes.md
|
|
@ -2,6 +2,38 @@
|
|||
|
||||
---
|
||||
|
||||
## 2026-08-02 – Dashboard rechnet wie die EÜR, Ereignisse zeigen alle Fehler, kompaktere Buchungszeilen
|
||||
|
||||
### Dashboard-Zahlen stimmen mit der EÜR überein und lassen sich aufschlüsseln (#167)
|
||||
|
||||
- Das Dashboard summierte nach `bookings.buchungstyp`, die EÜR nach `accounts.kontenart` – für 2026 wich das Ergebnis um 1.241,17 € ab (16.903,50 € statt 18.144,67 €). Im Einzelnen zählte das Dashboard 818,79 € Vorsteuer (Konten 1571/1576/1574, Kontenart „Zahlungskonto") und 350,00 € Anlagenzugang als Betriebsausgabe, ließ AfA-Buchungen komplett aus (`buchungstyp='AfA'` kam in keiner der beiden Abfragen vor) und minderte das Ergebnis um 389,42 € nicht abzugsfähige Bewirtungskosten, die die EÜR getrennt ausweist.
|
||||
- **`src/Services/EuerCalculator.php`:** Neue Methoden `summary($jahr, $monat = null)` und `fetchBookingsForBucket($jahr, $monat, $gruppe)`. Beide leiten ihre Bedingung aus denselben Konstanten ab wie `calculate()` – Zuordnungsregeln stehen damit an genau einer Stelle.
|
||||
- **`src/Controllers/AccountController.php`:** `dashboard()` bezieht Umsatz, Ausgaben und Ergebnis für Jahr, Vormonat und laufenden Monat aus `summary()`; die sechs handgeschriebenen SQL-Abfragen entfallen. Neue Action `bucketBookings()` für die Detailansicht.
|
||||
- **`src/Views/dashboard.php`, `src/Views/dashboard_bucket.php`** (neu): Alle neun Zahlen der ersten drei Kacheln verlinken auf `/dashboard/buchungen/{gruppe}/{jahr}[/{monat}]`. Die Liste zeigt Datum, Buchungsnummer, Beleg, Text und Konten mit Summenzeile; in der Ergebnisansicht stehen Betriebsausgaben mit negativem Vorzeichen, sodass die Spaltensumme dem Kachelwert entspricht. Nicht abzugsfähige Ausgaben werden unterhalb der Liste beziffert statt stillschweigend weggelassen.
|
||||
- **`tests/80_DashboardEuerAbgleichTest.php`** (neu), **`tests/e2e/specs/dashboard-detail.spec.js`** (neu): Der PHP-Test bucht Aufwand, AfA, Vorsteuer und nicht abzugsfähige Bewirtung und prüft, was davon wo landet; der E2E-Test vergleicht den angezeigten Kachelwert mit der Summe der verlinkten Liste.
|
||||
|
||||
### Ereignisse umfassen alle Fehler und Warnungen (#165)
|
||||
|
||||
- Die Ereignisliste kannte nur den Nextcloud-Import. Fehler an anderer Stelle – etwa ein gescheiterter Beleg-Upload – waren nur als kurz sichtbare Meldung im Browser oder im Server-Log nachvollziehbar.
|
||||
- **`migrations/091_system_events_level.sql`** (neu): Spalte `level` (info/warning/error) samt Indizes. Bestehende Einträge werden anhand ihres Textpräfixes eingestuft, nach dem die Liste sie bisher eingefärbt hat.
|
||||
- **`src/Models/EventLog.php`:** `add()` nimmt einen Schweregrad entgegen, dazu `warning()`, `error()`, `find()` mit Filter nach Bereich und Schweregrad sowie `channels()`. `tryAdd()` schreibt das Ereignis, ohne selbst scheitern zu können – nötig für Aufrufer, die bereits einen Fehler behandeln.
|
||||
- **Neue Fehlerquellen:** `View::redirect()` protokolliert jede Meldung vom Typ `error` oder `warning` im Bereich `meldungen` – damit ist jede dem Nutzer angezeigte Fehlermeldung erfasst, ohne sie einzeln anbinden zu müssen. `BelegController::renderUploadErrors()` schreibt zusätzlich im Bereich `beleg-upload` mit Dateiname, `public/index.php` protokolliert unbehandelte Ausnahmen und schwere PHP-Fehler im Bereich `system`.
|
||||
- **`src/Controllers/EventController.php`, `src/Views/ereignisse/index.php`, `src/Views/ereignisse/_body.php`:** Aus der Nextcloud-Seite wird eine Übersicht über alle Bereiche mit zwei Auswahlfeldern (Bereich, Schweregrad), die nebeneinander stehen – beide setzen `width:auto` gegen das globale `select { width: 100% }`, das sie sonst im Flex-Formular untereinander schiebt. Der automatische Neuaufbau alle 60 Sekunden behält den eingestellten Filter. `/ereignisse/nextcloud-import` leitet auf die gefilterte Übersicht weiter, der bisherige Daten-Endpunkt bleibt erreichbar.
|
||||
- **`tests/81_EreignisseAlleFehlerTest.php`** (neu), **`tests/e2e/specs/ereignisse.spec.js`** (neu), **`tests/58_EreignisAutoReloadTest.php`** (angepasst).
|
||||
|
||||
### Buchungszeilen brauchen weniger Breite (#166)
|
||||
|
||||
- **`src/Views/belege/detail.php`:** Buchungsnummer, Buchungsdatum und Buchungstyp teilen sich in der Belegansicht eine Spalte (Nummer oben, Datum darunter, Typ-Badge zuunterst) – wie bereits bei Kategorie- und Zahlungskonto. Die Spalte **Anlage** entfällt ersatzlos; das Wirtschaftsgut ist weiterhin über Journal, UStVA- und EÜR-Drill-down sowie die Anlagenliste erreichbar. Von acht Spalten bleiben sechs, die Tabelle ist ohne waagerechtes Scrollen lesbar.
|
||||
- **`src/View.php`:** `formatMoney()` setzt zwischen Betrag und `€` ein geschütztes Leerzeichen. In schmalen Spalten rutschte das Währungszeichen sonst allein in die nächste Zeile. Wirkt in allen HTML-Ansichten; die PDF-Dienste haben eigene Formatierer und bleiben unberührt.
|
||||
- **`docs/anlagevermögen.md`:** Der Abschnitt „Anzeige in Buchungstabellen" nennt die Beleg-Detailansicht nicht mehr unter den Übersichten mit Anlage-Spalte.
|
||||
- **`tests/e2e/specs/beleg-buchung.spec.js`:** Tests auf die zusammengelegte Spalte (inklusive Typ und entfallener Anlage-Spalte) und darauf, dass Betrag und Währungszeichen in einer Zeile bleiben; die Betragsprüfungen der bestehenden Tests greifen jetzt auf die verschobene Zelle zu.
|
||||
|
||||
### Nebenbei
|
||||
|
||||
- **`docs/berechnungslogik.md`:** Die Tabelle der EÜR-Zuordnung nannte für nicht abzugsfähige Ausgaben noch `euer_zeile='51'`; seit Migration 075 ist es `'74'`, und `51` ist eine gewöhnliche Ausgabenzeile (u.a. Bürobedarf 4930). Korrigiert und um Anlagevermögen, Vorsteuerkonten und die Dashboard-Kacheln ergänzt.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-02 – Testläufe hinterlassen keine Verzeichnisse mehr, die echte Uploads blockieren
|
||||
|
||||
- Die Testsuite lief über `docker compose exec` als *root*, Apache/PHP dagegen als `www-data`. Legte ein Test unterhalb von `data/` ein Verzeichnis an, gehörte es root und war für die Anwendung nicht beschreibbar. Weil der Rollback nur die Datenbank zurückdreht, wurde die im Test vergebene Belegnummer danach erneut vergeben – der nächste echte Upload traf auf das bestehende Verzeichnis, übersprang das `mkdir` und scheiterte in `move_uploaded_file()` mit HTTP 500. In #168 war nur der konkrete Verursacher entschärft worden, nicht die Ursache.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
18
migrations/091_system_events_level.sql
Normal file
18
migrations/091_system_events_level.sql
Normal 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%';
|
||||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
31
src/View.php
31
src/View.php
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
namespace Steuerkram;
|
||||
|
||||
use Steuerkram\Auth;
|
||||
use Steuerkram\Models\EventLog;
|
||||
|
||||
class View
|
||||
{
|
||||
|
|
@ -37,11 +38,37 @@ class View
|
|||
Auth::startSession();
|
||||
if ($message !== '') {
|
||||
$_SESSION['flash'][] = ['type' => $type, 'message' => $message];
|
||||
self::logFlash($type, $message);
|
||||
}
|
||||
header('Location: ' . $path);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fehler- und Warnhinweise, die dem Nutzer angezeigt werden, wandern
|
||||
* zusätzlich in die Ereignisliste – dort sind sie auch dann noch
|
||||
* nachvollziehbar, wenn die Meldung längst weggeklickt ist (Issue #165).
|
||||
*/
|
||||
private static function logFlash(string $type, string $message): void
|
||||
{
|
||||
$level = match ($type) {
|
||||
'error' => EventLog::LEVEL_ERROR,
|
||||
'warning' => EventLog::LEVEL_WARNING,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($level === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pfad = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH);
|
||||
EventLog::tryAdd(
|
||||
'meldungen',
|
||||
$pfad ? "{$message} ({$pfad})" : $message,
|
||||
$level
|
||||
);
|
||||
}
|
||||
|
||||
public static function h(mixed $value): string
|
||||
{
|
||||
return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
|
|
@ -49,7 +76,9 @@ class View
|
|||
|
||||
public static function formatMoney(mixed $value): string
|
||||
{
|
||||
return number_format((float)$value, 2, ',', '.') . ' €';
|
||||
// Geschütztes Leerzeichen: in schmalen Tabellenspalten rutscht das
|
||||
// Währungszeichen sonst allein in die nächste Zeile.
|
||||
return number_format((float)$value, 2, ',', '.') . "\u{00A0}€";
|
||||
}
|
||||
|
||||
public static function formatDate(?string $date): string
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
·
|
||||
<?= $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>
|
||||
·
|
||||
<?= $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)' ?>">
|
||||
<?= $fmt($stats['gewinn']) ?>
|
||||
<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>
|
||||
·
|
||||
<?= $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">
|
||||
|
|
|
|||
113
src/Views/dashboard_bucket.php
Normal file
113
src/Views/dashboard_bucket.php
Normal 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. 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; ?>
|
||||
37
src/Views/ereignisse/_body.php
Normal file
37
src/Views/ereignisse/_body.php
Normal 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; ?>
|
||||
|
|
@ -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; ?>
|
||||
|
|
@ -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) {
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
200
tests/80_DashboardEuerAbgleichTest.php
Normal file
200
tests/80_DashboardEuerAbgleichTest.php
Normal 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',
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
109
tests/81_EreignisseAlleFehlerTest.php
Normal file
109
tests/81_EreignisseAlleFehlerTest.php
Normal 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
88
tests/e2e/specs/dashboard-detail.spec.js
Normal file
88
tests/e2e/specs/dashboard-detail.spec.js
Normal 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.')
|
||||
})
|
||||
})
|
||||
76
tests/e2e/specs/ereignisse.spec.js
Normal file
76
tests/e2e/specs/ereignisse.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue