Files
craftvia/src/server/services/reports/create.ts
T
msolarczekandClaude Opus 5 3883296300 Integration: Stubs von L2/L3/L4/L5 gegen echte Services getauscht
- Aufträge: createWorkOrder/transitionWorkOrder/getCompletionBlockers aus L2
- Dokumente: storeFile aus L1; neu services/documents/read.ts (readStoredBytes,
  readDocumentBytes mit Prüfsummenprüfung); L2-Upload nutzt zentrale Ablage
- Dubletten aus L1 (findDuplicateCustomers(ctx)); Objekt-Kandidaten als
  imports/site-candidates.ts; Dateityp-Erkennung mobil als field/mime.ts
- Objekt-Historie mobil als Adapter auf L1 getSiteHistory, PDF über /api/v1/reports/:id/pdf
- L4-Upload-Idempotenz: fester Upload-Lineage nach storeFile, Race → Soft-Delete + Replay
- PDF-Worker-Kontext: document:write zum Ablegen des Berichts-PDF
- Import: doppeltes Work-Order-Audit entfernt; bestätigte Aufträge starten planned
- Dateilinks in Auftragsdetail auf /files/[documentId]
- proxy: /api/v1 ohne Session → 401 JSON statt Login-Redirect
- ARCHITEKTUR §2: Objekt-Historie für Feldrollen (freigegebene Einsätze aller Teams)

Gate: tsc, lint, build, 42/42 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:49:19 +02:00

128 lines
5.7 KiB
TypeScript

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<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 };
}