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, isApplicationDocumentRequest } from '../src/views/scripts/travelRequestDetailExpenseModel.js' import { buildEmployeeProfileAdviceItems, buildTravelReceiptMaterialPrompts } from '../src/views/scripts/travelRequestDetailAdviceModel.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, /

\{\{ aiAdviceTitle \}\}<\/h3>/) assert.match(detailViewTemplate, /

\{\{ 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, /

\{\{ 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\]"/) 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': isMajorExpenseRisk\(item\)/) assert.match(detailViewTemplate, /class="mdi mdi-alert expense-risk-indicator"/) assert.match(detailViewStyle, /\.expense-risk-indicator \{/) assert.match(detailViewScript, /function isMajorExpenseRisk\(item\)/) assert.match(detailViewScript, /buildItemClaimRiskState\(item, resolveClaimRiskFlags\(\)\)/) }) 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, /]*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('related application information is shown above expense details for reimbursement check', () => { assert.ok( detailViewTemplate.indexOf('

关联单据信息

') < detailViewTemplate.indexOf("isApplicationDocument ? '申请详情' : '费用明细'") ) assert.match(detailViewTemplate, /
/) 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-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>[\s\S]*发生时间<\/th>/) assert.match(detailViewTemplate, /[\s\S]*\{\{ item\.filledAt \}\}[\s\S]* 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 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, /