收敛报销详情并展示五阶段进度(07-27-yang-jun-reimbursement-readiness)

这个提交包含在:
2026-08-01 12:37:11 +08:00
父节点 4cf73ce0d2
当前提交 421c2d6e1e
共修改 11 个文件,包含 840 行新增和 352 行删除
+262 -7
查看文件
@@ -329,6 +329,196 @@ function buildApprovalHistory(workflowInstance, historyItems = []) {
}));
}
const PROCESSING_STAGE_DEFINITIONS = Object.freeze([
{ key: "submitted", label: "申请提交" },
{ key: "manager_approval", label: "主管审批" },
{ key: "finance_review", label: "财务复核" },
{ key: "payment_batch_review", label: "付款批次复核" },
{ key: "payment_and_receipt", label: "付款与回单" },
]);
function isoDateTimeOrNull(value) {
return value?.toISOString?.() ?? null;
}
function classifyApprovalTask(task, index) {
const role = String(task?.assignee_role ?? "").toLowerCase();
const text = `${task?.step_id ?? ""} ${task?.step_name ?? ""}`.toLowerCase();
if (["finance", "finance_mgr"].includes(role) || /财务|finance/.test(text)) {
return "finance_review";
}
if (["manager", "ceo"].includes(role) || /主管|经理|manager|ceo/.test(text)) {
return "manager_approval";
}
return index === 0 ? "manager_approval" : index === 1 ? "finance_review" : null;
}
function buildApprovalProcessingStage(task, fallback) {
if (!task) {
return fallback;
}
const actorName = task.completer_employee?.name ?? task.assignee_employee?.name ?? null;
if (task.outcome === "rejected" || task.status === "rejected") {
return {
...fallback,
state: "exception",
actorName,
occurredAt: isoDateTimeOrNull(task.completed_at),
result: "驳回",
comment: task.comment ?? null,
detail: "申请已驳回,待申请人处理",
};
}
if (task.outcome === "returned") {
return {
...fallback,
state: "exception",
actorName,
occurredAt: isoDateTimeOrNull(task.completed_at),
result: "退回",
comment: task.comment ?? null,
detail: "申请已退回,待申请人补充后重新提交",
};
}
if (task.status === "completed" || task.outcome === "approved") {
return {
...fallback,
state: "completed",
actorName,
occurredAt: isoDateTimeOrNull(task.completed_at),
result: "通过",
comment: task.comment ?? null,
detail: null,
};
}
if (task.status === "pending") {
return {
...fallback,
state: "current",
actorName: task.assignee_employee?.name ?? null,
occurredAt: isoDateTimeOrNull(task.created_at),
result: "处理中",
comment: null,
detail: `待${fallback.label}处理`,
};
}
return fallback;
}
function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatchItem, paymentActorNames, canReadPaymentBatch }) {
const stageByKey = new Map(PROCESSING_STAGE_DEFINITIONS.map((stage) => [stage.key, {
...stage,
state: "pending",
actorName: null,
occurredAt: null,
result: null,
comment: null,
detail: "待处理",
targetHref: null,
}]));
const submittedStage = stageByKey.get("submitted");
if (workflowInstance) {
Object.assign(submittedStage, {
state: "completed",
actorName: reimbursement.submitter?.name ?? reimbursement.owner?.name ?? null,
occurredAt: isoDateTimeOrNull(workflowInstance.started_at ?? workflowInstance.created_at),
result: "已提交",
detail: null,
});
} else if (["draft", "rejected"].includes(reimbursement.status)) {
Object.assign(submittedStage, {
state: "current",
actorName: reimbursement.owner?.name ?? null,
result: "待提交",
detail: "待申请人提交",
});
}
for (const [index, task] of (workflowInstance?.workflow_tasks ?? []).entries()) {
const stageKey = classifyApprovalTask(task, index);
if (!stageKey || !stageByKey.has(stageKey)) continue;
stageByKey.set(stageKey, buildApprovalProcessingStage(task, stageByKey.get(stageKey)));
}
const batch = paymentBatchItem?.payment_batch ?? null;
const batchTargetHref = batch && canReadPaymentBatch
? `/payment-batches?id=${encodeURIComponent(batch.id)}`
: null;
const batchReviewStage = stageByKey.get("payment_batch_review");
const paymentStage = stageByKey.get("payment_and_receipt");
if (!batch) {
Object.assign(batchReviewStage, reimbursement.status === "approved"
? { state: "current", result: "待建批次", detail: "待财务创建付款批次" }
: { detail: "待财务创建付款批次" });
paymentStage.detail = "待付款批次复核后处理";
} else if (batch.status === "draft") {
Object.assign(batchReviewStage, {
state: "current",
result: "待复核",
detail: "所在付款批次待非制单人复核",
targetHref: batchTargetHref,
});
Object.assign(paymentStage, { detail: "待所在付款批次复核通过", targetHref: batchTargetHref });
} else if (["reviewed", "executed", "failed"].includes(batch.status)) {
Object.assign(batchReviewStage, {
state: "completed",
actorName: paymentActorNames.get(batch.reviewed_by) ?? null,
occurredAt: isoDateTimeOrNull(batch.reviewed_at),
result: "通过",
detail: batch.status === "reviewed" ? "所在付款批次已复核,待执行付款" : null,
targetHref: batchTargetHref,
});
if (batch.status === "reviewed") {
Object.assign(paymentStage, {
state: "current",
result: "待付款",
detail: "所在付款批次已复核,待执行付款",
targetHref: batchTargetHref,
});
} else if (batch.status === "failed") {
Object.assign(paymentStage, {
state: "exception",
actorName: paymentActorNames.get(batch.executed_by) ?? null,
occurredAt: isoDateTimeOrNull(batch.executed_at),
result: "付款失败",
detail: "所在付款批次执行失败,待财务重新处理",
targetHref: batchTargetHref,
});
}
} else if (batch.status === "cancelled") {
Object.assign(batchReviewStage, {
state: "exception",
actorName: paymentActorNames.get(batch.cancelled_by) ?? null,
occurredAt: isoDateTimeOrNull(batch.cancelled_at),
result: "批次已取消",
comment: batch.cancelled_reason ?? null,
detail: "所在付款批次已取消,待财务重新创建",
targetHref: batchTargetHref,
});
Object.assign(paymentStage, {
detail: "待财务重新创建付款批次",
targetHref: batchTargetHref,
});
}
if (batch?.status === "executed" || reimbursement.status === "paid") {
const receiptCount = batch?._count?.receipt_attachments ?? 0;
Object.assign(paymentStage, {
state: "completed",
actorName: reimbursement.paid_by_employee?.name ?? paymentActorNames.get(batch?.executed_by) ?? null,
occurredAt: isoDateTimeOrNull(reimbursement.paid_at ?? batch?.executed_at),
result: "已付款",
detail: receiptCount > 0 ? "付款与回单已留存" : "付款已完成,回单待留存",
targetHref: batchTargetHref,
});
}
return { stages: PROCESSING_STAGE_DEFINITIONS.map(({ key }) => stageByKey.get(key)) };
}
function ensureApplicantOrAdmin(request, reimbursement) {
if (request.employee?.roleCodes?.includes("admin")) {
return;
@@ -2255,6 +2445,9 @@ async function reimbursementRoutes(fastify) {
select: {
id: true,
current_step_id: true,
current_status: true,
started_at: true,
completed_at: true,
created_at: true,
context_data: true,
workflow_definition: {
@@ -2262,6 +2455,23 @@ async function reimbursementRoutes(fastify) {
definition_json: true,
},
},
workflow_tasks: {
orderBy: [{ created_at: "asc" }, { id: "asc" }],
select: {
id: true,
step_id: true,
step_name: true,
assignee_id: true,
assignee_role: true,
status: true,
outcome: true,
comment: true,
created_at: true,
completed_at: true,
assignee_employee: { select: { name: true } },
completer_employee: { select: { name: true } },
},
},
},
});
const workflowHistory = pendingInstance
@@ -2297,6 +2507,48 @@ async function reimbursementRoutes(fastify) {
},
})
: [];
const paymentBatchItem = await fastify.prisma.paymentBatchItem.findFirst({
where: {
entity_id: reimbursementId,
payment_batch: { entity_type: "reimbursement" },
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
select: {
id: true,
payment_batch: {
select: {
id: true,
status: true,
created_by: true,
reviewed_at: true,
reviewed_by: true,
executed_at: true,
executed_by: true,
cancelled_at: true,
cancelled_by: true,
cancelled_reason: true,
_count: {
select: { receipt_attachments: { where: { archived_at: null } } },
},
},
},
},
});
const paymentActorIds = Array.from(new Set([
paymentBatchItem?.payment_batch?.created_by,
paymentBatchItem?.payment_batch?.reviewed_by,
paymentBatchItem?.payment_batch?.executed_by,
paymentBatchItem?.payment_batch?.cancelled_by,
].filter(Boolean)));
const paymentActors = paymentActorIds.length
? await fastify.prisma.employee.findMany({
where: { id: { in: paymentActorIds } },
select: { id: true, name: true },
})
: [];
const paymentActorNames = new Map(paymentActors.map((actor) => [actor.id, actor.name]));
const canReadPaymentBatch = ["payment.batch.manage", "risk.review.manage", "payment.batch.review"]
.some((permissionCode) => fastify.hasPermission(request.employee, permissionCode));
const riskFlags = await fastify.prisma.entityRiskFlag.findMany({
where: {
entity_type: "reimbursement",
@@ -2306,10 +2558,6 @@ async function reimbursementRoutes(fastify) {
orderBy: [{ created_at: "desc" }, { id: "desc" }],
});
const travelItems = reimbursement.items.filter((item) => item.expense_type?.category === "travel");
const travelItemDates = travelItems
.map((item) => item.expense_date)
.filter(Boolean)
.sort((left, right) => left.getTime() - right.getTime());
const isTravelReimbursement = Boolean(reimbursement.linked_travel_request_id || travelItems.length);
return sendData(reply, request, {
@@ -2349,10 +2597,10 @@ async function reimbursementRoutes(fastify) {
linkedTravelRequest: mapLinkedTravelRequestSummary(reimbursement.travel_request),
travelSummary: {
isTravel: isTravelReimbursement,
purpose: reimbursement.travel_purpose ?? (isTravelReimbursement ? reimbursement.description : null),
purpose: reimbursement.travel_purpose ?? null,
location: reimbursement.travel_location ?? null,
startDate: toDateOnlyString(reimbursement.travel_start_date ?? travelItemDates[0] ?? null),
endDate: toDateOnlyString(reimbursement.travel_end_date ?? travelItemDates.at(-1) ?? null),
startDate: toDateOnlyString(reimbursement.travel_start_date),
endDate: toDateOnlyString(reimbursement.travel_end_date),
},
owner: reimbursement.owner,
submitter: reimbursement.submitter,
@@ -2397,6 +2645,13 @@ async function reimbursementRoutes(fastify) {
}
: null,
workflowProgress: pendingInstance ? buildWorkflowProgress(pendingInstance, workflowHistory) : null,
processingProgress: buildProcessingProgress({
reimbursement,
workflowInstance: latestWorkflowInstance,
paymentBatchItem,
paymentActorNames,
canReadPaymentBatch,
}),
approvalHistory: buildApprovalHistory(latestWorkflowInstance, approvalHistoryRows),
riskFlags: riskFlags.map(buildPersistedRiskFlag),
});