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:
2026-09-14 12:22:40 +02:00
co-authored by Claude Opus 5
parent bf4456718e
commit 8a6fdd8f7a
26 changed files with 2129 additions and 3 deletions
+113
View File
@@ -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;
}