// Lane L3 (Auftragsimport) — pure logic without DB: plausibility rules, extraction parsing, // JSON schema for structured outputs, mapping extraction → form, form validation, // correction diff, duplicate normalisation, file sniffing. // // Lauf: npx tsx scripts/test-import-rules.ts import "dotenv/config"; import type { WorkOrderExtraction } from "../src/server/ai/providers"; import { emptyExtraction, extractionJsonSchema, parseExtraction, EXTRACTION_FIELDS } from "../src/lib/imports/extraction"; import { checkPlausibility, parseDate, toIsoDate, VIOLATION_CONFIDENCE } from "../src/lib/imports/plausibility"; import { computeCorrections, extractionToForm, formFieldMeta, reviewFormSchema } from "../src/lib/imports/review"; import { normalizeCompany, normalizeStreet } from "../src/server/services/imports/duplicates-stub"; import { normalizeFileName, sniffMime } from "../src/server/services/imports/document-store-stub"; import { buildPdf } from "./make-sample-pdfs"; let failures = 0; const ok = (cond: boolean, msg: string) => { console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); if (!cond) failures++; }; const NOW = new Date("2026-09-14T10:00:00Z"); function sample(): WorkOrderExtraction { return { ...emptyExtraction(), orderNumber: { value: "AB-2026-0815", confidence: 0.97, source: "Auftragsnummer: AB-2026-0815" }, customerNumber: { value: "K-10042", confidence: 0.95 }, companyName: { value: "Musterbau GmbH", confidence: 0.96 }, customerAddress: { value: { street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg", country: "DE" }, confidence: 0.93 }, siteName: { value: "Neubau Bürogebäude Speicherhof", confidence: 0.7, source: "Bauvorhaben: Neubau Bürogebäude" }, siteAddress: { value: { street: "Am Kaiserkai", houseNumber: "30", postalCode: "20457", city: "Hamburg" }, confidence: 0.9 }, contactName: { value: "Jana Köhler", confidence: 0.9 }, phone: { value: "040 0000 2233", confidence: 0.9 }, email: { value: "j.koehler@musterbau.example.org", confidence: 0.92 }, orderDate: { value: "2026-09-02", confidence: 0.95 }, plannedStart: { value: "12.10.2026", confidence: 0.9 }, plannedEnd: { value: "2026-10-16", confidence: 0.9 }, title: { value: "Montage Wärmepumpe", confidence: 0.85 }, description: { value: "Montage Wärmepumpe inkl. Inbetriebnahme", confidence: 0.9 }, positions: { value: [ { position: "1", name: "Montage Wärmepumpe", quantity: 1, unit: "Stk", isMaterial: false }, { position: "2", name: "Wärmepumpe Aerotherm 12 kW", articleNumber: "WP-AT-12", quantity: 1, unit: "Stk", isMaterial: true }, { position: "4", name: "Kupferrohr 22 mm", articleNumber: "CU-22", quantity: 24, unit: "m", isMaterial: true }, ], confidence: 0.88, }, }; } // ---------- plausibility ---------- { const { extraction, hints } = checkPlausibility(sample(), NOW); ok(hints.length === 0, "(P1) plausible extraction → no hints"); ok(extraction.plannedStart.value === "2026-10-12", "(P2) German date 12.10.2026 normalised to ISO"); ok(extraction.plannedStart.confidence === 0.9, "(P3) confidence unchanged without violation"); const bad = sample(); bad.customerAddress.value = { ...bad.customerAddress.value!, postalCode: "2045" }; bad.email.value = "j.koehler(at)musterbau"; bad.phone.value = "call me"; bad.plannedEnd.value = "2026-10-01"; bad.orderDate.value = "31.02.2026"; bad.documentDate.value = "1999-01-01"; bad.documentDate.confidence = 0.99; const r = checkPlausibility(bad, NOW); const codes = new Set(r.hints.map((h) => `${h.field}:${h.code}`)); ok(codes.has("customerAddress:postal_code_invalid"), "(P4) 4-digit German postal code flagged"); ok(codes.has("email:email_invalid"), "(P5) invalid e-mail flagged"); ok(codes.has("phone:phone_invalid"), "(P6) invalid phone flagged"); ok(codes.has("plannedEnd:end_before_start"), "(P7) end before start flagged"); ok(codes.has("orderDate:date_invalid"), "(P8) impossible date 31.02. flagged"); ok(codes.has("documentDate:date_implausible"), "(P9) date 27 years back flagged as implausible"); ok(r.extraction.documentDate.confidence <= VIOLATION_CONFIDENCE && r.extraction.email.confidence <= VIOLATION_CONFIDENCE, "(P10) violations lower confidence ≤ 0.4"); ok(bad.email.confidence === 0.92, "(P11) input extraction is not mutated"); const foreign = sample(); foreign.customerAddress.value = { street: "Hauptstrasse", postalCode: "8001", city: "Zürich", country: "CH" }; ok(checkPlausibility(foreign, NOW).hints.length === 0, "(P12) non-German postal codes are not checked against the 5-digit rule"); ok(parseDate("2026-02-29") === null && parseDate("29.02.2028") !== null, "(P13) leap-year aware date parsing"); ok(toIsoDate("1.3.26") === "2026-03-01", "(P14) short German date 1.3.26 → 2026-03-01"); } // ---------- extraction parsing + schema ---------- { const raw = { ...Object.fromEntries(EXTRACTION_FIELDS.map((k) => [k, { value: null, confidence: 0.3, source: null }])), companyName: { value: " Musterbau GmbH ", confidence: 1.7, source: null }, email: { value: "", confidence: 0.9, source: "E-Mail:" }, positions: { value: [{ position: null, name: "Rohr", articleNumber: null, quantity: 3, unit: "m", isMaterial: true }], confidence: 0.8, source: null }, }; let parsed: WorkOrderExtraction | null = null; try { parsed = parseExtraction(raw); } catch (e) { console.error(e); } ok(parsed !== null, "(E1) provider output with nulls parses"); ok(parsed?.companyName.value === "Musterbau GmbH", "(E2) values are trimmed"); ok(parsed?.companyName.confidence === 0, "(E3) out-of-range confidence is rejected (→ 0, i.e. uncertain)"); ok(parsed?.email.value === null && parsed?.email.confidence === 0, "(E4) empty string counts as not found"); ok(parsed?.positions.value?.[0].articleNumber === undefined && parsed?.positions.value?.[0].quantity === 3, "(E5) nested nulls → undefined"); let threw = false; try { parseExtraction({ foo: 1 }); } catch { threw = true; } ok(threw, "(E6) structurally invalid output throws"); const schema = extractionJsonSchema(); const strict = (node: unknown): boolean => { if (!node || typeof node !== "object") return true; const n = node as Record; if (n.type === "object") { const props = Object.keys((n.properties as object) ?? {}); const req = (n.required as string[]) ?? []; if (n.additionalProperties !== false || props.length !== req.length || !props.every((p) => req.includes(p))) return false; } return Object.values(n).every((v) => (Array.isArray(v) ? v.every(strict) : strict(v))); }; ok(strict(schema), "(E7) JSON schema: every object has additionalProperties:false and all properties required"); const exProps = ((schema.properties as Record).extraction.properties); ok(EXTRACTION_FIELDS.every((k) => k in exProps), "(E8) JSON schema covers all 22 extraction fields"); ok(!JSON.stringify(schema).match(/"(minimum|maximum|minLength|maxLength|pattern)"/), "(E9) no unsupported constraint keywords"); } // ---------- mapping + validation + corrections ---------- { const ex = checkPlausibility(sample(), NOW).extraction; const form = extractionToForm(ex); ok(form.customerMode === "new" && form.customer.companyName === "Musterbau GmbH", "(M1) customer fields mapped, default new customer"); ok(form.siteMode === "new" && form.site.street === "Am Kaiserkai" && form.site.houseNumber === "30", "(M2) site address mapped"); ok(form.contact.name === "Jana Köhler" && form.contact.email === "j.koehler@musterbau.example.org", "(M3) contact mapped"); ok(form.order.externalOrderNumber === "AB-2026-0815" && form.order.plannedStart === "2026-10-12", "(M4) order number and dates mapped"); ok(form.positions?.length === 3 && form.positions[0].asMaterial === false && form.positions[1].asMaterial === true, "(M5) positions mapped, material preselected"); ok(extractionToForm(ex, { customerId: "c1", siteId: "s1" }).customerMode === "existing", "(M6) existing customer preselectable"); const noTitle = { ...ex, title: { value: null, confidence: 0 } }; ok(extractionToForm(noTitle).order.title === "Montage Wärmepumpe inkl. Inbetriebnahme", "(M7) title falls back to first description line"); ok(extractionToForm(emptyExtraction()).siteMode === "none", "(M8) empty extraction → no site"); const meta = formFieldMeta(ex); ok(meta["site.name"].uncertain === true && meta["site.name"].source?.startsWith("Bauvorhaben") === true, "(M9) confidence 0.7 → uncertain with source snippet"); ok(meta["customer.companyName"].uncertain === false, "(M10) confidence 0.96 → not uncertain"); ok(meta["order.offerNumber"].uncertain === false, "(M11) missing value is not flagged as uncertain"); const valid = reviewFormSchema.safeParse(form); ok(valid.success, "(V1) mapped form validates"); const invalid = reviewFormSchema.safeParse({ ...form, customer: { ...form.customer, email: "nope", postalCode: "123" }, order: { ...form.order, title: " ", plannedEnd: "2026-10-01" }, positions: [{ name: "Rohr", quantity: "abc" }], }); const paths = invalid.success ? [] : invalid.error.issues.map((i) => `${i.path.join(".")}:${i.message}`); ok(paths.includes("customer.email:email_invalid"), "(V2) invalid e-mail rejected"); ok(paths.includes("customer.postalCode:postal_code_invalid"), "(V3) invalid postal code rejected"); ok(paths.includes("order.title:title_required"), "(V4) empty title rejected"); ok(paths.includes("order.plannedEnd:end_before_start"), "(V5) end before start rejected"); ok(paths.includes("positions.0.quantity:quantity_invalid"), "(V6) non-numeric quantity rejected"); const comma = reviewFormSchema.safeParse({ ...form, positions: [{ name: "Rohr", quantity: "2,5", unit: "m", asMaterial: true }] }); ok(comma.success && comma.data.positions[0].quantity === 2.5, "(V7) German decimal comma accepted"); const existingWithout = reviewFormSchema.safeParse({ ...form, customerMode: "existing", customerId: "" }); ok(!existingWithout.success, "(V8) existing customer requires a selection"); const unchanged = reviewFormSchema.parse(form); ok(Object.keys(computeCorrections(ex, unchanged)).length === 0, "(C1) unchanged form → no corrections"); const edited = reviewFormSchema.parse({ ...form, order: { ...form.order, title: "Wärmepumpe montieren" }, site: { ...form.site, postalCode: "20459" }, positions: form.positions!.slice(1), }); const corr = computeCorrections(ex, edited); ok(corr["order.title"]?.from === "Montage Wärmepumpe" && corr["order.title"]?.to === "Wärmepumpe montieren", "(C2) edited title recorded as from/to"); ok(corr["site.postalCode"]?.to === "20459", "(C3) edited postal code recorded"); ok("positions" in corr && Object.keys(corr).length === 3, "(C4) removed position recorded, nothing else"); const onlyMaterialToggle = reviewFormSchema.parse({ ...form, positions: form.positions!.map((p) => ({ ...p, asMaterial: !p.asMaterial })) }); ok(!("positions" in computeCorrections(ex, onlyMaterialToggle)), "(C5) material toggle is a decision, not a data correction"); } // ---------- duplicates normalisation + files ---------- { ok(normalizeCompany("Musterbau GmbH") === "musterbau" && normalizeCompany("MUSTERBAU G.m.b.H.") !== "", "(D1) legal form stripped from company names"); ok(normalizeCompany("Elbblick Wohnen eG") === normalizeCompany("elbblick wohnen eg"), "(D2) company normalisation is case-insensitive"); ok(normalizeStreet("Hafenstraße") === normalizeStreet("Hafenstr.") && normalizeStreet("Hafenstrasse") === normalizeStreet("Hafenstraße"), "(D3) street variants normalise equally"); const pdf = buildPdf([[{ text: "Auftragsbestätigung Größe 5 €" }]]); ok(sniffMime(pdf) === "application/pdf", "(F1) generated sample is a PDF by magic bytes"); ok(sniffMime(Buffer.from([0xff, 0xd8, 0xff, 0xe0])) === "image/jpeg", "(F2) JPEG magic bytes"); ok(sniffMime(Buffer.from("MZ\x90\x00 fake exe", "latin1")) === null, "(F3) executable rejected"); ok(normalizeFileName("../../etc/pass.pdf") === "pa_ss_wd_.pdf", "(F4) file name without path and control characters"); const raw = pdf.toString("latin1"); ok(raw.includes("(Auftragsbest\\344tigung Gr\\366\\337e 5 \\200)") && raw.trimEnd().endsWith("%%EOF"), "(F5) PDF writer encodes umlauts/€ as WinAnsi octal escapes and closes the file"); } if (failures === 0) console.log("\nOK — Importregeln (Plausibilität, Mapping, Korrekturen) erfüllt."); else console.log(`\n${failures} FEHLER.`); process.exit(failures === 0 ? 0 : 1);