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