// Lane L12 „Zeiterfassung" — Service-Tests: manuelle Nachträge (Überlappung, 7 Tage, Zukunft, // 16 h), Freigabe/Ablehnung (Rollen, Team-Scope, nie eigene Einträge), Korrekturvorschlag (alte // Werte zählen bis zur Freigabe), Bericht-/Summen nur freigegeben, Abrechnungsfreigabe blockiert, // „Für heute beenden", Auto-Wechsel (conflict ohne Flag, atomar mit Flag), Segmente, Events, // Audit und Mandantentrennung. // // Lauf: npx tsx scripts/test-zeiterfassung-service.ts (lokale Postgres-DB aus .env) import "dotenv/config"; import { randomUUID } from "node:crypto"; import { prisma } from "../src/server/db"; import type { ServiceCtx } from "../src/server/services/context"; import { addManualTimeEntry, approveTimeEntries, approveTimeEntry, countPendingTimeEntries, getMyTimeOverview, listPendingTimeEntries, pendingTimeOfOrder, proposeTimeCorrection, rejectTimeEntry, } from "../src/server/services/field/time-entries"; import { getMyActiveSession, pauseSession, resumeSession, startSession, stopForToday, switchSegment } from "../src/server/services/field/sessions"; import { correctTimeEntry } from "../src/server/services/field/time-correction"; import { buildReportContent } from "../src/server/services/reports/build-content"; import { getTimesTab } from "../src/server/services/work-orders/detail"; import { getDashboardTiles } from "../src/server/services/work-orders/dashboard"; import { computeCompletionBlockers } from "../src/server/services/work-orders/completion"; import { releaseForBilling } from "../src/server/services/work-orders/release-billing"; import { sessionStartPayload, sessionControlPayload } from "../src/lib/sync/ops"; import { ServiceError } from "../src/server/services/context"; import { berlinAt, codeOf, createTenant, dayKey, expectCode, ok, runSuite, section, type TenantFixture } from "./lib/e2e-fixture"; const SLUG_A = "zz-l12-time-a"; const SLUG_B = "zz-l12-time-b"; async function order(A: TenantFixture, status: string, extra: Record = {}) { return prisma.workOrder.create({ data: { tenantId: A.tenantId, number: `Z-${randomUUID().slice(0, 6)}`, customerId: A.customerId, siteId: A.siteId, title: "Wärmepumpe", status: status as never, assignedTeamId: A.teamId, ...extra, }, }); } async function errorOf(p: Promise): Promise { try { await p; return null; } catch (err) { return err instanceof ServiceError ? err : null; } } const notif = (tenantId: string, userId: string, type: string, entityId: string) => prisma.notification.count({ where: { tenantId, userId, type, entityId } }); async function contentOf(ctx: ServiceCtx, workOrderId: string, date?: string) { return buildReportContent(ctx, { workOrderId, type: date ? "daily" : "completion", reportDate: date ?? dayKey(0), reportNumber: "B-TEST", version: 1, technicianUserId: null }); } runSuite("L12 Zeiterfassung (Service)", [SLUG_A, SLUG_B], async () => { const A = await createTenant(SLUG_A); const B = await createTenant(SLUG_B); const wo = await order(A, "in_progress", { assignees: { create: [{ tenantId: A.tenantId, userId: A.users.tech.id }] } }); const woB = await order(B, "in_progress"); section("Nachtragen: Validierung"); const base = { workOrderId: wo.id, type: "work" as const, reason: "Start vergessen" }; const y8 = berlinAt(-1, 8); const y10 = berlinAt(-1, 10); const e1 = await addManualTimeEntry(A.ctx.tech, { ...base, startedAt: y8, endedAt: y10 }); ok(e1.approvalStatus === "pending" && e1.source === "manual" && e1.correctionReason === "Start vergessen", "Monteur-Nachtrag → pending, source manual, Begründung gespeichert"); const manualSession = await prisma.workSession.findUnique({ where: { id: e1.workSessionId } }); ok(!!manualSession?.manual && manualSession.status === "ended" && manualSession.userId === A.users.tech.id, "manuelle Session (ended, manual) je User+Auftrag+Tag"); const e1b = await addManualTimeEntry(A.ctx.tech, { ...base, startedAt: berlinAt(-1, 13), durationMinutes: 30 }); ok(e1b.workSessionId === e1.workSessionId && e1b.endedAt!.getTime() - e1b.startedAt.getTime() === 30 * 60_000, "Dauer statt Ende; gleiche manuelle Session am selben Tag"); const overlap = await errorOf(addManualTimeEntry(A.ctx.tech, { ...base, startedAt: berlinAt(-1, 9), endedAt: berlinAt(-1, 11) })); ok(overlap?.code === "invalid" && overlap.message === "overlap" && (overlap.details as { reason?: string })?.reason === "overlap", "Überlappung mit eigenem Eintrag → invalid (overlap)"); ok((await errorOf(addManualTimeEntry(A.ctx.tech, { ...base, startedAt: berlinAt(-9, 8), endedAt: berlinAt(-9, 9) })))?.message === "too_old", "älter als 7 Tage → invalid (too_old)"); ok((await errorOf(addManualTimeEntry(A.ctx.tech, { ...base, startedAt: new Date(Date.now() + 3600_000), durationMinutes: 30 })))?.message === "in_future", "in der Zukunft → invalid (in_future)"); ok((await errorOf(addManualTimeEntry(A.ctx.tech, { ...base, startedAt: berlinAt(-2, 1), endedAt: berlinAt(-2, 18) })))?.message === "too_long", "länger als 16 h → invalid (too_long)"); ok((await errorOf(addManualTimeEntry(A.ctx.tech, { ...base, startedAt: y10, endedAt: y8 })))?.code === "invalid", "Ende vor Beginn → invalid"); await expectCode(() => addManualTimeEntry(A.ctx.tech, { ...base, startedAt: berlinAt(-3, 8), durationMinutes: 30, reason: "" }), "invalid", "ohne Begründung → invalid"); await expectCode(() => addManualTimeEntry(A.ctx.outsider, { ...base, startedAt: berlinAt(-3, 8), durationMinutes: 30 }), "not_found", "Monteur ohne Zuweisung (Team Süd) → not_found"); await expectCode(() => addManualTimeEntry(A.ctx.backoffice, { ...base, startedAt: berlinAt(-3, 8), durationMinutes: 30 }), "forbidden", "Backoffice ohne field:record_own_time → forbidden"); await expectCode(() => addManualTimeEntry(A.ctx.tech, { ...base, startedAt: berlinAt(-3, 8), durationMinutes: 30, forUserId: A.users.tech2.id }), "forbidden", "Monteur für Kollegen → forbidden"); section("Events + Audit"); ok((await notif(A.tenantId, A.users.lead.id, "time.approval_requested", e1.id)) === 1, "time.approval_requested → Teamleiter des Auftrags-Teams (In-App)"); ok((await notif(A.tenantId, A.users.tech.id, "time.approval_requested", e1.id)) === 0, "… nicht an den Monteur selbst"); ok((await notif(A.tenantId, A.users.backoffice.id, "time.approval_requested", e1.id)) === 0, "… Backoffice nur ohne zuständigen Teamleiter"); ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "time_entry", entityId: e1.id, action: "create" } })) === 1, "Audit create für den Nachtrag"); section("Pending zählt nicht"); let content = await contentOf(A.ctx.backoffice, wo.id, dayKey(-1)); ok(content.time.totalMinutes === 0 && content.time.pendingMinutes === 150, `Tagesbericht: 0 min gezählt, 150 min zur Freigabe offen (${content.time.totalMinutes}/${content.time.pendingMinutes})`); const tab = await getTimesTab(A.ctx.backoffice, wo.id); ok(tab.reduce((s, x) => s + x.workMinutes, 0) === 0 && tab.reduce((s, x) => s + x.pendingMinutes, 0) === 150, "Backoffice Zeiten-Tab: freigegeben 0, offen 150"); const ov = await getMyTimeOverview(A.ctx.tech, { date: dayKey(-1) }); ok(ov.approvedSeconds === 0 && ov.pendingSeconds === 150 * 60 && ov.groups[0]?.entries.length === 2, "Meine Zeiten: getrennt freigegeben/offen"); ok((await pendingTimeOfOrder(A.ctx.backoffice, wo.id)).pendingEntries === 2, "pendingTimeOfOrder zählt offene Nachträge"); section("Freigabe: Rollen und Scope"); await expectCode(() => approveTimeEntry(A.ctx.tech, e1.id), "forbidden", "Monteur darf nicht freigeben"); await expectCode(() => approveTimeEntry(A.ctx.tech2, e1.id), "forbidden", "Monteur-Kollege darf nicht freigeben"); await expectCode(() => approveTimeEntry(A.ctx.lead2, e1.id), "not_found", "Teamleiter Süd sieht Eintrag von Team Nord nicht → not_found"); const leadOwn = await addManualTimeEntry(A.ctx.lead, { ...base, startedAt: berlinAt(-1, 8), endedAt: berlinAt(-1, 9) }); ok(leadOwn.approvalStatus === "pending", "Teamleiter-eigener Nachtrag → ebenfalls pending"); await expectCode(() => approveTimeEntry(A.ctx.lead, leadOwn.id), "forbidden", "Teamleiter kann eigenen Eintrag nicht freigeben"); ok((await notif(A.tenantId, A.users.backoffice.id, "time.approval_requested", leadOwn.id)) === 1, "eigener Teamleiter-Nachtrag → Backoffice benachrichtigt (kein anderer Teamleiter)"); await expectCode(() => correctTimeEntry(A.ctx.lead, { timeEntryId: leadOwn.id, startedAt: berlinAt(-1, 8), endedAt: berlinAt(-1, 9), reason: "selbst" }), "forbidden", "Direktkorrektur eigener Einträge → forbidden"); const leadList = await listPendingTimeEntries(A.ctx.lead); ok(leadList.some((i) => i.id === e1.id) && !leadList.some((i) => i.id === leadOwn.id), "Teamleiter-Liste: Team-Einträge, nicht die eigenen"); ok((await listPendingTimeEntries(A.ctx.lead2)).length === 0, "Teamleiter Süd: leere Liste"); const boList = await listPendingTimeEntries(A.ctx.backoffice); ok(boList.some((i) => i.id === e1.id) && boList.some((i) => i.id === leadOwn.id), "Backoffice-Liste: alle"); ok((await countPendingTimeEntries(A.ctx.lead)) === 2 && (await countPendingTimeEntries(A.ctx.tech)) === 0, "Badge-Zähler (Teamleiter 2, Monteur 0)"); ok((await getDashboardTiles(A.ctx.backoffice, {})).timeApprovals === 3, "Dashboard-Kachel „Zeiten zur Freigabe“"); const approved = await approveTimeEntry(A.ctx.lead, e1.id); ok(approved.approvalStatus === "approved" && approved.approvedById === A.users.lead.id && !!approved.approvedAt, "Teamleiter gibt Team-Eintrag frei"); await expectCode(() => approveTimeEntry(A.ctx.lead, e1.id), "invalid", "zweite Freigabe → invalid (nicht offen)"); ok((await notif(A.tenantId, A.users.tech.id, "time.approved", e1.id)) === 1, "time.approved → Monteur (In-App)"); const boApproved = await approveTimeEntries(A.ctx.backoffice, [e1b.id, leadOwn.id]); ok(boApproved.every((r) => r.ok), "Backoffice Sammelfreigabe (auch Teamleiter-Eintrag)"); content = await contentOf(A.ctx.backoffice, wo.id, dayKey(-1)); ok(content.time.totalMinutes === 210 && (content.time.pendingMinutes ?? 0) === 0, `nach Freigabe zählt der Nachtrag im Bericht (${content.time.totalMinutes} min)`); section("Ablehnung"); const e2 = await addManualTimeEntry(A.ctx.tech, { ...base, startedAt: berlinAt(-2, 8), endedAt: berlinAt(-2, 9) }); await expectCode(() => rejectTimeEntry(A.ctx.backoffice, e2.id, ""), "invalid", "Ablehnen ohne Grund → invalid"); const rejected = await rejectTimeEntry(A.ctx.backoffice, e2.id, "Nicht plausibel"); ok(rejected.approvalStatus === "rejected" && rejected.rejectionReason === "Nicht plausibel", "abgelehnt mit Grund"); ok((await notif(A.tenantId, A.users.tech.id, "time.rejected", e2.id)) === 1, "time.rejected → Monteur"); const notification = await prisma.notification.findFirst({ where: { tenantId: A.tenantId, userId: A.users.tech.id, type: "time.rejected", entityId: e2.id } }); ok(!!notification?.message.includes("Nicht plausibel") && notification.link === "/m/time", "Text enthält den Grund, Link „Meine Zeiten“"); content = await contentOf(A.ctx.backoffice, wo.id, dayKey(-2)); ok(content.time.totalMinutes === 0, "abgelehnter Eintrag zählt nicht"); const again = await addManualTimeEntry(A.ctx.tech, { ...base, startedAt: berlinAt(-2, 8), endedAt: berlinAt(-2, 9), reason: "Neu erfasst" }); ok(again.approvalStatus === "pending", "abgelehnter Zeitraum blockiert keinen neuen Nachtrag"); section("Korrekturvorschlag"); const tracked = await prisma.workSession.create({ data: { tenantId: A.tenantId, workOrderId: wo.id, userId: A.users.tech.id, status: "ended", startedAt: berlinAt(-3, 8), endedAt: berlinAt(-3, 12) } }); const te = await prisma.timeEntry.create({ data: { tenantId: A.tenantId, workSessionId: tracked.id, userId: A.users.tech.id, type: "work", startedAt: berlinAt(-3, 8), endedAt: berlinAt(-3, 12) } }); await expectCode(() => proposeTimeCorrection(A.ctx.tech2, { timeEntryId: te.id, startedAt: berlinAt(-3, 9), endedAt: berlinAt(-3, 12), reason: "fremd" }), "forbidden", "Vorschlag für fremden Eintrag → forbidden"); const proposed = await proposeTimeCorrection(A.ctx.tech, { timeEntryId: te.id, startedAt: berlinAt(-3, 8, 30), endedAt: berlinAt(-3, 12), reason: "Start zu früh gestempelt" }); ok(proposed.approvalStatus === "approved" && proposed.startedAt.getTime() === berlinAt(-3, 8).getTime() && !!proposed.pendingChange, "Vorschlag: alte Werte bleiben, pendingChange gesetzt"); content = await contentOf(A.ctx.backoffice, wo.id, dayKey(-3)); ok(content.time.totalMinutes === 240, "bis zur Freigabe zählt der alte Wert (240 min)"); ok((await notif(A.tenantId, A.users.lead.id, "time.approval_requested", te.id)) === 1, "Event für Korrekturvorschlag"); await expectCode(() => approveTimeEntry(A.ctx.lead2, te.id), "not_found", "fremder Teamleiter → not_found"); const corrected = await approveTimeEntry(A.ctx.lead, te.id); ok(corrected.corrected && corrected.startedAt.getTime() === berlinAt(-3, 8, 30).getTime() && corrected.pendingChange === null && corrected.correctionReason === "Start zu früh gestempelt", "nach Freigabe: neue Werte + corrected + Grund"); content = await contentOf(A.ctx.backoffice, wo.id, dayKey(-3)); ok(content.time.totalMinutes === 210, "danach zählt der neue Wert (210 min)"); await proposeTimeCorrection(A.ctx.tech, { timeEntryId: te.id, startedAt: berlinAt(-3, 7), endedAt: berlinAt(-3, 12), reason: "doch früher" }); await rejectTimeEntry(A.ctx.lead, te.id, "Stempelzeit passt"); const afterReject = await prisma.timeEntry.findUniqueOrThrow({ where: { id: te.id } }); ok(afterReject.approvalStatus === "approved" && afterReject.pendingChange === null && afterReject.startedAt.getTime() === berlinAt(-3, 8, 30).getTime() && afterReject.rejectionReason === "Stempelzeit passt", "abgelehnter Vorschlag: Eintrag unverändert gültig, Grund sichtbar"); section("Direkt für Teammitglied (field:correct_time)"); const direct = await addManualTimeEntry(A.ctx.lead, { ...base, startedAt: berlinAt(-1, 14), endedAt: berlinAt(-1, 15), forUserId: A.users.tech2.id }); ok(direct.approvalStatus === "approved" && direct.source === "manual" && direct.userId === A.users.tech2.id && direct.approvedById === A.users.lead.id, "Teamleiter legt Eintrag für Teammitglied an → approved, manual"); await expectCode(() => addManualTimeEntry(A.ctx.lead, { ...base, startedAt: berlinAt(-1, 14), endedAt: berlinAt(-1, 15), forUserId: A.users.outsider.id }), "forbidden", "Teamleiter für Nicht-Teammitglied → forbidden"); await expectCode(() => addManualTimeEntry(A.ctx.lead2, { ...base, startedAt: berlinAt(-1, 14), endedAt: berlinAt(-1, 15), forUserId: A.users.tech.id }), "not_found", "Teamleiter Süd auf Auftrag von Team Nord → not_found"); section("Abrechnungsfreigabe blockiert bei offenen Zeiten"); const review = await order(A, "in_review", { assignees: { create: [{ tenantId: A.tenantId, userId: A.users.tech.id }] } }); await prisma.report.create({ data: { tenantId: A.tenantId, workOrderId: review.id, type: "completion", reportDate: new Date(), lineageId: randomUUID(), status: "approved", content: {} } }); const late = await addManualTimeEntry(A.ctx.tech, { ...base, workOrderId: review.id, startedAt: berlinAt(-4, 8), endedAt: berlinAt(-4, 9) }); const blocked = await errorOf(releaseForBilling(A.ctx.backoffice, { workOrderId: review.id })); ok(blocked?.code === "blocked" && JSON.stringify(blocked.details).includes("pending_time_entries"), "release-billing → blocked (pending_time_entries)"); await approveTimeEntry(A.ctx.backoffice, late.id); ok((await codeOf(releaseForBilling(A.ctx.backoffice, { workOrderId: review.id }))) === "ok", "nach Freigabe → Abrechnungsfreigabe möglich"); section("Für heute beenden"); const multi = await order(A, "accepted", { assignees: { create: [{ tenantId: A.tenantId, userId: A.users.tech.id }] } }); await startSession(A.ctx.tech, sessionStartPayload.parse({ workOrderId: multi.id, mode: "work" })); const stopped = await stopForToday(A.ctx.tech, sessionControlPayload.parse({ workOrderId: multi.id })); const multiAfter = await prisma.workOrder.findUniqueOrThrow({ where: { id: multi.id } }); ok(stopped.status === "ended" && multiAfter.status === "paused", `Session beendet, Auftrag bleibt offen (${multiAfter.status})`); ok(!(await computeCompletionBlockers(A.ctx.backoffice, multi.id)).some((b) => b.kind === "running_session"), "keine laufende Session mehr (kein Abschluss-Blocker)"); await expectCode(() => stopForToday(A.ctx.tech, sessionControlPayload.parse({ workOrderId: multi.id })), "invalid", "erneut beenden ohne Session → invalid"); section("Auto-Wechsel"); const woA = await order(A, "accepted", { assignees: { create: [{ tenantId: A.tenantId, userId: A.users.tech.id }] } }); const woB2 = await order(A, "accepted", { assignees: { create: [{ tenantId: A.tenantId, userId: A.users.tech.id }] } }); await startSession(A.ctx.tech, sessionStartPayload.parse({ workOrderId: woA.id, mode: "work" })); const conflict = await errorOf(startSession(A.ctx.tech, sessionStartPayload.parse({ workOrderId: woB2.id, mode: "work" }))); ok(conflict?.code === "conflict" && conflict.message === "other_session_running" && (conflict.details as { number?: string })?.number === woA.number, "ohne Flag → conflict other_session_running mit Auftragsnummer"); ok((await prisma.workSession.count({ where: { workOrderId: woB2.id } })) === 0, "… auf dem zweiten Auftrag nichts angelegt"); // failing start with flag (travel while the target already has an own session is impossible here) → use an invalid target status const planned = await order(A, "planned", { assignees: { create: [{ tenantId: A.tenantId, userId: A.users.tech.id }] } }); await expectCode(() => startSession(A.ctx.tech, sessionStartPayload.parse({ workOrderId: planned.id, mode: "work", switchFromOther: true })), "invalid", "Wechsel auf nicht startbaren Auftrag → invalid"); ok((await prisma.workSession.findFirstOrThrow({ where: { workOrderId: woA.id, userId: A.users.tech.id } })).status === "running", "… andere Session läuft unverändert weiter (atomar)"); const switched = await startSession(A.ctx.tech, sessionStartPayload.parse({ workOrderId: woB2.id, mode: "work", switchFromOther: true })); const sessA = await prisma.workSession.findFirstOrThrow({ where: { workOrderId: woA.id, userId: A.users.tech.id }, include: { entries: { orderBy: { startedAt: "asc" } } } }); const woAAfter = await prisma.workOrder.findUniqueOrThrow({ where: { id: woA.id } }); ok(switched.status === "running" && sessA.status === "paused" && sessA.entries.at(-1)?.type === "break" && !sessA.entries.at(-1)?.endedAt && woAAfter.status === "paused", "mit Flag: andere Session pausiert (Pause-Segment, Auftrag paused), neue läuft"); ok((await prisma.workSession.count({ where: { userId: A.users.tech.id, status: { in: ["running", "en_route"] } } })) === 1, "genau eine laufende Uhr je Nutzer"); const resumeConflict = await errorOf(resumeSession(A.ctx.tech, { workOrderId: woA.id })); ok(resumeConflict?.message === "other_session_running", "Weiter auf A während B läuft → ebenfalls conflict"); section("Segmente + aktive Session"); await switchSegment(A.ctx.tech, { workOrderId: woB2.id, type: "return_travel" }); let active = await getMyActiveSession(A.ctx.tech); ok(active?.workOrderId === woB2.id && active.segmentType === "return_travel" && active.number === woB2.number && active.title === "Wärmepumpe", "getMyActiveSession: Auftrag, Nummer, Titel, Segment Rückfahrt"); await expectCode(() => switchSegment(A.ctx.tech, { workOrderId: woB2.id, type: "return_travel" }), "invalid", "gleiches Segment erneut → invalid"); await switchSegment(A.ctx.tech, { workOrderId: woB2.id, type: "material_procurement" }); await pauseSession(A.ctx.tech, { workOrderId: woB2.id }); active = await getMyActiveSession(A.ctx.tech); ok(active?.status === "paused" && active.segmentType === "break", "pausierte Session wird angezeigt (neueste)"); const types = (await prisma.timeEntry.findMany({ where: { workSession: { workOrderId: woB2.id } }, orderBy: { startedAt: "asc" } })).map((e) => e.type).join(","); ok(types === "work,return_travel,material_procurement,break", `Segmentfolge ${types}`); section("Mandantentrennung"); await expectCode(() => approveTimeEntry(B.ctx.backoffice, again.id), "not_found", "Mandant B (Backoffice) kann A-Eintrag nicht freigeben"); await expectCode(() => rejectTimeEntry(B.ctx.admin, again.id, "fremd"), "not_found", "Mandant B (Admin) kann A-Eintrag nicht ablehnen"); ok(!(await listPendingTimeEntries(B.ctx.backoffice)).some((i) => i.id === again.id), "Mandant B sieht A-Einträge nicht in der Liste"); await expectCode(() => addManualTimeEntry(B.ctx.tech, { ...base, startedAt: berlinAt(-5, 8), durationMinutes: 30 }), "not_found", "Mandant B kann auf A-Auftrag nicht nachtragen"); await expectCode(() => proposeTimeCorrection(B.ctx.tech, { timeEntryId: te.id, startedAt: berlinAt(-3, 9), endedAt: berlinAt(-3, 12), reason: "fremd" }), "not_found", "Mandant B kann A-Eintrag keine Korrektur vorschlagen"); ok((await getMyTimeOverview(B.ctx.tech, { date: dayKey(-1) })).groups.length === 0, "Meine Zeiten in B ohne A-Daten"); ok((await countPendingTimeEntries(B.ctx.backoffice)) === 0 && !!woB, "Badge in B = 0"); const aStill = await prisma.timeEntry.findUniqueOrThrow({ where: { id: again.id } }); ok(aStill.approvalStatus === "pending", "A-Eintrag unverändert"); });