Files
X-Financial/web/tests/attachment-association-job-result.test.mjs

237 lines
9.4 KiB
JavaScript
Raw Permalink Normal View History

import test from 'node:test'
import assert from 'node:assert/strict'
import {
buildAttachmentAssociationCandidateActions,
normalizeAttachmentAssociationJob,
resolveAttachmentAssociationTarget
} from '../src/utils/attachmentAssociationJobModel.js'
import {
buildAiAttachmentAssociationConfirmationResultMessage,
buildAiAttachmentAssociationResultMessage
} from '../src/utils/aiAttachmentAssociationModel.js'
import { renderAiConversationHtml } from '../src/utils/aiConversationHtmlRenderer.js'
import { createWorkbenchAiMessageRuntime } from '../src/composables/workbenchAiMode/workbenchAiMessageModel.js'
import { useWorkbenchAiAttachmentAssociationJobs } from '../src/composables/workbenchAiMode/useWorkbenchAiAttachmentAssociationJobs.js'
function buildConfirmationJob(overrides = {}) {
return {
job_id: 'association-job-1',
status: 'succeeded',
resolution: 'requires_confirmation',
requires_confirmation: true,
expense_case_id: 'case-1',
application_claim_id: 'application-1',
application_claim_no: 'SQ-20260716-001',
confidence: 'medium',
confidence_score: 0.8,
match_reasons: ['日期接近', '行程城市相同'],
exceptions: [{ code: 'ambiguous_candidates', message: '前两名候选分差过小' }],
missing_fields: ['项目待补充'],
risk_items: [{ code: 'amount_gap', description: '票据金额与申请预算存在差异' }],
candidates: [
{
target_type: 'reimbursement_draft',
expense_case_id: 'case-1',
application_claim_id: 'application-1',
application_claim_no: 'SQ-20260716-001',
claim_id: 'draft-1',
claim_no: 'BX-20260716-001',
confidence: 'medium',
score: 0.8,
match_reasons: ['日期接近', '行程城市相同']
},
{
target_type: 'reimbursement_draft',
expense_case_id: 'case-2',
claim_id: 'draft-2',
claim_no: 'BX-20260716-002',
confidence: 'medium',
score: 0.67,
match_reasons: ['日期接近']
}
],
draft_payload: { claim_id: 'draft-preview', claim_no: 'BX-PREVIEW-001' },
...overrides
}
}
test('附件关联任务完整规范化新协议并在会话序列化后保留', () => {
const normalized = normalizeAttachmentAssociationJob(buildConfirmationJob())
assert.equal(normalized.requiresConfirmation, true)
assert.equal(normalized.expenseCaseId, 'case-1')
assert.equal(normalized.applicationClaimNo, 'SQ-20260716-001')
assert.equal(normalized.confidenceScore, 0.8)
assert.deepEqual(normalized.matchReasons, ['日期接近', '行程城市相同'])
assert.equal(normalized.exceptions[0].code, 'ambiguous_candidates')
assert.equal(normalized.missingFields[0].message, '项目待补充')
assert.equal(normalized.riskItems[0].message, '票据金额与申请预算存在差异')
assert.equal(normalized.candidates[0].claimId, 'draft-1')
assert.equal(normalized.draftPayload.claimNo, 'BX-PREVIEW-001')
const runtime = createWorkbenchAiMessageRuntime()
const message = runtime.createInlineMessage('assistant', '后台处理中', {
attachmentAssociationJob: buildConfirmationJob()
})
const restored = runtime.normalizeRuntimeMessage(runtime.serializeRuntimeMessage(message))
assert.deepEqual(restored.attachmentAssociationJob, normalized)
})
test('待确认结果集中展示候选和异常且不伪装成自动归集成功', () => {
const job = normalizeAttachmentAssociationJob(buildConfirmationJob())
const message = buildAiAttachmentAssociationConfirmationResultMessage({
job,
fileNames: ['武汉-上海.pdf']
})
const html = renderAiConversationHtml(message)
const actions = buildAttachmentAssociationCandidateActions(job)
assert.match(message, /当前信息不足以安全自动归集/)
assert.match(html, /候选单据需要确认/)
assert.match(html, /BX-20260716-001/)
assert.match(html, /前两名候选分差过小/)
assert.match(html, /项目待补充/)
assert.match(html, /票据金额与申请预算存在差异/)
assert.match(html, /没有写入任何草稿/)
assert.doesNotMatch(message, /已完成自动归集/)
assert.equal(actions.length, 2)
assert.equal(actions[0].label, '查看候选草稿 1')
assert.equal(actions[0].payload.claim_id, 'draft-1')
assert.equal(actions[0].payload.application_claim_no, 'SQ-20260716-001')
})
test('只有申请候选时保留申请详情动作且不会伪造报销草稿', () => {
const job = normalizeAttachmentAssociationJob(buildConfirmationJob({
candidates: [{
target_type: 'approved_application',
expense_case_id: 'case-application-only',
application_claim_id: 'application-only',
application_claim_no: 'AP-ONLY-001',
confidence: 'high',
score: 0.8,
match_reasons: ['申请日期和票据日期一致']
}]
}))
const actions = buildAttachmentAssociationCandidateActions(job)
assert.equal(job.candidates.length, 1)
assert.equal(job.candidates[0].claimId, '')
assert.equal(actions[0].label, '查看候选申请')
assert.equal(actions[0].payload.claim_id, 'application-only')
assert.equal(actions[0].payload.document_type, 'application')
})
test('幂等重放展示为已关联而不是未归集', () => {
const message = buildAiAttachmentAssociationResultMessage({
claimNo: 'BX-20260716-001',
uploadedCount: 0,
skippedCount: 1,
fileNames: ['武汉-上海.pdf']
})
assert.match(message, /此前已经完成归集/)
assert.match(message, /1 份已关联,无需重复归集/)
assert.doesNotMatch(message, /未归集附件|部分完成/)
})
test('自动归集成功仍展示草稿风险和复核提示', () => {
const message = buildAiAttachmentAssociationResultMessage({
claimNo: 'BX-20260716-001',
uploadedCount: 1,
riskItems: [{ message: '票据金额需要人工复核' }],
confirmationRequired: true
})
assert.match(message, /风险提示/)
assert.match(message, /票据金额需要人工复核/)
assert.match(message, /待复核/)
assert.match(message, /复核后再提交/)
})
test('任务编排把待确认结果视为安全终态且不发送归集成功通知', async () => {
const runtime = createWorkbenchAiMessageRuntime()
const conversationMessages = {
value: [runtime.createInlineMessage('assistant', '后台处理中', { id: 'message-1', pending: true })]
}
let notifyCount = 0
let persistCount = 0
const flow = useWorkbenchAiAttachmentAssociationJobs({
conversationMessages,
createInlineMessage: runtime.createInlineMessage,
persistCurrentConversation: () => { persistCount += 1 },
replaceInlineMessage: (messageId, message) => {
const index = conversationMessages.value.findIndex((item) => item.id === messageId)
conversationMessages.value.splice(index, 1, message)
},
streamOrSetInlineAssistantContent: async () => {},
notifyRequestUpdated: () => { notifyCount += 1 },
toast: () => {},
buildDetailActions: () => [{ label: '查看草稿' }],
buildThinkingEvents: (status) => [{ status }]
})
const done = await flow.updateJobMessage({
job: buildConfirmationJob(),
messageId: 'message-1',
fileNames: ['武汉-上海.pdf']
})
const resultMessage = conversationMessages.value[0]
assert.equal(done, true)
assert.equal(notifyCount, 0)
assert.equal(persistCount, 1)
assert.equal(resultMessage.pending, false)
assert.equal(resultMessage.stewardPlan.streamStatus, 'completed')
assert.match(resultMessage.content, /未归集/)
assert.doesNotMatch(resultMessage.content, /已完成自动归集/)
assert.equal(resultMessage.suggestedActions[0].payload.claim_id, 'draft-1')
})
test('自动完成结果可从 draft_payload 恢复并继续提供查看草稿动作', async () => {
const runtime = createWorkbenchAiMessageRuntime()
const conversationMessages = {
value: [runtime.createInlineMessage('assistant', '后台处理中', { id: 'message-2', pending: true })]
}
let notification = null
const flow = useWorkbenchAiAttachmentAssociationJobs({
conversationMessages,
createInlineMessage: runtime.createInlineMessage,
persistCurrentConversation: () => {},
replaceInlineMessage: (messageId, message) => {
const index = conversationMessages.value.findIndex((item) => item.id === messageId)
conversationMessages.value.splice(index, 1, message)
},
streamOrSetInlineAssistantContent: async () => {},
notifyRequestUpdated: (payload) => { notification = payload },
toast: () => {},
buildDetailActions: (target) => [{
label: '查看草稿',
action_type: 'open_application_detail',
payload: { claim_id: target.claimId, claim_no: target.claimNo }
}],
buildThinkingEvents: (status) => [{ status }]
})
const job = {
job_id: 'association-job-2',
status: 'succeeded',
resolution: 'auto_associated',
uploaded_count: 1,
skipped_count: 0,
draft_payload: { claim_id: 'draft-auto', claim_no: 'BX-AUTO-001' }
}
await flow.updateJobMessage({ job, messageId: 'message-2', fileNames: ['住宿发票.pdf'] })
const resultMessage = conversationMessages.value[0]
assert.deepEqual(resolveAttachmentAssociationTarget(job), {
claimId: 'draft-auto',
claimNo: 'BX-AUTO-001'
})
assert.match(resultMessage.content, /已完成自动归集/)
assert.equal(resultMessage.suggestedActions[0].label, '查看草稿')
assert.equal(resultMessage.suggestedActions[0].payload.claim_id, 'draft-auto')
assert.equal(notification.claimId, 'draft-auto')
})