feat: 新增风险规则生成引擎与知识图谱可视化
后端新增风险规则自动生成和模板执行服务,支持从规则资产 批量生成并持久化风险规则文件;知识库入库日志增强图谱 查询和本地 RAG 回退,前端审计页面增加风险规则模型和流 程图组件,知识入库面板拆分为图谱可视化子组件,报销创 建页面增加引导式流程模型,更新知识库索引数据。
This commit is contained in:
@@ -18,10 +18,12 @@ const sampleRows = [
|
||||
id: 'EXP-001',
|
||||
typeCode: 'travel',
|
||||
type: '差旅费',
|
||||
archiveTypeCode: 'reimbursement',
|
||||
archiveType: '报销',
|
||||
department: '研发部',
|
||||
archiveMonth: '2026-05',
|
||||
archiveMonthLabel: '2026年05月',
|
||||
archiveTab: '差旅报销',
|
||||
archiveTab: '报销归档',
|
||||
hasRisk: true,
|
||||
riskTone: 'high',
|
||||
risk: '2条',
|
||||
@@ -31,10 +33,12 @@ const sampleRows = [
|
||||
id: 'EXP-002',
|
||||
typeCode: 'entertainment',
|
||||
type: '业务招待费',
|
||||
archiveTypeCode: 'reimbursement',
|
||||
archiveType: '报销',
|
||||
department: '销售部',
|
||||
archiveMonth: '2026-04',
|
||||
archiveMonthLabel: '2026年04月',
|
||||
archiveTab: '招待报销',
|
||||
archiveTab: '报销归档',
|
||||
hasRisk: false,
|
||||
riskTone: 'none',
|
||||
risk: '0条',
|
||||
@@ -80,14 +84,27 @@ test('applyArchiveListFilters supports department and archive month', () => {
|
||||
assert.equal(filtered[0].id, 'EXP-002')
|
||||
})
|
||||
|
||||
test('build filter options are derived from loaded rows', () => {
|
||||
test('applyArchiveListFilters supports the unified reimbursement archive tab', () => {
|
||||
const reimbursementRows = applyArchiveListFilters(sampleRows, {
|
||||
tab: '报销归档'
|
||||
})
|
||||
const oldTravelRows = applyArchiveListFilters(sampleRows, {
|
||||
tab: '差旅报销'
|
||||
})
|
||||
|
||||
assert.equal(reimbursementRows.length, 2)
|
||||
assert.equal(oldTravelRows.length, 0)
|
||||
})
|
||||
|
||||
test('build filter options are derived from archive types', () => {
|
||||
const typeLabels = buildTypeFilterOptions(sampleRows).map((item) => item.label)
|
||||
const typeValues = buildTypeFilterOptions(sampleRows).map((item) => item.value)
|
||||
const departmentLabels = buildDepartmentFilterOptions(sampleRows).map((item) => item.label)
|
||||
const monthOptions = buildArchiveMonthFilterOptions(sampleRows)
|
||||
|
||||
assert.equal(typeLabels[0], '全部类型')
|
||||
assert.ok(typeLabels.includes('差旅费'))
|
||||
assert.ok(typeLabels.includes('业务招待费'))
|
||||
assert.equal(typeLabels[0], '全部归档类型')
|
||||
assert.deepEqual(typeValues, ['all', 'reimbursement'])
|
||||
assert.deepEqual(typeLabels, ['全部归档类型', '报销'])
|
||||
assert.equal(departmentLabels[0], '全部部门')
|
||||
assert.ok(departmentLabels.includes('研发部'))
|
||||
assert.ok(departmentLabels.includes('销售部'))
|
||||
|
||||
@@ -32,3 +32,26 @@ test('archive center is wired into navigation and api client', () => {
|
||||
assert.match(navigationScript, /id:\s*'archive'/)
|
||||
assert.match(reimbursementsService, /\/reimbursements\/claims\/archives/)
|
||||
})
|
||||
|
||||
test('archive center uses generic archive category and type wording', () => {
|
||||
const archiveView = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/ArchiveCenterView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const archiveScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/ArchiveCenterView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
assert.match(archiveScript, /const tabs = \[ARCHIVE_TAB_ALL, ARCHIVE_TAB_REIMBURSEMENT\]/)
|
||||
assert.match(archiveScript, /const ARCHIVE_TAB_REIMBURSEMENT = '报销归档'/)
|
||||
assert.match(archiveScript, /archiveType:\s*ARCHIVE_TYPE_REIMBURSEMENT/)
|
||||
assert.match(archiveScript, /archiveTypeCode:\s*ARCHIVE_TYPE_REIMBURSEMENT_CODE/)
|
||||
assert.doesNotMatch(archiveScript, /'差旅报销'/)
|
||||
assert.doesNotMatch(archiveScript, /'招待报销'/)
|
||||
assert.doesNotMatch(archiveScript, /'其他费用'/)
|
||||
assert.match(archiveView, /placeholder="搜索单号、申请人、部门、归档类型\.\.\."/)
|
||||
assert.match(archiveView, /<th>归档类型<\/th>/)
|
||||
assert.match(archiveView, /\{\{\s*row\.archiveType\s*\}\}/)
|
||||
assert.doesNotMatch(archiveView, /<th>报销类型<\/th>/)
|
||||
})
|
||||
|
||||
@@ -26,8 +26,26 @@ function buildRun() {
|
||||
chunk_count: 5,
|
||||
entity_count: 3,
|
||||
relation_count: 2,
|
||||
entities: ['远光软件', '支出管理'],
|
||||
relations: [{ source: '远光软件', target: '支出管理', type: '关联' }]
|
||||
entities: [
|
||||
{
|
||||
name: '远光软件',
|
||||
type: 'ORGANIZATION',
|
||||
description: '远光软件是支出管理制度的公司主体。',
|
||||
descriptions: ['远光软件是支出管理制度的公司主体。'],
|
||||
properties: { created_at: '2026-05-23' }
|
||||
},
|
||||
'支出管理'
|
||||
],
|
||||
relations: [
|
||||
{
|
||||
source: '远光软件',
|
||||
target: '支出管理',
|
||||
type: '约束',
|
||||
description: '通过制度约束支出审批。',
|
||||
keywords: ['制度', '审批'],
|
||||
weight: 2.5
|
||||
}
|
||||
]
|
||||
},
|
||||
documents: [
|
||||
{
|
||||
@@ -40,7 +58,8 @@ function buildRun() {
|
||||
chunk_count: 3,
|
||||
entity_count: 2,
|
||||
relation_count: 1,
|
||||
chunks: [{ id: 'chunk-1', order: 0, tokens: 21, summary: '支出管理范围' }],
|
||||
chunks: [{ id: 'chunk-1', order: 0, tokens: 21, summary: '支出管理范围', excerpt: '支出管理范围正文' }],
|
||||
entity_chunks: [{ entity: '支出管理', chunk_ids: ['chunk-1'] }],
|
||||
sections: [{ title: '第一章 总则', excerpt: '适用于公司支出。' }],
|
||||
events: [{ at: '2026-05-22T08:00:00Z', level: 'info', message: '完成' }]
|
||||
},
|
||||
@@ -75,8 +94,18 @@ function testBuildsInteractiveModel() {
|
||||
assert.equal(model.documents.length, 2)
|
||||
assert.equal(model.documents[0].statusLabel, '已完成')
|
||||
assert.equal(model.documents[0].chunks[0].summary, '支出管理范围')
|
||||
assert.equal(model.documents[0].chunks[0].excerpt, '支出管理范围正文')
|
||||
assert.deepEqual(model.documents[0].entityChunks, [{ entity: '支出管理', chunkIds: ['chunk-1'] }])
|
||||
assert.deepEqual(model.documents[1].chunks, [])
|
||||
assert.deepEqual(model.documents[1].sections, [])
|
||||
assert.deepEqual(model.documents[1].events, [])
|
||||
assert.equal(model.graph.entityCount, 3)
|
||||
assert.equal(model.graph.entities[0].name, '远光软件')
|
||||
assert.equal(model.graph.entities[0].type, 'ORGANIZATION')
|
||||
assert.equal(model.graph.entities[0].descriptions[0], '远光软件是支出管理制度的公司主体。')
|
||||
assert.equal(model.graph.relations[0].source, '远光软件')
|
||||
assert.equal(model.graph.relations[0].description, '通过制度约束支出审批。')
|
||||
assert.deepEqual(model.graph.relations[0].keywords, ['制度', '审批'])
|
||||
assert.equal(model.metrics[1].value, '5')
|
||||
}
|
||||
|
||||
|
||||
@@ -38,3 +38,8 @@ test('composer keeps backend raw text but displays structured user message', ()
|
||||
assert.match(submitComposerScript, /const rawText = resolveComposerSubmitText\(options\.rawText\)\.trim\(\)/)
|
||||
assert.match(submitComposerScript, /resolveComposerDisplaySubmitText\(rawText\)/)
|
||||
})
|
||||
|
||||
test('knowledge questions keep enough request time for LightRAG retrieval', () => {
|
||||
assert.match(submitComposerScript, /timeoutMs:\s*75000/)
|
||||
assert.match(submitComposerScript, /知识问答仍在检索整理/)
|
||||
})
|
||||
|
||||
189
web/tests/travel-reimbursement-guided-flow.test.mjs
Normal file
189
web/tests/travel-reimbursement-guided-flow.test.mjs
Normal file
@@ -0,0 +1,189 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
EXPENSE_WELCOME_QUICK_ACTIONS,
|
||||
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'
|
||||
|
||||
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'
|
||||
)
|
||||
|
||||
test('welcome quick actions are reduced to three guided local actions', () => {
|
||||
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.notDeepEqual(
|
||||
buildWelcomeQuickActions(SESSION_TYPE_KNOWLEDGE).map((item) => item.label),
|
||||
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.label),
|
||||
'knowledge hot questions should stay independent'
|
||||
)
|
||||
})
|
||||
|
||||
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\(/)
|
||||
})
|
||||
|
||||
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(guidedFlowScript, /submitExistingComposer\(submitOptions\)/)
|
||||
assert.match(guidedFlowScript, /submitExistingComposer\(\{[\s\S]*pendingText:\s*'正在查询单据状态\.\.\.'/)
|
||||
})
|
||||
Reference in New Issue
Block a user