- 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>
255 lines
10 KiB
TypeScript
255 lines
10 KiB
TypeScript
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;
|
|
}
|