基金转换 T+1 模型:T-13 convert_fund 退役 + 并发面重写 + 全量门禁(830 绿 / 真库 9/9 / 紧池 100%)

This commit is contained in:
2026-09-12 11:52:27 +08:00
parent 9ad8a90de1
commit 3bc84a093d
7 changed files with 460 additions and 1782 deletions
+122 -472
View File
@@ -1,19 +1,19 @@
"""T-7 `convert_service` 八步编排单测(开发计划 §6.2 DoD)。
"""`convert_service` 补偿单点单测(T-12/T-13 · 开发计划 §6.2 DoD)。
sqlite 内存库(`conftest.sqlite_engine`,含 C×R 矩阵种子),数据自建。
折算期望值一律由生产 `calc.py` 实算(不自造公式副本,自检第 13 问)。
覆盖:
1. 三阶段贯通(占位 pending→completed / 2 流水 / 响应字段与 PRD §5.3 对齐)
2. **blocked 不占位**(PRD §7.0:前四步不落库)
3. 幂等命中返回首次结果、**不产生第二组流水**
4. 未抢到执行权 → `status=processing`(T-9 映射 202)
5. 全额转出豁免 `min_redeem_qty`(验收 16)/ 强制全转留痕
6. `nav_stale` 额外落副审计(1~2 条)
7. 阶段 1.5 引擎异常**不阻断**已成立交易
8. 阶段二失败 → `convert_detail_write_failed` 审计 + 占位 `failed`
9. 各 4xx/503 分支:SameProduct / 不可赎回 / 跨主体 / 份额不足 / 低于最低份额 /
无净值 / 批次数超限
**T-13 退役说明**:本文件原承载 v1.0 两阶段实时入口 `convert_fund` 的八步编排
用例(三阶段贯通 / 占位幂等 / nav_stale 副审计 / 阶段一失败重试等)。`convert_fund`
随 T+1 模型退役删除后,其验证点的 **T+1 替代断言**已由以下用例承接:
- 受理段(blocked 不占位 / 同键幂等 / 最低份额 / 强制全转 / 产品校验 / 在途占用 /
uk_idem 竞态)→ `tests/test_convert_accept.py`;
- 确认段(happy path 与 PRD §5.3 对表 / 缺净值 nav_pending / 引擎恰好一次 /
引擎异常不阻断但留痕 / 真引擎出单去重 / 强制全转继承)→ `tests/test_convert_confirm.py`。
本文件保留 **补偿单点 `compensate_convert`** 的全部用例,并补 2 条 T+1 缺口:
确认第 ⑧ 步镜像失败不回滚 Core(v1.0「阶段二失败」语义的承接)、
确认段批次数超限 rejected(v1.0 `TooManyLots` 503 语义的 T+1 承接)。
"""
from __future__ import annotations
@@ -24,42 +24,20 @@ from decimal import Decimal
import pytest
from sqlalchemy import text
from unittest.mock import patch
from app.config.settings import settings
from app.gateway.convert_core_repository import ConvertCoreRepository
from app.repository.convert_repository import ConvertRepository
from app.repository.convert_request_repository import ConvertRequestRepository
from app.repository.core_ro import CoreReadOnlyRepository
from app.repository.risk_repository import RiskRepository
from app.service.convert.calc import (
convert_amount,
diff_fee,
hold_days,
in_qty,
lot_amount,
lot_fee,
plan_lots,
)
from app.service.convert.convert_service import (
PROCESSING,
compensate_convert,
convert_fund,
)
from app.service.convert.errors import (
BelowMinQty,
CrossEntityNotSupported,
InsufficientShares,
LotConflict,
NavNotReady,
ProductNotRedeemable,
SameProduct,
TooManyLots,
)
from app.service.convert.fee import pick_fee_rate
from app.service.convert.types import FeeRule, Lot
from app.service.convert.confirm_service import confirm_one
from app.service.convert.convert_service import compensate_convert
from app.service.risk.locks import try_lock
from app.service.risk.rules import RiskThresholds
CUST = "CUST-T7" # C3 客户 → R4 产品 allowed_with_disclosure(放行)
CUST_LOW = "CUST-T7L" # C1 客户 → R4 forbidden(用于 blocked 分支)
PROD_OUT = "PROD-T7A"
PROD_IN = "PROD-T7B"
COMPANY = "华夏模拟基金"
@@ -92,24 +70,12 @@ def _seed(engine, *, nav_date: date = TODAY, min_redeem: str = "0", min_hold: st
"VALUES (:c, 'T7客户', 40, 1)",
c=CUST,
)
_exec(
engine,
"INSERT INTO core_customer (customer_id, display_name, age, is_active) "
"VALUES (:c, 'T7低风险客户', 40, 1)",
c=CUST_LOW,
)
_exec(
engine,
"INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at, expires_at) "
"VALUES (:c, 'C3', :t, :exp)",
c=CUST, t=NOW - timedelta(days=30), exp=NOW + timedelta(days=300),
)
_exec(
engine,
"INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at, expires_at) "
"VALUES (:c, 'C1', :t, :exp)",
c=CUST_LOW, t=NOW - timedelta(days=30), exp=NOW + timedelta(days=300),
)
_exec(
engine,
"INSERT INTO core_product (product_id, product_name, min_risk_code, product_type, "
@@ -146,17 +112,6 @@ def _seed(engine, *, nav_date: date = TODAY, min_redeem: str = "0", min_hold: st
"pnl_pct, as_of) VALUES (:c, :p, 150, 150, 154.5, 0, :d)",
c=CUST, p=PROD_OUT, d=TODAY,
)
# CUST_LOW(C1,R4 应被适当性拦截)也需有份额,否则 ② 份额校验会先于 ④ 触发
_seed_lot(engine, "LOT-T7L-1", "100", "1.0300", datetime(2026, 8, 1, 10, 0, 0),
customer=CUST_LOW)
_seed_lot(engine, "LOT-T7L-2", "50", "1.0000", datetime(2026, 9, 1, 10, 0, 0),
customer=CUST_LOW)
_exec(
engine,
"INSERT INTO core_holding (customer_id, product_id, qty, cost_amount, market_value, "
"pnl_pct, as_of) VALUES (:c, :p, 150, 150, 154.5, 0, :d)",
c=CUST_LOW, p=PROD_OUT, d=TODAY,
)
def _seed_lot(engine, lot_id: str, qty: str, nav: str, confirmed_at: datetime,
@@ -174,415 +129,14 @@ def _services(engine):
core_ro=CoreReadOnlyRepository(engine=engine),
risk_repo=RiskRepository(engine=engine),
convert_repo=ConvertRepository(engine=engine),
# 确认事务的 core 写侧也必须钉在同一 sqlite 引擎(缺省构造会连真 MySQL,
# CUST-T7 在真库无 customer 行 → FK 1452)
core_writer=ConvertCoreRepository(engine=engine),
request_repo=ConvertRequestRepository(engine=engine),
)
def _req(customer: str = CUST, qty: str = "120", cid_req: str | None = None) -> dict:
return {
"customer_id": customer,
"from_product_id": PROD_OUT,
"to_product_id": PROD_IN,
"qty": qty,
"client_request_id": cid_req,
}
def _expected(engine, requested: str) -> dict:
"""用生产纯函数算一遍期望值(与 service 内部同一套口径)。"""
core = CoreReadOnlyRepository(engine=engine)
lots = [Lot.from_row(r) for r in core.list_share_lots(CUST, PROD_OUT)]
rules = [FeeRule.from_row(r) for r in core.get_redeem_fee_rules(PROD_OUT)]
plan = plan_lots(lots, Decimal(requested))
out_amount = Decimal("0")
redeem_fee = Decimal("0")
for alloc in plan.allocations:
amount = lot_amount(alloc.qty, alloc.nav)
rate = pick_fee_rate(rules, hold_days(TODAY, alloc.confirmed_at), product_id=PROD_OUT)
out_amount += amount
redeem_fee += lot_fee(amount, rate)
conv = convert_amount(out_amount, redeem_fee)
gap = diff_fee(conv, OUT_RATE, IN_RATE, "amount_diff")
in_amount = conv - gap
return {
"out_amount": out_amount,
"redeem_fee": redeem_fee,
"convert_amount": conv,
"diff_fee": gap,
"in_amount": in_amount,
"in_qty": in_qty(in_amount, IN_NAV),
"actual_qty": plan.actual_qty,
}
# ── 1. 三阶段贯通 ───────────────────────────────────────────────────
def test_happy_path_writes_two_trades_and_completes_placeholder(sqlite_engine):
_seed(sqlite_engine)
exp = _expected(sqlite_engine, "120")
resp = convert_fund(_req(), now=NOW, **_services(sqlite_engine))
assert resp["blocked"] is False
assert resp["estimated"] is True
assert resp["convert_group_id"].startswith("CNV-")
assert Decimal(resp["out_amount"]) == exp["out_amount"]
assert Decimal(resp["redeem_fee"]) == exp["redeem_fee"]
assert Decimal(resp["convert_amount"]) == exp["convert_amount"]
assert Decimal(resp["diff_fee"]) == exp["diff_fee"]
assert Decimal(resp["in_amount"]) == exp["in_amount"]
assert Decimal(resp["in_qty"]) == exp["in_qty"]
assert Decimal(resp["actual_qty"]) == exp["actual_qty"]
assert resp["lot_count"] == 2
assert [b["hold_days"] for b in resp["lot_breakdown"]] == [34, 3]
assert resp["confirm_basis"] == "natural_day_approx"
# 阶段二:占位 completed
rows = _rows(
sqlite_engine,
"SELECT * FROM risk_convert_detail WHERE convert_group_id = :g",
g=resp["convert_group_id"],
)
assert len(rows) == 1 and rows[0]["status"] == "completed"
# 两条流水同组、redeem/subscribe(R-b)
trades = _rows(
sqlite_engine,
"SELECT * FROM core_trade WHERE convert_group_id = :g ORDER BY trade_type",
g=resp["convert_group_id"],
)
assert [t["trade_type"] for t in trades] == ["redeem", "subscribe"]
# 主审计 1 条(净值新鲜 → 无 nav_stale 副审计)
assert len(
_rows(sqlite_engine, "SELECT * FROM audit_log WHERE decision = 'convert_accepted'")
) == 1
assert _rows(sqlite_engine, "SELECT * FROM audit_log WHERE decision = 'nav_stale'") == []
# ── 2. blocked 不占位(PRD §7.0)─────────────────────────────────────
def test_suitability_blocked_does_not_placeholder(sqlite_engine):
_seed(sqlite_engine)
resp = convert_fund(
_req(customer=CUST_LOW), now=NOW, **_services(sqlite_engine)
)
assert resp["blocked"] is True
assert resp["block_response_code"]
# 关键:前四步不落库 —— 无占位、无流水
assert _rows(sqlite_engine, "SELECT * FROM risk_convert_detail") == []
assert _rows(sqlite_engine, "SELECT * FROM core_trade") == []
assert len(
_rows(sqlite_engine, "SELECT * FROM audit_log WHERE decision = 'suitability_blocked'")
) == 1
# ── 3. 幂等:同键重复提交不产生第二组流水 ────────────────────────────
def test_idempotent_repeat_returns_first_result(sqlite_engine):
_seed(sqlite_engine)
svc = _services(sqlite_engine)
first = convert_fund(_req(cid_req="CLI-T7-001"), now=NOW, **svc)
again = convert_fund(_req(cid_req="CLI-T7-001"), now=NOW, **svc)
assert again["convert_group_id"] == first["convert_group_id"]
assert again["in_qty"] == first["in_qty"]
assert again["out_trade_id"] == first["out_trade_id"]
# 只有一组流水(2 条),没有第二组
assert len(_rows(sqlite_engine, "SELECT * FROM core_trade")) == 2
assert len(_rows(sqlite_engine, "SELECT * FROM risk_convert_detail")) == 1
# ── 4. 未抢到执行权 → processing(T-9 映射 202)───────────────────────
def test_lock_not_acquired_returns_processing(sqlite_engine, monkeypatch):
import app.service.convert.convert_service as cs
from app.service.risk.locks import _NoLock
_seed(sqlite_engine)
monkeypatch.setattr(cs, "try_lock", lambda *a, **k: _NoLock())
resp = convert_fund(_req(cid_req="CLI-T7-002"), now=NOW, **_services(sqlite_engine))
assert resp["status"] == PROCESSING
assert "convert_group_id" in resp
assert _rows(sqlite_engine, "SELECT * FROM core_trade") == []
assert _rows(sqlite_engine, "SELECT * FROM risk_convert_detail") == []
# ── 5. 全额转出豁免最低份额(验收 16)────────────────────────────────
def test_full_transfer_waives_min_redeem_qty(sqlite_engine):
_seed(sqlite_engine, min_redeem="10000")
# 持 150(< min_redeem 10000)但申请全额 → 豁免,成功
resp = convert_fund(_req(qty="150"), now=NOW, **_services(sqlite_engine))
assert resp["blocked"] is False
assert Decimal(resp["actual_qty"]) == Decimal("150")
def test_below_min_qty_rejected_when_not_full(sqlite_engine):
_seed(sqlite_engine, min_redeem="10000")
# 持 150、申请 100(< 最低 10000,且非全额)→ 400 BELOW_MIN_QTY
with pytest.raises(BelowMinQty):
convert_fund(_req(qty="100"), now=NOW, **_services(sqlite_engine))
# ── 6. 强制全转留痕 ─────────────────────────────────────────────────
def test_forced_full_transfer_flag(sqlite_engine):
_seed(sqlite_engine, min_hold="100")
# 持 150、申请 140 → 余额 10 < 100 → 强制全转 150
resp = convert_fund(_req(qty="140"), now=NOW, **_services(sqlite_engine))
assert resp["forced_full_transfer"] is True
assert resp["min_hold_action"] == "force_transfer"
assert Decimal(resp["actual_qty"]) == Decimal("150")
# ── 7. nav_stale 额外落副审计 ────────────────────────────────────────
def test_nav_stale_adds_second_audit(sqlite_engine):
_seed(sqlite_engine, nav_date=date(2026, 8, 25)) # 距今 10 天 > 3
resp = convert_fund(_req(), now=NOW, **_services(sqlite_engine))
assert resp["nav_stale"] is True
assert len(_rows(sqlite_engine, "SELECT * FROM audit_log WHERE decision = 'nav_stale'")) == 1
assert len(
_rows(sqlite_engine, "SELECT * FROM audit_log WHERE decision = 'convert_accepted'")
) == 1
# ── 8. 阶段 1.5:引擎异常不阻断已成立的交易 ───────────────────────────
def test_engine_exception_does_not_block_trade(sqlite_engine):
_seed(sqlite_engine)
def boom(out_trade, in_trade): # noqa: ANN001
raise RuntimeError("引擎炸了")
resp = convert_fund(_req(), now=NOW, engine_hook=boom, **_services(sqlite_engine))
assert resp["blocked"] is False
assert resp["engine_error"] is True
assert len(_rows(sqlite_engine, "SELECT * FROM core_trade")) == 2 # 交易仍成立
assert len(_rows(sqlite_engine, "SELECT * FROM audit_log WHERE decision = 'engine_error'")) == 1
def test_engine_result_merged_into_response(sqlite_engine):
_seed(sqlite_engine)
hook = lambda o, i: { # noqa: E731
"triggered_rules": ["RISK-002"],
"alert_ids": ["ALT-1"],
"aml_hit": False,
}
resp = convert_fund(_req(), now=NOW, engine_hook=hook, **_services(sqlite_engine))
assert resp["triggered_rules"] == ["RISK-002"]
assert resp["alert_ids"] == ["ALT-1"]
# ── 9. 阶段二失败:留痕 + 占位 failed(不回滚 Core)───────────────────
def test_phase_two_failure_keeps_trade_and_marks_failed(sqlite_engine, monkeypatch):
_seed(sqlite_engine)
def boom(self, group_id, **kwargs): # noqa: ANN001
raise RuntimeError("阶段二写失败")
monkeypatch.setattr(ConvertRepository, "complete_convert", boom)
resp = convert_fund(
_req(cid_req="CLI-T7-003"), now=NOW, **_services(sqlite_engine)
)
# 交易已成立:core 有两条流水,响应照常返回
assert len(_rows(sqlite_engine, "SELECT * FROM core_trade")) == 2
assert resp["blocked"] is False
assert len(
_rows(
sqlite_engine,
"SELECT * FROM audit_log WHERE decision = 'convert_detail_write_failed'",
)
) == 1
rows = _rows(sqlite_engine, "SELECT * FROM risk_convert_detail")
assert rows[0]["status"] == "failed"
# ── 9b. 阶段一失败 → 同键重试必须可成功(T-13 前置修复的回归闸门)──────
def test_retry_after_phase_one_failure_succeeds_with_same_key(
sqlite_engine, monkeypatch,
):
"""`LotConflict`(409) 后带**同一** `client_request_id` 重试 → 成功、只有一组流水。
这是架构 §8.3「`LOT_CONFLICT` → 调用方重试,建议 ≤3 次、间隔 100/200/400ms」的
服务端契约。修复前:占位被 `mark_failed` 置 failed,重试复用同一 `group_id`
再次进入阶段零,`insert_placeholder` 的朴素 INSERT 撞 `uk_group`/`uk_idem`
→ `IdempotencyUnavailable`(503) —— **确定性失败**,重试永远不会成功。
"""
_seed(sqlite_engine)
original = ConvertCoreRepository.apply_convert
def conflict(self, req): # noqa: ANN001
raise LotConflict("并发争抢:条件 UPDATE rowcount=0")
monkeypatch.setattr(ConvertCoreRepository, "apply_convert", conflict)
with pytest.raises(LotConflict):
convert_fund(_req(cid_req="CLI-T13-RETRY"), now=NOW, **_services(sqlite_engine))
monkeypatch.setattr(ConvertCoreRepository, "apply_convert", original)
# 前置态:占位 failed、两条流水都没落
pre = _rows(sqlite_engine, "SELECT * FROM risk_convert_detail")
assert len(pre) == 1 and pre[0]["status"] == "failed"
assert _rows(sqlite_engine, "SELECT * FROM core_trade") == []
first_gid = pre[0]["convert_group_id"]
# 带同键重试 → 必须成功,且复用原 group_id(杜绝第二组流水)
resp = convert_fund(_req(cid_req="CLI-T13-RETRY"), now=NOW, **_services(sqlite_engine))
assert resp["convert_group_id"] == first_gid
assert len(_rows(sqlite_engine, "SELECT * FROM core_trade")) == 2
assert _rows(sqlite_engine, "SELECT * FROM risk_convert_detail")[0]["status"] == "completed"
assert len(_rows(sqlite_engine, "SELECT * FROM risk_convert_detail")) == 1 # 不新增占位行
def test_retry_after_phase_one_failure_can_fail_again_and_still_retry(
sqlite_engine, monkeypatch,
):
"""连续两次 409 后再重试仍能成功 —— 证明「failed → pending」可反复回置。"""
_seed(sqlite_engine)
original = ConvertCoreRepository.apply_convert
state = {"n": 0}
def conflict_twice(self, req): # noqa: ANN001
state["n"] += 1
if state["n"] <= 2:
raise LotConflict("第 %d 次争抢失败" % state["n"])
original(self, req)
monkeypatch.setattr(ConvertCoreRepository, "apply_convert", conflict_twice)
for _ in range(2):
with pytest.raises(LotConflict):
convert_fund(_req(cid_req="CLI-T13-RETRY2"), now=NOW, **_services(sqlite_engine))
resp = convert_fund(_req(cid_req="CLI-T13-RETRY2"), now=NOW, **_services(sqlite_engine))
assert resp["blocked"] is False and resp["in_qty"]
assert len(_rows(sqlite_engine, "SELECT * FROM core_trade")) == 2
assert [r["status"] for r in _rows(sqlite_engine, "SELECT * FROM risk_convert_detail")] == [
"completed"
]
def test_placeholder_uk_idem_race_returns_processing_without_writing(
sqlite_engine, monkeypatch,
):
"""同键并发子窗口(两笔都判定"无占位")→ 撞 `uk_idem` 的那笔必须回 202,且零写入。
构造方式:**只把"按 cid 查"这一读打桩成 None**(等价于并发下"那一行还没插进来"),
占位表里预置另一笔同 cid 的占位。于是:
① 幂等判定读不到 → 本笔自生成新 group_id;
② 阶段零 INSERT 撞 `uk_idem` → `insert_placeholder` 返回 False → 本笔让路。
这是**资金安全**的闸门:若不接住这个信号(仓储吞掉 False、调用方照跑),
两笔会各带不同 group_id 跑完阶段一 —— `in_lot_id` 派生自 group_id 不再相撞
→ **双扣**(真库突变验证实测:120 份变 240 份、两组流水,见
`tests/test_convert_concurrency.py::test_same_client_request_id_placeholder_race_must_not_leak_5xx`)。
"""
_seed(sqlite_engine)
_exec(
sqlite_engine,
"INSERT INTO risk_convert_detail"
" (convert_group_id, client_request_id, status, estimated)"
" VALUES ('CNV-OTHER-CONC', 'CID-RACE', 'pending', 0)",
)
monkeypatch.setattr(
ConvertRepository, "get_by_client_request_id", lambda self, cid: None
)
resp = convert_fund(_req(cid_req="CID-RACE"), now=NOW, **_services(sqlite_engine))
assert resp["status"] == PROCESSING # 让路 → 202(T-9 映射)
assert resp["convert_group_id"] is None
# 零写入:没有第二组流水、没有第二行占位、没有扣份额
assert _rows(sqlite_engine, "SELECT * FROM core_trade") == []
placeholders = _rows(sqlite_engine, "SELECT * FROM risk_convert_detail")
assert len(placeholders) == 1 and placeholders[0]["convert_group_id"] == "CNV-OTHER-CONC"
assert Decimal(
_rows(
sqlite_engine,
"SELECT SUM(remain_qty) AS s FROM core_share_lot WHERE customer_id = :c",
c=CUST,
)[0]["s"]
) == Decimal("150")
# ── 10. 各 4xx / 503 分支 ───────────────────────────────────────────
def test_same_product_rejected(sqlite_engine):
_seed(sqlite_engine)
req = _req()
req["to_product_id"] = PROD_OUT
with pytest.raises(SameProduct):
convert_fund(req, now=NOW, **_services(sqlite_engine))
def test_cross_entity_rejected(sqlite_engine):
_seed(sqlite_engine)
_exec(
sqlite_engine,
"UPDATE core_product SET fund_company = '易方达模拟基金' WHERE product_id = :p",
p=PROD_IN,
)
with pytest.raises(CrossEntityNotSupported):
convert_fund(_req(), now=NOW, **_services(sqlite_engine))
def test_out_product_not_redeemable_rejected(sqlite_engine):
_seed(sqlite_engine)
_exec(
sqlite_engine,
"UPDATE core_product SET can_redeem = 0 WHERE product_id = :p",
p=PROD_OUT,
)
with pytest.raises(ProductNotRedeemable):
convert_fund(_req(), now=NOW, **_services(sqlite_engine))
def test_insufficient_shares_rejected(sqlite_engine):
_seed(sqlite_engine)
with pytest.raises(InsufficientShares):
convert_fund(_req(qty="500"), now=NOW, **_services(sqlite_engine))
def test_nav_not_ready_503(sqlite_engine):
_seed(sqlite_engine)
_exec(sqlite_engine, "DELETE FROM core_product_nav")
with pytest.raises(NavNotReady) as exc:
convert_fund(_req(), now=NOW, **_services(sqlite_engine))
assert exc.value.status_code == 503
def test_too_many_lots_rejected(sqlite_engine, monkeypatch):
from app.config import settings as settings_module
_seed(sqlite_engine)
monkeypatch.setattr(settings_module.settings, "convert_batch_max_lots", 1)
with pytest.raises(TooManyLots) as exc:
convert_fund(_req(qty="120"), now=NOW, **_services(sqlite_engine))
assert exc.value.extra == {"batch_count": 2, "max_lots": 1}
# ── 10. 阶段 1.5 接线(T-8):引擎真跑并出单 ─────────────────────────
def test_engine_wired_produces_single_alert_with_two_events(sqlite_engine):
"""T-8 落地后阶段 1.5 不再跳过:大额转换**真出一张单**、`payload.events` 两条(验收 7)。
本用例是「接线回归」:若 `_run_engine` 又被改回静默跳过(或签名对不上被
ImportError 吞掉),这里会因 `triggered_rules` 为空而变红。
"""
_seed(sqlite_engine)
th = RiskThresholds(
large_amount=Decimal("100"), # 调低以让 120 份的折算额命中 RISK-001
daily_total=Decimal("1000000"),
freq_count=3,
probe_window_minutes=5,
probe_count=3,
probe_amount=Decimal("400000"),
small_amount=Decimal("10000"),
small_count=3,
concentration_threshold=1.01,
)
resp = convert_fund(_req(), now=NOW, thresholds=th, **_services(sqlite_engine))
assert resp["engine_error"] is False, "引擎真的跑了且没炸(未走 ImportError 跳过分支)"
assert "RISK-001" in resp["triggered_rules"]
assert len(resp["alert_ids"]) == 1, "一次转换只出一张单"
alerts = _rows(sqlite_engine, "SELECT * FROM risk_alert")
assert len(alerts) == 1
payload = json.loads(alerts[0]["payload"])
assert len(payload["events"]) == 2, "一张单承载两条事件(转出 + 转入)"
assert [e["trade_type"] for e in payload["events"]] == ["redeem", "subscribe"]
# ── 11. T-12 补偿:阶段二失败 → 按 group 补写详情 + 预警(FR-C17 / 架构 §5.4)──
# ── 11. T-12 补偿:确认段第 ⑦/⑧ 步失败 → 按 group 补写详情 + 预警 ─────────
def _low_thresholds() -> RiskThresholds:
"""调低大额阈值,让 120 份的折算额足以命中 RISK-001(与 §10 接线用例同口径)。"""
return RiskThresholds(
@@ -717,9 +271,7 @@ def test_compensate_rebuilds_detail_and_alerts(sqlite_engine):
)
assert len(audits) == 1
# 补偿审计可追溯:phase 标注补偿来源 + 引擎异常事实入 summary
import json as _json
summary = _json.loads(
summary = json.loads(
_rows(
sqlite_engine,
"SELECT input_summary FROM audit_log WHERE decision = 'confirmed'",
@@ -802,4 +354,102 @@ def test_compensate_returns_locked_when_execution_right_taken(sqlite_engine):
out = _compensate(sqlite_engine, gid)
assert out["state"] == "locked" and out["alert_ids"] == []
assert _detail_rows(sqlite_engine, gid)[0]["status"] == "failed" # 仍待补偿
assert _rows(sqlite_engine, "SELECT * FROM risk_alert") == []
# ── 12. T+1 缺口补用(v1.0 用例退役后的承接,T-13)─────────────────────
def _seed_accepted_request(engine, cid_req: str, qty: str = "30000") -> str:
"""直插一张 accepted 受理单(确认段输入态;日历缺失由 `_safe_next_biz_day` 容错)。
两端净值都锚在受理日 T(`get_nav_on` 精确匹配);转出端净值取批次 nav 同值,
使逐批折算与明细口径自洽。
"""
_exec(
engine,
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) "
"VALUES (:p, 1.0300, 0, :d)",
p=PROD_OUT, d=TODAY,
)
_exec(
engine,
"INSERT INTO core_convert_request (convert_group_id, client_request_id,"
" customer_id, from_product_id, to_product_id, qty, status,"
" cancel_before, requested_at)"
" VALUES (:gid, :cid_req, :c, :fp, :tp, :q, 'accepted', NULL, :rat)",
gid="CNV-T13-0001", cid_req=cid_req, c=CUST, fp=PROD_OUT, tp=PROD_IN,
q=float(qty), rat=NOW,
)
return "CNV-T13-0001"
def test_confirm_mirror_write_failure_keeps_core_confirmed(sqlite_engine):
"""确认第 ⑧ 步镜像写入失败 → Core 已 confirmed 不回滚、镜像停 pending(v1.0
「阶段二失败不回滚」语义的 T+1 承接;失败现场由补偿 T-12 收口,真库证据
`verify_convert_compensate.py` A 组)。"""
_seed(sqlite_engine)
gid = _seed_accepted_request(sqlite_engine, "CLI-T13-MIRROR")
svc = _services(sqlite_engine)
with patch.object(
ConvertRepository, "sync_mirror", side_effect=RuntimeError("T13 故意:镜像失败")
):
out = confirm_one(
gid,
core_ro=svc["core_ro"],
risk_repo=svc["risk_repo"],
convert_repo=svc["convert_repo"],
core_writer=svc["core_writer"],
request_repo=svc["request_repo"],
thresholds=_low_thresholds(),
now=NOW,
as_of=TODAY,
)
assert out["status"] == "confirmed" # Core 事务不受 agent 故障影响
assert len(_rows(sqlite_engine, "SELECT * FROM core_trade")) == 2
req_status = _rows(
sqlite_engine,
"SELECT status FROM core_convert_request WHERE convert_group_id = :g", g=gid,
)[0]["status"]
assert req_status == "confirmed"
mirror = _detail_rows(sqlite_engine, gid)
assert mirror == [] or mirror[0]["status"] == "pending" # 第 ⑧ 步未推进(绝不写 completed)
def test_confirm_rejects_too_many_lots(sqlite_engine, monkeypatch):
"""参与批次数超上限 → `rejected` + `TOO_MANY_LOTS`(占用释放,份额不变;
v1.0 `TooManyLots` 4xx 语义在确认段的承接)。"""
_seed(sqlite_engine)
# 再补 2 个小批次 → 共 4 批;把确认批上限钳到 2
_seed_lot(sqlite_engine, "LOT-T13-3", "30", "1.0100", datetime(2026, 7, 1, 10, 0, 0))
_seed_lot(sqlite_engine, "LOT-T13-4", "20", "1.0200", datetime(2026, 6, 1, 10, 0, 0))
gid = _seed_accepted_request(sqlite_engine, "CLI-T13-LOTS")
monkeypatch.setattr(settings, "convert_batch_max_lots", 2)
svc = _services(sqlite_engine)
out = confirm_one(
gid,
core_ro=svc["core_ro"],
risk_repo=svc["risk_repo"],
core_writer=svc["core_writer"],
request_repo=svc["request_repo"],
convert_repo=svc["convert_repo"],
thresholds=_low_thresholds(),
now=NOW,
as_of=TODAY,
)
assert out["status"] == "rejected"
assert out["reject_reason"] == "TOO_MANY_LOTS"
assert len(_rows(sqlite_engine, "SELECT * FROM core_trade")) == 0 # 无流水
remain = _rows(
sqlite_engine,
"SELECT COALESCE(SUM(remain_qty), 0) AS s FROM core_share_lot "
"WHERE customer_id = :c AND product_id = :p",
c=CUST, p=PROD_OUT,
)[0]["s"]
assert Decimal(str(remain)) == Decimal("200") # 份额原封不动(占用自然释放)
req_status = _rows(
sqlite_engine,
"SELECT status FROM core_convert_request WHERE convert_group_id = :g", g=gid,
)[0]["status"]
assert req_status == "rejected"