From 3cf8b43f742a000e2d39daa588d6e70cda97a453 Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 28 Mar 2026 18:57:02 +0800 Subject: [PATCH] =?UTF-8?q?R41:=20=E5=AE=8C=E6=88=90=E5=91=98=E5=B7=A5?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/users.ts | 67 ++ client/src/pages/UserManagementPage.tsx | 52 +- .../user-management/UserImportDialog.tsx | 363 +++++++++ ...28_12-36-28-r37-agent-modes-governance.csv | 2 +- ...26-03-28_17-58-04-r41-user-bulk-import.csv | 5 + server/src/routes/users.js | 705 ++++++++++++++++-- server/tests/unit/routes/users.import.test.js | 346 +++++++++ 7 files changed, 1477 insertions(+), 63 deletions(-) create mode 100644 client/src/pages/user-management/UserImportDialog.tsx create mode 100644 issues/2026-03-28_17-58-04-r41-user-bulk-import.csv create mode 100644 server/tests/unit/routes/users.import.test.js diff --git a/client/src/api/users.ts b/client/src/api/users.ts index 82927228..8727e18a 100644 --- a/client/src/api/users.ts +++ b/client/src/api/users.ts @@ -23,6 +23,55 @@ export type UserListResponse = { items: UserSummary[]; }; +export type UserImportRowError = { + field: string; + message: string; +}; + +export type UserImportPreviewRow = { + rowNumber: number; + employeeNo: string; + name: string; + normalized: { + employeeNo: string; + name: string; + phone: string; + email: string; + departmentName: string; + title: string; + directManagerEmployeeNo: string; + roles: string[]; + hireDate: string; + }; + errors: UserImportRowError[]; +}; + +export type UserImportDryRunResult = { + sourceFilename: string; + summary: { + totalRows: number; + validRows: number; + invalidRows: number; + }; + rows: UserImportPreviewRow[]; +}; + +export type UserImportApplyResult = { + sourceFilename: string; + summary: { + totalRows: number; + validRows: number; + invalidRows: number; + }; + imported: Array<{ + employeeId: string; + employeeNo: string; + name: string; + email: string; + oneTimePassword: string; + }>; +}; + export async function fetchUsers(params: { page: number; pageSize: number; @@ -82,3 +131,21 @@ export async function setUserRoles(userId: string, roles: string[]) { body: JSON.stringify({ roles }), }); } + +export async function dryRunUserImport(file: File) { + const formData = new FormData(); + formData.append("file", file); + return apiRequestData("/api/users/import/dry-run", { + method: "POST", + body: formData, + }); +} + +export async function applyUserImport(file: File) { + const formData = new FormData(); + formData.append("file", file); + return apiRequestData("/api/users/import/apply", { + method: "POST", + body: formData, + }); +} diff --git a/client/src/pages/UserManagementPage.tsx b/client/src/pages/UserManagementPage.tsx index 49d588f4..f9fd7678 100644 --- a/client/src/pages/UserManagementPage.tsx +++ b/client/src/pages/UserManagementPage.tsx @@ -23,6 +23,7 @@ import { TableSkeleton } from "../components/common/TableSkeleton"; import { StatusBadge } from "../components/common/StatusBadge"; import { Pagination } from "../components/common/Pagination"; import { CreateUserDialog, type CreateUserFormData } from "./user-management/CreateUserDialog"; +import { UserImportDialog } from "./user-management/UserImportDialog"; import { UserRoleDialog } from "./user-management/UserRoleDialog"; const PAGE_SIZE = 20; @@ -51,6 +52,7 @@ export function UserManagementPage() { const [resetResult, setResetResult] = useState<{ userId: string; password: string } | null>(null); const [createOpen, setCreateOpen] = useState(false); + const [importOpen, setImportOpen] = useState(false); const [roleDialogUser, setRoleDialogUser] = useState(null); const listQuery = useQuery({ @@ -142,25 +144,37 @@ export function UserManagementPage() { title="系统用户管理" subtitle="创建/启用/禁用账号,重置密码并分配角色(仅试运行最小运营)。" actions={ - createMutation.mutate(data)} - isPending={createMutation.isPending} - departments={{ - isLoading: departmentsQuery.isLoading, - items: departmentsQuery.data?.items ?? [], - }} - managers={{ - isLoading: managersQuery.isLoading, - items: (managersQuery.data?.items ?? []).map((item) => ({ - id: item.id, - name: item.name, - employeeNo: item.employeeNo, - })), - }} - roleOptions={ROLE_OPTIONS} - /> +
+ { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["users"] }), + queryClient.invalidateQueries({ queryKey: ["employees"] }), + ]); + }} + /> + createMutation.mutate(data)} + isPending={createMutation.isPending} + departments={{ + isLoading: departmentsQuery.isLoading, + items: departmentsQuery.data?.items ?? [], + }} + managers={{ + isLoading: managersQuery.isLoading, + items: (managersQuery.data?.items ?? []).map((item) => ({ + id: item.id, + name: item.name, + employeeNo: item.employeeNo, + })), + }} + roleOptions={ROLE_OPTIONS} + /> +
} /> diff --git a/client/src/pages/user-management/UserImportDialog.tsx b/client/src/pages/user-management/UserImportDialog.tsx new file mode 100644 index 00000000..3d91a41d --- /dev/null +++ b/client/src/pages/user-management/UserImportDialog.tsx @@ -0,0 +1,363 @@ +import { useEffect, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { + applyUserImport, + dryRunUserImport, + type UserImportApplyResult, + type UserImportDryRunResult, +} from "../../api/users"; +import { mapApiErrorMessage } from "../../api/errorMessages"; +import { isApiError } from "../../api/types"; +import { downloadBlob } from "../../lib/download"; +import { Button } from "../../components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../../components/ui/dialog"; +import { Input } from "../../components/ui/input"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"; +import { toast } from "sonner"; + +export interface UserImportDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onImported: () => Promise | void; +} + +const TEMPLATE_CSV = [ + "employeeNo,name,phone,email,departmentName,title,directManagerEmployeeNo,roles,hireDate", + "E1001,李经理,13800000001,manager@example.com,行政部,经理,,manager,2026-03-01", + "E1002,张三,13800000002,zhangsan@example.com,行政部,员工,E1001,employee,2026-03-15", +].join("\n"); + +function toCsvCell(value: string) { + if (/[",\n\r]/.test(value)) { + return `"${value.replace(/"/g, "\"\"")}"`; + } + return value; +} + +function formatErrorMessage(error: unknown, fallback: string) { + if (isApiError(error)) { + return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; + } + return error instanceof Error ? error.message : fallback; +} + +function parsePreviewFromError(error: unknown, sourceFilename: string): UserImportDryRunResult | null { + if (!isApiError(error) || !error.details || typeof error.details !== "object") { + return null; + } + + const summary = "summary" in error.details ? error.details.summary : null; + const rows = "rows" in error.details ? error.details.rows : null; + if (!summary || !rows || !Array.isArray(rows)) { + return null; + } + + return { + sourceFilename, + summary: summary as UserImportDryRunResult["summary"], + rows: rows as UserImportDryRunResult["rows"], + }; +} + +function buildPasswordsCsv(result: UserImportApplyResult) { + const lines = [ + "employeeNo,name,email,oneTimePassword", + ...result.imported.map((item) => + [ + item.employeeNo, + item.name, + item.email, + item.oneTimePassword, + ].map(toCsvCell).join(","), + ), + ]; + return `\uFEFF${lines.join("\n")}`; +} + +export function UserImportDialog({ open, onOpenChange, onImported }: UserImportDialogProps) { + const [selectedFile, setSelectedFile] = useState(null); + const [error, setError] = useState(""); + const [dryRunResult, setDryRunResult] = useState(null); + const [applyResult, setApplyResult] = useState(null); + + useEffect(() => { + if (open) { + return; + } + setSelectedFile(null); + setError(""); + setDryRunResult(null); + setApplyResult(null); + }, [open]); + + const dryRunMutation = useMutation({ + mutationFn: async () => { + if (!selectedFile) { + throw new Error("请先选择 CSV 或 XLSX 文件"); + } + return dryRunUserImport(selectedFile); + }, + onSuccess: (result) => { + setDryRunResult(result); + setApplyResult(null); + setError(""); + if (result.summary.invalidRows > 0) { + toast.error(`预检完成,存在 ${result.summary.invalidRows} 行问题`); + } else { + toast.success("预检通过,可以执行导入"); + } + }, + onError: (mutationError) => { + setError(formatErrorMessage(mutationError, "预检失败")); + const preview = parsePreviewFromError(mutationError, selectedFile?.name ?? "users-import"); + if (preview) { + setDryRunResult(preview); + } + toast.error(formatErrorMessage(mutationError, "预检失败")); + }, + }); + + const applyMutation = useMutation({ + mutationFn: async () => { + if (!selectedFile) { + throw new Error("请先选择 CSV 或 XLSX 文件"); + } + return applyUserImport(selectedFile); + }, + onSuccess: async (result) => { + setApplyResult(result); + setError(""); + await onImported(); + toast.success(`导入完成,成功创建 ${result.imported.length} 个账号`); + }, + onError: (mutationError) => { + setError(formatErrorMessage(mutationError, "导入失败")); + const preview = parsePreviewFromError(mutationError, selectedFile?.name ?? "users-import"); + if (preview) { + setDryRunResult(preview); + } + toast.error(formatErrorMessage(mutationError, "导入失败")); + }, + }); + + const summary = applyResult?.summary ?? dryRunResult?.summary ?? null; + const canApply = + Boolean(selectedFile) && + Boolean(dryRunResult) && + dryRunResult?.summary.invalidRows === 0 && + !applyResult && + !applyMutation.isPending; + + function handleDownloadTemplate() { + downloadBlob( + new Blob([`\uFEFF${TEMPLATE_CSV}`], { type: "text/csv;charset=utf-8" }), + "users-import-template.csv", + ); + } + + function handleDownloadPasswords() { + if (!applyResult) { + return; + } + downloadBlob( + new Blob([buildPasswordsCsv(applyResult)], { type: "text/csv;charset=utf-8" }), + "users-import-passwords.csv", + ); + } + + return ( + + + + + + + 批量导入员工 + + 导入结果会同时创建员工档案、登录账号和角色。流程固定为 dry-run 预检后再 apply,成功后可下载一次性密码清单。 + + + +
+
+
+ + { + const nextFile = event.target.files?.[0] ?? null; + setSelectedFile(nextFile); + setError(""); + setDryRunResult(null); + setApplyResult(null); + }} + /> +
+ 必填列:employeeNo、name、phone、email、departmentName +
+
+ 选填列:title、directManagerEmployeeNo、roles、hireDate +
+ {selectedFile ? ( +
+ 当前文件:{selectedFile.name} +
+ ) : null} +
+ +
+
+ + + {applyResult ? ( + + ) : null} +
+ + {error ? ( +

+ {error} +

+ ) : null} + + {summary ? ( +
+
+
总行数
+
{summary.totalRows}
+
+
+
通过行数
+
{summary.validRows}
+
+
+
失败行数
+
{summary.invalidRows}
+
+
+ ) : ( +
+ 下载模板并上传文件后,先执行 dry-run 查看逐行校验结果。 +
+ )} +
+
+ + {dryRunResult ? ( +
+
+

dry-run 结果

+
{dryRunResult.sourceFilename}
+
+ +
+ + + + 行号 + 员工 + 标准化结果 + 校验结果 + + + + {dryRunResult.rows.map((row) => ( + + {row.rowNumber} + +
{row.name || "-"}
+
{row.employeeNo || "-"}
+
+ +
{row.normalized.departmentName} · {row.normalized.title} · {row.normalized.hireDate}
+
+ 直属上级:{row.normalized.directManagerEmployeeNo || "-"} · 角色:{row.normalized.roles.join(" / ")} +
+
+ + {row.errors.length ? ( +
+ {row.errors.map((item, index) => ( +
+ {item.field}: {item.message} +
+ ))} +
+ ) : ( + 通过 + )} +
+
+ ))} +
+
+
+
+ ) : null} + + {applyResult ? ( +
+
+

导入成功清单

+
+ 已生成一次性密码,建议立即下载并线下发放。 +
+
+ +
+ + + + 员工 + 邮箱 + 一次性密码 + + + + {applyResult.imported.map((item) => ( + + +
{item.name}
+
{item.employeeNo}
+
+ {item.email} + {item.oneTimePassword} +
+ ))} +
+
+
+
+ ) : null} +
+
+
+ ); +} diff --git a/issues/2026-03-28_12-36-28-r37-agent-modes-governance.csv b/issues/2026-03-28_12-36-28-r37-agent-modes-governance.csv index 54f78178..30d740c1 100644 --- a/issues/2026-03-28_12-36-28-r37-agent-modes-governance.csv +++ b/issues/2026-03-28_12-36-28-r37-agent-modes-governance.csv @@ -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。R40 后续观测: 同页中的可看 可改 可导出边界不能只靠前端,最终需要前后端同时收口;hireDate 这类“看起来已有字段”的需求仍要先核对 PATCH schema 与 update data,再决定是否是纯前端改动;员工导出验证了 exports.js 路由模式可以继续平移复用。" +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 路由模式可以继续平移复用。R41 后续观测: 日常运营类导入继续放在 system.users 域内比挂到 cutover console 更顺,Files 派工也更清晰;qa-runner 在这轮除了命令验证,还额外覆盖了 multipart 上传与一次性密码清单下载这类结果物校验;reviewer 仍无需默认启用,但同批上级引用会引入两阶段创建与 direct_manager_id 回填,这类跨行依赖以后仍应优先判定为后端主导问题。" diff --git a/issues/2026-03-28_17-58-04-r41-user-bulk-import.csv b/issues/2026-03-28_17-58-04-r41-user-bulk-import.csv new file mode 100644 index 00000000..f341ebc8 --- /dev/null +++ b/issues/2026-03-28_17-58-04-r41-user-bulk-import.csv @@ -0,0 +1,5 @@ +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +R41-0,R41执行合同建档与范围冻结,为员工批量导入创建独立合同并冻结本轮只做员工加账号一体化导入 不承接考勤增强与 mixed-mode 导入,CSV 校验通过 且明确导入语义为员工档案 账号 角色一起创建 初始密码为系统生成的一次性口令,"python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-28_17-58-04-r41-user-bulk-import.csv",none,DONE,DONE,DONE,issues/2026-03-28_17-58-04-r41-user-bulk-import.csv,[],"合同已建档并校验通过。本轮入口固定在 UserManagementPage,不复用 cutover_console_v1 与 migration-runs。" +R41-1,用户批量导入后端 dry-run 与 apply,在 users 域内新增批量导入 dry-run apply 两个接口 支持 csv xlsx 校验预览与全量事务写入,管理员可上传模板文件执行 dry-run 获得逐行错误与标准化预览 apply 成功时整批创建 employee 与 employee_roles 并返回一次性密码清单 任一行失败整批回滚,"业务流程: system.users 用户上传员工模板 执行 dry-run 后查看行级错误 再 apply 成功创建账号与员工。验证命令: cd server && node --check src/routes/users.js && npm test -- tests/unit/routes/users.import.test.js",manual,DONE,DONE,DONE,server/src/routes/users.js|server/tests/unit/routes/users.import.test.js,"[""R41-0""]","已新增 /api/users/import/dry-run 与 /api/users/import/apply,支持 csv xlsx 解析 文件内重复校验 部门精确匹配 同批上级引用 与全量事务回滚。已验证: node --check src/routes/users.js 通过;npm test -- tests/unit/routes/users.import.test.js 5/5 通过;npm test -- tests/unit/routes/users.list.test.js 3/3 通过。" +R41-2,用户管理页导入入口与密码清单下载,在 UserManagementPage 增加批量导入弹窗 支持模板下载 文件上传 dry-run 结果展示 apply 与一次性密码清单下载,用户管理页显示批量导入按钮 dry-run 有错误时 apply 不可用 apply 成功后可下载 employeeNo name email oneTimePassword 清单 不影响现有单条新建流程,"业务流程: 管理员打开用户管理页 下载模板 上传 csv xlsx 执行 dry-run 查看结果 再 apply 并下载密码清单。验证命令: cd client && npm run typecheck && npm run build",manual,DONE,DONE,DONE,client/src/api/users.ts|client/src/pages/UserManagementPage.tsx|client/src/pages/user-management/UserImportDialog.tsx,"[""R41-0"",""R41-1""]","已新增 UserImportDialog 和 users import API 类型;前端不重写业务校验,只透传后端行号 字段 错误信息。已验证: cd client && npm run typecheck 通过;cd client && npm run build 通过。" +R41-3,QA回归与R37-3继续复盘,完成批量导入单项验证 状态回填 并把导入域与 cutover 域的边界观察补充到治理复盘,R41 各状态正确回填 且 Notes 记录为何不复用 cutover console 为什么员工导入继续沿用 users 域语义,"业务流程: 验证 dry-run 缺表头 重复值 部门错误 上级错误 apply 全量回滚 apply 成功 下载密码清单 非 system.users 无入口 且单条新建 启停 角色分配不回归。验证命令: cd server && node --check src/routes/users.js && npm test -- tests/unit/routes/users.import.test.js && cd ../client && npm run typecheck && npm run build",manual,DONE,DONE,DONE,issues/2026-03-28_17-58-04-r41-user-bulk-import.csv|issues/2026-03-28_12-36-28-r37-agent-modes-governance.csv|server/src/routes/users.js|client/src/pages/UserManagementPage.tsx,"[""R41-1"",""R41-2""]","复盘结论: 员工导入继续留在 system.users 域内比挂到 cutover console 更清晰,因为它是日常运营动作而不是迁移动作;dry-run 再 apply 的双阶段足够顺畅,且能在 apply 阶段重跑校验避免文件变更或库状态漂移;同批上级工号引用可落地,但实现上需要后端显式做两阶段创建和二次回填 direct_manager_id。" diff --git a/server/src/routes/users.js b/server/src/routes/users.js index 1031b0a0..89aea07d 100644 --- a/server/src/routes/users.js +++ b/server/src/routes/users.js @@ -1,5 +1,8 @@ const crypto = require("node:crypto"); +const path = require("node:path"); +const { Readable } = require("node:stream"); +const ExcelJS = require("exceljs"); const { z } = require("zod"); const { AppError } = require("../utils/AppError"); @@ -14,6 +17,7 @@ const { maskBankAccount, maskIdNumber, } = require("../services/crypto"); +const { currentDateString } = require("../utils/date"); const roleCodeSchema = z.enum([ "employee", @@ -76,6 +80,17 @@ const sensitiveBodySchema = z }) .strict(); +const USER_IMPORT_REQUIRED_HEADERS = ["employeeNo", "name", "phone", "email", "departmentName"]; +const USER_IMPORT_OPTIONAL_HEADERS = ["title", "directManagerEmployeeNo", "roles", "hireDate"]; +const USER_IMPORT_ALLOWED_EXTENSIONS = new Set([".csv", ".xlsx"]); +const USER_IMPORT_ALLOWED_MIME_TYPES = new Set([ + "text/csv", + "application/csv", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", +]); +const USER_IMPORT_MAX_ROWS = 500; + function parseUserId(params) { const parsed = z.string().uuid().safeParse(params?.id); if (!parsed.success) { @@ -233,6 +248,496 @@ function parseSensitiveBody(body) { return parsed.data; } +function buildValidationError(details, statusCode = 400) { + return new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode, + details, + }); +} + +function generateOneTimePassword() { + const password = `ResetA1${crypto.randomBytes(6).toString("hex")}`; + const errors = validatePassword(password); + if (errors.length) { + throw buildValidationError({ password: errors }); + } + return password; +} + +function normalizeImportHeader(value) { + return String(value ?? "").replace(/^\ufeff/, "").trim(); +} + +function normalizeImportCell(value) { + if (value == null) { + return ""; + } + if (value instanceof Date) { + return value.toISOString().slice(0, 10); + } + if (typeof value === "object") { + if (Array.isArray(value.richText)) { + return value.richText.map((item) => item?.text ?? "").join("").trim(); + } + if (typeof value.text === "string") { + return value.text.trim(); + } + if (value.result != null) { + return normalizeImportCell(value.result); + } + } + return String(value).trim(); +} + +function normalizeImportRoles(rawValue) { + const raw = normalizeImportCell(rawValue); + if (!raw) { + return ["employee"]; + } + const values = raw + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + return values.length ? values : ["employee"]; +} + +function buildImportRowDraft(rawRow, defaultHireDate) { + const normalized = { + employeeNo: normalizeImportCell(rawRow.data.employeeNo), + name: normalizeImportCell(rawRow.data.name), + phone: normalizeImportCell(rawRow.data.phone), + email: normalizeImportCell(rawRow.data.email), + departmentName: normalizeImportCell(rawRow.data.departmentName), + title: normalizeImportCell(rawRow.data.title) || "员工", + directManagerEmployeeNo: normalizeImportCell(rawRow.data.directManagerEmployeeNo), + roles: normalizeImportRoles(rawRow.data.roles), + hireDate: normalizeImportCell(rawRow.data.hireDate) || defaultHireDate, + }; + + return { + rowNumber: rawRow.rowNumber, + employeeNo: normalized.employeeNo, + name: normalized.name, + normalized, + errors: [], + resolvedDepartmentId: null, + resolvedDirectManagerId: null, + resolvedImportedManagerEmployeeNo: null, + }; +} + +function pushImportRowError(row, field, message) { + const key = `${field}:${message}`; + if (row.errors.some((item) => `${item.field}:${item.message}` === key)) { + return; + } + row.errors.push({ field, message }); +} + +function markDuplicateImportValues(rows, key, field) { + const byValue = new Map(); + for (const row of rows) { + const value = row.normalized[key]; + if (!value) { + continue; + } + const bucket = byValue.get(value) ?? []; + bucket.push(row); + byValue.set(value, bucket); + } + + for (const bucket of byValue.values()) { + if (bucket.length <= 1) { + continue; + } + for (const row of bucket) { + pushImportRowError(row, field, "duplicate_in_file"); + } + } +} + +function resolveWorksheetRows(worksheet) { + if (!worksheet) { + return { headers: [], rows: [] }; + } + + const headerRow = worksheet.getRow(1); + const headers = headerRow.values.slice(1).map((value) => normalizeImportHeader(value)); + const rows = []; + + worksheet.eachRow((row, rowNumber) => { + if (rowNumber === 1) { + return; + } + const data = {}; + headers.forEach((header, index) => { + if (!header) { + return; + } + data[header] = normalizeImportCell(row.getCell(index + 1).value); + }); + + if (Object.values(data).some((value) => value !== "")) { + rows.push({ rowNumber, data }); + } + }); + + return { headers, rows }; +} + +async function parseUserImportRowsFromBuffer(buffer, extension) { + const workbook = new ExcelJS.Workbook(); + if (extension === ".csv") { + const worksheet = await workbook.csv.read(Readable.from([buffer])); + return resolveWorksheetRows(worksheet); + } + + await workbook.xlsx.load(buffer); + return resolveWorksheetRows(workbook.worksheets[0] ?? null); +} + +async function readUserImportPayload(request) { + let filePart = null; + try { + filePart = await request.file(); + } catch (error) { + if (error?.code === "FST_REQ_FILE_TOO_LARGE") { + throw buildValidationError({ file: "file_too_large" }); + } + throw error; + } + + if (!filePart) { + throw buildValidationError({ file: "missing" }); + } + + const extension = path.extname(filePart.filename ?? "").toLowerCase(); + if (!USER_IMPORT_ALLOWED_EXTENSIONS.has(extension)) { + throw buildValidationError({ file: "unsupported_extension" }); + } + + if (!USER_IMPORT_ALLOWED_MIME_TYPES.has(filePart.mimetype)) { + throw buildValidationError({ file: "unsupported_mime_type" }); + } + + const buffer = await filePart.toBuffer(); + const parsed = await parseUserImportRowsFromBuffer(buffer, extension); + const missingHeaders = USER_IMPORT_REQUIRED_HEADERS.filter((header) => !parsed.headers.includes(header)); + if (missingHeaders.length) { + throw buildValidationError({ file: "missing_headers", missingHeaders }); + } + + if (!parsed.rows.length) { + throw buildValidationError({ file: "empty" }); + } + + if (parsed.rows.length > USER_IMPORT_MAX_ROWS) { + throw buildValidationError({ file: "row_limit_exceeded", limit: USER_IMPORT_MAX_ROWS }); + } + + return { + filename: filePart.filename ?? "users-import", + rows: parsed.rows, + }; +} + +function validateImportRowFields(row) { + const { normalized } = row; + + if (!normalized.employeeNo) { + pushImportRowError(row, "employeeNo", "required"); + } else if (normalized.employeeNo.length > 50) { + pushImportRowError(row, "employeeNo", "too_long"); + } + + if (!normalized.name) { + pushImportRowError(row, "name", "required"); + } else if (normalized.name.length > 100) { + pushImportRowError(row, "name", "too_long"); + } + + if (!normalized.phone) { + pushImportRowError(row, "phone", "required"); + } else if (normalized.phone.length < 6) { + pushImportRowError(row, "phone", "too_short"); + } else if (normalized.phone.length > 30) { + pushImportRowError(row, "phone", "too_long"); + } + + if (!normalized.email) { + pushImportRowError(row, "email", "required"); + } else { + const parsedEmail = z.string().trim().email().max(200).safeParse(normalized.email); + if (!parsedEmail.success) { + pushImportRowError(row, "email", "invalid"); + } + } + + if (!normalized.departmentName) { + pushImportRowError(row, "departmentName", "required"); + } else if (normalized.departmentName.length > 100) { + pushImportRowError(row, "departmentName", "too_long"); + } + + if (!normalized.title) { + pushImportRowError(row, "title", "required"); + } else if (normalized.title.length > 100) { + pushImportRowError(row, "title", "too_long"); + } + + if (normalized.directManagerEmployeeNo && normalized.directManagerEmployeeNo.length > 50) { + pushImportRowError(row, "directManagerEmployeeNo", "too_long"); + } + + const parsedHireDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).safeParse(normalized.hireDate); + if (!parsedHireDate.success) { + pushImportRowError(row, "hireDate", "invalid"); + } + + if (!normalized.roles.length) { + pushImportRowError(row, "roles", "required"); + } + for (const roleCode of normalized.roles) { + if (!roleCodeSchema.safeParse(roleCode).success) { + pushImportRowError(row, "roles", `invalid_role:${roleCode}`); + } + } +} + +function createDepartmentResolutionMap(departments) { + const map = new Map(); + for (const item of departments) { + const bucket = map.get(item.name) ?? []; + bucket.push(item); + map.set(item.name, bucket); + } + return map; +} + +function createEmployeeLookupMaps(employees) { + return { + byEmployeeNo: new Map(employees.map((item) => [item.employee_no, item])), + byPhone: new Map(employees.map((item) => [item.phone, item])), + byEmail: new Map(employees.map((item) => [item.email, item])), + }; +} + +async function buildUserImportPreview(prisma, rawRows, options = {}) { + const defaultHireDate = currentDateString(options.timeZone ?? "Asia/Shanghai"); + const previewRows = rawRows.map((row) => buildImportRowDraft(row, defaultHireDate)); + + for (const row of previewRows) { + validateImportRowFields(row); + } + + markDuplicateImportValues(previewRows, "employeeNo", "employeeNo"); + markDuplicateImportValues(previewRows, "phone", "phone"); + markDuplicateImportValues(previewRows, "email", "email"); + + const departmentNames = Array.from( + new Set(previewRows.map((row) => row.normalized.departmentName).filter(Boolean)), + ); + const employeeNos = Array.from( + new Set( + previewRows.flatMap((row) => + [row.normalized.employeeNo, row.normalized.directManagerEmployeeNo].filter(Boolean), + ), + ), + ); + const phones = Array.from(new Set(previewRows.map((row) => row.normalized.phone).filter(Boolean))); + const emails = Array.from(new Set(previewRows.map((row) => row.normalized.email).filter(Boolean))); + + const [departments, employees] = await Promise.all([ + departmentNames.length + ? prisma.department.findMany({ + where: { name: { in: departmentNames } }, + select: { id: true, name: true, archived_at: true }, + }) + : Promise.resolve([]), + employeeNos.length || phones.length || emails.length + ? prisma.employee.findMany({ + where: { + OR: [ + ...(employeeNos.length ? [{ employee_no: { in: employeeNos } }] : []), + ...(phones.length ? [{ phone: { in: phones } }] : []), + ...(emails.length ? [{ email: { in: emails } }] : []), + ], + }, + select: { + id: true, + employee_no: true, + phone: true, + email: true, + status: true, + }, + }) + : Promise.resolve([]), + ]); + + const departmentsByName = createDepartmentResolutionMap(departments); + const existingLookup = createEmployeeLookupMaps(employees); + + for (const row of previewRows) { + const departmentMatches = departmentsByName.get(row.normalized.departmentName) ?? []; + const activeDepartmentMatches = departmentMatches.filter((item) => !item.archived_at); + if (!departmentMatches.length) { + pushImportRowError(row, "departmentName", "not_found"); + } else if (!activeDepartmentMatches.length) { + pushImportRowError(row, "departmentName", "archived"); + } else if (activeDepartmentMatches.length > 1) { + pushImportRowError(row, "departmentName", "ambiguous"); + } else { + row.resolvedDepartmentId = activeDepartmentMatches[0].id; + } + + const employeeNoConflict = existingLookup.byEmployeeNo.get(row.normalized.employeeNo); + if (employeeNoConflict) { + pushImportRowError(row, "employeeNo", "duplicate_existing"); + } + + const phoneConflict = existingLookup.byPhone.get(row.normalized.phone); + if (phoneConflict) { + pushImportRowError(row, "phone", "duplicate_existing"); + } + + const emailConflict = existingLookup.byEmail.get(row.normalized.email); + if (emailConflict) { + pushImportRowError(row, "email", "duplicate_existing"); + } + + if (!row.normalized.directManagerEmployeeNo) { + continue; + } + + if (row.normalized.directManagerEmployeeNo === row.normalized.employeeNo) { + pushImportRowError(row, "directManagerEmployeeNo", "self_not_allowed"); + continue; + } + + const existingManager = existingLookup.byEmployeeNo.get(row.normalized.directManagerEmployeeNo); + if (existingManager) { + if (existingManager.status !== "active") { + pushImportRowError(row, "directManagerEmployeeNo", "inactive"); + } else { + row.resolvedDirectManagerId = existingManager.id; + } + } + } + + const importRowsByEmployeeNo = new Map( + previewRows + .filter((row) => row.normalized.employeeNo && !row.errors.some((item) => item.field === "employeeNo")) + .map((row) => [row.normalized.employeeNo, row]), + ); + const resolvedRows = new Set(); + const resolvingRows = new Set(); + + function resolveImportedManager(row) { + if (!row.normalized.directManagerEmployeeNo || row.resolvedDirectManagerId) { + resolvedRows.add(row); + return; + } + if (resolvedRows.has(row)) { + return; + } + if (resolvingRows.has(row)) { + pushImportRowError(row, "directManagerEmployeeNo", "cycle_not_allowed"); + return; + } + + const importedManager = importRowsByEmployeeNo.get(row.normalized.directManagerEmployeeNo); + if (!importedManager) { + pushImportRowError(row, "directManagerEmployeeNo", "not_found"); + resolvedRows.add(row); + return; + } + + resolvingRows.add(row); + resolveImportedManager(importedManager); + resolvingRows.delete(row); + + if (importedManager.errors.length) { + pushImportRowError(row, "directManagerEmployeeNo", "referenced_row_invalid"); + } else { + row.resolvedImportedManagerEmployeeNo = importedManager.normalized.employeeNo; + } + resolvedRows.add(row); + } + + for (const row of previewRows) { + resolveImportedManager(row); + } + + return { + summary: { + totalRows: previewRows.length, + validRows: previewRows.filter((row) => row.errors.length === 0).length, + invalidRows: previewRows.filter((row) => row.errors.length > 0).length, + }, + rows: previewRows.map((row) => ({ + rowNumber: row.rowNumber, + employeeNo: row.employeeNo, + name: row.name, + normalized: row.normalized, + errors: row.errors, + })), + validRows: previewRows.filter((row) => row.errors.length === 0), + }; +} + +async function createSystemUser(tx, options) { + const now = options.now ?? new Date(); + const passwordHash = await hashPassword(options.password); + + const employee = await tx.employee.create({ + data: { + employee_no: options.employeeNo, + name: options.name, + phone: options.phone, + email: options.email, + department_id: options.departmentId, + title: options.title ?? "员工", + direct_manager_id: options.directManagerId ?? null, + hire_date: options.hireDate, + status: "active", + bank_account_encrypted: "", + bank_name: "", + password_hash: passwordHash, + must_change_password: true, + created_at: now, + created_by: options.actorId, + updated_at: now, + updated_by: options.actorId, + }, + select: { + id: true, + employee_no: true, + name: true, + phone: true, + email: true, + status: true, + department_id: true, + title: true, + created_at: true, + updated_at: true, + }, + }); + + await tx.employeeRole.createMany({ + data: options.roles.map((roleCode) => ({ + employee_id: employee.id, + role_code: roleCode, + created_at: now, + created_by: options.actorId, + })), + skipDuplicates: true, + }); + + return employee; +} + async function usersRoutes(fastify, opts = {}) { const basePathRaw = typeof opts.basePath === "string" && opts.basePath.trim() ? opts.basePath.trim() : "/users"; @@ -412,55 +917,25 @@ async function usersRoutes(fastify, opts = {}) { } } - const passwordHash = await hashPassword(body.password); const now = new Date(); let created = null; try { created = await fastify.prisma.$transaction(async (tx) => { - const employee = await tx.employee.create({ - data: { - employee_no: body.employeeNo, - name: body.name, - phone: body.phone, - email: body.email, - department_id: departmentId, - title: body.title ?? "员工", - direct_manager_id: body.directManagerId ?? null, - hire_date: hireDate, - status: "active", - bank_account_encrypted: "", - bank_name: "", - password_hash: passwordHash, - must_change_password: true, - created_at: now, - created_by: request.employee.id, - updated_at: now, - updated_by: request.employee.id, - }, - select: { - id: true, - employee_no: true, - name: true, - phone: true, - email: true, - status: true, - department_id: true, - title: true, - created_at: true, - updated_at: true, - }, - }); - - await tx.employeeRole.createMany({ - data: body.roles.map((roleCode) => ({ - employee_id: employee.id, - role_code: roleCode, - created_at: now, - created_by: request.employee.id, - })), - skipDuplicates: true, + const employee = await createSystemUser(tx, { + employeeNo: body.employeeNo, + name: body.name, + phone: body.phone, + email: body.email, + departmentId, + title: body.title ?? "员工", + directManagerId: body.directManagerId ?? null, + hireDate, + password: body.password, + roles: body.roles, + actorId: request.employee.id, + now, }); await tx.auditLog.create({ @@ -503,6 +978,150 @@ async function usersRoutes(fastify, opts = {}) { }, ); + if (routeBasePath === "/users") { + fastify.post( + `${routeBasePath}/import/dry-run`, + { + preHandler: fastify.requirePermission("system.users"), + }, + async (request, reply) => { + const { filename, rows } = await readUserImportPayload(request); + const preview = await buildUserImportPreview(fastify.prisma, rows, { + timeZone: fastify.config.appTimezone, + }); + + await fastify.prisma.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "SYSTEM_USERS_IMPORT_DRY_RUN", + targetType: "employee_import", + extra: { + sourceFilename: filename, + ...preview.summary, + }, + }), + }); + + return sendData(reply, request, { + sourceFilename: filename, + summary: preview.summary, + rows: preview.rows, + }); + }, + ); + + fastify.post( + `${routeBasePath}/import/apply`, + { + preHandler: fastify.requirePermission("system.users"), + }, + async (request, reply) => { + const { filename, rows } = await readUserImportPayload(request); + + let result = null; + try { + result = await fastify.prisma.$transaction(async (tx) => { + const preview = await buildUserImportPreview(tx, rows, { + timeZone: fastify.config.appTimezone, + }); + + if (preview.summary.invalidRows > 0) { + throw buildValidationError( + { + summary: preview.summary, + rows: preview.rows, + }, + 409, + ); + } + + const now = new Date(); + const createdRows = []; + const createdByEmployeeNo = new Map(); + + for (const row of preview.validRows) { + const oneTimePassword = generateOneTimePassword(); + const employee = await createSystemUser(tx, { + employeeNo: row.normalized.employeeNo, + name: row.normalized.name, + phone: row.normalized.phone, + email: row.normalized.email, + departmentId: row.resolvedDepartmentId, + title: row.normalized.title, + directManagerId: row.resolvedImportedManagerEmployeeNo ? null : row.resolvedDirectManagerId, + hireDate: new Date(`${row.normalized.hireDate}T00:00:00.000Z`), + password: oneTimePassword, + roles: row.normalized.roles, + actorId: request.employee.id, + now, + }); + + const item = { + employee, + row, + oneTimePassword, + }; + createdRows.push(item); + createdByEmployeeNo.set(row.normalized.employeeNo, item); + } + + for (const item of createdRows) { + if (!item.row.resolvedImportedManagerEmployeeNo) { + continue; + } + const manager = createdByEmployeeNo.get(item.row.resolvedImportedManagerEmployeeNo); + await tx.employee.update({ + where: { id: item.employee.id }, + data: { + direct_manager_id: manager.employee.id, + updated_at: now, + updated_by: request.employee.id, + }, + }); + } + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "SYSTEM_USERS_IMPORTED", + targetType: "employee_import", + extra: { + sourceFilename: filename, + importedCount: createdRows.length, + employeeNos: createdRows.map((item) => item.employee.employee_no), + }, + }), + }); + + return { + sourceFilename: filename, + summary: preview.summary, + imported: createdRows.map((item) => ({ + employeeId: item.employee.id, + employeeNo: item.employee.employee_no, + name: item.employee.name, + email: item.employee.email, + oneTimePassword: item.oneTimePassword, + })), + }; + }); + } catch (error) { + if (error?.code === "P2002") { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: normalizeUniqueErrorDetails(error), + }); + } + throw error; + } + + return sendData(reply, request, result); + }, + ); + } + fastify.patch( `${routeBasePath}/:id`, { diff --git a/server/tests/unit/routes/users.import.test.js b/server/tests/unit/routes/users.import.test.js new file mode 100644 index 00000000..9c3329fc --- /dev/null +++ b/server/tests/unit/routes/users.import.test.js @@ -0,0 +1,346 @@ +const Fastify = require("fastify"); +const multipart = require("@fastify/multipart"); +const ExcelJS = require("exceljs"); + +const usersRoutes = require("../../../src/routes/users"); +const { AppError } = require("../../../src/utils/AppError"); + +function buildMultipartRequest({ filename, contentType, content }) { + const boundary = "----codex-user-import-boundary"; + const head = Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${contentType}\r\n\r\n`, + ); + const tail = Buffer.from(`\r\n--${boundary}--\r\n`); + const body = Buffer.concat([head, Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8"), tail]); + + return { + payload: body, + headers: { + "content-type": `multipart/form-data; boundary=${boundary}`, + }, + }; +} + +async function buildXlsxBuffer(rows) { + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet("users"); + worksheet.addRow([ + "employeeNo", + "name", + "phone", + "email", + "departmentName", + "title", + "directManagerEmployeeNo", + "roles", + "hireDate", + ]); + + for (const row of rows) { + worksheet.addRow([ + row.employeeNo ?? "", + row.name ?? "", + row.phone ?? "", + row.email ?? "", + row.departmentName ?? "", + row.title ?? "", + row.directManagerEmployeeNo ?? "", + row.roles ?? "", + row.hireDate ?? "", + ]); + } + + return Buffer.from(await workbook.xlsx.writeBuffer()); +} + +async function buildApp(options = {}) { + const app = Fastify(); + const existingEmployees = options.existingEmployees ?? []; + const departments = options.departments ?? []; + const auditCreate = vi.fn().mockResolvedValue({}); + const roleCreateMany = vi.fn().mockResolvedValue({ count: 1 }); + const employeeUpdate = vi.fn().mockResolvedValue({}); + let createIndex = 0; + const employeeCreate = vi.fn(async ({ data }) => { + createIndex += 1; + return { + id: `00000000-0000-4000-8000-${String(createIndex).padStart(12, "0")}`, + employee_no: data.employee_no, + name: data.name, + phone: data.phone, + email: data.email, + status: data.status, + department_id: data.department_id, + title: data.title, + created_at: data.created_at, + updated_at: data.updated_at, + }; + }); + const employeeFindMany = vi.fn(async () => existingEmployees); + const departmentFindMany = vi.fn(async () => departments); + + const tx = { + department: { findMany: departmentFindMany }, + employee: { + findMany: employeeFindMany, + create: employeeCreate, + update: employeeUpdate, + }, + employeeRole: { + createMany: roleCreateMany, + }, + auditLog: { + create: auditCreate, + }, + }; + + app.decorate("config", { appTimezone: "Asia/Shanghai" }); + app.decorate("prisma", { + ...tx, + $transaction: vi.fn(async (callback) => callback(tx)), + }); + app.decorate("requirePermission", (permissionCode) => async (request) => { + request.employee = { + id: "actor-1", + department_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + permissionCodes: ["system.users"], + }; + const permissions = request.employee.permissionCodes ?? []; + if (!permissions.includes("*") && !permissions.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"], + }; + }); + app.decorate("hasPermission", () => true); + + await app.register(multipart, { + attachFieldsToBody: false, + limits: { fileSize: 1024 * 1024, files: 1 }, + }); + await app.register(usersRoutes, { prefix: "/api" }); + + return { + app, + auditCreate, + departmentFindMany, + employeeCreate, + employeeFindMany, + employeeUpdate, + roleCreateMany, + }; +} + +describe("users import routes", () => { + it("supports xlsx dry-run and allows manager references to later rows in the same file", async () => { + const { app } = await buildApp({ + departments: [{ id: "dept-1", name: "行政部", archived_at: null }], + }); + const fileBuffer = await buildXlsxBuffer([ + { + employeeNo: "E003", + name: "Carol", + phone: "13800000003", + email: "carol@example.com", + departmentName: "行政部", + directManagerEmployeeNo: "E002", + hireDate: "2026-03-28", + }, + { + employeeNo: "E002", + name: "Bob", + phone: "13800000002", + email: "bob@example.com", + departmentName: "行政部", + title: "经理", + roles: "manager", + hireDate: "2026-03-27", + }, + ]); + + const multipartRequest = buildMultipartRequest({ + filename: "users.xlsx", + contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + content: fileBuffer, + }); + + const response = await app.inject({ + method: "POST", + url: "/api/users/import/dry-run", + ...multipartRequest, + }); + + expect(response.statusCode).toBe(200); + const payload = response.json().data; + expect(payload.summary).toEqual({ + totalRows: 2, + validRows: 2, + invalidRows: 0, + }); + expect(payload.rows[0].normalized.title).toBe("员工"); + expect(payload.rows[0].normalized.directManagerEmployeeNo).toBe("E002"); + expect(payload.rows[0].errors).toEqual([]); + + await app.close(); + }); + + it("rejects dry-run uploads with unsupported mime type", async () => { + const { app } = await buildApp(); + const multipartRequest = buildMultipartRequest({ + filename: "users.csv", + contentType: "application/json", + content: "employeeNo,name,phone,email,departmentName\nE001,Alice,13800000000,alice@example.com,行政部\n", + }); + + const response = await app.inject({ + method: "POST", + url: "/api/users/import/dry-run", + ...multipartRequest, + }); + + expect(response.statusCode).toBe(400); + + await app.close(); + }); + + it("reports duplicate fields, archived departments and inactive managers in dry-run output", async () => { + const { app } = await buildApp({ + departments: [{ id: "dept-archived", name: "归档部门", archived_at: new Date("2026-03-01T00:00:00.000Z") }], + existingEmployees: [ + { + id: "emp-existing", + employee_no: "E001", + phone: "13800000009", + email: "existing@example.com", + status: "active", + }, + { + id: "mgr-archived", + employee_no: "M001", + phone: "13800000008", + email: "manager@example.com", + status: "archived", + }, + ], + }); + const multipartRequest = buildMultipartRequest({ + filename: "users.csv", + contentType: "text/csv", + content: [ + "employeeNo,name,phone,email,departmentName,title,directManagerEmployeeNo,roles,hireDate", + "E001,Alice,13800000009,alice@example.com,归档部门,,M001,employee,2026-03-28", + "E001,Bob,13800000007,bob@example.com,缺失部门,,,employee,2026-03-28", + ].join("\n"), + }); + + const response = await app.inject({ + method: "POST", + url: "/api/users/import/dry-run", + ...multipartRequest, + }); + + expect(response.statusCode).toBe(200); + const payload = response.json().data; + expect(payload.summary.invalidRows).toBe(2); + expect(payload.rows[0].errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: "employeeNo", message: "duplicate_existing" }), + expect.objectContaining({ field: "employeeNo", message: "duplicate_in_file" }), + expect.objectContaining({ field: "departmentName", message: "archived" }), + expect.objectContaining({ field: "directManagerEmployeeNo", message: "inactive" }), + ]), + ); + expect(payload.rows[1].errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: "employeeNo", message: "duplicate_in_file" }), + expect.objectContaining({ field: "departmentName", message: "not_found" }), + ]), + ); + + await app.close(); + }); + + it("applies a valid csv import in one transaction and returns one-time passwords", async () => { + const { app, employeeCreate, employeeUpdate, roleCreateMany, auditCreate } = await buildApp({ + departments: [{ id: "dept-1", name: "行政部", archived_at: null }], + }); + const multipartRequest = buildMultipartRequest({ + filename: "users.csv", + contentType: "text/csv", + content: [ + "employeeNo,name,phone,email,departmentName,title,directManagerEmployeeNo,roles,hireDate", + "E003,Carol,13800000003,carol@example.com,行政部,,E002,employee,2026-03-28", + "E002,Bob,13800000002,bob@example.com,行政部,经理,,manager,2026-03-27", + ].join("\n"), + }); + + const response = await app.inject({ + method: "POST", + url: "/api/users/import/apply", + ...multipartRequest, + }); + + expect(response.statusCode).toBe(200); + const payload = response.json().data; + expect(payload.summary).toEqual({ + totalRows: 2, + validRows: 2, + invalidRows: 0, + }); + expect(payload.imported).toHaveLength(2); + expect(payload.imported[0]).toEqual( + expect.objectContaining({ + employeeNo: "E003", + email: "carol@example.com", + }), + ); + expect(payload.imported[0].oneTimePassword).toMatch(/^ResetA1[0-9a-f]{12}$/); + expect(employeeCreate).toHaveBeenCalledTimes(2); + expect(roleCreateMany).toHaveBeenCalledTimes(2); + expect(employeeUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "00000000-0000-4000-8000-000000000001" }, + data: expect.objectContaining({ + direct_manager_id: "00000000-0000-4000-8000-000000000002", + }), + }), + ); + expect(auditCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + action: "SYSTEM_USERS_IMPORTED", + }), + }), + ); + + await app.close(); + }); + + it("refuses apply when preview contains invalid rows and does not create employees", async () => { + const { app, employeeCreate } = await buildApp(); + const multipartRequest = buildMultipartRequest({ + filename: "users.csv", + contentType: "text/csv", + content: [ + "employeeNo,name,phone,email,departmentName,title,directManagerEmployeeNo,roles,hireDate", + "E003,Carol,13800000003,carol@example.com,不存在部门,,,employee,2026-03-28", + ].join("\n"), + }); + + const response = await app.inject({ + method: "POST", + url: "/api/users/import/apply", + ...multipartRequest, + }); + + expect(response.statusCode).toBe(409); + expect(employeeCreate).not.toHaveBeenCalled(); + + await app.close(); + }); +});