- 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>
57 lines
2.4 KiB
TypeScript
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;
|
|
}
|