Files
cma-management/frontend/src/views/BudgetManagement.vue
T

1737 lines
115 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div>
<h3>预算管理</h3>
<div v-if="noMap" style="padding:60px 0;text-align:center;color:#999;">
<p style="font-size:16px;margin-bottom:12px;">暂无已发布的战略地图</p>
<p style="font-size:13px;">预算编制需要基于已发布的战略地图请先在战略地图中创建并发布</p>
<el-button type="primary" style="margin-top:16px;" @click="$router.push('/maps')">去创建战略地图</el-button>
</div>
<div v-else>
<el-tabs v-model="activeTab" style="margin-top:16px;">
<el-tab-pane label="预算录入" name="input">
<div style="margin-bottom:8px;font-size:12px;color:#909399;display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
选择战略地图
<el-select v-model="selectedMapId" placeholder="选择已发布的战略地图" style="width:260px;" @change="onMapSelect">
<el-option v-for="m in publishedMaps" :key="m.id" :label="m.title" :value="m.id" />
</el-select>
<el-tag v-if="selectedMapId" size="small" type="success">已选择</el-tag>
<span style="color:#999;"> {{ total }} 条KPI</span>
</div>
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
<el-select v-model="filterYear" placeholder="年份" style="width:100px;" @change="loadBudget"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
<el-select v-model="filterVersion" placeholder="版本" style="width:180px;" @change="loadBudget" clearable>
<el-option v-for="v in versionsList" :key="v.version" :label="v.version + (v.status === 'active' ? ' (当前)' : '')" :value="v.version" />
</el-select>
<el-select v-model="filterMonth" placeholder="月份" style="width:90px;" @change="loadBudget"><el-option label="全年" :value="0" /><el-option v-for="m in 12" :key="m" :label="`${m}月`" :value="m" /></el-select>
<el-input v-model="searchKpi" placeholder="搜索KPI名称" clearable style="width:200px;" @clear="loadBudget" @keyup.enter="loadBudget" />
<el-button type="primary" @click="loadBudget">查询</el-button>
<el-button type="success" @click="showAddBudget = true">+ 新增预算</el-button>
<el-button @click="showDecompose = true" :disabled="!filterYear">年度分解</el-button>
<el-divider direction="vertical" />
<el-radio-group v-model="budgetViewMode" size="small" @change="onViewModeChange"><el-radio-button value="list">KPI列表</el-radio-button><el-radio-button value="summary">维度汇总</el-radio-button></el-radio-group>
<el-switch v-model="batchEditMode" active-text="批量编辑" inactive-text="逐行编辑" @change="onBatchEditToggle" />
<el-button v-if="batchEditMode" type="primary" @click="batchSaveAll" :loading="batchSavingAll" :disabled="changedRows.length === 0">批量保存 ({{ changedRows.length }})</el-button>
<el-button v-if="batchEditMode" @click="batchCancelAll">取消</el-button>
<el-divider direction="vertical" />
<div style="display:flex;align-items:center;gap:6px;font-size:12px;">
<span style="color:#909399;">预算模式</span>
<el-switch
v-model="budgetMode"
active-value="rolling"
inactive-value="fixed"
active-text="滚动预算"
inactive-text="固定预算"
@change="onBudgetModeChange"
/>
<el-tag v-if="budgetMode === 'rolling'" size="small" type="warning" effect="plain">滚动12月</el-tag>
<el-tag v-else size="small" type="info" effect="plain">固定年度</el-tag>
</div>
<el-button v-if="budgetMode === 'rolling'" size="small" type="warning" @click="doRollForward" :loading="rollingForward">延展</el-button>
</div>
<template v-if="budgetViewMode === 'list'">
<el-table :data="budgetList" v-loading="loading" border stripe size="small" style="width:100%;" :row-class-name="rowClass" :empty-text="filterYear ? '暂无数据,在行内编辑填值后保存即可创建' : '请先选择年份'">
<el-table-column type="index" label="#" width="40" />
<el-table-column prop="kpi_code" label="KPI编码" width="120" />
<el-table-column prop="kpi_name" label="KPI名称" min-width="160" />
<el-table-column prop="dimension" label="维度" width="80"><template #default="{ row }"><el-tag size="small" :type="dimTag(row.dimension)">{{ dimLabel(row.dimension) }}</el-tag></template></el-table-column>
<el-table-column prop="period" label="期间" width="90" />
<el-table-column prop="budget_value" label="预算值" width="170">
<template #default="{ row }"><el-input-number v-if="row._editing" v-model="row._editValue" :min="0" :precision="row.precision || 0" :step="row.step || 1" controls-position="right" style="width:155px;" @change="onEditChange(row)" /><span v-else>{{ formatNumber(row.budget_value, row.unit) }}</span></template>
</el-table-column>
<el-table-column prop="unit" label="单位" width="60" />
<!-- 行动方案列非财务维度显示 -->
<el-table-column label="行动方案" width="120">
<template #default="{ row }">
<span v-if="row.dimension === 'finance'" style="color:#ccc;font-size:12px;"></span>
<el-button v-else size="small" link type="primary" @click="showActionPlans(row)">
{{ row._actionCount != null ? `${row._actionCount}` : '关联' }}
</el-button>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button v-if="!batchEditMode && !row._editing" size="small" @click="startEdit(row)">编辑</el-button>
<template v-else-if="!batchEditMode"><el-button size="small" type="primary" @click="saveEdit(row)" :loading="row._saving">保存</el-button><el-button size="small" @click="cancelEdit(row)">取消</el-button></template>
<el-button v-if="!batchEditMode && row.id" size="small" type="danger" plain @click="doDelete(row)">删除</el-button>
<el-tag v-else-if="!batchEditMode" size="small" type="info" effect="plain" style="cursor:pointer" @click="startEdit(row)">待创建</el-tag>
</template>
</el-table-column>
</el-table>
</template>
<template v-if="budgetViewMode === 'summary'">
<el-row :gutter="16" style="margin-bottom:16px;">
<el-col :span="6" v-for="card in summaryCards" :key="card.key">
<el-card shadow="hover" :body-style="{ borderLeft: '4px solid ' + card.color }">
<div style="text-align:center;"><div style="font-size:14px;margin-bottom:4px;">{{ card.icon }} {{ card.name }}</div><div style="font-size:24px;font-weight:600;color:#333;">{{ formatNumber(card.totalBudget) }}</div><div style="font-size:11px;color:#999;margin-top:4px;">{{ card.kpiCount }}个KPI · 占比{{ card.ratio }}%</div></div>
</el-card>
</el-col>
</el-row>
<el-table :data="dimSummaryRows" border stripe size="small" style="width:100%;">
<el-table-column prop="dimension" label="维度" width="100"><template #default="{ row }"><el-tag size="small" :type="dimTag(row.dimension)">{{ dimLabel(row.dimension) }}</el-tag></template></el-table-column>
<el-table-column prop="kpiCount" label="KPI数" width="80" />
<el-table-column prop="totalBudget" label="预算总额" width="140"><template #default="{ row }">{{ formatNumber(row.totalBudget) }}</template></el-table-column>
<el-table-column prop="ratio" label="占比" width="100"><template #default="{ row }"><el-progress :percentage="row.ratio" :stroke-width="12" text-inside /></template></el-table-column>
<el-table-column prop="avgPerKpi" label="KPI均值" width="140"><template #default="{ row }">{{ formatNumber(row.avgPerKpi) }}</template></el-table-column>
</el-table>
</template>
</el-tab-pane>
<el-tab-pane label="年度分解" name="decompose">
<el-card>
<template #header><div style="display:flex;justify-content:space-between;align-items:center;"><span>年度预算 月度自动分解</span><el-button type="primary" @click="doDecompose" :loading="decomposing">执行分解</el-button></div></template>
<el-form :model="decomposeForm" label-width="120px" style="max-width:500px;">
<el-form-item label="年份"><el-select v-model="decomposeForm.year" style="width:150px;"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select></el-form-item>
<el-form-item label="分解方式"><el-radio-group v-model="decomposeForm.method"><el-radio value="equal">均分(1/12</el-radio><el-radio value="weighted">按历史权重</el-radio></el-radio-group></el-form-item>
</el-form>
<el-alert v-if="decomposeResult" :title="decomposeResult" type="success" show-icon :closable="false" style="margin-top:16px;" />
<el-table v-if="decomposeDetails.length > 0" :data="decomposeDetails" border stripe size="small" style="width:100%;margin-top:12px;" max-height="300">
<el-table-column prop="kpi_code" label="KPI编码" width="120" /><el-table-column prop="kpi_name" label="KPI名称" min-width="150" />
<el-table-column prop="annual_budget" label="年度预算" width="120"><template #default="{ row }">{{ formatNumber(row.annual_budget) }}</template></el-table-column>
<el-table-column prop="method" label="方式" width="80" /><el-table-column prop="monthly_count" label="月数" width="60" />
</el-table>
</el-card>
</el-tab-pane>
<el-tab-pane label="战略预算编制" name="strategy">
<div v-if="!selectedMap" style="padding:40px 0;text-align:center;color:#999;"><p>请先在预算录入中选择战略地图</p></div>
<template v-else>
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
<span style="font-weight:500;">{{ selectedMap.title }}</span><el-tag size="small" type="success">已发布</el-tag>
<el-select v-model="strategyFilterYear" placeholder="年份" style="width:100px;" @change="loadStrategyBudget"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
<el-select v-model="strategyFilterMonth" placeholder="月份" style="width:90px;" @change="loadStrategyBudget"><el-option label="全年" :value="0" /><el-option v-for="m in 12" :key="m" :label="`${m}月`" :value="m" /></el-select>
<el-button type="primary" size="small" @click="loadStrategyBudget">刷新</el-button>
<el-divider direction="vertical" /><span style="font-size:13px;color:#666;">已选 {{ strategySelectedKpis.length }} 个KPI</span>
<el-button size="small" @click="strategyBatchSetSame">统一设值</el-button>
<el-input-number v-model="strategyBatchValue" :min="0" controls-position="right" style="width:120px;" size="small" />
<el-button v-if="strategySelectedKpis.length > 0" size="small" type="primary" :loading="strategyBatchSaving" @click="strategyBatchSave">批量保存</el-button>
</div>
<div v-for="dim in strategyTree" :key="dim.key" class="strategy-dim-block">
<div class="strategy-dim-header" :style="{ borderLeftColor: dim.color }">
<span class="strategy-dim-icon">{{ dim.icon }}</span><span class="strategy-dim-name">{{ dim.name }}</span>
<span class="strategy-dim-summary">{{ dim.kpiCount }}个KPI · 预算合计{{ formatNumber(dim.totalBudget) }}</span>
<el-button size="small" link style="margin-left:auto;" @click="strategySelectDim(dim.key)">全选</el-button>
</div>
<div v-for="obj in dim.objectives" :key="obj.name" class="strategy-obj-block">
<div class="strategy-obj-header"><span class="strategy-obj-name">{{ obj.name }}</span></div>
<el-table :data="obj.kpis" border stripe size="small" style="width:100%;" :show-header="false" @selection-change="(sel: any[]) => onStrategyKpiSelect(sel, dim.key)">
<el-table-column type="selection" width="36" />
<el-table-column prop="kpi_code" label="编码" width="110" /><el-table-column prop="kpi_name" label="KPI名称" min-width="160" />
<el-table-column label="战略目标" width="110"><template #default="{ row }"><span style="color:#e6a23c;font-size:12px;">{{ row.target != null ? formatNumber(row.target, row.unit) : '-' }}</span></template></el-table-column>
<el-table-column label="预算值" width="190">
<template #default="{ row }">
<el-input-number v-model="row._editValue" :min="0" :precision="row.precision || 0" :step="row.step || 1" controls-position="right" style="width:150px;" @change="row._changed = (row._editValue !== (row.budget_value ?? 0))" />
<span style="font-size:12px;color:#999;margin-left:2px;">{{ row.unit }}</span>
<el-button v-if="row._changed" size="small" type="primary" link @click="saveStrategyKpi(row)" :loading="row._saving" style="margin-left:4px;">保存</el-button>
</template>
</el-table-column>
<el-table-column prop="period" label="期间" width="90" /><el-table-column prop="version" label="版本" width="60" />
</el-table>
</div>
</div>
</template>
</el-tab-pane>
<el-tab-pane label="版本管理" name="versions">
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center;">
<el-select v-model="versionYear" placeholder="年份" style="width:120px;"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
<el-button type="primary" @click="loadVersions">刷新</el-button>
</div>
<el-table :data="versionsList" v-loading="versionsLoading" border stripe size="small" style="width:100%;">
<el-table-column prop="version" label="版本" width="90"><template #default="{ row }"><el-tag :type="row.version === currentVersion ? 'success' : 'info'" size="small">{{ row.version }}</el-tag></template></el-table-column>
<el-table-column prop="approval_status" label="状态" width="90">
<template #default="{ row }"><el-tag v-if="row.approval_status === 'approved'" type="success" size="small">已批准</el-tag><el-tag v-else-if="row.approval_status === 'submitted'" type="warning" size="small">待审批</el-tag><el-tag v-else-if="row.approval_status === 'rejected'" type="danger" size="small">已驳回</el-tag><el-tag v-else type="info" size="small">草稿</el-tag></template>
</el-table-column>
<el-table-column prop="plan_count" label="预算条目数" width="100" />
<el-table-column prop="total_budget" label="预算总额" width="130"><template #default="{ row }">{{ formatNumber(row.total_budget) }}</template></el-table-column>
<el-table-column prop="last_updated" label="最后更新" min-width="150" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button v-if="row.approval_status === 'draft'" size="small" @click="submitVersion(row)">提交审批</el-button>
<template v-if="row.approval_status === 'submitted'"><el-button size="small" type="success" @click="approveVersion(row, 'approved')">批准</el-button><el-button size="small" type="danger" @click="approveVersion(row, 'rejected')">驳回</el-button></template>
<el-button v-if="row.approval_status === 'rejected'" size="small" @click="resubmitVersion(row)">重新提交</el-button>
<el-checkbox v-model="row._selected" @change="onVersionSelect(row)" :disabled="selectedVersions.length >= 2 && !row._selected" style="margin-left:8px;" />
</template>
</el-table-column>
</el-table>
<div v-if="selectedVersions.length === 2" style="margin-top:16px;"><el-button type="primary" @click="loadVersionDiff" :loading="diffLoading">对比 {{ selectedVersions[0].version }} vs {{ selectedVersions[1].version }}</el-button></div>
</el-tab-pane>
<el-tab-pane label="预算执行" name="execution">
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
<el-select v-model="execFilterYear" placeholder="年份" style="width:100px;" @change="loadExecutionReport"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
<el-select v-model="execFilterMonth" placeholder="截止月份" style="width:110px;" @change="loadExecutionReport"><el-option v-for="m in 12" :key="m" :label="`至${m}月`" :value="m" /></el-select>
<el-select v-model="execFilterDim" placeholder="维度" clearable style="width:110px;" @change="loadExecutionReport"><el-option label="财务" value="finance" /><el-option label="客户" value="customer" /><el-option label="内部流程" value="process" /><el-option label="学习成长" value="learning" /></el-select>
<el-button type="primary" @click="loadExecutionReport">刷新</el-button>
<el-button type="warning" :loading="syncingCash" @click="syncCashPlans">⇄ 同步现金流计划</el-button>
</div>
<el-row :gutter="16" style="margin-bottom:16px;">
<el-col :span="6" v-for="card in execSummaryCards" :key="card.label"><el-card shadow="hover"><div style="text-align:center;"><div style="font-size:12px;color:#999;">{{ card.label }}</div><div style="font-size:22px;font-weight:600;margin-top:4px;" :style="{color: card.color}">{{ card.value }}</div></div></el-card></el-col>
</el-row>
<el-table :data="execReport" v-loading="execLoading" border stripe size="small" style="width:100%;">
<el-table-column prop="kpi_code" label="编码" width="110" /><el-table-column prop="kpi_name" label="KPI名称" min-width="140" />
<el-table-column prop="dimension" label="维度" width="80"><template #default="{ row }"><el-tag size="small" :type="dimTag(row.dimension)">{{ dimLabel(row.dimension) }}</el-tag></template></el-table-column>
<el-table-column prop="budget_value" label="预算值" width="130"><template #default="{ row }">{{ formatNumber(row.budget_value, row.unit) }}</template></el-table-column>
<!-- 战略目标 vs 预算(2026-08-27: 允许不同, 差异可见可解释) -->
<el-table-column prop="strategic_target" label="战略目标" width="120"><template #default="{ row }"><span :style="{color: row.target_gap_level === 'high' ? '#f56c6c' : row.target_gap_level === 'medium' ? '#e6a23c' : '#606266'}">{{ formatNumber(row.strategic_target, row.unit) }}</span></template></el-table-column>
<el-table-column label="目标差异" width="110"><template #default="{ row }">
<span v-if="row.target_gap_pct != null" :style="{color: row.target_gap_level === 'high' ? '#f56c6c' : row.target_gap_level === 'medium' ? '#e6a23c' : '#67c23a', fontWeight:600}">{{ row.target_gap_pct > 0 ? '+' : '' }}{{ row.target_gap_pct }}%</span>
<el-tooltip v-if="row.target_gap_level === 'high'" content="预算偏离战略目标超20%需说明原因" placement="top"><span style="margin-left:4px;cursor:help;">⚠️</span></el-tooltip>
<span v-else style="color:#c0c4cc;">-</span>
</template></el-table-column>
<el-table-column prop="actual_value" label="实际值" width="130"><template #default="{ row }">{{ formatNumber(row.actual_value, row.unit) }}</template></el-table-column>
<el-table-column prop="deviation_value" label="偏差" width="130"><template #default="{ row }"><span :style="{color: row.deviation_value > 0 ? '#f56c6c' : row.deviation_value < 0 ? '#67c23a' : '#999'}">{{ row.deviation_value > 0 ? '+' : '' }}{{ formatNumber(row.deviation_value, row.unit) }}</span></template></el-table-column>
<el-table-column label="执行率" width="100"><template #default="{ row }"><el-progress :percentage="row.execution_rate || 0" :status="row.execution_rate > 100 ? 'exception' : row.execution_rate > 80 ? 'warning' : 'success'" :stroke-width="16" :text-inside="true" /></template></el-table-column>
<el-table-column label="预警" width="80"><template #default="{ row }"><el-tag v-if="row.alert_level === 'red'" size="small" type="danger">严重</el-tag><el-tag v-else-if="row.alert_level === 'yellow'" size="small" type="warning">关注</el-tag><el-tag v-else size="small" type="success">正常</el-tag></template></el-table-column>
</el-table>
<!-- P2-⑥ 现金流分类规则 + 待分类队列 -->
<el-row :gutter="16" style="margin-top:20px;">
<el-col :span="12">
<el-card shadow="never">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span><strong>现金流分类规则</strong> <span style="font-size:12px;color:#999;">KPI→receive/pay 可维护)</span></span>
<el-button type="primary" size="small" @click="showClassifyRuleDialog = true">+ 新建规则</el-button>
</div>
</template>
<el-table :data="cashClassifyRules" border stripe size="small" style="width:100%;" max-height="240">
<el-table-column prop="kpi_code" label="KPI编码" width="110"><template #default="{ row }">{{ row.kpi_code || row.kpi_code_pattern || '--' }}</template></el-table-column>
<el-table-column prop="kpi_name" label="名称/关键词" min-width="110"><template #default="{ row }">{{ row.kpi_name || row.kpi_code_pattern || '--' }}</template></el-table-column>
<el-table-column label="类型" width="80">
<template #default="{ row }"><el-tag size="small" :type="row.plan_type === 'receive' ? 'success' : 'danger'">{{ row.plan_type === 'receive' ? '收' : '付' }}</el-tag></template>
</el-table-column>
<el-table-column prop="priority" label="优先级" width="70" />
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }"><el-button size="small" link type="danger" @click="deleteClassifyRule(row)">删除</el-button></template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span><strong>待分类队列</strong> <span style="font-size:12px;color:#999;">(无法判别的KPI不再静默跳过)</span></span>
<el-badge :value="unclassifiedCount" :hidden="unclassifiedCount === 0" type="danger"><el-button size="small" @click="loadCashClassify">刷新</el-button></el-badge>
</div>
</template>
<el-table :data="unclassifiedRows" border stripe size="small" style="width:100%;" max-height="240">
<el-table-column prop="kpi_name" label="KPI" min-width="120" />
<el-table-column prop="period" label="期间" width="80" />
<el-table-column prop="budget_value" label="预算值" width="90"><template #default="{ row }">{{ formatNumber(row.budget_value) }}</template></el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="success" @click="classifyUnclassified(row, 'receive')">收</el-button>
<el-button size="small" link type="danger" @click="classifyUnclassified(row, 'pay')">付</el-button>
<el-button size="small" link @click="ignoreUnclassified(row)">忽略</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="unclassifiedRows.length === 0" style="padding:16px 0;text-align:center;color:#999;font-size:13px;">暂无待分类KPI,同步现金流计划后未命中规则的KPI会出现在这里</div>
</el-card>
</el-col>
</el-row>
</el-tab-pane>
<el-tab-pane label="编制方法" name="method">
<div>
<p style="font-size:14px;color:#606266;margin-bottom:16px;">选择预算编制方法 — CMA P1 预算编制(增量预算 vs 零基预算 vs 弹性预算)</p>
<!-- 方法选择卡片 -->
<div style="display:flex;gap:16px;margin-bottom:20px;flex-wrap:wrap;">
<div v-for="m in budgetMethods" :key="m.id"
:class="['method-card', { selected: selectedMethod === m.id, recommended: m.is_recommended }]"
@click="selectedMethod = m.id; loadMethodDetail(m.id)"
style="flex:1;min-width:220px;border:2px solid #e8e8e8;border-radius:10px;padding:16px;cursor:pointer;transition:all .2s;position:relative;">
<div v-if="m.is_recommended" style="position:absolute;top:-10px;right:12px;background:#e6a23c;color:#fff;font-size:11px;padding:2px 10px;border-radius:8px;">推荐</div>
<div style="font-size:12px;color:#999;">{{ m.name_en }}</div>
<div style="font-size:18px;font-weight:600;margin:6px 0;">{{ m.name }}</div>
<div style="font-size:13px;color:#606266;margin-bottom:8px;">{{ m.pros }} · {{ m.cons }}</div>
<div v-if="m.result_value != null" style="font-size:24px;font-weight:700;color:#409eff;">{{ formatNumber(m.result_value) }}<span style="font-size:14px;font-weight:400;color:#999;"> 万</span></div>
<div v-if="m.savings" style="font-size:12px;color:#67c23a;">节省 {{ formatNumber(m.savings) }} 万</div>
<div v-if="m.variance" style="font-size:12px;" :style="{ color: m.variance > 0 ? '#f56c6c' : '#67c23a' }">差异 {{ m.variance > 0 ? '+' : '' }}{{ formatNumber(m.variance) }} 万</div>
</div>
</div>
<!-- 方法详情 -->
<el-card v-if="selectedMethodDetail" shadow="never" style="margin-bottom:12px;">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span><strong>{{ selectedMethodDetail.name }}</strong> <span style="font-size:12px;color:#999;">{{ selectedMethodDetail.name_en }}</span></span>
<el-tag v-if="selectedMethodDetail.is_recommended" type="warning" effect="dark">推荐</el-tag>
</div>
</template>
<p style="margin:0 0 8px;color:#606266;">{{ selectedMethodDetail.detail }}</p>
<div style="display:flex;gap:20px;flex-wrap:wrap;">
<div><span style="color:#909399;">优点:</span>{{ selectedMethodDetail.pros }}</div>
<div><span style="color:#909399;">缺点:</span>{{ selectedMethodDetail.cons }}</div>
</div>
<div v-if="selectedMethodDetail.result_value != null" style="margin-top:12px;padding:10px 14px;background:#f0f9ff;border-radius:6px;display:flex;align-items:center;gap:12px;">
<span style="color:#909399;">预算结果:</span>
<span style="font-size:22px;font-weight:700;color:#409eff;">{{ formatNumber(selectedMethodDetail.result_value) }} 万</span>
</div>
</el-card>
<div style="display:flex;gap:8px;margin-top:16px;">
<el-button v-if="selectedMethod" type="primary" @click="confirmMethod">确认选择({{ selectedMethodName }}</el-button>
<el-button @click="refreshMethodComparison">刷新计算</el-button>
</div>
<!-- P2-① 零基逐项论证 -->
<el-card shadow="never" style="margin-top:20px;">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
<span><strong>零基逐项论证</strong> <span style="font-size:12px;color:#999;">(真零基:逐项输入基准/论证/建议值 → 生成预算)</span></span>
<div style="display:flex;gap:8px;align-items:center;">
<el-select v-model="zbbKpiId" placeholder="选择KPI" filterable style="width:200px;" size="small" @change="loadZeroBasedItems">
<el-option v-for="k in zbbKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" />
</el-select>
<el-select v-model="zbbPeriod" placeholder="期间" style="width:100px;" size="small" @change="loadZeroBasedItems">
<el-option v-for="p in zbbPeriodOptions" :key="p" :label="p" :value="p" />
</el-select>
<el-button type="primary" size="small" @click="addZeroBasedItem">+ 论证项</el-button>
<el-button type="warning" size="small" :loading="zbbGenerating" :disabled="!zbbKpiId || !zbbPeriod" @click="generateZeroBased">生成零基预算</el-button>
</div>
</div>
</template>
<el-alert v-if="zbbTotalProposed != null" :title="`论证项合计: ${formatNumber(zbbTotalProposed)} 万(Σ建议值)`" type="success" :closable="false" show-icon style="margin-bottom:10px;" />
<el-table :data="zeroBasedItems" border stripe size="small" style="width:100%;">
<el-table-column prop="item_name" label="费用科目" min-width="120" />
<el-table-column prop="item_category" label="类别" width="100">
<template #default="{ row }">
<el-tag size="small" :type="row.item_category === 'fixed' ? 'info' : row.item_category === 'variable' ? 'warning' : 'primary'">
{{ row.item_category === 'fixed' ? '固定' : row.item_category === 'variable' ? '变动' : '酌量' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="base_value" label="基准值" width="90"><template #default="{ row }">{{ formatNumber(row.base_value) }}</template></el-table-column>
<el-table-column prop="justification" label="逐项论证理由" min-width="160" />
<el-table-column prop="proposed_value" label="论证后金额" width="100"><template #default="{ row }"><b>{{ formatNumber(row.proposed_value) }}</b></template></el-table-column>
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }"><el-tag size="small" :type="row.status === 'approved' ? 'success' : 'info'">{{ row.status === 'approved' ? '已批准' : '草稿' }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="editZeroBasedItem(row)">编辑</el-button>
<el-button size="small" link type="danger" @click="deleteZeroBasedItem(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="zeroBasedItems.length === 0" style="padding:20px 0;text-align:center;color:#999;font-size:13px;">请选择KPI和期间后录入逐项论证</div>
</el-card>
<!-- P2-② 派生规则配置 -->
<el-card shadow="never" style="margin-top:16px;">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span><strong>派生规则配置</strong> <span style="font-size:12px;color:#999;">apply-method 派生KPI时优先读规则,替代固定比例)</span></span>
<el-button type="primary" size="small" @click="showDerivationRuleDialog = true">+ 新建规则</el-button>
</div>
</template>
<el-table :data="derivationRules" border stripe size="small" style="width:100%;">
<el-table-column prop="kpi_code" label="目标KPI编码" width="110" />
<el-table-column prop="kpi_name" label="目标KPI" min-width="110" />
<el-table-column prop="rule_type" label="规则类型" width="130">
<template #default="{ row }">
<el-tag size="small" :type="row.rule_type === 'percentage_of' ? 'primary' : row.rule_type === 'incremental' ? 'warning' : 'info'">
{{ row.rule_type === 'percentage_of' ? '按来源比例' : row.rule_type === 'incremental' ? '增量' : '公式' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="参数" width="120">
<template #default="{ row }"><span v-if="row.params">{{ (row.params.rate != null ? `比例 ${(row.params.rate * 100).toFixed(0)}%` : '') }}</span></template>
</el-table-column>
<el-table-column label="来源KPI" width="130">
<template #default="{ row }"><span v-if="row.base_kpi_code">{{ row.base_kpi_code }}</span><span v-else style="color:#ccc;">--</span></template>
</el-table-column>
<el-table-column prop="formula_text" label="公式说明" min-width="160" />
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="danger" @click="deleteDerivationRule(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</el-tab-pane>
<el-tab-pane label="驱动因子预算" name="driver">
<DriverFactorBudget />
</el-tab-pane>
<el-tab-pane label="实际值归集" name="collect">
<el-row :gutter="16" style="margin-bottom:16px;">
<el-col :span="6" v-for="c in collectCoverageCards" :key="c.label">
<el-card shadow="hover"><div style="text-align:center;"><div style="font-size:12px;color:#999;">{{ c.label }}</div><div style="font-size:22px;font-weight:600;margin-top:4px;" :style="{color: c.color}">{{ c.value }}</div></div></el-card>
</el-col>
</el-row>
<el-tabs v-model="collectTab" type="border-card" style="margin-top:4px;">
<el-tab-pane label="取数映射" name="mappings">
<div style="display:flex;gap:8px;margin-bottom:12px;align-items:center;flex-wrap:wrap;">
<el-select v-model="collectKpiId" placeholder="选择KPI(可选)" filterable clearable style="width:220px;" @change="loadValueSources">
<el-option v-for="k in zbbKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" />
</el-select>
<el-button type="primary" @click="showValueSourceDialog = true">+ 新建映射</el-button>
<el-button type="warning" :loading="collectRunning" @click="runValueCollect">▶ 立即采集</el-button>
<el-button @click="loadCollectData">刷新</el-button>
</div>
<el-table :data="valueSources" border stripe size="small" style="width:100%;">
<el-table-column prop="kpi_code" label="KPI编码" width="100" />
<el-table-column prop="kpi_name" label="KPI名称" min-width="120" />
<el-table-column prop="source_table" label="源头表" width="160">
<template #default="{ row }"><el-tag size="small">{{ row.source_table }}</el-tag></template>
</el-table-column>
<el-table-column prop="source_field" label="金额字段" width="110" />
<el-table-column prop="aggregate" label="聚合" width="70" />
<el-table-column label="过滤规则" width="140">
<template #default="{ row }"><span style="font-size:12px;">{{ JSON.stringify(row.filter_rule || {}) }}</span></template>
</el-table-column>
<el-table-column prop="unit_conversion" label="倍率" width="60" />
<el-table-column label="状态" width="80">
<template #default="{ row }"><el-tag size="small" :type="row.status === 'active' ? 'success' : 'info'">{{ row.status === 'active' ? '启用' : '停用' }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="testValueSource(row)">试跑</el-button>
<el-button size="small" link type="warning" @click="toggleValueSource(row)">{{ row.status === 'active' ? '停用' : '启用' }}</el-button>
<el-button size="small" link type="danger" @click="deleteValueSource(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="实际值标签" name="values">
<div style="display:flex;gap:8px;margin-bottom:12px;align-items:center;">
<el-radio-group v-model="valueTagFilter" size="small">
<el-radio-button value="auto_collect">已自动归集</el-radio-button>
<el-radio-button value="manual">需人工确认</el-radio-button>
<el-radio-button value="all">全部</el-radio-button>
</el-radio-group>
<span style="font-size:12px;color:#999;">对账页将按数据来源分类显示,自动归集数据可追溯采集批次</span>
</div>
<el-table :data="valueTagRows" border stripe size="small" style="width:100%;">
<el-table-column prop="kpi_code" label="KPI编码" width="100" />
<el-table-column prop="kpi_name" label="KPI名称" min-width="120" />
<el-table-column prop="period" label="期间" width="90" />
<el-table-column prop="actual_value" label="实际值" width="110"><template #default="{ row }">{{ formatNumber(row.actual_value) }}</template></el-table-column>
<el-table-column label="来源" width="130">
<template #default="{ row }">
<el-tag v-if="row.source_type === 'auto_collect'" size="small" type="success">已自动归集</el-tag>
<el-tag v-else-if="row.source_type === 'manual' || row.source_type === 'excel'" size="small" type="warning">需人工确认</el-tag>
<el-tag v-else size="small" type="info">{{ row.source_type || 'manual' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="source_batch" label="采集批次" width="170"><template #default="{ row }"><span style="font-size:12px;">{{ row.source_batch || '--' }}</span></template></el-table-column>
<el-table-column prop="remark" label="备注" min-width="150" />
</el-table>
</el-tab-pane>
<el-tab-pane label="采集日志" name="logs">
<div style="display:flex;gap:8px;margin-bottom:12px;align-items:center;">
<el-select v-model="collectLogStatus" placeholder="状态" clearable style="width:110px;" size="small">
<el-option label="成功" value="success" /><el-option label="失败" value="failed" />
</el-select>
<el-button size="small" type="primary" @click="loadCollectLogs">查询</el-button>
</div>
<el-table :data="collectLogs" border stripe size="small" style="width:100%;">
<el-table-column prop="kpi_code" label="KPI编码" width="100" />
<el-table-column prop="kpi_name" label="KPI名称" min-width="110" />
<el-table-column prop="period" label="期间" width="90" />
<el-table-column prop="source_table" label="源头表" width="140" />
<el-table-column prop="collected_value" label="采集值" width="100"><template #default="{ row }">{{ formatNumber(row.collected_value) }}</template></el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }"><el-tag size="small" :type="row.status === 'success' ? 'success' : 'danger'">{{ row.status === 'success' ? '成功' : '失败' }}</el-tag></template>
</el-table-column>
<el-table-column prop="message" label="说明" min-width="180" />
<el-table-column prop="collected_at" label="采集时间" width="160" />
</el-table>
</el-tab-pane>
</el-tabs>
</el-tab-pane>
<el-tab-pane label="持续规划" name="rolling">
<el-tabs v-model="rollingTab" type="border-card" style="margin-top:4px;">
<el-tab-pane label="实际vs预测对比" name="comparison">
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
<el-select v-model="comparisonYear" placeholder="年份" style="width:100px;"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
<el-select v-model="comparisonKpiId" placeholder="选择KPI(可选)" filterable clearable style="width:220px;" @change="loadComparison">
<el-option v-for="k in comparisonKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" />
</el-select>
<el-button type="primary" @click="loadComparison">刷新</el-button>
<el-tag v-if="budgetMode === 'rolling'" size="small" type="warning">滚动预算 - 当前月之后为预测值</el-tag>
<el-tag v-else size="small" type="info">固定预算</el-tag>
</div>
<!-- 对比图表 -->
<el-card v-if="comparisonData.length > 0" shadow="hover" style="margin-bottom:16px;">
<div ref="comparisonChartRef" style="width:100%;height:360px;"></div>
</el-card>
<!-- 分界点指示 & 摘要 -->
<el-row :gutter="16" style="margin-bottom:16px;" v-if="comparisonSummary">
<el-col :span="6" v-for="s in comparisonSummary" :key="s.label">
<el-card shadow="hover">
<div style="text-align:center;">
<div style="font-size:12px;color:#999;">{{ s.label }}</div>
<div style="font-size:20px;font-weight:600;margin-top:4px;" :style="{color: s.color}">{{ s.value }}</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 对比详情表格 -->
<el-table :data="comparisonData" v-loading="comparisonLoading" border stripe size="small" style="width:100%;">
<el-table-column prop="period" label="期间" width="100" />
<el-table-column prop="budget_total" label="预算值" width="150"><template #default="{ row }">{{ formatNumber(row.budget_total) }}</template></el-table-column>
<el-table-column prop="actual_total" label="实际值" width="150"><template #default="{ row }">{{ row.actual_total != null ? formatNumber(row.actual_total) : '--' }}</template></el-table-column>
<el-table-column label="偏差率" width="130">
<template #default="{ row }">
<el-tag v-if="row.deviation_rate != null" :type="Math.abs(row.deviation_rate) > 20 ? 'danger' : Math.abs(row.deviation_rate) > 10 ? 'warning' : 'success'" size="small">
{{ row.deviation_rate > 0 ? '+' : '' }}{{ row.deviation_rate }}%
</el-tag>
<span v-else style="color:#ccc;">--</span>
</template>
</el-table-column>
<el-table-column label="类型标记" width="120">
<template #default="{ row }">
<el-tag v-if="row.is_prediction" size="small" type="warning">预测值</el-tag>
<el-tag v-else-if="row.is_current_period" size="small" type="danger">当前期</el-tag>
<el-tag v-else size="small" type="success">已发生</el-tag>
</template>
</el-table-column>
</el-table>
<div v-if="comparisonData.length === 0 && !comparisonLoading" style="padding:40px 0;text-align:center;color:#999;">
<p>暂无对比数据,请先录入预算和实际值</p>
</div>
</el-tab-pane>
<el-tab-pane label="偏差告警" name="alerts">
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
<el-select v-model="alertFilterPeriod" placeholder="期间" style="width:120px;">
<el-option v-for="p in alertPeriodOptions" :key="p" :label="p" :value="p" />
</el-select>
<el-select v-model="alertFilterLevel" placeholder="级别" clearable style="width:100px;">
<el-option label="严重" value="critical" />
<el-option label="警告" value="warning" />
</el-select>
<el-select v-model="alertFilterStatus" placeholder="状态" clearable style="width:100px;">
<el-option label="未处理" value="open" />
<el-option label="已解决" value="resolved" />
<el-option label="已忽略" value="ignored" />
</el-select>
<el-button type="primary" @click="loadDeviationAlerts">查询</el-button>
<el-button type="danger" @click="doDeviationCheck" :loading="deviationChecking">执行偏差检查 (超20%告警)</el-button>
</div>
<el-alert
v-if="deviationAlertMessage"
:title="deviationAlertMessage"
type="info"
show-icon
:closable="true"
style="margin-bottom:12px;"
@close="deviationAlertMessage = ''"
/>
<el-table :data="deviationAlerts" v-loading="alertLoading" border stripe size="small" style="width:100%;">
<el-table-column prop="period" label="期间" width="90" />
<el-table-column prop="kpi_code" label="KPI编码" width="100" />
<el-table-column prop="kpi_name" label="KPI名称" min-width="140" />
<el-table-column prop="budget_value" label="预算值" width="120"><template #default="{ row }">{{ formatNumber(row.budget_value) }}</template></el-table-column>
<el-table-column prop="actual_value" label="实际值" width="120"><template #default="{ row }">{{ formatNumber(row.actual_value) }}</template></el-table-column>
<el-table-column prop="deviation_rate" label="偏差率" width="110">
<template #default="{ row }">
<el-tag :type="Math.abs(row.deviation_rate) > 50 ? 'danger' : 'warning'" size="small">
{{ row.deviation_rate > 0 ? '+' : '' }}{{ row.deviation_rate }}%
</el-tag>
</template>
</el-table-column>
<el-table-column label="级别" width="80">
<template #default="{ row }">
<el-tag :type="row.alert_level === 'critical' ? 'danger' : 'warning'" size="small">
{{ row.alert_level === 'critical' ? '严重' : '警告' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="suggestion" label="建议" min-width="180" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag v-if="row.status === 'open'" type="danger" size="small">未处理</el-tag>
<el-tag v-else-if="row.status === 'resolved'" type="success" size="small">已解决</el-tag>
<el-tag v-else type="info" size="small">已忽略</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" plain @click="showAlertAttribution(row)">归因</el-button>
<el-button v-if="row.status === 'open'" size="small" type="success" @click="resolveAlert(row)">解决</el-button>
<el-button v-else-if="row.status === 'resolved'" size="small" @click="reopenAlert(row)">重开</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="deviationAlerts.length === 0 && !alertLoading" style="padding:40px 0;text-align:center;color:#999;">
<p>暂无偏差预警,点击「执行偏差检查」扫描当前期间</p>
</div>
</el-tab-pane>
<!-- 告警归因详情弹窗 (P1-③) -->
<el-dialog v-model="showAttributionDialog" title="告警归因分析" width="720" append-to-body>
<template v-if="attributionDetail">
<el-descriptions :column="2" border size="small" style="margin-bottom:12px;">
<el-descriptions-item label="KPI">{{ attributionDetail.kpi_name }}</el-descriptions-item>
<el-descriptions-item label="期间">{{ attributionDetail.period }}</el-descriptions-item>
<el-descriptions-item label="预算">{{ formatNumber(attributionDetail.budget_value) }}</el-descriptions-item>
<el-descriptions-item label="实际">{{ formatNumber(attributionDetail.actual_value) }}</el-descriptions-item>
<el-descriptions-item label="偏差率">
<el-tag :type="Math.abs(attributionDetail.deviation_rate) > 50 ? 'danger' : 'warning'" size="small">
{{ attributionDetail.deviation_rate > 0 ? '+' : '' }}{{ attributionDetail.deviation_rate }}%
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="量价差">
<el-tag size="small" :type="attributionDetail.attribution?.variance_type === 'quantity_diff' ? 'warning' : attributionDetail.attribution?.variance_type === 'price_diff' ? 'danger' : 'info'">
{{ attributionDetail.attribution?.variance_type === 'quantity_diff' ? '量差' : attributionDetail.attribution?.variance_type === 'price_diff' ? '价差' : attributionDetail.attribution?.variance_type === 'mixed' ? '量价混合' : '--' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<!-- 趋势标识 -->
<div v-if="attributionDetail.attribution?.trend?.anomaly" style="margin-bottom:12px;">
<el-alert :title="attributionDetail.attribution.trend.message" type="warning" show-icon :closable="false">
<template #default>
<span style="font-size:12px;">{{ (attributionDetail.attribution.trend.periods || []).join(' → ') }}</span>
</template>
</el-alert>
</div>
<!-- 子KPI维度拆解 -->
<div v-if="attributionDetail.attribution?.dimensions?.length" style="margin-bottom:12px;">
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">📊 子KPI维度拆解(量差方向)</div>
<el-table :data="attributionDetail.attribution.dimensions" border stripe size="small">
<el-table-column prop="kpi_name" label="子KPI" min-width="120" />
<el-table-column prop="weight" label="权重" width="70"><template #default="{ row }">{{ row.weight }}%</template></el-table-column>
<el-table-column prop="budget_value" label="预算" width="90"><template #default="{ row }">{{ formatNumber(row.budget_value) }}</template></el-table-column>
<el-table-column prop="actual_value" label="实际" width="90"><template #default="{ row }">{{ formatNumber(row.actual_value) }}</template></el-table-column>
<el-table-column label="差异率" width="100">
<template #default="{ row }">
<span v-if="row.deviation_rate != null" :style="{ color: row.deviation_rate > 0 ? '#f56c6c' : row.deviation_rate < 0 ? '#67c23a' : '#999' }">
{{ row.deviation_rate > 0 ? '+' : '' }}{{ row.deviation_rate }}%
</span>
<span v-else style="color:#ccc;">--</span>
</template>
</el-table-column>
</el-table>
</div>
<!-- 科目明细拆解 -->
<div v-if="attributionDetail.attribution?.subjects?.length" style="margin-bottom:12px;">
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">💡 科目明细拆解(价差方向)</div>
<el-table :data="attributionDetail.attribution.subjects" border stripe size="small">
<el-table-column prop="subject_code" label="科目编码" width="90" />
<el-table-column prop="subject_name" label="科目" min-width="110" />
<el-table-column prop="amount_diff" label="发生额差" width="100"><template #default="{ row }">{{ formatNumber(row.amount_diff) }}</template></el-table-column>
<el-table-column prop="share_pct" label="占比" width="80"><template #default="{ row }">{{ row.share_pct }}%</template></el-table-column>
</el-table>
</div>
<!-- 场景建议 -->
<div v-if="attributionDetail.scenario" style="border:1px solid #e6a23c;border-radius:8px;padding:12px;background:#fdf6ec;">
<div style="font-weight:600;font-size:13px;color:#e6a23c;margin-bottom:6px;">🎯 场景建议:{{ attributionDetail.scenario.title }}</div>
<div style="font-size:13px;color:#606266;white-space:pre-line;">{{ attributionDetail.scenario.description }}</div>
<div v-if="attributionDetail.scenario.action_template" style="margin-top:8px;font-size:12px;color:#606266;background:#fff;border-radius:6px;padding:8px 10px;white-space:pre-line;">
<strong>行动模板:</strong>{{ attributionDetail.scenario.action_template }}
</div>
</div>
<div v-else style="border:1px solid #ebeef5;border-radius:8px;padding:12px;color:#999;font-size:13px;">
暂无匹配的场景建议模板
</div>
</template>
<template #footer><el-button @click="showAttributionDialog = false">关闭</el-button></template>
</el-dialog>
<!-- 年度预算分解弹窗 (P1-① 年度分解按钮修复; MyDialog 铁律) -->
<MyDialog v-model="showDecompose" title="年度预算分解" :width="640">
<el-form :model="decomposeForm" label-width="100px" style="max-width:480px;">
<el-form-item label="年份">
<el-select v-model="decomposeForm.year" style="width:150px;">
<el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" />
</el-select>
</el-form-item>
<el-form-item label="分解方式">
<el-radio-group v-model="decomposeForm.method">
<el-radio value="equal">均分(1/12</el-radio>
<el-radio value="weighted">按历史权重</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<el-alert v-if="decomposeResult" :title="decomposeResult" type="success" show-icon :closable="false" style="margin-top:8px;" />
<el-table v-if="decomposeDetails.length > 0" :data="decomposeDetails" border stripe size="small" style="width:100%;margin-top:12px;" max-height="300">
<el-table-column prop="kpi_code" label="KPI编码" width="120" />
<el-table-column prop="kpi_name" label="KPI名称" min-width="150" />
<el-table-column prop="annual_budget" label="年度预算" width="120"><template #default="{ row }">{{ formatNumber(row.annual_budget) }}</template></el-table-column>
<el-table-column prop="method" label="方式" width="80" />
<el-table-column prop="monthly_count" label="月数" width="60" />
</el-table>
<template #footer>
<el-button @click="showDecompose = false">取消</el-button>
<el-button type="primary" @click="doDecompose" :loading="decomposing">执行分解</el-button>
</template>
</MyDialog>
</el-tabs>
</el-tab-pane>
</el-tabs>
<MyDialog v-model="showAddBudget" title="新增预算" :width="500">
<el-form :model="addForm" label-width="80px">
<el-form-item label="KPI"><el-select v-model="addForm.kpi_id" placeholder="搜索并选择KPI" filterable remote :remote-method="searchKpiForAdd" :loading="addKpiLoading" style="width:100%;" :teleported="false"><el-option v-for="k in addKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" /></el-select></el-form-item>
<el-form-item label="年份"><el-select v-model="addForm.year" style="width:120px;"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select></el-form-item>
<el-form-item label="月份"><el-select v-model="addForm.month" style="width:120px;"><el-option v-for="m in 12" :key="m" :label="`${m}月`" :value="m" /></el-select></el-form-item>
<el-form-item label="预算值"><el-input-number v-model="addForm.value" :min="0" :precision="0" style="width:200px;" /></el-form-item>
</el-form>
<template #footer><el-button @click="showAddBudget = false">取消</el-button><el-button type="primary" @click="doAddBudget" :loading="addSaving">创建</el-button></template>
</MyDialog>
<MyDialog v-model="showVersionDiff" title="版本差异对比" :width="900">
<template v-if="diffData">
<el-alert show-icon :closable="false" style="margin-bottom:12px;"><template #title><span>对比 {{ diffData.summary.version_a }} → {{ diffData.summary.version_b }}</span></template></el-alert>
<el-table :data="diffData.diffs" border stripe size="small" max-height="500" style="width:100%;">
<el-table-column prop="kpi_code" label="编码" width="110" /><el-table-column prop="kpi_name" label="名称" min-width="140" /><el-table-column prop="period" label="期间" width="90" />
<el-table-column prop="old_value" label="旧值" width="110"><template #default="{ row }">{{ formatNumber(row.old_value) }}</template></el-table-column>
<el-table-column prop="new_value" label="新值" width="110"><template #default="{ row }">{{ formatNumber(row.new_value) }}</template></el-table-column>
<el-table-column prop="diff_value" label="差值" width="110"><template #default="{ row }"><span :style="{color: row.diff_value > 0 ? '#f56c6c' : row.diff_value < 0 ? '#67c23a' : '#999'}">{{ row.diff_value > 0 ? '+' : '' }}{{ formatNumber(row.diff_value) }}</span></template></el-table-column>
<el-table-column prop="diff_rate" label="变动率" width="90"><template #default="{ row }"><el-tag v-if="row.diff_rate === 0" size="small" type="info">持平</el-tag><el-tag v-else :type="row.diff_rate > 0 ? 'danger' : 'success'" size="small">{{ row.diff_rate > 0 ? '+' : '' }}{{ row.diff_rate }}%</el-tag></template></el-table-column>
</el-table>
</template>
<template #footer><el-button @click="showVersionDiff = false">关闭</el-button></template>
</MyDialog>
<!-- 行动方案关联弹窗 -->
<MyDialog v-model="showActionDialog" :title="actionDialogTitle" :width="600">
<template v-if="actionPlansForKpi.length > 0">
<div style="margin-bottom:12px;font-size:13px;color:#666;">已关联 {{ actionPlansForKpi.length }} 项行动方案</div>
<div v-for="plan in actionPlansForKpi" :key="plan.id" style="border:1px solid #e8e8e8;border-radius:8px;padding:12px;margin-bottom:8px;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;">
<div>
<div style="font-weight:500;">{{ plan.title }}</div>
<div v-if="plan.description" style="font-size:12px;color:#888;margin-top:4px;">{{ plan.description }}</div>
</div>
<el-tag v-if="plan.status === 'completed'" type="success" size="small">已完成</el-tag>
<el-tag v-else-if="plan.status === 'in_progress'" type="warning" size="small">进行中</el-tag>
<el-tag v-else type="info" size="small">{{ plan.status }}</el-tag>
</div>
<div style="display:flex;gap:16px;margin-top:8px;font-size:12px;color:#999;">
<span>负责人: {{ plan.assignee || '未指定' }}</span>
<span v-if="plan.due_date">截止: {{ plan.due_date?.slice(0,10) }}</span>
<span>优先级: {{ plan.priority }}</span>
</div>
</div>
</template>
<div v-else style="padding:40px 0;text-align:center;color:#999;">
<p>暂无关联行动方案</p>
<p style="font-size:12px;margin-top:4px;">非财务维度KPI需要配套行动方案才能将战略意图转化为实际行动</p>
</div>
<div style="margin-top:16px;display:flex;gap:8px;">
<el-button type="primary" @click="goCreateActionPlan">+ 新建行动方案</el-button>
<el-button @click="goActionPlansPage">去行动方案库查看</el-button>
</div>
<template #footer><el-button @click="showActionDialog = false">关闭</el-button></template>
</MyDialog>
<!-- P2-① 零基论证项编辑弹窗 -->
<MyDialog v-model="showZbbItemDialog" :title="zbbItemForm.id ? '编辑论证项' : '新增论证项'" :width="520">
<el-form :model="zbbItemForm" label-width="100px">
<el-form-item label="费用科目"><el-input v-model="zbbItemForm.item_name" placeholder=": 招待费" /></el-form-item>
<el-form-item label="类别">
<el-select v-model="zbbItemForm.item_category" style="width:100%;">
<el-option label="固定" value="fixed" /><el-option label="变动" value="variable" /><el-option label="酌量" value="discretionary" />
</el-select>
</el-form-item>
<el-form-item label="基准值"><el-input-number v-model="zbbItemForm.base_value" :min="0" :precision="2" style="width:200px;" /></el-form-item>
<el-form-item label="论证理由"><el-input v-model="zbbItemForm.justification" type="textarea" :rows="2" placeholder="为何保留/削减/取消" /></el-form-item>
<el-form-item label="论证后金额"><el-input-number v-model="zbbItemForm.proposed_value" :min="0" :precision="2" style="width:200px;" /></el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="zbbItemForm.status"><el-radio value="draft">草稿</el-radio><el-radio value="approved">已批准</el-radio></el-radio-group>
</el-form-item>
</el-form>
<template #footer><el-button @click="showZbbItemDialog = false">取消</el-button><el-button type="primary" @click="saveZeroBasedItem">保存</el-button></template>
</MyDialog>
<!-- P2-② 派生规则新建弹窗 -->
<MyDialog v-model="showDerivationRuleDialog" title="新建派生规则" :width="520">
<el-form :model="derivationRuleForm" label-width="100px">
<el-form-item label="目标KPI">
<el-select v-model="derivationRuleForm.kpi_id" filterable style="width:100%;">
<el-option v-for="k in zbbKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" />
</el-select>
</el-form-item>
<el-form-item label="规则类型">
<el-select v-model="derivationRuleForm.rule_type" style="width:100%;">
<el-option label="按来源KPI比例 (percentage_of)" value="percentage_of" />
<el-option label="增量 (incremental)" value="incremental" />
<el-option label="公式 (formula)" value="formula" />
</el-select>
</el-form-item>
<el-form-item v-if="derivationRuleForm.rule_type === 'percentage_of'" label="来源KPI">
<el-select v-model="derivationRuleForm.base_kpi_id" filterable style="width:100%;">
<el-option v-for="k in zbbKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" />
</el-select>
</el-form-item>
<el-form-item label="比例(%)"><el-input-number v-model="derivationRuleForm.rate_pct" :min="0" :max="100" :precision="2" style="width:200px;" /></el-form-item>
<el-form-item label="公式说明"><el-input v-model="derivationRuleForm.formula_text" placeholder="可读公式说明" /></el-form-item>
</el-form>
<template #footer><el-button @click="showDerivationRuleDialog = false">取消</el-button><el-button type="primary" @click="saveDerivationRule">保存</el-button></template>
</MyDialog>
<!-- P1-④ 取数映射新建弹窗 -->
<MyDialog v-model="showValueSourceDialog" title="新建取数映射" :width="560">
<el-form :model="valueSourceForm" label-width="100px">
<el-form-item label="目标KPI">
<el-select v-model="valueSourceForm.kpi_id" filterable style="width:100%;">
<el-option v-for="k in zbbKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" />
</el-select>
</el-form-item>
<el-form-item label="源头表">
<el-select v-model="valueSourceForm.source_table" style="width:100%;">
<el-option label="网银凭证明细 voucher_details" value="voucher_details" />
<el-option label="库存汇总 product_inventory" value="product_inventory" />
<el-option label="库存明细 product_inventory_detail" value="product_inventory_detail" />
<el-option label="收付款计划 cash_plans" value="cash_plans" />
</el-select>
</el-form-item>
<el-form-item label="金额字段">
<el-select v-model="valueSourceForm.source_field" style="width:100%;">
<el-option v-for="f in ['credit_amount','debit_amount','amount','qty','out_amount','in_amount','end_amount']" :key="f" :label="f" :value="f" />
</el-select>
</el-form-item>
<el-form-item label="聚合方式">
<el-select v-model="valueSourceForm.aggregate" style="width:120px;"><el-option v-for="a in ['sum','avg','count','max','min']" :key="a" :label="a" :value="a" /></el-select>
</el-form-item>
<el-form-item label="方向过滤">
<el-select v-model="valueSourceForm.direction" clearable placeholder="不限定" style="width:150px;">
<el-option label="贷方(收入)" value="credit" /><el-option label="借方(支出)" value="debit" />
</el-select>
</el-form-item>
<el-form-item label="科目过滤"><el-input v-model="valueSourceForm.subject_code" placeholder=": 6601 销售费用" /></el-form-item>
<el-form-item label="期间字段">
<el-select v-model="valueSourceForm.period_field" style="width:150px;"><el-option label="period" value="period" /><el-option label="voucher_date" value="voucher_date" /></el-select>
</el-form-item>
<el-form-item label="单位倍率"><el-input-number v-model="valueSourceForm.unit_conversion" :min="0.0001" :precision="4" style="width:150px;" /><span style="font-size:12px;color:#999;margin-left:6px;">元→万元填 0.0001</span></el-form-item>
</el-form>
<template #footer><el-button @click="showValueSourceDialog = false">取消</el-button><el-button type="primary" @click="saveValueSource">保存</el-button></template>
</MyDialog>
<!-- P2-⑥ 分类规则新建弹窗 -->
<MyDialog v-model="showClassifyRuleDialog" title="新建现金流分类规则" :width="500">
<el-form :model="classifyRuleForm" label-width="110px">
<el-form-item label="KPI(精确)">
<el-select v-model="classifyRuleForm.kpi_id" filterable clearable placeholder="或使用关键词" style="width:100%;">
<el-option v-for="k in zbbKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" />
</el-select>
</el-form-item>
<el-form-item label="关键词(兜底)"><el-input v-model="classifyRuleForm.kpi_code_pattern" placeholder=": 保证金" /></el-form-item>
<el-form-item label="收付类型">
<el-radio-group v-model="classifyRuleForm.plan_type"><el-radio value="receive">收</el-radio><el-radio value="pay">付</el-radio></el-radio-group>
</el-form-item>
<el-form-item label="优先级"><el-input-number v-model="classifyRuleForm.priority" :min="1" :max="100" style="width:120px;" /></el-form-item>
</el-form>
<template #footer><el-button @click="showClassifyRuleDialog = false">取消</el-button><el-button type="primary" @click="saveClassifyRule">保存</el-button></template>
</MyDialog>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, watch, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { budgetApi, kpiApi, mapApi, actionPlanApi } from '../api/index'
import MyDialog from '../components/MyDialog.vue'
import DriverFactorBudget from '../components/DriverFactorBudget.vue'
const activeTab = ref('input')
const currentYear = new Date().getFullYear()
const currentMonth = new Date().getMonth() + 1
function getEntityId() {
return Number(localStorage.getItem('cma_entity_id') || 1)
}
const yearOptions = computed(() => { const y: number[] = []; for (let i = currentYear - 2; i <= currentYear + 2; i++) y.push(i); return y })
const filterYear = ref(currentYear); const filterMonth = ref(0); const searchKpi = ref(''); const filterVersion = ref('')
const loading = ref(false); const budgetList = ref<any[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const noMap = ref(false)
const batchEditMode = ref(false); const batchSavingAll = ref(false)
// ── 现金流联动(断点修复#1, 2026-08-27 ──
const syncingCash = ref(false)
async function syncCashPlans() {
syncingCash.value = true
try {
const r: any = await api.post('/budget/sync-cash-plans')
ElMessage.success(r.message || "现金流计划已同步")
loadExecutionReport()
} catch (e: any) {
ElMessage.error("同步失败: " + (e?.message || ""))
} finally {
syncingCash.value = false
}
}
// ── 战略地图选择 ──
const publishedMaps = ref<any[]>([])
const selectedMapId = ref<number | null>(null)
const selectedMap = ref<any>(null)
async function loadPublishedMaps() {
try {
const r: any = await mapApi.list(); const maps = Array.isArray(r) ? r : (r.data || r.items || [])
const published = maps.filter((m: any) => m.status === 'published')
.sort((a: any, b: any) => new Date(b.updated_at || b.created_at).getTime() - new Date(a.updated_at || a.created_at).getTime())
publishedMaps.value = published
noMap.value = published.length === 0
// 如果有默认选中的地图ID没变,保持
if (selectedMapId.value && published.find(m => m.id === selectedMapId.value)) return
// 默认选中第一个
if (published.length > 0) {
selectedMapId.value = published[0].id
selectedMap.value = published[0]
// 同步加载版本并默认选中当前版本(与onMapSelect一致, 2026-08-27根治)
await loadVersions()
selectDefaultVersion()
}
} catch { publishedMaps.value = []; noMap.value = true }
}
async function onMapSelect(id: number) {
selectedMap.value = publishedMaps.value.find(m => m.id === id) || null
filterVersion.value = '' // 切换地图清空版本筛选(重新加载后由用户选择)
await loadVersions() // 加载该地图年度版本列表
selectDefaultVersion() // 默认选中当前active版本, 避免全版本堆叠
loadBudget()
}
// 加载版本后默认选中当前active版本(避免全版本堆叠重复期间, 2026-08-27
function selectDefaultVersion() {
const cur = versionsList.value.find((v: any) => v.status === 'active') || versionsList.value[0]
if (cur) filterVersion.value = cur.version
}
function getMapKpiCodes(): Set<string> {
const codes = new Set<string>(); if (!selectedMap.value) return codes
let dims = selectedMap.value.dimensions
if (typeof dims === 'string') { try { dims = JSON.parse(dims) } catch { return codes } }
if (!Array.isArray(dims)) return codes
for (const dim of dims) for (const obj of (dim.objectives || [])) for (const code of (obj.kpis || [])) if (code) codes.add(code)
return codes
}
const budgetViewMode = ref('list'); const summaryCards = ref<any[]>([]); const dimSummaryRows = ref<any[]>([])
function onViewModeChange(mode: string) { if (mode === 'summary') loadSummary() }
function loadSummary() {
const cfg: Record<string, any> = { finance: { n: '财务维度', i: '💰', c: '#409eff' }, customer: { n: '客户维度', i: '🤝', c: '#67c23a' }, process: { n: '内部流程', i: '⚙️', c: '#e6a23c' }, learning: { n: '学习成长', i: '📚', c: '#f56c6c' } }
const dd: Record<string, { t: number; n: number }> = {}
for (const item of budgetList.value) { const d = item.dimension || 'other'; if (!dd[d]) dd[d] = { t: 0, n: 0 }; dd[d].t += (item.budget_value || 0); dd[d].n++ }
const tb = Object.values(dd).reduce((s, v) => s + v.t, 0); const cards: any[] = []; const rows: any[] = []
for (const [k, v] of Object.entries(dd)) { const c = cfg[k] || { n: k, i: '📊', c: '#909399' }; const r = tb > 0 ? Math.round(v.t / tb * 100) : 0; cards.push({ key: k, name: c.n, icon: c.i, color: c.c, totalBudget: v.t, kpiCount: v.n, ratio: r }); rows.push({ dimension: k, kpiCount: v.n, totalBudget: v.t, ratio: r, avgPerKpi: v.n > 0 ? Math.round(v.t / v.n) : 0 }) }
summaryCards.value = cards; dimSummaryRows.value = rows
}
const showAddBudget = ref(false)
const addForm = ref({ kpi_id: null as any, year: currentYear, month: new Date().getMonth() + 1, value: 0 })
const addKpiOptions = ref<any[]>([]); const addKpiLoading = ref(false); const addSaving = ref(false)
async function searchKpiForAdd(query: string) {
addKpiLoading.value = true
try { const r: any = await kpiApi.list({ keyword: query, page_size: 20, entity_id: getEntityId() }); const d = r.data || r || []; addKpiOptions.value = Array.isArray(d) ? d : (d.items || []) } catch { }
addKpiLoading.value = false
}
async function doAddBudget() {
if (!addForm.value.kpi_id) { ElMessage.warning('请选择KPI'); return }; addSaving.value = true
try { await budgetApi.create({ kpi_id: addForm.value.kpi_id, period: `${addForm.value.year}-${String(addForm.value.month).padStart(2, '0')}`, budget_value: addForm.value.value, budget_year: addForm.value.year, budget_month: addForm.value.month }); ElMessage.success('预算已创建'); showAddBudget.value = false; loadBudget(); addForm.value = { kpi_id: null, year: currentYear, month: new Date().getMonth() + 1, value: 0 } } catch (e: any) { ElMessage.error(e?.response?.data?.detail || e?.message || '创建失败') }
addSaving.value = false
}
const dimMap: Record<string, string> = { finance: '财务', customer: '客户', process: '内部流程', learning: '学习成长' }
const dimTagMap: Record<string, string> = { finance: 'danger', customer: 'warning', process: 'primary', learning: 'success' }
function dimLabel(d: string) { return dimMap[d] || d }; function dimTag(d: string) { return dimTagMap[d] || 'info' }
function formatNumber(v: any, unit?: string) { if (v === null || v === undefined) return '--'; const n = Number(v); const p = unit === '%' ? 2 : 0; return n.toLocaleString('zh-CN', { minimumFractionDigits: p, maximumFractionDigits: p }) + (unit ? ` ${unit}` : '') }
function rowClass({ row }: any) { return row._changed ? 'row-changed' : '' }
async function loadBudget() {
if (batchEditMode.value && changedRows.value.length > 0) { try { await ElMessageBox.confirm(`有 ${changedRows.value.length} 条修改未保存,是否放弃?`, '提示', { confirmButtonText: '放弃修改', cancelButtonText: '取消' }) } catch { return } }
if (!selectedMap.value) return
const mapCodes = getMapKpiCodes()
if (mapCodes.size === 0) { budgetList.value = []; total.value = 0; return }
noMap.value = false; loading.value = true
try {
const params: any = { page: page.value, page_size: pageSize.value, entity_id: getEntityId(), map_id: selectedMapId.value }; if (filterYear.value) params.year = filterYear.value; if (filterVersion.value) params.version = filterVersion.value; if (filterMonth.value > 0) params.budget_month = filterMonth.value; if (searchKpi.value) params.keyword = searchKpi.value
const r: any = await budgetApi.list(params); const d = r.data || r || []; const allPlans = (Array.isArray(d) ? d : (d.items || [])) as any[]
// 只保留选中地图关联KPI的预算记录
const plans = allPlans.filter((p: any) => mapCodes.has(p.kpi_code))
const kr: any = await kpiApi.list({ page_size: 100, keyword: searchKpi.value || undefined, entity_id: getEntityId() }); const kd = kr.data || kr || []; const ak = Array.isArray(kd) ? kd : (kd.items || [])
const mk = ak.filter((k: any) => mapCodes.has(k.kpi_code)); const pk = new Set(plans.map((p: any) => p.kpi_code))
const ek = mk.filter((k: any) => !pk.has(k.kpi_code)).map((k: any) => ({ kpi_id: k.id, kpi_code: k.kpi_code, kpi_name: k.kpi_name, dimension: k.dimension, unit: k.unit || '', period: `${filterYear.value || currentYear}-${String(filterMonth.value || 1).padStart(2, '0')}`, budget_value: null, version: 'v1.0', status: '', precision: 0, step: 1 }))
budgetList.value = [...plans.map((item: any) => initRow({ ...item, _empty: false })), ...ek.map(item => initRow({ ...item, _empty: true }))]
total.value = budgetList.value.length; if (budgetViewMode.value === 'summary') loadSummary()
// 加载非财务维度KPI的行动方案计数
loadActionPlanCounts()
} catch (e) { ElMessage.error('加载预算数据失败') }; loading.value = false
}
function initRow(item: any) { return { ...item, _editing: false, _editValue: item.budget_value ?? 0, _originalValue: item.budget_value, _saving: false, _changed: false } }
// ── 行动方案关联 ──
const showActionDialog = ref(false)
const actionDialogTitle = ref('')
const actionPlansForKpi = ref<any[]>([])
const actionDialogCurrentRow = ref<any>(null)
async function loadActionPlanCounts() {
// 筛选出非财务维度且有关联kpi_id的行
const rows = budgetList.value.filter(r => r.dimension !== 'finance' && r.kpi_id)
for (const row of rows) {
row._actionCount = null // 重置
try {
const r: any = await actionPlanApi.list({ kpi_id: row.kpi_id, page_size: 1 })
const d = r.data || []
row._actionCount = Array.isArray(d) ? d.length : 0
} catch { row._actionCount = 0 }
}
}
function showActionPlans(row: any) {
actionDialogCurrentRow.value = row
actionDialogTitle.value = `行动方案 - ${row.kpi_name || row.kpi_code}`
actionPlansForKpi.value = []
if (!row.kpi_id) { showActionDialog.value = true; return }
actionPlanApi.list({ kpi_id: row.kpi_id }).then((r: any) => {
const d = r.data || []
actionPlansForKpi.value = Array.isArray(d) ? d : []
}).catch(() => { actionPlansForKpi.value = [] })
showActionDialog.value = true
}
function goCreateActionPlan() {
const row = actionDialogCurrentRow.value
if (!row) return
// 跳转到差异分析页面并预填KPI
window.open(`/action-plans?kpi_id=${row.kpi_id}&kpi_name=${encodeURIComponent(row.kpi_name || '')}`, '_blank')
}
function goActionPlansPage() {
window.open('/action-plans', '_blank')
}
function onBatchEditToggle(val: boolean) { budgetList.value.forEach(r => { r._editing = val; r._editValue = r.budget_value ?? 0; r._originalValue = r.budget_value; r._changed = false }) }
function onEditChange(row: any) { row._changed = row._editValue !== row._originalValue }
const changedRows = computed(() => budgetList.value.filter(r => r._changed))
async function batchSaveAll() {
const ts = changedRows.value; if (ts.length === 0) return; batchSavingAll.value = true; let ok = 0; let no = 0
for (const r of ts) { try { if (r._empty || !r.id) { await budgetApi.create({ kpi_id: r.kpi_id, period: r.period, budget_value: r._editValue, budget_year: filterYear.value || currentYear, budget_month: filterMonth.value || 1 }) } else { await budgetApi.update(r.id, { budget_value: r._editValue }) }; r.budget_value = r._editValue; r._originalValue = r._editValue; r._changed = false; ok++ } catch { no++ } }
ElMessage.success(`批量保存完成:${ok} 成功,${no} 失败`); batchSavingAll.value = false
}
function batchCancelAll() { budgetList.value.forEach(r => { r._editValue = r._originalValue ?? 0; r._changed = false }) }
function startEdit(row: any) { row._editing = true; row._editValue = row.budget_value ?? 0; row._originalValue = row.budget_value }
function cancelEdit(row: any) { row._editing = false; row._editValue = row.budget_value ?? 0; row._originalValue = row.budget_value; row._changed = false }
async function saveEdit(row: any) {
row._saving = true
try { if (row._empty || !row.id) { await budgetApi.create({ kpi_id: row.kpi_id, period: row.period, budget_value: row._editValue, budget_year: filterYear.value || currentYear, budget_month: filterMonth.value || 1 }); ElMessage.success('预算已创建') } else { await budgetApi.update(row.id, { budget_value: row._editValue }); ElMessage.success('已更新') }; row.budget_value = row._editValue; row._originalValue = row._editValue; row._editing = false; row._changed = false; row._empty = false } catch (e) { ElMessage.error('保存失败') }
row._saving = false
}
async function doDelete(row: any) { try { await ElMessageBox.confirm(`确认删除「${row.kpi_name || row.kpi_code}」的预算?`, '确认'); await budgetApi.delete(row.id); ElMessage.success('已删除'); loadBudget() } catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') } }
const showDecompose = ref(false); const decomposeForm = ref({ year: currentYear, method: 'equal' }); const decomposing = ref(false); const decomposeResult = ref(''); const decomposeDetails = ref<any[]>([])
const strategyTree = ref<any[]>([]); const strategyFilterYear = ref(currentYear); const strategyFilterMonth = ref(0); const strategyAllBudgetPlans = ref<any[]>([])
async function loadStrategyBudget() {
if (!selectedMap.value) return
try { const p: any = { budget_year: strategyFilterYear.value, entity_id: getEntityId(), map_id: selectedMapId.value }; if (strategyFilterMonth.value > 0) p.budget_month = strategyFilterMonth.value; const r: any = await budgetApi.list(p); const d = r.data || r || []; const plans = (Array.isArray(d) ? d : (d.items || [])) as any[]; strategyAllBudgetPlans.value = plans
let dims = selectedMap.value.dimensions; if (typeof dims === 'string') { try { dims = JSON.parse(dims) } catch { dims = [] } }; if (!Array.isArray(dims) || dims.length === 0) { strategyTree.value = []; return }
const kr: any = await kpiApi.list({ page_size: 100, entity_id: getEntityId() }); const kd = kr.data || kr || []; const km = new Map<string, any>(); (Array.isArray(kd) ? kd : (kd.items || [])).forEach((k: any) => km.set(k.kpi_code, k))
const tree: any[] = []
for (const dim of dims) { const dt = { budget: 0, kpiCount: 0 }; const objs = (dim.objectives || []).map((obj: any) => { const kpis = (obj.kpis || []).map((code: string) => { const kpi = km.get(code); const plan = plans.find((p: any) => p.kpi_code === code); const bv = plan ? plan.budget_value : null; dt.kpiCount++; if (bv) dt.budget += bv; return { kpi_code: code, kpi_id: kpi?.id || null, kpi_name: kpi?.kpi_name || code, unit: kpi?.unit || '', precision: kpi?.precision || 0, step: kpi?.step || 1, dimension: dim.key, period: plan?.period || `${strategyFilterYear.value}-${String(strategyFilterMonth.value || 1).padStart(2, '0')}`, version: plan?.version || 'v1.0', budget_value: bv, plan_id: plan?.id || null, target: kpi?.target_monthly || kpi?.target_value || null, _editValue: bv ?? 0, _changed: false, _saving: false, _dimKey: dim.key } }).filter(Boolean); return { name: obj.name, kpis } }); tree.push({ key: dim.key, name: dim.name, icon: dim.icon, color: dim.color, kpiCount: dt.kpiCount, totalBudget: dt.budget, objectives: objs }) }
strategyTree.value = tree
} catch (e: any) { ElMessage.error('加载战略预算数据失败: ' + (e?.response?.data?.detail || e?.message || '未知错误')) }
}
const strategySelectedKpis = ref<any[]>([]); const strategyBatchValue = ref(0); const strategyBatchSaving = ref(false)
function onStrategyKpiSelect(sel: any[], dimKey: string) { sel.forEach((i: any) => { i._dimKey = dimKey }); strategySelectedKpis.value = [...strategySelectedKpis.value.filter((k: any) => k._dimKey !== dimKey), ...sel] }
function strategySelectDim(dimKey: string) { const dim = strategyTree.value.find((d: any) => d.key === dimKey); if (!dim) return; const all: any[] = []; for (const o of dim.objectives) for (const k of o.kpis) { k._dimKey = dimKey; all.push(k) }; strategySelectedKpis.value = [...strategySelectedKpis.value.filter((k: any) => k._dimKey !== dimKey), ...all] }
function strategyBatchSetSame() { strategySelectedKpis.value.forEach((k: any) => { k._editValue = strategyBatchValue.value; k._changed = (k._editValue !== (k.budget_value ?? 0)) }) }
async function strategyBatchSave() {
const ts = strategySelectedKpis.value.filter((k: any) => k._changed); if (ts.length === 0) { ElMessage.warning('没有需要保存的变更'); return }; strategyBatchSaving.value = true; let ok = 0; let no = 0
for (const r of ts) { try { if (r.plan_id) { await budgetApi.update(r.plan_id, { budget_value: r._editValue }) } else { const rr: any = await budgetApi.create({ kpi_id: r.kpi_id, period: r.period, budget_value: r._editValue, budget_year: strategyFilterYear.value, budget_month: strategyFilterMonth.value || 1, map_id: selectedMapId.value }); r.plan_id = rr.id }; r.budget_value = r._editValue; r._changed = false; ok++ } catch { no++ } }
ElMessage.success(`批量保存完成:${ok} 成功,${no} 失败`); strategyBatchSaving.value = false; loadStrategyBudget()
}
async function saveStrategyKpi(row: any) {
row._saving = true
try { if (row.plan_id) { await budgetApi.update(row.plan_id, { budget_value: row._editValue }) } else { const rr: any = await budgetApi.create({ kpi_id: row.kpi_id, period: row.period, budget_value: row._editValue, budget_year: strategyFilterYear.value, budget_month: strategyFilterMonth.value || 1, map_id: selectedMapId.value }); row.plan_id = rr.id }; row.budget_value = row._editValue; row._changed = false; ElMessage.success('已保存') } catch (e) { ElMessage.error('保存失败') }
row._saving = false
}
async function submitVersion(row: any) { try { await ElMessageBox.confirm(`确认将版本「${row.version}」提交审批?`, '确认'); await budgetApi.versionSubmit({ version: row.version }); ElMessage.success('已提交审批'); loadVersions() } catch { } }
async function approveVersion(row: any, action: string) { const l = action === 'approved' ? '批准' : '驳回'; try { await ElMessageBox.confirm(`确认${l}版本「${row.version}」?`, '确认'); await budgetApi.versionApprove({ version: row.version, action }); ElMessage.success(`已${l}`); loadVersions() } catch { } }
async function resubmitVersion(row: any) { try { await ElMessageBox.confirm(`确认重新提交版本「${row.version}」?`, '确认'); await budgetApi.versionSubmit({ version: row.version }); ElMessage.success('已重新提交'); loadVersions() } catch { } }
const versionYear = ref(currentYear); const versionsList = ref<any[]>([]); const versionsLoading = ref(false); const selectedVersions = ref<any[]>([]); const showVersionDiff = ref(false); const diffLoading = ref(false); const diffData = ref<any>(null); const currentVersion = ref('')
async function loadVersions() { versionsLoading.value = true; try { const r: any = await budgetApi.versions({ year: versionYear.value, entity_id: getEntityId() }); const d = r.data || []; versionsList.value = d.map((v: any) => ({ ...v, _selected: false })); currentVersion.value = d[0]?.version || '' } catch (e) { ElMessage.error('加载版本列表失败') }; versionsLoading.value = false }
function onVersionSelect(row: any) { selectedVersions.value = versionsList.value.filter((v: any) => v._selected); if (selectedVersions.value.length > 2) { row._selected = false; selectedVersions.value = versionsList.value.filter((v: any) => v._selected) } }
async function loadVersionDiff() { if (selectedVersions.value.length !== 2) return; diffLoading.value = true; try { const [a, b] = selectedVersions.value; const r: any = await budgetApi.versionDiff({ version_a: a.version, version_b: b.version, year: versionYear.value }); diffData.value = r; showVersionDiff.value = true } catch (e) { ElMessage.error('加载版本对比失败') }; diffLoading.value = false }
const execFilterYear = ref(currentYear); const execFilterMonth = ref(new Date().getMonth()); const execFilterDim = ref(''); const execLoading = ref(false); const execReport = ref<any[]>([]); const execSummaryCards = ref<any[]>([])
async function loadExecutionReport() {
execLoading.value = true
try { const r: any = await budgetApi.deviationReport({ year: execFilterYear.value, month: execFilterMonth.value, dimension: execFilterDim.value || undefined, hierarchical: true, entity_id: getEntityId() }); const items: any[] = r.items || []; const en = items.map((i: any) => ({ ...i, execution_rate: i.budget_value && i.budget_value > 0 ? Math.round((i.actual_value || 0) / i.budget_value * 100) : 0, deviation_value: (i.actual_value || 0) - (i.budget_value || 0) })); execReport.value = en; const hb = en.filter((i: any) => i.budget_value != null); const ob = en.filter((i: any) => i.is_over_budget); const ar = hb.length > 0 ? Math.round(hb.reduce((s, i) => s + (i.execution_rate || 0), 0) / hb.length) : 0; const tb = hb.reduce((s, i) => s + (i.budget_value || 0), 0); const ta = hb.reduce((s, i) => s + (i.actual_value || 0), 0); execSummaryCards.value = [{ label: '有预算KPI', value: `${hb.length}/${en.length}`, color: '#409eff' }, { label: '超预算KPI', value: ob.length.toString(), color: '#f56c6c' }, { label: '平均执行率', value: `${ar}%`, color: ar > 100 ? '#f56c6c' : '#67c23a' }, { label: '预算执行进度', value: tb > 0 ? `${Math.round(ta / tb * 100)}%` : '-', color: '#e6a23c' }] } catch (e) { ElMessage.error('加载执行报告失败') }
execLoading.value = false
}
async function doDecompose() {
decomposing.value = true; decomposeResult.value = ''; decomposeDetails.value = []
try { const r: any = await budgetApi.autoDecompose({ year: decomposeForm.value.year, method: decomposeForm.value.method, entity_id: getEntityId() }); decomposeResult.value = r.message || '分解成功'; if (r.results) decomposeDetails.value = r.results.map((res: any) => ({ kpi_code: res.kpi_code, kpi_name: res.kpi_name, annual_budget: res.annual_budget, method: res.method === 'equal' ? '均分' : '加权', monthly_count: res.monthly.length })); ElMessage.success('年度预算分解完成'); loadBudget() } catch (e: any) { ElMessage.error(e.detail || e.message || '分解失败') }
decomposing.value = false
}
// ── 预算方法三选一向导 ──
const selectedMethod = ref('zero_based')
const budgetMethods = ref<any[]>([])
const selectedMethodDetail = ref<any>(null)
const selectedMethodName = computed(() => {
return budgetMethods.value.find(m => m.id === selectedMethod.value)?.name || ''
})
function loadMethodDetail(id: string) {
selectedMethodDetail.value = budgetMethods.value.find(m => m.id === id) || null
}
async function refreshMethodComparison() {
try {
const r: any = await budgetApi.methodComparison({ entity: 'hanke', entity_id: getEntityId() })
budgetMethods.value = r.methods || []
selectedMethod.value = r.recommended || 'zero_based'
loadMethodDetail(selectedMethod.value)
} catch { ElMessage.error('加载预算方法对比失败') }
}
async function confirmMethod() {
const m = selectedMethodDetail.value
if (!m) return
try {
const r: any = await budgetApi.applyMethod({
method: m.id,
year: currentYear,
entity: 'hanke',
entity_id: getEntityId(),
last_month_budget: 91,
current_revenue: 122,
})
ElMessage.success(r.message || `已应用「${m.name}」`)
// 刷新预算列表
loadBudget()
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || e?.message || '应用失败')
}
}
// ── 滚动/固定预算切换 ──
const budgetMode = ref('fixed')
const rollingForward = ref(false)
async function loadBudgetConfig() {
try {
const r: any = await budgetApi.getConfig()
budgetMode.value = r.budget_mode || r.mode || 'fixed'
} catch { budgetMode.value = 'fixed' }
}
async function onBudgetModeChange(mode: string) {
try {
await budgetApi.setConfig({ mode, rolling_months: 12 })
ElMessage.success(`预算模式已切换为${mode === 'rolling' ? '滚动预算' : '固定预算'}`)
loadBudget()
} catch (e: any) {
budgetMode.value = mode === 'rolling' ? 'fixed' : 'rolling'
ElMessage.error(e?.response?.data?.detail || '切换失败')
}
}
async function doRollForward() {
rollingForward.value = true
try {
const r: any = await budgetApi.rollForward()
ElMessage.success(r.message || '滚动预算已延展')
if (r.rolled_kpis?.length > 0) {
ElMessage.info(`新增了 ${r.rolled_kpis.length} 个KPI的未来一个月预测`)
}
loadBudget()
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '延展失败')
}
rollingForward.value = false
}
// ── 实际vs预测对比 ──
const rollingTab = ref('comparison')
const comparisonYear = ref(currentYear)
const comparisonKpiId = ref<number | null>(null)
const comparisonLoading = ref(false)
const comparisonData = ref<any[]>([])
const comparisonSummary = ref<any[]>([])
const comparisonKpiOptions = ref<any[]>([])
const comparisonChartRef = ref<HTMLElement | null>(null)
let comparisonChart: any = null
async function loadComparisonKpiOptions() {
try {
const r: any = await kpiApi.list({ page_size: 200, entity_id: getEntityId() })
const d = r.data || r || []
comparisonKpiOptions.value = Array.isArray(d) ? d : (d.items || [])
} catch { comparisonKpiOptions.value = [] }
}
async function loadComparison() {
comparisonLoading.value = true
try {
const params: any = { year: comparisonYear.value, entity_id: getEntityId() }
if (comparisonKpiId.value) params.kpi_id = comparisonKpiId.value
const r: any = await budgetApi.getComparison(params)
comparisonData.value = r.months_data || []
// 计算摘要
const md = comparisonData.value
const totalBudget = md.reduce((s: number, m: any) => s + (m.budget_total || 0), 0)
const totalActual = md.reduce((s: number, m: any) => s + (m.actual_total || 0), 0)
const maxDevRate = Math.max(...md.filter((m: any) => m.deviation_rate != null).map((m: any) => Math.abs(m.deviation_rate)), 0)
const alertCount = md.filter((m: any) => m.deviation_rate != null && Math.abs(m.deviation_rate) > 20).length
comparisonSummary.value = [
{ label: '预算总额', value: formatNumber(totalBudget), color: '#409eff' },
{ label: '实际总额', value: totalActual > 0 ? formatNumber(totalActual) : '--', color: totalActual > totalBudget ? '#f56c6c' : '#67c23a' },
{ label: '最大偏差率', value: maxDevRate > 0 ? `${maxDevRate}%` : '--', color: maxDevRate > 20 ? '#f56c6c' : '#67c23a' },
{ label: '超20%偏差月数', value: `${alertCount}个月`, color: alertCount > 0 ? '#f56c6c' : '#67c23a' },
]
// 渲染echarts图表
await nextTick()
renderComparisonChart()
} catch (e: any) {
ElMessage.error('加载对比数据失败')
comparisonData.value = []
}
comparisonLoading.value = false
}
function renderComparisonChart() {
if (!comparisonChartRef.value) return
// 使用ECharts
const echarts = (window as any).echarts
if (!echarts) {
// 如果没有全局echarts,尝试从已挂载的组件获取
import('echarts').then(echarts => {
doRenderChart(echarts)
}).catch(() => {
// ECharts可能已经在vendor bundle中
if ((window as any).echarts) doRenderChart((window as any).echarts)
})
return
}
doRenderChart(echarts)
}
function doRenderChart(echarts: any) {
if (comparisonChart) comparisonChart.dispose()
comparisonChart = echarts.init(comparisonChartRef.value!)
const data = comparisonData.value
const periods = data.map((d: any) => d.period)
const budgetValues = data.map((d: any) => d.budget_total != null ? d.budget_total : null)
const actualValues = data.map((d: any) => d.actual_total != null ? d.actual_total : null)
// 找到分界点(当前期之后为预测)
const nowPeriod = `${currentYear}-${String(currentMonth).padStart(2, '0')}`
const splitIndex = periods.findIndex((p: string) => p > nowPeriod)
const option = {
tooltip: { trigger: 'axis' },
legend: { data: ['预算值', '实际值'] },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: periods, axisLabel: { rotate: 45 } },
yAxis: { type: 'value' },
series: [
{
name: '预算值',
type: 'line',
data: budgetValues,
smooth: true,
lineStyle: { width: 2, color: '#409eff' },
itemStyle: { color: '#409eff' },
markLine: splitIndex >= 0 ? {
data: [{ xAxis: periods[splitIndex] || periods[splitIndex - 1] }],
label: { formatter: '▼ 预测开始', color: '#e6a23c' },
lineStyle: { color: '#e6a23c', type: 'dashed', width: 2 },
} : undefined,
},
{
name: '实际值',
type: 'bar',
data: actualValues,
barWidth: '20%',
itemStyle: {
color: (params: any) => {
const item = data[params.dataIndex]
if (!item) return '#67c23a'
if (item.actual_total == null) return '#d9d9d9'
if (item.deviation_rate != null && Math.abs(item.deviation_rate) > 20) return '#f56c6c'
if (item.deviation_rate != null && Math.abs(item.deviation_rate) > 10) return '#e6a23c'
return '#67c23a'
},
},
},
],
}
comparisonChart.setOption(option)
}
// ── 偏差告警 ──
const alertFilterPeriod = ref(`${currentYear}-${String(currentMonth).padStart(2, '0')}`)
const alertFilterLevel = ref('')
const alertFilterStatus = ref('')
const alertLoading = ref(false)
const deviationAlerts = ref<any[]>([])
const deviationChecking = ref(false)
const deviationAlertMessage = ref('')
const alertPeriodOptions = computed(() => {
const opts: string[] = []
for (let m = 1; m <= 12; m++) {
opts.push(`${currentYear}-${String(m).padStart(2, '0')}`)
}
return opts
})
async function loadDeviationAlerts() {
alertLoading.value = true
try {
const params: any = { entity_id: getEntityId() }
if (alertFilterPeriod.value) params.period = alertFilterPeriod.value
if (alertFilterLevel.value) params.alert_level = alertFilterLevel.value
if (alertFilterStatus.value) params.status = alertFilterStatus.value
const r: any = await budgetApi.listDeviationAlerts(params)
deviationAlerts.value = r.data || []
} catch { deviationAlerts.value = [] }
alertLoading.value = false
}
async function doDeviationCheck() {
deviationChecking.value = true
deviationAlertMessage.value = ''
try {
const r: any = await budgetApi.deviationCheck({
period: alertFilterPeriod.value,
threshold: 20,
entity_id: getEntityId(),
})
if (r.alerts_generated > 0) {
deviationAlertMessage.value = `偏差检查完成:生成了 ${r.alerts_generated} 条预警`
ElMessage.warning(`发现 ${r.alerts_generated} 条偏差预警`)
} else {
deviationAlertMessage.value = '偏差检查完成:未发现超过20%的偏差'
ElMessage.success('未发现偏差预警')
}
loadDeviationAlerts()
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '偏差检查失败')
}
deviationChecking.value = false
}
async function resolveAlert(row: any) {
try {
await budgetApi.updateDeviationAlert(row.id, { status: 'resolved' })
ElMessage.success('已标记为已解决')
row.status = 'resolved'
loadDeviationAlerts()
} catch { ElMessage.error('操作失败') }
}
async function reopenAlert(row: any) {
try {
await budgetApi.updateDeviationAlert(row.id, { status: 'open' })
ElMessage.success('已重新打开')
row.status = 'open'
loadDeviationAlerts()
} catch { ElMessage.error('操作失败') }
}
// ══════════════════════════════════════════════
// P1-③ 告警归因
// ══════════════════════════════════════════════
const showAttributionDialog = ref(false)
const attributionDetail = ref<any>(null)
async function showAlertAttribution(row: any) {
try {
const r: any = await budgetApi.getAlertAttribution(row.id)
attributionDetail.value = r.data || r
showAttributionDialog.value = true
} catch (e) { ElMessage.error('加载归因详情失败') }
}
// ══════════════════════════════════════════════
// 通用 KPI 选项(零基/取数映射/派生规则/分类规则共用)
// ══════════════════════════════════════════════
const zbbKpiOptions = ref<any[]>([])
async function loadZbbKpiOptions() {
try {
const r: any = await kpiApi.list({ page_size: 200, entity_id: getEntityId() })
const d = r.data || r || []
zbbKpiOptions.value = Array.isArray(d) ? d : (d.items || [])
} catch { zbbKpiOptions.value = [] }
}
// ══════════════════════════════════════════════
// P2-① 零基逐项论证
// ══════════════════════════════════════════════
const zbbKpiId = ref<number | null>(null)
const zbbPeriod = ref(`${currentYear}-${String(currentMonth).padStart(2, '0')}`)
const zbbPeriodOptions = computed(() => {
const opts: string[] = []
const now = new Date()
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
opts.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`)
}
return opts
})
const zeroBasedItems = ref<any[]>([])
const zbbTotalProposed = ref<number | null>(null)
const showZbbItemDialog = ref(false)
const zbbItemForm = ref<any>({})
const zbbGenerating = ref(false)
async function loadZeroBasedItems() {
if (!zbbKpiId.value) { zeroBasedItems.value = []; zbbTotalProposed.value = null; return }
try {
const r: any = await budgetApi.zeroBasedItems({ kpi_id: zbbKpiId.value, period: zbbPeriod.value, entity_id: getEntityId() })
zeroBasedItems.value = r.data || []
zbbTotalProposed.value = r.total_proposed ?? null
} catch { zeroBasedItems.value = [] }
}
function addZeroBasedItem() {
if (!zbbKpiId.value) { ElMessage.warning('请先选择KPI'); return }
zbbItemForm.value = { kpi_id: zbbKpiId.value, period: zbbPeriod.value, item_name: '', item_category: 'discretionary', base_value: 0, justification: '', proposed_value: 0, status: 'draft' }
showZbbItemDialog.value = true
}
function editZeroBasedItem(row: any) {
zbbItemForm.value = { ...row }
showZbbItemDialog.value = true
}
async function saveZeroBasedItem() {
const f = zbbItemForm.value
if (!f.item_name) { ElMessage.warning('请填写费用科目'); return }
try {
if (f.id) await budgetApi.updateZeroBasedItem(f.id, f)
else await budgetApi.createZeroBasedItem(f)
ElMessage.success('已保存')
showZbbItemDialog.value = false
loadZeroBasedItems()
} catch (e) { ElMessage.error('保存失败') }
}
async function deleteZeroBasedItem(row: any) {
try {
await ElMessageBox.confirm(`确认删除论证项「${row.item_name}」?`, '确认')
await budgetApi.deleteZeroBasedItem(row.id)
ElMessage.success('已删除')
loadZeroBasedItems()
} catch { }
}
async function generateZeroBased() {
if (!zbbKpiId.value || !zbbPeriod.value) return
zbbGenerating.value = true
try {
const r: any = await budgetApi.generateZeroBased({ kpi_id: zbbKpiId.value, period: zbbPeriod.value, entity_id: getEntityId() })
ElMessage.success(r.message || '零基预算已生成')
} catch (e: any) { ElMessage.error(e?.response?.data?.detail || '生成失败') }
zbbGenerating.value = false
}
// ══════════════════════════════════════════════
// P2-② 派生规则配置
// ══════════════════════════════════════════════
const derivationRules = ref<any[]>([])
const showDerivationRuleDialog = ref(false)
const derivationRuleForm = ref<any>({})
async function loadDerivationRules() {
try {
const r: any = await budgetApi.derivationRules({ entity_id: getEntityId() })
derivationRules.value = r.data || []
} catch { derivationRules.value = [] }
}
function openDerivationRuleDialog() {
derivationRuleForm.value = { kpi_id: null, rule_type: 'percentage_of', base_kpi_id: null, rate_pct: 2, formula_text: '' }
showDerivationRuleDialog.value = true
}
async function saveDerivationRule() {
const f = derivationRuleForm.value
if (!f.kpi_id) { ElMessage.warning('请选择目标KPI'); return }
try {
await budgetApi.createDerivationRule({
kpi_id: f.kpi_id,
rule_type: f.rule_type,
base_kpi_id: f.rule_type === 'percentage_of' ? f.base_kpi_id : null,
params: { rate: (f.rate_pct ?? 0) / 100 },
formula_text: f.formula_text,
entity_id: getEntityId(),
})
ElMessage.success('规则已创建')
showDerivationRuleDialog.value = false
loadDerivationRules()
} catch (e: any) { ElMessage.error(e?.response?.data?.detail || '创建失败') }
}
async function deleteDerivationRule(row: any) {
try {
await ElMessageBox.confirm(`确认删除规则「${row.kpi_name || row.kpi_code}」?`, '确认')
await budgetApi.deleteDerivationRule(row.id)
ElMessage.success('已删除')
loadDerivationRules()
} catch { }
}
// ══════════════════════════════════════════════
// P1-④ 实际值自动归集
// ══════════════════════════════════════════════
const collectTab = ref('mappings')
const collectKpiId = ref<number | null>(null)
const collectCoverageCards = ref<any[]>([])
const valueSources = ref<any[]>([])
const showValueSourceDialog = ref(false)
const valueSourceForm = ref<any>({})
const collectRunning = ref(false)
const collectLogStatus = ref('')
const collectLogs = ref<any[]>([])
const valueTagFilter = ref('auto_collect')
const valueTagRows = ref<any[]>([])
async function loadCollectData() {
await Promise.all([loadValueSources(), loadCollectCoverage(), loadValueTags(), loadCollectLogs()])
}
async function loadValueSources() {
try {
const params: any = { entity_id: getEntityId() }
if (collectKpiId.value) params.kpi_id = collectKpiId.value
const r: any = await budgetApi.valueSources(params)
valueSources.value = r.data || []
} catch { valueSources.value = [] }
}
async function loadCollectCoverage() {
try {
const r: any = await budgetApi.valueSourceCoverage({ entity_id: getEntityId() })
const c = r.data || r || {}
collectCoverageCards.value = [
{ label: '已配映射KPI', value: c.mapped_count ?? 0, color: '#409eff' },
{ label: '活跃KPI总数', value: c.total_kpis ?? 0, color: '#606266' },
{ label: '覆盖率', value: `${c.coverage_pct ?? 0}%`, color: '#67c23a' },
{ label: '未配置KPI', value: c.unmapped_count ?? 0, color: '#e6a23c' },
]
} catch { collectCoverageCards.value = [] }
}
async function loadValueTags() {
try {
const r: any = await kpiApi.list({ page_size: 100, entity_id: getEntityId() })
const d = r.data || r || []
const kpis = Array.isArray(d) ? d : (d.items || [])
const rows: any[] = []
for (const k of kpis) {
const vr: any = await kpiApi.values(k.id, { entity_id: getEntityId() }).catch(() => null)
const vals = vr?.data || []
for (const v of (Array.isArray(vals) ? vals : (vals.items || []))) {
rows.push({ kpi_code: k.kpi_code, kpi_name: k.kpi_name, period: v.period, actual_value: v.actual_value, source_type: v.source_type || 'manual', source_batch: v.source_batch || '', remark: v.remark || '' })
}
}
const recent = rows.filter((r: any) => r.actual_value != null).sort((a: any, b: any) => (b.period || '').localeCompare(a.period || '')).slice(0, 100)
valueTagRows.value = valueTagFilter.value === 'all' ? recent : recent.filter((r: any) => (valueTagFilter.value === 'auto_collect' ? r.source_type === 'auto_collect' : r.source_type !== 'auto_collect'))
} catch { valueTagRows.value = [] }
}
async function loadCollectLogs() {
try {
const params: any = { entity_id: getEntityId() }
if (collectLogStatus.value) params.status = collectLogStatus.value
const r: any = await budgetApi.valueCollectLogs(params)
collectLogs.value = r.data || []
} catch { collectLogs.value = [] }
}
function openValueSourceDialog() {
valueSourceForm.value = { kpi_id: null, source_table: 'voucher_details', source_field: 'credit_amount', aggregate: 'sum', direction: '', subject_code: '', period_field: 'period', unit_conversion: 1 }
showValueSourceDialog.value = true
}
async function saveValueSource() {
const f = valueSourceForm.value
if (!f.kpi_id) { ElMessage.warning('请选择KPI'); return }
const filter: any = {}
if (f.direction) filter.direction = f.direction
if (f.subject_code) filter.subject_code = f.subject_code
try {
await budgetApi.createValueSource({
kpi_id: f.kpi_id,
source_table: f.source_table,
source_field: f.source_field,
aggregate: f.aggregate,
filter_rule: Object.keys(filter).length ? filter : null,
period_field: f.period_field,
unit_conversion: f.unit_conversion,
entity_id: getEntityId(),
})
ElMessage.success('映射已创建')
showValueSourceDialog.value = false
loadValueSources()
loadCollectCoverage()
} catch (e: any) { ElMessage.error(e?.response?.data?.detail || '创建失败') }
}
async function deleteValueSource(row: any) {
try {
await ElMessageBox.confirm(`确认删除映射(${row.kpi_name}${row.source_table})?`, '确认')
await budgetApi.deleteValueSource(row.id)
ElMessage.success('已删除')
loadValueSources()
loadCollectCoverage()
} catch { }
}
async function toggleValueSource(row: any) {
try {
await budgetApi.updateValueSource(row.id, { status: row.status === 'active' ? 'inactive' : 'active' })
row.status = row.status === 'active' ? 'inactive' : 'active'
} catch { ElMessage.error('操作失败') }
}
async function testValueSource(row: any) {
try {
const r: any = await budgetApi.testValueSource({
kpi_id: row.kpi_id,
source_table: row.source_table,
source_field: row.source_field,
aggregate: row.aggregate,
filter_rule: row.filter_rule,
period_field: row.period_field,
unit_conversion: row.unit_conversion,
period: zbbPeriod.value,
})
const d = r.data || r
if (d.success) ElMessage.success(`试跑值: ${formatNumber(d.value)} (${d.message || ''})`)
else ElMessage.error(`试跑失败: ${d.message || ''}`)
} catch { ElMessage.error('试跑失败') }
}
async function runValueCollect() {
collectRunning.value = true
try {
const r: any = await budgetApi.runValueCollect({ period: zbbPeriod.value, entity_id: getEntityId() })
const d = r.data || r
ElMessage.success(`采集完成: 成功${d.collected ?? 0} 失败${d.failed ?? 0}`)
loadCollectData()
} catch { ElMessage.error('采集失败') }
collectRunning.value = false
}
// ══════════════════════════════════════════════
// P2-⑥ 现金流分类规则 + 待分类队列
// ══════════════════════════════════════════════
const cashClassifyRules = ref<any[]>([])
const showClassifyRuleDialog = ref(false)
const classifyRuleForm = ref<any>({})
const unclassifiedRows = ref<any[]>([])
const unclassifiedCount = ref(0)
async function loadCashClassify() {
try {
const r: any = await budgetApi.cashClassifyRules({ entity_id: getEntityId() })
cashClassifyRules.value = r.data || []
} catch { cashClassifyRules.value = [] }
try {
const r: any = await budgetApi.cashUnclassified({ status: 'pending', entity_id: getEntityId() })
unclassifiedRows.value = r.data || []
unclassifiedCount.value = (r.data || []).length
} catch { unclassifiedRows.value = []; unclassifiedCount.value = 0 }
}
function openClassifyRuleDialog() {
classifyRuleForm.value = { kpi_id: null, kpi_code_pattern: '', plan_type: 'receive', priority: 10 }
showClassifyRuleDialog.value = true
}
async function saveClassifyRule() {
const f = classifyRuleForm.value
if (!f.kpi_id && !f.kpi_code_pattern) { ElMessage.warning('请选择KPI或填写关键词'); return }
try {
await budgetApi.createCashClassifyRule({
kpi_id: f.kpi_id || null,
kpi_code_pattern: f.kpi_code_pattern || null,
plan_type: f.plan_type,
priority: f.priority || 10,
entity_id: getEntityId(),
})
ElMessage.success('规则已创建')
showClassifyRuleDialog.value = false
loadCashClassify()
} catch (e: any) { ElMessage.error(e?.response?.data?.detail || '创建失败') }
}
async function deleteClassifyRule(row: any) {
try {
await ElMessageBox.confirm('确认删除该分类规则?', '确认')
await budgetApi.deleteCashClassifyRule(row.id)
ElMessage.success('已删除')
loadCashClassify()
} catch { }
}
async function classifyUnclassified(row: any, planType: string) {
try {
const r: any = await budgetApi.classifyCashUnclassified(row.id, { plan_type: planType })
ElMessage.success(r.message || `已归类为${planType === 'receive' ? '收' : '付'}`)
loadCashClassify()
} catch (e: any) { ElMessage.error(e?.response?.data?.detail || '归类失败') }
}
async function ignoreUnclassified(row: any) {
try {
await budgetApi.ignoreCashUnclassified(row.id)
ElMessage.success('已忽略')
loadCashClassify()
} catch { ElMessage.error('操作失败') }
}
watch(activeTab, (tab) => {
if (tab === 'decompose') loadBudget()
else if (tab === 'strategy') loadStrategyBudget()
else if (tab === 'versions') loadVersions()
else if (tab === 'execution') { loadExecutionReport(); loadCashClassify() }
else if (tab === 'method') { refreshMethodComparison(); loadDerivationRules() }
else if (tab === 'collect') loadCollectData()
else if (tab === 'driver') {
// DriverFactorBudget handles its own loading on mount
}
else if (tab === 'rolling') {
loadComparison()
loadDeviationAlerts()
}
})
onMounted(async () => {
await loadPublishedMaps()
loadBudget()
loadBudgetConfig()
loadComparisonKpiOptions()
loadZbbKpiOptions()
})
</script>
<style scoped>
:deep(.row-changed) { --el-table-tr-bg-color: #fff7e6 !important; }
.strategy-dim-block { margin-bottom: 20px; border: 1px solid #ebeef5; border-radius: 8px; overflow: hidden; }
.strategy-dim-header { padding: 10px 16px; background: #f5f7fa; border-left: 4px solid #409eff; display: flex; align-items: center; gap: 8px; }
.strategy-dim-icon { font-size: 20px; }
.strategy-dim-name { font-weight: 600; font-size: 15px; }
.strategy-dim-summary { font-size: 12px; color: #999; }
.strategy-obj-block { padding: 8px 16px; }
.strategy-obj-block:not(:last-child) { border-bottom: 1px solid #f0f0f0; }
.strategy-obj-header { padding: 6px 0 8px; font-size: 13px; color: #555; font-weight: 500; }
.strategy-obj-name::before { content: '◆ '; color: #999; font-size: 10px; }
</style>