文件
hyb-oa/server/src/routes/workflow.js
T
wangsli 229867f5fd
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
fix: 财务本人报销由总经理独立审批(09-04-reimbursement-state-gap-finance-view)
2026-09-12 20:03:29 +08:00

3373 行
103 KiB
JavaScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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,
};