Files
group_xinghuo_jinrong/scripts/dev/verify_convert_confirm.py
T
GaoYiYuan_0626 cde0c226fe 基金转换 T+1 模型:T-15/T-16/T-17 收官(842 绿 / 真库 9/9 / 转换线第 5 步完成)
T-15 D28 部分成交专项:
- sqlite +1 终态单幂等 skipped 口径裁定(ConfirmConflict 仅限读后状态才变窗口)
- 真库 confirm +3 组 19 项(E2 占用释放双向 / G2 actual_qty=0 → rejected / H2 撤单后确认)→ 102/102
- CONVERT_STRESS Barrier(2) 窄窗争批:恰 1 confirmed + 1 rejected
- 突变实证防御纵深:④可用量复核 / ⑤plan_lots / 事务内哨兵三道防线拆任一道仍收敛 rejected

T-16 D29 资金流中转:
- _insert_cash_flows 全仓唯一写入点(out/redeem 费前 + in/subscribe 净转入,remark=convert:{gid} 同事务)
- test_confirm_writes_no_cash_flow 反转为恰 2 条 + 金额对账(T-7 登记闭环)
- 真库 apply A2 组 + B 组 0 残留 → 30/30;confirm 反转断言 → 103/103
- FK 坑(core_cash_flow.fk_cf_customer 挡客户删除):5 处清理段补删

T-17 D30 share_class + A/C 互转:
- _validate_products 纯新增拦截分支:两端 share_class 非空且不同才查 allow_ac_convert(任一端=1 两向放行)
- 口径裁定 2 条(R-12 字面偏差,待用户追认):NULL 不参与 A/C 判定 / 同类走一般转换规则
- 联网核实(用户铁律):A/C 互转需管理人开通(摩根 2025-11 公告)= 开关真实载体;持有期重新起算与本仓一致
- 种子 ⑥ 段(双开对/双关对照/同类对照);sqlite +4 → 842 passed / 8 skipped;真库 accept H 组 → 43/43
- 突变:分支失效 → closed 红,还原绿

文档:开发计划 T-15~T-17 DoD 全勾 + 执行记录 · 基线 842 · TODO/MEMORY/AGENTS/交接文档 v4.5 终态(第 5 步收官 → 下一步第 6 步集成测试)
2026-09-12 13:42:49 +08:00

744 lines
37 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 真 MySQL 验证脚本:`confirm_service` 确认事务 + T+1 批处理实跑 + DoD 断言。
**为什么 sqlite 单测全绿还不够(T-7 视角)**
确认段是 T+1 模型里唯一**跨 6 张表多写**的段落,而这些写入的真实约束只有 InnoDB
才成立 —— sqlite 上「代码分支走过」不等于「真库落得对」:
1. **确认事务原子性**:`core_convert_request.status` 的条件 UPDATE
(`WHERE status IN ('accepted','nav_pending')`,`rowcount != 1` → 整事务回滚)
依赖 InnoDB 的**行锁 + 影响行数语义**;sqlite 的 `rowcount` 是 matched rows,
MySQL 是 **changed rows** —— 这正是 `nav_pending` 重试会被误判成冲突的根源。
2. **`core_trade` 恰 2 行 / 同 `convert_group_id`**:`uk_idem` 与索引在真库生效
(验收 2 / 24)。
3. **`core_convert_lot_detail.nav_date` / `nav` / `fee_rate` 为 NOT NULL + DECIMAL**:
T 日净值逐批落库零漂移(PRD §5.3.2 示例数字即由此重放)。
4. **`core_trade.traded_at` / `confirmed_at` 是 DATETIME(3)**:确认日时刻零漂移。
5. **批处理锁 + 捞单窗口**:`idx_status_asof` 上 `status IN (accepted, nav_pending)`
的 ENUM 比较 + 按受理日区间捞单,只有真库才验证得到。
6. 确认写 `core_cash_flow` 恰 2 条且与流水金额对账(T-16/R-11,原 0 行断言已反转)。
**核心锚点**:本脚本用 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` 逐字节一致 ——
真库与文档不漂移的最终关卡。
用法:
python scripts/dev/verify_convert_confirm.py # 建隔离数据 → 跑断言 → 清理
约定(与 `verify_convert_accept.py` / `verify_convert_service.py` 一致):
- 隔离前缀 **CVF**(客户 / 产品 / group_id / client_request_id),跑完**两个库**全清;
- 建/清数据走 `role="admin"`(需 DELETE);业务链路走默认 ro/rw 账号
(确认段写 Core 由 `ConvertCoreRepository` 内部固定用 `xh_core_rw`,D20);
- 交易日历沿用真库 `core_trade_calendar`(T-1 已灌 503 交易日),基准日取**日历中位
开市日**,期望值动态推算 —— 日历内容变了脚本不该误报;
- 批次 `confirmed_at` 相对基准日 D 计算(D−100 / D−3),使持有天数档位确定;
- 退出码:0=全一致 / 1=有断言不一致 / 2=环境不可用(连不上库或日历缺数据)。
"""
from __future__ import annotations
import itertools
import sys
from datetime import date, datetime, time, timedelta
from decimal import ROUND_HALF_UP, Decimal
from pathlib import Path
from sqlalchemy import text
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from app.config.settings import settings # noqa: E402
from app.gateway.convert_core_repository import ConvertCoreRepository # noqa: E402
from app.repository.convert_repository import ConvertRepository # noqa: E402
from app.repository.convert_request_repository import ( # noqa: E402
ConvertRequestRepository,
)
from app.repository.core_ro import CoreReadOnlyRepository # noqa: E402
from app.repository.risk_repository import RiskRepository # noqa: E402
from app.service.convert.confirm_service import confirm_batch, confirm_one # noqa: E402
from app.service.convert.convert_service import accept_convert, cancel_convert # noqa: E402
from app.service.convert.errors import InsufficientShares # noqa: E402
from app.service.convert.trading_calendar import next_biz_day # noqa: E402
from app.utils.db import dispose_engines, get_engine # noqa: E402
CUSTOMER = "CUST-CVF" # C5 客户 → 任何产品放行
CUSTOMER_LOW = "CUST-CVFL" # 受理时 C5,确认前降级 C1 → 复核阻断
PROD_OUT = "PROD-CVFO"
PROD_IN = "PROD-CVFI"
COMPANY = "CVF模拟基金"
TA = "TA-CVF-001"
# PRD §5.3.2 示例输入(真实净值口径)
OUT_NAV = Decimal("1.3604")
IN_NAV = Decimal("1.9194")
OUT_RATE = Decimal("0.0030") # 转出端申购费率 → 价外法基准
IN_RATE = Decimal("0.0080") # 转入端申购费率(更高 → 收补差费)
QTY_LOT_1 = Decimal("30000")
QTY_LOT_2 = Decimal("20000")
QTY_TOTAL = QTY_LOT_1 + QTY_LOT_2
_passed = 0
_failed = 0
def check(name: str, actual, expected) -> None:
"""逐条断言并打印(与 `verify_convert_accept.py` 同款输出)。"""
global _passed, _failed
ok = actual == expected
if ok:
_passed += 1
else:
_failed += 1
flag = "✅" if ok else "❌"
print(f" {flag} {name}: 实际 {actual!r}" + ("" if ok else f" / 期望 {expected!r}"))
def dec(value, places: str = "0.01") -> Decimal:
"""真库读回是 `Decimal`(MySQL DECIMAL)→ 统一量化后比较,避开末位噪声。"""
return Decimal(str(value)).quantize(Decimal(places), rounding=ROUND_HALF_UP)
def q1(engine, sql: str, **params):
with engine.connect() as conn:
return conn.execute(text(sql), params).scalar()
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(engine, base_day: date) -> None:
"""灌入两个客户、两只产品、费率四档、两批份额、T 日净值。
批次 `confirmed_at` 相对 `base_day`(= 确认日 T+1 的前一交易日 T)计算:
`D−100` → 持有 100 天 → 档位 (30,180) = 0.50%;`D−3` → 持有 3 天 → (0,7) = 1.50%。
"""
with engine.begin() as conn:
for cid, name in ((CUSTOMER, "CVF真库验证"), (CUSTOMER_LOW, "CVF复核降级")):
conn.execute(
text(
"INSERT INTO core_customer (customer_id, display_name, open_date, age, is_active) "
"VALUES (:c, :n, :d, 40, 1)"
),
{"c": cid, "n": name, "d": base_day - timedelta(days=400)},
)
conn.execute(
text(
"INSERT INTO core_customer_risk "
"(customer_id, risk_code, evaluated_at, expires_at) "
"VALUES (:c, 'C5', :t, :exp)"
),
{
"c": cid,
"t": base_day - timedelta(days=30),
"exp": base_day + timedelta(days=300),
},
)
# 转出债基 R2(min_hold 0,不触发强制全转);转入股基 R4(C5 放行)
conn.execute(
text(
"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, min_hold_action, fund_company, ta_code) "
"VALUES (:p, 'CVF转出债基', 'R2', 'bond', 1, 1, :r, 0, 0, "
"'force_transfer', :co, :ta)"
),
{"p": PROD_OUT, "r": float(OUT_RATE), "co": COMPANY, "ta": TA},
)
conn.execute(
text(
"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, 'CVF转入股基', 'R4', 'stock', 1, 1, :r, :co, :ta)"
),
{"p": PROD_IN, "r": float(IN_RATE), "co": COMPANY, "ta": TA},
)
# 赎回费分档(22 号文 §10 下限口径)
for lo, hi, rate in [
(0, 7, "0.0150"), (7, 30, "0.0100"), (30, 180, "0.0050"),
(180, 365, "0.0025"), (365, None, "0.0000"),
]:
conn.execute(
text(
"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": lo, "b": hi, "r": rate},
)
# T 日净值(确认段 `get_nav_on` 精确匹配,缺一不可)
for pid, nav in ((PROD_OUT, OUT_NAV), (PROD_IN, IN_NAV)):
conn.execute(
text(
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) "
"VALUES (:p, :n, 0, :d)"
),
{"p": pid, "n": float(nav), "d": base_day},
)
# 两批份额(转出端),**两个客户各一份**(D 组用 CUSTOMER_LOW),合计 50000
for cid, tag in ((CUSTOMER, "C"), (CUSTOMER_LOW, "L")):
_insert_lots_by_conn(conn, cid, tag, base_day)
conn.execute(
text(
"INSERT INTO core_holding (customer_id, product_id, qty, cost_amount, "
"market_value, pnl_pct, as_of) VALUES (:c, :p, :q, :q, 51000, 0, :d)"
),
{"c": cid, "p": PROD_OUT, "q": float(QTY_TOTAL), "d": base_day},
)
def _insert_lots_by_conn(conn, customer: str, tag: str, base_day: date) -> None:
"""插两批份额:`D−100` → 持有 100 天 → 档位 (30,180) = 0.50%;`D−3` → 3 天 → 1.50%。"""
for idx, (qty, confirmed_delta) in enumerate(((QTY_LOT_1, 100), (QTY_LOT_2, 3))):
conn.execute(
text(
"INSERT INTO core_share_lot (lot_id, customer_id, product_id, qty, "
"remain_qty, nav, confirmed_at) "
"VALUES (:l, :c, :p, :q, :q, 1.0300, :cat)"
),
{
"l": f"LOT-CVF-{tag}{idx + 1}",
"c": customer,
"p": PROD_OUT,
"q": float(qty),
"cat": datetime.combine(base_day - timedelta(days=confirmed_delta), time(10, 0)),
},
)
def reset_shares(engine, customer: str, base_day: date) -> None:
"""恢复某客户的转出端批次与持仓 —— 多组用例复用同一客户时的**隔离手段**。
每组用例都会把 50000 份额用光;不恢复会让后一组受理时「可转份额不足 0」。
同时清掉转入端持仓与占用(终态单本身已不占额度,此处只清持仓余额)。
"""
tag = "C" if customer == CUSTOMER else "L"
with engine.begin() as conn:
conn.execute(
text("DELETE FROM core_share_lot WHERE customer_id = :c"), {"c": customer}
)
conn.execute(
text("DELETE FROM core_holding WHERE customer_id = :c AND product_id = :p"),
{"c": customer, "p": PROD_IN},
)
conn.execute(
text(
"UPDATE core_holding SET qty = :q, market_value = 51000 "
"WHERE customer_id = :c AND product_id = :p"
),
{"c": customer, "p": PROD_OUT, "q": float(QTY_TOTAL)},
)
_insert_lots_by_conn(conn, customer, tag, base_day)
def cleanup_core(engine) -> None:
with engine.begin() as conn:
for sql, params in [
("DELETE FROM core_convert_lot_detail WHERE convert_group_id LIKE 'CNV-CVF%'", {}),
("DELETE FROM core_convert_request WHERE customer_id IN (:c1, :c2)",
{"c1": CUSTOMER, "c2": CUSTOMER_LOW}),
("DELETE FROM core_trade WHERE customer_id IN (:c1, :c2)",
{"c1": CUSTOMER, "c2": CUSTOMER_LOW}),
("DELETE FROM core_share_lot WHERE customer_id IN (:c1, :c2)",
{"c1": CUSTOMER, "c2": CUSTOMER_LOW}),
("DELETE FROM core_holding WHERE customer_id IN (:c1, :c2)",
{"c1": CUSTOMER, "c2": CUSTOMER_LOW}),
("DELETE FROM core_cash_flow WHERE customer_id IN (:c1, :c2)",
{"c1": CUSTOMER, "c2": CUSTOMER_LOW}),
("DELETE FROM core_customer_risk WHERE customer_id IN (:c1, :c2)",
{"c1": CUSTOMER, "c2": CUSTOMER_LOW}),
("DELETE FROM core_fee_rule WHERE product_id LIKE 'PROD-CVF%'", {}),
("DELETE FROM core_product_nav WHERE product_id LIKE 'PROD-CVF%'", {}),
("DELETE FROM core_product WHERE product_id LIKE 'PROD-CVF%'", {}),
("DELETE FROM core_customer WHERE customer_id IN (:c1, :c2)",
{"c1": CUSTOMER, "c2": CUSTOMER_LOW}),
]:
conn.execute(text(sql), params)
def cleanup_agent(engine) -> None:
with engine.begin() as conn:
# 双保险:gid 走注入 id_factory(CNV-CVF-*);client_request_id 前缀兜底 ——
# `risk_convert_detail.uk_idem` 一旦残留会让下一轮全红。
conn.execute(
text("DELETE FROM risk_convert_detail WHERE convert_group_id LIKE 'CNV-CVF%'")
)
conn.execute(
text("DELETE FROM risk_convert_detail WHERE client_request_id LIKE 'CVF-REQ-%'")
)
conn.execute(
text("DELETE FROM risk_alert WHERE customer_id IN (:c1, :c2)"),
{"c1": CUSTOMER, "c2": CUSTOMER_LOW},
)
conn.execute(text("DELETE FROM audit_log WHERE input_summary LIKE '%CNV-CVF%'"))
# ── 链路辅助 ────────────────────────────────────────────────────────
def _accept_services() -> dict:
"""受理段四仓储走默认账号(core ro/rw + agent rw)。"""
return dict(
core_ro=CoreReadOnlyRepository(),
risk_repo=RiskRepository(),
convert_repo=ConvertRepository(),
request_repo=ConvertRequestRepository(),
)
def _confirm_services() -> dict:
"""确认段多一个 `core_writer`(内部固定用 `xh_core_rw`,D20 账号分离)。"""
return dict(**_accept_services(), core_writer=ConvertCoreRepository())
_SEQ = itertools.count(1)
def _id_factory(prefix: str, now: datetime) -> str:
"""固定前缀 `CNV-CVF-nnn`(生产 `_new_id` 带随机 hex,脚本无法按前缀清理)。"""
return f"CNV-CVF-{next(_SEQ):03d}"
def _accept(req: dict, now: datetime) -> dict:
return accept_convert(req, now=now, id_factory=_id_factory, **_accept_services())
def _confirm(gid: str, now: datetime, as_of: date, **kw) -> dict:
return confirm_one(gid, now=now, as_of=as_of, **_confirm_services(), **kw)
def _base_req(**over) -> dict:
req = {
"customer_id": CUSTOMER,
"from_product_id": PROD_OUT,
"to_product_id": PROD_IN,
"qty": QTY_TOTAL,
}
req.update(over)
return req
def _pick_days(core_admin, is_open) -> tuple[date, date]:
"""取日历**中位**开市日作 T 日,及其下一交易日(T+1 确认日)。"""
rows = _rows(
core_admin,
"SELECT cal_date FROM core_trade_calendar WHERE is_open = 1 ORDER BY cal_date ASC",
)
if len(rows) < 10:
raise RuntimeError("core_trade_calendar 开市日不足,请先跑 10-seed-trade-calendar.sql")
day = rows[len(rows) // 2]["cal_date"]
if not isinstance(day, date):
day = date.fromisoformat(str(day)[:10])
return day, next_biz_day(day, is_open)
def _req_row(core_admin, gid: str) -> dict:
return _rows(
core_admin,
"SELECT * FROM core_convert_request WHERE convert_group_id = :g",
g=gid,
)[0]
def _trade_rows(core_admin, gid: str) -> list[dict]:
return _rows(
core_admin,
"SELECT trade_id, trade_type, amount, qty, trade_status, traded_at "
"FROM core_trade WHERE convert_group_id = :g ORDER BY trade_type",
g=gid,
)
def _remain(core_admin, lot_id: str) -> Decimal:
return dec(
q1(core_admin, "SELECT remain_qty FROM core_share_lot WHERE lot_id = :l", l=lot_id),
"0.0001",
)
def _inflight(core_ro, customer: str = CUSTOMER) -> Decimal:
return dec(core_ro.sum_inflight_qty(customer, PROD_OUT))
# ── 主流程 ──────────────────────────────────────────────────────────
def main() -> int:
core_admin = get_engine(settings.mysql_core_database, "admin")
agent_admin = get_engine(settings.mysql_database, "admin")
core_ro = CoreReadOnlyRepository()
try:
day, next_day = _pick_days(core_admin, core_ro.is_open)
except Exception as exc: # noqa: BLE001
print(f"❌ 环境不可用:{exc}")
dispose_engines()
return 2
submit_at = datetime.combine(day, time(10, 0))
confirm_at = datetime.combine(next_day, time(9, 0))
print(f"T 日={day}(受理)→ T+1={next_day}(确认业务日)")
try:
cleanup_core(core_admin)
cleanup_agent(agent_admin)
seed(core_admin, day)
# ══ A. 确认全流程 + PRD §5.3.2 逐字节对齐(验收 2 / 10 / 24)══
print("\n【A】确认全流程:扣批次 / 2 行流水 / 两端持仓 / 状态 confirmed")
gid = _accept(_base_req(client_request_id="CVF-REQ-A"), submit_at)["convert_group_id"]
check("受理后占用 = 50000", _inflight(core_ro), Decimal("50000.00"))
check("受理后批次未动(T 日不扣份额)", _remain(core_admin, "LOT-CVF-C1"), Decimal("30000.0000"))
res = _confirm(gid, confirm_at, next_day)
check("确认状态 confirmed", res["status"], "confirmed")
check("确认非部分成交", res["partial"], False)
check("无强制全转", res["forced_full_transfer"], False)
# ── PRD §5.3.2 示例数字(真库重放,逐字节)──
check("转出净值 = T 日净值 1.3604", res["out_nav"], "1.3604")
check("净值日期 = T 日", res["nav_date"], str(day))
check("转出金额 68020.00", res["out_amount"], "68020.00")
check("赎回费 612.18", res["redeem_fee"], "612.18")
check("净转出金额 67407.82", res["convert_amount"], "67407.82")
check("补差费 333.36", res["diff_fee"], "333.36")
check("转入金额 67074.46", res["in_amount"], "67074.46")
check("转入净值 1.9194", res["in_nav"], "1.9194")
check("转入份额 34945.54", res["in_qty"], "34945.54")
check("尾差 -0.0049", res["rounding_diff"], "-0.0049")
check("批次明细 2 条", res["lot_count"], 2)
# ── 批次扣减(FIFO)──
check("批次 1 remain 归零", _remain(core_admin, "LOT-CVF-C1"), Decimal("0.0000"))
check("批次 2 remain 归零", _remain(core_admin, "LOT-CVF-C2"), Decimal("0.0000"))
# ── 流水恰 2 行、同 gid、一赎一申 ──
trades = _trade_rows(core_admin, gid)
check("core_trade 恰 2 行", len(trades), 2)
check("两行类型", sorted(t["trade_type"] for t in trades), ["redeem", "subscribe"])
out_tr = next(t for t in trades if t["trade_type"] == "redeem")
in_tr = next(t for t in trades if t["trade_type"] == "subscribe")
check("转出流水金额 68020.00", dec(out_tr["amount"]), Decimal("68020.00"))
check("转出流水份额 50000.00", dec(out_tr["qty"]), Decimal("50000.00"))
check("转入流水金额 67074.46", dec(in_tr["amount"]), Decimal("67074.46"))
check("转入流水份额 34945.54", dec(in_tr["qty"]), Decimal("34945.54"))
check("流水状态 confirmed", {t["trade_status"] for t in trades}, {"confirmed"})
check("流水交易日 = 确认业务日", {t["traded_at"].date() for t in trades}, {next_day})
# ── 明细表(Core 侧自包含数据源)──
details = _rows(
core_admin,
"SELECT lot_id, qty, hold_days, fee_rate, fee_amount, nav, nav_date "
"FROM core_convert_lot_detail WHERE convert_group_id = :g ORDER BY lot_id",
g=gid,
)
check("明细 2 行", len(details), 2)
d1, d2 = details[0], details[1]
check("明细批 1 持有天数 100", d1["hold_days"], 100)
check("明细批 1 费率 0.0050", dec(d1["fee_rate"], "0.0001"), Decimal("0.0050"))
check("明细批 1 费额 204.06", dec(d1["fee_amount"]), Decimal("204.06"))
check("明细批 1 净值 1.3604", dec(d1["nav"], "0.0001"), Decimal("1.3604"))
check("明细批 2 持有天数 3", d2["hold_days"], 3)
check("明细批 2 费率 0.0150", dec(d2["fee_rate"], "0.0001"), Decimal("0.0150"))
check("明细批 2 费额 408.12", dec(d2["fee_amount"]), Decimal("408.12"))
check("明细净值日期 = T 日", d1["nav_date"], day)
# ── 两端持仓 ──
hold_out = dec(
q1(core_admin, "SELECT qty FROM core_holding WHERE customer_id = :c "
"AND product_id = :p", c=CUSTOMER, p=PROD_OUT)
)
hold_in_row = _rows(
core_admin,
"SELECT qty FROM core_holding WHERE customer_id = :c AND product_id = :p",
c=CUSTOMER, p=PROD_IN,
)
check("转出端持仓归零", hold_out, Decimal("0.00"))
check("转入端持仓 = 34945.54",
dec(hold_in_row[0]["qty"]) if hold_in_row else None, Decimal("34945.54"))
# ── 受理单回填 ──
row = _req_row(core_admin, gid)
check("受理单状态 confirmed", row["status"], "confirmed")
check("受理单 actual_qty 回填 50000", dec(row["actual_qty"]), Decimal("50000.00"))
check("受理单 coupon 回填 333.36", dec(row["coupon"]), Decimal("333.36"))
check("受理单 confirmed_at 非空", row["confirmed_at"] is not None, True)
check("确认后占用释放(终态不在途)", _inflight(core_ro), Decimal("0.00"))
# ── 确认写资金流恰 2 条(T-16 / R-11 反转:原「必须 0 行」已过时)──
check("core_cash_flow 恰 2 条(out/redeem + in/subscribe)",
q1(core_admin, "SELECT COUNT(*) FROM core_cash_flow WHERE customer_id = :c",
c=CUSTOMER), 2)
check("资金流与流水金额对账一致",
sorted(dec(r["amount"]) for r in _rows(
core_admin, "SELECT amount FROM core_cash_flow WHERE customer_id = :c",
c=CUSTOMER)),
sorted(dec(r["amount"]) for r in _rows(
core_admin, "SELECT amount FROM core_trade WHERE convert_group_id = :g",
g=gid)))
# ── agent 镜像 ──
mirror = _rows(
agent_admin,
"SELECT status, fee_amount FROM risk_convert_detail WHERE convert_group_id = :g",
g=gid,
)
check("镜像 completed", mirror[0]["status"] if mirror else None, "completed")
check("审计 confirmed 1 条",
q1(agent_admin,
"SELECT COUNT(*) FROM audit_log WHERE event_type = 'convert_request' "
"AND decision = 'confirmed' AND input_summary LIKE :p",
p=f"%{gid}%"), 1)
# ══ B. 幂等:同 as_of 重复批处理不重复确认(验收 24 后半 / 15)══
print("\n【B】幂等:重复 confirm_batch 同 as_of → 第二次 0 处理、流水不重复")
again = _confirm(gid, confirm_at, next_day)
check("单笔重放返回 skipped", again["status"], "skipped")
batch = confirm_batch(next_day, now=confirm_at, **_confirm_services())
check("批处理锁定成功", batch["locked"], True)
check("第二轮 scanned = 0(终态不再入窗)", batch["scanned"], 0)
check("第二条流水未产生",
q1(core_admin, "SELECT COUNT(*) FROM core_trade WHERE convert_group_id = :g", g=gid), 2)
# ══ C. 缺 T 日净值 → nav_pending → 补净值 → confirmed(验收 25)══
print("\n【C】缺 T 日净值:nav_pending → 补 nav → 重跑 confirmed")
reset_shares(core_admin, CUSTOMER, day) # 恢复份额(A 组已用光)
gid_c = _accept(_base_req(client_request_id="CVF-REQ-C"), submit_at)["convert_group_id"]
with core_admin.begin() as conn:
conn.execute(
text("DELETE FROM core_product_nav WHERE product_id = :p AND nav_date = :d"),
{"p": PROD_IN, "d": day},
)
res_c = _confirm(gid_c, confirm_at, next_day)
check("缺净值 → nav_pending", res_c["status"], "nav_pending")
check("nav_pending 标记 transitioned", res_c["transitioned"], True)
check("缺净值单状态落库 nav_pending", _req_row(core_admin, gid_c)["status"], "nav_pending")
check("缺净值不写流水",
q1(core_admin, "SELECT COUNT(*) FROM core_trade WHERE convert_group_id = :g",
g=gid_c), 0)
check("缺净值批次不动", _remain(core_admin, "LOT-CVF-C1"), Decimal("30000.0000"))
# 重试(仍缺净值)→ 不应被误判成并发冲突
res_c2 = _confirm(gid_c, confirm_at, next_day)
check("缺净值重试仍 nav_pending", res_c2["status"], "nav_pending")
check("重试 transitioned=False(未误判冲突)", res_c2["transitioned"], False)
with core_admin.begin() as conn:
conn.execute(
text(
"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": day},
)
res_c3 = _confirm(gid_c, confirm_at, next_day)
check("补净值后 confirmed", res_c3["status"], "confirmed")
check("补净值后流水 2 行",
q1(core_admin, "SELECT COUNT(*) FROM core_trade WHERE convert_group_id = :g",
g=gid_c), 2)
# ══ D. T+1 复核不通过 → rejected + 占用释放(验收 26)══
print("\n【D】T+1 复核不通过:rejected + 占用释放 + 份额不变 + 无流水")
gid_d = _accept(
_base_req(customer_id=CUSTOMER_LOW, client_request_id="CVF-REQ-D"), submit_at
)["convert_group_id"]
check("受理时占用 = 50000", _inflight(core_ro, CUSTOMER_LOW), Decimal("50000.00"))
# 风评到期 + 降级(C1 不匹配 R4)
with core_admin.begin() as conn:
conn.execute(
text("UPDATE core_customer_risk SET risk_code = 'C1', expires_at = :e "
"WHERE customer_id = :c"),
{"c": CUSTOMER_LOW, "e": submit_at - timedelta(days=1)},
)
res_d = _confirm(gid_d, confirm_at, next_day)
check("复核不通过 → rejected", res_d["status"], "rejected")
check("拒绝原因 = 适当性", res_d["reject_reason"] not in (None, ""), True)
check("单状态落库 rejected", _req_row(core_admin, gid_d)["status"], "rejected")
check("不写流水",
q1(core_admin, "SELECT COUNT(*) FROM core_trade WHERE convert_group_id = :g",
g=gid_d), 0)
check("份额不变", _remain(core_admin, "LOT-CVF-L1"), Decimal("30000.0000"))
check("占用释放", _inflight(core_ro, CUSTOMER_LOW), Decimal("0.00"))
# ══ E. 部分成交(占用被抢)→ actual_qty < qty + remark(验收 30)══
print("\n【E】部分成交:占用被抢 → actual_qty < qty、remark=partial")
reset_shares(core_admin, CUSTOMER, day)
gid_e = _accept(_base_req(client_request_id="CVF-REQ-E"), submit_at)["convert_group_id"]
# 模拟 T→T+1 之间份额被别处用掉:批次 1 只剩 15000(可用 15000+20000=35000)
with core_admin.begin() as conn:
conn.execute(
text("UPDATE core_share_lot SET remain_qty = 15000 WHERE lot_id = 'LOT-CVF-C1'")
)
res_e = _confirm(gid_e, confirm_at, next_day)
check("部分成交标记", res_e["partial"], True)
check("实际成交 = 35000(申请 50000)", res_e["actual_qty"], "35000.00")
check("申请量回显 50000", res_e["requested_qty"], "50000.00")
row_e = _req_row(core_admin, gid_e)
check("受理单 remark = partial", row_e["remark"], "partial")
check("受理单 actual_qty 记 35000", dec(row_e["actual_qty"]), Decimal("35000.00"))
check("被抢走后批次 1 归零", _remain(core_admin, "LOT-CVF-C1"), Decimal("0.0000"))
check("批次 2 归零", _remain(core_admin, "LOT-CVF-C2"), Decimal("0.0000"))
check("未确认部分占用释放", _inflight(core_ro), Decimal("0.00"))
# ══ E2. 部分确认的占用释放(T-15 · 验收 30 · 双向证明)══
# R-10 语义:actual = min(申请, 可用) → 确认把可用全部吃掉,物理余量归零;
# 「释放」的是**受理占用**(未确认部分从不在物理上存在)。故双向证明:
# 确认前:占用未释放 → 新受理被在途占用拦截;
# 确认后:终态释放 → 同样份额规模按物理余量正常受理。
print("\n【E2】部分成交占用释放:确认前拦截 / 确认后放行")
reset_shares(core_admin, CUSTOMER, day)
gid_e2 = _accept(_base_req(client_request_id="CVF-REQ-E2"), submit_at)[
"convert_group_id"
]
with core_admin.begin() as conn:
conn.execute(
text("UPDATE core_share_lot SET remain_qty = 15000 WHERE lot_id = 'LOT-CVF-C1'")
)
# 确认前:物理 35000 − 在途 50000 < 0 → 新受理必须被拦截(占用不提前释放)
blocked = None
try:
_accept(_base_req(qty=Decimal("15000"), client_request_id="CVF-REQ-E2B"),
submit_at)
except InsufficientShares:
blocked = "InsufficientShares"
check("E2 确认前新受理被在途占用拦截", blocked, "InsufficientShares")
res_e2 = _confirm(gid_e2, confirm_at, next_day)
check("E2 部分成交 confirmed", res_e2["status"], "confirmed")
check("E2 实际成交 35000(申请 50000)", res_e2["actual_qty"], "35000.00")
check("E2 确认后占用归零(终态释放,无幽灵滞留)",
_inflight(core_ro), Decimal("0.00"))
# 确认后:灌回 15000(模拟 T+1 申购到账)→ 受理 15000 放行
# (若旧申请 50000 仍幽灵占用,available = 15000 − 50000 < 0 会再被拦)
with core_admin.begin() as conn:
conn.execute(
text("INSERT INTO core_share_lot (lot_id, customer_id, product_id, qty, "
"remain_qty, nav, confirmed_at) VALUES ('LOT-CVF-E2IN', :c, :p, "
"15000, 15000, 1.0000, :t)"),
{"c": CUSTOMER, "p": PROD_OUT, "t": confirm_at},
)
gid_e2b = _accept(
_base_req(qty=Decimal("15000"), client_request_id="CVF-REQ-E2B"), submit_at
)["convert_group_id"]
check("E2b 释放后同规模受理 accepted",
_req_row(core_admin, gid_e2b)["status"], "accepted")
check("E2b 后占用 = 15000(新单占用)", _inflight(core_ro), Decimal("15000.00"))
# E2b 置 expired(S2 运维兜底)释放占用,还原干净状态给后续组
with core_admin.begin() as conn:
conn.execute(
text("UPDATE core_convert_request SET status = 'expired' "
"WHERE convert_group_id = :g"),
{"g": gid_e2b},
)
check("E2b expired 释放占用(S2 兜底语义)", _inflight(core_ro), Decimal("0.00"))
# ══ G2. actual_qty=0 → rejected(T-15 · R-10 补强 · 验收 30 边界)══
print("\n【G2】占用全被抢:actual_qty=0 → rejected,零流水零扣减")
reset_shares(core_admin, CUSTOMER, day)
gid_g2 = _accept(_base_req(client_request_id="CVF-REQ-G2"), submit_at)[
"convert_group_id"
]
with core_admin.begin() as conn:
conn.execute(
text("UPDATE core_share_lot SET remain_qty = 0 WHERE customer_id = :c"),
{"c": CUSTOMER},
)
res_g2 = _confirm(gid_g2, confirm_at, next_day)
check("G2 全被抢 → rejected", res_g2["status"], "rejected")
check("G2 拒绝原因 = NO_AVAILABLE_QTY", res_g2["reject_reason"], "NO_AVAILABLE_QTY")
check("G2 单状态落库 rejected", _req_row(core_admin, gid_g2)["status"], "rejected")
check("G2 零流水(不产生零额确认)",
q1(core_admin, "SELECT COUNT(*) FROM core_trade WHERE convert_group_id = :g",
g=gid_g2), 0)
check("G2 占用释放", _inflight(core_ro), Decimal("0.00"))
check("G2 明细零写入",
q1(core_admin, "SELECT COUNT(*) FROM core_convert_lot_detail "
"WHERE convert_group_id = :g", g=gid_g2), 0)
# ══ H2. 撤单与确认竞态(T-15 · 确认侧后到 = 幂等 skipped)══
print("\n【H2】撤单先落库、确认后到 → skipped,零流水,占用保持释放")
reset_shares(core_admin, CUSTOMER, day)
gid_h2 = _accept(_base_req(client_request_id="CVF-REQ-H2"), submit_at)[
"convert_group_id"
]
cancel_res = cancel_convert(gid_h2, now=submit_at, **_accept_services())
check("H2 撤单成功(T 日窗口内)", cancel_res["status"], "cancelled")
res_h2 = _confirm(gid_h2, confirm_at, next_day)
check("H2 确认幂等跳过", res_h2["status"], "skipped")
check("H2 current_status = cancelled", res_h2["current_status"], "cancelled")
check("H2 单保持 cancelled(确认未夺走终态)",
_req_row(core_admin, gid_h2)["status"], "cancelled")
check("H2 零流水",
q1(core_admin, "SELECT COUNT(*) FROM core_trade WHERE convert_group_id = :g",
g=gid_h2), 0)
check("H2 占用保持释放", _inflight(core_ro), Decimal("0.00"))
# ══ F. 引擎恰好一次(验收 5 / 7 / 29)══
print("\n【F】规则引擎:恰好跑一次(重复确认不再投递)")
reset_shares(core_admin, CUSTOMER, day)
gid_f = _accept(_base_req(client_request_id="CVF-REQ-F"), submit_at)["convert_group_id"]
calls: list[str] = []
def _hook(out_trade, in_trade):
calls.append(str(out_trade.get("convert_group_id")))
return {"triggered_rules": [], "alert_ids": [], "engine_error": False}
res_f = _confirm(gid_f, confirm_at, next_day, engine_hook=_hook)
check("引擎被调用 1 次", len(calls), 1)
check("引擎入参为同组 gid", calls[0], gid_f)
check("确认成功", res_f["status"], "confirmed")
_confirm(gid_f, confirm_at, next_day, engine_hook=_hook)
check("重放不再投递引擎(仍 1 次)", len(calls), 1)
# F2:真实引擎(不注入 hook)+ 重放不翻倍 —— 验收 5/7/29 的真库侧
reset_shares(core_admin, CUSTOMER, day)
gid_f2 = _accept(_base_req(client_request_id="CVF-REQ-F2"), submit_at)["convert_group_id"]
res_f2 = _confirm(gid_f2, confirm_at, next_day)
check("真实引擎路径确认成功", res_f2["status"], "confirmed")
alerts_before = q1(
agent_admin, "SELECT COUNT(*) FROM risk_alert WHERE customer_id = :c", c=CUSTOMER
)
_confirm(gid_f2, confirm_at, next_day)
alerts_after = q1(
agent_admin, "SELECT COUNT(*) FROM risk_alert WHERE customer_id = :c", c=CUSTOMER
)
check("重放不新增 risk_alert(RISK-002 不翻倍)", alerts_after, alerts_before)
# ══ G. 真库专属约束 ══
print("\n【G】真库专属:ENUM 严校验 / 精度 / 索引")
enum_err = None
try:
with core_admin.begin() as conn:
conn.execute(
text("UPDATE core_convert_request SET status = 'bogus' "
"WHERE convert_group_id = :g"),
{"g": gid},
)
except Exception as exc: # noqa: BLE001
enum_err = type(exc).__name__
check("非法 status 被 ENUM 拒绝", bool(enum_err), True)
check("拒绝后状态未被污染", _req_row(core_admin, gid)["status"], "confirmed")
check("confirmed_at 列精度为 DATETIME(3)",
q1(core_admin,
"SELECT datetime_precision FROM information_schema.columns "
"WHERE table_schema = :db AND table_name = 'core_convert_request' "
"AND column_name = 'confirmed_at'",
db=settings.mysql_core_database), 3)
check("idx_status_asof 索引存在",
q1(core_admin,
"SELECT COUNT(*) FROM information_schema.statistics "
"WHERE table_schema = :db AND table_name = 'core_convert_request' "
"AND index_name = 'idx_status_asof'",
db=settings.mysql_core_database), 2)
finally:
cleanup_core(core_admin)
cleanup_agent(agent_admin)
dispose_engines()
print(f"\n{'=' * 60}")
print(f"真库验证(T-7 confirm_service):{_passed} 项一致 / {_failed} 项不一致")
return 1 if _failed else 0
if __name__ == "__main__":
sys.exit(main())