Files
FireAdministration/tests/unit/kasse-vorschlag.test.ts
T
Florian WildeandClaude Opus 5 4165334c5a 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>
2026-09-04 19:23:34 +02:00

201 lines
6.4 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
STANDARDKATEGORIEN,
aehnlichkeit,
kategorieVorschlagen,
woerter,
} from "@/lib/kasse/vorschlag";
/**
* Der Kategorievorschlag.
*
* Sein Nutzen liegt nicht in den gesparten Sekunden, sondern darin, dass
* dieselbe Sache immer unter derselben Kategorie landet — erst das macht die
* Jahresauswertung brauchbar. Zwei Kategorien für Diesel, weil einmal
* „Kraftstoff" und einmal „Fahrzeuge" gewählt wurde, sind in der
* Jahreshauptversammlung nicht zu erklären.
*
* Ebenso wichtig ist, wann er schweigt: ein falscher Vorschlag wird
* übernommen, ohne gelesen zu werden. Deshalb die Schwelle bei der Hälfte
* gemeinsamer Wörter — und deshalb prüft dieser Test beide Seiten.
*/
const KATEGORIEN = [
{ id: "k-kraftstoff", name: "Kraftstoff", art: "AUSGABE" as const },
{ id: "k-reparatur", name: "Reparatur", art: "AUSGABE" as const },
{ id: "k-strom", name: "Wasser und Strom", art: "AUSGABE" as const },
{ id: "k-zuschuss", name: "Zuschüsse", art: "EINNAHME" as const },
{ id: "k-beitrag", name: "Mitgliedsbeiträge", art: "EINNAHME" as const },
];
describe("Wörter", () => {
it("wirft Füllwörter, kurze Wörter und reine Zahlen weg", () => {
expect(woerter("Rechnung Nr. 4711 vom 12.03. für die Feuerwehr GmbH")).toEqual([]);
});
it("behält, was unterscheidet", () => {
expect(woerter("Tankstelle Musterhausen Diesel LF 20")).toEqual([
"tankstelle",
"musterhausen",
"diesel",
]);
});
it("lässt Umlaute stehen", () => {
expect(woerter("Prüfung Drehleiter")).toEqual(["prüfung", "drehleiter"]);
});
});
describe("Ähnlichkeit", () => {
it("misst am kürzeren der beiden Texte", () => {
// "stadtwerke musterhausen" steckt vollständig in beiden.
const wert = aehnlichkeit(
"Stadtwerke Musterhausen Abschlag Strom",
"Stadtwerke Musterhausen Jahresabrechnung Strom und Wasser",
);
expect(wert).toBeGreaterThanOrEqual(0.5);
});
it("ergibt null bei nichts Gemeinsamem", () => {
expect(aehnlichkeit("Diesel Tankstelle", "Einsatzjacken Beschaffung")).toBe(0);
});
it("ergibt null, wenn nach dem Aussieben nichts übrig ist", () => {
expect(aehnlichkeit("Rechnung vom", "Rechnung Nr. 12")).toBe(0);
});
});
describe("Vorschlag aus dem eigenen Bestand", () => {
const vorbilder = [
{
kategorieId: "k-kraftstoff",
kategorieName: "Kraftstoff",
verwendungszweck: "Tankstelle Musterhausen Diesel LF 20 Sammelrechnung",
art: "AUSGABE" as const,
},
{
kategorieId: "k-reparatur",
kategorieName: "Reparatur",
verwendungszweck: "Werkstatt Müller Kupplung MTW",
art: "AUSGABE" as const,
},
];
it("nimmt die Kategorie, unter der dasselbe zuletzt gebucht wurde", () => {
const vorschlag = kategorieVorschlagen(
"Tankstelle Musterhausen Diesel MTW",
"AUSGABE",
vorbilder,
KATEGORIEN,
);
expect(vorschlag?.kategorieId).toBe("k-kraftstoff");
expect(vorschlag?.grund).toMatch(/zuletzt so gebucht/);
});
it("geht dem Bestand vor den Stichwörtern nach — die Wehr kennt sich selbst am besten", () => {
const eigen = [
{
kategorieId: "k-strom",
kategorieName: "Wasser und Strom",
// Diese Wehr bucht Diesel unter Wasser und Strom. Merkwürdig, aber
// ihre Entscheidung — und sie soll dabei bleiben dürfen.
verwendungszweck: "Tankstelle Musterhausen Diesel LF 20",
art: "AUSGABE" as const,
},
];
const vorschlag = kategorieVorschlagen(
"Tankstelle Musterhausen Diesel LF 20",
"AUSGABE",
eigen,
KATEGORIEN,
);
expect(vorschlag?.kategorieId).toBe("k-strom");
});
it("schweigt bei zufälliger Übereinstimmung, statt zu raten", () => {
const vorschlag = kategorieVorschlagen(
"Möbelhaus Sonnleitner Tischbeschaffung Schulungsraum",
"AUSGABE",
vorbilder,
KATEGORIEN,
);
expect(vorschlag).toBeNull();
});
it("mischt Einnahmen und Ausgaben nicht", () => {
const vorschlag = kategorieVorschlagen(
"Tankstelle Musterhausen Diesel LF 20 Sammelrechnung",
"EINNAHME",
vorbilder,
KATEGORIEN,
);
// Der Vorgänger ist eine Ausgabe und kommt deshalb nicht in Frage; ein
// Stichwort für Einnahmen trifft hier ebenso wenig.
expect(vorschlag?.kategorieId).not.toBe("k-kraftstoff");
});
});
describe("Vorschlag aus Stichwörtern", () => {
it("greift, solange es keinen Bestand gibt", () => {
const vorschlag = kategorieVorschlagen("Diesel für das LF 20", "AUSGABE", [], KATEGORIEN);
expect(vorschlag?.kategorieId).toBe("k-kraftstoff");
expect(vorschlag?.grund).toMatch(/Verwendungszweck/);
// Niedrigere Sicherheit als ein Treffer aus dem Bestand: ein Stichwort
// weiß nichts über die Gepflogenheiten dieser Wehr.
expect(vorschlag?.sicherheit).toBeLessThan(1);
});
it("erkennt den Gemeindezuschuss als Einnahme", () => {
const vorschlag = kategorieVorschlagen(
"Zuschuss der Gemeinde für den laufenden Betrieb",
"EINNAHME",
[],
KATEGORIEN,
);
expect(vorschlag?.kategorieId).toBe("k-zuschuss");
});
it("schlägt nichts vor, wenn es die Kategorie in dieser Wehr nicht gibt", () => {
const vorschlag = kategorieVorschlagen("Neue Einsatzjacken", "AUSGABE", [], KATEGORIEN);
expect(vorschlag).toBeNull();
});
it("schweigt bei leerem Verwendungszweck", () => {
expect(kategorieVorschlagen(" ", "AUSGABE", [], KATEGORIEN)).toBeNull();
});
});
describe("Standardkontenrahmen", () => {
it("verweist nur auf Oberkategorien, die es auch gibt", () => {
const namen = new Set(STANDARDKATEGORIEN.map((eintrag) => eintrag.name));
for (const eintrag of STANDARDKATEGORIEN) {
if (eintrag.oberkategorie) {
expect(namen.has(eintrag.oberkategorie)).toBe(true);
}
}
});
it("gibt jeden Namen nur einmal — die Datenbank verlangt es so", () => {
const namen = STANDARDKATEGORIEN.map((eintrag) => eintrag.name);
expect(new Set(namen).size).toBe(namen.length);
});
it("stellt jede Unterkategorie in eine Oberkategorie derselben Richtung", () => {
const nachName = new Map(STANDARDKATEGORIEN.map((eintrag) => [eintrag.name, eintrag]));
for (const eintrag of STANDARDKATEGORIEN) {
if (eintrag.oberkategorie) {
expect(nachName.get(eintrag.oberkategorie)?.art).toBe(eintrag.art);
}
}
});
});