基金转换 T+1 模型:T-13 convert_fund 退役 + 并发面重写 + 全量门禁(830 绿 / 真库 9/9 / 紧池 100%)
This commit is contained in:
+122
-472
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user