收敛报销详情并展示五阶段进度(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -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),
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户