Merge lane/stammdaten in feature/craftvia-mvp
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
// 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user