import { ref } from 'vue' import { buildApplicationTemplatePreview, buildLocalApplicationPreviewMessage } from '../../utils/expenseApplicationPreview.js' import { fetchExpenseClaims } from '../../services/reimbursements.js' import { buildRequiredApplicationActions, buildRequiredApplicationMissingText, buildRequiredApplicationSelectionText, filterRequiredApplicationCandidates, requiresApplicationBeforeReimbursement } from './travelReimbursementApplicationLinkModel.js' import { GUIDED_ACTION_START_APPLICATION, GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW, GUIDED_ACTION_CONTINUE_FILLING, GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR, GUIDED_ACTION_PROCESS_INTERRUPTION, GUIDED_ACTION_SELECT_EXPENSE_TYPE, GUIDED_ACTION_SELECT_REQUIRED_APPLICATION, GUIDED_ACTION_SELECT_QUERY_MODE, GUIDED_ACTION_SELECT_QUERY_STATUS, GUIDED_ACTION_START_REIMBURSEMENT, GUIDED_ACTION_START_STATUS_QUERY, GUIDED_FLOW_MODE_REIMBURSEMENT, GUIDED_FLOW_MODE_STATUS_QUERY, applyGuidedReimbursementAnswer, buildGuidedExpenseTypeActions, buildGuidedInterruptionActions, buildGuidedInterruptionText, buildGuidedQueryModeActions, buildGuidedQueryPromptText, buildGuidedQueryStatusActions, buildGuidedReimbursementStartText, buildGuidedReimbursementSummaryText, buildGuidedReviewConfirmationActions, buildGuidedReviewSubmitOptions, buildGuidedStatusQueryStartText, buildGuidedStatusQueryText, buildGuidedStepPromptText, createEmptyGuidedFlowState, createGuidedReimbursementState, createGuidedStatusQueryState, getCurrentGuidedStep, isGuidedFlowActive, isGuidedReimbursementReadyForReview, normalizeGuidedFlowState, resolveGuidedExpenseTypeFromText, resolveGuidedQueryModeFromText, selectGuidedExpenseType, selectGuidedRequiredApplication, selectGuidedQueryMode, shouldConfirmGuidedInterruption, waitForGuidedApplicationSelection } from './travelReimbursementGuidedFlowModel.js' function normalizeText(value) { return String(value || '').trim() } function buildFileNames(files) { return Array.from(files || []) .map((file) => normalizeText(file?.name)) .filter(Boolean) } function mergePendingFiles(currentFiles, nextFiles) { const merged = [...Array.from(currentFiles || [])] Array.from(nextFiles || []).forEach((file) => { const name = normalizeText(file?.name) if (!name) return const duplicated = merged.some((item) => normalizeText(item?.name) === name && Number(item?.size || 0) === Number(file?.size || 0)) if (!duplicated) { merged.push(file) } }) return merged } export function useTravelReimbursementGuidedFlow({ guidedFlowState, messages, composerDraft, attachedFiles, composerBusinessTimeTags, composerBusinessTimeDraftTouched, fileInputRef, submitting, reviewActionBusy, sessionSwitchBusy, createMessage, nextTick, scrollToBottom, persistSessionState, clearAttachedFiles, adjustComposerTextareaHeight, buildComposerBusinessTimeContext, openTravelCalculator, lockSuggestedActionMessage, submitExistingComposer, currentUser, refreshCurrentUserFromBackend, toast }) { const guidedPendingFiles = ref([]) function persistAndScroll() { persistSessionState() nextTick(() => { adjustComposerTextareaHeight?.() scrollToBottom?.() }) } function clearComposerRuntime() { composerDraft.value = '' clearAttachedFiles?.() if (fileInputRef?.value) { fileInputRef.value.value = '' } if (composerBusinessTimeTags) { composerBusinessTimeTags.value = [] } if (composerBusinessTimeDraftTouched) { composerBusinessTimeDraftTouched.value = false } } function pushAssistant(text, extras = {}) { messages.value.push(createMessage('assistant', text, [], extras)) } function pushUser(text, attachmentNames = []) { const normalizedText = normalizeText(text) messages.value.push(createMessage('user', normalizedText || `上传 ${attachmentNames.length} 份附件`, attachmentNames)) } function resetGuidedFlowState() { guidedFlowState.value = createEmptyGuidedFlowState() guidedPendingFiles.value = [] } function startGuidedReimbursement() { guidedFlowState.value = createGuidedReimbursementState() guidedPendingFiles.value = [] pushAssistant(buildGuidedReimbursementStartText(), { meta: ['引导式报销'], suggestedActions: buildGuidedExpenseTypeActions() }) persistAndScroll() } async function resolveApplicationPreviewUser() { const user = currentUser?.value || {} if (String(user.position || '').trim() || typeof refreshCurrentUserFromBackend !== 'function') { return user } await refreshCurrentUserFromBackend({ silent: true }) return currentUser?.value || user } async function startGuidedApplicationTemplate() { resetGuidedFlowState() const applicationPreview = buildApplicationTemplatePreview(await resolveApplicationPreviewUser()) pushAssistant(buildLocalApplicationPreviewMessage(applicationPreview), { meta: ['申请模板'], applicationPreview }) persistAndScroll() } function startGuidedStatusQuery() { guidedFlowState.value = createGuidedStatusQueryState() guidedPendingFiles.value = [] pushAssistant(buildGuidedStatusQueryStartText(), { meta: ['引导式查询'], suggestedActions: buildGuidedQueryModeActions() }) persistAndScroll() } async function handleGuidedShortcut(shortcut) { const actionType = normalizeText(shortcut?.action) if (actionType === GUIDED_ACTION_START_APPLICATION) { await startGuidedApplicationTemplate() return true } if (actionType === GUIDED_ACTION_START_REIMBURSEMENT) { startGuidedReimbursement() return true } if (actionType === GUIDED_ACTION_START_STATUS_QUERY) { startGuidedStatusQuery() return true } if (actionType === GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR) { openTravelCalculator?.() pushAssistant('差旅计算器已打开。你可以直接填写目的地、天数和金额,我会按规则中心标准帮你测算。', { meta: ['差旅计算器'] }) persistAndScroll() return true } return false } function buildAnswerText(rawText, state) { const text = normalizeText(rawText) if (text) { return text } const currentStep = getCurrentGuidedStep(state) if (currentStep?.key === 'time_range') { const businessTimeContext = buildComposerBusinessTimeContext?.() return normalizeText(businessTimeContext?.time_range || businessTimeContext?.business_time) } return '' } function pushNextReimbursementPrompt() { pushAssistant(buildGuidedStepPromptText(guidedFlowState.value), { meta: ['引导式报销'] }) } function pushReimbursementSummary() { pushAssistant(buildGuidedReimbursementSummaryText(guidedFlowState.value), { meta: ['待生成报销草稿'], suggestedActions: buildGuidedReviewConfirmationActions() }) } async function selectExpenseTypeForGuidedReimbursement(currentState, expenseType, options = {}) { const nextState = options.pendingSceneSelection ? { ...currentState, values: { ...currentState.values, pending_scene_original_message: normalizeText(options.pendingSceneSelection.originalMessage), pending_scene_expense_type_label: normalizeText(options.pendingSceneSelection.expenseTypeLabel) } } : currentState if (!requiresApplicationBeforeReimbursement(expenseType)) { guidedFlowState.value = selectGuidedExpenseType(nextState, expenseType) pushNextReimbursementPrompt() return } let claimsPayload = null try { claimsPayload = await fetchExpenseClaims() } catch (error) { console.warn('Fetch reimbursement applications failed:', error) guidedFlowState.value = createEmptyGuidedFlowState() pushAssistant('查询可关联申请单时出现异常,请稍后再试。为避免直接报销,我先暂停当前流程。', { meta: ['申请单查询失败'] }) toast?.('申请单查询失败,请稍后再试') return } const applications = filterRequiredApplicationCandidates(claimsPayload, expenseType, currentUser?.value || {}) if (!applications.length) { guidedFlowState.value = createEmptyGuidedFlowState() pushAssistant(buildRequiredApplicationMissingText(expenseType), { meta: ['缺少可关联申请单'] }) return } guidedFlowState.value = waitForGuidedApplicationSelection(nextState, expenseType, applications) pushAssistant(buildRequiredApplicationSelectionText(expenseType, applications), { meta: ['等待关联申请单'], suggestedActions: buildRequiredApplicationActions(applications, GUIDED_ACTION_SELECT_REQUIRED_APPLICATION) }) } function buildPendingSceneSubmitOptions(state) { const current = normalizeGuidedFlowState(state) const originalMessage = normalizeText(current.values.pending_scene_original_message) const expenseTypeLabel = normalizeText(current.values.pending_scene_expense_type_label) const applicationNo = normalizeText(current.values.application_claim_no) const applicationId = normalizeText(current.values.application_claim_id) const applicationReason = normalizeText(current.values.application_reason) const applicationLocation = normalizeText(current.values.application_location) const applicationBusinessTime = normalizeText(current.values.application_business_time) const applicationTransportMode = normalizeText(current.values.application_transport_mode) if (!originalMessage || !expenseTypeLabel || !applicationNo) { return null } const rawText = [ originalMessage, `用户选择报销场景:${expenseTypeLabel}`, `关联申请单:${applicationNo}` ].join('\n') return { rawText, userText: `关联申请单 ${applicationNo}`, pendingText: `已关联申请单,正在生成${expenseTypeLabel}草稿...`, systemGenerated: true, skipUserMessage: true, extraContext: { draft_claim_id: '', review_action: 'save_draft', user_input_text: originalMessage, expense_scene_selection: { expense_type: current.expenseType || 'other', expense_type_label: expenseTypeLabel, original_message: originalMessage, application_claim_id: applicationId, application_claim_no: applicationNo }, review_form_values: { expense_type: expenseTypeLabel, reason: applicationReason, location: applicationLocation, time_range: applicationBusinessTime, transport_mode: applicationTransportMode, amount: '', application_claim_id: applicationId, application_claim_no: applicationNo, application_reason: applicationReason, application_location: applicationLocation, application_amount: current.values.application_amount || '', application_amount_label: current.values.application_amount_label || '', application_business_time: applicationBusinessTime, application_days: current.values.application_days || '', application_transport_mode: current.values.application_transport_mode || '', application_lodging_daily_cap: current.values.application_lodging_daily_cap || '', application_subsidy_daily_cap: current.values.application_subsidy_daily_cap || '', application_transport_policy: current.values.application_transport_policy || '', application_policy_estimate: current.values.application_policy_estimate || '', application_rule_name: current.values.application_rule_name || '', application_rule_version: current.values.application_rule_version || '' } } } } async function handleReimbursementAnswer(answerText, files) { const currentState = normalizeGuidedFlowState(guidedFlowState.value) const currentStep = getCurrentGuidedStep(currentState) const fileNames = buildFileNames(files) if (isGuidedReimbursementReadyForReview(currentState) && fileNames.length) { const mergedFiles = mergePendingFiles(guidedPendingFiles.value, files) guidedPendingFiles.value = mergedFiles const submitOptions = { ...buildGuidedReviewSubmitOptions(currentState, mergedFiles), skipDraftAssociationPrompt: true, skipUserMessage: true, pendingText: '已关联申请单,正在识别票据并生成报销草稿...' } resetGuidedFlowState() persistAndScroll() await submitExistingComposer(submitOptions) return } if (currentState.stepKey === 'expense_type') { const expenseType = resolveGuidedExpenseTypeFromText(answerText) if (!expenseType) { pushAssistant('我还需要先确认报销类型。请点击下面最贴近的费用场景后,我再继续问下一项。', { meta: ['等待选择报销类型'], suggestedActions: buildGuidedExpenseTypeActions() }) return } await selectExpenseTypeForGuidedReimbursement(currentState, expenseType) return } if (currentState.stepKey === 'application_selection') { pushAssistant('请先点击上方列出的申请单完成关联。关联后,我会直接进入生成报销草稿。', { meta: ['等待关联申请单'], suggestedActions: buildRequiredApplicationActions( currentState.applicationCandidates, GUIDED_ACTION_SELECT_REQUIRED_APPLICATION ) }) return } if (!currentStep) { pushAssistant(buildGuidedReimbursementStartText(), { meta: ['引导式报销'], suggestedActions: buildGuidedExpenseTypeActions() }) return } if (!answerText && fileNames.length && currentStep.key !== 'attachments') { guidedPendingFiles.value = mergePendingFiles(guidedPendingFiles.value, files) pushAssistant([ `我已先记录 ${fileNames.length} 份附件。`, '', `当前还需要补充:${currentStep.summaryLabel}。`, currentStep.prompt ].join('\n'), { meta: ['已记录附件'] }) return } if (fileNames.length) { guidedPendingFiles.value = mergePendingFiles(guidedPendingFiles.value, files) } guidedFlowState.value = applyGuidedReimbursementAnswer(currentState, answerText, fileNames) if (isGuidedReimbursementReadyForReview(guidedFlowState.value)) { pushReimbursementSummary() return } pushNextReimbursementPrompt() } async function runStatusQuery(queryText, skipUserMessage = true) { const normalizedQuery = normalizeText(queryText) resetGuidedFlowState() clearComposerRuntime() persistAndScroll() if (!normalizedQuery) { return true } await submitExistingComposer({ rawText: normalizedQuery, userText: normalizedQuery, pendingText: '正在查询单据状态...', skipUserMessage }) return true } async function handleStatusQueryAnswer(answerText) { const currentState = normalizeGuidedFlowState(guidedFlowState.value) if (currentState.stepKey === 'query_mode') { const queryMode = resolveGuidedQueryModeFromText(answerText) if (!queryMode) { pushAssistant(buildGuidedStatusQueryStartText(), { meta: ['引导式查询'], suggestedActions: buildGuidedQueryModeActions() }) return true } guidedFlowState.value = selectGuidedQueryMode(currentState, queryMode) const actions = guidedFlowState.value.stepKey === 'status_value' ? buildGuidedQueryStatusActions() : [] pushAssistant(buildGuidedQueryPromptText(guidedFlowState.value), { meta: ['引导式查询'], suggestedActions: actions }) return true } const queryText = buildGuidedStatusQueryText(currentState, answerText) return runStatusQuery(queryText, true) } async function handleGuidedComposerSubmit(options = {}) { const currentState = normalizeGuidedFlowState(guidedFlowState.value) if (!isGuidedFlowActive(currentState)) { return false } if (options.systemGenerated || normalizeText(options.extraContext?.review_action)) { return false } if (submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) { return true } const files = Array.from(options.files ?? attachedFiles.value ?? []) const fileNames = buildFileNames(files) const answerText = buildAnswerText(options.rawText ?? composerDraft.value, currentState) if (!answerText && !fileNames.length) { return true } pushUser(answerText, fileNames) if (shouldConfirmGuidedInterruption(answerText, currentState) && !fileNames.length) { guidedFlowState.value = { ...currentState, pendingInterruptionText: answerText } pushAssistant(buildGuidedInterruptionText(answerText), { meta: ['等待确认是否打断'], suggestedActions: buildGuidedInterruptionActions() }) clearComposerRuntime() persistAndScroll() return true } if (currentState.mode === GUIDED_FLOW_MODE_REIMBURSEMENT) { await handleReimbursementAnswer(answerText, files) clearComposerRuntime() persistAndScroll() return true } if (currentState.mode === GUIDED_FLOW_MODE_STATUS_QUERY) { clearComposerRuntime() persistAndScroll() await handleStatusQueryAnswer(answerText) return true } return false } async function handleGuidedSuggestedAction(message, action) { const actionType = normalizeText(action?.action_type) if (!actionType) { return false } const guidedActionTypes = new Set([ GUIDED_ACTION_SELECT_EXPENSE_TYPE, GUIDED_ACTION_SELECT_REQUIRED_APPLICATION, GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW, GUIDED_ACTION_CONTINUE_FILLING, GUIDED_ACTION_PROCESS_INTERRUPTION, GUIDED_ACTION_SELECT_QUERY_MODE, GUIDED_ACTION_SELECT_QUERY_STATUS ]) if (!guidedActionTypes.has(actionType)) { return false } if (submitting.value || reviewActionBusy.value || sessionSwitchBusy.value || message?.suggestedActionsLocked) { return true } if (!lockSuggestedActionMessage(message, action)) { return true } if (actionType === GUIDED_ACTION_SELECT_EXPENSE_TYPE) { const expenseType = normalizeText(action?.payload?.expense_type) const expenseTypeLabel = normalizeText(action?.payload?.expense_type_label || action?.label) pushUser(`选择${expenseTypeLabel || '报销类型'}`) await selectExpenseTypeForGuidedReimbursement(guidedFlowState.value, expenseType) persistAndScroll() return true } if (actionType === GUIDED_ACTION_SELECT_REQUIRED_APPLICATION) { const applicationNo = normalizeText(action?.payload?.application_claim_no || action?.label) pushUser(`关联申请单 ${applicationNo || ''}`.trim()) guidedFlowState.value = selectGuidedRequiredApplication(guidedFlowState.value, action?.payload || {}) const pendingSceneSubmitOptions = buildPendingSceneSubmitOptions(guidedFlowState.value) if (pendingSceneSubmitOptions) { resetGuidedFlowState() persistAndScroll() await submitExistingComposer(pendingSceneSubmitOptions) return true } if (isGuidedReimbursementReadyForReview(guidedFlowState.value)) { pushReimbursementSummary() persistAndScroll() return true } pushNextReimbursementPrompt() persistAndScroll() return true } if (actionType === GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW) { const submitOptions = buildGuidedReviewSubmitOptions(guidedFlowState.value, guidedPendingFiles.value) resetGuidedFlowState() persistAndScroll() await submitExistingComposer(submitOptions) return true } if (actionType === GUIDED_ACTION_CONTINUE_FILLING) { const pendingState = { ...normalizeGuidedFlowState(guidedFlowState.value), pendingInterruptionText: '' } guidedFlowState.value = pendingState if (pendingState.mode === GUIDED_FLOW_MODE_STATUS_QUERY) { pushAssistant(buildGuidedQueryPromptText(pendingState), { meta: ['引导式查询'], suggestedActions: pendingState.stepKey === 'status_value' ? buildGuidedQueryStatusActions() : [] }) } else { pushNextReimbursementPrompt() } persistAndScroll() return true } if (actionType === GUIDED_ACTION_PROCESS_INTERRUPTION) { const pendingText = normalizeText(guidedFlowState.value?.pendingInterruptionText) resetGuidedFlowState() persistAndScroll() await submitExistingComposer({ rawText: pendingText, userText: pendingText, pendingText: '正在处理你的问题...', skipUserMessage: true }) return true } if (actionType === GUIDED_ACTION_SELECT_QUERY_MODE) { const queryMode = normalizeText(action?.payload?.query_mode) const queryModeLabel = normalizeText(action?.payload?.query_mode_label || action?.label) guidedFlowState.value = selectGuidedQueryMode(guidedFlowState.value, queryMode) pushUser(`选择${queryModeLabel || '查询方式'}`) pushAssistant(buildGuidedQueryPromptText(guidedFlowState.value), { meta: ['引导式查询'], suggestedActions: guidedFlowState.value.stepKey === 'status_value' ? buildGuidedQueryStatusActions() : [] }) persistAndScroll() return true } if (actionType === GUIDED_ACTION_SELECT_QUERY_STATUS) { const statusLabel = normalizeText(action?.payload?.query_status_label || action?.label) pushUser(`选择${statusLabel || '单据状态'}`) const queryText = buildGuidedStatusQueryText(guidedFlowState.value, statusLabel) await runStatusQuery(queryText, true) return true } return false } async function handleSceneSelectionApplicationGate(message, action) { const actionType = normalizeText(action?.action_type) if (actionType !== 'select_expense_type') { return false } const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {} const expenseType = normalizeText(actionPayload.expense_type) if (!requiresApplicationBeforeReimbursement(expenseType)) { return false } const expenseTypeLabel = normalizeText(actionPayload.expense_type_label || action?.label) const originalMessage = normalizeText(actionPayload.original_message || message?.text) if (!expenseTypeLabel || !originalMessage) { return false } if (!lockSuggestedActionMessage(message, action)) { return true } guidedPendingFiles.value = [] pushUser(`选择${expenseTypeLabel}`) await selectExpenseTypeForGuidedReimbursement(createGuidedReimbursementState(), expenseType, { pendingSceneSelection: { originalMessage, expenseTypeLabel } }) persistAndScroll() return true } return { handleGuidedShortcut, handleGuidedComposerSubmit, handleGuidedSuggestedAction, handleSceneSelectionApplicationGate, resetGuidedFlowState } }