2026-07-13 16:58:51 +08:00
|
|
|
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,
|
2026-07-20 10:31:27 +08:00
|
|
|
isExpenseCaseNotAvailableError,
|
|
|
|
|
orderExpenseCaseEventsForDisplay
|
2026-07-13 16:58:51 +08:00
|
|
|
} 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, '费用申请已提交')
|
2026-07-20 10:31:27 +08:00
|
|
|
assert.equal(timeline.events[0].icon, 'mdi mdi-file-send-outline')
|
2026-07-13 16:58:51 +08:00
|
|
|
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, '住宿票据缺少入住人信息')
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-16 11:42:08 +08:00
|
|
|
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')
|
2026-07-20 10:31:27 +08:00
|
|
|
assert.equal(timeline.events[2].icon, 'mdi mdi-shield-check-outline')
|
2026-07-16 11:42:08 +08:00
|
|
|
assert.match(timeline.events[2].summary, /1 条重大风险/)
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-14 10:09:09 +08:00
|
|
|
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/)
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-13 16:58:51 +08:00
|
|
|
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/)
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-20 10:31:27 +08:00
|
|
|
test('expense case records show newer events first and preserve equal-time server order', () => {
|
|
|
|
|
const timeline = buildExpenseCaseTimelineViewModel({
|
|
|
|
|
events: [
|
|
|
|
|
{
|
|
|
|
|
id: 'older',
|
|
|
|
|
event_type: 'application_submitted',
|
|
|
|
|
occurred_at: '2026-07-17T14:54:00Z'
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'newer-first',
|
|
|
|
|
event_type: 'application_pre_review_completed',
|
|
|
|
|
occurred_at: '2026-07-18T02:00:00Z'
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'newer-second',
|
|
|
|
|
event_type: 'claim_submitted',
|
|
|
|
|
occurred_at: '2026-07-18T02:00:00Z'
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const displayedEvents = orderExpenseCaseEventsForDisplay(timeline.events)
|
|
|
|
|
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
displayedEvents.map((event) => event.id),
|
|
|
|
|
['newer-first', 'newer-second', 'older']
|
|
|
|
|
)
|
|
|
|
|
assert.equal(displayedEvents[0].icon, 'mdi mdi-shield-check-outline')
|
|
|
|
|
assert.deepEqual(timeline.events.map((event) => event.id), ['older', 'newer-first', 'newer-second'])
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-13 16:58:51 +08:00
|
|
|
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, /<TravelRequestExpenseCaseTimeline/)
|
|
|
|
|
assert.match(progressCard, /TravelRequestExpenseCaseTimeline/)
|
2026-07-20 10:31:27 +08:00
|
|
|
assert.match(timelineComponent, /费用操作记录/)
|
|
|
|
|
assert.match(timelineComponent, /发生时间/)
|
|
|
|
|
assert.match(timelineComponent, /操作人/)
|
|
|
|
|
assert.doesNotMatch(timelineComponent, /expense-case-latest/)
|
|
|
|
|
assert.match(timelineComponent, /orderExpenseCaseEventsForDisplay/)
|
2026-07-13 16:58:51 +08:00
|
|
|
assert.match(timelineComponent, /当前单据尚未纳入统一费用事件/)
|
|
|
|
|
assert.match(expenseCaseService, /\/expense-cases\/by-claim\//)
|
|
|
|
|
})
|