Files
X-Financial/web/src/views/scripts/useTravelReimbursementGuidedFlow.js

671 lines
23 KiB
JavaScript
Raw Normal View History

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