Merge lane/import in feature/craftvia-mvp

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:22:07 +02:00
co-authored by Claude Opus 5
38 changed files with 4204 additions and 4 deletions
+231
View File
@@ -0,0 +1,231 @@
import { z } from "zod";
import type { ExtractedField, WorkOrderExtraction } from "@/server/ai/providers";
/**
* Client-safe extraction model for the order import (spec §9.4/§9.5).
*
* - `EXTRACTION_FIELDS`: canonical field order (review mask, corrections, tests).
* - `workOrderExtractionSchema`: Zod validation of provider output (null → undefined for sub-objects).
* - `extractionJsonSchema()`: JSON schema for Claude structured outputs (all keys required,
* nullable via anyOf, additionalProperties:false — the structured-output subset).
* - `StoredExtraction`: shape persisted in ImportJob.extraction.
*/
export const EXTRACTION_VERSION = "craftvia-import-v1";
export const EXTRACTION_FIELDS = [
"orderNumber",
"offerNumber",
"customerNumber",
"companyName",
"customerFirstName",
"customerLastName",
"customerAddress",
"siteName",
"siteAddress",
"contactName",
"phone",
"email",
"orderDate",
"documentDate",
"plannedStart",
"plannedEnd",
"title",
"description",
"positions",
"notes",
"totalAmount",
"references",
] as const satisfies readonly (keyof WorkOrderExtraction)[];
export type ExtractionFieldKey = (typeof EXTRACTION_FIELDS)[number];
export type Address = { street?: string; houseNumber?: string; postalCode?: string; city?: string; country?: string };
export type Position = { position?: string; name: string; articleNumber?: string; quantity?: number; unit?: string; isMaterial?: boolean };
const confidence = z.number().min(0).max(1).catch(0);
const source = z.string().nullish().transform((v) => v ?? undefined);
const optStr = z.string().nullish().transform((v) => (v == null || v.trim() === "" ? undefined : v.trim()));
function field<T extends z.ZodTypeAny>(value: T) {
return z.object({ value: value.nullable().catch(null), confidence, source });
}
const addressSchema = z.object({
street: optStr,
houseNumber: optStr,
postalCode: optStr,
city: optStr,
country: optStr,
});
const positionSchema = z.object({
position: optStr,
name: z.string().min(1),
articleNumber: optStr,
quantity: z.number().nullish().transform((v) => (v == null || Number.isNaN(v) ? undefined : v)),
unit: optStr,
isMaterial: z.boolean().nullish().transform((v) => v ?? undefined),
});
const str = z.string().transform((v) => v.trim());
export const workOrderExtractionSchema = z.object({
orderNumber: field(str),
offerNumber: field(str),
customerNumber: field(str),
companyName: field(str),
customerFirstName: field(str),
customerLastName: field(str),
customerAddress: field(addressSchema),
siteName: field(str),
siteAddress: field(addressSchema),
contactName: field(str),
phone: field(str),
email: field(str),
orderDate: field(str),
documentDate: field(str),
plannedStart: field(str),
plannedEnd: field(str),
title: field(str),
description: field(str),
positions: field(z.array(positionSchema)),
notes: field(str),
totalAmount: field(z.number()),
references: field(z.array(z.string())),
});
/** Parse arbitrary provider output into a WorkOrderExtraction (throws on structurally invalid input). */
export function parseExtraction(raw: unknown): WorkOrderExtraction {
const parsed = workOrderExtractionSchema.parse(raw);
// Empty strings count as "not found".
const out = {} as Record<string, ExtractedField<unknown>>;
for (const key of EXTRACTION_FIELDS) {
const f = parsed[key] as ExtractedField<unknown>;
const empty = f.value === null || (typeof f.value === "string" && f.value === "");
out[key] = { value: empty ? null : f.value, confidence: empty ? 0 : f.confidence, ...(f.source ? { source: f.source } : {}) };
}
return out as unknown as WorkOrderExtraction;
}
/** Extraction with every field empty (no provider / manual entry). */
export function emptyExtraction(): WorkOrderExtraction {
const out = {} as Record<string, ExtractedField<unknown>>;
for (const key of EXTRACTION_FIELDS) out[key] = { value: null, confidence: 0 };
return out as unknown as WorkOrderExtraction;
}
// ---------- JSON schema for structured outputs ----------
type Json = Record<string, unknown>;
const nullable = (schema: Json): Json => ({ anyOf: [schema, { type: "null" }] });
const obj = (properties: Record<string, Json>): Json => ({
type: "object",
properties,
required: Object.keys(properties),
additionalProperties: false,
});
const addressJson = obj({
street: nullable({ type: "string" }),
houseNumber: nullable({ type: "string" }),
postalCode: nullable({ type: "string" }),
city: nullable({ type: "string" }),
country: nullable({ type: "string", description: "ISO 3166-1 alpha-2, e.g. DE" }),
});
const positionJson = obj({
position: nullable({ type: "string" }),
name: { type: "string" },
articleNumber: nullable({ type: "string" }),
quantity: nullable({ type: "number" }),
unit: nullable({ type: "string" }),
isMaterial: nullable({ type: "boolean" }),
});
const fieldJson = (value: Json, description: string): Json => ({
...obj({
value: nullable(value),
confidence: { type: "number", description: "0..1 — how certain the value is correct" },
source: nullable({ type: "string", description: "short verbatim snippet from the document" }),
}),
description,
});
const FIELD_DESCRIPTIONS: Record<ExtractionFieldKey, [Json, string]> = {
orderNumber: [{ type: "string" }, "Order / confirmation number of the issuer (Auftragsnummer)"],
offerNumber: [{ type: "string" }, "Offer number (Angebotsnummer)"],
customerNumber: [{ type: "string" }, "Customer number (Kundennummer)"],
companyName: [{ type: "string" }, "Company name of the customer (the ordering party, NOT the craft business)"],
customerFirstName: [{ type: "string" }, "First name if the customer is a private person"],
customerLastName: [{ type: "string" }, "Last name if the customer is a private person"],
customerAddress: [addressJson, "Billing / postal address of the customer"],
siteName: [{ type: "string" }, "Name of the site / construction site (Objekt, Baustelle)"],
siteAddress: [addressJson, "Address where the work is carried out"],
contactName: [{ type: "string" }, "Contact person"],
phone: [{ type: "string" }, "Phone number of the customer or contact"],
email: [{ type: "string" }, "E-mail address of the customer or contact"],
orderDate: [{ type: "string" }, "Order date, ISO 8601 (YYYY-MM-DD)"],
documentDate: [{ type: "string" }, "Document date, ISO 8601 (YYYY-MM-DD)"],
plannedStart: [{ type: "string" }, "Planned start of execution, ISO 8601 (YYYY-MM-DD)"],
plannedEnd: [{ type: "string" }, "Planned end of execution, ISO 8601 (YYYY-MM-DD)"],
title: [{ type: "string" }, "Short title of the job (max. 80 characters)"],
description: [{ type: "string" }, "Description of services (Leistungsbeschreibung)"],
positions: [{ type: "array", items: positionJson }, "Line items with quantities; isMaterial=true for material/articles"],
notes: [{ type: "string" }, "Special notes (access, deadlines, safety)"],
totalAmount: [{ type: "number" }, "Total gross amount in EUR as a number"],
references: [{ type: "array", items: { type: "string" } }, "Other references (project no., purchase order no.)"],
};
/** JSON schema of the complete provider answer: full text + structured fields. */
export function extractionJsonSchema(): Json {
const fields: Record<string, Json> = {};
for (const key of EXTRACTION_FIELDS) {
const [value, description] = FIELD_DESCRIPTIONS[key];
fields[key] = fieldJson(value, description);
}
return obj({
text: { type: "string", description: "Full recognised text of the document (reading order)" },
extraction: obj(fields),
});
}
// ---------- persisted shape ----------
export type PlausibilityHintCode =
| "date_invalid"
| "date_implausible"
| "postal_code_invalid"
| "email_invalid"
| "phone_invalid"
| "end_before_start"
| "manual_entry";
export type PlausibilityHint = { field: ExtractionFieldKey | null; code: PlausibilityHintCode };
export type DuplicateCandidate = { customerId: string; score: number; reasons: string[] };
export type SiteCandidate = { siteId: string; customerId: string; name: string; score: number; reasons: string[] };
export type StoredExtraction = {
fields: WorkOrderExtraction;
hints: PlausibilityHint[];
siteCandidates: SiteCandidate[];
};
export function readStoredExtraction(raw: unknown): StoredExtraction {
const r = (raw ?? {}) as Partial<StoredExtraction>;
let fields: WorkOrderExtraction;
try {
fields = r.fields ? parseExtraction(r.fields) : emptyExtraction();
} catch {
fields = emptyExtraction();
}
return {
fields,
hints: Array.isArray(r.hints) ? r.hints : [],
siteCandidates: Array.isArray(r.siteCandidates) ? r.siteCandidates : [],
};
}
/** Fields below this confidence are marked "unsicher" in the review mask. */
export const LOW_CONFIDENCE = 0.8;
+109
View File
@@ -0,0 +1,109 @@
import type { WorkOrderExtraction } from "@/server/ai/providers";
import type { ExtractionFieldKey, PlausibilityHint } from "./extraction";
/**
* Plausibility check of an extraction (spec §9.3 step 6). Pure and client-safe.
* A violation never removes the value — it lowers the confidence (so the review mask marks
* the field as uncertain) and adds a hint the UI explains.
*/
/** Confidence ceiling for fields that violate a rule. */
export const VIOLATION_CONFIDENCE = 0.4;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
// Digits with common separators, optional leading +; 6..20 digits.
const PHONE_RE = /^\+?[\d\s()/.-]+$/;
const ISO_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
const GERMAN_DATE_RE = /^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})$/;
export function isValidEmail(v: string): boolean {
return EMAIL_RE.test(v.trim());
}
export function isValidPhone(v: string): boolean {
const digits = v.replace(/\D/g, "");
return PHONE_RE.test(v.trim()) && digits.length >= 6 && digits.length <= 20;
}
export function isValidGermanPostalCode(v: string): boolean {
return /^\d{5}$/.test(v.trim());
}
/** Accepts ISO (YYYY-MM-DD) and German (TT.MM.JJJJ) dates; returns a UTC date or null. */
export function parseDate(v: string): Date | null {
const s = v.trim();
let y: number, m: number, d: number;
const iso = ISO_DATE_RE.exec(s.slice(0, 10));
const de = GERMAN_DATE_RE.exec(s);
if (iso) {
[y, m, d] = [Number(iso[1]), Number(iso[2]), Number(iso[3])];
} else if (de) {
[d, m, y] = [Number(de[1]), Number(de[2]), Number(de[3])];
if (y < 100) y += 2000;
} else {
return null;
}
const date = new Date(Date.UTC(y, m - 1, d));
if (date.getUTCFullYear() !== y || date.getUTCMonth() !== m - 1 || date.getUTCDate() !== d) return null;
return date;
}
/** Normalise a date string to YYYY-MM-DD, or null. */
export function toIsoDate(v: string | null | undefined): string | null {
if (!v) return null;
const d = parseDate(v);
return d ? d.toISOString().slice(0, 10) : null;
}
const DATE_FIELDS = ["orderDate", "documentDate", "plannedStart", "plannedEnd"] as const;
const MS_YEAR = 365 * 24 * 3600 * 1000;
export function checkPlausibility(
input: WorkOrderExtraction,
now: Date = new Date(),
): { extraction: WorkOrderExtraction; hints: PlausibilityHint[] } {
const extraction = structuredClone(input);
const hints: PlausibilityHint[] = [];
const flag = (field: ExtractionFieldKey, code: PlausibilityHint["code"]) => {
hints.push({ field, code });
const f = extraction[field];
f.confidence = Math.min(f.confidence, VIOLATION_CONFIDENCE);
};
const dates: Partial<Record<(typeof DATE_FIELDS)[number], Date>> = {};
for (const key of DATE_FIELDS) {
const f = extraction[key];
if (!f.value) continue;
const parsed = parseDate(f.value);
if (!parsed) {
flag(key, "date_invalid");
continue;
}
f.value = parsed.toISOString().slice(0, 10); // normalise German formats
dates[key] = parsed;
// Order/document dates lie in the past (≤ 1 month ahead); execution within −2 … +3 years.
const diff = parsed.getTime() - now.getTime();
const plausible =
key === "orderDate" || key === "documentDate"
? diff <= MS_YEAR / 12 && diff >= -10 * MS_YEAR
: diff >= -2 * MS_YEAR && diff <= 3 * MS_YEAR;
if (!plausible) flag(key, "date_implausible");
}
if (dates.plannedStart && dates.plannedEnd && dates.plannedEnd < dates.plannedStart) {
flag("plannedEnd", "end_before_start");
}
for (const key of ["customerAddress", "siteAddress"] as const) {
const addr = extraction[key].value;
if (!addr?.postalCode) continue;
const country = (addr.country ?? "DE").toUpperCase();
if ((country === "DE" || country === "DEUTSCHLAND") && !isValidGermanPostalCode(addr.postalCode)) {
flag(key, "postal_code_invalid");
}
}
if (extraction.email.value && !isValidEmail(extraction.email.value)) flag("email", "email_invalid");
if (extraction.phone.value && !isValidPhone(extraction.phone.value)) flag("phone", "phone_invalid");
return { extraction, hints };
}
+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;
}
+19
View File
@@ -0,0 +1,19 @@
/** Import status → UI tone/label key (client-safe). Status is never shown by colour alone. */
export const IMPORT_STATUSES = ["uploaded", "processing", "review_required", "confirmed", "failed", "discarded"] as const;
export type ImportStatusKey = (typeof IMPORT_STATUSES)[number];
export const IMPORT_STATUS_TONE: Record<ImportStatusKey, "info" | "warn" | "ok" | "risk" | "mut"> = {
uploaded: "info",
processing: "info",
review_required: "warn",
confirmed: "ok",
failed: "risk",
discarded: "mut",
};
/** Statuses in which the page should poll for progress. */
export const IMPORT_IN_PROGRESS: readonly ImportStatusKey[] = ["uploaded", "processing"];
export const IMPORT_MAX_BYTES = 25 * 1024 * 1024;
export const IMPORT_IMAGE_MAX_BYTES = 15 * 1024 * 1024;
export const IMPORT_MIME_TYPES = ["application/pdf", "image/jpeg", "image/png"] as const;