Files
cma-management/backend/app/api/dashboard.py
T
Hermes CI Fix 1f1385ebbf
ci/woodpecker/push/main Pipeline failed
feat(cvp): CM≤0 fail-closed 禁写0.0 + 博海BEP扩面(新口径) + 影子CVP标注is_mock + 前端NULL守卫/不适用
- kpi_bep_deriver.py: 实体口径注册表(app/utils/bep_caliber.py 单一事实源);博海(6001/6401/6601-03/1602);
  fail-closed② 边际贡献非正/固定成本退化 ⇒ 五条保本值 NULL+pending+remark'不适用'(禁回落0.0)
- dashboard.py /bep-card: 多实体口径 + not_applicable 态返回 label/reason
- predict.py /cvp-detailed: 显式 source=scenario_simulation + is_mock=true(影子CVP标注)
- 前端 BEPBreakEvenCard.vue: 全量数值判空 + '不适用'态;PredictDashboard.vue: 情景模拟·非实测标签

验收 accept-bep-cvp-20261003.py: BEFORE 2/11 → AFTER 11/11 PASS
任务 finance-20261003-cvp-fix-and-bohai-coverage-fullstack
2026-10-03 16:03:12 +08:00

1070 lines
49 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""驾驶舱 API v2 — 支持时间区间"""
from fastapi import APIRouter, Depends, Query, Request, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import case, 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
from app.utils.alert_entity_scope import month_period_clause
# BEP 实体口径单一事实源(2026-10-03 扩面:酣客 entity1 / 博海 entity2 同表驱动派生器与卡面)
from app.utils.bep_caliber import CLOSING_SUMMARY_LIKE, caliber_or_none
# 「实测区 / 预估区」分区口径单一事实源(财务裁定 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
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 月均(carry_forward=0 行域;账面口径含折旧)
# 折旧 = 560213 借−贷 净额 YTD 月均(同上;口径4 见 _decisions/cma-expense-profit-net-caliber-20261002.md)
# 渠补率 = **实测累计**(Σ571103借方 ÷ Σ5001贷方,YTD)——取自派生落库值 F_BEP_REBATE(单一事实源)
# 保本额 = 月固定成本 ÷ (1-渠补率)
# 2026-10-02 口径修订(任总 11:43 CST 放行「改,这样数据更加真实」):
# ① 渠补率不再用政策常量 84.61 参与计算,改用派生落库的实测累计值;
# 政策常量仅作对照返回(inputs.rebate_policy_pct),**不参与计算**;
# ② 账面/现金两条路径**统一读派生落库值**——此前 book_bep 实时复算 voucher、
# cash_bep 读派生值,9 月数据入库而派生器未跑时同卡两口径不同步;
# ③ fail-closed:六条派生值(含渠补率)**同期齐全**才出卡;缺数 ⇒ available=False,
# 不出保本值、不回落政策常量(不跨期回落,避免混期口径)。
# 账面口径(含折旧) / 现金口径(剔除折旧) 双口径只读返回,无写操作。
BEP_CODES = ("F_BEP_REVENUE", "F_BEP_COVER", "F_BEP_GAP",
"F_BEP_REVENUE_CASH", "F_BEP_COVER_CASH", "F_BEP_REBATE")
BEP_BOOK_CODES = ("F_BEP_REVENUE", "F_BEP_COVER", "F_BEP_GAP") # 账面3条必须齐全才 available
BEP_CASH_CODES = ("F_BEP_REVENUE_CASH", "F_BEP_COVER_CASH")
BEP_REBATE_CODE = "F_BEP_REBATE" # 变动/渠补率派生KPI编码(卡面单一事实源)
# 展示用输入口径常量(**仅酣客(entity1)**;多实体一律走 app/utils/bep_caliber.py 的 ENTITY_CALIBERS,
# 2026-10-03 扩面:博海 entity2 为 6001/6401/6601+6602+6603/1602)
BEP_SALES_SUBJECT = "5001"
BEP_ADMIN_PREFIX = "5602"
BEP_DEP_SUBJECT = "560213"
# 政策常量:仅作对照展示(2026-10-02 起不参与任何计算;博海无渠补科目 ⇒ None)
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, *, exclude_closing: bool = False,
subject_filters=None) -> float:
"""聚合 voucher_details 金额(元),subject_filter: ('eq', code) / ('like', prefix)
field="net" = 借−贷 净额(口径4:费用/损益一律净额)。
⚠️ 净额只在 carry_forward==0 行域成立(含结转腿时损益类净额恒 0)
—— 见 /srv/hermes/wisdom/_decisions/cma-expense-profit-net-caliber-20261002.md。
exclude_closing(2026-10-03):该账套结转腿未标 carry_forward 时(博海:全账套 cf 恒 0),
须按 summary NOT LIKE '%结转%' 显式剔除,否则已结账期间净额恒 0。
subject_filters:多条 OR(固定成本 6601+6602+6603)。
作废/撤单标记过滤(2026-09-14 收口): 已作废凭证不参与 BEP 均值。
"""
expr = (VoucherDetail.debit_amount - VoucherDetail.credit_amount
if field == "net" else getattr(VoucherDetail, field))
q = db.query(func.sum(expr)).filter(
VoucherDetail.entity_id == entity_id,
VoucherDetail.carry_forward == 0,
VoucherDetail.period >= period_from,
VoucherDetail.period <= period_to,
)
if exclude_closing:
q = q.filter(or_(VoucherDetail.summary.is_(None),
~VoucherDetail.summary.like(CLOSING_SUMMARY_LIKE)))
filt = subject_filter
if subject_filters:
clauses = []
for kind, code in subject_filters:
clauses.append(VoucherDetail.subject_code == code if kind == "eq"
else VoucherDetail.subject_code.like(code + "%"))
q = q.filter(or_(*clauses))
elif filt:
kind, code = filt
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_inputs(db, entity_id: int, cal: dict, period_from: str, period: str, months: int) -> dict:
"""卡面展示用输入(万元/百分比)——按实体口径现算,与派生器同源同式。"""
sales = _bep_sum_field(db, entity_id, period_from, period, cal["revenue_field"],
cal["revenue_filter"], exclude_closing=cal["exclude_closing"]) / 10000.0
admin = _bep_sum_field(db, entity_id, period_from, period, "net",
subject_filters=cal["fixed_filters"],
exclude_closing=cal["exclude_closing"]) / 10000.0
dep = _bep_sum_field(db, entity_id, period_from, period, cal["dep_field"],
cal["dep_filter"], exclude_closing=cal["exclude_closing"]) / 10000.0
return {
"sales_avg": round(sales / months, 2),
"admin_avg": round(admin / months, 2),
"dep_avg": round(dep / months, 2),
"cash_admin_avg": round((admin - dep) / months, 2),
}
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区)——账面/现金双口径
2026-10-02 口径修订(任总 11:43 CST 放行):
· 渠补率 = 派生落库的**实测累计值**(F_BEP_REBATE,单一事实源);政策常量仅对照返回;
· 账面 / 现金两条路径**统一读派生落库值**(不再实时复算 voucher);
· fail-closed:六条派生值同期不齐(尤其缺渠补率)⇒ available=False,不出保本值、不回落常量。
"""
cal = caliber_or_none(entity_id)
if cal is None:
return {"available": False, "entity_id": entity_id,
"message": f"entity={entity_id} 未注册 BEP 口径(见 app/utils/bep_caliber.py)",
"rebate_policy_pct": BEP_REBATE_POLICY_PCT, "readonly": True}
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')——按 code × period 建表,period desc 排序
# ⚠️ 不过滤 actual_value IS NULL:fail-closed 落库的 pending 期(值 NULL)必须参与「最新期」判定,
# 否则卡面会悄悄跨期回落到上一期,掩盖「本期不出值」的事实。
rows = db.query(KPIValue, KPIDefinition.kpi_code).join(
KPIDefinition, KPIDefinition.id == KPIValue.kpi_id,
).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code.in_(BEP_CODES),
KPIValue.source_type == "derived",
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc(), KPIValue.id.desc()).all()
by_code = {} # {code: {period: value|None}}
remark_map = {} # {(code, period): remark} —— 用于区分「不适用」与「缺数」
for v, code in rows:
by_code.setdefault(code, {}).setdefault(v.period, v.actual_value)
remark_map.setdefault((code, v.period), v.remark or "")
# 卡面期 = 账面三条派生行的**最新期间**(与派生器目标期一致)
book_periods = sorted({p for c in BEP_BOOK_CODES for p in by_code.get(c, {})}, reverse=True)
if not book_periods:
return {"available": False, "entity_id": entity_id,
"message": "尚无派生值,请先运行 kpi_bep_deriver.py",
"rebate_policy_pct": BEP_REBATE_POLICY_PCT, "readonly": True}
period = book_periods[0]
year = period[:4]
months = int(period[5:7])
period_from = f"{year}-01"
# fail-closed:六条派生值(含变动成本率)本期必须齐全,缺一条即不出保本值
missing_at_period = [c for c in BEP_CODES if by_code.get(c, {}).get(period) is None]
if missing_at_period:
# ① 不适用(派生器 fail-closed 落库:remark 已注明「保本点不适用」)
# —— 与「缺数」区分:卡面显示「不适用」而非「数据不齐」,且不得显示 0。
na_remarks = [remark_map.get((c, period), "") for c in missing_at_period]
na_hit = [r for r in na_remarks if "不适用" in r]
if na_hit and BEP_REBATE_CODE not in missing_at_period:
reason = na_hit[0]
label = ("边际贡献为负 · 保本点不适用" if "边际贡献非正" in reason
else "固定成本退化 · 保本点不适用")
return {"available": False, "not_applicable": True, "entity_id": entity_id,
"period": period, "months": months,
"label": label, "reason": reason,
"rebate_pct": by_code.get(BEP_REBATE_CODE, {}).get(period),
"rebate_policy_pct": (cal.get("policy_pct")
if cal.get("policy_pct") is not None else BEP_REBATE_POLICY_PCT),
"entity_name": cal["name"],
"missing_codes": missing_at_period,
"derived_periods": book_periods[:6],
"data_source": "derived", "readonly": True}
# ② 缺数(真不齐)
if BEP_REBATE_CODE in missing_at_period:
msg = (f"{cal['margin_label']}不可得:本期未落 F_BEP_REBATE 派生值 ⇒ 不出保本值"
"(fail-closed,不回落政策常量、不跨期回落)")
else:
msg = ("本期派生值不齐 %s ⇒ 不出保本值(fail-closed,不跨期回落)"
% ",".join(missing_at_period))
return {"available": False, "entity_id": entity_id, "period": period,
"message": msg, "missing_codes": missing_at_period,
"rebate_policy_pct": (cal.get("policy_pct") if cal.get("policy_pct") is not None
else BEP_REBATE_POLICY_PCT),
"derived_periods": book_periods[:6], "readonly": True}
# 六条派生落库值(同期)—— 账面 / 现金两条路径同一来源,卡面不做实时复算
d = {c: by_code[c][period] for c in BEP_CODES}
book_bep = float(d["F_BEP_REVENUE"])
book_cover = float(d["F_BEP_COVER"])
book_gap = float(d["F_BEP_GAP"])
cash_bep = float(d["F_BEP_REVENUE_CASH"])
cash_cover = float(d["F_BEP_COVER_CASH"])
cash_gap = round(100.0 - cash_cover, 1) # 缺口 = 100 − 覆盖率(与派生器同口径)
rebate_pct = float(d[BEP_REBATE_CODE]) # 实测累计渠补率(单一事实源)
cm_pct = round(100.0 - rebate_pct, 2)
# 输入口径(万元,展示用;数值口径一律以派生落库值为准)——按实体口径现算
inp = _bep_inputs(db, entity_id, cal, period_from, period, months)
sales_avg = inp["sales_avg"]
admin_avg = inp["admin_avg"]
dep_avg = inp["dep_avg"]
cash_admin = inp["cash_admin_avg"]
policy_pct = cal.get("policy_pct") # 博海无渠补政策常量 ⇒ None(前端不显示对照)
# 回显六条派生落库值(同期)
derived = {c: {"period": period, "value": d[c]} for c in BEP_CODES}
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,
"rebate_policy_pct": BEP_REBATE_POLICY_PCT, # 政策常量:仅作对照,不参与计算
"rebate_source": "derived:F_BEP_REBATE(Σ571103借方÷Σ5001贷方, YTD累计)",
"cm_pct": cm_pct,
},
"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,
"data_source": "derived", # 账面/现金同源:均读派生落库值
"formula": f"保本销售额 = 月固定成本 ÷ (1 − 实测渠补率{rebate_pct:.2f}%)",
"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
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")
kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
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 = 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")
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":
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
while d <= end:
periods.append(d.strftime("%Y-%m"))
d += timedelta(days=32)
d = d.replace(day=1)
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 = 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,
})
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),
):
"""获取当前用户负责的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 = []
forecast_section = [] # 预估区(单列,不进 actual_value / trend)
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,
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",
).order_by(KPIAlert.id.desc()).first()
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)]
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,
"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,
"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,
})
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),
):
"""财务工作台分析数据"""
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 = []
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)]
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,
"forecast_value": forecast_value,
"is_forecast": False,
"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)
resp = {
"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,
}
}
if include_forecast:
resp["forecast"] = forecast_section
return resp
@router.get("/predict")
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")
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 = []
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
# 简单线性回归: 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),
})
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
# ── 个人工作台 ──────────────────────────────
# 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)当成"最新"。
# 期间口径(R3 正则 / SQL 等价式)已于 2026-09-26 收口到 app/utils/alert_entity_scope.py
# (is_month_period / month_period_clause),此处不再保留第二份副本。
# 主值优先(2026-09-18 同期间重复行判据收口,项目单 project-20260918-kpi-row-priority)
# 同一期间可能并存两行:①主值(source_type='ledger' / data_status='verified',结账/复核口径)
# ②自动归集重复行(source_type='auto_collect',备注自述「与主值为同一事实重复行」)。
# 原判据仅看写入时刻:重复行由复采批量写入、calculated_at 更晚 ⇒ 会被选中(实测 entity1
# 2026-08 的 F_REVENUE/F_NET_PROFIT:2136/2127 auto_collect 写于 09-14,晚于主值 1822/2102)。
# 今日两行数值一致(Δ=0)故用户不可见;一旦重复行与主值漂移,卡片会静默显示重复行的值。
# 故在**同一期间内**加「主值优先」:verified(已复核)> ledger(账面主值)> 其余。
# 注意:跨期间仍以 period 倒序为第一判据(不改变 d33b5e1 的期间口径——当期只有未复核行时,
# 不会回退到上一期已复核行)。
_MAIN_VALUE_PRIORITY = case(
(KPIValue.data_status == "verified", 0),
(KPIValue.source_type == "ledger", 1),
else_=2,
)
def latest_kpi_value(db: Session, kpi_id: int) -> Optional[KPIValue]:
"""该 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),
actual_only_clause())
.order_by(
KPIValue.period.desc(),
_MAIN_VALUE_PRIORITY,
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, actual_only_clause())
.order_by(KPIValue.calculated_at.desc(), KPIValue.id.desc())
.first()
)
@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),
):
"""个人工作台:返回我的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 = []
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"
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,
"forecast_value": forecast_value,
"is_forecast": is_forecast_row(latest_v),
"period_type": "actual",
})
# 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",
})
resp = {
"kpis": kpi_list,
"action_plans": plan_list,
"reminders": reminders,
}
if include_forecast:
resp["forecast"] = forecast_section
return resp