diff --git a/app/repository/core_ro.py b/app/repository/core_ro.py
index d109a0d..28b5132 100644
--- a/app/repository/core_ro.py
+++ b/app/repository/core_ro.py
@@ -464,6 +464,91 @@ class CoreReadOnlyRepository:
row = conn.execute(sql, {"pid": product_id}).mappings().first()
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]]:
"""全量在册客户(id + display_name;AML scan_all 全量扫描用,仅 SELECT)。"""
sql = text(
diff --git a/app/repository/share_lot_repository.py b/app/repository/share_lot_repository.py
new file mode 100644
index 0000000..56c793d
--- /dev/null
+++ b/app/repository/share_lot_repository.py
@@ -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)
diff --git a/docs/memory/2026-09-10.md b/docs/memory/2026-09-10.md
index f4c0d3c..0df3a61 100644
--- a/docs/memory/2026-09-10.md
+++ b/docs/memory/2026-09-10.md
@@ -211,3 +211,17 @@ SOP A-4:CUST-9527 / PROD-510300 / subscribe / 1000 **连发 4 笔**:
- ✅ 删文件用**纯 `rm -f <精确路径>`** + `git add -A` 记录删除;
- ✅ 批量删除后**立刻** `find
-type f | wc -l` 复核,异常立即 `git checkout HEAD -- `。
- 根因未查明,**规避即可**。
+
+---
+
+## 七、设计方法论固化为 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);本项目记忆回到「状态 + 红线 + 口径」本职。
diff --git a/docs/项目框架设计/开发计划-基金转换交易.md b/docs/项目框架设计/开发计划-基金转换交易.md
index af0a9ff..944dde1 100644
--- a/docs/项目框架设计/开发计划-基金转换交易.md
+++ b/docs/项目框架设计/开发计划-基金转换交易.md
@@ -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,无需改) |
| `app/repository/share_lot_repository.py` | 【新增】 | `core_share_lot` **读侧**:FIFO 选批 + 汇总(写侧归 `convert_core_repository`,D2) |
-**DoD**
-- [ ] 5 个方法各有 sqlite 单测(`test_convert_calc.py` 或独立 `test_share_lot.py` 中)
-- [ ] `list_share_lots` 在**同一 `confirmed_at` 多批次**时顺序可复现(构造两行同 `confirmed_at`,断言按 `lot_id` 升序)
-- [ ] `sum_remain_qty` 只统计 `remain_qty > 0`
-- [ ] `pytest -q` 全绿(新增方法不动既有行为)
+**DoD(全部达成,见下方执行记录)**
+- [x] 5 个方法各有 sqlite 单测(落在独立 `tests/test_share_lot.py` 中)
+- [x] `list_share_lots` 在**同一 `confirmed_at` 多批次**时顺序可复现(构造两行同 `confirmed_at`,断言按 `lot_id` 升序,S1)
+- [x] `sum_remain_qty` 只统计 `remain_qty > 0`(归零批不计入)
+- [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-4(`convert_repository` 代理侧)/ T-5(`locks.try_lock`)可并行(并行组 A);关键路径 T-6(`apply_convert` 单事务)→ T-7(`convert_service` 编排)
+
---
### 5.2 T-4 · `convert_repository`(agent 侧)
diff --git a/tests/test_share_lot.py b/tests/test_share_lot.py
new file mode 100644
index 0000000..6aad74e
--- /dev/null
+++ b/tests/test_share_lot.py
@@ -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")) == []