feat: 重构报销单AI预审流程并添加平台风险规则引擎

- 将AI验审改为AI预审,高风险不再拦截而是随单流转给审批人复核
- 新增平台风险规则评估引擎,支持事由过短、票据异常、重复发票等多种评估器
- 用户上下文增加部门信息(department_name),认证流程同步关联组织架构
- 规则scenario_json改为中文标签(差旅/费用科目),统一场景分类
- 新增orchestrator审核流程测试用例
- 前端更新审计视图、差旅报销等相关页面
This commit is contained in:
caoxiaozhu
2026-05-20 09:36:01 +08:00
parent 2574bc81d1
commit 57957d11a0
23 changed files with 2109 additions and 553 deletions

View File

@@ -303,6 +303,8 @@ const RISK_SCENARIO_OPTIONS = [
{ value: '通用', label: '通用' }
]
const RISK_SCENARIO_VALUES = new Set(RISK_SCENARIO_OPTIONS.map((item) => item.value).filter(Boolean))
const LEGACY_RISK_SCENARIO_KEYS = new Set([
'expense',
'risk_check',
@@ -313,7 +315,10 @@ const LEGACY_RISK_SCENARIO_KEYS = new Set([
'travel_standard',
'attachment_policy',
'scene_policy',
'invoice_anomaly'
'invoice_anomaly',
'communication_expense',
'expense_standard',
'approval_required'
])
const SPREADSHEET_DETAIL_MODE = 'spreadsheet'
@@ -409,7 +414,7 @@ function createPreviewRuleDetailPayload() {
name: '公司差旅费报销规则',
description: '前端预览态:先展示 Excel 规则详情页布局、版本卡片和编辑入口位置。',
domain: 'expense',
scenario_json: ['expense', 'travel_policy', 'travel_standard'],
scenario_json: ['差旅'],
owner: '财务制度管理组',
reviewer: '顾承宇',
status: 'active',
@@ -422,6 +427,8 @@ function createPreviewRuleDetailPayload() {
tag: '财务规则',
detail_mode: 'spreadsheet',
runtime_kind: 'travel_policy',
scenario_category: '差旅',
ai_review_category: '差旅',
rule_template_label: '差旅报销 Excel 模板',
rule_document: {
...currentMeta,
@@ -588,26 +595,37 @@ function inferRiskCategoryFromCode(code) {
return '通用'
}
function normalizeRiskScenarioCategory(value) {
const normalized = normalizeText(value)
return RISK_SCENARIO_VALUES.has(normalized) ? normalized : ''
}
function readScenarioItems(source) {
if (Array.isArray(source?.scenario_json)) {
return source.scenario_json
}
if (Array.isArray(source?.scenarioList)) {
return source.scenarioList
}
return []
}
function resolveRiskRuleCategory(source) {
const configJson = readConfigJson(source)
const explicit = normalizeText(configJson.risk_category)
const explicit = normalizeRiskScenarioCategory(configJson.risk_category)
if (explicit) {
return explicit
}
const payloadCategory = normalizeText(source?.risk_category)
const payloadCategory = normalizeRiskScenarioCategory(source?.risk_category)
if (payloadCategory) {
return payloadCategory
}
const scenarioItems = Array.isArray(source?.scenario_json)
? source.scenario_json
: Array.isArray(source?.scenarioList)
? source.scenarioList
: []
const scenarioItems = readScenarioItems(source)
const businessScenario = scenarioItems
.map((item) => normalizeText(item))
.find((item) => item && !LEGACY_RISK_SCENARIO_KEYS.has(item))
.find((item) => item && !LEGACY_RISK_SCENARIO_KEYS.has(item) && RISK_SCENARIO_VALUES.has(item))
if (businessScenario) {
return businessScenario
}
@@ -615,6 +633,75 @@ function resolveRiskRuleCategory(source) {
return inferRiskCategoryFromCode(source?.code)
}
function inferFinancialRuleCategory(source) {
const configJson = readConfigJson(source)
const explicit =
normalizeRiskScenarioCategory(configJson.scenario_category) ||
normalizeRiskScenarioCategory(configJson.ai_review_category) ||
normalizeRiskScenarioCategory(configJson.risk_category) ||
normalizeRiskScenarioCategory(source?.scenario_category) ||
normalizeRiskScenarioCategory(source?.risk_category)
if (explicit) {
return explicit
}
const scenarioCategory = readScenarioItems(source)
.map((item) => normalizeRiskScenarioCategory(item))
.find(Boolean)
if (scenarioCategory) {
return scenarioCategory
}
const configRuntimeRule = isPlainObject(configJson.runtime_rule) ? configJson.runtime_rule : {}
const haystack = [
source?.code,
source?.name,
source?.description,
configJson.runtime_kind,
configRuntimeRule.kind,
configRuntimeRule.scenario,
configRuntimeRule.template_key,
...readScenarioItems(source)
]
.map((item) => normalizeText(item).toLowerCase())
.filter(Boolean)
.join(' ')
if (!haystack) {
return '通用'
}
if (/(travel|trip|差旅|出差|住宿|酒店)/i.test(haystack)) {
return '差旅'
}
if (/(invoice|receipt|attachment|票据|发票|单据|附件)/i.test(haystack)) {
return '发票'
}
if (/(meal|dining|entertainment|餐饮|招待|餐费|用餐)/i.test(haystack)) {
return '餐饮招待'
}
if (/(transport|traffic|taxi|交通|出行|打车|机票|火车|高铁|地铁|公交)/i.test(haystack)) {
return '交通出行'
}
if (/(office|material|suppl|办公|物料|耗材)/i.test(haystack)) {
return '办公物料'
}
if (/(communication|telecom|phone|expense_standard|费用科目|费用标准|通信|通讯|手机|补贴|福利|科目)/i.test(haystack)) {
return '费用科目'
}
return '通用'
}
function resolveRuleScenarioCategory(source, tabId = '') {
const resolvedTabId = tabId || resolveRuleTabId(source)
if (resolvedTabId === 'riskRules' || isJsonRiskRuleSource(source)) {
return resolveRiskRuleCategory(source)
}
if (resolvedTabId === 'financialRules') {
return inferFinancialRuleCategory(source)
}
return ''
}
function buildRiskListSubtitle(text, maxLength = 42) {
const normalized = normalizeText(text)
if (!normalized) {
@@ -1006,7 +1093,7 @@ function buildListItem(asset) {
const usesSpreadsheetRule = typeKey === 'rules' && isSpreadsheetRuleSource(asset)
const usesJsonRiskRule = typeKey === 'rules' && isJsonRiskRuleSource(asset)
const ruleDocument = readRuleDocumentMeta(asset)
const riskCategory = isRiskRule ? resolveRiskRuleCategory(asset) : ''
const ruleScenarioCategory = typeKey === 'rules' ? resolveRuleScenarioCategory(asset, tabId) : ''
const listSubtitle = isRiskRule
? buildRiskListSubtitle(asset.description)
: normalizeText(asset.description)
@@ -1028,8 +1115,8 @@ function buildListItem(asset) {
category: resolveDomainLabel(asset.domain),
owner: asset.owner,
reviewer: asset.reviewer || '待分配',
scope: isRiskRule ? riskCategory || '通用' : formatScenarioList(asset.scenario_json),
riskCategory,
scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(asset.scenario_json),
riskCategory: ruleScenarioCategory,
model: buildRowRuntime(asset, typeKey),
version: workingVersion,
versionDisplay: typeKey === 'rules' ? `${changeCount}` : workingVersion,
@@ -1050,6 +1137,7 @@ function buildListItem(asset) {
function buildRuleFields(detail) {
const ruleDocument = readRuleDocumentMeta(detail)
const ruleScenarioCategory = resolveRuleScenarioCategory(detail)
return [
{ label: '规则编码', value: detail.code },
{
@@ -1073,7 +1161,7 @@ function buildRuleFields(detail) {
label: '运行时类型',
value: normalizeText(detail.config_json?.runtime_kind) || 'policy_rule_draft'
},
{ label: '适用场景', value: formatScenarioList(detail.scenario_json) },
{ label: '适用场景', value: ruleScenarioCategory || '通用' },
{ label: '线上版本', value: detail.published_version || '-' },
{ label: '工作版本', value: detail.working_version || detail.current_version || '-' }
]
@@ -1417,6 +1505,7 @@ function buildDetailViewModel(detail, runs) {
const ruleTemplateKey = normalizeText(configJson.rule_template_key || previewRuntimeRule.template_key)
const ruleTemplateLabel = normalizeText(configJson.rule_template_label) || resolveRuleTemplateLabel(ruleTemplateKey)
const runtimeKind = normalizeText(configJson.runtime_kind || previewRuntimeRule.kind) || 'policy_rule_draft'
const ruleScenarioCategory = typeKey === 'rules' ? resolveRuleScenarioCategory(detail, tabId) : ''
return {
id: detail.id,
@@ -1431,7 +1520,7 @@ function buildDetailViewModel(detail, runs) {
owner: detail.owner,
reviewer: detail.reviewer || detail.latest_review?.reviewer || '待分配',
category: resolveDomainLabel(detail.domain),
scope: usesJsonRiskRule ? resolveRiskRuleCategory(detail) || '通用' : formatScenarioList(detail.scenario_json),
scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(detail.scenario_json),
version: detail.working_version || detail.current_version || '-',
currentVersion: detail.current_version || '-',
publishedVersion: detail.published_version || '-',
@@ -1451,9 +1540,13 @@ function buildDetailViewModel(detail, runs) {
riskRuleDescription: '',
riskRuleSubtitle: usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : '',
riskRuleSourceRef: '',
riskCategory: usesJsonRiskRule ? resolveRiskRuleCategory(detail) : '',
riskCategory: typeKey === 'rules' ? ruleScenarioCategory : '',
ruleDocument,
scenarioList: Array.isArray(detail.scenario_json) ? [...detail.scenario_json] : [],
scenarioList: typeKey === 'rules' && ruleScenarioCategory
? [ruleScenarioCategory]
: Array.isArray(detail.scenario_json)
? [...detail.scenario_json]
: [],
markdownContent: previewMarkdown,
runtimeRuleText: stringifyRuntimeRule(previewRuntimeRule),
ruleTemplateKey,
@@ -1474,7 +1567,12 @@ function buildDetailViewModel(detail, runs) {
typeKey === 'rules' ? [] : buildPromptSections(detail, typeKey, latestRun, latestCall),
outputRules: buildOutputRules(detail, typeKey, latestRun, latestCall),
tests: buildTests(detail, typeKey, latestRun, latestCall),
triggers: detail.scenario_json?.length ? detail.scenario_json.map((item) => SCENARIO_LABELS[item] || item) : ['未配置场景'],
triggers:
typeKey === 'rules'
? [ruleScenarioCategory || '通用']
: detail.scenario_json?.length
? detail.scenario_json.map((item) => SCENARIO_LABELS[item] || item)
: ['未配置场景'],
tools:
typeKey === 'rules'
? [
@@ -1769,7 +1867,9 @@ export default {
const selectedStatusLabel = computed(
() => STATUS_OPTIONS.find((item) => item.value === selectedStatus.value)?.label || '状态'
)
const showRiskScenarioFilter = computed(() => activeType.value === 'riskRules')
const showRiskScenarioFilter = computed(() =>
['financialRules', 'riskRules'].includes(activeType.value)
)
const showStatusFilter = computed(() => activeType.value !== 'riskRules')
const selectedRiskScenarioLabel = computed(
() =>
@@ -1799,6 +1899,13 @@ export default {
})
const auditEmptyState = computed(() => {
const hasFilters = activeFilterTokens.value.length > 0
const supportedFilters = [
'业务域',
'负责人',
...(showRiskScenarioFilter.value ? ['使用场景'] : []),
...(showStatusFilter.value ? ['状态'] : []),
'关键词'
]
if (!currentAssets.value.length) {
return {
@@ -1810,10 +1917,10 @@ export default {
actionIcon: '',
tone: 'amber',
artLabel: 'ASSET',
tips:
activeType.value === 'riskRules'
? ['切换页签可查看其他资产类型', '支持按业务域、负责人和使用场景做过滤']
: ['切换页签可查看其他资产类型', '支持按业务域、负责人和状态做过滤']
tips: [
'切换页签可查看其他资产类型',
`支持按${supportedFilters.slice(0, -1).join('、')}和关键词做过滤`
]
}
}
@@ -1821,9 +1928,7 @@ export default {
eyebrow: '筛选结果为空',
title: `没有找到匹配的${activeTabLabel.value}`,
desc: hasFilters
? showRiskScenarioFilter.value
? '试试清空业务域、负责人、使用场景或关键词筛选,再重新查看。'
: '试试清空业务域、负责人、状态或关键词筛选,再重新查看。'
? `试试清空${supportedFilters.join('、')}筛选,再重新查看。`
: `当前列表中还没有满足展示条件的${activeTabLabel.value}资产。`,
icon: hasFilters ? 'mdi mdi-tune-variant' : 'mdi mdi-view-grid-outline',
actionLabel: hasFilters ? '清空筛选' : '',
@@ -1831,9 +1936,12 @@ export default {
tone: hasFilters ? 'emerald' : 'slate',
artLabel: hasFilters ? 'FILTER' : 'QUEUE',
tips: hasFilters
? showRiskScenarioFilter.value
? ['业务域、负责人、使用场景与关键词会叠加过滤', '可以换个规则名称或场景分类继续搜索']
: ['业务域、负责人、状态与关键词会叠加过滤', '可以换个编码、名称或负责人关键词继续搜索']
? [
`${supportedFilters.join('、')}会叠加过滤`,
showRiskScenarioFilter.value
? '可以换个规则名称或场景分类继续搜索'
: '可以换个编码、名称或负责人关键词继续搜索'
]
: ['列表展示来自真实资产 API', '切换资产类型后会自动重新拉取数据']
}
})