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