Files
craftvia/src/server/services/field/time-correction.ts
T
msolarczekandClaude Opus 5 952e74cddd L4 Einsatz mobil: Field-Services, Sync-API, Uploads und Tests
- services/field: Einsatz-Sessions (Anfahrt/Arbeit/Pause als TimeEntry-Segmente,
  eine aktive Session je User+Auftrag), Zeitkorrektur mit Recht + Grund + Audit,
  Checkliste, Material (Abweichung nur mit Begründung, Zusatzmaterial), Notizen,
  Fotos, Sprachnotizen (ohne Transkriptions-Processor Status disabled), Uploads
  (idempotent je Mandant), autorisierte Dokument-Auslieferung, Lesemodelle + Bundle
- services/sync: applyOperations mit Idempotenz, baseVersion-Konfliktprüfung,
  Registry für Ops anderer Lanes, lane-lokaler requireApiContext
- /api/v1/sync, /api/v1/uploads, /api/v1/field/bundle, /api/v1/field/documents/[id]
- lib/sync/ops.ts (Zod-Payloads je opType), lib/field/material-rules.ts
- Stubs mit Vertragssignatur: transitionWorkOrder (L2), storeFile (§4.3),
  getSiteHistory (L1)
- Processor image-derivatives + Registrierung, Audit-Entity-Labels
- Tests: test-einsatz-field (48 Prüfungen), test-einsatz-sync (38 Prüfungen)

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

57 lines
2.4 KiB
TypeScript

import { z } from "zod";
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
import { audit } from "./common";
/** Manual time corrections (Spec §12.2): only with `field:correct_time`, reason mandatory, always audited. */
export const TIME_ENTRY_TYPES = ["travel", "work", "break", "material_procurement", "return_travel", "interruption"] as const;
export const timeCorrectionSchema = z
.object({
timeEntryId: z.string().min(1).max(64),
startedAt: z.coerce.date(),
endedAt: z.coerce.date().nullish(),
type: z.enum(TIME_ENTRY_TYPES).optional(),
reason: z.string().trim().min(3).max(1000),
})
.refine((v) => !v.endedAt || v.endedAt.getTime() >= v.startedAt.getTime(), { message: "end before start", path: ["endedAt"] });
export type TimeCorrectionInput = z.input<typeof timeCorrectionSchema>;
export async function correctTimeEntry(ctx: ServiceCtx, raw: TimeCorrectionInput) {
assertCan(ctx, "field:correct_time");
const parsed = timeCorrectionSchema.safeParse(raw);
if (!parsed.success) throw new ServiceError("invalid", "invalid time correction", parsed.error.issues);
const input = parsed.data;
const entry = await ctx.db.timeEntry.findFirst({
where: { id: input.timeEntryId },
include: { workSession: { select: { workOrderId: true } } },
});
if (!entry) throw new ServiceError("not_found", "time entry not found");
await requireVisibleWorkOrder(ctx, entry.workSession.workOrderId, { id: true });
if (input.startedAt.getTime() > Date.now() + 60_000) throw new ServiceError("invalid", "start in the future");
const before = { type: entry.type, startedAt: entry.startedAt, endedAt: entry.endedAt, corrected: entry.corrected, correctionReason: entry.correctionReason };
const updated = await ctx.db.timeEntry.update({
where: { id: entry.id },
data: {
startedAt: input.startedAt,
endedAt: input.endedAt === undefined ? entry.endedAt : input.endedAt,
type: input.type ?? entry.type,
corrected: true,
correctionReason: input.reason,
correctedById: ctx.userId,
},
});
await audit(ctx, "update", "time_entry", entry.id, before, {
type: updated.type,
startedAt: updated.startedAt,
endedAt: updated.endedAt,
corrected: true,
correctionReason: input.reason,
});
return updated;
}