"""convert 专属异常(架构 §8.3 错误码映射)。 全部继承 `app.utils.exceptions.ApiError` —— 复用 `app/main.py` 已有的 `register_error_handlers`,统一输出 `{error_code, message, trace_id, request_id}` 错误体, **不新增异常出口、不改中间件**。 两点必须分清(PRD L-4 / §8.3): 1. **技术故障码不是业务阻断**。`NavNotReady`(503)、`LotConflict`(409)、 `IdempotencyUnavailable`(503) 表示系统瞬时状态,恢复后重试即可成功, **不适用「仅 R-02 可阻断交易」的业务铁律**。 2. **`extra` 只承载结构性附加字段**(如 `TOO_MANY_LOTS` 的 `batch_count`/`max_lots`), 由 T-7 的 `api/simulate.py` 分支展开进错误体;本包不构造响应。 """ from __future__ import annotations from typing import Any from app.utils.exceptions import ApiError class ConvertError(ApiError): """convert 错误共同基类(便于 service 层统一 `except ConvertError` 分流)。 子类通过类属性声明 `status_code` / `error_code`,无需逐个重写 `__init__`。 """ status_code: int = 400 error_code: str = "BAD_REQUEST" default_message: str = "基金转换请求被拒绝" def __init__(self, message: str | None = None, *, extra: dict[str, Any] | None = None) -> None: super().__init__(self.status_code, self.error_code, message or self.default_message) #: 附加字段(供错误体展开,如 batch_count / max_lots);不参与 message。 self.extra: dict[str, Any] = dict(extra or {}) # ── 400:业务校验类(HTTP 400 · §8.3 前 7 条)───────────────────────── class ProductNotRedeemable(ConvertError): """转出方不可赎回(`core_product.can_redeem = 0`)。""" error_code = "PRODUCT_NOT_REDEEMABLE" default_message = "转出基金当前不可赎回" class ProductNotSubscribable(ConvertError): """转入方不可申购(`core_product.can_subscribe = 0`)。""" error_code = "PRODUCT_NOT_SUBSCRIBABLE" default_message = "转入基金当前不可申购" class InsufficientShares(ConvertError): """可转份额不足(`Σ remain_qty < 申请份额` · PRD §12)。""" error_code = "INSUFFICIENT_SHARES" default_message = "可转份额不足" class BelowMinQty(ConvertError): """低于最低转出份额(申请份额 == 全部可转份额时豁免 `min_redeem_qty`)。""" error_code = "BELOW_MIN_QTY" default_message = "低于该基金的最低转出份额" class SameProduct(ConvertError): """转出与转入为同一产品。""" error_code = "SAME_PRODUCT" default_message = "转出与转入基金不能为同一产品" class CrossEntityNotSupported(ConvertError): """跨主体转换(不满足 同销售机构 + 同管理人 + 同 TA)。""" error_code = "CROSS_ENTITY_NOT_SUPPORTED" default_message = "仅支持同一销售机构、同一管理人、同一注册登记机构的基金互转" class TooManyLots(ConvertError): """跨越批次数超上限(`convert_batch_max_lots`,默认 200)。 一期**不做自动分拆**(§1 原则 12:单笔请求 = 单事务 = 单 `convert_group_id`), 故把实际所需批次数与上限一并回给前端,提示「请拆分多笔申请」。 `extra` 与 PRD「执行期风险 #5」的响应体约定一致: `{"error_code":"TOO_MANY_LOTS","batch_count":<实际>,"max_lots":200}`。 """ error_code = "TOO_MANY_LOTS" default_message = "本次转换跨越的批次数超过上限,请拆分多笔申请" def __init__( self, batch_count: int, max_lots: int, message: str | None = None, ) -> None: super().__init__( message or f"本次转换需跨 {batch_count} 个批次,超过上限 {max_lots},请拆分多笔申请", extra={"batch_count": int(batch_count), "max_lots": int(max_lots)}, ) self.batch_count = int(batch_count) self.max_lots = int(max_lots) # ── 409 / 503:技术故障类(**非业务阻断** · PRD L-4)────────────────── class LotConflict(ConvertError): """批次条件 UPDATE 的 `rowcount != 1`(并发扣减冲突)。 调用方按 100/200/400ms 退避重试,建议 ≤3 次(§8.3)。 """ status_code = 409 error_code = "LOT_CONFLICT" default_message = "份额批次并发冲突,请重试" class NavNotReady(ConvertError): """该产品无任何净值记录(PRD §8.3:503 `NAV_NOT_READY`)。 ⚠️ **`nav_stale`(净值过期)不是本异常**:净值过期只额外落一条 `nav_stale` 审计并继续折算(PRD §2.2),只有**完全没有净值**才阻断。 """ status_code = 503 error_code = "NAV_NOT_READY" default_message = "该基金尚无可用净值,暂时无法转换" class IdempotencyUnavailable(ConvertError): """幂等执行权获取失败且无法判定状态(如 Redis 与占位表同时不可用)。""" status_code = 503 error_code = "IDEMPOTENCY_UNAVAILABLE" default_message = "幂等校验服务暂不可用,请稍后重试" # ── 500:内部兜底(§8.3 表之外 · 仅数据缺失时触发)──────────────────── class FeeRuleMissing(ConvertError): """未匹配到任何赎回费率档 —— **属数据完整性事故,不是业务分支**。 触发条件:`core_fee_rule` 缺少该产品的 `[0, 7)` 档(种子漏灌或产品未配档)。 真实费用规则必含最低档,故此处**不降级为 0 费率**——静默按 0 计费会 少收赎回费且不留痕,比直接失败危险得多。 """ status_code = 500 error_code = "FEE_RULE_MISSING" default_message = "未匹配到赎回费率规则(费率表数据缺失)" __all__ = [ "ConvertError", "ProductNotRedeemable", "ProductNotSubscribable", "InsufficientShares", "BelowMinQty", "SameProduct", "CrossEntityNotSupported", "TooManyLots", "LotConflict", "NavNotReady", "IdempotencyUnavailable", "FeeRuleMissing", ]