"""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%'", # T-16 起确认事务写 core_cash_flow(fk_cf_customer 挡在客户删除前) "DELETE FROM core_cash_flow WHERE remark LIKE 'convert:%'", "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: # ── H. redeem 份额申报金额(D26/R-6)· **必须最先**(只读、依赖未扣份额)── # T-10 起 `_redeem_quote` 走 `available_qty_with_inflight`(R-3)硬校验: # 必须放在任何受理之前执行(此时在途=0,可赎=物理余量 50000), # 否则会被后续受理单的在途占用拦截 —— 见 H2 真库断言。 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")) # ── 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) # ── H2. T-10 在途占用真库校验:受理 REQ-A 占满 50000 在途 → redeem 申报必须被拒 ── # R-3 硬校验(available_qty_with_inflight):受理后 可赎 = 50000 − 50000 = 0, # `_redeem_quote` 抛 InsufficientShares —— 与扣减侧同口径(拒绝超量,不静默裁剪)。 print("\n【H2】T-10:在途占用拦截赎回申报(InsufficientShares 硬校验)") from app.gateway.trade_gateway import _redeem_quote from app.service.convert.errors import InsufficientShares try: _redeem_quote( core_ro, customer_id=CUSTOMER, product_id=PROD_OUT, qty=Decimal("100"), traded_at=datetime.combine(accept_date, time(10, 0)), ) except InsufficientShares as exc: check("在途占用拦截(InsufficientShares 抛出)", "申报赎回" in str(exc), True) else: check("在途占用拦截(InsufficientShares 抛出)", False, True) # ── 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())