diff --git a/client/src/auth/permissions.ts b/client/src/auth/permissions.ts index 535b1831..4a3d8890 100644 --- a/client/src/auth/permissions.ts +++ b/client/src/auth/permissions.ts @@ -28,12 +28,6 @@ const PROCUREMENT_SELF_SERVICE_PERMISSIONS = [ "procurement.submit", ] as const; -const TRAVEL_SELF_SERVICE_PERMISSIONS = [ - "travel.create", - "travel.view_own", - "travel.submit", -] as const; - const ASSET_SELF_SERVICE_PERMISSIONS = ["asset.view_own"] as const; const PROJECT_SELF_SERVICE_PERMISSIONS = ["project.view_own"] as const; @@ -44,7 +38,6 @@ const EMPLOYEE_BASE_PERMISSIONS = [ ...EXPENSE_SELF_SERVICE_PERMISSIONS, ...LEAVE_SELF_SERVICE_PERMISSIONS, ...ATTENDANCE_SELF_SERVICE_PERMISSIONS, - ...TRAVEL_SELF_SERVICE_PERMISSIONS, ...PROJECT_SELF_SERVICE_PERMISSIONS, ] as const; @@ -66,8 +59,6 @@ const ROLE_PERMISSIONS: Record = { "attendance.view_all", "procurement.approve", "procurement.view_all", - "travel.approve", - "travel.view_all", "asset.view_all", "report.dashboard.view", "project.view_all", @@ -88,8 +79,6 @@ const ROLE_PERMISSIONS: Record = { "attendance.view_all", "procurement.approve", "procurement.view_all", - "travel.approve", - "travel.view_all", "asset.view_all", "report.dashboard.view", "budget.view", @@ -106,8 +95,6 @@ const ROLE_PERMISSIONS: Record = { "payment.batch.manage", "expense.view_all", "procurement.view_all", - "travel.view_all", - "travel.payment.manage", "asset.view_all", "attendance.view_all", ], @@ -129,9 +116,6 @@ const ROLE_PERMISSIONS: Record = { "expense.finance.override_duplicate", "procurement.view_all", "procurement.manage", - "travel.view_all", - "travel.manage", - "travel.payment.manage", "asset.view_all", "asset.manage", "asset.inventory", @@ -149,9 +133,6 @@ const ROLE_PERMISSIONS: Record = { ...EMPLOYEE_BASE_PERMISSIONS, "procurement.view_all", "procurement.manage", - "travel.view_all", - "travel.manage", - "travel.payment.manage", "asset.view_all", "asset.manage", "asset.transfer", diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx index aeef704a..c0b5926d 100644 --- a/client/src/components/navigation/NavLinks.tsx +++ b/client/src/components/navigation/NavLinks.tsx @@ -38,7 +38,7 @@ export const NAV_ITEMS: NavItem[] = [ { to: "/workflow/tasks", label: "审批待办", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] }, { to: "/contracts-registry", label: "合同台账", icon: "description", anyOf: ["project.view_all", "project.manage", "system.settings"] }, { to: "/purchase-requests", label: "采购申请", icon: "shopping_cart", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] }, - { to: "/travel-requests", label: "差旅申请", icon: "flight", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage", "integration.manage"] }, + { to: "/travel-requests", label: "差旅申请", icon: "flight", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage"] }, { to: "/budgets", label: "预算管理", icon: "account_balance", anyOf: ["budget.view", "budget.manage"] }, { to: "/payment-batches", label: "支付批次", icon: "payments", anyOf: ["payment.batch.manage", "risk.review.manage"] }, { to: "/ap-operations", label: "AP 运营", icon: "account_balance_wallet", anyOf: ["payment.batch.manage", "risk.review.manage"] }, diff --git a/client/src/pages/EmployeeProfilePage.tsx b/client/src/pages/EmployeeProfilePage.tsx index 8b702de2..0a907797 100644 --- a/client/src/pages/EmployeeProfilePage.tsx +++ b/client/src/pages/EmployeeProfilePage.tsx @@ -183,6 +183,7 @@ export function EmployeeProfilePage() { queryFn: () => fetchEmployeeProfile(employeeId), }); const canManageEmployeeProfile = hasPermission(permissions, "system.users"); + const canManageSensitiveHr = hasPermission(permissions, "system.settings"); const canEditSelfProfile = me?.id === employeeId; const canEditBaseFields = canManageEmployeeProfile || canEditSelfProfile; const canEditExtendedFields = canManageEmployeeProfile; @@ -266,11 +267,19 @@ export function EmployeeProfilePage() { }, [employeeId, requestedTab]); useEffect(() => { - if (canManageEmployeeProfile || canEditSelfProfile || activeTab === "profile" || activeTab === "performance") { + if (activeTab === "salary" && !canManageSensitiveHr && !canEditSelfProfile) { + updateActiveTab("profile"); + return; + } + if (activeTab === "performance" && !canManageSensitiveHr && !canEditSelfProfile) { + updateActiveTab("profile"); + return; + } + if (canManageEmployeeProfile || canEditSelfProfile || activeTab === "profile") { return; } updateActiveTab("profile"); - }, [activeTab, canEditSelfProfile, canManageEmployeeProfile]); + }, [activeTab, canEditSelfProfile, canManageEmployeeProfile, canManageSensitiveHr]); useEffect(() => { setAvatarFile(null); @@ -528,8 +537,8 @@ export function EmployeeProfilePage() { profileQuery.isError && isApiError(profileQuery.error) && profileQuery.error.status === 403; const performanceOnlyMode = activeTab === "performance" && profilePermissionDenied && Boolean(performanceQuery.data); const showPerformanceTab = - canManageEmployeeProfile || canEditSelfProfile || performanceOnlyMode || performanceQuery.data?.viewerRole === "manager"; - const showSalaryTab = canManageEmployeeProfile || canEditSelfProfile; + canManageSensitiveHr || canEditSelfProfile || performanceOnlyMode || performanceQuery.data?.viewerRole === "manager"; + const showSalaryTab = canManageSensitiveHr || canEditSelfProfile; const performanceContent = performanceQuery.isLoading ? ( @@ -546,7 +555,7 @@ export function EmployeeProfilePage() { ]} /> ); - const salaryContent = ; + const salaryContent = ; if (profileQuery.isLoading || (activeTab === "performance" && profilePermissionDenied && performanceQuery.isLoading)) { return
加载员工档案中...
; diff --git a/client/src/pages/PeopleStatsPage.tsx b/client/src/pages/PeopleStatsPage.tsx index e6b45dfa..5cbbe493 100644 --- a/client/src/pages/PeopleStatsPage.tsx +++ b/client/src/pages/PeopleStatsPage.tsx @@ -117,7 +117,7 @@ export function PeopleStatsPage() {
上月人均薪资
{moneyText(salarySummary.previousMonth?.averageNetAmount)}
-
非 system.users 不展示任何薪资汇总
+
非 system.settings 不展示任何薪资汇总
) : null}
diff --git a/client/src/router/index.tsx b/client/src/router/index.tsx index 968ae458..6e891828 100644 --- a/client/src/router/index.tsx +++ b/client/src/router/index.tsx @@ -471,7 +471,7 @@ export function AppRouter() { + {withRouteFallback()} } @@ -480,7 +480,7 @@ export function AppRouter() { + {withRouteFallback()} } diff --git a/issues/2026-05-23_23-42-53-oa-permission-freeze-search-filter-v1.csv b/issues/2026-05-23_23-42-53-oa-permission-freeze-search-filter-v1.csv new file mode 100644 index 00000000..e65c0f97 --- /dev/null +++ b/issues/2026-05-23_23-42-53-oa-permission-freeze-search-filter-v1.csv @@ -0,0 +1,10 @@ +"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes" +"OAPERM1-0","建立权限冻结与搜索过滤修复合同","承接 OAAUTO1-4 发现:差旅、薪酬、绩效和合同/涉密资料搜索结果在最终启用前需要按 blocker 处理。本合同只处理上线前权限收口,不做真实业务写入、不付款、不恢复数据库。","Issue CSV 校验通过;明确本轮目标为 P0 + 轻 P1 启用前权限收口;未授权的后置模块返回 403 或功能关闭 409;搜索不泄露合同台账、涉密资料或差旅摘要给非授权角色。","受影响流程:公网权限矩阵 -> blocker 分类 -> 修复合同;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-23_23-42-53-oa-permission-freeze-search-filter-v1.csv。","python3|manual","DONE","DONE","DONE","issues/2026-05-23_23-42-53-oa-permission-freeze-search-filter-v1.csv","[]","已完成:Issue CSV 已创建并通过 validate_issues_csv.py 和 check-process-contract general。MAGI 审视:权限问题必须先收口再启动 7 天稳定化;执行:按最小启用范围修冻结项和搜索过滤;提升:后续模块启用必须先扩权限矩阵。回滚/清理:本合同可通过回退本次权限常量、路由守卫和搜索过滤代码恢复原访问口径;不涉及 schema 或数据迁移。" +"OAPERM1-1","差旅模块上线前冻结","差旅申请不属于当前最终启用范围,员工、主管、财务、CEO、财务经理和行政在上线前不应通过直连 API、导航、审批待办或全局搜索访问差旅申请;仅 admin/system.settings 保留治理可见性。","employee、manager、finance 对 /api/travel-requests?page=1&pageSize=5 返回 403 或 409;全局搜索不返回 travel_request;导航不展示差旅入口给无 travel 权限角色;历史差旅 workflow task 只向 system.settings/admin 暴露摘要或详情。","受影响流程:角色登录 -> 差旅列表直连 -> 导航 -> 审批待办 -> 全局搜索;验证步骤:node scripts/qa/check-oa-permission-matrix.mjs,重点检查 travel-requests 和 global-search-sensitive;npx vitest run tests/unit/constants/permissions.test.js tests/unit/routes/workflow*.test.js。","node|playwright","DONE","DONE","TODO","server/src/constants/permissions.js;client/src/auth/permissions.ts;server/src/routes/workflow.js;server/src/routes/search.js;client/src/components/navigation/NavLinks.tsx;server/src/services/workspaceSettings.js;scripts/qa/check-oa-permission-matrix.mjs","[""OAPERM1-0""]","已实现:从 employee/manager/finance/ceo/finance_mgr/administration 默认权限移除 travel.*;前端权限事实源和导航同步;search/workflow 对 travel_request 改为仅 system.settings/admin 可见;受影响 vitest 12 个文件 49 条通过。公网权限矩阵待部署后复核。权限模型变更。回滚/清理:恢复相关角色的 travel.* 权限、导航 anyOf、search 条件和 workflow visibility 即可恢复原差旅访问;如未来启用差旅,需新 Issue 重新定义角色和验收。" +"OAPERM1-2","薪酬和绩效管理入口收紧","薪酬配置和绩效周期属于高敏/后置模块;HR 当前可管理人员,但不应默认拥有薪酬项目、薪资导出、绩效周期管理权限、员工档案薪资/绩效入口或统计摘要。","HR 访问 /api/payroll/items 返回 403;HR 访问 /api/performance/cycles 返回 403 或 409;员工统计对非 system.settings 不返回薪酬/绩效摘要;admin 仍可通过对应正例;不影响 HR 的人员管理和企微归档查看正例。","受影响流程:HR 登录 -> 薪酬配置直连 -> 薪资导出 -> 员工档案薪资/绩效入口 -> 绩效周期直连 -> 员工统计 -> admin 正例;验证步骤:node scripts/qa/check-oa-permission-matrix.mjs;npx vitest run tests/unit/routes/employees.stats.test.js tests/unit/routes/payroll*.test.js tests/unit/routes/performance*.test.js;cd client && npm run typecheck。","node|playwright","DONE","DONE","TODO","server/src/routes/payroll.js;server/src/routes/performance.js;server/src/routes/exports.js;server/src/routes/employees.js;server/src/services/employeeStats.js;client/src/router/index.tsx;client/src/pages/PeopleStatsPage.tsx;client/src/pages/EmployeeProfilePage.tsx;scripts/qa/check-oa-permission-matrix.mjs","[""OAPERM1-0""]","已实现:薪酬项目、薪资资料、薪资导出、绩效周期/评审、前端 payroll/performance 路由、员工档案薪资/绩效 tab 和员工统计敏感摘要改由 system.settings 管理;人员统计页文案同步 system.settings。公网权限矩阵待部署后复核。权限模型变更。回滚/清理:将相关路由守卫和员工统计 include 条件恢复为 system.users 即可恢复 HR 原访问;薪酬/绩效后续正式启用需独立合同和更细权限。" +"OAPERM1-3","合同台账和涉密资料搜索过滤","合同台账和涉密资料引用属于敏感台账;非管理员/系统设置角色不得从全局搜索看到 contract_registry 或 confidential_doc_ref 结果,即使其有项目查看/管理权限。","employee、manager、finance、HR、CEO 的 /api/search?q=TEST&limit=10 不包含 contract_registry、confidential_doc_ref 或对应 href;admin 正例仍可搜索。","受影响流程:全局搜索 -> 搜索结果权限过滤 -> 点击入口;验证步骤:node scripts/qa/check-oa-permission-matrix.mjs,重点检查 global-search-sensitive。","node|playwright","DONE","DONE","TODO","server/src/routes/search.js;client/src/components/GlobalSearchDialog.tsx;scripts/qa/check-oa-permission-matrix.mjs","[""OAPERM1-0""]","已实现:全局搜索中 contract_registry/confidential_doc_ref 仅 system.settings 或 admin 可见;新增项目经理搜索负例单测并通过。只调整搜索暴露面,不在本轮重构合同台账业务权限。回滚/清理:恢复 search.js 中合同/资料搜索权限条件即可恢复原搜索口径。" +"OAPERM1-4","后端语法与最小静态门禁","完成权限修复后运行后端语法、前端类型检查和项目最小门禁,确认无语法或构建回归。","node --check 覆盖修改路由;受影响 vitest 通过;cd client && npm run typecheck 通过;bash scripts/check-minimal.sh 通过;git diff --check 通过。","受影响流程:代码修改 -> 静态检查 -> 最小回归;验证步骤:node --check server/src/routes/search.js;node --check server/src/routes/payroll.js;node --check server/src/routes/performance.js;node --check server/src/routes/travelRequests.js;node --check server/src/routes/exports.js;node --check server/src/routes/workflow.js;bash scripts/check-minimal.sh;git diff --check。","node|bash|git","DONE","DONE","DONE","server/src/routes/search.js;server/src/routes/payroll.js;server/src/routes/performance.js;server/src/routes/exports.js;server/src/routes/travelRequests.js;server/src/routes/workflow.js;server/src/routes/employees.js;server/src/services/employeeStats.js;server/src/services/workspaceSettings.js;server/src/constants/permissions.js;client/src/auth/permissions.ts;client/src/components/navigation/NavLinks.tsx;client/src/router/index.tsx;client/src/pages/EmployeeProfilePage.tsx;client/src/pages/PeopleStatsPage.tsx","[""OAPERM1-1"",""OAPERM1-2"",""OAPERM1-3""]","两轮 reviewer 阻断已回修:同步前端权限事实源、员工统计、search/workflow 差旅强冻结、员工档案敏感 tab 和权限常量测试。已通过受影响 vitest 12 个文件 49 条、client typecheck、node --check、git diff --check 和 bash scripts/check-minimal.sh。回滚/清理:若后续公网发现误伤,回退本次代码或缩小修改面后重跑。" +"OAPERM1-5","公网权限矩阵复核","修复部署前可本地语法验证;部署后使用 live smoke env 跑公网权限矩阵,确认 OAAUTO1-4 finding 已消除或可解释。","权限矩阵结论达到 PASS_PERMISSION_MATRIX_READY;如外部配置阻断,必须记录为 SKIP/BLOCKED_BY_EXTERNAL_CONFIG 且不得伪装为通过。","受影响流程:多角色登录 -> 正例入口 -> 负例直连 -> 搜索过滤;验证步骤:LIVE_SMOKE_BASE_URL=https://oa.hyinda.com node scripts/qa/check-oa-permission-matrix.mjs。","node|playwright","TODO","TODO","TODO","scripts/qa/check-oa-permission-matrix.mjs;artifacts/qa-flow/oa-permission-matrix-*","[""OAPERM1-4""]","本地不能代表公网角色状态;公网验证需要 live smoke env,但不得打印密码或完整账号。回滚/清理:公网发现误伤时回滚本次部署或恢复 OAPERM1 权限代码后重新部署;报告仅保留脱敏状态。" +"OAPERM1-6","只读 reviewer 评审","由于本轮涉及权限模型和高敏入口,实施后启动只读 reviewer subagent 审查 diff、测试覆盖和潜在越权/误伤。","reviewer 只输出意见不改代码;无 P0 blocker 后才能进入回归/部署判断;发现问题则回到对应 OAPERM1 行修复。","受影响流程:权限修复 -> 独立评审 -> 回归;验证步骤:reviewer 阅读 diff、Issue CSV 和权限矩阵输出,重点检查员工/主管/财务/HR/CEO/admin 权限边界。","subagent|manual","DONE","DONE","DONE","server/src/constants/permissions.js;client/src/auth/permissions.ts;server/src/routes/search.js;server/src/routes/workflow.js;server/src/routes/payroll.js;server/src/routes/performance.js;server/src/routes/employees.js","[""OAPERM1-4""]","已完成两轮只读 reviewer:第一轮发现权限常量测试、员工统计、前端事实源和 workflow 差旅待办残留;第二轮发现 travel_request 搜索/workflow 仍应强制 system.settings 和 HR 前端薪资 tab 噪声;均已回修。最终 reviewer 结论:不再阻断提交,也不阻断进入部署/公网权限矩阵复核。回滚/清理:若后续公网矩阵失败,保持 OAPERM1-5 未 DONE 并回修。" +"OAPERM1-7","OALAUNCH1 权限证据刷新","权限矩阵通过后刷新最终启用汇总,把权限 blocker 从 open gate 中移除或转为已解释 finding;真实业务、备份恢复、7 天稳定化和 Go/No-Go 仍保持人工门禁。","run-oa-final-launch-readiness 输出不再因 OAAUTO1-4 权限 blocker 阻断;但在人工项未完成前仍不得输出 PASS_OA_LAUNCH_READY。","受影响流程:权限证据 -> OALAUNCH 汇总 -> 启用门禁;验证步骤:OALAUNCH_PERMISSION_EVIDENCE= bash scripts/qa/run-oa-final-launch-readiness.sh。","bash|manual","TODO","TODO","TODO","scripts/qa/run-oa-final-launch-readiness.sh;artifacts/qa-flow/oa-final-launch-readiness-*","[""OAPERM1-5"",""OAPERM1-6""]","回滚/清理:若权限证据不通过,不刷新为通过结论;最终启用仍由 Day 7 Go/No-Go 人工决定。" +"OAPERM1-8","备份恢复与真实业务后续边界","本轮只把备份恢复演练和真实业务样本接入后续顺序,不执行影子库恢复、不提交真实报销/采购/合同、不做真实付款。","明确下一步仍为 OABK1 影子库恢复演练合同、最小真实业务样本和 7 天稳定化;不产生公网业务写入。","受影响流程:权限收口 -> 备份恢复准备 -> 真实业务样本 -> 7 天稳定化;验证步骤:最终回复列明剩余人工项和禁止自动替代边界。","manual","DONE","DONE","DONE","issues/2026-05-24_00-50-23-oa-backup-restore-drill-v1.csv;issues/2026-05-23_23-01-55-oa-automation-manual-cutover-v1.csv;issues/2026-05-23_16-49-39-oa-final-launch-readiness-v1.csv","[""OAPERM1-6""]","已新建 OABK1 影子库恢复演练合同并重申真实业务最小样本步骤;未执行恢复、未提交真实业务、未付款。回滚/清理:这是执行边界记录,不修改系统;后续 OABK1 只能在人工批准后做影子库恢复。" diff --git a/issues/2026-05-24_00-50-23-oa-backup-restore-drill-v1.csv b/issues/2026-05-24_00-50-23-oa-backup-restore-drill-v1.csv new file mode 100644 index 00000000..f587c052 --- /dev/null +++ b/issues/2026-05-24_00-50-23-oa-backup-restore-drill-v1.csv @@ -0,0 +1,9 @@ +"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes" +"OABK1-0","建立影子库恢复演练合同","为 OA 正式启用前补齐备份恢复门禁,建立只针对影子库/临时库的恢复演练执行合同;本合同不覆盖生产库、不重置公网、不改变真实业务数据。","Issue CSV 校验通过;明确演练目标、禁止项、人工批准点、证据格式和清理路径;未批准前不执行任何恢复命令。","受影响流程:备份恢复准备 -> 人工批准 -> 影子库恢复 -> 验证 -> 清理;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-24_00-50-23-oa-backup-restore-drill-v1.csv;node scripts/qa/check-process-contract.mjs --issue issues/2026-05-24_00-50-23-oa-backup-restore-drill-v1.csv --profile general --json。","python3|node|manual","DONE","DONE","DONE","issues/2026-05-24_00-50-23-oa-backup-restore-drill-v1.csv","[]","MAGI 审视:正式启用不能只看备份存在,必须证明可恢复;执行:先做合同和清单;提升:以后把 RTO/RPO 和恢复证据纳入 OALAUNCH1。回滚/清理:本行只新增合同,不执行远端命令;若不采纳可标记 superseded,不影响公网或数据库。" +"OABK1-1","备份源与恢复目标确认","确认公网 OA 当前备份来源、最近备份时间、备份文件或快照位置、附件存储备份位置、恢复目标机器/目录/影子库名和临时凭据。","形成脱敏恢复参数清单;不在仓库、聊天或报告写入密码、Secret、完整连接串、完整本地路径或真实敏感业务内容。","受影响流程:运维备份 -> 参数核对 -> 恢复窗口;验证步骤:人工填写 OAAUTO1 证据模板或临时 /tmp env;只记录脱敏别名、时间、大小、hash/校验状态。","manual|ssh","TODO","TODO","TODO","external|/tmp|artifacts/qa-flow","[""OABK1-0""]","人工确认点:A 使用最近自动备份;B 指定某个备份点;C 暂停演练。回滚/清理:本步骤只读核对,清理临时 env 和脱敏清单即可。" +"OABK1-2","影子库恢复命令草案","准备不会触碰生产库的恢复命令草案:创建临时数据库或临时 schema,导入备份,指向只读验证连接,禁止把恢复目标设为生产 DSN。","命令草案包含 dry-run/echo 预览、目标库二次确认、禁止生产库保护、失败退出和清理命令;未经批准不执行。","受影响流程:命令准备 -> 人工确认 -> 受控执行;验证步骤:bash -n 或 shellcheck 可用时检查脚本片段;人工核查目标 DSN 不是生产库。","bash|manual","TODO","TODO","TODO","artifacts/qa-flow/oa-launch-manual-evidence-*;docs/trialrun/OPS7运维交接Runbook.md","[""OABK1-1""]","建议命名:hyb_oa_restore_shadow_。回滚/清理:DROP 临时库/临时 schema、删除临时连接文件、撤销临时凭据;禁止 DROP/restore 生产库。" +"OABK1-3","恢复后只读抽样验证","在影子库中只读验证关键表和附件引用:员工/角色、报销/发票、付款批次/AP、采购、合同台账、资产台账、审计日志、文件引用一致性。","输出脱敏 summary:表存在、行数区间、最近更新时间、关键外键抽样、附件引用可定位;不输出真实票号、合同正文、银行流水、完整账号、完整路径或原始 payload。","受影响流程:影子库恢复 -> 只读 SQL/脚本抽样 -> 脱敏证据;验证步骤:只读查询或 QA-only 脚本输出 artifacts/qa-flow/oa-backup-restore-drill-/summary.json。","node|psql|manual","TODO","TODO","TODO","scripts/qa;artifacts/qa-flow/oa-backup-restore-drill-*","[""OABK1-2""]","允许新增 QA-only 脚本,但不得写业务库。回滚/清理:删除影子库连接文件和报告中的敏感原文;保留脱敏 summary 作为证据。" +"OABK1-4","RTO/RPO 和负责人记录","记录恢复耗时、备份点时间、RTO/RPO 初始值、演练负责人、问题 owner、下一次演练建议。","RTO/RPO 有明确数值或区间;如果恢复超时或备份过旧,记录 blocker/finding 并进入整改合同,不得直接 Go。","受影响流程:恢复结果 -> 运维指标 -> 启用门禁;验证步骤:summary.json 或人工证据模板包含 rtoMinutes、rpoMinutes、owner、decision。","manual","TODO","TODO","TODO","artifacts/qa-flow/oa-backup-restore-drill-*;issues/2026-05-23_16-49-39-oa-final-launch-readiness-v1.csv","[""OABK1-3""]","回滚/清理:指标记录只保留脱敏信息;如结果不合格,不更新 OALAUNCH 为通过。" +"OABK1-5","人工接受或重跑决策","恢复演练完成后由你选择接受、修复后重跑或暂停正式启用;自动化不能替代该决策。","用户明确选择 A/B/C;A 接受恢复演练结果并进入 Go/No-Go 准备;B 修复后重跑;C 暂停正式启用。","受影响流程:恢复证据 -> 用户决策 -> OALAUNCH1 门禁;验证步骤:在最终回复或 OALAUNCH evidence 中记录脱敏决策,不写入敏感运维凭据。","manual","TODO","TODO","TODO","external|issues/2026-05-23_16-49-39-oa-final-launch-readiness-v1.csv","[""OABK1-4""]","人工选项:A 接受;B 修复后重跑;C 暂停。回滚/清理:未接受前 OALAUNCH1 继续 PAUSED_MANUAL_CONFIRMATION。" +"OABK1-6","OALAUNCH1 恢复证据接入","在恢复演练被接受后,把脱敏 summary 接入 run-oa-final-launch-readiness,作为 BACKUP_RESTORE_DRILL 门禁证据。","OALAUNCH1 不再因备份恢复缺证据阻断;但真实业务闭环、7 天稳定化和 Day 7 Go/No-Go 未完成前仍不得 PASS_OA_LAUNCH_READY。","受影响流程:恢复证据 -> 最终启用汇总;验证步骤:OALAUNCH_BACKUP_EVIDENCE= bash scripts/qa/run-oa-final-launch-readiness.sh。","bash|manual","TODO","TODO","TODO","scripts/qa/run-oa-final-launch-readiness.sh;artifacts/qa-flow/oa-final-launch-readiness-*","[""OABK1-5""]","回滚/清理:若证据不被接受,不设置 OALAUNCH_BACKUP_EVIDENCE;不伪造通过。" +"OABK1-7","真实业务样本最小人工步骤确认","并行明确真实业务最小样本:1 条真实报销闭环、1 条采购/费用申请、1 条合同台账;这些不能由自动化伪造为真实业务。","步骤和脱敏证据字段明确;不要求大量样本;不上传涉密合同正文、银行流水原件、密码、Secret、完整票号或完整本地路径到仓库/聊天/报告。","受影响流程:真实报销 -> 审批 -> 付款/AP;采购/费用 -> 审批 -> 关联;合同台账 -> 归档位置 -> 权限抽查;验证步骤:人工完成后用 OAAUTO1 evidence 模板记录业务别名、状态、日期、角色和证据路径。","manual","DONE","DONE","DONE","issues/2026-05-23_23-01-55-oa-automation-manual-cutover-v1.csv;artifacts/qa-flow/oa-launch-manual-evidence-*","[""OABK1-0""]","已细化步骤来源 OAAUTO1-8/9/10;本合同只重申最小真实样本边界。回滚/清理:真实业务数据按审计保留;误录按业务状态归档/冲正,不从仓库清理。" diff --git a/server/src/constants/permissions.js b/server/src/constants/permissions.js index 885fa99b..38162a39 100644 --- a/server/src/constants/permissions.js +++ b/server/src/constants/permissions.js @@ -44,7 +44,6 @@ const EMPLOYEE_BASE_PERMISSIONS = [ ...EXPENSE_SELF_SERVICE_PERMISSIONS, ...LEAVE_SELF_SERVICE_PERMISSIONS, ...ATTENDANCE_SELF_SERVICE_PERMISSIONS, - ...TRAVEL_SELF_SERVICE_PERMISSIONS, ...PROJECT_SELF_SERVICE_PERMISSIONS, ]; @@ -154,8 +153,6 @@ const ROLE_PERMISSIONS = { "attendance.view_all", "procurement.approve", "procurement.view_all", - "travel.approve", - "travel.view_all", "asset.view_all", "report.dashboard.view", "project.view_all", @@ -176,8 +173,6 @@ const ROLE_PERMISSIONS = { "attendance.view_all", "procurement.approve", "procurement.view_all", - "travel.approve", - "travel.view_all", "asset.view_all", "report.dashboard.view", "budget.view", @@ -194,8 +189,6 @@ const ROLE_PERMISSIONS = { "payment.batch.manage", "expense.view_all", "procurement.view_all", - "travel.view_all", - "travel.payment.manage", "asset.view_all", "attendance.view_all", ], @@ -217,9 +210,6 @@ const ROLE_PERMISSIONS = { "expense.finance.override_duplicate", "procurement.view_all", "procurement.manage", - "travel.view_all", - "travel.manage", - "travel.payment.manage", "asset.view_all", "asset.manage", "asset.inventory", @@ -237,9 +227,6 @@ const ROLE_PERMISSIONS = { ...EMPLOYEE_BASE_PERMISSIONS, "procurement.view_all", "procurement.manage", - "travel.view_all", - "travel.manage", - "travel.payment.manage", "asset.view_all", "asset.manage", "asset.transfer", diff --git a/server/src/routes/employees.js b/server/src/routes/employees.js index bed0539a..0b34c8c0 100644 --- a/server/src/routes/employees.js +++ b/server/src/routes/employees.js @@ -844,7 +844,8 @@ async function employeesRoutes(fastify) { const stats = await buildEmployeeStats(fastify.prisma, { timeZone: fastify.config.appTimezone, - includeSalarySummary: fastify.hasPermission(request.employee, "system.users"), + includeSalarySummary: fastify.hasPermission(request.employee, "system.settings"), + includePerformanceSummary: fastify.hasPermission(request.employee, "system.settings"), }); return sendData(reply, request, stats); }); diff --git a/server/src/routes/exports.js b/server/src/routes/exports.js index fce0a211..4bd310b0 100644 --- a/server/src/routes/exports.js +++ b/server/src/routes/exports.js @@ -1762,7 +1762,7 @@ async function exportsRoutes(fastify) { fastify.get( "/exports/payslips", { - preHandler: fastify.requirePermission("system.users"), + preHandler: fastify.requirePermission("system.settings"), }, async (request, reply) => { const query = parseQuery(PAYSLIP_EXPORT_QUERY_SCHEMA, request.query); diff --git a/server/src/routes/payroll.js b/server/src/routes/payroll.js index 0bca1b0e..fb8b4fb1 100644 --- a/server/src/routes/payroll.js +++ b/server/src/routes/payroll.js @@ -21,6 +21,7 @@ const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); const PAY_MONTH_SCHEMA = z.string().regex(/^\d{4}-\d{2}$/); const MONEY_INPUT_SCHEMA = z.union([z.number(), z.string()]); const SALARY_ITEM_TYPE_SCHEMA = z.enum(["earning", "deduction"]); +const PAYROLL_MANAGE_PERMISSION = "system.settings"; const SALARY_ITEM_CREATE_SCHEMA = z .object({ @@ -232,14 +233,14 @@ async function ensurePayslipExists(prisma, payslipId) { } function canViewEmployeePayslips(fastify, request, employeeId) { - return request.employee.id === employeeId || fastify.hasPermission(request.employee, "system.users"); + return request.employee.id === employeeId || fastify.hasPermission(request.employee, PAYROLL_MANAGE_PERMISSION); } async function payrollRoutes(fastify) { fastify.get( "/payroll/items", { - preHandler: fastify.requirePermission("system.users"), + preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION), }, async (request, reply) => { const items = await fastify.prisma.salaryItem.findMany({ @@ -255,7 +256,7 @@ async function payrollRoutes(fastify) { fastify.post( "/payroll/items", { - preHandler: fastify.requirePermission("system.users"), + preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION), }, async (request, reply) => { const body = parseBody(SALARY_ITEM_CREATE_SCHEMA, request.body); @@ -300,7 +301,7 @@ async function payrollRoutes(fastify) { fastify.patch( "/payroll/items/:itemId", { - preHandler: fastify.requirePermission("system.users"), + preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION), }, async (request, reply) => { const itemId = parseId(request.params?.itemId); @@ -347,7 +348,7 @@ async function payrollRoutes(fastify) { fastify.get( "/payroll/employees/:employeeId/profile", { - preHandler: fastify.requirePermission("system.users"), + preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION), }, async (request, reply) => { const employeeId = parseId(request.params?.employeeId); @@ -375,7 +376,7 @@ async function payrollRoutes(fastify) { fastify.put( "/payroll/employees/:employeeId/profile", { - preHandler: fastify.requirePermission("system.users"), + preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION), }, async (request, reply) => { const employeeId = parseId(request.params?.employeeId); @@ -461,7 +462,7 @@ async function payrollRoutes(fastify) { } await ensureEmployeeExists(fastify.prisma, employeeId); - const isAdmin = fastify.hasPermission(request.employee, "system.users"); + const isAdmin = fastify.hasPermission(request.employee, PAYROLL_MANAGE_PERMISSION); const payslips = await fastify.prisma.payslip.findMany({ where: { employee_id: employeeId, @@ -496,7 +497,7 @@ async function payrollRoutes(fastify) { fastify.get( "/payroll/payslips", { - preHandler: fastify.requirePermission("system.users"), + preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION), }, async (request, reply) => { const query = parseQuery(PAYSLIP_QUERY_SCHEMA, request.query); @@ -519,7 +520,7 @@ async function payrollRoutes(fastify) { fastify.post( "/payroll/payslips/generate", { - preHandler: fastify.requirePermission("system.users"), + preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION), }, async (request, reply) => { const body = parseBody(PAYSLIP_GENERATE_SCHEMA, request.body); @@ -679,7 +680,7 @@ async function payrollRoutes(fastify) { fastify.patch( "/payroll/payslips/:payslipId", { - preHandler: fastify.requirePermission("system.users"), + preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION), }, async (request, reply) => { const payslipId = parseId(request.params?.payslipId); @@ -753,7 +754,7 @@ async function payrollRoutes(fastify) { fastify.post( "/payroll/payslips/issue", { - preHandler: fastify.requirePermission("system.users"), + preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION), }, async (request, reply) => { const body = parseBody(PAYSLIP_ISSUE_SCHEMA, request.body); diff --git a/server/src/routes/performance.js b/server/src/routes/performance.js index daa8bec5..94626ddd 100644 --- a/server/src/routes/performance.js +++ b/server/src/routes/performance.js @@ -25,6 +25,7 @@ const SCORE_SCHEMA = z .refine((value) => Number.isFinite(value) && Math.abs(value * 2 - Math.round(value * 2)) < 1e-9, { message: "invalid_score_step", }); +const PERFORMANCE_MANAGE_PERMISSION = "system.settings"; const CYCLE_CREATE_SCHEMA = z .object({ @@ -212,15 +213,9 @@ function mapReview(review) { } function resolveViewerRole(fastify, request, employee) { - if (fastify.hasPermission(request.employee, "system.users")) { + if (fastify.hasPermission(request.employee, PERFORMANCE_MANAGE_PERMISSION)) { return "admin"; } - if (request.employee.id === employee.id) { - return "self"; - } - if (employee.direct_manager_id && employee.direct_manager_id === request.employee.id) { - return "manager"; - } return null; } @@ -386,8 +381,8 @@ async function buildEmployeeTimeline(prisma, employeeId, viewerRole) { async function performanceRoutes(fastify) { fastify.get("/performance/cycles", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); - if (!fastify.hasPermission(request.employee, "system.users")) { - throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); + if (!fastify.hasPermission(request.employee, PERFORMANCE_MANAGE_PERMISSION)) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: PERFORMANCE_MANAGE_PERMISSION } }); } const cycles = await fastify.prisma.performanceReviewCycle.findMany(); @@ -398,8 +393,8 @@ async function performanceRoutes(fastify) { fastify.post("/performance/cycles", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); - if (!fastify.hasPermission(request.employee, "system.users")) { - throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); + if (!fastify.hasPermission(request.employee, PERFORMANCE_MANAGE_PERMISSION)) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: PERFORMANCE_MANAGE_PERMISSION } }); } const body = parseBody(CYCLE_CREATE_SCHEMA, request.body); @@ -431,8 +426,8 @@ async function performanceRoutes(fastify) { fastify.patch("/performance/cycles/:cycleId", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); - if (!fastify.hasPermission(request.employee, "system.users")) { - throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); + if (!fastify.hasPermission(request.employee, PERFORMANCE_MANAGE_PERMISSION)) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: PERFORMANCE_MANAGE_PERMISSION } }); } const cycleId = parseId(request.params?.cycleId); @@ -508,8 +503,8 @@ async function performanceRoutes(fastify) { fastify.get("/performance/cycles/:cycleId/reviews", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); - if (!fastify.hasPermission(request.employee, "system.users")) { - throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); + if (!fastify.hasPermission(request.employee, PERFORMANCE_MANAGE_PERMISSION)) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: PERFORMANCE_MANAGE_PERMISSION } }); } const cycleId = parseId(request.params?.cycleId); diff --git a/server/src/routes/search.js b/server/src/routes/search.js index c09cbacf..15b7a921 100644 --- a/server/src/routes/search.js +++ b/server/src/routes/search.js @@ -65,6 +65,14 @@ async function isFlagEnabled(fastify, request, flagKey) { return gate.enabled; } +function canSearchSensitiveRegistry(fastify, request, isAdmin) { + return isAdmin || fastify.hasPermission(request.employee, "system.settings"); +} + +function canSearchFrozenTravel(fastify, request, isAdmin) { + return isAdmin || fastify.hasPermission(request.employee, "system.settings"); +} + async function searchRoutes(fastify) { fastify.get("/search", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); @@ -307,7 +315,7 @@ async function searchRoutes(fastify) { ); } - if (featureEnabled.projectLinking && (fastify.hasPermission(request.employee, "project.view_all") || fastify.hasPermission(request.employee, "project.manage") || fastify.hasPermission(request.employee, "system.settings") || isAdmin)) { + if (featureEnabled.projectLinking && canSearchSensitiveRegistry(fastify, request, isAdmin)) { tasks.push( fastify.prisma.contractRegistry.findMany({ where: { @@ -348,7 +356,7 @@ async function searchRoutes(fastify) { ); } - if (featureEnabled.confidentialDocRefs && (fastify.hasPermission(request.employee, "project.view_all") || fastify.hasPermission(request.employee, "project.manage") || fastify.hasPermission(request.employee, "system.settings") || isAdmin)) { + if (featureEnabled.confidentialDocRefs && canSearchSensitiveRegistry(fastify, request, isAdmin)) { tasks.push( fastify.prisma.confidentialDocumentRef.findMany({ where: { @@ -449,7 +457,7 @@ async function searchRoutes(fastify) { ); } - if (fastify.hasPermission(request.employee, "travel.view_own") || fastify.hasPermission(request.employee, "travel.view_all") || fastify.hasPermission(request.employee, "travel.manage")) { + if (canSearchFrozenTravel(fastify, request, isAdmin)) { tasks.push( fastify.prisma.travelRequest.findMany({ where: { diff --git a/server/src/routes/workflow.js b/server/src/routes/workflow.js index 0cbf9352..14a83471 100644 --- a/server/src/routes/workflow.js +++ b/server/src/routes/workflow.js @@ -561,6 +561,29 @@ async function buildTaskVisibilityFilters(tx, request) { return assigneeFilters; } +function canViewWorkflowEntityType(request, entityType) { + const normalizedEntityType = normalizeWorkflowEntityType(entityType); + if (normalizedEntityType === "travel_request") { + return hasPermission(request.employee.permissionCodes ?? [], "*") + || hasPermission(request.employee.permissionCodes ?? [], "system.settings"); + } + return true; +} + +function buildWorkflowEntityVisibilityWhere(request, entityType = null) { + if (entityType) { + return canViewWorkflowEntityType(request, entityType) + ? { entity_type: normalizeWorkflowEntityType(entityType) } + : { entity_type: "__forbidden__" }; + } + + const hiddenEntityTypes = []; + if (!canViewWorkflowEntityType(request, "travel_request")) { + hiddenEntityTypes.push("travel_request"); + } + return hiddenEntityTypes.length ? { entity_type: { notIn: hiddenEntityTypes } } : {}; +} + function listAllowedActionsForTask(request, task, actionable) { if (task.status !== "pending" || !actionable) { return []; @@ -1957,6 +1980,7 @@ async function workflowRoutes(fastify) { const where = { status: "pending", ...(visibilityFilters ? { OR: visibilityFilters } : {}), + workflow_instance: { is: buildWorkflowEntityVisibilityWhere(request) }, }; const tasks = await fastify.prisma.workflowTask.findMany({ @@ -1998,9 +2022,7 @@ async function workflowRoutes(fastify) { const where = { status: query.status, OR: assigneeFilters, - ...(query.entityType - ? { workflow_instance: { is: { entity_type: query.entityType } } } - : {}), + workflow_instance: { is: buildWorkflowEntityVisibilityWhere(request, query.entityType ?? null) }, }; const [tasks, total] = await Promise.all([ @@ -2151,6 +2173,7 @@ async function workflowRoutes(fastify) { where: { id: taskId, OR: assigneeFilters, + workflow_instance: { is: buildWorkflowEntityVisibilityWhere(request) }, }, select: { id: true, @@ -3199,7 +3222,9 @@ module.exports.__testables = { buildReturnedCopy, buildWorkflowTaskSummaryPayload, buildWorkflowEntityMeta, + buildWorkflowEntityVisibilityWhere, calculateWorkflowTaskDueAt, + canViewWorkflowEntityType, handlePurchaseApprovalErpSync, isWorkflowTaskOverdue, listAllowedActionsForTask, diff --git a/server/src/services/employeeStats.js b/server/src/services/employeeStats.js index 7c3b63eb..4cfa78cc 100644 --- a/server/src/services/employeeStats.js +++ b/server/src/services/employeeStats.js @@ -29,6 +29,7 @@ function buildPerformanceDistribution(rows) { async function buildEmployeeStats(prisma, options = {}) { const timeZone = options.timeZone ?? "Asia/Shanghai"; const includeSalarySummary = options.includeSalarySummary ?? true; + const includePerformanceSummary = options.includePerformanceSummary ?? true; const todayString = currentDateString(timeZone); const today = parseDateOnlyString(todayString); const last30DateFrom = addDays(today, -29); @@ -114,14 +115,18 @@ async function buildEmployeeStats(prisma, options = {}) { }, }, }), - prisma.performanceReviewCycle.findFirst({ - where: { status: "active" }, - orderBy: [{ activated_at: "desc" }, { start_date: "desc" }], - }), - prisma.performanceReviewCycle.findFirst({ - where: { status: "completed" }, - orderBy: [{ completed_at: "desc" }, { end_date: "desc" }], - }), + includePerformanceSummary + ? prisma.performanceReviewCycle.findFirst({ + where: { status: "active" }, + orderBy: [{ activated_at: "desc" }, { start_date: "desc" }], + }) + : Promise.resolve(null), + includePerformanceSummary + ? prisma.performanceReviewCycle.findFirst({ + where: { status: "completed" }, + orderBy: [{ completed_at: "desc" }, { end_date: "desc" }], + }) + : Promise.resolve(null), includeSalarySummary ? prisma.payslip.aggregate({ where: { @@ -155,7 +160,7 @@ async function buildEmployeeStats(prisma, options = {}) { distribution: [], }; - if (performanceCycle) { + if (includePerformanceSummary && performanceCycle) { const [reviewAggregate, reviewDistribution] = await Promise.all([ prisma.performanceReview.aggregate({ where: { cycle_id: performanceCycle.id }, diff --git a/server/src/services/workspaceSettings.js b/server/src/services/workspaceSettings.js index 0a9f2d1b..32c99403 100644 --- a/server/src/services/workspaceSettings.js +++ b/server/src/services/workspaceSettings.js @@ -46,7 +46,7 @@ const NAVIGATION_REGISTRY = Object.freeze({ workflowTasks: { key: "workflowTasks", label: "审批待办", icon: "fact_check", href: "/workflow/tasks", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] }, dashboard: { key: "dashboard", label: "仪表盘", icon: "dashboard", href: "/dashboard", anyOf: ["report.dashboard.view"] }, purchaseRequests: { key: "purchaseRequests", label: "采购申请", icon: "shopping_cart", href: "/purchase-requests", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] }, - travelRequests: { key: "travelRequests", label: "差旅申请", icon: "flight", href: "/travel-requests", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage", "integration.manage"] }, + travelRequests: { key: "travelRequests", label: "差旅申请", icon: "flight", href: "/travel-requests", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage"] }, budgets: { key: "budgets", label: "预算管理", icon: "account_balance", href: "/budgets", anyOf: ["budget.view", "budget.manage"] }, paymentBatches: { key: "paymentBatches", label: "支付批次", icon: "payments", href: "/payment-batches", anyOf: ["payment.batch.manage", "risk.review.manage"], featureFlagKey: FEATURE_FLAG_KEYS.paymentBatch }, apOperations: { key: "apOperations", label: "AP 运营", icon: "account_balance_wallet", href: "/ap-operations", anyOf: ["payment.batch.manage", "risk.review.manage"], featureFlagKey: FEATURE_FLAG_KEYS.paymentBatch }, diff --git a/server/tests/unit/constants/permissions.test.js b/server/tests/unit/constants/permissions.test.js index dcdc48fd..800cb3fa 100644 --- a/server/tests/unit/constants/permissions.test.js +++ b/server/tests/unit/constants/permissions.test.js @@ -51,21 +51,23 @@ describe("Permission Constants", () => { expect(hasPermission(resolvePermissionsForRoles(["administration"]), "procurement.manage")).toBe(true); }); - it("grants travel permissions to configured roles", () => { - expect(hasPermission(resolvePermissionsForRoles(["employee"]), "travel.create")).toBe(true); - expect(hasPermission(resolvePermissionsForRoles(["employee"]), "travel.submit")).toBe(true); - expect(hasPermission(resolvePermissionsForRoles(["manager"]), "travel.approve")).toBe(true); + it("freezes travel permissions for non-admin launch roles", () => { + expect(hasPermission(resolvePermissionsForRoles(["employee"]), "travel.create")).toBe(false); + expect(hasPermission(resolvePermissionsForRoles(["employee"]), "travel.submit")).toBe(false); + expect(hasPermission(resolvePermissionsForRoles(["manager"]), "travel.approve")).toBe(false); expect(hasPermission(resolvePermissionsForRoles(["finance"]), "travel.approve")).toBe(false); - expect(hasPermission(resolvePermissionsForRoles(["finance_mgr"]), "travel.manage")).toBe(true); - expect(hasPermission(resolvePermissionsForRoles(["administration"]), "travel.manage")).toBe(true); + expect(hasPermission(resolvePermissionsForRoles(["finance_mgr"]), "travel.manage")).toBe(false); + expect(hasPermission(resolvePermissionsForRoles(["administration"]), "travel.manage")).toBe(false); + expect(hasPermission(resolvePermissionsForRoles(["admin"]), "travel.manage")).toBe(true); }); - it("grants travel.payment.manage only to configured finance and administration roles", () => { - expect(hasPermission(resolvePermissionsForRoles(["finance"]), "travel.payment.manage")).toBe(true); - expect(hasPermission(resolvePermissionsForRoles(["finance_mgr"]), "travel.payment.manage")).toBe(true); - expect(hasPermission(resolvePermissionsForRoles(["administration"]), "travel.payment.manage")).toBe(true); + it("freezes travel.payment.manage before travel launch", () => { + expect(hasPermission(resolvePermissionsForRoles(["finance"]), "travel.payment.manage")).toBe(false); + expect(hasPermission(resolvePermissionsForRoles(["finance_mgr"]), "travel.payment.manage")).toBe(false); + expect(hasPermission(resolvePermissionsForRoles(["administration"]), "travel.payment.manage")).toBe(false); expect(hasPermission(resolvePermissionsForRoles(["manager"]), "travel.payment.manage")).toBe(false); expect(hasPermission(resolvePermissionsForRoles(["employee"]), "travel.payment.manage")).toBe(false); + expect(hasPermission(resolvePermissionsForRoles(["admin"]), "travel.payment.manage")).toBe(true); }); it("grants ai.assist only to finance manager and admin", () => { diff --git a/server/tests/unit/routes/employees.stats.test.js b/server/tests/unit/routes/employees.stats.test.js index 3c569788..9039c244 100644 --- a/server/tests/unit/routes/employees.stats.test.js +++ b/server/tests/unit/routes/employees.stats.test.js @@ -143,7 +143,7 @@ async function buildApp({ permissionCodes = ["system.users"], stats } = {}) { } describe("employees stats route", () => { - it("returns aggregated employee stats for read-only viewers without salary data", async () => { + it("returns aggregated employee stats for read-only viewers without salary or performance data", async () => { const { app, employeeGroupBy, @@ -182,27 +182,10 @@ describe("employees stats route", () => { expiringSoonCount: 4, }); expect(payload.data.performanceSummary).toEqual({ - cycle: { - id: "cycle-active", - name: "2026 Q2", - cycleType: "quarterly", - status: "active", - startDate: "2026-04-01", - endDate: "2026-06-30", - }, - averageScore: 4.2, - reviewedCount: 5, - distribution: [ - { score: 1, count: 0 }, - { score: 1.5, count: 0 }, - { score: 2, count: 0 }, - { score: 2.5, count: 0 }, - { score: 3, count: 0 }, - { score: 3.5, count: 0 }, - { score: 4, count: 3 }, - { score: 4.5, count: 2 }, - { score: 5, count: 0 }, - ], + cycle: null, + averageScore: null, + reviewedCount: 0, + distribution: [], }); expect(payload.data.salarySummary).toBeNull(); expect(employeeGroupBy).toHaveBeenCalledTimes(2); @@ -211,17 +194,17 @@ describe("employees stats route", () => { expect(contractCount).toHaveBeenCalledTimes(1); expect(certificateGroupBy).toHaveBeenCalledTimes(1); expect(certificateCount).toHaveBeenCalledTimes(1); - expect(performanceCycleFindFirst).toHaveBeenCalledTimes(2); - expect(performanceReviewAggregate).toHaveBeenCalledTimes(1); - expect(performanceReviewGroupBy).toHaveBeenCalledTimes(1); + expect(performanceCycleFindFirst).not.toHaveBeenCalled(); + expect(performanceReviewAggregate).not.toHaveBeenCalled(); + expect(performanceReviewGroupBy).not.toHaveBeenCalled(); expect(payslipAggregate).not.toHaveBeenCalled(); await app.close(); }); - it("returns salary summary for system.users viewers", async () => { + it("returns salary and performance summary for system settings viewers", async () => { const { app, payslipAggregate } = await buildApp({ - permissionCodes: ["system.users"], + permissionCodes: ["system.users", "system.settings"], }); const response = await app.inject({ @@ -244,6 +227,10 @@ describe("employees stats route", () => { issuedCount: 2, }, }); + expect(response.json().data.performanceSummary.cycle).toMatchObject({ + id: "cycle-active", + name: "2026 Q2", + }); expect(payslipAggregate).toHaveBeenCalledTimes(2); await app.close(); diff --git a/server/tests/unit/routes/payroll.exports.test.js b/server/tests/unit/routes/payroll.exports.test.js index 28bb4487..3052a454 100644 --- a/server/tests/unit/routes/payroll.exports.test.js +++ b/server/tests/unit/routes/payroll.exports.test.js @@ -24,7 +24,7 @@ function buildPayslipRow() { } async function buildApp({ - actor = { id: ADMIN_ID, permissionCodes: ["system.users"] }, + actor = { id: ADMIN_ID, permissionCodes: ["system.settings"] }, } = {}) { const app = Fastify(); const salaryItems = [ diff --git a/server/tests/unit/routes/payroll.items.test.js b/server/tests/unit/routes/payroll.items.test.js index d32d84df..c604b1ea 100644 --- a/server/tests/unit/routes/payroll.items.test.js +++ b/server/tests/unit/routes/payroll.items.test.js @@ -21,7 +21,7 @@ function buildSalaryItem(overrides = {}) { } async function buildApp({ - actor = { id: ADMIN_ID, permissionCodes: ["system.users"] }, + actor = { id: ADMIN_ID, permissionCodes: ["system.settings"] }, } = {}) { const app = Fastify(); const state = { diff --git a/server/tests/unit/routes/payroll.payslips.test.js b/server/tests/unit/routes/payroll.payslips.test.js index d0e07416..401087d3 100644 --- a/server/tests/unit/routes/payroll.payslips.test.js +++ b/server/tests/unit/routes/payroll.payslips.test.js @@ -48,7 +48,7 @@ const PAYSLIP_IDS = [ ]; async function buildApp({ - actor = { id: ADMIN_ID, permissionCodes: ["system.users"] }, + actor = { id: ADMIN_ID, permissionCodes: ["system.settings"] }, omitProfileFor = [], } = {}) { const app = Fastify(); diff --git a/server/tests/unit/routes/payroll.salary-profile.test.js b/server/tests/unit/routes/payroll.salary-profile.test.js index f78c3e71..64f910fc 100644 --- a/server/tests/unit/routes/payroll.salary-profile.test.js +++ b/server/tests/unit/routes/payroll.salary-profile.test.js @@ -50,7 +50,7 @@ function buildSalaryRow(overrides = {}) { } async function buildApp({ - actor = { id: ADMIN_ID, permissionCodes: ["system.users"] }, + actor = { id: ADMIN_ID, permissionCodes: ["system.settings"] }, } = {}) { const app = Fastify(); const state = { diff --git a/server/tests/unit/routes/performance.cycles.test.js b/server/tests/unit/routes/performance.cycles.test.js index 7a102492..b4ac43f1 100644 --- a/server/tests/unit/routes/performance.cycles.test.js +++ b/server/tests/unit/routes/performance.cycles.test.js @@ -41,7 +41,7 @@ function buildCycleRow(overrides = {}) { }; } -async function buildApp({ actor = { id: ADMIN_ID, department_id: DEPARTMENT_ID, permissionCodes: ["system.users"] } } = {}) { +async function buildApp({ actor = { id: ADMIN_ID, department_id: DEPARTMENT_ID, permissionCodes: ["system.settings"] } } = {}) { const app = Fastify(); const state = { cycles: [], diff --git a/server/tests/unit/routes/performance.reviews.test.js b/server/tests/unit/routes/performance.reviews.test.js index 521f0b46..51f896ce 100644 --- a/server/tests/unit/routes/performance.reviews.test.js +++ b/server/tests/unit/routes/performance.reviews.test.js @@ -276,7 +276,7 @@ async function buildApp({ } describe("performance review routes", () => { - it("allows direct managers to score direct reports in active cycles", async () => { + it("rejects direct managers while performance management is frozen before launch", async () => { const { app, state, performanceReviewDimension } = await buildApp(); const response = await app.inject({ @@ -292,23 +292,14 @@ describe("performance review routes", () => { }, }); - expect(response.statusCode).toBe(200); - expect(response.json().data).toMatchObject({ - employeeId: EMPLOYEE_ID, - overallScore: 4.5, - comment: "表现稳定", - dimensions: [ - { dimensionType: "work_quality", score: 4.5 }, - { dimensionType: "teamwork", score: 4 }, - ], - }); - expect(state.reviews.find((item) => item.cycle_id === ACTIVE_CYCLE_ID && item.employee_id === EMPLOYEE_ID)).toBeTruthy(); - expect(performanceReviewDimension.createMany).toHaveBeenCalledTimes(1); + expect(response.statusCode).toBe(403); + expect(state.reviews.find((item) => item.cycle_id === ACTIVE_CYCLE_ID && item.employee_id === EMPLOYEE_ID)).toBeFalsy(); + expect(performanceReviewDimension.createMany).not.toHaveBeenCalled(); await app.close(); }); - it("returns completed results only for self viewers", async () => { + it("rejects self viewers while performance management is frozen before launch", async () => { const { app } = await buildApp({ actor: { id: EMPLOYEE_ID, @@ -322,11 +313,7 @@ describe("performance review routes", () => { url: `/api/performance/employees/${EMPLOYEE_ID}/reviews`, }); - expect(response.statusCode).toBe(200); - expect(response.json().data.viewerRole).toBe("self"); - expect(response.json().data.items).toHaveLength(1); - expect(response.json().data.items[0].cycle.status).toBe("completed"); - expect(response.json().data.items[0].review.overallScore).toBe(4); + expect(response.statusCode).toBe(403); await app.close(); }); @@ -359,7 +346,7 @@ describe("performance review routes", () => { actor: { id: ADMIN_ID, department_id: DEPARTMENT_ID, - permissionCodes: ["system.users"], + permissionCodes: ["system.settings"], }, }); diff --git a/server/tests/unit/routes/search.test.js b/server/tests/unit/routes/search.test.js index 71015044..3a34866f 100644 --- a/server/tests/unit/routes/search.test.js +++ b/server/tests/unit/routes/search.test.js @@ -109,6 +109,27 @@ async function buildApp({ employee, prisma }) { } describe("global search R23 confidential boundary", () => { + it("does not expose contract registry or confidential document refs to project managers through search", async () => { + const prisma = createPrisma(); + const app = await buildApp({ + employee: { id: "manager-1", department_id: null, permissionCodes: ["project.view_all", "project.manage"] }, + prisma, + }); + + const response = await app.inject({ + method: "GET", + url: "/api/search?q=HT-001", + headers: { authorization: "Bearer test-token" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.items).toEqual([]); + expect(prisma.contractRegistry.findMany).not.toHaveBeenCalled(); + expect(prisma.confidentialDocumentRef.findMany).not.toHaveBeenCalled(); + + await app.close(); + }); + it("does not match sensitive summary text but still matches minimal identifiers", async () => { const prisma = createPrisma(); const app = await buildApp({ diff --git a/server/tests/unit/routes/workflow.permissions.test.js b/server/tests/unit/routes/workflow.permissions.test.js index a7826b6a..4daad442 100644 --- a/server/tests/unit/routes/workflow.permissions.test.js +++ b/server/tests/unit/routes/workflow.permissions.test.js @@ -1,7 +1,12 @@ const workflowRoutes = require("../../../src/routes/workflow"); describe("Workflow Route Permissions", () => { - const { listAllowedActionsForTask, parseTaskListQuery } = workflowRoutes.__testables; + const { + buildWorkflowEntityVisibilityWhere, + canViewWorkflowEntityType, + listAllowedActionsForTask, + parseTaskListQuery, + } = workflowRoutes.__testables; it("allows leave approvers to act on leave tasks", () => { const actions = listAllowedActionsForTask( @@ -133,4 +138,26 @@ describe("Workflow Route Permissions", () => { it("rejects unsupported workflow entityType filters", () => { expect(() => parseTaskListQuery({ entityType: "invoice_pool" })).toThrow("参数校验失败"); }); + + it("hides travel workflow entities when travel is frozen for the actor", () => { + const request = { employee: { permissionCodes: [] } }; + + expect(canViewWorkflowEntityType(request, "travel_request")).toBe(false); + expect(buildWorkflowEntityVisibilityWhere(request)).toEqual({ + entity_type: { notIn: ["travel_request"] }, + }); + expect(buildWorkflowEntityVisibilityWhere(request, "travel_request")).toEqual({ + entity_type: "__forbidden__", + }); + }); + + it("keeps travel workflow entities visible only for system settings holders", () => { + const request = { employee: { permissionCodes: ["system.settings"] } }; + + expect(canViewWorkflowEntityType(request, "travel_request")).toBe(true); + expect(buildWorkflowEntityVisibilityWhere(request)).toEqual({}); + expect(buildWorkflowEntityVisibilityWhere(request, "travel_request")).toEqual({ + entity_type: "travel_request", + }); + }); }); diff --git a/server/tests/unit/routes/workflow.transfer.test.js b/server/tests/unit/routes/workflow.transfer.test.js index 71447e5c..155af503 100644 --- a/server/tests/unit/routes/workflow.transfer.test.js +++ b/server/tests/unit/routes/workflow.transfer.test.js @@ -159,7 +159,7 @@ async function buildApp({ prisma, employee }) { } describe("workflow transfer route", () => { - it.each(["leave", "purchase_request", "travel_request"])( + it.each(["leave", "purchase_request"])( "allows transfer for %s when target has matching approve permission", async (entityType) => { const tx = buildTx({ entityType }); @@ -217,6 +217,33 @@ describe("workflow transfer route", () => { }, ); + it("rejects transfer for frozen travel_request tasks", async () => { + const tx = buildTx({ entityType: "travel_request" }); + const app = await buildApp({ + prisma: buildPrisma(tx), + employee: { + id: "actor-1", + roleCodes: ["manager"], + permissionCodes: ["expense.transfer"], + }, + }); + + const response = await app.inject({ + method: "POST", + url: `/api/workflow/tasks/${buildTaskRow().id}/transfer`, + headers: { authorization: "Bearer test" }, + payload: { + toApproverId: "44444444-4444-4444-8444-444444444444", + reason: "当前事项需转交对应审批人继续处理,请及时跟进。", + }, + }); + + expect(response.statusCode).toBe(403); + expect(tx.workflowTask.update).not.toHaveBeenCalled(); + + await app.close(); + }); + it("rejects target approver without matching entity approve permission", async () => { const tx = buildTx({ entityType: "leave", targetRoleCode: "finance" }); const app = await buildApp({