- 新增 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:
@@ -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)
|
||||
Reference in New Issue
Block a user