Files
group_xinghuo_jinrong/scripts/dev/verify_convert_engine.py
T
GaoYiYuan_0626 cde0c226fe 基金转换 T+1 模型:T-15/T-16/T-17 收官(842 绿 / 真库 9/9 / 转换线第 5 步完成)
T-15 D28 部分成交专项:
- sqlite +1 终态单幂等 skipped 口径裁定(ConfirmConflict 仅限读后状态才变窗口)
- 真库 confirm +3 组 19 项(E2 占用释放双向 / G2 actual_qty=0 → rejected / H2 撤单后确认)→ 102/102
- CONVERT_STRESS Barrier(2) 窄窗争批:恰 1 confirmed + 1 rejected
- 突变实证防御纵深:④可用量复核 / ⑤plan_lots / 事务内哨兵三道防线拆任一道仍收敛 rejected

T-16 D29 资金流中转:
- _insert_cash_flows 全仓唯一写入点(out/redeem 费前 + in/subscribe 净转入,remark=convert:{gid} 同事务)
- test_confirm_writes_no_cash_flow 反转为恰 2 条 + 金额对账(T-7 登记闭环)
- 真库 apply A2 组 + B 组 0 残留 → 30/30;confirm 反转断言 → 103/103
- FK 坑(core_cash_flow.fk_cf_customer 挡客户删除):5 处清理段补删

T-17 D30 share_class + A/C 互转:
- _validate_products 纯新增拦截分支:两端 share_class 非空且不同才查 allow_ac_convert(任一端=1 两向放行)
- 口径裁定 2 条(R-12 字面偏差,待用户追认):NULL 不参与 A/C 判定 / 同类走一般转换规则
- 联网核实(用户铁律):A/C 互转需管理人开通(摩根 2025-11 公告)= 开关真实载体;持有期重新起算与本仓一致
- 种子 ⑥ 段(双开对/双关对照/同类对照);sqlite +4 → 842 passed / 8 skipped;真库 accept H 组 → 43/43
- 突变:分支失效 → closed 红,还原绿

文档:开发计划 T-15~T-17 DoD 全勾 + 执行记录 · 基线 842 · TODO/MEMORY/AGENTS/交接文档 v4.5 终态(第 5 步收官 → 下一步第 6 步集成测试)
2026-09-12 13:42:49 +08:00

512 lines
24 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.
"""T-8 真 MySQL 验证脚本:**引擎时机**(受理不触引擎 / 确认事务提交后恰跑一次)实跑 + DoD 断言。
**为什么 sqlite 单测全绿还不够(T-8 视角)**
1. **「引擎时机」是主流程行为**:T+1 模型下引擎必须**只在确认事务提交后**跑一次(FR-C28)——
受理段触引擎会让「尚未确认的交易」提前进风控台账(验收 29 前半),
确认段重复触引擎会让 RISK-002 当日累计**翻倍**(验收 5/7)。真库跑一遍才能证明两条都成立。
2. **`convert_group_id` 的 NULL 值域**:MySQL 里普通交易该列为 NULL、convert 两条为同一串值;
`amount_view` 的 `if not gid` 必须在真库值域下成立(NULL / 空串都不得误聚合)。
3. **DECIMAL 精度**:`core_trade.amount` 在 MySQL 是 DECIMAL,进 Python 后与生产纯函数
逐项比对(sqlite 用 REAL 无此保证,需 `float()` 绑定)。
4. **JSON 列反解**:`risk_alert.payload` 真库落库后反解的 `events` 长度必须为 2。
**断言分组**
A0 受理**不触引擎**:受理后 `risk_alert` 0 新增、无 `pass` / `engine_error` 审计(验收 29 前半)
A 确认后引擎真跑出单:1 张单 / `payload.events` 两条 / 顺序 [转出, 转入]
B **RISK-002 不翻倍**(阈值夹逼:单条 < 阈值 < 两条之和)
C 去重不删行:`core_trade` 仍 2 条且同 `convert_group_id`
D 幂等重放(重复确认)**不产生第二张单**
E 无命中场景(独立小额客户)不建单、仅 pass 审计
F 清理后残留为零(自检)
用法:
python scripts/dev/verify_convert_engine.py
约定(与 T-6/T-7 脚本一致):
- 用 **T8M 前缀**隔离数据(客户/产品/批次/group_id),跑完**两个库**(core + agent)全清;
- 建/清数据走 `role="admin"`(需 DELETE);业务链路走默认账号(确认段写 Core 由
`ConvertCoreRepository` 内部固定用 `xh_core_rw`,D20);
- 基准日取真库 `core_trade_calendar` 的**中位开市日**作 T 日(避开数据边界),
T+1 = 下一交易日;净值插在 T 日(确认段 `get_nav_on` 精确匹配,缺则 `nav_pending`);
- 批次 `confirmed_at` 相对 T 日计算(T−400 / T−3),使持有期档位与费额确定;
- 固定 `trace_id = "T8M-VERIFY-TRACE"`,便于精准清理 `audit_log`。
"""
from __future__ import annotations
import itertools
import json
import sys
from datetime import date, datetime, time, timedelta
from decimal import Decimal
from pathlib import Path
from sqlalchemy import text
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from app.config.settings import settings # noqa: E402
from app.gateway.convert_core_repository import ConvertCoreRepository # noqa: E402
from app.repository.convert_repository import ConvertRepository # noqa: E402
from app.repository.convert_request_repository import ( # noqa: E402
ConvertRequestRepository,
)
from app.repository.core_ro import CoreReadOnlyRepository # noqa: E402
from app.repository.risk_repository import RiskRepository # noqa: E402
from app.service.convert.calc import ( # noqa: E402
convert_amount,
diff_fee,
hold_days,
in_qty,
lot_amount,
lot_fee,
plan_lots,
)
from app.service.convert.confirm_service import confirm_one # noqa: E402
from app.service.convert.convert_service import accept_convert # noqa: E402
from app.service.convert.fee import pick_fee_rate # noqa: E402
from app.service.convert.trading_calendar import next_biz_day # noqa: E402
from app.service.convert.types import FeeRule, Lot # noqa: E402
from app.service.risk.rules import RiskThresholds # noqa: E402
from app.utils.db import dispose_engines, get_engine # noqa: E402
from app.utils.trace import new_trace # noqa: E402
CUSTOMER = "CUST-T8M"
CUSTOMER_SMALL = "CUST-T8MS" # E 组:无命中场景专用(独立客户避免当日累计互相干扰)
PROD_OUT = "PROD-T8MO"
PROD_IN = "PROD-T8MI"
COMPANY = "华夏模拟基金"
TA = "TA-CN-001"
OUT_NAV = Decimal("1.0000") # T 日转出净值(未知价法 → 逐批金额按它计)
IN_NAV = Decimal("0.9500")
OUT_RATE = Decimal("0.0030")
IN_RATE = Decimal("0.0080")
FEE_TIERS = [
(0, 7, "0.0150"), (7, 30, "0.0100"), (30, 180, "0.0050"),
(180, 365, "0.0025"), (365, None, "0.0000"),
]
QTY_ALL = "500000"
CID_REQ = "T8M-IDEM-0001"
TRACE = "T8M-VERIFY-TRACE"
#: 基准受理日(T 日)—— 运行时由真库日历在 `main` 内赋值(模块级无法静态确定)
TA_DAY: date | None = None
# 夹逼阈值:单条转出 500000 < daily_total 800000 < 两条之和(约 996035)
# ⚠️ **必须显式传给 `confirm_one(thresholds=TH)`**:缺省会退回 `RiskThresholds.from_settings()`,
# settings 的 daily_total 一旦低于单条金额,RISK-002 就会「命中」—— 症状看着像去重失效,
# 实为阈值口径没传(首轮实跑即栽在此,B 组断言直接捕获)。
TH = RiskThresholds(
large_amount=Decimal("100000"),
daily_total=Decimal("800000"),
freq_count=3,
probe_window_minutes=5,
probe_count=3,
probe_amount=Decimal("400000"),
small_amount=Decimal("10000"),
small_count=3,
concentration_threshold=1.01, # 持仓画像不参与(避免 RISK-006 干扰本组断言)
)
_passed = 0
_failed = 0
def check(name: str, actual, expected) -> None:
global _passed, _failed
ok = actual == expected
if ok:
_passed += 1
else:
_failed += 1
flag = "✅" if ok else "❌"
print(f" {flag} {name}: 实际 {actual!r}" + ("" if ok else f" / 期望 {expected!r}"))
_SEQ = itertools.count(1)
def _t8m_id(prefix: str, now: datetime) -> str:
"""固定前缀的 id 工厂:生产 `_new_id` 生成 `CNV-<日期>-<uuid>`,
与清理口径 `LIKE 'CNV-T8M%'` 不匹配 → 重跑会留撞唯一键的残行(T-6/T-7 均踩过)。"""
return f"CNV-T8M-{next(_SEQ):03d}"
def q1(engine, sql: str, **params):
with engine.connect() as conn:
return conn.execute(text(sql), params).scalar()
def rows(engine, sql: str, **params):
with engine.connect() as conn:
return [dict(r) for r in conn.execute(text(sql), params).mappings()]
# ── 数据准备 / 清理 ─────────────────────────────────────────────────
def cleanup(core_engine, agent_engine) -> None:
"""按 T8M 前缀清理两个库(含幂等重跑前置清理)。"""
with core_engine.begin() as conn:
for sql, params in [
("DELETE FROM core_convert_lot_detail WHERE convert_group_id LIKE 'CNV-T8M%'", {}),
("DELETE FROM core_convert_request WHERE customer_id IN (:c1, :c2)",
{"c1": CUSTOMER, "c2": CUSTOMER_SMALL}),
("DELETE FROM core_trade WHERE customer_id IN (:c1, :c2)",
{"c1": CUSTOMER, "c2": CUSTOMER_SMALL}),
("DELETE FROM core_share_lot WHERE customer_id IN (:c1, :c2)",
{"c1": CUSTOMER, "c2": CUSTOMER_SMALL}),
("DELETE FROM core_holding WHERE customer_id IN (:c1, :c2)",
{"c1": CUSTOMER, "c2": CUSTOMER_SMALL}),
("DELETE FROM core_customer_risk WHERE customer_id IN (:c1, :c2)",
{"c1": CUSTOMER, "c2": CUSTOMER_SMALL}),
("DELETE FROM core_fee_rule WHERE product_id LIKE 'PROD-T8M%'", {}),
("DELETE FROM core_product_nav WHERE product_id LIKE 'PROD-T8M%'", {}),
("DELETE FROM core_product WHERE product_id LIKE 'PROD-T8M%'", {}),
# T-16 起确认事务写 core_cash_flow(fk_cf_customer 挡在客户删除前)
("DELETE FROM core_cash_flow WHERE remark LIKE 'convert:%'", {}),
("DELETE FROM core_customer WHERE customer_id IN (:c1, :c2)",
{"c1": CUSTOMER, "c2": CUSTOMER_SMALL}),
]:
conn.execute(text(sql), params)
with agent_engine.begin() as conn:
conn.execute(
text(
"DELETE FROM risk_convert_detail "
"WHERE convert_group_id LIKE 'CNV-T8M%' OR client_request_id LIKE 'T8M-%'"
)
)
conn.execute(
text("DELETE FROM risk_alert WHERE customer_id IN (:c1, :c2)"),
{"c1": CUSTOMER, "c2": CUSTOMER_SMALL},
)
conn.execute(
text("DELETE FROM customer_profile_l3 WHERE customer_id IN (:c1, :c2)"),
{"c1": CUSTOMER, "c2": CUSTOMER_SMALL},
)
conn.execute(text("DELETE FROM audit_log WHERE trace_id LIKE 'T8M-VERIFY%'"))
def seed(core_engine, base_day: date, submit_at: datetime) -> None:
"""两个客户 + 两只产品 + 费率五档 + T 日净值 + 批次(T−400 / T−3)。"""
with core_engine.begin() as conn:
for cid, name in ((CUSTOMER, "T8引擎时机"), (CUSTOMER_SMALL, "T8小额客户")):
conn.execute(
text(
"INSERT INTO core_customer (customer_id, display_name, open_date) "
"VALUES (:c, :n, :d)"
),
{"c": cid, "n": name, "d": base_day - timedelta(days=500)},
)
conn.execute(
text(
"INSERT INTO core_customer_risk "
"(customer_id, risk_code, evaluated_at, expires_at) "
"VALUES (:c, 'C3', :t, :exp)"
),
{"c": cid, "t": submit_at - timedelta(days=30),
"exp": submit_at + timedelta(days=300)},
)
for pid, name, ptype, rate in [
(PROD_OUT, "T8转出基金", "bond", OUT_RATE),
(PROD_IN, "T8转入基金", "stock", IN_RATE),
]:
conn.execute(
text(
"INSERT INTO core_product (product_id, product_name, min_risk_code, "
"product_type, can_subscribe, can_redeem, subscribe_fee_rate, "
"min_hold_qty, min_hold_action, fund_company, ta_code) "
"VALUES (:p, :n, 'R2', :t, 1, 1, :r, 0, 'force_transfer', :co, :ta)"
),
{"p": pid, "n": name, "t": ptype, "r": str(rate), "co": COMPANY, "ta": TA},
)
for mh, mh_max, rate in FEE_TIERS:
conn.execute(
text(
"INSERT INTO core_fee_rule (product_id, fee_type, min_hold_days, "
"max_hold_days, rate) VALUES (:p, 'redeem', :mh, :mh_max, :rate)"
),
{"p": PROD_OUT, "mh": mh, "mh_max": mh_max, "rate": rate},
)
# T 日净值:两端都必须有(确认段 `get_nav_on` 精确匹配)
for pid, nav in ((PROD_OUT, OUT_NAV), (PROD_IN, IN_NAV)):
conn.execute(
text(
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) "
"VALUES (:p, :nav, 0, :d)"
),
{"p": pid, "nav": str(nav), "d": base_day},
)
# 主客户两批:40 万份(持 400 天,费率 0)+ 10 万份(持 3 天,费率 1.5%)
for lot_id, qty, confirmed_delta in (
("LOT-T8M-A1", "400000", 400),
("LOT-T8M-A2", "100000", 3),
):
conn.execute(
text(
"INSERT INTO core_share_lot (lot_id, customer_id, product_id, qty, "
"remain_qty, nav, confirmed_at) VALUES (:l, :c, :p, :q, :q, 1.0300, :cat)"
),
{"l": lot_id, "c": CUSTOMER, "p": PROD_OUT, "q": qty,
"cat": datetime.combine(base_day - timedelta(days=confirmed_delta), time(10, 0))},
)
# 小额客户一批(1000 份,持 400 天 → 费率 0)
conn.execute(
text(
"INSERT INTO core_share_lot (lot_id, customer_id, product_id, qty, "
"remain_qty, nav, confirmed_at) VALUES ('LOT-T8M-S1', :c, :p, 1000, 1000, "
"1.0300, :cat)"
),
{"c": CUSTOMER_SMALL, "p": PROD_OUT,
"cat": datetime.combine(base_day - timedelta(days=400), time(10, 0))},
)
for cid, qty in ((CUSTOMER, "500000"), (CUSTOMER_SMALL, "1000")):
conn.execute(
text(
"INSERT INTO core_holding (customer_id, product_id, qty, cost_amount, "
"market_value, pnl_pct, as_of) VALUES (:c, :p, :q, :q, :q, 0, :d)"
),
{"c": cid, "p": PROD_OUT, "q": qty, "d": base_day},
)
def expected_quote(core_ro, requested: str) -> dict:
"""期望折算(全部走生产纯函数,与 service 同口径)。
⚠️ T+1 变更点:逐批金额按 **T 日净值**(`OUT_NAV`)计,**不是**批次买入净值 ——
这正是「未知价法」与 v1.0 的实质差异,脚本不得沿用 `alloc.nav`。
"""
lots = [Lot.from_row(r) for r in core_ro.list_share_lots(CUSTOMER, PROD_OUT)]
fee_rules = [FeeRule.from_row(r) for r in core_ro.get_redeem_fee_rules(PROD_OUT)]
plan = plan_lots(lots, Decimal(requested))
out_amount = Decimal("0")
redeem_fee = Decimal("0")
for alloc in plan.allocations:
amount = lot_amount(alloc.qty, OUT_NAV)
rate = pick_fee_rate(
fee_rules, hold_days(TA_DAY, alloc.confirmed_at), product_id=PROD_OUT
)
out_amount += amount
redeem_fee += lot_fee(amount, rate)
conv = convert_amount(out_amount, redeem_fee)
gap = diff_fee(conv, OUT_RATE, IN_RATE, settings.convert_diff_fee_mode)
return {
"out_amount": out_amount,
"redeem_fee": redeem_fee,
"convert_amount": conv,
"diff_fee": gap,
"in_amount": conv - gap,
"in_qty": in_qty(conv - gap, IN_NAV),
}
def _accept_services() -> dict:
return dict(
core_ro=CoreReadOnlyRepository(),
risk_repo=RiskRepository(),
convert_repo=ConvertRepository(),
request_repo=ConvertRequestRepository(),
)
def _confirm_services() -> dict:
return dict(**_accept_services(), core_writer=ConvertCoreRepository())
def _pick_days(core_admin, is_open) -> tuple[date, date]:
"""取日历**中位**开市日作 T 日,及其下一交易日(T+1 确认业务日)。
⚠️ 不能用「最新开市日」:日历种子到 2027 年底为止,取末日会让 T+1 撞
`next_biz_day` 的数据边界(T-6 实测暴露过该缺陷)。
"""
rws = rows(
core_admin,
"SELECT cal_date FROM core_trade_calendar WHERE is_open = 1 ORDER BY cal_date ASC",
)
if len(rws) < 10:
raise RuntimeError("core_trade_calendar 开市日不足,请先跑 10-seed-trade-calendar.sql")
day = rws[len(rws) // 2]["cal_date"]
if not isinstance(day, date):
day = date.fromisoformat(str(day)[:10])
return day, next_biz_day(day, is_open)
def main() -> int:
global TA_DAY
core_engine = get_engine("jinrong_core", role="admin")
agent_engine = get_engine("jinrong_agent", role="admin")
core_ro = CoreReadOnlyRepository()
print("=" * 60)
print("T-8 真库验证:引擎时机(受理不触引擎 / 确认后恰跑一次)")
print("=" * 60)
try:
ta_day, t1_day = _pick_days(core_engine, core_ro.is_open)
except Exception as exc: # noqa: BLE001
print(f"❌ 环境不可用:{exc}")
dispose_engines()
return 2
TA_DAY = ta_day
submit_at = datetime.combine(ta_day, time(10, 0))
confirm_at = datetime.combine(t1_day, time(9, 0))
print(f"T 日={ta_day}(受理)→ T+1={t1_day}(确认业务日)")
cleanup(core_engine, agent_engine)
seed(core_engine, ta_day, submit_at)
new_trace(TRACE)
exp = expected_quote(core_ro, QTY_ALL)
# ── A0:受理**不触引擎**(FR-C28 · 验收 29 前半)─────────────────
print("\n【A0】受理不触引擎:risk_alert 0 新增、无 pass / engine_error 审计")
accepted = accept_convert(
{
"customer_id": CUSTOMER,
"from_product_id": PROD_OUT,
"to_product_id": PROD_IN,
"qty": Decimal(QTY_ALL),
"client_request_id": CID_REQ,
},
now=submit_at,
id_factory=_t8m_id,
**_accept_services(),
)
gid = accepted["convert_group_id"]
check("受理成功(accepted)", accepted["status"], "accepted")
check("受理后 risk_alert 0 行",
q1(agent_engine, "SELECT COUNT(*) FROM risk_alert WHERE customer_id = :c",
c=CUSTOMER), 0)
check("受理后无 pass 审计",
q1(agent_engine, "SELECT COUNT(*) FROM audit_log WHERE trace_id = :t "
"AND decision = 'pass'", t=TRACE), 0)
check("受理后无 engine_error 审计",
q1(agent_engine, "SELECT COUNT(*) FROM audit_log WHERE trace_id = :t "
"AND decision = 'engine_error'", t=TRACE), 0)
check("受理后 core_trade 0 行",
q1(core_engine, "SELECT COUNT(*) FROM core_trade WHERE convert_group_id = :g",
g=gid), 0)
# ── A:确认后引擎真跑出单 ────────────────────────────────────────
print("\n【A】确认后引擎真跑:一张单 + payload.events 两条")
res = confirm_one(gid, now=confirm_at, as_of=t1_day, thresholds=TH, **_confirm_services())
check("确认状态 confirmed", res["status"], "confirmed")
check("折算 out_amount 与生产纯函数一致", Decimal(res["out_amount"]), exp["out_amount"])
check("折算 in_amount 与生产纯函数一致", Decimal(res["in_amount"]), exp["in_amount"])
check("折算 in_qty 与生产纯函数一致", Decimal(res["in_qty"]), exp["in_qty"])
check("RISK-001 命中(转出端 500000 ≥ 100000)", "RISK-001" in res["triggered_rules"], True)
check("只出一张单", len(res["alert_ids"]), 1)
alerts = rows(agent_engine, "SELECT * FROM risk_alert WHERE customer_id = :c", c=CUSTOMER)
check("risk_alert 落库 1 行", len(alerts), 1)
payload = alerts[0]["payload"]
payload = json.loads(payload) if isinstance(payload, str) else payload
check("payload.events 长度 2", len(payload["events"]), 2)
check("events 顺序 [转出, 转入]",
[e["trade_type"] for e in payload["events"]], ["redeem", "subscribe"])
check("events[0].trade_id 为转出端", payload["events"][0]["trade_id"], res["out_trade_id"])
check("events[1].trade_id 为转入端", payload["events"][1]["trade_id"], res["in_trade_id"])
check("主流水口径取转出端", alerts[0]["trade_id"], res["out_trade_id"])
# ── B:RISK-002 不翻倍(阈值夹逼)────────────────────────────────
print("\n【B】RISK-002 不翻倍(单条 500000 < 800000 < 两条之和)")
check("triggered_rules 不含 RISK-002", "RISK-002" in res["triggered_rules"], False)
total_two = exp["out_amount"] + exp["in_amount"]
check("两条之和确实 ≥ 阈值(夹逼成立)", total_two >= TH.daily_total, True)
print(f" └ 转出 {exp['out_amount']} + 转入 {exp['in_amount']} = {total_two}")
# ── C:去重不删行 ────────────────────────────────────────────────
print("\n【C】去重不删行:core_trade 仍 2 条且同组")
trades = rows(core_engine, "SELECT * FROM core_trade WHERE convert_group_id = :g", g=gid)
by_type = {t["trade_type"]: t for t in trades}
check("core_trade 2 条", len(trades), 2)
check("类型覆盖 [redeem, subscribe]", sorted(by_type), ["redeem", "subscribe"])
check("两条同 convert_group_id", {t["convert_group_id"] for t in trades}, {gid})
check("转出端金额精度零漂移(DECIMAL 18,2)",
Decimal(str(by_type["redeem"]["amount"])), exp["out_amount"])
check("转入端金额精度零漂移(DECIMAL 18,2)",
Decimal(str(by_type["subscribe"]["amount"])), exp["in_amount"])
# ── D:幂等重放(重复确认)不产生第二张单 ────────────────────────
print("\n【D】幂等重放:重复确认 skipped,不产生第二张单")
again = confirm_one(
gid, now=confirm_at, as_of=t1_day, thresholds=TH, **_confirm_services()
)
check("重放返回 skipped", again["status"], "skipped")
check("重放 core_trade 仍 2 条",
q1(core_engine, "SELECT COUNT(*) FROM core_trade WHERE convert_group_id = :g",
g=gid), 2)
check("重放后预警单仍 1 张",
q1(agent_engine, "SELECT COUNT(*) FROM risk_alert WHERE customer_id = :c",
c=CUSTOMER), 1)
check("重放后 lot 明细仍 2 条",
q1(core_engine, "SELECT COUNT(*) FROM core_convert_lot_detail "
"WHERE convert_group_id = :g", g=gid), 2)
# ── E:无命中场景 ────────────────────────────────────────────────
print("\n【E】无命中场景(独立小额客户)不建单、仅 pass 审计")
small = accept_convert(
{
"customer_id": CUSTOMER_SMALL,
"from_product_id": PROD_OUT,
"to_product_id": PROD_IN,
"qty": Decimal("1000"),
"client_request_id": "T8M-IDEM-0002",
},
now=submit_at,
id_factory=_t8m_id,
**_accept_services(),
)
res3 = confirm_one(
small["convert_group_id"],
now=confirm_at,
as_of=t1_day,
thresholds=TH,
**_confirm_services(),
)
check("小额转换确认成功", res3["status"], "confirmed")
check("未命中任何规则", res3["triggered_rules"], [])
check("未新建预警单",
q1(agent_engine, "SELECT COUNT(*) FROM risk_alert WHERE customer_id = :c",
c=CUSTOMER_SMALL), 0)
check("落 pass 审计 1 条",
q1(agent_engine, "SELECT COUNT(*) FROM audit_log WHERE trace_id = :t "
"AND decision = 'pass'", t=TRACE), 1)
# ── F:清理自检 ─────────────────────────────────────────────────
print("\n【F】清理 T8M 前缀数据并自检无残留")
cleanup(core_engine, agent_engine)
for label, engine, sql, params in [
("core_trade", core_engine,
"SELECT COUNT(*) FROM core_trade WHERE customer_id IN (:c1, :c2)",
{"c1": CUSTOMER, "c2": CUSTOMER_SMALL}),
("core_convert_request", core_engine,
"SELECT COUNT(*) FROM core_convert_request WHERE customer_id IN (:c1, :c2)",
{"c1": CUSTOMER, "c2": CUSTOMER_SMALL}),
("core 客户", core_engine,
"SELECT COUNT(*) FROM core_customer WHERE customer_id IN (:c1, :c2)",
{"c1": CUSTOMER, "c2": CUSTOMER_SMALL}),
("risk_alert", agent_engine,
"SELECT COUNT(*) FROM risk_alert WHERE customer_id IN (:c1, :c2)",
{"c1": CUSTOMER, "c2": CUSTOMER_SMALL}),
("customer_profile_l3", agent_engine,
"SELECT COUNT(*) FROM customer_profile_l3 WHERE customer_id IN (:c1, :c2)",
{"c1": CUSTOMER, "c2": CUSTOMER_SMALL}),
("audit_log", agent_engine,
"SELECT COUNT(*) FROM audit_log WHERE trace_id LIKE 'T8M-VERIFY%'", {}),
("risk_convert_detail", agent_engine,
"SELECT COUNT(*) FROM risk_convert_detail WHERE convert_group_id LIKE 'CNV-T8M%' "
"OR client_request_id LIKE 'T8M-%'", {}),
]:
check(f"{label} 零残留", q1(engine, sql, **params), 0)
dispose_engines()
print("\n" + "=" * 60)
print(f"真库验证(T-8 引擎时机):{_passed} 项一致 / {_failed} 项不一致")
print("=" * 60)
return 1 if _failed else 0
if __name__ == "__main__":
raise SystemExit(main())