Files
group_xinghuo_jinrong/tests/test_core_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

108 lines
4.6 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.
"""core_tools 查询口径单测(T-11 · FR-C15 + 归零行过滤)。
自建 sqlite 完整种子(`core_product` / `core_holding` / `core_trade`),直调 Tool 函数。
覆盖 T-11 两条 DoD:
1. `query_recent_trades` 的 `sum_amount` 不因 convert 两条流水而**翻倍**
(走 `amount_view`;明细与 `total_count` 仍为全量),
并与 `core_ro.sum_trades_on_date` 做**跨口径一致性**断言(自检第 13 问:
同一口径不得有两份实现漂移);
2. `query_holdings` 不返回 `qty = 0` 的**归零行**(convert 转出全部份额后的台账留痕行)。
种子时间刻意取**同一个 `now`**:既落在 `query_recent_trades` 的 [now−30d, now) 窗内,
又保证属于 `now.date()` 这一天,使跨口径断言不受运行时刻影响。
"""
from __future__ import annotations
import datetime as _dt
from decimal import Decimal
import pytest
from sqlalchemy import text
from _ddl import create_sqlite_engine
from app.repository.core_ro import CoreReadOnlyRepository
from app.tool.core_tools import query_holdings, query_recent_trades
CUST = "CUST-T11"
@pytest.fixture()
def seed():
engine = create_sqlite_engine()
now = _dt.datetime.now()
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_product (product_id, product_name, min_risk_code, product_type,"
" min_subscribe_amount, term_days) VALUES"
" ('PA', '甲基金', 'R3', 'mixed', 100, 0),"
" ('PB', '乙基金', 'R3', 'mixed', 100, 0)"
)
)
# PA 正常持有;PB 为 convert 转出全部后的**归零行**(qty = 0,行保留)
conn.execute(
text(
"INSERT INTO core_holding (customer_id, product_id, qty, cost_amount,"
" market_value, pnl_pct, as_of) VALUES"
" (:cid, 'PA', 1000.00, 1000.00, 1200.00, 0.2000, :d),"
" (:cid, 'PB', 0.00, 0.00, 0.00, 0.0000, :d)"
),
{"cid": CUST, "d": now.date()},
)
# 一次 convert 落两条(转出 redeem 300000 + 转入 subscribe 300000,共享 G1)
# + 1 笔普通赎回 100000(convert_group_id = NULL)
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"
" ('TX-OUT', :cid, 'PA', 'redeem', 300000, 250.00, 'G1', 'confirmed', :t),"
" ('TX-IN', :cid, 'PB', 'subscribe', 300000, 240.00, 'G1', 'confirmed', :t),"
" ('TX-PLAIN', :cid, 'PA', 'redeem', 100000, 80.00, NULL, 'confirmed', :t)"
),
{"cid": CUST, "t": now},
)
yield engine
engine.dispose()
def test_query_recent_trades_sum_not_doubled(seed):
"""FR-C15:汇总只计转出端;**明细与条数保持全量**(一次转换两条是真实的)。"""
repo = CoreReadOnlyRepository(engine=seed)
res = query_recent_trades(CUST, days=30, core_ro=repo)
# 明细全量:convert 两条 + 普通赎回 1 条
assert res["total_count"] == 3
assert sorted(r["trade_type"] for r in res["items"]) == ["redeem", "redeem", "subscribe"]
# 汇总去重:300000(转出端)+ 100000(普通赎回)= 400000
# 若未去重则为 700000(TX-OUT + TX-IN 双计)—— 用具体值才区分得开
assert res["sum_amount"] == 400000.0
def test_query_recent_trades_sum_matches_sum_trades_on_date(seed):
"""跨口径一致性:Tool 汇总(Python 侧 amount_view)== 仓储 SQL 汇总(IS NULL OR ='')。
两处是同一口径的两种落地(一个 Python 一个 SQL),此处用断言锁死等价性,
任一侧口径漂移都会在此变红。
"""
repo = CoreReadOnlyRepository(engine=seed)
tool_sum = query_recent_trades(CUST, days=30, core_ro=repo)["sum_amount"]
sql_sum = repo.sum_trades_on_date(CUST, _dt.date.today())
assert Decimal(str(tool_sum)) == sql_sum
assert sql_sum == Decimal("400000")
def test_query_holdings_excludes_zero_qty(seed):
"""归零行(convert 转出全部份额,`qty = 0` 台账留痕)不得作为持仓返回。"""
repo = CoreReadOnlyRepository(engine=seed)
res = query_holdings(CUST, core_ro=repo)
assert res["total_count"] == 1
assert [r["product_id"] for r in res["items"]] == ["PA"]
# 合计不含归零行(PB 的 market_value 为 0,即使计入也不变;
# 故断言**条数**才是真正能区分对错的判据)
assert res["sum_market_value"] == 1200.0