- Aufträge: createWorkOrder/transitionWorkOrder/getCompletionBlockers aus L2 - Dokumente: storeFile aus L1; neu services/documents/read.ts (readStoredBytes, readDocumentBytes mit Prüfsummenprüfung); L2-Upload nutzt zentrale Ablage - Dubletten aus L1 (findDuplicateCustomers(ctx)); Objekt-Kandidaten als imports/site-candidates.ts; Dateityp-Erkennung mobil als field/mime.ts - Objekt-Historie mobil als Adapter auf L1 getSiteHistory, PDF über /api/v1/reports/:id/pdf - L4-Upload-Idempotenz: fester Upload-Lineage nach storeFile, Race → Soft-Delete + Replay - PDF-Worker-Kontext: document:write zum Ablegen des Berichts-PDF - Import: doppeltes Work-Order-Audit entfernt; bestätigte Aufträge starten planned - Dateilinks in Auftragsdetail auf /files/[documentId] - proxy: /api/v1 ohne Session → 401 JSON statt Login-Redirect - ARCHITEKTUR §2: Objekt-Historie für Feldrollen (freigegebene Einsätze aller Teams) Gate: tsc, lint, build, 42/42 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
203 lines
13 KiB
TypeScript
203 lines
13 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 { normalizeCompanyName as normalizeCompany, normalizeStreet } from "../src/lib/customers/duplicates";
|
|
import { detectMime as sniffMime } from "../src/server/services/documents/scanner";
|
|
import { normalizeFileName } from "../src/server/services/documents/store";
|
|
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/pa |