Steuerkram/tests/85_ModellAbfragenTest.php

478 lines
22 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
use Steuerkram\Database;
use Steuerkram\Models\Account;
use Steuerkram\Models\BankSource;
use Steuerkram\Models\BankTransaction;
use Steuerkram\Models\Beleg;
use Steuerkram\Models\Booking;
use Steuerkram\Models\Customer;
use Steuerkram\Models\FiscalYear;
use Steuerkram\Models\Invoice;
use Steuerkram\Models\Supplier;
use Steuerkram\Models\UstvaSubmission;
/**
* Test 85: Aus Controllern verschobene Modell-Abfragen (Fix #156).
*
* 54 direkte Database::-Aufrufe in Controllern verletzten die Modellschicht
* und waren nur über einen vollständigen HTTP-Request testbar. Dieser Test
* deckt die dabei neu entstandenen bzw. konsolidierten Modellmethoden ab –
* mit Schwerpunkt auf der bislang völlig ungetesteten Kennzahlenberechnung
* Booking::dashboardKennzahlen(). Ein Regressionsschutz am Ende stellt
* sicher, dass kein Controller wieder direkt auf Database:: zugreift.
*/
class ModellAbfragenTest extends TestCase
{
protected string $testName = 'Test 85: Aus Controllern verschobene Modell-Abfragen (Fix #156)';
private int $zahlungsId;
private int $kategorieId;
private int $belegSeq = 0;
private ?int $offenerBelegId = null;
private ?int $recentBookingId = null;
public function run(): void
{
$this->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, '<xml>erster-stand</xml>', ['81' => ['wert' => 100]]);
$ersterStand = UstvaSubmission::find($year, $month);
$this->assertEquals('<xml>erster-stand</xml>', $ersterStand['xml_content'], 'upsert() legt beim ersten Aufruf einen Datensatz an');
UstvaSubmission::upsert($year, $month, '<xml>zweiter-stand</xml>', ['81' => ['wert' => 200]]);
$zweiterStand = UstvaSubmission::find($year, $month);
$this->assertEquals('<xml>zweiter-stand</xml>', $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',
]);
}
}