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:
caoxiaozhu
2026-06-22 15:55:59 +08:00
parent aa965da69d
commit ba444a514f
11 changed files with 1756 additions and 25 deletions

View 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\)/)
})