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