// L1 Stammdaten — Kunden, Ansprechpartner, vorläufige Kunden, Zusammenführen (Spec §7, US-003): // (1) Anlage mit Nummernkreis, manuelle Nummer, Eindeutigkeit, Dublettenhinweis, Validierung, Audit // (2) Bearbeiten, Ansprechpartner, vorläufig → aktiv // (3) Mandantentrennung: Mandant B kann A weder lesen noch ändern noch zusammenführen // (4) Rollen/Scope: Monteur ohne Zuweisung → not_found, mit Teamauftrag → sichtbar, keine Schreibrechte // (5) Zusammenführen: nur mit customer:merge + Bestätigung, hängt Kontakte/Objekte/Aufträge/Dokumente um // (6) Soft Delete blockiert bei offenen Aufträgen // // Lauf: npx tsx scripts/test-stammdaten-customers.ts import "dotenv/config"; // must run before any module that constructs the Prisma client import { confirmProvisionalCustomer, createCustomer, deleteCustomer, getCustomer, listCustomers, updateCustomer, } from "../src/server/services/customers/customers"; import { createContact, deleteContact, updateContact } from "../src/server/services/customers/contacts"; import { mergeCustomers } from "../src/server/services/customers/merge"; import { checker, cleanupTenants, createTeamWithMember, createTenant, createUser, createWorkOrder, ctxFor, disconnect, prisma, } from "./lib-stammdaten-fixtures"; const SLUG_A = "zz-l1-cust-a"; const SLUG_B = "zz-l1-cust-b"; const DOMAIN = "zz-l1-cust.test"; const c = checker("Kunden"); async function main() { await cleanupTenants([SLUG_A, SLUG_B], DOMAIN); const tA = await createTenant(SLUG_A, "L1 Kunden A"); const tB = await createTenant(SLUG_B, "L1 Kunden B"); const boA = await createUser(tA.id, `bo@${DOMAIN}`, "Backoffice A"); const techA = await createUser(tA.id, `tech@${DOMAIN}`, "Monteur A"); const boB = await createUser(tB.id, `bo-b@${DOMAIN}`, "Backoffice B"); const ctxA = ctxFor(tA.id, boA.id, "backoffice"); const ctxB = ctxFor(tB.id, boB.id, "backoffice"); const ctxTech = ctxFor(tA.id, techA.id, "technician"); console.log("— (1) Anlage —"); const alpha = await createCustomer(ctxA, { companyName: "Alpha Sanitär GmbH", street: "Hafenstraße", houseNumber: "1", postalCode: "20457", city: "Hamburg" }); c.ok(/^K-\d{5}$/.test(alpha.customerNumber ?? ""), `Kundennummer aus Nummernkreis (${alpha.customerNumber})`); c.ok(alpha.createdById === boA.id && alpha.status === "active" && alpha.country === "DE", "createdById, Status aktiv, Land DE"); const audit = await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "customer", entityId: alpha.id, action: "create" } }); c.ok(!!audit?.after, "Audit-Eintrag (create, after) geschrieben"); const manual = await createCustomer(ctxA, { companyName: "Beta Elektro", customerNumber: "K-00002" }); c.ok(manual.customerNumber === "K-00002", "manuelle Kundennummer übernommen"); const auto = await createCustomer(ctxA, { lastName: "Gamma", firstName: "Gerda" }); c.ok(auto.customerNumber === "K-00003", `Nummernkreis überspringt manuell vergebene Nummer (${auto.customerNumber})`); await c.expectServiceError(() => createCustomer(ctxA, { companyName: "Delta", customerNumber: "K-00002" }), "conflict", "doppelte Kundennummer → conflict", "number_taken"); const inB = await createCustomer(ctxB, { companyName: "Delta B", customerNumber: "K-00002" }); c.ok(inB.customerNumber === "K-00002", "gleiche Kundennummer in anderem Mandanten erlaubt (eindeutig je Mandant)"); await c.expectServiceError( () => createCustomer(ctxA, { companyName: "Alpha Sanitär", city: "Hamburg", street: "Hafen-Str.", houseNumber: "1", postalCode: "20457" }), "conflict", "mögliche Dublette ohne Bestätigung → conflict", "possible_duplicates", ); try { await createCustomer(ctxA, { companyName: "Alpha Sanitär" }); } catch (err) { const cands = (err as { details?: { candidates?: { customerId: string }[] } }).details?.candidates ?? []; c.ok(cands.some((x) => x.customerId === alpha.id), "Dublettenhinweis enthält den bestehenden Kunden"); } const ack = await createCustomer(ctxA, { companyName: "Alpha Sanitär" }, { acknowledgeDuplicates: true }); c.ok(!!ack.id, "nach Bestätigung trotzdem angelegt (Nutzer entscheidet)"); await c.expectErrorName(() => createCustomer(ctxA, { city: "Nirgendwo" }), "ZodError", "ohne Firmenname/Nachname → Validierungsfehler"); await c.expectErrorName(() => createCustomer(ctxA, { companyName: "X", email: "kein-mail" }), "ZodError", "ungültige E-Mail → Validierungsfehler"); console.log("\n— (2) Bearbeiten, Ansprechpartner, vorläufig —"); const updated = await updateCustomer(ctxA, alpha.id, { email: "info@alpha.example", notes: null }); c.ok(updated.email === "info@alpha.example", "E-Mail geändert"); const upAudit = await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "customer", entityId: alpha.id, action: "update" } }); c.ok(!!upAudit?.before && !!upAudit?.after, "Audit-Eintrag (update, before/after)"); await c.expectServiceError(() => updateCustomer(ctxA, alpha.id, { companyName: null, lastName: null }), "invalid", "Name darf nicht vollständig entfernt werden", "name_required"); await c.expectServiceError(() => updateCustomer(ctxA, alpha.id, { customerNumber: "K-00002" }), "conflict", "Änderung auf vergebene Nummer → conflict", "number_taken"); const contact = await createContact(ctxA, alpha.id, { name: "Petra Planer", role: "Hausverwaltung", phone: "040 111", preferredChannel: "phone" }); const contact2 = await createContact(ctxA, alpha.id, { name: "Olaf Objekt", email: "olaf@alpha.example", preferredChannel: "email" }); c.ok(contact.customerId === alpha.id && contact2.preferredChannel === "email", "mehrere Ansprechpartner mit bevorzugtem Kontaktweg"); const contactUp = await updateContact(ctxA, contact.id, { name: "Petra Planer", preferredChannel: "mobile", mobile: "0170 1" }); c.ok(contactUp.preferredChannel === "mobile", "Ansprechpartner bearbeitet"); await c.expectErrorName(() => createContact(ctxA, alpha.id, { name: "X", preferredChannel: "fax" as never }), "ZodError", "ungültiger Kontaktweg → Validierungsfehler"); await deleteContact(ctxA, contact2.id); const detail = await getCustomer(ctxA, alpha.id); c.ok(detail.contacts.length === 1 && detail.contacts[0].id === contact.id, "gelöschter Ansprechpartner ausgeblendet (Soft Delete)"); const prov = await createCustomer(ctxA, { lastName: "Notdienstkunde", status: "provisional" }, { acknowledgeDuplicates: true }); c.ok(prov.status === "provisional" && prov.isProvisional, "vorläufiger Kunde angelegt"); const provList = await listCustomers(ctxA, { status: "provisional" }); c.ok(provList.items.some((x) => x.id === prov.id) && provList.items.every((x) => x.status === "provisional"), "Filter „vorläufig“"); const confirmed = await confirmProvisionalCustomer(ctxA, prov.id); c.ok(confirmed.status === "active" && !confirmed.isProvisional, "vorläufig → aktiv bestätigt"); await c.expectServiceError(() => confirmProvisionalCustomer(ctxA, prov.id), "conflict", "erneutes Bestätigen → conflict", "not_provisional"); const search = await listCustomers(ctxA, { q: "alpha", pageSize: 1 }); c.ok(search.total === 2 && search.items.length === 1 && search.pageSize === 1, "Suche + Paginierung"); console.log("\n— (3) Mandantentrennung —"); await c.expectServiceError(() => getCustomer(ctxB, alpha.id), "not_found", "Mandant B liest Kunden von A → not_found"); await c.expectServiceError(() => updateCustomer(ctxB, alpha.id, { notes: "gehackt" }), "not_found", "Mandant B ändert Kunden von A → not_found"); await c.expectServiceError(() => createContact(ctxB, alpha.id, { name: "Fremd" }), "not_found", "Mandant B legt Kontakt an Kunde A an → not_found"); await c.expectServiceError(() => updateContact(ctxB, contact.id, { name: "Fremd" }), "not_found", "Mandant B ändert Kontakt von A → not_found"); await c.expectServiceError(() => confirmProvisionalCustomer(ctxB, prov.id), "not_found", "Mandant B bestätigt Kunden von A → not_found"); await c.expectServiceError(() => deleteCustomer(ctxB, alpha.id), "not_found", "Mandant B löscht Kunden von A → not_found"); const listB = await listCustomers(ctxB, { pageSize: 100 }); c.ok(listB.items.every((x) => x.id === inB.id), "Liste von B enthält nur eigene Kunden"); const stillA = await prisma.customer.findUnique({ where: { id: alpha.id } }); c.ok(stillA?.notes === null && stillA?.email === "info@alpha.example", "Kunde A unverändert"); console.log("\n— (4) Rollen/Scope Monteur —"); await c.expectServiceError(() => getCustomer(ctxTech, alpha.id), "not_found", "Monteur ohne Zuweisung → not_found"); c.ok((await listCustomers(ctxTech)).total === 0, "Monteur ohne Zuweisung sieht keine Kunden"); await c.expectServiceError(() => updateCustomer(ctxTech, alpha.id, { notes: "x" }), "forbidden", "Monteur darf Kunden nicht ändern → forbidden"); await c.expectServiceError(() => createCustomer(ctxTech, { companyName: "Monteurkunde" }), "forbidden", "Monteur darf keine Kunden anlegen → forbidden"); const team = await createTeamWithMember(tA.id, "Team Nord", techA.id); const order = await createWorkOrder(tA.id, { customerId: alpha.id, assignedTeamId: team.id }); const techView = await getCustomer(ctxTech, alpha.id); c.ok(techView.id === alpha.id, "Monteur sieht Kunden über Auftrag seines Teams"); c.ok((await listCustomers(ctxTech)).items.map((x) => x.id).join() === alpha.id, "Monteur-Liste nur mit erreichbaren Kunden"); console.log("\n— (5) Zusammenführen —"); const source = await createCustomer(ctxA, { companyName: "Quelle Haustechnik" }); const target = await createCustomer(ctxA, { companyName: "Ziel Haustechnik" }); const sContact = await createContact(ctxA, source.id, { name: "Kontakt Quelle" }); const sSite = await prisma.site.create({ data: { tenantId: tA.id, customerId: source.id, name: "Objekt Quelle" } }); const sOrder = await createWorkOrder(tA.id, { customerId: source.id, siteId: sSite.id }); const sDoc = await prisma.document.create({ data: { tenantId: tA.id, customerId: source.id, category: "other", fileName: "a.pdf", storageKey: `${tA.id}/x`, mimeType: "application/pdf", fileSize: 1, checksum: "0", lineageId: `lin-${source.id}` }, }); await c.expectServiceError( () => mergeCustomers(ctxFor(tA.id, boA.id, "backoffice", { remove: ["customer:merge"] }), { sourceId: source.id, targetId: target.id, confirm: true }), "forbidden", "ohne customer:merge → forbidden", ); await c.expectServiceError(() => mergeCustomers(ctxTech, { sourceId: source.id, targetId: target.id, confirm: true }), "forbidden", "Monteur → forbidden"); await c.expectErrorName(() => mergeCustomers(ctxA, { sourceId: source.id, targetId: target.id, confirm: false as true }), "ZodError", "ohne Bestätigung → abgelehnt"); await c.expectErrorName(() => mergeCustomers(ctxA, { sourceId: source.id, targetId: source.id, confirm: true }), "ZodError", "Quelle = Ziel → abgelehnt"); await c.expectServiceError(() => mergeCustomers(ctxB, { sourceId: source.id, targetId: target.id, confirm: true }), "not_found", "Mandant B führt Kunden von A zusammen → not_found"); const bCustomer = await createCustomer(ctxB, { companyName: "B-Kunde" }); await c.expectServiceError(() => mergeCustomers(ctxA, { sourceId: source.id, targetId: bCustomer.id, confirm: true }), "not_found", "Ziel aus fremdem Mandanten → not_found (keine Kreuzung)"); await c.expectServiceError(() => mergeCustomers(ctxB, { sourceId: bCustomer.id, targetId: target.id, confirm: true }), "not_found", "Quelle B in Ziel A → not_found"); c.ok((await prisma.customer.findUnique({ where: { id: source.id } }))?.status === "active", "nach abgelehnten Versuchen ist die Quelle unverändert"); const result = await mergeCustomers(ctxA, { sourceId: source.id, targetId: target.id, confirm: true }); c.ok( result.moved.contacts === 1 && result.moved.sites === 1 && result.moved.workOrders === 1 && result.moved.documents === 1, `umgehängt: ${JSON.stringify(result.moved)}`, ); const [srcAfter, contactAfter, siteAfter, orderAfter, docAfter] = await Promise.all([ prisma.customer.findUnique({ where: { id: source.id } }), prisma.contact.findUnique({ where: { id: sContact.id } }), prisma.site.findUnique({ where: { id: sSite.id } }), prisma.workOrder.findUnique({ where: { id: sOrder.id } }), prisma.document.findUnique({ where: { id: sDoc.id } }), ]); c.ok(srcAfter?.status === "merged" && srcAfter.mergedIntoId === target.id, "Quelle: status merged + mergedIntoId"); c.ok(contactAfter?.customerId === target.id && siteAfter?.customerId === target.id && docAfter?.customerId === target.id, "Kontakt, Objekt, Dokument hängen am Ziel"); c.ok(orderAfter?.customerId === target.id && orderAfter.version === sOrder.version + 1, "Auftrag am Ziel, Version erhöht (Offline-Konflikterkennung)"); const mergeAudits = await prisma.auditLog.count({ where: { tenantId: tA.id, entity: "customer", entityId: { in: [source.id, target.id] }, action: "update" } }); c.ok(mergeAudits === 2, "volles Audit: Einträge für Quelle und Ziel"); await c.expectServiceError(() => mergeCustomers(ctxA, { sourceId: source.id, targetId: target.id, confirm: true }), "conflict", "erneutes Zusammenführen → conflict", "already_merged"); c.ok(!(await listCustomers(ctxA, { pageSize: 100 })).items.some((x) => x.id === source.id), "Standardliste blendet zusammengeführte Kunden aus"); await c.expectServiceError(() => updateCustomer(ctxA, source.id, { notes: "x" }), "not_found", "zusammengeführter Kunde ist nicht mehr bearbeitbar"); console.log("\n— (6) Soft Delete —"); await c.expectServiceError(() => deleteCustomer(ctxA, alpha.id), "blocked", "Löschen bei offenem Auftrag → blocked", "open_work_orders"); await prisma.workOrder.update({ where: { id: order.id }, data: { status: "cancelled" } }); const deleted = await deleteCustomer(ctxA, alpha.id); c.ok(!!deleted.deletedAt, "Kunde soft-gelöscht (deletedAt gesetzt)"); c.ok(!!(await prisma.customer.findUnique({ where: { id: alpha.id } })), "Datensatz physisch noch vorhanden"); await c.expectServiceError(() => getCustomer(ctxA, alpha.id), "not_found", "gelöschter Kunde → 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); });