// L1 Stammdaten — Dublettenprüfung (Spec §7.3, US-003): // (1) Normalisierung: Kleinschreibung, Umlaute, Rechtsformen, Straße/Str., Telefon nur Ziffern // (2) Scoring: Kundennummer, Name, Adresse, E-Mail, Telefon; Schwelle // (3) findDuplicateCustomers gegen die DB inkl. Mandantentrennung und Monteur-Scope // // Lauf: npx tsx scripts/test-stammdaten-duplicates.ts import "dotenv/config"; // must run before any module that constructs the Prisma client import { DUPLICATE_THRESHOLD, nameSimilarity, normalizeCompanyName, normalizePhone, normalizeStreet, normalizeText, scoreDuplicate, } from "../src/lib/customers/duplicates"; import { findDuplicateCustomers } from "../src/server/services/customers/duplicates"; import { checker, cleanupTenants, createTenant, createUser, ctxFor, disconnect, prisma } from "./lib-stammdaten-fixtures"; const SLUG_A = "zz-l1-dup-a"; const SLUG_B = "zz-l1-dup-b"; const DOMAIN = "zz-l1-dup.test"; const c = checker("Dublettenprüfung"); async function main() { console.log("— (1) Normalisierung —"); c.ok(normalizeText(" ÄÖÜ ßtraße ") === "aeoeue sstrasse", "Umlaute/ß transliteriert, getrimmt, klein"); c.ok(normalizeCompanyName("Müller Haustechnik GmbH & Co. KG") === "mueller haustechnik", "Rechtsform „GmbH & Co. KG“ entfernt"); c.ok(normalizeCompanyName("MUELLER Haustechnik AG") === "mueller haustechnik", "Rechtsform „AG“ entfernt, Groß/Klein egal"); c.ok(normalizeCompanyName("Bauer e.K.") === "bauer", "Rechtsform „e.K.“ entfernt"); c.ok(normalizeCompanyName("AGRAR Service") === "agrar service", "„AG“ innerhalb eines Wortes bleibt erhalten"); c.ok(normalizeStreet("Hafenstraße") === "hafenstr" && normalizeStreet("Hafen-Str.") === "hafenstr" && normalizeStreet("Hafen Strasse") === "hafenstr", "Straße/Str./Strasse vereinheitlicht"); c.ok(normalizePhone("+49 40 123456-0") === "0401234560" && normalizePhone("040 / 123 456 0") === "0401234560", "Telefon nur Ziffern, +49 → 0"); c.ok(normalizePhone("12") === "", "zu kurze Nummern werden ignoriert"); console.log("\n— (2) Scoring —"); const existing = { customerNumber: "K-00042", companyName: "Müller Haustechnik GmbH", street: "Hafenstraße", houseNumber: "12", postalCode: "20457", email: "info@mueller.example", phone: "+49 40 123456-0", }; const byNumber = scoreDuplicate({ customerNumber: "k-00042" }, existing); c.ok(byNumber.score === 1 && byNumber.reasons.includes("customer_number"), "gleiche Kundennummer → Score 1"); const byNameAddress = scoreDuplicate({ companyName: "Mueller Haustechnik", street: "Hafen-Str.", houseNumber: "12", postalCode: "20457" }, existing); c.ok(Math.abs(byNameAddress.score - 0.76) < 0.001 && byNameAddress.reasons.join() === "company_name,address", `Name + Adresse → 0.76 (${byNameAddress.score})`); const byEmail = scoreDuplicate({ email: "INFO@mueller.example " }, existing); c.ok(byEmail.score === 0.6 && byEmail.reasons.join() === "email", "E-Mail (Groß/Klein, Leerzeichen) → 0.6"); const byPhone = scoreDuplicate({ mobile: "040 1234560" }, existing); c.ok(byPhone.score === 0.5 && byPhone.reasons.join() === "phone", "Telefon gegen Mobil-Eingabe → 0.5"); const similar = scoreDuplicate({ companyName: "Müller Haustechnick" }, existing); c.ok(similar.reasons.includes("company_name") && similar.score === 0.45, `ähnlicher Name (Tippfehler) → 0.45 (${similar.score})`); c.ok(nameSimilarity("abc", "xyz") === 0, "unähnliche Namen → Ähnlichkeit 0"); const unrelated = scoreDuplicate({ companyName: "Nordlicht Elektro", postalCode: "10115", street: "Invalidenstraße" }, existing); c.ok(unrelated.score === 0 && unrelated.score < DUPLICATE_THRESHOLD, "fremder Kunde → Score 0 (unter Schwelle)"); const all = scoreDuplicate({ ...existing }, existing); c.ok(all.score === 1 && all.reasons.length === 5, "alle Merkmale gleich → 5 Gründe, Score 1"); console.log("\n— (3) findDuplicateCustomers (DB) —"); await cleanupTenants([SLUG_A, SLUG_B], DOMAIN); const tA = await createTenant(SLUG_A, "L1 Dubletten A"); const tB = await createTenant(SLUG_B, "L1 Dubletten 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 custA = await prisma.customer.create({ data: { tenantId: tA.id, ...existing } }); const otherA = await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-00043", companyName: "Schmidt Bedachung", city: "Kiel", postalCode: "24103" } }); const mergedA = await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-00044", companyName: "Müller Haustechnik", status: "merged" } }); const custB = await prisma.customer.create({ data: { tenantId: tB.id, ...existing } }); const ctxA = ctxFor(tA.id, boA.id, "backoffice"); const ctxB = ctxFor(tB.id, boB.id, "backoffice"); const hits = await findDuplicateCustomers(ctxA, { companyName: "Mueller Haustechnik", street: "Hafen-Str.", houseNumber: "12", postalCode: "20457" }); c.ok(hits.length === 1 && hits[0].customerId === custA.id, "Treffer in Mandant A gefunden (Name + Adresse)"); c.ok(hits[0]?.score >= 0.76 && hits[0]?.displayName === existing.companyName, "Treffer mit Score und Anzeigename"); c.ok(!hits.some((h) => h.customerId === custB.id), "Mandantentrennung: gleicher Kunde in Mandant B wird NICHT gefunden"); c.ok(!hits.some((h) => h.customerId === mergedA.id), "zusammengeführte Kunden werden nicht vorgeschlagen"); c.ok(!hits.some((h) => h.customerId === otherA.id), "unähnlicher Kunde nicht vorgeschlagen"); const phoneHits = await findDuplicateCustomers(ctxA, { phone: "040 1234560" }); c.ok(phoneHits.length === 1 && phoneHits[0].customerId === custA.id && phoneHits[0].reasons.join() === "phone", "Treffer nur über formatiert gespeicherte Telefonnummer"); const numberHits = await findDuplicateCustomers(ctxA, { customerNumber: "k-00042" }); c.ok(numberHits[0]?.customerId === custA.id && numberHits[0]?.score === 1, "Treffer über Kundennummer (Score 1)"); const excluded = await findDuplicateCustomers(ctxA, { customerNumber: "K-00042" }, { excludeId: custA.id }); c.ok(excluded.length === 0, "excludeId schließt den eigenen Datensatz aus"); const hitsB = await findDuplicateCustomers(ctxB, { customerNumber: "K-00043" }); c.ok(hitsB.length === 0, "Mandant B findet Kunden von A nicht über deren Kundennummer"); const techHits = await findDuplicateCustomers(ctxFor(tA.id, techA.id, "technician"), { customerNumber: "K-00042" }); c.ok(techHits.length === 0, "Monteur ohne sichtbaren Auftrag erhält keine Kundentreffer (Scope)"); c.ok((await findDuplicateCustomers(ctxA, {})).length === 0, "leerer Kandidat → keine Treffer"); await cleanupTenants([SLUG_A, SLUG_B], DOMAIN); } main() .catch(async (err) => { console.error(err); c.ok(false, "unerwarteter Fehler"); }) .finally(async () => { await cleanupTenants([SLUG_A, SLUG_B], DOMAIN).catch(() => {}); const failures = c.finish(); await disconnect(); process.exit(failures === 0 ? 0 : 1); });