基金转换 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:
2026-09-10 15:42:55 +08:00
co-authored by WorkBuddy
parent 497cee289f
commit 3244922a55
5 changed files with 498 additions and 5 deletions
+85
View File
@@ -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(
+64
View File
@@ -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)
+14
View File
@@ -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 侧)
+317
View File
@@ -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")) == []