补齐审批退回结果通知 [09-04-approval-return-notification-gap]

这个提交包含在:
2026-09-04 16:22:27 +08:00
父节点 a6ffb7fc3c
当前提交 2e69fe4edb
共修改 19 个文件,包含 857 行新增和 14 行删除
@@ -1,6 +1,7 @@
const {
IM_SUPPORTED_NOTIFICATION_TYPES,
NOTIFICATION_DOMAIN_SCOPES,
NOTIFICATION_TYPE_TO_DOMAIN_SCOPE,
NOTIFICATION_TYPES,
resolveNotificationDomainScope,
} = require("../../../src/constants/notificationTypes");
@@ -37,4 +38,20 @@ describe("notification types", () => {
"payment_batch",
)).toBe(NOTIFICATION_DOMAIN_SCOPES.reimbursement);
});
it.each([
["reimbursementReturned", "reimbursement.returned", NOTIFICATION_DOMAIN_SCOPES.reimbursement],
["leaveReturned", "leave.returned", NOTIFICATION_DOMAIN_SCOPES.leave],
["purchaseRequestReturned", "purchase_request.returned", NOTIFICATION_DOMAIN_SCOPES.procurement],
["travelRequestReturned", "travel_request.returned", NOTIFICATION_DOMAIN_SCOPES.travel],
])("registers %s in the type, IM support, and domain registries", (key, value, domainScope) => {
expect(NOTIFICATION_TYPES[key]).toBe(value);
expect(IM_SUPPORTED_NOTIFICATION_TYPES).toContain(value);
expect(NOTIFICATION_TYPE_TO_DOMAIN_SCOPE[value]).toBe(domainScope);
expect(resolveNotificationDomainScope(value)).toBe(domainScope);
});
it("keeps the IM-supported notification registry unique", () => {
expect(new Set(IM_SUPPORTED_NOTIFICATION_TYPES).size).toBe(IM_SUPPORTED_NOTIFICATION_TYPES.length);
});
});
+18 -2
查看文件
@@ -1,6 +1,7 @@
const Fastify = require('fastify');
const { AppError } = require('../../../src/utils/AppError');
const { NOTIFICATION_TYPES } = require('../../../src/constants/notificationTypes');
const { encryptStringOrThrow } = require('../../../src/services/crypto');
const notificationsRoutes = require('../../../src/routes/notifications');
@@ -446,8 +447,13 @@ describe('notifications channel routes', () => {
timeoutMs: 800,
routing: {
roleCodes: ['manager'],
domainScopes: ['workflow'],
allowedTypes: ['workflow.task_assigned'],
domainScopes: ['workflow', 'leave', 'procurement', 'travel'],
allowedTypes: [
NOTIFICATION_TYPES.workflowTaskAssigned,
NOTIFICATION_TYPES.leaveReturned,
NOTIFICATION_TYPES.purchaseRequestReturned,
NOTIFICATION_TYPES.travelRequestReturned,
],
},
},
},
@@ -460,6 +466,16 @@ describe('notifications channel routes', () => {
webhookUrl: null,
secretConfigured: false,
secretMasked: null,
routing: {
roleCodes: ['manager'],
domainScopes: ['workflow', 'leave', 'procurement', 'travel'],
allowedTypes: [
NOTIFICATION_TYPES.workflowTaskAssigned,
NOTIFICATION_TYPES.leaveReturned,
NOTIFICATION_TYPES.purchaseRequestReturned,
NOTIFICATION_TYPES.travelRequestReturned,
],
},
}),
);
expect(stateChannels[0].config_json.secretEncrypted).toBeUndefined();
@@ -150,6 +150,14 @@ async function buildApp(options = {}) {
create: vi.fn(async () => ({ id: "delivery-1" })),
update: vi.fn(async () => ({})),
},
notification: {
create: vi.fn(async ({ data }) => {
if (options.notificationCreateError) {
throw options.notificationCreateError;
}
return { id: "notification-1", ...data };
}),
},
};
const prisma = {
@@ -295,6 +303,50 @@ describe("workflow batch routes", () => {
await app.close();
});
it("keeps a return committed when outcome notification creation fails", async () => {
const { app, state, tx } = await buildApp({
notificationCreateError: new Error("synthetic notification failure"),
});
const taskId = "11111111-1111-4111-8111-111111111111";
const response = await app.inject({
method: "POST",
url: `/api/workflow/tasks/${taskId}/return`,
payload: {
comment: "材料不完整,请修改后重新提交。",
},
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual(
expect.objectContaining({
taskId,
outcome: "returned",
reimbursementStatus: "draft",
}),
);
expect(tx.notification.create).toHaveBeenCalledTimes(1);
expect(state.tasks.get(taskId)).toEqual(
expect.objectContaining({
status: "completed",
outcome: "returned",
}),
);
expect(tx.reimbursementForm.update).toHaveBeenCalledWith({
where: { id: taskId },
data: expect.objectContaining({ status: "draft" }),
});
expect(state.history).toEqual([
expect.objectContaining({
instance_id: taskId,
task_id: taskId,
action: "return",
}),
]);
await app.close();
});
it("does not roll back successful tasks when one task fails or has unsupported entity type", async () => {
const { app, state } = await buildApp({
updateErrorTaskId: "44444444-4444-4444-8444-444444444444",
@@ -0,0 +1,150 @@
const { NOTIFICATION_TYPES } = require("../../../src/constants/notificationTypes");
const workflowRoutes = require("../../../src/routes/workflow");
describe("workflow returned outcome notifications", () => {
const { queueWorkflowOutcomeNotifications } = workflowRoutes.__testables;
const cases = [
{
entityType: "leave",
entityId: "leave-1",
notificationType: NOTIFICATION_TYPES.leaveReturned,
targetType: "leave_request",
targetPath: "/leave-requests",
modelName: "leaveRequest",
record: {
id: "leave-1",
owner_id: "employee-1",
start_date: new Date("2026-09-01T00:00:00.000Z"),
end_date: new Date("2026-09-02T00:00:00.000Z"),
requested_days: "2",
leave_type: { name: "年假" },
owner: { id: "employee-1", name: "测试申请人" },
},
expectedBody: "申请人:测试申请人\n假种:年假\n天数:2 天\n起止日期:2026-09-01 ~ 2026-09-02\n结果:已退回,可修改后重新提交:请补充说明",
},
{
entityType: "purchase_request",
entityId: "purchase-1",
notificationType: NOTIFICATION_TYPES.purchaseRequestReturned,
targetType: "purchase_request",
targetPath: "/purchase-requests",
modelName: "purchaseRequest",
record: {
id: "purchase-1",
request_no: "CG-TEST-001",
total_amount: "1200.00",
applicant_id: "employee-1",
submitter_id: "employee-1",
applicant: { id: "employee-1", name: "测试申请人" },
},
expectedBody: "申请人:测试申请人\n金额:1200.00\n单号:CG-TEST-001\n结果:已退回,可修改后重新提交:请补充说明",
},
{
entityType: "travel_request",
entityId: "travel-1",
notificationType: NOTIFICATION_TYPES.travelRequestReturned,
targetType: "travel_request",
targetPath: "/travel-requests?id=travel-1",
modelName: "travelRequest",
record: {
id: "travel-1",
request_no: "CL-TEST-001",
estimated_amount: "860.00",
applicant_employee_id: "employee-1",
submitter_id: "employee-1",
applicant: { id: "employee-1", name: "测试申请人" },
},
expectedBody: "申请人:测试申请人\n预估金额:860.00\n单号:CL-TEST-001\n结果:已退回,可修改后重新提交:请补充说明",
},
];
it.each(cases)("queues station, email, and IM work for $entityType returns", async (testCase) => {
const notificationCreate = vi.fn(async ({ data }) => ({ id: "notification-1", ...data }));
const tx = {
[testCase.modelName]: {
findUnique: vi.fn().mockResolvedValue(testCase.record),
},
notification: {
create: notificationCreate,
},
};
const emailTasks = [];
const dispatchTasks = [];
await queueWorkflowOutcomeNotifications({
tx,
actorId: "approver-1",
emailTasks,
dispatchTasks,
workflowInstance: {
entity_type: testCase.entityType,
entity_id: testCase.entityId,
},
outcome: "returned",
comment: "请补充说明",
});
expect(notificationCreate).toHaveBeenCalledTimes(1);
expect(notificationCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
recipient_employee_id: "employee-1",
type: testCase.notificationType,
body: testCase.expectedBody,
target_type: testCase.targetType,
target_id: testCase.entityId,
}),
});
expect(dispatchTasks).toEqual([
expect.objectContaining({
type: testCase.notificationType,
body: testCase.expectedBody,
targetType: testCase.targetType,
targetId: testCase.entityId,
targetPath: testCase.targetPath,
actorId: "approver-1",
}),
]);
expect(emailTasks).toEqual([
expect.objectContaining({
type: testCase.notificationType,
recipientEmployeeIds: ["employee-1"],
body: testCase.expectedBody,
targetPath: testCase.targetPath,
}),
]);
});
it("deduplicates purchase applicant and submitter before creating side effects", async () => {
const notificationCreate = vi.fn(async ({ data }) => ({ id: "notification-1", ...data }));
const tx = {
purchaseRequest: {
findUnique: vi.fn().mockResolvedValue({
id: "purchase-1",
request_no: "CG-TEST-001",
total_amount: "1200.00",
applicant_id: "employee-1",
submitter_id: "employee-1",
applicant: { id: "employee-1", name: "测试申请人" },
}),
},
notification: { create: notificationCreate },
};
const emailTasks = [];
const dispatchTasks = [];
await queueWorkflowOutcomeNotifications({
tx,
actorId: "approver-1",
emailTasks,
dispatchTasks,
workflowInstance: { entity_type: "purchase_request", entity_id: "purchase-1" },
outcome: "returned",
comment: "请补充说明",
});
expect(notificationCreate).toHaveBeenCalledTimes(1);
expect(emailTasks[0].recipientEmployeeIds).toEqual(["employee-1"]);
expect(dispatchTasks[0].notifications).toHaveLength(1);
});
});
@@ -85,7 +85,7 @@ describe("workflow travel helpers", () => {
});
expect(buildReturnedCopy(reimbursementMeta, "请补发票")).toEqual({
title: "【已退回】报销单 BX-20260325-001",
body: "申请人:刘报销\n金额:880.00\n单号:BX-20260325-001\n结果:已退回:请补发票",
body: "申请人:刘报销\n金额:880.00\n单号:BX-20260325-001\n结果:已退回,可修改后重新提交:请补发票",
});
expect(buildRemindedTaskCopy(reimbursementMeta)).toEqual({
title: "【催办】报销单 BX-20260325-001 待审批",
@@ -97,14 +97,20 @@ describe("workflow travel helpers", () => {
});
});
it("resolves approved and rejected notification types for travel workflows", () => {
expect(resolveWorkflowOutcomeNotificationType("travel_request", "approved")).toBe(
NOTIFICATION_TYPES.travelRequestApproved,
);
expect(resolveWorkflowOutcomeNotificationType("travel_request", "rejected")).toBe(
NOTIFICATION_TYPES.travelRequestRejected,
);
expect(resolveWorkflowOutcomeNotificationType("travel_request", "returned")).toBeNull();
it.each([
["reimbursement", NOTIFICATION_TYPES.reimbursementReturned],
["leave", NOTIFICATION_TYPES.leaveReturned],
["purchase_request", NOTIFICATION_TYPES.purchaseRequestReturned],
["procurement", NOTIFICATION_TYPES.purchaseRequestReturned],
["travel_request", NOTIFICATION_TYPES.travelRequestReturned],
])("resolves returned notification type for %s workflows", (entityType, expectedType) => {
expect(resolveWorkflowOutcomeNotificationType(entityType, "returned")).toBe(expectedType);
});
it("keeps travel approved/rejected and attendance returned outcome mappings unchanged", () => {
expect(resolveWorkflowOutcomeNotificationType("travel_request", "approved")).toBe(NOTIFICATION_TYPES.travelRequestApproved);
expect(resolveWorkflowOutcomeNotificationType("travel_request", "rejected")).toBe(NOTIFICATION_TYPES.travelRequestRejected);
expect(resolveWorkflowOutcomeNotificationType("attendance_correction", "returned")).toBeNull();
});
it("builds pending task productivity summary with overdue buckets", () => {
@@ -1,3 +1,7 @@
const {
NOTIFICATION_DOMAIN_SCOPES,
NOTIFICATION_TYPES,
} = require("../../../src/constants/notificationTypes");
const {
isImChannelTypeSupported,
matchesNotificationChannel,
@@ -88,4 +92,37 @@ describe("IM routing", () => {
}),
).toBe(false);
});
it.each([
[NOTIFICATION_TYPES.leaveReturned, NOTIFICATION_DOMAIN_SCOPES.leave],
[NOTIFICATION_TYPES.purchaseRequestReturned, NOTIFICATION_DOMAIN_SCOPES.procurement],
[NOTIFICATION_TYPES.travelRequestReturned, NOTIFICATION_DOMAIN_SCOPES.travel],
])("requires both the allowed type and domain for %s", (notificationType, domainScope) => {
const buildChannel = (routing) => ({
enabled: true,
channel_type: "wecom",
config_json: { routing },
});
const context = {
notificationType,
domainScope,
recipientRoleCodes: ["employee"],
};
expect(matchesNotificationChannel(buildChannel({
roleCodes: [],
domainScopes: [domainScope],
allowedTypes: [notificationType],
}), context)).toBe(true);
expect(matchesNotificationChannel(buildChannel({
roleCodes: [],
domainScopes: [NOTIFICATION_DOMAIN_SCOPES.workflow],
allowedTypes: [notificationType],
}), context)).toBe(false);
expect(matchesNotificationChannel(buildChannel({
roleCodes: [],
domainScopes: [domainScope],
allowedTypes: [NOTIFICATION_TYPES.workflowTaskAssigned],
}), context)).toBe(false);
});
});