import type { TenantDb } from "@/server/db"; import type { DuplicateCandidate, SiteCandidate } from "@/lib/imports/extraction"; /** * STUB (lane L3) for the L1 contract `src/lib/customers/duplicates.ts#findDuplicateCustomers(db, candidate) → Candidate[]` * (ARCHITEKTUR §6, spec §7.3). Same signature and result shape `{ customerId, score, reasons[] }`; * replace the import in services/imports/process.ts after the L1 merge. * * Compares customer number, company/person name, address, e-mail and phone. Never merges — * it only proposes candidates; the backoffice decides (US-003). */ export type CustomerCandidateInput = { 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; }; const LEGAL_FORMS = /\b(gmbh|mbh|ag|kg|ohg|gbr|ug|e\.?\s?k|e\.?\s?v|co|haftungsbeschränkt|und|&)\b/g; export function normalizeCompany(v: string | null | undefined): string { return (v ?? "") .toLowerCase() .replace(/[.,;:()"'`´+/-]/g, " ") .replace(LEGAL_FORMS, " ") .replace(/\s+/g, " ") .trim(); } export function normalizeStreet(v: string | null | undefined): string { return (v ?? "") .toLowerCase() .replace(/ß/g, "ss") .replace(/strasse|str\./g, "str") .replace(/[^a-z0-9äöü]/g, ""); } const digits = (v: string | null | undefined) => (v ?? "").replace(/\D/g, "").replace(/^49/, "0").replace(/^00/, "0"); const lc = (v: string | null | undefined) => (v ?? "").trim().toLowerCase(); export async function findDuplicateCustomers(db: TenantDb, candidate: CustomerCandidateInput): Promise { const company = normalizeCompany(candidate.companyName); const firstWord = company.split(" ").find((w) => w.length >= 3); const or: object[] = []; if (candidate.customerNumber) or.push({ customerNumber: candidate.customerNumber.trim() }); if (candidate.email) or.push({ email: { equals: candidate.email.trim(), mode: "insensitive" } }); if (firstWord) or.push({ companyName: { contains: firstWord, mode: "insensitive" } }); if (candidate.lastName) or.push({ lastName: { equals: candidate.lastName.trim(), mode: "insensitive" } }); if (candidate.postalCode) or.push({ postalCode: candidate.postalCode.trim() }); if (candidate.phone) or.push({ phone: { not: null } }); if (or.length === 0) return []; const rows = await db.customer.findMany({ where: { deletedAt: null, status: { not: "merged" }, OR: or }, select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, street: true, houseNumber: true, postalCode: true, email: true, phone: true, mobile: true }, take: 200, }); const out: DuplicateCandidate[] = []; for (const c of rows) { let score = 0; const reasons: string[] = []; if (candidate.customerNumber && c.customerNumber && c.customerNumber.trim() === candidate.customerNumber.trim()) { score += 0.6; reasons.push("customer_number"); } const cc = normalizeCompany(c.companyName); if (company && cc) { if (cc === company) { score += 0.4; reasons.push("company_name"); } else if (cc.includes(company) || company.includes(cc)) { score += 0.25; reasons.push("company_name_similar"); } } if (candidate.lastName && lc(c.lastName) === lc(candidate.lastName) && (!candidate.firstName || lc(c.firstName) === lc(candidate.firstName))) { score += 0.3; reasons.push("person_name"); } if (candidate.email && lc(c.email) === lc(candidate.email)) { score += 0.3; reasons.push("email"); } const phone = digits(candidate.phone); if (phone.length >= 6 && [c.phone, c.mobile].some((p) => digits(p) === phone)) { score += 0.2; reasons.push("phone"); } if ( candidate.postalCode && c.postalCode === candidate.postalCode.trim() && normalizeStreet(c.street) !== "" && normalizeStreet(c.street) === normalizeStreet(candidate.street) ) { score += 0.25; reasons.push("address"); } if (score >= 0.25) out.push({ customerId: c.id, score: Math.min(1, Math.round(score * 100) / 100), reasons }); } return out.sort((a, b) => b.score - a.score).slice(0, 5); } /** Sites of the candidate customers at the same address (lane L3, spec §9.3 step 7). */ export async function findSiteCandidates( db: TenantDb, customerIds: string[], address: { name?: string | null; street?: string | null; houseNumber?: string | null; postalCode?: string | null } | null, ): Promise { if (!customerIds.length || !address || (!address.street && !address.name)) return []; const sites = await db.site.findMany({ where: { customerId: { in: customerIds }, deletedAt: null }, select: { id: true, customerId: true, name: true, street: true, houseNumber: true, postalCode: true }, take: 200, }); const out: SiteCandidate[] = []; for (const s of sites) { let score = 0; const reasons: string[] = []; if (address.street && normalizeStreet(s.street) === normalizeStreet(address.street) && (!address.postalCode || s.postalCode === address.postalCode)) { score += 0.6; reasons.push("address"); if (address.houseNumber && lc(s.houseNumber) === lc(address.houseNumber)) { score += 0.3; reasons.push("house_number"); } } if (address.name && lc(s.name) === lc(address.name)) { score += 0.3; reasons.push("site_name"); } if (score >= 0.3) out.push({ siteId: s.id, customerId: s.customerId, name: s.name, score: Math.min(1, Math.round(score * 100) / 100), reasons }); } return out.sort((a, b) => b.score - a.score).slice(0, 5); }