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 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 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 omits compact-breaking summary labels', () => { assert.match(detailViewTemplate, /
/) assert.match(detailViewTemplate, /当前还没有费用明细/) assert.doesNotMatch(detailViewTemplate, /class="total-row"/) assert.doesNotMatch(detailViewTemplate, /expense-total-bar/) assert.doesNotMatch(detailViewTemplate, /合计 \{\{ expenseTotal \}\}/) assert.doesNotMatch(detailViewTemplate, /\{\{ uploadedExpenseCount \}\} 项已关联票据/) assert.doesNotMatch(detailViewTemplate, /\{\{ expenseSummaryText \}\}/) }) 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"/g) || []).length, 2 ) assert.match(detailViewScript, /const attachments = invoiceId \? \[attachmentName \|\| invoiceId\] : \[\]/) assert.match(detailViewScript, /attachmentStatus: 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, /