- Introduced `convert_meta_api` endpoint to fetch the latest NAV date for conversion processes, restricted to users with the "risk_officer" role. - Updated `created_at` field in `AgentMessage` to use UTC timezone for consistency in timestamp handling. - Added `get_max_product_nav_date` method in `CoreReadOnlyRepository` to support the new API functionality. - Enhanced Milvus template loading in `MilvusTemplateVectorStore` to ensure collections are loaded when they exist. This update improves the API's capability to handle conversion metadata and ensures accurate timestamp management across the application.
300 lines
12 KiB
Python
300 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
|
||
|
||
|
||
@admin_convert_router.get("/meta")
|
||
def convert_meta_api(
|
||
auth: AuthContext = Depends(get_auth_context),
|
||
) -> dict[str, Any]:
|
||
"""转换演示元数据:Core 净值库最新 nav_date(确认批处理默认日)。"""
|
||
if not auth.has_role("risk_officer"):
|
||
deny(auth, "AUTH_403_ROLE", _repo(), message="risk_officer only")
|
||
latest = _core().get_max_product_nav_date()
|
||
return {
|
||
"latest_nav_date": latest.isoformat() if latest else None,
|
||
}
|
||
|
||
|
||
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"]
|