Phase 9: Kassenverwaltung (#14)

This commit was merged in pull request #14.
This commit is contained in:
2026-09-05 02:30:51 +00:00
parent d70acad4cd
commit 2b53556175
78 changed files with 12314 additions and 42 deletions
+258
View File
@@ -0,0 +1,258 @@
import { expect, test } from "@playwright/test";
import { anmelden, eigenerName } from "./wehr";
/**
* E2E-Tests für Phase 9 — die Kassenverwaltung.
*
* Was hier geprüft wird, ist nicht die Kür, sondern das, wovon die
* Kassenprüfung abhängt:
*
* * die Übersicht zeigt Bestände, die aus den Buchungen gerechnet sind
* * eine Buchung entsteht und erscheint im Kassenbuch
* * eine festgeschriebene Buchung lässt sich nicht mehr ändern — die
* Oberfläche bietet Gegenbuchen an, nicht Bearbeiten
* * die Beitragsliste zeigt Rückstände, nicht nur Summen
* * eine Rechnung entsteht als Entwurf mit fortlaufender Nummer
* * der geprüfte Zeitraum des Vorjahres ist zu
*
* Voraussetzung: Stack läuft, Datenbank ist migriert und geseedet
* (`npm run db:test-reset`).
*/
test.describe.configure({ mode: "serial" });
const JAHR = new Date().getFullYear();
test.describe("Kassenübersicht", () => {
test("zeigt die Konten mit gerechnetem Bestand", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse");
await expect(page.getByRole("heading", { name: "Kasse", level: 1 })).toBeVisible();
// Aus dem Seed — die Überschriften der Kontenkarten, nicht irgendein
// Vorkommen des Wortes: "Barkasse" steht auch in Verwendungszwecken.
await expect(page.getByRole("heading", { name: "Barkasse" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Girokonto Raiffeisenbank" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Rücklage Fahrzeugbeschaffung" }),
).toBeVisible();
// Ein Bestand steht da, und zwar als Betrag — nicht als leeres Feld.
await expect(page.getByText(/\d+,\d{2}\s€/).first()).toBeVisible();
});
test("führt zu den vier Aufgaben der Kasse", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse");
for (const [name, pfad] of [
["Buchungen", "/kasse/buchungen"],
["Beiträge", "/kasse/beitraege"],
["Rechnungen", "/kasse/rechnungen"],
["Abschluss", "/kasse/abschluss"],
] as const) {
await expect(page.locator(`a[href="${pfad}"]`).first()).toBeVisible();
expect(name.length).toBeGreaterThan(0);
}
});
});
test.describe("Kassenbuch", () => {
test("zeigt die Buchungen mit Summen über alle Treffer", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse/buchungen");
await expect(page.getByRole("heading", { name: "Buchungen", level: 1 })).toBeVisible();
// Die drei Summen oben. Sie beziehen sich auf alle Treffer, nicht nur auf
// die angezeigte Seite — sonst bedeutete die Zahl nichts.
await expect(page.getByText("Einnahmen", { exact: true })).toBeVisible();
await expect(page.getByText("Ausgaben", { exact: true })).toBeVisible();
await expect(page.getByText("Saldo", { exact: true })).toBeVisible();
// Aus dem Seed.
await expect(page.getByRole("cell", { name: /Maifest/ }).first()).toBeVisible();
});
test("erfasst eine Buchung und zeigt sie danach im Kassenbuch", async ({ page }) => {
const zweck = eigenerName("Buchung");
await anmelden(page);
await page.goto("/kasse/buchungen");
await page.getByRole("button", { name: "Buchung erfassen" }).click();
await page.getByLabel("Betrag", { exact: true }).fill("42.50");
await page.getByLabel("Verwendungszweck").fill(zweck);
// Kategorie wählen — die erste Ausgabenkategorie reicht.
await page.getByLabel("Kategorie", { exact: true }).click();
await page.getByRole("option").first().click();
await page.getByRole("button", { name: "Buchen" }).click();
// In der Zeile mit dem eigenen Verwendungszweck — der Betrag allein wäre
// nicht eindeutig, sobald ein zweiter Lauf dieselbe Buchung erzeugt hat.
const zeile = page.getByRole("row", { name: new RegExp(zweck) });
await expect(zeile).toBeVisible();
await expect(zeile.getByRole("cell", { name: "−42,50 €" })).toBeVisible();
});
test("bietet für festgeschriebene Buchungen Gegenbuchen statt Bearbeiten an", async ({
page,
}) => {
await anmelden(page);
await page.goto("/kasse/buchungen");
// Die Vorjahresbuchungen sind festgeschrieben — daran hängt die ganze
// Kassenprüfung. Die Oberfläche muss das zeigen, nicht nur der Server
// beim Versuch. Die Pressluftatmer gibt es im Seed nur im Vorjahr.
const fest = page.getByRole("row", { name: /Pressluftatmer/ });
await expect(fest).toBeVisible();
await expect(fest.getByRole("button", { name: "Gegenbuchen" })).toBeVisible();
await expect(fest.getByRole("button", { name: "Verwerfen" })).toHaveCount(0);
// Und die Gegenprobe: eine Buchung des laufenden Jahres ist noch ein
// Entwurf und darf verworfen werden.
const offen = page.getByRole("row", { name: /Kupplung MTW/ });
await expect(offen.getByRole("button", { name: "Verwerfen" })).toBeVisible();
await expect(offen.getByRole("button", { name: "Gegenbuchen" })).toHaveCount(0);
});
test("erklärt beim Gegenbuchen, warum beide Buchungen stehen bleiben", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse/buchungen");
await page
.getByRole("row", { name: /Pressluftatmer/ })
.getByRole("button", { name: "Gegenbuchen" })
.click();
await expect(page.getByRole("dialog")).toContainText("Kassenprüfer");
// Ohne Grund geht es nicht weiter — er steht später im Verwendungszweck.
await expect(page.getByRole("button", { name: "Gegenbuchen" }).last()).toBeDisabled();
});
});
test.describe("Beiträge", () => {
test("zeigt Sollstellung, Offenes und die Rückstände", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse/beitraege");
await expect(page.getByRole("heading", { name: "Mitgliedsbeiträge", level: 1 })).toBeVisible();
await expect(page.getByText("Sollstellung")).toBeVisible();
await expect(page.getByText("Bezahlt", { exact: true }).first()).toBeVisible();
// Die Sätze aus dem Seed.
await expect(page.getByText("Aktive und Passive")).toBeVisible();
await expect(page.getByText("Jugendfeuerwehr").first()).toBeVisible();
});
test("zeigt die Vorschau des Beitragslaufs, bevor etwas entsteht", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse/beitraege");
await page.getByRole("button", { name: `Beitragslauf ${JAHR}` }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
// Das Jahr ist im Seed bereits gelaufen — der Lauf darf deshalb nichts
// doppelt anlegen und muss genau das sagen.
await expect(dialog).toContainText(/bereits einen|0 Beiträge/);
});
});
test.describe("Rechnungen", () => {
test("zeigt die Rechnungen aus dem Bestand mit ihrem Stand", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse/rechnungen");
await expect(page.getByRole("heading", { name: "Rechnungen", level: 1 })).toBeVisible();
await expect(page.getByRole("cell", { name: `${JAHR}-0001` })).toBeVisible();
await expect(page.getByRole("cell", { name: /Sonnleitner/ })).toBeVisible();
await expect(page.getByRole("cell", { name: /Trachtenverein/ })).toBeVisible();
});
test("schreibt eine Rechnung mit fortlaufender Nummer", async ({ page }) => {
const empfaenger = eigenerName("Rechnung");
await anmelden(page);
await page.goto("/kasse/rechnungen");
await page.getByRole("button", { name: "Rechnung schreiben" }).click();
await page.getByLabel("Empfänger").fill(empfaenger);
await page.getByLabel("Bezeichnung").fill("Einsatz LF 20, angefangene Stunde");
await page.getByLabel("Einzelpreis").fill("396.00");
// Der Betrag wird gerechnet, nicht eingegeben — hier zur Anzeige, auf dem
// Server noch einmal verbindlich.
await expect(page.getByRole("dialog")).toContainText("396,00 €");
await page.getByRole("button", { name: "Als Entwurf anlegen" }).click();
await expect(page.getByText(/Rechnung \d{4}-\d{4} als Entwurf angelegt/)).toBeVisible();
await expect(page.getByRole("cell", { name: new RegExp(empfaenger) })).toBeVisible();
});
});
test.describe("Abschluss und Auswertung", () => {
test("zeigt die Kassenprüfung des Vorjahres als festgeschrieben", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse/abschluss");
await expect(
page.getByRole("heading", { name: "Abschluss und Auswertung", level: 1 }),
).toBeVisible();
await expect(page.getByText("Festgeschrieben").first()).toBeVisible();
// Die Differenz aus dem Seed steht mit ihrer Erklärung da. Genau das ist
// der Zweck des Feldes: in fünf Jahren weiß sonst niemand mehr, woher sie
// kam.
await expect(page.getByText("−3,20 €")).toBeVisible();
await expect(page.getByText(/Wechselgeld/)).toBeVisible();
});
test("weist einen Abschluss ab, dessen Zeitraum schon geprüft ist", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse/abschluss");
await page.getByRole("button", { name: "Kassenprüfung anlegen" }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
// Der Dialog schlägt von sich aus das Vorjahr vor — und genau das ist
// bereits geprüft. Erst die Kontenliste abwarten: sie kommt vom Server,
// und ohne sie wären null Felder zu füllen.
await expect(dialog.getByRole("columnheader", { name: "Laut Buch" })).toBeVisible();
const felder = dialog.getByRole("spinbutton");
const anzahl = await felder.count();
for (let stelle = 0; stelle < anzahl; stelle += 1) {
await felder.nth(stelle).fill("100");
}
await dialog.getByRole("button", { name: "Als Entwurf anlegen" }).click();
await expect(page.getByText(/überschneidet sich mit einem bereits geprüften/)).toBeVisible();
});
test("zeigt die Jahresauswertung mit Vorjahresvergleich", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse/abschluss");
await page.getByRole("button", { name: "Auswertung" }).click();
await expect(page.getByText(`Verlauf ${JAHR}`)).toBeVisible();
await expect(page.getByText("Einnahmen nach Kategorie")).toBeVisible();
await expect(page.getByText("Ausgaben nach Kategorie")).toBeVisible();
// Eine Zahl ohne Vergleich sagt nichts — das Vorjahr steht daneben.
await expect(page.getByText(new RegExp(`${JAHR - 1}:`)).first()).toBeVisible();
});
});