feat(expense): add persistent zero-entry receipt association

This commit is contained in:
caoxiaozhu
2026-07-16 10:23:23 +08:00
parent 54754b5502
commit ae3f02c35a
30 changed files with 4450 additions and 810 deletions

View File

@@ -410,8 +410,8 @@ export function useWorkbenchAiAttachmentAssociationFlow({
return []
}
return [{
label: '查看单据',
description: '打开已归集票据的报销。',
label: '查看草稿',
description: '打开已归集票据的报销草稿。',
icon: 'mdi mdi-open-in-new',
action_type: 'open_application_detail',
payload: {

View File

@@ -1,5 +1,13 @@
import * as aiAttachmentAssociationModel from '../../utils/aiAttachmentAssociationModel.js'
import { fetchAttachmentAssociationJob } from '../../services/attachmentAssociationJobs.js'
import {
buildAttachmentAssociationCandidateActions,
isAttachmentAssociationConfirmationResult,
resolveAttachmentAssociationTarget
} from '../../utils/attachmentAssociationJobModel.js'
import {
fetchAttachmentAssociationJob,
normalizeAttachmentAssociationJob
} from '../../services/attachmentAssociationJobs.js'
const ATTACHMENT_ASSOCIATION_JOB_POLL_INTERVAL_MS = 1200
const ATTACHMENT_ASSOCIATION_JOB_MAX_POLLS = 90
@@ -25,23 +33,7 @@ export function useWorkbenchAiAttachmentAssociationJobs({
}
function normalizeJob(job = {}) {
const jobId = String(job?.job_id || job?.jobId || '').trim()
if (!jobId) {
return null
}
return {
jobId,
status: String(job?.status || 'queued').trim() || 'queued',
message: String(job?.message || '').trim(),
receiptIds: (Array.isArray(job?.receipt_ids) ? job.receipt_ids : job?.receiptIds || [])
.map((item) => String(item || '').trim())
.filter(Boolean),
claimId: String(job?.claim_id || job?.claimId || '').trim(),
claimNo: String(job?.claim_no || job?.claimNo || '').trim(),
uploadedCount: Number(job?.uploaded_count ?? job?.uploadedCount ?? 0) || 0,
skippedCount: Number(job?.skipped_count ?? job?.skippedCount ?? 0) || 0,
error: String(job?.error || '').trim()
}
return normalizeAttachmentAssociationJob(job)
}
function isPending(job = {}) {
@@ -112,12 +104,39 @@ export function useWorkbenchAiAttachmentAssociationJobs({
if (!findMessage(messageId)) {
return true
}
if (isAttachmentAssociationConfirmationResult(normalizedJob)) {
const confirmationMessage = aiAttachmentAssociationModel.buildAiAttachmentAssociationConfirmationResultMessage({
job: normalizedJob,
fileNames
})
await streamOrSetInlineAssistantContent(messageId, confirmationMessage)
replaceJobMessage(messageId, confirmationMessage, {
attachmentAssociationJob: normalizedJob,
attachmentOcrDetails,
stewardPlan: {
streamStatus: 'completed',
thinkingEvents: buildThinkingEvents('completed')
},
suggestedActions: buildAttachmentAssociationCandidateActions(normalizedJob)
})
persistCurrentConversation()
return true
}
if (normalizedJob.status === 'succeeded') {
const target = resolveAttachmentAssociationTarget(normalizedJob)
const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationResultMessage({
claimNo: normalizedJob.claimNo,
claimNo: target.claimNo,
fileNames,
uploadedCount: normalizedJob.uploadedCount,
skippedCount: normalizedJob.skippedCount
skippedCount: normalizedJob.skippedCount,
applicationClaimNo: normalizedJob.applicationClaimNo,
confidence: normalizedJob.confidence,
matchReasons: normalizedJob.matchReasons,
riskItems: normalizedJob.riskItems,
confirmationRequired: Boolean(
normalizedJob.draftPayload?.confirmationRequired
?? normalizedJob.draftPayload?.confirmation_required
)
})
await streamOrSetInlineAssistantContent(messageId, finalMessageText)
replaceJobMessage(messageId, finalMessageText, {
@@ -128,13 +147,13 @@ export function useWorkbenchAiAttachmentAssociationJobs({
thinkingEvents: buildThinkingEvents('completed')
},
suggestedActions: buildDetailActions({
claimId: normalizedJob.claimId,
claimNo: normalizedJob.claimNo
claimId: target.claimId,
claimNo: target.claimNo
})
})
notifyRequestUpdated?.({
claimId: normalizedJob.claimId,
claimNo: normalizedJob.claimNo,
claimId: target.claimId,
claimNo: target.claimNo,
source: 'ai-workbench-attachment-association-job',
uploadedCount: normalizedJob.uploadedCount,
skippedCount: normalizedJob.skippedCount
@@ -244,7 +263,7 @@ export function useWorkbenchAiAttachmentAssociationJobs({
extractReceiptIdsFromOcrDocuments,
normalizeJob,
pollJob,
resumePendingJobs
resumePendingJobs,
updateJobMessage
}
}

View File

@@ -1,3 +1,5 @@
import { normalizeAttachmentAssociationJob } from '../../utils/attachmentAssociationJobModel.js'
export const AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION = 'confirm_ai_attachment_association'
export const AI_ATTACHMENT_OCR_DETAIL_ACTION = 'show_ai_attachment_ocr_details'
@@ -212,28 +214,7 @@ export function createWorkbenchAiMessageRuntime() {
}
export function normalizeInlineAttachmentAssociationJob(job = null) {
if (!job || typeof job !== 'object') {
return null
}
const jobId = String(job.jobId || job.job_id || '').trim()
if (!jobId) {
return null
}
const status = String(job.status || 'queued').trim() || 'queued'
const receiptIds = (Array.isArray(job.receiptIds) ? job.receiptIds : job.receipt_ids || [])
.map((item) => String(item || '').trim())
.filter(Boolean)
return {
jobId,
status,
message: String(job.message || '').trim(),
receiptIds,
claimId: String(job.claimId || job.claim_id || '').trim(),
claimNo: String(job.claimNo || job.claim_no || '').trim(),
uploadedCount: Number(job.uploadedCount ?? job.uploaded_count ?? 0) || 0,
skippedCount: Number(job.skippedCount ?? job.skipped_count ?? 0) || 0,
error: String(job.error || '').trim()
}
return normalizeAttachmentAssociationJob(job)
}
export function normalizeInlineLinkedReimbursementDraftJob(job = null) {

View File

@@ -1,21 +1,25 @@
import { apiRequest } from './api.js'
import { normalizeAttachmentAssociationJob } from '../utils/attachmentAssociationJobModel.js'
export { normalizeAttachmentAssociationJob } from '../utils/attachmentAssociationJobModel.js'
function normalizeJobId(jobId) {
return String(jobId || '').trim()
}
export function createAttachmentAssociationJob(payload = {}) {
return apiRequest('/reimbursements/attachment-association-jobs', {
export async function createAttachmentAssociationJob(payload = {}) {
const job = await apiRequest('/reimbursements/attachment-association-jobs', {
method: 'POST',
body: JSON.stringify(payload || {})
})
return normalizeAttachmentAssociationJob(job)
}
export function fetchAttachmentAssociationJob(jobId) {
export async function fetchAttachmentAssociationJob(jobId) {
const normalizedJobId = normalizeJobId(jobId)
if (!normalizedJobId) {
throw new Error('附件关联任务不存在或已失效。')
}
return apiRequest(`/reimbursements/attachment-association-jobs/${encodeURIComponent(normalizedJobId)}`)
const job = await apiRequest(`/reimbursements/attachment-association-jobs/${encodeURIComponent(normalizedJobId)}`)
return normalizeAttachmentAssociationJob(job)
}

View File

@@ -449,25 +449,136 @@ export function buildAiAttachmentAssociationResultMessage({
claimNo = '',
uploadedCount = 0,
skippedCount = 0,
fileNames = []
fileNames = [],
applicationClaimNo = '',
confidence = '',
matchReasons = [],
riskItems = [],
confirmationRequired = false
} = {}) {
const normalizedUploadedCount = Math.max(0, Number(uploadedCount || 0))
const normalizedSkippedCount = Math.max(0, Number(skippedCount || 0))
const done = normalizedUploadedCount > 0 && normalizedSkippedCount === 0
const alreadyAssociated = normalizedUploadedCount === 0 && normalizedSkippedCount > 0
const completed = normalizedUploadedCount + normalizedSkippedCount > 0
const riskText = formatAssociationIssueList(riskItems)
const requiresReview = Boolean(confirmationRequired || riskText)
const resultText = alreadyAssociated
? `${normalizedSkippedCount} 份已关联,无需重复归集`
: `${normalizedUploadedCount} 份新归集${normalizedSkippedCount ? `${normalizedSkippedCount} 份已关联并跳过` : ''}`
return [
done ? '已完成自动归集。' : '自动归集已处理完成,请留意未归集附件。',
alreadyAssociated
? '票据此前已经完成归集,本次未重复写入。'
: completed
? '已完成自动归集。'
: '自动归集未写入附件,请进入草稿核对。',
renderAssociationCard({
title: done ? '票据已归集' : '票据归集结果',
status: done ? '已完成' : '部分完成',
tone: done ? 'is-success' : 'is-warning',
title: alreadyAssociated ? '票据已关联' : completed ? '票据已归集' : '票据归集结果',
status: requiresReview ? '待复核' : completed ? '已完成' : '完成',
tone: requiresReview || !completed ? 'is-warning' : 'is-success',
fields: [
renderAssociationField('关联单据', claimNo || '当前匹配单据'),
renderAssociationField('归集结果', `${normalizedUploadedCount} 份成功${normalizedSkippedCount ? `${normalizedSkippedCount} 份未归集` : ''}`),
applicationClaimNo
? renderAssociationField('来源申请', applicationClaimNo)
: '',
renderAssociationField('归集结果', resultText),
confidence
? renderAssociationField('匹配置信度', confidence)
: '',
Array.isArray(matchReasons) && matchReasons.length
? renderAssociationField('匹配依据', matchReasons.join(''), { wide: true, muted: true })
: '',
riskText
? renderAssociationField('风险提示', riskText, { wide: true })
: '',
renderAssociationField('附件', formatAttachmentNames(fileNames), { wide: true })
],
note: done
? '附件已经写入该报销单,可进入详情页继续核对。'
: '部分附件没有找到可用明细项,请进入详情页手动核对。'
].filter(Boolean),
note: requiresReview
? '附件已关联,但草稿仍有风险或待确认项,请进入详情页复核后再提交。'
: completed
? '附件已经写入该报销单,可进入详情页继续核对。'
: '没有附件写入,请进入详情页手动核对。'
})
].join('\n\n')
}
function formatAssociationIssueList(items = []) {
return (Array.isArray(items) ? items : [])
.map((item) => String(item?.message || item?.description || item?.detail || item || '').trim())
.filter(Boolean)
.join('')
}
function formatServerCandidate(candidate = {}, index = 0) {
const claimNo = String(candidate?.claimNo || candidate?.claim_no || '').trim()
const applicationClaimNo = String(
candidate?.applicationClaimNo || candidate?.application_claim_no || ''
).trim()
const documentNo = claimNo || applicationClaimNo || '未编号单据'
const confidence = String(candidate?.confidence || '').trim()
const score = Number(candidate?.score ?? candidate?.confidenceScore ?? candidate?.confidence_score ?? 0) || 0
const reasons = Array.isArray(candidate?.matchReasons)
? candidate.matchReasons
: Array.isArray(candidate?.match_reasons)
? candidate.match_reasons
: []
return [
`${index + 1}. ${documentNo}`,
claimNo && applicationClaimNo ? `来源申请 ${applicationClaimNo}` : '',
confidence ? `置信度 ${confidence}${score ? `${score}` : ''}` : score ? `匹配分 ${score}` : '',
reasons.length ? reasons.join('、') : ''
].filter(Boolean).join('')
}
export function buildAiAttachmentAssociationConfirmationResultMessage({
job = null,
fileNames = []
} = {}) {
const candidates = (Array.isArray(job?.candidates) ? job.candidates : [])
.slice(0, 3)
.map((candidate, index) => formatServerCandidate(candidate, index))
const exceptions = formatAssociationIssueList(job?.exceptions)
const missingFields = formatAssociationIssueList(job?.missingFields || job?.missing_fields)
const riskItems = formatAssociationIssueList(job?.riskItems || job?.risk_items)
const matchReasons = (Array.isArray(job?.matchReasons) ? job.matchReasons : job?.match_reasons || [])
.map((item) => String(item || '').trim())
.filter(Boolean)
const confidence = String(job?.confidence || '').trim()
const confidenceScore = Number(job?.confidenceScore ?? job?.confidence_score ?? 0) || 0
return [
'我已完成票据匹配分析,但当前信息不足以安全自动归集。',
renderAssociationCard({
title: '候选单据需要确认',
status: '未归集',
tone: 'is-warning',
ariaLabel: '票据关联候选确认',
fields: [
renderAssociationField('本次附件', formatAttachmentNames(fileNames)),
confidence || confidenceScore
? renderAssociationField(
'整体置信度',
[confidence, confidenceScore ? String(confidenceScore) : ''].filter(Boolean).join(' / ')
)
: '',
matchReasons.length
? renderAssociationField('匹配依据', matchReasons.join(''), { wide: true, muted: true })
: '',
candidates.length
? renderAssociationField('候选单据', candidates.join(''), { wide: true })
: renderAssociationField('候选单据', '暂未形成可安全查看的候选单据', { wide: true }),
exceptions
? renderAssociationField('待处理异常', exceptions, { wide: true })
: '',
missingFields
? renderAssociationField('待补信息', missingFields, { wide: true })
: '',
riskItems
? renderAssociationField('风险提示', riskItems, { wide: true })
: ''
].filter(Boolean),
note: candidates.length
? '我没有写入任何草稿。请先查看候选单据,核对后再决定归集目标。'
: '我没有写入任何草稿,避免因信息不足造成误归集。'
})
].join('\n\n')
}

View File

@@ -0,0 +1,162 @@
function normalizeText(value = '') {
return String(value ?? '').trim()
}
function normalizeStringList(values = []) {
return (Array.isArray(values) ? values : [])
.map((item) => normalizeText(item))
.filter(Boolean)
}
function normalizeIssue(item = null) {
if (!item) {
return null
}
if (typeof item === 'string' || typeof item === 'number') {
const message = normalizeText(item)
return message ? { code: '', message } : null
}
if (typeof item !== 'object') {
return null
}
const code = normalizeText(item.code || item.type || item.key)
const message = normalizeText(
item.message || item.detail || item.description || item.reason || item.label || code
)
return message ? { code, message } : null
}
function normalizeIssueList(values = []) {
return (Array.isArray(values) ? values : [])
.map((item) => normalizeIssue(item))
.filter(Boolean)
}
function normalizeDraftPayload(payload = null) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return null
}
return {
...payload,
claimId: normalizeText(payload.claimId || payload.claim_id || payload.id),
claimNo: normalizeText(payload.claimNo || payload.claim_no)
}
}
export function normalizeAttachmentAssociationCandidate(candidate = null) {
if (!candidate || typeof candidate !== 'object') {
return null
}
const claimId = normalizeText(candidate.claimId || candidate.claim_id)
const claimNo = normalizeText(candidate.claimNo || candidate.claim_no)
const applicationClaimId = normalizeText(candidate.applicationClaimId || candidate.application_claim_id)
const applicationClaimNo = normalizeText(candidate.applicationClaimNo || candidate.application_claim_no)
if (!claimId && !claimNo && !applicationClaimId && !applicationClaimNo) {
return null
}
return {
targetType: normalizeText(candidate.targetType || candidate.target_type || 'expense_claim'),
expenseCaseId: normalizeText(candidate.expenseCaseId || candidate.expense_case_id),
applicationClaimId,
applicationClaimNo,
claimId,
claimNo,
confidence: normalizeText(candidate.confidence),
score: Number(candidate.score ?? candidate.confidence_score ?? 0) || 0,
matchReasons: normalizeStringList(candidate.matchReasons || candidate.match_reasons || [])
}
}
export function normalizeAttachmentAssociationJob(job = null) {
if (!job || typeof job !== 'object') {
return null
}
const jobId = normalizeText(job.jobId || job.job_id)
if (!jobId) {
return null
}
const resolution = normalizeText(job.resolution)
const requiresConfirmation = Boolean(
job.requiresConfirmation ?? job.requires_confirmation ?? resolution === 'requires_confirmation'
)
return {
jobId,
status: normalizeText(job.status || 'queued') || 'queued',
resolution: resolution || (requiresConfirmation ? 'requires_confirmation' : ''),
requiresConfirmation,
message: normalizeText(job.message),
receiptIds: normalizeStringList(job.receiptIds || job.receipt_ids || []),
expenseCaseId: normalizeText(job.expenseCaseId || job.expense_case_id),
applicationClaimId: normalizeText(job.applicationClaimId || job.application_claim_id),
applicationClaimNo: normalizeText(job.applicationClaimNo || job.application_claim_no),
claimId: normalizeText(job.claimId || job.claim_id),
claimNo: normalizeText(job.claimNo || job.claim_no),
confidence: normalizeText(job.confidence),
confidenceScore: Number(job.confidenceScore ?? job.confidence_score ?? 0) || 0,
matchReasons: normalizeStringList(job.matchReasons || job.match_reasons || []),
exceptions: normalizeIssueList(job.exceptions || []),
missingFields: normalizeIssueList(job.missingFields || job.missing_fields || []),
riskItems: normalizeIssueList(job.riskItems || job.risk_items || []),
candidates: (Array.isArray(job.candidates) ? job.candidates : [])
.map((candidate) => normalizeAttachmentAssociationCandidate(candidate))
.filter(Boolean),
draftPayload: normalizeDraftPayload(job.draftPayload || job.draft_payload),
uploadedCount: Number(job.uploadedCount ?? job.uploaded_count ?? 0) || 0,
skippedCount: Number(job.skippedCount ?? job.skipped_count ?? 0) || 0,
error: normalizeText(job.error)
}
}
export function isAttachmentAssociationConfirmationResult(job = null) {
const normalizedJob = normalizeAttachmentAssociationJob(job)
return Boolean(
normalizedJob?.requiresConfirmation || normalizedJob?.resolution === 'requires_confirmation'
)
}
export function resolveAttachmentAssociationTarget(job = null) {
const normalizedJob = normalizeAttachmentAssociationJob(job)
if (!normalizedJob) {
return { claimId: '', claimNo: '' }
}
const draftPayload = normalizedJob.draftPayload || {}
const firstCandidate = normalizedJob.candidates[0] || {}
return {
claimId: normalizeText(
normalizedJob.claimId || draftPayload.claimId || draftPayload.claim_id || draftPayload.id || firstCandidate.claimId
),
claimNo: normalizeText(
normalizedJob.claimNo || draftPayload.claimNo || draftPayload.claim_no || firstCandidate.claimNo
)
}
}
export function buildAttachmentAssociationCandidateActions(job = null) {
const normalizedJob = normalizeAttachmentAssociationJob(job)
if (!normalizedJob) {
return []
}
return normalizedJob.candidates.slice(0, 3).map((candidate, index) => {
const applicationOnly = !candidate.claimId && !candidate.claimNo && Boolean(
candidate.applicationClaimId || candidate.applicationClaimNo
)
const labelPrefix = applicationOnly ? '查看候选申请' : '查看候选草稿'
const documentNo = applicationOnly ? candidate.applicationClaimNo : candidate.claimNo
return {
label: normalizedJob.candidates.length > 1 ? `${labelPrefix} ${index + 1}` : labelPrefix,
description: documentNo
? `打开候选${applicationOnly ? '申请单' : '报销草稿'} ${documentNo} 核对详情。`
: `打开候选${applicationOnly ? '申请单' : '报销草稿'}核对详情。`,
icon: 'mdi mdi-open-in-new',
action_type: 'open_application_detail',
payload: {
claim_id: applicationOnly ? candidate.applicationClaimId : candidate.claimId,
claim_no: applicationOnly ? candidate.applicationClaimNo : candidate.claimNo,
document_type: applicationOnly ? 'application' : 'expense',
expense_case_id: candidate.expenseCaseId,
application_claim_id: candidate.applicationClaimId,
application_claim_no: candidate.applicationClaimNo
}
}
})
}

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