收敛报销详情并展示五阶段进度(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),
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户