AP6 增加审批催办与效率摘要
这个提交包含在:
@@ -25,6 +25,7 @@
|
||||
"job:archive-unused-invoices": "node src/scripts/run-invoice-archive-once.js",
|
||||
"job:integration-outbox-once": "node src/scripts/run-integration-outbox-once.js",
|
||||
"job:verification-once": "node src/scripts/run-verification-once.js",
|
||||
"job:workflow-overdue-reminders": "node src/scripts/run-workflow-overdue-reminders.js",
|
||||
"audit:smoke-data": "node src/scripts/audit-smoke-data.js",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
|
||||
@@ -10,6 +10,8 @@ const NOTIFICATION_DOMAIN_SCOPES = Object.freeze({
|
||||
|
||||
const NOTIFICATION_TYPES = Object.freeze({
|
||||
workflowTaskAssigned: 'workflow.task_assigned',
|
||||
workflowTaskReminded: 'workflow.task_reminded',
|
||||
workflowTaskOverdue: 'workflow.task_overdue',
|
||||
reimbursementApproved: 'reimbursement.approved',
|
||||
reimbursementRejected: 'reimbursement.rejected',
|
||||
reimbursementReturned: 'reimbursement.returned',
|
||||
@@ -28,6 +30,8 @@ const NOTIFICATION_TYPES = Object.freeze({
|
||||
|
||||
const IM_SUPPORTED_NOTIFICATION_TYPES = Object.freeze([
|
||||
NOTIFICATION_TYPES.workflowTaskAssigned,
|
||||
NOTIFICATION_TYPES.workflowTaskReminded,
|
||||
NOTIFICATION_TYPES.workflowTaskOverdue,
|
||||
NOTIFICATION_TYPES.reimbursementApproved,
|
||||
NOTIFICATION_TYPES.reimbursementRejected,
|
||||
NOTIFICATION_TYPES.reimbursementReturned,
|
||||
@@ -46,6 +50,8 @@ const IM_SUPPORTED_NOTIFICATION_TYPES = Object.freeze([
|
||||
|
||||
const NOTIFICATION_TYPE_TO_DOMAIN_SCOPE = Object.freeze({
|
||||
[NOTIFICATION_TYPES.workflowTaskAssigned]: NOTIFICATION_DOMAIN_SCOPES.workflow,
|
||||
[NOTIFICATION_TYPES.workflowTaskReminded]: NOTIFICATION_DOMAIN_SCOPES.workflow,
|
||||
[NOTIFICATION_TYPES.workflowTaskOverdue]: NOTIFICATION_DOMAIN_SCOPES.workflow,
|
||||
[NOTIFICATION_TYPES.reimbursementApproved]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
|
||||
[NOTIFICATION_TYPES.reimbursementRejected]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
|
||||
[NOTIFICATION_TYPES.reimbursementReturned]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -141,7 +141,16 @@ const TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES = {
|
||||
car: "travel_taxi",
|
||||
other: "transport",
|
||||
};
|
||||
const PRINTABLE_WORKFLOW_ACTIONS = new Set(["start", "approve", "reject", "return", "transfer", "withdraw"]);
|
||||
const PRINTABLE_WORKFLOW_ACTIONS = new Set([
|
||||
"start",
|
||||
"approve",
|
||||
"reject",
|
||||
"return",
|
||||
"transfer",
|
||||
"withdraw",
|
||||
"remind",
|
||||
"overdue_remind",
|
||||
]);
|
||||
const ATTACHMENT_MIME_ALLOWLIST = new Set([
|
||||
"application/pdf",
|
||||
"image/jpeg",
|
||||
|
||||
@@ -18,8 +18,10 @@ const {
|
||||
} = require("../services/notifications");
|
||||
const {
|
||||
buildApprovedCopy,
|
||||
buildOverdueTaskCopy,
|
||||
buildPendingTaskCopy,
|
||||
buildRejectedCopy,
|
||||
buildRemindedTaskCopy,
|
||||
buildReturnedCopy,
|
||||
buildWorkflowEntityMeta,
|
||||
normalizeWorkflowEntityType,
|
||||
@@ -48,6 +50,9 @@ 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(),
|
||||
@@ -55,6 +60,14 @@ const TASK_LIST_QUERY_SCHEMA = z.object({
|
||||
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 APPROVER_LIST_QUERY_SCHEMA = z.object({
|
||||
keyword: z.string().trim().max(100).optional(),
|
||||
@@ -155,6 +168,38 @@ function parseBatchRejectBody(body) {
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -404,6 +449,77 @@ function buildTaskBusinessSummary(task) {
|
||||
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, task.step_name ?? 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 } }]
|
||||
@@ -842,6 +958,69 @@ async function queueTaskAssignedNotifications({
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -1023,6 +1202,150 @@ function ensureWorkflowEntityType(workflowInstance, 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,
|
||||
@@ -1530,6 +1853,55 @@ function mapBatchTaskError(taskId, 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 } : {}),
|
||||
};
|
||||
|
||||
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",
|
||||
{
|
||||
@@ -2168,6 +2540,24 @@ async function workflowRoutes(fastify) {
|
||||
},
|
||||
);
|
||||
|
||||
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",
|
||||
{
|
||||
@@ -2662,11 +3052,16 @@ module.exports = workflowRoutes;
|
||||
|
||||
module.exports.__testables = {
|
||||
buildApprovedCopy,
|
||||
buildOverdueTaskCopy,
|
||||
buildPendingTaskCopy,
|
||||
buildRejectedCopy,
|
||||
buildRemindedTaskCopy,
|
||||
buildReturnedCopy,
|
||||
buildWorkflowTaskSummaryPayload,
|
||||
buildWorkflowEntityMeta,
|
||||
calculateWorkflowTaskDueAt,
|
||||
handlePurchaseApprovalErpSync,
|
||||
isWorkflowTaskOverdue,
|
||||
listAllowedActionsForTask,
|
||||
parseTaskListQuery,
|
||||
resolveWorkflowOutcomeNotificationType,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
require("dotenv/config");
|
||||
|
||||
const { getConfig } = require("../config");
|
||||
const { createPrismaClient } = require("../prismaClient");
|
||||
const { remindOverdueWorkflowTasks } = require("../jobs/workflowOverdueReminderJob");
|
||||
|
||||
(async () => {
|
||||
const config = getConfig();
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
await prisma.$connect();
|
||||
try {
|
||||
const result = await remindOverdueWorkflowTasks(prisma, console, config);
|
||||
process.stdout.write(
|
||||
`checked=${result.checked} reminded=${result.reminded} skipped=${result.skipped}\n`,
|
||||
);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -174,6 +174,20 @@ function buildPendingTaskCopy(entityMeta, { transfer = false } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildRemindedTaskCopy(entityMeta) {
|
||||
return {
|
||||
title: `【催办】${buildSubject(entityMeta)} 待审批`,
|
||||
body: buildBody(entityMeta, "动作:申请人已催办,请及时处理审批任务。"),
|
||||
};
|
||||
}
|
||||
|
||||
function buildOverdueTaskCopy(entityMeta) {
|
||||
return {
|
||||
title: `【超时提醒】${buildSubject(entityMeta)} 待审批`,
|
||||
body: buildBody(entityMeta, "动作:该审批已超时,请尽快处理。"),
|
||||
};
|
||||
}
|
||||
|
||||
function buildApprovedCopy(entityMeta) {
|
||||
return {
|
||||
title: `【已批准】${buildSubject(entityMeta)}`,
|
||||
@@ -204,8 +218,10 @@ function buildReturnedCopy(entityMeta, comment) {
|
||||
|
||||
module.exports = {
|
||||
buildApprovedCopy,
|
||||
buildOverdueTaskCopy,
|
||||
buildPendingTaskCopy,
|
||||
buildRejectedCopy,
|
||||
buildRemindedTaskCopy,
|
||||
buildReturnedCopy,
|
||||
buildWorkflowEntityMeta,
|
||||
normalizeWorkflowEntityType,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
const {
|
||||
IM_SUPPORTED_NOTIFICATION_TYPES,
|
||||
NOTIFICATION_DOMAIN_SCOPES,
|
||||
NOTIFICATION_TYPES,
|
||||
resolveNotificationDomainScope,
|
||||
} = require("../../../src/constants/notificationTypes");
|
||||
|
||||
describe("notification types", () => {
|
||||
it("allows workflow reminder and overdue notifications through IM routing", () => {
|
||||
expect(IM_SUPPORTED_NOTIFICATION_TYPES).toEqual(
|
||||
expect.arrayContaining([
|
||||
NOTIFICATION_TYPES.workflowTaskReminded,
|
||||
NOTIFICATION_TYPES.workflowTaskOverdue,
|
||||
]),
|
||||
);
|
||||
expect(resolveNotificationDomainScope(NOTIFICATION_TYPES.workflowTaskReminded, "workflow_task")).toBe(
|
||||
NOTIFICATION_DOMAIN_SCOPES.workflow,
|
||||
);
|
||||
expect(resolveNotificationDomainScope(NOTIFICATION_TYPES.workflowTaskOverdue, "workflow_task")).toBe(
|
||||
NOTIFICATION_DOMAIN_SCOPES.workflow,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
const {
|
||||
calculateDueAt,
|
||||
remindOverdueWorkflowTask,
|
||||
} = require("../../../src/jobs/workflowOverdueReminderJob");
|
||||
const { NOTIFICATION_TYPES } = require("../../../src/constants/notificationTypes");
|
||||
|
||||
describe("workflow overdue reminder job", () => {
|
||||
it("calculates fallback due time from task creation", () => {
|
||||
const dueAt = calculateDueAt({
|
||||
created_at: new Date("2026-05-11T10:00:00.000Z"),
|
||||
due_date: null,
|
||||
});
|
||||
|
||||
expect(dueAt.toISOString()).toBe("2026-05-12T10:00:00.000Z");
|
||||
});
|
||||
|
||||
it("creates history, audit and notification once cooldown allows", async () => {
|
||||
const task = {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
instance_id: "22222222-2222-4222-8222-222222222222",
|
||||
step_id: "manager",
|
||||
step_name: "经理审批",
|
||||
assignee_id: "33333333-3333-4333-8333-333333333333",
|
||||
assignee_role: null,
|
||||
created_at: new Date("2026-05-11T07:00:00.000Z"),
|
||||
due_date: null,
|
||||
workflow_instance: {
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
entity_type: "reimbursement",
|
||||
entity_id: "44444444-4444-4444-8444-444444444444",
|
||||
reimbursement_form: {
|
||||
form_number: "BX-20260512-001",
|
||||
total_amount: "120.00",
|
||||
owner: { name: "测试员工" },
|
||||
},
|
||||
},
|
||||
};
|
||||
const createdHistory = [];
|
||||
const createdAudit = [];
|
||||
const createdNotifications = [];
|
||||
const tx = {
|
||||
workflowHistory: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockImplementation(async ({ data }) => {
|
||||
createdHistory.push(data);
|
||||
return { id: "history-1", ...data };
|
||||
}),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockImplementation(async ({ data }) => {
|
||||
createdAudit.push(data);
|
||||
return { id: "audit-1", ...data };
|
||||
}),
|
||||
},
|
||||
notification: {
|
||||
create: vi.fn().mockImplementation(async ({ data }) => {
|
||||
const row = { id: `notification-${createdNotifications.length + 1}`, ...data };
|
||||
createdNotifications.push(row);
|
||||
return row;
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await remindOverdueWorkflowTask(tx, task, new Date("2026-05-12T08:00:00.000Z"));
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
expect(createdHistory[0]).toEqual(expect.objectContaining({ action: "overdue_remind" }));
|
||||
expect(createdAudit[0]).toEqual(expect.objectContaining({ action: "WORKFLOW_TASK_OVERDUE_NOTIFIED" }));
|
||||
expect(createdNotifications[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
recipient_employee_id: "33333333-3333-4333-8333-333333333333",
|
||||
type: NOTIFICATION_TYPES.workflowTaskOverdue,
|
||||
target_type: "workflow_task",
|
||||
target_id: task.id,
|
||||
}),
|
||||
);
|
||||
expect(result.dispatchTask).toEqual(
|
||||
expect.objectContaining({
|
||||
type: NOTIFICATION_TYPES.workflowTaskOverdue,
|
||||
targetPath: `/mobile/approvals?taskId=${task.id}`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,14 @@ const workflowRoutes = require("../../../src/routes/workflow");
|
||||
describe("workflow travel helpers", () => {
|
||||
const {
|
||||
buildApprovedCopy,
|
||||
buildOverdueTaskCopy,
|
||||
buildPendingTaskCopy,
|
||||
buildRejectedCopy,
|
||||
buildRemindedTaskCopy,
|
||||
buildReturnedCopy,
|
||||
buildWorkflowTaskSummaryPayload,
|
||||
calculateWorkflowTaskDueAt,
|
||||
isWorkflowTaskOverdue,
|
||||
buildWorkflowEntityMeta,
|
||||
resolveWorkflowOutcomeNotificationType,
|
||||
} = workflowRoutes.__testables;
|
||||
@@ -82,6 +87,14 @@ describe("workflow travel helpers", () => {
|
||||
title: "【已退回】报销单 BX-20260325-001",
|
||||
body: "申请人:刘报销\n金额:880.00\n单号:BX-20260325-001\n结果:已退回:请补发票",
|
||||
});
|
||||
expect(buildRemindedTaskCopy(reimbursementMeta)).toEqual({
|
||||
title: "【催办】报销单 BX-20260325-001 待审批",
|
||||
body: "申请人:刘报销\n金额:880.00\n单号:BX-20260325-001\n动作:申请人已催办,请及时处理审批任务。",
|
||||
});
|
||||
expect(buildOverdueTaskCopy(reimbursementMeta)).toEqual({
|
||||
title: "【超时提醒】报销单 BX-20260325-001 待审批",
|
||||
body: "申请人:刘报销\n金额:880.00\n单号:BX-20260325-001\n动作:该审批已超时,请尽快处理。",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves approved and rejected notification types for travel workflows", () => {
|
||||
@@ -93,4 +106,47 @@ describe("workflow travel helpers", () => {
|
||||
);
|
||||
expect(resolveWorkflowOutcomeNotificationType("travel_request", "returned")).toBeNull();
|
||||
});
|
||||
|
||||
it("builds pending task productivity summary with overdue buckets", () => {
|
||||
const now = new Date("2026-05-12T08:00:00.000Z");
|
||||
const tasks = [
|
||||
{
|
||||
id: "task-1",
|
||||
status: "pending",
|
||||
step_name: "经理审批",
|
||||
assignee_role: "manager",
|
||||
created_at: new Date("2026-05-11T07:00:00.000Z"),
|
||||
due_date: null,
|
||||
workflow_instance: { entity_type: "reimbursement" },
|
||||
},
|
||||
{
|
||||
id: "task-2",
|
||||
status: "pending",
|
||||
step_name: "财务审批",
|
||||
assignee_role: "finance",
|
||||
created_at: new Date("2026-05-12T07:00:00.000Z"),
|
||||
due_date: new Date("2026-05-12T07:30:00.000Z"),
|
||||
workflow_instance: { entity_type: "purchase_request" },
|
||||
},
|
||||
];
|
||||
|
||||
expect(calculateWorkflowTaskDueAt(tasks[0], now).toISOString()).toBe("2026-05-12T07:00:00.000Z");
|
||||
expect(isWorkflowTaskOverdue(tasks[0], now)).toBe(true);
|
||||
const summary = buildWorkflowTaskSummaryPayload(tasks, "mine", now);
|
||||
|
||||
expect(summary).toEqual(
|
||||
expect.objectContaining({
|
||||
scope: "mine",
|
||||
pendingTotal: 2,
|
||||
overdueTotal: 2,
|
||||
earliestPendingAt: "2026-05-11T07:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
expect(summary.byEntityType).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ key: "reimbursement", pendingTotal: 1, overdueTotal: 1 },
|
||||
{ key: "purchase_request", pendingTotal: 1, overdueTotal: 1 },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户