feat: 增强风险规则生成引擎与预算中心页面
后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块, 优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强 报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图 组件,重构审计页面和风险规则测试对话框交互,完善文档中心 和报销创建页面细节,补充单元测试覆盖。
This commit is contained in:
291
web/tests/expense-application-fast-preview.test.mjs
Normal file
291
web/tests/expense-application-fast-preview.test.mjs
Normal file
@@ -0,0 +1,291 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
buildApplicationPreviewFooterMessage,
|
||||
buildApplicationPreviewRows,
|
||||
buildApplicationPreviewSubmitText,
|
||||
buildApplicationTemplatePreview,
|
||||
applyApplicationPolicyEstimateResult,
|
||||
buildApplicationPolicyEstimateRequest,
|
||||
buildLocalApplicationPreview,
|
||||
buildLocalApplicationPreviewMessage,
|
||||
buildModelRefinedApplicationPreview,
|
||||
normalizeApplicationPreview,
|
||||
shouldUseLocalApplicationPreview
|
||||
} from '../src/utils/expenseApplicationPreview.js'
|
||||
|
||||
const submitComposerScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const createViewScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const createViewTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const conversationModelScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/travelReimbursementConversationModel.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const previewEditorScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useApplicationPreviewEditor.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('application intent uses local preview instead of immediate orchestrator call', () => {
|
||||
const prompt = '申请 2026-05-20 至 2026-05-23 去上海支撑上海电力部署项目,出差3天,高铁,预计金额2358元'
|
||||
assert.equal(
|
||||
shouldUseLocalApplicationPreview(prompt, {
|
||||
sessionType: 'application',
|
||||
attachmentCount: 0,
|
||||
reviewAction: '',
|
||||
systemGenerated: false
|
||||
}),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
shouldUseLocalApplicationPreview('帮我查询申请状态', {
|
||||
sessionType: 'application',
|
||||
attachmentCount: 0,
|
||||
reviewAction: '',
|
||||
systemGenerated: false
|
||||
}),
|
||||
false
|
||||
)
|
||||
|
||||
const preview = buildLocalApplicationPreview(prompt, { name: '李文静', departmentName: '财务部', grade: 'P5' })
|
||||
assert.equal(preview.fields.applicationType, '差旅费用申请')
|
||||
assert.equal(preview.fields.time, '2026-05-20 至 2026-05-23')
|
||||
assert.equal(preview.fields.location, '上海')
|
||||
assert.equal(preview.fields.days, '3天')
|
||||
assert.equal(preview.fields.transportMode, '火车')
|
||||
assert.equal(preview.fields.amount, '2358元')
|
||||
assert.equal(preview.fields.grade, 'P5')
|
||||
assert.equal(preview.readyToSubmit, true)
|
||||
assert.doesNotMatch(buildLocalApplicationPreviewMessage(preview), /#application-submit/)
|
||||
assert.match(buildApplicationPreviewFooterMessage(preview), /#application-submit/)
|
||||
assert.match(buildLocalApplicationPreviewMessage(preview), /点击对应行即可直接编辑/)
|
||||
})
|
||||
|
||||
test('application preview renders ordered editable rows and submit text uses edited values', () => {
|
||||
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去新疆,伊犁出差,服务美团业务部署,火车,预计费用1800元', {
|
||||
name: '李文静',
|
||||
grade: 'P5'
|
||||
})
|
||||
assert.equal(preview.fields.location, '新疆,伊犁')
|
||||
assert.equal(preview.fields.reason, '服务美团业务部署')
|
||||
const editedPreview = normalizeApplicationPreview({
|
||||
...preview,
|
||||
fields: {
|
||||
...preview.fields,
|
||||
reason: '客户现场项目支持',
|
||||
amount: '1900元'
|
||||
}
|
||||
})
|
||||
|
||||
const rows = buildApplicationPreviewRows(editedPreview)
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.label),
|
||||
['申请类型', '职级', '发生时间', '地点', '事由', '天数', '出行方式', '住宿上限/天', '补贴标准/天', '交通费用口径', '规则测算参考', '用户预估费用']
|
||||
)
|
||||
assert.equal(rows.find((row) => row.key === 'amount')?.value, '1900元')
|
||||
assert.equal(rows.find((row) => row.key === 'amount')?.highlight, true)
|
||||
assert.equal(rows.find((row) => row.key === 'lodgingDailyCap')?.editable, false)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /事由:客户现场项目支持/)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /用户预估费用:1900元/)
|
||||
})
|
||||
|
||||
test('application preview cleans empty time labels and keeps only business reason', () => {
|
||||
const preview = buildLocalApplicationPreview('发生时间:,去九江出差3天,服务美团业务部署,预计费用1800元,火车', {
|
||||
name: '李文静',
|
||||
grade: 'P5'
|
||||
})
|
||||
|
||||
assert.equal(preview.fields.location, '九江')
|
||||
assert.equal(preview.fields.days, '3天')
|
||||
assert.equal(preview.fields.reason, '服务美团业务部署')
|
||||
assert.equal(preview.fields.transportMode, '火车')
|
||||
assert.doesNotMatch(preview.fields.reason, /发生时间|去九江|出差3天/)
|
||||
})
|
||||
|
||||
test('application preview can be refined by ontology model extraction', () => {
|
||||
const rawText = '发生时间:,去九江出差3天,服务美团业务部署,预计费用1800元,火车'
|
||||
const localPreview = buildLocalApplicationPreview(rawText, { name: '李文静', grade: 'P5' })
|
||||
const refinedPreview = buildModelRefinedApplicationPreview(
|
||||
localPreview,
|
||||
{
|
||||
parse_strategy: 'llm_primary',
|
||||
entities: [
|
||||
{ type: 'expense_type', value: '差旅费', normalized_value: 'travel' },
|
||||
{ type: 'location', value: '九江', normalized_value: '九江' },
|
||||
{ type: 'reason', value: '服务美团业务部署', normalized_value: '服务美团业务部署' },
|
||||
{ type: 'transport_mode', value: '火车', normalized_value: '火车' },
|
||||
{ type: 'amount', value: '1800元', normalized_value: '1800' }
|
||||
],
|
||||
time_range: {},
|
||||
missing_slots: []
|
||||
},
|
||||
rawText,
|
||||
{ name: '李文静', grade: 'P5' }
|
||||
)
|
||||
|
||||
assert.equal(refinedPreview.modelRefined, true)
|
||||
assert.equal(refinedPreview.parseStrategy, 'llm_primary')
|
||||
assert.equal(refinedPreview.modelReviewStatus, 'completed')
|
||||
assert.equal(refinedPreview.fields.applicationType, '差旅费用申请')
|
||||
assert.equal(refinedPreview.fields.time, '')
|
||||
assert.equal(refinedPreview.fields.reason, '服务美团业务部署')
|
||||
assert.equal(refinedPreview.fields.transportMode, '火车')
|
||||
})
|
||||
|
||||
test('application preview keeps rule fallback distinct from model reviewed result', () => {
|
||||
const rawText = '申请 2026-05-20 至 2026-05-23 去上海支撑服务器部署,出差3天,火车,预计费用1800元'
|
||||
const localPreview = buildLocalApplicationPreview(rawText, { name: '李文静', grade: 'P5' })
|
||||
const fallbackPreview = buildModelRefinedApplicationPreview(
|
||||
localPreview,
|
||||
{
|
||||
parse_strategy: 'rule_fallback',
|
||||
entities: [
|
||||
{ type: 'expense_type', value: '差旅费', normalized_value: 'travel' },
|
||||
{ type: 'location', value: '上海', normalized_value: '上海' },
|
||||
{ type: 'amount', value: '1800元', normalized_value: '1800' }
|
||||
],
|
||||
time_range: {
|
||||
start: '2026-05-20',
|
||||
end: '2026-05-23'
|
||||
},
|
||||
missing_slots: []
|
||||
},
|
||||
rawText,
|
||||
{ name: '李文静', grade: 'P5' }
|
||||
)
|
||||
const message = buildLocalApplicationPreviewMessage(fallbackPreview)
|
||||
const footer = buildApplicationPreviewFooterMessage(fallbackPreview)
|
||||
|
||||
assert.equal(fallbackPreview.modelReviewStatus, 'fallback')
|
||||
assert.match(message, /规则兜底/)
|
||||
assert.match(footer, /规则兜底/)
|
||||
assert.doesNotMatch(footer, /#application-submit/)
|
||||
})
|
||||
|
||||
test('application preview with missing budget stays in chat and asks for补充信息', () => {
|
||||
const preview = buildLocalApplicationPreview('我想申请去北京出差,高铁,但是不知道预算', {
|
||||
name: '李文静',
|
||||
grade: 'P5'
|
||||
})
|
||||
assert.equal(preview.fields.amount, '待测算')
|
||||
assert.equal(preview.readyToSubmit, false)
|
||||
assert.match(buildLocalApplicationPreviewMessage(preview), /下方表格/)
|
||||
assert.doesNotMatch(buildLocalApplicationPreviewMessage(preview), /当前还需要补充/)
|
||||
assert.match(buildApplicationPreviewFooterMessage(preview), /当前还需要补充/)
|
||||
assert.doesNotMatch(buildLocalApplicationPreviewMessage(preview), /#application-submit/)
|
||||
})
|
||||
|
||||
test('application quick start renders a template without model review', () => {
|
||||
const preview = buildApplicationTemplatePreview({
|
||||
name: '李文静',
|
||||
departmentName: '财务部',
|
||||
grade: 'P5'
|
||||
})
|
||||
const message = buildLocalApplicationPreviewMessage(preview)
|
||||
|
||||
assert.equal(preview.modelReviewStatus, 'template')
|
||||
assert.equal(preview.fields.applicationType, '费用申请')
|
||||
assert.equal(preview.fields.applicant, '李文静')
|
||||
assert.equal(preview.fields.department, '财务部')
|
||||
assert.equal(preview.fields.grade, 'P5')
|
||||
assert.match(message, /不调用大模型/)
|
||||
assert.match(message, /点击对应行直接填写/)
|
||||
assert.doesNotMatch(message, /#application-submit/)
|
||||
assert.match(buildApplicationPreviewFooterMessage(preview), /当前还需要补充/)
|
||||
})
|
||||
|
||||
test('application session shows intent flow, persists preview, and supports inline table edit', () => {
|
||||
assert.match(submitComposerScript, /shouldUseLocalApplicationPreview/)
|
||||
assert.match(submitComposerScript, /buildLocalApplicationPreviewMessage/)
|
||||
assert.match(submitComposerScript, /buildApplicationPreviewWithModelReview/)
|
||||
assert.match(submitComposerScript, /fetchOntologyParse/)
|
||||
assert.match(submitComposerScript, /calculateTravelReimbursement/)
|
||||
assert.match(submitComposerScript, /buildApplicationPolicyEstimateRequest/)
|
||||
assert.match(submitComposerScript, /模型复核中/)
|
||||
assert.match(submitComposerScript, /startFlowStep\('intent'/)
|
||||
assert.match(submitComposerScript, /startFlowStep\('application-review-preview'/)
|
||||
assert.match(submitComposerScript, /completeFlowStep\('intent'/)
|
||||
assert.match(submitComposerScript, /insightPanelCollapsed\.value = true/)
|
||||
assert.doesNotMatch(submitComposerScript, /void refineApplicationPreviewWithModel/)
|
||||
assert.match(submitComposerScript, /return null[\s\S]*const hasUnsavedReviewDraft/)
|
||||
assert.ok(
|
||||
submitComposerScript.indexOf('shouldUseLocalApplicationPreview') <
|
||||
submitComposerScript.indexOf('const payload = await runOrchestrator')
|
||||
)
|
||||
|
||||
assert.match(createViewScript, /const isApplicationSession = computed/)
|
||||
assert.match(createViewScript, /insightPanelCollapsed,/)
|
||||
assert.doesNotMatch(createViewScript, /if \(isApplicationSession\.value\) \{\s*return false\s*\}/)
|
||||
assert.match(createViewScript, /flowSteps\.value\.length > 0/)
|
||||
assert.match(createViewScript, /useApplicationPreviewEditor/)
|
||||
assert.match(createViewScript, /message-bubble-application-preview/)
|
||||
assert.match(createViewScript, /buildApplicationPreviewFooterMessage/)
|
||||
assert.match(createViewScript, /function buildApplicationPreviewFooterText\(message\)/)
|
||||
assert.match(createViewScript, /buildApplicationPreviewSubmitText/)
|
||||
assert.match(createViewScript, /user_input_text: applicationSubmitText/)
|
||||
assert.match(conversationModelScript, /applicationPreview: null/)
|
||||
assert.match(conversationModelScript, /applicationPreview: message\.applicationPreview \|\| null/)
|
||||
|
||||
assert.match(createViewTemplate, /class="application-preview-table"/)
|
||||
assert.match(createViewTemplate, /class="application-preview-footer message-answer-content message-answer-markdown"/)
|
||||
assert.match(createViewTemplate, /v-html="renderMarkdown\(buildApplicationPreviewFooterText\(message\)\)"/)
|
||||
assert.match(createViewTemplate, /'has-insight': hasInsightPanelContent && showInsightPanel/)
|
||||
assert.match(createViewTemplate, /v-model="applicationPreviewEditor\.draftValue"/)
|
||||
assert.match(createViewTemplate, /application-preview-select/)
|
||||
assert.match(createViewTemplate, /resolveApplicationPreviewEditorOptions/)
|
||||
assert.match(createViewTemplate, /row\.editable && !isApplicationPreviewEditing\(message, row\.key\).*openApplicationPreviewEditor\(message, row\.key, row\.value\)"/)
|
||||
assert.match(createViewTemplate, /@keydown\.enter\.prevent="row\.editable && !isApplicationPreviewEditing\(message, row\.key\).*openApplicationPreviewEditor\(message, row\.key, row\.value\)"/)
|
||||
assert.match(createViewTemplate, /@keydown\.stop="handleApplicationPreviewEditorKeydown\(\$event, message\)"/)
|
||||
assert.match(createViewTemplate, /mdi mdi-pencil-outline/)
|
||||
assert.match(createViewTemplate, /@click\.stop="openApplicationPreviewEditor\(message, row\.key, row\.value\)"/)
|
||||
assert.match(createViewTemplate, /openApplicationPreviewEditor/)
|
||||
assert.match(createViewTemplate, /commitApplicationPreviewEditor/)
|
||||
|
||||
assert.match(previewEditorScript, /normalizeApplicationPreview/)
|
||||
assert.match(previewEditorScript, /APPLICATION_TRANSPORT_MODE_OPTIONS/)
|
||||
assert.match(previewEditorScript, /buildLocalApplicationPreviewMessage/)
|
||||
assert.match(previewEditorScript, /targetRow\.editable === false/)
|
||||
assert.match(previewEditorScript, /\[editor\.fieldKey\]: nextValue/)
|
||||
})
|
||||
|
||||
test('application preview merges rule center travel estimate into highlighted rows', () => {
|
||||
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去上海出差3天,服务项目部署,火车,预计费用1800元', {
|
||||
name: '李文静',
|
||||
grade: 'P5'
|
||||
})
|
||||
const request = buildApplicationPolicyEstimateRequest(preview, { grade: 'P5' })
|
||||
assert.equal(request.canCalculate, true)
|
||||
assert.deepEqual(request.payload, { days: 3, location: '上海', grade: 'P5' })
|
||||
|
||||
const estimatedPreview = applyApplicationPolicyEstimateResult(preview, {
|
||||
days: 3,
|
||||
location: '上海',
|
||||
matched_city: '上海',
|
||||
grade: 'P5',
|
||||
hotel_rate: 600,
|
||||
hotel_amount: 1800,
|
||||
total_allowance_rate: 120,
|
||||
allowance_amount: 360,
|
||||
total_amount: 2160,
|
||||
rule_name: '公司差旅费报销规则',
|
||||
rule_version: '2026版'
|
||||
}, { grade: 'P5' })
|
||||
|
||||
assert.equal(estimatedPreview.fields.lodgingDailyCap, '600元/天')
|
||||
assert.equal(estimatedPreview.fields.subsidyDailyCap, '120元/天')
|
||||
assert.match(estimatedPreview.fields.transportPolicy, /实报实销/)
|
||||
assert.match(estimatedPreview.fields.policyEstimate, /2,160元/)
|
||||
assert.equal(buildApplicationPreviewRows(estimatedPreview).find((row) => row.key === 'policyEstimate')?.highlight, true)
|
||||
})
|
||||
Reference in New Issue
Block a user