- Aufträge: createWorkOrder/transitionWorkOrder/getCompletionBlockers aus L2 - Dokumente: storeFile aus L1; neu services/documents/read.ts (readStoredBytes, readDocumentBytes mit Prüfsummenprüfung); L2-Upload nutzt zentrale Ablage - Dubletten aus L1 (findDuplicateCustomers(ctx)); Objekt-Kandidaten als imports/site-candidates.ts; Dateityp-Erkennung mobil als field/mime.ts - Objekt-Historie mobil als Adapter auf L1 getSiteHistory, PDF über /api/v1/reports/:id/pdf - L4-Upload-Idempotenz: fester Upload-Lineage nach storeFile, Race → Soft-Delete + Replay - PDF-Worker-Kontext: document:write zum Ablegen des Berichts-PDF - Import: doppeltes Work-Order-Audit entfernt; bestätigte Aufträge starten planned - Dateilinks in Auftragsdetail auf /files/[documentId] - proxy: /api/v1 ohne Session → 401 JSON statt Login-Redirect - ARCHITEKTUR §2: Objekt-Historie für Feldrollen (freigegebene Einsätze aller Teams) Gate: tsc, lint, build, 42/42 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
147 lines
13 KiB
TypeScript
147 lines
13 KiB
TypeScript
// Lane L4 „Einsatz mobil" — Service-Tests: Einsatz-Sessions (Zustände, Pause-Segmente, Dauer),
|
|
// Zeitkorrektur (Recht + Grund + Audit), Material (Abweichung ohne Grund → invalid, Zusatzmaterial,
|
|
// Idempotenz), Checkliste/Notizen, Abschluss-Guards, Rollen/Scope und Mandantentrennung.
|
|
//
|
|
// Lauf: npx tsx scripts/test-einsatz-field.ts (lokale Postgres-DB aus .env)
|
|
|
|
import "dotenv/config";
|
|
import { prisma } from "../src/server/db";
|
|
import { closeJobQueues } from "../src/server/jobs/queues";
|
|
import { endSession, pauseSession, resumeSession, startSession } from "../src/server/services/field/sessions";
|
|
import { correctTimeEntry } from "../src/server/services/field/time-correction";
|
|
import { upsertMaterialUsage } from "../src/server/services/field/materials";
|
|
import { toggleChecklistItem } from "../src/server/services/field/checklist";
|
|
import { createNote } from "../src/server/services/field/notes";
|
|
import { getFieldOrderDetail, listFieldOrders, listTodayOrders, getFieldBundle } from "../src/server/services/field/queries";
|
|
import { transitionWorkOrder } from "../src/server/services/work-orders/transition";
|
|
import { createFixture, expectCode, failures, ok } from "./lib/einsatz-fixture";
|
|
|
|
const min = (n: number) => n * 60 * 1000;
|
|
|
|
async function main() {
|
|
const f = await createFixture("l4field");
|
|
const wo = f.orderA.id;
|
|
try {
|
|
console.log("\n— Einsatz-Session: Zustände —");
|
|
const t0 = Date.now() - min(120);
|
|
const at = (m: number) => new Date(t0 + min(m)).toISOString();
|
|
|
|
const travel = await startSession(f.ctxTech, { workOrderId: wo, mode: "travel", at: at(0), offline: true, deviceInfo: "test", latitude: 53.5, longitude: 10 });
|
|
ok(travel.status === "en_route" && travel.workOrderStatus === "en_route", "Losfahren → Session en_route, Auftrag en_route");
|
|
const s0 = await prisma.workSession.findUnique({ where: { id: travel.sessionId } });
|
|
ok(s0?.startedOffline === true && s0.startLat === 53.5 && s0.deviceInfo === "test", "Start speichert Standort, Gerätestatus und Offline-Flag");
|
|
await expectCode(() => startSession(f.ctxTech, { workOrderId: wo, mode: "travel", offline: false }), "conflict", "zweites Losfahren → conflict");
|
|
|
|
const work = await startSession(f.ctxTech, { workOrderId: wo, mode: "work", at: at(10), offline: false });
|
|
ok(work.sessionId === travel.sessionId && work.status === "running" && work.workOrderStatus === "in_progress", "Arbeit starten übernimmt die Anfahrt-Session → running, Auftrag in_progress");
|
|
await expectCode(() => startSession(f.ctxTech, { workOrderId: wo, mode: "work", offline: false }), "conflict", "keine doppelte laufende Session je User+Auftrag");
|
|
const active = await prisma.workSession.count({ where: { workOrderId: wo, userId: f.tech.id, status: { in: ["en_route", "running", "paused"] } } });
|
|
ok(active === 1, "genau eine aktive Session");
|
|
|
|
const paused = await pauseSession(f.ctxTech, { workOrderId: wo, at: at(40) });
|
|
ok(paused.status === "paused" && paused.workOrderStatus === "paused", "Pause → Session paused, Auftrag paused");
|
|
await expectCode(() => pauseSession(f.ctxTech, { workOrderId: wo }), "invalid", "Pause ohne laufende Session → invalid");
|
|
const resumed = await resumeSession(f.ctxTech, { workOrderId: wo, at: at(50) });
|
|
ok(resumed.status === "running" && resumed.workOrderStatus === "in_progress", "Weiter → running, Auftrag in_progress");
|
|
|
|
const entries = await prisma.timeEntry.findMany({ where: { workSessionId: travel.sessionId }, orderBy: { startedAt: "asc" } });
|
|
ok(entries.map((e) => e.type).join(",") === "travel,work,break,work", "Zeitabschnitte als Segmente travel → work → break → work");
|
|
ok(entries.slice(0, 3).every((e) => e.endedAt), "abgeschlossene Segmente haben ein Ende");
|
|
|
|
console.log("\n— Abschluss-Guards —");
|
|
await expectCode(() => transitionWorkOrder(f.ctxTech, { workOrderId: wo, to: "technically_completed" }), "blocked", "Abschluss mit offener Pflicht-Checkliste/Pflichtfoto/laufender Session → blocked");
|
|
|
|
const ended = await endSession(f.ctxTech, { workOrderId: wo, at: at(80) });
|
|
ok(ended.status === "ended", "Ende → Session ended");
|
|
ok(ended.workSeconds === 3600 && ended.breakSeconds === 600 && ended.travelSeconds === 600 && ended.totalSeconds === 4800, `Ende berechnet Dauer (Arbeit ${ended.workSeconds}s, Pause ${ended.breakSeconds}s, Anfahrt ${ended.travelSeconds}s)`);
|
|
await expectCode(() => endSession(f.ctxTech, { workOrderId: wo }), "invalid", "Ende ohne aktive Session → invalid");
|
|
|
|
console.log("\n— Zeitkorrektur —");
|
|
const entry = entries[1];
|
|
await expectCode(
|
|
() => correctTimeEntry(f.ctxTech, { timeEntryId: entry.id, startedAt: at(12), endedAt: at(40), reason: "vergessen" }),
|
|
"forbidden",
|
|
"Monteur ohne field:correct_time → forbidden",
|
|
);
|
|
await expectCode(() => correctTimeEntry(f.ctxLead, { timeEntryId: entry.id, startedAt: at(12), endedAt: at(40), reason: "" }), "invalid", "Korrektur ohne Grund → invalid");
|
|
await expectCode(() => correctTimeEntry(f.ctxLead, { timeEntryId: entry.id, startedAt: at(40), endedAt: at(12), reason: "Ende vor Start" }), "invalid", "Ende vor Beginn → invalid");
|
|
const corrected = await correctTimeEntry(f.ctxLead, { timeEntryId: entry.id, startedAt: at(12), endedAt: at(40), reason: "Start zu früh gestempelt" });
|
|
ok(corrected.corrected && corrected.correctionReason === "Start zu früh gestempelt" && corrected.correctedById === f.lead.id, "Teamleiter korrigiert mit Grund → corrected-Flag + Grund + Bearbeiter");
|
|
const auditRow = await prisma.auditLog.findFirst({ where: { tenantId: f.tenantA.id, entity: "time_entry", entityId: entry.id }, orderBy: { createdAt: "desc" } });
|
|
const before = auditRow?.before as { startedAt?: string } | null;
|
|
const after = auditRow?.after as { startedAt?: string; correctionReason?: string } | null;
|
|
ok(!!auditRow && new Date(before!.startedAt!).getTime() === t0 + min(10) && new Date(after!.startedAt!).getTime() === t0 + min(12) && after?.correctionReason === "Start zu früh gestempelt", "Audit mit before/after der Korrektur");
|
|
await expectCode(() => correctTimeEntry(f.ctxB as never, { timeEntryId: entry.id, startedAt: at(12), reason: "fremd" }), "forbidden", "Mandant B (Monteur) kann nicht korrigieren");
|
|
const leadB = { ...f.ctxB, permissions: new Set([...f.ctxB.permissions, "field:correct_time"]) };
|
|
await expectCode(() => correctTimeEntry(leadB, { timeEntryId: entry.id, startedAt: at(12), reason: "fremder Mandant" }), "not_found", "Mandant B mit Korrekturrecht sieht Zeiteintrag von A nicht → not_found");
|
|
|
|
console.log("\n— Material —");
|
|
const plan = f.orderA.materialPlans[0];
|
|
await expectCode(() => upsertMaterialUsage(f.ctxTech, { workOrderId: wo, materialPlanId: plan.id, quantity: 5, unit: "m", usageStatus: "partially_used" }), "invalid", "teilweise verwendet ohne Begründung → invalid");
|
|
await expectCode(() => upsertMaterialUsage(f.ctxTech, { workOrderId: wo, materialPlanId: plan.id, quantity: 12, unit: "m", usageStatus: "fully_used" }), "invalid", "Mehrmenge ohne Begründung → invalid");
|
|
await expectCode(() => upsertMaterialUsage(f.ctxTech, { workOrderId: wo, materialPlanId: plan.id, quantity: 3, unit: "m", usageStatus: "not_used", deviationReason: "x" }), "invalid", "nicht verwendet mit Menge > 0 → invalid");
|
|
const partial = await upsertMaterialUsage(f.ctxTech, { workOrderId: wo, materialPlanId: plan.id, quantity: 5, unit: "m", usageStatus: "partially_used", deviationReason: "Leitung kürzer" });
|
|
const full = await upsertMaterialUsage(f.ctxTech, { workOrderId: wo, materialPlanId: plan.id, quantity: 10, unit: "m", usageStatus: "fully_used" });
|
|
ok(partial.usageId === full.usageId, "Bestätigung derselben Planposition aktualisiert statt doppelt anzulegen");
|
|
const planUsage = await prisma.materialUsage.findUnique({ where: { id: full.usageId } });
|
|
ok(planUsage?.usageStatus === "fully_used" && Number(planUsage.actualQuantity) === 10 && planUsage.deviationReason === null, "vollständig verwendet ohne Abweichung braucht keine Begründung");
|
|
|
|
await expectCode(() => upsertMaterialUsage(f.ctxTech, { workOrderId: wo, name: "Muffe", quantity: 2, unit: "Stk", usageStatus: "additional" }), "invalid", "Zusatzmaterial ohne Grund → invalid");
|
|
const clientId = "7f1c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f";
|
|
const add1 = await upsertMaterialUsage(f.ctxTech, { workOrderId: wo, clientId, name: "Muffe", quantity: 2, unit: "Stk", usageStatus: "additional", deviationReason: "Übergang fehlte" });
|
|
const add2 = await upsertMaterialUsage(f.ctxTech, { workOrderId: wo, clientId, name: "Muffe", quantity: 3, unit: "Stk", usageStatus: "additional", deviationReason: "Übergang fehlte" });
|
|
ok(add1.usageId === add2.usageId && (await prisma.materialUsage.count({ where: { workOrderId: wo } })) === 2, "Zusatzmaterial idempotent über clientId");
|
|
|
|
console.log("\n— Checkliste & Notizen —");
|
|
const item = f.orderA.checklistItems[0];
|
|
const toggled = await toggleChecklistItem(f.ctxTech, { workOrderId: wo, itemId: item.id, checked: true, comment: "erledigt" });
|
|
const itemRow = await prisma.checklistItem.findUnique({ where: { id: item.id } });
|
|
ok(toggled.checked && itemRow?.checkedById === f.tech.id && itemRow.comment === "erledigt", "Checklistenpunkt abhaken mit Kommentar");
|
|
const noteClient = "1b2c3d4e-5f60-4718-8293-a4b5c6d7e8f9";
|
|
const n1 = await createNote(f.ctxTech, { workOrderId: wo, clientId: noteClient, kind: "problem", text: "Ventil klemmt" });
|
|
const n2 = await createNote(f.ctxTech, { workOrderId: wo, clientId: noteClient, kind: "problem", text: "Ventil klemmt" });
|
|
ok(n1.noteId === n2.noteId && (await prisma.activityNote.count({ where: { workOrderId: wo } })) === 1, "Notiz idempotent über clientId");
|
|
const noteAudit = await prisma.auditLog.count({ where: { tenantId: f.tenantA.id, entity: "activity_note", entityId: n1.noteId } });
|
|
ok(noteAudit === 1, "Notiz erzeugt genau einen Audit-Eintrag");
|
|
|
|
console.log("\n— Rollen/Scope (Monteur ohne Zuweisung) —");
|
|
await expectCode(() => startSession(f.ctxOutsider, { workOrderId: wo, mode: "work", offline: false }), "not_found", "fremder Auftrag: Session starten → not_found");
|
|
await expectCode(() => toggleChecklistItem(f.ctxOutsider, { workOrderId: wo, itemId: item.id, checked: false }), "not_found", "fremder Auftrag: Checkliste → not_found");
|
|
await expectCode(() => createNote(f.ctxOutsider, { workOrderId: wo, kind: "general", text: "x" }), "not_found", "fremder Auftrag: Notiz → not_found");
|
|
await expectCode(() => getFieldOrderDetail(f.ctxOutsider, wo), "not_found", "fremder Auftrag: Detail → not_found");
|
|
ok((await listFieldOrders(f.ctxOutsider, "running")).length === 0 && (await listTodayOrders(f.ctxOutsider)).length === 0, "Monteur ohne Zuweisung sieht keine Aufträge");
|
|
const noExecute = { ...f.ctxTech, permissions: new Set([...f.ctxTech.permissions].filter((p) => p !== "field:execute")) };
|
|
await expectCode(() => createNote(noExecute, { workOrderId: wo, kind: "general", text: "x" }), "forbidden", "ohne field:execute → forbidden");
|
|
|
|
console.log("\n— Sichtbarkeit für Zugewiesene/Teamleiter —");
|
|
const running = await listFieldOrders(f.ctxTech, "running");
|
|
ok(running.some((o) => o.id === wo), "Monteur sieht zugewiesenen Auftrag unter „laufend\"");
|
|
ok((await listTodayOrders(f.ctxTech)).some((o) => o.id === wo), "Auftrag erscheint unter „Heute\"");
|
|
const detail = await getFieldOrderDetail(f.ctxLead, wo);
|
|
ok(detail.card.address === "Hafenstraße 1, 20457 Hamburg" && detail.site?.accessNotes === "Schlüssel beim Pförtner", "Teamleiter sieht Detail inkl. Objektadresse und Zugangshinweis");
|
|
ok(detail.blockers.some((b) => b.kind === "photo_requirement") && !detail.blockers.some((b) => b.kind === "checklist_item"), "Blocker: nur noch Pflichtfoto offen");
|
|
const bundle = await getFieldBundle(f.ctxTech);
|
|
ok(bundle.orders.length === 1 && bundle.orders[0].id === wo && bundle.orders[0].materialPlans.length === 1, "Bundle liefert Auftrag im Scope inkl. Materialvorgabe");
|
|
|
|
console.log("\n— Mandantentrennung (B gegen A) —");
|
|
await expectCode(() => getFieldOrderDetail(f.ctxB, wo), "not_found", "B liest Auftrag von A → not_found");
|
|
await expectCode(() => startSession(f.ctxB, { workOrderId: wo, mode: "work", offline: false }), "not_found", "B startet Session auf Auftrag von A → not_found");
|
|
await expectCode(() => upsertMaterialUsage(f.ctxB, { workOrderId: f.orderB.id, materialPlanId: plan.id, quantity: 1, unit: "m", usageStatus: "fully_used" }), "not_found", "B nutzt Planposition von A → not_found");
|
|
await expectCode(() => toggleChecklistItem(f.ctxB, { workOrderId: f.orderB.id, itemId: item.id, checked: false }), "not_found", "B ändert Checklistenpunkt von A → not_found");
|
|
const itemAfter = await prisma.checklistItem.findUnique({ where: { id: item.id } });
|
|
ok(itemAfter?.checked === true, "Checklistenpunkt von A unverändert");
|
|
ok((await getFieldBundle(f.ctxB)).orders.every((o) => o.id !== wo), "Bundle von B enthält keine Aufträge von A");
|
|
} finally {
|
|
await f.cleanup();
|
|
await closeJobQueues();
|
|
await prisma.$disconnect();
|
|
}
|
|
console.log(`\n${failures === 0 ? "✓ alle Prüfungen grün" : `✗ ${failures} Fehler`}`);
|
|
process.exit(failures ? 1 : 0);
|
|
}
|
|
|
|
main().catch(async (err) => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|