基金转换 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
This commit is contained in:
2026-09-10 18:40:27 +08:00
parent 5c2dc94b40
commit a6ac39edd6
12 changed files with 562 additions and 31 deletions
+18 -2
View File
@@ -19,6 +19,7 @@ from decimal import Decimal
from typing import Any, Callable
from app.repository.core_ro import CoreReadOnlyRepository
from app.service.risk.rules import amount_view
DETAIL_LIMIT = 20 # 明细条数上限(上下文与落库同限,防超长)
DEFAULT_TRADE_DAYS = 30
@@ -76,6 +77,12 @@ def query_holdings(
SQL 层 LIMIT HOLDING_FETCH_LIMIT(T-04 评审 P2):命中上限时 truncated=
True——此时 total_count/sum_market_value 为"已拉取部分"的统计,摘要会
显式提示截断,避免静默给出偏小口径。
⚠️ **归零行已在 SQL 层过滤**(T-11 · `core_ro.list_holdings` 的 `h.qty > 0`):
convert 转出全部份额时 `core_share_lot` 批次归零、`core_holding.qty` 记 0,
但**行仍保留**(份额台账留痕,D 决策:转出归零保留 `qty=0` 行,不物理删除);
持仓查询须过滤,否则会把已清仓产品当持仓返回。过滤**下沉到 SQL** 而非本层,
是为让 `total_count` / `sum_market_value` / `truncated` 三者口径一致。
"""
repo = core_ro or CoreReadOnlyRepository()
# 多取 1 条用于判定是否真被截断(恰好 500 笔不误报)
@@ -98,12 +105,21 @@ def query_recent_trades(
core_ro: CoreReadOnlyRepository | None = None,
risk_repo=None,
) -> dict[str, Any]:
"""近 N 天 confirmed 申赎流水(时间升序截断至 DETAIL_LIMIT)。"""
"""近 N 天 confirmed 申赎流水(时间升序截断至 DETAIL_LIMIT)。
⚠️ **汇总与明细口径刻意不同**(FR-C15 · PRD §6.4 · T-11):
`items` / `total_count` 保持**全量** —— 一次基金转换落两条流水是真实的两笔权益变动,
读到两条是对的;但 `sum_amount` **必须走 `amount_view`**,
否则同一笔转换会被计两次、金额翻倍。
`amount_view` 是全仓唯一的金额聚合口径(D7 / 自检第 13 问),**不得在本模块另写一份**;
与 `core_ro.sum_trades_on_date` 的 SQL 条件同语义(由跨口径一致性断言守护)。
"""
repo = core_ro or CoreReadOnlyRepository()
end = _now_naive()
start = end - _dt.timedelta(days=days)
rows = repo.list_trades_range(customer_id, start, end)
total = sum((r.get("amount") or 0) for r in rows)
total = sum((r.get("amount") or 0) for r in amount_view(rows))
return {
"days": days,
"total_count": len(rows),