Kaynağa Gözat

供水首页bug修复

zengy 3 hafta önce
ebeveyn
işleme
089815442e

+ 21 - 0
docs/water-home-dashboard-fix-2026-09-24.md

@@ -0,0 +1,21 @@
+# 供水首页报警态势接口修复
+
+## 原因与修复
+
+恢复的 `WaterSupplyHome.vue` 仍引用已删除的 `getAlarmLedgerDashboard`,导致浏览器在加载模块时抛出 named export 错误,页面无法打开。旧后台台账 dashboard 同样已删除,不能只补前端导出。
+
+- 首页改用 `src/api/alarm.js` 的 `getAlarmDashboard`。
+- 统一 `AlarmController` 提供 `GET /api/alarm/dashboard?rootTypeId=1&days=7`。
+- 保留 `alarm:operations:list` 权限和 `AlarmReviewScopeService.resolveOperations` 的菜单授权/设备类型树校验。
+- 返回 `totalCount`、`alarmingDeviceCount`、`trend`、`warningTypeRatio`、`deviceTypeRatio`,兼容首页现有统计图表的数据形状。
+- 总量和占比包含范围内历史已处理事件;报警中设备按设备状态去重,不以事件已处理状态代替。近 N 天分级趋势按北京时间自然日统计,支持 1~31 天,无事件日期补 0。
+- 聚合在 SQL 内完成,不拉取全量明细、不按某一分页推算总数。指标名称优先关联配置表,未知分类有明确兜底。
+
+## 验证与生效
+
+- 前端相关 Node 测试 50 项、生产构建通过,新增首页 API 命名导入/导出契约回归。
+- 后端运营 service、mapper、controller 及范围测试共 26 项通过。
+- `sql/tests/alarm_dashboard.test.mjs` 使用 MyBatis 实际生成 SQL,4 项内存 PGlite 测试通过,覆盖供水范围、去重、日期边界、历史事件与名称关联;不是 KingbaseES 实机验收。
+- 两仓 diff 检查通过,未进行真实登录/数据库验证,未提交或部署。
+
+无数据库结构或菜单脚本。更新前端、重新构建启动管理后端后刷新供水首页;仅刷新前端不能使尚未部署的新后端接口生效。

+ 2 - 0
pipe-network-service/CONTEXT.md

@@ -59,6 +59,8 @@ _Avoid_:把供水子系统等同于单一的报警页面,或把监测设备
 
 4.4 **监测报警一张图**:在地图中只展示供水异常相关点位,同一设备聚合为一个点位;主图标按正常、报警、预警、关联工单和离线状态展示,报警等级作为详情或辅助信息展示,支持异常搜索、定位和详情查看。
 
+供水首页工作台的报警态势通过统一 `GET /api/alarm/dashboard` 查询,保留报警总数、报警中设备、近 N 天分级趋势和分类占比;使用获授权设备类型树及 `alarm:operations:list` 权限,供水传 `rootTypeId=1`,不恢复已删除的旧台账 dashboard 接口。统计由数据库全量聚合,报警中设备按设备状态去重,只有趋势限制自然日范围(2026-09-24 首页导入错误修复)。
+
 ### 工单视图分工
 
 统一工单视图集中检索和处置所有供水类型工单,覆盖故障维修、日常巡检和设备保养三类业务;报警审核视图只承载由报警转入的故障维修工单。巡检或保养工单即使由其他系统派发给供水用户,仍可在统一工单视图中继续接单、处理和验收。

+ 8 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/alarm/AlarmController.java

@@ -55,6 +55,14 @@ public class AlarmController {
     @PreAuthorize("@ss.hasPermi('alarm:operations:list')")
     public AjaxResult statistics(@Valid AlarmQuery query) { return AjaxResult.success(operations.statistics(query)); }
 
+    @GetMapping("/dashboard")
+    @ApiOperation("首页报警态势:报警总数、报警中设备、近N天分级趋势及分类占比")
+    @PreAuthorize("@ss.hasPermi('alarm:operations:list')")
+    public AjaxResult dashboard(@Valid AlarmQuery query,
+            @RequestParam(value = "days", defaultValue = "7") int days) {
+        return AjaxResult.success(operations.dashboard(query, days));
+    }
+
     @GetMapping("/reminders")
     @ApiOperation("按最近操作时间查询未处理报警提醒,固定 alarm_status=0")
     @PreAuthorize("@ss.hasPermi('alarm:operations:list')")

+ 13 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/alarm/AlarmOperationsControllerTest.java

@@ -32,6 +32,19 @@ class AlarmOperationsControllerTest {
         mvc.perform(get("/api/alarm").param("rootTypeId","2").param("pageSize","1001")).andExpect(status().isBadRequest());
         verifyNoInteractions(operations);
     }
+    @Test void dashboardBindsSupplyScopeAndDefaultOrExplicitDays() throws Exception {
+        mvc.perform(get("/api/alarm/dashboard").param("rootTypeId","1")).andExpect(status().isOk());
+        ArgumentCaptor<AlarmQuery> query = ArgumentCaptor.forClass(AlarmQuery.class);
+        verify(operations).dashboard(query.capture(),eq(7));
+        assertEquals(1,query.getValue().getRootTypeId());
+        mvc.perform(get("/api/alarm/dashboard").param("rootTypeId","1").param("days","14")).andExpect(status().isOk());
+        verify(operations).dashboard(any(AlarmQuery.class),eq(14));
+        mvc.perform(get("/api/alarm/dashboard")).andExpect(status().isBadRequest());
+        mvc.perform(get("/api/alarm/dashboard").param("rootTypeId","1").param("days","bad")).andExpect(status().isBadRequest());
+        assertEquals("@ss.hasPermi('alarm:operations:list')", AlarmController.class
+                .getMethod("dashboard",AlarmQuery.class,int.class)
+                .getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value());
+    }
     @Test void analysisChoicesBindActualValueFilterBeforePagination() throws Exception {
         mvc.perform(get("/api/alarm").param("rootTypeId","2").param("actualValueOnly","true").param("pageSize","100"))
                 .andExpect(status().isOk());

+ 6 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/mapper/AlarmOperationsMapper.java

@@ -15,6 +15,12 @@ public interface AlarmOperationsMapper {
     Page<AlarmVO> selectReminders(Page<AlarmVO> page, @Param("query") AlarmQuery query, @Param("typeIds") List<Integer> typeIds);
     AlarmVO selectDetail(@Param("id") String id, @Param("query") AlarmQuery query, @Param("typeIds") List<Integer> typeIds);
     List<Map<String, Object>> countByLevel(@Param("query") AlarmQuery query, @Param("typeIds") List<Integer> typeIds);
+    AlarmDashboardVO selectDashboardCounts(@Param("query") AlarmQuery query, @Param("typeIds") List<Integer> typeIds,
+            @Param("deviceAlarmStatus") int deviceAlarmStatus);
+    List<Map<String, Object>> selectDashboardTrend(@Param("query") AlarmQuery query, @Param("typeIds") List<Integer> typeIds,
+            @Param("start") LocalDateTime start, @Param("end") LocalDateTime end);
+    List<Map<String, Object>> countByWarningType(@Param("query") AlarmQuery query, @Param("typeIds") List<Integer> typeIds);
+    List<Map<String, Object>> countByDeviceType(@Param("query") AlarmQuery query, @Param("typeIds") List<Integer> typeIds);
     List<AlarmVO> selectMap(@Param("query") AlarmQuery query, @Param("typeIds") List<Integer> typeIds);
     AlarmSampleVO selectLatestSample(@Param("source") AlarmMetricSource source, @Param("deviceCode") String deviceCode);
     List<AlarmSampleVO> selectSamples(@Param("source") AlarmMetricSource source, @Param("deviceCode") String deviceCode,

+ 34 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/service/AlarmOperationsService.java

@@ -7,11 +7,14 @@ import com.zksy.base.alarm.enums.*;
 import com.zksy.base.alarm.enums.AlarmLogOperTypeEnum;
 import com.zksy.base.alarm.mapper.*;
 import com.zksy.base.enums.EquipmentTypeEnum;
+import com.zksy.base.enums.AlarmStatusEnum;
 import com.zksy.common.exception.ServiceException;
 import com.zksy.ops.enums.WorkOrderStatusEnum;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Service;
 import java.time.LocalDateTime;
+import java.time.LocalDate;
+import java.time.ZoneId;
 import java.util.*;
 import java.util.stream.Collectors;
 
@@ -53,6 +56,37 @@ public class AlarmOperationsService {
         validate(query);
         return mapper.countByLevel(query, scope.resolveOperations(query));
     }
+    /** 首页统计保留全量口径,仅趋势限制日期;所有聚合在数据库内完成。 */
+    public AlarmDashboardVO dashboard(AlarmQuery query, int days) {
+        validate(query);
+        if (days < 1 || days > 31) throw new ServiceException("趋势天数应在1至31之间");
+        List<Integer> typeIds = scope.resolveOperations(query);
+        LocalDate today = LocalDate.now(ZoneId.of("Asia/Shanghai"));
+        LocalDate first = today.minusDays(days - 1L);
+        Map<String, Map<String, Object>> trend = new LinkedHashMap<>();
+        for (int offset = 0; offset < days; offset++) {
+            String date = first.plusDays(offset).toString();
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("date", date);
+            for (AlarmLevelEnum level : AlarmLevelEnum.values()) item.put("level" + level.getCode() + "Count", 0L);
+            trend.put(date, item);
+        }
+        AlarmDashboardVO result = new AlarmDashboardVO();
+        if (!typeIds.isEmpty()) {
+            result = mapper.selectDashboardCounts(query, typeIds, AlarmStatusEnum.ALARM.getCode());
+            for (Map<String, Object> row : mapper.selectDashboardTrend(query, typeIds,
+                    first.atStartOfDay(), today.plusDays(1).atStartOfDay())) {
+                Number code = (Number) row.get("level");
+                AlarmLevelEnum level = AlarmLevelEnum.fromCode(code == null ? null : code.intValue());
+                Map<String, Object> day = trend.get(String.valueOf(row.get("date")));
+                if (day != null && level != null) day.put("level" + level.getCode() + "Count", row.get("total"));
+            }
+            result.setWarningTypeRatio(mapper.countByWarningType(query, typeIds));
+            result.setDeviceTypeRatio(mapper.countByDeviceType(query, typeIds));
+        }
+        result.setTrend(new ArrayList<>(trend.values()));
+        return result;
+    }
     public List<AlarmVO> map(AlarmQuery query) {
         requireDrainage(query);
         validate(query);

+ 7 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/ops/dto/WorkOrderDispatchDTO.java

@@ -1,11 +1,13 @@
 package com.zksy.ops.dto;
 
+import com.fasterxml.jackson.annotation.JsonFormat;
 import io.swagger.annotations.ApiModel;
 import io.swagger.annotations.ApiModelProperty;
 import lombok.Data;
 
 import javax.validation.constraints.NotNull;
 import java.io.Serializable;
+import java.util.Date;
 
 /**
  * 工单派单入参
@@ -26,4 +28,9 @@ public class WorkOrderDispatchDTO implements Serializable {
     /** 派单备注 */
     @ApiModelProperty(value = "派单备注")
     private String remarks;
+
+    /** 计划完成时间:派单时可设定或修改;为空则保留工单原有值(不覆盖) */
+    @ApiModelProperty(value = "计划完成时间(派单时可设定/修改,为空则保留原值)")
+    @JsonFormat(locale = "zh", pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date planFinishTime;
 }

+ 4 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/ops/service/impl/WorkOrderServiceImpl.java

@@ -436,7 +436,10 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         Date now = new Date();
         this.update(updateWrapper(id).set(WorkOrder::getOrderStatus, WorkOrderStatusEnum.PENDING_ACCEPT.getCode()).set(WorkOrder::getReceiveUser, handlerId).set(WorkOrder::getDispatchUser, currentUserId()).set(WorkOrder::getDispatchTime, now)
                 // 数据归属部门跟随派单指定的责任人
-                .set(handler.getDeptId() != null, WorkOrder::getDeptId, handler.getDeptId()).eq(WorkOrder::getOrderStatus, WorkOrderStatusEnum.PENDING_DISPATCH.getCode()));
+                .set(handler.getDeptId() != null, WorkOrder::getDeptId, handler.getDeptId())
+                // 计划完成时间:派单时可设定/修改,为空则保留原值
+                .set(dto.getPlanFinishTime() != null, WorkOrder::getPlanFinishTime, dto.getPlanFinishTime())
+                .eq(WorkOrder::getOrderStatus, WorkOrderStatusEnum.PENDING_DISPATCH.getCode()));
 
         String logContent = "派单给责任人:" + userLabel(handler) + (StringUtils.isEmpty(dto.getRemarks()) ? "" : ",备注:" + dto.getRemarks());
         writeLog(id, WorkOrderLogOperTypeEnum.DISPATCH, logContent, null);

+ 27 - 0
pipe-network-service/zksy-system/src/main/resources/mapper/base/AlarmOperationsMapper.xml

@@ -60,6 +60,33 @@
         SELECT <include refid="columns"/> <include refid="joins"/>
         <where><include refid="filters"/></where> ORDER BY a.create_time DESC NULLS LAST, a.id DESC LIMIT 10001
     </select>
+    <select id="selectDashboardCounts" resultType="com.zksy.base.alarm.dto.AlarmDashboardVO">
+        SELECT COUNT(*) AS total_count,
+            COUNT(DISTINCT CASE WHEN EXISTS (SELECT 1 FROM app_user.equipment_status s
+                WHERE s.equipment_id = e.equipment_id AND s.del_flag = '0'
+                    AND s.alarm_status = #{deviceAlarmStatus}) THEN a.device_code END) AS alarming_device_count
+        <include refid="joins"/> <where><include refid="filters"/></where>
+    </select>
+    <select id="selectDashboardTrend" resultType="java.util.Map">
+        SELECT to_char(a.create_time, 'YYYY-MM-DD') AS date, a.alarm_level AS level, COUNT(*) AS total
+        <include refid="joins"/>
+        <where><include refid="filters"/> AND a.create_time &gt;= #{start} AND a.create_time &lt; #{end}</where>
+        GROUP BY 1, 2 ORDER BY 1, 2
+    </select>
+    <select id="countByWarningType" resultType="java.util.Map">
+        SELECT COALESCE(NULLIF(trim(wt.warning_name), ''), NULLIF(trim(a.warning_type), ''), '未分类') AS name,
+            COUNT(*) AS count
+        <include refid="joins"/>
+        LEFT JOIN LATERAL (SELECT t.warning_name FROM app_user.equipment_warning_type t
+            WHERE t.equipment_type_id = e.equipment_type_id AND t.warning_code = a.warning_code
+            ORDER BY t.warning_name LIMIT 1) wt ON true
+        <where><include refid="filters"/></where> GROUP BY 1 ORDER BY count DESC, name
+    </select>
+    <select id="countByDeviceType" resultType="java.util.Map">
+        SELECT COALESCE(NULLIF(trim(t.type_name), ''), '未分类') AS name, COUNT(*) AS count
+        <include refid="joins"/> LEFT JOIN app_user.equipment_type t ON t.id = e.equipment_type_id
+        <where><include refid="filters"/></where> GROUP BY 1 ORDER BY count DESC, name
+    </select>
     <!-- source 来自服务端枚举,绝不能由请求直接指定表名/字段名。无效字符串不强转,不补零。 -->
     <sql id="numericValue">
         CASE WHEN trim(CAST(${source.column} AS text)) ~ '^[+-]?([0-9]+([.][0-9]*)?|[.][0-9]+)([eE][+-]?[0-9]+)?$'

+ 16 - 0
pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/mapper/AlarmOperationsMapperTest.java

@@ -36,6 +36,22 @@ class AlarmOperationsMapperTest {
         assertFalse(numeric.contains("a.alarm_status ="));
         rendered.put("selectNumericPage",numeric);
         query.setActualValueOnly(false);
+        params.put("deviceAlarmStatus",com.zksy.base.enums.AlarmStatusEnum.ALARM.getCode());
+        for (String name : Arrays.asList("selectDashboardCounts", "selectDashboardTrend", "countByWarningType", "countByDeviceType")) {
+            String dashboard = sql(config,name,params);
+            assertTrue(dashboard.contains("e.equipment_type_id IN"));
+            assertFalse(dashboard.contains("a.alarm_status ="));
+            assertFalse(dashboard.contains("a.actual_value IS NOT NULL"));
+            rendered.put(name,dashboard);
+            params.put("typeIds",Collections.emptyList());
+            assertTrue(sql(config,name,params).contains("1 = 0"));
+            params.put("typeIds",Arrays.asList(2,21));
+        }
+        assertTrue(rendered.get("selectDashboardCounts").contains("s.alarm_status = ?"));
+        assertTrue(rendered.get("selectDashboardCounts").contains("COUNT(DISTINCT"));
+        assertTrue(rendered.get("countByWarningType").contains("wt.warning_name"));
+        assertTrue(rendered.get("countByDeviceType").contains("t.type_name"));
+        assertTrue(rendered.get("selectDashboardTrend").contains("a.create_time >= ? AND a.create_time < ?"));
         query.setWarningCode("WARN_INSTANT_FLOW"); query.setAlarmStatus(0);
         assertTrue(sql(config,"selectPage",params).contains("a.warning_code = ?"));
         assertTrue(sql(config,"selectPage",params).contains("a.alarm_status = ?"));

+ 43 - 0
pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/service/AlarmOperationsServiceTest.java

@@ -8,6 +8,8 @@ import com.zksy.common.exception.ServiceException;
 import org.junit.jupiter.api.*;
 import java.math.BigDecimal;
 import java.time.LocalDateTime;
+import java.time.LocalDate;
+import java.time.ZoneId;
 import java.util.*;
 import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.Mockito.*;
@@ -106,4 +108,45 @@ class AlarmOperationsServiceTest {
         when(mapper.selectMap(eq(query),anyList())).thenReturn(Collections.nCopies(10001,alarm));
         assertThrows(ServiceException.class, () -> service.map(query));
     }
+    @Test void dashboardKeepsTotalsAndGroupsFourLevelsOnConsecutiveBeijingDates() {
+        query.setRootTypeId(1);
+        List<Integer> ids = Arrays.asList(1, 11);
+        when(scope.resolveOperations(query)).thenReturn(ids);
+        AlarmDashboardVO counts = new AlarmDashboardVO();
+        counts.setTotalCount(200); counts.setAlarmingDeviceCount(3);
+        when(mapper.selectDashboardCounts(query, ids, com.zksy.base.enums.AlarmStatusEnum.ALARM.getCode())).thenReturn(counts);
+        LocalDate today = LocalDate.now(ZoneId.of("Asia/Shanghai"));
+        Map<String,Object> yesterday = new HashMap<>();
+        yesterday.put("date", today.minusDays(1).toString()); yesterday.put("level", 2); yesterday.put("total", 9L);
+        Map<String,Object> unknownLevel = new HashMap<>(yesterday); unknownLevel.put("level", null);
+        when(mapper.selectDashboardTrend(eq(query),eq(ids),any(),any())).thenReturn(Arrays.asList(yesterday, unknownLevel));
+        List<Map<String,Object>> ratios = Collections.singletonList(Collections.singletonMap("count", 200L));
+        when(mapper.countByWarningType(query,ids)).thenReturn(ratios);
+        when(mapper.countByDeviceType(query,ids)).thenReturn(ratios);
+        AlarmDashboardVO result = service.dashboard(query, 7);
+        assertEquals(200,result.getTotalCount()); assertEquals(3,result.getAlarmingDeviceCount());
+        assertEquals(7,result.getTrend().size());
+        assertEquals(today.minusDays(6).toString(),result.getTrend().get(0).get("date"));
+        assertEquals(0L,result.getTrend().get(0).get("level1Count"));
+        assertEquals(9L,result.getTrend().get(5).get("level2Count"));
+        assertEquals(0L,result.getTrend().get(6).get("level4Count"));
+        assertSame(ratios,result.getWarningTypeRatio()); assertSame(ratios,result.getDeviceTypeRatio());
+        verify(mapper).selectDashboardTrend(query,ids,today.minusDays(6).atStartOfDay(),today.plusDays(1).atStartOfDay());
+        assertNull(query.getStartTime()); assertNull(query.getEndTime());
+        verify(scope).resolveOperations(query);
+    }
+    @Test void dashboardWithEmptyScopeReturnsZeroDaysWithoutUnscopedQueries() {
+        when(scope.resolveOperations(query)).thenReturn(Collections.emptyList());
+        AlarmDashboardVO result = service.dashboard(query, 1);
+        assertEquals(0,result.getTotalCount()); assertEquals(0,result.getAlarmingDeviceCount());
+        assertEquals(1,result.getTrend().size()); assertTrue(result.getWarningTypeRatio().isEmpty());
+        assertTrue(result.getDeviceTypeRatio().isEmpty()); verifyNoInteractions(mapper);
+    }
+    @Test void dashboardRejectsInvalidDaysAndUnauthorizedScopeBeforeReadingData() {
+        assertThrows(ServiceException.class, () -> service.dashboard(query, 0));
+        assertThrows(ServiceException.class, () -> service.dashboard(query, 32));
+        when(scope.resolveOperations(query)).thenThrow(new org.springframework.security.access.AccessDeniedException("denied"));
+        assertThrows(org.springframework.security.access.AccessDeniedException.class, () -> service.dashboard(query, 7));
+        verifyNoInteractions(mapper);
+    }
 }