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

1195 lines
54 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,
buildClaimSummaryRiskCards,
buildItemClaimRiskState,
extractRiskTagsFromText,
filterRiskCardsByBusinessStage,
resolveRiskTags,
resolveRiskTagTone
} from '../src/views/scripts/travelRequestDetailInsights.js'
import {
buildExpenseItemViewModel,
buildDraftBlockingIssues,
buildStandardAdjustmentMap,
isApplicationDocumentRequest
} from '../src/views/scripts/travelRequestDetailExpenseModel.js'
import {
buildEmployeeProfileAdviceItems,
buildTravelReceiptMaterialPrompts
} from '../src/views/scripts/travelRequestDetailAdviceModel.js'
import {
filterSubmitterResolvedRiskCards
} from '../src/views/scripts/travelRequestDetailStandardAdjustment.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 detailViewInsights = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelRequestDetailInsights.js', import.meta.url)),
'utf8'
)
const detailExpenseModelScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelRequestDetailExpenseModel.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 stageRiskAdviceCard = readFileSync(
fileURLToPath(new URL('../src/components/travel/StageRiskAdviceCard.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('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: [
{
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 keeps visible risk flags when backend uses tone instead of severity', () => {
const riskCards = buildAttachmentRiskCards({
claimRiskFlags: [
{
source: 'submission_review',
tone: 'medium',
label: '中风险',
message: '直属领导缺失,当前单据需审批环节补充分配。'
}
]
})
assert.equal(riskCards.length, 1)
assert.equal(riskCards[0].tone, 'medium')
assert.equal(riskCards[0].risk, '直属领导缺失,当前单据需审批环节补充分配。')
assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('审批链校验')))
assert.ok(riskCards[0].suggestion.includes('员工档案'))
})
test('risk card badge only shows severity while title keeps business risk name', () => {
const riskCards = buildAttachmentRiskCards({
claimRiskFlags: [
{
source: 'attachment_analysis',
severity: 'high',
label: '票据日期超出差旅行程高风险',
message: '酒店发票日期为 2 月,晚于已批准 6 月差旅行程范围。'
}
]
})
assert.equal(riskCards.length, 1)
assert.equal(riskCards[0].tone, 'high')
assert.equal(riskCards[0].label, '高风险')
assert.equal(riskCards[0].title, '票据日期超出差旅行程高风险')
})
test('AI advice falls back to claim risk summary instead of showing an empty risk area', () => {
const riskCards = buildClaimSummaryRiskCards({
riskSummary: '自动检测发现 1 条中风险附件,已随单流转给审批人复核。'
})
assert.equal(riskCards.length, 1)
assert.equal(riskCards[0].businessStage, 'reimbursement')
assert.equal(riskCards[0].tone, 'medium')
assert.equal(riskCards[0].label, '中风险')
assert.match(riskCards[0].risk, /中风险附件/)
assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('风险汇总')))
assert.ok(riskCards[0].suggestion.includes('附件预览'))
})
test('risk cards carry structured business stage for approval advice filtering', () => {
const applicationCards = buildAttachmentRiskCards({
businessStage: 'expense_application',
claimRiskFlags: [
{
source: 'policy_review',
severity: 'medium',
label: '预算风险',
message: '申请金额可能占用预算余额,需要预算管理者复核。'
}
]
})
const reimbursementCards = buildAttachmentRiskCards({
businessStage: 'expense_application',
claimRiskFlags: [
{
source: 'attachment_analysis',
business_stage: 'reimbursement',
severity: 'high',
label: '票据风险',
message: '报销票据城市与行程城市不一致。'
}
]
})
const legacyAttachmentCards = buildAttachmentRiskCards({
businessStage: 'expense_application',
claimRiskFlags: [
{
source: 'attachment_analysis',
severity: 'medium',
label: '附件风险',
message: '差旅附件暂未识别到有效票据信息,请重新上传清晰附件或人工补录。'
}
]
})
const summaryCards = buildClaimSummaryRiskCards({
documentTypeCode: 'application',
riskSummary: '预算余额不足,申请需要复核。'
})
const attachmentSummaryCards = buildClaimSummaryRiskCards({
documentTypeCode: 'application',
businessStage: 'expense_application',
riskSummary: '差旅附件暂未识别到有效票据信息,请重新上传清晰附件或人工补录。'
})
assert.equal(applicationCards.length, 1)
assert.equal(applicationCards[0].businessStage, 'expense_application')
assert.equal(reimbursementCards.length, 1)
assert.equal(reimbursementCards[0].businessStage, 'reimbursement')
assert.equal(legacyAttachmentCards.length, 1)
assert.equal(legacyAttachmentCards[0].businessStage, 'reimbursement')
assert.deepEqual(
filterRiskCardsByBusinessStage(
[...applicationCards, ...reimbursementCards, ...legacyAttachmentCards],
'expense_application'
).map((card) => card.risk),
['申请金额可能占用预算余额,需要预算管理者复核。']
)
assert.equal(summaryCards.length, 1)
assert.equal(summaryCards[0].businessStage, 'expense_application')
assert.equal(attachmentSummaryCards.length, 1)
assert.equal(attachmentSummaryCards[0].businessStage, 'reimbursement')
assert.deepEqual(filterRiskCardsByBusinessStage(attachmentSummaryCards, 'expense_application'), [])
})
test('stage risk advice card exposes direct reviewer action suggestion', () => {
assert.match(stageRiskAdviceCard, /class="employee-risk-action"/)
assert.match(stageRiskAdviceCard, /\{\{ decisionAction \}\}/)
assert.match(stageRiskAdviceCard, /compactEvidenceItems/)
assert.match(stageRiskAdviceCard, /compactAdviceItems/)
assert.ok(
stageRiskAdviceCard.indexOf('class="employee-risk-advice-list"')
< stageRiskAdviceCard.indexOf('class="employee-risk-action"')
)
assert.match(stageRiskAdviceCard, /employee-risk-tone-pill/)
assert.match(stageRiskAdviceCard, /\.employee-risk-ai-note \{[\s\S]*grid-template-columns: minmax\(0, 1fr\);/)
assert.match(stageRiskAdviceCard, /\.employee-risk-action \{[\s\S]*align-items: center;[\s\S]*justify-content: center;[\s\S]*text-align: center;/)
assert.match(stageRiskAdviceCard, /建议退回补充票据、行程说明或超标原因/)
assert.match(stageRiskAdviceCard, /可按权限继续审批/)
})
test('AI advice ignores approval opinions and flow logs as risks', () => {
const riskCards = buildAttachmentRiskCards({
claimRiskFlags: [
{
source: 'manual_approval',
severity: 'info',
label: '领导审批通过',
message: '同意'
},
{
source: 'finance_approval',
severity: 'info',
label: '财务审核通过',
message: '周晓彤 已完成财务审核,进入归档入账。'
}
]
})
assert.deepEqual(riskCards, [])
assert.deepEqual(buildClaimSummaryRiskCards({ riskSummary: '同意' }), [])
assert.deepEqual(buildClaimSummaryRiskCards({ riskSummary: '周晓彤 已完成财务审核,进入归档入账。' }), [])
})
test('expense row risk state falls back to claim item risk flags', () => {
const state = buildItemClaimRiskState(
{
id: 'hotel-item',
name: '住宿费'
},
[
{
source: 'attachment_analysis',
item_id: 'hotel-item',
severity: 'high',
label: '高风险',
message: '费用明细第 2 条:住宿标准:当前酒店识别金额约 880.00 元/晚。',
summary: '当前住宿票据金额超过规则中心差旅住宿标准。',
points: ['住宿标准:当前酒店识别金额约 880.00 元/晚。']
}
]
)
assert.equal(state.tone, 'high')
assert.equal(state.label, '高风险')
assert.match(state.summary, /住宿票据金额超过/)
assert.deepEqual(state.points, ['住宿标准:当前酒店识别金额约 880.00 元/晚。'])
})
test('attachment risk cards do not duplicate claim fallback flags for the same item', () => {
const riskCards = buildAttachmentRiskCards({
expenseItems: [
{
id: 'hotel-item',
name: '住宿费',
invoiceId: 'hotel-risk.png'
}
],
attachmentMetaByItemId: {
'hotel-item': {
analysis: {
severity: 'high',
label: '高风险',
headline: 'AI提示住宿金额超出报销标准',
summary: '当前住宿票据金额超过规则中心差旅住宿标准。',
points: ['住宿标准:当前酒店识别金额约 880.00 元/晚。'],
suggestion: '请补充超标说明。'
}
}
},
claimRiskFlags: [
{
source: 'attachment_analysis',
item_id: 'hotel-item',
severity: 'high',
label: '高风险',
message: '费用明细第 1 条:住宿标准:当前酒店识别金额约 880.00 元/晚。',
summary: '当前住宿票据金额超过规则中心差旅住宿标准。',
points: ['住宿标准:当前酒店识别金额约 880.00 元/晚。']
}
]
})
assert.equal(riskCards.length, 1)
assert.equal(riskCards[0].risk, '住宿标准:当前酒店识别金额约 880.00 元/晚。')
})
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: '已知存在风险1项', kind: 'risk' }
]
)
assert.deepEqual(advice.sections[0].items, ['补充业务地点', '补充报销金额'])
assert.equal(advice.sections[1].items.length, 1)
})
test('AI advice view model sorts and displays every risk card', () => {
const riskCards = [
{ id: 'risk-1', tone: 'medium', label: '中风险', title: '中风险一', risk: '中风险一。' },
{ id: 'risk-2', tone: 'high', label: '高风险', title: '高风险一', risk: '高风险一。' },
{ id: 'risk-3', tone: 'medium', label: '中风险', title: '中风险二', risk: '中风险二。' },
{ id: 'risk-4', tone: 'high', label: '高风险', title: '高风险二', risk: '高风险二。' }
]
const advice = buildAiAdviceViewModel({
completionItems: [],
riskCards
})
const riskSection = advice.sections.find((section) => section.kind === 'risk')
assert.equal(advice.riskCards.length, 4)
assert.equal(riskSection.items.length, 4)
assert.equal(riskSection.hiddenCount, undefined)
assert.deepEqual(riskSection.items.map((item) => item.id), ['risk-2', 'risk-4', 'risk-1', 'risk-3'])
})
test('AI advice view model omits empty sections', () => {
const readyAdvice = buildAiAdviceViewModel({
completionItems: [],
riskCards: []
})
const completionOnlyAdvice = buildAiAdviceViewModel({
completionItems: ['补充业务地点'],
riskCards: []
})
const riskOnlyAdvice = buildAiAdviceViewModel({
completionItems: [],
riskCards: [
{
id: 'risk-1',
tone: 'medium',
label: '中风险',
title: '说明不完整',
risk: '缺少业务背景。',
ruleBasis: ['系统预审规则命中该风险提示。'],
suggestion: '补充说明。'
}
]
})
assert.deepEqual(readyAdvice.sections, [])
assert.equal(readyAdvice.badge, '可以提交')
assert.deepEqual(completionOnlyAdvice.sections.map((section) => section.title), ['建议补充字段'])
assert.deepEqual(riskOnlyAdvice.sections.map((section) => section.title), ['已知存在风险1项'])
})
test('AI advice separates material prompts and profile advice from risk cards', () => {
const advice = buildAiAdviceViewModel({
completionItems: [],
materialPrompts: ['当前包含 1 条住宿费用明细,但暂未关联住宿发票或酒店水单。'],
profileAdviceItems: ['历史退单建议:近 90 天存在 1 次退单或退回记录。'],
riskCards: []
})
assert.equal(advice.riskCards.length, 0)
assert.equal(advice.badge, '建议关注')
assert.deepEqual(
advice.sections.map((section) => ({ kind: section.kind, title: section.title })),
[
{ kind: 'material', title: '材料补充提示' },
{ kind: 'profile', title: '历史操作建议' }
]
)
})
test('AI advice template renders grouped section titles with completion before risk', () => {
assert.match(detailViewTemplate, /v-if="showAiAdvicePanel" class="detail-card panel validation-card"/)
assert.match(detailViewTemplate, /<h3>\{\{ aiAdviceTitle \}\}<\/h3>/)
assert.match(detailViewTemplate, /<p v-if="aiAdviceHint">\{\{ aiAdviceHint \}\}<\/p>/)
assert.doesNotMatch(detailViewScript, /AI预审已完成请按风险提示补充原因或进入下一步。/)
assert.match(detailViewScript, /businessStage: currentBusinessStage/)
assert.match(detailViewScript, /filterRiskCardsByBusinessStage/)
assert.match(detailViewScript, /const summaryRiskCards = filterRiskCardsByBusinessStage/)
assert.match(detailViewScript, /buildClaimSummaryRiskCards\(\{/)
assert.match(detailViewScript, /const canViewApprovalRiskAdvice = computed/)
assert.match(detailViewScript, /!isCurrentApplicant\.value/)
assert.match(detailViewScript, /const hasVisibleRiskCards = computed/)
assert.match(detailViewScript, /const showCompactSafeAdvice = computed/)
assert.match(detailViewScript, /const showAiAdvicePanel = computed\(\(\) => \(/)
assert.match(detailViewScript, /isCurrentApplicant\.value && !isApplicationDocument\.value && hasVisibleRiskCards\.value/)
assert.match(detailViewScript, /return '报销风险提示'/)
assert.match(detailViewScript, /canViewApprovalRiskAdvice\.value && aiAdvice\.value\.riskCards\.length > 0/)
assert.match(detailViewScript, /buildTravelReceiptMaterialPrompts\(request\.value, expenseItems\.value\)/)
assert.match(detailViewScript, /buildEmployeeProfileAdviceItems\(employeeRiskProfile\.value\)/)
assert.match(detailViewScript, /fetchEmployeeLatestProfile\(employeeId/)
assert.doesNotMatch(detailViewScript, /hasAiPreReviewResult\.value/)
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 !== 'risk'" class="validation-list"/)
assert.match(detailViewTemplate, /v-else class="risk-advice-list"/)
assert.ok(
detailViewTemplate.indexOf("section.kind !== 'risk'") < detailViewTemplate.indexOf('risk-advice-card')
)
})
test('AI advice risk section uses compact card styling hooks', () => {
assert.match(detailViewTemplate, /class="\['risk-advice-card', card\.tone, \{ 'is-highlighted': isHighlightedRiskCard\(card\) \}\]"/)
assert.match(detailViewTemplate, /class="risk-advice-compact-meta"/)
assert.doesNotMatch(detailViewTemplate, /section\.hiddenCount/)
assert.doesNotMatch(detailViewTemplate, /risk-advice-more/)
assert.doesNotMatch(detailViewTemplate, /card\.tags\?\.length/)
assert.doesNotMatch(detailViewTemplate, /risk-card-tag-list/)
assert.doesNotMatch(detailViewTemplate, /risk-note-tag/)
assert.match(detailViewScript, /tags: resolveRiskTags\(card\)/)
assert.match(detailViewInsights, /const sortedRiskCards = sortRiskCardsByTone\(normalizedRiskCards\)/)
assert.doesNotMatch(detailViewInsights, /visibleRiskCards/)
assert.doesNotMatch(detailViewInsights, /hiddenCount/)
assert.match(detailViewStyle, /\.validation-card \{\s*border: 1px solid #e5e7eb;/)
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-list \{\s*display: grid;\s*gap: 8px;\s*max-height: 360px;/)
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card \{\s*position: relative;\s*display: grid;\s*grid-template-columns: minmax\(0, 1\.1fr\) minmax\(220px, \.9fr\);/)
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card\.low/)
assert.match(detailViewStyle, /\.risk-advice-card\.low/)
assert.doesNotMatch(detailViewStyle, /\.risk-note-tag/)
assert.match(detailViewStyle, /\.risk-advice-compact-meta span,\s*\.risk-advice-compact-meta em \{\s*margin: 0;/)
assert.doesNotMatch(detailViewStyle, /\.risk-advice-more/)
})
test('expense rows show a major-risk warning icon before time', () => {
assert.match(detailViewTemplate, /'has-major-risk': hasExpenseRiskIndicator\(item\)/)
assert.match(detailViewTemplate, /class="expense-risk-indicator"/)
assert.match(detailViewTemplate, /@click="focusExpenseRisk\(item\)"/)
assert.match(detailViewStyle, /\.expense-risk-indicator \{/)
assert.match(detailViewScript, /function hasExpenseRiskIndicator\(item\)/)
assert.match(detailViewScript, /buildItemClaimRiskState\(item, resolveClaimRiskFlags\(\)\)/)
})
test('expense risk indicator can focus and flash related risk card', () => {
assert.match(detailViewTemplate, /:id="resolveRiskCardDomId\(card\)"/)
assert.match(detailViewTemplate, /:data-risk-card-id="card\.id"/)
assert.match(detailViewTemplate, /'is-highlighted': isHighlightedRiskCard\(card\)/)
assert.match(detailViewScript, /async function focusExpenseRisk\(item\)/)
assert.match(detailViewScript, /document\.getElementById\(resolveRiskCardDomId\(card\)\)/)
assert.match(detailViewScript, /scrollIntoView\(\{ behavior: 'smooth', block: 'center' \}\)/)
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card\.is-highlighted/)
assert.match(detailViewStyle, /@keyframes risk-card-flash/)
})
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.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/)
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.match(detailViewTemplate, /通过智能录入上传票据后由系统自动归集/)
assert.doesNotMatch(detailViewTemplate, /增加明细/)
assert.doesNotMatch(detailViewTemplate, /handleAddExpenseItem/)
assert.doesNotMatch(detailViewScript, /handleAddExpenseItem/)
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('related application information is shown above expense details for reimbursement check', () => {
assert.ok(
detailViewTemplate.indexOf('<h3>关联单据信息</h3>')
< detailViewTemplate.indexOf("isApplicationDocument ? '申请详情' : '费用明细'")
)
assert.match(detailViewTemplate, /<article v-if="!isApplicationDocument" class="detail-card panel">/)
assert.match(detailViewTemplate, /展示本次报销关联的前置申请/)
assert.match(detailViewTemplate, /relatedApplicationFactItems/)
assert.match(detailViewTemplate, /暂未识别到关联申请单/)
assert.match(detailViewScript, /buildRelatedApplicationFactItems/)
assert.match(requestsComposableScript, /const RELATED_APPLICATION_STEP_LABEL = '关联单据'/)
assert.match(requestsComposableScript, /const ARCHIVED_STEP_LABEL = '已归档'/)
assert.match(detailViewStyle, /\.related-application-empty/)
assert.doesNotMatch(detailViewTemplate, /v-if="canEditDetailNote" class="detail-note-editor"/)
assert.doesNotMatch(detailViewTemplate, /v-model="detailNoteEditorView"/)
})
test('detail note model is retained for risk override persistence', () => {
assert.match(detailViewScript, /const canEditDetailNote = computed\(\(\) => isDraftRequest\.value\)/)
assert.match(detailViewScript, /function normalizeDetailNoteDraftValue\(value\)/)
assert.match(detailViewScript, /function stripRiskTagsForDisplay\(value\)/)
assert.match(detailViewScript, /function mergeVisibleNoteWithHiddenTags\(visibleText, rawText\)/)
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(detailExpenseModelScript, /const OPTIONAL_ATTACHMENT_EXPENSE_TYPES = new Set\(\['ride_ticket', '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-else-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>[\s\S]*<th class="col-time">发生时间<\/th>/)
assert.match(detailViewTemplate, /<td class="expense-filled-at col-filled-at">[\s\S]*\{\{ item\.filledAt \}\}[\s\S]*<td :class="\['expense-time col-time'/)
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*\(\) => 7 \+ \(isEditableRequest\.value \? 1 : 0\)/)
assert.match(requestsComposableScript, /filledAt: formatDateTime\(item\?\.created_at\) \|\| '待同步'/)
})
test('expense detail table has per-item risk explanation column', () => {
assert.match(detailViewTemplate, /<th class="col-risk-note">异常说明<\/th>/)
assert.match(detailViewTemplate, /v-model="expenseEditor\.itemNote"/)
assert.match(detailViewTemplate, /class="editor-textarea risk-note-editor-textarea"[\s\S]*rows="1"/)
assert.match(detailViewTemplate, /@input="resizeExpenseNoteInput"/)
assert.match(detailViewStyle, /\.risk-note-editor-textarea[\s\S]*max-height: 78px/)
assert.match(detailViewTemplate, /hasExpenseRiskOrAbnormal\(item\)[\s\S]*待补充异常说明/)
assert.match(detailViewScript, /itemNote: ''/)
assert.match(detailViewScript, /expenseEditor\.itemNote = item\.itemNote \|\| ''/)
assert.match(detailViewScript, /item_note: expenseEditor\.itemNote\.trim\(\)/)
assert.match(detailViewScript, /itemNote: expenseEditor\.itemNote\.trim\(\)/)
assert.match(detailViewScript, /function hasExpenseRiskOrAbnormal\(item\)/)
assert.match(detailExpenseModelScript, /const itemNote = String\(source\?\.itemNote \?\? source\?\.item_note \?\? ''\)\.trim\(\)/)
assert.match(requestsComposableScript, /const itemNote = String\(item\?\.item_note \|\| item\?\.itemNote \|\| ''\)\.trim\(\)/)
})
test('expense detail shows standard-adjusted reimbursable amount separately from receipt amount', () => {
assert.match(detailViewTemplate, /v-if="item\.hasStandardAdjustment" class="expense-adjusted-amount"/)
assert.match(detailViewTemplate, /class="expense-original-amount"[\s\S]*item\.originalAmountDisplay/)
assert.match(detailViewTemplate, /class="expense-reimbursable-amount"[\s\S]*item\.reimbursableAmountDisplay/)
assert.match(detailViewTemplate, /submitConfirmAmountDisplay/)
assert.match(detailViewStyle, /\.expense-original-amount[\s\S]*text-decoration-line: line-through/)
assert.match(detailViewScript, /const expenseTotal = computed\(\(\) => \{[\s\S]*item\.reimbursableAmount/)
assert.match(detailViewScript, /filterSubmitterResolvedRiskCards/)
assert.doesNotMatch(detailViewScript, /filterSubmitterStandardAdjustedRiskCards/)
assert.match(detailViewScript, /acceptExpenseClaimStandardAdjustment/)
assert.match(detailExpenseModelScript, /STANDARD_ADJUSTMENT_RISK_SOURCE = 'reimbursement_standard_adjustment'/)
assert.match(requestsComposableScript, /STANDARD_ADJUSTMENT_RISK_SOURCE = 'reimbursement_standard_adjustment'/)
assert.match(requestsComposableScript, /const visibleExpenseAmount = expenseItems\.reduce[\s\S]*item\.reimbursableAmount/)
const riskFlags = [
{
source: 'reimbursement_standard_adjustment',
item_id: 'expense-item-1',
original_amount: '880.00',
reimbursable_amount: '600.00',
employee_absorbed_amount: '280.00'
}
]
const adjustmentMap = buildStandardAdjustmentMap({ riskFlags })
assert.equal(adjustmentMap.get('expense-item-1').reimbursableAmount, 600)
const item = buildExpenseItemViewModel(
{
id: 'expense-item-1',
itemType: 'hotel_ticket',
itemReason: '北京住宿',
itemAmount: 880,
invoiceId: 'hotel.pdf'
},
0,
{ riskFlags }
)
assert.equal(item.itemAmount, 880)
assert.equal(item.reimbursableAmount, 600)
assert.equal(item.employeeAbsorbedAmount, 280)
assert.equal(item.hasStandardAdjustment, true)
})
test('standard adjustment resolves submitter risk prompt only after accepted while reviewer still sees notice', () => {
const originalRiskCard = {
id: 'risk-hotel-1',
source: 'attachment_analysis',
itemId: 'expense-item-1',
tone: 'high',
risk: '住宿票据金额超过职级标准。'
}
const reviewerNoticeCard = {
id: 'standard-adjustment-1',
source: 'reimbursement_standard_adjustment',
itemId: 'expense-item-1',
tone: 'medium',
risk: '提交人已选择按职级标准报销,超出部分由员工自担。'
}
const expenseItems = [{ id: 'expense-item-1' }]
const standardAdjustmentMap = buildStandardAdjustmentMap({
riskFlags: [
{
source: 'reimbursement_standard_adjustment',
item_id: 'expense-item-1',
original_amount: '880.00',
reimbursable_amount: '600.00',
employee_absorbed_amount: '280.00'
}
]
})
assert.deepEqual(
filterSubmitterResolvedRiskCards({
cards: [originalRiskCard],
businessStage: 'reimbursement',
isCurrentApplicant: true,
expenseItems,
standardAdjustmentMap: new Map()
}),
[originalRiskCard]
)
assert.deepEqual(
filterSubmitterResolvedRiskCards({
cards: [originalRiskCard],
businessStage: 'reimbursement',
isCurrentApplicant: true,
expenseItems,
standardAdjustmentMap
}),
[]
)
assert.deepEqual(
filterSubmitterResolvedRiskCards({
cards: [originalRiskCard, reviewerNoticeCard],
businessStage: 'reimbursement',
isCurrentApplicant: false,
expenseItems,
standardAdjustmentMap
}),
[originalRiskCard, reviewerNoticeCard]
)
})
test('expense item upload remains limited to one receipt per detail row', () => {
assert.match(detailViewTemplate, /ref="expenseUploadInput"[\s\S]*type="file"/)
assert.doesNotMatch(
detailViewTemplate,
/ref="expenseUploadInput"[\s\S]*\bmultiple\b[\s\S]*@change="handleExpenseFileChange"/
)
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('detail smart entry confirms receipt upload before running recognition', () => {
assert.match(detailViewTemplate, /@click="triggerSmartEntryUpload"/)
assert.match(detailViewTemplate, /ref="smartEntryUploadInput"[\s\S]*\bmultiple\b[\s\S]*@change="handleSmartEntryFileChange"/)
assert.match(detailViewTemplate, /:open="smartEntryUploadDialogOpen"/)
assert.match(detailViewTemplate, /v-if="smartEntryRecognitionBusy" class="expense-recognition-banner"/)
assert.match(detailViewTemplate, /uploadingExpenseId === item\.id" class="system-attachment-note pending"/)
assert.match(detailViewTemplate, /title="上传报销附件"/)
assert.match(detailViewTemplate, /@click="chooseSmartEntryFile"/)
assert.match(detailViewTemplate, /@click="clearSmartEntryFile"/)
assert.match(detailViewTemplate, /@confirm="confirmSmartEntryUpload"/)
assert.match(detailViewScript, /const smartEntryUploadDialogOpen = ref\(false\)/)
assert.match(detailViewScript, /const smartEntryRecognitionBusy = ref\(false\)/)
assert.match(detailViewScript, /const actionBusy = computed\(\(\) =>[\s\S]*smartEntryRecognitionBusy\.value/)
assert.match(detailViewScript, /const smartEntrySelectedFiles = ref\(\[\]\)/)
assert.match(detailViewScript, /function triggerSmartEntryUpload\(\)[\s\S]*smartEntryUploadDialogOpen\.value = true/)
assert.match(detailViewScript, /function handleSmartEntryFileChange\(event\)/)
assert.match(detailViewScript, /smartEntrySelectedFiles\.value = files/)
assert.match(detailViewScript, /function startSmartEntryRecognitionTask\(\{ claimId, files, itemSnapshots \}\)/)
assert.match(detailViewScript, /function subscribeSmartEntryRecognitionTask\(claimId, listener\)/)
assert.match(detailViewScript, /const smartEntryRecognitionCurrent = ref\(0\)/)
assert.match(detailViewScript, /return `附件识别中(\$\{current\}\/\$\{total\}),请稍候。识别完成前暂不可编辑费用明细。`/)
assert.match(detailViewScript, /const \{ task, reused \} = startSmartEntryRecognitionTask\(\{[\s\S]*claimId: request\.value\.claimId[\s\S]*itemSnapshots: expenseItems\.value/)
assert.match(detailViewScript, /bindSmartEntryRecognitionTask\(request\.value\.claimId\)/)
assert.match(detailViewScript, /void runSmartEntryRecognitionTask\(task, pendingFiles\)/)
assert.match(detailViewScript, /const payload = await uploadExpenseClaimItemAttachment\(task\.claimId, targetItem\.id, file\)/)
assert.doesNotMatch(detailViewScript, /function openAiEntry\(\)[\s\S]*emit\('openAssistant'/)
})
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, /const recognizedItemDate = normalizeIsoDateValue\(payload\?\.item_date \?\? payload\?\.itemDate\)/)
assert.match(detailViewScript, /const recognizedItemType = String\(payload\?\.item_type \?\? payload\?\.itemType \?\? ''\)\.trim\(\)/)
assert.match(detailViewScript, /const recognizedItemReason = String\(payload\?\.item_reason \?\? payload\?\.itemReason \?\? ''\)\.trim\(\)/)
assert.match(detailViewScript, /itemPatch\.itemAmount = recognizedItemAmount/)
assert.match(detailViewScript, /itemPatch\.amount = formatCurrency\(recognizedItemAmount\)/)
assert.match(detailViewScript, /populateExpenseEditor\(\{ \.\.\.item, \.\.\.itemPatch \}\)/)
})
test('expense detail edit keeps delete but removes cancel and allows draft placeholders', () => {
assert.doesNotMatch(detailViewTemplate, /@click="cancelExpenseEdit"/)
assert.doesNotMatch(detailViewScript, /function cancelExpenseEdit/)
assert.match(detailViewScript, /if \(expenseEditor\.itemDate && !isValidIsoDate\(expenseEditor\.itemDate\)\)/)
assert.doesNotMatch(detailViewScript, /请输入费用说明。/)
assert.doesNotMatch(detailViewScript, /请输入大于 0 的费用金额。/)
assert.match(detailViewScript, /const amountText = String\(expenseEditor\.itemAmount \|\| ''\)\.trim\(\)/)
assert.match(detailViewScript, /const nextAmount = amountText \? Number\(amountText\) : 0/)
assert.match(detailViewScript, /if \(expenseEditor\.itemDate\) \{[\s\S]*itemPayload\.item_date = expenseEditor\.itemDate/)
assert.match(detailViewScript, /itemPayload = \{[\s\S]*item_note: expenseEditor\.itemNote\.trim\(\)/)
})
test('travel detail AI advice uses material prompts only for required hotel receipts', () => {
assert.match(detailViewScript, /buildTravelReceiptMaterialPrompts\(request\.value, expenseItems\.value\)/)
assert.doesNotMatch(detailViewScript, /buildOptionalTravelReceiptRiskCards/)
assert.doesNotMatch(detailViewScript, /travel-optional-ride-ticket/)
assert.deepEqual(
buildTravelReceiptMaterialPrompts(
{ typeCode: 'travel', detailVariant: 'travel' },
[{ id: 'ride', itemType: 'ride_ticket', itemReason: '打车', invoiceId: '' }]
),
[]
)
assert.deepEqual(
buildTravelReceiptMaterialPrompts(
{ typeCode: 'travel', detailVariant: 'travel' },
[{ id: 'hotel', itemType: 'hotel_ticket', itemReason: '住宿', invoiceId: '' }]
),
['当前包含 1 条住宿费用明细,但暂未关联住宿发票或酒店水单。请补充住宿材料,避免后续被退回补件。']
)
assert.deepEqual(
buildDraftBlockingIssues(
{
profileName: '张三',
typeCode: 'transport',
typeLabel: '交通费',
reason: '客户现场打车',
occurredDisplay: '2026-06-01',
amountValue: 42
},
[
buildExpenseItemViewModel(
{
id: 'ride',
itemType: 'ride_ticket',
itemReason: '园区-客户现场',
itemDate: '2026-06-01',
itemAmount: 42,
invoiceId: ''
},
0,
{ typeCode: 'transport' }
)
]
),
[]
)
assert.ok(
buildDraftBlockingIssues(
{
profileName: '张三',
typeCode: 'hotel',
typeLabel: '住宿费',
reason: '住宿报销',
occurredDisplay: '2026-06-01',
amountValue: 450
},
[
buildExpenseItemViewModel(
{
id: 'hotel',
itemType: 'hotel_ticket',
itemReason: '北京中心酒店',
itemDate: '2026-06-01',
itemAmount: 450,
invoiceId: ''
},
0,
{ typeCode: 'hotel' }
)
]
).some((item) => item.includes('缺少票据标识'))
)
})
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"/
)
assert.match(
detailViewScript,
/if \(actionBusy\.value\) \{[\s\S]*toast\(uploadingExpenseId\.value \? '附件识别中,请等待识别完成后再保存。' : '当前操作处理中,请稍后再保存。'\)[\s\S]*return/
)
})
test('application detail uses application labels instead of reimbursement labels', () => {
assert.match(detailViewTemplate, /isApplicationDocument \? '申请进度'/)
assert.match(detailViewTemplate, /isApplicationDocument \? '申请详情' : '费用明细'/)
assert.match(detailViewTemplate, /展示本次申请的事实信息、职级规则测算和用户预估费用/)
assert.match(detailViewTemplate, /class="application-detail-facts"/)
assert.match(detailViewTemplate, /applicationDetailFactItems/)
assert.match(detailViewScript, /buildApplicationDetailFactItems/)
assert.match(detailViewStyle, /\.application-detail-fact\.highlight strong/)
assert.match(detailViewTemplate, /isApplicationDocument \? '申请类型' : '报销类型'/)
assert.match(detailViewTemplate, /isApplicationDocument \? '预计金额' : '报销金额'/)
assert.match(detailViewTemplate, /isApplicationDocument \? '退回申请' : '退回单据'/)
assert.match(detailViewTemplate, /当前申请单已进入流程,详情页仅展示状态与申请信息。/)
})
test('returned application detail can open assistant with editable prefill', () => {
assert.match(
detailViewTemplate,
/v-if="canModifyReturnedApplication"[\s\S]*@click="handleModifyApplication"[\s\S]*修改申请/
)
assert.match(
detailViewScript,
/const canModifyReturnedApplication = computed\(\(\) => \([\s\S]*isApplicationDocument\.value[\s\S]*isCurrentApplicant\.value[\s\S]*returned/
)
assert.match(detailViewScript, /function buildApplicationEditPreview\(\)/)
assert.match(detailViewScript, /applicationDetailFactItems\.value[\s\S]*sourceText:\s*'修改申请'/)
assert.match(detailViewScript, /fields:\s*\{[\s\S]*applicationType:[\s\S]*reason:[\s\S]*transportMode:/)
assert.match(detailViewScript, /function handleModifyApplication\(\)/)
assert.match(detailViewScript, /source:\s*'application'/)
assert.match(detailViewScript, /sessionType:\s*'application'/)
assert.match(detailViewScript, /prompt:\s*''/)
assert.match(detailViewScript, /applicationPreview:\s*buildApplicationEditPreview\(\)/)
assert.match(detailViewScript, /applicationEditMode:\s*true/)
assert.match(detailViewScript, /initialPromptAutoSubmit:\s*false/)
assert.match(detailViewScript, /canModifyReturnedApplication,/)
assert.match(detailViewScript, /handleModifyApplication,/)
})
test('application detail does not show optional travel receipt reminders', () => {
const request = {
documentTypeCode: 'application',
claimNo: 'APP-20260525-ABC123',
typeCode: 'travel_application',
detailVariant: 'travel'
}
assert.equal(isApplicationDocumentRequest(request), true)
assert.deepEqual(
buildTravelReceiptMaterialPrompts(request, [
{ id: 'allowance', itemType: 'travel_allowance', isSystemGenerated: true, invoiceId: '' }
]),
[]
)
})
test('employee profile advice highlights prior return and material quality issues', () => {
const items = buildEmployeeProfileAdviceItems({
profiles: [
{
profile_type: 'process_quality',
metrics: {
return_count: 2,
missing_attachment_count: 1,
missing_business_context_count: 1,
invoice_mismatch_count: 1
}
}
],
review_suggestions: [
{ message: '申请人近期材料质量波动较高,建议重点核对附件、事由和票据一致性。' }
]
})
assert.ok(items.some((item) => item.includes('历史退单建议')))
assert.ok(items.some((item) => item.includes('材料完整性建议')))
assert.ok(items.some((item) => item.includes('票据一致性建议')))
})
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', () => {
const routeItem = buildExpenseItemViewModel(
{
id: 'route-item',
itemType: 'train_ticket',
itemReason: '广州南-上海虹桥',
itemLocation: '上海',
itemAmount: 354,
invoiceId: 'train-ticket.png'
},
0,
{ claimId: 'claim-route', detailVariant: 'travel' }
)
const shipItem = buildExpenseItemViewModel(
{
id: 'ship-item',
itemType: 'ship_ticket',
itemReason: '上海港-舟山港',
itemLocation: '舟山',
itemAmount: 120,
invoiceId: 'ship-ticket.png'
},
1,
{ claimId: 'claim-route', detailVariant: 'travel' }
)
assert.equal(routeItem.desc, '广州南-上海虹桥')
assert.equal(routeItem.detail, '起始地-目的地')
assert.equal(shipItem.name, '轮船票')
assert.equal(shipItem.detail, '起始地-目的地')
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'\]\)/)
assert.match(detailViewScript, /const ROUTE_DESCRIPTION_PATTERN = \/\^\[A-Za-z0-9\\u4e00-\\u9fa5/)
assert.match(detailViewScript, /return '起始地-目的地,例如:广州南-北京南'/)
assert.match(detailViewScript, /return '起始地-目的地'/)
assert.match(detailViewScript, /return '目的地酒店,例如:北京中心酒店'/)
assert.match(detailViewScript, /return '目的地酒店'/)
assert.match(detailViewScript, /isSyntheticLocationDisplay\(item\.detail, item\.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('compliant attachment analysis does not create medium risk cards', () => {
const riskCards = buildAttachmentRiskCards({
expenseItems: [
{
id: 'item-001',
invoiceId: 'mock/invoice-001.txt',
itemReason: 'taxi',
itemType: 'transport'
}
],
attachmentMetaByItemId: {
'item-001': {
analysis: {
severity: 'success',
label: 'compliant',
headline: 'invoice fields match reimbursement item',
summary: 'mock OCR fields are consistent with the reimbursement detail',
points: ['amount and document type are consistent']
}
}
}
})
assert.deepEqual(riskCards, [])
assert.match(detailViewInsights, /success', 'ok', 'normal', 'none', 'compliant', 'approved'/)
assert.match(detailViewScript, /tone: normalizeRiskTone\(analysis\.severity \|\| 'low'\)/)
})
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, /APPLICATION_RETURN_REASON_OPTIONS/)
assert.match(returnReasonDialog, /application_info_incomplete/)
assert.match(returnReasonDialog, /application_business_need_unclear/)
assert.match(returnReasonDialog, /application_budget_basis_missing/)
assert.match(returnReasonDialog, /application_policy_mismatch/)
assert.match(returnReasonDialog, /application_attachment_needed/)
assert.match(returnReasonDialog, /application_other/)
assert.match(returnReasonDialog, /退单选项/)
assert.match(returnReasonDialog, /selectionError/)
assert.match(returnReasonDialog, /selectedApplicationCode/)
assert.match(returnReasonDialog, /application \? 'radio' : 'checkbox'/)
assert.match(returnReasonDialog, /selectedReasonCodes\.value\.length === 0/)
assert.match(returnReasonDialog, /lastAutoReason/)
assert.match(returnReasonDialog, /reason_codes/)
assert.match(approvalCenterTemplate, /<TravelRequestDetailView/)
assert.doesNotMatch(approvalCenterTemplate, /<ReturnReasonDialog/)
assert.match(detailViewTemplate, /<TravelRequestReturnDialog/)
assert.match(detailViewTemplate, /:application="isApplicationDocument"/)
assert.doesNotMatch(approvalCenterScript, /returnExpenseClaim/)
assert.match(detailViewScript, /returnExpenseClaim\(request\.value\.claimId, payload\)/)
assert.doesNotMatch(approvalCenterScript, /审批中心退回/)
assert.doesNotMatch(detailViewScript, /详情页退回/)
})