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 } from "@/server/services/work-orders/completion"; import { transitionWorkOrder } from "@/server/services/work-orders/transition"; 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; export type CreateReportResult = { report: Report; created: boolean }; async function byClientId(ctx: ServiceCtx, clientId?: string): Promise { 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 { 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 { 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 { 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 }; }