"""T-1 验证脚本:用 pymysql 复刻 `scripts/core/reset.ps1` 的 SQL 部分并跑 DoD 断言。 背景:本机 `mysql` 客户端不在 PATH,且 reset.ps1 用 `mysql -p`(交互式密码), 无法在非交互环境直接跑。本脚本用 pymysql 执行**同一组 SQL 文件**(00~09), 再对 T-1 的三条 DoD 判定阈值逐条断言,做到「判定阈值明确、非人工目测」。 用法: python scripts/dev/verify_convert_seed.py # 重建 jinrong_core + 恢复演示态 + 全部断言 python scripts/dev/verify_convert_seed.py --no-reset # 只跑断言(库已就绪时) 注意 1:会 DROP DATABASE jinrong_core 并重建 —— 该库是 Core 模拟底座,本就设计为可重置 (与 reset.ps1 行为一致)。**不触碰 jinrong_agent**。 注意 2:重建后会自动补跑 `scripts/demo/prepare_risk_demo.sql`(reset 的既定伴随步骤)—— 不做的话 `03-seed-customers.sql` 写死的风评有效期会让真库集成模块整体 skip (实测 516 → 505 passed)。 """ from __future__ import annotations import argparse import re import sys from datetime import date from pathlib import Path import pymysql ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) from app.config.settings import settings # noqa: E402 CORE_DIR = ROOT / "scripts" / "core" SQL_FILES = [ "00-create-database.sql", "01-ddl.sql", "02-seed-base.sql", "03-seed-customers.sql", "04-seed-holdings.sql", "05-seed-trades.sql", "06-seed-nav.sql", "07-seed-fee-rule.sql", "08-seed-share-lot.sql", "09-seed-org.sql", "10-seed-trade-calendar.sql", "11-seed-share-rule.sql", ] # 档位覆盖参照日:与该批种子中 as_of / 净值日一致(2026-09-04) AS_OF = date(2026, 9, 4) BANDS = [(0, 7, "0.0150"), (7, 30, "0.0100"), (30, 180, "0.0050"), (180, 365, "0.0025"), (365, None, "0.0000")] def split_statements(sql: str) -> list[str]: """剥离 `--` 整行注释后按分号切分。种子文件均无存储过程/触发器,可安全切分。""" body = "\n".join(ln for ln in sql.splitlines() if not ln.strip().startswith("--")) return [s.strip() for s in body.split(";") if s.strip()] def connect(with_db: bool = False) -> pymysql.connections.Connection: return pymysql.connect( host=settings.mysql_host, port=settings.mysql_port, user=settings.mysql_user, password=settings.mysql_password, database=settings.mysql_core_database if with_db else None, charset="utf8mb4", autocommit=True, ) def reset_core() -> None: with connect() as conn, conn.cursor() as cur: cur.execute(f"DROP DATABASE IF EXISTS `{settings.mysql_core_database}`") print(f" DROP DATABASE {settings.mysql_core_database}") for name in SQL_FILES: path = CORE_DIR / name for stmt in split_statements(path.read_text(encoding="utf-8")): cur.execute(stmt) print(f" ✓ {name}") def run_demo_prepare() -> None: """把演示态刷回去(风评有效期)。 重建 jinrong_core 会让 `03-seed-customers.sql` 写死的风评有效期回到「已过期」状态, 于是 `tests/conftest.py::ensure_risk_demo_ready` 判定演示数据未就位, **整个真库集成模块被 skip**(实测 516 → 505 passed)。这是 reset 的既定伴随步骤 (演示 SOP §2),reset.ps1 因不含 agent 库步骤而未内置,本脚本补上。 """ path = ROOT / "scripts" / "demo" / "prepare_risk_demo.sql" with connect(with_db=True) as conn, conn.cursor() as cur: for stmt in split_statements(path.read_text(encoding="utf-8")): cur.execute(stmt) print(" ✓ prepare_risk_demo.sql(演示客户风评刷为「剩余 275 天」)") def band_of(hold_days: int) -> str: for lo, hi, rate in BANDS: if hold_days >= lo and (hi is None or hold_days < hi): return rate return "?" def run_assertions() -> bool: ok = True with connect(with_db=True) as conn, conn.cursor() as cur: # ---- DoD ① 赎回费 5 档,且每只产品各 5 档 ---- cur.execute("SELECT COUNT(DISTINCT min_hold_days) FROM core_fee_rule WHERE fee_type='redeem'") n_bands = cur.fetchone()[0] cur.execute( "SELECT product_id, COUNT(*) FROM core_fee_rule WHERE fee_type='redeem' " "GROUP BY product_id HAVING COUNT(*) <> 5" ) uneven = cur.fetchall() cur.execute("SELECT COUNT(*), COUNT(DISTINCT product_id) FROM core_fee_rule WHERE fee_type='redeem'") total_rows, n_products = cur.fetchone() p1 = n_bands == 5 and not uneven print(f"\n① 赎回费档位:DISTINCT min_hold_days={n_bands}(期望 5)· " f"{n_products} 只产品 × {total_rows // max(n_products, 1)} 档 · 非 5 档产品={uneven or '无'}") if not p1: print(" ✗ 不满足 5 档 / 每产品 5 档") ok &= p1 # ---- DoD ② Σ remain_qty 逐行等于 core_holding.qty ---- cur.execute( """ SELECT h.customer_id, h.product_id, h.qty, COALESCE(SUM(l.remain_qty), 0) AS lot_sum FROM core_holding h LEFT JOIN core_share_lot l ON l.customer_id = h.customer_id AND l.product_id = h.product_id GROUP BY h.customer_id, h.product_id, h.qty HAVING ABS(lot_sum - h.qty) > 0.0001 """ ) mismatch = cur.fetchall() cur.execute("SELECT COUNT(*) FROM core_holding") n_holding = cur.fetchone()[0] cur.execute("SELECT COUNT(*), COUNT(DISTINCT CONCAT(customer_id, '|', product_id)) FROM core_share_lot") n_lots, n_pairs = cur.fetchone() p2 = not mismatch print(f"② 批次守恒:{n_holding} 行持仓 vs {n_lots} 行批次({n_pairs} 个客户-产品对)· " f"失配={mismatch or '无'}") if not p2: print(" ✗ 存在 Σ remain_qty ≠ qty 的持仓行") ok &= p2 # ---- DoD ③ 三列无 NULL 且存在不同费率对 ---- cur.execute( "SELECT COUNT(*) FROM core_product " "WHERE fund_company IS NULL OR ta_code IS NULL OR subscribe_fee_rate IS NULL" ) n_null = cur.fetchone()[0] cur.execute("SELECT COUNT(DISTINCT subscribe_fee_rate) FROM core_product") n_rates = cur.fetchone()[0] cur.execute( "SELECT product_id, subscribe_fee_rate FROM core_product " "WHERE product_id IN ('PROD-110022','PROD-003095','PROD-005827','PROD-510300','PROD-000001') " "ORDER BY product_id" ) demo = cur.fetchall() p3 = n_null == 0 and n_rates > 1 print(f"③ 机构/费率:NULL 行数={n_null}(期望 0)· DISTINCT 费率={n_rates}(期望 >1)") print(f" 演示费率对:{[(p, str(r)) for p, r in demo]}") if not p3: print(" ✗ 存在 NULL 或不存在不同费率对") ok &= p3 # ---- 附加:批次实际覆盖的档位(以 AS_OF 为参照) ---- cur.execute("SELECT lot_id, confirmed_at FROM core_share_lot") rows = cur.fetchall() seen: dict[str, int] = {} for _lot, ts in rows: b = band_of((AS_OF - ts.date()).days) seen[b] = seen.get(b, 0) + 1 p4 = len(seen) == 5 print(f"④ 批次档位覆盖(参照 {AS_OF}):{dict(sorted(seen.items()))}") if not p4: print(" ✗ 未覆盖全部 5 档 → 费率档演示无数据") ok &= p4 # ---- 附加:主演示客户 CUST-9527 确为跨批次 ---- cur.execute( "SELECT product_id, COUNT(*) FROM core_share_lot WHERE customer_id='CUST-9527' " "GROUP BY product_id ORDER BY product_id" ) c9527 = cur.fetchall() p5 = any(n >= 2 for _p, n in c9527) print(f"⑤ CUST-9527 批次数:{c9527}(至少一只产品 ≥2 批)") if not p5: print(" ✗ 主演示客户无跨批次数据") ok &= p5 # ---- DoD ⑨ 交易日历种子(T-1 · 10-seed):全区间(2026~2027)交易日 = 503 ---- cur.execute("SELECT COUNT(*) FROM core_trade_calendar WHERE is_open = 1") n_trade_days = cur.fetchone()[0] p9a = n_trade_days == 503 print(f"⑨a 交易日历:全区间交易日(is_open=1)={n_trade_days}(期望 503)") if not p9a: print(" ✗ 交易日历种子与 10-seed 头注释 503 不符") ok &= p9a # ---- DoD ⑩ core_share_rule 种子(T-1 · 11-seed):覆盖 ≥ 1 个产品 × 3 类型 ---- cur.execute( "SELECT COUNT(DISTINCT product_id), COUNT(*) FROM core_share_rule " "WHERE business_type IN ('subscribe','redeem','convert')" ) n_rule_products, n_rule_rows = cur.fetchone() p10 = n_rule_products >= 1 and n_rule_rows >= 3 print(f"⑩ 份额舍入规则:{n_rule_products} 只产品 × {n_rule_rows} 行(期望 ≥1 产品且 ≥3 行)") if not p10: print(" ✗ core_share_rule 种子不足") ok &= p10 # ---- T-14 开口②闭环:09-seed-org.sql ⑦ 段 truncate 载体在位 ---- cur.execute( "SELECT rounding_mode FROM core_product WHERE product_id = 'PROD-005828'" ) tr_row = cur.fetchone() p12 = bool(tr_row) and tr_row[0] == "truncate" print(f"⑫ 舍去法载体:PROD-005828.rounding_mode = {tr_row[0] if tr_row else None}(期望 truncate)") if not p12: print(" ✗ ⑦ 段未生效(重灌 scripts/core/09-seed-org.sql)") ok &= p12 # ---- DoD ⑪ core_convert_request 建表即含 6 态 ENUM(T-1 · 免二期 ALTER)---- cur.execute( "SELECT COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() " "AND TABLE_NAME = 'core_convert_request' AND COLUMN_NAME = 'status'" ) req_row = cur.fetchone() if req_row: vals = set(re.findall(r"'(\w+)'", req_row[0])) p11 = vals == {"accepted", "nav_pending", "confirmed", "rejected", "cancelled", "expired"} print(f"⑪ core_convert_request.status = {req_row[0]}") if not p11: print(f" ✗ ENUM 值不全(实际 {vals})") else: p11 = False print(f" ✗ 查不到 core_convert_request({req_row})") ok &= p11 # ---- DoD ⑥ 向后兼容:不带新列的老 INSERT 仍可执行(架构风险 #9)---- cur.execute("START TRANSACTION") try: cur.execute( "INSERT INTO core_trade (trade_id, customer_id, product_id, trade_type, amount, traded_at) " "VALUES ('TRD-BC-TEST', 'CUST-9527', 'PROD-110022', 'subscribe', 100.00, NOW(3))" ) cur.execute("SELECT convert_group_id FROM core_trade WHERE trade_id = 'TRD-BC-TEST'") gid = cur.fetchone()[0] cur.execute( "INSERT INTO core_product (product_id, product_name, product_type, min_risk_code, " "min_subscribe_amount, requires_disclosure) VALUES " "('PROD-BC-TEST', '向后兼容测试', 'bond', 'R1', 100.00, 0)" ) cur.execute( "SELECT can_subscribe, can_redeem, min_hold_action, subscribe_fee_rate, fund_company " "FROM core_product WHERE product_id = 'PROD-BC-TEST'" ) row = cur.fetchone() p6 = ( gid is None and row[0] == 1 and row[1] == 1 and row[2] == "force_transfer" and row[3] == 0 and row[4] is None ) finally: cur.execute("ROLLBACK") print(f"⑥ 老 INSERT 向后兼容:core_trade.convert_group_id={gid}(期望 None)· " f"core_product 新列默认值={row}(期望 1/1/force_transfer/0/None)") if not p6: print(" ✗ 不带新列的老 INSERT 失败或默认值不符") ok &= p6 # ---- DoD ⑦ risk_convert_detail 建表即含 5 个 status 值(免二期 ALTER)---- doc = (ROOT / "docs" / "项目框架设计" / "表设计" / "02-mysql-agent专用.sql").read_text(encoding="utf-8") m = re.search(r"CREATE TABLE risk_convert_detail \(.*?\n\) ENGINE=InnoDB[^;]*;", doc, re.S) assert m, "02-mysql-agent专用.sql 中未找到 risk_convert_detail 建表语句" tmp_db = "jinrong_tmp_check" with connect() as conn, conn.cursor() as cur: cur.execute(f"DROP DATABASE IF EXISTS {tmp_db}") cur.execute(f"CREATE DATABASE {tmp_db}") cur.execute(f"USE {tmp_db}") cur.execute(m.group(0)) cur.execute( "SELECT COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = %s " "AND TABLE_NAME = 'risk_convert_detail' AND COLUMN_NAME = 'status'", (tmp_db,), ) col_type = cur.fetchone()[0] cur.execute(f"DROP DATABASE {tmp_db}") vals = set(re.findall(r"'(\w+)'", col_type)) p7 = vals == {"pending", "completed", "failed", "cancelled", "expired"} print(f"⑦ risk_convert_detail.status = {col_type}") if not p7: print(f" ✗ ENUM 值不全(实际 {vals})") ok &= p7 # ---- DoD ⑧ 费率档必须与 product_type 匹配(自检第 12 问的机器化)---- # 22 号文 §8 的费率档由产品类型决定:stock ≤0.80% / mixed ≤0.50% / # bond·index ≤0.30% / money = 0。任何「升档凑数值差」都会在此暴露。 CAPS = {"stock": 0.0080, "mixed": 0.0050, "bond": 0.0030, "index": 0.0030, "money": 0.0} # 非公募主体:不适用 22 号文公募费率规定,本项目建模为费率 0 且不与公募互转 NOT_APPLICABLE = {"wealth_mgmt", "private_fund"} with connect(with_db=True) as conn, conn.cursor() as cur: cur.execute("SELECT product_id, product_type, subscribe_fee_rate FROM core_product") rows = cur.fetchall() offenders = [ (pid, ptype, float(rate)) for pid, ptype, rate in rows if ptype in CAPS and float(rate) > CAPS[ptype] + 1e-9 ] unmatched = [r for r in rows if r[1] not in CAPS and r[1] not in NOT_APPLICABLE] na_offenders = [ (pid, ptype, float(rate)) for pid, ptype, rate in rows if ptype in NOT_APPLICABLE and float(rate) != 0 ] # 主示例两端必须同主体(同管理人 + 同 TA),否则转换前置约束不成立 cur.execute( "SELECT DISTINCT fund_company, ta_code FROM core_product " "WHERE product_id IN ('PROD-110022', 'PROD-003095') AND fund_company IS NOT NULL" ) entities = cur.fetchall() # 主演示组内费率档必须多样(否则补差费/同费率对照无数据) cur.execute( "SELECT COUNT(DISTINCT subscribe_fee_rate) FROM core_product " "WHERE fund_company = '华夏模拟基金' AND ta_code = 'TA-CN-001'" ) main_group_rates = cur.fetchone()[0] p8 = ( not offenders and not unmatched and not na_offenders and len(entities) == 1 and main_group_rates >= 3 ) print(f"⑧ 费率档 ↔ product_type 匹配:越档={offenders or '无'} · 未归类={unmatched or '无'} · " f"非公募费率非 0={na_offenders or '无'}") print(f" 主示例两端主体={entities}(须唯一)· 华夏组费率档数={main_group_rates}(须 ≥3)") if not p8: print(" ✗ 存在「按更宽松档次取费率」或主示例两端非同主体 / 主组费率档不足") ok &= p8 return ok def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--no-reset", action="store_true", help="跳过重建,只跑断言") args = ap.parse_args() if not args.no_reset: print("== 重建 jinrong_core(等价 reset.ps1 的 SQL 部分)==") reset_core() print("== 恢复演示态 ==") run_demo_prepare() print("\n== T-1 DoD 断言 ==") ok = run_assertions() print("\n" + ("全部 PASS ✅" if ok else "存在 FAIL ❌")) return 0 if ok else 1 if __name__ == "__main__": raise SystemExit(main())