Integration: Stubs von L2/L3/L4/L5 gegen echte Services getauscht
- Aufträge: createWorkOrder/transitionWorkOrder/getCompletionBlockers aus L2 - Dokumente: storeFile aus L1; neu services/documents/read.ts (readStoredBytes, readDocumentBytes mit Prüfsummenprüfung); L2-Upload nutzt zentrale Ablage - Dubletten aus L1 (findDuplicateCustomers(ctx)); Objekt-Kandidaten als imports/site-candidates.ts; Dateityp-Erkennung mobil als field/mime.ts - Objekt-Historie mobil als Adapter auf L1 getSiteHistory, PDF über /api/v1/reports/:id/pdf - L4-Upload-Idempotenz: fester Upload-Lineage nach storeFile, Race → Soft-Delete + Replay - PDF-Worker-Kontext: document:write zum Ablegen des Berichts-PDF - Import: doppeltes Work-Order-Audit entfernt; bestätigte Aufträge starten planned - Dateilinks in Auftragsdetail auf /files/[documentId] - proxy: /api/v1 ohne Session → 401 JSON statt Login-Redirect - ARCHITEKTUR §2: Objekt-Historie für Feldrollen (freigegebene Einsätze aller Teams) Gate: tsc, lint, build, 42/42 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@ import { ctxFromGuard, ServiceError } from "@/server/services/context";
|
||||
import { requireVisibleReport } from "@/server/services/reports/common";
|
||||
import { captureSignature } from "@/server/services/reports/signature";
|
||||
// TODO(merge L4): signature PNG could go through POST /api/v1/uploads once available
|
||||
import { storeFile } from "@/server/services/reports/_stubs/documents";
|
||||
import { storeFile } from "@/server/services/documents/store";
|
||||
import { errorState, okState, str } from "./_state";
|
||||
|
||||
const guard = moduleGuard("reports");
|
||||
|
||||
@@ -3,7 +3,7 @@ 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";
|
||||
import { readStoredBytes } from "@/server/services/documents/read";
|
||||
|
||||
/**
|
||||
* BullMQ processor for "import-extraction" (ARCHITEKTUR §4.4). System context: tenant from the
|
||||
@@ -20,6 +20,6 @@ export async function process(payload: JobPayload): Promise<void> {
|
||||
};
|
||||
await processImport(ctx, payload.entityId, {
|
||||
provider: getExtractionProvider(),
|
||||
loadBytes: (doc) => readDocumentBytes(doc.storageKey),
|
||||
loadBytes: (doc) => readStoredBytes(doc.storageKey),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,14 +5,15 @@ import type { JobPayload } from "../queues";
|
||||
|
||||
/**
|
||||
* Queue "report-pdf": renders the PDF of an approved report (lane L5).
|
||||
* System context: tenant-bound db, read access to all reports of the tenant — nothing else.
|
||||
* System context: tenant-bound db, read access to all reports of the tenant, and storing the PDF document.
|
||||
*/
|
||||
export async function process(payload: JobPayload): Promise<void> {
|
||||
const ctx: ServiceCtx = {
|
||||
db: dbForTenant(payload.tenantId),
|
||||
tenantId: payload.tenantId,
|
||||
userId: payload.actorId ?? "system",
|
||||
permissions: new Set(["report:read", "work_order:read_all", "document:read_internal"]),
|
||||
// document:write: the system stores the rendered PDF via the central document service.
|
||||
permissions: new Set(["report:read", "work_order:read_all", "document:read_internal", "document:write"]),
|
||||
};
|
||||
const res = await generateReportPdf(ctx, payload.entityId);
|
||||
console.info(`[report-pdf] ${payload.entityId}: ${res.skipped ? "already rendered" : `stored ${res.documentId}`}`);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { storage } from "@/server/storage/adapter";
|
||||
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { sha256Hex } from "@/server/services/documents/store";
|
||||
|
||||
/**
|
||||
* Server-side byte access to stored documents (workers, PDF rendering, import extraction).
|
||||
*
|
||||
* NO visibility/scope check here — this is not a download path. User-facing downloads must go
|
||||
* through `services/documents/access.ts#openDocumentContent` (/files/[documentId]).
|
||||
*/
|
||||
|
||||
/** Read raw bytes by storage key; null when the object does not exist (or storage is the stub). */
|
||||
export async function readStoredBytes(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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a tenant document's bytes and verify its SHA-256 checksum.
|
||||
* Throws `not_found` (missing/soft-deleted/foreign tenant/no content) or `invalid` (checksum mismatch).
|
||||
*/
|
||||
export async function readDocumentBytes(
|
||||
ctx: ServiceCtx,
|
||||
documentId: string,
|
||||
): Promise<{ bytes: Buffer; mimeType: string; fileName: string; checksum: string }> {
|
||||
const document = await ctx.db.document.findFirst({
|
||||
where: { id: documentId, deletedAt: null },
|
||||
select: { storageKey: true, mimeType: true, fileName: true, checksum: true },
|
||||
});
|
||||
if (!document) throw new ServiceError("not_found", "document not found");
|
||||
// defense in depth: the key must carry the tenant prefix
|
||||
if (!document.storageKey.startsWith(`${ctx.tenantId}/`)) throw new ServiceError("not_found", "document content not available");
|
||||
const bytes = await readStoredBytes(document.storageKey);
|
||||
if (!bytes) throw new ServiceError("not_found", "document content not available");
|
||||
if (sha256Hex(bytes) !== document.checksum) throw new ServiceError("invalid", "document checksum mismatch");
|
||||
return { bytes, mimeType: document.mimeType, fileName: document.fileName, checksum: document.checksum };
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Magic-byte detection for mobile uploads (photo/voice note/preview): decides BEFORE storing
|
||||
* whether the payload matches the declared kind. Final validation happens in
|
||||
* services/documents/store.ts#storeFile (lane master data).
|
||||
*/
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
export type Kind = "image" | "pdf" | "audio";
|
||||
const LIMITS: Record<Kind, number> = { image: 15 * MB, pdf: 25 * MB, audio: 20 * MB };
|
||||
|
||||
/** Detects the real MIME type from magic bytes (null = not allowed). */
|
||||
export function sniffMime(bytes: Buffer): { mime: string; kind: Kind } | null {
|
||||
const b = bytes;
|
||||
const at = (offset: number, sig: number[]) => sig.every((v, i) => b[offset + i] === v);
|
||||
const ascii = (offset: number, s: string) => b.length >= offset + s.length && b.toString("latin1", offset, offset + s.length) === s;
|
||||
if (b.length < 12) return null;
|
||||
if (at(0, [0xff, 0xd8, 0xff])) return { mime: "image/jpeg", kind: "image" };
|
||||
if (at(0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return { mime: "image/png", kind: "image" };
|
||||
if (ascii(0, "RIFF") && ascii(8, "WEBP")) return { mime: "image/webp", kind: "image" };
|
||||
if (ascii(0, "%PDF-")) return { mime: "application/pdf", kind: "pdf" };
|
||||
if (at(0, [0x1a, 0x45, 0xdf, 0xa3])) return { mime: "audio/webm", kind: "audio" };
|
||||
if (ascii(0, "OggS")) return { mime: "audio/ogg", kind: "audio" };
|
||||
if (ascii(0, "RIFF") && ascii(8, "WAVE")) return { mime: "audio/wav", kind: "audio" };
|
||||
if (ascii(4, "ftyp")) {
|
||||
const brand = b.toString("latin1", 8, 12);
|
||||
if (/^(heic|heix|mif1|msf1)$/.test(brand)) return { mime: "image/heic", kind: "image" };
|
||||
return { mime: "audio/mp4", kind: "audio" }; // M4A / MP4 audio from iOS MediaRecorder
|
||||
}
|
||||
if (ascii(0, "ID3") || (b[0] === 0xff && (b[1] & 0xe0) === 0xe0)) return { mime: "audio/mpeg", kind: "audio" };
|
||||
return null;
|
||||
}
|
||||
@@ -4,8 +4,39 @@ import { allowedDocumentVisibility, requireVisibleWorkOrder, workOrderScope } fr
|
||||
import { STATUS_GROUP, type CompletionBlocker, type StatusGroup, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||
import { ACTIVE_SESSION_STATUSES } from "./sessions";
|
||||
// TODO(merge L2/L1): replace with the lane implementations
|
||||
import { completionBlockers } from "./stubs/work-order-transition";
|
||||
import { getSiteHistory, type SiteHistoryEntry } from "./stubs/site-history";
|
||||
import { getCompletionBlockers } from "@/server/services/work-orders/completion";
|
||||
import { getSiteHistory } from "@/server/services/sites/history";
|
||||
|
||||
/** One released report at the site, as shown in the mobile order detail (US-005). */
|
||||
export type SiteHistoryEntry = {
|
||||
reportId: string;
|
||||
reportType: "daily" | "completion";
|
||||
reportDate: Date;
|
||||
workOrderId: string;
|
||||
workOrderNumber: string;
|
||||
workOrderTitle: string;
|
||||
/** Immutable PDF of the approved report (lane reports API; requires report:read). */
|
||||
pdfHref: string;
|
||||
};
|
||||
|
||||
/** Adapter over the site history service (lane master data): flattens approved reports, newest first. */
|
||||
async function fieldSiteHistory(ctx: ServiceCtx, siteId: string, limit = 20): Promise<SiteHistoryEntry[]> {
|
||||
const { items } = await getSiteHistory(ctx, siteId, { onlyApproved: true, pageSize: Math.min(100, limit * 2) });
|
||||
return items
|
||||
.flatMap((i) =>
|
||||
i.approvedReports.map((r) => ({
|
||||
reportId: r.id,
|
||||
reportType: r.type,
|
||||
reportDate: r.reportDate,
|
||||
workOrderId: i.workOrderId,
|
||||
workOrderNumber: i.number,
|
||||
workOrderTitle: i.title,
|
||||
pdfHref: `/api/v1/reports/${encodeURIComponent(r.id)}/pdf`,
|
||||
})),
|
||||
)
|
||||
.sort((a, b) => b.reportDate.getTime() - a.reportDate.getTime())
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/** Read models of the mobile app (Spec §11.2, §22, US-005). All reads go through workOrderScope. */
|
||||
|
||||
@@ -208,8 +239,8 @@ export async function getFieldOrderDetail(ctx: ServiceCtx, workOrderId: string):
|
||||
const [orderDocs, siteDocs, siteHistory, blockers] = await Promise.all([
|
||||
ctx.db.document.findMany({ where: { ...docWhere, workOrderId: wo.id }, orderBy: { createdAt: "desc" }, select: docSelect }),
|
||||
wo.siteId ? ctx.db.document.findMany({ where: { ...docWhere, siteId: wo.siteId, workOrderId: null }, orderBy: { createdAt: "desc" }, select: docSelect }) : Promise.resolve([]),
|
||||
wo.siteId ? getSiteHistory(ctx, wo.siteId, { onlyApproved: true }).catch((err) => (err instanceof ServiceError ? [] : Promise.reject(err))) : Promise.resolve([]),
|
||||
completionBlockers(ctx, wo.id),
|
||||
wo.siteId ? fieldSiteHistory(ctx, wo.siteId).catch((err) => (err instanceof ServiceError ? [] : Promise.reject(err))) : Promise.resolve([]),
|
||||
getCompletionBlockers(ctx, wo.id),
|
||||
]);
|
||||
|
||||
// only the newest version per lineage
|
||||
@@ -268,7 +299,7 @@ export async function getFieldBundle(ctx: ServiceCtx, since?: Date | null) {
|
||||
|
||||
const siteIds = [...new Set(orders.map((o) => o.site?.id).filter((id): id is string => !!id))];
|
||||
const histories = Object.fromEntries(
|
||||
await Promise.all(siteIds.map(async (id) => [id, await getSiteHistory(ctx, id, { onlyApproved: true, limit: 5 }).catch(() => [])] as const)),
|
||||
await Promise.all(siteIds.map(async (id) => [id, await fieldSiteHistory(ctx, id, 5).catch(() => [])] as const)),
|
||||
);
|
||||
return {
|
||||
serverTime: serverTime.toISOString(),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { canTransition, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||
import type { ParsedOpPayload } from "@/lib/sync/ops";
|
||||
import { audit, opTime, requireFieldOrder, type FieldOrder } from "./common";
|
||||
// TODO(merge L2): replace with "@/server/services/work-orders/transition"
|
||||
import { transitionWorkOrder } from "./stubs/work-order-transition";
|
||||
import { transitionWorkOrder } from "@/server/services/work-orders/transition";
|
||||
|
||||
/**
|
||||
* Work sessions (Spec §12.1/§12.2): one active session per user + work order. A session consists
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
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 L4) — stands in for `src/server/services/documents/store.ts#storeFile` (ARCHITEKTUR §4.3),
|
||||
* which does not exist on the base commit. Same signature; validates allowlist, size limit per
|
||||
* kind, magic bytes, normalises the file name, computes SHA-256 and stores via the storage adapter.
|
||||
* On merge of the documents contract: delete this file and import the shared implementation.
|
||||
*/
|
||||
|
||||
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;
|
||||
title?: string | null;
|
||||
};
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
type Kind = "image" | "pdf" | "audio";
|
||||
const LIMITS: Record<Kind, number> = { image: 15 * MB, pdf: 25 * MB, audio: 20 * MB };
|
||||
|
||||
/** Detects the real MIME type from magic bytes (null = not allowed). */
|
||||
export function sniffMime(bytes: Buffer): { mime: string; kind: Kind } | null {
|
||||
const b = bytes;
|
||||
const at = (offset: number, sig: number[]) => sig.every((v, i) => b[offset + i] === v);
|
||||
const ascii = (offset: number, s: string) => b.length >= offset + s.length && b.toString("latin1", offset, offset + s.length) === s;
|
||||
if (b.length < 12) return null;
|
||||
if (at(0, [0xff, 0xd8, 0xff])) return { mime: "image/jpeg", kind: "image" };
|
||||
if (at(0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return { mime: "image/png", kind: "image" };
|
||||
if (ascii(0, "RIFF") && ascii(8, "WEBP")) return { mime: "image/webp", kind: "image" };
|
||||
if (ascii(0, "%PDF-")) return { mime: "application/pdf", kind: "pdf" };
|
||||
if (at(0, [0x1a, 0x45, 0xdf, 0xa3])) return { mime: "audio/webm", kind: "audio" };
|
||||
if (ascii(0, "OggS")) return { mime: "audio/ogg", kind: "audio" };
|
||||
if (ascii(0, "RIFF") && ascii(8, "WAVE")) return { mime: "audio/wav", kind: "audio" };
|
||||
if (ascii(4, "ftyp")) {
|
||||
const brand = b.toString("latin1", 8, 12);
|
||||
if (/^(heic|heix|mif1|msf1)$/.test(brand)) return { mime: "image/heic", kind: "image" };
|
||||
return { mime: "audio/mp4", kind: "audio" }; // M4A / MP4 audio from iOS MediaRecorder
|
||||
}
|
||||
if (ascii(0, "ID3") || (b[0] === 0xff && (b[1] & 0xe0) === 0xe0)) return { mime: "audio/mpeg", kind: "audio" };
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeFileName(name: string): string {
|
||||
const base = name.split(/[\\/]/).pop() ?? "datei";
|
||||
return base.normalize("NFC").replace(/[\u0000-\u001f<>:"|?*]+/g, "_").trim().slice(0, 180) || "datei";
|
||||
}
|
||||
|
||||
export async function storeFile(ctx: ServiceCtx, input: StoreFileInput): Promise<Document> {
|
||||
const sniffed = sniffMime(input.bytes);
|
||||
if (!sniffed) throw new ServiceError("invalid", "file type not allowed");
|
||||
if (input.bytes.byteLength > LIMITS[sniffed.kind]) throw new ServiceError("invalid", "file too large");
|
||||
const declaredBase = input.declaredMime.split(";")[0].trim().toLowerCase();
|
||||
// declared type must at least belong to the same family (image/*, audio/*, video/webm|mp4 for audio containers, pdf)
|
||||
const family = declaredBase.split("/")[0];
|
||||
const familyOk =
|
||||
sniffed.kind === "pdf" ? declaredBase === "application/pdf" : sniffed.kind === "image" ? family === "image" : family === "audio" || declaredBase === "video/webm" || declaredBase === "video/mp4";
|
||||
if (!familyOk) throw new ServiceError("invalid", "declared MIME type does not match content");
|
||||
|
||||
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.mime, 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 } });
|
||||
if (last) version = last.version + 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: input.title ?? null,
|
||||
fileName,
|
||||
storageKey: stored.storageKey,
|
||||
mimeType: sniffed.mime,
|
||||
fileSize: input.bytes.byteLength,
|
||||
checksum,
|
||||
version,
|
||||
lineageId,
|
||||
visibility: input.visibility,
|
||||
uploadStatus: "uploaded",
|
||||
uploadedById: ctx.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { siteScope } from "@/server/services/work-orders/visibility";
|
||||
|
||||
/**
|
||||
* STUB (lane L4) — stands in for L1 `src/server/services/sites/history.ts#getSiteHistory`
|
||||
* (Spec §8.3) until lane "Stammdaten" is merged. Contract used by the mobile order detail:
|
||||
* getSiteHistory(ctx, siteId, { onlyApproved }) → SiteHistoryEntry[] (newest first)
|
||||
* Read-only; the site must be in the user's site scope (otherwise not_found).
|
||||
*/
|
||||
|
||||
export type SiteHistoryEntry = {
|
||||
reportId: string;
|
||||
reportType: "daily" | "completion";
|
||||
reportDate: Date;
|
||||
approvedAt: Date | null;
|
||||
pdfDocumentId: string | null;
|
||||
workOrderId: string;
|
||||
workOrderNumber: string;
|
||||
workOrderTitle: string;
|
||||
};
|
||||
|
||||
export async function getSiteHistory(ctx: ServiceCtx, siteId: string, opts: { onlyApproved: boolean; limit?: number }): Promise<SiteHistoryEntry[]> {
|
||||
const site = await ctx.db.site.findFirst({ where: { AND: [{ id: siteId }, await siteScope(ctx)] }, select: { id: true } });
|
||||
if (!site) throw new ServiceError("not_found", "site not found");
|
||||
const reports = await ctx.db.report.findMany({
|
||||
where: {
|
||||
workOrder: { siteId, deletedAt: null },
|
||||
...(opts.onlyApproved ? { status: "approved" as const } : { status: { not: "superseded" as const } }),
|
||||
},
|
||||
orderBy: [{ reportDate: "desc" }, { createdAt: "desc" }],
|
||||
take: opts.limit ?? 20,
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
reportDate: true,
|
||||
approvedAt: true,
|
||||
pdfDocumentId: true,
|
||||
workOrder: { select: { id: true, number: true, title: true } },
|
||||
},
|
||||
});
|
||||
return reports.map((r) => ({
|
||||
reportId: r.id,
|
||||
reportType: r.type,
|
||||
reportDate: r.reportDate,
|
||||
approvedAt: r.approvedAt,
|
||||
pdfDocumentId: r.pdfDocumentId,
|
||||
workOrderId: r.workOrder.id,
|
||||
workOrderNumber: r.workOrder.number,
|
||||
workOrderTitle: r.workOrder.title,
|
||||
}));
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { emitEvent } from "@/server/events";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
|
||||
import type { EventType } from "@/lib/events";
|
||||
import {
|
||||
canTransition,
|
||||
requiredPermission,
|
||||
type CompletionBlocker,
|
||||
type WorkOrderStatus,
|
||||
} from "@/lib/work-orders/status";
|
||||
|
||||
/**
|
||||
* STUB (lane L4) — stands in for L2 `src/server/services/work-orders/transition.ts#transitionWorkOrder`
|
||||
* until lane "Aufträge" is merged. Same contract as ARCHITEKTUR §3:
|
||||
* transitionWorkOrder(ctx, { workOrderId, to, reason?, baseVersion? }) → { id, from, status, version }
|
||||
* throws ServiceError not_found | forbidden | invalid | conflict | blocked (details: CompletionBlocker[]).
|
||||
* On merge: delete this file and point the imports in services/field + services/sync to L2's module.
|
||||
*/
|
||||
|
||||
export type TransitionInput = {
|
||||
workOrderId: string;
|
||||
to: WorkOrderStatus;
|
||||
reason?: string | null;
|
||||
/** optimistic concurrency: must match WorkOrder.version when given */
|
||||
baseVersion?: number;
|
||||
};
|
||||
|
||||
export type TransitionResult = { id: string; from: WorkOrderStatus; status: WorkOrderStatus; version: number };
|
||||
|
||||
const EVENT_FOR: Partial<Record<WorkOrderStatus, EventType>> = {
|
||||
in_progress: "work_order.started",
|
||||
daily_report_created: "work_order.daily_report_created",
|
||||
technically_completed: "work_order.technically_completed",
|
||||
released_for_billing: "work_order.released_for_billing",
|
||||
cancelled: "work_order.cancelled",
|
||||
};
|
||||
|
||||
/** Completion guards (ARCHITEKTUR §3): required checklist items, photo requirements, running sessions. */
|
||||
export async function completionBlockers(ctx: ServiceCtx, workOrderId: string): Promise<CompletionBlocker[]> {
|
||||
const [items, requirements, sessions] = await Promise.all([
|
||||
ctx.db.checklistItem.findMany({ where: { workOrderId, required: true, checked: false }, select: { id: true, label: true }, orderBy: { sortOrder: "asc" } }),
|
||||
ctx.db.photoRequirement.findMany({ where: { workOrderId, photos: { none: {} } }, select: { id: true, label: true }, orderBy: { sortOrder: "asc" } }),
|
||||
ctx.db.workSession.findMany({ where: { workOrderId, status: { in: ["en_route", "running", "paused"] } }, select: { id: true, userId: true } }),
|
||||
]);
|
||||
return [
|
||||
...items.map((i): CompletionBlocker => ({ kind: "checklist_item", itemId: i.id, label: i.label })),
|
||||
...requirements.map((r): CompletionBlocker => ({ kind: "photo_requirement", requirementId: r.id, label: r.label })),
|
||||
...sessions.map((s): CompletionBlocker => ({ kind: "running_session", sessionId: s.id, userId: s.userId })),
|
||||
];
|
||||
}
|
||||
|
||||
export async function transitionWorkOrder(ctx: ServiceCtx, input: TransitionInput): Promise<TransitionResult> {
|
||||
const wo = await requireVisibleWorkOrder(ctx, input.workOrderId, { id: true, number: true, status: true, version: true });
|
||||
const from = wo.status as WorkOrderStatus;
|
||||
if (!canTransition(from, input.to)) throw new ServiceError("invalid", `transition ${from} → ${input.to} not allowed`);
|
||||
|
||||
const permission = requiredPermission(from, input.to);
|
||||
if (permission === "report:approve_team") {
|
||||
if (!can(ctx, "report:approve_team") && !can(ctx, "report:approve")) throw new ServiceError("forbidden", "missing permission report:approve_team");
|
||||
} else {
|
||||
assertCan(ctx, permission);
|
||||
}
|
||||
if (input.baseVersion !== undefined && input.baseVersion !== wo.version) {
|
||||
throw new ServiceError("conflict", "work order was changed in the meantime", { currentVersion: wo.version });
|
||||
}
|
||||
if (input.to === "technically_completed") {
|
||||
const blockers = await completionBlockers(ctx, wo.id);
|
||||
if (blockers.length) throw new ServiceError("blocked", "completion requirements missing", blockers);
|
||||
}
|
||||
|
||||
const updated = await ctx.db.workOrder.updateMany({
|
||||
where: { id: wo.id, version: wo.version },
|
||||
data: { status: input.to, version: { increment: 1 } },
|
||||
});
|
||||
if (updated.count !== 1) throw new ServiceError("conflict", "work order was changed in the meantime");
|
||||
|
||||
await ctx.db.workOrderStatusChange.create({
|
||||
data: { tenantId: ctx.tenantId, workOrderId: wo.id, fromStatus: from, toStatus: input.to, actorId: ctx.userId, reason: input.reason ?? null },
|
||||
});
|
||||
await writeAuditLog({
|
||||
tenantId: ctx.tenantId,
|
||||
actorId: ctx.userId,
|
||||
action: "update",
|
||||
entity: "work_order",
|
||||
entityId: wo.id,
|
||||
before: { status: from, version: wo.version },
|
||||
after: { status: input.to, version: wo.version + 1, reason: input.reason ?? null },
|
||||
});
|
||||
await emitEvent(ctx, {
|
||||
type: EVENT_FOR[input.to] ?? "work_order.changed",
|
||||
entityType: "work_order",
|
||||
entityId: wo.id,
|
||||
data: { number: wo.number, from, to: input.to },
|
||||
});
|
||||
return { id: wo.id, from, status: input.to, version: wo.version + 1 };
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import { dispatchJob } from "@/server/jobs/dispatch";
|
||||
import { JOB_QUEUES } from "@/server/jobs/queues";
|
||||
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { audit, isUniqueViolation, requireFieldOrder } from "./common";
|
||||
// TODO(merge documents contract §4.3): replace with "@/server/services/documents/store"
|
||||
import { sniffMime, storeFile } from "./stubs/documents-store";
|
||||
import { storeFile } from "@/server/services/documents/store";
|
||||
import { sniffMime } from "./mime";
|
||||
|
||||
/**
|
||||
* Binary uploads of the mobile app (ARCHITEKTUR §4.6: POST /api/v1/uploads → documentId).
|
||||
@@ -52,19 +52,24 @@ export async function storeFieldUpload(
|
||||
const expectedKind = meta.kind === "photo" ? "image" : "audio";
|
||||
if (!sniffed || sniffed.kind !== expectedKind) throw new ServiceError("invalid", `file is not a valid ${expectedKind}`);
|
||||
|
||||
// The central store treats `lineageId` as "new version of an existing document", so the file is
|
||||
// stored as a fresh document and the deterministic upload lineage (idempotency key per device
|
||||
// clientId) is attached afterwards. A concurrent upload with the same clientId loses the unique
|
||||
// (lineageId, version) race: its document is soft-deleted and the winner is returned.
|
||||
const created = await storeFile(ctx, {
|
||||
bytes: file.bytes,
|
||||
fileName: file.name || (meta.kind === "photo" ? "foto.jpg" : "sprachnotiz.webm"),
|
||||
declaredMime: file.type || sniffed.mime,
|
||||
category: meta.kind,
|
||||
visibility: "team",
|
||||
links: { workOrderId: wo.id, siteId: wo.siteId, customerId: wo.customerId },
|
||||
});
|
||||
let doc;
|
||||
try {
|
||||
doc = await storeFile(ctx, {
|
||||
bytes: file.bytes,
|
||||
fileName: file.name || (meta.kind === "photo" ? "foto.jpg" : "sprachnotiz.webm"),
|
||||
declaredMime: file.type || sniffed.mime,
|
||||
category: meta.kind,
|
||||
visibility: "team",
|
||||
links: { workOrderId: wo.id, siteId: wo.siteId, customerId: wo.customerId },
|
||||
lineageId,
|
||||
});
|
||||
doc = await ctx.db.document.update({ where: { id: created.id }, data: { lineageId } });
|
||||
} catch (err) {
|
||||
if (isUniqueViolation(err)) {
|
||||
await ctx.db.document.update({ where: { id: created.id }, data: { deletedAt: new Date() } });
|
||||
const raced = await replay();
|
||||
if (raced) return raced;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/serve
|
||||
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 { createWorkOrder } from "@/server/services/work-orders/create";
|
||||
import { startExtraction, type Dispatch } from "./upload";
|
||||
import { dispatchJob } from "@/server/jobs/dispatch";
|
||||
|
||||
@@ -168,13 +168,7 @@ export async function confirmImport(ctx: ServiceCtx, importId: string, rawForm:
|
||||
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 },
|
||||
});
|
||||
// work order creation is audited by services/work-orders/create (entity work_order)
|
||||
await writeAuditLog({
|
||||
...base,
|
||||
action: "import",
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
} 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";
|
||||
import { findDuplicateCustomers } from "@/server/services/customers/duplicates";
|
||||
import { findSiteCandidates } from "./site-candidates";
|
||||
|
||||
export type ProcessDeps = {
|
||||
provider: DocumentExtractionProvider | null;
|
||||
@@ -76,7 +77,7 @@ export async function processImport(ctx: ServiceCtx, importId: string, deps: Pro
|
||||
}
|
||||
|
||||
const address = fields.customerAddress.value ?? {};
|
||||
const duplicates = await findDuplicateCustomers(ctx.db, {
|
||||
const duplicates = await findDuplicateCustomers(ctx, {
|
||||
customerNumber: fields.customerNumber.value,
|
||||
companyName: fields.companyName.value,
|
||||
firstName: fields.customerFirstName.value,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { TenantDb } from "@/server/db";
|
||||
import { normalizeStreet } from "@/lib/customers/duplicates";
|
||||
import type { SiteCandidate } from "@/lib/imports/extraction";
|
||||
|
||||
const lc = (v: string | null | undefined) => (v ?? "").trim().toLowerCase();
|
||||
|
||||
/** 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);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ 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";
|
||||
import { storeFile } from "@/server/services/documents/store";
|
||||
|
||||
export type UploadFile = { bytes: Buffer; fileName: string; mimeType: string };
|
||||
export type Dispatch = (payload: JobPayload) => Promise<unknown>;
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
/**
|
||||
* STUB (lane L5 „Berichte") for the shared file contract ARCHITEKTUR §4.3
|
||||
* `src/server/services/documents/store.ts#storeFile` (not yet provided by the architect / documents lane).
|
||||
*
|
||||
* Minimal implementation behind the contracted interface: allowlist + magic bytes + size limit,
|
||||
* SHA-256, storage.put, Document row, lineage versioning. At merge the import in
|
||||
* services/reports/*.ts is switched to the real store and this file is deleted.
|
||||
*/
|
||||
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";
|
||||
|
||||
export type StoreFileInput = {
|
||||
bytes: Uint8Array;
|
||||
fileName: string;
|
||||
declaredMime: string;
|
||||
category: DocumentCategory;
|
||||
visibility: DocumentVisibility;
|
||||
links: { customerId?: string | null; siteId?: string | null; workOrderId?: string | null };
|
||||
lineageId?: string;
|
||||
title?: string | null;
|
||||
};
|
||||
|
||||
const LIMITS: Record<string, number> = { "application/pdf": 25 * 1024 * 1024, "image/png": 15 * 1024 * 1024, "image/jpeg": 15 * 1024 * 1024, "image/webp": 15 * 1024 * 1024 };
|
||||
|
||||
export function sniffMime(bytes: Uint8Array): string | null {
|
||||
const b = bytes;
|
||||
if (b.length >= 5 && b[0] === 0x25 && b[1] === 0x50 && b[2] === 0x44 && b[3] === 0x46 && b[4] === 0x2d) return "application/pdf";
|
||||
if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return "image/png";
|
||||
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg";
|
||||
if (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) return "image/webp";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function sha256Hex(bytes: Uint8Array): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
export async function storeFile(ctx: ServiceCtx, input: StoreFileInput): Promise<Document> {
|
||||
const mime = sniffMime(input.bytes);
|
||||
if (!mime || mime !== input.declaredMime) throw new ServiceError("invalid", "file type not allowed or does not match content");
|
||||
if (input.bytes.byteLength === 0 || input.bytes.byteLength > (LIMITS[mime] ?? 0)) throw new ServiceError("invalid", "file size not allowed");
|
||||
const fileName = input.fileName.normalize("NFC").replace(/[^\w.\- ]+/g, "_").slice(0, 120) || "datei";
|
||||
const checksum = sha256Hex(input.bytes);
|
||||
const put = await storage.put({ tenantId: ctx.tenantId, filename: fileName, contentType: mime, 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: input.title ?? null,
|
||||
fileName,
|
||||
storageKey: put.storageKey,
|
||||
mimeType: mime,
|
||||
fileSize: input.bytes.byteLength,
|
||||
checksum,
|
||||
version,
|
||||
lineageId,
|
||||
visibility: input.visibility,
|
||||
uploadStatus: "uploaded",
|
||||
uploadedById: ctx.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Read the bytes of a stored document (worker/PDF rendering). null if the backend has no bytes. */
|
||||
export async function readFileBytes(storageKey: string): Promise<Uint8Array | null> {
|
||||
const obj = await storage.get(storageKey);
|
||||
if (!obj) return null;
|
||||
const chunks: Uint8Array[] = [];
|
||||
const reader = obj.stream.getReader();
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) chunks.push(value);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* STUB (lane L5 „Berichte") for contracts owned by lane L2 „Aufträge":
|
||||
* - services/work-orders/transition.ts#transitionWorkOrder
|
||||
* - services/work-orders/guards.ts#getCompletionBlockers (ARCHITEKTUR §3 „Guards vor Abschluss")
|
||||
*
|
||||
* Interface follows ARCHITEKTUR §3. At merge the architect replaces the imports in
|
||||
* services/reports/*.ts with the L2 implementations and deletes this file.
|
||||
* Contract extension used by L5 (for L6 mail deduplication): optional `eventData` is merged into the emitted event's
|
||||
* `data` (e.g. `{ occurrenceId }`) — the L2 implementation should accept it as well.
|
||||
*/
|
||||
import type { WorkOrderStatus as DbWorkOrderStatus } from "@prisma/client";
|
||||
import type { DomainEvent } from "@/lib/events";
|
||||
import { canTransition, requiredPermission, type CompletionBlocker, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import { emitEvent } from "@/server/events";
|
||||
import { can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
|
||||
|
||||
export type TransitionInput = {
|
||||
workOrderId: string;
|
||||
to: WorkOrderStatus;
|
||||
reason?: string | null;
|
||||
/** optimistic concurrency (offline sync) */
|
||||
expectedVersion?: number;
|
||||
/** extra event data, e.g. { occurrenceId } for repeatable events */
|
||||
eventData?: DomainEvent["data"];
|
||||
};
|
||||
|
||||
export async function transitionWorkOrder(ctx: ServiceCtx, input: TransitionInput): Promise<{ id: string; status: WorkOrderStatus; version: number }> {
|
||||
const wo = await requireVisibleWorkOrder(ctx, input.workOrderId, { id: true, status: true, version: true, number: true });
|
||||
const from = wo.status as WorkOrderStatus;
|
||||
if (!canTransition(from, input.to)) throw new ServiceError("invalid", `transition ${from} → ${input.to} not allowed`);
|
||||
const perm = requiredPermission(from, input.to);
|
||||
const allowed = perm === "report:approve_team" ? can(ctx, "report:approve_team") || can(ctx, "report:approve") : can(ctx, perm);
|
||||
if (!allowed) throw new ServiceError("forbidden", `missing permission ${perm}`);
|
||||
if (input.expectedVersion !== undefined && input.expectedVersion !== wo.version) {
|
||||
throw new ServiceError("conflict", "work order version changed");
|
||||
}
|
||||
const res = await ctx.db.workOrder.updateMany({
|
||||
where: { id: wo.id, version: wo.version },
|
||||
data: { status: input.to as DbWorkOrderStatus, version: { increment: 1 } },
|
||||
});
|
||||
if (res.count !== 1) throw new ServiceError("conflict", "work order changed concurrently");
|
||||
await ctx.db.workOrderStatusChange.create({
|
||||
data: { tenantId: ctx.tenantId, workOrderId: wo.id, fromStatus: from, toStatus: input.to, actorId: ctx.userId, reason: input.reason ?? null },
|
||||
});
|
||||
await writeAuditLog({
|
||||
tenantId: ctx.tenantId,
|
||||
actorId: ctx.userId,
|
||||
action: "update",
|
||||
entity: "work_order",
|
||||
entityId: wo.id,
|
||||
before: { status: from },
|
||||
after: { status: input.to, reason: input.reason ?? null },
|
||||
});
|
||||
const eventType =
|
||||
input.to === "daily_report_created"
|
||||
? "work_order.daily_report_created"
|
||||
: input.to === "technically_completed"
|
||||
? "work_order.technically_completed"
|
||||
: input.to === "signature_pending"
|
||||
? "work_order.signature_missing"
|
||||
: "work_order.changed";
|
||||
await emitEvent(ctx, { type: eventType, entityType: "work_order", entityId: wo.id, data: { number: wo.number, from, to: input.to, ...input.eventData } });
|
||||
return { id: wo.id, status: input.to, version: wo.version + 1 };
|
||||
}
|
||||
|
||||
export async function getCompletionBlockers(ctx: ServiceCtx, workOrderId: string): Promise<CompletionBlocker[]> {
|
||||
await requireVisibleWorkOrder(ctx, workOrderId, { id: true });
|
||||
const [items, requirements, running] = await Promise.all([
|
||||
ctx.db.checklistItem.findMany({ where: { workOrderId, required: true, checked: false }, orderBy: { sortOrder: "asc" }, select: { id: true, label: true } }),
|
||||
ctx.db.photoRequirement.findMany({ where: { workOrderId, photos: { none: {} } }, orderBy: { sortOrder: "asc" }, select: { id: true, label: true } }),
|
||||
ctx.db.workSession.findMany({ where: { workOrderId, status: { in: ["en_route", "running", "paused"] } }, select: { id: true, userId: true } }),
|
||||
]);
|
||||
return [
|
||||
...items.map((i): CompletionBlocker => ({ kind: "checklist_item", itemId: i.id, label: i.label })),
|
||||
...requirements.map((r): CompletionBlocker => ({ kind: "photo_requirement", requirementId: r.id, label: r.label })),
|
||||
...running.map((s): CompletionBlocker => ({ kind: "running_session", sessionId: s.id, userId: s.userId })),
|
||||
];
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/cont
|
||||
import { nextNumber } from "@/server/services/numbering";
|
||||
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
|
||||
// TODO(merge L2): import from "@/server/services/work-orders/transition" and ".../guards"
|
||||
import { getCompletionBlockers, transitionWorkOrder } from "./_stubs/work-orders";
|
||||
import { getCompletionBlockers } from "@/server/services/work-orders/completion";
|
||||
import { transitionWorkOrder } from "@/server/services/work-orders/transition";
|
||||
import { buildReportContent, tenantTimeZone } from "./build-content";
|
||||
import { auditReport, reportAuditView } from "./common";
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createTranslator } from "next-intl";
|
||||
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
// TODO(merge documents): import from "@/server/services/documents/store"
|
||||
import { readFileBytes, storeFile } from "./_stubs/documents";
|
||||
import { readStoredBytes } from "@/server/services/documents/read";
|
||||
import { storeFile } from "@/server/services/documents/store";
|
||||
import { tenantTimeZone } from "./build-content";
|
||||
import { auditReport, contentOf, requireVisibleReport } from "./common";
|
||||
|
||||
@@ -18,7 +18,7 @@ async function loadReportMessages(locale: string): Promise<Record<string, unknow
|
||||
async function dataUri(ctx: ServiceCtx, documentId: string): Promise<string | null> {
|
||||
const doc = await ctx.db.document.findFirst({ where: { id: documentId, deletedAt: null }, select: { storageKey: true, mimeType: true } });
|
||||
if (!doc || !doc.mimeType.startsWith("image/")) return null;
|
||||
const bytes = await readFileBytes(doc.storageKey).catch(() => null);
|
||||
const bytes = await readStoredBytes(doc.storageKey).catch(() => null);
|
||||
return bytes ? `data:${doc.mimeType};base64,${Buffer.from(bytes).toString("base64")}` : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { CompletionBlocker } from "@/lib/work-orders/status";
|
||||
import { can, type ServiceCtx } from "@/server/services/context";
|
||||
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
|
||||
// TODO(merge L2): import from "@/server/services/work-orders/guards"
|
||||
import { getCompletionBlockers } from "./_stubs/work-orders";
|
||||
import { getCompletionBlockers } from "@/server/services/work-orders/completion";
|
||||
import { tenantTimeZone } from "./build-content";
|
||||
import { contentOf, reportScope, requireVisibleReport } from "./common";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
import { emitEvent } from "@/server/events";
|
||||
import { can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
// TODO(merge L2): import from "@/server/services/work-orders/transition"
|
||||
import { transitionWorkOrder } from "./_stubs/work-orders";
|
||||
import { transitionWorkOrder } from "@/server/services/work-orders/transition";
|
||||
import { auditReport, contentOf, orderNumberOf, reportAuditView, requireVisibleReport } from "./common";
|
||||
|
||||
export const rejectReportSchema = z.object({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
import { SIGNATURE_OUTCOMES, SIGNATURE_REASON_REQUIRED, type SignatureBlock } from "@/lib/reports/content";
|
||||
import { can, assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
// TODO(merge L2): import from "@/server/services/work-orders/transition"
|
||||
import { transitionWorkOrder } from "./_stubs/work-orders";
|
||||
import { transitionWorkOrder } from "@/server/services/work-orders/transition";
|
||||
import { auditReport, contentOf, requireVisibleReport } from "./common";
|
||||
|
||||
const optText = (max: number) =>
|
||||
|
||||
@@ -5,7 +5,8 @@ import type { CompletionBlocker, WorkOrderStatus } from "@/lib/work-orders/statu
|
||||
import { emitEvent } from "@/server/events";
|
||||
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
// TODO(merge L2): import from "@/server/services/work-orders/transition" and ".../guards"
|
||||
import { getCompletionBlockers, transitionWorkOrder } from "./_stubs/work-orders";
|
||||
import { getCompletionBlockers } from "@/server/services/work-orders/completion";
|
||||
import { transitionWorkOrder } from "@/server/services/work-orders/transition";
|
||||
import { auditReport, assertEditable, orderNumberOf, refreshContent, reportAuditView, requireVisibleReport } from "./common";
|
||||
|
||||
export const submitReportSchema = z.object({
|
||||
|
||||
@@ -12,7 +12,7 @@ import { upsertMaterialUsage } from "@/server/services/field/materials";
|
||||
import { attachPhoto } from "@/server/services/field/photos";
|
||||
import { attachVoiceNote } from "@/server/services/field/voice";
|
||||
// TODO(merge L2): replace with "@/server/services/work-orders/transition"
|
||||
import { transitionWorkOrder } from "@/server/services/field/stubs/work-order-transition";
|
||||
import { transitionWorkOrder } from "@/server/services/work-orders/transition";
|
||||
import { EXTERNAL_OP_OWNERS, EXTERNAL_OPS } from "./external-ops";
|
||||
|
||||
/**
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user