三接口路径以 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 组突变验证命中后还原
287 lines
12 KiB
Python
287 lines
12 KiB
Python
"""基金转换对外接口(T-9 · **路径与字段一律以 PRD 为准**)。
|
||
|
||
PRD §5.5 / §5.7 给出的三条契约路径,分挂两族前缀:
|
||
|
||
- `/api/simulate/trade/convert/...`:**撤单**(§5.5)+ **查询**(§5.7)——
|
||
交易动作与查询,延续 simulate 网关语义(与 §5.1 `POST /api/simulate/trade` 同族);
|
||
- `/api/admin/convert/confirm`:**确认批处理触发**(§5.7)—— 运维接口。
|
||
|
||
> ⚠️ **路径出处**:本模块路径**不是自拟**。开发计划 T-9 初稿曾自拟
|
||
> `/api/convert/{gid}/cancel` 等三条路径,与 PRD 原文完全不同;经用户
|
||
> 2026-09-11 裁定**以 PRD 为准**(见开发计划 §1.3 裁定 11)。
|
||
> 三轮独立审查当时未发现该差异,根因与补救见开发计划 §9 第 10 条。
|
||
|
||
**鉴权:两道闸门严格分离(红线 7,不可合并)**
|
||
|
||
==================== ========================================== ==========================
|
||
接口 闸门 理由
|
||
==================== ========================================== ==========================
|
||
撤单(§5.5) **交易 owner**(本人或 `risk_demo`) 撤单是资金动作,与下单同权限
|
||
查询(§5.7) **查询 scope**(`assert_customer_access`) 本人 / 已分配顾问 / risk_officer
|
||
确认(§5.7) **admin 级**(`risk_officer`) 触发全量批处理,非客户动作
|
||
==================== ========================================== ==========================
|
||
|
||
**错误体**:convert 服务层异常(`ConvertError` 子类)本身即 `ApiError`,由
|
||
`utils/response.register_error_handlers` 统一转 `{error_code, message, trace_id,
|
||
request_id}`(结构性附加字段 `extra` 自动展开,见该模块 T-9 注释)——
|
||
故本层**不捕获、不重包**,避免把 409/503 漂移成 500。
|
||
|
||
**确认接口的参数名与语义**(开发计划 §1.3 裁定 12):参数名沿用 PRD 的
|
||
`accept_date`,**语义取 PRD §5.7 表格口径「业务日」**(= 业务确认日 T+1),
|
||
直接映射 `confirm_batch(as_of=accept_date)`。PRD 代码块注释写「按受理日批量确认」
|
||
与同节表格口径「可指定业务日」**字面冲突**,取后者(与 R-1 实现一致)。
|
||
⚠️ 批处理的捞单窗口是「受理日 ∈ [业务日上推 SLA 个交易日, 业务日]」的**批量**语义,
|
||
**不是**「精确某一天受理」。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from datetime import datetime
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter, Depends, Query
|
||
|
||
from app.api.deps import (
|
||
AuthContext,
|
||
assert_customer_access,
|
||
deny,
|
||
get_auth_context,
|
||
)
|
||
from app.repository.convert_request_repository import (
|
||
REMARK_FULL_TRANSFER,
|
||
STATUS_CONFIRMED,
|
||
)
|
||
from app.repository.core_ro import CoreReadOnlyRepository
|
||
from app.repository.risk_repository import RiskRepository
|
||
from app.service.convert.confirm_service import confirm_batch
|
||
from app.service.convert.convert_service import (
|
||
cancel_convert,
|
||
rebuild_convert_response,
|
||
t1_t2_dates,
|
||
)
|
||
from app.service.convert.errors import (
|
||
ConcurrentConflict,
|
||
ConvertRequestNotFound,
|
||
)
|
||
from app.service.convert.format import D2, q
|
||
from app.service.convert.types import to_decimal, to_datetime
|
||
from app.utils.exceptions import ApiError
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
#: 撤单 + 查询(PRD §5.5 / §5.7 · 延续 simulate 网关语义)。
|
||
simulate_convert_router = APIRouter(
|
||
prefix="/api/simulate/trade/convert", tags=["convert"]
|
||
)
|
||
#: 确认批处理触发(PRD §5.7 · 运维接口)。
|
||
admin_convert_router = APIRouter(prefix="/api/admin/convert", tags=["convert-admin"])
|
||
|
||
#: PRD §5.3.2 确认结果中**仅确认段才产生**的折算字段白名单。
|
||
#: 未确认的单不得出现这些键(Q2:确认前不返回折算金额)。
|
||
_CONFIRMED_ONLY_FIELDS = (
|
||
"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",
|
||
"out_subscribe_fee_rate",
|
||
"in_subscribe_fee_rate",
|
||
"nav_date",
|
||
"nav_stale",
|
||
"forced_full_transfer",
|
||
)
|
||
|
||
|
||
def _core() -> CoreReadOnlyRepository:
|
||
"""只读仓储(测试 monkeypatch 点)。"""
|
||
return CoreReadOnlyRepository()
|
||
|
||
|
||
def _repo() -> RiskRepository:
|
||
"""审计仓储(deny 留痕用;测试 monkeypatch 点)。"""
|
||
return RiskRepository()
|
||
|
||
|
||
def _require_trade_owner(
|
||
auth: AuthContext, customer_id: str, risk_repo: RiskRepository
|
||
) -> None:
|
||
"""**交易 owner 闸门**(撤单用)· 与 `simulate.py` 下单闸门口径一致。
|
||
|
||
仅「`risk_demo` 演示角色」或「客户本人」放行 —— 代理人(advisor)是
|
||
**查询 scope** 的受益者,**不得**替客户撤单(红线 7:两类闸门不可合并)。
|
||
"""
|
||
if auth.has_role("risk_demo") or (
|
||
auth.is_customer() and auth.customer_id == customer_id
|
||
):
|
||
return
|
||
deny(
|
||
auth,
|
||
"AUTH_403_ROLE",
|
||
risk_repo,
|
||
customer_id=customer_id,
|
||
message="risk_demo or owner customer only",
|
||
agent_type="platform", # 网关越权与放行审计同口径(simulate 复审 P3)
|
||
)
|
||
|
||
|
||
def _load_request_or_404(
|
||
convert_group_id: str, core: CoreReadOnlyRepository
|
||
) -> dict[str, Any]:
|
||
"""读受理单;不存在 → 404 `CONVERT_REQUEST_NOT_FOUND`(PRD §8.3)。"""
|
||
row = core.get_convert_request(convert_group_id)
|
||
if row is None:
|
||
raise ConvertRequestNotFound(f"受理单不存在:{convert_group_id}")
|
||
return row
|
||
|
||
|
||
def _query_response(
|
||
row: dict[str, Any], core: CoreReadOnlyRepository
|
||
) -> dict[str, Any]:
|
||
"""查询响应(PRD §5.3.2;未确认 → 仅受理字段,**无折算金额** · Q2)。
|
||
|
||
`requested_qty` 的取值说明:受理段已把「强制全转」收敛为实转量并记入
|
||
`core_convert_request.qty`,**原始申请量不外存**(审计 summary 有,
|
||
但表内无列)→ 此处 `requested_qty` 即 `qty`(受理承诺量),
|
||
`actual_qty` 取列值(确认段部分成交才写,未确认为 `None`)。
|
||
这是**如实映射**,不臆造「原始申请量」字段。
|
||
"""
|
||
gid = str(row["convert_group_id"])
|
||
status = str(row["status"])
|
||
accept_date = to_datetime(row["requested_at"]).date()
|
||
confirm_date, available_date = t1_t2_dates(accept_date, core)
|
||
remark = str(row.get("remark") or "")
|
||
qty = to_decimal(row["qty"])
|
||
|
||
resp: dict[str, Any] = {
|
||
"convert_group_id": gid,
|
||
"client_request_id": row.get("client_request_id"),
|
||
"status": status,
|
||
"customer_id": str(row["customer_id"]),
|
||
"from_product_id": str(row["from_product_id"]),
|
||
"to_product_id": str(row["to_product_id"]),
|
||
# 受理承诺量(受理段已收敛强制全转);确认段可能因部分成交更小
|
||
"requested_qty": q(qty, D2),
|
||
"actual_qty": q(to_decimal(row["actual_qty"]), D2)
|
||
if row.get("actual_qty") is not None
|
||
else None,
|
||
"accept_date": str(accept_date),
|
||
"confirm_date": confirm_date,
|
||
"available_date": available_date,
|
||
"cancel_deadline": str(row["cancel_before"])
|
||
if row.get("cancel_before") is not None
|
||
else None,
|
||
"requested_at": str(row["requested_at"]),
|
||
"confirmed_at": str(row["confirmed_at"])
|
||
if row.get("confirmed_at") is not None
|
||
else None,
|
||
"remark": remark or None,
|
||
"confirmed": status == STATUS_CONFIRMED,
|
||
}
|
||
# 受理段的强制全转决策**可从未确认的单读出**(`remark` 由受理落库传承)
|
||
resp["forced_full_transfer"] = REMARK_FULL_TRANSFER in remark.split(";")
|
||
|
||
if status == STATUS_CONFIRMED:
|
||
detail = rebuild_convert_response(gid, core_ro=core)
|
||
if detail is None:
|
||
# 状态已 confirmed 但流水不足:属数据完整性异常,留痕不猜数
|
||
logger.error("查询:受理单已 confirmed 但 Core 流水不足,无法重建折算结果:%s", gid)
|
||
else:
|
||
resp.update({k: detail[k] for k in _CONFIRMED_ONLY_FIELDS if k in detail})
|
||
resp["confirmed"] = True
|
||
return resp
|
||
|
||
|
||
@simulate_convert_router.post("/{convert_group_id}/cancel")
|
||
def cancel_convert_api(
|
||
convert_group_id: str,
|
||
auth: AuthContext = Depends(get_auth_context),
|
||
) -> Any:
|
||
"""撤单(PRD §5.5 / FR-C22 · R-9):T 日 15:00 前且 `accepted` → `cancelled`。
|
||
|
||
- 受理单不存在 → 404;非 `accepted` 或已过窗口 → **409 `CANCEL_NOT_ALLOWED`**
|
||
(含「已确认后再撤」—— 状态闸门先于时间闸门,语义更准);
|
||
- 鉴权:**交易 owner 闸门**(本人 / `risk_demo`),代理人不可撤(红线 7);
|
||
- 占用释放:由「未终态受理单」推导(R-3),状态迁到 `cancelled` 即离开该集合,
|
||
**无需写任何份额**(不新增冻结列)。
|
||
"""
|
||
core = _core()
|
||
repo = _repo()
|
||
row = _load_request_or_404(convert_group_id, core)
|
||
_require_trade_owner(auth, str(row["customer_id"]), repo)
|
||
return cancel_convert(
|
||
convert_group_id, core_ro=core, risk_repo=repo, actor_id=auth.actor_id
|
||
)
|
||
|
||
|
||
@simulate_convert_router.get("/{convert_group_id}")
|
||
def query_convert_api(
|
||
convert_group_id: str,
|
||
auth: AuthContext = Depends(get_auth_context),
|
||
) -> Any:
|
||
"""查询受理单与确认结果(PRD §5.7 / §5.3.2)。
|
||
|
||
鉴权:**查询 scope 闸门**(`assert_customer_access`:本人 / 已分配顾问 /
|
||
`risk_officer`)—— 与撤单的 owner 闸门**不可合并**(代理人可查不可撤)。
|
||
"""
|
||
core = _core()
|
||
repo = _repo()
|
||
row = _load_request_or_404(convert_group_id, core)
|
||
assert_customer_access(auth, str(row["customer_id"]), core, repo)
|
||
return _query_response(row, core)
|
||
|
||
|
||
@admin_convert_router.post("/confirm")
|
||
def confirm_convert_api(
|
||
accept_date: str = Query(
|
||
...,
|
||
description=(
|
||
"业务确认日(YYYY-MM-DD)。⚠️ 参数名沿用 PRD §5.7(`accept_date`),"
|
||
"语义取同节表格口径「业务日」= 业务确认日 T+1(裁定 12)。"
|
||
),
|
||
pattern=r"^\d{4}-\d{2}-\d{2}$",
|
||
),
|
||
auth: AuthContext = Depends(get_auth_context),
|
||
) -> Any:
|
||
"""触发 T+1 确认批处理(PRD §5.7 / FR-C23 · R-1)。
|
||
|
||
- 鉴权:**admin 级**(`risk_officer`)—— 触发全量批处理,非客户动作;
|
||
- 串行、单笔容错、整批判处理锁 `convert:confirm:{as_of}`(防双跑);
|
||
- **未抢到锁** → 409 `CONCURRENT_CONFLICT`(而非静默返回 `confirmed=0` 的空成功,
|
||
后者会被误读成「今日无待确认单」);
|
||
- 缺 T 日净值 → 该单 `nav_pending`,本轮跳过,**不 reject、不降级**(FR-C27)。
|
||
"""
|
||
if not auth.has_role("risk_officer"):
|
||
deny(auth, "AUTH_403_ROLE", _repo(), message="risk_officer only")
|
||
as_of = _parse_as_of(accept_date)
|
||
result = confirm_batch(as_of, actor_id=auth.actor_id)
|
||
if not result.get("locked", True):
|
||
# 服务层对「未抢到锁」的既有语义是「让路、本轮不处理」(返回 locked=False,
|
||
# 不抛异常 —— 它是批处理内部的防双跑机制)。API 层将其显式化为 409,
|
||
# 让调用方区分「锁占用」与「确实没有待确认单」。
|
||
raise ConcurrentConflict(
|
||
f"{as_of} 的确认批处理正在执行中(未抢到批处理锁),本轮未处理"
|
||
)
|
||
return result
|
||
|
||
|
||
def _parse_as_of(raw: str) -> Any:
|
||
"""`accept_date` query 参数 → `date`(格式错误 → 400,不走业务异常)。"""
|
||
try:
|
||
return datetime.strptime(raw, "%Y-%m-%d").date()
|
||
except ValueError as exc:
|
||
raise ApiError(
|
||
400, "BAD_REQUEST", f"accept_date 需为 YYYY-MM-DD 格式,实际 {raw!r}"
|
||
) from exc
|
||
|
||
|
||
__all__ = ["simulate_convert_router", "admin_convert_router"]
|