Files
group_xinghuo_jinrong/app/service/convert/errors.py
T
GaoYiYuan_0626 c5182f1910 feat(convert): 基金转换 T-0/T-0b 门禁 + T-1 数据层 + T-2/T-2b 纯函数与实算回填
T-0 / T-0b(门禁 · 2026-09-10)
- T-0:sqlite 与 MySQL 结构对齐 —— core_holding 统一为 qty/cost_amount/as_of/pnl_pct
  + PK + UNIQUE(customer_id, product_id);补 core_product_nav;新增建库自校验
  _assert_ddl_aligned()(R-g);test_db.py 增 3 条门禁用例(含反向验证门禁失效)
- T-0b:DB 账号分离(D20)—— 新增 scripts/core/00-grant.sql(三账号逐表授权);
  settings.py 增 3 组账号;db.py 改 get_engine(db, role),缓存键改为 (库名, 角色),
  账号未配置回退单账号;core_ro→ro / gateway_repository→rw / risk·session_repository→rw;
  tests/conftest.py 四处显式 role="admin"(R-e)

T-1(数据层)
- scripts/core/01-ddl.sql:新建 core_fee_rule / core_share_lot / core_convert_lot_detail;
  core_trade 加 convert_group_id + idx_convert_group;core_product 加 8 列 + fee_rate 补 COMMENT
- 新增 07-seed-fee-rule.sql(赎回费 5 档 × 14 产品,按 22 号文 §10)/ 08-seed-share-lot.sql
  (58 行持仓 → 61 行批次,Σ remain_qty 恒等于 qty)/ 09-seed-org.sql(管理人 + TA +
  申购费率 + 最低持有余额,v1.1 按「管理人全产品线」重排)
- reset.ps1 追加 07/08/09;02-mysql-agent专用.sql 追加 risk_convert_detail
- tests/_ddl.py 同步 4 表 + 新增 REQUIRED_CONVERT_TABLES 建库门禁
- 新增 scripts/dev/verify_convert_seed.py(pymysql 等价 reset 流程 + 8 条 DoD 断言,
  含断言 ⑧「费率档 ↔ product_type 匹配」,越档即 FAIL)

T-2 / T-2b(纯函数包 + 示例实算回填)
- 新增 app/service/convert/ 7 文件:__init__ / types / calc / fee / nav / lot_bootstrap / errors
  (纯函数,不查库、不碰 SQL;所有量化显式 ROUND_HALF_UP;lot_bootstrap 用 zlib.crc32
   保证 D18 跨进程同源)
- 新增 tests/test_convert_calc.py 93 用例(12 类:HALF_UP 反向自证 / 分档边界 /
  FIFO 含同 confirmed_at 兜底 / 双口径 / 强制全转与强制赎回 / PRD §5.3 全链自证 /
  纯函数零 IO 依赖断言)
- 重写 scripts/dev/calc_convert_demo.py:去掉脚本内公式副本,改为调用生产 calc.py,
  末尾与 PRD §5.3 逐项比对(不一致即退出码 1),兼作一致性门禁

验证
- pytest 609 passed / 3 skipped(516 → +93,零回归)
- verify_convert_seed.py 8/8 PASS;calc_convert_demo.py 15/15 与 PRD §5.3 一致

文档:PRD v0.9.1(费率分类修正)· 架构 §7 签名回填 / §8.3 错误码注 / §15 T-2 完成 ·
开发计划 §1.5 新增 R-h + §4.2·§4.3 执行记录 · AGENTS.md · docs/memory
2026-09-10 14:45:55 +08:00

170 lines
6.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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",
]