refactor(frontend): split large reimbursement and audit modules
This commit is contained in:
@@ -135,6 +135,7 @@ async function testRejectsWithCustomTimeoutMessage() {
|
||||
}),
|
||||
(error) => {
|
||||
assert.equal(error.message, '知识问答整理超时,已停止等待。')
|
||||
assert.equal(error.code, 'REQUEST_TIMEOUT')
|
||||
return true
|
||||
}
|
||||
)
|
||||
|
||||
43
web/tests/backend-health-timeout.test.mjs
Normal file
43
web/tests/backend-health-timeout.test.mjs
Normal file
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { checkBackendHealth, useBackendHealth } from '../src/composables/useBackendHealth.js'
|
||||
|
||||
const routerScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/router/index.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('app route guard allows stale healthy state when health check times out', () => {
|
||||
assert.match(routerScript, /checkBackendHealth\(\{\s*allowStaleOnTimeout:\s*true\s*\}\)/)
|
||||
})
|
||||
|
||||
test('backend health timeout does not block app rendering when stale fallback is allowed', async () => {
|
||||
const originalFetch = global.fetch
|
||||
|
||||
global.fetch = async (_url, options = {}) =>
|
||||
new Promise((_, reject) => {
|
||||
options.signal.addEventListener('abort', () => {
|
||||
const error = new Error('aborted')
|
||||
error.name = 'AbortError'
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
const ok = await checkBackendHealth({
|
||||
force: true,
|
||||
allowStaleOnTimeout: true,
|
||||
timeoutMs: 1
|
||||
})
|
||||
const { backendHealthy, backendError } = useBackendHealth()
|
||||
|
||||
assert.equal(ok, true)
|
||||
assert.equal(backendHealthy.value, true)
|
||||
assert.match(backendError.value, /健康检查超时|health/i)
|
||||
} finally {
|
||||
global.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
@@ -39,7 +39,7 @@ test('semantic intent detail includes recognized expense type', () => {
|
||||
}
|
||||
]
|
||||
}),
|
||||
'已识别为报销场景,当前目标是草稿生成,费用类型为交通费'
|
||||
'已识别为报销场景,当前目标是信息核对,费用类型为交通费'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -15,6 +15,18 @@ const reimbursementService = readFileSync(
|
||||
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const reviewActionsScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementReviewActions.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const submitComposerScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const attachmentsScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementAttachments.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('review drawer tools expose the default review tab before conditional document and risk tabs', () => {
|
||||
assert.match(createViewTemplate, /title="报销识别核对"[\s\S]*@click="switchToReviewOverviewDrawer"/)
|
||||
@@ -143,3 +155,36 @@ test('review drawer save action is disabled while receipt recognition is submitt
|
||||
/function saveInlineReviewChanges\(\) \{[\s\S]*\|\| submitting\.value[\s\S]*\|\| sessionSwitchBusy\.value[\s\S]*\) return/
|
||||
)
|
||||
})
|
||||
|
||||
test('draft creation waits for composer attachments to be persisted before leaving submit state', () => {
|
||||
assert.match(
|
||||
submitComposerScript,
|
||||
/try \{\s*await syncComposerFilesToDraft\(resolvedDraftClaimId, files\)\s*\} catch \(error\) \{/s
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
submitComposerScript,
|
||||
/syncComposerFilesToDraft\(resolvedDraftClaimId, files\)\.catch/
|
||||
)
|
||||
assert.ok(
|
||||
submitComposerScript.indexOf('await syncComposerFilesToDraft(resolvedDraftClaimId, files)') <
|
||||
submitComposerScript.indexOf('submitting.value = false'),
|
||||
'attachment persistence should finish before submit state is cleared'
|
||||
)
|
||||
assert.match(attachmentsScript, /function normalizeAttachmentMatchName\(value\)/)
|
||||
assert.match(attachmentsScript, /const normalizedMatchBuckets = new Map\(\)/)
|
||||
assert.match(
|
||||
attachmentsScript,
|
||||
/const targetItem = nextExactMatch \|\| nextNormalizedMatch \|\| fallbackMatch/
|
||||
)
|
||||
})
|
||||
|
||||
test('review summary renders markdown and save draft relies on backend response only', () => {
|
||||
assert.match(
|
||||
createViewTemplate,
|
||||
/message\.text && message\.role === 'assistant' && message\.reviewPayload[\s\S]*v-html="renderMarkdown\(message\.text\)"/
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
reviewActionsScript,
|
||||
/messages\.value\.push\(\s*createMessage\('assistant', actionConfig\.successMessage/
|
||||
)
|
||||
})
|
||||
|
||||
@@ -6,8 +6,14 @@ import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
buildAiAdviceViewModel,
|
||||
buildAttachmentInsightViewModel,
|
||||
buildAttachmentRiskCards
|
||||
buildAttachmentRiskCards,
|
||||
extractRiskTagsFromText,
|
||||
resolveRiskTags,
|
||||
resolveRiskTagTone
|
||||
} from '../src/views/scripts/travelRequestDetailInsights.js'
|
||||
import {
|
||||
buildDraftBlockingIssues
|
||||
} from '../src/views/scripts/travelRequestDetailExpenseModel.js'
|
||||
|
||||
const detailViewTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/TravelRequestDetailView.vue', import.meta.url)),
|
||||
@@ -101,6 +107,24 @@ test('AI advice card splits every attachment risk point with basis and suggestio
|
||||
assert.ok(advice.riskCards.every((card) => card.suggestion.includes('费用项目调整为交通费')))
|
||||
})
|
||||
|
||||
test('risk cards carry severity and domain tags for statistics', () => {
|
||||
const hotelRisk = {
|
||||
tone: 'high',
|
||||
title: '住宿超标待说明',
|
||||
risk: '住宿标准:北京酒店 800 元/晚超出报销标准。'
|
||||
}
|
||||
const trafficRisk = {
|
||||
tone: 'medium',
|
||||
title: '交通票据提醒',
|
||||
risk: '火车票说明格式待调整。'
|
||||
}
|
||||
|
||||
assert.deepEqual(resolveRiskTags(hotelRisk), ['#high_risk', '#hotel'])
|
||||
assert.deepEqual(resolveRiskTags(trafficRisk), ['#middle_risk', '#traffic'])
|
||||
assert.equal(resolveRiskTagTone('#hotel'), 'hotel')
|
||||
assert.deepEqual(extractRiskTagsFromText('超标说明:#high_risk #hotel 原因'), ['#high_risk', '#hotel'])
|
||||
})
|
||||
|
||||
test('AI advice splits claim attachment risk flags into specific points', () => {
|
||||
const riskCards = buildAttachmentRiskCards({
|
||||
claimRiskFlags: [
|
||||
@@ -196,13 +220,23 @@ test('AI advice template renders grouped section titles with completion before r
|
||||
|
||||
test('AI advice risk section uses compact card styling hooks', () => {
|
||||
assert.match(detailViewTemplate, /class="\['risk-advice-card', card\.tone\]"/)
|
||||
assert.match(detailViewTemplate, /v-if="card\.tags\?\.length" class="risk-card-tag-list"/)
|
||||
assert.match(detailViewStyle, /\.validation-card \{\s*border: 1px solid #e5e7eb;/)
|
||||
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card \{\s*display: grid;\s*gap: 8px;\s*padding: 12px 12px 11px;/)
|
||||
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card\.low/)
|
||||
assert.match(detailViewStyle, /\.risk-advice-card\.low/)
|
||||
assert.match(detailViewStyle, /\.risk-note-tag\.high/)
|
||||
assert.match(detailViewStyle, /\.risk-note-tag\.hotel/)
|
||||
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-meta ul,\s*\.validation-section--risk \.risk-advice-meta p \{\s*margin: 0;/)
|
||||
})
|
||||
|
||||
test('expense rows show a major-risk warning icon before time', () => {
|
||||
assert.match(detailViewTemplate, /'has-major-risk': isMajorExpenseRisk\(item\)/)
|
||||
assert.match(detailViewTemplate, /class="mdi mdi-alert expense-risk-indicator"/)
|
||||
assert.match(detailViewStyle, /\.expense-risk-indicator \{/)
|
||||
assert.match(detailViewScript, /function isMajorExpenseRisk\(item\)/)
|
||||
})
|
||||
|
||||
test('AI advice shows only the latest manual return while preserving return count context', () => {
|
||||
const riskCards = buildAttachmentRiskCards({
|
||||
claimRiskFlags: [
|
||||
@@ -238,6 +272,10 @@ test('AI advice shows only the latest manual return while preserving return coun
|
||||
test('expense attachment actions keep preview as the only recognition entry point', () => {
|
||||
assert.match(detailViewTemplate, /:aria-label="resolveAttachmentPreviewTitle\(item\)"/)
|
||||
assert.match(detailViewScript, /return fileName \? `预览附件:\$\{fileName\}` : '预览附件'/)
|
||||
assert.match(detailViewScript, /\.filter\(\(item\) => canPreviewAttachment\(item\)\)/)
|
||||
assert.match(detailViewScript, /function hasStoredAttachmentReference\(item\) \{[\s\S]*return String\(item\?\.invoiceId \|\| ''\)\.includes\('\/'\)/)
|
||||
assert.match(detailViewScript, /if \(metadata\) \{[\s\S]*return metadata\.previewable !== false[\s\S]*return true/)
|
||||
assert.match(detailViewScript, /原件尚未保存到单据中,请重新上传后预览/)
|
||||
assert.doesNotMatch(detailViewTemplate, /aria-label="识别附件"/)
|
||||
assert.doesNotMatch(detailViewTemplate, /点击识别按钮/)
|
||||
assert.doesNotMatch(detailViewScript, /recognizeExpenseAttachment/)
|
||||
@@ -358,6 +396,8 @@ test('travel detail AI advice adds low risk reminders for optional receipts', ()
|
||||
test('expense detail save is blocked while attachment recognition is running', () => {
|
||||
assert.match(detailViewScript, /const uploadingExpenseId = ref\(''\)/)
|
||||
assert.match(detailViewScript, /const actionBusy = computed\(\(\) =>[\s\S]*Boolean\(uploadingExpenseId\.value\)/)
|
||||
assert.match(detailViewScript, /const canSubmit = computed\(\(\) => isEditableRequest\.value && !actionBusy\.value\)/)
|
||||
assert.match(detailViewScript, /if \(draftBlockingIssues\.value\.length\) \{[\s\S]*请先补全草稿信息,再提交审批。/)
|
||||
assert.match(
|
||||
detailViewTemplate,
|
||||
/@click="saveExpenseEdit\(item\)"[\s\S]*:disabled="actionBusy"/
|
||||
@@ -368,6 +408,36 @@ test('expense detail save is blocked while attachment recognition is running', (
|
||||
)
|
||||
})
|
||||
|
||||
test('draft submit validation uses expense detail date and amount when claim summary is stale', () => {
|
||||
const issues = buildDraftBlockingIssues(
|
||||
{
|
||||
profileName: '张三',
|
||||
typeLabel: '待补充',
|
||||
typeCode: 'office',
|
||||
reason: '待补充',
|
||||
location: '待补充',
|
||||
occurredDisplay: '待补充',
|
||||
amountValue: 0
|
||||
},
|
||||
[
|
||||
{
|
||||
id: 'item-1',
|
||||
itemDate: '2026-05-21',
|
||||
itemType: 'office',
|
||||
itemReason: '采购办公用品',
|
||||
itemLocation: '',
|
||||
itemAmount: 88,
|
||||
invoiceId: 'claim-1/item-1/office-note.png'
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert.ok(!issues.some((issue) => issue.includes('发生时间未完善')))
|
||||
assert.ok(!issues.some((issue) => issue.includes('报销金额未完善')))
|
||||
assert.ok(!issues.some((issue) => issue.includes('报销类型未完善')))
|
||||
assert.ok(!issues.some((issue) => issue.includes('报销事由未完善')))
|
||||
})
|
||||
|
||||
test('transport ticket descriptions use route format and invalid format becomes risk advice', () => {
|
||||
assert.match(detailViewScript, /const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set\(\['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'\]\)/)
|
||||
assert.match(detailViewScript, /const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set\(\['hotel_ticket'\]\)/)
|
||||
|
||||
@@ -11,6 +11,10 @@ const detailViewScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const detailExpenseModelScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/travelRequestDetailExpenseModel.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function extractFunction(source, name) {
|
||||
const signatureIndex = source.indexOf(`function ${name}(`)
|
||||
@@ -53,8 +57,21 @@ test('detail submit opens a confirmation dialog before calling submit API', () =
|
||||
assert.match(confirmSubmitRequest, /submitExpenseClaim\(request\.value\.claimId\)/)
|
||||
})
|
||||
|
||||
test('detail submit requires override reasons for high-risk claims', () => {
|
||||
assert.match(detailViewTemplate, /:open="riskOverrideDialogOpen"/)
|
||||
assert.match(detailViewTemplate, /重大风险/)
|
||||
assert.match(detailViewTemplate, /goToPreviousSubmitRisk/)
|
||||
assert.match(detailViewTemplate, /goToNextSubmitRisk/)
|
||||
assert.match(detailViewTemplate, /v-model="riskOverrideReasons\[currentSubmitRiskWarning\.id\]"/)
|
||||
assert.match(detailViewScript, /const submitRiskWarnings = computed/)
|
||||
assert.match(detailViewScript, /submitRiskWarnings\.value\.length && !hasRiskOverrideExplanation\.value/)
|
||||
assert.match(detailViewScript, /function confirmRiskOverrideReasons\(\)/)
|
||||
assert.match(detailViewScript, /updateExpenseClaim\(request\.value\.claimId,\s*\{\s*reason: nextNote/s)
|
||||
assert.match(detailViewScript, /超标说明:\$\{tags\}/)
|
||||
})
|
||||
|
||||
test('detail header and fallback progress use reimbursement wording', () => {
|
||||
assert.match(detailViewScript, /label:\s*'单据申请日期'/)
|
||||
assert.match(detailViewScript, /label:\s*'创建单据'/)
|
||||
assert.match(detailExpenseModelScript, /label:\s*'创建单据'/)
|
||||
assert.doesNotMatch(detailViewScript, /label:\s*'保存草稿'/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user