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, /import \{ isReimbursementCreationIntent \} from '\.\/workbenchAiApplicationGateModel\.js'/ ) 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\)/) })