Files
group_xinghuo_jinrong/scripts/dev/verify_convert_api.py
T
GaoYiYuan_0626 e04b44c44a 基金转换 T+1 模型:T-9 api 层(受理 202 + 撤单/确认/查询接口 + 网关分派 + redeem 份额申报)
三接口路径以 PRD 为准(裁定 11/12):
- app/api/convert_admin.py 新增 POST /api/simulate/trade/convert/{gid}/cancel(交易 owner 闸门)
  + POST /api/admin/convert/confirm?accept_date=(risk_officer)+ GET /api/simulate/trade/convert/{gid}(查询 scope 闸门)
- simulate.py convert 分支受理 202 + 受理回执(两类 202 分辨 accepted/processing)
- trade_gateway 分派改调 accept_convert(SUPPORTED_TRADE_TYPES 维持 2 类型)
- redeem 份额申报(D26/R-6:qty 入参,_redeem_quote = qty * T 净值 - 赎回费)
- errors.py 新增 CANCEL_NOT_ALLOWED / CONCURRENT_CONFLICT(均 409)

修正 2 处口径缺陷(测试打穿揭示,非只改测试):
- _rebuild_quote 的 out_nav 改直读 core_convert_lot_detail.nav(反推致 1.3604 -> 1.3600 漂移)
- _accept_idempotent 字段面扩展与受理回执对齐(幂等重试客户端可取到预计确认日)

验证:端到端 13 passed(T+1 两段链路重写);全量 834 passed / 10 skipped;
真库 verify_convert_api.py 88/88,存量 4 脚本零回归;3 组突变验证命中后还原
2026-09-11 20:37:45 +08:00

521 lines
25 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-9 真 MySQL 验证脚本:**HTTP 全链路**(受理 202 / 撤单 / 确认批处理 / 查询 / 鉴权)+ 真库断言。
**为什么端到端 pytest 绿了还要这个脚本**(T-6/T-7 同款纪律):
`test_convert_integration.py` 已走 `TestClient(main app)` + 真库,但那是 pytest ——
每次全量回归都会跑;而这个脚本是 **DoD 门禁**(开发计划 T-9 验收),须能**独立单跑**、
退出码判成败(0=全一致 / 1=有断言不一致 / 2=环境不可用),并且聚焦 **HTTP 契约**
与 **sqlite 永远看不见的真库语义**:
1. **202 映射**:受理必须 `202 + accepted + 受理回执`(不是 200)—— 状态码是 HTTP 契约,
只有走 HTTP 才验得到;
2. **ENUM 6 态**(`core_convert_request.status`)与 `uk_idem` 唯一索引在真库生效:
非法状态写不进、同键同单由库兜底;
3. **撤单窗口由 `cancel_before`(受理日截点 15:00)驱动**,真库 `DATETIME(3)` 零漂移;
4. **确认 `nav_date` 精确匹配**(`get_nav_on(accept_date)`)—— DATE 列比较在真库零漂移;
5. **两道鉴权闸门**(撤单=交易 owner / 查询=查询 scope)在 dev debug 头通道下走完整
`get_auth_context` → 审计留痕落真库 `audit_log`/`input_guard_log`;
6. **redeem 份额申报**(D26/R-6):`_redeem_quote = qty × D 净值 − 赎回费` 的金额口径
在真库净值/费率数据上重放。
**使用**:
python scripts/dev/verify_convert_api.py
**约定**(同 `verify_convert_accept.py` / `verify_convert_confirm.py`):
- 隔离前缀 **ACP**(客户 / 产品 / advisor 归属 / client_request_id)、`CNV-ACP-*` group_id、
`TRD-ACP-*` 流水;跑完**两个库全清**(幂等,异常退出也能自愈);
- 建/清数据走 `role="admin"`(需 DELETE);业务链路走默认账号(D20 账号分离在链路上生效);
- 交易日历沿用真库 `core_trade_calendar`,**受理日动态推算**(同端到端测试:此刻提交会落在
哪个受理日由生产 `resolve_accept_date` 决定,种子锚定该日 —— 脚本不手算日期);
- 期望折算数字**调生产纯函数**得出(禁手算 · 自检第 13 问)。
"""
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 fastapi.testclient import TestClient
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 import trade_gateway # noqa: E402
from app.main import app # noqa: E402
from app.repository.core_ro import CoreReadOnlyRepository # noqa: E402
from app.service.convert import confirm_service # noqa: E402
from app.service.convert import convert_service as cs # noqa: E402
from app.service.convert.calc import ( # noqa: E402
convert_amount,
diff_fee,
in_qty,
lot_amount,
lot_fee,
)
from app.service.convert.trading_calendar import ( # noqa: E402
parse_cutoff,
resolve_accept_date,
)
from app.utils.db import dispose_engines, get_engine # noqa: E402
# ── 隔离常量(全部 ACP 前缀)────────────────────────────────────────
CUSTOMER = "CUST-ACP"
CUSTOMER_OTHER = "CUST-ACP-O" # 「他人」鉴权主体(**不建行**——customer 分支只比 id 不查库)
PROD_OUT = "PROD-ACPO"
PROD_IN = "PROD-ACPI"
COMPANY = "ACP模拟基金"
TA = "TA-ACP-001"
OUT_RATE = Decimal("0.0030")
IN_RATE = Decimal("0.0080")
OUT_NAV = Decimal("1.3604") # T-2b:真实净值(06-seed-nav.sql)
IN_NAV = Decimal("1.9194")
FEE_TIERS = [
(0, 7, "0.0150"),
(7, 30, "0.0100"),
(30, 180, "0.0050"),
(180, 365, "0.0025"),
(365, None, "0.0000"),
]
#: PRD §5.3 场景:两批次 30000 持 100 天 + 20000 持 3 天 → 费率档 0.0050 / 0.0150
LOT_SPEC = [("LOT-ACP-A1", "30000", 100), ("LOT-ACP-A2", "20000", 3)]
TOTAL_QTY = sum(Decimal(q) for _, q, _ in LOT_SPEC)
#: 受理回执与未确认查询中**不得出现**的折算字段(Q2,与 convert_admin 白名单同源)
CONFIRMED_ONLY = (
"out_trade_id", "out_nav", "out_amount", "lot_count", "lot_breakdown",
"redeem_fee", "in_trade_id", "in_nav", "convert_amount", "diff_fee",
"in_amount", "in_qty", "rounding_diff", "nav_date", "nav_stale",
)
#: demo 头(交易动作 / 放行审计)/ ops 头(admin 接口)/ 本人 / 顾问 / 他人
DEMO = {"X-Debug-Role": "risk_demo", "X-Debug-Actor": "STAFF-ACP-DEMO"}
OPS = {"X-Debug-Role": "risk_officer", "X-Debug-Actor": "STAFF-ACP-OPS"}
SELF = {"X-Debug-Role": "customer", "X-Debug-Actor": CUSTOMER}
ADVISOR = {"X-Debug-Role": "advisor", "X-Debug-Actor": "STAFF-10086"} # 种子 advisor(02-seed-base.sql)
STRANGER = {"X-Debug-Role": "customer", "X-Debug-Actor": CUSTOMER_OTHER}
_passed = 0
_failed = 0
def check(name: str, actual, expected) -> None:
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:
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()]
# ── 受理日(与端到端测试同源:生产 `resolve_accept_date` 实时判定)──────
def _accept_date_now() -> date:
return resolve_accept_date(
datetime.now(),
CoreReadOnlyRepository().is_open,
parse_cutoff(settings.convert_cutoff_time),
)
# ── 数据准备 / 清理 ─────────────────────────────────────────────────
def seed(core_admin, agent_admin, accept_date: date) -> None:
base = datetime.combine(accept_date, time(10, 0))
with core_admin.begin() as conn:
conn.execute(
text(
"INSERT INTO core_customer (customer_id, display_name, open_date, age, is_active) "
"VALUES (:c, 'ACP真库HTTP验证', :d, 40, 1)"
),
{"c": CUSTOMER, "d": accept_date - 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": CUSTOMER, "t": base - timedelta(days=30), "exp": base + timedelta(days=300)},
)
# 顾问归属(查询 scope 闸门的 advisor 分支备料;STAFF-10086 存在于 core_staff)
conn.execute(
text(
"INSERT INTO core_customer_advisor (customer_id, advisor_id, rel_status,"
" effective_from) VALUES (:c, 'STAFF-10086', 'active', :d)"
),
{"c": CUSTOMER, "d": accept_date},
)
for pid, name, ptype, rate in [
(PROD_OUT, "ACP转出债基", "bond", OUT_RATE),
(PROD_IN, "ACP转入股基", "stock", IN_RATE),
]:
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, :n, 'R2', :t, 1, 1, :r, 0, 0, 'force_transfer', :co, :ta)"
),
{"p": pid, "n": name, "t": ptype, "r": str(rate), "co": COMPANY, "ta": TA},
)
for mh, mh_max, rate in FEE_TIERS:
conn.execute(
text(
"INSERT INTO core_fee_rule (product_id, fee_type, min_hold_days,"
" max_hold_days, rate) VALUES (:p, 'redeem', :mh, :mm, :r)"
),
{"p": PROD_OUT, "mh": mh, "mm": mh_max, "r": rate},
)
# 两端净值都落在**受理日**(确认段 `get_nav_on(pid, accept_date)` 精确匹配)
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": str(nav), "d": accept_date},
)
for lot_id, qty, days in LOT_SPEC:
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, :n, :cat)"
),
{
"l": lot_id,
"c": CUSTOMER,
"p": PROD_OUT,
"q": qty,
"n": str(OUT_NAV),
"cat": base - timedelta(days=days),
},
)
conn.execute(
text(
"INSERT INTO core_holding (customer_id, product_id, qty, cost_amount, market_value,"
" pnl_pct, as_of) VALUES (:c, :p, :q, :cost, :mv, 0, :d)"
),
{
"c": CUSTOMER,
"p": PROD_OUT,
"q": str(TOTAL_QTY),
"cost": str(TOTAL_QTY * OUT_NAV),
"mv": str(TOTAL_QTY * OUT_NAV),
"d": accept_date,
},
)
with agent_admin.begin() as conn:
# agent 库无自建行(镜像/审计由链路产生,清理见下);空事务占位避免误用
pass
def cleanup_core(engine) -> None:
with engine.begin() as conn:
for sql in [
"DELETE FROM core_convert_request WHERE customer_id = :c",
# 明细表**无 FK**(依赖 app 层清理约定)—— 脚本自建明细也必须自清,
# 否则残留行会让下一轮的 `rebuild_convert_response` 明细翻倍
# (`sum(fee_amount)` 把两轮明细全求和 → 赎回费 612.18 变 1224.36,
# T-9 首发实跑即中招;同 verify_convert_confirm.py:241 约定)。
"DELETE FROM core_convert_lot_detail WHERE convert_group_id LIKE 'CNV-ACP%'",
"DELETE FROM core_trade WHERE customer_id = :c",
"DELETE FROM core_share_lot WHERE customer_id = :c",
"DELETE FROM core_holding WHERE customer_id = :c",
"DELETE FROM core_customer_advisor WHERE customer_id = :c",
"DELETE FROM core_customer_risk WHERE customer_id = :c",
"DELETE FROM core_fee_rule WHERE product_id LIKE 'PROD-ACP%'",
"DELETE FROM core_product_nav WHERE product_id LIKE 'PROD-ACP%'",
"DELETE FROM core_product WHERE product_id LIKE 'PROD-ACP%'",
"DELETE FROM core_customer WHERE customer_id = :c",
]:
conn.execute(text(sql), {"c": CUSTOMER})
def cleanup_agent(engine) -> None:
with engine.begin() as conn:
conn.execute(text("DELETE FROM risk_convert_detail WHERE convert_group_id LIKE 'CNV-ACP%'"))
conn.execute(
text("DELETE FROM risk_convert_detail WHERE client_request_id LIKE 'ACP-REQ-%'")
)
conn.execute(text("DELETE FROM risk_alert WHERE customer_id = :c"), {"c": CUSTOMER})
conn.execute(text("DELETE FROM risk_suitability_log WHERE customer_id = :c"), {"c": CUSTOMER})
conn.execute(text("DELETE FROM audit_log WHERE customer_id = :c"), {"c": CUSTOMER})
# 鉴权拒绝双写的第二站(无 customer_id 列,按 actor 清:「他人查询」才会走到)
conn.execute(text("DELETE FROM audit_log WHERE actor_id = :a"), {"a": CUSTOMER_OTHER})
conn.execute(text("DELETE FROM input_guard_log WHERE actor_id = :a"), {"a": CUSTOMER_OTHER})
_SEQ = itertools.count(1)
def _install_id_factories() -> None:
"""替换三处单号生成点(HTTP 链路内部调用,无法注入 → 直接换模块属性)。
与 pytest 的 monkeypatch 等价:TestClient 单线程,无并发污染。
"""
def factory(prefix: str, now: datetime) -> str:
if prefix == "CNV":
return f"CNV-ACP-{next(_SEQ):03d}"
return f"TRD-ACP-{next(_SEQ):03d}"
cs._new_id = factory
confirm_service.new_id = factory
trade_gateway._new_trade_id = lambda now: factory("TRD", now)
# ── 链路辅助 ────────────────────────────────────────────────────────
def _body(qty="50000", cid=None) -> dict:
payload = {
"customer_id": CUSTOMER,
"trade_type": "convert",
"from_product_id": PROD_OUT,
"to_product_id": PROD_IN,
"qty": qty,
}
if cid:
payload["client_request_id"] = cid
return payload
def push(client: TestClient, **body) -> tuple[int, dict]:
r = client.post("/api/simulate/trade", json=body or _body(), headers=DEMO)
try:
return r.status_code, r.json()
except Exception: # noqa: BLE001
return r.status_code, {"_raw": r.text}
def _expected_figures(body: dict) -> tuple[Decimal, Decimal, Decimal, Decimal, Decimal, Decimal]:
"""由确认后查询响应反推各段期望(调生产纯函数 · 禁手算)。
返回 (out_amount, redeem_fee, convert_amount, diff_fee, in_amount, in_qty)。
"""
by_days = {leg["hold_days"]: leg for leg in body["lot_breakdown"]}
exp_out = Decimal("0")
exp_fee = Decimal("0")
for days, rate in ((100, Decimal("0.0050")), (3, Decimal("0.0150"))):
leg = by_days[days]
leg_amt = lot_amount(Decimal(leg["qty"]), Decimal(leg["nav"]))
leg_fee = lot_fee(leg_amt, rate)
exp_out += leg_amt
exp_fee += leg_fee
conv = convert_amount(exp_out, exp_fee)
gap = diff_fee(conv, OUT_RATE, IN_RATE)
in_amt = convert_amount(conv, gap)
return exp_out, exp_fee, conv, gap, in_amt, in_qty(in_amt, IN_NAV)
# ── 主流程 ──────────────────────────────────────────────────────────
def main() -> int:
core_admin = get_engine(settings.mysql_core_database, "admin")
agent_admin = get_engine(settings.mysql_database, "admin")
core_ro = CoreReadOnlyRepository()
accept_date = _accept_date_now()
submit = datetime.now()
print(f"受理日 T = {accept_date}(真实提交时刻 {submit:%H:%M:%S})")
try:
cleanup_core(core_admin)
cleanup_agent(agent_admin)
seed(core_admin, agent_admin, accept_date)
except Exception as exc: # noqa: BLE001
print(f"❌ 环境不可用:{exc}")
dispose_engines()
return 2
_install_id_factories()
client = TestClient(app)
try:
# ── A. 受理:HTTP 202 + 受理回执 + 真库只落受理单(PRD §5.3.1)──
print("\n【A】受理:202 + 受理回执;真库 ENUM 落单、零折算副作用")
code, body = push(client, **_body(cid="ACP-REQ-A"))
check("受理状态码", code, 202)
check("accepted 标记", body.get("accepted"), True)
check("业务状态 accepted", body.get("status"), "accepted")
check("受理单号前缀", str(body.get("convert_group_id", "")).startswith("CNV-ACP-"), True)
check("受理承诺量", body.get("requested_qty"), "50000.00")
check("受理承诺量2位", body.get("qty"), "50000.00")
check("预估标记", body.get("estimated"), True)
for key in CONFIRMED_ONLY:
check(f"受理回执不含 {key}", key in body, False)
gid_a = body["convert_group_id"]
row = _rows(
core_admin,
"SELECT status, qty, requested_at, cancel_before FROM core_convert_request"
" WHERE convert_group_id = :g",
g=gid_a,
)[0]
check("落库状态 ENUM accepted", row["status"], "accepted")
check("落库受理量", dec(row["qty"]), Decimal("50000.00"))
check("受理日刻零漂移", row["requested_at"].date(), accept_date)
n_trade = q1(core_admin, "SELECT COUNT(*) FROM core_trade WHERE customer_id = :c", c=CUSTOMER)
check("受理不写流水", n_trade, 0)
remain = dec(
q1(core_admin, "SELECT COALESCE(SUM(remain_qty), 0) FROM core_share_lot"
" WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_OUT)
)
check("受理不扣份额", remain, TOTAL_QTY.quantize(Decimal("0.01")))
# ── B. 幂等:同键重发命中既有单(真库 uk_idem)──
print("\n【B】幂等:同 client_request_id 重发 → 202 同单")
code, dup = push(client, cid="ACP-REQ-A", **_body(cid="ACP-REQ-A"))
check("重发状态码", code, 202)
check("重发同一单号", dup.get("convert_group_id"), gid_a)
check("重发幂等标记", dup.get("idempotent"), True)
n_req = q1(core_admin, "SELECT COUNT(*) FROM core_convert_request WHERE customer_id = :c",
c=CUSTOMER)
check("uk_idem 未重开受理单", n_req, 1)
# ── C. 未确认查询:无折算字段(Q2)──
print("\n【C】查询(未确认):只回受理字段")
code, body = push_free(client, "GET", f"/api/simulate/trade/convert/{gid_a}", OPS)
check("查询状态码", code, 200)
check("查询业务状态", body.get("status"), "accepted")
check("查询未确认标记", body.get("confirmed"), False)
check("请求量", body.get("requested_qty"), "50000.00")
check("实测量未确认不写", body.get("actual_qty"), None)
for key in CONFIRMED_ONLY:
check(f"未确认查询不含 {key}", key in body, False)
# ── H. redeem 份额申报金额(D26/R-6)· **必须最先**(只读、依赖未扣份额)──
print("\n【H】redeem 份额申报:`_redeem_quote = qty × D 净值 − 赎回费`(FIFO 逐批)")
# FIFO 从批次 A1(持 100 天)取前 100 份 → 费率档 0.0050(持有 100 天)。
# 期望 = 100 × 1.3604 = 136.04,扣费 0.68 → 实得 135.36。
# ⚠️ `_nav_as_of` 的语义是「traded_at 当日(含)前最新净值」→ traded_at 必须锚在
# **受理日(=交易日)**:受理日已顺延到 9/14(今天 9/11 20:24 过截点),种子净值
# 也插在 9/14 —— 若传 `datetime.now()`(9/11)就取不到净值(首版实跑即 NavNotReady)。
from app.gateway.trade_gateway import _redeem_quote
quote = _redeem_quote(
core_ro, customer_id=CUSTOMER, product_id=PROD_OUT, qty=Decimal("100"),
traded_at=datetime.combine(accept_date, time(10, 0)),
)
check("redeem 报价 = qty×净值−赎回费(FIFO 0.0050 档)", dec(quote), Decimal("135.36"))
# ── G. 鉴权:两道闸门分离(红线 7)· **确认之前**(REQ-A 份额未转走)──
# 复用 REQ-A:非本人两闸门都拒 / 顾问可查不可撤 / risk_demo 无查询 scope /
# risk_officer 可查 / **本人可撤(窗口内 200 + 释放量)→ REQ-A 转 cancelled**。
print("\n【G】鉴权:撤单=交易 owner / 查询=查询 scope")
code, r = push_free(client, "GET", f"/api/simulate/trade/convert/{gid_a}", STRANGER)
check("他人查询 403", code, 403)
code, r = push_free(client, "GET", f"/api/simulate/trade/convert/{gid_a}", ADVISOR)
check("顾问可查 200", code, 200)
code, r = push_free(client, "POST", f"/api/simulate/trade/convert/{gid_a}/cancel", ADVISOR)
check("顾问不可撤 403", code, 403)
check("顾问撤单错误码", r.get("error_code"), "AUTH_403_ROLE")
code, r = push_free(client, "GET", f"/api/simulate/trade/convert/{gid_a}", DEMO)
check("risk_demo 无查询 scope 403", code, 403)
code, r = push_free(client, "GET", f"/api/simulate/trade/convert/{gid_a}", OPS)
check("risk_officer 可查 200", code, 200)
code, r = push_free(client, "POST", f"/api/simulate/trade/convert/{gid_a}/cancel", SELF)
check("本人可撤 200(窗口内)", code, 200)
check("本人撤单状态", r.get("status"), "cancelled")
check("本人撤单释放量", r.get("released_qty"), "50000.00")
st = q1(core_admin, "SELECT status FROM core_convert_request WHERE convert_group_id = :g",
g=gid_a)
check("落库 cancelled(本人撤单生效)", st, "cancelled")
# 三次 403:他人查询(STRANGER)+ 顾问撤单(ADVISOR)+ demo 查询(DEMO)
n_authz = q1(
agent_admin,
"SELECT COUNT(*) FROM audit_log WHERE event_type = 'authz' AND decision = 'forbidden'"
" AND actor_id IN (:a1, :a2, :a3)",
a1=CUSTOMER_OTHER, a2=DEMO["X-Debug-Actor"], a3=ADVISOR["X-Debug-Actor"],
)
check("403 全部留痕(authz forbidden ≥3)", n_authz >= 3, True)
# ── D. 撤单语义:重复撤 409 + 占用释放后份额立即可用 ──
#(REQ-A 已被 G 段本人撤成 cancelled → 此处重复撤必 409;再受理 REQ-D 验
# 释放的 50000 立即可用。)
print("\n【D】撤单语义:重复撤 409;撤单释放的占用立即回到可用份额")
code, again = push_free(client, "POST", f"/api/simulate/trade/convert/{gid_a}/cancel", DEMO)
check("重复撤单 409", code, 409)
check("重复撤单错误码", again.get("error_code"), "CANCEL_NOT_ALLOWED")
code, body2 = push(client, **_body(cid="ACP-REQ-D"))
check("撤单后份额立即可用(受理成功)", body2.get("status"), "accepted")
gid_d = body2["convert_group_id"]
# ── E. 确认批处理:HTTP 触发 → confirmed + 折算结果与 PRD §5.3 对表 ──
print("\n【E】确认批处理(admin 接口)→ 折算结果与 PRD §5.3 示例逐项吻合")
code, batch = push_free(
client, "POST", f"/api/admin/convert/confirm?accept_date={accept_date}", OPS
)
check("确认批处理状态码", code, 200)
check("批处理抢锁成功", batch.get("locked"), True)
check("批处理捞单数", batch.get("scanned"), 1)
check("批处理确认数", batch.get("confirmed"), 1)
one = next(
r for r in batch["results"] if r["convert_group_id"] == gid_d
)
check("单笔确认状态", one.get("status"), "confirmed")
st = q1(core_admin, "SELECT status FROM core_convert_request WHERE convert_group_id = :g",
g=gid_d)
check("落库 confirmed", st, "confirmed")
code, body = push_free(client, "GET", f"/api/simulate/trade/convert/{gid_d}", OPS)
check("确认后查询状态码", code, 200)
check("确认后查询状态", body.get("status"), "confirmed")
check("确认后查询标记", body.get("confirmed"), True)
check("实测量=申请量(全额)", body.get("actual_qty"), "50000.00")
exp_out, exp_fee, exp_conv, exp_gap, exp_in_amt, exp_in_qty = _expected_figures(body)
check("转出金额 = 68020.00", dec(body["out_amount"]), Decimal("68020.00"))
check("赎回费 = 612.18", dec(body["redeem_fee"]), Decimal("612.18"))
check("转换净额 = 67407.82", dec(body["convert_amount"]), Decimal("67407.82"))
check("补差费 = 333.36", dec(body["diff_fee"]), Decimal("333.36"))
check("转入金额 = 67074.46", dec(body["in_amount"]), Decimal("67074.46"))
check("转入份额 = 34945.54", dec(body["in_qty"]), Decimal("34945.54"))
check("转出净值 = T 日净值 1.3604", dec(body["out_nav"], "0.0001"), Decimal("1.3604"))
check("净值日 = 受理日", body.get("nav_date"), str(accept_date))
check("两条流水落库", q1(core_admin, "SELECT COUNT(*) FROM core_trade WHERE customer_id = :c",
c=CUSTOMER), 2)
check("转入端持仓已建", q1(core_admin, "SELECT COUNT(*) FROM core_holding"
" WHERE customer_id = :c AND product_id = :p",
c=CUSTOMER, p=PROD_IN), 1)
# ── F. 确认后撤单 → 409(状态闸门,终态不可逆)──
print("\n【F】已确认的单再撤 → 409")
code, body = push_free(client, "POST", f"/api/simulate/trade/convert/{gid_d}/cancel", DEMO)
check("已确认撤单 409", code, 409)
check("已确认撤单错误码", body.get("error_code"), "CANCEL_NOT_ALLOWED")
finally:
cleanup_core(core_admin)
cleanup_agent(agent_admin)
dispose_engines()
print(f"\n结果:{_passed} 通过 / {_failed} 失败")
return 0 if _failed == 0 else 1
def push_free(
client: TestClient, method: str, path: str, headers: dict
) -> tuple[int, dict]:
"""GET/POST 封装(无 body)。"""
r = getattr(client, method.lower())(path, headers=headers)
try:
return r.status_code, r.json()
except Exception: # noqa: BLE001
return r.status_code, {"_raw": r.text}
if __name__ == "__main__":
sys.exit(main())