一、T-9 本体:HTTP 层 convert 端到端走通 - api/simulate.py:TradeRequest 三型字段分池(subscribe/redeem → product_id+amount; convert → from/to_product_id + qty + 可选 client_request_id)+ model_validator 分支校验; 未知类型放行给网关抛 400(保住既有 purchase → 400 断言);model_dump(exclude_none=True); PROCESSING → 202;异常捕获由 except LookupError 收窄为 except NotFoundError (原写法把 KeyError 这类编程错误静默转成 404,实测掩盖 convert 分支真实诊断)。 - gateway/trade_gateway.py:移除 convert 显式拒绝,新增 _submit_convert 分派 (只做参数映射 + 仓储装配);convert 不写 trade_request 审计(审计归 convert_service)。 - utils/response.py:错误体合入 exc.extra(TOO_MANY_LOTS 的 batch_count/max_lots); 既有 ApiError 无 extra 属性 → 老错误体逐字节不变。 - utils/trace.py + main.py:正则收敛单点定义。执行期发现 trace.py 与 main.py 各有一份 内容完全相同的白名单副本 —— S4 要防的「漂移」其实已经发生,现将常量上移 trace.py 成公开 HEADER_ID_PATTERN(同时解决 main→simulate 反向导入成环)。 二、展示位数口径修复(执行期发现 → 联网核验 → 修复 → 文档订正) 发现:同一逻辑响应两种写法 —— 首次 "53456.95" vs 幂等重放 "53456.9500",数值相等、字符串不等。 根因不是 T-7 写错,是契约缺位:§2.5 只规定「金额/份额 2 位」,净值、费率、申请份额的 回显位数根本没定义 → 实现只能 str(Decimal) 原样出网 → 位数随数据来源漂移。 修复:convert_service 新增 _q(value, unit) + _D2/_D4 规格常量作对外唯一出口 —— 金额/份额 2 位、净值/费率/份额尾差 4 位;响应 + 审计 summary + 异常日志共用该出口; 原 _s() 全部替换。首次路径幂等(除 requested_qty/actual_qty/lot[].qty 由 4 位补齐 2 位外不变)。 依据(2026-09-10 联网核验 7 家管理人公告):金额/份额「四舍五入保留至小数点后两位」; 「申请转换份额精确到小数点后两位」;净值保留 4 位第 5 位四舍五入(中欧/国泰公告由 3 位提高至 4 位); 费率以百分比 2 位表示。已知不统一:易方达 ETF 场外份额取整数位、南方基金取截断 → 取主流口径 并记入 PRD 已知差异(未来接真实 TA 需按基金合同配置化)。 三、文档订正 - PRD → v0.9.2:§2.5 拆 2.5.1 计算精度 / 2.5.2 展示位数(新增按字段分类的规格表 + 外部依据); §5.3 示例 requested_qty/actual_qty/lot_breakdown[].qty 4 位 → 2 位(原示例与 §2.5 「计算与对外展示按 2 位」自相矛盾,属漏改);字段类型约定补「位数不自由 + 两条路径须逐字节一致」。 - 架构 → v1.0.1:§1 原则 11 补「str() 前必须按 §2.5.2 量化」,无结构变更。 四、验证 - 新增 tests/test_convert_integration.py(8 条真 MySQL 端到端,CNV-TEST-/TRD-TEST- 前缀隔离): 折算与 PRD §5.3 逐项吻合、两条流水同组、持仓与批次如实变动、明细 completed + 审计、 幂等重试不产生第二组、跨主体 400、未知类型 400,以及 「首次与重放逐字段逐字节相等」+「展示位数规格」两条新闸门。 - test_trade_gateway.py +17(11 条错误码映射全表参数化 · 202 · 200 透传 · 不写 trade_request 审计)。 - test_integration_risk.py:R15 处置 —— 端到端已迁入新文件,原槽位改造为 test_invalid_type_400_and_no_new_trade_audit(改用 purchase 触发),保住「校验失败不落审计」不变量。 - pytest -q → 697 passed / 3 skipped(基线 672 +25,零回归)。 - 真库复跑:T-6 24/24 · T-7 35/35 · T-8 31/31;calc_convert_demo.py 15/15。 - 突变验证 4 组:关掉 convert 分派 → 21 条红;关掉错误体 extra 展开 → 精准 1 条; 关掉 client_request_id 正则 → 精准 1 条;关掉 _q() 展示量化 → 2 条红 (assert '50000.0000' == '50000' 直接复现原缺陷)。均已恢复。
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.0300")
|
||
IN_NAV = Decimal("0.9500")
|
||
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 实算回填)
|
||
assert (exp_out, exp_fee, exp_conv, exp_diff, exp_in_amount) == (
|
||
Decimal("51500.00"),
|
||
Decimal("463.50"),
|
||
Decimal("51036.50"),
|
||
Decimal("252.40"),
|
||
Decimal("50784.10"),
|
||
)
|
||
assert Decimal(body["in_qty"]) == Decimal("53456.95")
|
||
# 转入端费率高于转出端 → 补差费应 > 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("51500.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("53456.95")
|
||
|
||
|
||
# ── 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("463.50")
|
||
|
||
# 审计以 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)` 重建,若不经理发展示规格
|
||
收敛,同一字段会吐出 `53456.9500`(4 位)而首次是 `53456.95`(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"
|