Files
group_xinghuo_jinrong/tests/test_convert_repository.py
T
GaoYiYuan_0626 f056fcd007 基金转换 T+1 模型:T-1~T-6 落地(DDL/数据层/语义收窄/锁/受理事务)
AIcoding 第 5 步 todo 开发(开发计划 v2.0)前半段:

- T-1 3 张新表 DDL(core_convert_request 6 态 ENUM / core_trade_calendar /
  core_share_rule)+ 种子 + sqlite 单一事实源同步 + 列清单断言
- T-2/T-2b calc 扩展(product_round/redeem_amount/partial_qty)+ 真实净值实算回填
- T-3 convert_request_repository(6 态 + 条件 UPDATE 守卫)+ core_ro 三读方法
  + share_lot_repository.available_qty_with_inflight(R-3 在途占用推导)
- T-4 convert_repository.sync_mirror 成为 risk_convert_detail 唯一进度镜像写入口
  (旧三方法标 Deprecated,T-7 后删)
- T-5 locks.py 锁键构造器 convert_req_lock_key / convert_confirm_lock_key
- T-6 convert_service.accept_convert 受理事务(八步:锁→幂等→校验→受理日顺延
  →在途占用校验→落单→镜像+审计→202;不扣份额/不折算/不写流水)
  + tests/test_convert_accept.py(16 用例)
  + scripts/dev/verify_convert_accept.py(真库 36/36 一致)
  + trading_calendar.py 纯函数包(R-5)+ 21 用例

T-6 真库实测暴露并修复:confirm_eta 在日历数据边界抛 ValueError,会让已落库
的受理单在调用方眼里变 500;改为展示性字段容错 + 单测守护。

基线:798 passed / 10 skipped,零回归。
2026-09-11 19:21:15 +08:00

188 lines
8.5 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-4 `convert_repository`(agent 侧 risk_convert_detail · 语义收窄)单测。
sqlite 内存库(conftest.sqlite_engine 含 agent 库表 + risk_convert_detail);
数据由本文件经仓储自建,不依赖真 MySQL。
T+1 模型下 `risk_convert_detail.status` 5 值 = **agent 写入进度**(PRD §4.1):
pending=待写入 / completed=详情与审计已写 / failed=写失败待补偿 /
cancelled / expired = 保留值;**业务状态一律以 `core_convert_request.status` 6 态为准**。
覆盖(开发计划 v2.0 T-4 DoD):
1. `sync_mirror` 受理 ④ pending:INSERT 镜像(estimated=0)+ 行不存在也能写
2. `sync_mirror` 确认 ② completed:幂等重写(同 group_id 两次调用收敛)
3. `sync_mirror` 补偿 ④ failed → completed:重写覆盖
4. 撤单不推 cancelled:`sync_mirror` 只接受 pending/completed/failed(保留值不在写入面)
5. 状态迁移逻辑无残留:agent 侧不自主判定(业务状态权威在 Core)
6. 不与 risk_repository 混职责(convert 写操作不动 risk_alert)
7. v1.0 遗留方法(insert_placeholder/complete_convert/mark_failed)标注 Deprecated 但保留
(v1.0 的占位→completed/failed/expired 生命周期测试已随 T-4 语义收窄改写/删除——
`sync_mirror` 为唯一新语义写入口。)
"""
from __future__ import annotations
from datetime import date, datetime
from decimal import Decimal
import pytest
from sqlalchemy import text
from app.repository.convert_repository import ConvertRepository
from app.repository.risk_repository import RiskRepository
def _row(engine, gid: str) -> dict | None:
with engine.connect() as conn:
row = conn.execute(
text("SELECT * FROM risk_convert_detail WHERE convert_group_id = :gid"),
{"gid": gid},
).mappings().first()
return dict(row) if row else None
# ── 1. 受理 ④ pending 镜像(T-6 调用点) ────────────────────────────────
def test_sync_mirror_pending_insert(sqlite_engine):
"""受理落地 Core 后:镜像写 pending(estimated=0 真实请求,非预估单)。"""
repo = ConvertRepository(engine=sqlite_engine)
repo.sync_mirror("G-1", "pending", client_request_id="REQ-1")
row = _row(sqlite_engine, "G-1")
assert row is not None
assert row["status"] == "pending"
assert row["estimated"] == 0
assert row["client_request_id"] == "REQ-1"
# 受理阶段无确认详情
assert row["out_trade_id"] is None
assert row["in_trade_id"] is None
# ── 2. 确认 ② completed 镜像(T-7 调用点) ──────────────────────────────
def test_sync_mirror_completed_upsert(sqlite_engine):
"""确认完成后:completed 镜像带全部折算详情;同 group_id 二次调用幂等重写。"""
repo = ConvertRepository(engine=sqlite_engine)
# 先受理 pending(模拟 T-6 已写)
repo.sync_mirror("G-2", "pending", client_request_id="REQ-2")
# 确认 completed(T-7;同 group_id 二次调用 → UPDATE 重写,不炸)
repo.sync_mirror(
"G-2",
"completed",
out_trade_id="TRD-OUT-2",
in_trade_id="TRD-IN-2",
related_trade_id="TRD-OUT-2",
nav=Decimal("1.3604"),
nav_date=date(2026, 9, 9),
fee_amount=Decimal("612.18"),
hold_days_min=3,
hold_days_max=180,
nav_stale=False,
)
row = _row(sqlite_engine, "G-2")
assert row["status"] == "completed"
assert row["out_trade_id"] == "TRD-OUT-2"
assert row["in_trade_id"] == "TRD-IN-2"
assert row["related_trade_id"] == "TRD-OUT-2"
assert row["nav"] == pytest.approx(1.3604) # Decimal→float 绑定,读回一致
assert row["fee_amount"] == pytest.approx(612.18)
assert date.fromisoformat(str(row["nav_date"])) == date(2026, 9, 9)
assert row["hold_days_min"] == 3
assert row["hold_days_max"] == 180
assert row["nav_stale"] == 0
assert row["client_request_id"] == "REQ-2" # COALESCE 保旧值
# ── 3. 补偿 ④ failed → completed(T-12 调用点) ─────────────────────────
def test_sync_mirror_failed_then_completed(sqlite_engine):
"""补偿:agent 附加写入失败 → failed 镜像;补偿成功后重写 completed。"""
repo = ConvertRepository(engine=sqlite_engine)
repo.sync_mirror("G-3", "pending", client_request_id="REQ-3")
# 写入失败 → 置 failed(触发者之一,架构 §5.4)
repo.sync_mirror("G-3", "failed")
assert _row(sqlite_engine, "G-3")["status"] == "failed"
# 补偿成功(rebuild_alerts 仅凭 Core 补写)→ 回 completed
repo.sync_mirror(
"G-3", "completed",
out_trade_id="TRD-OUT-3", in_trade_id="TRD-IN-3",
nav=Decimal("1.3604"), nav_date=date(2026, 9, 9),
fee_amount=Decimal("612.18"),
)
row = _row(sqlite_engine, "G-3")
assert row["status"] == "completed"
assert row["out_trade_id"] == "TRD-OUT-3"
# ── 4. 撤单不推 cancelled(保留值不在写入面) ───────────────────────────
def test_sync_mirror_rejects_non_progress_status(sqlite_engine):
"""cancelled/expired 是 agent 进度保留值:sync_mirror 不得直接写入。"""
repo = ConvertRepository(engine=sqlite_engine)
for bad in ("cancelled", "expired"):
with pytest.raises(ValueError):
repo.sync_mirror("G-X", bad)
# ── 5. 状态迁移逻辑无残留(业务状态权威在 Core) ─────────────────────────
def test_sync_mirror_no_autonomous_state_machine(sqlite_engine):
"""agent 侧只记录进度,不自主判定业务状态迁移。
`sync_mirror` 的 status 参数由调用方(convert_service 读 Core 权威 6 态后)传入,
本仓储无 `from -> to` 条件迁移逻辑(那是 convert_request_repository 的职责)。
"""
import inspect
src = inspect.getsource(ConvertRepository)
# 进度三值之外的业务状态(accepted/nav_pending/confirmed)不得出现在写路径现值
assert "_STATUS_PENDING" in src or "pending" in src
# 无 from/to 条件垫片:agent 侧没有「仅当原状态=X 才迁移」的判定
# (匹配真实定义/调用而非 docstring 里的引用)
assert "def transition_status" not in src
assert "self.transition_status(" not in src and "transition_status(" not in src
# ── 6. 不与 risk_repository 混职责 ──────────────────────────────────────
def test_no_cross_write_to_risk_tables(sqlite_engine):
"""进度镜像只写 risk_convert_detail,绝不污染 risk_alert(职责隔离)。"""
repo = ConvertRepository(engine=sqlite_engine)
repo.sync_mirror("G-7", "pending", client_request_id="REQ-7")
with sqlite_engine.connect() as conn:
alert_count = conn.execute(text("SELECT COUNT(*) FROM risk_alert")).scalar_one()
assert alert_count == 0
assert isinstance(repo, ConvertRepository)
assert not isinstance(repo, RiskRepository)
# ── 7. v1.0 遗留方法保留(T-7 后删除) ──────────────────────────────────
def test_v1_legacy_methods_still_exist(sqlite_engine):
"""T-4 语义收窄不删旧方法(T-7 全量重写才删),v1.0 流回归仍可用。"""
repo = ConvertRepository(engine=sqlite_engine)
assert hasattr(repo, "insert_placeholder")
assert hasattr(repo, "complete_convert")
assert hasattr(repo, "mark_failed")
assert hasattr(repo, "list_expired_candidates") # 供 T-12 迁移前巡检沿用
assert hasattr(repo, "mark_expired")
# Deprecated 标注在位
import inspect
src = inspect.getsource(ConvertRepository)
assert "Deprecated" in src
# ── 8. 无 DELETE(S2 红线) ─────────────────────────────────────────────
def test_no_delete_statements(sqlite_engine):
"""S2 红线:仓储代码层不得出现 DELETE 语句(受理单只改状态、历史留痕)。"""
import inspect
import re
source = inspect.getsource(ConvertRepository)
# 用正则匹配真实 DELETE 语句(\b 词边界),排除 docstring 注释里的“无 DELETE”字样
assert not re.search(r"\bDELETE\s+FROM\b", source, re.IGNORECASE)