L8 Notdienst: Tests für Erfassung, Mandantentrennung, Sync und Prüfung
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
// Lane L8 „Notdienst" — Erfassung, Transaktion, Sichtbarkeit, Such-Service, Sync-Op, Abschluss-Event.
|
||||
//
|
||||
// Nachweise: vorläufiger Kunde/Objekt + Auftrag N-… in_progress + WorkSession in EINER Transaktion
|
||||
// (Fehler im Auftrag → kein verwaister Kunde), vorläufiger Kunde für Monteure nur über den eigenen
|
||||
// Notdienstauftrag sichtbar, Such-Service mit minimalem Feldumfang + Audit + Mandantentrennung,
|
||||
// Sync-Op emergency.create idempotent, Events emergency.created / emergency.completed.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-notdienst-flow.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { prisma } from "../src/server/db";
|
||||
import type { SyncOperationInput } from "../src/lib/sync/envelope";
|
||||
import type { EmergencyCreateInput } from "../src/lib/emergency/schemas";
|
||||
import { applyOperations } from "../src/server/services/sync/apply";
|
||||
import { createEmergencyOrder } from "../src/server/services/emergency/create";
|
||||
import { listSitesForEmergency, searchCustomersForEmergency, emergencyTeamOptions } from "../src/server/services/emergency/lookup";
|
||||
import { buildEmergencyCompletedData } from "../src/server/services/emergency/completion";
|
||||
import { createWorkOrder } from "../src/server/services/work-orders/create";
|
||||
import { requireVisibleWorkOrder } from "../src/server/services/work-orders/visibility";
|
||||
import { getCustomer } from "../src/server/services/customers/customers";
|
||||
import { endSession } from "../src/server/services/field/sessions";
|
||||
import { createCompletionReport } 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 { cleanupTenants, createFixture, expectCode, failures, ok, rawWorkOrder } from "./lib-auftraege-fixtures";
|
||||
|
||||
const SLUG_A = "zz-l8-flow-a";
|
||||
const SLUG_B = "zz-l8-flow-b";
|
||||
|
||||
export function emergencyPayload(over: Partial<EmergencyCreateInput> = {}): EmergencyCreateInput {
|
||||
return {
|
||||
clientIds: { workOrder: randomUUID(), session: randomUUID(), customer: randomUUID(), site: randomUUID() },
|
||||
customer: { mode: "new", firstName: "Lutz", lastName: "Meier", phone: "0171 1234567", street: "Elbchaussee", houseNumber: "12", postalCode: "22763", city: "Hamburg" },
|
||||
site: { mode: "new", street: "Elbchaussee", houseNumber: "12", postalCode: "22763", city: "Hamburg" },
|
||||
onSiteContact: { name: "Lutz Meier", phone: "0171 1234567" },
|
||||
reason: "Wasserrohrbruch im Keller",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function syncOp(payload: unknown, clientOpId = randomUUID()): SyncOperationInput {
|
||||
return { clientOpId, opType: "emergency.create", payload: payload as Record<string, unknown>, clientCreatedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await cleanupTenants([SLUG_A, SLUG_B]);
|
||||
const A = await createFixture(SLUG_A);
|
||||
const B = await createFixture(SLUG_B);
|
||||
try {
|
||||
console.log("\n— Erfassung mit vorläufigem Kunden —");
|
||||
const p1 = emergencyPayload();
|
||||
const r1 = await createEmergencyOrder(A.ctx.tech, p1);
|
||||
const wo = await prisma.workOrder.findUniqueOrThrow({
|
||||
where: { id: r1.workOrderId },
|
||||
include: { customer: true, site: true, contact: true, orderType: true, assignees: true, workSessions: { include: { entries: true } }, statusHistory: true },
|
||||
});
|
||||
ok(wo.isEmergency && /^N-\d+$/.test(wo.number), `Auftrag ist Notdienst mit Nummer aus Sequenz emergency (${wo.number})`);
|
||||
ok(wo.status === "in_progress" && wo.statusHistory.some((h) => h.toStatus === "in_progress"), "Status in_progress inkl. Statushistorie");
|
||||
ok(wo.orderType?.key === "notdienst", "Auftragsart notdienst");
|
||||
ok(wo.emergencyReason === "Wasserrohrbruch im Keller" && wo.title === "Wasserrohrbruch im Keller", "Grund als emergencyReason + Titel");
|
||||
ok(wo.createdById === A.users.tech.id, "createdById = Monteur");
|
||||
ok(wo.customer.status === "provisional" && wo.customer.isProvisional && wo.customer.customerNumber === null, "Kunde vorläufig, ohne Kundennummer");
|
||||
ok(wo.site?.status === "provisional" && wo.site.customerId === wo.customerId && wo.site.city === "Hamburg", "Einsatzadresse als vorläufiges Objekt");
|
||||
ok(wo.contact?.name === "Lutz Meier" && wo.contact.phone === "0171 1234567" && wo.site?.contactId === wo.contactId, "Ansprechpartner vor Ort + Telefon");
|
||||
ok(wo.assignedTeamId === A.teamId && wo.teamLeadUserId === A.users.lead.id, "Default: eigenes Team + Teamleiter");
|
||||
ok(wo.assignees.length === 1 && wo.assignees[0].userId === A.users.tech.id, "Default: eigener User zugewiesen");
|
||||
const session = wo.workSessions[0];
|
||||
ok(wo.workSessions.length === 1 && session.status === "running" && session.clientId === p1.clientIds.session, "WorkSession gestartet (clientId)");
|
||||
ok(session.entries.length === 1 && session.entries[0].type === "work" && !session.entries[0].endedAt, "Arbeitszeit-Segment offen");
|
||||
ok(r1.version === wo.version && r1.sessionId === session.id && !r1.replayed, "Ergebnis mit Version und Session-ID");
|
||||
const auditRows = await prisma.auditLog.findMany({ where: { tenantId: A.tenantId, entityId: { in: [r1.workOrderId, r1.customerId, r1.siteId] } }, select: { entity: true, action: true } });
|
||||
ok(["emergency", "customer", "site", "work_order"].every((e) => auditRows.some((a) => a.entity === e && a.action === "create")), "Audit: emergency, customer, site, work_order angelegt");
|
||||
const created = await prisma.notification.count({ where: { tenantId: A.tenantId, type: "emergency.created", userId: A.users.backoffice.id, entityId: r1.workOrderId } });
|
||||
ok(created === 1, "Event emergency.created → Benachrichtigung ans Backoffice");
|
||||
ok((await prisma.notification.count({ where: { tenantId: A.tenantId, type: "emergency.created", userId: A.users.tech.id } })) === 0, "Akteur selbst nicht benachrichtigt");
|
||||
|
||||
console.log("\n— Transaktion —");
|
||||
const failing = emergencyPayload({
|
||||
customer: { mode: "new", lastName: "Rollback", phone: "040 999" },
|
||||
site: { mode: "new", street: "Rollbackweg", city: "Hamburg" },
|
||||
onSiteContact: { name: "Rolf Rollback", phone: "040 999" },
|
||||
reason: "Rollback-Test",
|
||||
});
|
||||
await expectCode(
|
||||
() =>
|
||||
createEmergencyOrder(A.ctx.tech, failing, {
|
||||
createWorkOrder: async (c, i) => {
|
||||
await createWorkOrder(c, i);
|
||||
throw Object.assign(new Error("simulated failure after work order insert"), { code: "invalid" });
|
||||
},
|
||||
}),
|
||||
"invalid",
|
||||
"Fehler beim Auftrag → Fehler an Aufrufer",
|
||||
);
|
||||
ok((await prisma.customer.count({ where: { tenantId: A.tenantId, lastName: "Rollback" } })) === 0, "kein verwaister vorläufiger Kunde");
|
||||
ok((await prisma.site.count({ where: { tenantId: A.tenantId, street: "Rollbackweg" } })) === 0, "kein verwaistes Objekt");
|
||||
ok((await prisma.contact.count({ where: { tenantId: A.tenantId, name: "Rolf Rollback" } })) === 0, "kein verwaister Ansprechpartner");
|
||||
ok((await prisma.workOrder.count({ where: { tenantId: A.tenantId, emergencyReason: "Rollback-Test" } })) === 0, "kein Auftrag");
|
||||
ok((await prisma.workSession.count({ where: { clientId: failing.clientIds.session } })) === 0, "keine WorkSession");
|
||||
const r2 = await createEmergencyOrder(A.ctx.tech, emergencyPayload({ customer: { mode: "new", companyName: "Bäckerei Sonne", phone: "040 1111" } }));
|
||||
ok(Number(r2.number.slice(2)) === Number(r1.number.slice(2)) + 1, `Nummernkreis ohne Lücke nach Rollback (${r1.number} → ${r2.number})`);
|
||||
|
||||
console.log("\n— Sichtbarkeit vorläufiger Kunde —");
|
||||
ok((await getCustomer(A.ctx.tech, r1.customerId)).id === r1.customerId, "Monteur sieht vorläufigen Kunden über eigenen Notdienstauftrag");
|
||||
await expectCode(() => getCustomer(A.ctx.outsider, r1.customerId), "not_found", "anderer Monteur (fremdes Team) → not_found (Kunde)");
|
||||
await expectCode(() => requireVisibleWorkOrder(A.ctx.outsider, r1.workOrderId), "not_found", "anderer Monteur → not_found (Auftrag)");
|
||||
ok((await requireVisibleWorkOrder(A.ctx.lead, r1.workOrderId)).id === r1.workOrderId, "Teamleiter des gewählten Teams sieht den Auftrag");
|
||||
await expectCode(() => getCustomer(B.ctx.admin, r1.customerId), "not_found", "Mandant B → not_found (Kunde)");
|
||||
await expectCode(() => requireVisibleWorkOrder(B.ctx.admin, r1.workOrderId), "not_found", "Mandant B → not_found (Auftrag)");
|
||||
|
||||
console.log("\n— Such-Service —");
|
||||
const hits = await searchCustomersForEmergency(A.ctx.outsider, SLUG_A);
|
||||
ok(hits.some((h) => h.id === A.customerId), "Monteur findet fremden Kunden für den Notdienst");
|
||||
ok(hits.every((h) => JSON.stringify(Object.keys(h).sort()) === JSON.stringify(["city", "customerNumber", "id", "name", "provisional"])), "nur minimale Felder (id, Nummer, Name, Ort, vorläufig)");
|
||||
ok((await searchCustomersForEmergency(A.ctx.outsider, "Meier")).some((h) => h.id === r1.customerId && h.provisional), "vorläufige Kunden sind auffindbar (Dublettenvermeidung)");
|
||||
ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "emergency_customer_search", actorId: A.users.outsider.id } })) >= 2, "Suchzugriffe auditiert");
|
||||
ok((await searchCustomersForEmergency(B.ctx.tech, SLUG_A)).length === 0, "Mandant B findet keine Kunden von A");
|
||||
ok((await searchCustomersForEmergency(A.ctx.tech, "a")).length === 0, "Suchbegriff < 2 Zeichen → keine Treffer");
|
||||
await expectCode(() => searchCustomersForEmergency(A.ctx.backoffice, "Kunde"), "forbidden", "ohne emergency:create → forbidden");
|
||||
const sites = await listSitesForEmergency(A.ctx.outsider, A.customerId);
|
||||
ok(sites.some((s) => s.id === A.siteId) && sites.every((s) => JSON.stringify(Object.keys(s).sort()) === JSON.stringify(["address", "id", "name"])), "Objekte des Kunden mit minimalen Feldern");
|
||||
ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "emergency_site_lookup", entityId: A.customerId } })) === 1, "Objektabfrage auditiert");
|
||||
await expectCode(() => listSitesForEmergency(B.ctx.tech, A.customerId), "not_found", "Mandant B: Objekte von A → not_found");
|
||||
const teamsOfTech = await emergencyTeamOptions(A.ctx.tech);
|
||||
ok(teamsOfTech.length === 1 && teamsOfTech[0].id === A.teamId && teamsOfTech[0].members.some((m) => m.id === A.users.lead.id), "Teamauswahl: nur eigene Teams inkl. Mitglieder");
|
||||
|
||||
console.log("\n— Bestehender Kunde + Objekt, Validierung, Rechte —");
|
||||
const r3 = await createEmergencyOrder(
|
||||
A.ctx.outsider,
|
||||
emergencyPayload({ customer: { mode: "existing", customerId: A.customerId }, site: { mode: "existing", siteId: A.siteId }, onSiteContact: { name: "Frau Kontakt", phone: "040 5555" } }),
|
||||
);
|
||||
const wo3 = await prisma.workOrder.findUniqueOrThrow({ where: { id: r3.workOrderId }, include: { customer: true, contact: true } });
|
||||
ok(wo3.customerId === A.customerId && wo3.siteId === A.siteId && wo3.customer.status === "active", "bestehender Kunde/Objekt bleiben unverändert aktiv");
|
||||
ok(wo3.contact?.customerId === A.customerId && wo3.assignedTeamId === A.otherTeamId, "Kontakt am bestehenden Kunden, eigenes Team des Monteurs");
|
||||
ok((await getCustomer(A.ctx.outsider, A.customerId)).id === A.customerId, "Monteur sieht bestehenden Kunden danach über eigenen Notdienst");
|
||||
const r3b = await createEmergencyOrder(
|
||||
A.ctx.outsider,
|
||||
emergencyPayload({ customer: { mode: "existing", customerId: A.customerId }, site: { mode: "existing", siteId: A.siteId }, onSiteContact: { name: "frau kontakt", phone: "040 5555" } }),
|
||||
);
|
||||
ok(r3b.contactId === r3.contactId, "gleicher Ansprechpartner (Name + Telefon) wird wiederverwendet");
|
||||
await expectCode(
|
||||
() => createEmergencyOrder(A.ctx.tech, emergencyPayload({ customer: { mode: "existing", customerId: A.customerId }, site: { mode: "existing", siteId: r1.siteId } })),
|
||||
"invalid",
|
||||
"Objekt eines anderen Kunden → invalid",
|
||||
);
|
||||
await expectCode(() => createEmergencyOrder(A.ctx.outsider, emergencyPayload({ teamId: A.teamId })), "invalid", "fremdes Team → invalid");
|
||||
await expectCode(() => createEmergencyOrder(A.ctx.outsider, emergencyPayload({ assigneeIds: [A.users.tech.id] })), "invalid", "Kollege aus fremdem Team → invalid");
|
||||
const withColleague = await createEmergencyOrder(A.ctx.tech, emergencyPayload({ assigneeIds: [A.users.lead.id] }));
|
||||
ok((await prisma.workOrderAssignee.count({ where: { workOrderId: withColleague.workOrderId } })) === 2, "Kollege aus eigenem Team zugewiesen");
|
||||
await expectCode(() => createEmergencyOrder(B.ctx.tech, emergencyPayload({ customer: { mode: "existing", customerId: A.customerId } })), "invalid", "Mandant B mit Kunden-ID von A → invalid (nicht gefunden)");
|
||||
ok((await prisma.workOrder.count({ where: { tenantId: B.tenantId, isEmergency: true } })) === 0, "in Mandant B nichts angelegt");
|
||||
await expectCode(() => createEmergencyOrder(A.ctx.backoffice, emergencyPayload()), "forbidden", "Backoffice ohne emergency:create → forbidden");
|
||||
await expectCode(() => createEmergencyOrder(A.ctx.tech, emergencyPayload({ reason: " " })), "invalid", "Grund fehlt → invalid");
|
||||
await expectCode(
|
||||
() => createEmergencyOrder(A.ctx.tech, emergencyPayload({ customer: { mode: "new", lastName: "Ohne Telefon", phone: "" } })),
|
||||
"invalid",
|
||||
"vorläufiger Kunde ohne Telefon → invalid",
|
||||
);
|
||||
await expectCode(() => createEmergencyOrder(A.ctx.tech, emergencyPayload({ onSiteContact: { name: "X", phone: "" } })), "invalid", "Ansprechpartner ohne Telefon → invalid");
|
||||
|
||||
console.log("\n— Sync-Op emergency.create —");
|
||||
const syncPayload = emergencyPayload({ customer: { mode: "new", lastName: "Offline", phone: "040 2222" }, offline: true });
|
||||
const op = syncOp(syncPayload);
|
||||
const s1 = (await applyOperations(A.ctx.tech, { deviceId: "test", operations: [op] })).results[0];
|
||||
const syncWoId = s1.idMap?.[syncPayload.clientIds.workOrder];
|
||||
ok(s1.status === "applied" && !!syncWoId && !!s1.idMap?.[syncPayload.clientIds.session] && !!s1.idMap?.[syncPayload.clientIds.customer!], "applied mit idMap (Auftrag, Session, Kunde)");
|
||||
const s2 = (await applyOperations(A.ctx.tech, { deviceId: "test", operations: [op] })).results[0];
|
||||
const sorted = (m?: Record<string, string>) => JSON.stringify(Object.entries(m ?? {}).sort());
|
||||
ok(s2.status === "duplicate" && sorted(s2.idMap) === sorted(s1.idMap), "gleiche clientOpId → duplicate mit gespeicherter idMap");
|
||||
const s3 = (await applyOperations(A.ctx.tech, { deviceId: "test", operations: [syncOp(syncPayload)] })).results[0];
|
||||
ok(s3.status === "applied" && s3.idMap?.[syncPayload.clientIds.workOrder] === syncWoId, "neue clientOpId, gleiche Client-IDs → derselbe Auftrag (Replay)");
|
||||
ok((await prisma.customer.count({ where: { tenantId: A.tenantId, lastName: "Offline" } })) === 1, "keine Doppelanlage des Kunden");
|
||||
ok((await prisma.workSession.findFirst({ where: { clientId: syncPayload.clientIds.session } }))?.startedOffline === true, "Offline-Flag an der Session");
|
||||
const s4 = (await applyOperations(A.ctx.tech, { deviceId: "test", operations: [syncOp({ ...syncPayload, reason: "" })] })).results[0];
|
||||
ok(s4.status === "rejected" && s4.errorCode === "invalid", "ungültige Payload → rejected invalid");
|
||||
const s5 = (await applyOperations(A.ctx.outsider, { deviceId: "test", operations: [syncOp(syncPayload)] })).results[0];
|
||||
ok(s5.status === "rejected" && s5.errorCode === "invalid", "Replay durch anderen Nutzer → rejected (keine Übernahme)");
|
||||
await prisma.tenantModule.create({ data: { tenantId: A.tenantId, moduleKey: "emergency", enabled: false } });
|
||||
const s6 = (await applyOperations(A.ctx.tech, { deviceId: "test", operations: [syncOp(emergencyPayload())] })).results[0];
|
||||
ok(s6.status === "rejected" && s6.errorCode === "forbidden", "Modul emergency deaktiviert → rejected forbidden");
|
||||
await prisma.tenantModule.deleteMany({ where: { tenantId: A.tenantId, moduleKey: "emergency" } });
|
||||
|
||||
console.log("\n— Abschluss → emergency.completed —");
|
||||
ok((await buildEmergencyCompletedData(A.ctx.tech, (await rawWorkOrder(A, "in_progress")).id)) === null, "normaler Auftrag → kein Notdienst-Event");
|
||||
await endSession(A.ctx.tech, { workOrderId: r1.workOrderId });
|
||||
const report = (await createCompletionReport(A.ctx.tech, { workOrderId: r1.workOrderId })).report;
|
||||
await updateReportTexts(A.ctx.tech, { reportId: report.id, texts: { workPerformed: "Leitung abgedichtet, Wasser abgepumpt." } });
|
||||
await captureSignature(A.ctx.tech, { reportId: report.id, outcome: "customer_absent", reason: "Kunde nachts nicht erreichbar", confirmationText: "Bestätigt" });
|
||||
await submitReport(A.ctx.tech, { reportId: report.id });
|
||||
const afterSubmit = await prisma.workOrder.findUniqueOrThrow({ where: { id: r1.workOrderId } });
|
||||
ok(afterSubmit.status === "in_review", "Auftragsstatus nach Abschlussbericht: in_review");
|
||||
const completedNotes = await prisma.notification.findMany({ where: { tenantId: A.tenantId, type: "emergency.completed", entityId: r1.workOrderId } });
|
||||
ok(completedNotes.some((n) => n.userId === A.users.backoffice.id), "Event emergency.completed → Backoffice benachrichtigt");
|
||||
const data = await buildEmergencyCompletedData(A.ctx.backoffice, r1.workOrderId);
|
||||
ok(!!data && data.technician === `tech ${SLUG_A}` && data.customer === "Lutz Meier" && data.status === "in_review", "Daten: Monteur, Kunde, Status");
|
||||
ok(!!data && new Date(data.startedAt) <= new Date(data.endedAt), "Daten: Einsatzbeginn ≤ Einsatzende");
|
||||
const mails = await prisma.mailLog.findMany({ where: { tenantId: A.tenantId, template: "craftvia_emergency" } }).catch(() => null);
|
||||
if (mails) ok(mails.length >= 1, "Pflicht-E-Mail craftvia_emergency protokolliert");
|
||||
|
||||
console.log(`\n${failures === 0 ? "✓ alle Prüfungen bestanden" : `✗ ${failures} Prüfung(en) fehlgeschlagen`}`);
|
||||
} finally {
|
||||
await cleanupTenants([SLUG_A, SLUG_B]);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
process.exit(failures > 0 || process.exitCode ? 1 : 0);
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
// Lane L8 „Notdienst" — Backoffice-Nachbearbeitung: Liste/Detail (Fortschritt), Kunde bestätigen
|
||||
// (Kundennummer), bestehendem Kunden zuordnen (alles umgehängt, vorläufig → merged), Dublette
|
||||
// zusammenführen, Objekt korrigieren/zuordnen, Auftrag ergänzen, Abrechnungsfreigabe.
|
||||
// Rechte: Monteur/Teamleiter können nicht reviewen; Mandant B sieht/ändert nichts von A.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-notdienst-review.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { prisma } from "../src/server/db";
|
||||
import type { EmergencyCreateInput } from "../src/lib/emergency/schemas";
|
||||
import { createEmergencyOrder } from "../src/server/services/emergency/create";
|
||||
import {
|
||||
assignEmergencySite,
|
||||
assignEmergencyToCustomer,
|
||||
completeEmergencyOrderData,
|
||||
confirmEmergencyCustomer,
|
||||
correctEmergencySite,
|
||||
getEmergencyReview,
|
||||
listEmergencyReviews,
|
||||
mergeEmergencyCustomer,
|
||||
releaseEmergencyForBilling,
|
||||
} from "../src/server/services/emergency/review";
|
||||
import { endSession } from "../src/server/services/field/sessions";
|
||||
import { createCompletionReport } 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 } from "../src/server/services/reports/approve";
|
||||
import type { ServiceCtx } from "../src/server/services/context";
|
||||
import { cleanupTenants, createFixture, expectCode, failures, ok } from "./lib-auftraege-fixtures";
|
||||
|
||||
const SLUG_A = "zz-l8-review-a";
|
||||
const SLUG_B = "zz-l8-review-b";
|
||||
|
||||
function payload(over: Partial<EmergencyCreateInput> = {}): EmergencyCreateInput {
|
||||
return {
|
||||
clientIds: { workOrder: randomUUID(), session: randomUUID() },
|
||||
customer: { mode: "new", firstName: "Lutz", lastName: "Meier", phone: "0171 7654321", street: "Elbchaussee", houseNumber: "12", postalCode: "22763", city: "Hamburg" },
|
||||
site: { mode: "new", street: "Elbchaussee", houseNumber: "12", postalCode: "22763", city: "Hamburg" },
|
||||
onSiteContact: { name: "Lutz Meier", phone: "0171 7654321" },
|
||||
reason: "Heizung ausgefallen",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
async function finishWithReport(ctx: ServiceCtx, workOrderId: string): Promise<string> {
|
||||
await endSession(ctx, { workOrderId });
|
||||
const report = (await createCompletionReport(ctx, { workOrderId })).report;
|
||||
await updateReportTexts(ctx, { reportId: report.id, texts: { workPerformed: "Brenner getauscht." } });
|
||||
await captureSignature(ctx, { reportId: report.id, outcome: "customer_absent", reason: "Kunde nicht vor Ort", confirmationText: "Bestätigt" });
|
||||
await submitReport(ctx, { reportId: report.id });
|
||||
return report.id;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await cleanupTenants([SLUG_A, SLUG_B]);
|
||||
const A = await createFixture(SLUG_A);
|
||||
const B = await createFixture(SLUG_B);
|
||||
const office = A.ctx.backoffice;
|
||||
try {
|
||||
const r1 = await createEmergencyOrder(A.ctx.tech, payload());
|
||||
const reportId = await finishWithReport(A.ctx.tech, r1.workOrderId);
|
||||
const r2 = await createEmergencyOrder(A.ctx.tech, payload({ customer: { mode: "new", companyName: "Bäckerei Sonne", phone: "040 3333" }, onSiteContact: { name: "Frau Sonne", phone: "040 3333" }, reason: "Stromausfall Backstube" }));
|
||||
const existing = await prisma.customer.create({
|
||||
data: { tenantId: A.tenantId, customerNumber: "K-77001", firstName: "Lutz", lastName: "Meier", phone: "+49 171 7654321", city: "Hamburg", street: "Elbchaussee" },
|
||||
});
|
||||
const existingSite = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: existing.id, name: "Wohnhaus Elbchaussee", street: "Elbchaussee", houseNumber: "12", city: "Hamburg" } });
|
||||
|
||||
console.log("\n— Rechte & Mandantentrennung —");
|
||||
await expectCode(() => listEmergencyReviews(A.ctx.tech), "forbidden", "Monteur: Liste → forbidden");
|
||||
await expectCode(() => listEmergencyReviews(A.ctx.lead), "forbidden", "Teamleiter: Liste → forbidden");
|
||||
await expectCode(() => getEmergencyReview(A.ctx.tech, r1.workOrderId), "forbidden", "Monteur: Detail → forbidden");
|
||||
await expectCode(() => confirmEmergencyCustomer(A.ctx.tech, r2.workOrderId), "forbidden", "Monteur: Kunden bestätigen → forbidden");
|
||||
await expectCode(() => assignEmergencyToCustomer(A.ctx.tech, { workOrderId: r1.workOrderId, targetCustomerId: existing.id }), "forbidden", "Monteur: zuordnen → forbidden");
|
||||
await expectCode(() => releaseEmergencyForBilling(A.ctx.tech, { workOrderId: r1.workOrderId }), "forbidden", "Monteur: Abrechnung → forbidden");
|
||||
ok((await listEmergencyReviews(B.ctx.backoffice, { filter: "all" })).length === 0, "Mandant B: Liste enthält keine Notdienste von A");
|
||||
await expectCode(() => getEmergencyReview(B.ctx.backoffice, r1.workOrderId), "not_found", "Mandant B: Detail → not_found");
|
||||
await expectCode(() => confirmEmergencyCustomer(B.ctx.backoffice, r2.workOrderId), "not_found", "Mandant B: Kunden bestätigen → not_found");
|
||||
await expectCode(() => assignEmergencyToCustomer(B.ctx.backoffice, { workOrderId: r1.workOrderId, targetCustomerId: existing.id }), "not_found", "Mandant B: zuordnen → not_found");
|
||||
await expectCode(() => completeEmergencyOrderData(B.ctx.backoffice, { workOrderId: r1.workOrderId, title: "B" }), "not_found", "Mandant B: Auftrag ergänzen → not_found");
|
||||
ok((await prisma.customer.findUniqueOrThrow({ where: { id: r2.customerId } })).status === "provisional", "nach fremden Versuchen unverändert vorläufig");
|
||||
const regular = await prisma.workOrder.create({ data: { tenantId: A.tenantId, number: "A-TEST-1", title: "Normal", customerId: A.customerId, status: "in_review" } });
|
||||
await expectCode(() => getEmergencyReview(office, regular.id), "not_found", "normaler Auftrag ist kein Notdienst → not_found");
|
||||
|
||||
console.log("\n— Liste & Fortschritt —");
|
||||
const list = await listEmergencyReviews(office);
|
||||
const item1 = list.find((i) => i.id === r1.workOrderId);
|
||||
ok(!!item1 && !!list.find((i) => i.id === r2.workOrderId), "Liste enthält Notdienste mit vorläufigen Stammdaten");
|
||||
ok(item1?.progress.total === 5 && item1.progress.count === 0 && item1.technician === `tech ${SLUG_A}`, "Fortschritt 0 von 5, Monteur angezeigt");
|
||||
const detail = await getEmergencyReview(office, r1.workOrderId);
|
||||
ok(detail.duplicates.some((d) => d.customerId === existing.id), "Dublettenkandidat über findDuplicateCustomers (Telefon/Name)");
|
||||
ok(detail.startedAt instanceof Date && detail.endedAt instanceof Date && detail.startedAt <= detail.endedAt, "Einsatzbeginn/-ende aus Arbeitszeiten");
|
||||
|
||||
console.log("\n— Bestehendem Kunden zuordnen —");
|
||||
await expectCode(() => assignEmergencyToCustomer(office, { workOrderId: r1.workOrderId, targetCustomerId: r1.customerId }), "invalid", "gleicher Kunde → invalid");
|
||||
await expectCode(() => assignEmergencyToCustomer(office, { workOrderId: r1.workOrderId, targetCustomerId: B.customerId }), "invalid", "Kunde aus Mandant B als Ziel → invalid");
|
||||
const before = await prisma.workOrder.findUniqueOrThrow({ where: { id: r1.workOrderId } });
|
||||
const assigned = await assignEmergencyToCustomer(office, { workOrderId: r1.workOrderId, targetCustomerId: existing.id });
|
||||
const woAfter = await prisma.workOrder.findUniqueOrThrow({ where: { id: r1.workOrderId }, include: { site: true, contact: true } });
|
||||
const src = await prisma.customer.findUniqueOrThrow({ where: { id: r1.customerId } });
|
||||
ok(woAfter.customerId === existing.id && woAfter.version === before.version + 1, "Auftrag umgehängt, Version +1");
|
||||
ok(woAfter.site?.customerId === existing.id && woAfter.contact?.customerId === existing.id, "Objekt und Ansprechpartner umgehängt");
|
||||
ok(src.status === "merged" && src.mergedIntoId === existing.id && !src.isProvisional, "vorläufiger Kunde → merged (mergedIntoId)");
|
||||
ok(assigned.moved.workOrders === 1 && assigned.moved.sites === 1, "Zähler der umgehängten Datensätze");
|
||||
ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "customer", entityId: { in: [r1.customerId, existing.id] } } })) >= 2, "Audit für Quelle und Ziel");
|
||||
await expectCode(() => assignEmergencyToCustomer(office, { workOrderId: r1.workOrderId, targetCustomerId: A.customerId }), "conflict", "erneutes Zuordnen (nicht mehr vorläufig) → conflict");
|
||||
ok((await getEmergencyReview(office, r1.workOrderId)).progress.done.customer, "Prüfschritt Kunde erledigt");
|
||||
|
||||
console.log("\n— Objekt zuordnen / korrigieren —");
|
||||
const oldSiteId = woAfter.siteId!;
|
||||
const siteRes = await assignEmergencySite(office, { workOrderId: r1.workOrderId, siteId: existingSite.id });
|
||||
const woSite = await prisma.workOrder.findUniqueOrThrow({ where: { id: r1.workOrderId } });
|
||||
ok(woSite.siteId === existingSite.id && siteRes.removed, "Auftrag am bestehenden Objekt");
|
||||
ok(!!(await prisma.site.findUniqueOrThrow({ where: { id: oldSiteId } })).deletedAt, "ungenutztes vorläufiges Objekt soft-gelöscht");
|
||||
await expectCode(() => assignEmergencySite(office, { workOrderId: r1.workOrderId, siteId: B.siteId }), "invalid", "Objekt aus Mandant B → invalid");
|
||||
const corrected = await correctEmergencySite(office, { workOrderId: r2.workOrderId, patch: { name: "Backstube", accessNotes: "Hintereingang" }, confirm: true });
|
||||
ok(corrected.status === "active" && corrected.name === "Backstube" && corrected.customerId === r2.customerId, "vorläufiges Objekt korrigiert und bestätigt");
|
||||
|
||||
console.log("\n— Kunde bestätigen —");
|
||||
const confirmed = await confirmEmergencyCustomer(office, r2.workOrderId);
|
||||
const c2 = await prisma.customer.findUniqueOrThrow({ where: { id: r2.customerId } });
|
||||
ok(/^K-\d+$/.test(confirmed.customerNumber ?? "") && c2.customerNumber === confirmed.customerNumber, `Kundennummer bei Bestätigung vergeben (${confirmed.customerNumber})`);
|
||||
ok(c2.status === "active" && !c2.isProvisional, "Kunde aktiv");
|
||||
await expectCode(() => confirmEmergencyCustomer(office, r2.workOrderId), "conflict", "zweite Bestätigung → conflict");
|
||||
|
||||
console.log("\n— Dublette zusammenführen —");
|
||||
await expectCode(() => mergeEmergencyCustomer(office, { workOrderId: r2.workOrderId, targetCustomerId: A.customerId, confirm: false }), "invalid", "ohne Bestätigung → invalid");
|
||||
await expectCode(() => mergeEmergencyCustomer(A.ctx.tech, { workOrderId: r2.workOrderId, targetCustomerId: A.customerId, confirm: true }), "forbidden", "Monteur → forbidden");
|
||||
await mergeEmergencyCustomer(office, { workOrderId: r2.workOrderId, targetCustomerId: A.customerId, confirm: true });
|
||||
ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: r2.workOrderId } })).customerId === A.customerId, "Auftrag nach Merge am Zielkunden");
|
||||
ok((await prisma.customer.findUniqueOrThrow({ where: { id: r2.customerId } })).status === "merged", "Quelle merged");
|
||||
|
||||
console.log("\n— Auftrag ergänzen & Abrechnung —");
|
||||
const notdienst = await prisma.orderType.findFirstOrThrow({ where: { tenantId: A.tenantId, key: "notdienst" } });
|
||||
await expectCode(() => completeEmergencyOrderData(office, { workOrderId: r1.workOrderId, title: "" }), "invalid", "leerer Titel → invalid");
|
||||
await completeEmergencyOrderData(office, { workOrderId: r1.workOrderId, title: "Notdienst Heizung", description: "Brenner getauscht", orderTypeId: notdienst.id, billingType: "time_material" });
|
||||
const woData = await prisma.workOrder.findUniqueOrThrow({ where: { id: r1.workOrderId } });
|
||||
ok(woData.title === "Notdienst Heizung" && woData.billingType === "time_material" && woData.orderTypeId === notdienst.id, "Titel, Auftragsart, Abrechnungsart gespeichert");
|
||||
await expectCode(() => completeEmergencyOrderData(office, { workOrderId: r1.workOrderId, title: "alt", baseVersion: 1 }), "conflict", "veraltete Version → conflict");
|
||||
|
||||
const r3 = await createEmergencyOrder(A.ctx.tech, payload({ customer: { mode: "new", lastName: "Offen", phone: "040 4444" } }));
|
||||
await expectCode(() => releaseEmergencyForBilling(office, { workOrderId: r3.workOrderId }), "blocked", "vorläufige Stammdaten → Abrechnung blocked");
|
||||
const blockedErr = await releaseEmergencyForBilling(office, { workOrderId: r3.workOrderId }).catch((e) => e);
|
||||
ok((blockedErr?.details as { reason?: string })?.reason === "master_data_open", "Grund master_data_open");
|
||||
await expectCode(() => releaseEmergencyForBilling(office, { workOrderId: r1.workOrderId }), "blocked", "ohne freigegebenen Abschlussbericht → blocked");
|
||||
await approveReport(office, { reportId }, { dispatchPdf: async () => {} });
|
||||
const released = await releaseEmergencyForBilling(office, { workOrderId: r1.workOrderId });
|
||||
ok(released.status === "released_for_billing", "zur Abrechnung freigegeben");
|
||||
const final = await getEmergencyReview(office, r1.workOrderId);
|
||||
ok(final.progress.count === 5, "Fortschritt 5 von 5");
|
||||
ok(!(await listEmergencyReviews(office)).some((i) => i.id === r1.workOrderId), "erledigter Notdienst nicht mehr in „Zu prüfen“");
|
||||
ok((await listEmergencyReviews(office, { filter: "all" })).some((i) => i.id === r1.workOrderId), "unter „Alle“ weiterhin sichtbar");
|
||||
ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "emergency", entityId: r1.workOrderId, action: "update" } })) >= 4, "Prüfschritte auditiert");
|
||||
|
||||
console.log(`\n${failures === 0 ? "✓ alle Prüfungen bestanden" : `✗ ${failures} Prüfung(en) fehlgeschlagen`}`);
|
||||
} finally {
|
||||
await cleanupTenants([SLUG_A, SLUG_B]);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
process.exit(failures > 0 || process.exitCode ? 1 : 0);
|
||||
});
|
||||
Reference in New Issue
Block a user