import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' import { fileURLToPath } from 'node:url' import { ATTACHMENT_ASSOCIATION_CONFIRM_HREF, attachReceiptFolderIdsToFiles, buildAttachmentAssociationConfirmationMessage, buildOcrFilePreviews, buildReviewFilePreviewsFromReviewPayload, buildUnsavedDraftAttachmentConfirmationMessage, collectReceiptFiles, filterPersistableFilePreviews, mergeFilePreviews, normalizeOcrDocuments } from '../src/views/scripts/travelReimbursementAttachmentModel.js' import { buildDraftAssociationQueryPayload, buildExpenseQueryHint, EXPENSE_CENTER_HREF, normalizeExpenseQueryPayload } from '../src/views/scripts/travelReimbursementExpenseQueryModel.js' import { renderMarkdown } from '../src/utils/markdown.js' test('attachment association prompt prints recognized receipt details before confirmation link', () => { const message = buildAttachmentAssociationConfirmationMessage({ claimNo: 'EXP-202605-001', fileNames: ['train-ticket.pdf'], ocrDocuments: [ { filename: 'train-ticket.pdf', document_type: 'train_ticket', scene_label: '差旅票据', summary: '铁路电子客票 武汉-上海 票价 354 元', document_fields: [ { key: 'route', label: '行程', value: '武汉-上海' }, { key: 'amount', label: '票价', value: '354.00' }, { key: 'date', label: '乘车日期', value: '2026-02-20' } ] } ] }) assert.match(message, /已识别附件信息:/) assert.match(message, /> \*\*附件 1:train-ticket\.pdf\*\*/) assert.match(message, /附件类型:差旅票据/) assert.match(message, /行程:武汉-上海/) assert.match(message, /票价:354.00/) assert.match(message, /草稿单号:EXP-202605-001/) assert.match(message, new RegExp(`\\n\\n\\n如果 \\*\\*\\[确认\\]\\(${ATTACHMENT_ASSOCIATION_CONFIRM_HREF}\\)\\*\\* 该信息`)) const rendered = renderMarkdown(message) assert.match(rendered, /
/) const questionIndex = rendered.indexOf('请问是否确定将票据信息归集到单据') const attachmentCardCloseIndex = rendered.indexOf('') assert.ok(attachmentCardCloseIndex > -1 && questionIndex > attachmentCardCloseIndex) const attachmentCardHtml = rendered.slice( rendered.indexOf('
'), attachmentCardCloseIndex ) assert.doesNotMatch(attachmentCardHtml, /请问是否确定将票据信息归集到单据/) assert.match(rendered, //) assert.match(rendered, /确认<\/a><\/strong>/) }) test('multiple recognized attachments render as separated attachment cards', () => { const message = buildAttachmentAssociationConfirmationMessage({ claimNo: 'EXP-202605-001', ocrDocuments: [ { filename: '2月20 武汉-上海.pdf', document_type: 'train_ticket', document_type_label: '火车/高铁票', document_fields: [ { key: 'amount', label: '金额', value: '354元' }, { key: 'route', label: '行程', value: '武汉-上海' } ] }, { filename: '2月23 上海-武汉.pdf', document_type: 'train_ticket', document_type_label: '火车/高铁票', document_fields: [ { key: 'amount', label: '金额', value: '354元' }, { key: 'route', label: '行程', value: '上海-武汉' } ] } ] }) const rendered = renderMarkdown(message) assert.equal((rendered.match(/class="markdown-attachment-card"/g) || []).length, 2) assert.match(rendered, /附件 1:2月20 武汉-上海\.pdf<\/strong>/) assert.match(rendered, /附件 2:2月23 上海-武汉\.pdf<\/strong>/) assert.match(rendered, /本次待归集附件:2 份/) }) test('attachment upload association uses conversation selection instead of legacy modal', () => { const viewSource = readFileSync( fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)), 'utf8' ) const submitComposerSource = readFileSync( fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)), 'utf8' ) const flowSource = readFileSync( fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementFlow.js', import.meta.url)), 'utf8' ) const conversationSource = readFileSync( fileURLToPath(new URL('../src/views/scripts/travelReimbursementConversationModel.js', import.meta.url)), 'utf8' ) assert.doesNotMatch(viewSource, /检测到你已有单据事件|uploadDecisionDialogOpen|continueExistingUpload|createNewUploadDocument/) assert.doesNotMatch(submitComposerSource, /uploadDecisionDialogOpen|hasExistingDocumentEvent|skipUploadDecisionPrompt/) assert.doesNotMatch(submitComposerSource, /查询可关联草稿失败,已继续按新单据识别/) assert.match( submitComposerSource, /const claims = await fetchExpenseClaims\(\)[\s\S]*const queryPayload = buildDraftAssociationQueryPayload\(claims\)[\s\S]*meta: \['等待选择关联单据'\][\s\S]*queryPayload/ ) assert.match(submitComposerSource, /meta: \['单据查询失败'\][\s\S]*return null/) assert.match( submitComposerSource, /files\.length[\s\S]*!resolvedUploadDisposition[\s\S]*!options\.skipDraftAssociationPrompt[\s\S]*!reviewAction/ ) assert.match(submitComposerSource, /mode:\s*'save_then_associate'/) assert.match(submitComposerSource, /review_action:\s*'save_draft'[\s\S]*review_action:\s*'link_to_existing_draft'/) assert.match(submitComposerSource, /appendToCurrentFlow:\s*true/) assert.match(submitComposerSource, /const appendToCurrentFlow = Boolean\(options\.appendToCurrentFlow\)/) assert.match(submitComposerSource, /if \(!appendToCurrentFlow\) \{\s*resetFlowRun\(\)\s*\} else \{\s*clearFlowSimulationTimers\(\)/) assert.match(flowSource, /link_to_existing_draft:\s*\{[\s\S]*key:\s*'attachment-association'/) assert.match(flowSource, /responseMessage\.includes\('关联'\)[\s\S]*key:\s*'attachment-association'/) assert.match(flowSource, /'draft-risk-review'/) assert.match(flowSource, /草稿风险识别/) assert.match(conversationSource, /'attachment-association':\s*\{[\s\S]*title:\s*'票据关联草稿'/) assert.match(conversationSource, /'draft-risk-review':\s*\{[\s\S]*title:\s*'草稿风险识别'/) }) test('unsaved review attachment prompt asks for explicit rich-text confirmation', () => { const message = buildUnsavedDraftAttachmentConfirmationMessage({ fileNames: ['taxi.pdf'] }) const rendered = renderMarkdown(message) assert.match(message, /当前这笔报销信息还没有保存为草稿/) assert.match(message, /本次待归集附件:1 份/) assert.match(message, new RegExp(`\\*\\*\\[确定\\]\\(${ATTACHMENT_ASSOCIATION_CONFIRM_HREF}\\)\\*\\*`)) assert.match(rendered, /确定<\/a><\/strong>/) }) test('OCR preview builders keep hotel receipt image previews when preview kind is omitted', () => { const dataUrl = 'data:image/png;base64,abc123' const ocrPreviews = buildOcrFilePreviews({ documents: [ { filename: 'hotel.png', preview_data_url: dataUrl } ] }) const reviewPreviews = buildReviewFilePreviewsFromReviewPayload({ document_cards: [ { filename: 'hotel.png', preview_url: dataUrl } ] }) assert.deepEqual(ocrPreviews, [{ filename: 'hotel.png', kind: 'image', url: dataUrl }]) assert.deepEqual(reviewPreviews, [{ filename: 'hotel.png', kind: 'image', url: dataUrl }]) }) test('OCR receipt folder ids are kept for final draft attachment association', () => { const files = [ { name: 'invoice.png' }, { name: 'taxi.pdf' } ] const ocrPayload = { documents: [ { filename: 'invoice.png', receipt_id: 'receipt-1', receipt_status: 'unlinked', receipt_preview_url: '/receipt-folder/receipt-1/preview', receipt_source_url: '/receipt-folder/receipt-1/source' }, { filename: 'taxi.pdf', receipt_id: 'receipt-2', receipt_status: 'unlinked', receipt_preview_url: '/receipt-folder/receipt-2/preview', receipt_source_url: '/receipt-folder/receipt-2/source' } ] } const documents = normalizeOcrDocuments(ocrPayload) assert.equal(documents[0].receipt_id, 'receipt-1') assert.equal(documents[0].receipt_status, 'unlinked') assert.equal(documents[0].receipt_preview_url, '/receipt-folder/receipt-1/preview') assert.equal(documents[0].receipt_source_url, '/receipt-folder/receipt-1/source') assert.equal(attachReceiptFolderIdsToFiles(files, ocrPayload), 2) assert.equal(files[0].receiptId, 'receipt-1') assert.equal(files[1].receiptId, 'receipt-2') assert.equal(Object.getOwnPropertyDescriptor(files[0], 'receiptId')?.enumerable, false) }) test('OCR documents keep full recognized text for backend context', () => { const longText = [ '增值税电子发票', '购买方名称:远光软件股份有限公司', '销售方名称:上海高铁服务有限公司', '项目名称:客运服务', '出发地:武汉', '到达地:上海', '乘车日期:2026-02-20', '车次:G1234', '座位等级:二等座', '金额:354.00元', '税额:10.62元', '发票号码:12345678901234567890', '开票日期:2026-02-21', '购买方纳税人识别号:91440400618256625E', '销售方纳税人识别号:91310000132234123X', '备注:本票据用于差旅报销,请核对出发城市、到达城市、车次、座位等级、金额、税额和电子客票号。', '电子客票号:E1234567890' ].join('\n') assert.ok(longText.length > 240) const documents = normalizeOcrDocuments({ documents: [ { filename: 'train-ticket.pdf', text: longText, summary: '铁路电子客票 武汉-上海', document_fields: [ { key: 'amount', label: '金额', value: '354.00元' }, { key: 'ticket_no', label: '电子客票号', value: 'E1234567890' } ] } ] }) assert.equal(documents[0].text, longText) assert.match(documents[0].text, /电子客票号:E1234567890/) }) test('receipt files are collected through a single OCR persistence entry before draft association', async () => { const files = [ { name: 'invoice.png' } ] let recognizeCallCount = 0 const collected = await collectReceiptFiles({ files, recognizeOcrFiles: async (inputFiles, options) => { recognizeCallCount += 1 assert.equal(inputFiles, files) assert.equal(options.timeoutMs, 90000) return { documents: [ { filename: 'invoice.png', summary: '发票金额 100 元', preview_kind: 'image', preview_data_url: 'data:image/png;base64,abc123', receipt_id: 'receipt-collect-1', receipt_status: 'unlinked', receipt_preview_url: '/receipt-folder/receipt-collect-1/preview', receipt_source_url: '/receipt-folder/receipt-collect-1/source' } ] } } }) assert.equal(recognizeCallCount, 1) assert.equal(files[0].receiptId, 'receipt-collect-1') assert.equal(collected.ocrPayload.documents[0].receipt_id, 'receipt-collect-1') assert.equal(collected.ocrDocuments[0].receipt_id, 'receipt-collect-1') assert.equal(collected.ocrSummary, 'invoice.png:发票金额 100 元') assert.deepEqual(collected.ocrFilePreviews, [ { filename: 'invoice.png', kind: 'image', url: 'data:image/png;base64,abc123' } ]) const submitComposerSource = readFileSync( fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)), 'utf8' ) assert.match(submitComposerSource, /collectReceiptFiles\(/) assert.doesNotMatch(submitComposerSource, /recognizeOcrFiles\(files,[\s\S]*attachReceiptFolderIdsToFiles/) assert.doesNotMatch(submitComposerSource, /ocrDocuments = normalizeOcrDocuments\(ocrPayload\)/) assert.doesNotMatch(submitComposerSource, /ocrFilePreviews = buildOcrFilePreviews\(ocrPayload\)/) }) test('file preview cache replaces temporary object urls and never persists them', () => { const merged = mergeFilePreviews( [ { filename: 'invoice.pdf', kind: 'pdf', url: 'blob:http://localhost/old-preview' }, { filename: 'hotel.png', kind: 'image', url: 'data:image/png;base64,stable' } ], [ { filename: 'invoice.pdf', kind: 'pdf', url: 'blob:http://localhost/new-preview' }, { filename: 'hotel.png', kind: 'image' } ] ) assert.equal(merged.length, 2) assert.equal(merged[0].url, 'blob:http://localhost/new-preview') assert.equal(merged[1].url, 'data:image/png;base64,stable') assert.deepEqual(filterPersistableFilePreviews(merged), [ { filename: 'hotel.png', kind: 'image', url: 'data:image/png;base64,stable' } ]) }) test('draft association query keeps a single candidate selectable in the conversation', () => { const payload = buildDraftAssociationQueryPayload([ { id: 'claim-1', claim_no: 'EXP-202605-001', status: 'draft', expense_type: 'travel', reason: '上海出差', amount: 1280 } ]) assert.equal(payload.selectionMode, 'draft_association') assert.equal(payload.title, '选择关联草稿') assert.equal(payload.records.length, 1) assert.equal(payload.records[0].claimId, 'claim-1') }) test('expense query payload keeps structured risk items for claim-level risk drilldown', () => { const payload = normalizeExpenseQueryPayload({ result_type: 'expense_claim_list', records: [ { claim_id: 'claim-risk', claim_no: 'EXP-202605-009', amount: 880, risk_flags: [ { key: 'hotel-limit', level: 'high', level_label: '高风险', title: '酒店超标', summary: '住宿金额超过城市标准', detail: '上海 P5 住宿标准为 600 元,本次 880 元。' } ] } ] }) assert.equal(payload.records[0].riskItems.length, 1) assert.equal(payload.records[0].riskItems[0].levelLabel, '高风险') assert.equal(payload.records[0].riskItems[0].summary, '住宿金额超过城市标准') }) test('expense query info items render as prompts instead of low risk', () => { const payload = normalizeExpenseQueryPayload({ result_type: 'expense_claim_list', records: [ { claim_id: 'claim-info', claim_no: 'EXP-202605-010', amount: 59.1, risk_flags: [ { key: 'normal-tip', level: 'info', title: '票据提示', summary: '票据已识别,当前没有异常。' } ] } ] }) assert.equal(payload.records[0].riskItems[0].levelLabel, '提示') assert.notEqual(payload.records[0].riskItems[0].levelLabel, '低风险') }) test('expense query hint guides users to the document center after the top five results', () => { const payload = normalizeExpenseQueryPayload({ result_type: 'expense_claim_list', title: '最近 5 条你的归档报销单', scope_label: '你的归档报销单', record_count: 8, preview_count: 5, preview_limit: 5, records: [ { claim_id: 'claim-1', claim_no: 'EXP-1', amount: 100 } ] }) const hint = buildExpenseQueryHint(payload) assert.match(hint, /最近的 5 条记录/) assert.match(hint, new RegExp(`\\[\\*\\*这里\\*\\*\\]\\(${EXPENSE_CENTER_HREF}\\)`)) })