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, requires_invoice: 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", ocr_attempts: 1, ocr_last_requested_at: new Date("2026-04-09T01:00:00.000Z"), ocr_last_error: null, ocr_completed_at: new Date("2026-04-09T01:00:01.000Z"), }, ], 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"], roleCodes = [], reviewerTask = null, 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 = [], itemRecord = { id: "77777777-7777-4777-8777-777777777777", expense_type_id: "88888888-8888-4888-8888-888888888888", asset_prior_approval_basis: null, }, expenseType = { id: "88888888-8888-4888-8888-888888888888", enabled: true, category: "assets", }, } = {}) { const app = Fastify(); const tx = { $queryRaw: vi.fn(async () => [reimbursement]), reimbursementForm: { update: vi.fn(async () => ({ id: REIMBURSEMENT_ID })), delete: vi.fn(), }, expenseType: { findUnique: vi.fn(async () => expenseType), }, invoicePool: { updateMany: vi.fn(), }, reimbursementItem: { findFirst: vi.fn(async () => itemRecord), aggregate: vi.fn(async () => ({ _sum: { amount: "10.00" } })), create: vi.fn(async ({ data }) => ({ id: itemRecord.id, expense_type_id: data.expense_type_id, expense_date: data.expense_date, amount: data.amount, description: data.description ?? null, asset_prior_approval_basis: data.asset_prior_approval_basis, })), update: vi.fn(async ({ data }) => ({ id: itemRecord.id, expense_type_id: data.expense_type_id ?? itemRecord.expense_type_id, expense_date: data.expense_date ?? new Date("2026-04-10T00:00:00.000Z"), amount: data.amount ?? "10.00", description: data.description ?? "合成明细", asset_prior_approval_basis: data.asset_prior_approval_basis, })), deleteMany: vi.fn(), }, auditLog: { create: vi.fn(), }, }; const prisma = { $transaction: vi.fn(async (callback) => callback(tx)), reimbursementForm: { findUnique: vi.fn(async ({ where }) => { if (where.id !== REIMBURSEMENT_ID) { return null; } return reimbursement; }), update: vi.fn(async ({ data }) => ({ id: REIMBURSEMENT_ID, project_id: data.project_id ?? null, project: data.project_id ? { id: data.project_id, project_no: "P-001", name: "合成项目" } : null, })), }, featureFlag: { findUnique: vi.fn(async () => ({ flag_key: "project_linking_v1", override_mode: "inherit", rollout_type: "global", department_ids: [], version: 1, })), }, 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 () => []), }, delegationGrant: { findMany: vi.fn(async () => []), }, workflowTask: { findFirst: vi.fn(async () => reviewerTask), }, }; app.decorate("prisma", prisma); app.decorate("config", { budgetControlMode: "warn", appTimezone: "Asia/Shanghai", projectLinkingEnabled: true, }); 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, tx }; } describe("GET /api/reimbursements/:id detail enhancements", () => { it("allows an assigned workflow reviewer to read evidence without expense view permissions", async () => { const { app, prisma } = await buildApp({ employeeId: "99999999-9999-4999-8999-999999999999", permissionCodes: ["expense.approve"], roleCodes: ["finance"], reviewerTask: { id: "review-task" }, }); const response = await app.inject({ method: "GET", url: `/api/reimbursements/${REIMBURSEMENT_ID}`, headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(200); expect(prisma.workflowTask.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ workflow_instance: { entity_type: "reimbursement", entity_id: REIMBURSEMENT_ID }, }), select: { id: true }, })); await app.close(); }); it("keeps an unrelated employee outside the reimbursement evidence projection", async () => { const { app } = await buildApp({ employeeId: "99999999-9999-4999-8999-999999999999", permissionCodes: ["expense.approve"], reviewerTask: null, }); const response = await app.inject({ method: "GET", url: `/api/reimbursements/${REIMBURSEMENT_ID}`, headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(403); await app.close(); }); 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", amountEvidenceStatus: "verified", }, ], invoiceAmountManualReviewRequired: false, 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_created", label: "组建付款批次", state: "pending", actorName: null }), 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("keeps the manual amount-review marker visible to a finance detail reader", async () => { const reimbursement = buildReimbursementRecord({ invoice_pool_items: [{ id: "invoice-off", status: "bound", invoice_code: null, invoice_number: null, invoice_date: null, seller_name: null, total_amount: null, original_name: "manual-review.pdf", linked_item_id: "item-1", ocr_attempts: 0, ocr_last_requested_at: null, ocr_last_error: null, ocr_completed_at: null, }], }); const { app } = await buildApp({ employeeId: "finance-reader", permissionCodes: ["expense.view_all"], reimbursement, }); const response = await app.inject({ method: "GET", url: `/api/reimbursements/${REIMBURSEMENT_ID}`, headers: { authorization: "Bearer test" }, }); expect(response.statusCode, response.body).toBe(200); expect(response.json().data).toEqual(expect.objectContaining({ invoiceAmountManualReviewRequired: true, invoices: [expect.objectContaining({ id: "invoice-off", amountEvidenceStatus: "manual_review", })], })); 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_created", 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("projects legacy owner-self fallback task names as finance review", async () => { const { app } = await buildApp({ latestWorkflowInstance: { id: PENDING_INSTANCE_ID, current_step_id: "approval_1_finance_owner_self_fallback", current_status: "pending", created_at: new Date("2026-08-28T11:08:00.000Z"), started_at: new Date("2026-08-28T11:08:00.000Z"), context_data: { workflow_steps_snapshot: [{ id: "approval_1_finance_owner_self_fallback", name: "负责人本人申请兜底:finance", type: "approval", assignee_rule: { type: "role", value: "finance" }, }], }, workflow_definition: { definition_json: { steps: [] } }, workflow_tasks: [{ id: "owner-fallback-task", step_id: "approval_1_finance_owner_self_fallback", step_name: "负责人本人申请兜底:finance", assignee_role: "finance", status: "pending", outcome: null, comment: null, created_at: new Date("2026-08-28T11:08: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[1]).toEqual( expect.objectContaining({ key: "approval_owner-fallback-task", label: "财务复核", state: "current", actorName: "财务人员", detail: "待财务复核处理", }), ); 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({ key: "payment_batch_created", state: "completed", targetHref: null }); expect(stages[4]).toMatchObject({ state: reviewState, targetHref: null }); expect(stages[5]).toMatchObject({ state: paymentState, detail, targetHref: null }); if (status === "draft") expect(stages[4].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[4]).toMatchObject({ detail: "待非制单人复核付款", actorName: null, targetHref: "/payment-batches?id=batch-1", }); await app.close(); }); it("attaches the payment-batch link to exactly one status-owning stage", async () => { const cases = [ { status: "draft", reviewedAt: null, targetIndex: 4 }, { status: "reviewed", reviewedAt: new Date("2026-04-11T08:00:00.000Z"), targetIndex: 5 }, { status: "executed", reviewedAt: new Date("2026-04-11T08:00:00.000Z"), targetIndex: 5 }, { status: "failed", reviewedAt: new Date("2026-04-11T08:00:00.000Z"), targetIndex: 5 }, { status: "cancelled", reviewedAt: null, targetIndex: 4 }, { status: "cancelled", reviewedAt: new Date("2026-04-11T08:00:00.000Z"), targetIndex: 5 }, ]; for (const item of cases) { const { app } = await buildApp({ permissionCodes: ["expense.view_own", "payment.batch.review"], paymentBatchItem: { id: "batch-item-1", payment_batch: { id: "batch-1", status: item.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: item.reviewedAt, reviewed_by: item.reviewedAt ? "reviewer-1" : null, executed_at: ["executed", "failed"].includes(item.status) ? new Date("2026-04-12T08:00:00.000Z") : null, executed_by: ["executed", "failed"].includes(item.status) ? "executor-1" : null, cancelled_at: item.status === "cancelled" ? new Date("2026-04-11T09:00:00.000Z") : null, cancelled_by: item.status === "cancelled" ? "reviewer-1" : null, cancelled_reason: item.status === "cancelled" ? "批次资料需调整" : null, items: [{ status: item.status === "executed" ? "executed" : "pending", payment_channel_override: null }], _count: { receipt_attachments: item.status === "executed" ? 1 : 0 }, }, }, }); 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; const linkedStageIndexes = stages .map((stage, index) => stage.targetHref ? index : -1) .filter((index) => index >= 0); expect(linkedStageIndexes).toEqual([item.targetIndex]); expect(stages[item.targetIndex].targetHref).toBe("/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({ key: "payment_batch_created", 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[5]).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(); }); }); describe("POST /api/reimbursements/:id/link-project authorization", () => { const projectId = "66666666-6666-4666-8666-666666666666"; it("rejects a view-all nonparticipant without changing the project", async () => { const { app, prisma } = await buildApp({ employeeId: "finance-viewer", permissionCodes: ["expense.view_all", "expense.edit_own"], reimbursement: buildReimbursementRecord({ status: "draft" }), }); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/link-project`, headers: { authorization: "Bearer test" }, payload: { projectId }, }); expect(response.statusCode, response.body).toBe(403); expect(prisma.reimbursementForm.update).not.toHaveBeenCalled(); await app.close(); }); it.each(["approved", "paid"])("rejects participant project changes in %s status", async (status) => { const { app, prisma } = await buildApp({ employeeId: OWNER_ID, permissionCodes: ["expense.edit_own"], reimbursement: buildReimbursementRecord({ status }), }); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/link-project`, headers: { authorization: "Bearer test" }, payload: { projectId }, }); expect(response.statusCode, response.body).toBe(409); expect(prisma.reimbursementForm.update).not.toHaveBeenCalled(); await app.close(); }); it("allows a draft participant to change the project", async () => { const { app, prisma } = await buildApp({ employeeId: SUBMITTER_ID, permissionCodes: ["expense.edit_own"], reimbursement: buildReimbursementRecord({ status: "draft" }), }); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/link-project`, headers: { authorization: "Bearer test" }, payload: { projectId }, }); expect(response.statusCode, response.body).toBe(200); expect(prisma.reimbursementForm.update).toHaveBeenCalledTimes(1); await app.close(); }); }); describe("reimbursement maintenance authorization", () => { const itemId = "77777777-7777-4777-8777-777777777777"; const expenseTypeId = "88888888-8888-4888-8888-888888888888"; it.each([ ["PATCH", `/api/reimbursements/${REIMBURSEMENT_ID}`, { description: "合成修改" }, "expense.edit_own"], ["DELETE", `/api/reimbursements/${REIMBURSEMENT_ID}`, undefined, "expense.delete_own"], ["POST", `/api/reimbursements/${REIMBURSEMENT_ID}/items`, { expenseTypeId, expenseDate: "2026-04-10", amount: "10.00", description: "合成明细", }, "expense.edit_own"], ["PATCH", `/api/reimbursements/${REIMBURSEMENT_ID}/items/${itemId}`, { amount: "20.00" }, "expense.edit_own"], ["DELETE", `/api/reimbursements/${REIMBURSEMENT_ID}/items/${itemId}`, undefined, "expense.edit_own"], ])("rejects a view-all nonparticipant for %s %s before mutations", async (method, url, payload, permission) => { const { app, tx } = await buildApp({ employeeId: "finance-viewer", permissionCodes: [permission, "expense.view_all"], reimbursement: buildReimbursementRecord({ status: "draft" }), }); const response = await app.inject({ method, url, headers: { authorization: "Bearer test" }, ...(payload ? { payload } : {}), }); expect(response.statusCode, response.body).toBe(403); expect(tx.reimbursementForm.update).not.toHaveBeenCalled(); expect(tx.reimbursementForm.delete).not.toHaveBeenCalled(); expect(tx.invoicePool.updateMany).not.toHaveBeenCalled(); expect(tx.reimbursementItem.create).not.toHaveBeenCalled(); expect(tx.reimbursementItem.update).not.toHaveBeenCalled(); expect(tx.reimbursementItem.deleteMany).not.toHaveBeenCalled(); expect(tx.auditLog.create).not.toHaveBeenCalled(); await app.close(); }); }); describe("reimbursement asset item nullable approval-basis compatibility", () => { const itemId = "77777777-7777-4777-8777-777777777777"; const assetExpenseTypeId = "88888888-8888-4888-8888-888888888888"; const officeExpenseTypeId = "99999999-9999-4999-8999-999999999999"; it("creates an assets item with an omitted basis and persists null", async () => { const { app, tx } = await buildApp({ permissionCodes: ["expense.edit_own"], reimbursement: buildReimbursementRecord({ status: "draft" }), }); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/items`, headers: { authorization: "Bearer test" }, payload: { expenseTypeId: assetExpenseTypeId, expenseDate: "2026-04-10", amount: "10.00", description: "合成设备", }, }); expect(response.statusCode, response.body).toBe(200); expect(tx.reimbursementItem.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ asset_prior_approval_basis: null }), })); expect(response.json().data.item.assetPriorApprovalBasis).toBeNull(); await app.close(); }); it("edits an assets item whose stored basis is null while omitted and keeps null", async () => { const { app, tx } = await buildApp({ permissionCodes: ["expense.edit_own"], reimbursement: buildReimbursementRecord({ status: "draft" }), itemRecord: { id: itemId, expense_type_id: assetExpenseTypeId, asset_prior_approval_basis: null, }, }); const response = await app.inject({ method: "PATCH", url: `/api/reimbursements/${REIMBURSEMENT_ID}/items/${itemId}`, headers: { authorization: "Bearer test" }, payload: { amount: "20.00" }, }); expect(response.statusCode, response.body).toBe(200); expect(tx.reimbursementItem.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ asset_prior_approval_basis: null }), })); await app.close(); }); it("preserves an existing assets basis when the field is omitted", async () => { const { app, tx } = await buildApp({ permissionCodes: ["expense.edit_own"], reimbursement: buildReimbursementRecord({ status: "draft" }), itemRecord: { id: itemId, expense_type_id: assetExpenseTypeId, asset_prior_approval_basis: "历史依据", }, }); const response = await app.inject({ method: "PATCH", url: `/api/reimbursements/${REIMBURSEMENT_ID}/items/${itemId}`, headers: { authorization: "Bearer test" }, payload: { amount: "20.00" }, }); expect(response.statusCode, response.body).toBe(200); expect(tx.reimbursementItem.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ asset_prior_approval_basis: "历史依据" }), })); await app.close(); }); it.each([null, " "])("clears an assets basis when explicitly sent as %j", async (assetPriorApprovalBasis) => { const { app, tx } = await buildApp({ permissionCodes: ["expense.edit_own"], reimbursement: buildReimbursementRecord({ status: "draft" }), itemRecord: { id: itemId, expense_type_id: assetExpenseTypeId, asset_prior_approval_basis: "历史依据", }, }); const response = await app.inject({ method: "PATCH", url: `/api/reimbursements/${REIMBURSEMENT_ID}/items/${itemId}`, headers: { authorization: "Bearer test" }, payload: { assetPriorApprovalBasis }, }); expect(response.statusCode, response.body).toBe(200); expect(tx.reimbursementItem.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ asset_prior_approval_basis: null }), })); await app.close(); }); it("clears an existing basis when switching to a non-assets type", async () => { const { app, tx } = await buildApp({ permissionCodes: ["expense.edit_own"], reimbursement: buildReimbursementRecord({ status: "draft" }), itemRecord: { id: itemId, expense_type_id: assetExpenseTypeId, asset_prior_approval_basis: "历史依据", }, expenseType: { id: officeExpenseTypeId, enabled: true, category: "office" }, }); const response = await app.inject({ method: "PATCH", url: `/api/reimbursements/${REIMBURSEMENT_ID}/items/${itemId}`, headers: { authorization: "Bearer test" }, payload: { expenseTypeId: officeExpenseTypeId }, }); expect(response.statusCode, response.body).toBe(200); expect(tx.reimbursementItem.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ expense_type_id: officeExpenseTypeId, asset_prior_approval_basis: null, }), })); await app.close(); }); });