Files
X-Financial/web/tests/travel-request-detail-risk-advice.test.mjs

313 lines
15 KiB
JavaScript
Raw Normal View History

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 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('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, /详情页退回/)
})