feat(cvp): CM≤0 fail-closed 禁写0.0 + 博海BEP扩面(新口径) + 影子CVP标注is_mock + 前端NULL守卫/不适用
ci/woodpecker/push/main Pipeline failed

- 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
This commit is contained in:
Hermes CI Fix
2026-10-03 16:03:12 +08:00
parent 8631ce718f
commit 1f1385ebbf
7 changed files with 662 additions and 147 deletions
+83 -21
View File
@@ -12,6 +12,8 @@ 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)单列返回。
@@ -47,21 +49,27 @@ 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编码(卡面单一事实源)
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 起不参与任何计算)
# 政策常量:仅作对照展示(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) -> float:
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
@@ -72,8 +80,18 @@ def _bep_sum_field(db, entity_id: int, period_from: str, period_to: str,
VoucherDetail.period >= period_from,
VoucherDetail.period <= period_to,
)
if subject_filter:
kind, code = subject_filter
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":
@@ -83,6 +101,24 @@ def _bep_sum_field(db, entity_id: int, period_from: str, period_to: str,
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 语义一致。"""
@@ -118,6 +154,12 @@ def get_bep_card(
· 账面 / 现金两条路径**统一读派生落库值**(不再实时复算 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",
@@ -140,9 +182,11 @@ def get_bep_card(
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc(), KPIValue.id.desc()).all()
by_code = {} # {code: {period: value|None}}
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)
@@ -151,25 +195,44 @@ def get_bep_card(
"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:六条派生值(含实测渠补率)本期必须齐全,缺一条即不出保本值
# 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_BEP_REBATE 派生值 ⇒ 不出保本值"
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": BEP_REBATE_POLICY_PCT,
"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}
year = period[:4]
months = int(period[5:7])
period_from = f"{year}-01"
# 六条派生落库值(同期)—— 账面 / 现金两条路径同一来源,卡面不做实时复算
d = {c: by_code[c][period] for c in BEP_CODES}
book_bep = float(d["F_BEP_REVENUE"])
@@ -181,14 +244,13 @@ def get_bep_card(
rebate_pct = float(d[BEP_REBATE_CODE]) # 实测累计渠补率(单一事实源)
cm_pct = round(100.0 - rebate_pct, 2)
# 输入口径(万元,展示用;数值口径一律以派生落库值为准)
sales_avg = _bep_sum_field(db, entity_id, period_from, period,
"credit_amount", ("eq", BEP_SALES_SUBJECT)) / 10000.0 / months
admin_avg = _bep_sum_field(db, entity_id, period_from, period,
"net", ("like", BEP_ADMIN_PREFIX)) / 10000.0 / months
dep_avg = _bep_sum_field(db, entity_id, period_from, period,
"net", ("eq", BEP_DEP_SUBJECT)) / 10000.0 / months
cash_admin = admin_avg - dep_avg
# 输入口径(万元,展示用;数值口径一律以派生落库值为准)——按实体口径现算
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}