diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py index f06f38dc..eb8b2996 100644 --- a/backend/app/api/dashboard.py +++ b/backend/app/api/dashboard.py @@ -7,7 +7,7 @@ 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 +from app.models import KPIDefinition, KPIValue, KPIAlert, User, VoucherDetail from app.utils.cache import get as cache_get, set as cache_set import json import logging @@ -18,6 +18,161 @@ 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-渠补率) +# 账面口径(含折旧) / 现金口径(剔除折旧) 双口径只读返回,无写操作。 +BEP_CODES = ("F_BEP_REVENUE", "F_BEP_COVER", "F_BEP_GAP") +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)""" + 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 + "%")) + 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()} + if len(kpis) != 3: + return {"available": False, "message": "BEP派生KPI未完整注册", "found": list(kpis.keys())} + + # 最新派生期间与值(同派生器:source_type='derived') + latest = {} + for code in BEP_CODES: + 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_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 + # 现金口径:管理费-折旧 + cash_admin = admin_avg - dep_avg + 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 + + # 参考最新落库派生值(存在时使用,保证与 Phase1 KPI 一致) + 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_level = _bep_level(cash_cover, 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() diff --git a/frontend/src/components/BEPBreakEvenCard.vue b/frontend/src/components/BEPBreakEvenCard.vue new file mode 100644 index 00000000..02b25830 --- /dev/null +++ b/frontend/src/components/BEPBreakEvenCard.vue @@ -0,0 +1,196 @@ + + + + + diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index e244876f..90b498b6 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -42,6 +42,8 @@