三接口路径以 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 组突变验证命中后还原
521 lines
25 KiB
Python
521 lines
25 KiB
Python
"""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()) |