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
+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;
}