// L10a E2E §43.3 „Dublettenprüfung" beim Import (Spec §7.3, US-003) — Kandidaten werden gefunden // (Kundennummer, Firmenname, Adresse Straße/Str., E-Mail unabhängig von Groß-/Kleinschreibung, // Telefon unabhängig vom Format), aber NIE automatisch zugeordnet oder zusammengeführt; die // Entscheidung (bestehend / neu) trifft das Backoffice und wird protokolliert. Manuelle Anlage // zeigt dieselben Kandidaten. Mandant B findet nichts von A, Monteure haben keinen Zugriff. // // Lauf: npx tsx scripts/test-e2e-import-duplicates.ts import "dotenv/config"; import { prisma } from "../src/server/db"; import type { WorkOrderExtraction } from "../src/server/ai/providers"; import { FakeExtractionProvider } from "../src/server/ai/extraction/fake"; import { createImport } from "../src/server/services/imports/upload"; import { processImport } from "../src/server/services/imports/process"; import { confirmImport } from "../src/server/services/imports/confirm"; import { getImportDetail } from "../src/server/services/imports/queries"; import { extractionToForm } from "../src/lib/imports/review"; import { createCustomer } from "../src/server/services/customers/customers"; import { findDuplicateCustomers } from "../src/server/services/customers/duplicates"; import { mergeCustomers } from "../src/server/services/customers/merge"; import type { ServiceCtx } from "../src/server/services/context"; import { buildPdf } from "./make-sample-pdfs"; import { codeOf, createTenant, expectCode, ok, runSuite, section } from "./lib/e2e-fixture"; const SLUG_A = "zz-q-e2e-dup-a"; const SLUG_B = "zz-q-e2e-dup-b"; type Candidate = { customerId: string; score: number; reasons: string[] }; async function importWith(ctx: ServiceCtx, extraction: Partial) { const pdf = buildPdf([[{ text: "Auftrag" }]]); const job = await createImport(ctx, { bytes: pdf, fileName: "auftrag.pdf", mimeType: "application/pdf" }, { dispatch: async () => undefined }); const processed = await processImport(ctx, job.id, { provider: new FakeExtractionProvider({ extraction }), loadBytes: async () => pdf }); return { job, candidates: (processed.duplicateCandidates ?? []) as Candidate[] }; } runSuite("E2E Dublettenprüfung beim Import", [SLUG_A, SLUG_B], async () => { const A = await createTenant(SLUG_A); const B = await createTenant(SLUG_B); const bo = A.ctx.backoffice; const firm = await prisma.customer.create({ data: { tenantId: A.tenantId, customerNumber: "K-50001", companyName: "Musterbau Haustechnik GmbH", street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg", phone: "+49 40 5551234" }, }); const person = await prisma.customer.create({ data: { tenantId: A.tenantId, customerNumber: "K-50002", firstName: "Erika", lastName: "Beispiel", email: "erika@beispiel.example", street: "Lindenallee", houseNumber: "4", postalCode: "21073", city: "Hamburg" }, }); const foreign = await prisma.customer.create({ data: { tenantId: B.tenantId, customerNumber: "K-50001", companyName: "Musterbau Haustechnik GmbH", postalCode: "20457", phone: "040 5551234" } }); const firmBefore = await prisma.customer.findUniqueOrThrow({ where: { id: firm.id } }); section("1. Firmenkunde: Nummer, Name, Adresse (Str.), Telefon in anderem Format"); const i1 = await importWith(bo, { customerNumber: { value: "K-50001", confidence: 0.9 }, companyName: { value: "Musterbau Haustechnik GmbH", confidence: 0.95 }, customerAddress: { value: { street: "Hafenstr.", houseNumber: "12", postalCode: "20457", city: "Hamburg" }, confidence: 0.9 }, phone: { value: "040 / 555 12 34", confidence: 0.9 }, title: { value: "Wartung Lüftung", confidence: 0.9 }, }); const top = i1.candidates[0]; ok(top?.customerId === firm.id, "bestehender Firmenkunde ist bester Kandidat"); ok(!!top && ["customer_number", "company_name", "address", "phone"].every((r) => top.reasons.includes(r)), `Gründe: ${top?.reasons.join(", ")}`); ok(!i1.candidates.some((c) => c.customerId === foreign.id), "Kandidaten nie aus Mandant B"); ok((await prisma.workOrder.count({ where: { tenantId: A.tenantId } })) === 0, "keine automatische Zuordnung, kein Auftrag ohne Bestätigung"); ok((await prisma.customer.findUniqueOrThrow({ where: { id: firm.id } })).updatedAt.getTime() === firmBefore.updatedAt.getTime(), "bestehender Kunde unverändert"); section("2. Privatkunde: E-Mail in anderer Schreibweise"); const i2 = await importWith(bo, { customerFirstName: { value: "Erika", confidence: 0.9 }, customerLastName: { value: "Beispiel", confidence: 0.9 }, email: { value: "ERIKA@Beispiel.example", confidence: 0.9 }, title: { value: "Reparatur Mischbatterie", confidence: 0.9 }, }); ok(i2.candidates.some((c) => c.customerId === person.id && c.reasons.includes("email")), "Privatkunde über E-Mail gefunden"); section("3. Neukunde ohne Überschneidung"); const i3 = await importWith(bo, { companyName: { value: "Kieler Förde Wohnbau AG", confidence: 0.9 }, customerAddress: { value: { street: "Holtenauer Straße", houseNumber: "200", postalCode: "24105", city: "Kiel" }, confidence: 0.9 }, title: { value: "Neubau Heizzentrale", confidence: 0.9 }, }); ok(i3.candidates.length === 0, "kein Kandidat bei Neukunde"); section("4. Entscheidung des Backoffice"); const countBefore = await prisma.customer.count({ where: { tenantId: A.tenantId } }); const d2 = await getImportDetail(bo, i2.job.id); const c2 = await confirmImport(bo, i2.job.id, extractionToForm(d2.extraction.fields, { customerId: person.id })); ok(c2.customerId === person.id && (await prisma.customer.count({ where: { tenantId: A.tenantId } })) === countBefore, "Kandidat übernommen → kein neuer Kunde"); const d1 = await getImportDetail(bo, i1.job.id); const f1 = extractionToForm(d1.extraction.fields); ok(f1.customerMode === "new", "Formular schlägt ohne Entscheidung keinen bestehenden Kunden automatisch vor"); await expectCode(() => confirmImport(bo, i1.job.id, f1), "conflict", "„neu anlegen“ mit vergebener Kundennummer → conflict, keine Teilanlage"); f1.customer!.customerNumber = ""; const c1 = await confirmImport(bo, i1.job.id, f1); const newFirm = await prisma.customer.findUniqueOrThrow({ where: { id: c1.customerId } }); ok(newFirm.id !== firm.id && /^K-\d+$/.test(newFirm.customerNumber ?? ""), `bewusst neu angelegt mit eigener Nummer (${newFirm.customerNumber})`); ok((await prisma.customer.count({ where: { tenantId: A.tenantId, status: "merged" } })) === 0, "keine automatische Zusammenführung"); const job1 = await prisma.importJob.findUniqueOrThrow({ where: { id: i1.job.id } }); ok((job1.corrections as { decisions?: { customerMode?: string } }).decisions?.customerMode === "new", "Entscheidung „neu“ am Import protokolliert"); const d3 = await getImportDetail(bo, i3.job.id); const c3 = await confirmImport(bo, i3.job.id, extractionToForm(d3.extraction.fields)); ok((await prisma.customer.findUniqueOrThrow({ where: { id: c3.customerId } })).companyName === "Kieler Förde Wohnbau AG", "Neukunde angelegt"); section("5. Manuelle Anlage und Zusammenführung nur mit Bestätigung"); const dupErr = await codeOf(createCustomer(bo, { companyName: "Musterbau Haustechnik GmbH", phone: "040-5551234" })); ok(dupErr === "conflict", "manuelle Anlage → „Mögliche Dublette“ (conflict)"); const manual = await createCustomer(bo, { companyName: "Musterbau Haustechnik GmbH", phone: "040-5551234" }, { acknowledgeDuplicates: true }); ok(manual.status === "active", "„Trotzdem neu anlegen“ möglich"); await expectCode(() => mergeCustomers(bo, { sourceId: manual.id, targetId: firm.id, confirm: false } as never), "invalid", "Zusammenführen ohne Bestätigung → invalid"); await expectCode(() => mergeCustomers(A.ctx.tech, { sourceId: manual.id, targetId: firm.id, confirm: true }), "forbidden", "Monteur darf nicht zusammenführen"); await expectCode(() => mergeCustomers(B.ctx.backoffice, { sourceId: foreign.id, targetId: firm.id, confirm: true }), "not_found", "Mandant B kann nicht mit Kunden von A zusammenführen"); const merged = await mergeCustomers(bo, { sourceId: manual.id, targetId: firm.id, confirm: true }); ok(!!merged && (await prisma.customer.findUniqueOrThrow({ where: { id: manual.id } })).mergedIntoId === firm.id, "mit Bestätigung zusammengeführt"); section("6. Mandantentrennung und Rollen"); const fromB = await findDuplicateCustomers(B.ctx.backoffice, { customerNumber: "K-50001", companyName: "Musterbau Haustechnik GmbH", email: "erika@beispiel.example" }); ok(fromB.every((c) => c.customerId === foreign.id), "Dublettenprüfung in Mandant B findet nur B-Kunden"); ok((await findDuplicateCustomers(A.ctx.outsider, { customerNumber: "K-50001" })).length === 0, "Monteur ohne Aufträge findet keine Kunden"); await expectCode(() => getImportDetail(A.ctx.tech, i3.job.id), "forbidden", "Monteur: Prüfmaske → forbidden"); await expectCode(() => confirmImport(B.ctx.backoffice, i3.job.id, extractionToForm(d3.extraction.fields)), "not_found", "Mandant B: Import von A bestätigen → not_found"); });