AIcoding 第 5 步 todo 开发(开发计划 v2.0)前半段: - T-1 3 张新表 DDL(core_convert_request 6 态 ENUM / core_trade_calendar / core_share_rule)+ 种子 + sqlite 单一事实源同步 + 列清单断言 - T-2/T-2b calc 扩展(product_round/redeem_amount/partial_qty)+ 真实净值实算回填 - T-3 convert_request_repository(6 态 + 条件 UPDATE 守卫)+ core_ro 三读方法 + share_lot_repository.available_qty_with_inflight(R-3 在途占用推导) - T-4 convert_repository.sync_mirror 成为 risk_convert_detail 唯一进度镜像写入口 (旧三方法标 Deprecated,T-7 后删) - T-5 locks.py 锁键构造器 convert_req_lock_key / convert_confirm_lock_key - T-6 convert_service.accept_convert 受理事务(八步:锁→幂等→校验→受理日顺延 →在途占用校验→落单→镜像+审计→202;不扣份额/不折算/不写流水) + tests/test_convert_accept.py(16 用例) + scripts/dev/verify_convert_accept.py(真库 36/36 一致) + trading_calendar.py 纯函数包(R-5)+ 21 用例 T-6 真库实测暴露并修复:confirm_eta 在日历数据边界抛 ValueError,会让已落库 的受理单在调用方眼里变 500;改为展示性字段容错 + 单测守护。 基线:798 passed / 10 skipped,零回归。
487 lines
20 KiB
Python
487 lines
20 KiB
Python
"""T-9 真 MySQL 端到端集成测试(架构 §8/§10 · 开发计划 §7.2 DoD · §12 R15 迁入)。
|
||
|
||
链路:`TestClient(main app)` → HTTP → `trade_gateway` 分派 → `convert_service`
|
||
八步编排 → 真 `jinrong_core` / `jinrong_agent`。
|
||
|
||
**为什么必须有这一层**:单测层只验到「路由层不加工、原样透传」(见
|
||
`test_trade_gateway.py`),而折算数字是否与 PRD §5.3 逐项吻合、两条流水是否
|
||
真的同组同事务、持仓/批次是否真的扣减——只有真库能证明。
|
||
|
||
隔离策略(架构 §10)——**三条同时成立,缺一即污染种子**:
|
||
|
||
1. **id 前缀**:`CNV-TEST-` / `TRD-TEST-`(monkeypatch `convert_service._new_id`);
|
||
2. **数据自建**:客户/产品/费率/净值/批次全部 `CNVTEST` 前缀自建,**绝不碰种子**——
|
||
`risk_demo_env` 的 teardown 只清 `TRD-TEST-` 前缀与时间窗,**不还原
|
||
`core_share_lot`/`core_holding`**;若借种子客户跑转换,扣掉的份额会跨用例污染
|
||
`test_integration_risk.py`;
|
||
3. **teardown 全清**:函数级 fixture 按前缀删两库全部自建行(幂等,seed 失败亦可清)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import date, datetime, time, timedelta
|
||
from decimal import Decimal
|
||
from uuid import uuid4
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
from sqlalchemy import text
|
||
|
||
from conftest import ensure_risk_demo_ready
|
||
|
||
from app.gateway import trade_gateway # noqa: E402
|
||
from app.main import app # 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.risk import redis_gateway # noqa: E402
|
||
|
||
ensure_risk_demo_ready()
|
||
|
||
DEMO = {"X-Debug-Role": "risk_demo", "X-Debug-Actor": "STAFF-DEMO"}
|
||
|
||
# ── 隔离种子常量(全部带 CNVTEST 前缀)──────────────────────────────
|
||
CUSTOMER = "CUST-CNVTEST"
|
||
PROD_OUT = "PROD-CNVTESTO" # 债基,申购费率 0.0030
|
||
PROD_IN = "PROD-CNVTESTI" # 股基,申购费率 0.0080(**高于**转出端 → 补差费非零)
|
||
PROD_CROSS = "PROD-161725" # 真库既有:易方模拟基金 / TA-CN-002(跨主体负例)
|
||
COMPANY = "华夏模拟基金"
|
||
TA = "TA-CN-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-CNVTEST-A1", "30000", 100), ("LOT-CNVTEST-A2", "20000", 3)]
|
||
TOTAL_QTY = sum(Decimal(q) for _, q, _ in LOT_SPEC)
|
||
|
||
_GROUP_LIKE = "CNV-TEST-%"
|
||
|
||
|
||
class FakePub:
|
||
def __init__(self) -> None:
|
||
self.messages: list = []
|
||
self.deletes: list = []
|
||
|
||
def publish(self, channel, payload):
|
||
self.messages.append((channel, payload))
|
||
|
||
def delete(self, *keys):
|
||
self.deletes.append(keys)
|
||
|
||
|
||
def _test_new_id(prefix: str, now: datetime) -> str:
|
||
"""`_new_id` 替换:`CNV-TEST-xxxx` / `TRD-TEST-xxxx`(架构 §10 前缀约定)。"""
|
||
return f"{prefix}-TEST-{uuid4().hex[:8].upper()}"
|
||
|
||
|
||
# ── 种子与清理 ──────────────────────────────────────────────────────
|
||
def _seed(core) -> None:
|
||
"""自建 PRD §5.3 场景(客户 + 双产品 + 费率 + 净值 + 双批次 + 持仓)。"""
|
||
today = date.today()
|
||
base = datetime.combine(today, time(10, 0)) # 固定钟点 → hold_days 恒等于 LOT_SPEC 的天数
|
||
with core.begin() as conn:
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_customer (customer_id, display_name, open_date)"
|
||
" VALUES (:c, 'T9集成测试', :d)"
|
||
),
|
||
{"c": CUSTOMER, "d": today},
|
||
)
|
||
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)},
|
||
)
|
||
for pid, name, ptype, rate in [
|
||
(PROD_OUT, "T9转出基金", "bond", OUT_RATE),
|
||
(PROD_IN, "T9转入基金", "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,"
|
||
" fund_company, ta_code)"
|
||
" VALUES (:p, :n, 'R2', :t, 1, 1, :r, :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},
|
||
)
|
||
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": str(IN_NAV), "d": today},
|
||
)
|
||
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": today,
|
||
},
|
||
)
|
||
|
||
|
||
def _cleanup(core, agent) -> None:
|
||
"""按前缀清两库全部自建行(幂等)。"""
|
||
core_sqls = [
|
||
f"DELETE FROM core_convert_lot_detail WHERE convert_group_id LIKE '{_GROUP_LIKE}'",
|
||
"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_risk WHERE customer_id = :c",
|
||
"DELETE FROM core_fee_rule WHERE product_id LIKE 'PROD-CNVTEST%'",
|
||
"DELETE FROM core_product_nav WHERE product_id LIKE 'PROD-CNVTEST%'",
|
||
"DELETE FROM core_product WHERE product_id LIKE 'PROD-CNVTEST%'",
|
||
"DELETE FROM core_customer WHERE customer_id = :c",
|
||
]
|
||
with core.begin() as conn:
|
||
for sql in core_sqls:
|
||
conn.execute(text(sql), {"c": CUSTOMER})
|
||
with agent.begin() as conn:
|
||
conn.execute(text(f"DELETE FROM risk_convert_detail WHERE convert_group_id LIKE '{_GROUP_LIKE}'"))
|
||
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})
|
||
|
||
|
||
@pytest.fixture()
|
||
def conv_env(risk_demo_env, monkeypatch):
|
||
"""自建隔离种子 + 注入 TEST 前缀 id 工厂 + 函数级全清。"""
|
||
core = risk_demo_env["core"]
|
||
agent = risk_demo_env["agent"]
|
||
monkeypatch.setattr(cs, "_new_id", _test_new_id)
|
||
monkeypatch.setattr(trade_gateway, "_new_trade_id", lambda now: _test_new_id("TRD", now))
|
||
_cleanup(core, agent) # 先清后建:上一轮异常退出也能自愈
|
||
_seed(core)
|
||
try:
|
||
yield {"core": core, "agent": agent}
|
||
finally:
|
||
_cleanup(core, agent)
|
||
|
||
|
||
@pytest.fixture()
|
||
def client(conv_env, monkeypatch):
|
||
fake = FakePub()
|
||
with TestClient(app) as c:
|
||
monkeypatch.setattr(redis_gateway, "_gateway", fake)
|
||
yield c
|
||
|
||
|
||
def _body(qty="50000", cid=None, frm=PROD_OUT, to=PROD_IN):
|
||
payload = {
|
||
"customer_id": CUSTOMER,
|
||
"trade_type": "convert",
|
||
"from_product_id": frm,
|
||
"to_product_id": to,
|
||
"qty": qty,
|
||
}
|
||
if cid:
|
||
payload["client_request_id"] = cid
|
||
return payload
|
||
|
||
|
||
def _one(engine, sql: str, **params):
|
||
with engine.connect() as conn:
|
||
return conn.execute(text(sql), params).mappings().first()
|
||
|
||
|
||
def _n(engine, sql: str, **params) -> int:
|
||
with engine.connect() as conn:
|
||
return conn.execute(text(sql), params).scalar_one()
|
||
|
||
|
||
# ── 1. 端到端:与 PRD §5.3 示例逐项吻合 ─────────────────────────────
|
||
def test_convert_end_to_end_matches_prd_5_3(client, conv_env):
|
||
"""跨两批次转换走通,四段金额与 PRD §5.3 示例**逐项吻合**。
|
||
|
||
期望值一律**调生产纯函数**得出(禁手算 · 自检第 13 问),再与 PRD 示例数字对表。
|
||
"""
|
||
r = client.post("/api/simulate/trade", json=_body(), headers=DEMO)
|
||
assert r.status_code == 200, r.text
|
||
body = r.json()
|
||
|
||
assert body["blocked"] is False
|
||
assert body["estimated"] is True # T 日未知价法
|
||
assert body["lot_count"] == 2
|
||
assert body["confirm_basis"] == "natural_day_approx"
|
||
|
||
by_days = {b["hold_days"]: b for b in body["lot_breakdown"]}
|
||
assert set(by_days) == {100, 3}, "两批次持有期应为 100 / 3 天"
|
||
|
||
exp_out = Decimal("0")
|
||
exp_fee = Decimal("0")
|
||
for days, rate in ((100, Decimal("0.0050")), (3, Decimal("0.0150"))):
|
||
leg = by_days[days]
|
||
assert Decimal(leg["fee_rate"]) == rate, f"持有 {days} 天的费率档"
|
||
leg_amount = lot_amount(Decimal(leg["qty"]), Decimal(leg["nav"]))
|
||
leg_fee = lot_fee(leg_amount, rate)
|
||
assert Decimal(leg["fee_amount"]) == leg_fee, f"持有 {days} 天的逐批费用"
|
||
exp_out += leg_amount
|
||
exp_fee += leg_fee # 逐批舍入后求和(PRD §2.5)
|
||
|
||
exp_conv = convert_amount(exp_out, exp_fee)
|
||
exp_diff = diff_fee(exp_conv, OUT_RATE, IN_RATE)
|
||
exp_in_amount = convert_amount(exp_conv, exp_diff)
|
||
|
||
assert Decimal(body["out_amount"]) == exp_out
|
||
assert Decimal(body["redeem_fee"]) == exp_fee
|
||
assert Decimal(body["convert_amount"]) == exp_conv
|
||
assert Decimal(body["diff_fee"]) == exp_diff
|
||
assert Decimal(body["in_amount"]) == exp_in_amount
|
||
assert Decimal(body["in_qty"]) == in_qty(exp_in_amount, IN_NAV)
|
||
|
||
# 与 PRD §5.3 示例数字对表(该示例由 calc_convert_demo.py 实算回填,验收 20)
|
||
assert (exp_out, exp_fee, exp_conv, exp_diff, exp_in_amount) == (
|
||
Decimal("68020.00"),
|
||
Decimal("612.18"),
|
||
Decimal("67407.82"),
|
||
Decimal("333.36"),
|
||
Decimal("67074.46"),
|
||
)
|
||
assert Decimal(body["in_qty"]) == Decimal("34945.54")
|
||
# 转入端费率高于转出端 → 补差费应 > 0(验收 19 的非零场景)
|
||
assert exp_diff > 0
|
||
|
||
|
||
# ── 2. 两条流水同组、id 前缀正确 ────────────────────────────────────
|
||
def test_convert_writes_two_trades_in_same_group(client, conv_env):
|
||
core = conv_env["core"]
|
||
gid = client.post("/api/simulate/trade", json=_body(), headers=DEMO).json()["convert_group_id"]
|
||
assert gid.startswith("CNV-TEST-")
|
||
|
||
rows = {
|
||
row["trade_type"]: row
|
||
for row in [
|
||
dict(r)
|
||
for r in _all(
|
||
core,
|
||
"SELECT trade_type, trade_id, amount, convert_group_id FROM core_trade"
|
||
" WHERE customer_id = :c",
|
||
c=CUSTOMER,
|
||
)
|
||
]
|
||
}
|
||
assert set(rows) == {"redeem", "subscribe"}, "一次转换落两条流水(R-b)"
|
||
assert {r["convert_group_id"] for r in rows.values()} == {gid}
|
||
assert all(r["trade_id"].startswith("TRD-TEST-") for r in rows.values())
|
||
# 转出端金额 = 原始转出额(未扣费)
|
||
assert Decimal(str(rows["redeem"]["amount"])) == Decimal("68020.00")
|
||
|
||
|
||
def _all(engine, sql: str, **params):
|
||
with engine.connect() as conn:
|
||
return conn.execute(text(sql), params).mappings().all()
|
||
|
||
|
||
# ── 3. 持仓与批次如实变动 ───────────────────────────────────────────
|
||
def test_convert_updates_holding_and_lots(client, conv_env):
|
||
core = conv_env["core"]
|
||
client.post("/api/simulate/trade", json=_body(), headers=DEMO)
|
||
|
||
# 转出端批次:两批 remain_qty 归零(全部转出)—— 必须限定 product_id,
|
||
# 否则会把**转入端新建批次**算进同一客户的聚合(首版断言即栽在这里)
|
||
remain = _one(
|
||
core,
|
||
"SELECT SUM(remain_qty) AS s FROM core_share_lot"
|
||
" WHERE customer_id = :c AND product_id = :p",
|
||
c=CUSTOMER,
|
||
p=PROD_OUT,
|
||
)
|
||
assert Decimal(str(remain["s"])) == Decimal("0.0000")
|
||
|
||
# 转出端持仓归零但**行保留**(D9/P2)
|
||
out_holding = _one(
|
||
core,
|
||
"SELECT qty FROM core_holding WHERE customer_id = :c AND product_id = :p",
|
||
c=CUSTOMER,
|
||
p=PROD_OUT,
|
||
)
|
||
assert out_holding is not None, "转出归零保留行(不可 DELETE)"
|
||
assert Decimal(str(out_holding["qty"])) == Decimal("0.0000")
|
||
|
||
# 转入端持仓新建且份额 = 响应 in_qty
|
||
in_holding = _one(
|
||
core,
|
||
"SELECT qty FROM core_holding WHERE customer_id = :c AND product_id = :p",
|
||
c=CUSTOMER,
|
||
p=PROD_IN,
|
||
)
|
||
assert in_holding is not None
|
||
assert Decimal(str(in_holding["qty"])) == Decimal("34945.54")
|
||
|
||
|
||
# ── 4. 阶段二:明细 completed + 审计落库 ────────────────────────────
|
||
def test_convert_detail_completed_and_audited(client, conv_env):
|
||
agent = conv_env["agent"]
|
||
gid = client.post("/api/simulate/trade", json=_body(), headers=DEMO).json()["convert_group_id"]
|
||
|
||
detail = _one(
|
||
agent,
|
||
"SELECT status, fee_amount FROM risk_convert_detail WHERE convert_group_id = :g",
|
||
g=gid,
|
||
)
|
||
assert detail["status"] == "completed"
|
||
assert Decimal(str(detail["fee_amount"])) == Decimal("612.18")
|
||
|
||
# 审计以 convert_group_id 为关联主键(L-2:一次转换 = 一条主审计)
|
||
n_main = _n(
|
||
agent,
|
||
"SELECT COUNT(*) FROM audit_log WHERE customer_id = :c AND decision = 'convert_accepted'",
|
||
c=CUSTOMER,
|
||
)
|
||
assert n_main == 1
|
||
# 网关不重复写 trade_request(T-9:审计归 convert_service)
|
||
assert _n(
|
||
agent,
|
||
"SELECT COUNT(*) FROM audit_log WHERE customer_id = :c AND event_type = 'trade_request'",
|
||
c=CUSTOMER,
|
||
) == 0
|
||
|
||
|
||
# ── 5. 幂等:同键重试不产生第二组流水 ───────────────────────────────
|
||
#: 首发(走 calc 纯函数)与重放(按 core_trade/core_convert_lot_detail 重建)
|
||
#: 必须**逐字节相同**的字段。这份清单是「同一响应、两条路径」的一致性契约。
|
||
REPLAY_IDENTICAL_FIELDS = (
|
||
"requested_qty", "actual_qty",
|
||
"out_nav", "out_amount", "redeem_fee",
|
||
"in_nav", "convert_amount", "diff_fee", "in_amount", "in_qty",
|
||
"rounding_diff", "out_subscribe_fee_rate", "in_subscribe_fee_rate",
|
||
)
|
||
|
||
|
||
def test_convert_idempotent_retry_returns_byte_identical_response(client, conv_env):
|
||
"""带 `client_request_id` 重试 → 命中幂等、**响应与首次逐字节相同**。
|
||
|
||
这是「展示位数」的回归闸门:重放值由 `DECIMAL(18,4)` 重建,若不经理发展示规格
|
||
收敛,同一字段会吐出 `34945.5400`(4 位)而首次是 `34945.54`(2 位)——
|
||
数值相等但字符串不等,前端/对账按字符串比对即误判为两笔。
|
||
"""
|
||
core = conv_env["core"]
|
||
first = client.post(
|
||
"/api/simulate/trade", json=_body(cid="T9-IT-IDEM-1"), headers=DEMO
|
||
).json()
|
||
second = client.post(
|
||
"/api/simulate/trade", json=_body(cid="T9-IT-IDEM-1"), headers=DEMO
|
||
).json()
|
||
|
||
assert second["convert_group_id"] == first["convert_group_id"]
|
||
for field in REPLAY_IDENTICAL_FIELDS:
|
||
assert second[field] == first[field], f"重放 {field} 应与首次**逐字节**相同"
|
||
# 逐批明细同样逐字段一致(qty / nav / fee_rate 的位数也是展示规格的一部分)
|
||
assert second["lot_breakdown"] == first["lot_breakdown"]
|
||
|
||
assert _n(core, "SELECT COUNT(*) FROM core_trade WHERE customer_id = :c", c=CUSTOMER) == 2
|
||
assert _n(
|
||
core,
|
||
"SELECT COUNT(*) FROM core_share_lot"
|
||
" WHERE customer_id = :c AND product_id = :p AND remain_qty > 0",
|
||
c=CUSTOMER,
|
||
p=PROD_OUT,
|
||
) == 0, "重试不得再扣一次转出端份额"
|
||
assert _n(
|
||
core,
|
||
"SELECT COUNT(*) FROM core_share_lot WHERE customer_id = :c AND product_id = :p",
|
||
c=CUSTOMER,
|
||
p=PROD_IN,
|
||
) == 1, "重试不得再建一条转入端批次"
|
||
|
||
|
||
# ── 5b. 展示位数规格(真实公告口径,见 convert_service._q)─────────────
|
||
def test_convert_response_field_scales(client, conv_env):
|
||
"""响应的**展示位数**按字段分类收敛(真实公告口径),不随数据源漂移。
|
||
|
||
真实依据:金额/份额「四舍五入保留至小数点后两位」;净值保留 4 位、
|
||
第 5 位四舍五入;申购费率以百分比 2 位表示(小数形式即 4 位)。
|
||
"""
|
||
body = client.post("/api/simulate/trade", json=_body(), headers=DEMO).json()
|
||
|
||
def scale(key: str) -> int:
|
||
return len(body[key].split(".")[1])
|
||
|
||
# 金额 / 份额 → 2 位
|
||
for key in ("out_amount", "redeem_fee", "convert_amount", "diff_fee", "in_amount",
|
||
"in_qty", "requested_qty", "actual_qty"):
|
||
assert scale(key) == 2, f"{key} 应为金额/份额口径(2 位),实际 {body[key]}"
|
||
# 净值 / 费率 / 尾差 → 4 位
|
||
for key in ("out_nav", "in_nav", "out_subscribe_fee_rate",
|
||
"in_subscribe_fee_rate", "rounding_diff"):
|
||
assert scale(key) == 4, f"{key} 应为净值/费率口径(4 位),实际 {body[key]}"
|
||
|
||
for leg in body["lot_breakdown"]:
|
||
assert len(leg["qty"].split(".")[1]) == 2, f"逐批份额 2 位,实际 {leg['qty']}"
|
||
assert len(leg["fee_amount"].split(".")[1]) == 2, f"逐批费用 2 位,实际 {leg['fee_amount']}"
|
||
assert len(leg["nav"].split(".")[1]) == 4, f"逐批净值 4 位,实际 {leg['nav']}"
|
||
assert len(leg["fee_rate"].split(".")[1]) == 4, f"逐批费率 4 位,实际 {leg['fee_rate']}"
|
||
|
||
# 申请份额即使**不带小数**传入,也必须补齐到 2 位(不能回显成 "50000")
|
||
assert body["requested_qty"] == "50000.00"
|
||
|
||
|
||
# ── 6. 跨主体拒绝(验收 14)─────────────────────────────────────────
|
||
def test_convert_cross_entity_rejected(client, conv_env):
|
||
"""转出华夏/TA-CN-001 → 转入易方/TA-CN-002 → 400 `CROSS_ENTITY_NOT_SUPPORTED`。"""
|
||
r = client.post("/api/simulate/trade", json=_body(to=PROD_CROSS), headers=DEMO)
|
||
assert r.status_code == 400
|
||
body = r.json()
|
||
assert body["error_code"] == "CROSS_ENTITY_NOT_SUPPORTED"
|
||
assert body["trace_id"]
|
||
# 校验失败不留任何流水(4xx 不落库)
|
||
assert _n(conv_env["core"], "SELECT COUNT(*) FROM core_trade WHERE customer_id = :c", c=CUSTOMER) == 0
|
||
|
||
|
||
# ── 7. 未知类型仍 400(R3 的集成侧对应)─────────────────────────────
|
||
def test_unknown_trade_type_returns_400_over_http(client):
|
||
"""走 HTTP 的未知类型仍是 400(`TradeRequest` 分支校验**放行**未知类型给网关兜底拒绝)。"""
|
||
r = client.post(
|
||
"/api/simulate/trade",
|
||
json={"customer_id": CUSTOMER, "trade_type": "purchase", "amount": "1000"},
|
||
headers=DEMO,
|
||
)
|
||
assert r.status_code == 400
|
||
assert r.json()["error_code"] == "BAD_REQUEST"
|