// L1 Stammdaten — Objekte und Objekt-Historie (Spec §8, US-005, US-011): // (1) Objekt anlegen/ändern inkl. Validierung Kunde/Kontakt, Kartenlink, Soft Delete // (2) Historie: chronologisch, Arbeiten, Material aggregiert, Fotos, Berichte, Unterschrift, Folgearbeiten // (3) Rollen/Scope: Monteur sieht nur freigegebene Einsätze; ohne sichtbaren Auftrag → not_found // (4) Mandantentrennung // // Lauf: npx tsx scripts/test-stammdaten-sites.ts import "dotenv/config"; // must run before any module that constructs the Prisma client import { getSiteHistory } from "../src/server/services/sites/history"; import { siteMapUrl } from "../src/server/services/sites/map-link"; import { createSite, deleteSite, getSite, listSites, updateSite } from "../src/server/services/sites/sites"; import { checker, cleanupTenants, createTeamWithMember, createTenant, createUser, createWorkOrder, ctxFor, disconnect, prisma, } from "./lib-stammdaten-fixtures"; const SLUG_A = "zz-l1-site-a"; const SLUG_B = "zz-l1-site-b"; const DOMAIN = "zz-l1-site.test"; const c = checker("Objekte & Historie"); async function main() { await cleanupTenants([SLUG_A, SLUG_B], DOMAIN); const tA = await createTenant(SLUG_A, "L1 Objekte A"); const tB = await createTenant(SLUG_B, "L1 Objekte B"); const boA = await createUser(tA.id, `bo@${DOMAIN}`, "Backoffice A"); const tech1 = await createUser(tA.id, `tech1@${DOMAIN}`, "Monteur Team X"); const tech2 = await createUser(tA.id, `tech2@${DOMAIN}`, "Monteur ohne Team"); const boB = await createUser(tB.id, `bo-b@${DOMAIN}`, "Backoffice B"); const ctxA = ctxFor(tA.id, boA.id, "backoffice"); const ctxT1 = ctxFor(tA.id, tech1.id, "technician"); const ctxT2 = ctxFor(tA.id, tech2.id, "technician"); const ctxB = ctxFor(tB.id, boB.id, "backoffice"); const customer = await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-1", companyName: "Objektkunde" } }); const otherCustomer = await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-2", companyName: "Anderer Kunde" } }); const otherContact = await prisma.contact.create({ data: { tenantId: tA.id, customerId: otherCustomer.id, name: "Fremdkontakt" } }); const ownContact = await prisma.contact.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Hausmeister" } }); const customerB = await prisma.customer.create({ data: { tenantId: tB.id, customerNumber: "K-1", companyName: "B-Kunde" } }); console.log("— (1) Objekte —"); const site = await createSite(ctxA, { customerId: customer.id, name: "Wohnanlage Süd", street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg", contactId: ownContact.id, accessNotes: "Schlüssel beim Hausmeister", parkingNotes: "Hof", safetyNotes: "Asbest im Keller", latitude: "53,5413" as never, longitude: 9.9841, }); c.ok(site.customerId === customer.id && site.latitude === 53.5413 && site.safetyNotes === "Asbest im Keller", "Objekt mit Hinweisen und Koordinaten (Komma-Dezimal) angelegt"); c.ok(!!(await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "site", entityId: site.id, action: "create" } })), "Audit create"); await c.expectServiceError(() => createSite(ctxA, { customerId: customer.id, name: "X", contactId: otherContact.id }), "invalid", "Kontakt eines anderen Kunden → invalid", "contact_mismatch"); await c.expectServiceError(() => createSite(ctxA, { customerId: customerB.id, name: "X" }), "invalid", "Kunde aus fremdem Mandanten → invalid", "customer_not_found"); await c.expectServiceError(() => createSite(ctxT1, { customerId: customer.id, name: "X" }), "forbidden", "Monteur darf keine Objekte anlegen"); await c.expectErrorName(() => createSite(ctxA, { customerId: customer.id, name: "" }), "ZodError", "ohne Bezeichnung → Validierungsfehler"); await c.expectErrorName(() => createSite(ctxA, { customerId: customer.id, name: "X", latitude: 123 }), "ZodError", "Breitengrad außerhalb −90..90 → Validierungsfehler"); const moved = await updateSite(ctxA, site.id, { technicalNotes: "Heizung Baujahr 2004" }); c.ok(moved.technicalNotes === "Heizung Baujahr 2004" && moved.contactId === ownContact.id, "Objekt geändert, Kontakt bleibt"); const url = siteMapUrl(site); c.ok(!!url && url.startsWith("https://www.openstreetmap.org/?mlat=53.541300"), `Kartenlink aus Koordinaten (${url})`); const addrUrl = siteMapUrl({ street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg" }); c.ok(addrUrl === `https://www.openstreetmap.org/search?query=${encodeURIComponent("Hafenstraße 12, 20457 Hamburg")}`, "Kartenlink aus Adresse (kein Embed)"); c.ok(siteMapUrl({ street: "Nur Straße" }) === null, "ohne Ort/PLZ kein Kartenlink"); console.log("\n— (2) Historie (Backoffice) —"); const teamX = await createTeamWithMember(tA.id, "Team X", tech1.id); const teamY = await createTeamWithMember(tA.id, "Team Y", null); // WO1: freigegeben, Team Y (nicht Team des Monteurs), mit allen Nachweisen const wo1 = await createWorkOrder(tA.id, { customerId: customer.id, siteId: site.id, assignedTeamId: teamY.id, status: "released_for_billing", title: "Wartung Heizung", followUpWork: "Nachkontrolle im Herbst" }); const session = await prisma.workSession.create({ data: { tenantId: tA.id, workOrderId: wo1.id, userId: boA.id, startedAt: new Date("2026-03-01T08:00:00Z"), status: "ended" } }); await prisma.activityNote.createMany({ data: [ { tenantId: tA.id, workOrderId: wo1.id, kind: "work_done", text: "Heizung gewartet" }, { tenantId: tA.id, workOrderId: wo1.id, kind: "follow_up", text: "Ventil tauschen" }, { tenantId: tA.id, workOrderId: wo1.id, kind: "general", text: "INTERN nicht anzeigen" }, ], }); await prisma.materialUsage.createMany({ data: [ { tenantId: tA.id, workOrderId: wo1.id, workSessionId: session.id, name: "Ventil", unit: "Stk", actualQuantity: 1, usageStatus: "fully_used" }, { tenantId: tA.id, workOrderId: wo1.id, name: "ventil ", unit: "Stk", actualQuantity: 1.5, usageStatus: "additional" }, { tenantId: tA.id, workOrderId: wo1.id, name: "Dichtung", unit: "Stk", actualQuantity: 4, usageStatus: "not_used" }, ], }); for (let i = 0; i < 2; i++) { const doc = await prisma.document.create({ data: { tenantId: tA.id, workOrderId: wo1.id, category: "photo", fileName: `p${i}.jpg`, storageKey: `${tA.id}/p${i}`, mimeType: "image/jpeg", fileSize: 1, checksum: "0", lineageId: `lin-p${i}-${wo1.id}` } }); await prisma.photo.create({ data: { tenantId: tA.id, workOrderId: wo1.id, documentId: doc.id, takenAt: new Date() } }); } const report1 = await prisma.report.create({ data: { tenantId: tA.id, workOrderId: wo1.id, type: "completion", reportDate: new Date("2026-03-01"), lineageId: `r-${wo1.id}`, status: "approved", content: {} } }); await prisma.signature.create({ data: { tenantId: tA.id, reportId: report1.id, outcome: "signed", signerName: "Kunde", signedAt: new Date() } }); // WO2: Team X (Monteur 1), in Arbeit, nicht freigegeben const wo2 = await createWorkOrder(tA.id, { customerId: customer.id, siteId: site.id, assignedTeamId: teamX.id, status: "in_progress", title: "Leitung verlegen", plannedStart: new Date("2026-04-10T07:00:00Z") }); await prisma.activityNote.create({ data: { tenantId: tA.id, workOrderId: wo2.id, kind: "work_done", text: "Leitung verlegt" } }); // WO3: Team Y, Bericht nur eingereicht const wo3 = await createWorkOrder(tA.id, { customerId: customer.id, siteId: site.id, assignedTeamId: teamY.id, status: "in_review", plannedStart: new Date("2026-02-01T07:00:00Z") }); await prisma.report.create({ data: { tenantId: tA.id, workOrderId: wo3.id, type: "daily", reportDate: new Date("2026-02-01"), lineageId: `r-${wo3.id}`, status: "submitted", content: {} } }); // WO an anderem Objekt darf nicht erscheinen const otherSite = await prisma.site.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Anderes Objekt" } }); await createWorkOrder(tA.id, { customerId: customer.id, siteId: otherSite.id, assignedTeamId: teamY.id }); const all = await getSiteHistory(ctxA, site.id, { onlyApproved: false }); c.ok(all.total === 3 && !all.onlyApproved, `Backoffice sieht alle 3 Einsätze am Objekt (${all.total})`); c.ok(all.items.map((e) => e.workOrderId).join() === [wo2.id, wo1.id, wo3.id].join(), "chronologisch, neueste zuerst (Einsatzbeginn/Termin)"); const e1 = all.items.find((e) => e.workOrderId === wo1.id)!; c.ok(e1.workDone.join() === "Heizung gewartet" && !e1.summary.includes("INTERN"), "durchgeführte Arbeiten aus work_done, interne Notizen nicht enthalten"); c.ok(e1.materials.length === 1 && e1.materials[0].quantity === 2.5 && e1.materials[0].unit === "Stk", `Material aggregiert, not_used ausgeschlossen (${JSON.stringify(e1.materials)})`); c.ok(e1.photoCount === 2 && e1.signed && e1.approvedReports.length === 1 && e1.approvedReports[0].id === report1.id, "Fotoanzahl, Unterschrift, freigegebener Bericht"); c.ok(e1.hasOpenFollowUp && e1.followUps.length === 2, "offene Folgearbeiten (followUpWork + follow_up-Notiz) markiert"); c.ok(e1.team === "Team Y" && e1.date.toISOString() === "2026-03-01T08:00:00.000Z", "Team und Datum (Einsatzbeginn)"); const e3 = all.items.find((e) => e.workOrderId === wo3.id)!; c.ok(e3.approvedReports.length === 0 && !e3.hasOpenFollowUp, "eingereichter Bericht zählt nicht als freigegeben"); const approvedOnly = await getSiteHistory(ctxA, site.id, { onlyApproved: true }); c.ok(approvedOnly.total === 1 && approvedOnly.items[0].workOrderId === wo1.id, "onlyApproved=true → nur freigegebener Einsatz"); const paged = await getSiteHistory(ctxA, site.id, { page: 2, pageSize: 2 }); c.ok(paged.total === 3 && paged.items.length === 1, "Paginierung der Historie"); console.log("\n— (3) Rollen/Scope —"); const t1 = await getSiteHistory(ctxT1, site.id, { onlyApproved: false }); c.ok(t1.onlyApproved && t1.total === 1 && t1.items[0].workOrderId === wo1.id, "Monteur (Team X) erhält nur freigegebene Einsätze, auch mit onlyApproved=false"); c.ok(!t1.items.some((e) => e.workOrderId === wo2.id || e.workOrderId === wo3.id), "nicht freigegebene Einsätze bleiben für Monteur verborgen"); await c.expectServiceError(() => getSiteHistory(ctxT2, site.id), "not_found", "Monteur ohne sichtbaren Auftrag am Objekt → not_found"); await c.expectServiceError(() => getSite(ctxT2, site.id), "not_found", "Monteur ohne Zuweisung liest Objekt → not_found"); c.ok((await getSite(ctxT1, site.id)).id === site.id, "Monteur mit Teamauftrag liest Objekt"); const t1List = await listSites(ctxT1, { pageSize: 50 }); c.ok(t1List.items.map((s) => s.id).join() === site.id, "Monteur-Objektliste nur mit erreichbaren Objekten"); await c.expectServiceError(() => updateSite(ctxT1, site.id, { name: "x" }), "forbidden", "Monteur darf Objekt nicht ändern"); await c.expectServiceError(() => getSiteHistory(ctxFor(tA.id, tech1.id, "technician", { remove: ["site:read"] }), site.id), "forbidden", "ohne site:read → forbidden"); console.log("\n— (4) Mandantentrennung —"); await c.expectServiceError(() => getSite(ctxB, site.id), "not_found", "Mandant B liest Objekt von A → not_found"); await c.expectServiceError(() => getSiteHistory(ctxB, site.id), "not_found", "Mandant B liest Historie von A → not_found"); await c.expectServiceError(() => updateSite(ctxB, site.id, { name: "gehackt" }), "not_found", "Mandant B ändert Objekt von A → not_found"); await c.expectServiceError(() => deleteSite(ctxB, site.id), "not_found", "Mandant B löscht Objekt von A → not_found"); c.ok((await listSites(ctxB)).total === 0, "Liste von B leer"); c.ok((await prisma.site.findUnique({ where: { id: site.id } }))?.name === "Wohnanlage Süd", "Objekt A unverändert"); console.log("\n— Soft Delete —"); await c.expectServiceError(() => deleteSite(ctxA, site.id), "blocked", "Löschen bei offenen Aufträgen → blocked", "open_work_orders"); const emptySite = await createSite(ctxA, { customerId: customer.id, name: "Leeres Objekt" }); const del = await deleteSite(ctxA, emptySite.id); c.ok(!!del.deletedAt, "Objekt ohne Aufträge soft-gelöscht"); await c.expectServiceError(() => getSite(ctxA, emptySite.id), "not_found", "gelöschtes Objekt → not_found"); } main() .catch((err) => { console.error(err); c.ok(false, "unerwarteter Fehler"); }) .finally(async () => { await cleanupTenants([SLUG_A, SLUG_B], DOMAIN).catch((e) => console.error("cleanup", e)); const failures = c.finish(); await disconnect(); process.exit(failures === 0 ? 0 : 1); });