From cde0c226fe1200ab334558c30d603398dabd08e2 Mon Sep 17 00:00:00 2001 From: YUAN Date: Sat, 12 Sep 2026 13:42:49 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9F=BA=E9=87=91=E8=BD=AC=E6=8D=A2=20T+1=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=EF=BC=9AT-15/T-16/T-17=20=E6=94=B6=E5=AE=98?= =?UTF-8?q?=EF=BC=88842=20=E7=BB=BF=20/=20=E7=9C=9F=E5=BA=93=209/9=20/=20?= =?UTF-8?q?=E8=BD=AC=E6=8D=A2=E7=BA=BF=E7=AC=AC=205=20=E6=AD=A5=E5=AE=8C?= =?UTF-8?q?=E6=88=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T-15 D28 部分成交专项: - sqlite +1 终态单幂等 skipped 口径裁定(ConfirmConflict 仅限读后状态才变窗口) - 真库 confirm +3 组 19 项(E2 占用释放双向 / G2 actual_qty=0 → rejected / H2 撤单后确认)→ 102/102 - CONVERT_STRESS Barrier(2) 窄窗争批:恰 1 confirmed + 1 rejected - 突变实证防御纵深:④可用量复核 / ⑤plan_lots / 事务内哨兵三道防线拆任一道仍收敛 rejected T-16 D29 资金流中转: - _insert_cash_flows 全仓唯一写入点(out/redeem 费前 + in/subscribe 净转入,remark=convert:{gid} 同事务) - test_confirm_writes_no_cash_flow 反转为恰 2 条 + 金额对账(T-7 登记闭环) - 真库 apply A2 组 + B 组 0 残留 → 30/30;confirm 反转断言 → 103/103 - FK 坑(core_cash_flow.fk_cf_customer 挡客户删除):5 处清理段补删 T-17 D30 share_class + A/C 互转: - _validate_products 纯新增拦截分支:两端 share_class 非空且不同才查 allow_ac_convert(任一端=1 两向放行) - 口径裁定 2 条(R-12 字面偏差,待用户追认):NULL 不参与 A/C 判定 / 同类走一般转换规则 - 联网核实(用户铁律):A/C 互转需管理人开通(摩根 2025-11 公告)= 开关真实载体;持有期重新起算与本仓一致 - 种子 ⑥ 段(双开对/双关对照/同类对照);sqlite +4 → 842 passed / 8 skipped;真库 accept H 组 → 43/43 - 突变:分支失效 → closed 红,还原绿 文档:开发计划 T-15~T-17 DoD 全勾 + 执行记录 · 基线 842 · TODO/MEMORY/AGENTS/交接文档 v4.5 终态(第 5 步收官 → 下一步第 6 步集成测试) --- AGENTS.md | 2 +- app/gateway/convert_core_repository.py | 46 ++++++++ app/service/convert/convert_service.py | 17 +++ docs/memory/2026-09-12.md | 125 +++++++++++++++++++++ docs/memory/MEMORY.md | 6 +- docs/memory/TODO.md | 8 +- docs/项目框架设计/开发计划-基金转换交易.md | 88 +++++++++++++-- scripts/core/09-seed-org.sql | 13 +++ scripts/dev/verify_convert_accept.py | 70 +++++++++++- scripts/dev/verify_convert_api.py | 2 + scripts/dev/verify_convert_apply.py | 35 ++++++ scripts/dev/verify_convert_compensate.py | 2 + scripts/dev/verify_convert_confirm.py | 110 +++++++++++++++++- scripts/dev/verify_convert_engine.py | 2 + tests/_ddl.py | 18 +++ tests/test_convert_accept.py | 62 ++++++++++ tests/test_convert_concurrency.py | 64 +++++++++++ tests/test_convert_confirm.py | 69 +++++++++--- tests/test_convert_core.py | 5 +- tests/test_convert_integration.py | 3 + 20 files changed, 710 insertions(+), 37 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9feb930..ea5c25e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ - 技术选型 / JWT:`docs/项目框架设计/技术选型和版本/` - Core 模拟底座:`docs/项目框架设计/Core模拟底座/` - 业务记忆分层:`docs/业务记忆管理/业务记忆管理手册.md` -- **基金转换线(第 5 步 todo 开发进行中 · T+1 受理/确认分离模型)**:**T-1 ~ T-14 ✅ / T-15 ~ T-17 ⬜**(当前基线 **837 passed / 7 skipped** 实测;真库 verify 套件 **9/9 退出码 0**:accept 37 / confirm 83 / engine 39 / api 89 / compensate 56 / apply 24 / lots 20 / seed 全 PASS / tools 14;T-14 = D27 产品舍入真正接入——`core_ro.get_share_digits` 回退链单点 + 确认段/申购折算接入,**联网核实纠偏:种子「指数基金 4 位」证伪 → 11-seed v1.1 全 2 位**;开口 2 项待用户拍板 = PRD §2.5.3 易方达句修订 + rounding_mode=truncate 接入;下一步 **T-15** = D28 部分成交专项 + 边界补强);开工入口 **`交接文档.md` §B**(§B.11 下一条立项指引 = 最新状态)· `docs/PRD/PRD-基金转换交易.md`(**v1.1**)· `docs/项目框架设计/架构设计-基金转换交易.md`(**v2.1**)· `docs/项目框架设计/开发计划-基金转换交易.md`(**v2.0** · T-1~T-17 逐任务改法 + DoD) +- **基金转换线(第 5 步 todo 开发 ✅ 已收官 2026-09-12 · T+1 受理/确认分离模型)**:**T-1 ~ T-17 全部完成**(当前基线 **842 passed / 8 skipped** 实测;真库 verify 套件 **9/9 退出码 0**:accept 43 / confirm 103 / engine 39 / api 89 / compensate 56 / apply 30 / lots 20 / seed 全 PASS / tools 14。T-15 = D28 部分成交专项(防御纵深三道防线 + 真库争批);T-16 = D29 资金流中转(core_cash_flow 全仓唯一写入点同事务 2 条 remark 带 gid);T-17 = D30 A/C 互转(share_class 非空且不同才查 allow_ac_convert,任一端=1 放行;口径裁定 NULL 不参与判定/同类走一般规则待追认);开口 3 项待用户拍板 = PRD §2.5.3 易方达句修订 + rounding_mode=truncate 接入 + R-12 口径裁定追认;下一步 = **第 6 步集成测试**);开工入口 **`交接文档.md` §B**(§B.11 下一条立项指引 = 最新状态)· `docs/PRD/PRD-基金转换交易.md`(**v1.1**)· `docs/项目框架设计/架构设计-基金转换交易.md`(**v2.1**)· `docs/项目框架设计/开发计划-基金转换交易.md`(**v2.0** · T-1~T-17 逐任务改法 + DoD) - **架构改进线(✅ 2026-09-10 已结项,无待办)**:开工入口 **`交接文档.md` §C** - **风控模块主线**:**`交接文档.md` §A** diff --git a/app/gateway/convert_core_repository.py b/app/gateway/convert_core_repository.py index 4e20271..d1f9edf 100644 --- a/app/gateway/convert_core_repository.py +++ b/app/gateway/convert_core_repository.py @@ -261,6 +261,11 @@ class ConvertCoreRepository: """阶段一事务体(供 `apply_convert` 死锁重试外壳调用,勿单独使用)。""" with self._engine.begin() as conn: self._insert_trades(conn, req) + # D29 资金流中转(R-11 / T-16):两条 core_trade 之后紧跟 2 条 + # core_cash_flow —— 本方法是全仓 **core_cash_flow 唯一写入点** + # (普通申赎不写;T-7 确认流程此前不写,防双重归属)。与流水同 + # 事务,幂等由「重跑确认的 0 处理由 `_confirm_request` 哨兵拦截」保证。 + self._insert_cash_flows(conn, req) self._deduct_lots(conn, req) self._insert_in_lot(conn, req) self._apply_holding(conn, req, is_out=True) @@ -270,6 +275,47 @@ class ConvertCoreRepository: # accepted」的中间态会被下轮批处理再捞一次 → 双扣。 self._confirm_request(conn, req) + # ── 1b. 两条中转资金流(R-11 / T-16 · 全仓唯一写入点)─────────────── + def _insert_cash_flows(self, conn: Connection, req: ConvertApplyInput) -> None: + """确认事务内写 `core_cash_flow` 2 条:转出 out/redeem + 转入 in/subscribe。 + + 真实语义(D29):赎回款先入**注册登记账户**中转、再划转申购 —— 建模为 + 同一客户名下「转出产品出账 + 转入产品入账」两条流,`remark` 带 + `convert_group_id` 供对账,金额与两条 core_trade 一致(out_amount / + in_amount,均为费前口径与流水 `amount` 同源)。channel 取 DDL 默认 + `online`(本项目无渠道维度);counterparty 不填(模拟库无真实账户)。 + """ + sql = text( + """ + INSERT INTO core_cash_flow + (customer_id, flow_type, flow_subtype, amount, channel, remark, occurred_at) + VALUES (:cid, :ftype, :fsub, :amount, 'online', :remark, :at) + """ + ) + common = {"cid": req.customer_id, "at": req.traded_at} + # 转出端:out / redeem(赎回款流出,金额 = 费前转出金额,与流水同源) + conn.execute( + sql, + { + **common, + "ftype": "out", + "fsub": "redeem", + "amount": _bind(req.out_amount), + "remark": f"convert:{req.convert_group_id}", + }, + ) + # 转入端:in / subscribe(申购款流入) + conn.execute( + sql, + { + **common, + "ftype": "in", + "fsub": "subscribe", + "amount": _bind(req.in_amount), + "remark": f"convert:{req.convert_group_id}", + }, + ) + # ── 8. 受理单置 confirmed(T-7 确认段 · 事务内条件 UPDATE)────────── def _confirm_request(self, conn: Connection, req: ConvertApplyInput) -> None: """条件 UPDATE `WHERE status IN ('accepted','nav_pending')` = 确认权争夺哨兵。 diff --git a/app/service/convert/convert_service.py b/app/service/convert/convert_service.py index 5faca61..b20c342 100644 --- a/app/service/convert/convert_service.py +++ b/app/service/convert/convert_service.py @@ -182,6 +182,23 @@ def _validate_products( raise CrossEntityNotSupported( f"两端注册登记机构不同:{out_product.get('ta_code')} ≠ {in_product.get('ta_code')}" ) + + # ── D30 同基金 A/C 份额互转(T-17 / R-12)────────────────────────── + # 两端 share_class 非空且不同 = A/C 互转场景:真实业务需管理人事先开通 + # (联网核实:摩根行业睿选 2025-11《开通同一基金不同份额类别转换业务 + # 公告》,"开通"即 allow_ac_convert 的真实载体)。任一端开通即两向放行; + # share_class NULL 不参与 A/C 判定(走上方一般转换规则,历史种子零回归)。 + out_class = str(out_product.get("share_class") or "").strip() + in_class = str(in_product.get("share_class") or "").strip() + if out_class and in_class and out_class != in_class: + if not ( + int(out_product.get("allow_ac_convert") or 0) + or int(in_product.get("allow_ac_convert") or 0) + ): + raise CrossEntityNotSupported( + f"份额类别 {out_class} → {in_class} 未开通同基金 A/C 份额互转" + "(allow_ac_convert=0),仅支持同类份额转换" + ) return out_product, in_product diff --git a/docs/memory/2026-09-12.md b/docs/memory/2026-09-12.md index d2abd20..5e845e7 100644 --- a/docs/memory/2026-09-12.md +++ b/docs/memory/2026-09-12.md @@ -365,3 +365,128 @@ tools 14 —— 全部零回归(2 位产品路径与接入前逐字节一致 **T-15**(D28 部分成交专项测试 + 边界补强):用例矩阵(占用被抢 / 部分确认后 释放 / 撤单与确认竞态 / `actual_qty=0 → rejected` R-10 补强)+ `CONVERT_STRESS` 两单争同一批。入口:开发计划 T-15 节。 + +--- + +# T-15:D28 部分成交专项测试 + 边界补强(2026-09-12) + +## 一、时序口径裁定(sqlite +1 → 838 passed / 8 skipped) + +`test_confirm_after_cancel_conflicts`:撤单后确认 **不抛 ConfirmConflict**,返回 +`skipped + current_status='cancelled'` —— `confirm_one` 对**终态单(含 +cancelled)是幂等 skip**(批处理单笔容错语义),`ConfirmConflict` 仅在「读单 +之后状态才变」的并发窗口抛(`_confirm_request` 条件 UPDATE 哨兵 rowcount≠1)。 +断言:零流水零明细 + 受理占用释放。首版误断言抛异常(DID NOT RAISE)即该 +口径的实证。 + +## 二、真库 +3 组 19 项(verify_convert_confirm 83 → 102/102 退出码 0) + +| 组 | 场景 | 关键断言 | +|---|---|---| +| E2 | 占用释放双向证明 | 确认前灌 15000 受理 → 再受理被 `InsufficientShares` 拦(占用生效)→ 确认吃掉物理份额 → 灌回 15000 再受理 accepted(占用记账不重复扣)→ E2b 置 expired 释放占用收尾 | +| G2 | `actual_qty=0` 边界 | 占用全被抢的单确认后 `rejected`(R-10 补强),零流水零明细零持仓变动 | +| H2 | 撤单后确认 | `skipped` + `current_status='cancelled'`,零残留 | + +**R-10 语义实测钉死**:部分确认把可用**物理**份额全部吃掉(余量归零), +「释放」的是受理占用而非物理份额——E2 组设计时曾误设余量即此坑。 + +## 三、CONVERT_STRESS 争批 + 突变验证(防御纵深实证) + +- `test_two_confirms_race_same_lot_first_wins`:池 2 份 + 两单各 1 份, + `Barrier(2)` 经 monkeypatch 钉在 `apply_convert` 事务门口,构造「两单都完成 + ④ 可用量复核、⑤ plan_lots 校验」后同时进事务的窄窗口;断言恰 1 + confirmed + 1 rejected、扣减恰 1 份、恰 1 组 2 条流水、败者落 rejected。 +- **突变两次不红 = 防御纵深发现**:单独拆事务内扣减哨兵不红(④⑤ 复核拦住)、 + 单独拆 ④ `partial_qty` 不红(⑤ plan_lots 拦住)→ 窄窗 + 同拆哨兵 ×3 全红、 + 还原 ×3 全绿。**三道防线**(④可用量复核 / ⑤plan_lots 校验 / 事务内 + `remain_qty >= :q` 哨兵)拆任何一道,其余两道仍收敛 rejected——资金安全 + 不依赖单点。 + +--- + +# T-16:D29 资金流中转建模(2026-09-12) + +## 一、写入点(全仓唯一,反双重归属闭环) + +`convert_core_repository._apply_convert_once` 在 `_insert_trades` 后加 +`_insert_cash_flows`:2 条 —— `out/redeem` = out_amount(**费前**)+ +`in/subscribe` = in_amount(净转入),remark = `convert:{gid}`、channel = +`online`、occurred_at = traded_at;与流水/明细/持仓/受理置态**同一事务**, +幂等由 T-7 确认权哨兵天然保证。R-11「中转」语义 = 确认事务内出+入两条对冲, +无中间现金停留;费用不在资金流重复体现(与 core_trade 转出端同口径)。 + +## 二、DDL 与测试 + +- `tests/_ddl.py`:sqlite 版 `core_cash_flow` + 并入 REQUIRED_CONVERT_TABLES + 门禁(真库 01-ddl.sql 既有此表 F-14,无 ALTER)。 +- `test_confirm_writes_no_cash_flow` **反转为** + `test_confirm_writes_exactly_two_cash_flows`(T-7 登记的「表建立后改 0 行 + 断言」闭环):恰 2 条 / 两端金额 / remark / 幂等重确认无第 3 条。 +- `test_convert_core` 两回滚用例补资金流零残留断言。 + +## 三、真库(verify_convert_apply 24 → 30 项全绿)+ FK 新坑 + +- **A2 组 5 项**:条数恰 2 / `(out,redeem)+(in,subscribe)` 组合 / 两端金额 = + req.out_amount·in_amount / 与 core_trade 逐条对账;**B 组 +1**:冲突路径 + 资金流 0 残留。confirm 脚本的「确认不写资金流」断言(T-7 时代登记)同步 + 反转 → **102 → 103 项全绿**(全套终态复跑时暴露;对账项首版 `r[0]` 取 + dict 行踩 KeyError,`_rows` 返回 mappings 须按列名取)。 +- **新坑(T-9 FK 坑同款)**:`core_cash_flow.fk_cf_customer` 挡住 + `core_customer` 删除 —— **共 5 处清理段补删**:verify_apply 与 + test_convert_integration(T-16 收尾批,integration 13 个 ERROR 定位到此根因)+ + verify_engine / verify_api / verify_compensate(T-17 收尾全套终态复跑批, + 库内旧残留引发连环 FK)。删除条件用 remark LIKE `'convert:%'` / + `'convert:{_GROUP_LIKE}'` 精准定位,不误删既有种子。 +- **突变验证**:拆 `_insert_cash_flows` 调用 → 反转用例红(0 ≠ 2);还原 → + core+confirm 37 全绿,MUTATION 零残留。 + +## 四、下一步 + +**T-17**(D30 share_class + A/C 互转):受理校验放行规则(R-12 口径裁定: +种子 `share_class` NULL 不参与 A/C 判定走一般转换;两端非空且不同才查 +`allow_ac_convert`)+ 种子一对 A/C 产品 + 对照用例。入口:开发计划 T-17 节。 + +--- + +# T-17:D30 share_class + A/C 互转(2026-09-12 · 转换线收官) + +## 一、口径裁定(R-12 字面两处偏差,实现取业务正确口径,登记待用户追认) + +1. **`share_class` NULL 不参与 A/C 判定**:R-12 字面「不满足 → 400」若让 NULL + 参与比较,会打穿全部既有受理用例(种子产品 share_class 全 NULL)。实现 = + **两端非空且不同**才触发 A/C 分支,NULL/同类走一般转换规则。 +2. **「share_class 相同 → 400」不采纳**:非空同类别跨基金互转(同管理人同 TA) + 本就走一般转换规则放行,无业务理由禁转;DoD 对照用例改为「同类放行」回归 + 保护(sqlite + 真库 H3 双证据)。 + +## 二、实现与核实 + +- **实现(纯新增拦截分支)**:`convert_service._validate_products` ta_code 校验 + 后,两端 share_class 非空且不同时查 `allow_ac_convert`——任一端 =1 即两向 + 放行,全关 → `CrossEntityNotSupported`(错误码沿用,message 指明 A/C,任务书 + 改法①)。放行路径零改动,历史产品零回归。 +- **联网核实**:A/C 互转 = 同一基金不同份额类别转换业务,**需管理人「开通」** + (摩根行业睿选 2025-11 公告)= `allow_ac_convert` 真实载体;转换后**持有期 + 重新起算**(公告明写,本仓转入新批次语义一致零改动);转换费 = 转出赎回费 + + 申购补差费(深证监局,A/C 补差费不在本仓范围)。 +- **种子 ⑥ 段**(09-seed-org.sql):双开对 110022'A'/005827'C',双关对照 + 110023'A'/003095'C',同类对照 510300'C';其余产品保持 NULL。 + +## 三、验证(全量 842 passed / 8 skipped · 真库 43/43) + +- sqlite +4:双开放行 / 单开放行(任一为 1)/ 双关 400(message + 不落单)/ + 同类+NULL 放行回归保护。首跑发现:同用例第二次受理撞**在途占用**(150−100 + 可用 50),改申请 50——受理语义的顺带实证。 +- 真库 H 组 6 项(accept 37 → 43 退出码 0):H1 双开 accepted / H2 双关 400 + 语义 + 不落单 / H3 同类 `'A'→'A'` accepted。**H3 首跑撞种子 ⑤ 段 + `min_redeem_qty=100`**(申请 50 被 BelowMinQty 拦)→ 改申请 100:种子产品做 + 载体必须通读既有段配置。 +- **突变验证**:A/C 分支恒 False → closed 用例红;还原 → 全绿,MUTATION 零残留。 +- **模拟库近似留痕**:真实「同基金 A/C」按基金合同绑定同组合,模拟库无 + fund_code 字段,判定以「同管理人同 TA + share_class 非空且不同」近似。 + +## 五、下一步 + +**T-1~T-17 全部完成,转换线收官**。剩余:全套 9 个真库脚本复跑 + 三任务 +(T-15/T-16/T-17)提交 + 交接文档/TODO/MEMORY 终态更新;之后按开发计划第 6 步 +做集成测试。 diff --git a/docs/memory/MEMORY.md b/docs/memory/MEMORY.md index d68654c..0434d9a 100644 --- a/docs/memory/MEMORY.md +++ b/docs/memory/MEMORY.md @@ -13,7 +13,7 @@ **⚡ 基金转换(convert)线 — 两条子线**: - **(一)v0.x 两阶段实时模型(已结项 2026-09-10)**:PRD **v0.9.3** + 架构 **v1.0.1** + 开发计划 **v1.0**,T-0 ~ T-13 全部完成(第 5+6 步闭环,最终 **739 passed / 10 skipped**,`CONVERT_STRESS=1` 并发 9/9,含死锁重试补丁 `43cf2a1`);**该模型已被下一线取代,其 PRD/架构/开发计划作废,代码保留作过渡基线**。 -- **(二)下一线 · T+1 受理/确认分离模型(进行中 · 2026-09-11)**:入口 **交接文档.md §B.11**(10 项真实业务拍板)· PRD **v1.1** + 架构 **v2.1** 已整体重写并经独立 AI 联网审查通过(0 阻断级),**已定稿**(2026-09-11 用户批准);**第 4 步开发计划 v2.0 已整体重做**(`docs/项目框架设计/开发计划-基金转换交易.md`,797 行:T-0~T-17 按架构 §15 重排 + §1.4 代码事实核对表 F-1~F-25 + §1.5 实现级裁定 R-1~R-15 + §5 十六条回归面 + §6 验收 31 条 + FR-C29/C30 全映射),**经三轮独立审核闭环(第一轮挑毛病 2 阻断+5 重要+3 可选 / 第二轮验证型 12 项零残留 / 第三轮白纸重审 C1~C5+O1~O4 + 验证型复核 22/22 落位),已定稿(2026-09-11 用户批准)**。**核心认知:不新增冻结列,在途占用由受理单推导;紧池 80% 病根在 T+1 串行确认下自动消失。** 下一步:**第 5 步 todo 开发进行中(T-1~T-14 ✅,**837 passed / 7 skipped** 零失败;真库 verify 套件 **9/9 退出码 0**:accept 37 / confirm 83 / engine 39 / api 89 / compensate 56 / apply 24 / lots 20 / seed 全 PASS / tools 14;T-14 = D27 产品舍入真正接入——`core_ro.get_share_digits` 回退链单点(规则行 → core_product.share_digits → 默认 2)+ 确认段(convert)/申购折算(subscribe)两处接入;**联网核实纠偏:种子「指数基金 4 位」证伪(ETF 联接/指数合同全 2 位)→ 11-seed v1.1 全 2 位**,开口 = PRD §2.5.3 易方达句修订 + rounding_mode=truncate 接入待用户拍板;下一步 T-15 D28 部分成交专项)**。**T-7 关键裁定:强制全转属受理段决策** —— 受理由 `plan_lots` 判定并把 `qty` 收敛为实际全转量 + 受理单 `remark='full_transfer'`,确认段**只继承不重判**(`plan_lots` 不传 `min_hold_qty`);重判会因 T→T+1 可用份额变化(部分成交/他人申赎/产品参数改)得出与受理承诺不一致的结论(擅自扩大客户指令)。**T-8 关键裁定**:引擎时机(R-7)的实现部分在 T-7 已完成,T-8 补的是**守护与验证**(AST 守护唯一调用点 / 受理不触引擎 / 确认链路真引擎正证+反证);⚠️ **T-8 不动去重口径** —— `amount_view` 三处消费方原样不动(属 T-11);`convert_fund` 的阶段 1.5 调用点随 T-9 删函数一并清除(只摘调用会让 v1.0 过渡函数「扣份额但不出预警单」,风控缺失比时机错更危险)。撤单范围:PRD §5.5 原表述与实现(仅 `accepted`)不矛盾、无需回填(第三轮 C1 订正)。已就绪:`fetch_nav.py` / `06-seed-nav.sql`(60 个真实净值日)/ `gen_trade_calendar.py` / `10-seed-trade-calendar.sql`(503 交易日)。 +- **(二)下一线 · T+1 受理/确认分离模型(进行中 · 2026-09-11)**:入口 **交接文档.md §B.11**(10 项真实业务拍板)· PRD **v1.1** + 架构 **v2.1** 已整体重写并经独立 AI 联网审查通过(0 阻断级),**已定稿**(2026-09-11 用户批准);**第 4 步开发计划 v2.0 已整体重做**(`docs/项目框架设计/开发计划-基金转换交易.md`,797 行:T-0~T-17 按架构 §15 重排 + §1.4 代码事实核对表 F-1~F-25 + §1.5 实现级裁定 R-1~R-15 + §5 十六条回归面 + §6 验收 31 条 + FR-C29/C30 全映射),**经三轮独立审核闭环(第一轮挑毛病 2 阻断+5 重要+3 可选 / 第二轮验证型 12 项零残留 / 第三轮白纸重审 C1~C5+O1~O4 + 验证型复核 22/22 落位),已定稿(2026-09-11 用户批准)**。**核心认知:不新增冻结列,在途占用由受理单推导;紧池 80% 病根在 T+1 串行确认下自动消失。** 下一步:**第 5 步 todo 开发已收官(2026-09-12,T-1~T-17 全部完成,**842 passed / 8 skipped** 零失败;真库 verify 套件 **9/9 退出码 0**:accept 43 / confirm 103 / engine 39 / api 89 / compensate 56 / apply 30 / lots 20 / seed 全 PASS / tools 14。T-15 = D28 部分成交专项(终态单幂等 skipped 口径 + 防御纵深三道防线实证 + Barrier 窄窗争批);T-16 = D29 资金流中转(core_cash_flow 全仓唯一写入点,out/redeem + in/subscribe 各 1 条 remark 带 gid 同事务,5 处清理段 FK 坑修复);T-17 = D30 A/C 互转(share_class 非空且不同才查 allow_ac_convert 开关,任一端=1 两向放行,联网核实 = 摩根 2025-11「开通」公告;口径裁定:NULL 不参与判定、同类走一般规则,待用户追认),下一步 = 第 6 步集成测试)**。**T-7 关键裁定:强制全转属受理段决策** —— 受理由 `plan_lots` 判定并把 `qty` 收敛为实际全转量 + 受理单 `remark='full_transfer'`,确认段**只继承不重判**(`plan_lots` 不传 `min_hold_qty`);重判会因 T→T+1 可用份额变化(部分成交/他人申赎/产品参数改)得出与受理承诺不一致的结论(擅自扩大客户指令)。**T-8 关键裁定**:引擎时机(R-7)的实现部分在 T-7 已完成,T-8 补的是**守护与验证**(AST 守护唯一调用点 / 受理不触引擎 / 确认链路真引擎正证+反证);⚠️ **T-8 不动去重口径** —— `amount_view` 三处消费方原样不动(属 T-11);`convert_fund` 的阶段 1.5 调用点随 T-9 删函数一并清除(只摘调用会让 v1.0 过渡函数「扣份额但不出预警单」,风控缺失比时机错更危险)。撤单范围:PRD §5.5 原表述与实现(仅 `accepted`)不矛盾、无需回填(第三轮 C1 订正)。已就绪:`fetch_nav.py` / `06-seed-nav.sql`(60 个真实净值日)/ `gen_trade_calendar.py` / `10-seed-trade-calendar.sql`(503 交易日)。 **仓库地图:** | 路径 | 状态 | 说明 | @@ -200,8 +200,8 @@ RBAC 联调账号:scripts/dev/rbac-seed-reference.md 2. 改动属于 api / service / tool / repository 哪一层? 3. 是否需 customer_id 归属与 JWT RBAC? 4. Core 是模拟库只读还是 agent 库读写? -5. 如何验证?(`python -m pytest` 全量(当前 **837 passed / 7 skipped**,风控基线 510 / v0.x convert 完结 739)· uvicorn 启动 + /health · SQL / sync 脚本 · 对照 REQUIREMENTS 验收列) -6. **当前有哪两条并行线?**(① 风控/架构改进线:**已结项**(510 基线绿、§7.2 七项冒烟 7/7 PASS、`037ce7e` 已核实早已推送);② **基金转换线**:v0.x 实时模型已结项(739 绿,**已被 T+1 模型取代**);**下一线 T+1 受理/确认分离模型进行中(2026-09-11)**:PRD v1.1 / 架构 v2.1 / 开发计划 v2.0 重写完毕并经独立审查收敛,**已定稿**(2026-09-11 用户批准),第 5 步 todo 开发进行中(T-1~T-17;**当前进度 T-1~T-14 ✅,837 绿**))——动代码前先确认自己属于哪条线,别混淆前置条件。 +5. 如何验证?(`python -m pytest` 全量(当前 **842 passed / 8 skipped**,风控基线 510 / v0.x convert 完结 739)· uvicorn 启动 + /health · SQL / sync 脚本 · 对照 REQUIREMENTS 验收列) +6. **当前有哪两条并行线?**(① 风控/架构改进线:**已结项**(510 基线绿、§7.2 七项冒烟 7/7 PASS、`037ce7e` 已核实早已推送);② **基金转换线**:v0.x 实时模型已结项(739 绿,**已被 T+1 模型取代**);**下一线 T+1 受理/确认分离模型:第 5 步 todo 开发已收官(2026-09-12,T-1~T-17 全部完成,**842 绿**;真库 9/9 退出码 0)**)——动代码前先确认自己属于哪条线,别混淆前置条件。 7. **远程分支到底还在不在?**(**在**。`git ls-remote --heads origin` 实测 `refs/heads/risk-control-agent` = `fffb78a`。⚠️ **判断远程存亡只能用 `git ls-remote`** —— 2026-09-10 **同日误报两次**,勿再踩。) **本仓特有异常(2026-09-10 深挖确认,别误判为 stale ref)**:`.git/refs/remotes/**` **写入不落盘** —— `git update-ref refs/remotes/origin/X ` 返回 0,但松散引用消失,**且整个 `refs/remotes/origin/` 目录被删** diff --git a/docs/memory/TODO.md b/docs/memory/TODO.md index b129b4c..5e255f2 100644 --- a/docs/memory/TODO.md +++ b/docs/memory/TODO.md @@ -9,7 +9,7 @@ **⚡ 并行新线 · 基金转换(convert)**—— **两条子线**: - **(一)v0.x 两阶段实时模型(已结项 2026-09-10)**:PRD **v0.9.3** + 架构 **v1.0.1** + 开发计划 **v1.0**,**T-0 ~ T-13 全部完成**(第 5 步 + 第 6 步闭环,**736 passed / 10 skipped**;7 个 convert 真库脚本复跑零回归;压测修复 2 处并发缺陷 + 死锁重试后补 `43cf2a1` 最终 **739 passed / 10 skipped**)。**注:该模型已被下一线取代(按下述),其设计文档作废,代码仍保留作过渡基线**。 -- **(二)下一线 · T+1 受理/确认分离模型(进行中 · 2026-09-11)**:入口 **交接文档.md §B.11** · PRD **v1.1** + 架构 **v2.1** 均已整体重写并经独立 AI 联网审查通过(0 阻断级),**已定稿**(2026-09-11 用户批准);**第 4 步开发计划 v2.0 已整体重做(`docs/项目框架设计/开发计划-基金转换交易.md`,T-1~T-17 按架构 §15 重排 + 代码事实 F-1~F-25 + 裁定 R-1~R-15 + 16 条回归面 + 验收 31 条 + FR-C29/C30 全映射),经三轮独立审核闭环(挑毛病 / 验证型 / 白纸重审 + 验证型复核 22/22 落位),已定稿(2026-09-11 用户批准)**。**第 5 步 todo 开发已开工(2026-09-11):T-1 ~ T-14 完成(全量 pytest 837 passed / 7 skipped 零失败;真库 verify 套件 9/9 退出码 0:accept 37 / confirm 83 / engine 39 / api 89 / compensate 56 / apply 24 / lots 20 / seed 全 PASS / tools 14,v1.0 链路 verify_convert_service 已随 convert_fund 退役删除),下一步 T-15(D28 部分成交专项 + 边界补强)**。撤单范围:PRD §5.5 原表述与实现(仅 `accepted`)不矛盾、无需回填(第三轮 C1 订正)。 +- **(二)下一线 · T+1 受理/确认分离模型(进行中 · 2026-09-11)**:入口 **交接文档.md §B.11** · PRD **v1.1** + 架构 **v2.1** 均已整体重写并经独立 AI 联网审查通过(0 阻断级),**已定稿**(2026-09-11 用户批准);**第 4 步开发计划 v2.0 已整体重做(`docs/项目框架设计/开发计划-基金转换交易.md`,T-1~T-17 按架构 §15 重排 + 代码事实 F-1~F-25 + 裁定 R-1~R-15 + 16 条回归面 + 验收 31 条 + FR-C29/C30 全映射),经三轮独立审核闭环(挑毛病 / 验证型 / 白纸重审 + 验证型复核 22/22 落位),已定稿(2026-09-11 用户批准)**。**第 5 步 todo 开发已收官(2026-09-12):T-1 ~ T-17 全部完成(全量 pytest 842 passed / 8 skipped 零失败;真库 verify 套件 9/9 退出码 0:accept 43 / confirm 103 / engine 39 / api 89 / compensate 56 / apply 30 / lots 20 / seed 全 PASS / tools 14,v1.0 链路 verify_convert_service 已随 convert_fund 退役删除),下一步 = 第 6 步集成测试**。撤单范围:PRD §5.5 原表述与实现(仅 `accepted`)不矛盾、无需回填(第三轮 C1 订正)。 ### 基金转换线待办(推荐顺序) @@ -24,7 +24,7 @@ - [x] **【T-13 ✅】**(最后一个 · **已完成 2026-09-10**):**全量回归 → 50 并发压测 → 性能实测补录 → PRD §9 第 18 条数字回填**。**DoD 三条**:① 性能实测值**已回填** PRD §9 第 18 条(v0.9.3,替换「预估 < 100ms」);② 50 并发结论(不超卖 / `100/200/400ms` 退避**不够用**)**已写入** `交接文档.md` §B.6.6;③ 实测**未超阈值** → 无需重定阈值(**未修改实测数据**)。**产出**:`tests/test_convert_concurrency.py`(9 条 · `CONVERT_STRESS` 门禁)+ `test_convert_service` +3 + 修复 `insert_placeholder` 三步法 / 幂等判定 `pending`→202;**736 passed / 10 skipped**、`CONVERT_STRESS=1` 并发 **9/9**、重灌双库复跑零回归、4 组突变验证全命中。 - [ ] **【待用户裁定】幂等重放响应的数值位数偏差**(T-9 执行期发现,**未顺手改**):首次响应 2 位(`calc` 量化)vs 重放响应 4 位(`core_trade` `DECIMAL(18,4)` 直读)→ `"53456.95"` vs `"53456.9500"`,**数值相等**,违反 PRD「对外一律 2 位」展示契约,属 T-7 `_rebuild_quote` 范畴。集成测试已「比数值不比字符串」并钉住偏差 -### 第 5 步 · T+1 受理/确认分离模型(T-1 ~ T-17 · 进行中) +### 第 5 步 · T+1 受理/确认分离模型(T-1 ~ T-17 · ✅ 已收官 2026-09-12) - [x] **T-1 DDL + 种子 ✅(2026-09-11)**:`01-ddl.sql` 新建 `core_convert_request`(6 态 ENUM 权威状态机)/ `core_trade_calendar` / `core_share_rule` + `core_product` 补 4 列;`11-seed-share-rule.sql`(42 行);`reset.ps1` 追加 10/11;`settings` 加 `convert_cutoff_time="15:00"` / `convert_confirm_sla_days=2`;`tests/_ddl.py` 三表同步;`verify_convert_seed.py` 加 DoD ⑨⑩⑪(503 交易日 / 42 行 / 6 态 ENUM,真库全 PASS) - [x] **T-2 calc 扩展 ✅**:`calc.py` 加 `product_round` / `redeem_amount` / `partial_qty`(`__all__` 同步);`test_convert_calc.py` +10 用例 @@ -41,7 +41,9 @@ - [x] **T-12 补偿 + SLA 清理切 T+1 链路 ✅(2026-09-12)**:① `cleanup_pending_convert.py` **整体重写**(扫描对象迁 Core:`list_inflight_before` 扫 `accepted/nav_pending` 且 `requested_at` 严格早于 cutoff → 条件 UPDATE 置 `expired`(S2 不硬删、天然幂等不加锁);SLA 边界 = 当前业务日上推 `convert_confirm_sla_days` 个交易日 00:00(`previous_biz_day` 链式,与 `_window_start` 同口径,日历缺失降级自然日并告警);`--days`/`--dry-run`;**不动镜像**(T-4 契约));② `compensate_convert` **从 v1.0 Deprecated 组救回在役**(详情侧 = Core 三件套前置校验(流水 ≥2 → `confirmed` → 明细非空,任一不满足 `missing` 零写入),补写与确认段第⑧步同口径 `sync_mirror` + `confirmed` 审计 `phase='confirm-compensate'`);③ `convert_request_repository` +`list_inflight_before`。**联网核实**(用户铁律):基金转换「无效申请」真实口径(多家基金公司业务规则:TA T+1 确认、无效资金退回 → `rejected` 映射成立)+ A 股连续休市最长 = 2020 春节 10 自然日(上交所通知,`_MAX_SCAN_DAYS=256` 依据成立)。**⚠️ 真库脚本暴露并修复**:`confirm_one` 第⑦步引擎异常只 log 不留痕(全仓唯一不落痕路径)→ except 补写 `decision='engine_error'` 审计 + `engine_error` 标志贯穿(D17 不阻断保留;test_convert_confirm 断言翻转 is True + 补审计断言)。**验证**:全量 **849 passed / 10 skipped**(846+3 零回归);真库 `verify_convert_compensate.py` **整体重写 T+1 链路 56/56 残留 0**(含幂等重跑;真库修正 1 条口径:`confirmed` 审计真链路恰 2 条 = 确认段 + 补偿各 1);**突变 4 组全被抓还原**(cleanup 窗口反转 5 红 / 前置守卫删除 1 红 / 真库 expired→cancelled 2 红 / 真库 nav_stale=True 1 红)。**勘误**:开发计划原写 `scripts/agent/rebuild_alerts.py` 实际在 `scripts/demo/`(零改动) - [x] **T-13 全量门禁 + convert_fund 退役 ✅(2026-09-12,提交 `3bc84a0`)**:① `convert_fund` 及 v1.0 依赖链净删 468 行(`rebuild_convert_response` 按实际改判**保留在役**:T-9 查询接口读路径);`verify_convert_service.py` git rm;`test_convert_service.py`(805→约 440 行)与 `test_convert_concurrency.py`(v1.0 九用例 → T+1 六用例)全量重写;grep 仅余 4 处注释性历史说明、零调用。② **门禁**:全量 **830 passed / 7 skipped**(849 → 830 为 v1.0 用例组退役净减);CONVERT_STRESS=1 **6/6**;真库 **9/9 退出码 0**。③ **性能实测(PRD §9 第 18 条已回填)**:端到端 P50 79.2 / P95 110.3 / max 114.7 ms —— < 2s 硬门禁余量 17 倍。④ **紧池 100%(验收 31 已回填 PRD)**:50/50 受理 + 50/50 串行确认(v1.0 同场景 80% → 病根结构性消除)。⑤ **计划外产出**:`verify_convert_lots.py` B/C/D 组仍按 v1.0 金额赎回传 `amount=`(T-9 已改份额申报,redeem 分支 `qty is None` 静默跳过)→ 修正为 `qty=` 后 20/20。 - [x] **T-14 D27 产品舍入真正接入 + 联网核实纠偏 ✅(2026-09-12)**:**接入面 4 处单点收敛**——`core_ro.get_share_digits`(全仓唯一回退链:规则行 → `core_product.share_digits` → `calc.PLACES`=2)· `calc.in_qty` 加可选 `digits=PLACES`(默认行为逐字节不变,内部走 `product_round`)· 确认段(business_type=`convert`)· 网关申购折算(`subscribe`);redeem 份额申报(D26)无消费方。**边界裁定**:只接落库侧(DoD 即边界),展示维持 `q(D2)`(库内 DECIMAL(18,4) 本高于展示位,2 位产品零漂移)。**联网核实(用户铁律 · 本轮最重要产出)**:主流 2 位 ✅(民生加银/富时/新华/泰康)· **位数按各基金合同配置 = 行业真实做法** ✅(南方 2012 转换调整公告)· 「舍去 TRUNCATE」型真实存在 ✅(南方利众/宝元、邮储、华宝、申万菱信、东方)· 货基 2 位 ✅(中银/融通/鑫元)· **「指数基金份额 4 位」证伪** ❌(易方达沪深300非银 ETF 合同、汇添富深证300/华夏恒生/科创板 ETF 联接全 2 位;易方达创业板新能源 ETF 联接转换公告明写「转入份额保留两位」)→ **`11-seed-share-rule.sql` v1.1:3 产品 9 行 4→2 + 头注重写为核实出处**;「易方达 ETF 场外整数位」⚠️ 无证据且有反证(真实整数位载体 = 场内渠道,LOF 场内先 2 位再截位整数)。**测试 +7(830→837 零回归)**:calc 层 2 + confirm 段 3(回退链三层各 1,第一层带 business_type 干扰行)+ 申购段 2;4 位种子行 = D27 机制验证载体(docstring 钉明)。**突变 2 组**(confirm/gateway digits 改固定 2 → 各 2 红,还原零残留);真库 **9/9 退出码 0**(seed 重灌 42 行全 2 位生效)。**开口 2 项待用户拍板**:① PRD §2.5.3「易方达 ETF 场外取整数位」修订;② `rounding_mode=truncate` 消费接入(真实存在、T-14 范围外)。 -- [ ] T-15 ~ T-17(D28 部分成交专项 / D29 资金流中转 / D30 A/C 互转) +- [x] **T-15 D28 部分成交专项测试 + 边界补强 ✅(2026-09-12)**:sqlite +1 `test_confirm_after_cancel_conflicts`(**时序口径裁定**:`confirm_one` 对终态单(含 cancelled)= 幂等 `skipped` + current_status 回显,`ConfirmConflict` 仅限「读单后状态才变」窗口);真库 verify_confirm +3 组 19 项(E2 占用释放双向证明 / G2 `actual_qty=0` → `rejected` 零残留 / H2 撤单后确认)→ **102/102**;R-10 实测钉死 = 部分确认吃掉全部物理可用、「释放」的是受理占用。**CONVERT_STRESS 争批**:`Barrier(2)` 钉 `apply_convert` 事务门口构造「两单都完成 ④⑤ 读」窄窗,断言恰 1 confirmed + 1 rejected。**突变 = 防御纵深实证**:单独拆事务内哨兵不红(④⑤ 拦)、单独拆 ④ partial_qty 不红(⑤ plan_lots 拦)→ 窄窗 + 同拆哨兵 ×3 红、还原 ×3 绿——④可用量复核 / ⑤plan_lots / 事务内哨兵三道防线拆任何一道仍收敛 rejected。全量 **838 passed / 8 skipped**。 +- [x] **T-16 D29 资金流中转建模 ✅(2026-09-12)**:`_apply_convert_once` 在 `_insert_trades` 后加 `_insert_cash_flows` —— **全仓唯一写入点**,2 条 `out/redeem`=out_amount(费前)+ `in/subscribe`=in_amount(净转入),remark=`convert:{gid}`、channel=online,与流水/明细/持仓/置态同事务(幂等由 T-7 哨兵保证);`tests/_ddl.py` 补 sqlite 表 + 门禁;`test_confirm_writes_no_cash_flow` **反转为** `test_confirm_writes_exactly_two_cash_flows`(T-7 登记「表建立后改 0 行断言」闭环);真库 apply **A2 组 5 项 + B 组冲突 0 残留 → 24 → 30 项**、confirm「不写资金流」断言反转 → **103 项**。**⚠️ FK 新坑(T-9 同款)**:`core_cash_flow.fk_cf_customer` 挡客户删除 → **5 处清理段补删**(verify_apply / test_convert_integration / verify_engine / verify_api / verify_compensate,remark LIKE 精准定位)。**突变**:拆写入调用 → 反转用例红,还原全绿。 +- [x] **T-17 D30 share_class + A/C 互转 ✅(2026-09-12 · 转换线收官)**:`_validate_products` ta_code 后**纯新增拦截分支**——两端 share_class 非空且不同 = A/C 场景,任一端 `allow_ac_convert=1` 两向放行、全关 → `CrossEntityNotSupported`(错误码沿用,message 指明 A/C)。**口径裁定 2 条(R-12 字面偏差,待用户追认)**:① NULL 不参与 A/C 判定(字面会打穿全部既有受理用例);② 「share_class 相同 → 400」不采纳(同类别跨基金走一般转换规则)。**联网核实**:A/C 互转需管理人「开通」(摩根行业睿选 2025-11 公告)= 开关真实载体;转换后持有期重新起算(本仓转入新批次一致零改动);转换费 = 赎回费 + 申购补差(本仓范围外)。**种子 ⑥ 段**(09-seed-org.sql:双开对/双关对照/同类对照,其余 NULL 零回归)。**测试 +4(838→842)**:双开/单开/双关 400/同类+NULL 回归保护;真库 accept **H 组 6 项 → 43/43**(H3 首跑撞种子 min_redeem_qty=100 教训:种子产品做载体须通读既有段);**突变**:分支恒 False → closed 红,还原绿。模拟库近似留痕:无 fund_code,判定 = 同管理人同 TA + share_class 非空且不同。 > **第 0~2 批结果(2026-09-10)**:基线 **510 passed** → 批 0 后 **516 passed / 3 skipped**(+3 T-0 用例 +3 T-0b 引擎用例)→ 批 1(T-1)后**仍 516 passed / 3 skipped**(只加表与种子,未加用例 → **零回归**);**T-1 数据层断言 8/8 PASS**(含新增断言 ⑧:费率档 ↔ `product_type` 匹配,越档即 FAIL)→ 批 2(T-2 + T-2b)后 **609 passed / 3 skipped**(**+93 纯函数用例**,零回归);T-2b 实算脚本 15/15 与 PRD §5.3 一致(退出码 0)。 > ⚠️ **以下四条引用块均为 v0.x 两阶段实时线记录,其 T 编号与新线(T-1~T-17)无关、勿混**:新线 T-11 亦涉 `amount_view`,与 v0.x T-11「提升为公开函数」是两回事。 diff --git a/docs/项目框架设计/开发计划-基金转换交易.md b/docs/项目框架设计/开发计划-基金转换交易.md index 48a6ebf..26c60b7 100644 --- a/docs/项目框架设计/开发计划-基金转换交易.md +++ b/docs/项目框架设计/开发计划-基金转换交易.md @@ -2,7 +2,7 @@ > **文档状态**:v2.0 整体重写 · ✅ **三轮独立审核闭环**(第一轮 2 阻断 B1/B2 + 5 重要 + 3 可选;第二轮验证型 12 项零残留;**第三轮白纸重审 C1~C5+O1~O4 全修订 + 第三轮验证型复核 22/22 落位、D-1 残留已修复**)· **已定稿**(2026-09-11 用户批准)(批准后可进入第 5 步 todo 开发) > -> **代码基线**:分支 `risk-control-agent` · pytest 基线 **837 passed / 7 skipped**(T-14 后;T-0~T-14 完成,v1.0 `convert_fund` 已退役)· push 由用户定 +> **代码基线**:分支 `risk-control-agent` · pytest 基线 **842 passed / 8 skipped**(T-17 后;**T-0~T-17 全部完成**,v1.0 `convert_fund` 已退役)· push 由用户定 > > **上游依据**:PRD-基金转换交易.md **v1.1**(§2 模型 / §5 接口 / §6 引擎 / §9 验收 31 条 / Q1~Q15 拍板)· 架构设计-基金转换交易.md **v2.1**(D1~D30 · §15 任务映射 T-0~T-17) > @@ -801,13 +801,31 @@ product_round 加 mode 参数 + 回退链透传 + 种子配 truncate 产品) **依赖**:T-7 **DoD**: -- [ ] 验收 30 每条子场景 ≥1 用例绿(真库) -- [ ] `actual_qty=0` 边界:状态 `rejected`(不产生零额确认) -- [ ] pytest 全绿 +- [x] 验收 30 每条子场景 ≥1 用例绿(真库)—— verify_convert_confirm +3 组 19 项(E2 占用释放双向 / G2 actual_qty=0 / H2 撤单竞态)→ **102/102 退出码 0**(83→102) +- [x] `actual_qty=0` 边界:状态 `rejected`(不产生零额确认)—— G2 组:占用全被抢的单确认后 `rejected`,零流水零明细零持仓变动 +- [x] pytest 全绿 —— **838 passed / 8 skipped**(837 + 1 争批用例,零回归) **风险**:竞态不可复现 → 用真库 + 并发钩子确定性复现(skill 并发缺陷铁律) **回滚点**:补强点独立 +**✅ T-15 完成(D28 部分成交专项测试 + 边界补强 · 2026-09-12)**: +**① 时序口径裁定(sqlite +1)**:`test_confirm_after_cancel_conflicts` —— +`confirm_one` 对**终态单(含 cancelled)= skipped + current_status 回显**(幂等、 +批处理单笔容错语义),`ConfirmConflict` 仅在「读单后状态才变」的并发窗口抛出; +断言零流水零明细 + 受理占用释放。首版误断言抛异常(DID NOT RAISE)即该口径的 +实证。**② 真库 +3 组 19 项(verify_confirm 83 → 102/102)**:E2 占用释放**双向 +证明**(确认前 InsufficientShares 拦截 → 确认吃掉物理份额 → 灌回 15000 再受理 +accepted → E2b 置 expired 释放占用);G2 `actual_qty=0` → `rejected` 零残留; +H2 撤单后确认 skipped。R-10 实测语义钉死:部分确认把可用**物理**份额全部吃掉, +「释放」的是受理占用而非物理余量。**③ CONVERT_STRESS 争批**: +`test_two_confirms_race_same_lot_first_wins` —— `Barrier(2)` 钉在 +`apply_convert` 事务门口,构造「两单都完成 ④⑤ 读」的窄窗口;断言恰 1 +confirmed + 1 rejected、扣减恰 1 份、恰 1 组 2 条流水。**④ 突变验证(防御纵深 +实证)**:单独拆事务内扣减哨兵**不红**(④ 可用量复核拦住)、单独拆 ④ +`partial_qty` **不红**(⑤ plan_lots 余量校验拦住)→ Barrier 窄窗 + 同拆哨兵 +×3 全红、还原 ×3 全绿 —— ④可用量复核 / ⑤plan_lots 校验 / 事务内哨兵三道防线 +拆任何一道,其余两道仍收敛 rejected,资金安全不依赖单点。 + --- ### T-16 · D29 资金流中转建模 @@ -823,13 +841,38 @@ product_round 加 mode 参数 + 回退链透传 + 种子配 truncate 产品) **依赖**:T-6 **DoD**: -- [ ] 确认后 `core_cash_flow` 恰 2 条、金额与流水一致(验收 T-16) -- [ ] 确认失败回滚:core_cash_flow 0 条(同事务) -- [ ] pytest 全绿 +- [x] 确认后 `core_cash_flow` 恰 2 条、金额与流水一致(验收 T-16)—— sqlite `test_confirm_writes_exactly_two_cash_flows`(恰 2 条 / redeem=out_amount 费前、subscribe=in_amount 净转入 / remark=`convert:{gid}` / 幂等重确认无第 3 条)+ 真库 verify_apply A2 组 5 项(含与 core_trade 逐条对账) +- [x] 确认失败回滚:core_cash_flow 0 条(同事务)—— `test_convert_core` 两回滚用例补零残留断言 + 真库 B 组冲突路径资金流 0 条 +- [x] pytest 全绿 —— **838 passed / 8 skipped**(净增 0:反转用例重写 + 断言补强);真库 verify_apply **30/30 退出码 0**(24 → 30) **风险**:流水语义漂移(flow_subtype ENUM 值已含 redeem/subscribe,F-14 无需 ALTER) **回滚点**:确认事务整体回滚保护 +**✅ T-16 完成(D29 资金流中转建模 · 2026-09-12)**: +**① 写入点(全仓唯一)**:`convert_core_repository._apply_convert_once` 在 +`_insert_trades` 后加 `_insert_cash_flows` —— 2 条:`out/redeem` = out_amount +(费前)+ `in/subscribe` = in_amount(净转入),remark = `convert:{gid}`、 +channel = `online`、occurred_at = traded_at;与流水/明细/持仓/受理置态同一事务, +幂等由 T-7 确认权哨兵天然保证。R-11「中转」语义 = 确认事务内出+入两条对冲, +无中间现金停留;费用不在资金流重复体现(与 core_trade 转出端同口径)。 +**② DDL**:`tests/_ddl.py` 补 sqlite 版 `core_cash_flow` + 并入 +REQUIRED_CONVERT_TABLES 门禁(真库 01-ddl.sql 既有此表,F-14 无需 ALTER)。 +**③ 测试**:`test_confirm_writes_no_cash_flow` 反转为 +`test_confirm_writes_exactly_two_cash_flows`(T-7 登记的「表建立后改 0 行断言」 +闭环);`test_convert_core` 两回滚用例补资金流零残留。 +**④ 真库 verify_convert_apply.py**:A2 组 5 项(条数 / R-11 flow 组合 / 两端 +金额 = req / 与 core_trade 逐条对账)+ B 组冲突 0 残留 → **24 → 30 项全绿**; +verify_convert_confirm 头注登记的「确认不写资金流」断言同步反转为「恰 2 条 + +与流水金额对账」→ 102 → **103 项全绿**(全套终态复跑时暴露并随本轮修正)。 +另:confirm 对账项首版用 `r[0]` 取 dict 行踩 KeyError(`_rows` 返回 mappings), +改列名取值——各脚本 `_rows` 返回形态需先查再用。 +**⑤ 新坑(T-9 FK 坑同款)**:`core_cash_flow.fk_cf_customer` 挡住 +`core_customer` 删除 —— verify_apply 与 `test_convert_integration` 两处清理段 +均补「先删资金流」(integration 13 个 ERROR 定位到此根因,修复后 13 绿); +remark LIKE `'convert:%'` / `'convert:{_GROUP_LIKE}'` 精准定位不误删既有种子。 +**⑥ 突变验证**:拆 `_insert_cash_flows` 调用 → 反转用例红(0 ≠ 2);还原 → +core+confirm 37 全绿,MUTATION 零残留。 + --- ### T-17 · D30 share_class + A/C 互转 @@ -846,13 +889,38 @@ product_round 加 mode 参数 + 回退链透传 + 种子配 truncate 产品) **依赖**:T-1 / T-9 **DoD**: -- [ ] A/C 互转用例:同管理人同 TA + 开关开 → 成功受理 -- [ ] 开关关 / 跨管理人 / 相同 share_class → 400(对照) -- [ ] pytest 全绿 +- [x] A/C 互转用例:同管理人同 TA + 开关开 → 成功受理 —— sqlite 双开/单开(任一为 1 即两向放行)各 1 条 + 真库 H1(种子产品 `'A'`→`'C'` 双开 accepted) +- [x] 开关关 / 跨管理人 → 400(对照)—— 开关关 sqlite + 真库 H2(错误码沿用 `CROSS_ENTITY_NOT_SUPPORTED`、提示指明 A/C、不落单);跨管理人既有用例覆盖。**「相同 share_class → 400」按口径裁定修订为不 400**(走一般转换规则,见执行记录 ①) +- [x] pytest 全绿 —— **842 passed / 8 skipped**(838 + 4);真库 verify_accept **43/43 退出码 0**(37 → 43) **风险**:校验放行规则与「同产品禁转」冲突 → R-12 已裁定(share_class 不同即放行) **回滚点**:校验是前置分支,git 回退即还原 +**✅ T-17 完成(D30 share_class + A/C 互转 · 2026-09-12 · 转换线收官)**: +**① 口径裁定(R-12 字面两处偏差,取业务正确口径,登记待用户追认)**: +(a) `share_class` **NULL 不参与 A/C 判定**——字面「不满足 → 400」会打穿全部既有 +受理用例(种子产品 share_class 全 NULL);实现 = 两端非空且不同才触发 A/C +分支。(b) 字面「share_class 相同 → 400」**不采纳**:非空同类别跨基金互转走 +一般转换规则(同管理人同 TA 本就放行,无业务理由禁转),DoD 对照用例改为 +「同类放行」回归保护。**② 实现**:`_validate_products` ta_code 校验后**纯新增 +拦截分支**(A/C 场景开关全关才拦,放行路径零改动);错误码沿用 +`CrossEntityNotSupported`(改法①),message 指明「未开通同基金 A/C 份额互转」; +任一端 `allow_ac_convert=1` 即两向放行。**③ 联网核实**:A/C 互转 = 同一基金 +不同份额类别的转换业务,**需管理人「开通」**(摩根行业睿选 2025-11《开通同一 +基金不同份额类别转换业务公告》)= `allow_ac_convert` 的真实载体;转换后**持有 +期重新起算**(摩根公告明写;本仓转入新批次语义一致,零改动);转换费 = 转出 +赎回费 + 申购补差费(深证监局;本仓费率按转出端配置,A/C 补差费不在范围)。 +**④ 种子 ⑥ 段**(09-seed-org.sql):双开对 PROD-110022 `'A'` / PROD-005827 +`'C'`;双关对照 PROD-110023 `'A'` / PROD-003095 `'C'`;同类对照 PROD-510300 +`'C'`;其余产品保持 NULL 零回归。**⑤ 测试 +4(838 → 842)**:双开放行 / 单开 +放行 / 双关 400(message + 不落单)/ 同类+NULL 放行回归保护。**⑥ 真库 H 组 +6 项(37 → 43)**:H1 双开 accepted;H2 双关 400 语义 + 被拒不落单;H3 同类 +`'A'`→`'A'` accepted。H3 首跑撞种子 ⑤ 段 `min_redeem_qty=100`(申请 50 被 +BelowMinQty 拦)→ 改申请 100——种子产品做载体必须通读既有段配置。**⑦ 突变 +验证**:A/C 分支恒 False → closed 用例红;还原 → 全绿。**⑧ 模拟库近似留痕**: +真实「同基金 A/C」按基金合同绑定同组合,模拟库无 fund_code 字段,判定以 +「同管理人同 TA + share_class 非空且不同」近似。 + --- ## 5. 回归面清单(必然打穿的既有断言 · 全 tests/ 扫) diff --git a/scripts/core/09-seed-org.sql b/scripts/core/09-seed-org.sql index 69b64c9..b9b53c1 100644 --- a/scripts/core/09-seed-org.sql +++ b/scripts/core/09-seed-org.sql @@ -86,3 +86,16 @@ UPDATE core_product SET ta_code = 'TA-CN-099' WHERE ta_code IS NULL; -- ---- ⑤ 最低持有余额(FR-C15 两种动作的可触发数据)---- UPDATE core_product SET min_hold_qty = 1000.0000, min_redeem_qty = 100.0000, min_hold_action = 'force_transfer' WHERE product_id = 'PROD-110022'; UPDATE core_product SET min_hold_qty = 2000.0000, min_redeem_qty = 200.0000, min_hold_action = 'force_redeem' WHERE product_id = 'PROD-005827'; + +-- ---- ⑥ T-17 / D30 同基金 A/C 份额互转演示对(R-12)---- +-- 联网核实(2026-09-12):A/C 互转 = 同一基金不同份额类别的转换业务, +-- 需管理人「开通」(摩根行业睿选 2025-11《开通同一基金不同份额类别转换业务公告》), +-- 开通即 allow_ac_convert=1;转换后持有期重新起算(本仓转入新批次语义一致)。 +-- A/C 对(开关开 · 双端):TA-CN-001 内 'A' ↔ 'C' → 放行 +UPDATE core_product SET share_class = 'A', allow_ac_convert = 1 WHERE product_id = 'PROD-110022'; +UPDATE core_product SET share_class = 'C', allow_ac_convert = 1 WHERE product_id = 'PROD-005827'; +-- A/C 对照(开关关 · 双端 0):同 TA 同 'A'/'C' 但未开通 → 受理 400 +UPDATE core_product SET share_class = 'A', allow_ac_convert = 0 WHERE product_id = 'PROD-110023'; +UPDATE core_product SET share_class = 'C', allow_ac_convert = 0 WHERE product_id = 'PROD-003095'; +-- 同类别对照:'C' ↔ 'C' 不触发 A/C 判定,走一般转换规则放行(allow_ac_convert 保持默认 0) +UPDATE core_product SET share_class = 'C' WHERE product_id = 'PROD-510300'; diff --git a/scripts/dev/verify_convert_accept.py b/scripts/dev/verify_convert_accept.py index 316ba5c..3439841 100644 --- a/scripts/dev/verify_convert_accept.py +++ b/scripts/dev/verify_convert_accept.py @@ -49,7 +49,7 @@ from app.repository.convert_request_repository import ( # noqa: E402 from app.repository.core_ro import CoreReadOnlyRepository # noqa: E402 from app.repository.risk_repository import RiskRepository # noqa: E402 from app.service.convert.convert_service import accept_convert # noqa: E402 -from app.service.convert.errors import InsufficientShares # noqa: E402 +from app.service.convert.errors import CrossEntityNotSupported, InsufficientShares # noqa: E402 from app.service.convert.trading_calendar import ( # noqa: E402 next_biz_day, parse_cutoff, @@ -508,6 +508,74 @@ def main() -> int: "WHERE table_schema = :db AND table_name = 'core_convert_request' " "AND column_name = 'requested_at'", db=settings.mysql_core_database), 3) + + # ── H. D30 同基金 A/C 份额互转(T-17 / R-12 · 真库种子产品)── + print("\n【H】A/C 互转:双开放行 / 双关 400 / 同类别走一般规则") + # 种子就位(09-seed-org.sql ⑥ 段的幂等重申;rowcount 同时证明产品存在) + with core_admin.begin() as conn: + for pid, sc, ac in ( + ("PROD-110022", "A", 1), ("PROD-005827", "C", 1), + ("PROD-110023", "A", 0), ("PROD-003095", "C", 0), + ("PROD-510300", "C", 0), + ): + n = conn.execute( + text("UPDATE core_product SET share_class = :sc, allow_ac_convert = :ac " + "WHERE product_id = :p"), + {"sc": sc, "ac": ac, "p": pid}, + ).rowcount + if n != 1: + raise RuntimeError(f"种子产品缺失:{pid}(先灌 scripts/core/reset.ps1)") + # A 端持仓 1200 份(避开 PROD-110022 min_hold=1000 的强制全转干扰) + conn.execute( + text("DELETE FROM core_product_nav WHERE product_id = 'PROD-110022' AND nav_date = :d"), + {"d": day}, + ) + conn.execute( + text("INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) " + "VALUES ('PROD-110022', 1.0300, 0, :d)"), + {"d": day}, + ) + conn.execute( + text("INSERT INTO core_share_lot (lot_id, customer_id, product_id, qty, " + "remain_qty, nav, confirmed_at) " + "VALUES ('LOT-ACV-SEED', :c, 'PROD-110022', 1200, 1200, 1.0300, :cat)"), + {"c": CUSTOMER, "cat": datetime(2026, 1, 5, 10, 0, 0)}, + ) + conn.execute( + text("INSERT INTO core_holding (customer_id, product_id, qty, cost_amount, " + "market_value, pnl_pct, as_of) " + "VALUES (:c, 'PROD-110022', 1200, 1200.00, 1236.00, 0, :d)"), + {"c": CUSTOMER, "d": day}, + ) + + # H1:'A'→'C' 双端开通 → 放行 + ac_open = _accept(_base_req( + from_product_id="PROD-110022", to_product_id="PROD-005827", + qty=Decimal("100"), client_request_id="ACV-REQ-AC-OPEN"), submit_at) + check("H1 'A'→'C' 双开通受理 accepted", ac_open["status"], "accepted") + + # H2:'A'→'C' 双端未开通 → 400 语义沿用 CROSS_ENTITY_NOT_SUPPORTED(换 A/C 文案) + ac_err = None + try: + _accept(_base_req( + from_product_id="PROD-110023", to_product_id="PROD-003095", + qty=Decimal("100"), client_request_id="ACV-REQ-AC-CLOSED"), submit_at) + except CrossEntityNotSupported as exc: + ac_err = exc + check("H2 双关被拒", bool(ac_err), True) + check("H2 错误码沿用 CROSS_ENTITY_NOT_SUPPORTED", + ac_err.error_code if ac_err else None, "CROSS_ENTITY_NOT_SUPPORTED") + check("H2 提示指明 A/C 互转", "A/C" in (ac_err.message if ac_err else ""), True) + check("H2 被拒不落单", + q1(core_admin, "SELECT COUNT(*) FROM core_convert_request " + "WHERE customer_id = :c AND to_product_id = 'PROD-003095'", c=CUSTOMER), 0) + + # H3:'A'→'A' 同类别 → 不触发 A/C 判定,走一般转换规则放行 + # (申请 100 = 种子 min_redeem_qty;转后余 1000 恰不触发 min_hold 强制全转) + ac_same = _accept(_base_req( + from_product_id="PROD-110022", to_product_id="PROD-110023", + qty=Decimal("100"), client_request_id="ACV-REQ-AC-SAME"), submit_at) + check("H3 同类别 'A'→'A' 走一般规则 accepted", ac_same["status"], "accepted") finally: cleanup_core(core_admin) cleanup_agent(agent_admin) diff --git a/scripts/dev/verify_convert_api.py b/scripts/dev/verify_convert_api.py index 9838ffb..52580b4 100644 --- a/scripts/dev/verify_convert_api.py +++ b/scripts/dev/verify_convert_api.py @@ -244,6 +244,8 @@ def cleanup_core(engine) -> None: "DELETE FROM core_fee_rule WHERE product_id LIKE 'PROD-ACP%'", "DELETE FROM core_product_nav WHERE product_id LIKE 'PROD-ACP%'", "DELETE FROM core_product WHERE product_id LIKE 'PROD-ACP%'", + # T-16 起确认事务写 core_cash_flow(fk_cf_customer 挡在客户删除前) + "DELETE FROM core_cash_flow WHERE remark LIKE 'convert:%'", "DELETE FROM core_customer WHERE customer_id = :c", ]: conn.execute(text(sql), {"c": CUSTOMER}) diff --git a/scripts/dev/verify_convert_apply.py b/scripts/dev/verify_convert_apply.py index 3dc601a..1015185 100644 --- a/scripts/dev/verify_convert_apply.py +++ b/scripts/dev/verify_convert_apply.py @@ -174,6 +174,9 @@ def cleanup(engine) -> None: with engine.begin() as conn: for sql, params in [ ("DELETE FROM core_convert_lot_detail WHERE convert_group_id LIKE 'CNV-T6M%'", {}), + # T-16:资金流是确认事务的新写入点,且 fk_cf_customer 挡在客户删除前, + # 必须在 core_customer 之前清掉(remark 带 convert_group_id 可精准定位) + ("DELETE FROM core_cash_flow WHERE remark LIKE 'convert:%'", {}), ("DELETE FROM core_trade WHERE customer_id = :c", {"c": CUSTOMER}), ("DELETE FROM core_share_lot WHERE customer_id = :c", {"c": CUSTOMER}), ("DELETE FROM core_holding WHERE customer_id = :c", {"c": CUSTOMER}), @@ -300,6 +303,37 @@ def main() -> int: dec(req.in_amount), ) + # ── A2. 资金流中转(T-16 / R-11 / D29 · 真库侧)─────────────── + print("\n【A2】core_cash_flow 恰 2 条:out/redeem + in/subscribe,金额与流水一致") + check("资金流条数", q1(admin, "SELECT COUNT(*) FROM core_cash_flow WHERE remark = 'convert:CNV-T6M-1'"), 2) + check( + "R-11 flow 组合", + sorted((r[0], r[1]) for r in _rows( + admin, + "SELECT flow_type, flow_subtype FROM core_cash_flow WHERE remark = 'convert:CNV-T6M-1'", + )), + [("in", "subscribe"), ("out", "redeem")], + ) + check( + "转出端金额 = 流水费前金额", + dec(q1(admin, "SELECT amount FROM core_cash_flow WHERE remark = 'convert:CNV-T6M-1' AND flow_subtype = 'redeem'")), + dec(req.out_amount), + ) + check( + "转入端金额 = 流水净转入金额", + dec(q1(admin, "SELECT amount FROM core_cash_flow WHERE remark = 'convert:CNV-T6M-1' AND flow_subtype = 'subscribe'")), + dec(req.in_amount), + ) + check( + "金额与 core_trade 逐条一致(对账)", + sorted(dec(r[0]) for r in _rows( + admin, "SELECT amount FROM core_cash_flow WHERE remark = 'convert:CNV-T6M-1'", + )), + sorted(dec(r[0]) for r in _rows( + admin, "SELECT amount FROM core_trade WHERE convert_group_id = 'CNV-T6M-1'", + )), + ) + # ── B. 冲突路径(真实 InnoDB 事务回滚)──────────────────────── print("\n【B】冲突路径:remain_qty 不足 → LotConflict + 无残留") conflict = build_input(admin, "CNV-T6M-2", "30") @@ -324,6 +358,7 @@ def main() -> int: check("抛 LotConflict(409)", raised, 409) check("冲突后无流水残留", q1(admin, "SELECT COUNT(*) FROM core_trade WHERE convert_group_id = 'CNV-T6M-2'"), 0) check("冲突后无明细残留", q1(admin, "SELECT COUNT(*) FROM core_convert_lot_detail WHERE convert_group_id = 'CNV-T6M-2'"), 0) + check("冲突后资金流也零残留(T-16:写入点在同一事务内)", q1(admin, "SELECT COUNT(*) FROM core_cash_flow WHERE remark = 'convert:CNV-T6M-2'"), 0) check("批次未被误扣", dec(q1(admin, "SELECT remain_qty FROM core_share_lot WHERE lot_id = 'LOT-T6M-A2'")), Decimal("30.00")) # ── C. 转入端再次转换:增量累加(非覆盖)─────────────────────── diff --git a/scripts/dev/verify_convert_compensate.py b/scripts/dev/verify_convert_compensate.py index 9449054..fa5de1b 100644 --- a/scripts/dev/verify_convert_compensate.py +++ b/scripts/dev/verify_convert_compensate.py @@ -179,6 +179,8 @@ def cleanup_env(core_engine, agent_engine) -> None: ("DELETE FROM core_fee_rule WHERE product_id LIKE 'PROD-T12C%'", {}), ("DELETE FROM core_product_nav WHERE product_id LIKE 'PROD-T12C%'", {}), ("DELETE FROM core_product WHERE product_id LIKE 'PROD-T12C%'", {}), + # T-16 起确认事务写 core_cash_flow(fk_cf_customer 挡在客户删除前) + ("DELETE FROM core_cash_flow WHERE remark LIKE 'convert:%'", {}), ("DELETE FROM core_customer WHERE customer_id = :c", {"c": CUSTOMER}), ]: conn.execute(text(sql), params) diff --git a/scripts/dev/verify_convert_confirm.py b/scripts/dev/verify_convert_confirm.py index bf71a25..b7745c8 100644 --- a/scripts/dev/verify_convert_confirm.py +++ b/scripts/dev/verify_convert_confirm.py @@ -16,7 +16,7 @@ 4. **`core_trade.traded_at` / `confirmed_at` 是 DATETIME(3)**:确认日时刻零漂移。 5. **批处理锁 + 捞单窗口**:`idx_status_asof` 上 `status IN (accepted, nav_pending)` 的 ENUM 比较 + 按受理日区间捞单,只有真库才验证得到。 -6. 确认**不写** `core_cash_flow`(资金流在 T-16 落地前必须为 0 行)。 +6. 确认写 `core_cash_flow` 恰 2 条且与流水金额对账(T-16/R-11,原 0 行断言已反转)。 **核心锚点**:本脚本用 PRD §5.3.2 的真实示例输入(30000+20000 份、T 日净值 1.3604 / 1.9194、申购费率 0.30% / 0.80%、赎回费分档)**在真库上重放**,断言 @@ -58,7 +58,8 @@ from app.repository.convert_request_repository import ( # noqa: E402 from app.repository.core_ro import CoreReadOnlyRepository # noqa: E402 from app.repository.risk_repository import RiskRepository # noqa: E402 from app.service.convert.confirm_service import confirm_batch, confirm_one # noqa: E402 -from app.service.convert.convert_service import accept_convert # noqa: E402 +from app.service.convert.convert_service import accept_convert, cancel_convert # noqa: E402 +from app.service.convert.errors import InsufficientShares # noqa: E402 from app.service.convert.trading_calendar import next_biz_day # noqa: E402 from app.utils.db import dispose_engines, get_engine # noqa: E402 @@ -464,10 +465,17 @@ def main() -> int: check("受理单 confirmed_at 非空", row["confirmed_at"] is not None, True) check("确认后占用释放(终态不在途)", _inflight(core_ro), Decimal("0.00")) - # ── 确认不写资金流(T-16 前必须 0 行)── - check("无 core_cash_flow 写入", + # ── 确认写资金流恰 2 条(T-16 / R-11 反转:原「必须 0 行」已过时)── + check("core_cash_flow 恰 2 条(out/redeem + in/subscribe)", q1(core_admin, "SELECT COUNT(*) FROM core_cash_flow WHERE customer_id = :c", - c=CUSTOMER), 0) + c=CUSTOMER), 2) + check("资金流与流水金额对账一致", + sorted(dec(r["amount"]) for r in _rows( + core_admin, "SELECT amount FROM core_cash_flow WHERE customer_id = :c", + c=CUSTOMER)), + sorted(dec(r["amount"]) for r in _rows( + core_admin, "SELECT amount FROM core_trade WHERE convert_group_id = :g", + g=gid))) # ── agent 镜像 ── mirror = _rows( @@ -572,6 +580,98 @@ def main() -> int: check("批次 2 归零", _remain(core_admin, "LOT-CVF-C2"), Decimal("0.0000")) check("未确认部分占用释放", _inflight(core_ro), Decimal("0.00")) + # ══ E2. 部分确认的占用释放(T-15 · 验收 30 · 双向证明)══ + # R-10 语义:actual = min(申请, 可用) → 确认把可用全部吃掉,物理余量归零; + # 「释放」的是**受理占用**(未确认部分从不在物理上存在)。故双向证明: + # 确认前:占用未释放 → 新受理被在途占用拦截; + # 确认后:终态释放 → 同样份额规模按物理余量正常受理。 + print("\n【E2】部分成交占用释放:确认前拦截 / 确认后放行") + reset_shares(core_admin, CUSTOMER, day) + gid_e2 = _accept(_base_req(client_request_id="CVF-REQ-E2"), submit_at)[ + "convert_group_id" + ] + with core_admin.begin() as conn: + conn.execute( + text("UPDATE core_share_lot SET remain_qty = 15000 WHERE lot_id = 'LOT-CVF-C1'") + ) + # 确认前:物理 35000 − 在途 50000 < 0 → 新受理必须被拦截(占用不提前释放) + blocked = None + try: + _accept(_base_req(qty=Decimal("15000"), client_request_id="CVF-REQ-E2B"), + submit_at) + except InsufficientShares: + blocked = "InsufficientShares" + check("E2 确认前新受理被在途占用拦截", blocked, "InsufficientShares") + res_e2 = _confirm(gid_e2, confirm_at, next_day) + check("E2 部分成交 confirmed", res_e2["status"], "confirmed") + check("E2 实际成交 35000(申请 50000)", res_e2["actual_qty"], "35000.00") + check("E2 确认后占用归零(终态释放,无幽灵滞留)", + _inflight(core_ro), Decimal("0.00")) + # 确认后:灌回 15000(模拟 T+1 申购到账)→ 受理 15000 放行 + # (若旧申请 50000 仍幽灵占用,available = 15000 − 50000 < 0 会再被拦) + with core_admin.begin() as conn: + conn.execute( + text("INSERT INTO core_share_lot (lot_id, customer_id, product_id, qty, " + "remain_qty, nav, confirmed_at) VALUES ('LOT-CVF-E2IN', :c, :p, " + "15000, 15000, 1.0000, :t)"), + {"c": CUSTOMER, "p": PROD_OUT, "t": confirm_at}, + ) + gid_e2b = _accept( + _base_req(qty=Decimal("15000"), client_request_id="CVF-REQ-E2B"), submit_at + )["convert_group_id"] + check("E2b 释放后同规模受理 accepted", + _req_row(core_admin, gid_e2b)["status"], "accepted") + check("E2b 后占用 = 15000(新单占用)", _inflight(core_ro), Decimal("15000.00")) + # E2b 置 expired(S2 运维兜底)释放占用,还原干净状态给后续组 + with core_admin.begin() as conn: + conn.execute( + text("UPDATE core_convert_request SET status = 'expired' " + "WHERE convert_group_id = :g"), + {"g": gid_e2b}, + ) + check("E2b expired 释放占用(S2 兜底语义)", _inflight(core_ro), Decimal("0.00")) + + # ══ G2. actual_qty=0 → rejected(T-15 · R-10 补强 · 验收 30 边界)══ + print("\n【G2】占用全被抢:actual_qty=0 → rejected,零流水零扣减") + reset_shares(core_admin, CUSTOMER, day) + gid_g2 = _accept(_base_req(client_request_id="CVF-REQ-G2"), submit_at)[ + "convert_group_id" + ] + with core_admin.begin() as conn: + conn.execute( + text("UPDATE core_share_lot SET remain_qty = 0 WHERE customer_id = :c"), + {"c": CUSTOMER}, + ) + res_g2 = _confirm(gid_g2, confirm_at, next_day) + check("G2 全被抢 → rejected", res_g2["status"], "rejected") + check("G2 拒绝原因 = NO_AVAILABLE_QTY", res_g2["reject_reason"], "NO_AVAILABLE_QTY") + check("G2 单状态落库 rejected", _req_row(core_admin, gid_g2)["status"], "rejected") + check("G2 零流水(不产生零额确认)", + q1(core_admin, "SELECT COUNT(*) FROM core_trade WHERE convert_group_id = :g", + g=gid_g2), 0) + check("G2 占用释放", _inflight(core_ro), Decimal("0.00")) + check("G2 明细零写入", + q1(core_admin, "SELECT COUNT(*) FROM core_convert_lot_detail " + "WHERE convert_group_id = :g", g=gid_g2), 0) + + # ══ H2. 撤单与确认竞态(T-15 · 确认侧后到 = 幂等 skipped)══ + print("\n【H2】撤单先落库、确认后到 → skipped,零流水,占用保持释放") + reset_shares(core_admin, CUSTOMER, day) + gid_h2 = _accept(_base_req(client_request_id="CVF-REQ-H2"), submit_at)[ + "convert_group_id" + ] + cancel_res = cancel_convert(gid_h2, now=submit_at, **_accept_services()) + check("H2 撤单成功(T 日窗口内)", cancel_res["status"], "cancelled") + res_h2 = _confirm(gid_h2, confirm_at, next_day) + check("H2 确认幂等跳过", res_h2["status"], "skipped") + check("H2 current_status = cancelled", res_h2["current_status"], "cancelled") + check("H2 单保持 cancelled(确认未夺走终态)", + _req_row(core_admin, gid_h2)["status"], "cancelled") + check("H2 零流水", + q1(core_admin, "SELECT COUNT(*) FROM core_trade WHERE convert_group_id = :g", + g=gid_h2), 0) + check("H2 占用保持释放", _inflight(core_ro), Decimal("0.00")) + # ══ F. 引擎恰好一次(验收 5 / 7 / 29)══ print("\n【F】规则引擎:恰好跑一次(重复确认不再投递)") reset_shares(core_admin, CUSTOMER, day) diff --git a/scripts/dev/verify_convert_engine.py b/scripts/dev/verify_convert_engine.py index 5ad0608..1d1b6fa 100644 --- a/scripts/dev/verify_convert_engine.py +++ b/scripts/dev/verify_convert_engine.py @@ -164,6 +164,8 @@ def cleanup(core_engine, agent_engine) -> None: ("DELETE FROM core_fee_rule WHERE product_id LIKE 'PROD-T8M%'", {}), ("DELETE FROM core_product_nav WHERE product_id LIKE 'PROD-T8M%'", {}), ("DELETE FROM core_product WHERE product_id LIKE 'PROD-T8M%'", {}), + # T-16 起确认事务写 core_cash_flow(fk_cf_customer 挡在客户删除前) + ("DELETE FROM core_cash_flow WHERE remark LIKE 'convert:%'", {}), ("DELETE FROM core_customer WHERE customer_id IN (:c1, :c2)", {"c1": CUSTOMER, "c2": CUSTOMER_SMALL}), ]: diff --git a/tests/_ddl.py b/tests/_ddl.py index 0938170..9bc3843 100644 --- a/tests/_ddl.py +++ b/tests/_ddl.py @@ -137,6 +137,22 @@ SQLITE_TABLES: dict[str, str] = { rounding_mode VARCHAR(16) DEFAULT 'half_up', UNIQUE (product_id, business_type)) """, + # T-16(R-11/D29):core_cash_flow 与 MySQL scripts/core/01-ddl.sql 对齐 —— + # 确认事务内写 2 条中转资金流(转出 out/redeem + 转入 in/subscribe)。 + # MySQL 的 ENUM 在 sqlite 侧按既有惯例降为 VARCHAR(值域由写入方约束)。 + "core_cash_flow": """ + CREATE TABLE core_cash_flow ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id VARCHAR(64) NOT NULL, + flow_type VARCHAR(8) NOT NULL, + flow_subtype VARCHAR(16) NOT NULL DEFAULT 'other', + amount DECIMAL NOT NULL, + channel VARCHAR(16) NOT NULL DEFAULT 'online', + counterparty_account_mask VARCHAR(32), + counterparty_name VARCHAR(64), + remark VARCHAR(128), + occurred_at TIMESTAMP NOT NULL) + """, "risk_convert_detail": f""" CREATE TABLE risk_convert_detail ( id INTEGER PRIMARY KEY AUTOINCREMENT, convert_group_id VARCHAR(64) NOT NULL, @@ -255,6 +271,8 @@ REQUIRED_CONVERT_TABLES = { "core_fee_rule", "core_share_lot", "core_convert_lot_detail", "risk_convert_detail", # T+1 模型新增 3 表(T-1) "core_convert_request", "core_trade_calendar", "core_share_rule", + # T-16(R-11/D29):确认事务的中转资金流载体(MySQL 侧已建,F-14) + "core_cash_flow", } diff --git a/tests/test_convert_accept.py b/tests/test_convert_accept.py index 98ceb8e..5f30361 100644 --- a/tests/test_convert_accept.py +++ b/tests/test_convert_accept.py @@ -521,6 +521,68 @@ def test_accept_product_validation_errors(sqlite_engine): assert _reqs(sqlite_engine) == [] +# ── D30 同基金 A/C 份额互转(T-17 / R-12)──────────────────────────── +def _set_class(engine, pid: str, share_class: str | None, allow: int = 0) -> None: + _exec( + engine, + "UPDATE core_product SET share_class = :sc, allow_ac_convert = :ac " + "WHERE product_id = :p", + p=pid, sc=share_class, ac=allow, + ) + + +def test_accept_ac_convert_open_allows(sqlite_engine): + """两端 share_class 'A'/'C' + 双端开通 → 正常受理(A/C 互转放行路径)。""" + _seed(sqlite_engine) + _set_class(sqlite_engine, PROD_OUT, "A", allow=1) + _set_class(sqlite_engine, PROD_IN, "C", allow=1) + + result = _call(sqlite_engine, client_request_id="REQ-AC-OPEN") + + assert result["accepted"] is True and result["blocked"] is False + assert len(_reqs(sqlite_engine)) == 1 + + +def test_accept_ac_convert_single_side_open_allows(sqlite_engine): + """仅一端 allow_ac_convert=1 → 同样放行(R-12:任一为 1 即两向放行)。""" + _seed(sqlite_engine) + _set_class(sqlite_engine, PROD_OUT, "A", allow=0) + _set_class(sqlite_engine, PROD_IN, "C", allow=1) + + result = _call(sqlite_engine, client_request_id="REQ-AC-SINGLE") + + assert result["accepted"] is True + + +def test_accept_ac_convert_closed_raises(sqlite_engine): + """两端份额类别不同 + 开关全关 → 400 语义沿用 CROSS_ENTITY_NOT_SUPPORTED(换 A/C 提示),不落单。""" + _seed(sqlite_engine) + _set_class(sqlite_engine, PROD_OUT, "A", allow=0) + _set_class(sqlite_engine, PROD_IN, "C", allow=0) + + with pytest.raises(CrossEntityNotSupported) as exc: + _call(sqlite_engine) + assert "A/C" in exc.value.message + assert _reqs(sqlite_engine) == [] + + +def test_accept_same_or_null_share_class_walks_general_rule(sqlite_engine): + """share_class 同类或 NULL 不触发 A/C 判定:开关全关也放行(一般转换规则零回归保护)。""" + _seed(sqlite_engine) + _set_class(sqlite_engine, PROD_OUT, "C", allow=0) + _set_class(sqlite_engine, PROD_IN, "C", allow=0) + assert _call(sqlite_engine, client_request_id="REQ-AC-SAME")["accepted"] is True + + # 转入端回置 NULL:单端非空同样走一般规则(历史种子全 NULL 的回归保护); + # 第一单 100 份在途占用后可用仅剩 50,第二次申请 50 验证放行 + _exec( + sqlite_engine, + "UPDATE core_product SET share_class = NULL WHERE product_id = :p", + p=PROD_IN, + ) + assert _call(sqlite_engine, client_request_id="REQ-AC-NULL", qty=Decimal("50"))["accepted"] is True + + def test_accept_zero_or_negative_qty_raises(sqlite_engine): """申请份额必须为正(受理不落单)。""" _seed(sqlite_engine) diff --git a/tests/test_convert_concurrency.py b/tests/test_convert_concurrency.py index fd01eaa..640d4ad 100644 --- a/tests/test_convert_concurrency.py +++ b/tests/test_convert_concurrency.py @@ -688,6 +688,70 @@ def test_concurrent_confirms_never_oversell(conc_env): assert len(ok) >= 20, f"确认仅 {len(ok)} 笔,扣减路径疑似退化" +# ── T-15. 两单确定性争同一批:先到先得(验收 30 并发面)──────────────── +@requires_stress +def test_two_confirms_race_same_lot_first_wins(conc_env, monkeypatch): + """两单确认争同一批 → **恰一单成交**(先到先得),零超卖、零零额确认。 + + 确定性构造(不靠运气):池 = 1 批 2 份,同客户两单各受理 1 份(占用 + 2 = 池,受理段均合法)→ **确认前把批次缩减到 1 份**(模拟 T→T+1 间 + 份额被外部赎回用掉)→ Barrier(2) gate 钉在 `apply_convert` 事务门口, + 强制两单**都完成 ④可用复核/⑤选批 之后**才同时进事务 —— 这正是 + 「两单都读到余量、事务内短兵相接」的窄窗口。 + + ⚠️ **防御纵深(突变验证实测,T-15 留痕)**:确认段的资金安全由三道 + 防线共同保证 —— ④ 可用量复核(`partial_qty`)、⑤ `plan_lots` 余量 + 校验、事务内扣减哨兵(`remain_qty >= :q`)。**单独拆任何一道,后到单 + 都会被其余两道收敛成 `rejected`(用例仍绿)**;只有同时拆掉 ④+哨兵, + 后到单才会双扣(本用例经 gate 构造后对「哨兵缺失」敏感,见突变记录 + 于开发计划 T-15 执行记录)。50 并发超授场景(S2)由哨兵单独守护。 + + 失效形态(哨兵缺失 + 窄窗口):两单各扣 1 → 扣减 2 > 可用 1 → 超卖被抓; + 若 rejected 分支误产生零额确认 → 流水数 4(应为 2)被抓。 + """ + core = conc_env["core"] + pool = _seed(core, ["2"]) + a = _accept(conc_env, "1") + b = _accept(conc_env, "1") + assert a.get("accepted") and b.get("accepted"), "两单受理都应合法(占用 2 = 池)" + with core.begin() as conn: + conn.execute( + text("UPDATE core_share_lot SET remain_qty = 1 WHERE customer_id = :c"), + {"c": CUSTOMER}, + ) + + # gate:两单各自完成 ④⑤ 读之后、进确认事务之前对齐(Barrier 死等对齐) + gate = threading.Barrier(2, timeout=10) + orig_apply = ConvertCoreRepository.apply_convert + + def _gated_apply(self, req): + gate.wait() + return orig_apply(self, req) + + monkeypatch.setattr(ConvertCoreRepository, "apply_convert", _gated_apply) + + results = _concurrently( + lambda i: _confirm_with_backoff(conc_env, [a, b][i]["convert_group_id"]), 2 + ) + statuses = sorted("confirmed" if o.ok else str(o.error) for o in results) + print( + f"\n[T-15 争批] 结果 {statuses} · 扣减 {_deducted_total(core)} / 种子池 {pool} · " + f"受理单分布 {_request_statuses(core)}" + ) + # ① 恰一单 confirmed(先到先得),另一单 rejected(份额被抢空) + confirmed = [o for o in results if o.ok] + rejected = [o for o in results if not o.ok and o.error == "rejected"] + assert len(confirmed) == 1, f"恰 1 单成交,实际 {statuses}" + assert len(rejected) == 1, f"另一单应 rejected(actual=0),实际 {statuses}" + # ② 硬不变量:扣减恰 1(缩减后的可用量,先到者独得;种子池 2 被外部缩减 1) + assert _deducted_total(core) == Decimal("1") != pool, "扣减应恰为缩减后的 1 份" + # ③ rejected 单零流水:全组恰 1 组、2 条流水(赢家 redeem+subscribe) + counts = _group_counts(core) + assert len(counts) == 1 and sum(counts.values()) == 2, f"流水应恰 1 组 2 条:{counts}" + # ④ rejected 单受理单落 rejected(不滞留 accepted 占用) + assert _request_statuses(core).get("rejected", 0) == 1 + + # ── S3. 多客户并发确认:互不阻塞,但会出 1213 死锁(双层重试收敛)─────── @requires_stress def test_concurrent_different_customers_need_deadlock_retry(conc_env): diff --git a/tests/test_convert_confirm.py b/tests/test_convert_confirm.py index 672efef..5763da1 100644 --- a/tests/test_convert_confirm.py +++ b/tests/test_convert_confirm.py @@ -751,23 +751,38 @@ def test_real_engine_on_confirm_dedupes_daily_total(sqlite_engine): assert len(_trades(sqlite_engine, gid2)) == 2 -# ── 9. 资金流归属(T-7 明确不写 core_cash_flow,归 T-16)───────────── -def test_confirm_writes_no_cash_flow(sqlite_engine): - """确认事务**不写** `core_cash_flow` —— 资金流唯一归属 T-16(反双重归属)。 +# ── 9. 资金流中转(T-16 / R-11 / D29:确认事务写 2 条 core_cash_flow)── +def test_confirm_writes_exactly_two_cash_flows(sqlite_engine): + """确认事务写**恰 2 条** `core_cash_flow`(验收 2/24 资金流侧): - 本断言靠「表不存在」实现:sqlite 库若真被写该表,确认会直接报错而非静默通过。 - 表建立(T-16)之后此用例需改为「0 行」断言 —— 已在 T-16 的 DoD 中登记。 + - 转出端 `out`/`redeem`,金额 = 费前转出金额(与 redeem 流水 `amount` 同源); + - 转入端 `in`/`subscribe`,金额 = 净转入金额(与 subscribe 流水同源); + - `remark` 带 `convert_group_id`(对账锚点);`occurred_at` = 确认时刻。 + (T-7~T-15 期间确认段不写该表;T-16 起本方法 = 全仓唯一写入点,反转原 + 「表不存在」断言。) """ _seed(sqlite_engine) - tables = { - r["name"] - for r in _rows(sqlite_engine, "SELECT name FROM sqlite_master WHERE type='table'") - } - assert "core_cash_flow" not in tables, ( - "core_cash_flow 已建表 → 本断言须同步改为「确认段 0 行」(T-16 落地时)" - ) gid = _accept(sqlite_engine)["convert_group_id"] - assert _confirm(sqlite_engine, gid)["status"] == "confirmed" + res = _confirm(sqlite_engine, gid) + assert res["status"] == "confirmed" + + flows = _rows( + sqlite_engine, + "SELECT flow_type, flow_subtype, amount, remark, occurred_at FROM core_cash_flow", + ) + assert len(flows) == 2, f"确认后应恰 2 条资金流,实际 {len(flows)}" + by_sub = {f["flow_subtype"]: f for f in flows} + assert set(by_sub) == {"redeem", "subscribe"} + assert by_sub["redeem"]["flow_type"] == "out" + assert Decimal(str(by_sub["redeem"]["amount"])) == Decimal("68020.00") + assert by_sub["subscribe"]["flow_type"] == "in" + assert Decimal(str(by_sub["subscribe"]["amount"])) == Decimal("67074.46") + assert by_sub["redeem"]["remark"] == f"convert:{gid}" + assert by_sub["subscribe"]["remark"] == f"convert:{gid}" + + # 幂等:重复确认(skipped)不产生第 3 条 + _confirm(sqlite_engine, gid) + assert len(_rows(sqlite_engine, "SELECT 1 FROM core_cash_flow")) == 2 # ── 10. 撤单(PRD §7.0.3 / R-9)──────────────────────────────────── @@ -825,6 +840,34 @@ def test_cancel_confirmed_request_rejected(sqlite_engine): cancel_convert(gid, now=datetime(2026, 9, 4, 14, 0), **_services(sqlite_engine)) +# ── T-15(D28 · 验收 30):撤单与确认竞态 ─────────────────────────── +def test_confirm_after_cancel_conflicts(sqlite_engine): + """撤单与确认竞态(确认侧后到):撤单置 `cancelled` 后 confirm_one + **幂等跳过**(`skipped` + `current_status='cancelled'`),零流水、 + 占用保持已释放态 —— 批处理单笔容错语义(确认段不因已撤单中断整批)。 + + 时序口径(T-15 留痕):「撤单先落库、确认后到」= 状态闸门①的 skipped + 分支;「确认读到 accepted 后、事务内置位前状态被并发改掉」才是 + `ConfirmConflict` 回滚路径(`_confirm_request` 哨兵,见 + `test_confirm_conflict_rolls_back_whole_transaction`)。反向 + (确认后撤单)见 `test_cancel_confirmed_request_rejected`。 + """ + _seed(sqlite_engine) + gid = _accept(sqlite_engine)["convert_group_id"] + cancel_convert(gid, now=datetime(2026, 9, 4, 14, 0), **_services(sqlite_engine)) + + res = _confirm(sqlite_engine, gid) + + assert res["status"] == "skipped" + assert res["current_status"] == "cancelled" + # 单保持 cancelled(确认未夺走终态)+ 零流水 + 占用保持释放 + assert _req_row(sqlite_engine, gid)["status"] == "cancelled" + assert _trades(sqlite_engine, gid) == [] + assert CoreReadOnlyRepository(engine=sqlite_engine).sum_inflight_qty( + CUST, PROD_OUT + ) == Decimal("0") + + def test_cancel_unknown_request_raises_not_found(sqlite_engine): _seed(sqlite_engine) with pytest.raises(ConvertRequestNotFound): diff --git a/tests/test_convert_core.py b/tests/test_convert_core.py index af5ccd4..534cd8b 100644 --- a/tests/test_convert_core.py +++ b/tests/test_convert_core.py @@ -324,9 +324,10 @@ def test_apply_convert_raises_lot_conflict_and_leaves_nothing(sqlite_engine): ConvertCoreRepository(engine=sqlite_engine).apply_convert(req) assert exc.value.status_code == 409 - # 冲突即回滚:流水与明细都是 0 行 + # 冲突即回滚:流水、明细、资金流都是 0 行 assert _rows(sqlite_engine, "SELECT * FROM core_trade") == [] assert _rows(sqlite_engine, "SELECT * FROM core_convert_lot_detail") == [] + assert _rows(sqlite_engine, "SELECT * FROM core_cash_flow") == [] # ── 3. 回滚路径(同事务的硬证据)────────────────────────────────────── @@ -350,6 +351,8 @@ def test_apply_convert_rolls_back_everything_when_detail_insert_fails( # 全部无残留 = 阶段一确实在同一个事务里(架构 §5.1) assert _rows(sqlite_engine, "SELECT * FROM core_trade") == [] assert _rows(sqlite_engine, "SELECT * FROM core_convert_lot_detail") == [] + # T-16(R-11):cash_flow 与流水同事务 → 注入故障点之后的回滚同样零残留 + assert _rows(sqlite_engine, "SELECT * FROM core_cash_flow") == [] assert _dec( _rows( sqlite_engine, diff --git a/tests/test_convert_integration.py b/tests/test_convert_integration.py index 7c32248..a598082 100644 --- a/tests/test_convert_integration.py +++ b/tests/test_convert_integration.py @@ -275,6 +275,9 @@ def _cleanup(core, agent) -> None: core_sqls = [ "DELETE FROM core_convert_request WHERE customer_id = :c", f"DELETE FROM core_convert_lot_detail WHERE convert_group_id LIKE '{_GROUP_LIKE}'", + # T-16 起确认事务写 core_cash_flow(remark = 'convert:{gid}'), + # fk_cf_customer 挡在 core_customer 之前,必须先清(同 T-9 的 FK 坑) + f"DELETE FROM core_cash_flow WHERE remark LIKE 'convert:{_GROUP_LIKE}'", "DELETE FROM core_trade WHERE customer_id = :c", "DELETE FROM core_share_lot WHERE customer_id = :c", "DELETE FROM core_holding WHERE customer_id = :c",