AP6 增加审批催办与效率摘要

这个提交包含在:
2026-05-12 10:41:47 +08:00
父节点 f99fbce3d8
当前提交 72e38db4e5
共修改 23 个文件,包含 1362 行新增和 12 行删除
@@ -0,0 +1,252 @@
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
const { createNotificationsWithRecords, resolveRecipientsForAssignee } = require("../services/notifications");
const { buildOverdueTaskCopy, buildWorkflowEntityMeta } = require("../services/notifications/workflowCopy");
const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher");
const WORKFLOW_OVERDUE_ACTION = "overdue_remind";
const WORKFLOW_DEFAULT_DUE_MS = 24 * 60 * 60 * 1000;
const WORKFLOW_OVERDUE_COOLDOWN_MS = 24 * 60 * 60 * 1000;
function uniq(values) {
return Array.from(new Set((values ?? []).filter(Boolean)));
}
function calculateDueAt(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 pickEntityRecord(workflowInstance) {
if (!workflowInstance) {
return null;
}
if (workflowInstance.entity_type === "reimbursement") {
return workflowInstance.reimbursement_form ?? null;
}
if (workflowInstance.entity_type === "leave") {
return workflowInstance.leave_request ?? null;
}
if (workflowInstance.entity_type === "purchase_request") {
return workflowInstance.purchase_request ?? null;
}
if (workflowInstance.entity_type === "travel_request") {
return workflowInstance.travel_request ?? null;
}
return null;
}
async function loadOverduePendingTasks(prisma, now, batchSize) {
const fallbackCutoff = new Date(now.getTime() - WORKFLOW_DEFAULT_DUE_MS);
return prisma.workflowTask.findMany({
where: {
status: "pending",
OR: [
{ due_date: { lte: now } },
{ due_date: null, created_at: { lte: fallbackCutoff } },
],
},
orderBy: { created_at: "asc" },
take: batchSize,
select: {
id: true,
instance_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: {
id: true,
entity_type: true,
entity_id: true,
reimbursement_form: {
select: {
form_number: true,
total_amount: true,
owner: { select: { name: true } },
},
},
leave_request: {
select: {
start_date: true,
end_date: true,
requested_days: true,
leave_type: { select: { name: true } },
owner: { select: { name: true } },
},
},
purchase_request: {
select: {
request_no: true,
total_amount: true,
applicant: { select: { name: true } },
},
},
travel_request: {
select: {
request_no: true,
estimated_amount: true,
applicant: { select: { name: true } },
},
},
},
},
},
});
}
async function remindOverdueWorkflowTask(tx, task, now) {
const recentSince = new Date(now.getTime() - WORKFLOW_OVERDUE_COOLDOWN_MS);
const recent = await tx.workflowHistory.findFirst({
where: {
task_id: task.id,
action: WORKFLOW_OVERDUE_ACTION,
created_at: { gte: recentSince },
},
select: { id: true },
});
if (recent) {
return { skipped: true, reason: "cooldown" };
}
const recipients = uniq(
await resolveRecipientsForAssignee(tx, {
assigneeId: task.assignee_id,
assigneeRole: task.assignee_role,
}),
);
if (!recipients.length) {
return { skipped: true, reason: "no_recipients" };
}
const workflowInstance = task.workflow_instance;
if (!workflowInstance) {
return { skipped: true, reason: "missing_workflow_instance" };
}
const dueAt = calculateDueAt(task, now);
const actorId = recipients[0];
await tx.workflowHistory.create({
data: {
instance_id: workflowInstance.id,
task_id: task.id,
actor_id: actorId,
action: WORKFLOW_OVERDUE_ACTION,
comment: "系统超时提醒",
payload: {
from_step_id: task.step_id,
due_at: dueAt.toISOString(),
reminder_window_hours: 24,
actor_mode: "system",
},
},
});
await tx.auditLog.create({
data: {
request_id: `job:workflow-overdue:${now.getTime()}:${task.id}`,
actor_id: actorId,
action: "WORKFLOW_TASK_OVERDUE_NOTIFIED",
target_type: "workflow_task",
target_id: task.id,
ip_address: null,
user_agent: "system/node-cron",
extra: {
workflowInstanceId: workflowInstance.id,
entityType: workflowInstance.entity_type,
entityId: workflowInstance.entity_id,
assigneeId: task.assignee_id,
assigneeRole: task.assignee_role,
dueAt: dueAt.toISOString(),
actorMode: "system",
},
},
});
const entityMeta = buildWorkflowEntityMeta(
workflowInstance.entity_type,
pickEntityRecord(workflowInstance),
workflowInstance.entity_id,
);
const copy = buildOverdueTaskCopy(entityMeta);
const notifications = await createNotificationsWithRecords(tx, {
recipientEmployeeIds: recipients,
type: NOTIFICATION_TYPES.workflowTaskOverdue,
title: copy.title,
body: copy.body,
targetType: "workflow_task",
targetId: task.id,
});
return {
skipped: false,
dispatchTask: {
notifications,
type: NOTIFICATION_TYPES.workflowTaskOverdue,
title: copy.title,
body: copy.body,
targetType: "workflow_task",
targetId: task.id,
targetPath: `/mobile/approvals?taskId=${task.id}`,
actorId,
},
};
}
async function remindOverdueWorkflowTasks(prisma, logger, config, options = {}) {
const now = options.now ?? new Date();
const batchSize = Math.max(1, Number(options.batchSize ?? 100));
const tasks = await loadOverduePendingTasks(prisma, now, batchSize);
let reminded = 0;
let skipped = 0;
const dispatchTasks = [];
for (const task of tasks) {
const result = await prisma.$transaction((tx) => remindOverdueWorkflowTask(tx, task, now));
if (result.skipped) {
skipped += 1;
logger?.info?.({ taskId: task.id, reason: result.reason }, "Workflow overdue reminder skipped");
continue;
}
reminded += 1;
dispatchTasks.push(result.dispatchTask);
}
const dispatchResults = await flushNotificationDispatchTasks({
prisma,
config,
logger,
requestId: `job:workflow-overdue:${now.getTime()}`,
tasks: dispatchTasks,
});
logger?.info?.(
{ checked: tasks.length, reminded, skipped, dispatchTasks: dispatchTasks.length },
"Workflow overdue reminder batch finished",
);
return {
checked: tasks.length,
reminded,
skipped,
dispatchResults,
};
}
module.exports = {
WORKFLOW_OVERDUE_ACTION,
calculateDueAt,
loadOverduePendingTasks,
remindOverdueWorkflowTask,
remindOverdueWorkflowTasks,
};