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