Files
X-Financial/web/tests/travel-reimbursement-guided-flow.test.mjs
caoxiaozhu 0c74b4ab4a feat: 财务看板口径重构与半年模拟数据及报销状态注册表
- 重构 finance_dashboard 口径计算,新增模拟公司画像数据生成与筛选
- 引入 expense_claim_status_registry 统一报销状态流转
- 完善报销草稿流程、Item Sync 与本体解析器
- 优化总览页趋势图、分页组件与请求进度步骤
- 增强报销申请快速预览、本体工具与详情展示
- 新增半年报销模拟数据种子脚本与状态审计工具
- 补充财务看板、报销状态注册与模拟数据测试覆盖
2026-06-02 16:22:59 +08:00

416 lines
19 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 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_START_APPLICATION,
GUIDED_ACTION_PROCESS_INTERRUPTION,
GUIDED_ACTION_SELECT_EXPENSE_TYPE,
GUIDED_ACTION_SELECT_REQUIRED_APPLICATION,
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,
selectGuidedRequiredApplication,
selectGuidedExpenseType,
selectGuidedQueryMode,
shouldConfirmGuidedInterruption,
waitForGuidedApplicationSelection
} from '../src/views/scripts/travelReimbursementGuidedFlowModel.js'
import {
buildRequiredApplicationActions,
buildRequiredApplicationMissingText,
buildRequiredApplicationSelectionText,
filterRequiredApplicationCandidates,
requiresApplicationBeforeReimbursement
} from '../src/views/scripts/travelReimbursementApplicationLinkModel.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 guidedModelScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementGuidedFlowModel.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.equal(buildWelcomeQuickActions(SESSION_TYPE_APPLICATION)[0].action, GUIDED_ACTION_START_APPLICATION)
assert.ok(!buildWelcomeQuickActions(SESSION_TYPE_APPLICATION)[0].prompt)
assert.ok(
buildWelcomeQuickActions(SESSION_TYPE_APPLICATION).every((item) => item.action !== GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR)
)
assert.ok(
buildWelcomeQuickActions(SESSION_TYPE_APPROVAL).every((item) => item.action !== GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR)
)
assert.match(guidedFlowScript, /GUIDED_ACTION_START_APPLICATION/)
assert.match(guidedFlowScript, /buildApplicationTemplatePreview/)
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('去北京出差3天支撑国网仿生产环境部署', SESSION_TYPE_EXPENSE).targetSessionType,
SESSION_TYPE_APPLICATION
)
assert.equal(
resolveAssistantScopeGuard('去国网出差3天协助仿生产环境部署', SESSION_TYPE_EXPENSE).targetSessionType,
SESSION_TYPE_APPLICATION
)
assert.equal(
resolveAssistantScopeGuard('下周去上海支撑客户系统上线预计3天', SESSION_TYPE_EXPENSE).targetSessionType,
SESSION_TYPE_APPLICATION
)
assert.equal(
resolveAssistantScopeGuard('安排去深圳客户现场验收项目', SESSION_TYPE_EXPENSE).targetSessionType,
SESSION_TYPE_APPLICATION
)
assert.equal(
resolveAssistantScopeGuard('我要报销去北京出差的费用', SESSION_TYPE_APPLICATION).targetSessionType,
SESSION_TYPE_EXPENSE
)
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 requires application selection for travel and entertainment', () => {
assert.equal(requiresApplicationBeforeReimbursement('travel'), true)
assert.equal(requiresApplicationBeforeReimbursement('meal'), true)
assert.equal(requiresApplicationBeforeReimbursement('transport'), false)
const claimsPayload = {
items: [
{
id: 'app-travel',
claim_no: 'AP-202605-001',
employee_name: '张小青',
expense_type: 'travel_application',
reason: '去上海支持项目部署',
location: '上海',
amount: 1800,
occurred_at: '2026-05-20T08:00:00Z',
status: 'approved',
created_at: '2026-06-02T00:58:00Z',
risk_flags_json: [{
source: 'application_detail',
application_detail: {
application_business_time: '2026-05-20 至 2026-05-23',
days: '4 天',
transport_mode: '火车',
lodging_daily_cap: '600元/天',
subsidy_daily_cap: '120元/天',
transport_policy: '按真实票据复核',
policy_estimate: '住宿 2,400元 + 补贴 480元'
}
}]
},
{
id: 'app-meal',
claim_no: 'AP-202605-002',
employee_name: '张小青',
expense_type: 'expense_application',
reason: '客户招待沟通项目',
location: '武汉',
amount: 600,
status: 'submitted',
created_at: '2026-05-21T08:00:00Z'
},
{
id: 'app-draft',
claim_no: 'AP-202605-003',
employee_name: '张小青',
expense_type: 'travel_application',
reason: '草稿出差申请',
status: 'draft'
},
{
id: 'app-other-user',
claim_no: 'AP-202605-004',
employee_name: '李四',
expense_type: 'travel_application',
reason: '其他员工出差申请',
status: 'approved'
}
]
}
const currentUser = { name: '张小青', username: 'xiaoqing.zhang' }
const travelApplications = filterRequiredApplicationCandidates(claimsPayload, 'travel', currentUser)
assert.deepEqual(travelApplications.map((item) => item.claim_no), ['AP-202605-001'])
assert.match(buildRequiredApplicationSelectionText('travel', travelApplications), /需要先关联对应的申请单/)
assert.match(buildRequiredApplicationMissingText('meal'), /不能继续这类报销流程/)
const mealApplications = filterRequiredApplicationCandidates(claimsPayload, 'meal', currentUser)
assert.deepEqual(mealApplications.map((item) => item.claim_no), ['AP-202605-002'])
const actions = buildRequiredApplicationActions(travelApplications, GUIDED_ACTION_SELECT_REQUIRED_APPLICATION)
assert.equal(actions[0].action_type, GUIDED_ACTION_SELECT_REQUIRED_APPLICATION)
assert.equal(actions[0].payload.application_claim_no, 'AP-202605-001')
let state = waitForGuidedApplicationSelection(createGuidedReimbursementState(), 'travel', travelApplications)
assert.equal(state.stepKey, 'application_selection')
assert.equal(state.applicationCandidates[0].claim_no, 'AP-202605-001')
state = selectGuidedRequiredApplication(state, actions[0].payload)
assert.equal(state.stepKey, 'summary')
assert.equal(isGuidedReimbursementReadyForReview(state), true)
assert.equal(state.values.application_claim_no, 'AP-202605-001')
assert.equal(state.values.application_business_time, '2026-05-20 至 2026-05-23')
assert.equal(state.values.application_days, '4 天')
assert.equal(state.values.application_transport_mode, '火车')
assert.equal(state.values.application_lodging_daily_cap, '600元/天')
assert.equal(state.values.application_subsidy_daily_cap, '120元/天')
const summaryText = buildGuidedReimbursementSummaryText(state)
assert.match(summaryText, /关联申请单AP-202605-001/)
assert.match(summaryText, /草稿详情中上传对应票据/)
assert.doesNotMatch(summaryText, /事由:待补充/)
const submitOptions = buildGuidedReviewSubmitOptions(state)
assert.equal(submitOptions.extraContext.review_action, 'save_draft')
assert.equal(submitOptions.extraContext.review_form_values.application_claim_no, 'AP-202605-001')
assert.equal(submitOptions.extraContext.review_form_values.reason, '去上海支持项目部署')
assert.equal(submitOptions.extraContext.review_form_values.business_location, '上海')
assert.equal(submitOptions.extraContext.review_form_values.amount, '')
assert.equal(submitOptions.extraContext.review_form_values.application_amount, '1800')
assert.equal(submitOptions.extraContext.review_form_values.application_business_time, '2026-05-20 至 2026-05-23')
assert.equal(submitOptions.extraContext.review_form_values.application_days, '4 天')
assert.equal(submitOptions.extraContext.review_form_values.transport_mode, '火车')
assert.equal(submitOptions.extraContext.review_form_values.application_transport_mode, '火车')
assert.equal(submitOptions.extraContext.review_form_values.application_lodging_daily_cap, '600元/天')
assert.equal(submitOptions.extraContext.review_form_values.application_subsidy_daily_cap, '120元/天')
assert.equal(submitOptions.extraContext.expense_scene_selection.application_claim_no, 'AP-202605-001')
assert.match(submitOptions.rawText, /关联申请单AP-202605-001/)
assert.doesNotMatch(submitOptions.rawText, /事由:待补充/)
})
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: '查询状态?',
applicationCandidates: []
}
)
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, /resolveAccessibleSessionTypes\(\)\.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.match(guidedModelScript, /review_action:\s*['"]save_draft['"]/)
assert.match(guidedFlowScript, /fetchExpenseClaims/)
assert.match(guidedFlowScript, /GUIDED_ACTION_SELECT_REQUIRED_APPLICATION/)
assert.match(guidedFlowScript, /isGuidedReimbursementReadyForReview\(guidedFlowState\.value\)[\s\S]*pushReimbursementSummary\(\)/)
assert.match(guidedFlowScript, /isGuidedReimbursementReadyForReview\(currentState\) && fileNames\.length[\s\S]*buildGuidedReviewSubmitOptions\(currentState, mergedFiles\)[\s\S]*skipDraftAssociationPrompt:\s*true[\s\S]*skipUserMessage:\s*true[\s\S]*submitExistingComposer\(submitOptions\)/)
assert.doesNotMatch(guidedFlowScript, /amount:\s*applicationAmount/)
assert.match(guidedFlowScript, /amount:\s*''/)
assert.match(guidedFlowScript, /if \(!applications\.length\) \{[\s\S]*guidedFlowState\.value = createEmptyGuidedFlowState\(\)[\s\S]*meta: \['缺少可关联申请单'\][\s\S]*\}\)/)
assert.doesNotMatch(guidedFlowScript, /meta: \['缺少可关联申请单'\],[\s\S]{0,120}suggestedActions: buildGuidedExpenseTypeActions\(\)/)
assert.match(guidedFlowScript, /handleSceneSelectionApplicationGate/)
assert.match(createViewScript, /handleSceneSelectionApplicationGate/)
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*'正在查询单据状态\.\.\.'/)
})