# 开发计划 · 基金转换交易(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**: - [x] convert 两流水场景:`sum_amount` 计一次(验收 11)—— `test_confirm_then_sum_amount_counts_once`(**走真实链路**:受理 → `confirm_one` 落两条同 gid 流水后,`sum_amount` = 转出端 40812.00 + 无组普通 100000 = 140812.00;`total_count` 仍 3 = 明细全量;并以 `sum_trades_on_date(T+1)` 复验跨口径一致) - [x] 全转归零:`list_holdings` 不含 qty=0 行 —— `test_confirm_full_transfer_holding_zero_row_excluded`(**确认段场景**:先直查 SQL 证明归零行**仍在库里**(台账留痕,D 决策),再断言 `list_holdings` 只剩转入端且 qty 与 `res["in_qty"]` 逐字节一致,`query_holdings`(Tool 层)同口径;既有 `test_query_holdings_excludes_zero_qty`(口径级)与 `test_concentration_profile_filters_zero_qty`(集中度读取方)**保留不动**) - [x] pytest 全量绿 —— **846 passed / 10 skipped**(基线 844 + 新增 2,零回归) **✅ T-11 完成(core_tools 汇总去重 + 持仓过滤 · 确认段场景补用例 · 2026-09-12)**: **生产代码零改动**(计划即判「风险低;仅补用例」)—— 口径三处同调(F-10:`rules.amount_view` ① RISK-002/005 ② `query_recent_trades.sum_amount` ③ `sum_trades_on_date` SQL 条件)与 `list_holdings` 的 SQL 层 `qty > 0` 过滤**均已就位**(v0.x T-11 产物 + F-9/F-10 实测),本轮增量 = **把既有「直接插 SQL 模拟流水」的口径用例升级为 T+1 真实链路场景**(R-回归 8「保留 + 补用例」/ R-回归 13「保留断言 + 补确认段场景」——T+1 模型下流水与持仓**只在确认段产生**,既有用例覆盖不了这一点)。**落地**:`tests/test_core_tools.py` +2(新增 `t1_env` fixture:`seed_suitability_matrix` + 最小受理/确认种子;`_quiet_th()` 全阈值推高让引擎静默,避免依赖 settings 与 conftest autouse 隔离)。 **执行期裁定 2 条(留痕)**:① **转出端流水 `amount` = 费前转出金额**(40812.00 = 30000×1.3604,与 PRD §5.3.2 示例 68020.00 同语义;赎回费 204.06 单列 `redeem_fee` 字段)—— 初版锚点误按「费后 40607.94」断言被真跑抓出,已在用例注释钉死口径;② `t1_env` 必须调 `_ddl.seed_suitability_matrix`(C×R 矩阵是 `check_suitability` 的 L0 权威,裸 `create_sqlite_engine()` 缺矩阵 → 受理全量 forbidden)—— 与 conftest `sqlite_engine` fixture 同源(自检第 13 问)。 **验证**:`pytest tests/test_core_tools.py` 5/5 → 全量 **846 passed / 10 skipped**(844+2 零回归);**突变 2 组防假绿**:① 去掉 `list_holdings` 的 `qty > 0` → **精准 3 红**(新确认段用例 + 既有归零行用例 + 集中度跨口径一致性断言);② `query_recent_trades` 绕开 `amount_view` 直加全量 → **精准 3 红**(新确认段用例 + 既有不翻倍用例 + 跨口径一致性断言)—— 均已还原,`git status` 仅测试文件改动、`grep MUTATION` 无残留;真库 `verify_convert_tools.py` 复跑 **14/14、退出码 0、零残留**(零回归佐证)。 **风险**:低;仅补用例 **回滚点**:无代码改动风险 --- ### 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**: - [x] 造数:accepted 超 2 交易日 → 脚本执行后 `expired`(真库,仿 verify_convert_compensate.py) - [x] nav_pending 超 SLA → `expired`(不早于确认窗口) - [x] 第二次执行 0 处理(幂等) - [x] `rebuild_alerts.py` 从 Core 净数据补出的 detail 与流水一致(验收 17) **风险**:SLA 判定用交易日而非自然日(Q14)→ 用 trading_calendar.previous_biz_day 链式计算 **回滚点**:脚本只改状态不硬删(S2),天然可逆 **✅ T-12 完成(补偿 + SLA 清理切 T+1 链路 · 2026-09-12)**: **三个改动面**:① `scripts/agent/cleanup_pending_convert.py` **整体重写**:扫描对象迁 Core(`list_inflight_before` 扫 `accepted/nav_pending` 且 `requested_at` 严格早于 cutoff)→ 逐单 `transition_status` 条件 UPDATE 置 `expired`(S2 不硬删;并发双跑天然幂等,不加锁);SLA 边界 `_slab_cutoff` = 当前业务日(今天开市取今天否则 `previous_biz_day` 回退)上推 `convert_confirm_sla_days` 个交易日的 00:00,日历数据缺失降级自然日并告警(宁可少清);`--days`/`--dry-run` 参数、退出码 0/1、**不动 agent 镜像**(T-4 契约:镜像不推 cancelled/expired)。② `compensate_convert`(convert_service)**从 `__all__` 的 v1.0 Deprecated 组救回在役**:详情侧数据源 = Core 三件套前置校验(流水 ≥2 条 → 受理单 `confirmed` → 计费明细非空,任一不满足 `state=missing` **零写入不硬补**),补写与确认段第⑧步同口径(`sync_mirror(completed)` + `decision='confirmed'` 审计 `phase='confirm-compensate'`、`engine_error` 如实标记);`convert_fund`/`rebuild_convert_response` 仍留 Deprecated(T-13 退役)。③ `ConvertRequestRepository` 新增 `list_inflight_before`(T-12 专用捞单,复用 R-2 条件 UPDATE 守卫,不复制 SQL)。 **勘误**:原「涉及文件 `scripts/agent/rebuild_alerts.py`」路径有误,实际在 `scripts/demo/`;该脚本零改动(薄封装委托 compensate_convert,签名未变)。 **联网核实(用户铁律 2026-09-12)**:① 「基金转换申请失效」真实口径——多家基金公司《开放式基金业务规则》(华安/富国/嘉实/太平/德邦等):转出不可赎回或转入不可申购 → 无效;TA T+1 确认、T+2 可查;无效申请资金退回原账户。映射本项目:`rejected`(T+1 复核不通过)= 真实「无效申请」,`expired` 定位为运维兜底(释放占用 = 份额退回效果)——设计贴近真实 ✓;② A 股**连续休市历史最长 = 2020 年春节 10 个自然日**(国务院办公厅延长假期;上交所《关于2020年春节延长休市相关业务衔接安排的通知》:休市延至 2 月 2 日、2 月 3 日恢复开市)——`trading_calendar._MAX_SCAN_DAYS=256` 的量级依据成立,结论留痕进 verify 脚本头注释;verify 造数的受理日/SLA 边界全部改为 `previous_biz_day` 链式走**真库交易日历**,不猜自然日(对任意假期安排鲁棒)。 **T-12 真库脚本暴露并修复 1 处三链路不一致(本轮最重要产出)**:`confirm_one` 第⑦步引擎异常**只 log 不留痕**(`engine_result=None` → confirmed 审计/return dict 的 `engine_error=False`)——全仓唯一不落痕路径(v1.0 阶段 1.5 落 `decision='engine_error'` 审计、trade_gateway 落 `risk_engine_error`),且补偿侧 `has_engine_error_audit` 的「人工核对」保护对 T+1 主链路**永远失效**。修复:confirm_service 第⑦步 except 分支补写 `decision='engine_error'` 审计 + `engine_error` 标志贯穿 confirmed 审计与 return dict(D17「不阻断」语义保留,测试注释同步钉死「不阻断但留痕 + 标记」)。 **测试**:`tests/test_convert_service.py` 补偿用例 4 条改 T+1 断言 + 新增 2(`missing_when_request_not_confirmed` / `inserts_mirror_when_row_absent`);`tests/test_demo_scripts.py` cleanup 段重写(真实日历路径 + 幂等 + dry-run + 自定义 SLA 方向断言)+ 新增 `test_cleanup_window_matches_confirm_batch_window`(行为化等价:窗口下界日 confirm_batch 能捞到且 cleanup 不清,前一交易日两边都排除);`tests/test_convert_concurrency.py` 并发补偿改 T+1 造数 + 真库清场补 core_convert_request(FK 1451)。全量 **849 passed / 10 skipped**(846+3,零回归)。 **真库**:`scripts/dev/verify_convert_compensate.py` **整体重写为 T+1 链路**(受理 → confirm_one 第⑦⑧步双失败真实落痕 → Core 三件套对表 → 补偿 rebuilt / 复跑 skipped / 真 JSON 列 LIKE / cleanup ENUM·窗口同口径·dry-run·幂等·镜像不推),**56/56 退出码 0、残留 0**(含幂等重跑)。真库断言口径修正 1 条:`decision='confirmed'` 审计真链路下**恰 2 条**(确认段 `phase='confirm'` + 补偿 `phase='confirm-compensate'` 各 1,各司其职)——v1.0「主审计 1 条」是单测造数(未跑 confirm_one)造成的盲区,真库端到端才验得出。 **突变 4 组防假绿(全部被抓并还原)**:① cleanup 窗口方向反转(previous→next_biz_day)→ **5 红**(含窗口等价用例);② compensate 前置 confirmed 守卫删除 → **精准 1 红**;③ 真库 expired→cancelled → **2 红**(ENUM/状态断言);④ 真库镜像 `nav_stale=True` → **1 红**。 **待办移交 T-13**:`convert_fund`/`rebuild_convert_response` 退役 + verify_convert_service.py 处置 + 全量门禁。 --- ### 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 一致)**:**带「文件:行号」的断言会被逐条验;不带出处的自由书写没有锚点 → 必漏**。