L5 Berichte & Unterschrift: Berichtsinhalt, Services, Unterschrift und PDF
ReportContent-Vertrag, Content-Builder mit Tagesfilter, Services für Tages-/Abschlussbericht, Bearbeiten, Absenden, Freigabe, Zurückweisen, neue Version, Unterschrift und PDF-Erzeugung (playwright-core, Worker-Processor, Dockerfile-Stage worker). Stubs für L2-Transition/Blocker und Dokumenten-Store. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+24
@@ -103,3 +103,27 @@ CMD ["node", "server.js"]
|
|||||||
# GARAGE_RPC_SECRET/GARAGE_ADMIN_TOKEN); nur die secret-freie Basiskonfig wird kopiert.
|
# GARAGE_RPC_SECRET/GARAGE_ADMIN_TOKEN); nur die secret-freie Basiskonfig wird kopiert.
|
||||||
FROM dxflrs/garage:v1.2.0 AS garage
|
FROM dxflrs/garage:v1.2.0 AS garage
|
||||||
COPY deploy/garage.toml /etc/garage.toml
|
COPY deploy/garage.toml /etc/garage.toml
|
||||||
|
|
||||||
|
# --- Worker-Stage (Vorschlag Lane L5 Berichte): Craftvia-Job-Worker inkl. Chromium für PDF ---
|
||||||
|
# ARCHITEKTUR §1: HTML → PDF läuft über playwright-core + Chromium NUR im Worker, nie im App-Container.
|
||||||
|
# Debian-Chromium aus dem Paketspiegel statt Playwright-Download (reproduzierbar, Updates über das Base-Image);
|
||||||
|
# render.ts nutzt PDF_CHROMIUM_PATH. fonts-dejavu/-liberation als Fallback, Inter wird eingebettet (src/app/fonts).
|
||||||
|
# tsx + src/messages/prisma werden wie in der migrate-Stage zur Laufzeit gebraucht (Worker läuft über tsx).
|
||||||
|
FROM node:22.14.0-slim AS worker
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PDF_CHROMIUM_PATH=/usr/bin/chromium
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates chromium fonts-dejavu-core fonts-liberation \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY package.json package-lock.json prisma.config.ts tsconfig.json ./
|
||||||
|
COPY prisma ./prisma
|
||||||
|
COPY scripts ./scripts
|
||||||
|
COPY src ./src
|
||||||
|
COPY messages ./messages
|
||||||
|
ENV DATABASE_URL="postgresql://build:build@localhost:5432/build?schema=public"
|
||||||
|
RUN npx prisma generate
|
||||||
|
RUN groupadd --system --gid 1001 app \
|
||||||
|
&& useradd --system --uid 1001 --gid app --home-dir /app app
|
||||||
|
USER app
|
||||||
|
CMD ["npx", "tsx", "scripts/craftvia-worker.ts"]
|
||||||
|
|||||||
Generated
+15
-2
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "isms-tool",
|
"name": "craftvia",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "isms-tool",
|
"name": "craftvia",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.115.0",
|
"@anthropic-ai/sdk": "^0.115.0",
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"next-intl": "^4.13.1",
|
"next-intl": "^4.13.1",
|
||||||
"nodemailer": "^8.0.11",
|
"nodemailer": "^8.0.11",
|
||||||
"otplib": "^13.4.1",
|
"otplib": "^13.4.1",
|
||||||
|
"playwright-core": "^1.63.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
@@ -11179,6 +11180,18 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pngjs": {
|
"node_modules/pngjs": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"next-intl": "^4.13.1",
|
"next-intl": "^4.13.1",
|
||||||
"nodemailer": "^8.0.11",
|
"nodemailer": "^8.0.11",
|
||||||
"otplib": "^13.4.1",
|
"otplib": "^13.4.1",
|
||||||
|
"playwright-core": "^1.63.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { CompletionBlocker } from "@/lib/work-orders/status";
|
||||||
|
|
||||||
|
/** Result of report server actions (client-safe; "use server" files may only export async functions). */
|
||||||
|
export type ReportActionErrorCode = "generic" | "not_found" | "forbidden" | "conflict" | "invalid" | "blocked";
|
||||||
|
|
||||||
|
export type ReportActionState =
|
||||||
|
| { status: "idle" }
|
||||||
|
| { status: "ok"; reportId?: string; at: number }
|
||||||
|
| { status: "error"; code: ReportActionErrorCode; field?: string; blockers?: CompletionBlocker[]; at: number };
|
||||||
|
|
||||||
|
export const IDLE: ReportActionState = { status: "idle" };
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report content snapshot (ARCHITEKTUR §4.7, Spec §16.2/§17.2). Client-safe.
|
||||||
|
*
|
||||||
|
* Built from the database by src/server/services/reports/build-content.ts when a report is
|
||||||
|
* created, refreshed on submit/approve and frozen once the report is approved. The editable
|
||||||
|
* free-text block (`texts`) is owned by the technician and survives every refresh.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const REPORT_TYPES = ["daily", "completion"] as const;
|
||||||
|
export type ReportType = (typeof REPORT_TYPES)[number];
|
||||||
|
|
||||||
|
export const REPORT_STATUSES = ["draft", "submitted", "team_approved", "approved", "rejected", "superseded"] as const;
|
||||||
|
export type ReportStatus = (typeof REPORT_STATUSES)[number];
|
||||||
|
|
||||||
|
/** Statuses in which the technician may still edit texts / capture a signature. */
|
||||||
|
export const REPORT_EDITABLE: readonly ReportStatus[] = ["draft", "rejected"];
|
||||||
|
/** Statuses waiting for a reviewer ("Zur Prüfung"). */
|
||||||
|
export const REPORT_IN_REVIEW: readonly ReportStatus[] = ["submitted", "team_approved"];
|
||||||
|
|
||||||
|
/** Badge tone per report status — always rendered together with the status text. */
|
||||||
|
export const REPORT_STATUS_TONE: Record<ReportStatus, "mut" | "info" | "warn" | "ok" | "risk"> = {
|
||||||
|
draft: "mut",
|
||||||
|
submitted: "info",
|
||||||
|
team_approved: "info",
|
||||||
|
approved: "ok",
|
||||||
|
rejected: "risk",
|
||||||
|
superseded: "mut",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SIGNATURE_OUTCOMES = ["signed", "customer_absent", "refused", "later", "not_required"] as const;
|
||||||
|
export type SignatureOutcome = (typeof SIGNATURE_OUTCOMES)[number];
|
||||||
|
/** Outcomes that require a written reason (Spec §18.2). */
|
||||||
|
export const SIGNATURE_REASON_REQUIRED: readonly SignatureOutcome[] = ["customer_absent", "refused", "later"];
|
||||||
|
|
||||||
|
export const TIME_ENTRY_TYPES = ["travel", "work", "break", "material_procurement", "return_travel", "interruption"] as const;
|
||||||
|
export const PHOTO_PHASES = ["before", "during", "after"] as const;
|
||||||
|
export const MATERIAL_USAGE_STATUSES = ["fully_used", "partially_used", "not_used", "additional"] as const;
|
||||||
|
|
||||||
|
/** Editable free-text fields (technician / Lotse draft). */
|
||||||
|
export const REPORT_TEXT_FIELDS = ["workPerformed", "deviations", "additionalWork", "problems", "openItems", "nextSteps", "hints"] as const;
|
||||||
|
export type ReportTextField = (typeof REPORT_TEXT_FIELDS)[number];
|
||||||
|
/** Text fields that must not be empty before submit ("Pflichtangaben"). */
|
||||||
|
export const REPORT_REQUIRED_TEXTS: Record<ReportType, readonly ReportTextField[]> = {
|
||||||
|
daily: ["workPerformed"],
|
||||||
|
completion: ["workPerformed"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TEXT_MAX = 10_000;
|
||||||
|
|
||||||
|
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
const isoDateTime = z.string().datetime({ offset: true });
|
||||||
|
const nullableText = z.string().max(2_000).nullable();
|
||||||
|
|
||||||
|
export const addressSchema = z.object({
|
||||||
|
line1: nullableText,
|
||||||
|
line2: nullableText,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const reportTextsSchema = z.object({
|
||||||
|
workPerformed: z.string().max(TEXT_MAX),
|
||||||
|
deviations: z.string().max(TEXT_MAX),
|
||||||
|
additionalWork: z.string().max(TEXT_MAX),
|
||||||
|
problems: z.string().max(TEXT_MAX),
|
||||||
|
openItems: z.string().max(TEXT_MAX),
|
||||||
|
nextSteps: z.string().max(TEXT_MAX),
|
||||||
|
hints: z.string().max(TEXT_MAX),
|
||||||
|
});
|
||||||
|
export type ReportTexts = z.infer<typeof reportTextsSchema>;
|
||||||
|
|
||||||
|
export const materialLineSchema = z.object({
|
||||||
|
usageId: z.string().nullable(),
|
||||||
|
planId: z.string().nullable(),
|
||||||
|
name: z.string(),
|
||||||
|
articleNumber: nullableText,
|
||||||
|
plannedQuantity: z.string().nullable(),
|
||||||
|
actualQuantity: z.string().nullable(),
|
||||||
|
unit: z.string(),
|
||||||
|
status: z.enum(MATERIAL_USAGE_STATUSES).nullable(),
|
||||||
|
/** planned vs. actual differs (quantity or status) */
|
||||||
|
deviation: z.boolean(),
|
||||||
|
deviationReason: nullableText,
|
||||||
|
notes: nullableText,
|
||||||
|
/** false = planned material without any documented usage */
|
||||||
|
documented: z.boolean(),
|
||||||
|
});
|
||||||
|
export type MaterialLine = z.infer<typeof materialLineSchema>;
|
||||||
|
|
||||||
|
export const photoLineSchema = z.object({
|
||||||
|
photoId: z.string(),
|
||||||
|
documentId: z.string(),
|
||||||
|
phase: z.enum(PHOTO_PHASES).nullable(),
|
||||||
|
comment: nullableText,
|
||||||
|
requirement: nullableText,
|
||||||
|
takenAt: isoDateTime,
|
||||||
|
});
|
||||||
|
export type PhotoLine = z.infer<typeof photoLineSchema>;
|
||||||
|
|
||||||
|
export const timeLineSchema = z.object({
|
||||||
|
userId: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
type: z.enum(TIME_ENTRY_TYPES),
|
||||||
|
minutes: z.number().int().nonnegative(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const signatureBlockSchema = z.object({
|
||||||
|
outcome: z.enum(SIGNATURE_OUTCOMES),
|
||||||
|
signerName: nullableText,
|
||||||
|
signerRole: nullableText,
|
||||||
|
signedAt: isoDateTime,
|
||||||
|
reason: nullableText,
|
||||||
|
confirmationText: nullableText,
|
||||||
|
imageDocumentId: z.string().nullable(),
|
||||||
|
capturedByName: nullableText,
|
||||||
|
});
|
||||||
|
export type SignatureBlock = z.infer<typeof signatureBlockSchema>;
|
||||||
|
|
||||||
|
export const reportContentSchema = z.object({
|
||||||
|
schemaVersion: z.literal(1),
|
||||||
|
type: z.enum(REPORT_TYPES),
|
||||||
|
reportNumber: z.string(),
|
||||||
|
version: z.number().int().positive(),
|
||||||
|
reportDate: isoDate,
|
||||||
|
generatedAt: isoDateTime,
|
||||||
|
tenant: z.object({
|
||||||
|
name: z.string(),
|
||||||
|
address: nullableText,
|
||||||
|
phone: nullableText,
|
||||||
|
email: nullableText,
|
||||||
|
logoDocumentId: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
customer: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
number: nullableText,
|
||||||
|
name: z.string(),
|
||||||
|
address: addressSchema,
|
||||||
|
}),
|
||||||
|
site: z.object({ id: z.string(), name: z.string(), address: addressSchema }).nullable(),
|
||||||
|
contact: z.object({ name: z.string(), role: nullableText, phone: nullableText, email: nullableText }).nullable(),
|
||||||
|
workOrder: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
number: z.string(),
|
||||||
|
externalOrderNumber: nullableText,
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string().nullable(),
|
||||||
|
scope: z.string().nullable(),
|
||||||
|
orderType: nullableText,
|
||||||
|
signatureRequired: z.boolean(),
|
||||||
|
}),
|
||||||
|
/** Dates (YYYY-MM-DD, tenant time zone) with documented work; daily report = [reportDate]. */
|
||||||
|
workDates: z.array(isoDate),
|
||||||
|
staff: z.array(z.object({ userId: z.string(), name: z.string() })),
|
||||||
|
time: z.object({
|
||||||
|
entries: z.array(timeLineSchema),
|
||||||
|
totalsByType: z.record(z.string(), z.number().int().nonnegative()),
|
||||||
|
totalsByPerson: z.array(z.object({ userId: z.string(), name: z.string(), minutes: z.number().int().nonnegative() })),
|
||||||
|
/** billable total = all types except break */
|
||||||
|
totalMinutes: z.number().int().nonnegative(),
|
||||||
|
/** true if an entry was still running while the snapshot was built */
|
||||||
|
hasRunningEntries: z.boolean(),
|
||||||
|
}),
|
||||||
|
texts: reportTextsSchema,
|
||||||
|
materials: z.object({
|
||||||
|
used: z.array(materialLineSchema),
|
||||||
|
notUsed: z.array(materialLineSchema),
|
||||||
|
additional: z.array(materialLineSchema),
|
||||||
|
}),
|
||||||
|
photos: z.array(photoLineSchema),
|
||||||
|
checklist: z.array(z.object({ label: z.string(), required: z.boolean(), checked: z.boolean(), comment: nullableText })),
|
||||||
|
signature: signatureBlockSchema.nullable(),
|
||||||
|
technician: z.object({ userId: z.string(), name: z.string() }).nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ReportContent = z.infer<typeof reportContentSchema>;
|
||||||
|
|
||||||
|
export function emptyTexts(): ReportTexts {
|
||||||
|
return { workPerformed: "", deviations: "", additionalWork: "", problems: "", openItems: "", nextSteps: "", hints: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse stored JSON; throws on schema drift so broken snapshots never render silently. */
|
||||||
|
export function parseReportContent(json: unknown): ReportContent {
|
||||||
|
return reportContentSchema.parse(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Missing required text fields for submit. */
|
||||||
|
export function missingRequiredTexts(content: Pick<ReportContent, "type" | "texts">): ReportTextField[] {
|
||||||
|
return REPORT_REQUIRED_TEXTS[content.type].filter((f) => !content.texts[f].trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "7 h 05 min" style duration without locale dependency (labels come from messages). */
|
||||||
|
export function splitMinutes(minutes: number): { hours: number; minutes: number } {
|
||||||
|
return { hours: Math.floor(minutes / 60), minutes: minutes % 60 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Calendar-day helpers in the tenant time zone (client-safe, no dependencies).
|
||||||
|
* A daily report covers [start of reportDate, start of next day) in `timeZone`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Offset (ms) of `timeZone` relative to UTC at the given instant. */
|
||||||
|
function tzOffsetMs(instant: Date, timeZone: string): number {
|
||||||
|
const parts = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
hourCycle: "h23",
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
}).formatToParts(instant);
|
||||||
|
const get = (t: string) => Number(parts.find((p) => p.type === t)?.value);
|
||||||
|
const asUtc = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour"), get("minute"), get("second"));
|
||||||
|
return asUtc - Math.floor(instant.getTime() / 1000) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** YYYY-MM-DD of an instant in `timeZone`. */
|
||||||
|
export function localDateKey(instant: Date, timeZone: string): string {
|
||||||
|
return new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).format(instant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UTC instants [start, end) of a local calendar day. */
|
||||||
|
export function dayWindow(dateKey: string, timeZone: string): { start: Date; end: Date } {
|
||||||
|
const [y, m, d] = dateKey.split("-").map(Number);
|
||||||
|
const startGuess = Date.UTC(y, m - 1, d);
|
||||||
|
const endGuess = Date.UTC(y, m - 1, d + 1);
|
||||||
|
const start = new Date(startGuess - tzOffsetMs(new Date(startGuess), timeZone));
|
||||||
|
const end = new Date(endGuess - tzOffsetMs(new Date(endGuess), timeZone));
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Date-only column value (Prisma @db.Date) for a YYYY-MM-DD key. */
|
||||||
|
export function dateKeyToDbDate(dateKey: string): Date {
|
||||||
|
return new Date(`${dateKey}T00:00:00.000Z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** YYYY-MM-DD from a Prisma @db.Date value. */
|
||||||
|
export function dbDateToKey(d: Date): string {
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ export type JobProcessor = (payload: JobPayload) => Promise<void>;
|
|||||||
export const PROCESSORS: Partial<Record<JobQueueName, () => Promise<JobProcessor>>> = {
|
export const PROCESSORS: Partial<Record<JobQueueName, () => Promise<JobProcessor>>> = {
|
||||||
// lane-imports: "import-extraction": () => import("./import-extraction").then((m) => m.process),
|
// lane-imports: "import-extraction": () => import("./import-extraction").then((m) => m.process),
|
||||||
// lane-lotse: "transcription": () => import("./transcription").then((m) => m.process),
|
// lane-lotse: "transcription": () => import("./transcription").then((m) => m.process),
|
||||||
// lane-reports: "report-pdf": () => import("./report-pdf").then((m) => m.process),
|
"report-pdf": () => import(/* turbopackIgnore: true */ "./report-pdf").then((m) => m.process), // worker-only (react-dom/server + Chromium), kept out of the app bundle
|
||||||
// lane-field: "image-derivatives": () => import("./image-derivatives").then((m) => m.process),
|
// lane-field: "image-derivatives": () => import("./image-derivatives").then((m) => m.process),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { dbForTenant } from "@/server/db";
|
||||||
|
import type { ServiceCtx } from "@/server/services/context";
|
||||||
|
import { generateReportPdf } from "@/server/services/reports/pdf";
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
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"]),
|
||||||
|
};
|
||||||
|
const res = await generateReportPdf(ctx, payload.entityId);
|
||||||
|
console.info(`[report-pdf] ${payload.entityId}: ${res.skipped ? "already rendered" : `stored ${res.documentId}`}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML → PDF via playwright-core + Chromium (ARCHITEKTUR §1: runs in the worker, never in the app container).
|
||||||
|
*
|
||||||
|
* Browser resolution (first match wins):
|
||||||
|
* 1. PDF_CHROMIUM_PATH — explicit executable (Docker worker image: /usr/bin/chromium)
|
||||||
|
* 2. Playwright-managed Chromium (`npx playwright-core install chromium`)
|
||||||
|
* 3. Locally installed Google Chrome (developer machines, channel "chrome")
|
||||||
|
* Throws PdfRendererUnavailableError if none can be launched.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class PdfRendererUnavailableError extends Error {
|
||||||
|
constructor(cause: string) {
|
||||||
|
super(`PDF renderer unavailable: ${cause}`);
|
||||||
|
this.name = "PdfRendererUnavailableError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RenderPdfOptions = {
|
||||||
|
headerHtml?: string;
|
||||||
|
footerHtml?: string;
|
||||||
|
/** mm margins */
|
||||||
|
margin?: { top: string; bottom: string; left: string; right: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type Browser = import("playwright-core").Browser;
|
||||||
|
|
||||||
|
async function launch(): Promise<Browser> {
|
||||||
|
const { chromium } = await import("playwright-core");
|
||||||
|
const errors: string[] = [];
|
||||||
|
const explicit = process.env.PDF_CHROMIUM_PATH?.trim();
|
||||||
|
const attempts: Array<() => Promise<Browser>> = [];
|
||||||
|
if (explicit) attempts.push(() => chromium.launch({ executablePath: explicit, args: ["--no-sandbox", "--disable-dev-shm-usage"] }));
|
||||||
|
attempts.push(async () => {
|
||||||
|
const path = chromium.executablePath();
|
||||||
|
if (!path || !existsSync(path)) throw new Error("playwright chromium not installed");
|
||||||
|
return chromium.launch({ args: ["--disable-dev-shm-usage"] });
|
||||||
|
});
|
||||||
|
attempts.push(() => chromium.launch({ channel: "chrome" }));
|
||||||
|
for (const attempt of attempts) {
|
||||||
|
try {
|
||||||
|
return await attempt();
|
||||||
|
} catch (err) {
|
||||||
|
errors.push((err as Error).message.split("\n")[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new PdfRendererUnavailableError(errors.join(" | "));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a full HTML document to an A4 PDF (print backgrounds, header/footer with page numbers). */
|
||||||
|
export async function renderHtmlToPdf(html: string, opts: RenderPdfOptions = {}): Promise<Buffer> {
|
||||||
|
const browser = await launch();
|
||||||
|
try {
|
||||||
|
const context = await browser.newContext({ javaScriptEnabled: false });
|
||||||
|
const page = await context.newPage();
|
||||||
|
// No network: all assets (photos, logo, signature, fonts) are inlined as data: URIs.
|
||||||
|
await page.route("**/*", (route) => (route.request().url().startsWith("data:") ? route.continue() : route.abort()));
|
||||||
|
await page.setContent(html, { waitUntil: "load" });
|
||||||
|
const pdf = await page.pdf({
|
||||||
|
format: "A4",
|
||||||
|
printBackground: true,
|
||||||
|
displayHeaderFooter: Boolean(opts.headerHtml || opts.footerHtml),
|
||||||
|
headerTemplate: opts.headerHtml ?? "<span></span>",
|
||||||
|
footerTemplate: opts.footerHtml ?? "<span></span>",
|
||||||
|
margin: opts.margin ?? { top: "22mm", bottom: "20mm", left: "16mm", right: "16mm" },
|
||||||
|
preferCSSPageSize: false,
|
||||||
|
});
|
||||||
|
await context.close();
|
||||||
|
return pdf;
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** true if a browser can be launched (tests skip the render smoke otherwise). */
|
||||||
|
export async function pdfRendererAvailable(): Promise<{ ok: true } | { ok: false; reason: string }> {
|
||||||
|
try {
|
||||||
|
const b = await launch();
|
||||||
|
await b.close();
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, reason: (err as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
/* eslint-disable @next/next/no-head-element, @next/next/no-img-element -- standalone print document for Chromium, not a Next.js page */
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { REPORT_TEXT_FIELDS, splitMinutes, type MaterialLine, type ReportContent } from "@/lib/reports/content";
|
||||||
|
import { DOCUMENT_THEME, documentFooterLine } from "@/lib/document-brand";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report PDF template (React SSR → static HTML, rendered by src/server/pdf/render.ts).
|
||||||
|
* Craftvia document CD from src/lib/document-brand.ts; tenant logo if available, else company name.
|
||||||
|
* All labels come from messages/<locale>/reports.json (passed in as `t`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Translate = (key: string, values?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
export type ReportPdfInput = {
|
||||||
|
content: ReportContent;
|
||||||
|
reportId: string;
|
||||||
|
status: string;
|
||||||
|
approvedAt: Date | null;
|
||||||
|
t: Translate;
|
||||||
|
locale: string;
|
||||||
|
timeZone: string;
|
||||||
|
/** documentId → data: URI (photos, signature image, logo) */
|
||||||
|
images: Record<string, string>;
|
||||||
|
logoDataUri?: string | null;
|
||||||
|
/** SHA-256 of the canonical content snapshot (the PDF's own checksum is stored on the report) */
|
||||||
|
contentChecksum: string;
|
||||||
|
fontDataUri?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const esc = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
|
||||||
|
function css(fontDataUri?: string | null) {
|
||||||
|
const th = DOCUMENT_THEME;
|
||||||
|
return `
|
||||||
|
${fontDataUri ? `@font-face{font-family:"CraftviaInter";src:url(${fontDataUri}) format("truetype");font-weight:100 900;}` : ""}
|
||||||
|
@page{size:A4;}
|
||||||
|
*{box-sizing:border-box;}
|
||||||
|
html,body{margin:0;padding:0;}
|
||||||
|
body{font-family:${fontDataUri ? `"CraftviaInter",` : ""}${th.bodyFont};color:${th.text};font-size:9.5pt;line-height:1.45;background:${th.pageBackground};}
|
||||||
|
h1{font-family:${fontDataUri ? `"CraftviaInter",` : ""}${th.headingFont};color:${th.accent};font-size:17pt;margin:0 0 2mm;}
|
||||||
|
h2{font-family:${fontDataUri ? `"CraftviaInter",` : ""}${th.headingFont};color:${th.accent};font-size:11pt;margin:6mm 0 2mm;padding-bottom:1mm;border-bottom:0.6pt solid ${th.rule};break-after:avoid;}
|
||||||
|
h3{font-size:9.5pt;margin:3mm 0 1mm;color:${th.text};break-after:avoid;}
|
||||||
|
.muted{color:${th.textMuted};}
|
||||||
|
.head{display:flex;justify-content:space-between;align-items:flex-start;gap:8mm;border-bottom:2pt solid ${th.accentStrong};padding-bottom:3mm;margin-bottom:4mm;}
|
||||||
|
.logo{max-height:16mm;max-width:60mm;}
|
||||||
|
.org{font-weight:700;color:${th.accent};font-size:12pt;}
|
||||||
|
.meta{text-align:right;font-size:8.5pt;}
|
||||||
|
.grid{display:grid;grid-template-columns:1fr 1fr;gap:1.5mm 8mm;}
|
||||||
|
.kv dt{font-size:7.5pt;text-transform:uppercase;letter-spacing:.04em;color:${th.textMuted};margin:0;}
|
||||||
|
.kv dd{margin:0 0 1.5mm;}
|
||||||
|
table{width:100%;border-collapse:collapse;margin:1mm 0 2mm;}
|
||||||
|
th{background:${th.tableHeaderBackground};text-align:left;font-size:8pt;padding:1.2mm 1.5mm;border-bottom:0.6pt solid ${th.rule};}
|
||||||
|
td{padding:1.2mm 1.5mm;border-bottom:0.4pt solid ${th.rule};vertical-align:top;}
|
||||||
|
tr{break-inside:avoid;}
|
||||||
|
td.num,th.num{text-align:right;white-space:nowrap;}
|
||||||
|
.text{white-space:pre-wrap;break-inside:avoid-page;}
|
||||||
|
.photos{display:grid;grid-template-columns:1fr 1fr;gap:4mm;}
|
||||||
|
.photo{break-inside:avoid;border:0.4pt solid ${th.rule};padding:1.5mm;}
|
||||||
|
.photo img{width:100%;height:62mm;object-fit:contain;background:${th.tableHeaderBackground};display:block;}
|
||||||
|
.photo .cap{font-size:8pt;margin-top:1mm;}
|
||||||
|
.sig{break-inside:avoid;border:0.6pt solid ${th.rule};padding:3mm;}
|
||||||
|
.sig img{max-height:30mm;max-width:90mm;display:block;margin:2mm 0;}
|
||||||
|
.badge{display:inline-block;border:0.6pt solid ${th.accent};color:${th.accent};border-radius:2mm;padding:.3mm 2mm;font-size:8pt;font-weight:700;}
|
||||||
|
.dev{color:${th.accentStrong};font-weight:700;}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Kv({ label, value }: { label: string; value?: string | null }) {
|
||||||
|
if (!value) return null;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt>{label}</dt>
|
||||||
|
<dd>{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDuration(t: Translate, minutes: number) {
|
||||||
|
const s = splitMinutes(minutes);
|
||||||
|
return t("time.hoursMinutes", { hours: s.hours, minutes: String(s.minutes).padStart(2, "0") });
|
||||||
|
}
|
||||||
|
|
||||||
|
function MaterialTable({ t, lines }: { t: Translate; lines: MaterialLine[] }) {
|
||||||
|
return (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t("materials.name")}</th>
|
||||||
|
<th className="num">{t("materials.planned")}</th>
|
||||||
|
<th className="num">{t("materials.actual")}</th>
|
||||||
|
<th>{t("field.status")}</th>
|
||||||
|
<th>{t("materials.reason")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{lines.map((m, i) => (
|
||||||
|
<tr key={`${m.usageId ?? m.planId ?? i}`}>
|
||||||
|
<td>
|
||||||
|
{m.name}
|
||||||
|
{m.articleNumber ? <span className="muted"> · {m.articleNumber}</span> : null}
|
||||||
|
</td>
|
||||||
|
<td className="num">{m.plannedQuantity ? `${m.plannedQuantity} ${m.unit}` : "—"}</td>
|
||||||
|
<td className="num">{m.actualQuantity ? `${m.actualQuantity} ${m.unit}` : "—"}</td>
|
||||||
|
<td>
|
||||||
|
{m.status ? t(`materialStatus.${m.status}`) : t("materials.undocumented")}
|
||||||
|
{m.deviation ? <span className="dev"> · {t("materials.deviation")}</span> : null}
|
||||||
|
</td>
|
||||||
|
<td>{m.deviationReason ?? ""}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportDocument(input: ReportPdfInput) {
|
||||||
|
const { content: c, t } = input;
|
||||||
|
const dateFmt = new Intl.DateTimeFormat(input.locale, { timeZone: input.timeZone, dateStyle: "medium" });
|
||||||
|
const dateTimeFmt = new Intl.DateTimeFormat(input.locale, { timeZone: input.timeZone, dateStyle: "medium", timeStyle: "short" });
|
||||||
|
const dayFmt = (key: string) => new Intl.DateTimeFormat(input.locale, { timeZone: "UTC", dateStyle: "medium" }).format(new Date(`${key}T00:00:00Z`));
|
||||||
|
const addr = (a: { line1: string | null; line2: string | null }) => [a.line1, a.line2].filter(Boolean).join(", ");
|
||||||
|
const texts = REPORT_TEXT_FIELDS.filter((f) => c.texts[f].trim());
|
||||||
|
const hasMaterial = c.materials.used.length + c.materials.notUsed.length + c.materials.additional.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang={input.locale}>
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<title>{`${t(`type.${c.type}`)} ${c.reportNumber}`}</title>
|
||||||
|
<style dangerouslySetInnerHTML={{ __html: css(input.fontDataUri) }} />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div className="head">
|
||||||
|
<div>
|
||||||
|
{input.logoDataUri ? <img className="logo" src={input.logoDataUri} alt={c.tenant.name} /> : <div className="org">{c.tenant.name}</div>}
|
||||||
|
<div className="muted">{[c.tenant.address, c.tenant.phone, c.tenant.email].filter(Boolean).join(" · ")}</div>
|
||||||
|
</div>
|
||||||
|
<div className="meta">
|
||||||
|
<h1>{t(`type.${c.type}`)}</h1>
|
||||||
|
<div>
|
||||||
|
{t("field.reportNumber")}: <strong>{c.reportNumber}</strong> · {t("field.version")} {c.version}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t("field.reportDate")}: {dayFmt(c.reportDate)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t("pdf.approvalStatus")}:{" "}
|
||||||
|
<span className="badge">
|
||||||
|
{t(`status.${input.status}`)}
|
||||||
|
{input.approvedAt ? ` · ${dateFmt.format(input.approvedAt)}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>{t("section.header")}</h2>
|
||||||
|
<dl className="kv grid">
|
||||||
|
<Kv label={t("field.customer")} value={[c.customer.name, addr(c.customer.address)].filter(Boolean).join(", ")} />
|
||||||
|
<Kv label={t("field.customerNumber")} value={c.customer.number} />
|
||||||
|
<Kv label={t("field.site")} value={c.site ? [c.site.name, addr(c.site.address)].filter(Boolean).join(", ") : null} />
|
||||||
|
<Kv label={t("field.contact")} value={c.contact ? [c.contact.name, c.contact.role, c.contact.phone, c.contact.email].filter(Boolean).join(" · ") : null} />
|
||||||
|
<Kv label={t("field.orderNumber")} value={[c.workOrder.number, c.workOrder.externalOrderNumber].filter(Boolean).join(" / ")} />
|
||||||
|
<Kv label={t("field.orderType")} value={c.workOrder.orderType} />
|
||||||
|
<Kv label={t("field.workOrder")} value={c.workOrder.title} />
|
||||||
|
<Kv label={t("field.workDates")} value={c.workDates.map(dayFmt).join(", ")} />
|
||||||
|
<Kv label={t("field.staff")} value={c.staff.map((s) => s.name).join(", ")} />
|
||||||
|
<Kv label={t("field.technician")} value={c.technician?.name} />
|
||||||
|
</dl>
|
||||||
|
{c.workOrder.description ? (
|
||||||
|
<>
|
||||||
|
<h3>{t("field.description")}</h3>
|
||||||
|
<div className="text">{c.workOrder.description}</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<h2>{t("section.time")}</h2>
|
||||||
|
{c.time.entries.length === 0 ? (
|
||||||
|
<p className="muted">{t("time.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t("time.person")}</th>
|
||||||
|
<th>{t("time.type")}</th>
|
||||||
|
<th className="num">{t("time.duration")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{c.time.entries.map((e) => (
|
||||||
|
<tr key={`${e.userId}-${e.type}`}>
|
||||||
|
<td>{e.name}</td>
|
||||||
|
<td>{t(`timeType.${e.type}`)}</td>
|
||||||
|
<td className="num">{fmtDuration(t, e.minutes)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{Object.entries(c.time.totalsByType).map(([type, minutes]) => (
|
||||||
|
<tr key={`sum-${type}`}>
|
||||||
|
<td className="muted">{t("time.totalByType")}</td>
|
||||||
|
<td>{t(`timeType.${type}`)}</td>
|
||||||
|
<td className="num">{fmtDuration(t, minutes)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr>
|
||||||
|
<td colSpan={2}>
|
||||||
|
<strong>{t("time.total")}</strong>
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
<strong>{fmtDuration(t, c.time.totalMinutes)}</strong>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
{c.time.hasRunningEntries ? <p className="muted">{t("time.running", { time: dateTimeFmt.format(new Date(c.generatedAt)) })}</p> : null}
|
||||||
|
|
||||||
|
{texts.length ? <h2>{t("section.texts")}</h2> : null}
|
||||||
|
{texts.map((f) => (
|
||||||
|
<div key={f}>
|
||||||
|
<h3>{t(`texts.${f}`)}</h3>
|
||||||
|
<div className="text">{c.texts[f]}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<h2>{t("section.materials")}</h2>
|
||||||
|
{!hasMaterial ? <p className="muted">{t("materials.empty")}</p> : null}
|
||||||
|
{c.materials.used.length ? (
|
||||||
|
<>
|
||||||
|
<h3>{t("materials.used")}</h3>
|
||||||
|
<MaterialTable t={t} lines={c.materials.used} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{c.materials.notUsed.length ? (
|
||||||
|
<>
|
||||||
|
<h3>{t("materials.notUsed")}</h3>
|
||||||
|
<MaterialTable t={t} lines={c.materials.notUsed} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{c.materials.additional.length ? (
|
||||||
|
<>
|
||||||
|
<h3>{t("materials.additional")}</h3>
|
||||||
|
<MaterialTable t={t} lines={c.materials.additional} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{c.checklist.length ? (
|
||||||
|
<>
|
||||||
|
<h2>{t("section.checklist")}</h2>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{c.checklist.map((i, idx) => (
|
||||||
|
<tr key={idx}>
|
||||||
|
<td>
|
||||||
|
{i.label}
|
||||||
|
{i.required ? <span className="muted"> · {t("checklist.required")}</span> : null}
|
||||||
|
{i.comment ? <div className="muted">{i.comment}</div> : null}
|
||||||
|
</td>
|
||||||
|
<td className="num">{i.checked ? `✓ ${t("checklist.done")}` : `○ ${t("checklist.open")}`}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<h2>{t("section.photos")}</h2>
|
||||||
|
{c.photos.length === 0 ? (
|
||||||
|
<p className="muted">{t("photos.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="photos">
|
||||||
|
{c.photos.map((p, idx) => (
|
||||||
|
<div className="photo" key={p.photoId}>
|
||||||
|
{input.images[p.documentId] ? <img src={input.images[p.documentId]} alt={t("photos.alt", { index: idx + 1 })} /> : null}
|
||||||
|
<div className="cap">
|
||||||
|
<strong>{idx + 1}.</strong> {p.phase ? t(`phase.${p.phase}`) : ""}
|
||||||
|
{p.requirement ? ` · ${t("photos.requirement", { label: p.requirement })}` : ""} · {dateTimeFmt.format(new Date(p.takenAt))}
|
||||||
|
{p.comment ? <div>{p.comment}</div> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h2>{t("section.signature")}</h2>
|
||||||
|
{!c.signature ? (
|
||||||
|
<p className="muted">{t("signature.none")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="sig">
|
||||||
|
<div>
|
||||||
|
<strong>{t(`outcome.${c.signature.outcome}`)}</strong>
|
||||||
|
</div>
|
||||||
|
{c.signature.imageDocumentId && input.images[c.signature.imageDocumentId] ? (
|
||||||
|
<img src={input.images[c.signature.imageDocumentId]} alt={t("signature.image", { name: c.signature.signerName ?? "" })} />
|
||||||
|
) : null}
|
||||||
|
<dl className="kv grid">
|
||||||
|
<Kv label={t("signature.signer")} value={c.signature.signerName} />
|
||||||
|
<Kv label={t("signature.role")} value={c.signature.signerRole} />
|
||||||
|
<Kv label={t("signature.signedAt")} value={dateTimeFmt.format(new Date(c.signature.signedAt))} />
|
||||||
|
<Kv label={t("signature.capturedBy")} value={c.signature.capturedByName} />
|
||||||
|
<Kv label={t("signature.reason")} value={c.signature.reason} />
|
||||||
|
</dl>
|
||||||
|
{c.signature.confirmationText ? <div className="text muted">{c.signature.confirmationText}</div> : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the document HTML plus Chromium header/footer templates (page numbers, report id, version, checksum). */
|
||||||
|
export function renderReportHtml(input: ReportPdfInput): { html: string; headerHtml: string; footerHtml: string } {
|
||||||
|
const { content: c, t } = input;
|
||||||
|
const html = "<!doctype html>" + renderToStaticMarkup(<ReportDocument {...input} />);
|
||||||
|
const small = `font-family:${DOCUMENT_THEME.bodyFont};font-size:7pt;color:${DOCUMENT_THEME.textMuted};width:100%;padding:0 16mm;display:flex;justify-content:space-between;gap:6mm;`;
|
||||||
|
const headerHtml = `<div style="${small}"><span>${esc(c.tenant.name)}</span><span>${esc(t(`type.${c.type}`))} ${esc(c.reportNumber)} · ${esc(t("field.version"))} ${c.version}</span></div>`;
|
||||||
|
const page = esc(t("pdf.page", { page: "__P__", pages: "__N__" }))
|
||||||
|
.replace("__P__", '<span class="pageNumber"></span>')
|
||||||
|
.replace("__N__", '<span class="totalPages"></span>');
|
||||||
|
const footerHtml =
|
||||||
|
`<div style="${small}"><span>${esc(t("pdf.reportId"))}: ${esc(input.reportId)} · ${esc(t("field.version"))} ${c.version} · ` +
|
||||||
|
`${esc(t("pdf.checksum"))}: ${esc(input.contentChecksum)}<br/>${esc(documentFooterLine())}</span><span style="white-space:nowrap">${page}</span></div>`;
|
||||||
|
return { html, headerHtml, footerHtml };
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* 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 })),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { Report } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { emitEvent } from "@/server/events";
|
||||||
|
import { can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { auditReport, contentOf, orderNumberOf, refreshContent, reportAuditView, requireVisibleReport } from "./common";
|
||||||
|
|
||||||
|
export const approveReportSchema = z.object({ reportId: z.string().min(1).max(64) });
|
||||||
|
export type ApproveReportInput = z.input<typeof approveReportSchema>;
|
||||||
|
|
||||||
|
export type ApproveDeps = {
|
||||||
|
/** queue PDF rendering (worker); injectable for tests */
|
||||||
|
dispatchPdf: (ctx: ServiceCtx, reportId: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultApproveDeps: ApproveDeps = {
|
||||||
|
async dispatchPdf(ctx, reportId) {
|
||||||
|
try {
|
||||||
|
const [{ dispatchJob }, { JOB_QUEUES }] = await Promise.all([import("@/server/jobs/dispatch"), import("@/server/jobs/queues")]);
|
||||||
|
await dispatchJob(JOB_QUEUES.reportPdf, { tenantId: ctx.tenantId, entityId: reportId, actorId: ctx.userId });
|
||||||
|
} catch (err) {
|
||||||
|
// Approval stays valid; the PDF can be regenerated from the report detail page.
|
||||||
|
console.error(`[reports] pdf job for ${reportId} failed:`, (err as Error).message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freigabe (ARCHITEKTUR §2):
|
||||||
|
* - report:approve → approved (from submitted/team_approved): content frozen, older approved versions superseded, PDF job, event
|
||||||
|
* - report:approve_team → team_approved (from submitted); the report now waits for backoffice → report.submitted (approvalStage "backoffice")
|
||||||
|
*/
|
||||||
|
export async function approveReport(ctx: ServiceCtx, raw: ApproveReportInput, deps: ApproveDeps = defaultApproveDeps): Promise<Report> {
|
||||||
|
const input = approveReportSchema.parse(raw);
|
||||||
|
const final = can(ctx, "report:approve");
|
||||||
|
if (!final && !can(ctx, "report:approve_team")) throw new ServiceError("forbidden", "missing permission report:approve");
|
||||||
|
const report = await requireVisibleReport(ctx, input.reportId);
|
||||||
|
|
||||||
|
if (!final) {
|
||||||
|
if (report.status !== "submitted") throw new ServiceError("conflict", `report is ${report.status}`);
|
||||||
|
const teamApprovedAt = new Date();
|
||||||
|
const res = await ctx.db.report.updateMany({
|
||||||
|
where: { id: report.id, status: "submitted" },
|
||||||
|
data: { status: "team_approved", teamApprovedById: ctx.userId, teamApprovedAt },
|
||||||
|
});
|
||||||
|
if (res.count !== 1) throw new ServiceError("conflict", "report changed concurrently");
|
||||||
|
const updated = await ctx.db.report.findFirstOrThrow({ where: { id: report.id } });
|
||||||
|
await auditReport(ctx, "update", report.id, reportAuditView(report), reportAuditView(updated));
|
||||||
|
await emitEvent(ctx, {
|
||||||
|
type: "report.submitted",
|
||||||
|
entityType: "report",
|
||||||
|
entityId: report.id,
|
||||||
|
data: {
|
||||||
|
reportType: report.type,
|
||||||
|
number: contentOf(updated).reportNumber,
|
||||||
|
workOrderNumber: await orderNumberOf(ctx, report.workOrderId),
|
||||||
|
version: report.version,
|
||||||
|
approvalStage: "backoffice",
|
||||||
|
occurrenceId: `${report.id}:team_approved:${teamApprovedAt.getTime()}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (report.status !== "submitted" && report.status !== "team_approved") throw new ServiceError("conflict", `report is ${report.status}`);
|
||||||
|
const content = await refreshContent(ctx, report);
|
||||||
|
const res = await ctx.db.report.updateMany({
|
||||||
|
where: { id: report.id, status: { in: ["submitted", "team_approved"] } },
|
||||||
|
data: { status: "approved", approvedById: ctx.userId, approvedAt: new Date(), content },
|
||||||
|
});
|
||||||
|
if (res.count !== 1) throw new ServiceError("conflict", "report changed concurrently");
|
||||||
|
const superseded = await ctx.db.report.findMany({
|
||||||
|
where: { lineageId: report.lineageId, id: { not: report.id }, status: "approved", version: { lt: report.version } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (superseded.length) {
|
||||||
|
await ctx.db.report.updateMany({ where: { id: { in: superseded.map((s) => s.id) } }, data: { status: "superseded" } });
|
||||||
|
for (const s of superseded) await auditReport(ctx, "update", s.id, { status: "approved" }, { status: "superseded", supersededBy: report.id });
|
||||||
|
}
|
||||||
|
const updated = await ctx.db.report.findFirstOrThrow({ where: { id: report.id } });
|
||||||
|
await auditReport(ctx, "update", report.id, reportAuditView(report), reportAuditView(updated));
|
||||||
|
await emitEvent(ctx, {
|
||||||
|
type: "report.approved",
|
||||||
|
entityType: "report",
|
||||||
|
entityId: report.id,
|
||||||
|
data: {
|
||||||
|
reportType: report.type,
|
||||||
|
number: contentOf(updated).reportNumber,
|
||||||
|
workOrderNumber: await orderNumberOf(ctx, report.workOrderId),
|
||||||
|
version: report.version,
|
||||||
|
occurrenceId: report.id, // approval happens once per report version
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await deps.dispatchPdf(ctx, report.id);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import {
|
||||||
|
emptyTexts,
|
||||||
|
type MaterialLine,
|
||||||
|
type ReportContent,
|
||||||
|
type ReportTexts,
|
||||||
|
type ReportType,
|
||||||
|
type SignatureBlock,
|
||||||
|
} from "@/lib/reports/content";
|
||||||
|
import { dayWindow, localDateKey } from "@/lib/reports/dates";
|
||||||
|
import type { ServiceCtx } from "@/server/services/context";
|
||||||
|
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
|
||||||
|
|
||||||
|
export type BuildContentInput = {
|
||||||
|
workOrderId: string;
|
||||||
|
type: ReportType;
|
||||||
|
/** YYYY-MM-DD in tenant time zone */
|
||||||
|
reportDate: string;
|
||||||
|
reportNumber: string;
|
||||||
|
version: number;
|
||||||
|
/** keep edited texts; when omitted texts are prefilled from activity notes */
|
||||||
|
texts?: ReportTexts;
|
||||||
|
/** id of the report whose Signature row feeds the signature block */
|
||||||
|
reportId?: string | null;
|
||||||
|
/** fallback when the report has no Signature row (e.g. copied into a new version) */
|
||||||
|
previousSignature?: SignatureBlock | null;
|
||||||
|
technicianUserId: string | null;
|
||||||
|
now?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function tenantTimeZone(ctx: ServiceCtx): Promise<string> {
|
||||||
|
const s = await ctx.db.tenantSettings.findFirst({ select: { timezone: true } });
|
||||||
|
return s?.timezone || "Europe/Berlin";
|
||||||
|
}
|
||||||
|
|
||||||
|
const joinLines = (...parts: Array<string | null | undefined>) => parts.filter((p) => p && p.trim()).join("\n");
|
||||||
|
const dec = (v: Prisma.Decimal | null | undefined) => (v == null ? null : v.toString());
|
||||||
|
|
||||||
|
function address(street?: string | null, houseNumber?: string | null, postalCode?: string | null, city?: string | null) {
|
||||||
|
const line1 = [street, houseNumber].filter(Boolean).join(" ") || null;
|
||||||
|
const line2 = [postalCode, city].filter(Boolean).join(" ") || null;
|
||||||
|
return { line1, line2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the report snapshot from the database (ARCHITEKTUR §4.7).
|
||||||
|
* Daily report: only time entries, notes, photos and material usages of `reportDate`.
|
||||||
|
* Completion report: the whole work order.
|
||||||
|
* Access: the work order must be visible to the caller (workOrderScope).
|
||||||
|
*/
|
||||||
|
export async function buildReportContent(ctx: ServiceCtx, input: BuildContentInput): Promise<ReportContent> {
|
||||||
|
const now = input.now ?? new Date();
|
||||||
|
await requireVisibleWorkOrder(ctx, input.workOrderId, { id: true });
|
||||||
|
const timeZone = await tenantTimeZone(ctx);
|
||||||
|
const daily = input.type === "daily";
|
||||||
|
const win = dayWindow(input.reportDate, timeZone);
|
||||||
|
const inDay = daily ? { gte: win.start, lt: win.end } : undefined;
|
||||||
|
|
||||||
|
const [wo, settings, tenant, entries, notes, photos, usages, plans, checklist, signature, technician] = await Promise.all([
|
||||||
|
ctx.db.workOrder.findFirstOrThrow({
|
||||||
|
where: { id: input.workOrderId },
|
||||||
|
include: {
|
||||||
|
customer: true,
|
||||||
|
site: true,
|
||||||
|
contact: true,
|
||||||
|
orderType: { select: { name: true } },
|
||||||
|
assignees: { include: { user: { select: { id: true, name: true } } } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ctx.db.tenantSettings.findFirst({ select: { orgName: true, address: true, phone: true, email: true } }),
|
||||||
|
ctx.db.user.findFirst({ where: { id: ctx.userId }, select: { tenant: { select: { name: true } } } }),
|
||||||
|
ctx.db.timeEntry.findMany({
|
||||||
|
where: { workSession: { workOrderId: input.workOrderId }, ...(inDay ? { startedAt: inDay } : {}) },
|
||||||
|
orderBy: { startedAt: "asc" },
|
||||||
|
select: { userId: true, type: true, startedAt: true, endedAt: true },
|
||||||
|
}),
|
||||||
|
ctx.db.activityNote.findMany({
|
||||||
|
where: { workOrderId: input.workOrderId, deletedAt: null, ...(inDay ? { createdAt: inDay } : {}) },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
select: { kind: true, text: true },
|
||||||
|
}),
|
||||||
|
ctx.db.photo.findMany({
|
||||||
|
where: { workOrderId: input.workOrderId, includeInReport: true, ...(inDay ? { takenAt: inDay } : {}) },
|
||||||
|
orderBy: { takenAt: "asc" },
|
||||||
|
include: { photoRequirement: { select: { label: true } } },
|
||||||
|
}),
|
||||||
|
ctx.db.materialUsage.findMany({
|
||||||
|
where: { workOrderId: input.workOrderId, ...(inDay ? { createdAt: inDay } : {}) },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
include: { materialPlan: true },
|
||||||
|
}),
|
||||||
|
ctx.db.materialPlan.findMany({ where: { workOrderId: input.workOrderId }, orderBy: { sortOrder: "asc" } }),
|
||||||
|
ctx.db.checklistItem.findMany({ where: { workOrderId: input.workOrderId }, orderBy: { sortOrder: "asc" } }),
|
||||||
|
input.reportId ? ctx.db.signature.findFirst({ where: { reportId: input.reportId } }) : Promise.resolve(null),
|
||||||
|
input.technicianUserId ? ctx.db.user.findFirst({ where: { id: input.technicianUserId }, select: { id: true, name: true } }) : Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ---- people & time ----
|
||||||
|
const userIds = new Set<string>(entries.map((e) => e.userId));
|
||||||
|
if (signature?.capturedById) userIds.add(signature.capturedById);
|
||||||
|
const users = await ctx.db.user.findMany({ where: { id: { in: [...userIds] } }, select: { id: true, name: true } });
|
||||||
|
const nameOf = new Map(users.map((u) => [u.id, u.name]));
|
||||||
|
for (const a of wo.assignees) nameOf.set(a.user.id, a.user.name);
|
||||||
|
|
||||||
|
const perKey = new Map<string, { userId: string; name: string; type: (typeof entries)[number]["type"]; minutes: number }>();
|
||||||
|
let hasRunningEntries = false;
|
||||||
|
for (const e of entries) {
|
||||||
|
if (!e.endedAt) hasRunningEntries = true;
|
||||||
|
const minutes = Math.max(0, Math.round(((e.endedAt ?? now).getTime() - e.startedAt.getTime()) / 60_000));
|
||||||
|
const key = `${e.userId}|${e.type}`;
|
||||||
|
const cur = perKey.get(key) ?? { userId: e.userId, name: nameOf.get(e.userId) ?? "—", type: e.type, minutes: 0 };
|
||||||
|
cur.minutes += minutes;
|
||||||
|
perKey.set(key, cur);
|
||||||
|
}
|
||||||
|
const timeLines = [...perKey.values()];
|
||||||
|
const totalsByType: Record<string, number> = {};
|
||||||
|
const byPerson = new Map<string, { userId: string; name: string; minutes: number }>();
|
||||||
|
let totalMinutes = 0;
|
||||||
|
for (const l of timeLines) {
|
||||||
|
totalsByType[l.type] = (totalsByType[l.type] ?? 0) + l.minutes;
|
||||||
|
if (l.type === "break") continue;
|
||||||
|
totalMinutes += l.minutes;
|
||||||
|
const p = byPerson.get(l.userId) ?? { userId: l.userId, name: l.name, minutes: 0 };
|
||||||
|
p.minutes += l.minutes;
|
||||||
|
byPerson.set(l.userId, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
const staffIds = [...new Set(entries.map((e) => e.userId))];
|
||||||
|
const staff = (staffIds.length ? staffIds : wo.assignees.map((a) => a.userId)).map((id) => ({ userId: id, name: nameOf.get(id) ?? "—" }));
|
||||||
|
|
||||||
|
const workDates = daily
|
||||||
|
? [input.reportDate]
|
||||||
|
: [...new Set(entries.map((e) => localDateKey(e.startedAt, timeZone)))].sort();
|
||||||
|
|
||||||
|
// ---- texts (prefill from notes on create) ----
|
||||||
|
const byKind = (...kinds: string[]) => joinLines(...notes.filter((n) => kinds.includes(n.kind)).map((n) => n.text));
|
||||||
|
const texts: ReportTexts = input.texts ?? {
|
||||||
|
...emptyTexts(),
|
||||||
|
workPerformed: byKind("work_done", "general"),
|
||||||
|
deviations: byKind("deviation"),
|
||||||
|
additionalWork: byKind("additional_work"),
|
||||||
|
problems: byKind("problem", "not_executable"),
|
||||||
|
openItems: byKind("follow_up"),
|
||||||
|
nextSteps: "",
|
||||||
|
hints: byKind("recommendation", "customer_note"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- materials ----
|
||||||
|
const used: MaterialLine[] = [];
|
||||||
|
const notUsed: MaterialLine[] = [];
|
||||||
|
const additional: MaterialLine[] = [];
|
||||||
|
const plansWithUsage = new Set<string>();
|
||||||
|
for (const u of usages) {
|
||||||
|
if (u.materialPlanId) plansWithUsage.add(u.materialPlanId);
|
||||||
|
const planned = u.materialPlan ? dec(u.materialPlan.plannedQuantity) : null;
|
||||||
|
const quantityDiffers = u.materialPlan ? !u.materialPlan.plannedQuantity.equals(u.actualQuantity) : false;
|
||||||
|
const line: MaterialLine = {
|
||||||
|
usageId: u.id,
|
||||||
|
planId: u.materialPlanId,
|
||||||
|
name: u.name,
|
||||||
|
articleNumber: u.articleNumber,
|
||||||
|
plannedQuantity: planned,
|
||||||
|
actualQuantity: dec(u.actualQuantity),
|
||||||
|
unit: u.unit,
|
||||||
|
status: u.usageStatus,
|
||||||
|
deviation: u.usageStatus !== "fully_used" || quantityDiffers,
|
||||||
|
deviationReason: u.deviationReason,
|
||||||
|
notes: u.notes,
|
||||||
|
documented: true,
|
||||||
|
};
|
||||||
|
if (u.usageStatus === "additional" || !u.materialPlanId) additional.push({ ...line, deviation: true });
|
||||||
|
else if (u.usageStatus === "not_used") notUsed.push(line);
|
||||||
|
else used.push(line);
|
||||||
|
}
|
||||||
|
if (!daily) {
|
||||||
|
for (const p of plans) {
|
||||||
|
if (plansWithUsage.has(p.id)) continue;
|
||||||
|
notUsed.push({
|
||||||
|
usageId: null,
|
||||||
|
planId: p.id,
|
||||||
|
name: p.name,
|
||||||
|
articleNumber: p.articleNumber,
|
||||||
|
plannedQuantity: dec(p.plannedQuantity),
|
||||||
|
actualQuantity: null,
|
||||||
|
unit: p.unit,
|
||||||
|
status: null,
|
||||||
|
deviation: true,
|
||||||
|
deviationReason: null,
|
||||||
|
notes: p.notes,
|
||||||
|
documented: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- signature ----
|
||||||
|
let signatureBlock: SignatureBlock | null = input.previousSignature ?? null;
|
||||||
|
if (signature) {
|
||||||
|
signatureBlock = {
|
||||||
|
outcome: signature.outcome,
|
||||||
|
signerName: signature.signerName,
|
||||||
|
signerRole: signature.signerRole,
|
||||||
|
signedAt: signature.signedAt.toISOString(),
|
||||||
|
reason: signature.reason,
|
||||||
|
confirmationText: signature.confirmationText,
|
||||||
|
imageDocumentId: signature.imageDocumentId,
|
||||||
|
capturedByName: signature.capturedById ? (nameOf.get(signature.capturedById) ?? null) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerName =
|
||||||
|
wo.customer.companyName || [wo.customer.firstName, wo.customer.lastName].filter(Boolean).join(" ") || wo.customer.customerNumber || "—";
|
||||||
|
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: input.type,
|
||||||
|
reportNumber: input.reportNumber,
|
||||||
|
version: input.version,
|
||||||
|
reportDate: input.reportDate,
|
||||||
|
generatedAt: now.toISOString(),
|
||||||
|
tenant: {
|
||||||
|
name: settings?.orgName || tenant?.tenant.name || "—",
|
||||||
|
address: settings?.address ?? null,
|
||||||
|
phone: settings?.phone ?? null,
|
||||||
|
email: settings?.email ?? null,
|
||||||
|
logoDocumentId: null, // TODO(settings): TenantSettings.logoKey is not a Document yet
|
||||||
|
},
|
||||||
|
customer: {
|
||||||
|
id: wo.customer.id,
|
||||||
|
number: wo.customer.customerNumber,
|
||||||
|
name: customerName,
|
||||||
|
address: address(wo.customer.street, wo.customer.houseNumber, wo.customer.postalCode, wo.customer.city),
|
||||||
|
},
|
||||||
|
site: wo.site ? { id: wo.site.id, name: wo.site.name, address: address(wo.site.street, wo.site.houseNumber, wo.site.postalCode, wo.site.city) } : null,
|
||||||
|
contact: wo.contact
|
||||||
|
? { name: wo.contact.name, role: wo.contact.role, phone: wo.contact.phone ?? wo.contact.mobile, email: wo.contact.email }
|
||||||
|
: null,
|
||||||
|
workOrder: {
|
||||||
|
id: wo.id,
|
||||||
|
number: wo.number,
|
||||||
|
externalOrderNumber: wo.externalOrderNumber,
|
||||||
|
title: wo.title,
|
||||||
|
description: wo.description,
|
||||||
|
scope: wo.scope,
|
||||||
|
orderType: wo.orderType?.name ?? null,
|
||||||
|
signatureRequired: wo.signatureRequired,
|
||||||
|
},
|
||||||
|
workDates,
|
||||||
|
staff,
|
||||||
|
time: {
|
||||||
|
entries: timeLines,
|
||||||
|
totalsByType,
|
||||||
|
totalsByPerson: [...byPerson.values()],
|
||||||
|
totalMinutes,
|
||||||
|
hasRunningEntries,
|
||||||
|
},
|
||||||
|
texts,
|
||||||
|
materials: { used, notUsed, additional },
|
||||||
|
photos: photos.map((p) => ({
|
||||||
|
photoId: p.id,
|
||||||
|
documentId: p.documentId,
|
||||||
|
phase: p.phase,
|
||||||
|
comment: p.comment,
|
||||||
|
requirement: p.photoRequirement?.label ?? null,
|
||||||
|
takenAt: p.takenAt.toISOString(),
|
||||||
|
})),
|
||||||
|
checklist: checklist.map((c) => ({ label: c.label, required: c.required, checked: c.checked, comment: c.comment })),
|
||||||
|
signature: signatureBlock,
|
||||||
|
technician: technician ? { userId: technician.id, name: technician.name } : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { Prisma, Report } from "@prisma/client";
|
||||||
|
import { parseReportContent, type ReportContent } from "@/lib/reports/content";
|
||||||
|
import { dbDateToKey } from "@/lib/reports/dates";
|
||||||
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import { can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { workOrderScope } from "@/server/services/work-orders/visibility";
|
||||||
|
import { buildReportContent } from "./build-content";
|
||||||
|
|
||||||
|
/** Report where-clause restricted to work orders the caller may see (ARCHITEKTUR §2). */
|
||||||
|
export async function reportScope(ctx: ServiceCtx): Promise<Prisma.ReportWhereInput> {
|
||||||
|
if (!can(ctx, "report:read")) return { id: "__none__" };
|
||||||
|
return { workOrder: await workOrderScope(ctx) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load a report visible to the caller or throw not_found (never reveal existence). */
|
||||||
|
export async function requireVisibleReport(ctx: ServiceCtx, reportId: string): Promise<Report> {
|
||||||
|
const scope = await reportScope(ctx);
|
||||||
|
const report = await ctx.db.report.findFirst({ where: { AND: [{ id: reportId }, scope] } });
|
||||||
|
if (!report) throw new ServiceError("not_found", "report not found");
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contentOf(report: Pick<Report, "content">): ReportContent {
|
||||||
|
return parseReportContent(report.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rebuild DB-derived parts of a report snapshot while keeping number, version and edited texts. */
|
||||||
|
export async function refreshContent(ctx: ServiceCtx, report: Report): Promise<ReportContent> {
|
||||||
|
const current = contentOf(report);
|
||||||
|
return buildReportContent(ctx, {
|
||||||
|
workOrderId: report.workOrderId,
|
||||||
|
type: report.type,
|
||||||
|
reportDate: dbDateToKey(report.reportDate),
|
||||||
|
reportNumber: current.reportNumber,
|
||||||
|
version: report.version,
|
||||||
|
texts: current.texts,
|
||||||
|
reportId: report.id,
|
||||||
|
previousSignature: current.signature,
|
||||||
|
technicianUserId: report.createdById,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact, PII-light audit projection of a report. */
|
||||||
|
export function reportAuditView(r: Pick<Report, "id" | "status" | "type" | "version" | "lineageId" | "workOrderId"> & { rejectionReason?: string | null }) {
|
||||||
|
return { id: r.id, status: r.status, type: r.type, version: r.version, lineageId: r.lineageId, workOrderId: r.workOrderId, rejectionReason: r.rejectionReason ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function auditReport(
|
||||||
|
ctx: ServiceCtx,
|
||||||
|
action: "create" | "update",
|
||||||
|
entityId: string,
|
||||||
|
before: unknown,
|
||||||
|
after: unknown,
|
||||||
|
entity: "report" | "signature" = "report",
|
||||||
|
) {
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action, entity, entityId, before, after });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertEditable(report: Pick<Report, "status">) {
|
||||||
|
if (report.status !== "draft" && report.status !== "rejected") throw new ServiceError("conflict", `report is ${report.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer-facing work order numbers for events/templates. */
|
||||||
|
export async function orderNumberOf(ctx: ServiceCtx, workOrderId: string): Promise<string> {
|
||||||
|
const wo = await ctx.db.workOrder.findFirst({ where: { id: workOrderId }, select: { number: true } });
|
||||||
|
return wo?.number ?? "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { Report } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { FIELD_EDITABLE, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||||
|
import { dateKeyToDbDate, localDateKey } from "@/lib/reports/dates";
|
||||||
|
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
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 { buildReportContent, tenantTimeZone } from "./build-content";
|
||||||
|
import { auditReport, reportAuditView } from "./common";
|
||||||
|
|
||||||
|
export const createReportSchema = z.object({
|
||||||
|
workOrderId: z.string().min(1).max(64),
|
||||||
|
reportDate: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
.optional(),
|
||||||
|
clientId: z.string().min(1).max(64).optional(),
|
||||||
|
});
|
||||||
|
export type CreateReportInput = z.input<typeof createReportSchema>;
|
||||||
|
export type CreateReportResult = { report: Report; created: boolean };
|
||||||
|
|
||||||
|
async function byClientId(ctx: ServiceCtx, clientId?: string): Promise<Report | null> {
|
||||||
|
if (!clientId) return null;
|
||||||
|
return ctx.db.report.findFirst({ where: { clientId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDraft(
|
||||||
|
ctx: ServiceCtx,
|
||||||
|
args: { workOrderId: string; type: "daily" | "completion"; dateKey: string; clientId?: string },
|
||||||
|
): Promise<Report> {
|
||||||
|
const reportNumber = await nextNumber(ctx.db, ctx.tenantId, "report");
|
||||||
|
const content = await buildReportContent(ctx, {
|
||||||
|
workOrderId: args.workOrderId,
|
||||||
|
type: args.type,
|
||||||
|
reportDate: args.dateKey,
|
||||||
|
reportNumber,
|
||||||
|
version: 1,
|
||||||
|
technicianUserId: ctx.userId,
|
||||||
|
});
|
||||||
|
const report = await ctx.db.report.create({
|
||||||
|
data: {
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
workOrderId: args.workOrderId,
|
||||||
|
type: args.type,
|
||||||
|
reportDate: dateKeyToDbDate(args.dateKey),
|
||||||
|
version: 1,
|
||||||
|
lineageId: randomUUID(),
|
||||||
|
status: "draft",
|
||||||
|
content,
|
||||||
|
createdById: ctx.userId,
|
||||||
|
clientId: args.clientId ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await auditReport(ctx, "create", report.id, null, { ...reportAuditView(report), reportNumber });
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tagesbericht (Spec §16.3): draft for one calendar day, work order → daily_report_created, order stays open.
|
||||||
|
* Idempotent per (work order, day): an existing draft/rejected report of that day is returned.
|
||||||
|
*/
|
||||||
|
export async function createDailyReport(ctx: ServiceCtx, raw: CreateReportInput): Promise<CreateReportResult> {
|
||||||
|
assertCan(ctx, "report:write");
|
||||||
|
const input = createReportSchema.parse(raw);
|
||||||
|
const dup = await byClientId(ctx, input.clientId);
|
||||||
|
if (dup) return { report: dup, created: false };
|
||||||
|
|
||||||
|
const wo = await requireVisibleWorkOrder(ctx, input.workOrderId, { id: true, status: true });
|
||||||
|
const dateKey = input.reportDate ?? localDateKey(new Date(), await tenantTimeZone(ctx));
|
||||||
|
|
||||||
|
const existing = await ctx.db.report.findFirst({
|
||||||
|
where: { workOrderId: wo.id, type: "daily", reportDate: dateKeyToDbDate(dateKey), status: { not: "superseded" } },
|
||||||
|
orderBy: { version: "desc" },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
if (existing.status === "draft" || existing.status === "rejected") return { report: existing, created: false };
|
||||||
|
throw new ServiceError("conflict", "daily report for this day already submitted");
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = wo.status as WorkOrderStatus;
|
||||||
|
// one daily report per order and day → stable occurrence id for L6 mail deduplication
|
||||||
|
const eventData = { occurrenceId: `${wo.id}:${dateKey}` };
|
||||||
|
if (status === "paused" || status === "waiting_material") {
|
||||||
|
await transitionWorkOrder(ctx, { workOrderId: wo.id, to: "in_progress" });
|
||||||
|
await transitionWorkOrder(ctx, { workOrderId: wo.id, to: "daily_report_created", eventData });
|
||||||
|
} else if (status === "in_progress") {
|
||||||
|
await transitionWorkOrder(ctx, { workOrderId: wo.id, to: "daily_report_created", eventData });
|
||||||
|
} else if (status !== "daily_report_created") {
|
||||||
|
throw new ServiceError("invalid", `work order is ${status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = await createDraft(ctx, { workOrderId: wo.id, type: "daily", dateKey, clientId: input.clientId });
|
||||||
|
return { report, created: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abschlussbericht (Spec §17): checks completion guards first (blocked → CompletionBlocker[] in details).
|
||||||
|
* One completion lineage per work order; changes after approval go through createNewVersion.
|
||||||
|
*/
|
||||||
|
export async function createCompletionReport(ctx: ServiceCtx, raw: CreateReportInput): Promise<CreateReportResult> {
|
||||||
|
assertCan(ctx, "report:write");
|
||||||
|
const input = createReportSchema.parse(raw);
|
||||||
|
const dup = await byClientId(ctx, input.clientId);
|
||||||
|
if (dup) return { report: dup, created: false };
|
||||||
|
|
||||||
|
const wo = await requireVisibleWorkOrder(ctx, input.workOrderId, { id: true, status: true });
|
||||||
|
const existing = await ctx.db.report.findFirst({
|
||||||
|
where: { workOrderId: wo.id, type: "completion", status: { not: "superseded" } },
|
||||||
|
orderBy: { version: "desc" },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
if (existing.status === "draft" || existing.status === "rejected") return { report: existing, created: false };
|
||||||
|
throw new ServiceError("conflict", `completion report is ${existing.status}`);
|
||||||
|
}
|
||||||
|
if (!FIELD_EDITABLE.includes(wo.status as WorkOrderStatus)) throw new ServiceError("invalid", `work order is ${wo.status}`);
|
||||||
|
|
||||||
|
const blockers = await getCompletionBlockers(ctx, wo.id);
|
||||||
|
if (blockers.length) throw new ServiceError("blocked", "completion blocked", blockers);
|
||||||
|
|
||||||
|
const dateKey = input.reportDate ?? localDateKey(new Date(), await tenantTimeZone(ctx));
|
||||||
|
const report = await createDraft(ctx, { workOrderId: wo.id, type: "completion", dateKey, clientId: input.clientId });
|
||||||
|
return { report, created: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Report } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { reportTextsSchema } from "@/lib/reports/content";
|
||||||
|
import { assertCan, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { assertEditable, auditReport, contentOf, requireVisibleReport } from "./common";
|
||||||
|
|
||||||
|
export const editReportSchema = z.object({
|
||||||
|
reportId: z.string().min(1).max(64),
|
||||||
|
texts: reportTextsSchema.partial(),
|
||||||
|
});
|
||||||
|
export type EditReportInput = z.input<typeof editReportSchema>;
|
||||||
|
|
||||||
|
/** Edit the free-text block of a draft/rejected report (technician check & completion, Spec §16.3 step 4). */
|
||||||
|
export async function updateReportTexts(ctx: ServiceCtx, raw: EditReportInput): Promise<Report> {
|
||||||
|
assertCan(ctx, "report:write");
|
||||||
|
const input = editReportSchema.parse(raw);
|
||||||
|
const report = await requireVisibleReport(ctx, input.reportId);
|
||||||
|
assertEditable(report);
|
||||||
|
const content = contentOf(report);
|
||||||
|
const texts = { ...content.texts, ...input.texts };
|
||||||
|
const updated = await ctx.db.report.update({
|
||||||
|
where: { id: report.id },
|
||||||
|
data: { content: { ...content, texts } },
|
||||||
|
});
|
||||||
|
await auditReport(ctx, "update", report.id, { texts: content.texts }, { texts });
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { storage, type StoredContent } from "@/server/storage/adapter";
|
||||||
|
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { allowedDocumentVisibility } from "@/server/services/work-orders/visibility";
|
||||||
|
import { contentOf, requireVisibleReport } from "./common";
|
||||||
|
|
||||||
|
export type ReportFile = StoredContent & { mimeType: string; fileName: string; checksum: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a file that belongs to a report the caller may see: the report PDF, a photo, the signature image or
|
||||||
|
* the tenant logo referenced by the snapshot. Anything else → not_found (no generic file oracle).
|
||||||
|
*/
|
||||||
|
export async function openReportFile(ctx: ServiceCtx, reportId: string, documentId: string | "pdf"): Promise<ReportFile> {
|
||||||
|
const report = await requireVisibleReport(ctx, reportId);
|
||||||
|
let id: string | null;
|
||||||
|
if (documentId === "pdf") {
|
||||||
|
id = report.pdfDocumentId;
|
||||||
|
} else {
|
||||||
|
const c = contentOf(report);
|
||||||
|
const referenced = new Set<string>([
|
||||||
|
...c.photos.map((p) => p.documentId),
|
||||||
|
...(c.signature?.imageDocumentId ? [c.signature.imageDocumentId] : []),
|
||||||
|
...(c.tenant.logoDocumentId ? [c.tenant.logoDocumentId] : []),
|
||||||
|
...(report.pdfDocumentId ? [report.pdfDocumentId] : []),
|
||||||
|
]);
|
||||||
|
id = referenced.has(documentId) ? documentId : null;
|
||||||
|
}
|
||||||
|
if (!id) throw new ServiceError("not_found", "file not found");
|
||||||
|
const doc = await ctx.db.document.findFirst({
|
||||||
|
where: { id, deletedAt: null, visibility: { in: allowedDocumentVisibility(ctx) } },
|
||||||
|
select: { storageKey: true, mimeType: true, fileName: true, checksum: true },
|
||||||
|
});
|
||||||
|
if (!doc || !doc.storageKey.startsWith(`${ctx.tenantId}/`)) throw new ServiceError("not_found", "file not found");
|
||||||
|
const obj = await storage.get(doc.storageKey);
|
||||||
|
if (!obj) throw new ServiceError("not_found", "file not available");
|
||||||
|
return { ...obj, mimeType: doc.mimeType, fileName: doc.fileName, checksum: doc.checksum };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fileResponse(file: ReportFile, opts: { download?: boolean } = {}): Response {
|
||||||
|
const inlineOk = file.mimeType === "application/pdf" || file.mimeType.startsWith("image/");
|
||||||
|
const disposition = opts.download || !inlineOk ? "attachment" : "inline";
|
||||||
|
const headers = new Headers({
|
||||||
|
"Content-Type": file.mimeType,
|
||||||
|
"Content-Disposition": `${disposition}; filename="${file.fileName.replace(/["\\\r\n]/g, "_")}"`,
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"Cache-Control": "private, no-store",
|
||||||
|
"X-Checksum-SHA256": file.checksum,
|
||||||
|
});
|
||||||
|
if (file.size != null) headers.set("Content-Length", String(file.size));
|
||||||
|
return new Response(file.stream, { headers });
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { ZodError } from "zod";
|
||||||
|
import { moduleGuard } from "@/server/action-guard";
|
||||||
|
import { ModuleDisabledError } from "@/server/modules";
|
||||||
|
import { ForbiddenError, type Permission } from "@/server/rbac";
|
||||||
|
import { ctxFromGuard, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin adapter for /api/v1 report route handlers: module gate + DB-authoritative permissions → ServiceCtx,
|
||||||
|
* uniform JSON error mapping. (No shared requireApiContext exists yet; replace at merge if the architect adds one.)
|
||||||
|
*/
|
||||||
|
const guard = moduleGuard("reports");
|
||||||
|
|
||||||
|
const STATUS: Record<ServiceError["code"], number> = { not_found: 404, forbidden: 403, invalid: 400, conflict: 409, blocked: 422 };
|
||||||
|
|
||||||
|
export function apiError(err: unknown): Response {
|
||||||
|
if (err instanceof ServiceError) return Response.json({ error: err.code, details: err.details ?? null }, { status: STATUS[err.code] });
|
||||||
|
if (err instanceof ZodError) return Response.json({ error: "invalid", details: err.issues.map((i) => ({ path: i.path.join("."), code: i.code })) }, { status: 400 });
|
||||||
|
if (err instanceof ForbiddenError || err instanceof ModuleDisabledError) return Response.json({ error: "forbidden" }, { status: 403 });
|
||||||
|
if (err instanceof Error && /Nicht angemeldet|nicht aktiv|nicht mehr gueltig|Passwortwechsel/.test(err.message)) {
|
||||||
|
return Response.json({ error: "unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
console.error("[api/reports]", err);
|
||||||
|
return Response.json({ error: "internal" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withReportsApi(permissions: Permission[], handler: (ctx: ServiceCtx) => Promise<Response>): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const g = await guard(...permissions);
|
||||||
|
return await handler(ctxFromGuard(g));
|
||||||
|
} catch (err) {
|
||||||
|
return apiError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readJson(req: Request): Promise<Record<string, unknown>> {
|
||||||
|
const text = await req.text();
|
||||||
|
if (!text.trim()) return {};
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(text);
|
||||||
|
return v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
||||||
|
} catch {
|
||||||
|
throw new ServiceError("invalid", "body must be JSON");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportDto(r: { id: string; type: string; status: string; version: number; workOrderId: string; lineageId: string; pdfDocumentId?: string | null }) {
|
||||||
|
return { id: r.id, type: r.type, status: r.status, version: r.version, workOrderId: r.workOrderId, lineageId: r.lineageId, hasPdf: Boolean(r.pdfDocumentId) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { Report } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { dbDateToKey } from "@/lib/reports/dates";
|
||||||
|
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { buildReportContent } from "./build-content";
|
||||||
|
import { auditReport, contentOf, reportAuditView, requireVisibleReport } from "./common";
|
||||||
|
|
||||||
|
export const newVersionSchema = z.object({ reportId: z.string().min(1).max(64) });
|
||||||
|
export type NewVersionInput = z.input<typeof newVersionSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Änderungen nach Freigabe (Spec §17.4): a new draft version (same lineage, version+1) is created from the
|
||||||
|
* approved snapshot. The approved version is NEVER modified here; it becomes `superseded` only when the
|
||||||
|
* successor is approved (approveReport), so a valid approved PDF exists at all times.
|
||||||
|
*/
|
||||||
|
export async function createNewVersion(ctx: ServiceCtx, raw: NewVersionInput): Promise<Report> {
|
||||||
|
assertCan(ctx, "report:approve");
|
||||||
|
assertCan(ctx, "report:write");
|
||||||
|
const input = newVersionSchema.parse(raw);
|
||||||
|
const report = await requireVisibleReport(ctx, input.reportId);
|
||||||
|
if (report.status !== "approved") throw new ServiceError("conflict", `report is ${report.status}`);
|
||||||
|
const latest = await ctx.db.report.findFirst({ where: { lineageId: report.lineageId }, orderBy: { version: "desc" }, select: { id: true } });
|
||||||
|
if (latest?.id !== report.id) throw new ServiceError("conflict", "a newer version already exists");
|
||||||
|
|
||||||
|
const approved = contentOf(report);
|
||||||
|
const version = report.version + 1;
|
||||||
|
const content = await buildReportContent(ctx, {
|
||||||
|
workOrderId: report.workOrderId,
|
||||||
|
type: report.type,
|
||||||
|
reportDate: dbDateToKey(report.reportDate),
|
||||||
|
reportNumber: approved.reportNumber,
|
||||||
|
version,
|
||||||
|
texts: approved.texts,
|
||||||
|
reportId: null,
|
||||||
|
previousSignature: approved.signature,
|
||||||
|
technicianUserId: report.createdById,
|
||||||
|
});
|
||||||
|
const created = await ctx.db.report.create({
|
||||||
|
data: {
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
workOrderId: report.workOrderId,
|
||||||
|
type: report.type,
|
||||||
|
reportDate: report.reportDate,
|
||||||
|
version,
|
||||||
|
lineageId: report.lineageId,
|
||||||
|
status: "draft",
|
||||||
|
content,
|
||||||
|
createdById: report.createdById,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await auditReport(ctx, "create", created.id, null, { ...reportAuditView(created), previousVersionId: report.id });
|
||||||
|
return created;
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
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 { tenantTimeZone } from "./build-content";
|
||||||
|
import { auditReport, contentOf, requireVisibleReport } from "./common";
|
||||||
|
|
||||||
|
async function loadReportMessages(locale: string): Promise<Record<string, unknown>> {
|
||||||
|
const file = join(process.cwd(), "messages", locale, "reports.json");
|
||||||
|
const fallback = join(process.cwd(), "messages", "de", "reports.json");
|
||||||
|
return JSON.parse(await readFile(existsSync(file) ? file : fallback, "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
return bytes ? `data:${doc.mimeType};base64,${Buffer.from(bytes).toString("base64")}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SHA-256 of the canonical content snapshot (printed in the PDF footer). */
|
||||||
|
export function contentChecksum(content: unknown): string {
|
||||||
|
return createHash("sha256").update(JSON.stringify(content)).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the PDF of an APPROVED report and file it as Document (category daily_report/completion_report,
|
||||||
|
* visibility customer_report). Immutable: an existing pdfDocumentId is never replaced.
|
||||||
|
* Runs in the worker (jobs/processors/report-pdf.ts).
|
||||||
|
*/
|
||||||
|
export async function generateReportPdf(ctx: ServiceCtx, reportId: string): Promise<{ documentId: string; checksum: string; skipped: boolean }> {
|
||||||
|
const report = await requireVisibleReport(ctx, reportId);
|
||||||
|
if (report.status !== "approved" && report.status !== "superseded") throw new ServiceError("invalid", `report is ${report.status}`);
|
||||||
|
if (report.pdfDocumentId) return { documentId: report.pdfDocumentId, checksum: report.pdfChecksum ?? "", skipped: true };
|
||||||
|
|
||||||
|
const content = contentOf(report);
|
||||||
|
const settings = await ctx.db.tenantSettings.findFirst({ select: { locale: true } });
|
||||||
|
const locale = settings?.locale === "en" ? "en" : "de";
|
||||||
|
const timeZone = await tenantTimeZone(ctx);
|
||||||
|
const t = createTranslator({ locale, messages: await loadReportMessages(locale) }) as unknown as (key: string, values?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
const imageIds = [...content.photos.map((p) => p.documentId), ...(content.signature?.imageDocumentId ? [content.signature.imageDocumentId] : [])];
|
||||||
|
const images: Record<string, string> = {};
|
||||||
|
for (const id of imageIds) {
|
||||||
|
const uri = await dataUri(ctx, id);
|
||||||
|
if (uri) images[id] = uri;
|
||||||
|
}
|
||||||
|
const fontPath = join(process.cwd(), "src", "app", "fonts", "inter-variable.ttf");
|
||||||
|
const fontDataUri = existsSync(fontPath) ? `data:font/ttf;base64,${(await readFile(fontPath)).toString("base64")}` : null;
|
||||||
|
|
||||||
|
const [{ renderReportHtml }, { renderHtmlToPdf }] = await Promise.all([import("@/server/pdf/templates/report"), import("@/server/pdf/render")]);
|
||||||
|
const { html, headerHtml, footerHtml } = renderReportHtml({
|
||||||
|
content,
|
||||||
|
reportId: report.id,
|
||||||
|
status: report.status,
|
||||||
|
approvedAt: report.approvedAt,
|
||||||
|
t: (key, values) => t(key, values),
|
||||||
|
locale,
|
||||||
|
timeZone,
|
||||||
|
images,
|
||||||
|
logoDataUri: content.tenant.logoDocumentId ? await dataUri(ctx, content.tenant.logoDocumentId) : null,
|
||||||
|
contentChecksum: contentChecksum(content),
|
||||||
|
fontDataUri,
|
||||||
|
});
|
||||||
|
const pdf = await renderHtmlToPdf(html, { headerHtml, footerHtml });
|
||||||
|
|
||||||
|
const doc = await storeFile(ctx, {
|
||||||
|
bytes: pdf,
|
||||||
|
fileName: `${content.reportNumber}-v${report.version}.pdf`,
|
||||||
|
declaredMime: "application/pdf",
|
||||||
|
category: report.type === "daily" ? "daily_report" : "completion_report",
|
||||||
|
visibility: "customer_report",
|
||||||
|
links: { customerId: content.customer.id, siteId: content.site?.id ?? null, workOrderId: report.workOrderId },
|
||||||
|
title: `${t(`type.${report.type}`)} ${content.reportNumber} v${report.version}`,
|
||||||
|
});
|
||||||
|
await ctx.db.document.update({ where: { id: doc.id }, data: { approvalStatus: "approved" } });
|
||||||
|
|
||||||
|
const res = await ctx.db.report.updateMany({ where: { id: report.id, pdfDocumentId: null }, data: { pdfDocumentId: doc.id, pdfChecksum: doc.checksum } });
|
||||||
|
if (res.count !== 1) {
|
||||||
|
// a concurrent run won — keep the first PDF, retire ours
|
||||||
|
await ctx.db.document.update({ where: { id: doc.id }, data: { deletedAt: new Date() } });
|
||||||
|
const current = await ctx.db.report.findFirstOrThrow({ where: { id: report.id }, select: { pdfDocumentId: true, pdfChecksum: true } });
|
||||||
|
return { documentId: current.pdfDocumentId ?? doc.id, checksum: current.pdfChecksum ?? "", skipped: true };
|
||||||
|
}
|
||||||
|
await auditReport(ctx, "update", report.id, { pdfDocumentId: null }, { pdfDocumentId: doc.id, pdfChecksum: doc.checksum });
|
||||||
|
return { documentId: doc.id, checksum: doc.checksum, skipped: false };
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import type { Prisma, ReportStatus, ReportType } from "@prisma/client";
|
||||||
|
import { REPORT_STATUSES, REPORT_TYPES, type ReportContent } from "@/lib/reports/content";
|
||||||
|
import { dateKeyToDbDate, localDateKey } from "@/lib/reports/dates";
|
||||||
|
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 { tenantTimeZone } from "./build-content";
|
||||||
|
import { contentOf, reportScope, requireVisibleReport } from "./common";
|
||||||
|
|
||||||
|
export type ReportListFilters = { type?: string; status?: string; teamId?: string; from?: string; to?: string };
|
||||||
|
export const LIST_LIMIT = 200;
|
||||||
|
|
||||||
|
const REVIEW_RANK: Record<ReportStatus, number> = { submitted: 0, team_approved: 1, rejected: 2, draft: 3, approved: 4, superseded: 5 };
|
||||||
|
const isDate = (s?: string) => Boolean(s && /^\d{4}-\d{2}-\d{2}$/.test(s));
|
||||||
|
|
||||||
|
/** Backoffice list: in-review first, then most recently changed; filters type/status/team/period. */
|
||||||
|
export async function listReports(ctx: ServiceCtx, f: ReportListFilters) {
|
||||||
|
const and: Prisma.ReportWhereInput[] = [await reportScope(ctx)];
|
||||||
|
if (f.type && (REPORT_TYPES as readonly string[]).includes(f.type)) and.push({ type: f.type as ReportType });
|
||||||
|
if (f.status === "all") {
|
||||||
|
// include superseded
|
||||||
|
} else if (f.status && (REPORT_STATUSES as readonly string[]).includes(f.status)) {
|
||||||
|
and.push({ status: f.status as ReportStatus });
|
||||||
|
} else {
|
||||||
|
and.push({ status: { not: "superseded" } });
|
||||||
|
}
|
||||||
|
if (f.teamId) and.push({ workOrder: { assignedTeamId: f.teamId } });
|
||||||
|
if (isDate(f.from)) and.push({ reportDate: { gte: dateKeyToDbDate(f.from!) } });
|
||||||
|
if (isDate(f.to)) and.push({ reportDate: { lte: dateKeyToDbDate(f.to!) } });
|
||||||
|
|
||||||
|
const rows = await ctx.db.report.findMany({
|
||||||
|
where: { AND: and },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: LIST_LIMIT,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
type: true,
|
||||||
|
status: true,
|
||||||
|
version: true,
|
||||||
|
reportDate: true,
|
||||||
|
updatedAt: true,
|
||||||
|
aiDrafted: true,
|
||||||
|
content: true,
|
||||||
|
workOrder: { select: { id: true, number: true, title: true, team: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const items = rows
|
||||||
|
.map((r) => {
|
||||||
|
const c = r.content as Partial<ReportContent>;
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
type: r.type,
|
||||||
|
status: r.status,
|
||||||
|
version: r.version,
|
||||||
|
reportDate: r.reportDate,
|
||||||
|
updatedAt: r.updatedAt,
|
||||||
|
aiDrafted: r.aiDrafted,
|
||||||
|
reportNumber: c.reportNumber ?? "—",
|
||||||
|
customerName: c.customer?.name ?? "—",
|
||||||
|
workOrder: { id: r.workOrder.id, number: r.workOrder.number, title: r.workOrder.title },
|
||||||
|
teamName: r.workOrder.team?.name ?? null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => REVIEW_RANK[a.status] - REVIEW_RANK[b.status] || b.updatedAt.getTime() - a.updatedAt.getTime());
|
||||||
|
return { items, truncated: rows.length === LIST_LIMIT };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function teamOptions(ctx: ServiceCtx) {
|
||||||
|
return ctx.db.team.findMany({ where: { deletedAt: null }, orderBy: { name: "asc" }, select: { id: true, name: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detail incl. visible versions of the lineage and resolved actor names. */
|
||||||
|
export async function getReportDetail(ctx: ServiceCtx, reportId: string) {
|
||||||
|
const report = await requireVisibleReport(ctx, reportId);
|
||||||
|
const scope = await reportScope(ctx);
|
||||||
|
const [versions, workOrder] = await Promise.all([
|
||||||
|
ctx.db.report.findMany({
|
||||||
|
where: { AND: [{ lineageId: report.lineageId }, scope] },
|
||||||
|
orderBy: { version: "desc" },
|
||||||
|
select: { id: true, version: true, status: true, approvedAt: true, updatedAt: true, pdfDocumentId: true },
|
||||||
|
}),
|
||||||
|
ctx.db.workOrder.findFirstOrThrow({ where: { id: report.workOrderId }, select: { id: true, number: true, status: true } }),
|
||||||
|
]);
|
||||||
|
const actorIds = [report.teamApprovedById, report.approvedById].filter((x): x is string => Boolean(x));
|
||||||
|
const actors = actorIds.length ? await ctx.db.user.findMany({ where: { id: { in: actorIds } }, select: { id: true, name: true } }) : [];
|
||||||
|
const nameOf = (id: string | null) => (id ? (actors.find((a) => a.id === id)?.name ?? null) : null);
|
||||||
|
const latestVersion = versions[0]?.version ?? report.version;
|
||||||
|
return {
|
||||||
|
report,
|
||||||
|
content: contentOf(report),
|
||||||
|
versions,
|
||||||
|
workOrder,
|
||||||
|
teamApprovedByName: nameOf(report.teamApprovedById),
|
||||||
|
approvedByName: nameOf(report.approvedById),
|
||||||
|
permissions: {
|
||||||
|
approve: can(ctx, "report:approve") && (report.status === "submitted" || report.status === "team_approved"),
|
||||||
|
approveTeam: !can(ctx, "report:approve") && can(ctx, "report:approve_team") && report.status === "submitted",
|
||||||
|
reject: (can(ctx, "report:approve") && (report.status === "submitted" || report.status === "team_approved")) || (can(ctx, "report:approve_team") && report.status === "submitted"),
|
||||||
|
newVersion: can(ctx, "report:approve") && can(ctx, "report:write") && report.status === "approved" && report.version === latestVersion,
|
||||||
|
regeneratePdf: can(ctx, "report:approve") && report.status === "approved" && !report.pdfDocumentId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mobile screen state for /m/orders/[id]/report and /sign. */
|
||||||
|
export async function getMobileReportState(ctx: ServiceCtx, workOrderId: string, type: "daily" | "completion") {
|
||||||
|
const wo = await requireVisibleWorkOrder(ctx, workOrderId, { id: true, number: true, title: true, status: true, signatureRequired: true });
|
||||||
|
const timeZone = await tenantTimeZone(ctx);
|
||||||
|
const today = localDateKey(new Date(), timeZone);
|
||||||
|
const report = await ctx.db.report.findFirst({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
await reportScope(ctx),
|
||||||
|
{ workOrderId: wo.id, type, status: { not: "superseded" } },
|
||||||
|
type === "daily" ? { reportDate: dateKeyToDbDate(today) } : {},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: [{ version: "desc" }],
|
||||||
|
});
|
||||||
|
let blockers: CompletionBlocker[] = [];
|
||||||
|
if (type === "completion" && (!report || report.status === "draft" || report.status === "rejected")) {
|
||||||
|
blockers = await getCompletionBlockers(ctx, wo.id);
|
||||||
|
}
|
||||||
|
return { workOrder: wo, report, content: report ? contentOf(report) : null, blockers, today, timeZone };
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { requireSession } from "@/server/auth";
|
||||||
|
import { dbForTenant } from "@/server/db";
|
||||||
|
import type { ServiceCtx } from "@/server/services/context";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ServiceCtx for READ paths in server components. Permissions come from the session (JWT) as documented in
|
||||||
|
* AGENTS.md („Rechte im JWT wirken für Lesepfade"); mutations always go through moduleGuard (DB-authoritative).
|
||||||
|
*/
|
||||||
|
export async function readCtx(): Promise<ServiceCtx> {
|
||||||
|
const session = await requireSession();
|
||||||
|
return {
|
||||||
|
db: dbForTenant(session.user.tenantId),
|
||||||
|
tenantId: session.user.tenantId,
|
||||||
|
userId: session.user.id,
|
||||||
|
permissions: new Set(session.user.permissions ?? []),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { Report } from "@prisma/client";
|
||||||
|
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 { auditReport, contentOf, orderNumberOf, reportAuditView, requireVisibleReport } from "./common";
|
||||||
|
|
||||||
|
export const rejectReportSchema = z.object({
|
||||||
|
reportId: z.string().min(1).max(64),
|
||||||
|
reason: z.string().trim().min(3).max(2000),
|
||||||
|
});
|
||||||
|
export type RejectReportInput = z.input<typeof rejectReportSchema>;
|
||||||
|
|
||||||
|
/** Zurückweisen mit Pflichtgrund: report → rejected, completion order in_review → in_progress (Korrektur). */
|
||||||
|
export async function rejectReport(ctx: ServiceCtx, raw: RejectReportInput): Promise<Report> {
|
||||||
|
const final = can(ctx, "report:approve");
|
||||||
|
if (!final && !can(ctx, "report:approve_team")) throw new ServiceError("forbidden", "missing permission report:approve");
|
||||||
|
const parsed = rejectReportSchema.safeParse(raw);
|
||||||
|
if (!parsed.success) throw new ServiceError("invalid", "reason required", { field: "reason" });
|
||||||
|
const input = parsed.data;
|
||||||
|
const report = await requireVisibleReport(ctx, input.reportId);
|
||||||
|
const from = final ? (["submitted", "team_approved"] as const) : (["submitted"] as const);
|
||||||
|
if (!(from as readonly string[]).includes(report.status)) throw new ServiceError("conflict", `report is ${report.status}`);
|
||||||
|
|
||||||
|
const res = await ctx.db.report.updateMany({
|
||||||
|
where: { id: report.id, status: { in: [...from] } },
|
||||||
|
data: { status: "rejected", rejectionReason: input.reason },
|
||||||
|
});
|
||||||
|
if (res.count !== 1) throw new ServiceError("conflict", "report changed concurrently");
|
||||||
|
const updated = await ctx.db.report.findFirstOrThrow({ where: { id: report.id } });
|
||||||
|
// a report can be rejected more than once → one occurrence per rejection (L6 mail deduplication)
|
||||||
|
const occurrenceId = `${report.id}:rejected:${updated.updatedAt.getTime()}`;
|
||||||
|
|
||||||
|
if (report.type === "completion") {
|
||||||
|
const wo = await ctx.db.workOrder.findFirstOrThrow({ where: { id: report.workOrderId }, select: { status: true } });
|
||||||
|
if (wo.status === "in_review") await transitionWorkOrder(ctx, { workOrderId: report.workOrderId, to: "in_progress", reason: input.reason, eventData: { occurrenceId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditReport(ctx, "update", report.id, reportAuditView(report), reportAuditView(updated));
|
||||||
|
await emitEvent(ctx, {
|
||||||
|
type: "report.rejected",
|
||||||
|
entityType: "report",
|
||||||
|
entityId: report.id,
|
||||||
|
data: { reportType: report.type, number: contentOf(updated).reportNumber, workOrderNumber: await orderNumberOf(ctx, report.workOrderId), reason: input.reason, occurrenceId },
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import type { Signature } from "@prisma/client";
|
||||||
|
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 { auditReport, contentOf, requireVisibleReport } from "./common";
|
||||||
|
|
||||||
|
const optText = (max: number) =>
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(max)
|
||||||
|
.nullish()
|
||||||
|
.transform((v) => (v ? v : null));
|
||||||
|
|
||||||
|
export const captureSignatureSchema = z.object({
|
||||||
|
reportId: z.string().min(1).max(64),
|
||||||
|
outcome: z.enum(SIGNATURE_OUTCOMES),
|
||||||
|
signerName: optText(200),
|
||||||
|
signerRole: optText(200),
|
||||||
|
imageDocumentId: optText(64),
|
||||||
|
reason: optText(2000),
|
||||||
|
confirmationText: z.string().trim().min(1).max(2000),
|
||||||
|
clientId: z.string().min(1).max(64).optional(),
|
||||||
|
});
|
||||||
|
export type CaptureSignatureInput = z.input<typeof captureSignatureSchema>;
|
||||||
|
|
||||||
|
/** Outcomes that count as "signature documented" for the order flow (→ in_review). */
|
||||||
|
export const SIGNATURE_DOCUMENTED = ["signed", "not_required", "customer_absent", "refused"] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Digitale Kundenunterschrift bzw. begründete Ausnahme (Spec §18).
|
||||||
|
* - signed: signer name + PNG image document required
|
||||||
|
* - customer_absent / refused / later: reason required
|
||||||
|
* - not_required: only if the work order does not require a signature or caller has report:approve
|
||||||
|
* A signed signature is never overwritten; other outcomes may be replaced (e.g. "later" → "signed").
|
||||||
|
*/
|
||||||
|
export async function captureSignature(ctx: ServiceCtx, raw: CaptureSignatureInput): Promise<Signature> {
|
||||||
|
assertCan(ctx, "report:write");
|
||||||
|
const input = captureSignatureSchema.parse(raw);
|
||||||
|
if (input.clientId) {
|
||||||
|
const dup = await ctx.db.signature.findFirst({ where: { clientId: input.clientId } });
|
||||||
|
if (dup) return dup;
|
||||||
|
}
|
||||||
|
const report = await requireVisibleReport(ctx, input.reportId);
|
||||||
|
if (report.status === "approved" || report.status === "superseded") throw new ServiceError("conflict", `report is ${report.status}`);
|
||||||
|
const wo = await ctx.db.workOrder.findFirstOrThrow({ where: { id: report.workOrderId }, select: { id: true, status: true, signatureRequired: true } });
|
||||||
|
|
||||||
|
if (input.outcome === "signed") {
|
||||||
|
if (!input.signerName) throw new ServiceError("invalid", "signer name required", { field: "signerName" });
|
||||||
|
if (!input.imageDocumentId) throw new ServiceError("invalid", "signature image required", { field: "image" });
|
||||||
|
const doc = await ctx.db.document.findFirst({
|
||||||
|
where: { id: input.imageDocumentId, category: "signature", mimeType: "image/png", workOrderId: wo.id, deletedAt: null },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!doc) throw new ServiceError("invalid", "signature image not found", { field: "image" });
|
||||||
|
} else if (input.imageDocumentId) {
|
||||||
|
throw new ServiceError("invalid", "image only allowed for signed outcome", { field: "image" });
|
||||||
|
}
|
||||||
|
if (SIGNATURE_REASON_REQUIRED.includes(input.outcome) && !input.reason) {
|
||||||
|
throw new ServiceError("invalid", "reason required", { field: "reason" });
|
||||||
|
}
|
||||||
|
if (input.outcome === "not_required" && wo.signatureRequired && !can(ctx, "report:approve")) {
|
||||||
|
throw new ServiceError("forbidden", "signature is required for this work order");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await ctx.db.signature.findFirst({ where: { reportId: report.id } });
|
||||||
|
if (existing?.outcome === "signed") throw new ServiceError("conflict", "signature already captured");
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
outcome: input.outcome,
|
||||||
|
signerName: input.signerName,
|
||||||
|
signerRole: input.signerRole,
|
||||||
|
imageDocumentId: input.outcome === "signed" ? input.imageDocumentId : null,
|
||||||
|
confirmationText: input.confirmationText,
|
||||||
|
reason: input.reason,
|
||||||
|
signedAt: new Date(),
|
||||||
|
capturedById: ctx.userId,
|
||||||
|
};
|
||||||
|
const signature = existing
|
||||||
|
? await ctx.db.signature.update({ where: { id: existing.id }, data })
|
||||||
|
: await ctx.db.signature.create({ data: { ...data, tenantId: ctx.tenantId, reportId: report.id, clientId: input.clientId ?? null } });
|
||||||
|
|
||||||
|
const me = await ctx.db.user.findFirst({ where: { id: ctx.userId }, select: { name: true } });
|
||||||
|
const block: SignatureBlock = {
|
||||||
|
outcome: signature.outcome,
|
||||||
|
signerName: signature.signerName,
|
||||||
|
signerRole: signature.signerRole,
|
||||||
|
signedAt: signature.signedAt.toISOString(),
|
||||||
|
reason: signature.reason,
|
||||||
|
confirmationText: signature.confirmationText,
|
||||||
|
imageDocumentId: signature.imageDocumentId,
|
||||||
|
capturedByName: me?.name ?? null,
|
||||||
|
};
|
||||||
|
const content = contentOf(report);
|
||||||
|
await ctx.db.report.update({ where: { id: report.id }, data: { content: { ...content, signature: block } } });
|
||||||
|
|
||||||
|
const view = (s: Pick<Signature, "outcome" | "signerName" | "signerRole" | "reason" | "imageDocumentId"> | null) =>
|
||||||
|
s && { reportId: report.id, outcome: s.outcome, signerName: s.signerName, signerRole: s.signerRole, reason: s.reason, imageDocumentId: s.imageDocumentId };
|
||||||
|
await auditReport(ctx, existing ? "update" : "create", signature.id, view(existing), view(signature), "signature");
|
||||||
|
|
||||||
|
// Signature captured after submit: order waiting for it can move on to review.
|
||||||
|
if (
|
||||||
|
report.type === "completion" &&
|
||||||
|
wo.status === "signature_pending" &&
|
||||||
|
(SIGNATURE_DOCUMENTED as readonly string[]).includes(signature.outcome) &&
|
||||||
|
can(ctx, "field:execute")
|
||||||
|
) {
|
||||||
|
await transitionWorkOrder(ctx, { workOrderId: wo.id, to: "in_review", eventData: { occurrenceId: `${report.id}:signature:${signature.id}` } });
|
||||||
|
}
|
||||||
|
return signature;
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import type { Report } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { missingRequiredTexts, type SignatureBlock } from "@/lib/reports/content";
|
||||||
|
import type { CompletionBlocker, WorkOrderStatus } from "@/lib/work-orders/status";
|
||||||
|
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 { auditReport, assertEditable, orderNumberOf, refreshContent, reportAuditView, requireVisibleReport } from "./common";
|
||||||
|
|
||||||
|
export const submitReportSchema = z.object({
|
||||||
|
reportId: z.string().min(1).max(64),
|
||||||
|
/** WorkOrder.version seen by the device (offline sync conflict detection) */
|
||||||
|
expectedWorkOrderVersion: z.number().int().positive().optional(),
|
||||||
|
});
|
||||||
|
export type SubmitReportInput = z.input<typeof submitReportSchema>;
|
||||||
|
|
||||||
|
function signaturePending(signatureRequired: boolean, sig: SignatureBlock | null): boolean {
|
||||||
|
return signatureRequired && (!sig || sig.outcome === "later");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move the work order after a completion report was submitted (ARCHITEKTUR §3):
|
||||||
|
* … → in_progress → technically_completed → signature_pending | in_review.
|
||||||
|
* Statuses beyond review (new report versions) are left untouched.
|
||||||
|
*/
|
||||||
|
async function advanceOrder(ctx: ServiceCtx, workOrderId: string, status: WorkOrderStatus, signatureRequired: boolean, sig: SignatureBlock | null, occurrenceId: string) {
|
||||||
|
const pending = signaturePending(signatureRequired, sig);
|
||||||
|
const eventData = { occurrenceId };
|
||||||
|
let s = status;
|
||||||
|
if (s === "paused" || s === "waiting_material" || s === "daily_report_created") {
|
||||||
|
await transitionWorkOrder(ctx, { workOrderId, to: "in_progress", eventData });
|
||||||
|
s = "in_progress";
|
||||||
|
}
|
||||||
|
if (s === "in_progress") {
|
||||||
|
await transitionWorkOrder(ctx, { workOrderId, to: "technically_completed", eventData });
|
||||||
|
s = "technically_completed";
|
||||||
|
}
|
||||||
|
if (s === "technically_completed") {
|
||||||
|
await transitionWorkOrder(ctx, { workOrderId, to: pending ? "signature_pending" : "in_review", eventData });
|
||||||
|
} else if (s === "signature_pending" && !pending) {
|
||||||
|
await transitionWorkOrder(ctx, { workOrderId, to: "in_review", eventData });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Review stage the submitted report waits for (L6 recipient rules): "team" if the order has a team lead
|
||||||
|
* (explicit or leader of the assigned team), otherwise "backoffice".
|
||||||
|
*/
|
||||||
|
export async function approvalStageFor(ctx: ServiceCtx, workOrderId: string): Promise<"team" | "backoffice"> {
|
||||||
|
const wo = await ctx.db.workOrder.findFirst({
|
||||||
|
where: { id: workOrderId },
|
||||||
|
select: { teamLeadUserId: true, team: { select: { leaderUserId: true } } },
|
||||||
|
});
|
||||||
|
return wo?.teamLeadUserId || wo?.team?.leaderUserId ? "team" : "backoffice";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Technician submits a draft/rejected report for review ("Zur Prüfung"). */
|
||||||
|
export async function submitReport(ctx: ServiceCtx, raw: SubmitReportInput): Promise<Report> {
|
||||||
|
assertCan(ctx, "report:write");
|
||||||
|
const input = submitReportSchema.parse(raw);
|
||||||
|
const report = await requireVisibleReport(ctx, input.reportId);
|
||||||
|
assertEditable(report);
|
||||||
|
|
||||||
|
const wo = await ctx.db.workOrder.findFirstOrThrow({
|
||||||
|
where: { id: report.workOrderId },
|
||||||
|
select: { id: true, status: true, version: true, signatureRequired: true, number: true },
|
||||||
|
});
|
||||||
|
if (input.expectedWorkOrderVersion !== undefined && input.expectedWorkOrderVersion !== wo.version) {
|
||||||
|
throw new ServiceError("conflict", "work order version changed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await refreshContent(ctx, report);
|
||||||
|
const blockers: CompletionBlocker[] = missingRequiredTexts(content).map((field) => ({ kind: "missing_field", field }));
|
||||||
|
if (report.type === "completion" && report.version === 1) blockers.push(...(await getCompletionBlockers(ctx, wo.id)));
|
||||||
|
if (blockers.length) throw new ServiceError("blocked", "report incomplete", blockers);
|
||||||
|
|
||||||
|
const submittedAt = new Date();
|
||||||
|
const res = await ctx.db.report.updateMany({
|
||||||
|
where: { id: report.id, status: { in: ["draft", "rejected"] } },
|
||||||
|
data: { status: "submitted", submittedAt, content },
|
||||||
|
});
|
||||||
|
if (res.count !== 1) throw new ServiceError("conflict", "report changed concurrently");
|
||||||
|
const updated = await ctx.db.report.findFirstOrThrow({ where: { id: report.id } });
|
||||||
|
// a report can be submitted again after rejection → one occurrence per submission
|
||||||
|
const occurrenceId = `${report.id}:${submittedAt.getTime()}`;
|
||||||
|
|
||||||
|
if (report.type === "completion" && report.version === 1) {
|
||||||
|
await advanceOrder(ctx, wo.id, wo.status as WorkOrderStatus, wo.signatureRequired, content.signature, occurrenceId);
|
||||||
|
const sig = content.signature;
|
||||||
|
if (wo.signatureRequired && sig && (sig.outcome === "refused" || sig.outcome === "customer_absent")) {
|
||||||
|
await emitEvent(ctx, { type: "work_order.signature_missing", entityType: "work_order", entityId: wo.id, data: { number: wo.number, outcome: sig.outcome, occurrenceId } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditReport(ctx, "update", report.id, reportAuditView(report), reportAuditView(updated));
|
||||||
|
await emitEvent(ctx, {
|
||||||
|
type: "report.submitted",
|
||||||
|
entityType: "report",
|
||||||
|
entityId: report.id,
|
||||||
|
data: {
|
||||||
|
reportType: report.type,
|
||||||
|
number: content.reportNumber,
|
||||||
|
workOrderNumber: await orderNumberOf(ctx, wo.id),
|
||||||
|
version: report.version,
|
||||||
|
approvalStage: await approvalStageFor(ctx, wo.id),
|
||||||
|
occurrenceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user