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
+13 -3
View File
@@ -18,6 +18,9 @@ from app.utils.oplog import log_op
# 裁定 R1(2026-09-19 财务口径):demo 账套不参与预警生成/遍历(单一事实源)
# 裁定 R5(2026-09-19 财务口径):期间非规整月 YYYY-MM → 该 kpi_alerts 行不入库(生成侧守卫)
from app.utils.alert_entity_scope import guard_alert_period, is_excluded_entity
# 「实测区 / 预估区」分区口径(财务裁定 2026-10-02):**预估行禁作预警依据**——
# 本文件的取数读路径一律加 actual_only_clause(),动态阈值白名单去掉 estimated。
from app.utils.kpi_value_scope import actual_only_clause
logger = logging.getLogger("alert_rules")
@@ -317,11 +320,12 @@ def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends
alerts_generated += 1
continue
# 获取最新值
# 获取最新值(仅实测区:预估行禁作预警依据,财务裁定 2026-10-02)
latest_value = value_cache.get(rule.kpi_id)
if latest_value is None:
latest_value = db.query(KPIValue).filter(
KPIValue.kpi_id == rule.kpi_id,
actual_only_clause(),
).order_by(KPIValue.period.desc()).first()
if latest_value:
value_cache[rule.kpi_id] = latest_value
@@ -410,7 +414,8 @@ def calculate_dynamic_thresholds(db: Session = Depends(get_db), entity_id: int =
values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.actual_value.isnot(None),
KPIValue.data_status.in_(["verified", "estimated"]),
# 白名单去掉 estimated(财务裁定 2026-10-02):预估行不参与动态阈值计算
KPIValue.data_status.in_(["verified"]),
KPIValue.period < current_period,
).order_by(KPIValue.period.desc()).limit(3).all()
@@ -521,10 +526,11 @@ def _check_trend(kpi_id: int, value: float, direction: str, params: dict, db: Se
"""趋势检查 — 环比变化"""
threshold_pct = params.get("threshold_pct", 10)
# 获取上月值
# 获取上月值(仅实测区:伪期间预估行会因字符串序顶掉上月实测值)
prev_value = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.actual_value.isnot(None),
actual_only_clause(),
).order_by(KPIValue.period.desc()).offset(1).limit(1).first()
if not prev_value or not prev_value.actual_value or prev_value.actual_value == 0:
@@ -753,9 +759,11 @@ def run_forecast_deviation_check(
if not log or log.forecast_value is None:
continue
# 该预测期的实际值(同period匹配;兼容 2026-H1 等半年度)
# 仅实测区:预估行不得与预测值自比(否则偏差恒 0,预警失效)
actual = db.query(KPIValue).filter(
KPIValue.kpi_id == rule.kpi_id,
KPIValue.period == log.period,
actual_only_clause(),
).order_by(KPIValue.id.desc()).first()
if not actual or not actual.actual_value:
continue
@@ -878,9 +886,11 @@ def _check_customer_concentration(kpi_id: int, params: dict, db: Session, entity
detail = f"{head}收入占比={share}%(合计{total:.0f}元,{rng[0]}~{rng[1]}:{items})"
if share is None and source in ("auto", "kpi"):
# 仅实测区:预估行禁作预警依据(财务裁定 2026-10-02)
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.actual_value.isnot(None),
actual_only_clause(),
).order_by(KPIValue.period.desc(), KPIValue.id.desc()).first()
if latest:
share = round(float(latest.actual_value), 2)