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