L12 Zeiterfassung: Tests und authentifizierter Smoke

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 09:29:46 +02:00
co-authored by Claude Opus 5
parent fe3eba6e44
commit 9f4eecf6e2
4 changed files with 409 additions and 1 deletions
+15 -1
View File
@@ -81,6 +81,9 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
...tabs.map((t) => ({ path: wo(x.d07, `?tab=${t}`), mustContain: x.d07.number })), ...tabs.map((t) => ({ path: wo(x.d07, `?tab=${t}`), mustContain: x.d07.number })),
{ path: wo(x.d18, "?tab=reports"), mustContain: x.d18.number }, { path: wo(x.d18, "?tab=reports"), mustContain: x.d18.number },
{ path: "/work-orders/conflicts" }, { 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 }, { path: "/work-orders/emergency-review", mustContain: x.emergency?.number },
...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, mustContain: x.emergency.number }] : []), ...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, mustContain: x.emergency.number }] : []),
{ path: "/imports" }, { path: "/imports" },
@@ -113,6 +116,10 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
{ path: "/reports" }, { path: "/reports" },
...(x.inReview ? [{ path: `/reports/${x.inReview.id}`, mustContain: x.d16?.number }] : []), ...(x.inReview ? [{ path: `/reports/${x.inReview.id}`, mustContain: x.d16?.number }] : []),
{ path: m(x.d08), expect: [404] }, { 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<ReturnType<typeof ids>>): Plan[] {
{ path: "/m/emergency" }, { path: "/m/emergency" },
{ path: "/m/sync" }, { path: "/m/sync" },
{ path: "/m/offline" }, { 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] }] : []), ...(x.photo ? [{ path: `/files/${x.photo.documentId}`, expect: [200] }] : []),
{ path: m(x.d08), expect: [404] }, { path: m(x.d08), expect: [404] },
{ path: `/customers/${x.foreignCustomer.id}`, expect: [404, 307] }, { path: `/customers/${x.foreignCustomer.id}`, expect: [404, 307] },
+16
View File
@@ -26,6 +26,7 @@ import { createOrderType } from "../src/server/services/work-orders/settings";
import { listImports } from "../src/server/services/imports/queries"; import { listImports } from "../src/server/services/imports/queries";
import { createNote } from "../src/server/services/field/notes"; import { createNote } from "../src/server/services/field/notes";
import { correctTimeEntry } from "../src/server/services/field/time-correction"; 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 { createDailyReport } from "../src/server/services/reports/create";
import { approveReport } from "../src/server/services/reports/approve"; import { approveReport } from "../src/server/services/reports/approve";
import { listReports } from "../src/server/services/reports/queries"; 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: "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: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" }) }, { 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: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: "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") }, { permission: "emergency:create", label: "Notdienst-Kundensuche", run: (c) => searchCustomersForEmergency(c, "Hausverwaltung") },
+237
View File
@@ -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<string, unknown> = {}) {
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<unknown>): Promise<ServiceError | null> {
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");
});
+141
View File
@@ -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<string, unknown>, 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");
});