Merge pull request 'Feature: Ereignisanzeige aktualisiert sich automatisch (Ein-/Aus-Button)' (#141) from fds2610-bot/Steuerkram:main into main
Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/141
This commit is contained in:
commit
0d988c8bcd
9 changed files with 292 additions and 32 deletions
15
Changes.md
15
Changes.md
|
|
@ -2,6 +2,21 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-07-26 – Feature: Dritte Eingabezeile ohne USt. in der Essensbeleg-Buchungsvorlage (Issue #140)
|
||||||
|
|
||||||
|
- Die Buchungsvorlage „Essensbeleg n. abzugsfähig (nur USt.)" hatte nur Eingabezeilen für 7 % und 19 % USt. – Beträge ohne USt. (z. B. Trinkgeld) ließen sich damit nicht erfassen.
|
||||||
|
- **`migrations/088_buchungsvorlage_essensbeleg_ohne_ust.sql`:** Ergänzt die Vorlage um ein drittes `ust_zeile`-Feld `netto_0` (0 % USt.) sowie eine Buchungsregel, die den eingegebenen Betrag auf Konto 4657 („Nicht abzugsfähige Bewirtungskosten ohne Vorsteuer") bucht – Formular-Rendering (`buchungsvorlagen/modal.php`), Live-Berechnung (`app.js`) und Buchungsausführung (`VorlageBuchungsService`) sind bereits vollständig datengetrieben und benötigten keine Code-Änderung.
|
||||||
|
- **`tests/59_EssensbelegOhneUstTest.php`:** Neuer Test prüft das neue Feld, die zugehörige Regel und die korrekte Buchung auf Konto 4657 bei gleichzeitiger Nutzung aller drei Zeilen.
|
||||||
|
|
||||||
|
## 2026-07-26 – Feature: Ereignisanzeige aktualisiert sich automatisch (Issue #139)
|
||||||
|
|
||||||
|
- Die Ereignisanzeige (`/ereignisse/nextcloud-import`) war statisch – neu geloggte Ereignisse erschienen erst nach manuellem Neuladen der Seite.
|
||||||
|
- **`src/Controllers/EventController.php`:** Neue Methode `nextcloudImportData()` liefert die aktuellen Ereignisse als JSON (Anzahl + serverseitig gerendertes HTML-Fragment), damit initiale Seite und Auto-Reload denselben Rendering-Code (`ereignisse/_nextcloud_import_body.php`) verwenden.
|
||||||
|
- **`src/Views/ereignisse/_nextcloud_import_body.php`:** Tabellen-/Leer-Markup aus `nextcloud_import.php` in ein Partial ausgelagert, das von initialer Seite und Datenendpunkt gemeinsam genutzt wird.
|
||||||
|
- **`src/Views/ereignisse/nextcloud_import.php`:** Neuer Ein-/Aus-Button oben rechts in der Kartenkopfzeile schaltet automatisches Neuladen per `setInterval` (alle 60s, ohne externe Bibliothek) ein/aus; Zustand wird per `localStorage` gemerkt. Ein echter Push-Mechanismus (z. B. Server-Sent Events) hätte dauerhafte PHP-FPM-Worker gebunden – bei diesem Anwendungsfall (Log-Anzeige) ist periodisches Polling die robustere Lösung.
|
||||||
|
- **`public/index.php`:** Neue Route `GET /ereignisse/nextcloud-import/data`.
|
||||||
|
- **`tests/58_EreignisAutoReloadTest.php`:** Neuer Test prüft Button, Polling-Code, Partial-Wiederverwendung und Routen-Registrierung.
|
||||||
|
|
||||||
## 2026-07-21 – Fix: Zwei dauerhaft fehlschlagende Tests (Session-Cookie-Härtung, Nextcloud-SSRF)
|
## 2026-07-21 – Fix: Zwei dauerhaft fehlschlagende Tests (Session-Cookie-Härtung, Nextcloud-SSRF)
|
||||||
|
|
||||||
- **Test 54 (Session-Cookie-Härtung):** Der Test rief echtes `Auth::startSession()` auf. Da `tests/run.php` Ergebnisse laufend per `echo` ausgibt und Test 54 der einzige Test ist, der Sessions anfasst, war zum Testzeitpunkt bereits Output gesendet – PHP lehnt `session_set_cookie_params()`/`session_start()` danach mit „headers already sent" ab (Warnung + wirkungslos). Ein nachträglicher Output-Buffer im Test half nicht, da bereits vor dem Test unbuffered Output geflossen war.
|
- **Test 54 (Session-Cookie-Härtung):** Der Test rief echtes `Auth::startSession()` auf. Da `tests/run.php` Ergebnisse laufend per `echo` ausgibt und Test 54 der einzige Test ist, der Sessions anfasst, war zum Testzeitpunkt bereits Output gesendet – PHP lehnt `session_set_cookie_params()`/`session_start()` danach mit „headers already sent" ab (Warnung + wirkungslos). Ein nachträglicher Output-Buffer im Test half nicht, da bereits vor dem Test unbuffered Output geflossen war.
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ 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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
21
migrations/088_buchungsvorlage_essensbeleg_ohne_ust.sql
Normal file
21
migrations/088_buchungsvorlage_essensbeleg_ohne_ust.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
-- 088_buchungsvorlage_essensbeleg_ohne_ust.sql
|
||||||
|
-- Ergänzt die Vorlage "Essensbeleg n. abzugsfähig (nur USt.)" um eine dritte
|
||||||
|
-- Eingabezeile für Beträge ohne USt. (z.B. Trinkgeld).
|
||||||
|
-- Idempotent: Feld/Regel werden nur angelegt wenn nicht vorhanden.
|
||||||
|
|
||||||
|
SET @vorlage_name = 'Essensbeleg n. abzugsfähig (nur USt.)';
|
||||||
|
SET @vid = (SELECT id FROM buchungsvorlagen WHERE name = @vorlage_name LIMIT 1);
|
||||||
|
|
||||||
|
INSERT INTO buchungsvorlage_felder (vorlage_id, gruppe_nr, sort_order, feldname, label, typ, ust_satz, meta_key)
|
||||||
|
SELECT @vid, 2, 30, 'netto_0', '0% USt. (z.B. Trinkgeld)', 'ust_zeile', 0.00, NULL
|
||||||
|
WHERE @vid IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM buchungsvorlage_felder WHERE vorlage_id = @vid AND feldname = 'netto_0');
|
||||||
|
|
||||||
|
-- Regel: Betrag ohne USt. → nicht abzugsfähige Bewirtungskosten ohne Vorsteuer (Konto 4657)
|
||||||
|
INSERT INTO buchungsvorlage_regeln (vorlage_id, sort_order, bedingung_feld, bedingung_op, buchungen)
|
||||||
|
SELECT @vid, 30, 'netto_0', 'neq_zero',
|
||||||
|
JSON_ARRAY(
|
||||||
|
JSON_OBJECT('betrag_ref', 'netto_0', 'nach_konto', '4657')
|
||||||
|
)
|
||||||
|
WHERE @vid IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM buchungsvorlage_regeln WHERE vorlage_id = @vid AND bedingung_feld = 'netto_0');
|
||||||
|
|
@ -188,6 +188,7 @@ $router->post('/belege/{belegId}/vorlage/{vorlageId}/buchen', fn($p) =
|
||||||
// ── Ereignisse ───────────────────────────────────────────────
|
// ── Ereignisse ───────────────────────────────────────────────
|
||||||
$router->get('/ereignisse', fn($p) => (new EventController())->index($p));
|
$router->get('/ereignisse', fn($p) => (new EventController())->index($p));
|
||||||
$router->get('/ereignisse/nextcloud-import', fn($p) => (new EventController())->nextcloudImport($p));
|
$router->get('/ereignisse/nextcloud-import', fn($p) => (new EventController())->nextcloudImport($p));
|
||||||
|
$router->get('/ereignisse/nextcloud-import/data', fn($p) => (new EventController())->nextcloudImportData($p));
|
||||||
|
|
||||||
// ── Login / Logout ────────────────────────────────────────────
|
// ── Login / Logout ────────────────────────────────────────────
|
||||||
$router->get('/login', fn($p) => (new LoginController())->showLogin($p));
|
$router->get('/login', fn($p) => (new LoginController())->showLogin($p));
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,23 @@ class EventController extends BaseController
|
||||||
'pageTitle' => 'Ereignisse – Nextcloud-Import',
|
'pageTitle' => 'Ereignisse – Nextcloud-Import',
|
||||||
'content' => $this->renderView('ereignisse/nextcloud_import', [
|
'content' => $this->renderView('ereignisse/nextcloud_import', [
|
||||||
'events' => $events,
|
'events' => $events,
|
||||||
|
'eventsHtml' => $this->renderView('ereignisse/_nextcloud_import_body', [
|
||||||
|
'events' => $events,
|
||||||
|
]),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function nextcloudImportData(array $params): void
|
||||||
|
{
|
||||||
|
$events = EventLog::findByChannel('nextcloud-import', 500);
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'count' => count($events),
|
||||||
|
'html' => $this->renderView('ereignisse/_nextcloud_import_body', [
|
||||||
|
'events' => $events,
|
||||||
|
]),
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
28
src/Views/ereignisse/_nextcloud_import_body.php
Normal file
28
src/Views/ereignisse/_nextcloud_import_body.php
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?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,35 +1,76 @@
|
||||||
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:.75rem 1rem;border-bottom:1px solid var(--color-border)">
|
<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>
|
<strong>Nextcloud-Import</strong>
|
||||||
<span class="text-muted text-sm"><?= count($events) ?> Einträge (neueste zuerst)</span>
|
<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>
|
||||||
|
<button type="button" id="events-autoreload-toggle" class="btn btn-sm btn-secondary"></button>
|
||||||
</div>
|
</div>
|
||||||
<?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>
|
</div>
|
||||||
<?php endif; ?>
|
<div id="events-body">
|
||||||
|
<?= $eventsHtml ?>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var STORAGE_KEY = 'ereignisse-autoreload';
|
||||||
|
var POLL_MS = 60000;
|
||||||
|
var toggle = document.getElementById('events-autoreload-toggle');
|
||||||
|
var body = document.getElementById('events-body');
|
||||||
|
var count = document.getElementById('events-count');
|
||||||
|
var timer = null;
|
||||||
|
var lastHtml = body.innerHTML;
|
||||||
|
|
||||||
|
function isEnabled() {
|
||||||
|
return localStorage.getItem(STORAGE_KEY) !== 'off';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderToggle() {
|
||||||
|
var on = isEnabled();
|
||||||
|
toggle.textContent = on ? 'Auto-Reload: An' : 'Auto-Reload: Aus';
|
||||||
|
toggle.classList.toggle('btn-success', on);
|
||||||
|
toggle.classList.toggle('btn-secondary', !on);
|
||||||
|
}
|
||||||
|
|
||||||
|
function poll() {
|
||||||
|
fetch('/ereignisse/nextcloud-import/data', { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||||
|
.then(function (res) { return res.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
if (data.html !== lastHtml) {
|
||||||
|
lastHtml = data.html;
|
||||||
|
body.innerHTML = data.html;
|
||||||
|
}
|
||||||
|
count.textContent = data.count + ' Einträge (neueste zuerst)';
|
||||||
|
})
|
||||||
|
.catch(function () { /* nächster Versuch beim nächsten Intervall */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
stop();
|
||||||
|
timer = setInterval(poll, POLL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle.addEventListener('click', function () {
|
||||||
|
localStorage.setItem(STORAGE_KEY, isEnabled() ? 'off' : 'on');
|
||||||
|
renderToggle();
|
||||||
|
if (isEnabled()) {
|
||||||
|
poll();
|
||||||
|
start();
|
||||||
|
} else {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
renderToggle();
|
||||||
|
if (isEnabled()) {
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
|
||||||
65
tests/58_EreignisAutoReloadTest.php
Normal file
65
tests/58_EreignisAutoReloadTest.php
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test 58: Auto-Reload der Ereignisanzeige (Nextcloud-Import)
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
class EreignisAutoReloadTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 58: Ereignisanzeige aktualisiert sich automatisch (Ein-/Aus-Button)';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$viewFile = __DIR__ . '/../src/Views/ereignisse/nextcloud_import.php';
|
||||||
|
$view = file_get_contents($viewFile);
|
||||||
|
$this->assert($view !== false, 'View-Datei ereignisse/nextcloud_import.php ist lesbar');
|
||||||
|
|
||||||
|
$this->assert(
|
||||||
|
str_contains($view, 'id="events-autoreload-toggle"'),
|
||||||
|
'Ein-/Aus-Button für Auto-Reload ist vorhanden'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assert(
|
||||||
|
str_contains($view, 'localStorage'),
|
||||||
|
'Zustand des Auto-Reload-Buttons wird persistiert (localStorage)'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assert(
|
||||||
|
str_contains($view, 'setInterval') && str_contains($view, "'/ereignisse/nextcloud-import/data'"),
|
||||||
|
'JavaScript pollt periodisch den Daten-Endpunkt'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assert(
|
||||||
|
str_contains($view, "toggle.addEventListener('click'"),
|
||||||
|
'Button schaltet Auto-Reload per Klick ein/aus'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
|
||||||
|
$controllerFile = __DIR__ . '/../src/Controllers/EventController.php';
|
||||||
|
$controller = file_get_contents($controllerFile);
|
||||||
|
$this->assert(
|
||||||
|
str_contains($controller, 'function nextcloudImportData'),
|
||||||
|
'EventController::nextcloudImportData() ist implementiert'
|
||||||
|
);
|
||||||
|
$this->assert(
|
||||||
|
str_contains($controller, "'ereignisse/_nextcloud_import_body'"),
|
||||||
|
'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'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
tests/59_EssensbelegOhneUstTest.php
Normal file
72
tests/59_EssensbelegOhneUstTest.php
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class EssensbelegOhneUstTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 59: Essensbeleg-Vorlage – dritte Zeile ohne USt. (Trinkgeld)';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$vorlage = \Steuerkram\Database::fetchOne(
|
||||||
|
"SELECT * FROM buchungsvorlagen WHERE name = 'Essensbeleg n. abzugsfähig (nur USt.)' LIMIT 1"
|
||||||
|
);
|
||||||
|
$this->assertNotEmpty($vorlage, 'Seed-Vorlage "Essensbeleg n. abzugsfähig (nur USt.)" vorhanden');
|
||||||
|
$vid = (int)$vorlage['id'];
|
||||||
|
|
||||||
|
// ── Feld netto_0 mit 0% USt. vorhanden ──────────────────────────────
|
||||||
|
$felder = \Steuerkram\Models\Buchungsvorlage::getFelder($vid);
|
||||||
|
$feldNamen = array_column($felder, 'feldname');
|
||||||
|
$this->assert(in_array('netto_0', $feldNamen, true), 'Feld netto_0 (0% USt.) ist Teil der Vorlage');
|
||||||
|
|
||||||
|
$feldNull = current(array_filter($felder, fn($f) => $f['feldname'] === 'netto_0'));
|
||||||
|
$this->assertEquals('ust_zeile', $feldNull['typ'], 'netto_0 ist eine ust_zeile');
|
||||||
|
$this->assertEquals(0.0, (float)$feldNull['ust_satz'], 'netto_0 hat 0% USt.-Satz');
|
||||||
|
|
||||||
|
// ── Regel für netto_0 bucht auf Konto 4657 ──────────────────────────
|
||||||
|
$regeln = \Steuerkram\Models\Buchungsvorlage::getRegeln($vid);
|
||||||
|
$regelNull = current(array_filter($regeln, fn($r) => $r['bedingung_feld'] === 'netto_0'));
|
||||||
|
$this->assertNotEmpty($regelNull, 'Regel für netto_0 vorhanden');
|
||||||
|
$this->assertEquals('4657', $regelNull['buchungen'][0]['nach_konto'], 'netto_0 bucht auf Konto 4657 (ohne Vorsteuer)');
|
||||||
|
|
||||||
|
// ── Buchungsausführung mit allen drei Zeilen (7%, 19%, 0%) ─────────
|
||||||
|
$bankId = \Steuerkram\Database::fetchOne(
|
||||||
|
"SELECT id FROM accounts WHERE kontonummer = '1200' LIMIT 1"
|
||||||
|
);
|
||||||
|
$this->assertNotEmpty($bankId, 'Konto 1200 gefunden');
|
||||||
|
|
||||||
|
$belegNr = 'TEST-ESS-' . uniqid();
|
||||||
|
$belegId = \Steuerkram\Models\Beleg::create([
|
||||||
|
'beleg_nr' => $belegNr,
|
||||||
|
'filename' => 'test-essensbeleg-trinkgeld.pdf',
|
||||||
|
'storage_path' => 'static/uploads/test/test-essensbeleg-trinkgeld.pdf',
|
||||||
|
'mime_type' => 'application/pdf',
|
||||||
|
'file_hash' => hash('sha256', $belegNr),
|
||||||
|
'beleg_datum' => date('Y-m-d'),
|
||||||
|
'belegtyp' => 'Eingangsrechnung',
|
||||||
|
]);
|
||||||
|
$this->assertGreaterThan(0, $belegId, 'Beleg angelegt');
|
||||||
|
|
||||||
|
$svc = new \Steuerkram\Services\VorlageBuchungsService();
|
||||||
|
$ids = $svc->buchen($belegId, $vid, [
|
||||||
|
'netto_7' => '10.00',
|
||||||
|
'ust_7' => '0.70',
|
||||||
|
'netto_19' => '20.00',
|
||||||
|
'ust_19' => '3.80',
|
||||||
|
'netto_0' => '5.00',
|
||||||
|
'zahlungskonto_id' => (int)$bankId['id'],
|
||||||
|
'lieferant' => 'Test-Restaurant',
|
||||||
|
'belegdatum' => date('Y-m-d'),
|
||||||
|
], date('Y-m-d'));
|
||||||
|
|
||||||
|
$this->assertEquals(5, count($ids), '5 Buchungszeilen erzeugt (2x Netto+USt, 1x Trinkgeld ohne USt.)');
|
||||||
|
|
||||||
|
$bookings = \Steuerkram\Models\Booking::forBeleg($belegId);
|
||||||
|
$kontonummern = array_column($bookings, 'kategorie_nr');
|
||||||
|
sort($kontonummern);
|
||||||
|
$this->assertEquals(['1571', '1576', '4654', '4656', '4657'], $kontonummern, 'Buchungen auf Konten 1571, 1576, 4654, 4656 und 4657');
|
||||||
|
|
||||||
|
$trinkgeldBuchung = current(array_filter($bookings, fn($b) => $b['kategorie_nr'] === '4657'));
|
||||||
|
$this->assertEquals(5.0, (float)$trinkgeldBuchung['betrag_netto'], 'Trinkgeld-Betrag korrekt gebucht');
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue