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