feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造
- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制 - 引入费用审批动态路由、平台风险分级、预审与风险阶段管理 - 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板 - 新增 Hermes 风险线索收集器、Agent 链路追踪中心 - 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估 - 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
@@ -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
|
||||
|
||||
125
web/src/utils/agentTraceViewModel.js
Normal file
125
web/src/utils/agentTraceViewModel.js
Normal 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
70
web/src/utils/authUser.js
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
180
web/src/utils/expenseApplicationEstimate.js
Normal file
180
web/src/utils/expenseApplicationEstimate.js
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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') {
|
||||
|
||||
316
web/src/utils/riskVisibility.js
Normal file
316
web/src/utils/riskVisibility.js
Normal 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(' ')
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user