feat: 重构报销单AI预审流程并添加平台风险规则引擎
- 将AI验审改为AI预审,高风险不再拦截而是随单流转给审批人复核 - 新增平台风险规则评估引擎,支持事由过短、票据异常、重复发票等多种评估器 - 用户上下文增加部门信息(department_name),认证流程同步关联组织架构 - 规则scenario_json改为中文标签(差旅/费用科目),统一场景分类 - 新增orchestrator审核流程测试用例 - 前端更新审计视图、差旅报销等相关页面
This commit is contained in:
@@ -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', '切换资产类型后会自动重新拉取数据']
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user