【新增】
- 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。
159 lines
6.7 KiB
Python
159 lines
6.7 KiB
Python
"""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) |