Merge lane/berichte in feature/craftvia-mvp
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
// 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);
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
// L5 Berichte — PDF-Render-Smoke: Freigabe → generateReportPdf → Datei beginnt mit %PDF, Checksumme gespeichert,
|
||||
// PDF unveränderlich (zweiter Lauf überspringt), Download nur im eigenen Mandanten.
|
||||
// Ohne startbaren Chromium/Chrome wird der Render-Teil mit klarer Meldung übersprungen (Exit 0).
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-berichte-pdf.ts
|
||||
|
||||
import "dotenv/config";
|
||||
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 { pdfRendererAvailable } from "../src/server/pdf/render";
|
||||
import { createDailyReport } from "../src/server/services/reports/create";
|
||||
import { updateReportTexts } from "../src/server/services/reports/edit";
|
||||
import { submitReport } from "../src/server/services/reports/submit";
|
||||
import { approveReport } from "../src/server/services/reports/approve";
|
||||
import { generateReportPdf } from "../src/server/services/reports/pdf";
|
||||
import { openReportFile } from "../src/server/services/reports/files";
|
||||
import { readFileBytes, sha256Hex, 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++;
|
||||
};
|
||||
|
||||
const SLUG_A = "zz-berichte-pdf-a";
|
||||
const SLUG_B = "zz-berichte-pdf-b";
|
||||
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.workOrderStatusChange.deleteMany(w);
|
||||
await prisma.workOrderAssignee.deleteMany(w);
|
||||
await prisma.document.deleteMany(w);
|
||||
await prisma.workOrder.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-pdf.test" } } });
|
||||
}
|
||||
|
||||
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();
|
||||
const available = await pdfRendererAvailable();
|
||||
if (!available.ok) {
|
||||
console.log(`⚠ ÜBERSPRUNGEN: kein Chromium startbar (${available.reason}). Abhilfe: PDF_CHROMIUM_PATH setzen oder "npx playwright-core install chromium".`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tA = await prisma.tenant.create({ data: { name: "PDF A", slug: SLUG_A } });
|
||||
const tB = await prisma.tenant.create({ data: { name: "PDF B", slug: SLUG_B } });
|
||||
await prisma.tenantSettings.create({ data: { tenantId: tA.id, orgName: "Musterbetrieb PDF GmbH", address: "Hafenstraße 12, 20457 Hamburg", phone: "+49 40 1", email: "info@pdf.example" } });
|
||||
const mk = async (tenantId: string, key: string) => {
|
||||
const identity = await prisma.identity.create({ data: { email: `${key}@zz-berichte-pdf.test`, passwordHash: "x" } });
|
||||
return prisma.user.create({ data: { tenantId, identityId: identity.id, email: identity.email, name: `Nutzer ${key}` } });
|
||||
};
|
||||
const tech = await mk(tA.id, "tech");
|
||||
const office = await mk(tA.id, "office");
|
||||
const officeB = await mk(tB.id, "officeb");
|
||||
const techCtx = ctxOf(tA.id, tech.id, "technician");
|
||||
const officeCtx = ctxOf(tA.id, office.id, "backoffice");
|
||||
const officeBCtx = ctxOf(tB.id, officeB.id, "backoffice");
|
||||
|
||||
const customer = await prisma.customer.create({ data: { tenantId: tA.id, companyName: "Kundin & Söhne <GmbH>" } });
|
||||
const wo = await prisma.workOrder.create({
|
||||
data: { tenantId: tA.id, number: "A-PDF1", customerId: customer.id, title: "Heizung warten", status: "in_progress", assignees: { create: [{ tenantId: tA.id, userId: tech.id }] } },
|
||||
});
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const doc = await storeFile(techCtx, { bytes: PNG_1PX, fileName: `foto-${i}.png`, declaredMime: "image/png", category: "photo", visibility: "team", links: { workOrderId: wo.id } });
|
||||
await prisma.photo.create({ data: { tenantId: tA.id, workOrderId: wo.id, documentId: doc.id, phase: "after", comment: `Foto ${i}`, takenAt: new Date() } });
|
||||
}
|
||||
|
||||
const { report } = await createDailyReport(techCtx, { workOrderId: wo.id });
|
||||
await updateReportTexts(techCtx, { reportId: report.id, texts: { workPerformed: "Brenner gereinigt.\nDruck geprüft.".repeat(40), hints: "Filter in 6 Monaten tauschen" } });
|
||||
await submitReport(techCtx, { reportId: report.id });
|
||||
const approved = await approveReport(officeCtx, { reportId: report.id }, { dispatchPdf: async () => {} });
|
||||
ok(approved.status === "approved" && !approved.pdfDocumentId, "Bericht freigegeben, PDF noch nicht erzeugt");
|
||||
|
||||
const t0 = Date.now();
|
||||
const res = await generateReportPdf(officeCtx, report.id);
|
||||
ok(!res.skipped, `PDF gerendert (${Date.now() - t0} ms)`);
|
||||
const stored = await prisma.report.findUniqueOrThrow({ where: { id: report.id } });
|
||||
const doc = await prisma.document.findUniqueOrThrow({ where: { id: stored.pdfDocumentId! } });
|
||||
ok(doc.fileSize > 0 && doc.mimeType === "application/pdf", `Dokument gespeichert (${doc.fileSize} Bytes)`);
|
||||
ok(doc.category === "daily_report" && doc.visibility === "customer_report" && doc.workOrderId === wo.id, "Kategorie daily_report, Sichtbarkeit customer_report, am Auftrag");
|
||||
ok(stored.pdfChecksum === doc.checksum && /^[0-9a-f]{64}$/.test(stored.pdfChecksum ?? ""), "SHA-256-Prüfsumme am Bericht gespeichert");
|
||||
const bytes = await readFileBytes(doc.storageKey);
|
||||
if (bytes) {
|
||||
ok(Buffer.from(bytes).subarray(0, 4).toString() === "%PDF", "Datei beginnt mit %PDF");
|
||||
ok(sha256Hex(bytes) === stored.pdfChecksum, "Prüfsumme entspricht den gespeicherten Bytes");
|
||||
} else {
|
||||
console.log("⚠ Storage-Stub ohne Bytes (S3_* nicht gesetzt) — Byte-Prüfung übersprungen");
|
||||
}
|
||||
|
||||
const again = await generateReportPdf(officeCtx, report.id);
|
||||
ok(again.skipped && again.documentId === doc.id, "Zweiter Lauf überschreibt das freigegebene PDF nicht");
|
||||
|
||||
if (bytes) {
|
||||
const file = await openReportFile(officeCtx, report.id, "pdf");
|
||||
ok(file.mimeType === "application/pdf", "Download im eigenen Mandanten möglich");
|
||||
}
|
||||
try {
|
||||
await openReportFile(officeBCtx, report.id, "pdf");
|
||||
ok(false, "Mandant B: PDF von A → not_found erwartet");
|
||||
} catch (err) {
|
||||
ok(err instanceof ServiceError && err.code === "not_found", "Mandant B: PDF von A → not_found");
|
||||
}
|
||||
try {
|
||||
await openReportFile(officeCtx, report.id, doc.id === "x" ? "y" : "unrelated-document-id");
|
||||
ok(false, "Nicht referenziertes Dokument → not_found erwartet");
|
||||
} catch (err) {
|
||||
ok(err instanceof ServiceError && err.code === "not_found", "Nicht referenziertes Dokument über Berichtsroute → not_found");
|
||||
}
|
||||
}
|
||||
|
||||
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✓ PDF-Smoke abgeschlossen");
|
||||
process.exit(failures ? 1 : 0);
|
||||
});
|
||||
Reference in New Issue
Block a user