diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py index 5e624815..65fe743a 100644 --- a/backend/app/api/dashboard.py +++ b/backend/app/api/dashboard.py @@ -29,16 +29,25 @@ router = APIRouter(prefix="/api/cma/dashboard", tags=["驾驶舱"], # 月销 = voucher_details subject 5001(主营业务收入) 贷方 YTD 月均(万元) # 月管理费 = 5602% 借−贷 净额 YTD 月均(carry_forward=0 行域;账面口径含折旧) # 折旧 = 560213 借−贷 净额 YTD 月均(同上;口径4 见 _decisions/cma-expense-profit-net-caliber-20261002.md) -# 渠补率 = 厂家政策 84.61%(财务Bot权威口径) +# 渠补率 = **实测累计**(Σ571103借方 ÷ Σ5001贷方,YTD)——取自派生落库值 F_BEP_REBATE(单一事实源) # 保本额 = 月固定成本 ÷ (1-渠补率) +# 2026-10-02 口径修订(任总 11:43 CST 放行「改,这样数据更加真实」): +# ① 渠补率不再用政策常量 84.61 参与计算,改用派生落库的实测累计值; +# 政策常量仅作对照返回(inputs.rebate_policy_pct),**不参与计算**; +# ② 账面/现金两条路径**统一读派生落库值**——此前 book_bep 实时复算 voucher、 +# cash_bep 读派生值,9 月数据入库而派生器未跑时同卡两口径不同步; +# ③ fail-closed:六条派生值(含渠补率)**同期齐全**才出卡;缺数 ⇒ available=False, +# 不出保本值、不回落政策常量(不跨期回落,避免混期口径)。 # 账面口径(含折旧) / 现金口径(剔除折旧) 双口径只读返回,无写操作。 -# 现金口径数值优先读派生KPI(F_BEP_REVENUE_CASH/F_BEP_COVER_CASH)最新落库值,无派生值时回退现算。 BEP_CODES = ("F_BEP_REVENUE", "F_BEP_COVER", "F_BEP_GAP", - "F_BEP_REVENUE_CASH", "F_BEP_COVER_CASH") + "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_SALES_SUBJECT = "5001" BEP_ADMIN_PREFIX = "5602" BEP_DEP_SUBJECT = "560213" +# 政策常量:仅作对照展示(2026-10-02 起不参与任何计算) BEP_REBATE_POLICY_PCT = 84.61 @@ -98,7 +107,13 @@ def get_bep_card( db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id), ): - """本量利·保本点 卡片数据(只读,A区)——账面/现金双口径""" + """本量利·保本点 卡片数据(只读,A区)——账面/现金双口径 + + 2026-10-02 口径修订(任总 11:43 CST 放行): + · 渠补率 = 派生落库的**实测累计值**(F_BEP_REBATE,单一事实源);政策常量仅对照返回; + · 账面 / 现金两条路径**统一读派生落库值**(不再实时复算 voucher); + · fail-closed:六条派生值同期不齐(尤其缺渠补率)⇒ available=False,不出保本值、不回落常量。 + """ kpis = {k.kpi_code: k for k in db.query(KPIDefinition).filter( KPIDefinition.entity_id == entity_id, KPIDefinition.status == "active", @@ -109,60 +124,70 @@ def get_bep_card( return {"available": False, "message": "BEP账面口径派生KPI未完整注册", "found": list(kpis.keys()), "missing": missing_book} - # 最新派生期间与值(同派生器:source_type='derived');现金2条缺失不阻塞整卡 - latest = {} - for code in BEP_CODES: - if code not in kpis: - latest[code] = None - continue - v = db.query(KPIValue).filter( - KPIValue.kpi_id == kpis[code].id, - KPIValue.source_type == "derived", - KPIValue.actual_value.isnot(None), - month_period_clause(KPIValue.period), - ).order_by(KPIValue.period.desc(), KPIValue.id.desc()).first() - latest[code] = {"period": v.period, "value": v.actual_value} if v else None + # 派生落库值(同派生器:source_type='derived')——按 code × period 建表,period desc 排序 + # ⚠️ 不过滤 actual_value IS NULL:fail-closed 落库的 pending 期(值 NULL)必须参与「最新期」判定, + # 否则卡面会悄悄跨期回落到上一期,掩盖「本期不出值」的事实。 + rows = db.query(KPIValue, KPIDefinition.kpi_code).join( + KPIDefinition, KPIDefinition.id == KPIValue.kpi_id, + ).filter( + KPIDefinition.entity_id == entity_id, + KPIDefinition.kpi_code.in_(BEP_CODES), + KPIValue.source_type == "derived", + month_period_clause(KPIValue.period), + ).order_by(KPIValue.period.desc(), KPIValue.id.desc()).all() + + by_code = {} # {code: {period: value|None}} + for v, code in rows: + by_code.setdefault(code, {}).setdefault(v.period, v.actual_value) + + # 卡面期 = 账面三条派生行的**最新期间**(与派生器目标期一致) + book_periods = sorted({p for c in BEP_BOOK_CODES for p in by_code.get(c, {})}, reverse=True) + if not book_periods: + return {"available": False, "entity_id": entity_id, + "message": "尚无派生值,请先运行 kpi_bep_deriver.py", + "rebate_policy_pct": BEP_REBATE_POLICY_PCT, "readonly": True} + period = book_periods[0] + + # fail-closed:六条派生值(含实测渠补率)本期必须齐全,缺一条即不出保本值 + missing_at_period = [c for c in BEP_CODES if by_code.get(c, {}).get(period) is None] + if missing_at_period: + if BEP_REBATE_CODE in missing_at_period: + msg = ("渠补率(实测)不可得:本期未落 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, + "derived_periods": book_periods[:6], "readonly": True} - periods = [latest[c]["period"] for c in BEP_BOOK_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, + # 六条派生落库值(同期)—— 账面 / 现金两条路径同一来源,卡面不做实时复算 + d = {c: by_code[c][period] for c in BEP_CODES} + book_bep = float(d["F_BEP_REVENUE"]) + book_cover = float(d["F_BEP_COVER"]) + book_gap = float(d["F_BEP_GAP"]) + cash_bep = float(d["F_BEP_REVENUE_CASH"]) + cash_cover = float(d["F_BEP_COVER_CASH"]) + cash_gap = round(100.0 - cash_cover, 1) # 缺口 = 100 − 覆盖率(与派生器同口径) + 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_to, + 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_to, + dep_avg = _bep_sum_field(db, entity_id, period_from, period, "net", ("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 - # 现金口径:管理费-折旧;优先读派生KPI最新落库值(单一事实源),缺失时回退现算 cash_admin = admin_avg - dep_avg - cash_derived_bep = latest["F_BEP_REVENUE_CASH"]["value"] if latest.get("F_BEP_REVENUE_CASH") else None - cash_derived_cover = latest["F_BEP_COVER_CASH"]["value"] if latest.get("F_BEP_COVER_CASH") else None - if cash_derived_bep is not None and cash_derived_cover is not None: - cash_bep = cash_derived_bep - cash_cover = cash_derived_cover - else: - 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 - # 回显最新落库派生值(账面3条+现金2条,存在时输出) - derived = {} - for code in BEP_CODES: - lv = latest[code] - derived[code] = {"period": lv["period"], "value": lv["value"]} if lv else None + # 回显六条派生落库值(同期) + derived = {c: {"period": period, "value": d[c]} for c in BEP_CODES} cover_kpi = kpis["F_BEP_COVER"] book_level = _bep_level(book_cover, cover_kpi) @@ -182,7 +207,9 @@ def get_bep_card( "admin_avg": round(admin_avg, 2), "dep_avg": round(dep_avg, 2), "rebate_pct": rebate_pct, - "cm_pct": round(cm_pct, 2), + "rebate_policy_pct": BEP_REBATE_POLICY_PCT, # 政策常量:仅作对照,不参与计算 + "rebate_source": "derived:F_BEP_REBATE(Σ571103借方÷Σ5001贷方, YTD累计)", + "cm_pct": cm_pct, }, "book": { "bep_revenue": book_bep, @@ -200,7 +227,8 @@ def get_bep_card( "gap_level": cash_gap_level, }, "derived": derived, - "formula": "保本销售额 = 月固定成本 ÷ (1 − 渠补率84.61%)", + "data_source": "derived", # 账面/现金同源:均读派生落库值 + "formula": f"保本销售额 = 月固定成本 ÷ (1 − 实测渠补率{rebate_pct:.2f}%)", "readonly": True, } diff --git a/backend/scripts/kpi_bep_deriver.py b/backend/scripts/kpi_bep_deriver.py index 0429f92a..ed6903be 100644 --- a/backend/scripts/kpi_bep_deriver.py +++ b/backend/scripts/kpi_bep_deriver.py @@ -6,13 +6,20 @@ F_BEP_GAP 保本缺口(%) = (保本销售额-月均销售) ÷ 保本销售额 × 100 (反向: <=0绿 <=15黄 >15红) F_BEP_REVENUE_CASH 现金保本销售额(万元) = (月均管理费-月均折旧) ÷ 边际贡献率 (现金口径,剔除折旧560213,中性无阈值) F_BEP_COVER_CASH 现金保本覆盖率(%) = 月均销售 ÷ 现金保本销售额 × 100 (正向: >=100绿 >=85黄 <85红) + F_BEP_REBATE 实测渠补率(%) = Σ(571103借方) ÷ Σ(5001贷方) × 100 (YTD累计; 卡面单一事实源,无阈值) 口径(与财务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误含) -- 渠补率: 厂家政策定死 84.61% (571103渠补支出/5001销售, 1-7月累计凭证验证≈84.0%~84.6%) -- 边际贡献率 = 1 - 渠补率 = 15.39% +- 渠补率: **实测累计口径**(2026-10-02 财务裁定,任总 11:43 CST 放行「改,这样数据更加真实」) + 渠补率(实测) = Σ(571103 借方) ÷ Σ(5001 贷方) × 100,期间 = 年初至当期(YTD),保留 2 位小数。 + `POLICY_REBATE_RATE_PCT = 84.61` 政策常量**仅作对照展示**(写进返回值 rebate_policy_pct / remark), + **不参与任何计算**;`--rebate-rate` 显式传值时视为人工诊断覆盖(正常调用不再使用)。 +- 边际贡献率 = 100 − 渠补率(实测) +- fail-closed 边界(2026-10-02 裁定):某期 571103 或 5001 缺数/为 0 ⇒ **该期不出保本值** + (六条派生行写 actual_value=NULL + data_status='pending' + remark 注明「渠补率不可得」), + **不得静默回落 84.61 常量**;累计口径天然平滑单月波动(8 月单月 571103/5001=134% 不影响累计值)。 - 目标月: --period 显式指定;默认自动选最近「已结账」完整月(当月跳过)。 结账信号优先:DB 损益结转凭证(carry_forward=1 & subject_code='3103') → check.md 文件; 两者皆无时才退回旧「销售额阈值」兜底(销售低 ≠ 未结账,仅保底用,日志标注依据) @@ -48,6 +55,8 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(me logger = logging.getLogger("cma.bep_deriver") # 厂家政策渠补率(定死不可谈判,财务Bot报告权威值 2026-09-08) +# ⚠️ 2026-10-02 起**仅作对照展示**(返回值 rebate_policy_pct / remark),不参与任何计算。 +# 参与计算的渠补率 = 逐期实测累计(见 compute_rebate_rate / 文件头「渠补率」条)。 POLICY_REBATE_RATE_PCT = 84.61 # 派生KPI 编码 → (kpi_code, 名称) @@ -57,8 +66,15 @@ TARGET_KPIS = [ ("F_BEP_GAP", "保本缺口"), ("F_BEP_REVENUE_CASH", "现金口径保本销售额"), ("F_BEP_COVER_CASH", "现金口径保本覆盖率"), + ("F_BEP_REBATE", "实测渠补率"), ] +# 实测渠补率 KPI 编码(卡面单一事实源;无阈值,不参与预警级别判定) +REBATE_KPI = "F_BEP_REBATE" + +# 保本口径 KPI(缺 F_BEP_REBATE 定义时不阻断派生主流程,仅跳过渠补率落库并告警) +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"} @@ -161,6 +177,40 @@ def _sum_field(db, entity_id: int, period_from: str, period_to: str, return float(val or 0.0) +def compute_rebate_rate(db, entity_id: int, period_from: str, period_to: str) -> dict: + """实测累计渠补率(2026-10-02 财务裁定口径)。 + + 渠补率(实测) = Σ(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 + (调用方不得回落政策常量,须按「渠补率不可得」处理)。 + """ + 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 + + def _month_sales(db, entity_id: int, period: str) -> float: """单月销售收入(万元)——用于目标月完整度判断""" return _sum_field(db, entity_id, period, period, "credit_amount", @@ -287,17 +337,28 @@ def _eval_static_threshold(value: float, kpi) -> tuple: return None, None -def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float, +def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float = None, write_kpi: bool = True) -> dict: - """计算并落库五个派生KPI(账面3条+现金2条)。返回结果摘要。""" + """计算并落库六个派生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)。 + """ 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]), KPIDefinition.status == "active", ).all()} - missing = [c for c, _ in TARGET_KPIS if c not in kpis] + missing = [c for c in CORE_KPI_CODES if c not in kpis] 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) year = period[:4] period_from = f"{year}-01" @@ -307,7 +368,6 @@ def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float, # 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 - rebate_sum_w = _sum_field(db, entity_id, period_from, period_to, "net", ("eq", REBATE_CODE)) / 10000.0 dep_sum_w = _sum_field(db, entity_id, period_from, period_to, "net", ("eq", DEP_SUBJECT)) / 10000.0 sales_avg = sales_sum_w / months @@ -315,11 +375,22 @@ def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float, dep_avg = dep_sum_w / months cash_admin_avg = admin_avg - dep_avg # 现金口径固定成本(剔除折旧纸面费用) - # 2. 渠补率:厂家政策值优先,日志附凭证累计校验 - calc_rebate = (rebate_sum_w / sales_sum_w * 100.0) if sales_sum_w > 0 else 0.0 - if rebate_rate_pct is None: - rebate_rate_pct = POLICY_REBATE_RATE_PCT - cm_pct = 100.0 - rebate_rate_pct # 边际贡献率(%) + # 2. 渠补率:默认逐期实测累计(不再取政策常量;常量仅对照) + rebate_info = compute_rebate_rate(db, entity_id, period_from, period_to) + 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, + ("%.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) + + rebate_pct = rebate_info["rebate_pct"] + cm_pct = 100.0 - rebate_pct # 边际贡献率(%) = 100 − 实测渠补率 # 3. 派生计算(万元 / %)——账面3条路径不变;现金口径在账面基础上剔除折旧 bep = admin_avg / (cm_pct / 100.0) if cm_pct > 0 else 0.0 @@ -334,6 +405,7 @@ def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float, "F_BEP_GAP": round(gap, 1), "F_BEP_REVENUE_CASH": round(cash_bep, 1), "F_BEP_COVER_CASH": round(cash_cover, 1), + "F_BEP_REBATE": round(rebate_pct, 2), } # 4. upsert kpi_values (source_type='derived',幂等) @@ -342,12 +414,21 @@ def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float, value_rows = {} changed = [] for code, _ in TARGET_KPIS: - kpi = kpis[code] - if code in CASH_CODES: + kpi = kpis.get(code) + if kpi is None: # 定义缺失(仅可能为 F_BEP_REBATE,见上方告警) + 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}% 仅供对照,不参与计算)") + 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"=固定现金{cash_admin_avg:.2f}万 ÷ 边际贡献率{cm_pct:.2f}% → 保本{cash_bep:.2f}万" + f"(实测渠补率{rebate_pct:.2f}%)") else: - remark = f"保本派生器: 销售月均{sales_avg:.1f}万/管理费月均{admin_avg:.1f}万/渠补率{rebate_rate_pct:.2f}%" + remark = (f"保本派生器: 销售月均{sales_avg:.1f}万/管理费月均{admin_avg:.1f}万/" + f"实测渠补率{rebate_pct:.2f}%(政策{POLICY_REBATE_RATE_PCT:.2f}%仅对照)") existing = db.query(KPIValue).filter( KPIValue.kpi_id == kpi.id, KPIValue.period == period, @@ -437,6 +518,7 @@ def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float, "entity_id": entity_id, "period": period, "months": months, + "data_status": "ok", "sales_sum_w": round(sales_sum_w, 2), "sales_avg": round(sales_avg, 2), "admin_sum_w": round(admin_sum_w, 2), @@ -444,9 +526,12 @@ def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float, "dep_sum_w": round(dep_sum_w, 2), "dep_avg": round(dep_avg, 2), "cash_admin_avg": round(cash_admin_avg, 2), - "rebate_sum_w": round(rebate_sum_w, 2), - "rebate_policy_pct": rebate_rate_pct, - "rebate_calc_pct": round(calc_rebate, 2), + "rebate_sum_w": rebate_info["rebate_sum_w"], + "rebate_sales_base_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 "YTD实测累计(Σ571103借方/Σ5001贷方)", + "rebate_calc_pct": round(calc_pct, 2) if calc_pct is not None else None, "cm_pct": round(cm_pct, 2), "values": values, "written": written, @@ -454,12 +539,77 @@ def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float, } +def _fail_closed_rebate_unavailable(db, entity_id: int, period: str, months: int, + rebate_info: dict, kpis: dict, + write_kpi: bool) -> dict: + """fail-closed 落库:实测渠补率不可得 ⇒ 该期不出保本值。 + + 六条派生行写 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}%)") + batch = f"bep-{datetime.now().strftime('%Y%m%d%H%M%S')}" + written = {} + if write_kpi: + for code, _ in TARGET_KPIS: + kpi = kpis.get(code) + if kpi is None: + written[code] = "no-definition" + continue + 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=None, + source_type="derived", + source_batch=batch, + data_status="pending", + remark=detail, + )) + written[code] = "insert" + else: + existing.actual_value = None + existing.source_batch = batch + existing.data_status = "pending" + existing.remark = detail + written[code] = "upsert" + db.commit() + else: + written = {c: "dry-run" for c, _ in TARGET_KPIS} + return { + "entity_id": entity_id, + "period": period, + "months": months, + "data_status": "pending", + "rebate_unavailable": True, + "reason": detail, + "sales_sum_w": rebate_info["sales_sum_w"], + "rebate_sum_w": rebate_info["rebate_sum_w"], + "rebate_actual_pct": None, + "rebate_policy_pct": POLICY_REBATE_RATE_PCT, + "rebate_source": reason, + "values": {}, + "written": written, + "value_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("--rebate-rate", type=float, default=None, - help="渠补率%覆盖(默认厂家政策84.61)") + help="渠补率%%人工覆盖(诊断用;**默认按 YTD 从 voucher_details 实测现算**,不再取政策常量)") ap.add_argument("--dry-run", action="store_true", help="只计算不落库") args = ap.parse_args() @@ -469,18 +619,24 @@ def main(): 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']} " - f"(YTD {r['months']}个月) mode={'dry-run' if args.dry_run else 'write'}") + 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']}") + 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}万 | 渠补率: 政策{r['rebate_policy_pct']:.2f}% " - f"(凭证累计校验 {r['rebate_calc_pct']:.2f}%) | 边际贡献率 {r['cm_pct']:.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" 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" 落库: {r['written']}") finally: db.close() diff --git a/backend/scripts/sql/bep_kpi_defs_rebate_20261002.sql b/backend/scripts/sql/bep_kpi_defs_rebate_20261002.sql new file mode 100644 index 00000000..11784f69 --- /dev/null +++ b/backend/scripts/sql/bep_kpi_defs_rebate_20261002.sql @@ -0,0 +1,20 @@ +-- CMA BEP 实测渠补率派生KPI定义 — F_BEP_REBATE(2026-10-02) +-- 口径: 渠补率(实测) = Σ(571103 借方) ÷ Σ(5001 贷方) × 100,期间 = 年初至当期(YTD),2位小数 +-- 依据: 财务Bot 2026-10-02 裁定(任总 11:43 CST 放行「改,这样数据更加真实」) +-- 政策常量 84.61% 自本日起仅作对照展示,不再参与计算 +-- 幂等: NOT EXISTS 守卫,可安全重跑 +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 1, 'F_BEP_REBATE', '实测渠补率', 'finance', 'profitability', + 'round(sum(571103借方)/sum(5001贷方)*100,2)', + '渠补率(实测,YTD累计)=Σ571103借方÷Σ5001贷方; 供保本点卡面单一事实源; 政策常量84.61%仅对照; 缺数期不出值(fail-closed)', + 'derived', 'BEP派生器 kpi_bep_deriver.py(voucher_details 571103/5001 累计)', '财务部', 'monthly', '%', NULL, + 'ratio', NULL, NULL, NULL, + 'strategic', 'active', 'finance-bot', 'Epic2', 1 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM kpi_definitions WHERE entity_id = 1 AND kpi_code = 'F_BEP_REBATE' +); diff --git a/backend/tests/test_bep_rebate_actual_caliber.py b/backend/tests/test_bep_rebate_actual_caliber.py new file mode 100644 index 00000000..00f6c3f5 --- /dev/null +++ b/backend/tests/test_bep_rebate_actual_caliber.py @@ -0,0 +1,347 @@ +"""BEP 渠补率改「实测累计」口径 单测 — finance-20261002-bep-rebate-actual-caliber-fullstack + +口径依据(财务Bot 2026-10-02 裁定,任总 11:43 CST 放行「改,这样数据更加真实」): + 渠补率(实测) = Σ(571103 **借方**) ÷ Σ(5001 **贷方**) × 100,期间 = 年初至当期(YTD),2 位小数; + 政策常量 84.61% **仅作对照展示**,不参与任何计算; + fail-closed:571103 或 5001 缺数/为 0 ⇒ 该期**不出保本值**(pending + NULL),不静默回落常量。 + +覆盖两面: + · scripts/kpi_bep_deriver.py —— 派生落库写入面(去常量、YTD 现算、fail-closed) + · app/api/dashboard.py::/bep-card —— 卡面读取面(rebate_pct 读派生值、book/cash 同源读派生值) + +夹具数值刻意复刻财务复算口径,使「改前(84.61%常量) / 改后(85.49%实测)」两套输出可逐项区分: + 2026-01..07:Σ5001贷 = 794.35 万 | Σ571103借 = 672.10 万 ⇒ 渠补率 84.61%(= 常量,故该期值不变) + 2026-01..08:Σ5001贷 = 808.79 万 | Σ571103借 = 691.43 万 ⇒ 渠补率 85.49% + 月均管理费(含折旧) 25.774 万 / 现金固定成本月均 18.2605 万(8 个月) + ⇒ 改前(15.39%):保本 167.5 / 118.7,覆盖 60.4% / 85.2% + ⇒ 改后(14.51%):保本 177.6 / 125.8,覆盖 56.9% / 80.3% +""" +import datetime +import json +import os +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# 值变复核提示件默认落 finance 的 _reports/;测试期重定向到临时目录(防污染对端 profile) +os.environ["BEP_REVIEW_NOTE_DIR"] = tempfile.mkdtemp(prefix="bep-test-review-notes-") + +import pytest # noqa: E402 + +from app.models import KPIDefinition, KPIValue, VoucherDetail # noqa: E402 +from app.api.dashboard import get_bep_card # noqa: E402 +from scripts import kpi_bep_deriver as D # noqa: E402 + +ENTITY = 1 +D_DATE_07 = datetime.datetime(2026, 7, 31) +D_DATE_08 = datetime.datetime(2026, 8, 31) + +# ── 财务复算锚值(单位:元)──────────────────────────────────────────────── +# 2026-01..07 累计 +M07 = dict(period="2026-07", d=D_DATE_07, + sales=7943500.0, rebate=6721000.0, admin_other=1307810.0, dep=526050.0) +# 2026-08 单月增量(累计口径 01..08 = M07 + M08) +M08 = dict(period="2026-08", d=D_DATE_08, + sales=144400.0, rebate=193300.0, admin_other=153030.0, dep=75030.0) + +KPI_CODES = ( + ("F_BEP_REVENUE", "保本销售额", "万元", None, None, None), + ("F_BEP_COVER", "保本覆盖率", "%", ">=100", ">=85", "<85"), + ("F_BEP_GAP", "保本缺口", "%", "<=0", "<=15", ">15"), + ("F_BEP_REVENUE_CASH", "现金口径保本销售额", "万元", None, None, None), + ("F_BEP_COVER_CASH", "现金口径保本覆盖率", "%", ">=100", ">=85", "<85"), + ("F_BEP_REBATE", "实测渠补率", "%", None, None, None), +) + +_seq = [700000] + + +def _voucher(db, row, subject_code, debit=0.0, credit=0.0, cf=0): + _seq[0] += 1 + db.add(VoucherDetail(entity_id=ENTITY, voucher_no=str(_seq[0]), + voucher_date=row["d"], subject_code=subject_code, + subject_name="单测科目", debit_amount=debit, + credit_amount=credit, summary="渠补率口径单测", + carry_forward=cf, period=row["period"])) + db.flush() + + +def _month_rows(db, row, rebate_credit=0.0): + """一个月的凭证行:收入(5001贷) / 渠补(571103借) / 管理费(5602% 含折旧 560213)""" + _voucher(db, row, "5001", credit=row["sales"]) + if row["rebate"] is not None: + _voucher(db, row, "571103", debit=row["rebate"]) # 含显式 0 行(「存在但为 0」负例) + # 571103 贷方冲销行(负对照:证明实现走**借方**口径而非净额) + if rebate_credit: + _voucher(db, row, "571103", credit=rebate_credit) + _voucher(db, row, "560201", debit=row["admin_other"]) # 管理费其他明细 + _voucher(db, row, "560213", debit=row["dep"]) # 管理费用-折旧(含在 5602% 内) + + +def _kpi_defs(db, include_rebate=True): + for code, name, unit, g, y, r in KPI_CODES: + if code == "F_BEP_REBATE" and not include_rebate: + continue + db.add(KPIDefinition(entity_id=ENTITY, kpi_code=code, kpi_name=name, + dimension="finance", category="profitability", + formula="单测", data_source="单测夹具", data_owner="财务部", + frequency="monthly", unit=unit, + threshold_green=g, threshold_yellow=y, threshold_red=r, + kpi_level="strategic", status="active")) + db.flush() + + +def _fixture_01_08(db, rebate_credit=0.0): + _kpi_defs(db) + _month_rows(db, M07) + _month_rows(db, M08, rebate_credit=rebate_credit) + db.flush() + + +def _derived_rows(db, period="2026-08"): + out = {} + for code, *_ in KPI_CODES: + kpi = db.query(KPIDefinition).filter(KPIDefinition.entity_id == ENTITY, + KPIDefinition.kpi_code == code).first() + if kpi is None: + continue + v = db.query(KPIValue).filter(KPIValue.kpi_id == kpi.id, + KPIValue.period == period, + KPIValue.source_type == "derived").first() + if v is not None: + out[code] = v + return out + + +# ── 一、派生器:实测累计口径 ──────────────────────────────────────────────── + +def test_compute_rebate_rate_uses_ytd_actual(db): + """实测渠补率 = Σ571103借方 ÷ Σ5001贷方(YTD 累计,2 位小数)""" + _fixture_01_08(db) + info_07 = D.compute_rebate_rate(db, ENTITY, "2026-01", "2026-07") + info_08 = D.compute_rebate_rate(db, ENTITY, "2026-01", "2026-08") + assert info_07["available"] is True + assert abs(info_07["rebate_pct"] - 84.61) < 0.005, info_07 + assert abs(info_07["rebate_sum_w"] - 672.10) < 0.01, info_07 + assert abs(info_07["sales_sum_w"] - 794.35) < 0.01, info_07 + assert info_08["available"] is True + assert abs(info_08["rebate_pct"] - 85.49) < 0.005, info_08 + + +def test_deriver_debit_side_negative_control(db): + """571103 取**借方**而非净额(负对照):夹具含 10 万贷方冲销行。 + + 若实现退回「净额」口径 ⇒ 渠补率 = (691.43-10)/808.79 = 84.25% ≠ 85.49%,本用例必红。 + """ + _fixture_01_08(db, rebate_credit=100000.0) + info = D.compute_rebate_rate(db, ENTITY, "2026-01", "2026-08") + assert abs(info["rebate_pct"] - 85.49) < 0.005, info + net_w = D._sum_field(db, ENTITY, "2026-01", "2026-08", "net", ("eq", D.REBATE_CODE)) / 10000.0 + assert abs(net_w - 681.43) < 0.01, net_w # 净额口径 = 691.43 − 10 + assert net_w != info["rebate_sum_w"] # 两种口径在本夹具下必然不同 + + +def test_deriver_2026_08_values_follow_actual_rebate(db): + """派生器落库:改后 177.6 / 125.8 / 56.9 / 80.3,渠补率 85.49(政策常量仅对照)""" + _fixture_01_08(db) + r = D.run_derive(db, ENTITY, "2026-08", None, write_kpi=True) + assert r["data_status"] == "ok", r + assert abs(r["rebate_actual_pct"] - 85.49) < 0.005, r + assert r["rebate_policy_pct"] == 84.61 # 常量保留(仅对照) + assert abs(r["values"]["F_BEP_REVENUE"] - 177.6) < 0.05, r["values"] + assert abs(r["values"]["F_BEP_REVENUE_CASH"] - 125.8) < 0.05, r["values"] + assert abs(r["values"]["F_BEP_COVER"] - 56.9) < 0.05, r["values"] + assert abs(r["values"]["F_BEP_COVER_CASH"] - 80.3) < 0.05, r["values"] + assert abs(r["values"]["F_BEP_REBATE"] - 85.49) < 0.005, r["values"] + # 边界外反证:若仍吃常量 84.61(旧口径)= 167.5 / 118.7 + assert r["values"]["F_BEP_REVENUE"] != 167.5, r["values"] + + rows = _derived_rows(db, "2026-08") + assert set(rows) == {c for c, *_ in KPI_CODES}, sorted(rows) + assert abs(float(rows["F_BEP_REBATE"].actual_value) - 85.49) < 0.005 + assert "571103" in rows["F_BEP_REBATE"].remark and "政策常量" in rows["F_BEP_REBATE"].remark + + +def test_deriver_2026_07_unchanged_regression(db): + """回归:1-7 月实测渠补率恰为 84.61% ⇒ 与政策常量路径逐项等值(170.2 / 121.4)""" + _kpi_defs(db) + _month_rows(db, M07) + db.flush() + r_auto = D.run_derive(db, ENTITY, "2026-07", None, write_kpi=True) + r_policy = D.run_derive(db, ENTITY, "2026-07", 84.61, write_kpi=True) + assert abs(r_auto["rebate_actual_pct"] - 84.61) < 0.005, r_auto + assert r_auto["values"] == r_policy["values"], (r_auto["values"], r_policy["values"]) + assert abs(r_auto["values"]["F_BEP_REVENUE"] - 170.2) < 0.05, r_auto["values"] + assert abs(r_auto["values"]["F_BEP_REVENUE_CASH"] - 121.4) < 0.05, r_auto["values"] + assert abs(r_auto["values"]["F_BEP_COVER"] - 66.7) < 0.05, r_auto["values"] + assert abs(r_auto["values"]["F_BEP_COVER_CASH"] - 93.5) < 0.05, r_auto["values"] + + +def test_deriver_policy_constant_not_used(db): + """政策常量不参与计算(负对照):渠补率 50% ⇒ 保本 = 月固定成本 ÷ 50%,而非 ÷ 15.39%""" + _kpi_defs(db) + row = dict(period="2026-08", d=D_DATE_08, sales=8000000.0, rebate=4000000.0, + admin_other=800000.0, dep=0.0) + _month_rows(db, row) + db.flush() + r = D.run_derive(db, ENTITY, "2026-08", None, write_kpi=False) + assert abs(r["rebate_actual_pct"] - 50.0) < 0.005, r + # admin_avg = 80万/8个月 = 10 万 ⇒ 10/0.5 = 20.0 万(常量口径会是 10/0.1539 = 65.0 万) + assert abs(r["values"]["F_BEP_REVENUE"] - 20.0) < 0.05, r["values"] + assert abs(r["values"]["F_BEP_REVENUE"] - 65.0) > 0.5, r["values"] + + +# ── 二、派生器:fail-closed(缺数/为 0 不出值、不回落常量)───────────────── + +def test_deriver_fail_closed_when_rebate_missing(db): + _kpi_defs(db) + r07 = dict(M07) + r07["rebate"] = None # 全年 571103 完全缺数 + r08 = dict(M08) + r08["rebate"] = None + _month_rows(db, r07) + _month_rows(db, r08) + db.flush() + r = D.run_derive(db, ENTITY, "2026-08", None, write_kpi=True) + assert r["data_status"] == "pending", r + assert r["rebate_actual_pct"] is None, r + assert "渠补率不可得" in r["reason"], r + rows = _derived_rows(db, "2026-08") + assert set(rows) == {c for c, *_ in KPI_CODES}, sorted(rows) + for code, v in rows.items(): + assert v.actual_value is None, (code, v.actual_value) # 不出保本值 + assert v.data_status == "pending", (code, v.data_status) + assert "渠补率不可得" in (v.remark or ""), (code, v.remark) + assert rows["F_BEP_REVENUE"].actual_value != 167.5 # 未回落常量口径 + + +def test_deriver_fail_closed_when_rebate_zero(db): + """571103 存在但合计为 0 ⇒ 同样 fail-closed(不得静默回落 84.61)""" + _kpi_defs(db) + r07 = dict(M07) + r07["rebate"] = 0.0 # 显式 0 行 + r08 = dict(M08) + r08["rebate"] = 0.0 + _month_rows(db, r07) + _month_rows(db, r08) + db.flush() + r = D.run_derive(db, ENTITY, "2026-08", None, write_kpi=True) + assert r["data_status"] == "pending", r + assert r["rebate_sum_w"] == 0.0, r + rows = _derived_rows(db, "2026-08") + assert rows["F_BEP_REVENUE"].actual_value is None + assert rows["F_BEP_REVENUE"].data_status == "pending" + + +# ── 三、卡面 /bep-card:rebate_pct 读派生值 + book/cash 同源 ─────────────── + +def test_card_reads_derived_values_and_rebate_pct(db): + _fixture_01_08(db) + D.run_derive(db, ENTITY, "2026-08", None, write_kpi=True) + + card = get_bep_card(db=db, entity_id=ENTITY) + assert card["available"] is True, card + assert card["period"] == "2026-08", card + assert abs(card["inputs"]["rebate_pct"] - 85.49) < 0.005, card["inputs"] + assert card["inputs"]["rebate_policy_pct"] == 84.61, card["inputs"] + assert abs(card["inputs"]["cm_pct"] - 14.51) < 0.005, card["inputs"] + assert abs(card["book"]["bep_revenue"] - 177.6) < 0.05, card["book"] + assert abs(card["book"]["cover"] - 56.9) < 0.05, card["book"] + assert abs(card["cash"]["bep_revenue"] - 125.8) < 0.05, card["cash"] + assert abs(card["cash"]["cover"] - 80.3) < 0.05, card["cash"] + assert card["data_source"] == "derived" + + +def test_card_book_and_cash_both_read_derived_rows(db): + """book / cash 同源回归:直接改库内派生值,两条路径都必须随之变(不再实时复算 voucher)""" + _fixture_01_08(db) + D.run_derive(db, ENTITY, "2026-08", None, write_kpi=True) + first = get_bep_card(db=db, entity_id=ENTITY) + assert abs(first["book"]["bep_revenue"] - 177.6) < 0.05 + assert abs(first["cash"]["bep_revenue"] - 125.8) < 0.05 + + # 临时改库内派生值(夹具库,非生产)——两路径应同步跟随 + for code, new_val in (("F_BEP_REVENUE", 999.9), ("F_BEP_REVENUE_CASH", 888.8), + ("F_BEP_REBATE", 60.0)): + kpi = db.query(KPIDefinition).filter(KPIDefinition.entity_id == ENTITY, + KPIDefinition.kpi_code == code).first() + row = db.query(KPIValue).filter(KPIValue.kpi_id == kpi.id, + KPIValue.period == "2026-08", + KPIValue.source_type == "derived").first() + row.actual_value = new_val + db.commit() + + second = get_bep_card(db=db, entity_id=ENTITY) + assert abs(second["book"]["bep_revenue"] - 999.9) < 0.001, second["book"] + assert abs(second["cash"]["bep_revenue"] - 888.8) < 0.001, second["cash"] + assert abs(second["inputs"]["rebate_pct"] - 60.0) < 0.001, second["inputs"] + assert abs(second["inputs"]["cm_pct"] - 40.0) < 0.001, second["inputs"] + # 反证:实时复算渠道(voucher 现值 177.6)不得再决定卡面值 + assert abs(second["book"]["bep_revenue"] - 177.6) > 1.0 + + +def test_card_fail_closed_when_derived_rebate_absent(db): + """派生渠补率不可得 ⇒ 卡面不出保本值(available=False),不回落常量、不跨期回落""" + _kpi_defs(db) + _month_rows(db, M07) + _month_rows(db, M08) + db.flush() + # 1) 07 期正常派生(当时 571103 数据齐全) + r07 = D.run_derive(db, ENTITY, "2026-07", None, write_kpi=True) + assert r07["data_status"] == "ok" + # 2) 全年 571103 缺数 ⇒ 08 期 fail-closed(已落 07 值不动) + db.query(VoucherDetail).filter( + VoucherDetail.entity_id == ENTITY, + VoucherDetail.subject_code == "571103", + ).delete(synchronize_session=False) + db.commit() + r08 = D.run_derive(db, ENTITY, "2026-08", None, write_kpi=True) + assert r08["data_status"] == "pending", r08 + + card = get_bep_card(db=db, entity_id=ENTITY) + # 08 期六条不齐(渠补率 NULL)⇒ 卡面不得跨期回落到 07 期,也不得回落常量 + assert card["available"] is False, card + assert card["period"] == "2026-08", card + assert "渠补率" in card["message"], card + assert card.get("rebate_policy_pct") == 84.61, card + assert "book" not in card + + +# ── 四、验收探针(自测判别力):同一用例在改前 / 改后两树上跑 ──────────────── +# 只依赖两版共有的 API(run_derive 返回的 values / get_bep_card),故可在 HEAD 快照树上复跑: +# 改前(政策常量 84.61%)⇒ FAIL,且失败信息里出现 167.5 / 118.7 +# 改后(实测 85.49%) ⇒ PASS,输出 177.6 / 125.8 / 56.9 / 80.3 / rebate_pct 85.49 +def test_acceptance_probe_2026_08(db): + """验收探针:2026-08 口径数值(财务复算预告逐项比对)""" + _fixture_01_08(db) + r = D.run_derive(db, ENTITY, "2026-08", None, write_kpi=True) + vals = r.get("values", {}) + card = get_bep_card(db=db, entity_id=ENTITY) + obs = { + "deriver.F_BEP_REVENUE": vals.get("F_BEP_REVENUE"), + "deriver.F_BEP_REVENUE_CASH": vals.get("F_BEP_REVENUE_CASH"), + "deriver.F_BEP_COVER": vals.get("F_BEP_COVER"), + "deriver.F_BEP_COVER_CASH": vals.get("F_BEP_COVER_CASH"), + "deriver.F_BEP_REBATE": vals.get("F_BEP_REBATE"), + "card.available": card.get("available"), + "card.inputs.rebate_pct": (card.get("inputs") or {}).get("rebate_pct"), + "card.inputs.rebate_policy_pct": (card.get("inputs") or {}).get("rebate_policy_pct"), + "card.book.bep_revenue": (card.get("book") or {}).get("bep_revenue"), + "card.book.cover": (card.get("book") or {}).get("cover"), + "card.cash.bep_revenue": (card.get("cash") or {}).get("bep_revenue"), + "card.cash.cover": (card.get("cash") or {}).get("cover"), + } + print("\n[ACCEPTANCE-PROBE] " + json.dumps(obs, ensure_ascii=False)) + checks = [ + ("deriver.F_BEP_REVENUE", obs["deriver.F_BEP_REVENUE"], 177.6, 0.2), + ("deriver.F_BEP_REVENUE_CASH", obs["deriver.F_BEP_REVENUE_CASH"], 125.8, 0.2), + ("deriver.F_BEP_COVER", obs["deriver.F_BEP_COVER"], 56.9, 0.3), + ("deriver.F_BEP_COVER_CASH", obs["deriver.F_BEP_COVER_CASH"], 80.3, 0.3), + ("card.book.bep_revenue", obs["card.book.bep_revenue"], 177.6, 0.2), + ("card.cash.bep_revenue", obs["card.cash.bep_revenue"], 125.8, 0.2), + ("card.inputs.rebate_pct", obs["card.inputs.rebate_pct"], 85.49, 0.02), + ] + for name, got, want, tol in checks: + assert got is not None, f"{name}: got None, want {want}±{tol}" + assert abs(float(got) - want) <= tol, f"{name}: got {got}, want {want}±{tol}"