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