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 测试
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { fetchExpenseClaims } from '../../services/reimbursements.js'
|
||||
import { runOrchestrator } from '../../services/orchestrator.js'
|
||||
import {
|
||||
applyAiExpenseAnswer,
|
||||
buildAiExpenseStepPrompt,
|
||||
@@ -15,11 +16,34 @@ import {
|
||||
buildRequiredApplicationActions,
|
||||
buildRequiredApplicationMissingText,
|
||||
buildRequiredApplicationSelectionText,
|
||||
filterRequiredApplicationCandidates
|
||||
filterRequiredApplicationCandidates,
|
||||
resolveRequiredApplicationReimbursementType
|
||||
} from '../../views/scripts/travelReimbursementApplicationLinkModel.js'
|
||||
import {
|
||||
buildReimbursementAssociationActions,
|
||||
buildReimbursementAssociationMissingText,
|
||||
buildReimbursementAssociationSubmitOptions,
|
||||
buildReimbursementAssociationThinkingEvents,
|
||||
buildReimbursementAssociationSelectionText,
|
||||
buildReimbursementAssociationQueryFailedText,
|
||||
buildReimbursementDraftActions,
|
||||
buildReimbursementDraftSelectionText,
|
||||
fetchReimbursementAssociationClaims,
|
||||
filterReimbursementAssociationCandidates,
|
||||
filterReimbursementDraftCandidates,
|
||||
REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MS
|
||||
} from '../../views/scripts/travelReimbursementAssociationGateModel.js'
|
||||
|
||||
export { SESSION_TYPE_EXPENSE }
|
||||
|
||||
const AI_REIMBURSEMENT_ASSOCIATION_STEP_DELAY_MS = 320
|
||||
|
||||
function waitForReimbursementAssociationStep() {
|
||||
return new Promise((resolve) => {
|
||||
globalThis.setTimeout(resolve, AI_REIMBURSEMENT_ASSOCIATION_STEP_DELAY_MS)
|
||||
})
|
||||
}
|
||||
|
||||
export function useWorkbenchAiExpenseFlow({
|
||||
activateInlineConversation,
|
||||
aiExpenseDraft,
|
||||
@@ -32,11 +56,35 @@ export function useWorkbenchAiExpenseFlow({
|
||||
currentUser,
|
||||
persistCurrentConversation,
|
||||
pushInlineUserMessage,
|
||||
replaceInlineMessage = (id, nextMessage) => {
|
||||
const index = conversationMessages.value.findIndex((item) => item.id === id)
|
||||
if (index === -1) {
|
||||
conversationMessages.value.push(nextMessage)
|
||||
return
|
||||
}
|
||||
conversationMessages.value.splice(index, 1, nextMessage)
|
||||
},
|
||||
removeWorkbenchDateTag,
|
||||
resolveLatestInlineUserPrompt,
|
||||
scrollInlineConversationToBottom,
|
||||
startAiApplicationPreview
|
||||
startAiApplicationPreview,
|
||||
fetchExpenseClaimsForAi = fetchExpenseClaims,
|
||||
runOrchestratorForAi = runOrchestrator,
|
||||
associationQueryTimeoutMs = REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MS
|
||||
}) {
|
||||
function replaceInlineAssistantMessage(messageId, content = '', options = {}) {
|
||||
const nextMessage = createInlineMessage('assistant', content, {
|
||||
id: messageId,
|
||||
pending: Boolean(options.pending),
|
||||
stewardPlan: options.stewardPlan || null,
|
||||
suggestedActions: Array.isArray(options.suggestedActions) ? options.suggestedActions : [],
|
||||
draftPayload: options.draftPayload || null,
|
||||
text: options.text || content
|
||||
})
|
||||
replaceInlineMessage(messageId, nextMessage)
|
||||
return nextMessage
|
||||
}
|
||||
|
||||
function pushInlineExpenseSceneSelectionPrompt(originalMessage, selectedLabel = '') {
|
||||
const sourceText = String(originalMessage || '我要报销').trim()
|
||||
if (!conversationStarted.value) {
|
||||
@@ -65,6 +113,116 @@ export function useWorkbenchAiExpenseFlow({
|
||||
)
|
||||
}
|
||||
|
||||
async function startAiReimbursementAssociationGate(originalMessage = '我要报销', selectedLabel = '', options = {}) {
|
||||
const sourceText = String(originalMessage || '我要报销').trim() || '我要报销'
|
||||
if (!conversationStarted.value) {
|
||||
activateInlineConversation({
|
||||
title: String(selectedLabel || sourceText || '报销').trim().slice(0, 18) || '报销'
|
||||
})
|
||||
}
|
||||
assistantDraft.value = ''
|
||||
removeWorkbenchDateTag()
|
||||
closeWorkbenchDatePicker()
|
||||
clearAiModeFiles()
|
||||
aiExpenseDraft.value = null
|
||||
pushInlineUserMessage(String(selectedLabel || sourceText).trim())
|
||||
const pendingMessage = createInlineMessage('assistant', '', {
|
||||
pending: true,
|
||||
stewardPlan: {
|
||||
streamStatus: 'streaming',
|
||||
thinkingEvents: buildReimbursementAssociationThinkingEvents('intent')
|
||||
},
|
||||
suggestedActions: []
|
||||
})
|
||||
conversationMessages.value.push(pendingMessage)
|
||||
const pendingMessageId = pendingMessage.id
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
await waitForReimbursementAssociationStep()
|
||||
|
||||
replaceInlineAssistantMessage(pendingMessageId, '', {
|
||||
pending: true,
|
||||
stewardPlan: {
|
||||
streamStatus: 'streaming',
|
||||
thinkingEvents: buildReimbursementAssociationThinkingEvents('query')
|
||||
},
|
||||
suggestedActions: []
|
||||
})
|
||||
scrollInlineConversationToBottom()
|
||||
|
||||
let claims = null
|
||||
try {
|
||||
claims = await fetchReimbursementAssociationClaims({
|
||||
fetchExpenseClaims: fetchExpenseClaimsForAi,
|
||||
timeoutMs: associationQueryTimeoutMs
|
||||
})
|
||||
} catch (error) {
|
||||
replaceInlineAssistantMessage(pendingMessageId, buildReimbursementAssociationQueryFailedText(error), {
|
||||
stewardPlan: {
|
||||
streamStatus: 'failed',
|
||||
thinkingEvents: buildReimbursementAssociationThinkingEvents('failed')
|
||||
},
|
||||
suggestedActions: buildReimbursementAssociationActions([], sourceText)
|
||||
})
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
return
|
||||
}
|
||||
|
||||
const draftCandidates = options.skipDraftCheck
|
||||
? []
|
||||
: filterReimbursementDraftCandidates(claims, currentUser.value || {})
|
||||
if (draftCandidates.length) {
|
||||
replaceInlineAssistantMessage(pendingMessageId, '', {
|
||||
pending: true,
|
||||
stewardPlan: {
|
||||
streamStatus: 'streaming',
|
||||
thinkingEvents: buildReimbursementAssociationThinkingEvents('filter', { candidateCount: draftCandidates.length })
|
||||
},
|
||||
suggestedActions: []
|
||||
})
|
||||
scrollInlineConversationToBottom()
|
||||
await waitForReimbursementAssociationStep()
|
||||
|
||||
const content = buildReimbursementDraftSelectionText(draftCandidates)
|
||||
replaceInlineAssistantMessage(pendingMessageId, content, {
|
||||
stewardPlan: {
|
||||
streamStatus: 'completed',
|
||||
thinkingEvents: buildReimbursementAssociationThinkingEvents('completed', { candidateCount: draftCandidates.length })
|
||||
},
|
||||
suggestedActions: buildReimbursementDraftActions(draftCandidates, sourceText)
|
||||
})
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
return
|
||||
}
|
||||
|
||||
const candidates = filterReimbursementAssociationCandidates(claims, currentUser.value || {})
|
||||
replaceInlineAssistantMessage(pendingMessageId, '', {
|
||||
pending: true,
|
||||
stewardPlan: {
|
||||
streamStatus: 'streaming',
|
||||
thinkingEvents: buildReimbursementAssociationThinkingEvents('filter', { candidateCount: candidates.length })
|
||||
},
|
||||
suggestedActions: []
|
||||
})
|
||||
scrollInlineConversationToBottom()
|
||||
await waitForReimbursementAssociationStep()
|
||||
|
||||
const content = candidates.length
|
||||
? buildReimbursementAssociationSelectionText(candidates)
|
||||
: buildReimbursementAssociationMissingText()
|
||||
replaceInlineAssistantMessage(pendingMessageId, content, {
|
||||
stewardPlan: {
|
||||
streamStatus: 'completed',
|
||||
thinkingEvents: buildReimbursementAssociationThinkingEvents('completed', { candidateCount: candidates.length })
|
||||
},
|
||||
suggestedActions: buildReimbursementAssociationActions(candidates, sourceText)
|
||||
})
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
}
|
||||
|
||||
function startAiExpenseDraft(expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement) {
|
||||
if (!conversationStarted.value) {
|
||||
activateInlineConversation({ title: String(expenseTypeLabel || '报销').trim().slice(0, 18) || '报销' })
|
||||
@@ -109,7 +267,7 @@ export function useWorkbenchAiExpenseFlow({
|
||||
async function resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel) {
|
||||
let claims = null
|
||||
try {
|
||||
claims = await fetchExpenseClaims()
|
||||
claims = await fetchExpenseClaimsForAi()
|
||||
} catch {
|
||||
aiExpenseDraft.value = null
|
||||
conversationMessages.value.push(createInlineMessage('assistant', '查询可关联申请单时出现异常,请稍后再试,我先暂停这次报销流程。'))
|
||||
@@ -146,11 +304,47 @@ export function useWorkbenchAiExpenseFlow({
|
||||
scrollInlineConversationToBottom()
|
||||
}
|
||||
|
||||
function linkAiExpenseApplication(application = {}) {
|
||||
const draft = aiExpenseDraft.value
|
||||
if (!draft) {
|
||||
return
|
||||
function buildWorkbenchUserContext() {
|
||||
const user = currentUser.value || {}
|
||||
return {
|
||||
role_codes: Array.isArray(user.roleCodes) ? user.roleCodes : [],
|
||||
is_admin: Boolean(user.isAdmin),
|
||||
name: user.name || '',
|
||||
role: user.role || '',
|
||||
department: user.department || user.departmentName || '',
|
||||
department_name: user.department || user.departmentName || '',
|
||||
position: user.position || '',
|
||||
employee_position: user.position || user.employeePosition || user.employee_position || '',
|
||||
employee_no: user.employeeNo || user.employee_no || '',
|
||||
employeeNo: user.employeeNo || user.employee_no || '',
|
||||
session_type: SESSION_TYPE_EXPENSE,
|
||||
entry_source: 'workbench-ai'
|
||||
}
|
||||
}
|
||||
|
||||
function buildLinkedDraftAction(draftPayload = {}) {
|
||||
const claimNo = String(draftPayload.claim_no || draftPayload.claimNo || '').trim()
|
||||
const claimId = String(draftPayload.claim_id || draftPayload.claimId || '').trim()
|
||||
if (!claimNo && !claimId) {
|
||||
return []
|
||||
}
|
||||
return [{
|
||||
label: '查看报销草稿',
|
||||
description: '打开草稿详情继续上传票据或补充信息。',
|
||||
icon: 'mdi mdi-file-document-outline',
|
||||
action_type: 'open_application_detail',
|
||||
payload: {
|
||||
claim_id: claimId,
|
||||
claim_no: claimNo
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
async function linkAiExpenseApplication(application = {}) {
|
||||
const draft = aiExpenseDraft.value || (() => {
|
||||
const resolved = resolveRequiredApplicationReimbursementType(application)
|
||||
return createAiExpenseDraft(resolved.expenseType, resolved.expenseTypeLabel)
|
||||
})()
|
||||
const claimNo = String(application.application_claim_no || '').trim()
|
||||
pushInlineUserMessage(`关联申请单 ${claimNo}`.trim())
|
||||
|
||||
@@ -167,13 +361,60 @@ export function useWorkbenchAiExpenseFlow({
|
||||
stepKey: 'attachments'
|
||||
}
|
||||
aiExpenseDraft.value = linked
|
||||
conversationMessages.value.push(createInlineMessage('assistant', [
|
||||
`已关联申请单${claimNo ? ` ${claimNo}` : ''},事由、时间、地点、金额我先用申请单的内容预填了。`,
|
||||
'',
|
||||
'再确认一下票据:可以现在上传,或回复“稍后上传”。'
|
||||
].join('\n')))
|
||||
const pendingMessage = createInlineMessage('assistant', `已关联申请单${claimNo ? ` ${claimNo}` : ''},正在生成报销草稿...`, {
|
||||
pending: true,
|
||||
suggestedActions: []
|
||||
})
|
||||
conversationMessages.value.push(pendingMessage)
|
||||
const pendingMessageId = pendingMessage.id
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
|
||||
try {
|
||||
const submitOptions = buildReimbursementAssociationSubmitOptions(
|
||||
application,
|
||||
application.original_message || resolveLatestInlineUserPrompt() || '我要报销'
|
||||
)
|
||||
const user = currentUser.value || {}
|
||||
const payload = await runOrchestratorForAi(
|
||||
{
|
||||
source: 'user_message',
|
||||
user_id: user.username || user.name || 'anonymous',
|
||||
conversation_id: null,
|
||||
message: submitOptions.rawText,
|
||||
context_json: {
|
||||
...buildWorkbenchUserContext(),
|
||||
...submitOptions.extraContext
|
||||
}
|
||||
},
|
||||
{
|
||||
timeoutMs: 120000,
|
||||
timeoutMessage: '生成报销草稿超时,请稍后重试。'
|
||||
}
|
||||
)
|
||||
const draftPayload = payload?.result?.draft_payload || null
|
||||
const draftClaimNo = String(draftPayload?.claim_no || draftPayload?.claimNo || '').trim()
|
||||
const content = draftClaimNo
|
||||
? `报销草稿 ${draftClaimNo} 已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。`
|
||||
: `报销草稿已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。`
|
||||
replaceInlineAssistantMessage(pendingMessageId, content, {
|
||||
draftPayload,
|
||||
suggestedActions: buildLinkedDraftAction(draftPayload)
|
||||
})
|
||||
aiExpenseDraft.value = null
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
} catch {
|
||||
replaceInlineAssistantMessage(
|
||||
pendingMessageId,
|
||||
'生成报销草稿时出现异常。申请单关联信息我先保留在当前会话里,你可以稍后重试或单独新建报销单。',
|
||||
{
|
||||
suggestedActions: []
|
||||
}
|
||||
)
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -181,6 +422,7 @@ export function useWorkbenchAiExpenseFlow({
|
||||
linkAiExpenseApplication,
|
||||
pushInlineExpenseSceneSelectionPrompt,
|
||||
startAiApplicationPreviewFromAction,
|
||||
startAiReimbursementAssociationGate,
|
||||
startAiExpenseDraft
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user