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
+254
View File
@@ -0,0 +1,254 @@
import { z } from "zod";
import type { WorkOrderExtraction } from "@/server/ai/providers";
import { type ExtractionFieldKey, LOW_CONFIDENCE } from "./extraction";
import { isValidEmail, isValidGermanPostalCode, toIsoDate } from "./plausibility";
/**
* Review mask model (spec §9.6): mapping extraction → form, the form schema used by the
* confirm action/API, and the correction diff (extraction ↔ confirmed values, spec §9.7).
* Client-safe.
*/
// ---------- form schema ----------
const text = (max: number) =>
z
.string()
.max(max)
.transform((v) => v.trim())
.optional()
.default("");
const dateStr = z
.string()
.optional()
.default("")
.refine((v) => v.trim() === "" || toIsoDate(v) !== null, { message: "date_invalid" })
.transform((v) => toIsoDate(v) ?? "");
export const reviewPositionSchema = z.object({
name: z.string().trim().min(1).max(300),
articleNumber: text(100),
quantity: z
.union([z.number(), z.string()])
.optional()
.transform((v) => {
if (v === undefined || v === "") return null;
const n = typeof v === "number" ? v : Number(String(v).replace(",", "."));
return Number.isFinite(n) ? n : Number.NaN;
})
.refine((v) => v === null || (!Number.isNaN(v) && v >= 0), { message: "quantity_invalid" }),
unit: text(30),
asMaterial: z.boolean().optional().default(false),
});
export const reviewFormSchema = z
.object({
customerMode: z.enum(["existing", "new"]),
customerId: z.string().optional().default(""),
customer: z.object({
customerNumber: text(50),
companyName: text(200),
firstName: text(100),
lastName: text(100),
street: text(200),
houseNumber: text(20),
postalCode: text(10),
city: text(100),
country: text(2).transform((v) => (v ? v.toUpperCase() : "DE")),
phone: text(50),
email: text(200),
}),
siteMode: z.enum(["existing", "new", "none"]),
siteId: z.string().optional().default(""),
site: z.object({
name: text(200),
street: text(200),
houseNumber: text(20),
postalCode: text(10),
city: text(100),
country: text(2).transform((v) => (v ? v.toUpperCase() : "DE")),
}),
contact: z.object({ name: text(200), phone: text(50), email: text(200) }),
order: z.object({
title: z.string().trim().min(1, { message: "title_required" }).max(200),
externalOrderNumber: text(100),
offerNumber: text(100),
description: text(10_000),
plannedStart: dateStr,
plannedEnd: dateStr,
notes: text(10_000),
}),
positions: z.array(reviewPositionSchema).max(500).default([]),
})
.superRefine((v, ctx) => {
if (v.customerMode === "existing" && !v.customerId) ctx.addIssue({ code: "custom", path: ["customerId"], message: "customer_required" });
if (v.customerMode === "new" && !v.customer.companyName && !v.customer.lastName) {
ctx.addIssue({ code: "custom", path: ["customer", "companyName"], message: "customer_name_required" });
}
if (v.siteMode === "existing" && !v.siteId) ctx.addIssue({ code: "custom", path: ["siteId"], message: "site_required" });
if (v.siteMode === "new" && !v.site.name && !v.site.street) ctx.addIssue({ code: "custom", path: ["site", "name"], message: "site_name_required" });
for (const [path, email] of [[["customer", "email"], v.customer.email], [["contact", "email"], v.contact.email]] as const) {
if (email && !isValidEmail(email)) ctx.addIssue({ code: "custom", path: [...path], message: "email_invalid" });
}
for (const [path, pc, country] of [
[["customer", "postalCode"], v.customer.postalCode, v.customer.country],
[["site", "postalCode"], v.site.postalCode, v.site.country],
] as const) {
if (pc && country === "DE" && !isValidGermanPostalCode(pc)) ctx.addIssue({ code: "custom", path: [...path], message: "postal_code_invalid" });
}
if (v.order.plannedStart && v.order.plannedEnd && v.order.plannedEnd < v.order.plannedStart) {
ctx.addIssue({ code: "custom", path: ["order", "plannedEnd"], message: "end_before_start" });
}
});
export type ReviewFormInput = z.input<typeof reviewFormSchema>;
export type ReviewForm = z.output<typeof reviewFormSchema>;
// ---------- form field ↔ extraction field ----------
/** Form path → extraction field that fed it (for confidence badges and corrections). */
export const FORM_FIELD_SOURCE = {
"customer.customerNumber": "customerNumber",
"customer.companyName": "companyName",
"customer.firstName": "customerFirstName",
"customer.lastName": "customerLastName",
"customer.street": "customerAddress",
"customer.houseNumber": "customerAddress",
"customer.postalCode": "customerAddress",
"customer.city": "customerAddress",
"customer.country": "customerAddress",
"customer.phone": "phone",
"customer.email": "email",
"site.name": "siteName",
"site.street": "siteAddress",
"site.houseNumber": "siteAddress",
"site.postalCode": "siteAddress",
"site.city": "siteAddress",
"site.country": "siteAddress",
"contact.name": "contactName",
"contact.phone": "phone",
"contact.email": "email",
"order.title": "title",
"order.externalOrderNumber": "orderNumber",
"order.offerNumber": "offerNumber",
"order.description": "description",
"order.plannedStart": "plannedStart",
"order.plannedEnd": "plannedEnd",
"order.notes": "notes",
} as const satisfies Record<string, ExtractionFieldKey>;
export type FormFieldPath = keyof typeof FORM_FIELD_SOURCE;
export type FieldMeta = { confidence: number; source?: string; uncertain: boolean };
/** Confidence/source per form field; `uncertain` = value present-or-expected but < 0.8. */
export function formFieldMeta(ex: WorkOrderExtraction): Record<FormFieldPath, FieldMeta> {
const out = {} as Record<FormFieldPath, FieldMeta>;
for (const [path, key] of Object.entries(FORM_FIELD_SOURCE) as [FormFieldPath, ExtractionFieldKey][]) {
const f = ex[key];
out[path] = { confidence: f.confidence, source: f.source, uncertain: f.value !== null && f.confidence < LOW_CONFIDENCE };
}
return out;
}
function deriveTitle(ex: WorkOrderExtraction): string {
if (ex.title.value) return ex.title.value.slice(0, 200);
const firstLine = ex.description.value?.split(/\r?\n/)[0]?.trim();
if (firstLine) return firstLine.slice(0, 80);
return "";
}
/** Initial review form from an extraction (existing customer/site preselected if given). */
export function extractionToForm(
ex: WorkOrderExtraction,
opts: { customerId?: string | null; siteId?: string | null } = {},
): ReviewFormInput {
const ca = ex.customerAddress.value ?? {};
const sa = ex.siteAddress.value ?? {};
const hasSite = Boolean(ex.siteName.value || sa.street || sa.city);
return {
customerMode: opts.customerId ? "existing" : "new",
customerId: opts.customerId ?? "",
customer: {
customerNumber: ex.customerNumber.value ?? "",
companyName: ex.companyName.value ?? "",
firstName: ex.customerFirstName.value ?? "",
lastName: ex.customerLastName.value ?? "",
street: ca.street ?? "",
houseNumber: ca.houseNumber ?? "",
postalCode: ca.postalCode ?? "",
city: ca.city ?? "",
country: (ca.country ?? "DE").slice(0, 2).toUpperCase(),
phone: ex.phone.value ?? "",
email: ex.email.value ?? "",
},
siteMode: opts.siteId ? "existing" : hasSite ? "new" : "none",
siteId: opts.siteId ?? "",
site: {
name: ex.siteName.value ?? (sa.street ? [sa.street, sa.houseNumber].filter(Boolean).join(" ") : ""),
street: sa.street ?? "",
houseNumber: sa.houseNumber ?? "",
postalCode: sa.postalCode ?? "",
city: sa.city ?? "",
country: (sa.country ?? "DE").slice(0, 2).toUpperCase(),
},
contact: {
name: ex.contactName.value ?? "",
phone: ex.contactName.value ? ex.phone.value ?? "" : "",
email: ex.contactName.value ? ex.email.value ?? "" : "",
},
order: {
title: deriveTitle(ex),
externalOrderNumber: ex.orderNumber.value ?? "",
offerNumber: ex.offerNumber.value ?? "",
description: ex.description.value ?? "",
plannedStart: toIsoDate(ex.plannedStart.value) ?? "",
plannedEnd: toIsoDate(ex.plannedEnd.value) ?? "",
notes: ex.notes.value ?? "",
},
positions: (ex.positions.value ?? []).map((p) => ({
name: p.name,
articleNumber: p.articleNumber ?? "",
quantity: p.quantity ?? "",
unit: p.unit ?? "",
asMaterial: Boolean(p.isMaterial && p.quantity != null),
})),
};
}
// ---------- corrections ----------
export type Corrections = Record<string, { from: unknown; to: unknown }>;
function get(obj: unknown, path: string): unknown {
return path.split(".").reduce<unknown>((o, k) => (o && typeof o === "object" ? (o as Record<string, unknown>)[k] : undefined), obj);
}
const norm = (v: unknown) => (v === undefined || v === null ? "" : typeof v === "string" ? v.trim() : v);
/**
* Diff between the form proposed from the extraction and the confirmed form. Only entered
* data counts (customer/site decisions are recorded separately) — a field that was edited
* shows up as `{ from, to }` under its form path; positions are compared as a whole.
*/
export function computeCorrections(ex: WorkOrderExtraction, confirmed: ReviewForm): Corrections {
const proposed = extractionToForm(ex);
const out: Corrections = {};
for (const path of Object.keys(FORM_FIELD_SOURCE)) {
const from = norm(get(proposed, path));
const to = norm(get(confirmed, path));
if (from !== to) out[path] = { from, to };
}
const simplify = (ps: Array<{ name: string; articleNumber?: string; quantity?: unknown; unit?: string }>) =>
ps.map((p) => ({
name: p.name.trim(),
articleNumber: norm(p.articleNumber),
quantity: p.quantity === "" || p.quantity == null ? null : Number(p.quantity),
unit: norm(p.unit),
}));
const fromPos = simplify(proposed.positions ?? []);
const toPos = simplify(confirmed.positions);
if (JSON.stringify(fromPos) !== JSON.stringify(toPos)) out.positions = { from: fromPos, to: toPos };
return out;
}