Files
cma-management/backend/scripts/verify_cashplan_alert_engine_fix.py
T

457 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""cash_plan「到期未收款」预警引擎修复 — 生产级验收电池
任务:dama-cashplan-alert-engine-fix-20260913(提出者 wecom-dama / 审计 fin-alert-flood-e2)
覆盖验收项:
F1 新建预警 entity_id 恒等于入参 entity(+ 其他创建点复查)
F2 KPI 选择实体感知:博海应收=BH_AR_BALANCE(433) 现金=BH_CASH_BALANCE(434);
兜底查询显式 ORDER BY id(不再命中 BH_ADM_EXPENSE 437)
F3 去重命中时刷新 alert_message(金额/逾期天数)与 suggestion.days_late
F4 余额快照批次开关(默认 skip 不逐户;aggregate 聚合 1 条;per_customer 旧行为)
F5 POST /api/cma/admin/alerts/check 无鉴权=401;带 entity_id 只处理该实体
安全设计(零生产写入):
* F1-F4 全部在「生产库副本 cma_cashvfy」上执行(mysqldump 一次性复制)
* F3 用副本内合成计划(source='vfy_cashplan_fix',仅存在于副本)构造"非快照 + 存量陈旧预警"场景
* F5 走 in-process TestClient,app.database 的 engine/SessionLocal 重定向到副本
* 唯一对生产库的访问是只读 COUNT(前后对比,证明未被写入)
* 副本用完即 DROP;不进常驻 cron
用法:cd /root/cma-management/backend && venv/bin/python3 scripts/verify_cashplan_alert_engine_fix.py
"""
import json
import os
import re
import subprocess
import sys
from datetime import datetime
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, BASE)
import pymysql
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
PROD_DB = "cma"
SCRATCH_DB = "cma_cashvfy"
SOCKET = "/var/run/mysqld/mysqld.sock"
SCRATCH_URL = f"mysql+pymysql://root@localhost/{SCRATCH_DB}?unix_socket={SOCKET}&charset=utf8mb4"
VFY_SOURCE = "vfy_cashplan_fix"
RESULTS = []
_ENGINES = []
_SESSIONS = []
def check(name, cond, detail=""):
RESULTS.append((bool(cond), name, detail))
print(f"{'PASS' if cond else 'FAIL'} | {name}" + (f" | {detail}" if detail else ""))
return bool(cond)
def raw(sql, db=None, fetch=True, args=None):
"""root socket 直连(副本运维 + 生产只读计数)"""
conn = pymysql.connect(unix_socket=SOCKET, user="root", database=db, charset="utf8mb4")
try:
cur = conn.cursor()
cur.execute(sql, args) if args else cur.execute(sql)
rows = cur.fetchall() if fetch else None
conn.commit()
return rows
finally:
conn.close()
def new_session():
eng = create_engine(SCRATCH_URL)
_ENGINES.append(eng)
sess = sessionmaker(bind=eng)()
_SESSIONS.append(sess)
return sess
def prod_counts():
q = lambda s: raw(s, db=PROD_DB)[0][0]
return {
"total": q("SELECT COUNT(*) FROM kpi_alerts"),
"e1": q("SELECT COUNT(*) FROM kpi_alerts WHERE entity_id=1"),
"e2_pending": q("SELECT COUNT(*) FROM kpi_alerts WHERE entity_id=2 AND status='pending'"),
"kpi437_pending": q("SELECT COUNT(*) FROM kpi_alerts WHERE kpi_id=437 AND status='pending'"),
"plans_e2_pending": q("SELECT COUNT(*) FROM cash_plans WHERE entity_id=2 AND status='pending'"),
}
def amount_of(msg):
m = re.search(r"([\d.]+)万", msg or "")
return float(m.group(1)) if m else None
def days_of(msg):
m = re.search(r"已逾期(\d+)天", msg or "")
return int(m.group(1)) if m else None
def parse_sug(sug):
try:
d = json.loads(sug) if sug else {}
return d if isinstance(d, dict) else {}
except Exception:
return {}
def setup_scratch():
raw(f"DROP DATABASE IF EXISTS {SCRATCH_DB}", fetch=False)
raw(f"CREATE DATABASE {SCRATCH_DB} CHARACTER SET utf8mb4", fetch=False)
cmd = f"mysqldump --single-transaction --routines --triggers {PROD_DB} | mysql {SCRATCH_DB}"
p = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if p.returncode != 0:
raise RuntimeError(f"副本复制失败: {p.stderr[:500]}")
n = raw("SELECT COUNT(*) FROM kpi_alerts", db=SCRATCH_DB)[0][0]
print(f"[setup] cma -> {SCRATCH_DB} 完成, kpi_alerts={n}")
# 还原 8/29 洪水现场:副本内把实体的 cash_plan 预警状态恢复成 pending(验证 F3 刷新所需)
raw("UPDATE kpi_alerts SET status='pending', resolved_at=NULL, resolution=NULL "
"WHERE entity_id=2 AND alert_type='cash_plan'", db=SCRATCH_DB, fetch=False)
return n
def seed_synthetic_plans():
"""副本内合成 3 条非快照逾期应收计划(F1 新建 / F3 刷新场景)"""
import datetime as _dt
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
pdate = today.replace(day=1) - _dt.timedelta(days=1) # 上月最后一天
rows = [("VFY-测试客户A", 12.3), ("VFY-测试客户B", 0.4), ("VFY-测试客户C", 4567.89)]
for cp, amt in rows:
raw("INSERT INTO cash_plans (entity_id, plan_type, amount, plan_date, counterparty, description, "
"status, source) VALUES (2, 'receive', %s, %s, %s, '系统验证临时数据', 'pending', %s)",
db=SCRATCH_DB, fetch=False, args=(amt, pdate.strftime("%Y-%m-%d"), cp, VFY_SOURCE))
ids = [r[0] for r in raw("SELECT id FROM cash_plans WHERE source=%s ORDER BY id",
db=SCRATCH_DB, args=(VFY_SOURCE,))]
# 计划A:植入一条「陈旧 + 错挂」预警(entity=1 / kpi=437 / 金额虚增 / 逾期天数过期)
stale_msg = f"【到期未收款】应收款VFY-测试客户A 1230000.0万 原计划{pdate:%Y-%m-%d}到期,已逾期10天"
raw("INSERT INTO kpi_alerts (entity_id, kpi_id, alert_level, alert_message, alert_type, suggestion, status) "
"VALUES (1, 437, 'red', %s, 'cash_plan', %s, 'pending')",
db=SCRATCH_DB, fetch=False,
args=(stale_msg, json.dumps({"actions": ["联系客户催收"], "plan_id": ids[0], "days_late": 10},
ensure_ascii=False)))
return ids, pdate
def main():
import app.utils.cash_forecast_engine as eng
prod_before = prod_counts()
print(f"[prod before] {prod_before}")
# ══════════════════════════════════════════════════════════
# F2 — KPI 实体感知 + 兜底确定性
# ══════════════════════════════════════════════════════════
print("\n=== F2 KPI 实体感知 / ORDER BY 确定性 ===")
s = new_session()
ar2 = eng.find_kpi(s, 2, eng.KPI_CODE_CANDIDATES["receivables"], eng.KPI_CODE_SUFFIXES["receivables"])
cash2 = eng.find_kpi(s, 2, eng.KPI_CODE_CANDIDATES["cash_balance"], eng.KPI_CODE_SUFFIXES["cash_balance"])
ap2 = eng.find_kpi(s, 2, eng.KPI_CODE_CANDIDATES["payables"], eng.KPI_CODE_SUFFIXES["payables"])
check("F2 博海应收 → BH_AR_BALANCE(433)",
bool(ar2) and ar2.kpi_code == "BH_AR_BALANCE" and ar2.id == 433,
f"got={getattr(ar2, 'kpi_code', None)}({getattr(ar2, 'id', None)})")
check("F2 博海现金 → BH_CASH_BALANCE(434)",
bool(cash2) and cash2.kpi_code == "BH_CASH_BALANCE" and cash2.id == 434,
f"got={getattr(cash2, 'kpi_code', None)}({getattr(cash2, 'id', None)})")
check("F2 博海应付 → BH_AP_BALANCE(441)",
bool(ap2) and ap2.kpi_code == "BH_AP_BALANCE" and ap2.id == 441,
f"got={getattr(ap2, 'kpi_code', None)}({getattr(ap2, 'id', None)})")
fb_ids = {eng.find_fallback_kpi(s, 2).id for _ in range(20)}
fb = eng.find_fallback_kpi(s, 2)
check("F2 博海兜底 KPI 不等于 437(BH_ADM_EXPENSE)", fb.id != 437, f"fallback={fb.kpi_code}({fb.id})")
check("F2 博海兜底 20 次调用恒定(ORDER BY id)", len(fb_ids) == 1, f"ids={fb_ids}")
plan_dep = raw("SELECT id FROM kpi_definitions WHERE entity_id=2 LIMIT 1", db=SCRATCH_DB)[0][0]
plan_dep_uk = raw("SELECT id FROM kpi_definitions FORCE INDEX(uk_entity_kpi) "
"WHERE entity_id=2 LIMIT 1", db=SCRATCH_DB)[0][0]
check("F2 复现旧缺陷:同条件两种答案(执行计划相关)",
plan_dep == 318 and plan_dep_uk == 437, f"idx_entity={plan_dep} / FORCE uk={plan_dep_uk}")
ar1 = eng.find_kpi(s, 1, eng.KPI_CODE_CANDIDATES["receivables"], eng.KPI_CODE_SUFFIXES["receivables"])
cash1 = eng.find_kpi(s, 1, eng.KPI_CODE_CANDIDATES["cash_balance"], eng.KPI_CODE_SUFFIXES["cash_balance"]) or \
eng.find_kpi(s, 1, eng.KPI_CODE_CANDIDATES["operating_cash_flow"], eng.KPI_CODE_SUFFIXES["operating_cash_flow"])
check("F2 酣客口径零回归(应收=F_AR_DAYS)", bool(ar1) and ar1.kpi_code == "F_AR_DAYS",
f"got={getattr(ar1, 'kpi_code', None)}({getattr(ar1, 'id', None)})")
check("F2 酣客口径零回归(现金=F_OP_CFLOW)", bool(cash1) and cash1.kpi_code == "F_OP_CFLOW",
f"got={getattr(cash1, 'kpi_code', None)}({getattr(cash1, 'id', None)})")
s.close()
# ══════════════════════════════════════════════════════════
# F4-skip(默认)+ 快照批次不逐户
# ══════════════════════════════════════════════════════════
os.environ.pop("CMA_CASH_SNAPSHOT_MODE", None)
os.environ.pop("CMA_CASH_SNAPSHOT_SOURCES", None)
print("\n=== F4(skip 默认):余额快照批次不再逐户生成 ===")
s = new_session()
snap_before = raw("SELECT id, kpi_id, entity_id, alert_message FROM kpi_alerts "
"WHERE entity_id=2 AND alert_type='cash_plan' AND status='pending'", db=SCRATCH_DB)
before_ids = set(r[0] for r in raw("SELECT id FROM kpi_alerts", db=SCRATCH_DB))
e1_ids_before = {r[0] for r in raw("SELECT id FROM kpi_alerts WHERE entity_id=1", db=SCRATCH_DB)}
snap_plans = raw("SELECT COUNT(*) FROM cash_plans WHERE entity_id=2 AND plan_type='receive' "
"AND status='pending' AND plan_date < CURDATE()", db=SCRATCH_DB)[0][0]
res = eng.check_cash_alerts(s, entity_id=2)
check("F4 默认模式 = skip", res["balance_snapshot"]["mode"] == "skip",
f"mode={res['balance_snapshot']['mode']}")
check("F4 快照批次行全部跳过逐户生成", res["balance_snapshot"]["skipped_rows"] == snap_plans,
f"skipped={res['balance_snapshot']['skipped_rows']} expect={snap_plans}")
check("F4 默认模式零新增预警(不重建 216 条伪逾期)", res["new_alerts"] == 0,
f"new_alerts={res['new_alerts']}")
check("F2 引擎选中应收 KPI = BH_AR_BALANCE(433)", res["kpi_selection"]["ar_kpi_code"] == "BH_AR_BALANCE",
f"ar={res['kpi_selection']['ar_kpi_code']} cash={res['kpi_selection']['cash_kpi_code']}")
check("F2 引擎选中现金 KPI = BH_CASH_BALANCE(434)", res["kpi_selection"]["cash_kpi_code"] == "BH_CASH_BALANCE",
f"cash={res['kpi_selection']['cash_kpi_code']}")
after_ids = set(r[0] for r in raw("SELECT id FROM kpi_alerts", db=SCRATCH_DB))
check("F1 未产生任何新预警行", after_ids <= before_ids, f"新增={sorted(after_ids - before_ids)[:5]}")
snap_after = raw("SELECT id, kpi_id, entity_id, alert_message FROM kpi_alerts "
"WHERE entity_id=2 AND alert_type='cash_plan' AND status='pending'", db=SCRATCH_DB)
check("F4 skip 模式不触碰存量快照预警(交由财务清理)",
[tuple(r) for r in snap_before] == [tuple(r) for r in snap_after],
f"before={len(snap_before)} after={len(snap_after)}")
e1_ids_after = {r[0] for r in raw("SELECT id FROM kpi_alerts WHERE entity_id=1", db=SCRATCH_DB)}
check("F1 酣客(entity1) 预警零变化", e1_ids_before == e1_ids_after,
f"{len(e1_ids_before)} -> {len(e1_ids_after)}")
# ── F4b:summary/suggestions 展示口径与落库口径同源 ──
# (任务 dama-cashplan-zombie-alert-cleanup-20260913 B 段:曾是「写入 0 / 展示 216」)
sm = res["summary"]
check("F4b summary 快照批次不计入逐户口径(count=0)", sm["overdue_receive_count"] == 0,
f"count={sm['overdue_receive_count']} raw={sm['overdue_receive_count_raw']}")
check("F4b 展示口径 ⇔ 落库口径(overdue_receive_count=0 与 new_alerts=0 一致)",
(sm["overdue_receive_count"] == 0) == (res["new_alerts"] == 0),
f"count={sm['overdue_receive_count']} new={res['new_alerts']}")
check("F4b 剔除量显式可见(excluded == skipped_rows == 快照逾期行数)",
sm["snapshot_rows_excluded"] == res["balance_snapshot"]["skipped_rows"] == snap_plans,
f"excluded={sm['snapshot_rows_excluded']} skipped={res['balance_snapshot']['skipped_rows']} expect={snap_plans}")
check("F4b 全集可追溯(raw_count == 快照逾期行数)", sm["overdue_receive_count_raw"] == snap_plans,
f"raw={sm['overdue_receive_count_raw']} expect={snap_plans}")
check("F4b 批次明细可见(bohai_ar_20260630 210 户)",
any(b["source"] == "bohai_ar_20260630" and b["count"] == 210 for b in sm["snapshot_batches"]),
f"batches={sm['snapshot_batches']}")
s.close()
# ══════════════════════════════════════════════════════════
# F1 + F3 — 非快照计划的「新建归属」与「去重刷新」(副本合成计划)
# ══════════════════════════════════════════════════════════
print("\n=== F1 新建归属 + F3 去重刷新(副本合成非快照计划)===")
ids, pdate = seed_synthetic_plans()
exp_days = (datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) - pdate).days
s = new_session()
res2 = eng.check_cash_alerts(s, entity_id=2)
s.expire_all()
by_plan = {}
for aid, eid, kid, msg, sug, st in raw(
"SELECT id, entity_id, kpi_id, alert_message, suggestion, status FROM kpi_alerts "
"WHERE alert_type='cash_plan'", db=SCRATCH_DB):
pid = parse_sug(sug).get("plan_id")
if pid in ids:
by_plan[pid] = (aid, eid, kid, msg, parse_sug(sug), st)
check("F3 命中去重时刷新而非跳过(陈旧预警已更新)", res2["refreshed_alerts"] == 1,
f"refreshed={res2['refreshed_alerts']}")
check("F1 新建 2 条(无存量预警的计划)", res2["new_alerts"] == 2, f"new={res2['new_alerts']}")
check("F1/F3 3 条计划预警全部 entity_id=2", all(v[1] == 2 for v in by_plan.values()),
f"rows={[(k, v[1], v[2]) for k, v in by_plan.items()]}")
check("F2 3 条计划预警全部 kpi_id=433", all(v[2] == 433 for v in by_plan.values()),
f"rows={[(k, v[1], v[2]) for k, v in by_plan.items()]}")
ok_msg, bad = True, []
for pid in ids:
v = by_plan.get(pid)
if not v:
bad.append((pid, "缺失"))
continue
expected_amt = {ids[0]: 12.3, ids[1]: 0.4, ids[2]: 4567.89}[pid]
if f"{expected_amt:.1f}万" not in v[3]:
bad.append((pid, f"金额未刷新: {v[3][:50]}"))
if f"已逾期{exp_days}天" not in v[3]:
bad.append((pid, f"逾期天数未刷新: {v[3][:50]}"))
if v[4].get("days_late") != exp_days:
bad.append((pid, f"days_late={v[4].get('days_late')} expect={exp_days}"))
check("F3 文本金额/逾期天数与 suggestion.days_late 全部随现值刷新", not bad, f"异常={bad[:3]}")
check("F3 虚增金额(1230000.0万)已被现值覆盖",
"1230000.0万" not in "".join(v[3] for v in by_plan.values()), "")
check("F3 幂等:二次运行 0 新增 0 刷新",
(lambda r: r["new_alerts"] == 0 and r["refreshed_alerts"] == 0)(eng.check_cash_alerts(s, entity_id=2)),
"")
s.close()
# ══════════════════════════════════════════════════════════
# F4-aggregate — 方案(b):按批次聚合 1 条
# ══════════════════════════════════════════════════════════
print("\n=== F4(aggregate) 方案(b):按批次聚合 ===")
raw("DELETE FROM kpi_alerts WHERE entity_id=2 AND alert_type='cash_plan'", db=SCRATCH_DB, fetch=False)
os.environ["CMA_CASH_SNAPSHOT_MODE"] = "aggregate"
s = new_session()
res3 = eng.check_cash_alerts(s, entity_id=2)
check("F4 aggregate 模式生效", res3["balance_snapshot"]["mode"] == "aggregate",
f"mode={res3['balance_snapshot']['mode']}")
agg = raw("SELECT id, kpi_id, entity_id, alert_message FROM kpi_alerts "
"WHERE entity_id=2 AND alert_type='cash_plan' AND status='pending' "
"AND alert_message LIKE '%%批次汇总%%'", db=SCRATCH_DB)
check("F4 聚合为每批次 1 条(3 个应收批次)", len(agg) == 3, f"count={len(agg)}")
check("F4 聚合行归属正确(entity=2 / kpi=433)", all(r[1] == 433 and r[2] == 2 for r in agg),
f"rows={[(r[0], r[1], r[2]) for r in agg]}")
ar_agg = [r for r in agg if "bohai_ar_20260630" in r[3]]
check("F4 聚合文本含户数与合计(210户 / 198.27万)",
len(ar_agg) == 1 and "210户" in ar_agg[0][3] and "198.27万" in ar_agg[0][3],
f"msg={ar_agg[0][3] if ar_agg else None}")
res4 = eng.check_cash_alerts(s, entity_id=2)
check("F4 aggregate 幂等(二次 0 新增 / 0 变更)",
res4["new_alerts"] == 0 and res4["refreshed_alerts"] == 0,
f"new={res4['new_alerts']} refreshed={res4['refreshed_alerts']}")
agg_sm = res3["summary"]
check("F4b aggregate 展示口径=落库(业务行 + 每批次 1 条)",
agg_sm["overdue_receive_count"] ==
(agg_sm["overdue_receive_count_raw"] - agg_sm["snapshot_rows_total"]) + len(agg_sm["snapshot_batches"]),
f"count={agg_sm['overdue_receive_count']} raw={agg_sm['overdue_receive_count_raw']} "
f"snapshot={agg_sm['snapshot_rows_total']} batches={len(agg_sm['snapshot_batches'])}")
s.close()
# ══════════════════════════════════════════════════════════
# F4-per_customer — 旧行为开关回归(F1 使其也正确)
# ══════════════════════════════════════════════════════════
print("\n=== F4(per_customer) 旧行为开关回归 ===")
raw("DELETE FROM kpi_alerts WHERE entity_id=2 AND alert_type='cash_plan'", db=SCRATCH_DB, fetch=False)
os.environ["CMA_CASH_SNAPSHOT_MODE"] = "per_customer"
s = new_session()
res5 = eng.check_cash_alerts(s, entity_id=2)
rows5 = raw("SELECT kpi_id, entity_id, alert_message FROM kpi_alerts "
"WHERE entity_id=2 AND alert_type='cash_plan' AND status='pending'", db=SCRATCH_DB)
exp_cnt = snap_plans + 3
plan_amts = [r[0] for r in raw(
"SELECT amount FROM cash_plans WHERE entity_id=2 AND plan_type='receive' "
"AND status='pending' AND plan_date < CURDATE()", db=SCRATCH_DB)]
exp_sum = sum(round(a, 1) for a in plan_amts)
check("F4 per_customer 复原旧行为(逐户 216+3 条)", len(rows5) == exp_cnt,
f"count={len(rows5)} expect={exp_cnt}")
check("F1 旧模式下行归属仍正确(entity=2 / kpi=433)",
all(r[0] == 433 and r[1] == 2 for r in rows5), "")
amts5 = [amount_of(r[2]) or 0 for r in rows5]
check("F1/F3 旧模式金额按现值渲染(无 10000 倍放大)",
max(amts5) < 5000 and abs(sum(amts5) - exp_sum) < 0.6,
f"max={max(amts5)} sum={sum(amts5):.2f}万 expect={exp_sum:.2f}万")
sm5 = res5["summary"]
check("F4b per_customer 展示口径=落库(逐户全额 / excluded=0)",
sm5["overdue_receive_count"] == sm5["overdue_receive_count_raw"] and sm5["snapshot_rows_excluded"] == 0,
f"count={sm5['overdue_receive_count']} raw={sm5['overdue_receive_count_raw']} "
f"excluded={sm5['snapshot_rows_excluded']}")
s.close()
os.environ.pop("CMA_CASH_SNAPSHOT_MODE", None)
# ══════════════════════════════════════════════════════════
# F5 — 端点鉴权 + entity 过滤(in-process TestClient,副本重定向)
# ══════════════════════════════════════════════════════════
print("\n=== F5 /api/cma/admin/alerts/check 鉴权 + entity 参数 ===")
raw("DELETE FROM kpi_alerts WHERE entity_id=2 AND alert_type='cash_plan'", db=SCRATCH_DB, fetch=False)
import app.database as db_module
db_module._engine = create_engine(SCRATCH_URL)
_ENGINES.append(db_module._engine)
db_module._SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=db_module._engine)
import app.utils.notifier as notifier
notifier.push_pending_alerts = lambda db_session: 0 # 禁止脚本触碰企微推送
from app.main import app as fastapi_app
from fastapi.testclient import TestClient
client = TestClient(fastapi_app)
ENDPOINT = "/api/cma/admin/alerts/check"
r_noauth = client.post(ENDPOINT)
check("F5 无鉴权调用返回 401", r_noauth.status_code == 401,
f"status={r_noauth.status_code} body={r_noauth.text[:60]}")
r_badtoken = client.post(ENDPOINT, headers={"Authorization": "Bearer deadbeef"})
check("F5 无效 token 返回 401", r_badtoken.status_code == 401, f"status={r_badtoken.status_code}")
from app.auth_middleware import create_token
token = create_token(1, 2) # users.id=1 (admin) 在副本中同样存在
r_e2 = client.post(ENDPOINT + "?entity_id=2", headers={"Authorization": f"Bearer {token}"})
body = r_e2.json() if r_e2.status_code == 200 else {}
cash = body.get("cash") or {}
details = cash.get("details") or [{}]
d0 = details[0] if details else {}
check("F5 带 entity_id=2 请求 200", r_e2.status_code == 200,
f"status={r_e2.status_code} body={r_e2.text[:100]}")
check("F5 只处理指定实体(entities=1)", cash.get("entities") == 1, f"entities={cash.get('entities')}")
check("F5 处理后只产出 entity=2 记录", d0.get("entity_id") == 2, f"entity_id={d0.get('entity_id')}")
check("F5 修复后快照批次零重建(skipped=216 / 无快照预警)",
d0.get("balance_snapshot", {}).get("mode") == "skip"
and d0.get("balance_snapshot", {}).get("skipped_rows") == snap_plans,
f"snapshot={d0.get('balance_snapshot')}")
check("F4b 端点响应 summary=落库口径(count=new_alerts / 快照批次不计入 / excluded 可见)",
d0.get("summary", {}).get("overdue_receive_count") == d0.get("new_alerts")
and d0.get("summary", {}).get("snapshot_rows_excluded") == snap_plans,
f"count={d0.get('summary', {}).get('overdue_receive_count')} new={d0.get('new_alerts')} "
f"excluded={d0.get('summary', {}).get('snapshot_rows_excluded')} expect={snap_plans}")
snap_pids = set(r[0] for r in raw(
"SELECT id FROM cash_plans WHERE entity_id=2 AND plan_type='receive' AND status='pending' "
"AND plan_date < CURDATE() AND source IN ('bohai_ar_20260630','bohai_ap_20260630',"
"'bohai_prepay_20260630','receivables_migrate')", db=SCRATCH_DB))
snap_alerts = 0
for (sug,) in raw("SELECT suggestion FROM kpi_alerts WHERE entity_id=2 AND alert_type='cash_plan' "
"AND status='pending'", db=SCRATCH_DB):
if parse_sug(sug).get("plan_id") in snap_pids:
snap_alerts += 1
check("F5 调用后不存在指向快照计划的预警(原生 SQL 复核)", snap_alerts == 0, f"snap_alerts={snap_alerts}")
check("F5 引擎对博海不再重建伪预警(new = 仅 3 条合成非快照计划)",
d0.get("new_alerts") == 3, f"new={d0.get('new_alerts')}")
check("F5 响应暴露 KPI 归属便于复核(BH_AR_BALANCE)",
d0.get("kpi_selection", {}).get("ar_kpi_code") == "BH_AR_BALANCE",
f"kpi_selection={d0.get('kpi_selection')}")
r_404 = client.post(ENDPOINT + "?entity_id=9999", headers={"Authorization": f"Bearer {token}"})
check("F5 不存在/未激活 entity → 404", r_404.status_code == 404, f"status={r_404.status_code}")
leaked = raw("SELECT COUNT(*) FROM kpi_alerts WHERE entity_id<>2 AND status='pending' "
"AND alert_type='cash_plan' AND created_at > NOW() - INTERVAL 5 MINUTE",
db=SCRATCH_DB)[0][0]
check("F5 本次调用未向其他实体写入预警", leaked == 0, f"leaked={leaked}")
# ══════════════════════════════════════════════════════════
# 生产库零写入核验
# ══════════════════════════════════════════════════════════
prod_after = prod_counts()
check("生产库 kpi_alerts / cash_plans 零变化", prod_before == prod_after,
f"before={prod_before}\n after ={prod_after}")
ok = sum(1 for c, _, _ in RESULTS if c)
print(f"\n{'='*60}\n汇总: {ok}/{len(RESULTS)} PASS")
for c, name, detail in RESULTS:
if not c:
print(f" FAIL: {name} | {detail}")
return 0 if ok == len(RESULTS) else 1
def teardown():
for es in _SESSIONS:
try:
es.close()
except Exception:
pass
for en in _ENGINES:
try:
en.dispose()
except Exception:
pass
for attempt in range(3):
try:
conn = pymysql.connect(unix_socket=SOCKET, user="root", charset="utf8mb4")
cur = conn.cursor()
cur.execute("SET SESSION lock_wait_timeout=8")
cur.execute(f"DROP DATABASE IF EXISTS {SCRATCH_DB}")
conn.close()
print(f"[cleanup] {SCRATCH_DB} 已删除")
return
except Exception as e:
print(f"[cleanup] 第{attempt+1}次删除失败: {e}")
print(f"[cleanup] 警告:{SCRATCH_DB} 未删除,请手工清理")
if __name__ == "__main__":
code = 1
try:
setup_scratch()
code = main()
except Exception:
import traceback
traceback.print_exc()
finally:
if os.environ.get("KEEP_SCRATCH") != "1":
teardown()
sys.exit(code)