Fix #nummernkreis: Löschen gibt die zuletzt vergebene Rechnungs- und Belegnummer wieder frei
This commit is contained in:
parent
7cea573ffc
commit
043c68b2e4
8 changed files with 330 additions and 24 deletions
|
|
@ -2,6 +2,14 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-08-02 – Nummernkreise geben die zuletzt vergebene Nummer beim Löschen wieder frei
|
||||||
|
|
||||||
|
- Der E2E-Finalisierungstest verbrauchte bei jedem Lauf eine Rechnungs- und eine Belegnummer: Die Testrechnung wurde zwar wieder gelöscht, die Nummern blieben aber vergeben und rissen dauerhaft Lücken in den laufenden Nummernkreis.
|
||||||
|
- **`src/Models/Invoice.php`, `src/Models/Beleg.php`:** `delete()` gibt die Nummer des gelöschten Datensatzes jetzt wieder frei. Die zuletzt vergebene Nummer wird dafür aus dem Zählerstand rekonstruiert und nur bei exakter Übereinstimmung zurückgezählt – existiert bereits eine jüngere Rechnung bzw. ein jüngerer Beleg (oder stammt die Nummer aus einem früheren Monat/Jahr), bleibt der Zähler stehen. Damit kann keine Nummer doppelt vergeben werden. `Invoice::delete()` gibt zusätzlich die Belegnummer des mitgelöschten Rechnungsbelegs frei.
|
||||||
|
- **`scripts/e2e-cleanup.php`:** Räumt liegengebliebene Testdaten absteigend ab (jüngste zuerst), damit die Freigabe für die ganze Kette greift und nicht nur für den letzten Datensatz.
|
||||||
|
- **`tests/78_NummernkreisFreigabeTest.php`** (neu): Sichert Freigabe und Wiedervergabe für beide Nummernkreise ab – inklusive der Gegenprobe, dass das Löschen eines älteren Datensatzes den Zähler unverändert lässt.
|
||||||
|
- **`tests/e2e/specs/rechnung-export.spec.js`:** Neuer Test `Löschen einer finalisierten Rechnung gibt die Nummer wieder frei` weist über zwei aufeinanderfolgende Finalisierungen nach, dass der Testlauf den Nummernkreis nicht mehr weiterschiebt. Der Hinweis auf die „nicht umkehrbare Nebenwirkung" in `tests/e2e/README.md` entfällt damit.
|
||||||
|
|
||||||
## 2026-08-02 – E2E-Tests: Browser-Ebene abgedeckt, zwei kaputte Seiten repariert
|
## 2026-08-02 – E2E-Tests: Browser-Ebene abgedeckt, zwei kaputte Seiten repariert
|
||||||
|
|
||||||
- Die PHP-Suite prüft Models und Services, ruft aber nie einen Controller über HTTP auf. Routing, Views und das JavaScript in den Formularen waren damit vollständig ungetestet – genau die Ebene, auf der die Fehler der letzten Wochen auftraten. Steuerkram ist jetzt an den generischen [e2e-runner](../e2e-runner/) angebunden; auf dem Host wird dafür nichts installiert.
|
- Die PHP-Suite prüft Models und Services, ruft aber nie einen Controller über HTTP auf. Routing, Views und das JavaScript in den Formularen waren damit vollständig ungetestet – genau die Ebene, auf der die Fehler der letzten Wochen auftraten. Steuerkram ist jetzt an den generischen [e2e-runner](../e2e-runner/) angebunden; auf dem Host wird dafür nichts installiert.
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ CRUD für Kundenstammdaten. Kunden werden beim Erstellen von Ausgangsrechnungen
|
||||||
|
|
||||||
- DIN 5008-konformes Layout, PDF-Export via mPDF
|
- DIN 5008-konformes Layout, PDF-Export via mPDF
|
||||||
- Rechnungsnummernformat **PPPYYYYMM-nn** (konfigurierbarer Präfix + Jahr + Monat + laufende Nummer, monatlicher Reset)
|
- Rechnungsnummernformat **PPPYYYYMM-nn** (konfigurierbarer Präfix + Jahr + Monat + laufende Nummer, monatlicher Reset)
|
||||||
|
- **Nummernfreigabe beim Löschen**: Wird eine Rechnung gelöscht (im Testmodus) und trug sie die zuletzt vergebene Nummer, zählt der Nummernkreis zurück – die Nummer wird als nächste wieder vergeben, statt eine Lücke zu hinterlassen. Für die Belegnummer des zugehörigen Belegs gilt dasselbe. War die Nummer nicht die letzte, bleibt der Zähler stehen; eine Nummer wird nie doppelt vergeben.
|
||||||
- Status-Workflow: Entwurf → Finalisiert (gesperrt für Bearbeitung) → Gesendet → Teilzahlung / Bezahlt / Überzahlt / Storniert
|
- Status-Workflow: Entwurf → Finalisiert (gesperrt für Bearbeitung) → Gesendet → Teilzahlung / Bezahlt / Überzahlt / Storniert
|
||||||
- **Zahlungsstatus automatisch**: Sobald dem Beleg einer Rechnung Zahlungsbuchungen zugeordnet werden, ergibt sich der Status aus der Summe der aktiven Einnahme-Buchungen – weniger als der Rechnungsbetrag → `Teilzahlung`, exakt → `Bezahlt`, mehr → `Überzahlt`. Stornierte Buchungen zählen nicht mit; ohne aktive Buchung fällt die Rechnung auf `Gesendet` (falls bereits versendet, Zeitpunkt in `sent_at`) bzw. `Finalisiert` zurück. Entwürfe und stornierte Rechnungen bleiben unberührt. Der Button „Rechnung als bezahlt markieren" bleibt als manuelle Übersteuerung erhalten (z. B. bei Skonto), wird aber von der nächsten Buchungsänderung wieder überschrieben.
|
- **Zahlungsstatus automatisch**: Sobald dem Beleg einer Rechnung Zahlungsbuchungen zugeordnet werden, ergibt sich der Status aus der Summe der aktiven Einnahme-Buchungen – weniger als der Rechnungsbetrag → `Teilzahlung`, exakt → `Bezahlt`, mehr → `Überzahlt`. Stornierte Buchungen zählen nicht mit; ohne aktive Buchung fällt die Rechnung auf `Gesendet` (falls bereits versendet, Zeitpunkt in `sent_at`) bzw. `Finalisiert` zurück. Entwürfe und stornierte Rechnungen bleiben unberührt. Der Button „Rechnung als bezahlt markieren" bleibt als manuelle Übersteuerung erhalten (z. B. bei Skonto), wird aber von der nächsten Buchungsänderung wieder überschrieben.
|
||||||
- Das Zahlungs-Modal der Detailansicht und die Buchungsansicht zeigen Rechnungsbetrag, gezahlten Betrag und offenen Rest; der Betragsvorschlag entspricht dem offenen Restbetrag.
|
- Das Zahlungs-Modal der Detailansicht und die Buchungsansicht zeigen Rechnungsbetrag, gezahlten Betrag und offenen Rest; der Betragsvorschlag entspricht dem offenen Restbetrag.
|
||||||
|
|
|
||||||
|
|
@ -39,13 +39,16 @@ if (!Setting::isTestMode()) {
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Absteigend (jüngste zuerst): Nur die zuletzt vergebene Nummer wandert beim
|
||||||
|
// Löschen in den Nummernkreis zurück – rückwärts abgeräumt greift das für die
|
||||||
|
// ganze Kette statt nur für den letzten Datensatz.
|
||||||
$belege = Database::fetchAll(
|
$belege = Database::fetchAll(
|
||||||
'SELECT id, beleg_nr, filename FROM belege WHERE notes LIKE ? OR filename LIKE ?',
|
'SELECT id, beleg_nr, filename FROM belege WHERE notes LIKE ? OR filename LIKE ? ORDER BY id DESC',
|
||||||
[MARKER, MARKER]
|
[MARKER, MARKER]
|
||||||
);
|
);
|
||||||
|
|
||||||
$rechnungen = Database::fetchAll(
|
$rechnungen = Database::fetchAll(
|
||||||
'SELECT id, rechnungs_nr, betreff, status FROM invoices WHERE betreff LIKE ?',
|
'SELECT id, rechnungs_nr, betreff, status FROM invoices WHERE betreff LIKE ? ORDER BY id DESC',
|
||||||
[MARKER]
|
[MARKER]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -267,9 +267,40 @@ class Beleg
|
||||||
}
|
}
|
||||||
|
|
||||||
Database::execute('DELETE FROM belege WHERE id=?', [$id]);
|
Database::execute('DELETE FROM belege WHERE id=?', [$id]);
|
||||||
|
self::releaseBelegNr((string)($beleg['beleg_nr'] ?? ''));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt die Belegnummer eines gelöschten Belegs wieder frei, sofern sie die
|
||||||
|
* zuletzt vergebene war – sonst risse jeder gelöschte Beleg eine Lücke in
|
||||||
|
* den Nummernkreis.
|
||||||
|
*
|
||||||
|
* Die Nummer wird aus dem aktuellen Zählerstand rekonstruiert und nur bei
|
||||||
|
* exakter Übereinstimmung zurückgezählt. Existiert bereits ein jüngerer
|
||||||
|
* Beleg (oder stammt die Nummer aus einem früheren Jahr), passt die
|
||||||
|
* Rekonstruktion nicht und der Zähler bleibt stehen; eine Nummer kann
|
||||||
|
* dadurch nie doppelt vergeben werden.
|
||||||
|
*/
|
||||||
|
public static function releaseBelegNr(string $nr): void
|
||||||
|
{
|
||||||
|
if ($nr === '') return;
|
||||||
|
|
||||||
|
$settings = Database::fetchOne('SELECT beleg_nr_prefix, beleg_nr_next FROM company_settings WHERE id=1');
|
||||||
|
if (!$settings) return;
|
||||||
|
|
||||||
|
$next = (int)($settings['beleg_nr_next'] ?? 1);
|
||||||
|
if ($next <= 1) return;
|
||||||
|
|
||||||
|
$letzte = sprintf('%s-%s-%04d', $settings['beleg_nr_prefix'] ?? 'BLG', date('Y'), $next - 1);
|
||||||
|
if ($nr !== $letzte) return;
|
||||||
|
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE company_settings SET beleg_nr_next=? WHERE id=1 AND beleg_nr_next=?',
|
||||||
|
[$next - 1, $next]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public static function generateBelegNr(): string
|
public static function generateBelegNr(): string
|
||||||
{
|
{
|
||||||
$settings = Database::fetchOne('SELECT beleg_nr_prefix, beleg_nr_next FROM company_settings WHERE id=1');
|
$settings = Database::fetchOne('SELECT beleg_nr_prefix, beleg_nr_next FROM company_settings WHERE id=1');
|
||||||
|
|
|
||||||
|
|
@ -397,6 +397,7 @@ class Invoice
|
||||||
if (!$invoice) return;
|
if (!$invoice) return;
|
||||||
|
|
||||||
$belegId = !empty($invoice['beleg_id']) ? (int)$invoice['beleg_id'] : null;
|
$belegId = !empty($invoice['beleg_id']) ? (int)$invoice['beleg_id'] : null;
|
||||||
|
$beleg = $belegId !== null ? Beleg::find($belegId) : false;
|
||||||
|
|
||||||
Database::execute('DELETE FROM invoice_lines WHERE invoice_id = ?', [$id]);
|
Database::execute('DELETE FROM invoice_lines WHERE invoice_id = ?', [$id]);
|
||||||
Database::execute('DELETE FROM invoices WHERE id = ?', [$id]);
|
Database::execute('DELETE FROM invoices WHERE id = ?', [$id]);
|
||||||
|
|
@ -405,6 +406,48 @@ class Invoice
|
||||||
Database::execute('DELETE FROM bookings WHERE beleg_id = ?', [$belegId]);
|
Database::execute('DELETE FROM bookings WHERE beleg_id = ?', [$belegId]);
|
||||||
Database::execute('DELETE FROM belege WHERE id = ?', [$belegId]);
|
Database::execute('DELETE FROM belege WHERE id = ?', [$belegId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Beide Nummernkreise zurückrollen, solange die gelöschten Datensätze
|
||||||
|
// die zuletzt vergebenen Nummern trugen – sonst bliebe eine Lücke.
|
||||||
|
self::releaseRechnungsNr((string)($invoice['rechnungs_nr'] ?? ''));
|
||||||
|
if ($beleg) {
|
||||||
|
Beleg::releaseBelegNr((string)($beleg['beleg_nr'] ?? ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt die Rechnungsnummer einer gelöschten Rechnung wieder frei, sofern sie
|
||||||
|
* die zuletzt vergebene war.
|
||||||
|
*
|
||||||
|
* Die Nummer wird aus dem aktuellen Zählerstand rekonstruiert; nur bei exakter
|
||||||
|
* Übereinstimmung wird zurückgezählt. Gibt es bereits eine jüngere Rechnung,
|
||||||
|
* passt die Rekonstruktion nicht und der Zähler bleibt stehen – eine Nummer
|
||||||
|
* kann so nie doppelt vergeben werden.
|
||||||
|
*/
|
||||||
|
private static function releaseRechnungsNr(string $nr): void
|
||||||
|
{
|
||||||
|
if ($nr === '') return;
|
||||||
|
|
||||||
|
$settings = Database::fetchOne(
|
||||||
|
'SELECT invoice_nr_prefix, invoice_nr_next, invoice_nr_year, invoice_nr_month FROM company_settings WHERE id=1'
|
||||||
|
);
|
||||||
|
if (!$settings) return;
|
||||||
|
|
||||||
|
$next = (int)($settings['invoice_nr_next'] ?? 1);
|
||||||
|
if ($next <= 1) return;
|
||||||
|
|
||||||
|
$letzte = self::formatRechnungsNr(
|
||||||
|
(string)($settings['invoice_nr_prefix'] ?? 'AR'),
|
||||||
|
(int)($settings['invoice_nr_year'] ?? 0),
|
||||||
|
(int)($settings['invoice_nr_month'] ?? 0),
|
||||||
|
$next - 1
|
||||||
|
);
|
||||||
|
if ($nr !== $letzte) return;
|
||||||
|
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE company_settings SET invoice_nr_next=? WHERE id=1 AND invoice_nr_next=?',
|
||||||
|
[$next - 1, $next]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function generateRechnungsNr(): string
|
public static function generateRechnungsNr(): string
|
||||||
|
|
@ -423,8 +466,7 @@ class Invoice
|
||||||
$next = 1;
|
$next = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$digits = max(2, strlen((string)$next));
|
$nr = self::formatRechnungsNr($prefix, $year, $month, $next);
|
||||||
$nr = sprintf('%s%04d%02d-%0' . $digits . 'd', $prefix, $year, $month, $next);
|
|
||||||
|
|
||||||
Database::execute(
|
Database::execute(
|
||||||
'UPDATE company_settings SET invoice_nr_next=?, invoice_nr_year=?, invoice_nr_month=? WHERE id=1',
|
'UPDATE company_settings SET invoice_nr_next=?, invoice_nr_year=?, invoice_nr_month=? WHERE id=1',
|
||||||
|
|
@ -433,4 +475,11 @@ class Invoice
|
||||||
|
|
||||||
return $nr;
|
return $nr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Einheitliches Format des Nummernkreises: Präfix + Jahr + Monat + laufende Nummer. */
|
||||||
|
private static function formatRechnungsNr(string $prefix, int $year, int $month, int $lfd): string
|
||||||
|
{
|
||||||
|
$digits = max(2, strlen((string)$lfd));
|
||||||
|
return sprintf('%s%04d%02d-%0' . $digits . 'd', $prefix, $year, $month, $lfd);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
167
tests/78_NummernkreisFreigabeTest.php
Normal file
167
tests/78_NummernkreisFreigabeTest.php
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test 78: Freigabe der zuletzt vergebenen Nummer beim Löschen.
|
||||||
|
*
|
||||||
|
* Wird eine Rechnung oder ein Beleg gelöscht, gibt der jeweilige Nummernkreis
|
||||||
|
* die Nummer wieder frei – aber nur, wenn es die zuletzt vergebene war. Sonst
|
||||||
|
* hinterließe jeder Löschvorgang (z.B. ein E2E-Testlauf) eine unumkehrbare
|
||||||
|
* Lücke im Nummernkreis.
|
||||||
|
*
|
||||||
|
* Gegenprobe: Wird eine ältere Nummer gelöscht, bleibt der Zähler stehen –
|
||||||
|
* andernfalls würde eine bereits vergebene Nummer ein zweites Mal ausgegeben.
|
||||||
|
*/
|
||||||
|
class NummernkreisFreigabeTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 78: Nummernkreis gibt die zuletzt vergebene Nummer beim Löschen frei';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->rechnungsnummerWirdFreigegeben();
|
||||||
|
$this->aeltereRechnungsnummerBleibtVerbraucht();
|
||||||
|
$this->belegnummerWirdFreigegeben();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rechnungen ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function rechnungsnummerWirdFreigegeben(): void
|
||||||
|
{
|
||||||
|
$vorher = $this->invoiceNrNext();
|
||||||
|
|
||||||
|
$invoiceId = $this->createRechnung('Nummernkreis-Freigabe');
|
||||||
|
$nr = \Steuerkram\Models\Invoice::finalize($invoiceId);
|
||||||
|
$this->assertNotEmpty($nr, 'Finalisieren vergibt eine Rechnungsnummer');
|
||||||
|
$this->assertEquals($vorher + 1, $this->invoiceNrNext(), 'Zähler steht nach dem Finalisieren eins weiter');
|
||||||
|
|
||||||
|
\Steuerkram\Models\Invoice::delete($invoiceId);
|
||||||
|
$this->assertEquals(
|
||||||
|
$vorher,
|
||||||
|
$this->invoiceNrNext(),
|
||||||
|
'Nach dem Löschen steht der Zähler wieder auf dem Ausgangswert'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Die freigegebene Nummer muss erneut vergeben werden – sonst entstünde
|
||||||
|
// trotz zurückgesetztem Zähler eine andere Nummer.
|
||||||
|
$zweiteId = $this->createRechnung('Nummernkreis-Wiedervergabe');
|
||||||
|
$this->assertEquals($nr, \Steuerkram\Models\Invoice::finalize($zweiteId), 'Die freigegebene Nummer wird erneut vergeben');
|
||||||
|
|
||||||
|
\Steuerkram\Models\Invoice::delete($zweiteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function aeltereRechnungsnummerBleibtVerbraucht(): void
|
||||||
|
{
|
||||||
|
$ersteId = $this->createRechnung('Nummernkreis-Alt');
|
||||||
|
\Steuerkram\Models\Invoice::finalize($ersteId);
|
||||||
|
|
||||||
|
$zweiteId = $this->createRechnung('Nummernkreis-Neu');
|
||||||
|
$zweiteNr = \Steuerkram\Models\Invoice::finalize($zweiteId);
|
||||||
|
$standMitBeiden = $this->invoiceNrNext();
|
||||||
|
|
||||||
|
// Die ältere der beiden Rechnungen löschen: ihre Nummer darf nicht
|
||||||
|
// freigegeben werden, sie liegt vor der bereits vergebenen $zweiteNr.
|
||||||
|
\Steuerkram\Models\Invoice::delete($ersteId);
|
||||||
|
$this->assertEquals(
|
||||||
|
$standMitBeiden,
|
||||||
|
$this->invoiceNrNext(),
|
||||||
|
'Das Löschen einer älteren Rechnung lässt den Zähler unverändert'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Jetzt die jüngste löschen – deren Nummer wird freigegeben.
|
||||||
|
\Steuerkram\Models\Invoice::delete($zweiteId);
|
||||||
|
$this->assertEquals(
|
||||||
|
$standMitBeiden - 1,
|
||||||
|
$this->invoiceNrNext(),
|
||||||
|
'Das Löschen der jüngsten Rechnung gibt deren Nummer frei'
|
||||||
|
);
|
||||||
|
|
||||||
|
$dritteId = $this->createRechnung('Nummernkreis-Nachrueck');
|
||||||
|
$this->assertEquals(
|
||||||
|
$zweiteNr,
|
||||||
|
\Steuerkram\Models\Invoice::finalize($dritteId),
|
||||||
|
'Die freigegebene Nummer der jüngsten Rechnung rückt nach'
|
||||||
|
);
|
||||||
|
\Steuerkram\Models\Invoice::delete($dritteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Belege ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function belegnummerWirdFreigegeben(): void
|
||||||
|
{
|
||||||
|
$vorher = $this->belegNrNext();
|
||||||
|
|
||||||
|
$ersterId = $this->createBeleg('Nummernkreis-Beleg-1');
|
||||||
|
$zweiterId = $this->createBeleg('Nummernkreis-Beleg-2');
|
||||||
|
$this->assertEquals($vorher + 2, $this->belegNrNext(), 'Zwei Belege verbrauchen zwei Nummern');
|
||||||
|
|
||||||
|
// Älterer Beleg: keine Freigabe.
|
||||||
|
$this->assert(\Steuerkram\Models\Beleg::delete($ersterId), 'Älterer Beleg ist löschbar');
|
||||||
|
$this->assertEquals(
|
||||||
|
$vorher + 2,
|
||||||
|
$this->belegNrNext(),
|
||||||
|
'Das Löschen eines älteren Belegs lässt den Zähler unverändert'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Jüngster Beleg: Nummer wird frei.
|
||||||
|
$this->assert(\Steuerkram\Models\Beleg::delete($zweiterId), 'Jüngster Beleg ist löschbar');
|
||||||
|
$this->assertEquals(
|
||||||
|
$vorher + 1,
|
||||||
|
$this->belegNrNext(),
|
||||||
|
'Das Löschen des jüngsten Belegs gibt dessen Nummer frei'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hilfsfunktionen ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function invoiceNrNext(): int
|
||||||
|
{
|
||||||
|
$row = \Steuerkram\Database::fetchOne('SELECT invoice_nr_next FROM company_settings WHERE id=1');
|
||||||
|
return (int)($row['invoice_nr_next'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function belegNrNext(): int
|
||||||
|
{
|
||||||
|
$row = \Steuerkram\Database::fetchOne('SELECT beleg_nr_next FROM company_settings WHERE id=1');
|
||||||
|
return (int)($row['beleg_nr_next'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createRechnung(string $marker): int
|
||||||
|
{
|
||||||
|
$customerId = \Steuerkram\Models\Customer::create([
|
||||||
|
'nachname' => 'Nummernkreis',
|
||||||
|
'firma' => $marker . ' GmbH',
|
||||||
|
'strasse' => 'Zählerweg 1',
|
||||||
|
'plz' => '12345',
|
||||||
|
'ort' => 'Nummernstadt',
|
||||||
|
'land' => 'DE',
|
||||||
|
'payment_days' => 14,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return \Steuerkram\Models\Invoice::create([
|
||||||
|
'customer_id' => $customerId,
|
||||||
|
'betreff' => $marker,
|
||||||
|
'rechnungs_datum' => date('Y-m-d'),
|
||||||
|
'lines' => [
|
||||||
|
['bezeichnung' => 'Leistung', 'menge' => 1, 'einheit' => 'Std.', 'einzelpreis' => 100.00, 'ust_satz' => 19.0],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legt einen Beleg ohne Datei an (storage_path zeigt bewusst ins Leere). */
|
||||||
|
private function createBeleg(string $marker): int
|
||||||
|
{
|
||||||
|
$belegNr = \Steuerkram\Models\Beleg::generateBelegNr();
|
||||||
|
|
||||||
|
return \Steuerkram\Models\Beleg::create([
|
||||||
|
'beleg_nr' => $belegNr,
|
||||||
|
'filename' => $marker . '.pdf',
|
||||||
|
'storage_path' => date('Y') . '/' . $marker . '/original.pdf',
|
||||||
|
'mime_type' => 'application/pdf',
|
||||||
|
'file_hash' => hash('sha256', $belegNr . $marker),
|
||||||
|
'belegtyp' => 'Eingangsrechnung',
|
||||||
|
'beleg_datum' => date('Y-m-d'),
|
||||||
|
'notes' => $marker,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -81,16 +81,23 @@ docker compose exec web php scripts/e2e-cleanup.php # nur anzeigen
|
||||||
docker compose exec web php scripts/e2e-cleanup.php --force # löschen
|
docker compose exec web php scripts/e2e-cleanup.php --force # löschen
|
||||||
```
|
```
|
||||||
|
|
||||||
### Nicht umkehrbare Nebenwirkung
|
### Nummernkreise
|
||||||
|
|
||||||
`rechnung-export.spec.js` finalisiert eine Rechnung und **verbraucht dabei eine
|
`rechnung-export.spec.js` finalisiert eine Rechnung und vergibt dabei eine
|
||||||
Nummer aus dem laufenden Nummernkreis**. Die Rechnung selbst wird wieder gelöscht,
|
Rechnungs- und eine Belegnummer. Beide gehen beim Aufräumen wieder zurück:
|
||||||
die Nummer bleibt vergeben (GoBD — eine Rücknahme wäre auch nicht wünschenswert).
|
`Invoice::delete()` und `Beleg::delete()` zählen den jeweiligen Nummernkreis
|
||||||
Wer das vermeiden will, lässt diesen Test weg:
|
zurück, wenn der gelöschte Datensatz die **zuletzt vergebene** Nummer trug.
|
||||||
|
Nach einem vollständigen Testlauf stehen die Zähler also wieder auf ihrem
|
||||||
|
Ausgangswert; der Test `Löschen einer finalisierten Rechnung gibt die Nummer
|
||||||
|
wieder frei` weist das nach.
|
||||||
|
|
||||||
```bash
|
War die Nummer nicht die letzte (z.B. weil parallel eine echte Rechnung
|
||||||
... npx playwright test --grep-invert "Finalisieren"
|
finalisiert wurde), bleibt der Zähler stehen — eine Nummer wird nie zweimal
|
||||||
```
|
vergeben. In dem Fall entsteht wie bisher eine Lücke im Nummernkreis.
|
||||||
|
|
||||||
|
Bricht ein Lauf mittendrin ab, bleiben Testdatensätze liegen;
|
||||||
|
`scripts/e2e-cleanup.php --force` räumt sie nachträglich ab und arbeitet dabei
|
||||||
|
absteigend, damit möglichst viele Nummern zurückwandern.
|
||||||
|
|
||||||
## Aufbau
|
## Aufbau
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,10 @@
|
||||||
// (BT-48), die im XML fehlte. Beide Male hing der Fehler am Zusammenspiel aus
|
// (BT-48), die im XML fehlte. Beide Male hing der Fehler am Zusammenspiel aus
|
||||||
// Controller, ZugferdService und den tatsächlich gespeicherten Rechnungsdaten.
|
// Controller, ZugferdService und den tatsächlich gespeicherten Rechnungsdaten.
|
||||||
//
|
//
|
||||||
// Hinweis: Der Finalisierungs-Test verbraucht eine Rechnungsnummer aus dem
|
// Hinweis: Der Finalisierungs-Test vergibt eine Rechnungsnummer, gibt sie beim
|
||||||
// laufenden Nummernkreis. Das lässt sich nicht zurücknehmen (GoBD) — die
|
// Löschen der Testrechnung aber wieder frei (Invoice::delete). Der Nummernkreis
|
||||||
// Rechnung selbst wird im Testmodus wieder gelöscht. Siehe README.md.
|
// steht nach dem Testlauf also wieder da, wo er vorher stand — der letzte Test
|
||||||
|
// dieser Datei weist genau das nach. Siehe README.md.
|
||||||
|
|
||||||
const { test, expect } = require('@playwright/test')
|
const { test, expect } = require('@playwright/test')
|
||||||
const { uniqueTag, gotoOk, acceptDialogs, cleanupInvoice, parseMoney } = require('../helpers/app')
|
const { uniqueTag, gotoOk, acceptDialogs, cleanupInvoice, parseMoney } = require('../helpers/app')
|
||||||
|
|
@ -46,6 +47,29 @@ async function entwurfAnlegen(page, { tag, einzelpreis = '100.00', ustSatz = '19
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Finalisiert den Entwurf über den Button in der Detailansicht. */
|
||||||
|
async function finalisieren(page, invoiceId) {
|
||||||
|
acceptDialogs(page)
|
||||||
|
await page.click('button[type="submit"]:has-text("Finalisieren")')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
await expect(page.locator('body'), 'Finalisieren hat den Status nicht geändert')
|
||||||
|
.not.toContainText('Finalisieren & Nummerieren')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest die vergebene Rechnungsnummer aus dem Dateinamen des XML-Downloads.
|
||||||
|
* Sie steht nirgends sonst maschinell eindeutig auf der Seite.
|
||||||
|
*/
|
||||||
|
async function rechnungsnummerAusXml(page, invoiceId) {
|
||||||
|
const response = await page.request.get(`/invoices/${invoiceId}/xml`)
|
||||||
|
expect(response.status()).toBe(200)
|
||||||
|
const dateiname = response.headers()['content-disposition'] || ''
|
||||||
|
const nummer = dateiname.match(/Rechnung-(.+)\.xml/)?.[1]
|
||||||
|
expect(nummer, `Rechnungsnummer nicht aus Dateiname ermittelbar: ${dateiname}`).toBeTruthy()
|
||||||
|
expect(nummer).not.toBe('Entwurf')
|
||||||
|
return nummer
|
||||||
|
}
|
||||||
|
|
||||||
test.describe('Rechnung: Anlegen, Finalisieren, Export', () => {
|
test.describe('Rechnung: Anlegen, Finalisieren, Export', () => {
|
||||||
let invoiceId = 0
|
let invoiceId = 0
|
||||||
|
|
||||||
|
|
@ -85,13 +109,9 @@ test.describe('Rechnung: Anlegen, Finalisieren, Export', () => {
|
||||||
test('Finalisieren vergibt eine Nummer und liefert PDF und XML aus', async ({ page }) => {
|
test('Finalisieren vergibt eine Nummer und liefert PDF und XML aus', async ({ page }) => {
|
||||||
const tag = uniqueTag()
|
const tag = uniqueTag()
|
||||||
invoiceId = await entwurfAnlegen(page, { tag })
|
invoiceId = await entwurfAnlegen(page, { tag })
|
||||||
acceptDialogs(page)
|
|
||||||
|
|
||||||
await page.click('button[type="submit"]:has-text("Finalisieren")')
|
await finalisieren(page, invoiceId)
|
||||||
await page.waitForLoadState('networkidle')
|
|
||||||
|
|
||||||
// Status darf nicht mehr "Entwurf" sein und es muss eine Nummer geben.
|
|
||||||
await expect(page.locator('body')).not.toContainText('Finalisieren & Nummerieren')
|
|
||||||
const seitentext = await page.locator('body').textContent()
|
const seitentext = await page.locator('body').textContent()
|
||||||
expect(seitentext).toContain(tag)
|
expect(seitentext).toContain(tag)
|
||||||
|
|
||||||
|
|
@ -109,13 +129,33 @@ test.describe('Rechnung: Anlegen, Finalisieren, Export', () => {
|
||||||
expect(xml).toContain('CrossIndustryInvoice')
|
expect(xml).toContain('CrossIndustryInvoice')
|
||||||
|
|
||||||
// Die vergebene Rechnungsnummer muss im XML stehen (BT-1).
|
// Die vergebene Rechnungsnummer muss im XML stehen (BT-1).
|
||||||
const dateiname = xmlResponse.headers()['content-disposition'] || ''
|
const nummer = await rechnungsnummerAusXml(page, invoiceId)
|
||||||
const nummer = dateiname.match(/Rechnung-(.+)\.xml/)?.[1]
|
|
||||||
expect(nummer, `Rechnungsnummer nicht aus Dateiname ermittelbar: ${dateiname}`).toBeTruthy()
|
|
||||||
expect(nummer).not.toBe('Entwurf')
|
|
||||||
expect(xml).toContain(nummer)
|
expect(xml).toContain(nummer)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Löschen einer finalisierten Rechnung gibt die Nummer wieder frei', async ({ page }) => {
|
||||||
|
// Der Testlauf darf keine Nummer aus dem laufenden Kreis verbrauchen:
|
||||||
|
// Invoice::delete() zählt den Nummernkreis zurück, wenn die gelöschte
|
||||||
|
// Rechnung die zuletzt nummerierte war. Nachweis über die Nummer der
|
||||||
|
// nächsten Rechnung — sie muss dieselbe sein.
|
||||||
|
const ersteId = await entwurfAnlegen(page, { tag: uniqueTag() })
|
||||||
|
await finalisieren(page, ersteId)
|
||||||
|
const ersteNummer = await rechnungsnummerAusXml(page, ersteId)
|
||||||
|
|
||||||
|
await cleanupInvoice(page, ersteId)
|
||||||
|
await expect(
|
||||||
|
page.locator(`form[action="/invoices/${ersteId}/delete"]`),
|
||||||
|
'Die finalisierte Testrechnung wurde nicht gelöscht'
|
||||||
|
).toHaveCount(0)
|
||||||
|
|
||||||
|
invoiceId = await entwurfAnlegen(page, { tag: uniqueTag() })
|
||||||
|
await finalisieren(page, invoiceId)
|
||||||
|
expect(
|
||||||
|
await rechnungsnummerAusXml(page, invoiceId),
|
||||||
|
'Der Nummernkreis wurde beim Löschen nicht zurückgesetzt'
|
||||||
|
).toBe(ersteNummer)
|
||||||
|
})
|
||||||
|
|
||||||
test('PDF eines Entwurfs wird nicht ausgeliefert', async ({ page }) => {
|
test('PDF eines Entwurfs wird nicht ausgeliefert', async ({ page }) => {
|
||||||
const tag = uniqueTag()
|
const tag = uniqueTag()
|
||||||
invoiceId = await entwurfAnlegen(page, { tag })
|
invoiceId = await entwurfAnlegen(page, { tag })
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue