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

这个提交包含在:
2026-05-12 10:41:47 +08:00
父节点 f99fbce3d8
当前提交 72e38db4e5
共修改 23 个文件,包含 1362 行新增和 12 行删除
+10 -1
查看文件
@@ -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",
+395
查看文件
@@ -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,