const { TRAVEL_ERP_BINDING_TYPES, TRAVEL_ERP_EVENT_TYPE, buildTravelReconciliationErpTrack, buildTravelReconciliationSummaryDedupeKey, buildTravelReconciliationSummaryPayload, } = require('../../../src/services/integrations/erpTravel'); describe('erpTravel service', () => { function createBatch(overrides = {}) { return { id: 'batch-1', batch_no: 'TRB-20260311-0001', status: 'reconciled', period_start: new Date('2026-03-01T00:00:00.000Z'), period_end: new Date('2026-03-31T00:00:00.000Z'), 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'), summary_json: { totalOrders: 2, totalTransactions: 2, anomalyItems: 1, matchRate: '50.00', totalOrderAmount: '200.00', totalSettledAmount: '180.00', totalReversedAmount: '20.00', }, legal_entity: { id: 'legal-1', code: 'HY', name: '海音达', }, cost_center: { id: 'cc-1', code: 'CC001', name: '销售中心', }, items: [ { id: 'item-1', travel_request_id: 'travel-1', travel_order_id: 'order-1', card_transaction_id: 'txn-1', 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, }, }, ], ...overrides, }; } it('builds travel reconciliation summary payload with batch snapshot', () => { const payload = buildTravelReconciliationSummaryPayload({ batch: createBatch() }); expect(payload).toEqual( expect.objectContaining({ exportType: 'travel_reconciliation_summary', entityType: 'travel_reconciliation_batch', entityId: 'batch-1', batchNo: 'TRB-20260311-0001', periodStart: '2026-03-01', periodEnd: '2026-03-31', dimensions: { legalEntityCode: 'HY', costCenterCode: 'CC001', }, summary: { totalOrders: 2, totalTransactions: 2, anomalyItems: 1, matchRate: '50.00', totalOrderAmount: '200.00', totalSettledAmount: '180.00', totalReversedAmount: '20.00', }, }), ); expect(payload.items[0]).toEqual( expect.objectContaining({ travelRequestId: 'travel-1', requestNo: 'CL-20260310-0001', travelOrderId: 'order-1', externalOrderNo: 'EXT-001', cardTransactionId: 'txn-1', externalTxnId: 'TXN-001', linkedReimbursementId: null, boundInvoiceCount: 1, }), ); }); it('builds stable dedupe key for travel reconciliation batch summary export', () => { expect(buildTravelReconciliationSummaryDedupeKey('batch-1')).toBe( 'travel_reconciliation_batch:batch-1:summary', ); }); it('builds ERP track with redacted bindings for non integration managers', async () => { const batch = createBatch(); const prisma = { integrationOutbox: { findUnique: vi.fn().mockResolvedValue({ id: 'outbox-1', event_type: TRAVEL_ERP_EVENT_TYPE, status: 'succeeded', attempts: 1, last_error: null, processed_at: new Date('2026-03-31T10:01:00.000Z'), updated_at: new Date('2026-03-31T10:01:00.000Z'), }), }, integrationBinding: { findUnique: vi .fn() .mockResolvedValueOnce({ id: 'binding-summary', provider: 'erp', entity_type: TRAVEL_ERP_BINDING_TYPES.payableSummary, local_id: 'batch-1', external_id: 'SUM-001', external_code: 'TRB-SUM-001', last_synced_at: new Date('2026-03-31T10:02:00.000Z'), }) .mockResolvedValueOnce({ id: 'binding-batch', provider: 'erp', entity_type: TRAVEL_ERP_BINDING_TYPES.settlementBatch, local_id: 'batch-1', external_id: 'BAT-001', external_code: 'TRB-BAT-001', last_synced_at: new Date('2026-03-31T10:02:00.000Z'), }), }, }; const track = await buildTravelReconciliationErpTrack(prisma, {}, 'batch-1', { context: batch, canManage: false, gate: { effectiveEnabled: true }, }); expect(track.stage.summaryExport.status).toBe('succeeded'); expect(track.stage.summaryExport.bindings.payableSummary).toEqual( expect.objectContaining({ entityType: TRAVEL_ERP_BINDING_TYPES.payableSummary, externalId: null, externalCode: null, }), ); expect(track.stage.summaryExport.bindings.settlementBatch).toEqual( expect.objectContaining({ entityType: TRAVEL_ERP_BINDING_TYPES.settlementBatch, externalId: null, externalCode: null, }), ); expect(track.permissions.canRetry).toBe(false); }); });