- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制 - 引入费用审批动态路由、平台风险分级、预审与风险阶段管理 - 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板 - 新增 Hermes 风险线索收集器、Agent 链路追踪中心 - 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估 - 完善报销申请快速预览、权限控制与前端测试覆盖
334 lines
9.5 KiB
JavaScript
334 lines
9.5 KiB
JavaScript
import {
|
||
JSON_RISK_DETAIL_MODE,
|
||
LEGACY_RISK_SCENARIO_KEYS,
|
||
RISK_SCENARIO_VALUES,
|
||
RULE_TAB_TAG_ALIASES,
|
||
SPREADSHEET_DETAIL_MODE,
|
||
TAB_META,
|
||
TYPE_META
|
||
} from './auditViewMetadata.js'
|
||
import {
|
||
isPlainObject,
|
||
normalizeText,
|
||
readConfigJson
|
||
} from './auditViewDataUtils.js'
|
||
import { formatScenarioList } from './auditViewFormatters.js'
|
||
const EXPENSE_TYPE_SCENARIO_LABELS = {
|
||
all: '全部',
|
||
travel: '差旅费',
|
||
hotel: '住宿费',
|
||
transport: '交通费',
|
||
meal: '业务招待费',
|
||
meeting: '会务费',
|
||
marketing: '市场推广费',
|
||
office: '办公用品费',
|
||
training: '培训费',
|
||
software: '软件服务费',
|
||
communication: '通信费',
|
||
welfare: '福利费'
|
||
}
|
||
|
||
export function readRuleDocumentMeta(value) {
|
||
const configJson = readConfigJson(value)
|
||
return isPlainObject(configJson.rule_document) ? configJson.rule_document : null
|
||
}
|
||
|
||
export function isSpreadsheetRuleSource(value) {
|
||
const configJson = readConfigJson(value)
|
||
return normalizeText(configJson.detail_mode || configJson.rule_detail_mode).toLowerCase() === SPREADSHEET_DETAIL_MODE
|
||
}
|
||
|
||
export function isJsonRiskRuleSource(value) {
|
||
const configJson = readConfigJson(value)
|
||
return normalizeText(configJson.detail_mode || configJson.rule_detail_mode).toLowerCase() === JSON_RISK_DETAIL_MODE
|
||
}
|
||
|
||
export function normalizeRuleTagValue(value) {
|
||
return normalizeText(value).toLowerCase().replace(/[\s_-]+/g, '')
|
||
}
|
||
|
||
export function collectRuleTagValues(source) {
|
||
const configJson = readConfigJson(source)
|
||
const rawValues = [
|
||
configJson.tag,
|
||
configJson.rule_tag,
|
||
...(Array.isArray(configJson.tags) ? configJson.tags : []),
|
||
...(Array.isArray(configJson.rule_tags) ? configJson.rule_tags : [])
|
||
]
|
||
|
||
return rawValues.map((item) => normalizeText(item)).filter(Boolean)
|
||
}
|
||
|
||
export function resolveRuleTabId(source) {
|
||
const code = normalizeText(source?.code || '').toLowerCase()
|
||
if (code.startsWith('risk.')) {
|
||
return 'riskRules'
|
||
}
|
||
if (isJsonRiskRuleSource(source)) {
|
||
return 'riskRules'
|
||
}
|
||
|
||
const normalizedTags = collectRuleTagValues(source).map((item) => normalizeRuleTagValue(item))
|
||
|
||
if (normalizedTags.some((item) => RULE_TAB_TAG_ALIASES.riskRules.has(item))) {
|
||
return 'riskRules'
|
||
}
|
||
if (normalizedTags.some((item) => RULE_TAB_TAG_ALIASES.financialRules.has(item))) {
|
||
return 'financialRules'
|
||
}
|
||
return ''
|
||
}
|
||
|
||
export function resolveTabId(source, typeKey) {
|
||
if (typeKey === 'rules') {
|
||
return resolveRuleTabId(source)
|
||
}
|
||
return typeKey
|
||
}
|
||
|
||
export function resolveTabMeta(tabId, typeKey) {
|
||
if (TAB_META[tabId]) {
|
||
return TAB_META[tabId]
|
||
}
|
||
if (typeKey === 'rules') {
|
||
return {
|
||
...TYPE_META.rules,
|
||
typeKey: 'rules',
|
||
badgeTone: 'primary'
|
||
}
|
||
}
|
||
return TAB_META[typeKey]
|
||
}
|
||
|
||
export function resolveRiskRuleDescription(payload) {
|
||
if (!isPlainObject(payload)) {
|
||
return ''
|
||
}
|
||
return normalizeText(payload.description)
|
||
}
|
||
|
||
export function resolveRiskRuleSourceRef(payload) {
|
||
if (!isPlainObject(payload)) {
|
||
return ''
|
||
}
|
||
const metadata = isPlainObject(payload.metadata) ? payload.metadata : {}
|
||
return normalizeText(metadata.source_ref)
|
||
}
|
||
|
||
export function inferRiskCategoryFromCode(code) {
|
||
const normalized = normalizeText(code).toLowerCase()
|
||
if (normalized.startsWith('risk.travel.')) {
|
||
return '差旅'
|
||
}
|
||
if (normalized.startsWith('risk.invoice.')) {
|
||
return '发票'
|
||
}
|
||
if (normalized.includes('entertainment') || normalized.includes('meal_localized')) {
|
||
return '餐饮招待'
|
||
}
|
||
if (normalized.includes('consecutive_transport')) {
|
||
return '交通出行'
|
||
}
|
||
if (normalized.startsWith('risk.expense.')) {
|
||
return '费用科目'
|
||
}
|
||
return '通用'
|
||
}
|
||
|
||
export function normalizeRiskScenarioCategory(value) {
|
||
const normalized = normalizeText(value)
|
||
const alias = normalized === '通讯费' ? '通信费' : normalized
|
||
return RISK_SCENARIO_VALUES.has(alias) ? alias : ''
|
||
}
|
||
|
||
export function normalizeExpenseTypeScenarioLabels(value) {
|
||
const values = Array.isArray(value) ? value : normalizeText(value) ? [value] : []
|
||
if (values.some((item) => ['all', '*', 'overall', 'general', '全部', '通用'].includes(normalizeText(item).toLowerCase()))) {
|
||
return ['全部']
|
||
}
|
||
|
||
const labels = []
|
||
const seen = new Set()
|
||
|
||
values.forEach((item) => {
|
||
const key = normalizeText(item).toLowerCase()
|
||
const label = EXPENSE_TYPE_SCENARIO_LABELS[key] || normalizeRiskScenarioCategory(item)
|
||
if (!label || seen.has(label)) {
|
||
return
|
||
}
|
||
seen.add(label)
|
||
labels.push(label)
|
||
})
|
||
|
||
return labels
|
||
}
|
||
|
||
export function readRiskRuleExpenseTypes(source) {
|
||
const configJson = readConfigJson(source)
|
||
const metadata = isPlainObject(configJson.metadata) ? configJson.metadata : {}
|
||
const appliesTo = isPlainObject(configJson.applies_to) ? configJson.applies_to : {}
|
||
const values = []
|
||
|
||
;[
|
||
configJson.expense_types,
|
||
metadata.expense_types,
|
||
appliesTo.expense_types,
|
||
source?.expense_types
|
||
].forEach((item) => {
|
||
if (Array.isArray(item)) {
|
||
values.push(...item)
|
||
} else if (normalizeText(item)) {
|
||
values.push(item)
|
||
}
|
||
})
|
||
|
||
return values
|
||
}
|
||
|
||
export function readScenarioItems(source) {
|
||
if (Array.isArray(source?.scenario_json)) {
|
||
return source.scenario_json
|
||
}
|
||
if (Array.isArray(source?.scenarioList)) {
|
||
return source.scenarioList
|
||
}
|
||
return []
|
||
}
|
||
|
||
export function resolveRiskRuleCategory(source) {
|
||
const configJson = readConfigJson(source)
|
||
const expenseScenarioLabels = normalizeExpenseTypeScenarioLabels(readRiskRuleExpenseTypes(source))
|
||
if (expenseScenarioLabels.length) {
|
||
return formatScenarioList(expenseScenarioLabels)
|
||
}
|
||
|
||
const expenseCategoryLabel =
|
||
normalizeText(configJson.expense_category_label) ||
|
||
normalizeText(configJson.metadata?.expense_category_label) ||
|
||
normalizeText(source?.expense_category_label)
|
||
if (expenseCategoryLabel) {
|
||
return expenseCategoryLabel
|
||
}
|
||
|
||
const explicit = normalizeRiskScenarioCategory(configJson.risk_category)
|
||
if (explicit) {
|
||
return explicit
|
||
}
|
||
|
||
const payloadCategory = normalizeRiskScenarioCategory(source?.risk_category)
|
||
if (payloadCategory) {
|
||
return payloadCategory
|
||
}
|
||
|
||
const scenarioItems = readScenarioItems(source)
|
||
const businessScenario = scenarioItems
|
||
.map((item) => normalizeText(item))
|
||
.find((item) => item && !LEGACY_RISK_SCENARIO_KEYS.has(item) && RISK_SCENARIO_VALUES.has(item))
|
||
if (businessScenario) {
|
||
return businessScenario
|
||
}
|
||
|
||
return inferRiskCategoryFromCode(source?.code)
|
||
}
|
||
|
||
export 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|通信|通讯|手机)/i.test(haystack)) {
|
||
return '通信费'
|
||
}
|
||
if (/(welfare|福利)/i.test(haystack)) {
|
||
return '福利费'
|
||
}
|
||
if (/(expense_standard|费用科目|费用标准|补贴|科目)/i.test(haystack)) {
|
||
return '费用科目'
|
||
}
|
||
return '通用'
|
||
}
|
||
|
||
export function resolveRuleScenarioCategory(source, tabId = '') {
|
||
const scenarioList = resolveRuleScenarioList(source, tabId)
|
||
if (scenarioList.length) {
|
||
return formatScenarioList(scenarioList)
|
||
}
|
||
return ''
|
||
}
|
||
|
||
export function resolveRuleScenarioList(source, tabId = '') {
|
||
const resolvedTabId = tabId || resolveRuleTabId(source)
|
||
if (resolvedTabId === 'riskRules' || isJsonRiskRuleSource(source)) {
|
||
const expenseScenarioLabels = normalizeExpenseTypeScenarioLabels(readRiskRuleExpenseTypes(source))
|
||
if (expenseScenarioLabels.length) {
|
||
return expenseScenarioLabels
|
||
}
|
||
const riskCategory = resolveRiskRuleCategory(source)
|
||
return riskCategory ? [riskCategory] : []
|
||
}
|
||
if (resolvedTabId === 'financialRules') {
|
||
const financialCategory = inferFinancialRuleCategory(source)
|
||
return financialCategory ? [financialCategory] : []
|
||
}
|
||
return []
|
||
}
|
||
|
||
export function buildRiskListSubtitle(text, maxLength = 42) {
|
||
const normalized = normalizeText(text)
|
||
if (!normalized) {
|
||
return '平台内置风险规则'
|
||
}
|
||
const firstSentence = normalized.split(/[。;;!?\n]/)[0] || normalized
|
||
if (firstSentence.length <= maxLength) {
|
||
return firstSentence
|
||
}
|
||
return `${firstSentence.slice(0, maxLength)}…`
|
||
}
|