OAPERM1 收紧启用前权限冻结
这个提交包含在:
@@ -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"] },
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 只能在人工批准后做影子库恢复。"
|
||||||
|
@@ -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;本合同只重申最小真实样本边界。回滚/清理:真实业务数据按审计保留;误录按业务状态归档/冲正,不从仓库清理。"
|
||||||
|
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户