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, 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)), '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('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 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 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), ['已知存在风险']) }) 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, /

\{\{ 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(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: [ { 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, /
/) 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('

附加说明

') < detailViewTemplate.indexOf('

费用明细

')) 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, /\{\{ 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>/) assert.match(detailViewTemplate, /[\s\S]*\{\{ item\.filledAt \}\}/) assert.match(detailViewTemplate, /条款填写时间<\/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, /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/) }) test('travel detail AI advice adds low risk reminders for optional receipts', () => { assert.match(detailViewScript, /function buildOptionalTravelReceiptRiskCards\(requestModel, items\)/) assert.match(detailViewScript, /id: 'travel-optional-hotel-ticket'[\s\S]*tone: 'low'[\s\S]*住宿票据提醒/) assert.match(detailViewScript, /不要忘记补充酒店住宿票据/) assert.match(detailViewScript, /id: 'travel-optional-ride-ticket'[\s\S]*tone: 'low'[\s\S]*乘车票据提醒/) assert.match(detailViewScript, /可以继续补充票据报销/) assert.match( detailViewScript, /\.\.\.buildOptionalTravelReceiptRiskCards\(request\.value, expenseItems\.value\)/ ) }) 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('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'\]\)/) 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('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, /