Files
group_xinghuo_jinrong/scripts/dev/verify_convert_tools.py
T
GaoYiYuan_0626 a6ac39edd6 基金转换 T-11:工具与 SQL 汇总去重(FR-C15 / R-d)
convert 在 core_trade 落两条流水(转出 redeem + 转入 subscribe,共享 convert_group_id),
所有金额聚合读取方必须只计一次,否则同笔转换金额翻倍。

落地(改码 3 处 + 新增 1 脚本 + 测试 3 文件)
- rules.py:_amount_view → 公开 amount_view(提升而非复制别名,全仓唯一金额聚合口径);
  docstring 补「跨模块共用」说明
- core_tools.py:query_recent_trades 的 sum_amount 改走 amount_view(FR-C15);
  items / total_count 保持全量不变——两条流水是真实的两笔权益变动,读到两条是对的
- core_ro.py:list_holdings 加 h.qty > 0(convert 转出全部份额留下的 qty=0 归零行不是持仓);
  sum_trades_on_date 加 convert 去重条件(R-d),组内只计转出端
- 新增 tests/test_core_tools.py(3 条:汇总不翻倍 / 跨口径一致性 / 持仓不返回 qty=0 行)
- tests/test_core_ro_sum.py 追加 1 条 convert 去重用例(既有断言零改动)
- 新增 scripts/dev/verify_convert_tools.py:真库验证脚本(MySQL 8.0.46)

执行期裁定 2 条(已留痕)
- _amount_view 提升为公开而非复制:一个函数两个名字会漂移(自检第 13 问)
- SQL 条件从 IS NULL 扩为 IS NULL OR = '':Python 的 if not gid 把空串当无组,
  而 SQL 里 '' IS NULL 恒假。真库实证:只写 IS NULL → 合计数 400000(正确 450000,漏算 50000)。
  不加则 RISK-002 漏算,且 sqlite 单测若只造 NULL 数据永远发现不了
- 等价性边界:组内无 redeem 时 SQL 丢整组、amount_view 保留首条;由 R-b 保证不可达

验证
- pytest -q → 718 passed / 3 skipped(基线 714 加 4,零回归)
- 突变验证 3 组均精准命中:去掉去重条件(2 红)/ 去掉 qty>0(1 红)/ 汇总不走 amount_view(2 红)
- 真库 verify_convert_tools.py 14/14,隔离数据零残留
- 复跑受影响真库脚本零回归:T-8 31/31、T-7 35/35、T-10 20/20
2026-09-10 18:40:27 +08:00

241 lines
11 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-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())