const Fastify = require("fastify"); const { AppError } = require("../../../src/utils/AppError"); const reimbursementRoutes = require("../../../src/routes/reimbursements"); const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111"; const OWNER_ID = "22222222-2222-4222-8222-222222222222"; const SUBMITTER_ID = "33333333-3333-4333-8333-333333333333"; const DEPARTMENT_ID = "44444444-4444-4444-8444-444444444444"; const PENDING_INSTANCE_ID = "55555555-5555-4555-8555-555555555555"; function buildReimbursementRecord(overrides = {}) { return { id: REIMBURSEMENT_ID, form_number: "BX20260410001", submitter_id: SUBMITTER_ID, owner_id: OWNER_ID, department_id: DEPARTMENT_ID, created_at: new Date("2026-04-10T09:00:00.000Z"), reimbursement_date: new Date("2026-04-10T00:00:00.000Z"), total_amount: "188.80", status: "reviewing", description: "客户拜访报销", project_id: null, linked_travel_request_id: null, department: { id: DEPARTMENT_ID, name: "华东销售部" }, project: null, travel_request: null, owner: { id: OWNER_ID, employee_no: "E1001", name: "张三" }, submitter: { id: SUBMITTER_ID, employee_no: "E1002", name: "李四" }, items: [ { id: "item-1", expense_type_id: "expense-type-1", expense_date: new Date("2026-04-09T00:00:00.000Z"), amount: "188.80", description: "客户打车", expense_type: { id: "expense-type-1", code: "travel_taxi", name: "打车费", category: "travel", enabled: true, }, }, ], attachments: [], invoice_pool_items: [ { id: "invoice-1", status: "bound", invoice_code: "FP001", invoice_number: "0001", invoice_date: new Date("2026-04-09T00:00:00.000Z"), seller_name: "上海供应商", total_amount: "188.80", original_name: "receipt.pdf", linked_item_id: "item-1", }, ], workflow_instances: [ { id: PENDING_INSTANCE_ID, current_step_id: "finance_approve", current_status: "pending", context_data: { workflow_steps_snapshot: [ { id: "manager_approve", name: "主管审批" }, { id: "finance_approve", name: "财务审批" }, ], }, workflow_definition: { definition_json: { steps: [ { id: "manager_approve", name: "主管审批" }, { id: "finance_approve", name: "财务审批" }, ], }, }, workflow_tasks: [ { id: "task-1", step_name: "财务审批", assignee_id: "finance-1", assignee_role: "finance", }, ], }, ], ...overrides, }; } async function buildApp({ employeeId = OWNER_ID, permissionCodes = ["expense.view_own"], reimbursement = buildReimbursementRecord(), latestWorkflowInstance = { 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: "主管审批" }, { id: "finance_approve", name: "财务审批" }, ], }, workflow_definition: { definition_json: { steps: [ { id: "manager_approve", name: "主管审批" }, { id: "finance_approve", name: "财务审批" }, ], }, }, 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 = [ { id: "history-start", action: "start", comment: null, payload: { step_id: "manager_approve" }, created_at: new Date("2026-04-10T10:00:00.000Z"), actor: { name: "李四" }, workflow_task: { step_name: "主管审批" }, }, { id: "history-approve", action: "approve", comment: "同意", payload: { from_step_id: "manager_approve", to_step_id: "finance_approve" }, created_at: new Date("2026-04-10T12:00:00.000Z"), actor: { name: "王经理" }, workflow_task: { step_name: "主管审批" }, }, { id: "history-skip", action: "skip", comment: null, payload: { step_id: "ceo_approve" }, created_at: new Date("2026-04-10T12:01:00.000Z"), actor: { name: "系统" }, workflow_task: null, }, ], paymentBatchItem = null, paymentActors = [], } = {}) { const app = Fastify(); const prisma = { reimbursementForm: { findUnique: vi.fn(async ({ where }) => { if (where.id !== REIMBURSEMENT_ID) { return null; } return reimbursement; }), }, workflowInstance: { findFirst: vi.fn(async () => latestWorkflowInstance), }, workflowHistory: { findMany: vi.fn(async () => workflowHistoryRows), }, entityRiskFlag: { findMany: vi.fn(async () => []), }, paymentBatchItem: { findFirst: vi.fn(async () => paymentBatchItem), }, employee: { findMany: vi.fn(async () => paymentActors), }, budgetPool: { findMany: vi.fn(async () => []), }, }; app.decorate("prisma", prisma); app.decorate("config", { budgetControlMode: "warn", appTimezone: "Asia/Shanghai", }); app.decorate("authenticate", async (request) => { if (!request.headers.authorization) { throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); } request.employee = { id: employeeId, permissionCodes, roleCodes: [], department_id: DEPARTMENT_ID, }; }); app.decorate( "hasPermission", (employee, permissionCode) => (employee?.permissionCodes ?? []).includes("*") || (employee?.permissionCodes ?? []).includes(permissionCode), ); app.decorate("requirePermission", (permissionCode) => async (request) => { await app.authenticate(request); if (!app.hasPermission(request.employee, permissionCode)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } }); app.decorate("assertEntityPermission", () => {}); app.setErrorHandler((error, request, reply) => { if (error instanceof AppError) { return reply.status(error.statusCode).send({ requestId: request.id, error: { code: error.code, message: error.message, details: error.details ?? {}, }, }); } return reply.status(500).send({ requestId: request.id, error: { code: "SYS_001", message: "服务器内部错误", details: {} }, }); }); await app.register(reimbursementRoutes, { prefix: "/api" }); return { app, prisma }; } describe("GET /api/reimbursements/:id detail enhancements", () => { it("returns department, submittedAt, expanded invoices and printable approvalHistory", async () => { const { app } = await buildApp(); 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).toMatchObject({ department: { id: DEPARTMENT_ID, name: "华东销售部", }, createdAt: "2026-04-10T09:00:00.000Z", submittedAt: "2026-04-10T10:00:00.000Z", travelSummary: { isTravel: true, purpose: null, location: null, startDate: null, endDate: null, }, invoices: [ { id: "invoice-1", invoiceCode: "FP001", invoiceNumber: "0001", invoiceDate: "2026-04-09", sellerName: "上海供应商", totalAmount: "188.80", originalName: "receipt.pdf", linkedItemId: "item-1", }, ], approvalHistory: [ { id: "history-start", stepName: "主管审批", action: "start", actorName: "李四", comment: null, createdAt: "2026-04-10T10:00:00.000Z", }, { id: "history-approve", stepName: "主管审批", action: "approve", actorName: "王经理", comment: "同意", createdAt: "2026-04-10T12:00:00.000Z", }, ], }); expect(response.json().data.workflowProgress).toMatchObject({ currentStepIndex: 1, totalSteps: 2, }); expect(response.json().data.processingProgress.stages).toEqual([ expect.objectContaining({ key: "submitted", state: "completed", actorName: "李四", result: "已提交" }), expect.objectContaining({ key: "approval_manager-task", label: "主管审批", state: "completed", actorName: "王经理", result: "通过" }), expect.objectContaining({ key: "approval_finance-task", label: "财务审批", 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(); }); it("returns null submittedAt and empty approvalHistory when reimbursement has never been submitted", async () => { const reimbursement = buildReimbursementRecord({ status: "draft", workflow_instances: [], }); const { app } = await buildApp({ reimbursement, latestWorkflowInstance: null, workflowHistoryRows: [], }); 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.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("keeps a third approval task and uses the role label when its configured name is only an ordinal", async () => { const { app } = await buildApp({ latestWorkflowInstance: { id: PENDING_INSTANCE_ID, current_step_id: "ceo_approve", current_status: "pending", created_at: new Date("2026-04-10T10:00:00.000Z"), started_at: new Date("2026-04-10T10:00:00.000Z"), context_data: { workflow_steps_snapshot: [ { id: "manager_approve", name: "主管审批" }, { id: "finance_approve", name: "财务复核" }, { id: "ceo_approve", name: "步骤3" }, ], }, workflow_definition: { definition_json: { steps: [ { id: "manager_approve", name: "主管审批" }, { id: "finance_approve", name: "财务复核" }, { id: "ceo_approve", name: "步骤3" }, ], }, }, 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-10T11:00:00.000Z"), assignee_employee: { name: "主管" }, completer_employee: { name: "主管" }, }, { id: "finance-task", step_id: "finance_approve", step_name: "财务复核", assignee_role: "finance", status: "completed", outcome: "approved", comment: null, created_at: new Date("2026-04-10T11:00:00.000Z"), completed_at: new Date("2026-04-10T12:00:00.000Z"), assignee_employee: { name: "财务" }, completer_employee: { name: "财务" }, }, { id: "ceo-task", step_id: "ceo_approve", step_name: "步骤3", assignee_role: "ceo", 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, }, ], }, }); 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.slice(1, 4)).toEqual([ expect.objectContaining({ key: "approval_manager-task", label: "主管审批", state: "completed" }), expect.objectContaining({ key: "approval_finance-task", label: "财务复核", state: "completed" }), expect.objectContaining({ key: "approval_ceo-task", label: "负责人复核", state: "current", actorName: "负责人" }), ]); await app.close(); }); it("appends future snapshot approvals that do not have workflow tasks yet", async () => { const { app } = await buildApp({ latestWorkflowInstance: { id: PENDING_INSTANCE_ID, current_step_id: "manager_approve", current_status: "pending", created_at: new Date("2026-04-10T10:00:00.000Z"), started_at: new Date("2026-04-10T10:00:00.000Z"), context_data: { workflow_steps_snapshot: [ { id: "manager_approve", name: "主管审批", type: "approval", assignee_rule: { type: "department_manager" } }, { id: "finance_approve", name: "财务复核", type: "approval", assignee_rule: { type: "role", value: "finance" } }, { id: "ceo_approve", name: "步骤3", type: "approval", assignee_rule: { type: "role", value: "ceo" } }, ], }, workflow_definition: { definition_json: { steps: [] } }, workflow_tasks: [{ id: "manager-task", step_id: "manager_approve", step_name: "主管审批", assignee_role: "manager", status: "pending", outcome: null, comment: null, created_at: new Date("2026-04-10T10:00:00.000Z"), completed_at: null, assignee_employee: { name: "主管" }, completer_employee: null, }], }, }); 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.slice(1, 4)).toEqual([ expect.objectContaining({ key: "approval_manager-task", label: "主管审批", state: "current", actorName: "主管" }), expect.objectContaining({ key: "approval_pending_2", label: "财务复核", state: "pending", actorName: null }), expect.objectContaining({ key: "approval_pending_3", label: "负责人复核", state: "pending", actorName: null }), ]); await app.close(); }); it("keeps multiple tasks with the same role as separate ordered approval stages", async () => { const { app } = await buildApp({ latestWorkflowInstance: { id: PENDING_INSTANCE_ID, current_step_id: "finance_final", current_status: "pending", created_at: new Date("2026-04-10T10:00:00.000Z"), started_at: new Date("2026-04-10T10:00:00.000Z"), context_data: { workflow_steps_snapshot: [ { id: "finance_first", name: "财务初审" }, { id: "finance_final", name: "财务终审" }, ], }, workflow_definition: { definition_json: { steps: [ { id: "finance_first", name: "财务初审" }, { id: "finance_final", name: "财务终审" }, ], }, }, workflow_tasks: [ { id: "finance-first-task", step_id: "finance_first", step_name: "finance_first", assignee_role: "finance", status: "completed", outcome: "approved", 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: "初审人" }, }, { id: "finance-final-task", step_id: "finance_final", step_name: "财务终审", assignee_role: "finance", status: "pending", outcome: null, comment: null, created_at: new Date("2026-04-10T11:00:00.000Z"), completed_at: null, assignee_employee: { name: "终审人" }, completer_employee: null, }, ], }, }); 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.slice(1, 3)).toEqual([ expect.objectContaining({ key: "approval_finance-first-task", label: "财务复核", actorName: "初审人", state: "completed" }), expect.objectContaining({ key: "approval_finance-final-task", label: "财务终审", actorName: "终审人", state: "current" }), ]); await app.close(); }); it("falls back to pending manager and finance placeholders when an instance has no tasks yet", async () => { const { app } = await buildApp({ latestWorkflowInstance: { id: PENDING_INSTANCE_ID, current_step_id: null, current_status: "pending", created_at: new Date("2026-04-10T10:00:00.000Z"), started_at: new Date("2026-04-10T10:00:00.000Z"), context_data: { workflow_steps_snapshot: [ { id: "manager_approve", name: "主管审批" }, { id: "finance_approve", name: "财务复核" }, ], }, workflow_definition: { definition_json: { steps: [ { id: "manager_approve", name: "主管审批" }, { id: "finance_approve", name: "财务复核" }, ], }, }, workflow_tasks: [], }, }); 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.slice(1, 3)).toEqual([ expect.objectContaining({ key: "approval_pending_1", label: "主管审批", state: "pending", actorName: null }), expect.objectContaining({ key: "approval_pending_2", label: "财务复核", state: "pending", actorName: null }), ]); await app.close(); }); it.each([ ["draft", "current", "pending", "待付款复核通过后由财务登记,非审批环节"], ["reviewed", "completed", "current", "等待财务线下付款并上传回单,非审批环节"], ["executed", "completed", "completed", "付款和回单已留存"], ["failed", "completed", "exception", "付款登记失败,待财务核对后重试,非审批环节"], ["cancelled", "exception", "terminated", null], ])("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, entity_type: "reimbursement", default_payment_channel: "bank", payment_order_export_count: 0, created_at: new Date("2026-04-11T07:00:00.000Z"), 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, items: [{ status: status === "executed" ? "executed" : "pending", payment_channel_override: 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", entity_type: "reimbursement", default_payment_channel: "bank", payment_order_export_count: 0, created_at: new Date("2026-04-11T07:00:00.000Z"), 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, items: [{ status: "pending", payment_channel_override: 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(); }); });