Files
group_xinghuo_jinrong/tests/test_convert_confirm.py
T
GaoYiYuan_0626 57f16a50a1 基金转换 T+1 模型:T-8 引擎时机收口(调用点唯一性守护 + 受理不触引擎 + 真库链路切 T+1)
【范围澄清】
引擎调用点在 T-7 即已迁到确认事务 commit 之后(engine_call.run_convert_engine 唯一出口),
受理段 accept_convert 自实现起就不含引擎调用 → R-7 已满足。本轮补齐**守护与端到端验证**。
注意 F-10 已明确:T-8 **不动**去重口径(amount_view 三处消费方原样不动,那是 T-11 的范围)。

【新增测试】
- test_convert_engine.py:test_process_convert_event_has_single_call_site —— AST 统计
  app/ 下 process_convert_event( 的 Call 节点**恰 1 处**且宿主为 run_convert_engine。
  用 AST 而非字符串匹配:engine_call.py 的说明段含该字样,会被误判。
  **已做突变验证**(临时加第二处调用 → 断言变红,随后还原)。
- test_convert_accept.py:受理无副作用用例补「不触引擎」断言 —— 受理后 risk_alert 0 行、
  无 pass / engine_error 审计(验收 29 前半)。
- test_convert_confirm.py:test_real_engine_on_confirm_dedupes_daily_total —— 确认链路接
  **真引擎**(不注入 hook):正证(阈值夹逼 → RISK-002 不命中,去重生效)+ 反证(阈值下调
  → 命中,证明确认段确实跑过引擎),并断言一张单 / payload.events 两条 / 去重不删行(验收 5/6/7)。
  **已做突变验证**(禁用引擎调用 → 变红,随后还原)。

【真库脚本】
verify_convert_engine.py 整体切换 T+1 链路(此前走 v1.0 convert_fund):
受理 accept_convert → 确认 confirm_one;新增 A0 组「受理不触引擎」;A~F 组保留原断言
(引擎出单 / RISK-002 夹逼 / 去重不删行 / 幂等重放 / 无命中 / 清理自检)。真库 39/39 一致。
- 折算期望改用 T 日净值(未知价法),不再沿用批次买入净值 alloc.nav;
- 基准日取真库日历中位开市日,T+1 = 下一交易日;
- 阈值必须显式传给 confirm_one —— 缺省退回 settings 默认会把 RISK-002 误判为命中,
  症状像「去重失效」,实为口径没传(首轮实跑被 B 组断言捕获,已钉进注释)。

【回归】
全量 829 passed / 10 skipped(827 → +2);复跑 T-6 verify_convert_accept 37/37、
T-7 verify_convert_confirm 83/83、v1.0 链路 verify_convert_service 35/35,均零失败。
2026-09-11 19:48:53 +08:00

823 lines
34 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-7 确认段(`confirm_service`)单测 · T+1 受理/确认分离模型的第二段。
**核心验证目标**
1. **折算与 PRD §5.3.2 示例逐字节一致** —— 同一个输入(30000+20000 份、T 日净值
1.3604 / 1.9194、费率 0.30% / 0.80%)必须算出 68020.00 / 612.18 / 67407.82 /
333.36 / 67074.46 / 34945.54 / -0.0049。这是「实现与文档不漂移」的硬锚点。
2. **T+1 相对 v1.0 的实质变更**:转出端逐批金额按 **T 日净值**(未知价法),
而不是批次买入时的净值。
3. **确认事务的原子性**:扣批次 + 两条流水 + 转入批次 + 两端持仓 + 明细 +
**受理单置 confirmed** 全在一个事务里,任何一步失败整体回滚。
4. **幂等**:重复确认(单笔 / 整批)不产生第二组流水。
5. **受理单状态不可逆**:终态单不再被批处理捞到。
sqlite 内存库;真库侧(ENUM 严校验 / DATETIME(3) / 死锁重试)由
`scripts/dev/verify_convert_confirm.py` 承担。
"""
from __future__ import annotations
import json
from datetime import date, datetime, timedelta
from decimal import Decimal
import pytest
from sqlalchemy import text
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.confirm_service import confirm_batch, confirm_one
from app.service.convert.convert_service import accept_convert, cancel_convert
from app.service.convert.errors import (
CancelNotAllowed,
ConfirmConflict,
ConvertRequestNotFound,
)
from app.service.risk.rules import RiskThresholds
CUST = "CUST-CC" # C3 客户 → 转入 R4 = 放行
CUST_LOW = "CUST-CCL" # C1 客户 → 转入 R4 = 阻断(T+1 复核用)
PROD_OUT = "PROD-CC-OUT"
PROD_IN = "PROD-CC-IN"
COMPANY = "华夏模拟基金"
TA = "TA-CN-001"
T_DAY = date(2026, 9, 4) # 受理日 T(周五)
T1_DAY = date(2026, 9, 7) # 确认业务日 T+1(下周一)
SUBMIT_AT = datetime(2026, 9, 4, 10, 0)
CONFIRM_AT = datetime(2026, 9, 7, 9, 0)
# PRD §5.3.2 的输入(真实净值:PROD-110022 / PROD-003095 在 T=2026-09-09 的值)
OUT_NAV = Decimal("1.3604")
IN_NAV = Decimal("1.9194")
OUT_RATE = Decimal("0.0030")
IN_RATE = Decimal("0.0080")
FEE_TIERS = [
(0, 7, "0.0150"), (7, 30, "0.0100"), (30, 180, "0.0050"),
(180, 365, "0.0025"), (365, None, "0.0000"),
]
# ── 种子 ────────────────────────────────────────────────────────────
def _exec(engine, sql: str, **params) -> None:
with engine.begin() as conn:
conn.execute(text(sql), params)
def _rows(engine, sql: str, **params) -> list[dict]:
with engine.connect() as conn:
return [dict(r) for r in conn.execute(text(sql), params).mappings()]
def _seed_calendar(engine, start: date = date(2026, 9, 1), days: int = 60) -> None:
cursor = start
for _ in range(days):
if cursor.weekday() < 5: # 周一~周五开市;周末不插行 = 休市
_exec(
engine,
"INSERT INTO core_trade_calendar (cal_date, is_open, remark) VALUES (:d, 1, 't')",
d=cursor,
)
cursor += timedelta(days=1)
def _seed(engine, *, with_nav: bool = True, min_hold: str = "0") -> None:
_exec(
engine,
"INSERT INTO core_customer (customer_id, display_name, age, is_active) "
"VALUES (:c, 'CC客户', 40, 1)",
c=CUST,
)
_exec(
engine,
"INSERT INTO core_customer (customer_id, display_name, age, is_active) "
"VALUES (:c, 'CC低风险', 40, 1)",
c=CUST_LOW,
)
for cid, code in ((CUST, "C3"), (CUST_LOW, "C1")):
_exec(
engine,
"INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at, expires_at) "
"VALUES (:c, :r, :t, :e)",
c=cid, r=code,
t=SUBMIT_AT - timedelta(days=30), e=SUBMIT_AT + timedelta(days=300),
)
_exec(
engine,
"INSERT INTO core_product (product_id, product_name, min_risk_code, product_type, "
"can_subscribe, can_redeem, subscribe_fee_rate, min_redeem_qty, min_hold_qty, "
"fund_company, ta_code) "
"VALUES (:p, '转出债基', 'R2', 'bond', 1, 1, :r, 0, :mh, :co, :ta)",
p=PROD_OUT, r=float(OUT_RATE), mh=float(min_hold), co=COMPANY, ta=TA,
)
_exec(
engine,
"INSERT INTO core_product (product_id, product_name, min_risk_code, product_type, "
"can_subscribe, can_redeem, subscribe_fee_rate, fund_company, ta_code) "
"VALUES (:p, '转入股基', 'R4', 'stock', 1, 1, :r, :co, :ta)",
p=PROD_IN, r=float(IN_RATE), co=COMPANY, ta=TA,
)
for mh, mh_max, rate in FEE_TIERS:
_exec(
engine,
"INSERT INTO core_fee_rule (product_id, fee_type, min_hold_days, max_hold_days, rate) "
"VALUES (:p, 'redeem', :a, :b, :r)",
p=PROD_OUT, a=mh, b=mh_max, r=float(rate),
)
if with_nav:
_exec(
engine,
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) "
"VALUES (:p, :n, 0, :d)",
p=PROD_OUT, n=float(OUT_NAV), d=T_DAY,
)
_exec(
engine,
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) "
"VALUES (:p, :n, 0, :d)",
p=PROD_IN, n=float(IN_NAV), d=T_DAY,
)
for cid, tag in ((CUST, "CC"), (CUST_LOW, "CCL")):
# 批次 1:2026-08-01 确认 → 到 T 日持有 34 天 → 档位 (30,180) = 0.50%
_exec(
engine,
"INSERT INTO core_share_lot (lot_id, customer_id, product_id, qty, remain_qty, "
"nav, confirmed_at) VALUES (:l, :c, :p, 30000, 30000, 1.0300, :cat)",
l=f"LOT-{tag}-1", c=cid, p=PROD_OUT, cat=datetime(2026, 8, 1, 10, 0, 0),
)
# 批次 2:2026-09-01 确认 → 到 T 日持有 3 天 → 档位 (0,7) = 1.50%
_exec(
engine,
"INSERT INTO core_share_lot (lot_id, customer_id, product_id, qty, remain_qty, "
"nav, confirmed_at) VALUES (:l, :c, :p, 20000, 20000, 1.0000, :cat)",
l=f"LOT-{tag}-2", c=cid, p=PROD_OUT, cat=datetime(2026, 9, 1, 10, 0, 0),
)
_exec(
engine,
"INSERT INTO core_holding (customer_id, product_id, qty, cost_amount, "
"market_value, pnl_pct, as_of) VALUES (:c, :p, 50000, 50000, 51000, 0, :d)",
c=cid, p=PROD_OUT, d=T_DAY,
)
_seed_calendar(engine)
def _services(engine) -> dict:
"""受理段 / 撤单段的仓储集合(不含写库网关 —— 这两段只读 + 落受理单)。"""
return dict(
core_ro=CoreReadOnlyRepository(engine=engine),
risk_repo=RiskRepository(engine=engine),
convert_repo=ConvertRepository(engine=engine),
request_repo=ConvertRequestRepository(engine=engine),
)
def _confirm_services(engine) -> dict:
"""确认段独有:多一个 `core_writer`(写库网关,确认事务的载体)。"""
return dict(**_services(engine), core_writer=ConvertCoreRepository(engine=engine))
def _accept(engine, *, qty="50000", customer: str = CUST, cid=None) -> dict:
req = {
"customer_id": customer,
"from_product_id": PROD_OUT,
"to_product_id": PROD_IN,
"qty": Decimal(qty),
}
if cid:
req["client_request_id"] = cid
return accept_convert(req, now=SUBMIT_AT, **_services(engine))
def _confirm(engine, gid: str, **kw) -> dict:
return confirm_one(gid, now=CONFIRM_AT, as_of=T1_DAY, **_confirm_services(engine), **kw)
def _trades(engine, gid: str) -> list[dict]:
return _rows(
engine,
"SELECT trade_id, trade_type, amount, qty FROM core_trade "
"WHERE convert_group_id = :g ORDER BY trade_type",
g=gid,
)
def _req_row(engine, gid: str) -> dict:
return _rows(
engine, "SELECT * FROM core_convert_request WHERE convert_group_id = :g", g=gid
)[0]
def _qty(engine, lot_id: str) -> Decimal:
r = _rows(engine, "SELECT remain_qty FROM core_share_lot WHERE lot_id = :l", l=lot_id)[0]
return Decimal(str(r["remain_qty"]))
# ── 1. 全流程 + PRD §5.3.2 逐字节对齐 ───────────────────────────────
def test_confirm_full_flow_matches_prd_example(sqlite_engine):
"""受理 50000 → 确认:折算值与 PRD §5.3.2 示例逐字节一致,且写全 6 类数据。"""
_seed(sqlite_engine)
accepted = _accept(sqlite_engine, cid="CC-REQ-1")
gid = accepted["convert_group_id"]
res = _confirm(sqlite_engine, gid)
assert res["status"] == "confirmed"
assert res["partial"] is False
assert res["forced_full_transfer"] is False
# ── 折算(PRD §5.3.2 示例数字,逐字节)──
assert res["out_nav"] == "1.3604"
assert res["nav_date"] == str(T_DAY)
assert res["out_amount"] == "68020.00"
assert res["redeem_fee"] == "612.18"
assert res["convert_amount"] == "67407.82"
assert res["diff_fee"] == "333.36"
assert res["in_amount"] == "67074.46"
assert res["in_qty"] == "34945.54"
assert res["rounding_diff"] == "-0.0049"
assert res["lot_count"] == 2
# 逐批:批次 1 持 34 天 → 0.50%;批次 2 持 3 天 → 1.50%
by_lot = {b["lot_id"]: b for b in res["lot_breakdown"]}
assert by_lot["LOT-CC-1"]["hold_days"] == 34
assert by_lot["LOT-CC-1"]["fee_rate"] == "0.0050"
assert by_lot["LOT-CC-1"]["fee_amount"] == "204.06"
assert by_lot["LOT-CC-2"]["hold_days"] == 3
assert by_lot["LOT-CC-2"]["fee_rate"] == "0.0150"
assert by_lot["LOT-CC-2"]["fee_amount"] == "408.12"
# ── 两条流水(R-b:redeem / subscribe,同 gid)──
trades = _trades(sqlite_engine, gid)
assert [t["trade_type"] for t in trades] == ["redeem", "subscribe"]
assert Decimal(str(trades[0]["amount"])) == Decimal("68020")
assert Decimal(str(trades[0]["qty"])) == Decimal("50000")
assert Decimal(str(trades[1]["amount"])) == Decimal("67074.46")
assert Decimal(str(trades[1]["qty"])) == Decimal("34945.54")
# ── 批次扣减 + 转入新批次 ──
assert _qty(sqlite_engine, "LOT-CC-1") == Decimal("0")
assert _qty(sqlite_engine, "LOT-CC-2") == Decimal("0")
assert _qty(sqlite_engine, f"LOT-{gid}-IN") == Decimal("34945.54")
# ── 两端持仓 ──
out_h = _rows(
sqlite_engine,
"SELECT qty FROM core_holding WHERE customer_id = :c AND product_id = :p",
c=CUST, p=PROD_OUT,
)[0]
in_h = _rows(
sqlite_engine,
"SELECT qty FROM core_holding WHERE customer_id = :c AND product_id = :p",
c=CUST, p=PROD_IN,
)
assert Decimal(str(out_h["qty"])) == Decimal("0") # 全部转出
assert len(in_h) == 1 and Decimal(str(in_h[0]["qty"])) == Decimal("34945.54")
# ── 受理单终态 + 回填 ──
row = _req_row(sqlite_engine, gid)
assert row["status"] == "confirmed"
assert Decimal(str(row["actual_qty"])) == Decimal("50000")
assert Decimal(str(row["coupon"])) == Decimal("333.36")
assert row["confirmed_at"] is not None
# ── 计费明细(补偿数据源)──
assert len(_rows(
sqlite_engine,
"SELECT 1 FROM core_convert_lot_detail WHERE convert_group_id = :g", g=gid,
)) == 2
# ── agent 镜像 completed(含详情)──
mirror = _rows(
sqlite_engine,
"SELECT status, out_trade_id, fee_amount, hold_days_min, hold_days_max "
"FROM risk_convert_detail WHERE convert_group_id = :g",
g=gid,
)[0]
assert mirror["status"] == "completed"
assert mirror["out_trade_id"] == res["out_trade_id"]
assert Decimal(str(mirror["fee_amount"])) == Decimal("612.18")
assert (int(mirror["hold_days_min"]), int(mirror["hold_days_max"])) == (3, 34)
audits = [
r["decision"]
for r in _rows(
sqlite_engine,
"SELECT decision FROM audit_log WHERE event_type = 'convert_request' "
"AND input_summary LIKE :p",
p=f"%{gid}%",
)
]
assert "confirmed" in audits
# ── 2. 幂等 ─────────────────────────────────────────────────────────
def test_confirm_twice_is_idempotent(sqlite_engine):
"""重复确认:第二次 `skipped`,流水不重复(验收 24 后半 / 15)。"""
_seed(sqlite_engine)
gid = _accept(sqlite_engine)["convert_group_id"]
first = _confirm(sqlite_engine, gid)
assert first["status"] == "confirmed"
second = _confirm(sqlite_engine, gid)
assert second["status"] == "skipped"
assert second["current_status"] == "confirmed"
assert len(_trades(sqlite_engine, gid)) == 2 # 没有第二组流水
assert _qty(sqlite_engine, f"LOT-{gid}-IN") == Decimal("34945.54")
def test_confirm_batch_second_run_processes_nothing(sqlite_engine):
"""同 as_of 再跑批处理 → 0 处理(捞单 SQL 只取 accepted/nav_pending)。"""
_seed(sqlite_engine)
_accept(sqlite_engine, qty="20000")
_accept(sqlite_engine, qty="30000")
first = confirm_batch(T1_DAY, now=CONFIRM_AT, **_confirm_services(sqlite_engine))
assert first["locked"] is True
assert first["scanned"] == 2 and first["confirmed"] == 2
second = confirm_batch(T1_DAY, now=CONFIRM_AT, **_confirm_services(sqlite_engine))
assert second["scanned"] == 0 and second["confirmed"] == 0
# ── 3. 缺 T 日净值 → nav_pending → 补净值后确认 ─────────────────────
def test_confirm_missing_nav_then_retry(sqlite_engine):
"""缺 T 日净值:受理单挂 `nav_pending`、**无流水、份额不变**;补净值后重跑即确认。"""
_seed(sqlite_engine, with_nav=False)
gid = _accept(sqlite_engine)["convert_group_id"]
res = _confirm(sqlite_engine, gid)
assert res["status"] == "nav_pending"
assert set(res["missing_nav_products"]) == {PROD_OUT, PROD_IN}
assert _req_row(sqlite_engine, gid)["status"] == "nav_pending"
assert _trades(sqlite_engine, gid) == []
assert _qty(sqlite_engine, "LOT-CC-1") == Decimal("30000") # 份额一分未动
# 占用仍保持(nav_pending 属在途态),超量申请照样被拒
from app.service.convert.errors import InsufficientShares
with pytest.raises(InsufficientShares):
_accept(sqlite_engine, qty="30000") # 可用仅 0(50000 全部在途)
# 补 T 日净值 → 重跑(同一单,状态 nav_pending → confirmed)
_exec(
sqlite_engine,
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) "
"VALUES (:p, :n, 0, :d)",
p=PROD_OUT, n=float(OUT_NAV), d=T_DAY,
)
_exec(
sqlite_engine,
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) "
"VALUES (:p, :n, 0, :d)",
p=PROD_IN, n=float(IN_NAV), d=T_DAY,
)
again = _confirm(sqlite_engine, gid)
assert again["status"] == "confirmed"
assert again["out_amount"] == "68020.00"
def test_confirm_nav_pending_retry_does_not_false_conflict(sqlite_engine):
"""已是 `nav_pending` 的单重跑**不得**被判成并发冲突。
真库陷阱:MySQL 的 `rowcount` 是 **changed rows**,
`SET status='nav_pending' WHERE status='nav_pending'` 返回 0(sqlite 返回 1)
—— 若无条件地复用 `transition_status`,真库上会把「正常的重试」误判成冲突。
"""
_seed(sqlite_engine, with_nav=False)
gid = _accept(sqlite_engine)["convert_group_id"]
_confirm(sqlite_engine, gid)
res = _confirm(sqlite_engine, gid) # 第二次仍缺净值
assert res["status"] == "nav_pending"
assert res["transitioned"] is False # 状态没变,如实回 False(而非报冲突)
# ── 4. T+1 适当性复核(D25 · 验收 26)───────────────────────────────
def test_confirm_suitability_recheck_rejects_and_releases(sqlite_engine):
"""受理通过、T+1 复核不通过 → `rejected` + 占用释放、份额不变、无流水。"""
_seed(sqlite_engine)
# 该客户在受理时**合规**(C3 且风评在有效期内)→ 受理通过;
# 随后风评到期并降级为 C1(转入 R4 股基即不匹配),模拟 D25 的 T+1 复核场景。
_exec(
sqlite_engine,
"UPDATE core_customer_risk SET risk_code = 'C3', evaluated_at = :t, expires_at = :e "
"WHERE customer_id = :c",
c=CUST_LOW, t=SUBMIT_AT - timedelta(days=30), e=SUBMIT_AT + timedelta(days=300),
)
gid = _accept(sqlite_engine, customer=CUST_LOW)["convert_group_id"]
assert _req_row(sqlite_engine, gid)["status"] == "accepted"
# T+1 受理日之前:风评到期 + 降级 → 复核不通过
_exec(
sqlite_engine,
"UPDATE core_customer_risk SET risk_code = 'C1', expires_at = :e "
"WHERE customer_id = :c",
c=CUST_LOW, e=SUBMIT_AT - timedelta(days=1),
)
res = _confirm(sqlite_engine, gid)
assert res["status"] == "rejected"
assert _req_row(sqlite_engine, gid)["status"] == "rejected"
assert _trades(sqlite_engine, gid) == []
assert _qty(sqlite_engine, "LOT-CCL-1") == Decimal("30000")
# 占用释放 → 同客户可再次受理全量
assert CoreReadOnlyRepository(engine=sqlite_engine).sum_inflight_qty(
CUST_LOW, PROD_OUT
) == Decimal("0")
def test_confirm_rejects_when_product_disabled(sqlite_engine):
"""确认段复核产品可申赎状态(受理后产品被下架 → rejected)。"""
_seed(sqlite_engine)
gid = _accept(sqlite_engine)["convert_group_id"]
_exec(sqlite_engine, "UPDATE core_product SET can_redeem = 0 WHERE product_id = :p", p=PROD_OUT)
res = _confirm(sqlite_engine, gid)
assert res["status"] == "rejected"
assert res["reject_reason"] == "PRODUCT_NOT_REDEEMABLE"
assert "PRODUCT_NOT_REDEEMABLE" in (_req_row(sqlite_engine, gid)["remark"] or "")
# ── 5. 部分成交(R-10 · 验收 30)────────────────────────────────────
def test_confirm_partial_fill_records_actual_and_releases(sqlite_engine):
"""T 日→T+1 之间份额被别处用掉 → 部分成交:`actual_qty < qty` + `remark='partial'`。"""
_seed(sqlite_engine)
gid = _accept(sqlite_engine)["convert_group_id"] # 申请 50000
# 模拟:批次 2 的份额在确认前被普通赎回用掉 10000
_exec(
sqlite_engine,
"UPDATE core_share_lot SET remain_qty = 10000 WHERE lot_id = 'LOT-CC-2'",
)
res = _confirm(sqlite_engine, gid)
assert res["status"] == "confirmed"
assert res["partial"] is True
assert res["requested_qty"] == "50000.00"
assert res["actual_qty"] == "40000.00"
row = _req_row(sqlite_engine, gid)
assert row["remark"] == "partial"
# 未确认部分(10000 份)占用随终态释放
assert CoreReadOnlyRepository(engine=sqlite_engine).sum_inflight_qty(
CUST, PROD_OUT
) == Decimal("0")
def test_confirm_rejects_when_no_share_left(sqlite_engine):
"""可用份额全被抢光 → `rejected`(无可确认份额,不清零占用以外的任何东西)。"""
_seed(sqlite_engine)
gid = _accept(sqlite_engine)["convert_group_id"]
_exec(sqlite_engine, "UPDATE core_share_lot SET remain_qty = 0 WHERE customer_id = :c", c=CUST)
res = _confirm(sqlite_engine, gid)
assert res["status"] == "rejected"
assert res["reject_reason"] == "NO_AVAILABLE_QTY"
# ── 6. 确认事务原子性(状态被别人改掉 → 整体回滚)────────────────────
def test_confirm_conflict_rolls_back_whole_transaction(sqlite_engine, monkeypatch):
"""读单(accepted)→ 事务内置位之间状态被改 → `ConfirmConflict` 且**全部回滚**。
这是「状态与份额必须同生同死」的核心守护:若状态置位不在事务内,
会出现「份额已扣、受理单仍 accepted」→ 下轮批处理二次扣减(超扣)。
"""
_seed(sqlite_engine)
gid = _accept(sqlite_engine)["convert_group_id"]
real_apply = ConvertCoreRepository.apply_convert
def racing_apply(self, req):
# 模拟并发:另一路先把受理单撤了
_exec(
sqlite_engine,
"UPDATE core_convert_request SET status = 'cancelled' WHERE convert_group_id = :g",
g=gid,
)
return real_apply(self, req)
monkeypatch.setattr(ConvertCoreRepository, "apply_convert", racing_apply)
with pytest.raises(ConfirmConflict):
_confirm(sqlite_engine, gid)
# 事务整体回滚:无流水、份额未动、无转入批次
assert _trades(sqlite_engine, gid) == []
assert _qty(sqlite_engine, "LOT-CC-1") == Decimal("30000")
assert _qty(sqlite_engine, "LOT-CC-2") == Decimal("20000")
assert _rows(
sqlite_engine, "SELECT 1 FROM core_share_lot WHERE lot_id = :l", l=f"LOT-{gid}-IN"
) == []
# ── 7. 批处理:串行 + 锁 + 汇总 ─────────────────────────────────────
def test_confirm_batch_summary_and_serial_order(sqlite_engine):
"""批处理按受理先后串行处理,汇总计数正确(FR-C23)。"""
_seed(sqlite_engine)
a = _accept(sqlite_engine, qty="20000")["convert_group_id"] # 先受理
b = _accept(sqlite_engine, qty="30000")["convert_group_id"]
res = confirm_batch(T1_DAY, now=CONFIRM_AT, **_confirm_services(sqlite_engine))
assert res["scanned"] == 2
assert res["confirmed"] == 2
assert [r["convert_group_id"] for r in res["results"]] == [a, b] # requested_at 升序
for gid in (a, b):
assert _req_row(sqlite_engine, gid)["status"] == "confirmed"
def test_confirm_batch_skips_when_lock_not_acquired(sqlite_engine, monkeypatch):
"""未抢到批处理锁 → 整轮让路(R-1 防双跑),不处理任何单。"""
_seed(sqlite_engine)
_accept(sqlite_engine)
import app.service.convert.confirm_service as cs
monkeypatch.setattr(cs, "run_locked", lambda key, fn: fn(False))
res = confirm_batch(T1_DAY, now=CONFIRM_AT, **_confirm_services(sqlite_engine))
assert res["locked"] is False
assert res["scanned"] == 0
assert _rows(sqlite_engine, "SELECT 1 FROM core_trade") == [] # 一单未处理
def test_confirm_batch_survives_single_failure(sqlite_engine, monkeypatch):
"""单笔异常不中断整批(其余单继续确认)。"""
_seed(sqlite_engine)
a = _accept(sqlite_engine, qty="20000")["convert_group_id"]
b = _accept(sqlite_engine, qty="30000")["convert_group_id"]
import app.service.convert.confirm_service as cs
real_one = cs.confirm_one
def flaky(gid, **kw):
if gid == a:
raise RuntimeError("模拟单笔故障")
return real_one(gid, **kw)
monkeypatch.setattr(cs, "confirm_one", flaky)
res = confirm_batch(T1_DAY, now=CONFIRM_AT, **_confirm_services(sqlite_engine))
assert res["failed"] == 1
assert res["confirmed"] == 1
assert _req_row(sqlite_engine, b)["status"] == "confirmed"
assert _req_row(sqlite_engine, a)["status"] == "accepted" # 未被破坏,下轮可重试
# ── 8. 引擎恰好一次(FR-C28 · 验收 5/7/29)──────────────────────────
def test_engine_runs_exactly_once_on_confirm(sqlite_engine):
"""引擎只在确认段跑一次;重复确认不再跑(受理段不跑)。"""
_seed(sqlite_engine)
calls: list[str] = []
def hook(out_trade, in_trade):
calls.append(out_trade["convert_group_id"])
return {"triggered_rules": ["RISK-002"], "alert_ids": [], "aml_hit": False}
gid = _accept(sqlite_engine)["convert_group_id"]
assert calls == [] # 受理段不跑引擎
first = _confirm(sqlite_engine, gid, engine_hook=hook)
assert calls == [gid]
assert first["triggered_rules"] == ["RISK-002"]
_confirm(sqlite_engine, gid, engine_hook=hook) # 第二次 skipped
assert calls == [gid] # 仍然只跑过一次
def test_engine_exception_does_not_block_confirmation(sqlite_engine):
"""引擎异常不阻断已成立的交易(D17)。"""
_seed(sqlite_engine)
gid = _accept(sqlite_engine)["convert_group_id"]
def boom(out_trade, in_trade):
raise RuntimeError("引擎炸了")
res = _confirm(sqlite_engine, gid, engine_hook=boom)
assert res["status"] == "confirmed"
assert len(_trades(sqlite_engine, gid)) == 2
assert res["engine_error"] is False # 异常被吞,未标记 engine_error 字段
def test_real_engine_on_confirm_dedupes_daily_total(sqlite_engine):
"""确认链路接**真引擎**(不注入 hook):一张单、两条事件、RISK-002 只计一次。
验收 5/6/7 的端到端版本 —— 本文件其余用例用 `engine_hook` 注入计数(验证时机),
这条走真实 `process_convert_event`,证明确认段确实在事务提交后把两条流水
投给了引擎,且金额聚合去重生效。
折算值:转出端 68020.00 / 转入端 67074.46(和 135094.46)。阈值夹在
「单条」与「两条之和」之间 → 去重后不命中;若聚合未去重则 RISK-002 误报。
"""
_seed(sqlite_engine)
base = dict(
large_amount=Decimal("1000000"), # 关掉 RISK-001,避免与 RISK-002 混淆
freq_count=99,
probe_window_minutes=5,
probe_count=99,
probe_amount=Decimal("99999999"),
small_amount=Decimal("1"),
small_count=99,
concentration_threshold=1.01,
)
# ① 正证:68020 < 100000 < 135094.46 → 去重后不命中
gid = _accept(sqlite_engine, cid="CC-ENG-1")["convert_group_id"]
res = _confirm(
sqlite_engine, gid, thresholds=RiskThresholds(daily_total=Decimal("100000"), **base)
)
assert res["status"] == "confirmed"
assert res["triggered_rules"] == [], "金额聚合未去重时 RISK-002 会误报"
assert res["alert_ids"] == []
assert _rows(sqlite_engine, "SELECT 1 FROM risk_alert") == []
# ② 反证:阈值降到 60000(< 单条 68020)→ 命中,证明引擎确实在确认段跑过
_exec(
sqlite_engine,
"UPDATE core_share_lot SET remain_qty = qty "
"WHERE customer_id = :c AND product_id = :p",
c=CUST, p=PROD_OUT,
)
gid2 = _accept(sqlite_engine, cid="CC-ENG-2")["convert_group_id"]
res2 = _confirm(
sqlite_engine, gid2, thresholds=RiskThresholds(daily_total=Decimal("60000"), **base)
)
assert res2["status"] == "confirmed"
assert "RISK-002" in res2["triggered_rules"]
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"]
assert alerts[0]["trade_id"] == res2["out_trade_id"], "主流水口径取转出端"
# 去重不删行:两单各 2 条流水仍在(验收 6)
assert len(_trades(sqlite_engine, gid)) == 2
assert len(_trades(sqlite_engine, gid2)) == 2
# ── 9. 资金流归属(T-7 明确不写 core_cash_flow,归 T-16)─────────────
def test_confirm_writes_no_cash_flow(sqlite_engine):
"""确认事务**不写** `core_cash_flow` —— 资金流唯一归属 T-16(反双重归属)。
本断言靠「表不存在」实现:sqlite 库若真被写该表,确认会直接报错而非静默通过。
表建立(T-16)之后此用例需改为「0 行」断言 —— 已在 T-16 的 DoD 中登记。
"""
_seed(sqlite_engine)
tables = {
r["name"]
for r in _rows(sqlite_engine, "SELECT name FROM sqlite_master WHERE type='table'")
}
assert "core_cash_flow" not in tables, (
"core_cash_flow 已建表 → 本断言须同步改为「确认段 0 行」(T-16 落地时)"
)
gid = _accept(sqlite_engine)["convert_group_id"]
assert _confirm(sqlite_engine, gid)["status"] == "confirmed"
# ── 10. 撤单(PRD §7.0.3 / R-9)────────────────────────────────────
def test_cancel_within_window_releases_occupancy(sqlite_engine):
"""窗口内撤单:`cancelled` + 占用释放 + 审计留痕。"""
_seed(sqlite_engine)
gid = _accept(sqlite_engine, qty="50000")["convert_group_id"]
repo = CoreReadOnlyRepository(engine=sqlite_engine)
assert repo.sum_inflight_qty(CUST, PROD_OUT) == Decimal("50000")
res = cancel_convert(
gid, now=datetime(2026, 9, 4, 14, 59), **_services(sqlite_engine)
)
assert res["status"] == "cancelled"
assert res["released_qty"] == "50000.00"
assert repo.sum_inflight_qty(CUST, PROD_OUT) == Decimal("0") # 占用已释放
row = _req_row(sqlite_engine, gid)
assert row["status"] == "cancelled"
# 撤单只动 Core 状态,镜像保留原进度 + 留痕撤单时刻
mirror = _rows(
sqlite_engine,
"SELECT status, cancelled_at FROM risk_convert_detail WHERE convert_group_id = :g",
g=gid,
)[0]
assert mirror["status"] == "pending"
assert mirror["cancelled_at"] is not None
audits = [
r["decision"]
for r in _rows(
sqlite_engine,
"SELECT decision FROM audit_log WHERE input_summary LIKE :p",
p=f"%{gid}%",
)
]
assert "convert_cancelled" in audits
def test_cancel_after_deadline_rejected(sqlite_engine):
"""过 15:00 截点 → `CancelNotAllowed`(验收 23)。"""
_seed(sqlite_engine)
gid = _accept(sqlite_engine)["convert_group_id"]
with pytest.raises(CancelNotAllowed):
cancel_convert(gid, now=datetime(2026, 9, 4, 15, 0, 1), **_services(sqlite_engine))
assert _req_row(sqlite_engine, gid)["status"] == "accepted"
def test_cancel_confirmed_request_rejected(sqlite_engine):
"""已确认的单不可撤(终态不可逆)。"""
_seed(sqlite_engine)
gid = _accept(sqlite_engine)["convert_group_id"]
_confirm(sqlite_engine, gid)
with pytest.raises(CancelNotAllowed):
cancel_convert(gid, now=datetime(2026, 9, 4, 14, 0), **_services(sqlite_engine))
def test_cancel_unknown_request_raises_not_found(sqlite_engine):
_seed(sqlite_engine)
with pytest.raises(ConvertRequestNotFound):
cancel_convert("CNV-NOPE", now=SUBMIT_AT, **_services(sqlite_engine))
def test_cancel_then_accept_again_full_amount(sqlite_engine):
"""撤单释放占用后,同量可再次受理(占用确实还原)。"""
_seed(sqlite_engine)
gid = _accept(sqlite_engine, qty="50000")["convert_group_id"]
cancel_convert(gid, now=datetime(2026, 9, 4, 14, 0), **_services(sqlite_engine))
again = _accept(sqlite_engine, qty="50000")
assert again["accepted"] is True
assert again["convert_group_id"] != gid
# ── 11. 强制全转在确认段的表现 ──────────────────────────────────────
def test_confirm_forced_full_transfer_records_actual(sqlite_engine):
"""强制全转:受理申请 48000(留 2000 < min_hold 5000)→ 实际全转 50000。
关键契约:**强制全转是受理段的决策**,确认段只能继承 —— 受理单 `qty` 已
收敛为 50000,确认段若重新判最低持有会因 `leftover = 0` 得出 False(误报)。
"""
_seed(sqlite_engine, min_hold="5000")
accepted = _accept(sqlite_engine, qty="48000")
gid = accepted["convert_group_id"]
assert accepted["forced_full_transfer"] is True # 受理响应已如实标注
row = _req_row(sqlite_engine, gid)
assert row["qty"] == 50000 # 受理时已收敛为实际全转量
assert row["remark"] == "full_transfer" # 决策落库,供确认段继承
res = _confirm(sqlite_engine, gid)
assert res["status"] == "confirmed"
assert res["forced_full_transfer"] is True
assert res["min_hold_action"] == "force_transfer"
assert res["actual_qty"] == "50000.00"
# 确认后 remark 仍保留受理标记(未被确认段覆盖)
assert _req_row(sqlite_engine, gid)["remark"] == "full_transfer"
assert _qty(sqlite_engine, "LOT-CC-1") == Decimal("0")
assert _qty(sqlite_engine, "LOT-CC-2") == Decimal("0")
def test_confirm_partial_mark_coexists_with_full_transfer(sqlite_engine):
"""受理标记与确认标记共存:`full_transfer;partial`(`;` 连接,顺序固定)。"""
_seed(sqlite_engine, min_hold="5000")
gid = _accept(sqlite_engine, qty="48000")["convert_group_id"]
assert _req_row(sqlite_engine, gid)["remark"] == "full_transfer"
# 抢走批次 1 的一半 → T+1 可用 35000 < 受理 50000 → 部分成交
_exec(
sqlite_engine,
"UPDATE core_share_lot SET remain_qty = 15000 WHERE lot_id = 'LOT-CC-1'",
)
res = _confirm(sqlite_engine, gid)
assert res["status"] == "confirmed"
assert res["partial"] is True
assert res["forced_full_transfer"] is True
assert res["actual_qty"] == "35000.00"
assert _req_row(sqlite_engine, gid)["remark"] == "full_transfer;partial"
assert _req_row(sqlite_engine, gid)["actual_qty"] == 35000
def test_confirm_does_not_promote_to_full_transfer(sqlite_engine):
"""确认段**不得**因可用量/阈值变化新触发强制全转(受理未标 → 确认也不标)。
受理时 `min_hold=0`(可用 50000、申请 48000 → 留 2000 不触发,remark 为空);
T+1 之前把阈值调大到 30000。若确认段重判最低持有,就会得出「留 2000 < 30000」
而擅自把客户指令扩大成全转 —— 该用例锁死「只继承、不重判」的语义边界。
"""
_seed(sqlite_engine, min_hold="0")
accepted = _accept(sqlite_engine, qty="48000")
gid = accepted["convert_group_id"]
assert accepted["forced_full_transfer"] is False
assert _req_row(sqlite_engine, gid)["remark"] is None
# T+1 之前产品参数变更(阈值调大)
_exec(
sqlite_engine,
"UPDATE core_product SET min_hold_qty = 30000 WHERE product_id = :p",
p=PROD_OUT,
)
res = _confirm(sqlite_engine, gid)
assert res["status"] == "confirmed"
assert res["forced_full_transfer"] is False # 未被"追认"
assert res["actual_qty"] == "48000.00"
assert _req_row(sqlite_engine, gid)["remark"] is None