diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py index d3545a4c..cd706394 100644 --- a/backend/app/api/dashboard.py +++ b/backend/app/api/dashboard.py @@ -52,6 +52,24 @@ def period_prefix(period_type: str): return str(datetime.now().year) return None +def kpi_target_by_frequency(k): + """按考核频率返回对应周期的目标值(多粒度改造) + monthly/weekly -> target_monthly; quarterly/half_year -> target_quarterly; yearly -> target_yearly + 兼容: 对应列无值时回退 target_value + """ + freq = (k.frequency or "monthly").lower() + if freq in ("monthly", "weekly"): + val = getattr(k, "target_monthly", None) + elif freq in ("quarterly", "half_year"): + val = getattr(k, "target_quarterly", None) + elif freq == "yearly": + val = getattr(k, "target_yearly", None) + else: + val = None + if val is None: + val = k.target_value + return val + @router.get("/summary") def get_dashboard_summary(role: str = Query("ceo"), period: str = Query("month"), db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): @@ -143,6 +161,7 @@ def get_dashboard_kpis(role: str = Query("ceo"), period: str = Query("month"), result.append({ "id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name, "dimension": k.dimension, "unit": k.unit, "target_value": k.target_value, + "target_monthly": k.target_monthly, "target_quarterly": k.target_quarterly, "target_yearly": k.target_yearly, "actual_value": latest.actual_value if latest else None, "period": latest.period if latest else None, "alert_level": alert.alert_level if alert else "none", @@ -198,6 +217,7 @@ def get_my_kpis( "id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name, "dimension": k.dimension, "unit": k.unit, "target_value": k.target_value, + "target_monthly": k.target_monthly, "target_quarterly": k.target_quarterly, "target_yearly": k.target_yearly, "actual_value": latest.actual_value if latest else None, "period": latest.period if latest else period_str, "alert_level": alert.alert_level if alert else "none", @@ -251,6 +271,7 @@ def get_finance_analysis( kpi_data.append({ "id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name, "unit": k.unit, "target_value": k.target_value, + "target_monthly": k.target_monthly, "target_quarterly": k.target_quarterly, "target_yearly": k.target_yearly, "actual_value": latest.actual_value if latest else None, "threshold_green": k.threshold_green, "threshold_yellow": k.threshold_yellow, @@ -414,7 +435,7 @@ def my_dashboard( ).order_by(KPIValue.calculated_at.desc()).first() actual = latest_v.actual_value if latest_v else None - target = k.target_value + target = kpi_target_by_frequency(k) level = "gray" if actual is not None and target: # 反向指标(越低越好):费用率/渠补率/应收天数/返利率/成本率 @@ -437,6 +458,10 @@ def my_dashboard( "dimension": k.dimension, "category": k.category, "target_value": target, + "target_monthly": k.target_monthly, + "target_quarterly": k.target_quarterly, + "target_yearly": k.target_yearly, + "frequency": k.frequency, "actual_value": actual, "unit": k.unit, "level": level, diff --git a/docs/req-kpi-target-multi-granularity.md b/docs/req-kpi-target-multi-granularity.md new file mode 100644 index 00000000..665d6169 --- /dev/null +++ b/docs/req-kpi-target-multi-granularity.md @@ -0,0 +1,155 @@ +# KPI目标值多粒度改造(月/季/年分离) + +> 需求提出:2026-08-17 | 提出人:任总(工作台使用中发现混淆) +> 文档版本:v1.0 | 状态:待评审 +> 类型:后端改表 + 前端改造 + 数据清洗 + +## 一、问题描述 + +### 用户故事 + +作为CMA系统的操作人员,我在工作台看到"目标 1200"时,**无法判断这个目标是月度、季度还是年度**;当我点击进入KPI详情调整目标值时,**不知道该改的是月目标、季目标还是年目标**——系统只有一个单值 target_value,三个周期的目标混在一起,必然造成操作混淆。 + +### 现状痛点 + +``` +① KPI定义的frequency字段:monthly 63个 / quarterly 11个 / weekly 2个 / half_year 10个 + → 定义了"考核频率",但target_value是单值,没说清对应哪个周期 + +② target_value单值无法表达多周期目标: + 同一KPI(如F_REVENUE营收): + frequency=quarterly(季度考核) + target_value=1200(单值) + kpi_values.period=2026.06(月度数据!) + → 1200到底是月目标?季目标?年度目标?三个信息互相矛盾 + +③ period格式混乱: + 2026.06 / 2025.1(缺零)/ 2026H1 / 2026-06 / 2021(年) + → 前端排序、环比计算、图表展示不可靠 +``` + +### 核心矛盾 + +``` +考核频率(月/季/年)× 目标值(单值)× 实际数据周期(混合格式) + ↓ +三者不对齐 = 数据粒度不匹配 = 使用者必然混淆 +``` + +## 二、解决方案 + +### 方案总览:目标值分级 + period格式化 + +``` +① 数据库:kpi_definitions 增加三列 + target_monthly / target_quarterly / target_yearly + +② 数据迁移:现有target_value按frequency归入对应列 + +③ 前端显示:按frequency显示对应目标值 + 周期标签 + +④ period格式统一:kpi_values.period 标准化为 YYYY-MM / YYYY-Qn / YYYY +``` + +## 三、功能列表 + +### 后端(backend) + +- [ ] 1. `kpi_definitions` 表加3列(target_monthly / target_quarterly / target_yearly,decimal(15,2) 可空) +- [ ] 2. 数据迁移脚本:`UPDATE ... SET target_monthly=target_value WHERE frequency='monthly'`(quarterly/yearly同理);weekly/half_year归入最接近周期(weekly→monthly,half_year→quarterly)并记录 +- [ ] 3. API改造:KPI详情/列表接口返回 `target_monthly`、`target_quarterly`、`target_yearly` + `frequency` +- [ ] 4. 前端保存KPI时,按frequency写入对应目标列(如frequency=monthly则写target_monthly) +- [ ] 5. 新增period标准化工具脚本(清洗历史数据) + +### 前端(frontend) + +- [ ] 6. 工作台KPI卡:目标值旁显示周期标签"目标(月)"/"目标(季)"/"目标(年)" +- [ ] 7. KPI详情页"目标值"编辑:显示当前frequency对应的目标字段,明确标注周期 +- [ ] 8. 历史数据tab:period统一展示格式(YYYY-MM) + +### 数据(data) + +- [ ] 9. 清洗kpi_values.period历史数据(2025.1→2025-01,2026H1→2026-H1,等) + +## 四、数据流 + +``` +展示流: +工作台/详情页 GET /kpis/:id + → 返回 kpi + target_monthly/quarterly/yearly + frequency + → 前端按frequency取对应目标值 + → 显示"目标(月): 18%" 或 "目标(季): 1200" + +编辑流: +用户在详情页改目标值 + → 前端按frequency路由到对应字段(monthly→target_monthly) + → PUT /kpis/:id { target_monthly: 20 } + → 后端只更新该列,其他周期目标不受影响 +``` + +## 五、界面设计 + +### 工作台KPI卡(改动) + +``` +┌─────────────────────────────────┐ +│ 毛利率 [达标] │ +│ 18.5% 目标(月): 18% │ ← 加频率标签 +│ ████████████░░░░░ │ +└─────────────────────────────────┘ +``` + +### KPI详情页-基本信息(改动) + +``` +目标值(月度): [ 18 ] % ← frequency=monthly时显示这个 +目标值(季度): [ ] ← 季度目标可另行填写 +目标值(年度): [ ] ← 年度目标可另行填写 +``` + +## 六、验收标准 + +1. 工作台KPI卡显示带周期标签的目标值(如"目标(月): 18%") +2. 月度KPI改目标只改target_monthly,季度/年度目标不受影响 +3. frequency=quarterly的KPI(如F_REVENUE)工作台显示季度目标 +4. kpi_values.period全部为标准格式(无2025.1、2026H1等异常格式) +5. 数据迁移后:原monthly的target_value已迁入target_monthly,无丢失 +6. 一致性检查通过(consistency-check不报新错误) + +## 七、工作量评估 + +| 项 | 内容 | 人天 | +|:--|:----|:---:| +| 后端 | 加列+迁移脚本+API改造 | 0.5 | +| 前端 | 工作台标签+详情页多周期目标 | 0.5 | +| 数据 | period清洗脚本+执行 | 0.3 | +| 测试 | 验收用例(多frequency验证) | 0.2 | +| **合计** | | **1.5天** | + +## 八、关联模块 + +- 工作台(MyDashboard.vue)—— KPI卡显示 +- KPI详情页(KPIDetail.vue)—— 目标值编辑 +- KPI字典列表(KPIList.vue)—— 列表目标值列 +- kpis.py API —— 目标值读写 +- dashboard.py API —— my-dashboard返回 +- 与"科目≠KPI"规范(kpi-account-governance-rule.md)配套:本需求解决"时间粒度",前者解决"类型混用" + +## 九、风险与注意 + +``` +⚠️ 迁移不可逆:先备份kpi_definitions表(参考2026-08-17备份流程) +⚠️ weekly/half_year归并需人工确认映射规则 +⚠️ period清洗会影响历史报表展示,需回归测试 +⚠️ 前端所有"目标值"引用点需全局搜索(不止工作台和详情页) +``` + +## 十、执行分工建议 + +``` +方案评审:研学(已分析) +需求文档:研学(本文档) +开发实施:项目Bot或全栈Bot(按此文档) +数据迁移:开发时一起做(备份先行) +验收:研学(按验收标准逐条验证,铁律七) +``` diff --git a/frontend/src/views/KPIDetail.vue b/frontend/src/views/KPIDetail.vue index 9f385894..224f1368 100644 --- a/frontend/src/views/KPIDetail.vue +++ b/frontend/src/views/KPIDetail.vue @@ -35,7 +35,9 @@ {{ kpi.data_owner || '缺失' }} {{ kpi.unit || '-' }} - {{ kpi.target_value ?? '-' }} + {{ kpi.target_monthly ?? '-' }} + {{ kpi.target_quarterly ?? '-' }} + {{ kpi.target_yearly ?? '-' }} {{ kpi.data_source_type || '-' }} {{ dimLabel(kpi.dimension) }} {{ kpi.frequency || '-' }} @@ -63,12 +65,16 @@ - + + + + + @@ -543,6 +549,13 @@ async function save() { if (saving.value) return saving.value = true try { + // 多粒度目标同步:按frequency将对应周期目标写入target_value(兼容评分/阈值等旧逻辑) + const freq = kpi.value.frequency || 'monthly' + let tv: any = null + if (freq === 'quarterly' || freq === 'half_year') tv = kpi.value.target_quarterly + else if (freq === 'yearly') tv = kpi.value.target_yearly + else tv = kpi.value.target_monthly + if (tv != null) kpi.value.target_value = tv await kpiApi.update(kpi.value.id, kpi.value) ElMessage.success('保存成功') isDirty.value = false diff --git a/frontend/src/views/KPIList.vue b/frontend/src/views/KPIList.vue index 64dd81e4..7d781ea6 100644 --- a/frontend/src/views/KPIList.vue +++ b/frontend/src/views/KPIList.vue @@ -103,8 +103,10 @@ - - + + @@ -198,7 +200,15 @@ - + + + + + + + + + @@ -209,7 +219,9 @@ + + @@ -430,6 +442,9 @@ function openEdit(row: any) { dimension: row.dimension, category: row.category, target_value: row.target_value, + target_monthly: row.target_monthly, + target_quarterly: row.target_quarterly, + target_yearly: row.target_yearly, unit: row.unit, frequency: row.frequency, data_source_type: row.data_source_type, @@ -505,6 +520,21 @@ function formatTarget(val: number, unit: string) { return val + (unit || '') } +// 多粒度目标:按frequency取对应周期目标值 +function targetForRow(row: any) { + const freq = row.frequency || 'monthly' + if (freq === 'quarterly' || freq === 'half_year') return row.target_quarterly ?? row.target_value + if (freq === 'yearly') return row.target_yearly ?? row.target_value + return row.target_monthly ?? row.target_value +} + +function targetLabel(row: any) { + const freq = row.frequency || 'monthly' + if (freq === 'quarterly' || freq === 'half_year') return '季' + if (freq === 'yearly') return '年' + return '月' +} + // ── 加载分类树 ── async function loadCategories() { try { @@ -639,6 +669,13 @@ async function saveKPI() { if (saving.value) return saving.value = true try { + // 多粒度目标同步:按frequency将对应周期目标写入target_value(兼容评分/阈值等旧逻辑) + const freq = form.value.frequency || 'monthly' + let tv: any = null + if (freq === 'quarterly' || freq === 'half_year') tv = form.value.target_quarterly + else if (freq === 'yearly') tv = form.value.target_yearly + else tv = form.value.target_monthly + if (tv != null) form.value.target_value = tv if (editMode.value) { await kpiApi.update(form.value.id, form.value) ElMessage.success('保存成功') diff --git a/frontend/src/views/MyDashboard.vue b/frontend/src/views/MyDashboard.vue index 876dd7ec..d83f98a2 100644 --- a/frontend/src/views/MyDashboard.vue +++ b/frontend/src/views/MyDashboard.vue @@ -70,7 +70,7 @@
{{ fmtValue(k.actual_value, k.kpi_code) }}
-
目标 {{ k.target_value ?? '-' }}
+
{{ targetLabel(k) }} {{ k.target_value ?? '-' }}