feat: 新增预算后端服务与差旅风险规则库
后端新增预算模型、端点和服务模块,支持预算 CRUD 和余额 查询,清理旧生成规则文件并替换为按严重等级分类的差旅风 险规则库,优化认证权限和报销单访问策略,新增财务规则目 录和演示数据构建脚本,前端预算中心增加对话框交互,完善 审计页面运行时模型和元数据展示,补充单元测试。
This commit is contained in:
@@ -63,11 +63,24 @@
|
||||
.budget-edit-body {
|
||||
min-height: 0;
|
||||
padding: 18px 24px 16px;
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.budget-edit-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.budget-edit-section:first-child {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.budget-edit-section + .budget-edit-section {
|
||||
margin-top: 18px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.budget-edit-section h3 {
|
||||
@@ -76,6 +89,7 @@
|
||||
font-size: 15px;
|
||||
line-height: 1.35;
|
||||
font-weight: 800;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.budget-edit-form-grid {
|
||||
@@ -153,6 +167,9 @@
|
||||
border: 1px solid #edf1f6;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.budget-edit-table {
|
||||
@@ -243,27 +260,35 @@
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.budget-edit-total {
|
||||
height: 42px;
|
||||
margin-top: 8px;
|
||||
padding: 0 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
border: 1px solid #edf1f6;
|
||||
border-radius: 8px;
|
||||
background: #fbfcfe;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.budget-edit-total span,
|
||||
.budget-edit-total strong {
|
||||
.budget-edit-total span {
|
||||
color: #111827;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.budget-edit-total strong {
|
||||
color: #059669;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.budget-edit-foot {
|
||||
padding: 18px 24px 20px;
|
||||
display: flex;
|
||||
|
||||
@@ -252,6 +252,10 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.budget-work-grid.single-department {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.budget-department-panel,
|
||||
.budget-table-panel,
|
||||
.budget-chart-panel,
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
id="application-intent-input"
|
||||
v-model="draft"
|
||||
rows="5"
|
||||
placeholder="例如:申请下周去北京做客户现场验收,差旅预算18000元"
|
||||
placeholder="例如:申请下周去北京做客户现场验收,预计费用18000元"
|
||||
></textarea>
|
||||
|
||||
<div class="application-example-row">
|
||||
|
||||
@@ -113,7 +113,7 @@ export function useAppShell() {
|
||||
return {
|
||||
title: isApplicationDocument ? '申请单详情' : '报销单详情',
|
||||
desc: isApplicationDocument
|
||||
? '查看申请信息、预计金额、审批进度与预算管理口径。'
|
||||
? '查看申请信息、预计金额与审批进度。'
|
||||
: '查看报销明细、票据材料、审批进度与风险提示。'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -764,7 +764,7 @@ export function mapExpenseClaimToRequest(claim) {
|
||||
riskSummary,
|
||||
attachmentSummary: isApplicationDocument ? '申请单' : (invoiceCount > 0 ? `${invoiceCount} 张票据` : '无'),
|
||||
expenseTableSummary: isApplicationDocument
|
||||
? '预计金额已纳入预算管理口径'
|
||||
? '预计金额已随申请提交'
|
||||
: expenseItems.length
|
||||
? (invoiceCount > 0
|
||||
? `共 ${expenseItems.length} 条费用明细,已关联 ${invoiceCount} 张票据`
|
||||
|
||||
@@ -107,7 +107,7 @@ function buildAnonymousUser() {
|
||||
}
|
||||
}
|
||||
|
||||
function buildLegacyAdminUser(username = '') {
|
||||
function buildLegacyAdminUser(username = '') {
|
||||
const normalized = String(username || '').trim()
|
||||
const name = normalized || DEFAULT_USER_NAME
|
||||
|
||||
@@ -129,10 +129,25 @@ function buildLegacyAdminUser(username = '') {
|
||||
email: '',
|
||||
avatar: name.slice(0, 1).toUpperCase(),
|
||||
isAdmin: true
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredUser() {
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePlatformAdminFlag(payload, roleCodes = []) {
|
||||
const username = String(payload?.username || payload?.account || '').trim().toLowerCase()
|
||||
const role = String(payload?.role || '').trim().toLowerCase()
|
||||
const normalizedRoleCodes = roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
|
||||
|
||||
return (
|
||||
Boolean(payload?.isAdmin)
|
||||
|| username === 'admin'
|
||||
|| role === 'admin'
|
||||
|| role === '管理员'
|
||||
|| role === '系统管理员'
|
||||
|| normalizedRoleCodes.includes('admin')
|
||||
)
|
||||
}
|
||||
|
||||
function readStoredUser() {
|
||||
if (typeof window === 'undefined') {
|
||||
return buildAnonymousUser()
|
||||
}
|
||||
@@ -164,9 +179,9 @@ function readStoredUser() {
|
||||
roleCodes,
|
||||
email: String(payload.email || ''),
|
||||
avatar: String(payload.avatar || name.slice(0, 1).toUpperCase()),
|
||||
isAdmin: Boolean(payload.isAdmin)
|
||||
}
|
||||
}
|
||||
isAdmin: resolvePlatformAdminFlag(payload, roleCodes)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return buildLegacyAdminUser(readStoredUsername())
|
||||
}
|
||||
@@ -609,8 +624,14 @@ async function handleLogin(credentials) {
|
||||
password: credentials.password
|
||||
})
|
||||
|
||||
const user = response?.user || buildAnonymousUser()
|
||||
loggedIn.value = true
|
||||
const responseUser = response?.user || buildAnonymousUser()
|
||||
const responseRoleCodes = Array.isArray(responseUser.roleCodes) ? responseUser.roleCodes.filter(Boolean) : []
|
||||
const user = {
|
||||
...responseUser,
|
||||
roleCodes: responseRoleCodes,
|
||||
isAdmin: resolvePlatformAdminFlag(responseUser, responseRoleCodes)
|
||||
}
|
||||
loggedIn.value = true
|
||||
persistAuthState(true, user)
|
||||
currentUser.value = user
|
||||
touchAuthActivity(true)
|
||||
|
||||
@@ -46,11 +46,13 @@ function readCurrentUserHeaders() {
|
||||
const username = String(payload?.username || '').trim()
|
||||
const name = String(payload?.name || username).trim()
|
||||
const roleCodes = Array.isArray(payload?.roleCodes) ? payload.roleCodes.filter(Boolean) : []
|
||||
const isAdmin = Boolean(payload?.isAdmin)
|
||||
const isAdmin = resolveStoredUserAdminFlag(payload, roleCodes)
|
||||
const department = String(payload?.department || payload?.departmentName || '').trim()
|
||||
const costCenter = String(payload?.costCenter || payload?.cost_center || '').trim()
|
||||
const safeUsername = pickSafeHeaderValue(username)
|
||||
const safeName = pickSafeHeaderValue(name)
|
||||
const safeDepartment = pickSafeHeaderValue(department)
|
||||
const safeCostCenter = pickSafeHeaderValue(costCenter)
|
||||
|
||||
if (!safeUsername && !safeName) {
|
||||
return {}
|
||||
@@ -73,11 +75,30 @@ function readCurrentUserHeaders() {
|
||||
headers['x-auth-department'] = safeDepartment
|
||||
}
|
||||
|
||||
if (safeCostCenter) {
|
||||
headers['x-auth-cost-center'] = safeCostCenter
|
||||
}
|
||||
|
||||
return headers
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStoredUserAdminFlag(payload, roleCodes = []) {
|
||||
const username = String(payload?.username || payload?.account || '').trim().toLowerCase()
|
||||
const role = String(payload?.role || '').trim().toLowerCase()
|
||||
const normalizedRoleCodes = roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
|
||||
|
||||
return (
|
||||
Boolean(payload?.isAdmin)
|
||||
|| username === 'admin'
|
||||
|| role === 'admin'
|
||||
|| role === '管理员'
|
||||
|| role === '系统管理员'
|
||||
|| normalizedRoleCodes.includes('admin')
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeApiBaseUrl(value) {
|
||||
return String(value || '/api/v1').replace(/\/$/, '')
|
||||
|
||||
30
web/src/services/budgets.js
Normal file
30
web/src/services/budgets.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { apiRequest } from './api.js'
|
||||
|
||||
function buildQuery(params = {}) {
|
||||
const search = new URLSearchParams()
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
if (typeof value === 'undefined' || value === null || value === '') return
|
||||
search.set(key, String(value))
|
||||
})
|
||||
const query = search.toString()
|
||||
return query ? `?${query}` : ''
|
||||
}
|
||||
|
||||
export function fetchBudgetSummary(params = {}) {
|
||||
return apiRequest(`/budgets/summary${buildQuery(params)}`)
|
||||
}
|
||||
|
||||
export function fetchBudgetAllocations(params = {}) {
|
||||
return apiRequest(`/budgets/allocations${buildQuery(params)}`)
|
||||
}
|
||||
|
||||
export function createBudgetAllocation(payload = {}) {
|
||||
return apiRequest('/budgets/allocations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchBudgetTransactions(allocationId) {
|
||||
return apiRequest(`/budgets/allocations/${encodeURIComponent(String(allocationId || '').trim())}/transactions`)
|
||||
}
|
||||
@@ -13,44 +13,82 @@ export const DEFAULT_APP_VIEW_ORDER = [
|
||||
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'documents', 'policies'])
|
||||
const VIEW_ROLE_RULES = {
|
||||
overview: ['finance', 'executive'],
|
||||
budget: ['finance', 'executive'],
|
||||
audit: ['auditor', 'finance'],
|
||||
logs: ['manager'],
|
||||
employees: ['manager'],
|
||||
settings: ['manager']
|
||||
}
|
||||
budget: ['budget_monitor', 'executive'],
|
||||
audit: ['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'])
|
||||
const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver'])
|
||||
|
||||
function normalizedRoleCodes(user) {
|
||||
if (!user) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Array.isArray(user.roleCodes)
|
||||
? user.roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
|
||||
: []
|
||||
}
|
||||
|
||||
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 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 Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager')
|
||||
return hasPlatformAdminIdentity(user) || normalizedRoleCodes(user).includes('manager')
|
||||
}
|
||||
|
||||
export function isPlatformAdminUser(user) {
|
||||
return Boolean(user?.isAdmin)
|
||||
return hasPlatformAdminIdentity(user)
|
||||
}
|
||||
|
||||
export function isFinanceUser(user) {
|
||||
return normalizedRoleCodes(user).includes('finance')
|
||||
}
|
||||
|
||||
export function isExecutiveUser(user) {
|
||||
return normalizedRoleCodes(user).includes('executive')
|
||||
}
|
||||
|
||||
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 (Boolean(user?.isAdmin)) {
|
||||
if (isPlatformAdminUser(user)) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -58,21 +96,21 @@ export function canManageExpenseClaims(user) {
|
||||
}
|
||||
|
||||
export function canDeleteArchivedExpenseClaims(user) {
|
||||
return Boolean(user?.isAdmin)
|
||||
return isPlatformAdminUser(user)
|
||||
}
|
||||
|
||||
export function canReturnExpenseClaims(user) {
|
||||
if (Boolean(user?.isAdmin)) {
|
||||
if (isPlatformAdminUser(user)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return normalizedRoleCodes(user).some((roleCode) => CLAIM_RETURN_ROLE_CODES.has(roleCode))
|
||||
}
|
||||
|
||||
export function canApproveLeaderExpenseClaims(user) {
|
||||
if (Boolean(user?.isAdmin)) {
|
||||
return true
|
||||
}
|
||||
export function canApproveLeaderExpenseClaims(user) {
|
||||
if (isPlatformAdminUser(user)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode))
|
||||
}
|
||||
@@ -86,6 +124,14 @@ export function canAccessAppView(user, 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
|
||||
}
|
||||
|
||||
@@ -119,16 +119,16 @@ export const BUDGET_CONTROL_ACTION_OPTIONS = ['正常', '提醒', '管控']
|
||||
export const BUDGET_YEAR_OPTIONS = ['2026', '2027', '2028']
|
||||
export const BUDGET_QUARTER_OPTIONS = ['Q1', 'Q2', 'Q3', 'Q4']
|
||||
export const BUDGET_EXPENSE_TYPE_OPTIONS = Object.freeze([
|
||||
{ value: 'travel', label: '差旅费' },
|
||||
{ value: 'travel', label: '差旅' },
|
||||
{ value: 'hotel', label: '住宿费' },
|
||||
{ value: 'transport', label: '交通费' },
|
||||
{ value: 'meal', label: '业务招待费' },
|
||||
{ value: 'meal', label: '招待费' },
|
||||
{ value: 'meeting', label: '会务费' },
|
||||
{ value: 'marketing', label: '市场推广费' },
|
||||
{ value: 'office', label: '办公用品费' },
|
||||
{ value: 'office', label: '办公用品' },
|
||||
{ value: 'training', label: '培训费' },
|
||||
{ value: 'software', label: '软件服务费' },
|
||||
{ value: 'communication', label: '通讯费' },
|
||||
{ value: 'communication', label: '通信' },
|
||||
{ value: 'welfare', label: '福利费' }
|
||||
])
|
||||
|
||||
@@ -139,6 +139,17 @@ const BUDGET_EXPENSE_TYPE_BY_CODE = Object.freeze(
|
||||
}, {})
|
||||
)
|
||||
|
||||
export const BUDGET_VISIBLE_EXPENSE_TYPE_CODES = Object.freeze([
|
||||
'travel',
|
||||
'communication',
|
||||
'meal',
|
||||
'office'
|
||||
])
|
||||
|
||||
export const BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS = Object.freeze(
|
||||
BUDGET_VISIBLE_EXPENSE_TYPE_CODES.map((code) => BUDGET_EXPENSE_TYPE_BY_CODE[code]).filter(Boolean)
|
||||
)
|
||||
|
||||
export function resolveBudgetExpenseTypeLabel(code, fallback = '') {
|
||||
return BUDGET_EXPENSE_TYPE_BY_CODE[String(code || '').trim()]?.label || fallback
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ const PROMPT_FIELD_LABELS = [
|
||||
]
|
||||
|
||||
export const APPLICATION_EXAMPLES = [
|
||||
'申请下周去北京做客户现场验收,差旅预算18000元',
|
||||
'申请下周去北京做客户现场验收,预计费用18000元',
|
||||
'申请上海产品发布会会务费32000元,需要场地和物料',
|
||||
'申请部门集中采购办公用品4800元,用于新员工入职'
|
||||
]
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
@summary-change="documentSummary = $event"
|
||||
/>
|
||||
|
||||
<BudgetCenterView v-else-if="activeView === 'budget'" />
|
||||
<BudgetCenterView v-else-if="activeView === 'budget'" :current-user="currentUser" />
|
||||
<PoliciesView v-else-if="activeView === 'policies'" @summary-change="knowledgeSummary = $event" />
|
||||
<AuditView v-else-if="activeView === 'audit'" @detail-open-change="auditDetailOpen = $event" />
|
||||
<LogDetailView v-else-if="activeView === 'logs' && logDetailMode" />
|
||||
|
||||
@@ -285,27 +285,16 @@
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="json-risk-meta-item">
|
||||
<span class="json-risk-meta-label">风险分数</span>
|
||||
<span class="json-risk-meta-value">{{ selectedSkill.riskRuleScore ?? '待计算' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="json-risk-meta-item">
|
||||
<span class="json-risk-meta-label">是否上线</span>
|
||||
<span class="json-risk-meta-value">
|
||||
<span class="meta-status-indicator" :class="{ 'is-active': selectedSkill.isOnlineValue }">
|
||||
<span class="indicator-dot"></span>
|
||||
<span class="json-risk-meta-badge" :class="selectedSkill.isOnlineValue ? 'test-passed' : 'test-pending'">
|
||||
{{ selectedSkill.isOnlineLabel || '待上线' }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="json-risk-meta-item">
|
||||
<span class="json-risk-meta-label">规则状态</span>
|
||||
<span class="json-risk-meta-value">
|
||||
<span class="json-risk-meta-badge" :class="selectedSkill.statusTone">
|
||||
{{ selectedSkill.status || '-' }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="json-risk-meta-item">
|
||||
<span class="json-risk-meta-label">测试状态</span>
|
||||
<span class="json-risk-meta-value">
|
||||
@@ -318,10 +307,7 @@
|
||||
<span class="json-risk-meta-label">创建者</span>
|
||||
<span class="json-risk-meta-value">{{ selectedSkill.creator || selectedSkill.publisher || '-' }}</span>
|
||||
</div>
|
||||
<div class="json-risk-meta-item">
|
||||
<span class="json-risk-meta-label">审核人</span>
|
||||
<span class="json-risk-meta-value">{{ selectedSkill.reviewer || '-' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="json-risk-meta-item">
|
||||
<span class="json-risk-meta-label">创建时间</span>
|
||||
<span class="json-risk-meta-value">
|
||||
@@ -829,7 +815,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="picker-filter" :class="{ open: activeFilterPopover === 'owner' }">
|
||||
<div
|
||||
v-if="showOwnerFilter"
|
||||
class="picker-filter"
|
||||
:class="{ open: activeFilterPopover === 'owner' }"
|
||||
>
|
||||
<button
|
||||
class="picker-trigger"
|
||||
type="button"
|
||||
@@ -844,13 +834,13 @@
|
||||
v-if="activeFilterPopover === 'owner'"
|
||||
class="picker-popover"
|
||||
role="dialog"
|
||||
:aria-label="activeType === 'riskRules' ? '选择审核人' : '选择负责人'"
|
||||
aria-label="选择负责人"
|
||||
>
|
||||
<header>
|
||||
<strong>{{ activeType === 'riskRules' ? '选择审核人' : '选择负责人' }}</strong>
|
||||
<strong>选择负责人</strong>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="activeType === 'riskRules' ? '关闭审核人选择' : '关闭负责人选择'"
|
||||
aria-label="关闭负责人选择"
|
||||
@click="closeFilterPopover"
|
||||
>
|
||||
<i class="mdi mdi-close"></i>
|
||||
@@ -871,6 +861,48 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showRiskLevelFilter"
|
||||
class="picker-filter"
|
||||
:class="{ open: activeFilterPopover === 'riskLevel' }"
|
||||
>
|
||||
<button
|
||||
class="picker-trigger"
|
||||
type="button"
|
||||
:aria-expanded="activeFilterPopover === 'riskLevel'"
|
||||
aria-haspopup="dialog"
|
||||
@click="toggleFilterPopover('riskLevel')"
|
||||
>
|
||||
<span class="picker-label">{{ selectedRiskLevelLabel }}</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</button>
|
||||
<div
|
||||
v-if="activeFilterPopover === 'riskLevel'"
|
||||
class="picker-popover"
|
||||
role="dialog"
|
||||
aria-label="选择风险等级"
|
||||
>
|
||||
<header>
|
||||
<strong>选择风险等级</strong>
|
||||
<button type="button" aria-label="关闭风险等级选择" @click="closeFilterPopover">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</header>
|
||||
<div class="picker-option-list">
|
||||
<button
|
||||
v-for="option in riskLevelOptions"
|
||||
:key="option.value || 'all-risk-level'"
|
||||
type="button"
|
||||
class="picker-option"
|
||||
:class="{ active: selectedRiskLevel === option.value }"
|
||||
@click="selectFilter('riskLevel', option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div
|
||||
v-if="showRiskScenarioFilter"
|
||||
@@ -1130,7 +1162,16 @@
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ skill.category }}</td>
|
||||
<td>{{ skill.owner }}</td>
|
||||
<td>
|
||||
<span
|
||||
v-if="skill.usesJsonRiskRule"
|
||||
class="json-risk-meta-badge"
|
||||
:class="skill.riskLevelTone"
|
||||
>
|
||||
{{ skill.riskLevelLabel || '-' }}
|
||||
</span>
|
||||
<template v-else>{{ skill.owner }}</template>
|
||||
</td>
|
||||
<td><span class="scope-pill">{{ skill.scope }}</span></td>
|
||||
<td v-if="showRuntimeColumn">{{ skill.model }}</td>
|
||||
<td v-if="showVersionColumn">{{ skill.versionDisplay || skill.version }}</td>
|
||||
@@ -1389,7 +1430,7 @@
|
||||
<strong :class="{ passed: riskRuleTestPassed }">
|
||||
{{ riskRuleTestPassed ? '当前版本已通过测试确认' : '当前版本尚未确认测试通过' }}
|
||||
</strong>
|
||||
<p>只有保存测试报告的风险规则,才能提交给高级管理人员审核。</p>
|
||||
<p>只有保存测试报告的风险规则,才能提交给高级财务人员审核。</p>
|
||||
</div>
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="budget-action-set">
|
||||
<button class="budget-primary-btn" type="button" @click="openBudgetEditDialog">
|
||||
<button v-if="canEditBudget" class="budget-primary-btn" type="button" @click="openBudgetEditDialog">
|
||||
<i class="mdi mdi-pencil-outline"></i>
|
||||
<span>编辑预算</span>
|
||||
</button>
|
||||
@@ -70,8 +70,8 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="budget-work-grid">
|
||||
<aside class="budget-department-panel">
|
||||
<section class="budget-work-grid" :class="{ 'single-department': !canSwitchDepartments }">
|
||||
<aside v-if="canSwitchDepartments" class="budget-department-panel">
|
||||
<header>
|
||||
<strong>部门切换</strong>
|
||||
</header>
|
||||
@@ -232,7 +232,7 @@
|
||||
<option v-for="quarter in quarters" :key="quarter" :value="quarter">{{ quarter }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="required">
|
||||
<label v-if="canSwitchDepartments" class="required">
|
||||
<span>所属部门</span>
|
||||
<select v-model="budgetEditForm.departmentCode">
|
||||
<option v-for="department in departments" :key="department.code" :value="department.code">
|
||||
@@ -240,27 +240,9 @@
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="required">
|
||||
<span>预算负责人</span>
|
||||
<select v-model="budgetEditForm.budgetOwner">
|
||||
<option>张晓明</option>
|
||||
<option>李娜</option>
|
||||
<option>王凯</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="required">
|
||||
<span>预算版本</span>
|
||||
<select v-model="budgetEditForm.budgetVersion">
|
||||
<option>V1.0(初始版本)</option>
|
||||
<option>V1.1(调整版本)</option>
|
||||
<option>V2.0(发布版本)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="required">
|
||||
<span>预算状态</span>
|
||||
<select v-model="budgetEditForm.budgetStatus">
|
||||
<option v-for="status in statusOptions" :key="status">{{ status }}</option>
|
||||
</select>
|
||||
<label v-else class="required">
|
||||
<span>所属部门</span>
|
||||
<input :value="activeDepartmentName" type="text" disabled />
|
||||
</label>
|
||||
</div>
|
||||
<label class="budget-edit-textarea">
|
||||
@@ -328,20 +310,29 @@
|
||||
<span>添加行</span>
|
||||
</button>
|
||||
<div class="budget-edit-total">
|
||||
<span>合计</span>
|
||||
<strong>{{ budgetEditTotal }}</strong>
|
||||
<span>合计金额</span>
|
||||
<strong>¥ {{ budgetEditTotal }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="budget-edit-foot">
|
||||
<button class="budget-edit-cancel" type="button" @click="closeBudgetEditDialog">取消</button>
|
||||
<button class="budget-edit-draft" type="button" @click="saveBudgetDraft">保存草稿</button>
|
||||
<button class="budget-edit-publish" type="button" @click="publishBudget">保存并发布</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</Transition>
|
||||
<ConfirmDialog
|
||||
:open="confirmDeleteOpen"
|
||||
title="确认删除"
|
||||
content="确定要删除当前预算明细行吗?删除后不可恢复。"
|
||||
confirm-text="确认删除"
|
||||
confirm-tone="danger"
|
||||
confirm-icon="mdi mdi-delete-outline"
|
||||
@close="cancelDeleteRow"
|
||||
@confirm="confirmDeleteRow"
|
||||
/>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -235,7 +235,7 @@
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h3>系统角色分配</h3>
|
||||
<p>为员工分配管理员、财务人员、使用者、高级管理人员等业务角色。</p>
|
||||
<p>为员工分配管理员、财务人员、使用者、高级财务人员、预算监控员等业务角色。</p>
|
||||
</div>
|
||||
<span class="count-badge">{{ roleCount }} 个角色</span>
|
||||
</div>
|
||||
|
||||
@@ -1402,7 +1402,7 @@
|
||||
badge="提交确认"
|
||||
badge-tone="primary"
|
||||
title="确认提交当前费用申请?"
|
||||
description="提交后申请将进入领导审核流程,并同步纳入预算管理口径,请确认关键申请信息和预计费用已经核对无误。"
|
||||
description="提交后申请将进入领导审核流程,请确认关键申请信息和预计费用已经核对无误。"
|
||||
cancel-text="再检查一下"
|
||||
confirm-text="确认提交"
|
||||
busy-text="提交中..."
|
||||
|
||||
@@ -70,7 +70,8 @@ import {
|
||||
import {
|
||||
createDefaultRiskRuleForm,
|
||||
RISK_RULE_BUSINESS_STAGE_OPTIONS,
|
||||
RISK_RULE_EXPENSE_CATEGORY_OPTIONS
|
||||
RISK_RULE_EXPENSE_CATEGORY_OPTIONS,
|
||||
RISK_RULE_LEVEL_OPTIONS
|
||||
} from './auditViewRiskRuleModel.js'
|
||||
|
||||
export default {
|
||||
@@ -99,6 +100,7 @@ export default {
|
||||
const activeFilterPopover = ref('')
|
||||
const selectedDomain = ref('')
|
||||
const selectedOwner = ref('')
|
||||
const selectedRiskLevel = ref('')
|
||||
const selectedStatus = ref('')
|
||||
const selectedRiskScenario = ref('')
|
||||
const selectedOnlineState = ref('')
|
||||
@@ -345,20 +347,29 @@ export default {
|
||||
const ownerOptions = computed(() => {
|
||||
const uniqueOwners = [...new Set(currentAssets.value.map((item) => item.owner).filter(Boolean))]
|
||||
return [
|
||||
{ value: '', label: activeType.value === 'riskRules' ? '全部审核人' : '全部负责人' },
|
||||
{ value: '', label: '全部负责人' },
|
||||
...uniqueOwners.map((value) => ({
|
||||
value,
|
||||
label: value
|
||||
}))
|
||||
]
|
||||
})
|
||||
const riskLevelOptions = computed(() => [
|
||||
{ value: '', label: '全部风险等级' },
|
||||
...RISK_RULE_LEVEL_OPTIONS
|
||||
])
|
||||
const selectedDomainLabel = computed(
|
||||
() => domainOptions.value.find((item) => item.value === selectedDomain.value)?.label || '业务域'
|
||||
)
|
||||
const selectedOwnerLabel = computed(
|
||||
() =>
|
||||
ownerOptions.value.find((item) => item.value === selectedOwner.value)?.label ||
|
||||
(activeType.value === 'riskRules' ? '审核人' : '负责人')
|
||||
'负责人'
|
||||
)
|
||||
const selectedRiskLevelLabel = computed(
|
||||
() =>
|
||||
riskLevelOptions.value.find((item) => item.value === selectedRiskLevel.value)?.label ||
|
||||
'风险等级'
|
||||
)
|
||||
const selectedStatusLabel = computed(
|
||||
() => STATUS_OPTIONS.find((item) => item.value === selectedStatus.value)?.label || '状态'
|
||||
@@ -366,6 +377,8 @@ export default {
|
||||
const showRiskScenarioFilter = computed(() =>
|
||||
['financialRules', 'riskRules'].includes(activeType.value)
|
||||
)
|
||||
const showOwnerFilter = computed(() => activeType.value !== 'riskRules')
|
||||
const showRiskLevelFilter = computed(() => activeType.value === 'riskRules')
|
||||
const showStatusFilter = computed(() => true)
|
||||
const showOnlineFilter = computed(() => false)
|
||||
const showEnabledFilter = computed(() => false)
|
||||
@@ -402,8 +415,11 @@ export default {
|
||||
if (showEnabledFilter.value && selectedEnabledState.value) {
|
||||
tokens.push(`是否启用:${selectedEnabledStateLabel.value}`)
|
||||
}
|
||||
if (selectedOwner.value) {
|
||||
tokens.push(`${activeType.value === 'riskRules' ? '审核人' : '负责人'}:${selectedOwner.value}`)
|
||||
if (showOwnerFilter.value && selectedOwner.value) {
|
||||
tokens.push(`负责人:${selectedOwner.value}`)
|
||||
}
|
||||
if (showRiskLevelFilter.value && selectedRiskLevel.value) {
|
||||
tokens.push(`风险等级:${selectedRiskLevelLabel.value}`)
|
||||
}
|
||||
if (keyword.value.trim()) {
|
||||
tokens.push(`搜索:${keyword.value.trim()}`)
|
||||
@@ -415,7 +431,8 @@ export default {
|
||||
const hasFilters = activeFilterTokens.value.length > 0
|
||||
const supportedFilters = [
|
||||
'业务域',
|
||||
activeType.value === 'riskRules' ? '审核人' : '负责人',
|
||||
...(showOwnerFilter.value ? ['负责人'] : []),
|
||||
...(showRiskLevelFilter.value ? ['风险等级'] : []),
|
||||
...(showRiskScenarioFilter.value ? ['使用场景'] : []),
|
||||
...(showStatusFilter.value ? ['状态'] : []),
|
||||
...(showOnlineFilter.value ? ['是否上线'] : []),
|
||||
@@ -480,7 +497,7 @@ export default {
|
||||
return '当前为页面预览态,暂不执行真实审核和上线。'
|
||||
}
|
||||
if (!canManageSelected.value) {
|
||||
return '仅高级管理人员可执行审核和上线。'
|
||||
return '仅高级财务人员可执行审核和上线。'
|
||||
}
|
||||
if (!isDisplayingWorkingVersion.value) {
|
||||
return '请先切回当前工作版本,再执行审核或上线。'
|
||||
@@ -498,6 +515,7 @@ export default {
|
||||
keyword: keyword.value,
|
||||
selectedDomain: selectedDomain.value,
|
||||
selectedOwner: selectedOwner.value,
|
||||
selectedRiskLevel: selectedRiskLevel.value,
|
||||
selectedStatus: selectedStatus.value,
|
||||
selectedRiskScenario: selectedRiskScenario.value,
|
||||
selectedOnlineState: selectedOnlineState.value,
|
||||
@@ -548,6 +566,7 @@ export default {
|
||||
keyword.value = ''
|
||||
selectedDomain.value = ''
|
||||
selectedOwner.value = ''
|
||||
selectedRiskLevel.value = ''
|
||||
selectedStatus.value = ''
|
||||
selectedRiskScenario.value = ''
|
||||
selectedOnlineState.value = ''
|
||||
@@ -579,6 +598,9 @@ export default {
|
||||
if (name === 'owner') {
|
||||
selectedOwner.value = value
|
||||
}
|
||||
if (name === 'riskLevel') {
|
||||
selectedRiskLevel.value = value
|
||||
}
|
||||
if (name === 'status') {
|
||||
selectedStatus.value = value
|
||||
}
|
||||
@@ -1832,22 +1854,27 @@ export default {
|
||||
detailError,
|
||||
selectedDomain,
|
||||
selectedOwner,
|
||||
selectedRiskLevel,
|
||||
selectedStatus,
|
||||
selectedRiskScenario,
|
||||
selectedOnlineState,
|
||||
selectedEnabledState,
|
||||
selectedDomainLabel,
|
||||
selectedOwnerLabel,
|
||||
selectedRiskLevelLabel,
|
||||
selectedStatusLabel,
|
||||
selectedRiskScenarioLabel,
|
||||
selectedOnlineStateLabel,
|
||||
selectedEnabledStateLabel,
|
||||
showRiskScenarioFilter,
|
||||
showOwnerFilter,
|
||||
showRiskLevelFilter,
|
||||
showStatusFilter,
|
||||
showOnlineFilter,
|
||||
showEnabledFilter,
|
||||
domainOptions,
|
||||
ownerOptions,
|
||||
riskLevelOptions,
|
||||
statusOptions: STATUS_OPTIONS,
|
||||
riskScenarioOptions: RISK_SCENARIO_OPTIONS,
|
||||
onlineStateOptions: ONLINE_STATE_OPTIONS,
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import { createBudgetAllocation, fetchBudgetSummary } from '../../services/budgets.js'
|
||||
import { fetchEmployeeMeta } from '../../services/employees.js'
|
||||
import {
|
||||
canEditBudgetCenter,
|
||||
canSwitchBudgetDepartments,
|
||||
isBudgetMonitorUser,
|
||||
isExecutiveUser
|
||||
} from '../../utils/accessControl.js'
|
||||
import {
|
||||
BUDGET_CONTROL_ACTION_OPTIONS,
|
||||
BUDGET_EXPENSE_TYPE_OPTIONS,
|
||||
BUDGET_QUARTER_OPTIONS,
|
||||
BUDGET_STATUS_OPTIONS,
|
||||
BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS,
|
||||
BUDGET_WARNING_OPTIONS,
|
||||
BUDGET_YEAR_OPTIONS,
|
||||
buildBudgetOntologyContext,
|
||||
@@ -25,16 +33,9 @@ const FALLBACK_DEPARTMENTS = [
|
||||
|
||||
const EXPENSE_BUDGET_SEED = {
|
||||
travel: { total: 600000, used: 242300, occupied: 150000, warning: 80, action: '提醒' },
|
||||
hotel: { total: 360000, used: 139800, occupied: 84000, warning: 80, action: '提醒' },
|
||||
transport: { total: 280000, used: 104600, occupied: 56000, warning: 75, action: '提醒' },
|
||||
meal: { total: 420000, used: 168200, occupied: 118000, warning: 80, action: '管控' },
|
||||
meeting: { total: 260000, used: 84500, occupied: 52000, warning: 75, action: '提醒' },
|
||||
marketing: { total: 500000, used: 186400, occupied: 120000, warning: 80, action: '提醒' },
|
||||
office: { total: 300000, used: 68500, occupied: 60000, warning: 70, action: '正常' },
|
||||
training: { total: 200000, used: 42300, occupied: 20000, warning: 70, action: '正常' },
|
||||
software: { total: 600000, used: 249500, occupied: 240800, warning: 80, action: '管控' },
|
||||
communication: { total: 120000, used: 38600, occupied: 18000, warning: 70, action: '正常' },
|
||||
welfare: { total: 240000, used: 96500, occupied: 42000, warning: 75, action: '提醒' }
|
||||
meal: { total: 420000, used: 168200, occupied: 118000, warning: 80, action: '管控' },
|
||||
office: { total: 180000, used: 68500, occupied: 32000, warning: 70, action: '正常' }
|
||||
}
|
||||
|
||||
const DEFAULT_EXPENSE_BUDGET = {
|
||||
@@ -45,7 +46,7 @@ const DEFAULT_EXPENSE_BUDGET = {
|
||||
action: '正常'
|
||||
}
|
||||
|
||||
const EXPENSE_BLUEPRINTS = BUDGET_EXPENSE_TYPE_OPTIONS.map((option) => ({
|
||||
const EXPENSE_BLUEPRINTS = BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS.map((option) => ({
|
||||
...DEFAULT_EXPENSE_BUDGET,
|
||||
...EXPENSE_BUDGET_SEED[option.value],
|
||||
budgetSubjectCode: option.value,
|
||||
@@ -68,6 +69,67 @@ const parseBudgetAmount = (value) => Number(String(value || '').replace(/[^\d.-]
|
||||
const makeBudgetRowId = () => `budget-row-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
const BUDGET_PAGE_SIZE_OPTIONS = [5, 10]
|
||||
|
||||
const normalizePeriodKey = (year, quarter) => {
|
||||
const normalizedYear = String(year || '').replace(/[^\d]/g, '') || '2026'
|
||||
const normalizedQuarter = BUDGET_QUARTER_OPTIONS.includes(String(quarter || '').trim())
|
||||
? String(quarter || '').trim()
|
||||
: BUDGET_QUARTER_OPTIONS[0]
|
||||
return `${normalizedYear}${normalizedQuarter}`
|
||||
}
|
||||
|
||||
const parsePercent = (value, fallback = 80) => {
|
||||
const parsed = Number(String(value || '').replace(/[^\d.-]/g, ''))
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
const resolveControlActionCode = (value) => {
|
||||
if (value === BUDGET_CONTROL_ACTION_OPTIONS[0]) return 'allow'
|
||||
if (value === BUDGET_CONTROL_ACTION_OPTIONS[1]) return 'warn'
|
||||
if (value === BUDGET_CONTROL_ACTION_OPTIONS[2]) return 'block'
|
||||
return String(value || '').trim() || 'block'
|
||||
}
|
||||
|
||||
const resolveControlActionLabel = (value) => {
|
||||
const normalized = String(value || '').trim().toLowerCase()
|
||||
if (normalized === 'allow') return BUDGET_CONTROL_ACTION_OPTIONS[0]
|
||||
if (normalized === 'warn') return BUDGET_CONTROL_ACTION_OPTIONS[1]
|
||||
if (normalized === 'block') return BUDGET_CONTROL_ACTION_OPTIONS[2]
|
||||
return value || BUDGET_CONTROL_ACTION_OPTIONS[2]
|
||||
}
|
||||
|
||||
function normalizeBudgetAllocationRow(item) {
|
||||
const balance = item?.balance || {}
|
||||
const totalAmount = Number(balance.total_amount ?? item?.original_amount ?? 0)
|
||||
const usedAmount = Number(balance.consumed_amount ?? 0)
|
||||
const occupiedAmount = Number(balance.reserved_amount ?? 0)
|
||||
const leftAmount = Number(balance.available_amount ?? 0)
|
||||
const rate = Number(balance.usage_rate ?? 0)
|
||||
const warning = parsePercent(item?.warning_threshold, 80)
|
||||
const budgetSubjectCode = String(item?.subject_code || '').trim()
|
||||
const expenseType = item?.subject_name || resolveBudgetExpenseTypeLabel(budgetSubjectCode, budgetSubjectCode)
|
||||
|
||||
return {
|
||||
allocationId: item?.id || '',
|
||||
budgetNo: item?.budget_no || '',
|
||||
budgetSubjectCode,
|
||||
expenseType,
|
||||
totalAmount,
|
||||
usedAmount,
|
||||
occupiedAmount,
|
||||
leftAmount,
|
||||
rate,
|
||||
rateTone: rate >= warning ? 'danger' : rate >= warning - 12 ? 'warn' : 'ok',
|
||||
warning,
|
||||
warningTone: warning >= 80 ? 'budget-warning-red' : 'budget-warning-yellow',
|
||||
warningLine: `${warning}%`,
|
||||
action: resolveControlActionLabel(item?.control_action),
|
||||
total: currency(totalAmount),
|
||||
used: currency(usedAmount),
|
||||
occupied: currency(occupiedAmount),
|
||||
left: currency(leftAmount)
|
||||
}
|
||||
}
|
||||
|
||||
function buildDepartmentRows(departmentCode) {
|
||||
const seed = Array.from(String(departmentCode || '')).reduce(
|
||||
(sum, char) => sum + char.charCodeAt(0),
|
||||
@@ -119,10 +181,17 @@ function buildTrendData(rows) {
|
||||
|
||||
export default {
|
||||
name: 'BudgetCenterView',
|
||||
components: {
|
||||
BudgetTrendChart
|
||||
props: {
|
||||
currentUser: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
setup() {
|
||||
components: {
|
||||
BudgetTrendChart,
|
||||
ConfirmDialog
|
||||
},
|
||||
setup(props) {
|
||||
const departments = ref(FALLBACK_DEPARTMENTS)
|
||||
const activeDepartmentCode = ref(FALLBACK_DEPARTMENTS[0].code)
|
||||
const departmentKeyword = ref('')
|
||||
@@ -134,6 +203,10 @@ export default {
|
||||
})
|
||||
const budgetPage = ref(1)
|
||||
const budgetPageSize = ref(5)
|
||||
const budgetRows = ref([])
|
||||
const budgetLoading = ref(false)
|
||||
const budgetError = ref('')
|
||||
const budgetSaving = ref(false)
|
||||
const budgetEditOpen = ref(false)
|
||||
const budgetEditForm = ref({
|
||||
budgetYear: '2026',
|
||||
@@ -147,15 +220,24 @@ export default {
|
||||
budgetDescription: ''
|
||||
})
|
||||
const budgetEditRows = ref([])
|
||||
const canEditBudget = computed(() => canEditBudgetCenter(props.currentUser))
|
||||
const canSwitchDepartments = computed(() => canSwitchBudgetDepartments(props.currentUser))
|
||||
const isDepartmentBudgetMonitor = computed(
|
||||
() => isBudgetMonitorUser(props.currentUser) && !canSwitchDepartments.value && !isExecutiveUser(props.currentUser)
|
||||
)
|
||||
|
||||
const activeDepartment = computed(() =>
|
||||
departments.value.find((item) => item.code === activeDepartmentCode.value) || departments.value[0]
|
||||
)
|
||||
|
||||
const activeDepartmentName = computed(() => activeDepartment.value?.name || '市场部')
|
||||
const departmentRows = computed(() =>
|
||||
buildDepartmentRows(activeDepartment.value?.code || activeDepartmentCode.value)
|
||||
const currentUserDepartmentName = computed(() =>
|
||||
String(props.currentUser?.departmentName || props.currentUser?.department || '').trim()
|
||||
)
|
||||
const currentUserCostCenter = computed(() =>
|
||||
String(props.currentUser?.costCenter || props.currentUser?.cost_center || '').trim()
|
||||
)
|
||||
const departmentRows = computed(() => budgetRows.value)
|
||||
const filteredBudgetRows = computed(() =>
|
||||
departmentRows.value
|
||||
.filter((row) => filters.value.expenseType === '全部' || row.expenseType === filters.value.expenseType)
|
||||
@@ -271,7 +353,13 @@ export default {
|
||||
)
|
||||
|
||||
function buildEditableRows() {
|
||||
return departmentRows.value.map((row) => ({
|
||||
const rows = departmentRows.value.length ? departmentRows.value : EXPENSE_BLUEPRINTS.map((row) => ({
|
||||
...row,
|
||||
totalAmount: row.total || 0,
|
||||
warning: row.warning || 80,
|
||||
action: row.action || BUDGET_CONTROL_ACTION_OPTIONS[2]
|
||||
}))
|
||||
return rows.map((row) => ({
|
||||
id: makeBudgetRowId(),
|
||||
budgetSubject: row.expenseType,
|
||||
budgetSubjectCode: row.budgetSubjectCode || '',
|
||||
@@ -285,8 +373,8 @@ export default {
|
||||
function resolveNextExpenseTypeOption() {
|
||||
const usedCodes = new Set(budgetEditRows.value.map((row) => row.budgetSubjectCode))
|
||||
return (
|
||||
BUDGET_EXPENSE_TYPE_OPTIONS.find((item) => !usedCodes.has(item.value)) ||
|
||||
BUDGET_EXPENSE_TYPE_OPTIONS[0]
|
||||
BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS.find((item) => !usedCodes.has(item.value)) ||
|
||||
BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS[0]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -295,6 +383,7 @@ export default {
|
||||
}
|
||||
|
||||
function openBudgetEditDialog() {
|
||||
if (!canEditBudget.value) return
|
||||
const department = activeDepartment.value
|
||||
const budgetPeriod = formatBudgetPeriod(filters.value.year, filters.value.quarter)
|
||||
budgetEditForm.value = {
|
||||
@@ -329,9 +418,26 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
const confirmDeleteOpen = ref(false)
|
||||
const rowToDelete = ref(null)
|
||||
|
||||
function removeBudgetDetailRow(rowId) {
|
||||
if (budgetEditRows.value.length <= 1) return
|
||||
budgetEditRows.value = budgetEditRows.value.filter((row) => row.id !== rowId)
|
||||
rowToDelete.value = rowId
|
||||
confirmDeleteOpen.value = true
|
||||
}
|
||||
|
||||
function confirmDeleteRow() {
|
||||
if (rowToDelete.value !== null) {
|
||||
budgetEditRows.value = budgetEditRows.value.filter((row) => row.id !== rowToDelete.value)
|
||||
rowToDelete.value = null
|
||||
}
|
||||
confirmDeleteOpen.value = false
|
||||
}
|
||||
|
||||
function cancelDeleteRow() {
|
||||
rowToDelete.value = null
|
||||
confirmDeleteOpen.value = false
|
||||
}
|
||||
|
||||
function goToBudgetPage(page) {
|
||||
@@ -342,14 +448,68 @@ export default {
|
||||
goToBudgetPage(currentBudgetPage.value + direction)
|
||||
}
|
||||
|
||||
function saveBudgetDraft() {
|
||||
budgetEditForm.value.budgetStatus = '编制中'
|
||||
closeBudgetEditDialog()
|
||||
function buildBudgetPayloads(status) {
|
||||
const department = activeDepartment.value || {}
|
||||
return budgetEditRows.value.map((row) => ({
|
||||
fiscal_year: Number(String(budgetEditForm.value.budgetYear || filters.value.year || '2026').replace(/[^\d]/g, '')),
|
||||
period_type: 'quarter',
|
||||
period_key: normalizePeriodKey(
|
||||
budgetEditForm.value.budgetYear || filters.value.year,
|
||||
budgetEditForm.value.budgetQuarter || filters.value.quarter
|
||||
),
|
||||
department_id: department.id || null,
|
||||
department_name: department.name || '',
|
||||
cost_center: budgetEditForm.value.costCenter || department.costCenter || '',
|
||||
project_code: '',
|
||||
subject_code: row.budgetSubjectCode || '',
|
||||
subject_name: row.budgetSubject || resolveBudgetExpenseTypeLabel(row.budgetSubjectCode, row.budgetSubject),
|
||||
original_amount: parseBudgetAmount(row.budgetAmount),
|
||||
warning_threshold: parsePercent(row.warningThreshold, 80),
|
||||
control_action: resolveControlActionCode(row.controlAction),
|
||||
description: budgetEditForm.value.budgetDescription || status
|
||||
}))
|
||||
}
|
||||
|
||||
function publishBudget() {
|
||||
budgetEditForm.value.budgetStatus = '已发布'
|
||||
closeBudgetEditDialog()
|
||||
async function saveBudgetRows(status) {
|
||||
if (!canEditBudget.value) return
|
||||
budgetSaving.value = true
|
||||
try {
|
||||
const payloads = buildBudgetPayloads(status)
|
||||
for (const payload of payloads) {
|
||||
await createBudgetAllocation(payload)
|
||||
}
|
||||
await loadBudgetData()
|
||||
closeBudgetEditDialog()
|
||||
} finally {
|
||||
budgetSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function resolveScopedDepartments(options) {
|
||||
if (!isDepartmentBudgetMonitor.value) {
|
||||
return options
|
||||
}
|
||||
|
||||
const userDepartment = currentUserDepartmentName.value
|
||||
const userCostCenter = currentUserCostCenter.value
|
||||
const scoped = options.filter((item) => {
|
||||
if (userCostCenter && item.costCenter === userCostCenter) return true
|
||||
return userDepartment && item.name === userDepartment
|
||||
})
|
||||
|
||||
if (scoped.length) {
|
||||
return scoped
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: '',
|
||||
code: userCostCenter || userDepartment || 'CURRENT-DEPARTMENT',
|
||||
name: userDepartment || '当前部门',
|
||||
costCenter: userCostCenter
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
async function loadDepartments() {
|
||||
@@ -359,22 +519,53 @@ export default {
|
||||
const nextDepartments = options
|
||||
.filter((item) => item?.code && item?.name)
|
||||
.map((item) => ({
|
||||
id: String(item.id || ''),
|
||||
code: String(item.code),
|
||||
name: String(item.name),
|
||||
costCenter: String(item.costCenter || '')
|
||||
}))
|
||||
const scopedDepartments = resolveScopedDepartments(nextDepartments)
|
||||
|
||||
if (nextDepartments.length) {
|
||||
departments.value = nextDepartments
|
||||
if (!nextDepartments.some((item) => item.code === activeDepartmentCode.value)) {
|
||||
activeDepartmentCode.value = nextDepartments[0].code
|
||||
if (scopedDepartments.length) {
|
||||
departments.value = scopedDepartments
|
||||
if (!scopedDepartments.some((item) => item.code === activeDepartmentCode.value)) {
|
||||
activeDepartmentCode.value = scopedDepartments[0].code
|
||||
}
|
||||
}
|
||||
await loadBudgetData()
|
||||
} catch (error) {
|
||||
console.warn('Failed to load budget departments from employee meta:', error)
|
||||
await loadBudgetData()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBudgetData() {
|
||||
const department = activeDepartment.value || {}
|
||||
budgetLoading.value = true
|
||||
budgetError.value = ''
|
||||
try {
|
||||
const payload = await fetchBudgetSummary({
|
||||
year: filters.value.year,
|
||||
period: normalizePeriodKey(filters.value.year, filters.value.quarter),
|
||||
department_id: department.id || '',
|
||||
cost_center: department.costCenter || ''
|
||||
})
|
||||
const allocations = Array.isArray(payload?.allocations) ? payload.allocations : []
|
||||
budgetRows.value = allocations.map(normalizeBudgetAllocationRow)
|
||||
} catch (error) {
|
||||
budgetError.value = error?.message || 'Failed to load budget data'
|
||||
budgetRows.value = []
|
||||
console.warn('Failed to load budget data:', error)
|
||||
} finally {
|
||||
budgetLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function publishBudgetAction() {
|
||||
budgetEditForm.value.budgetStatus = BUDGET_STATUS_OPTIONS[1]
|
||||
await saveBudgetRows('published')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadDepartments()
|
||||
})
|
||||
@@ -393,6 +584,13 @@ export default {
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
[activeDepartmentCode, () => filters.value.year, () => filters.value.quarter],
|
||||
() => {
|
||||
void loadBudgetData()
|
||||
}
|
||||
)
|
||||
|
||||
watch(totalBudgetPages, (pages) => {
|
||||
if (budgetPage.value > pages) {
|
||||
budgetPage.value = pages
|
||||
@@ -407,25 +605,32 @@ export default {
|
||||
budgetEditOpen,
|
||||
budgetEditRows,
|
||||
budgetEditTotal,
|
||||
budgetError,
|
||||
budgetLoading,
|
||||
budgetMetrics,
|
||||
budgetOntologyContext,
|
||||
budgetPage: currentBudgetPage,
|
||||
budgetPageNumbers,
|
||||
budgetPageSize,
|
||||
budgetPageSizeOptions: BUDGET_PAGE_SIZE_OPTIONS,
|
||||
canEditBudget,
|
||||
canSwitchDepartments,
|
||||
closeBudgetEditDialog,
|
||||
controlActionOptions: BUDGET_CONTROL_ACTION_OPTIONS,
|
||||
changeBudgetPage,
|
||||
departmentKeyword,
|
||||
departments,
|
||||
expenseTypeOptions: BUDGET_EXPENSE_TYPE_OPTIONS,
|
||||
expenseTypeOptions: BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS,
|
||||
expenseTypes: ['全部', ...EXPENSE_BLUEPRINTS.map((item) => item.expenseType)],
|
||||
filters,
|
||||
openBudgetEditDialog,
|
||||
quarters: BUDGET_QUARTER_OPTIONS,
|
||||
publishBudget,
|
||||
addBudgetDetailRow,
|
||||
removeBudgetDetailRow,
|
||||
saveBudgetDraft,
|
||||
confirmDeleteOpen,
|
||||
confirmDeleteRow,
|
||||
cancelDeleteRow,
|
||||
publishBudget: publishBudgetAction,
|
||||
statusOptions: BUDGET_STATUS_OPTIONS,
|
||||
statuses: ['全部', '正常', '预警', '管控'],
|
||||
syncBudgetRowSubject,
|
||||
|
||||
@@ -39,20 +39,20 @@ const FALLBACK_ROLE_OPTIONS = [
|
||||
{
|
||||
id: 'executive',
|
||||
code: 'executive',
|
||||
label: '高级管理人员',
|
||||
desc: '可以查看跨部门数据看板与关键审批结果。'
|
||||
label: '高级财务人员',
|
||||
desc: '可以查看跨部门预算、经营看板与关键财务审批结果。'
|
||||
},
|
||||
{
|
||||
id: 'auditor',
|
||||
code: 'auditor',
|
||||
label: '审计观察员',
|
||||
desc: '可以查看变更记录和权限调整历史。'
|
||||
id: 'budget_monitor',
|
||||
code: 'budget_monitor',
|
||||
label: '预算监控员',
|
||||
desc: '可以查看本部门预算执行、预警和占用情况。'
|
||||
},
|
||||
{
|
||||
id: 'user',
|
||||
code: 'user',
|
||||
label: '使用者',
|
||||
desc: '可以发起报销、查看个人单据和使用 AI 助手。'
|
||||
desc: '可以发起费用申请、报销、查看个人单据和使用 AI 助手。'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -1633,7 +1633,7 @@ export default {
|
||||
toast(
|
||||
isArchivedRequest.value
|
||||
? '已归档单据不能删除,只有高级管理员可以执行删除。'
|
||||
: '当前单据已进入流程,只有高级管理人员可以删除。'
|
||||
: '当前单据已进入流程,只有高级财务人员可以删除。'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export const RULE_TABLE_COLUMNS = {
|
||||
|
||||
export const RISK_RULE_TABLE_COLUMNS = {
|
||||
...RULE_TABLE_COLUMNS,
|
||||
owner: '审核人',
|
||||
owner: '风险等级',
|
||||
status: '状态',
|
||||
metric: '创建者',
|
||||
updatedAt: '创建时间'
|
||||
@@ -80,7 +80,7 @@ export const TAB_META = {
|
||||
typeLabel: '风险规则',
|
||||
createButtonLabel: '新建风险规则',
|
||||
hintText: '仅展示平台风险规则;适用场景按差旅、发票、餐饮招待等分类,可用「使用场景」筛选。',
|
||||
searchPlaceholder: '搜索风险规则名称、编码或审核人',
|
||||
searchPlaceholder: '搜索风险规则名称、编码、风险等级或创建者',
|
||||
tableColumns: RISK_RULE_TABLE_COLUMNS,
|
||||
showRuntimeColumn: false,
|
||||
showVersionColumn: false,
|
||||
@@ -254,10 +254,12 @@ export const RISK_SCENARIO_OPTIONS = [
|
||||
{ value: '住宿费', label: '住宿费' },
|
||||
{ value: '交通费', label: '交通费' },
|
||||
{ value: '业务招待费', label: '业务招待费' },
|
||||
{ value: '市场推广费', label: '市场推广费' },
|
||||
{ value: '会务费', label: '会务费' },
|
||||
{ value: '办公用品费', label: '办公用品费' },
|
||||
{ value: '培训费', label: '培训费' },
|
||||
{ value: '通讯费', label: '通讯费' },
|
||||
{ value: '软件服务费', label: '软件服务费' },
|
||||
{ value: '通信费', label: '通信费' },
|
||||
{ value: '福利费', label: '福利费' },
|
||||
{ value: '差旅', label: '差旅' },
|
||||
{ value: '发票', label: '发票' },
|
||||
|
||||
@@ -35,6 +35,20 @@ import {
|
||||
resolveRiskRuleSeverityLabel
|
||||
} from './auditViewRiskRuleModel.js'
|
||||
|
||||
const EXPENSE_TYPE_SCENARIO_LABELS = {
|
||||
travel: '差旅费',
|
||||
hotel: '住宿费',
|
||||
transport: '交通费',
|
||||
meal: '业务招待费',
|
||||
meeting: '会务费',
|
||||
marketing: '市场推广费',
|
||||
office: '办公用品费',
|
||||
training: '培训费',
|
||||
software: '软件服务费',
|
||||
communication: '通信费',
|
||||
welfare: '福利费'
|
||||
}
|
||||
|
||||
export {
|
||||
DETAIL_TITLES,
|
||||
DOMAIN_LABELS,
|
||||
@@ -375,7 +389,48 @@ export function inferRiskCategoryFromCode(code) {
|
||||
|
||||
export function normalizeRiskScenarioCategory(value) {
|
||||
const normalized = normalizeText(value)
|
||||
return RISK_SCENARIO_VALUES.has(normalized) ? normalized : ''
|
||||
const alias = normalized === '通讯费' ? '通信费' : normalized
|
||||
return RISK_SCENARIO_VALUES.has(alias) ? alias : ''
|
||||
}
|
||||
|
||||
export function normalizeExpenseTypeScenarioLabels(value) {
|
||||
const values = Array.isArray(value) ? value : normalizeText(value) ? [value] : []
|
||||
const labels = []
|
||||
const seen = new Set()
|
||||
|
||||
values.forEach((item) => {
|
||||
const key = normalizeText(item).toLowerCase()
|
||||
const label = EXPENSE_TYPE_SCENARIO_LABELS[key] || normalizeRiskScenarioCategory(item)
|
||||
if (!label || seen.has(label)) {
|
||||
return
|
||||
}
|
||||
seen.add(label)
|
||||
labels.push(label)
|
||||
})
|
||||
|
||||
return labels
|
||||
}
|
||||
|
||||
export function readRiskRuleExpenseTypes(source) {
|
||||
const configJson = readConfigJson(source)
|
||||
const metadata = isPlainObject(configJson.metadata) ? configJson.metadata : {}
|
||||
const appliesTo = isPlainObject(configJson.applies_to) ? configJson.applies_to : {}
|
||||
const values = []
|
||||
|
||||
;[
|
||||
configJson.expense_types,
|
||||
metadata.expense_types,
|
||||
appliesTo.expense_types,
|
||||
source?.expense_types
|
||||
].forEach((item) => {
|
||||
if (Array.isArray(item)) {
|
||||
values.push(...item)
|
||||
} else if (normalizeText(item)) {
|
||||
values.push(item)
|
||||
}
|
||||
})
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
export function readScenarioItems(source) {
|
||||
@@ -390,6 +445,11 @@ export function readScenarioItems(source) {
|
||||
|
||||
export function resolveRiskRuleCategory(source) {
|
||||
const configJson = readConfigJson(source)
|
||||
const expenseScenarioLabels = normalizeExpenseTypeScenarioLabels(readRiskRuleExpenseTypes(source))
|
||||
if (expenseScenarioLabels.length) {
|
||||
return formatScenarioList(expenseScenarioLabels)
|
||||
}
|
||||
|
||||
const expenseCategoryLabel =
|
||||
normalizeText(configJson.expense_category_label) ||
|
||||
normalizeText(configJson.metadata?.expense_category_label) ||
|
||||
@@ -471,23 +531,43 @@ export function inferFinancialRuleCategory(source) {
|
||||
if (/(office|material|suppl|办公|物料|耗材)/i.test(haystack)) {
|
||||
return '办公物料'
|
||||
}
|
||||
if (/(communication|telecom|phone|expense_standard|费用科目|费用标准|通信|通讯|手机|补贴|福利|科目)/i.test(haystack)) {
|
||||
if (/(communication|telecom|phone|通信|通讯|手机)/i.test(haystack)) {
|
||||
return '通信费'
|
||||
}
|
||||
if (/(welfare|福利)/i.test(haystack)) {
|
||||
return '福利费'
|
||||
}
|
||||
if (/(expense_standard|费用科目|费用标准|补贴|科目)/i.test(haystack)) {
|
||||
return '费用科目'
|
||||
}
|
||||
return '通用'
|
||||
}
|
||||
|
||||
export function resolveRuleScenarioCategory(source, tabId = '') {
|
||||
const resolvedTabId = tabId || resolveRuleTabId(source)
|
||||
if (resolvedTabId === 'riskRules' || isJsonRiskRuleSource(source)) {
|
||||
return resolveRiskRuleCategory(source)
|
||||
}
|
||||
if (resolvedTabId === 'financialRules') {
|
||||
return inferFinancialRuleCategory(source)
|
||||
const scenarioList = resolveRuleScenarioList(source, tabId)
|
||||
if (scenarioList.length) {
|
||||
return formatScenarioList(scenarioList)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function resolveRuleScenarioList(source, tabId = '') {
|
||||
const resolvedTabId = tabId || resolveRuleTabId(source)
|
||||
if (resolvedTabId === 'riskRules' || isJsonRiskRuleSource(source)) {
|
||||
const expenseScenarioLabels = normalizeExpenseTypeScenarioLabels(readRiskRuleExpenseTypes(source))
|
||||
if (expenseScenarioLabels.length) {
|
||||
return expenseScenarioLabels
|
||||
}
|
||||
const riskCategory = resolveRiskRuleCategory(source)
|
||||
return riskCategory ? [riskCategory] : []
|
||||
}
|
||||
if (resolvedTabId === 'financialRules') {
|
||||
const financialCategory = inferFinancialRuleCategory(source)
|
||||
return financialCategory ? [financialCategory] : []
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function buildRiskListSubtitle(text, maxLength = 42) {
|
||||
const normalized = normalizeText(text)
|
||||
if (!normalized) {
|
||||
@@ -950,6 +1030,18 @@ export function buildListItem(asset) {
|
||||
const businessStage = usesJsonRiskRule
|
||||
? resolveRiskRuleBusinessStage(asset)
|
||||
: { value: '', label: '' }
|
||||
const ruleScenarioList = typeKey === 'rules' ? resolveRuleScenarioList(asset, tabId) : []
|
||||
const riskScoreLevel = usesJsonRiskRule
|
||||
? resolveRiskRuleScoreLevel(asset.config_json, asset.config_json)
|
||||
: ''
|
||||
const riskLevelValue = usesJsonRiskRule
|
||||
? riskScoreLevel || resolveRiskRuleSeverity(asset.config_json)
|
||||
: ''
|
||||
const riskLevelLabel = usesJsonRiskRule
|
||||
? riskScoreLevel
|
||||
? resolveRiskRuleScoreLabel(asset.config_json, asset.config_json) || resolveRiskRuleSeverityLabel(asset.config_json)
|
||||
: resolveRiskRuleSeverityLabel(asset.config_json)
|
||||
: ''
|
||||
|
||||
return {
|
||||
id: asset.id,
|
||||
@@ -966,12 +1058,16 @@ export function buildListItem(asset) {
|
||||
summary: listSubtitle,
|
||||
listSubtitle,
|
||||
category: resolveDomainLabel(asset.domain),
|
||||
owner: isRiskRule ? reviewer : asset.owner,
|
||||
owner: isRiskRule ? creator : asset.owner,
|
||||
reviewer,
|
||||
scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(asset.scenario_json),
|
||||
riskCategory: ruleScenarioCategory,
|
||||
scenarioList: ruleScenarioList,
|
||||
businessStageValue: businessStage.value,
|
||||
businessStageLabel: businessStage.label,
|
||||
riskLevelValue,
|
||||
riskLevelLabel,
|
||||
riskLevelTone: riskLevelValue,
|
||||
model: buildRowRuntime(asset, typeKey),
|
||||
version: workingVersion,
|
||||
versionDisplay: typeKey === 'rules' ? `${changeCount} 次` : workingVersion,
|
||||
@@ -1304,6 +1400,7 @@ export function buildDetailViewModel(detail, runs) {
|
||||
const ruleTemplateLabel = normalizeText(configJson.rule_template_label) || resolveRuleTemplateLabel(ruleTemplateKey)
|
||||
const runtimeKind = normalizeText(configJson.runtime_kind || previewRuntimeRule.kind) || 'policy_rule_draft'
|
||||
const ruleScenarioCategory = typeKey === 'rules' ? resolveRuleScenarioCategory(detail, tabId) : ''
|
||||
const ruleScenarioList = typeKey === 'rules' ? resolveRuleScenarioList(detail, tabId) : []
|
||||
const isEnabledValue = usesJsonRiskRule ? resolveRiskRuleEnabled(detail) : true
|
||||
const generationStatus = normalizeText(configJson.generation_status || detail.status)
|
||||
const riskRuleGenerationFailed = usesJsonRiskRule && (detail.status === 'failed' || generationStatus === 'failed')
|
||||
@@ -1404,8 +1501,8 @@ export function buildDetailViewModel(detail, runs) {
|
||||
latestTestSummary: detail.latest_test_summary || detail.latestTestSummary || null,
|
||||
riskCategory: typeKey === 'rules' ? ruleScenarioCategory : '',
|
||||
ruleDocument,
|
||||
scenarioList: typeKey === 'rules' && ruleScenarioCategory
|
||||
? [ruleScenarioCategory]
|
||||
scenarioList: typeKey === 'rules' && ruleScenarioList.length
|
||||
? ruleScenarioList
|
||||
: Array.isArray(detail.scenario_json)
|
||||
? [...detail.scenario_json]
|
||||
: [],
|
||||
|
||||
@@ -87,12 +87,15 @@ export function filterAuditAssets(assets = [], filters = {}) {
|
||||
|
||||
return assets.filter((item) => {
|
||||
const matchesKeyword = normalizedKeyword
|
||||
? [item.name, item.code, item.summary, item.owner, item.scope]
|
||||
? [item.name, item.code, item.summary, item.owner, item.scope, item.riskLevelLabel]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(normalizedKeyword))
|
||||
: true
|
||||
const matchesDomain = filters.selectedDomain ? item.domainValue === filters.selectedDomain : true
|
||||
const matchesOwner = filters.selectedOwner ? item.owner === filters.selectedOwner : true
|
||||
const matchesRiskLevel = filters.selectedRiskLevel
|
||||
? item.riskLevelValue === filters.selectedRiskLevel
|
||||
: true
|
||||
const matchesStatus = filters.showStatusFilter
|
||||
? filters.selectedStatus
|
||||
? item.statusValue === filters.selectedStatus
|
||||
@@ -100,7 +103,9 @@ export function filterAuditAssets(assets = [], filters = {}) {
|
||||
: true
|
||||
const matchesRiskScenario = filters.showRiskScenarioFilter
|
||||
? filters.selectedRiskScenario
|
||||
? item.riskCategory === filters.selectedRiskScenario
|
||||
? Array.isArray(item.scenarioList) && item.scenarioList.length
|
||||
? item.scenarioList.includes(filters.selectedRiskScenario)
|
||||
: item.riskCategory === filters.selectedRiskScenario
|
||||
: true
|
||||
: true
|
||||
const matchesOnline = filters.showOnlineFilter
|
||||
@@ -118,6 +123,7 @@ export function filterAuditAssets(assets = [], filters = {}) {
|
||||
matchesKeyword &&
|
||||
matchesDomain &&
|
||||
matchesOwner &&
|
||||
matchesRiskLevel &&
|
||||
matchesStatus &&
|
||||
matchesRiskScenario &&
|
||||
matchesOnline &&
|
||||
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
canApproveLeaderExpenseClaims,
|
||||
canAccessAppView,
|
||||
canDeleteArchivedExpenseClaims,
|
||||
canEditBudgetCenter,
|
||||
canManageExpenseClaims,
|
||||
canReturnExpenseClaims
|
||||
canReturnExpenseClaims,
|
||||
canSwitchBudgetDepartments
|
||||
} from '../src/utils/accessControl.js'
|
||||
import { canProcessApprovalRequest } from '../src/utils/approvalInbox.js'
|
||||
|
||||
@@ -45,6 +47,25 @@ test('legacy reimbursement approval and archive centers are no longer accessible
|
||||
assert.equal(canAccessAppView(adminUser, 'documents'), true)
|
||||
})
|
||||
|
||||
test('budget center is visible to platform admin, budget monitor, and executive roles only', () => {
|
||||
assert.equal(canAccessAppView({ isAdmin: true, roleCodes: ['manager'] }, 'budget'), true)
|
||||
assert.equal(canAccessAppView({ username: 'admin', roleCodes: ['manager'] }, 'budget'), true)
|
||||
assert.equal(canAccessAppView({ roleCodes: ['budget_monitor'] }, 'budget'), true)
|
||||
assert.equal(canAccessAppView({ roleCodes: ['auditor'] }, 'budget'), true)
|
||||
assert.equal(canAccessAppView({ roleCodes: ['executive'] }, 'budget'), true)
|
||||
assert.equal(canAccessAppView({ roleCodes: ['finance'] }, 'budget'), false)
|
||||
assert.equal(canAccessAppView({ roleCodes: ['manager'] }, 'budget'), false)
|
||||
})
|
||||
|
||||
test('budget edit and department switching are limited to admin and senior finance', () => {
|
||||
assert.equal(canEditBudgetCenter({ username: 'admin', roleCodes: ['manager'] }), true)
|
||||
assert.equal(canSwitchBudgetDepartments({ username: 'admin', roleCodes: ['manager'] }), true)
|
||||
assert.equal(canEditBudgetCenter({ roleCodes: ['executive'] }), true)
|
||||
assert.equal(canSwitchBudgetDepartments({ roleCodes: ['executive'] }), true)
|
||||
assert.equal(canEditBudgetCenter({ roleCodes: ['budget_monitor'] }), false)
|
||||
assert.equal(canSwitchBudgetDepartments({ roleCodes: ['budget_monitor'] }), false)
|
||||
})
|
||||
|
||||
test('finance approval inbox only processes finance-stage requests', () => {
|
||||
const financeUser = { roleCodes: ['finance'], name: '财务' }
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
BUDGET_EXPENSE_TYPE_OPTIONS,
|
||||
BUDGET_ONTOLOGY_FIELDS,
|
||||
BUDGET_QUARTER_OPTIONS,
|
||||
BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS,
|
||||
BUDGET_YEAR_OPTIONS,
|
||||
buildBudgetOntologyContext
|
||||
} from '../src/utils/budgetOntology.js'
|
||||
@@ -44,7 +45,7 @@ test('budget ontology context maps dialog fields to ontology payload', () => {
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
budgetSubject: '差旅费',
|
||||
budgetSubject: '差旅',
|
||||
budgetSubjectCode: 'travel',
|
||||
budgetAmount: '600,000.00',
|
||||
warningThreshold: '80%',
|
||||
@@ -63,7 +64,7 @@ test('budget ontology context maps dialog fields to ontology payload', () => {
|
||||
assert.equal(context.budget_header.cost_center, 'CC-4100')
|
||||
assert.equal(context.budget_details[0].budget_subject_code, 'travel')
|
||||
assert.equal(context.budget_details[0].expense_type, 'travel')
|
||||
assert.equal(context.budget_details[0].expense_type_label, '差旅费')
|
||||
assert.equal(context.budget_details[0].expense_type_label, '差旅')
|
||||
assert.equal(context.budget_details[0].warning_threshold, '80%')
|
||||
})
|
||||
|
||||
@@ -85,6 +86,12 @@ test('budget expense type options expose real expense type codes', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('budget center visible expense type options only expose current supported budget subjects', () => {
|
||||
const optionLabels = BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS.map((item) => item.label)
|
||||
|
||||
assert.deepEqual(optionLabels, ['差旅', '通信', '招待费', '办公用品'])
|
||||
})
|
||||
|
||||
test('budget center exposes separate year and quarter dimensions', () => {
|
||||
assert.deepEqual(BUDGET_YEAR_OPTIONS, ['2026', '2027', '2028'])
|
||||
assert.deepEqual(BUDGET_QUARTER_OPTIONS, ['Q1', 'Q2', 'Q3', 'Q4'])
|
||||
|
||||
@@ -24,7 +24,7 @@ test('expense application submit uses rich text link and confirm dialog', () =>
|
||||
)
|
||||
assert.match(createViewTemplate, /:open="applicationSubmitConfirmDialog\.open"/)
|
||||
assert.match(createViewTemplate, /title="确认提交当前费用申请?"/)
|
||||
assert.match(createViewTemplate, /description="提交后申请将进入领导审核流程,并同步纳入预算管理口径/)
|
||||
assert.match(createViewTemplate, /description="提交后申请将进入领导审核流程,请确认关键申请信息和预计费用已经核对无误。"/)
|
||||
assert.match(createViewTemplate, /@confirm="confirmApplicationSubmit"/)
|
||||
assert.match(createViewScript, /const APPLICATION_SUBMIT_HREF = '#application-submit'/)
|
||||
assert.match(
|
||||
|
||||
@@ -29,7 +29,7 @@ test('application claims are mapped as application documents', () => {
|
||||
assert.equal(request.typeLabel, '差旅费用申请')
|
||||
assert.equal(request.secondaryStatusLabel, '申请材料')
|
||||
assert.equal(request.secondaryStatusValue, '已进入审批流程')
|
||||
assert.equal(request.expenseTableSummary, '预计金额已纳入预算管理口径')
|
||||
assert.equal(request.expenseTableSummary, '预计金额已随申请提交')
|
||||
assert.deepEqual(
|
||||
request.progressSteps.map((step) => step.label),
|
||||
['创建申请', '直属领导审批', '审批完成']
|
||||
|
||||
Reference in New Issue
Block a user