- Claude-Extraktion (PDF nativ/Bild, Structured Output, Konfidenzen, Volltext), FakeProvider - Plausibilitätsprüfung, Mapping Extraktion → Formular, Korrektur-Diff - Services Upload/Verarbeitung/Bestätigung/Verwerfen/Neu verarbeiten, Processor import-extraction - Stubs: storeFile (§4.3), findDuplicateCustomers (L1), createWorkOrder (L2) - Tests test-import-rules/-flow/-live, Beispiel-PDFs + Generator Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
143 lines
5.7 KiB
TypeScript
143 lines
5.7 KiB
TypeScript
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<DuplicateCandidate[]> {
|
||
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<SiteCandidate[]> {
|
||
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);
|
||
}
|