diff --git a/client/src/pages/MobileApprovalsPage.tsx b/client/src/pages/MobileApprovalsPage.tsx index 5487f5c0..f21beda9 100644 --- a/client/src/pages/MobileApprovalsPage.tsx +++ b/client/src/pages/MobileApprovalsPage.tsx @@ -183,10 +183,10 @@ export function MobileApprovalsPage() { return null; }, [cachedDetailQuery.data?.detail, canUseRemote, detailQuery.data]); - const canTransfer = + const canTransfer = Boolean( canUseRemote && - selectedTask?.workflowInstance?.entityType === "reimbursement" && - selectedTask.allowedActions.includes("transfer"); + selectedTask?.allowedActions.includes("transfer"), + ); const approversQuery = useQuery({ enabled: Boolean(canTransfer), diff --git a/client/src/pages/WorkflowTaskDetailPage.tsx b/client/src/pages/WorkflowTaskDetailPage.tsx index a3d48dc0..11c0ddd8 100644 --- a/client/src/pages/WorkflowTaskDetailPage.tsx +++ b/client/src/pages/WorkflowTaskDetailPage.tsx @@ -179,7 +179,7 @@ export function WorkflowTaskDetailPage() { const detail = detailQuery.data; const isLeaveTask = detail.workflowInstance?.entityType === "leave"; - const canTransfer = allowedActions.has("transfer") && detail.workflowInstance?.entityType === "reimbursement"; + const canTransfer = allowedActions.has("transfer"); return ( diff --git a/server/scripts/regression-workflow-actions.sh b/server/scripts/regression-workflow-actions.sh index f7e1d0bd..f87460af 100755 --- a/server/scripts/regression-workflow-actions.sh +++ b/server/scripts/regression-workflow-actions.sh @@ -1101,7 +1101,7 @@ assert_error_detail "$TMP_DIR/transfer-same-action.json" '.error.details.transfe withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SAME" "cleanup transfer same assignee" "$TMP_DIR/transfer-same-withdraw-cleanup.json" assert_reimbursement_status_db "$RID_TRANSFER_SAME" "draft" "transfer same cleanup" -echo "[15/23] transfer chain limit: 4th transfer blocked" +echo "[15/23] transfer chain limit: 4th transfer blocked => WORKFLOW_003" RID_TRANSFER_CHAIN="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/transfer-chain-create.json")" create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_TRANSFER_CHAIN" "transfer-chain" >/dev/null submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_CHAIN" "$TMP_DIR/transfer-chain-submit.json" @@ -1121,7 +1121,7 @@ assert_transfer_evidence "$TASK_TRANSFER_CHAIN" "$RID_TRANSFER_CHAIN" "$MANAGER_ transfer_chain_4_status="$(transfer_task "$CEO_TOKEN" "$TASK_TRANSFER_CHAIN" "$MANAGER_ID" "transfer chain step four should fail" "$TMP_DIR/transfer-chain-4.json")" assert_status "$transfer_chain_4_status" "409" "transfer chain step 4" "$TMP_DIR/transfer-chain-4.json" -assert_error_code "$TMP_DIR/transfer-chain-4.json" "VALID_001" "transfer chain step 4" +assert_error_code "$TMP_DIR/transfer-chain-4.json" "WORKFLOW_003" "transfer chain step 4" assert_error_detail "$TMP_DIR/transfer-chain-4.json" '.error.details.transfer' "chain_limit_exceeded" "transfer chain step 4" ceo_approve_chain_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_TRANSFER_CHAIN/approve" "$CEO_TOKEN" "$TMP_DIR/transfer-chain-approve-ceo.json" '{}')" diff --git a/server/src/routes/workflow.js b/server/src/routes/workflow.js index 0a811f32..c51d15be 100644 --- a/server/src/routes/workflow.js +++ b/server/src/routes/workflow.js @@ -54,7 +54,7 @@ const APPROVER_LIST_QUERY_SCHEMA = z.object({ }); const APPROVER_ROLE_CODES = Object.keys(ROLE_PERMISSIONS).filter((roleCode) => - hasPermission(ROLE_PERMISSIONS[roleCode], "expense.approve"), + ROLE_PERMISSIONS[roleCode].some((permissionCode) => permissionCode.endsWith(".approve")), ); function parseRequiredComment(body) { @@ -303,14 +303,12 @@ function listAllowedActionsForTask(request, task, actionable) { const actions = []; const approvePermission = resolveEntityPermission(task.workflow_instance?.entity_type, "approve"); - if (approvePermission && hasPermission(request.employee.permissionCodes, approvePermission)) { + const canApprove = approvePermission && hasPermission(request.employee.permissionCodes, approvePermission); + if (canApprove) { actions.push("approve", "reject", "return"); } - if ( - task.workflow_instance?.entity_type === "reimbursement" && - hasPermission(request.employee.permissionCodes, "expense.transfer") - ) { + if (canApprove && hasPermission(request.employee.permissionCodes, "expense.transfer")) { actions.push("transfer"); } @@ -734,8 +732,8 @@ async function ensureTransferChainNotExceeded(tx, taskId) { if (transferCount >= 3) { throw new AppError({ - code: "VALID_001", - message: "参数校验失败", + code: "WORKFLOW_003", + message: "已达转审上限", statusCode: 409, details: { transfer: "chain_limit_exceeded" }, }); @@ -744,7 +742,7 @@ async function ensureTransferChainNotExceeded(tx, taskId) { return transferCount; } -async function ensureTransferTargetEligible(tx, toApproverId) { +async function ensureTransferTargetEligible(tx, toApproverId, approvePermission) { const target = await tx.employee.findUnique({ where: { id: toApproverId }, select: { @@ -769,7 +767,7 @@ async function ensureTransferTargetEligible(tx, toApproverId) { const roleCodes = target.roles.map((item) => item.role_code); const permissionCodes = resolvePermissionsForRoles(roleCodes); - if (!hasPermission(permissionCodes, "expense.approve")) { + if (!approvePermission || !hasPermission(permissionCodes, approvePermission)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", @@ -2039,7 +2037,11 @@ async function workflowRoutes(fastify) { const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request); const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id); - if (workflowInstance.entity_type !== "reimbursement") { + const approvePermission = resolveEntityPermission( + workflowInstance.entity_type, + "approve", + ); + if (!approvePermission) { throw new AppError({ code: "VALID_001", message: "参数校验失败", @@ -2048,7 +2050,7 @@ async function workflowRoutes(fastify) { }); } const transferCount = await ensureTransferChainNotExceeded(tx, taskId); - await ensureTransferTargetEligible(tx, toApproverId); + await ensureTransferTargetEligible(tx, toApproverId, approvePermission); if (toApproverId === request.employee.id) { throw new AppError({ @@ -2067,19 +2069,23 @@ async function workflowRoutes(fastify) { }, }); - const transfer = await tx.approvalTransfer.create({ - data: { - reimbursement_id: workflowInstance.entity_id, - from_approver_id: request.employee.id, - to_approver_id: toApproverId, - reason, - status: "active", - created_by: request.employee.id, - }, - select: { - id: true, - }, - }); + let transferId = null; + if (workflowInstance.entity_type === "reimbursement") { + const transfer = await tx.approvalTransfer.create({ + data: { + reimbursement_id: workflowInstance.entity_id, + from_approver_id: request.employee.id, + to_approver_id: toApproverId, + reason, + status: "active", + created_by: request.employee.id, + }, + select: { + id: true, + }, + }); + transferId = transfer.id; + } await tx.workflowHistory.create({ data: { @@ -2137,8 +2143,8 @@ async function workflowRoutes(fastify) { taskId, outcome: "transferred", workflowInstanceId: workflowInstance.id, - reimbursementStatus: "reviewing", - transferId: transfer.id, + reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "reviewing" : null, + transferId, fromApproverId: request.employee.id, toApproverId, transferCount: transferCount + 1, diff --git a/server/tests/unit/routes/workflow.permissions.test.js b/server/tests/unit/routes/workflow.permissions.test.js index f1927a0f..b765f43d 100644 --- a/server/tests/unit/routes/workflow.permissions.test.js +++ b/server/tests/unit/routes/workflow.permissions.test.js @@ -55,7 +55,7 @@ describe("Workflow Route Permissions", () => { expect(actions).toEqual(["approve", "reject", "return"]); }); - it("keeps transfer action limited to reimbursement tasks", () => { + it("allows transfer on approval tasks when actor also has expense.transfer", () => { const reimbursementActions = listAllowedActionsForTask( { employee: { permissionCodes: ["expense.approve", "expense.transfer"] } }, { @@ -72,8 +72,39 @@ describe("Workflow Route Permissions", () => { }, true, ); + const purchaseActions = listAllowedActionsForTask( + { employee: { permissionCodes: ["procurement.approve", "expense.transfer"] } }, + { + status: "pending", + workflow_instance: { entity_type: "purchase_request" }, + }, + true, + ); + const travelActions = listAllowedActionsForTask( + { employee: { permissionCodes: ["travel.approve", "expense.transfer"] } }, + { + status: "pending", + workflow_instance: { entity_type: "travel_request" }, + }, + true, + ); expect(reimbursementActions).toContain("transfer"); - expect(leaveActions).not.toContain("transfer"); + expect(leaveActions).toContain("transfer"); + expect(purchaseActions).toContain("transfer"); + expect(travelActions).toContain("transfer"); + }); + + it("does not allow transfer without matching approve permission", () => { + const actions = listAllowedActionsForTask( + { employee: { permissionCodes: ["expense.transfer"] } }, + { + status: "pending", + workflow_instance: { entity_type: "leave" }, + }, + true, + ); + + expect(actions).toEqual([]); }); }); diff --git a/server/tests/unit/routes/workflow.transfer.test.js b/server/tests/unit/routes/workflow.transfer.test.js new file mode 100644 index 00000000..b8192631 --- /dev/null +++ b/server/tests/unit/routes/workflow.transfer.test.js @@ -0,0 +1,255 @@ +const Fastify = require("fastify"); + +const { AppError } = require("../../../src/utils/AppError"); +const workflowRoutes = require("../../../src/routes/workflow"); + +function buildTaskRow() { + return { + id: "11111111-1111-4111-8111-111111111111", + instance_id: "22222222-2222-4222-8222-222222222222", + step_id: "manager_approval", + step_name: "经理审批", + assignee_id: "actor-1", + assignee_role: null, + status: "pending", + }; +} + +function buildWorkflowInstance(entityType) { + return { + id: "22222222-2222-4222-8222-222222222222", + entity_type: entityType, + entity_id: "33333333-3333-4333-8333-333333333333", + current_step_id: "manager_approval", + context_data: {}, + workflow_definition: { + definition_json: { + steps: [], + }, + }, + }; +} + +function buildTarget(roleCode) { + return { + id: "44444444-4444-4444-8444-444444444444", + status: "active", + roles: [ + { + role_code: roleCode, + }, + ], + }; +} + +function buildTx({ entityType, targetRoleCode = "manager", transferCount = 0 }) { + return { + $queryRaw: vi.fn().mockResolvedValue([buildTaskRow()]), + workflowInstance: { + findUnique: vi.fn().mockResolvedValue(buildWorkflowInstance(entityType)), + }, + employee: { + findUnique: vi.fn().mockResolvedValue(buildTarget(targetRoleCode)), + }, + workflowTask: { + update: vi.fn().mockResolvedValue({ id: "11111111-1111-4111-8111-111111111111" }), + }, + workflowHistory: { + count: vi.fn().mockResolvedValue(transferCount), + create: vi.fn().mockResolvedValue({ id: "history-1" }), + }, + approvalTransfer: { + create: vi.fn().mockResolvedValue({ id: "transfer-1" }), + }, + }; +} + +function buildPrisma(tx) { + return { + $transaction: vi.fn().mockImplementation((callback) => callback(tx)), + }; +} + +async function buildApp({ prisma, employee }) { + const app = Fastify(); + + app.decorate("prisma", prisma); + app.decorate("config", {}); + app.decorate("authenticate", async (request) => { + if (!request.headers.authorization) { + throw new AppError({ + code: "AUTH_001", + message: "未登录", + statusCode: 401, + }); + } + + request.employee = employee; + }); + app.decorate("hasPermission", (actor, permissionCode) => { + return (actor?.permissionCodes ?? []).includes("*") || (actor?.permissionCodes ?? []).includes(permissionCode); + }); + app.decorate("requirePermission", (permissionCode) => async (request) => { + await app.authenticate(request); + if (!app.hasPermission(request.employee, permissionCode)) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + details: { permission: permissionCode }, + }); + } + }); + + app.setErrorHandler((error, request, reply) => { + if (error instanceof AppError) { + return reply.status(error.statusCode).send({ + requestId: request.id, + error: { + code: error.code, + message: error.message, + details: error.details ?? {}, + }, + }); + } + + return reply.status(500).send({ + requestId: request.id, + error: { code: "SYS_001", message: "服务器内部错误", details: {} }, + }); + }); + + await app.register(workflowRoutes, { prefix: "/api" }); + return app; +} + +describe("workflow transfer route", () => { + it.each(["leave", "purchase_request", "travel_request"])( + "allows transfer for %s when target has matching approve permission", + async (entityType) => { + const tx = buildTx({ entityType }); + const app = await buildApp({ + prisma: buildPrisma(tx), + employee: { + id: "actor-1", + roleCodes: [], + 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(200); + expect(response.json().data).toEqual( + expect.objectContaining({ + outcome: "transferred", + toApproverId: "44444444-4444-4444-8444-444444444444", + transferCount: 1, + transferId: null, + }), + ); + expect(tx.workflowTask.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: buildTaskRow().id }, + data: expect.objectContaining({ + assignee_id: "44444444-4444-4444-8444-444444444444", + assignee_role: null, + }), + }), + ); + expect(tx.workflowHistory.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + action: "transfer", + payload: expect.objectContaining({ + to_approver_id: "44444444-4444-4444-8444-444444444444", + transfer_count: 1, + }), + }), + }), + ); + expect(tx.approvalTransfer.create).not.toHaveBeenCalled(); + + await app.close(); + }, + ); + + it("rejects target approver without matching entity approve permission", async () => { + const tx = buildTx({ entityType: "leave", targetRoleCode: "finance" }); + const app = await buildApp({ + prisma: buildPrisma(tx), + employee: { + id: "actor-1", + roleCodes: [], + 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(409); + expect(response.json().error).toEqual( + expect.objectContaining({ + code: "VALID_001", + details: expect.objectContaining({ + toApproverId: "target_not_approver", + }), + }), + ); + expect(tx.workflowTask.update).not.toHaveBeenCalled(); + + await app.close(); + }); + + it("returns WORKFLOW_003 when transfer chain exceeds limit", async () => { + const tx = buildTx({ entityType: "leave", transferCount: 3 }); + const app = await buildApp({ + prisma: buildPrisma(tx), + employee: { + id: "actor-1", + roleCodes: [], + 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(409); + expect(response.json().error).toEqual( + expect.objectContaining({ + code: "WORKFLOW_003", + details: expect.objectContaining({ + transfer: "chain_limit_exceeded", + }), + }), + ); + expect(tx.workflowTask.update).not.toHaveBeenCalled(); + expect(tx.workflowHistory.create).not.toHaveBeenCalled(); + + await app.close(); + }); +});