const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { buildAuditLogData } = require("../utils/audit"); const { sendData } = require("../utils/reply"); const { resolvePermissionsForRoles, hasPermission, ROLE_PERMISSIONS, resolveEntityPermission } = require("../constants/permissions"); const { getWorkflowStepsSnapshot, resolveActionableStep, toMoneyString } = require("../utils/workflow"); const { consumeReservedLeave, releaseReservedLeave, formatDateOnly } = require("../services/leave"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { confirmBudgetForEntity, releaseReservedBudgetIfAny } = require("../services/purchaseBudget"); const { PROCUREMENT_ERP_TARGETS, enqueuePurchaseCommitmentJournalExport, loadPurchaseRequestErpContext, } = require("../services/integrations/erpProcurement"); const { createNotificationsWithRecords, resolveRecipientsForAssignee, } = require("../services/notifications"); const { buildApprovedCopy, buildPendingTaskCopy, buildRejectedCopy, buildReturnedCopy, buildWorkflowEntityMeta, normalizeWorkflowEntityType, } = require("../services/notifications/workflowCopy"); const { sendNotificationEmailSafe } = require("../services/email"); const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher"); 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 BATCH_APPROVE_BODY_SCHEMA = z.object({ taskIds: z.array(z.string().uuid()).min(1).max(50), comment: z.string().trim().max(500).optional(), }); const BATCH_REJECT_BODY_SCHEMA = z.object({ taskIds: z.array(z.string().uuid()).min(1).max(50), comment: z.string().trim().min(1).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) => ROLE_PERMISSIONS[roleCode].some((permissionCode) => permissionCode.endsWith(".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 parseBatchApproveBody(body) { const parsed = BATCH_APPROVE_BODY_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } return { taskIds: Array.from(new Set(parsed.data.taskIds)), comment: parsed.data.comment?.trim() || null, }; } function parseBatchRejectBody(body) { const parsed = BATCH_REJECT_BODY_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } return { taskIds: Array.from(new Set(parsed.data.taskIds)), comment: parsed.data.comment.trim(), }; } async function handlePurchaseApprovalErpSync({ tx, config, request, actorId, purchaseRequestId, loadContext = loadPurchaseRequestErpContext, evaluateGate = evaluateFeatureFlag, enqueueCommitment = enqueuePurchaseCommitmentJournalExport, }) { const context = await loadContext(tx, purchaseRequestId); if (!context?.purchaseRequest) { return { skipped: true, reason: 'purchase_request_not_found' }; } const purchaseRequest = context.purchaseRequest; const departmentId = purchaseRequest.department_id ?? request.employee.department_id ?? null; const gate = await evaluateGate({ prisma: tx, config, flagKey: FEATURE_FLAG_KEYS.erpSync, departmentId, strictDepartment: true, }); if (!gate.enabled) { await tx.auditLog.create({ data: buildAuditLogData(request, { actorId, action: 'ERP_SYNC_SKIPPED_BY_FEATURE_FLAG', targetType: 'purchase_request', targetId: purchaseRequest.id, extra: { erpTarget: PROCUREMENT_ERP_TARGETS.commitmentJournal, eventType: 'erp.journal.export', purchaseRequestId: purchaseRequest.id, purchaseOrderArchiveId: null, gateReason: gate.reason, featureFlag: FEATURE_FLAG_KEYS.erpSync, departmentId, }, }), }); return { skipped: true, gate, purchaseRequest }; } const enqueued = await enqueueCommitment(tx, config, { purchaseRequest, occurredAt: context.approvalOccurredAt ?? new Date(), actorId, requeueOnFailure: false, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId, action: 'ERP_SYNC_ENQUEUED', targetType: 'purchase_request', targetId: purchaseRequest.id, extra: { erpTarget: PROCUREMENT_ERP_TARGETS.commitmentJournal, eventType: enqueued.eventType, purchaseRequestId: purchaseRequest.id, purchaseOrderArchiveId: null, outboxId: enqueued.id, duplicated: enqueued.duplicated, requeued: enqueued.requeued, gateReason: gate.reason, featureFlag: FEATURE_FLAG_KEYS.erpSync, departmentId, }, }), }); return { skipped: false, gate, purchaseRequest, enqueued, }; } 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, allowedActions = []) { 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), allowedActions, 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 = []; const approvePermission = resolveEntityPermission(task.workflow_instance?.entity_type, "approve"); const canApprove = approvePermission && hasPermission(request.employee.permissionCodes, approvePermission); if (canApprove) { actions.push("approve", "reject", "return"); } if (canApprove && 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_type: 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 loadReimbursementNotifyContext(tx, reimbursementId) { return tx.reimbursementForm.findUnique({ where: { id: reimbursementId }, select: { id: true, form_number: true, total_amount: true, owner_id: true, submitter_id: true, owner: { select: { id: true, name: true, }, }, }, }); } function uniq(values) { return Array.from(new Set((values ?? []).filter(Boolean))); } async function loadLeaveNotifyContext(tx, leaveRequestId) { return tx.leaveRequest.findUnique({ where: { id: leaveRequestId }, select: { id: true, owner_id: true, start_date: true, end_date: true, requested_days: true, leave_type: { select: { id: true, code: true, name: true, }, }, owner: { select: { id: true, name: true, }, }, }, }); } async function loadPurchaseNotifyContext(tx, purchaseRequestId) { return tx.purchaseRequest.findUnique({ where: { id: purchaseRequestId }, select: { id: true, request_no: true, total_amount: true, applicant_id: true, submitter_id: true, applicant: { select: { id: true, name: true, }, }, }, }); } async function loadTravelNotifyContext(tx, travelRequestId) { return tx.travelRequest.findUnique({ where: { id: travelRequestId }, select: { id: true, request_no: true, estimated_amount: true, applicant_employee_id: true, submitter_id: true, applicant: { select: { id: true, name: true, }, }, }, }); } async function loadWorkflowNotifyContext(tx, entityType, entityId) { const normalizedEntityType = normalizeWorkflowEntityType(entityType); if (normalizedEntityType === 'reimbursement') { const record = await loadReimbursementNotifyContext(tx, entityId); return { record, recipientEmployeeIds: uniq([record?.owner_id, record?.submitter_id]), }; } if (normalizedEntityType === 'leave') { const record = await loadLeaveNotifyContext(tx, entityId); return { record, recipientEmployeeIds: uniq([record?.owner_id]), }; } if (normalizedEntityType === 'purchase_request') { const record = await loadPurchaseNotifyContext(tx, entityId); return { record, recipientEmployeeIds: uniq([record?.applicant_id, record?.submitter_id]), }; } if (normalizedEntityType === 'travel_request') { const record = await loadTravelNotifyContext(tx, entityId); return { record, recipientEmployeeIds: uniq([record?.applicant_employee_id, record?.submitter_id]), }; } return { record: null, recipientEmployeeIds: [], }; } function resolveWorkflowOutcomeNotificationType(entityType, outcome) { const normalizedEntityType = normalizeWorkflowEntityType(entityType); if (outcome === 'approved') { if (normalizedEntityType === 'reimbursement') return NOTIFICATION_TYPES.reimbursementApproved; if (normalizedEntityType === 'leave') return NOTIFICATION_TYPES.leaveApproved; if (normalizedEntityType === 'purchase_request') return NOTIFICATION_TYPES.purchaseRequestApproved; if (normalizedEntityType === 'travel_request') return NOTIFICATION_TYPES.travelRequestApproved; } if (outcome === 'rejected') { if (normalizedEntityType === 'reimbursement') return NOTIFICATION_TYPES.reimbursementRejected; if (normalizedEntityType === 'leave') return NOTIFICATION_TYPES.leaveRejected; if (normalizedEntityType === 'purchase_request') return NOTIFICATION_TYPES.purchaseRequestRejected; if (normalizedEntityType === 'travel_request') return NOTIFICATION_TYPES.travelRequestRejected; } if (outcome === 'returned') { if (normalizedEntityType === 'reimbursement') return NOTIFICATION_TYPES.reimbursementReturned; } return null; } async function queueTaskAssignedNotifications({ tx, actorId, emailTasks, dispatchTasks, recipientEmployeeIds, title, body, taskId, entityType, entityId, }) { const recipients = uniq(recipientEmployeeIds); if (!recipients.length) { return; } const notifications = await createNotificationsWithRecords(tx, { recipientEmployeeIds: recipients, type: NOTIFICATION_TYPES.workflowTaskAssigned, title, body, targetType: 'workflow_task', targetId: taskId, }); dispatchTasks.push({ notifications, type: NOTIFICATION_TYPES.workflowTaskAssigned, title, body, targetType: 'workflow_task', targetId: taskId, targetPath: `/workflow/tasks/${taskId}`, actorId, }); emailTasks.push({ type: NOTIFICATION_TYPES.workflowTaskAssigned, recipientEmployeeIds: recipients, subject: title, body, targetPath: `/workflow/tasks/${taskId}`, }); } async function queueWorkflowOutcomeNotifications({ tx, actorId, emailTasks, dispatchTasks, workflowInstance, outcome, comment = null, }) { const normalizedEntityType = normalizeWorkflowEntityType(workflowInstance.entity_type); const notificationType = resolveWorkflowOutcomeNotificationType(normalizedEntityType, outcome); if (!notificationType) { return; } const context = await loadWorkflowNotifyContext(tx, normalizedEntityType, workflowInstance.entity_id); const recipients = uniq(context.recipientEmployeeIds); if (!recipients.length) { return; } const entityMeta = buildWorkflowEntityMeta(normalizedEntityType, context.record, workflowInstance.entity_id); const copy = outcome === 'approved' ? buildApprovedCopy(entityMeta) : outcome === 'rejected' ? buildRejectedCopy(entityMeta, comment) : buildReturnedCopy(entityMeta, comment); const notifications = await createNotificationsWithRecords(tx, { recipientEmployeeIds: recipients, type: notificationType, title: copy.title, body: copy.body, targetType: entityMeta.targetType, targetId: workflowInstance.entity_id, }); dispatchTasks.push({ notifications, type: notificationType, title: copy.title, body: copy.body, targetType: entityMeta.targetType, targetId: workflowInstance.entity_id, targetPath: entityMeta.detailPath, actorId, }); emailTasks.push({ type: notificationType, recipientEmployeeIds: recipients, subject: copy.title, body: copy.body, targetPath: entityMeta.detailPath, }); } 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: "WORKFLOW_003", message: "已达转审上限", statusCode: 409, details: { transfer: "chain_limit_exceeded" }, }); } return transferCount; } async function ensureTransferTargetEligible(tx, toApproverId, approvePermission) { 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 (!approvePermission || !hasPermission(permissionCodes, approvePermission)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { toApproverId: "target_not_approver" }, }); } } async function flushWorkflowSideEffects({ fastify, request, emailTasks, dispatchTasks }) { for (const task of emailTasks) { void sendNotificationEmailSafe({ prisma: fastify.prisma, config: fastify.config, logger: fastify.log, requestId: request.id, ...task, }); } await flushNotificationDispatchTasks({ prisma: fastify.prisma, config: fastify.config, logger: fastify.log, requestId: request.id, tasks: dispatchTasks, }); } function ensureWorkflowEntityType(workflowInstance, entityType) { if (!entityType || workflowInstance.entity_type === entityType) { return; } throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { task: "unsupported_entity_type", entityType: workflowInstance.entity_type, expectedEntityType: entityType, }, }); } async function executeApproveWorkflowTask({ fastify, request, taskId, allowedEntityType = null, }) { let result = null; const emailTasks = []; const dispatchTasks = []; 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); ensureWorkflowEntityType(workflowInstance, allowedEntityType); fastify.assertEntityPermission(request.employee, workflowInstance.entity_type, "approve"); 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, ), }, }); try { const notificationContext = await loadWorkflowNotifyContext( tx, workflowInstance.entity_type, workflowInstance.entity_id, ); const entityMeta = buildWorkflowEntityMeta( workflowInstance.entity_type, notificationContext.record, workflowInstance.entity_id, ); const pendingCopy = buildPendingTaskCopy(entityMeta); const recipients = await resolveRecipientsForAssignee(tx, { assigneeId: actionable.assigneeId, assigneeRole: actionable.assigneeRole, }); await queueTaskAssignedNotifications({ tx, actorId: request.employee.id, emailTasks, dispatchTasks, recipientEmployeeIds: recipients, title: pendingCopy.title, body: pendingCopy.body, taskId: nextTask.id, entityType: workflowInstance.entity_type, entityId: workflowInstance.entity_id, }); } catch (error) { fastify.log.error( { err: error, taskId: nextTask.id }, "Failed to create task assigned notifications", ); } result = { taskId, outcome: "approved", workflowInstanceId: workflowInstance.id, nextTaskId: nextTask.id, reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "reviewing" : null, }; return; } await tx.workflowInstance.update({ where: { id: workflowInstance.id }, data: { current_status: "approved", completed_at: now, updated_by: request.employee.id, }, }); if (workflowInstance.entity_type === "reimbursement") { await tx.reimbursementForm.update({ where: { id: workflowInstance.entity_id }, data: { status: "approved", updated_by: request.employee.id, }, }); } else if (workflowInstance.entity_type === "leave") { const leaveRequest = await tx.leaveRequest.findUnique({ where: { id: workflowInstance.entity_id }, select: { id: true, owner_id: true, leave_type_id: true, requested_days: true, }, }); const reserveAnchor = await tx.leaveLedger.findFirst({ where: { leave_request_id: workflowInstance.entity_id, entry_type: "reserve", }, orderBy: { created_at: "desc" }, select: { leave_policy_id: true, period_start: true, period_end: true, }, }); await tx.leaveRequest.update({ where: { id: workflowInstance.entity_id }, data: { status: "approved", updated_by: request.employee.id, }, }); if (leaveRequest) { await consumeReservedLeave(tx, { leaveRequestId: workflowInstance.entity_id, employeeId: leaveRequest.owner_id, leaveTypeId: leaveRequest.leave_type_id, leavePolicyId: reserveAnchor?.leave_policy_id ?? null, periodStart: reserveAnchor?.period_start ?? null, periodEnd: reserveAnchor?.period_end ?? null, requestedDays: leaveRequest.requested_days, createdBy: request.employee.id, }); } } else if (workflowInstance.entity_type === "purchase_request") { await tx.purchaseRequest.update({ where: { id: workflowInstance.entity_id }, data: { status: "approved", updated_by: request.employee.id, }, }); await confirmBudgetForEntity(tx, { entityType: "purchase_request", entityId: workflowInstance.entity_id, actorId: request.employee.id, note: "approved_confirm_purchase_budget", }); try { await handlePurchaseApprovalErpSync({ tx, config: fastify.config, request, actorId: request.employee.id, purchaseRequestId: workflowInstance.entity_id, }); } catch (error) { fastify.log.error( { err: error, purchaseRequestId: workflowInstance.entity_id }, "Failed to enqueue purchase approval ERP events", ); } } else if (workflowInstance.entity_type === "travel_request") { await tx.travelRequest.update({ where: { id: workflowInstance.entity_id }, data: { status: "approved", updated_by: request.employee.id, }, }); await confirmBudgetForEntity(tx, { entityType: "travel_request", entityId: workflowInstance.entity_id, actorId: request.employee.id, note: "approved_confirm_travel_budget", }); } 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, ), }, }); try { await queueWorkflowOutcomeNotifications({ tx, actorId: request.employee.id, emailTasks, dispatchTasks, workflowInstance, outcome: "approved", }); } catch (error) { fastify.log.error( { err: error, taskId, entityType: workflowInstance.entity_type, entityId: workflowInstance.entity_id }, "Failed to create approval outcome notifications", ); } result = { taskId, outcome: "approved", workflowInstanceId: workflowInstance.id, nextTaskId: null, reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "approved" : null, purchaseRequestStatus: workflowInstance.entity_type === "purchase_request" ? "approved" : null, travelRequestStatus: workflowInstance.entity_type === "travel_request" ? "approved" : null, }; }); return { result, emailTasks, dispatchTasks }; } async function executeRejectWorkflowTask({ fastify, request, taskId, comment, allowedEntityType = null, }) { let result = null; const emailTasks = []; const dispatchTasks = []; 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); ensureWorkflowEntityType(workflowInstance, allowedEntityType); fastify.assertEntityPermission(request.employee, workflowInstance.entity_type, "approve"); 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, }, }); if (workflowInstance.entity_type === "reimbursement") { await tx.reimbursementForm.update({ where: { id: workflowInstance.entity_id }, data: { status: "rejected", updated_by: request.employee.id, }, }); } else if (workflowInstance.entity_type === "leave") { const leaveRequest = await tx.leaveRequest.findUnique({ where: { id: workflowInstance.entity_id }, select: { id: true, owner_id: true, leave_type_id: true, }, }); const reserveAnchor = await tx.leaveLedger.findFirst({ where: { leave_request_id: workflowInstance.entity_id, entry_type: "reserve", }, orderBy: { created_at: "desc" }, select: { leave_policy_id: true, period_start: true, period_end: true, }, }); await tx.leaveRequest.update({ where: { id: workflowInstance.entity_id }, data: { status: "rejected", updated_by: request.employee.id, }, }); if (leaveRequest) { await releaseReservedLeave(tx, { leaveRequestId: workflowInstance.entity_id, employeeId: leaveRequest.owner_id, leaveTypeId: leaveRequest.leave_type_id, leavePolicyId: reserveAnchor?.leave_policy_id ?? null, periodStart: reserveAnchor?.period_start ?? null, periodEnd: reserveAnchor?.period_end ?? null, note: "rejected_release_leave", payload: { source: "workflow_reject" }, createdBy: request.employee.id, }); } } else if (workflowInstance.entity_type === "purchase_request") { await tx.purchaseRequest.update({ where: { id: workflowInstance.entity_id }, data: { status: "rejected", updated_by: request.employee.id, }, }); await releaseReservedBudgetIfAny(tx, { entityType: "purchase_request", entityId: workflowInstance.entity_id, actorId: request.employee.id, note: "rejected_release_purchase_budget", }); } else if (workflowInstance.entity_type === "travel_request") { await tx.travelRequest.update({ where: { id: workflowInstance.entity_id }, data: { status: "rejected", updated_by: request.employee.id, }, }); await releaseReservedBudgetIfAny(tx, { entityType: "travel_request", entityId: workflowInstance.entity_id, actorId: request.employee.id, note: "rejected_release_travel_budget", }); } 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, ), }, }); try { await queueWorkflowOutcomeNotifications({ tx, actorId: request.employee.id, emailTasks, dispatchTasks, workflowInstance, outcome: "rejected", comment, }); } catch (error) { fastify.log.error( { err: error, taskId, entityType: workflowInstance.entity_type, entityId: workflowInstance.entity_id }, "Failed to create rejection outcome notifications", ); } result = { taskId, outcome: "rejected", workflowInstanceId: workflowInstance.id, reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "rejected" : null, purchaseRequestStatus: workflowInstance.entity_type === "purchase_request" ? "rejected" : null, travelRequestStatus: workflowInstance.entity_type === "travel_request" ? "rejected" : null, }; }); return { result, emailTasks, dispatchTasks }; } function mapBatchTaskError(taskId, error) { if ( error instanceof AppError && error.code === "VALID_001" && error.statusCode === 409 && error.details?.task === "already_processed" ) { return { taskId, outcome: "skipped", reason: "already_processed", }; } if (error instanceof AppError) { return { taskId, outcome: "error", reason: `${error.code}: ${error.message}`, }; } return { taskId, outcome: "error", reason: error instanceof Error ? error.message : "unknown_error", }; } async function workflowRoutes(fastify) { fastify.get( "/workflow/tasks", { schema: { tags: ["Workflow"], summary: "List 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 }), ]); const taskSummaries = await Promise.all( tasks.map(async (task) => { 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; } } } return mapTaskSummary(task, listAllowedActionsForTask(request, task, actionable)); }), ); return sendData(reply, request, { page: query.page, pageSize: query.pageSize, total, items: taskSummaries, }); }, ); fastify.get( "/workflow/tasks/:taskId", { schema: { tags: ["Workflow"], summary: "Get Workflow Task Detail" }, 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, entity_type: true, entity_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, }, }, }, }, leave_request: { select: { id: true, status: true, start_date: true, end_date: true, start_session: true, end_session: true, requested_days: true, reason: true, attachment_original_name: true, attachment_mime_type: true, leave_type: { select: { id: true, code: true, name: true, }, }, owner: { select: { id: true, employee_no: true, name: true, }, }, }, }, purchase_request: { select: { id: true, request_no: true, status: true, title: true, reason: true, currency_code: true, total_amount: true, expected_delivery_date: true, applicant: { select: { id: true, employee_no: true, name: true, }, }, supplier: { select: { id: true, code: true, name: true, }, }, legal_entity: { select: { id: true, code: true, name: true, }, }, cost_center: { select: { id: true, code: true, name: true, }, }, document_category: { select: { id: true, code: true, name: true, }, }, }, }, travel_request: { select: { id: true, request_no: true, status: true, purpose: true, trip_type: true, start_date: true, end_date: true, estimated_amount: true, applicant: { select: { id: true, employee_no: true, name: true, }, }, legal_entity: { select: { id: true, code: true, name: true, }, }, cost_center: { select: { id: true, code: true, name: true, }, }, segments: { orderBy: [{ sort_order: "asc" }, { created_at: "asc" }], select: { id: true, sort_order: true, segment_role: true, transport_type: true, from_city: true, to_city: 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, entityType: task.workflow_instance.entity_type, 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, leaveRequest: task.workflow_instance?.leave_request ? { id: task.workflow_instance.leave_request.id, status: task.workflow_instance.leave_request.status, startDate: formatDateOnly(task.workflow_instance.leave_request.start_date), endDate: formatDateOnly(task.workflow_instance.leave_request.end_date), startSession: task.workflow_instance.leave_request.start_session, endSession: task.workflow_instance.leave_request.end_session, requestedDays: Number(task.workflow_instance.leave_request.requested_days), reason: task.workflow_instance.leave_request.reason ?? null, attachment: task.workflow_instance.leave_request.attachment_original_name || task.workflow_instance.leave_request.attachment_mime_type ? { originalName: task.workflow_instance.leave_request.attachment_original_name ?? null, mimeType: task.workflow_instance.leave_request.attachment_mime_type ?? null, } : null, leaveType: task.workflow_instance.leave_request.leave_type ? { id: task.workflow_instance.leave_request.leave_type.id, code: task.workflow_instance.leave_request.leave_type.code, name: task.workflow_instance.leave_request.leave_type.name, } : null, owner: task.workflow_instance.leave_request.owner ? { id: task.workflow_instance.leave_request.owner.id, employeeNo: task.workflow_instance.leave_request.owner.employee_no, name: task.workflow_instance.leave_request.owner.name, } : null, } : null, purchaseRequest: task.workflow_instance?.purchase_request ? { id: task.workflow_instance.purchase_request.id, requestNo: task.workflow_instance.purchase_request.request_no ?? null, status: task.workflow_instance.purchase_request.status, title: task.workflow_instance.purchase_request.title, reason: task.workflow_instance.purchase_request.reason ?? null, currencyCode: task.workflow_instance.purchase_request.currency_code, totalAmount: toMoneyString(task.workflow_instance.purchase_request.total_amount), expectedDeliveryDate: formatDateOnly(task.workflow_instance.purchase_request.expected_delivery_date), applicant: task.workflow_instance.purchase_request.applicant ? { id: task.workflow_instance.purchase_request.applicant.id, employeeNo: task.workflow_instance.purchase_request.applicant.employee_no, name: task.workflow_instance.purchase_request.applicant.name, } : null, supplier: task.workflow_instance.purchase_request.supplier ? { id: task.workflow_instance.purchase_request.supplier.id, code: task.workflow_instance.purchase_request.supplier.code, name: task.workflow_instance.purchase_request.supplier.name, } : null, legalEntity: task.workflow_instance.purchase_request.legal_entity ? { id: task.workflow_instance.purchase_request.legal_entity.id, code: task.workflow_instance.purchase_request.legal_entity.code, name: task.workflow_instance.purchase_request.legal_entity.name, } : null, costCenter: task.workflow_instance.purchase_request.cost_center ? { id: task.workflow_instance.purchase_request.cost_center.id, code: task.workflow_instance.purchase_request.cost_center.code, name: task.workflow_instance.purchase_request.cost_center.name, } : null, documentCategory: task.workflow_instance.purchase_request.document_category ? { id: task.workflow_instance.purchase_request.document_category.id, code: task.workflow_instance.purchase_request.document_category.code, name: task.workflow_instance.purchase_request.document_category.name, } : null, } : null, travelRequest: task.workflow_instance?.travel_request ? { id: task.workflow_instance.travel_request.id, requestNo: task.workflow_instance.travel_request.request_no ?? null, status: task.workflow_instance.travel_request.status, purpose: task.workflow_instance.travel_request.purpose, tripType: task.workflow_instance.travel_request.trip_type, startDate: formatDateOnly(task.workflow_instance.travel_request.start_date), endDate: formatDateOnly(task.workflow_instance.travel_request.end_date), estimatedAmount: toMoneyString(task.workflow_instance.travel_request.estimated_amount), applicant: task.workflow_instance.travel_request.applicant ? { id: task.workflow_instance.travel_request.applicant.id, employeeNo: task.workflow_instance.travel_request.applicant.employee_no, name: task.workflow_instance.travel_request.applicant.name, } : null, legalEntity: task.workflow_instance.travel_request.legal_entity ? { id: task.workflow_instance.travel_request.legal_entity.id, code: task.workflow_instance.travel_request.legal_entity.code, name: task.workflow_instance.travel_request.legal_entity.name, } : null, costCenter: task.workflow_instance.travel_request.cost_center ? { id: task.workflow_instance.travel_request.cost_center.id, code: task.workflow_instance.travel_request.cost_center.code, name: task.workflow_instance.travel_request.cost_center.name, } : null, segments: task.workflow_instance.travel_request.segments.map((item) => ({ id: item.id, sortOrder: item.sort_order, segmentRole: item.segment_role, transportType: item.transport_type, fromCity: item.from_city, toCity: item.to_city, })), } : 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.authenticate, }, async (request, reply) => { const taskId = parseTaskId(request.params?.taskId); const { result, emailTasks, dispatchTasks } = await executeApproveWorkflowTask({ fastify, request, taskId, }); await flushWorkflowSideEffects({ fastify, request, emailTasks, dispatchTasks }); return sendData(reply, request, result); }, ); fastify.post( "/workflow/tasks/:taskId/reject", { preHandler: fastify.authenticate, }, async (request, reply) => { const taskId = parseTaskId(request.params?.taskId); const comment = parseRequiredComment(request.body); const { result, emailTasks, dispatchTasks } = await executeRejectWorkflowTask({ fastify, request, taskId, comment, }); await flushWorkflowSideEffects({ fastify, request, emailTasks, dispatchTasks }); return sendData(reply, request, result); }, ); fastify.post( "/workflow/batch-approve", { preHandler: fastify.requirePermission("expense.approve"), }, async (request, reply) => { const body = parseBatchApproveBody(request.body); const results = []; const summary = { approved: 0, rejected: 0, skipped: 0, errored: 0, }; for (const taskId of body.taskIds) { try { const { result, emailTasks, dispatchTasks } = await executeApproveWorkflowTask({ fastify, request, taskId, allowedEntityType: "reimbursement", }); try { await flushWorkflowSideEffects({ fastify, request, emailTasks, dispatchTasks }); } catch (error) { fastify.log.error({ err: error, taskId }, "Failed to flush batch approve side effects"); } results.push({ taskId, outcome: result.outcome, }); summary.approved += 1; } catch (error) { const mapped = mapBatchTaskError(taskId, error); results.push(mapped); if (mapped.outcome === "skipped") { summary.skipped += 1; } else { summary.errored += 1; } } } return sendData(reply, request, { results, summary, }); }, ); fastify.post( "/workflow/batch-reject", { preHandler: fastify.requirePermission("expense.approve"), }, async (request, reply) => { const body = parseBatchRejectBody(request.body); const results = []; const summary = { approved: 0, rejected: 0, skipped: 0, errored: 0, }; for (const taskId of body.taskIds) { try { const { result, emailTasks, dispatchTasks } = await executeRejectWorkflowTask({ fastify, request, taskId, comment: body.comment, allowedEntityType: "reimbursement", }); try { await flushWorkflowSideEffects({ fastify, request, emailTasks, dispatchTasks }); } catch (error) { fastify.log.error({ err: error, taskId }, "Failed to flush batch reject side effects"); } results.push({ taskId, outcome: result.outcome, }); summary.rejected += 1; } catch (error) { const mapped = mapBatchTaskError(taskId, error); results.push(mapped); if (mapped.outcome === "skipped") { summary.skipped += 1; } else { summary.errored += 1; } } } return sendData(reply, request, { results, summary, }); }, ); fastify.post( "/workflow/tasks/:taskId/return", { preHandler: fastify.authenticate, }, async (request, reply) => { const taskId = parseTaskId(request.params?.taskId); const comment = parseRequiredComment(request.body); let result = null; const emailTasks = []; const dispatchTasks = []; 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); fastify.assertEntityPermission(request.employee, workflowInstance.entity_type, "approve"); 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, }, }); if (workflowInstance.entity_type === "reimbursement") { await tx.reimbursementForm.update({ where: { id: workflowInstance.entity_id }, data: { status: "draft", updated_by: request.employee.id, }, }); } else if (workflowInstance.entity_type === "leave") { const leaveRequest = await tx.leaveRequest.findUnique({ where: { id: workflowInstance.entity_id }, select: { id: true, owner_id: true, leave_type_id: true, }, }); const reserveAnchor = await tx.leaveLedger.findFirst({ where: { leave_request_id: workflowInstance.entity_id, entry_type: "reserve", }, orderBy: { created_at: "desc" }, select: { leave_policy_id: true, period_start: true, period_end: true, }, }); await tx.leaveRequest.update({ where: { id: workflowInstance.entity_id }, data: { status: "draft", updated_by: request.employee.id, }, }); if (leaveRequest) { await releaseReservedLeave(tx, { leaveRequestId: workflowInstance.entity_id, employeeId: leaveRequest.owner_id, leaveTypeId: leaveRequest.leave_type_id, leavePolicyId: reserveAnchor?.leave_policy_id ?? null, periodStart: reserveAnchor?.period_start ?? null, periodEnd: reserveAnchor?.period_end ?? null, note: "returned_release_leave", payload: { source: "workflow_return" }, createdBy: request.employee.id, }); } } else if (workflowInstance.entity_type === "purchase_request") { await tx.purchaseRequest.update({ where: { id: workflowInstance.entity_id }, data: { status: "draft", updated_by: request.employee.id, }, }); await releaseReservedBudgetIfAny(tx, { entityType: "purchase_request", entityId: workflowInstance.entity_id, actorId: request.employee.id, note: "returned_release_purchase_budget", }); } else if (workflowInstance.entity_type === "travel_request") { await tx.travelRequest.update({ where: { id: workflowInstance.entity_id }, data: { status: "draft", updated_by: request.employee.id, }, }); await releaseReservedBudgetIfAny(tx, { entityType: "travel_request", entityId: workflowInstance.entity_id, actorId: request.employee.id, note: "returned_release_travel_budget", }); } 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, ), }, }); try { await queueWorkflowOutcomeNotifications({ tx, actorId: request.employee.id, emailTasks, dispatchTasks, workflowInstance, outcome: 'returned', comment, }); } catch (error) { fastify.log.error( { err: error, taskId, entityType: workflowInstance.entity_type, entityId: workflowInstance.entity_id }, "Failed to create return outcome notifications", ); } result = { taskId, outcome: "returned", workflowInstanceId: workflowInstance.id, reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "draft" : null, purchaseRequestStatus: workflowInstance.entity_type === "purchase_request" ? "draft" : null, travelRequestStatus: workflowInstance.entity_type === "travel_request" ? "draft" : null, }; }); for (const task of emailTasks) { void sendNotificationEmailSafe({ prisma: fastify.prisma, config: fastify.config, logger: fastify.log, requestId: request.id, ...task, }); } await flushNotificationDispatchTasks({ prisma: fastify.prisma, config: fastify.config, logger: fastify.log, requestId: request.id, tasks: dispatchTasks, }); 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; const emailTasks = []; const dispatchTasks = []; 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 approvePermission = resolveEntityPermission( workflowInstance.entity_type, "approve", ); if (!approvePermission) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { transfer: "unsupported_entity_type" }, }); } const transferCount = await ensureTransferChainNotExceeded(tx, taskId); await ensureTransferTargetEligible(tx, toApproverId, approvePermission); 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, }, }); 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: { 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, ), }, }); try { const notificationContext = await loadWorkflowNotifyContext( tx, workflowInstance.entity_type, workflowInstance.entity_id, ); const entityMeta = buildWorkflowEntityMeta( workflowInstance.entity_type, notificationContext.record, workflowInstance.entity_id, ); const pendingCopy = buildPendingTaskCopy(entityMeta, { transfer: true }); await queueTaskAssignedNotifications({ tx, actorId: request.employee.id, emailTasks, dispatchTasks, recipientEmployeeIds: [toApproverId], title: pendingCopy.title, body: pendingCopy.body, taskId, entityType: workflowInstance.entity_type, entityId: workflowInstance.entity_id, }); } catch (error) { fastify.log.error( { err: error, taskId, toApproverId }, "Failed to create task transferred notifications", ); } result = { taskId, outcome: "transferred", workflowInstanceId: workflowInstance.id, reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "reviewing" : null, transferId, fromApproverId: request.employee.id, toApproverId, transferCount: transferCount + 1, }; }); for (const task of emailTasks) { void sendNotificationEmailSafe({ prisma: fastify.prisma, config: fastify.config, logger: fastify.log, requestId: request.id, ...task, }); } await flushNotificationDispatchTasks({ prisma: fastify.prisma, config: fastify.config, logger: fastify.log, requestId: request.id, tasks: dispatchTasks, }); return sendData(reply, request, result); }, ); } module.exports = workflowRoutes; module.exports.__testables = { buildApprovedCopy, buildPendingTaskCopy, buildRejectedCopy, buildReturnedCopy, buildWorkflowEntityMeta, handlePurchaseApprovalErpSync, listAllowedActionsForTask, resolveWorkflowOutcomeNotificationType, };