文件
hyb-oa/server/src/routes/workflow.js
T

2411 行
71 KiB
JavaScript

const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { sendData } = require("../utils/reply");
const { resolvePermissionsForRoles, hasPermission, ROLE_PERMISSIONS, resolveEntityPermission } = require("../constants/permissions");
const { getWorkflowStepsSnapshot, resolveActionableStep, toMoneyString } = require("../utils/workflow");
const { consumeReservedLeave, releaseReservedLeave, formatDateOnly } = require("../services/leave");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { confirmBudgetForEntity, releaseReservedBudgetIfAny } = require("../services/purchaseBudget");
const {
PROCUREMENT_ERP_TARGETS,
enqueuePurchaseCommitmentJournalExport,
loadPurchaseRequestErpContext,
} = require("../services/integrations/erpProcurement");
const {
createNotificationsWithRecords,
resolveRecipientsForAssignee,
} = require("../services/notifications");
const {
buildApprovedCopy,
buildPendingTaskCopy,
buildRejectedCopy,
buildReturnedCopy,
buildWorkflowEntityMeta,
normalizeWorkflowEntityType,
} = require("../services/notifications/workflowCopy");
const { sendNotificationEmailSafe } = require("../services/email");
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher");
const {
findActiveDelegationForDelegate,
listActiveDelegationPrincipals,
} = require("../utils/delegation");
const TASK_ID_SCHEMA = z.string().uuid();
const REQUIRED_COMMENT_SCHEMA = z.object({
comment: z.string().trim().min(1).max(500),
});
const TRANSFER_BODY_SCHEMA = z.object({
toApproverId: z.string().uuid(),
reason: z.string().max(500),
});
const BATCH_APPROVE_BODY_SCHEMA = z.object({
taskIds: z.array(z.string().uuid()).min(1).max(50),
comment: z.string().trim().max(500).optional(),
});
const BATCH_REJECT_BODY_SCHEMA = z.object({
taskIds: z.array(z.string().uuid()).min(1).max(50),
comment: z.string().trim().min(1).max(500),
});
const TASK_LIST_QUERY_SCHEMA = z.object({
status: z.enum(["pending", "completed", "rejected", "cancelled", "skipped"]).optional(),
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
});
const APPROVER_LIST_QUERY_SCHEMA = z.object({
keyword: z.string().trim().max(100).optional(),
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
});
const APPROVER_ROLE_CODES = Object.keys(ROLE_PERMISSIONS).filter((roleCode) =>
ROLE_PERMISSIONS[roleCode].some((permissionCode) => permissionCode.endsWith(".approve")),
);
function parseRequiredComment(body) {
const parsed = REQUIRED_COMMENT_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return parsed.data.comment;
}
function parseTransferBody(body) {
const parsed = TRANSFER_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const reason = parsed.data.reason.trim();
if (reason.length < 10) {
throw new AppError({
code: "VALID_002",
message: "转审原因不足 10 字",
statusCode: 400,
details: { reason: "too_short" },
});
}
return {
toApproverId: parsed.data.toApproverId,
reason,
};
}
function parseTaskId(taskId) {
const parsed = TASK_ID_SCHEMA.safeParse(taskId);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { taskId: "invalid_uuid" },
});
}
return parsed.data;
}
function parseBatchApproveBody(body) {
const parsed = BATCH_APPROVE_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return {
taskIds: Array.from(new Set(parsed.data.taskIds)),
comment: parsed.data.comment?.trim() || null,
};
}
function parseBatchRejectBody(body) {
const parsed = BATCH_REJECT_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return {
taskIds: Array.from(new Set(parsed.data.taskIds)),
comment: parsed.data.comment.trim(),
};
}
async function handlePurchaseApprovalErpSync({
tx,
config,
request,
actorId,
purchaseRequestId,
loadContext = loadPurchaseRequestErpContext,
evaluateGate = evaluateFeatureFlag,
enqueueCommitment = enqueuePurchaseCommitmentJournalExport,
}) {
const context = await loadContext(tx, purchaseRequestId);
if (!context?.purchaseRequest) {
return { skipped: true, reason: 'purchase_request_not_found' };
}
const purchaseRequest = context.purchaseRequest;
const departmentId = purchaseRequest.department_id ?? request.employee.department_id ?? null;
const gate = await evaluateGate({
prisma: tx,
config,
flagKey: FEATURE_FLAG_KEYS.erpSync,
departmentId,
strictDepartment: true,
});
if (!gate.enabled) {
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId,
action: 'ERP_SYNC_SKIPPED_BY_FEATURE_FLAG',
targetType: 'purchase_request',
targetId: purchaseRequest.id,
extra: {
erpTarget: PROCUREMENT_ERP_TARGETS.commitmentJournal,
eventType: 'erp.journal.export',
purchaseRequestId: purchaseRequest.id,
purchaseOrderArchiveId: null,
gateReason: gate.reason,
featureFlag: FEATURE_FLAG_KEYS.erpSync,
departmentId,
},
}),
});
return { skipped: true, gate, purchaseRequest };
}
const enqueued = await enqueueCommitment(tx, config, {
purchaseRequest,
occurredAt: context.approvalOccurredAt ?? new Date(),
actorId,
requeueOnFailure: false,
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId,
action: 'ERP_SYNC_ENQUEUED',
targetType: 'purchase_request',
targetId: purchaseRequest.id,
extra: {
erpTarget: PROCUREMENT_ERP_TARGETS.commitmentJournal,
eventType: enqueued.eventType,
purchaseRequestId: purchaseRequest.id,
purchaseOrderArchiveId: null,
outboxId: enqueued.id,
duplicated: enqueued.duplicated,
requeued: enqueued.requeued,
gateReason: gate.reason,
featureFlag: FEATURE_FLAG_KEYS.erpSync,
departmentId,
},
}),
});
return {
skipped: false,
gate,
purchaseRequest,
enqueued,
};
}
function parseTaskListQuery(query) {
const parsed = TASK_LIST_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return {
status: parsed.data.status ?? "pending",
page: parsed.data.page ?? 1,
pageSize: parsed.data.pageSize ?? 20,
};
}
function parseApproverListQuery(query) {
const parsed = APPROVER_LIST_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return {
keyword: parsed.data.keyword ?? "",
page: parsed.data.page ?? 1,
pageSize: parsed.data.pageSize ?? 20,
};
}
function isoOrNull(value) {
if (!value) {
return null;
}
return value.toISOString();
}
function mapTaskSummary(task, allowedActions = []) {
const workflowInstance = task.workflow_instance
? {
entity_type: task.workflow_instance.entity_type,
entity_id: task.workflow_instance.entity_id,
current_status: task.workflow_instance.current_status,
}
: null;
return {
id: task.id,
instance_id: task.instance_id,
step_id: task.step_id,
step_name: task.step_name,
assignee_id: task.assignee_id,
assignee_role: task.assignee_role,
status: task.status,
outcome: task.outcome,
created_at: isoOrNull(task.created_at),
completed_at: isoOrNull(task.completed_at),
workflow_instance: workflowInstance,
instanceId: task.instance_id,
stepId: task.step_id,
stepName: task.step_name,
assigneeId: task.assignee_id,
assigneeRole: task.assignee_role,
createdAt: isoOrNull(task.created_at),
completedAt: isoOrNull(task.completed_at),
allowedActions,
workflowInstance: workflowInstance
? {
entityType: workflowInstance.entity_type,
entityId: workflowInstance.entity_id,
currentStatus: workflowInstance.current_status,
}
: null,
};
}
async function buildTaskVisibilityFilters(tx, request) {
const roleFilters = request.employee.roleCodes.length
? [{ assignee_role: { in: request.employee.roleCodes } }]
: [];
const delegatedPrincipalIds = await listActiveDelegationPrincipals(tx, {
delegateEmployeeId: request.employee.id,
allowedScopes: ["all"],
});
const assigneeFilters = [{ assignee_id: request.employee.id }, ...roleFilters];
if (delegatedPrincipalIds.length) {
assigneeFilters.push({ assignee_id: { in: delegatedPrincipalIds } });
}
return assigneeFilters;
}
function listAllowedActionsForTask(request, task, actionable) {
if (task.status !== "pending" || !actionable) {
return [];
}
const actions = [];
const approvePermission = resolveEntityPermission(task.workflow_instance?.entity_type, "approve");
const canApprove = approvePermission && hasPermission(request.employee.permissionCodes, approvePermission);
if (canApprove) {
actions.push("approve", "reject", "return");
}
if (canApprove && hasPermission(request.employee.permissionCodes, "expense.transfer")) {
actions.push("transfer");
}
return actions;
}
async function lockWorkflowTask(tx, taskId) {
const taskRows = await tx.$queryRaw`
SELECT id, instance_id, step_id, step_name, assignee_id, assignee_role, status
FROM "workflow_tasks"
WHERE id = ${taskId}::uuid
FOR UPDATE
`;
if (!Array.isArray(taskRows) || taskRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
return taskRows[0];
}
function ensurePendingTask(task) {
if (task.status !== "pending") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { task: "already_processed" },
});
}
}
async function ensureTaskAssignableToCurrentEmployee(tx, task, request) {
const matchesAssigneeId = Boolean(task.assignee_id && task.assignee_id === request.employee.id);
const matchesAssigneeRole = Boolean(
task.assignee_role && request.employee.roleCodes.includes(task.assignee_role),
);
const isUnassigned = !task.assignee_id && !task.assignee_role;
if (matchesAssigneeId || matchesAssigneeRole || isUnassigned) {
return {
delegated: false,
delegationId: null,
principalId: null,
};
}
if (task.assignee_id) {
const delegation = await findActiveDelegationForDelegate(tx, {
principalEmployeeId: task.assignee_id,
delegateEmployeeId: request.employee.id,
allowedScopes: ["all"],
});
if (delegation) {
return {
delegated: true,
delegationId: delegation.id,
principalId: delegation.principal_employee_id,
};
}
}
throw new AppError({
code: "PERM_002",
message: "不在审批范围/非当前处理人",
statusCode: 403,
});
}
function withDelegationPayload(payload, assignment, delegateId) {
const nextPayload = payload ? { ...payload } : {};
if (assignment?.delegated) {
nextPayload.delegation_id = assignment.delegationId;
nextPayload.principal_id = assignment.principalId;
nextPayload.delegate_id = delegateId;
}
return nextPayload;
}
async function loadWorkflowInstanceForTask(tx, instanceId) {
const workflowInstance = await tx.workflowInstance.findUnique({
where: { id: instanceId },
select: {
id: true,
entity_type: true,
entity_id: true,
current_step_id: true,
context_data: true,
workflow_definition: {
select: {
definition_json: true,
},
},
},
});
if (!workflowInstance) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
return workflowInstance;
}
async function loadReimbursementNotifyContext(tx, reimbursementId) {
return tx.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: {
id: true,
form_number: true,
total_amount: true,
owner_id: true,
submitter_id: true,
owner: {
select: {
id: true,
name: true,
},
},
},
});
}
function uniq(values) {
return Array.from(new Set((values ?? []).filter(Boolean)));
}
async function loadLeaveNotifyContext(tx, leaveRequestId) {
return tx.leaveRequest.findUnique({
where: { id: leaveRequestId },
select: {
id: true,
owner_id: true,
start_date: true,
end_date: true,
requested_days: true,
leave_type: {
select: {
id: true,
code: true,
name: true,
},
},
owner: {
select: {
id: true,
name: true,
},
},
},
});
}
async function loadPurchaseNotifyContext(tx, purchaseRequestId) {
return tx.purchaseRequest.findUnique({
where: { id: purchaseRequestId },
select: {
id: true,
request_no: true,
total_amount: true,
applicant_id: true,
submitter_id: true,
applicant: {
select: {
id: true,
name: true,
},
},
},
});
}
async function loadTravelNotifyContext(tx, travelRequestId) {
return tx.travelRequest.findUnique({
where: { id: travelRequestId },
select: {
id: true,
request_no: true,
estimated_amount: true,
applicant_employee_id: true,
submitter_id: true,
applicant: {
select: {
id: true,
name: true,
},
},
},
});
}
async function loadWorkflowNotifyContext(tx, entityType, entityId) {
const normalizedEntityType = normalizeWorkflowEntityType(entityType);
if (normalizedEntityType === 'reimbursement') {
const record = await loadReimbursementNotifyContext(tx, entityId);
return {
record,
recipientEmployeeIds: uniq([record?.owner_id, record?.submitter_id]),
};
}
if (normalizedEntityType === 'leave') {
const record = await loadLeaveNotifyContext(tx, entityId);
return {
record,
recipientEmployeeIds: uniq([record?.owner_id]),
};
}
if (normalizedEntityType === 'purchase_request') {
const record = await loadPurchaseNotifyContext(tx, entityId);
return {
record,
recipientEmployeeIds: uniq([record?.applicant_id, record?.submitter_id]),
};
}
if (normalizedEntityType === 'travel_request') {
const record = await loadTravelNotifyContext(tx, entityId);
return {
record,
recipientEmployeeIds: uniq([record?.applicant_employee_id, record?.submitter_id]),
};
}
return {
record: null,
recipientEmployeeIds: [],
};
}
function resolveWorkflowOutcomeNotificationType(entityType, outcome) {
const normalizedEntityType = normalizeWorkflowEntityType(entityType);
if (outcome === 'approved') {
if (normalizedEntityType === 'reimbursement') return NOTIFICATION_TYPES.reimbursementApproved;
if (normalizedEntityType === 'leave') return NOTIFICATION_TYPES.leaveApproved;
if (normalizedEntityType === 'purchase_request') return NOTIFICATION_TYPES.purchaseRequestApproved;
if (normalizedEntityType === 'travel_request') return NOTIFICATION_TYPES.travelRequestApproved;
}
if (outcome === 'rejected') {
if (normalizedEntityType === 'reimbursement') return NOTIFICATION_TYPES.reimbursementRejected;
if (normalizedEntityType === 'leave') return NOTIFICATION_TYPES.leaveRejected;
if (normalizedEntityType === 'purchase_request') return NOTIFICATION_TYPES.purchaseRequestRejected;
if (normalizedEntityType === 'travel_request') return NOTIFICATION_TYPES.travelRequestRejected;
}
if (outcome === 'returned') {
if (normalizedEntityType === 'reimbursement') return NOTIFICATION_TYPES.reimbursementReturned;
}
return null;
}
async function queueTaskAssignedNotifications({
tx,
actorId,
emailTasks,
dispatchTasks,
recipientEmployeeIds,
title,
body,
taskId,
entityType,
entityId,
}) {
const recipients = uniq(recipientEmployeeIds);
if (!recipients.length) {
return;
}
const notifications = await createNotificationsWithRecords(tx, {
recipientEmployeeIds: recipients,
type: NOTIFICATION_TYPES.workflowTaskAssigned,
title,
body,
targetType: 'workflow_task',
targetId: taskId,
});
dispatchTasks.push({
notifications,
type: NOTIFICATION_TYPES.workflowTaskAssigned,
title,
body,
targetType: 'workflow_task',
targetId: taskId,
targetPath: `/workflow/tasks/${taskId}`,
actorId,
});
emailTasks.push({
type: NOTIFICATION_TYPES.workflowTaskAssigned,
recipientEmployeeIds: recipients,
subject: title,
body,
targetPath: `/workflow/tasks/${taskId}`,
});
}
async function queueWorkflowOutcomeNotifications({
tx,
actorId,
emailTasks,
dispatchTasks,
workflowInstance,
outcome,
comment = null,
}) {
const normalizedEntityType = normalizeWorkflowEntityType(workflowInstance.entity_type);
const notificationType = resolveWorkflowOutcomeNotificationType(normalizedEntityType, outcome);
if (!notificationType) {
return;
}
const context = await loadWorkflowNotifyContext(tx, normalizedEntityType, workflowInstance.entity_id);
const recipients = uniq(context.recipientEmployeeIds);
if (!recipients.length) {
return;
}
const entityMeta = buildWorkflowEntityMeta(normalizedEntityType, context.record, workflowInstance.entity_id);
const copy =
outcome === 'approved'
? buildApprovedCopy(entityMeta)
: outcome === 'rejected'
? buildRejectedCopy(entityMeta, comment)
: buildReturnedCopy(entityMeta, comment);
const notifications = await createNotificationsWithRecords(tx, {
recipientEmployeeIds: recipients,
type: notificationType,
title: copy.title,
body: copy.body,
targetType: entityMeta.targetType,
targetId: workflowInstance.entity_id,
});
dispatchTasks.push({
notifications,
type: notificationType,
title: copy.title,
body: copy.body,
targetType: entityMeta.targetType,
targetId: workflowInstance.entity_id,
targetPath: entityMeta.detailPath,
actorId,
});
emailTasks.push({
type: notificationType,
recipientEmployeeIds: recipients,
subject: copy.title,
body: copy.body,
targetPath: entityMeta.detailPath,
});
}
async function createSkipHistoryRecords(tx, workflowInstanceId, actorId, ownerId, skippedSteps) {
for (const skipped of skippedSteps) {
await tx.workflowHistory.create({
data: {
instance_id: workflowInstanceId,
actor_id: actorId,
action: "skip",
payload: {
step_id: skipped.step.id,
reason: skipped.reason,
owner_id: ownerId,
depth_checked: skipped.depthChecked,
},
},
});
}
}
async function cancelOtherPendingTasks(tx, workflowInstanceId, taskId, actorId) {
await tx.workflowTask.updateMany({
where: {
instance_id: workflowInstanceId,
status: "pending",
NOT: { id: taskId },
},
data: {
status: "cancelled",
completed_at: new Date(),
completed_by: actorId,
},
});
}
async function ensureTransferChainNotExceeded(tx, taskId) {
const transferCount = await tx.workflowHistory.count({
where: {
task_id: taskId,
action: "transfer",
},
});
if (transferCount >= 3) {
throw new AppError({
code: "WORKFLOW_003",
message: "已达转审上限",
statusCode: 409,
details: { transfer: "chain_limit_exceeded" },
});
}
return transferCount;
}
async function ensureTransferTargetEligible(tx, toApproverId, approvePermission) {
const target = await tx.employee.findUnique({
where: { id: toApproverId },
select: {
id: true,
status: true,
roles: {
select: {
role_code: true,
},
},
},
});
if (!target || target.status !== "active") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { toApproverId: "invalid_target" },
});
}
const roleCodes = target.roles.map((item) => item.role_code);
const permissionCodes = resolvePermissionsForRoles(roleCodes);
if (!approvePermission || !hasPermission(permissionCodes, approvePermission)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { toApproverId: "target_not_approver" },
});
}
}
async function flushWorkflowSideEffects({ fastify, request, emailTasks, dispatchTasks }) {
for (const task of emailTasks) {
void sendNotificationEmailSafe({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
...task,
});
}
await flushNotificationDispatchTasks({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
tasks: dispatchTasks,
});
}
function ensureWorkflowEntityType(workflowInstance, entityType) {
if (!entityType || workflowInstance.entity_type === entityType) {
return;
}
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
task: "unsupported_entity_type",
entityType: workflowInstance.entity_type,
expectedEntityType: entityType,
},
});
}
async function executeApproveWorkflowTask({
fastify,
request,
taskId,
allowedEntityType = null,
}) {
let result = null;
const emailTasks = [];
const dispatchTasks = [];
await fastify.prisma.$transaction(async (tx) => {
const task = await lockWorkflowTask(tx, taskId);
ensurePendingTask(task);
const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request);
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
ensureWorkflowEntityType(workflowInstance, allowedEntityType);
fastify.assertEntityPermission(request.employee, workflowInstance.entity_type, "approve");
const steps = getWorkflowStepsSnapshot(workflowInstance);
const currentStepIndex = steps.findIndex((step) => step.id === workflowInstance.current_step_id);
if (currentStepIndex < 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { workflow: "current_step_not_found" },
});
}
const now = new Date();
await tx.workflowTask.update({
where: { id: taskId },
data: {
status: "completed",
outcome: "approved",
completed_at: now,
completed_by: request.employee.id,
},
});
const { actionable, skipped } = await resolveActionableStep({
tx,
steps,
startIndex: currentStepIndex + 1,
ownerId: workflowInstance.context_data?.owner_id,
logger: fastify.log,
});
await createSkipHistoryRecords(
tx,
workflowInstance.id,
request.employee.id,
workflowInstance.context_data?.owner_id,
skipped,
);
if (actionable) {
const nextTask = await tx.workflowTask.create({
data: {
instance_id: workflowInstance.id,
step_id: actionable.step.id,
step_name: actionable.step.name ?? actionable.step.id,
assignee_id: actionable.assigneeId,
assignee_role: actionable.assigneeRole,
},
select: { id: true },
});
await tx.workflowInstance.update({
where: { id: workflowInstance.id },
data: {
current_step_id: actionable.step.id,
current_status: "pending",
updated_by: request.employee.id,
},
});
await tx.workflowHistory.create({
data: {
instance_id: workflowInstance.id,
task_id: taskId,
actor_id: request.employee.id,
action: "approve",
payload: withDelegationPayload(
{
from_step_id: task.step_id,
to_step_id: actionable.step.id,
},
assignment,
request.employee.id,
),
},
});
try {
const notificationContext = await loadWorkflowNotifyContext(
tx,
workflowInstance.entity_type,
workflowInstance.entity_id,
);
const entityMeta = buildWorkflowEntityMeta(
workflowInstance.entity_type,
notificationContext.record,
workflowInstance.entity_id,
);
const pendingCopy = buildPendingTaskCopy(entityMeta);
const recipients = await resolveRecipientsForAssignee(tx, {
assigneeId: actionable.assigneeId,
assigneeRole: actionable.assigneeRole,
});
await queueTaskAssignedNotifications({
tx,
actorId: request.employee.id,
emailTasks,
dispatchTasks,
recipientEmployeeIds: recipients,
title: pendingCopy.title,
body: pendingCopy.body,
taskId: nextTask.id,
entityType: workflowInstance.entity_type,
entityId: workflowInstance.entity_id,
});
} catch (error) {
fastify.log.error(
{ err: error, taskId: nextTask.id },
"Failed to create task assigned notifications",
);
}
result = {
taskId,
outcome: "approved",
workflowInstanceId: workflowInstance.id,
nextTaskId: nextTask.id,
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "reviewing" : null,
};
return;
}
await tx.workflowInstance.update({
where: { id: workflowInstance.id },
data: {
current_status: "approved",
completed_at: now,
updated_by: request.employee.id,
},
});
if (workflowInstance.entity_type === "reimbursement") {
await tx.reimbursementForm.update({
where: { id: workflowInstance.entity_id },
data: {
status: "approved",
updated_by: request.employee.id,
},
});
} else if (workflowInstance.entity_type === "leave") {
const leaveRequest = await tx.leaveRequest.findUnique({
where: { id: workflowInstance.entity_id },
select: {
id: true,
owner_id: true,
leave_type_id: true,
requested_days: true,
},
});
const reserveAnchor = await tx.leaveLedger.findFirst({
where: {
leave_request_id: workflowInstance.entity_id,
entry_type: "reserve",
},
orderBy: { created_at: "desc" },
select: {
leave_policy_id: true,
period_start: true,
period_end: true,
},
});
await tx.leaveRequest.update({
where: { id: workflowInstance.entity_id },
data: {
status: "approved",
updated_by: request.employee.id,
},
});
if (leaveRequest) {
await consumeReservedLeave(tx, {
leaveRequestId: workflowInstance.entity_id,
employeeId: leaveRequest.owner_id,
leaveTypeId: leaveRequest.leave_type_id,
leavePolicyId: reserveAnchor?.leave_policy_id ?? null,
periodStart: reserveAnchor?.period_start ?? null,
periodEnd: reserveAnchor?.period_end ?? null,
requestedDays: leaveRequest.requested_days,
createdBy: request.employee.id,
});
}
} else if (workflowInstance.entity_type === "purchase_request") {
await tx.purchaseRequest.update({
where: { id: workflowInstance.entity_id },
data: {
status: "approved",
updated_by: request.employee.id,
},
});
await confirmBudgetForEntity(tx, {
entityType: "purchase_request",
entityId: workflowInstance.entity_id,
actorId: request.employee.id,
note: "approved_confirm_purchase_budget",
});
try {
await handlePurchaseApprovalErpSync({
tx,
config: fastify.config,
request,
actorId: request.employee.id,
purchaseRequestId: workflowInstance.entity_id,
});
} catch (error) {
fastify.log.error(
{ err: error, purchaseRequestId: workflowInstance.entity_id },
"Failed to enqueue purchase approval ERP events",
);
}
} else if (workflowInstance.entity_type === "travel_request") {
await tx.travelRequest.update({
where: { id: workflowInstance.entity_id },
data: {
status: "approved",
updated_by: request.employee.id,
},
});
await confirmBudgetForEntity(tx, {
entityType: "travel_request",
entityId: workflowInstance.entity_id,
actorId: request.employee.id,
note: "approved_confirm_travel_budget",
});
}
await tx.workflowHistory.create({
data: {
instance_id: workflowInstance.id,
task_id: taskId,
actor_id: request.employee.id,
action: "approve",
payload: withDelegationPayload(
{
from_step_id: task.step_id,
terminal: true,
},
assignment,
request.employee.id,
),
},
});
try {
await queueWorkflowOutcomeNotifications({
tx,
actorId: request.employee.id,
emailTasks,
dispatchTasks,
workflowInstance,
outcome: "approved",
});
} catch (error) {
fastify.log.error(
{ err: error, taskId, entityType: workflowInstance.entity_type, entityId: workflowInstance.entity_id },
"Failed to create approval outcome notifications",
);
}
result = {
taskId,
outcome: "approved",
workflowInstanceId: workflowInstance.id,
nextTaskId: null,
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "approved" : null,
purchaseRequestStatus: workflowInstance.entity_type === "purchase_request" ? "approved" : null,
travelRequestStatus: workflowInstance.entity_type === "travel_request" ? "approved" : null,
};
});
return { result, emailTasks, dispatchTasks };
}
async function executeRejectWorkflowTask({
fastify,
request,
taskId,
comment,
allowedEntityType = null,
}) {
let result = null;
const emailTasks = [];
const dispatchTasks = [];
await fastify.prisma.$transaction(async (tx) => {
const task = await lockWorkflowTask(tx, taskId);
ensurePendingTask(task);
const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request);
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
ensureWorkflowEntityType(workflowInstance, allowedEntityType);
fastify.assertEntityPermission(request.employee, workflowInstance.entity_type, "approve");
const now = new Date();
await tx.workflowTask.update({
where: { id: taskId },
data: {
status: "rejected",
outcome: "rejected",
comment,
completed_at: now,
completed_by: request.employee.id,
},
});
await cancelOtherPendingTasks(tx, workflowInstance.id, taskId, request.employee.id);
await tx.workflowInstance.update({
where: { id: workflowInstance.id },
data: {
current_status: "rejected",
completed_at: now,
updated_by: request.employee.id,
},
});
if (workflowInstance.entity_type === "reimbursement") {
await tx.reimbursementForm.update({
where: { id: workflowInstance.entity_id },
data: {
status: "rejected",
updated_by: request.employee.id,
},
});
} else if (workflowInstance.entity_type === "leave") {
const leaveRequest = await tx.leaveRequest.findUnique({
where: { id: workflowInstance.entity_id },
select: {
id: true,
owner_id: true,
leave_type_id: true,
},
});
const reserveAnchor = await tx.leaveLedger.findFirst({
where: {
leave_request_id: workflowInstance.entity_id,
entry_type: "reserve",
},
orderBy: { created_at: "desc" },
select: {
leave_policy_id: true,
period_start: true,
period_end: true,
},
});
await tx.leaveRequest.update({
where: { id: workflowInstance.entity_id },
data: {
status: "rejected",
updated_by: request.employee.id,
},
});
if (leaveRequest) {
await releaseReservedLeave(tx, {
leaveRequestId: workflowInstance.entity_id,
employeeId: leaveRequest.owner_id,
leaveTypeId: leaveRequest.leave_type_id,
leavePolicyId: reserveAnchor?.leave_policy_id ?? null,
periodStart: reserveAnchor?.period_start ?? null,
periodEnd: reserveAnchor?.period_end ?? null,
note: "rejected_release_leave",
payload: { source: "workflow_reject" },
createdBy: request.employee.id,
});
}
} else if (workflowInstance.entity_type === "purchase_request") {
await tx.purchaseRequest.update({
where: { id: workflowInstance.entity_id },
data: {
status: "rejected",
updated_by: request.employee.id,
},
});
await releaseReservedBudgetIfAny(tx, {
entityType: "purchase_request",
entityId: workflowInstance.entity_id,
actorId: request.employee.id,
note: "rejected_release_purchase_budget",
});
} else if (workflowInstance.entity_type === "travel_request") {
await tx.travelRequest.update({
where: { id: workflowInstance.entity_id },
data: {
status: "rejected",
updated_by: request.employee.id,
},
});
await releaseReservedBudgetIfAny(tx, {
entityType: "travel_request",
entityId: workflowInstance.entity_id,
actorId: request.employee.id,
note: "rejected_release_travel_budget",
});
}
await tx.workflowHistory.create({
data: {
instance_id: workflowInstance.id,
task_id: taskId,
actor_id: request.employee.id,
action: "reject",
comment,
payload: withDelegationPayload(
{
from_step_id: task.step_id,
},
assignment,
request.employee.id,
),
},
});
try {
await queueWorkflowOutcomeNotifications({
tx,
actorId: request.employee.id,
emailTasks,
dispatchTasks,
workflowInstance,
outcome: "rejected",
comment,
});
} catch (error) {
fastify.log.error(
{ err: error, taskId, entityType: workflowInstance.entity_type, entityId: workflowInstance.entity_id },
"Failed to create rejection outcome notifications",
);
}
result = {
taskId,
outcome: "rejected",
workflowInstanceId: workflowInstance.id,
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "rejected" : null,
purchaseRequestStatus: workflowInstance.entity_type === "purchase_request" ? "rejected" : null,
travelRequestStatus: workflowInstance.entity_type === "travel_request" ? "rejected" : null,
};
});
return { result, emailTasks, dispatchTasks };
}
function mapBatchTaskError(taskId, error) {
if (
error instanceof AppError &&
error.code === "VALID_001" &&
error.statusCode === 409 &&
error.details?.task === "already_processed"
) {
return {
taskId,
outcome: "skipped",
reason: "already_processed",
};
}
if (error instanceof AppError) {
return {
taskId,
outcome: "error",
reason: `${error.code}: ${error.message}`,
};
}
return {
taskId,
outcome: "error",
reason: error instanceof Error ? error.message : "unknown_error",
};
}
async function workflowRoutes(fastify) {
fastify.get(
"/workflow/tasks",
{
schema: { tags: ["Workflow"], summary: "List Workflow Tasks" },
preHandler: fastify.authenticate,
},
async (request, reply) => {
const query = parseTaskListQuery(request.query);
const skip = (query.page - 1) * query.pageSize;
const assigneeFilters = await buildTaskVisibilityFilters(fastify.prisma, request);
const where = {
status: query.status,
OR: assigneeFilters,
};
const [tasks, total] = await Promise.all([
fastify.prisma.workflowTask.findMany({
where,
orderBy: { created_at: "desc" },
skip,
take: query.pageSize,
select: {
id: true,
instance_id: true,
step_id: true,
step_name: true,
assignee_id: true,
assignee_role: true,
status: true,
outcome: true,
created_at: true,
completed_at: true,
workflow_instance: {
select: {
entity_type: true,
entity_id: true,
current_status: true,
},
},
},
}),
fastify.prisma.workflowTask.count({ where }),
]);
const taskSummaries = await Promise.all(
tasks.map(async (task) => {
let actionable = false;
if (task.status === "pending") {
try {
await ensureTaskAssignableToCurrentEmployee(fastify.prisma, task, request);
actionable = true;
} catch (error) {
if (error instanceof AppError && error.code === "PERM_002") {
actionable = false;
} else {
throw error;
}
}
}
return mapTaskSummary(task, listAllowedActionsForTask(request, task, actionable));
}),
);
return sendData(reply, request, {
page: query.page,
pageSize: query.pageSize,
total,
items: taskSummaries,
});
},
);
fastify.get(
"/workflow/tasks/:taskId",
{
schema: { tags: ["Workflow"], summary: "Get Workflow Task Detail" },
preHandler: fastify.authenticate,
},
async (request, reply) => {
const taskId = parseTaskId(request.params?.taskId);
const assigneeFilters = await buildTaskVisibilityFilters(fastify.prisma, request);
const task = await fastify.prisma.workflowTask.findFirst({
where: {
id: taskId,
OR: assigneeFilters,
},
select: {
id: true,
instance_id: true,
step_id: true,
step_name: true,
assignee_id: true,
assignee_role: true,
status: true,
outcome: true,
comment: true,
created_at: true,
completed_at: true,
workflow_instance: {
select: {
id: true,
entity_type: true,
entity_id: true,
current_status: true,
current_step_id: true,
started_at: true,
completed_at: true,
reimbursement_form: {
select: {
id: true,
form_number: true,
status: true,
total_amount: true,
reimbursement_date: true,
description: true,
owner: {
select: {
id: true,
employee_no: true,
name: true,
},
},
submitter: {
select: {
id: true,
employee_no: true,
name: true,
},
},
},
},
leave_request: {
select: {
id: true,
status: true,
start_date: true,
end_date: true,
start_session: true,
end_session: true,
requested_days: true,
reason: true,
attachment_original_name: true,
attachment_mime_type: true,
leave_type: {
select: {
id: true,
code: true,
name: true,
},
},
owner: {
select: {
id: true,
employee_no: true,
name: true,
},
},
},
},
purchase_request: {
select: {
id: true,
request_no: true,
status: true,
title: true,
reason: true,
currency_code: true,
total_amount: true,
expected_delivery_date: true,
applicant: {
select: {
id: true,
employee_no: true,
name: true,
},
},
supplier: {
select: {
id: true,
code: true,
name: true,
},
},
legal_entity: {
select: {
id: true,
code: true,
name: true,
},
},
cost_center: {
select: {
id: true,
code: true,
name: true,
},
},
document_category: {
select: {
id: true,
code: true,
name: true,
},
},
},
},
travel_request: {
select: {
id: true,
request_no: true,
status: true,
purpose: true,
trip_type: true,
start_date: true,
end_date: true,
estimated_amount: true,
applicant: {
select: {
id: true,
employee_no: true,
name: true,
},
},
legal_entity: {
select: {
id: true,
code: true,
name: true,
},
},
cost_center: {
select: {
id: true,
code: true,
name: true,
},
},
segments: {
orderBy: [{ sort_order: "asc" }, { created_at: "asc" }],
select: {
id: true,
sort_order: true,
segment_role: true,
transport_type: true,
from_city: true,
to_city: true,
},
},
},
},
},
},
},
});
if (!task) {
const existing = await fastify.prisma.workflowTask.findUnique({
where: { id: taskId },
select: { id: true },
});
if (existing) {
throw new AppError({
code: "PERM_002",
message: "不在审批范围/非当前处理人",
statusCode: 403,
});
}
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
let actionable = false;
if (task.status === "pending") {
try {
await ensureTaskAssignableToCurrentEmployee(fastify.prisma, task, request);
actionable = true;
} catch (error) {
if (error instanceof AppError && error.code === "PERM_002") {
actionable = false;
} else {
throw error;
}
}
}
const historyItems = await fastify.prisma.workflowHistory.findMany({
where: { instance_id: task.instance_id },
orderBy: { created_at: "asc" },
select: {
id: true,
task_id: true,
actor_id: true,
action: true,
comment: true,
payload: true,
created_at: true,
actor: {
select: {
id: true,
name: true,
employee_no: true,
},
},
},
});
return sendData(reply, request, {
task: {
id: task.id,
instanceId: task.instance_id,
stepId: task.step_id,
stepName: task.step_name,
assigneeId: task.assignee_id,
assigneeRole: task.assignee_role,
status: task.status,
outcome: task.outcome,
comment: task.comment,
createdAt: isoOrNull(task.created_at),
completedAt: isoOrNull(task.completed_at),
},
workflowInstance: task.workflow_instance
? {
id: task.workflow_instance.id,
entityType: task.workflow_instance.entity_type,
currentStatus: task.workflow_instance.current_status,
currentStepId: task.workflow_instance.current_step_id,
startedAt: isoOrNull(task.workflow_instance.started_at),
completedAt: isoOrNull(task.workflow_instance.completed_at),
}
: null,
reimbursement: task.workflow_instance?.reimbursement_form
? {
id: task.workflow_instance.reimbursement_form.id,
formNumber: task.workflow_instance.reimbursement_form.form_number,
status: task.workflow_instance.reimbursement_form.status,
reimbursementDate:
task.workflow_instance.reimbursement_form.reimbursement_date
?.toISOString?.()
?.slice(0, 10) ?? null,
totalAmount: toMoneyString(task.workflow_instance.reimbursement_form.total_amount),
description: task.workflow_instance.reimbursement_form.description,
owner: task.workflow_instance.reimbursement_form.owner
? {
id: task.workflow_instance.reimbursement_form.owner.id,
employeeNo: task.workflow_instance.reimbursement_form.owner.employee_no,
name: task.workflow_instance.reimbursement_form.owner.name,
}
: null,
submitter: task.workflow_instance.reimbursement_form.submitter
? {
id: task.workflow_instance.reimbursement_form.submitter.id,
employeeNo: task.workflow_instance.reimbursement_form.submitter.employee_no,
name: task.workflow_instance.reimbursement_form.submitter.name,
}
: null,
}
: null,
leaveRequest: task.workflow_instance?.leave_request
? {
id: task.workflow_instance.leave_request.id,
status: task.workflow_instance.leave_request.status,
startDate: formatDateOnly(task.workflow_instance.leave_request.start_date),
endDate: formatDateOnly(task.workflow_instance.leave_request.end_date),
startSession: task.workflow_instance.leave_request.start_session,
endSession: task.workflow_instance.leave_request.end_session,
requestedDays: Number(task.workflow_instance.leave_request.requested_days),
reason: task.workflow_instance.leave_request.reason ?? null,
attachment:
task.workflow_instance.leave_request.attachment_original_name ||
task.workflow_instance.leave_request.attachment_mime_type
? {
originalName: task.workflow_instance.leave_request.attachment_original_name ?? null,
mimeType: task.workflow_instance.leave_request.attachment_mime_type ?? null,
}
: null,
leaveType: task.workflow_instance.leave_request.leave_type
? {
id: task.workflow_instance.leave_request.leave_type.id,
code: task.workflow_instance.leave_request.leave_type.code,
name: task.workflow_instance.leave_request.leave_type.name,
}
: null,
owner: task.workflow_instance.leave_request.owner
? {
id: task.workflow_instance.leave_request.owner.id,
employeeNo: task.workflow_instance.leave_request.owner.employee_no,
name: task.workflow_instance.leave_request.owner.name,
}
: null,
}
: null,
purchaseRequest: task.workflow_instance?.purchase_request
? {
id: task.workflow_instance.purchase_request.id,
requestNo: task.workflow_instance.purchase_request.request_no ?? null,
status: task.workflow_instance.purchase_request.status,
title: task.workflow_instance.purchase_request.title,
reason: task.workflow_instance.purchase_request.reason ?? null,
currencyCode: task.workflow_instance.purchase_request.currency_code,
totalAmount: toMoneyString(task.workflow_instance.purchase_request.total_amount),
expectedDeliveryDate: formatDateOnly(task.workflow_instance.purchase_request.expected_delivery_date),
applicant: task.workflow_instance.purchase_request.applicant
? {
id: task.workflow_instance.purchase_request.applicant.id,
employeeNo: task.workflow_instance.purchase_request.applicant.employee_no,
name: task.workflow_instance.purchase_request.applicant.name,
}
: null,
supplier: task.workflow_instance.purchase_request.supplier
? {
id: task.workflow_instance.purchase_request.supplier.id,
code: task.workflow_instance.purchase_request.supplier.code,
name: task.workflow_instance.purchase_request.supplier.name,
}
: null,
legalEntity: task.workflow_instance.purchase_request.legal_entity
? {
id: task.workflow_instance.purchase_request.legal_entity.id,
code: task.workflow_instance.purchase_request.legal_entity.code,
name: task.workflow_instance.purchase_request.legal_entity.name,
}
: null,
costCenter: task.workflow_instance.purchase_request.cost_center
? {
id: task.workflow_instance.purchase_request.cost_center.id,
code: task.workflow_instance.purchase_request.cost_center.code,
name: task.workflow_instance.purchase_request.cost_center.name,
}
: null,
documentCategory: task.workflow_instance.purchase_request.document_category
? {
id: task.workflow_instance.purchase_request.document_category.id,
code: task.workflow_instance.purchase_request.document_category.code,
name: task.workflow_instance.purchase_request.document_category.name,
}
: null,
}
: null,
travelRequest: task.workflow_instance?.travel_request
? {
id: task.workflow_instance.travel_request.id,
requestNo: task.workflow_instance.travel_request.request_no ?? null,
status: task.workflow_instance.travel_request.status,
purpose: task.workflow_instance.travel_request.purpose,
tripType: task.workflow_instance.travel_request.trip_type,
startDate: formatDateOnly(task.workflow_instance.travel_request.start_date),
endDate: formatDateOnly(task.workflow_instance.travel_request.end_date),
estimatedAmount: toMoneyString(task.workflow_instance.travel_request.estimated_amount),
applicant: task.workflow_instance.travel_request.applicant
? {
id: task.workflow_instance.travel_request.applicant.id,
employeeNo: task.workflow_instance.travel_request.applicant.employee_no,
name: task.workflow_instance.travel_request.applicant.name,
}
: null,
legalEntity: task.workflow_instance.travel_request.legal_entity
? {
id: task.workflow_instance.travel_request.legal_entity.id,
code: task.workflow_instance.travel_request.legal_entity.code,
name: task.workflow_instance.travel_request.legal_entity.name,
}
: null,
costCenter: task.workflow_instance.travel_request.cost_center
? {
id: task.workflow_instance.travel_request.cost_center.id,
code: task.workflow_instance.travel_request.cost_center.code,
name: task.workflow_instance.travel_request.cost_center.name,
}
: null,
segments: task.workflow_instance.travel_request.segments.map((item) => ({
id: item.id,
sortOrder: item.sort_order,
segmentRole: item.segment_role,
transportType: item.transport_type,
fromCity: item.from_city,
toCity: item.to_city,
})),
}
: null,
allowedActions: listAllowedActionsForTask(request, task, actionable),
history: historyItems.map((item) => ({
id: item.id,
taskId: item.task_id,
actorId: item.actor_id,
actorName: item.actor?.name ?? "",
actorEmployeeNo: item.actor?.employee_no ?? "",
action: item.action,
comment: item.comment,
payload: item.payload,
createdAt: isoOrNull(item.created_at),
})),
});
},
);
fastify.get(
"/workflow/approvers",
{
preHandler: fastify.requirePermission("expense.transfer"),
},
async (request, reply) => {
const query = parseApproverListQuery(request.query);
const skip = (query.page - 1) * query.pageSize;
const where = {
status: "active",
id: { not: request.employee.id },
roles: {
some: {
role_code: { in: APPROVER_ROLE_CODES },
},
},
...(query.keyword
? {
OR: [
{ name: { contains: query.keyword, mode: "insensitive" } },
{ email: { contains: query.keyword, mode: "insensitive" } },
{ employee_no: { contains: query.keyword, mode: "insensitive" } },
],
}
: {}),
};
const [items, total] = await Promise.all([
fastify.prisma.employee.findMany({
where,
orderBy: { name: "asc" },
skip,
take: query.pageSize,
select: {
id: true,
employee_no: true,
name: true,
email: true,
roles: {
select: {
role_code: true,
},
},
},
}),
fastify.prisma.employee.count({ where }),
]);
return sendData(reply, request, {
page: query.page,
pageSize: query.pageSize,
total,
items: items.map((item) => ({
id: item.id,
employeeNo: item.employee_no,
name: item.name,
email: item.email,
roleCodes: item.roles.map((role) => role.role_code),
})),
});
},
);
fastify.post(
"/workflow/tasks/:taskId/approve",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const taskId = parseTaskId(request.params?.taskId);
const { result, emailTasks, dispatchTasks } = await executeApproveWorkflowTask({
fastify,
request,
taskId,
});
await flushWorkflowSideEffects({ fastify, request, emailTasks, dispatchTasks });
return sendData(reply, request, result);
},
);
fastify.post(
"/workflow/tasks/:taskId/reject",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const taskId = parseTaskId(request.params?.taskId);
const comment = parseRequiredComment(request.body);
const { result, emailTasks, dispatchTasks } = await executeRejectWorkflowTask({
fastify,
request,
taskId,
comment,
});
await flushWorkflowSideEffects({ fastify, request, emailTasks, dispatchTasks });
return sendData(reply, request, result);
},
);
fastify.post(
"/workflow/batch-approve",
{
preHandler: fastify.requirePermission("expense.approve"),
},
async (request, reply) => {
const body = parseBatchApproveBody(request.body);
const results = [];
const summary = {
approved: 0,
rejected: 0,
skipped: 0,
errored: 0,
};
for (const taskId of body.taskIds) {
try {
const { result, emailTasks, dispatchTasks } = await executeApproveWorkflowTask({
fastify,
request,
taskId,
allowedEntityType: "reimbursement",
});
try {
await flushWorkflowSideEffects({ fastify, request, emailTasks, dispatchTasks });
} catch (error) {
fastify.log.error({ err: error, taskId }, "Failed to flush batch approve side effects");
}
results.push({
taskId,
outcome: result.outcome,
});
summary.approved += 1;
} catch (error) {
const mapped = mapBatchTaskError(taskId, error);
results.push(mapped);
if (mapped.outcome === "skipped") {
summary.skipped += 1;
} else {
summary.errored += 1;
}
}
}
return sendData(reply, request, {
results,
summary,
});
},
);
fastify.post(
"/workflow/batch-reject",
{
preHandler: fastify.requirePermission("expense.approve"),
},
async (request, reply) => {
const body = parseBatchRejectBody(request.body);
const results = [];
const summary = {
approved: 0,
rejected: 0,
skipped: 0,
errored: 0,
};
for (const taskId of body.taskIds) {
try {
const { result, emailTasks, dispatchTasks } = await executeRejectWorkflowTask({
fastify,
request,
taskId,
comment: body.comment,
allowedEntityType: "reimbursement",
});
try {
await flushWorkflowSideEffects({ fastify, request, emailTasks, dispatchTasks });
} catch (error) {
fastify.log.error({ err: error, taskId }, "Failed to flush batch reject side effects");
}
results.push({
taskId,
outcome: result.outcome,
});
summary.rejected += 1;
} catch (error) {
const mapped = mapBatchTaskError(taskId, error);
results.push(mapped);
if (mapped.outcome === "skipped") {
summary.skipped += 1;
} else {
summary.errored += 1;
}
}
}
return sendData(reply, request, {
results,
summary,
});
},
);
fastify.post(
"/workflow/tasks/:taskId/return",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const taskId = parseTaskId(request.params?.taskId);
const comment = parseRequiredComment(request.body);
let result = null;
const emailTasks = [];
const dispatchTasks = [];
await fastify.prisma.$transaction(async (tx) => {
const task = await lockWorkflowTask(tx, taskId);
ensurePendingTask(task);
const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request);
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
fastify.assertEntityPermission(request.employee, workflowInstance.entity_type, "approve");
const now = new Date();
await tx.workflowTask.update({
where: { id: taskId },
data: {
status: "completed",
outcome: "returned",
comment,
completed_at: now,
completed_by: request.employee.id,
},
});
await cancelOtherPendingTasks(tx, workflowInstance.id, taskId, request.employee.id);
await tx.workflowInstance.update({
where: { id: workflowInstance.id },
data: {
current_status: "terminated",
completed_at: now,
updated_by: request.employee.id,
},
});
if (workflowInstance.entity_type === "reimbursement") {
await tx.reimbursementForm.update({
where: { id: workflowInstance.entity_id },
data: {
status: "draft",
updated_by: request.employee.id,
},
});
} else if (workflowInstance.entity_type === "leave") {
const leaveRequest = await tx.leaveRequest.findUnique({
where: { id: workflowInstance.entity_id },
select: {
id: true,
owner_id: true,
leave_type_id: true,
},
});
const reserveAnchor = await tx.leaveLedger.findFirst({
where: {
leave_request_id: workflowInstance.entity_id,
entry_type: "reserve",
},
orderBy: { created_at: "desc" },
select: {
leave_policy_id: true,
period_start: true,
period_end: true,
},
});
await tx.leaveRequest.update({
where: { id: workflowInstance.entity_id },
data: {
status: "draft",
updated_by: request.employee.id,
},
});
if (leaveRequest) {
await releaseReservedLeave(tx, {
leaveRequestId: workflowInstance.entity_id,
employeeId: leaveRequest.owner_id,
leaveTypeId: leaveRequest.leave_type_id,
leavePolicyId: reserveAnchor?.leave_policy_id ?? null,
periodStart: reserveAnchor?.period_start ?? null,
periodEnd: reserveAnchor?.period_end ?? null,
note: "returned_release_leave",
payload: { source: "workflow_return" },
createdBy: request.employee.id,
});
}
} else if (workflowInstance.entity_type === "purchase_request") {
await tx.purchaseRequest.update({
where: { id: workflowInstance.entity_id },
data: {
status: "draft",
updated_by: request.employee.id,
},
});
await releaseReservedBudgetIfAny(tx, {
entityType: "purchase_request",
entityId: workflowInstance.entity_id,
actorId: request.employee.id,
note: "returned_release_purchase_budget",
});
} else if (workflowInstance.entity_type === "travel_request") {
await tx.travelRequest.update({
where: { id: workflowInstance.entity_id },
data: {
status: "draft",
updated_by: request.employee.id,
},
});
await releaseReservedBudgetIfAny(tx, {
entityType: "travel_request",
entityId: workflowInstance.entity_id,
actorId: request.employee.id,
note: "returned_release_travel_budget",
});
}
await tx.workflowHistory.create({
data: {
instance_id: workflowInstance.id,
task_id: taskId,
actor_id: request.employee.id,
action: "return",
comment,
payload: withDelegationPayload(
{
from_step_id: task.step_id,
},
assignment,
request.employee.id,
),
},
});
try {
await queueWorkflowOutcomeNotifications({
tx,
actorId: request.employee.id,
emailTasks,
dispatchTasks,
workflowInstance,
outcome: 'returned',
comment,
});
} catch (error) {
fastify.log.error(
{ err: error, taskId, entityType: workflowInstance.entity_type, entityId: workflowInstance.entity_id },
"Failed to create return outcome notifications",
);
}
result = {
taskId,
outcome: "returned",
workflowInstanceId: workflowInstance.id,
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "draft" : null,
purchaseRequestStatus: workflowInstance.entity_type === "purchase_request" ? "draft" : null,
travelRequestStatus: workflowInstance.entity_type === "travel_request" ? "draft" : null,
};
});
for (const task of emailTasks) {
void sendNotificationEmailSafe({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
...task,
});
}
await flushNotificationDispatchTasks({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
tasks: dispatchTasks,
});
return sendData(reply, request, result);
},
);
fastify.post(
"/workflow/tasks/:taskId/transfer",
{
preHandler: fastify.requirePermission("expense.transfer"),
},
async (request, reply) => {
const taskId = parseTaskId(request.params?.taskId);
const { toApproverId, reason } = parseTransferBody(request.body);
let result = null;
const emailTasks = [];
const dispatchTasks = [];
await fastify.prisma.$transaction(async (tx) => {
const task = await lockWorkflowTask(tx, taskId);
ensurePendingTask(task);
const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request);
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
const approvePermission = resolveEntityPermission(
workflowInstance.entity_type,
"approve",
);
if (!approvePermission) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { transfer: "unsupported_entity_type" },
});
}
const transferCount = await ensureTransferChainNotExceeded(tx, taskId);
await ensureTransferTargetEligible(tx, toApproverId, approvePermission);
if (toApproverId === request.employee.id) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { transfer: "same_assignee_not_allowed" },
});
}
await tx.workflowTask.update({
where: { id: taskId },
data: {
assignee_id: toApproverId,
assignee_role: null,
},
});
let transferId = null;
if (workflowInstance.entity_type === "reimbursement") {
const transfer = await tx.approvalTransfer.create({
data: {
reimbursement_id: workflowInstance.entity_id,
from_approver_id: request.employee.id,
to_approver_id: toApproverId,
reason,
status: "active",
created_by: request.employee.id,
},
select: {
id: true,
},
});
transferId = transfer.id;
}
await tx.workflowHistory.create({
data: {
instance_id: workflowInstance.id,
task_id: taskId,
actor_id: request.employee.id,
action: "transfer",
comment: reason,
payload: withDelegationPayload(
{
from_approver_id: request.employee.id,
to_approver_id: toApproverId,
reason,
transfer_count: transferCount + 1,
},
assignment,
request.employee.id,
),
},
});
try {
const notificationContext = await loadWorkflowNotifyContext(
tx,
workflowInstance.entity_type,
workflowInstance.entity_id,
);
const entityMeta = buildWorkflowEntityMeta(
workflowInstance.entity_type,
notificationContext.record,
workflowInstance.entity_id,
);
const pendingCopy = buildPendingTaskCopy(entityMeta, { transfer: true });
await queueTaskAssignedNotifications({
tx,
actorId: request.employee.id,
emailTasks,
dispatchTasks,
recipientEmployeeIds: [toApproverId],
title: pendingCopy.title,
body: pendingCopy.body,
taskId,
entityType: workflowInstance.entity_type,
entityId: workflowInstance.entity_id,
});
} catch (error) {
fastify.log.error(
{ err: error, taskId, toApproverId },
"Failed to create task transferred notifications",
);
}
result = {
taskId,
outcome: "transferred",
workflowInstanceId: workflowInstance.id,
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "reviewing" : null,
transferId,
fromApproverId: request.employee.id,
toApproverId,
transferCount: transferCount + 1,
};
});
for (const task of emailTasks) {
void sendNotificationEmailSafe({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
...task,
});
}
await flushNotificationDispatchTasks({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
tasks: dispatchTasks,
});
return sendData(reply, request, result);
},
);
}
module.exports = workflowRoutes;
module.exports.__testables = {
buildApprovedCopy,
buildPendingTaskCopy,
buildRejectedCopy,
buildReturnedCopy,
buildWorkflowEntityMeta,
handlePurchaseApprovalErpSync,
listAllowedActionsForTask,
resolveWorkflowOutcomeNotificationType,
};