Files
craftvia/src/lib/customers/duplicates.ts
T
msolarczekandClaude Opus 5 1f8e6413fe L1 Stammdaten: Services, Dublettenprüfung, Server Actions und API v1
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>
2026-09-14 12:26:07 +02:00

213 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 };
}