feat: 预算系统6项技术改进(告警归因/实际值自动归集/真零基/派生规则/告警路径统一/现金流分类)

P1-③ 告警归因: budget_deviation_alerts+alert_type/attribution/scenario_id, 归因引擎alert_attribution.py(子KPI/科目/量价差/趋势), deviation-check统一写归因+场景, GET /deviation-alerts/{id}/attribution详情(旧告警现场组装)
P1-④ 实际值自动归集: kpi_value_sources/kpi_value_collect_logs表+CRUD+试跑+覆盖率, 采集器kpi_value_collector.py(voucher_details/进销存/cash_plans按entity+period汇总, 幂等upsert不覆盖人工), crontab每日06:30
P2-① 真零基: budget_zero_based_items逐项论证表+generate, method-comparison有论证项逐项求和is_demo=false否则fallback
P2-② 派生规则: budget_derivation_rules配置表, apply-method优先读规则rule_source=configured
P2-⑤ 告警双路径合并: deviation_engine.build_deviation_alert统一函数, 方向列表配置化kpi_alert_higher_better+alert-direction接口
P2-⑥ 现金流分类: cash_plan_classify_rules规则表+cash_plan_unclassified待分类队列, sync-cash-plans未命中进队列不静默跳过
新增: GET /kpis/{kpi_id}/values + 前端kpiApi.values(归集标签页数据源), scenario_suggestions幂等seed(init_db)
测试: test_budget_tech_improve.py 15用例, 预算相关96 passed, 全量646 passed
This commit is contained in:
Hermes CI Fix
2026-08-28 18:03:47 +08:00
parent 3bc68fa1c6
commit 94aeb14e95
16 changed files with 3164 additions and 118 deletions
+34
View File
@@ -47,6 +47,8 @@ export const kpiApi = {
hierarchy: (params?: any) => api.get('/kpis/hierarchy', { params }),
// KPI-8: 因果链追踪
causalityChain: (id: number) => api.get(`/kpis/${id}/causality-chain`),
// KPI实际值列表(归集标签页)
values: (id: number, params?: any) => api.get(`/kpis/${id}/values`, { params }),
}
export const mapApi = {
@@ -208,6 +210,38 @@ export const budgetApi = {
deviationCheck: (data: any) => api.post('/budget/deviation-check', data),
listDeviationAlerts: (params?: any) => api.get('/budget/deviation-alerts', { params }),
updateDeviationAlert: (id: number, data: any) => api.put(`/budget/deviation-alerts/${id}`, data),
// P1-③ 告警归因
getAlertAttribution: (alertId: number) => api.get(`/budget/deviation-alerts/${alertId}/attribution`),
getAlertDirection: () => api.get('/budget/alert-direction'),
updateAlertDirection: (data: any) => api.put('/budget/alert-direction', data),
// P1-④ 实际值自动归集
valueSources: (params?: any) => api.get('/budget/value-sources', { params }),
createValueSource: (data: any) => api.post('/budget/value-sources', data),
updateValueSource: (id: number, data: any) => api.put(`/budget/value-sources/${id}`, data),
deleteValueSource: (id: number) => api.delete(`/budget/value-sources/${id}`),
testValueSource: (data: any) => api.post('/budget/value-sources/test', data),
runValueCollect: (data: any) => api.post('/budget/value-collect/run', data),
valueCollectLogs: (params?: any) => api.get('/budget/value-collect/logs', { params }),
valueSourceCoverage: () => api.get('/budget/value-sources/coverage'),
// P2-① 零基逐项论证
zeroBasedItems: (params?: any) => api.get('/budget/zero-based/items', { params }),
createZeroBasedItem: (data: any) => api.post('/budget/zero-based/items', data),
updateZeroBasedItem: (id: number, data: any) => api.put(`/budget/zero-based/items/${id}`, data),
deleteZeroBasedItem: (id: number) => api.delete(`/budget/zero-based/items/${id}`),
generateZeroBased: (data: any) => api.post('/budget/zero-based/generate', data),
// P2-② 派生规则
derivationRules: (params?: any) => api.get('/budget/derivation-rules', { params }),
createDerivationRule: (data: any) => api.post('/budget/derivation-rules', data),
updateDerivationRule: (id: number, data: any) => api.put(`/budget/derivation-rules/${id}`, data),
deleteDerivationRule: (id: number) => api.delete(`/budget/derivation-rules/${id}`),
// P2-⑥ 现金流分类规则 + 待分类队列
cashClassifyRules: () => api.get('/budget/cash-classify-rules'),
createCashClassifyRule: (data: any) => api.post('/budget/cash-classify-rules', data),
updateCashClassifyRule: (id: number, data: any) => api.put(`/budget/cash-classify-rules/${id}`, data),
deleteCashClassifyRule: (id: number) => api.delete(`/budget/cash-classify-rules/${id}`),
cashUnclassified: (params?: any) => api.get('/budget/cash-unclassified', { params }),
classifyCashUnclassified: (id: number, data: any) => api.post(`/budget/cash-unclassified/${id}/classify`, data),
ignoreCashUnclassified: (id: number) => api.post(`/budget/cash-unclassified/${id}/ignore`),
}
export const costApi = {
+722 -5
View File
@@ -203,6 +203,54 @@
<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">
@@ -248,6 +296,82 @@
<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>
@@ -255,6 +379,96 @@
<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">
@@ -367,10 +581,11 @@
<el-tag v-else type="info" size="small">已忽略</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<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>
<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>
@@ -378,6 +593,80 @@
<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>
</el-tabs>
</el-tab-pane>
</el-tabs>
@@ -437,6 +726,106 @@
</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>
@@ -954,12 +1343,339 @@ async function reopenAlert(row: any) {
} 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()
else if (tab === 'method') refreshMethodComparison()
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
}
@@ -973,6 +1689,7 @@ onMounted(async () => {
loadBudget()
loadBudgetConfig()
loadComparisonKpiOptions()
loadZbbKpiOptions()
})
</script>