// Lane L3 (Auftragsimport) — service flow against the local DB: // upload/validation, processing (fake provider), duplicate + site candidates, AiGeneration, // "kein Auftrag ohne Bestätigung", confirm (existing vs. new customer) incl. document link, // corrections and audit, double confirm, provider error → failed + retry, no provider → manual, // discard, tenant isolation (B cannot read/change A) and roles (technician → forbidden). // // Lauf: npx tsx scripts/test-import-flow.ts import "dotenv/config"; import type { WorkOrderExtraction } from "../src/server/ai/providers"; import { dbForTenant, prisma } from "../src/server/db"; import { ROLE_DEFS } from "../src/server/rbac"; import { ServiceError, type ServiceCtx } from "../src/server/services/context"; import { createImport } from "../src/server/services/imports/upload"; import { processImport } from "../src/server/services/imports/process"; import { confirmImport, discardImport, retryImport } from "../src/server/services/imports/confirm"; import { getImportDetail, getImportFile, listImports, searchCustomers } from "../src/server/services/imports/queries"; import { FakeExtractionProvider } from "../src/server/ai/extraction/fake"; import { emptyExtraction } from "../src/lib/imports/extraction"; import { extractionToForm } from "../src/lib/imports/review"; import { buildPdf } from "./make-sample-pdfs"; let failures = 0; const ok = (cond: boolean, msg: string) => { console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); if (!cond) failures++; }; async function code(p: Promise): Promise { try { await p; return "ok"; } catch (e) { return e instanceof ServiceError ? e.code : `error:${(e as Error).message}`; } } const SLUG_A = "zz-import-a"; const SLUG_B = "zz-import-b"; async function cleanup() { const tenants = await prisma.tenant.findMany({ where: { slug: { in: [SLUG_A, SLUG_B] } }, select: { id: true } }); const ids = tenants.map((t) => t.id); if (!ids.length) return; const w = { tenantId: { in: ids } }; await prisma.auditLog.deleteMany({ where: w }); await prisma.aiGeneration.deleteMany({ where: w }); await prisma.materialPlan.deleteMany({ where: w }); await prisma.workOrderStatusChange.deleteMany({ where: w }); await prisma.document.updateMany({ where: w, data: { workOrderId: null, siteId: null, customerId: null } }); await prisma.workOrder.deleteMany({ where: w }); await prisma.importJob.deleteMany({ where: w }); await prisma.document.deleteMany({ where: w }); await prisma.site.deleteMany({ where: w }); await prisma.contact.deleteMany({ where: w }); await prisma.customer.deleteMany({ where: w }); await prisma.numberSequence.deleteMany({ where: w }); await prisma.tenant.deleteMany({ where: { id: { in: ids } } }); } const ctxFor = (tenantId: string, role: keyof typeof ROLE_DEFS, userId: string): ServiceCtx => ({ db: dbForTenant(tenantId), tenantId, userId, permissions: new Set(ROLE_DEFS[role].permissions), }); const PDF = buildPdf([[{ text: "Auftragsbestätigung AB-2026-0815" }, { text: "Musterbau GmbH, Hafenstraße 12, 20457 Hamburg" }]]); const loadBytes = async () => PDF; const noDispatch = async () => undefined; function musterbauExtraction(): Partial { return { orderNumber: { value: "AB-2026-0815", confidence: 0.97 }, 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: "Speicherhof", confidence: 0.7, source: "Bauvorhaben: Speicherhof" }, siteAddress: { value: { street: "Am Kaiserkai", houseNumber: "30", postalCode: "20457", city: "Hamburg" }, confidence: 0.9 }, plannedStart: { value: "12.10.2026", confidence: 0.9 }, plannedEnd: { value: "16.10.2026", confidence: 0.9 }, title: { value: "Montage Wärmepumpe", confidence: 0.85 }, positions: { value: [ { name: "Wärmepumpe Aerotherm 12 kW", articleNumber: "WP-AT-12", quantity: 1, unit: "Stk", isMaterial: true }, { name: "Montage", quantity: 8, unit: "Std", isMaterial: false }, ], confidence: 0.88, }, }; } async function main() { await cleanup(); const tA = await prisma.tenant.create({ data: { name: "ZZ Import A", slug: SLUG_A } }); const tB = await prisma.tenant.create({ data: { name: "ZZ Import B", slug: SLUG_B } }); const boA = ctxFor(tA.id, "backoffice", "zz-user-bo-a"); const techA = ctxFor(tA.id, "technician", "zz-user-tech-a"); const leadA = ctxFor(tA.id, "team-lead", "zz-user-lead-a"); const boB = ctxFor(tB.id, "backoffice", "zz-user-bo-b"); // Existing master data in A (duplicate + site candidate). const existing = await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-10042", companyName: "Musterbau GmbH", street: "Hafenstrasse", houseNumber: "12", postalCode: "20457", city: "Hamburg" }, }); const existingSite = await prisma.site.create({ data: { tenantId: tA.id, customerId: existing.id, name: "Speicherhof", street: "Am Kaiserkai", houseNumber: "30", postalCode: "20457", city: "Hamburg" }, }); await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-99999", companyName: "Andere Firma AG", postalCode: "80331", city: "München" } }); const foreignCustomer = await prisma.customer.create({ data: { tenantId: tB.id, customerNumber: "K-10042", companyName: "Musterbau GmbH", postalCode: "20457" } }); // ---------- upload ---------- const dispatched: unknown[] = []; const job1 = await createImport(boA, { bytes: PDF, fileName: "../AB 2026-0815.pdf", mimeType: "application/pdf" }, { dispatch: async (p) => void dispatched.push(p) }); const doc1 = await prisma.document.findUnique({ where: { id: job1.documentId } }); ok(job1.status === "uploaded" && job1.importedById === boA.userId, "(U1) upload creates ImportJob uploaded with importing user"); ok(doc1?.category === "order_confirmation" && doc1.visibility === "backoffice_only" && doc1.mimeType === "application/pdf", "(U2) document category/visibility/type"); ok(doc1?.fileName === "AB 2026-0815.pdf" && doc1.checksum.length === 64, "(U3) file name normalised, SHA-256 stored"); ok(dispatched.length === 1 && (dispatched[0] as { tenantId: string; entityId: string }).entityId === job1.id && (dispatched[0] as { tenantId: string }).tenantId === tA.id, "(U4) extraction job dispatched with tenant + import id"); ok((await prisma.auditLog.count({ where: { tenantId: tA.id, entity: "import_job", entityId: job1.id, action: "import" } })) === 1, "(U5) upload audited"); ok((await code(createImport(boA, { bytes: Buffer.from("MZ not a pdf"), fileName: "x.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch }))) === "invalid", "(U6) wrong magic bytes rejected"); const big = Buffer.concat([Buffer.from("%PDF-1.4\n"), Buffer.alloc(25 * 1024 * 1024)]); ok((await code(createImport(boA, { bytes: big, fileName: "big.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch }))) === "invalid", "(U7) file > 25 MB rejected"); ok((await code(createImport(boA, { bytes: PDF, fileName: "x.png", mimeType: "image/png" }, { dispatch: noDispatch }))) === "invalid", "(U8) declared type must match content"); ok((await code(createImport(boA, { bytes: PDF, fileName: "x.docx", mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }, { dispatch: noDispatch }))) === "invalid", "(U9) non-allowlisted type rejected"); // ---------- processing ---------- const provider = new FakeExtractionProvider({ extraction: musterbauExtraction(), text: "Auftragsbestätigung AB-2026-0815 Musterbau GmbH" }); const processed = await processImport(boA, job1.id, { provider, loadBytes, now: new Date("2026-09-14T10:00:00Z") }); ok(processed.status === "review_required", "(X1) processing → review_required"); ok(provider.calls.length === 1 && provider.calls[0].mimeType === "application/pdf", "(X2) provider received the stored PDF"); ok(processed.extractedText?.includes("AB-2026-0815") === true && processed.provider === "fake" && processed.extractionModel === "fake-extraction-1" && processed.extractionVersion !== null, "(X3) text, provider, model and version stored"); const dups = processed.duplicateCandidates as Array<{ customerId: string; score: number; reasons: string[] }>; ok(dups[0]?.customerId === existing.id && dups[0].reasons.includes("customer_number") && dups[0].reasons.includes("address"), "(X4) existing customer found via number + address (Straße/Strasse)"); ok(!dups.some((d) => d.customerId === foreignCustomer.id), "(X5) duplicate check never returns customers of another tenant"); const stored = processed.extraction as { siteCandidates: Array<{ siteId: string }>; fields: WorkOrderExtraction }; ok(stored.siteCandidates[0]?.siteId === existingSite.id, "(X6) site candidate at same address found"); ok(stored.fields.plannedStart.value === "2026-10-12", "(X7) plausibility normalised dates before storing"); ok((await prisma.aiGeneration.count({ where: { tenantId: tA.id, entityId: job1.id, kind: "import_extraction" } })) === 1, "(X8) AiGeneration recorded"); ok((await prisma.workOrder.count({ where: { tenantId: tA.id } })) === 0, "(X9) no work order without confirmation"); const again = await processImport(boA, job1.id, { provider, loadBytes }); ok(again.status === "review_required" && provider.calls.length === 1, "(X10) processing is idempotent"); // ---------- roles ---------- ok((await code(listImports(techA))) === "forbidden", "(R1) technician cannot list imports"); ok((await code(getImportDetail(techA, job1.id))) === "forbidden", "(R2) technician cannot read an import"); ok((await code(getImportFile(leadA, job1.id))) === "forbidden", "(R3) team lead cannot load the original document"); ok((await code(createImport(techA, { bytes: PDF, fileName: "a.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch }))) === "forbidden", "(R4) technician cannot upload"); ok((await code(confirmImport(techA, job1.id, extractionToForm(stored.fields)))) === "forbidden", "(R5) technician cannot confirm"); ok((await code(discardImport(techA, job1.id))) === "forbidden", "(R6) technician cannot discard"); ok((await code(searchCustomers(techA, "Muster"))) === "forbidden", "(R7) technician cannot use the import customer search"); // ---------- tenant isolation ---------- ok((await code(getImportDetail(boB, job1.id))) === "not_found", "(T1) tenant B cannot read A's import"); ok((await code(getImportFile(boB, job1.id))) === "not_found", "(T2) tenant B cannot load A's document"); ok((await code(confirmImport(boB, job1.id, extractionToForm(stored.fields)))) === "not_found", "(T3) tenant B cannot confirm A's import"); ok((await code(discardImport(boB, job1.id))) === "not_found", "(T4) tenant B cannot discard A's import"); ok((await code(retryImport(boB, job1.id, { dispatch: noDispatch }))) === "not_found", "(T5) tenant B cannot retry A's import"); ok((await code(processImport(boB, job1.id, { provider, loadBytes }))) === "not_found", "(T6) tenant B context cannot process A's import"); ok(!(await listImports(boB)).some((i) => i.id === job1.id), "(T7) A's import not listed for B"); ok(!(await searchCustomers(boB, "Muster")).some((c) => c.id === existing.id), "(T8) customer search stays in tenant"); ok((await prisma.importJob.findUnique({ where: { id: job1.id } }))?.status === "review_required", "(T9) A's import unchanged after B's attempts"); // B's own import may not reference A's customer/site. const jobB = await createImport(boB, { bytes: PDF, fileName: "b.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch }); await processImport(boB, jobB.id, { provider: new FakeExtractionProvider({ extraction: musterbauExtraction() }), loadBytes }); const formB = extractionToForm((await getImportDetail(boB, jobB.id)).extraction.fields, { customerId: existing.id, siteId: existingSite.id }); ok((await code(confirmImport(boB, jobB.id, formB))) === "not_found", "(T10) B cannot assign A's customer to its import"); const detailB = await getImportDetail(boB, jobB.id); ok(detailB.customerCandidates.every((c) => c.customerId === foreignCustomer.id), "(T11) B's candidates only from B"); // ---------- confirm: existing customer ---------- const detail1 = await getImportDetail(boA, job1.id); ok(detail1.customerCandidates[0]?.customer.companyName === "Musterbau GmbH" && detail1.customerCandidates[0].customer.sites.length === 1, "(K1) detail enriches candidates with customer and sites"); const form1 = extractionToForm(detail1.extraction.fields, { customerId: existing.id, siteId: existingSite.id }); form1.order!.title = "Wärmepumpe Speicherhof montieren"; ok((await code(confirmImport(boA, job1.id, { ...form1, customerId: "" }))) === "invalid", "(K2) invalid form rejected (existing customer without id)"); const customersBefore = await prisma.customer.count({ where: { tenantId: tA.id } }); const res1 = await confirmImport(boA, job1.id, form1); const wo1 = await prisma.workOrder.findUnique({ where: { id: res1.workOrderId }, include: { statusHistory: { orderBy: { createdAt: "asc" } }, materialPlans: true } }); ok(wo1?.status === "planned" && wo1.sourceImportId === job1.id && wo1.customerId === existing.id && wo1.siteId === existingSite.id, "(K3) work order planned, linked to import, customer and site"); ok(wo1?.number.startsWith("A-") === true && wo1.externalOrderNumber === "AB-2026-0815" && wo1.plannedStart?.toISOString().startsWith("2026-10-12") === true, "(K4) number allocated, document order number and dates taken over"); ok(wo1?.statusHistory.map((s) => s.toStatus).join(">") === "review_required>planned", "(K5) status history review_required → planned"); ok(wo1?.materialPlans.length === 1 && wo1.materialPlans[0].articleNumber === "WP-AT-12", "(K6) only positions marked as material become material plan"); ok((await prisma.customer.count({ where: { tenantId: tA.id } })) === customersBefore, "(K7) no new customer when existing one is used"); const docAfter = await prisma.document.findUnique({ where: { id: job1.documentId } }); ok(docAfter?.workOrderId === wo1?.id && docAfter?.customerId === existing.id && docAfter?.siteId === existingSite.id, "(K8) original document linked to work order, customer and site"); const job1After = await prisma.importJob.findUnique({ where: { id: job1.id } }); const corr = (job1After?.corrections as { fields: Record }).fields; ok(job1After?.status === "confirmed" && job1After.confirmedById === boA.userId && job1After.confirmedAt !== null, "(K9) import confirmed with user and time"); ok(corr["order.title"]?.to === "Wärmepumpe Speicherhof montieren" && corr["order.title"]?.from === "Montage Wärmepumpe", "(K10) corrections stored as diff"); ok((await prisma.auditLog.count({ where: { tenantId: tA.id, entity: "import_job", entityId: job1.id, action: "import", after: { path: ["status"], equals: "confirmed" } } })) === 1, "(K11) confirmation audited as import"); ok((await prisma.auditLog.count({ where: { tenantId: tA.id, entity: "work_order", entityId: res1.workOrderId, action: "create" } })) === 1, "(K12) work order creation audited"); ok((await code(confirmImport(boA, job1.id, form1))) === "conflict", "(K13) second confirmation → conflict (no duplicate order)"); ok((await prisma.workOrder.count({ where: { tenantId: tA.id } })) === 1, "(K14) still exactly one work order"); ok((await code(discardImport(boA, job1.id))) === "conflict", "(K15) confirmed import cannot be discarded"); // ---------- confirm: new customer ---------- const job2 = await createImport(boA, { bytes: PDF, fileName: "neu.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch }); const ex2 = { ...musterbauExtraction(), customerNumber: { value: null, confidence: 0 }, companyName: { value: "Neubau Kraft KG", confidence: 0.94 }, customerAddress: { value: { street: "Ringstraße", houseNumber: "5", postalCode: "22761", city: "Hamburg" }, confidence: 0.9 }, contactName: { value: "Olaf Kraft", confidence: 0.9 }, email: { value: "o.kraft@example.org", confidence: 0.9 }, siteName: { value: "Lagerhalle Nord", confidence: 0.9 }, siteAddress: { value: { street: "Nordkanalstraße", houseNumber: "1", postalCode: "20097", city: "Hamburg" }, confidence: 0.9 }, }; await processImport(boA, job2.id, { provider: new FakeExtractionProvider({ extraction: ex2 }), loadBytes }); const detail2 = await getImportDetail(boA, job2.id); ok(detail2.customerCandidates.length === 0, "(N1) unknown customer → no duplicate candidates"); const form2 = extractionToForm(detail2.extraction.fields); ok(form2.customerMode === "new" && form2.siteMode === "new", "(N2) form proposes new customer and new site"); const res2 = await confirmImport(boA, job2.id, form2); const cust2 = await prisma.customer.findUnique({ where: { id: res2.customerId }, include: { contacts: true, sites: true } }); ok(cust2?.companyName === "Neubau Kraft KG" && cust2.customerNumber?.startsWith("K-") === true && cust2.tenantId === tA.id, "(N3) new customer created in tenant with K- number"); ok(cust2?.contacts[0]?.name === "Olaf Kraft" && res2.contactId === cust2.contacts[0].id, "(N4) contact created"); ok(cust2?.sites[0]?.name === "Lagerhalle Nord" && res2.siteId === cust2.sites[0].id && cust2.sites[0].contactId === res2.contactId, "(N5) site created with contact"); ok(Object.keys((await prisma.importJob.findUnique({ where: { id: job2.id } }))!.corrections as object).length > 0, "(N6) decisions/corrections stored"); ok((await prisma.auditLog.count({ where: { tenantId: tA.id, entity: "customer", entityId: res2.customerId, action: "create" } })) === 1, "(N7) customer creation audited"); // New customer with a customer number that already exists → conflict, nothing created. const job3 = await createImport(boA, { bytes: PDF, fileName: "dup.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch }); await processImport(boA, job3.id, { provider: new FakeExtractionProvider({ extraction: musterbauExtraction() }), loadBytes }); const form3 = extractionToForm((await getImportDetail(boA, job3.id)).extraction.fields); const woCount = await prisma.workOrder.count({ where: { tenantId: tA.id } }); ok((await code(confirmImport(boA, job3.id, form3))) === "conflict", "(N8) new customer with taken customer number → conflict"); ok((await prisma.workOrder.count({ where: { tenantId: tA.id } })) === woCount && (await prisma.importJob.findUnique({ where: { id: job3.id } }))?.status === "review_required", "(N9) nothing created, import stays reviewable"); // ---------- discard ---------- await discardImport(boA, job3.id); ok((await prisma.importJob.findUnique({ where: { id: job3.id } }))?.status === "discarded", "(D1) discard → discarded"); ok((await code(confirmImport(boA, job3.id, form3))) === "conflict", "(D2) discarded import cannot be confirmed"); ok((await prisma.document.findUnique({ where: { id: job3.documentId } })) !== null, "(D3) original document kept after discard"); // ---------- provider failure + retry ---------- const job4 = await createImport(boA, { bytes: PDF, fileName: "fail.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch }); const failed = await processImport(boA, job4.id, { provider: new FakeExtractionProvider({ fail: new Error("Claude API error 529 (OverloadedError)") }), loadBytes }); ok(failed.status === "failed" && failed.errorMessage?.includes("529") === true, "(F1) provider error → failed with message"); ok((await prisma.auditLog.count({ where: { tenantId: tA.id, entityId: job4.id, after: { path: ["status"], equals: "failed" } } })) === 1, "(F2) failure audited"); ok((await code(confirmImport(boA, job4.id, form3))) === "conflict", "(F3) failed import cannot be confirmed"); const missing = await createImport(boA, { bytes: PDF, fileName: "missing.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch }); const noBytes = await processImport(boA, missing.id, { provider, loadBytes: async () => null }); ok(noBytes.status === "failed" && noBytes.errorMessage === "file_unavailable", "(F4) unavailable file → failed (file_unavailable)"); const redispatched: unknown[] = []; await retryImport(boA, job4.id, { dispatch: async (p) => void redispatched.push(p) }); ok((await prisma.importJob.findUnique({ where: { id: job4.id } }))?.status === "uploaded" && redispatched.length === 1, "(F5) retry → uploaded + dispatched again"); const retried = await processImport(boA, job4.id, { provider: new FakeExtractionProvider({ extraction: musterbauExtraction() }), loadBytes }); ok(retried.status === "review_required" && retried.errorMessage === null, "(F6) retried processing succeeds"); ok((await code(retryImport(boA, job4.id, { dispatch: noDispatch }))) === "conflict", "(F7) only failed imports can be retried"); const dispatchFail = await createImport(boA, { bytes: PDF, fileName: "q.pdf", mimeType: "application/pdf" }, { dispatch: async () => { throw new Error("redis down"); } }); ok((await prisma.importJob.findUnique({ where: { id: dispatchFail.id } }))?.status === "failed", "(F8) dispatch failure → failed (retryable)"); // ---------- no provider ---------- const job5 = await createImport(boA, { bytes: PDF, fileName: "manual.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch }); const manual = await processImport(boA, job5.id, { provider: null, loadBytes }); const manualStored = manual.extraction as { hints: Array<{ code: string }>; fields: WorkOrderExtraction }; ok(manual.status === "review_required" && manualStored.hints.some((h) => h.code === "manual_entry"), "(M1) without provider → review_required with manual-entry hint"); const emptyKeys = Object.keys(emptyExtraction()); ok( emptyKeys.every((k) => { const f = (manualStored.fields as Record)[k]; return f?.value === null && f.confidence === 0; }) && manual.provider === null, "(M2) empty extraction, no provider recorded", ); ok((await prisma.aiGeneration.count({ where: { tenantId: tA.id, entityId: job5.id } })) === 0, "(M3) no AiGeneration without AI use"); await cleanup(); if (failures === 0) console.log("\nOK — Importablauf, Mandantentrennung und Rollen erfüllt."); else console.log(`\n${failures} FEHLER.`); await prisma.$disconnect(); process.exit(failures === 0 ? 0 : 1); } main().catch(async (e) => { console.error(e); await cleanup().catch(() => {}); await prisma.$disconnect(); process.exit(1); });