- 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>
128 lines
5.7 KiB
TypeScript
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 };
|
|
}
|