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; export type ReviewForm = z.output; // ---------- 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; 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 { const out = {} as Record; 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; function get(obj: unknown, path: string): unknown { return path.split(".").reduce((o, k) => (o && typeof o === "object" ? (o as Record)[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; }