From 9f4eecf6e2aced941c76737db4dd880cf106a6bf Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 15 Sep 2026 09:29:46 +0200 Subject: [PATCH] L12 Zeiterfassung: Tests und authentifizierter Smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test-zeiterfassung-service (78 Prüfungen) und test-zeiterfassung-sync (29 Prüfungen), Rollen-Matrix um field:record_own_time und time:approve ergänzt, smoke-auth um /m/time, /m/time/new, /m/approvals, Uhr-Leiste und /work-orders/time-approvals. Co-Authored-By: Claude Opus 5 --- scripts/smoke-auth.ts | 16 +- scripts/test-security-roles.ts | 16 ++ scripts/test-zeiterfassung-service.ts | 237 ++++++++++++++++++++++++++ scripts/test-zeiterfassung-sync.ts | 141 +++++++++++++++ 4 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 scripts/test-zeiterfassung-service.ts create mode 100644 scripts/test-zeiterfassung-sync.ts diff --git a/scripts/smoke-auth.ts b/scripts/smoke-auth.ts index 2ef0bca..d2fab2e 100644 --- a/scripts/smoke-auth.ts +++ b/scripts/smoke-auth.ts @@ -81,6 +81,9 @@ function plans(x: Awaited>): Plan[] { ...tabs.map((t) => ({ path: wo(x.d07, `?tab=${t}`), mustContain: x.d07.number })), { path: wo(x.d18, "?tab=reports"), mustContain: x.d18.number }, { path: "/work-orders/conflicts" }, + // L12 Zeiterfassung + { path: "/work-orders/time-approvals", mustContain: "Zeiten zur Freigabe" }, + { path: "/dashboard", mustContain: "Zeiten zur Freigabe" }, { path: "/work-orders/emergency-review", mustContain: x.emergency?.number }, ...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, mustContain: x.emergency.number }] : []), { path: "/imports" }, @@ -113,6 +116,10 @@ function plans(x: Awaited>): Plan[] { { path: "/reports" }, ...(x.inReview ? [{ path: `/reports/${x.inReview.id}`, mustContain: x.d16?.number }] : []), { path: m(x.d08), expect: [404] }, + // L12 Zeiterfassung + { path: "/m/time", mustContain: ["Meine Zeiten", "Zeit nachtragen"] }, + { path: "/m/approvals", mustContain: "Zeiten freigeben" }, + { path: "/m/profile", mustContain: "Zeiten freigeben" }, ], }, { @@ -136,7 +143,14 @@ function plans(x: Awaited>): Plan[] { { path: "/m/emergency" }, { path: "/m/sync" }, { path: "/m/offline" }, - { path: "/m/profile", mustContain: "Max Monteur" }, + { path: "/m/profile", mustContain: ["Max Monteur", "Meine Zeiten"] }, + // L12 Zeiterfassung + { path: "/m", mustContain: ["Laufende Zeiterfassung", "Für heute beenden"] }, + { path: "/m/time", mustContain: ["Meine Zeiten", "Zeit nachtragen"] }, + { path: `/m/time?date=${new Date(Date.now() - 86400_000).toISOString().slice(0, 10)}`, mustContain: "Meine Zeiten" }, + { path: "/m/time/new", mustContain: ["Zeit nachtragen", "Begründung"] }, + { path: "/m/approvals", mustContain: "nicht freigeschaltet" }, + { path: m(x.d07, "/time"), mustContain: x.d07.number }, ...(x.photo ? [{ path: `/files/${x.photo.documentId}`, expect: [200] }] : []), { path: m(x.d08), expect: [404] }, { path: `/customers/${x.foreignCustomer.id}`, expect: [404, 307] }, diff --git a/scripts/test-security-roles.ts b/scripts/test-security-roles.ts index 6c8e0f5..1a556d9 100644 --- a/scripts/test-security-roles.ts +++ b/scripts/test-security-roles.ts @@ -26,6 +26,7 @@ import { createOrderType } from "../src/server/services/work-orders/settings"; import { listImports } from "../src/server/services/imports/queries"; import { createNote } from "../src/server/services/field/notes"; import { correctTimeEntry } from "../src/server/services/field/time-correction"; +import { addManualTimeEntry, approveTimeEntry } from "../src/server/services/field/time-entries"; import { createDailyReport } from "../src/server/services/reports/create"; import { approveReport } from "../src/server/services/reports/approve"; import { listReports } from "../src/server/services/reports/queries"; @@ -98,6 +99,21 @@ runSuite("Sicherheit: Rollen-Matrix", [SLUG], async () => { { permission: "import:write", label: "Importe", run: (c) => listImports(c) }, { permission: "field:execute", label: "Tätigkeitsnotiz erfassen", run: (c) => createNote(c, noteCreatePayload.parse({ workOrderId: shared.id, text: "Rollenprüfung" })) }, { permission: "field:correct_time", label: "Arbeitszeit korrigieren", run: (c) => correctTimeEntry(c, { timeEntryId: entry.id, startedAt: session.startedAt, endedAt: session.endedAt, reason: "Rollenprüfung" }) }, + // L12 Zeiterfassung + { + permission: "field:record_own_time", + label: "Eigene Zeit nachtragen", + run: (c) => addManualTimeEntry(c, { workOrderId: shared.id, type: "work", startedAt: new Date(Date.now() - 26 * 3600_000), durationMinutes: 30, reason: "Rollenprüfung" }), + }, + { + permission: "time:approve", + label: "Zeitnachtrag freigeben", + run: async (c) => { + const s = await prisma.workSession.create({ data: { tenantId: A.tenantId, workOrderId: shared.id, userId: A.users.tech2.id, status: "ended", manual: true, startedAt: new Date(Date.now() - 7200_000), endedAt: new Date(Date.now() - 3600_000) } }); + const e = await prisma.timeEntry.create({ data: { tenantId: A.tenantId, workSessionId: s.id, userId: A.users.tech2.id, startedAt: s.startedAt, endedAt: s.endedAt, source: "manual", approvalStatus: "pending", correctionReason: "Rollenprüfung" } }); + return approveTimeEntry(c, e.id); + }, + }, { permission: "report:write", label: "Tagesbericht anlegen", run: async (c) => createDailyReport(c, { workOrderId: (await rawOrder(A, "planned")).id }) }, { permission: "report:approve_team", label: "Bericht freigeben (Teamleiter oder Backoffice)", run: (c) => approveReport(c, { reportId: draftReport.id }, { dispatchPdf: async () => undefined }) }, { permission: "emergency:create", label: "Notdienst-Kundensuche", run: (c) => searchCustomersForEmergency(c, "Hausverwaltung") }, diff --git a/scripts/test-zeiterfassung-service.ts b/scripts/test-zeiterfassung-service.ts new file mode 100644 index 0000000..9372f01 --- /dev/null +++ b/scripts/test-zeiterfassung-service.ts @@ -0,0 +1,237 @@ +// 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"); +}); diff --git a/scripts/test-zeiterfassung-sync.ts b/scripts/test-zeiterfassung-sync.ts new file mode 100644 index 0000000..4dd7d6b --- /dev/null +++ b/scripts/test-zeiterfassung-sync.ts @@ -0,0 +1,141 @@ +// Lane L12 „Zeiterfassung" — Sync/Offline: neue Ops (time.add_manual, time.propose_correction, +// session.stop_day, session.segment, session.start mit switchFromOther) über denselben Pfad wie +// die Outbox (applyOperations), Idempotenz (gleiche clientOpId → duplicate), Überlappung → rejected +// mit Klartext-Schlüssel, Auto-Wechsel-Konflikt mit Auftragsnummer, optimistische lokale Ansicht, +// Mandantentrennung und Scope. +// +// Lauf: npx tsx scripts/test-zeiterfassung-sync.ts + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { prisma } from "../src/server/db"; +import { applyOperations } from "../src/server/services/sync/apply"; +import type { SyncOperationInput, SyncOpType } from "../src/lib/sync/envelope"; +import { OP_PAYLOAD_SCHEMAS } from "../src/lib/sync/ops"; +import { VERSION_CHANGING_OPS, createEntry, problemKey } from "../src/lib/offline/outbox-core"; +import { buildOrderView } from "../src/lib/offline/bundle-core"; +import type { BundleOrderData } from "../src/lib/offline/types"; +import { otherSessionOf, timeErrorKey } from "../src/lib/field/time-rules"; +import { berlinAt, createTenant, ok, runSuite, section, type TenantFixture } from "./lib/e2e-fixture"; + +const SLUG_A = "zz-l12-sync-a"; +const SLUG_B = "zz-l12-sync-b"; +const DEVICE = "device-l12"; + +const op = (opType: SyncOpType, payload: Record, clientOpId: string = randomUUID()): SyncOperationInput => ({ + clientOpId, + opType, + payload, + clientCreatedAt: new Date().toISOString(), +}); + +async function order(A: TenantFixture, status: string) { + return prisma.workOrder.create({ + data: { + tenantId: A.tenantId, + number: `S-${randomUUID().slice(0, 6)}`, + customerId: A.customerId, + siteId: A.siteId, + title: "Heizung", + status: status as never, + assignedTeamId: A.teamId, + assignees: { create: [{ tenantId: A.tenantId, userId: A.users.tech.id }] }, + }, + }); +} + +runSuite("L12 Zeiterfassung (Sync/Offline)", [SLUG_A, SLUG_B], async () => { + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + const wo1 = await order(A, "accepted"); + const wo2 = await order(A, "accepted"); + + section("Payload-Schemas"); + ok(!OP_PAYLOAD_SCHEMAS["time.add_manual"].safeParse({ workOrderId: wo1.id, startedAt: new Date().toISOString(), durationMinutes: 30 }).success, "time.add_manual ohne Begründung → ungültig"); + ok(OP_PAYLOAD_SCHEMAS["session.start"].safeParse({ workOrderId: wo1.id, switchFromOther: true }).success, "session.start akzeptiert switchFromOther"); + ok(!OP_PAYLOAD_SCHEMAS["session.segment"].safeParse({ workOrderId: wo1.id, type: "break" }).success, "session.segment nur work/return_travel/material_procurement"); + + section("Ops angewendet + idempotent"); + const manualClient = randomUUID(); + const batch = [ + op("time.add_manual", { workOrderId: wo1.id, clientId: manualClient, type: "work", startedAt: berlinAt(-1, 8).toISOString(), endedAt: berlinAt(-1, 9).toISOString(), reason: "Kein Netz" }), + op("session.start", { workOrderId: wo1.id, mode: "work", clientId: randomUUID(), offline: true }), + op("session.segment", { workOrderId: wo1.id, type: "material_procurement" }), + op("session.stop_day", { workOrderId: wo1.id }), + ]; + const first = await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: batch }); + ok(first.results.every((r) => r.status === "applied"), `4 Ops applied (${first.results.map((r) => `${r.status}${r.message ? `:${r.message}` : ""}`).join(", ")})`); + const manualId = first.results[0].idMap?.[manualClient]; + const manual = manualId ? await prisma.timeEntry.findUnique({ where: { id: manualId } }) : null; + ok(manual?.approvalStatus === "pending" && manual.source === "manual", "idMap liefert den Nachtrag (pending, manual)"); + const wo1After = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo1.id } }); + ok(wo1After.status === "paused" && first.results[3].entityVersion === wo1After.version, `stop_day: Auftrag paused, entityVersion ${first.results[3].entityVersion}`); + const countBefore = await prisma.timeEntry.count({ where: { tenantId: A.tenantId } }); + const again = await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: batch }); + ok(again.results.every((r) => r.status === "duplicate"), "gleiche clientOpIds → duplicate"); + ok((await prisma.timeEntry.count({ where: { tenantId: A.tenantId } })) === countBefore, "keine Doppelanlage"); + ok(again.results[0].idMap?.[manualClient] === manualId, "duplicate liefert gespeicherte idMap"); + ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.lead.id, type: "time.approval_requested", entityId: manualId } })) === 1, "Event über Sync ausgelöst (Teamleiter)"); + + section("Überlappung → rejected mit Klartext"); + const overlapOp = op("time.add_manual", { workOrderId: wo1.id, clientId: randomUUID(), type: "work", startedAt: berlinAt(-1, 8, 30).toISOString(), endedAt: berlinAt(-1, 9, 30).toISOString(), reason: "Kein Netz" }); + const [overlap] = (await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: [overlapOp] })).results; + ok(overlap.status === "rejected" && overlap.errorCode === "invalid" && overlap.message === "overlap", "Überlappung → rejected invalid (overlap)"); + ok(timeErrorKey(overlap) === "overlap", "Client-Schlüssel field.myTime.errors.overlap"); + ok(problemKey({ status: "rejected", opType: "time.add_manual", lastError: { code: "invalid", message: overlap.message } }) === "timeOverlap", "/m/sync: Klartext offline.problem.timeOverlap"); + ok(problemKey({ status: "rejected", opType: "time.add_manual", lastError: { code: "invalid", message: "too_old" } }) === "timeWindow", "/m/sync: Zeitfenster → offline.problem.timeWindow"); + const stored = await prisma.syncOperation.findFirst({ where: { clientOpId: overlapOp.clientOpId } }); + ok(stored?.status === "rejected" && stored.errorCode === "invalid", "SyncOperation rejected gespeichert (deterministisch)"); + + section("Korrekturvorschlag über Sync"); + const workEntry = await prisma.timeEntry.findFirstOrThrow({ where: { userId: A.users.tech.id, source: "tracked", type: "work", workSession: { workOrderId: wo1.id } } }); + const [proposal] = ( + await applyOperations(A.ctx.tech, { + deviceId: DEVICE, + operations: [op("time.propose_correction", { workOrderId: wo1.id, timeEntryId: workEntry.id, startedAt: new Date(workEntry.startedAt.getTime() - 10 * 60_000).toISOString(), endedAt: workEntry.endedAt!.toISOString(), reason: "Start vergessen" })], + }) + ).results; + const proposed = await prisma.timeEntry.findUniqueOrThrow({ where: { id: workEntry.id } }); + ok(proposal.status === "applied" && !!proposed.pendingChange && proposed.startedAt.getTime() === workEntry.startedAt.getTime(), "time.propose_correction applied, alte Werte bleiben"); + + section("Auto-Wechsel über Sync"); + const [resume1] = (await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: [op("session.start", { workOrderId: wo1.id, mode: "work", clientId: randomUUID() })] })).results; + ok(resume1.status === "applied", "Arbeit auf Auftrag 1 gestartet"); + const conflictOp = op("session.start", { workOrderId: wo2.id, mode: "work", clientId: randomUUID() }); + const [conflict] = (await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: [conflictOp] })).results; + const other = otherSessionOf(conflict); + ok(conflict.status === "rejected" && conflict.errorCode === "conflict" && other?.number === wo1.number && other.workOrderId === wo1.id, `ohne Flag → rejected conflict mit Auftragsnummer ${other?.number}`); + ok(problemKey({ status: "rejected", opType: "session.start", lastError: { code: "conflict", message: conflict.message } }) === "otherSession", "/m/sync: Klartext offline.problem.otherSession"); + const [dupConflict] = (await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: [conflictOp] })).results; + ok(dupConflict.status === "duplicate" && dupConflict.errorCode === "conflict", "Wiederholung der abgelehnten Op → duplicate (Ergebnis gespeichert)"); + const [switched] = (await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: [op("session.start", { workOrderId: wo2.id, mode: "work", clientId: randomUUID(), switchFromOther: true })] })).results; + const s1 = await prisma.workSession.findFirstOrThrow({ where: { workOrderId: wo1.id, userId: A.users.tech.id, status: { not: "ended" } } }); + ok(switched.status === "applied" && s1.status === "paused", "mit switchFromOther → applied, Auftrag 1 pausiert"); + ok(VERSION_CHANGING_OPS.includes("session.stop_day") && VERSION_CHANGING_OPS.includes("session.segment"), "Outbox verkettet baseVersion auch nach stop_day/segment"); + + section("Optimistische lokale Ansicht"); + const ctx = { tenantId: A.tenantId, userId: A.users.tech.id }; + const now = new Date(); + const data = { + id: wo2.id, number: wo2.number, title: "Heizung", status: "in_progress", statusGroup: "in_progress", priority: "normal", isEmergency: false, + plannedStart: null, plannedEnd: null, version: 3, mySession: { id: "s", status: "running", startedAt: now.toISOString() }, + customer: { companyName: "K", firstName: null, lastName: null, street: null, houseNumber: null, postalCode: null, city: null }, + checklistItems: [], photoRequirements: [], materialPlans: [], materialUsages: [], documents: [], siteHistory: [], + } as BundleOrderData; + const stopEntry = createEntry(ctx, { opType: "session.stop_day", payload: { workOrderId: wo2.id, at: now.toISOString() } }, { clientOpId: randomUUID(), now, existing: [] }); + const manualEntry = createEntry(ctx, { opType: "time.add_manual", payload: { workOrderId: wo2.id, clientId: randomUUID(), type: "work", startedAt: berlinAt(-2, 8).toISOString(), durationMinutes: 30, reason: "Kein Netz" } }, { clientOpId: randomUUID(), now, existing: [stopEntry] }); + const view = buildOrderView({ ctxKey: `${ctx.tenantId}:${ctx.userId}`, workOrderId: wo2.id, data, syncedAt: now.toISOString() }, [stopEntry, manualEntry]); + ok(view.local.session === null && view.status === "paused", "stop_day offline: Uhr aus, Auftrag pausiert (offen)"); + ok(view.local.manualTimes.length === 1 && view.local.manualTimes[0].pending && view.local.manualTimes[0].durationMinutes === 30, "add_manual offline: Nachtrag mit pending-Badge sichtbar"); + + section("Mandantentrennung + Scope"); + const [foreign] = (await applyOperations(B.ctx.tech, { deviceId: DEVICE, operations: [op("time.add_manual", { workOrderId: wo1.id, type: "work", startedAt: berlinAt(-2, 8).toISOString(), durationMinutes: 30, reason: "fremd" })] })).results; + ok(foreign.status === "rejected" && foreign.errorCode === "not_found", "Mandant B: time.add_manual auf A-Auftrag → not_found"); + const [foreignStop] = (await applyOperations(B.ctx.tech, { deviceId: DEVICE, operations: [op("session.stop_day", { workOrderId: wo2.id })] })).results; + ok(foreignStop.status === "rejected" && foreignStop.errorCode === "not_found", "Mandant B: session.stop_day → not_found"); + const [foreignProposal] = (await applyOperations(B.ctx.tech, { deviceId: DEVICE, operations: [op("time.propose_correction", { workOrderId: wo1.id, timeEntryId: workEntry.id, startedAt: berlinAt(-1, 8).toISOString(), endedAt: berlinAt(-1, 9).toISOString(), reason: "fremd" })] })).results; + ok(foreignProposal.status === "rejected" && foreignProposal.errorCode === "not_found", "Mandant B: Korrekturvorschlag → not_found"); + const [outsider] = (await applyOperations(A.ctx.outsider, { deviceId: DEVICE, operations: [op("session.segment", { workOrderId: wo2.id, type: "return_travel" })] })).results; + ok(outsider.status === "rejected" && outsider.errorCode === "not_found", "Monteur ohne Zuweisung: session.segment → not_found"); + ok((await prisma.timeEntry.count({ where: { tenantId: B.tenantId } })) === 0, "in Mandant B nichts angelegt"); +});