"""基金转换对外接口(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"]