Fix #167: Dashboard-Kacheln rechnen wie die EÜR und führen auf ihre Einzelbuchungen
This commit is contained in:
parent
c9afc3e33f
commit
3ba3912fa4
10 changed files with 661 additions and 62 deletions
30
Changes.md
30
Changes.md
|
|
@ -2,6 +2,36 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-08-02 – Dashboard rechnet wie die EÜR, Ereignisse zeigen alle Fehler, kompaktere Buchungszeilen
|
||||||
|
|
||||||
|
### Dashboard-Zahlen stimmen mit der EÜR überein und lassen sich aufschlüsseln (#167)
|
||||||
|
|
||||||
|
- Das Dashboard summierte nach `bookings.buchungstyp`, die EÜR nach `accounts.kontenart` – für 2026 wich das Ergebnis um 1.241,17 € ab (16.903,50 € statt 18.144,67 €). Im Einzelnen zählte das Dashboard 818,79 € Vorsteuer (Konten 1571/1576/1574, Kontenart „Zahlungskonto") und 350,00 € Anlagenzugang als Betriebsausgabe, ließ AfA-Buchungen komplett aus (`buchungstyp='AfA'` kam in keiner der beiden Abfragen vor) und minderte das Ergebnis um 389,42 € nicht abzugsfähige Bewirtungskosten, die die EÜR getrennt ausweist.
|
||||||
|
- **`src/Services/EuerCalculator.php`:** Neue Methoden `summary($jahr, $monat = null)` und `fetchBookingsForBucket($jahr, $monat, $gruppe)`. Beide leiten ihre Bedingung aus denselben Konstanten ab wie `calculate()` – Zuordnungsregeln stehen damit an genau einer Stelle.
|
||||||
|
- **`src/Controllers/AccountController.php`:** `dashboard()` bezieht Umsatz, Ausgaben und Ergebnis für Jahr, Vormonat und laufenden Monat aus `summary()`; die sechs handgeschriebenen SQL-Abfragen entfallen. Neue Action `bucketBookings()` für die Detailansicht.
|
||||||
|
- **`src/Views/dashboard.php`, `src/Views/dashboard_bucket.php`** (neu): Alle neun Zahlen der ersten drei Kacheln verlinken auf `/dashboard/buchungen/{gruppe}/{jahr}[/{monat}]`. Die Liste zeigt Datum, Buchungsnummer, Beleg, Text und Konten mit Summenzeile; in der Ergebnisansicht stehen Betriebsausgaben mit negativem Vorzeichen, sodass die Spaltensumme dem Kachelwert entspricht. Nicht abzugsfähige Ausgaben werden unterhalb der Liste beziffert statt stillschweigend weggelassen.
|
||||||
|
- **`tests/80_DashboardEuerAbgleichTest.php`** (neu), **`tests/e2e/specs/dashboard-detail.spec.js`** (neu): Der PHP-Test bucht Aufwand, AfA, Vorsteuer und nicht abzugsfähige Bewirtung und prüft, was davon wo landet; der E2E-Test vergleicht den angezeigten Kachelwert mit der Summe der verlinkten Liste.
|
||||||
|
|
||||||
|
### Ereignisse umfassen alle Fehler und Warnungen (#165)
|
||||||
|
|
||||||
|
- Die Ereignisliste kannte nur den Nextcloud-Import. Fehler an anderer Stelle – etwa ein gescheiterter Beleg-Upload – waren nur als kurz sichtbare Meldung im Browser oder im Server-Log nachvollziehbar.
|
||||||
|
- **`migrations/091_system_events_level.sql`** (neu): Spalte `level` (info/warning/error) samt Indizes. Bestehende Einträge werden anhand ihres Textpräfixes eingestuft, nach dem die Liste sie bisher eingefärbt hat.
|
||||||
|
- **`src/Models/EventLog.php`:** `add()` nimmt einen Schweregrad entgegen, dazu `warning()`, `error()`, `find()` mit Filter nach Bereich und Schweregrad sowie `channels()`. `tryAdd()` schreibt das Ereignis, ohne selbst scheitern zu können – nötig für Aufrufer, die bereits einen Fehler behandeln.
|
||||||
|
- **Neue Fehlerquellen:** `View::redirect()` protokolliert jede Meldung vom Typ `error` oder `warning` im Bereich `meldungen` – damit ist jede dem Nutzer angezeigte Fehlermeldung erfasst, ohne sie einzeln anbinden zu müssen. `BelegController::renderUploadErrors()` schreibt zusätzlich im Bereich `beleg-upload` mit Dateiname, `public/index.php` protokolliert unbehandelte Ausnahmen und schwere PHP-Fehler im Bereich `system`.
|
||||||
|
- **`src/Controllers/EventController.php`, `src/Views/ereignisse/index.php`, `src/Views/ereignisse/_body.php`:** Aus der Nextcloud-Seite wird eine Übersicht über alle Bereiche mit zwei Auswahlfeldern (Bereich, Schweregrad). Der automatische Neuaufbau alle 60 Sekunden behält den eingestellten Filter. `/ereignisse/nextcloud-import` leitet auf die gefilterte Übersicht weiter, der bisherige Daten-Endpunkt bleibt erreichbar.
|
||||||
|
- **`tests/81_EreignisseAlleFehlerTest.php`** (neu), **`tests/e2e/specs/ereignisse.spec.js`** (neu), **`tests/58_EreignisAutoReloadTest.php`** (angepasst).
|
||||||
|
|
||||||
|
### Buchungszeilen brauchen weniger Breite (#166)
|
||||||
|
|
||||||
|
- **`src/Views/belege/detail.php`:** Buchungsnummer und Buchungsdatum teilen sich in der Belegansicht eine Spalte (Nummer oben, Datum darunter) – wie bereits bei Kategorie- und Zahlungskonto. Die Tabelle ist damit eine Spalte schmaler und ohne waagerechtes Scrollen lesbar.
|
||||||
|
- **`tests/e2e/specs/beleg-buchung.spec.js`:** Neuer Test auf die zusammengelegte Spalte; die Betragsprüfungen der bestehenden Tests greifen jetzt auf die verschobene Zelle zu.
|
||||||
|
|
||||||
|
### Nebenbei
|
||||||
|
|
||||||
|
- **`docs/berechnungslogik.md`:** Die Tabelle der EÜR-Zuordnung nannte für nicht abzugsfähige Ausgaben noch `euer_zeile='51'`; seit Migration 075 ist es `'74'`, und `51` ist eine gewöhnliche Ausgabenzeile (u.a. Bürobedarf 4930). Korrigiert und um Anlagevermögen, Vorsteuerkonten und die Dashboard-Kacheln ergänzt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-08-02 – Testläufe hinterlassen keine Verzeichnisse mehr, die echte Uploads blockieren
|
## 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.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,13 @@ Die EÜR basiert auf dem Feld `accounts.kontenart` sowie `accounts.euer_zeile` d
|
||||||
| kontenart | euer_zeile | EÜR-Abschnitt |
|
| kontenart | euer_zeile | EÜR-Abschnitt |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `Betriebseinnahme` | beliebig | Betriebseinnahmen |
|
| `Betriebseinnahme` | beliebig | Betriebseinnahmen |
|
||||||
| `Betriebsausgabe` | `≠ '51'` | Betriebsausgaben (fließen in `total_ausgaben` und `gewinn` ein) |
|
| `Betriebsausgabe` | `≠ '74'` | 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'` | 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) |
|
| `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
|
### Vorzeichen
|
||||||
|
|
||||||
|
|
@ -35,6 +36,14 @@ Die EÜR basiert auf dem Feld `accounts.kontenart` sowie `accounts.euer_zeile` d
|
||||||
- Sortierung: nach `accounts.kontonummer` aufsteigend
|
- Sortierung: nach `accounts.kontonummer` aufsteigend
|
||||||
- Ergebnis: `total_einnahmen − total_ausgaben = gewinn`
|
- 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)
|
## UStVA (Umsatzsteuervoranmeldung)
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,26 @@ Vollständige Kontenliste: [kontenrahmen.md](kontenrahmen.md)
|
||||||
- KI-gestützter Kontierungsvorschlag (optional, siehe [konfiguration.md](konfiguration.md))
|
- KI-gestützter Kontierungsvorschlag (optional, siehe [konfiguration.md](konfiguration.md))
|
||||||
- Nextcloud-Import via WebDAV (optional)
|
- Nextcloud-Import via WebDAV (optional)
|
||||||
- Belege können nicht gelöscht werden, solange Buchungen darauf verweisen (GoBD)
|
- 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,8 @@ $router = new Router();
|
||||||
// ── Dashboard ────────────────────────────────────────────────
|
// ── Dashboard ────────────────────────────────────────────────
|
||||||
$router->get('/', fn($p) => (new AccountController())->dashboard($p));
|
$router->get('/', fn($p) => (new AccountController())->dashboard($p));
|
||||||
$router->get('/dashboard', 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 ─────────────────────────────────────────────
|
// ── Kontenrahmen ─────────────────────────────────────────────
|
||||||
$router->get('/accounts', fn($p) => (new AccountController())->index($p));
|
$router->get('/accounts', fn($p) => (new AccountController())->index($p));
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ namespace Steuerkram\Controllers;
|
||||||
use Steuerkram\Models\Account;
|
use Steuerkram\Models\Account;
|
||||||
use Steuerkram\Models\Booking;
|
use Steuerkram\Models\Booking;
|
||||||
use Steuerkram\Models\Beleg;
|
use Steuerkram\Models\Beleg;
|
||||||
|
use Steuerkram\Services\EuerCalculator;
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
|
|
||||||
class AccountController extends BaseController
|
class AccountController extends BaseController
|
||||||
|
|
@ -22,57 +23,29 @@ class AccountController extends BaseController
|
||||||
// Quick stats
|
// Quick stats
|
||||||
$stats = [];
|
$stats = [];
|
||||||
|
|
||||||
// Einnahmen
|
// Einnahmen, Ausgaben und Ergebnis nach denselben Regeln wie die EÜR
|
||||||
$umsatz = \Steuerkram\Database::fetchOne(
|
// (maßgeblich ist accounts.kontenart, nicht bookings.buchungstyp) –
|
||||||
'SELECT COALESCE(SUM(bk.betrag_netto),0) as total
|
// sonst weichen Dashboard und EÜR voneinander ab, siehe Issue #167.
|
||||||
FROM bookings bk
|
$calc = new EuerCalculator();
|
||||||
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
|
$jahr = $calc->summary($year);
|
||||||
WHERE bk.buchungstyp = \'Einnahme\' AND bk.is_reversal = 0 AND fy.year = ?',
|
$monat = $calc->summary($year, $month);
|
||||||
[$year]
|
$vormonat = $calc->summary($prevYear, $prevMonth);
|
||||||
);
|
|
||||||
$stats['umsatz'] = (float)($umsatz['total'] ?? 0);
|
|
||||||
|
|
||||||
$r = \Steuerkram\Database::fetchOne(
|
$stats['umsatz'] = $jahr['einnahmen'];
|
||||||
'SELECT COALESCE(SUM(betrag_netto),0) as total FROM bookings
|
$stats['umsatz_monat'] = $monat['einnahmen'];
|
||||||
WHERE buchungstyp=\'Einnahme\' AND is_reversal=0 AND YEAR(buchungs_datum)=? AND MONTH(buchungs_datum)=?',
|
$stats['umsatz_vormonat'] = $vormonat['einnahmen'];
|
||||||
[$year, $month]
|
|
||||||
);
|
|
||||||
$stats['umsatz_monat'] = (float)($r['total'] ?? 0);
|
|
||||||
|
|
||||||
$r = \Steuerkram\Database::fetchOne(
|
$stats['ausgaben'] = $jahr['ausgaben'];
|
||||||
'SELECT COALESCE(SUM(betrag_netto),0) as total FROM bookings
|
$stats['ausgaben_monat'] = $monat['ausgaben'];
|
||||||
WHERE buchungstyp=\'Einnahme\' AND is_reversal=0 AND YEAR(buchungs_datum)=? AND MONTH(buchungs_datum)=?',
|
$stats['ausgaben_vormonat'] = $vormonat['ausgaben'];
|
||||||
[$prevYear, $prevMonth]
|
|
||||||
);
|
|
||||||
$stats['umsatz_vormonat'] = (float)($r['total'] ?? 0);
|
|
||||||
|
|
||||||
// Ausgaben
|
$stats['gewinn'] = $jahr['ergebnis'];
|
||||||
$ausgaben = \Steuerkram\Database::fetchOne(
|
$stats['gewinn_monat'] = $monat['ergebnis'];
|
||||||
'SELECT COALESCE(SUM(bk.betrag_netto),0) as total
|
$stats['gewinn_vormonat'] = $vormonat['ergebnis'];
|
||||||
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);
|
|
||||||
|
|
||||||
$r = \Steuerkram\Database::fetchOne(
|
$stats['prev_year'] = $prevYear;
|
||||||
'SELECT COALESCE(SUM(betrag_netto),0) as total FROM bookings
|
$stats['prev_month'] = $prevMonth;
|
||||||
WHERE buchungstyp=\'Ausgabe\' AND is_reversal=0 AND YEAR(buchungs_datum)=? AND MONTH(buchungs_datum)=?',
|
$stats['month'] = $month;
|
||||||
[$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['year'] = $year;
|
$stats['year'] = $year;
|
||||||
$stats['month_label'] = $monthNames[$month - 1];
|
$stats['month_label'] = $monthNames[$month - 1];
|
||||||
$stats['vormonat_label'] = $monthNames[$prevMonth - 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
|
public function index(array $params): void
|
||||||
{
|
{
|
||||||
$filter = $_GET;
|
$filter = $_GET;
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,26 @@ use Steuerkram\Database;
|
||||||
*/
|
*/
|
||||||
class EuerCalculator
|
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
|
public function calculate(int $year): array
|
||||||
{
|
{
|
||||||
$rows = Database::fetchAll(
|
$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
|
public function fetchBookingsForKonto(int $year, int $kontoId): array
|
||||||
{
|
{
|
||||||
return Database::fetchAll(
|
return Database::fetchAll(
|
||||||
|
|
|
||||||
|
|
@ -2,39 +2,55 @@
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
$h = fn($v) => View::h($v);
|
$h = fn($v) => View::h($v);
|
||||||
$fmt = fn($v) => View::formatMoney($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="stats-grid">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">Umsatz <?= $h($stats['year']) ?></div>
|
<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">Betriebseinnahmen (netto)</div>
|
||||||
<div class="stat-meta" style="font-size:.75rem;margin-top:.25rem;color:var(--color-muted)">
|
<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>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">Ausgaben <?= $h($stats['year']) ?></div>
|
<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">Betriebsausgaben (netto)</div>
|
||||||
<div class="stat-meta" style="font-size:.75rem;margin-top:.25rem;color:var(--color-muted)">
|
<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>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">Gewinn / Verlust <?= $h($stats['year']) ?></div>
|
<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']) ?>
|
<?= $fmt($stats['gewinn']) ?>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-meta">EÜR-Ergebnis</div>
|
<div class="stat-meta">EÜR-Ergebnis</div>
|
||||||
<div class="stat-meta" style="font-size:.75rem;margin-top:.25rem;color:var(--color-muted)">
|
<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>
|
</div>
|
||||||
<div class="stat-card">
|
<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; ?>
|
||||||
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',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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.')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Reference in a new issue