From 9eb38cfa890afcb39f6dd9e44c7b0eb1555ec50b Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Tue, 8 Sep 2026 13:58:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20=E7=BB=8F=E8=90=A5=E7=9C=8B?= =?UTF-8?q?=E6=9D=BF=E3=80=8C=E6=9C=AC=E9=87=8F=E5=88=A9=C2=B7=E4=BF=9D?= =?UTF-8?q?=E6=9C=AC=E7=82=B9=E3=80=8D=E5=8D=A1=E7=89=87=20+=20=E5=8F=AA?= =?UTF-8?q?=E8=AF=BBBEP=E6=A6=82=E8=A7=88=E7=AB=AF=E7=82=B9=20(cma-bep-kpi?= =?UTF-8?q?=20Phase2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 GET /api/cma/dashboard/bep-card:账面/现金双口径只读数据 (5001月销113.6/5602管理费26.1/折旧560213 7.51/渠补率84.61%) 账面: 保本169.9万 覆盖率66.9%(黄) 缺口33.1%; 现金: 保本121.0万 覆盖率93.9% - 新增 components/BEPBreakEvenCard.vue:主指标覆盖率红黄绿圆点+副指标 并排+下钻面板(公式/输入KPI/双口径切换/解读),A区只读 - Dashboard.vue CEO/财务视图顶部引入卡片 --- backend/app/api/dashboard.py | 157 ++++++++++++++- frontend/src/components/BEPBreakEvenCard.vue | 196 +++++++++++++++++++ frontend/src/views/Dashboard.vue | 5 + 3 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/BEPBreakEvenCard.vue 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 @@