- 新增 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:
@@ -18,6 +18,9 @@ from app.utils.oplog import log_op
|
|||||||
# 裁定 R1(2026-09-19 财务口径):demo 账套不参与预警生成/遍历(单一事实源)
|
# 裁定 R1(2026-09-19 财务口径):demo 账套不参与预警生成/遍历(单一事实源)
|
||||||
# 裁定 R5(2026-09-19 财务口径):期间非规整月 YYYY-MM → 该 kpi_alerts 行不入库(生成侧守卫)
|
# 裁定 R5(2026-09-19 财务口径):期间非规整月 YYYY-MM → 该 kpi_alerts 行不入库(生成侧守卫)
|
||||||
from app.utils.alert_entity_scope import guard_alert_period, is_excluded_entity
|
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")
|
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
|
alerts_generated += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 获取最新值
|
# 获取最新值(仅实测区:预估行禁作预警依据,财务裁定 2026-10-02)
|
||||||
latest_value = value_cache.get(rule.kpi_id)
|
latest_value = value_cache.get(rule.kpi_id)
|
||||||
if latest_value is None:
|
if latest_value is None:
|
||||||
latest_value = db.query(KPIValue).filter(
|
latest_value = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == rule.kpi_id,
|
KPIValue.kpi_id == rule.kpi_id,
|
||||||
|
actual_only_clause(),
|
||||||
).order_by(KPIValue.period.desc()).first()
|
).order_by(KPIValue.period.desc()).first()
|
||||||
if latest_value:
|
if latest_value:
|
||||||
value_cache[rule.kpi_id] = 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(
|
values = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == kpi.id,
|
KPIValue.kpi_id == kpi.id,
|
||||||
KPIValue.actual_value.isnot(None),
|
KPIValue.actual_value.isnot(None),
|
||||||
KPIValue.data_status.in_(["verified", "estimated"]),
|
# 白名单去掉 estimated(财务裁定 2026-10-02):预估行不参与动态阈值计算
|
||||||
|
KPIValue.data_status.in_(["verified"]),
|
||||||
KPIValue.period < current_period,
|
KPIValue.period < current_period,
|
||||||
).order_by(KPIValue.period.desc()).limit(3).all()
|
).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)
|
threshold_pct = params.get("threshold_pct", 10)
|
||||||
|
|
||||||
# 获取上月值
|
# 获取上月值(仅实测区:伪期间预估行会因字符串序顶掉上月实测值)
|
||||||
prev_value = db.query(KPIValue).filter(
|
prev_value = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == kpi_id,
|
KPIValue.kpi_id == kpi_id,
|
||||||
KPIValue.actual_value.isnot(None),
|
KPIValue.actual_value.isnot(None),
|
||||||
|
actual_only_clause(),
|
||||||
).order_by(KPIValue.period.desc()).offset(1).limit(1).first()
|
).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:
|
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:
|
if not log or log.forecast_value is None:
|
||||||
continue
|
continue
|
||||||
# 该预测期的实际值(同period匹配;兼容 2026-H1 等半年度)
|
# 该预测期的实际值(同period匹配;兼容 2026-H1 等半年度)
|
||||||
|
# 仅实测区:预估行不得与预测值自比(否则偏差恒 0,预警失效)
|
||||||
actual = db.query(KPIValue).filter(
|
actual = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == rule.kpi_id,
|
KPIValue.kpi_id == rule.kpi_id,
|
||||||
KPIValue.period == log.period,
|
KPIValue.period == log.period,
|
||||||
|
actual_only_clause(),
|
||||||
).order_by(KPIValue.id.desc()).first()
|
).order_by(KPIValue.id.desc()).first()
|
||||||
if not actual or not actual.actual_value:
|
if not actual or not actual.actual_value:
|
||||||
continue
|
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})"
|
detail = f"{head}收入占比={share}%(合计{total:.0f}元,{rng[0]}~{rng[1]}:{items})"
|
||||||
|
|
||||||
if share is None and source in ("auto", "kpi"):
|
if share is None and source in ("auto", "kpi"):
|
||||||
|
# 仅实测区:预估行禁作预警依据(财务裁定 2026-10-02)
|
||||||
latest = db.query(KPIValue).filter(
|
latest = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == kpi_id,
|
KPIValue.kpi_id == kpi_id,
|
||||||
KPIValue.actual_value.isnot(None),
|
KPIValue.actual_value.isnot(None),
|
||||||
|
actual_only_clause(),
|
||||||
).order_by(KPIValue.period.desc(), KPIValue.id.desc()).first()
|
).order_by(KPIValue.period.desc(), KPIValue.id.desc()).first()
|
||||||
if latest:
|
if latest:
|
||||||
share = round(float(latest.actual_value), 2)
|
share = round(float(latest.actual_value), 2)
|
||||||
|
|||||||
+207
-21
@@ -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.voucher_guard import apply_voucher_guard
|
||||||
from app.utils.threshold_parser import eval_threshold
|
from app.utils.threshold_parser import eval_threshold
|
||||||
from app.utils.alert_entity_scope import month_period_clause
|
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 收口)。
|
# 指标方向(反向/正向)唯一权威源 = app.api.kpis.REVERSE_INDICATORS(2026-09-15 M2 准入2 收口)。
|
||||||
# 本文件原自带一份字面量副本,与 kpis.py 漂移后会与「能力评分」判定相反,故改为导入。
|
# 本文件原自带一份字面量副本,与 kpis.py 漂移后会与「能力评分」判定相反,故改为导入。
|
||||||
from app.api.kpis import REVERSE_INDICATORS
|
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)
|
cache_set("dashboard", cache_key, result, ttl_seconds=30)
|
||||||
return result
|
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")
|
@router.get("/kpis")
|
||||||
def get_dashboard_kpis(role: str = Query("ceo"), period: str = Query("month"),
|
def get_dashboard_kpis(role: str = Query("ceo"), period: str = Query("month"),
|
||||||
start_date: str = Query(None), end_date: str = Query(None),
|
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)):
|
db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
start, end = parse_period(period, start_date, end_date)
|
start, end = parse_period(period, start_date, end_date)
|
||||||
period_str = start.strftime("%Y-%m")
|
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
|
KPIDefinition.entity_id == entity_id
|
||||||
).all()
|
).all()
|
||||||
result = []
|
result = []
|
||||||
|
forecast_section = [] # 预估区(单列,不计入上面的 actual_value)
|
||||||
|
|
||||||
for k in kpis:
|
for k in kpis:
|
||||||
base_query = db.query(KPIValue).filter(KPIValue.kpi_id == k.id)
|
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":
|
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":
|
elif period == "quarter":
|
||||||
months = period_prefix("quarter")
|
months = period_prefix("quarter")
|
||||||
values = base_query.filter(KPIValue.period.in_(months)).all()
|
latest = _aggregate_actual(actual_query.filter(KPIValue.period.in_(months)).all(), range_label)
|
||||||
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
|
if forecast_query is not None:
|
||||||
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{months[0]}~{months[-1]}"})() if latest_val else None
|
forecast_value = _sum_rows(forecast_query.filter(KPIValue.period.in_(months)).all())
|
||||||
elif period == "year":
|
elif period == "year":
|
||||||
values = base_query.filter(KPIValue.period.like(f"{period_str[:4]}%")).all()
|
latest = _aggregate_actual(
|
||||||
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
|
actual_query.filter(KPIValue.period.like(f"{period_str[:4]}%")).all(), range_label)
|
||||||
latest = type('obj', (object,), {"actual_value": latest_val, "period": period_str[:4]})() if latest_val else None
|
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:
|
elif period == "custom" and start_date and end_date:
|
||||||
periods = []
|
periods = []
|
||||||
d = start
|
d = start
|
||||||
@@ -364,36 +418,62 @@ def get_dashboard_kpis(role: str = Query("ceo"), period: str = Query("month"),
|
|||||||
periods.append(d.strftime("%Y-%m"))
|
periods.append(d.strftime("%Y-%m"))
|
||||||
d += timedelta(days=32)
|
d += timedelta(days=32)
|
||||||
d = d.replace(day=1)
|
d = d.replace(day=1)
|
||||||
values = base_query.filter(KPIValue.period.in_(set(periods))).all()
|
latest = _aggregate_actual(actual_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
|
range_label)
|
||||||
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{start_date}~{end_date}"})() if latest_val else None
|
if forecast_query is not None:
|
||||||
|
forecast_value = _sum_rows(
|
||||||
|
forecast_query.filter(KPIValue.period.in_(set(periods))).all())
|
||||||
else:
|
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(
|
alert = db.query(KPIAlert).filter(
|
||||||
KPIAlert.kpi_id == k.id,
|
KPIAlert.kpi_id == k.id,
|
||||||
KPIAlert.status == "pending",
|
KPIAlert.status == "pending",
|
||||||
).order_by(KPIAlert.id.desc()).first()
|
).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({
|
result.append({
|
||||||
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
|
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
|
||||||
"dimension": k.dimension, "unit": k.unit, "target_value": k.target_value,
|
"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,
|
"target_monthly": k.target_monthly, "target_quarterly": k.target_quarterly, "target_yearly": k.target_yearly,
|
||||||
"actual_value": latest.actual_value if latest else None,
|
"actual_value": latest.actual_value if latest else None,
|
||||||
"period": latest.period 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_level": alert.alert_level if alert else "none",
|
||||||
"alert_message": alert.alert_message if alert else None,
|
"alert_message": alert.alert_message if alert else None,
|
||||||
"frequency": k.frequency,
|
"frequency": k.frequency,
|
||||||
"responsible_dept": k.responsible_dept,
|
"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")
|
@router.get("/my-kpis")
|
||||||
def get_my_kpis(
|
def get_my_kpis(
|
||||||
current_user: User = Depends(require_auth),
|
current_user: User = Depends(require_auth),
|
||||||
period: str = Query("month"),
|
period: str = Query("month"),
|
||||||
|
include_forecast: bool = Query(False, description="是否单列返回预估区;默认 false=只取实测区"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
entity_id: int = Depends(get_entity_id),
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
@@ -411,6 +491,7 @@ def get_my_kpis(
|
|||||||
KPIDefinition.entity_id == entity_id, # 账套隔离 2026-09-21
|
KPIDefinition.entity_id == entity_id, # 账套隔离 2026-09-21
|
||||||
).all()
|
).all()
|
||||||
result = []
|
result = []
|
||||||
|
forecast_section = [] # 预估区(单列,不进 actual_value / trend)
|
||||||
|
|
||||||
for k in kpis:
|
for k in kpis:
|
||||||
# business角色筛选
|
# business角色筛选
|
||||||
@@ -419,11 +500,36 @@ def get_my_kpis(
|
|||||||
if responsible and responsible != username and responsible != name:
|
if responsible and responsible != username and responsible != name:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# 实测区恒过滤(预估行禁作实测值/禁进趋势序列)
|
||||||
latest = db.query(KPIValue).filter(
|
latest = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == k.id,
|
KPIValue.kpi_id == k.id,
|
||||||
KPIValue.period == period_str,
|
KPIValue.period == period_str,
|
||||||
|
actual_only_clause(),
|
||||||
).order_by(KPIValue.id.desc()).first()
|
).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(
|
alert = db.query(KPIAlert).filter(
|
||||||
KPIAlert.kpi_id == k.id,
|
KPIAlert.kpi_id == k.id,
|
||||||
KPIAlert.status == "pending",
|
KPIAlert.status == "pending",
|
||||||
@@ -432,6 +538,7 @@ def get_my_kpis(
|
|||||||
trend_values = db.query(KPIValue).filter(
|
trend_values = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == k.id,
|
KPIValue.kpi_id == k.id,
|
||||||
month_period_clause(KPIValue.period),
|
month_period_clause(KPIValue.period),
|
||||||
|
actual_only_clause(),
|
||||||
).order_by(KPIValue.period.desc()).limit(6).all()
|
).order_by(KPIValue.period.desc()).limit(6).all()
|
||||||
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(trend_values)]
|
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,
|
"target_monthly": k.target_monthly, "target_quarterly": k.target_quarterly, "target_yearly": k.target_yearly,
|
||||||
"actual_value": latest.actual_value if latest else None,
|
"actual_value": latest.actual_value if latest else None,
|
||||||
"period": latest.period if latest else period_str,
|
"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_level": alert.alert_level if alert else "none",
|
||||||
"alert_message": alert.alert_message if alert else None,
|
"alert_message": alert.alert_message if alert else None,
|
||||||
"alert_id": alert.id 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,
|
"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")
|
@router.get("/finance-analysis")
|
||||||
def get_finance_analysis(
|
def get_finance_analysis(
|
||||||
current_user: User = Depends(require_auth),
|
current_user: User = Depends(require_auth),
|
||||||
period: str = Query("month"),
|
period: str = Query("month"),
|
||||||
|
include_forecast: bool = Query(False, description="是否单列返回预估区;默认 false=只取实测区"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
entity_id: int = Depends(get_entity_id),
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
@@ -474,15 +588,33 @@ def get_finance_analysis(
|
|||||||
).all()
|
).all()
|
||||||
|
|
||||||
kpi_data = []
|
kpi_data = []
|
||||||
|
forecast_section = [] # 预估区(单列,不进 trend / summary)
|
||||||
for k in finance_kpis:
|
for k in finance_kpis:
|
||||||
|
# 实测区恒过滤(预估行禁作实测值/禁进趋势序列)
|
||||||
latest = db.query(KPIValue).filter(
|
latest = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == k.id,
|
KPIValue.kpi_id == k.id,
|
||||||
KPIValue.period == period_str,
|
KPIValue.period == period_str,
|
||||||
|
actual_only_clause(),
|
||||||
).order_by(KPIValue.id.desc()).first()
|
).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(
|
trend_values = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == k.id,
|
KPIValue.kpi_id == k.id,
|
||||||
month_period_clause(KPIValue.period),
|
month_period_clause(KPIValue.period),
|
||||||
|
actual_only_clause(),
|
||||||
).order_by(KPIValue.period.desc()).limit(6).all()
|
).order_by(KPIValue.period.desc()).limit(6).all()
|
||||||
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(trend_values)]
|
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,
|
"unit": k.unit, "target_value": k.target_value,
|
||||||
"target_monthly": k.target_monthly, "target_quarterly": k.target_quarterly, "target_yearly": k.target_yearly,
|
"target_monthly": k.target_monthly, "target_quarterly": k.target_quarterly, "target_yearly": k.target_yearly,
|
||||||
"actual_value": latest.actual_value if latest else None,
|
"actual_value": latest.actual_value if latest else None,
|
||||||
|
"forecast_value": forecast_value,
|
||||||
|
"is_forecast": False,
|
||||||
"threshold_green": k.threshold_green,
|
"threshold_green": k.threshold_green,
|
||||||
"threshold_yellow": k.threshold_yellow,
|
"threshold_yellow": k.threshold_yellow,
|
||||||
"threshold_red": k.threshold_red,
|
"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)
|
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)
|
receivable = next((k for k in kpi_data if k["kpi_code"] == "RECEIVABLE_TURNOVER"), None)
|
||||||
|
|
||||||
return {
|
resp = {
|
||||||
"period": period_str,
|
"period": period_str,
|
||||||
"kpis": kpi_data,
|
"kpis": kpi_data,
|
||||||
"summary": {
|
"summary": {
|
||||||
@@ -519,11 +653,21 @@ def get_finance_analysis(
|
|||||||
"receivable_turnover": receivable["actual_value"] if receivable else None,
|
"receivable_turnover": receivable["actual_value"] if receivable else None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if include_forecast:
|
||||||
|
resp["forecast"] = forecast_section
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@router.get("/predict")
|
@router.get("/predict")
|
||||||
def predict_kpis(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
def predict_kpis(include_forecast: bool = Query(
|
||||||
"""基于历史趋势预测下月KPI值(简单线性回归)"""
|
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
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
period_str = datetime.now().strftime("%Y-%m")
|
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
|
KPIDefinition.entity_id == entity_id
|
||||||
).all()
|
).all()
|
||||||
predictions = []
|
predictions = []
|
||||||
|
forecast_rows = [] # 预估区(单列,不参与回归)
|
||||||
|
|
||||||
for k in kpis:
|
for k in kpis:
|
||||||
values = db.query(KPIValue).filter(
|
values = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == k.id,
|
KPIValue.kpi_id == k.id,
|
||||||
|
actual_only_clause(),
|
||||||
).order_by(KPIValue.period.asc()).all()
|
).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个数据点才能做预测
|
# 需要至少3个数据点才能做预测
|
||||||
if len(values) < 3:
|
if len(values) < 3:
|
||||||
continue
|
continue
|
||||||
@@ -595,13 +752,16 @@ def predict_kpis(db: Session = Depends(get_db), entity_id: int = Depends(get_ent
|
|||||||
"data_points": len(points),
|
"data_points": len(points),
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
resp = {
|
||||||
"current_period": period_str,
|
"current_period": period_str,
|
||||||
"next_period": next_period,
|
"next_period": next_period,
|
||||||
"predictions": predictions,
|
"predictions": predictions,
|
||||||
"kpi_count": len(kpis),
|
"kpi_count": len(kpis),
|
||||||
"predictable_count": len(predictions),
|
"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]:
|
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 = (
|
monthly = (
|
||||||
db.query(KPIValue)
|
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(
|
.order_by(
|
||||||
KPIValue.period.desc(),
|
KPIValue.period.desc(),
|
||||||
_MAIN_VALUE_PRIORITY,
|
_MAIN_VALUE_PRIORITY,
|
||||||
@@ -651,7 +816,7 @@ def latest_kpi_value(db: Session, kpi_id: int) -> Optional[KPIValue]:
|
|||||||
return monthly
|
return monthly
|
||||||
return (
|
return (
|
||||||
db.query(KPIValue)
|
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())
|
.order_by(KPIValue.calculated_at.desc(), KPIValue.id.desc())
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
@@ -660,6 +825,7 @@ def latest_kpi_value(db: Session, kpi_id: int) -> Optional[KPIValue]:
|
|||||||
@router.get("/my-dashboard")
|
@router.get("/my-dashboard")
|
||||||
def my_dashboard(
|
def my_dashboard(
|
||||||
current_user: User = Depends(require_auth),
|
current_user: User = Depends(require_auth),
|
||||||
|
include_forecast: bool = Query(False, description="是否单列返回预估区;默认 false=只取实测区"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
entity_id: int = Depends(get_entity_id),
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
@@ -703,9 +869,23 @@ def my_dashboard(
|
|||||||
all_kpis = assigned_kpis + preset_kpis
|
all_kpis = assigned_kpis + preset_kpis
|
||||||
|
|
||||||
kpi_list = []
|
kpi_list = []
|
||||||
|
forecast_section = [] # 预估区(单列,不作卡片实测值)
|
||||||
for k in all_kpis:
|
for k in all_kpis:
|
||||||
latest_v = latest_kpi_value(db, k.id)
|
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
|
actual = latest_v.actual_value if latest_v else None
|
||||||
target = kpi_target_by_frequency(k)
|
target = kpi_target_by_frequency(k)
|
||||||
level = "gray"
|
level = "gray"
|
||||||
@@ -735,6 +915,9 @@ def my_dashboard(
|
|||||||
"unit": k.unit,
|
"unit": k.unit,
|
||||||
"level": level,
|
"level": level,
|
||||||
"period": latest_v.period if latest_v else None,
|
"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/管理员看全部)
|
# 2. 我的改善行动(assignee匹配;CEO/管理员看全部)
|
||||||
@@ -814,8 +997,11 @@ def my_dashboard(
|
|||||||
"related_type": "kpi",
|
"related_type": "kpi",
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
resp = {
|
||||||
"kpis": kpi_list,
|
"kpis": kpi_list,
|
||||||
"action_plans": plan_list,
|
"action_plans": plan_list,
|
||||||
"reminders": reminders,
|
"reminders": reminders,
|
||||||
}
|
}
|
||||||
|
if include_forecast:
|
||||||
|
resp["forecast"] = forecast_section
|
||||||
|
return resp
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<span class="forecast-tag" :title="title">{{ label }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 预估标签(predefined「预估」标记)
|
||||||
|
*
|
||||||
|
* 口径:财务裁定 2026-10-02 —— 预估行(data_status='estimated' 或 source_type='forecast')
|
||||||
|
* 单列展示、带本标签;不得与实测相加、不得同线绘制、不得作预警依据。
|
||||||
|
* 无 emoji(前端护栏)。
|
||||||
|
*/
|
||||||
|
withDefaults(defineProps<{ label?: string; title?: string }>(), {
|
||||||
|
label: '预估',
|
||||||
|
title: '预估值:来源 data_status=estimated / source_type=forecast,单列展示,不计入实测汇总、不进趋势与预警',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.forecast-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #b88230;
|
||||||
|
background: #fdf6ec;
|
||||||
|
border: 1px solid #f5dab1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -93,6 +93,11 @@
|
|||||||
<span class="kpi-row-val">{{ fmtValue(k.actual_value, k.kpi_name) }}</span>
|
<span class="kpi-row-val">{{ fmtValue(k.actual_value, k.kpi_name) }}</span>
|
||||||
<span class="kpi-row-target">/ {{ k.target_value ?? '-' }}</span>
|
<span class="kpi-row-target">/ {{ k.target_value ?? '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 预估区(单列,不与上面的实测值相加) -->
|
||||||
|
<div v-if="k.forecast_value != null" class="kpi-row-forecast">
|
||||||
|
<ForecastTag />
|
||||||
|
<span class="kpi-row-fc-val">{{ fmtValue(k.forecast_value, k.kpi_name) }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,6 +129,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 预估区(单列展示:不与实测相加、不进趋势序列、不作预警依据) -->
|
||||||
|
<div v-if="forecastList.length > 0" class="right-card forecast-card">
|
||||||
|
<div class="right-card-title">预估区(不计入实测)</div>
|
||||||
|
<div class="forecast-note">以下为预估值(data_status=estimated / source_type=forecast),单列展示</div>
|
||||||
|
<div v-for="f in forecastList" :key="f.kpi_code + '|' + f.period" class="forecast-row">
|
||||||
|
<span class="forecast-name">{{ f.kpi_name }}</span>
|
||||||
|
<span class="forecast-period">{{ f.period }}</span>
|
||||||
|
<span class="forecast-val">{{ fmtValue(f.value, f.kpi_code) }}</span>
|
||||||
|
<ForecastTag />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -149,6 +166,11 @@
|
|||||||
<span class="item-unit">{{ k.unit }}</span>
|
<span class="item-unit">{{ k.unit }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-item-target">目标 {{ k.target_value ?? '-' }}</div>
|
<div class="card-item-target">目标 {{ k.target_value ?? '-' }}</div>
|
||||||
|
<!-- 预估区(单列,不与上面的实测值相加) -->
|
||||||
|
<div v-if="k.forecast_value != null" class="card-item-forecast">
|
||||||
|
<ForecastTag />
|
||||||
|
<span class="card-item-fc-val">{{ fmtValue(k.forecast_value, k.kpi_name) }}</span>
|
||||||
|
</div>
|
||||||
<div class="card-item-trend" v-if="k.trend && k.trend.length >= 2">
|
<div class="card-item-trend" v-if="k.trend && k.trend.length >= 2">
|
||||||
<span v-for="(t, i) in k.trend.slice(-3)" :key="i" class="tdot" :style="{ background: trendColor(t.value, k) }"></span>
|
<span v-for="(t, i) in k.trend.slice(-3)" :key="i" class="tdot" :style="{ background: trendColor(t.value, k) }"></span>
|
||||||
<span class="trend-hint">近3月</span>
|
<span class="trend-hint">近3月</span>
|
||||||
@@ -185,6 +207,21 @@
|
|||||||
<div class="tbar-label">{{ t.period.slice(-2) }}</div>
|
<div class="tbar-label">{{ t.period.slice(-2) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 预估区(单列;不进上方趋势条,趋势序列仅实测区) -->
|
||||||
|
<div v-if="k.forecast_value != null" class="card-item-forecast">
|
||||||
|
<ForecastTag />
|
||||||
|
<span class="card-item-fc-val">{{ fmtValue(k.forecast_value, k.kpi_code) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 预估区(单列展示:不与实测相加、不进趋势序列) -->
|
||||||
|
<div v-if="forecastList.length > 0" class="forecast-block">
|
||||||
|
<div class="forecast-block-title">预估区(不计入实测)</div>
|
||||||
|
<div v-for="f in forecastList" :key="f.kpi_code + '|' + f.period" class="forecast-row">
|
||||||
|
<span class="forecast-name">{{ f.kpi_name }}</span>
|
||||||
|
<span class="forecast-period">{{ f.period }}</span>
|
||||||
|
<span class="forecast-val">{{ fmtValue(f.value, f.kpi_code) }}</span>
|
||||||
|
<ForecastTag />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -277,6 +314,8 @@ import CountUp from '../components/CountUp.vue'
|
|||||||
import GrowthQuality from '../components/GrowthQuality.vue'
|
import GrowthQuality from '../components/GrowthQuality.vue'
|
||||||
import BEPBreakEvenCard from '../components/BEPBreakEvenCard.vue'
|
import BEPBreakEvenCard from '../components/BEPBreakEvenCard.vue'
|
||||||
import MyDialog from '../components/MyDialog.vue'
|
import MyDialog from '../components/MyDialog.vue'
|
||||||
|
// 预估区标签(财务裁定 2026-10-02:预估行单列展示,不与实测相加/同线)
|
||||||
|
import ForecastTag from '../components/ForecastTag.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -327,6 +366,8 @@ const yellowKpis = computed(() => kpis.value.filter((k: any) => k.alert_level ==
|
|||||||
const myKpis = ref<any[]>([])
|
const myKpis = ref<any[]>([])
|
||||||
const finKpis = ref<any[]>([])
|
const finKpis = ref<any[]>([])
|
||||||
const finSummary = ref<any>({})
|
const finSummary = ref<any>({})
|
||||||
|
// 预估区(单列,不计入实测汇总;财务裁定 2026-10-02)
|
||||||
|
const forecastList = ref<any[]>([])
|
||||||
const enabledChannels = ref(0)
|
const enabledChannels = ref(0)
|
||||||
const aiAnalysis = ref('')
|
const aiAnalysis = ref('')
|
||||||
const aiLoading = ref(false)
|
const aiLoading = ref(false)
|
||||||
@@ -456,25 +497,29 @@ async function loadData() {
|
|||||||
try {
|
try {
|
||||||
const entityId = Number(localStorage.getItem('cma_entity_id') || 1)
|
const entityId = Number(localStorage.getItem('cma_entity_id') || 1)
|
||||||
const params = { ...getPeriodParams(), entity_id: entityId }
|
const params = { ...getPeriodParams(), entity_id: entityId }
|
||||||
|
// include_forecast=true:预估行**单列返回**(forecast 字段),实测取值不受影响
|
||||||
if (userRole.value === 'ceo') {
|
if (userRole.value === 'ceo') {
|
||||||
const [kpiRes, alertRes, sumRes, predRes] = await Promise.all([
|
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 }),
|
alertApi.list({ limit: 10, entity_id: entityId }),
|
||||||
dashboardApi.summary({ role: 'ceo', ...params }),
|
dashboardApi.summary({ role: 'ceo', ...params }),
|
||||||
dashboardApi.predict({ entity_id: entityId }).catch(() => ({ data: [] })),
|
dashboardApi.predict({ entity_id: entityId }).catch(() => ({ data: [] })),
|
||||||
])
|
])
|
||||||
kpis.value = (kpiRes as any).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') || []
|
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 }
|
summary.value = { kpi_total: (sumRes as any).kpi_total || 0, alert_count: (sumRes as any).alert_count || 0 }
|
||||||
predictions.value = (predRes as any)?.predictions || []
|
predictions.value = (predRes as any)?.predictions || []
|
||||||
dataRange.value = (kpiRes as any).range?.start + ' ~ ' + (kpiRes as any).range?.end
|
dataRange.value = (kpiRes as any).range?.start + ' ~ ' + (kpiRes as any).range?.end
|
||||||
} else if (userRole.value === 'business') {
|
} 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 || []
|
myKpis.value = (r as any)?.data || []
|
||||||
|
forecastList.value = (r as any)?.forecast || []
|
||||||
dataRange.value = (r as any)?.period || ''
|
dataRange.value = (r as any)?.period || ''
|
||||||
} else if (userRole.value === 'finance') {
|
} 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 || []
|
finKpis.value = (r as any)?.kpis || []
|
||||||
|
forecastList.value = (r as any)?.forecast || []
|
||||||
finSummary.value = (r as any)?.summary || {}
|
finSummary.value = (r as any)?.summary || {}
|
||||||
dataRange.value = (r as any)?.period || ''
|
dataRange.value = (r as any)?.period || ''
|
||||||
} else if (userRole.value === 'it') {
|
} else if (userRole.value === 'it') {
|
||||||
@@ -546,6 +591,9 @@ onMounted(() => { loadData() })
|
|||||||
.kpi-row-btm { display:flex; align-items:baseline; gap:4px; }
|
.kpi-row-btm { display:flex; align-items:baseline; gap:4px; }
|
||||||
.kpi-row-val { font-size:18px; font-weight:700; color:#1a1a2e; }
|
.kpi-row-val { font-size:18px; font-weight:700; color:#1a1a2e; }
|
||||||
.kpi-row-target { font-size:12px; color:#aaa; }
|
.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; }
|
.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; }
|
.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; }
|
.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-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; }
|
.card-item-trend { display:flex; align-items:center; gap:4px; }
|
||||||
.tdot { width:8px; height:8px; border-radius:50%; display:inline-block; }
|
.tdot { width:8px; height:8px; border-radius:50%; display:inline-block; }
|
||||||
.trend-hint { font-size:10px; color:#ccc; margin-left:2px; }
|
.trend-hint { font-size:10px; color:#ccc; margin-left:2px; }
|
||||||
|
|||||||
@@ -87,6 +87,11 @@
|
|||||||
:show-text="false"
|
:show-text="false"
|
||||||
/>
|
/>
|
||||||
<div v-else style="height:6px;line-height:6px;color:#c0c4cc;font-size:11px">—</div>
|
<div v-else style="height:6px;line-height:6px;color:#c0c4cc;font-size:11px">—</div>
|
||||||
|
<!-- 预估区(单列,不与上方实测值相加;财务裁定 2026-10-02) -->
|
||||||
|
<div v-if="k.forecast_value != null" class="kpi-forecast-row">
|
||||||
|
<ForecastTag />
|
||||||
|
<span class="kpi-forecast-val">{{ k.forecast_value }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,6 +133,8 @@
|
|||||||
import { ref, computed, onMounted } from "vue"
|
import { ref, computed, onMounted } from "vue"
|
||||||
import { useRouter } from "vue-router"
|
import { useRouter } from "vue-router"
|
||||||
import api from "../api/index"
|
import api from "../api/index"
|
||||||
|
// 预估区标签(财务裁定 2026-10-02:预估行单列展示,不与实测相加/同线)
|
||||||
|
import ForecastTag from "../components/ForecastTag.vue"
|
||||||
|
|
||||||
// 排序固化: danger(逾期行动/红色预警) 在前, warning(黄色预警) 在后;
|
// 排序固化: danger(逾期行动/红色预警) 在前, warning(黄色预警) 在后;
|
||||||
// 同 severity 保持后端生成顺序 (overdue→red→yellow), 不改变现有展示次序
|
// 同 severity 保持后端生成顺序 (overdue→red→yellow), 不改变现有展示次序
|
||||||
@@ -146,6 +153,8 @@ const loading = ref(true)
|
|||||||
const kpis = ref<any[]>([])
|
const kpis = ref<any[]>([])
|
||||||
const actionPlans = ref<any[]>([])
|
const actionPlans = ref<any[]>([])
|
||||||
const reminders = ref<any[]>([])
|
const reminders = ref<any[]>([])
|
||||||
|
// 预估区(单列,不计入卡片实测值;财务裁定 2026-10-02)
|
||||||
|
const forecastList = ref<any[]>([])
|
||||||
const todayStr = new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' })
|
const todayStr = new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' })
|
||||||
|
|
||||||
function fmtValue(val: any, code: string) {
|
function fmtValue(val: any, code: string) {
|
||||||
@@ -224,8 +233,10 @@ function goToDetail(r: any) {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
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 || []
|
kpis.value = r.kpis || []
|
||||||
|
forecastList.value = r.forecast || []
|
||||||
actionPlans.value = r.action_plans || []
|
actionPlans.value = r.action_plans || []
|
||||||
reminders.value = r.reminders || []
|
reminders.value = r.reminders || []
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -342,6 +353,9 @@ onMounted(async () => {
|
|||||||
padding: 0 6px; border-radius: 4px; margin-right: 6px;
|
padding: 0 6px; border-radius: 4px; margin-right: 6px;
|
||||||
}
|
}
|
||||||
.kpi-target { font-size: 12px; color: #999; }
|
.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; }
|
.plans-section { margin-bottom: 28px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user