const Fastify = require('fastify'); const { AppError } = require('../../../src/utils/AppError'); const travelReconciliationRoutes = require('../../../src/routes/travelReconciliation'); function buildTravelRequest(overrides = {}) { return { id: '11111111-1111-4111-8111-111111111111', request_no: 'CL-20260311-0001', ...overrides, }; } function buildOrder(overrides = {}) { return { id: '22222222-2222-4222-8222-222222222222', source: 'ctrip', external_order_no: 'EXT-001', linked_reimbursement_id: null, travel_request_id: '11111111-1111-4111-8111-111111111111', amount: '100.00', currency: 'CNY', status: 'paid', payment_date: new Date('2026-03-10T00:00:00.000Z'), paid_at: new Date('2026-03-10T08:00:00.000Z'), created_at: new Date('2026-03-10T08:00:00.000Z'), updated_at: new Date('2026-03-10T08:00:00.000Z'), travel_request: { id: '11111111-1111-4111-8111-111111111111', request_no: 'CL-20260311-0001', legal_entity_id: '44444444-4444-4444-8444-444444444444', cost_center_id: '55555555-5555-4555-8555-555555555555', }, ...overrides, }; } function buildTransaction(overrides = {}) { return { id: '33333333-3333-4333-8333-333333333333', source: 'corp-card', external_txn_id: 'TXN-001', travel_request_id: '11111111-1111-4111-8111-111111111111', travel_order_id: '22222222-2222-4222-8222-222222222222', amount: '100.00', currency: 'CNY', txn_type: 'charge', status: 'settled', occurred_at: new Date('2026-03-10T08:30:00.000Z'), created_at: new Date('2026-03-10T08:30:00.000Z'), travel_request: { id: '11111111-1111-4111-8111-111111111111', request_no: 'CL-20260311-0001', }, travel_order: { id: '22222222-2222-4222-8222-222222222222', external_order_no: 'EXT-001', linked_reimbursement_id: null, status: 'paid', }, ...overrides, }; } function buildPrisma({ orders = [], transactions = [], invoices = [], featureFlag = { id: 'flag-1', flag_key: 'erp_sync', override_mode: 'force_on', rollout_type: 'global', department_ids: [], note: '', version: 1, created_at: new Date('2026-03-11T00:00:00.000Z'), updated_at: new Date('2026-03-11T00:00:00.000Z'), }, } = {}) { const state = { legalEntities: [{ id: '44444444-4444-4444-8444-444444444444', code: 'HY', name: '海音达' }], costCenters: [{ id: '55555555-5555-4555-8555-555555555555', code: 'CC001', name: '销售中心', legal_entity_id: '44444444-4444-4444-8444-444444444444' }], travelRequests: [buildTravelRequest()], orders: orders.map((item) => ({ ...item })), transactions: transactions.map((item) => ({ ...item })), invoices: invoices.map((item) => ({ ...item })), batches: [], items: [], integrationOutboxes: [], auditLogs: [], sequenceMap: {}, }; let batchCounter = 1; let itemCounter = 1; function materializeTravelRequest(id) { const request = state.travelRequests.find((item) => item.id === id); return request ? { id: request.id, request_no: request.request_no } : null; } function materializeOrder(id) { const order = state.orders.find((item) => item.id === id); return order ? { id: order.id, external_order_no: order.external_order_no, status: order.status, } : null; } function materializeTransaction(id) { const transaction = state.transactions.find((item) => item.id === id); return transaction ? { id: transaction.id, external_txn_id: transaction.external_txn_id, status: transaction.status, txn_type: transaction.txn_type, } : null; } function materializeItem(item) { const batch = state.batches.find((row) => row.id === item.travel_reconciliation_batch_id); return { ...item, batch: batch ? { id: batch.id, status: batch.status, summary_json: batch.summary_json, } : null, travel_request: materializeTravelRequest(item.travel_request_id), travel_order: materializeOrder(item.travel_order_id), card_transaction: materializeTransaction(item.card_transaction_id), }; } function materializeBatch(batch) { if (!batch) { return null; } return { ...batch, legal_entity: state.legalEntities.find((item) => item.id === batch.legal_entity_id) ?? null, cost_center: state.costCenters.find((item) => item.id === batch.cost_center_id) ?? null, confirmed_by_employee: batch.confirmed_by ? { id: batch.confirmed_by, employee_no: 'E001', name: '财务一号' } : null, items: state.items .filter((item) => item.travel_reconciliation_batch_id === batch.id) .map(materializeItem), }; } const prisma = { legalEntity: { findUnique: vi.fn().mockImplementation(async ({ where }) => { return state.legalEntities.find((item) => item.id === where.id) ?? null; }), }, costCenter: { findUnique: vi.fn().mockImplementation(async ({ where }) => { return state.costCenters.find((item) => item.id === where.id) ?? null; }), }, travelOrder: { findMany: vi.fn().mockImplementation(async () => state.orders.map((item) => ({ ...item }))), }, cardTransaction: { findMany: vi.fn().mockImplementation(async () => state.transactions.map((item) => ({ ...item }))), }, invoicePool: { findMany: vi.fn().mockImplementation(async ({ where }) => { const ids = new Set(where?.travel_order_id?.in ?? []); return state.invoices.filter((item) => ids.has(item.travel_order_id)).map((item) => ({ ...item })); }), }, documentCategory: { findUnique: vi.fn().mockImplementation(async ({ where }) => { if (where.code === 'TR_RECON') { return { code: 'TR_RECON', entity_type: 'travel_reconciliation_batch', sequence_prefix: 'TRB', number_length: 4, reset_period: 'yearly', enabled: true, archived_at: null, }; } return null; }), findFirst: vi.fn().mockResolvedValue(null), }, documentSequence: { findUnique: vi.fn().mockImplementation(async ({ where }) => state.sequenceMap[where.document_type] ?? null), upsert: vi.fn().mockImplementation(async ({ where, create, update }) => { const current = state.sequenceMap[where.document_type]; if (current) { const next = { ...current, prefix: update.prefix, date_format: update.date_format, separator: update.separator, number_length: update.number_length, reset_period: update.reset_period, current_number: current.current_number + 1, updated_by: update.updated_by, updated_at: new Date('2026-03-11T09:00:00.000Z'), }; state.sequenceMap[where.document_type] = next; return next; } const created = { document_type: create.document_type, prefix: create.prefix, date_format: create.date_format, separator: create.separator, number_length: create.number_length, reset_period: create.reset_period, current_number: create.current_number, last_reset_date: null, updated_at: new Date('2026-03-11T09:00:00.000Z'), updated_by: create.updated_by, }; state.sequenceMap[where.document_type] = created; return created; }), }, travelReconciliationBatch: { create: vi.fn().mockImplementation(async ({ data }) => { const batch = { id: `66666666-6666-4666-8666-${String(batchCounter++).padStart(12, '0')}`, batch_no: data.batch_no, legal_entity_id: data.legal_entity_id, cost_center_id: data.cost_center_id ?? null, period_start: data.period_start, period_end: data.period_end, status: data.status, summary_json: data.summary_json, confirmed_at: null, confirmed_by: null, created_at: new Date('2026-03-11T09:00:00.000Z'), created_by: data.created_by ?? null, updated_at: new Date('2026-03-11T09:00:00.000Z'), updated_by: data.updated_by ?? null, }; state.batches.push(batch); return { id: batch.id }; }), findUnique: vi.fn().mockImplementation(async ({ where }) => { const batch = state.batches.find((item) => item.id === where.id) ?? null; return materializeBatch(batch); }), findMany: vi.fn().mockImplementation(async ({ where }) => { return state.batches .filter((item) => { if (where?.status && item.status !== where.status) return false; if (where?.legal_entity_id && item.legal_entity_id !== where.legal_entity_id) return false; if (where?.cost_center_id && item.cost_center_id !== where.cost_center_id) return false; return true; }) .map(materializeBatch); }), count: vi.fn().mockImplementation(async ({ where }) => { return state.batches.filter((item) => { if (where?.status && item.status !== where.status) return false; if (where?.legal_entity_id && item.legal_entity_id !== where.legal_entity_id) return false; if (where?.cost_center_id && item.cost_center_id !== where.cost_center_id) return false; return true; }).length; }), update: vi.fn().mockImplementation(async ({ where, data }) => { const index = state.batches.findIndex((item) => item.id === where.id); state.batches[index] = { ...state.batches[index], ...data, updated_at: new Date('2026-03-11T10:00:00.000Z'), }; return materializeBatch(state.batches[index]); }), }, travelReconciliationItem: { createMany: vi.fn().mockImplementation(async ({ data }) => { data.forEach((row) => { state.items.push({ id: `77777777-7777-4777-8777-${String(itemCounter++).padStart(12, '0')}`, ...row, created_at: new Date('2026-03-11T09:00:00.000Z'), updated_at: new Date('2026-03-11T09:00:00.000Z'), }); }); return { count: data.length }; }), findUnique: vi.fn().mockImplementation(async ({ where }) => { const item = state.items.find((row) => row.id === where.id) ?? null; return item ? materializeItem(item) : null; }), update: vi.fn().mockImplementation(async ({ where, data }) => { const index = state.items.findIndex((item) => item.id === where.id); state.items[index] = { ...state.items[index], ...data, updated_at: new Date('2026-03-11T10:00:00.000Z'), }; return materializeItem(state.items[index]); }), }, integrationOutbox: { findUnique: vi.fn().mockImplementation(async ({ where }) => { const key = where?.event_type_dedupe_key; if (!key) { return null; } return ( state.integrationOutboxes.find( (item) => item.event_type === key.event_type && item.dedupe_key === key.dedupe_key, ) ?? null ); }), create: vi.fn().mockImplementation(async ({ data }) => { const row = { id: `outbox-${state.integrationOutboxes.length + 1}`, event_type: data.event_type, status: data.status, payload: data.payload, dedupe_key: data.dedupe_key ?? null, max_attempts: data.max_attempts, created_by: data.created_by ?? null, updated_by: data.updated_by ?? null, attempts: 0, last_error: null, processed_at: null, dead_letter_at: null, next_retry_at: null, created_at: new Date('2026-03-11T10:00:00.000Z'), updated_at: new Date('2026-03-11T10:00:00.000Z'), }; state.integrationOutboxes.push(row); return { id: row.id, status: row.status }; }), update: vi.fn().mockImplementation(async ({ where, data }) => { const index = state.integrationOutboxes.findIndex((item) => item.id === where.id); state.integrationOutboxes[index] = { ...state.integrationOutboxes[index], ...data, updated_at: new Date('2026-03-11T10:05:00.000Z'), }; return { id: state.integrationOutboxes[index].id, status: state.integrationOutboxes[index].status }; }), }, auditLog: { create: vi.fn().mockImplementation(async ({ data }) => { state.auditLogs.push(data); return { id: `audit-${state.auditLogs.length}` }; }), }, featureFlag: { findUnique: vi.fn().mockResolvedValue(featureFlag), }, $queryRaw: vi.fn().mockImplementation(async (strings, id) => { const sql = Array.isArray(strings) ? strings.join('?') : String(strings); if (sql.includes('FROM "travel_reconciliation_batches"')) { return state.batches.some((item) => item.id === id) ? [{ id }] : []; } if (sql.includes('FROM "travel_reconciliation_items"')) { return state.items.some((item) => item.id === id) ? [{ id }] : []; } return []; }), $transaction: vi.fn().mockImplementation(async (callback) => callback(prisma)), }; return { prisma, state }; } async function buildApp({ prisma, permissionCodes = ['travel.payment.manage'], config = {} }) { const app = Fastify(); app.decorate('prisma', prisma); app.decorate('config', { appTimezone: 'Asia/Shanghai', 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) => { return (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(travelReconciliationRoutes, { prefix: '/api' }); return app; } describe('travelReconciliation routes', () => { it('creates batches and supports list/detail queries', async () => { const { prisma } = buildPrisma({ orders: [buildOrder()], transactions: [buildTransaction()], invoices: [{ id: 'inv-1', travel_order_id: '22222222-2222-4222-8222-222222222222', total_amount: '100.00' }], }); const app = await buildApp({ prisma }); const createResponse = await app.inject({ method: 'POST', url: '/api/travel/reconciliation/batches', headers: { authorization: 'Bearer test' }, payload: { legalEntityId: '44444444-4444-4444-8444-444444444444', costCenterId: '55555555-5555-4555-8555-555555555555', periodStart: '2026-03-01', periodEnd: '2026-03-31', }, }); expect(createResponse.statusCode).toBe(200); const created = createResponse.json().data; expect(created).toEqual( expect.objectContaining({ batchNo: expect.stringMatching(/^TRB-\d{8}-\d{4}$/), status: 'draft', permissions: expect.objectContaining({ canManagePayments: true, }), }), ); expect(created.summary).toEqual( expect.objectContaining({ totalOrders: 1, totalTransactions: 1, matchedItems: 1, pendingItems: 0, }), ); const listResponse = await app.inject({ method: 'GET', url: '/api/travel/reconciliation/batches?status=draft', headers: { authorization: 'Bearer test' }, }); expect(listResponse.statusCode).toBe(200); expect(listResponse.json().data.total).toBe(1); const detailResponse = await app.inject({ method: 'GET', url: `/api/travel/reconciliation/batches/${created.id}`, headers: { authorization: 'Bearer test' }, }); expect(detailResponse.statusCode).toBe(200); expect(detailResponse.json().data.items).toHaveLength(1); await app.close(); }); it('confirms and reopens anomaly items inside draft batch', async () => { const { prisma } = buildPrisma({ orders: [buildOrder()], transactions: [], }); const app = await buildApp({ prisma }); const createResponse = await app.inject({ method: 'POST', url: '/api/travel/reconciliation/batches', headers: { authorization: 'Bearer test' }, payload: { legalEntityId: '44444444-4444-4444-8444-444444444444', costCenterId: '55555555-5555-4555-8555-555555555555', periodStart: '2026-03-01', periodEnd: '2026-03-31', }, }); const created = createResponse.json().data; const item = created.items.find((row) => row.matchStatus !== 'matched'); const confirmResponse = await app.inject({ method: 'POST', url: `/api/travel/reconciliation/items/${item.id}/confirm`, headers: { authorization: 'Bearer test' }, payload: { note: '金额差异已人工确认' }, }); expect(confirmResponse.statusCode).toBe(200); expect(confirmResponse.json().data.summary.pendingItems).toBe(0); expect(confirmResponse.json().data.item.resolutionStatus).toBe('confirmed'); expect(confirmResponse.json().data.item.confirmNote).toBe('金额差异已人工确认'); const reopenResponse = await app.inject({ method: 'POST', url: `/api/travel/reconciliation/items/${item.id}/reopen`, headers: { authorization: 'Bearer test' }, payload: {}, }); expect(reopenResponse.statusCode).toBe(200); expect(reopenResponse.json().data.summary.pendingItems).toBe(1); expect(reopenResponse.json().data.item.resolutionStatus).toBe('pending'); await app.close(); }); it('rejects finalize when pending anomaly items remain', async () => { const { prisma } = buildPrisma({ orders: [buildOrder()], transactions: [], }); const app = await buildApp({ prisma }); const createResponse = await app.inject({ method: 'POST', url: '/api/travel/reconciliation/batches', headers: { authorization: 'Bearer test' }, payload: { legalEntityId: '44444444-4444-4444-8444-444444444444', costCenterId: '55555555-5555-4555-8555-555555555555', periodStart: '2026-03-01', periodEnd: '2026-03-31', }, }); const batchId = createResponse.json().data.id; const finalizeResponse = await app.inject({ method: 'POST', url: `/api/travel/reconciliation/batches/${batchId}/finalize`, headers: { authorization: 'Bearer test' }, payload: {}, }); expect(finalizeResponse.statusCode).toBe(409); expect(finalizeResponse.json().error.details.batch).toBe('reconciliation_batch_has_pending_items'); await app.close(); }); it('finalizes fully confirmed batch and enqueues ERP export when gate is on', async () => { const { prisma, state } = buildPrisma({ orders: [buildOrder()], transactions: [buildTransaction()], }); const app = await buildApp({ prisma, config: { erpSyncEnabled: true } }); const createResponse = await app.inject({ method: 'POST', url: '/api/travel/reconciliation/batches', headers: { authorization: 'Bearer test' }, payload: { legalEntityId: '44444444-4444-4444-8444-444444444444', costCenterId: '55555555-5555-4555-8555-555555555555', periodStart: '2026-03-01', periodEnd: '2026-03-31', }, }); const batchId = createResponse.json().data.id; const finalizeResponse = await app.inject({ method: 'POST', url: `/api/travel/reconciliation/batches/${batchId}/finalize`, headers: { authorization: 'Bearer test' }, payload: {}, }); expect(finalizeResponse.statusCode).toBe(200); expect(finalizeResponse.json().data).toEqual( expect.objectContaining({ batchId, status: 'reconciled', erpExport: expect.objectContaining({ eventType: 'erp.travel.export', enqueued: true, }), }), ); expect(state.integrationOutboxes).toHaveLength(1); expect(state.integrationOutboxes[0].event_type).toBe('erp.travel.export'); await app.close(); }); it('returns 409 for empty reconciliation scope', async () => { const { prisma } = buildPrisma({ orders: [], transactions: [] }); const app = await buildApp({ prisma }); const response = await app.inject({ method: 'POST', url: '/api/travel/reconciliation/batches', headers: { authorization: 'Bearer test' }, payload: { legalEntityId: '44444444-4444-4444-8444-444444444444', costCenterId: '55555555-5555-4555-8555-555555555555', periodStart: '2026-03-01', periodEnd: '2026-03-31', }, }); expect(response.statusCode).toBe(409); expect(response.json().error.details.batch).toBe('travel_reconciliation_empty_scope'); await app.close(); }); it('blocks reads without travel payment or integration management permission', async () => { const { prisma } = buildPrisma({ orders: [buildOrder()], transactions: [buildTransaction()], }); const app = await buildApp({ prisma, permissionCodes: ['travel.view_own'] }); const response = await app.inject({ method: 'GET', url: '/api/travel/reconciliation/batches', headers: { authorization: 'Bearer test' }, }); expect(response.statusCode).toBe(403); await app.close(); }); });