From ba444a514fb58ec640809da951dcef67617f305b Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Mon, 22 Jun 2026 15:55:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=8A=A5=E9=94=80=E5=8D=95?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=85=B3=E8=81=94=E7=94=B3=E8=AF=B7=E5=8D=95?= =?UTF-8?q?=E9=97=A8=E6=8E=A7=E4=B8=8E=E8=8D=89=E7=A8=BF=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 测试 --- .../useWorkbenchAiActionRouter.js | 23 +- .../useWorkbenchAiExpenseFlow.js | 266 ++++++- web/src/views/scripts/stewardPlanModel.js | 46 ++ ...travelReimbursementApplicationLinkModel.js | 76 +- ...travelReimbursementAssociationGateModel.js | 747 ++++++++++++++++++ .../useTravelReimbursementSubmitComposer.js | 17 +- .../useTravelReimbursementSuggestedActions.js | 62 +- .../steward-plan-model-pending-flow.test.mjs | 41 + .../travel-reimbursement-guided-flow.test.mjs | 39 +- web/tests/workbench-ai-action-router.test.mjs | 138 ++++ ...ai-reimbursement-association-gate.test.mjs | 326 ++++++++ 11 files changed, 1756 insertions(+), 25 deletions(-) create mode 100644 web/src/views/scripts/travelReimbursementAssociationGateModel.js create mode 100644 web/tests/workbench-ai-action-router.test.mjs create mode 100644 web/tests/workbench-ai-reimbursement-association-gate.test.mjs 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\)/) +})