PC6 完善 PC 工作台与审批信息架构
这个提交包含在:
+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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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("参数校验失败");
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户