Files
Laufzettel/tests/abteilungsabschnitte.test.ts
T
Florian Wilde b8166e10ea
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(vorlagen): Abschnitte nur fuer bestimmte Abteilungen
Beim Onboarding in der Buchhaltung gehoeren Schritte dazu, die sonst niemand
braucht: DATEV, Drucker fuer Formulare, Bankportal. Bisher hiess die Wahl: eine
Vorlage je Abteilung pflegen, oder Schritte, die fuer die meisten "nicht
zutreffend" sind.

Jeder Abschnitt kann jetzt nennen, fuer welche Abteilungen er gilt -- als
**Namen**, nicht als Kennungen. Der Abschnitt gehoert zur Version und muss ein
Umbenennen oder Archivieren einer Abteilung ueberleben; dieselbe Entscheidung
wie bei Mitarbeiter.abteilung. Deshalb wandert die Einschraenkung auch durch
Export und Import.

Welche Abschnitte ein Lauf hat, wird beim Start festgeschrieben. Ein spaeterer
Abteilungswechsel aendert ihn nicht -- sonst verschwaende ein Schritt, an dem
schon eine Notiz haengt, und ein archivierter Lauf muss genau das zeigen, was
damals abgearbeitet wurde.

Leer heisst alle: Laeufe von vor dieser Fassung verhalten sich unveraendert,
ohne dass eine Migration Daten nachfuehren muesste. Damit das eindeutig bleibt,
wird ein Lauf abgewiesen, fuer den kein Abschnitt uebrig bleibt -- es gaebe
ohnehin nichts abzuarbeiten.

Die Abschlussregel zaehlt nur geltende Pflichtschritte. Vorher waere der
Abschluss unerreichbar gewesen: abhaken laesst sich nur, was in der Abarbeitung
steht. Der Test hat das sofort gezeigt.

Die Regel liegt in src/lib, weil drei Schichten sie brauchen -- der Dienst beim
Start und beim Abschluss, die Lesemodelle der Abarbeitung und des Drucks. In
einem Lesemodell waere sie eine Abhaengigkeit in die falsche Richtung.

Im gebauten Stand durchgeklickt: einschraenken, speichern, veroeffentlichen,
zwei Laeufe mit verschiedenen Abteilungen starten -- 6 Abschnitte mit 23
Schritten fuer die Buchhaltung, 5 mit 20 fuer den Vertrieb. Dabei einen eigenen
Numerusfehler im Hinweistext gefunden und behoben.

Closes #51
2026-10-04 20:04:14 +02:00

266 lines
11 KiB
TypeScript
Raw 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.
import { beforeEach, describe, expect, it } from "vitest";
import { LaufStatus, SchrittZustand, VersionStatus } from "@/generated/prisma/enums";
import { LaufFehler, laufAnlegen, laufAbschliessen } from "@/server/services/laeufe";
import { geltendeAbschnitteErmitteln, nurGeltende } from "@/lib/geltende-abschnitte";
import { laufZumAbarbeiten } from "@/server/queries/laeufe";
import { laufZumDrucken } from "@/server/queries/druck";
import { schrittergebnisSetzen } from "@/server/services/schrittergebnisse";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Abteilungsspezifische Zusatzabschnitte (#031).
*
* Die Auswahl wird **beim Start festgeschrieben**. Alles hier dreht sich darum:
* dass sie stimmt, dass sie sich danach nicht mehr aendert, dass ein
* ausgefilterter Pflichtschritt den Abschluss nicht blockiert, und dass die
* Druckansicht genau dasselbe zeigt wie die Abarbeitung.
*/
describe("Auswahlregel", () => {
const abschnitte = [
{ id: "a-alle", nurFuerAbteilungen: [] },
{ id: "a-buch", nurFuerAbteilungen: ["Buchhaltung"] },
{ id: "a-beide", nurFuerAbteilungen: ["Buchhaltung", "Vertrieb"] },
];
it("nimmt uneingeschränkte Abschnitte immer", () => {
expect(geltendeAbschnitteErmitteln(abschnitte, null)).toEqual(["a-alle"]);
});
it("nimmt den Abschnitt der passenden Abteilung dazu", () => {
expect(geltendeAbschnitteErmitteln(abschnitte, "Buchhaltung")).toEqual(["a-alle", "a-buch", "a-beide"]);
expect(geltendeAbschnitteErmitteln(abschnitte, "Vertrieb")).toEqual(["a-alle", "a-beide"]);
});
it("vergleicht ohne Rücksicht auf Groß- und Kleinschreibung und Ränder", () => {
// Die Namen kommen aus zwei Quellen – der gepflegten Abteilung und dem
// Freitextfeld im Editor. „buchhaltung“ ist dieselbe Abteilung.
expect(geltendeAbschnitteErmitteln(abschnitte, " buchhaltung ")).toContain("a-buch");
});
it("lässt eine unbekannte Abteilung nur die uneingeschränkten Abschnitte", () => {
expect(geltendeAbschnitteErmitteln(abschnitte, "Einkauf")).toEqual(["a-alle"]);
});
});
describe("nurGeltende", () => {
const abschnitte = [{ id: "eins" }, { id: "zwei" }, { id: "drei" }];
it("gibt bei leerer Liste alles zurück – so bleiben alte Läufe unberührt", () => {
expect(nurGeltende(abschnitte, [])).toEqual(abschnitte);
});
it("filtert und behält die Reihenfolge der Version", () => {
expect(nurGeltende(abschnitte, ["drei", "eins"])).toEqual([{ id: "eins" }, { id: "drei" }]);
});
});
async function aufbau(
abschnitte: readonly {
readonly titel: string;
readonly nurFuerAbteilungen?: readonly string[];
readonly schritte: readonly { readonly titel: string; readonly pflicht?: boolean }[];
}[],
) {
const kunde = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global" } });
const admin = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
});
const buchhaltung = await testPrisma.abteilung.create({
data: { kundeId: kunde.id, name: "Buchhaltung" },
});
const vertrieb = await testPrisma.abteilung.create({
data: { kundeId: kunde.id, name: "Vertrieb" },
});
const vorlage = await testPrisma.vorlage.create({ data: { name: "Onboarding" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
});
for (const [position, abschnitt] of abschnitte.entries()) {
const angelegt = await testPrisma.vorlagenAbschnitt.create({
data: {
versionId: version.id,
titel: abschnitt.titel,
position,
nurFuerAbteilungen: [...(abschnitt.nurFuerAbteilungen ?? [])],
},
});
await testPrisma.vorlagenSchritt.createMany({
data: abschnitt.schritte.map((schritt, stelle) => ({
abschnittId: angelegt.id,
titel: schritt.titel,
pflicht: schritt.pflicht ?? false,
position: stelle,
})),
});
}
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
});
return {
kunde,
admin: { id: admin.id, rolle: admin.rolle },
buchhaltung,
vertrieb,
versionId: version.id,
};
}
const VORLAGE = [
{ titel: "Vorbereitung", schritte: [{ titel: "Papiere", pflicht: true }] },
{
titel: "Buchhaltung",
nurFuerAbteilungen: ["Buchhaltung"],
schritte: [{ titel: "DATEV einrichten", pflicht: true }],
},
{ titel: "Abschluss", schritte: [{ titel: "Übergabe", pflicht: true }] },
] as const;
describe.runIf(datenbankVerfuegbar)("Abteilungsabschnitte am Lauf", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("nimmt den Buchhaltungs-Abschnitt für einen Lauf der Buchhaltung", async () => {
const { admin, buchhaltung, versionId } = await aufbau(VORLAGE);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, abteilungId: buchhaltung.id }, admin);
const daten = await laufZumAbarbeiten(lauf.id, admin);
expect(daten?.abschnitte.map((abschnitt) => abschnitt.titel)).toEqual([
"Vorbereitung",
"Buchhaltung",
"Abschluss",
]);
// Die Nummerierung zählt nur die geltenden Schritte.
expect(daten?.abschnitte.flatMap((a) => a.schritte.map((s) => s.nummer))).toEqual([1, 2, 3]);
});
it("lässt ihn für einen Lauf des Vertriebs weg", async () => {
const { admin, vertrieb, versionId } = await aufbau(VORLAGE);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, abteilungId: vertrieb.id }, admin);
const daten = await laufZumAbarbeiten(lauf.id, admin);
expect(daten?.abschnitte.map((abschnitt) => abschnitt.titel)).toEqual(["Vorbereitung", "Abschluss"]);
expect(daten?.anzahlPflicht).toBe(2);
});
it("gibt einem Lauf ohne Abteilung nur die uneingeschränkten Abschnitte", async () => {
// Ein Firewall-Rollout hat keine Abteilung, und das ist kein Mangel.
const { admin, versionId } = await aufbau(VORLAGE);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
const daten = await laufZumAbarbeiten(lauf.id, admin);
expect(daten?.abschnitte.map((abschnitt) => abschnitt.titel)).toEqual(["Vorbereitung", "Abschluss"]);
});
it("legt nur für die geltenden Schritte ein Schrittergebnis an", async () => {
const { admin, vertrieb, versionId } = await aufbau(VORLAGE);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, abteilungId: vertrieb.id }, admin);
const ergebnisse = await testPrisma.schrittergebnis.findMany({
where: { laufId: lauf.id },
include: { vorlagenSchritt: true },
});
expect(ergebnisse.map((e) => e.vorlagenSchritt.titel).sort()).toEqual(["Papiere", "Übergabe"]);
});
it("ändert die Abschnitte nicht, wenn später die Abteilung des Laufs wechselt", async () => {
/*
* Der Grund für das Festschreiben: ein archivierter Lauf muss genau die
* Schritte zeigen, die damals abgearbeitet wurden. Ein Abteilungswechsel
* darf keinen Schritt entfernen, an dem schon eine Notiz hängt.
*/
const { admin, buchhaltung, vertrieb, versionId } = await aufbau(VORLAGE);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, abteilungId: buchhaltung.id }, admin);
expect((await laufZumAbarbeiten(lauf.id, admin))?.abschnitte).toHaveLength(3);
await testPrisma.lauf.update({ where: { id: lauf.id }, data: { abteilungId: vertrieb.id } });
const nachher = await laufZumAbarbeiten(lauf.id, admin);
expect(nachher?.abschnitte.map((abschnitt) => abschnitt.titel)).toEqual([
"Vorbereitung",
"Buchhaltung",
"Abschluss",
]);
});
it("lässt den Abschluss zu, obwohl ein ausgefilterter Pflichtschritt existiert", async () => {
// Ohne diese Regel wäre der Abschluss unerreichbar: der Pflichtschritt der
// Buchhaltung ließe sich in einem Vertriebs-Lauf nie abhaken.
const { admin, vertrieb, versionId } = await aufbau(VORLAGE);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, abteilungId: vertrieb.id }, admin);
const ergebnisse = await testPrisma.schrittergebnis.findMany({ where: { laufId: lauf.id } });
for (const ergebnis of ergebnisse) {
await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: ergebnis.vorlagenSchrittId,
zustand: SchrittZustand.ERLEDIGT,
},
admin,
);
}
const abgeschlossen = await laufAbschliessen(lauf.id, admin);
expect(abgeschlossen.status).toBe(LaufStatus.ABGESCHLOSSEN);
});
it("zeigt in der Druckansicht genau dieselben Abschnitte", async () => {
const { admin, vertrieb, versionId } = await aufbau(VORLAGE);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, abteilungId: vertrieb.id }, admin);
const abarbeitung = await laufZumAbarbeiten(lauf.id, admin);
const druck = await laufZumDrucken(lauf.id, admin);
expect(druck?.abschnitte.map((abschnitt) => abschnitt.titel)).toEqual(
abarbeitung?.abschnitte.map((abschnitt) => abschnitt.titel),
);
expect(druck?.abschnitte.map((abschnitt) => abschnitt.titel)).not.toContain("Buchhaltung");
});
it("weist einen Lauf ab, für den kein Abschnitt übrig bleibt", async () => {
const { admin, versionId } = await aufbau([
{
titel: "Nur Buchhaltung",
nurFuerAbteilungen: ["Buchhaltung"],
schritte: [{ titel: "DATEV" }],
},
]);
await expect(laufAnlegen({ vorlagenVersionId: versionId }, admin)).rejects.toThrow(LaufFehler);
await expect(laufAnlegen({ vorlagenVersionId: versionId }, admin)).rejects.toThrow(/kein Abschnitt/);
});
it("lässt einen Lauf ohne Einschränkungen unverändert – geltendeAbschnitte zählt alle", async () => {
// Kein Sonderfall im Leseweg: die Liste enthält auch dann alle Abschnitte.
const { admin, versionId } = await aufbau([
{ titel: "Eins", schritte: [{ titel: "a" }] },
{ titel: "Zwei", schritte: [{ titel: "b" }] },
]);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
const zeile = await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } });
expect(zeile.geltendeAbschnitte).toHaveLength(2);
expect((await laufZumAbarbeiten(lauf.id, admin))?.abschnitte).toHaveLength(2);
});
it("zeigt bei einem Lauf von vor dieser Fassung alle Abschnitte", async () => {
// `geltendeAbschnitte` leer heißt alle – so verhalten sich bestehende Läufe
// unverändert, ohne dass eine Migration Daten nachführen müsste.
const { admin, versionId } = await aufbau(VORLAGE);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
await testPrisma.lauf.update({ where: { id: lauf.id }, data: { geltendeAbschnitte: [] } });
const daten = await laufZumAbarbeiten(lauf.id, admin);
expect(daten?.abschnitte.map((abschnitt) => abschnitt.titel)).toEqual([
"Vorbereitung",
"Buchhaltung",
"Abschluss",
]);
});
});