① PRD §2.5.3 修订:删「易方达 ETF 场外取整数位」(无证据且有反证,真实整数位载体为场内),改记「2 位主流 + 舍去型真实存在」,版本块加注记 ② rounding_mode=truncate 消费接入(用户拍板补做): - core_ro.get_share_rounding = 位数+模式回退链单点(规则行 → core_product → 默认 2/half_up,mode 白名单读取即校验,get_share_digits 转薄包装) - calc.product_round/in_qty 加 mode 参数(truncate → ROUND_DOWN) - 确认段 + 申购折算两处透传;载体 09-seed-org.sql ⑦ 段 PROD-005828(product 层回退,零转换场景引用) - 测试 +7(842→849):calc 4 + 确认段 2(truncate 34945.53 vs half_up .54 分化 + 回退链遮蔽语义)+ 申购段 1 - 突变:两调用点同拆 mode 透传 → 2 红,还原绿;真库 9/9(seed 新增 ⑫ 载体在位断言) ③ R-12 口径裁定追认定稿:条目回写开发计划(NULL 不参与 A/C 判定 / 同类走一般转换规则) 审核教训留痕:PRD 独立 AI 三轮审查未抓出外部事实断言无出处 —— 联网核实铁律再添例证 文档:开发计划 T-14 开口闭环段 + 基线 849 · 日志新节 · TODO/MEMORY/AGENTS/交接文档开口清零
364 lines
16 KiB
Python
364 lines
16 KiB
Python
"""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())
|