diff --git a/src/Views/tax/euer_konto_bookings.php b/src/Views/tax/euer_konto_bookings.php
index 3c6e296..0c7f16a 100644
--- a/src/Views/tax/euer_konto_bookings.php
+++ b/src/Views/tax/euer_konto_bookings.php
@@ -1,6 +1,5 @@
View::h($v);
$fmt = fn($v) => number_format((float)$v, 2, ',', '.') . ' €';
?>
diff --git a/src/Views/tax/euer_show.php b/src/Views/tax/euer_show.php
index e168520..22c865a 100644
--- a/src/Views/tax/euer_show.php
+++ b/src/Views/tax/euer_show.php
@@ -1,7 +1,5 @@
View::h($v);
-$fmt = fn($v) => View::formatMoney($v);
?>
diff --git a/src/Views/tax/ustva_index.php b/src/Views/tax/ustva_index.php
index a053858..6d67b14 100644
--- a/src/Views/tax/ustva_index.php
+++ b/src/Views/tax/ustva_index.php
@@ -1,5 +1,3 @@
- View::h($v); ?>
-
diff --git a/src/Views/tax/ustva_kz_bookings.php b/src/Views/tax/ustva_kz_bookings.php
index fa386d5..102676b 100644
--- a/src/Views/tax/ustva_kz_bookings.php
+++ b/src/Views/tax/ustva_kz_bookings.php
@@ -1,6 +1,5 @@
View::h($v);
$fmtKz = fn($v) => number_format((float)$v, 2, ',', '.') . ' €';
// Für Kz 69: vorzeichenbehafteter USt-Beitrag zur Zahllast.
diff --git a/src/Views/tax/ustva_show.php b/src/Views/tax/ustva_show.php
index ed8c425..40abaac 100644
--- a/src/Views/tax/ustva_show.php
+++ b/src/Views/tax/ustva_show.php
@@ -1,7 +1,5 @@
View::h($v);
-$fmt = fn($v) => View::formatMoney($v);
$fmtKz = fn($v) => $v !== null ? number_format((float)$v, 2, ',', '.') . ' €' : '–';
$fmtKzGanz = fn($v) => $v !== null ? number_format((float)$v, 0, ',', '.') . ',- €' : '–';
?>
diff --git a/src/Views/useradmin/list.php b/src/Views/useradmin/list.php
index cac5a7f..7943df1 100644
--- a/src/Views/useradmin/list.php
+++ b/src/Views/useradmin/list.php
@@ -1,4 +1,5 @@
- View::h($v);
+
diff --git a/tests/14_UstBruttoAnzeigeTest.php b/tests/14_UstBruttoAnzeigeTest.php
index d793b5b..83045e8 100644
--- a/tests/14_UstBruttoAnzeigeTest.php
+++ b/tests/14_UstBruttoAnzeigeTest.php
@@ -5,8 +5,9 @@ declare(strict_types=1);
/**
* Test 14: USt- und Brutto-Anzeige im Buchungsformular
*
- * Prüft, dass die notwendigen HTML-Elemente und die JS-Funktion
- * updateNettoAnzeige() im Beleg-Detailview vorhanden sind.
+ * Prüft, dass die notwendigen HTML-Elemente (Teil-View _buchungsform.php)
+ * und die JS-Funktion updateNettoAnzeige() (static/js/beleg-form.js)
+ * vorhanden sind.
*/
class UstBruttoAnzeigeTest extends TestCase
{
@@ -14,12 +15,20 @@ class UstBruttoAnzeigeTest extends TestCase
public function run(): void
{
- $viewFile = __DIR__ . '/../src/Views/belege/detail.php';
- $content = file_get_contents($viewFile);
+ $viewFile = __DIR__ . '/../src/Views/belege/_buchungsform.php';
+ $jsFile = __DIR__ . '/../public/static/js/beleg-form.js';
+ $viewContent = file_get_contents($viewFile);
+ $jsContent = file_get_contents($jsFile);
+ $content = $viewContent . "\n" . $jsContent;
$this->assert(
- $content !== false,
- 'View-Datei belege/detail.php ist lesbar'
+ $viewContent !== false,
+ 'Teil-View belege/_buchungsform.php ist lesbar'
+ );
+
+ $this->assert(
+ $jsContent !== false,
+ 'JS-Datei static/js/beleg-form.js ist lesbar'
);
$this->assert(
diff --git a/tests/61_BetragModusToggleTest.php b/tests/61_BetragModusToggleTest.php
index b6ee142..9023234 100644
--- a/tests/61_BetragModusToggleTest.php
+++ b/tests/61_BetragModusToggleTest.php
@@ -5,9 +5,10 @@ declare(strict_types=1);
/**
* Test 61: Netto/Brutto-Umschalter im Buchungsformular
*
- * Prüft, dass im Beleg-Detailview die beiden Umschalt-Buttons für
- * Netto- bzw. Bruttobetrag vorhanden sind und die zugehörige JS-Logik
- * (Moduswechsel, Umrechnung, Konvertierung vor dem Absenden) existiert.
+ * Prüft, dass in der Teil-View _buchungsform.php die beiden Umschalt-Buttons
+ * für Netto- bzw. Bruttobetrag vorhanden sind und die zugehörige JS-Logik
+ * (Moduswechsel, Umrechnung, Konvertierung vor dem Absenden) in
+ * static/js/beleg-form.js existiert.
*/
class BetragModusToggleTest extends TestCase
{
@@ -15,12 +16,20 @@ class BetragModusToggleTest extends TestCase
public function run(): void
{
- $viewFile = __DIR__ . '/../src/Views/belege/detail.php';
- $content = file_get_contents($viewFile);
+ $viewFile = __DIR__ . '/../src/Views/belege/_buchungsform.php';
+ $jsFile = __DIR__ . '/../public/static/js/beleg-form.js';
+ $viewContent = file_get_contents($viewFile);
+ $jsContent = file_get_contents($jsFile);
+ $content = $viewContent . "\n" . $jsContent;
$this->assert(
- $content !== false,
- 'View-Datei belege/detail.php ist lesbar'
+ $viewContent !== false,
+ 'Teil-View belege/_buchungsform.php ist lesbar'
+ );
+
+ $this->assert(
+ $jsContent !== false,
+ 'JS-Datei static/js/beleg-form.js ist lesbar'
);
$this->assert(
diff --git a/tests/85_ModellAbfragenTest.php b/tests/85_ModellAbfragenTest.php
new file mode 100644
index 0000000..80c367e
--- /dev/null
+++ b/tests/85_ModellAbfragenTest.php
@@ -0,0 +1,478 @@
+zahlungsId = (int)$this->findAccountId('1200');
+ $this->kategorieId = (int)$this->findAccountId('8400');
+
+ if (!$this->zahlungsId || !$this->kategorieId) {
+ $this->assert(false, 'Konten 1200/8400 nicht gefunden – SKR03-Seed vorhanden?');
+ return;
+ }
+
+ $this->testDashboardKennzahlen();
+ $this->testRecent();
+ $this->testJournalForAccount();
+ $this->testFiscalYearRegisteredYears();
+ $this->testBewirtungssplit();
+ $this->testDeleteHard();
+ $this->testInvoiceQuerverweise();
+ $this->testInvoiceFinalizeWithBeleg();
+ $this->testBelegUpdates();
+ $this->testUstvaSubmission();
+ $this->testBankImportBatch();
+ $this->testKeineDirektenDatenbankaufrufeInControllern();
+ }
+
+ private function testDashboardKennzahlen(): void
+ {
+ $vorher = Booking::dashboardKennzahlen();
+
+ $customerId = Customer::create([
+ 'nachname' => 'Kennzahlen',
+ 'firma' => 'Kennzahlen GmbH',
+ 'strasse' => 'Teststraße 1',
+ 'plz' => '10115',
+ 'ort' => 'Berlin',
+ 'payment_days' => 14,
+ ]);
+ $invoiceId = Invoice::create([
+ 'customer_id' => $customerId,
+ 'betreff' => 'Testrechnung Dashboard-Kennzahlen',
+ 'rechnungs_datum' => date('Y-m-d'),
+ 'lines' => [[
+ 'bezeichnung' => 'Beratung',
+ 'menge' => 1,
+ 'einheit' => 'Std.',
+ 'einzelpreis' => 500.00,
+ 'ust_satz' => 19.0,
+ ]],
+ ]);
+ Invoice::finalize($invoiceId);
+ $invoice = Invoice::find($invoiceId);
+
+ $belegId = $this->newBeleg('OFFEN', $invoice['rechnungs_datum'], $invoice['rechnungs_nr'], (float)$invoice['betrag_brutto']);
+ Invoice::setBelegId($invoiceId, $belegId);
+ Invoice::markSent($invoiceId, 'Finalisiert');
+
+ // Zweiter, völlig unabhängiger Beleg ohne jede Buchung.
+ $ungebuchterBelegId = $this->newBeleg('UNGEBUCHT', date('Y-m-d'), null, 42.00);
+
+ $nachher = Booking::dashboardKennzahlen();
+
+ $this->assertEquals(
+ $vorher['offene_rechnungen'] + 1,
+ $nachher['offene_rechnungen'],
+ 'dashboardKennzahlen(): offene_rechnungen zählt die neue unbezahlte Rechnung'
+ );
+ $this->assertEquals(
+ round($vorher['offene_rechnungen_sum'] + (float)$invoice['betrag_brutto'], 2),
+ round($nachher['offene_rechnungen_sum'], 2),
+ 'dashboardKennzahlen(): offene_rechnungen_sum enthält den vollen Rechnungsbetrag (keine Zahlung erfasst)'
+ );
+ $this->assertEquals(
+ $vorher['ungebuchte_belege'] + 2,
+ $nachher['ungebuchte_belege'],
+ 'dashboardKennzahlen(): ungebuchte_belege zählt Rechnungs- und Einzelbeleg (beide ohne Buchung)'
+ );
+
+ // Für nachfolgende Tests weiterreichen.
+ $this->offenerBelegId = $belegId;
+ }
+
+ private function testRecent(): void
+ {
+ // ust_satz 0, damit Booking::create() keine zusätzliche USt-Kindbuchung
+ // anlegt – sonst wäre nicht eindeutig, welche der beiden Zeilen jünger ist.
+ $bookingId = Booking::create([
+ 'beleg_id' => $this->offenerBelegId,
+ 'buchungs_datum' => date('Y-m-d'),
+ 'buchungstyp' => 'Einnahme',
+ 'kategorie_konto_id' => $this->kategorieId,
+ 'zahlungskonto_id' => $this->zahlungsId,
+ 'betrag_netto' => 100.00,
+ 'ust_satz' => 0,
+ 'buchungstext' => 'Test recent() Buchung',
+ ]);
+
+ $recentOne = Booking::recent(1);
+ $this->assertEquals(1, count($recentOne), 'recent(1) liefert genau einen Datensatz');
+ $this->assertEquals($bookingId, (int)$recentOne[0]['id'], 'recent(1) liefert die zuletzt angelegte Buchung');
+ $this->assertNotEmpty($recentOne[0]['kategorie_nr'] ?? null, 'recent() liefert die Kategorie-Kontonummer mit');
+ $this->assertNotEmpty($recentOne[0]['beleg_nr'] ?? null, 'recent() liefert die Belegnummer mit');
+
+ $recentMany = Booking::recent(50);
+ $ids = array_column($recentMany, 'id');
+ $this->assert(in_array($bookingId, $ids, true), 'recent(50) enthält die soeben angelegte Buchung');
+
+ $this->recentBookingId = $bookingId;
+ }
+
+ private function testJournalForAccount(): void
+ {
+ $year = (int)date('Y');
+
+ $rows = Booking::journalForAccount($this->kategorieId, $year);
+ $ids = array_column($rows, 'id');
+ $this->assert(
+ in_array($this->recentBookingId, $ids, true),
+ 'journalForAccount() findet die Buchung über das Kategorie-Konto'
+ );
+
+ $rowsZahlung = Booking::journalForAccount($this->zahlungsId, $year);
+ $idsZahlung = array_column($rowsZahlung, 'id');
+ $this->assert(
+ in_array($this->recentBookingId, $idsZahlung, true),
+ 'journalForAccount() findet dieselbe Buchung auch über das Zahlungskonto'
+ );
+
+ $rowsAndererJahr = Booking::journalForAccount($this->kategorieId, $year - 50);
+ $idsAndererJahr = array_column($rowsAndererJahr, 'id');
+ $this->assert(
+ !in_array($this->recentBookingId, $idsAndererJahr, true),
+ 'journalForAccount() filtert korrekt nach Geschäftsjahr'
+ );
+ }
+
+ private function testFiscalYearRegisteredYears(): void
+ {
+ $exotischesJahr = 2091;
+ FiscalYear::findOrCreateByYear($exotischesJahr);
+
+ $jahre = array_column(FiscalYear::registeredYears(), 'year');
+ $this->assert(
+ in_array($exotischesJahr, $jahre, true),
+ 'registeredYears() liefert ein frisch angelegtes Geschäftsjahr'
+ );
+ }
+
+ private function testBewirtungssplit(): void
+ {
+ $belegId = $this->newBeleg('BEWIRTUNG', date('Y-m-d'), null, 119.00);
+
+ $data = [
+ 'beleg_id' => $belegId,
+ 'buchungs_datum' => date('Y-m-d'),
+ 'buchungstyp' => 'Ausgabe',
+ 'kategorie_konto_id' => (int)$this->findAccountId('4650'),
+ 'zahlungskonto_id' => $this->zahlungsId,
+ 'betrag_netto' => 100.00,
+ 'ust_satz' => 19.0,
+ 'buchungstext' => 'Testessen Bewirtung',
+ ];
+
+ Booking::bucheBewirtungssplit($data, '4650');
+
+ $hauptbuchungen = array_values(array_filter(
+ Booking::forBeleg($belegId),
+ fn(array $b): bool => $b['parent_booking_id'] === null
+ ));
+ $this->assertEquals(2, count($hauptbuchungen), 'bucheBewirtungssplit() legt zwei Hauptbuchungen an');
+
+ $konto4650Id = (int)$this->findAccountId('4650');
+ $konto4654Id = (int)$this->findAccountId('4654');
+
+ $b70 = null;
+ $b30 = null;
+ foreach ($hauptbuchungen as $b) {
+ if ((int)$b['kategorie_konto_id'] === $konto4650Id) $b70 = $b;
+ if ((int)$b['kategorie_konto_id'] === $konto4654Id) $b30 = $b;
+ }
+
+ $this->assertNotEmpty($b70, '70 %-Buchung liegt auf dem Bewirtungskonto 4650');
+ $this->assertNotEmpty($b30, '30 %-Buchung liegt auf dem Abzugskonto 4654');
+ $this->assertEquals(70.00, (float)($b70['betrag_netto'] ?? 0), '70 % Netto auf dem Bewirtungskonto');
+ $this->assertEquals(30.00, (float)($b30['betrag_netto'] ?? 0), '30 % Netto auf dem Abzugskonto');
+ }
+
+ private function testDeleteHard(): void
+ {
+ $belegId = $this->newBeleg('DELETEHARD', date('Y-m-d'), null, 119.00);
+
+ $bookingId = Booking::create([
+ 'beleg_id' => $belegId,
+ 'buchungs_datum' => date('Y-m-d'),
+ 'buchungstyp' => 'Einnahme',
+ 'kategorie_konto_id' => $this->kategorieId,
+ 'zahlungskonto_id' => $this->zahlungsId,
+ 'betrag_netto' => 100.00,
+ 'ust_satz' => 19.0,
+ 'buchungstext' => 'Test deleteHard() Buchung',
+ ]);
+
+ // 19 % USt erzeugt automatisch eine Kindbuchung (USt-Zeile).
+ $kinder = Database::fetchAll('SELECT id FROM bookings WHERE parent_booking_id = ?', [$bookingId]);
+ $this->assertGreaterThan(0, count($kinder), 'Voraussetzung: Hauptbuchung hat eine Kindbuchung');
+
+ Booking::deleteHard($bookingId);
+
+ $this->assert(Booking::find($bookingId) === false, 'deleteHard() entfernt die Hauptbuchung');
+ $verbleibendeKinder = Database::fetchAll('SELECT id FROM bookings WHERE parent_booking_id = ?', [$bookingId]);
+ $this->assertEquals(0, count($verbleibendeKinder), 'deleteHard() entfernt auch die Kindbuchung');
+ }
+
+ private function testInvoiceQuerverweise(): void
+ {
+ $customerId = Customer::create([
+ 'nachname' => 'Querverweis',
+ 'firma' => 'Querverweis GmbH',
+ 'strasse' => 'Teststraße 2',
+ 'plz' => '10115',
+ 'ort' => 'Berlin',
+ 'payment_days' => 14,
+ ]);
+ $invoiceId = Invoice::create([
+ 'customer_id' => $customerId,
+ 'betreff' => 'Testrechnung Querverweis',
+ 'rechnungs_datum' => date('Y-m-d'),
+ 'lines' => [[
+ 'bezeichnung' => 'Beratung',
+ 'menge' => 1,
+ 'einheit' => 'Std.',
+ 'einzelpreis' => 200.00,
+ 'ust_satz' => 19.0,
+ ]],
+ ]);
+ Invoice::finalize($invoiceId);
+ $invoice = Invoice::find($invoiceId);
+ $belegId = $this->newBeleg('QUERVERWEIS', $invoice['rechnungs_datum'], $invoice['rechnungs_nr'], (float)$invoice['betrag_brutto']);
+ Invoice::setBelegId($invoiceId, $belegId);
+
+ $stornoId = Invoice::createCancellation($invoiceId);
+
+ $storno = Invoice::findStorno($invoiceId);
+ $this->assertEquals($stornoId, (int)($storno['id'] ?? 0), 'findStorno() liefert die Stornorechnung zur Originalrechnung');
+
+ $ref = Invoice::findReference($invoiceId);
+ $this->assertEquals($invoice['rechnungs_nr'], $ref['rechnungs_nr'] ?? null, 'findReference() liefert die Rechnungsnummer');
+ $this->assertEquals($invoice['rechnungs_datum'], $ref['rechnungs_datum'] ?? null, 'findReference() liefert das Rechnungsdatum');
+
+ // deleteLine()
+ $lineInvoiceId = Invoice::create([
+ 'customer_id' => $customerId,
+ 'betreff' => 'Testrechnung mit zwei Positionen',
+ 'rechnungs_datum' => date('Y-m-d'),
+ 'lines' => [
+ ['bezeichnung' => 'Position A', 'menge' => 1, 'einheit' => 'Stk.', 'einzelpreis' => 10.00, 'ust_satz' => 19.0],
+ ['bezeichnung' => 'Position B', 'menge' => 1, 'einheit' => 'Stk.', 'einzelpreis' => 20.00, 'ust_satz' => 19.0],
+ ],
+ ]);
+ $lines = Invoice::lines($lineInvoiceId);
+ $this->assertEquals(2, count($lines), 'Voraussetzung: Rechnung hat zwei Positionen');
+ Invoice::deleteLine($lineInvoiceId, (int)$lines[0]['id']);
+ $this->assertEquals(1, count(Invoice::lines($lineInvoiceId)), 'deleteLine() entfernt genau eine Position');
+
+ // forCustomer()
+ $kundenRechnungen = array_column(Invoice::forCustomer($customerId), 'id');
+ $this->assert(in_array($invoiceId, $kundenRechnungen, true), 'forCustomer() liefert die Rechnungen des Kunden');
+
+ // markSent()
+ $markInvoiceId = Invoice::create([
+ 'customer_id' => $customerId,
+ 'betreff' => 'Testrechnung markSent',
+ 'rechnungs_datum' => date('Y-m-d'),
+ 'lines' => [['bezeichnung' => 'Beratung', 'menge' => 1, 'einheit' => 'Std.', 'einzelpreis' => 50.00, 'ust_satz' => 19.0]],
+ ]);
+ Invoice::finalize($markInvoiceId);
+ Invoice::markSent($markInvoiceId, 'Finalisiert');
+ $markInvoice = Invoice::find($markInvoiceId);
+ $this->assertEquals('Gesendet', $markInvoice['status'], 'markSent() setzt den Status bei der ersten Sendung auf Gesendet');
+ $this->assertNotEmpty($markInvoice['sent_at'], 'markSent() setzt sent_at');
+
+ $sentAtErsteSendung = $markInvoice['sent_at'];
+ Invoice::markSent($markInvoiceId, 'Gesendet');
+ $markInvoiceErneut = Invoice::find($markInvoiceId);
+ $this->assertEquals('Gesendet', $markInvoiceErneut['status'], 'markSent() ändert den Status beim erneuten Versand nicht');
+ $this->assertEquals($sentAtErsteSendung, $markInvoiceErneut['sent_at'], 'markSent() überschreibt sent_at beim erneuten Versand nicht');
+ }
+
+ private function testInvoiceFinalizeWithBeleg(): void
+ {
+ $customerId = Customer::create([
+ 'nachname' => 'Finalisierung',
+ 'firma' => 'Finalisierung GmbH',
+ 'strasse' => 'Teststraße 3',
+ 'plz' => '10115',
+ 'ort' => 'Berlin',
+ 'payment_days' => 14,
+ ]);
+
+ $invoiceId = Invoice::create([
+ 'customer_id' => $customerId,
+ 'betreff' => 'Testrechnung finalizeWithBeleg',
+ 'rechnungs_datum' => date('Y-m-d'),
+ 'lines' => [['bezeichnung' => 'Beratung', 'menge' => 1, 'einheit' => 'Std.', 'einzelpreis' => 80.00, 'ust_satz' => 19.0]],
+ ]);
+
+ [$nr, $belegNr] = Invoice::finalizeWithBeleg($invoiceId, function (array $invoice): string {
+ return 'TESTBELEG-' . $invoice['id'];
+ });
+
+ $this->assertNotEmpty($nr, 'finalizeWithBeleg() liefert die vergebene Rechnungsnummer');
+ $this->assertEquals('TESTBELEG-' . $invoiceId, $belegNr, 'finalizeWithBeleg() liefert den Rückgabewert des Beleg-Callbacks');
+ $this->assertEquals('Finalisiert', Invoice::find($invoiceId)['status'], 'Rechnung ist nach finalizeWithBeleg() finalisiert');
+ }
+
+ private function testBelegUpdates(): void
+ {
+ $belegId = $this->newBeleg('UPDATES', date('Y-m-d'), null, 50.00);
+
+ Beleg::updateBewirtung($belegId, 'Geschäftsessen', 'Person A, Person B');
+ $beleg = Beleg::find($belegId);
+ $this->assertEquals('Geschäftsessen', $beleg['bewirtungsgrund'], 'updateBewirtung() setzt bewirtungsgrund');
+ $this->assertEquals('Person A, Person B', $beleg['teilnehmerliste'], 'updateBewirtung() setzt teilnehmerliste');
+
+ Beleg::updateEigenanteil30($belegId, 1);
+ $this->assertEquals(1, (int)Beleg::find($belegId)['eigenanteil_30'], 'updateEigenanteil30() setzt das Flag');
+
+ // Beleg::find() überschreibt betrag_brutto mit einer SUM-Subquery über
+ // gebuchte Zahlungen (0 ohne Buchungen) – die rohe Spalte daher direkt prüfen.
+ Beleg::updateBetragBrutto($belegId, 123.45);
+ $rohwert = Database::fetchOne('SELECT betrag_brutto FROM belege WHERE id = ?', [$belegId]);
+ $this->assertEquals(123.45, (float)($rohwert['betrag_brutto'] ?? 0), 'updateBetragBrutto() aktualisiert den Betrag');
+
+ $hash = hash('sha256', 'test-inhalt-' . $belegId);
+ Beleg::updateStorage($belegId, '2026/TEST/original.pdf', 'Testdatei.pdf', $hash);
+ $belegNachUpdate = Beleg::find($belegId);
+ $this->assertEquals('2026/TEST/original.pdf', $belegNachUpdate['storage_path'], 'updateStorage() aktualisiert storage_path');
+ $this->assertEquals('Testdatei.pdf', $belegNachUpdate['filename'], 'updateStorage() aktualisiert filename');
+ $this->assertEquals($hash, $belegNachUpdate['file_hash'], 'updateStorage() aktualisiert file_hash');
+
+ $supplierId = Supplier::findOrCreate('Testlieferant Modellabfragen');
+ Beleg::setSupplierId($belegId, $supplierId);
+ $this->assertEquals($supplierId, (int)Beleg::find($belegId)['supplier_id'], 'setSupplierId() setzt den Lieferanten ohne locked-Prüfung');
+ }
+
+ private function testUstvaSubmission(): void
+ {
+ $year = 2077;
+ $month = 6;
+
+ $this->assert(UstvaSubmission::find($year, $month) === false, 'Vor dem Speichern existiert noch keine Übermittlung');
+
+ UstvaSubmission::upsert($year, $month, 'erster-stand', ['81' => ['wert' => 100]]);
+ $ersterStand = UstvaSubmission::find($year, $month);
+ $this->assertEquals('erster-stand', $ersterStand['xml_content'], 'upsert() legt beim ersten Aufruf einen Datensatz an');
+
+ UstvaSubmission::upsert($year, $month, 'zweiter-stand', ['81' => ['wert' => 200]]);
+ $zweiterStand = UstvaSubmission::find($year, $month);
+ $this->assertEquals('zweiter-stand', $zweiterStand['xml_content'], 'upsert() überschreibt beim zweiten Aufruf den Inhalt');
+ $this->assertEquals((int)$ersterStand['id'], (int)$zweiterStand['id'], 'upsert() legt beim zweiten Aufruf keinen zweiten Datensatz an');
+
+ $alleIds = array_column(UstvaSubmission::all(), 'id');
+ $this->assert(in_array((int)$zweiterStand['id'], $alleIds, true), 'all() enthält die gespeicherte Übermittlung');
+ }
+
+ private function testBankImportBatch(): void
+ {
+ $sourceId = BankSource::create([
+ 'type' => 'csv',
+ 'bezeichnung' => 'Test Modellabfragen CSV',
+ 'kuerzel' => 'TMA',
+ 'farbe' => '#123456',
+ 'skip_rows' => 0,
+ 'has_header_row' => 1,
+ 'delimiter' => ';',
+ 'enclosure' => '"',
+ 'col_buchungstag' => 0,
+ 'col_wertstellung' => 1,
+ 'col_vorgang' => 2,
+ 'col_buchungstext' => 3,
+ 'col_wert' => 4,
+ ]);
+
+ $rows = [
+ ['buchungstag' => date('Y-m-d'), 'wertstellung' => date('Y-m-d'), 'vorgang' => 'Gutschrift', 'buchungstext' => 'Modellabfragen Testzeile 1', 'wert' => '10.00'],
+ ['buchungstag' => date('Y-m-d'), 'wertstellung' => date('Y-m-d'), 'vorgang' => 'Gutschrift', 'buchungstext' => 'Modellabfragen Testzeile 2', 'wert' => '20.00'],
+ ];
+
+ $batchId = BankTransaction::importBatch($sourceId, 'modellabfragen-test.csv', $rows);
+ $this->assertGreaterThan(0, $batchId, 'importBatch() legt einen Import-Batch an');
+
+ $importiert = Database::fetchAll('SELECT * FROM bank_transactions WHERE batch_id = ? ORDER BY id', [$batchId]);
+ $this->assertEquals(2, count($importiert), 'importBatch() legt beide Zeilen im selben Batch an');
+ $this->assertEquals('Modellabfragen Testzeile 1', $importiert[0]['buchungstext'] ?? null, 'Erste Zeile wird unverändert übernommen');
+ $this->assertEquals('20.00', $importiert[1]['wert'] ?? null, 'Zweite Zeile wird unverändert übernommen');
+ }
+
+ /**
+ * Regressionsschutz: kein Controller darf wieder direkt auf Database:: zugreifen.
+ *
+ * Ausnahme AssetController.php: dort verbleiben vier Aufrufe außerhalb des
+ * AfA-Umfangs von Fix #155 (Anlage-Verknüpfung einer Buchung, vorheriges
+ * AfA-Konto, harte Löschung im Testmodus) – laut Issue #156 nicht Teil
+ * dieses Tickets.
+ */
+ private function testKeineDirektenDatenbankaufrufeInControllern(): void
+ {
+ $verzeichnis = dirname(__DIR__) . '/src/Controllers';
+ $ausnahmen = ['AssetController.php'];
+
+ $treffer = [];
+ foreach (glob($verzeichnis . '/*.php') ?: [] as $datei) {
+ if (in_array(basename($datei), $ausnahmen, true)) continue;
+ $inhalt = file_get_contents($datei);
+ if ($inhalt !== false && str_contains($inhalt, 'Database::')) {
+ $treffer[] = basename($datei);
+ }
+ }
+
+ $this->assert(
+ empty($treffer),
+ empty($treffer)
+ ? 'Kein Controller (außer den dokumentierten Ausnahmen) ruft Database:: direkt auf'
+ : 'Unerwartete Database::-Aufrufe in: ' . implode(', ', $treffer)
+ );
+ }
+
+ private function newBeleg(string $suffix, string $belegDatum, ?string $rechnungsNr, float $betragBrutto): int
+ {
+ $this->belegSeq++;
+ return Beleg::create([
+ 'beleg_nr' => Beleg::generateBelegNr(),
+ 'filename' => "modellabfragen-{$suffix}-{$this->belegSeq}.pdf",
+ 'storage_path' => '',
+ 'mime_type' => 'application/pdf',
+ 'file_hash' => hash('sha256', $suffix . $this->belegSeq),
+ 'beleg_datum' => $belegDatum,
+ 'rechnungs_nr' => $rechnungsNr,
+ 'betrag_brutto' => $betragBrutto,
+ 'belegtyp' => $rechnungsNr !== null ? 'Ausgangsrechnung' : 'Eingangsrechnung',
+ ]);
+ }
+}
diff --git a/tests/86_BelegDetailStrukturTest.php b/tests/86_BelegDetailStrukturTest.php
new file mode 100644
index 0000000..dad3f89
--- /dev/null
+++ b/tests/86_BelegDetailStrukturTest.php
@@ -0,0 +1,98 @@
+-Blöcken) die mit Abstand größte View der Anwendung. Dieser Test
+ * hält die im Zuge der Aufteilung entstandene Struktur fest: detail.php ist
+ * nur noch ein Gerüst aus Includes und