import { ATTACHMENT_ASSOCIATION_CONFIRM_HREF } from './travelReimbursementAttachmentModel.js' export function createSubmitAttachmentAssociationFlow({ activeReviewPayload, buildReviewFormContextFromPayload, createMessage, draftClaimId, emitDraftSaved, fetchReceiptFolderItems, isKnowledgeSession, messages, nextTick, persistSessionState, resetFlowRun, reviewInlineForm, scrollToBottom, sessionSwitchBusy, submitComposer, submitting, toast }) { const pendingAttachmentAssociations = new Map() function createPendingAttachmentAssociationId() { return `attachment-association-${Date.now()}-${Math.random().toString(16).slice(2)}` } function emitSavedDraftRefresh(draftPayload) { if (!emitDraftSaved || isKnowledgeSession.value || !draftPayload?.claim_no) { return } const draftType = String(draftPayload.draft_type || '').trim() emitDraftSaved({ claimId: String(draftPayload.claim_id || draftPayload.claimId || '').trim(), claimNo: String(draftPayload.claim_no || draftPayload.claimNo || '').trim(), status: String(draftPayload.status || '').trim(), approvalStage: String(draftPayload.approval_stage || draftPayload.approvalStage || '').trim(), documentType: draftType === 'expense_application' ? 'application' : 'reimbursement' }) } function normalizeRecognizedAttachmentData(data) { if (!data || typeof data !== 'object') { return null } const documents = Array.isArray(data.ocrDocuments) ? data.ocrDocuments : [] if (!documents.length) { return null } return { ocrPayload: data.ocrPayload || null, ocrSummary: String(data.ocrSummary || '').trim(), ocrDocuments: documents, ocrFilePreviews: Array.isArray(data.ocrFilePreviews) ? data.ocrFilePreviews : [] } } function hasReceiptFolderSourceFile(files) { return files.some((file) => String(file?.receiptId || '').trim()) } async function promptUnlinkedReceiptFolderIfNeeded({ detailScopedClaimId, files, fileNames, options, rawText, resolvedUploadDisposition, reviewAction, systemGenerated, userText }) { if ( isKnowledgeSession.value || systemGenerated || !files.length || detailScopedClaimId || resolvedUploadDisposition || options.skipReceiptFolderUnlinkedPrompt || options.skipDraftAssociationPrompt || reviewAction || hasReceiptFolderSourceFile(files) ) { return false } let unlinkedReceipts = [] try { unlinkedReceipts = await fetchReceiptFolderItems('unlinked') } catch (error) { console.warn('Failed to load unlinked receipt folder items before attachment upload:', error) return false } const count = Array.isArray(unlinkedReceipts) ? unlinkedReceipts.length : 0 if (!count) { return false } resetFlowRun() if (!options.skipUserMessage) { messages.value.push(createMessage('user', userText, fileNames)) } messages.value.push(createMessage( 'assistant', `票据夹中还有 ${count} 份未关联票据。建议先处理这些票据再上传新附件,避免重复保存或遗漏关联。`, [], { meta: ['票据夹待关联'], suggestedActions: [ { action_type: 'open_receipt_folder', label: '去票据夹关联', icon: 'mdi mdi-folder-open-outline', payload: { target_view: 'receiptFolder' } }, { action_type: 'continue_upload_with_unlinked_receipts', label: '继续上传新附件', icon: 'mdi mdi-upload-outline', payload: { raw_text: rawText } } ] } )) nextTick(scrollToBottom) persistSessionState() return true } function buildConfirmedAssociationText(message) { return String(message?.text || '') .replace(`[确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})`, '已确认') .replace(`[确定](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})`, '已确定') } function resolveReviewPanelScope({ reviewPayload = null, reviewAction = '', fileCount = 0, rawText = '' } = {}) { if (!reviewPayload || typeof reviewPayload !== 'object') { return '' } const normalizedAction = String(reviewAction || '').trim() const documentCount = Array.isArray(reviewPayload.document_cards) ? reviewPayload.document_cards.length : 0 const riskCount = Array.isArray(reviewPayload.risk_briefs) ? reviewPayload.risk_briefs.length : 0 const asksRisk = /风险|隐患|超标|异常|重复|待整改|风险项|高风险|中风险|低风险/.test(String(rawText || '')) if (fileCount > 0 && documentCount > 0) { return 'documents' } if (riskCount > 0 && (asksRisk || ['next_step', 'submit', 'submit_claim'].includes(normalizedAction))) { return 'risk' } if (!normalizedAction && fileCount === 0) { return 'overview' } return '' } async function confirmPendingAttachmentAssociation(message) { if (submitting.value || sessionSwitchBusy.value) return null const pending = message?.pendingAttachmentAssociation && typeof message.pendingAttachmentAssociation === 'object' ? message.pendingAttachmentAssociation : null const associationId = String(pending?.id || '').trim() if (!associationId || pending?.status === 'confirmed') { return null } const runtime = pendingAttachmentAssociations.get(associationId) if (!runtime || !Array.isArray(runtime.files) || !runtime.files.length) { toast('当前会话里没有可归集的附件原件,请重新上传票据后再确认。') return null } pending.status = 'confirmed' message.pendingAttachmentAssociation = pending message.text = buildConfirmedAssociationText(message) message.meta = ['已确认归集'] persistSessionState() if (pending.mode === 'save_then_associate') { const inheritedReviewContext = buildReviewFormContextFromPayload( activeReviewPayload.value, reviewInlineForm.value ) const savePayload = await submitComposer({ rawText: '请先把当前已识别的报销信息保存为草稿,随后继续归集本次上传的附件。', userText: '', files: [], skipUserMessage: true, pendingText: '正在先保存未保存单据...', systemGenerated: true, extraContext: { ...runtime.extraContext, ...inheritedReviewContext, review_action: 'save_draft' } }) const savedClaimId = String(savePayload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim() const savedClaimNo = String(savePayload?.result?.draft_payload?.claim_no || '').trim() if (!savedClaimId) { toast('当前单据还没有保存成功,请稍后重试。') return savePayload } return submitComposer({ rawText: `确认将本次上传的 ${runtime.fileNames.length} 份票据归集到草稿 ${savedClaimNo || '当前草稿'}`, userText: `保存草稿并归集 ${runtime.fileNames.length} 份票据`, files: runtime.files, uploadDisposition: 'continue_existing', skipDraftAssociationPrompt: true, skipUserMessage: true, appendToCurrentFlow: true, systemGenerated: true, pendingText: savedClaimNo ? `草稿 ${savedClaimNo} 已保存,正在识别并归集附件...` : '草稿已保存,正在识别并归集附件...', associationConfirmed: true, extraContext: { ...runtime.extraContext, review_action: 'link_to_existing_draft', draft_claim_id: savedClaimId, selected_claim_id: savedClaimId, selected_claim_no: savedClaimNo, attachment_association_confirmed: true } }) } return submitComposer({ rawText: `确认将本次上传的 ${runtime.fileNames.length} 份票据归集到草稿 ${runtime.claimNo || '当前草稿'}`, userText: `确认归集到草稿 ${runtime.claimNo || '当前草稿'}`, files: runtime.files, uploadDisposition: 'continue_existing', skipDraftAssociationPrompt: true, pendingText: runtime.claimNo ? `正在将票据归集到草稿 ${runtime.claimNo}...` : '正在将票据归集到当前草稿...', associationConfirmed: true, recognizedAttachmentData: { ocrPayload: runtime.ocrPayload, ocrSummary: runtime.ocrSummary, ocrDocuments: runtime.ocrDocuments, ocrFilePreviews: runtime.ocrFilePreviews }, extraContext: { ...runtime.extraContext, review_action: 'link_to_existing_draft', draft_claim_id: runtime.claimId, selected_claim_id: runtime.claimId, selected_claim_no: runtime.claimNo, attachment_association_confirmed: true } }) } return { confirmPendingAttachmentAssociation, createPendingAttachmentAssociationId, emitSavedDraftRefresh, normalizeRecognizedAttachmentData, pendingAttachmentAssociations, promptUnlinkedReceiptFolderIfNeeded, resolveReviewPanelScope } }