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:
2026-09-14 12:17:13 +02:00
co-authored by Claude Opus 5
parent bf4456718e
commit 12a764786a
22 changed files with 2592 additions and 1 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;
+132
View File
@@ -0,0 +1,132 @@
import Anthropic from "@anthropic-ai/sdk";
import { AI_MODEL, getAnthropic } from "@/server/ai/client";
import type { DocumentExtractionProvider, WorkOrderExtraction, ProviderMeta } from "@/server/ai/providers";
import { extractionJsonSchema, parseExtraction } from "@/lib/imports/extraction";
/**
* Claude-based document extraction (spec §9.4, ARCHITEKTUR §4.5).
*
* - PDF is sent natively as a base64 `document` block (text layer + page images, so scanned
* PDFs work without a separate OCR step); JPG/PNG as `image` block.
* - Structured output via `output_config.format` (JSON schema) — the response is guaranteed to
* match the schema; we still validate with Zod (`parseExtraction`).
* - Streaming + `finalMessage()`: the full text of long documents can be large.
* - Refusals are handled explicitly; for Claude Opus 5 / Fable 5.1 the server-side fallback
* (`fallbacks: "default"`) re-runs a declined request on the recommended fallback model.
*/
const SUPPORTED_IMAGE = new Set(["image/jpeg", "image/png"]);
const FALLBACK_BETA = "server-side-fallback-2026-07-01";
const MAX_TOKENS = 64_000;
const SYSTEM_PROMPT = `You extract structured data from order documents (Auftragsbestätigungen, Aufträge, Bestellungen) of German craft and installation businesses.
Rules:
- Return the full recognised text of the document in "text" (reading order, line breaks preserved).
- Extract each field only from what is written in the document. Never invent, guess or complete values. If a field is not present, set value to null and confidence to 0.
- confidence is 0..1 and reflects how certain you are that the value is correct and belongs to this field. Use values below 0.8 whenever the value is ambiguous, hard to read (scan quality, handwriting), inferred from context, or could belong to a different party. Use values of 0.95 and above only for values printed clearly and labelled unambiguously.
- source: a short verbatim snippet (max. 120 characters) from the document that the value was taken from.
- The customer is the ordering party (Auftraggeber / Rechnungsempfänger), not the business issuing the document (letterhead, footer, bank details).
- The site (Objekt / Baustelle / Einsatzort / Lieferadresse) is where the work is carried out. If the document names no separate site, leave siteName and siteAddress null.
- Normalise German formats: dates to ISO 8601 (YYYY-MM-DD; "15.03.2026" → "2026-03-15"), numbers with decimal comma to JSON numbers ("1.234,50" → 1234.5), country to ISO 3166-1 alpha-2 (default "DE" only if the address is clearly German).
- Split street and house number ("Hafenstraße 12a" → street "Hafenstraße", houseNumber "12a").
- A planned execution period like "KW 12/2026" or "ab 03.04." may be converted to dates only if the year is unambiguous; otherwise keep the value null and mention it in notes.
- positions: every line item with quantity and unit; set isMaterial=true for physical material/articles, false for labour/services.
- title: a short, factual job title in German (max. 80 characters) derived from the document subject or main service.`;
export class AnthropicExtractionProvider implements DocumentExtractionProvider {
readonly name = "anthropic";
readonly model: string;
constructor(
private readonly client: Anthropic,
model: string = AI_MODEL,
) {
this.model = model;
}
async extract(input: { bytes: Buffer; mimeType: string; fileName: string }): Promise<{
text: string;
extraction: WorkOrderExtraction;
meta: ProviderMeta;
}> {
const data = input.bytes.toString("base64");
let fileBlock: Anthropic.Beta.BetaContentBlockParam;
if (input.mimeType === "application/pdf") {
fileBlock = { type: "document", source: { type: "base64", media_type: "application/pdf", data }, title: input.fileName };
} else if (SUPPORTED_IMAGE.has(input.mimeType)) {
fileBlock = { type: "image", source: { type: "base64", media_type: input.mimeType as "image/jpeg" | "image/png", data } };
} else {
throw new Error(`unsupported mime type for extraction: ${input.mimeType}`);
}
const useFallback = /^claude-(opus-5|fable-5-1|mythos-5-1)/.test(this.model);
const today = new Date().toISOString().slice(0, 10);
let message: Anthropic.Beta.BetaMessage;
try {
const stream = this.client.beta.messages.stream({
model: this.model,
max_tokens: MAX_TOKENS,
thinking: { type: "adaptive" },
system: SYSTEM_PROMPT,
output_config: { format: { type: "json_schema", schema: extractionJsonSchema() } },
messages: [
{
role: "user",
content: [
fileBlock,
{ type: "text", text: `Extract the order data from this document. Today is ${today} (for resolving incomplete dates).` },
],
},
],
...(useFallback ? { betas: [FALLBACK_BETA], fallbacks: "default" as const } : {}),
});
message = await stream.finalMessage();
} catch (err) {
if (err instanceof Anthropic.APIError) {
// No document content in the message — only status/type for the import job's error field.
throw new Error(`Claude API error ${err.status ?? "?"} (${err.name})`);
}
throw err;
}
if (message.stop_reason === "refusal") {
throw new Error("Claude declined to process the document (refusal)");
}
if (message.stop_reason === "max_tokens") {
throw new Error("Claude response truncated (max_tokens)");
}
const textBlock = message.content.find((b): b is Anthropic.Beta.BetaTextBlock => b.type === "text");
if (!textBlock) throw new Error("Claude response contained no text block");
let parsed: { text?: unknown; extraction?: unknown };
try {
parsed = JSON.parse(textBlock.text) as typeof parsed;
} catch {
throw new Error("Claude response was not valid JSON");
}
return {
text: typeof parsed.text === "string" ? parsed.text : "",
extraction: parseExtraction(parsed.extraction),
meta: {
provider: this.name,
model: message.model ?? this.model,
inputTokens: message.usage.input_tokens,
outputTokens: message.usage.output_tokens,
},
};
}
}
/**
* Configured extraction provider or `null` (graceful degradation → manual entry).
* `AI_EXTRACTION_PROVIDER` may be unset or "anthropic"; any other value disables extraction.
*/
export function getExtractionProvider(): DocumentExtractionProvider | null {
const configured = process.env.AI_EXTRACTION_PROVIDER?.trim().toLowerCase();
if (configured && configured !== "anthropic") return null;
const client = getAnthropic();
return client ? new AnthropicExtractionProvider(client) : null;
}
+34
View File
@@ -0,0 +1,34 @@
import type { DocumentExtractionProvider, ProviderMeta, WorkOrderExtraction } from "@/server/ai/providers";
import { emptyExtraction } from "@/lib/imports/extraction";
/**
* Deterministic provider for tests and demos: returns a fixed extraction (merged over an empty
* one) or throws the configured error. Records every call.
*/
export class FakeExtractionProvider implements DocumentExtractionProvider {
readonly name = "fake";
readonly model = "fake-extraction-1";
readonly calls: Array<{ mimeType: string; fileName: string; size: number }> = [];
constructor(
private readonly opts: {
extraction?: Partial<WorkOrderExtraction>;
text?: string;
fail?: Error;
} = {},
) {}
async extract(input: { bytes: Buffer; mimeType: string; fileName: string }): Promise<{
text: string;
extraction: WorkOrderExtraction;
meta: ProviderMeta;
}> {
this.calls.push({ mimeType: input.mimeType, fileName: input.fileName, size: input.bytes.byteLength });
if (this.opts.fail) throw this.opts.fail;
return {
text: this.opts.text ?? "",
extraction: { ...emptyExtraction(), ...structuredClone(this.opts.extraction ?? {}) },
meta: { provider: this.name, model: this.model, inputTokens: 100, outputTokens: 50 },
};
}
}
@@ -0,0 +1,25 @@
import { dbForTenant } from "@/server/db";
import type { JobPayload } from "@/server/jobs/queues";
import type { ServiceCtx } from "@/server/services/context";
import { getExtractionProvider } from "@/server/ai/extraction/anthropic";
import { processImport } from "@/server/services/imports/process";
import { readDocumentBytes } from "@/server/services/imports/document-store-stub";
/**
* BullMQ processor for "import-extraction" (ARCHITEKTUR §4.4). System context: tenant from the
* payload (dbForTenant), no user permissions — processImport performs no permission-gated steps.
* Errors are recorded on the job (status failed) instead of being rethrown, so a failed
* extraction is retried by the user ("Neu verarbeiten"), not blindly by the queue.
*/
export async function process(payload: JobPayload): Promise<void> {
const ctx: ServiceCtx = {
db: dbForTenant(payload.tenantId),
tenantId: payload.tenantId,
userId: payload.actorId ?? "",
permissions: new Set<string>(),
};
await processImport(ctx, payload.entityId, {
provider: getExtractionProvider(),
loadBytes: (doc) => readDocumentBytes(doc.storageKey),
});
}
+1 -1
View File
@@ -8,7 +8,7 @@ export type JobProcessor = (payload: JobPayload) => Promise<void>;
* Lazy imports keep the app bundle free of worker-only dependencies (Playwright etc.).
*/
export const PROCESSORS: Partial<Record<JobQueueName, () => Promise<JobProcessor>>> = {
// lane-imports: "import-extraction": () => import("./import-extraction").then((m) => m.process),
"import-extraction": () => import("./import-extraction").then((m) => m.process),
// lane-lotse: "transcription": () => import("./transcription").then((m) => m.process),
// lane-reports: "report-pdf": () => import("./report-pdf").then((m) => m.process),
// lane-field: "image-derivatives": () => import("./image-derivatives").then((m) => m.process),
+228
View File
@@ -0,0 +1,228 @@
import type { Prisma } from "@prisma/client";
import type { TenantDb } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
import { nextNumber } from "@/server/services/numbering";
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
import { readStoredExtraction } from "@/lib/imports/extraction";
import { computeCorrections, reviewFormSchema, type ReviewForm } from "@/lib/imports/review";
// TODO(L3→L2): replace with the L2 work order service after merge (same input type).
import { createWorkOrder } from "./work-orders-stub";
import { startExtraction, type Dispatch } from "./upload";
import { dispatchJob } from "@/server/jobs/dispatch";
export type ConfirmResult = { workOrderId: string; workOrderNumber: string; customerId: string; siteId: string | null; contactId: string | null };
const blankToNull = (v: string | null | undefined) => (v && v.trim() !== "" ? v.trim() : null);
function positionsAsScope(form: ReviewForm): string | null {
if (!form.positions.length) return null;
return form.positions
.map((p) => [p.quantity != null ? `${String(p.quantity).replace(".", ",")} ${p.unit}`.trim() : null, p.name, p.articleNumber ? `(${p.articleNumber})` : null].filter(Boolean).join(" "))
.join("\n");
}
/**
* Confirm a reviewed import (spec §9.6/§9.3 step 10): one transaction creates or assigns
* customer, contact and site, creates the work order (status planned, sourceImportId, material
* plan from positions marked as material), links the original document and stores the
* corrections. Only allowed from `review_required` (atomic status switch → no double orders).
*/
export async function confirmImport(ctx: ServiceCtx, importId: string, rawForm: unknown): Promise<ConfirmResult> {
assertCan(ctx, "import:write");
assertCan(ctx, "work_order:write");
const parsed = reviewFormSchema.safeParse(rawForm);
if (!parsed.success) {
throw new ServiceError("invalid", "form_invalid", parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })));
}
const form = parsed.data;
const job = await ctx.db.importJob.findFirst({ where: { id: importId } });
if (!job) throw new ServiceError("not_found", "import not found");
if (job.status !== "review_required") throw new ServiceError("conflict", "import_not_reviewable");
// Decisions are validated before the transaction (all lookups tenant-scoped via ctx.db).
if (form.customerMode === "existing") {
const exists = await ctx.db.customer.findFirst({ where: { id: form.customerId, deletedAt: null, status: { not: "merged" } }, select: { id: true } });
if (!exists) throw new ServiceError("not_found", "customer not found");
} else {
assertCan(ctx, "customer:write");
if (form.customer.customerNumber) {
const taken = await ctx.db.customer.findFirst({ where: { customerNumber: form.customer.customerNumber }, select: { id: true } });
if (taken) throw new ServiceError("conflict", "customer_number_taken");
}
}
if (form.siteMode === "existing") {
if (form.customerMode !== "existing") throw new ServiceError("invalid", "site_requires_existing_customer");
const site = await ctx.db.site.findFirst({ where: { id: form.siteId, customerId: form.customerId, deletedAt: null }, select: { id: true } });
if (!site) throw new ServiceError("not_found", "site not found");
} else if (form.siteMode === "new") {
assertCan(ctx, "site:write");
}
if (form.contact.name && form.customerMode === "existing") assertCan(ctx, "customer:write");
const stored = readStoredExtraction(job.extraction);
const corrections = computeCorrections(stored.fields, form);
const now = new Date();
const result = await ctx.db.$transaction(async (tx) => {
const db = tx as unknown as TenantDb;
const txCtx: ServiceCtx = { ...ctx, db };
const switched = await db.importJob.updateMany({
where: { id: job.id, status: "review_required" },
data: {
status: "confirmed",
confirmedById: ctx.userId,
confirmedAt: now,
corrections: { fields: corrections, decisions: { customerMode: form.customerMode, siteMode: form.siteMode } } as unknown as Prisma.InputJsonValue,
},
});
if (switched.count !== 1) throw new ServiceError("conflict", "import_not_reviewable");
let customerId = form.customerId;
let createdCustomer = false;
if (form.customerMode === "new") {
const c = form.customer;
const customer = await db.customer.create({
data: {
tenantId: ctx.tenantId,
customerNumber: c.customerNumber || (await nextNumber(db, ctx.tenantId, "customer")),
companyName: blankToNull(c.companyName),
firstName: blankToNull(c.firstName),
lastName: blankToNull(c.lastName),
street: blankToNull(c.street),
houseNumber: blankToNull(c.houseNumber),
postalCode: blankToNull(c.postalCode),
city: blankToNull(c.city),
country: c.country || "DE",
phone: blankToNull(c.phone),
email: blankToNull(c.email),
status: "active",
createdById: ctx.userId,
},
});
customerId = customer.id;
createdCustomer = true;
}
let contactId: string | null = null;
if (form.contact.name) {
const contact = await db.contact.create({
data: {
tenantId: ctx.tenantId,
customerId,
name: form.contact.name,
phone: blankToNull(form.contact.phone),
email: blankToNull(form.contact.email),
},
});
contactId = contact.id;
}
let siteId: string | null = form.siteMode === "existing" ? form.siteId : null;
let createdSite = false;
if (form.siteMode === "new") {
const s = form.site;
const site = await db.site.create({
data: {
tenantId: ctx.tenantId,
customerId,
name: s.name || [s.street, s.houseNumber].filter(Boolean).join(" "),
street: blankToNull(s.street),
houseNumber: blankToNull(s.houseNumber),
postalCode: blankToNull(s.postalCode),
city: blankToNull(s.city),
country: s.country || "DE",
contactId,
},
});
siteId = site.id;
createdSite = true;
}
const wo = await createWorkOrder(txCtx, {
customerId,
siteId,
contactId,
title: form.order.title,
description: blankToNull(form.order.description),
scope: positionsAsScope(form),
externalOrderNumber: blankToNull(form.order.externalOrderNumber),
offerNumber: blankToNull(form.order.offerNumber),
plannedStart: form.order.plannedStart ? new Date(`${form.order.plannedStart}T00:00:00.000Z`) : null,
plannedEnd: form.order.plannedEnd ? new Date(`${form.order.plannedEnd}T00:00:00.000Z`) : null,
internalNotes: blankToNull(form.order.notes),
sourceImportId: job.id,
status: "planned",
materials: form.positions
.filter((p) => p.asMaterial && p.quantity != null)
.map((p) => ({ name: p.name, articleNumber: blankToNull(p.articleNumber), plannedQuantity: p.quantity as number, unit: p.unit || "Stk" })),
});
// Original document stays with the order forever (spec §9.7).
await db.document.update({ where: { id: job.documentId }, data: { workOrderId: wo.id, customerId, siteId } });
return { workOrderId: wo.id, workOrderNumber: wo.number, customerId, siteId, contactId, createdCustomer, createdSite };
});
const base = { tenantId: ctx.tenantId, actorId: ctx.userId };
if (result.createdCustomer) await writeAuditLog({ ...base, action: "create", entity: "customer", entityId: result.customerId, after: { source: "import", importId: job.id } });
if (result.contactId) await writeAuditLog({ ...base, action: "create", entity: "contact", entityId: result.contactId, after: { customerId: result.customerId, source: "import" } });
if (result.createdSite && result.siteId) await writeAuditLog({ ...base, action: "create", entity: "site", entityId: result.siteId, after: { customerId: result.customerId, source: "import" } });
await writeAuditLog({
...base,
action: "create",
entity: "work_order",
entityId: result.workOrderId,
after: { number: result.workOrderNumber, status: "planned", customerId: result.customerId, siteId: result.siteId, sourceImportId: job.id },
});
await writeAuditLog({
...base,
action: "import",
entity: "import_job",
entityId: job.id,
before: { status: job.status },
after: {
status: "confirmed",
workOrderId: result.workOrderId,
customerId: result.customerId,
siteId: result.siteId,
customerMode: form.customerMode,
siteMode: form.siteMode,
correctedFields: Object.keys(corrections),
},
});
return {
workOrderId: result.workOrderId,
workOrderNumber: result.workOrderNumber,
customerId: result.customerId,
siteId: result.siteId,
contactId: result.contactId,
};
}
/** Discard an import (no order is created; the original document is kept). */
export async function discardImport(ctx: ServiceCtx, importId: string): Promise<void> {
assertCan(ctx, "import:write");
const job = await ctx.db.importJob.findFirst({ where: { id: importId }, select: { id: true, status: true } });
if (!job) throw new ServiceError("not_found", "import not found");
const res = await ctx.db.importJob.updateMany({
where: { id: job.id, status: { in: ["uploaded", "review_required", "failed"] } },
data: { status: "discarded" },
});
if (res.count !== 1) throw new ServiceError("conflict", "import_not_discardable");
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "import_job", entityId: job.id, before: { status: job.status }, after: { status: "discarded" } });
}
/** Re-run the extraction of a failed import. */
export async function retryImport(ctx: ServiceCtx, importId: string, opts: { dispatch?: Dispatch } = {}): Promise<void> {
assertCan(ctx, "import:write");
const job = await ctx.db.importJob.findFirst({ where: { id: importId }, select: { id: true, status: true } });
if (!job) throw new ServiceError("not_found", "import not found");
const res = await ctx.db.importJob.updateMany({ where: { id: job.id, status: "failed" }, data: { status: "uploaded", errorMessage: null } });
if (res.count !== 1) throw new ServiceError("conflict", "import_not_retryable");
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "import_job", entityId: job.id, before: { status: "failed" }, after: { status: "uploaded", retry: true } });
await startExtraction(ctx, job, opts.dispatch ?? ((p) => dispatchJob("import-extraction", p)));
}
@@ -0,0 +1,101 @@
import { createHash, randomUUID } from "node:crypto";
import type { Document, DocumentCategory, DocumentVisibility } from "@prisma/client";
import { storage } from "@/server/storage/adapter";
import { ServiceError, type ServiceCtx } from "@/server/services/context";
/**
* STUB (lane L3) for the document service contract ARCHITEKTUR §4.3
* `src/server/services/documents/store.ts#storeFile` — not present on the base commit.
* Same signature; replace the import in services/imports/upload.ts once the real service exists.
*
* Implements the parts the import needs: allowlist (PDF/JPEG/PNG), size limit per type
* (PDF 25 MB, images 15 MB), magic-byte check, normalised file name, SHA-256, storage.put,
* Document row with lineage.
*/
export type StoreFileInput = {
bytes: Buffer;
fileName: string;
declaredMime: string;
category: DocumentCategory;
visibility: DocumentVisibility;
links: { customerId?: string | null; siteId?: string | null; workOrderId?: string | null };
lineageId?: string;
};
const LIMITS: Record<string, number> = {
"application/pdf": 25 * 1024 * 1024,
"image/jpeg": 15 * 1024 * 1024,
"image/png": 15 * 1024 * 1024,
};
/** Detect the real type from magic bytes (null = not allowed). */
export function sniffMime(bytes: Buffer): string | null {
if (bytes.length >= 5 && bytes.subarray(0, 5).toString("latin1") === "%PDF-") return "application/pdf";
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png";
return null;
}
export function normalizeFileName(name: string): string {
const base = name.split(/[\\/]/).pop() ?? "";
const cleaned = base.normalize("NFC").replace(/[\x00-\x1f<>:"|?*]+/g, "_").replace(/\s+/g, " ").trim();
return (cleaned || "datei").slice(0, 180);
}
export async function storeFile(ctx: ServiceCtx, input: StoreFileInput): Promise<Document> {
if (input.bytes.byteLength === 0) throw new ServiceError("invalid", "file_empty");
const sniffed = sniffMime(input.bytes);
if (!sniffed) throw new ServiceError("invalid", "file_type_not_allowed");
const declared = input.declaredMime === "image/jpg" ? "image/jpeg" : input.declaredMime;
// Browsers sometimes send application/octet-stream — the magic bytes are authoritative,
// but a declared allowed type must match them.
if (declared in LIMITS && declared !== sniffed) throw new ServiceError("invalid", "file_type_mismatch");
if (input.bytes.byteLength > LIMITS[sniffed]) throw new ServiceError("invalid", "file_too_large");
const fileName = normalizeFileName(input.fileName);
const checksum = createHash("sha256").update(input.bytes).digest("hex");
const stored = await storage.put({ tenantId: ctx.tenantId, filename: fileName, contentType: sniffed, bytes: input.bytes });
let version = 1;
const lineageId = input.lineageId ?? randomUUID();
if (input.lineageId) {
const last = await ctx.db.document.findFirst({ where: { lineageId }, orderBy: { version: "desc" }, select: { version: true } });
version = (last?.version ?? 0) + 1;
}
return ctx.db.document.create({
data: {
tenantId: ctx.tenantId,
customerId: input.links.customerId ?? null,
siteId: input.links.siteId ?? null,
workOrderId: input.links.workOrderId ?? null,
category: input.category,
title: fileName,
fileName,
storageKey: stored.storageKey,
mimeType: sniffed,
fileSize: input.bytes.byteLength,
checksum,
version,
lineageId,
visibility: input.visibility,
uploadStatus: "uploaded",
uploadedById: ctx.userId,
},
});
}
/** Read the bytes of a stored document (null if the backend keeps no bytes, e.g. stub storage). */
export async function readDocumentBytes(storageKey: string): Promise<Buffer | null> {
const content = await storage.get(storageKey);
if (!content) return null;
const chunks: Uint8Array[] = [];
const reader = content.stream.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (value) chunks.push(value);
}
return Buffer.concat(chunks);
}
@@ -0,0 +1,142 @@
import type { TenantDb } from "@/server/db";
import type { DuplicateCandidate, SiteCandidate } from "@/lib/imports/extraction";
/**
* STUB (lane L3) for the L1 contract `src/lib/customers/duplicates.ts#findDuplicateCustomers(db, candidate) → Candidate[]`
* (ARCHITEKTUR §6, spec §7.3). Same signature and result shape `{ customerId, score, reasons[] }`;
* replace the import in services/imports/process.ts after the L1 merge.
*
* Compares customer number, company/person name, address, e-mail and phone. Never merges —
* it only proposes candidates; the backoffice decides (US-003).
*/
export type CustomerCandidateInput = {
customerNumber?: string | null;
companyName?: string | null;
firstName?: string | null;
lastName?: string | null;
street?: string | null;
houseNumber?: string | null;
postalCode?: string | null;
city?: string | null;
email?: string | null;
phone?: string | null;
};
const LEGAL_FORMS = /\b(gmbh|mbh|ag|kg|ohg|gbr|ug|e\.?\s?k|e\.?\s?v|co|haftungsbeschränkt|und|&)\b/g;
export function normalizeCompany(v: string | null | undefined): string {
return (v ?? "")
.toLowerCase()
.replace(/[.,;:()"'`´+/-]/g, " ")
.replace(LEGAL_FORMS, " ")
.replace(/\s+/g, " ")
.trim();
}
export function normalizeStreet(v: string | null | undefined): string {
return (v ?? "")
.toLowerCase()
.replace(/ß/g, "ss")
.replace(/strasse|str\./g, "str")
.replace(/[^a-z0-9äöü]/g, "");
}
const digits = (v: string | null | undefined) => (v ?? "").replace(/\D/g, "").replace(/^49/, "0").replace(/^00/, "0");
const lc = (v: string | null | undefined) => (v ?? "").trim().toLowerCase();
export async function findDuplicateCustomers(db: TenantDb, candidate: CustomerCandidateInput): Promise<DuplicateCandidate[]> {
const company = normalizeCompany(candidate.companyName);
const firstWord = company.split(" ").find((w) => w.length >= 3);
const or: object[] = [];
if (candidate.customerNumber) or.push({ customerNumber: candidate.customerNumber.trim() });
if (candidate.email) or.push({ email: { equals: candidate.email.trim(), mode: "insensitive" } });
if (firstWord) or.push({ companyName: { contains: firstWord, mode: "insensitive" } });
if (candidate.lastName) or.push({ lastName: { equals: candidate.lastName.trim(), mode: "insensitive" } });
if (candidate.postalCode) or.push({ postalCode: candidate.postalCode.trim() });
if (candidate.phone) or.push({ phone: { not: null } });
if (or.length === 0) return [];
const rows = await db.customer.findMany({
where: { deletedAt: null, status: { not: "merged" }, OR: or },
select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, street: true, houseNumber: true, postalCode: true, email: true, phone: true, mobile: true },
take: 200,
});
const out: DuplicateCandidate[] = [];
for (const c of rows) {
let score = 0;
const reasons: string[] = [];
if (candidate.customerNumber && c.customerNumber && c.customerNumber.trim() === candidate.customerNumber.trim()) {
score += 0.6;
reasons.push("customer_number");
}
const cc = normalizeCompany(c.companyName);
if (company && cc) {
if (cc === company) {
score += 0.4;
reasons.push("company_name");
} else if (cc.includes(company) || company.includes(cc)) {
score += 0.25;
reasons.push("company_name_similar");
}
}
if (candidate.lastName && lc(c.lastName) === lc(candidate.lastName) && (!candidate.firstName || lc(c.firstName) === lc(candidate.firstName))) {
score += 0.3;
reasons.push("person_name");
}
if (candidate.email && lc(c.email) === lc(candidate.email)) {
score += 0.3;
reasons.push("email");
}
const phone = digits(candidate.phone);
if (phone.length >= 6 && [c.phone, c.mobile].some((p) => digits(p) === phone)) {
score += 0.2;
reasons.push("phone");
}
if (
candidate.postalCode &&
c.postalCode === candidate.postalCode.trim() &&
normalizeStreet(c.street) !== "" &&
normalizeStreet(c.street) === normalizeStreet(candidate.street)
) {
score += 0.25;
reasons.push("address");
}
if (score >= 0.25) out.push({ customerId: c.id, score: Math.min(1, Math.round(score * 100) / 100), reasons });
}
return out.sort((a, b) => b.score - a.score).slice(0, 5);
}
/** Sites of the candidate customers at the same address (lane L3, spec §9.3 step 7). */
export async function findSiteCandidates(
db: TenantDb,
customerIds: string[],
address: { name?: string | null; street?: string | null; houseNumber?: string | null; postalCode?: string | null } | null,
): Promise<SiteCandidate[]> {
if (!customerIds.length || !address || (!address.street && !address.name)) return [];
const sites = await db.site.findMany({
where: { customerId: { in: customerIds }, deletedAt: null },
select: { id: true, customerId: true, name: true, street: true, houseNumber: true, postalCode: true },
take: 200,
});
const out: SiteCandidate[] = [];
for (const s of sites) {
let score = 0;
const reasons: string[] = [];
if (address.street && normalizeStreet(s.street) === normalizeStreet(address.street) && (!address.postalCode || s.postalCode === address.postalCode)) {
score += 0.6;
reasons.push("address");
if (address.houseNumber && lc(s.houseNumber) === lc(address.houseNumber)) {
score += 0.3;
reasons.push("house_number");
}
}
if (address.name && lc(s.name) === lc(address.name)) {
score += 0.3;
reasons.push("site_name");
}
if (score >= 0.3) out.push({ siteId: s.id, customerId: s.customerId, name: s.name, score: Math.min(1, Math.round(score * 100) / 100), reasons });
}
return out.sort((a, b) => b.score - a.score).slice(0, 5);
}
+145
View File
@@ -0,0 +1,145 @@
import type { ImportJob, Prisma } from "@prisma/client";
import { writeAuditLog } from "@/server/audit";
import { emitEvent } from "@/server/events";
import type { DocumentExtractionProvider, WorkOrderExtraction } from "@/server/ai/providers";
import { ServiceError, type ServiceCtx } from "@/server/services/context";
import {
emptyExtraction,
EXTRACTION_VERSION,
type PlausibilityHint,
type StoredExtraction,
} from "@/lib/imports/extraction";
import { checkPlausibility } from "@/lib/imports/plausibility";
// TODO(L3→L1): replace with "@/lib/customers/duplicates" after the L1 merge (same interface).
import { findDuplicateCustomers, findSiteCandidates } from "./duplicates-stub";
export type ProcessDeps = {
provider: DocumentExtractionProvider | null;
loadBytes: (doc: { storageKey: string }) => Promise<Buffer | null>;
now?: Date;
};
/**
* Extraction pipeline (spec §9.3 steps 3–7): processing → provider (text + fields) →
* plausibility → duplicate customers + site candidates → review_required.
* Without provider: review_required with empty extraction and hint "manual_entry".
* Any error: failed + event import.failed. Never creates customers or work orders.
* Idempotent: only jobs in uploaded/processing are processed.
*/
export async function processImport(ctx: ServiceCtx, importId: string, deps: ProcessDeps): Promise<ImportJob> {
const job = await ctx.db.importJob.findFirst({ where: { id: importId } });
if (!job) throw new ServiceError("not_found", "import not found");
if (job.status !== "uploaded" && job.status !== "processing") return job;
const actorId = job.importedById ?? undefined;
await ctx.db.importJob.update({ where: { id: job.id }, data: { status: "processing", errorMessage: null } });
try {
const doc = await ctx.db.document.findFirst({ where: { id: job.documentId } });
if (!doc) throw new Error("document_missing");
let fields: WorkOrderExtraction;
let hints: PlausibilityHint[] = [];
let text: string | null = null;
let providerName: string | null = null;
let model: string | null = null;
if (!deps.provider) {
fields = emptyExtraction();
hints = [{ field: null, code: "manual_entry" }];
} else {
const bytes = await deps.loadBytes({ storageKey: doc.storageKey });
if (!bytes) throw new Error("file_unavailable");
const result = await deps.provider.extract({ bytes, mimeType: doc.mimeType, fileName: doc.fileName });
const checked = checkPlausibility(result.extraction, deps.now);
fields = checked.extraction;
hints = checked.hints;
text = result.text;
providerName = result.meta.provider;
model = result.meta.model;
await ctx.db.aiGeneration.create({
data: {
tenantId: ctx.tenantId,
kind: "import_extraction",
provider: result.meta.provider,
model: result.meta.model,
entityType: "import_job",
entityId: job.id,
input: { documentId: doc.id, fileName: doc.fileName, mimeType: doc.mimeType, fileSize: doc.fileSize, checksum: doc.checksum },
output: { extraction: result.extraction, hints } as unknown as Prisma.InputJsonValue,
inputTokens: result.meta.inputTokens ?? null,
outputTokens: result.meta.outputTokens ?? null,
createdById: actorId ?? null,
},
});
}
const address = fields.customerAddress.value ?? {};
const duplicates = await findDuplicateCustomers(ctx.db, {
customerNumber: fields.customerNumber.value,
companyName: fields.companyName.value,
firstName: fields.customerFirstName.value,
lastName: fields.customerLastName.value,
street: address.street,
houseNumber: address.houseNumber,
postalCode: address.postalCode,
city: address.city,
email: fields.email.value,
phone: fields.phone.value,
});
const siteAddress = fields.siteAddress.value ?? (fields.siteName.value ? {} : null);
const siteCandidates = await findSiteCandidates(
ctx.db,
duplicates.map((d) => d.customerId),
siteAddress ? { ...siteAddress, name: fields.siteName.value } : null,
);
const stored: StoredExtraction = { fields, hints, siteCandidates };
const updated = await ctx.db.importJob.update({
where: { id: job.id },
data: {
status: "review_required",
errorMessage: null,
extractedText: text,
extraction: stored as unknown as Prisma.InputJsonValue,
extractionVersion: EXTRACTION_VERSION,
extractionModel: model,
provider: providerName,
duplicateCandidates: duplicates as unknown as Prisma.InputJsonValue,
},
});
await writeAuditLog({
tenantId: ctx.tenantId,
actorId,
action: "update",
entity: "import_job",
entityId: job.id,
before: { status: job.status },
after: { status: updated.status, provider: providerName, model, hints: hints.length, duplicateCandidates: duplicates.length },
});
await emitEvent(ctx, {
type: "import.ready_for_review",
entityType: "import_job",
entityId: job.id,
data: { manual: !deps.provider, duplicateCandidates: duplicates.length },
});
return updated;
} catch (err) {
const message = ((err as Error).message || "extraction_failed").slice(0, 500);
console.error(`[imports] processing ${job.id} failed:`, message);
const failed = await ctx.db.importJob.update({ where: { id: job.id }, data: { status: "failed", errorMessage: message } });
await writeAuditLog({
tenantId: ctx.tenantId,
actorId,
action: "update",
entity: "import_job",
entityId: job.id,
before: { status: job.status },
after: { status: "failed", errorMessage: message },
});
await emitEvent(ctx, { type: "import.failed", entityType: "import_job", entityId: job.id, data: { reason: message.slice(0, 120) } });
return failed;
}
}
+153
View File
@@ -0,0 +1,153 @@
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
import { allowedDocumentVisibility, customerScope } from "@/server/services/work-orders/visibility";
import { readStoredExtraction, type DuplicateCandidate, type StoredExtraction } from "@/lib/imports/extraction";
/**
* Read models of the import module. Imports are backoffice data: every read requires
* `import:write` (technicians/team leads never see them) and stays inside the tenant (ctx.db).
* Unknown or foreign ids → not_found (no existence oracle).
*/
export async function listImports(ctx: ServiceCtx, opts: { take?: number } = {}) {
assertCan(ctx, "import:write");
const jobs = await ctx.db.importJob.findMany({
orderBy: { createdAt: "desc" },
take: Math.min(opts.take ?? 100, 500),
select: {
id: true,
status: true,
errorMessage: true,
createdAt: true,
confirmedAt: true,
documentId: true,
importedById: true,
provider: true,
createdWorkOrder: { select: { id: true, number: true } },
},
});
const [docs, users] = await Promise.all([
ctx.db.document.findMany({ where: { id: { in: jobs.map((j) => j.documentId) } }, select: { id: true, fileName: true, mimeType: true, fileSize: true } }),
ctx.db.user.findMany({ where: { id: { in: jobs.map((j) => j.importedById).filter((v): v is string => !!v) } }, select: { id: true, name: true } }),
]);
const docById = new Map(docs.map((d) => [d.id, d]));
const userById = new Map(users.map((u) => [u.id, u.name]));
return jobs.map((j) => ({
...j,
document: docById.get(j.documentId) ?? null,
importedByName: j.importedById ? userById.get(j.importedById) ?? null : null,
}));
}
export type ImportListItem = Awaited<ReturnType<typeof listImports>>[number];
export async function getImportDetail(ctx: ServiceCtx, importId: string) {
assertCan(ctx, "import:write");
const job = await ctx.db.importJob.findFirst({
where: { id: importId },
include: { createdWorkOrder: { select: { id: true, number: true, status: true } } },
});
if (!job) throw new ServiceError("not_found", "import not found");
const document = await ctx.db.document.findFirst({
where: { id: job.documentId },
select: { id: true, fileName: true, mimeType: true, fileSize: true, visibility: true, createdAt: true },
});
const stored: StoredExtraction = readStoredExtraction(job.extraction);
const duplicates = (Array.isArray(job.duplicateCandidates) ? job.duplicateCandidates : []) as unknown as DuplicateCandidate[];
const scope = await customerScope(ctx);
const customerIds = [...new Set([...duplicates.map((d) => d.customerId), ...stored.siteCandidates.map((s) => s.customerId)])];
const customers = customerIds.length
? await ctx.db.customer.findMany({
where: { AND: [{ id: { in: customerIds } }, scope] },
select: {
id: true,
customerNumber: true,
companyName: true,
firstName: true,
lastName: true,
street: true,
houseNumber: true,
postalCode: true,
city: true,
email: true,
phone: true,
sites: { where: { deletedAt: null }, select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true }, orderBy: { name: "asc" }, take: 50 },
},
})
: [];
const customerById = new Map(customers.map((c) => [c.id, c]));
const importer = job.importedById ? await ctx.db.user.findFirst({ where: { id: job.importedById }, select: { name: true } }) : null;
return {
id: job.id,
status: job.status,
errorMessage: job.errorMessage,
createdAt: job.createdAt,
confirmedAt: job.confirmedAt,
provider: job.provider,
extractionModel: job.extractionModel,
extractionVersion: job.extractionVersion,
extractedText: job.extractedText,
importedByName: importer?.name ?? null,
document,
extraction: stored,
corrections: job.corrections,
createdWorkOrder: job.createdWorkOrder,
customerCandidates: duplicates
.map((d) => ({ ...d, customer: customerById.get(d.customerId) ?? null }))
.filter((d): d is typeof d & { customer: NonNullable<typeof d.customer> } => d.customer !== null),
siteCandidates: stored.siteCandidates.filter((s) => customerById.has(s.customerId)),
};
}
export type ImportDetail = Awaited<ReturnType<typeof getImportDetail>>;
/** Customer search for the review mask ("bestehenden Kunden verwenden" without a candidate). */
export async function searchCustomers(ctx: ServiceCtx, q: string) {
assertCan(ctx, "import:write");
const term = q.trim();
if (term.length < 2) return [];
const scope = await customerScope(ctx);
return ctx.db.customer.findMany({
where: {
AND: [
scope,
{ status: { not: "merged" } },
{
OR: [
{ customerNumber: { contains: term, mode: "insensitive" } },
{ companyName: { contains: term, mode: "insensitive" } },
{ lastName: { contains: term, mode: "insensitive" } },
{ city: { contains: term, mode: "insensitive" } },
{ email: { contains: term, mode: "insensitive" } },
],
},
],
},
select: {
id: true,
customerNumber: true,
companyName: true,
firstName: true,
lastName: true,
postalCode: true,
city: true,
sites: { where: { deletedAt: null }, select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true }, orderBy: { name: "asc" }, take: 50 },
},
orderBy: { companyName: "asc" },
take: 10,
});
}
/** Original file for the preview/download route; enforces document visibility. */
export async function getImportFile(ctx: ServiceCtx, importId: string) {
assertCan(ctx, "import:write");
const job = await ctx.db.importJob.findFirst({ where: { id: importId }, select: { documentId: true } });
if (!job) throw new ServiceError("not_found", "import not found");
const doc = await ctx.db.document.findFirst({
where: { id: job.documentId, deletedAt: null, visibility: { in: allowedDocumentVisibility(ctx) } },
select: { storageKey: true, mimeType: true, fileName: true },
});
if (!doc) throw new ServiceError("not_found", "document not found");
return doc;
}
+65
View File
@@ -0,0 +1,65 @@
import type { ImportJob } from "@prisma/client";
import { writeAuditLog } from "@/server/audit";
import { dispatchJob } from "@/server/jobs/dispatch";
import type { JobPayload } from "@/server/jobs/queues";
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
import { IMPORT_MAX_BYTES, IMPORT_MIME_TYPES } from "@/lib/imports/status";
// TODO(L3→documents): replace with "@/server/services/documents/store" once available (ARCHITEKTUR §4.3).
import { storeFile } from "./document-store-stub";
export type UploadFile = { bytes: Buffer; fileName: string; mimeType: string };
export type Dispatch = (payload: JobPayload) => Promise<unknown>;
const defaultDispatch: Dispatch = (payload) => dispatchJob("import-extraction", payload);
/**
* Upload an order document (spec §9.3 steps 1–2): file check via the document service
* (category order_confirmation, visibility backoffice_only), ImportJob `uploaded`, extraction job.
* No work order is created here — only after confirmation (spec §9.6).
*/
export async function createImport(ctx: ServiceCtx, file: UploadFile, opts: { dispatch?: Dispatch } = {}): Promise<ImportJob> {
assertCan(ctx, "import:write");
if (file.bytes.byteLength > IMPORT_MAX_BYTES) throw new ServiceError("invalid", "file_too_large");
const declared = file.mimeType === "image/jpg" ? "image/jpeg" : file.mimeType;
if (declared && declared !== "application/octet-stream" && !(IMPORT_MIME_TYPES as readonly string[]).includes(declared)) {
throw new ServiceError("invalid", "file_type_not_allowed");
}
const document = await storeFile(ctx, {
bytes: file.bytes,
fileName: file.fileName,
declaredMime: declared,
category: "order_confirmation",
visibility: "backoffice_only",
links: {},
});
const job = await ctx.db.importJob.create({
data: { tenantId: ctx.tenantId, documentId: document.id, status: "uploaded", importedById: ctx.userId },
});
await writeAuditLog({
tenantId: ctx.tenantId,
actorId: ctx.userId,
action: "import",
entity: "import_job",
entityId: job.id,
after: { status: job.status, documentId: document.id, fileName: document.fileName, mimeType: document.mimeType, fileSize: document.fileSize, checksum: document.checksum },
});
await startExtraction(ctx, job, opts.dispatch ?? defaultDispatch);
return job;
}
/** Queue (or run inline) the extraction; a dispatch failure marks the job failed (retryable). */
export async function startExtraction(ctx: ServiceCtx, job: Pick<ImportJob, "id">, dispatch: Dispatch): Promise<void> {
try {
await dispatch({ tenantId: ctx.tenantId, entityId: job.id, actorId: ctx.userId });
} catch (err) {
console.error(`[imports] dispatch for ${job.id} failed:`, (err as Error).message);
await ctx.db.importJob.updateMany({
where: { id: job.id, status: { in: ["uploaded", "processing"] } },
data: { status: "failed", errorMessage: "dispatch_failed" },
});
}
}
@@ -0,0 +1,87 @@
import type { WorkOrder } from "@prisma/client";
import { nextNumber } from "@/server/services/numbering";
import { assertCan, type ServiceCtx } from "@/server/services/context";
/**
* STUB (lane L3) for the L2 contract `src/server/services/work-orders/*#createWorkOrder(ctx, input)`
* (ARCHITEKTUR §6: "Bestätigung → … Auftrag (über L2-Service createWorkOrder)").
* Exported input type + function; after the L2 merge, services/imports/confirm.ts imports the
* real service instead. Kept minimal on purpose: number allocation, status history
* (review_required → planned), material plan. No events/audit here — the caller audits.
*
* `ctx.db` may be a transaction client (the confirm transaction passes one).
*/
export type CreateWorkOrderInput = {
customerId: string;
siteId?: string | null;
contactId?: string | null;
title: string;
description?: string | null;
scope?: string | null;
externalOrderNumber?: string | null;
offerNumber?: string | null;
plannedStart?: Date | null;
plannedEnd?: Date | null;
internalNotes?: string | null;
sourceImportId?: string | null;
/** Initial status; imports pass "planned" after confirmation (history: review_required → planned). */
status?: "draft" | "review_required" | "planned";
materials?: Array<{ name: string; articleNumber?: string | null; plannedQuantity: number; unit: string }>;
};
export async function createWorkOrder(ctx: ServiceCtx, input: CreateWorkOrderInput): Promise<WorkOrder> {
assertCan(ctx, "work_order:write");
const number = await nextNumber(ctx.db, ctx.tenantId, "work_order");
const status = input.status ?? "draft";
const wo = await ctx.db.workOrder.create({
data: {
tenantId: ctx.tenantId,
number,
customerId: input.customerId,
siteId: input.siteId ?? null,
contactId: input.contactId ?? null,
title: input.title,
description: input.description ?? null,
scope: input.scope ?? null,
externalOrderNumber: input.externalOrderNumber ?? null,
offerNumber: input.offerNumber ?? null,
plannedStart: input.plannedStart ?? null,
plannedEnd: input.plannedEnd ?? null,
internalNotes: input.internalNotes ?? null,
sourceImportId: input.sourceImportId ?? null,
status,
createdById: ctx.userId,
},
});
const history: Array<{ fromStatus: WorkOrder["status"] | null; toStatus: WorkOrder["status"] }> =
status === "planned" && input.sourceImportId
? [
{ fromStatus: null, toStatus: "review_required" },
{ fromStatus: "review_required", toStatus: "planned" },
]
: [{ fromStatus: null, toStatus: status }];
for (const h of history) {
await ctx.db.workOrderStatusChange.create({
data: { tenantId: ctx.tenantId, workOrderId: wo.id, fromStatus: h.fromStatus, toStatus: h.toStatus, actorId: ctx.userId },
});
}
let sort = 0;
for (const m of input.materials ?? []) {
await ctx.db.materialPlan.create({
data: {
tenantId: ctx.tenantId,
workOrderId: wo.id,
name: m.name,
articleNumber: m.articleNumber ?? null,
plannedQuantity: m.plannedQuantity,
unit: m.unit,
sortOrder: sort++,
},
});
}
return wo;
}