const Fastify = require("fastify"); const { AppError } = require("../../../src/utils/AppError"); const travelRequestsRoutes = require("../../../src/routes/travelRequests"); function buildTravelRequestRecord() { const now = new Date("2026-03-10T00:00:00.000Z"); return { id: "11111111-1111-4111-8111-111111111111", request_no: "CL-20260310-0001", applicant_employee_id: "employee-1", submitter_id: null, department_id: "dept-1", legal_entity_id: "legal-1", cost_center_id: "cc-1", budget_pool_id: "pool-1", purpose: "上海客户拜访", trip_type: "domestic", start_date: new Date("2026-03-15T00:00:00.000Z"), end_date: new Date("2026-03-17T00:00:00.000Z"), estimated_amount: "1280.00", status: "approved", created_at: now, updated_at: now, applicant: { id: "employee-1", employee_no: "E001", name: "张三" }, submitter: null, department: { id: "dept-1", name: "销售部" }, legal_entity: { id: "legal-1", code: "HY", name: "海音达" }, cost_center: { id: "cc-1", code: "CC001", name: "销售中心", legal_entity_id: "legal-1" }, budget_pool: { id: "pool-1", code: "POOL-1", name: "差旅池" }, segments: [ { id: "seg-1", sort_order: 1, segment_role: "outbound", transport_type: "flight", from_city: "深圳", to_city: "上海", depart_at: new Date("2026-03-15T01:00:00.000Z"), arrive_at: new Date("2026-03-15T03:30:00.000Z"), created_at: now, updated_at: now, }, ], travel_orders: [ { id: "22222222-2222-4222-8222-222222222222", source: "ctrip", external_order_no: "EXT-001", employee_id: "employee-1", department_id: "dept-1", linked_reimbursement_id: null, travel_request_id: "11111111-1111-4111-8111-111111111111", occurred_at: new Date("2026-03-12T00:00:00.000Z"), amount: "500.00", currency: "CNY", status: "confirmed", payment_voucher_no: null, payment_date: null, payment_channel: null, paid_at: null, paid_by: null, created_at: now, updated_at: now, }, ], card_authorizations: [ { id: "33333333-3333-4333-8333-333333333333", source: "corp-card", external_auth_no: "AUTH-001", employee_id: "employee-1", department_id: "dept-1", travel_request_id: "11111111-1111-4111-8111-111111111111", budget_pool_id: "pool-1", legal_entity_id: "legal-1", cost_center_id: "cc-1", authorized_amount: "500.00", settled_amount: "0.00", reversed_amount: "0.00", currency: "CNY", occurred_at: now, expires_at: null, status: "authorized", created_at: now, updated_at: now, }, ], card_transactions: [ { id: "44444444-4444-4444-8444-444444444444", source: "corp-card", external_txn_id: "TXN-001", card_authorization_id: "33333333-3333-4333-8333-333333333333", employee_id: "employee-1", department_id: "dept-1", travel_request_id: "11111111-1111-4111-8111-111111111111", travel_order_id: "22222222-2222-4222-8222-222222222222", amount: "500.00", currency: "CNY", txn_type: "charge", status: "settled", occurred_at: now, freeze_suggested_at: null, freeze_suggested_by: null, freeze_reason_code: null, freeze_reason_note: null, created_at: now, updated_at: now, }, ], workflow_instances: [], }; } function buildPrisma({ detail = buildTravelRequestRecord() } = {}) { return { travelRequest: { findMany: vi.fn().mockResolvedValue([detail]), count: vi.fn().mockResolvedValue(1), findUnique: vi.fn().mockResolvedValue(detail), }, travelOrder: { findUnique: vi.fn().mockResolvedValue({ ...detail.travel_orders[0], travel_request: { id: detail.id, request_no: detail.request_no, department_id: detail.department_id, applicant_employee_id: detail.applicant_employee_id, submitter_id: detail.submitter_id, legal_entity: { code: detail.legal_entity.code }, cost_center: { code: detail.cost_center.code }, budget_pool: { code: detail.budget_pool.code }, }, }), }, invoicePool: { findMany: vi.fn().mockResolvedValue([ { id: "invoice-1", status: "bound", original_name: "flight.pdf", total_amount: "500.00", invoice_code: "INV001", invoice_number: "NO001", invoice_date: new Date("2026-03-16T00:00:00.000Z"), linked_reimbursement_id: null, travel_order_id: "22222222-2222-4222-8222-222222222222", }, ]), }, budgetLedgerEntry: { findMany: vi.fn().mockImplementation(async ({ where }) => { if (where?.entity_type === "travel_request") { return [ { id: "ledger-r1", budget_pool_id: "pool-1", kind: "reserve", amount: "500.00", created_at: new Date("2026-03-10T00:00:00.000Z"), }, { id: "ledger-r2", budget_pool_id: "pool-1", kind: "confirm", amount: "500.00", created_at: new Date("2026-03-10T01:00:00.000Z"), }, ]; } return [ { id: "ledger-c1", budget_pool_id: "pool-1", entity_type: "card_authorization", entity_id: "33333333-3333-4333-8333-333333333333", kind: "authorize", amount: "500.00", created_at: new Date("2026-03-10T00:00:00.000Z"), }, { id: "ledger-c2", budget_pool_id: "pool-1", entity_type: "card_transaction", entity_id: "44444444-4444-4444-8444-444444444444", kind: "settle", amount: "500.00", created_at: new Date("2026-03-10T02:00:00.000Z"), }, ]; }), }, budgetPool: { findMany: vi.fn().mockResolvedValue([{ id: "pool-1", code: "POOL-1", name: "差旅池" }]), }, integrationBinding: { findUnique: vi.fn().mockResolvedValue(null), }, integrationOutbox: { findFirst: vi.fn().mockResolvedValue(null), }, }; } async function buildApp({ employee, prisma }) { const app = Fastify(); app.decorate("prisma", prisma); app.decorate("config", { appTimezone: "Asia/Shanghai" }); app.decorate("authenticate", async (request) => { if (!request.headers.authorization) { throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); } request.employee = employee; }); app.decorate("hasPermission", (actor, permissionCode) => { return (actor?.permissionCodes ?? []).includes("*") || (actor?.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.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(travelRequestsRoutes, { prefix: "/api" }); return app; } describe("travelRequests payment-track route", () => { it("allows owner to view payment track in read-only mode", async () => { const detail = buildTravelRequestRecord(); const app = await buildApp({ employee: { id: "employee-1", department_id: "dept-1", permissionCodes: ["travel.view_own"] }, prisma: buildPrisma({ detail }), }); const response = await app.inject({ method: "GET", url: "/api/travel-requests/11111111-1111-4111-8111-111111111111/payment-track", headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(200); expect(response.json().data.permissions.canManagePayments).toBe(false); expect(response.json().data.travelOrders[0].actions.canMarkPaid).toBe(false); await app.close(); }); it("allows finance manager to view payment track with manage actions", async () => { const detail = buildTravelRequestRecord(); const app = await buildApp({ employee: { id: "finance-1", department_id: "dept-9", permissionCodes: ["travel.view_all", "travel.payment.manage"] }, prisma: buildPrisma({ detail }), }); const response = await app.inject({ method: "GET", url: "/api/travel-requests/11111111-1111-4111-8111-111111111111/payment-track", headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(200); expect(response.json().data.permissions.canManagePayments).toBe(true); expect(response.json().data.travelOrders[0].actions).toEqual( expect.objectContaining({ canMarkPaid: true, canBindInvoice: true }), ); await app.close(); }); it("blocks outsider without view_all permission", async () => { const detail = buildTravelRequestRecord(); const app = await buildApp({ employee: { id: "employee-9", department_id: "dept-9", permissionCodes: ["travel.view_own"] }, prisma: buildPrisma({ detail }), }); const response = await app.inject({ method: "GET", url: "/api/travel-requests/11111111-1111-4111-8111-111111111111/payment-track", headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(403); expect(response.json().error.code).toBe("PERM_001"); await app.close(); }); });