Files
X-Financial/web/src/views/scripts/useTravelReimbursementSuggestedActions.js
caoxiaozhu ba444a514f feat(web): 报销单新增关联申请单门控与草稿检测流程
- 新增 travelReimbursementAssociationGateModel,查询可关联申请单/草稿报销单并生成跳过/选择/单独新建动作,区分差旅费与业务招待费类型
- travelReimbursementApplicationLinkModel 补充 buildLinkedApplicationReferenceIndex/buildRequiredApplicationActions 等关联构建逻辑
- useTravelReimbursementSuggestedActions 接入 select_required_application/skip 系列动作,'我要报销'入口改为先走关联门控
- useWorkbenchAiActionRouter 新增 SKIP_REQUIRED_APPLICATION_LINK/SKIP_REIMBURSEMENT_DRAFT_CHECK 动作分发
- useWorkbenchAiExpenseFlow 暴露 startAiReimbursementAssociationGate,stewardPlanModel 待处理流程适配
- 新增 workbench-ai-action-router、workbench-ai-reimbursement-association-gate 测试并更新 guided-flow、steward-plan 测试
2026-06-22 15:55:59 +08:00

507 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
APPLICATION_TRANSPORT_MODE_OPTIONS,
normalizeApplicationPreview,
normalizeTransportModeOption
} from '../../utils/expenseApplicationPreview.js'
import {
mergeComposerPrefill,
resolveSuggestedActionPrefill
} from '../../utils/assistantSuggestedActionPrefill.js'
import { ASSISTANT_SCOPE_ACTION_SWITCH } from '../../utils/assistantSessionScope.js'
import { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js'
import {
TRAVEL_PLANNING_ACTION_GENERATE,
TRAVEL_PLANNING_ACTION_SKIP,
buildTravelPlanningRecommendation
} from '../../utils/travelApplicationPlanning.js'
import {
SESSION_TYPE_APPLICATION,
SESSION_TYPE_BUDGET,
SESSION_TYPE_EXPENSE,
canUseBudgetAssistantSession
} from './travelReimbursementConversationModel.js'
import {
SKIP_REQUIRED_APPLICATION_LINK_ACTION,
SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION,
buildReimbursementAssociationSubmitOptions,
pushReimbursementAssociationPromptMessage
} from './travelReimbursementAssociationGateModel.js'
import { STEWARD_ASSISTANT_NAME } from './useTravelReimbursementStewardRuntime.js'
import {
buildStewardFieldCompletionContinuation,
buildStewardFieldCompletionRawText
} from './stewardFieldCompletionModel.js'
import { MAX_ATTACHMENTS, VISIBLE_ATTACHMENT_CHIPS } from './travelReimbursementAttachmentModel.js'
export const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field'
export function useTravelReimbursementSuggestedActions({
applicationPreviewEditor,
attachedFiles,
buildExpenseSceneSelectionActions,
buildExpenseSceneSelectionMessage,
commitApplicationPreviewEditor,
composerDraft,
composerFilesExpanded,
composerTextareaRef,
createMessage,
currentUser,
emit,
fetchExpenseClaims = async () => ({ items: [] }),
handleGuidedShortcut,
handleGuidedSuggestedAction,
handleSceneSelectionApplicationGate,
lockSuggestedActionMessage,
mergeFilesWithLimit,
messages,
nextTick,
openApplicationPreviewEditor,
persistSessionState,
resolveApplicationPreviewMissingFields,
reviewActionBusy,
router,
scrollToBottom,
sessionSwitchBusy,
startExpenseSceneSelectionAfterIntentConfirmation,
submitComposer,
submitComposerInternal,
submitting,
switchSessionType,
toast,
adjustComposerTextareaHeight
}) {
async function runShortcut(shortcut) {
if (shortcut?.action === 'switch_view' && shortcut?.targetSessionType) {
if (shortcut.targetSessionType === SESSION_TYPE_BUDGET && !canUseBudgetAssistantSession(currentUser.value)) {
toast('目前暂无权限访问预算编制助手')
return
}
if (shortcut.active) {
return
}
await switchSessionType(shortcut.targetSessionType)
return
}
if (await handleGuidedShortcut(shortcut)) {
return
}
const prompt = String(shortcut?.prompt || '').trim()
if (!prompt) return
composerDraft.value = prompt
submitComposer()
}
function isSuggestedActionSelected(message, action) {
const selectedKey = String(message?.selectedSuggestedActionKey || '').trim()
return Boolean(selectedKey) && selectedKey === buildSuggestedActionKey(action)
}
function buildApplicationPreviewFieldAppliedText(message, fieldLabel = '', value = '') {
const missingFields = resolveApplicationPreviewMissingFields(message)
const resolvedFieldLabel = String(fieldLabel || '补充项').trim()
const resolvedValue = String(value || '').trim()
if (missingFields.length) {
return [
`已更新:**${resolvedFieldLabel}${resolvedValue}**。`,
'',
`我重新检查了一遍,当前还需要补充:**${missingFields.join('、')}**。`,
'',
'请继续补齐下方核对表里的待补充项;补齐后我再继续推进申请提交。'
].join('\n')
}
return [
`已更新:**${resolvedFieldLabel}${resolvedValue}**。`,
'',
'我已经重新同步下方申请核对表和费用测算。',
'',
'请继续核查表格内容;如果信息无误,点击确认进入审批环节。'
].join('\n')
}
function isStewardApplicationPreviewFieldCompletion(targetMessage, payload = {}) {
return Boolean(
payload.steward_delegated_field_completion ||
String(targetMessage?.assistantName || '').trim() === STEWARD_ASSISTANT_NAME ||
targetMessage?.stewardPlan
)
}
async function continueStewardApplicationFieldCompletion({
targetMessage,
action,
sourcePreview,
fieldKey,
fieldLabel,
value
}) {
if (!lockSuggestedActionMessage(targetMessage, action)) {
return true
}
const continuation = buildStewardFieldCompletionContinuation(
targetMessage?.stewardContinuation || null,
fieldKey,
value
)
const userText = `选择${fieldLabel || '补充项'}${value}`
const carryText = buildStewardFieldCompletionRawText({
preview: sourcePreview,
fieldKey,
fieldLabel,
value,
continuation
})
if (!action?.suppressUserEcho) {
messages.value.push(createMessage('user', userText))
}
persistSessionState()
nextTick(scrollToBottom)
await submitComposerInternal({
rawText: carryText,
userText,
pendingText: '小财管家正在根据补齐信息查询票据并测算费用...',
files: [],
skipScopeGuard: true,
skipApplicationModelReview: true,
skipStewardPlan: true,
skipUserMessage: true,
sessionTypeOverride: SESSION_TYPE_APPLICATION,
stewardContinuation: continuation
})
return true
}
async function applyApplicationPreviewFieldAction(message, action) {
const payload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const fieldKey = String(payload.field_key || payload.fieldKey || '').trim()
const fieldLabel = String(payload.field_label || payload.fieldLabel || action?.label || '').trim()
let value = String(payload.value || action?.label || '').trim()
const targetMessage = messages.value.find((item) => String(item.id || '') === String(message?.id || '')) || message
const sourcePreview = targetMessage?.applicationPreview ||
payload.applicationPreview ||
payload.application_preview ||
payload.preview ||
null
if (!sourcePreview || !fieldKey || !value) {
return false
}
if (fieldKey === 'transportMode') {
value = normalizeTransportModeOption(value, '')
}
if (fieldKey === 'transportMode' && !APPLICATION_TRANSPORT_MODE_OPTIONS.includes(value)) {
toast('请选择有效的出行方式。')
return true
}
if (isStewardApplicationPreviewFieldCompletion(targetMessage, payload)) {
return continueStewardApplicationFieldCompletion({
targetMessage,
action,
sourcePreview,
fieldKey,
fieldLabel,
value
})
}
if (!lockSuggestedActionMessage(targetMessage, action)) {
return true
}
targetMessage.applicationPreview = normalizeApplicationPreview(sourcePreview)
messages.value.push(createMessage('user', `选择${fieldLabel || '补充项'}${value}`))
openApplicationPreviewEditor(targetMessage, fieldKey, targetMessage.applicationPreview?.fields?.[fieldKey] || '')
applicationPreviewEditor.value = {
...applicationPreviewEditor.value,
draftValue: value
}
await commitApplicationPreviewEditor(targetMessage)
if (String(targetMessage.assistantName || '').trim() === STEWARD_ASSISTANT_NAME || targetMessage.stewardPlan) {
targetMessage.assistantName = STEWARD_ASSISTANT_NAME
targetMessage.text = buildApplicationPreviewFieldAppliedText(targetMessage, fieldLabel, value)
const nextMeta = Array.isArray(targetMessage.meta) ? targetMessage.meta : []
targetMessage.meta = Array.from(new Set([
STEWARD_ASSISTANT_NAME,
resolveApplicationPreviewMissingFields(targetMessage).length ? '等待补充' : '等待用户确认',
...nextMeta.filter((item) => String(item || '').trim() && item !== STEWARD_ASSISTANT_NAME)
])).slice(0, 4)
}
persistSessionState()
nextTick(scrollToBottom)
return true
}
function pushExpenseSceneSelectionPrompt(originalMessage, userEcho = '我要报销') {
const sourceText = String(originalMessage || '').trim()
if (!sourceText) {
return
}
startExpenseSceneSelectionAfterIntentConfirmation(sourceText)
messages.value.push(createMessage('user', String(userEcho || '我要报销').trim() || '我要报销'))
messages.value.push(createMessage('assistant', buildExpenseSceneSelectionMessage(sourceText), [], {
meta: ['等待选择场景'],
suggestedActions: buildExpenseSceneSelectionActions(sourceText)
}))
nextTick(scrollToBottom)
persistSessionState()
}
async function pushExpenseAssociationGatePrompt(originalMessage, options = {}) {
const sourceText = String(originalMessage || '我要报销').trim() || '我要报销'
startExpenseSceneSelectionAfterIntentConfirmation(sourceText)
messages.value.push(createMessage('user', String(options.userText || '我要报销').trim() || '我要报销'))
await pushReimbursementAssociationPromptMessage({
rawText: sourceText,
createMessage,
messages,
nextTick,
scrollToBottom,
persistSessionState,
fetchExpenseClaims,
currentUser,
skipDraftCheck: Boolean(options.skipDraftCheck)
})
}
function applySuggestedActionPrefill(action) {
const prefillText = resolveSuggestedActionPrefill(action)
if (!prefillText) {
return false
}
composerDraft.value = mergeComposerPrefill(composerDraft.value, prefillText)
nextTick(() => {
adjustComposerTextareaHeight()
composerTextareaRef.value?.focus()
})
persistSessionState()
return true
}
async function handleSuggestedAction(message, action) {
const actionType = String(action?.action_type || '').trim()
if (!actionType || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return
if (message?.suggestedActionsLocked) return
if (applySuggestedActionPrefill(action)) return
if (await handleGuidedSuggestedAction(message, action)) return
if (await handleSceneSelectionApplicationGate(message, action)) return
if (actionType === SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION) {
const originalMessage = String(action?.payload?.original_message || message?.text || '我要报销').trim() || '我要报销'
if (!lockSuggestedActionMessage(message, action)) return
await pushExpenseAssociationGatePrompt(originalMessage, {
skipDraftCheck: true,
userText: action?.label || '不用草稿,关联申请单新建报销单'
})
return
}
if (actionType === SKIP_REQUIRED_APPLICATION_LINK_ACTION) {
const originalMessage = String(action?.payload?.original_message || message?.text || '我要报销').trim() || '我要报销'
if (!lockSuggestedActionMessage(message, action)) return
pushExpenseSceneSelectionPrompt(originalMessage, action?.label || '不关联,单独新建报销单')
return
}
if (actionType === 'select_required_application') {
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const applicationNo = String(actionPayload.application_claim_no || actionPayload.claim_no || '').trim()
const originalMessage = String(actionPayload.original_message || message?.text || '我要报销').trim() || '我要报销'
if (!lockSuggestedActionMessage(message, action)) return
messages.value.push(createMessage('user', `关联申请单 ${applicationNo || ''}`.trim() || '关联申请单'))
nextTick(scrollToBottom)
persistSessionState()
await submitComposer(buildReimbursementAssociationSubmitOptions(actionPayload, originalMessage))
return
}
if (actionType === APPLICATION_PREVIEW_FIELD_ACTION_SET) {
await applyApplicationPreviewFieldAction(message, action)
return
}
if (actionType === 'open_application_detail') {
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const claimId = String(actionPayload.claim_id || actionPayload.claimId || '').trim()
if (!claimId) {
toast('当前没有可查看的申请单据。')
return
}
if (!lockSuggestedActionMessage(message, action)) return
await router.push({
name: 'app-document-detail',
params: { requestId: claimId }
})
emit('close')
return
}
if (actionType === 'open_receipt_folder') {
if (!lockSuggestedActionMessage(message, action)) return
await router.push({ name: 'app-receiptFolder' })
emit('close')
return
}
if (actionType === 'continue_upload_with_unlinked_receipts') {
if (!lockSuggestedActionMessage(message, action)) return
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
await submitComposer({
rawText: String(actionPayload.raw_text || composerDraft.value || '').trim(),
files: Array.from(attachedFiles.value || []),
skipReceiptFolderUnlinkedPrompt: true
})
return
}
if (actionType === TRAVEL_PLANNING_ACTION_GENERATE) {
if (!lockSuggestedActionMessage(message, action)) return
const sourcePreview = action?.payload?.applicationPreview || action?.payload?.preview || null
const sourceDraftPayload = action?.payload?.draftPayload || action?.payload?.draft_payload || null
const recommendation = buildTravelPlanningRecommendation(sourcePreview, sourceDraftPayload)
if (recommendation) {
messages.value.push(createMessage('user', '生成行程规划'))
messages.value.push(createMessage('assistant', recommendation, [], {
meta: ['行程规划建议']
}))
nextTick(scrollToBottom)
persistSessionState()
}
return
}
if (actionType === TRAVEL_PLANNING_ACTION_SKIP) {
if (!lockSuggestedActionMessage(message, action)) return
messages.value.push(createMessage('assistant', '好的,本次先保留申请结果。后续需要规划交通或酒店时,可以继续在这里告诉我。', [], {
meta: ['暂不规划']
}))
nextTick(scrollToBottom)
persistSessionState()
return
}
if (actionType === ASSISTANT_SCOPE_ACTION_SWITCH) {
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const targetSessionType = String(actionPayload.session_type || '').trim()
if (!targetSessionType) return
if (targetSessionType === SESSION_TYPE_BUDGET && !canUseBudgetAssistantSession(currentUser.value)) {
toast('目前暂无权限访问预算编制助手')
return
}
const carryText = String(actionPayload.carry_text || '').trim()
const carryFiles = actionPayload.carry_files ? Array.from(attachedFiles.value || []) : []
if (!lockSuggestedActionMessage(message, action)) return
if (targetSessionType === SESSION_TYPE_EXPENSE && carryText === '我要报销') {
await pushExpenseAssociationGatePrompt(carryText)
return
}
if (String(actionPayload.steward_plan_id || '').trim()) {
const confirmedByText = Boolean(action.confirmedByText)
delete action.confirmedByText
await submitComposerInternal({
rawText: carryText,
userText: action.label || '确定',
pendingText: targetSessionType === SESSION_TYPE_APPLICATION
? '小财管家正在调用申请助手生成申请单核对结果...'
: '小财管家正在调用报销助手整理报销核对结果...',
files: carryFiles,
skipScopeGuard: true,
skipApplicationModelReview: targetSessionType === SESSION_TYPE_APPLICATION,
skipStewardSlotDecision: targetSessionType === SESSION_TYPE_APPLICATION,
skipStewardPlan: true,
skipUserMessage: confirmedByText,
sessionTypeOverride: targetSessionType,
stewardContinuation: {
planId: String(actionPayload.steward_plan_id || '').trim(),
currentTaskId: String(actionPayload.steward_next_task_id || '').trim(),
currentTask: actionPayload.steward_current_task || null,
remainingTasks: Array.isArray(actionPayload.steward_remaining_tasks)
? actionPayload.steward_remaining_tasks
: []
}
})
return
}
await switchSessionType(targetSessionType)
if (carryText) {
composerDraft.value = carryText
}
if (carryFiles.length) {
const fileMergeResult = mergeFilesWithLimit([], carryFiles, MAX_ATTACHMENTS)
attachedFiles.value = fileMergeResult.files
composerFilesExpanded.value = fileMergeResult.files.length > VISIBLE_ATTACHMENT_CHIPS
}
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
persistSessionState()
if (actionPayload.auto_submit && carryText) {
await submitComposer({
rawText: carryText,
userText: action.label || '确认继续处理',
pendingText: '正在按确认内容继续处理...',
files: carryFiles,
skipScopeGuard: true
})
}
return
}
if (actionType === 'confirm_expense_intent') {
const originalMessage = String(action?.payload?.original_message || message?.text || '').trim()
if (!originalMessage) return
if (!lockSuggestedActionMessage(message, action)) return
await pushExpenseAssociationGatePrompt(originalMessage)
return
}
if (actionType !== 'select_expense_type') {
const fallbackText = String(action?.description || action?.label || '').trim()
if (!fallbackText) return
if (!lockSuggestedActionMessage(message, action)) return
await submitComposer({
rawText: fallbackText,
userText: fallbackText,
pendingText: '正在继续处理...'
})
return
}
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const expenseType = String(actionPayload.expense_type || '').trim()
const expenseTypeLabel = String(actionPayload.expense_type_label || action?.label || '').trim()
const originalMessage = String(actionPayload.original_message || message?.text || '').trim()
if (!expenseTypeLabel || !originalMessage) return
if (!lockSuggestedActionMessage(message, action)) return
await submitComposer({
rawText: `${originalMessage}\n用户选择报销场景:${expenseTypeLabel}`,
userText: `选择${expenseTypeLabel}`,
pendingText: `已选择${expenseTypeLabel},正在按该场景识别...`,
systemGenerated: true,
extraContext: {
draft_claim_id: '',
user_input_text: originalMessage,
expense_scene_selection: {
expense_type: expenseType,
expense_type_label: expenseTypeLabel,
original_message: originalMessage
},
review_form_values: {
expense_type: expenseTypeLabel
}
}
})
}
return {
continueStewardApplicationFieldCompletion,
handleSuggestedAction,
isSuggestedActionSelected,
runShortcut
}
}