"""T-10 真 MySQL 验证脚本:普通申赎批次维护 + `rebuild_lots.py` 实跑 + DoD 断言。 **为什么 sqlite 单测全绿还不够** T-10 的扣减 SQL 写的是 `remain_qty = remain_qty - (:q + 0.0)`,其中 `+ 0.0` 是**为绕开 sqlite 坑**而加的(sqlite 在 UPDATE 的算术表达式里不把 TEXT 绑定参数转数值 → 不加就扣 0;见开发计划 §B.8 第 11 条)。 **该写法在 MySQL 是否同样成立,sqlite 侧证明不了** —— 真库若因字符串绑定 没被隐式转换成数值,扣减会静默失效(批次永远扣不掉 → convert 超扣)。 此外真库还能验证 sqlite 测不到的:DECIMAL(18,4) 存取精度、InnoDB 真实提交语义, 以及 `rebuild_lots.py` 的 DELETE/INSERT 在真库是否跑通。 用法: python scripts/dev/verify_convert_lots.py # 建隔离数据 → 跑断言 → 清理 注意: - 全部数据用 **LOTT 前缀**(客户 `CUST-LOTT` / 产品 `PROD-LOTT*`), 跑完 DELETE 干净,不碰既有种子; - 建/清数据走 `role="admin"`(需 DELETE,R-e);**被测路径走生产同款仓储** (`GatewayRepository` rw 写 + `CoreReadOnlyRepository` ro 读); - 退出码 1 = 有断言不一致(供 CI / 人工判定)。 """ from __future__ import annotations import sys from datetime import date, datetime from decimal import ROUND_HALF_UP, 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.gateway_repository import GatewayRepository # noqa: E402 from app.gateway.trade_gateway import _maintain_lots # noqa: E402 from app.repository.core_ro import CoreReadOnlyRepository # noqa: E402 from app.service.convert.lot_bootstrap import bootstrap_lots # noqa: E402 from app.utils.db import dispose_engines, get_engine # noqa: E402 CUSTOMER = "CUST-LOTT" PROD_SUB = "PROD-LOTTA" # A 组:申购建批次 PROD_BOOT = "PROD-LOTTB" # D 组:无批次有持仓 → D8 兜底补建 PROD_REDEEM = "PROD-LOTTC" # B/C 组:赎回 FIFO 扣减 NAV_DATE = date(2026, 9, 4) TRADE_AT = datetime(2026, 9, 4, 14, 0, 0) _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), rounding=ROUND_HALF_UP) def q1(engine, sql: str, **params): with engine.connect() as conn: return conn.execute(text(sql), params).scalar_one_or_none() # ── 数据准备 / 清理 ───────────────────────────────────────────────── def seed(engine) -> None: with engine.begin() as conn: conn.execute( text( "INSERT INTO core_customer (customer_id, display_name, open_date) " "VALUES (:c, 'T10真库验证', :d)" ), {"c": CUSTOMER, "d": NAV_DATE}, ) for pid, name in [ (PROD_SUB, "T10申购基金"), (PROD_BOOT, "T10兜底基金"), (PROD_REDEEM, "T10赎回基金"), ]: conn.execute( text( "INSERT INTO core_product (product_id, product_name, min_risk_code, " "product_type, can_subscribe, can_redeem) " "VALUES (:p, :n, 'R2', 'bond', 1, 1)" ), {"p": pid, "n": name}, ) # 三个产品的 T 日净值:A 用 1.2 验折算,B/C 用 1.0 让份额=金额、断言直观 for pid, nav in [(PROD_SUB, "1.2000"), (PROD_BOOT, "1.0000"), (PROD_REDEEM, "1.0000")]: conn.execute( text( "INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) " "VALUES (:p, :nav, 0, :d)" ), {"p": pid, "nav": nav, "d": NAV_DATE}, ) # C 组:两个已存在批次(FIFO 扣减用) for lot_id, qty, nav, confirmed in [ ("LOT-LOTT-C1", "100", "1.0000", datetime(2026, 9, 1, 10, 0, 0)), ("LOT-LOTT-C2", "50", "1.1000", datetime(2026, 9, 2, 10, 0, 0)), ]: 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, :nav, :cat)" ), {"l": lot_id, "c": CUSTOMER, "p": PROD_REDEEM, "q": qty, "nav": nav, "cat": confirmed}, ) # D 组:只有持仓、没有批次(D8 兜底补建) # A 组:持仓与申购批次同额(供 rebuild_lots 重建验证) for pid, qty, cost in [(PROD_BOOT, "100", "100.00"), (PROD_SUB, "10000", "12000.00")]: conn.execute( text( "INSERT INTO core_holding (customer_id, product_id, qty, cost_amount, " "market_value, pnl_pct, as_of) VALUES (:c, :p, :q, :cost, :cost, 0, :d)" ), {"c": CUSTOMER, "p": pid, "q": qty, "cost": cost, "d": date(2026, 8, 1)}, ) def cleanup(engine) -> None: """倒序删(子表 → 父表),只删 LOTT 前缀数据。""" with engine.begin() as conn: conn.execute(text("DELETE FROM core_share_lot WHERE customer_id = :c"), {"c": CUSTOMER}) conn.execute(text("DELETE FROM core_holding WHERE customer_id = :c"), {"c": CUSTOMER}) conn.execute(text("DELETE FROM core_product_nav WHERE product_id LIKE 'PROD-LOTT%'")) conn.execute(text("DELETE FROM core_product WHERE product_id LIKE 'PROD-LOTT%'")) conn.execute(text("DELETE FROM core_customer WHERE customer_id = :c"), {"c": CUSTOMER}) # ── 断言组 ────────────────────────────────────────────────────────── def assert_subscribe(admin, writer, core) -> None: print("【A】申购建批次(FR-C16 · qty = amount ÷ T 日净值)") _maintain_lots( writer=writer, core=core, trade_id="TRD-LOTT-SUB", customer_id=CUSTOMER, product_id=PROD_SUB, trade_type="subscribe", amount=Decimal("12000"), traded_at=TRADE_AT, ) check( "批次行数", q1(admin, "SELECT COUNT(*) FROM core_share_lot WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_SUB), 1, ) check( "lot_id", q1(admin, "SELECT lot_id FROM core_share_lot WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_SUB), "LOT-SUB-TRD-LOTT-SUB", ) check( "qty = 12000 ÷ 1.2000", dec(q1(admin, "SELECT qty FROM core_share_lot WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_SUB)), dec("10000.00"), ) check( "remain_qty = qty", dec(q1(admin, "SELECT remain_qty FROM core_share_lot WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_SUB)), dec("10000.00"), ) check( "nav 4 位精度存取", dec(q1(admin, "SELECT nav FROM core_share_lot WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_SUB), "0.0001"), Decimal("1.2000"), ) check( "confirmed_at = T", str(q1(admin, "SELECT confirmed_at FROM core_share_lot WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_SUB)), "2026-09-04 14:00:00", ) def assert_redeem_fifo(admin, writer, core) -> None: print("【B/C】赎回 FIFO 扣减(★ 真库验证 `(:q + 0.0)` 方言语义)") # 120 元 ÷ 1.0 = 120 份 → C1 扣满 100 归零,C2 再扣 20 _maintain_lots( writer=writer, core=core, trade_id="TRD-LOTT-RED", customer_id=CUSTOMER, product_id=PROD_REDEEM, trade_type="redeem", amount=Decimal("120"), traded_at=TRADE_AT, ) check( "C1(最老批)remain_qty 归零 —— 扣减真库生效", dec(q1(admin, "SELECT remain_qty FROM core_share_lot WHERE lot_id = 'LOT-LOTT-C1'")), dec("0.00"), ) check( "C2 remain_qty = 50 − 20", dec(q1(admin, "SELECT remain_qty FROM core_share_lot WHERE lot_id = 'LOT-LOTT-C2'")), dec("30.00"), ) check( "归零批次**保留行不删**(D9/P2)", q1(admin, "SELECT COUNT(*) FROM core_share_lot WHERE lot_id = 'LOT-LOTT-C1'"), 1, ) check( "不超扣:Σ remain = 150 − 120", dec(q1(admin, "SELECT SUM(remain_qty) FROM core_share_lot WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_REDEEM)), dec("30.00"), ) def assert_bootstrap(admin, writer, core) -> None: print("【D】D8 兜底补建:无批次有持仓 → 按 as_of 补建后再扣") _maintain_lots( writer=writer, core=core, trade_id="TRD-LOTT-BOOT", customer_id=CUSTOMER, product_id=PROD_BOOT, trade_type="redeem", amount=Decimal("30"), traded_at=TRADE_AT, ) check( "补建批次行数", q1(admin, "SELECT COUNT(*) FROM core_share_lot WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_BOOT), 1, ) check( "lot_id = 确定性兜底 id", q1(admin, "SELECT lot_id FROM core_share_lot WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_BOOT), "LOT-BOOT-CUST-LOTT-PROD-LOTTB", ) check( "qty = 持仓快照 100", dec(q1(admin, "SELECT qty FROM core_share_lot WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_BOOT)), dec("100.00"), ) check( "补建 100 后扣 30 → remain 70", dec(q1(admin, "SELECT remain_qty FROM core_share_lot WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_BOOT)), dec("70.00"), ) def assert_rebuild_lots(admin) -> None: print("【E】rebuild_lots 真库跑通(D18 同源 + 幂等)") import importlib.util spec = importlib.util.spec_from_file_location( "rebuild_lots_under_test", ROOT / "scripts" / "core" / "rebuild_lots.py" ) rebuild = importlib.util.module_from_spec(spec) spec.loader.exec_module(rebuild) # 幂等:此刻三个产品都已有批次 → 零写入 stats = rebuild.rebuild_lots(admin, customer_id=CUSTOMER) check("默认模式:已有批次全跳过", stats["written"], 0) check("默认模式:补建 0 行", stats["bootstrapped"], 0) # 人为删掉 A 组批次 → 模拟「批次缺失」,验证真库补建 with admin.begin() as conn: conn.execute( text("DELETE FROM core_share_lot WHERE customer_id = :c AND product_id = :p"), {"c": CUSTOMER, "p": PROD_SUB}, ) stats = rebuild.rebuild_lots(admin, customer_id=CUSTOMER) check("补建 1 行", stats["written"], 1) rebuilt_lot_id = q1( admin, "SELECT lot_id FROM core_share_lot WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_SUB, ) check("补建 lot_id", rebuilt_lot_id, "LOT-BOOT-CUST-LOTT-PROD-LOTTA") check( "补建份额 = 持仓快照", dec(q1(admin, "SELECT remain_qty FROM core_share_lot WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_SUB)), dec("10000.00"), ) # ★ D18 同源:真库补出的 confirmed_at 必须与 bootstrap_lots 直算一致 holding = { "customer_id": CUSTOMER, "product_id": PROD_SUB, "qty": q1(admin, "SELECT qty FROM core_holding WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_SUB), "cost_amount": q1(admin, "SELECT cost_amount FROM core_holding WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_SUB), "as_of": q1(admin, "SELECT as_of FROM core_holding WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_SUB), } expected_confirmed = bootstrap_lots(holding)[0].confirmed_at check( "D18 同源:真库补建 confirmed_at == bootstrap_lots 直算", str(q1(admin, "SELECT confirmed_at FROM core_share_lot WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_SUB)), str(expected_confirmed), ) def main() -> int: admin = get_engine(settings.mysql_core_database, "admin") try: with admin.connect() as conn: conn.execute(text("SELECT 1")) except Exception as exc: # noqa: BLE001 - CLI 兜底 print(f"MySQL 不可达:{exc}", file=sys.stderr) print("请确认本机 MySQL 服务已启动(见 FLOW §0 本机状态)。", file=sys.stderr) return 1 cleanup(admin) try: seed(admin) # 被测路径走生产同款仓储(rw 写 / ro 读) writer = GatewayRepository() core = CoreReadOnlyRepository() assert_subscribe(admin, writer, core) assert_redeem_fifo(admin, writer, core) assert_bootstrap(admin, writer, core) assert_rebuild_lots(admin) finally: cleanup(admin) dispose_engines() print(f"\n{'=' * 60}") print(f"真库验证:{_passed} 项一致 / {_failed} 项不一致") return 1 if _failed else 0 if __name__ == "__main__": sys.exit(main())