2026-05-26 12:16:20 +08:00
|
|
|
|
import { computed, onMounted, ref, watch } from 'vue'
|
2026-05-26 09:15:14 +08:00
|
|
|
|
|
|
|
|
|
|
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
|
2026-05-26 17:29:35 +08:00
|
|
|
|
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
|
|
|
|
|
import { createBudgetAllocation, fetchBudgetSummary } from '../../services/budgets.js'
|
2026-05-26 09:15:14 +08:00
|
|
|
|
import { fetchEmployeeMeta } from '../../services/employees.js'
|
2026-05-26 17:29:35 +08:00
|
|
|
|
import {
|
|
|
|
|
|
canEditBudgetCenter,
|
|
|
|
|
|
canSwitchBudgetDepartments,
|
|
|
|
|
|
isBudgetMonitorUser,
|
|
|
|
|
|
isExecutiveUser
|
|
|
|
|
|
} from '../../utils/accessControl.js'
|
2026-05-26 12:16:20 +08:00
|
|
|
|
import {
|
|
|
|
|
|
BUDGET_CONTROL_ACTION_OPTIONS,
|
|
|
|
|
|
BUDGET_QUARTER_OPTIONS,
|
|
|
|
|
|
BUDGET_STATUS_OPTIONS,
|
2026-05-26 17:29:35 +08:00
|
|
|
|
BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS,
|
2026-05-26 12:16:20 +08:00
|
|
|
|
BUDGET_WARNING_OPTIONS,
|
|
|
|
|
|
BUDGET_YEAR_OPTIONS,
|
|
|
|
|
|
buildBudgetOntologyContext,
|
|
|
|
|
|
formatBudgetPeriod,
|
|
|
|
|
|
resolveBudgetExpenseTypeLabel
|
|
|
|
|
|
} from '../../utils/budgetOntology.js'
|
2026-05-26 09:15:14 +08:00
|
|
|
|
|
|
|
|
|
|
const FALLBACK_DEPARTMENTS = [
|
|
|
|
|
|
{ code: 'MARKET-DEPT', name: '市场部', costCenter: 'CC-4100' },
|
|
|
|
|
|
{ code: 'FINANCE-DEPT', name: '财务部', costCenter: 'CC-2100' },
|
|
|
|
|
|
{ code: 'TECH-DEPT', name: '技术部', costCenter: 'CC-6100' },
|
|
|
|
|
|
{ code: 'HR-DEPT', name: '人力资源部', costCenter: 'CC-3200' },
|
|
|
|
|
|
{ code: 'PRODUCTION-DEPT', name: '生产部', costCenter: 'CC-7200' },
|
|
|
|
|
|
{ code: 'PRESIDENT-OFFICE', name: '总裁办', costCenter: 'CC-1000' }
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-05-26 12:16:20 +08:00
|
|
|
|
const EXPENSE_BUDGET_SEED = {
|
|
|
|
|
|
travel: { total: 600000, used: 242300, occupied: 150000, warning: 80, action: '提醒' },
|
|
|
|
|
|
communication: { total: 120000, used: 38600, occupied: 18000, warning: 70, action: '正常' },
|
2026-05-26 17:29:35 +08:00
|
|
|
|
meal: { total: 420000, used: 168200, occupied: 118000, warning: 80, action: '管控' },
|
|
|
|
|
|
office: { total: 180000, used: 68500, occupied: 32000, warning: 70, action: '正常' }
|
2026-05-26 12:16:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const DEFAULT_EXPENSE_BUDGET = {
|
|
|
|
|
|
total: 100000,
|
|
|
|
|
|
used: 0,
|
|
|
|
|
|
occupied: 0,
|
|
|
|
|
|
warning: 70,
|
|
|
|
|
|
action: '正常'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 17:29:35 +08:00
|
|
|
|
const EXPENSE_BLUEPRINTS = BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS.map((option) => ({
|
2026-05-26 12:16:20 +08:00
|
|
|
|
...DEFAULT_EXPENSE_BUDGET,
|
|
|
|
|
|
...EXPENSE_BUDGET_SEED[option.value],
|
|
|
|
|
|
budgetSubjectCode: option.value,
|
|
|
|
|
|
expenseType: option.label
|
|
|
|
|
|
}))
|
2026-05-26 09:15:14 +08:00
|
|
|
|
|
|
|
|
|
|
const currency = (value) =>
|
|
|
|
|
|
Number(value || 0).toLocaleString('zh-CN', {
|
|
|
|
|
|
minimumFractionDigits: 2,
|
|
|
|
|
|
maximumFractionDigits: 2
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-26 12:16:20 +08:00
|
|
|
|
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]
|
|
|
|
|
|
|
2026-05-26 17:29:35 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
|
function buildDepartmentRows(departmentCode) {
|
2026-05-26 12:16:20 +08:00
|
|
|
|
const seed = Array.from(String(departmentCode || '')).reduce(
|
|
|
|
|
|
(sum, char) => sum + char.charCodeAt(0),
|
|
|
|
|
|
0
|
|
|
|
|
|
)
|
2026-05-26 09:15:14 +08:00
|
|
|
|
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))
|
2026-05-26 12:16:20 +08:00
|
|
|
|
const occupiedAmount = Math.round(
|
|
|
|
|
|
item.occupied * (0.92 + ((seed + index * 3) % 10) / 100)
|
|
|
|
|
|
)
|
2026-05-26 09:15:14 +08:00
|
|
|
|
const leftAmount = Math.max(totalAmount - usedAmount - occupiedAmount, 0)
|
|
|
|
|
|
const rate = Number((((usedAmount + occupiedAmount) / totalAmount) * 100).toFixed(2))
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
...item,
|
|
|
|
|
|
totalAmount,
|
|
|
|
|
|
usedAmount,
|
|
|
|
|
|
occupiedAmount,
|
|
|
|
|
|
leftAmount,
|
|
|
|
|
|
rate,
|
|
|
|
|
|
rateTone: rate >= item.warning ? 'danger' : rate >= item.warning - 12 ? 'warn' : 'ok',
|
|
|
|
|
|
warningTone: item.warning >= 80 ? 'budget-warning-red' : 'budget-warning-yellow',
|
|
|
|
|
|
warningLine: `${item.warning}%`,
|
|
|
|
|
|
total: currency(totalAmount),
|
|
|
|
|
|
used: currency(usedAmount),
|
|
|
|
|
|
occupied: currency(occupiedAmount),
|
|
|
|
|
|
left: currency(leftAmount)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildTrendData(rows) {
|
|
|
|
|
|
const total = rows.reduce((sum, item) => sum + item.totalAmount, 0)
|
|
|
|
|
|
const used = rows.reduce((sum, item) => sum + item.usedAmount + item.occupiedAmount, 0)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
labels: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
|
|
|
|
|
|
budget: [0.05, 0.18, 0.25, 0.34, 0.45, 0.52, 0.68, 0.76, 0.84, 0.91, 0.96, 1].map((ratio) =>
|
|
|
|
|
|
Math.round(total * ratio)
|
|
|
|
|
|
),
|
|
|
|
|
|
used: [0.03, 0.1, 0.13, 0.22, 0.3, 0.37, 0.51, 0.59, 0.69, 0.73, 0.86, 0.96].map((ratio) =>
|
|
|
|
|
|
Math.round(used * ratio)
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default {
|
|
|
|
|
|
name: 'BudgetCenterView',
|
2026-05-26 17:29:35 +08:00
|
|
|
|
props: {
|
|
|
|
|
|
currentUser: {
|
|
|
|
|
|
type: Object,
|
|
|
|
|
|
default: () => ({})
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-05-26 09:15:14 +08:00
|
|
|
|
components: {
|
2026-05-26 17:29:35 +08:00
|
|
|
|
BudgetTrendChart,
|
|
|
|
|
|
ConfirmDialog
|
2026-05-26 09:15:14 +08:00
|
|
|
|
},
|
2026-05-26 17:29:35 +08:00
|
|
|
|
setup(props) {
|
2026-05-26 09:15:14 +08:00
|
|
|
|
const departments = ref(FALLBACK_DEPARTMENTS)
|
|
|
|
|
|
const activeDepartmentCode = ref(FALLBACK_DEPARTMENTS[0].code)
|
|
|
|
|
|
const departmentKeyword = ref('')
|
|
|
|
|
|
const filters = ref({
|
2026-05-26 12:16:20 +08:00
|
|
|
|
year: '2026',
|
|
|
|
|
|
quarter: 'Q1',
|
2026-05-26 09:15:14 +08:00
|
|
|
|
expenseType: '全部',
|
|
|
|
|
|
status: '全部'
|
|
|
|
|
|
})
|
2026-05-26 12:16:20 +08:00
|
|
|
|
const budgetPage = ref(1)
|
|
|
|
|
|
const budgetPageSize = ref(5)
|
2026-05-26 17:29:35 +08:00
|
|
|
|
const budgetRows = ref([])
|
|
|
|
|
|
const budgetLoading = ref(false)
|
|
|
|
|
|
const budgetError = ref('')
|
|
|
|
|
|
const budgetSaving = ref(false)
|
2026-05-26 12:16:20 +08:00
|
|
|
|
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([])
|
2026-05-26 17:29:35 +08:00
|
|
|
|
const canEditBudget = computed(() => canEditBudgetCenter(props.currentUser))
|
|
|
|
|
|
const canSwitchDepartments = computed(() => canSwitchBudgetDepartments(props.currentUser))
|
|
|
|
|
|
const isDepartmentBudgetMonitor = computed(
|
|
|
|
|
|
() => isBudgetMonitorUser(props.currentUser) && !canSwitchDepartments.value && !isExecutiveUser(props.currentUser)
|
|
|
|
|
|
)
|
2026-05-26 09:15:14 +08:00
|
|
|
|
|
|
|
|
|
|
const activeDepartment = computed(() =>
|
|
|
|
|
|
departments.value.find((item) => item.code === activeDepartmentCode.value) || departments.value[0]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const activeDepartmentName = computed(() => activeDepartment.value?.name || '市场部')
|
2026-05-26 17:29:35 +08:00
|
|
|
|
const currentUserDepartmentName = computed(() =>
|
|
|
|
|
|
String(props.currentUser?.departmentName || props.currentUser?.department || '').trim()
|
|
|
|
|
|
)
|
|
|
|
|
|
const currentUserCostCenter = computed(() =>
|
|
|
|
|
|
String(props.currentUser?.costCenter || props.currentUser?.cost_center || '').trim()
|
2026-05-26 12:16:20 +08:00
|
|
|
|
)
|
2026-05-26 17:29:35 +08:00
|
|
|
|
const departmentRows = computed(() => budgetRows.value)
|
2026-05-26 12:16:20 +08:00
|
|
|
|
const filteredBudgetRows = computed(() =>
|
2026-05-26 09:15:14 +08:00
|
|
|
|
departmentRows.value
|
|
|
|
|
|
.filter((row) => filters.value.expenseType === '全部' || row.expenseType === filters.value.expenseType)
|
|
|
|
|
|
.filter((row) => {
|
|
|
|
|
|
if (filters.value.status === '全部') return true
|
|
|
|
|
|
if (filters.value.status === '预警') return row.rateTone === 'warn'
|
|
|
|
|
|
if (filters.value.status === '管控') return row.rateTone === 'danger'
|
|
|
|
|
|
return row.rateTone === 'ok'
|
|
|
|
|
|
})
|
|
|
|
|
|
)
|
2026-05-26 12:16:20 +08:00
|
|
|
|
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)
|
|
|
|
|
|
})
|
2026-05-26 09:15:14 +08:00
|
|
|
|
|
|
|
|
|
|
const totals = computed(() => {
|
|
|
|
|
|
const rows = departmentRows.value
|
|
|
|
|
|
const total = rows.reduce((sum, item) => sum + item.totalAmount, 0)
|
|
|
|
|
|
const used = rows.reduce((sum, item) => sum + item.usedAmount, 0)
|
|
|
|
|
|
const occupied = rows.reduce((sum, item) => sum + item.occupiedAmount, 0)
|
|
|
|
|
|
return {
|
|
|
|
|
|
total,
|
|
|
|
|
|
used,
|
|
|
|
|
|
occupied,
|
|
|
|
|
|
left: Math.max(total - used - occupied, 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const budgetMetrics = computed(() => [
|
|
|
|
|
|
{
|
|
|
|
|
|
label: '预算总额',
|
|
|
|
|
|
value: `¥${currency(totals.value.total)}`,
|
2026-05-26 12:16:20 +08:00
|
|
|
|
yoy: comparison('+8.42%', 'up'),
|
|
|
|
|
|
mom: comparison('+2.16%', 'up'),
|
2026-05-26 09:15:14 +08:00
|
|
|
|
tone: 'green',
|
|
|
|
|
|
icon: 'mdi mdi-wallet-outline'
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
label: '已发生',
|
|
|
|
|
|
value: `¥${currency(totals.value.used)}`,
|
2026-05-26 12:16:20 +08:00
|
|
|
|
yoy: comparison('+12.68%', 'up'),
|
|
|
|
|
|
mom: comparison('+4.35%', 'up'),
|
2026-05-26 09:15:14 +08:00
|
|
|
|
tone: 'blue',
|
|
|
|
|
|
icon: 'mdi mdi-chart-line'
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
label: '已占用',
|
|
|
|
|
|
value: `¥${currency(totals.value.occupied)}`,
|
2026-05-26 12:16:20 +08:00
|
|
|
|
yoy: comparison('+6.37%', 'up'),
|
|
|
|
|
|
mom: comparison('-1.84%', 'down'),
|
2026-05-26 09:15:14 +08:00
|
|
|
|
tone: 'orange',
|
|
|
|
|
|
icon: 'mdi mdi-briefcase-check-outline'
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
label: '剩余可用',
|
|
|
|
|
|
value: `¥${currency(totals.value.left)}`,
|
2026-05-26 12:16:20 +08:00
|
|
|
|
yoy: comparison('-3.26%', 'down'),
|
|
|
|
|
|
mom: comparison('-2.08%', 'down'),
|
2026-05-26 09:15:14 +08:00
|
|
|
|
tone: 'green',
|
2026-05-26 12:16:20 +08:00
|
|
|
|
icon: 'mdi mdi-cash'
|
2026-05-26 09:15:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
const visibleDepartments = computed(() => {
|
|
|
|
|
|
const keyword = departmentKeyword.value.trim()
|
|
|
|
|
|
return departments.value
|
|
|
|
|
|
.filter((item) => !keyword || item.name.includes(keyword) || item.code.includes(keyword))
|
|
|
|
|
|
.map((item) => ({
|
|
|
|
|
|
...item,
|
|
|
|
|
|
icon: item.code === activeDepartmentCode.value ? 'mdi mdi-account-group-outline' : 'mdi mdi-domain'
|
|
|
|
|
|
}))
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const warnings = computed(() =>
|
|
|
|
|
|
departmentRows.value
|
|
|
|
|
|
.slice()
|
|
|
|
|
|
.sort((a, b) => b.rate - a.rate)
|
|
|
|
|
|
.slice(0, 4)
|
|
|
|
|
|
.map((row, index) => ({
|
|
|
|
|
|
title: row.expenseType,
|
|
|
|
|
|
desc: `使用率已达 ${row.rate}%,${row.rate >= row.warning ? '已超过预警线' : '接近预警线'}(${row.warningLine})`,
|
|
|
|
|
|
date: index < 2 ? '2026-05-12' : '2026-05-10',
|
|
|
|
|
|
tone: row.rate >= row.warning ? 'danger' : row.rate >= row.warning - 12 ? 'warn' : 'ok'
|
|
|
|
|
|
}))
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const trendData = computed(() => buildTrendData(departmentRows.value))
|
2026-05-26 12:16:20 +08:00
|
|
|
|
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() {
|
2026-05-26 17:29:35 +08:00
|
|
|
|
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) => ({
|
2026-05-26 12:16:20 +08:00
|
|
|
|
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 (
|
2026-05-26 17:29:35 +08:00
|
|
|
|
BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS.find((item) => !usedCodes.has(item.value)) ||
|
|
|
|
|
|
BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS[0]
|
2026-05-26 12:16:20 +08:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function syncBudgetRowSubject(row) {
|
|
|
|
|
|
row.budgetSubject = resolveBudgetExpenseTypeLabel(row.budgetSubjectCode, row.budgetSubject)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openBudgetEditDialog() {
|
2026-05-26 17:29:35 +08:00
|
|
|
|
if (!canEditBudget.value) return
|
2026-05-26 12:16:20 +08:00
|
|
|
|
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: ''
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 17:29:35 +08:00
|
|
|
|
const confirmDeleteOpen = ref(false)
|
|
|
|
|
|
const rowToDelete = ref(null)
|
|
|
|
|
|
|
2026-05-26 12:16:20 +08:00
|
|
|
|
function removeBudgetDetailRow(rowId) {
|
|
|
|
|
|
if (budgetEditRows.value.length <= 1) return
|
2026-05-26 17:29:35 +08:00
|
|
|
|
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
|
2026-05-26 12:16:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function goToBudgetPage(page) {
|
|
|
|
|
|
budgetPage.value = Math.min(Math.max(Number(page) || 1, 1), totalBudgetPages.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function changeBudgetPage(direction) {
|
|
|
|
|
|
goToBudgetPage(currentBudgetPage.value + direction)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 17:29:35 +08:00
|
|
|
|
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
|
|
|
|
|
|
}))
|
2026-05-26 12:16:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 17:29:35 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
2026-05-26 12:16:20 +08:00
|
|
|
|
}
|
2026-05-26 09:15:14 +08:00
|
|
|
|
|
|
|
|
|
|
async function loadDepartments() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const payload = await fetchEmployeeMeta()
|
|
|
|
|
|
const options = Array.isArray(payload?.organizationOptions) ? payload.organizationOptions : []
|
|
|
|
|
|
const nextDepartments = options
|
|
|
|
|
|
.filter((item) => item?.code && item?.name)
|
|
|
|
|
|
.map((item) => ({
|
2026-05-26 17:29:35 +08:00
|
|
|
|
id: String(item.id || ''),
|
2026-05-26 09:15:14 +08:00
|
|
|
|
code: String(item.code),
|
|
|
|
|
|
name: String(item.name),
|
|
|
|
|
|
costCenter: String(item.costCenter || '')
|
|
|
|
|
|
}))
|
2026-05-26 17:29:35 +08:00
|
|
|
|
const scopedDepartments = resolveScopedDepartments(nextDepartments)
|
2026-05-26 09:15:14 +08:00
|
|
|
|
|
2026-05-26 17:29:35 +08:00
|
|
|
|
if (scopedDepartments.length) {
|
|
|
|
|
|
departments.value = scopedDepartments
|
|
|
|
|
|
if (!scopedDepartments.some((item) => item.code === activeDepartmentCode.value)) {
|
|
|
|
|
|
activeDepartmentCode.value = scopedDepartments[0].code
|
2026-05-26 09:15:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-26 17:29:35 +08:00
|
|
|
|
await loadBudgetData()
|
2026-05-26 09:15:14 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.warn('Failed to load budget departments from employee meta:', error)
|
2026-05-26 17:29:35 +08:00
|
|
|
|
await loadBudgetData()
|
2026-05-26 09:15:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 17:29:35 +08:00
|
|
|
|
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')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
void loadDepartments()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-26 12:16:20 +08:00
|
|
|
|
watch(
|
|
|
|
|
|
[
|
|
|
|
|
|
activeDepartmentCode,
|
|
|
|
|
|
budgetPageSize,
|
|
|
|
|
|
() => filters.value.year,
|
|
|
|
|
|
() => filters.value.quarter,
|
|
|
|
|
|
() => filters.value.expenseType,
|
|
|
|
|
|
() => filters.value.status
|
|
|
|
|
|
],
|
|
|
|
|
|
() => {
|
|
|
|
|
|
budgetPage.value = 1
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-26 17:29:35 +08:00
|
|
|
|
watch(
|
|
|
|
|
|
[activeDepartmentCode, () => filters.value.year, () => filters.value.quarter],
|
|
|
|
|
|
() => {
|
|
|
|
|
|
void loadBudgetData()
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-26 12:16:20 +08:00
|
|
|
|
watch(totalBudgetPages, (pages) => {
|
|
|
|
|
|
if (budgetPage.value > pages) {
|
|
|
|
|
|
budgetPage.value = pages
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
|
return {
|
|
|
|
|
|
activeDepartmentCode,
|
|
|
|
|
|
activeDepartmentName,
|
2026-05-26 12:16:20 +08:00
|
|
|
|
addBudgetDetailRow,
|
|
|
|
|
|
budgetEditForm,
|
|
|
|
|
|
budgetEditOpen,
|
|
|
|
|
|
budgetEditRows,
|
|
|
|
|
|
budgetEditTotal,
|
2026-05-26 17:29:35 +08:00
|
|
|
|
budgetError,
|
|
|
|
|
|
budgetLoading,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
budgetMetrics,
|
2026-05-26 12:16:20 +08:00
|
|
|
|
budgetOntologyContext,
|
|
|
|
|
|
budgetPage: currentBudgetPage,
|
|
|
|
|
|
budgetPageNumbers,
|
|
|
|
|
|
budgetPageSize,
|
|
|
|
|
|
budgetPageSizeOptions: BUDGET_PAGE_SIZE_OPTIONS,
|
2026-05-26 17:29:35 +08:00
|
|
|
|
canEditBudget,
|
|
|
|
|
|
canSwitchDepartments,
|
2026-05-26 12:16:20 +08:00
|
|
|
|
closeBudgetEditDialog,
|
|
|
|
|
|
controlActionOptions: BUDGET_CONTROL_ACTION_OPTIONS,
|
|
|
|
|
|
changeBudgetPage,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
departmentKeyword,
|
2026-05-26 12:16:20 +08:00
|
|
|
|
departments,
|
2026-05-26 17:29:35 +08:00
|
|
|
|
expenseTypeOptions: BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
expenseTypes: ['全部', ...EXPENSE_BLUEPRINTS.map((item) => item.expenseType)],
|
|
|
|
|
|
filters,
|
2026-05-26 12:16:20 +08:00
|
|
|
|
openBudgetEditDialog,
|
|
|
|
|
|
quarters: BUDGET_QUARTER_OPTIONS,
|
2026-05-26 17:29:35 +08:00
|
|
|
|
addBudgetDetailRow,
|
2026-05-26 12:16:20 +08:00
|
|
|
|
removeBudgetDetailRow,
|
2026-05-26 17:29:35 +08:00
|
|
|
|
confirmDeleteOpen,
|
|
|
|
|
|
confirmDeleteRow,
|
|
|
|
|
|
cancelDeleteRow,
|
|
|
|
|
|
publishBudget: publishBudgetAction,
|
2026-05-26 12:16:20 +08:00
|
|
|
|
statusOptions: BUDGET_STATUS_OPTIONS,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
statuses: ['全部', '正常', '预警', '管控'],
|
2026-05-26 12:16:20 +08:00
|
|
|
|
syncBudgetRowSubject,
|
|
|
|
|
|
goToBudgetPage,
|
|
|
|
|
|
totalBudgetPages,
|
|
|
|
|
|
totalBudgetRows,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
trendData,
|
|
|
|
|
|
visibleBudgetRows,
|
|
|
|
|
|
visibleDepartments,
|
2026-05-26 12:16:20 +08:00
|
|
|
|
warningOptions: BUDGET_WARNING_OPTIONS,
|
|
|
|
|
|
warnings,
|
|
|
|
|
|
years: BUDGET_YEAR_OPTIONS
|
2026-05-26 09:15:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|