import { computed, onMounted, ref, watch } from 'vue' import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue' import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue' import { fetchBudgetSummary } from '../../services/budgets.js' import { fetchEmployeeMeta } from '../../services/employees.js' import { canEditBudgetCenter, canSwitchBudgetDepartments, isBudgetMonitorUser, isExecutiveUser } from '../../utils/accessControl.js' import { BUDGET_QUARTER_OPTIONS, BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS, BUDGET_YEAR_OPTIONS, resolveBudgetExpenseTypeLabel } from '../../utils/budgetOntology.js' 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' } ] const EXPENSE_BUDGET_SEED = { travel: { total: 600000, used: 242300, occupied: 150000, warning: 80, action: '提醒' }, communication: { total: 120000, used: 38600, occupied: 18000, warning: 70, 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 = { total: 100000, used: 0, occupied: 0, warning: 70, action: '正常' } const EXPENSE_BLUEPRINTS = BUDGET_VISIBLE_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', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) const comparison = (value, direction) => ({ value, tone: direction === 'down' ? 'down' : 'up', icon: direction === 'down' ? 'mdi mdi-arrow-down' : 'mdi mdi-arrow-up' }) const BUDGET_PAGE_SIZE_OPTIONS = [5, 10] const ALERT_DATE_FORMATTER = new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit' }) const BUDGET_COMPILED_TIME_FORMATTER = new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }) 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 clampPercent = (value) => Math.min(100, Math.max(0, Number(value) || 0)) function buildThresholds(warning) { const alert = clampPercent(warning) return { reminder: clampPercent(alert - 10), alert, risk: clampPercent(alert + 10) } } function formatBudgetCompiledAt(value) { if (!value) return '—' const date = new Date(value) if (Number.isNaN(date.getTime())) return '—' return BUDGET_COMPILED_TIME_FORMATTER.format(date).replace(/\//g, '-') } function resolveBudgetCompiler(item) { return String( item?.compiler || item?.compiled_by || item?.compiledBy || item?.created_by || item?.createdBy || item?.owner_name || item?.ownerName || '预算编制助手' ).trim() } function resolveBudgetReviewer(item) { return String( item?.reviewer || item?.reviewed_by || item?.reviewedBy || item?.approved_by || item?.approvedBy || item?.auditor || item?.updated_by || item?.updatedBy || '高级财务人员' ).trim() } 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 thresholds = buildThresholds(warning) const budgetSubjectCode = String(item?.subject_code || '').trim() const expenseType = item?.subject_name || resolveBudgetExpenseTypeLabel(budgetSubjectCode, budgetSubjectCode) return { allocationId: item?.id || '', budgetNo: item?.budget_no || '', budgetSubjectCode, compiledAt: formatBudgetCompiledAt(item?.created_at || item?.createdAt || item?.updated_at || item?.updatedAt), compiler: resolveBudgetCompiler(item), reviewer: resolveBudgetReviewer(item), expenseType, totalAmount, usedAmount, occupiedAmount, leftAmount, rate, rateTone: rate >= thresholds.risk ? 'danger' : rate >= thresholds.alert ? 'warn' : 'ok', reminderThreshold: thresholds.reminder, alertThreshold: thresholds.alert, riskThreshold: thresholds.risk, reminderLine: `${thresholds.reminder}%`, alertLine: `${thresholds.alert}%`, riskLine: `${thresholds.risk}%`, total: currency(totalAmount), used: currency(usedAmount), occupied: currency(occupiedAmount), left: currency(leftAmount) } } function normalizeBudgetUsageData(rows) { const source = Array.isArray(rows) ? rows : [] return { labels: source.map((item) => item.expenseType || '未分类'), budget: source.map((item) => Number(item.totalAmount || 0)), used: source.map((item) => Number(item.usedAmount || 0)), occupied: source.map((item) => Number(item.occupiedAmount || 0)), available: source.map((item) => Math.max(Number(item.leftAmount || 0), 0)) } } function formatAlertDate(value) { if (!value) return '' const date = new Date(value) if (Number.isNaN(date.getTime())) return '' return ALERT_DATE_FORMATTER.format(date) } function normalizeBudgetWarning(item) { const subjectName = item?.subject_name || resolveBudgetExpenseTypeLabel(item?.subject_code, item?.subject_code) const departmentName = item?.department_name || '' const usageRate = Number(item?.usage_rate || 0) const warningThreshold = Number(item?.warning_threshold || 0) const tone = item?.severity === 'danger' ? 'danger' : 'warn' return { id: item?.allocation_id || `${departmentName}-${subjectName}-${item?.period_key || ''}`, title: departmentName ? `${departmentName} · ${subjectName}` : subjectName, desc: item?.message || `使用率已达 ${usageRate}%,达到预警线 ${warningThreshold}%。`, date: formatAlertDate(item?.occurred_at), tone } } export default { name: 'BudgetCenterView', props: { currentUser: { type: Object, default: () => ({}) } }, emits: ['openAssistant'], components: { BudgetTrendChart, EnterpriseSelect }, setup(props, { emit }) { const departments = ref(FALLBACK_DEPARTMENTS) const activeDepartmentCode = ref(FALLBACK_DEPARTMENTS[0].code) const departmentKeyword = ref('') const filters = ref({ year: '2026', quarter: 'Q1', expenseType: '全部', status: '全部' }) const budgetPage = ref(1) const budgetPageSize = ref(5) const budgetTableKeyword = ref('') const budgetRows = ref([]) const budgetSummary = ref(null) const budgetLoading = ref(false) const budgetError = 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 yearOptions = BUDGET_YEAR_OPTIONS.map((year) => ({ label: `${year}年度`, value: year })) const budgetPageSizeOptions = BUDGET_PAGE_SIZE_OPTIONS.map((size) => ({ label: `${size} 条/页`, value: size })) const departmentOptions = computed(() => departments.value.map((department) => ({ label: department.name, value: department.code })) ) const activeDepartment = computed(() => departments.value.find((item) => item.code === activeDepartmentCode.value) || departments.value[0] ) const activeDepartmentName = computed(() => activeDepartment.value?.name || '市场部') 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(() => { const keyword = budgetTableKeyword.value.trim().toLowerCase() return departmentRows.value .filter((row) => { if (!keyword) return true return [ row.compiledAt, row.compiler, row.reviewer, row.expenseType, row.total, row.used, row.occupied, row.left, `${row.rate}%`, row.reminderLine, row.alertLine, row.riskLine ].some((value) => String(value || '').toLowerCase().includes(keyword)) }) .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' }) }) 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 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)}`, yoy: comparison('+8.42%', 'up'), mom: comparison('+2.16%', 'up'), tone: 'primary', icon: 'mdi mdi-wallet-outline' }, { label: '已发生', value: `¥${currency(totals.value.used)}`, yoy: comparison('+12.68%', 'up'), mom: comparison('+4.35%', 'up'), tone: 'info', icon: 'mdi mdi-chart-line' }, { label: '已占用', value: `¥${currency(totals.value.occupied)}`, yoy: comparison('+6.37%', 'up'), mom: comparison('-1.84%', 'down'), tone: 'warning', icon: 'mdi mdi-briefcase-check-outline' }, { label: '剩余可用', value: `¥${currency(totals.value.left)}`, yoy: comparison('-3.26%', 'down'), mom: comparison('-2.08%', 'down'), tone: 'primary', icon: 'mdi mdi-cash' } ]) 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(() => (Array.isArray(budgetSummary.value?.warnings) ? budgetSummary.value.warnings : []) .map(normalizeBudgetWarning) ) const budgetUsageData = computed(() => normalizeBudgetUsageData(departmentRows.value) ) function openBudgetAssistant() { if (!canEditBudget.value) return emit('openAssistant', { source: 'budget', sessionType: 'budget', prompt: '', files: [], conversation: null }) } function goToBudgetPage(page) { budgetPage.value = Math.min(Math.max(Number(page) || 1, 1), totalBudgetPages.value) } function changeBudgetPage(direction) { goToBudgetPage(currentBudgetPage.value + direction) } 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() { try { const payload = await fetchEmployeeMeta() const options = Array.isArray(payload?.organizationOptions) ? payload.organizationOptions : [] 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 (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 : [] budgetSummary.value = payload || null budgetRows.value = allocations.map(normalizeBudgetAllocationRow) } catch (error) { budgetError.value = error?.message || 'Failed to load budget data' budgetSummary.value = null budgetRows.value = [] console.warn('Failed to load budget data:', error) } finally { budgetLoading.value = false } } onMounted(() => { void loadDepartments() }) watch( [ activeDepartmentCode, budgetPageSize, () => filters.value.year, () => filters.value.quarter, () => filters.value.expenseType, () => filters.value.status, budgetTableKeyword ], () => { budgetPage.value = 1 } ) watch( [activeDepartmentCode, () => filters.value.year, () => filters.value.quarter], () => { void loadBudgetData() } ) watch(totalBudgetPages, (pages) => { if (budgetPage.value > pages) { budgetPage.value = pages } }) return { activeDepartmentCode, activeDepartmentName, budgetError, budgetLoading, budgetMetrics, budgetPage: currentBudgetPage, budgetPageNumbers, budgetPageSize, budgetPageSizeOptions, budgetTableKeyword, canEditBudget, canSwitchDepartments, changeBudgetPage, departmentKeyword, departments, expenseTypes: ['全部', ...EXPENSE_BLUEPRINTS.map((item) => item.expenseType)], filters, openBudgetAssistant, quarters: BUDGET_QUARTER_OPTIONS, departmentOptions, statuses: ['全部', '正常', '预警', '管控'], goToBudgetPage, totalBudgetPages, totalBudgetRows, budgetUsageData, visibleBudgetRows, visibleDepartments, warnings, yearOptions, years: BUDGET_YEAR_OPTIONS } } }