diff --git a/AGENTS.md b/AGENTS.md
index 5ac91bb..60e262f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -20,6 +20,8 @@
- 技术选型 / JWT:`docs/项目框架设计/技术选型和版本/`
- Core 模拟底座:`docs/项目框架设计/Core模拟底座/`
- 业务记忆分层:`docs/业务记忆管理/业务记忆管理手册.md`
+- **基金转换线(第 5 步进行中 · T-0/T-0b/T-1/T-2/T-2b 已完成 · 下一步 T-3)**:开工入口 `docs/交接文档-基金转换.md` · `docs/PRD/PRD-基金转换交易.md`(v0.9.1)· `docs/项目框架设计/架构设计-基金转换交易.md`(v1.0)· `docs/项目框架设计/开发计划-基金转换交易.md`(v1.0 · T-0~T-13 逐任务改法 + DoD)
+- **架构改进线(已交付 · 待收尾)**:开工入口 `docs/交接文档-架构改进.md`
## 代码入口
diff --git a/app/config/settings.py b/app/config/settings.py
index afb4bc1..1bb97cd 100644
--- a/app/config/settings.py
+++ b/app/config/settings.py
@@ -17,6 +17,17 @@ class Settings(BaseSettings):
mysql_user: str = "root"
mysql_password: str = ""
+ # ===== DB 账号分离(D20 · 架构设计-基金转换交易 §11.1)=====
+ # 把「Core 只读」「审计不可删改」从代码约定升级为 DB 级强制。
+ # 账号由 scripts/core/00-grant.sql 创建(管理员执行一次,不进 reset.ps1)。
+ # 留空 = 回退 mysql_user,行为与单账号时代完全一致(渐进启用,不阻塞本地开发)。
+ mysql_core_ro_user: str = ""
+ mysql_core_ro_password: str = ""
+ mysql_core_rw_user: str = ""
+ mysql_core_rw_password: str = ""
+ mysql_agent_user: str = ""
+ mysql_agent_password: str = ""
+
redis_url: str = "redis://127.0.0.1:6379/0"
neo4j_uri: str = "bolt://localhost:7687"
diff --git a/app/gateway/gateway_repository.py b/app/gateway/gateway_repository.py
index 2343025..8b51464 100644
--- a/app/gateway/gateway_repository.py
+++ b/app/gateway/gateway_repository.py
@@ -18,10 +18,16 @@ from app.utils.db import get_engine
class GatewayRepository:
- """core_trade 唯一写入口;仅 INSERT,不改不删(模拟网关语义)。"""
+ """core_trade 唯一写入口;仅 INSERT,不改不删(模拟网关语义)。
+
+ D20(T-0b):绑定 `role="rw"`(账号 `xh_core_rw`,仅限 4 张表的
+ SELECT/INSERT/UPDATE,**无 DELETE、无 DDL**)。注意 gateway 的**读**走
+ `role="ro"`(`CoreReadOnlyRepository`),两个 engine 并存——这是「最小
+ 权限」能否成立的关键边界(架构 §11.1)。
+ """
def __init__(self, engine: Engine | None = None) -> None:
- self._engine = engine or get_engine(settings.mysql_core_database)
+ self._engine = engine or get_engine(settings.mysql_core_database, "rw")
def insert_trade(
self,
diff --git a/app/repository/core_ro.py b/app/repository/core_ro.py
index 6b51928..d109a0d 100644
--- a/app/repository/core_ro.py
+++ b/app/repository/core_ro.py
@@ -49,10 +49,15 @@ def _is_expired(value: Any, today: date | None = None) -> bool:
class CoreReadOnlyRepository:
- """仅 SELECT jinrong_core;禁止写操作。"""
+ """仅 SELECT jinrong_core;禁止写操作。
+
+ D20(T-0b):绑定 `role="ro"`(账号 `xh_core_ro`,库级 SELECT),
+ 使「Core 只读」从代码约定升级为 **DB 级强制**——写语句在数据库层即被拒。
+ 账号未配置时回退 `mysql_user`(单账号时代行为)。
+ """
def __init__(self, engine: Engine | None = None) -> None:
- self._engine = engine or get_engine(settings.mysql_core_database)
+ self._engine = engine or get_engine(settings.mysql_core_database, "ro")
def get_customer_l0(self, customer_id: str) -> dict[str, Any] | None:
"""L0 客户主档 + 正式风评(对齐用户信息数据示例 / 适当性指南;main 扩列版)。"""
diff --git a/app/repository/risk_repository.py b/app/repository/risk_repository.py
index 397a108..ec67756 100644
--- a/app/repository/risk_repository.py
+++ b/app/repository/risk_repository.py
@@ -34,7 +34,7 @@ class RiskRepository:
"""风控产出表读写;不修改表结构(PRD 冻结约束)。"""
def __init__(self, engine: Engine | None = None) -> None:
- self._engine = engine or get_engine(settings.mysql_database)
+ self._engine = engine or get_engine(settings.mysql_database, "rw")
# ---------- risk_alert ----------
diff --git a/app/repository/session_repository.py b/app/repository/session_repository.py
index 8db1738..d3f6e5d 100644
--- a/app/repository/session_repository.py
+++ b/app/repository/session_repository.py
@@ -22,7 +22,7 @@ class SessionRepository:
"""agent_session / agent_message 读写;audit 类表不在本层(T-02 中间件)。"""
def __init__(self, engine: Engine | None = None) -> None:
- self._engine = engine or get_engine(settings.mysql_database)
+ self._engine = engine or get_engine(settings.mysql_database, "rw")
# ---------- agent_session ----------
diff --git a/app/service/convert/__init__.py b/app/service/convert/__init__.py
new file mode 100644
index 0000000..75d1b1b
--- /dev/null
+++ b/app/service/convert/__init__.py
@@ -0,0 +1,12 @@
+"""基金转换(convert)纯函数包(架构设计-基金转换交易 §2/§7)。
+
+**本包硬约束**:只做金额/费率/份额/日期口径的计算与规则判定,
+**不查库、不碰 SQL、不读 settings、不写日志**——所有输入由调用方
+(`repository` 取数后)显式传入,输出为不可变 dataclass。
+
+这样做的目的:
+1. 金额链路最易错,纯函数才能**穷举单测**(D1);
+2. `plan_lots()` 必须"先规划再判上限",不能在事务里半途失败(§8.3 `TOO_MANY_LOTS`);
+3. `lot_bootstrap.bootstrap_lots()` 被 `trade_gateway` 兜底补建与
+ `scripts/core/rebuild_lots.py` **同调**(D18),必须与 IO 解耦才能两侧一致。
+"""
diff --git a/app/service/convert/calc.py b/app/service/convert/calc.py
new file mode 100644
index 0000000..b0fced1
--- /dev/null
+++ b/app/service/convert/calc.py
@@ -0,0 +1,274 @@
+"""基金转换金额与份额口径(架构 §7 纯函数签名 · D1/D13)。
+
+**本模块的每一条规则都对应一处已核验的真实业务口径**(不是"看起来合理"):
+
+| 规则 | 依据 |
+| --- | --- |
+| 金额/份额一律 **2 位 ROUND_HALF_UP** | 证监会公告〔2025〕22 号;PRD §2.5(v1.0 已废止"4 位向下取整"残留) |
+| **逐批先舍入后求和** | PRD §12「舍入顺序」;否则跨批次与逐批计费结果不一致 |
+| 补差费 **双口径**(B 默认 / A 保留) | D13;`convert_diff_fee_mode` 可切 |
+| 持有期 **不含申请日**、分档**左闭右开** | PRD §12 I-2;满 7 日归 7–30 档 |
+| FIFO 主序 `confirmed_at`、同行以 `lot_id` 兜底 | 评审 S1(否则同注册日多批次顺序不可复现) |
+| 最低持有余额:**余额 < 阈值**才触发(非 ≤) | PRD §12 I-4;`≤` 会把"恰好等于下限"误判为强制全转 |
+
+⚠️ **精度陷阱**:`Decimal` 的**默认舍入是 `ROUND_HALF_EVEN`**(银行家舍入),
+`Decimal("0.005").quantize(Decimal("0.01"))` 会得到 `0.00` 而非 `0.01`。
+本模块所有量化点**显式传 `rounding=ROUND_HALF_UP`**,不留默认值(架构风险 #3)。
+"""
+
+from __future__ import annotations
+
+from datetime import date, datetime
+from decimal import ROUND_HALF_UP, Decimal
+from typing import Iterable
+
+from app.service.convert.errors import InsufficientShares, TooManyLots
+from app.service.convert.types import (
+ DiffFeeMode,
+ Lot,
+ LotAllocation,
+ MinHoldAction,
+ PlanAction,
+ PlanResult,
+ to_date,
+ to_decimal,
+)
+
+#: 金额与份额的量化位数(PRD §2.5:两者都是 2 位四舍五入)。
+PLACES = 2
+_CENT = Decimal(1).scaleb(-PLACES)
+_ONE = Decimal("1")
+_ZERO_2 = Decimal("0.00")
+#: 理论份额与实得份额的差额(`rounding_diff`)保留 4 位——PRD §5.3 示例为 `-0.0026`。
+_TEN_THOUSANDTH = Decimal("0.0001")
+
+
+def round2(value: Decimal) -> Decimal:
+ """2 位四舍五入(**显式 HALF_UP**,见模块 docstring 的精度陷阱)。"""
+ return to_decimal(value).quantize(_CENT, rounding=ROUND_HALF_UP)
+
+
+# ── 逐批计算 ────────────────────────────────────────────────────────
+def lot_amount(qty: Decimal, nav: Decimal) -> Decimal:
+ """本批转出金额 = 份额 × 成交净值(2 位 HALF_UP)。"""
+ return round2(to_decimal(qty) * to_decimal(nav))
+
+
+def lot_fee(amount: Decimal, rate: Decimal) -> Decimal:
+ """本批赎回费 = 本批金额 × 适用费率(2 位 HALF_UP)。
+
+ 与 `lot_amount()` 配成一对,供 `plan_lots()` 的每个 `LotAllocation` 逐批调用——
+ **逐批算完先舍入、再求和**,是 PRD §5.3 示例(154.50 + 309.00 = 463.50)
+ 与"整笔按加权费率算"结果不同的根源。
+ """
+ return round2(to_decimal(amount) * to_decimal(rate))
+
+
+def convert_amount(out_amount: Decimal, redeem_fee: Decimal) -> Decimal:
+ """转换金额 = 转出金额 − 赎回费(即"扣赎回费后可用于转入的金额")。"""
+ return round2(to_decimal(out_amount) - to_decimal(redeem_fee))
+
+
+def in_qty(in_amount: Decimal, in_nav: Decimal) -> Decimal:
+ """转入份额 = 净转入金额 ÷ 转入基金净值(2 位 HALF_UP · v1.0 勘误)。
+
+ ⚠️ 份额**不是** 4 位向下取整——那是 v0.2 的残留、与 §1 原则 10 直接矛盾,
+ PRD v0.8 / 架构 v1.0 已统一为 **2 位四舍五入**,尾差"在基金资产列支"。
+ """
+ nav = to_decimal(in_nav)
+ if nav <= 0:
+ raise ValueError(f"转入净值必须大于 0,实际 {nav}")
+ return round2(to_decimal(in_amount) / nav)
+
+
+def rounding_diff(
+ in_amount: Decimal, in_nav: Decimal, actual_in_qty: Decimal, places: int = 4
+) -> Decimal:
+ """份额尾差 = 理论份额 − 实得份额(可正可负,负 = 客户多得)。
+
+ 理论份额按 `places`(默认 4)位 HALF_UP 量化后相减,与 PRD §5.3
+ 的 `-0.0026` 口径一致。该值仅供前端展示与对账,**不参与任何后续计算**。
+ """
+ nav = to_decimal(in_nav)
+ if nav <= 0:
+ raise ValueError(f"转入净值必须大于 0,实际 {nav}")
+ unit = _TEN_THOUSANDTH if places == 4 else Decimal(1).scaleb(-places)
+ theoretical = (to_decimal(in_amount) / nav).quantize(unit, rounding=ROUND_HALF_UP)
+ return (theoretical - to_decimal(actual_in_qty)).quantize(unit, rounding=ROUND_HALF_UP)
+
+
+# ── 补差费(D13 双口径)──────────────────────────────────────────────
+def diff_fee(
+ conv_amount: Decimal,
+ out_rate: Decimal,
+ in_rate: Decimal,
+ mode: DiffFeeMode = "amount_diff",
+) -> Decimal:
+ """补差费(申购费差额),**只在转入端费率高时收**,结果 2 位 HALF_UP。
+
+ - `mode="amount_diff"`(**默认 · 口径 B · 价外法两端差**):
+ 两端各按价外法算出申购费,**各自 2 位舍入后取差**,负值归零。
+ 与 PRD §5.3 表格逐行一致(405.05 − 152.65 = 252.40)。
+ - `mode="rate_diff"`(口径 A · 费率差法):
+ `conv × max(in−out, 0) / (1 + max(in−out, 0))`,同样 2 位 HALF_UP。
+
+ 两口径在同一输入下相差约 0.6%(252.40 vs 253.91),故**必须显式选口径**,
+ 不可混用(PRD §2.1.1 / Q9)。
+ """
+ conv = to_decimal(conv_amount)
+ out_r = to_decimal(out_rate)
+ in_r = to_decimal(in_rate)
+
+ if mode == "amount_diff":
+ # 价外法(申购费 = 金额 × 费率 ÷ (1 + 费率))两端各算一次、各自舍入。
+ in_fee = round2(conv * in_r / (_ONE + in_r))
+ out_fee = round2(conv * out_r / (_ONE + out_r))
+ delta = in_fee - out_fee
+ return delta if delta > 0 else _ZERO_2
+
+ if mode == "rate_diff":
+ gap = in_r - out_r
+ if gap <= 0:
+ return _ZERO_2
+ return round2(conv * gap / (_ONE + gap))
+
+ raise ValueError(f"未知的补差费口径 mode={mode!r}(应为 amount_diff / rate_diff)")
+
+
+# ── 持有期 ──────────────────────────────────────────────────────────
+def hold_days(trade_date: date, confirmed_at: datetime) -> int:
+ """持有天数 =(交易日 − 确认日).days —— **自然日、不含申请日**(PRD §12 I-2)。
+
+ 模拟库无交易日历,故用自然日近似(真实为工作日);`convert_confirm_offset_days`
+ 让转换转入批次的 `confirmed_at = T+1`,因此同一交易日下该批次的 `hold_days`
+ 比"自 T 起算"**少 1 天**(费率档更严,PRD B-5)——这正是要贴近的真实口径。
+ """
+ d = to_date(trade_date)
+ c = to_date(confirmed_at)
+ if d is None or c is None:
+ raise ValueError("交易日与确认日都不能为 None")
+ return (d - c).days
+
+
+# ── FIFO 分配与最低持有处置 ───────────────────────────────────────────
+def _fifo_order(lots: Iterable[Lot]) -> list[Lot]:
+ """FIFO 排序:`confirmed_at` 升序,同行以 `lot_id` 升序兜底(评审 S1)。
+
+ 调用方 SQL 已 `ORDER BY confirmed_at ASC, lot_id ASC`;此处**再排一次**
+ 是为了让本函数自身确定——纯函数不该依赖调用方是否记得写 ORDER BY
+ (否则同一注册日的多批次顺序由存储引擎决定,重跑结果漂移)。
+ """
+ return sorted(lots, key=lambda lot: (lot.confirmed_at, lot.lot_id))
+
+
+def plan_lots(
+ lots: list[Lot],
+ requested_qty: Decimal,
+ min_hold_qty: Decimal | None = None,
+ min_hold_action: MinHoldAction = "force_transfer",
+) -> PlanResult:
+ """FIFO 分配 + 最低持有处置判定(架构 §7 · PRD §12 I-4)。
+
+ 判定顺序(**严格按 PRD 校验优先级 ①③**;② 最低转出份额由 service 层前置,
+ 因其需要"是否等于全部可转份额"的上下文,本函数不重复实现):
+
+ 1. `Σ remain_qty >= 申请份额`?否则 `InsufficientShares`;
+ 2. 申请后**仍有剩余**且 `剩余 < min_hold_qty` → 强制处置:
+ `actual_qty = 全部可转份额`、`forced_full_transfer = True`、
+ `action` 取 `min_hold_action`(强制全转 / 强制赎回剩余)。
+
+ **实现级裁定:申请后剩余为 0(客户本就是清仓)不触发强制处置。**
+ PRD 的字面条件是"余额 < 阈值",而余额 = 0 时字面也成立;但此时
+ `actual_qty == requested_qty`,"强制"二字对客户是**误报**(响应里的
+ `forced_full_transfer` 会被前端当成"你的指令被改了")。
+ 故加 `剩余 > 0` 前置条件。
+ """
+ requested = to_decimal(requested_qty)
+ if requested <= 0:
+ raise ValueError(f"申请份额必须大于 0,实际 {requested}")
+
+ ordered = _fifo_order(lots)
+ available = sum(
+ (to_decimal(lot.remain_qty) for lot in ordered if to_decimal(lot.remain_qty) > 0),
+ Decimal("0"),
+ )
+ if requested > available:
+ raise InsufficientShares(
+ f"可转份额不足:可转 {available},申请 {requested}"
+ )
+
+ threshold = to_decimal(min_hold_qty) if min_hold_qty is not None else Decimal("0")
+ leftover = available - requested
+ trigger = leftover > 0 and threshold > 0 and leftover < threshold
+
+ if trigger:
+ if min_hold_action not in ("force_transfer", "force_redeem"):
+ raise ValueError(
+ f"未知的最低持有处置动作 min_hold_action={min_hold_action!r}"
+ "(应为 force_transfer / force_redeem)"
+ )
+ actual_qty = available
+ action: PlanAction = min_hold_action
+ else:
+ actual_qty = requested
+ action = "transfer"
+
+ # FIFO 摊到各批次:按顺序吃满为止(actual_qty 已保证 <= available)。
+ allocations: list[LotAllocation] = []
+ remaining = actual_qty
+ for lot in ordered:
+ if remaining <= 0:
+ break
+ lot_remain = to_decimal(lot.remain_qty)
+ if lot_remain <= 0:
+ continue
+ take = lot_remain if lot_remain < remaining else remaining
+ allocations.append(
+ LotAllocation(
+ lot_id=lot.lot_id,
+ qty=take,
+ nav=to_decimal(lot.nav),
+ confirmed_at=lot.confirmed_at,
+ )
+ )
+ remaining -= take
+
+ if remaining > 0: # 防御:available 已校验,走到这里说明 lots 被并发改过
+ raise InsufficientShares(
+ f"可转份额不足:可转 {available},申请 {actual_qty}"
+ )
+
+ return PlanResult(
+ allocations=tuple(allocations),
+ requested_qty=requested,
+ actual_qty=actual_qty,
+ available_qty=available,
+ forced_full_transfer=trigger,
+ action=action,
+ )
+
+
+def ensure_batch_limit(plan: PlanResult, max_lots: int) -> None:
+ """批次数上限校验 —— **必须在 `plan_lots()` 之后**调用(§8.3)。
+
+ 先规划再判上限,是为了让 `TOO_MANY_LOTS` 的响应体能带上**继续转换实际所需的
+ 批次数**(可能远大于上限),前端据此提示"请拆分多笔申请";
+ 反之若边分配边判,会在事务里半途失败、占位残留。
+ """
+ if plan.batch_count > max_lots:
+ raise TooManyLots(plan.batch_count, max_lots)
+
+
+__all__ = [
+ "PLACES",
+ "round2",
+ "lot_amount",
+ "lot_fee",
+ "convert_amount",
+ "in_qty",
+ "rounding_diff",
+ "diff_fee",
+ "hold_days",
+ "plan_lots",
+ "ensure_batch_limit",
+]
diff --git a/app/service/convert/errors.py b/app/service/convert/errors.py
new file mode 100644
index 0000000..83a2cbc
--- /dev/null
+++ b/app/service/convert/errors.py
@@ -0,0 +1,169 @@
+"""convert 专属异常(架构 §8.3 错误码映射)。
+
+全部继承 `app.utils.exceptions.ApiError` —— 复用 `app/main.py` 已有的
+`register_error_handlers`,统一输出 `{error_code, message, trace_id, request_id}` 错误体,
+**不新增异常出口、不改中间件**。
+
+两点必须分清(PRD L-4 / §8.3):
+
+1. **技术故障码不是业务阻断**。`NavNotReady`(503)、`LotConflict`(409)、
+ `IdempotencyUnavailable`(503) 表示系统瞬时状态,恢复后重试即可成功,
+ **不适用「仅 R-02 可阻断交易」的业务铁律**。
+2. **`extra` 只承载结构性附加字段**(如 `TOO_MANY_LOTS` 的 `batch_count`/`max_lots`),
+ 由 T-7 的 `api/simulate.py` 分支展开进错误体;本包不构造响应。
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from app.utils.exceptions import ApiError
+
+
+class ConvertError(ApiError):
+ """convert 错误共同基类(便于 service 层统一 `except ConvertError` 分流)。
+
+ 子类通过类属性声明 `status_code` / `error_code`,无需逐个重写 `__init__`。
+ """
+
+ status_code: int = 400
+ error_code: str = "BAD_REQUEST"
+ default_message: str = "基金转换请求被拒绝"
+
+ def __init__(self, message: str | None = None, *, extra: dict[str, Any] | None = None) -> None:
+ super().__init__(self.status_code, self.error_code, message or self.default_message)
+ #: 附加字段(供错误体展开,如 batch_count / max_lots);不参与 message。
+ self.extra: dict[str, Any] = dict(extra or {})
+
+
+# ── 400:业务校验类(HTTP 400 · §8.3 前 7 条)─────────────────────────
+class ProductNotRedeemable(ConvertError):
+ """转出方不可赎回(`core_product.can_redeem = 0`)。"""
+
+ error_code = "PRODUCT_NOT_REDEEMABLE"
+ default_message = "转出基金当前不可赎回"
+
+
+class ProductNotSubscribable(ConvertError):
+ """转入方不可申购(`core_product.can_subscribe = 0`)。"""
+
+ error_code = "PRODUCT_NOT_SUBSCRIBABLE"
+ default_message = "转入基金当前不可申购"
+
+
+class InsufficientShares(ConvertError):
+ """可转份额不足(`Σ remain_qty < 申请份额` · PRD §12)。"""
+
+ error_code = "INSUFFICIENT_SHARES"
+ default_message = "可转份额不足"
+
+
+class BelowMinQty(ConvertError):
+ """低于最低转出份额(申请份额 == 全部可转份额时豁免 `min_redeem_qty`)。"""
+
+ error_code = "BELOW_MIN_QTY"
+ default_message = "低于该基金的最低转出份额"
+
+
+class SameProduct(ConvertError):
+ """转出与转入为同一产品。"""
+
+ error_code = "SAME_PRODUCT"
+ default_message = "转出与转入基金不能为同一产品"
+
+
+class CrossEntityNotSupported(ConvertError):
+ """跨主体转换(不满足 同销售机构 + 同管理人 + 同 TA)。"""
+
+ error_code = "CROSS_ENTITY_NOT_SUPPORTED"
+ default_message = "仅支持同一销售机构、同一管理人、同一注册登记机构的基金互转"
+
+
+class TooManyLots(ConvertError):
+ """跨越批次数超上限(`convert_batch_max_lots`,默认 200)。
+
+ 一期**不做自动分拆**(§1 原则 12:单笔请求 = 单事务 = 单 `convert_group_id`),
+ 故把实际所需批次数与上限一并回给前端,提示「请拆分多笔申请」。
+
+ `extra` 与 PRD「执行期风险 #5」的响应体约定一致:
+ `{"error_code":"TOO_MANY_LOTS","batch_count":<实际>,"max_lots":200}`。
+ """
+
+ error_code = "TOO_MANY_LOTS"
+ default_message = "本次转换跨越的批次数超过上限,请拆分多笔申请"
+
+ def __init__(
+ self,
+ batch_count: int,
+ max_lots: int,
+ message: str | None = None,
+ ) -> None:
+ super().__init__(
+ message
+ or f"本次转换需跨 {batch_count} 个批次,超过上限 {max_lots},请拆分多笔申请",
+ extra={"batch_count": int(batch_count), "max_lots": int(max_lots)},
+ )
+ self.batch_count = int(batch_count)
+ self.max_lots = int(max_lots)
+
+
+# ── 409 / 503:技术故障类(**非业务阻断** · PRD L-4)──────────────────
+class LotConflict(ConvertError):
+ """批次条件 UPDATE 的 `rowcount != 1`(并发扣减冲突)。
+
+ 调用方按 100/200/400ms 退避重试,建议 ≤3 次(§8.3)。
+ """
+
+ status_code = 409
+ error_code = "LOT_CONFLICT"
+ default_message = "份额批次并发冲突,请重试"
+
+
+class NavNotReady(ConvertError):
+ """该产品无任何净值记录(PRD §8.3:503 `NAV_NOT_READY`)。
+
+ ⚠️ **`nav_stale`(净值过期)不是本异常**:净值过期只额外落一条
+ `nav_stale` 审计并继续折算(PRD §2.2),只有**完全没有净值**才阻断。
+ """
+
+ status_code = 503
+ error_code = "NAV_NOT_READY"
+ default_message = "该基金尚无可用净值,暂时无法转换"
+
+
+class IdempotencyUnavailable(ConvertError):
+ """幂等执行权获取失败且无法判定状态(如 Redis 与占位表同时不可用)。"""
+
+ status_code = 503
+ error_code = "IDEMPOTENCY_UNAVAILABLE"
+ default_message = "幂等校验服务暂不可用,请稍后重试"
+
+
+# ── 500:内部兜底(§8.3 表之外 · 仅数据缺失时触发)────────────────────
+class FeeRuleMissing(ConvertError):
+ """未匹配到任何赎回费率档 —— **属数据完整性事故,不是业务分支**。
+
+ 触发条件:`core_fee_rule` 缺少该产品的 `[0, 7)` 档(种子漏灌或产品未配档)。
+ 真实费用规则必含最低档,故此处**不降级为 0 费率**——静默按 0 计费会
+ 少收赎回费且不留痕,比直接失败危险得多。
+ """
+
+ status_code = 500
+ error_code = "FEE_RULE_MISSING"
+ default_message = "未匹配到赎回费率规则(费率表数据缺失)"
+
+
+__all__ = [
+ "ConvertError",
+ "ProductNotRedeemable",
+ "ProductNotSubscribable",
+ "InsufficientShares",
+ "BelowMinQty",
+ "SameProduct",
+ "CrossEntityNotSupported",
+ "TooManyLots",
+ "LotConflict",
+ "NavNotReady",
+ "IdempotencyUnavailable",
+ "FeeRuleMissing",
+]
diff --git a/app/service/convert/fee.py b/app/service/convert/fee.py
new file mode 100644
index 0000000..3c21169
--- /dev/null
+++ b/app/service/convert/fee.py
@@ -0,0 +1,65 @@
+"""持有期 → 赎回费率查表(架构 §7 · D11 数据驱动)。
+
+规则来自 `core_fee_rule`(`fee_type='redeem'`),**本模块不查库** ——
+调用方(`core_ro.get_redeem_fee_rules(product_id)`)取回规则行后传入。
+
+**区间口径:左闭右开 `[min_hold_days, max_hold_days)`**,`max_hold_days = NULL` 表示无上限。
+与 `scripts/core/07-seed-fee-rule.sql`、架构 §7 分档表三处一致:
+
+| 档 | 区间 | 费率(22 号文 §10 下限) |
+| --- | --- | --- |
+| < 7 日 | `[0, 7)` | 0.0150 |
+| 7–30 日 | `[7, 30)` | 0.0100 |
+| 30–180 日 | `[30, 180)` | 0.0050 |
+| 180–365 日 | `[180, 365)` | 0.0025 |
+| ≥ 365 日 | `[365, NULL)` | 0.0000 |
+
+边界语义举例(PRD §12:**满 7 日归 7–30 档**):`hold_days = 6` → 1.5%;
+`hold_days = 7` → 1.0%;`hold_days = 365` → 0。
+"""
+
+from __future__ import annotations
+
+from decimal import Decimal
+from typing import Iterable, Sequence
+
+from app.service.convert.errors import FeeRuleMissing
+from app.service.convert.types import FeeRule, to_decimal
+
+
+def matches(rule: FeeRule, hold_days: int) -> bool:
+ """单条规则是否命中(左闭右开;`max_hold_days is None` 表示无上限)。"""
+ if hold_days < rule.min_hold_days:
+ return False
+ return rule.max_hold_days is None or hold_days < rule.max_hold_days
+
+
+def pick_fee_rate(
+ rules: Sequence[FeeRule] | Iterable[FeeRule],
+ hold_days: int,
+ *,
+ product_id: str | None = None,
+) -> Decimal:
+ """按持有天数取赎回费率;**无命中即数据缺失,直接失败**(不降级为 0)。
+
+ 多档同时命中时取 `min_hold_days` **最大**的一档(最具体的区间)——
+ 真实费率表本不应重叠,这条规则是防止种子误配出重叠区间时"取到哪档看运气"。
+
+ ⚠️ 为什么无命中不能返回 0:静默按 0 计费会**少收赎回费且不留痕**
+ (客户与基金财产双向损失),比直接失败危险得多。费率表必含 `[0, 7)` 档,
+ 走到这里说明种子漏灌或产品未配档 → `FeeRuleMissing`(500,内部兜底)。
+ """
+ candidates = [
+ rule
+ for rule in rules
+ if rule.fee_type == "redeem" and matches(rule, hold_days)
+ ]
+ if not candidates:
+ raise FeeRuleMissing(
+ f"未匹配到赎回费率档:product_id={product_id!r},持有 {hold_days} 天"
+ )
+ best = max(candidates, key=lambda rule: rule.min_hold_days)
+ return to_decimal(best.rate)
+
+
+__all__ = ["matches", "pick_fee_rate"]
diff --git a/app/service/convert/lot_bootstrap.py b/app/service/convert/lot_bootstrap.py
new file mode 100644
index 0000000..cb50b15
--- /dev/null
+++ b/app/service/convert/lot_bootstrap.py
@@ -0,0 +1,130 @@
+"""批次兜底补建(架构 D18 · D8 · PRD §4.4 历史数据兜底)。
+
+**唯一目的:让"补建规则"只有一个副本。**
+
+`core_holding` 是持仓快照、`core_share_lot` 是 FIFO 计费的权威源。存量持仓
+(批次机制上线前就存在)没有批次行,此时:
+
+- `trade_gateway` 的普通申赎分支要**兜底补建后再扣**(D8,不跳过、不阻断);
+- `scripts/core/rebuild_lots.py` 要按持仓**快照重建**整表(L-7,非交易回滚)。
+
+两处都要"由 `core_holding` 反推初始批次的 `confirmed_at`"。若各写一份,
+**必然漂移**——同一持仓在两处算出不同的 `confirmed_at`,费率档随之不同,
+而这类差异在演示里看不出来、在生产里就是错账。故本模块被两侧共同调用(D18)。
+
+**错开规则**与 `scripts/core/08-seed-share-lot.sql` 同族:以 `core_holding.as_of`
+为基准日向前错开 `(491, 201, 34, 10, 3)` 天,分别落在
+`[365,∞)` / `[180,365)` / `[30,180)` / `[7,30)` / `[0,7)` 五档,
+避免"全部持仓都落在同一费率档"导致分档分支无数据。
+"""
+
+from __future__ import annotations
+
+import zlib
+from datetime import datetime, time, timedelta
+from decimal import ROUND_HALF_UP, Decimal
+from typing import Any, Mapping, Sequence
+
+from app.service.convert.types import Lot, to_date, to_decimal
+
+#: 错开天数(与 08 种子同族,依次对应 ≥365 / 180-365 / 30-180 / 7-30 / <7 五档)。
+BOOTSTRAP_OFFSET_DAYS: tuple[int, ...] = (491, 201, 34, 10, 3)
+
+#: 确认时点统一取 10:00:00,与 08 种子的 `confirmed_at` 写法保持一致
+#: (持有期按自然日算,时分秒不参与计费,统一值只为可比对)。
+BOOTSTRAP_CONFIRM_TIME = time(10, 0, 0)
+
+#: `lot_id` 前缀:便于在库里一眼区分「兜底补建」与「真实流水生成」的批次。
+BOOTSTRAP_LOT_PREFIX = "LOT-BOOT"
+
+#: `lot_id` / `product_id` 列宽(`core_share_lot.lot_id VARCHAR(64)`)。
+_MAX_LOT_ID_LEN = 64
+
+
+def offset_for(
+ customer_id: str,
+ product_id: str,
+ offsets: Sequence[int] = BOOTSTRAP_OFFSET_DAYS,
+) -> int:
+ """按 `(customer_id, product_id)` 稳定选择一个错开天数。
+
+ ⚠️ **必须用 `zlib.crc32`,不可用内置的 `hash` 函数**:CPython 对 `str` 启用了
+ 哈希随机化(`PYTHONHASHSEED`),内置哈希在不同进程间结果不同 ——
+ 这会让 gateway 与 `rebuild_lots.py`(两个进程)算出**不同**的 `confirmed_at`,
+ D18 的同源断言变成"时绿时红"的随机失败。
+ """
+ key = f"{customer_id}|{product_id}".encode("utf-8")
+ return offsets[zlib.crc32(key) % len(offsets)]
+
+
+def bootstrap_lot_id(customer_id: str, product_id: str) -> str:
+ """兜底批次的 `lot_id`(确定性:同一持仓重复补建得到同一个 id,天然防重)。
+
+ 超长时退化为「截断 + crc32 摘要」而**不抛异常** —— D8 明确要求兜底补建
+ 「不跳过、不阻断」,让一个 64 字符列宽问题打断交易是不对的。
+ 两个 id 列各 64 字符,拼起来可能超 `VARCHAR(64)`,故保留摘要段保证唯一性。
+ """
+ raw = f"{customer_id}-{product_id}"
+ lot_id = f"{BOOTSTRAP_LOT_PREFIX}-{raw}"
+ if len(lot_id) > _MAX_LOT_ID_LEN:
+ digest = f"{zlib.crc32(raw.encode('utf-8')):08x}"
+ keep = _MAX_LOT_ID_LEN - len(BOOTSTRAP_LOT_PREFIX) - len(digest) - 2
+ lot_id = f"{BOOTSTRAP_LOT_PREFIX}-{raw[:keep]}-{digest}"
+ return lot_id
+
+
+def bootstrap_lots(
+ holding_row: Mapping[str, Any],
+ *,
+ offset_days: int | None = None,
+) -> list[Lot]:
+ """按 `core_holding` 一行反推初始批次(D8 兜底补建 / `rebuild_lots.py` 共用)。
+
+ 返回**单批次**(历史存量视为一次确认),`qty = remain_qty = holding.qty`,
+ 因此天然满足 `Σ remain_qty == core_holding.qty`(08 种子的同一不变量)。
+
+ `nav` 用 **成本单价 `cost_amount / qty`**(4 位 HALF_UP)近似成交净值:
+ `core_holding` 没有净值列,而 `core_share_lot.nav` 是 NOT NULL;
+ 补建批次是"历史成本"语义,成本单价虽不等于当时净值,但**口径一致、可复算**,
+ 且不会被误当成当日净值参与定价(定价一律用 `core_product_nav`)。
+
+ `offset_days` 显式传入时覆盖默认错开(供单测固定档位用)。
+ """
+ qty = to_decimal(holding_row["qty"])
+ if qty <= 0:
+ # 份额归零的持仓行**保留但不再补建批次**(D9/P2:归零行不删)。
+ return []
+
+ customer_id = str(holding_row["customer_id"])
+ product_id = str(holding_row["product_id"])
+ as_of = to_date(holding_row["as_of"])
+ if as_of is None:
+ raise ValueError("core_holding.as_of 不能为 None(反推批次的基准日)")
+
+ cost_amount = to_decimal(holding_row.get("cost_amount") or 0)
+ offset = offset_days if offset_days is not None else offset_for(customer_id, product_id)
+ confirmed_at = datetime.combine(as_of - timedelta(days=offset), BOOTSTRAP_CONFIRM_TIME)
+ nav = (cost_amount / qty).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
+
+ return [
+ Lot(
+ lot_id=bootstrap_lot_id(customer_id, product_id),
+ confirmed_at=confirmed_at,
+ remain_qty=qty,
+ nav=nav,
+ qty=qty,
+ customer_id=customer_id,
+ product_id=product_id,
+ source_trade_id=None, # 兜底补建无来源流水(与 08 种子一致)
+ )
+ ]
+
+
+__all__ = [
+ "BOOTSTRAP_OFFSET_DAYS",
+ "BOOTSTRAP_CONFIRM_TIME",
+ "BOOTSTRAP_LOT_PREFIX",
+ "offset_for",
+ "bootstrap_lot_id",
+ "bootstrap_lots",
+]
diff --git a/app/service/convert/nav.py b/app/service/convert/nav.py
new file mode 100644
index 0000000..94bd9fd
--- /dev/null
+++ b/app/service/convert/nav.py
@@ -0,0 +1,74 @@
+"""净值口径判定(架构 §7 · D10 · PRD §2.2/§8.3)。
+
+**本模块不查库** —— 调用方先 `core_ro.get_nav_as_of(product_id, trade_date)` 取回
+`nav_date <= 交易日` 的最近一行,再传入此处判定。取值本身(T 日未知价法、
+降序取 1)由 SQL 保证(D10:**不改** `get_latest_nav`,它会取到未来日期的净值)。
+
+两个判定必须分清——它们对应**完全不同的处置**:
+
+| 判定 | 含义 | 处置 |
+| --- | --- | --- |
+| `ensure_nav_ready()` | 该产品**无任何净值记录** | **503 `NAV_NOT_READY`**,不落占位(PRD §8.3) |
+| `is_stale()` | 有净值但距交易日过久 | **不阻断**,仅额外落一条 `nav_stale` 审计(PRD §2.2) |
+
+把"净值过期"误当"无净值"会拦掉本可完成的转换;反之会让演示库的过期净值
+静默按新鲜净值折算。故两者是**两个独立函数**,不合并成一个布尔返回。
+"""
+
+from __future__ import annotations
+
+from datetime import date
+
+from app.service.convert.errors import NavNotReady
+from app.service.convert.types import to_date
+
+#: 净值过期阈值(天)—— 与 `settings.convert_nav_stale_days` 默认值一致。
+#: 纯函数不读配置,故此处只是**默认参数**,运行时以调用方传入为准。
+DEFAULT_STALE_DAYS = 3
+
+
+def ensure_nav_ready(nav_date: date | None, *, product_id: str | None = None) -> None:
+ """无净值记录 → `NavNotReady`(503);有则原样通过。"""
+ if to_date(nav_date) is None:
+ raise NavNotReady(f"基金 {product_id!r} 尚无可用净值(无任何净值记录)")
+
+
+def is_stale(
+ nav_date: date | None,
+ trade_date: date,
+ stale_days: int = DEFAULT_STALE_DAYS,
+) -> bool:
+ """净值是否过期:`(交易日 − 净值日期).days > stale_days`。
+
+ 注意是 **`>`**:恰好等于阈值不算过期(与 PRD §2.2「超过此值」一致)。
+
+ `nav_date` 为 `None` 时抛 `ValueError` 而**不是**返回 `True`——
+ "无净值"必须走 `ensure_nav_ready()` 得到 503,混进 `stale` 分支会
+ 变成"不阻断 + 落审计",性质完全不同。调用顺序:先 `ensure_nav_ready` 再 `is_stale`。
+ """
+ d = to_date(nav_date)
+ t = to_date(trade_date)
+ if d is None:
+ raise ValueError("nav_date 为 None(无净值记录),应先调用 ensure_nav_ready()")
+ if t is None:
+ raise ValueError("trade_date 不能为 None")
+ return (t - d).days > stale_days
+
+
+def evaluate_nav(
+ nav_date: date | None,
+ trade_date: date,
+ stale_days: int = DEFAULT_STALE_DAYS,
+ *,
+ product_id: str | None = None,
+) -> bool:
+ """便捷入口:先确保有净值(否则 503),再返回是否过期。
+
+ 返回值即响应体 `nav_stale` 字段(PRD §5.3),也是 `risk_convert_detail.nav_stale`
+ 的取值(PRD §4.1)。
+ """
+ ensure_nav_ready(nav_date, product_id=product_id)
+ return is_stale(nav_date, trade_date, stale_days)
+
+
+__all__ = ["DEFAULT_STALE_DAYS", "ensure_nav_ready", "is_stale", "evaluate_nav"]
diff --git a/app/service/convert/types.py b/app/service/convert/types.py
new file mode 100644
index 0000000..1b94b7c
--- /dev/null
+++ b/app/service/convert/types.py
@@ -0,0 +1,167 @@
+"""基金转换纯函数层的类型定义(架构 §7 · 评审 S3)。
+
+**为什么用 `@dataclass(frozen=True)` 而不是裸 dict**
+
+主架构 `app/entities.py` 的 31 个 ORM 类实测闲置、全链路 dict 传参,
+键名写错只在运行时炸。基金转换的金额链路是
+「份额 → 本批金额 → 本批赎回费 → 转换金额 → 补差费 → 净转入金额 → 转入份额」,
+**任一步键名写错都会算出一个"看着合理"的错数**(不是抛异常),
+故本包一律用不可变 dataclass 承载,错误在构造期即暴露。
+
+字段与 DB 列的对应关系(列名以 `scripts/core/01-ddl.sql` 为准):
+
+| 本包类型 | DB 表 |
+| --- | --- |
+| `Lot` | `core_share_lot`(FIFO 选批的输入行) |
+| `FeeRule` | `core_fee_rule`(持有期分档,`fee_type='redeem'`) |
+| `LotAllocation` / `PlanResult` | 计算产物,不落库;落库为 `core_convert_lot_detail` |
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import date, datetime
+from decimal import Decimal
+from typing import Any, Literal, Mapping
+
+# ── 枚举型别名(与 PRD/架构口径一一对应)────────────────────────────
+#: `core_product.min_hold_action`(PRD Q12 · D15):
+#: `force_transfer` = 阈值大(100/500/1000 份)→ 强制全转;
+#: `force_redeem` = 阈值小(0.1/1 份)→ 强制赎回剩余。
+MinHoldAction = Literal["force_transfer", "force_redeem"]
+
+#: `PlanResult.action`:正常转出 / 强制全转 / 强制赎回剩余余额。
+#: 与前者的区别——`force_*` 表示**客户指令被改变**(实际转出份额 > 申请份额),
+#: 必须在响应与审计留痕(PRD 二轮第 9 条)。
+PlanAction = Literal["transfer", "force_transfer", "force_redeem"]
+
+#: 补差费口径(D13 · `convert_diff_fee_mode`)。
+#: `amount_diff` = 价外法两端差(默认 B);`rate_diff` = 费率差法(A)。
+DiffFeeMode = Literal["amount_diff", "rate_diff"]
+
+
+def to_decimal(value: Any) -> Decimal:
+ """把 DB 取回的值统一成 `Decimal`(`float` 会引入二进制误差,禁止直传)。"""
+ if isinstance(value, Decimal):
+ return value
+ return Decimal(str(value))
+
+
+def to_datetime(value: Any) -> datetime:
+ """统一时间值(MySQL `DATETIME(3)`→`datetime` / sqlite `TIMESTAMP`→`str` 兜底)。"""
+ if isinstance(value, datetime):
+ return value
+ if isinstance(value, date):
+ return datetime(value.year, value.month, value.day)
+ return datetime.fromisoformat(str(value))
+
+
+def to_date(value: Any) -> date | None:
+ """统一日期值;`None` 原样返回(净值缺失的判定依赖 `None`,不可吞成 `today`)。"""
+ if value is None:
+ return None
+ if isinstance(value, datetime):
+ return value.date()
+ if isinstance(value, date):
+ return value
+ return date.fromisoformat(str(value)[:10])
+
+
+@dataclass(frozen=True)
+class Lot:
+ """`core_share_lot` 一行 —— FIFO 选批与逐批计费的输入(评审 S3)。
+
+ `qty` 为原始份额、`remain_qty` 为 FIFO 扣减后的剩余份额;
+ **计费与分配一律以 `remain_qty` 为准**(PRD §12:份额是否足够以
+ `Σ core_share_lot.remain_qty` 判定,不以 `core_holding.qty` 为准)。
+ """
+
+ lot_id: str
+ confirmed_at: datetime
+ remain_qty: Decimal
+ nav: Decimal
+ qty: Decimal | None = None
+ customer_id: str | None = None
+ product_id: str | None = None
+ source_trade_id: str | None = None
+
+ @classmethod
+ def from_row(cls, row: Mapping[str, Any]) -> Lot:
+ """由 DB 行映射构造(`row._mapping` 或普通 dict 均可)。"""
+ return cls(
+ lot_id=str(row["lot_id"]),
+ confirmed_at=to_datetime(row["confirmed_at"]),
+ remain_qty=to_decimal(row["remain_qty"]),
+ nav=to_decimal(row["nav"]),
+ qty=to_decimal(row["qty"]) if row.get("qty") is not None else None,
+ customer_id=row.get("customer_id"),
+ product_id=row.get("product_id"),
+ source_trade_id=row.get("source_trade_id"),
+ )
+
+
+@dataclass(frozen=True)
+class FeeRule:
+ """`core_fee_rule` 一行 —— 持有期→赎回费率的查表输入(`fee_type='redeem'`)。
+
+ `to_fund_ratio`(赎回费计入基金财产比例)22 号文 §10 恒为 1.0,
+ 本期**不参与计算、仅留痕**(D16),故不设参与计算的字段语义。
+ """
+
+ min_hold_days: int
+ max_hold_days: int | None
+ rate: Decimal
+ fee_type: str = "redeem"
+ product_id: str | None = None
+ to_fund_ratio: Decimal = Decimal("1.0000")
+
+ @classmethod
+ def from_row(cls, row: Mapping[str, Any]) -> FeeRule:
+ return cls(
+ min_hold_days=int(row["min_hold_days"]),
+ max_hold_days=int(row["max_hold_days"]) if row.get("max_hold_days") is not None else None,
+ rate=to_decimal(row["rate"]),
+ fee_type=str(row.get("fee_type") or "redeem"),
+ product_id=row.get("product_id"),
+ to_fund_ratio=to_decimal(row.get("to_fund_ratio") or "1.0000"),
+ )
+
+
+@dataclass(frozen=True)
+class LotAllocation:
+ """FIFO 分配结果中的单批次:**本批次被扣减的份额**及其成交要素。
+
+ 带上 `nav` / `confirmed_at` 是为了让下游(`convert_service` 落
+ `core_convert_lot_detail`)**无需再回查 `Lot`**——避免"同一批次两次取数
+ 拿到不同快照"的非确定性(评审 S1 关注的同类问题)。
+ """
+
+ lot_id: str
+ qty: Decimal
+ nav: Decimal
+ confirmed_at: datetime
+
+
+@dataclass(frozen=True)
+class PlanResult:
+ """`plan_lots()` 的完整规划结果(**先规划再判上限**,§8.3)。
+
+ `actual_qty != requested_qty` 即为「强制全转/强制赎回」发生过,
+ 调用方须把 `forced_full_transfer` 与 `action` 写进响应与审计(PRD §9 第 13 条)。
+ """
+
+ allocations: tuple[LotAllocation, ...]
+ requested_qty: Decimal
+ actual_qty: Decimal
+ available_qty: Decimal
+ forced_full_transfer: bool
+ action: PlanAction
+
+ @property
+ def batch_count(self) -> int:
+ """本次实际跨越的批次数。
+
+ 刻意做成 property 而非字段:`batch_count` 必须与 `allocations` **恒等**,
+ 独立字段存在被手填错的余地,而它正是 `TOO_MANY_LOTS` 的判据(§8.3)。
+ """
+ return len(self.allocations)
diff --git a/app/utils/db.py b/app/utils/db.py
index 3dfab89..eedb5bb 100644
--- a/app/utils/db.py
+++ b/app/utils/db.py
@@ -1,10 +1,13 @@
"""SQLAlchemy 引擎工厂(B7 · 开发计划挂账⑥)。
-按库名缓存单例 Engine:deps / risk / simulate 路由每请求实例化 Repository 时
-复用同一连接池,不再每次 create_engine(B6 复审 P3:实例化点泄漏);应用
-shutdown 经 dispose_engines 统一释放(B7 评审 P1-1:显式 Engine.dispose(),
-不依赖 GC 兜底)。测试直传 engine= 参数的用法不受影响;
+按 (库名, 角色) 缓存单例 Engine:deps / risk / simulate 路由每请求实例化
+Repository 时复用同一连接池,不再每次 create_engine(B6 复审 P3:实例化点
+泄漏);应用 shutdown 经 dispose_engines 统一释放(B7 评审 P1-1:显式
+Engine.dispose(),不依赖 GC 兜底)。测试直传 engine= 参数的用法不受影响;
monkeypatch settings 后须先 dispose_engines() 清缓存。
+
+角色(D20 · 架构设计-基金转换交易 §11.1):ro / rw / admin 三档,
+由 `get_engine` 的 `role` 参数选择账号;账号未配置时回退 mysql_user。
"""
from __future__ import annotations
@@ -19,20 +22,62 @@ from app.config.settings import settings
_engines: dict[str, Engine] = {}
_engines_lock = threading.Lock()
+_VALID_ROLES = ("ro", "rw", "admin")
-def get_engine(database: str) -> Engine:
- """取指定库的单例 Engine(进程内共享连接池)。"""
+
+def _resolve_credentials(database: str, role: str) -> tuple[str, str]:
+ """解析该 (库, 角色) 应使用的 (user, password)。
+
+ - `admin`:恒用 `mysql_user`(DDL / 测试 teardown 等需 DELETE 的场景)
+ - `ro` / `rw` + core 库:分别用 `mysql_core_ro_*` / `mysql_core_rw_*`
+ - `ro` / `rw` + agent 库:统一用 `mysql_agent_*`(agent 库无独立只读账号)
+ - 对应账号为空串 → 回退 `mysql_user`(渐进启用,不阻塞本地开发)
+ """
+ if role == "admin":
+ return settings.mysql_user, settings.mysql_password
+ if role not in _VALID_ROLES:
+ raise ValueError(f"unknown db role: {role!r}(应为 ro / rw / admin)")
+
+ if database == settings.mysql_core_database:
+ if role == "ro":
+ user, password = settings.mysql_core_ro_user, settings.mysql_core_ro_password
+ else:
+ user, password = settings.mysql_core_rw_user, settings.mysql_core_rw_password
+ else:
+ user, password = settings.mysql_agent_user, settings.mysql_agent_password
+
+ if not user:
+ return settings.mysql_user, settings.mysql_password
+ return user, password
+
+
+def get_engine(database: str, role: str = "rw") -> Engine:
+ """取指定 (库名, 角色) 的单例 Engine(进程内共享连接池)。
+
+ ⚠️ 调用点必须显式声明角色(F-11 清单共 13 处),不要依赖默认值表达意图:
+ - `role="ro"`:只读账号 → `core_ro` / `core_tools` / deps 归属校验 /
+ risk 扫描 / **gateway 的校验读**
+ - `role="rw"`:读写账号 → `gateway_repository`(core 写)、
+ `risk_repository` / `session_repository`(agent 读写)
+ - `role="admin"`:`mysql_user` → 建库/DDL、`tests/conftest.py` teardown(需 DELETE)
+
+ 账号未配置时回退 `mysql_user`,与单账号时代行为一致。
+ """
+ if role not in _VALID_ROLES:
+ raise ValueError(f"unknown db role: {role!r}(应为 ro / rw / admin)")
+
+ cache_key = f"{database}:{role}"
with _engines_lock:
- engine = _engines.get(database)
+ engine = _engines.get(cache_key)
if engine is None:
- pwd = settings.mysql_password
- auth = f"{settings.mysql_user}:{pwd}" if pwd else settings.mysql_user
+ user, password = _resolve_credentials(database, role)
+ auth = f"{user}:{password}" if password else user
url = (
f"mysql+pymysql://{auth}@{settings.mysql_host}:{settings.mysql_port}"
f"/{database}?charset=utf8mb4"
)
engine = create_engine(url, pool_pre_ping=True)
- _engines[database] = engine
+ _engines[cache_key] = engine
return engine
diff --git a/docs/PRD/PRD-基金转换交易.md b/docs/PRD/PRD-基金转换交易.md
index 5f85ac3..d0c7d79 100644
--- a/docs/PRD/PRD-基金转换交易.md
+++ b/docs/PRD/PRD-基金转换交易.md
@@ -1,6 +1,6 @@
# PRD · 基金转换(convert)交易
-> 版本:**v0.9(架构评审增补 · 定稿)** · 日期:2026-09-10
+> 版本:**v0.9.1(费率分类修正 · 定稿)** · 日期:2026-09-10
> 分支:`risk-control-agent`
> 状态:第 2 步产出。**v0.7 已冻结**(架构 v0.2 §0 查证发现 4 项合规硬伤,外审判定「❌ 不建议进入编码」)
> → 回退修订出 **v0.8**(按处置表全量修订,33 条闭环)→ 架构 v1.0 独立评审**通过**后,回填 2 处契约出 **v0.9**。
@@ -20,6 +20,21 @@
> | 2 | 响应增 `confirm_basis: "natural_day_approx"`(T+1 用自然日近似,真实 Core 接入后为工作日) | 契约(响应) | 架构评审 R2 |
> | 3 | `cleanup_pending_convert.py` 措辞由「清理超 24h 的 `pending` 孤儿」统一为「置 `status='expired'`(**标记不硬删**,留痕供对账)」 | **措辞统一**(同 S2 语义,非新增契约) | 架构评审 S2 |
>
+> **v0.9 → v0.9.1 变更(费率分类修正 · 用户裁定,2026-09-10)**
+>
+> | # | 变更 | 性质 | 依据 |
+> | --- | --- | --- | --- |
+> | 1 | 申购费率档**严格按 `product_type` 取**:`PROD-005827` 由「主动偏股 **0.80%**」更正为「**其他混合型 0.50%**」—— `mixed` 只能套 ≤0.5% 档 | 契约(种子口径) | 22 号文 §8 的分类维度 + 用户裁定 |
+> | 2 | 主示例**转入方**由 `PROD-005827` 改为 `PROD-003095`(`stock` / 主动偏股型 / R4 / 净值 **0.9500**);费率仍 0.80%、**§5.3 全部派生数字一个都不用改** | 契约(示例) | 同上(两者净值恰好同为 0.9500) |
+> | 3 | 主示例两端主体明确为 **华夏模拟基金 / TA-CN-001**(同管理人 + 同 TA —— 转换的前置硬约束) | 契约(示例) | 架构:同销售机构 + 同管理人 + 同 TA |
+> | 4 | 新增「**分类必须与 `product_type` 匹配**」说明 + 连带约束核对(同主体 / 净值 / 适当性三分支) | 说明(非契约) | 本次「外审级漏网」复盘 |
+>
+> **根因(必须记住)**:v0.8 按 22 号文上限重定费率时,为凑出 0.50% 的补差幅度,
+> 把 `mixed` 的 `PROD-005827` 标成「主动偏股」并直接取到 0.8% 上限 —— **这是分类错误**。
+> 五轮外审与我方逐条核对**只验证了「数值 ≤ 上限」,从未验证「该产品是否属于这一档」**。
+> → 新增**自检第 12 问:类型与口径必须匹配**,并把该约束固化为**可执行断言 ⑧**
+> (`scripts/dev/verify_convert_seed.py`,越档即 FAIL),不再依赖人工比对。
+>
> **变更门规(防版本循环)**:只有「**对外契约 / 表结构 / 枚举**」变更才回改本 PRD;
> 算法、补偿方式、索引、类型定义等**内部实现由架构自主决定**,本 PRD 不动。
>
@@ -117,7 +132,7 @@ B 亦为新近公告的主流写法。
> ⚠️ `diff_fee` 一期由两端产品的 `subscribe_fee_rate` 决定(**独立列,详见 §4.2 / §10 Q8,
> 不得取 `fee_rate`**——后者是管理费率,语义不同)。
-> **主示例取不同费率**(债基 **0.30%** → 主动偏股 **0.80%**)→ `diff_fee = 252.40`(§5.3 示例自证块)。
+> **主示例取不同费率**(`PROD-110022` 债基 **0.30%** → `PROD-003095` 主动偏股 **0.80%**)→ `diff_fee = 252.40`(§5.3 示例自证块)。
> 同费率场景(补差 0)由 §5.3 同费率对照 + §9 第 19 条用例覆盖。
### 2.1.3 赎回费分档(v0.8 按 22 号文重排)
@@ -376,11 +391,26 @@ ALTER TABLE core_product
> | 产品 | 类型 | `subscribe_fee_rate` | 依据(22 号文 §8 上限) |
> | --- | --- | --- | --- |
> | `PROD-110022`(示例转出) | bond | **0.0030** | 债券型 ≤0.3% |
- > | `PROD-005827`(示例转入) | 主动偏股 | **0.0080** | 主动偏股型 ≤0.8% |
+ > | `PROD-005827` | mixed(其他混合型) | **0.0050** | 其他混合型 ≤0.5% |
+ > | `PROD-003095`(示例转入) | stock(主动偏股型) | **0.0080** | 主动偏股型 ≤0.8% |
> | `PROD-000001`(同费率对照组) | money | 0.0000 | 货基 0 |
> | `PROD-510300`(同费率对照组) | index | 0.0030 | 指数型 ≤0.3% |
>
- > §5.3 主示例即取自前两行(**补差 0.50%**);§9 第 19 条两条用例(非零 / 同费率)均需可跑。
+ > §5.3 主示例即取自 `PROD-110022`(0.0030)→ `PROD-003095`(0.0080)两行(**补差 0.50%**);
+ > §9 第 19 条两条用例(非零 / 同费率)均需可跑。
+ >
+ > ⚠️ **分类必须与 `product_type` 匹配**(自检第 12 问):费率档由**产品类型**决定,
+ > **不得为凑出数值差把 `mixed` 产品按「主动偏股」取 0.8%**。v0.8 曾把
+ > `PROD-005827`(`mixed`,平衡混合一号)标为「主动偏股」并取到 0.8% 上限——
+ > 属分类错误,v0.9.1 已修正:`PROD-005827` 归「其他混合型 0.5%」,
+ > 示例转入方改用真正的主动偏股产品 `PROD-003095`(`stock` / R4)。
+ >
+ > **连带约束核对**(同一次修正必须一起看):
+ > ① `PROD-003095` 归 **华夏模拟基金 / TA-CN-001**,与转出方 `PROD-110022`
+ > **同管理人、同 TA** —— 满足转换的前置硬约束(否则跨主体不可转换);
+ > ② `PROD-003095` 净值 **0.9500**,与原示例除数的 0.9500 相同 → **§5.3 派生数字全部不变**;
+ > ③ 客户 CUST-9527 为 **C3**、`PROD-003095` 为 **R4** → 匹配结果
+ > `allowed_with_disclosure`(需签风险揭示书后**放行**,非阻断;`forbidden` 才阻断,§7.2)。
>
> **赎回费种子同步(B-3)**:`07-seed-fee-rule.sql` 的 `core_fee_rule.rate` 按 §2.1.3 新档写
> `0.0150 / 0.0100 / 0.0050 / 0.0025 / 0`(**v0.7 的 7–30 日 0.0075 低于法定下限,违规**)。
@@ -448,7 +478,7 @@ POST /api/simulate/trade
"customer_id": "CUST-9527",
"trade_type": "convert",
"from_product_id": "PROD-110022",
- "to_product_id": "PROD-005827",
+ "to_product_id": "PROD-003095",
"qty": 50000.0000,
"client_request_id": "CLI-20260909-0001" // 幂等键,选填
}
@@ -468,8 +498,9 @@ POST /api/simulate/trade
> **示例自证:每个派生值都由本块数据算出(v0.8 · 口径 B · 脚本实算)**
>
-> 数值来源:`scripts/dev/calc_convert_demo.py`(与 §2.1 / §2.5 同一套公式),
-> **禁止手算**——v0.7 曾两次因手算与口径不符被外部审查质疑。
+> 数值来源:`scripts/dev/calc_convert_demo.py`,**禁止手算**——v0.7 曾两次因手算与口径不符被外部审查质疑。
+> **T-2b 落地(2026-09-10)**:该脚本已改为**直接调用生产 `app/service/convert/calc.py` 与 `fee.py`**
+> (不再自带公式副本),并在末尾与本节表格**逐项比对,不一致即退出码 1**;实算 15/15 一致。
>
> | 步 | 计算 | 结果 |
> | --- | --- | --- |
@@ -485,7 +516,7 @@ POST /api/simulate/trade
> | `in_qty` | 50784.10 ÷ 0.9500 = 53456.9474 | q2 **53456.95**(2 位四舍五入) |
> | `rounding_diff` | 理论 53456.9474 − 实际 53456.95 | **-0.0026**(负 = 客户多得) |
>
-> **两端申购费率**:转出 `PROD-110022` 债基 **0.30%**、转入 `PROD-005827` 主动偏股 **0.80%**
+> **两端申购费率**:转出 `PROD-110022` 稳健债基 A(bond)**0.30%**、转入 `PROD-003095` 医药健康精选(stock,主动偏股型)**0.80%**
> (均 §4.3 种子显式给定,且在 22 号文 §8 上限内)。
>
> **补差费口径差异对照**:同输入下口径 A(费率差法)`diff_fee = 253.91`、`in_qty = 53455.36`
@@ -525,7 +556,7 @@ POST /api/simulate/trade
// ── 转入端(视同申购)──
"in_trade_id": "TRD-20260909-YYYYYYYY",
- "in_nav": "0.9500", // PROD-005827 成交净值
+ "in_nav": "0.9500", // PROD-003095 成交净值
"convert_amount": "51036.50", // 扣赎回费后可用于转入的金额 = 51500.00 − 463.50
"diff_fee": "252.40", // 口径B:405.05 − 152.65(价外法两端差,§2.1.1)
"in_amount": "50784.10", // 扣补差费后实际买入金额 = 51036.50 − 252.40
@@ -834,7 +865,7 @@ SELECT 1 FROM core_trade WHERE convert_group_id = :gid LIMIT 1;
第 6 步集成测试**实测补录真实耗时**;若实测超阈值,需优化索引 / 锁策略后再定阈值,
**不得反向修改实测数据迁就指标**)
19. **补差费非零场景有覆盖**(v0.7 自查问题 A 衍生 · **Q8 已定稿**):
- 存在两端 `subscribe_fee_rate` 不同的产品对(`PROD-110022` 0.30% / `PROD-005827` 0.80%),且有用例验证
+ 存在两端 `subscribe_fee_rate` 不同的产品对(`PROD-110022` 0.30% / `PROD-003095` 0.80%,且**同属一个管理人 + 同一 TA**),且有用例验证
`diff_fee > 0` 时 §2.1 公式成立(`in_amount = convert_amount − diff_fee`);
另需一条**同费率对照**用例(`diff_fee = "0.00"`)。
否则补差费链路在种子数据下**恒为 0、永不触发**(同第 14 条教训:无覆盖 = 校验能力漏测)
diff --git a/docs/memory/2026-09-10.md b/docs/memory/2026-09-10.md
new file mode 100644
index 0000000..930d7a0
--- /dev/null
+++ b/docs/memory/2026-09-10.md
@@ -0,0 +1,172 @@
+# 2026-09-10 工作日志
+
+> 本日两条线:**架构改进线收尾闭环**(本文主体)+ 基金转换线**第 4 步开发计划待启动**。
+
+---
+
+## 一、接手与状态核对(会话起始)
+
+读入:`docs/交接文档-基金转换.md`(v1.0)· `docs/memory/MEMORY.md §0` · `docs/交接文档-架构改进.md`(v1.1)
+· `docs/项目框架设计/架构设计-基金转换交易.md §15/§15.1` · `docs/memory/{TODO,ITERATION}.md` · `AGENTS.md`。
+
+**基线复核**:`python -m pytest -q`(系统 Python 3.13.14)→ **510 passed**(9.93s)。
+
+**设计资产核对**:`tests/_ddl.py:50-54` 的 `core_holding` 仍为 `market_value`/`quantity` 且**无 PK** ——
+与 T-0 描述**一致**,架构文档无误。
+
+### ⚠️ 核对中发现的两处「文档与实况不符」(以实况为准)
+
+1. **`037ce7e`「未 push」是过期表述。**
+ 实测 `git ls-remote origin`:`refs/heads/risk-control-agent` = **`fffb78a`** = 本地 HEAD;
+ `git merge-base --is-ancestor 037ce7e fffb78a` → 成立。即**架构改进线全部提交(含其后 5 个文档提交)早已推送**。
+ 本地 `git branch -vv` 显示 `origin/risk-control-agent: gone` **只是本地远程跟踪引用失效**,非远程分支被删。
+2. **基金转换线 6 个设计文件全部 untracked**(未纳入版本库):`docs/PRD/PRD-基金转换交易.md`、
+ `docs/项目框架设计/架构设计-基金转换交易.md`、`基金转换-审查意见处置表.md`、
+ `评审待办-风控主架构与基金转换.md`、`docs/交接文档-基金转换.md`、`scripts/dev/calc_convert_demo.py`。
+ → 用户拍板:**先 commit 入库(只 commit,不 push)**。
+
+---
+
+## 二、架构改进线 §7.2 手工冒烟(七项,全部代跑,2026-09-10)
+
+用户授权「七项全部代跑」。所有临时改动**跑完即恢复**。
+
+### 第 1 项 · 无 key 启动告警 ✅ PASS
+
+- 本机 `.env` 的 `DEEPSEEK_API_KEY` **本就为空**(实测 `raw_value_len = 0`),故「清空」态即当前态。
+- `uvicorn app.main:app`(端口 8123)→ **1 秒就绪**,`/health` → `200 {"status":"ok","env":"development"}`。
+- 进程存活 → **告警不阻塞启动** ✓。
+- 日志命中:`DEEPSEEK_API_KEY 未配置,对话将走降级回复(前缀 (LLM 未配置:请在 .env 设置 DEEPSEEK_API_KEY 后重启)),LLM 能力不可用`(`app/main.py:66-72`)。
+
+### 第 2 项 · 恢复 key → 无告警 ✅ PASS(**口径替代,已留痕**)
+
+- **替代说明**:本机无真实 DeepSeek key,「恢复 key」一支改用**非空占位值**(`sk-smoke-placeholder-not-a-real-key`)。
+ 告警分支只判 `not settings.deepseek_api_key`,**不校验 key 有效性**,故该替代足以验证条件分支。
+- 改后 `key_present: True` → 启动(端口 8124)→ `/health` 200 → 日志**未出现**该告警 ✓。
+- 顺带验证 trace 中间件:响应头 `x-trace-id: trc-48349abc526e4055` / `x-request-id: req-612a3f2acb8349d6` ✓。
+- **`.env` 已还原**,`key_present: False`(原状)✓。
+
+### 第 3 项 · 交易阻断 + 放行各一次 ✅ PASS
+
+| 场景 | 请求(debug 头 `X-Debug-Role: risk_demo` / `X-Debug-Actor: STAFF-DEMO`) | 响应 |
+| --- | --- | --- |
+| 阻断(SOP A-2) | CUST-4001 / PROD-161725 / subscribe / 20000 | `blocked=true`,`block_response_code=SUIT_AGE_CONFIRM`,`needs_branch_confirm=true`,`rule_refs=["FM-01"]` |
+| 放行(SOP A-3) | CUST-3001 / PROD-510300 / subscribe / 500000 | `blocked=false`,`triggered_rules=["RISK-001","RISK-002"]`,`alert_ids=["ALT-20260910-7BCBBC94"]` |
+
+**与 SOP 记录的预期逐字一致。** 库内核验:
+
+- `core_trade`:阻断单 `TRD-20260910-C1D8AA26` **未落库** ✓;放行单 `TRD-20260910-FE545DF0` 已落库(`confirmed`, 500000.00)✓
+- `risk_suitability_log`:CUST-4001 `is_blocked=1` ✓;`risk_alert` 出 `suitability` 单 `ALT-20260910-652280E1`(`pending_review`, score 90)✓
+- `audit_log`:`suitability_block / suitability_alert_created`、`trade_request / suitability_blocked`(`rule_id=SUIT_AGE_CONFIRM`)、
+ `trade_request / trade_accepted`(放行)、`risk_judgement / alert_created` ✓
+
+### 第 4 项 · 预警聚合行为不变 ✅ PASS
+
+SOP A-4:CUST-9527 / PROD-510300 / subscribe / 1000 **连发 4 笔**:
+
+| 笔次 | `triggered_rules` | `alert_ids` |
+| --- | --- | --- |
+| 1 | `[]` | `[]` |
+| 2 | `[]` | `[]` |
+| 3 | `["RISK-003"]` | `["ALT-20260910-C8600425"]` |
+| 4 | `["RISK-003"]` | `["ALT-20260910-C8600425"]` ← **与第 3 笔同一张单(并入,非新建)** |
+
+库内核验:
+
+- `risk_alert` CUST-9527 当日 **`pending_review` 单数 = 1** ✓;`payload.events` **长度 = 2** ✓
+- `audit_log` 呈 **`alert_created`(1184) → `alert_appended`(1187)`** 序列,聚合语义在审计层可证 ✓
+- 4 笔交易全部落库(`TRD-20260910-B66CB0D8` / `B39E1456` / `83D58A41` / `79EDC16E`)✓
+
+> 注:`risk_alert.status` 实际取值为 **`pending_review`**(文档口头简称「pending」)。首次核库我按 `status='pending'` 查得 0 条,
+> 系**断言写错**而非系统问题,修正后为 1 ✓。
+
+### 第 5 项 · Redis 不可用 → 退回进程内锁 ✅ PASS(**口径替代,已留痕**)
+
+- **替代说明**:本机 Redis 为 `redis-server.exe`(PID 7716,Services 会话),**无文档化启动方式、`redis-server` 不在 PATH、
+ `sc.exe` 被本机安全策略拉黑**,杀掉后无法保证原样拉起(且 `FLOW.md` 明示其非权威数据源)。
+ 改用 **`REDIS_URL=redis://127.0.0.1:6399/0`(死端口)**:应用侧抛 `ConnectionError`,与真宕机**同一条代码路径**
+ (`redis_gateway._ensure()` → 命令异常 → `locks._acquire_redis` 的 `except Exception` → `unavailable` → 进程内锁),且完全可逆。
+- 结果:启动**不被阻塞**(1s,`/health` 200)→ 交易返回 `blocked=false`,`alert_ids=["ALT-20260910-7BCBBC94"]`,**业务照常完成** ✓
+- 日志**明确出现 2 次** `Redis 锁不可用,退回进程内锁`:
+ `lock:agg:event:CUST-3001:2026-09-10`(`alert_service` 聚合锁)、`lock:l3:CUST-3001`(`profile_l3` L3 锁)✓
+- 底层异常:`redis.exceptions.ConnectionError: Error 10061 connecting to 127.0.0.1:6399`,被正确吞噬未上抛 ✓
+
+### 第 6 项 · Redis 恢复 → 行为一致 ✅ PASS
+
+- `redis.Redis.from_url(settings.redis_url).ping()` → **True**(`redis://127.0.0.1:6379/0`)。
+- 同笔交易 → `blocked=false`,`alert_ids=["ALT-20260910-7BCBBC94"]`(并入同单)✓
+- 日志中 `退回进程内锁` **命中 0 行** → 确实走 Redis 锁路径,**未降级** ✓
+- `triggered_rules` 由 `["RISK-001","RISK-002"]` 变为 `+RISK-003`:CUST-3001 当日累计至第 3 笔触发频次规则,
+ **属预期累积行为,非不一致**。
+
+### 第 7 项 · 调换装饰器验证 T-202 守卫变红 ✅ PASS(本项价值最高)
+
+守用例:`tests/test_audit_middleware.py::test_audit_middleware_runs_inside_trace_middleware:210`。
+
+| 步骤 | 结果 |
+| --- | --- |
+| ① 基线 | `1 passed`(绿) |
+| ② 调换 `main.py` 两装饰器(trace 先注册 → 变最内层;audit 后注册 → 变最外层) | 已调换,行序:`trace_middleware`@89 / `audit_middleware_entry`@117 |
+| ③ 跑守卫 | **`1 failed`** ✓ 变红 |
+| ④ 失败断言原文 | `AssertionError: audit 若先于 trace 执行,此处会静默为空` / `assert ''` @ `test_audit_middleware.py:221` |
+| ⑤ 还原 | `sha256` 前后同为 `d9d7559445dffd2a4576cdc5454030d3acae5fd4c076de8daa5a35f53802510a` → **字节级一致** ✓ |
+| ⑥ 再跑守卫 | `1 passed`(转绿)✓ |
+| ⑦ 现场核对 | `git status --short app/main.py` 空、无残留 `*.smoke.bak` ✓ |
+
+→ **守卫确实能抓住「audit 先于 trace 执行 → 全站审计静默丢 trace_id 且不报错」这一回归**。设计意图达成。
+
+---
+
+## 三、冒烟副产物与现场恢复
+
+### 3.1 集成测试失败(防护行为,非回归)
+
+冒烟后在真 MySQL 上跑全量:**507 passed / 3 failed**,全部落在 `tests/test_integration_risk.py`:
+
+- `_assert_a3_clean` → `CUST-3001 今日已有 confirmed 交易(影响 RISK-002 累计/RISK-003 频次口径)`(我的 `TRD-20260910-FE545DF0`)
+- `_assert_a4_clean` → `CUST-9527 今日已有 confirmed 交易(A-4 频次计数失真)`(我的 `TRD-20260910-B66CB0D8`)
+- `test_a7_handle_state_machine_compliance_forbidden` → 级联 `KeyError: 'a3_alert_id'`
+
+→ 这正是**核查单⑥「演示/测试同日交叉防护」前置断言**在按设计工作(交接文档已预警)。
+
+### 3.2 演示库重灌(用户拍板:现在重灌)
+
+按 `docs/项目框架设计/演示SOP-风控模块.md §2` 脚本化方式执行(`MYSQL_PWD` 传密码,避开 `reset.ps1` 交互 `-p` 卡死):
+
+1. `DROP DATABASE IF EXISTS jinrong_core; DROP DATABASE IF EXISTS jinrong_agent;`
+2. 依序灌 11 个 SQL:`scripts/core/00~06` → `表设计/01-mysql-共用底座.sql` → `表设计/02-mysql-agent专用.sql`
+ → `scripts/agent/seed-aml-list.sql` → `scripts/demo/prepare_risk_demo.sql`
+3. `python scripts/sync/sync_advisor_rel.py`
+
+**完成标志三项全部吻合**:AML 名单 **8** 条 · `prepare_risk_demo` **7** 行测评(`expires_at` 剩余 **275** 天)·
+`sync_advisor_rel` upserted **33** rows。
+
+### 3.3 全量复跑(用户要求「改完后再跑一次」)
+
+**`510 passed, 656 warnings in 9.52s`** → **复绿** ✓
+
+现场状态:`.env` 已还原(key 为空、`REDIS_URL` 为 6379)✓ · `app/main.py` 字节级还原且 git 无改动 ✓ · 无残留 `.smoke.bak` ✓。
+
+---
+
+## 四、回写清单
+
+| 文档 | 改动 |
+| --- | --- |
+| `docs/项目框架设计/TODO-架构改进.md` | T-107 手工冒烟勾选 + 完成判定块三勾 + 状态块改「全部闭环」+ push 状态纠正 |
+| `docs/交接文档-架构改进.md` | **v1.1 → v1.2**;状态块重写(push 纠正 + 两处替代口径留痕);§7.3 全勾;§10 重写为结项 |
+| `docs/交接文档-基金转换.md` | §9「另一条线的遗留」改为**已闭环**,纠正 push 表述 |
+| `docs/memory/MEMORY.md` | §0 末尾「待办两条线」改为「架构改进线已结项」+ push 纠正常见坑 |
+| `docs/memory/TODO.md` | 模块侧开放项「架构改进线 · 收尾」勾选并附七项证据摘要 |
+| `docs/memory/ITERATION.md` | 新增 **2026-09-10 架构改进线收尾闭环** 迭代行 |
+| `docs/memory/2026-09-10.md` | **本文件(新建)** |
+
+---
+
+## 五、下一步
+
+**架构改进线:结项,无待办。**(可选:push 后打里程碑 tag,待用户拍板)
+
+**基金转换线:第 4 步「产出开发计划」** —— 输入已齐备:
+`docs/项目框架设计/架构设计-基金转换交易.md` §15(任务映射 T-0~T-13)+ §15.1(依赖拓扑与并行分组)+ §12(风险表);
+产出落点 `docs/项目框架设计/开发计划-基金转换交易.md`(待创建)。
diff --git a/docs/memory/FRAMEWORK.md b/docs/memory/FRAMEWORK.md
index ce84b97..5d93520 100644
--- a/docs/memory/FRAMEWORK.md
+++ b/docs/memory/FRAMEWORK.md
@@ -39,6 +39,7 @@
| 代理人助手 Agent | L2 画像、RAG、草稿 | L1 只读、Milvus | 空壳 service(chat 骨架已通) |
| 数据分析 Agent | NL→SQL→解读 | Core RO、画像只读 | 空壳 service(chat 骨架已通) |
| 风控监测 Agent | 预警、L3、R-02 适当性 | 交易事件、AML 名单 | **已实现 B1~B7 + C1~C3(对话线四 Tool + risk 分支 StateGraph,A-6 验收)**;追加 FR-8/9/10(C4~C6:集中度/时效升级/行为链)方案已定稿待编码 |
+| **基金转换(convert)交易** | 转出赎回 + 转入申购、逐批次计费、补差费、批次维护 | Core 写(仅 `app/gateway/`)、`core_share_lot` 批次表、规则引擎 | 🟡 **第 5 步进行中**(**T-0 / T-0b / T-1 / T-2 / T-2b 已完成(609 passed),下一步 = T-3**):PRD **v0.9.1** + 架构 **v1.0** + 开发计划 **v1.0**(T-3~T-13 待实现);入口 `docs/交接文档-基金转换.md` |
| Core 只读层 | L0 事实查询 | `jinrong_core` | **已实现 + 已接对话 Tool(T-04)**:core_ro 经 app/tool/core_tools.py 三只读 Tool(L0/持仓/流水)进 chat;风控扩展查询照旧 |
| 共用底座 | 会话、审计、输入防护 | MySQL 11 表 + Redis | **已接入(2026-09-07)**:会话(T-06 session_repository + memory_service 窗口)、审计中间件(T-02 http_access + input_guard_log 双写)、agent_tool_call Tool 留痕(T-04)、输入防护(T-03 input_guard:注入词表纯函数检测 + oversize + Redis 固定窗口限流,chat 链路 限流→注入/超长→归属) |
| 对话编排 | LangGraph StateGraph + DeepSeek | langgraph/langchain-openai | **已实现(T-07 骨架 + T-04 Tool 节点 + C1 风控四 Tool + C5 追加 query_overdue_alerts 共五只 + C2 risk 分支)**:tool(分组关键词意图→Tool,customer/advisor/risk)→llm→guard(免责声明);analyst 分支与 LLM intent 待后续 |
diff --git a/docs/memory/ITERATION.md b/docs/memory/ITERATION.md
index 2dcd385..7bd0a9b 100644
--- a/docs/memory/ITERATION.md
+++ b/docs/memory/ITERATION.md
@@ -12,3 +12,11 @@
| 2026-09-07 | 《实现方案-风控追加需求v1.1-C4C6.md》定稿:独立 AI 评审 FAIL(P0×1/P1×4/P2×6)→ 修订闭环;C4~C6 编码依据可照抄 | 开发流程第 2 步(另一 AI 验证挑毛病) | 实现方案文档 |
| 2026-09-07 | 全库文档同步:分支口径修正(`feature/risk` → 实况 `risk-control-agent`,与 main 分叉 67/4)+ T-21 评审闭环 / 425 绿 / T-03 / C1~C3 状态落账 + 新建项目根交接文档.md | 文档与实况不一致(用户排查要求) | MEMORY / TODO / REQUIREMENTS / FRAMEWORK / FLOW / ENVIRONMENT / ITERATION / PRD / 架构设计 / 开发计划 / README / 交接文档 |
| 2026-09-07 | **阶段一「对齐 main 基准」AL-01~AL-08 完成**(逐项独立 commit bb244f4~b5fd52e):SUIT-001~008 换核为 core_ro.check_suitability(C×R 矩阵 + match_result 五值 + JR-AST/FM 契约)· risk_suitability_log 重建 21 列 · Core 表 is_hnw/风评 expires_at 七新列 · 种子 33 客户/14 产品 · 全量 **406 passed 0 failed 0 skipped**(425→406,SUIT 测试重写)· uvicorn 冒烟三端点通过 · risk-m1 补打(3c07de6)· 六项拍板全落定 | 阶段一执行(用户拍板 main = 需求唯一基准);基线变更与 SUIT 体系退役需落账 | MEMORY / TODO / REQUIREMENTS / FRAMEWORK / FLOW / ENVIRONMENT / ITERATION / README / 交接文档 |
+| 2026-09-08 | 前端接入面交付:**方案 B** 会话管理三端点(`8328c24`)+ **方案 C** SSE 流式对话(`01ec5fc`),测试 482→**503 绿** | 前端 React 多 Agent 入口需后端接入面先就绪(用户拍板方案 B+C) | chat.py / session_repository / agent_service / 测试 / README / 交接文档 |
+| 2026-09-09 | **架构改进与稳定性加固 T-101~T-202**:Redis 双层分布式锁(T-201)+ 无 key 启动告警(T-107)+ 审计 trace 顺序守卫(T-202)+ 文档口径勘误(词表 45 / Tool 5 / Agent 表 6);pytest 503→**510 绿**(+7);接口契约与表结构零变更 | 架构梳理发现 24 项待处理,两轮独立审核通过;用户拍板「补告警 + 换 Redis 锁」;合并 main 前只做低风险项 | 交接文档-架构改进 / 架构设计-风控模块 / MEMORY / 开发计划-架构改进 / TODO / 测试 +7 |
+| 2026-09-10 | **架构改进线收尾闭环**:§7.2 七项手工冒烟补跑 **7/7 PASS**(无 key 告警 / 交易阻断放行 / 预警聚合 / Redis 降级退回进程内锁 / Redis 恢复零降级 / 调换装饰器使 T-202 守卫变红后字节级还原);**核实并纠正「`037ce7e` 未 push」的过期表述**(远程 `risk-control-agent` = `fffb78a` = 本地 HEAD,早已推送);冒烟残留按 SOP §2 重灌双库清除,全量 **510 passed** 复绿 | 架构改进线唯一缺口是手工冒烟未执行;交接文档/项目记忆的 push 状态与实际 git 状态不符(以 `git ls-remote` 为准) | TODO-架构改进 / 交接文档-架构改进 / 交接文档-基金转换 / MEMORY / TODO / ITERATION / 当日日志 |
+| 2026-09-10 | **基金转换(convert)线设计闭环**:PRD **v0.9**(33 条外审闭环)+ 架构 **v1.0**(独立评审通过,13 条建议 **0 悬空**)+ 执行期风险 5 条 + **D18/D19/D20**;**DB 账号分离定案**(用户拍板「按真实项目走」,接主架构 C1);**新建 `docs/交接文档-基金转换.md`** | `PRD-风控监测Agent` FR-1 一期显式拒收 convert,需放开;合规口径须外部事实核验(证监会公告〔2025〕22 号);账号方案为跨线交叉点 | PRD-基金转换 / 架构设计-基金转换 / 评审待办 / 处置表 / **交接文档-基金转换(新)** / MEMORY / TODO / 交接文档.md / 交接文档-架构改进 / 开发计划-架构改进 |
+| 2026-09-10 | **基金转换(convert)线第 4 步开发计划闭环**:产出 `docs/项目框架设计/开发计划-基金转换交易.md`(**v1.0,1,047 行**,T-0~T-13 逐任务改法 + DoD + §0 速览 + §1.4 **十五条代码事实核对表** + §1.5 **七条实现级裁定 R-a~R-g** + §12 **十六条回归面** + §13 PRD 19 条验收映射 + §14 实现级风险表);**经四轮独立子代理审核收敛:12 条 → 接受 11 / 驳回 1(附实测证据)/ 0 悬空**(一轮补 R15 真 MySQL 集成用例 `test_integration_risk.py:426-437` + R16 redeem 用例 + **R-c 拆「降级规则 + 覆盖率补偿」双条** + F-11 计数 11→13;二轮抓出 **R-a 并发论证不成立**——执行权锁是**请求粒度** → 增第 ③ 步 `IntegrityError` 回退 + T-6 并发用例;三轮修 §14「四道→五道」口径不一致 + **实测驳回**「SQLAlchemy 会抛 `PendingRollbackError`」的推测;四轮终验通过、无「声明已改实际未改」残留);**三项实测加固**(sqlite 3.50.4 不支持 `ON DUPLICATE KEY UPDATE`,且 `WHERE NOT EXISTS`/`FROM DUAL` 两个候选写法**方言互斥**;SQLAlchemy 2.0.51 异常后同事务可用;pydantic 2.13.4 `Optional+gt` 保 422) | AIcoding 第 4 步;用户要求「写完拉起子进程审核,改完后再审,审到没问题才能进行下一步」;架构/PRD 存在 2 处口径不一致需裁定(W-1/W-2),且实现级空白 7 处需收口 | 开发计划-基金转换交易(新) / 交接文档-基金转换(v1.2) / MEMORY / TODO / ITERATION / skill `ai-coding-step4-dev-plan`(新) |
+| 2026-09-10 | **基金转换线:T-0 / T-0b / T-1 落地 + PRD 费率分类修正(v0.9 → v0.9.1)**:**T-0** sqlite/MySQL 结构对齐(`core_holding` 列名+PK、补 `core_product_nav`)+ 建库自校验 · **T-0b** DB 账号分离 D20(`xh_core_ro` / `xh_core_rw` / `xh_agent_rw`)· **T-1** 数据层(`core_fee_rule`、`core_share_lot`、`core_convert_lot_detail`、`risk_convert_detail` 四表 + `07-seed-fee-rule` / `08-seed-share-lot` / `09-seed-org` 三个种子 + reset 链),全量 **516 passed / 3 skipped**(批 0 后基线,零回归),T-1 数据层断言 **8/8 PASS**;**外审漏网的费率分类错误修正**——`PROD-005827` 由「主动偏股 0.0080」归位「其他混合型 0.0050」(22 号文 §8 分档),主示例转入方改真主动偏股 `PROD-003095`,`09-seed-org` 按真实「管理人全产品线」重排为 v1.1 | 用户驳回「那是两种类型,怎么能混呢?」并强调「按真实业务走,强调很多遍了,这个项目要贴近真实,是要上线的」;根因是**五轮评审只做内部自洽核对,从未做「产品类型 ↔ 费率档」匹配核对** | PRD-基金转换(v0.9.1) / 开发计划-基金转换交易 / 交接文档-基金转换(v1.3) / 架构设计-基金转换交易 / AGENTS.md / `09-seed-org.sql`(v1.1) / `verify_convert_seed.py` / MEMORY(自检第 11、12 问) / TODO / FRAMEWORK |
+| 2026-09-10 | **基金转换线 T-2(纯函数包)+ T-2b(实算回填)完成**:新增 `app/service/convert/` **7 文件**(`__init__` / `types`(frozen dataclass + 3 归一工具)/ `calc` / `fee` / `nav` / `lot_bootstrap` / `errors`(`ConvertError` + **11** 子类))+ 新增 `tests/test_convert_calc.py` **93 用例**(12 类,含 **HALF_UP 反向自证**与**纯函数零 IO 依赖断言**,把 DoD 的「人工 grep」升级为硬断言);**重写 `scripts/dev/calc_convert_demo.py`** —— 去掉脚本内公式副本、改调生产 `calc.py`、末尾 15 项自校验(不一致即退出码 1),使其兼作「PRD §5.3 ↔ 生产口径」一致性门禁;新增实现级裁定 **R-h**(`plan_lots` 零剩余不触发强制处置);两处文档计数差异已留痕(异常实为 11 个而非 8 个 / 架构 §7 补 `hold_days`·`rounding_diff`·`ensure_batch_limit` 三签名);全量 **609 passed / 3 skipped**(516 → +93,零回归) | 用户「继续推进到结束」;T-2 是并行组 A(T-3/T-4/T-5)与 T-2b 的唯一前置;原实算脚本自带公式副本 = 与 D18 同类的漂移风险 | 开发计划-基金转换交易(§1.5 R-h + §4.2/§4.3 执行记录 + DoD 勾选) / 架构设计-基金转换交易(§7 签名回填 · §8.3 `FEE_RULE_MISSING` 注 · §15 T-2 完成) / PRD-基金转换(§5.3 数值来源) / 交接文档-基金转换(v1.4) / AGENTS.md / `app/service/convert/*`(新) / `tests/test_convert_calc.py`(新) / `scripts/dev/calc_convert_demo.py` / MEMORY / TODO / FRAMEWORK / ITERATION |
+| 2026-09-10 | **基金转换线第 5 步开工 · 第 0 批门禁 T-0 + T-0b 完成**:基线 510 → **516 passed / 3 skipped**。**T-0**:`tests/_ddl.py` 重写 `core_holding`(`qty`/`cost_amount`/`as_of`/`pnl_pct` + PK + `uk_cust_product`)+ 新增 `core_product_nav` + `_assert_ddl_aligned()` 建库自校验;2 处测试 INSERT 改 `qty` **并补 3 个 NOT NULL 列**(**原计划漏项**);`test_db.py` +3 用例(含**门禁反向验证的自动化用例**)。**T-0b(D20 账号分离)**:新增 `scripts/core/00-grant.sql`(3 账号逐表授权,不进 reset.ps1);`settings.py` +6 项;`db.py` `get_engine(db, role)` + `_resolve_credentials`(缓存键 `(db, role)`,未配置回退 `mysql_user`);`core_ro`→`ro` / `gateway_repository`→`rw` / `risk_repository`·`session_repository`→`rw` 显式;`conftest.py` 4 处→`admin`(R-e)。**两处口径修正**:① `audit_log` 实授 **`SELECT, INSERT`**(字面「只授 INSERT」会连读一起剥夺,致 `has_engine_error_audit`/`list_audit_events` 失权)② conftest 必须 admin。**遗留环境操作**:`00-grant.sql` 需管理员执行 + 写 `.env`(3 条权限断言未配置时自动 skip) | AIcoding 第 5 步(T-0/T-0b 为阻断前置);用户「你进行下一步」 | tests/_ddl.py · tests/test_db.py · tests/conftest.py · tests/test_chat_tools.py · tests/test_concentration_c4.py · app/utils/db.py · app/config/settings.py · 4 个 Repository · **scripts/core/00-grant.sql(新)** · 开发计划-基金转换(§3.3) · 架构设计-基金转换(§11.1 实施记录) · 交接文档-基金转换(v1.2) · MEMORY / TODO / 当日日志 |
diff --git a/docs/memory/MEMORY.md b/docs/memory/MEMORY.md
index 6d04972..c2b0c14 100644
--- a/docs/memory/MEMORY.md
+++ b/docs/memory/MEMORY.md
@@ -11,6 +11,8 @@
**当前进度:** 需求与表设计已定 · **风控模块 B1~B9b 全部完成(M2 tag risk-m2),M4 复核已闭环(2026-09-07),风控阶段 B 正式完结** · **Wave 0 已完成(2026-09-07,经独立 AI 评审闭环)**:T-01 JWT 鉴权(auth_service Auth SDK + deps 工厂替换 + X-Agent-Type 准入矩阵)/ T-02 审计中间件(http_access + 独立 request_id + 4xx/500 统一错误体 + input_guard_log 双写)/ T-06 chat 最小闭环(POST /api/chat + 会话落库 + Redis 窗口)/ T-07 LangGraph StateGraph 骨架 + DeepSeek(无 key 降级)· **T-04 Core RO Tool 节点已完成(2026-09-07)**:app/tool/core_tools.py 三只读 Tool + tool_service(意图/归属校验/run_tool)+ 图 tool 节点 + agent_tool_call 落库 + `utils/authz.py` 公共鉴权留痕,**321 测试绿(首评+复审双闭环)**。风控阶段 C 已完成(2026-09-07,345 绿,tag `risk-m3`,A-6 对话线验收通过,独立 AI 评审 PASS P0=0)。T-03 输入防护已完成(2026-09-07,378 绿,独立 AI 评审 PASS with findings P0=0):app/service/input_guard.py 注入词表 45 条纯函数检测 + oversize 4000 + actor 级 Redis 固定窗口限流 30 次/分(fail-open);chat 链路顺序 = 鉴权→准入→空白→限流 429→注入/超长 400→归属→会话,被拒 fail-fast 不建会话,blocked 落 input_guard_log(ENUM 四值已用满)。**阶段一「对齐 main 基准」AL-01~AL-08 已完成(2026-09-07,逐项独立 commit bb244f4~b5fd52e):适当性判定换核为 main 的 core_ro.check_suitability(C×R 矩阵表数据驱动,match_result 五值/JR-AST-012/FM-01/FM-03/JR-AST-PRO 契约,SUIT-001~008 退役);risk_suitability_log 重建 21 列;Core 表加 is_hnw/风评七新列(expires_at);种子 33 客户/14 产品;全量 406 passed 0 failed 0 skipped + uvicorn 冒烟三端点通过;risk-m1 已补打(指向 3c07de6)**。**下一步:阶段一验收门(**合并 main 前的最终交付检查点,非分支开发阻塞**;用户浏览器目视确认 UI)→ AL-09 合并 main → AL-10 PRD v1.2 → AL-11 docx 登记 → 阶段二 C4~C6(**已完成并打 `risk-m4` tag**)/ 前端 React 多 Agent 入口(HashRouter `web/` init)。****开发在分支 `risk-control-agent`(与 origin/main 已分叉:领先 84 提交 / 落后 0,origin/main 为分支祖先;**分支已推送远程,origin/risk-control-agent 同步于 `1d00e53`,本地跟踪已建立**;**架构改进与稳定性加固 T-101~T-202 已于 2026-09-09 完成并推送 origin/risk-control-agent(2d0e2fa..f733fc2 快进,pytest 510 绿,较 503 基线 +7 例:T-201.3 双层锁 6 例 + T-202 trace 顺序守卫 1 例),含 Redis 双层分布式锁 T-201 与审计中间件 trace 顺序守卫 T-202,均为文档/告警/锁原语修正、接口契约与表结构零变更;远程已提示走 PR 合并 main;**合并(risk-control-agent → main)由合并执行人负责,不归用户管**——AI 只负责把分支推到远程 + 更新文档,不得主动发起 PR / 执行合并,移交执行人按《合并注意事项-风控模块并入main.md》操作。** 旧文档中的 `feature/risk` 为过时口径)。**
+**⚡ 并行新线 · 基金转换(convert)交易(2026-09-10 设计+开发计划闭环,**第 5 步进行中:T-0 / T-0b / T-1 已完成**):** `PRD-风控监测Agent` FR-1 一期显式拒收 convert,本线将其放开。**AIcoding 第 1~4 步已完成**:PRD **v0.9.1 定稿**(33 条外审闭环 + 2 处架构回填 + **费率分类修正**)→ 架构 **v1.0 定稿**(独立评审通过,13 条建议 **0 悬空**,接受 10 / 修正性接受 3 / 驳回 0)→ 门控 **M-7 已满足** → **开发计划 v1.0 已产出(`docs/项目框架设计/开发计划-基金转换交易.md`,**1,047 行**)并经**四轮**独立子代理审核收敛(**12 条意见 → 接受 11 / 驳回 1(附实测证据)/ 0 悬空**)。**第 5 步进行中:T-0 + T-0b + T-1 + T-2 + T-2b 已于 2026-09-10 完成(609 passed / 3 skipped;T-1 断言 8/8 PASS;T-2 纯函数包 7 文件 / 93 用例;T-2b 实算脚本 15/15 一致)**,**下一步 = T-3(并行组 A:T-3 / T-4 / T-5 可同时开工)**。**开工前置两个阻断项(✅ 2026-09-10 均已完成)**:**T-0**(sqlite/MySQL 结构对齐:`core_holding` 列名+PK **+ 补 `core_product_nav`**,建库自校验,**同步改 2 处测试 INSERT 并补 3 个 NOT NULL 列**)与 **T-0b**(**DB 账号分离 D20**:`xh_core_ro` SELECT 全库 / `xh_core_rw` **4 表写**无 DELETE·DDL / `xh_agent_rw` **`audit_log` 只授 SELECT+INSERT**(不可改删);**`conftest.py` 四处 engine 已显式 `role="admin"`**)。**遗留环境操作**:`scripts/core/00-grant.sql` 需管理员执行一次 + 写 `.env`,未执行时 3 条权限断言自动 skip,不阻塞 T-1。设计资产五件套:`docs/PRD/PRD-基金转换交易.md` · `docs/项目框架设计/架构设计-基金转换交易.md` · **`docs/项目框架设计/开发计划-基金转换交易.md`(新)** · `评审待办-风控主架构与基金转换.md` · `基金转换-审查意见处置表.md`。**开工前必读开发计划 §1.4(15 条代码事实)/ §1.5(7 条实现级裁定 R-a~R-g)/ §12(16 条回归面)** —— 尤其是 **R-a(弃用方言 UPSERT)· R-b(流水写 redeem/subscribe 不写 convert)· R-e(conftest 用 admin)** 三条,不读必踩。**交接入口:`docs/交接文档-基金转换.md`**。合规基准 = 证监会公告〔2025〕22 号。
+
**仓库地图:**
| 路径 | 状态 | 说明 |
@@ -34,7 +36,7 @@
| `scripts/core/*.sql` + `reset.ps1` | **已实现** | Core 模拟库 DDL + 种子 |
| `scripts/agent/` `scripts/demo/` `scripts/dev/` | **已实现** | AML 名单种子 + 风控演示数据 + subscribe_alerts/rebuild_alerts + issue_dev_token(JWT 签发) |
| `scripts/sync/*.py` | **已实现** | 归属同步 + Neo4j 全图 |
-| `tests/` | **已实现** | 36 个测试模块 510 用例(sqlite 隔离;DDL 单一事实源 `_ddl.py`;`test_integration_risk.py` 走真 MySQL + TRD-TEST- 前缀隔离;pytest 依赖装在系统 Python 3.13.14;阶段一基线 425→406,前端接入 B/C 后 **482→503**,架构改进 T-201.3+T-202 后 **503→510**)。**改路由必同步 `tests/test_main.py::test_all_routers_mounted` 的路径清单,否则必红** |
+| `tests/` | **已实现** | 36 个测试模块 516 用例(sqlite 隔离;DDL 单一事实源 `_ddl.py`;`test_integration_risk.py` 走真 MySQL + TRD-TEST- 前缀隔离;pytest 依赖装在系统 Python 3.13.14;阶段一基线 425→406,前端接入 B/C 后 **482→503**,架构改进 T-201.3+T-202 后 **503→510**)。**改路由必同步 `tests/test_main.py::test_all_routers_mounted` 的路径清单,否则必红** |
| `docs/需求拆解/` | 已定 | 场景 P0、矩阵、合规原文 |
| `docs/PRD/PRD-风控监测Agent.md` | **已冻结(v1.1)** | 风控 PRD v1.0 + v1.1 追加 FR-8/9/10(§4A)+ 规则表附录 |
| `docs/项目框架设计/实现方案-风控追加需求v1.1-C4C6.md` | **已定稿** | C4~C6 编码依据(经独立 AI 评审修订闭环);分支/进度速览另见项目根 `交接文档.md` |
@@ -42,6 +44,10 @@
| `docs/项目框架设计/表设计/` | 已定 | Agent 共用 11 表 + agent 专用 SQL |
| `docs/项目框架设计/Core模拟底座/` | 已定 | 无真实 Core 时的 L0 方案 |
| `web/` | **不存在** | 前端 React 待 init |
+| `docs/PRD/PRD-基金转换交易.md` | **已定稿(v0.9.1)** | 基金转换线需求权威(FR-C1~C16 / §4 表结构 / §5 接口 / §9 验收);⚠️ **代码未实现** |
+| `docs/项目框架设计/架构设计-基金转换交易.md` | **已定稿(v1.0)** | 基金转换实现依据(D1~D20 / §5 事务 / §11.1 DB 账号 / §15 任务 T-0~T-13);⚠️ **代码未实现** |
+| `docs/项目框架设计/开发计划-基金转换交易.md` | **已定稿(v1.0 · 2026-09-10)** | **基金转换实现计划**(T-0~T-13 逐任务改法 + DoD + §1.4 **十五条代码事实核对表** + §1.5 **七条实现级裁定 R-a~R-g** + §12 **十六条回归面** + 四轮审核记录 + **§3.3 第 0 批 + §4.1 第 1 批执行记录**);**T-0/T-0b/T-1 已实现**,T-2 起待实现 · **开工必读** |
+| `docs/交接文档-基金转换.md` | **新增(2026-09-10)** | **基金转换线交接入口**——给下一会话 AI,读完即可开工 |
**本地 bootstrap(首次):** 完整步骤与前置说明见 `FLOW.md` §0(权威),速览:
@@ -64,6 +70,8 @@
**下一步开发(见 TODO):** **模块侧交付完毕(2026-09-07:全量 pytest 482 绿 + 接口实调验收通过——suitability/check 阻断+放行、simulate/trade 阻断、三条鉴权边界 401/403/403 契约零偏差;`risk-m1~m4` tag 齐)。合并 main 已移交合并执行人,操作手册《docs/项目框架设计/合并注意事项-风控模块并入main.md》(含基底锁定/20 冲突裁决/14 静默文件/三硬伤/合并后必测,实测数据编制)。模块侧开放项:chat 链路 risk_suitability_log.actor_id 落 SYSTEM 待评估 / 前端 React 多 Agent 入口(`web/` 未 init,归属待拍板)。**演示走查按 `docs/项目框架设计/演示SOP-风控模块.md`(debug 头通道仍有效;JWT 通道签发用 `scripts/dev/issue_dev_token.py`;演示库已按 AL-08 expires_at 新口径重灌)。知识库入库:`python scripts/kb/build_kb.py`(先启 Ollama;**Milvus 数据路径必须纯英文**——faiss 不支持中文路径,本机 .env 已配 C:/Users/YUAN/.jinrong/milvus/)。
+**另(2026-09-10 待办)**:① **基金转换线**第 5 步进行中(**T-0 / T-0b / T-1 / T-2 / T-2b 已完成,下一步 = T-3**;设计 + 开发计划均已闭环,入口 `docs/交接文档-基金转换.md`);② ~~架构改进线收尾~~ —— **2026-09-10 已闭环结项**:§7.2 七项手工冒烟补跑 **7/7 PASS**、冒烟残留按 SOP §2 重灌双库清除、全量 **510 passed** 复绿;**「`037ce7e` 未 push」的旧表述已作废**(实测 `git ls-remote`:远程 `risk-control-agent` = `fffb78a` = 本地 HEAD,`037ce7e` 在其祖先链上,早已推送;本地 `git branch -vv` 显示 `origin/risk-control-agent: gone` 只是远程跟踪引用失效,`git fetch` 即恢复,非远程分支被删)。入口 `docs/交接文档-架构改进.md`。
+
**禁止(改代码前必记):** Core 正式 C1~C5 不可被画像覆盖 · 审计表只 INSERT · 代理人草稿不外发 · 仅 R-02 可阻断交易 · 四 Agent 不互调 LLM。
**里程碑 tag 中文对照(tag 本体保留英文原名——git 技术标识符需跨平台/脚本稳定引用,符合「技术字面量保留英文」约定;中文名经对照表统一):**
@@ -145,7 +153,7 @@ Core 模拟:scripts/core/reset.ps1 · 文档 docs/项目框架设计/Core模
种子:scripts/agent/seed-aml-list.sql(AML 名单)· scripts/demo/prepare_risk_demo.sql(reset 后重跑)
依赖:requirements.txt(LangGraph + langchain-core/openai + FastAPI + SQLAlchemy)
启动:uvicorn app.main:app --reload → GET /health
-测试:python -m pytest(510 用例;集成测试需本机演示数据,未灌库时自动 skip)
+测试:python -m pytest(516 用例;集成测试需本机演示数据,未灌库时自动 skip)
运维/演示脚本:scripts/demo/subscribe_alerts.py(订阅推送演示)· rebuild_alerts.py TRD-xxx(引擎异常补偿重放)
JWT 联调:python scripts/dev/issue_dev_token.py --sub STAFF-30001 --roles risk_officer(+ Authorization: Bearer + X-Agent-Type)
配置:.env(见 .env.example)
@@ -172,6 +180,8 @@ RBAC 联调账号:scripts/dev/rbac-seed-reference.md
| `docs/需求拆解/` | 业务原文(场景、矩阵、合规) |
| `docs/项目框架设计/` | 表结构、JWT 手册、Core 模拟、技术版本 |
| `docs/业务记忆管理/` | Redis 短期 vs MySQL/Milvus/Neo4j 权威记忆 |
+| `docs/交接文档-基金转换.md` | **基金转换线开工入口** —— 接手本线先读这一份(不必重读代码) |
+| `docs/PRD/PRD-基金转换交易.md` · `docs/项目框架设计/架构设计-基金转换交易.md` | 基金转换需求与实现依据(**v0.9.1 / v1.0,代码未实现**) |
缺 `docs/memory/*` 文件:按 project-memory-kit 同名补回,**禁止空模板盖进度**。
@@ -189,6 +199,7 @@ RBAC 联调账号:scripts/dev/rbac-seed-reference.md
2. 改动属于 api / service / tool / repository 哪一层?
3. 是否需 customer_id 归属与 JWT RBAC?
4. Core 是模拟库只读还是 agent 库读写?
-5. 如何验证?(`python -m pytest` 全量(当前 **510 绿**)· uvicorn 启动 + /health · SQL / sync 脚本 · 对照 REQUIREMENTS 验收列)
+5. 如何验证?(`python -m pytest` 全量(当前 **516 passed / 3 skipped**,基线 510)· uvicorn 启动 + /health · SQL / sync 脚本 · 对照 REQUIREMENTS 验收列)
+6. **当前有哪两条并行线?**(① 风控/架构改进线:**已结项**(510 基线绿、§7.2 七项冒烟 7/7 PASS、`037ce7e` 已核实早已推送);② **基金转换线**:设计闭环,**第 5 步进行中 —— T-0 / T-0b / T-1 / T-2 / T-2b 已完成(609 passed),下一步 = T-3**)——动代码前先确认自己属于哪条线,别混淆前置条件。
大任务:FRAMEWORK/FLOW 与实现状态不符时先更新 memory 再编码(用户确认跳过除外)。
diff --git a/docs/memory/TODO.md b/docs/memory/TODO.md
index e4c82ca..5a5d8d0 100644
--- a/docs/memory/TODO.md
+++ b/docs/memory/TODO.md
@@ -7,6 +7,22 @@
**阶段一 AL-01~AL-08 与阶段二 C4~C6 均已完成(2026-09-07)**:全量 pytest **482 passed 0 failed 0 skipped**(真库集成)✓ · uvicorn 冒烟三端点 ✓ · 接口实调验收 ✓(2026-09-07:suitability/check 阻断+放行、simulate/trade 阻断、三条鉴权边界 401/403/403,契约零偏差)· risk-m1~m4 tag 齐。**合并 main 已移交合并执行人**(操作手册:《docs/项目框架设计/合并注意事项-风控模块并入main.md》,随分支上传),后续模块侧待办见下方。
+**⚡ 并行新线 · 基金转换(convert)**(2026-09-10):**设计 + 开发计划均已闭环** —— PRD **v0.9.1** + 架构 **v1.0** + 独立评审 13 条 **0 悬空**(接受 10 / 修正性接受 3 / 驳回 0),门控 **M-7 已满足**;**第 4 步开发计划 v1.0 已产出并经独立审核**(4 条意见全接受、**驳回 0**,含新增 2 条回归面 R15/R16 + R-c 双条修订);**第 5 步进行中**:**T-0 + T-0b + T-1 + T-2 + T-2b 均于 2026-09-10 完成**(**609 passed / 3 skipped**;T-1 断言 **8/8 PASS**、T-2 纯函数 **93 用例**、T-2b 实算 **15/15 一致**),**下一步 = T-3(并行组 A:T-3 / T-4 / T-5 可同时开工)**。两个阻断前置(**T-0** sqlite/MySQL 列名统一 + 建库自校验 · **T-0b** DB 账号分离 D20:`xh_core_ro`/`xh_core_rw`/`xh_agent_rw`)**均已落地**。**入口:`docs/交接文档-基金转换.md`**(读这一份即可开工);**开工前必读开发计划 §1.4(15 条代码事实)+ §1.5(7 条实现级裁定 R-a~R-g)+ §12(16 条回归面)**。
+
+### 基金转换线待办(推荐顺序)
+
+- [x] **【第 4 步】产出开发计划** —— **完成(2026-09-10)**:`docs/项目框架设计/开发计划-基金转换交易.md`(**1,047 行**,含 T-0~T-13 任务拆解 + 逐条改法 + DoD + **四轮审核记录**)。**四轮独立子代理审核已收敛:12 条意见 → 接受 11 / 驳回 1(附实测证据)/ 0 悬空**(第一轮:补 R15 真 MySQL 集成用例 `test_integration_risk.py:426-437` + 补 R16 redeem 用例 + **R-c 拆「降级规则 + 覆盖率补偿」双条** + F-11 计数 11→13 更正;第二轮:发现**执行权锁是请求粒度**致并发首次建行撞 UNIQUE → **R-a 增第 ③ 步 `IntegrityError` 回退 UPDATE**,并实测排除 `WHERE NOT EXISTS`/`FROM DUAL` 两个不可移植写法;第三轮:修 §14「四道→五道」口径不一致 + 驳回「SQLAlchemy 会抛 PendingRollbackError」的推测;第四轮:终验通过、无「声明已改实际未改」残留)。**本计划新增的增量资产**:§1.3 两处 PRD-架构口径裁定(W-1/W-2)· §1.4 十五条代码事实核对表 · §1.5 七条实现级裁定(**R-a「条件 UPDATE → 首次 INSERT → `IntegrityError` 回退 UPDATE」三步**、R-b 流水写 redeem/subscribe 不写 convert、R-c 批次降级+覆盖、R-d `sum_trades_on_date` 去重、R-e conftest 用 admin 角色、R-f `core_product_nav` 并入 T-0、R-g 自校验落 `_ddl.py`)· §12 十六条回归面 · §13 PRD 19 条验收映射 · §14 新增实现级风险表
+- [x] **【第 5 步 · T-0 · 阻断前置】sqlite/MySQL 结构对齐 + 建库自校验** —— **完成(2026-09-10)**:`tests/_ddl.py` 重写 `core_holding`(`qty`/`cost_amount`/`as_of`/`pnl_pct` + PK + `uk_cust_product`)+ 补 `core_product_nav`(R-f)+ `_assert_ddl_aligned()` 建库自校验(R-g);2 处测试 INSERT(`test_chat_tools.py:81`、`test_concentration_c4.py:74`)改 `qty` **并补 3 个 NOT NULL 列**(原计划漏项);`test_db.py` 新增 `test_core_holding_columns` / `test_core_product_nav_columns` / `test_ddl_alignment_guard_fails_when_column_missing`(反向验证门禁,无需手工改删)
+- [x] **【T-0b · 阻断前置】DB 账号分离(D20)** —— **完成(2026-09-10)**:`scripts/core/00-grant.sql` 新建(3 账号逐表授权,**不进 reset.ps1**);`settings.py` +6 项;`db.py` 改 `get_engine(db, role)` + `_resolve_credentials`(缓存键 `(db, role)`,未配置回退 `mysql_user`);`core_ro`→`ro` · `gateway_repository`→`rw` · `risk_repository`/`session_repository`→`rw` 显式;`conftest.py` 4 处→`admin`(R-e)。**两处口径修正**:① `audit_log` 实授 **`SELECT, INSERT`**(字面「只授 INSERT」会剥夺读,致 `has_engine_error_audit`/`list_audit_events` 失权)② conftest 必须 admin。**遗留环境操作**:`00-grant.sql` 需管理员执行 + 写 `.env`,未执行时 3 条权限断言自动 skip
+- [x] **【T-1 · 第 1 批】DDL + 种子 + sqlite 同步** —— **完成(2026-09-10)**:`scripts/core/01-ddl.sql` 新建 `core_fee_rule`/`core_share_lot`/`core_convert_lot_detail` + `core_trade` 加 `convert_group_id`+索引 + `core_product` 加 8 列(`subscribe_fee_rate` 等)+ `fee_rate` 补 COMMENT;**新增 `07-seed-fee-rule.sql`**(14 产品 × 5 档,按 22 号文 §10)/ **`08-seed-share-lot.sql`**(58 行持仓 → 61 行批次,Σ remain_qty 恒等于 qty,CUST-9527 跨批次)/ **`09-seed-org.sql`**(管理人 + TA + 申购费率 + 最低持有余额);`reset.ps1` 追加 07/08/09;`02-mysql-agent专用.sql` 追加 `risk_convert_detail`(status ENUM 建表即 5 值);`tests/_ddl.py` 同步 4 表 + `REQUIRED_CONVERT_TABLES` 门禁。**验证**:新增 `scripts/dev/verify_convert_seed.py`(pymysql 等价 reset 流程 + 8 条断言)→ **8/8 PASS**;`pytest -q` → **516 passed / 3 skipped(零回归)**。**3 点需注意**:① mysql 不在 PATH → 用该脚本替代 reset.ps1;② `core_fee_rule` 读取走只读账号(T-6 遵守);③ ~~`PROD-005827` 费率分类口径差异(`mixed` vs 主动偏股)待裁定~~ → **已裁定并修正(PRD v0.9.1)**:`mixed` 归位 `0.0050`(其他混合型),主示例转入方改真主动偏股 `PROD-003095`,`09-seed-org.sql` 升 **v1.1** 按「管理人全产品线」重排,并新增断言 ⑧ 机器化卡口
+- [x] **【T-2 · 第 2 批】`service/convert/` 纯函数包** —— **完成(2026-09-10)**:新建 `app/service/convert/` **7 文件**(`__init__` / `types`(`Lot`/`FeeRule`/`LotAllocation`/`PlanResult` frozen dataclass + 3 个归一工具)/ `calc`(`plan_lots`/`lot_amount`/`lot_fee`/`convert_amount`/`in_qty`/`rounding_diff`/`diff_fee`/`hold_days`/`ensure_batch_limit`)/ `fee`(`pick_fee_rate` 左闭右开)/ `nav`(`ensure_nav_ready`→503 / `is_stale`)/ `lot_bootstrap`(D18 单点,`crc32` 确定性偏移)/ `errors`(`ConvertError` + 11 子类))。**新增 `tests/test_convert_calc.py` 93 用例**(12 类:精度 HALF_UP 反向自证 / 分档边界 6-7-29-30-179-180-364-365 / FIFO 含同 `confirmed_at` tiebreak / 跨批计费 / 双口径 252.40 vs 253.91 / 强制全转与强制赎回 / 恰好等于阈值不触发 / **零剩余不触发(新裁定 R-h)** / PRD §5.3 全链自证 / T+1 起算 / 净值 503 与 stale 分家 / D18 确定性 / §8.3 错误码 / **纯函数零 IO 依赖断言**)。**验证**:`pytest -q` → **609 passed / 3 skipped(+93,零回归)**;`calc_convert_demo.py` → **15/15 与 PRD §5.3 一致**(退出码 0)
+- [ ] **【T-3 起】** 仓储与锁(并行组 A:T-3 / T-4 / T-5 可同时开工)→ T-6 / T-7 → T-8 / T-9 / T-11 → **T-10 高风险单列** → T-12 → **T-13「50 并发压测 + 性能补录」**(最后跑,产出 PRD §9 第 18 条实测值)。**T-13 内部顺序**:先 50 并发压测 → 再性能实测补录 → 最后 PRD §5.3 数字回填(详见架构 §15 + 开发计划 §2~§10)
+
+> **第 0~2 批结果(2026-09-10)**:基线 **510 passed** → 批 0 后 **516 passed / 3 skipped**(+3 T-0 用例 +3 T-0b 引擎用例)→ 批 1(T-1)后**仍 516 passed / 3 skipped**(只加表与种子,未加用例 → **零回归**);**T-1 数据层断言 8/8 PASS**(含新增断言 ⑧:费率档 ↔ `product_type` 匹配,越档即 FAIL)→ 批 2(T-2 + T-2b)后 **609 passed / 3 skipped**(**+93 纯函数用例**,零回归);T-2b 实算脚本 15/15 与 PRD §5.3 一致(退出码 0)。
+> **下一步 = T-3**(`core_ro` 五个新方法:`get_nav_as_of` / `get_redeem_fee_rules` / `list_share_lots` / `sum_remain_qty` / `get_holding` + 新增 `app/repository/share_lot_repository.py`;DoD 见开发计划 §5.1)。**并行组 A 的 T-3 / T-4 / T-5 可同时开工**。
+
+> ⚠️ **基金转换的 T-0b 与下方「架构改进第 3/4 批」的 `core_ro` 只读账号是同一件事** —— 已由本线定案为 D20,**不再挂在架构改进线**(该线原「不要做」清单已更新)。
+
## 待办(推荐顺序)
- [x] **【阶段一验收门】接口实调验收** —— **通过(2026-09-07 委托 AI 代验:三接口契约 + 三鉴权边界全部符合;前端 web/ 未 init,验收口径为后端接口验证)**
@@ -35,6 +51,7 @@
## 待办(模块侧开放项)
+- [x] **【架构改进线 · 收尾】`docs/开发计划-架构改进.md` §7.2 七项手工冒烟 —— 2026-09-10 全部补跑,7/7 PASS**:无 key 启动告警(清空/占位非空各一次)· 交易阻断+放行(`SUIT_AGE_CONFIRM` / `RISK-001,002`)· 预警聚合(CUST-9527 连发 4 笔,第 3/4 笔 `alert_ids` 同单、`pending_review` 计数 1、`audit` 呈 `alert_created→alert_appended`)· Redis 不可用→退回进程内锁(日志 2 次 `退回进程内锁`,交易仍 200)· Redis 恢复→`退回进程内锁` 命中 0 行 · 调换 `main.py` 装饰器→T-202 守卫变红(`assert ''` @ `test_audit_middleware.py:221`)→字节级还原后转绿。**`037ce7e` 早已 push(远程 `risk-control-agent` = `fffb78a` = 本地 HEAD),原「未 push」表述作废**;残留已按 SOP §2 重灌,全量 **510 passed**。入口:`docs/交接文档-架构改进.md`
- [ ] chat 链路 `risk_suitability_log.actor_id` 暂落 SYSTEM(run_tool 统一透传需动 core_tools/kb_tools 冻结 func 契约,发起者留痕已由 agent_tool_call 覆盖)——AL-06 遗留,动 Tool 契约前先出方案
- [x] **【前端接入·方案 B】chat 拉侧三端点**(commit `8328c24`,2026-09-08):`GET /api/chat/sessions`(本人+本 Agent 线分页列表)、`GET /api/chat/sessions/{id}/messages`(历史消息升序分页,closed 仍可读)、`POST /api/chat/sessions/{id}/close`(active→closed,重复/非 active 409);chat.py 抽 `_resolve_agent_type`/`_assert_chat_entry`/`_guard_session` 三守卫供四端点共用,POST "" 行为零回归;risk_manager 在对话线数据面保持 403(PRD 4A.1)。测试 482→**494 绿**;独立评审 P0=0,P1(close 并发 rowcount 静默 200)已修复
- [x] **【前端接入·方案 C】SSE 流式对话**(commit `01ec5fc`,2026-09-08):新增 `POST /api/chat/stream`(OpenAI 兼容 chunk:首帧 meta → delta → finish_reason=stop → `[DONE]`);`agent_service.stream_chat` 生成器 + `needs_disclaimer`;`_guard_request`/`_prepare_turn` 与同步端点共用守卫;`session_repository.insert_turn` user+assistant 同事务落库(修评审 P0/P1)。测试 494→**503 绿**。遗留:无心跳帧、断连留空会话待清理
diff --git a/docs/项目框架设计/Core模拟底座/00-方案总览.md b/docs/项目框架设计/Core模拟底座/00-方案总览.md
index 8f36cf4..e6c0957 100644
--- a/docs/项目框架设计/Core模拟底座/00-方案总览.md
+++ b/docs/项目框架设计/Core模拟底座/00-方案总览.md
@@ -68,6 +68,10 @@ scripts/
├── 04-seed-holdings.sql # 持仓
├── 05-seed-trades.sql # 流水/交易(含 R-01 大额样例)
├── 06-seed-nav.sql # 净值/行情(C-05)
+ ├── 07-seed-fee-rule.sql # 【T-1】赎回费分档(14 产品 × 5 档,22 号文 §10)
+ ├── 08-seed-share-lot.sql # 【T-1】份额批次(按持仓反推,Σ remain_qty = qty)
+ ├── 09-seed-org.sql # 【T-1】产品机构与申购费率(同管理人/同 TA、费率对)
+ ├── 00-grant.sql # 【T-0b】DB 最小权限账号(管理员执行一次,不进 reset.ps1)
├── reset.ps1 # Windows:drop 数据 + 重跑 seed
└── README.md # 执行顺序
diff --git a/docs/项目框架设计/TODO-架构改进.md b/docs/项目框架设计/TODO-架构改进.md
index c7b8b40..5a48397 100644
--- a/docs/项目框架设计/TODO-架构改进.md
+++ b/docs/项目框架设计/TODO-架构改进.md
@@ -120,7 +120,7 @@ logger.exception(
### 第 1 批完成检查
- [x] `python -m pytest -q` 全绿(实测 **510 passed**)
-- [ ] T-107 手工冒烟通过(清空/恢复 key 各一次)
+- [x] T-107 手工冒烟通过(清空/恢复 key 各一次)—— **2026-09-10 补跑 ✓**:本机 `.env` 的 `DEEPSEEK_API_KEY` 本就为空(len=0),清空态直接验证:uvicorn 1s 起、`/health` 200、进程存活不阻塞、日志出现「DEEPSEEK_API_KEY 未配置,对话将走降级回复」;「恢复 key 无告警」一支因本机无真实 key,改用**非空占位值**验证条件分支(告警只判 `not settings.deepseek_api_key`,不校验有效性),未出现告警 ✓;验证后 `.env` 已还原
---
@@ -239,12 +239,13 @@ def test_audit_middleware_runs_inside_trace_middleware(client, captured_audit):
## 完成判定(总验收)
-> 状态(2026-09-09):**代码侧已完成并提交 `037ce7e`**;下列手工冒烟项**尚未执行**,需 hy3/用户在起飞前补齐。
+> 状态(**2026-09-10 全部闭环**):代码侧自 `037ce7e` 起已推送远程;**§7.2 七项手工冒烟已补跑 7/7 PASS**;冒烟残留已按 SOP §2 重灌清除,全量 **510 passed** 复绿。
- [x] FR-01~FR-06 六项验收标准逐条通过(FR-01/02 文档勘误与标注、FR-03 无 key 告警、FR-04 审计告警、FR-05 Redis 锁、FR-06 中间件顺序测试均已落地)
-- [x] `python -m pytest -q` 全绿且用例数 ≥ 503(实测 **510**)
-- [ ] uvicorn 冒烟:`/health` 正常,无 key 告警按预期出现
-- [ ] 交易阻断 + 放行路径各一次,预警聚合行为不变
-- [ ] 停/启 Redis 各测一次,业务均可完成
+- [x] `python -m pytest -q` 全绿且用例数 ≥ 503(实测 **510**;冒烟残留曾致 3 条集成前置断言红,重灌后复绿 510)
+- [x] uvicorn 冒烟:`/health` 正常,无 key 告警按预期出现(**2026-09-10 ✓**)
+- [x] 交易阻断 + 放行路径各一次,预警聚合行为不变(**2026-09-10 ✓** 证据见 `docs/memory/2026-09-10.md`)
+- [x] 停/启 Redis 各测一次,业务均可完成(**2026-09-10 ✓** Redis 不可用退回进程内锁、恢复后零降级命中)
- [x] 未新增配置项、未新增依赖、未改路由
-- [x] **commit 但不 push**(已 commit `037ce7e`),等用户在浏览器目视确认后再推送
+- [x] **已推送**(远程 `risk-control-agent` = `fffb78a` = 本地 HEAD,`037ce7e` 在其祖先链上)——原「commit 但不 push」表述**已作废**
+- [x] 附加守卫有效性验证:调换 `main.py` 两装饰器 → T-202 守卫变红 → 字节级还原后转绿(**2026-09-10 ✓**)
diff --git a/docs/项目框架设计/开发计划-基金转换交易.md b/docs/项目框架设计/开发计划-基金转换交易.md
new file mode 100644
index 0000000..14ca60d
--- /dev/null
+++ b/docs/项目框架设计/开发计划-基金转换交易.md
@@ -0,0 +1,1209 @@
+# 开发计划 · 基金转换交易(convert)
+
+> **文档状态**:开发计划,**尚未动代码**。待独立审核 + 用户确认后按批次实施。
+> **代码基线**:分支 `risk-control-agent`,HEAD `ea9a132`(含 6 个基金转换设计文件);**批 0(T-0 / T-0b)完成后 pytest 516 绿**(开工前基线 510)。
+> **上游依据**:`docs/PRD/PRD-基金转换交易.md`(v0.9.1)· `docs/项目框架设计/架构设计-基金转换交易.md`(v1.0)
+> · `docs/项目框架设计/评审待办-风控主架构与基金转换.md`(13 条 0 悬空)。
+> **任务编号**:沿用架构 §15 的 `T-0 ~ T-13`(**不重新编号**),§15.1 依赖拓扑为准。
+> **本计划的增量职责**:架构解决「怎么做」,本计划解决「谁先谁后、每一步改哪个文件的哪几行、
+> 怎么验证、改错了怎么退」。**所有事实性陈述均已实地核对代码**,核对结果见 §1.4。
+
+---
+
+## 审核记录
+
+### 第一轮(2026-09-10 · 独立子代理,全新上下文,只报告不改码)
+
+**总体结论:需修改后实施(小改)。** 审核方逐条核对了 §1.4 的 15 条事实与 §12 的回归面,
+并用 `grep` 独立复查。判定结果:**15 条事实全部属实的 14 条**(F-11 计数有误,见下),
+**§12 漏报 2 条必破用例**(其中 1 条是真 MySQL 集成用例),**R-c 代价被低估**。
+
+| # | 审核意见 | 档位 | 本轮处理 |
+| --- | --- | --- | --- |
+| 1 | §12 **漏报** `tests/test_integration_risk.py:426-437` 的 `test_convert_400_and_no_new_trade_audit`(真 MySQL 集成用例,断言 convert → 400 + 不落审计) | **重要** | **接受**:§12 补 **R15**;T-9 的 DoD 增该用例的处置 |
+| 2 | §12 **漏报** `tests/test_trade_gateway.py:145-154` 的 `test_redeem_accepted_without_alert`(redeem 正向用例,`env` 无持仓无批次) | **重要** | **接受**:§12 补 **R16**;**R-c 补 redeem 侧降级规则**(初版只覆盖 subscribe 侧) |
+| 3 | R-c 让 subscribe 全面降级 → **批次维护在单测层零覆盖**,验收第 9 条(不超扣)形同虚设 | **重要(建设性)** | **接受并实质修订**:R-c 拆为 **(1) 降级规则 + (2) 覆盖率补偿**——新测试必须自建种子、覆盖真实路径,**不得让降级路径充当覆盖**;T-10 增第 5 道风险控制 |
+| 4 | F-11「`get_engine` 共 11 处」计数错误,实际 **13 处**(test_db.py 是 5 处不是 6 处) | 可选 | **接受**:F-11 更正为 13 处,T-0b DoD 同步更正 |
+
+**驳回:0 条。**
+
+**审核方确认无误的部分**(供后续会话判断哪些已复核过):
+F-1 / F-3 / F-4 / F-5 / F-6 / F-8 / F-9 / F-10 / F-13 / F-14 / F-15 逐条比对代码后属实;
+W-1 / W-2 两处 PRD-架构冲突确实存在、以架构为准合理;R-a / R-b / R-d / R-e / R-f / R-g 六条裁定成立;
+§13 的 19 条验收**条条有承载任务**、无落空;一期不做项(自动分拆 / 撤单 / 巨额赎回比例 / 二期补偿自动化)**未被卷入**;
+`list_trades_range` 不改可接受(convert 两条流水跨不同产品,RISK-003 按产品计数不受影响);
+T-10 排在 T-7 之后**合理**(convert 走 `bootstrap_lots`,不依赖普通申赎的批次)。
+
+**审核方无法核实、本轮已补实的部分**:审核方称 F-7(sqlite 不支持 `ON DUPLICATE KEY UPDATE`)为方言常识未实跑。
+本计划已实跑验证(sqlite **3.50.4**):`ON DUPLICATE KEY UPDATE` → `near "DUPLICATE": syntax error`;
+`ON CONFLICT(b) DO UPDATE` → 正常执行;`PRAGMA index_list` 的 `unique` 字段 = `'u'`(UNIQUE 断言可行)。
+另实测 pydantic **2.13.4**:`Decimal | None = Field(None, gt=0)` 对 `0`/`-1` → 422、对 `None` → 放行(R5 的保住方案成立)。
+
+**仍待实测的部分**(依赖真 MySQL 或需跑测试,留到实施期):T-0b 的 3 条权限断言 · T-1 灌库 · T-6 回滚断言 ·
+T-7 幂等窗口 · T-13 的 50 并发压测与性能补录 · PRD §5.3 实算回填。
+
+### 第二轮(2026-09-10 · 独立子代理,全新上下文,只报告不改码)
+
+**总体结论:基本可进入下一步。** 审核方**逐条打开代码文件**核实上一轮 4 条意见的落地情况
+(结论:4 条全部已落地,其中 1 条改了但遗留一处内部矛盾),并新发现 **1 处真实的并发漏洞**。
+判定:**0 阻断级 · 2 重要 · 2 可选**,本轮**全部接受,驳回 0**。
+
+| # | 审核意见 | 档位 | 本轮处理 |
+| --- | --- | --- | --- |
+| 1 | **R-a 的并发论证不成立**:理由是「阶段一持执行权锁」,但该锁 key 是 `convert:idem:{client_request_id}`(**请求粒度**);同一 `(customer_id, product_id)`、**不同** `client_request_id` 的两笔并发转换会各持不同锁、双双进入阶段一 → 首次建行时并发 `INSERT` 撞 `UNIQUE` → `IntegrityError` | **重要** | **接受**:R-a 裁定增**第 ③ 步「`IntegrityError` 回退 UPDATE」** + 写清并发必要性 + T-6 增「并发首次建行」用例;并**实测排除了两个看似可行的替代写法**(见下) |
+| 2 | **文档自相矛盾**:T-10 正文仍写「风险控制(**四道**)」,而审核记录声称已改为「五道」 | **重要** | **接受**:T-10 已更正为「五道」(**上一轮该处编辑未生效**,本轮重修并逐条验证) |
+| 3 | **用例数不自洽**:§11 写「+97 → 607」,但保守区间写「580~600」 | 可选 | **接受**:统一为「**+97 → 607**(含压测)· **CI 内 ≈597** · 落地区间 **585~610**」,4 处口径(§0 / §10 / §11 / §13)同步 |
+| 4 | **2 处 DoD 偏模糊**:T-1「5×14 档(或按产品数)」、T-13「记录在 `docs/` 下」 | 可选 | **接受**:T-1 改为 **3 条可 SQL 断言的判据**;T-13 落点明确到「PRD §9 第 18 条」「交接文档」具体章节 |
+
+**本轮实测补跑**(审核方标注「无法核实」的方言问题)——**这是 R-a 最终写法的决定依据**:
+
+| 候选写法 | sqlite 3.50.4 | MySQL | 结论 |
+| --- | --- | --- | --- |
+| `INSERT ... SELECT ... WHERE NOT EXISTS`(无 `FROM`) | ✅ 实测通过 | ❌ `SELECT` 无 `FROM` 时不允许 `WHERE` | **不通用** |
+| 同上 + `FROM DUAL` | ❌ `no such table: DUAL` | ✅ | **不通用** |
+| **条件 UPDATE → INSERT → `IntegrityError` 回退 UPDATE** | ✅(实测:捕获异常后同事务内可继续 `UPDATE`、可正常提交) | ✅(InnoDB 语句级回滚,语义一致) | **通用 → 采纳** |
+
+**审核方确认无误的部分**:任务排序(T-10 位于 T-7 之后)合理 · §13 的 19 条验收**无落空** ·
+一期不做项(自动分拆 / 撤单 / 巨额赎回比例 / 二期补偿自动化)**未被卷入** · 回归面独立 `grep` **无新遗漏**
+(含 `test_core_ro_sum.py:52` 用 `trade_type='convert'` 作种子数据的用例——因 R-d 的首条件 `IN('subscribe','redeem')` 仍将其排除,恒等通过,§12 R10 处置正确)· F-1~F-15 抽核行号与代码一致。
+
+**仍待实测**(依赖真 MySQL / pytest,留到实施期):T-0b 的 3 条权限断言 · T-1 灌库 · **T-6 的回滚断言与并发建行用例** ·
+T-7 幂等窗口 · T-13 的 50 并发压测与性能补录 · PRD §5.3 实算回填。
+
+### 第三轮(2026-09-10 · 独立子代理 · **聚焦验证**,只报告不改码)
+
+**总体结论:可进入下一步(todo 开发)。** 本轮只做两件事:复核第二轮 4 条是否真正落地、快速复扫有无新问题。
+
+| # | 第三轮发现 | 档位 | 本轮处理 |
+| --- | --- | --- | --- |
+| 1 | **§14 风险 #6 仍写「四道风险控制」**,与 T-10 的「五道」矛盾 —— 第二轮修复遗漏了第三处 | **重要** | **接受**:已改为「五道」,并注明「第五道 = 覆盖率补偿,见 §8」 |
+| 2 | **推测**:SQLAlchemy 2.0 在语句失败后会把事务置为 pending,第 ③ 步继续执行将抛 `PendingRollbackError`,**裁定因此失效** | 重要(推测) | **驳回(附实测证据)**:本机实测 **SQLAlchemy 2.0.51 + sqlite 3.50.4** → 捕获 `IntegrityError` 后继续 `UPDATE` 成功(`rowcount=1`)、`with engine.begin()` 正常退出且提交成功,**未抛 `PendingRollbackError`**。原因是 SQLAlchemy **Core** 的 `Connection` 不标记事务失效(该行为属 ORM `Session` 的 flush 机制),而本项目全链路用 `text()` + Core `Connection`。已把**版本号 + 驳回理由 + 实施期防护**写入 §1.5 R-a |
+| 3 | `tests/test_suitability.py:26` 导入了 `get_engine`,未在 §12 列出(担心其 teardown 用默认角色发 DELETE) | 可选 | **接受(经核实无风险)**:实测该 `import` **未被调用**(全文件仅第 26 行出现一次),不构成调用点、也无角色隐患 → 已补入 F-11 备注 |
+| 4 | MySQL 的 `rowcount` 按「changed」而非「matched」计,同值 `UPDATE` 会得 0 → 多走一次冗余兜底 | 可选 | **不处理(无害)**:仅多一次尝试,语义正确,不引入错误 |
+
+**第二轮 4 条的复核结果**:**3 条已改到位**(#1 R-a 三步 + 并发必要性 + T-6 用例;#3 用例数 4 处口径一致;#4 两处 DoD 已可机械验证),
+**1 条修复不彻底**(#2 的 §14 未同步 —— 本轮已补,见上表第 1 行)。
+
+**第三轮确认无误**:R-a 的方言互斥对照表**诚实**(第三轮独立复现 sqlite 侧结果与文档一致)·
+§14 新增的 R-a1/R-a2 与 §1.5 R-a **自洽** · §12 的 R1~R16 覆盖完整(无新遗漏)· T-1 / T-13 / T-6 的 DoD **均可机械验证**。
+
+**三轮审核合计**:**12 条意见 → 接受 11 · 驳回 1**(第三轮 #2,附实测证据)·
+**0 条悬空 · 0 条遗留阻断**。
+
+### 第四轮(2026-09-10 · 独立子代理 · **终验**,只报告不改码)
+
+**终局结论:可以进入第 5 步 todo 开发。**
+
+逐条核实第三轮 4 条的闭环情况:**4 条全部闭环**。
+- §14 #6 已为「五道」,与 T-10 一致(正文无「四道」残留,「四道」仅存在于审核记录的历史描述中)
+- §1.5 R-a 已写明实测版本号(SQLAlchemy 2.0.51 + sqlite 3.50.4)与驳回理由,**审核方判定技术正确**
+- `tests/test_suitability.py` 全文件仅第 26 行 import、无调用,F-11 备注与之相符
+- MySQL `rowcount` 冗余兜底项标注「不处理(无害)」,代码逻辑未被误改
+
+**专项检查「声明已改但实际未改」的残留 → 无。** 三轮计数自洽(4+4+4=12 条,接受 11 / 驳回 1 / 0 悬空)。
+
+> **审核收敛结论**:经四轮独立审核,本计划**已达到可实施状态**。
+> 四轮的价值排序(供后人参考):**第一轮最有价值**(补出真 MySQL 集成用例漏项 + 指出降级致零覆盖的实质性缺陷)·
+> **第二轮最有技术含量**(发现执行权锁是请求粒度这一并发漏洞)· 第三/四轮为收敛验证。
+> 后续若再改本计划,**必须重跑至少一轮聚焦验证**(模式:给出「上轮意见 + 声称处置」→ 让对方打开文件核实,而非只看自述)。
+
+---
+
+## 0. 结论速览
+
+| 批次 | 任务 | 内容 | 依赖 | 风险 |
+| --- | --- | --- | --- | --- |
+| **第 0 批 · 门禁** | **T-0** ✅ | sqlite/MySQL 结构对齐(`core_holding` 列名 + PK、补 `core_product_nav`)+ 建库自校验 + 门禁用例 —— **2026-09-10 完成** | 无 | **低(但阻断)** |
+| | **T-0b** ✅ | DB 账号分离(D20):`00-grant.sql` + `settings` 3 组账号 + `get_engine(db, role)` + 3 个权限断言 —— **2026-09-10 完成** | 无(可并行 T-0) | **低(但阻断)** |
+| **第 1 批 · 数据与纯函数** | T-1 | MySQL DDL + 3 个种子 + `reset.ps1` + sqlite DDL 同步 | **T-0 + T-0b 双绿** | 低 |
+| | T-2 | `service/convert/` 纯函数包(`types`/`calc`/`fee`/`nav`/`lot_bootstrap`/`errors`) | 无 | 低 |
+| | T-2b | `calc_convert_demo.py` 实算 + 回填 PRD §5.3 与验收断言 | T-2 | 低 |
+| **第 2 批 · 仓储与锁** | T-3 | `core_ro` 五个新方法 + `share_lot_repository` | T-1 | 低 |
+| | T-4 | `convert_repository`(占位/回写/查询/清理) | T-1 | 低 |
+| | T-5 | `locks.try_lock` + 单测 | 无 | 低 |
+| **第 3 批 · 事务与编排** | T-6 | `convert_core_repository.apply_convert`(阶段一单事务) | T-1/T-3 | **高(方言 + 并发)** |
+| | T-7 | `convert_service` 编排(八步 + 执行权 + 幂等 + 三阶段 + 阶段 1.5) | T-2~T-6 | **高(关键路径)** |
+| **第 4 批 · 引擎与网关** | T-8 | `_amount_view` + `engine.process_convert_event` + `alert_service.events` | 无(可与 T-2 并行) | 中 |
+| | T-9 | `api/simulate.py` 模型与错误码 + `trade_gateway` convert 分派 | T-7 | 中 |
+| | T-11 | `core_tools` 汇总去重 + 持仓 `qty <= 0` 过滤 + `sum_trades_on_date` 去重 | T-8 | 中 |
+| **第 5 批 · 高风险专项** | **T-10** | 普通申赎批次维护(FR-C16,含 D8 兜底补建)+ `rebuild_lots.py` | T-3(排在 T-7 后) | **最高(打穿 510)** |
+| **第 6 批 · 补偿** | T-12 | `rebuild_alerts --convert-group` + `cleanup_pending_convert.py` | T-4/T-7 | 低 |
+| **第 7 批 · 收口** | T-13 | 全量回归 + 集成测试 + 50 并发压测 + 性能实测补录 | 全部 | 中 |
+
+**关键路径**:`T-0 → T-1 → T-2 → T-6 → T-7 → T-13`
+**并行组 A**:T-3 / T-4 / T-5(T-1 完成后同时开工)
+**并行组 B**:T-8 全程可与 T-2 之后任意任务并行
+**硬门禁**:`T-0` 与 `T-0b` **双双绿**才允许启动 T-1 及之后(T-0 用例 = `test_db.py::test_core_holding_columns`)
+**测试基线**:**516**(批 0 后;原 510)→ 预计 **591 ~ 616**(CI 内约 **603**;含不进 CI 的压测用例约 **613**,见 §11)
+
+---
+
+## 1. 背景与约束
+
+### 1.1 上游依据与决策链
+
+1. 第 1 步「讨论需求」→ 第 2 步 PRD(v0.9,经 5 轮外部审查 + 架构校准轮,接受 27 / 修正性接受 5 / 驳回 0)
+2. 第 3 步架构设计(v1.0,独立评审 M-7 通过:接受 10 / 修正性接受 3 / 驳回 0,T-0 为唯一硬前置)
+3. 用户拍板 P1~P8(处置表 §九):**批次兜底补建 / 归零保留行 / Core 明细仅补偿 / 补差费 B 默认 / 最低持有双动作 / 22 号文费率 / 份额 2 位 / T+1 建模**
+4. 主架构评审 C1(`core_ro` 无 DB 级只读强制)跨线并入 → **D20 / T-0b**
+5. 本计划:把上述结论拆成 14 个可独立验证、可独立回滚的开发任务
+
+### 1.2 硬约束
+
+| 约束 | 对本计划的影响 |
+| --- | --- |
+| **五条红线** | Core 只读(本期升级为 DB 级强制)· 审计只 INSERT · 代理人草稿不外发 · **仅 R-02 可阻断交易**(`NAV_NOT_READY`/`LOT_CONFLICT` 属技术故障码,不适用本铁律)· 四 Agent 不互调 LLM |
+| **既有用例必须全绿**(批 0 前 510 → 当前 **516**) | 每个任务完成后即跑全量,不留到最后 |
+| **实测才能改文档** | PRD §9 第 18 条:性能阈值为预估值,实测补录;**不得反向修改实测数据迁就指标** |
+| **不新增路由** | `tests/test_main.py:58-74` 硬编码路由清单 → convert 复用 `/api/simulate/trade`,**该断言零影响**(需显式声明,见 §12) |
+| **不新增第三方依赖** | 技术选型硬阀门(`requirements.txt` 不动) |
+| **sqlite 无账号概念** | T-0b 的账号分离**只在真 MySQL 生效**,sqlite 路径不引入任何连接方式变化 |
+| **不动范围** | RISK-001~006 口径不变(仅金额视图去重)· `entities.py` ORM 不新增 · 不改 `run_locked` · 不改 `get_latest_nav` 语义 · 不改 `rules._eligible` 的 trade_type 白名单 |
+
+### 1.3 文档口径差异裁定(**实现前必须先定,否则会两边都做**)
+
+核对 PRD v0.9 与架构 v1.0 时,发现 **2 处同一件事的表述不一致**。裁定原则:**架构 v1.0 晚于并细于 PRD 对应段落,以架构为准**;PRD 对应行视为旧版本残留。
+
+| # | 事项 | PRD 表述 | 架构表述 | **裁定(实现依据)** |
+| --- | --- | --- | --- | --- |
+| **W-1** | 阶段一「两条流水同事务插入」的落点 | §5.2 表格:「`gateway_repository.py` → 新增 `insert_convert_pair()` 同事务插两条」 | §2 / D3:**新增** `app/gateway/convert_core_repository.py::apply_convert()`(单事务:2 流水 + 批次 + 明细 + 两端持仓) | **以架构为准**:`gateway_repository` **只**扩列(`qty`/`convert_group_id`),convert 的写全部走 `convert_core_repository` |
+| **W-2** | 引擎如何吃两条流水 | §7.0 表述:「引擎只调用一次 `process_trade_event()`,**传入两条流水**」 | D5:**新增** `process_convert_event(out_trade, in_trade)`,与 `process_trade_event` 共用内部 `_run()`;**不改现有函数签名** | **以架构为准**:`process_trade_event` 签名零改动(避免动 13 处调用方) |
+
+> **为何必须显式裁定**:两处差异都属于「同一功能的两个落点」,若不裁定,
+> 实现者可能既写 `insert_convert_pair()` 又写 `apply_convert()`(重复且事务边界冲突),
+> 或给 `process_trade_event` 加第二参数(打穿既有 13 处调用与断言)。这是**成本最高的返工类型**。
+
+### 1.4 开工前代码事实核对表(**本计划新增 · 全部实地核对**)
+
+> 下表每条都是「架构/PRD 未点明,但实现时必然撞上」的事实。**核对方式**:读源码 + `grep` 全仓引用,
+> 行号为撰写时实测值。**这些是审核方最应复核的部分**——若有一条与代码不符,说明我对现状的判断有误。
+
+| # | 事实 | 证据(文件:行) | 对实现的影响 |
+| --- | --- | --- | --- |
+| **F-1** | sqlite `core_holding` 为 `market_value`/`quantity` **且无 PK、无 UNIQUE** | `tests/_ddl.py:50-54` | T-0 根因;须改为 MySQL 的 `id` 自增 PK + `uk_cust_product` + 5 业务列 |
+| **F-2** | **`tests/test_db.py` 已存在**(3 个用例:缓存单例 / dispose / 空缓存) | `tests/test_db.py:30-54` | 架构 §15 写「**新增** `tests/test_db.py::test_core_holding_columns`」表述不准 → 应为**在既有文件追加用例**,禁止覆盖 |
+| **F-3** | sqlite `core_trade` **缺 `qty`**(MySQL 侧 `qty DECIMAL(18,4) NULL` **已存在**) | `tests/_ddl.py:55-60` vs `scripts/core/01-ddl.sql` core_trade | T-1 在 MySQL 只加 `convert_group_id`;sqlite 要**补两列**(`qty` + `convert_group_id`) |
+| **F-4** | sqlite **缺 `core_product_nav` 表**(MySQL 有,`get_latest_nav` 查它) | `tests/_ddl.py:15-138`(无该表)· `app/repository/core_ro.py:450-460` | 架构 §9 的 sqlite 同步清单(4 张新表)**漏了它**;不补则 `get_nav_as_of` 的 sqlite 单测无法跑 → 建议并入 T-0 |
+| **F-5** | sqlite `core_holding` 改列名会打穿 **2 处** 测试 INSERT(显式写 `market_value, quantity`) | `tests/test_chat_tools.py:81` · `tests/test_concentration_c4.py:74` | T-0 必须同步改这 2 处为 `qty`;否则 `OperationalError: no such column: quantity` |
+| **F-6** | `app/` 全仓**无任何代码读 `core_holding.quantity`**;`list_holdings` 用 `SELECT h.*`、`concentration_profile` 只用 `market_value` | `grep -rn "market_value\|quantity" app/` → 仅 `core_ro.py:290/320/324/340`(均 `market_value`) | 列名统一对**生产代码零影响**,回归面**只在 2 处测试 INSERT** |
+| **F-7** | **UPSERT 方言互斥**:MySQL 用 `INSERT ... ON DUPLICATE KEY UPDATE`,sqlite 用 `INSERT ... ON CONFLICT(...) DO UPDATE`,**两者不通用** | 架构 D9 备注「⚠️ sqlite ≥3.24 支持该语法」——该表述**不准确**(3.24 支持的是 `ON CONFLICT`,非 MySQL 语法) | **阶段一事务在 sqlite 单测会直接语法错**:`apply_convert` 的 `core_holding` 写入必须改用**方言无关**写法(见 §1.5 R-a) |
+| **F-8** | convert 两条流水的 `trade_type` **必须是 `redeem` / `subscribe`**,靠 `convert_group_id` 关联 | 推论链:`rules._eligible`(`rules.py:78-83`)只放行 `subscribe`/`redeem` → 若写 `convert` 则**两条都进不了事件线**,与 PRD §6.1「两条流水都进事件线」矛盾;且架构 §7 `_amount_view` 明写「组内保留 `trade_type='redeem'` 那条」 | **实现时必须写死为 `redeem`/`subscribe`**;MySQL ENUM 里的 `convert` 值**本期不使用**(保留不用,注释说明) |
+| **F-9** | `core_ro.sum_trades_on_date`(SQL 侧求和)**硬写 `trade_type IN ('subscribe','redeem')`,不经过 `_amount_view`** | `app/repository/core_ro.py:420-432` | 自检第 2 问「谁读」的**遗漏项**:convert 落地后此方法会把两条流水都算进去 → **双计**。架构 §6 只提了 `core_tools`,未提它(见 §1.5 R-d) |
+| **F-10** | `sum_trades_on_date` **生产代码无调用方**,仅 `tests/test_core_ro_sum.py` 在用 | `grep -rn "sum_trades_on_date"` → 仅 `core_ro.py:420` 定义 + 该测试 5 处断言 | 改动**零生产回归**,但它是公开仓储方法,应顺带修正口径 |
+| **F-11** | `get_engine` 调用点在 **T-0b 前为 13 处**(app 4 + conftest 4 + test_db.py **5**) | **T-0b 实施后实测行号**:`app/gateway/gateway_repository.py:30` · `app/repository/core_ro.py:60` · `risk_repository.py:37` · `session_repository.py:25` · `tests/conftest.py:185/186/236/237` · `tests/test_db.py` **21 处**(角色/缓存/回退用例自身,均带显式 `role` 或默认 `rw`) | T-0b 的改动清单必须**逐点定角色**,不能只改 `db.py`(
※ 初版误记为 11 处,经第一轮审核指出后实测更正为 13 处。
※ 第三轮补充:`tests/test_suitability.py:26` **仅 import 未调用** `get_engine`,不计入调用点、也无角色隐患) |
+| **F-12** | 既有 4 处断言会因「放开 convert」而失效 | `tests/test_trade_gateway.py:92-99`(`test_convert_rejected`)· `:215-219`(`test_api_convert_returns_400`)· `:98`(`core_trade` 计数为 0)· `:261-263`(`amount=0` → 422,**必须保住**) | 逐条处置见 §12 |
+| **F-13** | `tests/test_trade_gateway.py` 的 `env` fixture **未灌 `core_holding` 与净值** | `tests/test_trade_gateway.py:40-72`(只灌 customer/customer_risk/product) | T-10 后 subscribe 分支要建批次、redeem 分支要兜底补建(读 `core_holding`)→ **在既有用例上必然走到无数据路径**,见 §1.5 R-c |
+| **F-14** | `scripts/core/01-ddl.sql` 中 `core_product.fee_rate` **无 COMMENT**、`core_trade.trade_type` ENUM **已含 `convert`** | `01-ddl.sql`(core_product / core_trade 段) | T-1 补 COMMENT(Q8 定稿);`convert` ENUM 值保留不用(F-8) |
+| **F-15** | agent 库 DDL 落点 = `docs/项目框架设计/表设计/02-mysql-agent专用.sql`(`audit_log`/`risk_alert` 在 `01-mysql-共用底座.sql`) | `grep -rln "CREATE TABLE.*audit_log"` → `01-mysql-共用底座.sql`;`tests/conftest.py:181` 的 bootstrap 提示链 | 架构 §9 选点(`risk_convert_detail` → `02-mysql-agent专用.sql`)**正确**,与 `risk_aml_list` 等业务专用表同类 |
+
+### 1.5 实现级裁定(**本计划拍板,若不同意请在审核中驳回**)
+
+架构回答了架构级问题,但以下 **7 条是实现级空白**——不定就无法写代码,或写出来必然出错。
+每条给出「问题 / 裁定 / 理由 / 若被驳回的替代方案」。
+
+**R-a · `core_holding` 写入用「UPDATE + rowcount + INSERT」两步,不用方言 UPSERT**
+
+- **问题**:架构 D9 写 `INSERT ... ON DUPLICATE KEY UPDATE`,但 sqlite 不支持该语法(F-7)。若实现时按 D9 原文写,**阶段一事务在任何 sqlite 单测里立即语法错**。
+- **裁定(第二轮审核后修订)**:`apply_convert` 内对两端 `core_holding` 统一走「条件 UPDATE → 首次 INSERT → 异常回退 UPDATE」:
+ ```python
+ # ① 条件 UPDATE(常规路径:该持仓行已存在)
+ r = conn.execute(update_sql, params) # UPDATE core_holding SET qty=:q, cost_amount=:c,
+ # market_value=:mv, pnl_pct=:pnl, as_of=:d
+ # WHERE customer_id=:cid AND product_id=:pid
+ if r.rowcount == 0:
+ # ② 首次建行 → INSERT
+ try:
+ conn.execute(insert_sql, params)
+ except IntegrityError:
+ # ③ 兜底:并发事务已插入该行 → 退化为 UPDATE(同一事务内可继续执行,已实测)
+ conn.execute(update_sql, params)
+ ```
+- **理由**:① 方言无关(不依赖任一方言的 UPSERT 语法);② 仍在 `with engine.begin()` 单事务内,原子性不变;③ 不引入 `engine.dialect.name` 分支(分支是长期维护负担,且 MySQL 分支在 sqlite 单测里永不被执行)。
+- **⚠️ 为何不能用「条件 INSERT」(本轮实测排除的两个方案,留痕防返工)**:
+ | 写法 | sqlite | MySQL | 结论 |
+ | --- | --- | --- | --- |
+ | `INSERT ... SELECT ... WHERE NOT EXISTS`(**无 FROM**) | ✅ 可用(实测通过) | ❌ MySQL 的 `SELECT` 不带 `FROM` 时**不允许 `WHERE`** | 不通用 |
+ | 同上 + **`FROM DUAL`** | ❌ 实测报 `no such table: DUAL` | ✅ 可用 | 不通用 |
+ → 二者**又组成一对互斥方言**,仍做不到「同一套 SQL 两库通吃」。故最终选**异常回退**写法。
+- **⚠️ 并发兜底的必要性(第二轮审核指出 · 初版理由③有误)**:
+ 初版称「阶段一持执行权锁可保不撞 UNIQUE」——**该论证不成立**。执行权锁的 key 是
+ `convert:idem:{client_request_id}`(**请求粒度**,见 §6.2 步骤⑤ / T-5),不是持仓粒度。
+ 因此**同一客户、同一产品、不同 `client_request_id` 的两笔并发转换**(典型场景:A→C 与 B→C
+ 同时转入 C;或首次转入某产品)会各持**不同**锁、双双进入阶段一,都 `UPDATE` 得 `rowcount == 0`
+ 后并发 `INSERT` → **撞 `UNIQUE(customer_id, product_id)` 抛 `IntegrityError`**
+ (事务回滚、返回 5xx;**非数据损坏,但属未兜底的异常路径**)。②③ 两步正是为此存在。
+- **必须覆盖的用例(写入 T-6 的 DoD)**:模拟两笔并发首次转入同一 `(cid, pid)` →
+ 断言最终**只有一行**、金额为两笔之和、**两笔都成功**(不得出现 5xx)。
+- **实测依据(SQLAlchemy 2.0.51 + sqlite 3.50.4,本项目系统 Python 3.13.14 环境实跑)**:
+ 同一事务内捕获 `IntegrityError` 后,连接仍可用、可继续执行 `UPDATE`(`rowcount == 1`)、
+ `with engine.begin()` 块正常退出且事务提交成功——**未抛 `PendingRollbackError`**。
+ > **第三轮的推测已被本次实测驳回**:第三轮曾推测「SQLAlchemy 2.0 在语句失败后会把事务置为 pending,
+ > 后续执行抛 `PendingRollbackError`,从而使本裁定失效」。实测否定了该推测——SQLAlchemy **Core**
+ > 的 `Connection` 在语句级失败后不标记事务失效(该行为属 ORM `Session` 的 flush 机制);
+ > 本项目全链路用 `text()` + Core `Connection`,不涉及 Session。
+ > **实施期防护**:若后续升级 SQLAlchemy 主版本,T-6 的「并发首次建行」用例会立刻暴露行为变化,不会静默失效。
+ MySQL InnoDB 亦为**语句级回滚**,失败语句不影响事务后续执行,语义一致。
+- **替代方案(若被驳回)**:保留 D9 原写法 + `if engine.dialect.name == "sqlite"` 分支。**不推荐**:两套 SQL 必然漂移,且 MySQL 分支在 sqlite 单测里永不被执行。
+ 另一备选 = 阶段一增加 `(customer_id, product_id)` 维度的执行锁 —— **一期不取**:会把锁管理复杂度引入事务内部,
+ 并新增「锁超时」这一失败模式;**若 T-6 单测证明异常回退不足,再评估**。
+
+**R-b · convert 两条流水写 `trade_type='redeem'/'subscribe'`,本期不使用 ENUM 里的 `convert`**
+
+- **问题**:MySQL `core_trade.trade_type` ENUM **已含 `convert`**(F-14),实现者极易直觉地写 `trade_type='convert'`。
+- **裁定**:转出端写 `redeem`、转入端写 `subscribe`,两行共享 `convert_group_id`;`trade_type='convert'` **本期任何代码都不写**。
+- **理由**:`rules._eligible`(`rules.py:78-83`)白名单只有 `subscribe`/`redeem`,写 `convert` 会让流水**被引擎过滤掉**,直接违反 PRD §6.1 / 验收第 6 条(F-8)。
+- **落地要求**:在 `convert_core_repository` 与 `errors.py` 各留一条中文注释说明「ENUM 值 convert 保留不用,原因见裁定 R-b」,防后人改回去。
+
+**R-c · 批次维护的「数据不全降级」与「覆盖率补偿」(第一轮审核后修订)**
+
+- **问题**:PRD §4.4 要求批次由交易统一出入口维护(`subscribe`/转换转入 → 建批次;`redeem`/转换转出 → FIFO 扣减),但:
+ - 普通申购请求体**只有 `amount` 没有份额**,份额需 `amount ÷ 当日净值`;sqlite 测试库**无 `core_product_nav` 数据**;
+ - `tests/test_trade_gateway.py` 的 `env` fixture(`:40-72`)**既无 `core_holding` 也无批次**(F-13),而 redeem 侧要读持仓做 D8 兜底补建。
+- **裁定(两条并行,缺一不可)**:
+
+ **(1) 降级规则 —— 只在「源数据缺失」时生效,用于保住既有 510**
+ | 场景 | 处置 |
+ | --- | --- |
+ | `subscribe` / 转换转入,**取不到当日净值** | `logger.warning`(记 `trade_id` + 原因)→ **跳过建批次**,不抛异常、不阻断交易 |
+ | `redeem`,**既无批次也无持仓行**(`core_holding` 无该 `(cid,pid)`) | `logger.warning` → **跳过批次扣减**,不抛异常、不阻断交易 |
+ | `redeem`,**无批次但有持仓** | **走 D8 兜底补建**(`lot_bootstrap.bootstrap_lots`)再扣 —— 这是 D8 的主场景,**必须执行** |
+
+ **(2) 覆盖率补偿 —— 回答「降级会不会导致批次维护零覆盖」**
+ - **`test_share_lot.py` 必须自建完整种子**(`core_holding` + `core_share_lot` + `core_product_nav`),
+ **完整覆盖两条真实路径**:redeem FIFO 扣减(含 D8 兜底补建)× subscribe 建批次 × `rebuild_lots.py` 入口。
+ - **不得**用降级路径充当测试覆盖(降级是「数据不全时的兜底」,不是被测对象)。
+ - 断言 `core_share_lot.remain_qty` 之和 = 初始值 − 实际扣减量(不超扣)。
+- **理由**:① 批次扣减(redeem 侧)是 convert 正确性的**前提**(PRD §4.4 的核心缺陷——不扣就超扣);
+ ② 既有 510 的 fixture 无持仓/净值,若强行走「缺数据即报错」会**直接打穿 510**(违反 §1.2 硬约束);
+ ③ 但若只做降级不做补偿,批次维护会在单测层**零覆盖**,验收第 9 条(不超扣)形同虚设 ——
+ 这是**第一轮审核指出的实质问题**,故补入 (2)。
+- **一句话原则**:**降级只用于「既有测试数据不全」的场景;新测试必须造全数据、测真实路径。**
+- **若被驳回**:替代方案 = 给 `test_trade_gateway.py` 的 `env` 等多个 fixture 统一补灌持仓与净值种子
+ (T-10 工作量增加、改动面扩到 3+ 个测试文件,但覆盖更自然)。**可接受但要评估**。
+- **注意**:此裁定**只约束 T-10 的普通申赎**;换入端的新批次(`convert` 转入)**必须建**(它有完整折算结果,不存在取不到净值的问题)。
+
+**R-d · `core_ro.sum_trades_on_date` 同步加 convert 去重(架构 §6 未覆盖的读取方)**
+
+- **问题**:F-9/F-10——该方法 SQL 侧按 `trade_type IN ('subscribe','redeem')` 求和,convert 落地后会**双计**,且它不经过 `_amount_view`。架构 §6 只列了 `core_tools.query_recent_trades`。
+- **裁定**:
+ ```sql
+ AND trade_type IN ('subscribe', 'redeem')
+ AND (convert_group_id IS NULL OR trade_type = 'redeem') -- 组内只计转出端,同 _amount_view 口径
+ ```
+- **理由**:① 口径与 `_amount_view`(D7)**完全一致**,一处语义两处落地但可互相印证;② 既有 `test_core_ro_sum.py` 的 5 条断言数据**无 `convert_group_id`** → 恒等通过,**零回归**;③ 顺带补 1 条 convert 去重用例。
+- **落地要求**:`test_core_ro_sum.py` 追加 1 条用例(同日一组 convert 两条流水 + 1 笔普通赎回,断言只计转出端与本笔普通赎回)。
+- **若被驳回**:至少要在 docstring 加「不适用 convert 场景,金额聚合请用 `rules._amount_view`」,避免后人误用 —— **不接受「什么都不做」**。
+
+**R-e · `conftest.py` 的真库 engine 改用 `role="admin"`,业务调用仍走拆分账号**
+
+- **问题**:T-0b 后 `xh_core_rw` **无 DELETE 权限**、`xh_agent_rw` 对 `audit_log` **只有 INSERT**。而 `tests/conftest.py` 的 teardown 要 `DELETE FROM core_trade`(`:219`)、`DELETE FROM risk_alert/audit_log`(`:221-223`),setup 还要跑 `prepare_risk_demo.sql`(含 INSERT)。**若 conftest 沿用默认角色,真库集成测试的 setup/teardown 会因权限被拒而失败。**
+- **裁定**:`tests/conftest.py:183/184/232/233` 四处 `get_engine(...)` 显式传 `role="admin"`(= `root`);`app/` 内的业务调用**一律不用 admin**。
+- **理由**:① 测试的建数据/清数据是**运维动作**,不是应用行为,用管理员账号符合最小权限的**边界定义**(应用运行时不持有 root,测试脚手架可以);② 若给 `xh_core_rw` 加 DELETE 只为清理,会**直接破坏**「无 DELETE」这一最小权限目标(评审 C1 的初衷),否决。
+- **附带动作**:架构 §11.1 表里 `root` 的用途需**扩充一行**——「真 MySQL 集成测试的 setup/teardown(`tests/conftest.py`)」;本计划 §3.2 落实,并在实施时同步回写架构文档那一格。
+
+**R-f · T-0 范围扩至 `core_product_nav`(架构 §15 只写了 `core_holding`)**
+
+- **问题**:F-4——sqlite 缺 `core_product_nav` 表,而 `get_latest_nav`(`core_ro.py:450`)与本次新增的 `get_nav_as_of` 都查它。
+- **裁定**:T-0 的 sqlite 对齐**一并补建 `core_product_nav`**(含 `uk_product_date` 唯一约束),并把 T-0 的门禁断言从「单表列名」扩为「**① `core_holding` 列名 + PK;② 关键表存在性**」。
+- **理由**:① 两者同属「sqlite/MySQL 结构失配」,是同一个 R1 风险的两种表现,**一次改完、一个门禁守住**,比分两次改省一轮回归;② 若留到 T-1 补,则 T-1 会同时承担「新表 DDL」与「补历史缺失表」两类工作,出问题时归因困难。
+- **若被驳回**:`core_product_nav` 挪到 T-1,T-0 只做 `core_holding`。**可接受**,但 T-0 的 DoD 需相应缩小。
+
+**R-g · 自校验落在 `_ddl.create_sqlite_engine()` 内,`test_db.py` 作为可独立跑的门禁用例**
+
+- **问题**:架构 §9 要求「`conftest.py` 加**启动期**列名断言」。但 `conftest.py` 的 `sqlite_engine` fixture 只是**其中一个建库入口**——测试文件各自 `create_sqlite_engine()` 的场景更多(如 `test_trade_gateway.py:42`)。
+- **裁定**:双落点、**同源常量**——
+ 1. **主**:`tests/_ddl.py::create_sqlite_engine()` 建表后调用新增的 `_assert_ddl_aligned()`(读权威列/表清单常量,缺项即 `AssertionError`)。**所有**建库路径自动被覆盖,无需改 conftest。
+ 2. **辅**:`tests/test_db.py::test_core_holding_columns`(T-0 门禁用例),供 CI 单独指定运行(`pytest tests/test_db.py::test_core_holding_columns`)。
+- **理由**:① 建库入口唯一(`create_sqlite_engine` 是 `_ddl.py` 唯一建表函数),断言放这里 = 全量覆盖,**不留「某个测试文件自己建库绕过断言」的口子**;② CI 需要可单独指定的门禁用例 → 保留 `test_db.py` 那条;③ 两份断言读**同一份常量** → 不会漂移。
+- **若被驳回**:只在 `conftest.py` 加 fixture 断言(架构原文),但需接受「部分测试文件不走 conftest fixture、门禁被绕过」的风险。
+
+**R-h · `plan_lots` 的「零剩余不触发强制处置」(**T-2 执行期新增裁定**)**
+
+- **问题**:PRD §12 I-4 的触发条件是「余额 < `min_hold_qty`」。客户**申请份额 == 全部可转份额**时余额 = 0,字面上 `0 < min_hold_qty` 成立 → 会被判为「强制全转 / 强制赎回剩余」,`forced_full_transfer = True` 落响应与审计;但此时 `actual_qty == requested_qty`,**客户指令其实没有被改动**。
+- **裁定**:触发条件**增加「余额 > 0」前置**:
+ ```python
+ trigger = leftover > 0 and threshold > 0 and leftover < threshold
+ ```
+- **理由**:① `forced_full_transfer` 的语义是「**客户指令被改变**」(PRD 二轮第 9 条),零剩余时该语义不成立——前端会误报「您的申请已被系统改为全额转出」;② 零剩余时 `min_hold_action` 的两个分支(强制全转 / 强制赎回剩余)**都无事可做**,标记只产生噪音;③ 不改变 PRD 审查例的结论(持 6000、申请 5000、阈值 1000 → 余额恰好 1000 → 仍不触发)。
+- **落地**:`calc.plan_lots()` 内有对应中文注释;`tests/test_convert_calc.py::TestMinHoldAction::test_zero_leftover_not_forced` 锁定该语义。
+- **若被驳回**:按 PRD 字面实现(余额 = 0 也触发),但需同步改前端文案,说明「申请全额转出时也会带 `forced_full_transfer=true`」——评审大概率判为误导。
+
+---
+
+## 2. 执行分组与排期策略
+
+```text
+第 0 批(门禁,不可并行跳过)
+ T-0 sqlite/MySQL 结构对齐 + 建库自校验 + test_db 门禁用例
+ T-0b DB 账号分离(00-grant / settings / get_engine(db,role) / 3 权限断言)
+ │ ⛔ 双双绿才放行
+ ▼
+第 1 批 T-1(DDL + 种子 + sqlite 同步)───┬─► T-2(纯函数包)─► T-2b(实算回填)
+ │
+第 2 批 T-3 / T-4 / T-5(并行组 A)───────┤
+ ▼
+第 3 批 T-6(阶段一事务)─► T-7(编排 · 关键路径)─┬─► T-9(API + 网关分派)
+ └─► T-12(补偿脚本)
+第 4 批 T-8(引擎改造,全程并行)─► T-11(工具/求和去重)
+第 5 批 T-10(批次维护 · 回归风险最高,单独结项)
+第 6 批 T-13(全量回归 + 集成 + 50 并发压测 + 性能补录)
+```
+
+**排期原则**
+
+| 原则 | 说明 |
+| --- | --- |
+| **门禁优先** | T-0/T-0b 不是「准备工作」,是**独立交付物**:四件事做完(sqlite 对齐 + 自校验 + 账号分离 + 权限断言),T-1 才能开始 |
+| **纯函数先于集成** | T-2 不依赖任何表结构,可与 T-1 并行开工,且它的单测最容易穷举(金额逻辑最易错) |
+| **高风险单列** | T-10 改 `trade_gateway` 主流程、直接打穿 510 → **单独结项**:先跑基线快照 → 改造 → 再跑全量 |
+| **每任务一个回滚点** | 每个任务完成后**立即 commit**(不含下游任务改动),出现回归可精确回退到上一任务 |
+| **不做「顺手重构」** | 除 §1.5 明列的补充(R-a~R-g),**不碰**任何非本任务范围的代码 |
+
+---
+
+## 3. 第 0 批 · 门禁
+
+### 3.1 T-0 · sqlite/MySQL 结构对齐 + 建库自校验
+
+**目标**:让 sqlite 测试库的结构成为 MySQL 权威结构的**真子集**,且失配在**建库那一刻**就炸,而不是留到集成测试期。
+
+**涉及文件**
+
+| 文件 | 动作 |
+| --- | --- |
+| `tests/_ddl.py` | 【改】重写 `core_holding` DDL · 新增 `core_product_nav` · 新增权威清单常量 + `_assert_ddl_aligned()` |
+| `tests/test_chat_tools.py` | 【改】`:81` INSERT 列名 `quantity` → `qty` |
+| `tests/test_concentration_c4.py` | 【改】`:74` 同上 |
+| `tests/test_db.py` | 【改】**追加** `test_core_holding_columns`(**不覆盖**既有 3 个用例,见 F-2) |
+
+**改法(逐项)**
+
+1. **`core_holding` DDL 重写**(以 `scripts/core/01-ddl.sql` 为准;sqlite 无 `DECIMAL(18,4)` 精度语义,保留类型名即可):
+ ```python
+ "core_holding": """
+ CREATE TABLE core_holding (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ customer_id VARCHAR(64) NOT NULL, product_id VARCHAR(64) NOT NULL,
+ qty DECIMAL NOT NULL, cost_amount DECIMAL NOT NULL,
+ market_value DECIMAL NOT NULL, pnl_pct DECIMAL NOT NULL, as_of DATE NOT NULL,
+ UNIQUE (customer_id, product_id))
+ """,
+ ```
+ > 保留 `market_value` 列名**不动**(F-6:`core_ro` 的 `list_holdings`/`concentration_profile` 依赖它)。
+ > 不建外键(sqlite 测试库既有表均无 FK,保持一致,避免插入顺序约束)。
+
+2. **新增 `core_product_nav`**(F-4 / R-f):
+ ```python
+ "core_product_nav": """
+ CREATE TABLE core_product_nav (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, product_id VARCHAR(64) NOT NULL,
+ nav DECIMAL NOT NULL, daily_chg_pct DECIMAL NOT NULL, nav_date DATE NOT NULL,
+ UNIQUE (product_id, nav_date))
+ """,
+ ```
+
+3. **新增权威清单常量 + 自校验**(R-g):
+ ```python
+ # 与 scripts/core/01-ddl.sql 人工同步的权威结构清单(T-0 门禁比对基准)
+ EXPECTED_CORE_HOLDING_COLUMNS = {
+ "id", "customer_id", "product_id", "qty",
+ "cost_amount", "market_value", "pnl_pct", "as_of",
+ }
+ REQUIRED_CORE_TABLES = {"core_holding", "core_product_nav", "core_trade", "core_product"}
+
+ def _assert_ddl_aligned(engine) -> None:
+ """建库后自校验:缺列/缺表立即 AssertionError(R1 门禁,防止失配留到集成测试期)。"""
+ ```
+ `create_sqlite_engine()` 在 `for ddl in SQLITE_TABLES.values()` 之后调用它。
+
+4. **新增门禁用例** `tests/test_db.py::test_core_holding_columns`:
+ - 断言 `PRAGMA table_info(core_holding)` 的列名集合 ⊇ `EXPECTED_CORE_HOLDING_COLUMNS`
+ - 断言主键 = `{id}`(`PRAGMA table_info` 的 `pk` 字段)
+ - 断言 `UNIQUE(customer_id, product_id)` 存在(`PRAGMA index_list(core_holding)` + `index_info`)
+ > **已实测可行**(sqlite 3.50.4):`UNIQUE(a,b)` 会生成 `sqlite_autoindex_*` 且 `PRAGMA index_list` 的
+ > `unique` 字段为 `'u'`,可稳定断言。
+ - 断言 `core_product_nav` 表存在且含 `(product_id, nav_date)` 唯一约束
+
+5. **同步改 2 处测试 INSERT**(F-5):把 `market_value, quantity` 改成 `market_value, qty`,数值保持原样(`100` / `1000` / `500`)。
+
+ > ⚠️ **实施时发现的漏项(原计划未覆盖)**:这 2 处 INSERT 原本**只写 4 列**,
+ > 而新 DDL 的 `cost_amount` / `pnl_pct` / `as_of` 均为 **NOT NULL** ——
+ > 只改列名会直接撞 `NOT NULL constraint failed` 而全红。
+ > 故须一并补全这三列,取中性值(`cost_amount` = `market_value`、`pnl_pct` = `0`、
+ > `as_of` = `'2026-09-04'` 固定日期,不依赖当天日期以保证测试确定性)。
+
+**依赖**:无
+**完成标准(DoD)**
+- [ ] `pytest tests/test_db.py::test_core_holding_columns -q` 绿
+- [x] `pytest -q` **全量全绿**(批 0 前基线 510;含改后的 2 处 INSERT,另补 3 个 NOT NULL 列)
+- [ ] 反向验证门禁真的生效 —— **已固化为自动化用例**(无需手工改删再恢复):
+ `test_db.py::test_ddl_alignment_guard_fails_when_column_missing`
+ (monkeypatch 抽掉 `qty` 列 → 断言建库即 `AssertionError`)
+- [ ] `scripts/core/01-ddl.sql` 与 `EXPECTED_CORE_HOLDING_COLUMNS` 逐列对照,写入 `_ddl.py` 注释
+
+**风险**:低。唯一变量是那 2 处测试 INSERT(已在 F-5 定位)。
+**回滚点**:本任务单独一个 commit;回滚即恢复到现状(convert 尚未开始,无下游依赖)。
+
+---
+
+### 3.2 T-0b · DB 账号分离(D20)
+
+**目标**:把「Core 只读」「审计只 INSERT」从**代码约定**升级为 **DB 级强制**。
+
+**涉及文件**
+
+| 文件 | 动作 |
+| --- | --- |
+| `scripts/core/00-grant.sql` | 【新增】3 账号 + 逐表最小权限(管理员执行) |
+| `app/config/settings.py` | 【改】新增 6 个配置项(3 组账号密码,默认 `""`) |
+| `app/utils/db.py` | 【改】`get_engine(database, role="rw")`,缓存键 → `(database, role)` |
+| `app/repository/core_ro.py:55` | 【改】`get_engine(core_db, "ro")` |
+| `app/gateway/gateway_repository.py:24` | 【改】`get_engine(core_db, "rw")` |
+| `app/repository/risk_repository.py:37` · `session_repository.py:25` | 【改】`get_engine(agent_db, "rw")`(显式,可读性) |
+| `tests/conftest.py:183/184/232/233` | 【改】显式 `role="admin"`(**R-e**) |
+| `tests/test_db.py` | 【改】追加 2 条用例:同库不同角色 → 不同 engine;角色未配置 → 回退 `mysql_user` |
+| `docs/项目框架设计/架构设计-基金转换交易.md` §11.1 | 【改】`root` 用途行补「真 MySQL 集成测试 setup/teardown」(**R-e 附带**) |
+
+**改法(逐项)**
+
+1. **`00-grant.sql`**(**不进 `reset.ps1`**:`DROP DATABASE` 不清 `mysql.db` 授权行,授权一次即可;且它需管理员执行):
+ ```sql
+ -- 逐表授权:MySQL 无法用「库级 GRANT + 表级 REVOKE」做部分撤销,必须枚举
+ -- jinrong_core:只读账号
+ CREATE USER IF NOT EXISTS 'xh_core_ro'@'%' IDENTIFIED BY '<从 .env 取,禁止写死在仓库>';
+ GRANT SELECT ON jinrong_core.* TO 'xh_core_ro'@'%';
+ -- jinrong_core:写账号,限 4 表、无 DELETE、无 DDL
+ CREATE USER IF NOT EXISTS 'xh_core_rw'@'%' IDENTIFIED BY '<...>';
+ GRANT SELECT, INSERT, UPDATE ON jinrong_core.core_trade TO 'xh_core_rw'@'%';
+ GRANT SELECT, INSERT, UPDATE ON jinrong_core.core_share_lot TO 'xh_core_rw'@'%';
+ GRANT SELECT, INSERT, UPDATE ON jinrong_core.core_holding TO 'xh_core_rw'@'%';
+ GRANT SELECT, INSERT, UPDATE ON jinrong_core.core_convert_lot_detail TO 'xh_core_rw'@'%';
+ -- jinrong_agent:业务表逐表授权,audit_log 只授 INSERT(红线升级为 DB 强制)
+ CREATE USER IF NOT EXISTS 'xh_agent_rw'@'%' IDENTIFIED BY '<...>';
+ GRANT SELECT, INSERT, UPDATE ON jinrong_agent.<各业务表,逐一枚举> TO 'xh_agent_rw'@'%';
+ -- ⚠️ 口径修正(实施时发现):架构 §11.1 原文「audit_log 只授 INSERT」若**字面执行**,
+ -- 会连 SELECT 一并剥夺 → has_engine_error_audit(:231) 与 list_audit_events(:420) 双双失败。
+ -- 红线本意是「只能追加,不能修改或删除」→ 正确授权 = SELECT + INSERT(无 UPDATE/DELETE)。
+ GRANT SELECT, INSERT ON jinrong_agent.audit_log TO 'xh_agent_rw'@'%';
+ GRANT SELECT, INSERT, UPDATE ON jinrong_agent.risk_convert_detail TO 'xh_agent_rw'@'%';
+ ```
+ > 表级 GRANT 可针对**尚不存在**的表(T-1 才建),故 T-0b 可先于 T-1 执行 ✓
+
+2. **`settings.py`** 新增(默认空 = 回退 `mysql_user`,**不阻塞本地开发**):
+ `mysql_core_ro_user` / `mysql_core_ro_password` / `mysql_core_rw_user` / `mysql_core_rw_password` / `mysql_agent_user` / `mysql_agent_password`
+
+3. **`db.py`**:
+ ```python
+ def get_engine(database: str, role: str = "rw") -> Engine:
+ """按 (库名, 角色) 缓存单例 Engine;role ∈ {"ro","rw","admin"}。
+ 对应角色账号未配置 → 回退 settings.mysql_user(行为与现状一致)。"""
+ ```
+ - `role="ro"` → 仅 core 库有意义(用 `mysql_core_ro_user`);agent 库无 ro 账号,回落 `mysql_agent_user`(或 `mysql_user`)
+ - `role="admin"` → 恒用 `mysql_user`
+ - 缓存键 `f"{database}:{role}"`;`dispose_engines()` 语义不变
+
+4. **关键边界(架构 §11.1 明确)**:**gateway 的读走 `ro`、写走 `rw`** —— `trade_gateway.py:105` 的校验读 `core_ro or CoreReadOnlyRepository()` **保持 ro 不变**,只有 `gateway_repository.insert_trade` 与新增的 `convert_core_repository` 用 `rw`。否则 gateway 顺带获得全库读权限,最小权限落空。
+
+**依赖**:无(可与 T-0 并行;但两者都必须在 T-1 之前)
+**完成标准(DoD)**
+- [x] `pytest -q` **全量全绿**(批 0 前基线 510 → 516;**默认配置下账号为空 → 全走 `mysql_user`,与现状完全一致**);
+ `tests/conftest.py:185/186/236/237` 四处已显式 `role="admin"`(R-e,防 teardown 失权)
+- [ ] 本地配置 3 组账号后,真 MySQL 三条权限断言绿(**账号未配置或真库不可达时自动 skip**,
+ 不阻塞默认基线):
+ - `test_db.py::test_core_ro_account_is_readonly`(ro 执行 INSERT → 被拒)
+ - `test_db.py::test_audit_log_append_only`(`audit_log` UPDATE/DELETE → 被拒)
+ - `test_db.py::test_core_rw_scope`(rw 对第 5 张表 `core_product` UPDATE → 被拒)
+- [ ] `test_db.py::test_get_engine_role_isolation`(同库不同角色 → 不同 engine)
+- [x] `grep -rn "get_engine("` **全部调用点逐点有明确角色**(F-11 清单:app 4 处 = `ro`/`rw`;`conftest` 4 处 = `admin`;其余为 `test_db.py` 用例自身)
+- [ ] 账号未配置时回退路径有单测(回退 = 现状行为)
+
+**风险**:低,但**若漏掉 conftest 的 admin 角色(R-e),真库集成测试会在 T-13 集体失败**——这是本任务最易漏的一处。
+**回滚点**:单独 commit;回滚 = 恢复单账号(`get_engine` 保留 `role` 参数但全部回落,无副作用)。
+
+---
+
+### 3.3 第 0 批执行记录(2026-09-10 完成)
+
+**基线**:开工前 `pytest -q` → **510 passed**。
+
+| 任务 | 落地文件 | 结果 |
+| --- | --- | --- |
+| T-0 | `tests/_ddl.py`(重写 `core_holding` + 新增 `core_product_nav` + `EXPECTED_CORE_HOLDING_COLUMNS` / `REQUIRED_CORE_TABLES` + `_assert_ddl_aligned()`)· `tests/test_chat_tools.py` · `tests/test_concentration_c4.py` · `tests/test_db.py`(+3 用例) | ✅ |
+| T-0b | `scripts/core/00-grant.sql`【新增】· `app/config/settings.py`(+6 项)· `app/utils/db.py`(`get_engine(db, role)` + `_resolve_credentials`)· `core_ro.py`→`ro` · `gateway_repository.py`→`rw` · `risk_repository.py`/`session_repository.py`→`rw` 显式 · `tests/conftest.py` 4 处→`admin` · `tests/test_db.py`(+3 引擎用例 +3 权限断言) | ✅ |
+
+**测试结果**:`pytest -q` → **516 passed / 3 skipped**(510 基线 + 3 条 T-0 用例 + 3 条 T-0b 引擎用例;
+3 条真 MySQL 权限断言因账号未配置按设计 skip)。
+
+**实施中发现并已回写本计划的 3 处偏差**:
+1. **2 处测试 INSERT 缺 3 个 NOT NULL 列**(原计划只提「改列名」)→ 详见 §3.1 第 5 点。
+2. **`audit_log` 授权口径**:架构 §11.1 原文「只授 INSERT」若**字面执行**会让
+ `has_engine_error_audit` / `list_audit_events` 失权 → 实际授权 **`SELECT, INSERT`**(无 UPDATE/DELETE)。
+3. **门禁反向验证改为自动化用例**(`test_ddl_alignment_guard_fails_when_column_missing`),
+ 不再需要「手工删列 → 验证 → 恢复」三步。
+
+**开工前置完成情况**:T-0 与 T-0b **代码侧已全部落地,默认配置下全绿**。
+`00-grant.sql` 的**实际执行**(建账号)与 `.env` 写入密码属环境操作 —— 执行后
+3 条权限断言才会由 skip 转为真实运行;未执行不影响 T-1 开工(默认回退单账号)。
+
+---
+
+---
+
+## 4. 第 1 批 · 数据与纯函数
+
+### 4.1 T-1 · DDL + 种子 + sqlite 同步
+
+**目标**:真库结构就位,种子可复现,sqlite 与之对齐。
+
+**涉及文件与改动**
+
+| 文件 | 动作 | 要点 |
+| --- | --- | --- |
+| `scripts/core/01-ddl.sql` | 【改】 | ① 新建 `core_fee_rule` / `core_share_lot` / `core_convert_lot_detail`(含 `nav`/`nav_date`,D6)② `core_trade` **只加** `convert_group_id VARCHAR(64) NULL` + `KEY idx_convert_group`(**`qty` 已存在**,F-3)③ `core_product` 加 **8 列**:`can_subscribe`/`can_redeem`(TINYINT DEFAULT 1)、`min_hold_qty`/`min_redeem_qty`(DECIMAL DEFAULT 0)、`min_hold_action`(VARCHAR DEFAULT `'force_transfer'`)、`subscribe_fee_rate`(DECIMAL(6,4) DEFAULT 0)、`fund_company`/`ta_code`(VARCHAR NULL)④ `core_product.fee_rate` 补 COMMENT(Q8) |
+| `scripts/core/07-seed-fee-rule.sql` | 【新增】 | 赎回费 5 档:`0.0150 / 0.0100 / 0.0050 / 0.0025 / 0`,`to_fund_ratio = 1.0`;**文件头带版本注释块** |
+| `scripts/core/08-seed-share-lot.sql` | 【新增】 | 按 `core_holding` 反推批次,**按客户/产品错开持有期**以覆盖多档;文件头版本块 |
+| `scripts/core/09-seed-org.sql` | 【新增】 | `fund_company`/`ta_code` + `subscribe_fee_rate`;**费率档严格按 `product_type` 取**(`stock`=0.0080 / `mixed`=0.0050 / `bond`·`index`=0.0030 / `money`=0),**造出不同费率对**:`PROD-110022`(bond)=0.0030 / `PROD-003095`(stock)=0.0080(**同属华夏组、可互转**);同费率对照 `PROD-510300`(index)=0.0030、零费率对照 `PROD-000001`=0;**分组按真实「管理人全产品线」重排**;文件头版本块 |
+| `scripts/core/reset.ps1` | 【改】 | `$files` 追加 `07/08/09`(**00-grant 不加入**) |
+| `docs/项目框架设计/表设计/02-mysql-agent专用.sql` | 【改】 | 追加 `risk_convert_detail` 建表(PRD §4.1 DDL 原文,`status` ENUM **5 值含 `expired`**) |
+| `tests/_ddl.py` | 【改】 | `core_trade` 补 `qty`/`convert_group_id`;`core_product` 补 8 列;新增 `core_fee_rule`/`core_share_lot`/`core_convert_lot_detail`/`risk_convert_detail`(共 4 张) |
+
+**DoD(全部达成,见下方执行记录)**
+- [x] `reset.ps1` 全流程跑通,且**可用 SQL 逐条断言**(判定阈值明确,非人工目测):
+ ① `SELECT COUNT(DISTINCT min_hold_days) FROM core_fee_rule WHERE fee_type='redeem'` = **5**(且每只产品各 5 档);
+ ② 按 `(customer_id, product_id)` 汇总 `core_share_lot.remain_qty` = `core_holding.qty`(逐行相等);
+ ③ `fund_company` / `ta_code` / `subscribe_fee_rate` 三列**无 NULL**,且存在 `subscribe_fee_rate` 不同的产品对
+ ※ 执行方式:本机 `mysql` 客户端不在 PATH 且 `reset.ps1` 的 `-p` 需交互密码,
+ 改用**等价流程**(`pymysql` 执行同一组 00~09 SQL)并**固化为 `scripts/dev/verify_convert_seed.py`**。
+- [x] `pytest -q` 全量全绿(当前基线 **516**;sqlite 加表不改任何既有断言)
+- [x] 老 INSERT(不带新列)仍可执行 → 向后兼容成立(架构风险 #9)
+- [x] `risk_convert_detail.status` ENUM **建表即含 5 值**(免二期 ALTER)
+
+**执行记录(2026-09-10)**
+
+| 项 | 内容 |
+| --- | --- |
+| 落地文件 | `scripts/core/01-ddl.sql`(3 新表 + `core_trade.convert_group_id` + `core_product` 8 列 + `fee_rate` COMMENT)· `07/08/09-seed-*.sql`【新增】· `reset.ps1`(`$files` 追加 07/08/09)· `docs/项目框架设计/表设计/02-mysql-agent专用.sql`(追加 `risk_convert_detail`)· `tests/_ddl.py`(`core_trade` 补 `qty`/`convert_group_id`、`core_product` 补 8 列、新增 4 表、`REQUIRED_CONVERT_TABLES` 门禁)· `scripts/dev/verify_convert_seed.py`【新增】 |
+| 断言结果 | **8/8 PASS**:① 5 档 × 14 产品(70 行,无产品档数≠5)② 58 行持仓 vs 61 行批次,**失配 0** ③ NULL 0 行、DISTINCT 费率 4 ④ 批次覆盖 5 档(13/12/12/12/12)⑤ CUST-9527 跨批次(005827×3、110022×2)⑥ 老 INSERT 兼容(新列取默认值 `None`/`1`/`force_transfer`/`0`)⑦ `status = enum('pending','completed','failed','cancelled','expired')` ⑧ **费率档 ↔ `product_type` 匹配**(越档 0 · 主示例两端同主体「华夏模拟基金/TA-CN-001」· 华夏组费率档 4) |
+| pytest | `516 passed / 3 skipped`(与批 0 后基线**完全一致 → 零回归**;sqlite 加表与既有断言无耦合) |
+| 真库落位 | `jinrong_agent.risk_convert_detail` **已实际建表**(`SHOW COLUMNS` 实测 `status = enum('pending','completed','failed','cancelled','expired')`);`jinrong_core` 已按 00~09 重灌并通过断言 |
+| 文档同步 | `scripts/core/README.md`(手动顺序 + 种子规模:持仓 ~45→**58**、新增费率/批次两行)· `docs/项目框架设计/Core模拟底座/00-方案总览.md`(目录树补 00-grant/07/08/09)· `演示SOP-风控模块.md`(for 循环补 07/08/09)· **`docs/PRD/PRD-基金转换交易.md`(§4.3 费率分类归位 + §5.3 主示例转入方改 `PROD-003095` + §2.1.1/§9 引用同步)** |
+
+**实施中发现的 3 处需注意项**:
+
+1. **`reset.ps1` 的可执行性缺口(实跑踩坑)**:需 `mysql` 在 PATH 且 `-p` 交互输密码 → 非交互/CI 场景不可用。
+ 已用 `scripts/dev/verify_convert_seed.py` 提供等价无交互路径(含 7 条 DoD 断言)。
+ **更关键的连带坑**:重建 `jinrong_core` 后**必须补跑 `scripts/demo/prepare_risk_demo.sql`**
+ (reset 的既定伴随步骤,SOP §2)—— 否则 `03-seed-customers.sql` 写死的风评有效期会让
+ `tests/conftest.py::ensure_risk_demo_ready` 判定演示数据未就位,**整个真库集成模块被 skip**
+ (实测 **516 → 505 passed**)。本脚本已内置该步骤,避免二次踩坑。**计划外增补,非返工**。
+2. **`core_fee_rule` 的读取路径已明确**(自检第 2 问「谁读」):`00-grant.sql` 中
+ `xh_core_ro` 为 `SELECT ON jinrong_core.*`,**库级授权自动覆盖 T-1 新建表**;
+ `xh_core_rw` 刻意不授该表读权限 → convert 费率读取**必须走只读账号**(T-6 遵守)。**无需改 grant**。
+3. **费率分类错误 —— 已按真实业务修正(2026-09-10 用户裁定)**:v1.0 种子把
+ `PROD-005827`(`product_type='mixed'`,平衡混合一号)按「主动偏股」取到 **0.0080** 上限,
+ 目的是凑出 0.50% 的补差幅度。**这是分类错误** —— 22 号文 §8 的费率档由**产品类型**决定,
+ `mixed` 只能套「其他混合型 ≤0.5%」。用户驳回原话:「**那是两种类型,怎么能混呢?**」
+ **修正内容**(三处联动,缺一不可):
+ - `PROD-005827` → **0.0050**(其他混合型);主示例转入方改用**真正的主动偏股产品
+ `PROD-003095`**(`stock` / R4 / 净值 **0.9500**)→ 与原示例除数相同,**§5.3 全部派生数字
+ (`in_qty=53456.95` / `diff_fee=252.40` 等)一个都不用改**(已跑 `calc_convert_demo.py` 复核);
+ - **分组按真实业务重排**:v1.0 的「华夏组全低风险、易方组全股票型」不符合真实
+ (真实基金管理人是**全产品线**)→ 现两组各 6 只、各覆盖多个风险层级;
+ 理财/私募单列(非公募主体,天然不可与公募互转);
+ - **连带约束核对**:`PROD-003095` 与转出方 `PROD-110022` **同属华夏模拟基金 / TA-CN-001**
+ (同管理人 + 同 TA → 满足转换前置硬约束);C3 客户 → R4 产品为
+ `allowed_with_disclosure`(需签揭示书后**放行**,非阻断)。
+ **机制落地**:该约束已固化为断言 **⑧「费率档 ↔ `product_type` 匹配」**(越档即 FAIL),
+ 不再依赖人工比对;并写入项目记忆**自检第 12 问:类型与口径必须匹配**。
+
+**风险**:低。注意 `08-seed-share-lot.sql` 的持有期错开必须**真的落在不同档位**(否则费率档单测无数据)。
+
+---
+
+### 4.2 T-2 · `service/convert/` 纯函数包
+
+**目标**:所有金额/费率/份额逻辑落到可穷举单测的纯函数里,**不查库、不碰 SQL**。
+
+**新增文件**(`app/service/convert/`)
+
+| 文件 | 内容(架构 §7 签名) |
+| --- | --- |
+| `__init__.py` | 空 |
+| `types.py` | `@dataclass(frozen=True)` 定义 `Lot` / `FeeRule` / `PlanResult`(评审 S3,**不用裸 dict**) |
+| `calc.py` | `plan_lots()` · `lot_amount()` · `lot_fee()` · `convert_amount()` · `diff_fee(mode)` · `in_qty()` |
+| `fee.py` | `pick_fee_rate(rules, hold_days)`(区间左闭右开) |
+| `nav.py` | `is_stale()` + NAV_NOT_READY 判定(输入已取到的净值行,不查库) |
+| `lot_bootstrap.py` | `bootstrap_lots(holding_row) -> list[Lot]`(D18:gateway 兜底补建与 `rebuild_lots.py` **同源**) |
+| `errors.py` | 8 个 convert 专属异常(继承 `ApiError` 语义,§8.3 映射) |
+
+**必须写死的实现细节**
+
+1. **精度**:所有量化处显式 `rounding=ROUND_HALF_UP`(`Decimal` 默认是 `ROUND_HALF_EVEN`,架构风险 #3);**逐批先舍入后求和**。
+2. **补差费双口径**(D13):
+ - `mode="amount_diff"`(默认,口径 B):`Max[conv×in/(1+in) − conv×out/(1+out), 0]`
+ - `mode="rate_diff"`(口径 A):`conv × max(in−out,0) / (1 + max(in−out,0))`
+3. **`in_qty` 2 位 HALF_UP**(v1.0 勘误:v0.2 的「4 位 ROUND_FLOOR」是残留,已废)。
+4. **`pick_fee_rate` 边界**:`[min_hold_days, max_hold_days)`;`hold_days = (交易日 − confirmed_at).days`(**自然日,不含申请日**;满 7 日归 7–30 档)。
+5. **`plan_lots` 一次性返回 `PlanResult`(含 `actual_qty`/`forced_full_transfer`/`action`/`batch_count`)**——`TOO_MANY_LOTS` 必须**先规划再判上限**,避免半途失败(§8.3)。
+
+**另增 3 个纯函数(架构 §7 签名清单未列,T-2 执行期补入并已回填架构 §7)**
+
+| 函数 | 归属 | 为什么必须有 |
+| --- | --- | --- |
+| `hold_days(trade_date, confirmed_at)` | `calc.py` | `pick_fee_rate` 的入参是持有天数,而 `confirmed_at` 是时间戳;**日期差必须可单测**(T+1 起算、满 7 日归 7–30 档都靠它),不能散落在 service 里 |
+| `rounding_diff(in_amount, in_nav, actual_in_qty)` | `calc.py` | PRD §5.3 响应字段,需与 `in_qty` **同一套舍入**算差值,独立实现在脚本侧会漂移 |
+| `ensure_batch_limit(plan, max_lots)` | `calc.py` | 把「批次数超上限」的判据固定在 `plan_lots` 的产物上(`batch_count`),避免调用方各写一遍 `>` 判断 |
+
+**DoD(全部达成,见下方执行记录)**
+- [x] `tests/test_convert_calc.py` ~35 条绿:FIFO / 跨批次计费 / 双口径各一组 / **`.5` 边界(HALF_UP vs HALF_EVEN 结果必须不同处命中 HALF_UP)** / 分档边界 `6/7/29/30/179/180/364/365` / 强制全转 + 强制赎回双动作 / T+1 起算 / 份额 2 位
+- [x] `plan_lots` 的 `batch_count` 在 `> 200` 时可供调用方抛 `TOO_MANY_LOTS`
+- [x] 纯函数**零 import 仓储/引擎**(`grep -n "import" calc.py` 人工确认)
+
+**执行记录(2026-09-10)**
+
+**新增 7 个文件**(`app/service/convert/`):
+
+| 文件 | 内容 |
+| --- | --- |
+| `__init__.py` | 包职责 docstring(纯函数约束:不查库 / 不碰 SQL / 不读配置 / 不写日志) |
+| `types.py` | `Lot` / `FeeRule` / `LotAllocation` / `PlanResult`(`@dataclass(frozen=True)`)+ `to_decimal` / `to_datetime` / `to_date` 三个归一工具 |
+| `calc.py` | `round2` / `lot_amount` / `lot_fee` / `convert_amount` / `in_qty` / `rounding_diff` / `diff_fee` / `hold_days` / `plan_lots` / `ensure_batch_limit` |
+| `fee.py` | `matches` / `pick_fee_rate`(左闭右开、多档命中取最具体档) |
+| `nav.py` | `ensure_nav_ready`(无净值 → 503)/ `is_stale` / `evaluate_nav` |
+| `lot_bootstrap.py` | `offset_for` / `bootstrap_lot_id` / `bootstrap_lots`(D18 单点) |
+| `errors.py` | `ConvertError` 基类 + **11 个**子类(见下方差异说明) |
+
+**关键实现点**
+
+1. **精度**:所有量化点显式 `rounding=ROUND_HALF_UP`(`Decimal` 默认 `ROUND_HALF_EVEN`);测试里有一条**反向自证**用例——先证明 `HALF_EVEN` 会得到不同结果,再断言 `round2` 不等于它(漏传 `rounding` 时必红)。
+2. **`plan_lots` 不判上限**:只做 FIFO 分配 + 最低持有处置,上限判据由 `ensure_batch_limit` 在**规划之后**执行(§8.3「先规划再判上限」)。
+3. **`_fifo_order` 内部再排一次序**:SQL 已 `ORDER BY confirmed_at, lot_id`,但纯函数不依赖调用方是否记得写 ORDER BY(否则同注册日多批次顺序不可复现,评审 S1)。
+4. **`bootstrap_lots` 用 `zlib.crc32` 而非内置 `hash`**:`hash` 受 `PYTHONHASHSEED` 随机化,会让 gateway 与 `rebuild_lots.py`(两个进程)算出不同 `confirmed_at`,D18 的同源断言会随机失败。
+5. **`pick_fee_rate` 无命中 → 500 `FeeRuleMissing`**,**不降级为 0**:静默按 0 计费会少收赎回费且不留痕。
+
+**与 §8.3 / 开发计划的两处差异(均已核实、非笔误)**
+
+| 项 | 文档原写法 | 实际实现 | 说明 |
+| --- | --- | --- | --- |
+| 异常个数 | 开发计划 §4.2 写「**8 个**」 | **11 个** | §8.3 表实有 10 条(7 个业务 400 + `LOT_CONFLICT` 409 + 2 个 503);另加 1 个 §8.3 表外的内部兜底 `FeeRuleMissing`(500)。开发计划「8 个」为计数笔误,以 §8.3 表为准 |
+| `hold_days` 等 | 架构 §7 未列 | 见上方「另增 3 个纯函数」 | 已回填架构 §7(纯函数签名清单补 2 行)+ §8.3(补 `FEE_RULE_MISSING` 注) |
+
+**验证证据**
+
+| 项 | 结果 |
+| --- | --- |
+| `pytest tests/test_convert_calc.py -q` | **93 passed**(12 个测试类,参数化展开后 93 条) |
+| `pytest -q` 全量 | **609 passed / 3 skipped**(T-1 后基线 516 → **+93**,零回归) |
+| 纯函数零 IO 依赖 | 由 `TestPurity` **3 条断言**强制(import 白名单 + 禁用符号 + `hash(` 禁用),**不留人工核对口子** |
+| PRD §5.3 数字自证 | 见 T-2b 执行记录(15/15 一致,脚本退出码 0) |
+
+**回归面(§12)**:**T-2 为纯新增包,未修改任何既有文件**(`app/service/convert/` 下 7 个新文件 + 1 个新测试文件),
+故 §12 十六条回归面**一项都未触发**;全量 609 passed 中的 516 条既有用例**逐条原样通过**,证明这一点。
+
+**依赖**:无(可与 T-1 并行)
+
+---
+
+### 4.3 T-2b · 示例实算回填(禁止手算)
+
+**目标**:PRD §5.3 主示例的每个数字由**生产同一套 `calc.py`** 实算产出,回填 PRD 与验收断言。
+
+**新增文件**:`scripts/dev/calc_convert_demo.py`
+**输出**:主示例(口径 B,`in_qty = 53456.95`)+ 口径 A 对照(`53455.36`)+ 同费率对照(`diff_fee = 0.00`)
+
+**DoD(全部达成,见下方执行记录)**
+- [x] 脚本输出与 PRD §5.3 表格**逐行一致**(不一致则说明公式或示例数据有问题 → 停下来查,**不得改示例数字迁就脚本**,也不得手算改脚本)
+- [x] PRD §5.3 表格标注「由 `calc_convert_demo.py` 实算回填」
+- [x] 脚本纳入 `docs/项目框架设计/` 引用,供审核复核
+
+**执行记录(2026-09-10)**
+
+**脚本被重写,不只是「跑一遍」——原实现有一处结构性隐患**
+
+| | 改前(T-1 期的临时脚本) | 改后 |
+| --- | --- | --- |
+| 公式来源 | **脚本内自带一份副本**(`q2()` + 逐批循环 + 两个口径的算式) | **只调用生产 `app.service.convert.calc` / `fee`**,脚本仅做输入准备与打印 |
+| 风险 | 与生产实现**两处公式必然漂移**(生产 `calc.py` 改了、脚本没改 → PRD 里的数字变成**假证据**)——与 D18「规则只留一个副本」是同一类问题 | 单点来源,无副本可漂移 |
+| 自校验 | 无(人工比对输出与 PRD) | **15 项期望值逐项比对,不一致即 `exit 1`** → 脚本同时是「PRD 数字 ↔ 生产口径」一致性门禁 |
+
+**顺带修正**:脚本补 `sys.path` 引导项目根(`ROOT = parents[2]`,同 `verify_convert_seed.py` 先例)——改前脚本不 import 项目代码,故未暴露;一旦改成调用生产函数,直跑会 `ModuleNotFoundError: No module named 'app'`。
+
+**验证结果**(`python scripts/dev/calc_convert_demo.py`,退出码 **0**):
+
+```
+批次 1(LOT-DEMO-01)30000.0000 份 / 持有 100 天 / 费率 0.0050 → amount=30900.00 fee=154.50
+批次 2(LOT-DEMO-02)20000.0000 份 / 持有 3 天 / 费率 0.0150 → amount=20600.00 fee=309.00
+✅ 全部 15 项与 PRD §5.3 一致(口径 B in_qty=53456.95)
+```
+
+15 项 = 逐批 4 项(两批各 amount/fee)+ 主链 7 项(`out_amount` 51500.00 / `redeem_fee` 463.50 / `convert_amount` 51036.50 / `diff_fee(B)` 252.40 / `in_amount` 50784.10 / `in_qty(B)` 53456.95 / `rounding_diff` -0.0026)+ 口径 A 对照 2 项(253.91 / 53455.36)+ 同费率对照 2 项(0.00 / 53722.63)。
+
+**依赖**:T-2
+
+---
+
+## 5. 第 2 批 · 仓储与锁(并行组 A)
+
+### 5.1 T-3 · `core_ro` 五个新方法 + `share_lot_repository`
+
+| 文件 | 动作 | 要点 |
+| --- | --- | --- |
+| `app/repository/core_ro.py` | 【改】新增 5 方法 | `get_nav_as_of(pid, trade_date)`(`nav_date <= :d` 降序取 1,D10)· `get_redeem_fee_rules(pid)` · `list_share_lots(cid, pid, max_lots)`(**`ORDER BY confirmed_at ASC, lot_id ASC`**,S1 确定性 tiebreaker)· `sum_remain_qty(cid, pid)` · `get_holding(cid, pid)` |
+| | 【改】**不动** | `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` 全绿(新增方法不动既有行为)
+
+**依赖**:T-1
+
+---
+
+### 5.2 T-4 · `convert_repository`(agent 侧)
+
+| 方法 | 用途 |
+| --- | --- |
+| `insert_placeholder(group_id, client_request_id)` | 阶段零占位(`uk_idem` 兜底) |
+| `complete_convert(group_id, ...)` | 阶段二回写 `completed` + 详情 |
+| `mark_failed(group_id)` | 阶段一失败 → 占位置 `failed` |
+| `get_by_group_id(group_id)` | 重试判定读取 |
+| `get_by_client_request_id(cid_req)` | 幂等命中读取 |
+| `list_expired_candidates(hours)` | 供 `cleanup_pending_convert.py` 取超 24h 的 `pending` |
+| `mark_expired(group_id)` | 置 `expired`(**标记不硬删**,S2) |
+
+**DoD**
+- [ ] sqlite 单测覆盖:占位 → completed / 占位 → failed / 超时 → expired
+- [ ] `client_request_id` 为 `None` 时不受 `uk_idem` 约束(MySQL 允许多个 NULL;sqlite 侧需断言行为一致)
+- [ ] 不与 `risk_repository` 混职责(后者不动)
+
+**依赖**:T-1
+
+---
+
+### 5.3 T-5 · `locks.try_lock`
+
+```python
+def try_lock(key: str, ttl_seconds: int = LOCK_TTL_SECONDS) -> _Token | None:
+ """单次尝试抢锁:抢到返回 token,抢不到立即 None(不等、不降级)。
+ Redis 不可用 → 退回进程内 Lock.acquire(blocking=False),语义一致。"""
+```
+- **不改 `run_locked`**(D4:`run_locked` 会等 2s 并降级 `fn(False)`,与「抢不到立即 202」语义相反;且它已有 3 处调用:`alert_service.py:180` 的 `agg:event:` / `:239` 的 `agg:suitability:` 等)
+- 返回 `_NoLock` 哨兵对象,`__enter__` 返回 `False`,避免 `with None` 报错
+
+**DoD**
+- [ ] 抢到 / 抢不到 / Redis 不可用回退 三条路径单测
+- [ ] key 形态覆盖:`convert:idem:{cid_req}` / `convert:rerun:{gid}`
+- [ ] `run_locked` 的既有用例零改动
+
+**依赖**:无
+
+---
+
+## 6. 第 3 批 · 事务与编排
+
+### 6.1 T-6 · `convert_core_repository.apply_convert`(阶段一单事务 · 高风险)
+
+**新增文件**:`app/gateway/convert_core_repository.py`
+
+**单事务内容**(`with engine.begin()` 串行,D3)
+
+| 序 | SQL | 备注 |
+| --- | --- | --- |
+| 1-2 | `INSERT core_trade` × 2 | **同 `convert_group_id`**;`trade_type` 分别为 `redeem`(转出)/`subscribe`(转入)——**R-b** |
+| 3 | `UPDATE core_share_lot SET remain_qty = remain_qty - :q WHERE lot_id=:lot AND remain_qty >= :q` × N | **条件 UPDATE**,`rowcount != 1` → 抛 `LotConflict`(409) |
+| 4 | `INSERT core_share_lot` × 1 | 转入新批次(`confirmed_at = T+1`,D14) |
+| 5-6 | `UPDATE core_holding ...` × 2 → `rowcount == 0` 则 `INSERT` | **不用方言 UPSERT**(R-a) |
+| 7 | `INSERT core_convert_lot_detail` × N | 含 `nav`/`nav_date`(D6) |
+
+**必须守住**
+- 引擎**必须**用 `get_engine(core_db, "rw")`(T-0b)
+- 阶段一**任何一步失败 → 整体回滚**(`engine.begin()` 异常即 rollback)
+- 转出端 `qty` 归零**保留行**(D9/P2),不 DELETE(`xh_core_rw` 本就无 DELETE 权限)
+- `cost_amount` 等比例结转:`cost × (1 − actual/原qty)`;`pnl_pct = (mv − cost)/cost`,`cost=0` 置 0
+- 两端 `core_holding` 写入**必须走 R-a 的三步**(条件 UPDATE → 首次 INSERT → `IntegrityError` 回退 UPDATE),
+ 不得用 `ON DUPLICATE KEY UPDATE`(sqlite 语法错)也不得用 `WHERE NOT EXISTS` / `FROM DUAL`(方言互斥,§1.5 R-a 已实测排除)
+
+**DoD**
+- [ ] sqlite 单测:正常路径落 2 流水 + N 明细 + 两端持仓
+- [ ] **冲突路径**:构造 `remain_qty < q` → `rowcount=0` → `LotConflict`
+- [ ] **回滚路径**:在明细 INSERT 处注入异常 → 断言 `core_trade` / `core_share_lot` / `core_holding` **全部无残留**(这是「同事务」的硬证据)
+- [ ] 两端 `core_holding`:转出端归零保留行;转入端首次 INSERT、再次 UPDATE
+- [ ] **并发首次建行(R-a 兜底验证 · 第二轮审核补入)**:模拟两笔并发首次转入同一 `(cid, pid)` →
+ 断言最终**只有一行**、金额为两笔之和、**两笔都成功**(不得出现 5xx)
+
+**依赖**:T-1 / T-3
+
+---
+
+### 6.2 T-7 · `convert_service` 编排(关键路径 · 高风险)
+
+**新增文件**:`app/service/convert/convert_service.py` + `__init__.py` 导出
+
+**八步顺序**(架构 §3 / PRD §7.0,**前四步不落库**)
+
+```
+① 参数与产品校验(from != to / can_redeem / can_subscribe / 同 fund_company+ta_code)→ 4xx
+② 份额校验(Σ share_lot.remain_qty 为权威源,非 core_holding.qty)+ 最低份额(全额豁免)→ 4xx
+③ 净值取数与折算(纯函数)→ 无净值 503 NAV_NOT_READY
+④ 适当性校验(转入端,唯一业务阻断点)→ blocked 出 R-02 预警 + 审计 → return(不占位)
+⑤ 阶段零:try_lock("convert:idem:{cid_req}") 未抢到 → 202 + group_id;占位失败(带键)→ 503
+⑥ 阶段一:apply_convert(失败 → 占位 failed + 4xx/5xx)
+⑦ 阶段 1.5:process_convert_event(out, in) → 异常落 engine_error + 本地日志,**不阻断**
+⑧ 阶段二:complete_convert + 审计(失败 → decision='convert_detail_write_failed' + logger.exception)
+```
+
+**关键实现点**
+- **重试判定**:取得执行权后 `SELECT 1 FROM core_trade WHERE convert_group_id = :gid LIMIT 1` → 有行 = 阶段一已成,**只补跑阶段二**(走 `core_ro` 只读;依赖 `idx_convert_group`)
+- **补跑阶段二必须 `try_lock("convert:rerun:{gid}")`**(PRD §11:两个并发重试会审计双写,审计表只 INSERT 无法去重,只能靠锁串行)
+- **`nav_stale`**:`nav_date` 距交易日 > `convert_nav_stale_seconds`(默认 3 天)→ 额外落 `nav_stale` 副审计
+- **`rounding_diff`** 落审计(正负向 + 金额,Q10)
+- **本地日志兜底**:阶段二失败时 `logger.exception` 输出 `group_id` + 全部折算参数(PRD §7.1 第三轮第 8 条)
+
+**DoD**
+- [ ] `tests/test_convert_service.py` ~22 条:八步顺序(**blocked 不占位**)/ 三阶段 / 幂等命中 / 阶段二失败补偿 / `nav_stale` 审计条数
+- [ ] 幂等:同 `client_request_id` 重复提交 → 只一组流水(验收 12)
+- [ ] 幂等窗口闭合:阶段二失败后带同键重试 → 不产生第二组流水,且 RISK-002 当日累计**不翻倍**(验收 15)
+- [ ] 未抢到执行权 → 202 `{convert_group_id, status: "processing"}`
+- [ ] 全额转出豁免:持有 6000 / 申请全转 6000 / `min_redeem_qty`=10000 → **成功**(验收 16)
+
+**依赖**:T-2 ~ T-6
+
+---
+
+## 7. 第 4 批 · 引擎与网关
+
+### 7.1 T-8 · 规则引擎改造
+
+| 文件 | 动作 |
+| --- | --- |
+| `app/service/risk/rules.py` | 【改】新增 `_amount_view(trades)`;`run_rules` 内部:`eligible`(全量)供 RISK-001/003/004,`_amount_view(eligible)` 供 **RISK-002/005**(RISK-006 读持仓快照,不受影响) |
+| `app/service/risk/engine.py` | 【改】新增 `process_convert_event(out_trade, in_trade, *, core_ro=None, risk_repo=None, thresholds=None, on_error_hook=None)`,与 `process_trade_event` 共用内部 `_run()`(**不改现有签名**,W-2) |
+| `app/service/risk/alert_service.py` | 【改】`record_trade_alerts(..., events: list[dict] | None = None)`,缺省 `None` → 退化为现有「单 event」行为(**现有调用零改动**) |
+
+**关键实现点**
+- `_amount_view`:同 `convert_group_id` 组内**只保留 `trade_type='redeem'` 那条**;**无 gid 的交易原样通过**(非 convert 场景恒等 → 510 零影响);组内无 redeem 时取第一条(防御)
+- `process_convert_event`:取当日全量流水(已含两条)→ `run_rules` → `record_trade_alerts(primary=out_trade, hits, events=[event_of(out), event_of(in)])` → **一张单、`payload.events` 两条**
+- **`on_error_hook`(D19)**:一期传 `None`;**hook 调用必须包 `try/except`**,hook 自身失败**不得**反噬主流程(与「阶段 1.5 不阻断交易」同原则)——**必须有单测**
+
+**DoD**
+- [ ] `_amount_view` 对无 gid 交易恒等(`assert _amount_view(x) == x` 型用例)
+- [ ] 一组 convert 两条流水 → RISK-002 只计一次;RISK-001/RISK-003 仍看到两条(验收 5/6)
+- [ ] `process_convert_event` 只出**一条**预警单,`payload.events` 长度 2(验收 7)
+- [ ] hook 抛异常时主流程正常返回(断言不抛)
+- [ ] `pytest -q` 全绿(引擎既有 13 处 `run_rules` 调用零改动)
+
+**依赖**:无(可与 T-2 之后任意阶段并行)
+
+---
+
+### 7.2 T-9 · API 模型与网关分派
+
+| 文件 | 动作 |
+| --- | --- |
+| `app/api/simulate.py` | 【改】`TradeRequest`:`product_id` 改 `Optional`、`amount` 改 `Decimal | None = Field(None, gt=0)`、新增 `from_product_id`/`to_product_id`/`qty`/`client_request_id`;`@model_validator(mode="after")` 按 `trade_type` 分支校验 |
+| | 【改】错误映射:`UnsupportedTradeType` → 400 保留(未知类型);新增 convert 异常 → `ApiError`(§8.3 映射表) |
+| `app/gateway/trade_gateway.py` | 【改】三处并列:① 移除 `:112-113` 的 convert 拒绝 ② subscribe/redeem 分支增补批次维护(**T-10**)③ 新增 convert 分派(调 `convert_service`) |
+| `app/gateway/gateway_repository.py` | 【改】`insert_trade` 增**可选**参数 `qty=None` / `convert_group_id=None`(默认 None → 既有调用零改动) |
+
+**关键实现点**
+- **`client_request_id` 校验复用 `app/main.py:44` 的 `_TRACE_ID_PATTERN`**(`^[A-Za-z0-9._-]{1,64}$`),**不自造正则**(S4;避免两套白名单漂移)
+- `amount: Decimal | None = Field(None, gt=0)`:**保住**「amount=0 → 422」(F-12 第 4 条);pydantic v2 中 `gt` 对 `None` 不校验、对 `0` 校验 → 该用例自动通过
+- convert 响应:**纯 dict + 全部 Decimal 已 `str()`**,含 `lot_breakdown`/`out_amount`/`convert_amount`/`in_amount`/`in_qty`/`rounding_diff`/`batch_count`/`max_lots`
+- `TOO_MANY_LOTS` 错误体带 `batch_count` + `max_lots`(前端提示「请拆分多笔申请」)
+- **不改路由**:仍为 `POST /api/simulate/trade`(F-12/§1.2,路由清单断言零影响)
+
+**DoD**
+- [ ] `tests/test_trade_gateway.py` 的 4 处断言按 §12 清单改写完毕(R3/R4/R5 + R7 补批次断言)
+- [ ] `tests/test_integration_risk.py:426-437` 的 `test_convert_400_and_no_new_trade_audit` 按 §12 **R15** 处置(改写为端到端走通,或迁入 `test_convert_integration.py`)
+- [ ] `test_convert_integration.py`:真 MySQL `CNV-TEST-`/`TRD-TEST-` 前缀隔离,端到端折算与 PRD §5.3 示例逐项吻合(~6 条)
+- [ ] 错误码映射 8 条各有断言(含 `CROSS_ENTITY_NOT_SUPPORTED`,验收 14)
+- [ ] 未知 trade_type(如 `purchase`)仍 400
+
+**依赖**:T-7
+
+---
+
+### 7.3 T-11 · 工具汇总去重 + SQL 求和去重
+
+| 文件 | 动作 |
+| --- | --- |
+| `app/tool/core_tools.py` | 【改】`query_recent_trades` 汇总走 `_amount_view`(FR-C15);持仓查询(`query_holdings`)过滤 `qty <= 0`(P2 归零行保留但需过滤) |
+| `app/repository/core_ro.py` | 【改】`sum_trades_on_date` 加 convert 去重条件(**R-d**,架构 §6 未覆盖) |
+| `tests/test_core_ro_sum.py` | 【改】追加 1 条 convert 去重用例 |
+
+**DoD**
+- [ ] `core_tools` 汇总不翻倍(验收 11)
+- [ ] 持仓查询不返回 `qty = 0` 的行
+- [ ] `sum_trades_on_date` 既有 5 条断言零改动 + 新 1 条去重用例绿
+- [ ] `pytest -q` 全绿
+
+**依赖**:T-8
+
+---
+
+## 8. 第 5 批 · 高风险专项
+
+### T-10 · 普通申赎批次维护(FR-C16)+ `rebuild_lots.py`
+
+> **单列原因**:这是**唯一直接改 `trade_gateway` 主流程**的任务,而 `trade_gateway` 是
+> `test_trade_gateway.py`(~20 条)与 `test_integration_risk.py`、`test_audit_middleware.py` 的公共入口
+> → **510 用例直接受影响**。架构风险表 #6 定为「先跑基线后改造」。
+
+**目标**:批次表由**交易统一出入口**维护,覆盖全部交易类型;消除 PRD §4.4 描述的「普通赎回后批次与持仓失配 → convert 超扣」。
+
+**涉及文件**
+
+| 文件 | 动作 |
+| --- | --- |
+| `app/gateway/trade_gateway.py` | 【改】subscribe 分支:新增批次;redeem 分支:FIFO 扣减;**两者均含 D8 兜底补建** |
+| `scripts/core/rebuild_lots.py` | 【新增】按 `core_holding` 重建批次(**快照重建,非交易回滚**,L-7);**与 gateway 兜底补建同调 `lot_bootstrap.bootstrap_lots`**(D18) |
+| `tests/test_share_lot.py` | 【新增】~12 条:普通申赎批次维护 / 无批次兜底补建 / `rebuild_lots` 入口 |
+| `tests/test_trade_gateway.py` | 【改】补批次维护断言 |
+
+**风险控制(五道)**
+
+1. **开工前跑全量全绿基线并快照**(当前 **516**;架构风险 #6 硬要求)
+2. **先写测试再改**:`test_share_lot.py` 先行
+3. **D18 同源断言**:构造同一 `core_holding` 行,断言「gateway 兜底补建」与「`rebuild_lots.py`」算出的批次**完全一致**(`confirmed_at` 必须相同——这是 D18 设立的唯一目的)
+4. **降级规则(R-c(1))**:`subscribe` 取不到当日净值 → warning + 不建批次;`redeem` **既无批次也无持仓行** → warning + 跳过扣减;**两者均不抛异常、不阻断交易**(既有 510 用例的 `env` 无持仓无净值,F-13)
+5. **覆盖率补偿(R-c(2),第一轮审核补入)**:`test_share_lot.py` **自建完整种子**(`core_holding` + `core_share_lot` + `core_product_nav`),覆盖两条**真实路径**——`redeem` FIFO 扣减(含 D8 兜底补建再扣)与 `subscribe` 建批次;**不得让降级路径充当测试覆盖**
+
+**D8 兜底补建规则(`lot_bootstrap.bootstrap_lots` 单点)**
+- `qty` = `core_holding.qty`
+- `confirmed_at` 按**错开规则**由 `core_holding.as_of` 反推(与 `08-seed-share-lot.sql` 同口径)
+- 无批次时**补建再扣**,**不跳过、不阻断**
+
+**DoD**
+- [ ] 开工前:全量全绿基线已记录(当前 **516**)
+- [ ] 正常赎回后再 convert,**批次与持仓一致、不超扣**(验收 9)
+- [ ] **真实路径全覆盖**(R-c(2)):`test_share_lot.py` 自建种子 → `subscribe` 建批次(`qty = amount ÷ nav`,2 位 HALF_UP,`confirmed_at = T`)· `redeem` FIFO 扣减 · 无批次有持仓 → **D8 兜底补建后再扣** · `rebuild_lots.py` 入口
+- [ ] 降级路径单测(R-c(1)):`subscribe` 无净值 → warning + 不建批次 + 不抛异常;`redeem` 无持仓无批次 → warning + 跳过扣减
+- [ ] D18 同源断言绿(两侧 `confirmed_at` 逐一相等)
+- [ ] `pytest -q` **510 + 新增全绿**
+- [ ] `test_trade_gateway.py` 既有断言按 §12 处置完毕(**R16 应零改动通过**)
+
+**依赖**:T-3(排期置于 T-7 之后)
+
+---
+
+## 9. 第 6 批 · 补偿脚本(T-12)
+
+| 文件 | 动作 |
+| --- | --- |
+| `scripts/demo/rebuild_alerts.py` | 【改】支持 `--convert-group CNV-xxx`:仅凭 Core 侧(`core_trade` + `core_convert_lot_detail` 的 `nav`/`nav_date`)重算详情并回写 → `completed`;**必须 `try_lock("convert:rerun:{gid}")`** |
+| `scripts/agent/cleanup_pending_convert.py` | 【新增】超 `convert_compensate_sla_hours`(24h) 的 `pending` 占位 → 置 `status='expired'`(**标记不硬删**,S2) |
+
+**幂等锚点(Q4)**:以**转出端 `out_trade_id`** 为锚点 —— 复用 `rebuild_alerts.py:54-66` 的 `find_alerts_by_trade(trade_id)`,命中即 `skipped`;**一次转换有两条流水,只认转出端**,避免重复出单。该脚本**本就幂等**(其 `:8-10` docstring),**不需要再加 `uk_idem`**。
+
+**DoD**
+- [ ] 阶段二失败 → `decision='convert_detail_write_failed'` 审计存在(验收 17 前置)
+- [ ] `rebuild_alerts --convert-group` 能仅凭 Core 侧数据补出**完整详情** → `completed`(验收 17)
+- [ ] 重复执行补偿 → `skipped`,不产生第二张预警单(幂等)
+- [ ] `cleanup_pending_convert.py` 把超 24h `pending` 置 `expired`,**行仍在**(不硬删)
+
+**依赖**:T-4 / T-7
+
+---
+
+## 10. 第 7 批 · 回归与实测(T-13)
+
+### 10.1 全量回归
+- `pytest -q` 全绿,计数 ≥ 585(CI 内预计约 597,压测 10 条不进门禁)
+- 集成测试(真 MySQL,`CNV-TEST-`/`TRD-TEST-` 前缀)绿
+- 按 SOP 重灌双库后复跑一次(避免残留数据污染)
+
+### 10.2 50 并发压测(评审 Q6 · **不进 CI 门禁**)
+同一 `(customer_id, product_id)` 上 50 并发争抢同一批份额,断言三件事:
+1. `LotConflict`(409) 命中数与剩余可转份额一致 → **不许超卖**
+2. 按 `100/200/400ms` 退避重试 ≤3 次后的**最终成功率**(验证 §8.3 建议间隔是否够)
+3. `core_trade` 中 `convert_group_id` **无重复**;`core_share_lot.remain_qty` 之和 = 初始值 − 实际成交份额
+
+### 10.3 性能实测补录(PRD §9 第 18 条)
+- 端到端响应 **< 2s**(本地模拟库)
+- 阶段一单库事务实测耗时**补录真实值**(PRD 原文的「< 100ms」是**预估值、非验收硬指标**)
+- **若实测超阈值 → 优化索引/锁策略后重定阈值;不得反向修改实测数据迁就指标**
+
+**DoD**
+- [ ] 三项完成且**落点明确**:① 性能实测数据**回填 `docs/PRD/PRD-基金转换交易.md` §9 第 18 条**(用实测值替换「预估 < 100ms」);
+ ② 50 并发结论(是否超卖 / 退避间隔够不够)写入 `docs/交接文档-基金转换.md`;③ 若实测超阈值 → 优化索引/锁策略后**重定阈值**(**不得反向修改实测数据迁就指标**)
+- [ ] 50 并发结果写入交接文档(含退避间隔是否够用的结论)
+
+---
+
+## 11. 测试增量与基线预测
+
+| 层 | 文件 | 覆盖点 | 预计 |
+| --- | --- | --- | --- |
+| 纯函数 | `test_convert_calc.py` | FIFO / 跨批计费 / 双口径 A·B / 舍入 `.5` 边界 / 分档边界(6/7/29/30/179/180/364/365)/ 双动作 / T+1 | ~35 |
+| 编排 | `test_convert_service.py` | 八步 / 三阶段 + 阶段 1.5 / 幂等命中 / 阶段二失败 / `nav_stale` 条数 | ~22 |
+| 并发 | `test_convert_concurrency.py` | 条件 UPDATE rowcount=0 → 409 / 同键并发 → 202 / 补跑加锁 / 50 并发 | ~10 |
+| 批次 | `test_share_lot.py` | 普通申赎批次维护 / 兜底补建 / `rebuild_lots` | ~12 |
+| 集成 | `test_convert_integration.py` | 真 MySQL 端到端,与 §5.3 逐项吻合 | ~6 |
+| 回归 | `test_trade_gateway.py` | 批次维护断言 + convert 走通改写 | +5 |
+| 门禁 | `test_db.py` | 列名/PK/表存在性 + 角色隔离 + 3 权限断言 | +6 |
+| 仓储 | `test_core_ro_sum.py` | convert 去重 | +1 |
+| **合计** | | | **+97**(含不进 CI 的压测 ~10 条)→ **516 → 613**;**扣除压测后 CI 内 ≈603**;落地区间 **591~616** |
+
+---
+
+## 12. 回归面清单(既有用例的逐条处置 · **本计划核心交付物之一**)
+
+> 下表每一条都是**必然会被本次改造打穿**的既有断言。不做处置 = 510 会红。
+> 核对方式:`grep` + 读源码(行号为撰写时实测值)。
+
+| # | 位置 | 现有断言 | 为何失效 | **处置** |
+| --- | --- | --- | --- | --- |
+| **R1** | `tests/test_chat_tools.py:81` | `INSERT INTO core_holding (customer_id, product_id, market_value, quantity)` | T-0 删 `quantity` 列 → `no such column`;**且新 DDL 的 `cost_amount`/`pnl_pct`/`as_of` 为 NOT NULL** | 改为 `... market_value, qty, cost_amount, pnl_pct, as_of)` 并补 3 个 NOT NULL 值;`market_value`/`qty` 数值不变 |
+| **R2** | `tests/test_concentration_c4.py:74` | 同上 | 同上 | 同上 |
+| **R3** | `tests/test_trade_gateway.py:92-99` `test_convert_rejected` | `pytest.raises(UnsupportedTradeType, match="转换交易暂不支持")` | T-9 **移除**了 convert 拒绝 | **拆分**:① 未知类型 `purchase` → 仍 400(保留该半条断言)② convert → 改由新用例覆盖走通路径;`:98` 的 `core_trade == 0` 断言随 ① 保留 |
+| **R4** | `tests/test_trade_gateway.py:215-219` `test_api_convert_returns_400` | convert → 400 + `BAD_REQUEST` | 同上 | **改写为** `test_api_convert_returns_200`(需真 MySQL 或 sqlite 造齐产品/持仓/费率/净值数据)→ 若成本高,**降级为集成测试用例**(`test_convert_integration.py`)并在单测层删除该条 |
+| **R5** | `tests/test_trade_gateway.py:261-263` `test_api_non_positive_amount_returns_422` | `amount=0` → 422 | `amount` 变 `Optional`(**若 `gt` 被顺手删掉**才会失效) | **保住**:`amount: Decimal \| None = Field(None, gt=0)`。**已实测**(pydantic 2.13.4):`0`/`-1` → `greater_than` 校验失败(422);`None` → 放行;`100` → 正常 → **零改动通过**。**这是 `gt` 必须保留的理由** |
+| **R6** | `tests/test_trade_gateway.py:40-72` `env` fixture | 只灌 customer/customer_risk/product,**无 holding、无 nav** | T-10 后 subscribe 建批次需净值、redeem 兜底补建需持仓 | 按 **R-c** 降级(取不到净值 → warning 不建批次);**不改该 fixture** |
+| **R7** | `tests/test_trade_gateway.py` 全文件 | — | T-10 改 `trade_gateway` 主流程 | 补批次维护断言(+5);**T-10 单独结项、先跑基线** |
+| **R8** | `tests/test_db.py:30-54` 3 条既有用例 | `get_engine(database)` 缓存单例 + dispose | T-0b 缓存键变 `(db, role)` | `len(created) == 2` 等断言**仍成立**(不同 db → 不同 engine);**追加** 2 条新用例(角色隔离 / 回退),**不覆盖**既有 3 条 |
+| **R9** | `tests/test_main.py:58-74` `test_all_routers_mounted` | 硬编码路由清单 | **不新增路由**(convert 复用 `/api/simulate/trade`) | **零改动**(需在评审中显式声明,防被误判为漏项) |
+| **R10** | `tests/test_core_ro_sum.py` 5 条断言 | `sum_trades_on_date` SQL 求和 | R-d 加了 `convert_group_id` 条件 | 既有数据无 gid → **恒等通过**;追加 1 条去重用例 |
+| **R11** | `tests/conftest.py:183/184/232/233` | `get_engine(db)` 默认角色 | T-0b 后 core 默认角色 = `xh_core_rw`(**无 DELETE**)→ teardown 失败 | 显式 `role="admin"`(**R-e**) |
+| **R12** | `app/service/risk/rules.py:78-83` `_eligible` | `trade_type in ('subscribe','redeem')` | convert 两条流水若是 `convert` 类型 → 被过滤 | **不改 `_eligible`**;改为**约束写入端**(R-b:流水写 `redeem`/`subscribe`) |
+| **R13** | `app/repository/core_ro.py:390/432` | `trade_type IN ('subscribe','redeem')` 硬写 | —(不需改:convert 两条流水本就是这两类) | **不改**(`:432` 的求和在 R-d 中另加 gid 条件) |
+| **R14** | `app/main.py:88/94` 中间件顺序 | audit 先注册 / trace 后注册(T-202 守卫) | 本次不动 `main.py` | **零改动**(若因 T-0b 误改 `main.py`,`test_main.py` 的守卫用例会红 → 属自发现) |
+| **R15** | `tests/test_integration_risk.py:426-437` `test_convert_400_and_no_new_trade_audit` | convert → **400** + `BAD_REQUEST` + **不落审计**(`after["n"] == before["n"]`) | T-9 移除 convert 拒绝后,**状态码与审计计数双重失败** | **改写为端到端走通用例**(`convert → 200` + 断言落一条 `trade_accepted` 审计),或**整体迁入** `test_convert_integration.py` 后从本文件删除。
※ 本条为**第一轮审核补入**(初版 §12 漏列该真 MySQL 集成用例) |
+| **R16** | `tests/test_trade_gateway.py:145-154` `test_redeem_accepted_without_alert` | redeem 1000 元放行、`core_trade` 计 1 条、无预警 | T-10 给 redeem 加 FIFO 扣减后,`env` **无持仓无批次**(F-13)→ 无 R-c(1) 降级则直接失败 | 由 **R-c(1)「redeem 既无批次也无持仓 → warning 跳过扣减」** 保住,**该用例零改动**;其**真实扣减路径**由 `test_share_lot.py` 自建种子覆盖(R-c(2))
※ 本条为**第一轮审核补入** |
+
+---
+
+## 13. 验收清单(PRD §9 19 条 → 任务/用例映射)
+
+| # | 验收条目 | 承载任务 | 用例/证据 |
+| --- | --- | --- | --- |
+| 1 | pytest 全绿(516 + 新增) | T-13 | `pytest -q` ≥591(CI 内约 603) |
+| 2 | 两条流水同 `convert_group_id`、**同事务** | T-6 | 回滚路径用例(断言无残留) |
+| 3 | 折算金额与 §2.1 逐项吻合 | T-2/T-2b/T-6 | `test_convert_calc.py` + `calc_convert_demo.py` 输出比对 |
+| 4 | 转入端适当性不匹配 → blocked,两条流水都不落库 | T-7 | 八步顺序用例(blocked 不占位) |
+| 5 | RISK-002 对转换**只计一次** | T-8 | `_amount_view` 用例 |
+| 6 | RISK-001/003 仍看到两条(**未删行**) | T-8 | `eligible` 全量用例 |
+| 7 | 一次转换只产生**一条**预警事件 | T-8 | `process_convert_event` 用例(`payload.events` 长度 2,单数 1) |
+| 8 | 跨批次:各批按各自持有期计费,明细落库 | T-2/T-6 | 跨批用例 + `core_convert_lot_detail` 行数断言 |
+| 9 | 普通赎回后再 convert,批次与持仓一致、**不超扣** | **T-10** | `test_share_lot.py` + 端到端 |
+| 10 | 转换后 `core_holding` 两端已更新 | T-6 | 两端持仓断言 |
+| 11 | `core_tools` 汇总不翻倍 | T-11 | 工具汇总用例 |
+| 12 | 同 `client_request_id` 重复提交**不产生第二组流水** | T-7 | 幂等用例 |
+| 13 | 强制全转 `actual_qty != requested_qty`,响应与审计均记录 | T-2/T-7 | 双动作用例 |
+| 14 | `CROSS_ENTITY_NOT_SUPPORTED` 有用例覆盖 | T-9 | 跨机构用例(依赖 `09-seed-org.sql`) |
+| 15 | 幂等窗口闭合:阶段二失败后同键重试**不翻倍** | T-7 | 阶段二失败补偿用例 |
+| 16 | 全额转出豁免(≥ `min_redeem_qty` 校验豁免) | T-2/T-7 | 豁免用例 |
+| 17 | 阶段二失败可补偿(仅凭 Core 侧数据) | T-12 | `rebuild_alerts --convert-group` |
+| 18 | 性能:端到端 < 2s;阶段一实测**补录** | T-13 | 实测数据回填 PRD |
+| 19 | 补差费非零场景有覆盖 + 同费率对照 | T-2/T-1 | 双口径用例(依赖 `09-seed-org.sql` 的费率对) |
+
+---
+
+## 14. 风险登记与门禁总则
+
+沿架构 §12 的 10 条风险,**每条的「验收点」已前移到具体任务的 DoD**:
+
+| # | 风险 | 门禁落点(本计划) |
+| --- | --- | --- |
+| 1 | sqlite/MySQL 列名失配(**阻断**) | **T-0**:`_ddl.py` 自校验 + `test_db.py::test_core_holding_columns`;未绿不得启动 T-1 |
+| 2 | 批次补建规则写两处 → 漂移 | **T-10**:`lot_bootstrap.bootstrap_lots` 单点 + 两侧一致性断言 |
+| 3 | `Decimal` 默认 HALF_EVEN 与四舍五入不符 | **T-2**:所有量化处显式 `ROUND_HALF_UP` + `.5` 边界断言 |
+| 4 | 阶段 1.5 引擎异常不丢预警 | **T-8**:`on_error_hook` 存在且一期 `None` + **hook 抛异常不反噬**单测 |
+| 5 | `TOO_MANY_LOTS` 超限场景 | **T-2/T-9**:`PlanResult.batch_count` 先规划后判限 + 契约含 `max_lots` + 文案 |
+| 6 | 批次表改造打穿 510 | **T-10**:开工前基线快照 + 单独结项 + **五道风险控制**(第五道 = 覆盖率补偿,见 §8) |
+| 7 | 双库不一致 | **T-6/T-7/T-12**:回滚断言 + `convert_detail_write_failed` + 补偿脚本可跑通 |
+| 8 | 锁 TTL 30s 被阶段一超时突破 | **T-6/T-7**:抢到锁后**二次校验**占位与 Core 流水 |
+| 9 | 合并前引入 DDL | **T-1**:DDL 全追加式(新表 + 可空列 + DEFAULT);老 INSERT 仍可跑 |
+| 10 | Core「只读」仅代码约定(**阻断**) | **T-0b**:3 条真 MySQL 权限断言;账号未配置回退(不阻塞开发) |
+
+**本计划新增的实现级风险(架构 §12 未列,两轮审核产出)**
+
+| # | 风险 | 应对 | 验收点 |
+| --- | --- | --- | --- |
+| **R-a1** | **并发首次建行撞 `UNIQUE(customer_id, product_id)`**:执行权锁是**请求粒度**(`convert:idem:{cid_req}`),同一 `(cid, pid)`、不同 `cid_req` 的两笔并发转换会双双进入阶段一,首次建行时并发 `INSERT` 撞唯一键 → `IntegrityError` → 事务回滚返回 5xx(**非数据损坏**) | R-a 第 ③ 步「`IntegrityError` 回退 UPDATE」(方言无关,已实测同事务内可继续执行) | **T-6 DoD**:「并发首次建行」用例 → 断言最终**只有一行**、金额为两笔之和、**两笔都成功**(无 5xx) |
+| **R-a2** | **`core_holding` 写入若误用 `ON DUPLICATE KEY UPDATE` / `WHERE NOT EXISTS` / `FROM DUAL`**,会在 sqlite 或 MySQL 单侧语法错(方言互斥) | §1.5 R-a 已列三种候选写法的实测对照表 | T-6「必须守住」已写死必须走 R-a 三步;sqlite 单测天然覆盖 |
+
+> **门禁总则**:#1 与 #10 是**硬门禁**(T-0 / T-0b 双双绿才放行 T-1);#2~#5 是**开发中持续守**;
+> 任意一条失守都在**集成测试期才暴露**(成本最高),故已前移到各任务 DoD。
+
+---
+
+## 15. 待确认
+
+### 15.1 需要用户拍板
+
+| # | 事项 | 本计划默认 | 若改判的影响 |
+| --- | --- | --- | --- |
+| **Q-a** | **R-c**:批次维护的「降级规则 + 覆盖率补偿」双条方案(第一轮审核后修订) | **接受双条**(推荐) | 若只接受降级(不接受 (2) 补偿),则**批次维护在单测层零覆盖**,验收第 9 条失效;若两条都不接受,则须给 `test_trade_gateway.py` 等多个 fixture 补灌持仓与净值种子 → T-10 改动面从 1 个文件扩到 3+ 个测试文件 |
+| **Q-b** | **R-f**:`core_product_nav` 并入 T-0 | **并入**(推荐) | 若移回 T-1,T-0 的 DoD 缩小;T-1 工作量增加 |
+| **Q-c** | **R-d**:`sum_trades_on_date` 是否顺带修 | **修**(推荐) | 若不修,需在 docstring 标注「不适用 convert」,接受同类方法在二期再修的债务 |
+
+### 15.2 不需要拍板(架构/PRD 已定,仅登记)
+
+- 补差费默认口径 = B(P4)· 最低持有双动作(P5)· 份额 2 位 HALF_UP(P7)· T+1 建模(P8)
+- 单笔 = 单事务 = 单 `convert_group_id`,超限不自动分拆(原则 12 / R4)
+- 补偿一期人工 + SLA 24h,`pending` 超时置 `expired`(S2)
+
+---
+
+## 16. 给审核 AI 的检查清单
+
+> 审核要求:**全新上下文、只报告不改码**;按「**阻断级 / 重要 / 可选**」分档。
+> 背景包见随本计划一并提供的代码事实附录(§1.4 的全部行号可直接核对)。
+
+**A. 事实核验(最重要 —— 请优先挑战这部分)**
+
+1. §1.4 的 15 条事实是否与代码一致?**尤其** F-7(UPSERT 方言)、F-8(流水 `trade_type` 必须是 redeem/subscribe)、F-9(`sum_trades_on_date` 双计)、F-11(`get_engine` 调用点,批 0 前 13 处)、F-13(`env` fixture 无持仓/净值)。
+2. §12 的 **16** 条回归面是否**完整**?有没有被我漏掉的既有断言会被打穿?(请用 `grep` 自查 `UnsupportedTradeType` / `convert` / `core_holding` / `amount` 等关键词)
+3. §13 的 19 条验收是否**条条有承载任务**?有没有验收条目实际无实现落点?
+
+**B. 裁定合理性**
+
+4. §1.5 的 7 条实现级裁定(R-a~R-g)是否成立?**哪一条你认为是错的或代价过高的?**
+5. §1.3 的 2 条文档差异裁定(W-1/W-2)以架构为准,是否同意?
+
+**C. 依赖与排序**
+
+6. §2 的批间依赖是否成立?T-2 声称「不依赖表结构,可与 T-1 并行」是否属实?
+7. T-10 排期置于 T-7 之后、T-13 之前,是否最优?(它是否其实应该更早做,以免 T-7 的批次逻辑建立在未维护的批次表上?)
+
+**D. 遗漏面**
+
+8. 除本计划列出的文件外,还有哪些**读取方**会因 convert 或其他改动而语义变化?(自检第 2 问「谁读」)
+9. 有没有任务在「改完 A 之后,B 必须同步改」的隐形耦合,而本计划把它排成串行且顺序错了?
+10. 一期不做的项(自动分拆 / 撤销 / 巨额赎回比例 / 二期补偿自动化)是否被**意外卷入**本期范围?
+
+**E. 可执行性**
+
+11. 每个任务的 DoD 是否**可机械验证**(有明确命令或断言)?有没有「模糊 DoD」(如"确认正确")?
+
+---
+
+## 附:本计划引用的关键文件与行号索引
+
+| 文件 | 相关行 | 用途 |
+| --- | --- | --- |
+| `tests/_ddl.py` | `:15-138`(表字典)、`:50-54`(core_holding)、`:55-60`(core_trade)、`:141-149`(建库函数) | T-0 主战场 |
+| `tests/test_db.py` | `:30-54`(既有 3 用例) | T-0/T-0b 追加用例 |
+| `tests/test_chat_tools.py` | `:81` | R1 待改 |
+| `tests/test_concentration_c4.py` | `:74` | R2 待改 |
+| `tests/test_trade_gateway.py` | `:40-72`(env)、`:92-99`、`:145-154`、`:215-219`、`:261-263` | R3~R7 / R16 待改 |
+| `tests/test_integration_risk.py` | `:426-437`(convert 400 集成用例) | R15 待改 |
+| `tests/test_main.py` | `:58-74`(路由清单) | R9 零改动 |
+| `tests/test_core_ro_sum.py` | `:53/61/66/73/74` | R10 恒等通过 |
+| `tests/conftest.py` | `:181`(bootstrap 链)、`:183/184/232/233`(engine)、`:216-223`(清理) | R11 / R-e |
+| `app/gateway/trade_gateway.py` | `:36-37`(白名单/文案)、`:105`(校验读)、`:112-113`(convert 拒绝)、`:167-169`(insert_trade 调用) | T-9 / T-10 |
+| `app/gateway/gateway_repository.py` | `:24`(engine)、`:26-57`(insert_trade 7 列) | T-0b / T-9 |
+| `app/repository/core_ro.py` | `:55`(engine)、`:276-298`(list_holdings `SELECT h.*`)、`:300-340`(concentration)、`:378-404`(list_trades_range)、`:420-442`(sum_trades_on_date)、`:450-460`(get_latest_nav) | T-3 / T-11 |
+| `app/service/risk/rules.py` | `:78-83`(`_eligible`)、`:86-88`(`_amount`)、`:102-105`(RISK-002)、`:201-219`(run_rules) | T-8 |
+| `app/service/risk/engine.py` | `:114-192`(process_trade_event) | T-8 |
+| `app/service/risk/alert_service.py` | `:96-180`(record_trade_alerts)、`:180`(`agg:event:` 锁) | T-8 |
+| `app/service/risk/locks.py` | `:24`(TTL)、`:69+`(run_locked) | T-5 |
+| `app/api/simulate.py` | `:36-42`(TradeRequest)、`:45-60`(路由) | T-9 |
+| `app/utils/db.py` | `:19-49`(引擎缓存) | T-0b |
+| `app/config/settings.py` | `:13-18`(mysql_*) | T-0b |
+| `scripts/core/01-ddl.sql` | core_holding / core_trade / core_product / core_product_nav 段 | T-1 权威 |
+| `scripts/core/reset.ps1` | `:24-31`(`$files`) | T-1 |
+| `docs/项目框架设计/表设计/02-mysql-agent专用.sql` | 6 张表 | T-1 落点 |
diff --git a/docs/项目框架设计/开发计划-架构改进.md b/docs/项目框架设计/开发计划-架构改进.md
index af58c72..9b32c35 100644
--- a/docs/项目框架设计/开发计划-架构改进.md
+++ b/docs/项目框架设计/开发计划-架构改进.md
@@ -348,10 +348,18 @@ def test_audit_middleware_runs_inside_trace_middleware(client, captured_audit):
### 5.3 C1 · `core_ro` DB 级只读账号
+> **状态(2026-09-10)**:✅ **已由基金转换线接手定案,并扩展为 D20**(账号矩阵 `xh_core_ro`/`xh_core_rw`/`xh_agent_rw` +
+> `get_engine(db, role)` + 3 个权限断言 + **T-0b 阻断前置**)。落地清单见
+> `docs/项目框架设计/架构设计-基金转换交易.md` **§11.1**。**本节原始评估保留备查,勿重复设计。**
+
需运维配合。注意:`core_ro.py:55` 与 `gateway_repository.py:24` **共用 `get_engine(settings.mysql_core_database)`**,要拆成两个连接串(只读账号 + 可写账号)。
### 5.4 F1 · 支持 `convert`
+> **状态(2026-09-10)**:✅ **已立项为独立线**(PRD v0.9 + 架构 v1.0 定稿,**代码未动**),开工入口 `docs/交接文档-基金转换.md`。
+> 本节指出的**两处硬编码正是该线 D7 `_amount_view` 要解决的问题**(`core_ro.py:390`/`:432` 的 `trade_type` 白名单,
+> 不改则 convert 在 RISK-001/002/003 聚合中隐形且不报错)。本线不再跟进,本节保留备查。
+
**除网关外还有两处硬编码**(最易漏):`core_ro.py:390`(`list_trades_range`)与 `:432`(`sum_trades_on_date`)写死 `AND trade_type IN ('subscribe','redeem')`。不改的话 convert 在 RISK-001/002/003 聚合中**完全隐形且不报错**。
**前置**:产品需明确三条口径(校验对象、是否计入当日累计、RISK-003 算几笔),且属接口协议变更。
diff --git a/docs/项目框架设计/架构设计-基金转换交易.md b/docs/项目框架设计/架构设计-基金转换交易.md
index 5ad43a3..5a327b1 100644
--- a/docs/项目框架设计/架构设计-基金转换交易.md
+++ b/docs/项目框架设计/架构设计-基金转换交易.md
@@ -402,8 +402,17 @@ def diff_fee(conv_amount: Decimal, out_rate: Decimal, in_rate: Decimal,
mode="rate_diff" (口径 A):conv*max(in−out,0)/(1+max(in−out,0))
结果 2 位 ROUND_HALF_UP。"""
def in_qty(in_amount: Decimal, in_nav: Decimal) -> Decimal: # 2 位 ROUND_HALF_UP(v1.0 修正)
+def hold_days(trade_date: date, confirmed_at: datetime) -> int: # (交易日 − 确认日).days,不含申请日
+def rounding_diff(in_amount: Decimal, in_nav: Decimal, actual_in_qty: Decimal,
+ places: int = 4) -> Decimal: # 理论份额 − 实得份额(响应字段)
+def ensure_batch_limit(plan: PlanResult, max_lots: int) -> None: # 批次数超限 → TooManyLots(§8.3)
```
+> **T-2 落地补充(2026-09-10)**:末三行签名由 T-2 执行期补入,`hold_days` 是
+> `pick_fee_rate` 的入参来源(T+1 起算、满 7 日归 7–30 档都靠它),`rounding_diff`
+> 对应 PRD §5.3 响应字段,`ensure_batch_limit` 把「先规划再判上限」固定成一步。
+> 三者均在 `calc.py`,纯函数、零 IO,已在 `tests/test_convert_calc.py` 覆盖。
+
> ⚠️ **v1.0 勘误**:v0.2 此处写的是 `4 位 ROUND_FLOOR`,与 §1 原则 10「份额 2 位 ROUND_HALF_UP」**直接矛盾**,
> 属 v0.7 残留。PRD v0.8 已定 **2 位四舍五入**,v1.0 一并修正。示例数字由 `scripts/dev/calc_convert_demo.py` 实算回填:
> `in_qty = 53456.95`(口径 B)/ `53455.36`(口径 A)。
@@ -508,6 +517,14 @@ class TradeRequest(BaseModel):
异常类定义在 `app/utils/exceptions.py`(复用现有 `ApiError`),convert 专属异常放 `service/convert/errors.py`。
+> **T-2 落地补充(2026-09-10)**:`errors.py` 实际实现 **11 个**异常 —— 上表 10 条
+> 全部落地(含 `LotConflict` / `IdempotencyUnavailable` 两个机制类),**另加 1 个上表之外的
+> 内部兜底**:`FeeRuleMissing`(500 / `FEE_RULE_MISSING`),仅在 `core_fee_rule` 缺少
+> `[0, 7)` 档(种子漏灌)时触发。**它不是业务错误体契约的一部分**,列在这里只为
+> 「触发条件可查」:`pick_fee_rate` 无命中时**绝不返回 0 费率**——静默按 0 计费会少收
+> 赎回费且不留痕,比直接失败危险得多。(开发计划 §4.2 原文写「8 个」,为该文档的计数笔误,
+> 以本表为准;差异已在开发计划执行记录中留痕。)
+
---
## 9. 数据模型落点清单(DDL 与种子)
@@ -589,8 +606,8 @@ teardown 按前缀清理 `core_trade`/`core_share_lot`/`core_convert_lot_detail`
| --- | --- | --- | --- |
| `xh_core_ro` | `jinrong_core` | `SELECT`(全库) | `core_ro` · `core_tools` · `deps` 归属校验 · `risk` 扫描 · **`trade_gateway` 的校验读** |
| `xh_core_rw` | `jinrong_core` | `SELECT/INSERT/UPDATE`,**限 4 表**:`core_trade` / `core_share_lot` / `core_holding` / `core_convert_lot_detail`;**无 DELETE、无 DDL、无 GRANT** | **仅 `app/gateway/` 的写路径**(`gateway_repository.insert_trade` · `convert_core_repository.apply_convert`) |
-| `xh_agent_rw` | `jinrong_agent` | `SELECT/INSERT/UPDATE` 业务表;**`audit_log` 只授 `INSERT`** | Agent 侧全部(`risk_repository` / `session_repository` / `convert_repository`) |
-| `root`(管理员) | 两库 | 全权 | **仅** `00-grant.sql` / `01-ddl.sql` / `reset.ps1`,**应用运行时不持有** |
+| `xh_agent_rw` | `jinrong_agent` | `SELECT/INSERT/UPDATE` 业务表;**`audit_log` 只授 `SELECT, INSERT`**(无 `UPDATE`/`DELETE` —— 只能追加、不可改删) | Agent 侧全部(`risk_repository` / `session_repository` / `convert_repository`) |
+| `root`(管理员) | 两库 | 全权 | **仅** `00-grant.sql` / `01-ddl.sql` / `reset.ps1`,以及 **`tests/conftest.py` 真 MySQL 集成测试的 setup/teardown**(teardown 需 DELETE,见下方修正);**应用运行时不持有** |
> ⚠️ **关键边界**:**gateway 的读走 `ro`、写走 `rw`**,两个 engine 并存 ——
> `trade_gateway.py:105` 现有的 `core_ro or CoreReadOnlyRepository()`(校验读)**保持只读账号不变**,
@@ -613,8 +630,13 @@ def get_engine(database: str, role: str = "rw") -> Engine:
对应角色账号未配置 → 回退 settings.mysql_user(行为与现状一致)。"""
```
-> `dispose_engines()` 语义不变(清空全部缓存);`tests/conftest.py:183-233` 的两处调用
-> 保持单参数默认值即可,**现有 510 用例零改动**。
+> `dispose_engines()` 语义不变(清空全部缓存)。
+>
+> ⚠️ **修正(T-0b 实施时发现)**:`tests/conftest.py` 的四处调用**不能**沿用默认角色 ——
+> teardown 要 `DELETE FROM core_trade / risk_alert / audit_log`,而 `xh_core_rw` 与
+> `xh_agent_rw` **均无 DELETE 权限**,沿用默认角色会让真 MySQL 集成测试在 setup/teardown 失权。
+> 故四处**显式 `role="admin"`**(对应开发计划 R-e);`app/` 内业务调用一律不用 admin。
+> sqlite 单测路径无账号概念,默认配置下现有用例仍零改动。
**验收(并入 T-0b,真 MySQL 才跑)**
@@ -627,6 +649,20 @@ def get_engine(database: str, role: str = "rw") -> Engine:
> **sqlite 测试路径零影响**:`tests/conftest.py` 走 sqlite + `tests/_ddl.py`,**无账号概念**;
> 账号分离只在真 MySQL(集成测试 / 生产)生效,不改变任何现有用例的连接方式。
+**实施记录(T-0b · 2026-09-10 完成)**
+
+- `scripts/core/00-grant.sql` 已落地:3 账号 + 逐表授权(agent 库 17 张业务表 + `risk_convert_detail`),
+ **不进 `reset.ps1`**(`DROP DATABASE` 不清 `mysql.db` 授权行,授权一次即可)。
+- **两处口径修正(实施时发现,已同步 `00-grant.sql` 注释)**:
+ 1. `audit_log` 实际授权为 **`SELECT, INSERT`** —— 原文「只授 INSERT」若**字面执行**会一并剥夺
+ `SELECT`,使 `risk_repository.has_engine_error_audit`(`:231`)与 `list_audit_events`(`:420`)
+ 双双失败;红线本意是「只能追加、不可改删」,故正确授权含 SELECT。
+ 2. `tests/conftest.py` 四处改 `role="admin"`(见上方修正框,对应开发计划 R-e)。
+- **不授 DELETE 的依据**:`app/` 与 `scripts/` 全仓**无任何 `DELETE` / `TRUNCATE` / `DROP`**(已 grep 核实),
+ 故 `xh_core_rw` / `xh_agent_rw` 去掉 DELETE 不影响任何现有代码路径。
+- 3 条权限断言用例已写入 `tests/test_db.py`,**账号未配置或真库不可达时自动 skip**;
+ 默认配置下全量测试保持全绿(本任务完成时 **516 passed / 3 skipped**,基线 510)。
+
---
## 12. 风险与应对
@@ -707,7 +743,7 @@ def get_engine(database: str, role: str = "rw") -> Engine:
| 2 | **谁读**? | `core_ro`(批次/净值/费率/持仓)· `rules._amount_view`(消费 `convert_group_id`)· `core_tools.query_recent_trades`(汇总去重)· `rebuild_alerts.py`(补偿)· `cleanup_pending_convert.py`(超时置 `expired`) |
| 3 | **枚举/常量 DDL**? | `risk_convert_detail.status` **ENUM 5 值**(`pending`/`completed`/`failed`/`cancelled`/**`expired`**,建表即全量、零 ALTER,S2)· `core_trade.trade_type` ENUM **已含 convert** · `audit_log.decision` **VARCHAR(64)**,`nav_stale`/`convert_detail_write_failed`/`engine_error` 加值不改 DDL |
| 4 | **事务跨库**? | 跨 → 三阶段 + 阶段 1.5(不参与事务)+ 补偿(一期手动、SLA 24h) |
-| 5 | **种子数据**? | 07/08/09 三新种子(**文件头带版本注释块**);`subscribe_fee_rate` 造出**合规上限内**的不同费率对(债基 0.0030 vs 主动偏股 0.0080),否则补差费恒 0;赎回费种子覆盖 5 档且 7–30 日档 ≥1% |
+| 5 | **种子数据**? | 07/08/09 三新种子(**文件头带版本注释块**);`subscribe_fee_rate` **严格按 `product_type` 取档**(`stock` 0.0080 / `mixed` 0.0050 / `bond`·`index` 0.0030 / `money` 0)造出**合规上限内且同主体可互转**的不同费率对(`PROD-110022` 债基 0.0030 → `PROD-003095` 主动偏股 0.0080,同属华夏模拟基金/TA-CN-001),否则补差费恒 0;赎回费种子覆盖 5 档且 7–30 日档 ≥1% |
| 6 | **并发安全**? | 批次条件 UPDATE + rowcount 校验 · 执行权 `try_lock` · `uk_idem` 兜底 · 补跑阶段二加锁 |
| 7 | **汇总语义唯一**? | 响应不返回汇总 `hold_days` · `_amount_view` 组内取转出端 · `out_amount`/`convert_amount`/`in_amount` 三者语义分立(PRD §5.3) |
| 8 | **向后兼容**? | `core_trade` 加可空列 + 索引 · `core_product` 8 列均有 DEFAULT · `get_latest_nav` 不动改新增 · `record_trade_alerts` 新增可选参数缺省退化 · **`get_engine(db, role="rw")` 新参数带默认值、角色账号未配置回退 `mysql_user`**(D20,现有调用点零改动) |
@@ -720,10 +756,10 @@ def get_engine(database: str, role: str = "rw") -> Engine:
| 任务 | 内容 | 依赖 |
| --- | --- | --- |
-| **T-0** | **列名统一 + 启动断言(评审 R1 · 阻断前置)**:以 MySQL 为准重写 `tests/_ddl.py` 的 `core_holding`(`qty`/`cost_amount`/`as_of`/`pnl_pct` + PK),`conftest.py` 加启动期列名断言,新增 **`tests/test_db.py::test_core_holding_columns`** | 无 |
-| **T-0b** | **DB 账号分离(D20 · 主架构 C1 · 阻断前置)**:`scripts/core/00-grant.sql` 建 3 账号授权;`settings.py` 加 3 组账号;`db.py` 改 `get_engine(db, role)`;`core_ro` 走 `ro`、gateway 写路径走 `rw`(读仍 `ro`);新增 3 个权限断言用例(§11.1) | 无(可与 T-0 并行) |
+| **T-0** ✅ | **列名统一 + 启动断言(评审 R1 · 阻断前置)**:以 MySQL 为准重写 `tests/_ddl.py` 的 `core_holding`(`qty`/`cost_amount`/`as_of`/`pnl_pct` + PK)+ 新增 `core_product_nav` + 建库自校验 `_assert_ddl_aligned()`,新增 **`tests/test_db.py::test_core_holding_columns`**。**2026-09-10 完成**(另发现:2 处测试 INSERT 还需补 3 个 NOT NULL 列) | 无 |
+| **T-0b** ✅ | **DB 账号分离(D20 · 主架构 C1 · 阻断前置)**:`scripts/core/00-grant.sql` 建 3 账号授权;`settings.py` 加 3 组账号;`db.py` 改 `get_engine(db, role)`;`core_ro` 走 `ro`、gateway 写路径走 `rw`(读仍 `ro`);新增 3 个权限断言用例(§11.1)。**2026-09-10 完成**(`audit_log` 授权修正为 `SELECT, INSERT`;conftest 四处改 `role="admin"`) | 无(可与 T-0 并行) |
| T-1 | DDL + 种子(**00-grant** / 01-ddl / 07 / 08 / 09 / 02-agent)+ reset.ps1 | **T-0 + T-0b(门禁绿)** |
-| T-2 | `service/convert/` 纯函数:`types.py` + `calc.py` + `fee.py` + `nav.py` + `lot_bootstrap.py`(D18) | 无 |
+| T-2 | `service/convert/` 纯函数:`types.py` + `calc.py` + `fee.py` + `nav.py` + `lot_bootstrap.py` + `errors.py`(D18) | 无 | ✅ **2026-09-10 完成**(7 文件 / 93 用例 / 全量 609 绿;T-2b 实算脚本 15/15 一致) |
| T-2b | `scripts/dev/calc_convert_demo.py` 实算主示例 + 同费率对照,**回填 PRD §5.3 与验收断言** | T-2 |
| T-3 | `core_ro` 五个新方法 + `share_lot_repository` | T-1 |
| T-4 | `convert_repository`(agent 侧占位/回写/查询/清理) | T-1 |
diff --git a/docs/项目框架设计/演示SOP-风控模块.md b/docs/项目框架设计/演示SOP-风控模块.md
index d70f053..faab80e 100644
--- a/docs/项目框架设计/演示SOP-风控模块.md
+++ b/docs/项目框架设计/演示SOP-风控模块.md
@@ -25,7 +25,9 @@ MYSQL="C:/Program Files/MySQL/MySQL Server 8.0/bin/mysql.exe" # mysql.exe 不
"$MYSQL" -h 127.0.0.1 -u root -e "DROP DATABASE IF EXISTS jinrong_core; DROP DATABASE IF EXISTS jinrong_agent;"
for f in scripts/core/00-create-database.sql scripts/core/01-ddl.sql scripts/core/02-seed-base.sql \
scripts/core/03-seed-customers.sql scripts/core/04-seed-holdings.sql scripts/core/05-seed-trades.sql \
- scripts/core/06-seed-nav.sql "docs/项目框架设计/表设计/01-mysql-共用底座.sql" \
+ scripts/core/06-seed-nav.sql scripts/core/07-seed-fee-rule.sql \
+ scripts/core/08-seed-share-lot.sql scripts/core/09-seed-org.sql \
+ "docs/项目框架设计/表设计/01-mysql-共用底座.sql" \
"docs/项目框架设计/表设计/02-mysql-agent专用.sql" scripts/agent/seed-aml-list.sql \
scripts/demo/prepare_risk_demo.sql; do
"$MYSQL" -h 127.0.0.1 -u root --default-character-set=utf8mb4 < "$f"
diff --git a/docs/项目框架设计/表设计/02-mysql-agent专用.sql b/docs/项目框架设计/表设计/02-mysql-agent专用.sql
index 9972839..6f0912a 100644
--- a/docs/项目框架设计/表设计/02-mysql-agent专用.sql
+++ b/docs/项目框架设计/表设计/02-mysql-agent专用.sql
@@ -106,3 +106,38 @@ CREATE TABLE risk_aml_list (
KEY idx_name (full_name),
KEY idx_active (is_active)
) ENGINE=InnoDB COMMENT='【风控专用】AML 名单本地镜像 · 依据 docs/PRD/PRD-风控监测Agent.md §6.2';
+
+-- ============================================================================
+-- 基金转换详情(Agent 侧)· T-1 新增(2026-09-10)
+-- 依据:docs/PRD/PRD-基金转换交易.md §4.1 DDL 原文 · 架构设计-基金转换交易.md §9
+-- 说明:
+-- · `status` **建表即含全部 5 值**(pending / completed / failed / cancelled / expired),
+-- 二期撤单与占位超时清理**零 ALTER**(评审 S2 / L-5)。
+-- · `uk_idem(client_request_id)` 支撑 FR-C17 幂等;值为 NULL 时不触发唯一约束
+-- (MySQL 允许多个 NULL),故无幂等键的请求不受影响。
+-- · Core 侧的批次计费明细在 `jinrong_core.core_convert_lot_detail`(含 nav/nav_date),
+-- 本表只存汇总与流程状态;两库各自 engine,**无法单事务**,补偿路径见 PRD §7。
+-- ============================================================================
+CREATE TABLE risk_convert_detail (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ convert_group_id VARCHAR(64) NOT NULL,
+ status ENUM('pending','completed','failed','cancelled','expired') NOT NULL DEFAULT 'pending'
+ COMMENT 'pending=阶段一前占位;completed=两阶段均成;failed=阶段二失败待补偿;cancelled=二期撤单预留;expired=占位超时清理(S2,建表即含,免 ALTER)',
+ out_trade_id VARCHAR(64) NULL COMMENT '占位阶段为 NULL',
+ in_trade_id VARCHAR(64) NULL COMMENT '占位阶段为 NULL',
+ related_trade_id VARCHAR(64) NULL COMMENT '对端 trade_id',
+ nav DECIMAL(10,4) NULL COMMENT '成交净值',
+ nav_date DATE NULL,
+ fee_amount DECIMAL(18,2) NULL COMMENT '本笔费用',
+ hold_days_min INT NULL COMMENT '跨批次时最短持有天数',
+ hold_days_max INT NULL COMMENT '跨批次时最长持有天数',
+ estimated TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1=模拟折算 0=真实确认',
+ nav_stale TINYINT(1) NOT NULL DEFAULT 0,
+ client_request_id VARCHAR(64) NULL COMMENT '幂等键',
+ cancelled_at DATETIME(3) NULL COMMENT '二期撤单时间预留(一期不写入)',
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ UNIQUE KEY uk_group (convert_group_id),
+ UNIQUE KEY uk_idem (client_request_id),
+ KEY idx_out (out_trade_id),
+ KEY idx_in (in_trade_id)
+) ENGINE=InnoDB COMMENT='基金转换详情(Agent 侧)';
diff --git a/scripts/core/00-grant.sql b/scripts/core/00-grant.sql
new file mode 100644
index 0000000..cce26b4
--- /dev/null
+++ b/scripts/core/00-grant.sql
@@ -0,0 +1,93 @@
+-- ============================================================================
+-- 00-grant.sql · DB 账号与最小权限(D20 · 架构设计-基金转换交易 §11.1)
+-- ============================================================================
+-- 目的:把「Core 只读」「审计不可删改」从**代码约定**升级为 **DB 级强制**。
+-- 依据主架构评审 C1:原备选「代码层 SQL 白名单」已否决(注释/子查询/
+-- 存储过程易绕过),独立最小权限账号是唯一可靠手段。
+--
+-- 执行:管理员(root)执行,且**不进 reset.ps1**
+-- —— DROP DATABASE 不清除 mysql.db 中的授权行,本脚本只需执行一次;
+-- 且建账号需管理员权限,与 reset.ps1 的运行账号不是一回事。
+-- 命令:mysql -u root -p < scripts/core/00-grant.sql
+--
+-- ⚠️ 执行前:把三处 REPLACE_ME 换成 .env 中同名配置的真实密码。
+-- 本文件**禁止提交真实密码**;改完执行,执行完不要提交本次改动。
+-- 若 PyMySQL 报认证插件错误(MySQL 8.0+ caching_sha2_password),
+-- 把 IDENTIFIED BY 改为 IDENTIFIED WITH mysql_native_password BY。
+--
+-- 与 settings.py 的对应(账号留空 → db.get_engine 回退 mysql_user):
+-- xh_core_ro → mysql_core_ro_user / mysql_core_ro_password
+-- xh_core_rw → mysql_core_rw_user / mysql_core_rw_password
+-- xh_agent_rw → mysql_agent_user / mysql_agent_password
+-- ============================================================================
+
+-- ---------------------------------------------------------------------------
+-- 1) xh_core_ro · jinrong_core 全库只读
+-- 使用者:core_ro / core_tools / deps 归属校验 / risk 扫描,
+-- 以及 **gateway 的校验读**(架构 §11.1 关键边界)
+-- ---------------------------------------------------------------------------
+CREATE USER IF NOT EXISTS 'xh_core_ro'@'%' IDENTIFIED BY 'REPLACE_ME_core_ro';
+GRANT SELECT ON jinrong_core.* TO 'xh_core_ro'@'%';
+
+-- ---------------------------------------------------------------------------
+-- 2) xh_core_rw · 仅 gateway 写路径:限 4 表,无 DELETE / 无 DDL / 无 GRANT
+-- 使用者:gateway_repository.insert_trade · convert_core_repository.apply_convert
+--
+-- ⚠️ 关键边界:**gateway 读走 xh_core_ro、写走本账号**,两个 engine 并存。
+-- 本账号**刻意不授** core_product / core_fee_rule / core_product_nav 的读权限,
+-- 故 T-6 的 convert 计算链路读取产品/费率/净值**必须走只读账号**;
+-- 不得为图省事给本账号补 SELECT ——否则 gateway 顺带获得全库读权限,
+-- 「最小权限」落空(这是本任务最易走偏的一处)。
+--
+-- 表级 GRANT 可针对**尚不存在**的表:core_share_lot / core_convert_lot_detail
+-- 由 T-1 创建,故本脚本可合法地先于 T-1 执行。
+-- ---------------------------------------------------------------------------
+CREATE USER IF NOT EXISTS 'xh_core_rw'@'%' IDENTIFIED BY 'REPLACE_ME_core_rw';
+GRANT SELECT, INSERT, UPDATE ON jinrong_core.core_trade TO 'xh_core_rw'@'%';
+GRANT SELECT, INSERT, UPDATE ON jinrong_core.core_share_lot TO 'xh_core_rw'@'%';
+GRANT SELECT, INSERT, UPDATE ON jinrong_core.core_holding TO 'xh_core_rw'@'%';
+GRANT SELECT, INSERT, UPDATE ON jinrong_core.core_convert_lot_detail TO 'xh_core_rw'@'%';
+
+-- ---------------------------------------------------------------------------
+-- 3) xh_agent_rw · jinrong_agent 业务表读写(无 DELETE)
+-- 使用者:risk_repository / session_repository / convert_repository 等 Agent 侧全部
+--
+-- 不授 DELETE 的依据(T-0b 实测):app/ 与 scripts/ 全仓 **无任何
+-- DELETE / TRUNCATE / DROP**,故不授 DELETE 不影响任何现有代码路径。
+-- tests/conftest.py 的 teardown 需要 DELETE → 显式改用 role="admin"(R-e)。
+-- ---------------------------------------------------------------------------
+CREATE USER IF NOT EXISTS 'xh_agent_rw'@'%' IDENTIFIED BY 'REPLACE_ME_agent_rw';
+
+GRANT SELECT, INSERT, UPDATE ON jinrong_agent.agent_session TO 'xh_agent_rw'@'%';
+GRANT SELECT, INSERT, UPDATE ON jinrong_agent.agent_message TO 'xh_agent_rw'@'%';
+GRANT SELECT, INSERT, UPDATE ON jinrong_agent.agent_tool_call TO 'xh_agent_rw'@'%';
+GRANT SELECT, INSERT, UPDATE ON jinrong_agent.input_guard_log TO 'xh_agent_rw'@'%';
+GRANT SELECT, INSERT, UPDATE ON jinrong_agent.customer_advisor_rel TO 'xh_agent_rw'@'%';
+GRANT SELECT, INSERT, UPDATE ON jinrong_agent.customer_profile_l1 TO 'xh_agent_rw'@'%';
+GRANT SELECT, INSERT, UPDATE ON jinrong_agent.customer_profile_l2 TO 'xh_agent_rw'@'%';
+GRANT SELECT, INSERT, UPDATE ON jinrong_agent.customer_profile_l3 TO 'xh_agent_rw'@'%';
+GRANT SELECT, INSERT, UPDATE ON jinrong_agent.risk_alert TO 'xh_agent_rw'@'%';
+GRANT SELECT, INSERT, UPDATE ON jinrong_agent.risk_suitability_log TO 'xh_agent_rw'@'%';
+GRANT SELECT, INSERT, UPDATE ON jinrong_agent.customer_threshold_config TO 'xh_agent_rw'@'%';
+GRANT SELECT, INSERT, UPDATE ON jinrong_agent.customer_notify_log TO 'xh_agent_rw'@'%';
+GRANT SELECT, INSERT, UPDATE ON jinrong_agent.advisor_draft TO 'xh_agent_rw'@'%';
+GRANT SELECT, INSERT, UPDATE ON jinrong_agent.compliance_hit_log TO 'xh_agent_rw'@'%';
+GRANT SELECT, INSERT, UPDATE ON jinrong_agent.analytics_query_log TO 'xh_agent_rw'@'%';
+GRANT SELECT, INSERT, UPDATE ON jinrong_agent.risk_aml_list TO 'xh_agent_rw'@'%';
+-- risk_convert_detail 由 T-1 建表(本行可先于建表执行)
+GRANT SELECT, INSERT, UPDATE ON jinrong_agent.risk_convert_detail TO 'xh_agent_rw'@'%';
+
+-- 审计表:**追加写、不可改不可删**
+-- 口径澄清(T-0b 修正架构表述):架构 §11.1 原文写「audit_log 只授 INSERT」,
+-- 字面执行会连 SELECT 一并剥夺,导致 risk_repository.has_engine_error_audit
+-- (:231 SELECT 1 FROM audit_log)与 list_audit_events(:420 SELECT *)双双失败。
+-- 红线本意是「**只能追加,不能修改或删除**」→ 正确授权 = SELECT + INSERT。
+GRANT SELECT, INSERT ON jinrong_agent.audit_log TO 'xh_agent_rw'@'%';
+
+-- ---------------------------------------------------------------------------
+-- 4) 执行后自检(管理员执行,非必需)
+-- ---------------------------------------------------------------------------
+-- SHOW GRANTS FOR 'xh_core_ro'@'%'; -- 期望:仅 SELECT ON jinrong_core.*
+-- SHOW GRANTS FOR 'xh_core_rw'@'%'; -- 期望:4 表 SELECT/INSERT/UPDATE,无 DELETE、无 ALL
+-- SHOW GRANTS FOR 'xh_agent_rw'@'%'; -- 期望:业务表三权限;audit_log 仅 SELECT, INSERT
+-- 注:CREATE USER / GRANT 会自动刷新权限缓存,无需 FLUSH PRIVILEGES。
diff --git a/scripts/core/01-ddl.sql b/scripts/core/01-ddl.sql
index 3ea3f8b..dfed8f7 100644
--- a/scripts/core/01-ddl.sql
+++ b/scripts/core/01-ddl.sql
@@ -120,7 +120,16 @@ CREATE TABLE core_product (
term_days INT UNSIGNED NULL COMMENT '产品期限(天),NULL=灵活开放',
requires_disclosure TINYINT(1) NOT NULL DEFAULT 0 COMMENT '购买前需签署风险揭示书',
industry_code VARCHAR(16) NULL,
- fee_rate DECIMAL(6,4) NULL,
+ fee_rate DECIMAL(6,4) NULL COMMENT '历史费率展示位(换算逻辑不读;申购费率见 subscribe_fee_rate)',
+ -- ===== 基金转换(convert)扩展列 · T-1 =====
+ can_subscribe TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否可申购(转换转入前置校验)',
+ can_redeem TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否可赎回(转换转出前置校验)',
+ min_hold_qty DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '最低持有份额阈值',
+ min_redeem_qty DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '最低赎回份额',
+ min_hold_action VARCHAR(32) NOT NULL DEFAULT 'force_transfer' COMMENT '低于 min_hold_qty 的动作:force_transfer=强制全转 / force_redeem=强制赎回剩余;用 VARCHAR 非 ENUM,加动作免 ALTER',
+ subscribe_fee_rate DECIMAL(6,4) NOT NULL DEFAULT 0 COMMENT '申购费率(补差费计算源,价外法)。22 号文 §8 上限:指数/债券 ≤0.0030、其他混合 ≤0.0050、主动偏股 ≤0.0080、货基 0',
+ fund_company VARCHAR(64) NULL COMMENT '基金管理人(同一笔转换需同管理人)',
+ ta_code VARCHAR(32) NULL COMMENT '注册登记机构代码(同一笔转换需同 TA)',
is_open TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
KEY idx_min_risk (min_risk_code),
@@ -152,6 +161,7 @@ CREATE TABLE core_trade (
trade_type ENUM('subscribe','redeem','convert') NOT NULL,
amount DECIMAL(18,2) NOT NULL,
qty DECIMAL(18,4) NULL,
+ convert_group_id VARCHAR(64) NULL COMMENT '基金转换组 ID:同组两条流水(转出 redeem / 转入 subscribe)共享;非 convert 场景为 NULL',
channel ENUM('online','mobile','counter','other') NOT NULL DEFAULT 'online',
counterparty_account_mask VARCHAR(32) NULL,
counterparty_name VARCHAR(64) NULL,
@@ -161,6 +171,7 @@ CREATE TABLE core_trade (
trade_status ENUM('confirmed','pending','cancelled') NOT NULL DEFAULT 'confirmed',
traded_at DATETIME(3) NOT NULL,
KEY idx_customer_time (customer_id, traded_at),
+ KEY idx_convert_group (convert_group_id),
KEY idx_amount (amount, traded_at),
KEY idx_channel_time (channel, traded_at),
CONSTRAINT fk_trade_customer FOREIGN KEY (customer_id) REFERENCES core_customer(customer_id),
@@ -192,3 +203,56 @@ CREATE TABLE core_product_nav (
UNIQUE KEY uk_product_date (product_id, nav_date),
CONSTRAINT fk_nav_product FOREIGN KEY (product_id) REFERENCES core_product(product_id)
) ENGINE=InnoDB COMMENT='产品净值';
+
+-- ============================================================
+-- 基金转换(convert)新增表 · T-1(2026-09-10)
+-- 依据:docs/PRD/PRD-基金转换交易.md §4.1 · 架构设计-基金转换交易.md §9
+-- ============================================================
+
+-- 费率规则:redeem 按持有期分档(本期启用);subscribe 金额分档为真实 Core 接入扩展位(本期不启用)
+CREATE TABLE core_fee_rule (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ product_id VARCHAR(64) NOT NULL,
+ fee_type ENUM('subscribe','redeem') NOT NULL,
+ min_hold_days INT NULL COMMENT 'redeem:持有期下界(含)',
+ max_hold_days INT NULL COMMENT 'redeem:持有期上界(不含);NULL=无上限',
+ min_amount DECIMAL(18,2) NULL COMMENT 'subscribe:金额下界(含);本期不启用',
+ max_amount DECIMAL(18,2) NULL COMMENT 'subscribe:金额上界(不含);本期不启用',
+ rate DECIMAL(6,4) NOT NULL COMMENT '费率;redeem 档位按 22 号文 §10 下限(<7d 1.5% / 7-30d 1% / 30-180d 0.5% / ≥180d 合同约定)',
+ to_fund_ratio DECIMAL(5,4) NOT NULL DEFAULT 1.0000 COMMENT '赎回费计入基金财产比例。22 号文 §10:赎回费全额计入基金财产 → 恒 1.0(旧规 75%/50%/25% 分级已废止)。本期仅留痕、不参与计算',
+ KEY idx_product_type (product_id, fee_type),
+ CONSTRAINT fk_fee_rule_product FOREIGN KEY (product_id) REFERENCES core_product(product_id)
+) ENGINE=InnoDB COMMENT='费率规则(赎回费分档 / 申购费扩展位)';
+
+-- 份额批次:FIFO 计费的权威源(普通申赎无批次时由 lot_bootstrap 兜底补建,D8/D18)
+CREATE TABLE core_share_lot (
+ lot_id VARCHAR(64) NOT NULL PRIMARY KEY,
+ customer_id VARCHAR(64) NOT NULL,
+ product_id VARCHAR(64) NOT NULL,
+ qty DECIMAL(18,4) NOT NULL COMMENT '原始份额',
+ remain_qty DECIMAL(18,4) NOT NULL COMMENT '剩余份额(FIFO 扣减后;归零保留行不删,保可追溯)',
+ nav DECIMAL(10,4) NOT NULL COMMENT '成交净值',
+ confirmed_at DATETIME(3) NOT NULL COMMENT '份额确认时间(持有期自 T+1 起算,D14)',
+ source_trade_id VARCHAR(64) NULL COMMENT '来源流水(兜底补建时为 NULL)',
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ KEY idx_cust_prod_time (customer_id, product_id, confirmed_at),
+ CONSTRAINT fk_lot_customer FOREIGN KEY (customer_id) REFERENCES core_customer(customer_id),
+ CONSTRAINT fk_lot_product FOREIGN KEY (product_id) REFERENCES core_product(product_id)
+) ENGINE=InnoDB COMMENT='份额批次(FIFO 计费权威源)';
+
+-- 转换批次计费明细:含 nav/nav_date 使 Core 侧自包含,供 rebuild_alerts 补偿(D6)
+CREATE TABLE core_convert_lot_detail (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ convert_group_id VARCHAR(64) NOT NULL,
+ lot_id VARCHAR(64) NOT NULL COMMENT '被扣减的原批次(对应 core_share_lot.lot_id)',
+ qty DECIMAL(18,4) NOT NULL COMMENT '本批转出份额',
+ hold_days INT NOT NULL COMMENT '本批持有天数(自然日,不含申请日)',
+ amount DECIMAL(18,2) NOT NULL COMMENT '本批金额(已按 2 位四舍五入)',
+ fee_rate DECIMAL(6,4) NOT NULL COMMENT '本批适用费率',
+ fee_amount DECIMAL(18,2) NOT NULL COMMENT '本批费用(已按 2 位四舍五入)',
+ nav DECIMAL(10,4) NOT NULL COMMENT '本批成交净值',
+ nav_date DATE NOT NULL COMMENT '净值日期',
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ KEY idx_group (convert_group_id),
+ KEY idx_lot (lot_id)
+) ENGINE=InnoDB COMMENT='转换批次计费明细(Core 侧自包含,补偿数据源)';
diff --git a/scripts/core/07-seed-fee-rule.sql b/scripts/core/07-seed-fee-rule.sql
new file mode 100644
index 0000000..4b95ec8
--- /dev/null
+++ b/scripts/core/07-seed-fee-rule.sql
@@ -0,0 +1,107 @@
+USE jinrong_core;
+
+-- ============================================================================
+-- 07-seed-fee-rule.sql · 赎回费分档种子
+-- 版本:v1.0(2026-09-10 · T-1 新增)
+-- 依据:证监会公告〔2025〕22 号《公开募集证券投资基金销售费用管理规定》§10
+-- (2026-01-01 施行,替代 2013 年 26 号文的分级计入规则)
+-- · 持有期 < 7 日 → ≥ 1.5%
+-- · 持有期 7 ~ 30 日 → ≥ 1.0%
+-- · 持有期 30 ~ 180 日 → ≥ 0.5%
+-- · 持有期 ≥ 180 日 → 无强制下限(合同常见 0)
+-- · 赎回费全额计入基金财产 → to_fund_ratio 恒 1.0000(旧规 75%/50%/25% 已废止)
+-- 区间口径:左闭右开 [min_hold_days, max_hold_days),max_hold_days = NULL 表示无上限;
+-- 与 app/service/convert/fee.py 的 pick_fee_rate 完全一致。
+-- 说明 1:14 只产品统一灌 5 档,目的是让分档演示/真库集成有数据可查。
+-- 分档边界单测(6/7/29/30/179/180/364/365)走纯函数自建种子,**不依赖本文件**。
+-- 说明 2:真实货基(PROD-000001)赎回费为 0、指数/债基持满 7 日对个人投资者有豁免,
+-- 模拟库未做该豁免(豁免属投资者维度,非产品维度),保持 5 档统一。
+-- 说明 3:`fee_type='subscribe'` 的金额分档本期**不启用**(申购费简化在
+-- core_product.subscribe_fee_rate,见 09-seed-org.sql),此处不灌数据。
+-- ============================================================================
+
+INSERT INTO core_fee_rule (product_id, fee_type, min_hold_days, max_hold_days, rate, to_fund_ratio) VALUES
+-- PROD-110022 稳健债基 A(bond)
+('PROD-110022', 'redeem', 0, 7, 0.0150, 1.0000),
+('PROD-110022', 'redeem', 7, 30, 0.0100, 1.0000),
+('PROD-110022', 'redeem', 30, 180, 0.0050, 1.0000),
+('PROD-110022', 'redeem', 180, 365, 0.0025, 1.0000),
+('PROD-110022', 'redeem', 365, NULL, 0.0000, 1.0000),
+-- PROD-110023 信用债精选(bond)
+('PROD-110023', 'redeem', 0, 7, 0.0150, 1.0000),
+('PROD-110023', 'redeem', 7, 30, 0.0100, 1.0000),
+('PROD-110023', 'redeem', 30, 180, 0.0050, 1.0000),
+('PROD-110023', 'redeem', 180, 365, 0.0025, 1.0000),
+('PROD-110023', 'redeem', 365, NULL, 0.0000, 1.0000),
+-- PROD-005827 平衡混合一号(mixed · 转出主演示产品)
+('PROD-005827', 'redeem', 0, 7, 0.0150, 1.0000),
+('PROD-005827', 'redeem', 7, 30, 0.0100, 1.0000),
+('PROD-005827', 'redeem', 30, 180, 0.0050, 1.0000),
+('PROD-005827', 'redeem', 180, 365, 0.0025, 1.0000),
+('PROD-005827', 'redeem', 365, NULL, 0.0000, 1.0000),
+-- PROD-005828 稳健增利混合(mixed)
+('PROD-005828', 'redeem', 0, 7, 0.0150, 1.0000),
+('PROD-005828', 'redeem', 7, 30, 0.0100, 1.0000),
+('PROD-005828', 'redeem', 30, 180, 0.0050, 1.0000),
+('PROD-005828', 'redeem', 180, 365, 0.0025, 1.0000),
+('PROD-005828', 'redeem', 365, NULL, 0.0000, 1.0000),
+-- PROD-161725 科技成长主题(stock)
+('PROD-161725', 'redeem', 0, 7, 0.0150, 1.0000),
+('PROD-161725', 'redeem', 7, 30, 0.0100, 1.0000),
+('PROD-161725', 'redeem', 30, 180, 0.0050, 1.0000),
+('PROD-161725', 'redeem', 180, 365, 0.0025, 1.0000),
+('PROD-161725', 'redeem', 365, NULL, 0.0000, 1.0000),
+-- PROD-161726 消费升级主题(stock)
+('PROD-161726', 'redeem', 0, 7, 0.0150, 1.0000),
+('PROD-161726', 'redeem', 7, 30, 0.0100, 1.0000),
+('PROD-161726', 'redeem', 30, 180, 0.0050, 1.0000),
+('PROD-161726', 'redeem', 180, 365, 0.0025, 1.0000),
+('PROD-161726', 'redeem', 365, NULL, 0.0000, 1.0000),
+-- PROD-003095 医药健康精选(stock)
+('PROD-003095', 'redeem', 0, 7, 0.0150, 1.0000),
+('PROD-003095', 'redeem', 7, 30, 0.0100, 1.0000),
+('PROD-003095', 'redeem', 30, 180, 0.0050, 1.0000),
+('PROD-003095', 'redeem', 180, 365, 0.0025, 1.0000),
+('PROD-003095', 'redeem', 365, NULL, 0.0000, 1.0000),
+-- PROD-510300 沪深300指数(index · 转入主演示产品)
+('PROD-510300', 'redeem', 0, 7, 0.0150, 1.0000),
+('PROD-510300', 'redeem', 7, 30, 0.0100, 1.0000),
+('PROD-510300', 'redeem', 30, 180, 0.0050, 1.0000),
+('PROD-510300', 'redeem', 180, 365, 0.0025, 1.0000),
+('PROD-510300', 'redeem', 365, NULL, 0.0000, 1.0000),
+-- PROD-510500 中证500指数(index)
+('PROD-510500', 'redeem', 0, 7, 0.0150, 1.0000),
+('PROD-510500', 'redeem', 7, 30, 0.0100, 1.0000),
+('PROD-510500', 'redeem', 30, 180, 0.0050, 1.0000),
+('PROD-510500', 'redeem', 180, 365, 0.0025, 1.0000),
+('PROD-510500', 'redeem', 365, NULL, 0.0000, 1.0000),
+-- PROD-XYZ999 进取成长五号(stock)
+('PROD-XYZ999', 'redeem', 0, 7, 0.0150, 1.0000),
+('PROD-XYZ999', 'redeem', 7, 30, 0.0100, 1.0000),
+('PROD-XYZ999', 'redeem', 30, 180, 0.0050, 1.0000),
+('PROD-XYZ999', 'redeem', 180, 365, 0.0025, 1.0000),
+('PROD-XYZ999', 'redeem', 365, NULL, 0.0000, 1.0000),
+-- PROD-000001 现金宝货币(money)
+('PROD-000001', 'redeem', 0, 7, 0.0150, 1.0000),
+('PROD-000001', 'redeem', 7, 30, 0.0100, 1.0000),
+('PROD-000001', 'redeem', 30, 180, 0.0050, 1.0000),
+('PROD-000001', 'redeem', 180, 365, 0.0025, 1.0000),
+('PROD-000001', 'redeem', 365, NULL, 0.0000, 1.0000),
+-- PROD-000002 同业存单基金(bond)
+('PROD-000002', 'redeem', 0, 7, 0.0150, 1.0000),
+('PROD-000002', 'redeem', 7, 30, 0.0100, 1.0000),
+('PROD-000002', 'redeem', 30, 180, 0.0050, 1.0000),
+('PROD-000002', 'redeem', 180, 365, 0.0025, 1.0000),
+('PROD-000002', 'redeem', 365, NULL, 0.0000, 1.0000),
+-- PROD-WMG001 稳享180天理财(wealth_mgmt)
+('PROD-WMG001', 'redeem', 0, 7, 0.0150, 1.0000),
+('PROD-WMG001', 'redeem', 7, 30, 0.0100, 1.0000),
+('PROD-WMG001', 'redeem', 30, 180, 0.0050, 1.0000),
+('PROD-WMG001', 'redeem', 180, 365, 0.0025, 1.0000),
+('PROD-WMG001', 'redeem', 365, NULL, 0.0000, 1.0000),
+-- PROD-PRIV01 量化中性私募一号(private_fund)
+('PROD-PRIV01', 'redeem', 0, 7, 0.0150, 1.0000),
+('PROD-PRIV01', 'redeem', 7, 30, 0.0100, 1.0000),
+('PROD-PRIV01', 'redeem', 30, 180, 0.0050, 1.0000),
+('PROD-PRIV01', 'redeem', 180, 365, 0.0025, 1.0000),
+('PROD-PRIV01', 'redeem', 365, NULL, 0.0000, 1.0000);
diff --git a/scripts/core/08-seed-share-lot.sql b/scripts/core/08-seed-share-lot.sql
new file mode 100644
index 0000000..361d1c9
--- /dev/null
+++ b/scripts/core/08-seed-share-lot.sql
@@ -0,0 +1,94 @@
+USE jinrong_core;
+
+-- ============================================================================
+-- 08-seed-share-lot.sql · 份额批次种子
+-- 版本:v1.0(2026-09-10 · T-1 新增)
+-- 依据:PRD §4.1 `core_share_lot` + 架构 §5 D8/D18(批次为 FIFO 计费权威源)
+-- 生成规则:
+-- · 按 core_holding 逐行反推批次,**每个 (customer_id, product_id) 的
+-- Σ remain_qty 恒等于 core_holding.qty**(DoD ② 可逐行 SQL 断言)。
+-- · 主演示客户 CUST-9527 专门拆多批,用于跨批次 FIFO 与逐批计费演示:
+-- PROD-005827 50000 = 30000 + 15000 + 5000(覆盖 ≥365 / 30-180 / <7 三档)
+-- PROD-110022 80000 = 50000 + 30000 (覆盖 180-365 / 7-30 两档)
+-- PROD-000001 20000 = 20000 (单批,≥365 档)
+-- · 其余持仓每行 1 批,confirmed_at 按 D1→D5 循环,使整体覆盖全部 5 档。
+-- 日期模板(持有天数以 as_of = 2026-09-04 为参照计算,对应 07 档位表的区间):
+-- D1 = 2025-05-01 → 491 天 → [365, ∞) → rate 0.0000
+-- D2 = 2026-02-15 → 201 天 → [180, 365) → rate 0.0025
+-- D3 = 2026-08-01 → 34 天 → [30, 180) → rate 0.0050
+-- D4 = 2026-08-25 → 10 天 → [7, 30) → rate 0.0100
+-- D5 = 2026-09-01 → 3 天 → [0, 7) → rate 0.0150
+-- 重要说明:静态日期会随运行日期推移而整体后移(今天跑 D5 已是 7-30 档)。
+-- 因此 **分档边界单测必须用自建种子(相对 now 生成日期)**,不得依赖本文件;
+-- 本文件服务于模拟库人工演示与真库集成冒烟。
+-- 其他:nav 取 06-seed-nav.sql 的 2026-09-04 最新净值(模拟库近似,非逐批历史净值);
+-- source_trade_id 统一 NULL —— 04/05 两个种子各自独立造数,批次不回溯流水,
+-- 真实 TA 的批次由确认流水生成,模拟库不假装有这个链路。
+-- ============================================================================
+
+INSERT INTO core_share_lot (lot_id, customer_id, product_id, qty, remain_qty, nav, confirmed_at, source_trade_id) VALUES
+-- ---- CUST-9527 主 demo:跨批次专项 ----
+('LOT-9527-005827-01', 'CUST-9527', 'PROD-005827', 30000.0000, 30000.0000, 0.9500, '2025-05-01 10:00:00.000', NULL),
+('LOT-9527-005827-02', 'CUST-9527', 'PROD-005827', 15000.0000, 15000.0000, 0.9500, '2026-08-01 10:00:00.000', NULL),
+('LOT-9527-005827-03', 'CUST-9527', 'PROD-005827', 5000.0000, 5000.0000, 0.9500, '2026-09-01 10:00:00.000', NULL),
+('LOT-9527-110022-01', 'CUST-9527', 'PROD-110022', 50000.0000, 50000.0000, 1.0300, '2026-02-15 10:00:00.000', NULL),
+('LOT-9527-110022-02', 'CUST-9527', 'PROD-110022', 30000.0000, 30000.0000, 1.0300, '2026-08-25 10:00:00.000', NULL),
+('LOT-9527-000001-01', 'CUST-9527', 'PROD-000001', 20000.0000, 20000.0000, 1.0020, '2025-05-01 10:00:00.000', NULL),
+-- ---- CUST-1001 ~ 1024(单批,日期循环覆盖 5 档)----
+('LOT-1001-110022-01', 'CUST-1001', 'PROD-110022', 30000.0000, 30000.0000, 1.0300, '2025-05-01 10:00:00.000', NULL),
+('LOT-1001-000001-01', 'CUST-1001', 'PROD-000001', 50000.0000, 50000.0000, 1.0020, '2026-02-15 10:00:00.000', NULL),
+('LOT-1002-005828-01', 'CUST-1002', 'PROD-005828', 40000.0000, 40000.0000, 1.0200, '2026-08-01 10:00:00.000', NULL),
+('LOT-1002-110023-01', 'CUST-1002', 'PROD-110023', 25000.0000, 25000.0000, 1.0200, '2026-08-25 10:00:00.000', NULL),
+('LOT-1003-110022-01', 'CUST-1003', 'PROD-110022', 120000.0000, 120000.0000, 1.0300, '2026-09-01 10:00:00.000', NULL),
+('LOT-1003-005828-01', 'CUST-1003', 'PROD-005828', 30000.0000, 30000.0000, 1.0200, '2025-05-01 10:00:00.000', NULL),
+('LOT-1004-161725-01', 'CUST-1004', 'PROD-161725', 60000.0000, 60000.0000, 1.0500, '2026-02-15 10:00:00.000', NULL),
+('LOT-1004-510300-01', 'CUST-1004', 'PROD-510300', 40000.0000, 40000.0000, 1.0300, '2026-08-01 10:00:00.000', NULL),
+('LOT-1005-005827-01', 'CUST-1005', 'PROD-005827', 35000.0000, 35000.0000, 0.9500, '2026-08-25 10:00:00.000', NULL),
+('LOT-1005-510300-01', 'CUST-1005', 'PROD-510300', 15000.0000, 15000.0000, 1.0300, '2026-09-01 10:00:00.000', NULL),
+('LOT-1006-110023-01', 'CUST-1006', 'PROD-110023', 90000.0000, 90000.0000, 1.0200, '2025-05-01 10:00:00.000', NULL),
+('LOT-1006-000002-01', 'CUST-1006', 'PROD-000002', 40000.0000, 40000.0000, 1.0050, '2026-02-15 10:00:00.000', NULL),
+('LOT-1007-005828-01', 'CUST-1007', 'PROD-005828', 45000.0000, 45000.0000, 1.0200, '2026-08-01 10:00:00.000', NULL),
+('LOT-1008-161726-01', 'CUST-1008', 'PROD-161726', 20000.0000, 20000.0000, 1.0500, '2026-08-25 10:00:00.000', NULL),
+('LOT-1008-510500-01', 'CUST-1008', 'PROD-510500', 10000.0000, 10000.0000, 0.9800, '2026-09-01 10:00:00.000', NULL),
+('LOT-1009-000001-01', 'CUST-1009', 'PROD-000001', 150000.0000, 150000.0000, 1.0020, '2025-05-01 10:00:00.000', NULL),
+('LOT-1009-110022-01', 'CUST-1009', 'PROD-110022', 80000.0000, 80000.0000, 1.0300, '2026-02-15 10:00:00.000', NULL),
+('LOT-1010-003095-01', 'CUST-1010', 'PROD-003095', 55000.0000, 55000.0000, 0.9500, '2026-08-01 10:00:00.000', NULL),
+('LOT-1010-005827-01', 'CUST-1010', 'PROD-005827', 30000.0000, 30000.0000, 0.9500, '2026-08-25 10:00:00.000', NULL),
+('LOT-1011-161725-01', 'CUST-1011', 'PROD-161725', 70000.0000, 70000.0000, 1.0500, '2026-09-01 10:00:00.000', NULL),
+('LOT-1012-510300-01', 'CUST-1012', 'PROD-510300', 65000.0000, 65000.0000, 1.0300, '2025-05-01 10:00:00.000', NULL),
+('LOT-1012-110023-01', 'CUST-1012', 'PROD-110023', 35000.0000, 35000.0000, 1.0200, '2026-02-15 10:00:00.000', NULL),
+('LOT-1013-005828-01', 'CUST-1013', 'PROD-005828', 28000.0000, 28000.0000, 1.0200, '2026-08-01 10:00:00.000', NULL),
+('LOT-1014-510500-01', 'CUST-1014', 'PROD-510500', 90000.0000, 90000.0000, 0.9800, '2026-08-25 10:00:00.000', NULL),
+('LOT-1014-161726-01', 'CUST-1014', 'PROD-161726', 40000.0000, 40000.0000, 1.0500, '2026-09-01 10:00:00.000', NULL),
+('LOT-1015-110022-01', 'CUST-1015', 'PROD-110022', 100000.0000, 100000.0000, 1.0300, '2025-05-01 10:00:00.000', NULL),
+('LOT-1016-005827-01', 'CUST-1016', 'PROD-005827', 42000.0000, 42000.0000, 0.9500, '2026-02-15 10:00:00.000', NULL),
+('LOT-1016-003095-01', 'CUST-1016', 'PROD-003095', 18000.0000, 18000.0000, 0.9500, '2026-08-01 10:00:00.000', NULL),
+('LOT-1017-510300-01', 'CUST-1017', 'PROD-510300', 32000.0000, 32000.0000, 1.0300, '2026-08-25 10:00:00.000', NULL),
+('LOT-1018-000001-01', 'CUST-1018', 'PROD-000001', 200000.0000, 200000.0000, 1.0020, '2026-09-01 10:00:00.000', NULL),
+('LOT-1019-110023-01', 'CUST-1019', 'PROD-110023', 48000.0000, 48000.0000, 1.0200, '2025-05-01 10:00:00.000', NULL),
+('LOT-1019-005828-01', 'CUST-1019', 'PROD-005828', 22000.0000, 22000.0000, 1.0200, '2026-02-15 10:00:00.000', NULL),
+('LOT-1020-161725-01', 'CUST-1020', 'PROD-161725', 85000.0000, 85000.0000, 1.0500, '2026-08-01 10:00:00.000', NULL),
+('LOT-1020-XYZ999-01', 'CUST-1020', 'PROD-XYZ999', 15000.0000, 15000.0000, 0.9000, '2026-08-25 10:00:00.000', NULL),
+('LOT-1021-005827-01', 'CUST-1021', 'PROD-005827', 15000.0000, 15000.0000, 0.9500, '2026-09-01 10:00:00.000', NULL),
+('LOT-1022-510300-01', 'CUST-1022', 'PROD-510300', 72000.0000, 72000.0000, 1.0300, '2025-05-01 10:00:00.000', NULL),
+('LOT-1022-110022-01', 'CUST-1022', 'PROD-110022', 48000.0000, 48000.0000, 1.0300, '2026-02-15 10:00:00.000', NULL),
+('LOT-1023-005828-01', 'CUST-1023', 'PROD-005828', 38000.0000, 38000.0000, 1.0200, '2026-08-01 10:00:00.000', NULL),
+('LOT-1024-161726-01', 'CUST-1024', 'PROD-161726', 95000.0000, 95000.0000, 1.0500, '2026-08-25 10:00:00.000', NULL),
+-- ---- CUST-3001 / 4001 / 4002(单批)----
+('LOT-3001-510300-01', 'CUST-3001', 'PROD-510300', 200000.0000, 200000.0000, 1.0300, '2026-09-01 10:00:00.000', NULL),
+('LOT-3001-005827-01', 'CUST-3001', 'PROD-005827', 150000.0000, 150000.0000, 0.9500, '2025-05-01 10:00:00.000', NULL),
+('LOT-4001-XYZ999-01', 'CUST-4001', 'PROD-XYZ999', 80000.0000, 80000.0000, 0.9000, '2026-02-15 10:00:00.000', NULL),
+('LOT-4001-161725-01', 'CUST-4001', 'PROD-161725', 50000.0000, 50000.0000, 1.0500, '2026-08-01 10:00:00.000', NULL),
+('LOT-4002-161725-01', 'CUST-4002', 'PROD-161725', 10000.0000, 10000.0000, 1.0500, '2026-08-25 10:00:00.000', NULL),
+-- ---- 手册示范客户 CUST-DEMO-A ~ E(单批)----
+('LOT-DEMOA-510300-01', 'CUST-DEMO-A', 'PROD-510300', 35000.0000, 35000.0000, 1.0300, '2026-09-01 10:00:00.000', NULL),
+('LOT-DEMOA-510500-01', 'CUST-DEMO-A', 'PROD-510500', 25000.0000, 25000.0000, 0.9800, '2025-05-01 10:00:00.000', NULL),
+('LOT-DEMOA-000001-01', 'CUST-DEMO-A', 'PROD-000001', 10000.0000, 10000.0000, 1.0020, '2026-02-15 10:00:00.000', NULL),
+('LOT-DEMOB-161725-01', 'CUST-DEMO-B', 'PROD-161725', 500000.0000, 500000.0000, 1.0500, '2026-08-01 10:00:00.000', NULL),
+('LOT-DEMOB-PRIV01-01', 'CUST-DEMO-B', 'PROD-PRIV01', 2500000.0000, 2500000.0000, 1.0800, '2026-08-25 10:00:00.000', NULL),
+('LOT-DEMOC-110022-01', 'CUST-DEMO-C', 'PROD-110022', 400000.0000, 400000.0000, 1.0300, '2026-09-01 10:00:00.000', NULL),
+('LOT-DEMOC-WMG001-01', 'CUST-DEMO-C', 'PROD-WMG001', 200000.0000, 200000.0000, 1.0150, '2025-05-01 10:00:00.000', NULL),
+('LOT-DEMOD-005827-01', 'CUST-DEMO-D', 'PROD-005827', 300000.0000, 300000.0000, 0.9500, '2026-02-15 10:00:00.000', NULL),
+('LOT-DEMOD-510300-01', 'CUST-DEMO-D', 'PROD-510300', 150000.0000, 150000.0000, 1.0300, '2026-08-01 10:00:00.000', NULL),
+('LOT-DEMOE-161725-01', 'CUST-DEMO-E', 'PROD-161725', 120000.0000, 120000.0000, 1.0500, '2026-08-25 10:00:00.000', NULL),
+('LOT-DEMOE-510500-01', 'CUST-DEMO-E', 'PROD-510500', 80000.0000, 80000.0000, 0.9800, '2026-09-01 10:00:00.000', NULL);
diff --git a/scripts/core/09-seed-org.sql b/scripts/core/09-seed-org.sql
new file mode 100644
index 0000000..69b64c9
--- /dev/null
+++ b/scripts/core/09-seed-org.sql
@@ -0,0 +1,88 @@
+USE jinrong_core;
+
+-- ============================================================================
+-- 09-seed-org.sql · 产品机构信息与申购费率
+-- 版本:v1.1(2026-09-10 · T-1 新增;同日按「真实业务」修订 —— 分类归位 + 分组重排)
+-- 依据:
+-- · 证监会公告〔2025〕22 号 §8 申购费率上限(2026-01-01 施行):
+-- 主动偏股型 ≤ 0.80% · 其他混合型 ≤ 0.50% · 指数型/债券型 ≤ 0.30% · 货基 0
+-- · PRD §4.2/§4.3:申购费简化为单一 core_product.subscribe_fee_rate(补差费计算源)
+-- · PRD/架构:同一笔转换要求 **同销售机构 + 同管理人 + 同 TA**
+--
+-- 【v1.1 修订原因 · 分类必须与 product_type 匹配】
+-- v1.0 曾把 `PROD-005827`(`product_type='mixed'`,平衡混合一号)按「主动偏股」取到
+-- **0.80%** 上限 —— 目的是凑出 0.50% 的补差幅度。这是**分类错误**:
+-- 22 号文 §8 的费率档由**产品类型**决定,`mixed` 只能套「其他混合型 ≤0.5%」。
+-- 现修正:`PROD-005827` → **0.0050**;主示例转入方改用**真正的主动偏股产品**
+-- `PROD-003095`(`stock` / R4 / 净值 0.9500 —— 与原示例除数相同,故 §5.3 数字不变)。
+-- ⚠️ 通用规则:**用某一档上限前,先证明该产品确实属于这一档**,不得「看名字像」
+-- 或「为了凑数值差」升档(自检第 12 问)。
+--
+-- 【v1.1 修订原因 · 分组按真实业务重排】
+-- v1.0 的分组是「华夏组全是低风险(债/混合/货基/指数)、易方组全是股票型」——
+-- **不符合真实**:真实的基金管理人都是**全产品线**(同一家公司旗下同时有债基、
+-- 混合、指数、股票、货基)。真实的分组必须以「管理人」为单位、覆盖多风险层级,
+-- 转换约束「同管理人 + 同 TA」才有实际意义。
+-- 现分组:华夏 / 易方 各 6 只、均覆盖多个风险层级;理财与私募单列(真实形态:
+-- 银行理财子公司、私募管理人,与公募不同主体,天然不可与公募互转)。
+--
+-- 【分组与费率一览】
+-- 华夏模拟基金 / TA-CN-001(主演示组,覆盖 5 类)
+-- PROD-110022 稳健债基 A bond R1 0.0030 ← 主示例转出
+-- PROD-110023 信用债精选 bond R2 0.0030
+-- PROD-005827 平衡混合一号 mixed R3 0.0050 ← v1.1 由 0.0080 归位
+-- PROD-000001 现金宝货币 money R1 0.0000 ← 同费率对照组(0)
+-- PROD-510300 沪深300指数 index R3 0.0030 ← 同费率对照组(与 110022 同)
+-- PROD-003095 医药健康精选 stock R4 0.0080 ← 主示例转入(真主动偏股)
+-- 易方模拟基金 / TA-CN-002(覆盖 4 类)
+-- PROD-000002 同业存单基金 bond R1 0.0030
+-- PROD-005828 稳健增利混合 mixed R2 0.0050
+-- PROD-510500 中证500指数 index R4 0.0030
+-- PROD-161725 科技成长主题 stock R4 0.0080
+-- PROD-161726 消费升级主题 stock R4 0.0080
+-- PROD-XYZ999 进取成长五号 stock R5 0.0080
+-- 兴银理财 / TA-CN-003 : PROD-WMG001 稳享180天理财 wealth_mgmt R2 0.0000
+-- 量化私募 / TA-CN-004 : PROD-PRIV01 量化中性私募一号 private_fund R4 0.0000
+--
+-- 【可验证的费率关系(供补差费演示与对照)】
+-- 不同费率对:PROD-110022(0.0030)→ PROD-003095(0.0080)**同属华夏组、可互转** → 补差非 0
+-- 同费率对照:PROD-110022(0.0030)↔ PROD-510300(0.0030)→ 补差 = 0
+-- 零费率对照:PROD-000001(0)→ 任意产品补差非 0;任意 → PROD-000001 补差 = 0
+--
+-- 【跨主体负例保障(「同管理人 + 同 TA」硬约束的反例数据仍然充足)】
+-- CUST-1004 : 161725(易方) + 510300(华夏) → 跨管理人 ✗
+-- CUST-DEMO-A : 510500(易方) + 510300/000001(华夏) → 跨管理人 ✗
+-- CUST-DEMO-B : 161725(易方) + PRIV01(量化私募) → 跨管理人 + 跨机构类型 ✗
+--
+-- 【最低持有余额】让 FR-C15 的两种动作都有数据可触发(否则 DEFAULT 0 使该分支永不进入,
+-- 等于无测试覆盖):PROD-110022 = force_transfer(低于阈值强制全转)
+-- PROD-005827 = force_redeem (低于阈值强制赎回剩余)
+-- ============================================================================
+
+-- ---- ① 华夏模拟基金 / TA-CN-001(主演示组 · 全产品线)----
+UPDATE core_product SET fund_company = '华夏模拟基金', ta_code = 'TA-CN-001', subscribe_fee_rate = 0.0030 WHERE product_id = 'PROD-110022';
+UPDATE core_product SET fund_company = '华夏模拟基金', ta_code = 'TA-CN-001', subscribe_fee_rate = 0.0030 WHERE product_id = 'PROD-110023';
+UPDATE core_product SET fund_company = '华夏模拟基金', ta_code = 'TA-CN-001', subscribe_fee_rate = 0.0050 WHERE product_id = 'PROD-005827';
+UPDATE core_product SET fund_company = '华夏模拟基金', ta_code = 'TA-CN-001', subscribe_fee_rate = 0.0000 WHERE product_id = 'PROD-000001';
+UPDATE core_product SET fund_company = '华夏模拟基金', ta_code = 'TA-CN-001', subscribe_fee_rate = 0.0030 WHERE product_id = 'PROD-510300';
+UPDATE core_product SET fund_company = '华夏模拟基金', ta_code = 'TA-CN-001', subscribe_fee_rate = 0.0080 WHERE product_id = 'PROD-003095';
+
+-- ---- ② 易方模拟基金 / TA-CN-002(次演示组 · 全产品线)----
+UPDATE core_product SET fund_company = '易方模拟基金', ta_code = 'TA-CN-002', subscribe_fee_rate = 0.0030 WHERE product_id = 'PROD-000002';
+UPDATE core_product SET fund_company = '易方模拟基金', ta_code = 'TA-CN-002', subscribe_fee_rate = 0.0050 WHERE product_id = 'PROD-005828';
+UPDATE core_product SET fund_company = '易方模拟基金', ta_code = 'TA-CN-002', subscribe_fee_rate = 0.0030 WHERE product_id = 'PROD-510500';
+UPDATE core_product SET fund_company = '易方模拟基金', ta_code = 'TA-CN-002', subscribe_fee_rate = 0.0080 WHERE product_id = 'PROD-161725';
+UPDATE core_product SET fund_company = '易方模拟基金', ta_code = 'TA-CN-002', subscribe_fee_rate = 0.0080 WHERE product_id = 'PROD-161726';
+UPDATE core_product SET fund_company = '易方模拟基金', ta_code = 'TA-CN-002', subscribe_fee_rate = 0.0080 WHERE product_id = 'PROD-XYZ999';
+
+-- ---- ③ 非同主体产品(真实形态:非公募主体,天然不可与公募互转)----
+UPDATE core_product SET fund_company = '兴银理财', ta_code = 'TA-CN-003', subscribe_fee_rate = 0.0000 WHERE product_id = 'PROD-WMG001';
+UPDATE core_product SET fund_company = '量化私募', ta_code = 'TA-CN-004', subscribe_fee_rate = 0.0000 WHERE product_id = 'PROD-PRIV01';
+
+-- ---- ④ 兜底:任何未显式赋值的产品也保证非 NULL(DoD ③)----
+UPDATE core_product SET fund_company = '其他模拟管理人', ta_code = 'TA-CN-099' WHERE fund_company IS NULL;
+UPDATE core_product SET ta_code = 'TA-CN-099' WHERE ta_code IS NULL;
+
+-- ---- ⑤ 最低持有余额(FR-C15 两种动作的可触发数据)----
+UPDATE core_product SET min_hold_qty = 1000.0000, min_redeem_qty = 100.0000, min_hold_action = 'force_transfer' WHERE product_id = 'PROD-110022';
+UPDATE core_product SET min_hold_qty = 2000.0000, min_redeem_qty = 200.0000, min_hold_action = 'force_redeem' WHERE product_id = 'PROD-005827';
diff --git a/scripts/core/README.md b/scripts/core/README.md
index ddbf83c..dedd960 100644
--- a/scripts/core/README.md
+++ b/scripts/core/README.md
@@ -18,6 +18,10 @@ mysql -u root -p < scripts/core/03-seed-customers.sql
mysql -u root -p < scripts/core/04-seed-holdings.sql
mysql -u root -p < scripts/core/05-seed-trades.sql
mysql -u root -p < scripts/core/06-seed-nav.sql
+# 基金转换(convert)新增种子 · T-1
+mysql -u root -p < scripts/core/07-seed-fee-rule.sql
+mysql -u root -p < scripts/core/08-seed-share-lot.sql
+mysql -u root -p < scripts/core/09-seed-org.sql
# Agent 库(若未建)
mysql -u root -p < docs/项目框架设计/表设计/01-mysql-共用底座.sql
@@ -35,9 +39,16 @@ python scripts/sync/sync_neo4j.py
| 分析/风控/合规/运营 | 2+2+2+1(风控另有演示账号 STAFF-90001,我方分支独有) |
| 双角色 staff | 1(STAFF-10091 advisor+compliance) |
| 产品 | 14 |
-| 持仓记录 | ~45 |
+| 持仓记录 | 58 |
| 交易 | 25+ |
| C×R 适当性矩阵 | 25 行(core_suitability_rule,L0 权威) |
+| 赎回费规则(T-1) | 70 行(14 产品 × 5 档,`core_fee_rule`;档位按 22 号文 §10) |
+| 份额批次(T-1) | 61 行(58 行持仓反推,Σ remain_qty 恒等于持仓 qty;CUST-9527 跨批次) |
+
+> 新增的 07/08/09 三个种子**已并入 `reset.ps1`**;`00-grant.sql` 是账号授权脚本,
+> 需管理员单独执行一次,**刻意不进 reset.ps1**(DROP DATABASE 不清授权行)。
+> T-1 起 `reset.ps1` 需 mysql 客户端在 PATH;若不在 PATH 且需非交互执行,
+> 用 `python scripts/dev/verify_convert_seed.py`(等价流程 + DoD 断言)。
## RBAC 联调账号见
diff --git a/scripts/core/reset.ps1 b/scripts/core/reset.ps1
index 993dfb8..0d5f054 100644
--- a/scripts/core/reset.ps1
+++ b/scripts/core/reset.ps1
@@ -28,7 +28,10 @@ $files = @(
"scripts/core/03-seed-customers.sql",
"scripts/core/04-seed-holdings.sql",
"scripts/core/05-seed-trades.sql",
- "scripts/core/06-seed-nav.sql"
+ "scripts/core/06-seed-nav.sql",
+ "scripts/core/07-seed-fee-rule.sql",
+ "scripts/core/08-seed-share-lot.sql",
+ "scripts/core/09-seed-org.sql"
)
foreach ($f in $files) { Invoke-SqlFile (Join-Path $Root $f) }
diff --git a/scripts/dev/calc_convert_demo.py b/scripts/dev/calc_convert_demo.py
index 4e349f4..ade4d14 100644
--- a/scripts/dev/calc_convert_demo.py
+++ b/scripts/dev/calc_convert_demo.py
@@ -1,98 +1,172 @@
# -*- coding: utf-8 -*-
-"""基金转换主示例实算(PRD §5.3 数值来源)。
+"""基金转换主示例实算(T-2b · PRD §5.3 数值来源 · 禁止手算)。
-用途:PRD §5.3 响应示例中的**所有派生金额/份额**,必须由本脚本算出后回填,
-**禁止手算**——v0.7 曾两次因手算与口径不符被外部审查质疑(见处置表 T-2b / 自检第 9 问)。
+**为什么必须调用生产 `app.service.convert.calc` 而不是脚本内再写一套公式**
-公式与 PRD §2.1 / §2.1.1 / §2.1.3 / §2.5 完全一致:
- 转出金额 = Σ(各批次份额 × 转出净值)
- 赎回费 = Σ(各批次金额 × 该批适用赎回费率) # 逐批次先舍入、后求和
- 转换金额 = 转出金额 − 赎回费
- 补差费(B) = Max[ 转换金额×转入费率/(1+转入费率)
- − 转换金额×转出费率/(1+转出费率), 0 ] # 价外法两端差(本期默认)
- 补差费(A) = 转换金额 × max(转入费率−转出费率,0) / (1+补差费率) # 费率差法(配置保留)
- 转入金额 = 转换金额 − 补差费
- 转入份额 = 转入金额 ÷ 转入净值 # 2 位,ROUND_HALF_UP
+v0.7 曾两次因「手算与口径不符」被外部审查质疑;而"脚本自己实现一份公式"
+是同一类问题的变体——**两处公式必然漂移**(生产 `calc.py` 改了、脚本没改,
+PRD 里的数字就变成假证据)。这与 D18(批次补建规则只留一个副本)是同一条纪律。
+
+故本脚本:
+1. **只做输入准备与输出打印**,全部计算走 `calc` / `fee` 的生产函数;
+2. 打印后与 PRD §5.3 的**期望值逐项比对**,不一致即 `exit 1`
+ —— 这样它同时是一道「PRD 数字 ↔ 生产口径」一致性门禁。
运行:<托管 Python> scripts/dev/calc_convert_demo.py
"""
-from decimal import Decimal, ROUND_HALF_UP
-TWO = Decimal("0.01")
-FOUR = Decimal("0.0001")
+from __future__ import annotations
+
+import sys
+from datetime import date, datetime, time, timedelta
+from decimal import Decimal
+from pathlib import Path
+
+# ① sys.path 引导项目根(直跑脚本时 sys.path[0] 为脚本目录;同 verify_convert_seed 先例)
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT))
+
+from app.service.convert import calc, fee # noqa: E402
+from app.service.convert.types import FeeRule, Lot # noqa: E402
+
+#: 与 PRD §5.3 同一交易日(也是 06-seed-nav.sql 的净值日期)。
+TRADE_DATE = date(2026, 9, 4)
+
+#: 07-seed-fee-rule.sql 的赎回费五档(22 号文 §10 下限)。
+RULES = [
+ FeeRule(0, 7, Decimal("0.0150")),
+ FeeRule(7, 30, Decimal("0.0100")),
+ FeeRule(30, 180, Decimal("0.0050")),
+ FeeRule(180, 365, Decimal("0.0025")),
+ FeeRule(365, None, Decimal("0.0000")),
+]
+
+# 两端同属**华夏模拟基金 / TA-CN-001**(满足转换前置硬约束:同管理人 + 同 TA)。
+NAV_OUT = Decimal("1.0300") # PROD-110022 稳健债基 A(bond)
+NAV_IN = Decimal("0.9500") # PROD-003095 医药健康精选(stock / 主动偏股型)
+SUB_RATE_OUT = Decimal("0.0030") # 转出端申购费率(债基,22 号文 §8 上限 0.3%)
+SUB_RATE_IN = Decimal("0.0080") # 转入端申购费率(主动偏股型,上限 0.8%)
+
+REQUESTED_QTY = Decimal("50000.0000")
+
+#: PRD §5.3 期望值 —— 实算结果必须逐项命中。
+EXPECTED = {
+ "批次 1 amount": "30900.00",
+ "批次 1 fee": "154.50",
+ "批次 2 amount": "20600.00",
+ "批次 2 fee": "309.00",
+ "out_amount": "51500.00",
+ "redeem_fee": "463.50",
+ "convert_amount": "51036.50",
+ "diff_fee(B)": "252.40",
+ "in_amount": "50784.10",
+ "in_qty(B)": "53456.95",
+ "rounding_diff": "-0.0026",
+ "diff_fee(A)": "253.91",
+ "in_qty(A)": "53455.36",
+ "同费率 diff_fee": "0.00",
+ "同费率 in_qty": "53722.63",
+}
-def q2(x: Decimal) -> Decimal:
- """金额/份额统一保留 2 位、四舍五入(显式 ROUND_HALF_UP,禁用默认 HALF_EVEN)。"""
- return x.quantize(TWO, rounding=ROUND_HALF_UP)
+def _lot(lot_id: str, hold_days: int, qty: str, nav: Decimal) -> Lot:
+ """构造 `hold_days` 天前确认的批次(交易日固定为 TRADE_DATE)。"""
+ confirmed = datetime.combine(TRADE_DATE - timedelta(days=hold_days), time(10, 0, 0))
+ return Lot(
+ lot_id=lot_id,
+ confirmed_at=confirmed,
+ remain_qty=Decimal(qty),
+ nav=nav,
+ qty=Decimal(qty),
+ )
-def main() -> None:
- # —— 主示例输入(与 §5.3 / §4.3 种子一致)——
+def compute() -> dict[str, str]:
+ """跑一遍主示例全链,返回**结果字典**(键与 EXPECTED 对齐)。"""
+ # —— 输入:跨 2 个批次、持有期不同 → 费率不同(30000 份满 30 日 / 20000 份不足 7 日)
lots = [
- # (份额, 持有天数, 该批赎回费率) —— 费率来自 §2.1.3 分档表
- (Decimal("30000"), 100, Decimal("0.0050")), # [30,180) 日 → 0.5%
- (Decimal("20000"), 3, Decimal("0.0150")), # [0,7) 日 → 1.5%
+ _lot("LOT-DEMO-01", 100, "30000.0000", NAV_OUT),
+ _lot("LOT-DEMO-02", 3, "20000.0000", NAV_OUT),
]
- nav_out = Decimal("1.03") # PROD-110022 债基 转出净值
- nav_in = Decimal("0.95") # PROD-005827 主动偏股 转入净值
- fee_out = Decimal("0.0030") # 转出端申购费率(债基,22 号文 §8 上限 0.3%)
- fee_in = Decimal("0.0080") # 转入端申购费率(主动偏股,上限 0.8%)
+ plan = calc.plan_lots(lots, REQUESTED_QTY)
+ result: dict[str, str] = {}
+
+ # —— 逐批:先各自 2 位舍入、再求和(PRD §12「舍入顺序」)——
out_amount = Decimal("0")
redeem_fee = Decimal("0")
- detail = []
- for qty, days, rate in lots:
- amt = q2(qty * nav_out)
- fee = q2(amt * rate)
- out_amount += amt
- redeem_fee += fee
- detail.append((qty, days, rate, amt, fee))
+ for idx, alloc in enumerate(plan.allocations, start=1):
+ hold_days = calc.hold_days(TRADE_DATE, alloc.confirmed_at)
+ rate = fee.pick_fee_rate(RULES, hold_days)
+ amount = calc.lot_amount(alloc.qty, alloc.nav)
+ fee_amount = calc.lot_fee(amount, rate)
+ out_amount += amount
+ redeem_fee += fee_amount
+ result[f"批次 {idx} amount"] = str(amount)
+ result[f"批次 {idx} fee"] = str(fee_amount)
+ print(
+ f"批次 {idx}({alloc.lot_id}){alloc.qty} 份 / 持有 {hold_days} 天 / "
+ f"费率 {rate}({rate * 100}%)→ amount={amount} fee={fee_amount}"
+ )
- convert_amount = q2(out_amount - redeem_fee)
+ convert_amount = calc.convert_amount(out_amount, redeem_fee)
+ result["out_amount"] = str(out_amount)
+ result["redeem_fee"] = str(redeem_fee)
+ result["convert_amount"] = str(convert_amount)
- # —— 口径 B:价外法两端差(本期默认)——
- sub_in = convert_amount * fee_in / (Decimal("1") + fee_in)
- sub_out = convert_amount * fee_out / (Decimal("1") + fee_out)
- diff_b = q2(max(sub_in - sub_out, Decimal("0")))
- in_amount = q2(convert_amount - diff_b)
- theoretical = in_amount / nav_in
- in_qty = q2(theoretical)
- rounding_diff = theoretical - in_qty
+ # —— 口径 B:价外法两端差(本期默认,PRD §2.1.1)——
+ diff_b = calc.diff_fee(convert_amount, SUB_RATE_OUT, SUB_RATE_IN, "amount_diff")
+ in_amount = calc.convert_amount(convert_amount, diff_b)
+ in_qty_b = calc.in_qty(in_amount, NAV_IN)
+ result["diff_fee(B)"] = str(diff_b)
+ result["in_amount"] = str(in_amount)
+ result["in_qty(B)"] = str(in_qty_b)
+ result["rounding_diff"] = str(calc.rounding_diff(in_amount, NAV_IN, in_qty_b))
- # —— 口径 A:费率差法(配置 convert_diff_fee_mode='rate_diff' 保留)——
- rate_diff = max(fee_in - fee_out, Decimal("0"))
- diff_a = q2(convert_amount * rate_diff / (Decimal("1") + rate_diff))
- in_qty_a = q2(q2(convert_amount - diff_a) / nav_in)
+ # —— 口径 A:费率差法(配置 convert_diff_fee_mode='rate_diff' 时启用)——
+ diff_a = calc.diff_fee(convert_amount, SUB_RATE_OUT, SUB_RATE_IN, "rate_diff")
+ in_qty_a = calc.in_qty(calc.convert_amount(convert_amount, diff_a), NAV_IN)
+ result["diff_fee(A)"] = str(diff_a)
+ result["in_qty(A)"] = str(in_qty_a)
- # —— 同费率对照(补差 = 0),用于 §9 第 19 条 ——
- same_rate = Decimal("0.0030")
- diff_same = q2(max(convert_amount * same_rate / (Decimal("1") + same_rate)
- - convert_amount * same_rate / (Decimal("1") + same_rate),
- Decimal("0")))
- in_amount_same = q2(convert_amount - diff_same)
- in_qty_same = q2(in_amount_same / nav_in)
+ # —— 同费率对照(两端均为 0.0030)→ 补差 0,用于 PRD §9 第 19 条 ——
+ diff_same = calc.diff_fee(convert_amount, SUB_RATE_OUT, SUB_RATE_OUT, "amount_diff")
+ in_amount_same = calc.convert_amount(convert_amount, diff_same)
+ result["同费率 diff_fee"] = str(diff_same)
+ result["同费率 in_qty"] = str(calc.in_qty(in_amount_same, NAV_IN))
+
+ return result
+
+
+def main() -> int:
+ print("=== 主示例实算(与生产 calc.py 同一套公式)===")
+ actual = compute()
- print("=== 主示例(口径 B)===")
- for qty, days, rate, amt, fee in detail:
- print(f"批次 {qty} 份 / {days} 天 / 费率 {rate} -> amount={amt} fee={fee}")
- print(f"out_amount = {out_amount}")
- print(f"redeem_fee = {redeem_fee}")
- print(f"convert_amount = {convert_amount}")
- print(f" (转入端申购费) = {sub_in.quantize(FOUR)} -> q2 {q2(sub_in)}")
- print(f" (转出端申购费) = {sub_out.quantize(FOUR)} -> q2 {q2(sub_out)}")
- print(f"diff_fee(B) = {diff_b}")
- print(f"in_amount = {in_amount}")
- print(f"in_qty = {in_qty}")
- print(f"rounding_diff = {rounding_diff.quantize(FOUR)} (理论 {theoretical.quantize(FOUR)} - 实际 {in_qty})")
print()
- print("=== 对照:口径 A(费率差法)===")
- print(f"diff_fee(A) = {diff_a}")
- print(f"in_qty(A) = {in_qty_a}")
+ print("=== 与 PRD §5.3 期望值逐项比对 ===")
+ mismatches = []
+ for key, expected in EXPECTED.items():
+ got = actual.get(key, "<缺失>")
+ ok = got == expected
+ if not ok:
+ mismatches.append((key, expected, got))
+ print(f" [{'OK ' if ok else 'FAIL'}] {key:<18} 期望 {expected:<12} 实算 {got}")
+
+ if mismatches:
+ print()
+ print(f"❌ {len(mismatches)} 项不一致 —— PRD 数字或生产口径已被改动,停下查清:")
+ for key, expected, got in mismatches:
+ print(f" · {key}:期望 {expected} / 实算 {got}")
+ return 1
+
print()
- print("=== 同费率对照(两端 0.0030,补差 = 0)===")
- print(f"diff_fee={diff_same} in_amount={in_amount_same} in_qty={in_qty_same}")
+ print(f"✅ 全部 {len(EXPECTED)} 项与 PRD §5.3 一致(口径 B in_qty=53456.95)")
+ return 0
if __name__ == "__main__":
- main()
+ try: # Windows 控制台默认 cp936,避免打印中文报错
+ sys.stdout.reconfigure(encoding="utf-8")
+ except Exception: # noqa: BLE001 - 环境不支持时忽略,不影响计算
+ pass
+ raise SystemExit(main())
diff --git a/scripts/dev/verify_convert_seed.py b/scripts/dev/verify_convert_seed.py
new file mode 100644
index 0000000..16af2cf
--- /dev/null
+++ b/scripts/dev/verify_convert_seed.py
@@ -0,0 +1,312 @@
+"""T-1 验证脚本:用 pymysql 复刻 `scripts/core/reset.ps1` 的 SQL 部分并跑 DoD 断言。
+
+背景:本机 `mysql` 客户端不在 PATH,且 reset.ps1 用 `mysql -p`(交互式密码),
+无法在非交互环境直接跑。本脚本用 pymysql 执行**同一组 SQL 文件**(00~09),
+再对 T-1 的三条 DoD 判定阈值逐条断言,做到「判定阈值明确、非人工目测」。
+
+用法:
+ python scripts/dev/verify_convert_seed.py # 重建 jinrong_core + 恢复演示态 + 全部断言
+ python scripts/dev/verify_convert_seed.py --no-reset # 只跑断言(库已就绪时)
+
+注意 1:会 DROP DATABASE jinrong_core 并重建 —— 该库是 Core 模拟底座,本就设计为可重置
+(与 reset.ps1 行为一致)。**不触碰 jinrong_agent**。
+注意 2:重建后会自动补跑 `scripts/demo/prepare_risk_demo.sql`(reset 的既定伴随步骤)——
+不做的话 `03-seed-customers.sql` 写死的风评有效期会让真库集成模块整体 skip
+(实测 516 → 505 passed)。
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import sys
+from datetime import date
+from pathlib import Path
+
+import pymysql
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT))
+
+from app.config.settings import settings # noqa: E402
+
+CORE_DIR = ROOT / "scripts" / "core"
+SQL_FILES = [
+ "00-create-database.sql",
+ "01-ddl.sql",
+ "02-seed-base.sql",
+ "03-seed-customers.sql",
+ "04-seed-holdings.sql",
+ "05-seed-trades.sql",
+ "06-seed-nav.sql",
+ "07-seed-fee-rule.sql",
+ "08-seed-share-lot.sql",
+ "09-seed-org.sql",
+]
+
+# 档位覆盖参照日:与该批种子中 as_of / 净值日一致(2026-09-04)
+AS_OF = date(2026, 9, 4)
+BANDS = [(0, 7, "0.0150"), (7, 30, "0.0100"), (30, 180, "0.0050"), (180, 365, "0.0025"), (365, None, "0.0000")]
+
+
+def split_statements(sql: str) -> list[str]:
+ """剥离 `--` 整行注释后按分号切分。种子文件均无存储过程/触发器,可安全切分。"""
+ body = "\n".join(ln for ln in sql.splitlines() if not ln.strip().startswith("--"))
+ return [s.strip() for s in body.split(";") if s.strip()]
+
+
+def connect(with_db: bool = False) -> pymysql.connections.Connection:
+ return pymysql.connect(
+ host=settings.mysql_host,
+ port=settings.mysql_port,
+ user=settings.mysql_user,
+ password=settings.mysql_password,
+ database=settings.mysql_core_database if with_db else None,
+ charset="utf8mb4",
+ autocommit=True,
+ )
+
+
+def reset_core() -> None:
+ with connect() as conn, conn.cursor() as cur:
+ cur.execute(f"DROP DATABASE IF EXISTS `{settings.mysql_core_database}`")
+ print(f" DROP DATABASE {settings.mysql_core_database}")
+ for name in SQL_FILES:
+ path = CORE_DIR / name
+ for stmt in split_statements(path.read_text(encoding="utf-8")):
+ cur.execute(stmt)
+ print(f" ✓ {name}")
+
+
+def run_demo_prepare() -> None:
+ """把演示态刷回去(风评有效期)。
+
+ 重建 jinrong_core 会让 `03-seed-customers.sql` 写死的风评有效期回到「已过期」状态,
+ 于是 `tests/conftest.py::ensure_risk_demo_ready` 判定演示数据未就位,
+ **整个真库集成模块被 skip**(实测 516 → 505 passed)。这是 reset 的既定伴随步骤
+ (演示 SOP §2),reset.ps1 因不含 agent 库步骤而未内置,本脚本补上。
+ """
+ path = ROOT / "scripts" / "demo" / "prepare_risk_demo.sql"
+ with connect(with_db=True) as conn, conn.cursor() as cur:
+ for stmt in split_statements(path.read_text(encoding="utf-8")):
+ cur.execute(stmt)
+ print(" ✓ prepare_risk_demo.sql(演示客户风评刷为「剩余 275 天」)")
+
+
+def band_of(hold_days: int) -> str:
+ for lo, hi, rate in BANDS:
+ if hold_days >= lo and (hi is None or hold_days < hi):
+ return rate
+ return "?"
+
+
+def run_assertions() -> bool:
+ ok = True
+ with connect(with_db=True) as conn, conn.cursor() as cur:
+ # ---- DoD ① 赎回费 5 档,且每只产品各 5 档 ----
+ cur.execute("SELECT COUNT(DISTINCT min_hold_days) FROM core_fee_rule WHERE fee_type='redeem'")
+ n_bands = cur.fetchone()[0]
+ cur.execute(
+ "SELECT product_id, COUNT(*) FROM core_fee_rule WHERE fee_type='redeem' "
+ "GROUP BY product_id HAVING COUNT(*) <> 5"
+ )
+ uneven = cur.fetchall()
+ cur.execute("SELECT COUNT(*), COUNT(DISTINCT product_id) FROM core_fee_rule WHERE fee_type='redeem'")
+ total_rows, n_products = cur.fetchone()
+ p1 = n_bands == 5 and not uneven
+ print(f"\n① 赎回费档位:DISTINCT min_hold_days={n_bands}(期望 5)· "
+ f"{n_products} 只产品 × {total_rows // max(n_products, 1)} 档 · 非 5 档产品={uneven or '无'}")
+ if not p1:
+ print(" ✗ 不满足 5 档 / 每产品 5 档")
+ ok &= p1
+
+ # ---- DoD ② Σ remain_qty 逐行等于 core_holding.qty ----
+ cur.execute(
+ """
+ SELECT h.customer_id, h.product_id, h.qty, COALESCE(SUM(l.remain_qty), 0) AS lot_sum
+ FROM core_holding h
+ LEFT JOIN core_share_lot l
+ ON l.customer_id = h.customer_id AND l.product_id = h.product_id
+ GROUP BY h.customer_id, h.product_id, h.qty
+ HAVING ABS(lot_sum - h.qty) > 0.0001
+ """
+ )
+ mismatch = cur.fetchall()
+ cur.execute("SELECT COUNT(*) FROM core_holding")
+ n_holding = cur.fetchone()[0]
+ cur.execute("SELECT COUNT(*), COUNT(DISTINCT CONCAT(customer_id, '|', product_id)) FROM core_share_lot")
+ n_lots, n_pairs = cur.fetchone()
+ p2 = not mismatch
+ print(f"② 批次守恒:{n_holding} 行持仓 vs {n_lots} 行批次({n_pairs} 个客户-产品对)· "
+ f"失配={mismatch or '无'}")
+ if not p2:
+ print(" ✗ 存在 Σ remain_qty ≠ qty 的持仓行")
+ ok &= p2
+
+ # ---- DoD ③ 三列无 NULL 且存在不同费率对 ----
+ cur.execute(
+ "SELECT COUNT(*) FROM core_product "
+ "WHERE fund_company IS NULL OR ta_code IS NULL OR subscribe_fee_rate IS NULL"
+ )
+ n_null = cur.fetchone()[0]
+ cur.execute("SELECT COUNT(DISTINCT subscribe_fee_rate) FROM core_product")
+ n_rates = cur.fetchone()[0]
+ cur.execute(
+ "SELECT product_id, subscribe_fee_rate FROM core_product "
+ "WHERE product_id IN ('PROD-110022','PROD-003095','PROD-005827','PROD-510300','PROD-000001') "
+ "ORDER BY product_id"
+ )
+ demo = cur.fetchall()
+ p3 = n_null == 0 and n_rates > 1
+ print(f"③ 机构/费率:NULL 行数={n_null}(期望 0)· DISTINCT 费率={n_rates}(期望 >1)")
+ print(f" 演示费率对:{[(p, str(r)) for p, r in demo]}")
+ if not p3:
+ print(" ✗ 存在 NULL 或不存在不同费率对")
+ ok &= p3
+
+ # ---- 附加:批次实际覆盖的档位(以 AS_OF 为参照) ----
+ cur.execute("SELECT lot_id, confirmed_at FROM core_share_lot")
+ rows = cur.fetchall()
+ seen: dict[str, int] = {}
+ for _lot, ts in rows:
+ b = band_of((AS_OF - ts.date()).days)
+ seen[b] = seen.get(b, 0) + 1
+ p4 = len(seen) == 5
+ print(f"④ 批次档位覆盖(参照 {AS_OF}):{dict(sorted(seen.items()))}")
+ if not p4:
+ print(" ✗ 未覆盖全部 5 档 → 费率档演示无数据")
+ ok &= p4
+
+ # ---- 附加:主演示客户 CUST-9527 确为跨批次 ----
+ cur.execute(
+ "SELECT product_id, COUNT(*) FROM core_share_lot WHERE customer_id='CUST-9527' "
+ "GROUP BY product_id ORDER BY product_id"
+ )
+ c9527 = cur.fetchall()
+ p5 = any(n >= 2 for _p, n in c9527)
+ print(f"⑤ CUST-9527 批次数:{c9527}(至少一只产品 ≥2 批)")
+ if not p5:
+ print(" ✗ 主演示客户无跨批次数据")
+ ok &= p5
+
+ # ---- DoD ⑥ 向后兼容:不带新列的老 INSERT 仍可执行(架构风险 #9)----
+ cur.execute("START TRANSACTION")
+ try:
+ cur.execute(
+ "INSERT INTO core_trade (trade_id, customer_id, product_id, trade_type, amount, traded_at) "
+ "VALUES ('TRD-BC-TEST', 'CUST-9527', 'PROD-110022', 'subscribe', 100.00, NOW(3))"
+ )
+ cur.execute("SELECT convert_group_id FROM core_trade WHERE trade_id = 'TRD-BC-TEST'")
+ gid = cur.fetchone()[0]
+ cur.execute(
+ "INSERT INTO core_product (product_id, product_name, product_type, min_risk_code, "
+ "min_subscribe_amount, requires_disclosure) VALUES "
+ "('PROD-BC-TEST', '向后兼容测试', 'bond', 'R1', 100.00, 0)"
+ )
+ cur.execute(
+ "SELECT can_subscribe, can_redeem, min_hold_action, subscribe_fee_rate, fund_company "
+ "FROM core_product WHERE product_id = 'PROD-BC-TEST'"
+ )
+ row = cur.fetchone()
+ p6 = (
+ gid is None
+ and row[0] == 1 and row[1] == 1
+ and row[2] == "force_transfer" and row[3] == 0 and row[4] is None
+ )
+ finally:
+ cur.execute("ROLLBACK")
+ print(f"⑥ 老 INSERT 向后兼容:core_trade.convert_group_id={gid}(期望 None)· "
+ f"core_product 新列默认值={row}(期望 1/1/force_transfer/0/None)")
+ if not p6:
+ print(" ✗ 不带新列的老 INSERT 失败或默认值不符")
+ ok &= p6
+
+ # ---- DoD ⑦ risk_convert_detail 建表即含 5 个 status 值(免二期 ALTER)----
+ doc = (ROOT / "docs" / "项目框架设计" / "表设计" / "02-mysql-agent专用.sql").read_text(encoding="utf-8")
+ m = re.search(r"CREATE TABLE risk_convert_detail \(.*?\n\) ENGINE=InnoDB[^;]*;", doc, re.S)
+ assert m, "02-mysql-agent专用.sql 中未找到 risk_convert_detail 建表语句"
+ tmp_db = "jinrong_tmp_check"
+ with connect() as conn, conn.cursor() as cur:
+ cur.execute(f"DROP DATABASE IF EXISTS {tmp_db}")
+ cur.execute(f"CREATE DATABASE {tmp_db}")
+ cur.execute(f"USE {tmp_db}")
+ cur.execute(m.group(0))
+ cur.execute(
+ "SELECT COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = %s "
+ "AND TABLE_NAME = 'risk_convert_detail' AND COLUMN_NAME = 'status'",
+ (tmp_db,),
+ )
+ col_type = cur.fetchone()[0]
+ cur.execute(f"DROP DATABASE {tmp_db}")
+ vals = set(re.findall(r"'(\w+)'", col_type))
+ p7 = vals == {"pending", "completed", "failed", "cancelled", "expired"}
+ print(f"⑦ risk_convert_detail.status = {col_type}")
+ if not p7:
+ print(f" ✗ ENUM 值不全(实际 {vals})")
+ ok &= p7
+
+ # ---- DoD ⑧ 费率档必须与 product_type 匹配(自检第 12 问的机器化)----
+ # 22 号文 §8 的费率档由产品类型决定:stock ≤0.80% / mixed ≤0.50% /
+ # bond·index ≤0.30% / money = 0。任何「升档凑数值差」都会在此暴露。
+ CAPS = {"stock": 0.0080, "mixed": 0.0050, "bond": 0.0030, "index": 0.0030, "money": 0.0}
+ # 非公募主体:不适用 22 号文公募费率规定,本项目建模为费率 0 且不与公募互转
+ NOT_APPLICABLE = {"wealth_mgmt", "private_fund"}
+ with connect(with_db=True) as conn, conn.cursor() as cur:
+ cur.execute("SELECT product_id, product_type, subscribe_fee_rate FROM core_product")
+ rows = cur.fetchall()
+ offenders = [
+ (pid, ptype, float(rate))
+ for pid, ptype, rate in rows
+ if ptype in CAPS and float(rate) > CAPS[ptype] + 1e-9
+ ]
+ unmatched = [r for r in rows if r[1] not in CAPS and r[1] not in NOT_APPLICABLE]
+ na_offenders = [
+ (pid, ptype, float(rate))
+ for pid, ptype, rate in rows
+ if ptype in NOT_APPLICABLE and float(rate) != 0
+ ]
+ # 主示例两端必须同主体(同管理人 + 同 TA),否则转换前置约束不成立
+ cur.execute(
+ "SELECT DISTINCT fund_company, ta_code FROM core_product "
+ "WHERE product_id IN ('PROD-110022', 'PROD-003095') AND fund_company IS NOT NULL"
+ )
+ entities = cur.fetchall()
+ # 主演示组内费率档必须多样(否则补差费/同费率对照无数据)
+ cur.execute(
+ "SELECT COUNT(DISTINCT subscribe_fee_rate) FROM core_product "
+ "WHERE fund_company = '华夏模拟基金' AND ta_code = 'TA-CN-001'"
+ )
+ main_group_rates = cur.fetchone()[0]
+ p8 = (
+ not offenders and not unmatched and not na_offenders
+ and len(entities) == 1 and main_group_rates >= 3
+ )
+ print(f"⑧ 费率档 ↔ product_type 匹配:越档={offenders or '无'} · 未归类={unmatched or '无'} · "
+ f"非公募费率非 0={na_offenders or '无'}")
+ print(f" 主示例两端主体={entities}(须唯一)· 华夏组费率档数={main_group_rates}(须 ≥3)")
+ if not p8:
+ print(" ✗ 存在「按更宽松档次取费率」或主示例两端非同主体 / 主组费率档不足")
+ ok &= p8
+
+ return ok
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--no-reset", action="store_true", help="跳过重建,只跑断言")
+ args = ap.parse_args()
+
+ if not args.no_reset:
+ print("== 重建 jinrong_core(等价 reset.ps1 的 SQL 部分)==")
+ reset_core()
+ print("== 恢复演示态 ==")
+ run_demo_prepare()
+
+ print("\n== T-1 DoD 断言 ==")
+ ok = run_assertions()
+ print("\n" + ("全部 PASS ✅" if ok else "存在 FAIL ❌"))
+ return 0 if ok else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/_ddl.py b/tests/_ddl.py
index fa137bc..2c026ea 100644
--- a/tests/_ddl.py
+++ b/tests/_ddl.py
@@ -45,19 +45,77 @@ SQLITE_TABLES: dict[str, str] = {
product_id VARCHAR(64) PRIMARY KEY, product_name VARCHAR(128),
min_risk_code VARCHAR(8), product_type VARCHAR(32),
min_subscribe_amount DECIMAL DEFAULT 1.00, term_days INTEGER,
- requires_disclosure TINYINT DEFAULT 0)
+ requires_disclosure TINYINT DEFAULT 0,
+ can_subscribe TINYINT DEFAULT 1, can_redeem TINYINT DEFAULT 1,
+ min_hold_qty DECIMAL DEFAULT 0, min_redeem_qty DECIMAL DEFAULT 0,
+ min_hold_action VARCHAR(32) DEFAULT 'force_transfer',
+ subscribe_fee_rate DECIMAL DEFAULT 0,
+ fund_company VARCHAR(64), ta_code VARCHAR(32))
""",
+ # T-0(R1 门禁):以 scripts/core/01-ddl.sql:132-145 为权威逐列对齐 ——
+ # id PK / customer_id / product_id / qty / cost_amount / market_value /
+ # pnl_pct / as_of + UNIQUE(customer_id, product_id)。
+ # 原列名 quantity 已废弃(MySQL 恒为 qty,列名失配是 convert 首个阻断项)。
+ # 不建外键:sqlite 测试库既有表均无 FK,保持一致(避免插入顺序约束)。
"core_holding": """
CREATE TABLE core_holding (
- customer_id VARCHAR(64), product_id VARCHAR(64),
- market_value DECIMAL, quantity DECIMAL)
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ customer_id VARCHAR(64) NOT NULL, product_id VARCHAR(64) NOT NULL,
+ qty DECIMAL NOT NULL, cost_amount DECIMAL NOT NULL,
+ market_value DECIMAL NOT NULL, pnl_pct DECIMAL NOT NULL, as_of DATE NOT NULL,
+ UNIQUE (customer_id, product_id))
""",
"core_trade": """
CREATE TABLE core_trade (
trade_id VARCHAR(64) PRIMARY KEY, customer_id VARCHAR(64), product_id VARCHAR(64),
- trade_type VARCHAR(16), amount DECIMAL, trade_status VARCHAR(16),
+ trade_type VARCHAR(16), amount DECIMAL, qty DECIMAL,
+ convert_group_id VARCHAR(64), trade_status VARCHAR(16),
traded_at TIMESTAMP)
""",
+ # ---- convert 新增 4 表(T-1):MySQL 侧落点为
+ # scripts/core/01-ddl.sql(前 3 张)与 docs/项目框架设计/表设计/02-mysql-agent专用.sql
+ # (risk_convert_detail)。列集为测试所需列的并集,新列只改此处。
+ "core_fee_rule": """
+ CREATE TABLE core_fee_rule (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, product_id VARCHAR(64) NOT NULL,
+ fee_type VARCHAR(16) NOT NULL, min_hold_days INTEGER, max_hold_days INTEGER,
+ min_amount DECIMAL, max_amount DECIMAL, rate DECIMAL NOT NULL,
+ to_fund_ratio DECIMAL NOT NULL DEFAULT 1.0)
+ """,
+ "core_share_lot": f"""
+ CREATE TABLE core_share_lot (
+ lot_id VARCHAR(64) PRIMARY KEY, customer_id VARCHAR(64) NOT NULL,
+ product_id VARCHAR(64) NOT NULL, qty DECIMAL NOT NULL, remain_qty DECIMAL NOT NULL,
+ nav DECIMAL NOT NULL, confirmed_at TIMESTAMP NOT NULL,
+ source_trade_id VARCHAR(64), created_at {_TS})
+ """,
+ "core_convert_lot_detail": f"""
+ CREATE TABLE core_convert_lot_detail (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, convert_group_id VARCHAR(64) NOT NULL,
+ lot_id VARCHAR(64) NOT NULL, qty DECIMAL NOT NULL, hold_days INTEGER NOT NULL,
+ amount DECIMAL NOT NULL, fee_rate DECIMAL NOT NULL, fee_amount DECIMAL NOT NULL,
+ nav DECIMAL NOT NULL, nav_date DATE NOT NULL, created_at {_TS})
+ """,
+ "risk_convert_detail": f"""
+ CREATE TABLE risk_convert_detail (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, convert_group_id VARCHAR(64) NOT NULL,
+ status VARCHAR(16) NOT NULL DEFAULT 'pending',
+ out_trade_id VARCHAR(64), in_trade_id VARCHAR(64), related_trade_id VARCHAR(64),
+ nav DECIMAL, nav_date DATE, fee_amount DECIMAL,
+ hold_days_min INTEGER, hold_days_max INTEGER,
+ estimated TINYINT NOT NULL DEFAULT 1, nav_stale TINYINT NOT NULL DEFAULT 0,
+ client_request_id VARCHAR(64), cancelled_at TIMESTAMP,
+ created_at {_TS},
+ UNIQUE (convert_group_id), UNIQUE (client_request_id))
+ """,
+ # T-0(R-f):core_product_nav 与 MySQL scripts/core/01-ddl.sql:186-194 对齐
+ # (get_latest_nav 及新增 get_as_of 的读取目标;此前 sqlite 侧整表缺失)。
+ "core_product_nav": """
+ CREATE TABLE core_product_nav (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, product_id VARCHAR(64) NOT NULL,
+ nav DECIMAL NOT NULL, daily_chg_pct DECIMAL NOT NULL, nav_date DATE NOT NULL,
+ UNIQUE (product_id, nav_date))
+ """,
# ---- agent 库 ----
"audit_log": f"""
CREATE TABLE audit_log (
@@ -138,14 +196,65 @@ SQLITE_TABLES: dict[str, str] = {
}
+# ---------- T-0 建库自校验(R1 门禁)----------
+# 与 scripts/core/01-ddl.sql 人工同步的权威结构清单(逐列对照,见上表注释):
+# core_holding id BIGINT PK / customer_id / product_id / qty / cost_amount /
+# market_value / pnl_pct / as_of / UNIQUE KEY uk_cust_product
+# core_product_nav id BIGINT PK / product_id / nav / daily_chg_pct / nav_date /
+# UNIQUE KEY uk_product_date
+# 失配在建库那一刻即 AssertionError,不留到集成测试期。
+EXPECTED_CORE_HOLDING_COLUMNS = {
+ "id", "customer_id", "product_id", "qty",
+ "cost_amount", "market_value", "pnl_pct", "as_of",
+}
+REQUIRED_CORE_TABLES = {"core_holding", "core_product_nav", "core_trade", "core_product"}
+# T-1:convert 新增 4 表同样纳入存在性门禁。落点:前 3 张在 scripts/core/01-ddl.sql,
+# risk_convert_detail 在 docs/项目框架设计/表设计/02-mysql-agent专用.sql。
+REQUIRED_CONVERT_TABLES = {
+ "core_fee_rule", "core_share_lot", "core_convert_lot_detail", "risk_convert_detail",
+}
+
+
+def _assert_ddl_aligned(engine) -> None:
+ """建库后自校验:缺列/缺表立即 AssertionError(R1 门禁)。
+
+ 触发场景(真实踩过的坑):sqlite 列名曾为 quantity,而 MySQL 是 qty ——
+ 单测全绿但 convert 首次 UPDATE core_holding.qty 才炸;本校验把失败点前移。
+ """
+ with engine.connect() as conn:
+ tables = {
+ row[0]
+ for row in conn.execute(text("SELECT name FROM sqlite_master WHERE type = 'table'"))
+ }
+ missing_tables = REQUIRED_CORE_TABLES - tables
+ assert not missing_tables, (
+ f"sqlite DDL 缺表:{sorted(missing_tables)}(以 scripts/core/01-ddl.sql 为准)"
+ )
+ missing_convert = REQUIRED_CONVERT_TABLES - tables
+ assert not missing_convert, (
+ f"sqlite DDL 缺 convert 表:{sorted(missing_convert)}"
+ "(以 scripts/core/01-ddl.sql 与 docs/项目框架设计/表设计/02-mysql-agent专用.sql 为准)"
+ )
+ columns = {row[1] for row in conn.execute(text("PRAGMA table_info(core_holding)"))}
+ missing_columns = EXPECTED_CORE_HOLDING_COLUMNS - columns
+ assert not missing_columns, (
+ f"core_holding 缺列:{sorted(missing_columns)}(以 scripts/core/01-ddl.sql 为准)"
+ )
+
+
def create_sqlite_engine():
- """内存 sqlite + 全部表(StaticPool 单连接共享,同现有测试模式)。"""
+ """内存 sqlite + 全部表(StaticPool 单连接共享,同现有测试模式)。
+
+ 建表后经 `_assert_ddl_aligned` 自校验(T-0 门禁):结构失配在建库即失败,
+ 而不是留到集成测试期。
+ """
engine = create_engine(
"sqlite://", poolclass=StaticPool, connect_args={"check_same_thread": False}
)
with engine.begin() as conn:
for ddl in SQLITE_TABLES.values():
conn.execute(text(ddl))
+ _assert_ddl_aligned(engine)
return engine
diff --git a/tests/conftest.py b/tests/conftest.py
index 6fae557..22cf0d1 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -180,8 +180,10 @@ def ensure_risk_demo_ready() -> None:
hint = "先跑 FLOW §0 ③④:scripts/core/reset.ps1 → 01-mysql-共用底座.sql →" " 02-mysql-agent专用.sql → scripts/agent/seed-aml-list.sql → prepare_risk_demo.sql"
try:
- core = get_engine(settings.mysql_core_database)
- agent = get_engine(settings.mysql_database)
+ # T-0b(R-e):显式 admin —— 本函数与 fixture 的 setup/teardown 需
+ # INSERT/DELETE,而 xh_core_rw 无 DELETE、xh_agent_rw 亦无 DELETE。
+ core = get_engine(settings.mysql_core_database, role="admin")
+ agent = get_engine(settings.mysql_database, role="admin")
with core.connect() as conn:
# FM-03 口径(AL-05 换核):expires_at 未过期即就位(原 evaluated_at+365 天口径退役)
days = conn.execute(
@@ -229,8 +231,10 @@ def risk_demo_env():
from app.config.settings import settings
from app.utils.db import dispose_engines, get_engine
- core = get_engine(settings.mysql_core_database)
- agent = get_engine(settings.mysql_database)
+ # T-0b(R-e):teardown 需 DELETE(core_trade / risk_alert / audit_log),
+ # 只读账号与业务读写账号均无 DELETE 权限 → 显式 admin(= mysql_user)。
+ core = get_engine(settings.mysql_core_database, role="admin")
+ agent = get_engine(settings.mysql_database, role="admin")
started_at = datetime.now()
l3_snapshot: list = [] # 哨兵:setup 失败时 teardown 不因未绑定变量掩盖原始异常
try:
diff --git a/tests/test_chat_tools.py b/tests/test_chat_tools.py
index 36f0f2f..2b0d219 100644
--- a/tests/test_chat_tools.py
+++ b/tests/test_chat_tools.py
@@ -78,8 +78,9 @@ def tool_env(monkeypatch):
)
conn.execute(
text(
- "INSERT INTO core_holding (customer_id, product_id, market_value, quantity)"
- " VALUES ('CUST-9527', 'P-001', 50000.00, 100)"
+ "INSERT INTO core_holding (customer_id, product_id, market_value, qty,"
+ " cost_amount, pnl_pct, as_of)"
+ " VALUES ('CUST-9527', 'P-001', 50000.00, 100, 50000.00, 0, '2026-09-04')"
)
)
conn.execute(
diff --git a/tests/test_concentration_c4.py b/tests/test_concentration_c4.py
index 98e0be7..006cc3f 100644
--- a/tests/test_concentration_c4.py
+++ b/tests/test_concentration_c4.py
@@ -71,8 +71,10 @@ def env(monkeypatch):
)
conn.execute(
text(
- "INSERT INTO core_holding (customer_id, product_id, market_value, quantity)"
- " VALUES ('C1', 'P2', 900000, 1000), ('C1', 'P1', 100000, 500)"
+ "INSERT INTO core_holding (customer_id, product_id, market_value, qty,"
+ " cost_amount, pnl_pct, as_of)"
+ " VALUES ('C1', 'P2', 900000, 1000, 900000, 0, '2026-09-04'),"
+ " ('C1', 'P1', 100000, 500, 100000, 0, '2026-09-04')"
)
)
core = CoreReadOnlyRepository(engine=engine)
diff --git a/tests/test_convert_calc.py b/tests/test_convert_calc.py
new file mode 100644
index 0000000..23d26b7
--- /dev/null
+++ b/tests/test_convert_calc.py
@@ -0,0 +1,619 @@
+"""T-2 基金转换纯函数单测(开发计划 §4.2 DoD)。
+
+覆盖 12 类:
+
+1. **精度**:全部量化点必须 `ROUND_HALF_UP`(`Decimal` 默认是 `ROUND_HALF_EVEN`,
+ 架构风险 #3 —— `.5` 边界处两种舍入结果不同,必须命中 HALF_UP 那一侧);
+2. **分档边界**:`6/7/29/30/179/180/364/365`(左闭右开,满 7 日归 7–30 档);
+3. **FIFO**:主序 `confirmed_at`、同 `confirmed_at` 以 `lot_id` 兜底(评审 S1);
+4. **跨批次计费**:逐批先舍入后求和(PRD §5.3 主示例 154.50 + 309.00);
+5. **补差费双口径**:B `252.40` / A `253.91`(同输入,差约 0.6%);同费率归零;
+6. **最低持有处置**:`force_transfer` / `force_redeem` 双动作、
+ **恰好等于阈值不触发**、**零剩余不触发**(实现级裁定);
+7. **全链自证**:PRD §5.3 主示例每个数字逐项复算(含 `rounding_diff = -0.0026`);
+8. **持有期**:不含申请日、T+1 起算少 1 天;
+9. **净值**:无净值 → 503;过期 → 只标 `nav_stale` 不阻断;
+10. **批次补建**:D18 确定性(`crc32` 而非内置 `hash()`);
+11. **错误码映射**:§8.3 全表;
+12. **纯函数约束**:convert 包源码零仓储/引擎/配置依赖(DoD 第 3 条,
+ 从"人工 grep"升级为断言)。
+
+本文件**不依赖 DB、不依赖 conftest 夹具**——纯函数包的全部输入由测试自建。
+"""
+
+from __future__ import annotations
+
+import re
+import zlib
+from datetime import date, datetime, time, timedelta
+from decimal import Decimal
+from pathlib import Path
+
+import pytest
+
+from app.service.convert import calc, fee, lot_bootstrap, nav
+from app.service.convert.errors import (
+ BelowMinQty,
+ ConvertError,
+ CrossEntityNotSupported,
+ FeeRuleMissing,
+ IdempotencyUnavailable,
+ InsufficientShares,
+ LotConflict,
+ NavNotReady,
+ ProductNotRedeemable,
+ ProductNotSubscribable,
+ SameProduct,
+ TooManyLots,
+)
+from app.service.convert.types import FeeRule, Lot
+
+# ── 公共夹具数据 ─────────────────────────────────────────────────────
+#: 与 PRD §5.3 示例同一交易日(也是 `06-seed-nav.sql` 的净值日期)。
+TRADE_DATE = date(2026, 9, 4)
+#: PRD §4.3 / 07-seed-fee-rule.sql 的五档赎回费(22 号文 §10 下限)。
+RULES = [
+ FeeRule(min_hold_days=0, max_hold_days=7, rate=Decimal("0.0150")),
+ FeeRule(min_hold_days=7, max_hold_days=30, rate=Decimal("0.0100")),
+ FeeRule(min_hold_days=30, max_hold_days=180, rate=Decimal("0.0050")),
+ FeeRule(min_hold_days=180, max_hold_days=365, rate=Decimal("0.0025")),
+ FeeRule(min_hold_days=365, max_hold_days=None, rate=Decimal("0.0000")),
+]
+#: 主示例两端申购费率(09-seed-org.sql:110022 债基 0.0030 → 003095 主动偏股 0.0080)。
+OUT_SUB_RATE = Decimal("0.0030")
+IN_SUB_RATE = Decimal("0.0080")
+
+
+def _lot(lot_id: str, hold_days: int, qty: str, nav: str = "1.0300") -> Lot:
+ """构造 `hold_days` 天前确认的批次(交易日固定为 TRADE_DATE)。"""
+ confirmed = datetime.combine(TRADE_DATE - timedelta(days=hold_days), time(10, 0, 0))
+ return Lot(
+ lot_id=lot_id,
+ confirmed_at=confirmed,
+ remain_qty=Decimal(qty),
+ nav=Decimal(nav),
+ qty=Decimal(qty),
+ )
+
+
+def _fee_of(lot_allocation, hold_days: int) -> Decimal:
+ """按 `plan_lots` 的分配结果算单批金额与赎回费。"""
+ amount = calc.lot_amount(lot_allocation.qty, lot_allocation.nav)
+ return calc.lot_fee(amount, fee.pick_fee_rate(RULES, hold_days))
+
+
+# ── 1. 精度:HALF_UP 而非 HALF_EVEN ─────────────────────────────────
+class TestRounding:
+ """所有量化点显式 `ROUND_HALF_UP`(架构风险 #3)。"""
+
+ def test_round2_half_up_away_from_zero(self):
+ # Decimal 默认 ROUND_HALF_EVEN 会得到 0.00(偶数侧);HALF_UP 必须是 0.01
+ assert calc.round2(Decimal("0.005")) == Decimal("0.01")
+ assert calc.round2(Decimal("0.015")) == Decimal("0.02")
+
+ def test_default_context_differs_from_half_up(self):
+ """反向自证:若实现漏传 rounding,本用例会红。"""
+ from decimal import ROUND_HALF_EVEN
+
+ banker = Decimal("0.005").quantize(Decimal("0.01"), rounding=ROUND_HALF_EVEN)
+ assert banker == Decimal("0.00")
+ assert calc.round2(Decimal("0.005")) != banker
+
+ def test_lot_amount_2_places_half_up(self):
+ # 0.5 份 × 1.0001 = 0.50005 → 0.50;1.5 份 × 0.0001 之类极小值也要能舍
+ assert calc.lot_amount(Decimal("1.5"), Decimal("0.0034")) == Decimal("0.01")
+ assert calc.lot_amount(Decimal("30000.0000"), Decimal("1.0300")) == Decimal("30900.00")
+
+ def test_lot_fee_2_places_half_up(self):
+ assert calc.lot_fee(Decimal("30900.00"), Decimal("0.0050")) == Decimal("154.50")
+ assert calc.lot_fee(Decimal("20600.00"), Decimal("0.0150")) == Decimal("309.00")
+
+ def test_in_qty_2_places_half_up_not_floor(self):
+ # v1.0 勘误:份额是 2 位四舍五入,不是 4 位向下取整
+ # 50784.10 / 0.95 = 53456.9473... → 53456.95(向下取整会得 53456.94)
+ assert calc.in_qty(Decimal("50784.10"), Decimal("0.9500")) == Decimal("53456.95")
+
+ def test_in_qty_rejects_non_positive_nav(self):
+ with pytest.raises(ValueError):
+ calc.in_qty(Decimal("100"), Decimal("0"))
+
+ def test_rounding_diff_negative_means_client_gains(self):
+ # 理论 53456.9474 − 实得 53456.95 = -0.0026(负 = 客户多得)
+ diff = calc.rounding_diff(Decimal("50784.10"), Decimal("0.9500"), Decimal("53456.95"))
+ assert diff == Decimal("-0.0026")
+
+
+# ── 2. 费率分档边界 ─────────────────────────────────────────────────
+class TestFeeBands:
+ """左闭右开 `[min, max)`;满 7 日归 7–30 档(PRD §12 I-2)。"""
+
+ @pytest.mark.parametrize(
+ ("hold_days", "expected"),
+ [
+ (0, "0.0150"),
+ (6, "0.0150"), # < 7 日
+ (7, "0.0100"), # 满 7 日 → 进 7–30 档
+ (29, "0.0100"),
+ (30, "0.0050"), # 满 30 日 → 进 30–180 档
+ (179, "0.0050"),
+ (180, "0.0025"),
+ (364, "0.0025"),
+ (365, "0.0000"),
+ (1000, "0.0000"), # max_hold_days = NULL 表示无上限
+ ],
+ )
+ def test_band_boundaries(self, hold_days, expected):
+ assert fee.pick_fee_rate(RULES, hold_days) == Decimal(expected)
+
+ def test_missing_rule_raises_instead_of_zero(self):
+ # 无命中绝不能静默按 0 收费(少收赎回费且不留痕)
+ with pytest.raises(FeeRuleMissing) as exc:
+ fee.pick_fee_rate([FeeRule(365, None, Decimal("0.0000"))], 3)
+ assert exc.value.error_code == "FEE_RULE_MISSING"
+ assert exc.value.status_code == 500
+
+ def test_subscribe_rules_are_ignored(self):
+ """`fee_type='subscribe'` 本期不启用,混入规则表也不得被选中。"""
+ rules = [
+ FeeRule(0, 7, Decimal("0.8000"), fee_type="subscribe"),
+ FeeRule(0, 7, Decimal("0.0150"), fee_type="redeem"),
+ ]
+ assert fee.pick_fee_rate(rules, 3) == Decimal("0.0150")
+
+ def test_overlapping_bands_take_most_specific(self):
+ """区间误配重叠时取 `min_hold_days` 最大者,避免"取到哪档看运气"。"""
+ rules = [
+ FeeRule(0, 365, Decimal("0.0050")),
+ FeeRule(30, 180, Decimal("0.0025")),
+ ]
+ # 排序后被 max(min_hold_days) 选中,与传入顺序无关
+ assert fee.pick_fee_rate(rules, 100) == Decimal("0.0025")
+ assert fee.pick_fee_rate(list(reversed(rules)), 100) == Decimal("0.0025")
+
+
+# ── 3. FIFO 排序与分配 ───────────────────────────────────────────────
+class TestFifoPlan:
+ def test_allocates_oldest_lot_first(self):
+ lots = [_lot("LOT-B", 3, "20000.0000"), _lot("LOT-A", 100, "30000.0000")]
+ plan = calc.plan_lots(lots, Decimal("40000"))
+ assert [a.lot_id for a in plan.allocations] == ["LOT-A", "LOT-B"]
+ assert plan.allocations[0].qty == Decimal("30000.0000")
+ assert plan.allocations[1].qty == Decimal("10000.0000")
+
+ def test_same_confirmed_at_tiebreak_by_lot_id(self):
+ """同一注册日多批次顺序不可由存储引擎决定(评审 S1)。"""
+ same = datetime.combine(TRADE_DATE - timedelta(days=10), time(10, 0, 0))
+ lots = [
+ Lot("LOT-Z", same, Decimal("100.0000"), Decimal("1.0000")),
+ Lot("LOT-A", same, Decimal("100.0000"), Decimal("1.0000")),
+ ]
+ plan = calc.plan_lots(lots, Decimal("150"))
+ assert [a.lot_id for a in plan.allocations] == ["LOT-A", "LOT-Z"]
+
+ def test_ignores_zero_remain_qty_lots(self):
+ lots = [
+ _lot("LOT-USED", 100, "1000.0000"),
+ _lot("LOT-FRESH", 3, "500.0000"),
+ ]
+ lots[0] = Lot(
+ lots[0].lot_id, lots[0].confirmed_at, Decimal("0.0000"), lots[0].nav
+ )
+ plan = calc.plan_lots(lots, Decimal("500"))
+ assert [a.lot_id for a in plan.allocations] == ["LOT-FRESH"]
+
+ def test_insufficient_shares(self):
+ lots = [_lot("LOT-A", 100, "1000.0000")]
+ with pytest.raises(InsufficientShares) as exc:
+ calc.plan_lots(lots, Decimal("1000.0001"))
+ assert exc.value.error_code == "INSUFFICIENT_SHARES"
+
+ def test_requested_must_be_positive(self):
+ with pytest.raises(ValueError):
+ calc.plan_lots([_lot("LOT-A", 100, "1000")], Decimal("0"))
+
+ def test_batch_count_equals_allocations_len(self):
+ lots = [_lot(f"LOT-{i}", 100 - i, "1000.0000") for i in range(3)]
+ plan = calc.plan_lots(lots, Decimal("2500"))
+ assert plan.batch_count == len(plan.allocations) == 3
+
+ def test_available_qty_uses_remain_qty(self):
+ """份额足够判据以 `Σ remain_qty` 为准,不用 `core_holding.qty`(PRD §12)。"""
+ lots = [_lot("LOT-A", 100, "1000.0000")]
+ lots[0] = Lot(
+ lots[0].lot_id,
+ lots[0].confirmed_at,
+ Decimal("300.0000"),
+ lots[0].nav,
+ qty=Decimal("1000.0000"), # 原始份额更大,但已扣减
+ )
+ plan = calc.plan_lots(lots, Decimal("300"))
+ assert plan.available_qty == Decimal("300.0000")
+
+
+# ── 4. 最低持有余额处置 ──────────────────────────────────────────────
+class TestMinHoldAction:
+ """触发条件严格为「余额 < 阈值」(非 ≤);PRD §12 I-4。"""
+
+ def test_below_threshold_forces_full_transfer(self):
+ lots = [_lot("LOT-A", 100, "6000.0000")]
+ plan = calc.plan_lots(
+ lots, Decimal("5500"), Decimal("1000"), "force_transfer"
+ )
+ assert plan.forced_full_transfer is True
+ assert plan.action == "force_transfer"
+ assert plan.actual_qty == Decimal("6000.0000") # 全转,客户指令被改变
+ assert plan.requested_qty == Decimal("5500")
+
+ def test_below_threshold_force_redeem_action(self):
+ lots = [_lot("LOT-A", 100, "6000.0000")]
+ plan = calc.plan_lots(lots, Decimal("5500"), Decimal("1000"), "force_redeem")
+ assert plan.action == "force_redeem"
+ assert plan.actual_qty == Decimal("6000.0000")
+
+ def test_leftover_equal_threshold_not_triggered(self):
+ """PRD 审查例:持 6000、申请 5000、阈值 1000 → 余额恰好 1000 → 不触发。"""
+ lots = [_lot("LOT-A", 100, "6000.0000")]
+ plan = calc.plan_lots(lots, Decimal("5000"), Decimal("1000"), "force_transfer")
+ assert plan.forced_full_transfer is False
+ assert plan.action == "transfer"
+ assert plan.actual_qty == Decimal("5000")
+
+ def test_zero_leftover_not_forced(self):
+ """实现级裁定:客户本就是清仓(余额 = 0)不得被标记为"强制"。
+
+ 否则响应里的 `forced_full_transfer=True` 会被前端当成
+ "你的指令被系统改了",而实际转出份额与申请完全一致。
+ """
+ lots = [_lot("LOT-A", 100, "6000.0000")]
+ plan = calc.plan_lots(lots, Decimal("6000"), Decimal("1000"), "force_transfer")
+ assert plan.forced_full_transfer is False
+ assert plan.actual_qty == plan.requested_qty == Decimal("6000.0000")
+
+ def test_zero_threshold_disables_check(self):
+ lots = [_lot("LOT-A", 100, "6000.0000")]
+ plan = calc.plan_lots(lots, Decimal("5000"), Decimal("0"), "force_transfer")
+ assert plan.forced_full_transfer is False
+
+ def test_unknown_action_rejected(self):
+ lots = [_lot("LOT-A", 100, "6000.0000")]
+ with pytest.raises(ValueError):
+ calc.plan_lots(lots, Decimal("5500"), Decimal("1000"), "force_dance")
+
+
+# ── 5. 补差费双口径 ─────────────────────────────────────────────────
+class TestDiffFee:
+ CONV = Decimal("51036.50")
+
+ def test_amount_diff_is_default_and_matches_prd(self):
+ # 405.05(转入端 0.8% 价外费)− 152.65(转出端 0.3% 价外费)
+ assert calc.diff_fee(self.CONV, OUT_SUB_RATE, IN_SUB_RATE) == Decimal("252.40")
+ assert calc.diff_fee(
+ self.CONV, OUT_SUB_RATE, IN_SUB_RATE, "amount_diff"
+ ) == Decimal("252.40")
+
+ def test_rate_diff_matches_prd(self):
+ assert calc.diff_fee(
+ self.CONV, OUT_SUB_RATE, IN_SUB_RATE, "rate_diff"
+ ) == Decimal("253.91")
+
+ def test_two_modes_actually_differ(self):
+ """两口径差约 0.6% —— 必须显式选口径,不可混用(PRD §2.1.1 / Q9)。"""
+ b = calc.diff_fee(self.CONV, OUT_SUB_RATE, IN_SUB_RATE, "amount_diff")
+ a = calc.diff_fee(self.CONV, OUT_SUB_RATE, IN_SUB_RATE, "rate_diff")
+ assert a != b
+
+ @pytest.mark.parametrize("mode", ["amount_diff", "rate_diff"])
+ def test_zero_when_in_rate_not_higher(self, mode):
+ assert calc.diff_fee(self.CONV, IN_SUB_RATE, OUT_SUB_RATE, mode) == Decimal("0.00")
+ assert calc.diff_fee(self.CONV, OUT_SUB_RATE, OUT_SUB_RATE, mode) == Decimal("0.00")
+
+ def test_zero_rate_inbound_gives_zero(self):
+ """转入端为货基(申购费 0)→ 不补差。"""
+ assert calc.diff_fee(self.CONV, OUT_SUB_RATE, Decimal("0"), "amount_diff") == Decimal("0.00")
+
+ def test_invalid_mode_rejected(self):
+ with pytest.raises(ValueError):
+ calc.diff_fee(self.CONV, OUT_SUB_RATE, IN_SUB_RATE, "whatever")
+
+
+# ── 6. 全链自证(PRD §5.3 逐行) ─────────────────────────────────────
+class TestPrdMainExample:
+ """主示例每个派生值都由本块数据算出(v0.9.1 · 脚本实算回填)。"""
+
+ @pytest.fixture
+ def chain(self):
+ lots = [_lot("LOT-A", 100, "30000.0000"), _lot("LOT-B", 3, "20000.0000")]
+ plan = calc.plan_lots(lots, Decimal("50000"))
+ out_amount = sum(
+ (calc.lot_amount(a.qty, a.nav) for a in plan.allocations), Decimal("0")
+ )
+ redeem_fee = sum(
+ (
+ _fee_of(a, calc.hold_days(TRADE_DATE, a.confirmed_at))
+ for a in plan.allocations
+ ),
+ Decimal("0"),
+ )
+ return plan, out_amount, redeem_fee
+
+ def test_per_lot_breakdown(self, chain):
+ plan, _, _ = chain
+ assert [a.qty for a in plan.allocations] == [
+ Decimal("30000.0000"),
+ Decimal("20000.0000"),
+ ]
+ assert calc.hold_days(TRADE_DATE, plan.allocations[0].confirmed_at) == 100
+ assert calc.hold_days(TRADE_DATE, plan.allocations[1].confirmed_at) == 3
+ assert _fee_of(plan.allocations[0], 100) == Decimal("154.50")
+ assert _fee_of(plan.allocations[1], 3) == Decimal("309.00")
+
+ def test_amounts_chain(self, chain):
+ plan, out_amount, redeem_fee = chain
+ assert out_amount == Decimal("51500.00")
+ assert redeem_fee == Decimal("463.50") # 逐批舍入后求和
+ conv = calc.convert_amount(out_amount, redeem_fee)
+ assert conv == Decimal("51036.50")
+
+ diff_b = calc.diff_fee(conv, OUT_SUB_RATE, IN_SUB_RATE, "amount_diff")
+ assert diff_b == Decimal("252.40")
+ in_amount = calc.convert_amount(conv, diff_b)
+ assert in_amount == Decimal("50784.10")
+ assert calc.in_qty(in_amount, Decimal("0.9500")) == Decimal("53456.95")
+ assert (
+ calc.rounding_diff(in_amount, Decimal("0.9500"), Decimal("53456.95"))
+ == Decimal("-0.0026")
+ )
+
+ def test_three_amount_fields_are_distinct(self, chain):
+ """`out_amount` / `convert_amount` / `in_amount` 三者不可混用(PRD M-4)。"""
+ _, out_amount, redeem_fee = chain
+ conv = calc.convert_amount(out_amount, redeem_fee)
+ in_amount = calc.convert_amount(conv, Decimal("252.40"))
+ assert out_amount != conv != in_amount
+ assert out_amount == Decimal("51500.00")
+ assert conv == Decimal("51036.50")
+ assert in_amount == Decimal("50784.10")
+
+ def test_same_rate_control_group(self, chain):
+ """同费率对照(110022 0.0030 → 510300 0.0030):补差 0,份额 53722.63。"""
+ _, out_amount, redeem_fee = chain
+ conv = calc.convert_amount(out_amount, redeem_fee)
+ diff = calc.diff_fee(conv, OUT_SUB_RATE, OUT_SUB_RATE, "amount_diff")
+ assert diff == Decimal("0.00")
+ in_amount = calc.convert_amount(conv, diff)
+ assert in_amount == conv == Decimal("51036.50")
+ assert calc.in_qty(in_amount, Decimal("0.9500")) == Decimal("53722.63")
+
+
+# ── 7. 持有期 ───────────────────────────────────────────────────────
+class TestHoldDays:
+ def test_excludes_application_day(self):
+ """(交易日 − 确认日).days,不含申请日:昨日确认今天申请 = 1 天。"""
+ assert calc.hold_days(date(2026, 9, 4), datetime(2026, 9, 3, 10)) == 1
+
+ def test_t_plus_one_confirmation_is_one_day_less(self):
+ """转换转入批次以 T+1 为确认日 → 同一交易日下持有期比"自 T 起算"少 1 天。
+
+ 这是更贴近真实的做法(真实持有期自确认日起算),费率档因此更严(PRD B-5)。
+ """
+ trade = date(2026, 9, 4)
+ from_t = calc.hold_days(trade, datetime(2026, 8, 25, 10))
+ from_t1 = calc.hold_days(trade, datetime(2026, 8, 26, 10))
+ assert from_t == 10
+ assert from_t1 == 9
+
+ def test_full_seven_days_lands_in_second_band(self):
+ assert calc.hold_days(date(2026, 9, 10), datetime(2026, 9, 3, 10)) == 7
+ assert fee.pick_fee_rate(RULES, 7) == Decimal("0.0100")
+
+ def test_rejects_none(self):
+ with pytest.raises(ValueError):
+ calc.hold_days(None, datetime(2026, 9, 3, 10))
+ with pytest.raises(ValueError):
+ calc.hold_days(date(2026, 9, 4), None)
+
+
+# ── 8. 净值口径 ─────────────────────────────────────────────────────
+class TestNav:
+ def test_missing_nav_raises_503(self):
+ with pytest.raises(NavNotReady) as exc:
+ nav.ensure_nav_ready(None, product_id="PROD-X")
+ assert exc.value.status_code == 503
+ assert exc.value.error_code == "NAV_NOT_READY"
+
+ def test_present_nav_passes(self):
+ nav.ensure_nav_ready(date(2026, 9, 4), product_id="PROD-110022")
+
+ @pytest.mark.parametrize(
+ ("nav_date", "expected"),
+ [("2026-09-04", False), ("2026-09-01", False), ("2026-08-31", True)],
+ )
+ def test_stale_boundary_is_strictly_greater(self, nav_date, expected):
+ # 阈值 3 天:距交易日恰好 3 天不算过期(PRD §2.2「超过此值」)
+ assert nav.is_stale(date.fromisoformat(nav_date), TRADE_DATE, 3) is expected
+
+ def test_is_stale_rejects_missing_nav(self):
+ """"无净值"必须走 503,不得混进 stale 分支(两者处置完全不同)。"""
+ with pytest.raises(ValueError):
+ nav.is_stale(None, TRADE_DATE, 3)
+
+ def test_evaluate_nav_returns_stale_flag(self):
+ assert nav.evaluate_nav(date(2026, 9, 4), TRADE_DATE, 3) is False
+ assert nav.evaluate_nav(date(2026, 8, 1), TRADE_DATE, 3) is True
+
+ def test_evaluate_nav_raises_when_missing(self):
+ with pytest.raises(NavNotReady):
+ nav.evaluate_nav(None, TRADE_DATE, 3, product_id="PROD-X")
+
+
+# ── 9. 批次兜底补建(D8 / D18) ──────────────────────────────────────
+class TestBootstrapLots:
+ HOLDING = {
+ "customer_id": "CUST-9527",
+ "product_id": "PROD-110022",
+ "qty": Decimal("80000.0000"),
+ "cost_amount": Decimal("82400.00"),
+ "market_value": Decimal("82400.00"),
+ "pnl_pct": Decimal("0.0000"),
+ "as_of": date(2026, 9, 4),
+ }
+
+ def test_single_lot_equals_holding_qty(self):
+ lots = lot_bootstrap.bootstrap_lots(self.HOLDING)
+ assert len(lots) == 1
+ assert lots[0].remain_qty == lots[0].qty == Decimal("80000.0000")
+ # Σ remain_qty == core_holding.qty(08 种子的同一不变量)
+ assert sum(lot.remain_qty for lot in lots) == self.HOLDING["qty"]
+
+ def test_deterministic_across_calls(self):
+ """同一持仓重复补建结果完全一致 —— D18 同源断言的立足点。"""
+ first = lot_bootstrap.bootstrap_lots(self.HOLDING)
+ for _ in range(50):
+ assert lot_bootstrap.bootstrap_lots(self.HOLDING) == first
+
+ def test_offset_uses_crc32_not_builtin_hash(self):
+ """必须用 `zlib.crc32`:内置 `hash()` 受 PYTHONHASHSEED 随机化,
+ 会让 gateway 与 rebuild_lots.py(两个进程)算出不同 confirmed_at。"""
+ key = "CUST-9527|PROD-110022"
+ expected = lot_bootstrap.BOOTSTRAP_OFFSET_DAYS[
+ zlib.crc32(key.encode("utf-8")) % len(lot_bootstrap.BOOTSTRAP_OFFSET_DAYS)
+ ]
+ assert lot_bootstrap.offset_for("CUST-9527", "PROD-110022") == expected
+
+ def test_lot_id_is_deterministic_and_within_column_width(self):
+ lot_id = lot_bootstrap.bootstrap_lot_id("CUST-9527", "PROD-110022")
+ assert lot_id == lot_bootstrap.bootstrap_lot_id("CUST-9527", "PROD-110022")
+ assert len(lot_id) <= 64
+ # 超长组合不抛异常(D8:兜底补建不跳过、不阻断)
+ long_id = lot_bootstrap.bootstrap_lot_id("C" * 64, "P" * 64)
+ assert len(long_id) <= 64
+
+ def test_nav_approximates_cost_per_share(self):
+ lots = lot_bootstrap.bootstrap_lots(self.HOLDING)
+ # 82400.00 / 80000 = 1.0300(4 位 HALF_UP)
+ assert lots[0].nav == Decimal("1.0300")
+
+ def test_offset_covers_all_five_bands_across_customers(self):
+ """错开规则必须真能覆盖 5 档,否则分档分支在兜底路径上无数据。"""
+ offsets = {
+ lot_bootstrap.offset_for(f"CUST-{i:04d}", "PROD-110022") for i in range(1, 60)
+ }
+ assert offsets == set(lot_bootstrap.BOOTSTRAP_OFFSET_DAYS)
+
+ def test_forced_offset_pins_band(self):
+ lots = lot_bootstrap.bootstrap_lots(self.HOLDING, offset_days=3)
+ assert calc.hold_days(TRADE_DATE, lots[0].confirmed_at) == 3
+ assert fee.pick_fee_rate(RULES, 3) == Decimal("0.0150")
+
+ def test_zero_qty_holding_returns_no_lot(self):
+ holding = dict(self.HOLDING, qty=Decimal("0.0000"))
+ assert lot_bootstrap.bootstrap_lots(holding) == []
+
+ def test_missing_as_of_rejected(self):
+ holding = dict(self.HOLDING, as_of=None)
+ with pytest.raises(ValueError):
+ lot_bootstrap.bootstrap_lots(holding)
+
+
+# ── 10. 错误码映射(架构 §8.3) ──────────────────────────────────────
+ERROR_CASES = [
+ (ProductNotRedeemable, 400, "PRODUCT_NOT_REDEEMABLE"),
+ (ProductNotSubscribable, 400, "PRODUCT_NOT_SUBSCRIBABLE"),
+ (InsufficientShares, 400, "INSUFFICIENT_SHARES"),
+ (BelowMinQty, 400, "BELOW_MIN_QTY"),
+ (SameProduct, 400, "SAME_PRODUCT"),
+ (CrossEntityNotSupported, 400, "CROSS_ENTITY_NOT_SUPPORTED"),
+ (LotConflict, 409, "LOT_CONFLICT"),
+ (NavNotReady, 503, "NAV_NOT_READY"),
+ (IdempotencyUnavailable, 503, "IDEMPOTENCY_UNAVAILABLE"),
+ (FeeRuleMissing, 500, "FEE_RULE_MISSING"),
+]
+
+
+class TestErrorMapping:
+ @pytest.mark.parametrize(("cls", "status", "code"), ERROR_CASES)
+ def test_status_and_code(self, cls, status, code):
+ err = cls()
+ assert isinstance(err, ConvertError)
+ assert err.status_code == status
+ assert err.error_code == code
+ assert err.message # 必须有可读 message,不能是空串
+
+ def test_too_many_lots_carries_batch_context(self):
+ err = TooManyLots(batch_count=260, max_lots=200)
+ assert err.status_code == 400
+ assert err.error_code == "TOO_MANY_LOTS"
+ assert err.extra == {"batch_count": 260, "max_lots": 200}
+ assert "260" in err.message and "200" in err.message
+
+ def test_ensure_batch_limit_raises_over_limit(self):
+ lots = [_lot(f"LOT-{i}", 100 - i, "1000.0000") for i in range(5)]
+ plan = calc.plan_lots(lots, Decimal("5000"))
+ calc.ensure_batch_limit(plan, 5) # 恰好等于上限 → 放行
+ with pytest.raises(TooManyLots) as exc:
+ calc.ensure_batch_limit(plan, 4)
+ assert exc.value.batch_count == 5
+
+ def test_batch_limit_checked_after_planning(self):
+ """先规划再判上限:异常里带的是**实际所需**批次数,而非上限本身(§8.3)。"""
+ lots = [_lot(f"LOT-{i}", 100 - i, "1000.0000") for i in range(7)]
+ plan = calc.plan_lots(lots, Decimal("7000"))
+ with pytest.raises(TooManyLots) as exc:
+ calc.ensure_batch_limit(plan, 3)
+ assert exc.value.batch_count == 7
+
+
+# ── 11. 纯函数约束(DoD 第 3 条) ────────────────────────────────────
+CONVERT_DIR = Path(__file__).resolve().parents[1] / "app" / "service" / "convert"
+
+#: import 行中一旦出现即说明纯函数包被 IO 污染("不查库、不碰 SQL"是 D1/D18 的前提)。
+#: 只比对 **import 语句**:docstring 里提到 `core_ro.get_nav_as_of(...)` 是在说明
+#: "输入由调用方取好再传入",属合法引用,不应误判。
+FORBIDDEN_IMPORT_PREFIXES = (
+ "sqlalchemy",
+ "app.repository",
+ "app.config",
+ "app.utils.db",
+ "app.gateway",
+)
+
+#: 全文禁用符号(连 import 都不允许,出现在任何位置都是漏了依赖边界)。
+FORBIDDEN_SYMBOLS = ("get_engine", "create_engine")
+
+_IMPORT_RE = re.compile(r"^\s*(?:from|import)\s+([A-Za-z_][\w.]*)", re.M)
+
+
+class TestPurity:
+ @pytest.mark.parametrize("path", sorted(CONVERT_DIR.glob("*.py")), ids=lambda p: p.name)
+ def test_no_repository_or_engine_dependency(self, path: Path):
+ source = path.read_text(encoding="utf-8")
+ imported = _IMPORT_RE.findall(source)
+ bad_imports = [
+ name for name in imported if name.startswith(FORBIDDEN_IMPORT_PREFIXES)
+ ]
+ bad_symbols = [sym for sym in FORBIDDEN_SYMBOLS if sym in source]
+ assert not bad_imports, f"{path.name} 引入了 IO 依赖:{bad_imports}"
+ assert not bad_symbols, f"{path.name} 出现引擎符号:{bad_symbols}"
+
+ @pytest.mark.parametrize("path", sorted(CONVERT_DIR.glob("*.py")), ids=lambda p: p.name)
+ def test_imports_only_from_stdlib_or_own_package(self, path: Path):
+ source = path.read_text(encoding="utf-8")
+ allowed_roots = (
+ "app.service.convert",
+ "__future__",
+ # 唯一允许的包外依赖:复用 `register_error_handlers` 的统一错误体出口
+ # (架构 §8.3「不新增异常出口、不改中间件」)。它是纯类型基类,不触 IO。
+ "app.utils.exceptions",
+ )
+ stdlib = {"dataclasses", "datetime", "decimal", "typing", "zlib", "re"}
+ external = [
+ name
+ for name in _IMPORT_RE.findall(source)
+ if not name.startswith(allowed_roots) and name.split(".")[0] not in stdlib
+ ]
+ assert not external, f"{path.name} 引入了包外依赖:{external}"
+
+ def test_lot_bootstrap_avoids_builtin_hash(self):
+ """内置 `hash` 受 PYTHONHASHSEED 随机化,会破坏 D18 的跨进程同源。"""
+ source = (CONVERT_DIR / "lot_bootstrap.py").read_text(encoding="utf-8")
+ assert "hash(" not in source
diff --git a/tests/test_db.py b/tests/test_db.py
index 1fb84b0..0f93da1 100644
--- a/tests/test_db.py
+++ b/tests/test_db.py
@@ -2,11 +2,31 @@
create_engine 与缓存字典均 monkeypatch 替换,不触网;验证 dispose_engines
对每个缓存 Engine 显式调用 dispose() 并清空缓存(不再是仅 cache_clear)。
+
+另含 T-0 门禁用例(sqlite 结构对齐 + 门禁自身有效性反向验证)、T-0b 引擎角色
+隔离用例(同库不同角色 / 账号未配置回退 / 非法角色报错),以及 3 条真 MySQL
+权限断言(`xh_core_ro` 只读 · `audit_log` 只追加 · `xh_core_rw` 限 4 表)——
+后者在账号未配置或真库不可达时自动 skip,不阻塞单测基线。
"""
+import pytest
+from sqlalchemy import text
+
+from _ddl import EXPECTED_CORE_HOLDING_COLUMNS, create_sqlite_engine
from app.utils import db
+def _unique_column_sets(conn, table: str) -> set[tuple[str, ...]]:
+ """PRAGMA 取某表全部唯一约束的列组合(sqlite 自动索引 unique 字段为 1/'u')。"""
+ result: set[tuple[str, ...]] = set()
+ for row in conn.execute(text(f"PRAGMA index_list({table})")):
+ # 行结构:seq, name, unique, origin, partial
+ if row[2]:
+ cols = tuple(r[2] for r in conn.execute(text(f"PRAGMA index_info({row[1]})")))
+ result.add(cols)
+ return result
+
+
class SpyEngine:
def __init__(self):
self.dispose_calls = 0
@@ -52,3 +72,201 @@ def test_dispose_engines_calls_dispose_and_clears_cache(monkeypatch):
def test_dispose_engines_on_empty_cache_is_noop(monkeypatch):
_patch(monkeypatch)
db.dispose_engines() # 不抛异常即可
+
+
+# ---------- T-0 门禁:sqlite 结构对齐(R1)----------
+
+
+def test_core_holding_columns(sqlite_engine):
+ """sqlite core_holding 与 MySQL scripts/core/01-ddl.sql:132-145 逐列对齐。
+
+ convert 的首个阻断项正是列名失配(sqlite 曾为 quantity、MySQL 为 qty),
+ 本用例把这类失配钉死在单测层,不让它留到集成测试期。
+ """
+ with sqlite_engine.connect() as conn:
+ info = conn.execute(text("PRAGMA table_info(core_holding)")).all()
+ columns = {row[1] for row in info}
+ assert EXPECTED_CORE_HOLDING_COLUMNS <= columns, (
+ f"core_holding 缺列:{sorted(EXPECTED_CORE_HOLDING_COLUMNS - columns)}"
+ )
+ primary_key = {row[1] for row in info if row[5] > 0}
+ assert primary_key == {"id"}
+ assert ("customer_id", "product_id") in _unique_column_sets(conn, "core_holding")
+
+
+def test_core_product_nav_columns(sqlite_engine):
+ """core_product_nav 存在且含 (product_id, nav_date) 唯一约束(R-f)。"""
+ with sqlite_engine.connect() as conn:
+ tables = {
+ row[0]
+ for row in conn.execute(text("SELECT name FROM sqlite_master WHERE type = 'table'"))
+ }
+ assert "core_product_nav" in tables
+ columns = {row[1] for row in conn.execute(text("PRAGMA table_info(core_product_nav)"))}
+ assert {"id", "product_id", "nav", "daily_chg_pct", "nav_date"} <= columns
+ assert ("product_id", "nav_date") in _unique_column_sets(conn, "core_product_nav")
+
+
+def test_ddl_alignment_guard_fails_when_column_missing(monkeypatch):
+ """反向验证门禁自身有效:抽掉 qty 列 → 建库即 AssertionError。"""
+ import _ddl
+
+ broken = dict(_ddl.SQLITE_TABLES)
+ broken["core_holding"] = """
+ CREATE TABLE core_holding (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ customer_id VARCHAR(64) NOT NULL, product_id VARCHAR(64) NOT NULL,
+ market_value DECIMAL NOT NULL, UNIQUE (customer_id, product_id))
+ """
+ monkeypatch.setattr(_ddl, "SQLITE_TABLES", broken)
+ with pytest.raises(AssertionError, match="core_holding 缺列"):
+ create_sqlite_engine()
+
+
+# ---------- T-0b:引擎角色隔离(D20 · 缓存键 (库名, 角色))----------
+
+
+def test_get_engine_role_isolation(monkeypatch):
+ """同库不同角色 → 不同 engine(缓存键由「库名」改为「(库名, 角色)」)。"""
+ created = _patch(monkeypatch)
+ ro = db.get_engine("db_a", "ro")
+ rw = db.get_engine("db_a", "rw")
+ admin = db.get_engine("db_a", "admin")
+ assert ro is not rw
+ assert rw is not admin
+ assert ro is not admin
+ assert db.get_engine("db_a", "ro") is ro # 同角色仍复用
+ assert len(created) == 3
+
+
+def test_get_engine_role_accounts_and_fallback(monkeypatch):
+ """ro/rw 各用自己账号;账号留空 → 回退 mysql_user(渐进启用,不阻塞开发)。"""
+ from app.config.settings import settings
+
+ for name, value in (
+ ("mysql_host", "127.0.0.1"),
+ ("mysql_port", 3306),
+ ("mysql_user", "root"),
+ ("mysql_password", "rootpw"),
+ ("mysql_database", "jinrong_agent"),
+ ("mysql_core_database", "jinrong_core"),
+ ("mysql_core_ro_user", ""),
+ ("mysql_core_ro_password", ""),
+ ("mysql_core_rw_user", ""),
+ ("mysql_core_rw_password", ""),
+ ("mysql_agent_user", ""),
+ ("mysql_agent_password", ""),
+ ):
+ monkeypatch.setattr(settings, name, value)
+ created = _patch(monkeypatch)
+
+ db.get_engine("jinrong_core", "ro")
+ db.get_engine("jinrong_core", "rw")
+ db.get_engine("jinrong_agent", "rw")
+ assert len(created) == 3
+ # 三组账号均未配置 → 全部回退 mysql_user(与单账号时代行为一致)
+ assert all("root:rootpw@" in url for url in created)
+
+ # 配置账号后:各角色取各自账号
+ db.dispose_engines()
+ created.clear()
+ for name, value in (
+ ("mysql_core_ro_user", "xh_core_ro"),
+ ("mysql_core_ro_password", "p1"),
+ ("mysql_core_rw_user", "xh_core_rw"),
+ ("mysql_core_rw_password", "p2"),
+ ("mysql_agent_user", "xh_agent_rw"),
+ ("mysql_agent_password", "p3"),
+ ):
+ monkeypatch.setattr(settings, name, value)
+
+ db.get_engine("jinrong_core", "ro")
+ db.get_engine("jinrong_core", "rw")
+ db.get_engine("jinrong_agent", "rw")
+ assert "xh_core_ro:p1@" in created[0]
+ assert "xh_core_rw:p2@" in created[1]
+ assert "xh_agent_rw:p3@" in created[2]
+ assert "/jinrong_core?" in created[0]
+ assert "/jinrong_core?" in created[1]
+ assert "/jinrong_agent?" in created[2]
+ # agent 库无独立只读账号 → ro 亦走 mysql_agent_user(不静默落到 root),
+ # 但缓存键 (库, 角色) 不同,因此仍是独立 engine
+ agent_ro = db.get_engine("jinrong_agent", "ro")
+ assert "xh_agent_rw:p3@" in created[3]
+ assert agent_ro is not db.get_engine("jinrong_agent", "rw")
+
+
+def test_get_engine_rejects_unknown_role(monkeypatch):
+ """非法角色立即报错:防拼写错误静默取到默认账号。"""
+ _patch(monkeypatch)
+ with pytest.raises(ValueError, match="unknown db role"):
+ db.get_engine("db_a", "readonly")
+
+
+# ---------- T-0b:真 MySQL 权限断言(账号未配置则 skip)----------
+# 前置:管理员执行 scripts/core/00-grant.sql,把 3 组账号写入 .env。
+# 未配置时不阻塞单测基线(本文件默认全绿)。
+
+
+def _role_engine_or_skip(role: str, database_setting: str, user_setting: str):
+ """取角色 engine;账号未配置或真库不可达 → skip 并给出 bootstrap 提示。"""
+ from app.config.settings import settings
+ from app.utils.db import get_engine
+
+ if not getattr(settings, user_setting):
+ pytest.skip(
+ f"未配置 {user_setting}:先执行 scripts/core/00-grant.sql 并写入 .env"
+ )
+ engine = get_engine(getattr(settings, database_setting), role)
+ try:
+ with engine.connect() as conn:
+ conn.execute(text("SELECT 1"))
+ except Exception as exc:
+ pytest.skip(f"真 MySQL 不可用({exc})")
+ return engine
+
+
+def test_core_ro_account_is_readonly():
+ """xh_core_ro 只有 SELECT:INSERT 被数据库拒绝(Core 只读 DB 级强制)。"""
+ from sqlalchemy.exc import SQLAlchemyError
+
+ engine = _role_engine_or_skip("ro", "mysql_core_database", "mysql_core_ro_user")
+ with pytest.raises(SQLAlchemyError) as exc:
+ with engine.begin() as conn:
+ conn.execute(
+ text(
+ "INSERT INTO core_trade (trade_id, customer_id, product_id, trade_type,"
+ " amount, trade_status, traded_at)"
+ " VALUES ('TRD-TEST-RO-DENY', '__x__', '__x__', 'subscribe', 1,"
+ " 'confirmed', NOW())"
+ )
+ )
+ assert "denied" in str(exc.value).lower()
+
+
+def test_audit_log_append_only():
+ """audit_log 只允许追加:UPDATE / DELETE 均被拒(审计红线 DB 级强制)。"""
+ from sqlalchemy.exc import SQLAlchemyError
+
+ engine = _role_engine_or_skip("rw", "mysql_database", "mysql_agent_user")
+ for stmt in (
+ "UPDATE audit_log SET decision = 'tampered' WHERE id = -1",
+ "DELETE FROM audit_log WHERE id = -1",
+ ):
+ with pytest.raises(SQLAlchemyError) as exc:
+ with engine.begin() as conn:
+ conn.execute(text(stmt))
+ assert "denied" in str(exc.value).lower(), stmt
+
+
+def test_core_rw_scope():
+ """xh_core_rw 限 4 表:对第 5 张表 core_product 的写被拒(最小权限)。"""
+ from sqlalchemy.exc import SQLAlchemyError
+
+ engine = _role_engine_or_skip("rw", "mysql_core_database", "mysql_core_rw_user")
+ with pytest.raises(SQLAlchemyError) as exc:
+ with engine.begin() as conn:
+ conn.execute(
+ text("UPDATE core_product SET product_name = '__x__' WHERE product_id = '__x__'")
+ )
+ assert "denied" in str(exc.value).lower()