后端优化编排器报销查询和本体检测精度,增强报销单草稿保 存和附件回填逻辑,前端重构侧边栏组件支持折叠和图标导 航,完善文档中心状态筛选和详情提示,报销创建和审批详情 页优化会话管理和费用明细交互,新增助手应用服务和预设动 作工具函数,补充单元测试覆盖。
250 lines
11 KiB
JavaScript
250 lines
11 KiB
JavaScript
import assert from 'node:assert/strict'
|
||
import { readFileSync } from 'node:fs'
|
||
import test from 'node:test'
|
||
import { fileURLToPath } from 'node:url'
|
||
|
||
import {
|
||
APPLICATION_WELCOME_QUICK_ACTIONS,
|
||
APPROVAL_WELCOME_QUICK_ACTIONS,
|
||
ASSISTANT_SESSION_MODE_OPTIONS,
|
||
EXPENSE_WELCOME_QUICK_ACTIONS,
|
||
SESSION_TYPE_APPLICATION,
|
||
SESSION_TYPE_APPROVAL,
|
||
SESSION_TYPE_EXPENSE,
|
||
SESSION_TYPE_KNOWLEDGE,
|
||
buildWelcomeQuickActions
|
||
} from '../src/views/scripts/travelReimbursementConversationModel.js'
|
||
import {
|
||
GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW,
|
||
GUIDED_ACTION_CONTINUE_FILLING,
|
||
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
|
||
GUIDED_ACTION_PROCESS_INTERRUPTION,
|
||
GUIDED_ACTION_SELECT_EXPENSE_TYPE,
|
||
GUIDED_ACTION_SELECT_QUERY_MODE,
|
||
GUIDED_ACTION_SELECT_QUERY_STATUS,
|
||
GUIDED_ACTION_START_REIMBURSEMENT,
|
||
GUIDED_ACTION_START_STATUS_QUERY,
|
||
GUIDED_FLOW_MODE_REIMBURSEMENT,
|
||
GUIDED_FLOW_MODE_STATUS_QUERY,
|
||
applyGuidedReimbursementAnswer,
|
||
buildGuidedExpenseTypeActions,
|
||
buildGuidedInterruptionActions,
|
||
buildGuidedQueryModeActions,
|
||
buildGuidedQueryStatusActions,
|
||
buildGuidedReimbursementSummaryText,
|
||
buildGuidedReviewConfirmationActions,
|
||
buildGuidedReviewSubmitOptions,
|
||
buildGuidedStatusQueryText,
|
||
buildGuidedStepPromptText,
|
||
createEmptyGuidedFlowState,
|
||
createGuidedReimbursementState,
|
||
createGuidedStatusQueryState,
|
||
isGuidedReimbursementReadyForReview,
|
||
normalizeGuidedFlowState,
|
||
selectGuidedExpenseType,
|
||
selectGuidedQueryMode,
|
||
shouldConfirmGuidedInterruption
|
||
} from '../src/views/scripts/travelReimbursementGuidedFlowModel.js'
|
||
import {
|
||
ASSISTANT_SCOPE_ACTION_SWITCH,
|
||
resolveAssistantScopeGuard
|
||
} from '../src/utils/assistantSessionScope.js'
|
||
|
||
const createViewScript = readFileSync(
|
||
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||
'utf8'
|
||
)
|
||
const guidedFlowScript = readFileSync(
|
||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementGuidedFlow.js', import.meta.url)),
|
||
'utf8'
|
||
)
|
||
const sessionStateScript = readFileSync(
|
||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSessionState.js', import.meta.url)),
|
||
'utf8'
|
||
)
|
||
const submitComposerScript = readFileSync(
|
||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
||
'utf8'
|
||
)
|
||
|
||
test('assistant session modes expose independent quick actions', () => {
|
||
assert.deepEqual(
|
||
ASSISTANT_SESSION_MODE_OPTIONS.map((item) => item.label),
|
||
['申请助手', '报销助手', '审核助手', '财务知识助手']
|
||
)
|
||
assert.deepEqual(
|
||
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.label),
|
||
['快速发起报销', '查询单据状态', '差旅计算器']
|
||
)
|
||
assert.deepEqual(
|
||
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.action),
|
||
[
|
||
GUIDED_ACTION_START_REIMBURSEMENT,
|
||
GUIDED_ACTION_START_STATUS_QUERY,
|
||
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR
|
||
]
|
||
)
|
||
assert.ok(EXPENSE_WELCOME_QUICK_ACTIONS.every((item) => !item.prompt))
|
||
assert.equal(buildWelcomeQuickActions(SESSION_TYPE_EXPENSE).length, 3)
|
||
assert.deepEqual(
|
||
buildWelcomeQuickActions(SESSION_TYPE_APPLICATION).map((item) => item.label),
|
||
APPLICATION_WELCOME_QUICK_ACTIONS.map((item) => item.label)
|
||
)
|
||
assert.deepEqual(
|
||
buildWelcomeQuickActions(SESSION_TYPE_APPROVAL).map((item) => item.label),
|
||
APPROVAL_WELCOME_QUICK_ACTIONS.map((item) => item.label)
|
||
)
|
||
assert.notDeepEqual(
|
||
buildWelcomeQuickActions(SESSION_TYPE_KNOWLEDGE).map((item) => item.label),
|
||
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.label),
|
||
'knowledge hot questions should stay independent'
|
||
)
|
||
})
|
||
|
||
test('assistant session scope guard keeps business boundaries isolated', () => {
|
||
const expenseInApplication = resolveAssistantScopeGuard('我想报销的士票', SESSION_TYPE_APPLICATION)
|
||
assert.equal(expenseInApplication.targetSessionType, SESSION_TYPE_EXPENSE)
|
||
assert.match(expenseInApplication.text, /申请助手/)
|
||
assert.match(expenseInApplication.text, /报销助手/)
|
||
assert.equal(expenseInApplication.suggestedActions[0].action_type, ASSISTANT_SCOPE_ACTION_SWITCH)
|
||
assert.equal(expenseInApplication.suggestedActions[0].payload.session_type, SESSION_TYPE_EXPENSE)
|
||
assert.equal(expenseInApplication.suggestedActions[0].payload.carry_text, '我想报销的士票')
|
||
|
||
assert.equal(resolveAssistantScopeGuard('我想发起一笔费用申请', SESSION_TYPE_APPLICATION), null)
|
||
assert.equal(
|
||
resolveAssistantScopeGuard('帮我查询待我审核的单据', SESSION_TYPE_EXPENSE).targetSessionType,
|
||
SESSION_TYPE_APPROVAL
|
||
)
|
||
assert.equal(
|
||
resolveAssistantScopeGuard('差旅住宿标准是多少', SESSION_TYPE_EXPENSE).targetSessionType,
|
||
SESSION_TYPE_KNOWLEDGE
|
||
)
|
||
assert.equal(
|
||
resolveAssistantScopeGuard('报销标准是多少', SESSION_TYPE_EXPENSE).targetSessionType,
|
||
SESSION_TYPE_KNOWLEDGE
|
||
)
|
||
assert.equal(
|
||
resolveAssistantScopeGuard('解释这张单据酒店超标风险', SESSION_TYPE_EXPENSE, { hasActiveReviewPayload: true }),
|
||
null
|
||
)
|
||
})
|
||
|
||
test('guided reimbursement asks type first and walks travel fields in order', () => {
|
||
const typeActions = buildGuidedExpenseTypeActions()
|
||
assert.deepEqual(
|
||
typeActions.map((action) => action.label),
|
||
['差旅费', '交通费', '住宿费', '业务招待费', '办公用品费', '其他费用']
|
||
)
|
||
assert.ok(typeActions.every((action) => action.action_type === GUIDED_ACTION_SELECT_EXPENSE_TYPE))
|
||
|
||
let state = createGuidedReimbursementState()
|
||
assert.equal(state.mode, GUIDED_FLOW_MODE_REIMBURSEMENT)
|
||
assert.equal(state.stepKey, 'expense_type')
|
||
|
||
state = selectGuidedExpenseType(state, 'travel')
|
||
assert.equal(state.stepKey, 'reason')
|
||
assert.match(buildGuidedStepPromptText(state), /第 1 步:事由/)
|
||
|
||
state = applyGuidedReimbursementAnswer(state, '去上海支持上海电力部署项目')
|
||
assert.equal(state.stepKey, 'location')
|
||
state = applyGuidedReimbursementAnswer(state, '上海')
|
||
assert.equal(state.stepKey, 'time_range')
|
||
state = applyGuidedReimbursementAnswer(state, '2026-05-20 至 2026-05-23,出差 3 天')
|
||
assert.equal(state.stepKey, 'amount')
|
||
state = applyGuidedReimbursementAnswer(state, '待核算')
|
||
assert.equal(state.stepKey, 'attachments')
|
||
state = applyGuidedReimbursementAnswer(state, '稍后上传')
|
||
|
||
assert.ok(isGuidedReimbursementReadyForReview(state))
|
||
assert.match(buildGuidedReimbursementSummaryText(state), /已完成“差旅费”的引导填写/)
|
||
assert.deepEqual(
|
||
buildGuidedReviewConfirmationActions().map((action) => action.action_type),
|
||
[GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW]
|
||
)
|
||
|
||
const submitOptions = buildGuidedReviewSubmitOptions(state)
|
||
assert.equal(submitOptions.systemGenerated, true)
|
||
assert.equal(submitOptions.extraContext.expense_scene_selection.expense_type, 'travel')
|
||
assert.equal(submitOptions.extraContext.review_form_values.expense_type, '差旅费')
|
||
assert.match(submitOptions.rawText, /事由:去上海支持上海电力部署项目/)
|
||
assert.match(submitOptions.rawText, /出差时间\/天数:2026-05-20 至 2026-05-23,出差 3 天/)
|
||
})
|
||
|
||
test('guided reimbursement interrupts suspicious questions before expensive flow', () => {
|
||
const state = selectGuidedExpenseType(createGuidedReimbursementState(), 'transport')
|
||
assert.equal(shouldConfirmGuidedInterruption('送客户去机场', state), false)
|
||
assert.equal(shouldConfirmGuidedInterruption('帮我查询一下上周的报销状态?', state), true)
|
||
assert.deepEqual(
|
||
buildGuidedInterruptionActions().map((action) => action.action_type),
|
||
[GUIDED_ACTION_CONTINUE_FILLING, GUIDED_ACTION_PROCESS_INTERRUPTION]
|
||
)
|
||
})
|
||
|
||
test('status query guide collects a query mode before calling existing query flow', () => {
|
||
let state = createGuidedStatusQueryState()
|
||
assert.equal(state.mode, GUIDED_FLOW_MODE_STATUS_QUERY)
|
||
assert.equal(state.stepKey, 'query_mode')
|
||
assert.ok(buildGuidedQueryModeActions().every((action) => action.action_type === GUIDED_ACTION_SELECT_QUERY_MODE))
|
||
|
||
state = selectGuidedQueryMode(state, 'status')
|
||
assert.equal(state.stepKey, 'status_value')
|
||
assert.ok(buildGuidedQueryStatusActions().every((action) => action.action_type === GUIDED_ACTION_SELECT_QUERY_STATUS))
|
||
assert.equal(buildGuidedStatusQueryText(state, '已归档'), '帮我查询已归档的报销单据,筛选最近的 5 条记录')
|
||
|
||
const keywordState = selectGuidedQueryMode(createGuidedStatusQueryState(), 'keyword')
|
||
assert.equal(keywordState.stepKey, 'query_value')
|
||
assert.equal(
|
||
buildGuidedStatusQueryText(keywordState, '上海电力'),
|
||
'帮我查询地点或事由包含“上海电力”的报销单据状态,筛选最近的 5 条记录'
|
||
)
|
||
})
|
||
|
||
test('guided flow state is serializable and restored through session state', () => {
|
||
const empty = createEmptyGuidedFlowState()
|
||
assert.deepEqual(normalizeGuidedFlowState({ mode: 'bad' }), empty)
|
||
assert.deepEqual(
|
||
normalizeGuidedFlowState({
|
||
mode: GUIDED_FLOW_MODE_REIMBURSEMENT,
|
||
stepKey: 'amount',
|
||
expenseType: 'travel',
|
||
values: {
|
||
amount: 200,
|
||
attachment_names: ['a.pdf', '', 'a.pdf']
|
||
},
|
||
pendingInterruptionText: '查询状态?'
|
||
}),
|
||
{
|
||
mode: GUIDED_FLOW_MODE_REIMBURSEMENT,
|
||
stepKey: 'amount',
|
||
expenseType: 'travel',
|
||
values: {
|
||
amount: '200',
|
||
attachment_names: ['a.pdf']
|
||
},
|
||
pendingInterruptionText: '查询状态?'
|
||
}
|
||
)
|
||
|
||
assert.match(sessionStateScript, /guidedFlowState:\s*normalizeGuidedFlowState\(state\.guidedFlowState\)/)
|
||
assert.match(sessionStateScript, /runtimeRefs\.guidedFlowState\?\.value/)
|
||
assert.match(sessionStateScript, /guidedFlowState,\s*\n\s*insightPanelCollapsed/)
|
||
assert.match(sessionStateScript, /function refreshWelcomeQuickActions/)
|
||
assert.match(sessionStateScript, /buildWelcomeQuickActions\(/)
|
||
assert.match(sessionStateScript, /ASSISTANT_SESSION_TYPES\.reduce/)
|
||
assert.match(sessionStateScript, /props\.entrySource === 'application' \? SESSION_TYPE_APPLICATION : SESSION_TYPE_EXPENSE/)
|
||
assert.match(sessionStateScript, /const canRestorePersistedInitialState =[\s\S]*shouldPersistLocalSnapshot/)
|
||
})
|
||
|
||
test('guided flow is local until final confirmation or collected query handoff', () => {
|
||
assert.doesNotMatch(guidedFlowScript, /runOrchestrator/)
|
||
assert.doesNotMatch(guidedFlowScript, /startExpenseClaimDraftFlowStep/)
|
||
assert.doesNotMatch(guidedFlowScript, /review_action:\s*['"]save_draft['"]/)
|
||
assert.match(createViewScript, /if \(await handleGuidedComposerSubmit\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*return submitComposerInternal\(options\)/)
|
||
assert.match(createViewScript, /ASSISTANT_SCOPE_ACTION_SWITCH/)
|
||
assert.match(createViewScript, /actionPayload\.carry_text/)
|
||
assert.match(submitComposerScript, /resolveAssistantScopeGuard/)
|
||
assert.match(submitComposerScript, /skipScopeGuard/)
|
||
assert.match(guidedFlowScript, /submitExistingComposer\(submitOptions\)/)
|
||
assert.match(guidedFlowScript, /submitExistingComposer\(\{[\s\S]*pendingText:\s*'正在查询单据状态\.\.\.'/)
|
||
})
|