Files
group_xinghuo_jinrong/docs/项目框架设计/开发计划-基金转换交易.md
T
GaoYiYuan_0626 5a784dc185 文档:T-10 回写 + 全仓进度一致性(开发计划 DoD 全勾 + 执行记录)
- 开发计划 v2.0:T-10 六项 DoD 全勾(申购落批次 / 哨兵不超扣 / T+2 半开区间
  双向 / 金额公式 D26 / 转换后不超扣验收 9 / rebuild 无漂移)+ 新增执行记录
  (改法、验证证据 844 passed、真库 89/89、突防验证、3 处断点与口径)
- 交接文档 v4.0(gitignore 不入库,本轮未提交)
- AGENTS.md 入口引用 → T-1~T-10 ✅ / 844 / 89/89 / 下一步 T-11
- docs/memory/TODO.md:T-10 完成条目 + 待办行 T-11~T-17
- docs/memory/MEMORY.md:进度 + 自检第 5/6 问 → 844 / T-1~T-10
2026-09-11 21:24:40 +08:00

915 lines
92 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 开发计划 · 基金转换交易(v2.0 · T+1 受理/确认分离模型)
> **文档状态**:v2.0 整体重写 · ✅ **三轮独立审核闭环**(第一轮 2 阻断 B1/B2 + 5 重要 + 3 可选;第二轮验证型 12 项零残留;**第三轮白纸重审 C1~C5+O1~O4 全修订 + 第三轮验证型复核 22/22 落位、D-1 残留已修复**)· **已定稿**(2026-09-11 用户批准)(批准后可进入第 5 步 todo 开发)
>
> **代码基线**:分支 `risk-control-agent` · HEAD `96fe151`(领先远程 17 提交未推送,push 由用户定)· pytest 基线 **739 passed / 10 skipped**
>
> **上游依据**:PRD-基金转换交易.md **v1.1**(§2 模型 / §5 接口 / §6 引擎 / §9 验收 31 条 / Q1~Q15 拍板)· 架构设计-基金转换交易.md **v2.1**(D1~D30 · §15 任务映射 T-0~T-17)
>
> **任务编号**:**沿用架构 v2.1 §15,不重编**(T-0 ✅ / T-0b ✅ 已完成;T-1~T-17 按下表重排)。与旧 v1.0 计划的 T-1~T-13 **语义不同**:v1.0 是两阶段实时模型的任务,v1.1/v2.1 已整体换成 T+1 受理/确认分离模型,**旧计划作废**。
---
## 审核记录
### 第一轮(独立子代理 · agent-eca13b6e · 2026-09-11)
| # | 审核意见 | 档位 | 本轮处理 |
| --- | --- | --- | --- |
| B1 | `convert_cutoff_time` 配置缺失,PRD §2.6.1「不得硬编码」,R-5 写死 15:00 | 阻断 | **接受**:§1.3 裁定 10 + T-1 改法 8/DoD 2 + R-5/R-9 读 settings + F-17 补字段 + 风险 13 |
| B2 | `get_nav_as_of` 为 `nav_date <= :d` 回退语义(core_ro.py:509-512),R-8「无行→nav_pending」永不触发,静默降级违反 FR-C27 | 阻断 | **接受**:F-18/F-24 纠偏 + R-8 改 `get_nav_on(product_id, T)` 精确匹配 + T-3 新增 `get_nav_on`(DoD 2)+ T-7 注禁回退 + 风险 14 |
| I1 | 回归面缺 test_share_lot / test_concentration_c4 / test_db / test_demo_scripts / test_locks_redis(实测 tests/ 18 文件含 convert) | 重要 | **接受**:§5 补 R-回归 12~16;遗漏检查法注明已补 |
| I2 | R-9 与 PRD §5.5 撤单范围「冲突」 | 重要 | **修订性接受**:复核后确认 PRD §5.5 :626 原文为「accepted / nav_pending → 200」,状态机 :178/:181 亦同——**两处口径一致、矛盾不存在**;第一轮误判「PRD 内部矛盾」系引述不实。§1.3 裁定 9 已订正为「PRD 原表述保留、实现按窗口语义收紧为 accepted」(第三轮发现,见下方「第三轮」记录) |
| I3 | T-8/T-16/T-17 与 T-7/T-6 同文件编辑未标强耦合 | 重要 | **接受**:§0.3 并行组重构(P5/P6)+ 依赖列改 T-7/T-6 |
| I4 | 架构 §15「新增 5 表/8 列」陈旧(实测 3 表/4 列),计划正确 | 重要 | **接受**:F-14/F-15 标注「架构口径陈旧,计划以实测为准」 |
| I5 | 事实表行号偏差(F-20 rebuild_lots 170 行、F-18 `_nav_as_of` 在 trade_gateway 不在 nav.py、F-11 engine def 在 250) | 重要 | **接受**:F-11/F-18/F-20 已逐条纠偏 |
| O1 | 交叉引用失效(回归面引 F-25、事实表只到 F-23) | 可选 | **接受**:F-24/F-25 已新增入表 |
| O2 | T-9 `SUPPORTED_TRADE_TYPES` 加 CONVERT 冗余/双分派 | 可选 | **接受**:F-25 记录网关现状(:50 常量已存在但元组不含),T-9 改「**维持 2 类型、走独立分派**」+ DoD 相应改 |
| O3 | F-17 settings 枚举漏 `convert_deadlock_retry_base_ms` | 可选 | **接受**:F-17 已补全字段清单 |
### 第二轮(验证型审核 · agent-add9837f · 2026-09-11)
> 验证型提问:逐条打开文件核实第一轮 10 条处置是否真的落到正文。
| 核实项 | 结论 | 证据 |
| --- | --- | --- |
| B1 cutoff 配置 | **已落实**,出现 ≥6 处语义一致,T-1/R-5/R-9 均读 settings,PRD §2.6.1 被引用 | :19/:122/:146/:168/:200/:357/:751/:778 |
| B2 get_nav_on 精确匹配 | **已落实**,新增方法(非改既有)、DoD 含「仅 T−1 → None」、禁回退语义三处齐 | :273/:287/:388/:153 |
| I1 回归面 5 文件 | **已落实**,R-回归 12~16 各 1 行且处置非空 | :687~691 |
| I2 撤单范围 | **已落实**,裁定 9 + R-9 一致;**第三轮 C1 订正为「矛盾不存在」**——PRD §5.5 原表述(accepted/nav_pending)保留,实现按撤单窗口语义收紧为 `accepted`(nav_pending 分支时序不可达) | :121/:168 |
| I3 同文件依赖 | **已落实**,T-8→T-7、T-16→T-7、T-17→含 T-6,P1 剔除 T-8 | :54/:62/:63/:70 |
| I4 架构计数陈旧标注 | **已落实**,F-14/F-15 明示以实测 3 表/4 列为准 | :143/:144 |
| I5 行号纠偏 | **已落实**,F-11/F-18/F-20 均校正 | :140/:147/:149 |
| O1 交叉引用 | **已落实**,事实表扩至 F-25,T-9/R-回归 1 引用可解析 | :154/:458/:469 |
| O2 SUPPORTED 不动 | **已落实**,T-9 改法 + DoD 均「维持 2 类型」 | :458/:468 |
| O3 字段补全 | **已落实**,F-17 含 convert_deadlock_retry_base_ms | :146 |
| 交叉一致性 | 审核记录与正文一致,头部标注「首轮已闭环、待第二轮验证」 | :3/:13/:30 |
**结论:12 项逐条核实全部落实、零「声明已改/实际未改」残留 → 本版可作为第 5 步(todo 开发)依据,收敛。**
> ✅ **原「PRD §5.5 待回填」遗留项已撤销(第三轮 C1 订正)**:PRD §5.5 :626 原表述(`accepted / nav_pending` 可撤)与状态机 :178/:181 口径一致、**不存在矛盾**,PRD 无需修正;实现按撤单窗口(T 日 15:00 前)收紧为 `accepted`,nav_pending 分支时序不可达。**可选项(非阻塞)**:若后续希望 PRD 文字精确化,可在第 5 步触及 PRD 时加一行「nav_pending 分支实际不可达」注解。
### 第三轮(白纸重审 · 全新上下文 agent-91bd8bdd · 2026-09-11 17:xx)
> 用户训示「是否只改一次就通过、有没有仔细审核」——第二轮为验证型(验第一轮改没改),**缺一次不预设结论的全新重审**。本轮补齐:全新上下文子代理,不看既有审核记录,独立通读计划 + 上游 + 代码。
| # | 审核意见 | 档位 | 本轮处理 |
| --- | --- | --- | --- |
| C1 | **R-9 / 裁定 9 引述不实**:PRD §5.5 :626 原文是「accepted / nav_pending → 200」,并非「仅 accepted 可撤」;「PRD 内部矛盾」是误判(I2 已连带订正,见上) | 重要 | **接受**:§1.3 裁定 9 原文改写,标注「矛盾不存在、实现按窗口语义收紧」 |
| C2 | **§6 验收表缺 FR-C30(资金流中转 D29,P0)对应行**:§6 31 行无 T-16 / 资金流/中转 标注;FR-C29 亦无独立行 | 重要 | **接受**:§6 增补 C30 行(映射 T-16)与 C29 行(映射 T-10);§9 自检锚点 7 补「FR-C29/C30 有独立行」 |
| C3 | **回归面遗漏 test_share_lot.py 的 redeem 用例**:6 处 `_maintain(trade_type="redeem", amount=...)`(:441-550)会被 D26 打穿(redeem 入参改份额、移除 amount 反算),但 R-回归 12 只提 convert 用例 | 重要 | **接受**:R-回归 12 增补「D26 后 6 处 redeem 用例改 `qty` 入参」;§9 锚点 3 补「redeem 用例」 |
| C4 | **R-回归 6 引用了 0 命中文件**:`grep -c convert tests/test_risk_api.py` = 0,R-回归 6 误列 test_risk_api.py(应只有 test_integration_risk.py) | 重要 | **接受**:R-回归 6 删 test_risk_api.py,改「T-9 网关受理 202 后其 400 断言自然失效」留痕 |
| C5 | **T-7 与 T-16 的 core_cash_flow 双重归属**:T-7 改法⑥+DoD 已写「确认事务写 cash_flow 2 条(验收 T-16 前置)」,T-16 又写「确认事务补 2 条」→ 同文件同事务重复 | 重要 | **接受**:T-7 改法⑥与 DoD 删 cash_flow,改「归 T-16」;T-16 改法①保留为确认事务唯一写入点 |
| O1 | F-11 行号偏差(engine `_run` 实为 :142,计划写 :150-153) | 可选 | **接受**:F-11 行号改 `engine.py:142` |
| O2 | §6 编号与 PRD FR-C1~C31 不一一对应(部分散落任务 DoD) | 可选 | **接受**:§6 表头注明「PRD FR 编号见承载任务」 |
| O3 | T-13 性能/紧池 DoD 为「记录型」非硬门禁 | 可选 | **接受**:DoD 补「记录值 + 是否达设计目标」双输出 |
| O4 | R-2 状态机允许 nav_pending→cancelled 与 R-9 禁撤不一致 | 可选 | **接受**:R-2 状态机迁移表注明「nav_pending→cancelled 受 R-9 撤单窗口约束(T+1 已过窗口,实际不可达)」 |
**第三轮结论:C1~C5 + O1~O4 全部修订闭环(判定表见上)→ 经第三轮白纸重审,本版达到「可作第 5 步依据」标准。**
### 第三轮验证(验证型复核 · 全新上下文 agent-a673e184 · 2026-09-11 17:4x)
> 用户继续追问「确定这次是独立 AI 审核通过吗」——第三轮为「白纸重审」但修订动作由主代理自证,缺**独立方逐条核实声明是否真的落位**。本轮补齐验证型复核:全新子代理逐条打开文件核实。
| 判定项 | 结果 |
| --- | --- |
| C1~C5 + O1~O4 + 版本号 22 条声明的落位核实 | **22/22 全部落实**,无「声明已改/实际未改」、无「只改一半」 |
| 清单外残留 D-1 | **发现 1 处重要残留并已修复**:T-9 节 :488/:509/:511 仍把 `test_risk_api.py` 列为 convert 改造目标,与 R-回归 6(C4)「0 引用无关」自相矛盾——已改为「test_risk_api 无 convert 断言、零改动(C4)」三处 |
| 悬空引用 | **已修**:第一轮 I2 行「见 §13」→「见下方第三轮记录」(本文件无 §13) |
**第三轮验证结论:22/22 声明落位 + D-1 修复,零「声明已改/实际未改」残留 → 独立验证确认本版达到「可作第 5 步依据」标准,收敛。**
---
## 0. 结论速览
### 0.1 一句话
把 convert 从「实时两阶段(受理即扣份额)」整体重做为「**T+1 受理/确认分离**」:T 日受理只校验 + 落受理单(Core 库 `core_convert_request` 6 态权威状态机,202 回执),T+1 批处理串行确认(扣批次 + 两条流水 + 两端持仓 + 资金流中转),T+2 可赎回;撤单/确认/查询接口补齐;普通申赎同步对齐(redeem 份额申报 D26、批次全生命周期 FR-C16、T+2 校验 FR-C25)。
### 0.2 任务矩阵(架构 v2.1 §15 权威映射)
| 任务 | 内容 | 依赖 | 类型 | 关键风险 |
| --- | --- | --- | --- | --- |
| T-0 ✅ | 列名统一 + 启动断言 | 无 | 已完成 | — |
| T-0b ✅ | DB 账号分离(D20) | 无 | 已完成 | — |
| T-1 | DDL + 种子:新增 `core_convert_request` / `core_trade_calendar` / `core_share_rule` 3 表;`core_product` 补 `share_class` / `allow_ac_convert` / `rounding_mode` / `share_digits` 4 列;新增 `11-seed-share-rule.sql`;10-seed-trade-calendar 入 reset | T-0/T-0b | 新增 | DDL 方言(sqlite conftest 同步) |
| T-2 | `calc.py` 纯函数扩展:D27 产品舍入入口 / D28 partial 辅助 / D26 redeem_amount | 无 | 扩展 | 不动既有 round2 行为 |
| T-2b | `calc_convert_demo.py` 实算回填(随真实净值) | T-2 | 示例 | 与验收 20 强绑定 |
| T-3 | `core_ro` 新方法 + `share_lot_repository` 扩展 + **新增 `convert_request_repository`** | T-1 | 新增 | 在途占用 LEFT JOIN 正确性 |
| T-4 | `convert_repository` 语义收窄(只做进度/审计,扫 `core_convert_request`) | T-1 | 改写 | 状态机字段迁移 |
| T-5 | `run_locked` 复用(受理/确认/撤单统一锁原语) | 无 | 复用 | 锁键设计 |
| T-6 | **`accept_convert` 受理事务**(6 态状态机 + `uk_idem` 幂等 + 15:00 顺延 + 占用推导) | T-1/T-3 | 核心新增 | 幂等窗口 / 事务边界 |
| T-7 | **`convert_service` 三段编排 + 新增 `confirm_service`(T+1 批处理)** | T-2~T-6 | 核心重写 | 串行确认 / nav_pending / partial |
| T-8 | `rules.amount_view` + `engine.process_convert_event` 时机改(确认事务提交后跑一次) | **T-7(同文件,须 T-7 先合入)** | 时机改 | 三处消费方同步 |
| T-9 | api 层:`simulate.py` redeem 份额申报 + 撤单/确认/查询接口 + 网关分派 | T-7 | 新增 | API 契约变更 |
| T-10 | 普通申赎批次维护(redeem 份额申报 + 扣批次哨兵)+ T+2 校验 + `rebuild_lots` | T-3 | 扩展 | 打穿 test_trade_gateway |
| T-11 | `core_tools` 汇总去重 + 持仓过滤 | T-8 | 沿用 | — |
| T-12 | 补偿脚本:`cleanup_pending_convert` 改写扫 `core_convert_request` | T-4/T-7 | 改写 | 扫描对象迁移 |
| T-13 | 全量回归 + 集成 + 并发 + 性能补录 | 全部 | 收尾 | 基线重估 |
| T-14 | D27:`core_share_rule` seed + `calc` 真正接入产品舍入 | T-1/T-2 | 新增 | 全局 round2 行为切换 |
| T-15 | D28:部分成交专项测试 + 边界补强 | T-7 | 新增 | 验收 30 覆盖 |
| T-16 | D29:确认事务写 `core_cash_flow`(注册登记账户中转建模) | **T-7(改确认事务,须 T-7 先合入)** | 新增 | 流水语义 |
| T-17 | D30:`share_class` + `allow_ac_convert`,同基金 A/C 互转 | T-1/**T-6(受理校验前置)**/T-9 | 新增 | 校验放行规则 |
### 0.3 关键路径与并行组
**关键路径**:T-1 → T-3 → T-6 → T-7 → T-9 → T-10 → T-13(8 步,T-7 为最大单点;T-7 之后并联 T-8)
**并行组**(**强耦合约束**:T-8 改 `convert_service.py` 引擎调用点、T-16 改确认事务、T-17 改受理校验,三者分别与 T-7 / T-7 / T-6 同文件 → **同文件任务必须串行合入,先核心后扩展**):
- P1(并行开工):`{T-1, T-2, T-5}` —— T-1 不必等 T-2(calc 纯函数不查库)
- P2:`{T-2b, T-3, T-4}`(依赖 T-1/T-2)
- P3:`{T-6}`(依赖 T-1/T-3/T-5)
- P4:`{T-7}`(依赖 T-2~T-6)
- P5(T-7 合入后并行):`{T-8, T-9, T-10}`(各自与 T-7 衔接;T-8 依赖 T-7 已合)
- P6(后置):`{T-16(←T-7), T-17(←T-6/T-9)}`
### 0.4 硬门禁
1. **每任务 DoD 可机械验证**(有明确命令/断言),禁止「确认正确」类表述
2. **pytest 全绿**:基线 739 只减不增原则——新增可以加用例,既有用例除非被 v2.1 口径推翻(见 §5 回归面),否则不许删
3. 高风险任务(T-6/T-7/T-9/T-10):先跑基线快照 → 改造 → 全量回归
4. 真库验证一律走 `scripts/dev/verify_convert_*.py` 模式(隔离前缀、清理、DoD 断言、退出码 1=失败)
5. 方言红线:sqlite 主回归保留;core_convert_request 表在 sqlite conftest DDL 与 MySQL 01-ddl.sql **双份同步**(T-1 DoD 含一致性断言)
---
## 1. 背景与约束
### 1.1 上游依据与决策链
- PRD **v1.1** 定模型:§2.1~2.6(受理/确认/净值/占用/撤单/部分成交)· Q1~Q15 拍板(Q11 交易日历、Q13 T+1 适当性复核、Q14 SLA=2 交易日、Q15 redeem 份额申报)
- 架构 **v2.1** 定结构:目录划分 §2 · 状态机 §4(6 态)· 时序 §5 · 事务/并发 §7 · D1~D30 · 任务映射 §15
- 「下游同步令」:本计划落实 **架构 v2.1 §15** 的全部 T-1~T-17;**旧 v1.0 计划按 v0.x 模型整体作废**(两阶段实时模型)
- 已落地复用资产:D20 账号分离(T-0b)、列名统一+启动断言(T-0)、纯函数包(calc/fee/nav/lot_bootstrap)、金额去重口径(amount_view 三处同步)、死锁重试外壳(43cf2a1)
### 1.2 硬约束(红线)
| # | 约束 | 出处 |
| --- | --- | --- |
| 1 | convert 流水 trade_type 仍是 `redeem`/`subscribe`(R-b),**不写 convert ENUM**;`core_trade.trade_type` 无 convert 值 | 红线 · 01-ddl.sql:161 |
| 2 | 金额聚合去重口径**只此一处**:`rules.amount_view` + `core_ro.sum_trades_on_date` SQL 等价条件,改口径三处同步 | 红线 · rules.py:94-138 |
| 3 | 展示位数经 `convert_service._q` 单点量化;禁裸 `str(Decimal)` 出网;Decimal 恒显式 `ROUND_HALF_UP` | 红线 |
| 4 | 批次补建规则唯一副本 `lot_bootstrap.lot_bootstrap_lots`;`rebuild_lots.py` 与 trade_gateway 同调,禁另写 | 红线 · rebuild_lots.py:10-14 |
| 5 | conftest 四处 engine 须 `role="admin"`(R-e);DB 账号 xh_core_ro 读 / xh_core_rw 仅 4 表写 / xh_agent_rw 仅 audit INSERT | 红线 R-e · D20 |
| 6 | 验证一律上真 MySQL 8.0.46:ENUM 严校验 / JSON+LIKE / DATETIME(3) / DECIMAL / gap lock 真库才可见 | 红线 |
| 7 | 仅本人或 risk_demo 可交易、代理人只查(查询 scope 与交易 owner 两道闸门不可合并) | 红线 |
| 8 | 规则引擎跑在**确认事务提交后**(FR-C28),失败落 `engine_error` 审计**不阻断交易**(D17) | PRD FR-C28 |
### 1.3 文档口径差异裁定(PRD vs 架构,逐条定「以哪份为准 + 理由」)
| # | 差异点 | PRD v1.1 | 架构 v2.1 | 裁定 | 理由 |
| --- | --- | --- | --- | --- | --- |
| 1 | 受理响应 | 受理只回 202 + 受理单号(Q2:不返回折算金额) | 同 | **以 PRD 为准** | 目标一致,无冲突 |
| 2 | 确认触发 | §5.7 运维/批处理(可指定业务日) | §15 T-7 含 confirm_service 批处理 | **以架构为准**:批处理入口 `confirm_convert_daily.py --as-of` | 架构给了任务归属 |
| 3 | SLA | Q14:2 个交易日(`convert_confirm_sla_days`) | §15 T-12 扫 core_convert_request | **以 PRD 为准**:settings 新增 `convert_confirm_sla_days=2`(交易日),弃用 `convert_confirm_offset_days` 自然日偏移 | 用户拍板 Q14 |
| 4 | 部分成交 | §2.6.5 D28,投资者层 | §15 T-15 依赖 T-7 | **以架构为准**:实现对在 T-7,专测在 T-15 | 任务切分 |
| 5 | 资金流中转 | §2.7 D29 core_cash_flow | §15 T-16 依赖 T-6 | **以架构为准**:T-16 落在确认事务内 | 同上 |
| 6 | 净值挂起 | FR-C27 nav_pending 不降级 | 同 | **以 PRD 为准**:删除 `_nav_as_of` 取 T−1 旧行为 | 口径一致但实现必改 |
| 7 | 占用实现 | FR-C21 不新增冻结列,由受理单推导 | 同 | **以 PRD 为准**:SQL LEFT JOIN 未终态受理单聚合 | 无冲突 |
| 8 | 引擎时机 | FR-C28 确认后跑 | §15 T-8 | **以 PRD 为准**:T-8 改时机 | 无冲突 |
| 9 | 撤单范围 | 状态机表「`cancelled` ↔ accepted / nav_pending」(PRD :178/:181)与 §5.5 撤单接口(PRD :626:「T 日 15:00 前且状态 accepted / nav_pending → 200 置 cancelled」)**口径一致**——两者都允许 nav_pending 撤单。但 nav_pending 只在 T+1 确认时缺净值才出现,彼时 T 日 15:00 撤单窗口已过 | 架构任务 T-9(对接 R-9) | **裁定:仅 `accepted` 可撤(R-9)**;nav_pending 出现于 T+1,撤单窗口(T 日 15:00 前)早已关闭,PRD §5.5 所列条件在业务时序上不可达,保留 PRD 原表述但实现按 R-9 收紧为 `accepted` | 功能结果与「T 日 15:00 前」语义一致;第一轮审核曾误判「PRD 内部矛盾」(本行已订正:**矛盾不存在**,仅实现侧按窗口语义收紧) |
| 10 | 15:00 截点 | PRD §2.6.1「`convert_cutoff_time` 为配置项(默认 15:00),**不得硬编码**」 | 架构任务 R-5(R-5 初稿写死 `before_cutoff(dt, 15:00)`) | **以 PRD 为准**:T-1 settings 新增 `convert_cutoff_time="15:00"`;TradingCalendar.before_cutoff 读 settings,禁硬编码 | 审核 B1 |
| 11 | **T-9 三个接口路径** | §5.5 撤单 `POST /api/simulate/trade/convert/{convert_group_id}/cancel`;§5.7 确认 `POST /api/admin/convert/confirm?accept_date=…`、查询 `GET /api/simulate/trade/convert/{convert_group_id}` | 架构 §15 T-9 **未给路径**;**本计划 T-9 初稿自拟** `POST /api/convert/{gid}/cancel` / `POST /api/convert/confirm` / `GET /api/convert/{gid}` | **以 PRD 为准**(2026-09-11 用户拍板):T-9 按 PRD 三条路径实施,本计划初稿的自拟路径**作废** | PRD 是对外需求契约;其路径延续 `/api/simulate/trade/...` 与 `/api/admin/...` 既有风格,而初稿的 `/api/convert/...` 是新的顶格前缀、与全仓路由风格(`/api/risk`·`/api/simulate`·`/api/admin`·`/api/chat`)不一致。**根因留痕(审查为何未发现)**:计划的三轮独立审查均未发现本差异 —— §9 审核清单 9 条只对「架构 §15 / 代码事实行号 / tests 回归面 / PRD §9 **验收条目**」四类,**无一条对「对外契约逐字」**;且 §1.4 代码事实核对对的是**现有代码**、§1.3 差异裁定对的是「PRD **vs 架构**」,**都不覆盖「PRD vs 本计划自身」**。审查实际做的是「计划有没有**覆盖** PRD 的要求」,而非「计划有没有**擅自改** PRD 的承诺」—— 接口路径属后者,落在盲区。已补 §9 第 10 条 + 本表第 11/12 条 |
| 12 | **确认接口参数 `accept_date` 的名与义** | §5.7 代码块注释写「按**受理日**批量确认」、参数名 `accept_date`;但同节表格口径写「可指定**业务日**」—— **PRD 内部字面冲突** | R-1:`confirm_batch(as_of)` 的 `as_of` 是**业务确认日(T+1)**,捞单窗口 = `[as_of 上推 sla_days 个交易日, as_of]` 的**受理日** | **裁定(2026-09-11)**:参数名**保留 PRD 的 `accept_date`**(对外契约字面不动),**语义取同节表格口径「业务日」**,直接映射 `confirm_batch(as_of=accept_date)`;接口响应回显实际生效业务日 | PRD 表格口径与 R-1 实现一致(业务日);若按参数名取「受理日」语义,需给 `confirm_batch` 增加「精确单日受理」能力 —— 会改动 T-7 已验收的捞单窗口逻辑,超出 T-9 范围,且无业务必要(窗口上推 SLA 本就是为消化净值晚公告造成的积压)。**遗留(已在接口 docstring 标注)**:窗口是「上推 SLA」的**批量**语义,非「精确某受理日」 |
### 1.4 开工前代码事实核对表(§2 产物 · 全部带 文件:行号)
> 每条为「现状事实 + 对 v2.1 的意义」。**计数以实测为准**(grep 输出见各条注释)。
| # | 事实(现状代码) | 文件:行号 | 对 v2.1 的意义 |
| --- | --- | --- | --- |
| F-1 | `_maintain_lots` 按 **amount 反算 qty**(`qty = round2(amount / nav)`)维护普通申赎批次 | trade_gateway.py:161, 182-183, 220, 248, 285 | **v1.0 遗留与 D26 冲突**:T-9/T-10 改 redeem 份额申报 |
| F-2 | `_maintain_lots` 中 redeem 走 `qty = round2(amount / nav)` 反算后批量扣减;`deduct_share_lots` **无 `remain_qty>=:q` 哨兵** | trade_gateway.py:248, 285 · gateway_repository.py | 普通赎回超扣风险 → T-10 加哨兵(对齐 convert 严格校验) |
| F-3 | `CONFIRM_BASIS="natural_day_approx"`;`in_confirmed_at = now + timedelta(days=settings.convert_confirm_offset_days)`(自然日偏移=1) | convert_service.py · settings.py:99 | 违反 Q11(交易日历)→ T-7 换 `core_trade_calendar` 推导 |
| F-4 | 幂等锚点在 **agent 库 `risk_convert_detail`**(status pending/completed/failed/expired),非 Core 库 | convert_repository.py | 违反 D21(受理单下沉 Core)→ T-3/T-4/T-6 迁移 |
| F-5 | convert 请求模型:`from_product_id`+`to_product_id`+`qty`;未抢到执行权 → 202 + `{convert_group_id, status}` | simulate.py:44-65, 72-87, 99, 130-131 | T-9 保留 202 语义但改为「**受理成功**」+ 受理单号 |
| F-6 | `app/api/` 现有 8 文件,**无 convert 独立 API**(实测 ls:admin/audit_middleware/auth_adapter/chat/deps/knowledge/risk/simulate) | app/api/ 目录 | **撤单/确认/查询接口全新**(FR-C22/C23)→ T-9 |
| F-7 | `app/service/convert/` 现有 7 文件,**无 confirm_service**(实测 ls:calc/convert_service/errors/fee/lot_bootstrap/nav/types) | app/service/convert/ | **T+1 批处理全新** → T-7 |
| F-8 | `calc.py`:`round2/lot_amount/lot_fee/convert_amount/in_qty/rounding_diff/diff_fee(B/A)/hold_days/plan_lots/ensure_batch_limit`;全显式 ROUND_HALF_UP;plan_lots 零剩余不触发(R-h) | calc.py | T-2 扩展:D27 产品舍入 / D28 partial / D26 redeem_amount;**既有 round2 行为不动** |
| F-9 | `core_ro.py` convert 读方法齐全:`get_nav_as_of/get_redeem_fee_rules/list_share_lots/sum_remain_qty/get_holding/has_convert_trades/list_convert_trades/list_convert_lot_details`;`sum_trades_on_date` 已含 convert 去重(R-d);`list_holdings` 过滤 `qty>0` | core_ro.py | T-3 新增:`core_convert_request` 读写 + `core_trade_calendar` 查询 + 在途占用量 |
| F-10 | `rules.amount_view` **三处消费方**:①`run_rules` RISK-002/005 ②`core_tools.query_recent_trades` ③`core_ro.sum_trades_on_date`(SQL 等价条件 `(convert_group_id IS NULL OR ='' OR trade_type='redeem')`,rules.py:116-117);`_eligible` 只放行 confirmed 的 subscribe/redeem(rules.py:81-86) | rules.py:94-138 | T-8 只改「引擎时机」;去重口径**原样不动**(R-b 两流水仍是 redeem/subscribe,不受 `_eligible` 拦截) |
| F-11 | `process_convert_event`(阶段 1.5 入口):定义于 engine.py:250,转出端主流水、related_trades=[转出,转入]、`on_error_hook` 预留、异常不阻断(D17);`_run` 共用实现于 engine.py:142 | engine.py:142, 250-281 | T-8 把调用点从「受理后」移到「确认事务提交后」(FR-C28) |
| F-12 | `core_tools.py` 注释:convert 转出全部份额时批次归零、holding.qty 记 0 | core_tools.py:82 | T-11 沿用;注意「归零 + 持仓更新」口径与 T-7 一致 |
| F-13 | `convert_repository.py` 用 `get_engine(settings.mysql_database, "rw")`;状态 pending/completed/failed/expired | convert_repository.py | T-4 语义收窄:进度/审计表不动,状态机权威迁 Core |
| F-14 | `01-ddl.sql` 现有 **15 表**(实测 CREATE TABLE 清单:core_risk_grade/industry/suitability_rule/staff/customer/customer_risk/customer_advisor/product/holding/trade/cash_flow/product_nav/fee_rule/share_lot/convert_lot_detail);**缺 core_convert_request / core_trade_calendar / core_share_rule 3 表**;`core_cash_flow` 已建(:181-195)但无 convert 中转数据。**架构 §15 仍写「新增 5 表 + 2 种子」(陈旧口径,计划以实测 3 表为准)** | 01-ddl.sql:5-258 | T-1 新增 3 表 + T-16 复用 core_cash_flow |
| F-15 | `core_product` convert 扩展 8 列已建(can_subscribe/can_redeem/min_hold_qty/min_redeem_qty/min_hold_action/subscribe_fee_rate/fund_company/ta_code),**缺 share_class/allow_ac_convert/rounding_mode/share_digits 4 列**。**架构 §15 写「8 列」(陈旧,计划以实测 4 列为准)** | 01-ddl.sql:124-132 | T-1 补 4 列(rounding_mode/share_digits 走 T-14;share_class/allow_ac_convert 走 T-17) |
| F-16 | `10-seed-trade-calendar.sql` **已存在**:503 交易日(2026-01-01~2027-12-31,依上交所公告),文件头注释「表建好前不要加入 reset 清单」;2027 休市未公告 | scripts/core/10-seed-trade-calendar.sql | T-1 把表落 DDL + 加入 reset;2027 风险记 §7 |
| F-17 | settings convert 配置齐全(convert_nav_stale_days=3/convert_lock_ttl_seconds=30/convert_batch_max_lots=200/convert_diff_fee_mode="amount_diff"/convert_confirm_offset_days=1/convert_compensate_sla_hours=24/convert_deadlock_retry_max=2/convert_deadlock_retry_base_ms=50);**缺 convert_confirm_sla_days 与 convert_cutoff_time** | settings.py:91-107 | T-1 加 `convert_confirm_sla_days=2`(交易日)+ `convert_cutoff_time="15:00"`(B1);T-7 弃用自然日偏移 |
| F-18 | `nav.py` 净值取数:注释明言「本模块不查库」,`core_ro.get_nav_as_of` 实为 `nav_date <= :d ORDER BY nav_date DESC LIMIT 1`(即缺 T 日净值会**回退到 T−1**);`convert_service.py:263` 调 `core.get_nav_as_of(to_pid, trade_date)` | core_ro.py:500-514 · convert_service.py:263 | **违反 FR-C3/C27(B2)**:确认时须严格取 `nav_date == T`;缺则 nav_pending,**回退旧净值行为全部移除** |
| F-19 | `cleanup_pending_convert.py` 扫 agent 库 `risk_convert_detail` 超 SLA → expired(标记不硬删 S2);文件 103 行 | cleanup_pending_convert.py:1-103 | T-12 改写扫描对象为 `core_convert_request`(accepted/nav_pending 超 2 交易日 → expired) |
| F-20 | `rebuild_lots.py` 按 core_holding 快照重建(D18 单一副本 lot_bootstrap);文件 170 行 | rebuild_lots.py:1-170 | T-10 扩展后仍可用;不动补建规则 |
| F-21 | `scripts/dev/` 现有 verify_convert_*.py ×7 + calc_convert_demo.py;`scripts/core/` 现有 06-seed-nav.sql(60 个真实净值日)/ 10-seed-trade-calendar.sql | scripts/dev/ · scripts/core/ | T-13 全量回归重估基线;T-2b 示例随真实净值重算 |
| F-22 | convert 测试面:`tests/` 下 test_convert_{calc,concurrency,core,engine,integration,repository,service}.py ×7 + test_core_ro_sum / test_core_tools / test_trade_gateway / test_risk_* | tests/ | 回归面清单见 §5;T-13 汇总 |
| F-23 | conftest 四处 engine 已 role="admin"(R-e 已落地):conftest.py:185,186,236,237;sqlite 建表由 `create_sqlite_engine` 统一(R-g) | tests/conftest.py | T-1 新表必须双份同步(R-g 要求:落 create_sqlite_engine 的统一 DDL) |
| F-24 | **`get_nav_as_of` 回退语义是 B2 的根源**:`nav_date <= :d LIMIT 1` 恒有旧净值,nav_pending 永不触发 → T-3 须新增 `get_nav_on(product_id, trade_date)`(`nav_date == :d` 精确匹配)或改造现有方法加 strict 参数 | core_ro.py:509-512 | T-3 DoD 2 守护;R-8 改用它 |
| F-25 | **网关分派现状**:`CONVERT="convert"` 常量已在 trade_gateway.py:50,但 `SUPPORTED_TRADE_TYPES=(SUBSCRIBE,REDEEM)` 不含它(:53),convert 走 `_submit_convert` 独立分支(:105)+ `submit_trade` 的 `if trade_type == CONVERT` 分派。**T-9 不应把 CONVERT 加入 SUPPORTED_TRADE_TYPES**(那会改变「网关直写 core_trade」的申赎路径语义),保持独立分支即可 | trade_gateway.py:50-53, 105 | **O2 采纳**:T-9 改法改为「保留独立分支、SUPPORTED_TRADE_TYPES 不动」,DoD 相应改 |
### 1.5 实现级裁定(架构留白 → 实现细节 · 每条:问题/裁定/理由/被驳回的替代方案)
| # | 问题(架构留白) | 裁定 | 理由 | 被驳回的替代方案 |
| --- | --- | --- | --- | --- |
| R-1 | T+1 确认批处理怎么触发 | `scripts/core/confirm_convert_daily.py --as-of <业务日>`:调 `convert_service.confirm_batch(as_of)`;内部按受理日分组**串行**处理(组内循环调用 `confirm_one`),Redis 锁键 `convert:confirm:{as_of}` 防双跑 | PRD §5.7 运维/批处理,可指定业务日;串行满足 FR-C23;锁键复用 run_locked | 定时器常驻进程(过度;本地模拟环境以脚本触发为准) |
| R-2 | 受理单状态机迁移守卫 | 六态 `accepted / nav_pending / confirmed / rejected / cancelled / expired` 各自**只允许合法迁移**(accepted→{confirmed,rejected,cancelled,expired,nav_pending};nav_pending→{confirmed,rejected,cancelled,expired}——**其中 nav_pending→cancelled 仅理论上存在,受 R-9 撤单窗口约束(nav_pending 只在 T+1 出现、窗口早已关闭,实际不可达,保留迁移权以防误封**;终态不可再迁),`core_convert_request` 加 `status` + `updated_at`,迁移用条件 UPDATE `WHERE status=:expect` 守卫 | 防并发撤单/确认双写(FR-C18 同哲学);条件 UPDATE 天然幂等 | 应用层 if 判断(有竞态窗口) |
| R-3 | 在途占用(FR-C21)如何实现「不新增冻结列」 | 可用份额视图:`剩余份额 = core_share_lot.remain_qty − Σ(core_convert_request.qty WHERE customer+product+status IN ('accepted','nav_pending'))`;撤单/rejected/expired 自动释放(终态不再计入);查询入口放 `share_lot_repository.available_qty`,供受理校验与 T-10 普通赎回共用 | PRD FR-C21 明言不新增冻结列;由受理单推导 | 冻结列(PRD 否决) |
| R-4 | T+2 可赎回(FR-C25)「不加列」怎么实现 | 转入批次 `confirmed_at = 确认日(T+1)`;**可用判断**:`core_trade_calendar` 上前移一个交易日 → `trading_calendar.previous_biz_date(业务日)` 对比 `confirmed_at`(即 T+2 起可用)。普通赎回/再转换的可扣批次查询加此过滤 | 不加列、按日历推导,语义精确 | 加 `available_from` 列(违反 FR-C25「不加列」) |
| R-5 | 交易日历接入点 | 新纯函数包 `app/service/convert/trading_calendar.py`:`is_biz_day` / `next_biz_day` / `previous_biz_day` / `before_cutoff(dt, cutoff)`;数据源 `core_ro` 读 `core_trade_calendar`;**受理入口**(accept_convert)判定受理日(非交易日/超截点 → 下一交易日 FR-C24)+ **确认入口**(confirm 取受理日 T 净值);**截点值取自 `settings.convert_cutoff_time`(默认 `"15:00"`,禁硬编码,PRD §2.6.1)**;15:00 判定仅对「当日提交」生效,批处理回放历史不受限 | Q11 拍板按交易日历真模拟;B1 | 自然日近似(v1.0 已用,违反 Q11,弃) |
| R-6 | redeem 份额申报(D26)普通赎回侧怎么改 | `trade_gateway._maintain_lots` redeem 分支:入参改 `qty`;`赎回金额 = qty × T 日净值 − 赎回费`(复用 `fee.py` 分档);扣批次**加 `remain_qty>=:q` 哨兵 UPDATE**,扣不足抛错回滚 | PRD FR-C29 + 防超扣(F-2) | 保留 amount 反算(违反 Q15 拍板,弃) |
| R-7 | 引擎时机(FR-C28)具体改哪 | `convert_service` 确认段:`confirm_one` 事务 commit **之后**调 `engine.process_convert_event(out_trade, in_trade)`;异常落 `engine_error` 审计继续返回(D17 沿用);**受理段不再触引擎**(删 v1.0 阶段 1.5 调用) | FR-C28 明言;引擎只跑一次且在所有权益变更之后 | 受理时跑(v1.0,违反 FR-C28) |
| R-8 | 缺 T 日净值(FR-C27) | 确认段第 1 步取 **T 日** 净值:新增 `core_ro.get_nav_on(product_id, T)`(`nav_date == T` 精确匹配,F-24);无行 → 状态置 `nav_pending` 返回(不 reject、**不降级取旧净值**);补数(fetch_nav 重跑或补插 nav 行)后重跑 `confirm_one` → 继续确认。**既有 `get_nav_as_of`(`nav_date <= T` 回退)确认路径禁用于净值判定**(B2:回退会让 nav_pending 永不触发) | PRD FR-C27 明言不降级;B2 实测 core_ro.py:509-512 回退语义 | 取最近净值降级(v1.0 `_nav_as_of` 行为,违反 C27,全删) |
| R-9 | 撤单窗口判定(FR-C22) | 仅 `status='accepted'` 且**业务日 = 受理日**且提交时刻 ≤ 截点(`settings.convert_cutoff_time`,B1)可撤;否则 409 `CANCEL_NOT_ALLOWED`;撤单事务:条件 UPDATE `accepted→cancelled` + 占用自然释放(R-3 推导) | PRD §5.5;nav_pending 在 T+1 才出现,撤单窗口 T 日 15:00 前已过,不可撤(§1.3 裁定 9) | 允许 nav_pending 撤(业务上窗口已过,驳回) |
| R-10 | 部分成交(D28)怎么落库 | `core_convert_request` 加 `actual_qty DECIMAL(18,4) NULL`;确认时 `available = min(申请 qty, 当前可用)`;`actual_qty < qty` → 落 `actual_qty`、状态仍 `confirmed`、`remark='partial'`;未确认部分占用随终态自动释放 | 状态机不加 partial 态(6 态权威);记 actual_qty 差异,响应/查询可展示 | 加 `partial` 第七态(复杂化,驳回;PRD 六态) |
| R-11 | D29 资金流中转怎么建模 | 确认事务内写 **2 条** `core_cash_flow`:转出产品 `flow_subtype='redeem'`(out)+ 转入产品 `flow_subtype='subscribe'`(in),`remark` 带 `convert_group_id`;channel/金额对齐两条 core_trade;幂等:与流水同事务天然保证 | D29 注册登记账户中转 = 转出方出账 + 转入方入账;同事务防半写 | 单条汇总(丢失中转语义,驳回) |
| R-12 | D30 A/C 互转校验规则 | 放行条件:两端 `fund_company` 相同 + `ta_code` 相同 + `allow_ac_convert=1`(任一为 1 即两向放行)+ 产品不同但 `share_class` 不同;不满足 → 400 `CROSS_ENTITY_NOT_SUPPORTED` 语义沿用(换提示) | A/C 互转 = 同管理人同 TA 不同份额类别;D30 定义 | 放宽同产品(违反转换定义,驳回) |
| R-13 | D27 产品舍入接入点 | `core_share_rule(product_id, business_type)` 给 `share_digits`(默认 2);`calc.round2` **不动**,新增 `product_round(value, product_id)`:查规则取位数 → quantize;接入点 = `in_qty`(转入份额)与普通申赎 qty 落库处;**T-14 才切换**(T-2 只建入口不接) | 避免 T-2 动全局行为导致回归爆炸;T-14 一次性切换 + 全量回归 | T-2 直接接管 round2(回归爆炸,驳回) |
| R-14 | 补偿脚本改写后「重跑确认」语义 | `cleanup_pending_convert.py` 改为扫 `core_convert_request`:`accepted/nav_pending` 且受理日 < 当前业务日 − **2 个交易日** → `expired`(标记不硬删,S2 沿用);`rebuild_alerts.py` 继续仅凭 Core 侧数据(含 core_convert_lot_detail.nav/nav_date)补 agent 进度/审计 | Q14 拍板 SLA=2 交易日;验收 17 | 沿用 SLA 24h 自然小时(Q14 推翻,弃) |
| R-15 | `convert_confirm_offset_days` 处置 | **废弃**(保留字段+COMMENT 标记 deprecated,不删防破坏既有 env);T-7 全程改用交易日历;**同法处置新增 `convert_cutoff_time`(默认 "15:00")与 `convert_confirm_sla_days=2`** | 自然日近似作废(Q11);cutoff 硬化(B1);不删字段保测试环境兼容 | 直接删除(破坏既有 env 引用,驳回) |
---
## 2~N. 分批次任务
> 每个任务:目标 / 涉及文件 / 改法(逐条)/ 依赖 / DoD(可机械勾选)/ 风险 / 回滚点。
> 约定:所有新增 SQL 先写 MySQL 01-ddl.sql,**同时**按 R-g 同步到 sqlite 统一建表源(`tests/conftest.py` 或 `create_sqlite_engine`),两条 DDL 用一致性断言守护。
### T-1 · DDL + 种子(3 新表 + 4 新列 + 2 种子)
**目标**:Core 库具备 T+1 模型的全部表结构。
**涉及文件**:`scripts/core/01-ddl.sql`(追加)· `scripts/core/11-seed-share-rule.sql`(新)· `scripts/core/reset.ps1`(加入 10/11 号 seed)· sqlite 建表源(R-g)。
**改法**:
1. 01-ddl.sql 追加 `core_convert_request`:
- 列:`convert_group_id VARCHAR(64) PK`、`client_request_id VARCHAR(64) NULL`、`customer_id`、`from_product_id`、`to_product_id`、`qty DECIMAL(18,4)`、`actual_qty DECIMAL(18,4) NULL`(R-10)、`status ENUM('accepted','nav_pending','confirmed','rejected','cancelled','expired') NOT NULL`、`requested_at DATETIME(3)`、`confirmed_at DATETIME(3) NULL`、`coupon`/`remark` 等
- 索引:`UNIQUE KEY uk_idem (client_request_id)`(D21 幂等锚点;NULL 可多行)· `KEY idx_customer_status (customer_id, status)` · `KEY idx_status_asof (status, requested_at)`
- FK:customer / from_product / to_product
2. 01-ddl.sql 追加 `core_trade_calendar`(**与 10-seed-trade-calendar.sql 头注释给出的 DDL 逐字一致**):`cal_date DATE PK`、`is_open TINYINT(1)`、`remark VARCHAR(64) NULL`
3. 01-ddl.sql 追加 `core_share_rule`:`id PK`、`product_id`、`business_type ENUM('subscribe','redeem','convert')`、`share_digits TINYINT UNSIGNED DEFAULT 2`、`rounding_mode VARCHAR(16) DEFAULT 'half_up'`、`KEY uk_product_biz (product_id, business_type)`、FK product(R-13)
4. `core_product` ALTER 补 4 列:`share_class VARCHAR(16) NULL`、`allow_ac_convert TINYINT(1) DEFAULT 0`(T-17 用)、`rounding_mode VARCHAR(16) DEFAULT 'half_up'`、`share_digits TINYINT UNSIGNED DEFAULT 2`(T-14 用;与 core_share_rule 二选一优先,见 T-14 DoD)
5. 新 `11-seed-share-rule.sql`:给主要产品按 business_type 落 `share_digits`(默认 2;个别指数/货基可 4 位,种子给值并注释依据),含 `use core_share_rule` 先决
6. `reset.ps1` 执行序追加 `10-seed-trade-calendar.sql`、`11-seed-share-rule.sql`
7. sqlite 建表源同步 3 表 + 4 列,一致性断言(表/列清单 diff)
8. **settings 新增 `convert_cutoff_time="15:00"`(PRD §2.6.1,B1)与 `convert_confirm_sla_days=2`**(Q14)
**依赖**:T-0、T-0b(✅ 已完)
**DoD**:
- [ ] `数据库 reset 一次`(reset.ps1 全量重灌)成功,`SHOW TABLES` 见 18 表
- [ ] `python -c "from app.config.settings import settings; print(settings.convert_confirm_sla_days, settings.convert_cutoff_time)"` 输出 `2 15:00`(settings 同步加两字段)
- [ ] sqlite/MySQL 建表源一致性断言通过(列清单逐列 diff,0 差异)
- [ ] `10-seed-trade-calendar.sql` 可执行:`SELECT COUNT(*) FROM core_trade_calendar WHERE is_open=1` = 503(2026 区间)
- [ ] `11-seed-share-rule.sql` 可执行:`SELECT COUNT(*) FROM core_share_rule` ≥ 产品数
- [ ] pytest 基线仍 739/10 全绿(纯 DDL 不动代码)
**风险**:DDL 方言(sqlite 均支持;ENUM 用 VARCHAR 等价定义保持两端一致)· 双份漂移(DoD 第 3 条守护)
**回滚点**:新增表/列无破坏性;reset 重灌即可还原
---
### T-2 · calc.py 纯函数扩展(D27 入口 / D28 辅助 / D26 redeem_amount)
**目标**:纯函数层备齐 T+1 模型需要的算法,**不接主链路**(避免回归爆炸)。
**涉及文件**:`app/service/convert/calc.py`(扩展)· `tests/test_convert_calc.py`
**改法**:
1. 新增 `product_round(value: Decimal, digits: int) -> Decimal`:显式 `ROUND_HALF_UP` quantize 到 `digits` 位(T-14 由它接 core_share_rule;T-2 只提供函数)
2. 新增 `redeem_amount(qty: Decimal, nav: Decimal, fee: Decimal) -> Decimal`:`qty × nav − fee`(2 位 HALF_UP;D26 公式)
3. 新增 `partial_qty(requested: Decimal, available: Decimal) -> Decimal`:`min(requested, available)` + `diff = requested − actual`(R-10 辅助)
4. 新增 `next_biz_day/sec 相关纯函数`?—— **不**:日历查询依赖库表,放 trading_calendar.py(R-5),calc 只放无 IO 算法;若 trading_calendar 的判定逻辑可拆纯函数(如「15:00 判定」「周末判定」)则放 calc 供两端测
5. 既有函数**零改动**(round2 等行为不变)
**依赖**:无
**DoD**:
- [ ] 新增函数各 ≥1 单测(含边界:redeem_amount 费用>金额、partial diff 归零、product_round 4 位/0 位)
- [ ] `pytest tests/test_convert_calc.py` 全绿(既有 + 新增)
- [ ] 全量 pytest 基线 739/10 不变(纯新增)
**风险**:函数签名被 T-14 误用(DoD 1 守护行为)
**回滚点**:纯新增无破坏
---
### T-2b · calc_convert_demo 实算回填
**目标**:示例随真实净值重算,满足验收 20 强绑定。
**涉及文件**:`scripts/dev/calc_convert_demo.py` · `docs/PRD/PRD-基金转换交易.md` §5.3.2 示例 · `tests/test_convert_integration.py:278`
**改法**:
1. 按 T-2 新增公式重算示例数字(T 日净值 = 06-seed-nav.sql 真实净值)
2. 三处同步(demo / PRD 示例 / 集成测试断言),不一致即失败
**依赖**:T-2
**DoD**:
- [ ] `python scripts/dev/calc_convert_demo.py` 输出与 PRD §5.3.2、`test_convert_integration.py:278` 断言**逐字节一致**
- [ ] 验收 20 专用用例存在且绿
**风险**:净值种子刷新后漂移(DoD 1 守护)
**回滚点**:重算回填
---
### T-3 · core_ro 新方法 + share_lot_repository 扩展 + convert_request_repository(新)
**目标**:数据访问层备齐 T+1 模型读写。
**涉及文件**:`app/repository/core_ro.py`(扩展)· `app/repository/share_lot_repository.py`(扩展)· `app/repository/convert_request_repository.py`(**新**)· `tests/test_core_ro_sum.py` / `tests/test_convert_repository.py`(扩展)
**改法**:
1. `core_ro` 新增(全 `role="ro"`):
- `get_convert_request(group_id)` / `list_convert_requests(status=None, as_of=None)`
- `sum_inflight_qty(customer_id, product_id)`:`SELECT COALESCE(SUM(qty),0) FROM core_convert_request WHERE customer_id=:c AND product_id=:p AND status IN ('accepted','nav_pending')`(R-3 占用推导)
- **`get_nav_on(product_id, trade_date)`:`nav_date == :d` 精确匹配(F-24/B2,确认段净值判定专用;不得用 `get_nav_as_of` 回退语义)**
- `get_trade_calendar(start, end)` / `is_open(cal_date)`(R-5 数据源)
- `get_share_rule(product_id, business_type)`(R-13 数据源)
2. `share_lot_repository` 可用份额拆分(R-3):新增 `available_qty_with_inflight`(`Σ remain_qty − inflight`);**既有 `available_qty` 保留为「物理余量」**(两方法并存,调用点显式选,避免误用;渲染两方法用途注释)
3. **新** `convert_request_repository`(`role="rw"`,仅 `core_convert_request` 表 DML + 无 DELETE):
- `insert(request)`(首插,撞 uk_idem → IntegrityError 上抛由 service 转幂等 202)
- `transition_status(group_id, from_status, to_status)`:条件 UPDATE `WHERE status=:from`,rowcount 0 → 并发冲突(R-2)
- `list_pending_by_biz_date(as_of)`:`accepted/nav_pending` 且受理日 ≤ as_of − 1 交易日,供批处理捞单(T-7)
- `mark_actual(group_id, actual_qty)`(R-10)
**依赖**:T-1
**DoD**:
- [ ] `sum_inflight_qty` 用例:受理 50000 → 占用 50000;撤单/rejected → 归 0(真库 verify 脚本)
- [ ] `get_nav_on` 用例:有 T 日行 → 返回该行;仅 T−1 有 → **返回 None(nav_pending 前置)**(B2)
- [ ] `available_qty_with_inflight` 与「物理余量」双方法各 ≥2 用例(含占用归零边界)
- [ ] `transition_status` 并发用例:两线程同迁一个单,恰一个 rowcount=1(真库,CONVERT_STRESS 模式)
- [ ] 既有 test_core_ro_sum / test_convert_repository 全绿
- [ ] 全量 pytest 739/10 不变
**风险**:占用 LEFT JOIN 语义错(缺失 status 过滤导致把 rejected 也占住)→ DoD 1 守护;conftest 权限(新增写方法须 admin,R-e)
**回滚点**:纯新增方法;`available_qty` 改名属破坏性 → 用「新增方法 + 旧名保留别名」过渡
---
### T-4 · convert_repository 语义收窄(进度/审计)
**目标**:agent 库只留「进度镜像 + 审计」,状态机权威迁 Core。
**涉及文件**:`app/repository/convert_repository.py` · `tests/test_convert_repository.py`
**改法**:
1. 现状:操作 `risk_convert_detail` 六字段(含 status)→ 改为**语义收窄**:本表 `status` 仅表示 **agent 侧写入进度**三值(`pending`/`completed`/`failed`;`cancelled`/`expired` 为建表保留值,不写),由**唯一写入口 `sync_mirror`** 一次性整行覆盖,**不再承载状态迁移**。
⚠️ 列集与表定义严格对齐(`scripts/项目框架设计/表设计/02-mysql-agent专用.sql`;sqlite 侧 `tests/_ddl.py:140-151`)—— 实测**两库均无 `updated_at` 列**(2026-09-11 T-6 执行期核对),故 `sync_mirror` 只写 status / client_request_id / out_trade_id / in_trade_id / related_trade_id / nav / nav_date / fee_amount / hold_days_min / hold_days_max / nav_stale
2. 删除/停用 `mark_pending → completed/failed/expired` 的自主迁移逻辑;唯一写入口 = 受理/确认/撤单/补偿结果回写(由 convert_service 调)
3. `list_expired_candidates` 语义移交 T-12(扫 Core 库)
**依赖**:T-1
**DoD**:
- [ ] `risk_convert_detail` 写入口数量 grep 结果 = 预期的 4 处(受理/确认/撤单/补偿),每处有调用方注释
- [ ] 状态迁移逻辑全仓 grep 无残留「自主判定」分支(除 convert_request_repository.transition_status)
- [ ] test_convert_repository 改后全绿
**风险**:双写漂移(Core 权威、agent 镜像,时序上先 Core 后 agent)→ DoD 2 守护
**回滚点**:保留表结构与既有写路径注释;git 回退该文件即还原
---
### T-5 · run_locked 复用(统一锁原语)
**目标**:受理/确认/撤单共用锁,防并发双跑。
**涉及文件**:`app/service/risk/locks.py`(现状已实现)· `app/service/convert/convert_service.py`(接入)
**改法**:
1. 现状核对:`run_locked(name, ttl, fn)` / `try_lock`(Redis 主 + 进程内备,locks.py 已实测)
2. 定义锁键前缀:`convert:req:{customer_id}`(受理/撤单同客户串行,防同客户并发双单)、`convert:confirm:{as_of}`(R-1 批处理防双跑)
3. 锁异常降级:Redis 不可达时进程内锁兜底(locks.py 已备);锁获取失败 → 现 202 语义(受理)或直接抛 `CONCURRENT_CONFLICT`(撤单/确认)
**依赖**:无
**DoD**:
- [ ] 三处调用(受理/撤单/确认)各 ≥1 并发用例:同键双线程恰一个成功(真库)
- [ ] Redis 不可达模拟用例:降级进程锁仍互斥(既有 test_risk_locks 扩展)
**风险**:锁键粒度错(同客户锁过粗/过细)→ 用例守护
**回滚点**:锁是外加层,移除不影响数据正确性(幂等守卫仍在)
---
### T-6 · accept_convert 受理事务(核心新增)
**目标**:T 日受理:校验 + 落受理单 + 202;**不扣份额、不折算、不写流水**(FR-C19)。
**涉及文件**:`app/service/convert/convert_service.py`(新增 `accept_convert` 段)· 新 `convert_request_repository`(T-3 产物)· `trading_calendar.py`(R-5)· `tests/test_convert_service.py`(新增受理用例)
**改法**(事务顺序):
1. 锁:`run_locked('convert:req:'+customer_id, ttl=30, fn=...)`(T-5)
2. 幂等:`client_request_id` 入参非空 → `convert_request_repository.get_by_client_request_id`;命中非终态 → 返回既有受理单(202);命中终态 → 返回终态(不重开);**uk_idem 兜底**:插入撞唯一键 → 转幂等返回(R-2 守卫)
3. **现校验**(既有逻辑保留):
- 可转出/可转入(can_redeem/can_subscribe,F-15)
- 同管理人 + 同 TA(fund_company/ta_code,跨机构 → `CROSS_ENTITY_NOT_SUPPORTED`)
- min_hold_qty / min_redeem_qty / 最低持有动作(force_transfer 强制全转记 qty;R-h plan_lots 零剩余不触发)
- 风险适当性(**唯一阻断点**,受理时不过 → 400 blocked,受理单都不落,PRD 验收 4)
4. **受理日判定**(R-5):非交易日 / 超过截点(`settings.convert_cutoff_time`,B1;仅对当日提交生效)→ `next_biz_day`;受理单 `requested_at` 按此落
5. 可用份额校验(R-3):`available_qty = 物理余量 − inflight`;`qty > available` → 400 `INSUFFICIENT_SHARES`(验收 22)
6. Insert `core_convert_request(status='accepted', qty, actual_qty=NULL)`(Core 单库事务)
7. 回写 `risk_convert_detail` 镜像(T-4)+ 审计(agent 附加写入,失败走补偿 T-12)
8. 返回:`202 + {convert_group_id, status: 'accepted', requested_at}`(Q2:不返回折算金额)
**依赖**:T-1 / T-3 / T-5
**DoD**:真库 verify 脚本 `verify_convert_accept.py`:
- [ ] 受理成功:`core_convert_request.status='accepted'`、`core_share_lot.remain_qty` **不变**、`core_trade` **无新行**、HTTP 202(验收 21)
- [ ] 同 `client_request_id` 重复提交:第二次返回同一 `convert_group_id`,`core_convert_request` 仍 1 行(验收 12)
- [ ] 占用:受理 50000 后 `available_qty` = 余量 − 50000;再超量申请 → 400 `INSUFFICIENT_SHARES`(验收 22)
- [ ] 非交易日 / 超过截点提交:`requested_at` = 下一交易日(验收 28)
- [ ] 适当性不匹配:400 blocked 且受理单 0 行(验收 4)
- [ ] 强制全转:`qty` 记实际全转量(验收 13 前半)
- [ ] pytest 新增受理用例绿;全量回归 739+ 绿
**风险**:幂等窗口(INSERT 撞 uk_idem 竞态)→ DoD 2 守护;事务边界(Core 单库事务,agent 失败不阻断 Core 已提交,走补偿)
**回滚点**:受理单可撤(T-9 撤单接口)→ 数据自愈
---
### T-7 · convert_service 三段编排 + confirm_service(T+1 批处理 · 核心重写)
**目标**:受理/确认/撤单三段 + `confirm_service` 批处理;**确认事务** = 扣批次 + 两条流水 + 两端持仓 + 资金流中转(T-16 落地于此)+ 部分成交。
**涉及文件**:`app/service/convert/convert_service.py`(重写编排)· `app/service/convert/confirm_service.py`(**新**)· `app/service/convert/trading_calendar.py` · `app/service/convert/nav.py`(改 T 日净值)· `convert_core_repository.py`(确认事务重写)· `tests/test_convert_service.py` / `test_convert_core.py` / `test_convert_integration.py`(重写)· `settings.py`(废弃自然日偏移字段 R-15)
**改法**:
1. `convert_service` 拆三入口:`accept_convert`(T-6)/ `cancel_convert`(T-9 接口调用,逻辑在此)/ `confirm_batch(as_of)` + `confirm_one(group_id)`
2. **确认段**(confirm_one,Core 单库事务,顺序不可换):
- ① 取受理日 T 净值(R-8/B2:`core_ro.get_nav_on(product, T)` 精确匹配,**不用 `get_nav_as_of` 回退语义**):缺 → 置 `nav_pending` 返回(不 reject 不降级);重跑继续
- ② T+1 适当性复核(D25,验收 4 后半):复核不通过 → `rejected` + 占用释放 + 审计留痕(验收 26)
- ③ FIFO 扣批次(既有 apply_convert 逻辑复用):条件 UPDATE 哨兵(冗余校验 + 并发兜底)
- ④ 部分成交(R-10):`actual = min(qty, available)`;actual < qty → 记 actual_qty + remark='partial'(验收 30)
- ⑤ 折算:转入 `in_qty = product_round(转出金额净值折算)`;补差费按 `convert_diff_fee_mode`(默认 amount_diff,D29 相关费用随资金流)
- ⑥ 落两条 `core_trade`(转出 redeem / 转入 subscribe,同 convert_group_id,R-b)+ `core_convert_lot_detail`(资金流中转 core_cash_flow 2 条由 **T-16** 承担,本任务只落流水与明细)
- ⑦ 更新两端 `core_holding`(qty/cost_amount/as_of/pnl_pct)
- ⑧ 置 `status='confirmed'`(条件 UPDATE 守卫)
- ⑨ commit 后:`engine.process_convert_event(out, in)`(R-7,只跑一次)+ agent 回写 + 审计
3. `confirm_batch(as_of)`:`run_locked('convert:confirm:'+as_of)` → `list_pending_by_biz_date(as_of)` → **串行**逐单 `confirm_one`(组内串行 FR-C23)→ 汇总结果
4. 净值取数:`nav.py` 删 `_nav_as_of` 的「取 T−1」路径,全部改「取指定日 T」(F-18)
5. settings:`convert_confirm_sla_days=2` 启用;`convert_confirm_offset_days` 标记 deprecated(R-15)
6. 删除 v1.0「阶段零占位 pending + 阶段一实时 apply + 阶段二回写」三阶段(旧幂等锚点 agent 库)→ 由 T-6 受理单替代
**依赖**:T-2 ~ T-6
**DoD**:真库 verify 脚本 `verify_convert_confirm.py`:
- [x] 确认:`core_share_lot.remain_qty` 扣减、`core_trade` 恰 2 行同 gid、两端 `core_holding` 更新、状态 `confirmed`(验收 2/10/24)
- [x] 重复触发 confirm_batch 同 as_of:第二次 0 处理,流水不重复(验收 24 后半 / 15)
- [x] 缺 T 日净值:状态 `nav_pending`、无流水、`remain_qty` 不变;补 nav 后重跑 → `confirmed`(验收 25)
- [x] T+1 复核不通过 → `rejected` + 占用释放(验收 26)
- [x] 部分成交:占用被抢 → `actual_qty < qty`、remark='partial'、未确认部分占用释放(验收 30)
- [x] 跨批次:各批按持有期计费、明细落 `core_convert_lot_detail`(验收 8)
- [x] 确认事务内**不写** `core_cash_flow`(资金流写入唯一归属 T-16,反双重归属;若 T-16 未落地此断言恒守住)
- [x] 引擎恰好跑一次:`risk_alert` 1 条、RISK-002 不翻倍(验收 5/7/29)
- [x] pytest 重写用例全绿;**全量回归 739+(含 test_convert_integration 改写)**
**风险**:确认事务变长(扣批次 + 流水 + 持仓 + 资金流)→ 死锁概率↑(沿用 1213 重试外壳 43cf2a1);nav_pending 无限挂起(SLA 兜底 T-12);并发双跑(锁 + 条件 UPDATE 双守卫)
**回滚点**:受理单可撤(确认前);本任务改动面大,先基线快照再改
**执行记录(2026-09-11 · 提交 `048f1a9`)**
真库 `verify_convert_confirm.py` **83 项一致 / 0 不一致**;全量 pytest **827 passed / 10 skipped**(T-6 基线 798)。
基准日取日历中位开市日(本次实跑 T=2027-01-14 / T+1=2027-01-15),并用 PRD §5.3.2 示例输入在真库上
**逐字节重放**:`68020.00 / 612.18 / 67407.82 / 333.36 / 67074.46 / 34945.54 / -0.0049`。
与计划的差异与裁定(**均已在代码注释与本节留痕**):
1. **确认段不做最低持有重判(计划缺口 · 本次新发现)**。计划 ①-⑧ 未指明确认段是否重跑
`plan_lots` 的最低持有判定。实测暴露:受理段已把 `qty` **收敛为实际全转量**(申请 48000
→ 落库 50000),确认段若重判会因 `leftover = 0` 得出 `forced_full_transfer = False`(漏报);
更危险的是 T→T+1 之间可用份额变化(部分成交 / 他人申赎 / 产品参数改)会让重判得出与
**受理承诺不一致**的结论 —— 例如可用涨到 60000 后反被判「留 12000 < 新阈值」而擅自
把客户指令扩大成全转。**裁定:强制全转是受理段决策**,受理段把结论落受理单
`remark='full_transfer'`(新增常量 `REMARK_FULL_TRANSFER`),确认段**只继承、不重判**
(`plan_lots` 不传 `min_hold_qty`)。`remark` 支持多标记 `;` 连接,
故部分成交的强制全转单记 `full_transfer;partial`。用例守护:
`test_confirm_does_not_promote_to_full_transfer`。
2. **新增三个共用模块**(计划未列):`format.py`(`D2/D4/new_id/q` 展示规格唯一出口)、
`audit.py`(`write_convert_audit` 唯一审计写入点)、`engine_call.py`
(`run_convert_engine` 引擎唯一调用点)。理由:受理段与确认段共用同一套量化 / 审计 /
引擎口径,若两段各写一份必然漂移。`engine_call.py` 的引入完成了 T-8「引擎时机」的
**实现部分**(调用点已迁到确认事务 commit 之后,且全仓恰 1 处调用)。
⚠️ **订正(2026-09-11,T-8 执行期发现)**:本行原写「T-8 剩余工作仅为 `rules.amount_view`
与三处消费方同步」——**错**。F-10 已明确 T-8 **不动**去重口径(`amount_view` 三处消费方
**原样不动**,属 T-11 的范围)。T-8 剩余的是**验证面**:调用点唯一性守护 + 受理不触引擎用例
+ 确认链路真引擎出单 / RISK-002 不翻倍的真库断言。详见下方 T-8「执行记录」。
3. **`nav.py` 未改**:确认段直接调 `core_ro.get_nav_on(product, T)`(T-3 已新增的精确匹配口径),
无需经 `nav.py`。计划第 4 条要删的「取 T−1 路径」(`_nav_as_of`)属 v1.0 `convert_fund`,
该函数已标 `⛔ Deprecated`,随 T-9 一并删除。
4. **`test_convert_integration.py` 未改写**:其现状依赖 v1.0 `convert_fund` 全链路;
T-7 另起 `tests/test_convert_confirm.py`(24 例)覆盖确认段。integration 改写与本计划
「test_convert_service 重写」一并在 **T-9(api 层落地后)** 做,届时端到端才有真接口可打。
5. **确认事务原子性口径收窄**:计划 ⑧「条件 UPDATE 置 confirmed」在实现中**并入
`apply_convert` 同一事务**(`ConvertApplyInput.convert_request_id` + gateway 内
`_confirm_request`),`rowcount != 1` → 抛 `ConfirmConflict` 整事务回滚。若按计划放在
事务外,会出现「批次已扣、受理单未置位」的中间态(超扣风险)。
6. **MySQL `rowcount` = changed rows 陷阱**:`nav_pending` 重试时
`SET status='nav_pending' WHERE status='nav_pending'` 返回 0,直接复用 `transition_status`
的冲突判定会把**正常重试误判为并发冲突**。已修为「状态未变则不迁移」+ 返回 `transitioned`
标志(sqlite 返回 1、MySQL 返回 0 的方言差异由此抹平)。
---
### T-8 · 引擎时机改(确认后跑一次)
**目标**:FR-C28:引擎从受理后移到确认事务提交后;去重口径**不动**。
**涉及文件**:`app/service/convert/convert_service.py`(调用点迁移)· `app/service/risk/engine.py`(签名沿用)· `tests/test_convert_engine.py` / `test_risk_engine.py`
**改法**:
1. `process_convert_event` 保持现有签名/语义(转出端主流水、related_trades 两条、on_error_hook 预留,F-11)
2. `convert_service` 删除受理段引擎调用;在确认事务 commit **后**插入唯一调用点(R-7)
3. `amount_view` / `sum_trades_on_date` / `core_tools` **零改动**(三处同步口径红线 F-10)
4. 引擎异常 → `engine_error` 审计 + 不阻断(D17 既有行为保留)
**依赖**:无(引擎时机改动不与 T-7 强绑定前置,但联调在 T-7 DoD 覆盖)
**DoD**:
- [x] grep:`process_convert_event(` 全仓恰 1 处调用(确认段)
- [x] 受理路径用例:受理后 `risk_alert` 0 新增(验收 29 前半)
- [x] 确认路径用例:引擎跑一次、RISK-002 计一次(验收 5/7)
- [x] RISK-001/003 仍见两条(去重未删行,验收 6)
- [x] pytest 全绿
**风险**:调用点漏清(旧受理段残留)→ DoD 1 守护
**回滚点**:单点改动,git 回退即还原
**执行记录(2026-09-11 · 提交 `57f16a5`)**
真库 `verify_convert_engine.py` **39 项一致 / 0 不一致**(A0/A/B/C/D/E/F 七组);
全量 pytest **829 passed / 10 skipped**(T-7 基线 827,+2);复跑 T-6 `verify_convert_accept.py`
**37/37**、T-7 `verify_convert_confirm.py` **83/83**、v1.0 链路 `verify_convert_service.py` **35/35**,均零失败。
**本轮实际范围(与任务书字面的差异,已留痕)**:
1. **引擎时机的「实现」在 T-7 已完成,本轮只做「守护 + 端到端验证」**。T-7 抽出
`engine_call.run_convert_engine` 时即把调用点放在确认事务 commit 之后,且全仓仅此一处;
`accept_convert` 自 T-6 实现起就不含引擎调用(阶段 1.5 属 v1.0 `convert_fund`,不在受理段内)。
故 R-7 的两条要求(受理不触引擎 / 确认后唯一调用点)在 T-7 已成立,本轮补的是**可回归的守护**。
2. **DoD 1 的 grep 改用 AST 实现**。字符串匹配会被 `engine_call.py` 的说明段
(「`process_convert_event(` 全仓应恰 1 处调用」)误判为第二处调用 —— 该守护若用 grep 写,
会恒红或被迫加脆弱的排除规则。改用 AST 统计 `Call` 节点,并同时断言宿主函数为
`run_convert_engine`(防「文件对但函数被搬走」)。**已做突变验证**:
临时追加第二处调用 → 断言变红 → 还原。
3. **`convert_fund` 的阶段 1.5 调用点本轮不动**。该函数是 v1.0 实时模型的全流程入口
(已被 4 个测试文件 + gateway 依赖),T-9 切换 API 后整体删除。本轮若只摘掉它的引擎调用,
会让过渡函数变成「扣了份额但不出预警单」——**风控缺失比时机错更危险**,故保留,
由 T-9 随函数一并清除。
4. **确认链路的真引擎断言采用「正证 + 反证」双断言**。仅断言「RISK-002 不命中」无法区分
「去重生效」与「引擎根本没跑」;故正证(阈值夹逼 → 不命中)之外补反证
(阈值下调 → 命中),并做突变验证(禁用引擎调用 → 变红)。
5. **真库脚本踩坑(已钉进脚本注释)**:`confirm_one(thresholds=...)` 必须**显式传阈值**,
缺省退回 `RiskThresholds.from_settings()`;settings 的 `daily_total` 一旦低于单条金额,
RISK-002 就会「命中」,**症状看着像去重失效,实为阈值口径没传**(首轮实跑被 B 组断言捕获)。
---
### T-9 · api 层(redeem 份额申报 + 撤单/确认/查询接口 + 网关分派)
**目标**:对外契约对齐 T+1 模型。
**涉及文件**:`app/api/simulate.py`(改)· `app/api/convert_admin.py`(**新**)· `app/gateway/trade_gateway.py`(分派)· `app/service/convert/errors.py`(错误码)· `tests/test_integration_risk.py`(改写;**test_risk_api.py 实测 0 处 convert 引用,C4 无关不改**)
**改法**:
1. `simulate.py` convert 分支:返回改 `202 + {convert_group_id, status: 'accepted', requested_at}`(F-5 语义升级:202 从「未抢到执行权」改为「受理成功」;「未抢到执行权」仍 202 但并存 status 区分——**两类 202 需分辨**:`status='accepted'`=受理成功 / `status='processing'`=并发执行中,PRD §8.3)
2. **新增 `app/api/convert_admin.py`**(**路径以 PRD 为准**,见 §1.3 裁定 11/12):
- `POST /api/simulate/trade/convert/{convert_group_id}/cancel`(PRD §5.5):撤单(R-9;accepted + 窗口内 → cancelled;否则 409 `CANCEL_NOT_ALLOWED`);鉴权:**本人可撤(交易 owner 闸门,红线 7)**
- `POST /api/admin/convert/confirm?accept_date=<业务日>`(PRD §5.7):触发批处理(R-1 → `confirm_service.confirm_batch(as_of=accept_date)`;运维接口,鉴权同 admin 级);**参数名沿用 PRD `accept_date`、语义取 PRD §5.7 表格口径「业务日」**(裁定 12)
- `GET /api/simulate/trade/convert/{convert_group_id}`(PRD §5.7):查询状态 + 折算结果(确认后才有折算金额,Q2;未确认 → status + 无金额);鉴权:本人/代理人可查(查询 scope 闸门)
3. `trade_gateway.py`:**`SUPPORTED_TRADE_TYPES` 不动(F-25/O2)**——convert 走既有独立 `_submit_convert` 分派(trade_gateway.py:105),把内部实时确认改调 `accept_convert`(受理),**不把 CONVERT 加进支持元组**(避免改变申赎路径语义)
4. `errors.py`:新增 `CANCEL_NOT_ALLOWED`(409)/ `CONCURRENT_CONFLICT`(锁冲突);既有错误码保留
5. redeem 改份额申报(D26 · R-6):`TradeRequest` redeem 分支 `qty` 入参(替代 amount 反算);网关 redeem 调 `_maintain_lots` 新签名
**依赖**:T-7
**DoD**:
- [x] API 契约用例:受理 → 202 + accepted;**撤单**窗口内成功(cancelled + 占用释放)、窗口外 409(验收 23);确认后撤 → 409
- [x] 查询接口:确认前无折算金额字段、确认后有(Q2)
- [x] 两类 202 分辨用例存在
- [x] **`SUPPORTED_TRADE_TYPES` 维持 2 类型(SUBSCRIBE/REDEEM,O2)**;convert 走独立分派不再 400(验收 1)
- [x] redeem 份额申报用例:`qty` 入参与 `amount` 反算路径断言的既有用例**改写后**绿(回归面 F-25→R-回归 1)
- [x] 撤单/查询接口鉴权用例:本人可撤/可查、代理人可查不可撤、他人 403(红线 7)
- [x] pytest 全量绿(test_integration_risk 中旧 400 断言已改为受理 202;test_risk_api 无 convert 断言、零改动,C4)
**✅ T-9 完成(2026-09-11)**:`app/api/convert_admin.py`(**路径以 PRD 为准**,裁定 11/12)三接口 + `simulate.py` convert 分支 202 + `trade_gateway` 分派改调 `accept_convert`(`SUPPORTED_TRADE_TYPES` 维持 2 类型)+ redeem 份额申报 + 错误码新增 `CANCEL_NOT_ALLOWED`(409)/`CONCURRENT_CONFLICT`(409)。**验证**:① 端到端 `tests/test_convert_integration.py` 重写为 T+1 两段链路 + 新增 API 契约/鉴权用例(**13 passed**);② 全量 pytest **834 passed / 10 skipped**(基线 829 + 新增 5);③ 真库新脚本 `verify_convert_api.py`(HTTP 全链路)**88/88**,T-6/T-7/T-8/v1.0 四脚本复跑**零回归**(37/37 · 83/83 · 39/39 · 35/35);④ **3 组突变验证**(受理回执/未确认查询泄漏折算字段、顾问误放行撤单)**全部命中后还原**。**执行期发现并修正 2 处口径缺陷**:① `_rebuild_quote` 的 `out_nav` 原从「金额÷份额」反推(0.005 档 HALF_UP 舍入致 1.3604 → 1.3600 漂移)→ 新增 `_out_nav_rebuild` 优先直读 `core_convert_lot_detail.nav`(与确认段首次折算同源,缺失才回退反推);② `_accept_idempotent` 字段面扩展与受理回执对齐(补 `requested_qty/qty/forced_full_transfer/accept_date/confirm_date/available_date/cancel_deadline/actual_qty`,原仅 qty/actual_qty/requested_at → 幂等重试客户端否则拿不到预计确认日)。
**风险**:契约变更打穿 test_integration_risk 的 convert→400 断言 → 回归面 §5 处置(test_risk_api 无 convert 断言、非风险项,C4)
**回滚点**:API 加字段不删旧字段(兼容期);撤单接口新增无破坏
---
### T-10 · 普通申赎批次维护 + T+2 校验 + rebuild_lots
**目标**:FR-C16 份额批次全生命周期(普通申购落批次、普通赎回 FIFO 扣批次 + 哨兵)+ FR-C25 T+2 可赎回 + redeem 份额申报(D26 在普通赎回侧落地)。
**涉及文件**:`app/gateway/trade_gateway.py`(`_maintain_lots` 重写)· `app/gateway/gateway_repository.py`(哨兵)· `app/repository/share_lot_repository.py`(T+2 过滤)· `scripts/core/rebuild_lots.py`(回归验证)· `tests/test_trade_gateway.py`
**改法**:
1. `_maintain_lots` subscribe 分支保持(amount → qty 落新批次,F-1 中正确的一半)
2. redeem 分支重写(R-6):`qty` 入参 → 校验可用(**含在途占用与 T+2 过滤**,R-3/R-4)→ FIFO 扣批次带哨兵 `UPDATE ... SET remain_qty = remain_qty - :q WHERE lot_id=:id AND remain_qty >= :q`(gateway_repository 补哨兵,对齐 convert 严格校验)
3. T+2 过滤(R-4):`list_share_lots(..., available_from=业务日前一交易日)`;普通赎回/再转换可扣批次仅含 confirmed_at ≤ 该日的批次
4. `rebuild_lots.py` 回归验证(D18 单一副本不动)
5. `test_trade_gateway.py` 改写:amount 反算断言 → 份额申报断言(回归面 §5)
**依赖**:T-3
**DoD**:
- [x] 普通申购 → 新批次;普通赎回按 FIFO 扣批次,`remain_qty` 不出现负数(哨兵触发回滚用例存在)—— `test_redeem_deducts_fifo_across_lots` + `test_deduct_sentinel_blocks_overdraw`
- [x] T+2 用例:T+1 确认的转入批次在 T+2 前不可扣、T+2 起可扣(验收 27)—— `test_redeem_t2_not_yet_available_blocks_t1_lot`(申报 150 > 可赎 100 区分度)+ `test_redeem_t2_available_includes_t1_lot`(半开区间双向)
- [x] redeem 份额申报金额公式用例:`qty × T 净值 − 赎回费`(D26)—— `test_redeem_amount_formula_qty_times_nav_minus_fee` 断言 `1000×1.0−5.0=995.00`
- [x] 转换后普通赎回:批次与持仓一致、不超扣(验收 9)—— `test_redeem_after_convert_leaves_no_overdraw`
- [x] `rebuild_lots.py --dry-run`:无漂移报告 —— 真库 58 持仓行 0 补建 0 写入
- [x] pytest 全量绿(test_trade_gateway 改写 + 既有共享)—— **844 passed / 10 skipped**(基线 834 + 新增 11,零回归)
**✅ T-10 完成(普通申赎批次维护 + T+2 校验 + rebuild_lots · 2026-09-11)**:`trade_gateway` 主流程改 —— 新增 `_t2_available_from`(`trading_calendar.previous_biz_day`,日历缺行 try/except `ValueError` → `None` 不过滤,金额/扣减同口径);`_redeem_quote` 走 `available_qty_with_inflight` **硬校验**(R-3,申报超可赎抛 `InsufficientShares`,不静默裁剪改写客户指令);`_maintain_lots` redeem 分支三重裁剪(T+2 过滤 → D8 补建 → 在途占用 → 哨兵 FIFO 扣减);`core_ro.list_share_lots`/`share_lot_repository.select_for_convert` 加 `available_from` **T+2 半开区间过滤**(R-4,`confirmed_at < 前一交易日+1天`);`gateway_repository` 扣批次哨兵 `remain_qty >= :q`。**验证**:`tests/test_share_lot.py` +10 + `tests/test_trade_gateway.py` +1 → 全量 **844 passed / 10 skipped**(834+11 零回归);真库 `verify_convert_api.py` **89/89**(H 节提前到受理前执行 + 原位补 H2「受理占满在途后 redeem 申报抛 `InsufficientShares`」真库断言,88+1);`rebuild_lots.py --dry-run` 无漂移。**突防验证**:临时移除 `select_for_convert` 的 `available_from` 透传 → `test_*_t2_*` 变红(区分度成立,非假绿)→ 还原。**断点与口径**:① `AttributeError: TextClause.format` → 改为字符串 `.format(extra=...)` 再 `text(...)`;② 金额侧初版用 `plan_lots` 全量算、扣减侧裁剪到 `avail` → 改硬校验同口径;③ 在途占用(R-3)放普通赎回侧 = 同一份份额不能既等转换又已赎回。
**风险**:打穿 test_trade_gateway 大量断言(amount 反算)→ 回归面 §5 逐条处置;哨兵缺位致超扣(DoD 1 守护)
**回滚点**:_maintain_lots 独立函数,git 回退即还原
---
### T-11 · core_tools 汇总去重 + 持仓过滤
**目标**:对话侧金额汇总不翻倍(验收 11)。
**涉及文件**:`app/tool/core_tools.py`(沿用)· `tests/test_core_tools.py`
**改法**:
1. `query_recent_trades` 的 `sum_amount` 已同调 `amount_view`(F-10)→ **确认不回归**:补一条 convert 组用例
2. 持仓过滤 `qty>0`(F-9,core_ro.list_holdings 已实现)→ 补 convert 全转归零用例(F-12 场景:转出全部份额后 holding.qty=0 行不在持仓列表)
**依赖**:T-8(口径三处同调链)
**DoD**:
- [ ] convert 两流水场景:`sum_amount` 计一次(验收 11)
- [ ] 全转归零:`list_holdings` 不含 qty=0 行
- [ ] pytest 全量绿
**风险**:低;仅补用例
**回滚点**:无代码改动风险
---
### T-12 · 补偿脚本改写
**目标**:SLA 超时兜底 + agent 附加写入补偿,扫描对象迁 Core。
**涉及文件**:`scripts/agent/cleanup_pending_convert.py`(改写)· `scripts/agent/rebuild_alerts.py`(沿用)· `scripts/core/rebuild_lots.py`(沿用)
**改法**:
1. `cleanup_pending_convert.py` 重写(R-14):扫 `core_convert_request` 中 `status IN ('accepted','nav_pending')` 且受理日 < 当前业务日 − 2 交易日 → 置 `expired`(标记不硬删 S2,条件 UPDATE 守卫);`--hours` 参数改为 `--days`(交易日数)或直接取 `convert_confirm_sla_days`
2. `rebuild_alerts.py`:仅凭 Core 侧数据(含 `core_convert_lot_detail.nav`/`nav_date`)补 agent 进度/审计(F-19 已具备补偿数据源)
3. 全仓 grep 确认无其它处扫描 agent 库占位
**依赖**:T-4 / T-7
**DoD**:
- [ ] 造数:accepted 超 2 交易日 → 脚本执行后 `expired`(真库,仿 verify_convert_compensate.py)
- [ ] nav_pending 超 SLA → `expired`(不早于确认窗口)
- [ ] 第二次执行 0 处理(幂等)
- [ ] `rebuild_alerts.py` 从 Core 净数据补出的 detail 与流水一致(验收 17)
**风险**:SLA 判定用交易日而非自然日(Q14)→ 用 trading_calendar.previous_biz_day 链式计算
**回滚点**:脚本只改状态不硬删(S2),天然可逆
---
### T-13 · 全量回归 + 集成 + 并发 + 性能补录 + `convert_fund` 退役
**目标**:第 6 步前的门禁总校验 + v1.0 过渡函数收尾。
**涉及文件**:全部 · `scripts/dev/verify_convert_*.py` 套件 · `app/service/convert/convert_service.py` · `docs/项目框架设计/开发计划-基金转换交易.md`(本文件基线更新)
**改法**:
1. 全量 pytest:`739 基线 + 新增 convert 用例`,目标 **739 + N 全绿**
2. 真库 verify 套件全跑:accept / confirm / compensate / engine / lots / seed / service / tools(现有 7 个 + 新增 verify_convert_accept / verify_convert_confirm)
3. 并发:CONVERT_STRESS=1 用例全跑(受理并发 + 确认批处理并发双跑)
4. 性能补录(验收 18,不得改数据迁就指标):受理/确认事务耗时实测记录;端到端响应 < 2s
5. 紧池成功率重测(验收 31,设计目标 100%):构造需求=供给的紧池场景,批量受理 → 全部 accepted;确认后无超卖
6. **`convert_fund` 退役**(用户 2026-09-11 拍板并入 T-13):v1.0 两阶段实时过渡函数已无生产调用方(T-9 已把路由/网关/引擎套件切到 `accept_convert`/`confirm_one`)→ 本轮删除函数体 `convert_service.convert_fund`,同步清理其依赖:向 `verify_convert_service.py`(v1.0 链路脚本)提供替代验证路径或按 v1.0 模型留档;约 25 处测试改写为直接不发 v1.0 链路(R-回归 2/7 已有 T-6/T-7 的替代断言);迁移完成后跑全仓 pytest + 真库套件确认无游离引用
**依赖**:全部任务
**DoD**:
- [ ] `pytest -q` 全绿(计数记录到本文件头)
- [ ] 7+2 个真库 verify 脚本全部退出码 0(失败=1)
- [ ] CONVERT_STRESS 并发 9/9 通过
- [ ] 性能耗时实测定格进文档(受理/确认事务、端到端;**端到端 < 2s 为硬门禁,超限须归因后回填**)
- [ ] 紧池场景成功率实测记录(**目标 100%:成功率 < 100% → 记录 P50/P95/max 与瓶颈归因,作为第 6 步验收 31 前置数据;超卖不变量继续成立 = 硬门禁**)
- [ ] `convert_fund` 及其 v1.0 依赖链移除后全仓 pytest + 真库套件零回归(grep `convert_fund` 无生产调用残留)
**风险**:基线重估引入新增失败 → 逐条归因(归因铁律:先复跑真库脚本确认非环境噪音再改代码);`convert_fund` 删除可能打穿 `verify_convert_service.py` 与约 25 处测试 → 需一并改写(R-回归 2/7 已计划)
**回滚点**:无(纯验证 + v1.0 过渡函数删除,代码库有 git 历史可恢复)
---
### T-14 · D27 产品舍入真正接入
**目标**:`core_share_rule` seed + calc 接入(R-13,验收 10 兜底)。
**涉及文件**:`app/service/convert/calc.py`(`product_round` 接入)· `app/repository/core_ro.py`(get_share_rule 已备)· `scripts/core/11-seed-share-rule.sql` · `tests/test_convert_calc.py`
**改法**:
1. `in_qty` 与普通申赎 qty 落库处改用 `product_round(value, share_digits)`(digits 来源:core_share_rule.business_type 匹配,缺省 2)
2. 覆盖率补偿:新测试自建 core_share_rule 种子(4 位产品)测真实路径,**不得让降级路径当覆盖**(skill 反面教训 3)
3. 种子给 2 位默认 + 个别 4 位注释依据
**依赖**:T-1 / T-2
**DoD**:
- [ ] 4 位产品用例:转入份额 quantize 到 4 位(非降级)
- [ ] 缺规则产品:回退 2 位(显式降级用例存在但**非唯一覆盖**)
- [ ] 与 `round2` 默认行为一致性断言(2 位路径逐字节一致)
- [ ] pytest 全绿
**风险**:全局切舍入炸既有断言 → 与 round2 一致性断言守护
**回滚点**:单点切换(product_round 封装)
---
### T-15 · D28 部分成交专项测试 + 边界补强
**目标**:验收 30 全路径覆盖。
**涉及文件**:`tests/test_convert_integration.py` / `tests/test_convert_concurrency.py`(新增用例)· `app/service/convert/convert_service.py`(若边界暴露则补强)
**改法**:
1. 用例矩阵:占用被抢(另一单先确认)/ 部分确认后剩余占用释放 / 撤单与确认竞态 / actual_qty=0 边界(占用全被抢 → ?裁定:`actual_qty=0` 视为确认失败 → `rejected`,R-10 补强)
2. 并发:`CONVERT_STRESS` 下两单争同一批 → 先到先得
**依赖**:T-7
**DoD**:
- [ ] 验收 30 每条子场景 ≥1 用例绿(真库)
- [ ] `actual_qty=0` 边界:状态 `rejected`(不产生零额确认)
- [ ] pytest 全绿
**风险**:竞态不可复现 → 用真库 + 并发钩子确定性复现(skill 并发缺陷铁律)
**回滚点**:补强点独立
---
### T-16 · D29 资金流中转建模
**目标**:确认事务写 `core_cash_flow`(R-11,**本任务是 core_cash_flow 唯一写入点**——T-7 确认流程不写、防双重归属;金额与流水一致,验收 2/10/24 的资金流侧)。
**涉及文件**:`app/gateway/convert_core_repository.py`(确认事务内补写)· `scripts/core/01-ddl.sql`(core_cash_flow 已建,F-14,不需 DDL)· `tests/test_convert_core.py`
**改法**:
1. 确认事务内、两条 core_trade 之后补 2 条 `core_cash_flow`(R-11:redeem out + subscribe in,remark 带 gid,channel/金额对齐)
2. 幂等:与流水同事务天然保证(重跑确认的 0 处理由 T-7 守卫)
**依赖**:T-6
**DoD**:
- [ ] 确认后 `core_cash_flow` 恰 2 条、金额与流水一致(验收 T-16)
- [ ] 确认失败回滚:core_cash_flow 0 条(同事务)
- [ ] pytest 全绿
**风险**:流水语义漂移(flow_subtype ENUM 值已含 redeem/subscribe,F-14 无需 ALTER)
**回滚点**:确认事务整体回滚保护
---
### T-17 · D30 share_class + A/C 互转
**目标**:同基金 A/C 份额互转(R-12)。
**涉及文件**:`app/service/convert/calc.py` 或 `convert_service.py`(校验放行)· `scripts/core/01-ddl.sql`(4 列已补 T-1)· `scripts/core/02-seed-base.sql` / `03-seed-customers.sql`(种子 share_class / allow_ac_convert)· `tests/test_convert_service.py`
**改法**:
1. 校验放行(R-12):`fund_company` 同 + `ta_code` 同 + `(allow_ac_convert=1)` + 产品不同;满足则允许,错误码沿用 `CROSS_ENTITY_NOT_SUPPORTED` 语义(提示换「A/C 互转不支持」)
2. 种子:至少一对 A/C 产品(同管理人同 TA、allow_ac_convert=1)与一对不允许的产品做对照
3. 转出/转入金额折算同既有公式(A/C 净值不同,按各自 T 净值)
**依赖**:T-1 / T-9
**DoD**:
- [ ] A/C 互转用例:同管理人同 TA + 开关开 → 成功受理
- [ ] 开关关 / 跨管理人 / 相同 share_class → 400(对照)
- [ ] pytest 全绿
**风险**:校验放行规则与「同产品禁转」冲突 → R-12 已裁定(share_class 不同即放行)
**回滚点**:校验是前置分支,git 回退即还原
---
## 5. 回归面清单(必然打穿的既有断言 · 全 tests/ 扫)
> 处置原则:**口径被 v2.1 推翻** → 改写断言并注明上游依据;**口径仍成立** → 断言语义保留只改数据路径;**误伤** → 保留断言。
| # | 位置 | 现有断言 | 为何失效 | 处置 |
| --- | --- | --- | --- | --- |
| R-回归 1 | tests/test_trade_gateway.py | redeem 按 `amount 反算 qty` 扣批次 | D26 改份额申报(Q15 拍板) | **改写**:断言 qty 入参 + `qty×T净值−赎回费` 公式(T-9/T-10) |
| R-回归 2 | tests/test_convert_service.py | 两阶段实时编排(apply_convert 同步确认 + 阶段零占位 pending) | T+1 模型受理/确认分离(Q1) | **重写**:受理 202 + 确认批处理两段(T-6/T-7) |
| R-回归 3 | tests/test_convert_core.py | 确认时同步扣批次、写流水、持仓 | 拆分受理/确认后确认段保留此语义但入口变批处理 | **改写入口**:断言移至 confirm_one(T-7) |
| R-回归 4 | tests/test_convert_engine.py | 引擎在「受理后阶段 1.5」触发 | FR-C28 移到确认后(Q13 拍板) | **改写**:受理后 0 预警、确认后恰 1 预警(T-8) |
| R-回归 5 | tests/test_convert_repository.py | `risk_convert_detail` 自主状态迁移(pending→completed/failed/expired) | T-4 状态机权威迁 Core | **改写**:agent 侧只验证镜像写(T-4) |
| R-回归 6 | tests/test_integration_risk.py | convert → 400(网关不认) | T-9 网关接受 convert → 202 受理 | **改写**:受理 202 + 两类 202 分辨(T-9;test_risk_api.py 实测 0 处 convert 引用,C4 无关) |
| R-回归 7 | tests/test_convert_concurrency.py | 同键竞态以「实时扣批次」为断言底座 | 受理段不再扣份额(占用由受理单推导) | **改写**:受理并发(uk_idem + 锁)与确认批处理并发两组(T-6/T-7) |
| R-回归 8 | tests/test_core_ro_sum.py / test_core_tools.py | 跨口径一致性断言(amount_view 三处同调) | **口径不变**;仅新增 convert 组 | **保留 + 补用例**:sum_amount 计一次(T-11) |
| R-回归 9 | tests/test_risk_engine.py / test_risk_rules.py | `_eligible` 只看 confirmed subscribe/redeem | **不被打穿**(R-b 两流水仍是 redeem/subscribe) | **保留**;补「确认后触发」场景(T-8) |
| R-回归 10 | tests/test_convert_calc.py | 纯函数行为(round2 等) | T-2/T-14 新增函数不动既有 | **保留**;新增 product_round/redeem_amount/partial_qty 用例(T-2/T-14) |
| R-回归 11 | tests/test_convert_integration.py | 端到端按 v1.0 两阶段流 | 全链路 T+1 化 | **重写**(含 :278 示例断言,验收 20 强绑定) |
| R-回归 12 | tests/test_share_lot.py | `select_for_convert`(FIFO 选批、不足额返回全部可用、零额空)与 `bootstrap_lots`;**另有 6 处 `_maintain(trade_type="redeem", amount=...)`(:441-550)经网关 `_maintain_lots`** | T-3 改造 `available_qty` 为双方法 + T-10 扣批次哨兵;批次可用口径变化;**D26/Q15 后 redeem 入参改 `qty`(份额)并移除 `amount` 反算(:220/:248),这 6 处 redeem 用例打断** | **改写**:断言改为 `available_qty_with_inflight`;`select_for_convert` 加 T+2 过滤参数后补可用边界;**6 处 redeem `amount="120"` 改 `qty=` 份额入参 + 断言原生份额语义**(I1 + C3,T-3/T-9/T-10) |
| R-回归 13 | tests/test_concentration_c4.py:161 | convert 转出全部份额后 `qty=0` 行不构成持仓(集中度读取方) | T+1 模型持仓仅在确认段更新;T-11 需保持归零行过滤 | **保留断言 + 补确认段场景**(T-11;I1 实测) |
| R-回归 14 | tests/test_db.py:83 | 列名一致性(sqlite `quantity` vs MySQL `qty` 的历史失配护栏) | T-1 新增 3 表 + 4 列 → 该测试断言清单需含新列 | **改扩展**:列清单断言纳入 T-1 新表/新列(I1) |
| R-回归 15 | tests/test_demo_scripts.py | 演示脚本接线(cleanup/rebuild 等) | T-12 改写 cleanup_pending_convert 扫描对象 | **改写**:脚本调用签名/输出断言更新(I1,T-12) |
| R-回归 16 | tests/test_locks_redis.py:141-172 | 锁键(convert 相关锁已存在) | T-5 新增 `convert:req:{customer}` / `convert:confirm:{as_of}` 锁键 | **补用例**:新锁键粒度/互斥用例(I1,T-5) |
**遗漏检查法**(skill 反面教训 2):写完后对 `tests/` 全目录 `grep -rn "convert"` 逐个文件过一遍是否存在表中未列的引用。**首轮独立审核已补(I1)**:`test_share_lot.py` / `test_concentration_c4.py` / `test_db.py` / `test_demo_scripts.py` / `test_locks_redis.py` 已在表中;`test_risk_chat_tools.py` / `test_risk_repository.py` / `test_risk_api.py` 实测 **0 处 convert 引用**。**第三轮白纸重审补充(C3)**:仅 grep `convert` 会漏掉 `test_share_lot.py` 中 **redeem 子测试**(不依赖 convert 字样)——D26 后同样被打穿,已补 R-回归 12
---
## 6. 验收清单(PRD §9 31 条 → 承载任务 → 证据)
> 行 1~31 对应 PRD §9 验收 1~31;FR-C29(redeem 份额申报)/ FR-C30(资金流中转)为 v1.1 新增的 PRD §9 之外条目,列于行 32/33(PRD FR 编号依承载任务回溯)。
| 验收 | 内容 | 承载任务 | 证据 |
| --- | --- | --- | --- |
| 1 | pytest 全绿(739 基线 + 新增) | T-13 | pytest -q 输出 |
| 2 | 两条流水同事务同 gid | T-7 | verify_convert_confirm DoD 1 |
| 3 | 折算按 T 日净值 | T-7/R-8 | verify_convert_confirm DoD 3 |
| 4 | 受理 blocked / T+1 复核 rejected | T-6 / T-7(D25) | verify DoD 5 / DoD 3 |
| 5 | RISK-002 计一次 | T-8 | verify DoD 7 |
| 6 | RISK-001/003 见两条(不删行) | T-8 | test_risk_engine 用例 |
| 7 | 一条预警事件 | T-8 | verify DoD 7 |
| 8 | 跨批次各自计费 | T-7 | verify DoD 6 |
| 9 | 普通赎回后再 convert 不超扣 | T-10 | verify DoD 4 |
| 10 | 两端持仓更新 | T-7 | verify DoD 1 |
| 11 | core_tools 不翻倍 | T-11 | test_core_tools 新用例 |
| 12 | client_request_id 幂等 | T-6 | verify_convert_accept DoD 2 |
| 13 | 强制全转 actual_qty | T-6/T-7 | verify DoD 6 |
| 14 | CROSS_ENTITY_NOT_SUPPORTED 覆盖 | T-1/T-6 | test 用例 |
| 15 | 幂等窗口闭合(确认失败重跑不双写) | T-7/T-12 | verify DoD 2 + R-14 |
| 16 | 全额转出豁免(6000 vs min 10000) | T-6(R-h 已实现) | test 用例保留 |
| 17 | agent 附加写入可补偿 | T-12 | rebuild_alerts 真库实测 |
| 18 | 性能(第 6 步实测补录) | T-13 | 耗时记录 |
| 19 | 补差费非零覆盖(110022 0.30% vs 003095 0.80%) | T-2/T-2b | demo + 集成断言 |
| 20 | 示例与种子强绑定 | T-2b | 三处一致 DoD |
| 21 | T 日受理不扣份额 | T-6 | verify_convert_accept DoD 1 |
| 22 | 在途占用 + 超量 400 + 撤单恢复 | T-6/R-3 | verify DoD 3 |
| 23 | 撤单窗口内 / 窗口外 409 | T-9/R-9 | convert_admin 用例 |
| 24 | T+1 串行确认 + 重复触发 0 双写 | T-7 | verify DoD 1/2 |
| 25 | 缺净值 nav_pending 不降级 | T-7/R-8 | verify DoD 3 |
| 26 | 确认失败 rejected + 释放 + 留痕 | T-7 | verify DoD 4 |
| 27 | T+2 可赎回 | T-10/R-4 | test_trade_gateway 用例 |
| 28 | 交易日判定 / 截点顺延 | T-6/R-5 | verify_convert_accept DoD 4 |
| 29 | 引擎确认后只跑一次 | T-8 | verify DoD 7 |
| 30 | 部分成交 D28 | T-7/T-15 | verify DoD 5 |
| 31 | 紧池成功率 100%(设计目标) | T-13 | 紧池场景实测记录 |
| 32 | 资金流中转 FR-C30(D29):确认后 `core_cash_flow` 恰 2 条、flow_subtype=redeem/subscribe、金额与流水一致、remark 带 gid | T-16 | verify DoD(T-16) |
| 33 | redeem 份额申报 FR-C29(D26):redeem 入参改 `qty`(份额)、移除 `amount` 反算;`calc.redeem_amount` | T-10 | T-9/P-1 用例 + test_share_lot 改写 |
---
## 7. 风险登记与门禁总则
| # | 风险 | 级别 | 前移到任务 DoD |
| --- | --- | --- | --- |
| 1 | DDL 双份漂移(sqlite/MySQL) | 高 | T-1 DoD 3(列清单 diff 断言) |
| 2 | 幂等窗口竞态(uk_idem 撞键) | 高 | T-6 DoD 2(真库并发) |
| 3 | 确认事务变长死锁 | 中 | T-7(沿用 1213 重试外壳 43cf2a1)|
| 4 | nav_pending 无限挂起 | 中 | T-12 DoD 2(SLA expired)|
| 5 | 2027 休市安排未公告 | 低 | T-1 DoD 4(seed 注释 + 重跑 gen_trade_calendar)|
| 6 | 普通赎回超扣 | 高 | T-10 DoD 1(哨兵)|
| 7 | 引擎时机残留旧调用 | 中 | T-8 DoD 1(grep 恰 1 处)|
| 8 | 占用推导漏 status 过滤 | 高 | T-3 DoD 1(归零用例)|
| 9 | 舍入切换炸既有断言 | 中 | T-14 DoD 3(与 round2 一致性)|
| 10 | 部分成交竞态不可复现 | 中 | T-15 DoD 2(真库并发钩子)|
| 11 | conftest 权限连锁 | 中 | 各任务 DoD(R-e 红线)|
| 12 | 旧 v1.0 文档残留误导 | 低 | 本文件头部已声明作废 + 交接文档同步 |
| 13 | **cutoff 硬编码**(B1) | 中 | T-1 DoD 2 输出 15:00 + `convert_cutoff_time`;R-5/R-9 读 settings |
| 14 | **净值回退静默降级**(B2,nav_pending 永不触发) | 高 | T-3 DoD 2(`get_nav_on` 精确匹配用例);R-8/T-7 禁 `get_nav_as_of` 回退语义 |
**门禁总则**:每个任务合入前 `pytest -q` 不得低于前值;高危任务(T-6/T-7/T-9/T-10)真实库 verify 脚本退出码 0 才可进下一步;审核收敛后才允许进入第 5 步(todo 开发)。
---
## 8. 待确认(需用户拍板;其余一律自行裁定并标注)
| # | 待确认项 | 建议 |
| --- | --- | --- |
| 1 | T-13 紧池成功率验收 31 是否在本线(第 4 步)范围外、交由第 6 步集成测试实测 | 建议:第 4 步只建场景与标记,第 6 步实测(PRD 验收 31 已注明「待第 6 步重测」) |
| 2 | 撤单接口鉴权级别(admin 或本人可撤) | **建议:本人可撤(交易 owner 闸门),查询仅本人/代理人**(已按此写进 T-9,待用户确认) |
| 3 | `core_convert_request` 是否需要 `coupon`/手续费展示位以外字段 | 建议:按 PRD 六态最小集,后续需求再加 |
---
## 9. 给审核 AI 的检查清单(本计划的自检锚点)
1. **任务编号是否与架构 v2.1 §15 严格一致**(T-1~T-17 一一对应,无重编)
2. **代码事实核对表每条是否带 文件:行号**、计数是否以实测为准(F-6/F-7/F-14 是目录实测)
3. **回归面清单是否覆盖全 tests/**(已补 I1 的 5 个文件;`test_risk_chat_tools` / `test_risk_repository` / `test_risk_api` 已确认 0 引用;**C3:仅 grep `convert` 会漏 redeem 子测试 → `test_share_lot` 6 处 redeem 用例已补 R-回归 12**)
4. **降级裁定是否成对配覆盖率补偿**(R-8 缺净值降级 vs 验收 25;T-14 缺规则回退 vs 自建种子;B2 净值回退已改 `get_nav_on` 精确匹配)
5. **DoD 是否全部机械可验证**(有无「确认正确」类模糊表述)
6. **依赖排序是否与架构 §15 冲突**(P1 已剔除 T-8;T-16/T-17 依赖已改 T-7/T-6)
7. **验收 31 条是否无落空**(对照 §6 表逐条;**C2b:FR-C29/C30 已补独立行 32/33**)
8. **R-b / 红线 1/2/3 是否被无意违反**(trade_type 仍是 redeem/subscribe;amount_view 三处同步;_q 单点量化)
9. **B1/B2 是否已闭环**(`convert_cutoff_time` 配置全链读 settings;确认段净值判定用 `get_nav_on` 精确匹配、禁 `get_nav_as_of` 回退)
10. **对外契约是否逐条与 PRD 比对**(**T-9 实测补** · 2026-09-11):凡本计划中**新出现或改写**的对外契约 —— **API 路径 / 参数名 / 响应字段名 / HTTP 状态码 / 错误码** —— 必须回到 PRD 原文逐条对齐,并注明 PRD 出处(章节或行号)。
- **为什么单列一条**:T-9 初稿自拟的三个接口路径与 PRD §5.5/§5.7 给出的路径**完全不同**,而**三轮独立审查(挑毛病 / 验证型 / 白纸重审)全部未发现** —— 原因是本清单前 9 条对的是「架构 §15 / 代码事实行号 / tests 回归面 / PRD §9 验收**条目**」,**没有一条对接口契约本身**;审查做的是「计划是否**覆盖** PRD 的要求」,而非「计划是否**擅自改** PRD 的承诺」,契约改写恰好落在盲区。
- **做法**:对本计划所有 `/api/...` 字符串、`FIELD_NAME` 常量、`409`/`422` 等状态码做一次 `grep`,逐条问「PRD 原文怎么写的?出处是哪一节?」,**自拟而无出处的必须标出并请用户裁定**。
- **同源规律(与铁律 10 一致)**:**带「文件:行号」的断言会被逐条验;不带出处的自由书写没有锚点 → 必漏**。