const Fastify = require("fastify"); const { AppError } = require("../../../src/utils/AppError"); const travelOrdersRoutes = require("../../../src/routes/travelOrders"); function buildQueryRawHandler({ currentOrder, reimbursement = null }) { return vi.fn(async (strings) => { const sql = Array.isArray(strings) ? strings.join("?") : String(strings); if (sql.includes('FROM "travel_orders"')) { return currentOrder ? [currentOrder] : []; } if (sql.includes('FROM "reimbursement_forms"')) { return reimbursement ? [reimbursement] : []; } return []; }); } function buildTravelOrderRecord(overrides = {}) { const now = new Date("2026-03-10T10:00:00.000Z"); return { id: "11111111-1111-4111-8111-111111111111", source: "ctrip", external_order_no: "EXT-001", employee_id: "employee-1", department_id: "dept-1", linked_reimbursement_id: null, travel_request_id: "22222222-2222-4222-8222-222222222222", amount: "1888.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, ...overrides, }; } function buildTravelOrderWithRequest(order) { return { ...order, travel_request: { id: order.travel_request_id, request_no: "CL-20260310-0001", department_id: order.department_id, applicant_employee_id: "employee-1", submitter_id: null, legal_entity: { code: "HY" }, cost_center: { code: "CC001" }, budget_pool: { code: "POOL-1" }, }, }; } function buildPrisma({ employees = [], travelRequests = [], existingOrders = [], currentOrder = null, matchedTransaction = { id: "txn-1" }, featureFlagRecord = { id: "flag-1", flag_key: "erp_sync", override_mode: "inherit", rollout_type: "global", department_ids: [], note: "", version: 1, created_at: new Date("2026-03-10T00:00:00.000Z"), updated_at: new Date("2026-03-10T00:00:00.000Z"), }, existingOutbox = null, } = {}) { const state = { currentOrder: currentOrder ? { ...currentOrder } : null, }; const prisma = { employee: { findMany: vi.fn().mockResolvedValue(employees), }, travelRequest: { findMany: vi.fn().mockResolvedValue(travelRequests), }, travelOrder: { findMany: vi.fn().mockResolvedValue(existingOrders), upsert: vi.fn().mockImplementation(async ({ create, update }) => ({ id: state.currentOrder?.id ?? "order-1", source: create.source, external_order_no: create.external_order_no, employee_id: create.employee_id, department_id: create.department_id, linked_reimbursement_id: create.linked_reimbursement_id, travel_request_id: update.travel_request_id === undefined ? state.currentOrder?.travel_request_id ?? create.travel_request_id : update.travel_request_id, occurred_at: create.occurred_at, amount: create.amount, currency: create.currency, status: update.status, payment_voucher_no: state.currentOrder?.payment_voucher_no ?? null, payment_date: state.currentOrder?.payment_date ?? null, payment_channel: state.currentOrder?.payment_channel ?? null, paid_at: state.currentOrder?.paid_at ?? null, paid_by: state.currentOrder?.paid_by ?? null, created_at: new Date("2026-03-10T00:00:00.000Z"), updated_at: new Date("2026-03-10T00:00:00.000Z"), })), update: vi.fn().mockImplementation(async ({ data }) => { state.currentOrder = { ...state.currentOrder, ...data, updated_at: new Date("2026-03-10T11:00:00.000Z"), }; return state.currentOrder; }), findUnique: vi.fn().mockImplementation(async () => { if (!state.currentOrder) { return null; } return buildTravelOrderWithRequest(state.currentOrder); }), }, cardTransaction: { findFirst: vi.fn().mockResolvedValue(matchedTransaction), }, featureFlag: { findUnique: vi.fn().mockResolvedValue(featureFlagRecord), }, integrationOutbox: { findUnique: vi.fn().mockResolvedValue(existingOutbox), create: vi.fn().mockResolvedValue({ id: "outbox-1", status: "pending" }), }, auditLog: { create: vi.fn().mockResolvedValue({ id: "audit-1" }), }, $queryRaw: buildQueryRawHandler({ currentOrder: state.currentOrder }), $transaction: vi.fn().mockImplementation(async (callback) => callback(prisma)), }; return { prisma, state }; } async function buildApp({ prisma, permissionCodes = ["integration.manage", "travel.payment.manage"] , config = {} }) { const app = Fastify(); app.decorate("prisma", prisma); app.decorate("config", { travelSyncEnabled: true, erpSyncEnabled: true, integrationOutboxMaxAttempts: 5, ...config, }); app.decorate("authenticate", async (request) => { if (!request.headers.authorization) { throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); } request.employee = { id: "actor-1", department_id: "dept-1", permissionCodes }; }); 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.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(travelOrdersRoutes, { prefix: "/api" }); return app; } describe("travelOrders routes", () => { it("imports rows with legal travelRequestId linkage", async () => { const { prisma } = buildPrisma({ employees: [{ id: "employee-1", department_id: "dept-1" }], travelRequests: [{ id: "11111111-1111-4111-8111-111111111111", applicant_employee_id: "employee-1" }], }); const app = await buildApp({ prisma, permissionCodes: ["integration.manage"] }); const response = await app.inject({ method: "POST", url: "/api/travel/orders/import", headers: { authorization: "Bearer test" }, payload: { orders: [ { source: "ctrip", externalOrderNo: "EXT-001", employeeId: "employee-1", travelRequestId: "11111111-1111-4111-8111-111111111111", amount: "1888.00", }, ], }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual( expect.objectContaining({ imported: 1, linkedTravelRequestCount: 1, }), ); await app.close(); }); it("preserves existing travel linkage when new row omits travelRequestId", async () => { const existingOrder = buildTravelOrderRecord({ id: "order-2", source: "ctrip", external_order_no: "EXT-004", status: "paid", travel_request_id: "existing-travel-request", }); const { prisma } = buildPrisma({ employees: [{ id: "employee-1", department_id: "dept-1" }], existingOrders: [existingOrder], currentOrder: existingOrder, }); const app = await buildApp({ prisma, permissionCodes: ["integration.manage"] }); const response = await app.inject({ method: "POST", url: "/api/travel/orders/import", headers: { authorization: "Bearer test" }, payload: { orders: [{ source: "ctrip", externalOrderNo: "EXT-004", employeeId: "employee-1", amount: "500.00" }], }, }); expect(response.statusCode).toBe(200); expect(response.json().data.items[0].travelRequestId).toBe("existing-travel-request"); expect(response.json().data.items[0].status).toBe("paid"); await app.close(); }); it("marks confirmed order as paid and enqueues ERP journal", async () => { const { prisma, state } = buildPrisma({ currentOrder: buildTravelOrderRecord(), }); const app = await buildApp({ prisma }); const response = await app.inject({ method: "POST", url: "/api/travel-orders/11111111-1111-4111-8111-111111111111/mark-paid", headers: { authorization: "Bearer test" }, payload: { paymentVoucherNo: "BANK-001", paymentDate: "2026-03-10", paymentChannel: "bank_transfer", }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual( expect.objectContaining({ status: "paid", paymentVoucherNo: "BANK-001", paymentDate: "2026-03-10", paymentChannel: "bank_transfer", erpJournal: expect.objectContaining({ enqueued: true, outboxId: "outbox-1" }), }), ); expect(state.currentOrder.status).toBe("paid"); expect(prisma.integrationOutbox.create).toHaveBeenCalledTimes(1); await app.close(); }); it("returns idempotent success for already paid order with same payload", async () => { const currentOrder = buildTravelOrderRecord({ status: "paid", payment_voucher_no: "BANK-001", payment_date: new Date("2026-03-10T00:00:00.000Z"), payment_channel: "bank_transfer", paid_at: new Date("2026-03-10T12:00:00.000Z"), paid_by: "actor-1", }); const { prisma } = buildPrisma({ currentOrder, existingOutbox: { id: "outbox-existing", status: "pending", payload: {} }, }); const app = await buildApp({ prisma }); const response = await app.inject({ method: "POST", url: "/api/travel-orders/11111111-1111-4111-8111-111111111111/mark-paid", headers: { authorization: "Bearer test" }, payload: { paymentVoucherNo: "BANK-001", paymentDate: "2026-03-10", paymentChannel: "bank_transfer", }, }); expect(response.statusCode).toBe(200); expect(response.json().data.erpJournal).toEqual( expect.objectContaining({ enqueued: true, outboxId: "outbox-existing" }), ); expect(prisma.travelOrder.update).not.toHaveBeenCalled(); await app.close(); }); it("rejects conflicting paid payload", async () => { const { prisma } = buildPrisma({ currentOrder: buildTravelOrderRecord({ status: "paid", payment_voucher_no: "BANK-001", payment_date: new Date("2026-03-10T00:00:00.000Z"), payment_channel: "bank_transfer", }), }); const app = await buildApp({ prisma }); const response = await app.inject({ method: "POST", url: "/api/travel-orders/11111111-1111-4111-8111-111111111111/mark-paid", headers: { authorization: "Bearer test" }, payload: { paymentVoucherNo: "BANK-002", paymentDate: "2026-03-10", paymentChannel: "bank_transfer", }, }); expect(response.statusCode).toBe(409); expect(response.json().error.details).toEqual({ payment: "already_paid_conflict" }); await app.close(); }); it("returns 409 when no settled transaction matches the order", async () => { const { prisma } = buildPrisma({ currentOrder: buildTravelOrderRecord(), matchedTransaction: null, }); const app = await buildApp({ prisma }); const response = await app.inject({ method: "POST", url: "/api/travel-orders/11111111-1111-4111-8111-111111111111/mark-paid", headers: { authorization: "Bearer test" }, payload: { paymentVoucherNo: null, paymentDate: null, paymentChannel: "manual", }, }); expect(response.statusCode).toBe(409); expect(response.json().error.details).toEqual({ payment: "missing_settled_transaction" }); await app.close(); }); it("skips ERP enqueue when gate is off", async () => { const { prisma } = buildPrisma({ currentOrder: buildTravelOrderRecord(), }); const app = await buildApp({ prisma, config: { erpSyncEnabled: false } }); const response = await app.inject({ method: "POST", url: "/api/travel-orders/11111111-1111-4111-8111-111111111111/mark-paid", headers: { authorization: "Bearer test" }, payload: { paymentVoucherNo: "BANK-001", paymentDate: "2026-03-10", paymentChannel: "bank_transfer", }, }); expect(response.statusCode).toBe(200); expect(response.json().data.erpJournal).toEqual( expect.objectContaining({ enqueued: false, outboxId: null }), ); expect(prisma.integrationOutbox.create).not.toHaveBeenCalled(); await app.close(); }); });