Files
group_xinghuo_jinrong/tests/test_convert_confirm.py
T
GaoYiYuan_0626 b411d5a087 T-14/T-17 开口闭环:PRD §2.5.3 修订 + truncate 舍去法接入 + R-12 定稿(用户三项拍板 · 849 绿 / 真库 9/9)
① PRD §2.5.3 修订:删「易方达 ETF 场外取整数位」(无证据且有反证,真实整数位载体为场内),改记「2 位主流 + 舍去型真实存在」,版本块加注记
② rounding_mode=truncate 消费接入(用户拍板补做):
   - core_ro.get_share_rounding = 位数+模式回退链单点(规则行 → core_product → 默认 2/half_up,mode 白名单读取即校验,get_share_digits 转薄包装)
   - calc.product_round/in_qty 加 mode 参数(truncate → ROUND_DOWN)
   - 确认段 + 申购折算两处透传;载体 09-seed-org.sql ⑦ 段 PROD-005828(product 层回退,零转换场景引用)
   - 测试 +7(842→849):calc 4 + 确认段 2(truncate 34945.53 vs half_up .54 分化 + 回退链遮蔽语义)+ 申购段 1
   - 突变:两调用点同拆 mode 透传 → 2 红,还原绿;真库 9/9(seed 新增 ⑫ 载体在位断言)
③ R-12 口径裁定追认定稿:条目回写开发计划(NULL 不参与 A/C 判定 / 同类走一般转换规则)

审核教训留痕:PRD 独立 AI 三轮审查未抓出外部事实断言无出处 —— 联网核实铁律再添例证
文档:开发计划 T-14 开口闭环段 + 基线 849 · 日志新节 · TODO/MEMORY/AGENTS/交接文档开口清零
2026-09-12 14:31:35 +08:00

1027 lines
43 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
# ── 1.5 D27 产品级舍入(T-14):转入份额位数按 core_share_rule 回退链 ──
# 4 位期望锚点:67074.46 ÷ 1.9194 = 34945.53506… → 4 位 34945.5351(2 位 34945.54)
_IN_QTY_4 = Decimal("34945.5351")
def _in_trade_qty(engine, gid: str) -> Decimal:
return Decimal(str(
[t for t in _trades(engine, gid) if t["trade_type"] == "subscribe"][0]["qty"]
))
def test_confirm_in_qty_four_places_via_share_rule(sqlite_engine):
"""T-14 DoD1(非降级):转入产品 `convert` 规则行 4 位 → in_qty 量化到 4 位。
同产品插一条 `subscribe` 型 2 位干扰行 —— 确认段必须取 `convert` 型
(business_type 区分度;错取干扰行会得 34945.54 而红)。
⚠️ 4 位是 **D27 机制验证载体**(位数按产品合同配置,南方 2012 调整公告
为行业依据),非真实业务常态 —— 联网核实(2026-09-12):公募场外申购/
转换份额主流 2 位,产品间真实差异在舍入方式(rounding_mode)而非位数;
真库种子 11-seed 全 2 位(v1.0 的「指数基金 4 位」依据已证伪并纠偏)。
"""
_seed(sqlite_engine)
_exec(
sqlite_engine,
"INSERT INTO core_share_rule (product_id, business_type, share_digits, rounding_mode) "
"VALUES (:p, 'convert', 4, 'half_up')",
p=PROD_IN,
)
_exec(
sqlite_engine,
"INSERT INTO core_share_rule (product_id, business_type, share_digits, rounding_mode) "
"VALUES (:p, 'subscribe', 2, 'half_up')",
p=PROD_IN,
)
gid = _accept(sqlite_engine, cid="CC-REQ-D27A")["convert_group_id"]
res = _confirm(sqlite_engine, gid)
assert res["status"] == "confirmed"
assert _in_trade_qty(sqlite_engine, gid) == _IN_QTY_4
# 转入批次同值(库内 DECIMAL(18,4) 存 4 位量化结果)
assert _qty(sqlite_engine, f"LOT-{gid}-IN") == _IN_QTY_4
def test_confirm_in_qty_falls_back_to_product_share_digits(sqlite_engine):
"""T-14 回退链第二层:无规则行 → `core_product.share_digits`(显式置 4)。"""
_seed(sqlite_engine)
_exec(
sqlite_engine,
"UPDATE core_product SET share_digits = 4 WHERE product_id = :p",
p=PROD_IN,
)
gid = _accept(sqlite_engine, cid="CC-REQ-D27B")["convert_group_id"]
res = _confirm(sqlite_engine, gid)
assert res["status"] == "confirmed"
assert _in_trade_qty(sqlite_engine, gid) == _IN_QTY_4
def test_confirm_in_qty_missing_rule_falls_back_default_two_places(sqlite_engine):
"""T-14 DoD2:缺规则产品回退 2 位(显式降级用例,非唯一覆盖 ——
PRD §5.3.2 示例用例同样在无规则行下断言 2 位)。
连 `core_product.share_digits` 也置 NULL → 回退链第三层默认 `PLACES=2`。
"""
_seed(sqlite_engine)
_exec(
sqlite_engine,
"UPDATE core_product SET share_digits = NULL WHERE product_id = :p",
p=PROD_IN,
)
gid = _accept(sqlite_engine, cid="CC-REQ-D27C")["convert_group_id"]
res = _confirm(sqlite_engine, gid)
assert res["status"] == "confirmed"
# 与不接 D27 时代的 PRD §5.3.2 示例逐字节一致(DoD3 链路级锚点)
assert _in_trade_qty(sqlite_engine, gid) == Decimal("34945.54")
def test_confirm_in_qty_truncate_mode_from_product(sqlite_engine):
"""T-14 开口②闭环(2026-09-12 用户拍板补接):产品层 `rounding_mode='truncate'`
(无规则行)→ 确认段转入份额走**舍去法**。
基准值 34945.5351 天然分化:half_up = 34945.54(PRD §5.3.2 锚点),
truncate = 34945.53 —— 错取 half_up 即红。
舍去型真实出处:南方利众/宝元、邮储、华宝、申万菱信、东方基金等
(联网核实 2026-09-12,PRD §2.5.3);真库载体 = 09-seed-org.sql ⑦ 段
PROD-005828(规则行未覆盖 → 恰好走 core_product 层回退)。
"""
_seed(sqlite_engine)
_exec(
sqlite_engine,
"UPDATE core_product SET rounding_mode = 'truncate' WHERE product_id = :p",
p=PROD_IN,
)
gid = _accept(sqlite_engine, cid="CC-REQ-D27T")["convert_group_id"]
res = _confirm(sqlite_engine, gid)
assert res["status"] == "confirmed"
assert _in_trade_qty(sqlite_engine, gid) == Decimal("34945.53")
# 转入批次同值(库内 DECIMAL(18,4) 存量化结果)
assert _qty(sqlite_engine, f"LOT-{gid}-IN") == Decimal("34945.53")
def test_get_share_rounding_fallback_chain_digits_and_mode(sqlite_engine):
"""回退链单点扩展(开口②):`get_share_rounding` 位数与模式**逐层独立回退**。
① 全缺 → (2, 'half_up');② product 层配 mode(无规则行)→ (2, 'truncate');
③ 规则行存在 → digits+mode 同取规则行(规则行两列 NOT NULL,恒整体优先);
④ product 层 mode 非法 → 读取即 ValueError(错配不留到算份额)。
"""
_seed(sqlite_engine)
ro = CoreReadOnlyRepository(engine=sqlite_engine)
assert ro.get_share_rounding(PROD_IN, "convert") == (2, "half_up")
_exec(
sqlite_engine,
"UPDATE core_product SET rounding_mode = 'truncate' WHERE product_id = :p",
p=PROD_IN,
)
assert ro.get_share_rounding(PROD_IN, "convert") == (2, "truncate")
_exec(
sqlite_engine,
"INSERT INTO core_share_rule (product_id, business_type, share_digits, rounding_mode) "
"VALUES (:p, 'convert', 4, 'half_up')",
p=PROD_IN,
)
assert ro.get_share_rounding(PROD_IN, "convert") == (4, "half_up")
# ④ product 层 mode 非法 → 读取即 ValueError(先删规则行:规则行 mode
# 恒整体优先,其存在会遮蔽 product 层错配 —— 本断言同时钉住遮蔽语义)
_exec(
sqlite_engine,
"DELETE FROM core_share_rule WHERE product_id = :p AND business_type = 'convert'",
p=PROD_IN,
)
_exec(
sqlite_engine,
"UPDATE core_product SET rounding_mode = 'bogus' WHERE product_id = :p",
p=PROD_IN,
)
with pytest.raises(ValueError, match="舍入模式非法"):
ro.get_share_rounding(PROD_IN, "convert")
# ── 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)——但必须留痕 + 标记(T-12 三链路对齐)。
v1.0 阶段 1.5 与 trade_gateway 的引擎异常都落 `decision='engine_error'` 审计
且响应 `engine_error=True`;T+1 确认段(主链路)此前只 log 不留痕,导致补偿侧
`has_engine_error_audit` 的「人工核对」保护对主链路失效(T-12 真库脚本暴露)。
"""
_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" # 不阻断(D17)
assert len(_trades(sqlite_engine, gid)) == 2
assert res["engine_error"] is True # 异常必须标记(不再伪装成正常)
audit = _rows(
sqlite_engine,
"SELECT decision, input_summary FROM audit_log WHERE input_summary LIKE :p",
p=f"%{gid}%",
)
assert [a["decision"] for a in audit].count("engine_error") == 1, audit
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-16 / R-11 / D29:确认事务写 2 条 core_cash_flow)──
def test_confirm_writes_exactly_two_cash_flows(sqlite_engine):
"""确认事务写**恰 2 条** `core_cash_flow`(验收 2/24 资金流侧):
- 转出端 `out`/`redeem`,金额 = 费前转出金额(与 redeem 流水 `amount` 同源);
- 转入端 `in`/`subscribe`,金额 = 净转入金额(与 subscribe 流水同源);
- `remark` 带 `convert_group_id`(对账锚点);`occurred_at` = 确认时刻。
(T-7~T-15 期间确认段不写该表;T-16 起本方法 = 全仓唯一写入点,反转原
「表不存在」断言。)
"""
_seed(sqlite_engine)
gid = _accept(sqlite_engine)["convert_group_id"]
res = _confirm(sqlite_engine, gid)
assert res["status"] == "confirmed"
flows = _rows(
sqlite_engine,
"SELECT flow_type, flow_subtype, amount, remark, occurred_at FROM core_cash_flow",
)
assert len(flows) == 2, f"确认后应恰 2 条资金流,实际 {len(flows)}"
by_sub = {f["flow_subtype"]: f for f in flows}
assert set(by_sub) == {"redeem", "subscribe"}
assert by_sub["redeem"]["flow_type"] == "out"
assert Decimal(str(by_sub["redeem"]["amount"])) == Decimal("68020.00")
assert by_sub["subscribe"]["flow_type"] == "in"
assert Decimal(str(by_sub["subscribe"]["amount"])) == Decimal("67074.46")
assert by_sub["redeem"]["remark"] == f"convert:{gid}"
assert by_sub["subscribe"]["remark"] == f"convert:{gid}"
# 幂等:重复确认(skipped)不产生第 3 条
_confirm(sqlite_engine, gid)
assert len(_rows(sqlite_engine, "SELECT 1 FROM core_cash_flow")) == 2
# ── 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))
# ── T-15(D28 · 验收 30):撤单与确认竞态 ───────────────────────────
def test_confirm_after_cancel_conflicts(sqlite_engine):
"""撤单与确认竞态(确认侧后到):撤单置 `cancelled` 后 confirm_one
**幂等跳过**(`skipped` + `current_status='cancelled'`),零流水、
占用保持已释放态 —— 批处理单笔容错语义(确认段不因已撤单中断整批)。
时序口径(T-15 留痕):「撤单先落库、确认后到」= 状态闸门①的 skipped
分支;「确认读到 accepted 后、事务内置位前状态被并发改掉」才是
`ConfirmConflict` 回滚路径(`_confirm_request` 哨兵,见
`test_confirm_conflict_rolls_back_whole_transaction`)。反向
(确认后撤单)见 `test_cancel_confirmed_request_rejected`。
"""
_seed(sqlite_engine)
gid = _accept(sqlite_engine)["convert_group_id"]
cancel_convert(gid, now=datetime(2026, 9, 4, 14, 0), **_services(sqlite_engine))
res = _confirm(sqlite_engine, gid)
assert res["status"] == "skipped"
assert res["current_status"] == "cancelled"
# 单保持 cancelled(确认未夺走终态)+ 零流水 + 占用保持释放
assert _req_row(sqlite_engine, gid)["status"] == "cancelled"
assert _trades(sqlite_engine, gid) == []
assert CoreReadOnlyRepository(engine=sqlite_engine).sum_inflight_qty(
CUST, PROD_OUT
) == Decimal("0")
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