feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造

- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制
- 引入费用审批动态路由、平台风险分级、预审与风险阶段管理
- 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板
- 新增 Hermes 风险线索收集器、Agent 链路追踪中心
- 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估
- 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-01 17:07:14 +08:00
parent 7989f3a159
commit 92444e7eae
285 changed files with 25075 additions and 2986 deletions

View File

@@ -151,29 +151,46 @@ export function canApproveLeaderExpenseClaims(user) {
return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode))
}
export function canApproveBudgetExpenseApplications(user, request = null) {
if (isPlatformAdminUser(user)) {
return true
}
const roleCodes = normalizedRoleCodes(user)
if (roleCodes.includes('executive')) {
return true
}
if (!roleCodes.includes('budget_monitor')) {
return false
}
if (normalizedGrade(user) !== CLAIM_BUDGET_APPROVAL_GRADE) {
return false
}
export function canApproveBudgetExpenseApplications(user, request = null) {
if (isPlatformAdminUser(user)) {
return true
}
const roleCodes = normalizedRoleCodes(user)
if (!roleCodes.some((roleCode) => roleCode === 'budget_monitor' || roleCode === 'executive')) {
return false
}
if (normalizedGrade(user) !== CLAIM_BUDGET_APPROVAL_GRADE) {
return false
}
return request ? departmentIntersects(request, user) : true
}
export function isCurrentRequestApplicant(request, user) {
const applicantNames = collectIdentityNames(
request?.person,
request?.employeeName,
export function isCurrentRequestApplicant(request, user) {
const applicantIds = collectIdentityNames(
request?.employeeId,
request?.employee_id,
request?.profileEmployeeId,
request?.employeeNo,
request?.employee_no
)
const currentIds = collectIdentityNames(
user?.id,
user?.employeeId,
user?.employee_id,
user?.employeeNo,
user?.employee_no,
user?.username,
user?.email
)
if (applicantIds.length > 0 && identityIntersects(applicantIds, currentIds)) {
return true
}
const applicantNames = collectIdentityNames(
request?.person,
request?.employeeName,
request?.employee_name,
request?.profileName,
request?.applicant

View File

@@ -0,0 +1,125 @@
export const TRACE_STATUS_LABELS = {
succeeded: '成功',
failed: '失败',
blocked: '阻断',
running: '运行中'
}
export const TRACE_SOURCE_LABELS = {
user_message: '用户消息',
schedule: '定时任务',
system_event: '系统事件'
}
export function normalizeTraceListItem(item = {}) {
const runId = normalizeText(item.run_id || item.runId)
return {
runId,
conversationId: normalizeText(item.conversation_id || item.conversationId),
agent: normalizeText(item.agent) || 'orchestrator',
source: normalizeText(item.source),
sourceLabel: TRACE_SOURCE_LABELS[normalizeText(item.source)] || normalizeText(item.source) || '未知来源',
status: normalizeText(item.status) || 'unknown',
statusLabel: TRACE_STATUS_LABELS[normalizeText(item.status)] || normalizeText(item.status) || '未知',
scenario: normalizeText(item.scenario),
intent: normalizeText(item.intent),
title: normalizeText(item.title) || runId,
summary: normalizeText(item.summary),
eventCount: Number(item.event_count || item.eventCount || 0),
toolCallCount: Number(item.tool_call_count || item.toolCallCount || 0),
failedToolCallCount: Number(item.failed_tool_call_count || item.failedToolCallCount || 0),
startedAt: normalizeText(item.started_at || item.startedAt),
finishedAt: normalizeText(item.finished_at || item.finishedAt),
durationMs: Number(item.duration_ms || item.durationMs || 0)
}
}
export function normalizeTraceDetail(payload = {}) {
const run = payload.run || {}
const events = Array.isArray(payload.events)
? payload.events.map(normalizeTraceEvent)
: []
return {
runId: normalizeText(run.run_id || run.runId),
conversationId: normalizeText(payload.conversation_id || payload.conversationId),
agent: normalizeText(run.agent),
source: normalizeText(run.source),
status: normalizeText(run.status),
summary: normalizeText(run.result_summary || run.resultSummary),
errorMessage: normalizeText(run.error_message || run.errorMessage),
routeJson: run.route_json || run.routeJson || {},
ontologyJson: run.ontology_json || run.ontologyJson || {},
semanticParse: payload.semantic_parse || payload.semanticParse || run.semantic_parse || run.semanticParse || null,
toolCalls: Array.isArray(payload.tool_calls || payload.toolCalls)
? payload.tool_calls || payload.toolCalls
: [],
conversationMessages: Array.isArray(payload.conversation_messages || payload.conversationMessages)
? payload.conversation_messages || payload.conversationMessages
: [],
fallbackGenerated: Boolean(payload.fallback_generated || payload.fallbackGenerated),
events
}
}
export function normalizeTraceEvent(event = {}) {
const status = normalizeText(event.status) || 'unknown'
return {
id: normalizeText(event.id),
runId: normalizeText(event.run_id || event.runId),
sequence: Number(event.sequence || 0),
stage: normalizeText(event.stage),
eventName: normalizeText(event.event_name || event.eventName),
title: normalizeText(event.title),
summary: normalizeText(event.summary),
status,
statusLabel: TRACE_STATUS_LABELS[status] || status,
inputJson: event.input_json || event.inputJson || {},
outputJson: event.output_json || event.outputJson || {},
errorMessage: normalizeText(event.error_message || event.errorMessage),
startedAt: normalizeText(event.started_at || event.startedAt),
finishedAt: normalizeText(event.finished_at || event.finishedAt),
durationMs: Number(event.duration_ms || event.durationMs || 0)
}
}
export function resolveTraceStatusTone(status) {
const normalized = normalizeText(status)
if (normalized === 'succeeded') return 'success'
if (normalized === 'failed') return 'danger'
if (normalized === 'blocked') return 'warning'
if (normalized === 'running') return 'info'
return 'muted'
}
export function formatTraceDateTime(value) {
const normalized = normalizeText(value)
if (!normalized) return '-'
const date = new Date(normalized)
return Number.isNaN(date.getTime())
? normalized
: date.toLocaleString('zh-CN', { hour12: false })
}
export function formatTraceDuration(value) {
const durationMs = Number(value || 0)
if (!Number.isFinite(durationMs) || durationMs <= 0) {
return '0ms'
}
if (durationMs < 1000) {
return `${Math.round(durationMs)}ms`
}
return `${(durationMs / 1000).toFixed(2)}s`
}
export function formatTraceJson(value) {
try {
return JSON.stringify(value || {}, null, 2)
} catch {
return String(value || '')
}
}
function normalizeText(value) {
return String(value || '').trim()
}

70
web/src/utils/authUser.js Normal file
View File

@@ -0,0 +1,70 @@
function pickText(payload = {}, keys = [], fallback = '') {
for (const key of keys) {
const value = String(payload?.[key] ?? '').trim()
if (value) {
return value
}
}
return String(fallback || '').trim()
}
function normalizeRoleCodes(payload = {}) {
return Array.isArray(payload.roleCodes)
? payload.roleCodes.map((item) => String(item || '').trim()).filter(Boolean)
: []
}
export function normalizeAuthUserSnapshot(payload = {}, defaults = {}) {
const username = pickText(payload, ['username', 'email', 'account'])
const name = pickText(
payload,
['name', 'userName', 'user_name', 'employeeName', 'employee_name'],
username || defaults.defaultName
)
const department = pickText(payload, [
'department',
'departmentName',
'department_name',
'employeeDepartment',
'employee_department'
])
const position = pickText(payload, [
'position',
'employeePosition',
'employee_position',
'jobTitle',
'job_title',
'title'
])
const employeeNo = pickText(payload, ['employeeNo', 'employee_no'])
const costCenter = pickText(payload, ['costCenter', 'cost_center'])
const financeOwnerName = pickText(payload, ['financeOwnerName', 'finance_owner_name'])
const managerName = pickText(payload, [
'managerName',
'manager_name',
'directManagerName',
'direct_manager_name',
'leaderName',
'leader_name'
])
return {
username,
name,
role: String(payload.role || defaults.defaultRole || ''),
department,
departmentName: department,
position,
grade: pickText(payload, ['grade', 'employeeGrade', 'employee_grade']),
employeeNo,
managerName,
location: pickText(payload, ['location', 'employeeLocation', 'employee_location']),
costCenter,
financeOwnerName,
riskProfile: payload.riskProfile && typeof payload.riskProfile === 'object' ? payload.riskProfile : {},
roleCodes: normalizeRoleCodes(payload),
email: pickText(payload, ['email'], username),
avatar: pickText(payload, ['avatar'], name.slice(0, 1).toUpperCase()),
isAdmin: Boolean(payload.isAdmin)
}
}

View File

@@ -1,3 +1,6 @@
import { filterActionableRiskFlags, normalizeRiskFlagTone } from './riskFlags.js'
import { canViewRiskForContext } from './riskVisibility.js'
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set(['travel', 'meeting', 'entertainment'])
@@ -125,6 +128,39 @@ function getRiskFlags(request) {
return []
}
function resolveRiskAlert(request, context = {}) {
const flags = filterActionableRiskFlags(getRiskFlags(request))
.filter((flag) => (
context.currentUser
? canViewRiskForContext(flag, {
request,
currentUser: context.currentUser,
businessStage: context.businessStage
})
: true
))
if (!flags.length) {
return null
}
const highCount = flags.filter((flag) => normalizeRiskFlagTone(flag) === 'high').length
const mediumCount = flags.filter((flag) => normalizeRiskFlagTone(flag) === 'medium').length
if (highCount > 0) {
return {
label: `高风险 ${highCount}`,
tone: 'danger',
icon: 'mdi mdi-alert-octagon-outline'
}
}
if (mediumCount > 0) {
return {
label: `中风险 ${mediumCount}`,
tone: 'warning',
icon: 'mdi mdi-alert-circle-outline'
}
}
return null
}
function parseNonNegativeInteger(value) {
const nextValue = Number(value)
return Number.isFinite(nextValue) && nextValue > 0 ? Math.floor(nextValue) : 0
@@ -180,13 +216,18 @@ function resolveSlaReminderCount(request) {
}, 0)
}
export function buildDetailAlerts(request) {
export function buildDetailAlerts(request, context = {}) {
if (!request) {
return []
}
const alerts = []
const slaReminderCount = resolveSlaReminderCount(request)
const riskAlert = resolveRiskAlert(request, context)
if (riskAlert) {
alerts.push(riskAlert)
}
alerts.push({
label: `SLA 催单次数 ${slaReminderCount}`,

View File

@@ -20,14 +20,15 @@ export function formatDocumentListTime(value) {
const date = toDate(raw)
if (date) {
const year = String(date.getFullYear())
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
return `${month}-${day} ${hour}:${minute}`
return `${year}-${month}-${day} ${hour}:${minute}`
}
return raw.replace(/^\d{4}-/, '').slice(0, 11)
return raw.slice(0, 16)
}
export function resolveDocumentSortTime(value) {

View File

@@ -0,0 +1,180 @@
const LOCATION_BANDS = {
premium: ['北京', '上海', '广州', '深圳', '杭州', '南京', '苏州', '成都', '重庆', '天津'],
remote: ['新疆', '西藏', '青海', '甘肃', '宁夏', '内蒙古', '海南', '香港', '澳门', '台湾', '海外', '国外'],
coastal: ['上海', '广州', '深圳', '厦门', '福州', '青岛', '大连', '宁波', '舟山', '海口', '三亚', '天津']
}
const TRANSPORT_PRICE_BASE = {
火车: { default: 360, premium: 520, remote: 900, coastal: 520 },
飞机: { default: 850, premium: 1100, remote: 1800, coastal: 1050 },
轮船: { default: 320, premium: 480, remote: 680, coastal: 520 }
}
function normalizeTransportMode(value = '') {
const text = String(value || '').trim()
if (/飞机|机票|航班|乘机|坐飞机/.test(text)) return '飞机'
if (/轮船|船票|客轮|渡轮|邮轮|坐船/.test(text)) return '轮船'
if (/火车|高铁|动车|铁路|列车/.test(text)) return '火车'
return TRANSPORT_PRICE_BASE[text] ? text : ''
}
function resolveLocationBand(location = '') {
const text = String(location || '').trim()
if (LOCATION_BANDS.remote.some((keyword) => text.includes(keyword))) return 'remote'
if (LOCATION_BANDS.premium.some((keyword) => text.includes(keyword))) return 'premium'
if (LOCATION_BANDS.coastal.some((keyword) => text.includes(keyword))) return 'coastal'
return 'default'
}
function roundToTen(value) {
return Math.round(Number(value || 0) / 10) * 10
}
function parseApplicationEstimateDate(value = '') {
const match = String(value || '').match(/(20\d{2})[年\-/.](\d{1,2})[月\-/.](\d{1,2})/)
if (!match) return ''
const year = Number(match[1])
const month = Number(match[2])
const day = Number(match[3])
const parsed = new Date(Date.UTC(year, month - 1, day))
if (
Number.isNaN(parsed.getTime()) ||
parsed.getUTCFullYear() !== year ||
parsed.getUTCMonth() !== month - 1 ||
parsed.getUTCDate() !== day
) {
return ''
}
return parsed.toISOString().slice(0, 10)
}
function resolveTicketPriceFactor(queryDate = '') {
if (!queryDate) return 1
const parsed = new Date(`${queryDate}T00:00:00.000Z`)
if (Number.isNaN(parsed.getTime())) return 1
let factor = 1
const weekday = parsed.getUTCDay()
const month = parsed.getUTCMonth() + 1
if (weekday === 1) factor += 0.04
if (weekday === 5 || weekday === 0) factor += 0.08
if ([1, 2, 7, 8, 10].includes(month)) factor += 0.06
const jitter = (parsed.getUTCFullYear() + month * 13 + parsed.getUTCDate() * 7) % 7 - 3
factor += jitter * 0.01
return Math.min(1.22, Math.max(0.88, factor))
}
function resolveMockQueryLatencyMs(queryDate = '', mode = '', locationBand = '') {
let seed = String(mode || '').length * 43 + String(locationBand || '').length * 29
if (queryDate) {
const parsed = new Date(`${queryDate}T00:00:00.000Z`)
if (!Number.isNaN(parsed.getTime())) {
seed += parsed.getUTCFullYear() + (parsed.getUTCMonth() + 1) * 17 + parsed.getUTCDate() * 31
}
}
return 360 + (seed % 420)
}
export function parseApplicationEstimateMoney(value) {
if (typeof value === 'number') return Number.isFinite(value) ? value : 0
const normalized = String(value ?? '').replace(/,/g, '').replace(/[^\d.-]/g, '')
const amount = Number(normalized)
return Number.isFinite(amount) ? amount : 0
}
export function formatApplicationEstimateMoney(value) {
const amount = parseApplicationEstimateMoney(value)
return new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: 0,
maximumFractionDigits: Number.isInteger(amount) ? 0 : 2
}).format(amount)
}
export function buildMockApplicationTransportEstimate({
transportMode = '',
location = '',
travelDate = '',
time = ''
} = {}) {
const mode = normalizeTransportMode(transportMode)
if (!mode) return null
const locationBand = resolveLocationBand(location)
const queryDate = parseApplicationEstimateDate(travelDate) || parseApplicationEstimateDate(time)
const priceFactor = resolveTicketPriceFactor(queryDate)
const simulatedLatencyMs = resolveMockQueryLatencyMs(queryDate, mode, locationBand)
const priceConfig = TRANSPORT_PRICE_BASE[mode] || TRANSPORT_PRICE_BASE.火车
const oneWayAmount = priceConfig[locationBand] || priceConfig.default
const amount = roundToTen(oneWayAmount * 2 * priceFactor)
const amountDisplay = formatApplicationEstimateMoney(amount)
const bandLabel = {
premium: '一线/高频城市',
remote: '远途地区',
coastal: '沿海城市',
default: '普通城市'
}[locationBand]
const queryLabel = queryDate || '出行日期待确认'
return {
mode,
amount,
amountDisplay,
routeType: '往返',
locationBand,
queryDate,
priceFactor,
simulatedLatencyMs,
source: 'mock_ticket_price_query_v1',
confidence: 'mock',
basisText: `已查询 ${queryLabel} ${mode}参考票价,按${bandLabel}往返 ${amountDisplay}元估算(查询耗时 ${simulatedLatencyMs}ms`
}
}
export function resolveMockApplicationTransportWaitMs(estimate = null) {
const latency = Number(estimate?.simulatedLatencyMs || 0)
return Number.isFinite(latency) && latency > 0
? Math.min(320, Math.max(180, latency))
: 0
}
export async function waitForMockApplicationTransportQuote(options = {}) {
const estimate = buildMockApplicationTransportEstimate(options)
const waitMs = resolveMockApplicationTransportWaitMs(estimate)
if (waitMs > 0) {
await new Promise((resolve) => setTimeout(resolve, waitMs))
}
return estimate
}
export function buildSystemApplicationEstimate({
transportMode = '',
location = '',
travelDate = '',
time = '',
lodgingAmount = 0,
allowanceAmount = 0
} = {}) {
const transportEstimate = buildMockApplicationTransportEstimate({
transportMode,
location,
travelDate,
time
})
const transportAmount = transportEstimate?.amount || 0
const lodging = parseApplicationEstimateMoney(lodgingAmount)
const allowance = parseApplicationEstimateMoney(allowanceAmount)
const totalAmount = transportAmount + lodging + allowance
return {
transportEstimate,
transportAmount,
lodgingAmount: lodging,
allowanceAmount: allowance,
totalAmount,
transportAmountDisplay: transportEstimate ? transportEstimate.amountDisplay : '',
lodgingAmountDisplay: formatApplicationEstimateMoney(lodging),
allowanceAmountDisplay: formatApplicationEstimateMoney(allowance),
totalAmountDisplay: formatApplicationEstimateMoney(totalAmount)
}
}

View File

@@ -79,6 +79,7 @@ export function buildExpenseApplicationOntologyContext(currentUser = {}) {
department_name: currentUser.department || currentUser.departmentName || '',
position: currentUser.position || '',
grade: currentUser.grade || '',
manager_name: currentUser.managerName || currentUser.manager_name || '',
employee_no: currentUser.employeeNo || currentUser.employee_no || ''
}
}
@@ -386,6 +387,8 @@ export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser
transportMode,
applicant: currentUser.name || currentUser.username || '当前用户',
department: currentUser.department || currentUser.departmentName || '待补充',
position: currentUser.position || currentUser.employeePosition || '待补充',
managerName: currentUser.managerName || currentUser.manager_name || '待补充',
preApprovalRequired: PRE_APPROVAL_TYPES.has(expenseTypeCode),
attachmentPolicy
}

View File

@@ -1,4 +1,8 @@
import { buildApplicationFieldsFromOntology } from './expenseApplicationOntology.js'
import {
buildMockApplicationTransportEstimate,
buildSystemApplicationEstimate
} from './expenseApplicationEstimate.js'
import { getTodayDateValue } from './workbenchComposerDate.js'
const APPLICATION_SESSION_TYPE = 'application'
@@ -7,7 +11,11 @@ const APPLICATION_QUERY_PATTERN = /查询|状态|进度|列表|有哪些|材料
export const APPLICATION_PREVIEW_FIELD_DEFINITIONS = [
{ key: 'applicationType', label: '申请类型' },
{ key: 'applicant', label: '姓名', editable: false, required: false },
{ key: 'grade', label: '职级', highlight: true, editable: false, required: false },
{ key: 'department', label: '部门', editable: false, required: false },
{ key: 'position', label: '岗位', editable: false, required: false },
{ key: 'managerName', label: '直属领导', editable: false, required: false },
{ key: 'time', label: '发生时间' },
{ key: 'location', label: '地点' },
{ key: 'reason', label: '事由' },
@@ -17,13 +25,13 @@ export const APPLICATION_PREVIEW_FIELD_DEFINITIONS = [
{ key: 'subsidyDailyCap', label: '补贴标准/天', highlight: true, editable: false, required: false },
{ key: 'transportPolicy', label: '交通费用口径', highlight: true, editable: false, required: false },
{ key: 'policyEstimate', label: '规则测算参考', highlight: true, editable: false, required: false },
{ key: 'amount', label: '用户预估费用', highlight: true }
{ key: 'amount', label: '系统预估费用', highlight: true, editable: false, required: false }
]
export const APPLICATION_TRANSPORT_MODE_OPTIONS = ['火车', '飞机', '轮船']
const APPLICATION_POLICY_PENDING_TEXT = '填写地点和天数后自动测算'
const APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT = '车票、机票暂无实时价格接口,按真实票据实报实销'
const APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT = '选择火车、飞机或轮船后自动生成交通参考票价,报销阶段按真实票据复核'
function compactText(value) {
return String(value || '').replace(/\s+/g, '')
@@ -114,6 +122,38 @@ function resolveCurrentUserGrade(currentUser = {}) {
).trim()
}
function resolveCurrentUserDepartment(currentUser = {}) {
return String(
currentUser.department
|| currentUser.departmentName
|| currentUser.department_name
|| ''
).trim()
}
function resolveCurrentUserPosition(currentUser = {}) {
return String(
currentUser.position
|| currentUser.employeePosition
|| currentUser.employee_position
|| currentUser.jobTitle
|| currentUser.job_title
|| ''
).trim()
}
function resolveCurrentUserManagerName(currentUser = {}) {
return String(
currentUser.managerName
|| currentUser.manager_name
|| currentUser.directManagerName
|| currentUser.direct_manager_name
|| currentUser.leaderName
|| currentUser.leader_name
|| ''
).trim()
}
function parseApplicationDaysValue(value) {
const match = String(value || '').match(/\d+/)
const days = match ? Number(match[0]) : parseChineseNumber(value)
@@ -165,10 +205,12 @@ function formatDailyPolicyMoney(value) {
return display ? `${display}元/天` : APPLICATION_POLICY_PENDING_TEXT
}
function buildTransportPolicyText(transportMode) {
function buildTransportPolicyText(transportMode, location = '', transportEstimate = null, time = '') {
const mode = String(transportMode || '').trim()
if (!mode) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
return `${mode}票据暂无实时价格接口,按真实票据实报实销`
const estimate = transportEstimate || buildMockApplicationTransportEstimate({ transportMode: mode, location, time })
if (!estimate) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
return `${estimate.basisText},报销阶段按真实票据复核`
}
function ensureApplicationPolicyFields(fields = {}) {
@@ -180,7 +222,7 @@ function ensureApplicationPolicyFields(fields = {}) {
nextFields.subsidyDailyCap = APPLICATION_POLICY_PENDING_TEXT
}
if (!String(nextFields.transportPolicy || '').trim() || /实报实销/.test(String(nextFields.transportPolicy || ''))) {
nextFields.transportPolicy = buildTransportPolicyText(nextFields.transportMode)
nextFields.transportPolicy = buildTransportPolicyText(nextFields.transportMode, nextFields.location, null, nextFields.time)
}
if (!String(nextFields.policyEstimate || '').trim()) {
nextFields.policyEstimate = APPLICATION_POLICY_PENDING_TEXT
@@ -371,9 +413,22 @@ export function applyApplicationPolicyEstimateResult(preview = {}, result = {},
const hotelAmount = formatPolicyMoney(result?.hotel_amount)
const allowanceRate = formatPolicyMoney(result?.total_allowance_rate)
const allowanceAmount = formatPolicyMoney(result?.allowance_amount)
const totalAmount = formatPolicyMoney(result?.total_amount)
const matchedCity = String(result?.matched_city || fields.location || '').trim()
const grade = String(result?.grade || fields.grade || resolveCurrentUserGrade(currentUser)).trim()
const systemEstimate = buildSystemApplicationEstimate({
transportMode: fields.transportMode,
location: matchedCity || fields.location,
time: fields.time,
lodgingAmount: result?.hotel_amount,
allowanceAmount: result?.allowance_amount
})
const transportEstimate = systemEstimate.transportEstimate
const queryLabel = transportEstimate?.queryDate || '出行日期待确认'
const transportText = transportEstimate
? `交通 ${systemEstimate.transportAmountDisplay}元(按 ${queryLabel} 参考票价) + `
: ''
const totalAmount = systemEstimate.totalAmountDisplay
const amount = totalAmount ? `${totalAmount}` : fields.amount
return normalizeApplicationPreview({
...preview,
@@ -382,24 +437,85 @@ export function applyApplicationPolicyEstimateResult(preview = {}, result = {},
grade,
lodgingDailyCap: formatDailyPolicyMoney(result?.hotel_rate),
subsidyDailyCap: formatDailyPolicyMoney(result?.total_allowance_rate),
transportPolicy: buildTransportPolicyText(fields.transportMode),
policyEstimate: `住宿 ${hotelAmount}元 + 补贴 ${allowanceAmount}元 = ${totalAmount}元(${days},不含交通票据`,
transportPolicy: buildTransportPolicyText(fields.transportMode, matchedCity || fields.location, transportEstimate, fields.time),
policyEstimate: `${transportText}住宿 ${hotelAmount}元 + 补贴 ${allowanceAmount}元 = ${totalAmount}元(${days}天)`,
amount,
matchedCity,
ruleName: String(result?.rule_name || '').trim(),
ruleVersion: String(result?.rule_version || '').trim(),
hotelAmount: hotelAmount ? `${hotelAmount}` : '',
allowanceAmount: allowanceAmount ? `${allowanceAmount}` : '',
transportEstimatedAmount: systemEstimate.transportAmountDisplay ? `${systemEstimate.transportAmountDisplay}` : '',
transportEstimateDate: transportEstimate?.queryDate || '',
transportQueryLatencyMs: transportEstimate?.simulatedLatencyMs ? `${transportEstimate.simulatedLatencyMs}ms` : '',
transportEstimateSource: transportEstimate?.source || '',
transportEstimateConfidence: transportEstimate?.confidence || '',
policyTotalAmount: totalAmount ? `${totalAmount}` : ''
},
policyEstimate: {
...result,
grade,
matchedCity
matchedCity,
transport_estimate: transportEstimate,
system_total_amount: systemEstimate.totalAmount
},
policyEstimateStatus: 'completed'
})
}
export function refreshApplicationPreviewTransportEstimate(preview = {}) {
const normalized = normalizeApplicationPreview(preview)
const fields = { ...(normalized.fields || {}) }
const policyResult = normalized.policyEstimate && typeof normalized.policyEstimate === 'object'
? normalized.policyEstimate
: {}
const location = String(fields.matchedCity || policyResult.matched_city || fields.location || '').trim()
const hotelAmountSource = fields.hotelAmount || policyResult.hotel_amount || 0
const allowanceAmountSource = fields.allowanceAmount || policyResult.allowance_amount || 0
const systemEstimate = buildSystemApplicationEstimate({
transportMode: fields.transportMode,
location,
time: fields.time,
lodgingAmount: hotelAmountSource,
allowanceAmount: allowanceAmountSource
})
const transportEstimate = systemEstimate.transportEstimate
if (!transportEstimate) return normalized
const hotelAmount = formatPolicyMoney(hotelAmountSource)
const allowanceAmount = formatPolicyMoney(allowanceAmountSource)
const hasPolicyAmounts = parseMoneyNumber(hotelAmountSource) > 0 || parseMoneyNumber(allowanceAmountSource) > 0
const queryLabel = transportEstimate.queryDate || '出行日期待确认'
const nextFields = {
...fields,
transportPolicy: buildTransportPolicyText(fields.transportMode, location, transportEstimate, fields.time),
transportEstimatedAmount: systemEstimate.transportAmountDisplay ? `${systemEstimate.transportAmountDisplay}` : '',
transportEstimateDate: transportEstimate.queryDate || '',
transportQueryLatencyMs: transportEstimate.simulatedLatencyMs ? `${transportEstimate.simulatedLatencyMs}ms` : '',
transportEstimateSource: transportEstimate.source || '',
transportEstimateConfidence: transportEstimate.confidence || ''
}
if (hasPolicyAmounts) {
const days = Number(policyResult.days) || parseApplicationDaysValue(fields.days) || 1
const totalAmount = systemEstimate.totalAmountDisplay
nextFields.policyEstimate = `交通 ${systemEstimate.transportAmountDisplay}元(按 ${queryLabel} 参考票价) + 住宿 ${hotelAmount}元 + 补贴 ${allowanceAmount}元 = ${totalAmount}元(${days}天)`
nextFields.amount = totalAmount ? `${totalAmount}` : nextFields.amount
nextFields.policyTotalAmount = totalAmount ? `${totalAmount}` : ''
}
return normalizeApplicationPreview({
...normalized,
fields: nextFields,
policyEstimate: {
...policyResult,
matchedCity: location,
transport_estimate: transportEstimate,
system_total_amount: systemEstimate.totalAmount
}
})
}
export function applyApplicationPolicyEstimateError(preview = {}, error = null, currentUser = {}) {
const fields = { ...(preview?.fields || {}) }
const message = String(error?.message || error || '').trim()
@@ -408,7 +524,7 @@ export function applyApplicationPolicyEstimateError(preview = {}, error = null,
fields: {
...fields,
grade: fields.grade || resolveCurrentUserGrade(currentUser),
transportPolicy: buildTransportPolicyText(fields.transportMode),
transportPolicy: buildTransportPolicyText(fields.transportMode, fields.location, null, fields.time),
policyEstimate: message ? `规则中心暂未完成测算:${message}` : APPLICATION_POLICY_PENDING_TEXT
},
policyEstimateStatus: message ? 'failed' : 'pending'
@@ -456,7 +572,12 @@ export function buildModelRefinedApplicationPreview(localPreview = {}, ontology
amount: normalizeAmountFromOntology(ontologyFields, currentFields.amount),
grade: resolveProvidedValue(currentFields.grade, resolveCurrentUserGrade(currentUser)),
applicant: resolveProvidedValue(ontologyFields.applicant, currentFields.applicant),
department: resolveProvidedValue(ontologyFields.department, currentFields.department)
department: resolveProvidedValue(ontologyFields.department, currentFields.department || resolveCurrentUserDepartment(currentUser)),
position: resolveProvidedValue(currentFields.position, resolveCurrentUserPosition(currentUser)),
managerName: resolveProvidedValue(
ontologyFields.managerName,
currentFields.managerName || resolveCurrentUserManagerName(currentUser)
)
}
return normalizeApplicationPreview({
@@ -511,7 +632,9 @@ export function buildLocalApplicationPreview(rawText, currentUser = {}, options
amount: resolveApplicationAmount(sourceText),
grade: resolveCurrentUserGrade(currentUser),
applicant: currentUser.name || currentUser.username || '当前用户',
department: currentUser.department || currentUser.departmentName || '待补充'
department: resolveCurrentUserDepartment(currentUser) || '待补充',
position: resolveCurrentUserPosition(currentUser) || '待补充',
managerName: resolveCurrentUserManagerName(currentUser) || '待补充'
}
return normalizeApplicationPreview({
@@ -534,7 +657,9 @@ export function buildApplicationTemplatePreview(currentUser = {}) {
amount: '',
grade: resolveCurrentUserGrade(currentUser),
applicant: currentUser.name || currentUser.username || '当前用户',
department: currentUser.department || currentUser.departmentName || '待补充'
department: resolveCurrentUserDepartment(currentUser) || '待补充',
position: resolveCurrentUserPosition(currentUser) || '待补充',
managerName: resolveCurrentUserManagerName(currentUser) || '待补充'
},
modelReviewStatus: 'template'
})

View File

@@ -6,12 +6,28 @@ const NON_RISK_SOURCES = new Set([
'approval_log',
'expense_claim_approval',
'expense_claim_finance_approval',
'payment'
'application_detail',
'application_handoff',
'application_link',
'application_submission',
'approval_routing',
'budget_approval',
'payment',
'sla_reminder',
'reminder',
'urge'
])
const NON_RISK_EVENTS = new Set([
'expense_claim_approval',
'expense_claim_finance_approval',
'expense_claim_payment_completed'
'expense_claim_payment_completed',
'expense_application_submission',
'expense_application_to_reimbursement_draft',
'expense_reimbursement_application_linked',
'expense_application_budget_approval',
'sla_reminder',
'reminder',
'urge'
])
const NON_RISK_TONES = new Set(['info', 'pass', 'success', 'approved', 'ok', 'none'])
const RISK_SOURCES = new Set([
@@ -85,9 +101,13 @@ export function isActionableRiskFlag(flag) {
const source = normalizeKey(flag.source)
const eventType = normalizeKey(flag.event_type || flag.eventType)
const actionability = normalizeKey(flag.actionability)
if (NON_RISK_SOURCES.has(source) || NON_RISK_EVENTS.has(eventType)) {
return false
}
if (actionability === 'system_trace') {
return false
}
const tone = normalizeRiskFlagTone(flag)
if (tone === 'high' || tone === 'medium' || tone === 'low') {

View File

@@ -0,0 +1,316 @@
import {
canApproveBudgetExpenseApplications,
isCurrentDirectManagerForRequest,
isCurrentRequestApplicant,
isExecutiveUser,
isFinanceUser,
isPlatformAdminUser
} from './accessControl.js'
const APPLICATION_STAGE_ALIASES = new Set([
'expense_application',
'application',
'apply',
'pre_apply',
'pre_application',
'budget_application'
])
const REIMBURSEMENT_STAGE_ALIASES = new Set([
'reimbursement',
'expense_reimbursement',
'claim',
'expense_claim',
'expense_report'
])
const SUPPORTED_DOMAINS = new Set(['budget', 'policy', 'invoice', 'trip', 'amount', 'workflow', 'profile'])
const SUPPORTED_ACTIONABILITIES = new Set([
'fixable_by_submitter',
'review_decision',
'budget_governance',
'finance_check',
'system_trace'
])
const SUPPORTED_SCOPES = new Set(['submitter', 'leader', 'budget_manager', 'finance', 'admin'])
function normalizeText(value) {
return String(value || '').trim()
}
function normalizeKey(value) {
return normalizeText(value).toLowerCase()
}
export function normalizeRiskBusinessStage(value, fallback = 'reimbursement') {
const stage = normalizeKey(value)
if (APPLICATION_STAGE_ALIASES.has(stage)) {
return 'expense_application'
}
if (REIMBURSEMENT_STAGE_ALIASES.has(stage)) {
return 'reimbursement'
}
return fallback
}
export function resolveRiskBusinessStage(flag, fallback = 'reimbursement') {
if (!flag || typeof flag !== 'object') {
return resolveRiskTextBusinessStage(flag, fallback)
}
const explicitStage = normalizeRiskBusinessStage(
flag.business_stage || flag.businessStage || flag.control_stage || flag.controlStage,
''
)
if (explicitStage) {
return explicitStage
}
return resolveRiskTextBusinessStage(cardLikeText(flag), fallback)
}
export function resolveRiskTextBusinessStage(value, fallback = 'reimbursement') {
const text = normalizeText(value)
if (/报销|附件|单据|票据|发票|OCR|识别|付款|支付|酒店|住宿票|交通票/.test(text)) {
return 'reimbursement'
}
if (/申请|预算|额度|事前|预估|申请金额|申请事由/.test(text)) {
return 'expense_application'
}
return fallback
}
export function resolveRiskDomain(flag) {
if (!flag || typeof flag !== 'object') {
return inferRiskDomainFromText(flag)
}
const explicitDomain = normalizeKey(flag.risk_domain || flag.riskDomain || flag.domain)
if (SUPPORTED_DOMAINS.has(explicitDomain)) {
return explicitDomain
}
const source = normalizeKey(flag.source)
const eventType = normalizeKey(flag.event_type || flag.eventType)
if (source === 'budget_control' || eventType.includes('budget')) {
return 'budget'
}
if (source === 'attachment_analysis') {
return 'invoice'
}
if (source === 'manual_return' || source === 'approval_routing') {
return 'workflow'
}
if (source === 'financial_risk_graph') {
return 'profile'
}
return inferRiskDomainFromText(cardLikeText(flag))
}
export function resolveRiskActionability(flag, options = {}) {
const explicitActionability = normalizeKey(flag?.actionability)
if (SUPPORTED_ACTIONABILITIES.has(explicitActionability)) {
return explicitActionability
}
const domain = options.riskDomain || resolveRiskDomain(flag)
const stage = normalizeRiskBusinessStage(options.businessStage, 'reimbursement')
const source = normalizeKey(flag?.source)
if (isSystemTraceSource(source)) {
return 'system_trace'
}
if (domain === 'budget') {
return 'budget_governance'
}
if (source === 'manual_return') {
return 'fixable_by_submitter'
}
if (source === 'attachment_analysis') {
return 'fixable_by_submitter'
}
if (stage === 'expense_application') {
return 'review_decision'
}
if (['policy', 'invoice', 'trip', 'amount'].includes(domain)) {
return 'fixable_by_submitter'
}
return 'review_decision'
}
export function resolveRiskVisibilityScope(flag, options = {}) {
const explicitScope = normalizeKey(flag?.visibility_scope || flag?.visibilityScope)
if (SUPPORTED_SCOPES.has(explicitScope)) {
return explicitScope
}
const domain = options.riskDomain || resolveRiskDomain(flag)
const actionability = options.actionability || resolveRiskActionability(flag, options)
const stage = normalizeRiskBusinessStage(options.businessStage, 'reimbursement')
if (actionability === 'system_trace') {
return 'admin'
}
if (domain === 'budget' || actionability === 'budget_governance') {
return 'budget_manager'
}
if (actionability === 'fixable_by_submitter') {
return 'submitter'
}
if (stage === 'expense_application') {
return 'leader'
}
return 'finance'
}
export function isApplicationRiskStageRequest(request = {}) {
const documentType = normalizeText(
request?.documentTypeCode ||
request?.document_type_code ||
request?.documentType ||
request?.document_type
)
const claimNo = normalizeText(request?.claimNo || request?.claim_no || request?.documentNo || request?.id).toUpperCase()
const typeCode = normalizeKey(request?.typeCode || request?.expense_type)
return (
documentType === 'application' ||
documentType === 'expense_application' ||
claimNo.startsWith('AP-') ||
claimNo.startsWith('APP-') ||
typeCode === 'application' ||
typeCode.endsWith('_application')
)
}
export function buildRiskViewerContext(options = {}) {
const request = options.request || {}
const currentUser = options.currentUser || null
const isApplicationDocument = Boolean(
options.isApplicationDocument ?? isApplicationRiskStageRequest(request)
)
const businessStage = normalizeRiskBusinessStage(
options.businessStage,
isApplicationDocument ? 'expense_application' : 'reimbursement'
)
const isCurrentApplicant = Boolean(
options.isCurrentApplicant ?? isCurrentRequestApplicant(request, currentUser)
)
const isBudgetReviewer = Boolean(
options.isBudgetReviewer ?? canApproveBudgetExpenseApplications(currentUser, request)
)
const isDirectManagerReviewer = Boolean(
options.isDirectManagerReviewer ?? isCurrentDirectManagerForRequest(request, currentUser)
)
const isFinanceReviewer = Boolean(options.isFinanceReviewer ?? isFinanceUser(currentUser))
const isAdminViewer = Boolean(
options.isAdminViewer ?? (isPlatformAdminUser(currentUser) || isExecutiveUser(currentUser))
)
return {
request,
currentUser,
businessStage,
isApplicationDocument,
isCurrentApplicant,
isBudgetReviewer,
isDirectManagerReviewer,
isFinanceReviewer,
isAdminViewer,
canViewApprovalRiskAdvice: Boolean(options.canViewApprovalRiskAdvice)
}
}
export function canViewRiskForContext(flag, options = {}) {
const context = buildRiskViewerContext(options)
const stage = resolveRiskBusinessStage(flag, context.businessStage)
if (stage !== context.businessStage) {
return false
}
const riskDomain = resolveRiskDomain(flag)
const actionability = resolveRiskActionability(flag, { businessStage: stage, riskDomain })
const visibilityScope = resolveRiskVisibilityScope(flag, { businessStage: stage, riskDomain, actionability })
if (context.isAdminViewer) {
return true
}
if (actionability === 'system_trace' || visibilityScope === 'admin') {
return false
}
if (stage === 'expense_application') {
if (context.isCurrentApplicant) {
return false
}
if (riskDomain === 'budget' || actionability === 'budget_governance' || visibilityScope === 'budget_manager') {
return context.isBudgetReviewer
}
return context.isDirectManagerReviewer || context.canViewApprovalRiskAdvice || context.isBudgetReviewer
}
if (context.isCurrentApplicant) {
return actionability === 'fixable_by_submitter' || visibilityScope === 'submitter'
}
if (riskDomain === 'budget' || actionability === 'budget_governance' || visibilityScope === 'budget_manager') {
return context.isBudgetReviewer
}
if (context.isFinanceReviewer) {
return true
}
if (context.isDirectManagerReviewer || context.canViewApprovalRiskAdvice) {
return actionability !== 'finance_check'
}
return false
}
export function filterRiskCardsForVisibility(cards = [], options = {}) {
return (Array.isArray(cards) ? cards : []).filter((card) => canViewRiskForContext(card, options))
}
function inferRiskDomainFromText(value) {
const text = normalizeText(value)
if (/预算|余额|占用|超预算|budget/i.test(text)) {
return 'budget'
}
if (/城市|行程|住宿|交通|出差|地点|日期|时间|酒店|trip|travel|city|hotel|transport|period/i.test(text)) {
return 'trip'
}
if (/附件|票据|发票|OCR|识别|单据|invoice|receipt/i.test(text)) {
return 'invoice'
}
if (/金额|超标|阈值|额度|标准|amount|limit|over/i.test(text)) {
return 'amount'
}
if (/历史|画像|异常关系|profile|baseline/i.test(text)) {
return 'profile'
}
if (/审批|退回|流程|付款|routing|approval|return|payment/i.test(text)) {
return 'workflow'
}
return 'policy'
}
function isSystemTraceSource(source) {
return [
'application_detail',
'application_handoff',
'application_submission',
'approval',
'approval_log',
'approval_routing',
'budget_approval',
'expense_claim_approval',
'expense_claim_finance_approval',
'finance_approval',
'manual_approval',
'payment',
'sla_reminder',
'reminder',
'urge'
].includes(source)
}
function cardLikeText(card = {}) {
if (!card || typeof card !== 'object') {
return normalizeText(card)
}
return [
card.rule_code,
card.risk_category,
card.label,
card.title,
card.risk,
card.message,
card.summary,
card.suggestion,
card.description,
card.detail
].map((item) => normalizeText(item)).join(' ')
}

View File

@@ -83,6 +83,14 @@ export const SECTION_DEFINITIONS = [
longDesc: '查看系统运行日志、结构化事件和请求追踪信息,作为系统设置下的排障与审计子项。',
actionLabel: ''
},
{
id: 'agentTraces',
label: 'Agent Trace',
title: 'Agent 链路追踪',
desc: '对话链路、工具调用与事件重放',
longDesc: '按 Run ID 还原 Orchestrator 到下游 Agent 的语义识别、路由、工具调用、会话写回和最终回复,便于线上排障和审计复盘。',
actionLabel: ''
},
{
id: 'mail',
label: '邮箱设置',
@@ -474,6 +482,7 @@ export function computeSectionStatus(state) {
normalizeValue(state.logForm.logPath)
),
systemLogs: true,
agentTraces: true,
mail: Boolean(
normalizeValue(state.mailForm.smtpHost) &&
Number(state.mailForm.port) > 0 &&