P0-A cash.py: scope api_update_plan / api_delete_plan / api_complete_plan /
api_register_payment to session entity (cross-entity id -> 404, aligning with
the file's existing get_entity_id endpoints).
P0-B budget.py: write entity_id on BudgetPlan creation + require the KPI to
belong to the session entity; scope /comparison, /comparison/kpi/{id} and
/deviation-report reads to the session entity.
P1 notifications.py: explicit field whitelist (name/config/enabled) on
update_channel to close mass-assignment.
P1 read leaks scoped to session entity: bot_kpis list, data_quality check/stats
(KPI calibre only; quality-log table has no entity_id column), kpis causality
chain + associate-map (KPI and map ownership), dashboard my-kpis,
customer_dashboard list, kpi_governance audit, budget_generate candidates,
driver_budget history (+ entity_id on its write path), expenses stats
(+ entity_id on its write path).
P2 okr_templates.py: write entity_id on the StrategicMap created by apply.
tests: tests/test_cross_entity_isolation_batch4_20260921.py (30 cases;
RED before fix = 22 failed, GREEN after = 30 passed; full suite 1302 passed /
1 xfailed / 0 failed).
764 lines
32 KiB
Python
764 lines
32 KiB
Python
"""驾驶舱 API v2 — 支持时间区间"""
|
||
from fastapi import APIRouter, Depends, Query, Request, HTTPException
|
||
from sqlalchemy.orm import Session
|
||
from sqlalchemy import func, or_
|
||
from datetime import datetime, timedelta
|
||
from typing import Optional
|
||
from app.database import get_db
|
||
from app.auth_middleware import require_auth, require_role
|
||
from app.deps import get_entity_id
|
||
from app.models import KPIDefinition, KPIValue, KPIAlert, User, VoucherDetail
|
||
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
|
||
# 指标方向(反向/正向)唯一权威源 = app.api.kpis.REVERSE_INDICATORS(2026-09-15 M2 准入2 收口)。
|
||
# 本文件原自带一份字面量副本,与 kpis.py 漂移后会与「能力评分」判定相反,故改为导入。
|
||
from app.api.kpis import REVERSE_INDICATORS
|
||
import json
|
||
import logging
|
||
|
||
logger = logging.getLogger("cma.dashboard")
|
||
|
||
router = APIRouter(prefix="/api/cma/dashboard", tags=["驾驶舱"],
|
||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||
)
|
||
|
||
# ── 本量利·保本点 卡片只读数据(cma-bep-kpi Phase2 2026-09-08)──
|
||
# 口径与 backend/scripts/kpi_bep_deriver.py 完全一致:
|
||
# 月销 = voucher_details subject 5001(主营业务收入) 贷方 YTD 月均(万元)
|
||
# 月管理费 = 5602% 借方 YTD 月均(账面口径含折旧)
|
||
# 折旧 = 560213 借方 YTD 月均
|
||
# 渠补率 = 厂家政策 84.61%(财务Bot权威口径)
|
||
# 保本额 = 月固定成本 ÷ (1-渠补率)
|
||
# 账面口径(含折旧) / 现金口径(剔除折旧) 双口径只读返回,无写操作。
|
||
# 现金口径数值优先读派生KPI(F_BEP_REVENUE_CASH/F_BEP_COVER_CASH)最新落库值,无派生值时回退现算。
|
||
BEP_CODES = ("F_BEP_REVENUE", "F_BEP_COVER", "F_BEP_GAP",
|
||
"F_BEP_REVENUE_CASH", "F_BEP_COVER_CASH")
|
||
BEP_BOOK_CODES = ("F_BEP_REVENUE", "F_BEP_COVER", "F_BEP_GAP") # 账面3条必须齐全才 available
|
||
BEP_SALES_SUBJECT = "5001"
|
||
BEP_ADMIN_PREFIX = "5602"
|
||
BEP_DEP_SUBJECT = "560213"
|
||
BEP_REBATE_POLICY_PCT = 84.61
|
||
|
||
|
||
def _bep_sum_field(db, entity_id: int, period_from: str, period_to: str,
|
||
field: str, subject_filter=None) -> float:
|
||
"""聚合 voucher_details 金额(元),subject_filter: ('eq', code) / ('like', prefix)
|
||
|
||
作废/撤单标记过滤(2026-09-14 收口): 已作废凭证不参与 BEP 均值。
|
||
"""
|
||
q = db.query(func.sum(getattr(VoucherDetail, field))).filter(
|
||
VoucherDetail.entity_id == entity_id,
|
||
VoucherDetail.carry_forward == 0,
|
||
VoucherDetail.period >= period_from,
|
||
VoucherDetail.period <= period_to,
|
||
)
|
||
if subject_filter:
|
||
kind, code = subject_filter
|
||
if kind == "eq":
|
||
q = q.filter(VoucherDetail.subject_code == code)
|
||
elif kind == "like":
|
||
q = q.filter(VoucherDetail.subject_code.like(code + "%"))
|
||
q = apply_voucher_guard(db, q, VoucherDetail)
|
||
val = q.scalar()
|
||
return float(val or 0.0)
|
||
|
||
|
||
def _bep_level(value, kpi) -> str:
|
||
"""按 KPI 静态阈值文本判定级别(绿→黄→红,命中即止),无阈值返回 none。
|
||
与 alert_generator / kpis._static_threshold_level 语义一致。"""
|
||
import re as _re
|
||
for level, field in (("green", "threshold_green"), ("yellow", "threshold_yellow"),
|
||
("red", "threshold_red")):
|
||
expr = getattr(kpi, field, None)
|
||
if not expr:
|
||
continue
|
||
m = _re.match(r"(>=|<=|>|<|=|!=)\s*([\d.]+)", str(expr).strip())
|
||
if not m:
|
||
continue
|
||
op, val = m.group(1), float(m.group(2))
|
||
hit = {
|
||
">=": value >= val, "<=": value <= val,
|
||
">": value > val, "<": value < val,
|
||
"=": value == val, "!=": value != val,
|
||
}[op]
|
||
if hit:
|
||
return level
|
||
return "none"
|
||
|
||
|
||
@router.get("/bep-card")
|
||
def get_bep_card(
|
||
db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id),
|
||
):
|
||
"""本量利·保本点 卡片数据(只读,A区)——账面/现金双口径"""
|
||
kpis = {k.kpi_code: k for k in db.query(KPIDefinition).filter(
|
||
KPIDefinition.entity_id == entity_id,
|
||
KPIDefinition.status == "active",
|
||
KPIDefinition.kpi_code.in_(BEP_CODES),
|
||
).all()}
|
||
missing_book = [c for c in BEP_BOOK_CODES if c not in kpis]
|
||
if missing_book:
|
||
return {"available": False, "message": "BEP账面口径派生KPI未完整注册",
|
||
"found": list(kpis.keys()), "missing": missing_book}
|
||
|
||
# 最新派生期间与值(同派生器:source_type='derived');现金2条缺失不阻塞整卡
|
||
latest = {}
|
||
for code in BEP_CODES:
|
||
if code not in kpis:
|
||
latest[code] = None
|
||
continue
|
||
v = db.query(KPIValue).filter(
|
||
KPIValue.kpi_id == kpis[code].id,
|
||
KPIValue.source_type == "derived",
|
||
KPIValue.actual_value.isnot(None),
|
||
).order_by(KPIValue.period.desc(), KPIValue.id.desc()).first()
|
||
latest[code] = {"period": v.period, "value": v.actual_value} if v else None
|
||
|
||
periods = [latest[c]["period"] for c in BEP_BOOK_CODES if latest[c]]
|
||
if not periods:
|
||
return {"available": False, "message": "尚无派生值,请先运行 kpi_bep_deriver.py"}
|
||
period = max(periods)
|
||
year = period[:4]
|
||
months = int(period[5:7])
|
||
period_from = f"{year}-01"
|
||
period_to = period
|
||
|
||
# 输入口径(万元 / %)——与派生器一致
|
||
sales_avg = _bep_sum_field(db, entity_id, period_from, period_to,
|
||
"credit_amount", ("eq", BEP_SALES_SUBJECT)) / 10000.0 / months
|
||
admin_avg = _bep_sum_field(db, entity_id, period_from, period_to,
|
||
"debit_amount", ("like", BEP_ADMIN_PREFIX)) / 10000.0 / months
|
||
dep_avg = _bep_sum_field(db, entity_id, period_from, period_to,
|
||
"debit_amount", ("eq", BEP_DEP_SUBJECT)) / 10000.0 / months
|
||
rebate_pct = BEP_REBATE_POLICY_PCT
|
||
cm_pct = 100.0 - rebate_pct
|
||
|
||
# 账面口径:管理费含折旧(与派生落库值一致)
|
||
book_bep = round(admin_avg / (cm_pct / 100.0), 1)
|
||
book_cover = round(sales_avg / book_bep * 100.0, 1) if book_bep else 0.0
|
||
book_gap = round((book_bep - sales_avg) / book_bep * 100.0, 1) if book_bep else 0.0
|
||
# 现金口径:管理费-折旧;优先读派生KPI最新落库值(单一事实源),缺失时回退现算
|
||
cash_admin = admin_avg - dep_avg
|
||
cash_derived_bep = latest["F_BEP_REVENUE_CASH"]["value"] if latest.get("F_BEP_REVENUE_CASH") else None
|
||
cash_derived_cover = latest["F_BEP_COVER_CASH"]["value"] if latest.get("F_BEP_COVER_CASH") else None
|
||
if cash_derived_bep is not None and cash_derived_cover is not None:
|
||
cash_bep = cash_derived_bep
|
||
cash_cover = cash_derived_cover
|
||
else:
|
||
cash_bep = round(cash_admin / (cm_pct / 100.0), 1) if cash_admin > 0 else 0.0
|
||
cash_cover = round(sales_avg / cash_bep * 100.0, 1) if cash_bep else 0.0
|
||
cash_gap = round((cash_bep - sales_avg) / cash_bep * 100.0, 1) if cash_bep else 0.0
|
||
|
||
# 回显最新落库派生值(账面3条+现金2条,存在时输出)
|
||
derived = {}
|
||
for code in BEP_CODES:
|
||
lv = latest[code]
|
||
derived[code] = {"period": lv["period"], "value": lv["value"]} if lv else None
|
||
|
||
cover_kpi = kpis["F_BEP_COVER"]
|
||
book_level = _bep_level(book_cover, cover_kpi)
|
||
cash_cover_kpi = kpis.get("F_BEP_COVER_CASH") or cover_kpi
|
||
cash_level = _bep_level(cash_cover, cash_cover_kpi)
|
||
gap_kpi = kpis["F_BEP_GAP"]
|
||
book_gap_level = _bep_level(book_gap, gap_kpi)
|
||
cash_gap_level = _bep_level(cash_gap, gap_kpi)
|
||
|
||
return {
|
||
"available": True,
|
||
"entity_id": entity_id,
|
||
"period": period,
|
||
"months": months,
|
||
"inputs": {
|
||
"sales_avg": round(sales_avg, 2),
|
||
"admin_avg": round(admin_avg, 2),
|
||
"dep_avg": round(dep_avg, 2),
|
||
"rebate_pct": rebate_pct,
|
||
"cm_pct": round(cm_pct, 2),
|
||
},
|
||
"book": {
|
||
"bep_revenue": book_bep,
|
||
"cover": book_cover,
|
||
"gap": book_gap,
|
||
"cover_level": book_level,
|
||
"gap_level": book_gap_level,
|
||
},
|
||
"cash": {
|
||
"admin_cash": round(cash_admin, 2),
|
||
"bep_revenue": cash_bep,
|
||
"cover": cash_cover,
|
||
"gap": cash_gap,
|
||
"cover_level": cash_level,
|
||
"gap_level": cash_gap_level,
|
||
},
|
||
"derived": derived,
|
||
"formula": "保本销售额 = 月固定成本 ÷ (1 − 渠补率84.61%)",
|
||
"readonly": True,
|
||
}
|
||
|
||
def parse_period(period_type: str, start_date: str = None, end_date: str = None):
|
||
"""解析时间区间"""
|
||
today = datetime.now()
|
||
if period_type == "month":
|
||
start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||
end = today
|
||
elif period_type == "quarter":
|
||
q = (today.month - 1) // 3
|
||
start = today.replace(month=q*3+1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||
end = today
|
||
elif period_type == "year":
|
||
start = today.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||
end = today
|
||
elif period_type == "custom" and start_date and end_date:
|
||
start = datetime.strptime(start_date, "%Y-%m-%d")
|
||
end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
|
||
else:
|
||
start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||
end = today
|
||
return start, end
|
||
|
||
def period_prefix(period_type: str):
|
||
"""生成SQL期间前缀匹配"""
|
||
if period_type == "month":
|
||
return datetime.now().strftime("%Y-%m")
|
||
elif period_type == "quarter":
|
||
now = datetime.now()
|
||
q = (now.month - 1) // 3
|
||
months = [f"{now.year}-{m:02d}" for m in range(q*3+1, q*3+4)]
|
||
return months
|
||
elif period_type == "year":
|
||
return str(datetime.now().year)
|
||
return None
|
||
|
||
def kpi_target_by_frequency(k):
|
||
"""按考核频率返回对应周期的目标值(多粒度改造)
|
||
monthly/weekly -> target_monthly; quarterly/half_year -> target_quarterly; yearly -> target_yearly
|
||
兼容: 对应列无值时回退 target_value
|
||
"""
|
||
freq = (k.frequency or "monthly").lower()
|
||
if freq in ("monthly", "weekly"):
|
||
val = getattr(k, "target_monthly", None)
|
||
elif freq in ("quarterly", "half_year"):
|
||
val = getattr(k, "target_quarterly", None)
|
||
elif freq == "yearly":
|
||
val = getattr(k, "target_yearly", None)
|
||
else:
|
||
val = None
|
||
if val is None:
|
||
val = k.target_value
|
||
return val
|
||
|
||
@router.get("/summary")
|
||
def get_dashboard_summary(role: str = Query("ceo"), period: str = Query("month"),
|
||
db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||
cache_key = f"summary:{role}:{period}:{entity_id}"
|
||
cached = cache_get("dashboard", cache_key)
|
||
if cached:
|
||
return cached
|
||
kpi_total = db.query(func.count(KPIDefinition.id)).filter(
|
||
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).scalar()
|
||
alert_count = db.query(func.count(KPIAlert.id)).filter(
|
||
KPIAlert.entity_id == entity_id,
|
||
KPIAlert.status == "pending",
|
||
).scalar()
|
||
dims = db.query(KPIDefinition.dimension, func.count(KPIDefinition.id)).filter(
|
||
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id
|
||
).group_by(KPIDefinition.dimension).all()
|
||
|
||
# 读取最近一次同步状态(从日志文件最后一行)
|
||
sync_status = {"last_sync": None, "status": "unknown", "detail": ""}
|
||
try:
|
||
with open("/var/log/cma-daily-sync.log", "r") as f:
|
||
lines = f.readlines()
|
||
# 从最后往前找包含 "完成" 或 "失败" 的行
|
||
for line in reversed(lines[-50:]):
|
||
if "全部完成" in line:
|
||
sync_status["status"] = "success"
|
||
sync_status["last_sync"] = line.strip()
|
||
break
|
||
elif "失败" in line or "ERROR" in line:
|
||
sync_status["status"] = "failed"
|
||
sync_status["last_sync"] = line.strip()
|
||
break
|
||
else:
|
||
# 没找到完成/失败标记,取最后一行
|
||
sync_status["last_sync"] = lines[-1].strip() if lines else None
|
||
except Exception as e:
|
||
sync_status["detail"] = str(e)
|
||
|
||
result = {
|
||
"kpi_total": kpi_total or 0, "alert_count": alert_count or 0,
|
||
"dimension_stats": [{"dimension": d[0], "count": d[1]} for d in dims],
|
||
"sync_status": sync_status,
|
||
}
|
||
cache_set("dashboard", cache_key, result, ttl_seconds=30)
|
||
return result
|
||
|
||
@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),
|
||
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")
|
||
|
||
kpis = db.query(KPIDefinition).filter(
|
||
KPIDefinition.status == "active",
|
||
KPIDefinition.entity_id == entity_id
|
||
).all()
|
||
result = []
|
||
|
||
for k in kpis:
|
||
base_query = db.query(KPIValue).filter(KPIValue.kpi_id == k.id)
|
||
|
||
if period == "month":
|
||
latest = base_query.filter(KPIValue.period == period_str).order_by(KPIValue.id.desc()).first()
|
||
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
|
||
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
|
||
elif period == "custom" and start_date and end_date:
|
||
periods = []
|
||
d = start
|
||
while d <= end:
|
||
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
|
||
else:
|
||
latest = base_query.order_by(KPIValue.period.desc()).first()
|
||
|
||
alert = db.query(KPIAlert).filter(
|
||
KPIAlert.kpi_id == k.id,
|
||
KPIAlert.status == "pending",
|
||
).order_by(KPIAlert.id.desc()).first()
|
||
|
||
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,
|
||
"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")}}
|
||
|
||
|
||
@router.get("/my-kpis")
|
||
def get_my_kpis(
|
||
current_user: User = Depends(require_auth),
|
||
period: str = Query("month"),
|
||
db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id),
|
||
):
|
||
"""获取当前用户负责的KPI
|
||
- business角色:只看自己负责的KPI
|
||
- 其他角色:看所有有预警的KPI
|
||
"""
|
||
role = current_user.role
|
||
username = current_user.username
|
||
name = current_user.name
|
||
period_str = datetime.now().strftime("%Y-%m")
|
||
|
||
kpis = db.query(KPIDefinition).filter(
|
||
KPIDefinition.status == "active",
|
||
KPIDefinition.entity_id == entity_id, # 账套隔离 2026-09-21
|
||
).all()
|
||
result = []
|
||
|
||
for k in kpis:
|
||
# business角色筛选
|
||
if role == "business":
|
||
responsible = (k.responsible_user or "").strip()
|
||
if responsible and responsible != username and responsible != name:
|
||
continue
|
||
|
||
latest = db.query(KPIValue).filter(
|
||
KPIValue.kpi_id == k.id,
|
||
KPIValue.period == period_str,
|
||
).order_by(KPIValue.id.desc()).first()
|
||
|
||
alert = db.query(KPIAlert).filter(
|
||
KPIAlert.kpi_id == k.id,
|
||
KPIAlert.status == "pending",
|
||
).order_by(KPIAlert.id.desc()).first()
|
||
|
||
trend_values = db.query(KPIValue).filter(
|
||
KPIValue.kpi_id == k.id,
|
||
).order_by(KPIValue.period.desc()).limit(6).all()
|
||
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(trend_values)]
|
||
|
||
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 period_str,
|
||
"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,
|
||
"frequency": k.frequency,
|
||
"responsible_dept": k.responsible_dept,
|
||
"responsible_user": k.responsible_user,
|
||
"trend": trend,
|
||
"threshold_green": k.threshold_green,
|
||
"threshold_yellow": k.threshold_yellow,
|
||
"threshold_red": k.threshold_red,
|
||
})
|
||
|
||
return {"data": result, "user_role": role, "user_name": name, "period": period_str}
|
||
|
||
|
||
@router.get("/finance-analysis")
|
||
def get_finance_analysis(
|
||
current_user: User = Depends(require_auth),
|
||
period: str = Query("month"),
|
||
db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id),
|
||
):
|
||
"""财务工作台分析数据"""
|
||
period_str = datetime.now().strftime("%Y-%m")
|
||
|
||
finance_kpis = db.query(KPIDefinition).filter(
|
||
KPIDefinition.status == "active",
|
||
KPIDefinition.dimension == "finance",
|
||
KPIDefinition.entity_id == entity_id,
|
||
).all()
|
||
|
||
kpi_data = []
|
||
for k in finance_kpis:
|
||
latest = db.query(KPIValue).filter(
|
||
KPIValue.kpi_id == k.id,
|
||
KPIValue.period == period_str,
|
||
).order_by(KPIValue.id.desc()).first()
|
||
|
||
trend_values = db.query(KPIValue).filter(
|
||
KPIValue.kpi_id == k.id,
|
||
).order_by(KPIValue.period.desc()).limit(6).all()
|
||
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(trend_values)]
|
||
|
||
alert = db.query(KPIAlert).filter(
|
||
KPIAlert.kpi_id == k.id,
|
||
KPIAlert.status == "pending",
|
||
).order_by(KPIAlert.id.desc()).first()
|
||
|
||
kpi_data.append({
|
||
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
|
||
"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,
|
||
"threshold_green": k.threshold_green,
|
||
"threshold_yellow": k.threshold_yellow,
|
||
"threshold_red": k.threshold_red,
|
||
"trend": trend,
|
||
"alert_level": alert.alert_level if alert else "none",
|
||
"frequency": k.frequency,
|
||
})
|
||
|
||
total_sales = next((k for k in kpi_data if k["kpi_code"] == "SALES_TOTAL"), None)
|
||
gross_profit = next((k for k in kpi_data if k["kpi_code"] == "SALES_PROFIT_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)
|
||
|
||
return {
|
||
"period": period_str,
|
||
"kpis": kpi_data,
|
||
"summary": {
|
||
"total_sales": total_sales["actual_value"] if total_sales else None,
|
||
"gross_profit_rate": gross_profit["actual_value"] if gross_profit else None,
|
||
"cost_control_rate": cost_control["actual_value"] if cost_control else None,
|
||
"receivable_turnover": receivable["actual_value"] if receivable else None,
|
||
}
|
||
}
|
||
|
||
|
||
@router.get("/predict")
|
||
def predict_kpis(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||
"""基于历史趋势预测下月KPI值(简单线性回归)"""
|
||
from datetime import datetime, timedelta
|
||
|
||
period_str = datetime.now().strftime("%Y-%m")
|
||
next_month = int(period_str[5:7]) + 1
|
||
next_year = int(period_str[:4])
|
||
if next_month > 12:
|
||
next_month = 1
|
||
next_year += 1
|
||
next_period = f"{next_year}-{next_month:02d}"
|
||
|
||
kpis = db.query(KPIDefinition).filter(
|
||
KPIDefinition.status == "active",
|
||
KPIDefinition.entity_id == entity_id
|
||
).all()
|
||
predictions = []
|
||
|
||
for k in kpis:
|
||
values = db.query(KPIValue).filter(
|
||
KPIValue.kpi_id == k.id,
|
||
).order_by(KPIValue.period.asc()).all()
|
||
|
||
# 需要至少3个数据点才能做预测
|
||
if len(values) < 3:
|
||
continue
|
||
|
||
# 简单线性回归: y = a + bx
|
||
points = [(i, v.actual_value) for i, v in enumerate(values) if v.actual_value is not None]
|
||
if len(points) < 3:
|
||
continue
|
||
|
||
n = len(points)
|
||
sum_x = sum(p[0] for p in points)
|
||
sum_y = sum(p[1] for p in points)
|
||
sum_xy = sum(p[0] * p[1] for p in points)
|
||
sum_xx = sum(p[0] ** 2 for p in points)
|
||
|
||
# 斜率 b = (n*sum_xy - sum_x*sum_y) / (n*sum_xx - sum_x*sum_x)
|
||
denom = n * sum_xx - sum_x * sum_x
|
||
if denom == 0:
|
||
continue
|
||
b = (n * sum_xy - sum_x * sum_y) / denom
|
||
a = (sum_y - b * sum_x) / n
|
||
|
||
# 预测下个月(x = n,因为最后一个索引是 n-1)
|
||
predicted_value = a + b * n
|
||
|
||
# 检查预测值是否触发阈值
|
||
# 统一走 app.utils.threshold_parser(阈值语法单一事实源,2026-09-16 B1 修复):
|
||
# 原实现手工切操作符 + float(),只识别单条件标量表达式;生产库存在多条件
|
||
# 表达式(如 `<=36|>=124`)/区间/负数 → float() 抛 ValueError → 端点 500。
|
||
# eval_threshold 对非法/空表达式返回 False(不致命),并支持 A~B、C1|C2、负数。
|
||
alert_level = "none"
|
||
if eval_threshold(predicted_value, k.threshold_red):
|
||
alert_level = "red"
|
||
elif eval_threshold(predicted_value, k.threshold_yellow):
|
||
alert_level = "yellow"
|
||
|
||
predictions.append({
|
||
"kpi_id": k.id,
|
||
"kpi_code": k.kpi_code,
|
||
"kpi_name": k.kpi_name,
|
||
"target_value": k.target_value,
|
||
"last_value": points[-1][1] if points else None,
|
||
"predicted_value": round(predicted_value, 2),
|
||
"predicted_period": next_period,
|
||
"alert_level": alert_level,
|
||
"trend": "up" if b > 0 else ("down" if b < 0 else "stable"),
|
||
"confidence": "high" if len(points) >= 6 else ("medium" if len(points) >= 4 else "low"),
|
||
"data_points": len(points),
|
||
})
|
||
|
||
return {
|
||
"current_period": period_str,
|
||
"next_period": next_period,
|
||
"predictions": predictions,
|
||
"kpi_count": len(kpis),
|
||
"predictable_count": len(predictions),
|
||
}
|
||
|
||
|
||
# ── 个人工作台 ──────────────────────────────
|
||
|
||
# KPIValue 取数口径(2026-09-18 工作台「我的KPI」期间缺口修正)
|
||
# 原实现(my_dashboard 内联)按 calculated_at 倒序取「最近写入」的一条:批量补录/复采会把
|
||
# **旧期间**的行写得更晚 → 卡片显示过期期间。实测 entity1:F_REVENUE/F_NET_PROFIT 的
|
||
# 2026-07 行写入时刻(2026-09-14 17:01:22, id=2128/2130)晚于 2026-08 行(17:00:30, id=2125/2127)
|
||
# → 取到 7 月;C_REBATE_RATE 1–4 月补录行 calculated_at 并列(2026-08-24 17:17:38) → 取到 4 月。
|
||
# 新口径:优先取「月期间 YYYY-MM」中最新的一期(月期是采集/录入的常规粒度);若该 KPI 只有
|
||
# H1/H2/FY 等汇总期行,则回退到原按 calculated_at 的取法(不改变无月期 KPI 的行为)。
|
||
# 注意:**不能**只按 period 字典序排 —— '2026-H1'/'2026-H2' 字典序大于 '2026-08',会把
|
||
# 季度汇总行/半年度预估值(forecast)当成"最新"。
|
||
_MONTH_PERIOD_REGEX = r"^[0-9]{4}-(0[1-9]|1[0-2])$"
|
||
|
||
|
||
def latest_kpi_value(db: Session, kpi_id: int) -> Optional[KPIValue]:
|
||
"""该 KPI 的最新可用值:月期间优先,无月期间行时回退 calculated_at 倒序。"""
|
||
monthly = (
|
||
db.query(KPIValue)
|
||
.filter(KPIValue.kpi_id == kpi_id, KPIValue.period.op("REGEXP")(_MONTH_PERIOD_REGEX))
|
||
.order_by(KPIValue.period.desc(), KPIValue.calculated_at.desc(), KPIValue.id.desc())
|
||
.first()
|
||
)
|
||
if monthly is not None:
|
||
return monthly
|
||
return (
|
||
db.query(KPIValue)
|
||
.filter(KPIValue.kpi_id == kpi_id)
|
||
.order_by(KPIValue.calculated_at.desc(), KPIValue.id.desc())
|
||
.first()
|
||
)
|
||
|
||
|
||
@router.get("/my-dashboard")
|
||
def my_dashboard(
|
||
current_user: User = Depends(require_auth),
|
||
db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id),
|
||
):
|
||
"""个人工作台:返回我的KPI、改善行动、待办提醒(账套隔离: 按token企业过滤)"""
|
||
username = current_user.username
|
||
name = current_user.name
|
||
role = current_user.role
|
||
|
||
# 角色预设KPI编码
|
||
ROLE_PRESET_KPIS = {
|
||
"ceo": ["F_REVENUE", "F_NET_PROFIT", "F_COST_RATIO", "C_REBATE_RATE", "P_DELIVERY", "F_FCF"],
|
||
"finance": ["F_REVENUE", "F_NET_PROFIT", "F_COST_RATIO", "F_OP_CFLOW", "F_ROE"],
|
||
"business": ["C_REBATE_RATE", "C_NEW_CLIENTS", "F_REVENUE"],
|
||
"it": [],
|
||
}
|
||
preset_codes = ROLE_PRESET_KPIS.get(role, [])
|
||
|
||
# 1. 我的KPI(responsible_user匹配用户名或姓名)+ 角色预设(均按企业隔离)
|
||
assigned_kpis = db.query(KPIDefinition).filter(
|
||
or_(
|
||
KPIDefinition.responsible_user == username,
|
||
KPIDefinition.responsible_user == name,
|
||
),
|
||
KPIDefinition.status == "active",
|
||
KPIDefinition.entity_id == entity_id,
|
||
).all()
|
||
assigned_ids = {k.id for k in assigned_kpis}
|
||
|
||
# 补充角色预设KPI(去重,按企业隔离)
|
||
preset_kpis = []
|
||
if preset_codes:
|
||
q = db.query(KPIDefinition).filter(
|
||
KPIDefinition.kpi_code.in_(preset_codes),
|
||
KPIDefinition.status == "active",
|
||
KPIDefinition.entity_id == entity_id,
|
||
)
|
||
if assigned_ids:
|
||
q = q.filter(~KPIDefinition.id.in_(assigned_ids))
|
||
preset_kpis = q.all()
|
||
|
||
all_kpis = assigned_kpis + preset_kpis
|
||
|
||
kpi_list = []
|
||
for k in all_kpis:
|
||
latest_v = latest_kpi_value(db, k.id)
|
||
|
||
actual = latest_v.actual_value if latest_v else None
|
||
target = kpi_target_by_frequency(k)
|
||
level = "gray"
|
||
if actual is not None and target:
|
||
# 反向指标(越低越好)集合 = 单一权威源 app.api.kpis.REVERSE_INDICATORS
|
||
# (2026-09-15 M2 准入2:删本文件原字面量副本,避免与能力评分引擎方向漂移)
|
||
if k.kpi_code in REVERSE_INDICATORS:
|
||
# 反向:实际≤目标=绿;实际≤目标*1.1=黄;否则红
|
||
level = "green" if actual <= target else (
|
||
"yellow" if actual <= target * 1.1 else "red")
|
||
else:
|
||
ratio = actual / target
|
||
level = "green" if ratio >= 0.9 else ("yellow" if ratio >= 0.7 else "red")
|
||
|
||
kpi_list.append({
|
||
"id": k.id,
|
||
"kpi_code": k.kpi_code,
|
||
"kpi_name": k.kpi_name,
|
||
"dimension": k.dimension,
|
||
"category": k.category,
|
||
"target_value": target,
|
||
"target_monthly": k.target_monthly,
|
||
"target_quarterly": k.target_quarterly,
|
||
"target_yearly": k.target_yearly,
|
||
"frequency": k.frequency,
|
||
"actual_value": actual,
|
||
"unit": k.unit,
|
||
"level": level,
|
||
"period": latest_v.period if latest_v else None,
|
||
})
|
||
|
||
# 2. 我的改善行动(assignee匹配;CEO/管理员看全部)
|
||
from app.models import ActionPlan
|
||
if current_user.role == "ceo":
|
||
# CEO/管理员查看全部行动方案(2026-08-26修复: 原逻辑只按assignee过滤导致工作台显示空)
|
||
# 2026-08-31 隔离补漏: CEO 看全部也须按 entity_id 过滤,防跨账套待办串数据
|
||
my_plans = db.query(ActionPlan).filter(
|
||
ActionPlan.entity_id == entity_id,
|
||
).order_by(ActionPlan.updated_at.desc()).all()
|
||
else:
|
||
my_plans = db.query(ActionPlan).filter(
|
||
or_(
|
||
ActionPlan.assignee == username,
|
||
ActionPlan.assignee == name,
|
||
),
|
||
ActionPlan.entity_id == entity_id,
|
||
).order_by(ActionPlan.updated_at.desc()).all()
|
||
|
||
plan_list = []
|
||
for p in my_plans:
|
||
overdue = False
|
||
if p.due_date and p.status not in ("completed", "cancelled"):
|
||
overdue = p.due_date < datetime.now()
|
||
kpi_name = ""
|
||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first()
|
||
if kpi:
|
||
kpi_name = kpi.kpi_name
|
||
|
||
plan_list.append({
|
||
"id": p.id,
|
||
"kpi_id": p.kpi_id,
|
||
"kpi_name": kpi_name,
|
||
"title": p.title,
|
||
"assignee": p.assignee,
|
||
"priority": p.priority,
|
||
"status": p.status,
|
||
"progress": p.progress or 0,
|
||
"due_date": p.due_date.isoformat() if p.due_date else None,
|
||
"overdue": overdue,
|
||
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||
})
|
||
|
||
# 3. 待办提醒
|
||
reminders = []
|
||
|
||
# 逾期行动
|
||
for p in plan_list:
|
||
if p["overdue"]:
|
||
reminders.append({
|
||
"type": "overdue_plan",
|
||
"severity": "danger",
|
||
"message": f"你负责的「{p['title']}」已逾期",
|
||
"related_id": p["id"],
|
||
"related_type": "action_plan",
|
||
})
|
||
|
||
# 红色预警KPI
|
||
for k in kpi_list:
|
||
if k["level"] == "red":
|
||
reminders.append({
|
||
"type": "red_kpi",
|
||
"severity": "danger",
|
||
"message": f"你负责的KPI「{k['kpi_name']}」处于红色预警",
|
||
"related_id": k["id"],
|
||
"related_type": "kpi",
|
||
})
|
||
|
||
# 黄色预警KPI
|
||
for k in kpi_list:
|
||
if k["level"] == "yellow":
|
||
reminders.append({
|
||
"type": "yellow_kpi",
|
||
"severity": "warning",
|
||
"message": f"你负责的KPI「{k['kpi_name']}」处于黄色预警",
|
||
"related_id": k["id"],
|
||
"related_type": "kpi",
|
||
})
|
||
|
||
return {
|
||
"kpis": kpi_list,
|
||
"action_plans": plan_list,
|
||
"reminders": reminders,
|
||
}
|