feat(expense): add persistent zero-entry receipt association
This commit is contained in:
236
web/tests/attachment-association-job-result.test.mjs
Normal file
236
web/tests/attachment-association-job-result.test.mjs
Normal file
@@ -0,0 +1,236 @@
|
||||
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')
|
||||
})
|
||||
Reference in New Issue
Block a user