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