"""T-11 真 MySQL 验证脚本:金额汇总去重 + 归零行过滤(DoD 逐条断言)。 **为什么 sqlite 单测全绿还不够** T-11 的核心是把「一次转换只计一次金额」这个口径,从 Python(`rules.amount_view`) 复制到 SQL(`core_ro.sum_trades_on_date`)。跨语言的两份实现最容易漂移, **在 NULL 值域上尤其危险**: - `amount_view` 判的是 Python 的 `if not gid` —— **NULL 与空串都算「无组」**; - 若 SQL 只写 `convert_group_id IS NULL`,**空串会被漏掉**(三值逻辑里 `'' IS NULL` 恒为假), 该笔普通交易从当日累计里凭空消失 → RISK-002 少算、且**测试很难发现** (sqlite 单测若只造 NULL 数据,这条分支永远不被执行)。 于是本脚本专门造一笔 `convert_group_id = ''` 的真库数据,并**同时跑两条 SQL 做对照** (只 `IS NULL` vs `IS NULL OR = ''`),把「差异是真实存在的」变成可打印的证据。 同时验证 `list_holdings` 的 `qty > 0` 在 MySQL `DECIMAL(18,4)` 下的比较语义 (convert 转出全部份额后 `qty = 0.0000` 的台账留痕行必须被排除)。 用法: python scripts/dev/verify_convert_tools.py # 建隔离数据 → 跑断言 → 清理 注意: - 全部数据用 **TOOLT 前缀**(客户 `CUST-TOOLT` / 产品 `PROD-TOOLT*`),跑完清理干净,不碰既有种子; - 建/清数据走 `role="admin"`(需 DELETE,R-e);被测路径走生产同款仓储 (`CoreReadOnlyRepository` ro 读 + `core_tools` 同款函数); - 退出码 1 = 有断言不一致(供 CI / 人工判定)。 """ from __future__ import annotations import sys from datetime import 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.repository.core_ro import CoreReadOnlyRepository # noqa: E402 from app.tool.core_tools import query_holdings, query_recent_trades # noqa: E402 from app.utils.db import dispose_engines, get_engine # noqa: E402 CUSTOMER = "CUST-TOOLT" PROD_A = "PROD-TOOLTA" # 正常持仓(qty > 0) PROD_B = "PROD-TOOLTB" # 归零行(qty = 0,convert 转出全部后的台账留痕) GROUP = "G-TOOLT-1" # 金额设计(每一项都能区分「对 / 未去重 / 只判 IS NULL」三种实现): AMT_OUT = "300000" # convert 转出端 → 应计入 AMT_IN = "300000" # convert 转入端 → 同组,不应计入 AMT_PLAIN = "100000" # 普通赎回(gid = NULL)→ 应计入 AMT_EMPTY = "50000" # gid = 空串 → 视为无组,**应计入**(区分 ='' 分支的关键) EXPECTED_SUM = Decimal("450000") # 300000 + 100000 + 50000 SUM_IF_NO_DEDUP = Decimal("750000") # 未去重:再 + 300000 SUM_IF_NULL_ONLY = Decimal("400000") # 只写 IS NULL:丢掉空串那笔 50000 _passed = 0 _failed = 0 def check(name: str, actual, expected) -> None: """逐条断言并打印(与既有 verify_convert_*.py 同款输出)。""" 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}")) def dec(value, places: str = "0.01") -> Decimal: """真库读回是 `Decimal`(MySQL DECIMAL)→ 统一量化后比较。""" return Decimal(str(value)).quantize(Decimal(places)) def q1(engine, sql: str, **params): with engine.connect() as conn: return conn.execute(text(sql), params).scalar_one_or_none() # ── 数据准备 / 清理 ───────────────────────────────────────────────── def cleanup(engine) -> None: """按 FK 依赖倒序删:trade → holding → product → customer(全部按前缀)。""" with engine.begin() as conn: conn.execute( text("DELETE FROM core_trade WHERE customer_id LIKE :p"), {"p": f"{CUSTOMER}%"} ) conn.execute( text("DELETE FROM core_holding WHERE customer_id LIKE :p"), {"p": f"{CUSTOMER}%"} ) conn.execute( text("DELETE FROM core_product WHERE product_id LIKE :p"), {"p": "PROD-TOOLT%"} ) conn.execute( text("DELETE FROM core_customer WHERE customer_id LIKE :p"), {"p": f"{CUSTOMER}%"} ) def seed(engine, now: datetime) -> None: today = now.date() with engine.begin() as conn: conn.execute( text( "INSERT INTO core_customer (customer_id, display_name, open_date) " "VALUES (:c, 'T11真库验证', :d)" ), {"c": CUSTOMER, "d": today}, ) for pid, name in [(PROD_A, "T11正常持仓基金"), (PROD_B, "T11归零基金")]: conn.execute( text( # product_type 是真库 ENUM('money','bond','mixed',...) 的中文值非法 "INSERT INTO core_product (product_id, product_name, min_risk_code, " "product_type, can_subscribe, can_redeem) " "VALUES (:p, :n, 'R3', 'mixed', 1, 1)" ), {"p": pid, "n": name}, ) # 持仓:一条正常 + 一条归零(qty = 0,行必须保留 —— 台账留痕,不物理删除) conn.execute( text( "INSERT INTO core_holding (customer_id, product_id, qty, cost_amount, " "market_value, pnl_pct, as_of) VALUES " "(:c, :pa, 1000.0000, 1000.00, 1200.00, 0.2000, :d), " "(:c, :pb, 0.0000, 0.00, 0.00, 0.0000, :d)" ), {"c": CUSTOMER, "pa": PROD_A, "pb": PROD_B, "d": today}, ) # 流水:一组 convert(两条共享 gid)+ 普通赎回(NULL)+ 空串 gid 申购 rows = [ ("TX-TOOLT-OUT", PROD_A, "redeem", AMT_OUT, GROUP), ("TX-TOOLT-IN", PROD_B, "subscribe", AMT_IN, GROUP), ("TX-TOOLT-PLAIN", PROD_A, "redeem", AMT_PLAIN, None), ("TX-TOOLT-EMPTY", PROD_A, "subscribe", AMT_EMPTY, ""), ] for tid, pid, ttype, amt, gid in rows: conn.execute( text( "INSERT INTO core_trade (trade_id, customer_id, product_id, trade_type, " "amount, qty, convert_group_id, trade_status, traded_at) " "VALUES (:tid, :c, :p, :tt, :amt, 100.0000, :gid, 'confirmed', :t)" ), {"tid": tid, "c": CUSTOMER, "p": pid, "tt": ttype, "amt": amt, "gid": gid, "t": now}, ) # ── 被测路径断言 ──────────────────────────────────────────────────── def run_checks(engine, now: datetime) -> None: core_ro = CoreReadOnlyRepository(engine=get_engine(settings.mysql_core_database, "ro")) today = now.date() day_start = datetime.combine(today, time.min) print("\n[A] 真库值域确认(前提)") gid_out = q1( engine, "SELECT convert_group_id FROM core_trade WHERE trade_id = 'TX-TOOLT-OUT'" ) gid_plain = q1( engine, "SELECT convert_group_id FROM core_trade WHERE trade_id = 'TX-TOOLT-PLAIN'" ) gid_empty = q1( engine, "SELECT convert_group_id FROM core_trade WHERE trade_id = 'TX-TOOLT-EMPTY'" ) check("convert 转出端 gid 非空", gid_out, GROUP) check("普通交易 gid 为 NULL", gid_plain, None) check("空串 gid 真库存成空串(非 NULL)", gid_empty, "") print("\n[B] sum_trades_on_date 去重(R-d)") actual_sum = core_ro.sum_trades_on_date(CUSTOMER, today) check("去重后当日合计", dec(actual_sum), EXPECTED_SUM) check("不等于未去重的值(去重真的生效)", dec(actual_sum) != SUM_IF_NO_DEDUP, True) print("\n[C] 对照组:只写 IS NULL 会漏掉空串那笔(证明 ='' 分支必要)") null_only = q1( engine, "SELECT COALESCE(SUM(amount), 0) FROM core_trade " "WHERE customer_id = :c AND trade_type IN ('subscribe','redeem') " "AND trade_status = 'confirmed' " "AND traded_at >= :s AND traded_at < :e " "AND (convert_group_id IS NULL OR trade_type = 'redeem')", c=CUSTOMER, s=day_start, e=day_start + timedelta(days=1), ) check("只判 IS NULL 的结果(少算空串那笔)", dec(null_only), SUM_IF_NULL_ONLY) check("与正确口径差额 = 空串那笔金额", dec(actual_sum) - dec(null_only), Decimal(AMT_EMPTY)) print("\n[D] core_tools.query_recent_trades 汇总去重(FR-C15)") res = query_recent_trades(CUSTOMER, days=1, core_ro=core_ro) check("明细保持全量(4 条)", res["total_count"], 4) check("sum_amount 不翻倍", dec(res["sum_amount"]), EXPECTED_SUM) check("跨口径一致:Tool 汇总 == 仓储 SQL 汇总", dec(res["sum_amount"]), dec(actual_sum)) print("\n[E] core_tools.query_holdings 归零行过滤") holdings = query_holdings(CUSTOMER, core_ro=core_ro) check("持仓条数(归零行被排除)", holdings["total_count"], 1) check("返回的是正常持仓产品", [r["product_id"] for r in holdings["items"]], [PROD_A]) check("合计市值不含归零行", dec(holdings["sum_market_value"]), Decimal("1200")) print("\n[F] 底表复核:归零行确实还在库里(过滤发生在查询侧,不是删除)") zero_rows = q1( engine, "SELECT COUNT(*) FROM core_holding WHERE customer_id = :c AND qty <= 0", c=CUSTOMER, ) check("core_holding 归零行仍保留", zero_rows, 1) def main() -> int: now = datetime.now() engine = get_engine(settings.mysql_core_database, "admin") print("T-11 真库验证:金额汇总去重(FR-C15 / R-d)+ 归零行过滤") print(f"客户={CUSTOMER} 产品={PROD_A}/{PROD_B} 交易日={now.date()}") cleanup(engine) try: seed(engine, now) run_checks(engine, now) finally: cleanup(engine) left = q1( engine, "SELECT (SELECT COUNT(*) FROM core_trade WHERE customer_id LIKE 'CUST-TOOLT%') " "+ (SELECT COUNT(*) FROM core_holding WHERE customer_id LIKE 'CUST-TOOLT%') " "+ (SELECT COUNT(*) FROM core_product WHERE product_id LIKE 'PROD-TOOLT%') " "+ (SELECT COUNT(*) FROM core_customer WHERE customer_id LIKE 'CUST-TOOLT%')", ) print(f"\n清理后残留行数:{left}") dispose_engines() print(f"\n结果:{_passed} 通过 / {_failed} 失败") return 1 if _failed else 0 if __name__ == "__main__": raise SystemExit(main())