Bladeren bron

feat(cockpit): 预警设置面板消费预警自身生命周期契约 (#42)

后端 b047a60d 起两族口径分叉(维护者 2026-10-14 裁定):预警族只给预警自身生命周期
(WarningWorkflow 六态/等级/行业/片区分布 + 已解除率),工单口径六字段恒 null;
报警族保留工单口径并并存 alarm_status 生命周期分布。

前端适配:toOverviewWarningSettings null 保真(不补假零条、不数 0),
familyBlocks 拆为预警/报警两族各自的块(预警族无工单口径块),
态势总览处置率 KPI 改为预警已解除率(hint 写明分子分母);
mock 载荷与新契约同构(形状对齐 b047a60d,含 UNKNOWN/UNASSIGNED 显式桶)。

守卫:新增 t42-warning-settings-contract.mjs(真实契约形状 + 空态不适用);
t20 守卫随契约更新(B3c 移到报警族、B3e/B3f/C4d 新增、M4 锚点重定位、M14 新增)。

工作树混有票 37-40 的并行在制品,本提交经 git plumbing 只含本票 hunks,
孤儿批次原样留在工作树(字节未动)。
codex 6 dagen geleden
bovenliggende
commit
0cd16ac931

+ 79 - 0
.scratch/cockpit-real-data-wayfinding/handoff/baselines/warning-settings-lifecycle.md

@@ -0,0 +1,79 @@
+# 基线卡片 06:预警设置端点预警自身生命周期统计
+
+> 按决策票 [05 真实联调基线与收口证据门槛](../../issues/05-integration-baseline-and-evidence.md) 的固定骨架产出,对应执行票 [41 预警设置端点改为预警自身生命周期统计](../../issues/41-warning-settings-endpoint-rework.md)。**状态判定:③ 真实数据联调完成**(2026-10-14 第二实例实测通过,见 §4;共享实例部署窗口与前端走查不在本票范围)。
+
+## 一、基线
+
+| 项 | 值 |
+| --- | --- |
+| 记录时间 | 2026-10-14(本地时钟) |
+| 记录角色 | 后端执行代理(实施 + 编译 + 契约测试 + 第二实例复验 + 推送) |
+| 联调环境 baseUrl | `http://192.168.110.96:8301`(共享实例)+ 复验用第二实例 `http://127.0.0.1:8399`(本票构建,已停机) |
+| 后端仓库 / 分支 / 提交 | `http://192.168.110.30:3000/shaoy/pipe-ner-server` / `master` / **`b047a60d`**(本票 `fix(cockpit): 预警设置端点改为预警自身生命周期统计 (#41)`,变基含上游 `05c23774`) |
+| 复验方式 | 不动共享实例:`t41` 本地 profile(同库同 Redis,端口 8399、nacos 注册关闭、推送任务 delay 1h)起第二实例实测;对照共享实例区分上游修复与本票新契约 |
+| 后端推送状态 | **已推送**;远端 `master=b047a60d`,`05c23774..b047a60d` |
+| 前端仓库 | 本票**不涉及**前端(消费侧另立票,见票面「前端后续」) |
+| 访问控制 | 驾驶舱只读 REST 免鉴权(ADR 0005);两实例实测无凭据 HTTP 200 |
+| 只读数据源 | 联调库只读快照;`readOnly: true` |
+
+## 二、收口条件五行
+
+| # | 条件 | 状态 | 证据 | 未完成项 |
+| --- | --- | --- | --- | --- |
+| ① | 后端提交 / 编译 / 契约测试 | **已完成** | 编译通过;针对性 24/24;全量 `Cockpit*Test,BusinessCockpit*Test` **498/498**(变基后复跑) | 无 |
+| ② | 新字段形状(状态/等级/趋势/分布) | **已完成** | §4.2 实测:六态/1–4/四行业/三片区+未分区全铺,和恒等于 total | 无 |
+| ③ | 趋势窗口跟随 `range` | **已完成** | §4.2 实测:`LAST_7_DAYS` 只留窗口内日桶(窗外桶被裁) | 无 |
+| ④ | 空态 | **已完成** | 契约测试 `emptyFamilyUsesExplicitEmptyState`:`state=EMPTY`、处置率 `NO_WARNINGS`、分布仍全铺计数 0 | 无 |
+| ⑤ | 真实环境复验「不再 500」 | **已完成(第二实例)** | §4.1 实测 `code=200`;共享实例 8301 的 500 已由上游 `94bb7e4d`(删 `dept_id`)先行消除,与本票改动叠加后新契约生效 | 共享实例滚动重启窗口归票 17;前端走查归前端消费票 |
+
+## 三、契约变更摘要
+
+| 维度 | 预警族(`warning`) | 报警族(`alarm`) |
+| --- | --- | --- |
+| 状态分布 | `statusDistribution`:`WarningWorkflow` 六态(DRAFT/PENDING/RECEIVED/PROCESSING/HANDLED/CLOSED)全铺 | `statusDistribution`:`alarm_status` 归一 `UNHANDLED/HANDLED`(新增) |
+| 工单口径字段 | **`null`**(`handled/pending/cancelledCount/unlinkedEventCount/invalidWorkOrderStateCount/workOrderStates`) | **原样保留**(含 `workOrderStates` 0–7、未回联/脏码/已取消分列) |
+| `handlingEfficiency` | 已解除 ÷ 预警总数(预警自身口径;分母 0 → `NO_WARNINGS`) | 状态 5 ÷ 状态 0–6 工单数(工单口径,决策票 06) |
+| 等级分布 | `levelDistribution` 1–4 全铺(`warning_level` 字符串解析) | `levelDistribution` 1–4 全铺(`alarm_level`) |
+| 行业 / 片区 | 沿 `equipment_base` + `equipment_type` 树上溯;未知/未分区显式入桶 | 同左 |
+| 趋势 | 按发布时间分桶;`handled`=桶内已解除 | 按更新时间分桶;`handled`=关联工单已结案 |
+
+分布读数取整个快照(不随 `range` 收缩),故 `sum(分布桶) == total` 可被审计复算;只有趋势桶跟随窗口。
+
+## 四、实测记录
+
+### 4.1 端点 200 复验(第二实例,真实联调库)
+
+```powershell
+$r = Invoke-WebRequest 'http://127.0.0.1:8399/api/cockpit/v1/overview/warning-settings?mode=WARNING&range=LAST_7_DAYS' -UseBasicParsing
+# HTTP 200, code=200(改造前同库同端点为 HTTP 200 + body code=500:字段 "dept_id" 不存在)
+```
+
+### 4.2 预警族自洽性(真实数据复算)
+
+| 分布 | 实测计数 | 和 |
+| --- | --- | --- |
+| 状态 | DRAFT 2 + PENDING 4 + RECEIVED 0 + PROCESSING 4 + HANDLED 2 + CLOSED 19 | **31 = total** ✓ |
+| 等级 | 1级 13 + 2级 5 + 3级 1 + 4级 12 | **31** ✓ |
+| 行业 | 供水 2 + 排水 24 + 窨井 1 + UNKNOWN 4(燃气 0) | **31** ✓ |
+| 片区 | 城南 24 + 城北 1 + 太常 2 + 未分区 4 | **31** ✓ |
+| 处置率 | 19/31 = `0.6129`(已解除÷总数) | ✓ |
+| 趋势 | 2026-10-09/10/14 三桶(`LAST_7_DAYS` 窗内) | ✓ |
+
+报警族:`total=66`,`handled=64`,`statusDistribution` 2+64=66 ✓,`workOrderStates` 0–7 齐全,处置率 `1/5=0.2000`(工单口径)。
+
+### 4.3 对照证据(共享实例 vs 第二实例,同时刻)
+
+| 实例 | 构建 | HTTP/body code | 契约 |
+| --- | --- | --- | --- |
+| `8301` 共享 | 上游 `05c23774`(已被他人重启) | 200/200 | **旧契约**:`warning.handled=0`、`workOrderStates` 在、无 `statusDistribution`、处置率 0.2000 |
+| `8399` 第二 | 本票 `b047a60d` | 200/200 | **新契约**:见 §4.2 |
+
+> 判读:8301 的 500→200 恢复来自上游 `94bb7e4d` 删 `dept_id`(诊断期实测 `dispatch_user` → 复测 `dept_id`,两形态都是「实体声明了库中已删列」);本票在其上把预警族口径换为自身生命周期,8399 实测生效。
+
+## 五、已知边界与后续
+
+- 共享实例重启加载 `b047a60d` 前,8301 仍回旧契约(预警族工单字段为旧口径);滚动部署归票 17。
+- 前端消费侧(`toOverviewWarningSettings` / `alertSettingsPanel` / `contractMocks.js`)尚未读新分布块,
+  预警半边会显示全 0 —— 按票面验收另立前端票,不阻塞本票收口。
+- 预警族 `handlingEfficiency` 语义已从「工单处置率」改为「已解除率」;消费方必须连 `state/reason` 一起读,
+  分母 0 显示「不适用」而非 0%。

+ 154 - 0
.scratch/cockpit-real-data-wayfinding/issues/41-warning-settings-endpoint-rework.md

@@ -0,0 +1,154 @@
+# 41: 后端执行票 —— 预警设置端点改为预警自身生命周期统计
+
+Type: task
+Status: resolved
+Label: ready-for-agent
+Blocked by: None
+Category: backend-execution
+Assignee: codex
+
+## 来源
+
+维护者 2026-10-13 反馈第 1 条,并明确裁定:**预警不走工单流程,走自己单独的流程**;`/overview/warning-settings` 需要针对这条信息修改,**只展示预警自身生命周期的统计**(Q1=A:让后端改这个端点)。
+
+## 背景(实测)
+
+- 当前 `/api/cockpit/v1/overview/warning-settings` 返回的是**工单口径**字段:`workOrderStates`、`handled`、`pending`、`cancelledCount`、`handlingEfficiency`(处置率)等。
+- 该端点在本地联调环境实测 **HTTP 200 + body `code=500`**:`字段 "dispatch_user" 不存在`(`WorkOrderMapper` 仍映射 `app_user.work_order` 已不存在的列)——即该端点的工单依赖本身就是当前故障源。
+- 前端「总体概览 - 预警设置」面板因此显示「数据加载失败」(前端行为正确,如实反映后端故障)。
+
+## 范围
+
+1. **端点语义改为预警自身生命周期**:去掉工单口径字段(工单状态分布、处置率、已处理/待处理等),改为预警自身统计:
+   - 预警总数;
+   - 按**预警自身状态**分布;
+   - 按等级分布;
+   - 近 7 日趋势(保留既有 `range` 窗口契约);
+   - 按行业 / 片区分布。
+2. **与报警族的关系**:需明确该端点是否仍同时给预警与报警两族(现状是一次给两族、态势总览共用)。若不改这一结构,报警族也应改为**报警自身生命周期**口径,而非工单口径。
+3. **移除对工单表的依赖**:本端点不得再依赖 `work_order`(既消除 `dispatch_user` 故障,也符合「预警不走工单」)。
+4. **契约测试**:新字段形状、状态/等级分布计数自洽、趋势窗口跟随 `range`、空态。
+
+## 非目标
+
+- 不修改工单自身逻辑(工单仍由外部业务系统与既有流程负责)。
+- 不改驾驶舱只读边界。
+
+## 验收
+
+- 后端:编译 + 契约测试;本地联调环境复验该端点返回新契约且不再 500(登记到基线卡片)。
+- 前端消费侧改动(面板改为读新字段)在契约确定后另立前端票。
+
+## 诊断(2026-10-14,本会话实测)
+
+### 结论一:票面背景里的根因已经过期
+
+票面写「实测 HTTP 200 + body `code=500`:字段 `dispatch_user` 不存在」。本会话复测,**HTTP 200 + body `code=500` 仍然成立,但报错列已变成 `dept_id`**:
+
+```
+### Error querying database. Cause: com.kingbase8.util.KSQLException:
+ERROR: 字段 "dept_id" 不存在
+### SQL: SELECT order_id,order_no,device_id,fault_time,order_level,order_desc,
+       dept_id,receive_user,order_status,... FROM app_user.work_order
+```
+
+`dispatch_user` 在本地 HEAD(`a96091ce`)里**已经不存在**——上游 `8fb5cb52`(工单新增创建人)已把 `dispatchUser`/`dispatchTime` 从 `WorkOrder` 删除。票面记的是**更早一次**的故障形态。
+
+### 结论二:真实根因是「运行构建落后于库结构」,不是本端点的设计缺陷
+
+- 本地 HEAD `a96091ce` 的 `WorkOrder.java` 与 `WorkOrderMapper.xml` **仍声明并选择 `dept_id`**(`13d5bd1e` 引入)。
+- 上游 `origin/master`(`5a821872`)的 `94bb7e4d「移动端-工单管理」` **已删掉 `dept_id`**:实体去字段,`WorkOrderMapper.xml` 的 `Base_Column_List` 与 resultMap 同步去掉。
+- 联调实例(PID 17972,2026-10-14 08:48:40 启动)实测该列不存在 → **库已不再有 `dept_id`,而实例加载的构建仍去 SELECT 它**。
+
+这与 [票 19](19-19-early-warning-warning-name.md) 形态完全一致:**构建/部署基线错位,不是缺列**。同一错列还打穿了 `events/handling-efficiency` 与 `/work-orders/{code}/timeline`(本会话实测两者同样 `code=500` + `dept_id`),故障面比票面记的「单点」更大。
+
+### 结论三:这决定了本票的实施基点
+
+维护者本轮裁定(见下)要求**报警族保留工单口径**(报警靠工单闭环),而工单读取正是当前故障点,所以:
+
+> 本票实施必须建立在**已含 `94bb7e4d` 的基点**上,否则报警族仍会 `dept_id` 500,验收项「不再 500」无法达成。
+
+本地 `HEAD=a96091ce` 不含该提交(本地领先 merge-base `989a707e` 1 个未推送提交=票 32 几何改动;`origin/master=5a821872` 领先本地 4 个提交)。合并 `origin/master` 与共享工作树里并行会话的未提交几何改动**无文本冲突**:上游 `989a707e..origin/master` 未触碰 `cockpit/geometry` 任何文件(`git diff --name-only` 核实)。
+
+### 维护者裁定(本会话 grilling,2026-10-14)
+
+- **Q1 契约形状**:**两族都留**。预警族改为**预警自身生命周期**(预警不走工单);**报警族保留工单口径**——「报警的生命周期与工单关联性很强,报警需要通过工单去进行处理解决」,同时报警自身生命周期也要保留。
+- **Q2 验证授权**:全授权 —— Maven 编译 + `Cockpit*Test` + 重启实例做真实复验。
+
+### 前端影响(另立票,本票不改)
+
+现前端 `toOverviewWarningSettings()` 读 `warning.workOrderStates` / `handled` / `pending` / `handlingEfficiency` 等工单口径字段。预警族字段改造后这些字段回 `null`,前端 `numberish(null)` 会**数成 0**(不是 `—`)——「预警设置」面板的预警半边会显示全 0 而非新分布块,直到前端消费票落地;报警半边保持可用(旧字段保留)。
+
+## 实施(2026-10-14)
+
+**基点**:从 `origin/master`(`5a821872`)拉**独立 worktree** `E:\pipe-ner-server-t41`(`git worktree add --detach`),
+不动共享工作树(其上有并行会话的票 32 几何未提交改动)。选这个基点的原因见「诊断 结论三」:
+本地 `HEAD` 仍声明 `dept_id`,而上游 `94bb7e4d` 已删除它;报警族按裁定保留工单口径,
+必须建立在已删除该列的基点上,否则「不再 500」无法达成。
+
+**改动(7 文件,+935/−279)**:
+
+| 文件 | 改动 |
+| --- | --- |
+| `CockpitOverviewWarningSettingsResponse` | `FamilyMetrics` 工单口径字段改**包装类型**(`Integer`/`Map`)以便显式 `null`;新增 `Bucket` 与四个分布字段 |
+| `CockpitOverviewWarningSettingsSnapshot` | `SourceEvent` 增自身生命周期 `status`/`level` 与维度 `districtCode`/`industryCode`(旧构造器保留为委托重载) |
+| `CockpitOverviewWarningSettingsService` | 拆 `warningFamily`(纯自身生命周期,工单字段全 `null`)与 `alarmFamily`(保留工单口径 + 并存自身生命周期);新增状态/等级/行业/片区分布构造 |
+| `BusinessCockpitOverviewWarningSettingsAdapter` | 预警**不再桥接工单**(`warningBuckets` 的 handled 改判自身 `CLOSED`);两族沿 `equipment_base` + `equipment_type` 树解析片区/行业带入快照 |
+| 三个测试文件 | 契约测试重写为两族口径分离 |
+
+**口径落点**:
+- 预警族:`total`、`statusDistribution`(`WarningWorkflow` 六态全铺)、`levelDistribution`(1–4 全铺)、
+  `industryDistribution`(四正式行业 + 未知)、`districtDistribution`(0/1/2 + **恒有**未分区)、
+  `handlingEfficiency`=已解除÷总数、`trend`(按发布时间分桶,handled=该桶已解除)。
+  工单口径六字段一律 `null`。
+- 报警族:工单口径**原样保留**(含 `workOrderStates` 0–7、工单口径处置率、未回联/脏码/已取消分列),
+  **并存** `statusDistribution`(`alarm_status` 0/1)、等级/行业/片区分布。
+- 分布取整个快照(不随 `range` 收缩)→ `sum(分布桶) == total` 恒成立;只有趋势桶跟随窗口。
+- 「未知」类(脏状态/脏等级/推不出行业)与「未分区」都**显式入桶**,故计数不静默丢。
+
+**验证**:
+- 编译:`mvn -o -pl pipe-network-service/zksy-admin -am compile` → 通过。
+- 针对性契约测试:`CockpitOverviewWarningSettings*Test` + `BusinessCockpitOverviewWarningSettings*Test` → **24/24**。
+- 全量:`-Dtest=Cockpit*Test,BusinessCockpit*Test` → **479/479**,`BUILD SUCCESS`。
+- 提交:`ae9c5731`(独立 worktree,7 文件)。
+
+## 真实复验(2026-10-14,验收达成)
+
+**方法**:不动共享实例(PID 17972,其他会话在用),从本票 jar 在 **8399 端口**起一个**第二实例**
+(`t41` 本地 profile + 关闭 nacos 远端配置导入与注册、推送任务 initial-delay 全推后),
+连**同一个**联调库与 Redis。实测 `GET /overview/warning-settings?mode=WARNING&range=LAST_7_DAYS`:
+
+- **HTTP 200 + `code=200`**(验收项「不再 500」达成)。
+- **预警族新契约实测**(真实库数据,全部自洽):
+  - `total=31`;工单口径六字段**全部 `null`**(`handled/pending/cancelledCount/unlinkedEventCount/invalidWorkOrderStateCount/workOrderStates`)。
+  - `statusDistribution`:`DRAFT 2 + PENDING 4 + RECEIVED 0 + PROCESSING 4 + HANDLED 2 + CLOSED 19 = 31` ✓(和=总数)。
+  - `levelDistribution`:`13+5+1+12 = 31` ✓;`industryDistribution`:`0+2+24+1+UNKNOWN 4 = 31` ✓;
+    `districtDistribution`:`城南 24 + 城北 1 + 太常 2 + 未分区 4 = 31` ✓。
+  - `handlingEfficiency = 19/31 = 0.6129`(已解除÷总数,预警自身口径)。
+  - `trend` 按 `LAST_7_DAYS` 裁桶(2026-10-09/10/14 三桶,窗外桶被裁掉)。
+- **报警族实测**(工单口径保留 + 自身生命周期并存):
+  - `handled=64`(`alarm_status=1`);`workOrderStates` 0–7 齐全;处置率 `1/5 = 0.2000`(工单口径);
+  - `statusDistribution`:`UNHANDLED 2 + HANDLED 64 = 66` ✓;等级/行业/片区分布和均=66 ✓。
+- **对照证据**(同一时刻):共享实例 8301(彼时已被他人重启到上游 `05c23774`,`dept_id` 故障已随上游修复消失)
+  仍回**旧契约**(`warning.handled=0`、`workOrderStates` 在、无 `statusDistribution`、处置率 0.2000)——
+  证明 8301 的新 200 来自上游修复而非本票改动;本票的新契约在 8399 实例实测生效。
+
+**推送**:变基到 `05c23774` 后复跑全量 **498/498**(变基带来 19 个新测试),提交最终为 **`b047a60d`**,
+已推 `origin/master`(`05c23774..b047a60d`),远端 `master=b047a60d` 与本地一致。
+第二实例与临时 worktree `E:\pipe-ner-server-t41` 已停机并清理,共享工作树未触碰
+(票 32 几何改动仍原样留在工作树)。
+
+**边界说明**:实施期间上游基点从 `5a821872` 前移到 `05c23774`(并行会话推送的票 35 createdAt),已通过变基合入,无冲突。
+
+## 前端后续(另立票,不阻塞本票)
+
+预警族 `handled/pending/cancelledCount/workOrderStates` 等工单口径字段在真实环境回 `null` 后,
+前端 `toOverviewWarningSettings()` 会把 `null` 数成 0、`workOrderStates` 空铺 ——
+「预警设置」面板的预警半边会显示全 0 而非新分布块。**前端消费票**(改读
+`statusDistribution`/`levelDistribution`/`industryDistribution`/`districtDistribution` 与新处置率语义)
+需按票面验收第 2 条另立,与 mock 契约(`contractMocks.js` 的 `overviewWarningSettingsPayload`)同步改。
+
+## 关联
+
+- 相关:[11 工单状态与处置率读模型对齐](11-backend-work-order-rate.md)(工单口径本身的修订)、决策 06(异常统计与业务语义)。
+- 上位:本地票 25、Gogs #63。

+ 85 - 0
.scratch/cockpit-real-data-wayfinding/issues/42-42.md

@@ -0,0 +1,85 @@
+<!-- DSH-IDEMPOTENCY-KEY: t42-frontend-warning-settings-lifecycle -->
+<!-- parentKey: 00 -->
+# 42: 前端执行票 —— 预警设置面板消费预警自身生命周期契约
+
+Type: task
+Status: claimed
+Label: ready-for-agent
+Blocked by: 41
+Category: frontend-execution
+Assignee: codex
+
+## 来源
+
+执行票 [41 预警设置端点改为预警自身生命周期统计](41-warning-settings-endpoint-rework.md) 已收口(提交 `b047a60d` 已推 origin/master),新契约已确定并实测;证据见 [基线卡片 06 预警设置生命周期](../handoff/baselines/warning-settings-lifecycle.md)。本票承接票 41 验收第 2 条:"前端消费侧改动(面板改为读新字段)在契约确定后另立前端票"。
+
+## 后端新契约(b047a60d 已实测)
+
+`GET /api/cockpit/v1/overview/warning-settings` 一次仍给 `warning` + `alarm` 两族,但语义已分叉:
+
+**预警族(预警自身生命周期,不走工单)**:
+
+- `total`:预警总数(语义不变)。
+- `statusDistribution`:`[{code,label,count}]` — `WarningWorkflow` 六态全铺(DRAFT 草稿 / PENDING 待办 / RECEIVED 已接收 / PROCESSING 处置中 / HANDLED 已处置 / CLOSED 已解除;含 0 桶,脏值进 UNKNOWN 桶)。
+- `levelDistribution`:`[{code:"1".."4",label,count}]` 1–4 全铺(`warning_level` 字符串解析,脏值 UNKNOWN)。
+- `industryDistribution`:GAS 燃气 / WATER_SUPPLY 供水 / DRAINAGE 排水 / MANHOLE 窨井 四正式桶 + UNKNOWN。
+- `districtDistribution`:0 城南 / 1 城北 / 2 太常 + **恒有** UNASSIGNED 未分区。
+- `handlingEfficiency`:**语义已变** = 已解除 ÷ 预警总数(numerator/denominator/value/state/reason;分母 0 → `state=EMPTY`、`reason=NO_WARNINGS`)。
+- `trend`:按发布时间分桶 `{bucket,total,handled,handlingEfficiency}`,`handled`=桶内已解除。
+- **工单口径六字段一律 `null`**:`handled` / `pending` / `cancelledCount` / `unlinkedEventCount` / `invalidWorkOrderStateCount` / `workOrderStates`。
+
+**报警族(保留工单口径 + 并存自身生命周期)**:
+
+- 工单口径字段原样保留:`handled/pending/cancelledCount/unlinkedEventCount/invalidWorkOrderStateCount/workOrderStates(0–7)/handlingEfficiency`(处置率仍是工单口径)。
+- 新增 `statusDistribution`:UNHANDLED 未处理 / HANDLED 已处理(`alarm_status`)。
+- 新增 `levelDistribution` / `industryDistribution` / `districtDistribution`(形状同预警族)。
+- `trend` 不变(工单口径 handled)。
+
+注意:`sum(分布桶) == total` 恒成立(分布取整个快照、不随 `range` 收缩),只有 `trend` 跟随窗口。
+
+## 现状消费点(会坏的地方)
+
+- `src/modules/cockpit/domain/overviewPanels.js`:`toOverviewWarningSettings()` 的 `family()` 读 `workOrderStates/handled/pending/cancelledCount/unlinkedEventCount/invalidWorkOrderStateCount/handlingEfficiency` —— 预警族这些字段现在是 `null`;`familyBlocks()` 渲染「已处理/待处理/已取消/未关联工单/处置率/工单状态分布」六项 + stacked 块,预警半边会全 0 或空。
+- `situationSummaryPanel()`:`当前预警/当前报警` 读 `total`(不受影响);`处置率` 读 `warning.efficiency` —— 语义已变为**已解除率**,必须连 `state/reason` 一起读,分母 0 显示「不适用」。
+- `src/modules/cockpit/mocks/contractMocks.js`:`overviewWarningSettingsPayload` 需与新契约同构。
+
+**关键风险**:`numberish(null)` 会把「不适用」数成 0 —— 空态/不适用必须显式判 `state`/`null` 后显示占位符,不得显示 0(沿用票 22「故障不伪装成正常空态」的同一纪律)。
+
+## 范围
+
+1. `toOverviewWarningSettings` 适配新契约:预警族读四个分布 + 已解除率;报警族读工单口径 + 自身生命周期分布;`null` 字段在领域层记「不适用」,不数 0。
+2. `familyBlocks`:预警半边改为状态六桶 + 等级/行业/片区分布 + 已解除率;报警半边保留工单口径块 + 并存 `alarm_status` 分布。图表形态沿用票 39/40 既有块构造(`panelBlocks.js`),不另起一套。
+3. `situationSummaryPanel` 处置率语义更新(已解除率),空态「不适用」。
+4. mock 同步:`contractMocks.js` 的 warning-settings 载荷改为新契约形状(真实数值参照基线卡片 §4.2)。
+5. 契约/verify 脚本与领域层回归更新。
+
+## 非目标
+
+- 后端改动(票 41 已完成并推送)。
+- 不新增端点、不改只读边界、不改其它面板。
+
+## 验收
+
+- 契约测试/verify 脚本通过;mock 与 real 同构(同一领域层函数可消费两者)。
+- 预警半边:状态六桶/等级/行业/片区分布 + 已解除率正常渲染;工单口径字段不再出现(或显式「不适用」)。
+- 报警半边:工单口径块保留 + `alarm_status` 分布并存。
+- 空态与「不适用」显示占位符而非 0。
+- 真实环境走查(`http://127.0.0.1:8000/#/cockpit` 总体概览-预警设置):按项目规则,浏览器验收需维护者明确授权后执行;且后端需先滚动部署 `b047a60d`(当前共享实例 8301 仍回旧契约,见基线卡片 §4.3)。
+
+## 关联
+
+- 上游:[41 预警设置端点改为预警自身生命周期统计](41-warning-settings-endpoint-rework.md)(已收口)。
+- 基线:[基线卡片 06 预警设置生命周期](../handoff/baselines/warning-settings-lifecycle.md)。
+- 相关决策:决策 06(异常统计与业务语义)、[22 真实响应信封消费](22-22.md)(失败/空态纪律)。
+
+## 进度
+
+<!-- 做到哪一步了,在这里一行一行记 -->
+
+- 2026-10-14 建票(由票 41 收口时另立,承接其验收第 2 条);契约细节已写入票面,未动工。
+
+## Comments
+
+
+## Answer
+

+ 185 - 0
.scratch/cockpit-real-data-wayfinding/verify/t42-warning-settings-contract.mjs

@@ -0,0 +1,185 @@
+/**
+ * T42 契约守卫:预警设置面板消费**预警自身生命周期**契约(后端 `b047a60d`)。
+ *
+ * 守什么(票 42 验收):
+ *   1. 预警族工单口径字段**原样 null**(不适用 ≠ 0 —— `numberish` 绝不造 0,
+ *      视图显示占位符,领域层不得补假零条);
+ *   2. 预警族四组分布(状态六桶/等级/行业/片区)直读透传,且 `sum(桶)==total` 自洽;
+ *   3. 报警族保留工单口径(0–7 全铺、工单口径处置率)并**并存** `alarm_status` 分布;
+ *   4. 空态:两族 EMPTY、已解除率「不适用」,分布仍全铺计数 0(不是没有数据)。
+ *
+ * 数值用基线卡片 §4.2 的**真实复验值**(2026-10-14 联调库:预警 31 / 报警 66)——
+ * 守的是线上真实跑的那份契约,不是另编的玩具形状。mock(contractMocks)侧的同构
+ * 由 t20 的 D8 端到端(同一领域函数消费 mock 载荷)覆盖。
+ *
+ * 用法:node .scratch/cockpit-real-data-wayfinding/verify/t42-warning-settings-contract.mjs
+ */
+import assert from 'node:assert/strict'
+import { toOverviewWarningSettings, alertSettingsPanel } from '../../../src/modules/cockpit/domain/overviewPanels.js'
+import { handlingRateView } from '../../../src/modules/cockpit/domain/warningResponsePanels.js'
+
+// ---- 基线卡片 §4.2 的真实载荷形状(LAST_7_DAYS 实测,2026-10-14) ----------------
+const REAL_PAYLOAD = {
+    requestId: 't42', apiVersion: 'v1', readOnly: true, displayName: '预警设置',
+    selectedMode: 'WARNING', timezone: 'Asia/Shanghai',
+    range: { type: 'LAST_7_DAYS', start: '2026-10-07T12:16:17+08:00', end: '2026-10-14T12:16:17+08:00' },
+    updatedAt: '2026-10-14T11:37:12+08:00',
+    freshness: { status: 'CURRENT', reason: 'READ_ONLY_AGGREGATE', updatedAt: '2026-10-14T11:37:12+08:00', serverUpdatedAt: '2026-10-14T12:16:17+08:00' },
+    warning: {
+        eventFamily: 'WARNING', state: 'READY', stateReason: 'WARNINGS_RESOLVED', total: 31,
+        handled: null, pending: null, cancelledCount: null, unlinkedEventCount: null, invalidWorkOrderStateCount: null,
+        handlingEfficiency: { numerator: 19, denominator: 31, value: '0.6129', state: 'AVAILABLE', reason: null },
+        workOrderStates: null,
+        statusDistribution: [
+            { code: 'DRAFT', label: '草稿', count: 2 }, { code: 'PENDING', label: '待办', count: 4 },
+            { code: 'RECEIVED', label: '已接收', count: 0 }, { code: 'PROCESSING', label: '处置中', count: 4 },
+            { code: 'HANDLED', label: '已处置', count: 2 }, { code: 'CLOSED', label: '已解除', count: 19 },
+        ],
+        levelDistribution: [
+            { code: '1', label: '1级', count: 13 }, { code: '2', label: '2级', count: 5 },
+            { code: '3', label: '3级', count: 1 }, { code: '4', label: '4级', count: 12 },
+        ],
+        industryDistribution: [
+            { code: 'GAS', label: '燃气', count: 0 }, { code: 'WATER_SUPPLY', label: '供水', count: 2 },
+            { code: 'DRAINAGE', label: '排水', count: 24 }, { code: 'MANHOLE', label: '窨井', count: 1 },
+            { code: 'UNKNOWN', label: '未知', count: 4 },
+        ],
+        districtDistribution: [
+            { code: '0', label: '城南', count: 24 }, { code: '1', label: '城北', count: 1 },
+            { code: '2', label: '太常', count: 2 }, { code: 'UNASSIGNED', label: '未分区', count: 4 },
+        ],
+        trend: [
+            { bucket: '2026-10-10', total: 16, handled: 14, handlingEfficiency: { numerator: 14, denominator: 16, value: '0.8750', state: 'AVAILABLE', reason: null } },
+            { bucket: '2026-10-09', total: 3, handled: 2, handlingEfficiency: { numerator: 2, denominator: 3, value: '0.6667', state: 'AVAILABLE', reason: null } },
+            { bucket: '2026-10-14', total: 2, handled: 0, handlingEfficiency: { numerator: 0, denominator: 2, value: '0.0000', state: 'AVAILABLE', reason: null } },
+        ],
+    },
+    alarm: {
+        eventFamily: 'ALARM', state: 'READY', stateReason: 'EVENTS_RESOLVED', total: 66,
+        handled: 64, pending: 0, cancelledCount: 0, unlinkedEventCount: 65, invalidWorkOrderStateCount: 0,
+        handlingEfficiency: { numerator: 1, denominator: 5, value: '0.2000', state: 'AVAILABLE', reason: null },
+        workOrderStates: { 0: 0, 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 0, 7: 1 },
+        statusDistribution: [
+            { code: 'UNHANDLED', label: '未处理', count: 2 }, { code: 'HANDLED', label: '已处理', count: 64 },
+        ],
+        levelDistribution: [
+            { code: '1', label: '1级', count: 0 }, { code: '2', label: '2级', count: 0 },
+            { code: '3', label: '3级', count: 0 }, { code: '4', label: '4级', count: 66 },
+        ],
+        industryDistribution: [
+            { code: 'GAS', label: '燃气', count: 0 }, { code: 'WATER_SUPPLY', label: '供水', count: 0 },
+            { code: 'DRAINAGE', label: '排水', count: 66 }, { code: 'MANHOLE', label: '窨井', count: 0 },
+        ],
+        districtDistribution: [
+            { code: '0', label: '城南', count: 66 }, { code: '1', label: '城北', count: 0 },
+            { code: '2', label: '太常', count: 0 }, { code: 'UNASSIGNED', label: '未分区', count: 0 },
+        ],
+        trend: [
+            { bucket: '2026-10-10', total: 4, handled: 0, handlingEfficiency: { numerator: 0, denominator: 4, value: '0.0000', state: 'AVAILABLE', reason: null } },
+            { bucket: '2026-10-12', total: 62, handled: 1, handlingEfficiency: { numerator: 1, denominator: 62, value: '0.0161', state: 'AVAILABLE', reason: null } },
+        ],
+    },
+    mapFilter: { eventFamily: 'WARNING', label: '预警' },
+}
+
+// 空场景:两族都空;分布仍全铺计数 0(后端契约如此),工单口径字段按族各自缺席。
+const EMPTY_PAYLOAD = {
+    ...REAL_PAYLOAD,
+    updatedAt: '2026-10-14T12:00:00+08:00',
+    warning: {
+        ...REAL_PAYLOAD.warning, state: 'EMPTY', stateReason: 'NO_EVENTS', total: 0,
+        handlingEfficiency: { numerator: 0, denominator: 0, value: '0.0000', state: 'EMPTY', reason: 'NO_WARNINGS' },
+        statusDistribution: REAL_PAYLOAD.warning.statusDistribution.map((row) => ({ ...row, count: 0 })),
+        levelDistribution: REAL_PAYLOAD.warning.levelDistribution.map((row) => ({ ...row, count: 0 })),
+        industryDistribution: REAL_PAYLOAD.warning.industryDistribution.map((row) => ({ ...row, count: 0 })),
+        districtDistribution: REAL_PAYLOAD.warning.districtDistribution.map((row) => ({ ...row, count: 0 })),
+        trend: [],
+    },
+    alarm: {
+        ...REAL_PAYLOAD.alarm, state: 'EMPTY', stateReason: 'NO_EVENTS', total: 0,
+        handled: 0, pending: 0, cancelledCount: 0, unlinkedEventCount: 0, invalidWorkOrderStateCount: 0,
+        handlingEfficiency: { numerator: 0, denominator: 0, value: '0.0000', state: 'EMPTY', reason: 'NO_ELIGIBLE_WORK_ORDERS' },
+        workOrderStates: { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0 },
+        statusDistribution: REAL_PAYLOAD.alarm.statusDistribution.map((row) => ({ ...row, count: 0 })),
+        levelDistribution: REAL_PAYLOAD.alarm.levelDistribution.map((row) => ({ ...row, count: 0 })),
+        industryDistribution: REAL_PAYLOAD.alarm.industryDistribution.map((row) => ({ ...row, count: 0 })),
+        districtDistribution: REAL_PAYLOAD.alarm.districtDistribution.map((row) => ({ ...row, count: 0 })),
+        trend: [],
+    },
+}
+
+const section = { state: 'SUCCESS', loaded: true }
+const ready = (data) => ({ ...section, data })
+
+// ================================================================ 1 领域层
+const data = toOverviewWarningSettings(REAL_PAYLOAD)
+
+// 验收 1:预警族工单口径原样 null —— null 是"不适用",不是 0。
+assert.equal(data.warning.workOrderStates, null, '预警族 workOrderStates 必须原样 null(不补假零条)')
+assert.equal(data.warning.handled, null, '预警族 handled 必须原样 null')
+assert.equal(data.warning.pending, null, '预警族 pending 必须原样 null')
+assert.equal(data.warning.cancelledCount, null, '预警族 cancelledCount 必须原样 null')
+assert.equal(data.warning.unlinkedEventCount, null, '预警族 unlinkedEventCount 必须原样 null')
+assert.equal(data.warning.invalidWorkOrderStateCount, null, '预警族 invalidWorkOrderStateCount 必须原样 null')
+
+// 验收 2:四组分布直读透传且自洽(sum(桶)==total)。
+const sum = (rows) => rows.reduce((total, row) => total + row.count, 0)
+assert.equal(data.warning.statusDistribution.length, 6, '状态六桶全铺')
+assert.equal(sum(data.warning.statusDistribution), 31, '状态分布和=总数')
+assert.equal(sum(data.warning.levelDistribution), 31, '等级分布和=总数')
+assert.equal(sum(data.warning.industryDistribution), 31, '行业分布和=总数')
+assert.equal(sum(data.warning.districtDistribution), 31, '片区分布和=总数')
+assert.ok(data.warning.industryDistribution.some((row) => row.code === 'UNKNOWN' && row.label === '未知'), 'UNKNOWN 桶 label 原样透传')
+assert.ok(data.warning.districtDistribution.some((row) => row.code === 'UNASSIGNED' && row.label === '未分区'), 'UNASSIGNED 桶恒在')
+// 已解除率=已解除 ÷ 预警总数(19/31),语义与工单处置率分得开。
+assert.equal(data.warning.efficiency.numerator, 19)
+assert.equal(data.warning.efficiency.denominator, 31)
+assert.equal(data.warning.efficiency.handlingState, 'AVAILABLE')
+
+// 验收 3:报警族保留工单口径并并存自身生命周期。
+assert.ok(Array.isArray(data.alarm.workOrderStates) && data.alarm.workOrderStates.length === 8, '报警族工单 0–7 全铺')
+assert.equal(data.alarm.workOrderStates.find((row) => row.code === '5').count, 1, '报警族工单状态 5 计数来自后端')
+assert.equal(sum(data.alarm.statusDistribution), 66, '报警自身生命周期分布和=总数')
+assert.equal(data.alarm.statusDistribution.find((row) => row.code === 'HANDLED').count, 64, '报警已处理(alarm_status)')
+assert.equal(data.alarm.efficiency.numerator, 1, '报警处置率仍是工单口径(分子=状态 5 工单)')
+assert.equal(data.alarm.efficiency.denominator, 5, '报警处置率分母=状态 0–6 工单(7 张排除已取消 1 张)')
+
+// ================================================================ 2 视图层
+const view = alertSettingsPanel(data, ready(data))
+const kpiItems = view.blocks.flatMap((block) => block.items || [])
+const labels = view.blocks.map((block) => block.label).filter(Boolean)
+
+// 预警族 KPI 只剩 总数 + 已解除率(工单口径读数不再出现)。
+const warningKpiKeys = view.blocks[0].items.map((item) => item.key)
+assert.deepEqual(warningKpiKeys, ['WARNING-total', 'WARNING-rate'], '预警族 KPI=总数 + 已解除率')
+assert.equal(view.blocks[0].items.find((item) => item.key === 'WARNING-rate').label, '已解除率')
+// 空值显示占位符(不适用 ≠ 0):领域层 null 经 valueText 是 '—',
+// 这里直接断领域对象不携带 0(视图的 valueText 是既定纪律,t20 已守)。
+assert.notEqual(data.warning.handled, 0, 'null 不得被数成 0')
+
+// 分布块:预警四组 + 报警工单分布 + 报警四组;工单分布块只属于报警族。
+assert.ok(labels.includes('预警 · 生命周期状态'), '预警生命周期状态块')
+assert.equal(view.blocks.find((block) => block.label === '预警 · 生命周期状态').rows.length, 6, '状态六桶画满')
+assert.ok(labels.includes('预警 · 行业分布') && labels.includes('预警 · 片区分布'), '预警行业/片区分布块')
+assert.ok(labels.includes('报警 · 工单状态分布'), '报警工单分布块保留(工单口径)')
+assert.equal(view.blocks.find((block) => block.label === '报警 · 工单状态分布').rows.length, 8, '报警工单 0–7 画满')
+assert.ok(labels.includes('报警 · 生命周期状态'), '报警生命周期状态块并存')
+assert.equal(view.blocks.filter((block) => (block.label || '').startsWith('预警 · 工单')).length, 0, '预警族没有工单口径块')
+
+// 已解除率的值与 hint(票 20 单源判据:不适用不是 0%)。
+const rate = view.blocks[0].items.find((item) => item.key === 'WARNING-rate')
+assert.equal(rate.value, '61.3%', '已解除率 19/31 显示 61.3%')
+assert.equal(rate.hint, '已解除 19 / 预警总数 31', 'hint 写明分子分母,不冒充工单处置率')
+
+// ================================================================ 3 空态
+const emptyData = toOverviewWarningSettings(EMPTY_PAYLOAD)
+const emptyView = alertSettingsPanel(emptyData, ready(emptyData))
+assert.equal(emptyData.warning.state, 'EMPTY')
+assert.equal(handlingRateView(emptyData.warning.efficiency), '不适用', '空态已解除率=不适用(不是 0.0%)')
+assert.equal(emptyView.blocks[0].items.find((item) => item.key === 'WARNING-rate').value, '不适用')
+assert.equal(emptyView.blocks[0].items.find((item) => item.key === 'WARNING-rate').hint, '当前范围没有预警,已解除率不适用')
+assert.equal(emptyData.warning.workOrderStates, null, '空态下预警族工单口径仍是 null(族口径不随数据多少变)')
+assert.equal(sum(emptyData.warning.statusDistribution), 0, '空态分布全铺计数 0(真实计数,不是缺桶)')
+assert.equal(emptyData.alarm.efficiency.handlingReason, 'NO_ELIGIBLE_WORK_ORDERS', '报警族空态原因码透传')
+
+console.log('T42 warning-settings lifecycle contract: null-fidelity, four-distribution self-consistency, alarm work-order retention, empty-state not-applicable passed')

+ 112 - 22
src/modules/cockpit/domain/overviewPanels.js

@@ -197,6 +197,12 @@ export function toOverviewMonitoring(raw) {
  *
  * **一次响应同时给预警与报警两族**(`warning` + `alarm`),所以态势总览与预警设置
  * **共用这一次取数**(Q1 裁定),不做 WARNING/ALARM 切换。
+ *
+ * **票 42 起两族口径分叉**(后端 `b047a60d`,维护者 2026-10-14 裁定):
+ * 预警族只给**预警自身生命周期**(状态/等级/行业/片区分布 + 已解除率),
+ * 工单口径六字段(`handled/pending/cancelledCount/unlinkedEventCount/invalidWorkOrderStateCount/workOrderStates`)
+ * 恒为 `null`;报警族**保留工单口径**(报警靠工单闭环)并**并存** `alarm_status` 生命周期分布。
+ * `null` 在本层原样透传 —— 「不适用」与「真的是 0」必须在视图层分得开(票 22 纪律)。
  */
 export function toOverviewWarningSettings(raw) {
     const reader = fieldReader(raw, 'overviewWarningSettings')
@@ -205,29 +211,52 @@ export function toOverviewWarningSettings(raw) {
     /** 一族(预警或报警)→ 领域对象。 */
     const family = (key) => {
         const row = fieldReader(reader.raw[key], `overviewWarningSettings.${key}`)
-        const states = row.optionalObj('workOrderStates') || {}
+        // 工单状态分布:**缺失(null)=该族不走工单口径**(预警族恒如此),
+        // 绝不给它补 8 根假零条 —— 那是把"不适用"画成"全是 0"(票 42 验收第 4 条)。
+        const states = row.optionalObj('workOrderStates')
+        const efficiency = row.raw.handlingEfficiency || {}
+        // 分布桶直读:后端已按族全铺且 `sum(桶)==total`,前端**不补 0、不重排、不翻码**
+        //(`label` 就是后端给的人话)。后端没给某组时记 `null`,视图整块跳过(不画空块)。
+        const buckets = (name) => {
+            const items = row.raw[name]
+            if (!Array.isArray(items)) return null
+            return items.map((item) => {
+                const cell = fieldReader(item, `overviewWarningSettings.${key}.${name}`)
+                return { code: cell.optionalStr('code'), label: cell.optionalStr('label'), count: numberish(cell.raw.count) || 0 }
+            })
+        }
         return {
             family: row.optionalStr('eventFamily') || key.toUpperCase(),
             state: row.optionalStr('state'),
+            stateReason: row.optionalStr('stateReason'),
             total: numberish(row.raw.total),
+            // 工单口径五读数:预警族恒 `null`(视图显示「不适用」),报警族保留工单语义。
             handled: numberish(row.raw.handled),
             pending: numberish(row.raw.pending),
             cancelledCount: numberish(row.raw.cancelledCount),
             unlinkedEventCount: numberish(row.raw.unlinkedEventCount),
             invalidWorkOrderStateCount: numberish(row.raw.invalidWorkOrderStateCount),
-            // 处置率连同 `state` 一起带("分母为 0"与"真的是 0%"必须分得开)。
+            // 速率连同 `state/reason` 一起带("分母为 0/没有预警"与"真的是 0%"必须分得开);
+            // 语义按族不同:预警族=已解除÷总数,报警族=工单口径处置率(决策票 06)。
             efficiency: {
-                handlingRate: numberish((row.raw.handlingEfficiency || {}).value),
-                handlingState: ((row.raw.handlingEfficiency || {}).state) || '',
-                denominator: numberish((row.raw.handlingEfficiency || {}).denominator),
-                numerator: numberish((row.raw.handlingEfficiency || {}).numerator),
+                handlingRate: numberish(efficiency.value),
+                handlingState: efficiency.state || '',
+                handlingReason: efficiency.reason || null,
+                denominator: numberish(efficiency.denominator),
+                numerator: numberish(efficiency.numerator),
             },
-            // 工单状态分布:键是后端 0–7,逐态补 0(缺的态是"没有这张工单",0 是真实计数)。
-            workOrderStates: WORK_ORDER_STATES.map((state) => {
+            workOrderStates: states === null ? null : WORK_ORDER_STATES.map((state) => {
                 const value = states[state.code] === undefined ? states[String(state.code)] : states[state.code]
                 return { code: String(state.code), label: state.name, count: numberish(value) || 0 }
             }),
+            // 票 42:自身生命周期分布(两族都有;`statusDistribution` 的码表两族不同 ——
+            // 预警是 `WarningWorkflow` 六态、报警是 `alarm_status` 两态,label 由后端给)。
+            statusDistribution: buckets('statusDistribution'),
+            levelDistribution: buckets('levelDistribution'),
+            industryDistribution: buckets('industryDistribution'),
+            districtDistribution: buckets('districtDistribution'),
             // 趋势:桶是后端给的(`range` 声明窗口后由服务端过滤),本层不重采样。
+            // `handled` 的含义按族不同:预警=桶内已解除,报警=桶内关联工单已结案。
             trend: row.optionalList('trend').map((point) => {
                 const cell = fieldReader(point, 'warningTrendPoint')
                 return { key: cell.optionalStr('bucket'), label: cell.optionalStr('bucket'), count: numberish(cell.raw.total) || 0 }
@@ -304,7 +333,7 @@ function onlineRateHint(data) {
 /**
  * 态势总览面板视图(验收第 1 条)。
  *
- * 跨行业 KPI:设施底数、监测规模、在线率、当前预警/报警、处置率 + 近 7 日趋势。
+ * 跨行业 KPI:设施底数、监测规模、在线率、当前预警/报警、预警已解除率 + 近 7 日趋势。
  *
  * 数据来自**三份取数**(基础设施 / 运行监测 / 预警设置)——它们是各自独立成败的分区,
  * 所以这里把三份都作为输入,缺哪份就少哪几项读数,而**不**让整块面板消失。
@@ -330,9 +359,9 @@ export function situationSummaryPanel(data, section) {
             { key: 'alarmTotal', label: '当前报警', value: valueText(alarm && alarm.total), unit: '条', link: 'ALARM', linkHint: '跳到预警处置模式的当前报警列表' },
             {
                 key: 'handlingRate',
-                label: '处置率',
+                label: '预警已解除率',
                 value: warning ? handlingRateView(warning.efficiency) : DETAIL_PLACEHOLDER,
-                hint: warning ? handlingRateHint(warning.efficiency) : '',
+                hint: warning ? warningRateHint(warning.efficiency) : '',
                 link: 'EFFICIENCY',
                 linkHint: '跳到预警处置模式的预警效率明细',
             },
@@ -354,7 +383,7 @@ export function situationSummaryPanel(data, section) {
             { key: 'ALARM', label: '查看当前报警列表', mode: OVERVIEW_LINK_MODES.ALARM },
             { key: 'EFFICIENCY', label: '查看处置效率明细', mode: OVERVIEW_LINK_MODES.EFFICIENCY },
         ],
-        linkNote: '点预警/报警/处置率读数可跳到「预警处置」模式的事件列表(本模式端点不含点位编码,不在地图上定位)',
+        linkNote: '点预警/报警/已解除率读数可跳到「预警处置」模式的事件列表(本模式端点不含点位编码,不在地图上定位)',
         rangeNote: warnings && warnings.rangeType === 'LAST_7_DAYS' ? '趋势窗口:近 7 日(后端按声明窗口过滤)' : '',
         missing: panelMissingNote(current),
         placeholder: sectionPlaceholder(section),
@@ -480,19 +509,77 @@ export function monitoringSummaryPanel(data, section) {
     }
 }
 
-/** 一族(预警/报警)的统计块。 */
-function familyBlocks(title, family) {
+/**
+ * 票 42:预警族速率的说明。预警族的速率是**已解除率**(已解除 ÷ 预警总数),
+ * 与报警族/预警处置模式的**工单口径处置率**不是一个东西 —— hint 用词必须分开,
+ * 不让「已解除 19 / 预警 31」被读成工单处置率。判据复用 `handlingRateView`(票 20 单源)。
+ */
+function warningRateHint(efficiency) {
+    const row = efficiency || {}
+    if (handlingRateView(row) === '不适用') return '当前范围没有预警,已解除率不适用'
+    if (typeof row.numerator === 'number' && typeof row.denominator === 'number') {
+        return `已解除 ${row.numerator} / 预警总数 ${row.denominator}`
+    }
+    return ''
+}
+
+/**
+ * 票 42:自身生命周期分布块(状态/等级/行业/片区)。后端已按族全铺且计数自洽,
+ * 前端不补 0、不重排、不翻码;某组后端没给时**整块跳过** —— 「不适用」显式化,不画空块。
+ * `title` 是界面用语(「预警」/「报警」),与 KPI 的机器键(`WARNING-*`)分工。
+ */
+function lifecycleBars(title, row) {
+    const groups = [
+        ['statusDistribution', '生命周期状态'],
+        ['levelDistribution', '等级分布'],
+        ['industryDistribution', '行业分布'],
+        ['districtDistribution', '片区分布'],
+    ]
+    return groups
+        .filter(([name]) => Array.isArray(row[name]))
+        .map(([name, label]) => barsBlock(`${title} · ${label}`, row[name]))
+}
+
+/**
+ * 预警族的块(票 42:只给**预警自身生命周期**,工单口径整体不出现 ——
+ * 「预警不走工单流程」,画工单字段等于把不适用画成读数)。
+ */
+function warningFamilyBlocks(family) {
+    const row = family || {}
+    const familyKey = row.family || 'WARNING'
+    return [
+        kpiBlock([
+            { key: `${familyKey}-total`, label: '预警总数', value: valueText(row.total), unit: '条' },
+            {
+                key: `${familyKey}-rate`,
+                label: '已解除率',
+                value: handlingRateView(row.efficiency),
+                hint: warningRateHint(row.efficiency),
+                link: 'EFFICIENCY',
+                linkHint: '跳到预警处置模式的预警效率明细',
+            },
+        ]),
+        ...lifecycleBars('预警', row),
+    ]
+}
+
+/**
+ * 报警族的块(票 42:**保留工单口径** —— 维护者 2026-10-14 裁定「报警靠工单闭环」,
+ * 同时并存 `alarm_status` 自身生命周期分布,两者不同源、不互相冒充)。
+ */
+function alarmFamilyBlocks(family) {
     const row = family || {}
+    const familyKey = row.family || 'ALARM'
     const states = row.workOrderStates || []
     return [
         kpiBlock([
-            { key: `${row.family || title}-total`, label: `${title}总数`, value: valueText(row.total), unit: '条' },
-            { key: `${row.family || title}-handled`, label: '已处理', value: valueText(row.handled), unit: '条' },
-            { key: `${row.family || title}-pending`, label: '待处理', value: valueText(row.pending), unit: '条' },
-            { key: `${row.family || title}-cancelled`, label: '已取消', value: valueText(row.cancelledCount), unit: '条' },
-            { key: `${row.family || title}-unlinked`, label: '未关联工单', value: valueText(row.unlinkedEventCount), unit: '条' },
+            { key: `${familyKey}-total`, label: '报警总数', value: valueText(row.total), unit: '条' },
+            { key: `${familyKey}-handled`, label: '已处理', value: valueText(row.handled), unit: '条' },
+            { key: `${familyKey}-pending`, label: '待处理', value: valueText(row.pending), unit: '条' },
+            { key: `${familyKey}-cancelled`, label: '已取消', value: valueText(row.cancelledCount), unit: '条' },
+            { key: `${familyKey}-unlinked`, label: '未关联工单', value: valueText(row.unlinkedEventCount), unit: '条' },
             {
-                key: `${row.family || title}-rate`,
+                key: `${familyKey}-rate`,
                 label: '处置率',
                 value: handlingRateView(row.efficiency),
                 hint: handlingRateHint(row.efficiency),
@@ -500,7 +587,9 @@ function familyBlocks(title, family) {
             },
         ]),
         // 工单状态分布:0–7 全铺(缺的态是真实计数 0,不是"没有数据")。
-        barsBlock(`${title} · 工单状态分布`, states),
+        barsBlock('报警 · 工单状态分布', states),
+        // 票 42:报警自身生命周期(UNHANDLED/HANDLED 两桶 + 等级/行业/片区)并存。
+        ...lifecycleBars('报警', row),
     ]
 }
 
@@ -518,7 +607,8 @@ export function alertSettingsPanel(data, section) {
     const warning = warnings.warning || null
     const alarm = warnings.alarm || null
 
-    const blocks = [...familyBlocks('预警', warning), ...familyBlocks('报警', alarm)]
+    // 票 42:两族口径不同,块结构也不同 —— 预警族纯自身生命周期,报警族工单口径 + 自身生命周期。
+    const blocks = [...warningFamilyBlocks(warning), ...alarmFamilyBlocks(alarm)]
 
     return {
         name: 'alertSettings',

+ 130 - 33
src/modules/cockpit/mocks/contractMocks.js

@@ -2326,57 +2326,154 @@ function overviewMonitoringPayload(scenario, query) {
 }
 
 /**
- * `/overview/warning-settings`(票 20 验收第 1、4 条)。
+ * `/overview/warning-settings`(票 20 验收第 1、4 条;票 42 起契约两族分叉)。
  *
  * **一次返回预警与报警两族**(`warning` + `alarm`),所以态势总览与预警设置共用这一次取数。
  * `range` 入参真的过滤趋势桶:不声明时后端维持无界(`CURRENT_BASELINE`),
  * 声明 `LAST_7_DAYS` 时只留窗口内的桶 —— 本票固定声明它(验收第 1 条的"近 7 日")。
+ *
+ * **票 42(后端 `b047a60d`)**:预警族只给**预警自身生命周期**(状态/等级/行业/片区分布 +
+ * 已解除率),工单口径六字段(含 `workOrderStates`)恒 `null` —— mock 与 real 同构,
+ * **不用 0 冒充「不适用」**;报警族**保留工单口径**(报警靠工单闭环)并**并存**
+ * `alarm_status` 生命周期分布。分布数值在自家总数(8/9)内自洽(`sum(桶)==total`),
+ * 桶种类与真实契约一一对应(含 UNKNOWN / UNASSIGNED 这类显式桶)。
  */
 function overviewWarningSettingsPayload(scenario, query) {
     const rangeType = (query && query.range) || 'CURRENT_BASELINE'
     const bounded = rangeType === 'LAST_7_DAYS'
+    const blank = scenario === 'empty'
 
-    /** 一族:总数/已处理/待处理/已取消/未关联 + 0–7 状态分布 + 趋势。 */
-    const family = (eventFamily, total, handled, pending, cancelledCount, unlinkedEventCount, states, trend) => {
-        const denominator = total
-        return {
-            eventFamily,
-            state: total === 0 ? 'EMPTY' : 'READY',
-            stateReason: total === 0 ? 'NO_EVENTS' : 'EVENTS_RESOLVED',
-            total,
-            handled,
-            pending,
-            cancelledCount,
-            unlinkedEventCount,
-            invalidWorkOrderStateCount: 0,
-            handlingEfficiency: {
-                numerator: handled,
-                denominator,
+    // 预警族:WarningWorkflow 六态(DRAFT/PENDING/RECEIVED/PROCESSING/HANDLED/CLOSED)。
+    const warningStatusDistribution = [
+        { code: 'DRAFT', label: '草稿', count: 0 },
+        { code: 'PENDING', label: '待办', count: 1 },
+        { code: 'RECEIVED', label: '已接收', count: 0 },
+        { code: 'PROCESSING', label: '处置中', count: 1 },
+        { code: 'HANDLED', label: '已处置', count: 1 },
+        { code: 'CLOSED', label: '已解除', count: 5 },
+    ]
+    const warningLevelDistribution = [
+        { code: '1', label: '1级', count: 2 },
+        { code: '2', label: '2级', count: 3 },
+        { code: '3', label: '3级', count: 2 },
+        { code: '4', label: '4级', count: 1 },
+    ]
+    const warningIndustryDistribution = [
+        { code: 'GAS', label: '燃气', count: 3 },
+        { code: 'WATER_SUPPLY', label: '供水', count: 2 },
+        { code: 'DRAINAGE', label: '排水', count: 2 },
+        { code: 'MANHOLE', label: '窨井', count: 0 },
+        { code: 'UNKNOWN', label: '未知', count: 1 },
+    ]
+    const warningDistrictDistribution = [
+        { code: '0', label: '城南', count: 3 },
+        { code: '1', label: '城北', count: 2 },
+        { code: '2', label: '太常', count: 1 },
+        { code: 'UNASSIGNED', label: '未分区', count: 2 },
+    ]
+    // 报警族:工单口径 0–7 全键(缺的态是真实计数 0)+ alarm_status 两态。
+    const alarmWorkOrderStates = { 0: 0, 1: 1, 2: 2, 3: 1, 4: 1, 5: 3, 6: 1, 7: 0 }
+    const alarmStatusDistribution = [
+        { code: 'UNHANDLED', label: '未处理', count: 3 },
+        { code: 'HANDLED', label: '已处理', count: 6 },
+    ]
+    const alarmLevelDistribution = [
+        { code: '1', label: '1级', count: 1 },
+        { code: '2', label: '2级', count: 2 },
+        { code: '3', label: '3级', count: 4 },
+        { code: '4', label: '4级', count: 2 },
+    ]
+    const alarmIndustryDistribution = [
+        { code: 'GAS', label: '燃气', count: 2 },
+        { code: 'WATER_SUPPLY', label: '供水', count: 3 },
+        { code: 'DRAINAGE', label: '排水', count: 3 },
+        { code: 'MANHOLE', label: '窨井', count: 1 },
+    ]
+    const alarmDistrictDistribution = [
+        { code: '0', label: '城南', count: 4 },
+        { code: '1', label: '城北', count: 2 },
+        { code: '2', label: '太常', count: 2 },
+        { code: 'UNASSIGNED', label: '未分区', count: 1 },
+    ]
+    // 空场景:分布仍全铺(缺的态是真实计数 0,不是"没有数据")。
+    const zeroed = (rows) => rows.map((row) => ({ ...row, count: 0 }))
+    const pick = (rows) => (blank ? zeroed(rows) : rows)
+
+    // 趋势桶:`handled` 的含义按族不同 —— 预警=桶内已解除,报警=关联工单已结案(工单口径)。
+    const trendOf = (base) => {
+        const trend = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((index) => {
+            const total = blank ? 0 : base + (index % 3)
+            const handled = blank ? 0 : base
+            return {
+                bucket: `2026-09-${String(index + 1).padStart(2, '0')}`,
+                total,
+                handled,
                 // 分母为 0 时后端回 "0.0000" + EMPTY(照实模拟;界面不得画成 0.0%)。
-                value: denominator === 0 ? '0.0000' : (handled / denominator).toFixed(4),
-                state: denominator === 0 ? 'EMPTY' : 'AVAILABLE',
-                reason: denominator === 0 ? 'NO_EVENTS' : null,
-            },
-            // 0–7 全键(后端 `WORK_ORDER_STATES` 逐态初始化,缺的态是真实计数 0)。
-            workOrderStates: states,
-            // 桶是业务时区日期;声明窗口后只留窗口内的桶。
-            trend: bounded ? trend.slice(-7) : trend,
-        }
+                handlingEfficiency: { value: total === 0 ? '0.0000' : (handled / total).toFixed(4), state: total === 0 ? 'EMPTY' : 'AVAILABLE' },
+            }
+        })
+        // 桶是业务时区日期;声明窗口后只留窗口内的桶。
+        return bounded ? trend.slice(-7) : trend
     }
 
-    const blank = scenario === 'empty'
-    const trendOf = (base) => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((index) => ({ bucket: `2026-09-${String(index + 1).padStart(2, '0')}`, total: blank ? 0 : base + (index % 3), handled: blank ? 0 : base, handlingEfficiency: { value: '0.5000' } }))
-
     return {
         ...overviewEnvelope(blank ? null : SNAPSHOT_AT),
         displayName: '预警设置',
         // `mode` 不传时后端默认 WARNING(本票不传,因为两族都要)。
         selectedMode: (query && query.mode) || 'WARNING',
         range: { type: rangeType, start: bounded ? '2026-09-23T00:00:00+08:00' : null, end: bounded ? '2026-09-30T00:00:00+08:00' : null },
-        warning: family('WARNING', blank ? 0 : 8, blank ? 0 : 5, blank ? 0 : 2, blank ? 0 : 1, blank ? 0 : 1,
-            { 0: 1, 1: 1, 2: 1, 3: 1, 4: 1, 5: 2, 6: 1, 7: 0 }, trendOf(1)),
-        alarm: family('ALARM', blank ? 0 : 9, blank ? 0 : 6, blank ? 0 : 2, blank ? 0 : 1, blank ? 0 : 0,
-            { 0: 0, 1: 1, 2: 2, 3: 1, 4: 1, 5: 3, 6: 1, 7: 0 }, trendOf(2)),
+        warning: {
+            eventFamily: 'WARNING',
+            state: blank ? 'EMPTY' : 'READY',
+            stateReason: blank ? 'NO_EVENTS' : 'WARNINGS_RESOLVED',
+            total: blank ? 0 : 8,
+            // 预警不走工单流程(维护者 2026-10-14 裁定):工单口径六字段恒 null —— 不适用 ≠ 0。
+            handled: null,
+            pending: null,
+            cancelledCount: null,
+            unlinkedEventCount: null,
+            invalidWorkOrderStateCount: null,
+            // 已解除率=已解除 ÷ 预警总数(预警自身口径;分母 0 → EMPTY/NO_WARNINGS)。
+            handlingEfficiency: {
+                numerator: blank ? 0 : 5,
+                denominator: blank ? 0 : 8,
+                value: blank ? '0.0000' : '0.6250',
+                state: blank ? 'EMPTY' : 'AVAILABLE',
+                reason: blank ? 'NO_WARNINGS' : null,
+            },
+            workOrderStates: null,
+            statusDistribution: pick(warningStatusDistribution),
+            levelDistribution: pick(warningLevelDistribution),
+            industryDistribution: pick(warningIndustryDistribution),
+            districtDistribution: pick(warningDistrictDistribution),
+            trend: trendOf(1),
+        },
+        alarm: {
+            eventFamily: 'ALARM',
+            state: blank ? 'EMPTY' : 'READY',
+            stateReason: blank ? 'NO_EVENTS' : 'EVENTS_RESOLVED',
+            total: blank ? 0 : 9,
+            // 报警族保留工单口径;`handled` 是 alarm_status 自身生命周期(已处理 6)。
+            handled: blank ? 0 : 6,
+            pending: blank ? 0 : 6,
+            cancelledCount: 0,
+            unlinkedEventCount: 0,
+            invalidWorkOrderStateCount: 0,
+            // 处置率仍是**工单口径**(状态 5 ÷ 状态 0–6 = 3/9;分母 0 → NO_ELIGIBLE_WORK_ORDERS)。
+            handlingEfficiency: {
+                numerator: blank ? 0 : 3,
+                denominator: blank ? 0 : 9,
+                value: blank ? '0.0000' : '0.3333',
+                state: blank ? 'EMPTY' : 'AVAILABLE',
+                reason: blank ? 'NO_ELIGIBLE_WORK_ORDERS' : null,
+            },
+            workOrderStates: blank ? { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0 } : alarmWorkOrderStates,
+            statusDistribution: pick(alarmStatusDistribution),
+            levelDistribution: pick(alarmLevelDistribution),
+            industryDistribution: pick(alarmIndustryDistribution),
+            districtDistribution: pick(alarmDistrictDistribution),
+            trend: trendOf(2),
+        },
         mapFilter: { eventFamily: (query && query.mode) || 'WARNING', label: (query && query.mode) === 'ALARM' ? '报警' : '预警' },
     }
 }