import test from 'node:test' import assert from 'node:assert/strict' import { buildAiAttachmentAssociationActions, buildAiAttachmentAssociationMessage, buildAiAttachmentAssociationResultMessage, resolveAiAttachmentAssociationMatch } from '../src/utils/aiAttachmentAssociationModel.js' import { renderAiConversationHtml } from '../src/utils/aiConversationHtmlRenderer.js' function findAction(actions, actionType) { return actions.find((action) => action.action_type === actionType) } test('根据票据日期和行程自动匹配最可能关联的报销单', () => { const claims = [ { id: 'claim-wuhan-shanghai', claim_no: 'BX-20260220-001', expense_type: 'travel', status: 'draft', reason: '辅助国网仿生产服务器部署,武汉往返上海', location: '上海', occurred_at: '2026-02-20' }, { id: 'claim-nanjing', claim_no: 'BX-20260301-002', expense_type: 'travel', status: 'draft', reason: '南京客户拜访', location: '南京', occurred_at: '2026-03-01' } ] const ocrDocuments = [ { filename: '2月20 武汉-上海.pdf', summary: '武汉至上海高铁票', document_fields: [ { key: 'date', label: '日期', value: '2026-02-20' }, { key: 'route', label: '行程', value: '武汉-上海' } ] }, { filename: '2月23 上海-武汉.pdf', summary: '上海至武汉高铁票', document_fields: [ { key: 'date', label: '日期', value: '2026-02-23' }, { key: 'route', label: '行程', value: '上海-武汉' } ] } ] const match = resolveAiAttachmentAssociationMatch(claims, ocrDocuments) assert.equal(match.highConfidence, true) assert.equal(match.best.record.claimId, 'claim-wuhan-shanghai') assert.match(match.best.reasons.join('、'), /日期/) assert.match(match.best.reasons.join('、'), /地点|行程/) const message = buildAiAttachmentAssociationMessage({ match, fileNames: ocrDocuments.map((item) => item.filename), ocrDocuments }) assert.match(message, /BX-20260220-001/) assert.match(message, /确认是否自动归集/) assert.match(message, /ai-trusted-html:start/) assert.ok(message.indexOf('票据识别结果') < message.indexOf('可能关联单据')) const html = renderAiConversationHtml(message) assert.match(html, /ai-ocr-recognition-card/) assert.match(html, /ai-attachment-association-card/) assert.match(html, /可能关联单据/) assert.doesNotMatch(html, /26429165800002785705/) const actions = buildAiAttachmentAssociationActions(match, 'assoc-1', { includeOcrDetails: true }) assert.equal(actions[0].action_type, 'show_ai_attachment_ocr_details') assert.equal(findAction(actions, 'confirm_ai_attachment_association').payload.association_id, 'assoc-1') assert.ok(findAction(actions, 'open_application_detail')) }) test('自动归集消息展示票面 OCR 关键字段', () => { const match = resolveAiAttachmentAssociationMatch([ { id: 'claim-wuhan-shanghai', claim_no: 'BX-20260220-001', expense_type: 'travel', status: 'draft', reason: '辅助国网仿生产服务器部署,武汉往返上海', location: '上海', occurred_at: '2026-02-20' } ], [ { filename: '2月20 武汉-上海.pdf', summary: '电子发票(铁路电子客票) 武汉-上海 票价 354元', document_fields: [ { key: 'amount', label: '金额', value: '354元' }, { key: 'departed_at', label: '列车出发时间', value: '2026-02-20 07:55' }, { key: 'route', label: '行程', value: '武汉-上海' } ] } ]) const message = buildAiAttachmentAssociationMessage({ match, fileNames: ['2月20 武汉-上海.pdf'], ocrDocuments: [ { filename: '2月20 武汉-上海.pdf', summary: '电子发票(铁路电子客票) 武汉-上海 票价 354元', document_fields: [ { key: 'amount', label: '金额', value: '354元' }, { key: 'departed_at', label: '列车出发时间', value: '2026-02-20 07:55' }, { key: 'route', label: '行程', value: '武汉-上海' } ] } ] }) const html = renderAiConversationHtml(message) assert.ok(message.indexOf('票据识别结果') < message.indexOf('可能关联单据')) assert.match(message, /票面识别/) assert.match(html, /ai-ocr-recognition-card/) assert.match(html, /金额:354元/) assert.match(html, /列车出发时间:2026-02-20 07:55/) assert.match(html, /行程:武汉-上海/) }) test('自动归集卡片不把票据数字残片当成单据事项', () => { const dirtyReason = ':26429165800002785705; :2026; 05' const ocrDocuments = [ { filename: '2月20 武汉-上海.pdf', summary: '电子发票(铁路电子客票) 武汉-上海 票价 354元', document_fields: [ { key: 'invoice_number', label: '', value: '26429165800002785705' }, { key: 'year', label: '', value: '2026' }, { key: 'amount', label: '金额', value: '354元' }, { key: 'route', label: '行程', value: '武汉-上海' } ] } ] const match = resolveAiAttachmentAssociationMatch([ { id: 'claim-dirty-reason', claim_no: 'R74CB7C2R', expense_type: 'travel', status: 'draft', reason: dirtyReason, location: '上海', occurred_at: '2026-02-20' } ], ocrDocuments) const message = buildAiAttachmentAssociationMessage({ match, fileNames: ['2月20 武汉-上海.pdf'], ocrDocuments }) const html = renderAiConversationHtml(message) assert.match(html, /R74CB7C2R/) assert.match(html, /ai-ocr-recognition-card/) assert.match(html, /票面识别/) assert.match(html, /金额:354元/) assert.match(html, /行程:武汉-上海/) assert.doesNotMatch(html, /单据事项/) assert.doesNotMatch(html, /26429165800002785705/) assert.doesNotMatch(html, /:2026/) }) test('没有可关联草稿时给出清晰提示', () => { const match = resolveAiAttachmentAssociationMatch([ { id: 'claim-submitted', claim_no: 'BX-20260220-001', status: 'submitted', reason: '已提交报销单', location: '上海', occurred_at: '2026-02-20' } ], [ { filename: '2月20 武汉-上海.pdf', summary: '武汉至上海高铁票', document_fields: [ { key: 'date', label: '日期', value: '2026-02-20' }, { key: 'route', label: '行程', value: '武汉-上海' } ] } ]) assert.equal(match.highConfidence, false) assert.equal(match.rankedRecords.length, 0) const message = buildAiAttachmentAssociationMessage({ match, fileNames: ['2月20 武汉-上海.pdf'] }) assert.match(message, /没有查到可关联的报销草稿/) }) test('自动归集结果用卡片告知上传结果', () => { const message = buildAiAttachmentAssociationResultMessage({ claimNo: 'BX-20260220-001', uploadedCount: 2, skippedCount: 0, fileNames: ['2月20 武汉-上海.pdf', '2月23 上海-武汉.pdf'] }) const html = renderAiConversationHtml(message) assert.match(message, /已完成自动归集/) assert.match(html, /ai-attachment-association-card/) assert.match(html, /2 份/) assert.match(html, /BX-20260220-001/) }) test('低置信候选也提供快捷确认关联动作', () => { const claims = [ { id: 'claim-shanghai', claim_no: 'R74CB7C2R', expense_type: 'travel', status: 'draft', reason: '出差报销', location: '上海', occurred_at: '2026-05-18' } ] const ocrDocuments = [ { filename: '2月20 武汉-上海.pdf', summary: 'G458 Wuhan Shanghaihongqiao', document_fields: [ { key: 'route', label: '行程', value: 'Wuhan Shanghaihongqiao' } ] } ] const match = resolveAiAttachmentAssociationMatch(claims, ocrDocuments) assert.equal(match.highConfidence, false) assert.equal(match.recommended.record.claimId, 'claim-shanghai') const message = buildAiAttachmentAssociationMessage({ match, fileNames: ['2月20 武汉-上海.pdf'], ocrDocuments }) const html = renderAiConversationHtml(message) assert.match(html, /候选单据待核对/) assert.match(html, /ai-attachment-association-card/) const actions = buildAiAttachmentAssociationActions(match, 'assoc-low-confidence', { includeOcrDetails: true }) assert.equal(actions[0].action_type, 'show_ai_attachment_ocr_details') assert.equal(findAction(actions, 'confirm_ai_attachment_association').payload.claim_no, 'R74CB7C2R') }) test('旧版纯文本关联消息也渲染为卡片', () => { const legacyMessage = [ '### 我已先识别票据,并匹配到最可能的报销单', '', '本次附件:1 份(2月20 武汉-上海.pdf)', '', '识别摘要:2月20 武汉-上海.pdf:G458 Wuhan Shanghaihongqiao', '', '推荐关联:R74CB7C2R', '', '单据事项:上海差旅报销', '', '匹配依据:地点或行程包含 上海;当前单据仍是可归集草稿', '', '你可以直接点下方“查看匹配单据”核对详情,不需要再手动查找。' ].join('\n') const html = renderAiConversationHtml(legacyMessage) assert.match(html, /ai-attachment-association-card/) assert.match(html, /R74CB7C2R/) assert.doesNotMatch(html, /\*\*R74CB7C2R\*\*/) assert.doesNotMatch(html, /ai-html-title/) })