基金转换 T-12:补偿脚本(详情 + 预警)

阶段二失败(或阶段 1.5 引擎失败)后,仅凭 Core 侧数据把「详情 + 预警」两件事补回来
(PRD §7.1 / 架构 §5.4)。

落地(改 3 + 新增 2 脚本 + 测试 2 文件)
- convert_service:新增公开 compensate_convert(group_id, ...) —— 补偿的服务端单点入口
  · 锁键 convert:rerun:{gid},与 convert_fund 幂等重试路径同一个键
  · 详情侧:非 completed 才补写,复用 _finalize_from_core(不另写第二份阶段二)
  · 预警侧:幂等锚点 = 转出端 out_trade_id,复用 find_alerts_by_trade;
    命中即 skipped,否则跑 process_convert_event
- risk_repository:has_engine_error_audit 加 decision 参数(默认值不变)
  · convert 线阶段 1.5 失败审计用 engine_error,普通交易用 risk_engine_error,不是同一个码
- rebuild_alerts.py:新增 --convert-group(与 location 参数 trade_ids 互斥),薄封装
- 新增 scripts/agent/cleanup_pending_convert.py(架构 §2 与开发计划 §9 指定路径):
  超 convert_compensate_sla_hours 的 pending 占位 → status='expired'(标记不硬删,S2)
- 新增 scripts/dev/verify_convert_compensate.py:真库验证脚本(MySQL 8.0.46)
- 测试 +12:test_convert_service +4(补写 / 幂等 / missing / locked)、
  test_demo_scripts +8(--convert-group 分派与接线 + cleanup 脚本)

state 四态与 CLI 退出码
- rebuilt(0) / skipped(0 幂等) / missing(1 零写入) / locked(3)
- 退出码 2 保留给 argparse 用法错误,故 locked 取 3

真库专属证据(sqlite 单测给不了的,本任务核心增量)
- status='expired' 在 MySQL ENUM 上被接受(sqlite 该列是 VARCHAR,写什么都收)
- created_at < cutoff 在 DATETIME(3) 上的时间边界正确(超时进候选 / 未超时不进 / 复跑幂等)
- input_summary 是真 JSON 列,而 has_engine_error_audit 用 LIKE 判定:脚本先断言
  information_schema 的 DATA_TYPE='json' 再验命中,并反向断言决策码不匹配则不命中

顺带收口(用户指示)
- core_ro.concentration_profile 补 h.qty > 0,与 list_holdings 真正同口径
  · ratio 不变(归零行市值为 0),但 rows 不再多出已清仓产品、不虚占截断判定位
  · 新用例含跨出口一致性断言;突变验证:去掉 qty > 0 → 精准 1 条红

验证
- pytest -q → 731 passed / 3 skipped(基线 719 加 12,零回归)
- 突变验证 3 组精准命中:去掉幂等锚点(1 红)/ 去掉 status 过滤(2 红)/ 补偿无视锁(1 红)
- 真库 verify_convert_compensate.py 34/34,隔离数据零残留
- 全套 7 个真库脚本复跑零回归:seed 全 PASS / apply 24 / service 35 / engine 31 / lots 20 / tools 14 / compensate 34
This commit is contained in:
2026-09-10 18:59:42 +08:00
parent a6ac39edd6
commit 22f2a41192
13 changed files with 1296 additions and 32 deletions
@@ -1234,12 +1234,16 @@ sqlite 无 gap lock,故该分支由 `tests/test_convert_core.py` 用注入点
**回归复跑**(铁律 2,接线变化必跑):T-8 `31/31`(`amount_view` 改名影响面)·
T-7 `35/35` · T-10 `20/20`,**均零回归**
*未顺手改(不在本任务范围,已上报)*
*收口(原「未顺手改」,用户 2026-09-10 指示一并改掉)*
- **`concentration_profile` 未过滤 `qty = 0`**:它走**独立 SQL**(非 `list_holdings`),当前不过滤归零行。
归零行 `market_value = 0`,对 R4+R5 占比**无实际影响**,但 `core_ro.py` 中
「与 list_holdings 同源同口径」的注释与实际存在**措辞落差**。
是否一并收口属新范围,留待用户决定。
- **`concentration_profile` 已补 `qty > 0` 过滤**:它走**独立 SQL**(非 `list_holdings`),
原实现不过滤归零行,与 `core_ro` 中「与 list_holdings 同源同口径」的注释存在措辞落差。
本次收口后两个出口(持仓画像 / 持仓列表)对同一客户给出**一致的持仓集合**。
数值影响:`ratio` 不变(归零行市值为 0,分子分母同增 0),但 `rows` 明细不再多出已清仓产品、
不再虚占 `holdings_truncated` 的判定位。
- 新增用例 `tests/test_concentration_c4.py::test_concentration_profile_filters_zero_qty`,
内含**跨出口一致性断言**(`list_holdings` 与 `concentration_profile["rows"]` 同集合);
- 突变验证:去掉 `h.qty > 0` → **精准 1 条**红(该新用例),已恢复。
---
@@ -1359,15 +1363,81 @@ sqlite 无 gap lock,故该分支由 `tests/test_convert_core.py` 用注入点
**幂等锚点(Q4)**:以**转出端 `out_trade_id`** 为锚点 —— 复用 `rebuild_alerts.py:54-66` 的 `find_alerts_by_trade(trade_id)`,命中即 `skipped`;**一次转换有两条流水,只认转出端**,避免重复出单。该脚本**本就幂等**(其 `:8-10` docstring),**不需要再加 `uk_idem`**。
**DoD**
- [ ] 阶段二失败 → `decision='convert_detail_write_failed'` 审计存在(验收 17 前置)
- [ ] `rebuild_alerts --convert-group` 能仅凭 Core 侧数据补出**完整详情** → `completed`(验收 17)
- [ ] 重复执行补偿 → `skipped`,不产生第二张预警单(幂等)
- [ ] `cleanup_pending_convert.py` 把超 24h `pending` 置 `expired`,**行仍在**(不硬删)
- [x] 阶段二失败 → `decision='convert_detail_write_failed'` 审计存在(验收 17 前置)
- [x] `rebuild_alerts --convert-group` 能仅凭 Core 侧数据补出**完整详情** → `completed`(验收 17)
- [x] 重复执行补偿 → `skipped`,不产生第二张预警单(幂等)
- [x] `cleanup_pending_convert.py` 把超 24h `pending` 置 `expired`,**行仍在**(不硬删)
**依赖**:T-4 / T-7
---
### 9.1 执行记录(2026-09-10 · 已落地)
**改动文件**
| 文件 | 动作 |
| --- | --- |
| `app/service/convert/convert_service.py` | 【改】新增公开 **`compensate_convert(group_id, ...)`** —— 补偿的**服务端单点入口** |
| `app/repository/risk_repository.py` | 【改】`has_engine_error_audit(trade_id, decision=...)` 加 decision 参数(默认值不变,既有调用零改动) |
| `scripts/demo/rebuild_alerts.py` | 【改】新增 `--convert-group CNV-xxx`(与位置参数 trade_ids 互斥) |
| `scripts/agent/cleanup_pending_convert.py` | 【新增】超 SLA 的 `pending` → `expired`(**标记不硬删**) |
| `scripts/dev/verify_convert_compensate.py` | 【新增】真 MySQL 验证脚本 |
| `tests/test_convert_service.py` | 【改】+4 条补偿用例 |
| `tests/test_demo_scripts.py` | 【改】+8 条(`--convert-group` 分派/接线 + cleanup 脚本) |
**执行期裁定 3 条(留痕)**
1. **补偿逻辑收敛到 service 单点**(自检第 13 问)。`rebuild_alerts.py --convert-group` 只是
**薄封装**,不复制任何一条逻辑;脚本单测以「注入假实现验证参数转交」锁住这条约束。
2. **锁与幂等锚点都沿用既有口径,不新造**:
- 锁键 `convert:rerun:{gid}` —— 与 `convert_fund` 幂等重试路径**同一个键**,
故「人工补跑」与「客户端带同键重试」不会并发重复出单(架构 §5.4「为何不自动化」原话);
- 幂等锚点 = **转出端 `out_trade_id`**,复用 `find_alerts_by_trade`(评审 Q4 原话)。
3. **`has_engine_error_audit` 加 `decision` 参数而非另写一份查询**:convert 线的阶段 1.5 失败
审计决策码是 **`engine_error`**(`convert_service` ⑦ 步),与普通交易的 `risk_engine_error`
**不是同一个码**;默认值保持 `risk_engine_error`,B9a 既有路径零改动。
**`state` 四态(CLI 退出码据此判定)**
| state | 含义 | CLI 退出码 |
| --- | --- | --- |
| `rebuilt` | 至少补写了一项(`detail` / `engine` 各自给明细) | 0 |
| `skipped` | 详情已 `completed` 且预警已存在 —— **幂等语义** | 0 |
| `missing` | Core 侧不足两条流水(不是有效转换组,**零写入**) | 1 |
| `locked` | 未抢到 `convert:rerun:{gid}`(有并发重试/实例在跑) | 3 |
> 退出码 **2 保留给 argparse 的参数用法错误**,故 `locked` 取 3,避免与用法错误混淆。
**验证**
- `pytest -q` → **731 passed / 3 skipped**(基线 719 加 12,零回归)
- **突变验证 3 组(防假绿)**:
① 去掉预警侧幂等锚点(恒跑引擎)→ **精准 1 条**红(`test_compensate_is_idempotent`);
② 去掉 `list_expired_candidates` 的 `status` 过滤 → **精准 2 条**红(只清理超时 pending / 复跑幂等);
③ 让补偿无视抢锁结果 → **精准 1 条**红(`test_compensate_returns_locked_...`)。
三处均已恢复,`grep "MUTATION\|1 = 1"` **无残留**
- **真库**:新增 `verify_convert_compensate.py` **34/34**(零残留)
- **回归复跑(铁律 2)**:`convert_service` / `risk_repository` / `core_ro` 三处均被本次改动触及 →
全套 7 个真库脚本原样复跑:seed `全部 PASS` · apply `24/24` · service `35/35` · engine `31/31` ·
lots `20/20` · tools `14/14` · compensate `34/34`,**均零回归**
**真库专属证据(sqlite 给不了的)**
- `status='expired'` 在 MySQL **ENUM** 上被接受(sqlite 是 `VARCHAR(16)`,写什么都收);
- `created_at < cutoff` 在 **DATETIME(3)** 上的时间边界正确(超时进候选、未超时不进、复跑幂等);
- `input_summary LIKE '%gid%'` 在**真 JSON 列**上生效 —— 脚本先断言 `DATA_TYPE='json'` 再验命中,
并反向断言「决策码不匹配则不命中」,证明过滤真的按 `decision` 走。
**新增目录 `scripts/agent/`**
架构 §2 与本文 §9 均指定 `scripts/agent/cleanup_pending_convert.py`,**按文档落地**。
(`scripts/` 下原有 `core` / `cron` / `demo` / `dev` / `kb` / `sync`,其中 `cron` 已承载
`agent_behavior_scan` / `escalation_scan` 两个巡检脚本 —— 若后续要统一巡检脚本的家,
可评估把三者合并到 `cron/`;本次不擅自偏离已评审的架构文档。)
---
## 10. 第 7 批 · 回归与实测(T-13)
### 10.1 全量回归