Merge lane/import in feature/craftvia-mvp
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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