将差旅费用明细拆分为火车票、机票、住宿票、乘车等细分类 型,根据票据字段自动生成行程/事由描述,结合规则引擎自 动计算出差补贴金额,前端适配费用明细编辑和差旅票据审 核交互,补充单元测试覆盖。
313 lines
15 KiB
JavaScript
313 lines
15 KiB
JavaScript
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, /详情页退回/)
|
||
})
|