feat(vorlagen): Abschnitte nur fuer bestimmte Abteilungen (#58)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #58.
This commit is contained in:
2026-10-04 18:04:51 +00:00
parent 80870133b8
commit 855164066c
24 changed files with 815 additions and 37 deletions
+265
View File
@@ -0,0 +1,265 @@
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",
]);
});
});
+1
View File
@@ -151,6 +151,7 @@ describe("Zustand und Struktur", () => {
{
kennung: "a",
titel: "Vorbereitung",
nurFuerAbteilungen: [],
schritte: [
{
kennung: "s",