import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' import { fileURLToPath } from 'node:url' import { effectScope, nextTick, ref } from 'vue' import { buildExpenseCaseTimelineViewModel, isExpenseCaseNotAvailableError } from '../src/utils/expenseCaseTimeline.js' import { fetchExpenseCaseTimeline } from '../src/services/expenseCases.js' import { useExpenseCaseTimeline } from '../src/composables/requests/useExpenseCaseTimeline.js' function createDeferred() { let resolve let reject const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise reject = rejectPromise }) return { promise, resolve, reject } } async function flushTimelineUpdates() { await Promise.resolve() await nextTick() await Promise.resolve() } test('expense case timeline maps and sorts the real business event chain', () => { const timeline = buildExpenseCaseTimelineViewModel({ id: 'case-1', case_no: 'CASE-20260713-001', current_stage: 'reviewing', status: 'active', links: [ { id: 'link-1', relation_type: 'application', resource_id: 'application-1' }, { id: 'link-2', relation_type: 'generated_reimbursement', resource_id: 'claim-1' } ], events: [ { id: 'event-2', event_type: 'reimbursement_draft_generated', actor_id: 'system', occurred_at: '2026-07-13T02:20:00Z', payload_json: { application_claim_no: 'AP-20260713-001', next_status: 'draft', next_approval_stage: '待提交' } }, { id: 'event-1', event_type: 'application_submitted', actor_id: 'employee@example.com', occurred_at: '2026-07-13T01:10:00Z', payload_json: { claim_no: 'AP-20260713-001', previous_status: 'draft', next_status: 'submitted', next_approval_stage: '直属领导审批' } } ] }) assert.equal(timeline.caseNo, 'CASE-20260713-001') assert.equal(timeline.stageLabel, '审批中') assert.equal(timeline.linkCount, 2) assert.deepEqual(timeline.events.map((event) => event.id), ['event-1', 'event-2']) assert.equal(timeline.events[0].label, '费用申请已提交') assert.match(timeline.events[0].summary, /草稿 → 已提交/) assert.match(timeline.events[0].summary, /直属领导审批/) assert.equal(timeline.events[0].actorLabel, 'employee@example.com') assert.equal(timeline.events[1].label, '报销草稿已自动生成') assert.match(timeline.events[1].summary, /AP-20260713-001/) assert.equal(timeline.events[1].actorLabel, '系统') }) test('expense case timeline gives returned events an actionable reason', () => { const timeline = buildExpenseCaseTimelineViewModel({ current_stage: 'claiming', events: [ { id: 'returned-1', event_type: 'claim_returned', actor_id: 'finance@example.com', occurred_at: '2026-07-13T03:00:00Z', payload_json: { reason: '住宿票据缺少入住人信息', previous_status: 'submitted', next_status: 'returned' } } ] }) assert.equal(timeline.events[0].label, '报销单被退回') assert.equal(timeline.events[0].tone, 'warning') assert.equal(timeline.events[0].summary, '住宿票据缺少入住人信息') }) test('expense case timeline explains receipt collection, association and AI pre-review', () => { const timeline = buildExpenseCaseTimelineViewModel({ events: [ { id: 'receipt-1', event_type: 'receipt_received', actor_id: 'employee@example.com', occurred_at: '2026-07-13T01:00:00Z', payload_json: { file_name: '上海出差高铁票.pdf' } }, { id: 'association-1', event_type: 'attachment_associated', actor_id: 'employee@example.com', occurred_at: '2026-07-13T01:01:00Z', payload_json: { claim_no: 'BX-20260713-001' } }, { id: 'pre-review-1', event_type: 'claim_pre_review_completed', actor_id: 'employee@example.com', occurred_at: '2026-07-13T01:02:00Z', payload_json: { passed: false, blocking_risk_count: 1, message: '自动检测发现 1 条重大风险,请逐条填写原因后再提交审批。' } } ] }) assert.deepEqual(timeline.events.map((event) => event.label), [ '票据已进入费用事件', '票据已归集到报销草稿', '报销单 AI 预审已完成' ]) assert.match(timeline.events[0].summary, /上海出差高铁票\.pdf/) assert.match(timeline.events[1].summary, /BX-20260713-001/) assert.equal(timeline.events[2].tone, 'warning') assert.match(timeline.events[2].summary, /1 条重大风险/) }) test('expense case timeline explains a historical import without pretending it is a business action', () => { const timeline = buildExpenseCaseTimelineViewModel({ events: [ { id: 'historical-import-1', event_type: 'historical_claim_imported', actor_id: 'migration-worker', actor_type: 'user', occurred_at: '2026-07-14T03:00:00Z', correlation_id: 'private-correlation', delivery_status: 'pending', payload_json: { reason: '不应覆盖历史导入事件的专用说明', next_status: 'submitted', next_approval_stage: '直属领导审批', migration_batch_id: 'private-batch', source_table: 'expense_claims' } } ] }) const [event] = timeline.events assert.equal(event.label, '历史单据已纳入统一费用事件') assert.equal(event.icon, 'mdi mdi-history') assert.equal(event.tone, 'info') assert.equal(event.actorLabel, '系统') assert.equal( event.summary, '系统已根据原单据建立费用事件档案。纳入时状态:已提交 · 纳入时节点:直属领导审批。导入前办理记录以原单据为准,后续动作将在此持续记录。' ) assert.doesNotMatch( JSON.stringify(event), /不应覆盖|private-correlation|pending|private-batch|expense_claims/ ) }) test('expense case timeline keeps the historical import summary honest when state is missing', () => { const timeline = buildExpenseCaseTimelineViewModel({ events: [ { id: 'historical-import-without-state', event_type: 'historical_claim_imported', actor_id: 'system', occurred_at: '2026-07-14T03:00:00Z', payload_json: {} } ] }) const [event] = timeline.events assert.equal( event.summary, '系统已根据原单据建立费用事件档案。导入前办理记录以原单据为准,后续动作将在此持续记录。' ) assert.doesNotMatch(event.summary, /纳入时状态|纳入时节点|undefined|null/) }) test('expense case timeline describes application archive against the paid reimbursement', () => { const timeline = buildExpenseCaseTimelineViewModel({ events: [ { id: 'archived-1', event_type: 'application_archived', occurred_at: '2026-07-13T03:00:00Z', payload_json: { reimbursement_claim_no: 'BX-20260713-001' } } ] }) assert.equal( timeline.events[0].summary, '关联报销单 BX-20260713-001 已付款,本费用申请已归档。' ) }) test('expense case timeline treats 404 as a non-blocking not-yet-covered state', () => { assert.equal(isExpenseCaseNotAvailableError({ status: 404 }), true) assert.equal(isExpenseCaseNotAvailableError({ status: 500 }), false) }) test('expense case timeline keeps equal-time server order and hides unknown event internals', () => { const timeline = buildExpenseCaseTimelineViewModel({ events: [ { id: 'unknown-1', event_type: 'connector_internal_delivery_changed', occurred_at: '2026-07-13T03:00:00Z', correlation_id: 'private-correlation', delivery_status: 'pending' }, { id: 'known-2', event_type: 'payment_completed', occurred_at: '2026-07-13T03:00:00Z' } ] }) assert.deepEqual(timeline.events.map((event) => event.id), ['unknown-1', 'known-2']) assert.equal(timeline.events[0].label, '流程状态已更新') assert.doesNotMatch(JSON.stringify(timeline.events[0]), /private-correlation|pending/) }) test('expense case service safely encodes claim identifiers', async () => { const originalFetch = globalThis.fetch let requestedUrl = '' globalThis.fetch = async (url) => { requestedUrl = String(url) return { ok: true, status: 200, json: async () => ({ events: [] }) } } try { await fetchExpenseCaseTimeline(' claim/with space ') } finally { globalThis.fetch = originalFetch } assert.equal(requestedUrl, '/api/v1/expense-cases/by-claim/claim%2Fwith%20space') }) test('expense case composable ignores a stale response after switching claims', async () => { const claimId = ref('claim-a') const refreshKey = ref('claim-a|submitted') const requests = new Map([ ['claim-a', createDeferred()], ['claim-b', createDeferred()] ]) const scope = effectScope() let state scope.run(() => { state = useExpenseCaseTimeline({ claimId, refreshKey, fetchTimeline: (nextClaimId) => requests.get(nextClaimId).promise }) }) claimId.value = 'claim-b' refreshKey.value = 'claim-b|approved' await nextTick() requests.get('claim-b').resolve({ case_no: 'CASE-B', events: [] }) await flushTimelineUpdates() assert.equal(state.timeline.value.caseNo, 'CASE-B') requests.get('claim-a').resolve({ case_no: 'CASE-A', events: [] }) await flushTimelineUpdates() assert.equal(state.timeline.value.caseNo, 'CASE-B') scope.stop() }) test('expense case composable separates 404 compatibility state from retryable errors', async () => { const claimId = ref('claim-a') const refreshKey = ref('first') const scope = effectScope() let attempt = 0 let state scope.run(() => { state = useExpenseCaseTimeline({ claimId, refreshKey, fetchTimeline: async () => { attempt += 1 const error = new Error('request failed') error.status = attempt === 1 ? 404 : 500 throw error } }) }) await flushTimelineUpdates() assert.equal(state.notAvailable.value, true) assert.equal(state.errorMessage.value, '') refreshKey.value = 'retry' await flushTimelineUpdates() assert.equal(state.notAvailable.value, false) assert.match(state.errorMessage.value, /不受影响/) scope.stop() }) test('travel request detail consumes the real expense case endpoint', () => { const detailView = readFileSync( fileURLToPath(new URL('../src/views/TravelRequestDetailView.vue', import.meta.url)), 'utf8' ) const progressCard = readFileSync( fileURLToPath(new URL('../src/components/travel/TravelRequestProgressCard.vue', import.meta.url)), 'utf8' ) const timelineComponent = readFileSync( fileURLToPath(new URL('../src/components/travel/TravelRequestExpenseCaseTimeline.vue', import.meta.url)), 'utf8' ) const expenseCaseService = readFileSync( fileURLToPath(new URL('../src/services/expenseCases.js', import.meta.url)), 'utf8' ) assert.match(detailView, /:claim-id="request\.claimId"/) assert.match(detailView, /:refresh-key="expenseCaseTimelineRefreshKey"/) assert.match(progressCard, /