- 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>
49 lines
2.7 KiB
TypeScript
49 lines
2.7 KiB
TypeScript
import type { Report } from "@prisma/client";
|
|
import { z } from "zod";
|
|
import { emitEvent } from "@/server/events";
|
|
import { can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
|
// TODO(merge L2): import from "@/server/services/work-orders/transition"
|
|
import { transitionWorkOrder } from "@/server/services/work-orders/transition";
|
|
import { auditReport, contentOf, orderNumberOf, reportAuditView, requireVisibleReport } from "./common";
|
|
|
|
export const rejectReportSchema = z.object({
|
|
reportId: z.string().min(1).max(64),
|
|
reason: z.string().trim().min(3).max(2000),
|
|
});
|
|
export type RejectReportInput = z.input<typeof rejectReportSchema>;
|
|
|
|
/** Zurückweisen mit Pflichtgrund: report → rejected, completion order in_review → in_progress (Korrektur). */
|
|
export async function rejectReport(ctx: ServiceCtx, raw: RejectReportInput): Promise<Report> {
|
|
const final = can(ctx, "report:approve");
|
|
if (!final && !can(ctx, "report:approve_team")) throw new ServiceError("forbidden", "missing permission report:approve");
|
|
const parsed = rejectReportSchema.safeParse(raw);
|
|
if (!parsed.success) throw new ServiceError("invalid", "reason required", { field: "reason" });
|
|
const input = parsed.data;
|
|
const report = await requireVisibleReport(ctx, input.reportId);
|
|
const from = final ? (["submitted", "team_approved"] as const) : (["submitted"] as const);
|
|
if (!(from as readonly string[]).includes(report.status)) throw new ServiceError("conflict", `report is ${report.status}`);
|
|
|
|
const res = await ctx.db.report.updateMany({
|
|
where: { id: report.id, status: { in: [...from] } },
|
|
data: { status: "rejected", rejectionReason: input.reason },
|
|
});
|
|
if (res.count !== 1) throw new ServiceError("conflict", "report changed concurrently");
|
|
const updated = await ctx.db.report.findFirstOrThrow({ where: { id: report.id } });
|
|
// a report can be rejected more than once → one occurrence per rejection (L6 mail deduplication)
|
|
const occurrenceId = `${report.id}:rejected:${updated.updatedAt.getTime()}`;
|
|
|
|
if (report.type === "completion") {
|
|
const wo = await ctx.db.workOrder.findFirstOrThrow({ where: { id: report.workOrderId }, select: { status: true } });
|
|
if (wo.status === "in_review") await transitionWorkOrder(ctx, { workOrderId: report.workOrderId, to: "in_progress", reason: input.reason, eventData: { occurrenceId } });
|
|
}
|
|
|
|
await auditReport(ctx, "update", report.id, reportAuditView(report), reportAuditView(updated));
|
|
await emitEvent(ctx, {
|
|
type: "report.rejected",
|
|
entityType: "report",
|
|
entityId: report.id,
|
|
data: { reportType: report.type, number: contentOf(updated).reportNumber, workOrderNumber: await orderNumberOf(ctx, report.workOrderId), reason: input.reason, occurrenceId },
|
|
});
|
|
return updated;
|
|
}
|