From d0b9d9eaca322dffdbdb497f9e7a8a155454addc Mon Sep 17 00:00:00 2001 From: shunli Date: Wed, 25 Mar 2026 15:32:23 +0800 Subject: [PATCH] =?UTF-8?q?R36-1/R36-2/R36-3/R36-5=20=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=E8=AF=B7=E5=81=87=E7=BC=96=E8=BE=91=E4=B8=8E=E5=9B=A2=E9=98=9F?= =?UTF-8?q?=E6=97=A5=E5=8E=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/leave.ts | 34 +- client/src/auth/permissions.ts | 18 +- client/src/pages/LeaveRequestPage.tsx | 263 ++++++++++++---- client/src/pages/PortalHomePage.tsx | 4 +- client/tests/e2e/leave-flow.spec.ts | 42 +++ client/tests/e2e/leave-team-calendar.spec.ts | 94 +++++- client/tests/e2e/login-portal-ux.spec.ts | 12 + server/src/routes/leave.js | 162 +++++++++- server/tests/unit/routes/leave.edit.test.js | 314 +++++++++++++++++++ 9 files changed, 874 insertions(+), 69 deletions(-) create mode 100644 server/tests/unit/routes/leave.edit.test.js diff --git a/client/src/api/leave.ts b/client/src/api/leave.ts index 37534651..e45e585d 100644 --- a/client/src/api/leave.ts +++ b/client/src/api/leave.ts @@ -90,6 +90,7 @@ export type LeaveListQuery = { pageSize?: number; dateFrom?: string; dateTo?: string; + departmentId?: string; }; function toQueryString(query: Record) { @@ -194,7 +195,38 @@ export async function createLeaveRequest(payload: { export async function submitLeaveRequest(leaveRequestId: string) { return apiRequestData>(`/api/leave-requests/${leaveRequestId}/submit`, { method: "POST", - body: JSON.stringify({}), + body: JSON.stringify({}), + }); +} + +export async function patchLeaveRequest( + leaveRequestId: string, + payload: { + leaveTypeId: string; + startDate: string; + endDate: string; + startSession: LeaveSession; + endSession: LeaveSession; + reason?: string; + file?: File | null; + }, +) { + const formData = new FormData(); + formData.append("leaveTypeId", payload.leaveTypeId); + formData.append("startDate", payload.startDate); + formData.append("endDate", payload.endDate); + formData.append("startSession", payload.startSession); + formData.append("endSession", payload.endSession); + if (payload.reason?.trim()) { + formData.append("reason", payload.reason.trim()); + } + if (payload.file) { + formData.append("file", payload.file); + } + + return apiRequestData(`/api/leave-requests/${leaveRequestId}`, { + method: "PATCH", + body: formData, }); } diff --git a/client/src/auth/permissions.ts b/client/src/auth/permissions.ts index 96bd448a..be372304 100644 --- a/client/src/auth/permissions.ts +++ b/client/src/auth/permissions.ts @@ -54,6 +54,7 @@ const ROLE_PERMISSIONS: Record = { "expense.transfer", "expense.view_all", "leave.approve", + "leave.view_all", "procurement.approve", "procurement.view_all", "travel.approve", @@ -61,12 +62,9 @@ const ROLE_PERMISSIONS: Record = { "asset.view_all", "report.dashboard.view", "project.view_all", + "project.manage", "budget.view", - "project.view_all", - "project.manage", "budget.manage", - "project.view_all", - "project.manage", ], ceo: [ ...EMPLOYEE_BASE_PERMISSIONS, @@ -184,3 +182,15 @@ export function hasAnyPermission(permissionCodes: string[], candidates: string[] return candidates.some((candidate) => hasPermission(permissionCodes, candidate)); } + +export function isManagerRole(permissionCodes: string[]) { + if (hasPermission(permissionCodes, "*") || hasPermission(permissionCodes, "system.users")) { + return true; + } + + return permissionCodes.some( + (permission) => + permission.endsWith(".approve") || + (permission.endsWith(".manage") && !permission.endsWith(".view_all")), + ); +} diff --git a/client/src/pages/LeaveRequestPage.tsx b/client/src/pages/LeaveRequestPage.tsx index e5ae9108..d92ad4dd 100644 --- a/client/src/pages/LeaveRequestPage.tsx +++ b/client/src/pages/LeaveRequestPage.tsx @@ -7,6 +7,7 @@ import { fetchLeaveBalances, fetchLeaveRequests, fetchLeaveTypes, + patchLeaveRequest, submitLeaveRequest, withdrawLeaveRequest, type LeaveRequestItem, @@ -109,6 +110,14 @@ function buildCalendarWindow(anchorDate: Date, mode: LeaveCalendarMode) { }; } +function shiftCalendarAnchorDate(anchorDate: Date, mode: LeaveCalendarMode, direction: -1 | 1) { + if (mode === "week") { + return addUtcDays(anchorDate, direction * 7); + } + + return new Date(Date.UTC(anchorDate.getUTCFullYear(), anchorDate.getUTCMonth() + direction, 1)); +} + function leaveRequestIntersectsDate(item: LeaveRequestItem, dateKey: string) { return item.startDate <= dateKey && item.endDate >= dateKey; } @@ -187,14 +196,18 @@ export function LeaveRequestPage() { const queryClient = useQueryClient(); const { me, permissions } = useAuth(); - const canViewAll = hasPermission(permissions, "leave.view_all") || hasPermission(permissions, "leave.manage"); + const hasLeaveViewAll = hasPermission(permissions, "leave.view_all"); + const hasLeaveManage = hasPermission(permissions, "leave.manage"); + const canViewAll = hasLeaveViewAll || hasLeaveManage; + const canViewTeamCalendar = hasLeaveViewAll; + const useDepartmentCalendarScope = hasLeaveViewAll && !hasLeaveManage && Boolean(me?.departmentId); const [statusFilter, setStatusFilter] = useState(""); const [scope, setScope] = useState<"own" | "all">("own"); const [page, setPage] = useState(1); const [pageView, setPageView] = useState("records"); const [calendarMode, setCalendarMode] = useState("week"); - const [calendarAnchorDate] = useState(() => new Date()); + const [calendarAnchorDate, setCalendarAnchorDate] = useState(() => new Date()); const [leaveTypeId, setLeaveTypeId] = useState(""); const [startDate, setStartDate] = useState(""); @@ -203,6 +216,8 @@ export function LeaveRequestPage() { const [endSession, setEndSession] = useState("pm"); const [reason, setReason] = useState(""); const [attachmentFile, setAttachmentFile] = useState(null); + const [attachmentInputKey, setAttachmentInputKey] = useState(0); + const [editingLeaveRequest, setEditingLeaveRequest] = useState(null); const [formError, setFormError] = useState(""); const [withdrawTarget, setWithdrawTarget] = useState<{ id: string; label: string } | null>(null); @@ -239,8 +254,16 @@ export function LeaveRequestPage() { ); const calendarRequestsQuery = useQuery({ - queryKey: ["leave", "calendar", calendarMode, calendarWindow.queryFrom, calendarWindow.queryTo], - enabled: canViewAll && pageView === "calendar", + queryKey: [ + "leave", + "calendar", + calendarMode, + calendarAnchorDate.toISOString(), + calendarWindow.queryFrom, + calendarWindow.queryTo, + useDepartmentCalendarScope ? me?.departmentId ?? "no-department" : "all", + ], + enabled: canViewTeamCalendar && pageView === "calendar", queryFn: () => fetchLeaveRequests({ scope: "all", @@ -248,6 +271,7 @@ export function LeaveRequestPage() { pageSize: CALENDAR_PAGE_SIZE, dateFrom: calendarWindow.queryFrom, dateTo: calendarWindow.queryTo, + departmentId: useDepartmentCalendarScope ? me?.departmentId ?? undefined : undefined, }), }); @@ -280,40 +304,100 @@ export function LeaveRequestPage() { ); }, [calendarRequestsQuery.data?.items, calendarWindow.dates]); + function resetForm() { + setEditingLeaveRequest(null); + setLeaveTypeId(""); + setStartDate(""); + setEndDate(""); + setStartSession("am"); + setEndSession("pm"); + setReason(""); + setAttachmentFile(null); + setAttachmentInputKey((current) => current + 1); + setFormError(""); + } + + function startEditing(item: LeaveRequestItem) { + setEditingLeaveRequest(item); + setLeaveTypeId(item.leaveType?.id ?? ""); + setStartDate(item.startDate); + setEndDate(item.endDate); + setStartSession(item.startSession); + setEndSession(item.endSession); + setReason(item.reason ?? ""); + setAttachmentFile(null); + setAttachmentInputKey((current) => current + 1); + setFormError(""); + } + + function buildFormPayload({ requireAttachment }: { requireAttachment: boolean }) { + if (!leaveTypeId) { + throw new Error("请选择假期类型"); + } + if (!startDate || !endDate) { + throw new Error("请选择开始和结束日期"); + } + if (!selectedLeaveType) { + throw new Error("假期类型不存在或已失效"); + } + if (requestedDaysPreview == null || requestedDaysPreview <= 0) { + throw new Error("请检查开始/结束日期与半天时段"); + } + if ( + requireAttachment && + selectedLeaveType.requiresAttachment && + !attachmentFile && + !editingLeaveRequest?.attachment + ) { + throw new Error("当前假种必须上传附件"); + } + if ( + attachmentFile && + selectedLeaveType.attachmentMimeTypes.length > 0 && + !selectedLeaveType.attachmentMimeTypes.includes(attachmentFile.type) + ) { + throw new Error("附件类型不符合当前假种要求"); + } + + return { + leaveTypeId, + startDate, + endDate, + startSession, + endSession, + reason, + file: attachmentFile, + }; + } + const createMutation = useMutation({ + mutationFn: async () => createLeaveRequest(buildFormPayload({ requireAttachment: true })), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["leave", "requests"] }), + queryClient.invalidateQueries({ queryKey: ["leave", "calendar"] }), + queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }), + ]); + resetForm(); + toast.success("请假草稿已创建"); + }, + onError: (error) => { + const message = resolveError(error); + setFormError(message); + toast.error(message); + }, + }); + + const updateMutation = useMutation({ mutationFn: async () => { - if (!leaveTypeId) { - throw new Error("请选择假期类型"); - } - if (!startDate || !endDate) { - throw new Error("请选择开始和结束日期"); - } - if (!selectedLeaveType) { - throw new Error("假期类型不存在或已失效"); - } - if (requestedDaysPreview == null || requestedDaysPreview <= 0) { - throw new Error("请检查开始/结束日期与半天时段"); - } - if (selectedLeaveType.requiresAttachment && !attachmentFile) { - throw new Error("当前假种必须上传附件"); - } - if ( - attachmentFile && - selectedLeaveType.attachmentMimeTypes.length > 0 && - !selectedLeaveType.attachmentMimeTypes.includes(attachmentFile.type) - ) { - throw new Error("附件类型不符合当前假种要求"); + if (!editingLeaveRequest) { + throw new Error("未选择可编辑的请假单"); } - return createLeaveRequest({ - leaveTypeId, - startDate, - endDate, - startSession, - endSession, - reason, - file: attachmentFile, - }); + return patchLeaveRequest( + editingLeaveRequest.id, + buildFormPayload({ requireAttachment: false }), + ); }, onSuccess: async () => { await Promise.all([ @@ -321,15 +405,8 @@ export function LeaveRequestPage() { queryClient.invalidateQueries({ queryKey: ["leave", "calendar"] }), queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }), ]); - setLeaveTypeId(""); - setStartDate(""); - setEndDate(""); - setStartSession("am"); - setEndSession("pm"); - setReason(""); - setAttachmentFile(null); - setFormError(""); - toast.success("请假草稿已创建"); + resetForm(); + toast.success("请假单已保存,可继续提交"); }, onError: (error) => { const message = resolveError(error); @@ -371,6 +448,13 @@ export function LeaveRequestPage() { const totalPages = Math.max(1, Math.ceil((requestsQuery.data?.total ?? 0) / PAGE_SIZE)); const calendarIsTruncated = (calendarRequestsQuery.data?.total ?? 0) > (calendarRequestsQuery.data?.items.length ?? 0); + const isSavingForm = createMutation.isPending || updateMutation.isPending; + const formTitle = editingLeaveRequest ? "编辑请假单" : "新建请假草稿"; + const formDescription = editingLeaveRequest + ? "修改原单内容并保留同一请假单 ID,保存后仍可继续提交。" + : "先填写时间段与假种,再补充附件和说明。"; + const currentAttachmentLabel = + attachmentFile?.name ?? (editingLeaveRequest?.attachment?.originalName ?? null); const leaveRecordsPanel = ( @@ -439,6 +523,7 @@ export function LeaveRequestPage() { {(requestsQuery.data?.items ?? []).length ? ( requestsQuery.data?.items.map((item) => { const isOwner = item.ownerId === me?.id; + const canEdit = isOwner && ["draft", "rejected"].includes(item.status); const canSubmit = isOwner && ["draft", "rejected"].includes(item.status); const canWithdraw = isOwner && item.status === "reviewing"; @@ -460,6 +545,15 @@ export function LeaveRequestPage() { {item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "-"}
+ + + +
+ + setCalendarMode(value as LeaveCalendarMode)} data-testid="leave-calendar-mode-tabs"> + 周视图 月视图 - - + + + {calendarIsTruncated ? ( @@ -612,7 +740,7 @@ export function LeaveRequestPage() {
- +
{formError ? (

@@ -677,6 +805,7 @@ export function LeaveRequestPage() {

+ {editingLeaveRequest ? ( +

+ 当前附件:{currentAttachmentLabel ?? "未上传"}。未重新选择文件时保留原附件。 +

+ ) : null}
@@ -708,9 +842,32 @@ export function LeaveRequestPage() {
预计请假天数:{requestedDaysPreview ?? "-"}
- +
+ {editingLeaveRequest ? ( + + ) : null} + +
@@ -774,7 +931,7 @@ export function LeaveRequestPage() { - {canViewAll ? ( + {canViewTeamCalendar ? ( setPageView(value as LeavePageView)}> 请假记录 diff --git a/client/src/pages/PortalHomePage.tsx b/client/src/pages/PortalHomePage.tsx index 6344709d..b5645648 100644 --- a/client/src/pages/PortalHomePage.tsx +++ b/client/src/pages/PortalHomePage.tsx @@ -4,7 +4,7 @@ import { fetchPortalHome, type PortalHomePayload } from "../api/portal"; import { mapApiErrorMessage } from "../api/errorMessages"; import { isApiError } from "../api/types"; import { useAuth } from "../auth/AuthContext"; -import { hasAnyPermission } from "../auth/permissions"; +import { hasAnyPermission, isManagerRole } from "../auth/permissions"; import { EmptyStateCard } from "../components/common/EmptyStateCard"; import { MetricCard } from "../components/patterns/MetricCard"; import { PageScaffold } from "../components/patterns/PageScaffold"; @@ -201,7 +201,7 @@ export function PortalHomePage() { const { permissions } = useAuth(); const portalQuery = useQuery({ queryKey: ["portal-home"], queryFn: () => fetchPortalHome() }); const canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]); - const isManagerView = hasAnyPermission(permissions, ["system.users", "report.dashboard.view", "*"]); + const isManagerView = isManagerRole(permissions); if (portalQuery.isLoading) { return
加载 Portal 中...
; diff --git a/client/tests/e2e/leave-flow.spec.ts b/client/tests/e2e/leave-flow.spec.ts index f00aaca7..5aa86810 100644 --- a/client/tests/e2e/leave-flow.spec.ts +++ b/client/tests/e2e/leave-flow.spec.ts @@ -69,6 +69,48 @@ async function withdrawLeave(api: Awaited(api, `/api/leave-requests/${leaveRequestId}/withdraw`, {}); } +test("E2E 请假编辑:草稿保存后保留原单并可继续提交", async ({ page }) => { + const employeeClient = await createAuthenticatedApiClient(EMPLOYEE); + const baseOffset = 80 + Number(String(Date.now()).slice(-2)); + const startDate = formatDate(baseOffset); + const updatedEndDate = formatDate(baseOffset + 1); + + try { + await login(page, EMPLOYEE); + await (await openNavLink(page, "请假")).click(); + await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible(); + + const leaveId = await createLeaveDraft(page, startDate, startDate, `Playwright 编辑请假 ${Date.now()}`); + await page.goto("/leave-requests"); + + const draftRow = page.getByRole("row").filter({ hasText: startDate }).first(); + await expect(draftRow).toContainText("草稿", { timeout: 30_000 }); + + const updateResponsePromise = page.waitForResponse((response) => { + return response.request().method() === "PATCH" && new URL(response.url()).pathname === `/api/leave-requests/${leaveId}` && response.ok(); + }); + + await draftRow.getByRole("button", { name: "编辑" }).click(); + await expect(page.getByText("编辑请假单")).toBeVisible(); + await page.locator("#leave-end-date").fill(updatedEndDate); + await page.locator("#leave-reason").fill(`更新后的请假说明 ${Date.now()}`); + await page.getByRole("button", { name: "保存修改" }).click(); + + const updatePayload = (await (await updateResponsePromise).json()) as { data?: { id?: string } }; + expect(updatePayload.data?.id).toBe(leaveId); + + await page.goto("/leave-requests"); + const updatedRow = page.getByRole("row").filter({ hasText: updatedEndDate }).first(); + await expect(updatedRow).toContainText("草稿", { timeout: 30_000 }); + + await submitLeave(employeeClient.api, leaveId); + await page.goto("/leave-requests"); + await expect(updatedRow).toContainText("审批中", { timeout: 30_000 }); + } finally { + await employeeClient.dispose(); + } +}); + test("E2E 请假主链路:草稿 → 提交 → 撤回 + 提交 → 审批", async ({ page, workflowDetail }) => { const employeeClient = await createAuthenticatedApiClient(EMPLOYEE); const baseOffset = 30 + Number(String(Date.now()).slice(-2)); diff --git a/client/tests/e2e/leave-team-calendar.spec.ts b/client/tests/e2e/leave-team-calendar.spec.ts index ee9a1dcf..f46b4e39 100644 --- a/client/tests/e2e/leave-team-calendar.spec.ts +++ b/client/tests/e2e/leave-team-calendar.spec.ts @@ -1,8 +1,8 @@ import { expect, test, type Page } from "@playwright/test"; -import { EMPLOYEE, login, type E2EUser } from "./helpers/auth"; +import { EMPLOYEE, MANAGER, login, type E2EUser } from "./helpers/auth"; -const LEAVE_CALENDAR_MANAGER: E2EUser = { +const LEAVE_CALENDAR_HR: E2EUser = { identifier: "hr@hyinda.com", password: "HrPassword1234", name: "HR", @@ -149,7 +149,12 @@ function buildCalendarItems(dateFrom: string, dateTo: string) { } async function mockLeaveRequests(page: Page) { - const observedCalendarQueries: Array<{ dateFrom: string; dateTo: string; pageSize: string }> = []; + const observedCalendarQueries: Array<{ + dateFrom: string; + dateTo: string; + pageSize: string; + departmentId: string | null; + }> = []; await page.route("**/api/leave-requests?**", async (route) => { const url = new URL(route.request().url()); @@ -157,9 +162,10 @@ async function mockLeaveRequests(page: Page) { const pageSize = url.searchParams.get("pageSize") ?? "10"; const dateFrom = url.searchParams.get("dateFrom"); const dateTo = url.searchParams.get("dateTo"); + const departmentId = url.searchParams.get("departmentId"); if (scope === "all" && dateFrom && dateTo) { - observedCalendarQueries.push({ dateFrom, dateTo, pageSize }); + observedCalendarQueries.push({ dateFrom, dateTo, pageSize, departmentId }); await route.fulfill({ status: 200, contentType: "application/json", @@ -194,10 +200,10 @@ async function mockLeaveRequests(page: Page) { return observedCalendarQueries; } -test("E2E 请假团队日历:具备团队查看权限的用户可见周/月视图并显示截断提示", async ({ page }) => { +test("E2E 请假团队日历:HR 可见全员周/月视图并显示截断提示", async ({ page }) => { const observedCalendarQueries = await mockLeaveRequests(page); - await login(page, LEAVE_CALENDAR_MANAGER); + await login(page, LEAVE_CALENDAR_HR); await page.goto("/leave-requests"); await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible(); @@ -229,6 +235,82 @@ test("E2E 请假团队日历:具备团队查看权限的用户可见周/月视 await expect.poll(() => observedCalendarQueries.length).toBeGreaterThanOrEqual(2); expect(observedCalendarQueries.every((query) => Boolean(query.dateFrom && query.dateTo))).toBe(true); expect(observedCalendarQueries.every((query) => query.pageSize === "100")).toBe(true); + expect(observedCalendarQueries.every((query) => query.departmentId == null)).toBe(true); +}); + +test("E2E 请假团队日历:经理只拉本部门数据且导航会触发新查询", async ({ page }) => { + const observedCalendarQueries = await mockLeaveRequests(page); + + await login(page, MANAGER); + await page.goto("/leave-requests"); + + await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible(); + await expect(page.getByTestId("leave-view-calendar")).toBeVisible(); + + const firstResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return response.ok() && url.pathname === "/api/leave-requests" && url.searchParams.get("scope") === "all"; + }); + await page.getByTestId("leave-view-calendar").click(); + await firstResponse; + + await expect(page.getByTestId("leave-team-calendar-grid")).toBeVisible(); + await expect(page.getByTestId("leave-team-calendar-entry").filter({ hasText: "王小明" }).first()).toBeVisible(); + await expect.poll(() => observedCalendarQueries.length).toBeGreaterThanOrEqual(1); + expect(observedCalendarQueries.every((query) => Boolean(query.departmentId))).toBe(true); + + const firstQuery = observedCalendarQueries.at(-1); + if (!firstQuery) { + throw new Error("经理团队日历未记录到首个查询"); + } + + const previousResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return response.ok() && url.pathname === "/api/leave-requests" && url.searchParams.get("scope") === "all"; + }); + await page.getByTestId("leave-calendar-prev").click(); + await previousResponse; + + const previousQuery = observedCalendarQueries.at(-1); + if (!previousQuery) { + throw new Error("经理团队日历未记录到上一窗口查询"); + } + expect(previousQuery.dateFrom).not.toBe(firstQuery.dateFrom); + + const todayResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return response.ok() && url.pathname === "/api/leave-requests" && url.searchParams.get("scope") === "all"; + }); + await page.getByTestId("leave-calendar-today").click(); + await todayResponse; + + const todayQuery = observedCalendarQueries.at(-1); + if (!todayQuery) { + throw new Error("经理团队日历未记录到今天查询"); + } + expect(todayQuery.dateFrom).toBe(firstQuery.dateFrom); + + const monthResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return response.ok() && url.pathname === "/api/leave-requests" && url.searchParams.get("scope") === "all"; + }); + await page.getByTestId("leave-calendar-mode-month").click(); + await monthResponse; + + await expect.poll(() => page.getByTestId("leave-team-calendar-cell").count()).toBeGreaterThan(7); + + const nextMonthResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return response.ok() && url.pathname === "/api/leave-requests" && url.searchParams.get("scope") === "all"; + }); + await page.getByTestId("leave-calendar-next").click(); + await nextMonthResponse; + + const nextMonthQuery = observedCalendarQueries.at(-1); + if (!nextMonthQuery) { + throw new Error("经理团队日历未记录到下一窗口查询"); + } + expect(nextMonthQuery.dateFrom).not.toBe(todayQuery.dateFrom); }); test("E2E 请假团队日历:员工视角不显示团队日历 Tab", async ({ page }) => { diff --git a/client/tests/e2e/login-portal-ux.spec.ts b/client/tests/e2e/login-portal-ux.spec.ts index 7fcd5bc2..8e8a4c64 100644 --- a/client/tests/e2e/login-portal-ux.spec.ts +++ b/client/tests/e2e/login-portal-ux.spec.ts @@ -223,6 +223,18 @@ test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page await expect(page.getByRole("link", { name: "采购申请" })).toBeVisible(); }); +test("Portal 域管理角色也进入经理视图", async ({ page }) => { + await mockNavigation(page, "manager"); + await mockPortalHome(page, "manager"); + await mockMe(page, ["administration"], "Administration"); + + await login(page, EMPLOYEE); + + await expect(page.getByText("今日管理重点")).toBeVisible(); + await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toBeVisible(); + await expect(page.locator('[aria-label="portal-card-team_presence"]')).toBeVisible(); +}); + test("企微 WebView 下顶栏显示入口提示", async ({ page }) => { await page.addInitScript(() => { Object.defineProperty(window.navigator, "userAgent", { diff --git a/server/src/routes/leave.js b/server/src/routes/leave.js index f20120cd..27c8b487 100644 --- a/server/src/routes/leave.js +++ b/server/src/routes/leave.js @@ -44,6 +44,7 @@ const leaveListQuerySchema = z.object({ pageSize: z.coerce.number().int().min(1).max(100).optional(), dateFrom: DATE_ONLY_SCHEMA.optional(), dateTo: DATE_ONLY_SCHEMA.optional(), + departmentId: z.string().uuid().optional(), }); const leaveBalanceQuerySchema = z.object({ @@ -120,6 +121,9 @@ function parseLeaveListQuery(query) { if (dateFrom && dateTo && dateFrom > dateTo) { throw buildValidationError({ dateTo: "must_be_after_dateFrom" }); } + if (parsed.data.departmentId && parsed.data.scope !== "all") { + throw buildValidationError({ departmentId: "scope_all_only" }); + } return { status: parsed.data.status, @@ -128,6 +132,7 @@ function parseLeaveListQuery(query) { pageSize: parsed.data.pageSize ?? 20, dateFrom, dateTo, + departmentId: parsed.data.departmentId ?? null, }; } @@ -347,12 +352,18 @@ async function loadLeaveTypeOrThrow(prisma, leaveTypeId) { return leaveType; } -async function assertLeaveTypeAttachment(fastify, leaveType, filePart, employeeId) { +async function resolveLeaveTypeAttachment( + fastify, + leaveType, + filePart, + employeeId, + { requireWhenMissing = true } = {}, +) { const requiresAttachment = Boolean(leaveType.requires_attachment); const allowedMimeTypes = normalizeAttachmentMimeTypes(leaveType.attachment_mime_types); if (!filePart) { - if (requiresAttachment) { + if (requireWhenMissing && requiresAttachment) { throw buildValidationError({ file: "required" }, 422); } return null; @@ -417,6 +428,7 @@ async function leaveRoutes(fastify) { async (request, reply) => { const query = parseLeaveListQuery(request.query); const canViewAll = fastify.hasPermission(request.employee, "leave.view_all"); + const canManageAll = fastify.hasPermission(request.employee, "leave.manage"); if (query.scope === "all" && !canViewAll) { throw new AppError({ code: "PERM_001", @@ -424,6 +436,18 @@ async function leaveRoutes(fastify) { statusCode: 403, }); } + if ( + query.departmentId && + !canManageAll && + query.departmentId !== (request.employee.department_id ?? null) + ) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + details: { departmentId: "forbidden" }, + }); + } const page = query.page; const pageSize = query.pageSize; @@ -431,6 +455,7 @@ async function leaveRoutes(fastify) { const where = { ...(query.status ? { status: query.status } : {}), ...(query.scope === "all" && canViewAll ? {} : { owner_id: request.employee.id }), + ...(query.scope === "all" && query.departmentId ? { department_id: query.departmentId } : {}), ...(query.dateFrom || query.dateTo ? { AND: [ @@ -510,7 +535,7 @@ async function leaveRoutes(fastify) { }); const { body, filePart } = await collectCreatePayload(request); const leaveType = await loadLeaveTypeOrThrow(fastify.prisma, body.leaveTypeId); - const attachment = await assertLeaveTypeAttachment( + const attachment = await resolveLeaveTypeAttachment( fastify, leaveType, filePart, @@ -576,6 +601,137 @@ async function leaveRoutes(fastify) { }, ); + fastify.patch( + "/leave-requests/:id", + { + schema: { tags: ["Leave"], summary: "Update Leave Request" }, + preHandler: fastify.requireEntityPermission("leave", "submit"), + }, + async (request, reply) => { + const leaveId = parseLeaveId(request.params?.id); + const { body, filePart } = await collectCreatePayload(request); + let response = null; + + await fastify.prisma.$transaction(async (tx) => { + const leaveRows = await tx.$queryRaw` + SELECT + id, + owner_id, + department_id, + attachment_storage_path, + attachment_original_name, + attachment_mime_type, + status + FROM "leave_requests" + WHERE id = ${leaveId}::uuid + FOR UPDATE + `; + + if (!Array.isArray(leaveRows) || leaveRows.length === 0) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const leave = leaveRows[0]; + await ensureCutoverAllowed({ + prisma: tx, + config: fastify.config, + request, + domain: "leave", + departmentId: leave.department_id ?? request.employee.department_id ?? null, + }); + + if (leave.owner_id !== request.employee.id) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } + + if (!["draft", "rejected"].includes(leave.status)) { + throw buildValidationError({ status: "invalid_edit_status" }, 409); + } + + const leaveType = await loadLeaveTypeOrThrow(tx, body.leaveTypeId); + const attachment = await resolveLeaveTypeAttachment( + fastify, + leaveType, + filePart, + request.employee.id, + { requireWhenMissing: false }, + ); + const requestedDays = await resolveRequestedDaysForCreate( + fastify, + request.employee, + body, + ); + + const updatedLeave = await tx.leaveRequest.update({ + where: { id: leaveId }, + data: { + department_id: request.employee.department_id ?? leave.department_id ?? null, + leave_type_id: body.leaveTypeId, + start_date: body.startDate, + end_date: body.endDate, + start_session: body.startSession, + end_session: body.endSession, + requested_days: requestedDays, + reason: body.reason, + attachment_storage_path: attachment?.storagePath ?? leave.attachment_storage_path ?? null, + attachment_original_name: attachment + ? attachment.originalName ?? null + : leave.attachment_original_name ?? null, + attachment_mime_type: attachment + ? attachment.mimeType ?? null + : leave.attachment_mime_type ?? null, + updated_by: request.employee.id, + }, + select: { + id: true, + owner_id: true, + department_id: true, + leave_type_id: true, + start_date: true, + end_date: true, + start_session: true, + end_session: true, + requested_days: true, + reason: true, + attachment_original_name: true, + attachment_mime_type: true, + status: true, + created_at: true, + updated_at: true, + leave_type: { + select: { + id: true, + code: true, + name: true, + requires_attachment: true, + attachment_mime_types: true, + }, + }, + owner: { + select: { + id: true, + employee_no: true, + name: true, + }, + }, + }, + }); + + response = mapLeaveRequestResponse(updatedLeave); + }); + + return sendData(reply, request, response); + }, + ); + fastify.get( "/leave-requests/:id", { diff --git a/server/tests/unit/routes/leave.edit.test.js b/server/tests/unit/routes/leave.edit.test.js new file mode 100644 index 00000000..6c10d3ac --- /dev/null +++ b/server/tests/unit/routes/leave.edit.test.js @@ -0,0 +1,314 @@ +const Fastify = require("fastify"); + +const { AppError } = require("../../../src/utils/AppError"); +const leaveRoutes = require("../../../src/routes/leave"); + +function buildLeaveType() { + const now = new Date("2026-03-25T08:00:00.000Z"); + return { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + code: "annual", + name: "年假", + description: null, + enabled: true, + requires_attachment: false, + attachment_mime_types: [], + sort_order: 1, + created_at: now, + updated_at: now, + leave_policy: { + id: "policy-1", + annual_quota_days: 10, + enabled: true, + description: null, + }, + }; +} + +function buildLockedLeave({ + ownerId = "employee-1", + status = "draft", +} = {}) { + return { + id: "11111111-1111-4111-8111-111111111111", + owner_id: ownerId, + department_id: "department-1", + attachment_storage_path: null, + attachment_original_name: null, + attachment_mime_type: null, + status, + }; +} + +function buildUpdatedLeave({ + id = "11111111-1111-4111-8111-111111111111", + ownerId = "employee-1", + status = "draft", +} = {}) { + const now = new Date("2026-03-25T09:00:00.000Z"); + return { + id, + owner_id: ownerId, + department_id: "department-1", + leave_type_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + start_date: new Date("2026-04-01T00:00:00.000Z"), + end_date: new Date("2026-04-02T00:00:00.000Z"), + start_session: "am", + end_session: "pm", + requested_days: 2, + reason: "更新后的请假原因", + attachment_original_name: null, + attachment_mime_type: null, + status, + created_at: now, + updated_at: now, + leave_type: { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + code: "annual", + name: "年假", + requires_attachment: false, + attachment_mime_types: [], + }, + owner: { + id: ownerId, + employee_no: "E001", + name: "张三", + }, + }; +} + +function buildTx({ lockedLeave, updatedLeave }) { + return { + $queryRaw: vi.fn().mockResolvedValue([lockedLeave]), + leaveType: { + findUnique: vi.fn().mockResolvedValue(buildLeaveType()), + }, + leaveRequest: { + update: vi.fn().mockResolvedValue(updatedLeave), + }, + }; +} + +function buildPrisma(tx) { + return { + $transaction: vi.fn().mockImplementation((callback) => callback(tx)), + }; +} + +async function buildApp({ employee, prisma }) { + const app = Fastify(); + + app.decorate("prisma", prisma); + app.decorate("config", { + cutoverConsoleEnabled: false, + leaveHolidayCalendarEnabled: false, + }); + app.decorate("authenticate", async (request) => { + if (!request.headers.authorization) { + throw new AppError({ + code: "AUTH_001", + message: "未登录", + statusCode: 401, + }); + } + + request.employee = employee; + }); + app.decorate("hasPermission", (actor, permissionCode) => { + return (actor?.permissionCodes ?? []).includes("*") || (actor?.permissionCodes ?? []).includes(permissionCode); + }); + app.decorate("requireEntityPermission", (_entity, action) => async (request) => { + await app.authenticate(request); + const permissionCode = action === "submit" ? "leave.submit" : "leave.view_own"; + if (!app.hasPermission(request.employee, permissionCode)) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + details: { permission: permissionCode }, + }); + } + }); + + app.setErrorHandler((error, request, reply) => { + if (error instanceof AppError) { + return reply.status(error.statusCode).send({ + requestId: request.id, + error: { + code: error.code, + message: error.message, + details: error.details ?? {}, + }, + }); + } + + return reply.status(500).send({ + requestId: request.id, + error: { code: "SYS_001", message: "服务器内部错误", details: {} }, + }); + }); + + await app.register(leaveRoutes, { prefix: "/api" }); + return app; +} + +describe("leave edit route", () => { + it("allows owner to edit draft leave on the same record", async () => { + const lockedLeave = buildLockedLeave({ status: "draft" }); + const updatedLeave = buildUpdatedLeave({ status: "draft" }); + const tx = buildTx({ lockedLeave, updatedLeave }); + const prisma = buildPrisma(tx); + const app = await buildApp({ + employee: { + id: "employee-1", + department_id: "department-1", + permissionCodes: ["leave.submit"], + }, + prisma, + }); + + const response = await app.inject({ + method: "PATCH", + url: `/api/leave-requests/${lockedLeave.id}`, + headers: { authorization: "Bearer test" }, + payload: { + leaveTypeId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + startDate: "2026-04-01", + endDate: "2026-04-02", + startSession: "am", + endSession: "pm", + reason: "更新后的请假原因", + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual( + expect.objectContaining({ + id: lockedLeave.id, + status: "draft", + startDate: "2026-04-01", + endDate: "2026-04-02", + requestedDays: 2, + reason: "更新后的请假原因", + }), + ); + expect(tx.leaveRequest.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: lockedLeave.id }, + data: expect.objectContaining({ + leave_type_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + requested_days: 2, + updated_by: "employee-1", + }), + }), + ); + + await app.close(); + }); + + it("allows owner to edit rejected leave on the same record", async () => { + const lockedLeave = buildLockedLeave({ status: "rejected" }); + const updatedLeave = buildUpdatedLeave({ status: "rejected" }); + const tx = buildTx({ lockedLeave, updatedLeave }); + const app = await buildApp({ + employee: { + id: "employee-1", + department_id: "department-1", + permissionCodes: ["leave.submit"], + }, + prisma: buildPrisma(tx), + }); + + const response = await app.inject({ + method: "PATCH", + url: `/api/leave-requests/${lockedLeave.id}`, + headers: { authorization: "Bearer test" }, + payload: { + leaveTypeId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + startDate: "2026-04-01", + endDate: "2026-04-02", + startSession: "am", + endSession: "pm", + reason: "更新后的请假原因", + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.status).toBe("rejected"); + + await app.close(); + }); + + it("rejects editing when leave is already reviewing", async () => { + const lockedLeave = buildLockedLeave({ status: "reviewing" }); + const tx = buildTx({ lockedLeave, updatedLeave: buildUpdatedLeave({ status: "reviewing" }) }); + const app = await buildApp({ + employee: { + id: "employee-1", + department_id: "department-1", + permissionCodes: ["leave.submit"], + }, + prisma: buildPrisma(tx), + }); + + const response = await app.inject({ + method: "PATCH", + url: `/api/leave-requests/${lockedLeave.id}`, + headers: { authorization: "Bearer test" }, + payload: { + leaveTypeId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + startDate: "2026-04-01", + endDate: "2026-04-02", + startSession: "am", + endSession: "pm", + reason: "更新后的请假原因", + }, + }); + + expect(response.statusCode).toBe(409); + expect(response.json().error).toEqual( + expect.objectContaining({ + code: "VALID_001", + details: expect.objectContaining({ + status: "invalid_edit_status", + }), + }), + ); + expect(tx.leaveRequest.update).not.toHaveBeenCalled(); + + await app.close(); + }); + + it("rejects editing by non-owner", async () => { + const lockedLeave = buildLockedLeave({ ownerId: "employee-9", status: "draft" }); + const tx = buildTx({ lockedLeave, updatedLeave: buildUpdatedLeave({ ownerId: "employee-9", status: "draft" }) }); + const app = await buildApp({ + employee: { + id: "employee-1", + department_id: "department-1", + permissionCodes: ["leave.submit"], + }, + prisma: buildPrisma(tx), + }); + + const response = await app.inject({ + method: "PATCH", + url: `/api/leave-requests/${lockedLeave.id}`, + headers: { authorization: "Bearer test" }, + payload: { + leaveTypeId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + startDate: "2026-04-01", + endDate: "2026-04-02", + startSession: "am", + endSession: "pm", + reason: "更新后的请假原因", + }, + }); + + expect(response.statusCode).toBe(403); + expect(response.json().error.code).toBe("PERM_001"); + expect(tx.leaveRequest.update).not.toHaveBeenCalled(); + + await app.close(); + }); +});