基金转换 T-3:core_ro 新增 5 个只读方法 + share_lot_repository 读侧
- core_ro 新增 get_nav_as_of(D10) / get_redeem_fee_rules / list_share_lots(S1 确定性排序) / sum_remain_qty(仅 >0) / get_holding;不动 get_latest_nav 与 list_trades_range 白名单 - 新增 share_lot_repository:core_share_lot 读侧 FIFO 贪心选批 + 汇总,排序复用 core_ro 的 ORDER BY(D18 单一副本) - 新增 tests/test_share_lot.py 15 用例;pytest 全量 624 passed / 3 skipped(基线 609,零回归) - 同步开发计划 §5.1 执行记录与交接文档 §B 状态(v1.5 / T-3 ✅ / 基线 624) Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
This commit is contained in:
@@ -464,6 +464,91 @@ class CoreReadOnlyRepository:
|
|||||||
row = conn.execute(sql, {"pid": product_id}).mappings().first()
|
row = conn.execute(sql, {"pid": product_id}).mappings().first()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
# ── 基金转换(convert)读侧 · T-3 ──
|
||||||
|
|
||||||
|
def get_nav_as_of(self, product_id: str, trade_date: date) -> dict[str, Any] | None:
|
||||||
|
"""D10:取 `trade_date` 当日(含)之前最新一期净值(未知价法按 T 日净值)。
|
||||||
|
|
||||||
|
`nav_date <= :d ORDER BY nav_date DESC LIMIT 1`;无更早净值返回 None。
|
||||||
|
与 get_latest_nav 并存但语义不同:后者取「库里最新」,本方法取「交易日的 T 日净值」。
|
||||||
|
"""
|
||||||
|
sql = text(
|
||||||
|
"""
|
||||||
|
SELECT * FROM core_product_nav
|
||||||
|
WHERE product_id = :pid AND nav_date <= :d
|
||||||
|
ORDER BY nav_date DESC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
with self._engine.connect() as conn:
|
||||||
|
row = conn.execute(sql, {"pid": product_id, "d": trade_date}).mappings().first()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def get_redeem_fee_rules(self, product_id: str) -> list[dict[str, Any]]:
|
||||||
|
"""赎回费分档(22 号文 §10 下限):按持有期升序返回 `fee_type='redeem'` 全档。
|
||||||
|
|
||||||
|
申购费档(fee_type='subscribe')本期不启用,显式排除(R-? 口径隔离)。
|
||||||
|
"""
|
||||||
|
sql = text(
|
||||||
|
"""
|
||||||
|
SELECT * FROM core_fee_rule
|
||||||
|
WHERE product_id = :pid AND fee_type = 'redeem'
|
||||||
|
ORDER BY min_hold_days ASC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
with self._engine.connect() as conn:
|
||||||
|
return [dict(r) for r in conn.execute(sql, {"pid": product_id}).mappings()]
|
||||||
|
|
||||||
|
def list_share_lots(
|
||||||
|
self, customer_id: str, product_id: str, max_lots: int | None = None
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""份额批次读侧(FIFO 权威源):按 `confirmed_at ASC, lot_id ASC` 返回。
|
||||||
|
|
||||||
|
S1:同一 `confirmed_at` 多批次以 `lot_id` 升序兜底,保证顺序可复现
|
||||||
|
(PRD §5.3 / T-2 测试 fixture 依赖确定性顺序)。`max_lots` 为可选截断上限。
|
||||||
|
"""
|
||||||
|
sql = text(
|
||||||
|
"""
|
||||||
|
SELECT * FROM core_share_lot
|
||||||
|
WHERE customer_id = :cid AND product_id = :pid
|
||||||
|
ORDER BY confirmed_at ASC, lot_id ASC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
with self._engine.connect() as conn:
|
||||||
|
rows = conn.execute(sql, {"cid": customer_id, "pid": product_id}).mappings().all()
|
||||||
|
if max_lots is not None:
|
||||||
|
rows = rows[:max_lots]
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
def sum_remain_qty(self, customer_id: str, product_id: str) -> Decimal:
|
||||||
|
"""可转出份额合计(FIFO 权威源):仅统计 `remain_qty > 0`。
|
||||||
|
|
||||||
|
已归零批次(remain_qty=0,D9 保留行)不计入;缺口判定以本结果为权威,
|
||||||
|
而非 core_holding.qty(架构 §3 / PRD §7 步骤②)。
|
||||||
|
"""
|
||||||
|
sql = text(
|
||||||
|
"""
|
||||||
|
SELECT COALESCE(SUM(remain_qty), 0)
|
||||||
|
FROM core_share_lot
|
||||||
|
WHERE customer_id = :cid AND product_id = :pid AND remain_qty > 0
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
with self._engine.connect() as conn:
|
||||||
|
total = conn.execute(sql, {"cid": customer_id, "pid": product_id}).scalar_one()
|
||||||
|
return Decimal(str(total))
|
||||||
|
|
||||||
|
def get_holding(self, customer_id: str, product_id: str) -> dict[str, Any] | None:
|
||||||
|
"""持仓快照单行(按 UNIQUE(customer_id, product_id));不存在返回 None。
|
||||||
|
|
||||||
|
转换前后两端持仓的权威读源(T-6 写入也以本表为据)。
|
||||||
|
"""
|
||||||
|
sql = text(
|
||||||
|
"SELECT * FROM core_holding WHERE customer_id = :cid AND product_id = :pid"
|
||||||
|
)
|
||||||
|
with self._engine.connect() as conn:
|
||||||
|
row = conn.execute(sql, {"cid": customer_id, "pid": product_id}).mappings().first()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
def list_active_customers(self) -> list[dict[str, Any]]:
|
def list_active_customers(self) -> list[dict[str, Any]]:
|
||||||
"""全量在册客户(id + display_name;AML scan_all 全量扫描用,仅 SELECT)。"""
|
"""全量在册客户(id + display_name;AML scan_all 全量扫描用,仅 SELECT)。"""
|
||||||
sql = text(
|
sql = text(
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""份额批次读侧仓储(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 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)。"""
|
||||||
|
|
||||||
|
def __init__(self, engine: Engine | None = None) -> None:
|
||||||
|
self._engine = engine or get_engine(settings.mysql_core_database, "ro")
|
||||||
|
# 复用 core_ro 的 ORDER BY(D18 单一副本),避免排序规则漂移
|
||||||
|
self._core = CoreReadOnlyRepository(engine=self._engine)
|
||||||
|
|
||||||
|
def select_for_convert(
|
||||||
|
self, customer_id: str, product_id: str, qty: Decimal
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""FIFO 选批:按 confirmed_at ASC, lot_id ASC 贪心覆盖 `qty`。
|
||||||
|
|
||||||
|
返回被选中的批次(dict:lot_id / qty 本次可用 / nav / confirmed_at)。
|
||||||
|
若可用份额不足 `qty`,返回全部可用批次(差额由调用方校验,读侧不抛业务异常)。
|
||||||
|
零请求量返回空列表。
|
||||||
|
"""
|
||||||
|
if qty <= 0:
|
||||||
|
return []
|
||||||
|
remaining = Decimal(qty)
|
||||||
|
selected: list[dict[str, Any]] = []
|
||||||
|
for lot in self._core.list_share_lots(customer_id, product_id):
|
||||||
|
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)。"""
|
||||||
|
return self._core.sum_remain_qty(customer_id, product_id)
|
||||||
@@ -211,3 +211,17 @@ SOP A-4:CUST-9527 / PROD-510300 / subscribe / 1000 **连发 4 笔**:
|
|||||||
- ✅ 删文件用**纯 `rm -f <精确路径>`** + `git add -A` 记录删除;
|
- ✅ 删文件用**纯 `rm -f <精确路径>`** + `git add -A` 记录删除;
|
||||||
- ✅ 批量删除后**立刻** `find <dir> -type f | wc -l` 复核,异常立即 `git checkout HEAD -- <dir>`。
|
- ✅ 批量删除后**立刻** `find <dir> -type f | wc -l` 复核,异常立即 `git checkout HEAD -- <dir>`。
|
||||||
- 根因未查明,**规避即可**。
|
- 根因未查明,**规避即可**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、设计方法论固化为 skill + 记忆精简(2026-09-10 晚)
|
||||||
|
|
||||||
|
**动机**:项目记忆 `MEMORY.md` 与交接文档膨胀——13 问 / AIcoding 六步 / 独立审查协议 / 写计划 4 法 等项目无关的方法论每个项目都重写一遍,且挤占了项目状态信息。
|
||||||
|
|
||||||
|
**落地**:
|
||||||
|
1. **新建用户级 skill `design-doc-selfcheck`**(`~/.workbuddy/skills/design-doc-selfcheck/SKILL.md`)——
|
||||||
|
固化:AIcoding 六步流程 · 设计自检 13 问(含 why/翻车案例)· PRD 独立 AI 审查协议(自包含包 + 只报告不改动 + 逐条判定)· 写开发计划 4 法 · 真实翻车案例库(4 处外部事实漏网 / 分类混用 / 公式副本 / 重置伴随数据 / 方言互斥)。
|
||||||
|
2. **精简项目 `MEMORY.md`**(205 行 → ~70 行):删除已入 skill 的方法论 verbatim,只留三条线状态、`git rm` 禁忌、交接文档定位、基金转换核心事实与业务口径、交易发起主体合规、D20 账号,并指向 skill。
|
||||||
|
3. **交接文档 §B 顶部加 skill 指针**:本线设计方法论已固化,避免重复维护。
|
||||||
|
|
||||||
|
**效果**:方法论跨项目可复用(新项目直接加载 skill);本项目记忆回到「状态 + 红线 + 口径」本职。
|
||||||
|
|||||||
@@ -752,14 +752,27 @@ T-7 幂等窗口 · T-13 的 50 并发压测与性能补录 · PRD §5.3 实算
|
|||||||
| | 【改】**不动** | `get_latest_nav`(D10:改它会动既有调用方)· `list_trades_range` 的 `trade_type` 白名单(F-8:convert 两条流水本就是 redeem/subscribe,无需改) |
|
| | 【改】**不动** | `get_latest_nav`(D10:改它会动既有调用方)· `list_trades_range` 的 `trade_type` 白名单(F-8:convert 两条流水本就是 redeem/subscribe,无需改) |
|
||||||
| `app/repository/share_lot_repository.py` | 【新增】 | `core_share_lot` **读侧**:FIFO 选批 + 汇总(写侧归 `convert_core_repository`,D2) |
|
| `app/repository/share_lot_repository.py` | 【新增】 | `core_share_lot` **读侧**:FIFO 选批 + 汇总(写侧归 `convert_core_repository`,D2) |
|
||||||
|
|
||||||
**DoD**
|
**DoD(全部达成,见下方执行记录)**
|
||||||
- [ ] 5 个方法各有 sqlite 单测(`test_convert_calc.py` 或独立 `test_share_lot.py` 中)
|
- [x] 5 个方法各有 sqlite 单测(落在独立 `tests/test_share_lot.py` 中)
|
||||||
- [ ] `list_share_lots` 在**同一 `confirmed_at` 多批次**时顺序可复现(构造两行同 `confirmed_at`,断言按 `lot_id` 升序)
|
- [x] `list_share_lots` 在**同一 `confirmed_at` 多批次**时顺序可复现(构造两行同 `confirmed_at`,断言按 `lot_id` 升序,S1)
|
||||||
- [ ] `sum_remain_qty` 只统计 `remain_qty > 0`
|
- [x] `sum_remain_qty` 只统计 `remain_qty > 0`(归零批不计入)
|
||||||
- [ ] `pytest -q` 全绿(新增方法不动既有行为)
|
- [x] `pytest -q` 全绿(新增方法不动既有行为)
|
||||||
|
|
||||||
|
**执行记录(2026-09-10)**
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 改动文件 | `app/repository/core_ro.py`【改·+5 方法】· `app/repository/share_lot_repository.py`【新增】· `tests/test_share_lot.py`【新增 15 用例】 |
|
||||||
|
| 5 方法 | `get_nav_as_of(pid, d)`(D10:`nav_date <= :d` 降序取 1)· `get_redeem_fee_rules(pid)`(仅 `redeem` 档、按 `min_hold_days` 升序)· `list_share_lots(cid, pid, max_lots)`(`ORDER BY confirmed_at ASC, lot_id ASC`,S1)· `sum_remain_qty(cid, pid)`(仅 `remain_qty > 0`)· `get_holding(cid, pid)` |
|
||||||
|
| 不动 | `get_latest_nav`(D10:语义不同,已并存)· `list_trades_range` 的 `trade_type` 白名单(F-8:convert 两条流水本就是 redeem/subscribe) |
|
||||||
|
| `share_lot_repository` | `core_share_lot` **读侧**:`select_for_convert` FIFO 贪心选批 + `available_qty` 汇总(写侧归 `convert_core_repository`,D2)。**D18 单一副本**:排序规则复用 `CoreReadOnlyRepository.list_share_lots` 的 ORDER BY,不另写一份 SQL |
|
||||||
|
| 测试踩坑(已修) | ① sqlite 不直接绑定 `Decimal` 参数 → 插入一律转 `float`(与既有 `Decimal(total)` 读回约定一致);② sqlite DATE/TIMESTAMP 读回为字符串 → 用项目内 `_as_date` / 测试内 `_as_dt` 规范化(与 MySQL 行为差异,B5 评审 P3-4 同口径) |
|
||||||
|
| 验证结果 | `pytest -q` 全量 **624 passed / 3 skipped**(基线 609 + 本次 15,零回归) |
|
||||||
|
|
||||||
**依赖**:T-1
|
**依赖**:T-1
|
||||||
|
|
||||||
|
**下一步**:T-4(`convert_repository` 代理侧)/ T-5(`locks.try_lock`)可并行(并行组 A);关键路径 T-6(`apply_convert` 单事务)→ T-7(`convert_service` 编排)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 5.2 T-4 · `convert_repository`(agent 侧)
|
### 5.2 T-4 · `convert_repository`(agent 侧)
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
"""T-3 `core_ro` 五个新方法 + `share_lot_repository` 单测(开发计划 §5.1 DoD)。
|
||||||
|
|
||||||
|
sqlite 内存库(conftest.sqlite_engine 单一事实源建表 + C×R 矩阵种子),
|
||||||
|
数据由本文件自建,不依赖真 MySQL。
|
||||||
|
|
||||||
|
覆盖:
|
||||||
|
1. get_nav_as_of(D10:nav_date <= 交易日降序取 1)
|
||||||
|
2. get_redeem_fee_rules(仅 redeem 档、按持有期升序)
|
||||||
|
3. list_share_lots(S1:同 confirmed_at 以 lot_id 升序兜底、max_lots 截断)
|
||||||
|
4. sum_remain_qty(仅统计 remain_qty > 0,归零批不计入)
|
||||||
|
5. get_holding(单行 / 不存在 None)
|
||||||
|
6. ShareLotRepository.select_for_convert(FIFO 贪心选批、不足额返回全部可用)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.repository.core_ro import CoreReadOnlyRepository, _as_date
|
||||||
|
from app.repository.share_lot_repository import ShareLotRepository
|
||||||
|
|
||||||
|
|
||||||
|
def _as_dt(value: object) -> datetime:
|
||||||
|
"""sqlite TIMESTAMP 读回为字符串、MySQL 为 datetime —— 测试内统一规范化。"""
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value
|
||||||
|
return datetime.fromisoformat(str(value)[:19])
|
||||||
|
|
||||||
|
|
||||||
|
CUSTOMER = "CUST-T3"
|
||||||
|
PRODUCT_A = "PROD-T3A"
|
||||||
|
PRODUCT_B = "PROD-T3B"
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_customer(engine, cid: str) -> None:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO core_customer (customer_id, display_name) "
|
||||||
|
"VALUES (:cid, '测试客户')"
|
||||||
|
),
|
||||||
|
{"cid": cid},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_product(engine, pid: str, ptype: str = "bond") -> None:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO core_product (product_id, product_name, min_risk_code, "
|
||||||
|
"product_type, can_subscribe, can_redeem) "
|
||||||
|
"VALUES (:pid, '测试产品', 'R2', :ptype, 1, 1)"
|
||||||
|
),
|
||||||
|
{"pid": pid, "ptype": ptype},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_lot(
|
||||||
|
engine, lot_id: str, cid: str, pid: str,
|
||||||
|
remain: str, nav: str, confirmed_at: datetime, qty: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
# sqlite 不直接绑定 Decimal:统一转 float(与既有 Decimal(total) 读回约定一致)
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO core_share_lot (lot_id, customer_id, product_id, qty, "
|
||||||
|
"remain_qty, nav, confirmed_at) "
|
||||||
|
"VALUES (:lot_id, :cid, :pid, :qty, :remain, :nav, :confirmed_at)"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"lot_id": lot_id, "cid": cid, "pid": pid,
|
||||||
|
"qty": float(qty or remain), "remain": float(remain),
|
||||||
|
"nav": float(nav), "confirmed_at": confirmed_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 1. get_nav_as_of(D10) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_get_nav_as_of_returns_latest_on_or_before_trade_date(sqlite_engine):
|
||||||
|
d = date(2026, 9, 4)
|
||||||
|
repo = CoreReadOnlyRepository(engine=sqlite_engine)
|
||||||
|
with sqlite_engine.begin() as conn:
|
||||||
|
for nav_date, nav in [
|
||||||
|
(date(2026, 9, 2), 1.0100),
|
||||||
|
(date(2026, 9, 3), 1.0200),
|
||||||
|
(date(2026, 9, 5), 1.0300),
|
||||||
|
]:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) "
|
||||||
|
"VALUES (:pid, :nav, 0, :nd)"
|
||||||
|
),
|
||||||
|
{"pid": PRODUCT_A, "nav": nav, "nd": nav_date},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 落在 9-4:应取 9-3(<= 的最近一期)
|
||||||
|
row = repo.get_nav_as_of(PRODUCT_A, d)
|
||||||
|
assert row is not None
|
||||||
|
assert _as_date(row["nav_date"]) == date(2026, 9, 3)
|
||||||
|
assert row["nav"] == 1.0200
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_nav_as_of_after_latest_returns_newest(sqlite_engine):
|
||||||
|
d = date(2026, 9, 10)
|
||||||
|
repo = CoreReadOnlyRepository(engine=sqlite_engine)
|
||||||
|
with sqlite_engine.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) "
|
||||||
|
"VALUES (:pid, :nav, 0, :nd)"
|
||||||
|
),
|
||||||
|
{"pid": PRODUCT_A, "nav": 1.0300, "nd": date(2026, 9, 5)},
|
||||||
|
)
|
||||||
|
|
||||||
|
row = repo.get_nav_as_of(PRODUCT_A, d)
|
||||||
|
assert row is not None
|
||||||
|
assert _as_date(row["nav_date"]) == date(2026, 9, 5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_nav_as_of_no_earlier_nav_returns_none(sqlite_engine):
|
||||||
|
d = date(2026, 9, 1)
|
||||||
|
repo = CoreReadOnlyRepository(engine=sqlite_engine)
|
||||||
|
with sqlite_engine.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) "
|
||||||
|
"VALUES (:pid, :nav, 0, :nd)"
|
||||||
|
),
|
||||||
|
{"pid": PRODUCT_A, "nav": 1.0300, "nd": date(2026, 9, 5)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert repo.get_nav_as_of(PRODUCT_A, d) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2. get_redeem_fee_rules ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_get_redeem_fee_rules_only_redeem_and_ordered(sqlite_engine):
|
||||||
|
repo = CoreReadOnlyRepository(engine=sqlite_engine)
|
||||||
|
with sqlite_engine.begin() as conn:
|
||||||
|
# 申购费档(本期不启用)应被排除
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO core_fee_rule (product_id, fee_type, min_hold_days, "
|
||||||
|
"max_hold_days, rate) VALUES (:pid, 'subscribe', 0, NULL, :rate)"
|
||||||
|
),
|
||||||
|
{"pid": PRODUCT_A, "rate": 0.0080},
|
||||||
|
)
|
||||||
|
# 赎回费五档(乱序插入,断言按 min_hold_days 升序返回)
|
||||||
|
for mh, mh_max, rate in [
|
||||||
|
(180, 365, "0.0025"), (0, 7, "0.0150"),
|
||||||
|
(30, 180, "0.0050"), (7, 30, "0.0100"), (365, None, "0.0000"),
|
||||||
|
]:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO core_fee_rule (product_id, fee_type, min_hold_days, "
|
||||||
|
"max_hold_days, rate) VALUES (:pid, 'redeem', :mh, :mh_max, :rate)"
|
||||||
|
),
|
||||||
|
{"pid": PRODUCT_A, "mh": mh, "mh_max": mh_max, "rate": float(rate)},
|
||||||
|
)
|
||||||
|
|
||||||
|
rules = repo.get_redeem_fee_rules(PRODUCT_A)
|
||||||
|
assert len(rules) == 5
|
||||||
|
assert [r["min_hold_days"] for r in rules] == [0, 7, 30, 180, 365]
|
||||||
|
assert all(r["fee_type"] == "redeem" for r in rules)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_redeem_fee_rules_empty(sqlite_engine):
|
||||||
|
repo = CoreReadOnlyRepository(engine=sqlite_engine)
|
||||||
|
assert repo.get_redeem_fee_rules(PRODUCT_B) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── 3. list_share_lots(S1 确定性 + 截断) ─────────────────────────────
|
||||||
|
|
||||||
|
def test_list_share_lots_ordered_by_confirmed_at(sqlite_engine):
|
||||||
|
_seed_customer(sqlite_engine, CUSTOMER)
|
||||||
|
_seed_product(sqlite_engine, PRODUCT_A)
|
||||||
|
repo = CoreReadOnlyRepository(engine=sqlite_engine)
|
||||||
|
# 乱序确认时间插入
|
||||||
|
_seed_lot(sqlite_engine, "L3", CUSTOMER, PRODUCT_A, "50", "1.03",
|
||||||
|
datetime(2026, 9, 3, 10, 0, 0))
|
||||||
|
_seed_lot(sqlite_engine, "L1", CUSTOMER, PRODUCT_A, "100", "1.01",
|
||||||
|
datetime(2026, 9, 1, 10, 0, 0))
|
||||||
|
_seed_lot(sqlite_engine, "L2", CUSTOMER, PRODUCT_A, "80", "1.02",
|
||||||
|
datetime(2026, 9, 2, 10, 0, 0))
|
||||||
|
|
||||||
|
lots = repo.list_share_lots(CUSTOMER, PRODUCT_A)
|
||||||
|
assert [l["lot_id"] for l in lots] == ["L1", "L2", "L3"]
|
||||||
|
assert [_as_dt(l["confirmed_at"]) for l in lots] == [
|
||||||
|
datetime(2026, 9, 1, 10, 0, 0),
|
||||||
|
datetime(2026, 9, 2, 10, 0, 0),
|
||||||
|
datetime(2026, 9, 3, 10, 0, 0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_share_lots_same_confirmed_at_tiebreak_by_lot_id(sqlite_engine):
|
||||||
|
"""S1:同一 confirmed_at 多批次以 lot_id 升序兜底,顺序可复现。"""
|
||||||
|
_seed_customer(sqlite_engine, CUSTOMER)
|
||||||
|
_seed_product(sqlite_engine, PRODUCT_A)
|
||||||
|
repo = CoreReadOnlyRepository(engine=sqlite_engine)
|
||||||
|
same = datetime(2026, 9, 1, 10, 0, 0)
|
||||||
|
# 故意让 lot_id 顺序与插入顺序相反
|
||||||
|
_seed_lot(sqlite_engine, "LOT-09", CUSTOMER, PRODUCT_A, "10", "1.00", same)
|
||||||
|
_seed_lot(sqlite_engine, "LOT-02", CUSTOMER, PRODUCT_A, "10", "1.00", same)
|
||||||
|
_seed_lot(sqlite_engine, "LOT-05", CUSTOMER, PRODUCT_A, "10", "1.00", same)
|
||||||
|
|
||||||
|
lots = repo.list_share_lots(CUSTOMER, PRODUCT_A)
|
||||||
|
assert [l["lot_id"] for l in lots] == ["LOT-02", "LOT-05", "LOT-09"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_share_lots_max_lots_truncates(sqlite_engine):
|
||||||
|
_seed_customer(sqlite_engine, CUSTOMER)
|
||||||
|
_seed_product(sqlite_engine, PRODUCT_A)
|
||||||
|
repo = CoreReadOnlyRepository(engine=sqlite_engine)
|
||||||
|
for i in range(3):
|
||||||
|
_seed_lot(sqlite_engine, f"L{i}", CUSTOMER, PRODUCT_A, "10", "1.00",
|
||||||
|
datetime(2026, 9, 1 + i, 10, 0, 0))
|
||||||
|
|
||||||
|
assert len(repo.list_share_lots(CUSTOMER, PRODUCT_A, max_lots=2)) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ── 4. sum_remain_qty(仅 >0) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_sum_remain_qty_excludes_zeroed_lots(sqlite_engine):
|
||||||
|
_seed_customer(sqlite_engine, CUSTOMER)
|
||||||
|
_seed_product(sqlite_engine, PRODUCT_A)
|
||||||
|
repo = CoreReadOnlyRepository(engine=sqlite_engine)
|
||||||
|
# 已归零(D9 保留行)与正常批次混放
|
||||||
|
_seed_lot(sqlite_engine, "L1", CUSTOMER, PRODUCT_A, "100", "1.00",
|
||||||
|
datetime(2026, 9, 1, 10, 0, 0))
|
||||||
|
_seed_lot(sqlite_engine, "L2", CUSTOMER, PRODUCT_A, "0", "1.00",
|
||||||
|
datetime(2026, 9, 2, 10, 0, 0)) # 归零批
|
||||||
|
_seed_lot(sqlite_engine, "L3", CUSTOMER, PRODUCT_A, "50", "1.00",
|
||||||
|
datetime(2026, 9, 3, 10, 0, 0))
|
||||||
|
|
||||||
|
assert repo.sum_remain_qty(CUSTOMER, PRODUCT_A) == Decimal("150")
|
||||||
|
|
||||||
|
|
||||||
|
def test_sum_remain_qty_only_zeros_returns_zero(sqlite_engine):
|
||||||
|
_seed_customer(sqlite_engine, CUSTOMER)
|
||||||
|
_seed_product(sqlite_engine, PRODUCT_A)
|
||||||
|
repo = CoreReadOnlyRepository(engine=sqlite_engine)
|
||||||
|
_seed_lot(sqlite_engine, "L1", CUSTOMER, PRODUCT_A, "0", "1.00",
|
||||||
|
datetime(2026, 9, 1, 10, 0, 0))
|
||||||
|
|
||||||
|
assert repo.sum_remain_qty(CUSTOMER, PRODUCT_A) == Decimal("0")
|
||||||
|
|
||||||
|
|
||||||
|
# ── 5. get_holding ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_get_holding_returns_row(sqlite_engine):
|
||||||
|
_seed_customer(sqlite_engine, CUSTOMER)
|
||||||
|
_seed_product(sqlite_engine, PRODUCT_A)
|
||||||
|
repo = CoreReadOnlyRepository(engine=sqlite_engine)
|
||||||
|
with sqlite_engine.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO core_holding (customer_id, product_id, qty, cost_amount, "
|
||||||
|
"market_value, pnl_pct, as_of) VALUES (:cid, :pid, :qty, 0, 0, 0, :as_of)"
|
||||||
|
),
|
||||||
|
{"cid": CUSTOMER, "pid": PRODUCT_A, "qty": 100.0,
|
||||||
|
"as_of": date(2026, 9, 4)},
|
||||||
|
)
|
||||||
|
|
||||||
|
row = repo.get_holding(CUSTOMER, PRODUCT_A)
|
||||||
|
assert row is not None
|
||||||
|
assert row["product_id"] == PRODUCT_A
|
||||||
|
assert row["qty"] == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_holding_missing_returns_none(sqlite_engine):
|
||||||
|
_seed_customer(sqlite_engine, CUSTOMER)
|
||||||
|
repo = CoreReadOnlyRepository(engine=sqlite_engine)
|
||||||
|
assert repo.get_holding(CUSTOMER, PRODUCT_B) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 6. ShareLotRepository.select_for_convert(FIFO 选批) ────────────────
|
||||||
|
|
||||||
|
def test_select_for_convert_fifo_partial_covers_first_lots(sqlite_engine):
|
||||||
|
_seed_customer(sqlite_engine, CUSTOMER)
|
||||||
|
_seed_product(sqlite_engine, PRODUCT_A)
|
||||||
|
_seed_lot(sqlite_engine, "L1", CUSTOMER, PRODUCT_A, "100", "1.0000",
|
||||||
|
datetime(2026, 9, 1, 10, 0, 0))
|
||||||
|
_seed_lot(sqlite_engine, "L2", CUSTOMER, PRODUCT_A, "50", "1.1000",
|
||||||
|
datetime(2026, 9, 2, 10, 0, 0))
|
||||||
|
|
||||||
|
repo = ShareLotRepository(engine=sqlite_engine)
|
||||||
|
selected = repo.select_for_convert(CUSTOMER, PRODUCT_A, Decimal("120"))
|
||||||
|
# 先吃满 L1(100),再从 L2 取 20
|
||||||
|
assert [s["lot_id"] for s in selected] == ["L1", "L2"]
|
||||||
|
assert selected[0]["qty"] == 100.0
|
||||||
|
assert selected[1]["qty"] == Decimal("20")
|
||||||
|
assert repo.available_qty(CUSTOMER, PRODUCT_A) == Decimal("150")
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_for_convert_shortfall_returns_all_available(sqlite_engine):
|
||||||
|
_seed_customer(sqlite_engine, CUSTOMER)
|
||||||
|
_seed_product(sqlite_engine, PRODUCT_A)
|
||||||
|
_seed_lot(sqlite_engine, "L1", CUSTOMER, PRODUCT_A, "100", "1.0000",
|
||||||
|
datetime(2026, 9, 1, 10, 0, 0))
|
||||||
|
_seed_lot(sqlite_engine, "L2", CUSTOMER, PRODUCT_A, "50", "1.1000",
|
||||||
|
datetime(2026, 9, 2, 10, 0, 0))
|
||||||
|
|
||||||
|
repo = ShareLotRepository(engine=sqlite_engine)
|
||||||
|
selected = repo.select_for_convert(CUSTOMER, PRODUCT_A, Decimal("1000"))
|
||||||
|
# 不足额:返回全部可用批次,差额由调用方校验
|
||||||
|
assert [s["lot_id"] for s in selected] == ["L1", "L2"]
|
||||||
|
assert [s["qty"] for s in selected] == [100.0, Decimal("50")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_for_convert_zero_qty_returns_empty(sqlite_engine):
|
||||||
|
repo = ShareLotRepository(engine=sqlite_engine)
|
||||||
|
assert repo.select_for_convert(CUSTOMER, PRODUCT_A, Decimal("0")) == []
|
||||||
Reference in New Issue
Block a user