L3 Auftragsimport: Extraktion, Plausibilität, Dubletten, Bestätigung (Services + Tests)

- 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>
This commit is contained in:
2026-09-14 12:17:13 +02:00
co-authored by Claude Opus 5
parent bf4456718e
commit 12a764786a
22 changed files with 2592 additions and 1 deletions
@@ -0,0 +1,142 @@
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);
}