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') })