OAPERM1 收紧启用前权限冻结

这个提交包含在:
2026-05-24 00:57:11 +08:00
父节点 a3f13eab57
当前提交 65513a5291
共修改 27 个文件,包含 231 行新增和 149 行删除
-19
查看文件
@@ -28,12 +28,6 @@ const PROCUREMENT_SELF_SERVICE_PERMISSIONS = [
"procurement.submit", "procurement.submit",
] as const; ] 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 ASSET_SELF_SERVICE_PERMISSIONS = ["asset.view_own"] as const;
const PROJECT_SELF_SERVICE_PERMISSIONS = ["project.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, ...EXPENSE_SELF_SERVICE_PERMISSIONS,
...LEAVE_SELF_SERVICE_PERMISSIONS, ...LEAVE_SELF_SERVICE_PERMISSIONS,
...ATTENDANCE_SELF_SERVICE_PERMISSIONS, ...ATTENDANCE_SELF_SERVICE_PERMISSIONS,
...TRAVEL_SELF_SERVICE_PERMISSIONS,
...PROJECT_SELF_SERVICE_PERMISSIONS, ...PROJECT_SELF_SERVICE_PERMISSIONS,
] as const; ] as const;
@@ -66,8 +59,6 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"attendance.view_all", "attendance.view_all",
"procurement.approve", "procurement.approve",
"procurement.view_all", "procurement.view_all",
"travel.approve",
"travel.view_all",
"asset.view_all", "asset.view_all",
"report.dashboard.view", "report.dashboard.view",
"project.view_all", "project.view_all",
@@ -88,8 +79,6 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"attendance.view_all", "attendance.view_all",
"procurement.approve", "procurement.approve",
"procurement.view_all", "procurement.view_all",
"travel.approve",
"travel.view_all",
"asset.view_all", "asset.view_all",
"report.dashboard.view", "report.dashboard.view",
"budget.view", "budget.view",
@@ -106,8 +95,6 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"payment.batch.manage", "payment.batch.manage",
"expense.view_all", "expense.view_all",
"procurement.view_all", "procurement.view_all",
"travel.view_all",
"travel.payment.manage",
"asset.view_all", "asset.view_all",
"attendance.view_all", "attendance.view_all",
], ],
@@ -129,9 +116,6 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"expense.finance.override_duplicate", "expense.finance.override_duplicate",
"procurement.view_all", "procurement.view_all",
"procurement.manage", "procurement.manage",
"travel.view_all",
"travel.manage",
"travel.payment.manage",
"asset.view_all", "asset.view_all",
"asset.manage", "asset.manage",
"asset.inventory", "asset.inventory",
@@ -149,9 +133,6 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
...EMPLOYEE_BASE_PERMISSIONS, ...EMPLOYEE_BASE_PERMISSIONS,
"procurement.view_all", "procurement.view_all",
"procurement.manage", "procurement.manage",
"travel.view_all",
"travel.manage",
"travel.payment.manage",
"asset.view_all", "asset.view_all",
"asset.manage", "asset.manage",
"asset.transfer", "asset.transfer",
@@ -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: "/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: "/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: "/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: "/budgets", label: "预算管理", icon: "account_balance", anyOf: ["budget.view", "budget.manage"] },
{ to: "/payment-batches", label: "支付批次", icon: "payments", anyOf: ["payment.batch.manage", "risk.review.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"] }, { to: "/ap-operations", label: "AP 运营", icon: "account_balance_wallet", anyOf: ["payment.batch.manage", "risk.review.manage"] },
+14 -5
查看文件
@@ -183,6 +183,7 @@ export function EmployeeProfilePage() {
queryFn: () => fetchEmployeeProfile(employeeId), queryFn: () => fetchEmployeeProfile(employeeId),
}); });
const canManageEmployeeProfile = hasPermission(permissions, "system.users"); const canManageEmployeeProfile = hasPermission(permissions, "system.users");
const canManageSensitiveHr = hasPermission(permissions, "system.settings");
const canEditSelfProfile = me?.id === employeeId; const canEditSelfProfile = me?.id === employeeId;
const canEditBaseFields = canManageEmployeeProfile || canEditSelfProfile; const canEditBaseFields = canManageEmployeeProfile || canEditSelfProfile;
const canEditExtendedFields = canManageEmployeeProfile; const canEditExtendedFields = canManageEmployeeProfile;
@@ -266,11 +267,19 @@ export function EmployeeProfilePage() {
}, [employeeId, requestedTab]); }, [employeeId, requestedTab]);
useEffect(() => { 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; return;
} }
updateActiveTab("profile"); updateActiveTab("profile");
}, [activeTab, canEditSelfProfile, canManageEmployeeProfile]); }, [activeTab, canEditSelfProfile, canManageEmployeeProfile, canManageSensitiveHr]);
useEffect(() => { useEffect(() => {
setAvatarFile(null); setAvatarFile(null);
@@ -528,8 +537,8 @@ export function EmployeeProfilePage() {
profileQuery.isError && isApiError(profileQuery.error) && profileQuery.error.status === 403; profileQuery.isError && isApiError(profileQuery.error) && profileQuery.error.status === 403;
const performanceOnlyMode = activeTab === "performance" && profilePermissionDenied && Boolean(performanceQuery.data); const performanceOnlyMode = activeTab === "performance" && profilePermissionDenied && Boolean(performanceQuery.data);
const showPerformanceTab = const showPerformanceTab =
canManageEmployeeProfile || canEditSelfProfile || performanceOnlyMode || performanceQuery.data?.viewerRole === "manager"; canManageSensitiveHr || canEditSelfProfile || performanceOnlyMode || performanceQuery.data?.viewerRole === "manager";
const showSalaryTab = canManageEmployeeProfile || canEditSelfProfile; const showSalaryTab = canManageSensitiveHr || canEditSelfProfile;
const performanceContent = const performanceContent =
performanceQuery.isLoading ? ( performanceQuery.isLoading ? (
@@ -546,7 +555,7 @@ export function EmployeeProfilePage() {
]} ]}
/> />
); );
const salaryContent = <EmployeeSalaryPanel employeeId={employeeId} canManage={canManageEmployeeProfile} isSelf={canEditSelfProfile} />; const salaryContent = <EmployeeSalaryPanel employeeId={employeeId} canManage={canManageSensitiveHr} isSelf={canEditSelfProfile} />;
if (profileQuery.isLoading || (activeTab === "performance" && profilePermissionDenied && performanceQuery.isLoading)) { if (profileQuery.isLoading || (activeTab === "performance" && profilePermissionDenied && performanceQuery.isLoading)) {
return <div className="card-std p-6 text-sm text-zinc-500">加载员工档案中...</div>; return <div className="card-std p-6 text-sm text-zinc-500">加载员工档案中...</div>;
+1 -1
查看文件
@@ -117,7 +117,7 @@ export function PeopleStatsPage() {
<section className="card-std p-5"> <section className="card-std p-5">
<div className="text-sm text-zinc-500">上月人均薪资</div> <div className="text-sm text-zinc-500">上月人均薪资</div>
<div className="mt-2 text-3xl font-semibold text-zinc-900">{moneyText(salarySummary.previousMonth?.averageNetAmount)}</div> <div className="mt-2 text-3xl font-semibold text-zinc-900">{moneyText(salarySummary.previousMonth?.averageNetAmount)}</div>
<div className="mt-2 text-xs text-zinc-500">非 system.users 不展示任何薪资汇总</div> <div className="mt-2 text-xs text-zinc-500">非 system.settings 不展示任何薪资汇总</div>
</section> </section>
) : null} ) : null}
<section className="card-std p-5"> <section className="card-std p-5">
+2 -2
查看文件
@@ -471,7 +471,7 @@ export function AppRouter() {
<Route <Route
path="payroll" path="payroll"
element={ element={
<RequirePermission anyOf={["system.users"]}> <RequirePermission anyOf={["system.settings"]}>
{withRouteFallback(<PayrollPage />)} {withRouteFallback(<PayrollPage />)}
</RequirePermission> </RequirePermission>
} }
@@ -480,7 +480,7 @@ export function AppRouter() {
<Route <Route
path="performance" path="performance"
element={ element={
<RequirePermission anyOf={["system.users"]}> <RequirePermission anyOf={["system.settings"]}>
{withRouteFallback(<PerformancePage />)} {withRouteFallback(<PerformancePage />)}
</RequirePermission> </RequirePermission>
} }
@@ -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=<new summary.json> 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 只能在人工批准后做影子库恢复。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 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 或数据迁移。
3 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 重新定义角色和验收。
4 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 原访问;薪酬/绩效后续正式启用需独立合同和更细权限。
5 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 中合同/资料搜索权限条件即可恢复原搜索口径。
6 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。回滚/清理:若后续公网发现误伤,回退本次代码或缩小修改面后重跑。
7 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 权限代码后重新部署;报告仅保留脱敏状态。
8 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 并回修。
9 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=<new summary.json> 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 人工决定。
10 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 只能在人工批准后做影子库恢复。
@@ -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_<timestamp>。回滚/清理:DROP 临时库/临时 schema、删除临时连接文件、撤销临时凭据;禁止 DROP/restore 生产库。"
"OABK1-3","恢复后只读抽样验证","在影子库中只读验证关键表和附件引用:员工/角色、报销/发票、付款批次/AP、采购、合同台账、资产台账、审计日志、文件引用一致性。","输出脱敏 summary:表存在、行数区间、最近更新时间、关键外键抽样、附件引用可定位;不输出真实票号、合同正文、银行流水、完整账号、完整路径或原始 payload。","受影响流程:影子库恢复 -> 只读 SQL/脚本抽样 -> 脱敏证据;验证步骤:只读查询或 QA-only 脚本输出 artifacts/qa-flow/oa-backup-restore-drill-<timestamp>/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=<summary.json> 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;本合同只重申最小真实样本边界。回滚/清理:真实业务数据按审计保留;误录按业务状态归档/冲正,不从仓库清理。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 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,不影响公网或数据库。
3 OABK1-1 备份源与恢复目标确认 确认公网 OA 当前备份来源、最近备份时间、备份文件或快照位置、附件存储备份位置、恢复目标机器/目录/影子库名和临时凭据。 形成脱敏恢复参数清单;不在仓库、聊天或报告写入密码、Secret、完整连接串、完整本地路径或真实敏感业务内容。 受影响流程:运维备份 -> 参数核对 -> 恢复窗口;验证步骤:人工填写 OAAUTO1 证据模板或临时 /tmp env;只记录脱敏别名、时间、大小、hash/校验状态。 manual|ssh TODO TODO TODO external|/tmp|artifacts/qa-flow ["OABK1-0"] 人工确认点:A 使用最近自动备份;B 指定某个备份点;C 暂停演练。回滚/清理:本步骤只读核对,清理临时 env 和脱敏清单即可。
4 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_<timestamp>。回滚/清理:DROP 临时库/临时 schema、删除临时连接文件、撤销临时凭据;禁止 DROP/restore 生产库。
5 OABK1-3 恢复后只读抽样验证 在影子库中只读验证关键表和附件引用:员工/角色、报销/发票、付款批次/AP、采购、合同台账、资产台账、审计日志、文件引用一致性。 输出脱敏 summary:表存在、行数区间、最近更新时间、关键外键抽样、附件引用可定位;不输出真实票号、合同正文、银行流水、完整账号、完整路径或原始 payload。 受影响流程:影子库恢复 -> 只读 SQL/脚本抽样 -> 脱敏证据;验证步骤:只读查询或 QA-only 脚本输出 artifacts/qa-flow/oa-backup-restore-drill-<timestamp>/summary.json。 node|psql|manual TODO TODO TODO scripts/qa;artifacts/qa-flow/oa-backup-restore-drill-* ["OABK1-2"] 允许新增 QA-only 脚本,但不得写业务库。回滚/清理:删除影子库连接文件和报告中的敏感原文;保留脱敏 summary 作为证据。
6 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 为通过。
7 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。
8 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=<summary.json> 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;不伪造通过。
9 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;本合同只重申最小真实样本边界。回滚/清理:真实业务数据按审计保留;误录按业务状态归档/冲正,不从仓库清理。
-13
查看文件
@@ -44,7 +44,6 @@ const EMPLOYEE_BASE_PERMISSIONS = [
...EXPENSE_SELF_SERVICE_PERMISSIONS, ...EXPENSE_SELF_SERVICE_PERMISSIONS,
...LEAVE_SELF_SERVICE_PERMISSIONS, ...LEAVE_SELF_SERVICE_PERMISSIONS,
...ATTENDANCE_SELF_SERVICE_PERMISSIONS, ...ATTENDANCE_SELF_SERVICE_PERMISSIONS,
...TRAVEL_SELF_SERVICE_PERMISSIONS,
...PROJECT_SELF_SERVICE_PERMISSIONS, ...PROJECT_SELF_SERVICE_PERMISSIONS,
]; ];
@@ -154,8 +153,6 @@ const ROLE_PERMISSIONS = {
"attendance.view_all", "attendance.view_all",
"procurement.approve", "procurement.approve",
"procurement.view_all", "procurement.view_all",
"travel.approve",
"travel.view_all",
"asset.view_all", "asset.view_all",
"report.dashboard.view", "report.dashboard.view",
"project.view_all", "project.view_all",
@@ -176,8 +173,6 @@ const ROLE_PERMISSIONS = {
"attendance.view_all", "attendance.view_all",
"procurement.approve", "procurement.approve",
"procurement.view_all", "procurement.view_all",
"travel.approve",
"travel.view_all",
"asset.view_all", "asset.view_all",
"report.dashboard.view", "report.dashboard.view",
"budget.view", "budget.view",
@@ -194,8 +189,6 @@ const ROLE_PERMISSIONS = {
"payment.batch.manage", "payment.batch.manage",
"expense.view_all", "expense.view_all",
"procurement.view_all", "procurement.view_all",
"travel.view_all",
"travel.payment.manage",
"asset.view_all", "asset.view_all",
"attendance.view_all", "attendance.view_all",
], ],
@@ -217,9 +210,6 @@ const ROLE_PERMISSIONS = {
"expense.finance.override_duplicate", "expense.finance.override_duplicate",
"procurement.view_all", "procurement.view_all",
"procurement.manage", "procurement.manage",
"travel.view_all",
"travel.manage",
"travel.payment.manage",
"asset.view_all", "asset.view_all",
"asset.manage", "asset.manage",
"asset.inventory", "asset.inventory",
@@ -237,9 +227,6 @@ const ROLE_PERMISSIONS = {
...EMPLOYEE_BASE_PERMISSIONS, ...EMPLOYEE_BASE_PERMISSIONS,
"procurement.view_all", "procurement.view_all",
"procurement.manage", "procurement.manage",
"travel.view_all",
"travel.manage",
"travel.payment.manage",
"asset.view_all", "asset.view_all",
"asset.manage", "asset.manage",
"asset.transfer", "asset.transfer",
+2 -1
查看文件
@@ -844,7 +844,8 @@ async function employeesRoutes(fastify) {
const stats = await buildEmployeeStats(fastify.prisma, { const stats = await buildEmployeeStats(fastify.prisma, {
timeZone: fastify.config.appTimezone, 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); return sendData(reply, request, stats);
}); });
+1 -1
查看文件
@@ -1762,7 +1762,7 @@ async function exportsRoutes(fastify) {
fastify.get( fastify.get(
"/exports/payslips", "/exports/payslips",
{ {
preHandler: fastify.requirePermission("system.users"), preHandler: fastify.requirePermission("system.settings"),
}, },
async (request, reply) => { async (request, reply) => {
const query = parseQuery(PAYSLIP_EXPORT_QUERY_SCHEMA, request.query); const query = parseQuery(PAYSLIP_EXPORT_QUERY_SCHEMA, request.query);
+12 -11
查看文件
@@ -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 PAY_MONTH_SCHEMA = z.string().regex(/^\d{4}-\d{2}$/);
const MONEY_INPUT_SCHEMA = z.union([z.number(), z.string()]); const MONEY_INPUT_SCHEMA = z.union([z.number(), z.string()]);
const SALARY_ITEM_TYPE_SCHEMA = z.enum(["earning", "deduction"]); const SALARY_ITEM_TYPE_SCHEMA = z.enum(["earning", "deduction"]);
const PAYROLL_MANAGE_PERMISSION = "system.settings";
const SALARY_ITEM_CREATE_SCHEMA = z const SALARY_ITEM_CREATE_SCHEMA = z
.object({ .object({
@@ -232,14 +233,14 @@ async function ensurePayslipExists(prisma, payslipId) {
} }
function canViewEmployeePayslips(fastify, request, employeeId) { 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) { async function payrollRoutes(fastify) {
fastify.get( fastify.get(
"/payroll/items", "/payroll/items",
{ {
preHandler: fastify.requirePermission("system.users"), preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION),
}, },
async (request, reply) => { async (request, reply) => {
const items = await fastify.prisma.salaryItem.findMany({ const items = await fastify.prisma.salaryItem.findMany({
@@ -255,7 +256,7 @@ async function payrollRoutes(fastify) {
fastify.post( fastify.post(
"/payroll/items", "/payroll/items",
{ {
preHandler: fastify.requirePermission("system.users"), preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION),
}, },
async (request, reply) => { async (request, reply) => {
const body = parseBody(SALARY_ITEM_CREATE_SCHEMA, request.body); const body = parseBody(SALARY_ITEM_CREATE_SCHEMA, request.body);
@@ -300,7 +301,7 @@ async function payrollRoutes(fastify) {
fastify.patch( fastify.patch(
"/payroll/items/:itemId", "/payroll/items/:itemId",
{ {
preHandler: fastify.requirePermission("system.users"), preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION),
}, },
async (request, reply) => { async (request, reply) => {
const itemId = parseId(request.params?.itemId); const itemId = parseId(request.params?.itemId);
@@ -347,7 +348,7 @@ async function payrollRoutes(fastify) {
fastify.get( fastify.get(
"/payroll/employees/:employeeId/profile", "/payroll/employees/:employeeId/profile",
{ {
preHandler: fastify.requirePermission("system.users"), preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION),
}, },
async (request, reply) => { async (request, reply) => {
const employeeId = parseId(request.params?.employeeId); const employeeId = parseId(request.params?.employeeId);
@@ -375,7 +376,7 @@ async function payrollRoutes(fastify) {
fastify.put( fastify.put(
"/payroll/employees/:employeeId/profile", "/payroll/employees/:employeeId/profile",
{ {
preHandler: fastify.requirePermission("system.users"), preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION),
}, },
async (request, reply) => { async (request, reply) => {
const employeeId = parseId(request.params?.employeeId); const employeeId = parseId(request.params?.employeeId);
@@ -461,7 +462,7 @@ async function payrollRoutes(fastify) {
} }
await ensureEmployeeExists(fastify.prisma, employeeId); 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({ const payslips = await fastify.prisma.payslip.findMany({
where: { where: {
employee_id: employeeId, employee_id: employeeId,
@@ -496,7 +497,7 @@ async function payrollRoutes(fastify) {
fastify.get( fastify.get(
"/payroll/payslips", "/payroll/payslips",
{ {
preHandler: fastify.requirePermission("system.users"), preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION),
}, },
async (request, reply) => { async (request, reply) => {
const query = parseQuery(PAYSLIP_QUERY_SCHEMA, request.query); const query = parseQuery(PAYSLIP_QUERY_SCHEMA, request.query);
@@ -519,7 +520,7 @@ async function payrollRoutes(fastify) {
fastify.post( fastify.post(
"/payroll/payslips/generate", "/payroll/payslips/generate",
{ {
preHandler: fastify.requirePermission("system.users"), preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION),
}, },
async (request, reply) => { async (request, reply) => {
const body = parseBody(PAYSLIP_GENERATE_SCHEMA, request.body); const body = parseBody(PAYSLIP_GENERATE_SCHEMA, request.body);
@@ -679,7 +680,7 @@ async function payrollRoutes(fastify) {
fastify.patch( fastify.patch(
"/payroll/payslips/:payslipId", "/payroll/payslips/:payslipId",
{ {
preHandler: fastify.requirePermission("system.users"), preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION),
}, },
async (request, reply) => { async (request, reply) => {
const payslipId = parseId(request.params?.payslipId); const payslipId = parseId(request.params?.payslipId);
@@ -753,7 +754,7 @@ async function payrollRoutes(fastify) {
fastify.post( fastify.post(
"/payroll/payslips/issue", "/payroll/payslips/issue",
{ {
preHandler: fastify.requirePermission("system.users"), preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION),
}, },
async (request, reply) => { async (request, reply) => {
const body = parseBody(PAYSLIP_ISSUE_SCHEMA, request.body); const body = parseBody(PAYSLIP_ISSUE_SCHEMA, request.body);
+10 -15
查看文件
@@ -25,6 +25,7 @@ const SCORE_SCHEMA = z
.refine((value) => Number.isFinite(value) && Math.abs(value * 2 - Math.round(value * 2)) < 1e-9, { .refine((value) => Number.isFinite(value) && Math.abs(value * 2 - Math.round(value * 2)) < 1e-9, {
message: "invalid_score_step", message: "invalid_score_step",
}); });
const PERFORMANCE_MANAGE_PERMISSION = "system.settings";
const CYCLE_CREATE_SCHEMA = z const CYCLE_CREATE_SCHEMA = z
.object({ .object({
@@ -212,15 +213,9 @@ function mapReview(review) {
} }
function resolveViewerRole(fastify, request, employee) { function resolveViewerRole(fastify, request, employee) {
if (fastify.hasPermission(request.employee, "system.users")) { if (fastify.hasPermission(request.employee, PERFORMANCE_MANAGE_PERMISSION)) {
return "admin"; 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; return null;
} }
@@ -386,8 +381,8 @@ async function buildEmployeeTimeline(prisma, employeeId, viewerRole) {
async function performanceRoutes(fastify) { async function performanceRoutes(fastify) {
fastify.get("/performance/cycles", { preHandler: fastify.authenticate }, async (request, reply) => { fastify.get("/performance/cycles", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request); await ensureFeatureEnabled(fastify, request);
if (!fastify.hasPermission(request.employee, "system.users")) { if (!fastify.hasPermission(request.employee, PERFORMANCE_MANAGE_PERMISSION)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: PERFORMANCE_MANAGE_PERMISSION } });
} }
const cycles = await fastify.prisma.performanceReviewCycle.findMany(); 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) => { fastify.post("/performance/cycles", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request); await ensureFeatureEnabled(fastify, request);
if (!fastify.hasPermission(request.employee, "system.users")) { if (!fastify.hasPermission(request.employee, PERFORMANCE_MANAGE_PERMISSION)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: PERFORMANCE_MANAGE_PERMISSION } });
} }
const body = parseBody(CYCLE_CREATE_SCHEMA, request.body); 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) => { fastify.patch("/performance/cycles/:cycleId", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request); await ensureFeatureEnabled(fastify, request);
if (!fastify.hasPermission(request.employee, "system.users")) { if (!fastify.hasPermission(request.employee, PERFORMANCE_MANAGE_PERMISSION)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: PERFORMANCE_MANAGE_PERMISSION } });
} }
const cycleId = parseId(request.params?.cycleId); 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) => { fastify.get("/performance/cycles/:cycleId/reviews", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request); await ensureFeatureEnabled(fastify, request);
if (!fastify.hasPermission(request.employee, "system.users")) { if (!fastify.hasPermission(request.employee, PERFORMANCE_MANAGE_PERMISSION)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: PERFORMANCE_MANAGE_PERMISSION } });
} }
const cycleId = parseId(request.params?.cycleId); const cycleId = parseId(request.params?.cycleId);
+11 -3
查看文件
@@ -65,6 +65,14 @@ async function isFlagEnabled(fastify, request, flagKey) {
return gate.enabled; 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) { async function searchRoutes(fastify) {
fastify.get("/search", { preHandler: fastify.authenticate }, async (request, reply) => { fastify.get("/search", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request); 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( tasks.push(
fastify.prisma.contractRegistry.findMany({ fastify.prisma.contractRegistry.findMany({
where: { 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( tasks.push(
fastify.prisma.confidentialDocumentRef.findMany({ fastify.prisma.confidentialDocumentRef.findMany({
where: { 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( tasks.push(
fastify.prisma.travelRequest.findMany({ fastify.prisma.travelRequest.findMany({
where: { where: {
+28 -3
查看文件
@@ -561,6 +561,29 @@ async function buildTaskVisibilityFilters(tx, request) {
return assigneeFilters; 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) { function listAllowedActionsForTask(request, task, actionable) {
if (task.status !== "pending" || !actionable) { if (task.status !== "pending" || !actionable) {
return []; return [];
@@ -1957,6 +1980,7 @@ async function workflowRoutes(fastify) {
const where = { const where = {
status: "pending", status: "pending",
...(visibilityFilters ? { OR: visibilityFilters } : {}), ...(visibilityFilters ? { OR: visibilityFilters } : {}),
workflow_instance: { is: buildWorkflowEntityVisibilityWhere(request) },
}; };
const tasks = await fastify.prisma.workflowTask.findMany({ const tasks = await fastify.prisma.workflowTask.findMany({
@@ -1998,9 +2022,7 @@ async function workflowRoutes(fastify) {
const where = { const where = {
status: query.status, status: query.status,
OR: assigneeFilters, OR: assigneeFilters,
...(query.entityType workflow_instance: { is: buildWorkflowEntityVisibilityWhere(request, query.entityType ?? null) },
? { workflow_instance: { is: { entity_type: query.entityType } } }
: {}),
}; };
const [tasks, total] = await Promise.all([ const [tasks, total] = await Promise.all([
@@ -2151,6 +2173,7 @@ async function workflowRoutes(fastify) {
where: { where: {
id: taskId, id: taskId,
OR: assigneeFilters, OR: assigneeFilters,
workflow_instance: { is: buildWorkflowEntityVisibilityWhere(request) },
}, },
select: { select: {
id: true, id: true,
@@ -3199,7 +3222,9 @@ module.exports.__testables = {
buildReturnedCopy, buildReturnedCopy,
buildWorkflowTaskSummaryPayload, buildWorkflowTaskSummaryPayload,
buildWorkflowEntityMeta, buildWorkflowEntityMeta,
buildWorkflowEntityVisibilityWhere,
calculateWorkflowTaskDueAt, calculateWorkflowTaskDueAt,
canViewWorkflowEntityType,
handlePurchaseApprovalErpSync, handlePurchaseApprovalErpSync,
isWorkflowTaskOverdue, isWorkflowTaskOverdue,
listAllowedActionsForTask, listAllowedActionsForTask,
+14 -9
查看文件
@@ -29,6 +29,7 @@ function buildPerformanceDistribution(rows) {
async function buildEmployeeStats(prisma, options = {}) { async function buildEmployeeStats(prisma, options = {}) {
const timeZone = options.timeZone ?? "Asia/Shanghai"; const timeZone = options.timeZone ?? "Asia/Shanghai";
const includeSalarySummary = options.includeSalarySummary ?? true; const includeSalarySummary = options.includeSalarySummary ?? true;
const includePerformanceSummary = options.includePerformanceSummary ?? true;
const todayString = currentDateString(timeZone); const todayString = currentDateString(timeZone);
const today = parseDateOnlyString(todayString); const today = parseDateOnlyString(todayString);
const last30DateFrom = addDays(today, -29); const last30DateFrom = addDays(today, -29);
@@ -114,14 +115,18 @@ async function buildEmployeeStats(prisma, options = {}) {
}, },
}, },
}), }),
prisma.performanceReviewCycle.findFirst({ includePerformanceSummary
where: { status: "active" }, ? prisma.performanceReviewCycle.findFirst({
orderBy: [{ activated_at: "desc" }, { start_date: "desc" }], where: { status: "active" },
}), orderBy: [{ activated_at: "desc" }, { start_date: "desc" }],
prisma.performanceReviewCycle.findFirst({ })
where: { status: "completed" }, : Promise.resolve(null),
orderBy: [{ completed_at: "desc" }, { end_date: "desc" }], includePerformanceSummary
}), ? prisma.performanceReviewCycle.findFirst({
where: { status: "completed" },
orderBy: [{ completed_at: "desc" }, { end_date: "desc" }],
})
: Promise.resolve(null),
includeSalarySummary includeSalarySummary
? prisma.payslip.aggregate({ ? prisma.payslip.aggregate({
where: { where: {
@@ -155,7 +160,7 @@ async function buildEmployeeStats(prisma, options = {}) {
distribution: [], distribution: [],
}; };
if (performanceCycle) { if (includePerformanceSummary && performanceCycle) {
const [reviewAggregate, reviewDistribution] = await Promise.all([ const [reviewAggregate, reviewDistribution] = await Promise.all([
prisma.performanceReview.aggregate({ prisma.performanceReview.aggregate({
where: { cycle_id: performanceCycle.id }, where: { cycle_id: performanceCycle.id },
+1 -1
查看文件
@@ -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"] }, 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"] }, 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"] }, 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"] }, 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 }, 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 }, apOperations: { key: "apOperations", label: "AP 运营", icon: "account_balance_wallet", href: "/ap-operations", anyOf: ["payment.batch.manage", "risk.review.manage"], featureFlagKey: FEATURE_FLAG_KEYS.paymentBatch },
+12 -10
查看文件
@@ -51,21 +51,23 @@ describe("Permission Constants", () => {
expect(hasPermission(resolvePermissionsForRoles(["administration"]), "procurement.manage")).toBe(true); expect(hasPermission(resolvePermissionsForRoles(["administration"]), "procurement.manage")).toBe(true);
}); });
it("grants travel permissions to configured roles", () => { it("freezes travel permissions for non-admin launch roles", () => {
expect(hasPermission(resolvePermissionsForRoles(["employee"]), "travel.create")).toBe(true); expect(hasPermission(resolvePermissionsForRoles(["employee"]), "travel.create")).toBe(false);
expect(hasPermission(resolvePermissionsForRoles(["employee"]), "travel.submit")).toBe(true); expect(hasPermission(resolvePermissionsForRoles(["employee"]), "travel.submit")).toBe(false);
expect(hasPermission(resolvePermissionsForRoles(["manager"]), "travel.approve")).toBe(true); expect(hasPermission(resolvePermissionsForRoles(["manager"]), "travel.approve")).toBe(false);
expect(hasPermission(resolvePermissionsForRoles(["finance"]), "travel.approve")).toBe(false); expect(hasPermission(resolvePermissionsForRoles(["finance"]), "travel.approve")).toBe(false);
expect(hasPermission(resolvePermissionsForRoles(["finance_mgr"]), "travel.manage")).toBe(true); expect(hasPermission(resolvePermissionsForRoles(["finance_mgr"]), "travel.manage")).toBe(false);
expect(hasPermission(resolvePermissionsForRoles(["administration"]), "travel.manage")).toBe(true); 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", () => { it("freezes travel.payment.manage before travel launch", () => {
expect(hasPermission(resolvePermissionsForRoles(["finance"]), "travel.payment.manage")).toBe(true); expect(hasPermission(resolvePermissionsForRoles(["finance"]), "travel.payment.manage")).toBe(false);
expect(hasPermission(resolvePermissionsForRoles(["finance_mgr"]), "travel.payment.manage")).toBe(true); expect(hasPermission(resolvePermissionsForRoles(["finance_mgr"]), "travel.payment.manage")).toBe(false);
expect(hasPermission(resolvePermissionsForRoles(["administration"]), "travel.payment.manage")).toBe(true); expect(hasPermission(resolvePermissionsForRoles(["administration"]), "travel.payment.manage")).toBe(false);
expect(hasPermission(resolvePermissionsForRoles(["manager"]), "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(["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", () => { it("grants ai.assist only to finance manager and admin", () => {
@@ -143,7 +143,7 @@ async function buildApp({ permissionCodes = ["system.users"], stats } = {}) {
} }
describe("employees stats route", () => { 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 { const {
app, app,
employeeGroupBy, employeeGroupBy,
@@ -182,27 +182,10 @@ describe("employees stats route", () => {
expiringSoonCount: 4, expiringSoonCount: 4,
}); });
expect(payload.data.performanceSummary).toEqual({ expect(payload.data.performanceSummary).toEqual({
cycle: { cycle: null,
id: "cycle-active", averageScore: null,
name: "2026 Q2", reviewedCount: 0,
cycleType: "quarterly", distribution: [],
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 },
],
}); });
expect(payload.data.salarySummary).toBeNull(); expect(payload.data.salarySummary).toBeNull();
expect(employeeGroupBy).toHaveBeenCalledTimes(2); expect(employeeGroupBy).toHaveBeenCalledTimes(2);
@@ -211,17 +194,17 @@ describe("employees stats route", () => {
expect(contractCount).toHaveBeenCalledTimes(1); expect(contractCount).toHaveBeenCalledTimes(1);
expect(certificateGroupBy).toHaveBeenCalledTimes(1); expect(certificateGroupBy).toHaveBeenCalledTimes(1);
expect(certificateCount).toHaveBeenCalledTimes(1); expect(certificateCount).toHaveBeenCalledTimes(1);
expect(performanceCycleFindFirst).toHaveBeenCalledTimes(2); expect(performanceCycleFindFirst).not.toHaveBeenCalled();
expect(performanceReviewAggregate).toHaveBeenCalledTimes(1); expect(performanceReviewAggregate).not.toHaveBeenCalled();
expect(performanceReviewGroupBy).toHaveBeenCalledTimes(1); expect(performanceReviewGroupBy).not.toHaveBeenCalled();
expect(payslipAggregate).not.toHaveBeenCalled(); expect(payslipAggregate).not.toHaveBeenCalled();
await app.close(); 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({ const { app, payslipAggregate } = await buildApp({
permissionCodes: ["system.users"], permissionCodes: ["system.users", "system.settings"],
}); });
const response = await app.inject({ const response = await app.inject({
@@ -244,6 +227,10 @@ describe("employees stats route", () => {
issuedCount: 2, issuedCount: 2,
}, },
}); });
expect(response.json().data.performanceSummary.cycle).toMatchObject({
id: "cycle-active",
name: "2026 Q2",
});
expect(payslipAggregate).toHaveBeenCalledTimes(2); expect(payslipAggregate).toHaveBeenCalledTimes(2);
await app.close(); await app.close();
@@ -24,7 +24,7 @@ function buildPayslipRow() {
} }
async function buildApp({ async function buildApp({
actor = { id: ADMIN_ID, permissionCodes: ["system.users"] }, actor = { id: ADMIN_ID, permissionCodes: ["system.settings"] },
} = {}) { } = {}) {
const app = Fastify(); const app = Fastify();
const salaryItems = [ const salaryItems = [
@@ -21,7 +21,7 @@ function buildSalaryItem(overrides = {}) {
} }
async function buildApp({ async function buildApp({
actor = { id: ADMIN_ID, permissionCodes: ["system.users"] }, actor = { id: ADMIN_ID, permissionCodes: ["system.settings"] },
} = {}) { } = {}) {
const app = Fastify(); const app = Fastify();
const state = { const state = {
@@ -48,7 +48,7 @@ const PAYSLIP_IDS = [
]; ];
async function buildApp({ async function buildApp({
actor = { id: ADMIN_ID, permissionCodes: ["system.users"] }, actor = { id: ADMIN_ID, permissionCodes: ["system.settings"] },
omitProfileFor = [], omitProfileFor = [],
} = {}) { } = {}) {
const app = Fastify(); const app = Fastify();
@@ -50,7 +50,7 @@ function buildSalaryRow(overrides = {}) {
} }
async function buildApp({ async function buildApp({
actor = { id: ADMIN_ID, permissionCodes: ["system.users"] }, actor = { id: ADMIN_ID, permissionCodes: ["system.settings"] },
} = {}) { } = {}) {
const app = Fastify(); const app = Fastify();
const state = { const state = {
@@ -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 app = Fastify();
const state = { const state = {
cycles: [], cycles: [],
@@ -276,7 +276,7 @@ async function buildApp({
} }
describe("performance review routes", () => { 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 { app, state, performanceReviewDimension } = await buildApp();
const response = await app.inject({ const response = await app.inject({
@@ -292,23 +292,14 @@ describe("performance review routes", () => {
}, },
}); });
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(403);
expect(response.json().data).toMatchObject({ expect(state.reviews.find((item) => item.cycle_id === ACTIVE_CYCLE_ID && item.employee_id === EMPLOYEE_ID)).toBeFalsy();
employeeId: EMPLOYEE_ID, expect(performanceReviewDimension.createMany).not.toHaveBeenCalled();
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);
await app.close(); 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({ const { app } = await buildApp({
actor: { actor: {
id: EMPLOYEE_ID, id: EMPLOYEE_ID,
@@ -322,11 +313,7 @@ describe("performance review routes", () => {
url: `/api/performance/employees/${EMPLOYEE_ID}/reviews`, url: `/api/performance/employees/${EMPLOYEE_ID}/reviews`,
}); });
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(403);
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);
await app.close(); await app.close();
}); });
@@ -359,7 +346,7 @@ describe("performance review routes", () => {
actor: { actor: {
id: ADMIN_ID, id: ADMIN_ID,
department_id: DEPARTMENT_ID, department_id: DEPARTMENT_ID,
permissionCodes: ["system.users"], permissionCodes: ["system.settings"],
}, },
}); });
+21
查看文件
@@ -109,6 +109,27 @@ async function buildApp({ employee, prisma }) {
} }
describe("global search R23 confidential boundary", () => { 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 () => { it("does not match sensitive summary text but still matches minimal identifiers", async () => {
const prisma = createPrisma(); const prisma = createPrisma();
const app = await buildApp({ const app = await buildApp({
@@ -1,7 +1,12 @@
const workflowRoutes = require("../../../src/routes/workflow"); const workflowRoutes = require("../../../src/routes/workflow");
describe("Workflow Route Permissions", () => { 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", () => { it("allows leave approvers to act on leave tasks", () => {
const actions = listAllowedActionsForTask( const actions = listAllowedActionsForTask(
@@ -133,4 +138,26 @@ describe("Workflow Route Permissions", () => {
it("rejects unsupported workflow entityType filters", () => { it("rejects unsupported workflow entityType filters", () => {
expect(() => parseTaskListQuery({ entityType: "invoice_pool" })).toThrow("参数校验失败"); 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",
});
});
}); });
@@ -159,7 +159,7 @@ async function buildApp({ prisma, employee }) {
} }
describe("workflow transfer route", () => { 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", "allows transfer for %s when target has matching approve permission",
async (entityType) => { async (entityType) => {
const tx = buildTx({ 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 () => { it("rejects target approver without matching entity approve permission", async () => {
const tx = buildTx({ entityType: "leave", targetRoleCode: "finance" }); const tx = buildTx({ entityType: "leave", targetRoleCode: "finance" });
const app = await buildApp({ const app = await buildApp({