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