3373 行
103 KiB
JavaScript
3373 行
103 KiB
JavaScript
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,
|
||
};
|