AP6 增加审批催办与效率摘要
这个提交包含在:
@@ -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,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户