补齐审批退回结果通知 [09-04-approval-return-notification-gap]
这个提交包含在:
@@ -21,12 +21,15 @@ const NOTIFICATION_TYPES = Object.freeze({
|
||||
paymentBatchExecutionRequested: 'payment_batch.execution_requested',
|
||||
leaveApproved: 'leave.approved',
|
||||
leaveRejected: 'leave.rejected',
|
||||
leaveReturned: 'leave.returned',
|
||||
attendanceCorrectionApproved: 'attendance_correction.approved',
|
||||
attendanceCorrectionRejected: 'attendance_correction.rejected',
|
||||
purchaseRequestApproved: 'purchase_request.approved',
|
||||
purchaseRequestRejected: 'purchase_request.rejected',
|
||||
purchaseRequestReturned: 'purchase_request.returned',
|
||||
travelRequestApproved: 'travel_request.approved',
|
||||
travelRequestRejected: 'travel_request.rejected',
|
||||
travelRequestReturned: 'travel_request.returned',
|
||||
assetAssigned: 'asset.assigned',
|
||||
assetReturned: 'asset.returned',
|
||||
assetTransferred: 'asset.transferred',
|
||||
@@ -45,12 +48,15 @@ const IM_SUPPORTED_NOTIFICATION_TYPES = Object.freeze([
|
||||
NOTIFICATION_TYPES.paymentBatchExecutionRequested,
|
||||
NOTIFICATION_TYPES.leaveApproved,
|
||||
NOTIFICATION_TYPES.leaveRejected,
|
||||
NOTIFICATION_TYPES.leaveReturned,
|
||||
NOTIFICATION_TYPES.attendanceCorrectionApproved,
|
||||
NOTIFICATION_TYPES.attendanceCorrectionRejected,
|
||||
NOTIFICATION_TYPES.purchaseRequestApproved,
|
||||
NOTIFICATION_TYPES.purchaseRequestRejected,
|
||||
NOTIFICATION_TYPES.purchaseRequestReturned,
|
||||
NOTIFICATION_TYPES.travelRequestApproved,
|
||||
NOTIFICATION_TYPES.travelRequestRejected,
|
||||
NOTIFICATION_TYPES.travelRequestReturned,
|
||||
NOTIFICATION_TYPES.assetAssigned,
|
||||
NOTIFICATION_TYPES.assetReturned,
|
||||
NOTIFICATION_TYPES.assetTransferred,
|
||||
@@ -69,12 +75,15 @@ const NOTIFICATION_TYPE_TO_DOMAIN_SCOPE = Object.freeze({
|
||||
[NOTIFICATION_TYPES.paymentBatchExecutionRequested]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
|
||||
[NOTIFICATION_TYPES.leaveApproved]: NOTIFICATION_DOMAIN_SCOPES.leave,
|
||||
[NOTIFICATION_TYPES.leaveRejected]: NOTIFICATION_DOMAIN_SCOPES.leave,
|
||||
[NOTIFICATION_TYPES.leaveReturned]: NOTIFICATION_DOMAIN_SCOPES.leave,
|
||||
[NOTIFICATION_TYPES.attendanceCorrectionApproved]: NOTIFICATION_DOMAIN_SCOPES.attendance,
|
||||
[NOTIFICATION_TYPES.attendanceCorrectionRejected]: NOTIFICATION_DOMAIN_SCOPES.attendance,
|
||||
[NOTIFICATION_TYPES.purchaseRequestApproved]: NOTIFICATION_DOMAIN_SCOPES.procurement,
|
||||
[NOTIFICATION_TYPES.purchaseRequestRejected]: NOTIFICATION_DOMAIN_SCOPES.procurement,
|
||||
[NOTIFICATION_TYPES.purchaseRequestReturned]: NOTIFICATION_DOMAIN_SCOPES.procurement,
|
||||
[NOTIFICATION_TYPES.travelRequestApproved]: NOTIFICATION_DOMAIN_SCOPES.travel,
|
||||
[NOTIFICATION_TYPES.travelRequestRejected]: NOTIFICATION_DOMAIN_SCOPES.travel,
|
||||
[NOTIFICATION_TYPES.travelRequestReturned]: NOTIFICATION_DOMAIN_SCOPES.travel,
|
||||
[NOTIFICATION_TYPES.assetAssigned]: NOTIFICATION_DOMAIN_SCOPES.asset,
|
||||
[NOTIFICATION_TYPES.assetReturned]: NOTIFICATION_DOMAIN_SCOPES.asset,
|
||||
[NOTIFICATION_TYPES.assetTransferred]: NOTIFICATION_DOMAIN_SCOPES.asset,
|
||||
@@ -110,5 +119,6 @@ module.exports = {
|
||||
NOTIFICATION_TYPES,
|
||||
IM_SUPPORTED_NOTIFICATION_TYPES,
|
||||
NOTIFICATION_DOMAIN_SCOPES,
|
||||
NOTIFICATION_TYPE_TO_DOMAIN_SCOPE,
|
||||
resolveNotificationDomainScope,
|
||||
};
|
||||
|
||||
@@ -1094,6 +1094,9 @@ function resolveWorkflowOutcomeNotificationType(entityType, outcome) {
|
||||
|
||||
if (outcome === 'returned') {
|
||||
if (normalizedEntityType === 'reimbursement') return NOTIFICATION_TYPES.reimbursementReturned;
|
||||
if (normalizedEntityType === 'leave') return NOTIFICATION_TYPES.leaveReturned;
|
||||
if (normalizedEntityType === 'purchase_request') return NOTIFICATION_TYPES.purchaseRequestReturned;
|
||||
if (normalizedEntityType === 'travel_request') return NOTIFICATION_TYPES.travelRequestReturned;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -3334,5 +3337,6 @@ module.exports.__testables = {
|
||||
isWorkflowTaskOverdue,
|
||||
listAllowedActionsForTask,
|
||||
parseTaskListQuery,
|
||||
queueWorkflowOutcomeNotifications,
|
||||
resolveWorkflowOutcomeNotificationType,
|
||||
};
|
||||
|
||||
@@ -247,7 +247,7 @@ function buildReturnedCopy(entityMeta, comment) {
|
||||
const safeComment = String(comment ?? "").trim() || "无";
|
||||
return {
|
||||
title: `【已退回】${buildSubject(entityMeta)}`,
|
||||
body: buildBody(entityMeta, `结果:已退回:${safeComment}`),
|
||||
body: buildBody(entityMeta, `结果:已退回,可修改后重新提交:${safeComment}`),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户