Files
craftvia/scripts/test-import-rules.ts
T
msolarczekandClaude Opus 5 12a764786a 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>
2026-09-14 12:17:13 +02:00

202 lines
12 KiB
TypeScript

// 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<string, unknown>;
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<string, { properties: object }>).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<wd>.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);