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
266 lines
11 KiB
TypeScript
266 lines
11 KiB
TypeScript
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",
|
||
]);
|
||
});
|
||
});
|