Merge lane/stammdaten in feature/craftvia-mvp

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:38:34 +02:00
co-authored by Claude Opus 5
64 changed files with 7249 additions and 73 deletions
+212
View File
@@ -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 };
}