diff --git a/client/src/api/employees.ts b/client/src/api/employees.ts index 16528e27..27b0765d 100644 --- a/client/src/api/employees.ts +++ b/client/src/api/employees.ts @@ -1,6 +1,7 @@ import { apiRequestData } from "./http"; export type EmployeeStatus = "active" | "archived"; +export type EmployeeEmploymentStatus = "onboarding" | "probation" | "active" | "offboarding" | "inactive"; export type EmployeeContractType = "labor" | "nda" | "non_compete" | "internship"; export type EmployeeContractStatus = "active" | "expired" | "terminated"; @@ -17,8 +18,12 @@ export type EmployeeSummary = { email: string; status: EmployeeStatus; departmentId: string; + department: EmployeeDepartmentSummary | null; directManagerId: string | null; + directManager: { id: string; employeeNo: string; name: string } | null; title: string; + employmentStatus: EmployeeEmploymentStatus | null; + hireDate: string | null; avatarUrl: string | null; roleCodes: string[]; createdAt: string | null; @@ -119,7 +124,7 @@ export type EmployeeProfile = { phone: string; email: string; status: EmployeeStatus; - employmentStatus: "onboarding" | "probation" | "active" | "offboarding" | "inactive"; + employmentStatus: EmployeeEmploymentStatus; departmentId: string; title: string; directManagerId: string | null; @@ -158,19 +163,80 @@ export type EmployeeProfile = { }; }; -export async function fetchEmployees(params: { +export type FetchEmployeesParams = { page: number; pageSize: number; status?: EmployeeStatus; departmentId?: string; q?: string; -}) { + title?: string; + managerId?: string; + hireDateFrom?: string; + hireDateTo?: string; + employmentStatus?: EmployeeEmploymentStatus; +}; + +export type BatchOperationResult = { + summary: { + total: number; + updated: number; + }; + employeeIds: string[]; +}; + +export type EmployeeStatsResponse = { + asOfDate: string; + employmentStatusSummary: { + total: number; + items: Array<{ + status: EmployeeEmploymentStatus; + count: number; + }>; + }; + departmentHeadcount: Array<{ + departmentId: string; + departmentName: string; + count: number; + }>; + recentMovements: { + dateFrom: string; + dateTo: string; + hires: number; + departures: number; + }; + expiringContracts: { + dateFrom: string; + dateTo: string; + count: number; + }; +}; + +export type BatchTransferDepartmentPayload = { + employeeIds: string[]; + departmentId: string; +}; + +export type BatchToggleUsersPayload = { + employeeIds: string[]; +}; + +export type BatchSetManagerPayload = { + employeeIds: string[]; + managerId: string; +}; + +export async function fetchEmployees(params: FetchEmployeesParams) { const search = new URLSearchParams(); search.set("page", String(params.page)); search.set("pageSize", String(params.pageSize)); if (params.status) search.set("status", params.status); if (params.departmentId) search.set("departmentId", params.departmentId); if (params.q) search.set("q", params.q); + if (params.title) search.set("title", params.title); + if (params.managerId) search.set("managerId", params.managerId); + if (params.hireDateFrom) search.set("hireDateFrom", params.hireDateFrom); + if (params.hireDateTo) search.set("hireDateTo", params.hireDateTo); + if (params.employmentStatus) search.set("employmentStatus", params.employmentStatus); return apiRequestData(`/api/employees?${search.toString()}`); } @@ -264,6 +330,10 @@ export async function fetchEmployeeOrganization() { return apiRequestData<{ items: OrganizationDepartmentSection[] }>("/api/employees/organization"); } +export async function fetchEmployeeStats() { + return apiRequestData("/api/employees/stats"); +} + export async function fetchEmployeeContracts(employeeId: string) { return apiRequestData<{ items: EmployeeContract[] }>(`/api/employees/${employeeId}/contracts`); } @@ -316,3 +386,31 @@ export async function skipEmployeeChecklistTask(employeeId: string, taskId: stri body: JSON.stringify({}), }); } + +export async function batchTransferEmployeesDepartment(payload: BatchTransferDepartmentPayload) { + return apiRequestData("/api/users/batch/transfer-department", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function batchEnableUsers(payload: BatchToggleUsersPayload) { + return apiRequestData("/api/users/batch/enable", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function batchDisableUsers(payload: BatchToggleUsersPayload) { + return apiRequestData("/api/users/batch/disable", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function batchSetEmployeesManager(payload: BatchSetManagerPayload) { + return apiRequestData("/api/users/batch/set-manager", { + method: "POST", + body: JSON.stringify(payload), + }); +} diff --git a/client/src/api/exports.ts b/client/src/api/exports.ts index 7fa298a0..5de0f845 100644 --- a/client/src/api/exports.ts +++ b/client/src/api/exports.ts @@ -61,6 +61,11 @@ export async function exportEmployees(params: { status?: "active" | "archived"; departmentId?: string; q?: string; + title?: string; + managerId?: string; + hireDateFrom?: string; + hireDateTo?: string; + employmentStatus?: "onboarding" | "probation" | "active" | "offboarding" | "inactive"; limit?: number; }) { const qs = new URLSearchParams(); @@ -68,6 +73,11 @@ export async function exportEmployees(params: { if (params.status) qs.set("status", params.status); if (params.departmentId) qs.set("departmentId", params.departmentId); if (params.q) qs.set("q", params.q); + if (params.title) qs.set("title", params.title); + if (params.managerId) qs.set("managerId", params.managerId); + if (params.hireDateFrom) qs.set("hireDateFrom", params.hireDateFrom); + if (params.hireDateTo) qs.set("hireDateTo", params.hireDateTo); + if (params.employmentStatus) qs.set("employmentStatus", params.employmentStatus); if (typeof params.limit === "number") qs.set("limit", String(params.limit)); return apiRequestBlob(`/api/exports/employees?${qs.toString()}`); diff --git a/client/src/pages/PeoplePage.tsx b/client/src/pages/PeoplePage.tsx index 10688746..ae0a5fb6 100644 --- a/client/src/pages/PeoplePage.tsx +++ b/client/src/pages/PeoplePage.tsx @@ -1,11 +1,18 @@ +import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; -import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { + batchDisableUsers, + batchEnableUsers, + batchSetEmployeesManager, + batchTransferEmployeesDepartment, fetchEmployees, + fetchEmployeeOrganization, regularizeEmployee, startEmployeeOffboarding, startEmployeeOnboarding, + type EmployeeEmploymentStatus, + type OrganizationTreeNode, } from "../api/employees"; import { exportEmployees } from "../api/exports"; import { fetchDepartments } from "../api/departments"; @@ -13,32 +20,94 @@ import { isApiError } from "../api/types"; import { mapApiErrorMessage } from "../api/errorMessages"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; -import { downloadBlob } from "../lib/download"; import { EmployeeAvatar } from "../components/common/EmployeeAvatar"; +import { ConfirmDialog } from "../components/common/ConfirmDialog"; import { PageHeader } from "../components/common/PageHeader"; import { Button } from "../components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu"; import { Input } from "../components/ui/input"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; import { StatusBadge } from "../components/common/StatusBadge"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; +import { downloadBlob } from "../lib/download"; import { toast } from "sonner"; +const EMPLOYMENT_STATUS_OPTIONS: Array<{ value: EmployeeEmploymentStatus; label: string }> = [ + { value: "onboarding", label: "待入职" }, + { value: "probation", label: "试用期" }, + { value: "active", label: "在职" }, + { value: "offboarding", label: "离职办理中" }, + { value: "inactive", label: "已离职" }, +]; + +function mapApiMessage(error: unknown, fallback: string) { + return isApiError(error) + ? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}` + : error instanceof Error + ? error.message + : fallback; +} + +function employmentStatusLabel(status: EmployeeEmploymentStatus | null) { + return EMPLOYMENT_STATUS_OPTIONS.find((item) => item.value === status)?.label ?? "-"; +} + +function collectOrganizationEmployees(nodes: OrganizationTreeNode[], target: Map) { + for (const node of nodes) { + if (!target.has(node.id)) { + target.set(node.id, { + id: node.id, + name: node.name, + employeeNo: node.employeeNo, + departmentName: node.department?.name ?? "未分配部门", + }); + } + collectOrganizationEmployees(node.children, target); + } +} + export function PeoplePage() { const { permissions } = useAuth(); const queryClient = useQueryClient(); + const canManageUsers = hasPermission(permissions, "system.users"); + const canViewStats = canManageUsers || hasPermission(permissions, "report.dashboard.view"); + const [q, setQ] = useState(""); const [status, setStatus] = useState<"" | "active" | "archived">(""); const [departmentId, setDepartmentId] = useState(""); + const [title, setTitle] = useState(""); + const [managerId, setManagerId] = useState(""); + const [hireDateFrom, setHireDateFrom] = useState(""); + const [hireDateTo, setHireDateTo] = useState(""); + const [employmentStatus, setEmploymentStatus] = useState<"" | EmployeeEmploymentStatus>(""); const [exportError, setExportError] = useState(""); - const canExportEmployees = hasPermission(permissions, "system.users"); + const [selectedEmployeeIds, setSelectedEmployeeIds] = useState([]); + const [transferDialogOpen, setTransferDialogOpen] = useState(false); + const [managerDialogOpen, setManagerDialogOpen] = useState(false); + const [enableConfirmOpen, setEnableConfirmOpen] = useState(false); + const [disableConfirmOpen, setDisableConfirmOpen] = useState(false); + const [targetDepartmentId, setTargetDepartmentId] = useState(""); + const [targetManagerId, setTargetManagerId] = useState(""); const departmentsQuery = useQuery({ queryKey: ["departments", "people-page", "active"], queryFn: () => fetchDepartments({ includeArchived: false }), }); + const organizationQuery = useQuery({ + queryKey: ["employee-organization", "people-page"], + queryFn: fetchEmployeeOrganization, + }); + const employeesQuery = useQuery({ - queryKey: ["employees", "people-page", q, status, departmentId], + queryKey: ["employees", "people-page", q, status, departmentId, title, managerId, hireDateFrom, hireDateTo, employmentStatus], queryFn: () => fetchEmployees({ page: 1, @@ -46,54 +115,56 @@ export function PeoplePage() { q: q || undefined, status: status || undefined, departmentId: departmentId || undefined, + title: title || undefined, + managerId: managerId || undefined, + hireDateFrom: hireDateFrom || undefined, + hireDateTo: hireDateTo || undefined, + employmentStatus: employmentStatus || undefined, }), }); + useEffect(() => { + setSelectedEmployeeIds([]); + }, [q, status, departmentId, title, managerId, hireDateFrom, hireDateTo, employmentStatus]); + + async function invalidatePeopleQueries() { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["employees"] }), + queryClient.invalidateQueries({ queryKey: ["employee-organization"] }), + queryClient.invalidateQueries({ queryKey: ["employee-stats"] }), + ]); + } + const onboardingMutation = useMutation({ mutationFn: (employeeId: string) => startEmployeeOnboarding(employeeId), onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["employees"] }); + await invalidatePeopleQueries(); toast.success("已发起入职流程"); }, onError: (error) => { - const message = isApiError(error) - ? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}` - : error instanceof Error - ? error.message - : "发起入职失败"; - toast.error(message); + toast.error(mapApiMessage(error, "发起入职失败")); }, }); const regularizeMutation = useMutation({ mutationFn: (employeeId: string) => regularizeEmployee(employeeId), onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["employees"] }); + await invalidatePeopleQueries(); toast.success("已完成转正"); }, onError: (error) => { - const message = isApiError(error) - ? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}` - : error instanceof Error - ? error.message - : "转正失败"; - toast.error(message); + toast.error(mapApiMessage(error, "转正失败")); }, }); const offboardingMutation = useMutation({ mutationFn: (employeeId: string) => startEmployeeOffboarding(employeeId), onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["employees"] }); + await invalidatePeopleQueries(); toast.success("已发起离职流程"); }, onError: (error) => { - const message = isApiError(error) - ? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}` - : error instanceof Error - ? error.message - : "发起离职失败"; - toast.error(message); + toast.error(mapApiMessage(error, "发起离职失败")); }, }); @@ -104,6 +175,11 @@ export function PeoplePage() { q: q || undefined, status: status || undefined, departmentId: departmentId || undefined, + title: title || undefined, + managerId: managerId || undefined, + hireDateFrom: hireDateFrom || undefined, + hireDateTo: hireDateTo || undefined, + employmentStatus: employmentStatus || undefined, }), onSuccess: (response, format) => { downloadBlob(response.blob, response.filename ?? `employees.${format}`); @@ -111,27 +187,117 @@ export function PeoplePage() { toast.success("导出完成"); }, onError: (error) => { - const message = isApiError(error) - ? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}` - : error instanceof Error - ? error.message - : "导出失败"; + const message = mapApiMessage(error, "导出失败"); setExportError(message); toast.error(message); }, }); + const transferDepartmentMutation = useMutation({ + mutationFn: () => + batchTransferEmployeesDepartment({ + employeeIds: selectedEmployeeIds, + departmentId: targetDepartmentId, + }), + onSuccess: async (result) => { + await invalidatePeopleQueries(); + setSelectedEmployeeIds([]); + setTransferDialogOpen(false); + setTargetDepartmentId(""); + toast.success(`已批量调部门 ${result.summary.updated} 人`); + }, + onError: (error) => { + toast.error(mapApiMessage(error, "批量调部门失败")); + }, + }); + + const setManagerMutation = useMutation({ + mutationFn: () => + batchSetEmployeesManager({ + employeeIds: selectedEmployeeIds, + managerId: targetManagerId, + }), + onSuccess: async (result) => { + await invalidatePeopleQueries(); + setSelectedEmployeeIds([]); + setManagerDialogOpen(false); + setTargetManagerId(""); + toast.success(`已批量设置上级 ${result.summary.updated} 人`); + }, + onError: (error) => { + toast.error(mapApiMessage(error, "批量设置上级失败")); + }, + }); + + const batchEnableMutation = useMutation({ + mutationFn: () => batchEnableUsers({ employeeIds: selectedEmployeeIds }), + onSuccess: async (result) => { + await invalidatePeopleQueries(); + setSelectedEmployeeIds([]); + toast.success(`已批量启用 ${result.summary.updated} 人`); + }, + onError: (error) => { + toast.error(mapApiMessage(error, "批量启用失败")); + }, + }); + + const batchDisableMutation = useMutation({ + mutationFn: () => batchDisableUsers({ employeeIds: selectedEmployeeIds }), + onSuccess: async (result) => { + await invalidatePeopleQueries(); + setSelectedEmployeeIds([]); + toast.success(`已批量禁用 ${result.summary.updated} 人`); + }, + onError: (error) => { + toast.error(mapApiMessage(error, "批量禁用失败")); + }, + }); + const items = employeesQuery.data?.items ?? []; const departments = departmentsQuery.data?.items ?? []; + const managerMap = new Map(); + for (const section of organizationQuery.data?.items ?? []) { + collectOrganizationEmployees(section.roots, managerMap); + } + const managerOptions = Array.from(managerMap.values()).sort( + (left, right) => left.departmentName.localeCompare(right.departmentName) || left.employeeNo.localeCompare(right.employeeNo), + ); + const batchPending = + transferDepartmentMutation.isPending || + setManagerMutation.isPending || + batchEnableMutation.isPending || + batchDisableMutation.isPending; + const tableColumnCount = canManageUsers ? 7 : 6; + + function toggleSelection(employeeId: string, checked: boolean) { + setSelectedEmployeeIds((current) => { + if (!checked) { + return current.filter((item) => item !== employeeId); + } + if (current.includes(employeeId)) { + return current; + } + if (current.length >= 50) { + toast.error("单次最多选择 50 名员工"); + return current; + } + return [...current, employeeId]; + }); + } return (
- {canExportEmployees ? ( +
+ {canViewStats ? ( + + ) : null} + {canManageUsers ? ( + {canManageUsers ? ( + + ) : null}
} /> -
-
+
+
setQ(event.target.value)} /> + setTitle(event.target.value)} + /> + + + setHireDateFrom(event.target.value)} + /> + setHireDateTo(event.target.value)} + /> +
+
+ 入职日期从 + {hireDateFrom || "-"} + 到 + {hireDateTo || "-"}
+ {canManageUsers && selectedEmployeeIds.length ? ( +
+
+ 已选 {selectedEmployeeIds.length} 名员工 +
+
+ + + + + +
+
+ ) : null} +
{exportError ? (

@@ -194,19 +448,16 @@ export function PeoplePage() { ) : null} {employeesQuery.isError ? (

- {isApiError(employeesQuery.error) - ? `${employeesQuery.error.code}: ${mapApiErrorMessage( - employeesQuery.error.code, - employeesQuery.error.details, - employeesQuery.error.message, - )}` - : "加载人员列表失败"} + {mapApiMessage(employeesQuery.error, "加载人员列表失败")}

) : null} + {canManageUsers ? 选择 : null} 员工 + 部门 / 上级 + 岗位 / 雇用状态 角色 账号状态 操作 @@ -216,6 +467,18 @@ export function PeoplePage() { {items.length ? ( items.map((item) => ( + {canManageUsers ? ( + + toggleSelection(item.id, event.target.checked)} + /> + + ) : null}
@@ -227,29 +490,51 @@ export function PeoplePage() {
+ +
+
{item.department?.name ?? "-"}
+
+ 上级:{item.directManager ? `${item.directManager.name} · ${item.directManager.employeeNo}` : "-"} +
+
+
+ +
+
{item.title || "-"}
+
+ {employmentStatusLabel(item.employmentStatus)} · 入职 {item.hireDate ?? "-"} +
+
+
{item.roleCodes.join(" / ") || "-"} - + + +
- - - + {canManageUsers ? ( + <> + + + + + ) : null}
)) ) : ( - + 暂无匹配员工 @@ -257,6 +542,111 @@ export function PeoplePage() {
+ + + + + 批量调部门 + + 将对已选 {selectedEmployeeIds.length} 名员工统一调部门,并写入 transfer 生命周期事件。 + + +
+ + +
+ + + + +
+
+ + + + + 批量设置直属上级 + + 将对已选 {selectedEmployeeIds.length} 名员工统一设置直属上级,并写入 transfer 生命周期事件。 + + +
+ + +
+ + + + +
+
+ + { + await batchEnableMutation.mutateAsync(); + }} + /> + + { + await batchDisableMutation.mutateAsync(); + }} + />
); } diff --git a/client/src/pages/PeopleStatsPage.tsx b/client/src/pages/PeopleStatsPage.tsx new file mode 100644 index 00000000..87d2e40b --- /dev/null +++ b/client/src/pages/PeopleStatsPage.tsx @@ -0,0 +1,121 @@ +import { Link } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { fetchEmployeeStats, type EmployeeEmploymentStatus } from "../api/employees"; +import { PageHeader } from "../components/common/PageHeader"; +import { Button } from "../components/ui/button"; + +const EMPLOYMENT_STATUS_LABELS: Record = { + onboarding: "待入职", + probation: "试用期", + active: "在职", + offboarding: "离职办理中", + inactive: "已离职", +}; + +function employmentStatusLabel(status: EmployeeEmploymentStatus) { + return EMPLOYMENT_STATUS_LABELS[status] ?? status; +} + +export function PeopleStatsPage() { + const statsQuery = useQuery({ + queryKey: ["employee-stats"], + queryFn: fetchEmployeeStats, + }); + + if (statsQuery.isLoading) { + return
加载员工统计中...
; + } + + if (statsQuery.isError || !statsQuery.data) { + return
加载员工统计失败
; + } + + const stats = statsQuery.data; + + return ( +
+ + 返回人员管理 + + } + /> + +
+
+
在职人数
+
{stats.employmentStatusSummary.total}
+
按 active 账号员工汇总
+
+
+
近 30 天入职
+
{stats.recentMovements.hires}
+
+ {stats.recentMovements.dateFrom} 至 {stats.recentMovements.dateTo} +
+
+
+
近 30 天离职
+
{stats.recentMovements.departures}
+
+ {stats.recentMovements.dateFrom} 至 {stats.recentMovements.dateTo} +
+
+
+
30 天内合同到期
+
{stats.expiringContracts.count}
+
+ {stats.expiringContracts.dateFrom} 至 {stats.expiringContracts.dateTo} +
+
+
+ +
+
+
+
+

雇用状态分布

+

按当前 active 账号员工的 employment status 聚合。

+
+
+
+ {stats.employmentStatusSummary.items.length ? ( + stats.employmentStatusSummary.items.map((item) => ( +
+
{employmentStatusLabel(item.status)}
+
{item.count}
+
+ )) + ) : ( +
暂无雇用状态统计。
+ )} +
+
+ +
+
+

部门人数分布

+

按当前 active 账号员工的部门归属统计。

+
+
+ {stats.departmentHeadcount.length ? ( + stats.departmentHeadcount.map((item) => ( +
+
{item.departmentName}
+
{item.count}
+
+ )) + ) : ( +
暂无部门人数统计。
+ )} +
+
+
+
+ ); +} + +export default PeopleStatsPage; diff --git a/client/src/router/index.tsx b/client/src/router/index.tsx index 3b3976cc..533e4949 100644 --- a/client/src/router/index.tsx +++ b/client/src/router/index.tsx @@ -34,6 +34,7 @@ import { OrganizationPage, PaymentBatchesPage, PeoplePage, + PeopleStatsPage, PortalAnnouncementsPage, PortalHomePage, ProjectsPage, @@ -321,6 +322,15 @@ export function AppRouter() { } /> + + {withRouteFallback()} + + } + /> + )} /> import("./NotificationsPage")); export const AnnouncementsPage = lazy(() => import("./AnnouncementsPage")); export const PaymentBatchesPage = lazy(() => import("./PaymentBatchesPage")); export const PeoplePage = lazy(() => import("./PeoplePage")); +export const PeopleStatsPage = lazy(() => import("./PeopleStatsPage")); export const OrganizationPage = lazy(() => import("./OrganizationPage")); export const EmployeeProfilePage = lazy(() => import("./EmployeeProfilePage")); export const TeamPresencePage = lazy(() => import("./TeamPresencePage")); diff --git a/issues/2026-03-29_r44-employee-batch-filter-stats.csv b/issues/2026-03-29_r44-employee-batch-filter-stats.csv new file mode 100644 index 00000000..6f93fe85 --- /dev/null +++ b/issues/2026-03-29_r44-employee-batch-filter-stats.csv @@ -0,0 +1,9 @@ +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +A1,R44 合同建档与范围冻结,"创建 R44 Issue CSV,冻结范围为高级筛选、批量操作、统计页;锁定批量调部门/设置上级沿用 transfer 生命周期语义,统计页采用独立 /people/stats。","issues/2026-03-29_r44-employee-batch-filter-stats.csv 包含完整列、依赖关系和测试方法;不夹带导入、档案编辑、合同附件上传。","对照 AGENTS.md 与 issues/README.md 检查 CSV 列完整、Dependencies 为 JSON 数组字符串、Test_Method 含业务流程步骤;确认范围仅覆盖本轮 3 项。",manual,DONE,DONE,DONE,issues/2026-03-29_r44-employee-batch-filter-stats.csv,"[]",none +A2,员工高级筛选后端与导出对齐,"扩展 /api/employees 和 /api/exports/employees 的 query,新增 title、managerId、hireDateFrom、hireDateTo、employmentStatus;抽取共享筛选 builder;将 /api/employees 读取权限对齐到 system.users + report.dashboard.view。","列表与导出支持全部高级筛选;title 模糊、managerId 精确、hireDate 闭区间、employmentStatus 枚举生效;report.dashboard.view 可只读访问列表;导出仍仅 system.users。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/users.js && node --check src/routes/exports.js && node --check src/routes/employees.js。业务:分别用 system.users 和 report.dashboard.view 访问 /people,按 q+departmentId+status+title+managerId+hireDateFrom/hireDateTo+employmentStatus 组合筛选,列表结果正确;同样条件导出 CSV/XLSX 与页面一致;hireDateFrom>hireDateTo 返回 VALID_001。",manual,DONE,DONE,DONE,server/src/routes/users.js|server/src/routes/exports.js|server/src/routes/employees.js|server/src/services/employeeDirectoryFilters.js,"[""A1""]","回滚:删除高级筛选 query 与共享 builder,恢复 /api/employees 原读取权限口径。" +A3,PeoplePage 高级筛选前端接入,"在 PeoplePage 增加职位、直属上级、入职日期范围、雇用状态筛选,并把全部筛选参数透传到 fetchEmployees/exportEmployees;页头增加员工统计入口。","PeoplePage 可直接组合高级筛选且列表自动刷新;导出继续复用当前筛选状态;report.dashboard.view 可筛选只读列表但看不到导出按钮。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:在 PeoplePage 依次修改职位、直属上级、入职日期范围、雇用状态,列表立即刷新;清空筛选后恢复;system.users 可导出当前筛选结果;report.dashboard.view 无导出入口。",manual,DONE,DONE,DONE,client/src/pages/PeoplePage.tsx|client/src/api/employees.ts|client/src/api/exports.ts,"[""A2""]","直属上级候选项复用 /api/employees/organization 拍平,不新增重型选择组件。" +A4,员工批量操作后端,"新增 /api/users/batch/transfer-department、/enable、/disable、/set-manager;事务内整批执行;复用单条 transfer 和启停账号语义;每批写审计日志。","4 个批量端点均仅 system.users 可用;单次上限 50 人;任一员工失败整批回滚;批量调部门/设置上级会为每个员工写 transfer 生命周期事件和 checklist;批量启停复用 token_version 与 authSession 吊销语义。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/users.js && node --check src/routes/employees.js。业务:选 2-3 名员工批量调部门后,员工部门变化且每人新增 transfer 事件与 handover_note;批量设置上级后 direct_manager_id 更新且新增 transfer 事件;批量禁用后账号归档且会话吊销;批量启用后恢复 active;包含自己执行批量禁用返回 409 且整批不落库;提交 51 人返回校验错误。",manual,DONE,DONE,DONE,server/src/routes/users.js|server/src/routes/employees.js|server/src/services/employeeLifecycle.js,"[""A1""]","回滚:回撤批量路由和共享 helper;已产生的 lifecycle/audit 记录作为业务痕迹保留,不做数据回写。" +A5,PeoplePage 多选与批量操作栏,"在 PeoplePage 增加 checkbox 多选、批量操作栏和对应弹窗;仅 system.users 可见;与现有筛选/列表共存。","system.users 可在当前页多选员工并执行批量调部门、批量启用/禁用、批量设置上级;达到 50 人停止继续勾选;筛选变化、数据刷新、操作成功后清空选择;report.dashboard.view 看不到任何批量入口。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:在 PeoplePage 勾选多名员工后出现批量操作栏;批量调部门、启用、禁用、设置上级分别成功并刷新列表;筛选变化后已选中项被清空;report.dashboard.view 进入页面仍为只读列表。",manual,DONE,DONE,DONE,client/src/pages/PeoplePage.tsx|client/src/api/employees.ts,"[""A3"",""A4""]","本轮不做跨页全选;批量设置上级要求选择一个目标上级,不支持清空直属上级。" +A6,员工统计后端聚合接口,"新增 GET /api/employees/stats,返回雇用状态分布、部门人数分布、近 30 天入职/离职人数、30 天内即将到期合同数;实现仅用 Prisma groupBy/count。","/api/employees/stats 可被 system.users 和 report.dashboard.view 访问;返回仅包含聚合数据,无员工明细;统计口径按合同说明固定。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/employees.js。业务:构造含 onboarding/probation/active/offboarding/inactive、跨部门、近 30 天入职/离职、30 天内到期合同的样本数据后,请求 /api/employees/stats,返回的状态分布、部门人数、入离职人数、合同即将到期数均正确;report.dashboard.view 同样可访问。",manual,DONE,DONE,DONE,server/src/routes/employees.js|server/src/services/employeeStats.js,"[""A1""]","本轮不返回员工级提醒列表;回滚:删除 stats 路由与聚合 service。" +A7,员工统计页面与路由,"新增 /people/stats 页面和前端 API,展示统计卡片与简单列表;从 PeoplePage 页头进入,不新增全局导航项。","/people/stats 可由 system.users 与 report.dashboard.view 访问;页面展示状态分布、部门人数分布、近 30 天入离职、合同即将到期卡片;PeoplePage 入口与返回链路可用。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:从 PeoplePage 点击员工统计进入 /people/stats,页面加载成功;system.users 与 report.dashboard.view 都可查看;返回 PeoplePage 后现有筛选和列表流程不回归。",manual,DONE,DONE,DONE,client/src/pages/PeopleStatsPage.tsx|client/src/router/index.tsx|client/src/router/lazy/index.ts|client/src/router/lazy/PeopleStatsPage.ts|client/src/pages/PeoplePage.tsx|client/src/api/employees.ts,"[""A5"",""A6""]","不引入图表库,使用数字卡片 + 简单列表。" +A8,Reviewer 与 QA 收口,"按 AGENTS 编排触发 reviewer 与 qa,重点检查批量操作权限边界、事务回滚、安全审计和统计/筛选回归,并回填 CSV 状态。","A2-A7 的 Review1_Status 与 Regression_Status 全部有证据地回填;reviewer 风险项关闭;R40-R43 相关员工管理能力不回归。","Reviewer:审查 /api/employees 读权限调整、批量操作事务安全、transfer 生命周期复用、审计日志是否完整。QA:运行 backend post-check、frontend post-check,并手工回归 3 条主线:高级筛选+导出一致、批量调部门/启停/设置上级、/people/stats 汇总展示;同时确认 R40 档案编辑、R41 批量导入、R43 合同/头像/组织架构不回归。",manual,DONE,DONE,DONE,issues/2026-03-29_r44-employee-batch-filter-stats.csv,"[""A2"",""A3"",""A4"",""A5"",""A6"",""A7""]","reviewer 必启用:本轮涉及多人状态变更、权限边界与事务回滚。" diff --git a/server/src/routes/employees.js b/server/src/routes/employees.js index 5fd6efbb..223ec957 100644 --- a/server/src/routes/employees.js +++ b/server/src/routes/employees.js @@ -2,6 +2,8 @@ const sharp = require("sharp"); const { z } = require("zod"); const usersRoutes = require("./users"); +const { buildDefaultChecklist, resolveEffectiveDate, applyEmployeeTransfer } = require("../services/employeeLifecycle"); +const { buildEmployeeStats } = require("../services/employeeStats"); const { currentDateString } = require("../utils/date"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); @@ -103,10 +105,6 @@ async function ensureFeatureEnabled(fastify, request) { } } -function resolveEffectiveDate(rawValue) { - return rawValue ? new Date(`${rawValue}T00:00:00.000Z`) : new Date(); -} - function canManageProfile(fastify, request, employeeId) { return request.employee.id === employeeId || fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "report.dashboard.view"); } @@ -242,26 +240,6 @@ async function ensureEmployeeContractExists(prisma, employeeId, contractId) { return contract; } -function buildDefaultChecklist(scene, employeeId, actorId) { - const common = { employee_id: employeeId }; - if (scene === "onboarding") { - return [ - { ...common, scene, task_type: "account_setup", title: "完成账号与权限初始化", status: "pending" }, - { ...common, scene, task_type: "asset_issue", title: "确认办公资产发放", status: "pending" }, - { ...common, scene, task_type: "policy_ack", title: "确认制度与入职须知", status: "pending" }, - ]; - } - if (scene === "transfer") { - return [{ ...common, scene, task_type: "handover_note", title: "确认调岗交接说明", status: "pending" }]; - } - return [ - { ...common, scene, task_type: "asset_return", title: "确认资产归还/转交", status: "pending" }, - { ...common, scene, task_type: "access_revoke", title: "确认账号和权限回收", status: "pending" }, - { ...common, scene, task_type: "expense_clearance", title: "确认费用与审批待办已清理", status: "pending" }, - { ...common, scene, task_type: "handover_note", title: "确认离职交接说明", status: "pending" }, - ]; -} - async function maybeAdvanceLifecycle(tx, employeeId) { const employee = await tx.employee.findUnique({ where: { id: employeeId } }); if (!employee) { @@ -449,7 +427,12 @@ async function buildEmployeeProfile(prisma, employeeId) { } async function employeesRoutes(fastify) { - await usersRoutes(fastify, { basePath: "/employees", allowDepartmentFilter: true }); + await usersRoutes(fastify, { + basePath: "/employees", + allowDepartmentFilter: true, + listPermissionCodes: ["system.users", "report.dashboard.view"], + enableBatchActions: false, + }); fastify.get("/avatars/:employeeId", async (request, reply) => { const employeeId = parseId(request.params?.employeeId); @@ -549,6 +532,17 @@ async function employeesRoutes(fastify) { return sendData(reply, request, { items }); }); + fastify.get("/employees/stats", { preHandler: fastify.authenticate }, async (request, reply) => { + if (!canViewPeopleDirectory(fastify, request)) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.stats.view" } }); + } + + const stats = await buildEmployeeStats(fastify.prisma, { + timeZone: fastify.config.appTimezone, + }); + return sendData(reply, request, stats); + }); + fastify.get("/employees/:id/profile", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const employeeId = parseId(request.params?.id); @@ -726,7 +720,7 @@ async function employeesRoutes(fastify) { created_by: request.employee.id, }, }); - const checklist = buildDefaultChecklist("onboarding", employeeId, request.employee.id).map((item) => ({ ...item, due_date: body.probationEndDate ? new Date(`${body.probationEndDate}T00:00:00.000Z`) : null })); + const checklist = buildDefaultChecklist("onboarding", employeeId).map((item) => ({ ...item, due_date: body.probationEndDate ? new Date(`${body.probationEndDate}T00:00:00.000Z`) : null })); await tx.employeeChecklistTask.createMany({ data: checklist, skipDuplicates: false }); }); const profile = await buildEmployeeProfile(fastify.prisma, employeeId); @@ -766,31 +760,14 @@ async function employeesRoutes(fastify) { const body = parseBody(TRANSFER_SCHEMA, request.body); await fastify.prisma.$transaction(async (tx) => { await ensureEmployeeExists(tx, employeeId); - await tx.employee.update({ - where: { id: employeeId }, - data: { - ...(body.departmentId !== undefined ? { department_id: body.departmentId } : {}), - ...(body.directManagerId !== undefined ? { direct_manager_id: body.directManagerId } : {}), - ...(body.title !== undefined ? { title: body.title } : {}), - updated_by: request.employee.id, - }, - }); - await tx.employeeLifecycleEvent.create({ - data: { - employee_id: employeeId, - event_type: "transfer", - effective_date: resolveEffectiveDate(body.effectiveDate), - payload_json: { - departmentId: body.departmentId ?? null, - directManagerId: body.directManagerId ?? null, - title: body.title ?? null, - notes: body.notes ?? null, - }, - created_by: request.employee.id, - }, - }); - await tx.employeeChecklistTask.createMany({ - data: buildDefaultChecklist("transfer", employeeId, request.employee.id), + await applyEmployeeTransfer(tx, { + employeeId, + actorId: request.employee.id, + departmentId: body.departmentId, + directManagerId: body.directManagerId, + title: body.title, + effectiveDate: body.effectiveDate, + notes: body.notes, }); }); const profile = await buildEmployeeProfile(fastify.prisma, employeeId); @@ -823,7 +800,7 @@ async function employeesRoutes(fastify) { created_by: request.employee.id, }, }); - await tx.employeeChecklistTask.createMany({ data: buildDefaultChecklist("offboarding", employeeId, request.employee.id) }); + await tx.employeeChecklistTask.createMany({ data: buildDefaultChecklist("offboarding", employeeId) }); }); const profile = await buildEmployeeProfile(fastify.prisma, employeeId); return sendData(reply, request, profile); diff --git a/server/src/routes/exports.js b/server/src/routes/exports.js index e0f04f46..ba8f3c75 100644 --- a/server/src/routes/exports.js +++ b/server/src/routes/exports.js @@ -3,6 +3,11 @@ const { Readable } = require("node:stream"); const ExcelJS = require("exceljs"); const { z } = require("zod"); +const { + EMPLOYMENT_STATUS_VALUES, + buildEmployeeDirectoryWhere, + parseEmployeeDirectoryFilters, +} = require("../services/employeeDirectoryFilters"); const { HISTORY_STATUS_VALUES, buildPresenceValidationError, @@ -61,6 +66,11 @@ const EMPLOYEE_EXPORT_QUERY_SCHEMA = z.object({ status: z.enum(["active", "archived"]).optional(), departmentId: z.string().uuid().optional(), q: z.string().max(100).optional(), + title: z.string().max(100).optional(), + managerId: z.string().uuid().optional(), + hireDateFrom: DATE_ONLY_SCHEMA.optional(), + hireDateTo: DATE_ONLY_SCHEMA.optional(), + employmentStatus: z.enum(EMPLOYMENT_STATUS_VALUES).optional(), limit: z.coerce.number().int().min(1).max(20000).optional(), }); @@ -193,25 +203,6 @@ function buildDashboardWhere(request, query) { }; } -function buildEmployeeExportWhere(query) { - const q = query.q?.trim() ?? ""; - - return { - ...(query.status ? { status: query.status } : {}), - ...(query.departmentId ? { department_id: query.departmentId } : {}), - ...(q - ? { - OR: [ - { employee_no: { contains: q, mode: "insensitive" } }, - { name: { contains: q, mode: "insensitive" } }, - { phone: { contains: q, mode: "insensitive" } }, - { email: { contains: q, mode: "insensitive" } }, - ], - } - : {}), - }; -} - async function* streamDashboardReimbursementsCsv(prisma, where, limit) { yield "\ufeff"; yield csvRow([ @@ -907,7 +898,8 @@ async function exportsRoutes(fastify) { }, async (request, reply) => { const query = parseQuery(EMPLOYEE_EXPORT_QUERY_SCHEMA, request.query); - const where = buildEmployeeExportWhere(query); + const filters = parseEmployeeDirectoryFilters(query, { allowDepartmentFilter: true }); + const where = buildEmployeeDirectoryWhere(filters); const format = resolveExportFormat(query); const limit = query.limit ?? 5000; const total = await fastify.prisma.employee.count({ where }); @@ -922,6 +914,11 @@ async function exportsRoutes(fastify) { status: query.status ?? null, departmentId: query.departmentId ?? null, q: query.q?.trim() || null, + title: query.title?.trim() || null, + managerId: query.managerId ?? null, + hireDateFrom: query.hireDateFrom ?? null, + hireDateTo: query.hireDateTo ?? null, + employmentStatus: query.employmentStatus ?? null, limit, matchedTotal: total, }, diff --git a/server/src/routes/users.js b/server/src/routes/users.js index 79a10c90..9be5d522 100644 --- a/server/src/routes/users.js +++ b/server/src/routes/users.js @@ -5,6 +5,13 @@ const { Readable } = require("node:stream"); const ExcelJS = require("exceljs"); const { z } = require("zod"); +const { + EMPLOYMENT_STATUS_VALUES, + buildEmployeeDirectoryWhere, + parseEmployeeDirectoryFilters, +} = require("../services/employeeDirectoryFilters"); +const { applyEmployeeTransfer } = require("../services/employeeLifecycle"); +const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); @@ -30,14 +37,9 @@ const roleCodeSchema = z.enum([ "admin", ]); -const listQuerySchema = z.object({ +const listPaginationSchema = z.object({ page: z.coerce.number().int().min(1).optional(), pageSize: z.coerce.number().int().min(1).max(100).optional(), - status: z.enum(["active", "archived"]).optional(), - q: z.string().max(100).optional(), -}); -const listQueryWithDepartmentSchema = listQuerySchema.extend({ - departmentId: z.string().uuid().optional(), }); const createBodySchema = z.object({ @@ -90,6 +92,18 @@ const USER_IMPORT_ALLOWED_MIME_TYPES = new Set([ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ]); const USER_IMPORT_MAX_ROWS = 500; +const BATCH_EMPLOYEE_IDS_SCHEMA = z.array(z.string().uuid()).min(1); +const BATCH_TRANSFER_DEPARTMENT_SCHEMA = z.object({ + employeeIds: BATCH_EMPLOYEE_IDS_SCHEMA, + departmentId: z.string().uuid(), +}).strict(); +const BATCH_TOGGLE_SCHEMA = z.object({ + employeeIds: BATCH_EMPLOYEE_IDS_SCHEMA, +}).strict(); +const BATCH_SET_MANAGER_SCHEMA = z.object({ + employeeIds: BATCH_EMPLOYEE_IDS_SCHEMA, + managerId: z.string().uuid(), +}).strict(); function parseUserId(params) { const parsed = z.string().uuid().safeParse(params?.id); @@ -105,9 +119,7 @@ function parseUserId(params) { } function parseListQuery(query, options = {}) { - const allowDepartmentFilter = options.allowDepartmentFilter === true; - const schema = allowDepartmentFilter ? listQueryWithDepartmentSchema : listQuerySchema; - const parsed = schema.safeParse(query ?? {}); + const parsed = listPaginationSchema.safeParse(query ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", @@ -120,9 +132,7 @@ function parseListQuery(query, options = {}) { return { page: parsed.data.page ?? 1, pageSize: parsed.data.pageSize ?? 20, - status: parsed.data.status, - q: parsed.data.q?.trim() ?? "", - departmentId: allowDepartmentFilter ? parsed.data.departmentId : undefined, + ...parseEmployeeDirectoryFilters(query, options), }; } @@ -185,7 +195,18 @@ function mapEmployeeSummary(row) { email: row.email, status: row.status, departmentId: row.department_id, + department: row.department ? { id: row.department.id, name: row.department.name } : null, + directManagerId: row.direct_manager_id ?? null, + directManager: row.direct_manager + ? { + id: row.direct_manager.id, + employeeNo: row.direct_manager.employee_no, + name: row.direct_manager.name, + } + : null, title: row.title, + employmentStatus: row.employment_status ?? null, + hireDate: row.hire_date?.toISOString?.()?.slice(0, 10) ?? null, avatarUrl: row.avatar_path ? `/api/avatars/${row.id}` : null, roleCodes: row.roles.map((item) => item.role_code), createdAt: row.created_at?.toISOString?.() ?? null, @@ -258,6 +279,163 @@ function buildValidationError(details, statusCode = 400) { }); } +function parseBatchBody(schema, body) { + const parsed = schema.safeParse(body ?? {}); + if (!parsed.success) { + throw buildValidationError(parsed.error.flatten()); + } + + const employeeIds = [...new Set(parsed.data.employeeIds)]; + if (employeeIds.length > 50) { + throw buildValidationError({ employeeIds: "too_many" }, 409); + } + + return { + ...parsed.data, + employeeIds, + }; +} + +function ensureHasAnyPermission(fastify, request, permissionCodes) { + if (permissionCodes.some((code) => fastify.hasPermission(request.employee, code))) { + return; + } + + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); +} + +async function ensureLifecycleFeatureEnabled(fastify, request) { + const gate = await evaluateFeatureFlag({ + prisma: fastify.prisma, + config: fastify.config, + flagKey: FEATURE_FLAG_KEYS.employeeLifecycle, + departmentId: request.employee.department_id ?? null, + strictDepartment: false, + }); + + if (!gate.enabled) { + throw buildValidationError({ featureFlag: "employee_lifecycle_v1_disabled", reason: gate.reason }, 409); + } +} + +async function ensureDepartmentUsable(prisma, departmentId, fieldName = "departmentId") { + const department = await prisma.department.findUnique({ + where: { id: departmentId }, + select: { id: true, archived_at: true }, + }); + + if (!department) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { [fieldName]: "not_found" }, + }); + } + + if (department.archived_at) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { [fieldName]: "archived" }, + }); + } +} + +async function ensureManagerUsable(prisma, managerId, options = {}) { + const fieldName = options.fieldName ?? "directManagerId"; + const disallowedEmployeeIds = options.disallowedEmployeeIds ?? []; + + if (disallowedEmployeeIds.includes(managerId)) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { [fieldName]: "self_not_allowed" }, + }); + } + + const manager = await prisma.employee.findUnique({ + where: { id: managerId }, + select: { id: true, status: true }, + }); + + if (!manager) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { [fieldName]: "not_found" }, + }); + } + + if (manager.status !== "active") { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { [fieldName]: "inactive" }, + }); + } +} + +async function ensureEmployeesExist(prisma, employeeIds) { + const employees = await prisma.employee.findMany({ + where: { id: { in: employeeIds } }, + select: { id: true }, + }); + + if (employees.length !== employeeIds.length) { + const existingIds = new Set(employees.map((item) => item.id)); + const missingId = employeeIds.find((item) => !existingIds.has(item)); + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + details: { employeeId: missingId ?? null }, + }); + } +} + +async function setEmployeeAccountStatus(tx, request, options) { + const updated = await tx.employee.update({ + where: { id: options.employeeId }, + data: { + status: options.status, + archived_at: options.status === "archived" ? options.now : null, + token_version: { increment: 1 }, + updated_by: options.actorId, + }, + select: { + id: true, + status: true, + token_version: true, + }, + }); + + await tx.authSession.updateMany({ + where: { employee_id: options.employeeId, revoked_at: null }, + data: { revoked_at: options.now }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: options.actorId, + action: options.status === "archived" ? "SYSTEM_USER_DISABLED" : "SYSTEM_USER_ENABLED", + targetType: "employee", + targetId: options.employeeId, + extra: {}, + }), + }); + + return updated; +} + function generateOneTimePassword() { const password = `ResetA1${crypto.randomBytes(6).toString("hex")}`; const errors = validatePassword(password); @@ -720,7 +898,10 @@ async function createSystemUser(tx, options) { email: true, status: true, department_id: true, + direct_manager_id: true, title: true, + employment_status: true, + hire_date: true, created_at: true, updated_at: true, }, @@ -745,31 +926,22 @@ async function usersRoutes(fastify, opts = {}) { const basePath = basePathRaw.startsWith("/") ? basePathRaw : `/${basePathRaw}`; const routeBasePath = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath; const allowDepartmentFilter = opts.allowDepartmentFilter === true; + const listPermissionCodes = + Array.isArray(opts.listPermissionCodes) && opts.listPermissionCodes.length + ? [...new Set(opts.listPermissionCodes)] + : ["system.users"]; + const enableBatchActions = routeBasePath === "/users" && opts.enableBatchActions !== false; fastify.get( routeBasePath, { - preHandler: fastify.requirePermission("system.users"), + preHandler: fastify.authenticate, }, async (request, reply) => { + ensureHasAnyPermission(fastify, request, listPermissionCodes); const query = parseListQuery(request.query, { allowDepartmentFilter }); const skip = (query.page - 1) * query.pageSize; - - const q = query.q; - const where = { - ...(query.status ? { status: query.status } : {}), - ...(query.departmentId ? { department_id: query.departmentId } : {}), - ...(q - ? { - OR: [ - { employee_no: { contains: q, mode: "insensitive" } }, - { name: { contains: q, mode: "insensitive" } }, - { phone: { contains: q, mode: "insensitive" } }, - { email: { contains: q, mode: "insensitive" } }, - ], - } - : {}), - }; + const where = buildEmployeeDirectoryWhere(query); const [items, total] = await Promise.all([ fastify.prisma.employee.findMany({ @@ -785,10 +957,15 @@ async function usersRoutes(fastify, opts = {}) { email: true, status: true, department_id: true, + direct_manager_id: true, title: true, + employment_status: true, + hire_date: true, avatar_path: true, created_at: true, updated_at: true, + department: { select: { id: true, name: true } }, + direct_manager: { select: { id: true, employee_no: true, name: true } }, roles: { select: { role_code: true } }, }, }), @@ -874,49 +1051,10 @@ async function usersRoutes(fastify, opts = {}) { const hireDate = body.hireDate ? new Date(`${body.hireDate}T00:00:00.000Z`) : new Date(); const departmentId = body.departmentId ?? request.employee.department_id; - const department = await fastify.prisma.department.findUnique({ - where: { id: departmentId }, - select: { id: true, archived_at: true }, - }); - if (!department) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { departmentId: "not_found" }, - }); - } - - if (department.archived_at) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { departmentId: "archived" }, - }); - } + await ensureDepartmentUsable(fastify.prisma, departmentId); if (typeof body.directManagerId === "string") { - const manager = await fastify.prisma.employee.findUnique({ - where: { id: body.directManagerId }, - select: { id: true, status: true }, - }); - if (!manager) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { directManagerId: "not_found" }, - }); - } - if (manager.status !== "active") { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { directManagerId: "inactive" }, - }); - } + await ensureManagerUsable(fastify.prisma, body.directManagerId); } const now = new Date(); @@ -980,6 +1118,192 @@ async function usersRoutes(fastify, opts = {}) { }, ); + if (enableBatchActions) { + fastify.post( + `${routeBasePath}/batch/transfer-department`, + { + preHandler: fastify.requirePermission("system.users"), + }, + async (request, reply) => { + await ensureLifecycleFeatureEnabled(fastify, request); + const body = parseBatchBody(BATCH_TRANSFER_DEPARTMENT_SCHEMA, request.body); + await ensureDepartmentUsable(fastify.prisma, body.departmentId); + + await fastify.prisma.$transaction(async (tx) => { + await ensureEmployeesExist(tx, body.employeeIds); + + for (const employeeId of body.employeeIds) { + await applyEmployeeTransfer(tx, { + employeeId, + actorId: request.employee.id, + departmentId: body.departmentId, + }); + } + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "SYSTEM_USERS_BATCH_TRANSFER_DEPARTMENT", + targetType: "employee_batch", + extra: { + employeeIds: body.employeeIds, + departmentId: body.departmentId, + summary: { + total: body.employeeIds.length, + updated: body.employeeIds.length, + }, + }, + }), + }); + }); + + return sendData(reply, request, { + summary: { total: body.employeeIds.length, updated: body.employeeIds.length }, + employeeIds: body.employeeIds, + }); + }, + ); + + fastify.post( + `${routeBasePath}/batch/set-manager`, + { + preHandler: fastify.requirePermission("system.users"), + }, + async (request, reply) => { + await ensureLifecycleFeatureEnabled(fastify, request); + const body = parseBatchBody(BATCH_SET_MANAGER_SCHEMA, request.body); + await ensureManagerUsable(fastify.prisma, body.managerId, { + fieldName: "managerId", + disallowedEmployeeIds: body.employeeIds, + }); + + await fastify.prisma.$transaction(async (tx) => { + await ensureEmployeesExist(tx, body.employeeIds); + + for (const employeeId of body.employeeIds) { + await applyEmployeeTransfer(tx, { + employeeId, + actorId: request.employee.id, + directManagerId: body.managerId, + }); + } + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "SYSTEM_USERS_BATCH_SET_MANAGER", + targetType: "employee_batch", + extra: { + employeeIds: body.employeeIds, + managerId: body.managerId, + summary: { + total: body.employeeIds.length, + updated: body.employeeIds.length, + }, + }, + }), + }); + }); + + return sendData(reply, request, { + summary: { total: body.employeeIds.length, updated: body.employeeIds.length }, + employeeIds: body.employeeIds, + }); + }, + ); + + fastify.post( + `${routeBasePath}/batch/disable`, + { + preHandler: fastify.requirePermission("system.users"), + }, + async (request, reply) => { + const body = parseBatchBody(BATCH_TOGGLE_SCHEMA, request.body); + if (body.employeeIds.includes(request.employee.id)) { + throw buildValidationError({ user: "self_disable_not_allowed" }, 409); + } + + const now = new Date(); + await fastify.prisma.$transaction(async (tx) => { + await ensureEmployeesExist(tx, body.employeeIds); + + for (const employeeId of body.employeeIds) { + await setEmployeeAccountStatus(tx, request, { + employeeId, + actorId: request.employee.id, + status: "archived", + now, + }); + } + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "SYSTEM_USERS_BATCH_DISABLED", + targetType: "employee_batch", + extra: { + employeeIds: body.employeeIds, + summary: { + total: body.employeeIds.length, + updated: body.employeeIds.length, + }, + }, + }), + }); + }); + + return sendData(reply, request, { + summary: { total: body.employeeIds.length, updated: body.employeeIds.length }, + employeeIds: body.employeeIds, + }); + }, + ); + + fastify.post( + `${routeBasePath}/batch/enable`, + { + preHandler: fastify.requirePermission("system.users"), + }, + async (request, reply) => { + const body = parseBatchBody(BATCH_TOGGLE_SCHEMA, request.body); + const now = new Date(); + + await fastify.prisma.$transaction(async (tx) => { + await ensureEmployeesExist(tx, body.employeeIds); + + for (const employeeId of body.employeeIds) { + await setEmployeeAccountStatus(tx, request, { + employeeId, + actorId: request.employee.id, + status: "active", + now, + }); + } + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "SYSTEM_USERS_BATCH_ENABLED", + targetType: "employee_batch", + extra: { + employeeIds: body.employeeIds, + summary: { + total: body.employeeIds.length, + updated: body.employeeIds.length, + }, + }, + }), + }); + }); + + return sendData(reply, request, { + summary: { total: body.employeeIds.length, updated: body.employeeIds.length }, + employeeIds: body.employeeIds, + }); + }, + ); + } + if (routeBasePath === "/users") { fastify.post( `${routeBasePath}/import/dry-run`, @@ -1146,59 +1470,14 @@ async function usersRoutes(fastify, opts = {}) { } if (typeof body.departmentId === "string") { - const department = await fastify.prisma.department.findUnique({ - where: { id: body.departmentId }, - select: { id: true, archived_at: true }, - }); - if (!department) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { departmentId: "not_found" }, - }); - } - if (department.archived_at) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { departmentId: "archived" }, - }); - } + await ensureDepartmentUsable(fastify.prisma, body.departmentId); } if (Object.prototype.hasOwnProperty.call(body, "directManagerId")) { - if (body.directManagerId === userId) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { directManagerId: "self_not_allowed" }, - }); - } - if (typeof body.directManagerId === "string") { - const manager = await fastify.prisma.employee.findUnique({ - where: { id: body.directManagerId }, - select: { id: true, status: true }, + await ensureManagerUsable(fastify.prisma, body.directManagerId, { + disallowedEmployeeIds: [userId], }); - if (!manager) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { directManagerId: "not_found" }, - }); - } - if (manager.status !== "active") { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { directManagerId: "inactive" }, - }); - } } } @@ -1605,37 +1884,12 @@ async function usersRoutes(fastify, opts = {}) { const now = new Date(); const employee = await fastify.prisma.$transaction(async (tx) => { - const updated = await tx.employee.update({ - where: { id: userId }, - data: { - status: "archived", - archived_at: now, - token_version: { increment: 1 }, - updated_by: request.employee.id, - }, - select: { - id: true, - status: true, - token_version: true, - }, + return setEmployeeAccountStatus(tx, request, { + employeeId: userId, + actorId: request.employee.id, + status: "archived", + now, }); - - await tx.authSession.updateMany({ - where: { employee_id: userId, revoked_at: null }, - data: { revoked_at: now }, - }); - - await tx.auditLog.create({ - data: buildAuditLogData(request, { - actorId: request.employee.id, - action: "SYSTEM_USER_DISABLED", - targetType: "employee", - targetId: userId, - extra: {}, - }), - }); - - return updated; }); return sendData(reply, request, { @@ -1655,37 +1909,12 @@ async function usersRoutes(fastify, opts = {}) { const now = new Date(); const employee = await fastify.prisma.$transaction(async (tx) => { - const updated = await tx.employee.update({ - where: { id: userId }, - data: { - status: "active", - archived_at: null, - token_version: { increment: 1 }, - updated_by: request.employee.id, - }, - select: { - id: true, - status: true, - token_version: true, - }, + return setEmployeeAccountStatus(tx, request, { + employeeId: userId, + actorId: request.employee.id, + status: "active", + now, }); - - await tx.authSession.updateMany({ - where: { employee_id: userId, revoked_at: null }, - data: { revoked_at: now }, - }); - - await tx.auditLog.create({ - data: buildAuditLogData(request, { - actorId: request.employee.id, - action: "SYSTEM_USER_ENABLED", - targetType: "employee", - targetId: userId, - extra: {}, - }), - }); - - return updated; }); return sendData(reply, request, { diff --git a/server/src/services/employeeDirectoryFilters.js b/server/src/services/employeeDirectoryFilters.js new file mode 100644 index 00000000..6c94b29c --- /dev/null +++ b/server/src/services/employeeDirectoryFilters.js @@ -0,0 +1,98 @@ +const { z } = require("zod"); + +const { AppError } = require("../utils/AppError"); + +const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); +const EMPLOYMENT_STATUS_VALUES = ["onboarding", "probation", "active", "offboarding", "inactive"]; + +function buildValidationError(details, statusCode = 400) { + return new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode, + details, + }); +} + +function parseEmployeeDirectoryFilters(query, options = {}) { + const allowDepartmentFilter = options.allowDepartmentFilter === true; + const schema = z.object({ + status: z.enum(["active", "archived"]).optional(), + q: z.string().max(100).optional(), + title: z.string().max(100).optional(), + managerId: z.string().uuid().optional(), + hireDateFrom: DATE_ONLY_SCHEMA.optional(), + hireDateTo: DATE_ONLY_SCHEMA.optional(), + employmentStatus: z.enum(EMPLOYMENT_STATUS_VALUES).optional(), + ...(allowDepartmentFilter ? { departmentId: z.string().uuid().optional() } : {}), + }); + const parsed = schema.safeParse(query ?? {}); + + if (!parsed.success) { + throw buildValidationError(parsed.error.flatten()); + } + + const filters = { + status: parsed.data.status, + q: parsed.data.q?.trim() ?? "", + title: parsed.data.title?.trim() ?? "", + managerId: parsed.data.managerId, + hireDateFrom: parsed.data.hireDateFrom, + hireDateTo: parsed.data.hireDateTo, + employmentStatus: parsed.data.employmentStatus, + departmentId: allowDepartmentFilter ? parsed.data.departmentId : undefined, + }; + + if (filters.hireDateFrom && filters.hireDateTo && filters.hireDateFrom > filters.hireDateTo) { + throw buildValidationError({ hireDateTo: "must_be_after_or_equal_hireDateFrom" }); + } + + return filters; +} + +function buildEmployeeDirectoryWhere(filters = {}) { + const q = filters.q?.trim() ?? ""; + const title = filters.title?.trim() ?? ""; + const and = []; + + if (q) { + and.push({ + OR: [ + { employee_no: { contains: q, mode: "insensitive" } }, + { name: { contains: q, mode: "insensitive" } }, + { phone: { contains: q, mode: "insensitive" } }, + { email: { contains: q, mode: "insensitive" } }, + ], + }); + } + + if (title) { + and.push({ + title: { contains: title, mode: "insensitive" }, + }); + } + + return { + ...(filters.status ? { status: filters.status } : {}), + ...(filters.departmentId ? { department_id: filters.departmentId } : {}), + ...(filters.managerId ? { direct_manager_id: filters.managerId } : {}), + ...(filters.employmentStatus ? { employment_status: filters.employmentStatus } : {}), + ...(filters.hireDateFrom || filters.hireDateTo + ? { + hire_date: { + ...(filters.hireDateFrom ? { gte: new Date(`${filters.hireDateFrom}T00:00:00.000Z`) } : {}), + ...(filters.hireDateTo ? { lte: new Date(`${filters.hireDateTo}T23:59:59.999Z`) } : {}), + }, + } + : {}), + ...(and.length ? { AND: and } : {}), + }; +} + +module.exports = { + DATE_ONLY_SCHEMA, + EMPLOYMENT_STATUS_VALUES, + buildEmployeeDirectoryWhere, + buildValidationError, + parseEmployeeDirectoryFilters, +}; diff --git a/server/src/services/employeeLifecycle.js b/server/src/services/employeeLifecycle.js new file mode 100644 index 00000000..9e2e4bac --- /dev/null +++ b/server/src/services/employeeLifecycle.js @@ -0,0 +1,63 @@ +function resolveEffectiveDate(rawValue) { + return rawValue ? new Date(`${rawValue}T00:00:00.000Z`) : new Date(); +} + +function buildDefaultChecklist(scene, employeeId) { + const common = { employee_id: employeeId }; + + if (scene === "onboarding") { + return [ + { ...common, scene, task_type: "account_setup", title: "完成账号与权限初始化", status: "pending" }, + { ...common, scene, task_type: "asset_issue", title: "确认办公资产发放", status: "pending" }, + { ...common, scene, task_type: "policy_ack", title: "确认制度与入职须知", status: "pending" }, + ]; + } + + if (scene === "transfer") { + return [{ ...common, scene, task_type: "handover_note", title: "确认调岗交接说明", status: "pending" }]; + } + + return [ + { ...common, scene, task_type: "asset_return", title: "确认资产归还/转交", status: "pending" }, + { ...common, scene, task_type: "access_revoke", title: "确认账号和权限回收", status: "pending" }, + { ...common, scene, task_type: "expense_clearance", title: "确认费用与审批待办已清理", status: "pending" }, + { ...common, scene, task_type: "handover_note", title: "确认离职交接说明", status: "pending" }, + ]; +} + +async function applyEmployeeTransfer(tx, options) { + await tx.employee.update({ + where: { id: options.employeeId }, + data: { + ...(options.departmentId !== undefined ? { department_id: options.departmentId } : {}), + ...(options.directManagerId !== undefined ? { direct_manager_id: options.directManagerId } : {}), + ...(options.title !== undefined ? { title: options.title } : {}), + updated_by: options.actorId, + }, + }); + + await tx.employeeLifecycleEvent.create({ + data: { + employee_id: options.employeeId, + event_type: "transfer", + effective_date: resolveEffectiveDate(options.effectiveDate), + payload_json: { + departmentId: options.departmentId ?? null, + directManagerId: options.directManagerId ?? null, + title: options.title ?? null, + notes: options.notes ?? null, + }, + created_by: options.actorId, + }, + }); + + await tx.employeeChecklistTask.createMany({ + data: buildDefaultChecklist("transfer", options.employeeId), + }); +} + +module.exports = { + applyEmployeeTransfer, + buildDefaultChecklist, + resolveEffectiveDate, +}; diff --git a/server/src/services/employeeStats.js b/server/src/services/employeeStats.js new file mode 100644 index 00000000..7689148c --- /dev/null +++ b/server/src/services/employeeStats.js @@ -0,0 +1,96 @@ +const { currentDateString, parseDateOnlyString } = require("../utils/date"); + +function addDays(date, days) { + const next = new Date(date); + next.setUTCDate(next.getUTCDate() + days); + return next; +} + +async function buildEmployeeStats(prisma, options = {}) { + const timeZone = options.timeZone ?? "Asia/Shanghai"; + const todayString = currentDateString(timeZone); + const today = parseDateOnlyString(todayString); + const last30DateFrom = addDays(today, -29); + const next30DateTo = addDays(today, 30); + const activeEmployeeWhere = { status: "active" }; + + const [employmentStatusRows, departmentRows, departments, recentHires, recentDepartures, expiringContracts] = await Promise.all([ + prisma.employee.groupBy({ + by: ["employment_status"], + where: activeEmployeeWhere, + _count: { _all: true }, + }), + prisma.employee.groupBy({ + by: ["department_id"], + where: activeEmployeeWhere, + _count: { _all: true }, + }), + prisma.department.findMany({ + where: { archived_at: null }, + select: { id: true, name: true }, + }), + prisma.employee.count({ + where: { + hire_date: { + gte: last30DateFrom, + lte: new Date(`${todayString}T23:59:59.999Z`), + }, + }, + }), + prisma.employee.count({ + where: { + last_working_date: { + gte: last30DateFrom, + lte: new Date(`${todayString}T23:59:59.999Z`), + }, + }, + }), + prisma.employeeContract.count({ + where: { + terminated_date: null, + end_date: { + gte: today, + lte: new Date(`${next30DateTo.toISOString().slice(0, 10)}T23:59:59.999Z`), + }, + employee: activeEmployeeWhere, + }, + }), + ]); + + const departmentMap = new Map(departments.map((item) => [item.id, item.name])); + + return { + asOfDate: todayString, + employmentStatusSummary: { + total: employmentStatusRows.reduce((sum, item) => sum + item._count._all, 0), + items: employmentStatusRows + .map((item) => ({ + status: item.employment_status, + count: item._count._all, + })) + .sort((left, right) => right.count - left.count || left.status.localeCompare(right.status)), + }, + departmentHeadcount: departmentRows + .map((item) => ({ + departmentId: item.department_id, + departmentName: departmentMap.get(item.department_id) ?? "未知部门", + count: item._count._all, + })) + .sort((left, right) => right.count - left.count || left.departmentName.localeCompare(right.departmentName)), + recentMovements: { + dateFrom: last30DateFrom.toISOString().slice(0, 10), + dateTo: todayString, + hires: recentHires, + departures: recentDepartures, + }, + expiringContracts: { + dateFrom: todayString, + dateTo: next30DateTo.toISOString().slice(0, 10), + count: expiringContracts, + }, + }; +} + +module.exports = { + buildEmployeeStats, +}; diff --git a/server/tests/unit/routes/employees.stats.test.js b/server/tests/unit/routes/employees.stats.test.js new file mode 100644 index 00000000..80c7b414 --- /dev/null +++ b/server/tests/unit/routes/employees.stats.test.js @@ -0,0 +1,115 @@ +const Fastify = require("fastify"); + +const employeesRoutes = require("../../../src/routes/employees"); +const { AppError } = require("../../../src/utils/AppError"); + +async function buildApp({ permissionCodes = ["system.users"], stats } = {}) { + const app = Fastify(); + const employeeGroupBy = vi + .fn() + .mockResolvedValueOnce([ + { employment_status: "active", _count: { _all: 8 } }, + { employment_status: "probation", _count: { _all: 2 } }, + ]) + .mockResolvedValueOnce([ + { department_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", _count: { _all: 6 } }, + { department_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", _count: { _all: 4 } }, + ]); + const employeeCount = vi.fn().mockResolvedValueOnce(3).mockResolvedValueOnce(1); + const departmentFindMany = vi.fn().mockResolvedValue([ + { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", name: "行政部" }, + { id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", name: "研发部" }, + ]); + const contractCount = vi.fn().mockResolvedValue(2); + + app.decorate("prisma", { + employee: { + groupBy: stats?.employeeGroupBy ?? employeeGroupBy, + count: stats?.employeeCount ?? employeeCount, + findMany: vi.fn().mockResolvedValue([]), + findUnique: vi.fn().mockResolvedValue(null), + }, + department: { + findMany: stats?.departmentFindMany ?? departmentFindMany, + }, + employeeContract: { + count: stats?.employeeContractCount ?? contractCount, + findMany: vi.fn().mockResolvedValue([]), + }, + }); + app.decorate("authenticate", async (request) => { + request.employee = { + id: "actor-1", + department_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + permissionCodes, + roleCodes: [], + }; + }); + app.decorate("requirePermission", (permissionCode) => async (request) => { + request.employee = { + id: "actor-1", + department_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + permissionCodes, + roleCodes: [], + }; + if (!permissionCodes.includes("*") && !permissionCodes.includes(permissionCode)) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); + } + }); + app.decorate("hasPermission", (employee, permissionCode) => { + const codes = employee.permissionCodes ?? []; + return codes.includes("*") || codes.includes(permissionCode); + }); + app.decorate("config", { appTimezone: "Asia/Shanghai" }); + app.setErrorHandler((error, request, reply) => { + reply + .status(error.statusCode || 500) + .send({ code: error.code || "ERR", message: error.message, details: error.details ?? null }); + }); + + await app.register(employeesRoutes, { prefix: "/api" }); + return { app, employeeGroupBy, employeeCount, departmentFindMany, contractCount }; +} + +describe("employees stats route", () => { + it("returns aggregated employee stats for read-only viewers", async () => { + const { app, employeeGroupBy, employeeCount, departmentFindMany, contractCount } = await buildApp({ + permissionCodes: ["report.dashboard.view"], + }); + + const response = await app.inject({ + method: "GET", + url: "/api/employees/stats", + }); + + expect(response.statusCode).toBe(200); + const payload = response.json(); + expect(payload.data.employmentStatusSummary.total).toBe(10); + expect(payload.data.departmentHeadcount).toEqual([ + { departmentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", departmentName: "行政部", count: 6 }, + { departmentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", departmentName: "研发部", count: 4 }, + ]); + expect(payload.data.recentMovements.hires).toBe(3); + expect(payload.data.recentMovements.departures).toBe(1); + expect(payload.data.expiringContracts.count).toBe(2); + expect(employeeGroupBy).toHaveBeenCalledTimes(2); + expect(employeeCount).toHaveBeenCalledTimes(2); + expect(departmentFindMany).toHaveBeenCalledTimes(1); + expect(contractCount).toHaveBeenCalledTimes(1); + + await app.close(); + }); + + it("rejects requests without people directory permission", async () => { + const { app } = await buildApp({ permissionCodes: ["employee"] }); + + const response = await app.inject({ + method: "GET", + url: "/api/employees/stats", + }); + + expect(response.statusCode).toBe(403); + + await app.close(); + }); +}); diff --git a/server/tests/unit/routes/exports.employees.test.js b/server/tests/unit/routes/exports.employees.test.js index af0e5c07..b39923f2 100644 --- a/server/tests/unit/routes/exports.employees.test.js +++ b/server/tests/unit/routes/exports.employees.test.js @@ -62,7 +62,7 @@ describe("employee exports route", () => { const response = await app.inject({ method: "GET", - url: `/api/exports/employees?format=csv&status=active&departmentId=${DEPARTMENT_ID}&q=alice`, + url: `/api/exports/employees?format=csv&status=active&departmentId=${DEPARTMENT_ID}&q=alice&title=%E7%BB%8F%E7%90%86&managerId=bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb&hireDateFrom=2026-01-01&hireDateTo=2026-01-31&employmentStatus=active`, }); expect(response.statusCode).toBe(200); @@ -73,9 +73,22 @@ describe("employee exports route", () => { where: expect.objectContaining({ status: "active", department_id: DEPARTMENT_ID, - OR: expect.arrayContaining([ - expect.objectContaining({ name: expect.any(Object) }), - expect.objectContaining({ employee_no: expect.any(Object) }), + direct_manager_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + employment_status: "active", + hire_date: expect.objectContaining({ + gte: new Date("2026-01-01T00:00:00.000Z"), + lte: new Date("2026-01-31T23:59:59.999Z"), + }), + AND: expect.arrayContaining([ + expect.objectContaining({ + OR: expect.arrayContaining([ + expect.objectContaining({ name: expect.any(Object) }), + expect.objectContaining({ employee_no: expect.any(Object) }), + ]), + }), + expect.objectContaining({ + title: expect.objectContaining({ contains: "经理" }), + }), ]), }), }), @@ -84,12 +97,20 @@ describe("employee exports route", () => { where: expect.objectContaining({ status: "active", department_id: DEPARTMENT_ID, + direct_manager_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", }), }); expect(auditCreate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ action: "EXPORT_EMPLOYEES", + extra: expect.objectContaining({ + title: "经理", + managerId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + hireDateFrom: "2026-01-01", + hireDateTo: "2026-01-31", + employmentStatus: "active", + }), }), }), ); diff --git a/server/tests/unit/routes/users.list.test.js b/server/tests/unit/routes/users.list.test.js index de6a70ab..e430c809 100644 --- a/server/tests/unit/routes/users.list.test.js +++ b/server/tests/unit/routes/users.list.test.js @@ -1,6 +1,7 @@ const Fastify = require("fastify"); const usersRoutes = require("../../../src/routes/users"); +const { AppError } = require("../../../src/utils/AppError"); function buildEmployeeRow(overrides = {}) { const now = new Date("2026-03-28T10:00:00.000Z"); @@ -20,10 +21,17 @@ function buildEmployeeRow(overrides = {}) { }; } -async function buildApp(routeOptions = {}) { +async function buildApp({ routeOptions = {}, employee } = {}) { const app = Fastify(); const findMany = vi.fn().mockResolvedValue([buildEmployeeRow()]); const count = vi.fn().mockResolvedValue(1); + const actor = + employee ?? + { + id: "actor-1", + department_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + permissionCodes: ["system.users"], + }; app.decorate("prisma", { employee: { @@ -31,21 +39,20 @@ async function buildApp(routeOptions = {}) { count, }, }); - app.decorate("requirePermission", () => async (request) => { - request.employee = { - id: "actor-1", - department_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - permissionCodes: ["system.users"], - }; + app.decorate("requirePermission", (permissionCode) => async (request) => { + request.employee = actor; + const permissionCodes = actor.permissionCodes ?? []; + if (!permissionCodes.includes("*") && !permissionCodes.includes(permissionCode)) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); + } }); app.decorate("authenticate", async (request) => { - request.employee = { - id: "actor-1", - department_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - permissionCodes: ["system.users"], - }; + request.employee = actor; + }); + app.decorate("hasPermission", (requestEmployee, permissionCode) => { + const permissionCodes = requestEmployee.permissionCodes ?? []; + return permissionCodes.includes("*") || permissionCodes.includes(permissionCode); }); - app.decorate("hasPermission", () => true); app.decorate("config", {}); await app.register(usersRoutes, { prefix: "/api", ...routeOptions }); @@ -56,8 +63,10 @@ describe("usersRoutes list query", () => { it("supports departmentId filter for /employees when enabled", async () => { const departmentId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; const { app, findMany, count } = await buildApp({ - basePath: "/employees", - allowDepartmentFilter: true, + routeOptions: { + basePath: "/employees", + allowDepartmentFilter: true, + }, }); const response = await app.inject({ @@ -85,8 +94,10 @@ describe("usersRoutes list query", () => { it("combines q + status + departmentId for /employees", async () => { const departmentId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; const { app, findMany } = await buildApp({ - basePath: "/employees", - allowDepartmentFilter: true, + routeOptions: { + basePath: "/employees", + allowDepartmentFilter: true, + }, }); const response = await app.inject({ @@ -100,9 +111,13 @@ describe("usersRoutes list query", () => { where: expect.objectContaining({ status: "active", department_id: departmentId, - OR: expect.arrayContaining([ - expect.objectContaining({ name: expect.any(Object) }), - expect.objectContaining({ employee_no: expect.any(Object) }), + AND: expect.arrayContaining([ + expect.objectContaining({ + OR: expect.arrayContaining([ + expect.objectContaining({ name: expect.any(Object) }), + expect.objectContaining({ employee_no: expect.any(Object) }), + ]), + }), ]), }), }), @@ -111,6 +126,58 @@ describe("usersRoutes list query", () => { await app.close(); }); + it("supports advanced filters and report.dashboard.view for /employees", async () => { + const departmentId = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"; + const managerId = "ffffffff-ffff-4fff-8fff-ffffffffffff"; + const { app, findMany, count } = await buildApp({ + routeOptions: { + basePath: "/employees", + allowDepartmentFilter: true, + listPermissionCodes: ["system.users", "report.dashboard.view"], + }, + employee: { + id: "viewer-1", + department_id: departmentId, + permissionCodes: ["report.dashboard.view"], + }, + }); + + const response = await app.inject({ + method: "GET", + url: `/api/employees?page=1&pageSize=20&departmentId=${departmentId}&title=%E7%BB%8F%E7%90%86&managerId=${managerId}&hireDateFrom=2026-01-01&hireDateTo=2026-01-31&employmentStatus=probation`, + }); + + expect(response.statusCode).toBe(200); + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + department_id: departmentId, + direct_manager_id: managerId, + employment_status: "probation", + hire_date: expect.objectContaining({ + gte: new Date("2026-01-01T00:00:00.000Z"), + lte: new Date("2026-01-31T23:59:59.999Z"), + }), + AND: expect.arrayContaining([ + expect.objectContaining({ + title: expect.objectContaining({ + contains: "经理", + }), + }), + ]), + }), + }), + ); + expect(count).toHaveBeenCalledWith({ + where: expect.objectContaining({ + department_id: departmentId, + direct_manager_id: managerId, + }), + }); + + await app.close(); + }); + it("does not apply departmentId filter for /users", async () => { const departmentId = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; const { app, findMany } = await buildApp();