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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user