const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { resolvePermissionsForRoles, hasPermission, ROLE_PERMISSIONS } = require("../constants/permissions"); const { getWorkflowStepsSnapshot, resolveActionableStep, toMoneyString } = require("../utils/workflow"); const { findActiveDelegationForDelegate, listActiveDelegationPrincipals, } = require("../utils/delegation"); const TASK_ID_SCHEMA = z.string().uuid(); const REQUIRED_COMMENT_SCHEMA = z.object({ comment: z.string().trim().min(1).max(500), }); const TRANSFER_BODY_SCHEMA = z.object({ toApproverId: z.string().uuid(), reason: z.string().max(500), }); const TASK_LIST_QUERY_SCHEMA = z.object({ status: z.enum(["pending", "completed", "rejected", "cancelled", "skipped"]).optional(), page: z.coerce.number().int().min(1).optional(), pageSize: z.coerce.number().int().min(1).max(100).optional(), }); const APPROVER_LIST_QUERY_SCHEMA = z.object({ keyword: z.string().trim().max(100).optional(), page: z.coerce.number().int().min(1).optional(), pageSize: z.coerce.number().int().min(1).max(100).optional(), }); const APPROVER_ROLE_CODES = Object.keys(ROLE_PERMISSIONS).filter((roleCode) => hasPermission(ROLE_PERMISSIONS[roleCode], "expense.approve"), ); function parseRequiredComment(body) { const parsed = REQUIRED_COMMENT_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } return parsed.data.comment; } function parseTransferBody(body) { const parsed = TRANSFER_BODY_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } const reason = parsed.data.reason.trim(); if (reason.length < 10) { throw new AppError({ code: "VALID_002", message: "转审原因不足 10 字", statusCode: 400, details: { reason: "too_short" }, }); } return { toApproverId: parsed.data.toApproverId, reason, }; } function parseTaskId(taskId) { const parsed = TASK_ID_SCHEMA.safeParse(taskId); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { taskId: "invalid_uuid" }, }); } return parsed.data; } function parseTaskListQuery(query) { const parsed = TASK_LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } return { status: parsed.data.status ?? "pending", page: parsed.data.page ?? 1, pageSize: parsed.data.pageSize ?? 20, }; } function parseApproverListQuery(query) { const parsed = APPROVER_LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } return { keyword: parsed.data.keyword ?? "", page: parsed.data.page ?? 1, pageSize: parsed.data.pageSize ?? 20, }; } function isoOrNull(value) { if (!value) { return null; } return value.toISOString(); } function mapTaskSummary(task) { const workflowInstance = task.workflow_instance ? { entity_type: task.workflow_instance.entity_type, entity_id: task.workflow_instance.entity_id, current_status: task.workflow_instance.current_status, } : null; return { id: task.id, instance_id: task.instance_id, step_id: task.step_id, step_name: task.step_name, assignee_id: task.assignee_id, assignee_role: task.assignee_role, status: task.status, outcome: task.outcome, created_at: isoOrNull(task.created_at), completed_at: isoOrNull(task.completed_at), workflow_instance: workflowInstance, instanceId: task.instance_id, stepId: task.step_id, stepName: task.step_name, assigneeId: task.assignee_id, assigneeRole: task.assignee_role, createdAt: isoOrNull(task.created_at), completedAt: isoOrNull(task.completed_at), workflowInstance: workflowInstance ? { entityType: workflowInstance.entity_type, entityId: workflowInstance.entity_id, currentStatus: workflowInstance.current_status, } : null, }; } async function buildTaskVisibilityFilters(tx, request) { const roleFilters = request.employee.roleCodes.length ? [{ assignee_role: { in: request.employee.roleCodes } }] : []; const delegatedPrincipalIds = await listActiveDelegationPrincipals(tx, { delegateEmployeeId: request.employee.id, allowedScopes: ["all"], }); const assigneeFilters = [{ assignee_id: request.employee.id }, ...roleFilters]; if (delegatedPrincipalIds.length) { assigneeFilters.push({ assignee_id: { in: delegatedPrincipalIds } }); } return assigneeFilters; } function listAllowedActionsForTask(request, task, actionable) { if (task.status !== "pending" || !actionable) { return []; } const actions = []; if (hasPermission(request.employee.permissionCodes, "expense.approve")) { actions.push("approve", "reject", "return"); } if (hasPermission(request.employee.permissionCodes, "expense.transfer")) { actions.push("transfer"); } return actions; } async function lockWorkflowTask(tx, taskId) { const taskRows = await tx.$queryRaw` SELECT id, instance_id, step_id, step_name, assignee_id, assignee_role, status FROM "workflow_tasks" WHERE id = ${taskId}::uuid FOR UPDATE `; if (!Array.isArray(taskRows) || taskRows.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } return taskRows[0]; } function ensurePendingTask(task) { if (task.status !== "pending") { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { task: "already_processed" }, }); } } async function ensureTaskAssignableToCurrentEmployee(tx, task, request) { const matchesAssigneeId = Boolean(task.assignee_id && task.assignee_id === request.employee.id); const matchesAssigneeRole = Boolean( task.assignee_role && request.employee.roleCodes.includes(task.assignee_role), ); const isUnassigned = !task.assignee_id && !task.assignee_role; if (matchesAssigneeId || matchesAssigneeRole || isUnassigned) { return { delegated: false, delegationId: null, principalId: null, }; } if (task.assignee_id) { const delegation = await findActiveDelegationForDelegate(tx, { principalEmployeeId: task.assignee_id, delegateEmployeeId: request.employee.id, allowedScopes: ["all"], }); if (delegation) { return { delegated: true, delegationId: delegation.id, principalId: delegation.principal_employee_id, }; } } throw new AppError({ code: "PERM_002", message: "不在审批范围/非当前处理人", statusCode: 403, }); } function withDelegationPayload(payload, assignment, delegateId) { const nextPayload = payload ? { ...payload } : {}; if (assignment?.delegated) { nextPayload.delegation_id = assignment.delegationId; nextPayload.principal_id = assignment.principalId; nextPayload.delegate_id = delegateId; } return nextPayload; } async function loadWorkflowInstanceForTask(tx, instanceId) { const workflowInstance = await tx.workflowInstance.findUnique({ where: { id: instanceId }, select: { id: true, entity_id: true, current_step_id: true, context_data: true, workflow_definition: { select: { definition_json: true, }, }, }, }); if (!workflowInstance) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } return workflowInstance; } async function createSkipHistoryRecords(tx, workflowInstanceId, actorId, ownerId, skippedSteps) { for (const skipped of skippedSteps) { await tx.workflowHistory.create({ data: { instance_id: workflowInstanceId, actor_id: actorId, action: "skip", payload: { step_id: skipped.step.id, reason: skipped.reason, owner_id: ownerId, depth_checked: skipped.depthChecked, }, }, }); } } async function cancelOtherPendingTasks(tx, workflowInstanceId, taskId, actorId) { await tx.workflowTask.updateMany({ where: { instance_id: workflowInstanceId, status: "pending", NOT: { id: taskId }, }, data: { status: "cancelled", completed_at: new Date(), completed_by: actorId, }, }); } async function ensureTransferChainNotExceeded(tx, taskId) { const transferCount = await tx.workflowHistory.count({ where: { task_id: taskId, action: "transfer", }, }); if (transferCount >= 3) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { transfer: "chain_limit_exceeded" }, }); } return transferCount; } async function ensureTransferTargetEligible(tx, toApproverId) { const target = await tx.employee.findUnique({ where: { id: toApproverId }, select: { id: true, status: true, roles: { select: { role_code: true, }, }, }, }); if (!target || target.status !== "active") { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { toApproverId: "invalid_target" }, }); } const roleCodes = target.roles.map((item) => item.role_code); const permissionCodes = resolvePermissionsForRoles(roleCodes); if (!hasPermission(permissionCodes, "expense.approve")) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { toApproverId: "target_not_approver" }, }); } } async function workflowRoutes(fastify) { fastify.get( "/workflow/tasks", { preHandler: fastify.authenticate, }, async (request, reply) => { const query = parseTaskListQuery(request.query); const skip = (query.page - 1) * query.pageSize; const assigneeFilters = await buildTaskVisibilityFilters(fastify.prisma, request); const where = { status: query.status, OR: assigneeFilters, }; const [tasks, total] = await Promise.all([ fastify.prisma.workflowTask.findMany({ where, orderBy: { created_at: "desc" }, skip, take: query.pageSize, select: { id: true, instance_id: true, step_id: true, step_name: true, assignee_id: true, assignee_role: true, status: true, outcome: true, created_at: true, completed_at: true, workflow_instance: { select: { entity_type: true, entity_id: true, current_status: true, }, }, }, }), fastify.prisma.workflowTask.count({ where }), ]); return sendData(reply, request, { page: query.page, pageSize: query.pageSize, total, items: tasks.map(mapTaskSummary), }); }, ); fastify.get( "/workflow/tasks/:taskId", { preHandler: fastify.authenticate, }, async (request, reply) => { const taskId = parseTaskId(request.params?.taskId); const assigneeFilters = await buildTaskVisibilityFilters(fastify.prisma, request); const task = await fastify.prisma.workflowTask.findFirst({ where: { id: taskId, OR: assigneeFilters, }, select: { id: true, instance_id: true, step_id: true, step_name: true, assignee_id: true, assignee_role: true, status: true, outcome: true, comment: true, created_at: true, completed_at: true, workflow_instance: { select: { id: true, current_status: true, current_step_id: true, started_at: true, completed_at: true, reimbursement_form: { select: { id: true, form_number: true, status: true, total_amount: true, reimbursement_date: true, description: true, owner: { select: { id: true, employee_no: true, name: true, }, }, submitter: { select: { id: true, employee_no: true, name: true, }, }, }, }, }, }, }, }); if (!task) { const existing = await fastify.prisma.workflowTask.findUnique({ where: { id: taskId }, select: { id: true }, }); if (existing) { throw new AppError({ code: "PERM_002", message: "不在审批范围/非当前处理人", statusCode: 403, }); } throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } let actionable = false; if (task.status === "pending") { try { await ensureTaskAssignableToCurrentEmployee(fastify.prisma, task, request); actionable = true; } catch (error) { if (error instanceof AppError && error.code === "PERM_002") { actionable = false; } else { throw error; } } } const historyItems = await fastify.prisma.workflowHistory.findMany({ where: { instance_id: task.instance_id }, orderBy: { created_at: "asc" }, select: { id: true, task_id: true, actor_id: true, action: true, comment: true, payload: true, created_at: true, actor: { select: { id: true, name: true, employee_no: true, }, }, }, }); return sendData(reply, request, { task: { id: task.id, instanceId: task.instance_id, stepId: task.step_id, stepName: task.step_name, assigneeId: task.assignee_id, assigneeRole: task.assignee_role, status: task.status, outcome: task.outcome, comment: task.comment, createdAt: isoOrNull(task.created_at), completedAt: isoOrNull(task.completed_at), }, workflowInstance: task.workflow_instance ? { id: task.workflow_instance.id, currentStatus: task.workflow_instance.current_status, currentStepId: task.workflow_instance.current_step_id, startedAt: isoOrNull(task.workflow_instance.started_at), completedAt: isoOrNull(task.workflow_instance.completed_at), } : null, reimbursement: task.workflow_instance?.reimbursement_form ? { id: task.workflow_instance.reimbursement_form.id, formNumber: task.workflow_instance.reimbursement_form.form_number, status: task.workflow_instance.reimbursement_form.status, reimbursementDate: task.workflow_instance.reimbursement_form.reimbursement_date ?.toISOString?.() ?.slice(0, 10) ?? null, totalAmount: toMoneyString(task.workflow_instance.reimbursement_form.total_amount), description: task.workflow_instance.reimbursement_form.description, owner: task.workflow_instance.reimbursement_form.owner ? { id: task.workflow_instance.reimbursement_form.owner.id, employeeNo: task.workflow_instance.reimbursement_form.owner.employee_no, name: task.workflow_instance.reimbursement_form.owner.name, } : null, submitter: task.workflow_instance.reimbursement_form.submitter ? { id: task.workflow_instance.reimbursement_form.submitter.id, employeeNo: task.workflow_instance.reimbursement_form.submitter.employee_no, name: task.workflow_instance.reimbursement_form.submitter.name, } : null, } : null, allowedActions: listAllowedActionsForTask(request, task, actionable), history: historyItems.map((item) => ({ id: item.id, taskId: item.task_id, actorId: item.actor_id, actorName: item.actor?.name ?? "", actorEmployeeNo: item.actor?.employee_no ?? "", action: item.action, comment: item.comment, payload: item.payload, createdAt: isoOrNull(item.created_at), })), }); }, ); fastify.get( "/workflow/approvers", { preHandler: fastify.requirePermission("expense.transfer"), }, async (request, reply) => { const query = parseApproverListQuery(request.query); const skip = (query.page - 1) * query.pageSize; const where = { status: "active", id: { not: request.employee.id }, roles: { some: { role_code: { in: APPROVER_ROLE_CODES }, }, }, ...(query.keyword ? { OR: [ { name: { contains: query.keyword, mode: "insensitive" } }, { email: { contains: query.keyword, mode: "insensitive" } }, { employee_no: { contains: query.keyword, mode: "insensitive" } }, ], } : {}), }; const [items, total] = await Promise.all([ fastify.prisma.employee.findMany({ where, orderBy: { name: "asc" }, skip, take: query.pageSize, select: { id: true, employee_no: true, name: true, email: true, roles: { select: { role_code: true, }, }, }, }), fastify.prisma.employee.count({ where }), ]); return sendData(reply, request, { page: query.page, pageSize: query.pageSize, total, items: items.map((item) => ({ id: item.id, employeeNo: item.employee_no, name: item.name, email: item.email, roleCodes: item.roles.map((role) => role.role_code), })), }); }, ); fastify.post( "/workflow/tasks/:taskId/approve", { preHandler: fastify.requirePermission("expense.approve"), }, async (request, reply) => { const taskId = parseTaskId(request.params?.taskId); let result = null; await fastify.prisma.$transaction(async (tx) => { const task = await lockWorkflowTask(tx, taskId); ensurePendingTask(task); const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request); const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id); const steps = getWorkflowStepsSnapshot(workflowInstance); const currentStepIndex = steps.findIndex((step) => step.id === workflowInstance.current_step_id); if (currentStepIndex < 0) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { workflow: "current_step_not_found" }, }); } const now = new Date(); await tx.workflowTask.update({ where: { id: taskId }, data: { status: "completed", outcome: "approved", completed_at: now, completed_by: request.employee.id, }, }); const { actionable, skipped } = await resolveActionableStep({ tx, steps, startIndex: currentStepIndex + 1, ownerId: workflowInstance.context_data?.owner_id, logger: fastify.log, }); await createSkipHistoryRecords( tx, workflowInstance.id, request.employee.id, workflowInstance.context_data?.owner_id, skipped, ); if (actionable) { const nextTask = await tx.workflowTask.create({ data: { instance_id: workflowInstance.id, step_id: actionable.step.id, step_name: actionable.step.name ?? actionable.step.id, assignee_id: actionable.assigneeId, assignee_role: actionable.assigneeRole, }, select: { id: true }, }); await tx.workflowInstance.update({ where: { id: workflowInstance.id }, data: { current_step_id: actionable.step.id, current_status: "pending", updated_by: request.employee.id, }, }); await tx.workflowHistory.create({ data: { instance_id: workflowInstance.id, task_id: taskId, actor_id: request.employee.id, action: "approve", payload: withDelegationPayload( { from_step_id: task.step_id, to_step_id: actionable.step.id, }, assignment, request.employee.id, ), }, }); result = { taskId, outcome: "approved", workflowInstanceId: workflowInstance.id, nextTaskId: nextTask.id, reimbursementStatus: "reviewing", }; return; } await tx.workflowInstance.update({ where: { id: workflowInstance.id }, data: { current_status: "approved", completed_at: now, updated_by: request.employee.id, }, }); await tx.reimbursementForm.update({ where: { id: workflowInstance.entity_id }, data: { status: "approved", updated_by: request.employee.id, }, }); await tx.workflowHistory.create({ data: { instance_id: workflowInstance.id, task_id: taskId, actor_id: request.employee.id, action: "approve", payload: withDelegationPayload( { from_step_id: task.step_id, terminal: true, }, assignment, request.employee.id, ), }, }); result = { taskId, outcome: "approved", workflowInstanceId: workflowInstance.id, nextTaskId: null, reimbursementStatus: "approved", }; }); return sendData(reply, request, result); }, ); fastify.post( "/workflow/tasks/:taskId/reject", { preHandler: fastify.requirePermission("expense.approve"), }, async (request, reply) => { const taskId = parseTaskId(request.params?.taskId); const comment = parseRequiredComment(request.body); let result = null; await fastify.prisma.$transaction(async (tx) => { const task = await lockWorkflowTask(tx, taskId); ensurePendingTask(task); const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request); const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id); const now = new Date(); await tx.workflowTask.update({ where: { id: taskId }, data: { status: "rejected", outcome: "rejected", comment, completed_at: now, completed_by: request.employee.id, }, }); await cancelOtherPendingTasks(tx, workflowInstance.id, taskId, request.employee.id); await tx.workflowInstance.update({ where: { id: workflowInstance.id }, data: { current_status: "rejected", completed_at: now, updated_by: request.employee.id, }, }); await tx.reimbursementForm.update({ where: { id: workflowInstance.entity_id }, data: { status: "rejected", updated_by: request.employee.id, }, }); await tx.workflowHistory.create({ data: { instance_id: workflowInstance.id, task_id: taskId, actor_id: request.employee.id, action: "reject", comment, payload: withDelegationPayload( { from_step_id: task.step_id, }, assignment, request.employee.id, ), }, }); result = { taskId, outcome: "rejected", workflowInstanceId: workflowInstance.id, reimbursementStatus: "rejected", }; }); return sendData(reply, request, result); }, ); fastify.post( "/workflow/tasks/:taskId/return", { preHandler: fastify.requirePermission("expense.approve"), }, async (request, reply) => { const taskId = parseTaskId(request.params?.taskId); const comment = parseRequiredComment(request.body); let result = null; await fastify.prisma.$transaction(async (tx) => { const task = await lockWorkflowTask(tx, taskId); ensurePendingTask(task); const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request); const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id); const now = new Date(); await tx.workflowTask.update({ where: { id: taskId }, data: { status: "completed", outcome: "returned", comment, completed_at: now, completed_by: request.employee.id, }, }); await cancelOtherPendingTasks(tx, workflowInstance.id, taskId, request.employee.id); await tx.workflowInstance.update({ where: { id: workflowInstance.id }, data: { current_status: "terminated", completed_at: now, updated_by: request.employee.id, }, }); await tx.reimbursementForm.update({ where: { id: workflowInstance.entity_id }, data: { status: "draft", updated_by: request.employee.id, }, }); await tx.workflowHistory.create({ data: { instance_id: workflowInstance.id, task_id: taskId, actor_id: request.employee.id, action: "return", comment, payload: withDelegationPayload( { from_step_id: task.step_id, }, assignment, request.employee.id, ), }, }); result = { taskId, outcome: "returned", workflowInstanceId: workflowInstance.id, reimbursementStatus: "draft", }; }); return sendData(reply, request, result); }, ); fastify.post( "/workflow/tasks/:taskId/transfer", { preHandler: fastify.requirePermission("expense.transfer"), }, async (request, reply) => { const taskId = parseTaskId(request.params?.taskId); const { toApproverId, reason } = parseTransferBody(request.body); let result = null; await fastify.prisma.$transaction(async (tx) => { const task = await lockWorkflowTask(tx, taskId); ensurePendingTask(task); const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request); const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id); const transferCount = await ensureTransferChainNotExceeded(tx, taskId); await ensureTransferTargetEligible(tx, toApproverId); if (toApproverId === request.employee.id) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { transfer: "same_assignee_not_allowed" }, }); } await tx.workflowTask.update({ where: { id: taskId }, data: { assignee_id: toApproverId, assignee_role: null, }, }); 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, }, }); await tx.workflowHistory.create({ data: { instance_id: workflowInstance.id, task_id: taskId, actor_id: request.employee.id, action: "transfer", comment: reason, payload: withDelegationPayload( { from_approver_id: request.employee.id, to_approver_id: toApproverId, reason, transfer_count: transferCount + 1, }, assignment, request.employee.id, ), }, }); result = { taskId, outcome: "transferred", workflowInstanceId: workflowInstance.id, reimbursementStatus: "reviewing", transferId: transfer.id, fromApproverId: request.employee.id, toApproverId, transferCount: transferCount + 1, }; }); return sendData(reply, request, result); }, ); } module.exports = workflowRoutes;