|
|
@@ -28,17 +28,57 @@
|
|
|
|
|
|
#### 2.1.1 定时轮灌
|
|
|
|
|
|
+**重要说明:一个任务可以同时配置多个定时规则,模式 A 和模式 B 可以共存,每种模式也可以配置多条。**
|
|
|
+
|
|
|
**模式 A:按星期 + 时间重复**
|
|
|
|
|
|
- 用户选择星期(可多选,如:周一、周三、周五)和具体时刻(如 02:00)。
|
|
|
- 系统自动将配置转换为 Cron 表达式存储。
|
|
|
- 适用场景:固定周期的常规灌溉计划。
|
|
|
+- **可配置多条**:例如可以同时配置"周一三五凌晨2点"和"周二四六下午5点"两个规则。
|
|
|
|
|
|
**模式 B:启动时间 + 执行次数 + 执行周期**
|
|
|
|
|
|
- 用户配置起始时间、执行总次数(如 10 次)、执行间隔周期(如每 3 天)。
|
|
|
- 系统使用简单调度器(SimpleScheduleBuilder)管理,记录已执行次数,达到上限后自动停用任务。
|
|
|
- 适用场景:临时性或有限次数的灌溉计划。
|
|
|
+- **可配置多条**:例如可以同时配置"从3月1日开始每天执行10次"和"从3月15日开始每2天执行5次"两个规则。
|
|
|
+
|
|
|
+**多规则配置示例:**
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "taskName": "农场A区智能轮灌",
|
|
|
+ "scheduleRules": [
|
|
|
+ {
|
|
|
+ "ruleName": "每周一三五凌晨2点",
|
|
|
+ "scheduleType": "CRON",
|
|
|
+ "cronExpression": "0 0 2 ? * MON,WED,FRI",
|
|
|
+ "enabled": true
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "ruleName": "每月1号和15号",
|
|
|
+ "scheduleType": "CRON",
|
|
|
+ "cronExpression": "0 0 2 1,15 * ?",
|
|
|
+ "enabled": true
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "ruleName": "连续10天灌溉计划",
|
|
|
+ "scheduleType": "SIMPLE",
|
|
|
+ "startTime": "2026-03-10 06:00:00",
|
|
|
+ "intervalDays": 1,
|
|
|
+ "totalTimes": 10,
|
|
|
+ "enabled": true
|
|
|
+ }
|
|
|
+ ]
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**技术实现:**
|
|
|
+- 每个定时规则存储在独立的 `task_schedule_rule` 表中
|
|
|
+- 每个规则对应一个独立的 Quartz Job 和 Trigger
|
|
|
+- 规则之间互不干扰,任一规则故障不影响其他规则
|
|
|
+- 所有规则触发时共享同一并发触发防护机制(§6.6)
|
|
|
|
|
|
#### 2.1.2 联动控制(传感器触发)
|
|
|
|
|
|
@@ -528,15 +568,15 @@
|
|
|
|
|
|
**三层职责对照表:**
|
|
|
|
|
|
-| 用户配置的(意图) | 计划生成引擎转换为(物理执行节点) |
|
|
|
-| --------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
|
|
-| "水泵恒压-统一,300kPa" | 自动在 Zone1 成功后插入 `SET_PUMP_PRESSURE(300)` → `START_PUMP`,仅下发一次 |
|
|
|
-| "水泵恒压-分区" | Zone1 后插入 `SET_PUMP_PRESSURE(zone1.压力)` → `START_PUMP`,每次灌区切换时追加 `SET_PUMP_PRESSURE(新压力)` |
|
|
|
-| "球阀配置了目标压力" | 压力参数随 `OPEN_GROUP` 下发给球阀,与水泵压力模式无关,两者独立生效 |
|
|
|
+| 用户配置的(意图) | 计划生成引擎转换为(物理执行节点) |
|
|
|
+| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
|
|
+| "水泵恒压-统一,300kPa" | 自动在 Zone1 成功后插入 `SET_PUMP_PRESSURE(300)` → `START_PUMP`,仅下发一次 |
|
|
|
+| "水泵恒压-分区" | Zone1 后插入 `SET_PUMP_PRESSURE(zone1.压力)` → `START_PUMP`,每次灌区切换时追加 `SET_PUMP_PRESSURE(新压力)` |
|
|
|
+| "球阀配置了目标压力" | 压力参数随 `OPEN_GROUP` 下发给球阀,与水泵压力模式无关,两者独立生效 |
|
|
|
| "选择灌区组1→2→3,各配时长" | 自动展开为 `OPEN(1)→START_PUMP→WAIT(时长1)→OPEN(2)→ZONE_SWITCH_WAIT→CLOSE(1)→WAIT(时长2)→...→STOP_PUMP→CLOSE(N)`,严格遵循先开后关 |
|
|
|
-| "稳压等待 5 秒" | 在每个 `OPEN_GROUP(ZoneI)` 成功后、`CLOSE_GROUP(ZoneI-1)` 之前插入 `ZONE_SWITCH_WAIT(5s)` |
|
|
|
-| "跳过灌区2(运行时)" | 截断后续节点,重新生成,安全衔接已开启灌区 |
|
|
|
-| "人工修复后继续" | PAUSED 状态接受 Resume API,检测泵状态,自动插入 S-05 重建序列后接续执行 |
|
|
|
+| "稳压等待 5 秒" | 在每个 `OPEN_GROUP(ZoneI)` 成功后、`CLOSE_GROUP(ZoneI-1)` 之前插入 `ZONE_SWITCH_WAIT(5s)` |
|
|
|
+| "跳过灌区2(运行时)" | 截断后续节点,重新生成,安全衔接已开启灌区 |
|
|
|
+| "人工修复后继续" | PAUSED 状态接受 Resume API,检测泵状态,自动插入 S-05 重建序列后接续执行 |
|
|
|
|
|
|
---
|
|
|
|
|
|
@@ -1004,7 +1044,7 @@ public class PlanGenerator {
|
|
|
| ------- | -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- |
|
|
|
| Phase 2 | START_PUMP 前必须已有 OPEN_GROUP(S-01);PUMP_UNIFIED/PUMP_ZONE 模式必须有 SET_PUMP_PRESSURE;NONE 模式不插入 SET_PUMP_PRESSURE | 抛出 SafetyViolationException |
|
|
|
| Phase 3 | CLOSE_GROUP 前必须已有对应的 OPEN_GROUP 且中间有 ZONE_SWITCH_WAIT(S-02) | 抛出 SafetyViolationException |
|
|
|
-| Phase 4 | CLOSE_GROUP(ZoneN) 必须在 STOP_PUMP 之后(S-03) | 抛出 SafetyViolationException |
|
|
|
+| Phase 4 | CLOSE_GROUP(ZoneN) 必须在 STOP_PUMP 之后(S-03) | 抛出 SafetyViolationException |
|
|
|
|
|
|
> **设计意图**:安全检查在生成时一次性验证,运行时执行引擎无需重复校验,保证执行路径的确定性。
|
|
|
|
|
|
@@ -1120,12 +1160,12 @@ Thread.sleep方案:
|
|
|
|
|
|
> **施肥消息类型**:施肥子任务通过独立的 MQ 延迟消息驱动,不在主节点链路中,因此不作为主链路节点类型。施肥相关的 MQ 消息类型定义如下:
|
|
|
|
|
|
-| 施肥消息类型 | 说明 | 触发时机 |
|
|
|
-| ------------------ | -------------------------------------------------- | -------------------------------- |
|
|
|
-| `FERT_STIR_START` | 搅拌电机启动 | 施肥延迟时间到达后触发 |
|
|
|
-| `FERT_PUMP_START` | 施肥泵启动 | 搅拌提前时长到达后触发 |
|
|
|
-| `FERT_PUMP_STOP` | 施肥泵+搅拌电机停止(仅时间模式) | 施肥时长到达后触发 |
|
|
|
-| `FERT_FORCE_STOP` | 强制关闭施肥设备(灌区结束/切换时兜底) | 灌区 WAIT 完成或灌区切换时触发 |
|
|
|
+| 施肥消息类型 | 说明 | 触发时机 |
|
|
|
+| ----------------- | --------------------------------------- | ------------------------------ |
|
|
|
+| `FERT_STIR_START` | 搅拌电机启动 | 施肥延迟时间到达后触发 |
|
|
|
+| `FERT_PUMP_START` | 施肥泵启动 | 搅拌提前时长到达后触发 |
|
|
|
+| `FERT_PUMP_STOP` | 施肥泵+搅拌电机停止(仅时间模式) | 施肥时长到达后触发 |
|
|
|
+| `FERT_FORCE_STOP` | 强制关闭施肥设备(灌区结束/切换时兜底) | 灌区 WAIT 完成或灌区切换时触发 |
|
|
|
|
|
|
#### 5.3.2 NodeHandler 统一接口
|
|
|
|
|
|
@@ -1369,6 +1409,561 @@ Body:(无参数)
|
|
|
|
|
|
---
|
|
|
|
|
|
+### 5.6 灌区视图层设计(Zone View Layer)
|
|
|
+
|
|
|
+#### 5.6.1 问题背景
|
|
|
+
|
|
|
+执行引擎工作在"物理节点"层次,按顺序逐个执行 `OPEN_GROUP`、`START_PUMP`、`WAIT`、`CLOSE_GROUP` 等节点。但客户需要看到的是"灌区"层次的进度:**现在在灌哪个灌区、已经灌了多久、有没有出错、出错在哪里**。
|
|
|
+
|
|
|
+两层之间存在以下核心矛盾:
|
|
|
+
|
|
|
+| 维度 | 物理节点层(执行引擎视图) | 灌区层(客户视图) |
|
|
|
+| ---- | ------------------------ | ---------------- |
|
|
|
+| 粒度 | 每个 MQTT 指令为一个节点 | 灌区为单位 |
|
|
|
+| 顺序 | 严格线性,含安全插入节点 | 灌区列表顺序 |
|
|
|
+| 归属 | `START_PUMP`、`SET_PUMP_PRESSURE` 无灌区归属 | 需映射到某灌区 |
|
|
|
+| 施肥 | 并行 FertTask,不在主链路 | 作为灌区子状态展示 |
|
|
|
+| 失败 | 记录失败节点 index | 需翻译为灌区+失败原因 |
|
|
|
+
|
|
|
+**解决方案总览:**
|
|
|
+
|
|
|
+```
|
|
|
+物理节点层(execution_plan.nodes)
|
|
|
+ OPEN_GROUP(Z1) → SET_PUMP_PRESSURE → START_PUMP → WAIT(Z1) → OPEN_GROUP(Z2)
|
|
|
+ → ZONE_SWITCH_WAIT → CLOSE_GROUP(Z1) → WAIT(Z2) → ... → STOP_PUMP → CLOSE_GROUP(ZN)
|
|
|
+ │
|
|
|
+ │ ZonePlanInterpreter(§5.7)
|
|
|
+ ▼
|
|
|
+灌区视图层(zone_logs + 进度 API)
|
|
|
+ [灌区1: SUCCESS, 实灌3min] [灌区2: IRRIGATING, 已灌1m20s] [灌区3: PENDING]
|
|
|
+```
|
|
|
+
|
|
|
+#### 5.6.2 节点归属标注规则(zoneIndex / zonePhase)
|
|
|
+
|
|
|
+Phase 5(NodeIndexer)生成节点时,为每个节点增加两个元数据字段,供解析器反向推导灌区视图:
|
|
|
+
|
|
|
+| 节点类型 | zoneIndex 归属 | zonePhase |
|
|
|
+| ------- | -------------- | --------- |
|
|
|
+| `OPEN_GROUP(Z0)`(首个灌区启动) | 0 | `STARTING` |
|
|
|
+| `SET_PUMP_PRESSURE`(Zone1 首次启动后) | 0 | `STARTING` |
|
|
|
+| `START_PUMP`(首次启动) | 0 | `STARTING` |
|
|
|
+| `WAIT(source=IRRIGATE)`(ZI 灌溉等待) | I | `IRRIGATING` |
|
|
|
+| `OPEN_GROUP(ZI)`(灌区切换时,I≥1) | I | `STARTING` |
|
|
|
+| `SET_PUMP_PRESSURE`(PUMP_ZONE 切换时) | I | `STARTING` |
|
|
|
+| `ZONE_SWITCH_WAIT`(ZI-1 → ZI 切换) | I-1 | `SWITCHING` |
|
|
|
+| `CLOSE_GROUP(ZI)`(非最后灌区) | I | `SWITCHING` |
|
|
|
+| `STOP_PUMP`(最后灌区结束) | N-1 | `STOPPING` |
|
|
|
+| `CLOSE_GROUP(ZN)`(最后灌区) | N-1 | `STOPPING` |
|
|
|
+
|
|
|
+> **说明**:`zoneIndex` 是 `zone_sequence` 数组的位置(0-based),而非灌区ID。`ZONE_SWITCH_WAIT` 归属 ZI-1,因其语义是"上一灌区切换出去的稳压过渡期",在客户视图中属于上一灌区的最后阶段。
|
|
|
+
|
|
|
+#### 5.6.3 灌区阶段枚举(ZonePhase)
|
|
|
+
|
|
|
+客户视图中每个灌区经历以下阶段,由解析器(§5.7)根据 `current_index` 对应节点的 `zonePhase` 字段推导:
|
|
|
+
|
|
|
+| 阶段 | 说明 | 包含的物理节点 | 客户展示文案 |
|
|
|
+| ---- | ---- | ------------ | ---------- |
|
|
|
+| `PENDING` | 未开始 | 无 | 等待中 |
|
|
|
+| `STARTING` | 启动中(开阀、启泵) | `OPEN_GROUP` + `SET_PUMP_PRESSURE` + `START_PUMP` | 开启灌区中… |
|
|
|
+| `IRRIGATING` | 灌溉中(含施肥子状态) | `WAIT(IRRIGATE)` | 灌溉中 X分Y秒 |
|
|
|
+| `SWITCHING` | 切换中(稳压过渡,属于本灌区收尾) | `ZONE_SWITCH_WAIT` + `CLOSE_GROUP`(非末区) | 切换至下一灌区… |
|
|
|
+| `STOPPING` | 结束关闭中 | `STOP_PUMP` + `CLOSE_GROUP(ZN)` | 正在关闭… |
|
|
|
+| `SUCCESS` | 本灌区正常完成 | — | ✅ 已完成,实灌 Xmin |
|
|
|
+| `FAILED` | 本灌区失败 | — | ❌ 失败:[原因] |
|
|
|
+| `SKIPPED` | 被跳过(§5.4) | — | ⏭ 已跳过 |
|
|
|
+
|
|
|
+#### 5.6.4 施肥机作为灌区子状态
|
|
|
+
|
|
|
+施肥机以 FertTask 并行运行,不在主节点链路中,因此**不作为独立灌区阶段展示**,而是作为灌区 `IRRIGATING` 阶段的子状态:
|
|
|
+
|
|
|
+```
|
|
|
+灌区2 ── IRRIGATING(已灌溉 1分20秒 / 3分钟)
|
|
|
+ └─ 施肥子状态: FERT_RUNNING(施肥泵运行中)
|
|
|
+```
|
|
|
+
|
|
|
+FertTask 处理器每次状态变更时,通过以下路径将施肥状态回写到 `zone_logs`:
|
|
|
+
|
|
|
+```
|
|
|
+FertTask 状态变更
|
|
|
+ │
|
|
|
+ ▼
|
|
|
+[查找 zone_logs 中 zoneId 匹配的记录]
|
|
|
+ │
|
|
|
+ ▼
|
|
|
+[更新 zone_logs[i].fertPhase]
|
|
|
+[更新 zone_logs[i].stirStartAt / pumpStartAt / pumpStopAt]
|
|
|
+ │
|
|
|
+ ▼
|
|
|
+[UPDATE task_execution.execution_plan JSON(乐观锁)]
|
|
|
+```
|
|
|
+
|
|
|
+#### 5.6.5 水泵与全局节点的失败处理
|
|
|
+
|
|
|
+全局节点(`START_PUMP`/`STOP_PUMP`/`SET_PUMP_PRESSURE`)失败时,按以下规则归入对应灌区的失败记录:
|
|
|
+
|
|
|
+| 失败节点 | 归属 zoneIndex | failPhase | 处理策略 |
|
|
|
+| ------- | -------------- | --------- | ------- |
|
|
|
+| `START_PUMP` 失败 | 0(第一灌区) | `STARTING` | 触发安全关闭,任务 FAILED |
|
|
|
+| `SET_PUMP_PRESSURE` 失败 | 当前节点 zoneIndex | `STARTING` | 写入警告,灌溉继续(不阻断主流程) |
|
|
|
+| `STOP_PUMP` 失败 | N-1(最后灌区) | `STOPPING` | 记录报警,尽力而为(§6.4.2) |
|
|
|
+| 施肥泵/搅拌电机失败 | FertTask 绑定灌区 | `IRRIGATING` | 写入 fertFailReason,不影响主流程 |
|
|
|
+
|
|
|
+> **`SET_PUMP_PRESSURE` 失败策略**:压力设置失败不中断灌溉,仅触发告警,在 `zone_logs` 对应记录写入 `pumpPressureWarning` 字段。灌溉可在非目标压力下继续完成,避免辅助节点失败导致任务整体中止。
|
|
|
+
|
|
|
+#### 5.6.6 客户进度视图 API
|
|
|
+
|
|
|
+```
|
|
|
+GET /task-execution/{id}/progress
|
|
|
+
|
|
|
+响应:
|
|
|
+{
|
|
|
+ "executionId": 10001,
|
|
|
+ "taskName": "农场A区轮灌",
|
|
|
+ "status": "RUNNING",
|
|
|
+ "currentNodeIndex": 7,
|
|
|
+ "currentNodeType": "WAIT",
|
|
|
+
|
|
|
+ // 灌区层进度(由 ZonePlanInterpreter 推导,见 §5.7)
|
|
|
+ "totalZones": 3,
|
|
|
+ "currentZoneIndex": 1,
|
|
|
+ "currentZoneName": "灌区2",
|
|
|
+ "currentZonePhase": "IRRIGATING",
|
|
|
+ "irrigationElapsedSeconds": 80,
|
|
|
+ "irrigationTotalSeconds": 180,
|
|
|
+
|
|
|
+ // 施肥子状态(仅 IRRIGATING 阶段有值)
|
|
|
+ "currentFertPhase": "FERT_RUNNING",
|
|
|
+
|
|
|
+ // 各灌区摘要(与客户配置表单的灌区列表一一对应)
|
|
|
+ "zones": [
|
|
|
+ {
|
|
|
+ "zoneId": 101, "zoneName": "灌区1",
|
|
|
+ "phase": "SUCCESS",
|
|
|
+ "actualIrrigationSeconds": 180,
|
|
|
+ "fertPhase": "DONE",
|
|
|
+ "failReason": null
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "zoneId": 102, "zoneName": "灌区2",
|
|
|
+ "phase": "IRRIGATING",
|
|
|
+ "actualIrrigationSeconds": 80,
|
|
|
+ "fertPhase": "FERT_RUNNING",
|
|
|
+ "failReason": null
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "zoneId": 103, "zoneName": "灌区3",
|
|
|
+ "phase": "PENDING",
|
|
|
+ "actualIrrigationSeconds": null,
|
|
|
+ "fertPhase": null,
|
|
|
+ "failReason": null
|
|
|
+ }
|
|
|
+ ]
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+#### 5.6.7 失败时的精确定位展示
|
|
|
+
|
|
|
+任务失败时,客户视图精确定位到灌区 + 阶段 + 失败设备:
|
|
|
+
|
|
|
+```
|
|
|
+灌区2 ❌ 失败
|
|
|
+ 阶段: 启动中(STARTING)
|
|
|
+ 失败步骤: 开启灌区2(节点 index=4,类型 OPEN_GROUP)
|
|
|
+ 失败设备: ball-valve-003 → ACK超时,已重试3次
|
|
|
+ 失败时间: 2026-02-25 02:03:35
|
|
|
+```
|
|
|
+
|
|
|
+`zone_logs` 对应记录:
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "zoneId": 102,
|
|
|
+ "phase": "FAILED",
|
|
|
+ "failPhase": "STARTING",
|
|
|
+ "failNodeIndex": 4,
|
|
|
+ "failNodeType": "OPEN_GROUP",
|
|
|
+ "failReason": "ball-valve-003 ACK超时,重试3次",
|
|
|
+ "failedAt": "2026-02-25T02:03:35"
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 5.7 执行计划解析器(ZonePlanInterpreter)
|
|
|
+
|
|
|
+#### 5.7.1 解析器职责与定位
|
|
|
+
|
|
|
+`ZonePlanInterpreter` 是连接"物理节点层"与"灌区视图层"的翻译器,不参与任何执行逻辑,**只做只读推导**。其核心职责是:
|
|
|
+
|
|
|
+1. 从 `execution_plan.nodes` 中按 `zoneIndex` + `zonePhase` 聚合出每个灌区当前所处的阶段;
|
|
|
+2. 推导"当前正在执行的灌区"(`currentZoneIndex`);
|
|
|
+3. 计算各灌区的实际灌溉时长(`actualIrrigationSeconds`);
|
|
|
+4. 将节点级失败翻译为灌区级失败描述;
|
|
|
+5. 合并 FertTask 状态到灌区子状态。
|
|
|
+
|
|
|
+```
|
|
|
+输入:
|
|
|
+ execution_plan.nodes(含 zoneIndex、zonePhase 标注)
|
|
|
+ execution_plan.zone_logs(灌区摘要日志)
|
|
|
+ current_index(当前节点游标)
|
|
|
+ fert_task 列表(按 execution_id 查询)
|
|
|
+
|
|
|
+输出:
|
|
|
+ ZoneViewResult(进度 API 的响应体,见 §5.6.6)
|
|
|
+```
|
|
|
+
|
|
|
+> **调用时机**:进度查询接口(`GET /task-execution/{id}/progress`)、日志查询接口,以及任务完成后生成执行摘要时,均调用此解析器。解析器不写 DB,只读数据后实时计算。
|
|
|
+
|
|
|
+#### 5.7.2 解析器核心数据结构
|
|
|
+
|
|
|
+```java
|
|
|
+// 解析器输入
|
|
|
+ZoneInterpreterInput {
|
|
|
+ List<ExecutionNode> nodes; // 所有物理节点(含 zoneIndex、zonePhase)
|
|
|
+ List<ZoneLog> zoneLogs; // zone_logs 数组(from execution_plan JSON)
|
|
|
+ int currentIndex; // task_execution.current_index
|
|
|
+ List<ZoneConfig> zoneConfigs; // 灌区配置列表(含 zoneId、zoneName、durationSeconds)
|
|
|
+ List<Long> skipZones; // 已跳过灌区 ID 列表
|
|
|
+ List<FertTask> fertTasks; // 施肥子任务列表
|
|
|
+}
|
|
|
+
|
|
|
+// 单灌区视图
|
|
|
+ZoneView {
|
|
|
+ int zoneIndex; // 灌区在 zone_sequence 中的位置(0-based)
|
|
|
+ Long zoneId;
|
|
|
+ String zoneName;
|
|
|
+ ZonePhase phase; // PENDING/STARTING/IRRIGATING/SWITCHING/STOPPING/SUCCESS/FAILED/SKIPPED
|
|
|
+
|
|
|
+ // 时间统计
|
|
|
+ DateTime openAt; // 灌区开阀时刻
|
|
|
+ DateTime irrigationStartAt; // 灌溉等待开始时刻(WAIT(IRRIGATE) 开始执行时)
|
|
|
+ DateTime irrigationEndAt; // 灌溉等待结束时刻
|
|
|
+ Integer actualIrrigationSeconds; // 实际灌溉时长(irrigationEndAt - irrigationStartAt)
|
|
|
+ Integer elapsedSeconds; // 灌溉已进行时长(IRRIGATING 阶段:NOW - irrigationStartAt)
|
|
|
+
|
|
|
+ // 失败信息
|
|
|
+ String failPhase; // 失败时所在阶段
|
|
|
+ Integer failNodeIndex; // 失败的物理节点 index
|
|
|
+ String failNodeType; // 失败节点类型
|
|
|
+ String failReason; // 聚合失败描述(含失败设备列表)
|
|
|
+ DateTime failedAt;
|
|
|
+
|
|
|
+ // 施肥子状态(来自 FertTask)
|
|
|
+ FertPhase fertPhase; // PENDING/STIRRING/FERT_RUNNING/DONE/FAILED/null
|
|
|
+ DateTime stirStartAt;
|
|
|
+ DateTime pumpStartAt;
|
|
|
+ DateTime pumpStopAt;
|
|
|
+ String fertFailReason;
|
|
|
+
|
|
|
+ // 压力警告(SET_PUMP_PRESSURE 失败时置入)
|
|
|
+ Boolean pumpPressureWarning;
|
|
|
+ String pumpPressureWarningReason;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+#### 5.7.3 解析算法:五步推导
|
|
|
+
|
|
|
+```
|
|
|
+Step 1:建立节点分组(按 zoneIndex 聚合)
|
|
|
+Step 2:推导每个灌区当前阶段(ZonePhase)
|
|
|
+Step 3:计算灌溉时长
|
|
|
+Step 4:翻译失败信息
|
|
|
+Step 5:合并施肥子状态
|
|
|
+```
|
|
|
+
|
|
|
+##### Step 1:建立节点分组
|
|
|
+
|
|
|
+按 `zoneIndex` 将所有节点分组,建立 `Map<Integer, List<ExecutionNode>> nodesByZone`:
|
|
|
+
|
|
|
+```java
|
|
|
+Map<Integer, List<ExecutionNode>> nodesByZone = nodes.stream()
|
|
|
+ .filter(n -> n.getZoneIndex() >= 0) // 过滤掉 zoneIndex=-1 的全局节点(不应存在,NodeIndexer 已全部分配)
|
|
|
+ .collect(Collectors.groupingBy(ExecutionNode::getZoneIndex));
|
|
|
+```
|
|
|
+
|
|
|
+> **注意**:`NodeIndexer` 确保所有节点均有 `zoneIndex` 分配(`START_PUMP`、`SET_PUMP_PRESSURE` 等全局节点归 Zone0 或 ZoneN,见 §5.6.2),无 `zoneIndex=-1` 的游离节点。
|
|
|
+
|
|
|
+##### Step 2:推导每个灌区当前阶段
|
|
|
+
|
|
|
+对每个 `zoneIndex` I,按以下优先级判断其 `ZonePhase`:
|
|
|
+
|
|
|
+```java
|
|
|
+ZonePhase resolvePhase(int zoneIndex, List<ExecutionNode> zoneNodes, int currentIndex) {
|
|
|
+
|
|
|
+ // 1. 如果灌区在 skip_zones 列表中 → SKIPPED
|
|
|
+ if (skipZones.contains(zoneConfigs.get(zoneIndex).getZoneId())) {
|
|
|
+ return SKIPPED;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 找到本灌区的 WAIT(IRRIGATE) 节点(主等待节点)
|
|
|
+ ExecutionNode waitNode = zoneNodes.stream()
|
|
|
+ .filter(n -> n.getNodeType() == WAIT && "IRRIGATE".equals(n.getParams().getSource()))
|
|
|
+ .findFirst().orElse(null);
|
|
|
+
|
|
|
+ // 3. 找到本灌区的 CLOSE_GROUP 节点(结束节点)
|
|
|
+ ExecutionNode closeNode = zoneNodes.stream()
|
|
|
+ .filter(n -> n.getNodeType() == CLOSE_GROUP)
|
|
|
+ .findFirst().orElse(null);
|
|
|
+
|
|
|
+ // 4. 找到本灌区的 OPEN_GROUP 节点(首节点,开始标志)
|
|
|
+ ExecutionNode openNode = zoneNodes.stream()
|
|
|
+ .filter(n -> n.getNodeType() == OPEN_GROUP)
|
|
|
+ .findFirst().orElse(null);
|
|
|
+
|
|
|
+ // 5. 检查是否有失败节点(任意 status=FAILED 的节点)
|
|
|
+ boolean hasFailedNode = zoneNodes.stream()
|
|
|
+ .anyMatch(n -> n.getStatus() == NodeStatus.FAILED);
|
|
|
+ if (hasFailedNode) return FAILED;
|
|
|
+
|
|
|
+ // 6. 按节点执行状态推导阶段
|
|
|
+ if (closeNode != null && closeNode.getStatus() == SUCCESS) {
|
|
|
+ return SUCCESS;
|
|
|
+ }
|
|
|
+ if (waitNode != null && waitNode.getStatus() == SUCCESS) {
|
|
|
+ // WAIT 完成但 CLOSE 未完成 → 切换中(SWITCHING)或 结束中(STOPPING)
|
|
|
+ return zoneIndex == lastZoneIndex ? STOPPING : SWITCHING;
|
|
|
+ }
|
|
|
+ if (waitNode != null && waitNode.getStatus() == RUNNING) {
|
|
|
+ return IRRIGATING;
|
|
|
+ }
|
|
|
+ if (openNode != null && openNode.getStatus() != PENDING) {
|
|
|
+ return STARTING; // OPEN_GROUP 已开始(RUNNING/SUCCESS),泵可能还在启动
|
|
|
+ }
|
|
|
+ return PENDING;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+##### Step 3:计算灌溉时长
|
|
|
+
|
|
|
+从 `zone_logs` 中直接读取(由执行引擎在 WAIT 节点开始/结束时回写):
|
|
|
+
|
|
|
+```java
|
|
|
+Integer resolveIrrigationSeconds(ZoneView view, ZoneLog zoneLog, ZonePhase phase) {
|
|
|
+ if (phase == IRRIGATING) {
|
|
|
+ // 灌溉中:计算已进行时长
|
|
|
+ if (zoneLog.getIrrigationStartAt() != null) {
|
|
|
+ return (int) Duration.between(zoneLog.getIrrigationStartAt(), now).getSeconds();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (phase == SUCCESS || phase == SWITCHING || phase == STOPPING) {
|
|
|
+ // 灌溉已结束:使用实际完成时长
|
|
|
+ if (zoneLog.getIrrigationStartAt() != null && zoneLog.getIrrigationEndAt() != null) {
|
|
|
+ return (int) Duration.between(
|
|
|
+ zoneLog.getIrrigationStartAt(),
|
|
|
+ zoneLog.getIrrigationEndAt()
|
|
|
+ ).getSeconds();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> **`zone_logs` 回写时机(执行引擎侧)**:
|
|
|
+> - `WAIT(IRRIGATE)` 节点开始执行 → 回写 `zone_logs[I].irrigationStartAt = NOW()`
|
|
|
+> - `WAIT(IRRIGATE)` 节点完成(`status=SUCCESS`)→ 回写 `zone_logs[I].irrigationEndAt = NOW()`,计算 `actualIrrigationSeconds`
|
|
|
+> - `OPEN_GROUP(ZI)` 节点完成 → 回写 `zone_logs[I].openAt = NOW()`
|
|
|
+> - `CLOSE_GROUP(ZI)` 节点完成 → 更新 `zone_logs[I].phase = SUCCESS`
|
|
|
+
|
|
|
+##### Step 4:翻译失败信息
|
|
|
+
|
|
|
+```java
|
|
|
+void resolveFailInfo(ZoneView view, List<ExecutionNode> zoneNodes) {
|
|
|
+ zoneNodes.stream()
|
|
|
+ .filter(n -> n.getStatus() == NodeStatus.FAILED)
|
|
|
+ .findFirst()
|
|
|
+ .ifPresent(failedNode -> {
|
|
|
+ view.setFailPhase(failedNode.getZonePhase().name());
|
|
|
+ view.setFailNodeIndex(failedNode.getIndex());
|
|
|
+ view.setFailNodeType(failedNode.getNodeType().name());
|
|
|
+ view.setFailedAt(failedNode.getFinishedAt());
|
|
|
+
|
|
|
+ // 聚合失败设备描述
|
|
|
+ String failReason = failedNode.getDevices().stream()
|
|
|
+ .filter(d -> d.getAckStatus() != SUCCESS)
|
|
|
+ .map(d -> d.getDeviceId() + " → " + translateFailReason(d))
|
|
|
+ .collect(Collectors.joining(";"));
|
|
|
+ view.setFailReason(failReason);
|
|
|
+
|
|
|
+ // 特殊处理:SET_PUMP_PRESSURE 失败 → 写警告,不写 failReason
|
|
|
+ if (failedNode.getNodeType() == SET_PUMP_PRESSURE) {
|
|
|
+ view.setPumpPressureWarning(true);
|
|
|
+ view.setPumpPressureWarningReason(failReason);
|
|
|
+ view.setFailReason(null); // 不影响灌区状态
|
|
|
+ }
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+String translateFailReason(DeviceAck d) {
|
|
|
+ return switch (d.getAckStatus()) {
|
|
|
+ case TIMEOUT -> "ACK超时,重试" + d.getRetryCount() + "次";
|
|
|
+ case FAIL -> "设备返回失败:" + d.getFailReason();
|
|
|
+ default -> "未知原因";
|
|
|
+ };
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+##### Step 5:合并施肥子状态
|
|
|
+
|
|
|
+```java
|
|
|
+void mergeFertStatus(ZoneView view, List<FertTask> fertTasks, Long zoneId) {
|
|
|
+ fertTasks.stream()
|
|
|
+ .filter(f -> f.getZoneId().equals(zoneId))
|
|
|
+ .findFirst()
|
|
|
+ .ifPresent(fert -> {
|
|
|
+ view.setFertPhase(fert.getStatus().toZonePhase());
|
|
|
+ view.setStirStartAt(fert.getStirStartAt());
|
|
|
+ view.setPumpStartAt(fert.getPumpStartAt());
|
|
|
+ view.setPumpStopAt(fert.getPumpStopAt());
|
|
|
+ if (fert.getStatus() == FertStatus.FAILED) {
|
|
|
+ view.setFertFailReason(fert.getFailReason());
|
|
|
+ }
|
|
|
+ });
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+#### 5.7.4 解析器复杂场景处理
|
|
|
+
|
|
|
+##### 场景一:灌区切换期间(两个灌区同时"半开")
|
|
|
+
|
|
|
+当执行到 `ZONE_SWITCH_WAIT` 或 `CLOSE_GROUP(ZI-1)` 时,ZI-1 和 ZI 的阀门同时开启:
|
|
|
+
|
|
|
+```
|
|
|
+物理状态:ZI-1 阀门开 + ZI 阀门开 + 泵运行
|
|
|
+
|
|
|
+解析器输出:
|
|
|
+ ZI-1: phase = SWITCHING(CLOSE_GROUP 还未完成)
|
|
|
+ ZI: phase = STARTING(OPEN_GROUP 已完成,等待 WAIT(IRRIGATE) 开始)
|
|
|
+```
|
|
|
+
|
|
|
+客户视图展示:
|
|
|
+
|
|
|
+```
|
|
|
+灌区1 ── 切换中(正在稳压)
|
|
|
+灌区2 ── 启动中(阀门已开启)
|
|
|
+```
|
|
|
+
|
|
|
+这是预期的正常状态,解析器无需特殊处理,按节点状态顺序推导即可。
|
|
|
+
|
|
|
+##### 场景二:跳过灌区后的重算接续
|
|
|
+
|
|
|
+跳过 Z2 后,节点列表被截断重算,`skip_zones = [102]`,新生成节点中 `CLOSE_GROUP(Z1)` 的 `zoneIndex` 仍为 0(Z1 的结束),`OPEN_GROUP(Z3)` 的 `zoneIndex` 为 2。
|
|
|
+
|
|
|
+解析器:
|
|
|
+
|
|
|
+```java
|
|
|
+// skip_zones 中的灌区直接返回 SKIPPED,不参与其他推导
|
|
|
+if (skipZones.contains(config.getZoneId())) return SKIPPED;
|
|
|
+```
|
|
|
+
|
|
|
+客户视图:
|
|
|
+
|
|
|
+```
|
|
|
+灌区1 ── SUCCESS
|
|
|
+灌区2 ── ⏭ 已跳过
|
|
|
+灌区3 ── IRRIGATING
|
|
|
+```
|
|
|
+
|
|
|
+##### 场景三:故障接续(RESUME_REBUILD 节点)
|
|
|
+
|
|
|
+Resume 后插入的重建节点(`source=RESUME_REBUILD`)仍按正常 `zoneIndex` 归属,解析器不需要区分,正常按节点状态推导即可。但需过滤掉安全关闭期间插入的 `STOP_PUMP`(source=SAFE_CLOSE),避免误判最后灌区进入 STOPPING:
|
|
|
+
|
|
|
+```java
|
|
|
+// 过滤安全关闭节点,不纳入阶段推导
|
|
|
+boolean isSafeCloseNode = "SAFE_CLOSE".equals(node.getParams().getSource());
|
|
|
+if (isSafeCloseNode) continue;
|
|
|
+```
|
|
|
+
|
|
|
+##### 场景四:只有一个灌区
|
|
|
+
|
|
|
+```
|
|
|
+节点列表:OPEN_GROUP(Z0) → SET_PUMP_PRESSURE → START_PUMP → WAIT(Z0) → STOP_PUMP → CLOSE_GROUP(Z0)
|
|
|
+
|
|
|
+全部 zoneIndex = 0
|
|
|
+
|
|
|
+解析结果:
|
|
|
+ zones[0] 经历 PENDING → STARTING → IRRIGATING → STOPPING → SUCCESS
|
|
|
+ 无 SWITCHING 阶段(单灌区无切换)
|
|
|
+```
|
|
|
+
|
|
|
+解析器对此无特殊分支,`lastZoneIndex = 0`,`WAIT` 完成后判断为 `STOPPING`,逻辑自洽。
|
|
|
+
|
|
|
+#### 5.7.5 解析器类结构(Java 伪代码)
|
|
|
+
|
|
|
+```java
|
|
|
+@Component
|
|
|
+public class ZonePlanInterpreter {
|
|
|
+
|
|
|
+ public ZoneViewResult interpret(ZoneInterpreterInput input) {
|
|
|
+ // Step 1:节点分组
|
|
|
+ Map<Integer, List<ExecutionNode>> nodesByZone = groupByZone(input.getNodes());
|
|
|
+
|
|
|
+ int lastZoneIndex = input.getZoneConfigs().size() - 1;
|
|
|
+ List<ZoneView> views = new ArrayList<>();
|
|
|
+
|
|
|
+ for (int i = 0; i < input.getZoneConfigs().size(); i++) {
|
|
|
+ ZoneConfig config = input.getZoneConfigs().get(i);
|
|
|
+ ZoneLog log = findLog(input.getZoneLogs(), config.getZoneId());
|
|
|
+ List<ExecutionNode> zoneNodes = nodesByZone.getOrDefault(i, List.of());
|
|
|
+
|
|
|
+ ZoneView view = new ZoneView();
|
|
|
+ view.setZoneIndex(i);
|
|
|
+ view.setZoneId(config.getZoneId());
|
|
|
+ view.setZoneName(config.getZoneName());
|
|
|
+
|
|
|
+ // Step 2:推导阶段
|
|
|
+ view.setPhase(resolvePhase(i, zoneNodes, input.getCurrentIndex(), lastZoneIndex, input.getSkipZones(), config));
|
|
|
+
|
|
|
+ // Step 3:计算时长
|
|
|
+ view.setActualIrrigationSeconds(resolveIrrigationSeconds(view, log, view.getPhase()));
|
|
|
+ view.setElapsedSeconds(resolveElapsedSeconds(view, log, view.getPhase()));
|
|
|
+
|
|
|
+ // Step 4:翻译失败
|
|
|
+ resolveFailInfo(view, zoneNodes);
|
|
|
+
|
|
|
+ // Step 5:施肥子状态
|
|
|
+ mergeFertStatus(view, input.getFertTasks(), config.getZoneId());
|
|
|
+
|
|
|
+ views.add(view);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 找出当前灌区(第一个不是 SUCCESS/FAILED/SKIPPED 的灌区)
|
|
|
+ ZoneView currentZone = views.stream()
|
|
|
+ .filter(v -> !Set.of(SUCCESS, FAILED, SKIPPED).contains(v.getPhase()))
|
|
|
+ .findFirst()
|
|
|
+ .orElse(views.get(lastZoneIndex)); // 全部完成时返回最后灌区
|
|
|
+
|
|
|
+ return ZoneViewResult.builder()
|
|
|
+ .zones(views)
|
|
|
+ .currentZoneIndex(currentZone.getZoneIndex())
|
|
|
+ .currentZoneName(currentZone.getZoneName())
|
|
|
+ .currentZonePhase(currentZone.getPhase())
|
|
|
+ .irrigationElapsedSeconds(currentZone.getElapsedSeconds())
|
|
|
+ .irrigationTotalSeconds(
|
|
|
+ input.getZoneConfigs().get(currentZone.getZoneIndex()).getDurationSeconds()
|
|
|
+ )
|
|
|
+ .currentFertPhase(currentZone.getFertPhase())
|
|
|
+ .build();
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+#### 5.7.6 解析器与执行引擎的数据约定
|
|
|
+
|
|
|
+解析器依赖执行引擎在节点执行过程中的**回写行为**,双方通过 `zone_logs` 和节点 `status` 字段通信:
|
|
|
+
|
|
|
+| 执行引擎行为 | zone_logs 回写字段 | 解析器依赖 |
|
|
|
+| ----------- | ------------------ | --------- |
|
|
|
+| `OPEN_GROUP(ZI)` 成功 | `zone_logs[I].openAt` | 推导 STARTING → IRRIGATING 时机 |
|
|
|
+| `WAIT(IRRIGATE)` 开始 | `zone_logs[I].irrigationStartAt` | 计算 elapsedSeconds |
|
|
|
+| `WAIT(IRRIGATE)` 完成 | `zone_logs[I].irrigationEndAt`、`actualIrrigationSeconds` | 展示实际灌溉时长 |
|
|
|
+| `CLOSE_GROUP(ZI)` 成功 | `zone_logs[I].phase = SUCCESS` | 判断灌区完成 |
|
|
|
+| 任意节点 FAILED | 节点 `status=FAILED` | 触发 Step 4 失败翻译 |
|
|
|
+| FertTask 状态变更 | `zone_logs[I].fertPhase` | Step 5 施肥子状态 |
|
|
|
+
|
|
|
+> **设计原则**:`zone_logs` 作为执行引擎与解析器之间的状态缓存层,执行引擎负责写入,解析器只读。避免解析器重新扫描大量节点进行时间计算,降低进度接口的查询开销。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
## 6. 执行安全保障
|
|
|
|
|
|
### 6.1 三层安全保障体系
|
|
|
@@ -1610,12 +2205,12 @@ Body:(无参数)
|
|
|
|
|
|
每个节点执行时(包括 WAIT 节点开始执行时)更新 `last_heartbeat_at = NOW()`:
|
|
|
|
|
|
-| 更新时机 | 说明 |
|
|
|
-| ---------------------- | -------------------------------------------------------- |
|
|
|
-| 节点开始执行时 | 任何节点(ACK类、WAIT类)开始执行时立即更新 |
|
|
|
-| CHECK_ACK 消息处理时 | 每次检查 ACK 状态时更新,表明流程仍在活跃处理中 |
|
|
|
-| 重试发生时 | 每次重试下发指令时更新 |
|
|
|
-| NEXT_NODE 推进时 | 推进到下一节点时更新 |
|
|
|
+| 更新时机 | 说明 |
|
|
|
+| -------------------- | ----------------------------------------------- |
|
|
|
+| 节点开始执行时 | 任何节点(ACK类、WAIT类)开始执行时立即更新 |
|
|
|
+| CHECK_ACK 消息处理时 | 每次检查 ACK 状态时更新,表明流程仍在活跃处理中 |
|
|
|
+| 重试发生时 | 每次重试下发指令时更新 |
|
|
|
+| NEXT_NODE 推进时 | 推进到下一节点时更新 |
|
|
|
|
|
|
#### 6.5.2 看门狗扫描规则
|
|
|
|
|
|
@@ -1695,21 +2290,21 @@ RUNNING PAUSED
|
|
|
|
|
|
#### 7.1.1 task_execution(任务执行实例表)
|
|
|
|
|
|
-| 字段名 | 类型 | 说明 |
|
|
|
-| -------------------- | ----------- | ------------------------------------------------------------------------------- |
|
|
|
-| `id` | BIGINT PK | 执行实例唯一ID |
|
|
|
-| `task_id` | BIGINT | 关联的轮灌任务ID |
|
|
|
-| `trigger_type` | VARCHAR(20) | 触发类型:`SCHEDULED` / `LINKAGE` / `MANUAL` |
|
|
|
-| `execution_plan` | JSON | 完整有序节点列表(核心字段,见7.2) |
|
|
|
-| `current_index` | INT | 当前正在执行的节点索引 |
|
|
|
-| `status` | VARCHAR(20) | 执行状态:`PENDING` / `RUNNING` / `SUCCESS` / `FAILED` / `PAUSED` / `CANCELLED` |
|
|
|
-| `version` | INT | 乐观锁版本号,防止并发更新冲突 |
|
|
|
-| `started_at` | DATETIME | 实际开始时间 |
|
|
|
-| `finished_at` | DATETIME | 完成时间 |
|
|
|
-| `expected_finish_at` | DATETIME | 预计完成时间(含重试顺延后更新,前端展示用) |
|
|
|
+| 字段名 | 类型 | 说明 |
|
|
|
+| -------------------- | ----------- | ---------------------------------------------------------------------------------- |
|
|
|
+| `id` | BIGINT PK | 执行实例唯一ID |
|
|
|
+| `task_id` | BIGINT | 关联的轮灌任务ID |
|
|
|
+| `trigger_type` | VARCHAR(20) | 触发类型:`SCHEDULED` / `LINKAGE` / `MANUAL` |
|
|
|
+| `execution_plan` | JSON | 完整有序节点列表(核心字段,见7.2) |
|
|
|
+| `current_index` | INT | 当前正在执行的节点索引 |
|
|
|
+| `status` | VARCHAR(20) | 执行状态:`PENDING` / `RUNNING` / `SUCCESS` / `FAILED` / `PAUSED` / `CANCELLED` |
|
|
|
+| `version` | INT | 乐观锁版本号,防止并发更新冲突 |
|
|
|
+| `started_at` | DATETIME | 实际开始时间 |
|
|
|
+| `finished_at` | DATETIME | 完成时间 |
|
|
|
+| `expected_finish_at` | DATETIME | 预计完成时间(含重试顺延后更新,前端展示用) |
|
|
|
| `last_heartbeat_at` | DATETIME | 最近一次心跳时间(看门狗主要判据,每个节点执行时更新,超过12小时未更新则判定卡住) |
|
|
|
-| `paused_at` | DATETIME | 进入 PAUSED 状态的时间 |
|
|
|
-| `fail_reason` | TEXT | 失败/暂停原因描述 |
|
|
|
+| `paused_at` | DATETIME | 进入 PAUSED 状态的时间 |
|
|
|
+| `fail_reason` | TEXT | 失败/暂停原因描述 |
|
|
|
|
|
|
**乐观锁使用场景:**
|
|
|
|
|
|
@@ -1767,12 +2362,81 @@ WHERE id = ? AND version = ?
|
|
|
{
|
|
|
"zone_sequence": [101, 102, 103],
|
|
|
"skip_zones": [],
|
|
|
+
|
|
|
+ "zone_logs": [
|
|
|
+ {
|
|
|
+ "zoneId": 101,
|
|
|
+ "zoneName": "灌区1",
|
|
|
+ "zoneIndex": 0,
|
|
|
+ "openAt": "2026-02-25T02:00:00",
|
|
|
+ "irrigationStartAt": "2026-02-25T02:00:10",
|
|
|
+ "irrigationEndAt": "2026-02-25T02:03:10",
|
|
|
+ "actualIrrigationSeconds": 180,
|
|
|
+ "phase": "SUCCESS",
|
|
|
+ "fertPhase": "DONE",
|
|
|
+ "stirStartAt": "2026-02-25T02:00:13",
|
|
|
+ "pumpStartAt": "2026-02-25T02:00:15",
|
|
|
+ "pumpStopAt": "2026-02-25T02:00:20",
|
|
|
+ "fertFailReason": null,
|
|
|
+ "pumpPressureWarning": false,
|
|
|
+ "failPhase": null,
|
|
|
+ "failNodeIndex": null,
|
|
|
+ "failNodeType": null,
|
|
|
+ "failReason": null,
|
|
|
+ "failedAt": null
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "zoneId": 102,
|
|
|
+ "zoneName": "灌区2",
|
|
|
+ "zoneIndex": 1,
|
|
|
+ "openAt": "2026-02-25T02:03:10",
|
|
|
+ "irrigationStartAt": "2026-02-25T02:03:25",
|
|
|
+ "irrigationEndAt": null,
|
|
|
+ "actualIrrigationSeconds": null,
|
|
|
+ "phase": "IRRIGATING",
|
|
|
+ "fertPhase": "FERT_RUNNING",
|
|
|
+ "stirStartAt": "2026-02-25T02:03:28",
|
|
|
+ "pumpStartAt": "2026-02-25T02:03:30",
|
|
|
+ "pumpStopAt": null,
|
|
|
+ "fertFailReason": null,
|
|
|
+ "pumpPressureWarning": false,
|
|
|
+ "failPhase": null,
|
|
|
+ "failNodeIndex": null,
|
|
|
+ "failNodeType": null,
|
|
|
+ "failReason": null,
|
|
|
+ "failedAt": null
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "zoneId": 103,
|
|
|
+ "zoneName": "灌区3",
|
|
|
+ "zoneIndex": 2,
|
|
|
+ "openAt": null,
|
|
|
+ "irrigationStartAt": null,
|
|
|
+ "irrigationEndAt": null,
|
|
|
+ "actualIrrigationSeconds": null,
|
|
|
+ "phase": "PENDING",
|
|
|
+ "fertPhase": null,
|
|
|
+ "stirStartAt": null,
|
|
|
+ "pumpStartAt": null,
|
|
|
+ "pumpStopAt": null,
|
|
|
+ "fertFailReason": null,
|
|
|
+ "pumpPressureWarning": false,
|
|
|
+ "failPhase": null,
|
|
|
+ "failNodeIndex": null,
|
|
|
+ "failNodeType": null,
|
|
|
+ "failReason": null,
|
|
|
+ "failedAt": null
|
|
|
+ }
|
|
|
+ ],
|
|
|
+
|
|
|
"nodes": [
|
|
|
{
|
|
|
"index": 0,
|
|
|
"nodeType": "OPEN_GROUP",
|
|
|
"nodeName": "开启灌区1",
|
|
|
"refId": 101,
|
|
|
+ "zoneIndex": 0,
|
|
|
+ "zonePhase": "STARTING",
|
|
|
"params": {
|
|
|
"targetAngle": 80,
|
|
|
"targetPressureKpa": 250
|
|
|
@@ -1806,6 +2470,8 @@ WHERE id = ? AND version = ?
|
|
|
"nodeType": "SET_PUMP_PRESSURE",
|
|
|
"nodeName": "设置水泵压力",
|
|
|
"refId": 201,
|
|
|
+ "zoneIndex": 0,
|
|
|
+ "zonePhase": "STARTING",
|
|
|
"params": {
|
|
|
"pressureKpa": 300
|
|
|
},
|
|
|
@@ -1830,6 +2496,8 @@ WHERE id = ? AND version = ?
|
|
|
"nodeType": "START_PUMP",
|
|
|
"nodeName": "开启水泵",
|
|
|
"refId": 201,
|
|
|
+ "zoneIndex": 0,
|
|
|
+ "zonePhase": "STARTING",
|
|
|
"params": {},
|
|
|
"status": "RUNNING",
|
|
|
"retryCount": 0,
|
|
|
@@ -1850,10 +2518,16 @@ WHERE id = ? AND version = ?
|
|
|
{
|
|
|
"index": 3,
|
|
|
"nodeType": "WAIT",
|
|
|
- "nodeName": "等待3分钟",
|
|
|
+ "nodeName": "灌溉灌区1,等待3分钟",
|
|
|
"refId": null,
|
|
|
+ "zoneIndex": 0,
|
|
|
+ "zonePhase": "IRRIGATING",
|
|
|
"params": {
|
|
|
- "seconds": 180
|
|
|
+ "seconds": 180,
|
|
|
+ "source": "IRRIGATE",
|
|
|
+ "fertEnabled": true,
|
|
|
+ "fertPumpDeviceId": "fert-pump-001",
|
|
|
+ "stirDeviceId": "stir-motor-001"
|
|
|
},
|
|
|
"status": "PENDING",
|
|
|
"retryCount": 0,
|
|
|
@@ -1868,16 +2542,54 @@ WHERE id = ? AND version = ?
|
|
|
|
|
|
**字段说明:**
|
|
|
|
|
|
+**根节点字段说明:**
|
|
|
+
|
|
|
+| 字段 | 说明 |
|
|
|
+| ---------------- | --------------------------------------------------------------------- |
|
|
|
+| `zone_sequence` | 当前有效的灌区执行顺序(灌区 ID 列表,运行时可调整) |
|
|
|
+| `skip_zones` | 运行时被跳过的灌区 ID 列表,跳过后重新生成后续节点 |
|
|
|
+| `zone_logs` | 灌区摘要日志数组,由执行引擎回写,供 ZonePlanInterpreter(§5.7)读取 |
|
|
|
+| `nodes` | 物理执行节点列表(由计划生成引擎生成,执行引擎逐节点执行并更新状态) |
|
|
|
+
|
|
|
+**`zone_logs[]` 字段说明:**
|
|
|
+
|
|
|
+| 字段 | 说明 | 回写时机 |
|
|
|
+| ------------------------- | -------------------------------------------------------------------- | -------------------------------- |
|
|
|
+| `zoneId` | 灌区 ID | 计划生成时初始化 |
|
|
|
+| `zoneName` | 灌区名称 | 计划生成时初始化 |
|
|
|
+| `zoneIndex` | 灌区在 zone_sequence 中的位置(0-based) | 计划生成时初始化 |
|
|
|
+| `openAt` | `OPEN_GROUP` 节点完成时刻 | `OPEN_GROUP` 成功后回写 |
|
|
|
+| `irrigationStartAt` | `WAIT(IRRIGATE)` 节点开始执行时刻 | `WAIT(IRRIGATE)` 开始时回写 |
|
|
|
+| `irrigationEndAt` | `WAIT(IRRIGATE)` 节点完成时刻 | `WAIT(IRRIGATE)` 完成时回写 |
|
|
|
+| `actualIrrigationSeconds` | 实际灌溉时长(秒),由 irrigationEndAt - irrigationStartAt 计算 | `WAIT(IRRIGATE)` 完成时回写 |
|
|
|
+| `phase` | 灌区当前阶段(由解析器推导,也在 CLOSE_GROUP 后由引擎置为 SUCCESS) | `CLOSE_GROUP` 成功 / 解析器推导 |
|
|
|
+| `fertPhase` | 施肥子状态(由 FertTask 处理器回写) | FertTask 每次状态变更时回写 |
|
|
|
+| `stirStartAt` | 搅拌电机实际启动时刻 | FertTask 回写 |
|
|
|
+| `pumpStartAt` | 施肥泵实际启动时刻 | FertTask 回写 |
|
|
|
+| `pumpStopAt` | 施肥泵停止时刻 | FertTask 回写 |
|
|
|
+| `fertFailReason` | 施肥失败原因 | FertTask 失败时回写 |
|
|
|
+| `pumpPressureWarning` | 水泵压力设置失败警告标志(不阻断主流程) | `SET_PUMP_PRESSURE` 失败时回写 |
|
|
|
+| `failPhase` | 失败时所在灌区阶段(STARTING / IRRIGATING / SWITCHING / STOPPING) | 节点 FAILED 时回写 |
|
|
|
+| `failNodeIndex` | 失败的物理节点 index | 节点 FAILED 时回写 |
|
|
|
+| `failNodeType` | 失败节点类型 | 节点 FAILED 时回写 |
|
|
|
+| `failReason` | 聚合失败描述(含失败设备列表,由解析器翻译) | 节点 FAILED 时回写 |
|
|
|
+| `failedAt` | 失败时刻 | 节点 FAILED 时回写 |
|
|
|
+
|
|
|
+**`nodes[]` 字段说明:**
|
|
|
+
|
|
|
| 字段 | 说明 |
|
|
|
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
|
-| `zone_sequence` | 当前有效的灌区执行顺序(灌区 ID 列表,运行时可调整) |
|
|
|
-| `skip_zones` | 运行时被跳过的灌区 ID 列表,跳过后重新生成后续节点 |
|
|
|
| `index` | 节点顺序索引,从 0 开始 |
|
|
|
| `nodeType` | 节点类型,对应 Handler 路由 |
|
|
|
| `nodeName` | 可读的节点名称,用于日志和报警展示 |
|
|
|
| `refId` | 关联实体 ID(灌溉组ID / 水泵ID) |
|
|
|
+| `zoneIndex` | 节点所属灌区在 zone_sequence 中的位置(0-based);由 NodeIndexer(Phase 5)按§5.6.2 规则分配;供 ZonePlanInterpreter 分组使用 |
|
|
|
+| `zonePhase` | 节点在灌区生命周期中的阶段标识:`STARTING` / `IRRIGATING` / `SWITCHING` / `STOPPING`;由 NodeIndexer 按§5.6.2 规则分配 |
|
|
|
| `params` | 节点执行参数;`OPEN_GROUP` 含 `targetAngle`、`targetPressureKpa`(球阀配置了目标压力时);`SET_PUMP_PRESSURE` 含 `pressureKpa`;`WAIT` 含 `seconds` |
|
|
|
-| `params.source` | WAIT 类节点来源标识:`IRRIGATE`(灌溉等待)/ `ZONE_SWITCH`(稳压等待)/ `RESUME_REBUILD`(故障接续重建节点) |
|
|
|
+| `params.source` | WAIT 类节点来源标识:`IRRIGATE`(灌溉等待)/ `ZONE_SWITCH`(稳压等待)/ `RESUME_REBUILD`(故障接续重建节点)/ `SAFE_CLOSE`(安全关闭节点) |
|
|
|
+| `params.fertEnabled` | 仅 `WAIT(source=IRRIGATE)` 节点有值;`true` 表示执行此 WAIT 时需创建 FertTask 并触发施肥流程 |
|
|
|
+| `params.fertPumpDeviceId` | 施肥泵设备 ID(fertEnabled=true 时有值) |
|
|
|
+| `params.stirDeviceId` | 搅拌电机设备 ID(fertEnabled=true 时有值) |
|
|
|
| `status` | 节点状态:`PENDING` / `RUNNING` / `SUCCESS` / `FAILED` / `SKIPPED` |
|
|
|
| `retryCount` | 节点级别已重试次数 |
|
|
|
| `maxRetry` | 最大重试次数(默认3,WAIT/ZONE_SWITCH_WAIT 节点为0) |
|
|
|
@@ -1889,15 +2601,22 @@ WHERE id = ? AND version = ?
|
|
|
### 7.3 JSON 存储优势
|
|
|
|
|
|
```
|
|
|
-查某次执行的完整计划 → JSON一次读完 ✅
|
|
|
-查当前执行到哪一步 → current_index字段 ✅
|
|
|
-查某次执行是否成功 → status字段 ✅
|
|
|
-安全关闭查已开启设备 → 从JSON中内存过滤 ✅
|
|
|
-跳过灌区后重算后续计划 → 截断nodes + 重新追加新节点 ✅
|
|
|
-故障接续判断是否需要重建序列 → 查JSON中 STOP_PUMP 安全关闭节点 ✅
|
|
|
-执行历史归档 → task_execution记录保留,JSON即完整记录 ✅
|
|
|
+查某次执行的完整计划 → JSON一次读完 ✅
|
|
|
+查当前执行到哪一步 → current_index字段 ✅
|
|
|
+查某次执行是否成功 → status字段 ✅
|
|
|
+安全关闭查已开启设备 → 从JSON中内存过滤 ✅
|
|
|
+跳过灌区后重算后续计划 → 截断nodes + 重新追加新节点 ✅
|
|
|
+故障接续判断是否需要重建序列 → 查JSON中 STOP_PUMP 安全关闭节点 ✅
|
|
|
+执行历史归档 → task_execution记录保留,JSON即完整记录 ✅
|
|
|
+
|
|
|
+(新增,支持灌区视图层)
|
|
|
+查客户进度视图(当前灌哪个灌区)→ ZonePlanInterpreter 读 nodes[].zoneIndex + zone_logs ✅
|
|
|
+查灌区实际灌溉时长 → zone_logs[I].actualIrrigationSeconds ✅
|
|
|
+查灌区失败原因及失败节点 → zone_logs[I].failNodeIndex + failReason ✅
|
|
|
+查施肥子任务进度 → zone_logs[I].fertPhase + FertTask记录 ✅
|
|
|
+压力设置失败不阻断,单独告警 → zone_logs[I].pumpPressureWarning ✅
|
|
|
|
|
|
JSON大小估算:
|
|
|
- 10个节点,每节点含10个设备 → 约5-10KB
|
|
|
+ 10个节点,每节点含10个设备,3个灌区 zone_logs → 约8-15KB
|
|
|
完全在MySQL JSON字段承受范围内
|
|
|
```
|