R32-1 R32-4 R33-2: 收口工作台交互与报销链路

这个提交包含在:
2026-03-24 20:26:22 +08:00
父节点 5aaa628d54
当前提交 1056c81940
共修改 20 个文件,包含 856 行新增和 141 行删除
+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,
};
+23 -2
查看文件
@@ -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,
});
},
);