feat(h2-forecast): 实测区/预估区口径分离 —— 对外读路径默认只取实测区
ci/woodpecker/push/main Pipeline failed

- 新增 app/utils/kpi_value_scope.py(口径单一事实源:actual_only_clause / forecast_only_clause / is_forecast_row)
- dashboard.py: /kpis /my-kpis /finance-analysis /predict /my-dashboard / latest_kpi_value 默认剔预估,include_forecast=true 单列返回
- alert_rules.py: 动态阈值白名单去 estimated + 4 处读路径加实测区判据
- 前端 ForecastTag.vue 新增;Dashboard.vue / MyDashboard.vue 预估单列 + 标签(不与实测相加)

Task: finance-20261002-h2-forecast-separation-deploy-fullstack
This commit is contained in:
Hermes CI Fix
2026-10-02 23:33:15 +08:00
parent f32f6e7430
commit 5d7b3cc728
6 changed files with 399 additions and 29 deletions
+67
View File
@@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
"""KPI 取值分区口径 —— 「实测区 / 预估区」单一事实源(财务裁定 2026-10-02)
依据:`wecom-finance/pending-tasks/finance-20261002-h2-forecast-actual-separation-fullstack.md`
(任总 2026-10-02 11:35 CST DM 直令「把 H2 预测行与实际分离」)
财务口径裁定(逐字):
| 区 | 判据 | 展示 | 禁止 |
|---|---|---|---|
| 实测区 | `data_status` ∈ {verified, pending, calculated} 且 `source_type` ≠ forecast | 正常展示 | 不得含 estimated |
| 预估区 | `data_status = estimated` 或 `source_type = forecast` | 单列区 / 带「预估」标签 | 禁与实测相加、禁进趋势序列、禁作预警依据 |
实现口径说明(**边界决策,供复核**):
- 本模块按**预估区的补集**实现实测区(`data_status='estimated'` 或 `source_type='forecast'`
一律排除),而不是按 `{verified, pending, calculated}` 正向白名单实现。
理由两条:①裁定的「禁止」列只点名 `estimated`;②本单验收要求
「回归:实测区各端点返回值与改动前逐期等值(**仅少掉 2 行预估**)」,
正向白名单会连带排除 `invalid`(6) / `error`(6) 行,超出该回归口径。
若产品口径要求把 invalid/error 也逐出取数层,属**另一次口径变更**,须财务重新裁定。
- `NULL` 值按实测处理(`coalesce(col, '')`):历史行 `data_status` 可能为空,
空值不等于「预估」(fail-open 到既有行为,不静默吞行)。
约定:所有对外 `/api/cma/**` 的 KPIValue 读路径**默认只取实测区**;
仅当调用方显式传 `include_forecast=true` 时,预估行才**以独立字段**
(`forecast_value` / `is_forecast` / `forecast_rows`)返回,**不与实测相加**。
禁止在调用点各写一份 `data_status == 'estimated'` 字面量(避免多处漂移)。
"""
from sqlalchemy import and_, func, or_
from app.models import KPIValue
#: 预估区 —— 数据状态判据
FORECAST_DATA_STATUS = "estimated"
#: 预估区 —— 来源类型判据
FORECAST_SOURCE_TYPES = ("forecast",)
def _non_null_neq(column, value):
"""`coalesce(column, '') != value` —— NULL 视为「非该值」(不静默吞历史空值行)"""
return func.coalesce(column, "") != value
def actual_only_clause():
"""实测区 SQL 判据 = 预估区补集(对外读路径默认过滤条件)"""
return and_(
_non_null_neq(KPIValue.data_status, FORECAST_DATA_STATUS),
*[_non_null_neq(KPIValue.source_type, t) for t in FORECAST_SOURCE_TYPES],
)
def forecast_only_clause():
"""预估区 SQL 判据(`include_forecast=true` 时单列取数用)"""
return or_(
KPIValue.data_status == FORECAST_DATA_STATUS,
KPIValue.source_type.in_(FORECAST_SOURCE_TYPES),
)
def is_forecast_row(row) -> bool:
"""Python 侧同判据(单一事实源,避免调用点重写字面量)"""
if row is None:
return False
data_status = getattr(row, "data_status", None) or ""
source_type = getattr(row, "source_type", None) or ""
return (data_status == FORECAST_DATA_STATUS
or source_type in FORECAST_SOURCE_TYPES)