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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user