R32-1 R32-4 R33-2: 收口工作台交互与报销链路
这个提交包含在:
+239
-68
@@ -23,6 +23,7 @@ const {
|
||||
const { issueDocumentNumber } = require("../services/documentSequence");
|
||||
const {
|
||||
buildWorkflowStepsFromApprovalRule,
|
||||
getWorkflowStepsSnapshot,
|
||||
resolveActionableStep,
|
||||
toMoneyString,
|
||||
} = require("../utils/workflow");
|
||||
@@ -81,6 +82,38 @@ function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildWorkflowProgress(workflowInstance, historyItems = []) {
|
||||
if (!workflowInstance?.current_step_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const steps = getWorkflowStepsSnapshot(workflowInstance);
|
||||
const currentStepIndex = steps.findIndex((step) => step.id === workflowInstance.current_step_id);
|
||||
if (currentStepIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const skippedStepIds = new Set(
|
||||
historyItems
|
||||
.filter((item) => item.action === "skip" && item.payload?.step_id)
|
||||
.map((item) => String(item.payload.step_id)),
|
||||
);
|
||||
|
||||
return {
|
||||
currentStepIndex,
|
||||
totalSteps: steps.length,
|
||||
steps: steps.map((step, index) => ({
|
||||
id: step.id,
|
||||
name: step.name ?? step.id,
|
||||
status: skippedStepIds.has(step.id) || index < currentStepIndex
|
||||
? "completed"
|
||||
: index === currentStepIndex
|
||||
? "current"
|
||||
: "pending",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureApplicantOrAdmin(request, reimbursement) {
|
||||
if (request.employee?.roleCodes?.includes("admin")) {
|
||||
return;
|
||||
@@ -423,6 +456,161 @@ function buildPrecheckBlockedError(precheck) {
|
||||
});
|
||||
}
|
||||
|
||||
function buildSkippedNotificationResult(reason) {
|
||||
return { skipped: true, reason };
|
||||
}
|
||||
|
||||
function buildSkippedImResult(reason) {
|
||||
return { queued: 0, duplicated: 0, matchedChannels: [], skippedReason: reason };
|
||||
}
|
||||
|
||||
function buildSkippedEmailResult(reason, failed = 0) {
|
||||
return { skipped: true, reason, sent: 0, failed };
|
||||
}
|
||||
|
||||
async function finalizeReimbursementSubmitSideEffects({
|
||||
prisma,
|
||||
config,
|
||||
logger,
|
||||
requestId,
|
||||
reimbursementId,
|
||||
formNumber,
|
||||
workflowTaskId,
|
||||
assigneeId,
|
||||
assigneeRole,
|
||||
actorId,
|
||||
resolveRecipients = resolveRecipientsForAssignee,
|
||||
createAppNotifications = createNotifications,
|
||||
enqueueIm = enqueueImNotifications,
|
||||
sendEmailSafe = sendNotificationEmailSafe,
|
||||
}) {
|
||||
const title = `【待办】报销单 ${formNumber} 待审批`;
|
||||
const body = `报销单 ${formNumber} 已提交,请及时处理。`;
|
||||
const targetPath = `/workflow/tasks/${workflowTaskId}`;
|
||||
|
||||
let recipientEmployeeIds = [];
|
||||
try {
|
||||
recipientEmployeeIds = await resolveRecipients(prisma, {
|
||||
assigneeId,
|
||||
assigneeRole,
|
||||
});
|
||||
} catch (error) {
|
||||
logger?.error?.(
|
||||
{
|
||||
err: error,
|
||||
requestId,
|
||||
reimbursementId,
|
||||
workflowTaskId,
|
||||
},
|
||||
"Failed to resolve reimbursement submit notification recipients",
|
||||
);
|
||||
return {
|
||||
recipientEmployeeIds: [],
|
||||
notification: buildSkippedNotificationResult("recipient_resolution_failed"),
|
||||
im: buildSkippedImResult("recipient_resolution_failed"),
|
||||
email: buildSkippedEmailResult("recipient_resolution_failed"),
|
||||
};
|
||||
}
|
||||
|
||||
if (!recipientEmployeeIds.length) {
|
||||
return {
|
||||
recipientEmployeeIds: [],
|
||||
notification: buildSkippedNotificationResult("no_recipients"),
|
||||
im: buildSkippedImResult("no_recipients"),
|
||||
email: buildSkippedEmailResult("no_recipients"),
|
||||
};
|
||||
}
|
||||
|
||||
let notification = buildSkippedNotificationResult("not_attempted");
|
||||
try {
|
||||
notification = await createAppNotifications(prisma, {
|
||||
recipientEmployeeIds,
|
||||
type: NOTIFICATION_TYPES.workflowTaskAssigned,
|
||||
title,
|
||||
body,
|
||||
targetType: "workflow_task",
|
||||
targetId: workflowTaskId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger?.error?.(
|
||||
{
|
||||
err: error,
|
||||
requestId,
|
||||
reimbursementId,
|
||||
workflowTaskId,
|
||||
recipients: recipientEmployeeIds.length,
|
||||
},
|
||||
"Failed to create reimbursement submit notifications",
|
||||
);
|
||||
notification = buildSkippedNotificationResult("notification_failed");
|
||||
}
|
||||
|
||||
let im = buildSkippedImResult("not_attempted");
|
||||
try {
|
||||
im = await enqueueIm(prisma, {
|
||||
config,
|
||||
logger,
|
||||
recipientEmployeeIds,
|
||||
type: NOTIFICATION_TYPES.workflowTaskAssigned,
|
||||
title,
|
||||
body,
|
||||
targetType: "workflow_task",
|
||||
targetId: workflowTaskId,
|
||||
targetPath,
|
||||
entityType: "reimbursement",
|
||||
entityId: reimbursementId,
|
||||
actorId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger?.error?.(
|
||||
{
|
||||
err: error,
|
||||
requestId,
|
||||
reimbursementId,
|
||||
workflowTaskId,
|
||||
recipients: recipientEmployeeIds.length,
|
||||
},
|
||||
"Failed to enqueue reimbursement submit IM notifications",
|
||||
);
|
||||
im = buildSkippedImResult("enqueue_im_failed");
|
||||
}
|
||||
|
||||
let email = buildSkippedEmailResult("not_attempted");
|
||||
try {
|
||||
const emailResult = await sendEmailSafe({
|
||||
prisma,
|
||||
config,
|
||||
logger,
|
||||
requestId,
|
||||
recipientEmployeeIds,
|
||||
subject: title,
|
||||
body,
|
||||
type: NOTIFICATION_TYPES.workflowTaskAssigned,
|
||||
targetPath,
|
||||
});
|
||||
email = emailResult?.email ?? emailResult ?? buildSkippedEmailResult("empty_email_result");
|
||||
} catch (error) {
|
||||
logger?.error?.(
|
||||
{
|
||||
err: error,
|
||||
requestId,
|
||||
reimbursementId,
|
||||
workflowTaskId,
|
||||
recipients: recipientEmployeeIds.length,
|
||||
},
|
||||
"Failed to send reimbursement submit notification email",
|
||||
);
|
||||
email = buildSkippedEmailResult("email_failed", recipientEmployeeIds.length);
|
||||
}
|
||||
|
||||
return {
|
||||
recipientEmployeeIds,
|
||||
notification,
|
||||
im,
|
||||
email,
|
||||
};
|
||||
}
|
||||
|
||||
async function reimbursementRoutes(fastify) {
|
||||
const createBodySchema = z.object({
|
||||
ownerEmployeeId: z.string().uuid().optional(),
|
||||
@@ -705,6 +893,13 @@ async function reimbursementRoutes(fastify) {
|
||||
select: {
|
||||
id: true,
|
||||
current_step_id: true,
|
||||
current_status: true,
|
||||
context_data: true,
|
||||
workflow_definition: {
|
||||
select: {
|
||||
definition_json: true,
|
||||
},
|
||||
},
|
||||
workflow_tasks: {
|
||||
where: { status: "pending" },
|
||||
take: 1,
|
||||
@@ -738,6 +933,16 @@ async function reimbursementRoutes(fastify) {
|
||||
|
||||
const pendingInstance = reimbursement.workflow_instances[0] ?? null;
|
||||
const pendingTask = pendingInstance?.workflow_tasks?.[0] ?? null;
|
||||
const workflowHistory = pendingInstance
|
||||
? await fastify.prisma.workflowHistory.findMany({
|
||||
where: { instance_id: pendingInstance.id },
|
||||
orderBy: { created_at: "asc" },
|
||||
select: {
|
||||
action: true,
|
||||
payload: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
const riskFlags = await fastify.prisma.entityRiskFlag.findMany({
|
||||
where: {
|
||||
entity_type: "reimbursement",
|
||||
@@ -792,6 +997,7 @@ async function reimbursementRoutes(fastify) {
|
||||
task: pendingTask,
|
||||
}
|
||||
: null,
|
||||
workflowProgress: pendingInstance ? buildWorkflowProgress(pendingInstance, workflowHistory) : null,
|
||||
riskFlags: riskFlags.map(buildPersistedRiskFlag),
|
||||
});
|
||||
},
|
||||
@@ -1294,11 +1500,8 @@ async function reimbursementRoutes(fastify) {
|
||||
},
|
||||
async (request, reply) => {
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
let submitResult = null;
|
||||
let budgetCheck = null;
|
||||
const emailTasks = [];
|
||||
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
const submitResult = await fastify.prisma.$transaction(async (tx) => {
|
||||
let budgetCheck = null;
|
||||
const reimbursementRows = await tx.$queryRaw`
|
||||
SELECT id, owner_id, submitter_id, department_id, reimbursement_date, status, form_number, total_amount
|
||||
FROM "reimbursement_forms"
|
||||
@@ -1579,50 +1782,6 @@ async function reimbursementRoutes(fastify) {
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const recipients = await resolveRecipientsForAssignee(tx, {
|
||||
assigneeId: workflowTask.assignee_id,
|
||||
assigneeRole: workflowTask.assignee_role,
|
||||
});
|
||||
|
||||
await createNotifications(tx, {
|
||||
recipientEmployeeIds: recipients,
|
||||
type: NOTIFICATION_TYPES.workflowTaskAssigned,
|
||||
title: `【待办】报销单 ${formNumber} 待审批`,
|
||||
body: `报销单 ${formNumber} 已提交,请及时处理。`,
|
||||
targetType: "workflow_task",
|
||||
targetId: workflowTask.id,
|
||||
});
|
||||
|
||||
await enqueueImNotifications(tx, {
|
||||
config: fastify.config,
|
||||
logger: fastify.log,
|
||||
recipientEmployeeIds: recipients,
|
||||
type: NOTIFICATION_TYPES.workflowTaskAssigned,
|
||||
title: `【待办】报销单 ${formNumber} 待审批`,
|
||||
body: `报销单 ${formNumber} 已提交,请及时处理。`,
|
||||
targetType: "workflow_task",
|
||||
targetId: workflowTask.id,
|
||||
targetPath: `/workflow/tasks/${workflowTask.id}`,
|
||||
entityType: "reimbursement",
|
||||
entityId: reimbursementId,
|
||||
actorId: request.employee.id,
|
||||
});
|
||||
|
||||
emailTasks.push({
|
||||
type: NOTIFICATION_TYPES.workflowTaskAssigned,
|
||||
recipientEmployeeIds: recipients,
|
||||
subject: `【待办】报销单 ${formNumber} 待审批`,
|
||||
body: `报销单 ${formNumber} 已提交,请及时处理。`,
|
||||
targetPath: `/workflow/tasks/${workflowTask.id}`,
|
||||
});
|
||||
} catch (error) {
|
||||
fastify.log.error(
|
||||
{ err: error, reimbursementId, taskId: workflowTask.id },
|
||||
"Failed to create task assigned notifications",
|
||||
);
|
||||
}
|
||||
|
||||
if (reimbursement.submitter_id !== reimbursement.owner_id) {
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
@@ -1639,29 +1798,37 @@ async function reimbursementRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
submitResult = {
|
||||
reimbursementId,
|
||||
formNumber,
|
||||
workflowInstanceId: workflowInstance.id,
|
||||
currentTaskId: workflowTask.id,
|
||||
status: "reviewing",
|
||||
budget: budgetCheck,
|
||||
precheckDecision: authoritativePrecheck.decision,
|
||||
openedRiskFlagCount: openedRiskFlags.length,
|
||||
return {
|
||||
response: {
|
||||
reimbursementId,
|
||||
formNumber,
|
||||
workflowInstanceId: workflowInstance.id,
|
||||
currentTaskId: workflowTask.id,
|
||||
status: "reviewing",
|
||||
budget: budgetCheck,
|
||||
precheckDecision: authoritativePrecheck.decision,
|
||||
openedRiskFlagCount: openedRiskFlags.length,
|
||||
},
|
||||
sideEffectsContext: {
|
||||
reimbursementId,
|
||||
formNumber,
|
||||
workflowTaskId: workflowTask.id,
|
||||
assigneeId: workflowTask.assignee_id,
|
||||
assigneeRole: workflowTask.assignee_role,
|
||||
actorId: request.employee.id,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
for (const task of emailTasks) {
|
||||
void sendNotificationEmailSafe({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
logger: fastify.log,
|
||||
requestId: request.id,
|
||||
...task,
|
||||
});
|
||||
}
|
||||
await finalizeReimbursementSubmitSideEffects({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
logger: fastify.log,
|
||||
requestId: request.id,
|
||||
...submitResult.sideEffectsContext,
|
||||
});
|
||||
|
||||
return sendData(reply, request, submitResult);
|
||||
return sendData(reply, request, submitResult.response);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2093,3 +2260,7 @@ async function reimbursementRoutes(fastify) {
|
||||
}
|
||||
|
||||
module.exports = reimbursementRoutes;
|
||||
|
||||
module.exports.__testables = {
|
||||
finalizeReimbursementSubmitSideEffects,
|
||||
};
|
||||
|
||||
@@ -231,7 +231,7 @@ function isoOrNull(value) {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
function mapTaskSummary(task) {
|
||||
function mapTaskSummary(task, allowedActions = []) {
|
||||
const workflowInstance = task.workflow_instance
|
||||
? {
|
||||
entity_type: task.workflow_instance.entity_type,
|
||||
@@ -260,6 +260,7 @@ function mapTaskSummary(task) {
|
||||
assigneeRole: task.assignee_role,
|
||||
createdAt: isoOrNull(task.created_at),
|
||||
completedAt: isoOrNull(task.completed_at),
|
||||
allowedActions,
|
||||
workflowInstance: workflowInstance
|
||||
? {
|
||||
entityType: workflowInstance.entity_type,
|
||||
@@ -860,11 +861,31 @@ async function workflowRoutes(fastify) {
|
||||
fastify.prisma.workflowTask.count({ where }),
|
||||
]);
|
||||
|
||||
const taskSummaries = await Promise.all(
|
||||
tasks.map(async (task) => {
|
||||
let actionable = false;
|
||||
if (task.status === "pending") {
|
||||
try {
|
||||
await ensureTaskAssignableToCurrentEmployee(fastify.prisma, task, request);
|
||||
actionable = true;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError && error.code === "PERM_002") {
|
||||
actionable = false;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mapTaskSummary(task, listAllowedActionsForTask(request, task, actionable));
|
||||
}),
|
||||
);
|
||||
|
||||
return sendData(reply, request, {
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total,
|
||||
items: tasks.map(mapTaskSummary),
|
||||
items: taskSummaries,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
const reimbursementRoutes = require('../../../src/routes/reimbursements');
|
||||
|
||||
describe('reimbursement submit side effects helper', () => {
|
||||
const { finalizeReimbursementSubmitSideEffects } = reimbursementRoutes.__testables;
|
||||
|
||||
function createParams(overrides = {}) {
|
||||
return {
|
||||
prisma: {},
|
||||
config: {
|
||||
imNotifierEnabled: true,
|
||||
emailEnabled: false,
|
||||
},
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
requestId: 'req-1',
|
||||
reimbursementId: 'reimbursement-1',
|
||||
formNumber: 'BX-20260324-001',
|
||||
workflowTaskId: 'task-1',
|
||||
assigneeId: 'manager-1',
|
||||
assigneeRole: null,
|
||||
actorId: 'employee-1',
|
||||
resolveRecipients: vi.fn().mockResolvedValue(['manager-1']),
|
||||
createAppNotifications: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
enqueueIm: vi.fn().mockResolvedValue({
|
||||
queued: 1,
|
||||
duplicated: 0,
|
||||
matchedChannels: [{ id: 'channel-1' }],
|
||||
}),
|
||||
sendEmailSafe: vi.fn().mockResolvedValue({
|
||||
email: { skipped: true, sent: 0, failed: 0 },
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('does not throw when resolving recipients fails', async () => {
|
||||
const logger = {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
};
|
||||
const createAppNotifications = vi.fn();
|
||||
const enqueueIm = vi.fn();
|
||||
const sendEmailSafe = vi.fn();
|
||||
|
||||
const result = await finalizeReimbursementSubmitSideEffects(
|
||||
createParams({
|
||||
logger,
|
||||
resolveRecipients: vi.fn().mockRejectedValue(new Error('resolve failed')),
|
||||
createAppNotifications,
|
||||
enqueueIm,
|
||||
sendEmailSafe,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
expect(createAppNotifications).not.toHaveBeenCalled();
|
||||
expect(enqueueIm).not.toHaveBeenCalled();
|
||||
expect(sendEmailSafe).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
recipientEmployeeIds: [],
|
||||
notification: expect.objectContaining({ skipped: true, reason: 'recipient_resolution_failed' }),
|
||||
im: expect.objectContaining({ queued: 0, skippedReason: 'recipient_resolution_failed' }),
|
||||
email: expect.objectContaining({ skipped: true, reason: 'recipient_resolution_failed' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs notification and IM failures and still continues to email', async () => {
|
||||
const logger = {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
};
|
||||
const enqueueIm = vi.fn().mockRejectedValue(new Error('im failed'));
|
||||
const sendEmailSafe = vi.fn().mockResolvedValue({
|
||||
email: { skipped: true, sent: 0, failed: 0 },
|
||||
});
|
||||
|
||||
const result = await finalizeReimbursementSubmitSideEffects(
|
||||
createParams({
|
||||
logger,
|
||||
createAppNotifications: vi.fn().mockRejectedValue(new Error('notifications failed')),
|
||||
enqueueIm,
|
||||
sendEmailSafe,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledTimes(2);
|
||||
expect(enqueueIm).toHaveBeenCalledTimes(1);
|
||||
expect(sendEmailSafe).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
recipientEmployeeIds: ['manager-1'],
|
||||
notification: expect.objectContaining({ skipped: true, reason: 'notification_failed' }),
|
||||
im: expect.objectContaining({ queued: 0, skippedReason: 'enqueue_im_failed' }),
|
||||
email: expect.objectContaining({ skipped: true, sent: 0, failed: 0 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户