Flow-Tests (Content, Status, Unterschrift, Versionierung, Mandantentrennung, Scope) und PDF-Render-Smoke; Lane-Bericht docs/craftvia/lanes/berichte.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
346 lines
25 KiB
TypeScript
346 lines
25 KiB
TypeScript
// L5 Berichte & Unterschrift — Service-Tests gegen die lokale DB.
|
|
//
|
|
// Deckt ab: Content-Builder (Tagesfilter, Materialabweichungen), Statusfolgen create/submit/approve/reject,
|
|
// Auftragsstatus je Unterschriftsstand, Unterschrift-Validierung je outcome, Versionierung (freigegebene
|
|
// Version wird nie überschrieben), Mandantentrennung (B sieht/ändert nichts von A) und Rollen/Scope
|
|
// (Monteur ohne Zuweisung → not_found, fehlende Rechte → forbidden), Audit-Einträge.
|
|
//
|
|
// Lauf: npx tsx scripts/test-berichte-flow.ts
|
|
|
|
import "dotenv/config";
|
|
import { Prisma } from "@prisma/client";
|
|
import { prisma, dbForTenant } from "../src/server/db";
|
|
import { ROLE_DEFS, type RoleKey } from "../src/server/rbac";
|
|
import { ServiceError, type ServiceCtx } from "../src/server/services/context";
|
|
import { buildReportContent } from "../src/server/services/reports/build-content";
|
|
import { createCompletionReport, createDailyReport } from "../src/server/services/reports/create";
|
|
import { updateReportTexts } from "../src/server/services/reports/edit";
|
|
import { captureSignature } from "../src/server/services/reports/signature";
|
|
import { submitReport } from "../src/server/services/reports/submit";
|
|
import { approveReport, type ApproveDeps } from "../src/server/services/reports/approve";
|
|
import { rejectReport } from "../src/server/services/reports/reject";
|
|
import { createNewVersion } from "../src/server/services/reports/new-version";
|
|
import { requireVisibleReport } from "../src/server/services/reports/common";
|
|
import { listReports } from "../src/server/services/reports/queries";
|
|
import { storeFile } from "../src/server/services/reports/_stubs/documents";
|
|
|
|
let failures = 0;
|
|
const ok = (cond: boolean, msg: string) => {
|
|
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
|
if (!cond) failures++;
|
|
};
|
|
|
|
async function expectCode(fn: () => Promise<unknown>, code: ServiceError["code"], msg: string) {
|
|
try {
|
|
await fn();
|
|
ok(false, `${msg} — kein Fehler`);
|
|
} catch (err) {
|
|
const got = err instanceof ServiceError ? err.code : (err as Error).message;
|
|
ok(got === code, `${msg} (${got})`);
|
|
}
|
|
}
|
|
|
|
const SLUG_A = "zz-berichte-a";
|
|
const SLUG_B = "zz-berichte-b";
|
|
const EMAIL = (s: string) => `${s}@zz-berichte.test`;
|
|
const DAY1 = "2026-09-10";
|
|
const DAY2 = "2026-09-11";
|
|
// Europe/Berlin = UTC+2 in September
|
|
const at = (day: string, hhmm: string) => new Date(`${day}T${hhmm}:00+02:00`);
|
|
const PNG_1PX = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "base64");
|
|
|
|
async function cleanup() {
|
|
const tenants = await prisma.tenant.findMany({ where: { slug: { in: [SLUG_A, SLUG_B] } }, select: { id: true } });
|
|
const ids = tenants.map((t) => t.id);
|
|
if (ids.length) {
|
|
const w = { where: { tenantId: { in: ids } } };
|
|
await prisma.signature.deleteMany(w);
|
|
await prisma.report.deleteMany(w);
|
|
await prisma.photo.deleteMany(w);
|
|
await prisma.materialUsage.deleteMany(w);
|
|
await prisma.materialPlan.deleteMany(w);
|
|
await prisma.timeEntry.deleteMany(w);
|
|
await prisma.workSession.deleteMany(w);
|
|
await prisma.activityNote.deleteMany(w);
|
|
await prisma.checklistItem.deleteMany(w);
|
|
await prisma.photoRequirement.deleteMany(w);
|
|
await prisma.workOrderStatusChange.deleteMany(w);
|
|
await prisma.workOrderAssignee.deleteMany(w);
|
|
await prisma.document.deleteMany(w);
|
|
await prisma.workOrder.deleteMany(w);
|
|
await prisma.teamMember.deleteMany(w);
|
|
await prisma.team.deleteMany(w);
|
|
await prisma.site.deleteMany(w);
|
|
await prisma.contact.deleteMany(w);
|
|
await prisma.customer.deleteMany(w);
|
|
await prisma.numberSequence.deleteMany(w);
|
|
await prisma.auditLog.deleteMany(w);
|
|
await prisma.tenantSettings.deleteMany(w);
|
|
await prisma.user.deleteMany(w);
|
|
await prisma.tenant.deleteMany({ where: { id: { in: ids } } });
|
|
}
|
|
await prisma.identity.deleteMany({ where: { email: { endsWith: "@zz-berichte.test" } } });
|
|
}
|
|
|
|
async function mkUser(tenantId: string, key: string, name: string) {
|
|
const identity = await prisma.identity.create({ data: { email: EMAIL(`${key}-${tenantId.slice(-6)}`), passwordHash: "x" } });
|
|
return prisma.user.create({ data: { tenantId, identityId: identity.id, email: identity.email, name } });
|
|
}
|
|
|
|
const ctxOf = (tenantId: string, userId: string, role: RoleKey): ServiceCtx => ({
|
|
db: dbForTenant(tenantId),
|
|
tenantId,
|
|
userId,
|
|
permissions: new Set(ROLE_DEFS[role].permissions),
|
|
});
|
|
|
|
async function main() {
|
|
await cleanup();
|
|
|
|
// ---------- fixtures ----------
|
|
const tA = await prisma.tenant.create({ data: { name: "Berichte-Test A", slug: SLUG_A } });
|
|
const tB = await prisma.tenant.create({ data: { name: "Berichte-Test B", slug: SLUG_B } });
|
|
await prisma.tenantSettings.create({ data: { tenantId: tA.id, orgName: "Musterbetrieb A GmbH", address: "Weg 1, 20095 Hamburg" } });
|
|
await prisma.tenantSettings.create({ data: { tenantId: tB.id, orgName: "Betrieb B" } });
|
|
|
|
const tech = await mkUser(tA.id, "tech", "Max Monteur");
|
|
const tech2 = await mkUser(tA.id, "tech2", "Fremd Monteur");
|
|
const lead = await mkUser(tA.id, "lead", "Tina Teamleiter");
|
|
const lead2 = await mkUser(tA.id, "lead2", "Andere Teamleiterin");
|
|
const office = await mkUser(tA.id, "office", "Bernd Büro");
|
|
const officeB = await mkUser(tB.id, "officeb", "Zoe Büro B");
|
|
|
|
const techCtx = ctxOf(tA.id, tech.id, "technician");
|
|
const tech2Ctx = ctxOf(tA.id, tech2.id, "technician");
|
|
const leadCtx = ctxOf(tA.id, lead.id, "team-lead");
|
|
const lead2Ctx = ctxOf(tA.id, lead2.id, "team-lead");
|
|
const officeCtx = ctxOf(tA.id, office.id, "backoffice");
|
|
const officeBCtx = ctxOf(tB.id, officeB.id, "backoffice");
|
|
|
|
const team = await prisma.team.create({ data: { tenantId: tA.id, name: "Team Nord", leaderUserId: lead.id } });
|
|
await prisma.teamMember.create({ data: { tenantId: tA.id, teamId: team.id, userId: tech.id, validFrom: new Date("2026-01-01") } });
|
|
const customer = await prisma.customer.create({ data: { tenantId: tA.id, companyName: "Kunde GmbH", customerNumber: "K-00001", street: "Hauptstr.", houseNumber: "5", postalCode: "20095", city: "Hamburg" } });
|
|
const contact = await prisma.contact.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Frau Kontakt", role: "Hausverwaltung" } });
|
|
const site = await prisma.site.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Wohnanlage Süd", city: "Hamburg" } });
|
|
const wo = await prisma.workOrder.create({
|
|
data: {
|
|
tenantId: tA.id,
|
|
number: "A-09001",
|
|
customerId: customer.id,
|
|
siteId: site.id,
|
|
contactId: contact.id,
|
|
title: "Elektroinstallation Keller",
|
|
description: "Leitungen und Dosen setzen",
|
|
status: "in_progress",
|
|
assignedTeamId: team.id,
|
|
signatureRequired: true,
|
|
assignees: { create: [{ tenantId: tA.id, userId: tech.id }] },
|
|
},
|
|
});
|
|
const item = await prisma.checklistItem.create({ data: { tenantId: tA.id, workOrderId: wo.id, key: "fi", label: "FI-Schutzschalter geprüft", required: true } });
|
|
const req = await prisma.photoRequirement.create({ data: { tenantId: tA.id, workOrderId: wo.id, key: "typenschild", label: "Typenschild" } });
|
|
const pKabel = await prisma.materialPlan.create({ data: { tenantId: tA.id, workOrderId: wo.id, name: "NYM-J 3x1,5", plannedQuantity: new Prisma.Decimal(10), unit: "m" } });
|
|
const pDose = await prisma.materialPlan.create({ data: { tenantId: tA.id, workOrderId: wo.id, name: "Abzweigdose", plannedQuantity: new Prisma.Decimal(2), unit: "Stk", sortOrder: 1 } });
|
|
const pUnused = await prisma.materialPlan.create({ data: { tenantId: tA.id, workOrderId: wo.id, name: "Kabelbinder", plannedQuantity: new Prisma.Decimal(5), unit: "Stk", sortOrder: 2 } });
|
|
|
|
const session = await prisma.workSession.create({ data: { tenantId: tA.id, workOrderId: wo.id, userId: tech.id, teamId: team.id, status: "ended", startedAt: at(DAY1, "08:00"), endedAt: at(DAY2, "09:00") } });
|
|
await prisma.timeEntry.createMany({
|
|
data: [
|
|
{ tenantId: tA.id, workSessionId: session.id, userId: tech.id, type: "work", startedAt: at(DAY1, "08:00"), endedAt: at(DAY1, "12:00") },
|
|
{ tenantId: tA.id, workSessionId: session.id, userId: tech.id, type: "break", startedAt: at(DAY1, "12:00"), endedAt: at(DAY1, "12:30") },
|
|
{ tenantId: tA.id, workSessionId: session.id, userId: tech.id, type: "work", startedAt: at(DAY2, "07:00"), endedAt: at(DAY2, "09:00") },
|
|
],
|
|
});
|
|
await prisma.activityNote.createMany({
|
|
data: [
|
|
{ tenantId: tA.id, workOrderId: wo.id, authorId: tech.id, kind: "work_done", text: "Tag1 Leitungen verlegt", createdAt: at(DAY1, "11:00") },
|
|
{ tenantId: tA.id, workOrderId: wo.id, authorId: tech.id, kind: "work_done", text: "Tag2 Dosen gesetzt", createdAt: at(DAY2, "08:30") },
|
|
{ tenantId: tA.id, workOrderId: wo.id, authorId: tech.id, kind: "deviation", text: "Tag2 Dose falsche Größe", createdAt: at(DAY2, "08:40") },
|
|
],
|
|
});
|
|
const photoDoc = async (name: string) =>
|
|
storeFile(techCtx, { bytes: PNG_1PX, fileName: name, declaredMime: "image/png", category: "photo", visibility: "team", links: { workOrderId: wo.id } });
|
|
const d1 = await photoDoc("tag1.png");
|
|
const d2 = await photoDoc("tag2.png");
|
|
const d3 = await photoDoc("tag2-intern.png");
|
|
await prisma.photo.createMany({
|
|
data: [
|
|
{ tenantId: tA.id, workOrderId: wo.id, documentId: d1.id, phase: "before", takenAt: at(DAY1, "08:05"), includeInReport: true },
|
|
{ tenantId: tA.id, workOrderId: wo.id, documentId: d2.id, phase: "after", comment: "Fertig", takenAt: at(DAY2, "08:55"), includeInReport: true },
|
|
{ tenantId: tA.id, workOrderId: wo.id, documentId: d3.id, phase: "during", takenAt: at(DAY2, "08:10"), includeInReport: false },
|
|
],
|
|
});
|
|
await prisma.materialUsage.createMany({
|
|
data: [
|
|
{ tenantId: tA.id, workOrderId: wo.id, materialPlanId: pKabel.id, name: pKabel.name, actualQuantity: new Prisma.Decimal(12), unit: "m", usageStatus: "fully_used", deviationReason: "Umweg über Schacht", createdAt: at(DAY1, "11:30") },
|
|
{ tenantId: tA.id, workOrderId: wo.id, materialPlanId: pDose.id, name: pDose.name, actualQuantity: new Prisma.Decimal(0), unit: "Stk", usageStatus: "not_used", deviationReason: "Falsche Größe geliefert", createdAt: at(DAY2, "08:45") },
|
|
{ tenantId: tA.id, workOrderId: wo.id, name: "Muffe", actualQuantity: new Prisma.Decimal(3), unit: "Stk", usageStatus: "additional", deviationReason: "Zusätzlich benötigt", createdAt: at(DAY2, "08:50") },
|
|
],
|
|
});
|
|
|
|
// ---------- content builder ----------
|
|
console.log("\n— Content-Builder —");
|
|
const c1 = await buildReportContent(techCtx, { workOrderId: wo.id, type: "daily", reportDate: DAY1, reportNumber: "B-T", version: 1, technicianUserId: tech.id });
|
|
ok(c1.time.totalsByType.work === 240 && c1.time.totalsByType.break === 30 && c1.time.totalMinutes === 240, "Tagesbericht Tag 1: nur Zeiten des Tages, Pause nicht in Summe");
|
|
ok(c1.texts.workPerformed.includes("Tag1") && !c1.texts.workPerformed.includes("Tag2"), "Tagesbericht Tag 1: nur Notizen des Tages");
|
|
ok(c1.photos.length === 1 && c1.photos[0].documentId === d1.id, "Tagesbericht Tag 1: nur Fotos des Tages");
|
|
ok(c1.materials.used.length === 1 && c1.materials.used[0].deviation && c1.materials.used[0].deviationReason === "Umweg über Schacht", "Materialabweichung (Menge) mit Grund enthalten");
|
|
ok(c1.materials.notUsed.length === 0 && c1.materials.additional.length === 0, "Tagesbericht Tag 1: keine Material-Einträge anderer Tage");
|
|
ok(c1.customer.name === "Kunde GmbH" && c1.site?.name === "Wohnanlage Süd" && c1.contact?.name === "Frau Kontakt" && c1.tenant.name === "Musterbetrieb A GmbH", "Kopfdaten Mandant/Kunde/Objekt/Ansprechpartner");
|
|
|
|
const c2 = await buildReportContent(techCtx, { workOrderId: wo.id, type: "daily", reportDate: DAY2, reportNumber: "B-T", version: 1, technicianUserId: tech.id });
|
|
ok(c2.photos.length === 1 && c2.photos[0].documentId === d2.id, "Tag 2: Foto ohne includeInReport ausgeschlossen");
|
|
ok(c2.materials.notUsed[0]?.deviationReason === "Falsche Größe geliefert" && c2.materials.additional[0]?.name === "Muffe", "Tag 2: nicht verwendet + zusätzlich mit Gründen");
|
|
ok(c2.texts.deviations.includes("falsche Größe"), "Tag 2: Abweichungsnotiz vorbelegt");
|
|
|
|
const cc = await buildReportContent(techCtx, { workOrderId: wo.id, type: "completion", reportDate: DAY2, reportNumber: "B-T", version: 1, technicianUserId: tech.id });
|
|
ok(cc.time.totalMinutes === 360 && cc.workDates.join(",") === `${DAY1},${DAY2}`, "Abschluss: alle Zeiten + Einsatzdaten");
|
|
ok(cc.materials.notUsed.some((m) => m.planId === pUnused.id && !m.documented), "Abschluss: geplantes, nicht dokumentiertes Material als Abweichung");
|
|
ok(cc.photos.length === 2, "Abschluss: alle Berichtsfotos");
|
|
|
|
// ---------- scope & tenant isolation ----------
|
|
console.log("\n— Rollen/Scope & Mandantentrennung —");
|
|
await expectCode(() => createDailyReport(tech2Ctx, { workOrderId: wo.id, reportDate: DAY2 }), "not_found", "Monteur ohne Zuweisung: Tagesbericht anlegen → not_found");
|
|
await expectCode(() => buildReportContent(tech2Ctx, { workOrderId: wo.id, type: "daily", reportDate: DAY2, reportNumber: "x", version: 1, technicianUserId: null }), "not_found", "Monteur ohne Zuweisung: Content lesen → not_found");
|
|
await expectCode(() => createDailyReport(officeBCtx, { workOrderId: wo.id, reportDate: DAY2 }), "not_found", "Mandant B: Tagesbericht am Auftrag von A → not_found");
|
|
|
|
// ---------- daily report ----------
|
|
console.log("\n— Tagesbericht —");
|
|
const daily = await createDailyReport(techCtx, { workOrderId: wo.id, reportDate: DAY2 });
|
|
ok(daily.created && daily.report.status === "draft", "Tagesbericht-Entwurf angelegt");
|
|
const woAfterDaily = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } });
|
|
ok(woAfterDaily.status === "daily_report_created", `Auftrag → daily_report_created (${woAfterDaily.status})`);
|
|
const dailyAgain = await createDailyReport(techCtx, { workOrderId: wo.id, reportDate: DAY2 });
|
|
ok(!dailyAgain.created && dailyAgain.report.id === daily.report.id, "Tagesbericht idempotent je Tag");
|
|
ok(/^B-\d+$/.test((daily.report.content as { reportNumber: string }).reportNumber), "Berichtsnummer aus Nummernkreis (B-)");
|
|
|
|
await expectCode(() => requireVisibleReport(tech2Ctx, daily.report.id), "not_found", "Monteur ohne Zuweisung: Bericht lesen → not_found");
|
|
await expectCode(() => requireVisibleReport(officeBCtx, daily.report.id), "not_found", "Mandant B: Bericht von A lesen → not_found");
|
|
await expectCode(() => updateReportTexts(officeBCtx, { reportId: daily.report.id, texts: { hints: "B war hier" } }), "not_found", "Mandant B: Bericht von A ändern → not_found");
|
|
await expectCode(() => approveReport(officeBCtx, { reportId: daily.report.id }, { dispatchPdf: async () => {} }), "not_found", "Mandant B: Bericht von A freigeben → not_found");
|
|
let isoThrow = false;
|
|
try {
|
|
await dbForTenant(tB.id).report.update({ where: { id: daily.report.id }, data: { status: "approved" } });
|
|
} catch {
|
|
isoThrow = true;
|
|
}
|
|
ok(isoThrow, "Mandant B: direkter DB-Update auf Bericht von A wird vom Tenant-Guard abgewiesen");
|
|
const listB = await listReports(officeBCtx, {});
|
|
ok(listB.items.length === 0, "Mandant B: Liste enthält keine Berichte von A");
|
|
const listTech2 = await listReports(tech2Ctx, {});
|
|
ok(listTech2.items.length === 0, "Monteur ohne Zuweisung: Liste leer");
|
|
|
|
// ---------- completion report ----------
|
|
console.log("\n— Abschlussbericht, Blocker, Unterschrift —");
|
|
try {
|
|
await createCompletionReport(techCtx, { workOrderId: wo.id });
|
|
ok(false, "Abschlussbericht mit offenen Pflichtpunkten → blocked erwartet");
|
|
} catch (err) {
|
|
const blockers = (err as ServiceError).details as Array<{ kind: string }>;
|
|
ok(err instanceof ServiceError && err.code === "blocked" && blockers.some((b) => b.kind === "checklist_item") && blockers.some((b) => b.kind === "photo_requirement"), "Abschluss blockiert: Checkliste + Pflichtfoto als Liste");
|
|
}
|
|
await prisma.checklistItem.update({ where: { id: item.id }, data: { checked: true, checkedById: tech.id, checkedAt: new Date() } });
|
|
const d4 = await photoDoc("typenschild.png");
|
|
await prisma.photo.create({ data: { tenantId: tA.id, workOrderId: wo.id, documentId: d4.id, photoRequirementId: req.id, phase: "after", takenAt: at(DAY2, "08:58") } });
|
|
|
|
const completion = (await createCompletionReport(techCtx, { workOrderId: wo.id })).report;
|
|
ok(completion.status === "draft" && completion.type === "completion", "Abschlussbericht-Entwurf angelegt");
|
|
|
|
await updateReportTexts(techCtx, { reportId: completion.id, texts: { workPerformed: "" } });
|
|
await expectCode(() => submitReport(techCtx, { reportId: completion.id }), "blocked", "Absenden ohne Pflichtangabe (Ausgeführte Leistungen) → blocked");
|
|
await updateReportTexts(techCtx, { reportId: completion.id, texts: { workPerformed: "Leitungen verlegt, Dosen gesetzt.", nextSteps: "Abnahme" } });
|
|
|
|
const confirmationText = "Bestätigt.";
|
|
await expectCode(() => captureSignature(techCtx, { reportId: completion.id, outcome: "signed", imageDocumentId: d4.id, confirmationText }), "invalid", "signed ohne Name → invalid");
|
|
await expectCode(() => captureSignature(techCtx, { reportId: completion.id, outcome: "signed", signerName: "Herr Kunde", confirmationText }), "invalid", "signed ohne Bild → invalid");
|
|
await expectCode(() => captureSignature(techCtx, { reportId: completion.id, outcome: "signed", signerName: "Herr Kunde", imageDocumentId: d4.id, confirmationText }), "invalid", "signed mit Nicht-Unterschrift-Dokument → invalid");
|
|
await expectCode(() => captureSignature(techCtx, { reportId: completion.id, outcome: "refused", confirmationText }), "invalid", "refused ohne Begründung → invalid");
|
|
await expectCode(() => captureSignature(techCtx, { reportId: completion.id, outcome: "customer_absent", reason: " ", confirmationText }), "invalid", "customer_absent mit leerer Begründung → invalid");
|
|
await expectCode(() => captureSignature(techCtx, { reportId: completion.id, outcome: "later", confirmationText }), "invalid", "later ohne Begründung → invalid");
|
|
await expectCode(() => captureSignature(techCtx, { reportId: completion.id, outcome: "not_required", confirmationText }), "forbidden", "not_required bei Pflicht-Unterschrift durch Monteur → forbidden");
|
|
await expectCode(() => captureSignature(tech2Ctx, { reportId: completion.id, outcome: "later", reason: "x", confirmationText }), "not_found", "Monteur ohne Zuweisung: Unterschrift → not_found");
|
|
await captureSignature(techCtx, { reportId: completion.id, outcome: "later", reason: "Kunde erst morgen vor Ort", confirmationText });
|
|
ok((await prisma.signature.count({ where: { reportId: completion.id, outcome: "later" } })) === 1, "later mit Begründung gespeichert");
|
|
|
|
const submitted = await submitReport(techCtx, { reportId: completion.id });
|
|
const woPending = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } });
|
|
ok(submitted.status === "submitted", "Bericht → submitted");
|
|
ok(woPending.status === "signature_pending", `Auftrag ohne Unterschrift → signature_pending (${woPending.status})`);
|
|
const history = await prisma.workOrderStatusChange.findMany({ where: { workOrderId: wo.id }, orderBy: { createdAt: "asc" }, select: { toStatus: true } });
|
|
ok(history.map((h) => h.toStatus).join(">").endsWith("in_progress>technically_completed>signature_pending"), "Statusfolge über transitionWorkOrder protokolliert");
|
|
await expectCode(() => updateReportTexts(techCtx, { reportId: completion.id, texts: { hints: "nachträglich" } }), "conflict", "Bearbeiten nach Absenden → conflict");
|
|
|
|
const sigDoc = await storeFile(techCtx, { bytes: PNG_1PX, fileName: "sig.png", declaredMime: "image/png", category: "signature", visibility: "customer_report", links: { workOrderId: wo.id } });
|
|
await captureSignature(techCtx, { reportId: completion.id, outcome: "signed", signerName: "Herr Kunde", signerRole: "Eigentümer", imageDocumentId: sigDoc.id, confirmationText });
|
|
const woSigned = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } });
|
|
ok(woSigned.status === "in_review", `Unterschrift nachgereicht → Auftrag in_review (${woSigned.status})`);
|
|
await expectCode(() => captureSignature(techCtx, { reportId: completion.id, outcome: "refused", reason: "x", confirmationText }), "conflict", "Erfasste Unterschrift wird nicht überschrieben");
|
|
|
|
// ---------- review ----------
|
|
console.log("\n— Prüfung: Teamleiter, Zurückweisen, Freigabe —");
|
|
const dispatched: string[] = [];
|
|
const deps: ApproveDeps = { dispatchPdf: async (_c, id) => void dispatched.push(id) };
|
|
await expectCode(() => approveReport(techCtx, { reportId: completion.id }, deps), "forbidden", "Monteur darf nicht freigeben");
|
|
await expectCode(() => approveReport(lead2Ctx, { reportId: completion.id }, deps), "not_found", "Teamleiter eines anderen Teams → not_found");
|
|
const teamApproved = await approveReport(leadCtx, { reportId: completion.id }, deps);
|
|
ok(teamApproved.status === "team_approved" && teamApproved.teamApprovedById === lead.id, "Teamleiter → team_approved");
|
|
ok(dispatched.length === 0, "Teamleiter-Prüfung erzeugt kein PDF");
|
|
|
|
await expectCode(() => rejectReport(officeCtx, { reportId: completion.id, reason: "" }), "invalid", "Zurückweisen ohne Grund → invalid");
|
|
const rejected = await rejectReport(officeCtx, { reportId: completion.id, reason: "Materialliste unvollständig" });
|
|
const woRejected = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } });
|
|
ok(rejected.status === "rejected" && rejected.rejectionReason === "Materialliste unvollständig", "Backoffice → rejected mit Grund");
|
|
ok(woRejected.status === "in_progress", `Auftrag → in_progress (Korrektur) (${woRejected.status})`);
|
|
|
|
await updateReportTexts(techCtx, { reportId: completion.id, texts: { hints: "Materialliste ergänzt" } });
|
|
await submitReport(techCtx, { reportId: completion.id });
|
|
const woResubmitted = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } });
|
|
ok(woResubmitted.status === "in_review", `Erneut abgesendet mit Unterschrift → in_review (${woResubmitted.status})`);
|
|
|
|
const approved = await approveReport(officeCtx, { reportId: completion.id }, deps);
|
|
ok(approved.status === "approved" && approved.approvedById === office.id, "Backoffice → approved");
|
|
ok(dispatched.includes(completion.id), "Freigabe stößt PDF-Job an");
|
|
const frozen = JSON.stringify(approved.content);
|
|
const approvedAt = approved.approvedAt?.getTime();
|
|
ok((approved.content as { signature: { outcome: string } }).signature.outcome === "signed", "Freigegebener Inhalt enthält Unterschrift");
|
|
await expectCode(() => updateReportTexts(officeCtx, { reportId: completion.id, texts: { hints: "überschreiben" } }), "conflict", "Freigegebener Bericht nicht editierbar");
|
|
await expectCode(() => submitReport(techCtx, { reportId: completion.id }), "conflict", "Freigegebener Bericht nicht erneut absendbar");
|
|
await expectCode(() => captureSignature(officeCtx, { reportId: completion.id, outcome: "not_required", confirmationText }), "conflict", "Freigegebener Bericht: keine Unterschriftsänderung");
|
|
|
|
// ---------- versioning ----------
|
|
console.log("\n— Versionierung —");
|
|
await expectCode(() => createNewVersion(techCtx, { reportId: completion.id }), "forbidden", "Monteur darf keine neue Version anlegen");
|
|
const v2 = await createNewVersion(officeCtx, { reportId: completion.id });
|
|
ok(v2.version === 2 && v2.lineageId === completion.lineageId && v2.status === "draft", "Neue Version: gleiche Lineage, Version 2, Entwurf");
|
|
await expectCode(() => createNewVersion(officeCtx, { reportId: completion.id }), "conflict", "Zweite neue Version aus alter Version → conflict");
|
|
const v1AfterNew = await prisma.report.findUniqueOrThrow({ where: { id: completion.id } });
|
|
ok(v1AfterNew.status === "approved" && JSON.stringify(v1AfterNew.content) === frozen, "Version 1 bleibt unverändert freigegeben");
|
|
await updateReportTexts(officeCtx, { reportId: v2.id, texts: { hints: "Korrektur nach Freigabe" } });
|
|
await submitReport(officeCtx, { reportId: v2.id });
|
|
const v2Approved = await approveReport(officeCtx, { reportId: v2.id }, deps);
|
|
const v1Final = await prisma.report.findUniqueOrThrow({ where: { id: completion.id } });
|
|
ok(v2Approved.status === "approved", "Version 2 freigegeben");
|
|
ok(v1Final.status === "superseded", "Version 1 → superseded");
|
|
ok(JSON.stringify(v1Final.content) === frozen && v1Final.approvedAt?.getTime() === approvedAt, "Version 1 Inhalt/Freigabezeit nie überschrieben");
|
|
ok((v2Approved.content as { texts: { hints: string } }).texts.hints === "Korrektur nach Freigabe", "Version 2 enthält die Korrektur");
|
|
ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } })).status === "in_review", "Neue Version ändert den Auftragsstatus nicht");
|
|
|
|
// ---------- audit ----------
|
|
const audits = await prisma.auditLog.findMany({ where: { tenantId: tA.id, entity: { in: ["report", "signature"] } } });
|
|
ok(audits.length >= 10 && audits.some((a) => a.before !== null && a.after !== null), `Audit-Einträge mit before/after (${audits.length})`);
|
|
const officeList = await listReports(officeCtx, {});
|
|
ok(officeList.items.every((i) => i.status !== "superseded") && officeList.items.some((i) => i.id === v2.id), "Liste: ersetzte Versionen standardmäßig ausgeblendet");
|
|
}
|
|
|
|
main()
|
|
.catch((err) => {
|
|
console.error(err);
|
|
failures++;
|
|
})
|
|
.finally(async () => {
|
|
await cleanup().catch((e) => console.error("cleanup failed", e));
|
|
await prisma.$disconnect();
|
|
console.log(failures ? `\n✗ ${failures} Fehler` : "\n✓ Alle Berichte-Flow-Tests grün");
|
|
process.exit(failures ? 1 : 0);
|
|
});
|