Files
X-Financial/web/tests/workbench-ai-reimbursement-association-gate.test.mjs
caoxiaozhu 787bc3a481 feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
2026-07-17 14:14:08 +08:00

465 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import test from 'node:test'
import {
buildLinkedDraftRunningText,
useWorkbenchAiExpenseFlow
} from '../src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js'
import {
CONTINUE_REIMBURSEMENT_DRAFT_ACTION,
CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION
} from '../src/views/scripts/travelReimbursementAssociationGateModel.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' } },
createLinkedReimbursementDraftJobForAi: options.createLinkedReimbursementDraftJobForAi,
fetchExpenseClaimsForAi: options.fetchExpenseClaimsForAi,
fetchLinkedReimbursementDraftJobForAi: options.fetchLinkedReimbursementDraftJobForAi,
linkedDraftJobPollIntervalMs: options.linkedDraftJobPollIntervalMs ?? 0,
linkedDraftJobMaxPolls: options.linkedDraftJobMaxPolls ?? 2,
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('linked reimbursement draft running text avoids duplicate status wording', () => {
const content = buildLinkedDraftRunningText(
{ message: '正在后台生成报销草稿...' },
'AVF9ST8TT'
)
const repeated = content.match(/正在后台生成报销草稿/g) || []
assert.equal(repeated.length, 1)
assert.doesNotMatch(content, /处理状态:\s*正在后台生成报销草稿/)
assert.match(content, /回来后我会继续查询任务结果/)
})
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: '0.00',
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.match(assistantMessage.content, /待确认/)
assert.doesNotMatch(assistantMessage.content, />0\.00</)
assert.match(assistantMessage.content, /2026-06-23 10:00/)
assert.doesNotMatch(assistantMessage.content, /T10:00:00Z/)
assert.doesNotMatch(assistantMessage.content, /AP-202606-001/)
assert.match(assistantMessage.content, /下方三个按钮/)
assert.doesNotMatch(assistantMessage.content, /跳过草稿后再关联申请单/)
assert.equal(assistantMessage.suggestedActions.length, 3)
assert.equal(assistantMessage.suggestedActions[0].action_type, 'open_application_detail')
assert.equal(assistantMessage.suggestedActions[0].label, '查看草稿 RE-202606-010')
assert.equal(assistantMessage.suggestedActions[1].action_type, CONTINUE_REIMBURSEMENT_DRAFT_ACTION)
assert.equal(assistantMessage.suggestedActions[1].label, '继续关联草稿 RE-202606-010')
assert.equal(assistantMessage.suggestedActions[2].action_type, CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION)
assert.equal(assistantMessage.suggestedActions[2].label, '独立新建报销单')
})
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 createJobCalls = []
const fetchJobCalls = []
const { aiExpenseDraft, conversationMessages, flow } = buildFlow({
fetchExpenseClaimsForAi: async () => ({ items: [] }),
createLinkedReimbursementDraftJobForAi: async (payload) => {
createJobCalls.push(payload)
return {
job_id: 'linked-draft-job-1',
status: 'queued',
message: '已创建后台生成任务。'
}
},
fetchLinkedReimbursementDraftJobForAi: async (jobId) => {
fetchJobCalls.push(jobId)
return {
job_id: jobId,
status: 'succeeded',
message: '报销草稿已生成。',
draft_payload: {
claim_id: 'draft-linked-1',
claim_no: 'RE-202606-009',
status: 'draft',
expense_type: 'travel',
reason: '北京客户现场实施'
}
}
},
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, 0)
assert.equal(createJobCalls.length, 1)
assert.equal(createJobCalls[0].context_json.review_action, 'save_draft')
assert.equal(createJobCalls[0].context_json.expense_scene_selection.application_claim_no, 'AP-202606-001')
assert.equal(createJobCalls[0].context_json.review_form_values.application_claim_no, 'AP-202606-001')
assert.deepEqual(fetchJobCalls, ['linked-draft-job-1'])
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('linked reimbursement draft job resumes from pending history message', async () => {
conversationStarted.value = true
const fetchJobCalls = []
const { conversationMessages, flow } = buildFlow({
fetchLinkedReimbursementDraftJobForAi: async (jobId) => {
fetchJobCalls.push(jobId)
return {
job_id: jobId,
status: 'succeeded',
message: '报销草稿已生成。',
draft_payload: {
claim_id: 'draft-resumed-1',
claim_no: 'RE-202606-011',
status: 'draft',
expense_type: 'travel'
}
}
}
})
conversationMessages.value.push(createInlineMessage('assistant', '已关联申请单 AP-202606-001正在后台生成报销草稿...', {
id: 'pending-linked-draft-message',
pending: true,
linkedReimbursementDraftJob: {
jobId: 'linked-draft-job-resume',
status: 'queued',
applicationClaimNo: 'AP-202606-001'
}
}))
flow.resumePendingLinkedReimbursementDraftJobs()
await new Promise((resolve) => setTimeout(resolve, 5))
assert.deepEqual(fetchJobCalls, ['linked-draft-job-resume'])
assert.match(conversationMessages.value.at(-1).content, /报销草稿 RE-202606-011 已生成/)
assert.match(conversationMessages.value.at(-1).content, /AP-202606-001/)
assert.equal(conversationMessages.value.at(-1).pending, false)
assert.equal(conversationMessages.value.at(-1).draftPayload.claim_no, 'RE-202606-011')
})
test('continuing an existing reimbursement draft prompts for attachments or description', () => {
conversationStarted.value = false
const { conversationMessages, flow } = buildFlow()
flow.promptAiReimbursementDraftContinuation({
claim_id: 'draft-travel-1',
claim_no: 'RE-202606-010',
original_message: '我要报销'
})
assert.equal(conversationMessages.value.at(-2).role, 'user')
assert.equal(conversationMessages.value.at(-2).content, '继续关联草稿 RE-202606-010')
const assistantMessage = conversationMessages.value.at(-1)
assert.equal(assistantMessage.role, 'assistant')
assert.match(assistantMessage.content, /请上传相关的附件/)
assert.match(assistantMessage.content, /补充说明/)
assert.equal(assistantMessage.suggestedActions[0].action_type, 'open_application_detail')
assert.equal(assistantMessage.suggestedActions[0].label, '查看草稿 RE-202606-010')
})
test('standalone reimbursement draft branch asks before creating a new draft', () => {
conversationStarted.value = false
const { conversationMessages, flow } = buildFlow()
flow.promptStandaloneReimbursementDraftCreation('我要报销', '独立新建报销单')
assert.equal(conversationMessages.value.at(-2).role, 'user')
assert.equal(conversationMessages.value.at(-2).content, '独立新建报销单')
const assistantMessage = conversationMessages.value.at(-1)
assert.equal(assistantMessage.role, 'assistant')
assert.match(assistantMessage.content, /是否新建草稿单据/)
assert.equal(assistantMessage.suggestedActions[0].label, '新建草稿单据')
assert.equal(assistantMessage.suggestedActions[0].action_type, 'skip_required_application_link')
assert.equal(assistantMessage.suggestedActions[1].label, '暂不新建')
})
test('personal workbench routes reimbursement creation intent to association gate before steward', () => {
assert.match(
personalWorkbenchAiMode,
/import\s*\{\s*isReimbursementCreationIntent\s*\}\s*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\)/)
})