feat: 新增预算后端服务与差旅风险规则库

后端新增预算模型、端点和服务模块,支持预算 CRUD 和余额
查询,清理旧生成规则文件并替换为按严重等级分类的差旅风
险规则库,优化认证权限和报销单访问策略,新增财务规则目
录和演示数据构建脚本,前端预算中心增加对话框交互,完善
审计页面运行时模型和元数据展示,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-26 17:29:35 +08:00
parent e1e515ecae
commit e7bef0883d
85 changed files with 6443 additions and 1497 deletions

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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>

View File

@@ -235,7 +235,7 @@
<div class="card-head">
<div>
<h3>系统角色分配</h3>
<p>为员工分配管理员财务人员使用者高级管理人员等业务角色</p>
<p>为员工分配管理员财务人员使用者高级财务人员预算监控员等业务角色</p>
</div>
<span class="count-badge">{{ roleCount }} 个角色</span>
</div>

View File

@@ -1402,7 +1402,7 @@
badge="提交确认"
badge-tone="primary"
title="确认提交当前费用申请?"
description="提交后申请将进入领导审核流程,并同步纳入预算管理口径,请确认关键申请信息和预计费用已经核对无误。"
description="提交后申请将进入领导审核流程,请确认关键申请信息和预计费用已经核对无误。"
cancel-text="再检查一下"
confirm-text="确认提交"
busy-text="提交中..."

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 助手。'
}
]

View File

@@ -1633,7 +1633,7 @@ export default {
toast(
isArchivedRequest.value
? '已归档单据不能删除,只有高级管理员可以执行删除。'
: '当前单据已进入流程,只有高级管理人员可以删除。'
: '当前单据已进入流程,只有高级财务人员可以删除。'
)
return
}

View File

@@ -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: '发票' },

View File

@@ -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]
: [],

View File

@@ -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 &&