;
+};
+
+type WorkflowTaskSummaryResponse = {
+ pendingTotal: number;
+ overdueTotal: number;
+ byEntityType: Array<{
+ key: string;
+ pendingTotal: number;
+ overdueTotal: number;
+ }>;
+};
+
type LeaveTypeListResponse = {
items: Array<{
id: string;
@@ -640,6 +661,73 @@ test.describe("R3 权限直连审计", () => {
}
});
+ test("AP6 审批催办与待办汇总:申请人可催办且重复催办被限制", async () => {
+ const employee = await createAuthenticatedApiClient(EMPLOYEE);
+ const manager = await createAuthenticatedApiClient(MANAGER);
+ const admin = await createAuthenticatedApiClient(ADMIN);
+ const clients = [employee, manager, admin];
+
+ try {
+ const leave = await createSubmittedLeave(employee.api, `ap6-remind-${Date.now()}`);
+
+ const summaryBefore = await apiGetData(manager.api, "/api/workflow/tasks/summary");
+ expect(summaryBefore.pendingTotal).toBeGreaterThanOrEqual(1);
+ expect(summaryBefore.byEntityType.some((item) => item.key === "leave" && item.pendingTotal > 0)).toBe(true);
+
+ const managerRemind = await manager.api.post(`/api/workflow/tasks/${leave.currentTaskId}/remind`, { data: {} });
+ expectStatus(managerRemind.status(), [403], "manager POST workflow remind should not act as applicant");
+
+ const adminRemind = await admin.api.post(`/api/workflow/tasks/${leave.currentTaskId}/remind`, { data: {} });
+ expectStatus(adminRemind.status(), [403], "admin-only POST workflow remind should not act as applicant");
+
+ const reminded = await apiPostData<{ outcome: string; notificationCount: number }>(
+ employee.api,
+ `/api/workflow/tasks/${leave.currentTaskId}/remind`,
+ {},
+ );
+ expect(reminded.outcome).toBe("reminded");
+ expect(reminded.notificationCount).toBeGreaterThanOrEqual(1);
+
+ const duplicate = await employee.api.post(`/api/workflow/tasks/${leave.currentTaskId}/remind`, { data: {} });
+ expectStatus(duplicate.status(), [409], "duplicate remind should hit 24h cooldown");
+
+ const workflowDetail = await apiGetData(manager.api, `/api/workflow/tasks/${leave.currentTaskId}`);
+ expect(workflowDetail.task?.status).toBe("pending");
+ expect(workflowDetail.history.some((item) => item.action === "remind" && item.actorId === employee.me.id)).toBe(true);
+ expectNoSensitiveKeys(workflowDetail.history);
+
+ const notifications = await apiGetData(
+ manager.api,
+ "/api/notifications?type=workflow.task_reminded&page=1&pageSize=20",
+ );
+ expect(
+ notifications.items.some((item) => item.targetId === leave.currentTaskId && item.type === "workflow.task_reminded"),
+ ).toBe(true);
+ expectNoSensitiveKeys(notifications);
+
+ await expectAuditAction(
+ admin.api,
+ "WORKFLOW_TASK_REMINDED",
+ (item) => item.targetId === leave.currentTaskId && item.actorId === employee.me.id,
+ );
+
+ const systemSummary = await apiGetData(
+ admin.api,
+ "/api/workflow/tasks/summary?scope=system",
+ );
+ expect(systemSummary.pendingTotal).toBeGreaterThanOrEqual(summaryBefore.pendingTotal);
+ await expectApiStatus({
+ role: "employee",
+ api: employee.api,
+ path: "/api/workflow/tasks/summary?scope=system",
+ statuses: [403],
+ note: "普通员工不得查看全局审批效率摘要",
+ });
+ } finally {
+ await Promise.all(clients.map((client) => client.dispose()));
+ }
+ });
+
test("PA6 审计留痕:登录、审批历史、附件下载、导出与通知投递可追溯且脱敏", async () => {
const employee = await createAuthenticatedApiClient(EMPLOYEE);
const manager = await createAuthenticatedApiClient(MANAGER);
diff --git a/docs/需求-统一审批中心规约.md b/docs/需求-统一审批中心规约.md
index 7cac8b92..8b05bbab 100644
--- a/docs/需求-统一审批中心规约.md
+++ b/docs/需求-统一审批中心规约.md
@@ -325,8 +325,8 @@
R6 首轮试运行只验证已有审批主链路稳定性,不新增复杂审批能力。R6 后续优化可单独拆分:
- 待办聚合:PC 和移动统一展示待办、已办、抄送和通知入口。
-- 催办与超时提醒:按业务类型、金额或天数配置提醒策略,催办动作必须留痕。
-- 审批效率统计:按节点、角色和业务类型统计平均处理时长、超时数量和积压数量。
+- 催办与超时提醒:AP6 v1 允许申请人/提交人对本人审批中单据的 pending 任务催办,24 小时内同一任务最多 1 次;超时提醒只提醒 pending 待办,不改变审批状态,通知失败不回滚业务,但必须进入审计和通知投递记录。
+- 审批效率统计:AP6 v1 聚合待办总数、超时数、最早待处理时间、平均等待小时数,并按业务类型、节点和角色汇总;`scope=system` 仅 `system.audit.view` 可读,返回聚合数,不返回申请人明细。
- 流程配置治理:把金额阈值、兜底审批人、转审规则和通知路由纳入管理员可理解的配置页。
涉及状态机、权限模型、数据库迁移或审批规则调整的 Issue,必须在技术方案中写明回滚策略和回归范围。
diff --git a/issues/2026-05-12_01-42-00-r6-approval-productivity-v1.csv b/issues/2026-05-12_01-42-00-r6-approval-productivity-v1.csv
index 3fc81a17..e5a391eb 100644
--- a/issues/2026-05-12_01-42-00-r6-approval-productivity-v1.csv
+++ b/issues/2026-05-12_01-42-00-r6-approval-productivity-v1.csv
@@ -1,6 +1,6 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
AP6-1,审批效率 v1 合同建档,在 R6 通过后规划催办、超时提醒、待办聚合和效率摘要第一版。,"1) 本 CSV 校验通过;2) 不进入 R6 门禁;3) 不改现有审批状态机;4) 不做复杂 BPM。","流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-12_01-42-00-r6-approval-productivity-v1.csv;人工核对统一审批中心规约。",python3|manual,DONE,DONE,DONE,issues/2026-05-12_01-42-00-r6-approval-productivity-v1.csv,[],涉及审批权限、状态机或通知需 reviewer 复核。
-AP6-2,审批催办能力,申请人可对自己的待审批单据发起催办,催办留痕并通过企业微信个人应用消息提醒审批人。,"1) 仅申请人或提交人可催办自己的待审批单据;2) 催办有频率限制;3) 通知失败不阻断业务且可审计;4) 催办记录可在详情页查看。","流程级验证:员工提交单据 -> 催办 -> 审批人收到站内通知和企微投递审计 -> 重复催办触发频率限制 -> 详情页留痕。",node|playwright|manual,TODO,TODO,TODO,server/src/routes/workflow.js|server/src/services/notificationDispatcher.js|client/src/pages/WorkflowTasksPage.tsx,"[""AP6-1""]","新增接口和审计字段需保持向后兼容,不记录 secret。"
-AP6-3,超时提醒任务,按业务类型和节点配置首版固定阈值,自动提醒超时待办。,"1) 超时任务只提醒 pending 待办;2) 不改变审批状态;3) 提醒记录可审计;4) 可重复运行且幂等。","流程级验证:构造超时待办 -> 运行提醒脚本或 job -> 检查通知投递、审计和幂等。",node|manual,TODO,TODO,TODO,server/src/jobs|server/src/services,"[""AP6-2""]",首版可用固定策略,复杂配置后续拆分。
-AP6-4,待办聚合与效率摘要,PC 和移动展示待办数量、最早待处理时间、业务类型分布,并提供管理端只读效率摘要。,"1) 不影响审批动作;2) 统计按业务类型、节点和角色聚合;3) 管理端只读;4) 普通员工只看自己的摘要。","流程级验证:创建报销/请假/采购待办 -> 检查 PC/移动摘要 -> 管理端查看平均处理时长、超时数量和积压数量。",node|playwright,TODO,TODO,TODO,server/src/routes/workflow.js|client/src/pages/PortalHomePage.tsx|client/src/pages/MobilePortalPage.tsx,"[""AP6-3""]",统计口径写入统一审批中心规约或接口注释。
+AP6-2,审批催办能力,申请人可对自己的待审批单据发起催办,催办留痕并通过企业微信个人应用消息提醒审批人。,"1) 仅申请人或提交人可催办自己的待审批单据;2) 催办有频率限制;3) 通知失败不阻断业务且可审计;4) 催办记录可在详情页查看。","流程级验证:员工提交单据 -> 催办 -> 审批人收到站内通知和企微投递审计 -> 重复催办触发频率限制 -> 详情页留痕。",node|playwright|manual,DONE,DONE,DONE,server/src/routes/workflow.js|server/src/constants/notificationTypes.js|server/src/services/notifications/workflowCopy.js|client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/MobileReimbursementDetailPage.tsx|client/src/pages/PurchaseRequestsPage.tsx,"[""AP6-1""]","新增接口和审计字段需保持向后兼容,不记录 secret;本地业务门禁通过:artifacts/qa-flow/business-flow-20260512-102829/summary.txt;采购页面补充回归通过:artifacts/qa-flow/business-flow-20260512-103809/summary.txt。"
+AP6-3,超时提醒任务,按业务类型和节点配置首版固定阈值,自动提醒超时待办。,"1) 超时任务只提醒 pending 待办;2) 不改变审批状态;3) 提醒记录可审计;4) 可重复运行且幂等。","流程级验证:构造超时待办 -> 运行提醒脚本或 job -> 检查通知投递、审计和幂等。",node|manual,DONE,DONE,DONE,server/src/jobs/workflowOverdueReminderJob.js|server/src/scripts/run-workflow-overdue-reminders.js|server/src/constants/notificationTypes.js,"[""AP6-2""]","首版可用固定策略,复杂配置后续拆分;单测通过:workflowOverdueReminderJob.test.js。"
+AP6-4,待办聚合与效率摘要,PC 和移动展示待办数量、最早待处理时间、业务类型分布,并提供管理端只读效率摘要。,"1) 不影响审批动作;2) 统计按业务类型、节点和角色聚合;3) 管理端只读;4) 普通员工只看自己的摘要。","流程级验证:创建报销/请假/采购待办 -> 检查 PC/移动摘要 -> 管理端查看平均处理时长、超时数量和积压数量。",node|playwright,DONE,DONE,DONE,server/src/routes/workflow.js|client/src/pages/PortalHomePage.tsx|client/src/pages/MobilePortalPage.tsx,"[""AP6-3""]","统计口径已写入统一审批中心规约;本地业务门禁通过:artifacts/qa-flow/business-flow-20260512-102829/summary.txt。"
AP6-5,审批效率 v1 回归部署,完成审批效率 v1 后做权限、通知、PC/移动和公网复检。,"1) 后端语法和单测通过;2) PC/移动 E2E 通过;3) 企业微信投递审计通过;4) 部署后 live smoke 通过。","流程级验证:cd server && npm run prisma:validate;node --check workflow/notification 文件;cd client && npm run typecheck && npm run build;部署和 live smoke。",node|npm|bash|playwright,TODO,TODO,TODO,server|client|deploy/trialrun,"[""AP6-4""]","不得把催办误用为强制审批或状态跳转。"
diff --git a/server/package.json b/server/package.json
index 2a4d56e5..d4ad2c1c 100644
--- a/server/package.json
+++ b/server/package.json
@@ -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"
diff --git a/server/src/constants/notificationTypes.js b/server/src/constants/notificationTypes.js
index 1334fda0..b952dea1 100644
--- a/server/src/constants/notificationTypes.js
+++ b/server/src/constants/notificationTypes.js
@@ -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,
diff --git a/server/src/jobs/workflowOverdueReminderJob.js b/server/src/jobs/workflowOverdueReminderJob.js
new file mode 100644
index 00000000..97073400
--- /dev/null
+++ b/server/src/jobs/workflowOverdueReminderJob.js
@@ -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,
+};
diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js
index 554aa347..c8d7f069 100644
--- a/server/src/routes/reimbursements.js
+++ b/server/src/routes/reimbursements.js
@@ -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",
diff --git a/server/src/routes/workflow.js b/server/src/routes/workflow.js
index 2be9a5be..c55c2137 100644
--- a/server/src/routes/workflow.js
+++ b/server/src/routes/workflow.js
@@ -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,
diff --git a/server/src/scripts/run-workflow-overdue-reminders.js b/server/src/scripts/run-workflow-overdue-reminders.js
new file mode 100644
index 00000000..9845d0e7
--- /dev/null
+++ b/server/src/scripts/run-workflow-overdue-reminders.js
@@ -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);
+});
diff --git a/server/src/services/notifications/workflowCopy.js b/server/src/services/notifications/workflowCopy.js
index eb38988d..78b2d9f6 100644
--- a/server/src/services/notifications/workflowCopy.js
+++ b/server/src/services/notifications/workflowCopy.js
@@ -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,
diff --git a/server/tests/unit/constants/notificationTypes.test.js b/server/tests/unit/constants/notificationTypes.test.js
new file mode 100644
index 00000000..af2832be
--- /dev/null
+++ b/server/tests/unit/constants/notificationTypes.test.js
@@ -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,
+ );
+ });
+});
diff --git a/server/tests/unit/jobs/workflowOverdueReminderJob.test.js b/server/tests/unit/jobs/workflowOverdueReminderJob.test.js
new file mode 100644
index 00000000..b8ed2f0c
--- /dev/null
+++ b/server/tests/unit/jobs/workflowOverdueReminderJob.test.js
@@ -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}`,
+ }),
+ );
+ });
+});
diff --git a/server/tests/unit/routes/workflow.travel.test.js b/server/tests/unit/routes/workflow.travel.test.js
index 0a91f8c0..49389102 100644
--- a/server/tests/unit/routes/workflow.travel.test.js
+++ b/server/tests/unit/routes/workflow.travel.test.js
@@ -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 },
+ ]),
+ );
+ });
});