【范围澄清】 引擎调用点在 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,均零失败。
823 lines
34 KiB
Python
823 lines
34 KiB
Python
"""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
|