const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { buildAuditLogData } = require("../utils/audit"); const { sendData } = require("../utils/reply"); const { hasPermission, ROLE_PERMISSIONS, resolveEntityPermission } = require("../constants/permissions"); const { BUSINESS_APPROVAL_ROLE_CODES, FINANCE_OWNER_APPROVAL_STEP_ID, getWorkflowStepsSnapshot, isBusinessApprovalRoleCode, resolveActionableStep, resolveWorkflowStepDisplayName, 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, buildOverdueTaskCopy, buildPendingTaskCopy, buildRejectedCopy, buildRemindedTaskCopy, 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 REMIND_TASK_BODY_SCHEMA = z.object({ comment: z.string().trim().max(200).optional(), }); const TASK_LIST_QUERY_SCHEMA = z.object({ status: z.enum(["pending", "completed", "rejected", "cancelled", "skipped"]).optional(), entityType: z.enum(["reimbursement", "leave", "attendance_correction", "purchase_request", "travel_request"]).optional(), page: z.coerce.number().int().min(1).optional(), pageSize: z.coerce.number().int().min(1).max(100).optional(), }); const TASK_SUMMARY_QUERY_SCHEMA = z.object({ scope: z.enum(["mine", "system"]).optional(), }); const WORKFLOW_REMIND_ACTION = "remind"; const WORKFLOW_OVERDUE_ACTION = "overdue_remind"; const WORKFLOW_REMINDER_COOLDOWN_MS = 24 * 60 * 60 * 1000; const WORKFLOW_DEFAULT_DUE_MS = 24 * 60 * 60 * 1000; const REIMBURSEMENT_SNAPSHOT_CONTROL_MODES = new Set(["warn", "block"]); function projectReimbursementSubmissionSnapshot(contextData) { const source = contextData && typeof contextData === "object" && !Array.isArray(contextData) ? contextData : {}; const standardViolationsPresent = Object.prototype.hasOwnProperty.call(source, "standardViolations"); const budgetCheckPresent = Object.prototype.hasOwnProperty.call(source, "budgetCheck") && source.budgetCheck != null; const budgetCheckRecordedPresent = Object.prototype.hasOwnProperty.call(source, "budgetCheckRecorded"); const standardViolationsValid = Array.isArray(source.standardViolations) && source.standardViolations.every((item) => ( item && typeof item === "object" && typeof item.itemId === "string" && typeof item.expenseTypeId === "string" && typeof item.expenseTypeName === "string" && typeof item.amount === "string" && typeof item.standardId === "string" && typeof item.standardName === "string" && typeof item.maxAmount === "string" && ["item", "day", "trip"].includes(item.perUnit) && REIMBURSEMENT_SNAPSHOT_CONTROL_MODES.has(item.controlMode) && ["global", "department"].includes(item.scopeType) && (item.scopeKey == null || typeof item.scopeKey === "string") && typeof item.exceeded === "boolean" && typeof item.exceededAmount === "string" )); const standardViolations = standardViolationsValid ? source.standardViolations.map((item) => ({ itemId: item.itemId, expenseTypeId: item.expenseTypeId, expenseTypeName: item.expenseTypeName, amount: item.amount, standardId: item.standardId, standardName: item.standardName, maxAmount: item.maxAmount, perUnit: item.perUnit, controlMode: item.controlMode, scopeType: item.scopeType, scopeKey: item.scopeKey, exceeded: item.exceeded, exceededAmount: item.exceededAmount, })) : []; let budgetCheck = null; if (budgetCheckPresent) { const candidate = source.budgetCheck; const usagePercent = Number(candidate?.usagePercent); if ( candidate && typeof candidate === "object" && !Array.isArray(candidate) && typeof candidate.budgetId === "string" && typeof candidate.budgetCode === "string" && REIMBURSEMENT_SNAPSHOT_CONTROL_MODES.has(candidate.controlMode) && Number.isFinite(usagePercent) && typeof candidate.exceededThreshold === "boolean" && typeof candidate.exceededBudget === "boolean" ) { budgetCheck = { budgetId: candidate.budgetId, budgetCode: candidate.budgetCode, controlMode: candidate.controlMode, usagePercent, exceededThreshold: candidate.exceededThreshold, exceededBudget: candidate.exceededBudget, }; } } const parseStatus = (standardViolationsPresent && !standardViolationsValid) || (budgetCheckPresent && !budgetCheck) || (budgetCheckRecordedPresent && source.budgetCheckRecorded !== true) ? "invalid" : "ready"; return { standardViolations, budgetCheck, budgetCheckStatus: budgetCheck ? "available" : source.budgetCheckRecorded === true ? "not_applicable" : "missing", parseStatus, }; } 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 = BUSINESS_APPROVAL_ROLE_CODES.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(), }; } function parseRemindTaskBody(body) { const parsed = REMIND_TASK_BODY_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } return { comment: parsed.data.comment?.trim() || null, }; } function parseTaskSummaryQuery(query) { const parsed = TASK_SUMMARY_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } return { scope: parsed.data.scope ?? "mine", }; } 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", entityType: parsed.data.entityType ?? null, 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 businessSummary = buildTaskBusinessSummary(task); const stepDisplayName = resolveWorkflowStepDisplayName(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: stepDisplayName, 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: stepDisplayName, 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, businessSummary, }; } function fallbackBusinessTitle(entityType) { if (entityType === "reimbursement") return "报销单"; if (entityType === "leave") return "请假申请"; if (entityType === "attendance_correction") return "考勤修正"; if (entityType === "purchase_request") return "采购申请"; if (entityType === "travel_request") return "差旅申请"; return "业务单据"; } function buildTaskBusinessSummary(task) { const workflowInstance = task.workflow_instance; if (!workflowInstance) { return null; } const fallback = { reference: workflowInstance.entity_id, title: fallbackBusinessTitle(workflowInstance.entity_type), applicantName: null, amount: null, duration: null, businessStatus: workflowInstance.current_status ?? null, }; if (workflowInstance.entity_type === "reimbursement" && workflowInstance.reimbursement_form) { const form = workflowInstance.reimbursement_form; return { ...fallback, reference: form.form_number ?? workflowInstance.entity_id, title: form.description ? `报销:${form.description}` : "报销单", applicantName: form.submitter?.name ?? form.owner?.name ?? null, amount: toMoneyString(form.total_amount), businessStatus: form.status ?? fallback.businessStatus, }; } if (workflowInstance.entity_type === "leave" && workflowInstance.leave_request) { const leaveRequest = workflowInstance.leave_request; return { ...fallback, title: leaveRequest.leave_type?.name ? `请假:${leaveRequest.leave_type.name}` : "请假申请", applicantName: leaveRequest.owner?.name ?? null, duration: leaveRequest.requested_days == null ? null : `${Number(leaveRequest.requested_days)}天`, businessStatus: leaveRequest.status ?? fallback.businessStatus, }; } if (workflowInstance.entity_type === "attendance_correction" && workflowInstance.attendance_correction) { const correction = workflowInstance.attendance_correction; const kindLabel = correction.kind === "missed_punch" ? "补卡" : correction.kind === "out" ? "外出" : correction.kind === "overtime" ? "加班" : "考勤修正"; return { ...fallback, reference: correction.work_date?.toISOString?.().slice(0, 10) ?? workflowInstance.entity_id, title: `${kindLabel}:${correction.reason ?? "考勤修正"}`, applicantName: correction.owner?.name ?? null, duration: correction.start_at && correction.end_at ? `${correction.start_at.toISOString().slice(11, 16)}-${correction.end_at.toISOString().slice(11, 16)}` : null, businessStatus: correction.status ?? fallback.businessStatus, }; } if (workflowInstance.entity_type === "purchase_request" && workflowInstance.purchase_request) { const purchaseRequest = workflowInstance.purchase_request; const amount = toMoneyString(purchaseRequest.total_amount); return { ...fallback, reference: purchaseRequest.request_no ?? workflowInstance.entity_id, title: purchaseRequest.title || "采购申请", applicantName: purchaseRequest.applicant?.name ?? null, amount: amount ? `${amount} ${purchaseRequest.currency_code}` : null, businessStatus: purchaseRequest.status ?? fallback.businessStatus, }; } if (workflowInstance.entity_type === "travel_request" && workflowInstance.travel_request) { const travelRequest = workflowInstance.travel_request; return { ...fallback, reference: travelRequest.request_no ?? workflowInstance.entity_id, title: travelRequest.purpose ? `差旅:${travelRequest.purpose}` : "差旅申请", applicantName: travelRequest.applicant?.name ?? null, amount: toMoneyString(travelRequest.estimated_amount), businessStatus: travelRequest.status ?? fallback.businessStatus, }; } return fallback; } function calculateWorkflowTaskDueAt(task, now = new Date()) { if (task?.due_date) { return new Date(task.due_date); } const createdAt = task?.created_at ? new Date(task.created_at) : now; return new Date(createdAt.getTime() + WORKFLOW_DEFAULT_DUE_MS); } function isWorkflowTaskOverdue(task, now = new Date()) { if (task?.status && task.status !== "pending") { return false; } return calculateWorkflowTaskDueAt(task, now).getTime() <= now.getTime(); } function incrementSummaryBucket(map, key, task, now) { const normalizedKey = key || "未分配"; const current = map.get(normalizedKey) ?? { key: normalizedKey, pendingTotal: 0, overdueTotal: 0, }; current.pendingTotal += 1; if (isWorkflowTaskOverdue(task, now)) { current.overdueTotal += 1; } map.set(normalizedKey, current); } function buildWorkflowTaskSummaryPayload(tasks, scope = "mine", now = new Date()) { const byEntityType = new Map(); const byStep = new Map(); const byAssigneeRole = new Map(); let earliestPendingAt = null; let overdueTotal = 0; let totalPendingAgeMs = 0; for (const task of tasks ?? []) { const createdAt = task.created_at ? new Date(task.created_at) : null; if (createdAt && (!earliestPendingAt || createdAt < earliestPendingAt)) { earliestPendingAt = createdAt; } if (createdAt) { totalPendingAgeMs += Math.max(0, now.getTime() - createdAt.getTime()); } if (isWorkflowTaskOverdue(task, now)) { overdueTotal += 1; } incrementSummaryBucket(byEntityType, task.workflow_instance?.entity_type ?? "unknown", task, now); incrementSummaryBucket(byStep, resolveWorkflowStepDisplayName(task) || task.step_id || "unknown", task, now); incrementSummaryBucket(byAssigneeRole, task.assignee_role ?? (task.assignee_id ? "指定审批人" : "未分配"), task, now); } const pendingTotal = tasks?.length ?? 0; return { scope, pendingTotal, overdueTotal, earliestPendingAt: isoOrNull(earliestPendingAt), averagePendingAgeHours: pendingTotal ? Number((totalPendingAgeMs / pendingTotal / (60 * 60 * 1000)).toFixed(1)) : 0, byEntityType: Array.from(byEntityType.values()), byStep: Array.from(byStep.values()), byAssigneeRole: Array.from(byAssigneeRole.values()), }; } 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 canViewWorkflowEntityType(request, entityType) { const normalizedEntityType = normalizeWorkflowEntityType(entityType); if (normalizedEntityType === "travel_request") { return hasPermission(request.employee.permissionCodes ?? [], "*") || hasPermission(request.employee.permissionCodes ?? [], "system.settings"); } return true; } function buildWorkflowEntityVisibilityWhere(request, entityType = null) { if (entityType) { return canViewWorkflowEntityType(request, entityType) ? { entity_type: normalizeWorkflowEntityType(entityType) } : { entity_type: "__forbidden__" }; } const hiddenEntityTypes = []; if (!canViewWorkflowEntityType(request, "travel_request")) { hiddenEntityTypes.push("travel_request"); } return hiddenEntityTypes.length ? { entity_type: { notIn: hiddenEntityTypes } } : {}; } function listAllowedActionsForTask(request, task, actionable) { if (task.status !== "pending" || !actionable) { return []; } if (task.workflow_instance?.entity_type === "reimbursement" && task.step_id === FINANCE_OWNER_APPROVAL_STEP_ID && !request.employee.roleCodes.includes("ceo")) { 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"); if (task.workflow_instance?.entity_type !== "reimbursement") { actions.push("reject"); } if (task.workflow_instance?.entity_type !== "attendance_correction") { actions.push("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, submitter_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 loadAttendanceCorrectionNotifyContext(tx, correctionId) { return tx.attendanceCorrection.findUnique({ where: { id: correctionId }, select: { id: true, owner_id: true, kind: true, status: true, work_date: true, start_at: true, end_at: true, reason: 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 === 'attendance_correction') { const record = await loadAttendanceCorrectionNotifyContext(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 resolveBusinessApprovePermission(entityType) { return resolveEntityPermission(normalizeWorkflowEntityType(entityType), "approve"); } function roleHasBusinessApprovePermission(roleCode, approvePermission) { if (!isBusinessApprovalRoleCode(roleCode) || !approvePermission) { return false; } return (ROLE_PERMISSIONS[roleCode] ?? []).includes(approvePermission); } function employeeHasBusinessApproveRole(employee, entityType) { const approvePermission = resolveBusinessApprovePermission(entityType); return Boolean( approvePermission && (employee?.roleCodes ?? []).some((roleCode) => roleHasBusinessApprovePermission(roleCode, approvePermission), ), ); } function ensureBusinessApproveRole(employee, entityType) { if (employeeHasBusinessApproveRole(employee, entityType)) { return; } throw new AppError({ code: "PERM_002", message: "不在审批范围/非当前处理人", statusCode: 403, details: { workflow: "business_approval_role_required" }, }); } function collectWorkflowApplicantIds(workflowInstance, record) { const entityType = normalizeWorkflowEntityType(workflowInstance.entity_type); if (entityType === "reimbursement") { return uniq([record?.owner_id, record?.submitter_id, workflowInstance.submitter_id]); } if (entityType === "leave") { return uniq([record?.owner_id, workflowInstance.submitter_id]); } if (entityType === "attendance_correction") { return uniq([record?.owner_id, workflowInstance.submitter_id]); } if (entityType === "purchase_request") { return uniq([record?.applicant_id, record?.submitter_id, workflowInstance.submitter_id]); } if (entityType === "travel_request") { return uniq([record?.applicant_employee_id, record?.submitter_id, workflowInstance.submitter_id]); } return uniq([workflowInstance.submitter_id]); } async function loadWorkflowApplicantIds(tx, workflowInstance) { const { record } = await loadWorkflowNotifyContext( tx, workflowInstance.entity_type, workflowInstance.entity_id, ); if (!record) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { workflow: "entity_not_processable" }, }); } return collectWorkflowApplicantIds(workflowInstance, record); } function ensureNoSelfApproval(actorIds, applicantIds) { if (!actorIds.some((actorId) => applicantIds.includes(actorId))) { return; } throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { workflow: "self_approval_not_allowed" }, }); } async function ensureFinanceOwnerCeoActors(tx, workflowInstance, actorIds) { if (workflowInstance.entity_type !== "reimbursement" || workflowInstance.current_step_id !== FINANCE_OWNER_APPROVAL_STEP_ID) { return; } const eligible = await tx.employeeRole.findMany({ where: { employee_id: { in: actorIds }, role_code: "ceo", employee: { status: "active", archived_at: null }, }, select: { employee_id: true }, }); if (actorIds.some((actorId) => !eligible.some((holder) => holder.employee_id === actorId))) { throw buildBusinessValidationError("reimbursement.finance_owner_ceo_required", { workflow: "finance_owner_ceo_required", }, 403); } } async function ensureWorkflowApprovalAllowed(tx, workflowInstance, request, assignment) { ensureBusinessApproveRole(request.employee, workflowInstance.entity_type); const applicantIds = await loadWorkflowApplicantIds(tx, workflowInstance); const actorIds = uniq([request.employee.id, assignment?.principalId]); ensureNoSelfApproval(actorIds, applicantIds); await ensureFinanceOwnerCeoActors(tx, workflowInstance, actorIds); return applicantIds; } 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 === 'attendance_correction') return NOTIFICATION_TYPES.attendanceCorrectionApproved; 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 === 'attendance_correction') return NOTIFICATION_TYPES.attendanceCorrectionRejected; 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; if (normalizedEntityType === 'leave') return NOTIFICATION_TYPES.leaveReturned; if (normalizedEntityType === 'purchase_request') return NOTIFICATION_TYPES.purchaseRequestReturned; if (normalizedEntityType === 'travel_request') return NOTIFICATION_TYPES.travelRequestReturned; } 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 queueWorkflowTaskPromptNotifications({ tx, actorId, emailTasks, dispatchTasks, task, workflowInstance, notificationType, copyBuilder, excludeActor = true, }) { const recipientEmployeeIds = uniq( await resolveRecipientsForAssignee(tx, { assigneeId: task.assignee_id, assigneeRole: task.assignee_role, }), ).filter((employeeId) => !excludeActor || employeeId !== actorId); if (!recipientEmployeeIds.length) { return []; } const notificationContext = await loadWorkflowNotifyContext( tx, workflowInstance.entity_type, workflowInstance.entity_id, ); const entityMeta = buildWorkflowEntityMeta( workflowInstance.entity_type, notificationContext.record, workflowInstance.entity_id, ); const copy = copyBuilder(entityMeta); const notifications = await createNotificationsWithRecords(tx, { recipientEmployeeIds, type: notificationType, title: copy.title, body: copy.body, targetType: "workflow_task", targetId: task.id, }); dispatchTasks.push({ notifications, type: notificationType, title: copy.title, body: copy.body, targetType: "workflow_task", targetId: task.id, targetPath: `/mobile/approvals?taskId=${task.id}`, actorId, }); emailTasks.push({ type: notificationType, recipientEmployeeIds, subject: copy.title, body: copy.body, targetPath: `/workflow/tasks/${task.id}`, }); return notifications; } 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); if (!roleCodes.some((roleCode) => roleHasBusinessApprovePermission(roleCode, 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 ensureWorkflowReminderAllowed(tx, workflowInstance, task, request) { const applicantIds = await loadWorkflowApplicantIds(tx, workflowInstance); if (!applicantIds.includes(request.employee.id)) { throw new AppError({ code: "PERM_002", message: "无权催办该审批", statusCode: 403, details: { workflow: "remind_owner_required" }, }); } const recipientEmployeeIds = await resolveRecipientsForAssignee(tx, { assigneeId: task.assignee_id, assigneeRole: task.assignee_role, }); if (recipientEmployeeIds.includes(request.employee.id)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { workflow: "self_remind_not_allowed" }, }); } return applicantIds; } async function ensureWorkflowReminderCooldown(tx, taskId, actorId, now) { const since = new Date(now.getTime() - WORKFLOW_REMINDER_COOLDOWN_MS); const existing = await tx.workflowHistory.findFirst({ where: { task_id: taskId, actor_id: actorId, action: WORKFLOW_REMIND_ACTION, created_at: { gte: since }, }, orderBy: { created_at: "desc" }, select: { id: true, created_at: true, }, }); if (!existing) { return null; } const nextAllowedAt = new Date(existing.created_at.getTime() + WORKFLOW_REMINDER_COOLDOWN_MS); throw new AppError({ code: "VALID_001", message: "催办过于频繁", statusCode: 409, details: { workflow: "remind_too_frequent", nextAllowedAt: nextAllowedAt.toISOString(), }, }); } async function executeRemindWorkflowTask({ fastify, request, taskId, comment = null, }) { let result = null; const emailTasks = []; const dispatchTasks = []; const now = new Date(); await fastify.prisma.$transaction(async (tx) => { const lockedTask = await lockWorkflowTask(tx, taskId); ensurePendingTask(lockedTask); const workflowInstance = await loadWorkflowInstanceForTask(tx, lockedTask.instance_id); await ensureWorkflowReminderAllowed(tx, workflowInstance, lockedTask, request); await ensureWorkflowReminderCooldown(tx, taskId, request.employee.id, now); const safeComment = comment || "申请人催办"; await tx.workflowHistory.create({ data: { instance_id: workflowInstance.id, task_id: taskId, actor_id: request.employee.id, action: WORKFLOW_REMIND_ACTION, comment: safeComment, payload: { from_step_id: lockedTask.step_id, assignee_id: lockedTask.assignee_id, assignee_role: lockedTask.assignee_role, reminder_window_hours: 24, }, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "WORKFLOW_TASK_REMINDED", targetType: "workflow_task", targetId: taskId, extra: { workflowInstanceId: workflowInstance.id, entityType: workflowInstance.entity_type, entityId: workflowInstance.entity_id, assigneeId: lockedTask.assignee_id, assigneeRole: lockedTask.assignee_role, reminderWindowHours: 24, }, }), }); let notificationCount = 0; try { const notifications = await queueWorkflowTaskPromptNotifications({ tx, actorId: request.employee.id, emailTasks, dispatchTasks, task: lockedTask, workflowInstance, notificationType: NOTIFICATION_TYPES.workflowTaskReminded, copyBuilder: buildRemindedTaskCopy, }); notificationCount = notifications.length; } catch (error) { fastify.log.error( { err: error, taskId, workflowInstanceId: workflowInstance.id }, "Failed to create workflow reminder notifications", ); } result = { taskId, outcome: "reminded", workflowInstanceId: workflowInstance.id, nextAllowedAt: new Date(now.getTime() + WORKFLOW_REMINDER_COOLDOWN_MS).toISOString(), notificationCount, }; }); await flushWorkflowSideEffects({ fastify, request, emailTasks, dispatchTasks }); return result; } 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"); await ensureWorkflowApprovalAllowed(tx, workflowInstance, request, assignment); 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 === "attendance_correction") { await tx.attendanceCorrection.update({ where: { id: workflowInstance.entity_id }, data: { status: "approved", updated_by: 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, attendanceCorrectionStatus: workflowInstance.entity_type === "attendance_correction" ? "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"); await ensureWorkflowApprovalAllowed(tx, workflowInstance, request, assignment); 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 === "attendance_correction") { await tx.attendanceCorrection.update({ where: { id: workflowInstance.entity_id }, data: { status: "rejected", updated_by: 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, attendanceCorrectionStatus: workflowInstance.entity_type === "attendance_correction" ? "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 && error.details?.workflow === "self_approval_not_allowed") { return { taskId, outcome: "error", reason: "self_approval_not_allowed", }; } 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/summary", { schema: { tags: ["Workflow"], summary: "Workflow Task Summary" }, preHandler: fastify.authenticate, }, async (request, reply) => { const query = parseTaskSummaryQuery(request.query); if (query.scope === "system" && !hasPermission(request.employee.permissionCodes ?? [], "system.audit.view")) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.audit.view" }, }); } const visibilityFilters = query.scope === "system" ? null : await buildTaskVisibilityFilters(fastify.prisma, request); const where = { status: "pending", ...(visibilityFilters ? { OR: visibilityFilters } : {}), workflow_instance: { is: buildWorkflowEntityVisibilityWhere(request) }, }; const tasks = await fastify.prisma.workflowTask.findMany({ where, orderBy: { created_at: "asc" }, select: { id: true, step_id: true, step_name: true, assignee_id: true, assignee_role: true, status: true, created_at: true, due_date: true, workflow_instance: { select: { entity_type: true, }, }, }, }); return sendData(reply, request, buildWorkflowTaskSummaryPayload(tasks, query.scope)); }, ); 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, workflow_instance: { is: buildWorkflowEntityVisibilityWhere(request, query.entityType ?? null) }, }; 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, reimbursement_form: { select: { form_number: true, status: true, total_amount: true, description: true, owner: { select: { name: true, }, }, submitter: { select: { name: true, }, }, }, }, leave_request: { select: { status: true, requested_days: true, leave_type: { select: { name: true, }, }, owner: { select: { name: true, }, }, }, }, attendance_correction: { select: { status: true, kind: true, work_date: true, start_at: true, end_at: true, reason: true, owner: { select: { name: true, }, }, }, }, purchase_request: { select: { request_no: true, status: true, title: true, total_amount: true, currency_code: true, applicant: { select: { name: true, }, }, }, }, travel_request: { select: { request_no: true, status: true, purpose: true, estimated_amount: true, applicant: { select: { name: 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, workflow_instance: { is: buildWorkflowEntityVisibilityWhere(request) }, }, 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, context_data: 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, }, }, }, }, attendance_correction: { select: { id: true, status: true, kind: true, work_date: true, missed_punch_at: true, start_at: true, end_at: true, reason: true, location_summary: true, compensation_policy: 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, }, }, }, }); const reimbursementSubmissionSnapshot = task.workflow_instance?.entity_type === "reimbursement" ? projectReimbursementSubmissionSnapshot(task.workflow_instance.context_data) : null; return sendData(reply, request, { task: { id: task.id, instanceId: task.instance_id, stepId: task.step_id, stepName: resolveWorkflowStepDisplayName(task), 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, contextData: { standardViolations: reimbursementSubmissionSnapshot?.standardViolations ?? [], budgetCheck: reimbursementSubmissionSnapshot?.budgetCheck ?? null, budgetCheckStatus: reimbursementSubmissionSnapshot?.budgetCheckStatus ?? "missing", parseStatus: reimbursementSubmissionSnapshot?.parseStatus ?? "ready", }, 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, attendanceCorrection: task.workflow_instance?.attendance_correction ? { id: task.workflow_instance.attendance_correction.id, status: task.workflow_instance.attendance_correction.status, kind: task.workflow_instance.attendance_correction.kind, workDate: formatDateOnly(task.workflow_instance.attendance_correction.work_date), missedPunchAt: task.workflow_instance.attendance_correction.missed_punch_at?.toISOString?.() ?? null, startAt: task.workflow_instance.attendance_correction.start_at?.toISOString?.() ?? null, endAt: task.workflow_instance.attendance_correction.end_at?.toISOString?.() ?? null, reason: task.workflow_instance.attendance_correction.reason ?? null, locationSummary: task.workflow_instance.attendance_correction.location_summary ?? null, compensationPolicy: task.workflow_instance.attendance_correction.compensation_policy ?? null, owner: task.workflow_instance.attendance_correction.owner ? { id: task.workflow_instance.attendance_correction.owner.id, employeeNo: task.workflow_instance.attendance_correction.owner.employee_no, name: task.workflow_instance.attendance_correction.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/remind", { preHandler: fastify.authenticate, }, async (request, reply) => { const taskId = parseTaskId(request.params?.taskId); const body = parseRemindTaskBody(request.body); const result = await executeRemindWorkflowTask({ fastify, request, taskId, comment: body.comment, }); return sendData(reply, request, result); }, ); 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"); await ensureWorkflowApprovalAllowed(tx, workflowInstance, request, assignment); 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 applicantIds = await ensureWorkflowApprovalAllowed(tx, workflowInstance, request, assignment); const transferCount = await ensureTransferChainNotExceeded(tx, taskId); await ensureTransferTargetEligible(tx, toApproverId, approvePermission); ensureNoSelfApproval([toApproverId], applicantIds); await ensureFinanceOwnerCeoActors(tx, workflowInstance, [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, }, }); 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, buildOverdueTaskCopy, buildPendingTaskCopy, buildRejectedCopy, buildRemindedTaskCopy, buildReturnedCopy, buildWorkflowTaskSummaryPayload, buildWorkflowEntityMeta, buildWorkflowEntityVisibilityWhere, calculateWorkflowTaskDueAt, canViewWorkflowEntityType, employeeHasBusinessApproveRole, handlePurchaseApprovalErpSync, isWorkflowTaskOverdue, listAllowedActionsForTask, parseTaskListQuery, queueWorkflowOutcomeNotifications, resolveWorkflowOutcomeNotificationType, };