Merge lane/import in feature/craftvia-mvp
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { moduleGuard } from "@/server/action-guard";
|
||||
import { storage } from "@/server/storage/adapter";
|
||||
import { ctxFromGuard } from "@/server/services/context";
|
||||
import { getImportFile } from "@/server/services/imports/queries";
|
||||
|
||||
/**
|
||||
* GET /imports/[id]/file — original document of an import for the review mask preview.
|
||||
* Authorisation: session + DB-authoritative permission import:write + module + document
|
||||
* visibility (getImportFile). Unknown/foreign ids → 404. `?download=1` forces a download.
|
||||
* Inline delivery only for the allowlisted types stored by the import (PDF/JPEG/PNG) and
|
||||
* only framable by the own origin.
|
||||
*/
|
||||
const INLINE = new Set(["application/pdf", "image/jpeg", "image/png"]);
|
||||
|
||||
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
let file: { storageKey: string; mimeType: string; fileName: string };
|
||||
try {
|
||||
const ctx = ctxFromGuard(await moduleGuard("imports")("import:write"));
|
||||
const { id } = await params;
|
||||
file = await getImportFile(ctx, id);
|
||||
} catch {
|
||||
return new Response("Nicht gefunden.", { status: 404 });
|
||||
}
|
||||
|
||||
const content = await storage.get(file.storageKey);
|
||||
if (!content) return new Response("Datei nicht verfügbar.", { status: 404 });
|
||||
|
||||
const download = new URL(req.url).searchParams.get("download") === "1";
|
||||
const inline = !download && INLINE.has(file.mimeType);
|
||||
const asciiName = file.fileName.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
|
||||
const headers = new Headers({
|
||||
"Content-Type": INLINE.has(file.mimeType) ? file.mimeType : "application/octet-stream",
|
||||
"Content-Disposition": `${inline ? "inline" : "attachment"}; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(file.fileName)}`,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cache-Control": "private, no-store",
|
||||
"Content-Security-Policy": "frame-ancestors 'self'",
|
||||
"X-Frame-Options": "SAMEORIGIN",
|
||||
});
|
||||
if (content.size != null) headers.set("Content-Length", String(content.size));
|
||||
return new Response(content.stream, { headers });
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { ArrowLeft, CheckCircle2, Download, ExternalLink, Info, Loader2, XCircle } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { getImportDetail } from "@/server/services/imports/queries";
|
||||
import { confirmImportAction, discardImportAction, retryImportAction, searchCustomersAction } from "@/server/actions/imports/imports";
|
||||
import { PageHead } from "@/components/mockup-ui";
|
||||
import { ImportStatusPill } from "@/components/imports/status-pill";
|
||||
import { AutoRefresh } from "@/components/imports/auto-refresh";
|
||||
import { DiscardImportButton, RetryImportButton } from "@/components/imports/job-actions";
|
||||
import { ImportReviewForm } from "@/components/imports/review-form";
|
||||
import { extractionToForm, formFieldMeta } from "@/lib/imports/review";
|
||||
import { IMPORT_IN_PROGRESS } from "@/lib/imports/status";
|
||||
|
||||
/** /imports/[id] — review mask: original document left, form right (spec §9.6, US-002/003). */
|
||||
export default async function ImportDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await requireSession();
|
||||
if (!hasPermission(session, "import:write")) redirect("/imports");
|
||||
const { id } = await params;
|
||||
const t = await getTranslations("imports");
|
||||
const locale = await getLocale();
|
||||
const ctx: ServiceCtx = {
|
||||
db: dbForTenant(session.user.tenantId),
|
||||
tenantId: session.user.tenantId,
|
||||
userId: session.user.id,
|
||||
permissions: new Set(session.user.permissions),
|
||||
};
|
||||
|
||||
let detail;
|
||||
try {
|
||||
detail = await getImportDetail(ctx, id);
|
||||
} catch (err) {
|
||||
if (err instanceof ServiceError) notFound();
|
||||
throw err;
|
||||
}
|
||||
|
||||
const fmt = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" });
|
||||
const fileUrl = `/imports/${detail.id}/file`;
|
||||
const isPdf = detail.document?.mimeType === "application/pdf";
|
||||
const fields = detail.extraction.fields;
|
||||
const top = detail.customerCandidates[0];
|
||||
const topSite = top ? detail.siteCandidates.find((s) => s.customerId === top.customerId) : undefined;
|
||||
const inProgress = IMPORT_IN_PROGRESS.includes(detail.status);
|
||||
const errorKey = detail.errorMessage && t.has(`errors.${detail.errorMessage}`) ? `errors.${detail.errorMessage}` : null;
|
||||
const correctionCount =
|
||||
detail.corrections && typeof detail.corrections === "object" && "fields" in detail.corrections
|
||||
? Object.keys((detail.corrections as { fields?: Record<string, unknown> }).fields ?? {}).length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-4 sm:p-6">
|
||||
<AutoRefresh active={inProgress} />
|
||||
<Link href="/imports" className="inline-flex min-h-11 items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-4" aria-hidden /> {t("detail.back")}
|
||||
</Link>
|
||||
<PageHead
|
||||
crumb={t("title")}
|
||||
title={detail.document?.fileName ?? t("title")}
|
||||
sub={t("detail.meta", { file: detail.document?.fileName ?? "—", date: fmt.format(detail.createdAt), user: detail.importedByName ?? "—" })}
|
||||
actions={<ImportStatusPill status={detail.status} label={t(`status.${detail.status}`)} />}
|
||||
/>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)]">
|
||||
{/* Original document */}
|
||||
<section aria-labelledby="imp-preview" className="lg:sticky lg:top-20 lg:self-start">
|
||||
<div className="shadow-card rounded-xl border bg-card p-3">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2 px-1">
|
||||
<h2 id="imp-preview" className="font-heading text-[15px] font-semibold">{t("detail.preview")}</h2>
|
||||
<div className="flex gap-3 text-[12.5px] font-semibold">
|
||||
<a href={fileUrl} target="_blank" rel="noreferrer" className="inline-flex min-h-11 items-center gap-1 text-[var(--ui-primary)] hover:underline">
|
||||
<ExternalLink className="size-3.5" aria-hidden /> {t("detail.openNewTab")}
|
||||
</a>
|
||||
<a href={`${fileUrl}?download=1`} className="inline-flex min-h-11 items-center gap-1 text-[var(--ui-primary)] hover:underline">
|
||||
<Download className="size-3.5" aria-hidden /> {t("detail.download")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{isPdf ? (
|
||||
<iframe src={fileUrl} title={t("detail.previewTitle")} className="h-[55vh] w-full rounded-lg border bg-muted lg:h-[calc(100vh-11rem)]" />
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- authenticated same-origin file, no optimisation
|
||||
<img src={fileUrl} alt={t("detail.previewTitle")} className="max-h-[calc(100vh-11rem)] w-full rounded-lg border object-contain" />
|
||||
)}
|
||||
{detail.extractionModel && <p className="mt-2 px-1 text-[12px] text-muted-foreground">{t("detail.model", { model: detail.extractionModel })}</p>}
|
||||
</div>
|
||||
<details className="shadow-card mt-3 rounded-xl border bg-card p-3">
|
||||
<summary className="min-h-11 cursor-pointer content-center px-1 font-heading text-sm font-semibold">{t("detail.extractedText")}</summary>
|
||||
<pre className="mt-2 max-h-80 overflow-auto whitespace-pre-wrap px-1 text-[12px]">{detail.extractedText || t("detail.noText")}</pre>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
{/* Status / review mask */}
|
||||
<section aria-labelledby="imp-review">
|
||||
<h2 id="imp-review" className="mb-1 font-heading text-[17px] font-semibold">{t("review.title")}</h2>
|
||||
|
||||
{inProgress && (
|
||||
<div className="shadow-card flex items-start gap-3 rounded-xl border bg-card p-5" role="status">
|
||||
<Loader2 className="mt-0.5 size-5 shrink-0 animate-spin text-[var(--info)]" aria-hidden />
|
||||
<p className="text-sm">{t("detail.processing")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.status === "failed" && (
|
||||
<div className="shadow-card space-y-3 rounded-xl border border-[var(--risk)] bg-card p-5" role="alert">
|
||||
<p className="flex items-center gap-2 font-heading text-sm font-semibold text-[var(--risk)]">
|
||||
<XCircle className="size-4" aria-hidden /> {t("detail.failed")}
|
||||
</p>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
{errorKey ? t(errorKey) : t("detail.failedDetail", { message: detail.errorMessage ?? "—" })}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<RetryImportButton action={retryImportAction.bind(null, detail.id)} />
|
||||
<DiscardImportButton action={discardImportAction.bind(null, detail.id)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.status === "confirmed" && (
|
||||
<div className="shadow-card space-y-2 rounded-xl border border-[var(--ok)] bg-card p-5" role="status">
|
||||
<p className="flex items-center gap-2 font-heading text-sm font-semibold text-[var(--ok)]">
|
||||
<CheckCircle2 className="size-4" aria-hidden /> {t("detail.confirmed", { number: detail.createdWorkOrder?.number ?? "—" })}
|
||||
</p>
|
||||
<p className="text-[13px] text-muted-foreground">{t("detail.corrections", { count: correctionCount })}</p>
|
||||
{detail.createdWorkOrder && (
|
||||
<Link href={`/work-orders/${detail.createdWorkOrder.id}`} className="inline-flex min-h-11 items-center rounded-lg bg-[var(--ui-accent)] px-4 font-heading text-[13px] font-semibold text-[var(--ui-accent-foreground)] hover:opacity-90">
|
||||
{t("detail.openWorkOrder")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.status === "discarded" && (
|
||||
<div className="shadow-card flex items-start gap-3 rounded-xl border bg-card p-5" role="status">
|
||||
<Info className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<p className="text-sm">{t("detail.discarded")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.status === "review_required" && (
|
||||
<>
|
||||
<p className="mb-3 text-[13px] text-muted-foreground">
|
||||
{detail.extraction.hints.some((h) => h.code === "manual_entry") ? t("review.manualEntry") : t("review.intro")}
|
||||
</p>
|
||||
<ImportReviewForm
|
||||
initial={extractionToForm(fields, { customerId: top && top.score >= 0.6 ? top.customerId : null, siteId: top && top.score >= 0.6 ? topSite?.siteId ?? null : null })}
|
||||
meta={formFieldMeta(fields)}
|
||||
hints={detail.extraction.hints.filter((h) => h.code !== "manual_entry")}
|
||||
customerCandidates={detail.customerCandidates}
|
||||
siteCandidates={detail.siteCandidates}
|
||||
confirmAction={confirmImportAction.bind(null, detail.id)}
|
||||
searchAction={searchCustomersAction}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<DiscardImportButton action={discardImportAction.bind(null, detail.id)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,107 @@
|
||||
import { ModulePlaceholder } from "@/components/module-placeholder";
|
||||
import Link from "next/link";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { listImports } from "@/server/services/imports/queries";
|
||||
import { retryImportAction } from "@/server/actions/imports/imports";
|
||||
import { PageHead } from "@/components/mockup-ui";
|
||||
import { ImportUploader } from "@/components/imports/uploader";
|
||||
import { ImportStatusPill } from "@/components/imports/status-pill";
|
||||
import { AutoRefresh } from "@/components/imports/auto-refresh";
|
||||
import { RetryImportButton } from "@/components/imports/job-actions";
|
||||
import { IMPORT_IN_PROGRESS } from "@/lib/imports/status";
|
||||
|
||||
export default function Page() {
|
||||
return <ModulePlaceholder moduleKey="imports" />;
|
||||
/** /imports — upload + list of imports with status (spec §9, US-002). */
|
||||
export default async function ImportsPage() {
|
||||
const session = await requireSession();
|
||||
const t = await getTranslations("imports");
|
||||
const locale = await getLocale();
|
||||
|
||||
if (!hasPermission(session, "import:write")) {
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead crumb={t("crumb")} title={t("title")} />
|
||||
<p className="shadow-card rounded-xl border bg-card p-5 text-sm text-muted-foreground">{t("noPermission")}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const ctx: ServiceCtx = {
|
||||
db: dbForTenant(session.user.tenantId),
|
||||
tenantId: session.user.tenantId,
|
||||
userId: session.user.id,
|
||||
permissions: new Set(session.user.permissions),
|
||||
};
|
||||
const imports = await listImports(ctx);
|
||||
const fmt = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" });
|
||||
const inProgress = imports.some((i) => IMPORT_IN_PROGRESS.includes(i.status));
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-4 sm:p-6">
|
||||
<AutoRefresh active={inProgress} />
|
||||
<PageHead crumb={t("crumb")} title={t("title")} sub={t("sub")} />
|
||||
|
||||
<section aria-labelledby="imp-upload" className="max-w-3xl">
|
||||
<h2 id="imp-upload" className="sr-only">{t("upload.title")}</h2>
|
||||
<ImportUploader />
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="imp-list" className="mt-6">
|
||||
<h2 id="imp-list" className="mb-3 font-heading text-[15px] font-semibold">{t("list.title")}</h2>
|
||||
{imports.length === 0 ? (
|
||||
<p className="shadow-card rounded-xl border bg-card p-5 text-sm text-muted-foreground">{t("list.empty")}</p>
|
||||
) : (
|
||||
<div className="shadow-card overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full min-w-[640px] text-left text-[13px]">
|
||||
<thead className="border-b text-[12px] text-muted-foreground">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-2.5 font-semibold">{t("list.file")}</th>
|
||||
<th scope="col" className="px-4 py-2.5 font-semibold">{t("list.uploadedAt")}</th>
|
||||
<th scope="col" className="px-4 py-2.5 font-semibold">{t("list.uploadedBy")}</th>
|
||||
<th scope="col" className="px-4 py-2.5 font-semibold">{t("list.status")}</th>
|
||||
<th scope="col" className="px-4 py-2.5 font-semibold">{t("list.action")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{imports.map((imp) => (
|
||||
<tr key={imp.id} className="border-b last:border-0">
|
||||
<td className="max-w-[280px] truncate px-4 py-2.5 font-semibold">
|
||||
<Link href={`/imports/${imp.id}`} className="hover:underline">
|
||||
{imp.document?.fileName ?? "—"}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 whitespace-nowrap">{fmt.format(imp.createdAt)}</td>
|
||||
<td className="px-4 py-2.5">{imp.importedByName ?? "—"}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<ImportStatusPill status={imp.status} label={t(`status.${imp.status}`)} />
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{imp.status === "review_required" && (
|
||||
<Link href={`/imports/${imp.id}`} className="inline-flex min-h-11 items-center rounded-lg bg-[var(--ui-accent)] px-4 font-heading text-[13px] font-semibold text-[var(--ui-accent-foreground)] hover:opacity-90">
|
||||
{t("list.review")}
|
||||
</Link>
|
||||
)}
|
||||
{imp.status === "failed" && <RetryImportButton action={retryImportAction.bind(null, imp.id)} size="sm" />}
|
||||
{imp.status === "confirmed" && imp.createdWorkOrder && (
|
||||
<Link href={`/work-orders/${imp.createdWorkOrder.id}`} className="inline-flex min-h-11 items-center font-semibold text-[var(--ui-primary)] hover:underline">
|
||||
{t("list.workOrder", { number: imp.createdWorkOrder.number })}
|
||||
</Link>
|
||||
)}
|
||||
{(imp.status === "uploaded" || imp.status === "processing" || imp.status === "discarded") && (
|
||||
<Link href={`/imports/${imp.id}`} className="inline-flex min-h-11 items-center font-semibold text-[var(--ui-primary)] hover:underline">
|
||||
{t("list.open")}
|
||||
</Link>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { confirmImport } from "@/server/services/imports/confirm";
|
||||
import { apiError, importsApiContext } from "../../_context";
|
||||
|
||||
/**
|
||||
* POST /api/v1/imports/[id]/confirm — JSON body = review form (src/lib/imports/review.ts
|
||||
* `reviewFormSchema`). Creates/assigns customer, site, contact and the work order. Lane L3.
|
||||
*/
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const ctx = await importsApiContext("import:write", "work_order:write");
|
||||
const { id } = await params;
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "invalid", message: "json_required" }, { status: 400 });
|
||||
}
|
||||
return Response.json(await confirmImport(ctx, id, body));
|
||||
} catch (err) {
|
||||
return apiError(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { getImportDetail } from "@/server/services/imports/queries";
|
||||
import { apiError, importsApiContext } from "../_context";
|
||||
|
||||
/** GET /api/v1/imports/[id] — import status, extraction (with confidences), candidates. Lane L3. */
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const ctx = await importsApiContext("import:write");
|
||||
const { id } = await params;
|
||||
return Response.json(await getImportDetail(ctx, id));
|
||||
} catch (err) {
|
||||
return apiError(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { moduleGuard } from "@/server/action-guard";
|
||||
import { ForbiddenError, type Permission } from "@/server/rbac";
|
||||
import { ModuleDisabledError } from "@/server/modules";
|
||||
import { ctxFromGuard, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
|
||||
/**
|
||||
* Lane L3 helper for the /api/v1 import handlers (thin adapters). Uses the same DB-authoritative
|
||||
* guard as the server actions (session → account/identity status → permissions → module).
|
||||
* Not a route: files starting with "_" are ignored by the App Router.
|
||||
* TODO(architecture): replace with a shared `requireApiContext` once it exists.
|
||||
*/
|
||||
const guard = moduleGuard("imports");
|
||||
|
||||
export async function importsApiContext(...permissions: Permission[]): Promise<ServiceCtx> {
|
||||
return ctxFromGuard(await guard(...permissions));
|
||||
}
|
||||
|
||||
const STATUS: Record<ServiceError["code"], number> = { not_found: 404, forbidden: 403, invalid: 400, conflict: 409, blocked: 409 };
|
||||
|
||||
/** Map service/guard errors to JSON responses without leaking internals. */
|
||||
export function apiError(err: unknown): Response {
|
||||
if (err instanceof ServiceError) {
|
||||
return Response.json({ error: err.code, message: err.message, details: err.code === "invalid" ? err.details : undefined }, { status: STATUS[err.code] });
|
||||
}
|
||||
if (err instanceof ForbiddenError || err instanceof ModuleDisabledError) {
|
||||
return Response.json({ error: "forbidden" }, { status: 403 });
|
||||
}
|
||||
if (err instanceof Error && /Nicht angemeldet|nicht mehr gueltig/.test(err.message)) {
|
||||
return Response.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (err instanceof Error && /Konto ist nicht aktiv|Passwortwechsel/.test(err.message)) {
|
||||
return Response.json({ error: "forbidden" }, { status: 403 });
|
||||
}
|
||||
console.error("[api/imports]", err);
|
||||
return Response.json({ error: "internal" }, { status: 500 });
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createImport } from "@/server/services/imports/upload";
|
||||
import { apiError, importsApiContext } from "../../imports/_context";
|
||||
|
||||
/**
|
||||
* POST /api/v1/work-orders/import — multipart upload of an order document (field `file`).
|
||||
* Lane L3 (import). Response 201 `{ id, status }`; the extraction runs in the background.
|
||||
* Note: bodies > 10 MB need `experimental.proxyClientMaxBodySize` in next.config.ts (see lane report).
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const ctx = await importsApiContext("import:write");
|
||||
let form: FormData;
|
||||
try {
|
||||
form = await req.formData();
|
||||
} catch {
|
||||
return Response.json({ error: "invalid", message: "multipart_required" }, { status: 400 });
|
||||
}
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File)) return Response.json({ error: "invalid", message: "file_missing" }, { status: 400 });
|
||||
const job = await createImport(ctx, {
|
||||
bytes: Buffer.from(await file.arrayBuffer()),
|
||||
fileName: file.name,
|
||||
mimeType: file.type,
|
||||
});
|
||||
const current = await ctx.db.importJob.findFirst({ where: { id: job.id }, select: { id: true, status: true } });
|
||||
return Response.json(current ?? { id: job.id, status: job.status }, { status: 201 });
|
||||
} catch (err) {
|
||||
return apiError(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
/** Re-renders the server page periodically while an import is being processed. */
|
||||
export function AutoRefresh({ active, intervalMs = 4000 }: { active: boolean; intervalMs?: number }) {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const id = setInterval(() => router.refresh(), intervalMs);
|
||||
return () => clearInterval(id);
|
||||
}, [active, intervalMs, router]);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { RotateCcw, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { SimpleState } from "@/server/actions/imports/imports";
|
||||
|
||||
type Action = (prev: SimpleState, formData: FormData) => Promise<SimpleState>;
|
||||
|
||||
function ErrorText({ state }: { state: SimpleState }) {
|
||||
const t = useTranslations("imports.errors");
|
||||
if (state.status !== "error") return null;
|
||||
return (
|
||||
<span role="alert" className="text-[12.5px] font-semibold text-[var(--risk)]">
|
||||
{t.has(state.code) ? t(state.code) : t("error")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function RetryImportButton({ action, size = "default" }: { action: Action; size?: "default" | "sm" }) {
|
||||
const t = useTranslations("imports.actions");
|
||||
const [state, formAction, pending] = useActionState(action, { status: "idle" } as SimpleState);
|
||||
return (
|
||||
<form action={formAction} className="inline-flex flex-wrap items-center gap-2">
|
||||
<Button type="submit" variant="outline" size={size} disabled={pending} className={size === "sm" ? "h-9" : "h-11 px-4"}>
|
||||
<RotateCcw aria-hidden /> {pending ? t("retrying") : t("retry")}
|
||||
</Button>
|
||||
<ErrorText state={state} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function DiscardImportButton({ action }: { action: Action }) {
|
||||
const t = useTranslations("imports.actions");
|
||||
const [state, formAction, pending] = useActionState(action, { status: "idle" } as SimpleState);
|
||||
return (
|
||||
<form
|
||||
action={formAction}
|
||||
onSubmit={(e) => {
|
||||
if (!window.confirm(t("discardConfirm"))) e.preventDefault();
|
||||
}}
|
||||
className="inline-flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<Button type="submit" variant="outline" disabled={pending} className="h-11 px-4">
|
||||
<Trash2 aria-hidden /> {t("discard")}
|
||||
</Button>
|
||||
<ErrorText state={state} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useMemo, useState, useTransition } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AlertTriangle, Info, Loader2, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FieldMeta, FormFieldPath, ReviewFormInput } from "@/lib/imports/review";
|
||||
import type { PlausibilityHint } from "@/lib/imports/extraction";
|
||||
import type { ConfirmState } from "@/server/actions/imports/imports";
|
||||
|
||||
export type SiteOption = { id: string; name: string; street: string | null; houseNumber: string | null; postalCode: string | null; city: string | null };
|
||||
export type CustomerOption = {
|
||||
id: string;
|
||||
customerNumber: string | null;
|
||||
companyName: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
postalCode: string | null;
|
||||
city: string | null;
|
||||
sites: SiteOption[];
|
||||
};
|
||||
export type CustomerCandidateView = { customerId: string; score: number; reasons: string[]; customer: CustomerOption };
|
||||
export type SiteCandidateView = { siteId: string; customerId: string; score: number; reasons: string[] };
|
||||
|
||||
type Props = {
|
||||
initial: ReviewFormInput;
|
||||
meta: Record<FormFieldPath, FieldMeta>;
|
||||
hints: PlausibilityHint[];
|
||||
customerCandidates: CustomerCandidateView[];
|
||||
siteCandidates: SiteCandidateView[];
|
||||
confirmAction: (prev: ConfirmState, formData: FormData) => Promise<ConfirmState>;
|
||||
searchAction: (q: string) => Promise<CustomerOption[]>;
|
||||
};
|
||||
|
||||
type Section = "customer" | "site" | "contact" | "order";
|
||||
type Position = NonNullable<ReviewFormInput["positions"]>[number];
|
||||
|
||||
const customerLabel = (c: CustomerOption) =>
|
||||
[c.companyName || [c.firstName, c.lastName].filter(Boolean).join(" ") || "—", c.customerNumber ? `(${c.customerNumber})` : null].filter(Boolean).join(" ");
|
||||
const siteLabel = (s: SiteOption) => [s.name, [s.street, s.houseNumber].filter(Boolean).join(" "), [s.postalCode, s.city].filter(Boolean).join(" ")].filter(Boolean).join(" · ");
|
||||
|
||||
/**
|
||||
* Review mask (spec §9.6): editable form in sections, uncertain fields (< 0.8) marked with
|
||||
* warning colour + icon + text, source snippet as tooltip; customer/site decisions; editable
|
||||
* line items with "als Materialvorgabe übernehmen". Submits the form as JSON to the confirm action.
|
||||
*/
|
||||
export function ImportReviewForm({ initial, meta, hints, customerCandidates, siteCandidates, confirmAction, searchAction }: Props) {
|
||||
const t = useTranslations("imports.review");
|
||||
const te = useTranslations("imports.errors");
|
||||
const ta = useTranslations("imports.actions");
|
||||
const [form, setForm] = useState<ReviewFormInput>(initial);
|
||||
const [state, formAction, pending] = useActionState(confirmAction, { status: "idle" } as ConfirmState);
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<CustomerOption[] | null>(null);
|
||||
const [searching, startSearch] = useTransition();
|
||||
|
||||
const issues = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
if (state.status === "error") for (const i of state.issues ?? []) if (!map.has(i.path)) map.set(i.path, i.message);
|
||||
return map;
|
||||
}, [state]);
|
||||
|
||||
const knownCustomers = useMemo(() => {
|
||||
const map = new Map<string, CustomerOption>();
|
||||
for (const c of customerCandidates) map.set(c.customer.id, c.customer);
|
||||
for (const c of results ?? []) map.set(c.id, c);
|
||||
return map;
|
||||
}, [customerCandidates, results]);
|
||||
const selectedCustomer = form.customerMode === "existing" && form.customerId ? knownCustomers.get(form.customerId) ?? null : null;
|
||||
|
||||
const set = <S extends Section>(section: S, key: keyof NonNullable<ReviewFormInput[S]>, value: string) =>
|
||||
setForm((f) => ({ ...f, [section]: { ...(f[section] as object), [key]: value } }));
|
||||
const setPosition = (index: number, patch: Partial<Position>) =>
|
||||
setForm((f) => ({ ...f, positions: (f.positions ?? []).map((p, i) => (i === index ? { ...p, ...patch } : p)) }));
|
||||
|
||||
const errorText = (path: string) => {
|
||||
const code = issues.get(path);
|
||||
return code ? (te.has(code) ? te(code) : te("form_invalid")) : null;
|
||||
};
|
||||
|
||||
function field(section: Section, key: string, labelKey: string, opts: { type?: string; multiline?: boolean; required?: boolean; className?: string; inputMode?: "numeric" | "tel" | "email" } = {}) {
|
||||
const path = `${section}.${key}`;
|
||||
const m = (meta as Record<string, FieldMeta | undefined>)[path];
|
||||
const value = String(((form[section] as Record<string, unknown>)[key] as string | undefined) ?? "");
|
||||
const id = `rv-${section}-${key}`;
|
||||
const err = errorText(path);
|
||||
const tip = m?.source ? t("source", { text: m.source }) : undefined;
|
||||
const common = {
|
||||
id,
|
||||
value,
|
||||
title: tip,
|
||||
"aria-invalid": err ? true : undefined,
|
||||
"aria-describedby": m?.uncertain || err ? `${id}-note` : undefined,
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => set(section, key as never, e.target.value),
|
||||
className: cn("mt-1 min-h-11", m?.uncertain && "border-[var(--warn)] bg-[color-mix(in_srgb,var(--warn)_7%,transparent)]"),
|
||||
};
|
||||
return (
|
||||
<div className={opts.className}>
|
||||
<Label htmlFor={id} className="flex flex-wrap items-center gap-x-2">
|
||||
<span>
|
||||
{t(`fields.${labelKey}`)}
|
||||
{opts.required && <span aria-label={t("required")}> *</span>}
|
||||
</span>
|
||||
{m?.uncertain && (
|
||||
<span className="inline-flex items-center gap-1 text-[11.5px] font-bold text-[var(--warn)]" title={tip}>
|
||||
<AlertTriangle className="size-3.5" aria-hidden />
|
||||
{t("uncertain")} · {t("confidence", { percent: Math.round(m.confidence * 100) })}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
{opts.multiline ? (
|
||||
<Textarea {...common} rows={4} />
|
||||
) : (
|
||||
<Input {...common} type={opts.type ?? "text"} inputMode={opts.inputMode} required={opts.required} />
|
||||
)}
|
||||
<div id={`${id}-note`}>
|
||||
{m?.uncertain && m.source && <p className="mt-0.5 truncate text-[11.5px] text-muted-foreground">{tip}</p>}
|
||||
{err && (
|
||||
<p role="alert" className="mt-0.5 text-[12px] font-semibold text-[var(--risk)]">
|
||||
{err}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const radio = (name: string, value: string, checked: boolean, onChange: () => void, label: React.ReactNode, extra?: React.ReactNode) => (
|
||||
<label className={cn("flex min-h-11 cursor-pointer items-start gap-2.5 rounded-lg border p-3 text-[13.5px]", checked ? "border-[var(--ui-primary)] bg-[var(--ui-primary-soft)]" : "border-border")}>
|
||||
<input type="radio" name={name} value={value} checked={checked} onChange={onChange} className="mt-1 size-4" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="font-semibold">{label}</span>
|
||||
{extra}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
const sectionClass = "shadow-card rounded-xl border bg-card p-4 sm:p-5";
|
||||
const h2 = "mb-3 font-heading text-[15px] font-semibold";
|
||||
const siteOptions = selectedCustomer?.sites ?? [];
|
||||
const siteScore = new Map(siteCandidates.map((s) => [s.siteId, s]));
|
||||
|
||||
return (
|
||||
<form action={formAction} className="space-y-4" noValidate>
|
||||
<input type="hidden" name="payload" value={JSON.stringify(form)} />
|
||||
|
||||
{hints.length > 0 && (
|
||||
<div className="rounded-xl border border-[var(--warn)] bg-[color-mix(in_srgb,var(--warn)_8%,transparent)] p-4" role="status">
|
||||
<p className="flex items-center gap-2 font-heading text-sm font-semibold text-[var(--warn)]">
|
||||
<AlertTriangle className="size-4" aria-hidden /> {t("hintsTitle")}
|
||||
</p>
|
||||
<ul className="mt-1.5 list-disc space-y-0.5 pl-6 text-[13px]">
|
||||
{hints.map((h, i) => (
|
||||
<li key={i}>{t(`hints.${h.code}`, { field: h.field ? t(`extractionFields.${h.field}`) : "" })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kunde */}
|
||||
<section className={sectionClass} aria-labelledby="rv-h-customer">
|
||||
<h2 id="rv-h-customer" className={h2}>{t("sections.customer")}</h2>
|
||||
<fieldset className="grid gap-2 sm:grid-cols-2">
|
||||
<legend className="sr-only">{t("sections.customer")}</legend>
|
||||
{radio("customerMode", "existing", form.customerMode === "existing", () => setForm((f) => ({ ...f, customerMode: "existing", customerId: f.customerId || customerCandidates[0]?.customerId || "" })), t("customer.existing"))}
|
||||
{radio("customerMode", "new", form.customerMode === "new", () => setForm((f) => ({ ...f, customerMode: "new", siteMode: f.siteMode === "existing" ? "new" : f.siteMode })), t("customer.new"))}
|
||||
</fieldset>
|
||||
|
||||
{form.customerMode === "existing" && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<p className="text-[12.5px] font-semibold text-muted-foreground">{t("customer.candidates")}</p>
|
||||
{customerCandidates.length === 0 && <p className="text-[13px] text-muted-foreground">{t("customer.noCandidates")}</p>}
|
||||
<div className="space-y-2">
|
||||
{customerCandidates.map((c) =>
|
||||
radio(
|
||||
"customerId",
|
||||
c.customerId,
|
||||
form.customerId === c.customerId,
|
||||
() => setForm((f) => ({ ...f, customerId: c.customerId, siteId: "" })),
|
||||
customerLabel(c.customer),
|
||||
<span className="mt-0.5 block text-[12.5px] text-muted-foreground">
|
||||
{[c.customer.postalCode, c.customer.city].filter(Boolean).join(" ")} · {t("customer.match", { percent: Math.round(c.score * 100) })} · {c.reasons.map((r) => (t.has(`reasons.${r}`) ? t(`reasons.${r}`) : r)).join(", ")}
|
||||
{" · "}
|
||||
<a href={`/customers/${c.customerId}`} target="_blank" rel="noreferrer" className="font-semibold text-[var(--ui-primary)] underline-offset-2 hover:underline">
|
||||
{t("customer.merge")}
|
||||
</a>
|
||||
</span>,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-3">
|
||||
<Label htmlFor="rv-search">{t("customer.search")}</Label>
|
||||
<div className="mt-1 flex gap-2">
|
||||
<Input
|
||||
id="rv-search"
|
||||
value={query}
|
||||
placeholder={t("customer.searchPlaceholder")}
|
||||
className="min-h-11"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
startSearch(async () => setResults(await searchAction(query)));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" variant="outline" className="h-11 px-4" disabled={searching || query.trim().length < 2} onClick={() => startSearch(async () => setResults(await searchAction(query)))}>
|
||||
{searching ? <Loader2 className="animate-spin" aria-hidden /> : <Search aria-hidden />} {t("customer.searchButton")}
|
||||
</Button>
|
||||
</div>
|
||||
{results && results.length === 0 && <p className="mt-2 text-[13px] text-muted-foreground">{t("customer.searchEmpty")}</p>}
|
||||
<div className="mt-2 space-y-2">
|
||||
{(results ?? [])
|
||||
.filter((r) => !customerCandidates.some((c) => c.customerId === r.id))
|
||||
.map((r) =>
|
||||
radio(
|
||||
"customerId",
|
||||
r.id,
|
||||
form.customerId === r.id,
|
||||
() => setForm((f) => ({ ...f, customerId: r.id, siteId: "" })),
|
||||
customerLabel(r),
|
||||
<span className="mt-0.5 block text-[12.5px] text-muted-foreground">{[r.postalCode, r.city].filter(Boolean).join(" ")}</span>,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{errorText("customerId") && <p role="alert" className="text-[12px] font-semibold text-[var(--risk)]">{errorText("customerId")}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.customerMode === "new" && (
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-6">
|
||||
{field("customer", "companyName", "companyName", { className: "sm:col-span-2 lg:col-span-4" })}
|
||||
{field("customer", "customerNumber", "customerNumber", { className: "lg:col-span-2" })}
|
||||
{field("customer", "firstName", "firstName", { className: "lg:col-span-3" })}
|
||||
{field("customer", "lastName", "lastName", { className: "lg:col-span-3" })}
|
||||
{field("customer", "street", "street", { className: "lg:col-span-4" })}
|
||||
{field("customer", "houseNumber", "houseNumber", { className: "lg:col-span-2" })}
|
||||
{field("customer", "postalCode", "postalCode", { inputMode: "numeric", className: "lg:col-span-2" })}
|
||||
{field("customer", "city", "city", { className: "lg:col-span-3" })}
|
||||
{field("customer", "country", "country", { className: "lg:col-span-1" })}
|
||||
{field("customer", "phone", "phone", { type: "tel", inputMode: "tel", className: "lg:col-span-3" })}
|
||||
{field("customer", "email", "email", { type: "email", inputMode: "email", className: "lg:col-span-3" })}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Objekt */}
|
||||
<section className={sectionClass} aria-labelledby="rv-h-site">
|
||||
<h2 id="rv-h-site" className={h2}>{t("sections.site")}</h2>
|
||||
<fieldset className="grid gap-2 sm:grid-cols-3">
|
||||
<legend className="sr-only">{t("sections.site")}</legend>
|
||||
{radio("siteMode", "none", form.siteMode === "none", () => setForm((f) => ({ ...f, siteMode: "none" })), t("site.none"))}
|
||||
{radio("siteMode", "existing", form.siteMode === "existing", () => setForm((f) => ({ ...f, siteMode: "existing" })), t("site.existing"))}
|
||||
{radio("siteMode", "new", form.siteMode === "new", () => setForm((f) => ({ ...f, siteMode: "new" })), t("site.new"))}
|
||||
</fieldset>
|
||||
{form.siteMode === "existing" && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{!selectedCustomer && <p className="text-[13px] text-muted-foreground">{t("site.chooseCustomer")}</p>}
|
||||
{selectedCustomer && siteOptions.length === 0 && <p className="text-[13px] text-muted-foreground">{t("site.noSites")}</p>}
|
||||
{siteOptions.map((s) => {
|
||||
const cand = siteScore.get(s.id);
|
||||
return (
|
||||
<div key={s.id}>
|
||||
{radio(
|
||||
"siteId",
|
||||
s.id,
|
||||
form.siteId === s.id,
|
||||
() => setForm((f) => ({ ...f, siteId: s.id })),
|
||||
siteLabel(s),
|
||||
cand ? (
|
||||
<span className="mt-0.5 flex items-center gap-1 text-[12.5px] font-semibold text-[var(--ok)]">
|
||||
<Info className="size-3.5" aria-hidden /> {t("site.suggested")} · {t("customer.match", { percent: Math.round(cand.score * 100) })}
|
||||
</span>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{errorText("siteId") && <p role="alert" className="text-[12px] font-semibold text-[var(--risk)]">{errorText("siteId")}</p>}
|
||||
</div>
|
||||
)}
|
||||
{form.siteMode === "new" && (
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-6">
|
||||
{field("site", "name", "siteName", { className: "sm:col-span-2 lg:col-span-6" })}
|
||||
{field("site", "street", "street", { className: "lg:col-span-4" })}
|
||||
{field("site", "houseNumber", "houseNumber", { className: "lg:col-span-2" })}
|
||||
{field("site", "postalCode", "postalCode", { inputMode: "numeric", className: "lg:col-span-2" })}
|
||||
{field("site", "city", "city", { className: "lg:col-span-3" })}
|
||||
{field("site", "country", "country", { className: "lg:col-span-1" })}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Ansprechpartner */}
|
||||
<section className={sectionClass} aria-labelledby="rv-h-contact">
|
||||
<h2 id="rv-h-contact" className={h2}>{t("sections.contact")}</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{field("contact", "name", "name")}
|
||||
{field("contact", "phone", "phone", { type: "tel", inputMode: "tel" })}
|
||||
{field("contact", "email", "email", { type: "email", inputMode: "email" })}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Auftrag */}
|
||||
<section className={sectionClass} aria-labelledby="rv-h-order">
|
||||
<h2 id="rv-h-order" className={h2}>{t("sections.order")}</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{field("order", "title", "title", { required: true, className: "sm:col-span-2" })}
|
||||
{field("order", "externalOrderNumber", "externalOrderNumber")}
|
||||
{field("order", "offerNumber", "offerNumber")}
|
||||
{field("order", "plannedStart", "plannedStart", { type: "date" })}
|
||||
{field("order", "plannedEnd", "plannedEnd", { type: "date" })}
|
||||
{field("order", "description", "description", { multiline: true, className: "sm:col-span-2" })}
|
||||
{field("order", "notes", "notes", { multiline: true, className: "sm:col-span-2" })}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Positionen */}
|
||||
<section className={sectionClass} aria-labelledby="rv-h-positions">
|
||||
<h2 id="rv-h-positions" className={h2}>{t("sections.positions")}</h2>
|
||||
{(form.positions ?? []).length === 0 && <p className="text-[13px] text-muted-foreground">{t("positions.empty")}</p>}
|
||||
<div className="space-y-3">
|
||||
{(form.positions ?? []).map((p, i) => (
|
||||
<div key={i} className="grid gap-2 rounded-lg border p-3 sm:grid-cols-12">
|
||||
<div className="sm:col-span-5">
|
||||
<Label htmlFor={`rv-pos-${i}-name`}>{t("positions.name")}</Label>
|
||||
<Input id={`rv-pos-${i}-name`} className="mt-1 min-h-11" value={p.name} onChange={(e) => setPosition(i, { name: e.target.value })} />
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<Label htmlFor={`rv-pos-${i}-art`}>{t("positions.articleNumber")}</Label>
|
||||
<Input id={`rv-pos-${i}-art`} className="mt-1 min-h-11" value={p.articleNumber ?? ""} onChange={(e) => setPosition(i, { articleNumber: e.target.value })} />
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor={`rv-pos-${i}-qty`}>{t("positions.quantity")}</Label>
|
||||
<Input id={`rv-pos-${i}-qty`} className="mt-1 min-h-11" inputMode="decimal" value={String(p.quantity ?? "")} onChange={(e) => setPosition(i, { quantity: e.target.value })} />
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor={`rv-pos-${i}-unit`}>{t("positions.unit")}</Label>
|
||||
<Input id={`rv-pos-${i}-unit`} className="mt-1 min-h-11" value={p.unit ?? ""} onChange={(e) => setPosition(i, { unit: e.target.value })} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 sm:col-span-12">
|
||||
<label className="flex min-h-11 items-center gap-2 text-[13px]">
|
||||
<input type="checkbox" className="size-4" checked={Boolean(p.asMaterial)} onChange={(e) => setPosition(i, { asMaterial: e.target.checked })} />
|
||||
{t("positions.asMaterial")}
|
||||
</label>
|
||||
<Button type="button" variant="ghost" className="h-11 px-3" aria-label={t("positions.remove")} onClick={() => setForm((f) => ({ ...f, positions: (f.positions ?? []).filter((_, j) => j !== i) }))}>
|
||||
<Trash2 aria-hidden /> <span className="sm:sr-only">{t("positions.remove")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
{errorText(`positions.${i}.quantity`) && <p role="alert" className="text-[12px] font-semibold text-[var(--risk)] sm:col-span-12">{errorText(`positions.${i}.quantity`)}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-3 h-11 px-4"
|
||||
onClick={() => setForm((f) => ({ ...f, positions: [...(f.positions ?? []), { name: "", articleNumber: "", quantity: "", unit: "", asMaterial: false }] }))}
|
||||
>
|
||||
<Plus aria-hidden /> {t("positions.add")}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="submit" disabled={pending} className="h-11 bg-[var(--ui-accent)] px-5 text-[var(--ui-accent-foreground)]">
|
||||
{pending && <Loader2 className="animate-spin" aria-hidden />}
|
||||
{pending ? ta("confirming") : ta("confirm")}
|
||||
</Button>
|
||||
{state.status === "error" && (
|
||||
<p role="alert" className="text-[13px] font-semibold text-[var(--risk)]">
|
||||
{te.has(state.code) ? te(state.code) : te("error")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { AlertTriangle, Ban, CheckCircle2, Loader2, Upload, XCircle } from "lucide-react";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { IMPORT_STATUS_TONE, type ImportStatusKey } from "@/lib/imports/status";
|
||||
|
||||
const ICONS: Record<ImportStatusKey, typeof Upload> = {
|
||||
uploaded: Upload,
|
||||
processing: Loader2,
|
||||
review_required: AlertTriangle,
|
||||
confirmed: CheckCircle2,
|
||||
failed: XCircle,
|
||||
discarded: Ban,
|
||||
};
|
||||
|
||||
/** Import status as pill: colour + icon + text (never colour alone). */
|
||||
export function ImportStatusPill({ status, label }: { status: ImportStatusKey; label: string }) {
|
||||
const Icon = ICONS[status];
|
||||
return (
|
||||
<Pill tone={IMPORT_STATUS_TONE[status]}>
|
||||
<Icon className={status === "processing" ? "size-3.5 animate-spin" : "size-3.5"} aria-hidden />
|
||||
{label}
|
||||
</Pill>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CheckCircle2, FileUp, XCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IMPORT_IMAGE_MAX_BYTES, IMPORT_MAX_BYTES, IMPORT_MIME_TYPES } from "@/lib/imports/status";
|
||||
|
||||
type UploadState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "uploading"; percent: number; name: string }
|
||||
| { kind: "done"; name: string; id: string }
|
||||
| { kind: "error"; code: string };
|
||||
|
||||
const KNOWN_ERRORS = new Set(["file_type_not_allowed", "file_too_large", "file_type_mismatch", "file_empty", "file_missing", "forbidden", "unauthorized", "network"]);
|
||||
|
||||
/**
|
||||
* Drag & drop / file picker upload with progress (XHR upload events). Posts to
|
||||
* POST /api/v1/work-orders/import, then refreshes the server-rendered list.
|
||||
*/
|
||||
export function ImportUploader() {
|
||||
const t = useTranslations("imports.upload");
|
||||
const router = useRouter();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [state, setState] = useState<UploadState>({ kind: "idle" });
|
||||
|
||||
function upload(file: File) {
|
||||
const type = file.type === "image/jpg" ? "image/jpeg" : file.type;
|
||||
if (type && !(IMPORT_MIME_TYPES as readonly string[]).includes(type)) return setState({ kind: "error", code: "file_type_not_allowed" });
|
||||
const limit = type === "application/pdf" ? IMPORT_MAX_BYTES : IMPORT_IMAGE_MAX_BYTES;
|
||||
if (file.size > limit) return setState({ kind: "error", code: "file_too_large" });
|
||||
if (file.size === 0) return setState({ kind: "error", code: "file_empty" });
|
||||
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/v1/work-orders/import");
|
||||
xhr.responseType = "json";
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) setState({ kind: "uploading", percent: Math.round((e.loaded / e.total) * 100), name: file.name });
|
||||
};
|
||||
xhr.onload = () => {
|
||||
const res = (xhr.response ?? {}) as { id?: string; error?: string; message?: string };
|
||||
if (xhr.status === 201 && res.id) {
|
||||
setState({ kind: "done", name: file.name, id: res.id });
|
||||
router.refresh();
|
||||
} else {
|
||||
const code = res.message && KNOWN_ERRORS.has(res.message) ? res.message : res.error && KNOWN_ERRORS.has(res.error) ? res.error : "error";
|
||||
setState({ kind: "error", code });
|
||||
}
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
};
|
||||
xhr.onerror = () => setState({ kind: "error", code: "network" });
|
||||
setState({ kind: "uploading", percent: 0, name: file.name });
|
||||
xhr.send(body);
|
||||
}
|
||||
|
||||
const busy = state.kind === "uploading";
|
||||
|
||||
return (
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (!busy) setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file && !busy) upload(file);
|
||||
}}
|
||||
className={cn(
|
||||
"shadow-card rounded-xl border-2 border-dashed bg-card p-6 text-center transition-colors",
|
||||
dragging ? "border-[var(--ui-accent)] bg-[var(--ui-primary-soft)]" : "border-border",
|
||||
)}
|
||||
>
|
||||
<FileUp className="mx-auto size-8 text-[var(--ui-accent)]" aria-hidden />
|
||||
<p className="mt-2 font-heading text-sm font-semibold">{t("drop")}</p>
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">{t("or")}</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id="import-file"
|
||||
type="file"
|
||||
accept="application/pdf,image/jpeg,image/png,.pdf,.jpg,.jpeg,.png"
|
||||
className="sr-only"
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) upload(file);
|
||||
}}
|
||||
/>
|
||||
<Button type="button" className="mt-2 h-11 px-5" disabled={busy} onClick={() => inputRef.current?.click()}>
|
||||
{t("choose")}
|
||||
</Button>
|
||||
<p className="mt-3 text-[12.5px] text-muted-foreground">{t("limits")}</p>
|
||||
|
||||
<div aria-live="polite" className="mt-3 min-h-6">
|
||||
{state.kind === "uploading" && (
|
||||
<div className="mx-auto max-w-md text-left">
|
||||
<div className="flex justify-between text-[12.5px]">
|
||||
<span className="truncate">{state.name}</span>
|
||||
<span>{t("uploading", { percent: state.percent })}</span>
|
||||
</div>
|
||||
<div className="mt-1 h-2 overflow-hidden rounded-full bg-muted" role="progressbar" aria-valuenow={state.percent} aria-valuemin={0} aria-valuemax={100}>
|
||||
<div className="h-full bg-[var(--ui-accent)] transition-[width]" style={{ width: `${state.percent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{state.kind === "done" && (
|
||||
<p className="inline-flex items-center gap-1.5 text-[13px] font-semibold text-[var(--ok)]">
|
||||
<CheckCircle2 className="size-4" aria-hidden /> {state.name}: {t("done")}
|
||||
</p>
|
||||
)}
|
||||
{state.kind === "error" && (
|
||||
<p role="alert" className="inline-flex items-center gap-1.5 text-[13px] font-semibold text-[var(--risk)]">
|
||||
<XCircle className="size-4" aria-hidden /> {t(`errors.${state.code}`)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { z } from "zod";
|
||||
import type { ExtractedField, WorkOrderExtraction } from "@/server/ai/providers";
|
||||
|
||||
/**
|
||||
* Client-safe extraction model for the order import (spec §9.4/§9.5).
|
||||
*
|
||||
* - `EXTRACTION_FIELDS`: canonical field order (review mask, corrections, tests).
|
||||
* - `workOrderExtractionSchema`: Zod validation of provider output (null → undefined for sub-objects).
|
||||
* - `extractionJsonSchema()`: JSON schema for Claude structured outputs (all keys required,
|
||||
* nullable via anyOf, additionalProperties:false — the structured-output subset).
|
||||
* - `StoredExtraction`: shape persisted in ImportJob.extraction.
|
||||
*/
|
||||
|
||||
export const EXTRACTION_VERSION = "craftvia-import-v1";
|
||||
|
||||
export const EXTRACTION_FIELDS = [
|
||||
"orderNumber",
|
||||
"offerNumber",
|
||||
"customerNumber",
|
||||
"companyName",
|
||||
"customerFirstName",
|
||||
"customerLastName",
|
||||
"customerAddress",
|
||||
"siteName",
|
||||
"siteAddress",
|
||||
"contactName",
|
||||
"phone",
|
||||
"email",
|
||||
"orderDate",
|
||||
"documentDate",
|
||||
"plannedStart",
|
||||
"plannedEnd",
|
||||
"title",
|
||||
"description",
|
||||
"positions",
|
||||
"notes",
|
||||
"totalAmount",
|
||||
"references",
|
||||
] as const satisfies readonly (keyof WorkOrderExtraction)[];
|
||||
|
||||
export type ExtractionFieldKey = (typeof EXTRACTION_FIELDS)[number];
|
||||
|
||||
export type Address = { street?: string; houseNumber?: string; postalCode?: string; city?: string; country?: string };
|
||||
export type Position = { position?: string; name: string; articleNumber?: string; quantity?: number; unit?: string; isMaterial?: boolean };
|
||||
|
||||
const confidence = z.number().min(0).max(1).catch(0);
|
||||
const source = z.string().nullish().transform((v) => v ?? undefined);
|
||||
const optStr = z.string().nullish().transform((v) => (v == null || v.trim() === "" ? undefined : v.trim()));
|
||||
|
||||
function field<T extends z.ZodTypeAny>(value: T) {
|
||||
return z.object({ value: value.nullable().catch(null), confidence, source });
|
||||
}
|
||||
|
||||
const addressSchema = z.object({
|
||||
street: optStr,
|
||||
houseNumber: optStr,
|
||||
postalCode: optStr,
|
||||
city: optStr,
|
||||
country: optStr,
|
||||
});
|
||||
|
||||
const positionSchema = z.object({
|
||||
position: optStr,
|
||||
name: z.string().min(1),
|
||||
articleNumber: optStr,
|
||||
quantity: z.number().nullish().transform((v) => (v == null || Number.isNaN(v) ? undefined : v)),
|
||||
unit: optStr,
|
||||
isMaterial: z.boolean().nullish().transform((v) => v ?? undefined),
|
||||
});
|
||||
|
||||
const str = z.string().transform((v) => v.trim());
|
||||
|
||||
export const workOrderExtractionSchema = z.object({
|
||||
orderNumber: field(str),
|
||||
offerNumber: field(str),
|
||||
customerNumber: field(str),
|
||||
companyName: field(str),
|
||||
customerFirstName: field(str),
|
||||
customerLastName: field(str),
|
||||
customerAddress: field(addressSchema),
|
||||
siteName: field(str),
|
||||
siteAddress: field(addressSchema),
|
||||
contactName: field(str),
|
||||
phone: field(str),
|
||||
email: field(str),
|
||||
orderDate: field(str),
|
||||
documentDate: field(str),
|
||||
plannedStart: field(str),
|
||||
plannedEnd: field(str),
|
||||
title: field(str),
|
||||
description: field(str),
|
||||
positions: field(z.array(positionSchema)),
|
||||
notes: field(str),
|
||||
totalAmount: field(z.number()),
|
||||
references: field(z.array(z.string())),
|
||||
});
|
||||
|
||||
/** Parse arbitrary provider output into a WorkOrderExtraction (throws on structurally invalid input). */
|
||||
export function parseExtraction(raw: unknown): WorkOrderExtraction {
|
||||
const parsed = workOrderExtractionSchema.parse(raw);
|
||||
// Empty strings count as "not found".
|
||||
const out = {} as Record<string, ExtractedField<unknown>>;
|
||||
for (const key of EXTRACTION_FIELDS) {
|
||||
const f = parsed[key] as ExtractedField<unknown>;
|
||||
const empty = f.value === null || (typeof f.value === "string" && f.value === "");
|
||||
out[key] = { value: empty ? null : f.value, confidence: empty ? 0 : f.confidence, ...(f.source ? { source: f.source } : {}) };
|
||||
}
|
||||
return out as unknown as WorkOrderExtraction;
|
||||
}
|
||||
|
||||
/** Extraction with every field empty (no provider / manual entry). */
|
||||
export function emptyExtraction(): WorkOrderExtraction {
|
||||
const out = {} as Record<string, ExtractedField<unknown>>;
|
||||
for (const key of EXTRACTION_FIELDS) out[key] = { value: null, confidence: 0 };
|
||||
return out as unknown as WorkOrderExtraction;
|
||||
}
|
||||
|
||||
// ---------- JSON schema for structured outputs ----------
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
const nullable = (schema: Json): Json => ({ anyOf: [schema, { type: "null" }] });
|
||||
const obj = (properties: Record<string, Json>): Json => ({
|
||||
type: "object",
|
||||
properties,
|
||||
required: Object.keys(properties),
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const addressJson = obj({
|
||||
street: nullable({ type: "string" }),
|
||||
houseNumber: nullable({ type: "string" }),
|
||||
postalCode: nullable({ type: "string" }),
|
||||
city: nullable({ type: "string" }),
|
||||
country: nullable({ type: "string", description: "ISO 3166-1 alpha-2, e.g. DE" }),
|
||||
});
|
||||
|
||||
const positionJson = obj({
|
||||
position: nullable({ type: "string" }),
|
||||
name: { type: "string" },
|
||||
articleNumber: nullable({ type: "string" }),
|
||||
quantity: nullable({ type: "number" }),
|
||||
unit: nullable({ type: "string" }),
|
||||
isMaterial: nullable({ type: "boolean" }),
|
||||
});
|
||||
|
||||
const fieldJson = (value: Json, description: string): Json => ({
|
||||
...obj({
|
||||
value: nullable(value),
|
||||
confidence: { type: "number", description: "0..1 — how certain the value is correct" },
|
||||
source: nullable({ type: "string", description: "short verbatim snippet from the document" }),
|
||||
}),
|
||||
description,
|
||||
});
|
||||
|
||||
const FIELD_DESCRIPTIONS: Record<ExtractionFieldKey, [Json, string]> = {
|
||||
orderNumber: [{ type: "string" }, "Order / confirmation number of the issuer (Auftragsnummer)"],
|
||||
offerNumber: [{ type: "string" }, "Offer number (Angebotsnummer)"],
|
||||
customerNumber: [{ type: "string" }, "Customer number (Kundennummer)"],
|
||||
companyName: [{ type: "string" }, "Company name of the customer (the ordering party, NOT the craft business)"],
|
||||
customerFirstName: [{ type: "string" }, "First name if the customer is a private person"],
|
||||
customerLastName: [{ type: "string" }, "Last name if the customer is a private person"],
|
||||
customerAddress: [addressJson, "Billing / postal address of the customer"],
|
||||
siteName: [{ type: "string" }, "Name of the site / construction site (Objekt, Baustelle)"],
|
||||
siteAddress: [addressJson, "Address where the work is carried out"],
|
||||
contactName: [{ type: "string" }, "Contact person"],
|
||||
phone: [{ type: "string" }, "Phone number of the customer or contact"],
|
||||
email: [{ type: "string" }, "E-mail address of the customer or contact"],
|
||||
orderDate: [{ type: "string" }, "Order date, ISO 8601 (YYYY-MM-DD)"],
|
||||
documentDate: [{ type: "string" }, "Document date, ISO 8601 (YYYY-MM-DD)"],
|
||||
plannedStart: [{ type: "string" }, "Planned start of execution, ISO 8601 (YYYY-MM-DD)"],
|
||||
plannedEnd: [{ type: "string" }, "Planned end of execution, ISO 8601 (YYYY-MM-DD)"],
|
||||
title: [{ type: "string" }, "Short title of the job (max. 80 characters)"],
|
||||
description: [{ type: "string" }, "Description of services (Leistungsbeschreibung)"],
|
||||
positions: [{ type: "array", items: positionJson }, "Line items with quantities; isMaterial=true for material/articles"],
|
||||
notes: [{ type: "string" }, "Special notes (access, deadlines, safety)"],
|
||||
totalAmount: [{ type: "number" }, "Total gross amount in EUR as a number"],
|
||||
references: [{ type: "array", items: { type: "string" } }, "Other references (project no., purchase order no.)"],
|
||||
};
|
||||
|
||||
/** JSON schema of the complete provider answer: full text + structured fields. */
|
||||
export function extractionJsonSchema(): Json {
|
||||
const fields: Record<string, Json> = {};
|
||||
for (const key of EXTRACTION_FIELDS) {
|
||||
const [value, description] = FIELD_DESCRIPTIONS[key];
|
||||
fields[key] = fieldJson(value, description);
|
||||
}
|
||||
return obj({
|
||||
text: { type: "string", description: "Full recognised text of the document (reading order)" },
|
||||
extraction: obj(fields),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- persisted shape ----------
|
||||
|
||||
export type PlausibilityHintCode =
|
||||
| "date_invalid"
|
||||
| "date_implausible"
|
||||
| "postal_code_invalid"
|
||||
| "email_invalid"
|
||||
| "phone_invalid"
|
||||
| "end_before_start"
|
||||
| "manual_entry";
|
||||
|
||||
export type PlausibilityHint = { field: ExtractionFieldKey | null; code: PlausibilityHintCode };
|
||||
|
||||
export type DuplicateCandidate = { customerId: string; score: number; reasons: string[] };
|
||||
export type SiteCandidate = { siteId: string; customerId: string; name: string; score: number; reasons: string[] };
|
||||
|
||||
export type StoredExtraction = {
|
||||
fields: WorkOrderExtraction;
|
||||
hints: PlausibilityHint[];
|
||||
siteCandidates: SiteCandidate[];
|
||||
};
|
||||
|
||||
export function readStoredExtraction(raw: unknown): StoredExtraction {
|
||||
const r = (raw ?? {}) as Partial<StoredExtraction>;
|
||||
let fields: WorkOrderExtraction;
|
||||
try {
|
||||
fields = r.fields ? parseExtraction(r.fields) : emptyExtraction();
|
||||
} catch {
|
||||
fields = emptyExtraction();
|
||||
}
|
||||
return {
|
||||
fields,
|
||||
hints: Array.isArray(r.hints) ? r.hints : [],
|
||||
siteCandidates: Array.isArray(r.siteCandidates) ? r.siteCandidates : [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Fields below this confidence are marked "unsicher" in the review mask. */
|
||||
export const LOW_CONFIDENCE = 0.8;
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { WorkOrderExtraction } from "@/server/ai/providers";
|
||||
import type { ExtractionFieldKey, PlausibilityHint } from "./extraction";
|
||||
|
||||
/**
|
||||
* Plausibility check of an extraction (spec §9.3 step 6). Pure and client-safe.
|
||||
* A violation never removes the value — it lowers the confidence (so the review mask marks
|
||||
* the field as uncertain) and adds a hint the UI explains.
|
||||
*/
|
||||
|
||||
/** Confidence ceiling for fields that violate a rule. */
|
||||
export const VIOLATION_CONFIDENCE = 0.4;
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
||||
// Digits with common separators, optional leading +; 6..20 digits.
|
||||
const PHONE_RE = /^\+?[\d\s()/.-]+$/;
|
||||
const ISO_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
const GERMAN_DATE_RE = /^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})$/;
|
||||
|
||||
export function isValidEmail(v: string): boolean {
|
||||
return EMAIL_RE.test(v.trim());
|
||||
}
|
||||
|
||||
export function isValidPhone(v: string): boolean {
|
||||
const digits = v.replace(/\D/g, "");
|
||||
return PHONE_RE.test(v.trim()) && digits.length >= 6 && digits.length <= 20;
|
||||
}
|
||||
|
||||
export function isValidGermanPostalCode(v: string): boolean {
|
||||
return /^\d{5}$/.test(v.trim());
|
||||
}
|
||||
|
||||
/** Accepts ISO (YYYY-MM-DD) and German (TT.MM.JJJJ) dates; returns a UTC date or null. */
|
||||
export function parseDate(v: string): Date | null {
|
||||
const s = v.trim();
|
||||
let y: number, m: number, d: number;
|
||||
const iso = ISO_DATE_RE.exec(s.slice(0, 10));
|
||||
const de = GERMAN_DATE_RE.exec(s);
|
||||
if (iso) {
|
||||
[y, m, d] = [Number(iso[1]), Number(iso[2]), Number(iso[3])];
|
||||
} else if (de) {
|
||||
[d, m, y] = [Number(de[1]), Number(de[2]), Number(de[3])];
|
||||
if (y < 100) y += 2000;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(Date.UTC(y, m - 1, d));
|
||||
if (date.getUTCFullYear() !== y || date.getUTCMonth() !== m - 1 || date.getUTCDate() !== d) return null;
|
||||
return date;
|
||||
}
|
||||
|
||||
/** Normalise a date string to YYYY-MM-DD, or null. */
|
||||
export function toIsoDate(v: string | null | undefined): string | null {
|
||||
if (!v) return null;
|
||||
const d = parseDate(v);
|
||||
return d ? d.toISOString().slice(0, 10) : null;
|
||||
}
|
||||
|
||||
const DATE_FIELDS = ["orderDate", "documentDate", "plannedStart", "plannedEnd"] as const;
|
||||
const MS_YEAR = 365 * 24 * 3600 * 1000;
|
||||
|
||||
export function checkPlausibility(
|
||||
input: WorkOrderExtraction,
|
||||
now: Date = new Date(),
|
||||
): { extraction: WorkOrderExtraction; hints: PlausibilityHint[] } {
|
||||
const extraction = structuredClone(input);
|
||||
const hints: PlausibilityHint[] = [];
|
||||
const flag = (field: ExtractionFieldKey, code: PlausibilityHint["code"]) => {
|
||||
hints.push({ field, code });
|
||||
const f = extraction[field];
|
||||
f.confidence = Math.min(f.confidence, VIOLATION_CONFIDENCE);
|
||||
};
|
||||
|
||||
const dates: Partial<Record<(typeof DATE_FIELDS)[number], Date>> = {};
|
||||
for (const key of DATE_FIELDS) {
|
||||
const f = extraction[key];
|
||||
if (!f.value) continue;
|
||||
const parsed = parseDate(f.value);
|
||||
if (!parsed) {
|
||||
flag(key, "date_invalid");
|
||||
continue;
|
||||
}
|
||||
f.value = parsed.toISOString().slice(0, 10); // normalise German formats
|
||||
dates[key] = parsed;
|
||||
// Order/document dates lie in the past (≤ 1 month ahead); execution within −2 … +3 years.
|
||||
const diff = parsed.getTime() - now.getTime();
|
||||
const plausible =
|
||||
key === "orderDate" || key === "documentDate"
|
||||
? diff <= MS_YEAR / 12 && diff >= -10 * MS_YEAR
|
||||
: diff >= -2 * MS_YEAR && diff <= 3 * MS_YEAR;
|
||||
if (!plausible) flag(key, "date_implausible");
|
||||
}
|
||||
if (dates.plannedStart && dates.plannedEnd && dates.plannedEnd < dates.plannedStart) {
|
||||
flag("plannedEnd", "end_before_start");
|
||||
}
|
||||
|
||||
for (const key of ["customerAddress", "siteAddress"] as const) {
|
||||
const addr = extraction[key].value;
|
||||
if (!addr?.postalCode) continue;
|
||||
const country = (addr.country ?? "DE").toUpperCase();
|
||||
if ((country === "DE" || country === "DEUTSCHLAND") && !isValidGermanPostalCode(addr.postalCode)) {
|
||||
flag(key, "postal_code_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
if (extraction.email.value && !isValidEmail(extraction.email.value)) flag("email", "email_invalid");
|
||||
if (extraction.phone.value && !isValidPhone(extraction.phone.value)) flag("phone", "phone_invalid");
|
||||
|
||||
return { extraction, hints };
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { z } from "zod";
|
||||
import type { WorkOrderExtraction } from "@/server/ai/providers";
|
||||
import { type ExtractionFieldKey, LOW_CONFIDENCE } from "./extraction";
|
||||
import { isValidEmail, isValidGermanPostalCode, toIsoDate } from "./plausibility";
|
||||
|
||||
/**
|
||||
* Review mask model (spec §9.6): mapping extraction → form, the form schema used by the
|
||||
* confirm action/API, and the correction diff (extraction ↔ confirmed values, spec §9.7).
|
||||
* Client-safe.
|
||||
*/
|
||||
|
||||
// ---------- form schema ----------
|
||||
|
||||
const text = (max: number) =>
|
||||
z
|
||||
.string()
|
||||
.max(max)
|
||||
.transform((v) => v.trim())
|
||||
.optional()
|
||||
.default("");
|
||||
|
||||
const dateStr = z
|
||||
.string()
|
||||
.optional()
|
||||
.default("")
|
||||
.refine((v) => v.trim() === "" || toIsoDate(v) !== null, { message: "date_invalid" })
|
||||
.transform((v) => toIsoDate(v) ?? "");
|
||||
|
||||
export const reviewPositionSchema = z.object({
|
||||
name: z.string().trim().min(1).max(300),
|
||||
articleNumber: text(100),
|
||||
quantity: z
|
||||
.union([z.number(), z.string()])
|
||||
.optional()
|
||||
.transform((v) => {
|
||||
if (v === undefined || v === "") return null;
|
||||
const n = typeof v === "number" ? v : Number(String(v).replace(",", "."));
|
||||
return Number.isFinite(n) ? n : Number.NaN;
|
||||
})
|
||||
.refine((v) => v === null || (!Number.isNaN(v) && v >= 0), { message: "quantity_invalid" }),
|
||||
unit: text(30),
|
||||
asMaterial: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
export const reviewFormSchema = z
|
||||
.object({
|
||||
customerMode: z.enum(["existing", "new"]),
|
||||
customerId: z.string().optional().default(""),
|
||||
customer: z.object({
|
||||
customerNumber: text(50),
|
||||
companyName: text(200),
|
||||
firstName: text(100),
|
||||
lastName: text(100),
|
||||
street: text(200),
|
||||
houseNumber: text(20),
|
||||
postalCode: text(10),
|
||||
city: text(100),
|
||||
country: text(2).transform((v) => (v ? v.toUpperCase() : "DE")),
|
||||
phone: text(50),
|
||||
email: text(200),
|
||||
}),
|
||||
siteMode: z.enum(["existing", "new", "none"]),
|
||||
siteId: z.string().optional().default(""),
|
||||
site: z.object({
|
||||
name: text(200),
|
||||
street: text(200),
|
||||
houseNumber: text(20),
|
||||
postalCode: text(10),
|
||||
city: text(100),
|
||||
country: text(2).transform((v) => (v ? v.toUpperCase() : "DE")),
|
||||
}),
|
||||
contact: z.object({ name: text(200), phone: text(50), email: text(200) }),
|
||||
order: z.object({
|
||||
title: z.string().trim().min(1, { message: "title_required" }).max(200),
|
||||
externalOrderNumber: text(100),
|
||||
offerNumber: text(100),
|
||||
description: text(10_000),
|
||||
plannedStart: dateStr,
|
||||
plannedEnd: dateStr,
|
||||
notes: text(10_000),
|
||||
}),
|
||||
positions: z.array(reviewPositionSchema).max(500).default([]),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
if (v.customerMode === "existing" && !v.customerId) ctx.addIssue({ code: "custom", path: ["customerId"], message: "customer_required" });
|
||||
if (v.customerMode === "new" && !v.customer.companyName && !v.customer.lastName) {
|
||||
ctx.addIssue({ code: "custom", path: ["customer", "companyName"], message: "customer_name_required" });
|
||||
}
|
||||
if (v.siteMode === "existing" && !v.siteId) ctx.addIssue({ code: "custom", path: ["siteId"], message: "site_required" });
|
||||
if (v.siteMode === "new" && !v.site.name && !v.site.street) ctx.addIssue({ code: "custom", path: ["site", "name"], message: "site_name_required" });
|
||||
for (const [path, email] of [[["customer", "email"], v.customer.email], [["contact", "email"], v.contact.email]] as const) {
|
||||
if (email && !isValidEmail(email)) ctx.addIssue({ code: "custom", path: [...path], message: "email_invalid" });
|
||||
}
|
||||
for (const [path, pc, country] of [
|
||||
[["customer", "postalCode"], v.customer.postalCode, v.customer.country],
|
||||
[["site", "postalCode"], v.site.postalCode, v.site.country],
|
||||
] as const) {
|
||||
if (pc && country === "DE" && !isValidGermanPostalCode(pc)) ctx.addIssue({ code: "custom", path: [...path], message: "postal_code_invalid" });
|
||||
}
|
||||
if (v.order.plannedStart && v.order.plannedEnd && v.order.plannedEnd < v.order.plannedStart) {
|
||||
ctx.addIssue({ code: "custom", path: ["order", "plannedEnd"], message: "end_before_start" });
|
||||
}
|
||||
});
|
||||
|
||||
export type ReviewFormInput = z.input<typeof reviewFormSchema>;
|
||||
export type ReviewForm = z.output<typeof reviewFormSchema>;
|
||||
|
||||
// ---------- form field ↔ extraction field ----------
|
||||
|
||||
/** Form path → extraction field that fed it (for confidence badges and corrections). */
|
||||
export const FORM_FIELD_SOURCE = {
|
||||
"customer.customerNumber": "customerNumber",
|
||||
"customer.companyName": "companyName",
|
||||
"customer.firstName": "customerFirstName",
|
||||
"customer.lastName": "customerLastName",
|
||||
"customer.street": "customerAddress",
|
||||
"customer.houseNumber": "customerAddress",
|
||||
"customer.postalCode": "customerAddress",
|
||||
"customer.city": "customerAddress",
|
||||
"customer.country": "customerAddress",
|
||||
"customer.phone": "phone",
|
||||
"customer.email": "email",
|
||||
"site.name": "siteName",
|
||||
"site.street": "siteAddress",
|
||||
"site.houseNumber": "siteAddress",
|
||||
"site.postalCode": "siteAddress",
|
||||
"site.city": "siteAddress",
|
||||
"site.country": "siteAddress",
|
||||
"contact.name": "contactName",
|
||||
"contact.phone": "phone",
|
||||
"contact.email": "email",
|
||||
"order.title": "title",
|
||||
"order.externalOrderNumber": "orderNumber",
|
||||
"order.offerNumber": "offerNumber",
|
||||
"order.description": "description",
|
||||
"order.plannedStart": "plannedStart",
|
||||
"order.plannedEnd": "plannedEnd",
|
||||
"order.notes": "notes",
|
||||
} as const satisfies Record<string, ExtractionFieldKey>;
|
||||
|
||||
export type FormFieldPath = keyof typeof FORM_FIELD_SOURCE;
|
||||
|
||||
export type FieldMeta = { confidence: number; source?: string; uncertain: boolean };
|
||||
|
||||
/** Confidence/source per form field; `uncertain` = value present-or-expected but < 0.8. */
|
||||
export function formFieldMeta(ex: WorkOrderExtraction): Record<FormFieldPath, FieldMeta> {
|
||||
const out = {} as Record<FormFieldPath, FieldMeta>;
|
||||
for (const [path, key] of Object.entries(FORM_FIELD_SOURCE) as [FormFieldPath, ExtractionFieldKey][]) {
|
||||
const f = ex[key];
|
||||
out[path] = { confidence: f.confidence, source: f.source, uncertain: f.value !== null && f.confidence < LOW_CONFIDENCE };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function deriveTitle(ex: WorkOrderExtraction): string {
|
||||
if (ex.title.value) return ex.title.value.slice(0, 200);
|
||||
const firstLine = ex.description.value?.split(/\r?\n/)[0]?.trim();
|
||||
if (firstLine) return firstLine.slice(0, 80);
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Initial review form from an extraction (existing customer/site preselected if given). */
|
||||
export function extractionToForm(
|
||||
ex: WorkOrderExtraction,
|
||||
opts: { customerId?: string | null; siteId?: string | null } = {},
|
||||
): ReviewFormInput {
|
||||
const ca = ex.customerAddress.value ?? {};
|
||||
const sa = ex.siteAddress.value ?? {};
|
||||
const hasSite = Boolean(ex.siteName.value || sa.street || sa.city);
|
||||
return {
|
||||
customerMode: opts.customerId ? "existing" : "new",
|
||||
customerId: opts.customerId ?? "",
|
||||
customer: {
|
||||
customerNumber: ex.customerNumber.value ?? "",
|
||||
companyName: ex.companyName.value ?? "",
|
||||
firstName: ex.customerFirstName.value ?? "",
|
||||
lastName: ex.customerLastName.value ?? "",
|
||||
street: ca.street ?? "",
|
||||
houseNumber: ca.houseNumber ?? "",
|
||||
postalCode: ca.postalCode ?? "",
|
||||
city: ca.city ?? "",
|
||||
country: (ca.country ?? "DE").slice(0, 2).toUpperCase(),
|
||||
phone: ex.phone.value ?? "",
|
||||
email: ex.email.value ?? "",
|
||||
},
|
||||
siteMode: opts.siteId ? "existing" : hasSite ? "new" : "none",
|
||||
siteId: opts.siteId ?? "",
|
||||
site: {
|
||||
name: ex.siteName.value ?? (sa.street ? [sa.street, sa.houseNumber].filter(Boolean).join(" ") : ""),
|
||||
street: sa.street ?? "",
|
||||
houseNumber: sa.houseNumber ?? "",
|
||||
postalCode: sa.postalCode ?? "",
|
||||
city: sa.city ?? "",
|
||||
country: (sa.country ?? "DE").slice(0, 2).toUpperCase(),
|
||||
},
|
||||
contact: {
|
||||
name: ex.contactName.value ?? "",
|
||||
phone: ex.contactName.value ? ex.phone.value ?? "" : "",
|
||||
email: ex.contactName.value ? ex.email.value ?? "" : "",
|
||||
},
|
||||
order: {
|
||||
title: deriveTitle(ex),
|
||||
externalOrderNumber: ex.orderNumber.value ?? "",
|
||||
offerNumber: ex.offerNumber.value ?? "",
|
||||
description: ex.description.value ?? "",
|
||||
plannedStart: toIsoDate(ex.plannedStart.value) ?? "",
|
||||
plannedEnd: toIsoDate(ex.plannedEnd.value) ?? "",
|
||||
notes: ex.notes.value ?? "",
|
||||
},
|
||||
positions: (ex.positions.value ?? []).map((p) => ({
|
||||
name: p.name,
|
||||
articleNumber: p.articleNumber ?? "",
|
||||
quantity: p.quantity ?? "",
|
||||
unit: p.unit ?? "",
|
||||
asMaterial: Boolean(p.isMaterial && p.quantity != null),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- corrections ----------
|
||||
|
||||
export type Corrections = Record<string, { from: unknown; to: unknown }>;
|
||||
|
||||
function get(obj: unknown, path: string): unknown {
|
||||
return path.split(".").reduce<unknown>((o, k) => (o && typeof o === "object" ? (o as Record<string, unknown>)[k] : undefined), obj);
|
||||
}
|
||||
|
||||
const norm = (v: unknown) => (v === undefined || v === null ? "" : typeof v === "string" ? v.trim() : v);
|
||||
|
||||
/**
|
||||
* Diff between the form proposed from the extraction and the confirmed form. Only entered
|
||||
* data counts (customer/site decisions are recorded separately) — a field that was edited
|
||||
* shows up as `{ from, to }` under its form path; positions are compared as a whole.
|
||||
*/
|
||||
export function computeCorrections(ex: WorkOrderExtraction, confirmed: ReviewForm): Corrections {
|
||||
const proposed = extractionToForm(ex);
|
||||
const out: Corrections = {};
|
||||
for (const path of Object.keys(FORM_FIELD_SOURCE)) {
|
||||
const from = norm(get(proposed, path));
|
||||
const to = norm(get(confirmed, path));
|
||||
if (from !== to) out[path] = { from, to };
|
||||
}
|
||||
const simplify = (ps: Array<{ name: string; articleNumber?: string; quantity?: unknown; unit?: string }>) =>
|
||||
ps.map((p) => ({
|
||||
name: p.name.trim(),
|
||||
articleNumber: norm(p.articleNumber),
|
||||
quantity: p.quantity === "" || p.quantity == null ? null : Number(p.quantity),
|
||||
unit: norm(p.unit),
|
||||
}));
|
||||
const fromPos = simplify(proposed.positions ?? []);
|
||||
const toPos = simplify(confirmed.positions);
|
||||
if (JSON.stringify(fromPos) !== JSON.stringify(toPos)) out.positions = { from: fromPos, to: toPos };
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Import status → UI tone/label key (client-safe). Status is never shown by colour alone. */
|
||||
export const IMPORT_STATUSES = ["uploaded", "processing", "review_required", "confirmed", "failed", "discarded"] as const;
|
||||
export type ImportStatusKey = (typeof IMPORT_STATUSES)[number];
|
||||
|
||||
export const IMPORT_STATUS_TONE: Record<ImportStatusKey, "info" | "warn" | "ok" | "risk" | "mut"> = {
|
||||
uploaded: "info",
|
||||
processing: "info",
|
||||
review_required: "warn",
|
||||
confirmed: "ok",
|
||||
failed: "risk",
|
||||
discarded: "mut",
|
||||
};
|
||||
|
||||
/** Statuses in which the page should poll for progress. */
|
||||
export const IMPORT_IN_PROGRESS: readonly ImportStatusKey[] = ["uploaded", "processing"];
|
||||
|
||||
export const IMPORT_MAX_BYTES = 25 * 1024 * 1024;
|
||||
export const IMPORT_IMAGE_MAX_BYTES = 15 * 1024 * 1024;
|
||||
export const IMPORT_MIME_TYPES = ["application/pdf", "image/jpeg", "image/png"] as const;
|
||||
@@ -0,0 +1,77 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { moduleGuard } from "@/server/action-guard";
|
||||
import { ForbiddenError } from "@/server/rbac";
|
||||
import { ctxFromGuard, ServiceError } from "@/server/services/context";
|
||||
import { confirmImport, discardImport, retryImport } from "@/server/services/imports/confirm";
|
||||
import { searchCustomers } from "@/server/services/imports/queries";
|
||||
|
||||
/**
|
||||
* Server actions of the import module (thin adapters: guard → service → revalidate).
|
||||
* Uploads do NOT go through a server action (1 MB body limit) but through
|
||||
* POST /api/v1/work-orders/import with upload progress.
|
||||
*/
|
||||
const guard = moduleGuard("imports");
|
||||
|
||||
export type ConfirmState =
|
||||
| { status: "idle" }
|
||||
| { status: "error"; code: string; issues?: Array<{ path: string; message: string }> };
|
||||
|
||||
export type SimpleState = { status: "idle" } | { status: "error"; code: string };
|
||||
|
||||
function errorCode(err: unknown): string {
|
||||
if (err instanceof ServiceError) return err.code === "invalid" || err.code === "conflict" ? err.message : err.code;
|
||||
if (err instanceof ForbiddenError) return "forbidden";
|
||||
console.error("[actions/imports]", err);
|
||||
return "error";
|
||||
}
|
||||
|
||||
export async function confirmImportAction(importId: string, _prev: ConfirmState, formData: FormData): Promise<ConfirmState> {
|
||||
const ctx = ctxFromGuard(await guard("import:write", "work_order:write"));
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(String(formData.get("payload") ?? ""));
|
||||
} catch {
|
||||
return { status: "error", code: "form_invalid" };
|
||||
}
|
||||
try {
|
||||
await confirmImport(ctx, importId, payload);
|
||||
} catch (err) {
|
||||
const issues = err instanceof ServiceError && err.code === "invalid" ? (err.details as Array<{ path: string; message: string }> | undefined) : undefined;
|
||||
return { status: "error", code: errorCode(err), issues };
|
||||
}
|
||||
revalidatePath("/imports");
|
||||
redirect(`/imports/${importId}`);
|
||||
}
|
||||
|
||||
// Bound as (prev, formData) action for useActionState; the extra arguments are not needed.
|
||||
export async function discardImportAction(importId: string): Promise<SimpleState> {
|
||||
const ctx = ctxFromGuard(await guard("import:write"));
|
||||
try {
|
||||
await discardImport(ctx, importId);
|
||||
} catch (err) {
|
||||
return { status: "error", code: errorCode(err) };
|
||||
}
|
||||
revalidatePath("/imports");
|
||||
redirect("/imports");
|
||||
}
|
||||
|
||||
export async function retryImportAction(importId: string): Promise<SimpleState> {
|
||||
const ctx = ctxFromGuard(await guard("import:write"));
|
||||
try {
|
||||
await retryImport(ctx, importId);
|
||||
} catch (err) {
|
||||
return { status: "error", code: errorCode(err) };
|
||||
}
|
||||
revalidatePath("/imports");
|
||||
revalidatePath(`/imports/${importId}`);
|
||||
return { status: "idle" };
|
||||
}
|
||||
|
||||
export async function searchCustomersAction(q: string) {
|
||||
const ctx = ctxFromGuard(await guard("import:write"));
|
||||
const rows = await searchCustomers(ctx, String(q ?? "").slice(0, 100));
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { AI_MODEL, getAnthropic } from "@/server/ai/client";
|
||||
import type { DocumentExtractionProvider, WorkOrderExtraction, ProviderMeta } from "@/server/ai/providers";
|
||||
import { extractionJsonSchema, parseExtraction } from "@/lib/imports/extraction";
|
||||
|
||||
/**
|
||||
* Claude-based document extraction (spec §9.4, ARCHITEKTUR §4.5).
|
||||
*
|
||||
* - PDF is sent natively as a base64 `document` block (text layer + page images, so scanned
|
||||
* PDFs work without a separate OCR step); JPG/PNG as `image` block.
|
||||
* - Structured output via `output_config.format` (JSON schema) — the response is guaranteed to
|
||||
* match the schema; we still validate with Zod (`parseExtraction`).
|
||||
* - Streaming + `finalMessage()`: the full text of long documents can be large.
|
||||
* - Refusals are handled explicitly; for Claude Opus 5 / Fable 5.1 the server-side fallback
|
||||
* (`fallbacks: "default"`) re-runs a declined request on the recommended fallback model.
|
||||
*/
|
||||
|
||||
const SUPPORTED_IMAGE = new Set(["image/jpeg", "image/png"]);
|
||||
const FALLBACK_BETA = "server-side-fallback-2026-07-01";
|
||||
const MAX_TOKENS = 64_000;
|
||||
|
||||
const SYSTEM_PROMPT = `You extract structured data from order documents (Auftragsbestätigungen, Aufträge, Bestellungen) of German craft and installation businesses.
|
||||
|
||||
Rules:
|
||||
- Return the full recognised text of the document in "text" (reading order, line breaks preserved).
|
||||
- Extract each field only from what is written in the document. Never invent, guess or complete values. If a field is not present, set value to null and confidence to 0.
|
||||
- confidence is 0..1 and reflects how certain you are that the value is correct and belongs to this field. Use values below 0.8 whenever the value is ambiguous, hard to read (scan quality, handwriting), inferred from context, or could belong to a different party. Use values of 0.95 and above only for values printed clearly and labelled unambiguously.
|
||||
- source: a short verbatim snippet (max. 120 characters) from the document that the value was taken from.
|
||||
- The customer is the ordering party (Auftraggeber / Rechnungsempfänger), not the business issuing the document (letterhead, footer, bank details).
|
||||
- The site (Objekt / Baustelle / Einsatzort / Lieferadresse) is where the work is carried out. If the document names no separate site, leave siteName and siteAddress null.
|
||||
- Normalise German formats: dates to ISO 8601 (YYYY-MM-DD; "15.03.2026" → "2026-03-15"), numbers with decimal comma to JSON numbers ("1.234,50" → 1234.5), country to ISO 3166-1 alpha-2 (default "DE" only if the address is clearly German).
|
||||
- Split street and house number ("Hafenstraße 12a" → street "Hafenstraße", houseNumber "12a").
|
||||
- A planned execution period like "KW 12/2026" or "ab 03.04." may be converted to dates only if the year is unambiguous; otherwise keep the value null and mention it in notes.
|
||||
- positions: every line item with quantity and unit; set isMaterial=true for physical material/articles, false for labour/services.
|
||||
- title: a short, factual job title in German (max. 80 characters) derived from the document subject or main service.`;
|
||||
|
||||
export class AnthropicExtractionProvider implements DocumentExtractionProvider {
|
||||
readonly name = "anthropic";
|
||||
readonly model: string;
|
||||
|
||||
constructor(
|
||||
private readonly client: Anthropic,
|
||||
model: string = AI_MODEL,
|
||||
) {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
async extract(input: { bytes: Buffer; mimeType: string; fileName: string }): Promise<{
|
||||
text: string;
|
||||
extraction: WorkOrderExtraction;
|
||||
meta: ProviderMeta;
|
||||
}> {
|
||||
const data = input.bytes.toString("base64");
|
||||
let fileBlock: Anthropic.Beta.BetaContentBlockParam;
|
||||
if (input.mimeType === "application/pdf") {
|
||||
fileBlock = { type: "document", source: { type: "base64", media_type: "application/pdf", data }, title: input.fileName };
|
||||
} else if (SUPPORTED_IMAGE.has(input.mimeType)) {
|
||||
fileBlock = { type: "image", source: { type: "base64", media_type: input.mimeType as "image/jpeg" | "image/png", data } };
|
||||
} else {
|
||||
throw new Error(`unsupported mime type for extraction: ${input.mimeType}`);
|
||||
}
|
||||
|
||||
const useFallback = /^claude-(opus-5|fable-5-1|mythos-5-1)/.test(this.model);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
let message: Anthropic.Beta.BetaMessage;
|
||||
try {
|
||||
const stream = this.client.beta.messages.stream({
|
||||
model: this.model,
|
||||
max_tokens: MAX_TOKENS,
|
||||
thinking: { type: "adaptive" },
|
||||
system: SYSTEM_PROMPT,
|
||||
output_config: { format: { type: "json_schema", schema: extractionJsonSchema() } },
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
fileBlock,
|
||||
{ type: "text", text: `Extract the order data from this document. Today is ${today} (for resolving incomplete dates).` },
|
||||
],
|
||||
},
|
||||
],
|
||||
...(useFallback ? { betas: [FALLBACK_BETA], fallbacks: "default" as const } : {}),
|
||||
});
|
||||
message = await stream.finalMessage();
|
||||
} catch (err) {
|
||||
if (err instanceof Anthropic.APIError) {
|
||||
// No document content in the message — only status/type for the import job's error field.
|
||||
throw new Error(`Claude API error ${err.status ?? "?"} (${err.name})`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (message.stop_reason === "refusal") {
|
||||
throw new Error("Claude declined to process the document (refusal)");
|
||||
}
|
||||
if (message.stop_reason === "max_tokens") {
|
||||
throw new Error("Claude response truncated (max_tokens)");
|
||||
}
|
||||
const textBlock = message.content.find((b): b is Anthropic.Beta.BetaTextBlock => b.type === "text");
|
||||
if (!textBlock) throw new Error("Claude response contained no text block");
|
||||
|
||||
let parsed: { text?: unknown; extraction?: unknown };
|
||||
try {
|
||||
parsed = JSON.parse(textBlock.text) as typeof parsed;
|
||||
} catch {
|
||||
throw new Error("Claude response was not valid JSON");
|
||||
}
|
||||
|
||||
return {
|
||||
text: typeof parsed.text === "string" ? parsed.text : "",
|
||||
extraction: parseExtraction(parsed.extraction),
|
||||
meta: {
|
||||
provider: this.name,
|
||||
model: message.model ?? this.model,
|
||||
inputTokens: message.usage.input_tokens,
|
||||
outputTokens: message.usage.output_tokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configured extraction provider or `null` (graceful degradation → manual entry).
|
||||
* `AI_EXTRACTION_PROVIDER` may be unset or "anthropic"; any other value disables extraction.
|
||||
*/
|
||||
export function getExtractionProvider(): DocumentExtractionProvider | null {
|
||||
const configured = process.env.AI_EXTRACTION_PROVIDER?.trim().toLowerCase();
|
||||
if (configured && configured !== "anthropic") return null;
|
||||
const client = getAnthropic();
|
||||
return client ? new AnthropicExtractionProvider(client) : null;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { DocumentExtractionProvider, ProviderMeta, WorkOrderExtraction } from "@/server/ai/providers";
|
||||
import { emptyExtraction } from "@/lib/imports/extraction";
|
||||
|
||||
/**
|
||||
* Deterministic provider for tests and demos: returns a fixed extraction (merged over an empty
|
||||
* one) or throws the configured error. Records every call.
|
||||
*/
|
||||
export class FakeExtractionProvider implements DocumentExtractionProvider {
|
||||
readonly name = "fake";
|
||||
readonly model = "fake-extraction-1";
|
||||
readonly calls: Array<{ mimeType: string; fileName: string; size: number }> = [];
|
||||
|
||||
constructor(
|
||||
private readonly opts: {
|
||||
extraction?: Partial<WorkOrderExtraction>;
|
||||
text?: string;
|
||||
fail?: Error;
|
||||
} = {},
|
||||
) {}
|
||||
|
||||
async extract(input: { bytes: Buffer; mimeType: string; fileName: string }): Promise<{
|
||||
text: string;
|
||||
extraction: WorkOrderExtraction;
|
||||
meta: ProviderMeta;
|
||||
}> {
|
||||
this.calls.push({ mimeType: input.mimeType, fileName: input.fileName, size: input.bytes.byteLength });
|
||||
if (this.opts.fail) throw this.opts.fail;
|
||||
return {
|
||||
text: this.opts.text ?? "",
|
||||
extraction: { ...emptyExtraction(), ...structuredClone(this.opts.extraction ?? {}) },
|
||||
meta: { provider: this.name, model: this.model, inputTokens: 100, outputTokens: 50 },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import type { JobPayload } from "@/server/jobs/queues";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { getExtractionProvider } from "@/server/ai/extraction/anthropic";
|
||||
import { processImport } from "@/server/services/imports/process";
|
||||
import { readDocumentBytes } from "@/server/services/imports/document-store-stub";
|
||||
|
||||
/**
|
||||
* BullMQ processor for "import-extraction" (ARCHITEKTUR §4.4). System context: tenant from the
|
||||
* payload (dbForTenant), no user permissions — processImport performs no permission-gated steps.
|
||||
* Errors are recorded on the job (status failed) instead of being rethrown, so a failed
|
||||
* extraction is retried by the user ("Neu verarbeiten"), not blindly by the queue.
|
||||
*/
|
||||
export async function process(payload: JobPayload): Promise<void> {
|
||||
const ctx: ServiceCtx = {
|
||||
db: dbForTenant(payload.tenantId),
|
||||
tenantId: payload.tenantId,
|
||||
userId: payload.actorId ?? "",
|
||||
permissions: new Set<string>(),
|
||||
};
|
||||
await processImport(ctx, payload.entityId, {
|
||||
provider: getExtractionProvider(),
|
||||
loadBytes: (doc) => readDocumentBytes(doc.storageKey),
|
||||
});
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export type JobProcessor = (payload: JobPayload) => Promise<void>;
|
||||
* Lazy imports keep the app bundle free of worker-only dependencies (Playwright etc.).
|
||||
*/
|
||||
export const PROCESSORS: Partial<Record<JobQueueName, () => Promise<JobProcessor>>> = {
|
||||
// lane-imports: "import-extraction": () => import("./import-extraction").then((m) => m.process),
|
||||
"import-extraction": () => import("./import-extraction").then((m) => m.process),
|
||||
// lane-lotse: "transcription": () => import("./transcription").then((m) => m.process),
|
||||
// lane-reports: "report-pdf": () => import("./report-pdf").then((m) => m.process),
|
||||
// lane-field: "image-derivatives": () => import("./image-derivatives").then((m) => m.process),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user