feat: 完善审批退回流程与报销申请关联

后端优化报销单访问策略和常量定义,增强退回原因和审批状态
流转,前端完善退回对话框和审批交互组件,新增报销申请关联
模型,优化文档中心行数据和审批收件箱工具函数,增强引导
流程和会话模型,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-27 14:35:17 +08:00
parent 7d32eae74e
commit cbb98f4469
30 changed files with 1794 additions and 250 deletions

View File

@@ -4,6 +4,14 @@ 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,
@@ -11,6 +19,7 @@ import {
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,
@@ -41,8 +50,10 @@ import {
resolveGuidedExpenseTypeFromText,
resolveGuidedQueryModeFromText,
selectGuidedExpenseType,
selectGuidedRequiredApplication,
selectGuidedQueryMode,
shouldConfirmGuidedInterruption
shouldConfirmGuidedInterruption,
waitForGuidedApplicationSelection
} from './travelReimbursementGuidedFlowModel.js'
function normalizeText(value) {
@@ -211,7 +222,98 @@ export function useTravelReimbursementGuidedFlow({
})
}
function handleReimbursementAnswer(answerText, files) {
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)
pushAssistant('查询可关联申请单时出现异常,请稍后再试。为避免直接报销,我先暂停当前流程。', {
meta: ['申请单查询失败']
})
toast?.('申请单查询失败,请稍后再试')
return
}
const applications = filterRequiredApplicationCandidates(claimsPayload, expenseType, currentUser?.value || {})
if (!applications.length) {
guidedFlowState.value = createGuidedReimbursementState()
pushAssistant(buildRequiredApplicationMissingText(expenseType), {
meta: ['缺少可关联申请单'],
suggestedActions: buildGuidedExpenseTypeActions()
})
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)
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: '',
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,
application_claim_id: applicationId,
application_claim_no: applicationNo,
application_reason: current.values.application_reason || '',
application_location: current.values.application_location || '',
application_amount: current.values.application_amount || ''
}
}
}
}
async function handleReimbursementAnswer(answerText, files) {
const currentState = normalizeGuidedFlowState(guidedFlowState.value)
const currentStep = getCurrentGuidedStep(currentState)
const fileNames = buildFileNames(files)
@@ -225,8 +327,18 @@ export function useTravelReimbursementGuidedFlow({
})
return
}
guidedFlowState.value = selectGuidedExpenseType(currentState, expenseType)
pushNextReimbursementPrompt()
await selectExpenseTypeForGuidedReimbursement(currentState, expenseType)
return
}
if (currentState.stepKey === 'application_selection') {
pushAssistant('请先点击上方列出的申请单完成关联。关联后,我再继续询问报销依据。', {
meta: ['等待关联申请单'],
suggestedActions: buildRequiredApplicationActions(
currentState.applicationCandidates,
GUIDED_ACTION_SELECT_REQUIRED_APPLICATION
)
})
return
}
@@ -338,7 +450,7 @@ export function useTravelReimbursementGuidedFlow({
}
if (currentState.mode === GUIDED_FLOW_MODE_REIMBURSEMENT) {
handleReimbursementAnswer(answerText, files)
await handleReimbursementAnswer(answerText, files)
clearComposerRuntime()
persistAndScroll()
return true
@@ -361,6 +473,7 @@ export function useTravelReimbursementGuidedFlow({
}
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,
@@ -380,8 +493,23 @@ export function useTravelReimbursementGuidedFlow({
if (actionType === GUIDED_ACTION_SELECT_EXPENSE_TYPE) {
const expenseType = normalizeText(action?.payload?.expense_type)
const expenseTypeLabel = normalizeText(action?.payload?.expense_type_label || action?.label)
guidedFlowState.value = selectGuidedExpenseType(guidedFlowState.value, expenseType)
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
}
pushNextReimbursementPrompt()
persistAndScroll()
return true
@@ -450,10 +578,42 @@ export function useTravelReimbursementGuidedFlow({
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
}
}