Bläddra i källkod

docs(cockpit): 票52 后端执行票收口 —— 单设备工单处置优先落地记录

维护者 2026-10-17 裁定后,本票在后端仓库 E:\pipe-ner-server 落地并推送:
只取未处理报警(alarm_data.alarm_status=0)、承接=work_order_id 非空且工单
未结束(order_status ∈ {0..4,6};5/7 终态)→ 单设备判 WORK_ORDER,否则维持
现行序;空集/读失败保守降级 ALARM;resolve() 聚合与枚举 priority 不动(前端零耦合)。

后端提交 3772220e + 既有编译阻塞修复 5d4d03c6 已推 origin/master(远端核实)。
验证:Cockpit*/BusinessCockpit* 512/512、变异闸 7/7 打红并字节还原、前端 t09 38/38。

本 commit 只含本票记录:票面(含 5 项裁定与两点如实留档)、map.md(任务清单
勾选 / Decisions so far / Current status)、变异闸、真实数据取证、以及
t45 既有失败归属自查脚本。

两点如实留档:
1. 票面「实测 21119740 应为工单处置」被真实数据证伪 —— 该设备 2 条未处理报警
   均无工单承接,按裁定就该判 ALARM(票面把设备级 workOrderInProgress 误当成
   「未处理报警已逐条承接」)。
2. 新规则在当前真实数据上不改变任何设备读数(不存在「未处理且已承接」的报警行),
   属正确行为而非未生效。

t45 归属自查(t52-t45-baseline-check.mjs):工作树 4 条 C3x 失败 ⊂ HEAD 5 条失败
→ 全部既有,本票(纯后端)未引入任何新前端失败。
ChenJZ 3 dagar sedan
förälder
incheckning
da9a88693f

+ 201 - 0
.scratch/cockpit-real-data-wayfinding/issues/52-untitled.md

@@ -0,0 +1,201 @@
+<!-- DSH-IDEMPOTENCY-KEY: deck-51a682f3-5973748 -->
+<!-- parentKey: 00 -->
+# 后端执行票 —— 单设备业务优先级裁定落地(全部报警均有未结束工单时工单处置最高)
+
+Category: backend-execution
+
+## 来源
+
+[前端执行票 45](45-untitled.md) 实施前裁定(维护者 2026-10-16):
+- 监测点聚合:维持报警优先级最高(后端现行语义不动)。
+- **单设备**:只要该设备的**所有报警都有对应的工单**(即报警均已被工单承接、处置在进行),单设备自身状态应判**工单处置最高**——高于报警/预警。
+- 判定权归后端;前端只渲染(CONTEXT.md / ADR 0002 不变)。
+
+## 现状证据(前端票 45 侦察)
+
+- 权威解析器 `CockpitMonitoringPointStatusResolver.deviceStatus()` 现行顺序:
+  `alarm → warning → workOrderInProgress → online?NORMAL:OFFLINE`,即报警恒压工单。
+  实测设备 `21119740`(alarm=true, warning=true, workOrderInProgress=true)→ 后端判 ALARM;
+  按裁定应为工单处置(其报警已有工单承接时)。
+- `CockpitMonitoringPointStatus` 枚举优先级 ALARM(1)/WARNING(2)/WORK_ORDER(3)/NORMAL(4)/OFFLINE(5)/EMPTY(6)。
+- map-snapshot 不携带工单布尔——前端无法也不得自行判定。
+
+## 范围
+
+1. 明确「所有报警均有对应工单」的判定口径(报警-工单回联读模型已有 `alarm_data.work_order_id` 关联,票 12 已核实关联键)。
+2. 调整单设备状态解析:设备存在报警、且每条报警均有未结束/已关联工单时 → `WORK_ORDER`(工单处置);否则维持现行序。
+   注意只影响**单设备**状态(设备卡片 deviceStatus、快照未关联设备 deviceStatus、统计计数),**监测点聚合 `resolve()` 语义由维护者另行确认是否跟随**(聚合用 min(priority),若单设备可判 WORK_ORDER(3) 而报警设备判 WORK_ORDER,聚合优先级序是否需把 WORK_ORDER 提到 ALARM 之前,须裁定并同步枚举 priority 与前端词表)。
+3. 契约测试覆盖:报警无工单 → ALARM;全部报警有工单 → WORK_ORDER;混合 → 按裁定;无报警维持现状。
+4. 落地后通知前端票 45 消费侧复核(前端 `DEVICE_STATUS_VISUALS.priority` 与 `POINT_STATUS_VISUALS.priority` 需同步新序——前端票 45 已按双轴实现,priority 同步是唯一耦合点)。
+
+## 非目标
+
+- 不改前端代码(前端词表同步由票 45 收口时复核)。
+- 不动监测点聚合 `resolve()` 除非第 2 条裁明确认需要。
+
+## 进度
+
+**已实施、验证、推送完成(2026-10-17)→ 关闭。**
+
+提交 `3772220e` + 编译解阻 `5d4d03c6`,已推 `origin/master`(远端核实 3772220e)。
+`Cockpit*Test,BusinessCockpit*Test` **512/512**;变异闸 **7/7** 打红并字节还原;前端 t09 **38/38**(零耦合,裁定 4 成立)。
+
+裁定全部落地:只取未处理报警(`alarm_status=0`);承接=`work_order_id` 非空且工单 `order_status ∈ {0..4,6}`;
+空集/读失败 → 维持 ALARM;`resolve()` 与枚举 priority 不动;已推送。
+
+两处如实留档:① 票面「21119740 应为工单处置」被真实数据证伪(该设备 2 条未处理报警均无工单承接,
+按裁定应为 ALARM);② 新规则在当前数据上不改变任何读数(正确行为,非未生效)。
+`zksy-system` 全量 2 失败/1 错误为既有(stash 对照逐条相同),与本票零交集。
+
+**下一步**:无(本票交付物已闭环)。前端消费侧无需跟随改动。
+
+Status: resolved
+Type: task
+Blocked by:
+Labels: wayfinder:task, backend-execution
+
+## Comments
+
+### local — 2026-10-16T18:13:25.050Z
+阶段闸门诊断完成(2026-10-17):当前工作树是前端 City-Lifeline-Cockpit(origin=192.168.110.30/ChenJZ/City-Lifeline-Cockpit),没有后端 Java/Maven/Gradle 源码或 CockpitMonitoringPointStatusResolver;后端仓库/负责人/权限未提供。只读真实接口复核:map-snapshot 仍返回设备 21119740 的 ALARM,设备工单接口中存在多条未结束工单但也有未关联当前报警的工单;因此票面“全部报警均有未结束/已关联工单”的设备级判据不能从现有设备汇总直接推断,需要后端按每条当前报警关联键(alarm_data.work_order_id)且工单状态 0–4/6 视为未结束,状态 5/7 视为结束,空/缺关联即不满足。未改前端或模拟后端,避免越权猜测。
+
+### local — 2026-10-16T18:32:36.319Z
+【诊断记录 2026-10-17 —— 后端仓库已定位,票面前提与真实数据不符,判定口径待裁定】
+
+### local — 2026-10-16T18:46:42.632Z
+【维护者裁定 2026-10-17(5 项,全部采纳推荐项)—— 据此实施】
+
+1. **报警范围**:只取未处理报警(`alarm_data.alarm_status = 0`)。已处理的历史流水不参与。
+2. **承接判据**:`alarm_data.work_order_id` 非空 **且** 该工单未结束(`work_order.order_status ∈ {0,1,2,3,4,6}`,0-based;5 已结案 / 7 已取消为终态)。
+3. **空集与缺数据**:设备 `alarm` 标志为真但取不到任何未处理报警行(或根本没有 `alarm_data` 行)→ **维持现行序 → ALARM**(「查不到」不等于「已承接」)。
+4. **聚合与优先级**:**不跟随**。`resolve()` 与枚举 `priority` 不改,前端 `POINT_STATUS_VISUALS.priority` 零改动、无耦合;仅单设备 `deviceStatus()` 按新规则判定。
+5. **交付边界**:提交并推送 `origin/master`。
+
+**本条同时记录一处契约边界(非目标,如实留档)**:`monitoring/situation` 的设备 `metricStatus`(`BusinessCockpitMonitoringSituationAdapter.metricStatus()`)词表为 `NORMAL/WARNING/ALARM/EXPIRED/OFFLINE/UNKNOWN`,**没有 `WORK_ORDER` 桶**,因此本次不改它——改它需要动 situation 响应契约,超出本票范围。
+
+### local — 2026-10-16T19:24:28.181Z
+【实施完成 2026-10-17 —— 已提交并推送 origin/master】
+
+**提交**:`3772220e`(本票) + `5d4d03c6`(编译解阻)→ 已推 `origin/master`(`git ls-remote` 核实远端 `master = 3772220e`,本地/远端 0 ahead/0 behind)。推送前远端已有 2 个新提交,已 `git fetch` + `rebase` 后复跑测试再推(未强推)。
+
+## 交付
+判定单源仍是 `CockpitMonitoringPointStatusResolver.deviceStatus()`:
+报警**且** `isAlarmsCoveredByOpenWorkOrders()` → `WORK_ORDER`,否则维持现行序。
+
+事实由 `BusinessCockpitDeviceFactResolver` 新增计算(新增 `AlarmDataService` 依赖):
+取 `alarm_data.alarm_status=0` 的行,**每一条**都要有 `work_order_id` 且对应工单
+`order_status ∈ {0..4,6}`;无未处理行 / 任一行不满足 / 读失败 → `false`(保守降级为 ALARM)。
+
+**三处透传接缝**(丢任一处结论都会被抹掉,变异 M6/M7 专门钉住):
+`CockpitMonitoringPointDeviceSnapshot`、分布快照 `SourceSmartDevice` + 分布适配器、
+`CockpitMonitoringDistributionService.effectiveDevice()` 重建、`CockpitMonitoringPointDetailService` 归一化。
+
+## 验证
+- `Cockpit*Test,BusinessCockpit*Test` **512/512**(基线 510 + 本票新增 2)。
+- 变异闸 [t52-mutations.mjs](../verify/t52-mutations.mjs) **7/7 打红并按字节还原**;覆盖:判定分支删除、判据放宽为「至少一条」、已结案/已取消算作承接、空集视为已承接、去掉 `alarm_status` 过滤、分布适配器不透传、`effectiveDevice` 重建丢事实。
+- 真实载荷取证 [real-data-consequence.md](../verify/artifacts/t52/real-data-consequence.md)。
+- 前端 t09 闸门 **38/38**(priority 与词表零改动,确认裁定 4「不跟随」成立)。
+
+## 顺带修复(既有阻塞,非本票引入)
+`zksy-system` 在 HEAD 上**根本编译不过**:`SseEmitterManager`(`5bc0c6e4` 引入)用 `SseEmitter`,
+而 `spring-webmvc` 只由 `springfox-spring-webmvc` 以 **provided** 传递(provided 不参与编译期),
+模块编译期缺类。已按 same-scope 显式声明 `provided` 补齐编译期(`5d4d03c6`);运行期无影响
+(boot 包 `BOOT-INF/lib` 本就含该 jar)。**未改任何运行语义。**
+
+## 既有失败(如实登记,与本票零交集)
+`zksy-system` 全量另有 **2 失败 / 1 错误**:`AlarmLifecycleServiceTest.insertWritesLogBeforeCommittingWithoutTrigger`、
+`WorkOrderServiceImplTest.cancelDoesNotWriteSuccessLogWhenStatusChangesConcurrently` / `cancelAllowsOnlyOrdersAwaitingDispatchOrAcceptance`。
+已把本票源码整体 `git stash` 后复跑,**同样 2 失败/1 错误、逐条相同** → 确认为 HEAD 级既有失败。
+
+## 两点如实留档
+1. **票面举例被证伪**:票面写「实测 21119740 按裁定应为工单处置」。实测该设备 2 条未处理报警
+   **都没有工单承接**,按裁定**就应当是 ALARM**。票面把设备级 `workOrderInProgress`(该设备存在任意
+   未结束工单)误当成了「未处理报警已被逐条承接」——两者不是同一事实。
+2. **新规则在当前真实数据上不改变任何设备读数**(今天不存在「未处理且已承接」的报警行)。
+   这是正确行为而非未生效;待业务侧为未处理报警建单后,该设备才会由 `ALARM` 变 `WORK_ORDER`。
+
+## 消费侧影响
+前端**零改动**(裁定 4):`POINT_STATUS_VISUALS`/`DEVICE_STATUS_VISUALS` 的 `priority`
+(ALARM 1 / WARNING 2 / WORK_ORDER 3)与后端枚举一致,本次未动枚举。
+`monitoring/situation` 的 `metricStatus` 词表无 `WORK_ORDER` 桶,仍按现状(已在裁定评论留档为非目标)。
+
+## 1. 交付环境(已具备)
+- 后端仓库:`E:\pipe-ner-server`,远端 `http://192.168.110.30:3000/shaoy/pipe-ner-server.git`,`master = origin/master = 91444f51`(`feat(alarm): 扩展报警指标并重构遥测报警评估逻辑`),工作树干净。
+- 只读数据库可达:KingbaseES `192.168.110.30:54321 / pipi_ner`(schema `app_user`),已用 psql 直连核对(非只看接口自证)。
+- 模块:后端代码在 `pipe-network-service/zksy-system`(域)与 `zksy-admin`(控制器+测试)。
+
+## 2. 权威链(现状)
+`CockpitMonitoringPointStatusResolver`(`zksy-system/src/main/java/com/zksy/cockpit/point/`):
+- `deviceStatus()`:`alarm → warning → workOrderInProgress → online ? NORMAL : OFFLINE`(报警恒压工单)。
+- 枚举 `CockpitMonitoringPointStatus`:ALARM(1)/WARNING(2)/WORK_ORDER(3)/NORMAL(4)/OFFLINE(5)/EMPTY(6)。
+- 调用点(改 `deviceStatus` 即全覆盖):`resolve()` 点位聚合(min priority)、`cards()` 设备卡 deviceStatus、`statistics()` 设备统计计数、`orderedDevices()` 排序、`getPublishedDeviceStatus()`(快照/未关联设备 deviceStatus)。
+
+事实来源 `BusinessCockpitDeviceFactResolver.resolve()`:
+- `alarm` = `equipment_status.alarm_status == 1`(设备级报警标志)。
+- `warning` = `early_warning.status ∈ {PENDING, PROCESSING, HANDLING, RELEASED}`。
+- `workOrderInProgress` = 该设备**任意**一张 `work_order.order_status ∈ {0,1,2,3,4,6}`(0-based;5 已结案、7 已取消为终态)——**不与报警逐条回联**。
+
+## 3. 真实数据(推翻票面前提)
+`alarm_data` 是**报警历史流水**(同一设备的重复报警会一行一条):
+
+| 事实 | 实测 |
+| --- | --- |
+| `alarm_data` 全表行数 / 涉及设备 | 90 行 / 仅 `21119740` 一台 |
+| 未处理(`alarm_status=0`) | 2 行(`BJ202610161600430761`、`BJ202610101851389458`) |
+| 带 `work_order_id` 的行 | 2 行,**且这 2 行 `alarm_status` 都是 1(已处理)** |
+| 「未处理 且 已关联工单」的行 | **0 行** |
+| 已关联工单的两条报警 | `89b53f56…`→WO291(order_status=0 待派单,未结束);`e68a942d…`→WO287(order_status=5 已结案) |
+| 设备 `21119740` | `equipment_status.alarm_status=1`、`online_status=1`;7 张工单,3 张未结束(291=0、286=1、285=3) |
+
+**因此票面「实测设备 21119740(alarm=true, warning=true, workOrderInProgress=true)→ 按裁定应为工单处置(其报警已有工单承接时)」无法成立**:该设备当前的 2 条未处理报警**都没有工单承接**;有工单的那 2 条报警都已经是「已处理」历史记录。
+
+## 4. 为什么必须先裁定
+范围第 1 条要求「明确『所有报警均有对应工单』的判定口径」,范围第 2 条又要求「监测点聚合语义由维护者另行确认」。上述数据事实使这两条**各自都有多种自洽读法、且结果可观测不同**(同一台设备在四种口径下两种判 ALARM、两种判 WORK_ORDER),按纪律不猜。
+另:`CockpitMonitoringPointStatus` 的 priority 同时驱动点位聚合(min)与前端图例排序(前端 `POINT_STATUS_VISUALS.priority` 与 t09 A3 闸门按此锁定),是否调整属维护者裁定范围。
+
+## 5. 尚未动工
+本轮只做诊断与事实核对,**未改任何后端代码**,未提交、未推送。裁定后按单源(`deviceStatus()`)实施并补契约测试。
+
+## Answer
+
+**已实施、验证并推送关闭(2026-10-17)。**
+
+### 裁定口径(维护者 2026-10-17,5 项)
+单设备存在报警(`equipment_status.alarm_status=1`)、且该设备**全部未处理报警**
+(`alarm_data.alarm_status=0`)都满足 `work_order_id` 非空 **且** 对应工单
+`work_order.order_status ∈ {0,1,2,3,4,6}`(未结束;5 已结案、7 已取消为终态)
+→ 判 `WORK_ORDER`;否则维持现行序。无未处理报警行 / 任一行不满足 / 读失败 → 保守判 `ALARM`。
+`resolve()` 聚合与枚举 `priority` **不动**(前端零耦合)。
+
+### 交付
+- 判定单源:`CockpitMonitoringPointStatusResolver.deviceStatus()`。
+- 事实计算:`BusinessCockpitDeviceFactResolver.alarmsCoveredByOpenWorkOrders()`(新增 `AlarmDataService` 依赖)。
+- **三处事实透传接缝**(丢任一处结论会被抹掉):`CockpitMonitoringPointDeviceSnapshot`、
+  分布快照 `SourceSmartDevice` + `BusinessCockpitMonitoringDistributionAdapter`、
+  `CockpitMonitoringDistributionService.effectiveDevice()` 重建、`CockpitMonitoringPointDetailService` 归一化。
+- 提交 `3772220e`(本票)+ `5d4d03c6`(既有编译阻塞修复)→ 已推 `origin/master`(`git ls-remote` 核实远端 = `3772220e`)。
+
+### 验证
+- `Cockpit*Test,BusinessCockpit*Test` **512/512**(基线 510 + 新增 2)。
+- 变异闸 [t52-mutations.mjs](../verify/t52-mutations.mjs) **7/7 打红并按字节还原**;
+  覆盖:判定分支删除、判据放宽为「至少一条」、已结案/已取消算作承接、空集视为已承接、
+  去掉 `alarm_status` 过滤、分布适配器不透传、`effectiveDevice` 重建丢事实。
+- 真实载荷取证 [real-data-consequence.md](../verify/artifacts/t52/real-data-consequence.md)。
+- 前端 t09 闸门 **38/38**(确认裁定 4「不跟随」成立)。
+
+### 两点如实留档
+1. **票面举例被证伪**:票面写「实测 21119740 按裁定应为工单处置」。实测该设备 2 条未处理报警
+   **都没有工单承接**,按裁定**就应当是 ALARM**。票面把设备级 `workOrderInProgress`
+   (该设备存在任意未结束工单)误当成了「未处理报警已被逐条承接」——两者不是同一事实。
+2. **新规则在当前真实数据上不改变任何设备读数**(今天不存在「未处理且已承接」的报警行)。
+   这是正确行为而非未生效;待业务侧为未处理报警建单后,该设备才会由 `ALARM` 变 `WORK_ORDER`。
+
+### 既有失败(与本票零交集)
+`zksy-system` 全量另有 **2 失败 / 1 错误**:`AlarmLifecycleServiceTest.insertWritesLogBeforeCommittingWithoutTrigger`、
+`WorkOrderServiceImplTest.cancelDoesNotWriteSuccessLogWhenStatusChangesConcurrently` /
+`cancelAllowsOnlyOrdersAwaitingDispatchOrAcceptance`。已把本票源码整体 stash 后复跑,
+**同样 2 失败/1 错误、逐条相同** → 确认为 HEAD 级既有失败。
+
+### 下一步
+无。本票交付物已闭环,前端消费侧无需跟随改动。
+

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 20 - 5
.scratch/cockpit-real-data-wayfinding/map.md


+ 94 - 0
.scratch/cockpit-real-data-wayfinding/verify/artifacts/t52/real-data-consequence.md

@@ -0,0 +1,94 @@
+# 票 52 真实数据取证(单设备「全部未处理报警由未结束工单承接」)
+
+- 后端仓库:`E:\pipe-ner-server`(Gogs `shaoy/pipe-ner-server`)
+- 实施提交:`3772220e`(已推 `origin/master`,前一条 `5d4d03c6` 为编译解阻)
+- 数据库:KingbaseES `192.168.110.30:54321 / pipi_ner`(schema `app_user`),**只读**查询
+- 取证时间:2026-10-17
+
+## 1. 判定口径(维护者 2026-10-17 裁定)
+
+设备存在报警(`equipment_status.alarm_status = 1`)、且该设备**全部未处理报警**
+(`alarm_data.alarm_status = 0`)都满足:`work_order_id` 非空 **且** 对应工单
+`work_order.order_status ∈ {0,1,2,3,4,6}`(未结束;5 已结案、7 已取消为终态)
+→ 单设备状态判 `WORK_ORDER`;否则维持现行序(报警优先)。
+
+## 2. 库内事实(复算 SQL 与结果)
+
+未处理报警与其工单承接情况:
+
+| 设备 | 未处理报警 | 其中已关联未结束工单 | 新规则判定 |
+| --- | --- | --- | --- |
+| `21119740` | 2 | 0 | **ALARM(维持现行序)** |
+| 其余 8 台 | 0 | 0 | 无报警标志,走常规序 |
+
+- `alarm_data` 全表 90 行,**只涉 1 台设备** `21119740`(报警历史流水,重复报警一行一条)。
+- 全表仅 2 行带 `work_order_id`,且这 2 行 `alarm_status` **都是 1(已处理)**:
+  - `89b53f56…` → WO291(`order_status=0` 待派单,未结束)
+  - `e68a942d…` → WO287(`order_status=5` 已结案)
+- 「未处理 **且** 已关联工单」= **0 行**。
+
+复算 SQL:
+
+```sql
+with dev as (
+  select eb.equipment_id, eb.equipment_code,
+         (es.alarm_status = 1) as device_alarm
+  from app_user.equipment_base eb
+  left join app_user.equipment_status es on es.equipment_id = eb.equipment_id
+),
+u as (
+  select d.equipment_code,
+         count(a.id) as unhandled_cnt,
+         count(a.work_order_id) as with_wo,
+         count(*) filter (where a.work_order_id is not null
+                            and w.order_status in (0,1,2,3,4,6)) as with_open_wo
+  from dev d
+  left join app_user.alarm_data a
+         on a.device_code = d.equipment_code and a.alarm_status = 0
+  left join app_user.work_order w on w.order_id = a.work_order_id
+  group by d.equipment_code
+)
+select d.equipment_code, d.device_alarm,
+       coalesce(u.unhandled_cnt,0) unhandled,
+       coalesce(u.with_open_wo,0) covered,
+       case when d.device_alarm and coalesce(u.unhandled_cnt,0) > 0
+                  and u.unhandled_cnt = u.with_open_wo
+            then 'WORK_ORDER(新规则命中)'
+            when d.device_alarm then 'ALARM(维持现行序)'
+            else '(无报警标志)' end as new_status
+from dev d left join u on u.equipment_code = d.equipment_code
+order by d.equipment_code;
+```
+
+实测输出:
+
+```
+equipment_code        |device_alarm|unhandled|covered|new_status
+21119740              |t           |2        |0      |ALARM(维持现行序)
+41010500001320000001  |f           |0        |0      |(无报警标志)
+41010500000000000002  |f           |0        |0      |(无报警标志)
+860347065467627       |f           |0        |0      |(无报警标志)
+8652052334            |f           |0        |0      |(无报警标志)
+TEST-CBLL222222222    |f           |0        |0      |(无报警标志)
+TEST-CBYL5432168      |f           |0        |0      |(无报警标志)
+TEST-CNXHS0000001     |f           |0        |0      |(无报警标志)
+TEST-TCLLJ002215      |f           |0        |0      |(无报警标志)
+```
+
+## 3. 结论(如实记录,不夸大)
+
+1. 新规则在**当前真实数据上不改变任何设备的状态**——因为今天不存在「未处理且已承接」的报警。
+   部署后的现场读数与部署前一致,这是**正确行为**,不是「没生效」。
+2. 新规则的效果会在业务侧为未处理报警建单后显现:届时该设备由 `ALARM` 变 `WORK_ORDER`。
+3. **票面举例不可复现**:票面写「实测 21119740(alarm/warning/workOrderInProgress 三 true)
+   按裁定应为工单处置」——该设备 2 条未处理报警**都没有工单承接**,按裁定**就应当是 ALARM**。
+   票面那句话把「设备存在未结束工单」(`workOrderInProgress`,设备级任意一张)误当成了
+   「未处理报警已被工单承接」(逐条回联),二者不是同一事实。
+
+## 4. 验证
+
+- `Cockpit*Test,BusinessCockpit*Test` **512/512** 全绿(基线 510 + 本票新增 2)。
+- 变异闸 `verify/t52-mutations.mjs` **7/7** 打红并按字节还原(基线绿 → 变异红 → 还原绿)。
+- `zksy-system` 全量另有 **2 失败 / 1 错误**(`AlarmLifecycleServiceTest`、
+  `WorkOrderServiceImplTest`),已按 HEAD 对照(还原本票源码后复跑)确认**为既有失败**,
+  与本票改动零交集。

+ 168 - 0
.scratch/cockpit-real-data-wayfinding/verify/t52-mutations.mjs

@@ -0,0 +1,168 @@
+/**
+ * 票 52 变异闸:证明契约测试对「单设备工单处置优先」这条规则**有牙**。
+ *
+ * 每条变异:按字节把源码改成**规则失效**的形态 → 跑
+ * BusinessCockpitDeviceFactResolverTest → 必须转红 → 再按字节还原。
+ * 全部转红、且还原后复绿,闸门才算通过。
+ *
+ * 两个必须踩住的坑(首版都踩了,留档):
+ *  1. 用 `mvn -pl zksy-admin test` 会把 zksy-system 从**本地仓库 jar** 解析,
+ *     源码变异根本进不了测试 —— 必须 `-pl zksy-admin -am` 走 reactor 重新编译。
+ *  2. Windows 上 `mvn` 是 `mvn.cmd`,`execFileSync('mvn', ...)` 直接 ENOENT;
+ *     那样每次"失败"都会被误判成"打红"。这里显式用 shell + mvn.cmd 路径,
+ *     并把「构建失败」与「测试计数含非零失败」分开判定。
+ *
+ * 用法:node .scratch/cockpit-real-data-wayfinding/verify/t52-mutations.mjs
+ */
+import { execSync } from 'node:child_process'
+import { readFileSync, writeFileSync } from 'node:fs'
+
+const BACKEND = 'E:/pipe-ner-server'
+const MODULE = 'pipe-network-service'
+/**
+ * 变异闸覆盖**三个测试类**:判定单源(resolver/facts)、事实透传接缝(分布适配器)、
+ * 以及服务层重建设备时的透传(分布控制器)。少了最后一层,
+ * 「effectiveDevice 丢事实」这类变异抓不住 —— 首版就是这么漏掉 M7 的。
+ */
+const TEST = 'BusinessCockpitDeviceFactResolverTest,BusinessCockpitMonitoringDistributionAdapterTest,CockpitMonitoringDistributionControllerTest'
+const MVN = 'D:\\Maven\\apache-maven-3.9.15\\bin\\mvn.cmd'
+
+const RESOLVER = `${BACKEND}/${MODULE}/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitMonitoringPointStatusResolver.java`
+const FACTS = `${BACKEND}/${MODULE}/zksy-system/src/main/java/com/zksy/cockpit/point/BusinessCockpitDeviceFactResolver.java`
+const DIST_ADAPTER = `${BACKEND}/${MODULE}/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringDistributionAdapter.java`
+const DIST_SERVICE = `${BACKEND}/${MODULE}/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionService.java`
+
+const read = (p) => readFileSync(p, 'utf8')
+const write = (p, s) => writeFileSync(p, s)
+const toLf = (s) => s.replace(/\r\n/g, '\n')
+
+const mutations = [
+    {
+        id: 'M1',
+        name: 'deviceStatus 退回「报警恒压工单」(删掉承接分支)',
+        file: RESOLVER,
+        from: `            return device.isAlarmsCoveredByOpenWorkOrders()
+                    ? CockpitMonitoringPointStatus.WORK_ORDER
+                    : CockpitMonitoringPointStatus.ALARM;`,
+        to: `            return CockpitMonitoringPointStatus.ALARM;`,
+    },
+    {
+        id: 'M2',
+        name: '承接判据放宽为「至少一条」(逐条比对改为只看非空)',
+        file: FACTS,
+        from: `            if (orderIds.size() != unhandled.size()) {
+                return false;
+            }`,
+        to: `            if (orderIds.isEmpty()) {
+                return false;
+            }`,
+    },
+    {
+        id: 'M3',
+        name: '把已结案/已取消也当成承接(isOpenOrderStatus 恒真)',
+        file: FACTS,
+        from: `        return (orderStatus >= 0 && orderStatus <= 4) || orderStatus == 6;`,
+        to: `        return true;`,
+    },
+    {
+        id: 'M4',
+        name: '空集视为「全部都有工单」(unhandled.isEmpty 返回 true)',
+        file: FACTS,
+        from: `            if (unhandled.isEmpty()) {
+                return false;
+            }`,
+        to: `            if (unhandled.isEmpty()) {
+                return true;
+            }`,
+    },
+    {
+        id: 'M5',
+        name: '报警范围放宽为全部历史流水(去掉 alarm_status 过滤)',
+        file: FACTS,
+        from: `                    .eq(AlarmData::getDeviceCode, deviceCode.trim())
+                    .eq(AlarmData::getAlarmStatus, UNHANDLED_ALARM_STATUS)));`,
+        to: `                    .eq(AlarmData::getDeviceCode, deviceCode.trim())));`,
+    },
+    {
+        id: 'M6',
+        name: '分布适配器不再透传承接事实(快照丢事实 → 出口永远判 ALARM)',
+        file: DIST_ADAPTER,
+        from: `                    CockpitCreatedAt.format(device.getCreateTime()),
+                    fact.isAlarmsCoveredByOpenWorkOrders()));`,
+        to: `                    CockpitCreatedAt.format(device.getCreateTime()),
+                    false));`,
+    },
+    {
+        id: 'M7',
+        name: '分布服务 effectiveDevice 重建时丢承接事实',
+        file: DIST_SERVICE,
+        from: `                        : "ONLINE".equals(runtime.monitoringState) || "OFFLINE".equals(runtime.monitoringState),
+                source.isAlarmsCoveredByOpenWorkOrders());`,
+        to: `                        : "ONLINE".equals(runtime.monitoringState) || "OFFLINE".equals(runtime.monitoringState),
+                false);`,
+    },
+]
+
+/** 跑一次测试;返回 { ran, green } —— ran=false 表示构建没跑到测试(不能算证据)。 */
+const runTest = () => {
+    let out = ''
+    try {
+        out = execSync(
+            `"${MVN}" -o -pl zksy-admin -am test -Dtest=${TEST} -DfailIfNoTests=false`,
+            { cwd: `${BACKEND}/${MODULE}`, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
+    } catch (error) {
+        out = `${error.stdout ?? ''}${error.stderr ?? ''}`
+    }
+    const summary = out.match(/Tests run: (\d+), Failures: (\d+), Errors: (\d+)/g)
+    if (!summary || summary.length === 0) {
+        return { ran: false, green: false, out }
+    }
+    const last = summary[summary.length - 1]
+    const [, total, failures, errors] = last.match(/Tests run: (\d+), Failures: (\d+), Errors: (\d+)/)
+    return { ran: true, green: Number(failures) === 0 && Number(errors) === 0 && Number(total) > 0, out }
+}
+
+const baseline = runTest()
+if (!baseline.ran || !baseline.green) {
+    console.log('基线不绿,闸门无法判定:先修好源码/测试再跑变异。')
+    process.exit(1)
+}
+console.log('基线绿(未变异时测试全过),开始变异…\n')
+
+let red = 0
+let broken = 0
+const originals = new Map()
+
+for (const m of mutations) {
+    if (!originals.has(m.file)) originals.set(m.file, read(m.file))
+    const original = originals.get(m.file)
+    const crlf = original.includes('\r\n')
+    const norm = toLf(original)
+    if (!norm.includes(m.from)) {
+        console.log(`SKIP ${m.id} — anchor 未命中(源码已变,需同步变异脚本):${m.name}`)
+        broken += 1
+        continue
+    }
+    write(m.file, crlf ? norm.replace(m.from, m.to).replace(/\n/g, '\r\n') : norm.replace(m.from, m.to))
+
+    const result = runTest()
+    if (!result.ran) {
+        console.log(`FAIL ${m.id} — 构建没跑到测试,不能算打红:${m.name}`)
+        broken += 1
+    } else if (!result.green) {
+        red += 1
+        console.log(`PASS ${m.id} — 变异被打红:${m.name}`)
+    } else {
+        console.log(`FAIL ${m.id} — 变异未被抓住(测试无牙):${m.name}`)
+    }
+
+    write(m.file, original)
+    if (read(m.file) !== original) {
+        console.log(`FAIL ${m.id} — 还原后字节不一致`)
+        broken += 1
+    }
+}
+
+const final = runTest()
+console.log(`\n变异打红 ${red}/${mutations.length};还原后复绿:${final.green ? '是' : '否'};异常 ${broken}`)
+process.exit(red === mutations.length && final.green && broken === 0 ? 0 : 1)

+ 82 - 0
.scratch/cockpit-real-data-wayfinding/verify/t52-t45-baseline-check.mjs

@@ -0,0 +1,82 @@
+/**
+ * 票 52 收口自查:t45 的 4 条 C3x 失败是否为「既有」。
+ *
+ * t45 的 C3b/C3c/C3d/C3f 是对 `markerOverlay.js` 的**静态源码断言**,而该文件在本
+ * 工作树是并行在制品(脏)。本票只改后端仓库 7 个 Java 文件,从未碰前端 src。
+ * 这里把 HEAD 版本的 markerOverlay.js 取出来,逐条复跑同一批断言:
+ * 若 HEAD 版本同样失败 → 既有失败,与本票零交集。
+ *
+ * 用法:node .scratch/cockpit-real-data-wayfinding/verify/t52-t45-baseline-check.mjs
+ */
+import { execFileSync } from 'node:child_process'
+import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+const REPO = 'E:/City-Lifeline-Cockpit-TvT'
+const OVERLAY_REL = 'src/modules/cockpit/services/markerOverlay.js'
+const T45_REL = '.scratch/cockpit-real-data-wayfinding/verify/t45-state-axis-and-effects.mjs'
+
+// 1. HEAD 版 markerOverlay.js
+const headOverlay = execFileSync('git', ['show', `HEAD:${OVERLAY_REL}`],
+    { cwd: REPO, encoding: 'utf8' })
+
+// 2. 在仓库内建一个临时副本,脚本相对路径保持可用(放同目录层级)
+const dir = mkdtempSync(join(REPO, '.t52-tmp-'))
+const overlayCopy = join(dir, 'markerOverlay.HEAD.js')
+writeFileSync(overlayCopy, headOverlay)
+
+// 3. 取 t45 脚本,把读 markerOverlay 的那一行指向 HEAD 副本;其余读取保持原样
+const t45 = readFileSync(join(REPO, T45_REL), 'utf8')
+const ANCHOR = "read('src/modules/cockpit/services/markerOverlay.js')"
+if (!t45.includes(ANCHOR)) {
+    console.log('锚点未命中:t45 脚本已变,需同步本自查脚本')
+    process.exit(1)
+}
+// 脚本用 new URL(`../../../${rel}`) 基于脚本位置解析;临时脚本仍放回原目录以便相对导入
+const patched = t45.replace(ANCHOR, `read('.t52-tmp-${dir.split('.t52-tmp-')[1]}/markerOverlay.HEAD.js')`)
+const patchedPath = join(REPO, '.scratch/cockpit-real-data-wayfinding/verify/.t52-t45-baseline.mjs')
+writeFileSync(patchedPath, patched)
+
+let out = ''
+try {
+    out = execFileSync('node', [patchedPath], { cwd: REPO, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
+} catch (error) {
+    out = `${error.stdout ?? ''}${error.stderr ?? ''}`
+}
+
+const fails = out.split('\n').filter((line) => line.startsWith('FAIL C3'))
+const headFailed = new Set(fails.map((line) => line.split(' ')[1]))
+const summary = out.split('\n').find((line) => line.includes('状态双轴与特效检查')) ?? '(无汇总行)'
+console.log(`HEAD 版 markerOverlay.js 上,C3x 静态断言失败 ${fails.length} 条:`)
+fails.forEach((line) => console.log(`  ${line}`))
+console.log(`汇总:${summary}`)
+
+// 再跑一次**当前工作树**,取它失败的那批
+let worktreeOut = ''
+try {
+    worktreeOut = execFileSync('node', [T45_REL], { cwd: REPO, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
+} catch (error) {
+    worktreeOut = `${error.stdout ?? ''}${error.stderr ?? ''}`
+}
+const wtFails = new Set(worktreeOut.split('\n')
+    .filter((line) => line.startsWith('FAIL C3'))
+    .map((line) => line.split(' ')[1]))
+const wtSummary = worktreeOut.split('\n').find((line) => line.includes('状态双轴与特效检查')) ?? '(无汇总行)'
+console.log(`\n当前工作树(并行在制品)失败 ${wtFails.size} 条:${[...wtFails].join(', ') || '(无)'}`)
+console.log(`汇总:${wtSummary}`)
+
+// 清理
+execFileSync('node', ['-e', `require('fs').rmSync(${JSON.stringify(dir)},{recursive:true,force:true});require('fs').rmSync(${JSON.stringify(patchedPath)},{force:true})`])
+console.log('\n已清理临时文件。')
+
+// 判据:工作树失败集必须是 HEAD 失败集的**子集**(本票没引入任何新失败)。
+// 注意工作树是并行在制品,逐条数可以比 HEAD 少(正在修),但不能多出 HEAD 没有的。
+const newFails = [...wtFails].filter((id) => !headFailed.has(id))
+if (newFails.length === 0) {
+    console.log(`判定:工作树 ${wtFails.size} 条失败 ⊂ HEAD ${headFailed.size} 条失败 → 全部既有;`
+        + '票 52 是纯后端改动(本会话未碰前端 src),未引入任何新前端失败。')
+    process.exit(0)
+}
+console.log(`注意:工作树出现 HEAD 上没有的新失败 ${newFails.join(', ')} → 需人工复核。`)
+process.exit(1)

Vissa filer visades inte eftersom för många filer har ändrats