refactor: enforce 800 line source limits
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user