L12 Zeiterfassung: Services für Nachtrag, Freigabe, Auto-Wechsel und Für heute beenden

time-entries.ts (manuelle Einträge, Korrekturvorschläge, Freigabe/Ablehnung mit
Team-Scope), Sessions in inTransaction mit switchFromOther, stopForToday,
Segmentwechsel und getMyActiveSession. Sync-Ops time.add_manual,
time.propose_correction, session.stop_day, session.segment inkl. optimistischer
Offline-Ansicht. Bericht, Zeiten-Tab, Dashboard und Lotse zählen nur freigegebene
Zeiten; Abrechnungsfreigabe blockiert bei offenen Zeiten. Empfänger und Links der
Zeit-Events. Notdienst-Start pausiert eine laufende Uhr automatisch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 09:29:30 +02:00
co-authored by Claude Opus 5
parent 03a2ccab4f
commit 0e94eb0d9d
19 changed files with 1054 additions and 107 deletions
+19 -3
View File
@@ -5,7 +5,8 @@ import { ServiceError, type ServiceCtx } from "@/server/services/context";
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
import { CONFLICTING_OPS, type SyncOperationInput, type SyncOpResult, type SyncOpType, type SyncResponse } from "@/lib/sync/envelope";
import { OP_PAYLOAD_SCHEMAS, type ParsedOpPayload } from "@/lib/sync/ops";
import { endSession, pauseSession, resumeSession, startSession } from "@/server/services/field/sessions";
import { endSession, pauseSession, resumeSession, startSession, stopForToday, switchSegment } from "@/server/services/field/sessions";
import { addManualTimeEntry, proposeTimeCorrection } from "@/server/services/field/time-entries";
import { createNote } from "@/server/services/field/notes";
import { toggleChecklistItem } from "@/server/services/field/checklist";
import { upsertMaterialUsage } from "@/server/services/field/materials";
@@ -58,8 +59,23 @@ const FIELD_HANDLERS: Partial<Record<SyncOpType, Handler>> = {
"material.upsert": h<"material.upsert">(async (ctx, p) => ({ idMap: idMap(p.clientId, (await upsertMaterialUsage(ctx, p)).usageId) })),
"photo.attach": h<"photo.attach">(async (ctx, p) => ({ idMap: idMap(p.clientId, (await attachPhoto(ctx, p)).photoId) })),
"voice.attach": h<"voice.attach">(async (ctx, p) => ({ idMap: idMap(p.clientId, (await attachVoiceNote(ctx, p)).voiceNoteId) })),
// L12 Zeiterfassung
"session.stop_day": h<"session.stop_day">(async (ctx, p) => ({ entityVersion: (await stopForToday(ctx, p)).workOrderVersion })),
"session.segment": h<"session.segment">(async (ctx, p) => ({ entityVersion: (await switchSegment(ctx, p)).workOrderVersion })),
"time.add_manual": h<"time.add_manual">(async (ctx, p) => ({ idMap: idMap(p.clientId, (await addManualTimeEntry(ctx, p)).id) })),
"time.propose_correction": h<"time.propose_correction">(async (ctx, p) => {
await proposeTimeCorrection(ctx, p);
return {};
}),
};
/** Message of a rejected op: blockers as JSON, the auto-switch conflict with the other order (L12). */
function errorMessage(err: ServiceError): string {
if (err.code === "blocked") return JSON.stringify(err.details ?? []);
if (err.message === "other_session_running") return `other_session_running:${JSON.stringify(err.details ?? {})}`;
return err.message;
}
class NotAvailable extends Error {}
/** Route a validated op to the field handler or the registered module of another lane. */
@@ -205,12 +221,12 @@ async function applyOne(ctx: ServiceCtx, deviceId: string, op: SyncOperationInpu
// a transition conflict detected inside the service (race after the pre-check)
const status = err.code === "conflict" && CONFLICTING_OPS.includes(op.opType) ? "conflict" : "rejected";
const details = err.code === "blocked" ? { blockers: err.details } : {};
const rec = await record(ctx, op, deviceId, status, { message: err.message, ...details }, err.code);
const rec = await record(ctx, op, deviceId, status, { message: errorMessage(err), ...details }, err.code);
if (rec === "duplicate") return { ...base, status: "duplicate" };
if (status === "conflict") {
await emitEvent(ctx, { type: "sync.failed", entityType: "sync_operation", entityId: rec.id, data: { opType: op.opType, reason: "conflict" } });
}
return { ...base, status, errorCode: err.code, message: err.code === "blocked" ? JSON.stringify(err.details ?? []) : err.message };
return { ...base, status, errorCode: err.code, message: errorMessage(err) };
}
console.error(`[sync] ${op.opType} ${op.clientOpId} failed:`, err);
return { ...base, status: "rejected", errorCode: "internal", message: "internal error" };