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