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>
238 lines
21 KiB
TypeScript
238 lines
21 KiB
TypeScript
// 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");
|
|
});
|