Files
X-Financial/web/tests/travel-request-detail-risk-advice.test.mjs
caoxiaozhu f28d7e6d16 feat: 完善差旅票据行程提取与费用明细回填逻辑
增强文档智能识别的票据场景关键词和字段提取能力,优化
会话关联草稿报销单的解析路径,修复费用明细合并和票据
去重边界问题,前端改进报销创建和审批详情交互,补充单
元测试覆盖。
2026-05-21 14:24:51 +08:00

384 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
buildAiAdviceViewModel,
buildAttachmentInsightViewModel,
buildAttachmentRiskCards
} from '../src/views/scripts/travelRequestDetailInsights.js'
const detailViewTemplate = readFileSync(
fileURLToPath(new URL('../src/views/TravelRequestDetailView.vue', import.meta.url)),
'utf8'
)
const detailViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
'utf8'
)
const detailViewStyle = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view.css', import.meta.url)),
'utf8'
)
const requestsComposableScript = readFileSync(
fileURLToPath(new URL('../src/composables/useRequests.js', import.meta.url)),
'utf8'
)
const approvalCenterTemplate = readFileSync(
fileURLToPath(new URL('../src/views/ApprovalCenterView.vue', import.meta.url)),
'utf8'
)
const approvalCenterScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/ApprovalCenterView.js', import.meta.url)),
'utf8'
)
const returnReasonDialog = readFileSync(
fileURLToPath(new URL('../src/components/shared/ReturnReasonDialog.vue', import.meta.url)),
'utf8'
)
const attachmentMeta = {
file_name: 'taxi-invoice.pdf',
media_type: 'application/pdf',
previewable: true,
document_info: {
document_type: 'taxi_receipt',
document_type_label: '出租车/网约车票据',
fields: [
{ label: '金额', value: '121.54' },
{ label: '日期', value: '2026-03-04' }
]
},
requirement_check: {
matches: false,
message: '附件类型与当前费用项目不匹配。'
},
analysis: {
severity: 'high',
label: '高风险',
headline: '票据类型不匹配',
summary: '交通票据挂在办公费明细下。',
points: ['票据识别为出租车/网约车票据', '当前费用项目为办公费'],
suggestion: '把费用项目调整为交通费,或更换为办公用品票据。'
}
}
test('attachment insight exposes recognition fields and rule basis', () => {
const insight = buildAttachmentInsightViewModel(attachmentMeta, {
name: '办公费',
itemType: 'office'
})
assert.equal(insight.documentTypeLabel, '出租车/网约车票据')
assert.equal(insight.requirementLabel, '不符合当前费用类型')
assert.deepEqual(insight.fields, ['金额121.54', '日期2026-03-04'])
assert.ok(insight.ruleBasis.some((item) => item.includes('附件类型与当前费用项目不匹配')))
})
test('AI advice card splits every attachment risk point with basis and suggestion', () => {
const riskCards = buildAttachmentRiskCards({
expenseItems: [
{
id: 'item-1',
name: '办公费',
invoiceId: 'taxi-invoice.pdf'
}
],
attachmentMetaByItemId: {
'item-1': attachmentMeta
}
})
const advice = buildAiAdviceViewModel({
completionItems: [],
riskCards
})
assert.equal(riskCards.length, 2)
assert.equal(advice.badge, '优先整改')
assert.equal(advice.riskCards.length, 2)
assert.ok(advice.riskCards.every((card) => card.ruleBasis.length > 0))
assert.ok(advice.riskCards.every((card) => card.suggestion.includes('费用项目调整为交通费')))
})
test('AI advice splits claim attachment risk flags into specific points', () => {
const riskCards = buildAttachmentRiskCards({
claimRiskFlags: [
{
source: 'attachment_analysis',
severity: 'medium',
label: '中风险',
message: '费用明细第 2 条:日期字段:未识别到开票日期。',
summary: '当前附件可见部分内容,但金额、用途、日期或附件类型仍有缺失或不一致。',
points: [
'日期字段:未识别到开票日期或业务发生日期。',
'金额字段:附件识别金额 300.00 元与报销金额 88.00 元不一致。'
]
}
]
})
assert.equal(riskCards.length, 2)
assert.equal(riskCards[0].risk, '日期字段:未识别到开票日期或业务发生日期。')
assert.equal(riskCards[1].risk, '金额字段:附件识别金额 300.00 元与报销金额 88.00 元不一致。')
assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('风险汇总')))
})
test('AI advice view model exposes grouped completion and risk sections', () => {
const advice = buildAiAdviceViewModel({
completionItems: ['补充业务地点', '补充报销金额'],
riskCards: [
{
id: 'risk-1',
tone: 'high',
label: '高风险',
title: '票据类型不匹配',
risk: '交通票据挂在办公费明细下。',
ruleBasis: ['附件类型与当前费用项目不匹配。'],
suggestion: '把费用项目调整为交通费。'
}
]
})
assert.equal(advice.sections.length, 2)
assert.deepEqual(
advice.sections.map((section) => ({ title: section.title, kind: section.kind })),
[
{ title: '建议补充字段', kind: 'completion' },
{ title: '已知存在风险', kind: 'risk' }
]
)
assert.deepEqual(advice.sections[0].items, ['补充业务地点', '补充报销金额'])
assert.equal(advice.sections[1].items.length, 1)
})
test('AI advice view model omits empty sections', () => {
const completionOnlyAdvice = buildAiAdviceViewModel({
completionItems: ['补充业务地点'],
riskCards: []
})
const riskOnlyAdvice = buildAiAdviceViewModel({
completionItems: [],
riskCards: [
{
id: 'risk-1',
tone: 'medium',
label: '中风险',
title: '说明不完整',
risk: '缺少业务背景。',
ruleBasis: ['系统预审规则命中该风险提示。'],
suggestion: '补充说明。'
}
]
})
assert.deepEqual(completionOnlyAdvice.sections.map((section) => section.title), ['建议补充字段'])
assert.deepEqual(riskOnlyAdvice.sections.map((section) => section.title), ['已知存在风险'])
})
test('AI advice template renders grouped section titles with completion before risk', () => {
assert.match(detailViewTemplate, /v-if="aiAdvice\.sections\.length" class="validation-sections"/)
assert.match(detailViewTemplate, /v-for="section in aiAdvice\.sections"/)
assert.match(detailViewTemplate, /validation-section--\$\{section\.kind\}/)
assert.match(detailViewTemplate, /<h4 class="validation-section-title">\{\{ section\.title \}\}<\/h4>/)
assert.match(detailViewTemplate, /v-if="section\.kind === 'completion'" class="validation-list"/)
assert.match(detailViewTemplate, /v-else class="risk-advice-list"/)
assert.ok(
detailViewTemplate.indexOf("section.kind === 'completion'") < detailViewTemplate.indexOf('risk-advice-card')
)
})
test('AI advice risk section uses compact card styling hooks', () => {
assert.match(detailViewTemplate, /class="\['risk-advice-card', card\.tone\]"/)
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-meta ul,\s*\.validation-section--risk \.risk-advice-meta p \{\s*margin: 0;/)
})
test('AI advice shows only the latest manual return while preserving return count context', () => {
const riskCards = buildAttachmentRiskCards({
claimRiskFlags: [
{
source: 'manual_return',
severity: 'medium',
label: '人工退回',
message: '第一次退回:缺少附件。',
reason: '缺少附件。',
return_count: 1,
return_stage: '直属领导审批',
risk_points: ['附件缺失或不清晰']
},
{
source: 'manual_return',
severity: 'medium',
label: '人工退回',
message: '第二次退回:超标说明不完整。',
reason: '超标说明不完整。',
return_count: 2,
return_stage: '财务审批',
risk_points: ['超出制度标准或缺少超标说明']
}
]
})
assert.equal(riskCards.length, 1)
assert.equal(riskCards[0].risk, '第二次退回:超标说明不完整。')
assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('累计退回 2 次')))
assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('财务审批')))
})
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.doesNotMatch(detailViewTemplate, /aria-label="识别附件"/)
assert.doesNotMatch(detailViewTemplate, /点击识别按钮/)
assert.doesNotMatch(detailViewScript, /recognizeExpenseAttachment/)
assert.doesNotMatch(detailViewScript, /recognizingExpenseId/)
})
test('expense detail table shows the amount total below detail rows', () => {
assert.match(detailViewTemplate, /<div class="detail-expense-table">/)
assert.match(detailViewTemplate, /当前还没有费用明细/)
assert.doesNotMatch(detailViewTemplate, /class="total-row"/)
assert.match(detailViewTemplate, /class="expense-total-under-table"[\s\S]*金额合计[\s\S]*\{\{ expenseTotal \}\}/)
assert.doesNotMatch(detailViewTemplate, /\{\{ uploadedExpenseCount \}\} 项已关联票据/)
assert.doesNotMatch(detailViewTemplate, /\{\{ expenseSummaryText \}\}/)
})
test('additional note is shown above expense details as travel purpose text', () => {
assert.ok(detailViewTemplate.indexOf('<h3>附加说明</h3>') < detailViewTemplate.indexOf('<h3>费用明细</h3>'))
assert.match(detailViewTemplate, /用于说明本次出差或办事目的/)
assert.match(detailViewTemplate, /v-if="canEditDetailNote" class="detail-note-editor"/)
assert.match(detailViewTemplate, /v-else class="detail-note readonly"/)
assert.match(detailViewTemplate, /v-model="detailNoteEditor"/)
assert.match(detailViewTemplate, /提交后将作为明确说明展示/)
assert.match(detailViewScript, /const canEditDetailNote = computed\(\(\) => isDraftRequest\.value\)/)
assert.match(detailViewScript, /function normalizeDetailNoteDraftValue\(value\)/)
assert.match(detailViewScript, /const detailNoteSource = computed\(\(\) => normalizeDetailNoteDraftValue\(request\.value\.note\)\)/)
assert.match(detailViewScript, /updateExpenseClaim\(request\.value\.claimId/)
assert.match(detailViewScript, /emit\('request-updated', \{ claimId: request\.value\.claimId \}\)/)
assert.match(detailViewScript, /暂无附加说明。请补充本次出差或办事事由/)
assert.match(detailViewScript, /去北京客户现场出差,拜访 XX 客户并处理项目验收事项/)
assert.match(detailViewStyle, /\.detail-note-editor textarea/)
assert.match(detailViewStyle, /\.detail-note\.readonly/)
})
test('ticket item types and system allowance row are visible but read only', () => {
assert.match(detailViewScript, /value: 'train_ticket', label: '火车票'/)
assert.match(detailViewScript, /value: 'flight_ticket', label: '机票'/)
assert.match(detailViewScript, /value: 'hotel_ticket', label: '住宿票'/)
assert.match(detailViewScript, /value: 'ride_ticket', label: '乘车'/)
assert.match(detailViewScript, /value: 'travel_allowance', label: '出差补贴'/)
assert.match(detailViewScript, /const SYSTEM_GENERATED_EXPENSE_TYPES = new Set\(\['travel_allowance'\]\)/)
assert.match(detailViewTemplate, /'system-generated-row': item\.isSystemGenerated/)
assert.match(detailViewTemplate, /v-if="item\.isSystemGenerated" class="system-row-lock"/)
assert.match(detailViewTemplate, /v-if="item\.isSystemGenerated" class="system-attachment-note"/)
assert.match(detailViewScript, /系统自动计算的补贴行不能手动编辑/)
assert.match(detailViewScript, /系统自动计算的补贴行不能删除/)
})
test('travel item date caption distinguishes departure return and trip events', () => {
assert.match(detailViewTemplate, /<span>\{\{ item\.dayLabel \}\}<\/span>/)
assert.match(detailViewScript, /const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set\(\['train_ticket', 'flight_ticket'\]\)/)
assert.match(detailViewScript, /function buildTravelTimeLabelMap\(items, requestModel\)/)
assert.match(detailViewScript, /labels\.set\(item\.id, '出发时间'\)/)
assert.match(detailViewScript, /labels\.set\(item\.id, '返回时间'\)/)
assert.match(detailViewScript, /return '乘车时间'/)
assert.match(detailViewScript, /return '住宿时间'/)
assert.match(requestsComposableScript, /function buildTravelTimeLabelMap\(items, claim\)/)
assert.match(requestsComposableScript, /return claim\?\.expense_type === 'travel' \? '出行时间' : '业务发生时间'/)
assert.doesNotMatch(detailViewScript, /第 \$\{index \+ 1\} 项/)
})
test('expense detail table shows each item filled time from item creation time', () => {
assert.match(detailViewTemplate, /<th class="col-filled-at">填写时间<\/th>/)
assert.match(detailViewTemplate, /<td class="expense-filled-at col-filled-at">[\s\S]*\{\{ item\.filledAt \}\}/)
assert.match(detailViewTemplate, /<span>条款填写时间<\/span>/)
assert.match(detailViewScript, /function formatExpenseFilledTime\(value\)/)
assert.match(detailViewScript, /source\?\.filledAt[\s\S]*source\?\.created_at/)
assert.match(detailViewScript, /expenseTableColumnCount = computed\(\s*\(\) => 6 \+ \(isEditableRequest\.value \? 1 : 0\)/)
assert.match(requestsComposableScript, /filledAt: formatDateTime\(item\?\.created_at\) \|\| '待同步'/)
})
test('expense item upload remains limited to one receipt per detail row', () => {
assert.match(detailViewTemplate, /ref="expenseUploadInput"[\s\S]*type="file"/)
assert.doesNotMatch(detailViewTemplate, /\bmultiple\b/)
assert.equal(
(detailViewTemplate.match(/v-if="isEditableRequest && !item\.invoiceId && !item\.isSystemGenerated"/g) || []).length,
2
)
assert.match(detailViewScript, /const attachments = invoiceId \? \[attachmentName \|\| invoiceId\] : \[\]/)
assert.match(detailViewScript, /attachmentStatus: isSystemGenerated \? '无需附件' : attachments\.length \? '已关联票据' : '未上传'/)
assert.match(detailViewScript, /if \(item\?\.invoiceId\) \{[\s\S]*每条费用明细只能关联一张单据/)
assert.match(detailViewScript, /const fileCount = fileList\?\.length \|\| 0/)
assert.match(detailViewScript, /fileCount > 1[\s\S]*一条费用明细只能上传一张单据/)
})
test('expense item upload patches OCR amount into the visible detail row', () => {
assert.match(detailViewScript, /const recognizedItemAmount = Number\(payload\?\.item_amount \?\? payload\?\.itemAmount\)/)
assert.match(detailViewScript, /itemPatch\.itemAmount = recognizedItemAmount/)
assert.match(detailViewScript, /itemPatch\.amount = formatCurrency\(recognizedItemAmount\)/)
assert.match(detailViewScript, /expenseEditor\.itemAmount = String\(recognizedItemAmount\)/)
})
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(
detailViewTemplate,
/@click="saveExpenseEdit\(item\)"[\s\S]*:disabled="actionBusy"/
)
assert.match(
detailViewScript,
/if \(actionBusy\.value\) \{[\s\S]*toast\(uploadingExpenseId\.value \? '附件识别中,请等待识别完成后再保存。' : '当前操作处理中,请稍后再保存。'\)[\s\S]*return/
)
})
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', 'ride_ticket'\]\)/)
assert.match(detailViewScript, /const ROUTE_DESCRIPTION_PATTERN = \/\^\[A-Za-z0-9\\u4e00-\\u9fa5/)
assert.match(detailViewScript, /return isRouteDescriptionExpenseType\(itemType\) \? '始发地-目的地,例如:广州南-北京南' : '输入费用说明'/)
assert.match(detailViewScript, /return isRouteDescriptionExpenseType\(itemType\) \? '始发地-目的地' : '业务报销说明'/)
assert.match(
detailViewScript,
/isRouteDescriptionExpenseType\(item\.itemType\) && !isValidRouteDescription\(item\.itemReason\)[\s\S]*issues\.push\('行程说明格式错误'\)/
)
assert.match(
detailViewScript,
/isRouteDescriptionExpenseType\(expenseEditor\.itemType\)[\s\S]*!isValidRouteDescription\(expenseEditor\.itemReason\)[\s\S]*return '行程说明格式应为“始发地-目的地”,例如:广州南-北京南。'/
)
assert.match(
detailViewScript,
/fieldText === '行程说明格式错误'[\s\S]*return `\$\{labelPrefix\}的行程说明,格式应为“始发地-目的地”。`/
)
})
test('transport ticket items no longer generate business location completion advice', () => {
const locationRequiredBlock = detailViewScript.match(/const LOCATION_REQUIRED_EXPENSE_TYPES = new Set\(\[[\s\S]*?\]\)/)?.[0] || ''
assert.match(locationRequiredBlock, /'travel'/)
assert.match(locationRequiredBlock, /'meeting'/)
assert.match(locationRequiredBlock, /'entertainment'/)
assert.doesNotMatch(locationRequiredBlock, /'train_ticket'/)
assert.doesNotMatch(locationRequiredBlock, /'flight_ticket'/)
assert.doesNotMatch(locationRequiredBlock, /'ride_ticket'/)
assert.match(
detailViewScript,
/const locationRequired = isLocationRequiredExpenseType\(item\.itemType\)[\s\S]*if \(locationRequired && isPlaceholderValue\(item\.itemLocation\)\) \{[\s\S]*issues\.push\('缺少地点'\)/
)
assert.doesNotMatch(detailViewScript, /完善第 1 条费用明细的业务地点/)
})
test('return reason dialog is wired into approval and detail return actions', () => {
assert.match(returnReasonDialog, /missing_attachment/)
assert.match(returnReasonDialog, /invoice_mismatch/)
assert.match(returnReasonDialog, /reason_codes/)
assert.match(approvalCenterTemplate, /<TravelRequestDetailView/)
assert.doesNotMatch(approvalCenterTemplate, /<ReturnReasonDialog/)
assert.match(detailViewTemplate, /<ReturnReasonDialog/)
assert.doesNotMatch(approvalCenterScript, /returnExpenseClaim/)
assert.match(detailViewScript, /returnExpenseClaim\(request\.value\.claimId, payload\)/)
assert.doesNotMatch(approvalCenterScript, /审批中心退回/)
assert.doesNotMatch(detailViewScript, /详情页退回/)
})