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