Преглед на файлове

feature: 智能控制V2版本 第三版(初步任务)

Jayhaw преди 4 седмици
родител
ревизия
96dce92122
променени са 29 файла, в които са добавени 3028 реда и са изтрити 491 реда
  1. 185 0
      docs/migration-multi-schedule-rules.sql
  2. 354 0
      docs/修改总结-多定时规则支持.md
  3. 444 0
      docs/修改总结-多联动规则支持.md
  4. 767 48
      docs/球阀轮灌技术方案.md
  5. 139 125
      src/main/java/cn/sciento/farm/automationv2/api/controller/IrrigationTaskController.java
  6. 168 25
      src/main/java/cn/sciento/farm/automationv2/api/dto/CreateTaskRequest.java
  7. 18 10
      src/main/java/cn/sciento/farm/automationv2/app/job/IrrigationScheduledJob.java
  8. 217 121
      src/main/java/cn/sciento/farm/automationv2/app/service/QuartzManagementService.java
  9. 23 8
      src/main/java/cn/sciento/farm/automationv2/app/service/TaskTriggerService.java
  10. 33 0
      src/main/java/cn/sciento/farm/automationv2/app/service/irrigationTask/IrrigationTaskService.java
  11. 96 0
      src/main/java/cn/sciento/farm/automationv2/app/service/irrigationTask/impl/IrrigationTaskServiceImpl.java
  12. 23 84
      src/main/java/cn/sciento/farm/automationv2/domain/entity/IrrigationTask.java
  13. 1 0
      src/main/java/cn/sciento/farm/automationv2/domain/entity/TaskExecution.java
  14. 7 19
      src/main/java/cn/sciento/farm/automationv2/domain/entity/TaskGroupConfig.java
  15. 193 0
      src/main/java/cn/sciento/farm/automationv2/domain/entity/TaskScheduleRule.java
  16. 7 7
      src/main/java/cn/sciento/farm/automationv2/domain/enums/TaskStatus.java
  17. 14 0
      src/main/java/cn/sciento/farm/automationv2/domain/repository/IrrigationTaskRepository.java
  18. 14 0
      src/main/java/cn/sciento/farm/automationv2/domain/repository/TaskScheduleRuleRepository.java
  19. 15 8
      src/main/java/cn/sciento/farm/automationv2/domain/service/ExecutionPlanGenerator.java
  20. 86 0
      src/main/java/cn/sciento/farm/automationv2/domain/valueobject/IrrigationTaskVO.java
  21. 3 3
      src/main/java/cn/sciento/farm/automationv2/domain/valueobject/ZoneConfigView.java
  22. 14 0
      src/main/java/cn/sciento/farm/automationv2/infra/constant/RedisConstant.java
  23. 6 0
      src/main/java/cn/sciento/farm/automationv2/infra/mapper/IrrigationTaskMapper.java
  24. 5 0
      src/main/java/cn/sciento/farm/automationv2/infra/mapper/LinkageRuleMapper.java
  25. 88 0
      src/main/java/cn/sciento/farm/automationv2/infra/mapper/TaskScheduleRuleMapper.java
  26. 14 0
      src/main/java/cn/sciento/farm/automationv2/infra/repository/impl/IrrigationTaskRepositoryImpl.java
  27. 17 0
      src/main/java/cn/sciento/farm/automationv2/infra/repository/impl/TaskScheduleRuleRepositoryImpl.java
  28. 3 8
      src/main/resources/mapper/IrrigationGroupMapper.xml
  29. 74 25
      src/main/resources/mapper/IrrigationTaskMapper.xml

+ 185 - 0
docs/migration-multi-schedule-rules.sql

@@ -0,0 +1,185 @@
+-- ============================================================
+-- 智能轮灌系统V2 - 多定时规则支持
+-- 数据库迁移脚本
+-- 版本:1.0
+-- 日期:2026-03-02
+-- 说明:支持一个任务配置多个定时规则(模式A和模式B可共存)
+-- ============================================================
+
+-- ============================================================
+-- 1. 创建任务定时规则表
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `wfauto_v2_task_schedule_rule` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '规则ID',
+  `task_id` BIGINT NOT NULL COMMENT '关联的任务ID',
+  `rule_name` VARCHAR(100) COMMENT '规则名称(用于标识和管理)',
+  `schedule_type` VARCHAR(20) NOT NULL COMMENT '定时类型:CRON / SIMPLE',
+
+  -- CRON 模式字段
+  `cron_expression` VARCHAR(100) COMMENT 'Cron表达式(schedule_type=CRON时必填)',
+
+  -- SIMPLE 模式字段
+  `start_time` DATETIME COMMENT '起始时间(schedule_type=SIMPLE时必填)',
+  `interval_days` INT COMMENT '执行间隔天数(schedule_type=SIMPLE时必填)',
+  `total_times` INT COMMENT '执行总次数(schedule_type=SIMPLE时必填)',
+  `executed_count` INT DEFAULT 0 COMMENT '已执行次数',
+
+  -- 状态控制
+  `enabled` TINYINT(1) DEFAULT 1 COMMENT '是否启用:1启用 0禁用',
+  `status` VARCHAR(20) DEFAULT 'ACTIVE' COMMENT '规则状态:ACTIVE活跃 / COMPLETED已完成 / DISABLED已禁用',
+
+  -- Quartz 调度关联
+  `quartz_job_name` VARCHAR(100) COMMENT 'Quartz Job名称(自动生成)',
+  `quartz_trigger_name` VARCHAR(100) COMMENT 'Quartz Trigger名称(自动生成)',
+
+  -- 审计字段
+  `tenant_id` BIGINT COMMENT '租户ID',
+  `created_by` BIGINT COMMENT '创建人ID',
+  `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `updated_by` BIGINT COMMENT '更新人ID',
+  `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `deleted` TINYINT(1) DEFAULT 0 COMMENT '逻辑删除:0未删除 1已删除',
+
+  PRIMARY KEY (`id`),
+  KEY `idx_task_id` (`task_id`),
+  KEY `idx_enabled_status` (`enabled`, `status`),
+  KEY `idx_quartz_job` (`quartz_job_name`),
+  KEY `idx_deleted` (`deleted`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务定时规则表';
+
+-- ============================================================
+-- 2. 调整 irrigation_task 表(删除定时相关字段)
+-- ============================================================
+-- 注意:以下操作会删除旧的定时配置字段
+-- 建议先备份数据,或使用下面的数据迁移脚本
+
+ALTER TABLE `wfauto_v2_irrigation_task`
+  DROP COLUMN IF EXISTS `schedule_type`,
+  DROP COLUMN IF EXISTS `cron_expression`,
+  DROP COLUMN IF EXISTS `start_time`,
+  DROP COLUMN IF EXISTS `interval_days`,
+  DROP COLUMN IF EXISTS `total_times`,
+  DROP COLUMN IF EXISTS `executed_count`;
+
+-- ============================================================
+-- 3. 数据迁移(将旧的定时配置迁移到新表)
+-- ============================================================
+-- 为每个已有定时配置的任务创建对应的定时规则
+
+INSERT INTO `wfauto_v2_task_schedule_rule` (
+  `task_id`,
+  `rule_name`,
+  `schedule_type`,
+  `cron_expression`,
+  `start_time`,
+  `interval_days`,
+  `total_times`,
+  `executed_count`,
+  `enabled`,
+  `status`,
+  `quartz_job_name`,
+  `quartz_trigger_name`,
+  `tenant_id`,
+  `created_at`,
+  `deleted`
+)
+SELECT
+  t.`id` AS task_id,
+  CONCAT('迁移规则-', t.`task_name`) AS rule_name,
+  t.`schedule_type`,
+  t.`cron_expression`,
+  t.`start_time`,
+  t.`interval_days`,
+  t.`total_times`,
+  IFNULL(t.`executed_count`, 0) AS executed_count,
+  t.`enabled`,
+  CASE
+    WHEN t.`schedule_type` = 'SIMPLE' AND IFNULL(t.`executed_count`, 0) >= IFNULL(t.`total_times`, 0) AND t.`total_times` > 0
+      THEN 'COMPLETED'
+    ELSE 'ACTIVE'
+  END AS status,
+  NULL AS quartz_job_name,  -- 将在应用启动时自动填充
+  NULL AS quartz_trigger_name,  -- 将在应用启动时自动填充
+  t.`tenant_id`,
+  NOW() AS created_at,
+  0 AS deleted
+FROM `wfauto_v2_irrigation_task` t
+WHERE t.`schedule_type` IS NOT NULL
+  AND t.`deleted` = 0;
+
+-- 更新迁移后的规则,填充 Quartz 相关字段
+UPDATE `wfauto_v2_task_schedule_rule` r
+SET
+  r.`quartz_job_name` = CONCAT('TASK_', r.`task_id`, '_RULE_', r.`id`),
+  r.`quartz_trigger_name` = CONCAT('TRIGGER_', r.`task_id`, '_RULE_', r.`id`)
+WHERE r.`quartz_job_name` IS NULL;
+
+-- ============================================================
+-- 4. 验证迁移结果
+-- ============================================================
+-- 检查迁移的规则数量
+SELECT
+  COUNT(*) AS total_rules,
+  SUM(CASE WHEN schedule_type = 'CRON' THEN 1 ELSE 0 END) AS cron_rules,
+  SUM(CASE WHEN schedule_type = 'SIMPLE' THEN 1 ELSE 0 END) AS simple_rules,
+  SUM(CASE WHEN enabled = 1 THEN 1 ELSE 0 END) AS enabled_rules,
+  SUM(CASE WHEN status = 'ACTIVE' THEN 1 ELSE 0 END) AS active_rules,
+  SUM(CASE WHEN status = 'COMPLETED' THEN 1 ELSE 0 END) AS completed_rules
+FROM `wfauto_v2_task_schedule_rule`
+WHERE deleted = 0;
+
+-- 检查每个任务的规则数量
+SELECT
+  t.id AS task_id,
+  t.task_name,
+  COUNT(r.id) AS rule_count
+FROM `wfauto_v2_irrigation_task` t
+LEFT JOIN `wfauto_v2_task_schedule_rule` r ON r.task_id = t.id AND r.deleted = 0
+WHERE t.deleted = 0
+GROUP BY t.id, t.task_name
+ORDER BY rule_count DESC;
+
+-- ============================================================
+-- 5. 回滚脚本(如果迁移失败需要回滚)
+-- ============================================================
+/*
+-- 删除定时规则表
+DROP TABLE IF EXISTS `wfauto_v2_task_schedule_rule`;
+
+-- 恢复 irrigation_task 表的定时字段
+ALTER TABLE `wfauto_v2_irrigation_task`
+  ADD COLUMN `schedule_type` VARCHAR(20) COMMENT '定时类型:CRON / SIMPLE',
+  ADD COLUMN `cron_expression` VARCHAR(100) COMMENT 'Cron表达式',
+  ADD COLUMN `start_time` DATETIME COMMENT '起始时间',
+  ADD COLUMN `interval_days` INT COMMENT '执行间隔天数',
+  ADD COLUMN `total_times` INT COMMENT '执行总次数',
+  ADD COLUMN `executed_count` INT DEFAULT 0 COMMENT '已执行次数';
+*/
+
+-- ============================================================
+-- 6. 使用示例
+-- ============================================================
+/*
+-- 示例1:查询任务的所有定时规则
+SELECT * FROM `wfauto_v2_task_schedule_rule`
+WHERE task_id = 1 AND deleted = 0;
+
+-- 示例2:为任务添加新的定时规则
+INSERT INTO `wfauto_v2_task_schedule_rule` (
+  task_id, rule_name, schedule_type, cron_expression,
+  enabled, status, tenant_id, created_at
+) VALUES (
+  1, '每周一三五凌晨2点', 'CRON', '0 0 2 ? * MON,WED,FRI',
+  1, 'ACTIVE', 1, NOW()
+);
+
+-- 示例3:禁用某个规则
+UPDATE `wfauto_v2_task_schedule_rule`
+SET enabled = 0, status = 'DISABLED', updated_at = NOW()
+WHERE id = 1;
+
+-- 示例4:标记SIMPLE规则为已完成
+UPDATE `wfauto_v2_task_schedule_rule`
+SET status = 'COMPLETED', enabled = 0, updated_at = NOW()
+WHERE id = 2 AND schedule_type = 'SIMPLE' AND executed_count >= total_times;
+*/

+ 354 - 0
docs/修改总结-多定时规则支持.md

@@ -0,0 +1,354 @@
+# 多定时规则支持 - 修改总结
+
+## 一、修改概述
+
+根据需求"定时轮灌的模式A和模式B是可以同时存在的,也可以多条",对系统进行了全面改造,实现了以下功能:
+
+- ✅ 一个任务可以同时配置多个定时规则
+- ✅ CRON 模式(模式A)和 SIMPLE 模式(模式B)可以共存
+- ✅ 每种模式都可以配置多条规则
+- ✅ 每个规则可独立启用/禁用
+- ✅ 规则之间互不干扰,独立调度
+
+## 二、修改内容
+
+### 1. 数据库层修改
+
+#### 1.1 新增表:`wfauto_v2_task_schedule_rule`
+
+```sql
+CREATE TABLE `wfauto_v2_task_schedule_rule` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT,
+  `task_id` BIGINT NOT NULL,
+  `rule_name` VARCHAR(100),
+  `schedule_type` VARCHAR(20) NOT NULL,
+  `cron_expression` VARCHAR(100),
+  `start_time` DATETIME,
+  `interval_days` INT,
+  `total_times` INT,
+  `executed_count` INT DEFAULT 0,
+  `enabled` TINYINT(1) DEFAULT 1,
+  `status` VARCHAR(20) DEFAULT 'ACTIVE',
+  `quartz_job_name` VARCHAR(100),
+  `quartz_trigger_name` VARCHAR(100),
+  -- 其他字段...
+  PRIMARY KEY (`id`),
+  KEY `idx_task_id` (`task_id`)
+);
+```
+
+#### 1.2 调整表:`wfauto_v2_irrigation_task`
+
+删除了以下字段:
+- `schedule_type`
+- `cron_expression`
+- `start_time`
+- `interval_days`
+- `total_times`
+- `executed_count`
+
+### 2. 实体类修改
+
+#### 2.1 IrrigationTask.java
+- ✅ 删除了定时相关字段
+- ✅ 删除了相关业务方法(`reachedExecutionLimit()`, `incrementExecutedCount()`)
+
+#### 2.2 新增 TaskScheduleRule.java
+- ✅ 包含所有定时规则相关字段
+- ✅ 提供业务方法:`isCronMode()`, `isSimpleMode()`, `reachedExecutionLimit()`, `isActive()`
+- ✅ 提供工具方法:`generateJobName()`, `generateTriggerName()`, `fillQuartzNames()`
+
+### 3. 数据访问层修改
+
+#### 3.1 新增 Mapper
+- ✅ `TaskScheduleRuleMapper.java` - 定时规则数据访问接口
+
+#### 3.2 新增 Repository
+- ✅ `TaskScheduleRuleRepository.java` - 定时规则仓储接口
+- ✅ `TaskScheduleRuleRepositoryImpl.java` - 定时规则仓储实现
+
+### 4. API层修改
+
+#### 4.1 CreateTaskRequest DTO
+**修改前:**
+```java
+private String scheduleType;
+private String cronExpression;
+private Integer intervalDays;
+private Integer totalTimes;
+```
+
+**修改后:**
+```java
+private List<ScheduleRuleDTO> scheduleRules;
+
+public static class ScheduleRuleDTO {
+    private String ruleName;
+    private String scheduleType;
+    private String cronExpression;
+    private LocalDateTime startTime;
+    private Integer intervalDays;
+    private Integer totalTimes;
+    private Boolean enabled;
+}
+```
+
+### 5. 业务逻辑层修改
+
+#### 5.1 IrrigationScheduledJob.java
+- ✅ 添加 `PARAM_RULE_ID` 参数
+- ✅ 修改 `executeInternal()` 方法,支持 `ruleId` 参数
+- ✅ 调用 `taskTriggerService.scheduledTrigger(taskId, ruleId)`
+
+#### 5.2 QuartzManagementService.java
+**新增方法:**
+- ✅ `scheduleTaskRule(IrrigationTask, TaskScheduleRule)` - 为单个规则创建调度
+- ✅ `scheduleAllTaskRules(Long taskId)` - 为任务的所有启用规则创建调度
+- ✅ `unscheduleTaskRule(TaskScheduleRule)` - 删除单个规则的调度
+- ✅ `unscheduleAllTaskRules(Long taskId)` - 删除任务的所有规则调度
+- ✅ `pauseTaskRule(TaskScheduleRule)` - 暂停规则调度
+- ✅ `resumeTaskRule(TaskScheduleRule)` - 恢复规则调度
+- ✅ `triggerRuleNow(TaskScheduleRule)` - 立即触发规则
+- ✅ `ruleJobExists(TaskScheduleRule)` - 检查规则调度是否存在
+
+**废弃方法:**
+- ❌ `addCronJob(Long, String)` - 已废弃
+- ❌ `addSimpleJob(Long, int, int)` - 已废弃
+- ❌ `pauseJob(Long)` - 已废弃
+- ❌ `resumeJob(Long)` - 已废弃
+- ❌ `triggerJobNow(Long)` - 已废弃
+- ❌ `jobExists(Long)` - 已废弃
+
+#### 5.3 IrrigationTaskController.java
+
+**修改的方法:**
+
+1. **createTask()** - 支持创建多个定时规则
+   ```java
+   // 保存定时规则
+   if (request.getScheduleRules() != null && !request.getScheduleRules().isEmpty()) {
+       for (ScheduleRuleDTO ruleDTO : request.getScheduleRules()) {
+           // 创建规则
+           // 创建Quartz调度
+       }
+   }
+   ```
+
+2. **deleteTask()** - 删除所有规则的调度
+   ```java
+   quartzManagementService.unscheduleAllTaskRules(id);
+   taskScheduleRuleMapper.deleteByTaskId(id);
+   ```
+
+3. **enableTask()** - 启用所有规则
+   ```java
+   quartzManagementService.scheduleAllTaskRules(id);
+   ```
+
+4. **disableTask()** - 禁用所有规则
+   ```java
+   quartzManagementService.unscheduleAllTaskRules(id);
+   ```
+
+5. **executeNow()** - 已删除(废弃)
+
+## 三、数据迁移
+
+### 3.1 迁移脚本
+已创建迁移脚本:`docs/migration-multi-schedule-rules.sql`
+
+### 3.2 迁移步骤
+1. 备份数据库
+2. 执行迁移脚本中的"创建表"部分
+3. 执行"数据迁移"部分,将旧配置迁移到新表
+4. 验证迁移结果
+5. 执行"删除旧字段"部分(可选,建议先测试)
+
+### 3.3 迁移逻辑
+- 为每个有定时配置的任务创建对应的定时规则
+- 规则名称:`迁移规则-{任务名称}`
+- 自动填充 `quartz_job_name` 和 `quartz_trigger_name`
+- SIMPLE 模式下,如果已达到执行次数上限,状态设为 `COMPLETED`
+
+## 四、API 使用示例
+
+### 4.1 创建任务(多个定时规则)
+
+```http
+POST /api/tasks
+Content-Type: application/json
+
+{
+  "taskName": "农场A区智能轮灌",
+  "triggerType": "SCHEDULED",
+  "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
+    }
+  ],
+  "pumpId": "pump-001",
+  "pressureMode": "PUMP_UNIFIED",
+  "targetPressureKpa": 300,
+  "groupConfigs": [
+    {
+      "groupId": 101,
+      "sortOrder": 1,
+      "irrigationDurationMinutes": 30
+    }
+  ],
+  "tenantId": 1
+}
+```
+
+### 4.2 查询任务的定时规则
+
+```sql
+SELECT * FROM wfauto_v2_task_schedule_rule
+WHERE task_id = 1 AND deleted = 0
+ORDER BY id;
+```
+
+### 4.3 查询规则执行状态
+
+```sql
+SELECT
+  r.id,
+  r.rule_name,
+  r.schedule_type,
+  r.enabled,
+  r.status,
+  r.executed_count,
+  r.total_times,
+  CASE
+    WHEN r.schedule_type = 'CRON' THEN r.cron_expression
+    WHEN r.schedule_type = 'SIMPLE' THEN CONCAT('每', r.interval_days, '天执行')
+  END AS schedule_info
+FROM wfauto_v2_task_schedule_rule r
+WHERE r.task_id = 1 AND r.deleted = 0;
+```
+
+## 五、技术优势
+
+### 5.1 灵活性
+- ✅ 支持同时配置多个 CRON 规则和 SIMPLE 规则
+- ✅ 每个规则可独立启用/禁用
+- ✅ 规则可动态增删改
+
+### 5.2 可维护性
+- ✅ 定时规则独立管理,职责清晰
+- ✅ 符合关系型数据库设计规范
+- ✅ 便于后续扩展(如增加规则优先级、规则分组等)
+
+### 5.3 向后兼容
+- ✅ 提供数据迁移脚本
+- ✅ 旧版API标记为 @Deprecated
+- ✅ 旧版deleteJob()方法映射到新的unscheduleAllTaskRules()
+
+### 5.4 性能
+- ✅ 每个规则对应一个独立的 Quartz Job
+- ✅ 互不干扰,任一规则故障不影响其他规则
+- ✅ 并发触发防护机制确保同一任务不会重复执行
+
+### 5.5 安全性
+- ✅ 所有规则触发共享同一并发防护锁(Redis)
+- ✅ 同一任务同时只能有一个执行实例
+- ✅ 规则触发失败不影响其他规则
+
+## 六、注意事项
+
+### 6.1 数据迁移
+- ⚠️ 执行迁移前务必备份数据库
+- ⚠️ 建议先在测试环境验证
+- ⚠️ 迁移后需重启应用,让Quartz重新注册Job
+
+### 6.2 并发触发
+- ⚠️ 同一任务配置多个规则时,如果触发时间重叠,只有先触发的会执行
+- ⚠️ 后续触发会被并发防护机制拒绝,记录在日志中
+
+### 6.3 SIMPLE 模式
+- ⚠️ SIMPLE 模式达到执行次数上限后,规则状态自动变为 `COMPLETED`
+- ⚠️ Quartz Job 会被自动删除
+- ⚠️ 如需重新执行,需更新规则状态为 `ACTIVE` 并重建调度
+
+### 6.4 TaskTriggerService 依赖
+- ⚠️ `IrrigationScheduledJob` 现在调用 `scheduledTrigger(taskId, ruleId)`
+- ⚠️ 需要确保 `TaskTriggerService` 支持 `ruleId` 参数
+- ⚠️ 如果 `TaskTriggerService` 还未更新,需要同步修改
+
+## 七、后续优化建议
+
+### 7.1 功能扩展
+- [ ] 支持规则优先级
+- [ ] 支持规则分组
+- [ ] 支持规则生效时间范围(有效期)
+- [ ] 支持规则执行历史查询
+
+### 7.2 UI 改进
+- [ ] 可视化规则管理界面
+- [ ] 规则执行状态实时展示
+- [ ] 规则冲突检测提示
+
+### 7.3 性能优化
+- [ ] 规则数量较多时的分页查询
+- [ ] 规则缓存机制
+- [ ] 批量操作支持
+
+## 八、文件清单
+
+### 8.1 新增文件
+- `src/main/java/cn/sciento/farm/automationv2/domain/entity/TaskScheduleRule.java`
+- `src/main/java/cn/sciento/farm/automationv2/domain/repository/TaskScheduleRuleRepository.java`
+- `src/main/java/cn/sciento/farm/automationv2/infra/repository/impl/TaskScheduleRuleRepositoryImpl.java`
+- `src/main/java/cn/sciento/farm/automationv2/infra/mapper/TaskScheduleRuleMapper.java`
+- `docs/migration-multi-schedule-rules.sql`
+
+### 8.2 修改文件
+- `src/main/java/cn/sciento/farm/automationv2/domain/entity/IrrigationTask.java`
+- `src/main/java/cn/sciento/farm/automationv2/api/dto/CreateTaskRequest.java`
+- `src/main/java/cn/sciento/farm/automationv2/api/controller/IrrigationTaskController.java`
+- `src/main/java/cn/sciento/farm/automationv2/app/job/IrrigationScheduledJob.java`
+- `src/main/java/cn/sciento/farm/automationv2/app/service/QuartzManagementService.java`
+- `docs/球阀轮灌技术方案.md`
+
+## 九、测试建议
+
+### 9.1 单元测试
+- [ ] TaskScheduleRule 实体类业务方法测试
+- [ ] TaskScheduleRuleMapper 数据访问测试
+- [ ] QuartzManagementService 调度管理测试
+
+### 9.2 集成测试
+- [ ] 创建任务(多个规则)测试
+- [ ] 启用/禁用任务测试
+- [ ] 删除任务测试
+- [ ] 规则独立触发测试
+- [ ] 并发触发防护测试
+
+### 9.3 端到端测试
+- [ ] 多规则同时触发场景
+- [ ] SIMPLE 规则达到上限自动停用
+- [ ] 规则故障不影响其他规则
+- [ ] 数据迁移验证
+
+---
+
+**修改完成时间:** 2026-03-02
+**修改人:** Claude Sonnet 4.5
+**版本:** v2.0

+ 444 - 0
docs/修改总结-多联动规则支持.md

@@ -0,0 +1,444 @@
+# 多联动规则支持 - 修改总结
+
+## 一、修改概述
+
+与定时规则类似,现在支持**一个任务绑定多个传感器联动规则**:
+
+- ✅ 一个任务可以同时配置多个联动规则
+- ✅ 每个规则可以绑定不同的传感器设备
+- ✅ 同一传感器可以配置多个不同阈值条件的规则
+- ✅ 每个规则可独立启用/禁用
+- ✅ 规则之间互不干扰,独立触发
+
+## 二、修改内容
+
+### 1. 数据库层
+
+#### 现有表: `wfauto_v2_link_rule`
+
+联动规则表已经存在,支持多规则设计:
+- 通过 `task_id` 字段关联任务(一对多关系)
+- 每个规则有独立的 `id`、`rule_name`、`rule_code`
+- 支持冷却时间机制(`cooldown_minutes`)
+- 记录触发次数(`trigger_count`)和最后触发时间(`last_trigger_at`)
+
+### 2. 实体类
+
+#### 2.1 LinkageRule.java (已存在)
+已具备多规则支持的完整结构:
+- 包含所有联动规则相关字段
+- 提供业务方法:`matchCondition()`, `getCooldownSeconds()`, `incrementTriggerCount()`
+- 支持多种比较运算符:> / >= / < / <= / ==
+
+### 3. 数据访问层
+
+#### 3.1 LinkageRuleMapper.java (已修改)
+
+**新增方法:**
+```java
+/**
+ * 根据任务ID逻辑删除所有规则
+ */
+int deleteByTaskId(@Param("taskId") Long taskId);
+```
+
+**已有方法:**
+- `insert(LinkageRule)` - 插入规则
+- `selectByTaskId(Long)` - 查询任务的所有规则
+- `selectEnabledBySensorId(String)` - 查询传感器的启用规则
+- `selectBySensorId(String)` - 查询传感器的所有规则
+- `updateById(LinkageRule)` - 更新规则
+- `deleteById(Long)` - 删除单个规则
+
+### 4. API层修改
+
+#### 4.1 CreateTaskRequest DTO
+
+**新增字段:**
+```java
+/**
+ * 传感器联动规则列表(可选,为空则不配置联动触发)
+ * 支持配置多个联动规则,可以绑定不同传感器或同一传感器的不同阈值条件
+ */
+private List<LinkageRuleDTO> linkageRules;
+
+@Data
+public static class LinkageRuleDTO {
+    @NotBlank(message = "规则名称不能为空")
+    private String ruleName;
+
+    private String ruleCode;
+
+    @NotBlank(message = "传感器设备ID不能为空")
+    private String sensorDeviceId;
+
+    @NotBlank(message = "传感器数据项编码不能为空")
+    private String dataItemCode;  // 如: temperature, humidity, soilMoisture
+
+    @NotBlank(message = "比较运算符不能为空")
+    private String operator;  // GT(>), GTE(>=), LT(<), LTE(<=), EQ(=), NEQ(!=)
+
+    @NotNull(message = "阈值不能为空")
+    private Double threshold;
+
+    private Integer cooldownMinutes;  // 默认30分钟
+
+    private Boolean enabled;  // 默认true
+}
+```
+
+### 5. 业务逻辑层修改
+
+#### 5.1 IrrigationTaskController.java
+
+**修改的方法:**
+
+1. **createTask()** - 支持创建多个联动规则
+   ```java
+   // 5. 保存联动规则(如果配置了)
+   if (request.getLinkageRules() != null && !request.getLinkageRules().isEmpty()) {
+       for (LinkageRuleDTO linkageDTO : request.getLinkageRules()) {
+           LinkageRule linkageRule = LinkageRule.builder()
+               .taskId(taskId)
+               .ruleName(linkageDTO.getRuleName())
+               .ruleCode(linkageDTO.getRuleCode())
+               .sensorDeviceId(linkageDTO.getSensorDeviceId())
+               .sensorDataType(linkageDTO.getDataItemCode())
+               .operator(linkageDTO.getOperator())
+               .threshold(BigDecimal.valueOf(linkageDTO.getThreshold()))
+               .cooldownMinutes(linkageDTO.getCooldownMinutes() != null ?
+                   linkageDTO.getCooldownMinutes() : 30)
+               .enabled(linkageDTO.getEnabled() != null ?
+                   linkageDTO.getEnabled() : true)
+               .triggerCount(0)
+               .tenantId(request.getTenantId())
+               .createdAt(LocalDateTime.now())
+               .deleted(false)
+               .build();
+
+           linkageRuleMapper.insert(linkageRule);
+       }
+   }
+   ```
+
+2. **deleteTask()** - 删除所有联动规则
+   ```java
+   // 逻辑删除所有联动规则
+   linkageRuleMapper.deleteByTaskId(id);
+   ```
+
+## 三、API 使用示例
+
+### 3.1 创建任务(多个联动规则)
+
+```http
+POST /api/tasks
+Content-Type: application/json
+
+{
+  "taskName": "农场A区智能轮灌",
+  "triggerType": "LINKAGE",
+  "linkageRules": [
+    {
+      "ruleName": "土壤湿度过低触发",
+      "ruleCode": "SOIL_MOISTURE_LOW",
+      "sensorDeviceId": "sensor-soil-001",
+      "dataItemCode": "soilMoisture",
+      "operator": "<",
+      "threshold": 30.0,
+      "cooldownMinutes": 60,
+      "enabled": true
+    },
+    {
+      "ruleName": "温度过高触发",
+      "ruleCode": "TEMP_HIGH",
+      "sensorDeviceId": "sensor-temp-001",
+      "dataItemCode": "temperature",
+      "operator": ">",
+      "threshold": 35.0,
+      "cooldownMinutes": 30,
+      "enabled": true
+    },
+    {
+      "ruleName": "土壤湿度传感器备用规则",
+      "ruleCode": "SOIL_MOISTURE_LOW_BACKUP",
+      "sensorDeviceId": "sensor-soil-002",
+      "dataItemCode": "soilMoisture",
+      "operator": "<=",
+      "threshold": 25.0,
+      "cooldownMinutes": 60,
+      "enabled": true
+    }
+  ],
+  "pumpId": "pump-001",
+  "pressureMode": "PUMP_UNIFIED",
+  "targetPressureKpa": 300,
+  "groupConfigs": [
+    {
+      "groupId": 101,
+      "sortOrder": 1,
+      "irrigationDurationMinutes": 30
+    }
+  ],
+  "tenantId": 1
+}
+```
+
+### 3.2 定时+联动混合模式
+
+```json
+{
+  "taskName": "农场B区混合触发灌溉",
+  "triggerType": "SCHEDULED",
+  "scheduleRules": [
+    {
+      "ruleName": "每天凌晨2点",
+      "scheduleType": "CRON",
+      "cronExpression": "0 0 2 * * ?",
+      "enabled": true
+    }
+  ],
+  "linkageRules": [
+    {
+      "ruleName": "土壤湿度应急补水",
+      "sensorDeviceId": "sensor-soil-003",
+      "dataItemCode": "soilMoisture",
+      "operator": "<",
+      "threshold": 20.0,
+      "cooldownMinutes": 120,
+      "enabled": true
+    }
+  ],
+  "pumpId": "pump-002",
+  "pressureMode": "PUMP_ZONE",
+  "groupConfigs": [
+    {
+      "groupId": 201,
+      "sortOrder": 1,
+      "irrigationDurationMinutes": 25
+    }
+  ],
+  "tenantId": 1
+}
+```
+
+### 3.3 查询任务的联动规则
+
+```sql
+SELECT * FROM wfauto_v2_link_rule
+WHERE task_id = 1 AND deleted = 0
+ORDER BY id;
+```
+
+### 3.4 查询规则触发状态
+
+```sql
+SELECT
+  r.id,
+  r.rule_name,
+  r.sensor_device_id,
+  r.sensor_data_type,
+  r.operator,
+  r.threshold,
+  r.cooldown_minutes,
+  r.enabled,
+  r.trigger_count,
+  r.last_trigger_at,
+  TIMESTAMPDIFF(MINUTE, r.last_trigger_at, NOW()) AS minutes_since_last_trigger
+FROM wfauto_v2_link_rule r
+WHERE r.task_id = 1 AND r.deleted = 0
+ORDER BY r.id;
+```
+
+### 3.5 查询传感器关联的所有规则
+
+```sql
+SELECT
+  r.id,
+  r.rule_name,
+  r.task_id,
+  t.task_name,
+  r.operator,
+  r.threshold,
+  r.enabled
+FROM wfauto_v2_link_rule r
+LEFT JOIN wfauto_v2_irrigation_task t ON t.id = r.task_id
+WHERE r.sensor_device_id = 'sensor-soil-001'
+  AND r.deleted = 0
+  AND r.enabled = 1;
+```
+
+## 四、技术优势
+
+### 4.1 灵活性
+- ✅ 支持同时配置多个传感器的联动规则
+- ✅ 同一传感器可配置多个不同阈值的规则
+- ✅ 每个规则可独立启用/禁用
+- ✅ 规则可动态增删改
+
+### 4.2 可靠性
+- ✅ 冷却时间机制防止频繁触发
+- ✅ 触发次数统计便于分析规则效率
+- ✅ 多规则冗余设计提高系统容错性
+
+### 4.3 可维护性
+- ✅ 联动规则独立管理,职责清晰
+- ✅ 符合关系型数据库设计规范
+- ✅ 便于后续扩展(如增加规则优先级、复合条件等)
+
+### 4.4 性能
+- ✅ 通过传感器ID索引快速查询相关规则
+- ✅ 冷却时间机制减少不必要的任务触发
+- ✅ 规则触发状态独立记录,互不影响
+
+### 4.5 安全性
+- ✅ 每个规则独立的冷却时间控制
+- ✅ 触发次数限制防止恶意触发
+- ✅ 规则故障不影响其他规则
+
+## 五、与定时规则的对比
+
+| 特性 | 定时规则 | 联动规则 |
+|------|---------|---------|
+| 触发方式 | 基于时间调度 | 基于传感器数据 |
+| 调度引擎 | Quartz | 事件驱动 |
+| 防重复机制 | Quartz自带 | 冷却时间 |
+| 执行次数控制 | SIMPLE模式支持 | 无限制,统计触发次数 |
+| 状态管理 | ACTIVE/COMPLETED/DISABLED | enabled字段 |
+| 独立性 | 每个规则独立Job | 每个规则独立判断 |
+| 共同点 | 支持多规则,互不干扰,独立配置 | 支持多规则,互不干扰,独立配置 |
+
+## 六、注意事项
+
+### 6.1 规则设计
+- ⚠️ 合理设置冷却时间,避免频繁触发导致设备损耗
+- ⚠️ 同一传感器的多个规则应设置不同阈值,避免重复触发
+- ⚠️ 建议为关键传感器配置备用规则,提高可靠性
+
+### 6.2 触发机制
+- ⚠️ 传感器数据需要通过消息队列或API推送到系统
+- ⚠️ 系统根据 `sensorDeviceId` 查询启用的规则并匹配条件
+- ⚠️ 冷却时间内的触发请求会被忽略,不会执行任务
+
+### 6.3 并发控制
+- ⚠️ 多个联动规则同时满足条件时,需要任务级别的并发防护
+- ⚠️ 建议在 `TaskTriggerService.linkageTrigger()` 中实现分布式锁
+- ⚠️ 避免同一任务因多个规则同时触发导致重复执行
+
+### 6.4 数据精度
+- ⚠️ 阈值比较使用 `BigDecimal` 确保精度
+- ⚠️ 相等比较(`==`)使用0.001误差容忍度
+- ⚠️ 传感器数据应做异常值过滤
+
+### 6.5 规则数量
+- ⚠️ 单个任务建议不超过10个联动规则
+- ⚠️ 同一传感器建议不超过5个规则
+- ⚠️ 规则过多会增加匹配开销
+
+## 七、后续优化建议
+
+### 7.1 功能扩展
+- [ ] 支持复合条件(AND/OR逻辑)
+- [ ] 支持规则优先级
+- [ ] 支持时间窗口限制(仅在特定时间段生效)
+- [ ] 支持规则触发历史查询
+- [ ] 支持规则有效期设置
+
+### 7.2 性能优化
+- [ ] 规则匹配缓存
+- [ ] 传感器数据变化订阅机制
+- [ ] 批量规则匹配
+- [ ] 规则热数据缓存(Redis)
+
+### 7.3 智能化
+- [ ] 基于历史数据的阈值自适应调整
+- [ ] 规则效果分析(触发次数、成功率等)
+- [ ] 异常规则自动禁用
+- [ ] 规则冲突检测
+
+## 八、文件清单
+
+### 8.1 修改文件
+- `src/main/java/cn/sciento/farm/automationv2/api/dto/CreateTaskRequest.java` - 新增 LinkageRuleDTO
+- `src/main/java/cn/sciento/farm/automationv2/api/controller/IrrigationTaskController.java` - 支持多联动规则
+- `src/main/java/cn/sciento/farm/automationv2/infra/mapper/LinkageRuleMapper.java` - 新增 deleteByTaskId 方法
+
+### 8.2 已存在(无需修改)
+- `src/main/java/cn/sciento/farm/automationv2/domain/entity/LinkageRule.java` - 已支持多规则设计
+- `src/main/java/cn/sciento/farm/automationv2/domain/repository/LinkageRuleRepository.java`
+- `src/main/java/cn/sciento/farm/automationv2/infra/repository/impl/LinkageRuleRepositoryImpl.java`
+
+## 九、测试建议
+
+### 9.1 单元测试
+- [ ] LinkageRule 实体类业务方法测试(matchCondition等)
+- [ ] LinkageRuleMapper 数据访问测试
+- [ ] 冷却时间机制测试
+
+### 9.2 集成测试
+- [ ] 创建任务(多个联动规则)测试
+- [ ] 删除任务(级联删除规则)测试
+- [ ] 规则独立触发测试
+- [ ] 冷却时间有效性测试
+- [ ] 并发触发防护测试
+
+### 9.3 端到端测试
+- [ ] 多规则同时满足条件场景
+- [ ] 同一传感器多个阈值规则
+- [ ] 定时+联动混合触发
+- [ ] 规则故障不影响其他规则
+- [ ] 传感器数据异常处理
+
+## 十、与传感器服务集成建议
+
+### 10.1 数据推送方式
+```java
+// 方式1: 消息队列(推荐)
+@KafkaListener(topics = "sensor-data")
+public void handleSensorData(SensorDataEvent event) {
+    // 查询该传感器的所有启用规则
+    List<LinkageRule> rules = linkageRuleMapper
+        .selectEnabledBySensorId(event.getSensorDeviceId());
+
+    for (LinkageRule rule : rules) {
+        // 检查是否在冷却期
+        if (isInCooldown(rule)) continue;
+
+        // 匹配条件
+        if (rule.matchCondition(event.getValue())) {
+            // 触发任务
+            taskTriggerService.linkageTrigger(rule.getTaskId(), rule.getId());
+        }
+    }
+}
+
+// 方式2: HTTP API
+@PostMapping("/api/sensors/{sensorId}/data")
+public Result<Void> receiveSensorData(
+    @PathVariable String sensorId,
+    @RequestBody SensorDataDTO data) {
+    // 同上逻辑
+}
+```
+
+### 10.2 冷却期检查
+```java
+private boolean isInCooldown(LinkageRule rule) {
+    if (rule.getLastTriggerAt() == null) {
+        return false;
+    }
+
+    long minutesSinceLastTrigger = ChronoUnit.MINUTES.between(
+        rule.getLastTriggerAt(),
+        LocalDateTime.now()
+    );
+
+    return minutesSinceLastTrigger < rule.getCooldownMinutes();
+}
+```
+
+---
+
+**修改完成时间:** 2026-03-02
+**修改人:** Claude Sonnet 4.5
+**版本:** v2.0

+ 767 - 48
docs/球阀轮灌技术方案.md

@@ -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字段承受范围内
 ```

+ 139 - 125
src/main/java/cn/sciento/farm/automationv2/api/controller/IrrigationTaskController.java

@@ -1,20 +1,33 @@
 package cn.sciento.farm.automationv2.api.controller;
 
+import cn.sciento.core.domain.Page;
+import cn.sciento.core.iam.ResourceLevel;
+import cn.sciento.core.util.Results;
 import cn.sciento.farm.automationv2.api.dto.CreateTaskRequest;
-import cn.sciento.farm.automationv2.api.dto.PageRequest;
-import cn.sciento.farm.automationv2.api.dto.Result;
-import cn.sciento.farm.automationv2.app.service.QuartzManagementService;
 import cn.sciento.farm.automationv2.app.service.TaskTriggerService;
-import cn.sciento.farm.automationv2.domain.entity.IrrigationTask;
-import cn.sciento.farm.automationv2.domain.entity.TaskGroupConfig;
+import cn.sciento.farm.automationv2.app.service.irrigationTask.IrrigationTaskService;
+import cn.sciento.farm.automationv2.domain.entity.*;
 import cn.sciento.farm.automationv2.domain.enums.*;
+import cn.sciento.farm.automationv2.domain.valueobject.IrrigationTaskVO;
+import cn.sciento.farm.automationv2.infra.constant.BaseConstant;
 import cn.sciento.farm.automationv2.infra.mapper.IrrigationTaskMapper;
+import cn.sciento.farm.automationv2.infra.mapper.LinkageRuleMapper;
 import cn.sciento.farm.automationv2.infra.mapper.TaskGroupConfigMapper;
+import cn.sciento.farm.automationv2.infra.mapper.TaskScheduleRuleMapper;
+import cn.sciento.swagger.annotation.Permission;
+import io.choerodon.mybatis.pagehelper.annotation.PageableDefault;
+import io.choerodon.mybatis.pagehelper.domain.PageRequest;
+import io.choerodon.mybatis.pagehelper.domain.Sort;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.ResponseEntity;
+import org.springframework.transaction.annotation.Transactional;
 import org.springframework.web.bind.annotation.*;
 
 import javax.validation.Valid;
+import java.math.BigDecimal;
 import java.time.LocalDateTime;
 import java.util.ArrayList;
 import java.util.List;
@@ -22,79 +35,107 @@ import java.util.List;
 /**
  * 灌溉任务管理API
  */
-@Slf4j
+@Api("灌溉任务接口")
 @RestController
 @RequestMapping("/api/tasks")
-@RequiredArgsConstructor
 public class IrrigationTaskController {
 
-    private final IrrigationTaskMapper irrigationTaskMapper;
-    private final TaskGroupConfigMapper taskGroupConfigMapper;
-    private final QuartzManagementService quartzManagementService;
+//    private final TaskGroupConfigMapper taskGroupConfigMapper;
+//    private final TaskScheduleRuleMapper taskScheduleRuleMapper;
+//    private final LinkageRuleMapper linkageRuleMapper;
+//    private final QuartzManagementService quartzManagementService;
     private final TaskTriggerService taskTriggerService;
 
+    private IrrigationTaskService taskService;
+
+    public IrrigationTaskController(IrrigationTaskService taskService) {
+        this.taskService = taskService;
+    }
+
     /**
      * 创建任务
      */
     @PostMapping
-    public Result<Long> createTask(@Valid @RequestBody CreateTaskRequest request) {
-        log.info("创建灌溉任务,taskName={}", request.getTaskName());
-
-        try {
-            // 构建任务实体
-            IrrigationTask task = IrrigationTask.builder()
-                    .taskName(request.getTaskName())
-                    .taskCode(request.getTaskCode())
-                    .triggerType(TriggerType.valueOf(request.getTriggerType()))
-                    .scheduleType(request.getScheduleType() != null ?
-                            ScheduleType.valueOf(request.getScheduleType()) : null)
-                    .cronExpression(request.getCronExpression())
-                    .intervalDays(request.getIntervalDays())
-                    .totalTimes(request.getTotalTimes())
-                    .executedCount(0)
-                    .pumpId(request.getPumpId())
-                    .pressureMode(PressureMode.valueOf(request.getPressureMode()))
-                    .targetPressureKpa(request.getTargetPressureKpa())
-                    .switchStableSeconds(request.getSwitchStableSeconds() != null ? request.getSwitchStableSeconds() : 5)
-                    .fertilizerPumpId(request.getFertilizerPumpId())
-                    .stirMotorId(request.getStirMotorId())
-                    .fertilizerControlMode(request.getFertilizerControlMode() != null ?
-                            FertilizerControlMode.valueOf(request.getFertilizerControlMode()) : null)
-                    .fertDelayMinutes(request.getFertDelayMinutes())
-                    .preStirMinutes(request.getPreStirMinutes())
-                    .fertDurationMinutes(request.getFertDurationMinutes())
-                    .fertTargetLiters(request.getFertTargetLiters())
-                    .status(TaskStatus.ENABLED)
-                    .enabled(request.getEnabled() != null ? request.getEnabled() : true)
-                    .tenantId(request.getTenantId())
-                    .createdAt(LocalDateTime.now())
-                    .deleted(false)
-                    .build();
-
-            // 持久化任务
-            irrigationTaskMapper.insert(task);
-
-            // 保存灌区配置(任务与灌溉组的关联)
-            List<TaskGroupConfig> groupConfigs = new ArrayList<>();
-            for (CreateTaskRequest.GroupConfigDTO dto : request.getGroupConfigs()) {
-                TaskGroupConfig config = TaskGroupConfig.builder()
-                        .taskId(task.getId())
-                        .groupId(dto.getGroupId())
-                        .sortOrder(dto.getSortOrder())
-                        .irrigationDurationMinutes(dto.getIrrigationDurationMinutes())
-                        .build();
-                groupConfigs.add(config);
-            }
-            taskGroupConfigMapper.batchInsert(groupConfigs);
-
-            log.info("任务创建成功,taskId={}, groupCount={}", task.getId(), groupConfigs.size());
-
-            return Result.success(task.getId());
-
-        } catch (Exception e) {
-            log.error("创建任务失败,taskName={}", request.getTaskName(), e);
-            return Result.error("创建任务失败: " + e.getMessage());
-        }
+    @Permission(level = ResourceLevel.ORGANIZATION)
+    public ResponseEntity<IrrigationTask> createTask(@Valid @RequestBody CreateTaskRequest request) {
+
+
+
+//            // 4. 保存定时规则(如果配置了)
+//            if (request.getScheduleRules() != null && !request.getScheduleRules().isEmpty()) {
+//                for (CreateTaskRequest.ScheduleRuleDTO ruleDTO : request.getScheduleRules()) {
+//                    TaskScheduleRule rule = TaskScheduleRule.builder()
+//                            .taskId(taskId)
+//                            .ruleName(ruleDTO.getRuleName())
+//                            .scheduleType(ScheduleType.valueOf(ruleDTO.getScheduleType()))
+//                            .cronExpression(ruleDTO.getCronExpression())
+//                            .startTime(ruleDTO.getStartTime())
+//                            .intervalDays(ruleDTO.getIntervalDays())
+//                            .totalTimes(ruleDTO.getTotalTimes())
+//                            .executedCount(0)
+//                            .enabled(ruleDTO.getEnabled() != null ? ruleDTO.getEnabled() : true)
+//                            .status("ACTIVE")
+//                            .tenantId(request.getTenantId())
+//                            .deleted(false)
+//                            .build();
+//
+//                    // 插入规则
+//                    taskScheduleRuleMapper.insert(rule);
+//
+//                    // 填充Quartz相关字段
+//                    rule.fillQuartzNames();
+//                    taskScheduleRuleMapper.updateQuartzNames(
+//                            rule.getId(),
+//                            rule.getQuartzJobName(),
+//                            rule.getQuartzTriggerName()
+//                    );
+//
+//                    // 5. 为启用的规则创建Quartz调度
+//                    if (Boolean.TRUE.equals(rule.getEnabled())) {
+//                        try {
+//                            quartzManagementService.scheduleTaskRule(task, rule);
+//                        } catch (Exception e) {
+//                            log.error("创建规则调度失败,将继续处理,taskId={}, ruleId={}",
+//                                    taskId, rule.getId(), e);
+//                        }
+//                    }
+//                }
+//
+//                log.info("任务创建成功,taskId={}, groupCount={}, ruleCount={}",
+//                        taskId, groupConfigs.size(), request.getScheduleRules().size());
+//            } else {
+//                log.info("任务创建成功,taskId={}, groupCount={}", taskId, groupConfigs.size());
+//            }
+//
+//            // 5. 保存联动规则(如果配置了)
+//            if (request.getLinkageRules() != null && !request.getLinkageRules().isEmpty()) {
+//                for (CreateTaskRequest.LinkageRuleDTO linkageDTO : request.getLinkageRules()) {
+//                    LinkageRule linkageRule = LinkageRule.builder()
+//                            .taskId(taskId)
+//                            .ruleName(linkageDTO.getRuleName())
+//                            .ruleCode(linkageDTO.getRuleCode())
+//                            .sensorDeviceId(linkageDTO.getSensorDeviceId())
+//                            .sensorDataType(linkageDTO.getDataItemCode())
+//                            .operator(linkageDTO.getOperator())
+//                            .threshold(linkageDTO.getThreshold() != null ?
+//                                    BigDecimal.valueOf(linkageDTO.getThreshold()) : null)
+//                            .cooldownMinutes(linkageDTO.getCooldownMinutes() != null ?
+//                                    linkageDTO.getCooldownMinutes() : 30)
+//                            .enabled(linkageDTO.getEnabled() != null ? linkageDTO.getEnabled() : true)
+//                            .triggerCount(0)
+//                            .tenantId(request.getTenantId())
+//                            .createdAt(LocalDateTime.now())
+//                            .deleted(false)
+//                            .build();
+//
+//                    // 插入联动规则
+//                    linkageRuleMapper.insert(linkageRule);
+//                }
+//
+//                log.info("任务联动规则保存成功,taskId={}, linkageRuleCount={}",
+//                        taskId, request.getLinkageRules().size());
+//            }
+        return Results.success(taskService.create(request));
     }
 
     /**
@@ -112,21 +153,15 @@ public class IrrigationTaskController {
         return Result.success(task);
     }
 
-    /**
-     * 分页查询任务列表
-     */
-    @GetMapping
-    public Result<List<IrrigationTask>> listTasks(PageRequest pageRequest) {
-        log.info("分页查询任务列表,pageNum={}, pageSize={}, tenantId={}",
-                pageRequest.getPageNum(), pageRequest.getPageSize(), pageRequest.getTenantId());
-
-        List<IrrigationTask> tasks = irrigationTaskMapper.selectByTenant(
-                pageRequest.getTenantId(),
-                pageRequest.getOffset(),
-                pageRequest.getLimit()
-        );
-
-        return Result.success(tasks);
+
+    @ApiOperation("分页获取")
+    @Permission(level = ResourceLevel.ORGANIZATION)
+    @GetMapping("/page")
+    public ResponseEntity<Page<IrrigationTaskVO>> page(@PathVariable Long tenantId,
+                                                       IrrigationTask task,
+                                                       @PageableDefault(page = 1,size = 20,sort = {"creation_date"}, direction = Sort.Direction.DESC) PageRequest pageRequest){
+        task.setTenantId(tenantId);
+        return Results.success(taskService.page(task,pageRequest));
     }
 
     /**
@@ -137,7 +172,6 @@ public class IrrigationTaskController {
         log.info("更新任务,taskId={}", id);
 
         task.setId(id);
-        task.setUpdatedAt(LocalDateTime.now());
 
         int updated = irrigationTaskMapper.updateById(task);
         if (updated == 0) {
@@ -151,12 +185,19 @@ public class IrrigationTaskController {
      * 删除任务
      */
     @DeleteMapping("/{id}")
+    @Transactional(rollbackFor = Exception.class)
     public Result<Void> deleteTask(@PathVariable Long id) {
         log.info("删除任务,taskId={}", id);
 
         try {
-            // 先删除Quartz Job
-            quartzManagementService.deleteJob(id);
+            // 删除所有定时规则的调度
+            quartzManagementService.unscheduleAllTaskRules(id);
+
+            // 逻辑删除所有定时规则
+            taskScheduleRuleMapper.deleteByTaskId(id);
+
+            // 逻辑删除所有联动规则
+            linkageRuleMapper.deleteByTaskId(id);
 
             // 逻辑删除任务
             int deleted = irrigationTaskMapper.deleteById(id);
@@ -164,6 +205,7 @@ public class IrrigationTaskController {
                 return Result.error("任务不存在或删除失败");
             }
 
+            log.info("任务删除成功,taskId={}", id);
             return Result.success();
 
         } catch (Exception e) {
@@ -173,7 +215,7 @@ public class IrrigationTaskController {
     }
 
     /**
-     * 启用任务(添加定时调度)
+     * 启用任务(为所有启用的规则添加定时调度)
      */
     @PostMapping("/{id}/enable")
     public Result<Void> enableTask(@PathVariable Long id) {
@@ -185,23 +227,12 @@ public class IrrigationTaskController {
                 return Result.error("任务不存在");
             }
 
-            if (!TriggerType.SCHEDULED.equals(task.getTriggerType())) {
-                return Result.error("只有定时触发类型的任务才需要启用调度");
-            }
-
-            // 根据调度类型添加Job
-            if (ScheduleType.CRON.equals(task.getScheduleType())) {
-                quartzManagementService.addCronJob(id, task.getCronExpression());
-            } else if (ScheduleType.SIMPLE.equals(task.getScheduleType())) {
-                quartzManagementService.addSimpleJob(id, task.getIntervalDays(), task.getTotalTimes());
-            } else {
-                return Result.error("未知的调度类型");
-            }
+            // 为所有启用的规则创建调度
+            quartzManagementService.scheduleAllTaskRules(id);
 
             // 更新任务状态
-            task.setEnabled(true);
-            task.setStatus(TaskStatus.ENABLED);
-            task.setUpdatedAt(LocalDateTime.now());
+            task.setEnabledFlag(BaseConstant.ENABLE);
+            task.setStatus(TaskStatus.FREE);
             irrigationTaskMapper.updateById(task);
 
             return Result.success();
@@ -213,22 +244,21 @@ public class IrrigationTaskController {
     }
 
     /**
-     * 禁用任务(暂停定时调度)
+     * 禁用任务(删除所有规则的定时调度)
      */
     @PostMapping("/{id}/disable")
     public Result<Void> disableTask(@PathVariable Long id) {
         log.info("禁用任务,taskId={}", id);
 
         try {
-            // 暂停Quartz Job
-            quartzManagementService.pauseJob(id);
+            // 删除所有规则的Quartz调度
+            quartzManagementService.unscheduleAllTaskRules(id);
 
             // 更新任务状态
             IrrigationTask task = irrigationTaskMapper.selectById(id);
             if (task != null) {
                 task.setEnabled(false);
                 task.setStatus(TaskStatus.DISABLED);
-                task.setUpdatedAt(LocalDateTime.now());
                 irrigationTaskMapper.updateById(task);
             }
 
@@ -244,8 +274,9 @@ public class IrrigationTaskController {
      * 手动触发任务
      */
     @PostMapping("/{id}/trigger")
-    public Result<Long> triggerTask(@PathVariable Long id) {
-        log.info("手动触发任务,taskId={}", id);
+    @ApiOperation("手动触发任务")
+    @Permission(level = ResourceLevel.ORGANIZATION)
+    public ResponseEntity<Long> triggerTask(@PathVariable Long id) {
 
         try {
             Long executionId = taskTriggerService.manualTrigger(id);
@@ -256,21 +287,4 @@ public class IrrigationTaskController {
             return Result.error("触发任务失败: " + e.getMessage());
         }
     }
-
-    /**
-     * 立即执行一次定时任务
-     */
-    @PostMapping("/{id}/execute-now")
-    public Result<Void> executeNow(@PathVariable Long id) {
-        log.info("立即执行任务,taskId={}", id);
-
-        try {
-            quartzManagementService.triggerJobNow(id);
-            return Result.success();
-
-        } catch (Exception e) {
-            log.error("立即执行任务失败,taskId={}", id, e);
-            return Result.error("执行失败: " + e.getMessage());
-        }
-    }
 }

+ 168 - 25
src/main/java/cn/sciento/farm/automationv2/api/dto/CreateTaskRequest.java

@@ -1,10 +1,16 @@
 package cn.sciento.farm.automationv2.api.dto;
 
+import cn.sciento.farm.automationv2.domain.entity.IrrigationTask;
+import cn.sciento.farm.automationv2.domain.enums.FertilizerControlMode;
+import cn.sciento.farm.automationv2.domain.enums.TaskStatus;
+import cn.sciento.farm.automationv2.infra.constant.BaseConstant;
+import com.fasterxml.jackson.annotation.JsonFormat;
 import lombok.Data;
 
 import javax.validation.constraints.NotBlank;
 import javax.validation.constraints.NotEmpty;
 import javax.validation.constraints.NotNull;
+import java.time.LocalDateTime;
 import java.util.List;
 
 /**
@@ -19,50 +25,143 @@ public class CreateTaskRequest {
     @NotBlank(message = "任务名称不能为空")
     private String taskName;
 
+
+    // ================== 定时规则列表(新增) ==================
+
     /**
-     * 任务编码
+     * 定时规则列表(可选,为空则不配置定时触发)
+     * 支持配置多个定时规则,CRON 和 SIMPLE 模式可以共存
      */
-    private String taskCode;
+    private List<ScheduleRuleDTO> scheduleRules;
 
     /**
-     * 触发类型:SCHEDULED / LINKAGE / MANUAL
+     * 定时规则DTO
      */
-    @NotBlank(message = "触发类型不能为空")
-    private String triggerType;
+    @Data
+    public static class ScheduleRuleDTO {
+        /**
+         * 规则名称(用于标识和管理)
+         */
+        @NotBlank(message = "规则名称不能为空")
+        private String ruleName;
+
+        /**
+         * 定时类型:CRON / SIMPLE
+         */
+        @NotBlank(message = "定时类型不能为空")
+        private String scheduleType;
+
+        /**
+         * Cron表达式(scheduleType=CRON时必填)
+         */
+        private String cronExpression;
+
+        /**
+         * 起始时间(scheduleType=SIMPLE时必填)
+         */
+        @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+        private LocalDateTime startTime;
+
+        /**
+         * 执行间隔天数(scheduleType=SIMPLE时必填)
+         */
+        private Integer intervalDays;
+
+        /**
+         * 执行总次数(scheduleType=SIMPLE时必填)
+         */
+        private Integer totalTimes;
+
+        /**
+         * 是否启用(默认true)
+         */
+        private Boolean enabled;
+    }
+
+    // ================== 联动规则列表(新增) ==================
 
     /**
-     * 定时类型:CRON / SIMPLE
+     * 传感器联动规则列表(可选,为空则不配置联动触发)
+     * 支持配置多个联动规则,可以绑定不同传感器或同一传感器的不同阈值条件
      */
-    private String scheduleType;
+    private List<LinkageRuleDTO> linkageRules;
 
     /**
-     * Cron表达式(scheduleType=CRON时必填)
+     * 联动规则DTO
      */
-    private String cronExpression;
+    @Data
+    public static class LinkageRuleDTO {
+        /**
+         * 规则名称(用于标识和管理)
+         */
+        @NotBlank(message = "规则名称不能为空")
+        private String ruleName;
+
+        /**
+         * 规则编码(可选)
+         */
+        private String ruleCode;
+
+        /**
+         * 传感器设备ID
+         */
+        @NotBlank(message = "传感器设备ID不能为空")
+        private String sensorDeviceId;
+
+        /**
+         * 传感器数据项编码(如:temperature, humidity, soilMoisture等)
+         */
+        @NotBlank(message = "传感器数据项编码不能为空")
+        private String dataItemCode;
+
+        /**
+         * 比较运算符:GT(>), GTE(>=), LT(<), LTE(<=), EQ(=), NEQ(!=)
+         */
+        @NotBlank(message = "比较运算符不能为空")
+        private String operator;
+
+        /**
+         * 阈值
+         */
+        @NotNull(message = "阈值不能为空")
+        private Double threshold;
+
+        /**
+         * 冷却时间(分钟)
+         * 触发后,在冷却时间内不再触发,防止频繁触发
+         */
+        private Integer cooldownMinutes;
+
+        /**
+         * 是否启用(默认true)
+         */
+        private Boolean enabled;
+    }
+
+    // ========== 水泵配置 ==========
 
     /**
-     * 执行间隔天数(scheduleType=SIMPLE时必填)
+     * 水泵设备ID
      */
-    private Integer intervalDays;
+    @NotNull(message = "水泵设备ID不能为空")
+    private Long pumpDeviceId;
 
     /**
-     * 执行总次数(scheduleType=SIMPLE时必填)
+     * 水泵设备名称
      */
-    private Integer totalTimes;
-
-    // ========== 水泵配置 ==========
+    @NotNull(message = "水泵设备名称不能为空")
+    private String pumpDeviceName;
 
     /**
-     * 水泵设备ID
+     * 水泵压力模式: 统一恒压=1 / 灌区恒压=2
      */
-    @NotBlank(message = "水泵设备ID不能为空")
-    private String pumpId;
+    @NotNull(message = "水泵压力模式不能为空")
+    private Integer pressureMode;
 
     /**
-     * 水泵压力模式:NONE / PUMP_UNIFIED / PUMP_ZONE
+     * 是否启用首部恒压
      */
-    @NotBlank(message = "水泵压力模式不能为空")
-    private String pressureMode;
+    private Integer isPump;
 
     /**
      * 统一目标压力值(kPa,PUMP_UNIFIED模式必填)
@@ -70,10 +169,16 @@ public class CreateTaskRequest {
     private Integer targetPressureKpa;
 
     /**
-     * 灌区切换稳压等待时间(秒),默认5
+     * 灌区切换稳压等待时间(秒),默认60
      */
     private Integer switchStableSeconds;
 
+
+    /**
+     * 是否启用球阀恒压
+     */
+    private Integer isDevicePump;
+
     // ========== 施肥机配置(可选) ==========
 
     /**
@@ -122,7 +227,7 @@ public class CreateTaskRequest {
     /**
      * 是否启用
      */
-    private Boolean enabled;
+    private Integer enabledFlag;
 
     /**
      * 租户ID
@@ -131,6 +236,12 @@ public class CreateTaskRequest {
     private Long tenantId;
 
     /**
+     * 基地ID
+     */
+    @NotNull(message = "基地不能为空")
+    private Long organizationId;
+
+    /**
      * 灌区配置DTO
      */
     @Data
@@ -148,9 +259,41 @@ public class CreateTaskRequest {
         private Integer sortOrder;
 
         /**
-         * 灌溉时长(分钟)
+         * 灌溉时长(秒)
+         */
+        @NotNull(message = "灌溉时长不能为空")
+        private Long irrigationDuration;
+
+        /**
+         * 球阀目标压力(bar)
+         * 仅在启用球阀恒压 时生效,球阀设备下发新压力值
          */
         @NotNull(message = "灌溉时长不能为空")
-        private Integer irrigationDurationMinutes;
+        private Integer devicePressureKpa;
+    }
+    
+    public IrrigationTask buildTask(){
+        return IrrigationTask.builder()
+                .taskName(taskName)
+                .pumpDeviceId(pumpDeviceId)
+                .pumpDeviceName(pumpDeviceName)
+                .isPump(isPump)
+                .pressureMode(pressureMode)
+                .targetPressureKpa(targetPressureKpa)
+                .isDevicePump(isDevicePump)
+                .switchStableSeconds(switchStableSeconds != null ? switchStableSeconds : 60)
+                .fertilizerPumpId(fertilizerPumpId)
+                .stirMotorId(stirMotorId)
+                .fertilizerControlMode(fertilizerControlMode != null ?
+                        FertilizerControlMode.valueOf(fertilizerControlMode) : null)
+                .fertDelayMinutes(fertDelayMinutes)
+                .preStirMinutes(preStirMinutes)
+                .fertDurationMinutes(fertDurationMinutes)
+                .fertTargetLiters(fertTargetLiters)
+                .status(TaskStatus.FREE)
+                .enabledFlag(enabledFlag != null ? enabledFlag : BaseConstant.ENABLE)
+                .tenantId(tenantId)
+                .organizationId(organizationId)
+                .build();
     }
 }

+ 18 - 10
src/main/java/cn/sciento/farm/automationv2/app/job/IrrigationScheduledJob.java

@@ -16,7 +16,8 @@ import org.springframework.stereotype.Component;
  * 说明:
  * - Job只负责触发,不执行具体业务
  * - 发送MQ消息后立即返回
- * - 任务ID通过JobDataMap传递
+ * - 任务ID和规则ID通过JobDataMap传递
+ * - 支持多规则触发:同一任务可配置多个定时规则
  */
 @Slf4j
 @Component
@@ -29,34 +30,41 @@ public class IrrigationScheduledJob extends QuartzJobBean {
      * JobDataMap参数key
      */
     public static final String PARAM_TASK_ID = "taskId";
+    public static final String PARAM_RULE_ID = "ruleId";
 
     @Override
     protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
-        // 获取任务ID
+        // 获取任务ID和规则ID
         JobDataMap dataMap = context.getMergedJobDataMap();
         Long taskId = dataMap.getLong(PARAM_TASK_ID);
+        Long ruleId = dataMap.getLong(PARAM_RULE_ID);
 
         if (taskId == null) {
             log.error("任务ID为空,jobKey={}", context.getJobDetail().getKey());
             return;
         }
 
-        log.info("定时任务触发,taskId={}, jobKey={}, fireTime={}",
-                taskId, context.getJobDetail().getKey(), context.getFireTime());
+        if (ruleId == null) {
+            log.error("规则ID为空,taskId={}, jobKey={}", taskId, context.getJobDetail().getKey());
+            return;
+        }
+
+        log.info("定时规则触发,taskId={}, ruleId={}, jobKey={}, fireTime={}",
+                taskId, ruleId, context.getJobDetail().getKey(), context.getFireTime());
 
         try {
             // 触发任务(发送MQ消息后立即返回)
-            Long executionId = taskTriggerService.scheduledTrigger(taskId);
+            Long executionId = taskTriggerService.scheduledTrigger(taskId, ruleId);
 
-            log.info("定时任务触发成功,taskId={}, executionId={}, nextFireTime={}",
-                    taskId, executionId, context.getNextFireTime());
+            log.info("定时规则触发成功,taskId={}, ruleId={}, executionId={}, nextFireTime={}",
+                    taskId, ruleId, executionId, context.getNextFireTime());
 
         } catch (Exception e) {
-            log.error("定时任务触发失败,taskId={}, jobKey={}",
-                    taskId, context.getJobDetail().getKey(), e);
+            log.error("定时规则触发失败,taskId={}, ruleId={}, jobKey={}",
+                    taskId, ruleId, context.getJobDetail().getKey(), e);
 
             // 抛出异常,Quartz会记录失败信息
-            throw new JobExecutionException("定时任务触发失败", e);
+            throw new JobExecutionException("定时规则触发失败", e);
         }
     }
 }

+ 217 - 121
src/main/java/cn/sciento/farm/automationv2/app/service/QuartzManagementService.java

@@ -2,15 +2,26 @@ package cn.sciento.farm.automationv2.app.service;
 
 import cn.sciento.farm.automationv2.app.job.IrrigationScheduledJob;
 import cn.sciento.farm.automationv2.domain.entity.IrrigationTask;
+import cn.sciento.farm.automationv2.domain.entity.TaskScheduleRule;
 import cn.sciento.farm.automationv2.infra.mapper.IrrigationTaskMapper;
+import cn.sciento.farm.automationv2.infra.mapper.TaskScheduleRuleMapper;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.quartz.*;
 import org.springframework.stereotype.Service;
 
+import java.time.ZoneId;
+import java.util.Date;
+import java.util.List;
+
 /**
  * Quartz调度管理服务
- * 功能:管理定时任务的添加、暂停、恢复、删除
+ * 功能:管理定时规则的添加、暂停、恢复、删除
+ *
+ * 设计说明:
+ * - 一个任务可以配置多个定时规则
+ * - 每个规则对应一个独立的 Quartz Job
+ * - Job 和 Trigger 的命名格式:TASK_{taskId}_RULE_{ruleId}
  */
 @Slf4j
 @Service
@@ -19,229 +30,314 @@ public class QuartzManagementService {
 
     private final Scheduler scheduler;
     private final IrrigationTaskMapper irrigationTaskMapper;
+    private final TaskScheduleRuleMapper taskScheduleRuleMapper;
 
     /**
-     * Job和Trigger的命名规则
+     * Job和Trigger的Group名称
      */
-    private static final String JOB_PREFIX = "irrigation_job_";
-    private static final String TRIGGER_PREFIX = "irrigation_trigger_";
     private static final String GROUP_NAME = "irrigation_group";
 
     /**
-     * 为任务添加Cron定时调度
+     * 为定时规则创建 Quartz 调度
      *
-     * @param taskId         任务ID
-     * @param cronExpression Cron表达式
+     * @param task 任务信息
+     * @param rule 定时规则
      */
-    public void addCronJob(Long taskId, String cronExpression) {
+    public void scheduleTaskRule(IrrigationTask task, TaskScheduleRule rule) {
         try {
-            // 加载任务信息
-            IrrigationTask task = irrigationTaskMapper.selectById(taskId);
-            if (task == null) {
-                throw new IllegalArgumentException("任务不存在,taskId=" + taskId);
+            if (!rule.isActive()) {
+                log.warn("规则未启用,跳过调度,ruleId={}", rule.getId());
+                return;
             }
 
             // 构建JobKey和TriggerKey
-            JobKey jobKey = JobKey.jobKey(JOB_PREFIX + taskId, GROUP_NAME);
-            TriggerKey triggerKey = TriggerKey.triggerKey(TRIGGER_PREFIX + taskId, GROUP_NAME);
+            JobKey jobKey = new JobKey(rule.getQuartzJobName(), GROUP_NAME);
+            TriggerKey triggerKey = new TriggerKey(rule.getQuartzTriggerName(), GROUP_NAME);
 
-            // 检查是否已存在
+            // 检查是否已存在,若存在则先删除
             if (scheduler.checkExists(jobKey)) {
-                log.warn("定时任务已存在,将先删除后重建,taskId={}", taskId);
-                deleteJob(taskId);
+                log.warn("定时规则调度已存在,将先删除后重建,ruleId={}", rule.getId());
+                unscheduleTaskRule(rule);
             }
 
             // 构建JobDetail
             JobDetail jobDetail = JobBuilder.newJob(IrrigationScheduledJob.class)
                     .withIdentity(jobKey)
-                    .withDescription("灌溉任务: " + task.getTaskName())
-                    .usingJobData(IrrigationScheduledJob.PARAM_TASK_ID, taskId)
+                    .withDescription(String.format("任务[%s]-规则[%s]", task.getTaskName(), rule.getRuleName()))
+                    .usingJobData(IrrigationScheduledJob.PARAM_TASK_ID, task.getId())
+                    .usingJobData(IrrigationScheduledJob.PARAM_RULE_ID, rule.getId())
                     .storeDurably()
                     .build();
 
-            // 构建CronTrigger
-            CronTrigger trigger = TriggerBuilder.newTrigger()
-                    .withIdentity(triggerKey)
-                    .withDescription("灌溉任务触发器: " + task.getTaskName())
-                    .withSchedule(CronScheduleBuilder.cronSchedule(cronExpression)
-                            .withMisfireHandlingInstructionFireAndProceed())
-                    .build();
+            // 根据规则类型创建不同的 Trigger
+            Trigger trigger;
+            if (rule.isCronMode()) {
+                // CRON 模式
+                trigger = TriggerBuilder.newTrigger()
+                        .withIdentity(triggerKey)
+                        .withDescription(rule.getRuleName())
+                        .withSchedule(CronScheduleBuilder.cronSchedule(rule.getCronExpression())
+                                .withMisfireHandlingInstructionDoNothing())
+                        .build();
+
+                log.info("创建CRON定时规则调度,taskId={}, ruleId={}, cron={}, nextFireTime={}",
+                        task.getId(), rule.getId(), rule.getCronExpression(), trigger.getNextFireTime());
+
+            } else if (rule.isSimpleMode()) {
+                // SIMPLE 模式
+                SimpleScheduleBuilder scheduleBuilder = SimpleScheduleBuilder.simpleSchedule()
+                        .withIntervalInMilliseconds(rule.getIntervalDays() * 24 * 3600 * 1000L)
+                        .withRepeatCount(rule.getTotalTimes() - 1)
+                        .withMisfireHandlingInstructionNextWithRemainingCount();
+
+                trigger = TriggerBuilder.newTrigger()
+                        .withIdentity(triggerKey)
+                        .withDescription(rule.getRuleName())
+                        .startAt(Date.from(rule.getStartTime().atZone(ZoneId.systemDefault()).toInstant()))
+                        .withSchedule(scheduleBuilder)
+                        .build();
+
+                log.info("创建SIMPLE定时规则调度,taskId={}, ruleId={}, startTime={}, intervalDays={}, totalTimes={}, nextFireTime={}",
+                        task.getId(), rule.getId(), rule.getStartTime(), rule.getIntervalDays(),
+                        rule.getTotalTimes(), trigger.getNextFireTime());
+
+            } else {
+                throw new IllegalArgumentException("不支持的定时类型: " + rule.getScheduleType());
+            }
 
-            // 调度Job
+            // 注册到 Quartz
             scheduler.scheduleJob(jobDetail, trigger);
 
-            log.info("添加Cron定时任务成功,taskId={}, cronExpression={}, nextFireTime={}",
-                    taskId, cronExpression, trigger.getNextFireTime());
+            log.info("定时规则调度创建成功,taskId={}, ruleId={}, type={}",
+                    task.getId(), rule.getId(), rule.getScheduleType());
 
         } catch (SchedulerException e) {
-            log.error("添加Cron定时任务失败,taskId={}, cronExpression={}",
-                    taskId, cronExpression, e);
-            throw new RuntimeException("添加Cron定时任务失败", e);
+            log.error("创建定时规则调度失败,taskId={}, ruleId={}",
+                    task.getId(), rule.getId(), e);
+            throw new RuntimeException("创建定时规则调度失败", e);
         }
     }
 
     /**
-     * 为任务添加简单周期调度
+     * 为任务的所有启用规则创建调度
      *
-     * @param taskId       任务ID
-     * @param intervalDays 间隔天数
-     * @param totalTimes   总执行次数(0表示无限)
+     * @param taskId 任务ID
      */
-    public void addSimpleJob(Long taskId, int intervalDays, int totalTimes) {
-        try {
-            // 加载任务信息
-            IrrigationTask task = irrigationTaskMapper.selectById(taskId);
-            if (task == null) {
-                throw new IllegalArgumentException("任务不存在,taskId=" + taskId);
-            }
+    public void scheduleAllTaskRules(Long taskId) {
+        IrrigationTask task = irrigationTaskMapper.selectById(taskId);
+        if (task == null) {
+            throw new IllegalArgumentException("任务不存在,taskId=" + taskId);
+        }
 
-            // 构建JobKey和TriggerKey
-            JobKey jobKey = JobKey.jobKey(JOB_PREFIX + taskId, GROUP_NAME);
-            TriggerKey triggerKey = TriggerKey.triggerKey(TRIGGER_PREFIX + taskId, GROUP_NAME);
+        List<TaskScheduleRule> rules = taskScheduleRuleMapper.selectEnabledByTaskId(taskId);
+        if (rules == null || rules.isEmpty()) {
+            log.info("任务没有启用的定时规则,taskId={}", taskId);
+            return;
+        }
 
-            // 检查是否已存在
-            if (scheduler.checkExists(jobKey)) {
-                log.warn("定时任务已存在,将先删除后重建,taskId={}", taskId);
-                deleteJob(taskId);
+        for (TaskScheduleRule rule : rules) {
+            try {
+                scheduleTaskRule(task, rule);
+            } catch (Exception e) {
+                log.error("创建规则调度失败,将继续处理其他规则,taskId={}, ruleId={}",
+                        taskId, rule.getId(), e);
             }
+        }
 
-            // 构建JobDetail
-            JobDetail jobDetail = JobBuilder.newJob(IrrigationScheduledJob.class)
-                    .withIdentity(jobKey)
-                    .withDescription("灌溉任务: " + task.getTaskName())
-                    .usingJobData(IrrigationScheduledJob.PARAM_TASK_ID, taskId)
-                    .storeDurably()
-                    .build();
+        log.info("任务定时规则调度批量创建完成,taskId={}, ruleCount={}", taskId, rules.size());
+    }
 
-            // 构建SimpleTrigger
-            SimpleScheduleBuilder scheduleBuilder = SimpleScheduleBuilder.simpleSchedule()
-                    .withIntervalInHours(intervalDays * 24)
-                    .withMisfireHandlingInstructionFireNow();
+    /**
+     * 删除定时规则的调度
+     *
+     * @param rule 定时规则
+     */
+    public void unscheduleTaskRule(TaskScheduleRule rule) {
+        try {
+            JobKey jobKey = new JobKey(rule.getQuartzJobName(), GROUP_NAME);
 
-            if (totalTimes > 0) {
-                scheduleBuilder.withRepeatCount(totalTimes - 1); // repeatCount = totalTimes - 1
-            } else {
-                scheduleBuilder.repeatForever();
+            if (!scheduler.checkExists(jobKey)) {
+                log.warn("定时规则调度不存在,ruleId={}", rule.getId());
+                return;
             }
 
-            SimpleTrigger trigger = TriggerBuilder.newTrigger()
-                    .withIdentity(triggerKey)
-                    .withDescription("灌溉任务触发器: " + task.getTaskName())
-                    .startNow()
-                    .withSchedule(scheduleBuilder)
-                    .build();
-
-            // 调度Job
-            scheduler.scheduleJob(jobDetail, trigger);
+            scheduler.deleteJob(jobKey);
 
-            log.info("添加简单周期任务成功,taskId={}, intervalDays={}, totalTimes={}, nextFireTime={}",
-                    taskId, intervalDays, totalTimes, trigger.getNextFireTime());
+            log.info("删除定时规则调度成功,ruleId={}", rule.getId());
 
         } catch (SchedulerException e) {
-            log.error("添加简单周期任务失败,taskId={}, intervalDays={}, totalTimes={}",
-                    taskId, intervalDays, totalTimes, e);
-            throw new RuntimeException("添加简单周期任务失败", e);
+            log.error("删除定时规则调度失败,ruleId={}", rule.getId(), e);
+            throw new RuntimeException("删除定时规则调度失败", e);
         }
     }
 
     /**
-     * 暂停定时任务
+     * 删除任务的所有规则调度
+     *
+     * @param taskId 任务ID
      */
-    public void pauseJob(Long taskId) {
-        try {
-            JobKey jobKey = JobKey.jobKey(JOB_PREFIX + taskId, GROUP_NAME);
+    public void unscheduleAllTaskRules(Long taskId) {
+        List<TaskScheduleRule> rules = taskScheduleRuleMapper.selectByTaskId(taskId);
+        if (rules == null || rules.isEmpty()) {
+            log.info("任务没有定时规则,taskId={}", taskId);
+            return;
+        }
 
-            if (!scheduler.checkExists(jobKey)) {
-                log.warn("定时任务不存在,taskId={}", taskId);
-                return;
+        for (TaskScheduleRule rule : rules) {
+            try {
+                unscheduleTaskRule(rule);
+            } catch (Exception e) {
+                log.error("删除规则调度失败,将继续处理其他规则,taskId={}, ruleId={}",
+                        taskId, rule.getId(), e);
             }
-
-            scheduler.pauseJob(jobKey);
-
-            log.info("暂停定时任务成功,taskId={}", taskId);
-
-        } catch (SchedulerException e) {
-            log.error("暂停定时任务失败,taskId={}", taskId, e);
-            throw new RuntimeException("暂停定时任务失败", e);
         }
+
+        log.info("任务定时规则调度批量删除完成,taskId={}, ruleCount={}", taskId, rules.size());
     }
 
     /**
-     * 恢复定时任务
+     * 暂停定时规则调度
+     *
+     * @param rule 定时规则
      */
-    public void resumeJob(Long taskId) {
+    public void pauseTaskRule(TaskScheduleRule rule) {
         try {
-            JobKey jobKey = JobKey.jobKey(JOB_PREFIX + taskId, GROUP_NAME);
+            JobKey jobKey = new JobKey(rule.getQuartzJobName(), GROUP_NAME);
 
             if (!scheduler.checkExists(jobKey)) {
-                log.warn("定时任务不存在,taskId={}", taskId);
+                log.warn("定时规则调度不存在,ruleId={}", rule.getId());
                 return;
             }
 
-            scheduler.resumeJob(jobKey);
+            scheduler.pauseJob(jobKey);
 
-            log.info("恢复定时任务成功,taskId={}", taskId);
+            log.info("暂停定时规则调度成功,ruleId={}", rule.getId());
 
         } catch (SchedulerException e) {
-            log.error("恢复定时任务失败,taskId={}", taskId, e);
-            throw new RuntimeException("恢复定时任务失败", e);
+            log.error("暂停定时规则调度失败,ruleId={}", rule.getId(), e);
+            throw new RuntimeException("暂停定时规则调度失败", e);
         }
     }
 
     /**
-     * 删除定时任务
+     * 恢复定时规则调度
+     *
+     * @param rule 定时规则
      */
-    public void deleteJob(Long taskId) {
+    public void resumeTaskRule(TaskScheduleRule rule) {
         try {
-            JobKey jobKey = JobKey.jobKey(JOB_PREFIX + taskId, GROUP_NAME);
+            JobKey jobKey = new JobKey(rule.getQuartzJobName(), GROUP_NAME);
 
             if (!scheduler.checkExists(jobKey)) {
-                log.warn("定时任务不存在,taskId={}", taskId);
+                log.warn("定时规则调度不存在,ruleId={}", rule.getId());
                 return;
             }
 
-            scheduler.deleteJob(jobKey);
+            scheduler.resumeJob(jobKey);
 
-            log.info("删除定时任务成功,taskId={}", taskId);
+            log.info("恢复定时规则调度成功,ruleId={}", rule.getId());
 
         } catch (SchedulerException e) {
-            log.error("删除定时任务失败,taskId={}", taskId, e);
-            throw new RuntimeException("删除定时任务失败", e);
+            log.error("恢复定时规则调度失败,ruleId={}", rule.getId(), e);
+            throw new RuntimeException("恢复定时规则调度失败", e);
         }
     }
 
     /**
-     * 立即执行一次任务(不影响原调度计划)
+     * 立即触发规则执行一次(不影响原调度计划)
+     *
+     * @param rule 定时规则
      */
-    public void triggerJobNow(Long taskId) {
+    public void triggerRuleNow(TaskScheduleRule rule) {
         try {
-            JobKey jobKey = JobKey.jobKey(JOB_PREFIX + taskId, GROUP_NAME);
+            JobKey jobKey = new JobKey(rule.getQuartzJobName(), GROUP_NAME);
 
             if (!scheduler.checkExists(jobKey)) {
-                log.warn("定时任务不存在,taskId={}", taskId);
+                log.warn("定时规则调度不存在,ruleId={}", rule.getId());
                 return;
             }
 
             scheduler.triggerJob(jobKey);
 
-            log.info("立即执行任务成功,taskId={}", taskId);
+            log.info("立即执行规则成功,ruleId={}", rule.getId());
 
         } catch (SchedulerException e) {
-            log.error("立即执行任务失败,taskId={}", taskId, e);
-            throw new RuntimeException("立即执行任务失败", e);
+            log.error("立即执行规则失败,ruleId={}", rule.getId(), e);
+            throw new RuntimeException("立即执行规则失败", e);
         }
     }
 
     /**
-     * 检查定时任务是否存在
+     * 检查规则调度是否存在
+     *
+     * @param rule 定时规则
+     * @return 是否存在
      */
-    public boolean jobExists(Long taskId) {
+    public boolean ruleJobExists(TaskScheduleRule rule) {
         try {
-            JobKey jobKey = JobKey.jobKey(JOB_PREFIX + taskId, GROUP_NAME);
+            JobKey jobKey = new JobKey(rule.getQuartzJobName(), GROUP_NAME);
             return scheduler.checkExists(jobKey);
         } catch (SchedulerException e) {
-            log.error("检查定时任务失败,taskId={}", taskId, e);
+            log.error("检查定时规则调度失败,ruleId={}", rule.getId(), e);
             return false;
         }
     }
+
+    // ================== 兼容旧版API(已废弃) ==================
+
+    /**
+     * @deprecated 已废弃,请使用 scheduleTaskRule(IrrigationTask, TaskScheduleRule)
+     */
+    @Deprecated
+    public void addCronJob(Long taskId, String cronExpression) {
+        throw new UnsupportedOperationException("此方法已废弃,请使用基于规则的调度管理");
+    }
+
+    /**
+     * @deprecated 已废弃,请使用 scheduleTaskRule(IrrigationTask, TaskScheduleRule)
+     */
+    @Deprecated
+    public void addSimpleJob(Long taskId, int intervalDays, int totalTimes) {
+        throw new UnsupportedOperationException("此方法已废弃,请使用基于规则的调度管理");
+    }
+
+    /**
+     * @deprecated 已废弃,请使用 unscheduleAllTaskRules(Long)
+     */
+    @Deprecated
+    public void deleteJob(Long taskId) {
+        unscheduleAllTaskRules(taskId);
+    }
+
+    /**
+     * @deprecated 已废弃,请使用 pauseTaskRule(TaskScheduleRule)
+     */
+    @Deprecated
+    public void pauseJob(Long taskId) {
+        throw new UnsupportedOperationException("此方法已废弃,请指定具体规则");
+    }
+
+    /**
+     * @deprecated 已废弃,请使用 resumeTaskRule(TaskScheduleRule)
+     */
+    @Deprecated
+    public void resumeJob(Long taskId) {
+        throw new UnsupportedOperationException("此方法已废弃,请指定具体规则");
+    }
+
+    /**
+     * @deprecated 已废弃,请使用 triggerRuleNow(TaskScheduleRule)
+     */
+    @Deprecated
+    public void triggerJobNow(Long taskId) {
+        throw new UnsupportedOperationException("此方法已废弃,请指定具体规则");
+    }
+
+    /**
+     * @deprecated 已废弃,请使用 ruleJobExists(TaskScheduleRule)
+     */
+    @Deprecated
+    public boolean jobExists(Long taskId) {
+        throw new UnsupportedOperationException("此方法已废弃,请指定具体规则");
+    }
 }

+ 23 - 8
src/main/java/cn/sciento/farm/automationv2/app/service/TaskTriggerService.java

@@ -1,5 +1,6 @@
 package cn.sciento.farm.automationv2.app.service;
 
+import cn.sciento.core.redis.RedisHelper;
 import cn.sciento.farm.automationv2.domain.entity.IrrigationGroup;
 import cn.sciento.farm.automationv2.domain.entity.IrrigationTask;
 import cn.sciento.farm.automationv2.domain.entity.TaskExecution;
@@ -9,13 +10,19 @@ import cn.sciento.farm.automationv2.domain.enums.TriggerType;
 import cn.sciento.farm.automationv2.domain.service.ExecutionPlanGenerator;
 import cn.sciento.farm.automationv2.domain.valueobject.ExecutionPlan;
 import cn.sciento.farm.automationv2.domain.valueobject.ZoneConfigView;
+import cn.sciento.farm.automationv2.infra.constant.BaseConstant;
+import cn.sciento.farm.automationv2.infra.constant.RedisConstant;
 import cn.sciento.farm.automationv2.infra.mq.producer.FlowControlProducer;
 import cn.sciento.farm.automationv2.infra.mapper.IrrigationGroupMapper;
 import cn.sciento.farm.automationv2.infra.mapper.IrrigationTaskMapper;
 import cn.sciento.farm.automationv2.infra.mapper.TaskExecutionMapper;
 import cn.sciento.farm.automationv2.infra.mapper.TaskGroupConfigMapper;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -23,6 +30,7 @@ import java.time.LocalDateTime;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
 
 /**
@@ -41,6 +49,10 @@ public class TaskTriggerService {
     private final ExecutionPlanGenerator executionPlanGenerator;
     private final FlowControlProducer flowControlProducer;
 
+    @Qualifier("redisHelper")
+    @Autowired
+    private RedisHelper redisHelper;
+
     /**
      * 触发任务执行
      *
@@ -60,21 +72,22 @@ public class TaskTriggerService {
         }
 
         // 检查任务是否启用
-        if (!task.getEnabled()) {
+        if (!BaseConstant.ENABLE.equals(task.getEnabledFlag())) {
             log.warn("任务未启用,taskId={}", taskId);
             throw new IllegalStateException("任务未启用,无法触发");
         }
 
         // 检查是否达到执行上限
-        if (task.reachedExecutionLimit()) {
-            log.warn("任务已达执行上限,taskId={}, executedCount={}, totalTimes={}",
-                    taskId, task.getExecutedCount(), task.getTotalTimes());
-            throw new IllegalStateException("任务已达执行上限");
-        }
+//        if (task.reachedExecutionLimit()) {
+//            log.warn("任务已达执行上限,taskId={}, executedCount={}, totalTimes={}",
+//                    taskId, task.getExecutedCount(), task.getTotalTimes());
+//            throw new IllegalStateException("任务已达执行上限");
+//        }
 
         // 加载任务灌区配置(灌区列表 + 灌溉时长)
         List<TaskGroupConfig> groupConfigs = taskGroupConfigMapper.selectByTaskId(taskId);
         if (groupConfigs == null || groupConfigs.isEmpty()) {
+            // TODO 执行结束
             log.error("任务未配置灌区,taskId={}", taskId);
             throw new IllegalStateException("任务未配置灌区");
         }
@@ -101,6 +114,8 @@ public class TaskTriggerService {
 
         // 生成执行计划
         ExecutionPlan plan = executionPlanGenerator.generate(task, zones);
+        //存缓存
+        redisHelper.strSet(RedisConstant.TASK_NODE + task.getId(),JSON.toJSONString(plan),2, TimeUnit.DAYS);
 
         log.info("执行计划生成完成,taskId={}, totalNodes={}, expectedDurationMinutes={}",
                 taskId, plan.getNodes().size(), plan.calculateExpectedDurationMinutes());
@@ -110,7 +125,7 @@ public class TaskTriggerService {
                 .taskId(taskId)
                 .taskName(task.getTaskName())
                 .triggerType(triggerType)
-                .executionPlan(plan)
+//                .executionPlan(plan)
                 .currentIndex(0)
                 .status(ExecutionStatus.PENDING)
                 .version(0)
@@ -130,7 +145,7 @@ public class TaskTriggerService {
                 execution.getId(), taskId, triggerType, execution.getExpectedFinishAt());
 
         // 更新任务执行次数
-        task.incrementExecutedCount();
+//        task.incrementExecutedCount();
         irrigationTaskMapper.updateById(task);
 
         // 发送任务启动消息

+ 33 - 0
src/main/java/cn/sciento/farm/automationv2/app/service/irrigationTask/IrrigationTaskService.java

@@ -0,0 +1,33 @@
+package cn.sciento.farm.automationv2.app.service.irrigationTask;
+
+
+import cn.sciento.core.domain.Page;
+import cn.sciento.farm.automationv2.api.dto.CreateTaskRequest;
+import cn.sciento.farm.automationv2.api.dto.IrrigationGroupDTO;
+import cn.sciento.farm.automationv2.domain.entity.IrrigationGroup;
+import cn.sciento.farm.automationv2.domain.entity.IrrigationTask;
+import cn.sciento.farm.automationv2.domain.valueobject.IrrigationTaskVO;
+import io.choerodon.mybatis.pagehelper.domain.PageRequest;
+
+/**
+ * @author Jayhaw
+ * @description
+ * @date 2021/4/10 11:13
+ */
+public interface IrrigationTaskService {
+    /**
+     * 添加信息
+     * @param request
+     * @return irrigationGroup
+     */
+    IrrigationTask create(CreateTaskRequest request);
+
+
+    /**
+     *获取page
+     * @param irrigationTask
+     * @return
+     */
+    Page<IrrigationTaskVO> page(IrrigationTask irrigationTask, PageRequest pageRequest);
+
+}

+ 96 - 0
src/main/java/cn/sciento/farm/automationv2/app/service/irrigationTask/impl/IrrigationTaskServiceImpl.java

@@ -0,0 +1,96 @@
+package cn.sciento.farm.automationv2.app.service.irrigationTask.impl;
+
+
+import cn.sciento.core.domain.Page;
+import cn.sciento.core.exception.CommonException;
+import cn.sciento.core.util.ValidUtils;
+import cn.sciento.farm.automationv2.api.dto.BallValuesDTO;
+import cn.sciento.farm.automationv2.api.dto.CreateTaskRequest;
+import cn.sciento.farm.automationv2.api.dto.IrrigationGroupDTO;
+import cn.sciento.farm.automationv2.app.service.irrigationGroup.IrrigationGroupService;
+import cn.sciento.farm.automationv2.app.service.irrigationTask.IrrigationTaskService;
+import cn.sciento.farm.automationv2.domain.entity.IrrigationGroup;
+import cn.sciento.farm.automationv2.domain.entity.IrrigationTask;
+import cn.sciento.farm.automationv2.domain.entity.TaskGroupConfig;
+import cn.sciento.farm.automationv2.domain.enums.FertilizerControlMode;
+import cn.sciento.farm.automationv2.domain.enums.PressureMode;
+import cn.sciento.farm.automationv2.domain.enums.TaskStatus;
+import cn.sciento.farm.automationv2.domain.enums.TriggerType;
+import cn.sciento.farm.automationv2.domain.repository.IrrigationGroupRepository;
+import cn.sciento.farm.automationv2.domain.repository.IrrigationTaskRepository;
+import cn.sciento.farm.automationv2.domain.repository.TaskGroupConfigRepository;
+import cn.sciento.farm.automationv2.domain.valueobject.IrrigationTaskVO;
+import cn.sciento.farm.automationv2.infra.constant.BaseConstant;
+import cn.sciento.farm.automationv2.infra.utils.BeanCopyUtils;
+import cn.sciento.mybatis.domian.Condition;
+import cn.sciento.mybatis.util.Sqls;
+import com.alibaba.fastjson.JSON;
+import io.choerodon.mybatis.pagehelper.PageHelper;
+import io.choerodon.mybatis.pagehelper.domain.PageRequest;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.validation.Validator;
+import java.util.ArrayList;
+import java.util.List;
+
+
+/**
+ * @author Jayhaw
+ * @description
+ * @date 2021/1/18 15:51
+ */
+@Transactional(rollbackFor = Exception.class)
+@Service
+public class IrrigationTaskServiceImpl implements IrrigationTaskService {
+
+    private IrrigationTaskRepository irrigationTaskRepository;
+
+    private TaskGroupConfigRepository taskGroupConfigRepository;
+
+    private Validator validator;
+
+    public IrrigationTaskServiceImpl(IrrigationTaskRepository irrigationTaskRepository,
+                                     Validator validator) {
+        this.irrigationTaskRepository = irrigationTaskRepository;
+        this.validator = validator;
+    }
+
+
+    private void validDTO(IrrigationGroupDTO dto) {
+        ValidUtils.valid(validator, dto);
+        if (dto.getBallValuesDTOS() != null) {
+            ValidUtils.valid(validator, dto.getBallValuesDTOS());
+        }
+    }
+
+
+    @Override
+    public IrrigationTask create(CreateTaskRequest request) {
+        // 1. 构建任务实体
+        IrrigationTask task =  request.buildTask();
+        // 2. 持久化任务
+        irrigationTaskRepository.insertSelective(task);
+        // 3. 保存灌区配置(任务与灌溉组的关联)
+        List<TaskGroupConfig> groupConfigs = new ArrayList<>();
+        for (CreateTaskRequest.GroupConfigDTO dto : request.getGroupConfigs()) {
+            TaskGroupConfig config = TaskGroupConfig.builder()
+                    .taskId(task.getId())
+                    .groupId(dto.getGroupId())
+                    .sortOrder(dto.getSortOrder())
+                    .irrigationDuration(dto.getIrrigationDuration())
+                    .devicePressureKpa(dto.getDevicePressureKpa())
+                    .build();
+            groupConfigs.add(config);
+        }
+        taskGroupConfigRepository.batchInsertSelective(groupConfigs);
+        return task;
+    }
+
+    @Override
+    public Page<IrrigationTaskVO> page(IrrigationTask irrigationTask, PageRequest pageRequest) {
+        return PageHelper.doPageAndSort(pageRequest,() -> {
+            return irrigationTaskRepository.page(irrigationTask);
+        });
+    }
+}

+ 23 - 84
src/main/java/cn/sciento/farm/automationv2/domain/entity/IrrigationTask.java

@@ -2,6 +2,7 @@ package cn.sciento.farm.automationv2.domain.entity;
 
 import cn.sciento.farm.automationv2.domain.enums.*;
 import com.fasterxml.jackson.annotation.JsonFormat;
+import io.choerodon.mybatis.domain.AuditDomain;
 import io.swagger.annotations.ApiModel;
 import lombok.AllArgsConstructor;
 import lombok.Builder;
@@ -23,7 +24,7 @@ import java.time.LocalDateTime;
 @Builder
 @NoArgsConstructor
 @AllArgsConstructor
-public class IrrigationTask {
+public class IrrigationTask extends AuditDomain {
 
     /**
      * 任务ID
@@ -39,68 +40,49 @@ public class IrrigationTask {
 
     /**
      * 触发类型:SCHEDULED / LINKAGE / MANUAL
+     * 注意:定时触发规则已迁移至 task_schedule_rule 表
+     * 此字段保留用于向后兼容和标识任务触发方式
      */
     private TriggerType triggerType;
 
-    // ================== 定时触发配置 ==================
-
-    /**
-     * 定时类型:CRON / SIMPLE(仅trigger_type=SCHEDULED时有效)
-     */
-    private ScheduleType scheduleType;
-
-    /**
-     * Cron表达式(schedule_type=CRON)
-     */
-    private String cronExpression;
-
-    /**
-     * 起始时间(schedule_type=SIMPLE)
-     */
-    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
-    private LocalDateTime startTime;
+    // ================== 水泵配置 ==================
 
     /**
-     * 执行间隔天数(schedule_type=SIMPLE)
+     * 水泵设备ID
      */
-    private Integer intervalDays;
+    private Long pumpDeviceId;
 
     /**
-     * 执行总次数(schedule_type=SIMPLE)
+     * 水泵设备名称
      */
-    private Integer totalTimes;
+    private String pumpDeviceName;
 
     /**
-     * 已执行次数(schedule_type=SIMPLE)
+     * 是否启用首部恒压
      */
-    private Integer executedCount;
-
-    // ================== 水泵配置 ==================
+    private Integer isPump;
 
     /**
-     * 水泵设备ID
+     * 水泵压力模式: 统一恒压=1 / 灌区恒压=2
      */
-    private String pumpId;
+    private Integer pressureMode;
 
     /**
-     * 水泵压力模式:NONE / PUMP_UNIFIED / PUMP_ZONE
-     * NONE - 普通启停,不下发压力指令
-     * PUMP_UNIFIED - 统一压力,所有灌区使用同一压力值
-     * PUMP_ZONE - 分区压力,每个灌区可设置不同的水泵目标压力
+     * 统一目标压力值(kPa,仅 PUMP_UNIFIED 模式需配置)
      */
-    private PressureMode pressureMode;
+    private Integer targetPressureKpa;
 
     /**
-     * 统一目标压力值(kPa,仅 PUMP_UNIFIED 模式需配置)
+     * 是否启用球阀恒压
      */
-    private Integer targetPressureKpa;
+    private Integer isDevicePump;
 
     // ================== 安全参数 ==================
 
     /**
      * 灌区切换稳压等待时间(秒)
      * 用于平衡管路水压,防止压力突变损坏设备或爆管
-     * 默认值:5秒
+     * 默认值:1分钟
      */
     private Integer switchStableSeconds;
 
@@ -153,9 +135,9 @@ public class IrrigationTask {
     private TaskStatus status;
 
     /**
-     * 是否启用:1启用 0禁用
+     * 是否启用:1启用 0禁用 2删除
      */
-    private Boolean enabled;
+    private Integer enabledFlag;
 
     // ================== 多租户 ==================
 
@@ -164,34 +146,11 @@ public class IrrigationTask {
      */
     private Long tenantId;
 
-    // ================== 审计字段 ==================
-
     /**
-     * 创建人ID
+     * 基地ID
      */
-    private Long createdBy;
+    private Long organizationId;
 
-    /**
-     * 创建时间
-     */
-    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
-    private LocalDateTime createdAt;
-
-    /**
-     * 更新人ID
-     */
-    private Long updatedBy;
-
-    /**
-     * 更新时间
-     */
-    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
-    private LocalDateTime updatedAt;
-
-    /**
-     * 逻辑删除:0未删除 1已删除
-     */
-    private Boolean deleted;
 
     // ================== 业务方法 ==================
 
@@ -253,30 +212,10 @@ public class IrrigationTask {
     }
 
     /**
-     * Simple调度是否已达到执行次数上限
-     */
-    public boolean reachedExecutionLimit() {
-        return ScheduleType.SIMPLE.equals(scheduleType)
-                && executedCount != null
-                && totalTimes != null
-                && executedCount >= totalTimes;
-    }
-
-    /**
-     * 增加已执行次数
-     */
-    public void incrementExecutedCount() {
-        if (executedCount == null) {
-            executedCount = 0;
-        }
-        executedCount++;
-    }
-
-    /**
      * 获取稳压等待时间(秒)
      * 默认值:5秒
      */
     public int getSwitchStableSecondsOrDefault() {
-        return switchStableSeconds != null ? switchStableSeconds : 5;
+        return switchStableSeconds != null ? switchStableSeconds : 60;
     }
 }

+ 1 - 0
src/main/java/cn/sciento/farm/automationv2/domain/entity/TaskExecution.java

@@ -57,6 +57,7 @@ public class TaskExecution {
      * 执行计划(核心字段:预生成的完整有序节点列表)
      * 数据库中存储为JSON格式
      */
+    // TODO 存缓存
     @ColumnType(jdbcType = JdbcType.VARCHAR, typeHandler = JSONHandler.class)
     private ExecutionPlan executionPlan;
 

+ 7 - 19
src/main/java/cn/sciento/farm/automationv2/domain/entity/TaskGroupConfig.java

@@ -1,6 +1,7 @@
 package cn.sciento.farm.automationv2.domain.entity;
 
 import com.fasterxml.jackson.annotation.JsonFormat;
+import io.choerodon.mybatis.domain.AuditDomain;
 import io.swagger.annotations.ApiModel;
 import lombok.AllArgsConstructor;
 import lombok.Builder;
@@ -23,7 +24,7 @@ import java.time.LocalDateTime;
 @Builder
 @NoArgsConstructor
 @AllArgsConstructor
-public class TaskGroupConfig {
+public class TaskGroupConfig extends AuditDomain{
 
     /**
      * 配置ID
@@ -48,29 +49,16 @@ public class TaskGroupConfig {
     private Integer sortOrder;
 
     /**
-     * 灌溉时长(分钟,任务级配置)
+     * 灌溉时长(,任务级配置)
      * 每个灌区可设置不同时长
      */
-    private Integer irrigationDurationMinutes;
+    private Long irrigationDuration;
 
     /**
-     * 创建时间
+     * 球阀目标压力(bar)
+     * 仅在启用球阀恒压 时生效,球阀设备下发新压力值
      */
-    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
-    private LocalDateTime createdAt;
+    private Integer devicePressureKpa;
 
-    /**
-     * 更新时间
-     */
-    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
-    private LocalDateTime updatedAt;
-
-    // ================== 业务方法 ==================
 
-    /**
-     * 获取灌溉时长(秒)
-     */
-    public int getIrrigationDurationSeconds() {
-        return irrigationDurationMinutes != null ? irrigationDurationMinutes * 60 : 0;
-    }
 }

+ 193 - 0
src/main/java/cn/sciento/farm/automationv2/domain/entity/TaskScheduleRule.java

@@ -0,0 +1,193 @@
+package cn.sciento.farm.automationv2.domain.entity;
+
+import cn.sciento.farm.automationv2.domain.enums.ScheduleType;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.choerodon.mybatis.domain.AuditDomain;
+import io.swagger.annotations.ApiModel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import javax.persistence.GeneratedValue;
+import javax.persistence.Id;
+import javax.persistence.Table;
+import java.time.LocalDateTime;
+
+/**
+ * 任务定时规则实体
+ * 对应数据库表:task_schedule_rule
+ *
+ * 一个任务可以关联多个定时规则,每个规则可以是 CRON 或 SIMPLE 类型
+ */
+@Data
+@Table(name = "wfauto_v2_task_schedule_rule")
+@ApiModel("任务定时规则")
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class TaskScheduleRule extends AuditDomain {
+
+    /**
+     * 规则ID
+     */
+    @Id
+    @GeneratedValue
+    private Long id;
+
+    /**
+     * 关联的任务ID
+     */
+    private Long taskId;
+
+    /**
+     * 规则名称(用于标识和管理)
+     * 例如:"每周一三五凌晨2点" / "连续10天灌溉计划"
+     */
+    private String ruleName;
+
+    /**
+     * 定时类型:CRON / SIMPLE
+     */
+    private ScheduleType scheduleType;
+
+    // ================== CRON 模式字段 ==================
+
+    /**
+     * Cron表达式(schedule_type=CRON时必填)
+     * 例如:"0 0 2 ? * MON,WED,FRI" 表示每周一、三、五凌晨2点
+     */
+    private String cronExpression;
+
+    // ================== SIMPLE 模式字段 ==================
+
+    /**
+     * 起始时间(schedule_type=SIMPLE时必填)
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime startTime;
+
+    /**
+     * 执行间隔天数(schedule_type=SIMPLE时必填)
+     */
+    private Integer intervalDays;
+
+    /**
+     * 执行总次数(schedule_type=SIMPLE时必填)
+     */
+    private Integer totalTimes;
+
+    /**
+     * 已执行次数
+     */
+    private Integer executedCount;
+
+    // ================== 状态控制 ==================
+
+    /**
+     * 是否启用:true启用 false禁用
+     */
+    private Boolean enabled;
+
+    /**
+     * 规则状态:ACTIVE活跃 / COMPLETED已完成 / DISABLED已禁用
+     * ACTIVE: 规则正在生效
+     * COMPLETED: SIMPLE模式下已达到执行次数上限,自动停用
+     * DISABLED: 用户手动禁用
+     */
+    private Integer status;
+
+    // ================== Quartz 调度关联 ==================
+
+    /**
+     * Quartz Job名称(自动生成)
+     * 格式:TASK_{taskId}_RULE_{ruleId}
+     */
+    private String quartzJobName;
+
+    /**
+     * Quartz Trigger名称(自动生成)
+     * 格式:TRIGGER_{taskId}_RULE_{ruleId}
+     */
+    private String quartzTriggerName;
+
+    // ================== 多租户 ==================
+
+    /**
+     * 租户ID
+     */
+    private Long tenantId;
+
+
+    /**
+     * 是否启用,1=启用
+     */
+    private Integer enabledFlag;
+
+    // ================== 业务方法 ==================
+
+    /**
+     * 是否为 CRON 模式
+     */
+    public boolean isCronMode() {
+        return ScheduleType.CRON.equals(scheduleType);
+    }
+
+    /**
+     * 是否为 SIMPLE 模式
+     */
+    public boolean isSimpleMode() {
+        return ScheduleType.SIMPLE.equals(scheduleType);
+    }
+
+    /**
+     * SIMPLE 模式是否已达到执行次数上限
+     */
+    public boolean reachedExecutionLimit() {
+        return isSimpleMode()
+                && executedCount != null
+                && totalTimes != null
+                && executedCount >= totalTimes;
+    }
+
+    /**
+     * 增加已执行次数
+     */
+    public void incrementExecutedCount() {
+        if (executedCount == null) {
+            executedCount = 0;
+        }
+        executedCount++;
+    }
+
+    /**
+     * 是否有效(启用且未完成)
+     */
+    public boolean isActive() {
+        return Boolean.TRUE.equals(enabled) && "ACTIVE".equals(status);
+    }
+
+    /**
+     * 生成 Quartz Job 名称
+     */
+    public static String generateJobName(Long taskId, Long ruleId) {
+        return String.format("TASK_%d_RULE_%d", taskId, ruleId);
+    }
+
+    /**
+     * 生成 Quartz Trigger 名称
+     */
+    public static String generateTriggerName(Long taskId, Long ruleId) {
+        return String.format("TRIGGER_%d_RULE_%d", taskId, ruleId);
+    }
+
+    /**
+     * 填充Quartz相关字段
+     */
+    public void fillQuartzNames() {
+        if (this.taskId != null && this.id != null) {
+            this.quartzJobName = generateJobName(this.taskId, this.id);
+            this.quartzTriggerName = generateTriggerName(this.taskId, this.id);
+        }
+    }
+}

+ 7 - 7
src/main/java/cn/sciento/farm/automationv2/domain/enums/TaskStatus.java

@@ -11,21 +11,21 @@ import lombok.Getter;
 public enum TaskStatus {
 
     /**
-     * 启用
+     * 空闲中
      */
-    ENABLED("ENABLED", "启用"),
+    FREE(0, "空闲中"),
 
     /**
-     * 禁用
+     * 灌溉中
      */
-    DISABLED("DISABLED", "禁用"),
+    WORKING(1, "灌溉中"),
 
     /**
-     * 已删除
+     * 暂停中
      */
-    DELETED("DELETED", "已删除");
+    WAITING(2, "暂停中");
 
-    private final String code;
+    private final Integer code;
     private final String desc;
 
     public static TaskStatus fromCode(String code) {

+ 14 - 0
src/main/java/cn/sciento/farm/automationv2/domain/repository/IrrigationTaskRepository.java

@@ -1,7 +1,13 @@
 package cn.sciento.farm.automationv2.domain.repository;
 
+import cn.sciento.core.domain.Page;
 import cn.sciento.farm.automationv2.domain.entity.IrrigationTask;
+import cn.sciento.farm.automationv2.domain.valueobject.IrrigationTaskVO;
+import cn.sciento.farm.automationv2.infra.mapper.IrrigationTaskMapper;
 import cn.sciento.mybatis.base.BaseRepository;
+import io.choerodon.mybatis.pagehelper.domain.PageRequest;
+
+import java.util.List;
 
 /**
  * <p>
@@ -13,4 +19,12 @@ import cn.sciento.mybatis.base.BaseRepository;
  */
 public interface IrrigationTaskRepository extends BaseRepository<IrrigationTask> {
 
+    /**
+     * 列表
+     * @param irrigationTask
+     * @return
+     */
+    List<IrrigationTaskVO> page(IrrigationTask irrigationTask);
+
+
 }

+ 14 - 0
src/main/java/cn/sciento/farm/automationv2/domain/repository/TaskScheduleRuleRepository.java

@@ -0,0 +1,14 @@
+package cn.sciento.farm.automationv2.domain.repository;
+
+import cn.sciento.farm.automationv2.domain.entity.TaskScheduleRule;
+import cn.sciento.mybatis.base.BaseRepository;
+
+/**
+ * 任务定时规则 Repository
+ *
+ * @author Generated
+ * @since 2026/03/02
+ */
+public interface TaskScheduleRuleRepository extends BaseRepository<TaskScheduleRule> {
+
+}

+ 15 - 8
src/main/java/cn/sciento/farm/automationv2/domain/service/ExecutionPlanGenerator.java

@@ -1,5 +1,6 @@
 package cn.sciento.farm.automationv2.domain.service;
 
+import cn.sciento.core.redis.RedisHelper;
 import cn.sciento.farm.automationv2.domain.entity.IrrigationTask;
 import cn.sciento.farm.automationv2.domain.enums.NodeType;
 import cn.sciento.farm.automationv2.domain.valueobject.DeviceInfo;
@@ -10,6 +11,8 @@ import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.stereotype.Service;
 
 import java.util.ArrayList;
@@ -124,7 +127,7 @@ public class ExecutionPlanGenerator {
         List<DeviceInfo> devices = new ArrayList<>();
 
         // 添加电磁阀设备
-        devices.addAll(parseDeviceList(zone.getSolenoidValves(), "SOLENOID_VALVE"));
+//        devices.addAll(parseDeviceList(zone.getSolenoidValves(), "SOLENOID_VALVE"));
 
         // 添加球阀设备
         devices.addAll(parseDeviceList(zone.getBallValves(), "BALL_VALVE"));
@@ -134,13 +137,17 @@ public class ExecutionPlanGenerator {
         if (zone.getBallValves() != null && !zone.getBallValves().isEmpty()) {
             JSONArray ballValves = JSON.parseArray(zone.getBallValves());
             if (!ballValves.isEmpty()) {
-                JSONObject firstBallValve = ballValves.getJSONObject(0);
-                if (firstBallValve.containsKey("targetAngle")) {
-                    params.put("targetAngle", firstBallValve.getInteger("targetAngle"));
-                }
-                // 球阀目标压力(可选,与水泵压力独立)
-                if (firstBallValve.containsKey("targetPressureKpa")) {
-                    params.put("targetPressureKpa", firstBallValve.getInteger("targetPressureKpa"));
+                // TODO 判断,如果任务设定了球阀恒压,则传压力,否则传角度
+                if () {
+                    JSONObject firstBallValve = ballValves.getJSONObject(0);
+                    // 角度
+                    if (firstBallValve.containsKey("targetAngle")) {
+                        params.put("targetAngle", firstBallValve.getInteger("targetAngle"));
+                    }
+                    // 球阀目标压力(可选,与水泵压力独立)
+                    if (firstBallValve.containsKey("targetPressureKpa")) {
+                        params.put("targetPressureKpa", firstBallValve.getInteger("targetPressureKpa"));
+                    }
                 }
             }
         }

+ 86 - 0
src/main/java/cn/sciento/farm/automationv2/domain/valueobject/IrrigationTaskVO.java

@@ -0,0 +1,86 @@
+package cn.sciento.farm.automationv2.domain.valueobject;
+
+import cn.sciento.farm.automationv2.domain.enums.AckStatus;
+import cn.sciento.farm.automationv2.domain.enums.TaskStatus;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import javax.persistence.GeneratedValue;
+import javax.persistence.Id;
+import java.time.LocalDateTime;
+
+/**
+ * 轮灌任务VO
+ */
+@Data
+public class IrrigationTaskVO {
+
+    /**
+     * 任务ID
+     */
+    @Id
+    @GeneratedValue
+    private Long id;
+
+    /**
+     * 任务名称
+     */
+    private String taskName;
+
+    /**
+     * 水泵设备ID
+     */
+    private Long pumpDeviceId;
+
+    /**
+     * 水泵设备名称
+     */
+    private String pumpDeviceName;
+
+    /**
+     * 是否启用首部恒压
+     */
+    private Integer isPump;
+
+    /**
+     * 水泵压力模式: 统一恒压=1 / 灌区恒压=2
+     */
+    private Integer pressureMode;
+
+    /**
+     * 统一目标压力值(kPa,仅 PUMP_UNIFIED 模式需配置)
+     */
+    private Integer targetPressureKpa;
+
+    /**
+     * 是否启用球阀恒压
+     */
+    private Integer isDevicePump;
+
+    /**
+     * 任务状态:FREE / WORKING / WAITING
+     */
+    private TaskStatus status;
+
+    /**
+     * 是否启用:1启用 0禁用 2删除
+     */
+    private Integer enabledFlag;
+
+    /**
+     * 租户ID
+     */
+    private Long tenantId;
+
+    /**
+     * 基地ID
+     */
+    private Long organizationId;
+
+    /**
+     * 轮罐组数量
+     */
+    private Integer groupCount;
+}

+ 3 - 3
src/main/java/cn/sciento/farm/automationv2/domain/valueobject/ZoneConfigView.java

@@ -48,9 +48,9 @@ public class ZoneConfigView {
     private String ballValves;
 
     /**
-     * 灌溉时长(分钟)- 来自任务级配置
+     * 灌溉时长()- 来自任务级配置
      */
-    private Integer irrigationDurationMinutes;
+    private Long irrigationDuration;
 
     /**
      * 从灌溉组和任务配置合并创建
@@ -63,7 +63,7 @@ public class ZoneConfigView {
                 .zonePressureKpa(group.getZonePressureKpa())
                 .solenoidValves(group.getSolenoidValves())
                 .ballValves(group.getBallValves())
-                .irrigationDurationMinutes(config.getIrrigationDurationMinutes())
+                .irrigationDuration(config.getIrrigationDuration())
                 .build();
     }
 

+ 14 - 0
src/main/java/cn/sciento/farm/automationv2/infra/constant/RedisConstant.java

@@ -0,0 +1,14 @@
+package cn.sciento.farm.automationv2.infra.constant;
+
+/**
+ * @Date 2026年02月28日 16:05
+ * @Author Jayhaw
+ * @Description Redis字段
+ * @Verion 1.0
+ */
+public interface RedisConstant {
+
+    String TASK_NODE = "wfauto_v2_automation_task:";
+
+
+}

+ 6 - 0
src/main/java/cn/sciento/farm/automationv2/infra/mapper/IrrigationTaskMapper.java

@@ -1,6 +1,7 @@
 package cn.sciento.farm.automationv2.infra.mapper;
 
 import cn.sciento.farm.automationv2.domain.entity.IrrigationTask;
+import cn.sciento.farm.automationv2.domain.valueobject.IrrigationTaskVO;
 import io.choerodon.mybatis.common.BaseMapper;
 import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
@@ -66,4 +67,9 @@ public interface IrrigationTaskMapper extends BaseMapper<IrrigationTask> {
      * 统计租户的任务数量
      */
     int countByTenant(@Param("tenantId") Long tenantId);
+
+    /**
+     * 查询列表
+     */
+    List<IrrigationTaskVO> selectList(@Param("task") IrrigationTask task);
 }

+ 5 - 0
src/main/java/cn/sciento/farm/automationv2/infra/mapper/LinkageRuleMapper.java

@@ -65,6 +65,11 @@ public interface LinkageRuleMapper extends BaseMapper<LinkageRule> {
     int deleteById(@Param("id") Long id);
 
     /**
+     * 根据任务ID逻辑删除所有规则
+     */
+    int deleteByTaskId(@Param("taskId") Long taskId);
+
+    /**
      * 分页查询
      */
     List<LinkageRule> selectByTenant(@Param("tenantId") Long tenantId,

+ 88 - 0
src/main/java/cn/sciento/farm/automationv2/infra/mapper/TaskScheduleRuleMapper.java

@@ -0,0 +1,88 @@
+package cn.sciento.farm.automationv2.infra.mapper;
+
+import cn.sciento.farm.automationv2.domain.entity.TaskScheduleRule;
+import io.choerodon.mybatis.common.BaseMapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 任务定时规则 Mapper
+ */
+public interface TaskScheduleRuleMapper extends BaseMapper<TaskScheduleRule> {
+
+    /**
+     * 插入规则
+     */
+    int insert(TaskScheduleRule rule);
+
+    /**
+     * 批量插入规则
+     */
+    int batchInsert(@Param("rules") List<TaskScheduleRule> rules);
+
+    /**
+     * 根据ID查询
+     */
+    TaskScheduleRule selectById(@Param("id") Long id);
+
+    /**
+     * 根据任务ID查询所有规则
+     */
+    List<TaskScheduleRule> selectByTaskId(@Param("taskId") Long taskId);
+
+    /**
+     * 根据任务ID查询所有启用的规则
+     */
+    List<TaskScheduleRule> selectEnabledByTaskId(@Param("taskId") Long taskId);
+
+    /**
+     * 查询所有活跃的规则(启用且状态为ACTIVE)
+     */
+    List<TaskScheduleRule> selectActiveRules(@Param("tenantId") Long tenantId);
+
+    /**
+     * 更新规则
+     */
+    int updateById(TaskScheduleRule rule);
+
+    /**
+     * 更新已执行次数
+     */
+    int updateExecutedCount(@Param("id") Long id,
+                           @Param("executedCount") Integer executedCount);
+
+    /**
+     * 更新规则状态
+     */
+    int updateStatus(@Param("id") Long id,
+                    @Param("status") String status,
+                    @Param("enabled") Boolean enabled);
+
+    /**
+     * 更新Quartz相关字段
+     */
+    int updateQuartzNames(@Param("id") Long id,
+                         @Param("quartzJobName") String quartzJobName,
+                         @Param("quartzTriggerName") String quartzTriggerName);
+
+    /**
+     * 逻辑删除规则
+     */
+    int deleteById(@Param("id") Long id);
+
+    /**
+     * 批量逻辑删除任务的所有规则
+     */
+    int deleteByTaskId(@Param("taskId") Long taskId);
+
+    /**
+     * 统计任务的规则数量
+     */
+    int countByTaskId(@Param("taskId") Long taskId);
+
+    /**
+     * 统计任务的启用规则数量
+     */
+    int countEnabledByTaskId(@Param("taskId") Long taskId);
+}

+ 14 - 0
src/main/java/cn/sciento/farm/automationv2/infra/repository/impl/IrrigationTaskRepositoryImpl.java

@@ -2,9 +2,13 @@ package cn.sciento.farm.automationv2.infra.repository.impl;
 
 import cn.sciento.farm.automationv2.domain.entity.IrrigationTask;
 import cn.sciento.farm.automationv2.domain.repository.IrrigationTaskRepository;
+import cn.sciento.farm.automationv2.domain.valueobject.IrrigationTaskVO;
+import cn.sciento.farm.automationv2.infra.mapper.IrrigationTaskMapper;
 import cn.sciento.mybatis.base.impl.BaseRepositoryImpl;
 import org.springframework.stereotype.Service;
 
+import java.util.List;
+
 /**
  * <p>
  *       数据层
@@ -16,4 +20,14 @@ import org.springframework.stereotype.Service;
 @Service
 public class IrrigationTaskRepositoryImpl extends BaseRepositoryImpl<IrrigationTask> implements IrrigationTaskRepository {
 
+    private IrrigationTaskMapper mapper;
+
+    public IrrigationTaskRepositoryImpl(IrrigationTaskMapper mapper) {
+        this.mapper = mapper;
+    }
+
+    @Override
+    public List<IrrigationTaskVO> page(IrrigationTask irrigationTask) {
+        return mapper.selectList(irrigationTask);
+    }
 }

+ 17 - 0
src/main/java/cn/sciento/farm/automationv2/infra/repository/impl/TaskScheduleRuleRepositoryImpl.java

@@ -0,0 +1,17 @@
+package cn.sciento.farm.automationv2.infra.repository.impl;
+
+import cn.sciento.farm.automationv2.domain.entity.TaskScheduleRule;
+import cn.sciento.farm.automationv2.domain.repository.TaskScheduleRuleRepository;
+import cn.sciento.mybatis.base.impl.BaseRepositoryImpl;
+import org.springframework.stereotype.Service;
+
+/**
+ * 任务定时规则 Repository 实现
+ *
+ * @author Generated
+ * @since 2026/03/02
+ */
+@Service
+public class TaskScheduleRuleRepositoryImpl extends BaseRepositoryImpl<TaskScheduleRule> implements TaskScheduleRuleRepository {
+
+}

+ 3 - 8
src/main/resources/mapper/IrrigationGroupMapper.xml

@@ -5,17 +5,12 @@
     <resultMap id="BaseResultMap" type="cn.sciento.farm.automationv2.domain.entity.IrrigationGroup">
         <id column="id" property="id"/>
         <result column="group_name" property="groupName"/>
-        <result column="group_code" property="groupCode"/>
         <result column="zone_pressure_kpa" property="zonePressureKpa"/>
         <result column="solenoid_valves" property="solenoidValves"/>
         <result column="ball_valves" property="ballValves"/>
-        <result column="enabled" property="enabled"/>
+        <result column="enabled_flag" property="enabledFlag"/>
         <result column="tenant_id" property="tenantId"/>
-        <result column="created_by" property="createdBy"/>
-        <result column="created_at" property="createdAt"/>
-        <result column="updated_by" property="updatedBy"/>
-        <result column="updated_at" property="updatedAt"/>
-        <result column="deleted" property="deleted"/>
+        <result column="organization_id" property="organizationId"/>
     </resultMap>
 
     <insert id="insert" parameterType="cn.sciento.farm.automationv2.domain.entity.IrrigationGroup" useGeneratedKeys="true" keyProperty="id">
@@ -49,7 +44,7 @@
         <foreach collection="ids" item="id" open="(" separator="," close=")">
             #{id}
         </foreach>
-        AND deleted = 0
+        AND enabled_flag = 1
     </select>
 
     <!-- 以下方法已过时,灌溉组已独立于任务 -->

+ 74 - 25
src/main/resources/mapper/IrrigationTaskMapper.xml

@@ -3,31 +3,31 @@
 <mapper namespace="cn.sciento.farm.automationv2.infra.mapper.IrrigationTaskMapper">
 
     <!-- ResultMap -->
-    <resultMap id="BaseResultMap" type="cn.sciento.farm.automationv2.domain.entity.IrrigationTask">
-        <id column="id" property="id"/>
-        <result column="task_name" property="taskName"/>
-        <result column="task_code" property="taskCode"/>
-        <result column="trigger_type" property="triggerType" typeHandler="org.apache.ibatis.type.EnumTypeHandler"/>
-        <result column="schedule_type" property="scheduleType" typeHandler="org.apache.ibatis.type.EnumTypeHandler"/>
-        <result column="cron_expression" property="cronExpression"/>
-        <result column="start_time" property="startTime"/>
-        <result column="interval_days" property="intervalDays"/>
-        <result column="total_times" property="totalTimes"/>
-        <result column="executed_count" property="executedCount"/>
-        <result column="pump_id" property="pumpId"/>
-        <result column="pump_type" property="pumpType" typeHandler="org.apache.ibatis.type.EnumTypeHandler"/>
-        <result column="target_pressure" property="targetPressure"/>
-        <result column="fertilizer_id" property="fertilizerId"/>
-        <result column="fertilizer_program_no" property="fertilizerProgramNo"/>
-        <result column="status" property="status" typeHandler="org.apache.ibatis.type.EnumTypeHandler"/>
-        <result column="enabled" property="enabled"/>
-        <result column="tenant_id" property="tenantId"/>
-        <result column="created_by" property="createdBy"/>
-        <result column="created_at" property="createdAt"/>
-        <result column="updated_by" property="updatedBy"/>
-        <result column="updated_at" property="updatedAt"/>
-        <result column="deleted" property="deleted"/>
-    </resultMap>
+<!--    <resultMap id="BaseResultMap" type="cn.sciento.farm.automationv2.domain.entity.IrrigationTask">-->
+<!--        <id column="id" property="id"/>-->
+<!--        <result column="task_name" property="taskName"/>-->
+<!--        <result column="task_code" property="taskCode"/>-->
+<!--        <result column="trigger_type" property="triggerType" typeHandler="org.apache.ibatis.type.EnumTypeHandler"/>-->
+<!--        <result column="schedule_type" property="scheduleType" typeHandler="org.apache.ibatis.type.EnumTypeHandler"/>-->
+<!--        <result column="cron_expression" property="cronExpression"/>-->
+<!--        <result column="start_time" property="startTime"/>-->
+<!--        <result column="interval_days" property="intervalDays"/>-->
+<!--        <result column="total_times" property="totalTimes"/>-->
+<!--        <result column="executed_count" property="executedCount"/>-->
+<!--        <result column="pump_id" property="pumpId"/>-->
+<!--        <result column="pump_type" property="pumpType" typeHandler="org.apache.ibatis.type.EnumTypeHandler"/>-->
+<!--        <result column="target_pressure" property="targetPressure"/>-->
+<!--        <result column="fertilizer_id" property="fertilizerId"/>-->
+<!--        <result column="fertilizer_program_no" property="fertilizerProgramNo"/>-->
+<!--        <result column="status" property="status" typeHandler="org.apache.ibatis.type.EnumTypeHandler"/>-->
+<!--        <result column="enabled" property="enabled"/>-->
+<!--        <result column="tenant_id" property="tenantId"/>-->
+<!--        <result column="created_by" property="createdBy"/>-->
+<!--        <result column="created_at" property="createdAt"/>-->
+<!--        <result column="updated_by" property="updatedBy"/>-->
+<!--        <result column="updated_at" property="updatedAt"/>-->
+<!--        <result column="deleted" property="deleted"/>-->
+<!--    </resultMap>-->
 
     <!-- 插入 -->
     <insert id="insert" parameterType="cn.sciento.farm.automationv2.domain.entity.IrrigationTask" useGeneratedKeys="true" keyProperty="id">
@@ -127,4 +127,53 @@
         WHERE tenant_id = #{tenantId} AND deleted = 0
     </select>
 
+    <resultMap id="ListResultMap" type="cn.sciento.farm.automationv2.domain.valueobject.IrrigationTaskVO">
+        <id column="id" property="id"/>
+        <result column="task_name" property="taskName"/>
+        <result column="pump_device_id" property="pumpDeviceId"/>
+        <result column="pump_device_name" property="pumpDeviceName"/>
+        <result column="is_pump" property="isPump"/>
+        <result column="pressure_mode" property="pressureMode"/>
+        <result column="target_pressure_kpa" property="targetPressureKpa"/>
+        <result column="is_device_pump" property="isDevicePump"/>
+        <result column="status" property="status" typeHandler="org.apache.ibatis.type.EnumTypeHandler"/>
+        <result column="enabled_flag" property="enabledFlag"/>
+        <result column="tenant_id" property="tenantId"/>
+        <result column="organization_id" property="organizationId"/>
+        <collection property="groupCount" ofType="java.lang.Integer" column="id" select="findGroupNum"></collection>
+    </resultMap>
+
+    <select id="selectList" resultMap="ListResultMap">
+        SELECT
+            `id`,
+            task_name,
+            pump_device_id,
+            pump_device_name,
+            is_pump,
+            pressure_mode,
+            target_pressure_kpa,
+            is_device_pump,
+            `status`,
+            enabled_flag,
+            tenant_id,
+            organization_id
+        FROM
+            wfauto_v2_irrigation_task
+        WHERE
+            tenant_id = #{tenantId}
+            <if test="task.organizationId != null and task.organizationId != ''">
+                AND organization_id = #{task.organizationId}
+            </if>
+            <if test="task.status != null and task.status != ''">
+                AND `status` = #{task.status}
+            </if>
+            <if test="task.enabledFlag != null and task.enabledFlag != ''">
+                AND enabled_flag = #{task.enabledFlag}
+            </if>
+    </select>
+
+    <select id="findGroupNum" resultType="java.lang.Integer">
+        SELECT COUNT(`id`) FROM wfauto_v2_task_group_config WHERE task_id = #{id}
+    </select>
+
 </mapper>