209 lines
11 KiB
Python
209 lines
11 KiB
Python
#!/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 ""))
|