PC6 完善 PC 工作台与审批信息架构

这个提交包含在:
2026-05-12 02:34:15 +08:00
父节点 e7bf57380e
当前提交 aec660a8fe
共修改 13 个文件,包含 518 行新增和 74 行删除
+79 -9
查看文件
@@ -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,
+6
查看文件
@@ -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,
};
+71 -1
查看文件
@@ -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();
});
});
@@ -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("参数校验失败");
});
});