feat: 新增员工行为画像算法与费用风险标签体系

后端新增员工行为画像算法模块,支持标签规则引擎和评分计算,
完善员工模型、银行信息、序列化和导入逻辑,优化报销审批流
和工作流常量,增强 Hermes 同步和知识同步能力,前端新增费
用画像详情弹窗、雷达图和风险卡片组件,完善登录页和工作台
样式,优化文档中心和归档中心交互,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-28 12:09:49 +08:00
parent 04cd6d0f81
commit 8a4a777be7
96 changed files with 9835 additions and 704 deletions

View File

@@ -1,239 +1,239 @@
export const DEFAULT_APP_VIEW_ORDER = [
'overview',
'workbench',
'documents',
'budget',
'policies',
'audit',
'digitalEmployees',
'logs',
export const DEFAULT_APP_VIEW_ORDER = [
'workbench',
'documents',
'budget',
'audit',
'overview',
'policies',
'digitalEmployees',
'logs',
'employees',
'settings'
]
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'documents', 'policies'])
const VIEW_ROLE_RULES = {
overview: ['finance', 'executive'],
budget: ['budget_monitor', 'executive'],
audit: ['finance'],
digitalEmployees: ['finance'],
logs: ['manager'],
employees: ['manager'],
settings: ['manager']
}
const CLAIM_MANAGER_ROLE_CODES = new Set(['executive'])
const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver', 'budget_monitor'])
const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver'])
const CLAIM_BUDGET_APPROVAL_GRADE = 'P8'
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'documents', 'policies'])
const VIEW_ROLE_RULES = {
overview: ['finance', 'executive'],
budget: ['budget_monitor', 'executive'],
audit: ['finance'],
digitalEmployees: ['finance'],
logs: ['manager'],
employees: ['manager'],
settings: ['manager']
}
const CLAIM_MANAGER_ROLE_CODES = new Set(['executive'])
const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver', 'budget_monitor'])
const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver'])
const CLAIM_BUDGET_APPROVAL_GRADE = 'P8'
function normalizedRoleCodes(user) {
if (!user) {
return []
}
return Array.isArray(user.roleCodes)
? user.roleCodes
.map((item) => normalizeRoleCode(item))
.filter(Boolean)
: []
}
function normalizeRoleCode(value) {
const roleCode = String(value || '').trim().toLowerCase()
return roleCode === 'auditor' ? 'budget_monitor' : roleCode
}
function normalizeComparableText(value) {
return String(value || '').trim()
}
function collectIdentityNames(...values) {
return values
.map((value) => normalizeComparableText(value))
.filter(Boolean)
}
function identityIntersects(leftValues, rightValues) {
const rightSet = new Set(rightValues)
return leftValues.some((item) => rightSet.has(item))
}
function normalizedGrade(user) {
return String(user?.grade || user?.employeeGrade || '').trim().toUpperCase()
}
function departmentIntersects(request, user) {
const requestDepartments = collectIdentityNames(
request?.dept,
request?.departmentName,
request?.department_name
)
const currentDepartments = collectIdentityNames(
user?.department,
user?.departmentName,
user?.department_name
)
return requestDepartments.length > 0 && identityIntersects(requestDepartments, currentDepartments)
}
function hasPlatformAdminIdentity(user) {
if (!user) {
return false
}
const username = String(user.username || user.account || '').trim().toLowerCase()
const role = String(user.role || '').trim().toLowerCase()
const roleCodes = normalizedRoleCodes(user)
return (
Boolean(user.isAdmin)
|| username === 'admin'
|| role === 'admin'
|| role === '管理员'
|| role === '系统管理员'
|| roleCodes.includes('admin')
)
}
export function isManagerUser(user) {
return hasPlatformAdminIdentity(user) || normalizedRoleCodes(user).includes('manager')
}
export function isPlatformAdminUser(user) {
return hasPlatformAdminIdentity(user)
}
export function isFinanceUser(user) {
return normalizedRoleCodes(user).includes('finance')
}
function normalizedRoleCodes(user) {
if (!user) {
return []
}
export function isExecutiveUser(user) {
return normalizedRoleCodes(user).includes('executive')
}
export function isBudgetMonitorUser(user) {
return normalizedRoleCodes(user).includes('budget_monitor')
}
export function canEditBudgetCenter(user) {
return isPlatformAdminUser(user) || isExecutiveUser(user)
}
export function canSwitchBudgetDepartments(user) {
return isPlatformAdminUser(user) || isExecutiveUser(user)
}
export function canManageExpenseClaims(user) {
if (isPlatformAdminUser(user)) {
return true
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode))
}
export function canDeleteArchivedExpenseClaims(user) {
return isPlatformAdminUser(user)
}
export function canReturnExpenseClaims(user) {
if (isPlatformAdminUser(user)) {
return true
}
return Array.isArray(user.roleCodes)
? user.roleCodes
.map((item) => normalizeRoleCode(item))
.filter(Boolean)
: []
}
function normalizeRoleCode(value) {
const roleCode = String(value || '').trim().toLowerCase()
return roleCode === 'auditor' ? 'budget_monitor' : roleCode
}
function normalizeComparableText(value) {
return String(value || '').trim()
}
function collectIdentityNames(...values) {
return values
.map((value) => normalizeComparableText(value))
.filter(Boolean)
}
function identityIntersects(leftValues, rightValues) {
const rightSet = new Set(rightValues)
return leftValues.some((item) => rightSet.has(item))
}
function normalizedGrade(user) {
return String(user?.grade || user?.employeeGrade || '').trim().toUpperCase()
}
function departmentIntersects(request, user) {
const requestDepartments = collectIdentityNames(
request?.dept,
request?.departmentName,
request?.department_name
)
const currentDepartments = collectIdentityNames(
user?.department,
user?.departmentName,
user?.department_name
)
return requestDepartments.length > 0 && identityIntersects(requestDepartments, currentDepartments)
}
function hasPlatformAdminIdentity(user) {
if (!user) {
return false
}
const username = String(user.username || user.account || '').trim().toLowerCase()
const role = String(user.role || '').trim().toLowerCase()
const roleCodes = normalizedRoleCodes(user)
return (
Boolean(user.isAdmin)
|| username === 'admin'
|| role === 'admin'
|| role === '管理员'
|| role === '系统管理员'
|| roleCodes.includes('admin')
)
}
export function isManagerUser(user) {
return hasPlatformAdminIdentity(user) || normalizedRoleCodes(user).includes('manager')
}
export function isPlatformAdminUser(user) {
return hasPlatformAdminIdentity(user)
}
export function isFinanceUser(user) {
return normalizedRoleCodes(user).includes('finance')
}
export function isExecutiveUser(user) {
return normalizedRoleCodes(user).includes('executive')
}
export function isBudgetMonitorUser(user) {
return normalizedRoleCodes(user).includes('budget_monitor')
}
export function canEditBudgetCenter(user) {
return isPlatformAdminUser(user) || isExecutiveUser(user)
}
export function canSwitchBudgetDepartments(user) {
return isPlatformAdminUser(user) || isExecutiveUser(user)
}
export function canManageExpenseClaims(user) {
if (isPlatformAdminUser(user)) {
return true
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode))
}
export function canDeleteArchivedExpenseClaims(user) {
return isPlatformAdminUser(user)
}
export function canReturnExpenseClaims(user) {
if (isPlatformAdminUser(user)) {
return true
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_RETURN_ROLE_CODES.has(roleCode))
}
export function canApproveLeaderExpenseClaims(user) {
if (isPlatformAdminUser(user)) {
return true
}
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
}
return request ? departmentIntersects(request, user) : true
}
export function isCurrentRequestApplicant(request, user) {
const applicantNames = collectIdentityNames(
request?.person,
request?.employeeName,
request?.employee_name,
request?.profileName,
request?.applicant
)
const currentNames = collectIdentityNames(
user?.name,
user?.username,
user?.email,
user?.employeeNo,
user?.employee_no
)
return applicantNames.length > 0 && identityIntersects(applicantNames, currentNames)
}
export function isCurrentDirectManagerForRequest(request, user) {
if (isCurrentRequestApplicant(request, user)) {
return false
}
const managerNames = collectIdentityNames(
request?.profileManager,
request?.managerName,
request?.manager_name,
request?.directManagerName,
request?.direct_manager_name,
request?.manager
)
const currentNames = collectIdentityNames(
user?.name,
user?.username,
user?.email,
user?.employeeNo,
user?.employee_no
)
return managerNames.length > 0 && identityIntersects(managerNames, currentNames)
}
export function canAccessAppView(user, viewId) {
if (!viewId || !user) {
return false
}
if (!DEFAULT_APP_VIEW_ORDER.includes(viewId)) {
return false
}
if (viewId === 'budget') {
if (isPlatformAdminUser(user)) {
return true
}
const roleCodes = normalizedRoleCodes(user)
return VIEW_ROLE_RULES.budget.some((roleCode) => roleCodes.includes(roleCode))
}
if (isManagerUser(user)) {
return true
}
export function canApproveLeaderExpenseClaims(user) {
if (isPlatformAdminUser(user)) {
return true
}
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
}
return request ? departmentIntersects(request, user) : true
}
export function isCurrentRequestApplicant(request, user) {
const applicantNames = collectIdentityNames(
request?.person,
request?.employeeName,
request?.employee_name,
request?.profileName,
request?.applicant
)
const currentNames = collectIdentityNames(
user?.name,
user?.username,
user?.email,
user?.employeeNo,
user?.employee_no
)
return applicantNames.length > 0 && identityIntersects(applicantNames, currentNames)
}
export function isCurrentDirectManagerForRequest(request, user) {
if (isCurrentRequestApplicant(request, user)) {
return false
}
const managerNames = collectIdentityNames(
request?.profileManager,
request?.managerName,
request?.manager_name,
request?.directManagerName,
request?.direct_manager_name,
request?.manager
)
const currentNames = collectIdentityNames(
user?.name,
user?.username,
user?.email,
user?.employeeNo,
user?.employee_no
)
return managerNames.length > 0 && identityIntersects(managerNames, currentNames)
}
export function canAccessAppView(user, viewId) {
if (!viewId || !user) {
return false
}
if (!DEFAULT_APP_VIEW_ORDER.includes(viewId)) {
return false
}
if (viewId === 'budget') {
if (isPlatformAdminUser(user)) {
return true
}
const roleCodes = normalizedRoleCodes(user)
return VIEW_ROLE_RULES.budget.some((roleCode) => roleCodes.includes(roleCode))
}
if (isManagerUser(user)) {
return true
}
if (ALWAYS_VISIBLE_VIEWS.has(viewId)) {
return true

View File

@@ -10,7 +10,7 @@ function isArchivedRequestPayload(request) {
const normalizedStatus = String(request.status || '').trim().toLowerCase()
const stage = String(request.approval_stage || request.approvalStage || '').trim()
if (stage === '归档入账' || stage === 'completed') {
if (stage === '归档入账' || stage === '已付款' || stage === 'completed') {
return true
}
@@ -27,7 +27,7 @@ function isArchivedRequestPayload(request) {
}
return ARCHIVED_CLAIM_STATUSES.has(normalizedStatus)
&& (stage === '' || stage === '归档入账' || stage === 'completed')
&& (stage === '' || stage === '归档入账' || stage === '已付款' || stage === 'completed')
}
export function isArchivedDocumentRow(row) {

View File

@@ -4,7 +4,7 @@ export function isArchivedExpenseClaim(claim) {
const stage = String(claim?.approval_stage || claim?.approvalStage || '').trim()
const status = String(claim?.status || '').trim().toLowerCase()
if (stage === '归档入账' || stage === 'completed' || stage.includes('归档')) {
if (stage === '归档入账' || stage === '已付款' || stage === 'completed' || stage.includes('归档')) {
return true
}
@@ -16,5 +16,5 @@ export function isArchivedExpenseClaim(claim) {
return true
}
return !stage || stage === '归档入账' || stage === 'completed'
return !stage || stage === '归档入账' || stage === '已付款' || stage === 'completed'
}

View File

@@ -14,13 +14,22 @@ export const HERMES_SIMPLE_TASKS = [
frequency: 'weekly',
frequencyLabel: '每周一',
weekday: 1
},
{
id: 'employee_behavior_profile_scan',
label: '员工画像巡检',
hint: '沉淀费用、流程质量与 AI 协作画像快照',
frequency: 'weekly',
frequencyLabel: '每周一',
weekday: 1
}
]
function buildDefaultSchedules() {
const defaults = {
global_risk_scan: { enabled: true, frequency: 'daily', time: '09:00', weekday: 1, monthDay: 1, month: 1 },
weekly_expense_report: { enabled: false, frequency: 'weekly', time: '10:30', weekday: 1, monthDay: 1, month: 1 }
weekly_expense_report: { enabled: false, frequency: 'weekly', time: '10:30', weekday: 1, monthDay: 1, month: 1 },
employee_behavior_profile_scan: { enabled: false, frequency: 'weekly', time: '08:30', weekday: 1, monthDay: 1, month: 1 }
}
for (const task of HERMES_SIMPLE_TASKS) {
@@ -49,7 +58,8 @@ export function buildDefaultHermesEmployeeForm() {
notifyOnFailure: true,
capabilities: {
global_risk_scan: true,
weekly_expense_report: false
weekly_expense_report: false,
employee_behavior_profile_scan: false
},
schedules: buildDefaultSchedules()
}

View File

@@ -115,6 +115,7 @@ const APPROVAL_META = {
draft: { label: '草稿', tone: 'draft' },
in_progress: { label: '审批中', tone: 'info' },
supplement: { label: '待补充', tone: 'warning' },
pending_payment: { label: '待付款', tone: 'warning' },
completed: { label: '已完成', tone: 'success' },
rejected: { label: '已退回', tone: 'danger' }
}
@@ -126,8 +127,9 @@ const BACKEND_STATUS_META = {
reviewing: { key: 'in_progress', label: '审批中', tone: 'info' },
in_review: { key: 'in_progress', label: '审批中', tone: 'info' },
in_progress: { key: 'in_progress', label: '审批中', tone: 'info' },
pending_payment: { key: 'pending_payment', label: '待付款', tone: 'warning' },
approved: { key: 'completed', label: '已完成', tone: 'success' },
paid: { key: 'completed', label: '已完成', tone: 'success' },
paid: { key: 'completed', label: '已付款', tone: 'success' },
completed: { key: 'completed', label: '已完成', tone: 'success' },
supplement: { key: 'supplement', label: '待补充', tone: 'warning' },
returned: { key: 'supplement', label: '待提交', tone: 'warning' },
@@ -259,7 +261,7 @@ export function isArchivedRequestView(request) {
const displayStage = String(request?.workflowNode || request?.node || '').trim()
const stage = rawStage || displayStage
if (stage === '归档入账' || stage === 'completed' || stage.includes('归档') || stage.includes('入账')) {
if (stage === '归档入账' || stage === '已付款' || stage === 'completed' || stage.includes('归档') || stage.includes('入账')) {
return true
}
if (
@@ -270,7 +272,7 @@ export function isArchivedRequestView(request) {
return true
}
if (['approved', 'completed', 'paid'].includes(status)) {
return rawStage === '' || rawStage === '归档入账' || rawStage === 'completed'
return rawStage === '' || rawStage === '归档入账' || rawStage === '已付款' || rawStage === 'completed'
}
return approvalKey === 'completed'
}

View File

@@ -5,11 +5,13 @@ const NON_RISK_SOURCES = new Set([
'approval',
'approval_log',
'expense_claim_approval',
'expense_claim_finance_approval'
'expense_claim_finance_approval',
'payment'
])
const NON_RISK_EVENTS = new Set([
'expense_claim_approval',
'expense_claim_finance_approval'
'expense_claim_finance_approval',
'expense_claim_payment_completed'
])
const NON_RISK_TONES = new Set(['info', 'pass', 'success', 'approved', 'ok', 'none'])
const RISK_SOURCES = new Set([
@@ -39,6 +41,8 @@ function isApprovalOnlyText(value) {
/^(同意|通过|审批通过|审核通过|已同意|无意见)$/.test(text)
|| /已审批通过/.test(text)
|| /已完成财务审核/.test(text)
|| /进入待付款/.test(text)
|| /已确认付款/.test(text)
|| /进入归档入账/.test(text)
|| /流转至/.test(text)
)