diff --git a/web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js b/web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js
index a621592..bb33353 100644
--- a/web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js
+++ b/web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js
@@ -12,6 +12,10 @@ import {
AI_ATTACHMENT_OCR_DETAIL_ACTION
} from './workbenchAiMessageModel.js'
import { SESSION_TYPE_EXPENSE } from './useWorkbenchAiExpenseFlow.js'
+import {
+ SKIP_REQUIRED_APPLICATION_LINK_ACTION,
+ SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION
+} from '../../views/scripts/travelReimbursementAssociationGateModel.js'
export function useWorkbenchAiActionRouter({
aiExpenseDraft,
@@ -98,6 +102,23 @@ export function useWorkbenchAiActionRouter({
return
}
+ if (actionType === SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION) {
+ void expenseFlow.startAiReimbursementAssociationGate(
+ actionPayload.original_message || '我要报销',
+ action.label || '不用草稿,关联申请单新建报销单',
+ { skipDraftCheck: true }
+ )
+ return
+ }
+
+ if (actionType === SKIP_REQUIRED_APPLICATION_LINK_ACTION) {
+ expenseFlow.pushInlineExpenseSceneSelectionPrompt(
+ actionPayload.original_message || '我要报销',
+ action.label || '单独新建报销单'
+ )
+ return
+ }
+
if (actionType === 'ai_application_start_inline') {
aiExpenseDraft.value = null
void expenseFlow.startAiApplicationPreviewFromAction(action?.payload || {}, action?.label)
@@ -109,7 +130,7 @@ export function useWorkbenchAiActionRouter({
return
}
if (String(action?.payload?.session_type || '').trim() === SESSION_TYPE_EXPENSE && carryText === '我要报销') {
- expenseFlow.pushInlineExpenseSceneSelectionPrompt(carryText, action.label)
+ void expenseFlow.startAiReimbursementAssociationGate(carryText, action.label)
return
}
startInlineConversation(carryText, {
diff --git a/web/src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js b/web/src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js
index b1aca59..0a85607 100644
--- a/web/src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js
+++ b/web/src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js
@@ -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
}
}
diff --git a/web/src/views/scripts/stewardPlanModel.js b/web/src/views/scripts/stewardPlanModel.js
index 7945dbb..984d662 100644
--- a/web/src/views/scripts/stewardPlanModel.js
+++ b/web/src/views/scripts/stewardPlanModel.js
@@ -258,6 +258,51 @@ function resolveCandidateFlowExpenseType(flow = {}) {
return rawType
}
+function normalizeStewardExpenseTypeCode(value = '') {
+ const text = String(value || '').trim()
+ if (text === '差旅' || text === '差旅费' || text === 'travel') {
+ return 'travel'
+ }
+ return text
+}
+
+function resolveTaskExpenseType(task = null) {
+ const fields = task?.ontologyFields || task?.ontology_fields || {}
+ const explicitType = normalizeStewardExpenseTypeCode(
+ fields.expense_type ||
+ fields.expenseType ||
+ fields.application_type ||
+ fields.applicationType ||
+ ''
+ )
+ if (explicitType) {
+ return explicitType
+ }
+ const taskText = [
+ task?.title,
+ task?.summary,
+ fields.reason,
+ fields.location
+ ].map((item) => String(item || '').trim()).join(' ')
+ return /差旅|出差/.test(taskText) ? 'travel' : ''
+}
+
+function buildStewardApplicationPreviewRoutePayload(actionType, task = null) {
+ if (actionType !== 'confirm_create_application') {
+ return {}
+ }
+ const expenseType = resolveTaskExpenseType(task)
+ if (expenseType !== 'travel') {
+ return {}
+ }
+ return {
+ steward_confirm_flow: true,
+ flow_id: 'travel_application',
+ expense_type: expenseType,
+ expense_type_label: FLOW_EXPENSE_TYPE_LABELS[expenseType] || ''
+ }
+}
+
export function buildStewardSuggestedActions(plan) {
const normalized = normalizeStewardPlan(plan)
if (isOffTopicPlan(normalized)) {
@@ -320,6 +365,7 @@ export function buildStewardSuggestedActions(plan) {
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
payload: {
session_type: targetSessionType,
+ ...buildStewardApplicationPreviewRoutePayload(actionType, task),
carry_text: buildStewardCarryText(actionType, task, group, normalized),
carry_files: actionType !== 'confirm_create_application',
auto_submit: true,
diff --git a/web/src/views/scripts/travelReimbursementApplicationLinkModel.js b/web/src/views/scripts/travelReimbursementApplicationLinkModel.js
index 3b49827..b05b84a 100644
--- a/web/src/views/scripts/travelReimbursementApplicationLinkModel.js
+++ b/web/src/views/scripts/travelReimbursementApplicationLinkModel.js
@@ -46,6 +46,22 @@ function uniqueValues(values) {
return Array.from(new Set((Array.isArray(values) ? values : []).map(normalizeText).filter(Boolean)))
}
+function expandIdentityValues(values) {
+ const expanded = []
+ ;(Array.isArray(values) ? values : []).forEach((value) => {
+ const normalized = normalizeText(value)
+ if (!normalized) {
+ return
+ }
+ expanded.push(normalized)
+ const atIndex = normalized.indexOf('@')
+ if (atIndex > 0) {
+ expanded.push(normalized.slice(0, atIndex))
+ }
+ })
+ return uniqueValues(expanded)
+}
+
function normalizeClaimNo(claim) {
return normalizeText(claim?.claim_no || claim?.claimNo).toUpperCase()
}
@@ -205,7 +221,7 @@ function hasAnyApplicationReference(index) {
return Boolean(index?.ids?.size || index?.claimNos?.size)
}
-function buildLinkedApplicationReferenceIndex(claims) {
+export function buildLinkedApplicationReferenceIndex(claims) {
const index = createReferenceIndex()
;(Array.isArray(claims) ? claims : []).forEach((claim) => {
if (isExpenseApplicationClaim(claim)) {
@@ -297,6 +313,58 @@ export function getRequiredApplicationExpenseLabel(expenseType) {
return EXPENSE_TYPE_LABELS[normalizeLower(expenseType)] || '报销'
}
+export function resolveRequiredApplicationReimbursementType(application = {}) {
+ const expenseType = normalizeLower(
+ application.application_expense_type
+ || application.expense_type
+ || application.expenseType
+ || application.type_code
+ || application.typeCode
+ )
+ const source = {
+ expense_type: expenseType,
+ reason: application.application_reason || application.reason,
+ title: application.application_reason || application.reason,
+ description: application.application_reason || application.reason,
+ location: application.application_location || application.location
+ }
+
+ if (APPLICATION_TYPE_ALIASES.travel.has(expenseType)) {
+ return {
+ expenseType: 'travel',
+ expenseTypeLabel: EXPENSE_TYPE_LABELS.travel
+ }
+ }
+
+ if (APPLICATION_TYPE_ALIASES.meal.has(expenseType)) {
+ return {
+ expenseType: 'meal',
+ expenseTypeLabel: EXPENSE_TYPE_LABELS.meal
+ }
+ }
+
+ if (matchesGenericApplicationByText(source, 'meal')) {
+ return {
+ expenseType: 'meal',
+ expenseTypeLabel: EXPENSE_TYPE_LABELS.meal
+ }
+ }
+
+ if (matchesGenericApplicationByText(source, 'travel')) {
+ return {
+ expenseType: 'travel',
+ expenseTypeLabel: EXPENSE_TYPE_LABELS.travel
+ }
+ }
+
+ return {
+ expenseType: REQUIRED_APPLICATION_EXPENSE_TYPES.has(expenseType) ? expenseType : 'travel',
+ expenseTypeLabel: getRequiredApplicationExpenseLabel(
+ REQUIRED_APPLICATION_EXPENSE_TYPES.has(expenseType) ? expenseType : 'travel'
+ )
+ }
+}
+
export function isExpenseApplicationClaim(claim) {
const documentType = normalizeDocumentType(claim)
const expenseType = normalizeExpenseType(claim)
@@ -323,7 +391,7 @@ export function matchesRequiredApplicationExpenseType(claim, expenseType) {
}
export function isClaimOwnedByCurrentUser(claim, currentUser = {}) {
- const userIds = uniqueValues([
+ const userIds = expandIdentityValues([
currentUser.id,
currentUser.employeeId,
currentUser.employee_id,
@@ -332,11 +400,13 @@ export function isClaimOwnedByCurrentUser(claim, currentUser = {}) {
currentUser.username,
currentUser.email
])
- const claimIds = uniqueValues([
+ const claimIds = expandIdentityValues([
claim?.employee_id,
claim?.employeeId,
claim?.employee_no,
claim?.employeeNo,
+ claim?.employee_email,
+ claim?.employeeEmail,
claim?.username,
claim?.user_id,
claim?.userId
diff --git a/web/src/views/scripts/travelReimbursementAssociationGateModel.js b/web/src/views/scripts/travelReimbursementAssociationGateModel.js
new file mode 100644
index 0000000..2ec15cf
--- /dev/null
+++ b/web/src/views/scripts/travelReimbursementAssociationGateModel.js
@@ -0,0 +1,747 @@
+import {
+ buildLinkedApplicationReferenceIndex,
+ buildRequiredApplicationActions,
+ isClaimOwnedByCurrentUser,
+ isExpenseApplicationClaim,
+ isUsableRequiredApplicationClaim,
+ normalizeRequiredApplicationCandidate,
+ resolveRequiredApplicationReimbursementType
+} from './travelReimbursementApplicationLinkModel.js'
+
+const REIMBURSEMENT_DRAFT_STATUSES = new Set(['draft', 'supplement', 'returned'])
+
+const STATUS_LABELS = {
+ draft: '草稿',
+ supplement: '待补充',
+ returned: '已退回',
+ submitted: '审批中',
+ approved: '已审批',
+ completed: '已完成',
+ archived: '已归档',
+ pending_payment: '待付款',
+ paid: '已付款'
+}
+
+const EXPENSE_TYPE_LABELS = {
+ travel: '差旅费',
+ meal: '业务招待费'
+}
+const REIMBURSEMENT_ASSOCIATION_STEP_DELAY_MS = 320
+export const REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MS = 12000
+const REIMBURSEMENT_ASSOCIATION_QUERY_PARAMS = Object.freeze({ page: 1, pageSize: 100 })
+const REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MESSAGE = '查询可关联申请单超时,请稍后重试。'
+
+export const SKIP_REQUIRED_APPLICATION_LINK_ACTION = 'skip_required_application_link'
+export const SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION = 'skip_reimbursement_draft_check'
+
+function normalizeText(value) {
+ return String(value || '').trim()
+}
+
+function normalizeLower(value) {
+ return normalizeText(value).toLowerCase()
+}
+
+function normalizeExpenseType(claim) {
+ return normalizeLower(claim?.expense_type || claim?.expenseType || claim?.type_code || claim?.typeCode)
+}
+
+function normalizeClaimStatus(claim) {
+ return normalizeLower(claim?.status || claim?.state || claim?.approval_status || claim?.approvalStatus)
+}
+
+function extractClaims(claimsPayload) {
+ return Array.isArray(claimsPayload)
+ ? claimsPayload
+ : Array.isArray(claimsPayload?.items)
+ ? claimsPayload.items
+ : Array.isArray(claimsPayload?.claims)
+ ? claimsPayload.claims
+ : []
+}
+
+function escapeHtml(value = '') {
+ return String(value || '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+}
+
+function toTimestamp(value) {
+ const date = new Date(value)
+ return Number.isNaN(date.getTime()) ? 0 : date.getTime()
+}
+
+function formatAmount(value) {
+ const numberValue = Number(String(value ?? '').replace(/[^\d.-]/g, ''))
+ if (!Number.isFinite(numberValue) || numberValue <= 0) {
+ return ''
+ }
+ return `¥${new Intl.NumberFormat('zh-CN', {
+ minimumFractionDigits: Number.isInteger(numberValue) ? 0 : 2,
+ maximumFractionDigits: 2
+ }).format(numberValue)}`
+}
+
+function resolveCurrentUser(currentUser) {
+ return currentUser?.value && typeof currentUser.value === 'object'
+ ? currentUser.value
+ : currentUser && typeof currentUser === 'object'
+ ? currentUser
+ : {}
+}
+
+function waitForReimbursementAssociationStep() {
+ return new Promise((resolve) => {
+ globalThis.setTimeout(resolve, REIMBURSEMENT_ASSOCIATION_STEP_DELAY_MS)
+ })
+}
+
+function createReimbursementAssociationQueryTimeoutError() {
+ const error = new Error(REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MESSAGE)
+ error.code = 'REQUEST_TIMEOUT'
+ return error
+}
+
+export function isReimbursementAssociationQueryTimeoutError(error) {
+ return error?.code === 'REQUEST_TIMEOUT'
+}
+
+export function buildReimbursementAssociationQueryFailedText(error) {
+ if (isReimbursementAssociationQueryTimeoutError(error)) {
+ return '查询可关联申请单超时。你可以稍后重试,也可以选择不关联申请单,单独新建报销单。'
+ }
+ return '查询可关联申请单时出现异常。你可以稍后重试,也可以选择不关联申请单,单独新建报销单。'
+}
+
+export async function fetchReimbursementAssociationClaims({
+ fetchExpenseClaims,
+ timeoutMs = REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MS
+} = {}) {
+ if (typeof fetchExpenseClaims !== 'function') {
+ return { items: [] }
+ }
+
+ const queryPromise = Promise.resolve(fetchExpenseClaims(
+ REIMBURSEMENT_ASSOCIATION_QUERY_PARAMS,
+ {
+ timeoutMs,
+ timeoutMessage: REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MESSAGE
+ }
+ ))
+
+ const normalizedTimeoutMs = Number(timeoutMs)
+ if (!Number.isFinite(normalizedTimeoutMs) || normalizedTimeoutMs <= 0) {
+ return queryPromise
+ }
+
+ let timeoutId = 0
+ const timeoutPromise = new Promise((resolve, reject) => {
+ timeoutId = globalThis.setTimeout(() => {
+ reject(createReimbursementAssociationQueryTimeoutError())
+ }, normalizedTimeoutMs)
+ })
+
+ try {
+ return await Promise.race([queryPromise, timeoutPromise])
+ } finally {
+ if (timeoutId) {
+ globalThis.clearTimeout(timeoutId)
+ }
+ }
+}
+
+export function filterReimbursementAssociationCandidates(claimsPayload, currentUser = {}) {
+ const claims = extractClaims(claimsPayload)
+ const linkedApplicationReferences = buildLinkedApplicationReferenceIndex(claims)
+
+ return claims
+ .filter((claim) => (
+ isExpenseApplicationClaim(claim)
+ && isUsableRequiredApplicationClaim(claim, linkedApplicationReferences)
+ && isClaimOwnedByCurrentUser(claim, currentUser)
+ ))
+ .map(normalizeRequiredApplicationCandidate)
+ .sort((left, right) => toTimestamp(right.application_date) - toTimestamp(left.application_date))
+}
+
+export function normalizeReimbursementDraftCandidate(claim = {}) {
+ const status = normalizeClaimStatus(claim)
+ const amount = normalizeText(claim?.amount || claim?.total_amount || claim?.totalAmount)
+ const createdAt = normalizeText(
+ claim?.updated_at
+ || claim?.updatedAt
+ || claim?.created_at
+ || claim?.createdAt
+ || claim?.submitted_at
+ || claim?.submittedAt
+ )
+
+ return {
+ id: normalizeText(claim?.id || claim?.claim_id || claim?.claimId),
+ claim_no: normalizeText(claim?.claim_no || claim?.claimNo),
+ expense_type: normalizeExpenseType(claim),
+ reason: normalizeText(claim?.reason || claim?.business_reason || claim?.description || claim?.title),
+ location: normalizeText(claim?.location || claim?.business_location || claim?.businessLocation),
+ amount,
+ amount_label: formatAmount(amount),
+ status,
+ status_label: STATUS_LABELS[status] || normalizeText(claim?.status_label || claim?.statusLabel || claim?.approval_stage || claim?.approvalStage || status),
+ created_at: createdAt,
+ application_date: createdAt
+ }
+}
+
+export function filterReimbursementDraftCandidates(claimsPayload, currentUser = {}) {
+ return extractClaims(claimsPayload)
+ .filter((claim) => (
+ !isExpenseApplicationClaim(claim)
+ && REIMBURSEMENT_DRAFT_STATUSES.has(normalizeClaimStatus(claim))
+ && isClaimOwnedByCurrentUser(claim, currentUser)
+ ))
+ .map(normalizeReimbursementDraftCandidate)
+ .sort((left, right) => toTimestamp(right.application_date) - toTimestamp(left.application_date))
+}
+
+export function buildReimbursementAssociationSelectionText(applications) {
+ const candidates = Array.isArray(applications) ? applications : []
+ return [
+ '### 可关联申请单',
+ '',
+ '我先检查你名下是否有可继续的报销草稿,没有查到可继续的报销草稿。',
+ '',
+ '我先查询可关联申请单,并筛选了你名下已审批且未关联报销的记录。',
+ '',
+ `查到 ${candidates.length} 个已审批且尚未关联报销的申请单。你可以选择关联其中一个,也可以选择不关联、单独新建报销单。`,
+ '',
+ buildReimbursementAssociationCardsHtml(candidates),
+ '',
+ '请通过下方按钮确认是否关联申请单。'
+ ].join('\n')
+}
+
+export function buildReimbursementAssociationMissingText() {
+ return [
+ '我先检查你名下是否有可继续的报销草稿,没有查到可继续的报销草稿。',
+ '',
+ '我先查询可关联申请单,并筛选了你名下已审批且未关联报销的记录。',
+ '',
+ '暂时没有查到已审批且尚未关联报销的申请单。你仍然可以选择单独新建报销单,后续按报销类型继续补充信息。'
+ ].join('\n')
+}
+
+export function buildReimbursementDraftSelectionText(drafts) {
+ const candidates = Array.isArray(drafts) ? drafts : []
+ return [
+ '### 可继续报销草稿',
+ '',
+ '我先检查你名下是否有可继续的报销草稿。',
+ '',
+ `查到 ${candidates.length} 个可继续的报销草稿。你可以先继续草稿;如果这次是新的报销,可以跳过草稿后再关联申请单新建报销单。`,
+ '',
+ buildReimbursementDraftCardsHtml(candidates),
+ '',
+ '请通过下方按钮确认继续草稿,或跳过草稿进入申请单关联。'
+ ].join('\n')
+}
+
+export function buildSkipRequiredApplicationLinkAction(originalMessage = '') {
+ return {
+ label: '不关联,单独新建报销单',
+ description: '跳过申请单关联,继续选择报销类型并新建报销单。',
+ icon: 'mdi mdi-file-plus-outline',
+ action_type: SKIP_REQUIRED_APPLICATION_LINK_ACTION,
+ payload: {
+ original_message: normalizeText(originalMessage) || '我要报销'
+ }
+ }
+}
+
+export function buildSkipReimbursementDraftCheckAction(originalMessage = '') {
+ return {
+ label: '不用草稿,关联申请单新建报销单',
+ description: '跳过已有报销草稿,继续查询可关联申请单。',
+ icon: 'mdi mdi-file-search-outline',
+ action_type: SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION,
+ payload: {
+ original_message: normalizeText(originalMessage) || '我要报销'
+ }
+ }
+}
+
+export function buildReimbursementDraftActions(drafts, originalMessage = '') {
+ const sourceText = normalizeText(originalMessage) || '我要报销'
+ return [
+ ...(Array.isArray(drafts) ? drafts : []).map((draft) => {
+ const claimNo = normalizeText(draft.claim_no) || '未编号草稿'
+ return {
+ label: `继续草稿 ${claimNo}`,
+ description: [
+ draft.status_label,
+ draft.created_at && `更新时间:${draft.created_at}`,
+ draft.location && `地点:${draft.location}`,
+ draft.amount_label && `金额:${draft.amount_label}`,
+ draft.reason && `事由:${draft.reason}`
+ ].filter(Boolean).join(' · '),
+ icon: 'mdi mdi-file-document-edit-outline',
+ action_type: 'open_application_detail',
+ payload: {
+ claim_id: draft.id,
+ claim_no: draft.claim_no,
+ original_message: sourceText
+ }
+ }
+ }),
+ buildSkipReimbursementDraftCheckAction(sourceText)
+ ]
+}
+
+export function buildReimbursementAssociationActions(applications, originalMessage = '') {
+ const sourceText = normalizeText(originalMessage) || '我要报销'
+ return [
+ ...buildRequiredApplicationActions(applications, 'select_required_application').map((action) => ({
+ ...action,
+ payload: {
+ ...(action.payload || {}),
+ original_message: sourceText
+ }
+ })),
+ buildSkipRequiredApplicationLinkAction(sourceText)
+ ]
+}
+
+function buildAssociationCardFieldHtml(label = '', value = '', options = {}) {
+ const text = normalizeText(value)
+ if (!text) {
+ return ''
+ }
+ const fieldClass = options.fieldClass ? ` ${options.fieldClass}` : ''
+ const valueClass = options.valueClass ? ` ${options.valueClass}` : ''
+ return [
+ `
`,
+ `${escapeHtml(label)}`,
+ `${escapeHtml(text)}`,
+ '
'
+ ].join('')
+}
+
+function buildReimbursementDraftCardHtml(draft = {}) {
+ const statusLabel = normalizeText(draft.status_label) || '草稿'
+ const title = normalizeText(EXPENSE_TYPE_LABELS[normalizeLower(draft.expense_type)] || draft.expense_type) || '报销草稿'
+ const summaryHtml = [
+ buildAssociationCardFieldHtml('金额', draft.amount_label || draft.amount || '待确认', {
+ valueClass: 'ai-document-card__amount'
+ }),
+ buildAssociationCardFieldHtml('更新时间', draft.created_at || '待确认')
+ ].join('')
+ const detailsHtml = [
+ buildAssociationCardFieldHtml('地点', draft.location || '待补充'),
+ buildAssociationCardFieldHtml('单据编号', draft.claim_no || '未编号草稿', {
+ valueClass: 'ai-document-card__number'
+ }),
+ buildAssociationCardFieldHtml('事由', draft.reason || '待补充'),
+ buildAssociationCardFieldHtml('单据类型', `报销单 · ${title}`),
+ buildAssociationCardFieldHtml('操作', '使用下方按钮继续', {
+ fieldClass: 'ai-document-card__field--action'
+ })
+ ].join('')
+
+ return [
+ '',
+ '',
+ `${escapeHtml(title)}`,
+ `${escapeHtml(statusLabel)}`,
+ '',
+ '',
+ summaryHtml ? `
${summaryHtml}
` : '',
+ '
',
+ detailsHtml,
+ '
',
+ '
',
+ ''
+ ].join('')
+}
+
+export function buildReimbursementDraftCardsHtml(drafts = []) {
+ const candidates = (Array.isArray(drafts) ? drafts : []).slice(0, 5)
+ if (!candidates.length) {
+ return ''
+ }
+ return [
+ '',
+ '',
+ ...candidates.map((draft) => buildReimbursementDraftCardHtml(draft)),
+ '',
+ ''
+ ].join('\n')
+}
+
+function buildReimbursementAssociationCardHtml(application = {}) {
+ const statusLabel = normalizeText(application.status_label) || '已审批'
+ const statusTone = ['已审批', '已完成', '已归档'].some((item) => statusLabel.includes(item))
+ ? 'is-success'
+ : 'is-pending'
+ const title = normalizeText(resolveRequiredApplicationReimbursementType(application).expenseTypeLabel) || '费用申请'
+ const summaryHtml = [
+ buildAssociationCardFieldHtml('时间', application.business_time || '待补充'),
+ buildAssociationCardFieldHtml('预计金额', application.amount_label || application.amount || '待确认', {
+ valueClass: 'ai-document-card__amount'
+ })
+ ].join('')
+ const detailsHtml = [
+ buildAssociationCardFieldHtml('地点', application.location || '待补充'),
+ buildAssociationCardFieldHtml('单据编号', application.claim_no || '未编号申请单', {
+ valueClass: 'ai-document-card__number'
+ }),
+ buildAssociationCardFieldHtml('事由', application.reason || '待补充'),
+ buildAssociationCardFieldHtml('单据类型', `申请单 · ${title}`),
+ buildAssociationCardFieldHtml('操作', '使用下方按钮关联', {
+ fieldClass: 'ai-document-card__field--action'
+ })
+ ].join('')
+
+ return [
+ ``,
+ '',
+ `${escapeHtml(title)}`,
+ `${escapeHtml(statusLabel)}`,
+ '',
+ '',
+ summaryHtml ? `
${summaryHtml}
` : '',
+ '
',
+ detailsHtml,
+ '
',
+ '
',
+ ''
+ ].join('')
+}
+
+export function buildReimbursementAssociationCardsHtml(applications = []) {
+ const candidates = (Array.isArray(applications) ? applications : []).slice(0, 5)
+ if (!candidates.length) {
+ return ''
+ }
+ return [
+ '',
+ '',
+ ...candidates.map((application) => buildReimbursementAssociationCardHtml(application)),
+ '',
+ ''
+ ].join('\n')
+}
+
+function resolveAssociationStatusGroup(application = {}) {
+ const status = normalizeLower(application.status)
+ if (['approved', 'completed', 'archived'].includes(status)) {
+ return { key: 'completed', label: '已审批' }
+ }
+ if (['submitted', 'review', 'pending'].includes(status)) {
+ return { key: 'in_progress', label: '审批中' }
+ }
+ if (['returned', 'rejected'].includes(status)) {
+ return { key: 'draft', label: '待完善' }
+ }
+ return { key: 'other', label: '其他状态' }
+}
+
+export function buildReimbursementAssociationQueryPayload(applications = []) {
+ const candidates = Array.isArray(applications) ? applications : []
+ const records = candidates.map((application) => {
+ const statusGroup = resolveAssociationStatusGroup(application)
+ return {
+ claimId: normalizeText(application.id || application.claim_no),
+ claimNo: normalizeText(application.claim_no) || '未编号申请单',
+ employeeName: '',
+ expenseType: normalizeText(application.expense_type),
+ expenseTypeLabel: resolveRequiredApplicationReimbursementType(application).expenseTypeLabel,
+ amount: Number(String(application.amount || '').replace(/,/g, '')) || 0,
+ amountDisplay: normalizeText(application.amount_label || application.amount) || '待确认',
+ status: normalizeText(application.status),
+ statusLabel: normalizeText(application.status_label) || statusGroup.label,
+ statusGroup: statusGroup.key,
+ statusGroupLabel: statusGroup.label,
+ approvalStage: '',
+ documentDate: normalizeText(application.application_date),
+ occurredAt: '',
+ reason: normalizeText(application.reason) || '费用申请',
+ location: normalizeText(application.location),
+ riskItems: [],
+ summary: normalizeText(application.reason) || '费用申请',
+ dateDisplay: normalizeText(application.business_time || application.application_date) || '待补充日期'
+ }
+ })
+ const statusGroups = records.reduce((groups, record) => {
+ const key = record.statusGroup || 'other'
+ const existing = groups.get(key) || {
+ key,
+ label: record.statusGroupLabel || '其他状态',
+ count: 0
+ }
+ existing.count += 1
+ groups.set(key, existing)
+ return groups
+ }, new Map())
+ return {
+ resultType: 'expense_claim_list',
+ scopeLabel: '可关联申请单',
+ selectionMode: 'reimbursement_application_association',
+ selectionLocked: false,
+ selectedClaimId: '',
+ title: '可关联申请单',
+ emptyText: '当前没有可关联的已审批申请单。',
+ recentWindowApplied: false,
+ windowDays: null,
+ windowStartDate: '',
+ windowEndDate: '',
+ recordCount: records.length,
+ previewCount: records.length,
+ previewLimit: 5,
+ olderRecordCount: 0,
+ hasMoreInWindow: false,
+ totalAmount: records.reduce((sum, record) => sum + Number(record.amount || 0), 0),
+ statusGroups: Array.from(statusGroups.values()),
+ records,
+ currentPage: 1
+ }
+}
+
+export function buildReimbursementAssociationThinkingEvents(stage = 'intent', options = {}) {
+ const candidateCount = Number(options.candidateCount || 0)
+ const failed = stage === 'failed'
+ const stageOrder = {
+ intent: 0,
+ query: 1,
+ filter: 2,
+ completed: 4,
+ failed: 4
+ }
+ const currentOrder = stageOrder[stage] ?? 0
+ const resolveStatus = (index) => {
+ if (failed && index >= currentOrder - 1) {
+ return 'failed'
+ }
+ if (currentOrder > index) {
+ return 'completed'
+ }
+ if (currentOrder === index) {
+ return 'running'
+ }
+ return 'pending'
+ }
+
+ return [
+ {
+ eventId: 'reimbursement-association-intent',
+ title: '判断用户意图',
+ content: currentOrder > 0
+ ? '已识别为报销创建请求,需要先检查是否已有报销草稿。'
+ : '正在判断用户是否要创建报销单,并确认是否需要先进入草稿与申请单关联检查。',
+ status: resolveStatus(0)
+ },
+ {
+ eventId: 'reimbursement-draft-check',
+ title: '检查报销草稿',
+ content: currentOrder > 1
+ ? '已完成报销草稿检查,继续判断是否需要进入申请单关联。'
+ : '正在查询你名下是否存在可继续的报销草稿。',
+ status: resolveStatus(1)
+ },
+ {
+ eventId: 'reimbursement-association-query',
+ title: '查询可关联申请单',
+ content: currentOrder > 2
+ ? `已完成申请单查询与筛选,命中 ${candidateCount} 张可推荐单据。`
+ : '如未发现可继续草稿,就查询你名下已审批且尚未关联报销的申请单。',
+ status: resolveStatus(2)
+ },
+ {
+ eventId: 'reimbursement-association-card',
+ title: '生成单据卡片',
+ content: currentOrder > 3
+ ? '已按草稿优先、申请单后置的顺序生成单据卡片和快捷按钮。'
+ : '等待查询结果返回后,以卡片形式展示可继续或可关联的单据。',
+ status: resolveStatus(3)
+ }
+ ]
+}
+
+export function buildReimbursementAssociationSubmitOptions(application = {}, originalMessage = '') {
+ const sourceText = normalizeText(originalMessage) || '我要报销'
+ const resolvedType = resolveRequiredApplicationReimbursementType(application)
+ const applicationId = normalizeText(application.application_claim_id || application.id)
+ const applicationNo = normalizeText(application.application_claim_no || application.claim_no)
+ const applicationReason = normalizeText(application.application_reason || application.reason)
+ const applicationLocation = normalizeText(application.application_location || application.location)
+ const applicationBusinessTime = normalizeText(application.application_business_time || application.business_time)
+ const applicationTransportMode = normalizeText(application.application_transport_mode || application.transport_mode)
+
+ const rawText = [
+ sourceText,
+ `用户选择报销场景:${resolvedType.expenseTypeLabel}`,
+ applicationNo && `关联申请单:${applicationNo}`
+ ].filter(Boolean).join('\n')
+
+ return {
+ rawText,
+ userText: `关联申请单 ${applicationNo || ''}`.trim() || '关联申请单',
+ pendingText: `已关联申请单,正在生成${resolvedType.expenseTypeLabel}草稿...`,
+ systemGenerated: true,
+ skipUserMessage: true,
+ skipDraftAssociationPrompt: true,
+ associationConfirmed: true,
+ extraContext: {
+ draft_claim_id: '',
+ review_action: 'save_draft',
+ user_input_text: sourceText,
+ expense_scene_selection: {
+ expense_type: resolvedType.expenseType,
+ expense_type_label: resolvedType.expenseTypeLabel,
+ original_message: sourceText,
+ application_claim_id: applicationId,
+ application_claim_no: applicationNo
+ },
+ review_form_values: {
+ expense_type: resolvedType.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: application.application_amount || application.amount || '',
+ application_amount_label: application.application_amount_label || application.amount_label || '',
+ application_business_time: applicationBusinessTime,
+ application_days: application.application_days || application.days || '',
+ application_transport_mode: applicationTransportMode,
+ application_lodging_daily_cap: application.application_lodging_daily_cap || application.lodging_daily_cap || '',
+ application_subsidy_daily_cap: application.application_subsidy_daily_cap || application.subsidy_daily_cap || '',
+ application_transport_policy: application.application_transport_policy || application.transport_policy || '',
+ application_policy_estimate: application.application_policy_estimate || application.policy_estimate || '',
+ application_rule_name: application.application_rule_name || application.rule_name || '',
+ application_rule_version: application.application_rule_version || application.rule_version || ''
+ }
+ }
+ }
+}
+
+export async function buildReimbursementAssociationPromptPayload({
+ originalMessage = '我要报销',
+ fetchExpenseClaims,
+ currentUser,
+ skipDraftCheck = false,
+ queryTimeoutMs = REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MS
+} = {}) {
+ const sourceText = normalizeText(originalMessage) || '我要报销'
+ try {
+ const claimsPayload = await fetchReimbursementAssociationClaims({
+ fetchExpenseClaims,
+ timeoutMs: queryTimeoutMs
+ })
+ const user = resolveCurrentUser(currentUser)
+ const drafts = skipDraftCheck ? [] : filterReimbursementDraftCandidates(claimsPayload, user)
+ if (drafts.length) {
+ return {
+ text: buildReimbursementDraftSelectionText(drafts),
+ meta: ['等待选择报销草稿'],
+ suggestedActions: buildReimbursementDraftActions(drafts, sourceText),
+ queryPayload: null,
+ candidateCount: drafts.length
+ }
+ }
+ const candidates = filterReimbursementAssociationCandidates(claimsPayload, user)
+ return {
+ text: candidates.length
+ ? buildReimbursementAssociationSelectionText(candidates)
+ : buildReimbursementAssociationMissingText(),
+ meta: candidates.length ? ['等待关联申请单'] : ['无可关联申请单'],
+ suggestedActions: buildReimbursementAssociationActions(candidates, sourceText),
+ queryPayload: buildReimbursementAssociationQueryPayload(candidates),
+ candidateCount: candidates.length
+ }
+ } catch (error) {
+ console.warn('Fetch reimbursement association applications failed:', error)
+ return {
+ text: buildReimbursementAssociationQueryFailedText(error),
+ meta: ['申请单查询失败'],
+ suggestedActions: buildReimbursementAssociationActions([], sourceText),
+ queryPayload: buildReimbursementAssociationQueryPayload([]),
+ candidateCount: 0,
+ failed: true
+ }
+ }
+}
+
+export async function pushReimbursementAssociationPromptMessage({
+ rawText,
+ createMessage,
+ messages,
+ nextTick,
+ scrollToBottom,
+ persistSessionState,
+ fetchExpenseClaims,
+ currentUser,
+ skipDraftCheck = false,
+ queryTimeoutMs = REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MS
+} = {}) {
+ const pendingMessage = createMessage('assistant', '', [], {
+ pending: true,
+ meta: ['思考中'],
+ stewardPlan: {
+ streamStatus: 'streaming',
+ thinkingEvents: buildReimbursementAssociationThinkingEvents('intent')
+ },
+ suggestedActions: []
+ })
+ messages.value.push(pendingMessage)
+ nextTick(scrollToBottom)
+ persistSessionState()
+ await waitForReimbursementAssociationStep()
+
+ pendingMessage.stewardPlan = {
+ ...(pendingMessage.stewardPlan || {}),
+ streamStatus: 'streaming',
+ thinkingEvents: buildReimbursementAssociationThinkingEvents('query')
+ }
+ nextTick(scrollToBottom)
+
+ const associationPrompt = await buildReimbursementAssociationPromptPayload({
+ originalMessage: rawText,
+ fetchExpenseClaims,
+ currentUser,
+ skipDraftCheck,
+ queryTimeoutMs
+ })
+ pendingMessage.stewardPlan = {
+ ...(pendingMessage.stewardPlan || {}),
+ streamStatus: associationPrompt.failed ? 'failed' : 'streaming',
+ thinkingEvents: buildReimbursementAssociationThinkingEvents(
+ associationPrompt.failed ? 'failed' : 'filter',
+ { candidateCount: associationPrompt.candidateCount }
+ )
+ }
+ nextTick(scrollToBottom)
+ if (!associationPrompt.failed) {
+ await waitForReimbursementAssociationStep()
+ }
+
+ pendingMessage.text = associationPrompt.text
+ pendingMessage.pending = false
+ pendingMessage.meta = associationPrompt.meta
+ pendingMessage.suggestedActions = associationPrompt.suggestedActions
+ pendingMessage.queryPayload = associationPrompt.queryPayload
+ pendingMessage.stewardPlan = {
+ ...(pendingMessage.stewardPlan || {}),
+ streamStatus: associationPrompt.failed ? 'failed' : 'completed',
+ thinkingEvents: buildReimbursementAssociationThinkingEvents(
+ associationPrompt.failed ? 'failed' : 'completed',
+ { candidateCount: associationPrompt.candidateCount }
+ )
+ }
+ nextTick(scrollToBottom)
+ persistSessionState()
+}
diff --git a/web/src/views/scripts/useTravelReimbursementSubmitComposer.js b/web/src/views/scripts/useTravelReimbursementSubmitComposer.js
index 4830c95..cfbb16a 100644
--- a/web/src/views/scripts/useTravelReimbursementSubmitComposer.js
+++ b/web/src/views/scripts/useTravelReimbursementSubmitComposer.js
@@ -15,6 +15,9 @@ import {
buildOperationFeedbackState,
resolveAssistantResultText
} from './travelReimbursementSubmitResponseModel.js'
+import {
+ pushReimbursementAssociationPromptMessage
+} from './travelReimbursementAssociationGateModel.js'
export function useTravelReimbursementSubmitComposer(ctx) {
const {
@@ -446,10 +449,16 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}
if (waitForExpenseSceneSelection) {
- messages.value.push(createMessage('assistant', buildExpenseSceneSelectionMessage(rawText), [], {
- meta: ['等待选择场景'],
- suggestedActions: buildExpenseSceneSelectionActions(rawText)
- }))
+ await pushReimbursementAssociationPromptMessage({
+ rawText,
+ createMessage,
+ messages,
+ nextTick,
+ scrollToBottom,
+ persistSessionState,
+ fetchExpenseClaims,
+ currentUser
+ })
composerDraft.value = ''
composerBusinessTimeTags.value = []
composerBusinessTimeDraftTouched.value = false
diff --git a/web/src/views/scripts/useTravelReimbursementSuggestedActions.js b/web/src/views/scripts/useTravelReimbursementSuggestedActions.js
index 5f12e4a..cb45647 100644
--- a/web/src/views/scripts/useTravelReimbursementSuggestedActions.js
+++ b/web/src/views/scripts/useTravelReimbursementSuggestedActions.js
@@ -17,8 +17,15 @@ import {
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,
@@ -40,6 +47,7 @@ export function useTravelReimbursementSuggestedActions({
createMessage,
currentUser,
emit,
+ fetchExpenseClaims = async () => ({ items: [] }),
handleGuidedShortcut,
handleGuidedSuggestedAction,
handleSceneSelectionApplicationGate,
@@ -224,14 +232,14 @@ export function useTravelReimbursementSuggestedActions({
return true
}
- function pushExpenseSceneSelectionPrompt(originalMessage) {
+ function pushExpenseSceneSelectionPrompt(originalMessage, userEcho = '我要报销') {
const sourceText = String(originalMessage || '').trim()
if (!sourceText) {
return
}
startExpenseSceneSelectionAfterIntentConfirmation(sourceText)
- messages.value.push(createMessage('user', '我要报销'))
+ messages.value.push(createMessage('user', String(userEcho || '我要报销').trim() || '我要报销'))
messages.value.push(createMessage('assistant', buildExpenseSceneSelectionMessage(sourceText), [], {
meta: ['等待选择场景'],
suggestedActions: buildExpenseSceneSelectionActions(sourceText)
@@ -240,6 +248,23 @@ export function useTravelReimbursementSuggestedActions({
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) {
@@ -263,6 +288,35 @@ export function useTravelReimbursementSuggestedActions({
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
@@ -340,7 +394,7 @@ export function useTravelReimbursementSuggestedActions({
const carryFiles = actionPayload.carry_files ? Array.from(attachedFiles.value || []) : []
if (!lockSuggestedActionMessage(message, action)) return
if (targetSessionType === SESSION_TYPE_EXPENSE && carryText === '我要报销') {
- pushExpenseSceneSelectionPrompt(carryText)
+ await pushExpenseAssociationGatePrompt(carryText)
return
}
if (String(actionPayload.steward_plan_id || '').trim()) {
@@ -400,7 +454,7 @@ export function useTravelReimbursementSuggestedActions({
const originalMessage = String(action?.payload?.original_message || message?.text || '').trim()
if (!originalMessage) return
if (!lockSuggestedActionMessage(message, action)) return
- pushExpenseSceneSelectionPrompt(originalMessage)
+ await pushExpenseAssociationGatePrompt(originalMessage)
return
}
diff --git a/web/tests/steward-plan-model-pending-flow.test.mjs b/web/tests/steward-plan-model-pending-flow.test.mjs
index c35da9e..f07bc3f 100644
--- a/web/tests/steward-plan-model-pending-flow.test.mjs
+++ b/web/tests/steward-plan-model-pending-flow.test.mjs
@@ -83,3 +83,44 @@ test('steward pending flow confirmation builds candidate actions', () => {
assert.equal(actions[0].payload.flow_id, 'travel_application')
assert.equal(actions[1].payload.flow_id, 'travel_reimbursement')
})
+
+test('steward ready application confirmation routes workbench action to inline preview table', () => {
+ const actions = buildStewardSuggestedActions({
+ plan_id: 'steward-plan-ready-application',
+ plan_status: 'ready',
+ tasks: [
+ {
+ task_id: 'task-application-beijing',
+ task_type: 'expense_application',
+ title: '费用申请 2026-06-23 北京',
+ summary: '明天前往北京出差3天,支撑客户现场实施。',
+ assigned_agent: 'application_assistant',
+ ontology_fields: {
+ expense_type: 'travel',
+ time_range: '2026-06-23 至 2026-06-25',
+ location: '北京',
+ days: '3天',
+ reason: '支撑客户现场实施'
+ },
+ missing_fields: ['transport_mode']
+ }
+ ],
+ confirmation_groups: [
+ {
+ confirmation_id: 'confirm-application-beijing',
+ action_type: 'confirm_create_application',
+ target_task_id: 'task-application-beijing'
+ }
+ ]
+ })
+
+ assert.equal(actions.length, 1)
+ assert.equal(actions[0].label, '确定,先创建申请单')
+ assert.equal(actions[0].payload.steward_confirm_flow, true)
+ assert.equal(actions[0].payload.flow_id, 'travel_application')
+ assert.equal(actions[0].payload.expense_type, 'travel')
+ assert.equal(actions[0].payload.expense_type_label, '差旅费')
+ assert.match(actions[0].payload.carry_text, /支撑客户现场实施/)
+ assert.match(actions[0].payload.carry_text, /北京/)
+ assert.match(actions[0].payload.carry_text, /2026-06-23 至 2026-06-25/)
+})
diff --git a/web/tests/travel-reimbursement-guided-flow.test.mjs b/web/tests/travel-reimbursement-guided-flow.test.mjs
index eba20f4..268d391 100644
--- a/web/tests/travel-reimbursement-guided-flow.test.mjs
+++ b/web/tests/travel-reimbursement-guided-flow.test.mjs
@@ -56,6 +56,15 @@ import {
filterRequiredApplicationCandidates,
requiresApplicationBeforeReimbursement
} from '../src/views/scripts/travelReimbursementApplicationLinkModel.js'
+import {
+ SKIP_REQUIRED_APPLICATION_LINK_ACTION,
+ buildReimbursementAssociationActions,
+ buildReimbursementAssociationMissingText,
+ buildReimbursementAssociationQueryPayload,
+ buildReimbursementAssociationSelectionText,
+ buildReimbursementAssociationSubmitOptions,
+ buildReimbursementAssociationThinkingEvents
+} from '../src/views/scripts/travelReimbursementAssociationGateModel.js'
import {
ASSISTANT_SCOPE_ACTION_SWITCH,
resolveAssistantScopeGuard
@@ -335,6 +344,28 @@ test('guided reimbursement requires application selection for travel and enterta
assert.equal(actions[0].action_type, GUIDED_ACTION_SELECT_REQUIRED_APPLICATION)
assert.equal(actions[0].payload.application_claim_no, 'AP-202605-001')
+ const associationActions = buildReimbursementAssociationActions(travelApplications, '我要报销')
+ assert.equal(associationActions[0].action_type, 'select_required_application')
+ assert.equal(associationActions[0].payload.application_claim_no, 'AP-202605-001')
+ assert.equal(associationActions.at(-1).action_type, SKIP_REQUIRED_APPLICATION_LINK_ACTION)
+ assert.match(buildReimbursementAssociationSelectionText(travelApplications), /单独新建报销单/)
+ assert.match(buildReimbursementAssociationSelectionText(travelApplications), /ai-document-card-list/)
+ assert.match(buildReimbursementAssociationSelectionText(travelApplications), /ai-document-card--application/)
+ assert.match(buildReimbursementAssociationMissingText(), /单独新建报销单/)
+ const associationQueryPayload = buildReimbursementAssociationQueryPayload(travelApplications)
+ assert.equal(associationQueryPayload.selectionMode, 'reimbursement_application_association')
+ assert.equal(associationQueryPayload.records[0].claimNo, 'AP-202605-001')
+ const completedThinking = buildReimbursementAssociationThinkingEvents('completed', { candidateCount: 1 })
+ assert.equal(completedThinking[0].title, '判断用户意图')
+ assert.equal(completedThinking.at(-1).status, 'completed')
+ const associationSubmitOptions = buildReimbursementAssociationSubmitOptions(
+ associationActions[0].payload,
+ '我要报销'
+ )
+ assert.equal(associationSubmitOptions.skipDraftAssociationPrompt, true)
+ assert.equal(associationSubmitOptions.extraContext.expense_scene_selection.application_claim_no, 'AP-202605-001')
+ assert.equal(associationSubmitOptions.extraContext.review_form_values.application_business_time, '2026-05-20 至 2026-05-23')
+
let state = waitForGuidedApplicationSelection(createGuidedReimbursementState(), 'travel', travelApplications)
assert.equal(state.stepKey, 'application_selection')
assert.equal(state.applicationCandidates[0].claim_no, 'AP-202605-001')
@@ -459,7 +490,13 @@ test('guided flow is local until final confirmation or collected query handoff',
assert.match(messageHandlersScript, /if \(await handleGuidedComposerSubmit\(options\)\) return null[\s\S]*return submitComposerInternal\(options\)/)
assert.match(suggestedActionsScript, /ASSISTANT_SCOPE_ACTION_SWITCH/)
assert.match(suggestedActionsScript, /actionPayload\.carry_text/)
- assert.match(suggestedActionsScript, /targetSessionType === SESSION_TYPE_EXPENSE[\s\S]*carryText === '我要报销'[\s\S]*pushExpenseSceneSelectionPrompt\(carryText\)/)
+ assert.match(suggestedActionsScript, /targetSessionType === SESSION_TYPE_EXPENSE[\s\S]*carryText === '我要报销'[\s\S]*pushExpenseAssociationGatePrompt\(carryText\)/)
+ assert.match(suggestedActionsScript, /actionType === SKIP_REQUIRED_APPLICATION_LINK_ACTION[\s\S]*pushExpenseSceneSelectionPrompt/)
+ assert.match(suggestedActionsScript, /actionType === 'confirm_expense_intent'[\s\S]*pushExpenseAssociationGatePrompt\(originalMessage\)/)
+ assert.match(suggestedActionsScript, /pushReimbursementAssociationPromptMessage\(\{[\s\S]*skipDraftCheck: Boolean\(options\.skipDraftCheck\)/)
+ assert.match(submitComposerScript, /waitForExpenseSceneSelection[\s\S]*pushReimbursementAssociationPromptMessage\(\{[\s\S]*rawText/)
+ assert.match(submitComposerScript, /fetchExpenseClaims[\s\S]*currentUser/)
+ assert.doesNotMatch(submitComposerScript, /if \(waitForExpenseSceneSelection\) \{[\s\S]{0,260}buildExpenseSceneSelectionMessage/)
assert.match(submitComposerScript, /resolveAssistantScopeGuard/)
assert.match(submitComposerScript, /skipScopeGuard/)
assert.match(guidedFlowScript, /submitExistingComposer\(submitOptions\)/)
diff --git a/web/tests/workbench-ai-action-router.test.mjs b/web/tests/workbench-ai-action-router.test.mjs
new file mode 100644
index 0000000..9f03fb6
--- /dev/null
+++ b/web/tests/workbench-ai-action-router.test.mjs
@@ -0,0 +1,138 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+
+import { buildInlineApplicationPreview } from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
+import { buildStewardSuggestedActions } from '../src/views/scripts/stewardPlanModel.js'
+import { useWorkbenchAiActionRouter } from '../src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js'
+
+test('workbench steward application confirmation opens inline application preview directly', () => {
+ const [action] = buildStewardSuggestedActions({
+ plan_id: 'steward-plan-ready-application',
+ plan_status: 'ready',
+ tasks: [
+ {
+ task_id: 'task-application-beijing',
+ task_type: 'expense_application',
+ title: '费用申请 2026-06-23 北京',
+ summary: '明天前往北京出差3天,支撑客户现场实施。',
+ assigned_agent: 'application_assistant',
+ ontology_fields: {
+ expense_type: 'travel',
+ time_range: '2026-06-23 至 2026-06-25',
+ location: '北京',
+ days: '3天',
+ reason: '支撑客户现场实施'
+ },
+ missing_fields: ['transport_mode']
+ }
+ ],
+ confirmation_groups: [
+ {
+ confirmation_id: 'confirm-application-beijing',
+ action_type: 'confirm_create_application',
+ target_task_id: 'task-application-beijing'
+ }
+ ]
+ })
+
+ let previewPayload = null
+ let fallbackConversationStarted = false
+ const router = useWorkbenchAiActionRouter({
+ aiExpenseDraft: { value: null },
+ applicationFlow: {
+ isInlineSuggestedActionDisabled: () => false,
+ executeInlineApplicationPreviewAction: () => {}
+ },
+ assistantDraft: { value: '' },
+ attachmentFlow: {
+ confirmAiAttachmentAssociation: () => {}
+ },
+ emit: () => {},
+ expenseFlow: {
+ linkAiExpenseApplication: () => {},
+ pushInlineExpenseSceneSelectionPrompt: () => {},
+ startAiApplicationPreviewFromAction: (payload) => {
+ previewPayload = payload
+ },
+ startAiExpenseDraft: () => {}
+ },
+ focusAiModeInput: () => {},
+ hasInlineAttachmentOcrDetails: () => false,
+ resolveLatestInlineUserPrompt: () => '',
+ selectedFiles: { value: [] },
+ startInlineConversation: () => {
+ fallbackConversationStarted = true
+ },
+ toast: () => {},
+ toggleInlineAttachmentOcrDetails: () => {}
+ })
+
+ router.handleInlineSuggestedAction(action)
+
+ assert.equal(fallbackConversationStarted, false)
+ assert.equal(previewPayload?.flow_id, 'travel_application')
+ assert.equal(previewPayload?.expense_type, 'travel')
+ assert.equal(previewPayload?.expense_type_label, '差旅费')
+ assert.match(previewPayload?.carry_text || '', /支撑客户现场实施/)
+
+ const preview = buildInlineApplicationPreview(previewPayload.expense_type_label, previewPayload.carry_text, {
+ name: '测试用户',
+ departmentName: '交付部',
+ position: '实施顾问',
+ managerName: '张经理',
+ grade: 'P5'
+ })
+ assert.equal(preview.fields.time, '2026-06-23 至 2026-06-25')
+ assert.equal(preview.fields.location, '北京')
+ assert.equal(preview.fields.reason, '支撑客户现场实施')
+ assert.equal(preview.fields.days, '3天')
+ assert.equal(preview.fields.transportMode, '')
+})
+
+test('workbench reimbursement skip link action opens new reimbursement flow', () => {
+ let sceneSelectionPayload = null
+ let fallbackConversationStarted = false
+ const router = useWorkbenchAiActionRouter({
+ aiExpenseDraft: { value: null },
+ applicationFlow: {
+ isInlineSuggestedActionDisabled: () => false,
+ executeInlineApplicationPreviewAction: () => {}
+ },
+ assistantDraft: { value: '' },
+ attachmentFlow: {
+ confirmAiAttachmentAssociation: () => {}
+ },
+ emit: () => {},
+ expenseFlow: {
+ linkAiExpenseApplication: () => {},
+ pushInlineExpenseSceneSelectionPrompt: (sourceText, label) => {
+ sceneSelectionPayload = { sourceText, label }
+ },
+ startAiApplicationPreviewFromAction: () => {},
+ startAiExpenseDraft: () => {}
+ },
+ focusAiModeInput: () => {},
+ hasInlineAttachmentOcrDetails: () => false,
+ resolveLatestInlineUserPrompt: () => '',
+ selectedFiles: { value: [] },
+ startInlineConversation: () => {
+ fallbackConversationStarted = true
+ },
+ toast: () => {},
+ toggleInlineAttachmentOcrDetails: () => {}
+ })
+
+ router.handleInlineSuggestedAction({
+ label: '不关联,单独新建报销单',
+ action_type: 'skip_required_application_link',
+ payload: {
+ original_message: '我要报销'
+ }
+ })
+
+ assert.equal(fallbackConversationStarted, false)
+ assert.deepEqual(sceneSelectionPayload, {
+ sourceText: '我要报销',
+ label: '不关联,单独新建报销单'
+ })
+})
diff --git a/web/tests/workbench-ai-reimbursement-association-gate.test.mjs b/web/tests/workbench-ai-reimbursement-association-gate.test.mjs
new file mode 100644
index 0000000..aaf29c1
--- /dev/null
+++ b/web/tests/workbench-ai-reimbursement-association-gate.test.mjs
@@ -0,0 +1,326 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import test from 'node:test'
+
+import { useWorkbenchAiExpenseFlow } from '../src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js'
+
+const personalWorkbenchAiMode = readFileSync(
+ join(process.cwd(), 'web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js'),
+ 'utf8'
+)
+
+function createInlineMessage(role, content, extras = {}) {
+ return {
+ id: `${role}-${Math.random().toString(16).slice(2)}`,
+ role,
+ content,
+ text: content,
+ ...extras
+ }
+}
+
+function buildFlow(options = {}) {
+ const conversationMessages = { value: [] }
+ const aiExpenseDraft = { value: null }
+ const activated = []
+ let persisted = 0
+ const scrolled = []
+
+ const flow = useWorkbenchAiExpenseFlow({
+ activateInlineConversation: (payload) => {
+ activated.push(payload)
+ conversationStarted.value = true
+ },
+ aiExpenseDraft,
+ assistantDraft: { value: '我要报销' },
+ clearAiModeFiles: () => {},
+ closeWorkbenchDatePicker: () => {},
+ conversationMessages,
+ conversationStarted,
+ createInlineMessage,
+ currentUser: { value: options.currentUser || { name: '张小青', username: 'xiaoqing.zhang' } },
+ fetchExpenseClaimsForAi: options.fetchExpenseClaimsForAi,
+ runOrchestratorForAi: options.runOrchestratorForAi,
+ associationQueryTimeoutMs: options.associationQueryTimeoutMs,
+ persistCurrentConversation: () => {
+ persisted += 1
+ },
+ pushInlineUserMessage: (text) => {
+ conversationMessages.value.push(createInlineMessage('user', text))
+ },
+ removeWorkbenchDateTag: () => {},
+ resolveLatestInlineUserPrompt: () => '我要报销',
+ scrollInlineConversationToBottom: (payload) => {
+ scrolled.push(payload || {})
+ },
+ startAiApplicationPreview: () => {}
+ })
+ return { activated, aiExpenseDraft, conversationMessages, flow, get persisted() { return persisted }, scrolled }
+}
+
+const conversationStarted = { value: false }
+
+test('reimbursement intent checks drafts before recommending approved application documents', async () => {
+ conversationStarted.value = false
+ let queried = 0
+ const { conversationMessages, flow } = buildFlow({
+ fetchExpenseClaimsForAi: async () => {
+ queried += 1
+ return {
+ items: [
+ {
+ id: 'app-travel-1',
+ claim_no: 'AP-202606-001',
+ employee_name: '张小青',
+ expense_type: 'travel_application',
+ reason: '北京客户现场实施',
+ location: '北京',
+ status: 'approved',
+ start_date: '2026-06-23',
+ end_date: '2026-06-25',
+ amount: 1650
+ },
+ {
+ id: 're-linked-1',
+ claim_no: 'RE-202606-001',
+ employee_name: '张小青',
+ expense_type: 'travel',
+ status: 'submitted',
+ risk_flags_json: [{
+ source: 'application_link',
+ application_claim_no: 'AP-202606-002'
+ }]
+ },
+ {
+ id: 'app-linked-1',
+ claim_no: 'AP-202606-002',
+ employee_name: '张小青',
+ expense_type: 'travel_application',
+ reason: '已被关联的申请',
+ status: 'approved'
+ }
+ ]
+ }
+ }
+ })
+
+ await flow.startAiReimbursementAssociationGate('我要报销')
+
+ assert.equal(queried, 1)
+ assert.equal(conversationMessages.value[0]?.role, 'user')
+ assert.equal(conversationMessages.value[0]?.content, '我要报销')
+ const assistantMessage = conversationMessages.value.at(-1)
+ assert.equal(assistantMessage.role, 'assistant')
+ assert.match(assistantMessage.content, /先检查.*报销草稿/)
+ assert.match(assistantMessage.content, /没有查到可继续的报销草稿/)
+ assert.match(assistantMessage.content, /先查询.*可关联申请单/)
+ assert.match(assistantMessage.content, /AP-202606-001/)
+ assert.doesNotMatch(assistantMessage.content, /AP-202606-002/)
+ assert.equal(assistantMessage.suggestedActions[0].action_type, 'select_required_application')
+ assert.equal(assistantMessage.suggestedActions[0].payload.application_claim_no, 'AP-202606-001')
+ assert.equal(assistantMessage.suggestedActions.at(-1).action_type, 'skip_required_application_link')
+})
+
+test('reimbursement intent stops at existing reimbursement drafts before application association', async () => {
+ conversationStarted.value = false
+ const { conversationMessages, flow } = buildFlow({
+ fetchExpenseClaimsForAi: async () => ({
+ items: [
+ {
+ id: 'draft-travel-1',
+ claim_no: 'RE-202606-010',
+ employee_name: '张小青',
+ expense_type: 'travel',
+ reason: '北京客户现场实施报销',
+ location: '北京',
+ status: 'draft',
+ amount: 650,
+ created_at: '2026-06-23T10:00:00Z'
+ },
+ {
+ id: 'app-travel-1',
+ claim_no: 'AP-202606-001',
+ employee_name: '张小青',
+ expense_type: 'travel_application',
+ reason: '北京客户现场实施',
+ location: '北京',
+ status: 'approved',
+ start_date: '2026-06-23',
+ end_date: '2026-06-25',
+ amount: 1650
+ }
+ ]
+ })
+ })
+
+ await flow.startAiReimbursementAssociationGate('我要报销')
+
+ const assistantMessage = conversationMessages.value.at(-1)
+ assert.match(assistantMessage.content, /先检查.*报销草稿/)
+ assert.match(assistantMessage.content, /查到 1 个可继续的报销草稿/)
+ assert.match(assistantMessage.content, /RE-202606-010/)
+ assert.doesNotMatch(assistantMessage.content, /AP-202606-001/)
+ assert.equal(assistantMessage.suggestedActions[0].action_type, 'open_application_detail')
+ assert.match(assistantMessage.suggestedActions[0].label, /继续草稿/)
+ assert.equal(assistantMessage.suggestedActions.at(-1).action_type, 'skip_reimbursement_draft_check')
+})
+
+test('reimbursement association gate shows thinking before querying and renders application cards', async () => {
+ conversationStarted.value = false
+ let queried = 0
+ let resolveClaims = null
+ const claimsPromise = new Promise((resolve) => {
+ resolveClaims = resolve
+ })
+ const { conversationMessages, flow } = buildFlow({
+ fetchExpenseClaimsForAi: async () => {
+ queried += 1
+ return claimsPromise
+ }
+ })
+
+ const gatePromise = flow.startAiReimbursementAssociationGate('我要报销')
+ await Promise.resolve()
+
+ assert.equal(queried, 0)
+ assert.equal(conversationMessages.value[0]?.role, 'user')
+ const thinkingMessage = conversationMessages.value[1]
+ assert.equal(thinkingMessage.role, 'assistant')
+ assert.equal(thinkingMessage.pending, true)
+ assert.equal(thinkingMessage.stewardPlan.streamStatus, 'streaming')
+ assert.match(thinkingMessage.stewardPlan.thinkingEvents[0].title, /判断用户意图/)
+
+ await new Promise((resolve) => setTimeout(resolve, 360))
+ assert.equal(queried, 1)
+ const queryMessage = conversationMessages.value[1]
+ assert.notEqual(queryMessage, thinkingMessage)
+ assert.match(queryMessage.stewardPlan.thinkingEvents[1].title, /检查报销草稿/)
+ resolveClaims({
+ items: [{
+ id: 'app-travel-card',
+ claim_no: 'AP-202606-006',
+ employee_name: '张小青',
+ expense_type: 'travel_application',
+ reason: '北京客户现场实施',
+ location: '北京',
+ status: 'approved',
+ start_date: '2026-06-23',
+ end_date: '2026-06-25',
+ amount: 1650
+ }]
+ })
+ await gatePromise
+
+ const finalMessage = conversationMessages.value[1]
+ assert.notEqual(finalMessage, queryMessage)
+ assert.equal(finalMessage.pending, false)
+ assert.equal(finalMessage.stewardPlan.streamStatus, 'completed')
+ assert.match(finalMessage.content, /没有查到可继续的报销草稿/)
+ assert.match(finalMessage.content, /ai-document-card-list/)
+ assert.match(finalMessage.content, /ai-document-card--application/)
+ assert.match(finalMessage.content, /AP-202606-006/)
+})
+
+test('reimbursement association gate times out stalled claim query and unlocks fallback actions', async () => {
+ conversationStarted.value = false
+ const { conversationMessages, flow } = buildFlow({
+ associationQueryTimeoutMs: 5,
+ fetchExpenseClaimsForAi: async () => new Promise(() => {})
+ })
+
+ const gatePromise = flow.startAiReimbursementAssociationGate('发起报销')
+ await new Promise((resolve) => setTimeout(resolve, 360))
+ await gatePromise
+
+ const assistantMessage = conversationMessages.value.at(-1)
+ assert.equal(assistantMessage.pending, false)
+ assert.equal(assistantMessage.stewardPlan.streamStatus, 'failed')
+ assert.match(assistantMessage.content, /查询可关联申请单.*超时|查询可关联申请单时出现异常/)
+ assert.equal(assistantMessage.suggestedActions.at(-1).action_type, 'skip_required_application_link')
+})
+
+test('reimbursement association gate matches short username with returned employee email', async () => {
+ conversationStarted.value = false
+ const { conversationMessages, flow } = buildFlow({
+ currentUser: { name: 'caoxiaozhu', username: 'caoxiaozhu' },
+ fetchExpenseClaimsForAi: async () => ({
+ items: [
+ {
+ id: 'app-short-owner',
+ claim_no: 'AVF9ST8TT',
+ employee_id: 'emp-caoxiaozhu',
+ employee_name: '曹笑竹',
+ employee_email: 'caoxiaozhu@xf.com',
+ employee_no: 'E90919',
+ expense_type: 'travel_application',
+ reason: '参加相关残联会议',
+ location: '上海',
+ status: 'approved',
+ amount: 1200
+ }
+ ]
+ })
+ })
+
+ await flow.startAiReimbursementAssociationGate('发起报销')
+
+ const assistantMessage = conversationMessages.value.at(-1)
+ assert.match(assistantMessage.content, /AVF9ST8TT/)
+ assert.equal(assistantMessage.suggestedActions[0].action_type, 'select_required_application')
+ assert.equal(assistantMessage.suggestedActions[0].payload.application_claim_no, 'AVF9ST8TT')
+})
+
+test('linked application selection can create reimbursement draft from association gate', async () => {
+ conversationStarted.value = false
+ const orchestratorCalls = []
+ const { aiExpenseDraft, conversationMessages, flow } = buildFlow({
+ fetchExpenseClaimsForAi: async () => ({ items: [] }),
+ runOrchestratorForAi: async (payload, options) => {
+ orchestratorCalls.push({ payload, options })
+ return {
+ status: 'succeeded',
+ conversation_id: 'conv-linked-draft',
+ result: {
+ draft_payload: {
+ claim_id: 'draft-linked-1',
+ claim_no: 'RE-202606-009',
+ status: 'draft',
+ expense_type: 'travel',
+ reason: '北京客户现场实施'
+ }
+ }
+ }
+ }
+ })
+
+ await flow.linkAiExpenseApplication({
+ application_claim_no: 'AP-202606-001',
+ application_expense_type: 'travel_application',
+ application_reason: '北京客户现场实施',
+ application_location: '北京',
+ application_business_time: '2026-06-23 至 2026-06-25',
+ application_amount_label: '1,650元'
+ })
+
+ assert.equal(orchestratorCalls.length, 1)
+ assert.equal(orchestratorCalls[0].payload.context_json.review_action, 'save_draft')
+ assert.equal(orchestratorCalls[0].payload.context_json.expense_scene_selection.application_claim_no, 'AP-202606-001')
+ assert.equal(orchestratorCalls[0].payload.context_json.review_form_values.application_claim_no, 'AP-202606-001')
+ assert.equal(aiExpenseDraft.value, null)
+ assert.match(conversationMessages.value.at(-1).content, /报销草稿 RE-202606-009 已生成/)
+ assert.equal(conversationMessages.value.at(-1).draftPayload.claim_no, 'RE-202606-009')
+ assert.equal(conversationMessages.value.at(-1).suggestedActions[0].action_type, 'open_application_detail')
+})
+
+test('personal workbench routes reimbursement creation intent to association gate before steward', () => {
+ assert.match(personalWorkbenchAiMode, /function isReimbursementCreationIntent\(prompt = ''\)/)
+ const startConversationIndex = personalWorkbenchAiMode.indexOf('function startInlineConversation')
+ const gateIndex = personalWorkbenchAiMode.indexOf('expenseFlow.startAiReimbursementAssociationGate(cleanPrompt', startConversationIndex)
+ const stewardIndex = personalWorkbenchAiMode.indexOf('stewardFlow.requestInlineAssistantReply(cleanPrompt', startConversationIndex)
+
+ assert.ok(startConversationIndex >= 0)
+ assert.ok(gateIndex > startConversationIndex)
+ assert.ok(stewardIndex > gateIndex)
+ assert.match(personalWorkbenchAiMode, /function runAiModeAction\(item\)[\s\S]*expenseFlow\.startAiReimbursementAssociationGate\(item\.prompt, item\.label\)/)
+})