Files
X-Financial/web/tests/attachment-association-confirmation.test.mjs
caoxiaozhu 8417a9f542 feat(web): 设置中心缓存管理与文件预览资产工具
- 新增 documentPreviewAssets 工具,统一从 URL/Blob/File 推断预览类型(image/pdf/file/unsupported)
- SettingsView/SettingsView.js/settingsModelHelper 新增系统缓存管理区块,调用 /settings/cache/clear 并展示清理结果;useSettings/services 适配
- WorkbenchAiFilePreviewDialog/useWorkbenchAiFilePreview 接入预览资产工具,workbenchAiComposerModel 调整文件处理
- ReceiptFolder/LogDetailView/DigitalEmployeeWorkRecords/travelReimbursementAttachmentModel 配套适配
- 新增 settings-cache-management-section 测试,更新 settings-llm/rendering/receipt-folder-view/composer-components/attachment-association 测试
2026-06-24 12:35:59 +08:00

470 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, /> \*\*附件 1train-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, /<blockquote class="markdown-attachment-card">/)
const questionIndex = rendered.indexOf('请问是否确定将票据信息归集到单据')
const attachmentCardCloseIndex = rendered.indexOf('</blockquote>')
assert.ok(attachmentCardCloseIndex > -1 && questionIndex > attachmentCardCloseIndex)
const attachmentCardHtml = rendered.slice(
rendered.indexOf('<blockquote class="markdown-attachment-card">'),
attachmentCardCloseIndex
)
assert.doesNotMatch(attachmentCardHtml, /请问是否确定将票据信息归集到单据/)
assert.match(rendered, /<p class="markdown-action-paragraph">/)
assert.match(rendered, /<strong><a href="#confirm-attachment-association" class="markdown-action-link markdown-action-link-confirm">确认<\/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, /<strong>附件 12月20 武汉-上海\.pdf<\/strong>/)
assert.match(rendered, /<strong>附件 22月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 submitAttachmentFlowSource = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementSubmitAttachmentFlow.js', import.meta.url)),
'utf8'
)
const submitDraftPreflightSource = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementSubmitDraftPreflight.js', import.meta.url)),
'utf8'
)
const flowSource = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementFlow.js', import.meta.url)),
'utf8'
)
const flowToolSource = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementFlowToolModel.js', import.meta.url)),
'utf8'
)
const conversationSource = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementConversationSessionModel.js', import.meta.url)),
'utf8'
)
assert.doesNotMatch(viewSource, /检测到你已有单据事件|uploadDecisionDialogOpen|continueExistingUpload|createNewUploadDocument/)
assert.doesNotMatch(submitComposerSource, /uploadDecisionDialogOpen|hasExistingDocumentEvent|skipUploadDecisionPrompt/)
assert.doesNotMatch(submitComposerSource, /查询可关联草稿失败,已继续按新单据识别/)
assert.match(
submitDraftPreflightSource,
/const claims = await fetchExpenseClaims\([^)]*\)[\s\S]*const queryPayload = buildDraftAssociationQueryPayload\(claims\)[\s\S]*meta: \['等待选择关联单据'\][\s\S]*queryPayload/
)
assert.match(submitDraftPreflightSource, /meta: \['单据查询失败'\][\s\S]*return \{ handled: true, value: null \}/)
assert.match(
submitDraftPreflightSource,
/files\.length[\s\S]*!resolvedUploadDisposition[\s\S]*!options\.skipDraftAssociationPrompt[\s\S]*!reviewAction/
)
assert.match(submitDraftPreflightSource, /mode:\s*'save_then_associate'/)
assert.match(submitAttachmentFlowSource, /review_action:\s*'save_draft'[\s\S]*review_action:\s*'link_to_existing_draft'/)
assert.match(submitAttachmentFlowSource, /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(flowToolSource, /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, /<strong><a href="#confirm-attachment-association" class="markdown-action-link markdown-action-link-confirm">确定<\/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 preview builders reuse receipt folder image preview endpoints', () => {
const ocrPreviews = buildOcrFilePreviews({
documents: [
{
filename: '2月23 上海-武汉.pdf',
preview_kind: 'image',
receipt_preview_url: '/receipt-folder/receipt-train-1/preview'
}
]
})
assert.deepEqual(ocrPreviews, [
{
filename: '2月23 上海-武汉.pdf',
kind: 'image',
url: '/receipt-folder/receipt-train-1/preview'
}
])
})
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('OCR documents normalize receipt-folder field shapes for AI cards', () => {
const documents = normalizeOcrDocuments({
documents: [
{
filename: 'train-ticket.png',
document_info: {
fields: [
{ label: '身份证号', value: '4201061987****1615' },
{ key: 'seat_no', label: '座位号', value: '01B' }
]
}
},
{
filename: 'hotel.png',
fields: [
{ name: 'amount', label: '金额', value: '450元' }
]
}
]
})
assert.deepEqual(documents[0].document_fields, [
{ key: '身份证号', label: '身份证号', value: '4201061987****1615' },
{ key: 'seat_no', label: '座位号', value: '01B' }
])
assert.deepEqual(documents[1].document_fields, [
{ key: 'amount', label: '金额', value: '450元' }
])
})
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 submitRecognitionFlowSource = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementSubmitRecognitionFlow.js', import.meta.url)),
'utf8'
)
assert.match(submitRecognitionFlowSource, /collectReceiptFiles\(/)
assert.doesNotMatch(submitRecognitionFlowSource, /recognizeOcrFiles\(files,[\s\S]*attachReceiptFolderIdsToFiles/)
assert.doesNotMatch(submitRecognitionFlowSource, /ocrDocuments = normalizeOcrDocuments\(ocrPayload\)/)
assert.doesNotMatch(submitRecognitionFlowSource, /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}\\)`))
})