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 状态推进
这个提交包含在:
+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);
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户