358 lines
13 KiB
JavaScript
358 lines
13 KiB
JavaScript
|
|
import * as aiAttachmentAssociationModel from '../../utils/aiAttachmentAssociationModel.js'
|
|||
|
|
import { syncExpenseClaimFilesToDraft } from '../../utils/expenseClaimAttachmentSync.js'
|
|||
|
|
import { collectReceiptFiles } from '../../views/scripts/travelReimbursementAttachmentModel.js'
|
|||
|
|
import {
|
|||
|
|
createExpenseClaimItem,
|
|||
|
|
extractExpenseClaimItems,
|
|||
|
|
fetchExpenseClaimDetail,
|
|||
|
|
fetchExpenseClaims,
|
|||
|
|
uploadExpenseClaimItemAttachment
|
|||
|
|
} from '../../services/reimbursements.js'
|
|||
|
|
import { recognizeOcrFiles } from '../../services/ocr.js'
|
|||
|
|
import {
|
|||
|
|
AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION,
|
|||
|
|
buildInlineAttachmentOcrDetails
|
|||
|
|
} from './workbenchAiMessageModel.js'
|
|||
|
|
import { isLikelyAiModeOcrFile } from './workbenchAiComposerModel.js'
|
|||
|
|
|
|||
|
|
function buildAiAttachmentAssociationThinkingEvents(status = 'running') {
|
|||
|
|
const completed = status === 'completed'
|
|||
|
|
const failed = status === 'failed'
|
|||
|
|
const eventStatus = failed ? 'failed' : completed ? 'completed' : 'running'
|
|||
|
|
return [
|
|||
|
|
{
|
|||
|
|
eventId: 'attachment-ocr',
|
|||
|
|
title: '识别上传票据',
|
|||
|
|
content: '提取票据里的日期、地点和行程信息。',
|
|||
|
|
status: eventStatus
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
eventId: 'claim-lookup',
|
|||
|
|
title: '查询可关联报销单',
|
|||
|
|
content: '查找草稿、待补充和退回状态的可归集单据。',
|
|||
|
|
status: eventStatus
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
eventId: 'claim-match',
|
|||
|
|
title: '匹配票据与报销单',
|
|||
|
|
content: '根据票据时间、城市和报销事由判断最可能的关联单据。',
|
|||
|
|
status: eventStatus
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resolveAiAttachmentAssociationClaimNo(payload = {}) {
|
|||
|
|
return String(payload?.claim_no || payload?.claimNo || '').trim()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildAiAttachmentAssociationResultThinkingEvents(status = 'running') {
|
|||
|
|
const completed = status === 'completed'
|
|||
|
|
const failed = status === 'failed'
|
|||
|
|
const eventStatus = failed ? 'failed' : completed ? 'completed' : 'running'
|
|||
|
|
return [
|
|||
|
|
{
|
|||
|
|
eventId: 'attachment-confirm',
|
|||
|
|
title: '确认自动归集',
|
|||
|
|
content: '正在读取匹配单据并准备写入附件。',
|
|||
|
|
status: eventStatus
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
eventId: 'attachment-upload',
|
|||
|
|
title: '归集票据附件',
|
|||
|
|
content: '把本次上传的票据写入报销单明细。',
|
|||
|
|
status: eventStatus
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function useWorkbenchAiAttachmentAssociationFlow({
|
|||
|
|
aiAttachmentAssociationRuntime,
|
|||
|
|
conversationMessages,
|
|||
|
|
createAiAttachmentAssociationId,
|
|||
|
|
createInlineMessage,
|
|||
|
|
inlineConversationAutoScrollPinned,
|
|||
|
|
persistCurrentConversation,
|
|||
|
|
replaceInlineMessage,
|
|||
|
|
scrollInlineConversationToBottom,
|
|||
|
|
sending,
|
|||
|
|
streamOrSetInlineAssistantContent,
|
|||
|
|
toast
|
|||
|
|
}) {
|
|||
|
|
async function collectAiModeReceiptContext(files = []) {
|
|||
|
|
const safeFiles = Array.isArray(files) ? files : []
|
|||
|
|
const attachmentNames = safeFiles
|
|||
|
|
.map((file) => String(file?.name || '').trim())
|
|||
|
|
.filter(Boolean)
|
|||
|
|
const ocrFiles = safeFiles.filter((file) => isLikelyAiModeOcrFile(file))
|
|||
|
|
const ocrSourceFileNames = ocrFiles
|
|||
|
|
.map((file) => String(file?.name || '').trim())
|
|||
|
|
.filter(Boolean)
|
|||
|
|
|
|||
|
|
const baseContext = {
|
|||
|
|
attachmentNames,
|
|||
|
|
attachmentCount: attachmentNames.length,
|
|||
|
|
ocrSourceFileNames,
|
|||
|
|
ocrSummary: '',
|
|||
|
|
ocrDocuments: []
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!ocrFiles.length) {
|
|||
|
|
return baseContext
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const collected = await collectReceiptFiles({
|
|||
|
|
files: ocrFiles,
|
|||
|
|
recognizeOcrFiles
|
|||
|
|
})
|
|||
|
|
return {
|
|||
|
|
...baseContext,
|
|||
|
|
ocrSummary: String(collected.ocrSummary || '').trim(),
|
|||
|
|
ocrDocuments: Array.isArray(collected.ocrDocuments) ? collected.ocrDocuments : []
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.warn('AI mode OCR request failed:', error)
|
|||
|
|
return {
|
|||
|
|
...baseContext,
|
|||
|
|
ocrError: error?.message || 'OCR识别失败,已继续使用附件名称。'
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function findAiAttachmentAssociationRuntime(options = {}) {
|
|||
|
|
const normalizedAssociationId = String(options.associationId || options.association_id || '').trim()
|
|||
|
|
const normalizedClaimNo = String(options.claimNo || options.claim_no || '').trim()
|
|||
|
|
if (normalizedAssociationId) {
|
|||
|
|
const runtime = aiAttachmentAssociationRuntime.get(normalizedAssociationId)
|
|||
|
|
if (runtime) {
|
|||
|
|
return { associationId: normalizedAssociationId, runtime }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (normalizedClaimNo) {
|
|||
|
|
for (const [runtimeId, runtime] of aiAttachmentAssociationRuntime.entries()) {
|
|||
|
|
if (String(runtime?.claimNo || '').trim() === normalizedClaimNo && runtime?.files?.length) {
|
|||
|
|
return { associationId: runtimeId, runtime }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { associationId: '', runtime: null }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateAiAttachmentAssociationActionState(message = {}, associationId = '', state = {}, options = {}) {
|
|||
|
|
const normalizedAssociationId = String(associationId || '').trim()
|
|||
|
|
const normalizedClaimNo = String(options.claimNo || options.claim_no || '').trim()
|
|||
|
|
if (!message || !Array.isArray(message.suggestedActions) || (!normalizedAssociationId && !normalizedClaimNo)) {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
message.suggestedActions = message.suggestedActions.map((action) => {
|
|||
|
|
const actionAssociationId = String(action?.payload?.association_id || action?.payload?.associationId || '').trim()
|
|||
|
|
const actionClaimNo = resolveAiAttachmentAssociationClaimNo(action?.payload || {})
|
|||
|
|
const isSameAssociation = normalizedAssociationId && actionAssociationId === normalizedAssociationId
|
|||
|
|
const isSameClaim = normalizedClaimNo && actionClaimNo === normalizedClaimNo
|
|||
|
|
if (String(action?.action_type || '').trim() !== AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION || (!isSameAssociation && !isSameClaim)) {
|
|||
|
|
return action
|
|||
|
|
}
|
|||
|
|
return {
|
|||
|
|
...action,
|
|||
|
|
...state
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildAiAttachmentAssociationDetailActions(runtime = {}) {
|
|||
|
|
const claimNo = String(runtime.claimNo || '').trim()
|
|||
|
|
const claimId = String(runtime.claimId || '').trim()
|
|||
|
|
if (!claimNo && !claimId) {
|
|||
|
|
return []
|
|||
|
|
}
|
|||
|
|
return [{
|
|||
|
|
label: '查看单据',
|
|||
|
|
description: '打开已归集票据的报销单。',
|
|||
|
|
icon: 'mdi mdi-open-in-new',
|
|||
|
|
action_type: 'open_application_detail',
|
|||
|
|
payload: {
|
|||
|
|
claim_id: claimId,
|
|||
|
|
claim_no: claimNo,
|
|||
|
|
document_type: 'expense'
|
|||
|
|
}
|
|||
|
|
}]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function confirmAiAttachmentAssociation(actionPayload = {}, sourceMessage = null) {
|
|||
|
|
const requestedAssociationId = String(actionPayload.association_id || actionPayload.associationId || '').trim()
|
|||
|
|
const payloadClaimNo = resolveAiAttachmentAssociationClaimNo(actionPayload)
|
|||
|
|
const runtimeResult = findAiAttachmentAssociationRuntime({
|
|||
|
|
associationId: requestedAssociationId,
|
|||
|
|
claimNo: payloadClaimNo
|
|||
|
|
})
|
|||
|
|
const associationId = runtimeResult.associationId
|
|||
|
|
const runtime = runtimeResult.runtime
|
|||
|
|
const actionClaimNo = payloadClaimNo || String(runtime?.claimNo || '').trim()
|
|||
|
|
if (!associationId || !runtime?.files?.length) {
|
|||
|
|
toast('当前会话里没有可归集的附件原件,请重新上传票据后再确认。')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
updateAiAttachmentAssociationActionState(sourceMessage, associationId, {
|
|||
|
|
label: '正在归集...',
|
|||
|
|
disabled: true
|
|||
|
|
}, { claimNo: actionClaimNo })
|
|||
|
|
persistCurrentConversation()
|
|||
|
|
sending.value = true
|
|||
|
|
|
|||
|
|
const pendingMessage = createInlineMessage('assistant', '', {
|
|||
|
|
pending: true,
|
|||
|
|
stewardPlan: {
|
|||
|
|
streamStatus: 'streaming',
|
|||
|
|
thinkingEvents: buildAiAttachmentAssociationResultThinkingEvents('running')
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
conversationMessages.value.push(pendingMessage)
|
|||
|
|
scrollInlineConversationToBottom()
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const syncResult = await syncExpenseClaimFilesToDraft({
|
|||
|
|
claimId: runtime.claimId,
|
|||
|
|
files: runtime.files,
|
|||
|
|
fetchExpenseClaimDetail,
|
|||
|
|
createExpenseClaimItem,
|
|||
|
|
uploadExpenseClaimItemAttachment
|
|||
|
|
})
|
|||
|
|
const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationResultMessage({
|
|||
|
|
claimNo: runtime.claimNo,
|
|||
|
|
fileNames: runtime.fileNames,
|
|||
|
|
uploadedCount: syncResult.uploadedCount,
|
|||
|
|
skippedCount: syncResult.skippedCount
|
|||
|
|
})
|
|||
|
|
await streamOrSetInlineAssistantContent(pendingMessage.id, finalMessageText)
|
|||
|
|
replaceInlineMessage(
|
|||
|
|
pendingMessage.id,
|
|||
|
|
createInlineMessage('assistant', finalMessageText, {
|
|||
|
|
id: pendingMessage.id,
|
|||
|
|
stewardPlan: {
|
|||
|
|
streamStatus: 'completed',
|
|||
|
|
thinkingEvents: buildAiAttachmentAssociationResultThinkingEvents('completed')
|
|||
|
|
},
|
|||
|
|
suggestedActions: buildAiAttachmentAssociationDetailActions(runtime)
|
|||
|
|
})
|
|||
|
|
)
|
|||
|
|
updateAiAttachmentAssociationActionState(sourceMessage, associationId, {
|
|||
|
|
label: '已自动关联',
|
|||
|
|
disabled: true
|
|||
|
|
}, { claimNo: actionClaimNo })
|
|||
|
|
aiAttachmentAssociationRuntime.delete(associationId)
|
|||
|
|
persistCurrentConversation()
|
|||
|
|
} catch (error) {
|
|||
|
|
const finalMessageText = error?.message || '自动归集失败,请稍后重试。'
|
|||
|
|
replaceInlineMessage(
|
|||
|
|
pendingMessage.id,
|
|||
|
|
createInlineMessage('assistant', finalMessageText, {
|
|||
|
|
id: pendingMessage.id,
|
|||
|
|
stewardPlan: {
|
|||
|
|
streamStatus: 'failed',
|
|||
|
|
thinkingEvents: buildAiAttachmentAssociationResultThinkingEvents('failed')
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
)
|
|||
|
|
updateAiAttachmentAssociationActionState(sourceMessage, associationId, {
|
|||
|
|
label: '重新自动关联',
|
|||
|
|
disabled: false
|
|||
|
|
}, { claimNo: actionClaimNo })
|
|||
|
|
toast(finalMessageText)
|
|||
|
|
persistCurrentConversation()
|
|||
|
|
} finally {
|
|||
|
|
sending.value = false
|
|||
|
|
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function requestAiAttachmentAssociationReply(prompt, entry = {}, files = []) {
|
|||
|
|
let shouldAutoScrollOnFinish = true
|
|||
|
|
const pendingMessage = createInlineMessage('assistant', '', {
|
|||
|
|
pending: true,
|
|||
|
|
stewardPlan: {
|
|||
|
|
streamStatus: 'streaming',
|
|||
|
|
thinkingEvents: buildAiAttachmentAssociationThinkingEvents('running')
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
conversationMessages.value.push(pendingMessage)
|
|||
|
|
scrollInlineConversationToBottom()
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const collected = await collectReceiptFiles({
|
|||
|
|
files,
|
|||
|
|
recognizeOcrFiles
|
|||
|
|
})
|
|||
|
|
const attachmentOcrDetails = buildInlineAttachmentOcrDetails(collected, files)
|
|||
|
|
const claimsPayload = await fetchExpenseClaims({ page: 1, pageSize: 100 })
|
|||
|
|
const claims = extractExpenseClaimItems(claimsPayload)
|
|||
|
|
const match = aiAttachmentAssociationModel.resolveAiAttachmentAssociationMatch(claims, collected.ocrDocuments)
|
|||
|
|
const associationRecord = match.best?.record || match.recommended?.record || null
|
|||
|
|
const associationId = associationRecord?.claimId
|
|||
|
|
? createAiAttachmentAssociationId()
|
|||
|
|
: ''
|
|||
|
|
if (associationId) {
|
|||
|
|
aiAttachmentAssociationRuntime.set(associationId, {
|
|||
|
|
files,
|
|||
|
|
fileNames: files.map((file) => file?.name || '').filter(Boolean),
|
|||
|
|
claimId: String(associationRecord.claimId || '').trim(),
|
|||
|
|
claimNo: String(associationRecord.claimNo || '').trim(),
|
|||
|
|
ocrPayload: collected.ocrPayload,
|
|||
|
|
ocrSummary: collected.ocrSummary,
|
|||
|
|
ocrDocuments: collected.ocrDocuments,
|
|||
|
|
ocrFilePreviews: collected.ocrFilePreviews
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationMessage({
|
|||
|
|
match,
|
|||
|
|
fileNames: files.map((file) => file?.name || ''),
|
|||
|
|
ocrDocuments: collected.ocrDocuments
|
|||
|
|
})
|
|||
|
|
await streamOrSetInlineAssistantContent(pendingMessage.id, finalMessageText)
|
|||
|
|
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
|
|||
|
|
replaceInlineMessage(
|
|||
|
|
pendingMessage.id,
|
|||
|
|
createInlineMessage('assistant', finalMessageText, {
|
|||
|
|
id: pendingMessage.id,
|
|||
|
|
stewardPlan: {
|
|||
|
|
streamStatus: 'completed',
|
|||
|
|
thinkingEvents: buildAiAttachmentAssociationThinkingEvents('completed')
|
|||
|
|
},
|
|||
|
|
attachmentOcrDetails,
|
|||
|
|
suggestedActions: aiAttachmentAssociationModel.buildAiAttachmentAssociationActions(match, associationId, {
|
|||
|
|
includeOcrDetails: Boolean(attachmentOcrDetails)
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
)
|
|||
|
|
persistCurrentConversation()
|
|||
|
|
} catch (error) {
|
|||
|
|
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
|
|||
|
|
const finalMessageText = error?.message || '票据识别或单据匹配失败,请稍后再试。'
|
|||
|
|
replaceInlineMessage(
|
|||
|
|
pendingMessage.id,
|
|||
|
|
createInlineMessage('assistant', finalMessageText, {
|
|||
|
|
id: pendingMessage.id,
|
|||
|
|
stewardPlan: {
|
|||
|
|
streamStatus: 'failed',
|
|||
|
|
thinkingEvents: buildAiAttachmentAssociationThinkingEvents('failed')
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
)
|
|||
|
|
toast(finalMessageText)
|
|||
|
|
persistCurrentConversation()
|
|||
|
|
} finally {
|
|||
|
|
sending.value = false
|
|||
|
|
scrollInlineConversationToBottom({ force: shouldAutoScrollOnFinish })
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
collectAiModeReceiptContext,
|
|||
|
|
confirmAiAttachmentAssociation,
|
|||
|
|
requestAiAttachmentAssociationReply,
|
|||
|
|
resolveAiAttachmentAssociationClaimNo
|
|||
|
|
}
|
|||
|
|
}
|