Kunden (Nummernkreis, Ansprechpartner, vorläufig bestätigen, Zusammenführen mit Bestätigung), Objekte inkl. Historie, Teams mit Mitgliedschaften, Dublettenlogik (lib + Service), API-Kontext/Antwortformat unter src/server/api und die Endpunkte /api/v1/customers, /api/v1/sites, /api/v1/sites/[id]/history. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
213 lines
7.1 KiB
TypeScript
213 lines
7.1 KiB
TypeScript
// 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<DuplicateCandidateInput, "companyName" | "firstName" | "lastName">): 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<string, number> {
|
||
const compact = s.replace(/\s+/g, " ");
|
||
const map = new Map<string, number>();
|
||
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 };
|
||
}
|