FR-C16 份额批次全生命周期 + FR-C25 T+2 可赎回 + redeem 份额申报 D26 落地普通赎回侧: - trade_gateway:_redeem_quote 走 available_qty_with_inflight 硬校验(R-3, 申报超可赎抛 InsufficientShares 不静默裁剪);_maintain_lots redeem 分支 三重裁剪 = T+2 过滤 → D8 补建 → 在途占用 → 哨兵 FIFO 扣减;subscribe 落新批次 - core_ro.list_share_lots / share_lot_repository.select_for_convert 加 available_from T+2 半开区间过滤(R-4,日历缺行降级不过滤,金额/扣减同口径) - gateway_repository 扣批次加哨兵 remain_qty >= :q(rowcount!=1 回滚防超扣) 验证:test_share_lot +10 / test_trade_gateway +1 → 全量 844 passed / 10 skipped (基线 834 + 11 零回归);真库 verify_convert_api 89/89(H 节提前 + H2 在途 占用拦截断言);rebuild_lots --dry-run 58 持仓 0 补建 0 写入;突防验证区分度成立
114 lines
5.3 KiB
Python
114 lines
5.3 KiB
Python
"""份额批次读侧仓储(jinrong_core · 仅 SELECT)· T-3。
|
||
|
||
core_share_lot 的读侧封装:FIFO 选批 + 可用份额汇总。写侧(扣减 / 补建新批)
|
||
归 `convert_core_repository`(D2),本文件不写。
|
||
|
||
D18(单一副本):FIFO 排序规则(confirmed_at ASC, lot_id ASC)的唯一出处是
|
||
`CoreReadOnlyRepository.list_share_lots` 的 ORDER BY —— 本类复用它取批次,
|
||
**不另写一份排序 SQL**,贪心分配只在 Python 端做,避免排序口径漂移。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
from typing import Any
|
||
|
||
from sqlalchemy.engine import Engine
|
||
|
||
from app.config.settings import settings
|
||
from app.repository.core_ro import CoreReadOnlyRepository
|
||
from app.utils.db import get_engine
|
||
|
||
|
||
class ShareLotRepository:
|
||
"""core_share_lot 读侧:FIFO 选批 + 汇总(D2:写侧在 convert_core_repository)。
|
||
|
||
`core_ro` 入参(T-10 补入):普通申赎的批次维护(`trade_gateway`)已经持有
|
||
一个 `CoreReadOnlyRepository`(可能绑 sqlite 测试引擎),直接传入即可复用
|
||
**同一份 FIFO 分配口径** —— 否则 `trade_gateway` 只能另写一份贪心逻辑,
|
||
正是自检第 13 问「同一规则只留一个副本」要防的漂移。传 `core_ro` 时
|
||
`engine` 参数被忽略(读侧一律委托该实例)。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
engine: Engine | None = None,
|
||
core_ro: CoreReadOnlyRepository | None = None,
|
||
) -> None:
|
||
if core_ro is not None:
|
||
self._core = core_ro
|
||
else:
|
||
self._core = CoreReadOnlyRepository(
|
||
engine=engine or get_engine(settings.mysql_core_database, "ro")
|
||
)
|
||
|
||
def select_for_convert(
|
||
self,
|
||
customer_id: str,
|
||
product_id: str,
|
||
qty: Decimal,
|
||
available_from: date | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
"""FIFO 选批:按 confirmed_at ASC, lot_id ASC 贪心覆盖 `qty`。
|
||
|
||
返回被选中的批次(dict:lot_id / qty 本次可用 / nav / confirmed_at)。
|
||
若可用份额不足 `qty`,返回全部可用批次(差额由调用方校验,读侧不抛业务异常)。
|
||
零请求量返回空列表。
|
||
|
||
`available_from`(T-10 · R-4 · FR-C25):**T+2 可扣过滤**透传 `list_share_lots`
|
||
—— 普通赎回只从 `confirmed_at < available_from` 的批次中选。缺省 None
|
||
不过滤(转换受理/确认段 `select_for_convert` 可扣的是**全部在册批次**,
|
||
在途占用已由 `available_qty_with_inflight` 单独体现,两者不冲突)。
|
||
"""
|
||
if qty <= 0:
|
||
return []
|
||
remaining = Decimal(qty)
|
||
selected: list[dict[str, Any]] = []
|
||
for lot in self._core.list_share_lots(
|
||
customer_id, product_id, available_from=available_from
|
||
):
|
||
if remaining <= 0:
|
||
break
|
||
available = Decimal(lot["remain_qty"])
|
||
if available <= 0:
|
||
continue
|
||
take = min(available, remaining)
|
||
selected.append(
|
||
{
|
||
"lot_id": lot["lot_id"],
|
||
"qty": take,
|
||
"nav": Decimal(lot["nav"]),
|
||
"confirmed_at": lot["confirmed_at"],
|
||
}
|
||
)
|
||
remaining -= take
|
||
return selected
|
||
|
||
def available_qty(self, customer_id: str, product_id: str) -> Decimal:
|
||
"""可用份额合计(委托 core_ro.sum_remain_qty,仅统计 remain_qty > 0)。
|
||
|
||
这是**物理在册余量**(不含在途占用)。调用点按用途显式选:
|
||
· 转换**确认段**部分成交判定(confirm_service ④ 步)→ available_qty
|
||
(确认时才真扣份额,占用已在受理段扣过,不能重复扣);
|
||
· 普通赎回的**选批上限** → 见 `_maintain_lots`(T-10 在其内叠加
|
||
T+2 过滤 + 在途占用后裁剪,不直接调本方法判可用)。
|
||
"""
|
||
return self._core.sum_remain_qty(customer_id, product_id)
|
||
|
||
def available_qty_with_inflight(self, customer_id: str, product_id: str) -> Decimal:
|
||
"""**可用份额 = 物理余量 − 在途占用**(R-3 / FR-C21)。
|
||
|
||
在途 = `core_convert_request` 中 status ∈ (accepted, nav_pending) 的 qty 之和,
|
||
由 `core_ro.sum_inflight_qty` 计算。撤单/rejected/expired 自动释放(不再计入)。
|
||
与 `available_qty`(物理余量)语义不同,调用点须按用途显式选:
|
||
· 转换**受理**校验 → available_qty_with_inflight(紧池 80% 病根修复的关键:
|
||
争抢从数据行搬到「客户+产品」短临界区,见开发计划 v2.0 §0.1)
|
||
· **普通赎回**可用校验(T-10 · R-3)→ available_qty_with_inflight:
|
||
已被未终态受理单占用的份额**不可赎回**(同一份份额不能既等转换又已赎回)
|
||
"""
|
||
physical = self.available_qty(customer_id, product_id)
|
||
inflight = self._core.sum_inflight_qty(customer_id, product_id)
|
||
# clamp 到 0:并发窗口下占用短暂超调物理余量时,可用份额按 0 计(受理校验必拒)
|
||
return physical - inflight if physical > inflight else Decimal("0")
|