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
+126
View File
@@ -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 };
}