feat: 新增归档中心页面并完善知识库与报销查询能力
新增前端归档中心视图及相关工具函数,扩充知识库文档分类和 提取器支持多种格式,增强编排器报销查询的多维度检索,优 化本体规则和用户代理审核消息,前端完善报销创建和审批详 情交互细节,补充单元测试覆盖。
This commit is contained in:
@@ -1,10 +1,21 @@
|
||||
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,
|
||||
buildAttachmentAssociationConfirmationMessage
|
||||
buildAttachmentAssociationConfirmationMessage,
|
||||
buildOcrFilePreviews,
|
||||
buildReviewFilePreviewsFromReviewPayload
|
||||
} 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({
|
||||
@@ -26,9 +37,165 @@ test('attachment association prompt prints recognized receipt details before con
|
||||
})
|
||||
|
||||
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(`\\[确认\\]\\(${ATTACHMENT_ASSOCIATION_CONFIRM_HREF}\\)`))
|
||||
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>附件 1:2月20 武汉-上海\.pdf<\/strong>/)
|
||||
assert.match(rendered, /<strong>附件 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'
|
||||
)
|
||||
|
||||
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/
|
||||
)
|
||||
})
|
||||
|
||||
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('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 hint guides users to the reimbursement 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}\\)`))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user