R40: 完成档案编辑扩展与员工导出
这个提交包含在:
@@ -137,6 +137,7 @@ export async function patchEmployeeProfile(
|
||||
departmentId: string;
|
||||
directManagerId: string | null;
|
||||
title: string;
|
||||
hireDate: string | null;
|
||||
probationEndDate: string | null;
|
||||
lastWorkingDate: string | null;
|
||||
workLocation: string | null;
|
||||
|
||||
@@ -56,4 +56,21 @@ export async function exportDashboardReimbursements(params: {
|
||||
return apiRequestBlob(`/api/exports/dashboard/reimbursements?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export async function exportEmployees(params: {
|
||||
format: "csv" | "xlsx";
|
||||
status?: "active" | "archived";
|
||||
departmentId?: string;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set("format", params.format);
|
||||
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 (typeof params.limit === "number") qs.set("limit", String(params.limit));
|
||||
|
||||
return apiRequestBlob(`/api/exports/employees?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export type { ApiBlobResponse };
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
startEmployeeOffboarding,
|
||||
transferEmployee,
|
||||
} from "../api/employees";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
@@ -29,6 +31,7 @@ function errorMessage(error: unknown, fallback: string) {
|
||||
|
||||
export function EmployeeProfilePage() {
|
||||
const { employeeId = "" } = useParams();
|
||||
const { permissions, me } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const profileQuery = useQuery({
|
||||
enabled: Boolean(employeeId),
|
||||
@@ -37,7 +40,15 @@ export function EmployeeProfilePage() {
|
||||
});
|
||||
|
||||
const profile = profileQuery.data;
|
||||
const canManageEmployeeProfile = hasPermission(permissions, "system.users");
|
||||
const canEditSelfProfile = me?.id === employeeId;
|
||||
const canEditBaseFields = canManageEmployeeProfile || canEditSelfProfile;
|
||||
const canEditExtendedFields = canManageEmployeeProfile;
|
||||
const [form, setForm] = useState({
|
||||
title: "",
|
||||
hireDate: "",
|
||||
probationEndDate: "",
|
||||
lastWorkingDate: "",
|
||||
workLocation: "",
|
||||
emergencyContactName: "",
|
||||
emergencyContactPhone: "",
|
||||
@@ -47,6 +58,10 @@ export function EmployeeProfilePage() {
|
||||
useEffect(() => {
|
||||
if (!profile) return;
|
||||
setForm({
|
||||
title: profile.employee.title ?? "",
|
||||
hireDate: profile.employee.hireDate ?? "",
|
||||
probationEndDate: profile.employee.probationEndDate ?? "",
|
||||
lastWorkingDate: profile.employee.lastWorkingDate ?? "",
|
||||
workLocation: profile.employee.workLocation ?? "",
|
||||
emergencyContactName: profile.employee.emergencyContactName ?? "",
|
||||
emergencyContactPhone: profile.employee.emergencyContactPhone ?? "",
|
||||
@@ -55,7 +70,25 @@ export function EmployeeProfilePage() {
|
||||
}, [profile]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => patchEmployeeProfile(employeeId, { ...form }),
|
||||
mutationFn: () =>
|
||||
patchEmployeeProfile(employeeId, {
|
||||
...(canEditExtendedFields
|
||||
? {
|
||||
...(form.title.trim() ? { title: form.title.trim() } : {}),
|
||||
hireDate: form.hireDate || null,
|
||||
probationEndDate: form.probationEndDate || null,
|
||||
lastWorkingDate: form.lastWorkingDate || null,
|
||||
}
|
||||
: {}),
|
||||
...(canEditBaseFields
|
||||
? {
|
||||
workLocation: form.workLocation,
|
||||
emergencyContactName: form.emergencyContactName,
|
||||
emergencyContactPhone: form.emergencyContactPhone,
|
||||
notes: form.notes,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] });
|
||||
toast.success("档案已保存");
|
||||
@@ -161,6 +194,28 @@ export function EmployeeProfilePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canEditExtendedFields ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">职位</label>
|
||||
<Input value={form.title} onChange={(event) => setForm((prev) => ({ ...prev, title: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">入职日期</label>
|
||||
<Input type="date" value={form.hireDate} onChange={(event) => setForm((prev) => ({ ...prev, hireDate: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">试用期截止</label>
|
||||
<Input type="date" value={form.probationEndDate} onChange={(event) => setForm((prev) => ({ ...prev, probationEndDate: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">离职日期</label>
|
||||
<Input type="date" value={form.lastWorkingDate} onChange={(event) => setForm((prev) => ({ ...prev, lastWorkingDate: event.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canEditBaseFields ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">办公地点</label>
|
||||
@@ -175,15 +230,40 @@ export function EmployeeProfilePage() {
|
||||
<Input value={form.emergencyContactPhone} onChange={(event) => setForm((prev) => ({ ...prev, emergencyContactPhone: event.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500">办公地点</div>
|
||||
<div className="mt-2 text-sm text-zinc-900">{profile.employee.workLocation ?? "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500">紧急联系人</div>
|
||||
<div className="mt-2 text-sm text-zinc-900">{profile.employee.emergencyContactName ?? "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500">紧急联系电话</div>
|
||||
<div className="mt-2 text-sm text-zinc-900">{profile.employee.emergencyContactPhone ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canEditBaseFields ? (
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">备注</label>
|
||||
<Textarea value={form.notes} onChange={(event) => setForm((prev) => ({ ...prev, notes: event.target.value }))} />
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500">备注</div>
|
||||
<div className="mt-2 whitespace-pre-wrap text-sm text-zinc-900">{profile.employee.notes ?? "-"}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canEditBaseFields ? (
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>保存档案</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -7,21 +7,29 @@ import {
|
||||
startEmployeeOffboarding,
|
||||
startEmployeeOnboarding,
|
||||
} from "../api/employees";
|
||||
import { exportEmployees } from "../api/exports";
|
||||
import { fetchDepartments } from "../api/departments";
|
||||
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 { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
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 { toast } from "sonner";
|
||||
|
||||
export function PeoplePage() {
|
||||
const { permissions } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [q, setQ] = useState("");
|
||||
const [status, setStatus] = useState<"" | "active" | "archived">("");
|
||||
const [departmentId, setDepartmentId] = useState("");
|
||||
const [exportError, setExportError] = useState("");
|
||||
const canExportEmployees = hasPermission(permissions, "system.users");
|
||||
|
||||
const departmentsQuery = useQuery({
|
||||
queryKey: ["departments", "people-page", "active"],
|
||||
@@ -88,6 +96,30 @@ export function PeoplePage() {
|
||||
},
|
||||
});
|
||||
|
||||
const exportMutation = useMutation({
|
||||
mutationFn: (format: "csv" | "xlsx") =>
|
||||
exportEmployees({
|
||||
format,
|
||||
q: q || undefined,
|
||||
status: status || undefined,
|
||||
departmentId: departmentId || undefined,
|
||||
}),
|
||||
onSuccess: (response, format) => {
|
||||
downloadBlob(response.blob, response.filename ?? `employees.${format}`);
|
||||
setExportError("");
|
||||
toast.success("导出完成");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = isApiError(error)
|
||||
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "导出失败";
|
||||
setExportError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const items = employeesQuery.data?.items ?? [];
|
||||
const departments = departmentsQuery.data?.items ?? [];
|
||||
|
||||
@@ -98,6 +130,23 @@ export function PeoplePage() {
|
||||
subtitle="小团队用这里管入职、转正、离职与档案入口。"
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
{canExportEmployees ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" type="button" disabled={exportMutation.isPending}>
|
||||
{exportMutation.isPending ? "导出中..." : "导出"}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => exportMutation.mutate("csv")}>
|
||||
导出 CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => exportMutation.mutate("xlsx")}>
|
||||
导出 Excel
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<Button asChild variant="secondary" type="button">
|
||||
<Link to="/system/users">先新建账号</Link>
|
||||
</Button>
|
||||
@@ -137,6 +186,11 @@ export function PeoplePage() {
|
||||
</div>
|
||||
|
||||
<div className="card-std p-6">
|
||||
{exportError ? (
|
||||
<p className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{exportError}
|
||||
</p>
|
||||
) : null}
|
||||
{employeesQuery.isError ? (
|
||||
<p className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{isApiError(employeesQuery.error)
|
||||
|
||||
@@ -2,4 +2,4 @@ ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regr
|
||||
R37-0,R37治理合同补建与范围冻结,为 AGENTS 多 agent 规则更新补建治理合同并锁定本轮仅做治理文档与提交准备,新 CSV 校验通过 且范围明确为 AGENTS agent modes 固化与提交留痕 不承接业务代码改动,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-28_12-36-28-r37-agent-modes-governance.csv,none,DONE,DONE,DONE,issues/2026-03-28_12-36-28-r37-agent-modes-governance.csv,[],"本轮仅治理协作规则与提交流程;不修改 server/src 或 client/src 业务代码。"
|
||||
R37-1,Agent modes 治理规则固化与 review 修订落地,将 3 常驻加 2 按需模式固化到 AGENTS 并落实 qa 写权限 Test_Method 语义和 backend post-check 三处修订,AGENTS.md 包含 Agent modes 段落 且三处修订与现有 issues/README 和 docs/mcp-tools 边界一致,1) git diff --check 2) 核对 AGENTS.md 中 mode:qa 允许写 issues/*.csv 与 scripts/ 3) 核对回滚说明已移入 Notes 约束 4) 核对 backend post-check 已改为按变更类型执行,manual,DONE,DONE,DONE,AGENTS.md,"[""R37-0""]","若后续证明规则过严或过松,回滚方式为还原本次 Agent modes 段落并保留 CSV 审计记录。"
|
||||
R37-2,治理变更提交与审计留痕,提交 AGENTS 与治理 CSV 变更并使用中文 commit message 引用本 Issue ID,生成 1 个仅包含治理变更的 commit 且 git log 最新提交可见 Issue ID,"1) git diff --stat 确认仅含 AGENTS.md 与 issues/2026-03-28_12-36-28-r37-agent-modes-governance.csv 2) git commit -m ""R37: 固化 Agent modes 治理规则"" 3) git log -1 --oneline",manual,DONE,DONE,DONE,AGENTS.md|issues/2026-03-28_12-36-28-r37-agent-modes-governance.csv,"[""R37-1""]","commit 仅承载治理变更;若提交前发现额外文件改动则先停止并重新确认范围。"
|
||||
R37-3,下个真实迭代按新模式试跑并复盘,在后续一个同时涉及 backend frontend qa 的真实迭代中按新 Agent modes 跑一次并记录阻塞点,至少完成 1 次真实试跑 并能说明 handoff QA 回填 reviewer 触发条件是否顺畅,"1) 选择一个同时涉及 server/ 与 client/ 的真实 issue 组 2) 按 Agent modes 执行 3) 在 Notes 或后续治理 issue 中记录问题与修订建议",manual,DONE,DONE,DONE,TBD,"[""R37-2""]","已用 R38 员工管理试跑完成一次真实迭代。结论: Files 派工整体清晰 backend 侧唯一摩擦点是 /employees 复用 usersRoutes 需要显式 allowDepartmentFilter;QA 回填顺畅 命令式验证和合同状态映射明确;reviewer 触发条件当前无需细化 本轮未触及权限模型 状态机 workflow 或迁移。R39 后续观测: 围绕单一 profile 响应做 backend/frontend handoff 比 R38 更清晰 扩现有接口比新建专用端点更顺;qa-runner 的新增经验是即使 shell 初始 PATH 缺少 Node,也可通过显式注入 NVM 路径完成验证,R39 最终已完成 node --check、后端单测、client typecheck 和 build。"
|
||||
R37-3,下个真实迭代按新模式试跑并复盘,在后续一个同时涉及 backend frontend qa 的真实迭代中按新 Agent modes 跑一次并记录阻塞点,至少完成 1 次真实试跑 并能说明 handoff QA 回填 reviewer 触发条件是否顺畅,"1) 选择一个同时涉及 server/ 与 client/ 的真实 issue 组 2) 按 Agent modes 执行 3) 在 Notes 或后续治理 issue 中记录问题与修订建议",manual,DONE,DONE,DONE,TBD,"[""R37-2""]","已用 R38 员工管理试跑完成一次真实迭代。结论: Files 派工整体清晰 backend 侧唯一摩擦点是 /employees 复用 usersRoutes 需要显式 allowDepartmentFilter;QA 回填顺畅 命令式验证和合同状态映射明确;reviewer 触发条件当前无需细化 本轮未触及权限模型 状态机 workflow 或迁移。R39 后续观测: 围绕单一 profile 响应做 backend/frontend handoff 比 R38 更清晰 扩现有接口比新建专用端点更顺;qa-runner 的新增经验是即使 shell 初始 PATH 缺少 Node,也可通过显式注入 NVM 路径完成验证,R39 最终已完成 node --check、后端单测、client typecheck 和 build。R40 后续观测: 同页中的可看 可改 可导出边界不能只靠前端,最终需要前后端同时收口;hireDate 这类“看起来已有字段”的需求仍要先核对 PATCH schema 与 update data,再决定是否是纯前端改动;员工导出验证了 exports.js 路由模式可以继续平移复用。"
|
||||
|
||||
|
@@ -0,0 +1,5 @@
|
||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||
R40-0,R40执行合同建档与范围冻结,为档案编辑扩展与员工列表导出创建独立合同并锁定本轮不承接批量导入 考勤增强与批量操作,CSV 校验通过 且明确本轮只做 profile patch 小改动 管理员编辑态与员工导出链路,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-28_17-12-07-r40-profile-edit-and-people-export.csv,none,DONE,DONE,DONE,issues/2026-03-28_17-12-07-r40-profile-edit-and-people-export.csv,[],本轮沿用现有 /employees/:id/profile 与 /exports 基础设施 reviewer 不默认启用
|
||||
R40-1,档案编辑扩展,让 system.users 管理员在员工档案页直接编辑 title hireDate probationEndDate lastWorkingDate 并保持普通员工仅能编辑原 4 个字段,管理员保存 title hireDate probationEndDate lastWorkingDate 后刷新值保持 普通员工打开自己档案时这些字段仍为只读 且 department directManager employeeNo name phone email 维护边界不变,"业务流程: 管理员打开员工档案 修改职位 入职日期 试用期截止和离职日期后保存成功 刷新后值保持。普通员工打开自己档案时 仅 workLocation emergencyContactName emergencyContactPhone notes 可编辑。验证命令: cd server && node --check src/routes/employees.js && cd ../client && npm run typecheck && npm run build",manual,DONE,DONE,DONE,server/src/routes/employees.js|client/src/api/employees.ts|client/src/pages/EmployeeProfilePage.tsx,"[""R40-0""]","已在 PROFILE_PATCH_SCHEMA 与 employee.update 中补写 hireDate,并把 report.dashboard.view 对他人档案的 patch 显式收紧为只读。已验证: cd server && node --check src/routes/employees.js 通过;cd server && npm test -- tests/unit/routes/employees.profile.test.js tests/unit/routes/employees.patch.test.js 通过;cd client && npm run typecheck && npm run build 通过。"
|
||||
R40-2,员工列表导出,在人员管理页按当前筛选条件导出员工列表并复用现有 exports.js 与 blob 下载基础设施,PeoplePage 仅对 system.users 显示导出按钮 导出支持 csv xlsx 并复用 q status departmentId 筛选 产出列为工号 姓名 部门 职位 手机 邮箱 账号状态 入职日期,"业务流程: system.users 用户进入人员管理页 设置 q status departmentId 后导出 CSV 和 Excel 文件内容与筛选一致。仅 report.dashboard.view 用户进入人员管理页时看不到导出按钮。验证命令: cd server && node --check src/routes/exports.js && cd ../client && npm run typecheck && npm run build",manual,DONE,DONE,DONE,server/src/routes/exports.js|client/src/api/exports.ts|client/src/pages/PeoplePage.tsx,"[""R40-0""]","已新增 /api/exports/employees 与 exportEmployees,并在 PeoplePage 复用现有 dropdown + blob 下载模式。已验证: cd server && node --check src/routes/exports.js 通过;cd server && npm test -- tests/unit/routes/exports.employees.test.js 通过;cd client && npm run typecheck && npm run build 通过。"
|
||||
R40-3,QA回归与R37-3继续复盘,按 agent modes 完成 R40 单项验证 状态回填 并记录 hireDate 误判与导出模式复用的后续观察,R40 各状态正确回填 且 Notes 记录 hireDate 需补后端 system.users 与 report.dashboard.view 能力差异 员工导出是否证明 exports.js 模式可继续复用,"业务流程: 验证管理员档案编辑扩展 普通员工只读约束 PeoplePage 按组合筛选导出和非 system.users 无导出入口 并确认 R39 汇报链卡片不回归。验证命令: cd server && node --check src/routes/employees.js && node --check src/routes/exports.js && cd ../client && npm run typecheck && npm run build",manual,DONE,DONE,DONE,issues/2026-03-28_17-12-07-r40-profile-edit-and-people-export.csv|issues/2026-03-28_12-36-28-r37-agent-modes-governance.csv|server/src/routes/employees.js|server/src/routes/exports.js|client/src/pages/EmployeeProfilePage.tsx|client/src/pages/PeoplePage.tsx,"[""R40-1"",""R40-2""]","试跑结论: hireDate 从以为前端可接到确认需补后端 是一次有效纠偏;system.users 与 report.dashboard.view 的能力边界已同时在前端和后端收敛,避免只隐藏按钮但 API 仍可写;员工导出证明 exports.js 模式可继续平移复用。已验证: node --check employees.js/exports.js 通过;server 单测 3 文件 7 测试全部通过;client typecheck/build 通过。"
|
||||
|
@@ -14,6 +14,7 @@ const PROFILE_PATCH_SCHEMA = z
|
||||
departmentId: UUID_SCHEMA.optional(),
|
||||
directManagerId: UUID_SCHEMA.nullable().optional(),
|
||||
title: z.string().trim().min(1).max(100).optional(),
|
||||
hireDate: DATE_SCHEMA.nullable().optional(),
|
||||
probationEndDate: DATE_SCHEMA.nullable().optional(),
|
||||
lastWorkingDate: DATE_SCHEMA.nullable().optional(),
|
||||
workLocation: z.string().trim().max(100).nullable().optional(),
|
||||
@@ -370,7 +371,11 @@ async function employeesRoutes(fastify) {
|
||||
const body = parseBody(PROFILE_PATCH_SCHEMA, request.body);
|
||||
const managerMode = canManageLifecycle(fastify, request);
|
||||
const selfEditableKeys = ["workLocation", "emergencyContactName", "emergencyContactPhone", "notes"];
|
||||
if (!managerMode && request.employee.id === employeeId) {
|
||||
if (!managerMode) {
|
||||
if (request.employee.id !== employeeId) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.profile.write" } });
|
||||
}
|
||||
|
||||
const invalidKeys = Object.keys(body).filter((key) => !selfEditableKeys.includes(key));
|
||||
if (invalidKeys.length) {
|
||||
throw buildValidationError({ body: `self_profile_fields_forbidden:${invalidKeys.join(",")}` }, 403);
|
||||
@@ -384,6 +389,7 @@ async function employeesRoutes(fastify) {
|
||||
...(body.departmentId !== undefined ? { department_id: body.departmentId } : {}),
|
||||
...(body.directManagerId !== undefined ? { direct_manager_id: body.directManagerId } : {}),
|
||||
...(body.title !== undefined ? { title: body.title } : {}),
|
||||
...(body.hireDate !== undefined ? { hire_date: body.hireDate ? new Date(`${body.hireDate}T00:00:00.000Z`) : null } : {}),
|
||||
...(body.probationEndDate !== undefined ? { probation_end_date: body.probationEndDate ? new Date(`${body.probationEndDate}T00:00:00.000Z`) : null } : {}),
|
||||
...(body.lastWorkingDate !== undefined ? { last_working_date: body.lastWorkingDate ? new Date(`${body.lastWorkingDate}T00:00:00.000Z`) : null } : {}),
|
||||
...(body.workLocation !== undefined ? { work_location: body.workLocation ?? null } : {}),
|
||||
|
||||
@@ -49,6 +49,14 @@ const DASHBOARD_EXPORT_QUERY_SCHEMA = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(20000).optional(),
|
||||
});
|
||||
|
||||
const EMPLOYEE_EXPORT_QUERY_SCHEMA = z.object({
|
||||
format: EXPORT_FORMAT_SCHEMA.optional(),
|
||||
status: z.enum(["active", "archived"]).optional(),
|
||||
departmentId: z.string().uuid().optional(),
|
||||
q: z.string().max(100).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(20000).optional(),
|
||||
});
|
||||
|
||||
function csvCell(value) {
|
||||
if (value == null) return "";
|
||||
|
||||
@@ -168,6 +176,25 @@ 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([
|
||||
@@ -663,7 +690,159 @@ async function buildAuditLogsXlsx(prisma, where, limit) {
|
||||
return workbook.xlsx.writeBuffer();
|
||||
}
|
||||
|
||||
async function* streamEmployeesCsv(prisma, where, limit) {
|
||||
yield "\ufeff";
|
||||
yield csvRow(["工号", "姓名", "部门", "职位", "手机", "邮箱", "账号状态", "入职日期"]);
|
||||
|
||||
const batchSize = 500;
|
||||
let remaining = limit;
|
||||
let cursorCreatedAt = null;
|
||||
let cursorId = null;
|
||||
|
||||
while (remaining > 0) {
|
||||
const take = Math.min(batchSize, remaining);
|
||||
const rows = await prisma.employee.findMany({
|
||||
where: {
|
||||
...where,
|
||||
...(cursorCreatedAt
|
||||
? {
|
||||
OR: [
|
||||
{ created_at: { lt: cursorCreatedAt } },
|
||||
{ created_at: cursorCreatedAt, id: { lt: cursorId } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
||||
take,
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
status: true,
|
||||
title: true,
|
||||
hire_date: true,
|
||||
created_at: true,
|
||||
department: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!rows.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const item of rows) {
|
||||
yield csvRow([
|
||||
item.employee_no,
|
||||
item.name,
|
||||
item.department?.name ?? "",
|
||||
item.title ?? "",
|
||||
item.phone ?? "",
|
||||
item.email ?? "",
|
||||
item.status,
|
||||
item.hire_date?.toISOString?.()?.slice(0, 10) ?? "",
|
||||
]);
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) break;
|
||||
}
|
||||
|
||||
const last = rows[rows.length - 1];
|
||||
cursorCreatedAt = last.created_at;
|
||||
cursorId = last.id;
|
||||
}
|
||||
}
|
||||
|
||||
async function buildEmployeesXlsx(prisma, where, limit) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet("employees");
|
||||
|
||||
sheet.columns = [
|
||||
{ header: "工号", key: "employeeNo", width: 16 },
|
||||
{ header: "姓名", key: "name", width: 14 },
|
||||
{ header: "部门", key: "departmentName", width: 16 },
|
||||
{ header: "职位", key: "title", width: 16 },
|
||||
{ header: "手机", key: "phone", width: 16 },
|
||||
{ header: "邮箱", key: "email", width: 24 },
|
||||
{ header: "账号状态", key: "status", width: 12 },
|
||||
{ header: "入职日期", key: "hireDate", width: 14 },
|
||||
];
|
||||
|
||||
const rows = await prisma.employee.findMany({
|
||||
where,
|
||||
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
||||
take: limit,
|
||||
select: {
|
||||
employee_no: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
status: true,
|
||||
title: true,
|
||||
hire_date: true,
|
||||
department: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
for (const item of rows) {
|
||||
sheet.addRow({
|
||||
employeeNo: item.employee_no,
|
||||
name: item.name,
|
||||
departmentName: item.department?.name ?? "",
|
||||
title: item.title ?? "",
|
||||
phone: item.phone ?? "",
|
||||
email: item.email ?? "",
|
||||
status: item.status,
|
||||
hireDate: item.hire_date?.toISOString?.()?.slice(0, 10) ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
return workbook.xlsx.writeBuffer();
|
||||
}
|
||||
|
||||
async function exportsRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/exports/employees",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const query = parseQuery(EMPLOYEE_EXPORT_QUERY_SCHEMA, request.query);
|
||||
const where = buildEmployeeExportWhere(query);
|
||||
const format = resolveExportFormat(query);
|
||||
const limit = query.limit ?? 5000;
|
||||
const total = await fastify.prisma.employee.count({ where });
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "EXPORT_EMPLOYEES",
|
||||
targetType: "export",
|
||||
extra: {
|
||||
format,
|
||||
status: query.status ?? null,
|
||||
departmentId: query.departmentId ?? null,
|
||||
q: query.q?.trim() || null,
|
||||
limit,
|
||||
matchedTotal: total,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const filename = exportFilename("employees", format);
|
||||
setExportHeaders(reply, format, filename);
|
||||
|
||||
if (format === "xlsx") {
|
||||
const buffer = await buildEmployeesXlsx(fastify.prisma, where, limit);
|
||||
return reply.send(Buffer.from(buffer));
|
||||
}
|
||||
|
||||
const stream = Readable.from(streamEmployeesCsv(fastify.prisma, where, limit));
|
||||
return reply.send(stream);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/exports/reimbursements",
|
||||
{
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
const Fastify = require("fastify");
|
||||
|
||||
const employeesRoutes = require("../../../src/routes/employees");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
|
||||
const TARGET_EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const DEPARTMENT_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
|
||||
function buildProfileRow(overrides = {}) {
|
||||
return {
|
||||
id: TARGET_EMPLOYEE_ID,
|
||||
employee_no: "E001",
|
||||
name: "Alice",
|
||||
phone: "13800000000",
|
||||
email: "[email protected]",
|
||||
status: "active",
|
||||
employment_status: "active",
|
||||
department_id: DEPARTMENT_ID,
|
||||
title: "员工",
|
||||
direct_manager_id: null,
|
||||
hire_date: new Date("2026-01-10T00:00:00.000Z"),
|
||||
probation_end_date: new Date("2026-04-10T00:00:00.000Z"),
|
||||
last_working_date: null,
|
||||
work_location: "上海",
|
||||
emergency_contact_name: "联系人",
|
||||
emergency_contact_phone: "13900000000",
|
||||
notes: "none",
|
||||
archived_at: null,
|
||||
roles: [{ role_code: "employee" }],
|
||||
department: { id: DEPARTMENT_ID, name: "行政部" },
|
||||
direct_manager: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildApp(actor) {
|
||||
const app = Fastify();
|
||||
let storedEmployee = buildProfileRow();
|
||||
|
||||
const employeeFindUnique = vi.fn(async ({ where }) => {
|
||||
if (where.id === TARGET_EMPLOYEE_ID) {
|
||||
return storedEmployee;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const employeeUpdate = vi.fn(async ({ data }) => {
|
||||
storedEmployee = {
|
||||
...storedEmployee,
|
||||
...(data.title !== undefined ? { title: data.title } : {}),
|
||||
...(data.hire_date !== undefined ? { hire_date: data.hire_date } : {}),
|
||||
...(data.probation_end_date !== undefined ? { probation_end_date: data.probation_end_date } : {}),
|
||||
...(data.last_working_date !== undefined ? { last_working_date: data.last_working_date } : {}),
|
||||
...(data.work_location !== undefined ? { work_location: data.work_location } : {}),
|
||||
...(data.emergency_contact_name !== undefined ? { emergency_contact_name: data.emergency_contact_name } : {}),
|
||||
...(data.emergency_contact_phone !== undefined ? { emergency_contact_phone: data.emergency_contact_phone } : {}),
|
||||
...(data.notes !== undefined ? { notes: data.notes } : {}),
|
||||
};
|
||||
return storedEmployee;
|
||||
});
|
||||
|
||||
app.decorate("config", { employeeLifecycleEnabled: true });
|
||||
app.decorate("authenticate", async (request) => {
|
||||
request.employee = actor;
|
||||
});
|
||||
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("hasPermission", (employee, permissionCode) => {
|
||||
const permissionCodes = employee.permissionCodes ?? [];
|
||||
return permissionCodes.includes("*") || permissionCodes.includes(permissionCode);
|
||||
});
|
||||
app.decorate("prisma", {
|
||||
featureFlag: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "flag-1",
|
||||
flag_key: "employee_lifecycle_v1",
|
||||
override_mode: "force_on",
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "",
|
||||
version: 1,
|
||||
created_at: new Date("2026-03-28T00:00:00.000Z"),
|
||||
created_by: null,
|
||||
updated_at: new Date("2026-03-28T00:00:00.000Z"),
|
||||
updated_by: null,
|
||||
}),
|
||||
},
|
||||
employee: {
|
||||
findUnique: employeeFindUnique,
|
||||
update: employeeUpdate,
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
employeeChecklistTask: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
employeeLifecycleEvent: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
assetCard: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
workflowTask: {
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
});
|
||||
|
||||
await app.register(employeesRoutes, { prefix: "/api" });
|
||||
return { app, employeeUpdate };
|
||||
}
|
||||
|
||||
describe("employees profile patch", () => {
|
||||
it("allows system.users to patch hireDate and other extended profile fields", async () => {
|
||||
const { app, employeeUpdate } = await buildApp({
|
||||
id: "actor-1",
|
||||
department_id: DEPARTMENT_ID,
|
||||
permissionCodes: ["system.users"],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/employees/${TARGET_EMPLOYEE_ID}/profile`,
|
||||
payload: {
|
||||
title: "经理",
|
||||
hireDate: "2026-02-01",
|
||||
probationEndDate: "2026-05-01",
|
||||
lastWorkingDate: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(employeeUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: TARGET_EMPLOYEE_ID },
|
||||
data: expect.objectContaining({
|
||||
title: "经理",
|
||||
hire_date: new Date("2026-02-01T00:00:00.000Z"),
|
||||
probation_end_date: new Date("2026-05-01T00:00:00.000Z"),
|
||||
last_working_date: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects report.dashboard.view users patching other employee profiles", async () => {
|
||||
const { app } = await buildApp({
|
||||
id: "actor-2",
|
||||
department_id: DEPARTMENT_ID,
|
||||
permissionCodes: ["report.dashboard.view"],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/employees/${TARGET_EMPLOYEE_ID}/profile`,
|
||||
payload: { notes: "should fail" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
const Fastify = require("fastify");
|
||||
|
||||
const exportsRoutes = require("../../../src/routes/exports");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
|
||||
const DEPARTMENT_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
|
||||
async function buildApp(actor) {
|
||||
const app = Fastify();
|
||||
const findMany = vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
employee_no: "E001",
|
||||
name: "Alice",
|
||||
phone: "13800000000",
|
||||
email: "[email protected]",
|
||||
status: "active",
|
||||
title: "经理",
|
||||
hire_date: new Date("2026-01-10T00:00:00.000Z"),
|
||||
created_at: new Date("2026-03-28T10:00:00.000Z"),
|
||||
department: { name: "行政部" },
|
||||
},
|
||||
]);
|
||||
const count = vi.fn().mockResolvedValue(1);
|
||||
const auditCreate = vi.fn().mockResolvedValue({});
|
||||
|
||||
app.decorate("authenticate", async (request) => {
|
||||
request.employee = actor;
|
||||
});
|
||||
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("hasPermission", (employee, permissionCode) => {
|
||||
const permissionCodes = employee.permissionCodes ?? [];
|
||||
return permissionCodes.includes("*") || permissionCodes.includes(permissionCode);
|
||||
});
|
||||
app.decorate("prisma", {
|
||||
employee: {
|
||||
findMany,
|
||||
count,
|
||||
},
|
||||
auditLog: {
|
||||
create: auditCreate,
|
||||
},
|
||||
});
|
||||
|
||||
await app.register(exportsRoutes, { prefix: "/api" });
|
||||
return { app, findMany, count, auditCreate };
|
||||
}
|
||||
|
||||
describe("employee exports route", () => {
|
||||
it("exports filtered employees for system.users", async () => {
|
||||
const { app, findMany, count, auditCreate } = await buildApp({
|
||||
id: "actor-1",
|
||||
roleCodes: ["administration"],
|
||||
permissionCodes: ["system.users"],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/exports/employees?format=csv&status=active&departmentId=${DEPARTMENT_ID}&q=alice`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers["content-type"]).toContain("text/csv");
|
||||
expect(response.headers["content-disposition"]).toContain("employees-");
|
||||
expect(findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
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) }),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(count).toHaveBeenCalledWith({
|
||||
where: expect.objectContaining({
|
||||
status: "active",
|
||||
department_id: DEPARTMENT_ID,
|
||||
}),
|
||||
});
|
||||
expect(auditCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: "EXPORT_EMPLOYEES",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects non system.users access", async () => {
|
||||
const { app } = await buildApp({
|
||||
id: "actor-2",
|
||||
roleCodes: ["manager"],
|
||||
permissionCodes: ["report.dashboard.view"],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/exports/employees?format=csv",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户