From 1da0a74568847b9ce7bbb85f5bf00d21dd9bc8d2 Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Fri, 2 Oct 2026 23:41:17 +0800 Subject: [PATCH] =?UTF-8?q?fix(h2-forecast):=20=E9=A2=84=E4=BC=B0=E5=8C=BA?= =?UTF-8?q?=E6=AE=8B=E4=BD=99=E8=AF=BB=E8=B7=AF=E5=BE=84=20P2=20=E6=94=B6?= =?UTF-8?q?=E5=8F=A3=EF=BC=883=20=E7=B1=BB=206=20=E5=A4=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 任总 2026-10-02 11:35 直令「对外 /api/cma/** 读 KPIValue 默认只取实测区」同族残余 (ac4bbc6 范围外的 3 类 6 处)。6 处一律引 app.utils.kpi_value_scope.actual_only_clause(), 零新写字面量口径: - customer_dashboard.py:76 年视图汇总(预估禁与实测相加) - budget.py:260 按去年实绩加权派生基数(批量 auto-decompose) - budget.py:333 同上(单 KPI auto-decompose) - cash_forecast_engine.py:204 get_entity_kpi_history 历史/趋势序列 - cash_forecast_engine.py:259 get_current_cash_balance 最新值 - cash_forecast_engine.py:471 calculate_accuracy 实际值序列(预测输入) 证据:同脚本双树 A/B 15/15(改前 5/15,真库 kpi-forecast history 去掉 2026-H2、 next_target 336.32→250.99);分类计数 33/41/9/3 → 39 covered/38 period_guarded/9 write/0 unguarded; pytest 3 failed/1529 passed,失败集与基线逐条同 ID。不 push / 不部署 / 不重启 / 不写库。 Task: project-20261002-h2-forecast-residual-reads-p2-fullstack --- backend/app/api/budget.py | 2 ++ backend/app/api/customer_dashboard.py | 3 ++- backend/app/utils/cash_forecast_engine.py | 4 ++++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/app/api/budget.py b/backend/app/api/budget.py index 05e51ec4..d179b227 100644 --- a/backend/app/api/budget.py +++ b/backend/app/api/budget.py @@ -258,6 +258,7 @@ def auto_decompose_budget( last_year = year - 1 values = db.query(KPIValue).filter( KPIValue.kpi_id == kid, + actual_only_clause(), # 实测区口径:预估行禁作预算派生基数(kpi_value_scope) KPIValue.period.like(f"{last_year}-%"), KPIValue.actual_value.isnot(None), ).order_by(KPIValue.period.asc()).all() @@ -331,6 +332,7 @@ def auto_decompose_budget( last_year = year - 1 values = db.query(KPIValue).filter( KPIValue.kpi_id == kpi_id, + actual_only_clause(), # 实测区口径:预估行禁作预算派生基数(kpi_value_scope) KPIValue.period.like(f"{last_year}-%"), KPIValue.actual_value.isnot(None), ).order_by(KPIValue.period.asc()).all() diff --git a/backend/app/api/customer_dashboard.py b/backend/app/api/customer_dashboard.py index ac8da0c7..f611e0f6 100644 --- a/backend/app/api/customer_dashboard.py +++ b/backend/app/api/customer_dashboard.py @@ -9,6 +9,7 @@ from app.deps import get_entity_id from app.auth_middleware import require_auth, require_role from app.models import KPIDefinition, KPIValue, KPIAlert, User from app.utils.alert_entity_scope import month_period_clause +from app.utils.kpi_value_scope import actual_only_clause import logging logger = logging.getLogger("cma.customer") @@ -73,7 +74,7 @@ def list_customer_kpis( latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{months[0]}~{months[-1]}"})() if latest_val else None elif period == "year": - values = base_query.filter(KPIValue.period.like(f"{period_str[:4]}%")).all() + values = base_query.filter(actual_only_clause(), KPIValue.period.like(f"{period_str[:4]}%")).all() latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None latest = type('obj', (object,), {"actual_value": latest_val, "period": period_str[:4]})() if latest_val else None elif period == "custom" and start_date and end_date: diff --git a/backend/app/utils/cash_forecast_engine.py b/backend/app/utils/cash_forecast_engine.py index d8dddbb5..e56c3c6c 100644 --- a/backend/app/utils/cash_forecast_engine.py +++ b/backend/app/utils/cash_forecast_engine.py @@ -4,6 +4,7 @@ import os from datetime import datetime, timedelta from typing import Optional, TYPE_CHECKING from sqlalchemy.orm import Session +from app.utils.kpi_value_scope import actual_only_clause import math import random @@ -202,6 +203,7 @@ def get_entity_kpi_history(entity_id: int, kpi_code: str, db: Session, limit_mon return [] values = db.query(KPIValue).filter( KPIValue.kpi_id == kpi.id, + actual_only_clause(), # 实测区口径:预估行禁进历史/趋势序列(kpi_value_scope) KPIValue.actual_value.isnot(None), ).order_by(KPIValue.period.desc()).limit(limit_months).all() return values @@ -256,6 +258,7 @@ def get_current_cash_balance(db: Session, entity_id: int) -> Optional[float]: if kpi: latest_cash = db.query(KPIValue).filter( KPIValue.kpi_id == kpi.id, + actual_only_clause(), # 实测区口径:预估行禁作现金余额依据(kpi_value_scope) KPIValue.actual_value.isnot(None), ).order_by(KPIValue.period.desc()).first() if latest_cash and latest_cash.actual_value is not None: @@ -467,6 +470,7 @@ def calculate_accuracy(entity_id: int, db: Session) -> list: actuals = db.query(KPIValue).filter( KPIValue.kpi_id == cash_kpi.id, + actual_only_clause(), # 实测区口径:预估行禁进实际值序列(预测输入)(kpi_value_scope) KPIValue.actual_value.isnot(None), ).order_by(KPIValue.period.desc()).limit(12).all()