L10a Qualität & Abnahmetests: E2E-Prozesstests (regulärer Auftrag, mehrtägig, Notdienst, Offline-Sync, Pflichtfotos/Unterschrift, Dubletten, Mandantentrennung systematisch)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
// 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");
|
||||
});
|
||||
Reference in New Issue
Block a user