146 lines
11 KiB
TypeScript
146 lines
11 KiB
TypeScript
// 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");
|
|
});
|