feat: 新增预算后端服务与差旅风险规则库
后端新增预算模型、端点和服务模块,支持预算 CRUD 和余额 查询,清理旧生成规则文件并替换为按严重等级分类的差旅风 险规则库,优化认证权限和报销单访问策略,新增财务规则目 录和演示数据构建脚本,前端预算中心增加对话框交互,完善 审计页面运行时模型和元数据展示,补充单元测试。
This commit is contained in:
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user