feat(cvp): CM≤0 fail-closed 禁写0.0 + 博海BEP扩面(新口径) + 影子CVP标注is_mock + 前端NULL守卫/不适用
ci/woodpecker/push/main Pipeline failed
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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user