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
170 lines
6.1 KiB
Python
170 lines
6.1 KiB
Python
"""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",
|
||
]
|