feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造
- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制 - 引入费用审批动态路由、平台风险分级、预审与风险阶段管理 - 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板 - 新增 Hermes 风险线索收集器、Agent 链路追踪中心 - 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估 - 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
@@ -17,12 +17,18 @@ import {
|
||||
normalizeApplicationPreview,
|
||||
shouldUseLocalApplicationPreview
|
||||
} from '../src/utils/expenseApplicationPreview.js'
|
||||
import {
|
||||
buildMockApplicationTransportEstimate,
|
||||
resolveMockApplicationTransportWaitMs,
|
||||
buildSystemApplicationEstimate
|
||||
} from '../src/utils/expenseApplicationEstimate.js'
|
||||
import { renderMarkdown } from '../src/utils/markdown.js'
|
||||
import {
|
||||
createMessage as createConversationMessage,
|
||||
hasMeaningfulSessionMessages
|
||||
} from '../src/views/scripts/travelReimbursementConversationModel.js'
|
||||
import { useTravelReimbursementFlow } from '../src/views/scripts/useTravelReimbursementFlow.js'
|
||||
import { useApplicationPreviewEditor } from '../src/views/scripts/useApplicationPreviewEditor.js'
|
||||
|
||||
const submitComposerScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
||||
@@ -110,14 +116,24 @@ test('application intent uses local preview instead of immediate orchestrator ca
|
||||
false
|
||||
)
|
||||
|
||||
const preview = buildLocalApplicationPreview(prompt, { name: '李文静', departmentName: '财务部', grade: 'P5' })
|
||||
const preview = buildLocalApplicationPreview(prompt, {
|
||||
name: '李文静',
|
||||
departmentName: '财务部',
|
||||
position: '财务分析师',
|
||||
managerName: '王强',
|
||||
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.applicant, '李文静')
|
||||
assert.equal(preview.fields.grade, 'P5')
|
||||
assert.equal(preview.fields.department, '财务部')
|
||||
assert.equal(preview.fields.position, '财务分析师')
|
||||
assert.equal(preview.fields.managerName, '王强')
|
||||
assert.equal(preview.readyToSubmit, true)
|
||||
assert.doesNotMatch(buildLocalApplicationPreviewMessage(preview), /#application-submit/)
|
||||
assert.match(buildApplicationPreviewFooterMessage(preview), /#application-submit/)
|
||||
@@ -127,6 +143,9 @@ test('application intent uses local preview instead of immediate orchestrator ca
|
||||
test('application preview renders ordered editable rows and submit text uses edited values', () => {
|
||||
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去新疆,伊犁出差,服务美团业务部署,火车,预计费用1800元', {
|
||||
name: '李文静',
|
||||
departmentName: '财务部',
|
||||
position: '财务分析师',
|
||||
managerName: '王强',
|
||||
grade: 'P5'
|
||||
})
|
||||
assert.equal(preview.fields.location, '新疆,伊犁')
|
||||
@@ -143,14 +162,62 @@ test('application preview renders ordered editable rows and submit text uses edi
|
||||
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 === 'amount')?.editable, false)
|
||||
assert.equal(rows.find((row) => row.key === 'applicant')?.editable, false)
|
||||
assert.equal(rows.find((row) => row.key === 'grade')?.editable, false)
|
||||
assert.equal(rows.find((row) => row.key === 'department')?.editable, false)
|
||||
assert.equal(rows.find((row) => row.key === 'position')?.editable, false)
|
||||
assert.equal(rows.find((row) => row.key === 'managerName')?.editable, false)
|
||||
assert.equal(rows.find((row) => row.key === 'lodgingDailyCap')?.editable, false)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /姓名:李文静/)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /部门:财务部/)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /岗位:财务分析师/)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /直属领导:王强/)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /事由:客户现场项目支持/)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /用户预估费用:1900元/)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /系统预估费用:1900元/)
|
||||
})
|
||||
|
||||
test('application estimate builds deterministic mock transport amount and total', () => {
|
||||
const trainEstimate = buildMockApplicationTransportEstimate({ transportMode: '高铁', location: '上海' })
|
||||
const datedTrainEstimate = buildMockApplicationTransportEstimate({
|
||||
transportMode: '高铁',
|
||||
location: '上海',
|
||||
time: '2026-05-25 至 2026-05-28'
|
||||
})
|
||||
const flightEstimate = buildMockApplicationTransportEstimate({ transportMode: '机票', location: '新疆,伊犁' })
|
||||
const shipEstimate = buildMockApplicationTransportEstimate({ transportMode: '船票', location: '厦门' })
|
||||
const totalEstimate = buildSystemApplicationEstimate({
|
||||
transportMode: '火车',
|
||||
location: '上海',
|
||||
lodgingAmount: 1800,
|
||||
allowanceAmount: 360
|
||||
})
|
||||
const datedTotalEstimate = buildSystemApplicationEstimate({
|
||||
transportMode: '火车',
|
||||
location: '上海',
|
||||
time: '2026-05-25 至 2026-05-28',
|
||||
lodgingAmount: 1800,
|
||||
allowanceAmount: 360
|
||||
})
|
||||
|
||||
assert.equal(trainEstimate.amountDisplay, '1,040')
|
||||
assert.equal(datedTrainEstimate.queryDate, '2026-05-25')
|
||||
assert.equal(datedTrainEstimate.amountDisplay, '1,100')
|
||||
assert.equal(datedTrainEstimate.source, 'mock_ticket_price_query_v1')
|
||||
assert.match(datedTrainEstimate.basisText, /查询耗时 \d+ms/)
|
||||
assert.ok(datedTrainEstimate.simulatedLatencyMs >= 360)
|
||||
assert.ok(datedTrainEstimate.simulatedLatencyMs <= 779)
|
||||
assert.equal(resolveMockApplicationTransportWaitMs(datedTrainEstimate), 320)
|
||||
assert.equal(flightEstimate.amountDisplay, '3,600')
|
||||
assert.equal(shipEstimate.amountDisplay, '1,040')
|
||||
assert.equal(totalEstimate.transportAmountDisplay, '1,040')
|
||||
assert.equal(totalEstimate.totalAmountDisplay, '3,200')
|
||||
assert.equal(datedTotalEstimate.transportAmountDisplay, '1,100')
|
||||
assert.equal(datedTotalEstimate.totalAmountDisplay, '3,260')
|
||||
})
|
||||
|
||||
test('application preview cleans empty time labels and keeps only business reason', () => {
|
||||
@@ -258,6 +325,8 @@ test('application quick start renders a template without model review', () => {
|
||||
const preview = buildApplicationTemplatePreview({
|
||||
name: '李文静',
|
||||
departmentName: '财务部',
|
||||
position: '财务分析师',
|
||||
managerName: '王强',
|
||||
grade: 'P5'
|
||||
})
|
||||
const message = buildLocalApplicationPreviewMessage(preview)
|
||||
@@ -266,6 +335,8 @@ test('application quick start renders a template without model review', () => {
|
||||
assert.equal(preview.fields.applicationType, '费用申请')
|
||||
assert.equal(preview.fields.applicant, '李文静')
|
||||
assert.equal(preview.fields.department, '财务部')
|
||||
assert.equal(preview.fields.position, '财务分析师')
|
||||
assert.equal(preview.fields.managerName, '王强')
|
||||
assert.equal(preview.fields.grade, 'P5')
|
||||
assert.equal(buildApplicationPreviewRows(preview).find((row) => row.key === 'grade')?.editable, false)
|
||||
assert.match(message, /不调用大模型/)
|
||||
@@ -389,7 +460,9 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(messageItemStyles, /\.application-preview-missing-chip \{[\s\S]*background: rgba\(var\(--theme-primary-rgb/)
|
||||
assert.doesNotMatch(applicationMessageStyles, /\.application-date-editor-layer/)
|
||||
assert.match(applicationMessageStyles, /\.application-draft-preview \.application-draft-head \{[\s\S]*grid-template-columns: 36px minmax\(0, 1fr\) auto;/)
|
||||
assert.match(applicationMessageStyles, /\.application-draft-brief \{[\s\S]*border: 1px solid #d7e4f2;/)
|
||||
assert.match(applicationMessageStyles, /\.application-draft-brief \{[\s\S]*gap: 1px;[\s\S]*border: 1px solid #d7e4f2;[\s\S]*background: #d7e4f2;/)
|
||||
assert.match(applicationMessageStyles, /\.application-draft-brief-item \{[\s\S]*border: 0;[\s\S]*background: #ffffff;/)
|
||||
assert.doesNotMatch(applicationMessageStyles, /\.application-draft-brief-item:nth-child\(even\)/)
|
||||
assert.match(applicationMessageStyles, /\.application-draft-brief-item\.is-primary \{[\s\S]*grid-column: 1 \/ -1;/)
|
||||
|
||||
assert.match(flowScript, /application-submit-success/)
|
||||
@@ -490,7 +563,64 @@ test('application preview merges rule center travel estimate into highlighted ro
|
||||
|
||||
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.match(estimatedPreview.fields.transportPolicy, /参考票价/)
|
||||
assert.match(estimatedPreview.fields.transportPolicy, /2026-05-25/)
|
||||
assert.match(estimatedPreview.fields.transportPolicy, /查询耗时 \d+ms/)
|
||||
assert.match(estimatedPreview.fields.policyEstimate, /交通 1,100元/)
|
||||
assert.match(estimatedPreview.fields.policyEstimate, /3,260元/)
|
||||
assert.equal(estimatedPreview.fields.transportEstimatedAmount, '1,100元')
|
||||
assert.equal(estimatedPreview.fields.transportEstimateDate, '2026-05-25')
|
||||
assert.match(estimatedPreview.fields.transportQueryLatencyMs, /^\d+ms$/)
|
||||
assert.equal(estimatedPreview.fields.amount, '3,260元')
|
||||
assert.equal(buildApplicationPreviewRows(estimatedPreview).find((row) => row.key === 'policyEstimate')?.highlight, true)
|
||||
})
|
||||
|
||||
test('application preview editor refreshes transport estimate after mode change', async () => {
|
||||
const preview = applyApplicationPolicyEstimateResult(
|
||||
buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-27 去上海出差3天,服务项目部署', {
|
||||
name: '李文静',
|
||||
grade: 'P5'
|
||||
}),
|
||||
{
|
||||
days: 3,
|
||||
location: '上海',
|
||||
matched_city: '上海',
|
||||
grade: 'P5',
|
||||
hotel_rate: 600,
|
||||
hotel_amount: 1800,
|
||||
total_allowance_rate: 120,
|
||||
allowance_amount: 360,
|
||||
total_amount: 2160
|
||||
},
|
||||
{ grade: 'P5' }
|
||||
)
|
||||
const message = {
|
||||
id: 'application-preview-editor-message',
|
||||
applicationPreview: preview,
|
||||
text: ''
|
||||
}
|
||||
let persistCount = 0
|
||||
const toastMessages = []
|
||||
const editor = useApplicationPreviewEditor({
|
||||
persistSessionState: () => {
|
||||
persistCount += 1
|
||||
},
|
||||
toast: (messageText) => {
|
||||
toastMessages.push(messageText)
|
||||
}
|
||||
})
|
||||
|
||||
editor.openApplicationPreviewEditor(message, 'transportMode', '待补充')
|
||||
editor.applicationPreviewEditor.value.draftValue = '飞机'
|
||||
const committed = await editor.commitApplicationPreviewEditor(message)
|
||||
|
||||
assert.equal(committed, true)
|
||||
assert.equal(message.applicationPreview.fields.transportMode, '飞机')
|
||||
assert.equal(message.applicationPreview.fields.transportEstimatedAmount, '2,330元')
|
||||
assert.equal(message.applicationPreview.fields.amount, '4,490元')
|
||||
assert.match(message.applicationPreview.fields.transportPolicy, /已查询 2026-05-25 飞机参考票价/)
|
||||
assert.match(message.applicationPreview.fields.transportPolicy, /查询耗时 \d+ms/)
|
||||
assert.doesNotMatch(message.applicationPreview.fields.transportPolicy, /模拟/)
|
||||
assert.ok(persistCount >= 2)
|
||||
assert.equal(toastMessages.at(-1), '已更新出行方式和费用测算。')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user