// L10a E2E §43.3 „Mehrtägiger Auftrag mit Tagesbericht" — zwei Einsatztage, zwei Tagesberichte, Abschluss. // // Tag 1 (vorgestern) und Tag 2 (gestern) werden mit echten Zeitstempeln erfasst (Session-Segmente, // Fotos mit Aufnahmezeit; Notizen/Material werden als „am Einsatztag erfasst" zurückdatiert, weil // die Services den Erfassungszeitpunkt selbst setzen). Nachweise: Tagesbericht enthält nur die Daten // seines Tages, Auftrag bleibt offen (daily_report_created → in_progress am nächsten Tag), ein // Tagesbericht je Tag, Abschlussbericht fasst beide Tage zusammen, Freigabe + Abrechnung, // Mandantentrennung und Scope. // // Lauf: npx tsx scripts/test-e2e-multiday-order.ts import "dotenv/config"; import { randomUUID } from "node:crypto"; import { prisma } from "../src/server/db"; import { createWorkOrder } from "../src/server/services/work-orders/create"; import { assignWorkOrder } from "../src/server/services/work-orders/assign"; import { releaseForBilling } from "../src/server/services/work-orders/release-billing"; import { endSession, startSession } from "../src/server/services/field/sessions"; import { createNote } from "../src/server/services/field/notes"; import { upsertMaterialUsage } from "../src/server/services/field/materials"; import { toggleChecklistItem } from "../src/server/services/field/checklist"; import { storeFieldUpload } from "../src/server/services/field/uploads"; import { attachPhoto } from "../src/server/services/field/photos"; import { storeFile } from "../src/server/services/documents/store"; import { createCompletionReport, 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 { captureSignature } from "../src/server/services/reports/signature"; import { contentOf } from "../src/server/services/reports/common"; import { listReports } from "../src/server/services/reports/queries"; import { getSiteHistory } from "../src/server/services/sites/history"; import type { ServiceCtx } from "../src/server/services/context"; import { checklistTogglePayload, materialUpsertPayload, noteCreatePayload, photoAttachPayload, sessionControlPayload, sessionStartPayload } from "../src/lib/sync/ops"; import { berlinAt, createTenant, dayKey, expectCode, jpegBytes, ok, pngBytes, runSuite, section } from "./lib/e2e-fixture"; const SLUG_A = "zz-q-e2e-multi-a"; const SLUG_B = "zz-q-e2e-multi-b"; const noPdf = { dispatchPdf: async () => undefined }; runSuite("E2E mehrtägiger Auftrag", [SLUG_A, SLUG_B], async () => { const A = await createTenant(SLUG_A); const B = await createTenant(SLUG_B); const bo = A.ctx.backoffice; const DAY1 = dayKey(-2); const DAY2 = dayKey(-1); const jpeg = await jpegBytes("Baustelle"); const wo = await createWorkOrder(bo, { title: "Heizungsverteilung erneuern (2 Tage)", customerId: A.customerId, siteId: A.siteId, plannedStart: berlinAt(-2, 7), plannedEnd: berlinAt(-1, 16), signatureRequired: true, applyTemplate: false, checklistItems: [{ label: "Druckprobe durchgeführt", required: true }], materials: [{ name: "Kupferrohr 22 mm", articleNumber: "CU-22", plannedQuantity: 20, unit: "m" }], }); await assignWorkOrder(bo, { workOrderId: wo.id, teamId: A.teamId, userIds: [A.users.tech.id, A.users.tech2.id] }); const plan = await prisma.materialPlan.findFirstOrThrow({ where: { workOrderId: wo.id } }); /** Note/material recorded on a past field day (the services stamp "now"; the device was on site then). */ async function noteOn(ctx: ServiceCtx, when: Date, text: string) { const { noteId } = await createNote(ctx, noteCreatePayload.parse({ workOrderId: wo.id, kind: "work_done", text })); await prisma.activityNote.update({ where: { id: noteId }, data: { createdAt: when } }); } async function photoOn(ctx: ServiceCtx, when: Date, comment: string) { const up = await storeFieldUpload(ctx, { clientId: randomUUID(), workOrderId: wo.id, kind: "photo" }, { bytes: jpeg, name: "foto.jpg", type: "image/jpeg" }); await attachPhoto(ctx, photoAttachPayload.parse({ workOrderId: wo.id, documentId: up.documentId, phase: "during", comment, takenAt: when.toISOString() })); } // ---------- Tag 1 ---------- section(`Tag 1 (${DAY1}): Einsatz und Tagesbericht`); const tech = A.ctx.tech; await startSession(tech, sessionStartPayload.parse({ workOrderId: wo.id, mode: "work", at: berlinAt(-2, 7).toISOString() })); await noteOn(tech, berlinAt(-2, 11), "Tag 1: alte Verteilung demontiert"); const u1 = await upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: wo.id, materialPlanId: plan.id, quantity: 8, unit: "m", usageStatus: "partially_used", deviationReason: "Rest folgt an Tag 2" })); await prisma.materialUsage.update({ where: { id: u1.usageId }, data: { createdAt: berlinAt(-2, 12) } }); await photoOn(tech, berlinAt(-2, 13), "Tag 1 Konsolen"); await endSession(tech, sessionControlPayload.parse({ workOrderId: wo.id, at: berlinAt(-2, 15).toISOString() })); await expectCode(() => createDailyReport(B.ctx.tech, { workOrderId: wo.id, reportDate: DAY1 }), "not_found", "Mandant B: Tagesbericht am Auftrag von A → not_found"); await expectCode(() => createDailyReport(A.ctx.outsider, { workOrderId: wo.id, reportDate: DAY1 }), "not_found", "Monteur ohne Zuweisung: Tagesbericht → not_found"); const d1 = (await createDailyReport(tech, { workOrderId: wo.id, reportDate: DAY1 })).report; const c1 = contentOf(d1); ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } })).status === "daily_report_created", "Auftrag → Tagesbericht erstellt (bleibt offen)"); ok(c1.time.totalMinutes === 480, `Tagesbericht 1: nur Arbeitszeit von Tag 1 (${c1.time.totalMinutes} min)`); ok(c1.photos.length === 1 && c1.texts.workPerformed.includes("Tag 1") && !c1.texts.workPerformed.includes("Tag 2"), "Tagesbericht 1: Fotos und Tätigkeiten nur von Tag 1"); ok(c1.materials.used.length === 1 && c1.materials.used[0].deviationReason === "Rest folgt an Tag 2", "Tagesbericht 1: Material von Tag 1 mit Abweichungsgrund"); await submitReport(tech, { reportId: d1.id }); await approveReport(A.ctx.lead, { reportId: d1.id }, noPdf); const d1Approved = await approveReport(bo, { reportId: d1.id }, noPdf); ok(d1Approved.status === "approved", "Tagesbericht 1 eingereicht, vom Teamleiter geprüft und vom Backoffice freigegeben"); await expectCode(() => createDailyReport(tech, { workOrderId: wo.id, reportDate: DAY1 }), "conflict", "zweiter Tagesbericht für denselben Tag → conflict"); // ---------- Tag 2 ---------- section(`Tag 2 (${DAY2}): zweiter Monteur, zweiter Tagesbericht`); const tech2 = A.ctx.tech2; const s2 = await startSession(tech2, sessionStartPayload.parse({ workOrderId: wo.id, mode: "work", at: berlinAt(-1, 7).toISOString() })); ok(s2.workOrderStatus === "in_progress", "Arbeitsbeginn am Folgetag → Auftrag wieder in Arbeit"); await noteOn(tech2, berlinAt(-1, 10), "Tag 2: Heizkreise angeschlossen, Druckprobe bestanden"); const u2 = await upsertMaterialUsage(tech2, materialUpsertPayload.parse({ workOrderId: wo.id, clientId: randomUUID(), name: "Kugelhahn 22 mm", quantity: 4, unit: "Stk", usageStatus: "additional", deviationReason: "Absperrung je Heizkreis" })); await prisma.materialUsage.update({ where: { id: u2.usageId }, data: { createdAt: berlinAt(-1, 11) } }); await photoOn(tech2, berlinAt(-1, 12), "Tag 2 fertig"); const item = await prisma.checklistItem.findFirstOrThrow({ where: { workOrderId: wo.id } }); await toggleChecklistItem(tech2, checklistTogglePayload.parse({ workOrderId: wo.id, itemId: item.id, checked: true })); await endSession(tech2, sessionControlPayload.parse({ workOrderId: wo.id, at: berlinAt(-1, 14).toISOString() })); const d2 = (await createDailyReport(tech2, { workOrderId: wo.id, reportDate: DAY2 })).report; const c2 = contentOf(d2); ok(c2.time.totalMinutes === 420 && c2.photos.length === 1, `Tagesbericht 2: nur Zeiten/Fotos von Tag 2 (${c2.time.totalMinutes} min)`); ok(c2.texts.workPerformed.includes("Tag 2") && !c2.texts.workPerformed.includes("Tag 1"), "Tagesbericht 2: nur Tätigkeiten von Tag 2"); ok(c2.materials.additional.length === 1 && c2.materials.used.length === 0, "Tagesbericht 2: nur Material von Tag 2"); ok(c2.reportNumber !== c1.reportNumber && d2.lineageId !== d1.lineageId, `eigene Berichtsnummer je Tag (${c1.reportNumber}, ${c2.reportNumber})`); await submitReport(tech2, { reportId: d2.id }); // ---------- Abschluss ---------- section("Abschlussbericht über beide Tage, Freigabe, Abrechnung"); const completion = (await createCompletionReport(tech2, { workOrderId: wo.id, reportDate: DAY2 })).report; const cc = contentOf(completion); ok(cc.workDates.join(",") === `${DAY1},${DAY2}` && cc.time.totalMinutes === 900, `Abschlussbericht: beide Einsatztage, Gesamtzeit ${cc.time.totalMinutes} min`); ok(cc.photos.length === 2 && cc.materials.used.length === 1 && cc.materials.additional.length === 1, "Abschlussbericht: alle Fotos und Materialien"); await updateReportTexts(tech2, { reportId: completion.id, texts: { workPerformed: "Heizungsverteilung in zwei Tagen erneuert, Druckprobe bestanden." } }); const img = await storeFile(tech2, { bytes: await pngBytes(), fileName: "unterschrift.png", declaredMime: "image/png", category: "signature", visibility: "customer_report", links: { workOrderId: wo.id } }); await captureSignature(tech2, { reportId: completion.id, outcome: "signed", signerName: "Frau Prüf", imageDocumentId: img.id, confirmationText: "Bestätigt." }); await submitReport(tech2, { reportId: completion.id }); ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } })).status === "in_review", "Auftrag nach Abschluss „Zur Prüfung“"); for (const r of [d2.id, completion.id]) { await approveReport(A.ctx.lead, { reportId: r }, noPdf); await approveReport(bo, { reportId: r }, noPdf); } const released = await releaseForBilling(bo, { workOrderId: wo.id }); ok(released.status === "released_for_billing", "zur Abrechnung freigegeben"); const reports = await listReports(bo, { status: "all" }); const mine = reports.items.filter((r) => r.workOrder.id === wo.id); ok(mine.filter((r) => r.type === "daily").length === 2 && mine.filter((r) => r.type === "completion").length === 1, "Berichtsliste: 2 Tagesberichte + 1 Abschlussbericht"); ok((await listReports(B.ctx.backoffice, { status: "all" })).items.every((r) => r.workOrder.id !== wo.id), "Mandant B sieht keine Berichte von A"); ok((await listReports(A.ctx.outsider, { status: "all" })).items.every((r) => r.workOrder.id !== wo.id), "Monteur ohne Zuweisung sieht keine Berichte des Auftrags"); const hist = await getSiteHistory(bo, A.siteId); const entry = hist.items.find((i) => i.workOrderId === wo.id); ok(!!entry && entry.approvedReports.length === 3 && entry.workDone.length === 2, "Objekt-Historie: 3 freigegebene Berichte, Tätigkeiten beider Tage"); ok(!!entry && entry.date.getTime() === berlinAt(-2, 7).getTime(), "Objekt-Historie: Einsatzbeginn = erster Einsatztag"); });