237 lines
9.4 KiB
JavaScript
237 lines
9.4 KiB
JavaScript
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')
|
|
})
|