Phase 9: Kassenverwaltung
Kassenbuch, Mitgliedsbeitraege, Rechnungen an Dritte, Kassenpruefung und die Auswertungen fuer die Jahreshauptversammlung. Die drei Festlegungen aus Phase-9.md sitzen in der Datenbank, nicht im Router: eine festgeschriebene Buchung ist unveraenderbar, ein geprueftes Jahr nimmt kein Datum mehr an, und gerechnet wird mit Prisma.Decimal. Korrigiert wird ueber eine Gegenbuchung mit Verweis - beide Buchungen bleiben stehen, damit im Kassenbuch zu sehen ist, dass etwas berichtigt wurde. Neun Modelle, drei Trigger, zwoelf CHECKs, sechs Router, fuenf Ansichten. Dazu der Bankimport fuer CAMT.053 und MT940 ohne Fremdbibliothek, der Kategorievorschlag aus dem eigenen Buchungsbestand und die Bruecke von Phase 8: aus einem gegengezeichneten Beleg wird eine Buchung, das Foto haengt danach an ihr. Nebenbei behoben: die Rechnungsnummer war ein Wettlauf, und eine Organisation liess sich nicht mehr loeschen, sobald sie eine festgeschriebene Buchung hatte - der Trigger kannte den Aufbewahrungsschalter nicht. 562 Unit-Tests und 239 E2E-Tests gruen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d70acad4cd
commit
4165334c5a
@@ -67,9 +67,7 @@ test.describe("Anmeldung", () => {
|
||||
await expect(page.getByRole("note")).toContainText("analoge Überwachung");
|
||||
});
|
||||
|
||||
test("zeigt die Navigation und markiert künftige Module als noch nicht verfügbar", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("zeigt die Navigation mit allen umgesetzten Modulen", async ({ page }) => {
|
||||
await page.goto("/anmelden");
|
||||
await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
|
||||
await page.getByLabel("Passwort").fill(ADMIN_PASSWORT);
|
||||
@@ -95,13 +93,13 @@ test.describe("Anmeldung", () => {
|
||||
navigation.getByRole("link", { name: "Benachrichtigungen" }),
|
||||
).toBeVisible();
|
||||
|
||||
// Die Kasse kommt erst in Phase 9 — der Eintrag ist sichtbar, aber kein
|
||||
// Verweis. Beim Abschluss einer Phase wandert diese Zusicherung eine Zeile
|
||||
// weiter nach unten.
|
||||
await expect(navigation.getByText("Kasse")).toBeVisible();
|
||||
await expect(navigation.getByRole("link", { name: "Kasse" })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
// Die Kasse ist seit Phase 9 ein Verweis.
|
||||
//
|
||||
// Bis dahin stand hier die Gegenprobe: der Eintrag sichtbar, aber
|
||||
// ausgegraut, weil das Modul noch fehlte. Mit Phase 9 gibt es kein
|
||||
// künftiges Modul mehr — eine Zusicherung über ein Modul, das es nicht
|
||||
// gibt, wäre keine. Kommt später eines dazu, gehört sie hierher zurück.
|
||||
await expect(navigation.getByRole("link", { name: "Kasse" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user