预算池:{detail.budgetPool?.name ?? "待提交时自动解析"}
待办节点:{detail.pendingWorkflow?.task?.stepName ?? "-"}
总金额:{totalAmountPreview}
+ {!detail.budgetPool ? (
+
+ 未匹配预算池时仍会按当前规则提交审批;如需强预算校验,请由管理员补充预算配置。
+
+ ) : null}
diff --git a/client/src/pages/WorkflowTasksPage.tsx b/client/src/pages/WorkflowTasksPage.tsx
index de964174..b1a6411b 100644
--- a/client/src/pages/WorkflowTasksPage.tsx
+++ b/client/src/pages/WorkflowTasksPage.tsx
@@ -14,6 +14,35 @@ import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
import { TEST_IDS } from "../lib/testIds";
const PAGE_SIZE = 20;
+const WORKFLOW_STATUS_OPTIONS = [
+ { value: "pending", label: "待处理" },
+ { value: "completed", label: "已处理" },
+ { value: "rejected", label: "已驳回" },
+ { value: "cancelled", label: "已取消" },
+ { value: "skipped", label: "已跳过" },
+] as const;
+const WORKFLOW_ENTITY_OPTIONS = [
+ { value: "all", label: "全部业务" },
+ { value: "reimbursement", label: "报销" },
+ { value: "leave", label: "请假" },
+ { value: "purchase_request", label: "采购" },
+ { value: "travel_request", label: "差旅" },
+] as const;
+
+type WorkflowStatusFilter = (typeof WORKFLOW_STATUS_OPTIONS)[number]["value"];
+type WorkflowEntityFilter = (typeof WORKFLOW_ENTITY_OPTIONS)[number]["value"];
+
+function normalizeStatusFilter(value: string | null): WorkflowStatusFilter {
+ return WORKFLOW_STATUS_OPTIONS.some((item) => item.value === value)
+ ? (value as WorkflowStatusFilter)
+ : "pending";
+}
+
+function normalizeEntityFilter(value: string | null): WorkflowEntityFilter {
+ return WORKFLOW_ENTITY_OPTIONS.some((item) => item.value === value)
+ ? (value as WorkflowEntityFilter)
+ : "all";
+}
function entityLabel(entityType: string | undefined) {
if (entityType === "leave") {
@@ -82,19 +111,36 @@ export function WorkflowTasksPage() {
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
- const status =
- (searchParams.get("status") as
- | "pending"
- | "completed"
- | "rejected"
- | "cancelled"
- | "skipped"
- | null) ?? "pending";
+ const status = normalizeStatusFilter(searchParams.get("status"));
+ const entityType = normalizeEntityFilter(searchParams.get("entityType"));
const page = Number(searchParams.get("page") ?? "1") || 1;
+ function updateSearchParams(next: {
+ status?: WorkflowStatusFilter;
+ entityType?: WorkflowEntityFilter;
+ page?: number;
+ }) {
+ const nextStatus = next.status ?? status;
+ const nextEntityType = next.entityType ?? entityType;
+ const params: Record
= {
+ status: nextStatus,
+ page: String(next.page ?? page),
+ };
+ if (nextEntityType !== "all") {
+ params.entityType = nextEntityType;
+ }
+ setSearchParams(params);
+ }
+
const query = useQuery({
- queryKey: ["workflow", "tasks", status, page],
- queryFn: () => fetchWorkflowTasks({ status, page, pageSize: PAGE_SIZE }),
+ queryKey: ["workflow", "tasks", status, entityType, page],
+ queryFn: () =>
+ fetchWorkflowTasks({
+ status,
+ entityType: entityType === "all" ? undefined : entityType,
+ page,
+ pageSize: PAGE_SIZE,
+ }),
});
const approveMutation = useMutation({
@@ -125,32 +171,47 @@ export function WorkflowTasksPage() {
return (
-
+
-
setSearchParams({ status: value, page: "1" })}
- >
-
- {[
- { value: "pending", label: "待处理" },
- { value: "completed", label: "已处理" },
- { value: "rejected", label: "已驳回" },
- { value: "cancelled", label: "已取消" },
- { value: "skipped", label: "已跳过" },
- ].map((item) => (
-
- {item.label}
-
- ))}
-
-
+
+
updateSearchParams({ status: value as WorkflowStatusFilter, page: 1 })}
+ >
+
+ {WORKFLOW_STATUS_OPTIONS.map((item) => (
+
+ {item.label}
+
+ ))}
+
+
+
+
+
+
+
+
{query.isLoading ?
: null}
@@ -184,6 +245,7 @@ export function WorkflowTasksPage() {
申请人:{displayApplicant(task)}
金额/天数:{displayMetric(task)}
+
当前节点:{task.stepName}
业务状态:{businessStatusLabel(task.businessSummary?.businessStatus)}
@@ -224,12 +286,13 @@ export function WorkflowTasksPage() {
- 审批事项
+ 业务类型 / 编号
+ 标题
申请人
金额/天数
+ 当前节点
业务状态
- 任务状态
- 创建时间
+ 更新时间
操作
@@ -237,16 +300,18 @@ export function WorkflowTasksPage() {
{taskItems.length ? (
taskItems.map((task) => (
+
+ {entityLabel(task.workflowInstance?.entityType)}
+ {displayReference(task)}
+
{displayTitle(task)}
-
- {entityLabel(task.workflowInstance?.entityType)} · {displayReference(task)}
-
+
{displayApplicant(task)}
{displayMetric(task)}
+ {task.stepName}
{businessStatusLabel(task.businessSummary?.businessStatus)}
-
{new Date(task.createdAt).toLocaleString()}
@@ -275,7 +340,7 @@ export function WorkflowTasksPage() {
))
) : (
-
+
暂无数据
@@ -289,7 +354,7 @@ export function WorkflowTasksPage() {
setSearchParams({ status, page: String(p) })}
+ onPageChange={(p) => updateSearchParams({ page: p })}
testIdPrefix="workflow-tasks"
/>
diff --git a/client/tests/e2e/visual-portal.spec.ts b/client/tests/e2e/visual-portal.spec.ts
index 62e48cfc..0185fa61 100644
--- a/client/tests/e2e/visual-portal.spec.ts
+++ b/client/tests/e2e/visual-portal.spec.ts
@@ -1,6 +1,6 @@
import { expect, test, type Page, type TestInfo } from "@playwright/test";
-import { EMPLOYEE, login, logout, type E2EUser } from "./helpers/auth";
+import { EMPLOYEE, FINANCE, login, logout, type E2EUser } from "./helpers/auth";
const ADMIN: E2EUser = {
identifier: "admin@hyinda.com",
@@ -34,15 +34,27 @@ test.describe("visual portal", () => {
await page.setViewportSize({ width: 1440, height: 1200 });
await login(page, ADMIN);
- await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toBeVisible();
+ await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toHaveCount(0);
await expect(page.locator('[aria-label="portal-card-team_presence"]')).toBeVisible();
await expect(page.locator('[aria-label="portal-card-weekly_missing"]')).toBeVisible();
await expect(page.locator('[aria-label="portal-card-weekly_help"]')).toBeVisible();
- await expect(page.locator("summary").filter({ hasText: "今日管理重点 / 常用入口" })).toBeVisible();
+ await expect(page.locator("summary").filter({ hasText: "角色重点 / 常用入口" })).toBeVisible();
await expect(page.getByText("试点观测摘要")).toBeVisible();
await expect(page.getByRole("tab", { name: "公告" })).toBeVisible();
await attachScreenshot(page, testInfo, "portal-admin.png");
await logout(page, ADMIN.name);
});
+
+ test("财务 Portal 显示财务待处理入口", async ({ page }, testInfo) => {
+ await page.setViewportSize({ width: 1440, height: 1200 });
+ await login(page, FINANCE);
+
+ await expect(page.getByText("财务工作台")).toBeVisible();
+ await expect(page.getByText("财务处理")).toBeVisible();
+ await expect(page.locator("summary").filter({ hasText: "角色重点 / 常用入口" })).toBeVisible();
+
+ await attachScreenshot(page, testInfo, "portal-finance.png");
+ await logout(page, FINANCE.name);
+ });
});
diff --git a/client/tests/e2e/workflow-quick-approve.spec.ts b/client/tests/e2e/workflow-quick-approve.spec.ts
index ce8dbddf..ce3877ce 100644
--- a/client/tests/e2e/workflow-quick-approve.spec.ts
+++ b/client/tests/e2e/workflow-quick-approve.spec.ts
@@ -84,6 +84,7 @@ test("E2E 审批列表快捷通过:经理可在待办列表行内直接通过
await login(page, MANAGER);
await (await openNavLink(page, "待我处理")).click();
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
+ await page.getByTestId("workflow-tasks-entity-filter").selectOption("leave");
const taskRows = page.locator(
`[data-testid="${TEST_IDS.workflow.taskRow}"]:has(a[href="/workflow/tasks/${submitResult.currentTaskId}"])`,
diff --git a/issues/2026-05-12_01-40-00-r6-pc-workbench-ia-polish.csv b/issues/2026-05-12_01-40-00-r6-pc-workbench-ia-polish.csv
index 707cf8da..a192b396 100644
--- a/issues/2026-05-12_01-40-00-r6-pc-workbench-ia-polish.csv
+++ b/issues/2026-05-12_01-40-00-r6-pc-workbench-ia-polish.csv
@@ -1,6 +1,6 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
PC6-1,PC 信息架构优化合同建档,在 R6 首轮通过后固定 PC 工作台和高频业务页第一批优化范围。,"1) 本 CSV 校验通过;2) 范围限定 PC 工作台、审批中心、报销、财务、采购;3) 不扩预算、ERP、AI、完整差旅、复杂 BPM、审批设计器;4) 不改移动统一设计。","流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-12_01-40-00-r6-pc-workbench-ia-polish.csv;人工核对主 R6 已 DONE。",python3|manual,DONE,DONE,DONE,issues/2026-05-12_01-40-00-r6-pc-workbench-ia-polish.csv,[],R6 通过后才允许实施;本合同不代表全面 PC 重构。
-PC6-2,PC 工作台角色化摘要,优化 PC 工作台按员工、审批人、财务、管理员展示待办、通知、我的申请、财务待处理和最近业务摘要。,"1) 同一工作台框架按角色裁剪摘要和入口;2) 员工不看到财务付款和管理配置;3) 管理员不获得业务审批按钮;4) 入口与服务端权限一致。","流程级验证:员工、审批人、财务、管理员分别登录 PC 工作台 -> 检查摘要、入口和无权动作隐藏;直连无权 API 仍拒绝。",playwright|manual,TODO,TODO,TODO,client/src/pages/PortalHomePage.tsx|server/src/routes/portal.js,"[""PC6-1""]",如需后端摘要字段,先复用现有 portal 接口,新增字段必须保持向后兼容。
-PC6-3,PC 审批中心统一信息架构,统一待办、已办、我发起和通知入口,列表展示业务类型、编号、申请人、金额或天数、当前节点、状态和更新时间。,"1) PC 审批列表可按状态和业务类型筛选;2) 详情页展示摘要、业务字段、附件发票、审批流和日志;3) 自审阻断和 admin-only 禁止审批不回归。","流程级验证:SMOKE002 提交报销/请假/采购 -> SMOKE101 在 PC 审批中心处理 -> 查看已办和发起记录 -> 检查审批历史一致。",playwright|node,TODO,TODO,TODO,client/src/pages/WorkflowTasksPage.tsx|server/src/routes/workflow.js,"[""PC6-2""]",涉及审批权限时需 reviewer 复核。
-PC6-4,PC 业务详情页第一批收口,优化报销、采购和财务详情页的信息分区,突出发票、附件、审批流、付款状态和采购状态解释。,"1) 报销详情突出发票、附件、审批流、付款状态;2) 采购详情突出状态、附件、审批历史;3) 财务页展示待复核、待付款、已付款和导出入口;4) 不引入桌面页面到移动路由。","流程级验证:PC 1440x960 打开报销详情、采购详情、财务页面 -> 核对核心字段和操作;移动路由静态检查无 PC 页面误挂。",playwright|node,TODO,TODO,TODO,client/src/pages/ReimbursementsPage.tsx|client/src/pages/PurchaseRequestsPage.tsx|client/src/pages/ApOperationsPage.tsx,"[""PC6-3""]",只做第一批高频页面,不全站重构。
-PC6-5,PC 优化回归与部署,完成 PC 第一批优化后执行类型检查、构建、PC 视觉和公网复检。,"1) client typecheck/build 通过;2) PC 1440x960 视觉截图通过;3) 关键业务流不回归;4) 部署后 /ready、bundle 扫描和 live smoke 通过。","流程级验证:cd client && npm run typecheck && npm run build;PC Playwright 视觉;bash deploy/trialrun/deploy.sh;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh。",npm|playwright|bash,TODO,TODO,TODO,client|scripts/qa|deploy/trialrun,"[""PC6-4""]",面向用户可测前必须完成部署和公网复检。
+PC6-2,PC 工作台角色化摘要,优化 PC 工作台按员工、审批人、财务、管理员展示待办、通知、我的申请、财务待处理和最近业务摘要。,"1) 同一工作台框架按角色裁剪摘要和入口;2) 员工不看到财务付款和管理配置;3) 管理员不获得业务审批按钮;4) 入口与服务端权限一致。","流程级验证:员工、审批人、财务、管理员分别登录 PC 工作台 -> 检查摘要、入口和无权动作隐藏;直连无权 API 仍拒绝。",playwright|manual,DONE,DONE,DONE,client/src/pages/PortalHomePage.tsx|server/src/routes/portal.js,"[""PC6-1""]",已通过 portal 单测、员工/管理员/财务 Portal Playwright 视觉;管理员不显示业务审批主卡。
+PC6-3,PC 审批中心统一信息架构,统一待办、已办、我发起和通知入口,列表展示业务类型、编号、申请人、金额或天数、当前节点、状态和更新时间。,"1) PC 审批列表可按状态和业务类型筛选;2) 详情页展示摘要、业务字段、附件发票、审批流和日志;3) 自审阻断和 admin-only 禁止审批不回归。","流程级验证:SMOKE002 提交报销/请假/采购 -> SMOKE101 在 PC 审批中心处理 -> 查看已办和发起记录 -> 检查审批历史一致。",playwright|node,DONE,DONE,DONE,client/src/pages/WorkflowTasksPage.tsx|server/src/routes/workflow.js,"[""PC6-2""]",已通过 workflow entityType 单测、审批快捷通过 E2E 和 reimbursement/approval/leave/procurement 业务门禁。
+PC6-4,PC 业务详情页第一批收口,优化报销、采购和财务详情页的信息分区,突出发票、附件、审批流、付款状态和采购状态解释。,"1) 报销详情突出发票、附件、审批流、付款状态;2) 采购详情突出状态、附件、审批历史;3) 财务页展示待复核、待付款、已付款和导出入口;4) 不引入桌面页面到移动路由。","流程级验证:PC 1440x960 打开报销详情、采购详情、财务页面 -> 核对核心字段和操作;移动路由静态检查无 PC 页面误挂。",playwright|node,DONE,DONE,DONE,client/src/pages/ReimbursementsPage.tsx|client/src/pages/PurchaseRequestsPage.tsx|client/src/pages/ApOperationsPage.tsx,"[""PC6-3""]",已通过构建、PC Portal/审批 E2E 和三类业务门禁;仅收口 PC 页面,不改移动路由。
+PC6-5,PC 优化回归与部署,完成 PC 第一批优化后执行类型检查、构建、PC 视觉和公网复检。,"1) client typecheck/build 通过;2) PC 1440x960 视觉截图通过;3) 关键业务流不回归;4) 部署后 /ready、bundle 扫描和 live smoke 通过。","流程级验证:cd client && npm run typecheck && npm run build;PC Playwright 视觉;bash deploy/trialrun/deploy.sh;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh。",npm|playwright|bash,DONE,DONE,DONE,client|scripts/qa|deploy/trialrun,"[""PC6-4""]",已部署到 https://oa.hyinda.com;/ready、public bundle findings=0、live smoke 和真实 OCR 可解释终态通过,报告 artifacts/qa-flow/live-smoke-readiness-20260512-023246。
diff --git a/server/src/routes/portal.js b/server/src/routes/portal.js
index 85472578..f4ae3423 100644
--- a/server/src/routes/portal.js
+++ b/server/src/routes/portal.js
@@ -10,6 +10,30 @@ const { currentDateString, parseDateOnlyString } = require("../utils/date");
const PORTAL_RECENT_ACTIVITY_LIMIT = 15;
const PORTAL_RECENT_ACTIVITY_SOURCE_TAKE = 24;
+const BUSINESS_APPROVE_PERMISSIONS = [
+ "expense.approve",
+ "leave.approve",
+ "procurement.approve",
+ "travel.approve",
+];
+const BUSINESS_SUBMIT_PERMISSIONS = [
+ "expense.create",
+ "expense.submit",
+ "leave.create",
+ "leave.submit",
+ "procurement.create",
+ "procurement.submit",
+ "travel.create",
+ "travel.submit",
+];
+const SYSTEM_MANAGE_PERMISSIONS = [
+ "system.users",
+ "system.settings",
+ "system.audit.view",
+ "notification.channel.manage",
+];
+const FINANCE_ROLE_CODES = new Set(["finance", "finance_mgr"]);
+const BUSINESS_APPROVER_ROLE_CODES = new Set(["manager", "ceo", "finance", "finance_mgr", "hr"]);
async function buildTaskVisibilityFilters(tx, request) {
const roleFilters = (request.employee.roleCodes ?? []).length
@@ -18,6 +42,30 @@ async function buildTaskVisibilityFilters(tx, request) {
return [{ assignee_id: request.employee.id }, ...roleFilters];
}
+function hasAnyPermission(fastify, employee, permissionCodes) {
+ return permissionCodes.some((permissionCode) => fastify.hasPermission(employee, permissionCode));
+}
+
+function buildRoleCapabilities(fastify, employee) {
+ const roleCodes = employee.roleCodes ?? [];
+ const isAdmin = roleCodes.includes("admin") || fastify.hasPermission(employee, "*");
+ const hasBusinessApproverRole = roleCodes.some((roleCode) => BUSINESS_APPROVER_ROLE_CODES.has(roleCode));
+ const canUseFinance =
+ roleCodes.some((roleCode) => FINANCE_ROLE_CODES.has(roleCode)) &&
+ hasAnyPermission(fastify, employee, ["payment.batch.manage", "risk.review.manage", "expense.finance.pay"]);
+
+ return {
+ canSubmitApplications: hasAnyPermission(fastify, employee, BUSINESS_SUBMIT_PERMISSIONS),
+ canApproveTasks: hasBusinessApproverRole && hasAnyPermission(fastify, employee, BUSINESS_APPROVE_PERMISSIONS),
+ canUseFinance,
+ canManageSystem: isAdmin || hasAnyPermission(fastify, employee, SYSTEM_MANAGE_PERMISSIONS),
+ canViewTeam:
+ hasBusinessApproverRole ||
+ hasAnyPermission(fastify, employee, ["report.dashboard.view", "system.users", "project.manage"]),
+ isAdminOnly: isAdmin && !hasBusinessApproverRole,
+ };
+}
+
async function ensureFeatureEnabled(fastify, request) {
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
@@ -410,6 +458,7 @@ async function portalRoutes(fastify) {
const today = currentDateString(fastify.config.appTimezone);
const currentWeekStart = resolveWeekStart(today);
const assigneeFilters = await buildTaskVisibilityFilters(fastify.prisma, request);
+ const roleCapabilities = buildRoleCapabilities(fastify, request.employee);
const isManagerView =
fastify.hasPermission(request.employee, "system.users") ||
fastify.hasPermission(request.employee, "report.dashboard.view") ||
@@ -447,6 +496,8 @@ async function portalRoutes(fastify) {
openProjectRiskCount,
projectFollowUpDueTodayCount,
closurePendingProjectCount,
+ pendingFinanceBatchCount,
+ failedFinanceItemCount,
recentActivities,
teamStatusBoard,
] = await Promise.all([
@@ -454,7 +505,7 @@ async function portalRoutes(fastify) {
fastify.prisma.reimbursementForm.count({ where: { OR: [{ owner_id: request.employee.id }, { submitter_id: request.employee.id }] } }),
fastify.prisma.leaveRequest.count({ where: { owner_id: request.employee.id } }),
fastify.prisma.assetCard.count({ where: { assignee_employee_id: request.employee.id } }),
- fastify.hasPermission(request.employee, "payment.batch.manage") || fastify.hasPermission(request.employee, "risk.review.manage")
+ roleCapabilities.canUseFinance
? fastify.prisma.entityRiskFlag.count({ where: { status: "open" } })
: Promise.resolve(0),
featureEnabled.announcements
@@ -556,6 +607,14 @@ async function portalRoutes(fastify) {
select: { project_id: true },
}).then((items) => items.length)
: Promise.resolve(0),
+ roleCapabilities.canUseFinance
+ ? fastify.prisma.paymentBatch.count({
+ where: { status: { in: ["draft", "reviewed", "failed"] } },
+ })
+ : Promise.resolve(0),
+ roleCapabilities.canUseFinance
+ ? fastify.prisma.paymentBatchItem.count({ where: { status: "failed" } })
+ : Promise.resolve(0),
buildRecentActivities(fastify.prisma),
buildTeamStatusBoard(fastify.prisma, today),
]);
@@ -619,13 +678,15 @@ async function portalRoutes(fastify) {
href: "/people",
severity: (assetReturnPendingCount ?? 0) > 0 ? "warning" : "normal",
});
- cards.push({
- key: "pending_approvals",
- title: "审批待办",
- value: pendingTaskCount,
- href: "/workflow/tasks",
- severity: pendingTaskCount > 0 ? "warning" : "normal",
- });
+ if (roleCapabilities.canApproveTasks) {
+ cards.push({
+ key: "pending_approvals",
+ title: "审批待办",
+ value: pendingTaskCount,
+ href: "/workflow/tasks",
+ severity: pendingTaskCount > 0 ? "warning" : "normal",
+ });
+ }
if (featureEnabled.projectDomain) {
cards.push({ key: "project_milestones", title: "逾期里程碑", value: overdueProjectMilestoneCount, href: "/projects", severity: overdueProjectMilestoneCount > 0 ? "warning" : "normal" });
cards.push({ key: "project_risks", title: "项目风险", value: openProjectRiskCount, href: "/projects", severity: openProjectRiskCount > 0 ? "high" : "normal" });
@@ -660,7 +721,7 @@ async function portalRoutes(fastify) {
}
}
- if (featureEnabled.paymentBatch && riskCount > 0) {
+ if (featureEnabled.paymentBatch && roleCapabilities.canUseFinance && riskCount > 0) {
cards.push({ key: "risk_flags", title: "待复核风险", value: riskCount, href: "/ap-operations", severity: "high" });
}
@@ -747,6 +808,15 @@ async function portalRoutes(fastify) {
href: "/people",
}
: null,
+ roleCapabilities,
+ financeSummary: roleCapabilities.canUseFinance
+ ? {
+ pendingBatchCount: pendingFinanceBatchCount,
+ failedItemCount: failedFinanceItemCount,
+ riskCount,
+ href: "/ap-operations",
+ }
+ : null,
adoptionSummary,
recentActivities,
teamStatusBoard,
diff --git a/server/src/routes/workflow.js b/server/src/routes/workflow.js
index 9239ada5..2be9a5be 100644
--- a/server/src/routes/workflow.js
+++ b/server/src/routes/workflow.js
@@ -51,6 +51,7 @@ const BATCH_REJECT_BODY_SCHEMA = z.object({
const TASK_LIST_QUERY_SCHEMA = z.object({
status: z.enum(["pending", "completed", "rejected", "cancelled", "skipped"]).optional(),
+ entityType: z.enum(["reimbursement", "leave", "purchase_request", "travel_request"]).optional(),
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
});
@@ -250,6 +251,7 @@ function parseTaskListQuery(query) {
return {
status: parsed.data.status ?? "pending",
+ entityType: parsed.data.entityType ?? null,
page: parsed.data.page ?? 1,
pageSize: parsed.data.pageSize ?? 20,
};
@@ -1543,6 +1545,9 @@ async function workflowRoutes(fastify) {
const where = {
status: query.status,
OR: assigneeFilters,
+ ...(query.entityType
+ ? { workflow_instance: { is: { entity_type: query.entityType } } }
+ : {}),
};
const [tasks, total] = await Promise.all([
@@ -2663,5 +2668,6 @@ module.exports.__testables = {
buildWorkflowEntityMeta,
handlePurchaseApprovalErpSync,
listAllowedActionsForTask,
+ parseTaskListQuery,
resolveWorkflowOutcomeNotificationType,
};
diff --git a/server/tests/unit/routes/portal.test.js b/server/tests/unit/routes/portal.test.js
index 12a0fb21..2e64e737 100644
--- a/server/tests/unit/routes/portal.test.js
+++ b/server/tests/unit/routes/portal.test.js
@@ -164,6 +164,7 @@ async function buildApp(options = {}) {
);
const travelRows = options.travelRows ?? [];
const permissionCodes = options.permissionCodes ?? ["expense.view_own"];
+ const roleCodes = options.roleCodes ?? [];
const employeeId = options.employeeId ?? "employee-1";
resolvePresenceForEmployee.mockImplementation(async (_prisma, targetEmployeeId) => {
@@ -189,6 +190,15 @@ async function buildApp(options = {}) {
assetCard: {
count: vi.fn(async () => 1),
},
+ paymentBatch: {
+ count: vi.fn(async () => options.pendingFinanceBatchCount ?? 0),
+ },
+ paymentBatchItem: {
+ count: vi.fn(async () => options.failedFinanceItemCount ?? 0),
+ },
+ entityRiskFlag: {
+ count: vi.fn(async () => options.financeRiskCount ?? 0),
+ },
employee: {
count: vi.fn(async () => employees.length),
findMany: vi.fn(async () => employees),
@@ -295,7 +305,7 @@ async function buildApp(options = {}) {
name: "测试用户",
department_id: "department-1",
permissionCodes,
- roleCodes: [],
+ roleCodes,
};
});
app.decorate(
@@ -472,4 +482,64 @@ describe("portal routes", () => {
await app.close();
});
+
+ it("returns roleCapabilities and financeSummary only for finance roles", async () => {
+ featureFlagState.paymentBatch = true;
+ const { app } = await buildApp({
+ roleCodes: ["employee", "finance"],
+ permissionCodes: ["expense.view_own", "payment.batch.manage", "expense.finance.pay", "expense.approve"],
+ pendingFinanceBatchCount: 3,
+ failedFinanceItemCount: 2,
+ });
+
+ const response = await app.inject({
+ method: "GET",
+ url: "/api/portal/home",
+ headers: { authorization: "Bearer test" },
+ });
+
+ expect(response.statusCode).toBe(200);
+ const payload = response.json().data;
+ expect(payload.roleCapabilities).toMatchObject({
+ canApproveTasks: true,
+ canUseFinance: true,
+ canManageSystem: false,
+ });
+ expect(payload.financeSummary).toEqual({
+ pendingBatchCount: 3,
+ failedItemCount: 2,
+ riskCount: 0,
+ href: "/ap-operations",
+ });
+
+ await app.close();
+ });
+
+ it("does not expose financeSummary to admin-only accounts", async () => {
+ featureFlagState.paymentBatch = true;
+ const { app } = await buildApp({
+ roleCodes: ["admin"],
+ permissionCodes: ["*"],
+ pendingFinanceBatchCount: 3,
+ failedFinanceItemCount: 2,
+ });
+
+ const response = await app.inject({
+ method: "GET",
+ url: "/api/portal/home",
+ headers: { authorization: "Bearer test" },
+ });
+
+ expect(response.statusCode).toBe(200);
+ const payload = response.json().data;
+ expect(payload.roleCapabilities).toMatchObject({
+ canManageSystem: true,
+ canUseFinance: false,
+ isAdminOnly: true,
+ });
+ expect(payload.financeSummary).toBeNull();
+ expect(payload.cards.map((card) => card.key)).not.toContain("pending_approvals");
+
+ await app.close();
+ });
});
diff --git a/server/tests/unit/routes/workflow.permissions.test.js b/server/tests/unit/routes/workflow.permissions.test.js
index b765f43d..20e74805 100644
--- a/server/tests/unit/routes/workflow.permissions.test.js
+++ b/server/tests/unit/routes/workflow.permissions.test.js
@@ -1,7 +1,7 @@
const workflowRoutes = require("../../../src/routes/workflow");
describe("Workflow Route Permissions", () => {
- const { listAllowedActionsForTask } = workflowRoutes.__testables;
+ const { listAllowedActionsForTask, parseTaskListQuery } = workflowRoutes.__testables;
it("allows leave approvers to act on leave tasks", () => {
const actions = listAllowedActionsForTask(
@@ -107,4 +107,17 @@ describe("Workflow Route Permissions", () => {
expect(actions).toEqual([]);
});
+
+ it("parses optional workflow entityType filter for PC approval center", () => {
+ expect(parseTaskListQuery({ status: "pending", entityType: "purchase_request", page: "2" })).toMatchObject({
+ status: "pending",
+ entityType: "purchase_request",
+ page: 2,
+ pageSize: 20,
+ });
+ });
+
+ it("rejects unsupported workflow entityType filters", () => {
+ expect(() => parseTaskListQuery({ entityType: "invoice_pool" })).toThrow("参数校验失败");
+ });
});