Kernlogik, Mandantentrennung (Mandant B liest/ändert nichts von A) und Rollen/Scope (Monteur ohne Zuweisung → not_found/forbidden). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
174 lines
12 KiB
TypeScript
174 lines
12 KiB
TypeScript
// 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);
|
||
});
|