feat(dashboard): 经营看板「本量利·保本点」卡片 + 只读BEP概览端点 (cma-bep-kpi Phase2)
- 新增 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/财务视图顶部引入卡片
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<div v-if="card" class="bep-card-wrap">
|
||||
<div class="bep-card" :class="'lvl-' + level" @click="expanded = !expanded">
|
||||
<div class="bep-head">
|
||||
<div class="bep-title">
|
||||
<span class="bep-icon">⚖️</span>
|
||||
<span>本量利 · 保本点</span>
|
||||
</div>
|
||||
<div class="bep-meta">
|
||||
<span class="bep-period">{{ fmtPeriod(card.period) }}</span>
|
||||
<span class="bep-expand-hint">{{ expanded ? '收起 ▲' : '下钻 ▼' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bep-body">
|
||||
<!-- 主指标:保本覆盖率 -->
|
||||
<div class="bep-main">
|
||||
<div class="bep-cover">
|
||||
<span class="dot" :class="'dot-' + level"></span>
|
||||
<span class="bep-cover-num">{{ current.cover.toFixed(1) }}</span>
|
||||
<span class="bep-cover-unit">%</span>
|
||||
</div>
|
||||
<div class="bep-cover-label">保本覆盖率<span class="lvl-text">{{ lvlText }}</span></div>
|
||||
</div>
|
||||
<!-- 副指标并排 -->
|
||||
<div class="bep-sub-grid">
|
||||
<div class="bep-sub">
|
||||
<div class="sub-val">{{ current.bep_revenue.toFixed(1) }}</div>
|
||||
<div class="sub-label">月保本销售额(万)</div>
|
||||
</div>
|
||||
<div class="bep-sub">
|
||||
<div class="sub-val">{{ inputs.sales_avg.toFixed(1) }}</div>
|
||||
<div class="sub-label">当前月销(万)</div>
|
||||
</div>
|
||||
<div class="bep-sub">
|
||||
<div class="sub-val" :style="{ color: gapColor }">{{ current.gap.toFixed(1) }}%</div>
|
||||
<div class="sub-label">缺口</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 辅助行 -->
|
||||
<div class="bep-formula-line">
|
||||
固定成本 {{ current.admin_avg.toFixed(1) }}万 ÷ 边际贡献率 {{ inputs.cm_pct.toFixed(2) }}%
|
||||
<span v-if="mode === 'cash'" class="tag-cash">现金口径</span>
|
||||
<span v-else class="tag-book">账面口径</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 下钻面板 -->
|
||||
<div v-if="expanded" class="bep-detail">
|
||||
<!-- 口径切换 -->
|
||||
<div class="detail-mode">
|
||||
<el-radio-group v-model="mode" size="small">
|
||||
<el-radio-button value="book">账面口径</el-radio-button>
|
||||
<el-radio-button value="cash">现金口径</el-radio-button>
|
||||
</el-radio-group>
|
||||
<span class="mode-note">{{ modeNote }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-grid">
|
||||
<div class="detail-col">
|
||||
<div class="detail-title">📐 公式</div>
|
||||
<div class="detail-line">{{ card.formula }}</div>
|
||||
<div class="detail-line sub">账面:{{ inputs.admin_avg.toFixed(1) }}万 ÷ {{ inputs.cm_pct.toFixed(2) }}% = {{ current.bep_revenue.toFixed(1) }}万</div>
|
||||
<div v-if="mode === 'cash'" class="detail-line sub">
|
||||
现金:管理费{{ inputs.admin_avg.toFixed(1) }} − 折旧{{ inputs.dep_avg.toFixed(1) }} = {{ current.admin_avg.toFixed(1) }}万 ÷ {{ inputs.cm_pct.toFixed(2) }}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-col">
|
||||
<div class="detail-title">🔍 解读</div>
|
||||
<div class="detail-line">{{ insight }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-kpis">
|
||||
<div class="detail-title">📊 输入KPI</div>
|
||||
<div class="kpi-chips">
|
||||
<span class="kpi-chip">月管理费 <b>{{ inputs.admin_avg.toFixed(1) }}</b>万</span>
|
||||
<span class="kpi-chip">月收入(5001) <b>{{ inputs.sales_avg.toFixed(1) }}</b>万</span>
|
||||
<span class="kpi-chip">渠补率 <b>{{ inputs.rebate_pct.toFixed(2) }}</b>%</span>
|
||||
<span class="kpi-chip">折旧 <b>{{ inputs.dep_avg.toFixed(1) }}</b>万/月</span>
|
||||
</div>
|
||||
<div class="kpi-src-note">数据源:voucher_details 凭证聚合(YTD月均口径)· {{ card.period }} · 纯只读</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import api from '../api/index'
|
||||
|
||||
const card = ref<any>(null)
|
||||
const expanded = ref(false)
|
||||
const mode = ref<'book' | 'cash'>('book')
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const r: any = await api.get('/dashboard/bep-card')
|
||||
card.value = (r && (r.data || r)) || null
|
||||
if (!card.value?.available) card.value = null
|
||||
} catch (e) {
|
||||
console.error('加载保本点卡片失败', e)
|
||||
card.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const inputs = computed(() => card.value?.inputs || { sales_avg: 0, admin_avg: 0, dep_avg: 0, rebate_pct: 0, cm_pct: 0 })
|
||||
const current = computed(() => {
|
||||
const d = card.value?.[mode.value === 'cash' ? 'cash' : 'book'] || { bep_revenue: 0, cover: 0, gap: 0, admin_avg: 0 }
|
||||
return {
|
||||
...d,
|
||||
admin_avg: mode.value === 'cash' ? (card.value?.cash?.admin_cash ?? 0) : inputs.value.admin_avg,
|
||||
}
|
||||
})
|
||||
const level = computed(() => current.value.cover_level || 'none')
|
||||
const lvlText = computed(() => ({ green: '已保本', yellow: '未达保本', red: '远低保本' } as any)[level.value] || '—')
|
||||
const gapColor = computed(() => (current.value.gap <= 0 ? '#67c23a' : current.value.gap <= 50 ? '#e6a23c' : '#f56c6c'))
|
||||
const modeNote = computed(() =>
|
||||
mode.value === 'cash'
|
||||
? '固定成本=管理费−折旧(现金视角)'
|
||||
: '固定成本=管理费(含折旧,账面视角)'
|
||||
)
|
||||
|
||||
const insight = computed(() => {
|
||||
const sales = inputs.value.sales_avg || 0
|
||||
const bep = current.value.bep_revenue || 0
|
||||
const gap = current.value.gap || 0
|
||||
if (!bep) return '暂无数据'
|
||||
const need = bep - sales
|
||||
const pct = sales > 0 ? (need / sales) * 100 : 0
|
||||
if (gap <= 0) return `当前月销 ${sales.toFixed(1)}万 已超过保本线 ${bep.toFixed(1)}万,实现保本(余量 ${(-gap).toFixed(1)}%)。`
|
||||
return `当前月销距保本差 ${gap.toFixed(1)}%,月销需达 ${bep.toFixed(1)}万(再增 ${need.toFixed(1)}万 / +${pct.toFixed(0)}%)。`
|
||||
})
|
||||
|
||||
function fmtPeriod(p: string): string {
|
||||
if (!p) return ''
|
||||
const m = /^(\d{4})-(\d{1,2})/.exec(p)
|
||||
if (!m) return p
|
||||
const now = new Date()
|
||||
return (Number(m[1]) === now.getFullYear() ? '' : m[1] + '年') + Number(m[2]) + '月'
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bep-card-wrap { margin-bottom: 18px; }
|
||||
.bep-card {
|
||||
background: #fff; border-radius: 12px; padding: 16px 20px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
|
||||
border-left: 4px solid #e6a23c; cursor: pointer; transition: box-shadow .15s, transform .15s;
|
||||
}
|
||||
.bep-card.lvl-green { border-left-color: #67c23a; }
|
||||
.bep-card.lvl-yellow { border-left-color: #e6a23c; }
|
||||
.bep-card.lvl-red { border-left-color: #f56c6c; }
|
||||
.bep-card:hover { box-shadow: 0 4px 14px rgba(0,0,0,0.1); transform: translateY(-1px); }
|
||||
.bep-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.bep-title { font-size: 15px; font-weight: 600; color: #1a1a2e; display: flex; align-items: center; gap: 8px; }
|
||||
.bep-icon { font-size: 17px; }
|
||||
.bep-meta { display: flex; align-items: center; gap: 10px; font-size: 12px; color: #999; }
|
||||
.bep-expand-hint { color: #409eff; }
|
||||
.bep-body { display: flex; align-items: center; gap: 26px; flex-wrap: wrap; }
|
||||
.bep-main { display: flex; align-items: center; gap: 10px; min-width: 150px; }
|
||||
.bep-cover { display: flex; align-items: baseline; gap: 4px; }
|
||||
.bep-cover-num { font-size: 42px; font-weight: 800; color: #1a1a2e; letter-spacing: -1px; line-height: 1; }
|
||||
.bep-cover-unit { font-size: 15px; color: #888; }
|
||||
.dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
|
||||
.dot-green { background: #67c23a; } .dot-yellow { background: #e6a23c; } .dot-red { background: #f56c6c; } .dot-none { background: #ccc; }
|
||||
.bep-cover-label { font-size: 12px; color: #888; display: flex; flex-direction: column; gap: 3px; }
|
||||
.lvl-text { color: #e6a23c; font-weight: 600; }
|
||||
.lvl-green .lvl-text { color: #67c23a; } .lvl-red .lvl-text { color: #f56c6c; }
|
||||
.bep-sub-grid { display: flex; gap: 22px; }
|
||||
.bep-sub { text-align: left; }
|
||||
.sub-val { font-size: 19px; font-weight: 700; color: #1a1a2e; }
|
||||
.sub-label { font-size: 11px; color: #999; margin-top: 2px; }
|
||||
.bep-formula-line { margin-left: auto; font-size: 12px; color: #777; background: #f7f8fa; padding: 5px 10px; border-radius: 6px; }
|
||||
.tag-cash { color: #67c23a; margin-left: 4px; font-weight: 600; }
|
||||
.tag-book { color: #409eff; margin-left: 4px; font-weight: 600; }
|
||||
|
||||
/* 下钻 */
|
||||
.bep-detail { background: #fafbfc; border: 1px solid #eee; border-radius: 0 0 12px 12px; padding: 14px 18px; margin-top: 2px; }
|
||||
.detail-mode { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
|
||||
.mode-note { font-size: 12px; color: #999; }
|
||||
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 12px; }
|
||||
.detail-col { background: #fff; border: 1px solid #f0f0f0; border-radius: 8px; padding: 10px 12px; }
|
||||
.detail-title { font-size: 13px; font-weight: 600; color: #1a1a2e; margin-bottom: 6px; }
|
||||
.detail-line { font-size: 13px; color: #444; line-height: 1.7; }
|
||||
.detail-line.sub { font-size: 12px; color: #888; }
|
||||
.detail-kpis { background: #fff; border: 1px solid #f0f0f0; border-radius: 8px; padding: 10px 12px; }
|
||||
.kpi-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
|
||||
.kpi-chip { font-size: 12px; background: #f0f7ff; color: #3a6ea5; padding: 4px 10px; border-radius: 12px; }
|
||||
.kpi-chip b { color: #1a1a2e; }
|
||||
.kpi-src-note { font-size: 11px; color: #bbb; margin-top: 8px; }
|
||||
@media (max-width: 900px) { .detail-grid { grid-template-columns: 1fr; } .bep-formula-line { margin-left: 0; } }
|
||||
</style>
|
||||
@@ -42,6 +42,8 @@
|
||||
|
||||
<!-- ====== CEO视图 ====== -->
|
||||
<template v-if="userRole === 'ceo'">
|
||||
<!-- 本量利·保本点卡片(Phase2,A区只读) -->
|
||||
<BEPBreakEvenCard />
|
||||
<!-- 摘要卡片行 -->
|
||||
<div class="summary-row">
|
||||
<div class="stat-card blue" @click="$router.push('/kpis')" style="cursor:pointer;"><div class="stat-val"><CountUp :value="summary.kpi_total" /></div><div class="stat-label">KPI总数</div><div class="stat-sub click-hint">点击查看详情</div></div>
|
||||
@@ -157,6 +159,8 @@
|
||||
|
||||
<!-- ====== 财务视图 ====== -->
|
||||
<template v-if="userRole === 'finance'">
|
||||
<!-- 本量利·保本点卡片(Phase2,A区只读) -->
|
||||
<BEPBreakEvenCard />
|
||||
<div class="summary-row">
|
||||
<div class="stat-card blue" @click="$router.push('/deviations')" style="cursor:pointer;"><div class="stat-val">{{ finSummary.total_sales ? fmtValue(finSummary.total_sales, 'SALES_TOTAL') : '-' }}</div><div class="stat-label">本月销售额</div></div>
|
||||
<div class="stat-card" :class="(finSummary.gross_profit_rate ?? 0) < 0 ? 'red' : 'green'" @click="$router.push('/deviations')" style="cursor:pointer;">
|
||||
@@ -271,6 +275,7 @@ import { dashboardApi, alertApi, aiSuggestionApi } from '../api/index'
|
||||
import axios from 'axios'
|
||||
import CountUp from '../components/CountUp.vue'
|
||||
import GrowthQuality from '../components/GrowthQuality.vue'
|
||||
import BEPBreakEvenCard from '../components/BEPBreakEvenCard.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user