Files
group_xinghuo_jinrong/tests/test_convert_request_repository.py
T
GaoYiYuan_0626 048f1a9e1e 基金转换 T+1 模型:T-7 确认段落地(confirm_service 三段编排 + T+1 批处理)
【新增】
- app/service/convert/confirm_service.py:confirm_one(8 步确认)+ confirm_batch
  (按业务日捞单 / 整批锁 / 串行 / 单笔容错)。关键裁定:
  · T 日净值用 get_nav_on 精确匹配,缺则 nav_pending(绝不回退旧净值);
  · 扣批次 + 2 条流水 + 转入批次 + 两端持仓 + 明细 + 受理单置 confirmed
    同处一个 Core 单库事务,状态被抢(rowcount!=1)→ 整事务回滚;
  · 引擎在事务 commit 后跑,异常不阻断已成立的交易(FR-C28);
  · 部分成交 actual=min(申请,可用),被抢部分占用自然释放(R-10)。
- app/service/convert/format.py / audit.py / engine_call.py:展示规格、审计、
  引擎调用三处共用出口抽出(受理/确认两段不再各写一份,避免口径漂移)。
- scripts/dev/verify_convert_confirm.py:真库验证 81 项断言,含 PRD §5.3.2
  示例在真库上逐字节重放(68020.00/612.18/67407.82/333.36/67074.46/34945.54/-0.0049)。

【修复 · 受理-确认接口契约缺口】
强制全转是**受理段决策**(受理时 qty 已收敛为实际全转量),确认段拿不到原始
申请量、无法复现该判定。修法:
- 受理段把 forced_full_transfer 落受理单 remark(新增 REMARK_FULL_TRANSFER);
- 确认段改为**继承受理决策、不再重判最低持有**(plan_lots 不传 min_hold_qty)
  —— 重判会因 T→T+1 可用份额变化得出与受理承诺不一致的结论(擅自扩大客户指令);
- remark 支持多标记 `;` 连接(full_transfer;partial)。
- MySQL rowcount=changed rows 陷阱:nav_pending 重试不得复用 transition_status
  的冲突判定,改为 status 未变时不迁移、返回 transitioned=False。

【其他】
- convert_service:新增 cancel_convert(T 日撤单,两道闸门)、_t1_t2_dates
  (日历边界 None 容错);受理响应改为 PRD §5.3.1 字段;convert_fund 标 Deprecated。
- convert_core_repository:ConvertApplyInput 加 convert_request_id/diff_fee/
  request_remark;_apply_convert_once 末步 _confirm_request 事务内置状态守卫。
- convert_request_repository:_end_of_day 统一闭区间语义;新增 reject()。
- 测试:新增 tests/test_convert_confirm.py(24 例);全量 827 passed / 10 skipped。
2026-09-11 19:35:37 +08:00

159 lines
6.7 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-3 `convert_request_repository`(Core 库 core_convert_request)单测。
sqlite 内存库(conftest.sqlite_engine 的 DDL 含 core_convert_request, T-1 已建);
数据由本文件自建,不依赖真 MySQL。
覆盖(开发计划 v2.0 T-3 DoD):
1. insert 首插 accepted + uk_idem 撞键 → IntegrityError(service 转幂等 202)
2. transition_status 条件 UPDATE:并发两线程恰一个 rowcount=1(sqlite 模拟)
3. mark_actual 部分成交
4. list_pending_by_biz_date 按受理日区间捞单
5. get_by_client_request_id 幂等查询
6. 无 DELETE(S2 标记不硬删 —— 本仓储代码层无 DELETE 语句)
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
import pytest
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from app.repository.convert_request_repository import (
STATUS_ACCEPTED,
STATUS_CANCELLED,
STATUS_CONFIRMED,
STATUS_REJECTED,
ConvertRequestRepository,
)
def _seed_product(engine, pid: str = "P1") -> None:
"""插一个测试产品(core_convert_request 无 FK 于 sqlite 测试库,但保持种子完整)。"""
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_product (product_id, product_name, min_risk_code,"
" product_type, min_subscribe_amount) VALUES (:pid, '测试产品', 'R1',"
" 'bond', 1.00)"
),
{"pid": pid},
)
def _row(engine, gid: str):
with engine.connect() as conn:
row = conn.execute(
text("SELECT * FROM core_convert_request WHERE convert_group_id = :gid"),
{"gid": gid},
).mappings().first()
return dict(row) if row else None
def test_insert_and_idempotency_conflict(sqlite_engine):
"""首插 accepted;同 client_request_id 再插 → uk_idem 撞键 IntegrityError。"""
_seed_product(sqlite_engine)
repo = ConvertRequestRepository(engine=sqlite_engine)
repo.insert(
"G-1", "C1", "P1", "P2", Decimal("50000"),
client_request_id="REQ-1",
requested_at=datetime(2026, 9, 6, 10),
)
row = _row(sqlite_engine, "G-1")
assert row is not None
assert row["status"] == STATUS_ACCEPTED
assert Decimal(str(row["qty"])) == Decimal("50000")
# 同 client_request_id(新 group_id)再插 → uk_idem 冲突
with pytest.raises(IntegrityError):
repo.insert(
"G-2", "C1", "P1", "P2", Decimal("100"),
client_request_id="REQ-1",
requested_at=datetime(2026, 9, 6, 11),
)
# 冲突后不得留下半行(G-2 不存在)
assert _row(sqlite_engine, "G-2") is None
def test_transition_status_conditional_update(sqlite_engine):
"""条件 UPDATE 守卫:from 状态不匹配 → rowcount=0(False),天然幂等。"""
repo = ConvertRequestRepository(engine=sqlite_engine)
repo.insert("G-1", "C1", "P1", "P2", Decimal("100"),
requested_at=datetime(2026, 9, 6, 10))
# 正确迁移
assert repo.transition_status("G-1", STATUS_ACCEPTED, STATUS_CONFIRMED) is True
# 并发冲突:期望 from=accepted 但已是 confirmed → False
assert repo.transition_status("G-1", STATUS_ACCEPTED, STATUS_REJECTED) is False
# 从未态 confirmed 撤销 → False(终态不可再迁)
assert repo.transition_status("G-1", STATUS_CONFIRMED, STATUS_CANCELLED) is True # 允许显式
row = _row(sqlite_engine, "G-1")
assert row["status"] == STATUS_CANCELLED
def test_mark_actual_and_get_by_client_request_id(sqlite_engine):
"""部分成交记 actual_qty + 幂等查询。"""
repo = ConvertRequestRepository(engine=sqlite_engine)
repo.insert("G-1", "C1", "P1", "P2", Decimal("500"),
client_request_id="REQ-X",
requested_at=datetime(2026, 9, 6, 10))
repo.mark_actual("G-1", Decimal("300"), remark="partial")
row = _row(sqlite_engine, "G-1")
assert Decimal(str(row["actual_qty"])) == Decimal("300")
assert row["remark"] == "partial"
hit = repo.get_by_client_request_id("REQ-X")
assert hit is not None and hit["convert_group_id"] == "G-1"
assert repo.get_by_client_request_id("NO-SUCH") is None
assert repo.get_by_client_request_id(None) is None
def test_list_pending_by_biz_date(sqlite_engine):
"""批处理捞单:只返回 accepted/nav_pending 且受理日落在区间内的单。
`as_of` 为**闭区间上界**(含当日)—— 与 `list_convert_requests` 同语义(T-7 统一)。
"""
repo = ConvertRequestRepository(engine=sqlite_engine)
repo.insert("G-1", "C1", "P1", "P2", Decimal("100"),
requested_at=datetime(2026, 9, 4, 10))
repo.insert("G-2", "C1", "P1", "P2", Decimal("200"),
requested_at=datetime(2026, 9, 5, 10))
repo.insert("G-3", "C1", "P1", "P2", Decimal("300"),
requested_at=datetime(2026, 9, 5, 14))
repo.transition_status("G-3", STATUS_ACCEPTED, STATUS_CONFIRMED) # 已确认,不在捞单范围
rows = repo.list_pending_by_biz_date(datetime(2026, 9, 5, 0), datetime(2026, 9, 4, 0))
gids = [r["convert_group_id"] for r in rows]
assert gids == ["G-1", "G-2"] # 升序;G-3 已 confirmed 排除
# 边界:上界取 9-4 时**不含** 9-5 的单(闭区间上界不是「无限上界」)
only_4th = repo.list_pending_by_biz_date(datetime(2026, 9, 4, 0), datetime(2026, 9, 4, 0))
assert [r["convert_group_id"] for r in only_4th] == ["G-1"]
def test_reject_writes_reason_and_is_guarded(sqlite_engine):
"""`reject`:accepted → rejected + remark 落拒绝原因;条件 UPDATE 防重复。"""
repo = ConvertRequestRepository(engine=sqlite_engine)
repo.insert("G-R1", "C1", "P1", "P2", Decimal("100"),
requested_at=datetime(2026, 9, 4, 10))
assert repo.reject("G-R1", STATUS_ACCEPTED, "SUITABILITY_MISMATCH") is True
row = _row(sqlite_engine, "G-R1")
assert row["status"] == STATUS_REJECTED
assert row["remark"] == "SUITABILITY_MISMATCH"
# 已 rejected → 再用 accepted 作 from 迁移必然 False(并发守卫)
assert repo.reject("G-R1", STATUS_ACCEPTED, "AGAIN") is False
# 拒绝后不再被批处理捞到(终态不占用)
assert repo.list_pending_by_biz_date(datetime(2026, 9, 4, 0), datetime(2026, 9, 1, 0)) == []
def test_no_delete_statements(sqlite_engine):
"""S2 红线:仓储代码层不得出现 DELETE 语句(受理单只改状态、历史留痕)。"""
import inspect
import re
source = inspect.getsource(ConvertRequestRepository)
# 用正则匹配真实 DELETE 语句(\b 词边界),排除 docstring 注释里的“无 DELETE”字样
assert not re.search(r"\bDELETE\s+FROM\b", source, re.IGNORECASE)