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}
|
||||
|
||||
@@ -114,7 +114,12 @@ def api_scenario_analysis(data: dict):
|
||||
|
||||
@router.post("/cvp-detailed")
|
||||
def api_cvp_detailed(data: dict):
|
||||
"""CVP本量利详细分析 — 含改善方案推演和保本图数据 (CMA P2)"""
|
||||
"""CVP本量利详细分析 — 含改善方案推演和保本图数据 (CMA P2)
|
||||
|
||||
⚠️ 情景模拟器(scenario simulation):入参为**假设值**推演(前端滑杆),
|
||||
非 CMA 实测口径。真实实测口径见派生 KPI `F_BEP_*`(kpi_bep_deriver.py,单一事实源);
|
||||
响应显式带 source='scenario_simulation' + is_mock=True,前端据此标注「情景模拟·非实测」。
|
||||
"""
|
||||
try:
|
||||
fixed_cost = float(data.get("fixed_cost", 617))
|
||||
variable_cost_rate = float(data.get("variable_cost_rate", 0.4862))
|
||||
@@ -151,6 +156,9 @@ def api_cvp_detailed(data: dict):
|
||||
chart_data.append({"volume": vol, "revenue": rev, "total_cost": tc, "profit": round(rev - tc, 2)})
|
||||
|
||||
return {
|
||||
"source": "scenario_simulation",
|
||||
"is_mock": True,
|
||||
"basis": "user_input_scenario",
|
||||
"fixed_cost": fixed_cost,
|
||||
"variable_cost_rate": round(variable_cost_rate * 100, 2),
|
||||
"unit_price": unit_price,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""BEP(本量利·保本点)实体口径注册表 —— **单一事实源**。
|
||||
|
||||
被两侧共用,避免口径漂移:
|
||||
· 派生落库写入面:`backend/scripts/kpi_bep_deriver.py`
|
||||
· 卡面读取面 :`backend/app/api/dashboard.py::/bep-card`
|
||||
|
||||
口径(2026-10-03 财务裁定,任务 finance-20261003-cvp-fix-and-bohai-coverage-fullstack):
|
||||
|
||||
| 项 | entity 1 陕西酣客 | entity 2 博海 |
|
||||
|---|---|---|
|
||||
| 变动成本(分子) | Σ571103 借方(渠补/捐赠支出) | Σ6401 借方(主营业务成本) |
|
||||
| 收入基数(分母) | Σ5001 贷方(主营业务收入) | Σ6001 贷方(主营业务收入) |
|
||||
| 变动成本率 | Σ571103借 ÷ Σ5001贷(实测渠补率) | Σ6401借 ÷ Σ6001贷(主营业务成本率) |
|
||||
| 固定成本 | `5602%`(管理费用,含折旧)借−贷净额 | `6601+6602+6603`(销售/管理/财务费用)借−贷净额 |
|
||||
| 折旧剔除(现金口径) | `560213`(管理费用-折旧叶子) | `1602` 累计折旧**当期贷方发生额**(见下) |
|
||||
| 本期利润结转科目 | `3103` 本年利润 | `4103` 本年利润 |
|
||||
| 结账信号次选(落盘) | `/root/WB_WS/_data/zxt-hanke/{YYYY.MM}/check.md` | 无(只用 DB 信号) |
|
||||
|
||||
博海折旧口径说明:博海科目表只到 4 位一级科目(无费用类折旧叶子,**不存在** 酣客式 `560213`),
|
||||
唯一含「折旧」的是资产类 `1602 累计折旧`;其**当期贷方发生额** = 当期计提折旧额(对方科目
|
||||
计入 `6602 管理费用`),故取它作现金口径剔除项。实测 2026-01..07 每月 1,468.83 元,
|
||||
2026-08 起无计提行 ⇒ 2026-08 剔除额为 0(已在回报件列明)。
|
||||
|
||||
⚠️ 博海账套的「期间损益结转腿」(`summary='结转期间损溢'`,凭证如 `记-705/记-706`)
|
||||
**未**标 `carry_forward=1`(全账套 17343 行 carry_forward 恒 0)⇒ 若不显式剔除,
|
||||
损益类科目「借−贷」净额在已结账月份恒为 0,会把指标静默清零
|
||||
(口径见 `/srv/hermes/wisdom/_decisions/cma-expense-profit-net-caliber-20261002.md`)。
|
||||
故博海口径 `exclude_closing=True`。
|
||||
"""
|
||||
CLOSING_SUMMARY_LIKE = "%结转%"
|
||||
|
||||
# 酣客结账信号次选来源(智享通落盘 check.md;与 app/api/data.py 的 ZXT_LANDING_DIR 同源)
|
||||
ZXT_LANDING_DIR_HANKE = "/root/WB_WS/_data/zxt-hanke"
|
||||
|
||||
ENTITY_CALIBERS = {
|
||||
1: dict(
|
||||
name="陕西酣客",
|
||||
margin_label="实测渠补率",
|
||||
margin_desc="Σ571103借方 ÷ Σ5001贷方",
|
||||
revenue_field="credit_amount", revenue_filter=("eq", "5001"),
|
||||
varcost_field="debit_amount", varcost_filter=("eq", "571103"),
|
||||
fixed_filters=[("like", "5602")],
|
||||
fixed_label="管理费",
|
||||
dep_field="net", dep_filter=("eq", "560213"),
|
||||
closing_subject="3103",
|
||||
landing_dir=ZXT_LANDING_DIR_HANKE,
|
||||
exclude_closing=False,
|
||||
policy_pct=84.61, # 政策常量:仅对照展示,不参与计算(仅酣客有)
|
||||
),
|
||||
2: dict(
|
||||
name="博海",
|
||||
margin_label="变动成本率(主营业务成本率)",
|
||||
margin_desc="Σ6401借方 ÷ Σ6001贷方",
|
||||
revenue_field="credit_amount", revenue_filter=("eq", "6001"),
|
||||
varcost_field="debit_amount", varcost_filter=("eq", "6401"),
|
||||
fixed_filters=[("like", "6601"), ("like", "6602"), ("like", "6603")],
|
||||
fixed_label="固定成本(6601+6602+6603)",
|
||||
dep_field="credit_amount", dep_filter=("eq", "1602"),
|
||||
closing_subject="4103",
|
||||
landing_dir=None,
|
||||
exclude_closing=True,
|
||||
policy_pct=None, # 博海无渠补政策常量
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def caliber_or_none(entity_id: int):
|
||||
"""取实体口径;未注册返回 None(调用方自行决定放行/拒答)。"""
|
||||
return ENTITY_CALIBERS.get(entity_id)
|
||||
|
||||
|
||||
def caliber_for(entity_id: int) -> dict:
|
||||
"""取实体口径;未注册抛 KeyError(写入面用,fail-closed)。"""
|
||||
cal = ENTITY_CALIBERS.get(entity_id)
|
||||
if cal is None:
|
||||
raise KeyError(f"entity={entity_id} 未注册 BEP 口径(见 app/utils/bep_caliber.py)")
|
||||
return cal
|
||||
Reference in New Issue
Block a user