feat: 新增预算中心本体与风险规则评分回填

后端新增预算本体解析模块和风险规则评分回填服务,优化规则
生成本体对齐和提示词构建,增强费用类型关键词和本体验证,
完善报销查询和审计接口,前端预算中心页面增加对话框和本
体工具函数,重构审计页面元数据和视图模型,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-26 12:16:20 +08:00
parent 0e861d8fa6
commit e1e515ecae
53 changed files with 4350 additions and 921 deletions

View File

@@ -1,7 +1,18 @@
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
import { fetchEmployeeMeta } from '../../services/employees.js'
import {
BUDGET_CONTROL_ACTION_OPTIONS,
BUDGET_EXPENSE_TYPE_OPTIONS,
BUDGET_QUARTER_OPTIONS,
BUDGET_STATUS_OPTIONS,
BUDGET_WARNING_OPTIONS,
BUDGET_YEAR_OPTIONS,
buildBudgetOntologyContext,
formatBudgetPeriod,
resolveBudgetExpenseTypeLabel
} from '../../utils/budgetOntology.js'
const FALLBACK_DEPARTMENTS = [
{ code: 'MARKET-DEPT', name: '市场部', costCenter: 'CC-4100' },
@@ -12,13 +23,34 @@ const FALLBACK_DEPARTMENTS = [
{ code: 'PRESIDENT-OFFICE', name: '总裁办', costCenter: 'CC-1000' }
]
const EXPENSE_BLUEPRINTS = [
{ expenseType: '市场推广费', total: 500000, used: 186400, occupied: 120000, warning: 80, action: '提醒' },
{ expenseType: '差旅费', total: 600000, used: 242300, occupied: 150000, warning: 80, action: '提醒' },
{ expenseType: '办公费', total: 300000, used: 68500, occupied: 60000, warning: 70, action: '正常' },
{ expenseType: '培训费', total: 200000, used: 42300, occupied: 20000, warning: 70, action: '正常' },
{ expenseType: '软件服务费', total: 600000, used: 249500, occupied: 240800, warning: 80, action: '管控' }
]
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: '提醒' }
}
const DEFAULT_EXPENSE_BUDGET = {
total: 100000,
used: 0,
occupied: 0,
warning: 70,
action: '正常'
}
const EXPENSE_BLUEPRINTS = BUDGET_EXPENSE_TYPE_OPTIONS.map((option) => ({
...DEFAULT_EXPENSE_BUDGET,
...EXPENSE_BUDGET_SEED[option.value],
budgetSubjectCode: option.value,
expenseType: option.label
}))
const currency = (value) =>
Number(value || 0).toLocaleString('zh-CN', {
@@ -26,14 +58,29 @@ const currency = (value) =>
maximumFractionDigits: 2
})
const comparison = (value, direction) => ({
value,
tone: direction === 'down' ? 'down' : 'up',
icon: direction === 'down' ? 'mdi mdi-arrow-down' : 'mdi mdi-arrow-up'
})
const parseBudgetAmount = (value) => Number(String(value || '').replace(/[^\d.-]/g, '')) || 0
const makeBudgetRowId = () => `budget-row-${Date.now()}-${Math.random().toString(16).slice(2)}`
const BUDGET_PAGE_SIZE_OPTIONS = [5, 10]
function buildDepartmentRows(departmentCode) {
const seed = Array.from(String(departmentCode || '')).reduce((sum, char) => sum + char.charCodeAt(0), 0)
const seed = Array.from(String(departmentCode || '')).reduce(
(sum, char) => sum + char.charCodeAt(0),
0
)
const factor = 0.88 + (seed % 18) / 100
return EXPENSE_BLUEPRINTS.map((item, index) => {
const totalAmount = Math.round(item.total * factor)
const usedAmount = Math.round(item.used * (0.9 + ((seed + index) % 12) / 100))
const occupiedAmount = Math.round(item.occupied * (0.92 + ((seed + index * 3) % 10) / 100))
const occupiedAmount = Math.round(
item.occupied * (0.92 + ((seed + index * 3) % 10) / 100)
)
const leftAmount = Math.max(totalAmount - usedAmount - occupiedAmount, 0)
const rate = Number((((usedAmount + occupiedAmount) / totalAmount) * 100).toFixed(2))
@@ -80,18 +127,36 @@ export default {
const activeDepartmentCode = ref(FALLBACK_DEPARTMENTS[0].code)
const departmentKeyword = ref('')
const filters = ref({
period: '2026年度',
year: '2026',
quarter: 'Q1',
expenseType: '全部',
status: '全部'
})
const budgetPage = ref(1)
const budgetPageSize = ref(5)
const budgetEditOpen = ref(false)
const budgetEditForm = ref({
budgetYear: '2026',
budgetQuarter: 'Q1',
budgetPeriod: '2026年Q1',
departmentCode: FALLBACK_DEPARTMENTS[0].code,
costCenter: FALLBACK_DEPARTMENTS[0].costCenter,
budgetOwner: '张晓明',
budgetVersion: 'V1.0(初始版本)',
budgetStatus: '编制中',
budgetDescription: ''
})
const budgetEditRows = ref([])
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 visibleBudgetRows = computed(() =>
const departmentRows = computed(() =>
buildDepartmentRows(activeDepartment.value?.code || activeDepartmentCode.value)
)
const filteredBudgetRows = computed(() =>
departmentRows.value
.filter((row) => filters.value.expenseType === '全部' || row.expenseType === filters.value.expenseType)
.filter((row) => {
@@ -101,6 +166,21 @@ export default {
return row.rateTone === 'ok'
})
)
const totalBudgetRows = computed(() => filteredBudgetRows.value.length)
const totalBudgetPages = computed(() =>
Math.max(1, Math.ceil(totalBudgetRows.value / Number(budgetPageSize.value || 5)))
)
const currentBudgetPage = computed(() =>
Math.min(Math.max(1, budgetPage.value), totalBudgetPages.value)
)
const budgetPageNumbers = computed(() =>
Array.from({ length: totalBudgetPages.value }, (_, index) => index + 1)
)
const visibleBudgetRows = computed(() => {
const pageSize = Number(budgetPageSize.value || 5)
const start = (currentBudgetPage.value - 1) * pageSize
return filteredBudgetRows.value.slice(start, start + pageSize)
})
const totals = computed(() => {
const rows = departmentRows.value
@@ -119,30 +199,34 @@ export default {
{
label: '预算总额',
value: `¥${currency(totals.value.total)}`,
note: '本年累计',
yoy: comparison('+8.42%', 'up'),
mom: comparison('+2.16%', 'up'),
tone: 'green',
icon: 'mdi mdi-wallet-outline'
},
{
label: '已发生',
value: `¥${currency(totals.value.used)}`,
note: `占比 ${((totals.value.used / totals.value.total) * 100).toFixed(2)}%`,
yoy: comparison('+12.68%', 'up'),
mom: comparison('+4.35%', 'up'),
tone: 'blue',
icon: 'mdi mdi-chart-line'
},
{
label: '已占用',
value: `¥${currency(totals.value.occupied)}`,
note: `占比 ${((totals.value.occupied / totals.value.total) * 100).toFixed(2)}%`,
yoy: comparison('+6.37%', 'up'),
mom: comparison('-1.84%', 'down'),
tone: 'orange',
icon: 'mdi mdi-briefcase-check-outline'
},
{
label: '剩余可用',
value: `¥${currency(totals.value.left)}`,
note: `占比 ${((totals.value.left / totals.value.total) * 100).toFixed(2)}%`,
yoy: comparison('-3.26%', 'down'),
mom: comparison('-2.08%', 'down'),
tone: 'green',
icon: 'mdi mdi-currency-cny'
icon: 'mdi mdi-cash'
}
])
@@ -170,6 +254,103 @@ export default {
)
const trendData = computed(() => buildTrendData(departmentRows.value))
const budgetEditTotal = computed(() =>
currency(
budgetEditRows.value.reduce(
(sum, row) => sum + parseBudgetAmount(row.budgetAmount),
0
)
)
)
const budgetOntologyContext = computed(() =>
buildBudgetOntologyContext({
form: budgetEditForm.value,
rows: budgetEditRows.value,
departments: departments.value
})
)
function buildEditableRows() {
return departmentRows.value.map((row) => ({
id: makeBudgetRowId(),
budgetSubject: row.expenseType,
budgetSubjectCode: row.budgetSubjectCode || '',
budgetAmount: currency(row.totalAmount),
warningThreshold: `${row.warning}%`,
controlAction: row.action,
budgetRemark: `${row.expenseType}相关费用`
}))
}
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]
)
}
function syncBudgetRowSubject(row) {
row.budgetSubject = resolveBudgetExpenseTypeLabel(row.budgetSubjectCode, row.budgetSubject)
}
function openBudgetEditDialog() {
const department = activeDepartment.value
const budgetPeriod = formatBudgetPeriod(filters.value.year, filters.value.quarter)
budgetEditForm.value = {
budgetYear: filters.value.year,
budgetQuarter: filters.value.quarter,
budgetPeriod,
departmentCode: department?.code || activeDepartmentCode.value,
costCenter: department?.costCenter || '',
budgetOwner: '张晓明',
budgetVersion: 'V1.0(初始版本)',
budgetStatus: '编制中',
budgetDescription: `${department?.name || '当前部门'}2026年度预算编制用于指导费用支出及控制成本确保资源合理使用。`
}
budgetEditRows.value = buildEditableRows()
budgetEditOpen.value = true
}
function closeBudgetEditDialog() {
budgetEditOpen.value = false
}
function addBudgetDetailRow() {
const option = resolveNextExpenseTypeOption()
budgetEditRows.value.push({
id: makeBudgetRowId(),
budgetSubject: option.label,
budgetSubjectCode: option.value,
budgetAmount: '0.00',
warningThreshold: '70%',
controlAction: '正常',
budgetRemark: ''
})
}
function removeBudgetDetailRow(rowId) {
if (budgetEditRows.value.length <= 1) return
budgetEditRows.value = budgetEditRows.value.filter((row) => row.id !== rowId)
}
function goToBudgetPage(page) {
budgetPage.value = Math.min(Math.max(Number(page) || 1, 1), totalBudgetPages.value)
}
function changeBudgetPage(direction) {
goToBudgetPage(currentBudgetPage.value + direction)
}
function saveBudgetDraft() {
budgetEditForm.value.budgetStatus = '编制中'
closeBudgetEditDialog()
}
function publishBudget() {
budgetEditForm.value.budgetStatus = '已发布'
closeBudgetEditDialog()
}
async function loadDepartments() {
try {
@@ -198,19 +379,65 @@ export default {
void loadDepartments()
})
watch(
[
activeDepartmentCode,
budgetPageSize,
() => filters.value.year,
() => filters.value.quarter,
() => filters.value.expenseType,
() => filters.value.status
],
() => {
budgetPage.value = 1
}
)
watch(totalBudgetPages, (pages) => {
if (budgetPage.value > pages) {
budgetPage.value = pages
}
})
return {
activeDepartmentCode,
activeDepartmentName,
addBudgetDetailRow,
budgetEditForm,
budgetEditOpen,
budgetEditRows,
budgetEditTotal,
budgetMetrics,
budgetOntologyContext,
budgetPage: currentBudgetPage,
budgetPageNumbers,
budgetPageSize,
budgetPageSizeOptions: BUDGET_PAGE_SIZE_OPTIONS,
closeBudgetEditDialog,
controlActionOptions: BUDGET_CONTROL_ACTION_OPTIONS,
changeBudgetPage,
departmentKeyword,
departments,
expenseTypeOptions: BUDGET_EXPENSE_TYPE_OPTIONS,
expenseTypes: ['全部', ...EXPENSE_BLUEPRINTS.map((item) => item.expenseType)],
filters,
periods: ['2026年度', '2026年Q2', '2026年5月'],
openBudgetEditDialog,
quarters: BUDGET_QUARTER_OPTIONS,
publishBudget,
removeBudgetDetailRow,
saveBudgetDraft,
statusOptions: BUDGET_STATUS_OPTIONS,
statuses: ['全部', '正常', '预警', '管控'],
syncBudgetRowSubject,
goToBudgetPage,
totalBudgetPages,
totalBudgetRows,
trendData,
visibleBudgetRows,
visibleDepartments,
warnings
warningOptions: BUDGET_WARNING_OPTIONS,
warnings,
years: BUDGET_YEAR_OPTIONS
}
}
}