diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py index 927bf557..391d8e96 100644 --- a/backend/app/api/dashboard.py +++ b/backend/app/api/dashboard.py @@ -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} diff --git a/backend/app/api/predict.py b/backend/app/api/predict.py index bfe3ce67..756a433e 100644 --- a/backend/app/api/predict.py +++ b/backend/app/api/predict.py @@ -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, diff --git a/backend/app/utils/bep_caliber.py b/backend/app/utils/bep_caliber.py new file mode 100644 index 00000000..89f4bfd0 --- /dev/null +++ b/backend/app/utils/bep_caliber.py @@ -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 diff --git a/backend/scripts/kpi_bep_deriver.py b/backend/scripts/kpi_bep_deriver.py index ed6903be..9f77b29f 100644 --- a/backend/scripts/kpi_bep_deriver.py +++ b/backend/scripts/kpi_bep_deriver.py @@ -1,38 +1,57 @@ """保本点(盈亏平衡)派生KPI执行器 — 管理会计OS (cma-bep-kpi 2026-09-08) -按财务Bot权威口径(陕西酣客_保本点分析_2026年1-7月.md)自动计算并落库五个派生KPI: - F_BEP_REVENUE 保本销售额(万元) = 月均管理费 ÷ 边际贡献率 (账面口径,含折旧) +按财务Bot权威口径(陕西酣客_保本点分析_2026年1-7月.md)自动计算并落库六个派生KPI: + F_BEP_REVENUE 保本销售额(万元) = 月均固定成本 ÷ 边际贡献率 (账面口径,含折旧) F_BEP_COVER 保本覆盖率(%) = 月均销售 ÷ 保本销售额 × 100 (正向: >=100绿 >=85黄 <85红) F_BEP_GAP 保本缺口(%) = (保本销售额-月均销售) ÷ 保本销售额 × 100 (反向: <=0绿 <=15黄 >15红) - F_BEP_REVENUE_CASH 现金保本销售额(万元) = (月均管理费-月均折旧) ÷ 边际贡献率 (现金口径,剔除折旧560213,中性无阈值) + F_BEP_REVENUE_CASH 现金保本销售额(万元) = (月均固定成本-月均折旧) ÷ 边际贡献率 (现金口径,中性无阈值) F_BEP_COVER_CASH 现金保本覆盖率(%) = 月均销售 ÷ 现金保本销售额 × 100 (正向: >=100绿 >=85黄 <85红) - F_BEP_REBATE 实测渠补率(%) = Σ(571103借方) ÷ Σ(5001贷方) × 100 (YTD累计; 卡面单一事实源,无阈值) + F_BEP_REBATE 变动/渠补率(%) = 见下方「实体口径注册表」 (YTD累计; 卡面单一事实源,无阈值) + +实体口径注册表(2026-10-03 新增,任务 finance-20261003-cvp-fix-and-bohai-coverage-fullstack): + · entity 1 陕西酣客:变动成本率 = Σ571103借方 ÷ Σ5001贷方(实测渠补率);固定成本 = 5602% 净额; + 折旧剔除 = 560213(管理费用-折旧叶子)。 + · entity 2 博海:**无渠补科目(571103)**,变动成本 = Σ6401借(主营业务成本), + 变动成本率 = Σ6401借 ÷ Σ6001贷;固定成本 = (6601 销售费用 + 6602 管理费用 + 6603 财务费用) 净额; + 折旧剔除 = 1602 累计折旧「当期贷方发生额」(博海科目表只到 4 位一级,无费用类折旧叶子; + 2026-08 起博海 1602 无计提行,剔除额为 0 —— 见回报件)。 + 博海明细账的期间损益结转腿(summary='结转期间损溢',账套内 **未** 标 carry_forward=1) + 必须显式剔除,否则净额恒 0 会把已结账期间清零(口径见 + /srv/hermes/wisdom/_decisions/cma-expense-profit-net-caliber-20261002.md)。 口径(与财务Bot报告一致): -- 销售收入: voucher_details.subject_code='5001'(主营业务收入) 贷方合计,当年1月至目标月 YTD 月均 -- 管理费: subject_code LIKE '5602%'(管理费用,含560213折旧) 借−贷净额合计(carry_forward=0),YTD 月均 → 账面口径 -- 折旧: subject_code = '560213'(管理费用-折旧叶子) 借−贷净额合计(carry_forward=0),YTD 月均 → 现金口径剔除项(eq精确匹配,防5602130误含) -- 渠补率: **实测累计口径**(2026-10-02 财务裁定,任总 11:43 CST 放行「改,这样数据更加真实」) - 渠补率(实测) = Σ(571103 借方) ÷ Σ(5001 贷方) × 100,期间 = 年初至当期(YTD),保留 2 位小数。 +- 销售收入: entity1 = subject_code='5001'(主营业务收入) 贷方合计;entity2 = '6001' 贷方合计。当年1月至目标月 YTD 月均 +- 固定成本: entity1 = subject_code LIKE '5602%'(管理费用,含560213折旧) 借−贷净额合计(carry_forward=0),YTD 月均 + entity2 = 6601/6602/6603 借−贷净额合计(同上,但另需剔除结转腿) +- 折旧: entity1 = '560213' 借−贷净额;entity2 = '1602' 贷方发生额(现金口径剔除项) +- 变动成本率: **实测累计口径**(2026-10-02 财务裁定,任总 11:43 CST 放行「改,这样数据更加真实」) + 酣客 渠补率(实测) = Σ(571103 借方) ÷ Σ(5001 贷方) × 100,期间 = 年初至当期(YTD),保留 2 位小数。 `POLICY_REBATE_RATE_PCT = 84.61` 政策常量**仅作对照展示**(写进返回值 rebate_policy_pct / remark), **不参与任何计算**;`--rebate-rate` 显式传值时视为人工诊断覆盖(正常调用不再使用)。 -- 边际贡献率 = 100 − 渠补率(实测) +- 边际贡献率 = 100 − 变动成本率 - fail-closed 边界(2026-10-02 裁定):某期 571103 或 5001 缺数/为 0 ⇒ **该期不出保本值** - (六条派生行写 actual_value=NULL + data_status='pending' + remark 注明「渠补率不可得」), + (六条派生行写 actual_value=NULL + data_status='pending' + remark 注明「变动成本率不可得」), **不得静默回落 84.61 常量**;累计口径天然平滑单月波动(8 月单月 571103/5001=134% 不影响累计值)。 +- fail-closed 边界 ②(2026-10-03 裁定,本单):**变动成本率 ≥ 100% ⇔ 边际贡献率 ≤ 0 ⇒ 保本点不适用**。 + 该期 五条保本派生值(REVENUE/COVER/GAP/REVENUE_CASH/COVER_CASH)写 actual_value=NULL + + data_status='pending' + remark 含「边际贡献非正,保本点不适用」,**禁止回落 0.0** + (0.0 会被前端显示成「月保本 0 万、覆盖率 0%」,语义完全错误);变动成本率本身照常落库。 + 与既有 fail-closed(缺数/为 0)**同一处理路径风格**,仅文案与触发条件不同。 - 目标月: --period 显式指定;默认自动选最近「已结账」完整月(当月跳过)。 - 结账信号优先:DB 损益结转凭证(carry_forward=1 & subject_code='3103') → check.md 文件; + 结账信号优先:DB 损益结转凭证(carry_forward=1 & 本年利润科目 3103(酣客)/4103(博海)) → check.md 文件; 两者皆无时才退回旧「销售额阈值」兜底(销售低 ≠ 未结账,仅保底用,日志标注依据) - 单位: voucher 金额为元,聚合后 /10000 → 万元 - 幂等: 同 kpi_id+period+source_type='derived' 存在则更新,可安全重跑; **不降级**(ASK-20260924-10 / 2026-09-24):值未变或值变都不回写 data_status, 保留既有状态(含人工/任总放行的 verified),避免「人工转正 → 次日派生静默打回 pending」。 值变时 remark 留痕并落复核提示件(见 _reports/fullstack--bep-value-changed-review.md)。 + **例外**:fail-closed 两条路径(不可得 / 边际贡献非正)按裁定显式写 data_status='pending'。 - 只写 source_type='derived' 行;不触碰 auto_collect/manual/excel/ledger 行;不改 collector 逻辑 用法: venv/bin/python3 scripts/kpi_bep_deriver.py # 自动选目标月 venv/bin/python3 scripts/kpi_bep_deriver.py --period 2026-07 # 指定期间(实跑验证用) + venv/bin/python3 scripts/kpi_bep_deriver.py --entity 2 --period 2026-08 # 博海(新口径) venv/bin/python3 scripts/kpi_bep_deriver.py --entity 1 --rebate-rate 84.61 venv/bin/python3 scripts/kpi_bep_deriver.py --dry-run # 只计算不落库 """ @@ -45,7 +64,7 @@ from datetime import datetime, date sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import re -from sqlalchemy import func +from sqlalchemy import func, or_ from app.database import get_session_local from app.models import KPIValue, KPIDefinition, VoucherDetail, KPIAlert # 裁定 R1(2026-09-19 财务口径):demo 账套不产生业务预警(派生值仍落库,仅跳过预警同步) @@ -78,12 +97,23 @@ CORE_KPI_CODES = [c for c, _ in TARGET_KPIS if c != REBATE_KPI] # 现金口径 KPI 编码(计算路径独立于账面,remark 文案用现金口径) CASH_CODES = {"F_BEP_REVENUE_CASH", "F_BEP_COVER_CASH"} -SALES_CODE = "5001" # 主营业务收入 +SALES_CODE = "5001" # 主营业务收入(酣客) ADMIN_PREFIX = "5602" # 管理费用(含折旧叶子560213) DEP_SUBJECT = "560213" # 管理费用-折旧(现金口径剔除项,eq 精确匹配防 5602130 误含) REBATE_CODE = "571103" # 渠补/捐赠支出(校验用,不直接进公式) CARRY_SUBJECT = "3103" # 本年利润(carry_forward=1 且该科目 = 该期损益结转凭证) +# ── 实体口径注册表(2026-10-03)——单一事实源见 app/utils/bep_caliber.py ───────── +# 变动成本率(rate)= numerator ÷ denominator(YTD 累计): +# · 酣客:Σ571103借方(渠补) ÷ Σ5001贷方(收入) —— 2026-10-02 财务裁定;结转账腿已由 carry_forward=1 标识 +# · 博海:Σ6401借方(主营业务成本) ÷ Σ6001贷方(收入) —— 2026-10-03 财务裁定「新口径,勿套酣客渠补公式」 +# exclude_closing:该账套的「期间损益结转腿」是否需要用 summary 显式剔除 +# (博海账套的结转行 carry_forward 仍是 0,不剔除则已结账期间净额恒 0 ⇒ 指标被清零) +# 同表被 backend/app/api/dashboard.py::/bep-card 共用(口径不漂移)。 +from app.utils.bep_caliber import ( # noqa: E402 (紧随 sys.path 之后导入) + CLOSING_SUMMARY_LIKE, ENTITY_CALIBERS, caliber_for, +) + # 智享通落盘目录(与 app/api/data.py 的 ZXT_LANDING_DIR 一致)——结账信号次选来源 ZXT_LANDING_DIR = "/root/WB_WS/_data/zxt-hanke" @@ -152,12 +182,16 @@ def _month_range(period: str): def _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=True 时再显式剔除「期间损益结转腿」(summary 含「结转」)—— + 用于结转行未被标 carry_forward=1 的账套(博海),否则净额恒 0 会把已结账期间清零。 + subject_filters: [('like','6601'), ('like','6602'), ...] 多科目 OR(与 subject_filter 二选一) """ expr = (VoucherDetail.debit_amount - VoucherDetail.credit_amount if field == "net" else getattr(VoucherDetail, field)) @@ -167,70 +201,105 @@ def _sum_field(db, entity_id: int, period_from: str, period_to: str, VoucherDetail.period >= period_from, VoucherDetail.period <= period_to, ) + if explicit := subject_filters: + q = q.filter(or_(*[_subject_clause(k, c) for k, c in explicit])) 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 + "%")) + q = q.filter(_subject_clause(kind, code)) + if exclude_closing: + q = q.filter(or_(VoucherDetail.summary.is_(None), + ~VoucherDetail.summary.like(CLOSING_SUMMARY_LIKE))) val = q.scalar() return float(val or 0.0) +def _subject_clause(kind: str, code: str): + """科目过滤谓词:('eq', code) 精确 / ('like', prefix) 前缀""" + if kind == "eq": + return VoucherDetail.subject_code == code + if kind == "like": + return VoucherDetail.subject_code.like(code + "%") + raise ValueError(f"未知科目过滤类型: {kind}") + + +def compute_margin_rate(db, entity_id: int, period_from: str, period_to: str, + caliber: dict = None) -> dict: + """变动成本率(YTD 累计,2 位小数)—— 按实体口径现算。 + + · 酣客(entity1):Σ571103借方 ÷ Σ5001贷方(实测渠补率) + · 博海(entity2):Σ6401借方 ÷ Σ6001贷方(主营业务成本率) + + 返回兼容既有键名:{'available', 'rebate_pct', 'rebate_sum_w', 'sales_sum_w', 'reason'} + (rebate_sum_w = 分子/变动成本,sales_sum_w = 分母/收入基数;供卡面/报告沿用)。 + + **fail-closed**:分子或分母缺数/为 0 ⇒ available=False, rebate_pct=None + (调用方不得回落政策常量,须按「不可得」处理)。 + """ + cal = caliber or ENTITY_CALIBERS[entity_id] + num_raw = _sum_field(db, entity_id, period_from, period_to, + cal["varcost_field"], cal["varcost_filter"], + exclude_closing=cal["exclude_closing"]) + den_raw = _sum_field(db, entity_id, period_from, period_to, + cal["revenue_field"], cal["revenue_filter"], + exclude_closing=cal["exclude_closing"]) + num_w, den_w = num_raw / 10000.0, den_raw / 10000.0 + info = { + "rebate_sum_w": round(num_w, 2), + "sales_sum_w": round(den_w, 2), + "rebate_pct": None, + "available": False, + "reason": "", + "label": cal["margin_label"], + "desc": cal["margin_desc"], + } + if den_w <= 0: + info["reason"] = f"{cal['margin_label']}不可得:{cal['revenue_filter'][1]} 累计缺数或为 0" + return info + if num_w <= 0: + info["reason"] = f"{cal['margin_label']}不可得:{cal['varcost_filter'][1]} 累计缺数或为 0" + return info + info["available"] = True + info["rebate_pct"] = round(num_w / den_w * 100.0, 2) + return info + + def compute_rebate_rate(db, entity_id: int, period_from: str, period_to: str) -> dict: - """实测累计渠补率(2026-10-02 财务裁定口径)。 + """实测累计渠补率(2026-10-02 财务裁定口径)—— 酣客(entity1) 专用薄封装。 渠补率(实测) = Σ(571103 借方) ÷ Σ(5001 贷方) × 100,期间 = 年初至当期(YTD),保留 2 位小数。 · 571103 = 渠补/捐赠支出(损益类费用科目)⇒ 本期发生额落**借方**; · 5001 = 主营业务收入 ⇒ 本期发生额落**贷方**; · 行域 = carry_forward=0(与 _sum_field 一致,排除期间损益结转腿)。 - 返回 {'available', 'rebate_pct', 'rebate_sum_w', 'sales_sum_w', 'reason'}; - **fail-closed**:分子或分母缺数/为 0 ⇒ available=False, rebate_pct=None - (调用方不得回落政策常量,须按「渠补率不可得」处理)。 + 保留本函数名/键名以兼容既有调用方与单测;通用实现见 compute_margin_rate。 """ - rebate_sum_w = _sum_field(db, entity_id, period_from, period_to, - "debit_amount", ("eq", REBATE_CODE)) / 10000.0 - sales_sum_w = _sum_field(db, entity_id, period_from, period_to, - "credit_amount", ("eq", SALES_CODE)) / 10000.0 - info = { - "rebate_sum_w": round(rebate_sum_w, 2), - "sales_sum_w": round(sales_sum_w, 2), - "available": False, - "rebate_pct": None, - "reason": "", - } - if sales_sum_w <= 0: - info["reason"] = "渠补率不可得:5001 贷方累计缺数或为 0" - return info - if rebate_sum_w <= 0: - info["reason"] = "渠补率不可得:571103 借方累计缺数或为 0" - return info - info["available"] = True - info["rebate_pct"] = round(rebate_sum_w / sales_sum_w * 100.0, 2) - return info + return compute_margin_rate(db, entity_id, period_from, period_to, + ENTITY_CALIBERS.get(entity_id)) -def _month_sales(db, entity_id: int, period: str) -> float: - """单月销售收入(万元)——用于目标月完整度判断""" - return _sum_field(db, entity_id, period, period, "credit_amount", - ("eq", SALES_CODE)) / 10000.0 +def _month_sales(db, entity_id: int, period: str, caliber: dict = None) -> float: + """单月销售收入(万元)——用于目标月完整度判断(按实体口径的收入科目)""" + cal = caliber or ENTITY_CALIBERS.get(entity_id) or ENTITY_CALIBERS[1] + return _sum_field(db, entity_id, period, period, cal["revenue_field"], + cal["revenue_filter"], exclude_closing=cal["exclude_closing"]) / 10000.0 -def _is_period_closed(db, entity_id: int, period: str) -> tuple: +def _is_period_closed(db, entity_id: int, period: str, caliber: dict = None) -> tuple: """判断该期间是否已结账(损益结转已完成)。返回 (bool, 依据说明)。 - 主信号(DB): voucher_details 存在 carry_forward=1 且 subject_code='3103'(本年利润) 的行 - = 该期损益结转凭证已生成 - 次信号(文件): ZXT_LANDING_DIR/{YYYY.MM}/check.md 同时含「本期: PASS」与「损益结转: PASS」 + 主信号(DB): voucher_details 存在 carry_forward=1 且 subject_code=该账套本年利润科目 + (酣客 3103 / 博海 4103)= 该期损益结转凭证已生成 + 次信号(文件): {landing_dir}/{YYYY.MM}/check.md 同时含「本期: PASS」与「损益结转: PASS」 + (仅酣客有智享通落盘目录;博海 landing_dir=None ⇒ 跳过该信号) 均不成立 → (False, "")。任何读取异常均吞掉,不影响主流程。 """ + cal = caliber or ENTITY_CALIBERS.get(entity_id) or ENTITY_CALIBERS[1] # 主信号:DB 损益结转凭证 try: hit = db.query(VoucherDetail.id).filter( VoucherDetail.entity_id == entity_id, VoucherDetail.carry_forward == 1, - VoucherDetail.subject_code == CARRY_SUBJECT, + VoucherDetail.subject_code == cal["closing_subject"], VoucherDetail.period == period, ).first() if hit: @@ -238,21 +307,23 @@ def _is_period_closed(db, entity_id: int, period: str) -> tuple: except Exception as e: # 查询异常不阻断,降级到文件信号 logger.warning("结账信号(DB)查询失败 period=%s: %s", period, e) - # 次信号:智享通落盘 check.md + # 次信号:智享通落盘 check.md(仅酣客) + landing = cal.get("landing_dir") try: - path = os.path.join(ZXT_LANDING_DIR, period.replace("-", "."), "check.md") - if os.path.exists(path): - with open(path, "r", encoding="utf-8") as f: - txt = f.read() - if "本期: PASS" in txt and "损益结转: PASS" in txt: - return True, "check.md" + if landing: + path = os.path.join(landing, period.replace("-", "."), "check.md") + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as f: + txt = f.read() + if "本期: PASS" in txt and "损益结转: PASS" in txt: + return True, "check.md" except Exception: pass # 文件缺失/读取失败一律忽略 return False, "" -def _pick_target_period(db, entity_id: int, now: date) -> str: +def _pick_target_period(db, entity_id: int, now: date, caliber: dict = None) -> str: """自动选择目标月:跳过当月。 判定顺序(2026-09-12 修订): @@ -260,6 +331,7 @@ def _pick_target_period(db, entity_id: int, now: date) -> str: 结账成立时不再用销售额做任何筛选(销售低 ≠ 未结账,历史缺陷根因) 2. 所有候选月均无结账信号 → 退回旧「销售额 < 近6月均×30%」兜底(日志标注『依据销售额兜底』) """ + cal = caliber or ENTITY_CALIBERS.get(entity_id) or ENTITY_CALIBERS[1] # 收集已有数据月份(5001 贷方) rows = db.query(VoucherDetail.period).filter( VoucherDetail.entity_id == entity_id, @@ -280,7 +352,7 @@ def _pick_target_period(db, entity_id: int, now: date) -> str: # 1) 结账信号优先(DB损益结转 → check.md),命中即采用 for p in candidates: - closed, reason = _is_period_closed(db, entity_id, p) + closed, reason = _is_period_closed(db, entity_id, p, cal) if closed: logger.info("目标月 %s 判定已结账(依据:%s),采用", p, reason) return p @@ -294,14 +366,14 @@ def _pick_target_period(db, entity_id: int, now: date) -> str: for p in periods: if p >= cur: continue - s = _month_sales(db, entity_id, p) + s = _month_sales(db, entity_id, p, cal) ref_months.append((p, s)) if len(ref_months) >= 6: break ref_avg = (sum(s for _, s in ref_months) / len(ref_months)) if ref_months else None for p in candidates: - s = _month_sales(db, entity_id, p) + s = _month_sales(db, entity_id, p, cal) if s <= 0: continue # 该月无销售,往前找 if ref_avg and s < ref_avg * 0.3: @@ -339,13 +411,20 @@ def _eval_static_threshold(value: float, kpi) -> tuple: def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float = None, write_kpi: bool = True) -> dict: - """计算并落库六个派生KPI(账面3条+现金2条+实测渠补率1条)。返回结果摘要。 + """计算并落库六个派生KPI(账面3条+现金2条+变动成本率1条)。返回结果摘要。 - 渠补率(2026-10-02 裁定):默认按 YTD 从 voucher_details **现算** - (Σ571103借方 ÷ Σ5001贷方),政策常量 84.61% 仅作对照返回(rebate_policy_pct)。 - 显式传 rebate_rate_pct(`--rebate-rate`)时视为人工诊断覆盖。 - fail-closed:实测渠补率不可得且无人工覆盖 ⇒ 该期不出保本值(六行 pending/NULL)。 + 实体口径见 ENTITY_CALIBERS(entity1 酣客=渠补率;entity2 博海=主营业务成本率)。 + 变动成本率(2026-10-02 裁定):默认按 YTD 从 voucher_details **现算**, + 政策常量 84.61% 仅作对照返回(rebate_policy_pct,仅 entity1 有对照物)。 + 显式传 rebate_rate_pct(`--rebate-rate`)时视为人工诊断覆盖(仅 entity1 语境)。 + fail-closed ①:变动成本率不可得且无人工覆盖 ⇒ 该期不出保本值(六行 pending/NULL)。 + fail-closed ②(2026-10-03):变动成本率 ≥100%(边际贡献率 ≤0)⇒ 五条保本值 NULL + + remark「边际贡献非正,保本点不适用」,**禁写 0.0**。 """ + cal = ENTITY_CALIBERS.get(entity_id) + if cal is None: + raise RuntimeError(f"entity={entity_id} 未注册 BEP 口径(见 ENTITY_CALIBERS)") + kpis = {k.kpi_code: k for k in db.query(KPIDefinition).filter( KPIDefinition.entity_id == entity_id, KPIDefinition.kpi_code.in_([c for c, _ in TARGET_KPIS]), @@ -355,48 +434,77 @@ def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float = None, if missing: raise RuntimeError(f"entity={entity_id} 缺少派生KPI定义: {missing}") if REBATE_KPI not in kpis: - # 渠补率定义缺失不阻断保本主流程(部署顺序容错),但必须显式告警: - # 卡面此时取不到 rebate_pct(单一事实源缺失),需尽快补 bep_kpi_defs_rebate SQL。 - logger.error("entity=%s 缺少 %s 定义(见 scripts/sql/bep_kpi_defs_rebate_20261002.sql);" - "本期跳过实测渠补率落库,卡面将判「渠补率不可得」", entity_id, REBATE_KPI) + # 变动成本率定义缺失不阻断保本主流程(部署顺序容错),但必须显式告警: + # 卡面此时取不到 rebate_pct(单一事实源缺失),需尽快补 bep_kpi_defs*/rebate 种子 SQL。 + logger.error("entity=%s 缺少 %s 定义(见 scripts/sql/bep_kpi_defs*.sql);" + "本期跳过变动成本率落库,卡面将判「不可得」", entity_id, REBATE_KPI) year = period[:4] period_from = f"{year}-01" period_to = period months = int(period[5:7]) # YTD 月数(1月=1 ... 7月=7) - # 1. 取数(元 → 万元) - sales_sum_w = _sum_field(db, entity_id, period_from, period_to, "credit_amount", ("eq", SALES_CODE)) / 10000.0 - admin_sum_w = _sum_field(db, entity_id, period_from, period_to, "net", ("like", ADMIN_PREFIX)) / 10000.0 - dep_sum_w = _sum_field(db, entity_id, period_from, period_to, "net", ("eq", DEP_SUBJECT)) / 10000.0 + # 1. 取数(元 → 万元)——按实体口径 + sales_sum_w = _sum_field(db, entity_id, period_from, period_to, + cal["revenue_field"], cal["revenue_filter"], + exclude_closing=cal["exclude_closing"]) / 10000.0 + admin_sum_w = _sum_field(db, entity_id, period_from, period_to, "net", + subject_filters=cal["fixed_filters"], + exclude_closing=cal["exclude_closing"]) / 10000.0 + dep_sum_w = _sum_field(db, entity_id, period_from, period_to, + cal["dep_field"], cal["dep_filter"], + exclude_closing=cal["exclude_closing"]) / 10000.0 sales_avg = sales_sum_w / months admin_avg = admin_sum_w / months dep_avg = dep_sum_w / months cash_admin_avg = admin_avg - dep_avg # 现金口径固定成本(剔除折旧纸面费用) - # 2. 渠补率:默认逐期实测累计(不再取政策常量;常量仅对照) - rebate_info = compute_rebate_rate(db, entity_id, period_from, period_to) + # 2. 变动成本率:默认逐期实测累计(不再取政策常量;常量仅对照,仅 entity1 有) + rebate_info = compute_margin_rate(db, entity_id, period_from, period_to, cal) calc_pct = rebate_info["rebate_pct"] if rebate_rate_pct is not None: rebate_info = dict(rebate_info, available=True, rebate_pct=round(float(rebate_rate_pct), 2), reason="人工覆盖(--rebate-rate)") - logger.warning("渠补率使用人工覆盖值 %.2f%%(YTD 实算值 %s)", rebate_rate_pct, + logger.warning("变动成本率使用人工覆盖值 %.2f%%(YTD 实算值 %s)", rebate_rate_pct, ("%.2f%%" % calc_pct) if calc_pct is not None else "不可得") elif not rebate_info["available"]: # fail-closed:不出保本值,绝不静默回落政策常量 84.61 logger.error("period=%s %s ⇒ 本期不出保本值(fail-closed,不回落政策常量)", period, rebate_info["reason"]) - return _fail_closed_rebate_unavailable(db, entity_id, period, months, rebate_info, kpis, write_kpi) + return _fail_closed_rebate_unavailable(db, entity_id, period, months, rebate_info, cal, + kpis, write_kpi) rebate_pct = rebate_info["rebate_pct"] - cm_pct = 100.0 - rebate_pct # 边际贡献率(%) = 100 − 实测渠补率 + cm_pct = 100.0 - rebate_pct # 边际贡献率(%) = 100 − 变动成本率 + + # 2b. fail-closed ②(2026-10-03):边际贡献率 ≤ 0 ⇒ 保本点不适用(禁写 0.0) + if cm_pct <= 0: + logger.error("period=%s %s=%.2f%% ≥100%% ⇒ 边际贡献非正,保本点不适用(fail-closed,禁写 0.0)", + period, cal["margin_label"], rebate_pct) + return _fail_closed_cm_non_positive(db, entity_id, period, months, rebate_info, cal, + kpis, write_kpi, sales_avg=sales_avg, + admin_avg=admin_avg, dep_avg=dep_avg) + + # 2c. fail-closed ③(2026-10-03,同族补充):固定成本退化 ⇒ 保本点不适用 + # 边际贡献率 > 0 但分子(固定成本)退化时,公式给出 保本额≈0 / 覆盖率趋于无穷, + # 与 0.0 兜底同属「语义崩溃」类(实测:博海 2026-01 账面净固定成本月均 0.01万≈0、 + # 现金固定成本月均 −0.14万 ⇒ 公式值 保本 0.1万 / 覆盖率 175981% / 现金保本 −2.3万)。 + # 判据(同族、无新增经验常量):月均账面固定成本 ≤ 0 **或** 月均现金固定成本 ≤ 0。 + if admin_avg <= 0 or cash_admin_avg <= 0: + logger.error("period=%s %s月均=%.2f万、现金固定成本月均=%.2f万(含折旧月均%.2f万)" + "⇒ 固定成本退化,保本点不适用(fail-closed)", + period, cal["fixed_label"], admin_avg, cash_admin_avg, dep_avg) + return _fail_closed_cm_non_positive(db, entity_id, period, months, rebate_info, cal, + kpis, write_kpi, sales_avg=sales_avg, + admin_avg=admin_avg, dep_avg=dep_avg, guard="fixed", + cash_admin_avg=cash_admin_avg) # 3. 派生计算(万元 / %)——账面3条路径不变;现金口径在账面基础上剔除折旧 - bep = admin_avg / (cm_pct / 100.0) if cm_pct > 0 else 0.0 + bep = admin_avg / (cm_pct / 100.0) cover = (sales_avg / bep * 100.0) if bep > 0 else 0.0 gap = ((bep - sales_avg) / bep * 100.0) if bep > 0 else 0.0 - cash_bep = cash_admin_avg / (cm_pct / 100.0) if cm_pct > 0 else 0.0 + cash_bep = cash_admin_avg / (cm_pct / 100.0) cash_cover = (sales_avg / cash_bep * 100.0) if cash_bep > 0 else 0.0 values = { @@ -419,16 +527,30 @@ def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float = None, written[code] = "no-definition" continue if code == REBATE_KPI: - remark = (f"实测渠补率(YTD累计): Σ571103借方 {rebate_info['rebate_sum_w']:.2f}万 ÷ " - f"Σ5001贷方 {rebate_info['sales_sum_w']:.2f}万 × 100 = {rebate_pct:.2f}%" - f"(政策常量 {POLICY_REBATE_RATE_PCT:.2f}% 仅供对照,不参与计算)") + if entity_id == 1: + remark = (f"实测渠补率(YTD累计): Σ571103借方 {rebate_info['rebate_sum_w']:.2f}万 ÷ " + f"Σ5001贷方 {rebate_info['sales_sum_w']:.2f}万 × 100 = {rebate_pct:.2f}%" + f"(政策常量 {POLICY_REBATE_RATE_PCT:.2f}% 仅供对照,不参与计算)") + else: + remark = (f"{cal['margin_label']}(YTD累计): {cal['margin_desc']} = " + f"{rebate_info['rebate_sum_w']:.2f}万 ÷ {rebate_info['sales_sum_w']:.2f}万 " + f"× 100 = {rebate_pct:.2f}%({cal['name']}口径,无渠补科目)") elif code in CASH_CODES: - remark = (f"现金口径: 管理费月均{admin_avg:.2f}万-折旧月均{dep_avg:.2f}万" - f"=固定现金{cash_admin_avg:.2f}万 ÷ 边际贡献率{cm_pct:.2f}% → 保本{cash_bep:.2f}万" - f"(实测渠补率{rebate_pct:.2f}%)") + if entity_id == 1: + remark = (f"现金口径: 管理费月均{admin_avg:.2f}万-折旧月均{dep_avg:.2f}万" + f"=固定现金{cash_admin_avg:.2f}万 ÷ 边际贡献率{cm_pct:.2f}% → 保本{cash_bep:.2f}万" + f"(实测渠补率{rebate_pct:.2f}%)") + else: + remark = (f"现金口径: {cal['fixed_label']}月均{admin_avg:.2f}万-折旧月均{dep_avg:.2f}万" + f"=固定现金{cash_admin_avg:.2f}万 ÷ 边际贡献率{cm_pct:.2f}% → 保本{cash_bep:.2f}万" + f"({cal['margin_label']}{rebate_pct:.2f}%)") else: - remark = (f"保本派生器: 销售月均{sales_avg:.1f}万/管理费月均{admin_avg:.1f}万/" - f"实测渠补率{rebate_pct:.2f}%(政策{POLICY_REBATE_RATE_PCT:.2f}%仅对照)") + if entity_id == 1: + remark = (f"保本派生器: 销售月均{sales_avg:.1f}万/管理费月均{admin_avg:.1f}万/" + f"实测渠补率{rebate_pct:.2f}%(政策{POLICY_REBATE_RATE_PCT:.2f}%仅对照)") + else: + remark = (f"保本派生器: 销售月均{sales_avg:.1f}万/{cal['fixed_label']}月均{admin_avg:.1f}万/" + f"{cal['margin_label']}{rebate_pct:.2f}%") existing = db.query(KPIValue).filter( KPIValue.kpi_id == kpi.id, KPIValue.period == period, @@ -540,18 +662,22 @@ def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float = None, def _fail_closed_rebate_unavailable(db, entity_id: int, period: str, months: int, - rebate_info: dict, kpis: dict, + rebate_info: dict, cal: dict, kpis: dict, write_kpi: bool) -> dict: - """fail-closed 落库:实测渠补率不可得 ⇒ 该期不出保本值。 + """fail-closed 落库:变动成本率不可得 ⇒ 该期不出保本值。 - 六条派生行写 actual_value=NULL + data_status='pending' + remark 注明「渠补率不可得」, + 六条派生行写 actual_value=NULL + data_status='pending' + remark 注明「不可得」, **不回落**政策常量 84.61(2026-10-02 财务裁定 / 任总 11:43 CST 放行)。 不评估阈值、不产生/解除预警(无值 ⇒ 无级别可评)。 """ - reason = rebate_info["reason"] or "渠补率不可得" - detail = (f"{reason};Σ571103借方 {rebate_info['rebate_sum_w']:.2f}万 / " - f"Σ5001贷方 {rebate_info['sales_sum_w']:.2f}万 ⇒ 本期不出保本值(fail-closed," - f"不回落政策常量 {POLICY_REBATE_RATE_PCT:.2f}%)") + reason = rebate_info["reason"] or f"{cal['margin_label']}不可得" + if entity_id == 1: + detail = (f"{reason};Σ571103借方 {rebate_info['rebate_sum_w']:.2f}万 / " + f"Σ5001贷方 {rebate_info['sales_sum_w']:.2f}万 ⇒ 本期不出保本值(fail-closed," + f"不回落政策常量 {POLICY_REBATE_RATE_PCT:.2f}%)") + else: + detail = (f"{reason};{cal['margin_desc']} = {rebate_info['rebate_sum_w']:.2f}万 / " + f"{rebate_info['sales_sum_w']:.2f}万 ⇒ 本期不出保本值(fail-closed,禁回落、禁跨期)") batch = f"bep-{datetime.now().strftime('%Y%m%d%H%M%S')}" written = {} if write_kpi: @@ -604,39 +730,150 @@ def _fail_closed_rebate_unavailable(db, entity_id: int, period: str, months: int } +def _fail_closed_cm_non_positive(db, entity_id: int, period: str, months: int, + rebate_info: dict, cal: dict, kpis: dict, write_kpi: bool, + sales_avg: float = None, admin_avg: float = None, + dep_avg: float = None, guard: str = "cm", + cash_admin_avg: float = None) -> dict: + """fail-closed ② 落库:保本点**不适用**(2026-10-03 财务裁定)。 + + 五条保本派生值(REVENUE/COVER/GAP/REVENUE_CASH/COVER_CASH)写 + actual_value=NULL + data_status='pending' + remark 含「保本点不适用」, + **禁止回落 0.0**(0.0 会被前端显示成「月保本 0 万、覆盖率 0%」,语义完全错误)。 + 变动成本率本身是可测量的 ⇒ 该 KPI 照常落库(值 + YTD 累计 remark)。 + 不评估阈值、不产生/解除预警(保本值无值 ⇒ 无级别可评)。 + + guard 触发条件(同族,均属「公式退化」): + · 'cm' 边际贡献率 ≤ 0(变动成本率 ≥ 100%)——本单裁定 ① + · 'fixed' 月均账面固定成本 ≤ 0 ——同族补充③(分子非正 ⇒ 保本额≈0、覆盖率趋于无穷) + """ + rebate_pct = rebate_info["rebate_pct"] + cm_pct = round(100.0 - float(rebate_pct), 2) + if guard == "fixed": + detail = (f"保本点不适用(固定成本退化:{cal['fixed_label']}月均 {admin_avg:.2f}万 ≤ 0 或 " + f"现金固定成本月均 {cash_admin_avg:.2f}万 ≤ 0 ⇒ 分子退化,保本额≈0、覆盖率无意义;" + f"{cal['margin_label']} {rebate_pct:.2f}%、边际贡献率 {cm_pct:.2f}%;" + f"fail-closed,禁写 0.0)") + else: + detail = (f"边际贡献非正,保本点不适用({cal['margin_label']} {rebate_pct:.2f}% ≥ 100%," + f"边际贡献率 {cm_pct:.2f}% ⇒ 每增一元销售不产生正贡献,保本点无经济意义;" + f"fail-closed,禁写 0.0)") + if entity_id == 1: + rebate_remark = (f"实测渠补率(YTD累计): Σ571103借方 {rebate_info['rebate_sum_w']:.2f}万 ÷ " + f"Σ5001贷方 {rebate_info['sales_sum_w']:.2f}万 × 100 = {rebate_pct:.2f}%" + f"(政策常量 {POLICY_REBATE_RATE_PCT:.2f}% 仅供对照,不参与计算)") + else: + rebate_remark = (f"{cal['margin_label']}(YTD累计): {cal['margin_desc']} = " + f"{rebate_info['rebate_sum_w']:.2f}万 ÷ {rebate_info['sales_sum_w']:.2f}万 " + f"× 100 = {rebate_pct:.2f}%({cal['name']}口径,无渠补科目)") + batch = f"bep-{datetime.now().strftime('%Y%m%d%H%M%S')}" + written = {} + changed = [] + if write_kpi: + for code, _ in TARGET_KPIS: + kpi = kpis.get(code) + if kpi is None: + written[code] = "no-definition" + continue + is_rebate = (code == REBATE_KPI) + new_val = round(float(rebate_pct), 2) if is_rebate else None + rm = rebate_remark if is_rebate else detail + existing = db.query(KPIValue).filter( + KPIValue.kpi_id == kpi.id, + KPIValue.period == period, + KPIValue.source_type == "derived", + ).first() + if existing is None: + db.add(KPIValue( + entity_id=entity_id, + kpi_id=kpi.id, + period=period, + actual_value=new_val, + source_type="derived", + source_batch=batch, + data_status="pending", + remark=rm, + )) + written[code] = "insert" + else: + old_value = float(existing.actual_value) if existing.actual_value is not None else None + value_changed = ((old_value is None) != (new_val is None) + or (old_value is not None and abs(old_value - float(new_val)) > 1e-9)) + existing.actual_value = new_val + existing.source_batch = batch + existing.data_status = "pending" # 裁定:fail-closed 路径显式 pending + existing.remark = rm + if value_changed: + changed.append({"code": code, "old": old_value, "new": new_val, + "status": "pending"}) + written[code] = "upsert" + db.commit() + _write_value_changed_note(entity_id, period, batch, changed) + else: + written = {c: "dry-run" for c, _ in TARGET_KPIS} + return { + "entity_id": entity_id, + "period": period, + "months": months, + "data_status": "pending", + "cm_non_positive": True, + "reason": detail, + "rebate_sum_w": rebate_info["rebate_sum_w"], + "sales_sum_w": rebate_info["sales_sum_w"], + "rebate_actual_pct": rebate_pct, + "rebate_policy_pct": POLICY_REBATE_RATE_PCT, + "rebate_source": rebate_info.get("reason") or cal["margin_desc"], + "cm_pct": cm_pct, + "sales_avg": round(sales_avg, 2) if sales_avg is not None else None, + "admin_avg": round(admin_avg, 2) if admin_avg is not None else None, + "dep_avg": round(dep_avg, 2) if dep_avg is not None else None, + "values": {}, + "written": written, + "value_changed": changed, + } + + def main(): ap = argparse.ArgumentParser(description="保本点派生KPI执行器") ap.add_argument("--period", default=None, help="目标期间 YYYY-MM(默认自动选最近完整月)") - ap.add_argument("--entity", type=int, default=1, help="entity_id(默认1=陕西酣客)") + ap.add_argument("--entity", type=int, default=1, help="entity_id(1=陕西酣客 2=博海)") ap.add_argument("--rebate-rate", type=float, default=None, - help="渠补率%%人工覆盖(诊断用;**默认按 YTD 从 voucher_details 实测现算**,不再取政策常量)") + help="变动成本率%%人工覆盖(诊断用;**默认按 YTD 从 voucher_details 实测现算**,不再取政策常量)") ap.add_argument("--dry-run", action="store_true", help="只计算不落库") args = ap.parse_args() db = get_session_local()() try: - period = args.period or _pick_target_period(db, args.entity, date.today()) + cal = ENTITY_CALIBERS.get(args.entity) + if cal is None: + print(f"❌ entity={args.entity} 未注册 BEP 口径(见 ENTITY_CALIBERS)") + return 2 + period = args.period or _pick_target_period(db, args.entity, date.today(), cal) r = run_derive(db, args.entity, period, args.rebate_rate, write_kpi=not args.dry_run) - print(f"保本点派生计算完成 entity={r['entity_id']} period={r['period']} " + print(f"保本点派生计算完成 entity={r['entity_id']}({cal['name']}) period={r['period']} " f"(YTD {r['months']}个月) mode={'dry-run' if args.dry_run else 'write'} " f"data_status={r['data_status']}") if r["data_status"] == "pending": - print(f" ⚠ fail-closed:{r['reason']}") + kind = "边际贡献非正 ⇒ 保本点不适用" if r.get("cm_non_positive") else "不可得" + print(f" ⚠ fail-closed({kind}):{r['reason']}") + if r.get("rebate_actual_pct") is not None: + print(f" {cal['margin_label']}(YTD) = {r['rebate_actual_pct']:.2f}% " + f"| 边际贡献率 {r.get('cm_pct')}% ⇒ 仅落变动成本率,五条保本值 NULL") print(f" 落库: {r['written']}") return print(f" 销售收入累计 {r['sales_sum_w']:.2f}万 月均 {r['sales_avg']:.2f}万") - print(f" 管理费累计 {r['admin_sum_w']:.2f}万 月均 {r['admin_avg']:.2f}万 (含折旧,账面口径)") - print(f" 折旧累计 {r['dep_sum_w']:.2f}万 月均 {r['dep_avg']:.2f}万 (560213) | " - f"现金固定成本月均 {r['cash_admin_avg']:.2f}万 (管理费-折旧)") - print(f" 渠补支出累计 {r['rebate_sum_w']:.2f}万 | 实测渠补率(YTD) {r['rebate_actual_pct']:.2f}% " - f"| 政策常量 {r['rebate_policy_pct']:.2f}% (仅对照) | 边际贡献率 {r['cm_pct']:.2f}%") + print(f" 固定成本累计 {r['admin_sum_w']:.2f}万 月均 {r['admin_avg']:.2f}万 ({cal['fixed_label']},账面口径)") + print(f" 折旧累计 {r['dep_sum_w']:.2f}万 月均 {r['dep_avg']:.2f}万 ({cal['dep_filter'][1]}) | " + f"现金固定成本月均 {r['cash_admin_avg']:.2f}万 (固定成本-折旧)") + print(f" 变动成本累计 {r['rebate_sum_w']:.2f}万 | {cal['margin_label']}(YTD) {r['rebate_actual_pct']:.2f}% " + f"({cal['margin_desc']}) | 边际贡献率 {r['cm_pct']:.2f}%") print(f" F_BEP_REVENUE 保本销售额 = {r['values']['F_BEP_REVENUE']} 万元") print(f" F_BEP_COVER 保本覆盖率 = {r['values']['F_BEP_COVER']} %") print(f" F_BEP_GAP 保本缺口 = {r['values']['F_BEP_GAP']} %") print(f" F_BEP_REVENUE_CASH 现金保本销售额 = {r['values']['F_BEP_REVENUE_CASH']} 万元") print(f" F_BEP_COVER_CASH 现金保本覆盖率 = {r['values']['F_BEP_COVER_CASH']} %") - print(f" F_BEP_REBATE 实测渠补率 = {r['values']['F_BEP_REBATE']} %") + print(f" F_BEP_REBATE {cal['margin_label']} = {r['values']['F_BEP_REBATE']} %") print(f" 落库: {r['written']}") finally: db.close() diff --git a/backend/scripts/sql/bep_kpi_defs_bohai.sql b/backend/scripts/sql/bep_kpi_defs_bohai.sql new file mode 100644 index 00000000..6fd32641 --- /dev/null +++ b/backend/scripts/sql/bep_kpi_defs_bohai.sql @@ -0,0 +1,86 @@ +-- CMA BEP(保本点) 派生KPI定义 — 博海 entity_id=2 — cma-bep-kpi-20261003 +-- 任务: finance-20261003-cvp-fix-and-bohai-coverage-fullstack.md(放行出处 任总 2026-10-03 11:07/11:13 CST DM) +-- 口径(博海新口径,勿套酣客渠补公式): +-- 变动成本率 = Σ6401借方 ÷ Σ6001贷方(主营业务成本率;博海无渠补科目571103) +-- 固定成本 = (6601销售费用 + 6602管理费用 + 6603财务费用) 借−贷净额月均 +-- 折旧剔除 = 1602 累计折旧「当期贷方发生额」(现金口径;博海科目表只到4位一级,无费用类折旧叶子) +-- 剔除期间损益结转腿(summary LIKE '%结转%'):博海该账套 carry_forward 恒 0,不剔除则已结账月份净额恒 0 +-- 单位/阈值与 entity 1 保持一致;仅新增,幂等(NOT EXISTS 守卫),不触碰 entity 1 定义 + +INSERT INTO kpi_definitions +(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc, + data_source_type, data_source, data_owner, frequency, unit, target_value, + target_calc_type, threshold_green, threshold_yellow, threshold_red, + kpi_level, status, bot_source, epic, created_by) +SELECT 2, 'F_BEP_REVENUE', '保本销售额', 'finance', 'profitability', + 'round(月固定成本(万元) / (1 - 变动成本率), 1)', + '账面口径=月固定成本((6601+6602+6603)借−贷净额月均, 剔除结转账腿)÷边际贡献率(1-变动成本率); 中性参考值, 越高需更多销售覆盖', + 'derived', 'BEP派生器 kpi_bep_deriver.py(voucher_details 6601/6602/6603 月均)', '财务部', 'monthly', '万元', 0, + 'ratio', NULL, NULL, NULL, + 'strategic', 'active', 'finance-bot', 'Epic2', 1 +WHERE NOT EXISTS (SELECT 1 FROM kpi_definitions WHERE entity_id=2 AND kpi_code='F_BEP_REVENUE'); + +INSERT INTO kpi_definitions +(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc, + data_source_type, data_source, data_owner, frequency, unit, target_value, + target_calc_type, threshold_green, threshold_yellow, threshold_red, + kpi_level, status, bot_source, epic, created_by) +SELECT 2, 'F_BEP_COVER', '保本覆盖率', 'finance', 'profitability', + 'round(实际月销/保本销售额*100,1)', + '实际月销(6001贷方月均,剔除结转账腿)÷保本销售额; 正向指标: >=100绿 >=85黄 <85红', + 'derived', 'BEP派生器 kpi_bep_deriver.py(voucher_details 6001月均)', '财务部', 'monthly', '%', 100, + 'ratio', '>=100', '>=85', '<85', + 'strategic', 'active', 'finance-bot', 'Epic2', 1 +WHERE NOT EXISTS (SELECT 1 FROM kpi_definitions WHERE entity_id=2 AND kpi_code='F_BEP_COVER'); + +INSERT INTO kpi_definitions +(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc, + data_source_type, data_source, data_owner, frequency, unit, target_value, + target_calc_type, threshold_green, threshold_yellow, threshold_red, + kpi_level, status, bot_source, epic, created_by) +SELECT 2, 'F_BEP_GAP', '保本缺口', 'finance', 'profitability', + 'round((保本销售额-实际月销)/保本销售额*100,1)', + '反向指标(缺口越小越好): <=0绿(已保本) <=15黄 >15红', + 'derived', 'BEP派生器 kpi_bep_deriver.py(voucher_details 6001月均)', '财务部', 'monthly', '%', 0, + 'ratio', '<=0', '<=15', '>15', + 'strategic', 'active', 'finance-bot', 'Epic2', 1 +WHERE NOT EXISTS (SELECT 1 FROM kpi_definitions WHERE entity_id=2 AND kpi_code='F_BEP_GAP'); + +INSERT INTO kpi_definitions +(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc, + data_source_type, data_source, data_owner, frequency, unit, target_value, + target_calc_type, threshold_green, threshold_yellow, threshold_red, + kpi_level, status, bot_source, epic, created_by) +SELECT 2, 'F_BEP_REVENUE_CASH', '现金口径保本销售额', 'finance', 'profitability', + 'round((月固定成本月均-月折旧月均)/(1-变动成本率),1)', + '现金口径=月固定成本(6601+6602+6603 净额月均, 剔除折旧1602当期贷方发生额)÷边际贡献率; 中性参考值, 无阈值', + 'derived', 'BEP派生器 kpi_bep_deriver.py(voucher_details 6601/6602/6603/1602月均)', '财务部', 'monthly', '万元', 0, + 'ratio', NULL, NULL, NULL, + 'strategic', 'active', 'finance-bot', 'Epic2', 1 +WHERE NOT EXISTS (SELECT 1 FROM kpi_definitions WHERE entity_id=2 AND kpi_code='F_BEP_REVENUE_CASH'); + +INSERT INTO kpi_definitions +(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc, + data_source_type, data_source, data_owner, frequency, unit, target_value, + target_calc_type, threshold_green, threshold_yellow, threshold_red, + kpi_level, status, bot_source, epic, created_by) +SELECT 2, 'F_BEP_COVER_CASH', '现金口径保本覆盖率', 'finance', 'profitability', + 'round(实际月销/现金口径保本额*100,1)', + '实际月销(6001贷方月均)÷现金口径保本额; 正向指标: >=100绿 >=85黄 <85红', + 'derived', 'BEP派生器 kpi_bep_deriver.py(voucher_details 6001/6601/6602/6603/1602月均)', '财务部', 'monthly', '%', 100, + 'ratio', '>=100', '>=85', '<85', + 'strategic', 'active', 'finance-bot', 'Epic2', 1 +WHERE NOT EXISTS (SELECT 1 FROM kpi_definitions WHERE entity_id=2 AND kpi_code='F_BEP_COVER_CASH'); + +INSERT INTO kpi_definitions +(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc, + data_source_type, data_source, data_owner, frequency, unit, target_value, + target_calc_type, threshold_green, threshold_yellow, threshold_red, + kpi_level, status, bot_source, epic, created_by) +SELECT 2, 'F_BEP_REBATE', '变动成本率(主营业务成本率)', 'finance', 'profitability', + 'round(sum(6401借方)/sum(6001贷方)*100,2)', + '变动成本率(实测,YTD累计)=Σ6401借方÷Σ6001贷方(剔结转账腿); 博海无渠补科目571103, 故不套酣客渠补公式; 供保本点卡面单一事实源, 无阈值', + 'derived', 'BEP派生器 kpi_bep_deriver.py(voucher_details 6401/6001 累计)', '财务部', 'monthly', '%', 0, + 'ratio', NULL, NULL, NULL, + 'strategic', 'active', 'finance-bot', 'Epic2', 1 +WHERE NOT EXISTS (SELECT 1 FROM kpi_definitions WHERE entity_id=2 AND kpi_code='F_BEP_REBATE'); diff --git a/frontend/src/components/BEPBreakEvenCard.vue b/frontend/src/components/BEPBreakEvenCard.vue index 4cc10a35..298b96b5 100644 --- a/frontend/src/components/BEPBreakEvenCard.vue +++ b/frontend/src/components/BEPBreakEvenCard.vue @@ -1,6 +1,25 @@