feat(web): 报销单新增关联申请单门控与草稿检测流程
- 新增 travelReimbursementAssociationGateModel,查询可关联申请单/草稿报销单并生成跳过/选择/单独新建动作,区分差旅费与业务招待费类型 - travelReimbursementApplicationLinkModel 补充 buildLinkedApplicationReferenceIndex/buildRequiredApplicationActions 等关联构建逻辑 - useTravelReimbursementSuggestedActions 接入 select_required_application/skip 系列动作,'我要报销'入口改为先走关联门控 - useWorkbenchAiActionRouter 新增 SKIP_REQUIRED_APPLICATION_LINK/SKIP_REIMBURSEMENT_DRAFT_CHECK 动作分发 - useWorkbenchAiExpenseFlow 暴露 startAiReimbursementAssociationGate,stewardPlanModel 待处理流程适配 - 新增 workbench-ai-action-router、workbench-ai-reimbursement-association-gate 测试并更新 guided-flow、steward-plan 测试
This commit is contained in:
@@ -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/)
|
||||
})
|
||||
|
||||
@@ -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\)/)
|
||||
|
||||
138
web/tests/workbench-ai-action-router.test.mjs
Normal file
138
web/tests/workbench-ai-action-router.test.mjs
Normal file
@@ -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: '不关联,单独新建报销单'
|
||||
})
|
||||
})
|
||||
326
web/tests/workbench-ai-reimbursement-association-gate.test.mjs
Normal file
326
web/tests/workbench-ai-reimbursement-association-gate.test.mjs
Normal file
@@ -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\)/)
|
||||
})
|
||||
Reference in New Issue
Block a user