Files
X-Financial/web/tests/workbench-ai-reimbursement-association-gate.test.mjs
caoxiaozhu ee730aa31c feat(web): AI 工作台文件预览/附件关联任务与草稿分支
- 新增 WorkbenchAiFilePreviewDialog 附件预览对话框及 useWorkbenchAiFilePreview,附件支持点击预览
- 新增 attachmentAssociationJobs/linkedReimbursementDraftJobs 前端服务与对应 composable,接入后台任务轮询与状态展示
- 新增 travelReimbursementDraftBranchModel 草稿分支模型,报销关联门控支持跳过/选择草稿
- PersonalWorkbenchAiMode 及各 composable(expense/document/steward/application-preview/attachment-association)重构适配,WorkbenchAiComposer/FileStrip 样式与交互完善
- DocumentsCenter/ReceiptFolder/TravelReimbursementCreate 等视图及 scripts 重构,风险/差旅规划/审批等工具适配
- 新增/更新前端测试:application-result-card、reimbursement-list-preview-fetch、guided-flow、composer-components 等
2026-06-24 10:42:50 +08:00

465 lines
18 KiB
JavaScript
Raw Permalink 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 \{ 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\)/)
})