feat: 新增预算助手报告组件并优化报销交互细节
新增预算助手报告视图模型和组件,优化报销洞察面板和消息项 样式细节,完善预算中心页面布局和文档中心视图,增强报销创 建会话管理和提交编排器,调整 Vite 构建配置,补充单元测试。
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
|
||||
import { createBudgetAllocation, fetchBudgetSummary } from '../../services/budgets.js'
|
||||
import { fetchBudgetSummary } from '../../services/budgets.js'
|
||||
import { fetchEmployeeMeta } from '../../services/employees.js'
|
||||
import {
|
||||
canEditBudgetCenter,
|
||||
@@ -12,14 +11,9 @@ import {
|
||||
isExecutiveUser
|
||||
} from '../../utils/accessControl.js'
|
||||
import {
|
||||
BUDGET_CONTROL_ACTION_OPTIONS,
|
||||
BUDGET_QUARTER_OPTIONS,
|
||||
BUDGET_STATUS_OPTIONS,
|
||||
BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS,
|
||||
BUDGET_WARNING_OPTIONS,
|
||||
BUDGET_YEAR_OPTIONS,
|
||||
buildBudgetOntologyContext,
|
||||
formatBudgetPeriod,
|
||||
resolveBudgetExpenseTypeLabel
|
||||
} from '../../utils/budgetOntology.js'
|
||||
|
||||
@@ -66,13 +60,19 @@ const comparison = (value, direction) => ({
|
||||
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]
|
||||
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'
|
||||
@@ -87,19 +87,49 @@ const parsePercent = (value, fallback = 80) => {
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
|
||||
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 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) {
|
||||
@@ -110,6 +140,7 @@ function normalizeBudgetAllocationRow(item) {
|
||||
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)
|
||||
|
||||
@@ -117,17 +148,22 @@ function normalizeBudgetAllocationRow(item) {
|
||||
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 >= warning ? 'danger' : rate >= warning - 12 ? 'warn' : 'ok',
|
||||
warning,
|
||||
warningTone: warning >= 80 ? 'budget-warning-red' : 'budget-warning-yellow',
|
||||
warningLine: `${warning}%`,
|
||||
action: resolveControlActionLabel(item?.control_action),
|
||||
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),
|
||||
@@ -176,12 +212,12 @@ export default {
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
emits: ['openAssistant'],
|
||||
components: {
|
||||
BudgetTrendChart,
|
||||
ConfirmDialog,
|
||||
EnterpriseSelect
|
||||
},
|
||||
setup(props) {
|
||||
setup(props, { emit }) {
|
||||
const departments = ref(FALLBACK_DEPARTMENTS)
|
||||
const activeDepartmentCode = ref(FALLBACK_DEPARTMENTS[0].code)
|
||||
const departmentKeyword = ref('')
|
||||
@@ -193,25 +229,11 @@ export default {
|
||||
})
|
||||
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 budgetSaving = ref(false)
|
||||
const budgetEditOpen = ref(false)
|
||||
const confirmSaveOpen = 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 canEditBudget = computed(() => canEditBudgetCenter(props.currentUser))
|
||||
const canSwitchDepartments = computed(() => canSwitchBudgetDepartments(props.currentUser))
|
||||
const isDepartmentBudgetMonitor = computed(
|
||||
@@ -238,8 +260,26 @@ export default {
|
||||
String(props.currentUser?.costCenter || props.currentUser?.cost_center || '').trim()
|
||||
)
|
||||
const departmentRows = computed(() => budgetRows.value)
|
||||
const filteredBudgetRows = computed(() =>
|
||||
departmentRows.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
|
||||
@@ -247,7 +287,7 @@ export default {
|
||||
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)))
|
||||
@@ -330,120 +370,18 @@ export default {
|
||||
const budgetUsageData = computed(() =>
|
||||
normalizeBudgetUsageData(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() {
|
||||
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 || '',
|
||||
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_VISIBLE_EXPENSE_TYPE_OPTIONS.find((item) => !usedCodes.has(item.value)) ||
|
||||
BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS[0]
|
||||
)
|
||||
}
|
||||
|
||||
function syncBudgetRowSubject(row) {
|
||||
row.budgetSubject = resolveBudgetExpenseTypeLabel(row.budgetSubjectCode, row.budgetSubject)
|
||||
}
|
||||
|
||||
function openBudgetEditDialog() {
|
||||
function openBudgetAssistant() {
|
||||
if (!canEditBudget.value) return
|
||||
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() {
|
||||
confirmSaveOpen.value = false
|
||||
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: ''
|
||||
emit('openAssistant', {
|
||||
source: 'budget',
|
||||
sessionType: 'budget',
|
||||
prompt: '',
|
||||
files: [],
|
||||
conversation: null
|
||||
})
|
||||
}
|
||||
|
||||
const confirmDeleteOpen = ref(false)
|
||||
const rowToDelete = ref(null)
|
||||
|
||||
function removeBudgetDetailRow(rowId) {
|
||||
if (budgetEditRows.value.length <= 1) return
|
||||
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 requestSaveBudget() {
|
||||
if (!canEditBudget.value || budgetSaving.value) return
|
||||
confirmSaveOpen.value = true
|
||||
}
|
||||
|
||||
function cancelSaveBudget() {
|
||||
if (budgetSaving.value) return
|
||||
confirmSaveOpen.value = false
|
||||
}
|
||||
|
||||
function goToBudgetPage(page) {
|
||||
budgetPage.value = Math.min(Math.max(Number(page) || 1, 1), totalBudgetPages.value)
|
||||
@@ -453,44 +391,6 @@ export default {
|
||||
goToBudgetPage(currentBudgetPage.value + direction)
|
||||
}
|
||||
|
||||
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
|
||||
}))
|
||||
}
|
||||
|
||||
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
|
||||
@@ -568,13 +468,6 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmSaveBudget() {
|
||||
if (!canEditBudget.value || budgetSaving.value) return
|
||||
budgetEditForm.value.budgetStatus = BUDGET_STATUS_OPTIONS[0]
|
||||
await saveBudgetRows('saved')
|
||||
confirmSaveOpen.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadDepartments()
|
||||
})
|
||||
@@ -586,7 +479,8 @@ export default {
|
||||
() => filters.value.year,
|
||||
() => filters.value.quarter,
|
||||
() => filters.value.expenseType,
|
||||
() => filters.value.status
|
||||
() => filters.value.status,
|
||||
budgetTableKeyword
|
||||
],
|
||||
() => {
|
||||
budgetPage.value = 1
|
||||
@@ -609,52 +503,31 @@ export default {
|
||||
return {
|
||||
activeDepartmentCode,
|
||||
activeDepartmentName,
|
||||
addBudgetDetailRow,
|
||||
budgetEditForm,
|
||||
budgetEditOpen,
|
||||
budgetEditRows,
|
||||
budgetEditTotal,
|
||||
budgetError,
|
||||
budgetLoading,
|
||||
budgetMetrics,
|
||||
budgetOntologyContext,
|
||||
budgetSaving,
|
||||
budgetPage: currentBudgetPage,
|
||||
budgetPageNumbers,
|
||||
budgetPageSize,
|
||||
budgetPageSizeOptions,
|
||||
budgetTableKeyword,
|
||||
canEditBudget,
|
||||
canSwitchDepartments,
|
||||
closeBudgetEditDialog,
|
||||
controlActionOptions: BUDGET_CONTROL_ACTION_OPTIONS,
|
||||
changeBudgetPage,
|
||||
confirmSaveBudget,
|
||||
confirmSaveOpen,
|
||||
departmentKeyword,
|
||||
departments,
|
||||
expenseTypeOptions: BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS,
|
||||
expenseTypes: ['全部', ...EXPENSE_BLUEPRINTS.map((item) => item.expenseType)],
|
||||
filters,
|
||||
openBudgetEditDialog,
|
||||
openBudgetAssistant,
|
||||
quarters: BUDGET_QUARTER_OPTIONS,
|
||||
addBudgetDetailRow,
|
||||
removeBudgetDetailRow,
|
||||
confirmDeleteOpen,
|
||||
confirmDeleteRow,
|
||||
cancelDeleteRow,
|
||||
cancelSaveBudget,
|
||||
departmentOptions,
|
||||
requestSaveBudget,
|
||||
statusOptions: BUDGET_STATUS_OPTIONS,
|
||||
statuses: ['全部', '正常', '预警', '管控'],
|
||||
syncBudgetRowSubject,
|
||||
goToBudgetPage,
|
||||
totalBudgetPages,
|
||||
totalBudgetRows,
|
||||
budgetUsageData,
|
||||
visibleBudgetRows,
|
||||
visibleDepartments,
|
||||
warningOptions: BUDGET_WARNING_OPTIONS,
|
||||
warnings,
|
||||
yearOptions,
|
||||
years: BUDGET_YEAR_OPTIONS
|
||||
|
||||
Reference in New Issue
Block a user