diff --git a/backend/app/api/alert_rules.py b/backend/app/api/alert_rules.py index c80233b4..d26028d3 100644 --- a/backend/app/api/alert_rules.py +++ b/backend/app/api/alert_rules.py @@ -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) diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py index 65fe743a..927bf557 100644 --- a/backend/app/api/dashboard.py +++ b/backend/app/api/dashboard.py @@ -12,6 +12,10 @@ from app.utils.cache import get as cache_get, set as cache_set from app.utils.voucher_guard import apply_voucher_guard from app.utils.threshold_parser import eval_threshold from app.utils.alert_entity_scope import month_period_clause +# 「实测区 / 预估区」分区口径单一事实源(财务裁定 2026-10-02,任总「把 H2 预测行与实际分离」) +# —— 预估行(data_status='estimated' 或 source_type='forecast')禁进实测汇总 / 趋势序列 / +# 预警依据;仅 include_forecast=true 时以独立字段(forecast_value / is_forecast)单列返回。 +from app.utils.kpi_value_scope import actual_only_clause, forecast_only_clause, is_forecast_row # 指标方向(反向/正向)唯一权威源 = app.api.kpis.REVERSE_INDICATORS(2026-09-15 M2 准入2 收口)。 # 本文件原自带一份字面量副本,与 kpis.py 漂移后会与「能力评分」判定相反,故改为导入。 from app.api.kpis import REVERSE_INDICATORS @@ -330,9 +334,29 @@ def get_dashboard_summary(role: str = Query("ceo"), period: str = Query("month") cache_set("dashboard", cache_key, result, ttl_seconds=30) return result +def _sum_rows(values): + """区间汇总辅助:保留原实现的「和为 0 视为无数据」历史语义(回归等值前提)""" + if not values: + return None + total = sum(v.actual_value for v in values if v.actual_value) + return total if total else None + + +def _aggregate_actual(values, label): + """实测区区间汇总 → 与原匿名对象同形(actual_value / period)""" + total = _sum_rows(values) + if total is None: + return None + return type('obj', (object,), {"actual_value": total, "period": label})() + + @router.get("/kpis") def get_dashboard_kpis(role: str = Query("ceo"), period: str = Query("month"), start_date: str = Query(None), end_date: str = Query(None), + include_forecast: bool = Query( + False, + description="是否单列返回预估区(data_status=estimated 或 source_type=forecast);" + "默认 false=只取实测区,预估行禁与实测相加"), db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): start, end = parse_period(period, start_date, end_date) period_str = start.strftime("%Y-%m") @@ -342,21 +366,51 @@ def get_dashboard_kpis(role: str = Query("ceo"), period: str = Query("month"), KPIDefinition.entity_id == entity_id ).all() result = [] + forecast_section = [] # 预估区(单列,不计入上面的 actual_value) for k in kpis: base_query = db.query(KPIValue).filter(KPIValue.kpi_id == k.id) + # 实测区恒过滤:预估行(estimated/forecast)不进实测取值与汇总 + actual_query = base_query.filter(actual_only_clause()) + forecast_query = base_query.filter(forecast_only_clause()) if include_forecast else None + + actual_value = None + forecast_value = None + is_forecast = False + + # 期间展示标签(各模式下与实测取值同口径) + if period == "month": + range_label = period_str + elif period == "quarter": + _months = period_prefix("quarter") + range_label = f"{_months[0]}~{_months[-1]}" + elif period == "year": + range_label = period_str[:4] + elif period == "custom" and start_date and end_date: + range_label = f"{start_date}~{end_date}" + else: + range_label = None if period == "month": - latest = base_query.filter(KPIValue.period == period_str).order_by(KPIValue.id.desc()).first() + latest = actual_query.filter(KPIValue.period == period_str).order_by(KPIValue.id.desc()).first() + if forecast_query is not None: + fc_row = forecast_query.filter( + KPIValue.period == period_str).order_by(KPIValue.id.desc()).first() + if fc_row is not None: + forecast_value = fc_row.actual_value + if latest is None: # 该期只有预估行:值单列展示并打标,不并入实测 + is_forecast = True elif period == "quarter": months = period_prefix("quarter") - values = base_query.filter(KPIValue.period.in_(months)).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": f"{months[0]}~{months[-1]}"})() if latest_val else None + latest = _aggregate_actual(actual_query.filter(KPIValue.period.in_(months)).all(), range_label) + if forecast_query is not None: + forecast_value = _sum_rows(forecast_query.filter(KPIValue.period.in_(months)).all()) elif period == "year": - values = base_query.filter(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 + latest = _aggregate_actual( + actual_query.filter(KPIValue.period.like(f"{period_str[:4]}%")).all(), range_label) + if forecast_query is not None: + forecast_value = _sum_rows( + forecast_query.filter(KPIValue.period.like(f"{period_str[:4]}%")).all()) elif period == "custom" and start_date and end_date: periods = [] d = start @@ -364,36 +418,62 @@ def get_dashboard_kpis(role: str = Query("ceo"), period: str = Query("month"), periods.append(d.strftime("%Y-%m")) d += timedelta(days=32) d = d.replace(day=1) - values = base_query.filter(KPIValue.period.in_(set(periods))).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": f"{start_date}~{end_date}"})() if latest_val else None + latest = _aggregate_actual(actual_query.filter(KPIValue.period.in_(set(periods))).all(), + range_label) + if forecast_query is not None: + forecast_value = _sum_rows( + forecast_query.filter(KPIValue.period.in_(set(periods))).all()) else: - latest = base_query.filter(month_period_clause(KPIValue.period)).order_by(KPIValue.period.desc()).first() + latest = actual_query.filter( + month_period_clause(KPIValue.period)).order_by(KPIValue.period.desc()).first() + if forecast_query is not None: + fc_row = forecast_query.filter( + month_period_clause(KPIValue.period)).order_by(KPIValue.period.desc()).first() + if fc_row is not None: + forecast_value = fc_row.actual_value + if latest is None: + is_forecast = True + range_label = fc_row.period alert = db.query(KPIAlert).filter( KPIAlert.kpi_id == k.id, KPIAlert.status == "pending", ).order_by(KPIAlert.id.desc()).first() + if forecast_value is not None: + forecast_section.append({ + "kpi_id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name, + "value": forecast_value, "unit": k.unit, + "period": (latest.period if latest is not None else range_label), + "data_status": "estimated", "is_forecast": True, "period_type": "forecast", + }) + 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, + "forecast_value": forecast_value, + "is_forecast": is_forecast, + "period_type": "forecast" if is_forecast else "actual", "alert_level": alert.alert_level if alert else "none", "alert_message": alert.alert_message if alert else None, "frequency": k.frequency, "responsible_dept": k.responsible_dept, }) - return {"data": result, "period": period, "range": {"start": start.strftime("%Y-%m-%d"), "end": end.strftime("%Y-%m-%d")}} + resp = {"data": result, "period": period, "range": {"start": start.strftime("%Y-%m-%d"), "end": end.strftime("%Y-%m-%d")}} + if include_forecast: + resp["forecast"] = forecast_section + return resp @router.get("/my-kpis") def get_my_kpis( current_user: User = Depends(require_auth), period: str = Query("month"), + include_forecast: bool = Query(False, description="是否单列返回预估区;默认 false=只取实测区"), db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id), ): @@ -411,6 +491,7 @@ def get_my_kpis( KPIDefinition.entity_id == entity_id, # 账套隔离 2026-09-21 ).all() result = [] + forecast_section = [] # 预估区(单列,不进 actual_value / trend) for k in kpis: # business角色筛选 @@ -419,11 +500,36 @@ def get_my_kpis( if responsible and responsible != username and responsible != name: continue + # 实测区恒过滤(预估行禁作实测值/禁进趋势序列) latest = db.query(KPIValue).filter( KPIValue.kpi_id == k.id, KPIValue.period == period_str, + actual_only_clause(), ).order_by(KPIValue.id.desc()).first() + forecast_value = None + is_forecast = False + if include_forecast: + fc_row = db.query(KPIValue).filter( + KPIValue.kpi_id == k.id, + KPIValue.period == period_str, + forecast_only_clause(), + ).order_by(KPIValue.id.desc()).first() + # 无月期预估行时回退到最近一条预估行(半年度 H2 等伪期间),仅作单列展示 + if fc_row is None: + fc_row = db.query(KPIValue).filter( + KPIValue.kpi_id == k.id, + forecast_only_clause(), + ).order_by(KPIValue.period.desc(), KPIValue.id.desc()).first() + if fc_row is not None: + forecast_value = fc_row.actual_value + is_forecast = latest is None + forecast_section.append({ + "kpi_id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name, + "value": forecast_value, "unit": k.unit, "period": fc_row.period, + "data_status": "estimated", "is_forecast": True, "period_type": "forecast", + }) + alert = db.query(KPIAlert).filter( KPIAlert.kpi_id == k.id, KPIAlert.status == "pending", @@ -432,6 +538,7 @@ def get_my_kpis( trend_values = db.query(KPIValue).filter( KPIValue.kpi_id == k.id, month_period_clause(KPIValue.period), + actual_only_clause(), ).order_by(KPIValue.period.desc()).limit(6).all() trend = [{"period": v.period, "value": v.actual_value} for v in reversed(trend_values)] @@ -442,6 +549,9 @@ def get_my_kpis( "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, + "forecast_value": forecast_value, + "is_forecast": is_forecast, + "period_type": "forecast" if is_forecast else "actual", "alert_level": alert.alert_level if alert else "none", "alert_message": alert.alert_message if alert else None, "alert_id": alert.id if alert else None, @@ -454,13 +564,17 @@ def get_my_kpis( "threshold_red": k.threshold_red, }) - return {"data": result, "user_role": role, "user_name": name, "period": period_str} + resp = {"data": result, "user_role": role, "user_name": name, "period": period_str} + if include_forecast: + resp["forecast"] = forecast_section + return resp @router.get("/finance-analysis") def get_finance_analysis( current_user: User = Depends(require_auth), period: str = Query("month"), + include_forecast: bool = Query(False, description="是否单列返回预估区;默认 false=只取实测区"), db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id), ): @@ -474,15 +588,33 @@ def get_finance_analysis( ).all() kpi_data = [] + forecast_section = [] # 预估区(单列,不进 trend / summary) for k in finance_kpis: + # 实测区恒过滤(预估行禁作实测值/禁进趋势序列) latest = db.query(KPIValue).filter( KPIValue.kpi_id == k.id, KPIValue.period == period_str, + actual_only_clause(), ).order_by(KPIValue.id.desc()).first() + forecast_value = None + if include_forecast: + fc_row = db.query(KPIValue).filter( + KPIValue.kpi_id == k.id, + forecast_only_clause(), + ).order_by(KPIValue.period.desc(), KPIValue.id.desc()).first() + if fc_row is not None: + forecast_value = fc_row.actual_value + forecast_section.append({ + "kpi_id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name, + "value": forecast_value, "unit": k.unit, "period": fc_row.period, + "data_status": "estimated", "is_forecast": True, "period_type": "forecast", + }) + trend_values = db.query(KPIValue).filter( KPIValue.kpi_id == k.id, month_period_clause(KPIValue.period), + actual_only_clause(), ).order_by(KPIValue.period.desc()).limit(6).all() trend = [{"period": v.period, "value": v.actual_value} for v in reversed(trend_values)] @@ -496,6 +628,8 @@ def get_finance_analysis( "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, + "forecast_value": forecast_value, + "is_forecast": False, "threshold_green": k.threshold_green, "threshold_yellow": k.threshold_yellow, "threshold_red": k.threshold_red, @@ -509,7 +643,7 @@ def get_finance_analysis( cost_control = next((k for k in kpi_data if k["kpi_code"] == "COST_CONTROL_RATE"), None) receivable = next((k for k in kpi_data if k["kpi_code"] == "RECEIVABLE_TURNOVER"), None) - return { + resp = { "period": period_str, "kpis": kpi_data, "summary": { @@ -519,11 +653,21 @@ def get_finance_analysis( "receivable_turnover": receivable["actual_value"] if receivable else None, } } + if include_forecast: + resp["forecast"] = forecast_section + return resp @router.get("/predict") -def predict_kpis(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): - """基于历史趋势预测下月KPI值(简单线性回归)""" +def predict_kpis(include_forecast: bool = Query( + False, description="是否单列返回预估区;默认 false=只取实测区(预估行禁进预测序列)"), + db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): + """基于历史趋势预测下月KPI值(简单线性回归) + + 口径(财务裁定 2026-10-02):预估行(estimated/forecast)**不进回归序列**—— + 预估禁与实测相加、禁作预测/预警依据。`include_forecast=true` 仅额外单列返回 + 预估区(`forecast_rows`),不改变回归取数。 + """ from datetime import datetime, timedelta period_str = datetime.now().strftime("%Y-%m") @@ -539,12 +683,25 @@ def predict_kpis(db: Session = Depends(get_db), entity_id: int = Depends(get_ent KPIDefinition.entity_id == entity_id ).all() predictions = [] + forecast_rows = [] # 预估区(单列,不参与回归) for k in kpis: values = db.query(KPIValue).filter( KPIValue.kpi_id == k.id, + actual_only_clause(), ).order_by(KPIValue.period.asc()).all() + if include_forecast: + for fc_row in db.query(KPIValue).filter( + KPIValue.kpi_id == k.id, + forecast_only_clause(), + ).order_by(KPIValue.period.asc()).all(): + forecast_rows.append({ + "kpi_id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name, + "period": fc_row.period, "value": fc_row.actual_value, + "unit": k.unit, "data_status": "estimated", "is_forecast": True, "period_type": "forecast", + }) + # 需要至少3个数据点才能做预测 if len(values) < 3: continue @@ -595,13 +752,16 @@ def predict_kpis(db: Session = Depends(get_db), entity_id: int = Depends(get_ent "data_points": len(points), }) - return { + resp = { "current_period": period_str, "next_period": next_period, "predictions": predictions, "kpi_count": len(kpis), "predictable_count": len(predictions), } + if include_forecast: + resp["forecast_rows"] = forecast_rows + return resp # ── 个人工作台 ────────────────────────────── @@ -635,10 +795,15 @@ _MAIN_VALUE_PRIORITY = case( def latest_kpi_value(db: Session, kpi_id: int) -> Optional[KPIValue]: - """该 KPI 的最新可用值:月期间优先(同期内主值优先),无月期间行时回退 calculated_at 倒序。""" + """该 KPI 的最新可用值:月期间优先(同期内主值优先),无月期间行时回退 calculated_at 倒序。 + + 口径(财务裁定 2026-10-02):**只取实测区**——预估行(data_status='estimated' 或 + source_type='forecast')不得作为卡片「最新值」(工作台卡片=实测口径)。 + """ monthly = ( db.query(KPIValue) - .filter(KPIValue.kpi_id == kpi_id, month_period_clause(KPIValue.period)) + .filter(KPIValue.kpi_id == kpi_id, month_period_clause(KPIValue.period), + actual_only_clause()) .order_by( KPIValue.period.desc(), _MAIN_VALUE_PRIORITY, @@ -651,7 +816,7 @@ def latest_kpi_value(db: Session, kpi_id: int) -> Optional[KPIValue]: return monthly return ( db.query(KPIValue) - .filter(KPIValue.kpi_id == kpi_id) + .filter(KPIValue.kpi_id == kpi_id, actual_only_clause()) .order_by(KPIValue.calculated_at.desc(), KPIValue.id.desc()) .first() ) @@ -660,6 +825,7 @@ def latest_kpi_value(db: Session, kpi_id: int) -> Optional[KPIValue]: @router.get("/my-dashboard") def my_dashboard( current_user: User = Depends(require_auth), + include_forecast: bool = Query(False, description="是否单列返回预估区;默认 false=只取实测区"), db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id), ): @@ -703,9 +869,23 @@ def my_dashboard( all_kpis = assigned_kpis + preset_kpis kpi_list = [] + forecast_section = [] # 预估区(单列,不作卡片实测值) for k in all_kpis: latest_v = latest_kpi_value(db, k.id) - + + forecast_value = None + if include_forecast: + fc_row = db.query(KPIValue).filter( + KPIValue.kpi_id == k.id, + forecast_only_clause(), + ).order_by(KPIValue.period.desc(), KPIValue.id.desc()).first() + if fc_row is not None: + forecast_value = fc_row.actual_value + forecast_section.append({ + "kpi_id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name, + "value": forecast_value, "unit": k.unit, "period": fc_row.period, + "data_status": "estimated", "is_forecast": True, "period_type": "forecast", + }) actual = latest_v.actual_value if latest_v else None target = kpi_target_by_frequency(k) level = "gray" @@ -735,6 +915,9 @@ def my_dashboard( "unit": k.unit, "level": level, "period": latest_v.period if latest_v else None, + "forecast_value": forecast_value, + "is_forecast": is_forecast_row(latest_v), + "period_type": "actual", }) # 2. 我的改善行动(assignee匹配;CEO/管理员看全部) @@ -814,8 +997,11 @@ def my_dashboard( "related_type": "kpi", }) - return { + resp = { "kpis": kpi_list, "action_plans": plan_list, "reminders": reminders, } + if include_forecast: + resp["forecast"] = forecast_section + return resp diff --git a/backend/app/utils/kpi_value_scope.py b/backend/app/utils/kpi_value_scope.py new file mode 100644 index 00000000..e973bfe6 --- /dev/null +++ b/backend/app/utils/kpi_value_scope.py @@ -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) diff --git a/frontend/src/components/ForecastTag.vue b/frontend/src/components/ForecastTag.vue new file mode 100644 index 00000000..c73fe9fc --- /dev/null +++ b/frontend/src/components/ForecastTag.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 97e930c5..262a2586 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -93,6 +93,11 @@ {{ fmtValue(k.actual_value, k.kpi_name) }} / {{ k.target_value ?? '-' }} + +
+ + {{ fmtValue(k.forecast_value, k.kpi_name) }} +
@@ -124,6 +129,18 @@ + + +
+
预估区(不计入实测)
+
以下为预估值(data_status=estimated / source_type=forecast),单列展示
+
+ {{ f.kpi_name }} + {{ f.period }} + {{ fmtValue(f.value, f.kpi_code) }} + +
+
@@ -149,6 +166,11 @@ {{ k.unit }}
目标 {{ k.target_value ?? '-' }}
+ +
+ + {{ fmtValue(k.forecast_value, k.kpi_name) }} +
近3月 @@ -185,6 +207,21 @@
{{ t.period.slice(-2) }}
+ +
+ + {{ fmtValue(k.forecast_value, k.kpi_code) }} +
+ + + +
+
预估区(不计入实测)
+
+ {{ f.kpi_name }} + {{ f.period }} + {{ fmtValue(f.value, f.kpi_code) }} +
@@ -277,6 +314,8 @@ import CountUp from '../components/CountUp.vue' import GrowthQuality from '../components/GrowthQuality.vue' import BEPBreakEvenCard from '../components/BEPBreakEvenCard.vue' import MyDialog from '../components/MyDialog.vue' +// 预估区标签(财务裁定 2026-10-02:预估行单列展示,不与实测相加/同线) +import ForecastTag from '../components/ForecastTag.vue' const router = useRouter() @@ -327,6 +366,8 @@ const yellowKpis = computed(() => kpis.value.filter((k: any) => k.alert_level == const myKpis = ref([]) const finKpis = ref([]) const finSummary = ref({}) +// 预估区(单列,不计入实测汇总;财务裁定 2026-10-02) +const forecastList = ref([]) const enabledChannels = ref(0) const aiAnalysis = ref('') const aiLoading = ref(false) @@ -456,25 +497,29 @@ async function loadData() { try { const entityId = Number(localStorage.getItem('cma_entity_id') || 1) const params = { ...getPeriodParams(), entity_id: entityId } + // include_forecast=true:预估行**单列返回**(forecast 字段),实测取值不受影响 if (userRole.value === 'ceo') { const [kpiRes, alertRes, sumRes, predRes] = await Promise.all([ - dashboardApi.kpis({ role: 'ceo', ...params }), + dashboardApi.kpis({ role: 'ceo', ...params, include_forecast: true }), alertApi.list({ limit: 10, entity_id: entityId }), dashboardApi.summary({ role: 'ceo', ...params }), dashboardApi.predict({ entity_id: entityId }).catch(() => ({ data: [] })), ]) kpis.value = (kpiRes as any).data || [] + forecastList.value = (kpiRes as any).forecast || [] alerts.value = (alertRes as any).data?.filter((a: any) => a.status === 'pending') || [] summary.value = { kpi_total: (sumRes as any).kpi_total || 0, alert_count: (sumRes as any).alert_count || 0 } predictions.value = (predRes as any)?.predictions || [] dataRange.value = (kpiRes as any).range?.start + ' ~ ' + (kpiRes as any).range?.end } else if (userRole.value === 'business') { - const r: any = await dashboardApi.myKpis?.({ ...params }) + const r: any = await dashboardApi.myKpis?.({ ...params, include_forecast: true }) myKpis.value = (r as any)?.data || [] + forecastList.value = (r as any)?.forecast || [] dataRange.value = (r as any)?.period || '' } else if (userRole.value === 'finance') { - const r: any = await dashboardApi.financeAnalysis?.({ ...params }) + const r: any = await dashboardApi.financeAnalysis?.({ ...params, include_forecast: true }) finKpis.value = (r as any)?.kpis || [] + forecastList.value = (r as any)?.forecast || [] finSummary.value = (r as any)?.summary || {} dataRange.value = (r as any)?.period || '' } else if (userRole.value === 'it') { @@ -546,6 +591,9 @@ onMounted(() => { loadData() }) .kpi-row-btm { display:flex; align-items:baseline; gap:4px; } .kpi-row-val { font-size:18px; font-weight:700; color:#1a1a2e; } .kpi-row-target { font-size:12px; color:#aaa; } +/* 预估区(单列,不与实测值相加;财务裁定 2026-10-02) */ +.kpi-row-forecast { display:flex; align-items:center; gap:4px; margin-top:2px; } +.kpi-row-fc-val { font-size:12px; color:#b88230; } /* 右侧预测 */ .right-card { background:#fff; border-radius:10px; padding:16px; box-shadow:0 1px 3px rgba(0,0,0,0.05); border:1px solid #f0f0f0; } @@ -581,6 +629,18 @@ onMounted(() => { loadData() }) .card-item-val { font-size:26px; font-weight:700; margin-bottom:4px; } .item-unit { font-size:13px; color:#999; font-weight:400; margin-left:4px; } .card-item-target { font-size:12px; color:#aaa; margin-bottom:6px; } +/* 预估区(单列:卡片内独立一行 + 「预估」标签,不与实测相加/同线) */ +.card-item-forecast { display:flex; align-items:center; gap:4px; margin-top:2px; } +.card-item-fc-val { font-size:12px; color:#b88230; } +.forecast-card { margin-top:16px; } +.forecast-note { font-size:12px; color:#999; margin:-8px 0 10px; } +.forecast-block { margin-top:16px; background:#fff; border:1px solid #f0f0f0; border-radius:10px; padding:16px; box-shadow:0 1px 3px rgba(0,0,0,0.05); } +.forecast-block-title { font-size:15px; font-weight:600; margin-bottom:10px; } +.forecast-row { display:flex; align-items:center; gap:8px; padding:6px 0; border-top:1px dashed #f2f2f2; } +.forecast-row:first-of-type { border-top:none; } +.forecast-name { flex:1; font-size:13px; color:#333; } +.forecast-period { font-size:12px; color:#999; } +.forecast-val { font-size:13px; font-weight:600; color:#b88230; } .card-item-trend { display:flex; align-items:center; gap:4px; } .tdot { width:8px; height:8px; border-radius:50%; display:inline-block; } .trend-hint { font-size:10px; color:#ccc; margin-left:2px; } diff --git a/frontend/src/views/MyDashboard.vue b/frontend/src/views/MyDashboard.vue index 2bd33200..57b2852a 100644 --- a/frontend/src/views/MyDashboard.vue +++ b/frontend/src/views/MyDashboard.vue @@ -87,6 +87,11 @@ :show-text="false" />
—
+ +
+ + {{ k.forecast_value }} +
@@ -128,6 +133,8 @@ import { ref, computed, onMounted } from "vue" import { useRouter } from "vue-router" import api from "../api/index" +// 预估区标签(财务裁定 2026-10-02:预估行单列展示,不与实测相加/同线) +import ForecastTag from "../components/ForecastTag.vue" // 排序固化: danger(逾期行动/红色预警) 在前, warning(黄色预警) 在后; // 同 severity 保持后端生成顺序 (overdue→red→yellow), 不改变现有展示次序 @@ -146,6 +153,8 @@ const loading = ref(true) const kpis = ref([]) const actionPlans = ref([]) const reminders = ref([]) +// 预估区(单列,不计入卡片实测值;财务裁定 2026-10-02) +const forecastList = ref([]) const todayStr = new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' }) function fmtValue(val: any, code: string) { @@ -224,8 +233,10 @@ function goToDetail(r: any) { onMounted(async () => { try { - const r: any = await api.get('/dashboard/my-dashboard') + // include_forecast=true:预估行**单列返回**(forecast 字段),卡片实测值不受影响 + const r: any = await api.get('/dashboard/my-dashboard', { params: { include_forecast: true } }) kpis.value = r.kpis || [] + forecastList.value = r.forecast || [] actionPlans.value = r.action_plans || [] reminders.value = r.reminders || [] } catch (e: any) { @@ -342,6 +353,9 @@ onMounted(async () => { padding: 0 6px; border-radius: 4px; margin-right: 6px; } .kpi-target { font-size: 12px; color: #999; } +/* 预估区(单列一行 + 「预估」标签,不与卡片实测值相加) */ +.kpi-forecast-row { display: flex; align-items: center; gap: 4px; margin-top: 6px; } +.kpi-forecast-val { font-size: 12px; color: #b88230; } /* ── 改善行动 ── */ .plans-section { margin-bottom: 28px; }