feat(server): 工作流扩展 leave 实体骨架(F24)

- workflow_instances 解耦 reimbursement FK:新增 reimbursement_id/leave_request_id + 回填迁移
- 新增 leave_requests 表与 /api/leave-requests 最小链路(create/submit)
- workflow approve/reject/return 支持 leave 状态更新;transfer 非报销任务禁用
- seed-config 增加 leave workflow definition;更新 v3 CSV 状态推进
这个提交包含在:
2026-02-09 21:24:53 +08:00
父节点 3cc3013d5a
当前提交 70f4d729f0
共修改 9 个文件,包含 695 行新增和 132 行删除
+182 -130
查看文件
@@ -203,7 +203,10 @@ function listAllowedActionsForTask(request, task, actionable) {
actions.push("approve", "reject", "return");
}
if (hasPermission(request.employee.permissionCodes, "expense.transfer")) {
if (
task.workflow_instance?.entity_type === "reimbursement" &&
hasPermission(request.employee.permissionCodes, "expense.transfer")
) {
actions.push("transfer");
}
@@ -295,6 +298,7 @@ async function loadWorkflowInstanceForTask(tx, instanceId) {
where: { id: instanceId },
select: {
id: true,
entity_type: true,
entity_id: true,
current_step_id: true,
context_data: true,
@@ -501,6 +505,8 @@ async function workflowRoutes(fastify) {
workflow_instance: {
select: {
id: true,
entity_type: true,
entity_id: true,
current_status: true,
current_step_id: true,
started_at: true,
@@ -812,37 +818,39 @@ async function workflowRoutes(fastify) {
},
});
try {
const reimbursement = await loadReimbursementNotifyContext(
tx,
workflowInstance.entity_id,
);
const recipients = await resolveRecipientsForAssignee(tx, {
assigneeId: actionable.assigneeId,
assigneeRole: actionable.assigneeRole,
});
if (workflowInstance.entity_type === "reimbursement") {
try {
const reimbursement = await loadReimbursementNotifyContext(
tx,
workflowInstance.entity_id,
);
const recipients = await resolveRecipientsForAssignee(tx, {
assigneeId: actionable.assigneeId,
assigneeRole: actionable.assigneeRole,
});
await createNotifications(tx, {
recipientEmployeeIds: recipients,
type: "workflow.task_assigned",
title: `【待办】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 待审批`,
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已流转到你,请及时处理。`,
targetType: "workflow_task",
targetId: nextTask.id,
});
await createNotifications(tx, {
recipientEmployeeIds: recipients,
type: "workflow.task_assigned",
title: `【待办】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 待审批`,
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已流转到你,请及时处理。`,
targetType: "workflow_task",
targetId: nextTask.id,
});
emailTasks.push({
type: "workflow.task_assigned",
recipientEmployeeIds: recipients,
subject: `【待办】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 待审批`,
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已流转到你,请及时处理。`,
targetPath: `/workflow/tasks/${nextTask.id}`,
});
} catch (error) {
fastify.log.error(
{ err: error, taskId: nextTask.id },
"Failed to create task assigned notifications",
);
emailTasks.push({
type: "workflow.task_assigned",
recipientEmployeeIds: recipients,
subject: `【待办】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 待审批`,
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已流转到你,请及时处理。`,
targetPath: `/workflow/tasks/${nextTask.id}`,
});
} catch (error) {
fastify.log.error(
{ err: error, taskId: nextTask.id },
"Failed to create task assigned notifications",
);
}
}
result = {
@@ -850,7 +858,7 @@ async function workflowRoutes(fastify) {
outcome: "approved",
workflowInstanceId: workflowInstance.id,
nextTaskId: nextTask.id,
reimbursementStatus: "reviewing",
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "reviewing" : null,
};
return;
}
@@ -864,13 +872,23 @@ async function workflowRoutes(fastify) {
},
});
await tx.reimbursementForm.update({
where: { id: workflowInstance.entity_id },
data: {
status: "approved",
updated_by: request.employee.id,
},
});
if (workflowInstance.entity_type === "reimbursement") {
await tx.reimbursementForm.update({
where: { id: workflowInstance.entity_id },
data: {
status: "approved",
updated_by: request.employee.id,
},
});
} else if (workflowInstance.entity_type === "leave") {
await tx.leaveRequest.update({
where: { id: workflowInstance.entity_id },
data: {
status: "approved",
updated_by: request.employee.id,
},
});
}
await tx.workflowHistory.create({
data: {
@@ -889,33 +907,35 @@ async function workflowRoutes(fastify) {
},
});
try {
const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id);
const recipients = Array.from(
new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)),
);
if (workflowInstance.entity_type === "reimbursement") {
try {
const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id);
const recipients = Array.from(
new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)),
);
await createNotifications(tx, {
recipientEmployeeIds: recipients,
type: "reimbursement.approved",
title: `【已批准】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已审批通过,等待财务付款。`,
targetType: "reimbursement_form",
targetId: workflowInstance.entity_id,
});
await createNotifications(tx, {
recipientEmployeeIds: recipients,
type: "reimbursement.approved",
title: `【已批准】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已审批通过,等待财务付款。`,
targetType: "reimbursement_form",
targetId: workflowInstance.entity_id,
});
emailTasks.push({
type: "reimbursement.approved",
recipientEmployeeIds: recipients,
subject: `【已批准】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已审批通过,等待财务付款。`,
targetPath: `/reimbursements/${workflowInstance.entity_id}`,
});
} catch (error) {
fastify.log.error(
{ err: error, taskId, reimbursementId: workflowInstance.entity_id },
"Failed to create reimbursement approved notifications",
);
emailTasks.push({
type: "reimbursement.approved",
recipientEmployeeIds: recipients,
subject: `【已批准】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已审批通过,等待财务付款。`,
targetPath: `/reimbursements/${workflowInstance.entity_id}`,
});
} catch (error) {
fastify.log.error(
{ err: error, taskId, reimbursementId: workflowInstance.entity_id },
"Failed to create reimbursement approved notifications",
);
}
}
result = {
@@ -923,7 +943,7 @@ async function workflowRoutes(fastify) {
outcome: "approved",
workflowInstanceId: workflowInstance.id,
nextTaskId: null,
reimbursementStatus: "approved",
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "approved" : null,
};
});
@@ -982,13 +1002,23 @@ async function workflowRoutes(fastify) {
},
});
await tx.reimbursementForm.update({
where: { id: workflowInstance.entity_id },
data: {
status: "rejected",
updated_by: request.employee.id,
},
});
if (workflowInstance.entity_type === "reimbursement") {
await tx.reimbursementForm.update({
where: { id: workflowInstance.entity_id },
data: {
status: "rejected",
updated_by: request.employee.id,
},
});
} else if (workflowInstance.entity_type === "leave") {
await tx.leaveRequest.update({
where: { id: workflowInstance.entity_id },
data: {
status: "rejected",
updated_by: request.employee.id,
},
});
}
await tx.workflowHistory.create({
data: {
@@ -1007,40 +1037,42 @@ async function workflowRoutes(fastify) {
},
});
try {
const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id);
const recipients = Array.from(
new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)),
);
if (workflowInstance.entity_type === "reimbursement") {
try {
const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id);
const recipients = Array.from(
new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)),
);
await createNotifications(tx, {
recipientEmployeeIds: recipients,
type: "reimbursement.rejected",
title: `【已驳回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已被驳回:${comment}`,
targetType: "reimbursement_form",
targetId: workflowInstance.entity_id,
});
await createNotifications(tx, {
recipientEmployeeIds: recipients,
type: "reimbursement.rejected",
title: `【已驳回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已被驳回:${comment}`,
targetType: "reimbursement_form",
targetId: workflowInstance.entity_id,
});
emailTasks.push({
type: "reimbursement.rejected",
recipientEmployeeIds: recipients,
subject: `【已驳回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已被驳回:${comment}`,
targetPath: `/reimbursements/${workflowInstance.entity_id}`,
});
} catch (error) {
fastify.log.error(
{ err: error, taskId, reimbursementId: workflowInstance.entity_id },
"Failed to create reimbursement rejected notifications",
);
emailTasks.push({
type: "reimbursement.rejected",
recipientEmployeeIds: recipients,
subject: `【已驳回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已被驳回:${comment}`,
targetPath: `/reimbursements/${workflowInstance.entity_id}`,
});
} catch (error) {
fastify.log.error(
{ err: error, taskId, reimbursementId: workflowInstance.entity_id },
"Failed to create reimbursement rejected notifications",
);
}
}
result = {
taskId,
outcome: "rejected",
workflowInstanceId: workflowInstance.id,
reimbursementStatus: "rejected",
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "rejected" : null,
};
});
@@ -1099,13 +1131,23 @@ async function workflowRoutes(fastify) {
},
});
await tx.reimbursementForm.update({
where: { id: workflowInstance.entity_id },
data: {
status: "draft",
updated_by: request.employee.id,
},
});
if (workflowInstance.entity_type === "reimbursement") {
await tx.reimbursementForm.update({
where: { id: workflowInstance.entity_id },
data: {
status: "draft",
updated_by: request.employee.id,
},
});
} else if (workflowInstance.entity_type === "leave") {
await tx.leaveRequest.update({
where: { id: workflowInstance.entity_id },
data: {
status: "draft",
updated_by: request.employee.id,
},
});
}
await tx.workflowHistory.create({
data: {
@@ -1124,40 +1166,42 @@ async function workflowRoutes(fastify) {
},
});
try {
const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id);
const recipients = Array.from(
new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)),
);
if (workflowInstance.entity_type === "reimbursement") {
try {
const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id);
const recipients = Array.from(
new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)),
);
await createNotifications(tx, {
recipientEmployeeIds: recipients,
type: "reimbursement.returned",
title: `【已退回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已退回:${comment}`,
targetType: "reimbursement_form",
targetId: workflowInstance.entity_id,
});
await createNotifications(tx, {
recipientEmployeeIds: recipients,
type: "reimbursement.returned",
title: `【已退回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已退回:${comment}`,
targetType: "reimbursement_form",
targetId: workflowInstance.entity_id,
});
emailTasks.push({
type: "reimbursement.returned",
recipientEmployeeIds: recipients,
subject: `【已退回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已退回:${comment}`,
targetPath: `/reimbursements/${workflowInstance.entity_id}`,
});
} catch (error) {
fastify.log.error(
{ err: error, taskId, reimbursementId: workflowInstance.entity_id },
"Failed to create reimbursement returned notifications",
);
emailTasks.push({
type: "reimbursement.returned",
recipientEmployeeIds: recipients,
subject: `【已退回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已退回:${comment}`,
targetPath: `/reimbursements/${workflowInstance.entity_id}`,
});
} catch (error) {
fastify.log.error(
{ err: error, taskId, reimbursementId: workflowInstance.entity_id },
"Failed to create reimbursement returned notifications",
);
}
}
result = {
taskId,
outcome: "returned",
workflowInstanceId: workflowInstance.id,
reimbursementStatus: "draft",
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "draft" : null,
};
});
@@ -1192,6 +1236,14 @@ async function workflowRoutes(fastify) {
const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request);
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
if (workflowInstance.entity_type !== "reimbursement") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { transfer: "unsupported_entity_type" },
});
}
const transferCount = await ensureTransferChainNotExceeded(tx, taskId);
await ensureTransferTargetEligible(tx, toApproverId);