const Fastify = require('fastify'); const { AppError } = require('../../../src/utils/AppError'); const integrationsRoutes = require('../../../src/routes/integrations'); function buildBatch(overrides = {}) { return { id: '66666666-6666-4666-8666-000000000001', batch_no: 'TRB-20260311-0001', legal_entity_id: '44444444-4444-4444-8444-444444444444', cost_center_id: '55555555-5555-4555-8555-555555555555', period_start: new Date('2026-03-01T00:00:00.000Z'), period_end: new Date('2026-03-31T00:00:00.000Z'), status: 'reconciled', summary_json: { totalOrders: 1, totalTransactions: 1, anomalyItems: 0, matchRate: '100.00', totalOrderAmount: '100.00', totalSettledAmount: '100.00', totalReversedAmount: '0.00', }, confirmed_at: new Date('2026-03-31T10:00:00.000Z'), created_at: new Date('2026-03-31T09:00:00.000Z'), updated_at: new Date('2026-03-31T10:00:00.000Z'), legal_entity: { id: '44444444-4444-4444-8444-444444444444', code: 'HY', name: '海音达', }, cost_center: { id: '55555555-5555-4555-8555-555555555555', code: 'CC001', name: '销售中心', }, items: [ { id: 'item-1', travel_request_id: '11111111-1111-4111-8111-111111111111', travel_order_id: '22222222-2222-4222-8222-222222222222', card_transaction_id: '33333333-3333-4333-8333-333333333333', match_status: 'matched', resolution_status: 'confirmed', order_amount: '100.00', transaction_amount: '100.00', currency: 'CNY', snapshot_json: { requestNo: 'CL-20260310-0001', externalOrderNo: 'EXT-001', externalTxnId: 'TXN-001', linkedReimbursementId: null, boundInvoiceCount: 1, }, created_at: new Date('2026-03-31T09:00:00.000Z'), }, ], ...overrides, }; } function buildPrisma({ batch = buildBatch(), featureFlag, existingOutbox = null }) { const state = { batch: batch ? { ...batch } : null, outbox: existingOutbox ? { ...existingOutbox } : null, auditLogs: [], }; const payableBinding = { id: 'binding-summary', provider: 'erp', entity_type: 'travel_payable_summary', local_id: '66666666-6666-4666-8666-000000000001', external_id: 'EXT-SUM-001', external_code: 'SUM-001', last_synced_at: new Date('2026-03-31T10:01:00.000Z'), }; const settlementBinding = { id: 'binding-batch', provider: 'erp', entity_type: 'travel_settlement_batch', local_id: '66666666-6666-4666-8666-000000000001', external_id: 'EXT-BATCH-001', external_code: 'BAT-001', last_synced_at: new Date('2026-03-31T10:01:00.000Z'), }; const prisma = { travelReconciliationBatch: { findUnique: vi.fn().mockImplementation(async ({ where }) => { if (!state.batch || where.id !== state.batch.id) { return null; } return state.batch; }), }, featureFlag: { findUnique: vi.fn().mockResolvedValue( 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-31T00:00:00.000Z'), updated_at: new Date('2026-03-31T00:00:00.000Z'), }, ), }, integrationOutbox: { findUnique: vi.fn().mockImplementation(async ({ where }) => { const key = where?.event_type_dedupe_key; if (!key) { return null; } if (!state.outbox) { return null; } return state.outbox.event_type === key.event_type && state.outbox.dedupe_key === key.dedupe_key ? state.outbox : null; }), create: vi.fn().mockImplementation(async ({ data }) => { state.outbox = { id: 'outbox-created', event_type: data.event_type, status: data.status, payload: data.payload, dedupe_key: data.dedupe_key, attempts: 0, last_error: null, processed_at: null, updated_at: new Date('2026-03-31T10:05:00.000Z'), }; return { id: state.outbox.id, status: state.outbox.status }; }), update: vi.fn().mockImplementation(async ({ where, data }) => { if (!state.outbox || state.outbox.id !== where.id) { throw new Error('outbox_not_found'); } state.outbox = { ...state.outbox, ...data, updated_at: new Date('2026-03-31T10:06:00.000Z'), }; return { id: state.outbox.id, status: state.outbox.status }; }), }, integrationBinding: { findUnique: vi.fn().mockImplementation(async ({ where }) => { const bindingType = where.provider_entity_type_local_id.entity_type; if (bindingType === 'travel_payable_summary') { return payableBinding; } if (bindingType === 'travel_settlement_batch') { return settlementBinding; } return null; }), }, auditLog: { create: vi.fn().mockImplementation(async ({ data }) => { state.auditLogs.push(data); return { id: `audit-${state.auditLogs.length}` }; }), }, }; return { prisma, state }; } async function buildApp({ prisma, permissionCodes, config = {} }) { const app = Fastify(); app.decorate('prisma', prisma); app.decorate('config', { erpSyncEnabled: true, erpSyncProvider: 'mock', 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', 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(integrationsRoutes, { prefix: '/api' }); return app; } describe('Integrations travel reconciliation ERP routes', () => { const { canReadTravelReconciliationBatch } = integrationsRoutes.__testables; it('recognizes readable reconciliation roles', () => { expect( canReadTravelReconciliationBatch({ employee: { permissionCodes: ['travel.payment.manage'] } }), ).toBe(true); expect( canReadTravelReconciliationBatch({ employee: { permissionCodes: ['integration.manage'] } }), ).toBe(true); expect( canReadTravelReconciliationBatch({ employee: { permissionCodes: ['travel.view_all'] } }), ).toBe(false); }); it('redacts ERP binding refs for non integration managers on track endpoint', async () => { const { prisma } = buildPrisma({ existingOutbox: { id: 'outbox-1', event_type: 'erp.travel.export', dedupe_key: 'travel_reconciliation_batch:66666666-6666-4666-8666-000000000001:summary', status: 'succeeded', payload: { batchNo: 'TRB-20260311-0001' }, attempts: 1, last_error: null, processed_at: new Date('2026-03-31T10:00:00.000Z'), updated_at: new Date('2026-03-31T10:00:00.000Z'), }, }); const app = await buildApp({ prisma, permissionCodes: ['travel.payment.manage'] }); const response = await app.inject({ method: 'GET', url: '/api/integrations/erp/travel/reconciliation-batches/66666666-6666-4666-8666-000000000001/track', headers: { authorization: 'Bearer test' }, }); expect(response.statusCode).toBe(200); const track = response.json().data; expect(track.stage.summaryExport.bindings.payableSummary.externalId).toBe(null); expect(track.stage.summaryExport.bindings.settlementBatch.externalCode).toBe(null); expect(track.permissions.canRetry).toBe(false); await app.close(); }); it('shows full ERP binding refs to integration managers and allows requeue', async () => { const { prisma, state } = buildPrisma({ existingOutbox: { id: 'outbox-1', event_type: 'erp.travel.export', dedupe_key: 'travel_reconciliation_batch:66666666-6666-4666-8666-000000000001:summary', status: 'failed', payload: { entityId: '66666666-6666-4666-8666-000000000001', batchNo: 'TRB-20260311-0001' }, attempts: 2, last_error: 'failed_once', processed_at: null, updated_at: new Date('2026-03-31T10:00:00.000Z'), }, }); const app = await buildApp({ prisma, permissionCodes: ['integration.manage'] }); const trackResponse = await app.inject({ method: 'GET', url: '/api/integrations/erp/travel/reconciliation-batches/66666666-6666-4666-8666-000000000001/track', headers: { authorization: 'Bearer test' }, }); expect(trackResponse.statusCode).toBe(200); expect(trackResponse.json().data.stage.summaryExport.bindings.payableSummary.externalId).toBe('EXT-SUM-001'); const requeueResponse = await app.inject({ method: 'POST', url: '/api/integrations/erp/travel/reconciliation-batches/66666666-6666-4666-8666-000000000001/requeue', headers: { authorization: 'Bearer test' }, payload: {}, }); expect(requeueResponse.statusCode).toBe(200); expect(requeueResponse.json().data).toEqual( expect.objectContaining({ outboxId: 'outbox-1', eventType: 'erp.travel.export', requeued: true, }), ); expect(state.outbox.status).toBe('pending'); await app.close(); }); it('blocks requeue when ERP gate is disabled', async () => { const { prisma } = buildPrisma({}); const app = await buildApp({ prisma, permissionCodes: ['integration.manage'], config: { erpSyncEnabled: false }, }); const response = await app.inject({ method: 'POST', url: '/api/integrations/erp/travel/reconciliation-batches/66666666-6666-4666-8666-000000000001/requeue', headers: { authorization: 'Bearer test' }, payload: {}, }); expect(response.statusCode).toBe(409); expect(response.json().error.details.integration).toBe('erp_sync_disabled'); await app.close(); }); });