收敛报销详情并展示五阶段进度(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),
});
@@ -99,6 +99,8 @@ async function buildApp({
id: PENDING_INSTANCE_ID,
current_step_id: "finance_approve",
created_at: new Date("2026-04-10T10:00:00.000Z"),
started_at: new Date("2026-04-10T10:00:00.000Z"),
current_status: "pending",
context_data: {
workflow_steps_snapshot: [
{ id: "manager_approve", name: "主管审批" },
@@ -113,6 +115,34 @@ async function buildApp({
],
},
},
workflow_tasks: [
{
id: "manager-task",
step_id: "manager_approve",
step_name: "主管审批",
assignee_role: "manager",
status: "completed",
outcome: "approved",
comment: null,
created_at: new Date("2026-04-10T10:00:00.000Z"),
completed_at: new Date("2026-04-10T12:00:00.000Z"),
assignee_employee: { name: "王经理" },
completer_employee: { name: "王经理" },
},
{
id: "finance-task",
step_id: "finance_approve",
step_name: "财务审批",
assignee_role: "finance",
status: "pending",
outcome: null,
comment: null,
created_at: new Date("2026-04-10T12:00:00.000Z"),
completed_at: null,
assignee_employee: { name: "财务专员" },
completer_employee: null,
},
],
},
workflowHistoryRows = [
{
@@ -143,6 +173,8 @@ async function buildApp({
workflow_task: null,
},
],
paymentBatchItem = null,
paymentActors = [],
} = {}) {
const app = Fastify();
const prisma = {
@@ -163,6 +195,12 @@ async function buildApp({
entityRiskFlag: {
findMany: vi.fn(async () => []),
},
paymentBatchItem: {
findFirst: vi.fn(async () => paymentBatchItem),
},
employee: {
findMany: vi.fn(async () => paymentActors),
},
budgetPool: {
findMany: vi.fn(async () => []),
},
@@ -240,10 +278,10 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
submittedAt: "2026-04-10T10:00:00.000Z",
travelSummary: {
isTravel: true,
purpose: "客户拜访报销",
purpose: null,
location: null,
startDate: "2026-04-09",
endDate: "2026-04-09",
startDate: null,
endDate: null,
},
invoices: [
{
@@ -280,6 +318,13 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
currentStepIndex: 1,
totalSteps: 2,
});
expect(response.json().data.processingProgress.stages).toEqual([
expect.objectContaining({ key: "submitted", state: "completed", actorName: "李四", result: "已提交" }),
expect.objectContaining({ key: "manager_approval", state: "completed", actorName: "王经理", result: "通过" }),
expect.objectContaining({ key: "finance_review", state: "current", actorName: "财务专员", result: "处理中" }),
expect.objectContaining({ key: "payment_batch_review", state: "pending", actorName: null, detail: "待财务创建付款批次" }),
expect.objectContaining({ key: "payment_and_receipt", state: "pending", actorName: null }),
]);
await app.close();
});
@@ -304,6 +349,187 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
expect(response.statusCode).toBe(200);
expect(response.json().data.submittedAt).toBe(null);
expect(response.json().data.approvalHistory).toEqual([]);
expect(response.json().data.processingProgress.stages).toEqual([
expect.objectContaining({ key: "submitted", state: "current", actorName: "张三", detail: "待申请人提交" }),
expect.objectContaining({ key: "manager_approval", state: "pending", actorName: null }),
expect.objectContaining({ key: "finance_review", state: "pending", actorName: null }),
expect.objectContaining({ key: "payment_batch_review", state: "pending", actorName: null }),
expect.objectContaining({ key: "payment_and_receipt", state: "pending", actorName: null }),
]);
await app.close();
});
it.each([
["draft", "current", "pending", "待所在付款批次复核通过"],
["reviewed", "completed", "current", "所在付款批次已复核,待执行付款"],
["executed", "completed", "completed", "付款与回单已留存"],
["failed", "completed", "exception", "所在付款批次执行失败,待财务重新处理"],
["cancelled", "exception", "pending", "待财务重新创建付款批次"],
])("maps %s payment batch without exposing a batch link to ordinary applicants", async (status, reviewState, paymentState, detail) => {
const paymentBatchItem = {
id: "batch-item-1",
payment_batch: {
id: "batch-1",
status,
created_by: "maker-1",
reviewed_at: status === "draft" || status === "cancelled" ? null : new Date("2026-04-11T08:00:00.000Z"),
reviewed_by: status === "draft" || status === "cancelled" ? null : "reviewer-1",
executed_at: ["executed", "failed"].includes(status) ? new Date("2026-04-12T08:00:00.000Z") : null,
executed_by: ["executed", "failed"].includes(status) ? "executor-1" : null,
cancelled_at: status === "cancelled" ? new Date("2026-04-11T09:00:00.000Z") : null,
cancelled_by: status === "cancelled" ? "reviewer-1" : null,
cancelled_reason: status === "cancelled" ? "批次资料需调整" : null,
_count: { receipt_attachments: status === "executed" ? 1 : 0 },
},
};
const { app } = await buildApp({
paymentBatchItem,
paymentActors: [
{ id: "reviewer-1", name: "实际复核人" },
{ id: "executor-1", name: "实际付款人" },
],
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
const stages = response.json().data.processingProgress.stages;
expect(stages[3]).toMatchObject({ state: reviewState, targetHref: null });
expect(stages[4]).toMatchObject({ state: paymentState, detail, targetHref: null });
if (status === "draft") expect(stages[3].actorName).toBe(null);
await app.close();
});
it("returns only a safe payment-batch link to users with existing batch-read permission", async () => {
const { app } = await buildApp({
permissionCodes: ["expense.view_own", "payment.batch.review"],
paymentBatchItem: {
id: "batch-item-1",
payment_batch: {
id: "batch-1",
status: "draft",
created_by: "maker-1",
reviewed_at: null,
reviewed_by: null,
executed_at: null,
executed_by: null,
cancelled_at: null,
cancelled_by: null,
cancelled_reason: null,
_count: { receipt_attachments: 0 },
},
},
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.processingProgress.stages[3]).toMatchObject({
detail: "所在付款批次待非制单人复核",
actorName: null,
targetHref: "/payment-batches?id=batch-1",
});
await app.close();
});
it("marks finance batch creation as the current responsibility after workflow approval", async () => {
const { app } = await buildApp({
reimbursement: buildReimbursementRecord({ status: "approved", workflow_instances: [] }),
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.processingProgress.stages[3]).toMatchObject({
state: "current",
actorName: null,
result: "待建批次",
detail: "待财务创建付款批次",
});
await app.close();
});
it("keeps a legacy paid reimbursement readable when no payment batch exists", async () => {
const { app } = await buildApp({
reimbursement: buildReimbursementRecord({
status: "paid",
paid_at: new Date("2026-04-12T08:00:00.000Z"),
paid_by_employee: { id: "finance-1", employee_no: "F001", name: "实际付款人" },
workflow_instances: [],
}),
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.processingProgress.stages[4]).toMatchObject({
state: "completed",
actorName: "实际付款人",
occurredAt: "2026-04-12T08:00:00.000Z",
result: "已付款",
detail: "付款已完成,回单待留存",
});
await app.close();
});
it.each([
["returned", "退回", "请补充材料"],
["rejected", "驳回", "不符合报销要求"],
])("expands a %s workflow outcome with the actual handler and comment", async (outcome, result, comment) => {
const status = outcome === "rejected" ? "rejected" : "completed";
const { app } = await buildApp({
latestWorkflowInstance: {
id: PENDING_INSTANCE_ID,
current_step_id: "manager_approve",
current_status: outcome === "rejected" ? "rejected" : "terminated",
created_at: new Date("2026-04-10T10:00:00.000Z"),
started_at: new Date("2026-04-10T10:00:00.000Z"),
completed_at: new Date("2026-04-10T11:00:00.000Z"),
context_data: { workflow_steps_snapshot: [{ id: "manager_approve", name: "主管审批" }] },
workflow_definition: { definition_json: { steps: [{ id: "manager_approve", name: "主管审批" }] } },
workflow_tasks: [{
id: "manager-task",
step_id: "manager_approve",
step_name: "主管审批",
assignee_role: "manager",
status,
outcome,
comment,
created_at: new Date("2026-04-10T10:00:00.000Z"),
completed_at: new Date("2026-04-10T11:00:00.000Z"),
assignee_employee: { name: "王经理" },
completer_employee: { name: "王经理" },
}],
},
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.processingProgress.stages[1]).toMatchObject({
state: "exception",
actorName: "王经理",
result,
comment,
});
await app.close();
});