// Customer duplicate detection — pure, client-safe normalization and scoring (spec §7.3, US-003). // The DB lookup lives in src/server/services/customers/duplicates.ts#findDuplicateCustomers. // Used by lane "imports" (review mask) and the manual create flow. NEVER merges automatically. export type DuplicateReason = "customer_number" | "company_name" | "address" | "email" | "phone"; /** Fields of a new/imported customer that are compared against existing customers. */ export type DuplicateCandidateInput = { customerNumber?: string | null; companyName?: string | null; firstName?: string | null; lastName?: string | null; street?: string | null; houseNumber?: string | null; postalCode?: string | null; city?: string | null; email?: string | null; phone?: string | null; mobile?: string | null; }; export type DuplicateMatch = { score: number; reasons: DuplicateReason[] }; /** Minimum score for a record to be reported as "possible duplicate". */ export const DUPLICATE_THRESHOLD = 0.4; /** Signal weights; combined as probabilistic OR: 1 - Π(1 - w). */ export const DUPLICATE_WEIGHTS = { customerNumber: 1, email: 0.6, phone: 0.5, companyExact: 0.6, companySimilar: 0.45, addressExact: 0.4, addressStreetOnly: 0.25, } as const; // Legal forms, longest first so "gmbh & co kg" is removed before "gmbh"/"kg". const LEGAL_FORMS = [ "gmbh & co. kgaa", "gmbh & co. kg", "gmbh & co kg", "gmbh und co kg", "ug (haftungsbeschraenkt)", "ug haftungsbeschraenkt", "e. k.", "e.k.", "e. v.", "e.v.", "kgaa", "gmbh", "mbh", "ohg", "gbr", "partg", "ltd.", "ltd", "inc.", "inc", "ag", "kg", "ug", "se", "ek", "ev", ]; /** Lowercase, transliterate German umlauts, strip remaining diacritics, collapse whitespace. */ export function normalizeText(value: string | null | undefined): string { if (!value) return ""; return value .toLowerCase() .replace(/ä/g, "ae") .replace(/ö/g, "oe") .replace(/ü/g, "ue") .replace(/ß/g, "ss") .normalize("NFKD") .replace(/\p{M}+/gu, "") .replace(/\s+/g, " ") .trim(); } function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** Company name without legal form and punctuation, e.g. "Müller GmbH & Co. KG" → "mueller". */ export function normalizeCompanyName(value: string | null | undefined): string { let s = normalizeText(value); if (!s) return ""; for (const form of LEGAL_FORMS) { s = s.replace(new RegExp(`(^|[\\s,])${escapeRegExp(form)}(?=$|[\\s,])`, "g"), " "); } return s .replace(/&/g, " ") .replace(/\bund\b/g, " ") .replace(/[^a-z0-9]+/g, " ") .replace(/\s+/g, " ") .trim(); } /** Street with unified abbreviation: "Hafenstraße" / "Hafen-Str." / "Hafen Strasse" → "hafenstr". */ export function normalizeStreet(value: string | null | undefined): string { return normalizeText(value) .replace(/strasse\b|str\.?(?=\s|$)/g, "str") .replace(/[^a-z0-9]+/g, ""); } export function normalizeHouseNumber(value: string | null | undefined): string { return normalizeText(value).replace(/[^a-z0-9]+/g, ""); } export function normalizePostalCode(value: string | null | undefined): string { return (value ?? "").replace(/\s+/g, "").toUpperCase(); } export function normalizeEmail(value: string | null | undefined): string { return (value ?? "").trim().toLowerCase(); } /** * Phone digits only; international German prefix unified to national form * (+49 40 … / 0049 40 … / 040 … → "040…"). Numbers with < 6 digits are ignored. */ export function normalizePhone(value: string | null | undefined): string { let digits = (value ?? "").replace(/\D+/g, ""); if (digits.startsWith("0049")) digits = "0" + digits.slice(4); else if (digits.startsWith("49") && (value ?? "").trim().startsWith("+")) digits = "0" + digits.slice(2); return digits.length >= 6 ? digits : ""; } /** Display/compare name: company without legal form, otherwise "first last". */ export function normalizedPartyName(c: Pick): string { const company = normalizeCompanyName(c.companyName); if (company) return company; return normalizeText([c.firstName, c.lastName].filter(Boolean).join(" ")).replace(/[^a-z0-9 ]+/g, ""); } function bigrams(s: string): Map { const compact = s.replace(/\s+/g, " "); const map = new Map(); for (let i = 0; i < compact.length - 1; i++) { const g = compact.slice(i, i + 2); map.set(g, (map.get(g) ?? 0) + 1); } return map; } /** Sørensen–Dice coefficient over character bigrams (0..1). */ export function nameSimilarity(a: string, b: string): number { if (!a || !b) return 0; if (a === b) return 1; if (a.length < 2 || b.length < 2) return 0; const ba = bigrams(a); const bb = bigrams(b); let overlap = 0; for (const [g, n] of ba) overlap += Math.min(n, bb.get(g) ?? 0); const total = a.length - 1 + (b.length - 1); return (2 * overlap) / total; } /** Score one existing customer against a candidate. Pure — safe for client and tests. */ export function scoreDuplicate(candidate: DuplicateCandidateInput, existing: DuplicateCandidateInput): DuplicateMatch { const reasons: DuplicateReason[] = []; const weights: number[] = []; const numA = normalizeText(candidate.customerNumber).replace(/\s+/g, ""); const numB = normalizeText(existing.customerNumber).replace(/\s+/g, ""); if (numA && numA === numB) { reasons.push("customer_number"); weights.push(DUPLICATE_WEIGHTS.customerNumber); } const nameA = normalizedPartyName(candidate); const nameB = normalizedPartyName(existing); if (nameA && nameB) { if (nameA === nameB) { reasons.push("company_name"); weights.push(DUPLICATE_WEIGHTS.companyExact); } else if (nameSimilarity(nameA, nameB) >= 0.8) { reasons.push("company_name"); weights.push(DUPLICATE_WEIGHTS.companySimilar); } } const streetA = normalizeStreet(candidate.street); const streetB = normalizeStreet(existing.street); const plzA = normalizePostalCode(candidate.postalCode); const plzB = normalizePostalCode(existing.postalCode); if (streetA && streetA === streetB && plzA && plzA === plzB) { const hnA = normalizeHouseNumber(candidate.houseNumber); const hnB = normalizeHouseNumber(existing.houseNumber); reasons.push("address"); weights.push(hnA && hnA === hnB ? DUPLICATE_WEIGHTS.addressExact : DUPLICATE_WEIGHTS.addressStreetOnly); } const mailA = normalizeEmail(candidate.email); if (mailA && mailA === normalizeEmail(existing.email)) { reasons.push("email"); weights.push(DUPLICATE_WEIGHTS.email); } const phonesA = [normalizePhone(candidate.phone), normalizePhone(candidate.mobile)].filter(Boolean); const phonesB = new Set([normalizePhone(existing.phone), normalizePhone(existing.mobile)].filter(Boolean)); if (phonesA.some((p) => phonesB.has(p))) { reasons.push("phone"); weights.push(DUPLICATE_WEIGHTS.phone); } const score = 1 - weights.reduce((acc, w) => acc * (1 - w), 1); return { score: Math.round(Math.min(1, score) * 1000) / 1000, reasons }; }