#!/usr/bin/env python3 # -*- coding: utf-8 -*- """BEP 保本 KPI 口径一致性验证(**动态期**)— 原 fin-fix-bep-threshold-20260908 快照改造版 改造说明(2026-09-12,任务单 concurrent-duplicate-session-and-stale-verify-script-20260912): 原脚本是 fin-fix-bep-threshold-20260908 的**验收快照**:硬编码 period='2026-07'、 期望值 169.9/66.9/33.1、告警自增 ID 573~577。期值随派生器持续更新 (2026-07 现为 170.2/66.7/33.3,最新派生期已滚动到 2026-08)→ 复跑必然 `FAIL F_BEP_REVENUE = 169.9 got 170.2`,后人会把"快照过期"误读成"本轮回归"。 处置:改为**按最新派生期动态断言**(不是删除——阈值口径与派生一致性本身就是活的检查项): · 结构契约:阈值口径按 kpi_code 查,不写死 ID / 不写死期 · 数值检查:改判**定义级一致性**(GAP = 100 − COVER、现金收入 ≤ 账面收入、值 ≥ 0) · 告警检查:改为**按阈值复算级别**(不再比对自增 ID),另对"历史期仍 pending"给软告警 · 端点检查:凭据读 .secrets/cma-admin.env(不再硬编码账号口令),期望值与 card 复算比对 历史快照留档:backend/scripts/_archive/verify_bep_threshold_fix.snapshot-20260908.py 口径与访问纪律: · DB 只读 = cma_reader(.secrets/cma-reader.env,可 CMA_READER_ENV 覆盖;仅 SELECT 权限) · 登录口令 = .secrets/cma-admin.env(600,禁止把口令写进代码/文档/日志) 用法: python3 backend/scripts/verify_bep_threshold_fix.py (需后端 8010 在线) 退出码: 0 = 全部结构/一致性断言通过;1 = 有 FAIL(=真漂移,不是期回滚) """ import json import os import re import sys import urllib.request import pymysql BASE = os.environ.get("CMA_API_BASE", "http://127.0.0.1:8010") PROFILE_HOME = "/root/.hermes/profiles/wecom-project" READER_ENV = os.environ.get("CMA_READER_ENV", os.path.join(PROFILE_HOME, ".secrets/cma-reader.env")) ADMIN_ENV = os.environ.get("CMA_ADMIN_ENV", os.path.join(PROFILE_HOME, ".secrets/cma-admin.env")) BEP_CODES = ("F_BEP_REVENUE", "F_BEP_COVER", "F_BEP_GAP", "F_BEP_REVENUE_CASH", "F_BEP_COVER_CASH") passed, warned = [], [] def check(name, cond, detail=""): if cond: passed.append(name) print(f" PASS {name}") else: print(f" FAIL {name} {detail}") sys.exit(1) def warn(name, detail=""): warned.append(name) print(f" WARN {name} {detail}") def _dotenv(path): env = {} try: for line in open(path, encoding="utf-8"): line = line.strip() if line and "=" in line and not line.startswith("#"): k, v = line.split("=", 1) env[k] = v.strip().strip('"').strip("'") except Exception as e: print(f" FAIL 读取凭据文件失败 {path}: {str(e)[:80]}") sys.exit(1) return env def db_q(sql, args=None): e = _dotenv(READER_ENV) conn = pymysql.connect(host=e["CMA_READER_DB_HOST"], port=int(e["CMA_READER_DB_PORT"]), user=e["CMA_READER_DB_USER"], password=e["CMA_READER_DB_PASS"], database=e["CMA_READER_DB_NAME"], charset="utf8mb4") try: with conn.cursor() as cur: cur.execute(sql, args or ()) return cur.fetchall() finally: conn.close() def level_of(value, thr_yellow, thr_red): """阈值口径 → 级别:red 命中优先,其次 yellow,否则 green""" def hit(expr): if not expr or value is None: return False m = re.match(r"^\s*(>=|<=|>|<|=)\s*(-?[\d.]+)\s*$", str(expr)) if not m: return False op, num = m.group(1), float(m.group(2)) return {">=": value >= num, "<=": value <= num, ">": value > num, "<": value < num, "=": value == num}[op] if hit(thr_red): return "red" if hit(thr_yellow): return "yellow" return "green" def http_json(path, method="GET", payload=None, token=None): headers = {} if token: headers["Authorization"] = "Bearer " + token data = json.dumps(payload).encode() if payload is not None else None if data: headers["Content-Type"] = "application/json" req = urllib.request.Request(BASE + path, data=data, headers=headers, method=method) with urllib.request.urlopen(req, timeout=15) as r: return json.loads(r.read().decode()) # ── 1. 阈值口径(结构契约:不写死 ID、不写死期)─────────────────────────────── print("== 1. 阈值口径(按 kpi_code,结构契约)==") rows = db_q("SELECT kpi_code, threshold_green, threshold_yellow, threshold_red FROM kpi_definitions " "WHERE kpi_code IN %s", (BEP_CODES,)) thr = {r[0]: r[1:4] for r in rows} check("5 个 BEP 口径均已定义", all(c in thr for c in BEP_CODES), f"missing={[c for c in BEP_CODES if c not in thr]}") check("COVER 口径 100/85/85", thr.get("F_BEP_COVER") == (">=100", ">=85", "<85"), str(thr.get("F_BEP_COVER"))) check("GAP 口径 0/15/15", thr.get("F_BEP_GAP") == ("<=0", "<=15", ">15"), str(thr.get("F_BEP_GAP"))) check("REVENUE 无阈值(派生值不判级)", thr.get("F_BEP_REVENUE") == (None, None, None), str(thr.get("F_BEP_REVENUE"))) check("COVER_CASH 口径 100/85/85", thr.get("F_BEP_COVER_CASH") == (">=100", ">=85", "<85"), str(thr.get("F_BEP_COVER_CASH"))) check("REVENUE_CASH 无阈值", thr.get("F_BEP_REVENUE_CASH") == (None, None, None), str(thr.get("F_BEP_REVENUE_CASH"))) # ── 2. 最新派生期 + 5 口径齐全(实体动态:BEP 数据挂在哪个实体就验哪个实体)──── print("== 2. 最新派生期与落库完整性 ==") rows = db_q("SELECT v.entity_id, v.period, k.kpi_code, v.actual_value FROM kpi_values v " "JOIN kpi_definitions k ON v.kpi_id=k.id " "WHERE k.kpi_code LIKE 'F_BEP%%' AND v.source_type='derived'") per_entity = {} for eid, period, code, val in rows: per_entity.setdefault(int(eid), {}).setdefault(period, {})[code] = \ float(val) if val is not None else None check("存在派生期数据", bool(per_entity), "entities=%s" % sorted(per_entity)) ENTITY = max(per_entity, key=lambda e: max(per_entity[e])) LATEST = max(per_entity[ENTITY]) vals = per_entity[ENTITY][LATEST] print(f" 实测实体 entity_id={ENTITY},最新派生期 P = {LATEST}(当前值: " + ", ".join(f"{c}={vals.get(c)}" for c in BEP_CODES) + ")") check(f"最新派生期 {LATEST} 五口径齐全且非空", all(vals.get(c) is not None for c in BEP_CODES), f"missing={[c for c in BEP_CODES if vals.get(c) is None]}") check("派生值均 ≥ 0", all(vals[c] >= 0 for c in BEP_CODES if vals.get(c) is not None)) # ── 3. 定义级一致性(替代硬编码数值)───────────────────────────────────────── print("== 3. 定义级一致性(GAP = 100 − COVER;现金口径 ≤ 账面口径)==") check("GAP ≈ 100 − COVER(±0.2)", abs(vals["F_BEP_GAP"] - (100 - vals["F_BEP_COVER"])) <= 0.2, f"gap={vals['F_BEP_GAP']} cover={vals['F_BEP_COVER']}") check("REVENUE_CASH ≤ REVENUE(现金口径不高于账面口径)", vals["F_BEP_REVENUE_CASH"] <= vals["F_BEP_REVENUE"] + 0.05, f"cash={vals['F_BEP_REVENUE_CASH']} book={vals['F_BEP_REVENUE']}") # ── 4. 告警与阈值一致性(按阈值复算,不比对自增 ID)────────────────────────── print("== 4. 告警级别与阈值复算一致 ==") rows = db_q("SELECT a.id, a.alert_level, a.status, v.period, k.kpi_code, v.actual_value " "FROM kpi_alerts a JOIN kpi_definitions k ON a.kpi_id=k.id " "JOIN kpi_values v ON a.kpi_value_id=v.id WHERE k.kpi_code LIKE 'F_BEP%%'") latest_alerts = [r for r in rows if r[3] == LATEST] for _id, lvl, st, period, code, val in latest_alerts: exp = level_of(val, thr[code][1], thr[code][2]) if exp == "green": check(f"告警 {_id}({code} {period})不应存在(实测值 {val} 未越阈)", False, f"level={lvl} status={st}") else: check(f"告警 {_id} 级别与阈值一致({code} {period} 实测 {val} → {exp})", lvl == exp, f"expected={exp} got={lvl}") pending_latest = [r for r in latest_alerts if r[2] == "pending"] check(f"最新派生期 {LATEST} 的红/黄项均有 pending 告警", all(any(r[4] == c and r[2] == "pending" and level_of(r[5], thr[c][1], thr[c][2]) for r in latest_alerts) for c in ("F_BEP_COVER", "F_BEP_GAP")), "期值越阈但无 pending 告警") hist_pending = [r for r in rows if r[3] != LATEST and r[2] == "pending"] if hist_pending: warn(f"历史期仍有 {len(hist_pending)} 条 pending 告警(新期派生未自动解除旧期)" f":{[ (r[0], r[4], r[3]) for r in hist_pending ]}") # ── 5. bep-card 端点(动态期,凭据来自 .secrets)──────────────────────────── print("== 5. bep-card 端点(period 必须等于最新派生期,级别必须与阈值复算一致)==") adm = _dotenv(ADMIN_ENV) # 实体必须用第 2 段实测出来的 ENTITY(数据在哪就验哪),**不要信凭据文件里的 CMA_ADMIN_ENTITY_ID**: # 该字段是"默认登录实体",与"目标数据归属实体"无关(实测凭据=2/博海,而 BEP 派生数据只在 1/酣客, # 照凭据登录会拿到 available=false,把"验证脚本选错实体"误判成"端点回归")。 cred_entity = int(adm.get("CMA_ADMIN_ENTITY_ID", "1") or 1) if cred_entity != ENTITY: warn(f"凭据默认实体 {cred_entity} ≠ BEP 数据归属实体 {ENTITY}(按实测实体登录,不按凭据字段)") login = http_json("/api/cma/auth/login", "POST", {"username": adm.get("CMA_ADMIN_USER"), "password": adm.get("CMA_ADMIN_PASS"), "entity_id": ENTITY}) card = http_json("/api/cma/dashboard/bep-card", token=login["token"]) check(f"available(entity={ENTITY})", card.get("available") is True, f"message={card.get('message')} missing={card.get('missing')} found={card.get('found')}") if card.get("entity_id") is not None: check(f"card.entity_id == 数据归属实体 {ENTITY}", int(card["entity_id"]) == ENTITY, str(card.get("entity_id"))) check(f"card.period == 最新派生期 {LATEST}", card.get("period") == LATEST, str(card.get("period"))) check("derived 5 条", len(card.get("derived", {})) == 5, str(list(card.get("derived", {}).keys()))) for leg in ("book", "cash"): code = "F_BEP_COVER" if leg == "book" else "F_BEP_COVER_CASH" d = card.get(leg, {}) exp = level_of(d.get("cover"), thr[code][1], thr[code][2]) check(f"card.{leg}.cover_level 与阈值复算一致({d.get('cover')} → {exp})", d.get("cover_level") == exp, f"got={d.get('cover_level')}") check(f"card.{leg}.cover 与库内派生值一致", d.get("cover") is not None and abs(float(d["cover"]) - vals[code]) <= 0.05, f"card={d.get('cover')} db={vals[code]}") print(f"\nALL {len(passed)} CHECKS PASSED" + (f"(另有 {len(warned)} 条 WARN)" if warned else ""))