Files
craftvia/scripts/test-e2e-import-duplicates.ts
T

121 lines
9.0 KiB
TypeScript

// 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<WorkOrderExtraction>) {
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");
});