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")) == []