Jelajahi Sumber

运行部分修正

lisonglin 2 minggu lalu
induk
melakukan
27aa225a56
24 mengubah file dengan 1256 tambahan dan 103 penghapusan
  1. 4 1
      services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/common/SeMonitorThreshold.java
  2. 17 0
      services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/controller/optimization/IndicatorController.java
  3. 2 2
      services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/entity/FhzgSeCapacityDaily.java
  4. 57 0
      services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/entity/FhzgSeConsumerCountDaily.java
  5. 64 0
      services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/entity/FhzgSeLineLossDaily.java
  6. 64 0
      services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/entity/FhzgSeLineLossDetail.java
  7. 1 1
      services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/entity/dto/SeVoltageAlarmListReq.java
  8. 43 0
      services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/entity/vo/SeVoltageIndexVO.java
  9. 7 0
      services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/mapper/FhzgSeConsumerCountDailyMapper.java
  10. 7 0
      services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/mapper/FhzgSeLineLossDailyMapper.java
  11. 15 0
      services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/mapper/FhzgSeLineLossDetailMapper.java
  12. 7 1
      services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/mapper/FhzgSeSnapshotDetailMapper.java
  13. 32 10
      services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/scheduler/SeCapacityDailyScheduler.java
  14. 144 0
      services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/scheduler/SeConsumerCountDailyScheduler.java
  15. 146 0
      services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/scheduler/SeLineLossDailyScheduler.java
  16. 10 0
      services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/service/SeIndicatorService.java
  17. 6 4
      services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/service/impl/SeAlarmServiceImpl.java
  18. 295 47
      services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/service/impl/SeIndicatorServiceImpl.java
  19. 144 6
      services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/service/impl/SeSnapshotServiceImpl.java
  20. 35 31
      services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/service/staticgrid/impl/StaticGridDashboardServiceImpl.java
  21. 19 0
      services/load-transfer-bf/src/main/resources/mapper/FhzgSeLineLossDetailMapper.xml
  22. 23 0
      services/load-transfer-bf/src/main/resources/mapper/FhzgSeSnapshotDetailMapper.xml
  23. 108 0
      services/load-transfer-si/src/main/java/com/hdkj/lt/si/config/StaticGridThresholdProp.java
  24. 6 0
      services/load-transfer-si/src/main/java/com/hdkj/lt/si/service/staticgrid/impl/StaticIndicatorResultServiceImpl.java

+ 4 - 1
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/common/SeMonitorThreshold.java

@@ -37,7 +37,10 @@ public final class SeMonitorThreshold {
     public static final BigDecimal LOAD_RATE_HEAVY = new BigDecimal("80");
     public static final BigDecimal LOAD_RATE_OVERLOAD = new BigDecimal("100");
 
-    // === 持续越限触发: 时间跨度 ≥ 60分钟 ===
+    // === 电流重过载持续触发: 时间跨度 ≥ 15分钟 ===
+    public static final long CURRENT_TRIGGER_DURATION_MINUTES = 15L;
+
+    // === 电压越限持续触发: 时间跨度 ≥ 60分钟 ===
     public static final long TRIGGER_DURATION_MINUTES = 60L;
 
     // === 断面间隔(分钟) — 超过则重置 ===

+ 17 - 0
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/controller/optimization/IndicatorController.java

@@ -15,6 +15,7 @@ import com.hdkj.lt.bf.entity.vo.SeAlarmDetailVO;
 import com.hdkj.lt.bf.entity.vo.SeAlarmListVO;
 import com.hdkj.lt.bf.entity.vo.SeOutageDashboardVO;
 import com.hdkj.lt.bf.entity.vo.SeVoltageDashboardVO;
+import com.hdkj.lt.bf.entity.vo.SeVoltageIndexVO;
 import com.hdkj.lt.bf.scheduler.SeCapacityDailyScheduler;
 import com.hdkj.lt.bf.service.IndicatorDashboardService;
 import com.hdkj.lt.bf.service.SeAlarmService;
@@ -104,6 +105,22 @@ public class IndicatorController extends BaseController {
         return ApiResponse.success(seIndicatorService.queryVoltage(id, Integer.parseInt(type), timeScope));
     }
 
+    /**
+     * IEC 用户电压指数(day/month/year)
+     *
+     * @param params {id, type(2/3/4), timeScope(day/month/year)}
+     */
+    @PostMapping("/voltage/index")
+    public ApiResponse<SeVoltageIndexVO> voltageIndex(@RequestBody Map<String, String> params) {
+        String id = params.get("id");
+        String type = params.get("type");
+        String timeScope = params.get("timeScope");
+        if (id == null || type == null || timeScope == null) {
+            return ApiResponse.fail("参数缺失");
+        }
+        return ApiResponse.success(seIndicatorService.queryVoltageIndex(id, Integer.parseInt(type), timeScope));
+    }
+
     /**
      * 供电能力(today/month/year)
      *

+ 2 - 2
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/entity/FhzgSeCapacityDaily.java

@@ -51,10 +51,10 @@ public class FhzgSeCapacityDaily implements Serializable {
     /** 当天平均负载率% */
     private BigDecimal avgLoadRate;
 
-    /** 重载断面数 */
+    /** 当日重载事件数(从事件表统计,一条事件算1) */
     private Integer heavyCount;
 
-    /** 过载断面数 */
+    /** 当日过载事件数(从事件表统计,一条事件算1) */
     private Integer overloadCount;
 
     /** 总断面数 */

+ 57 - 0
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/entity/FhzgSeConsumerCountDaily.java

@@ -0,0 +1,57 @@
+package com.hdkj.lt.bf.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serializable;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * 分层分级用户数量日表(IEC用户电压指数分母)
+ * <p>
+ * dim_level = county/substation/feeder 三档,每档一行
+ * 数据源:fhzg_se_consumer_relation,每天凌晨1点刷昨天
+ *
+ * @author lsl
+ * @since 2026-07-30
+ */
+@TableName("fhzg_se_consumer_count_daily")
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class FhzgSeConsumerCountDaily implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 统计日期 */
+    private LocalDate statDate;
+
+    /** 维度级别:county/substation/feeder */
+    private String dimLevel;
+
+    /** 区县ID */
+    private String countyId;
+
+    /** 变电站ID */
+    private String subsId;
+
+    /** 馈线ID */
+    private String feederId;
+
+    /** 该维度下用户数 */
+    private Integer consumerCount;
+
+    private LocalDateTime createTime;
+
+    private LocalDateTime updateTime;
+}

+ 64 - 0
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/entity/FhzgSeLineLossDaily.java

@@ -0,0 +1,64 @@
+package com.hdkj.lt.bf.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * 线路线损日汇总
+ *
+ * @author lsl
+ * @since 2026-07-30
+ */
+@TableName("fhzg_se_line_loss_daily")
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class FhzgSeLineLossDaily implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 统计日期 */
+    private LocalDate statDate;
+
+    /** 馈线ID */
+    private String feederId;
+
+    /** 馈线名称 */
+    private String feederName;
+
+    /** 区县ID */
+    private String countyId;
+
+    /** 变电站ID */
+    private String subsId;
+
+    /** 日线损电量 kWh */
+    private BigDecimal lineLossKwh;
+
+    /** 日最大线损功率 kW */
+    private BigDecimal maxLineLossKw;
+
+    /** 日平均线损功率 kW */
+    private BigDecimal avgLineLossKw;
+
+    /** 总断面数 */
+    private Integer totalSnapshots;
+
+    private LocalDateTime createTime;
+
+    private LocalDateTime updateTime;
+}

+ 64 - 0
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/entity/FhzgSeLineLossDetail.java

@@ -0,0 +1,64 @@
+package com.hdkj.lt.bf.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 线路线损明细(逐段逐断面)
+ *
+ * @author lsl
+ * @since 2026-07-30
+ */
+@TableName("fhzg_se_line_loss_detail")
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class FhzgSeLineLossDetail implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 断面时间 */
+    private LocalDateTime snapTime;
+
+    /** 馈线ID */
+    private String feederId;
+
+    /** 段ID */
+    private String psrId;
+
+    /** 段类型: dxd-导线段, 0201-电缆段 */
+    private String psrType;
+
+    /** 区县ID */
+    private String countyId;
+
+    /** 变电站ID */
+    private String subsId;
+
+    /** 状估电流 kA */
+    private BigDecimal currentKa;
+
+    /** 段长度 米 */
+    private BigDecimal sbLengthM;
+
+    /** 电阻率 Ω/km */
+    private BigDecimal resistivity;
+
+    /** 线损功率 kW */
+    private BigDecimal lineLossKw;
+
+    private LocalDateTime createTime;
+}

+ 1 - 1
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/entity/dto/SeVoltageAlarmListReq.java

@@ -32,7 +32,7 @@ public class SeVoltageAlarmListReq {
     @ApiModelProperty(value = "时间排序 asc/desc")
     private String orderByTime;
 
-    @ApiModelProperty(value = "告警级别 feeder/mvtrans")
+    @ApiModelProperty(value = "告警级别 feeder/mvtrans", required = true)
     private String alarmLevel;
 
     @ApiModelProperty(value = "页码")

+ 43 - 0
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/entity/vo/SeVoltageIndexVO.java

@@ -0,0 +1,43 @@
+package com.hdkj.lt.bf.entity.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+/**
+ * IEC 用户电压指数 VO
+ * <p>
+ * 指数 = 用户越限总时长(上+下) / (总用户数 × 周期天数 × 24h) × 100%
+ * 分子:中台 ads_mrt_voltage_over_limit_daily_result(历史)+ 事件表 fhzg_se_voltage_event(昨日/今日)组合
+ * 分母:fhzg_se_consumer_count_daily 按日累加的用户数 × 24(时户数)
+ *
+ * @author lsl
+ * @since 2026-07-30
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@ApiModel(description = "IEC 用户电压指数")
+public class SeVoltageIndexVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty(value = "IEC用户电压指数(%)")
+    private BigDecimal voltageIndex;
+
+    @ApiModelProperty(value = "用户越限总时长(小时,分子)")
+    private BigDecimal overLimitHours;
+
+    @ApiModelProperty(value = "用户总时户数(分母,用户数×24h)")
+    private BigDecimal consumerHours;
+
+    @ApiModelProperty(value = "平均用户数(调试用)")
+    private BigDecimal avgConsumerCount;
+}

+ 7 - 0
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/mapper/FhzgSeConsumerCountDailyMapper.java

@@ -0,0 +1,7 @@
+package com.hdkj.lt.bf.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.hdkj.lt.bf.entity.FhzgSeConsumerCountDaily;
+
+public interface FhzgSeConsumerCountDailyMapper extends BaseMapper<FhzgSeConsumerCountDaily> {
+}

+ 7 - 0
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/mapper/FhzgSeLineLossDailyMapper.java

@@ -0,0 +1,7 @@
+package com.hdkj.lt.bf.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.hdkj.lt.bf.entity.FhzgSeLineLossDaily;
+
+public interface FhzgSeLineLossDailyMapper extends BaseMapper<FhzgSeLineLossDaily> {
+}

+ 15 - 0
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/mapper/FhzgSeLineLossDetailMapper.java

@@ -0,0 +1,15 @@
+package com.hdkj.lt.bf.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.hdkj.lt.bf.entity.FhzgSeLineLossDetail;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface FhzgSeLineLossDetailMapper extends BaseMapper<FhzgSeLineLossDetail> {
+
+    /** 批量插入线损明细 */
+    int insertBatch(@Param("list") List<FhzgSeLineLossDetail> list);
+}

+ 7 - 1
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/mapper/FhzgSeSnapshotDetailMapper.java

@@ -3,7 +3,13 @@ package com.hdkj.lt.bf.mapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.hdkj.lt.bf.entity.FhzgSeSnapshotDetail;
 import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
 
 @Mapper
 public interface FhzgSeSnapshotDetailMapper extends BaseMapper<FhzgSeSnapshotDetail> {
-}
+
+    /** 批量插入断面明细 */
+    int insertBatch(@Param("list") List<FhzgSeSnapshotDetail> list);
+}

+ 32 - 10
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/scheduler/SeCapacityDailyScheduler.java

@@ -2,8 +2,10 @@ package com.hdkj.lt.bf.scheduler;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.hdkj.lt.bf.entity.FhzgSeCapacityDaily;
+import com.hdkj.lt.bf.entity.FhzgSeCurrentEvent;
 import com.hdkj.lt.bf.entity.FhzgSeSnapshotDetail;
 import com.hdkj.lt.bf.mapper.FhzgSeCapacityDailyMapper;
+import com.hdkj.lt.bf.mapper.FhzgSeCurrentEventMapper;
 import com.hdkj.lt.bf.mapper.FhzgSeSnapshotDetailMapper;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -32,6 +34,7 @@ import java.util.Map;
 public class SeCapacityDailyScheduler {
 
     private final FhzgSeSnapshotDetailMapper snapshotDetailMapper;
+    private final FhzgSeCurrentEventMapper currentEventMapper;
     private final FhzgSeCapacityDailyMapper capacityDailyMapper;
 
     /**
@@ -51,13 +54,15 @@ public class SeCapacityDailyScheduler {
     }
 
     /**
-     * 聚合指定日期的断面数据到日汇总表(手动触发时调用)
+     * 聚合指定日期的断面/事件数据到日汇总表(手动触发时调用)
+     * <p>
+     * 负载率从断面明细聚合,重过载事件数从事件表统计(与事件表口径一致)
      */
     public String aggregateForDate(LocalDate date) {
         LocalDateTime startTime = date.atStartOfDay();
         LocalDateTime endTime = date.atTime(23, 59, 59);
 
-        // 查当天所有馈线断面
+        // 查当天所有馈线断面(负载率)
         LambdaQueryWrapper<FhzgSeSnapshotDetail> wrapper = new LambdaQueryWrapper<FhzgSeSnapshotDetail>()
                 .eq(FhzgSeSnapshotDetail::getDeviceType, "feeder")
                 .isNotNull(FhzgSeSnapshotDetail::getLoadRate)
@@ -66,9 +71,16 @@ public class SeCapacityDailyScheduler {
 
         List<FhzgSeSnapshotDetail> snapshots = snapshotDetailMapper.selectList(wrapper);
 
-        if (snapshots.isEmpty()) {
-            log.info("[供电能力日汇总] {} 无断面数据,跳过", date);
-            return date + " 无断面数据";
+        // 查当天重过载事件(事件数,口径与 fhzg_se_current_event 一致)
+        LambdaQueryWrapper<FhzgSeCurrentEvent> eventWrapper = new LambdaQueryWrapper<FhzgSeCurrentEvent>()
+                .ge(FhzgSeCurrentEvent::getFirstOverTime, startTime)
+                .lt(FhzgSeCurrentEvent::getFirstOverTime, date.plusDays(1).atStartOfDay())
+                .in(FhzgSeCurrentEvent::getAlarmType, "current_heavy", "current_overload");
+        List<FhzgSeCurrentEvent> events = currentEventMapper.selectList(eventWrapper);
+
+        if (snapshots.isEmpty() && events.isEmpty()) {
+            log.info("[供电能力日汇总] {} 无断面/事件数据,跳过", date);
+            return date + " 无断面/事件数据";
         }
 
         // 按馈线分组聚合
@@ -90,12 +102,20 @@ public class SeCapacityDailyScheduler {
                 acc.sumLoadRate = acc.sumLoadRate.add(loadRate);
                 acc.totalSnapshots++;
             }
+        }
 
-            Integer status = s.getCurrentStatus();
-            if (status != null) {
-                if (status == 1) acc.heavyCount++;
-                if (status == 2) acc.overloadCount++;
-            }
+        // 事件数按馈线累加(同一条馈线当日多个事件算多次)
+        for (FhzgSeCurrentEvent e : events) {
+            String feederId = e.getFeederId();
+            if (feederId == null) continue;
+
+            DailyAccumulator acc = accumulatorMap.computeIfAbsent(feederId, k -> new DailyAccumulator());
+            if (acc.countyId == null) acc.countyId = e.getCountyId();
+            if (acc.subsId == null) acc.subsId = e.getSubsId();
+            if (acc.feederName == null) acc.feederName = e.getFeederName();
+
+            if ("current_heavy".equals(e.getAlarmType())) acc.heavyCount++;
+            else if ("current_overload".equals(e.getAlarmType())) acc.overloadCount++;
         }
 
         // 先删当天已有数据(避免重复运行时叠加)
@@ -149,7 +169,9 @@ public class SeCapacityDailyScheduler {
         String feederName;
         BigDecimal maxLoadRate = BigDecimal.ZERO;
         BigDecimal sumLoadRate = BigDecimal.ZERO;
+        /** 当日重载事件数(从事件表统计) */
         int heavyCount = 0;
+        /** 当日过载事件数(从事件表统计) */
         int overloadCount = 0;
         int totalSnapshots = 0;
     }

+ 144 - 0
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/scheduler/SeConsumerCountDailyScheduler.java

@@ -0,0 +1,144 @@
+package com.hdkj.lt.bf.scheduler;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.hdkj.lt.bf.entity.FhzgSeConsumerCountDaily;
+import com.hdkj.lt.bf.entity.FhzgSeConsumerRelation;
+import com.hdkj.lt.bf.mapper.FhzgSeConsumerCountDailyMapper;
+import com.hdkj.lt.bf.mapper.FhzgSeConsumerRelationMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 分层分级用户数量日汇总定时任务
+ * <p>
+ * 每天凌晨1点,从 fhzg_se_consumer_relation 按 county/substation/feeder 三档聚合,
+ * 写入 fhzg_se_consumer_count_daily(IEC用户电压指数分母)。
+ * 先删当天已有数据再插入(幂等)。
+ *
+ * @author lsl
+ * @since 2026-07-30
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class SeConsumerCountDailyScheduler {
+
+    private final FhzgSeConsumerRelationMapper consumerRelationMapper;
+    private final FhzgSeConsumerCountDailyMapper consumerCountDailyMapper;
+
+    /** 维度级别常量 */
+    private static final String LEVEL_COUNTY = "county";
+    private static final String LEVEL_SUBSTATION = "substation";
+    private static final String LEVEL_FEEDER = "feeder";
+
+    /**
+     * 每天凌晨1点执行(刷昨天)
+     */
+    @Scheduled(cron = "0 0 1 * * ?")
+    public void aggregateYesterdayConsumerCount() {
+        LocalDate yesterday = LocalDate.now().minusDays(1);
+        log.info("[用户数量日汇总] 开始处理 {} 数据", yesterday);
+        try {
+            String result = aggregateForDate(yesterday);
+            log.info("[用户数量日汇总] {}", result);
+        } catch (Exception e) {
+            log.error("[用户数量日汇总] {} 处理失败", yesterday, e);
+        }
+    }
+
+    /**
+     * 聚合指定日期的分层分级用户数(手动触发时调用)
+     * <p>
+     * 三档独立聚合(SQL GROUP BY,不加载明细到内存):
+     * county 档 = county_id 非空按区县分组;
+     * substation 档 = county_id+subs_id 非空按变电站分组;
+     * feeder 档 = 三级id均非空按馈线分组。
+     */
+    public String aggregateForDate(LocalDate date) {
+        List<FhzgSeConsumerCountDaily> records = new ArrayList<>();
+
+        // 区县档
+        records.addAll(aggregateLevel(date, LEVEL_COUNTY));
+
+        // 变电站档
+        records.addAll(aggregateLevel(date, LEVEL_SUBSTATION));
+
+        // 馈线档
+        records.addAll(aggregateLevel(date, LEVEL_FEEDER));
+
+        if (records.isEmpty()) {
+            log.info("[用户数量日汇总] {} 无用户关系数据,跳过", date);
+            return date + " 无用户关系数据";
+        }
+
+        // 先删当天已有数据(幂等)
+        LambdaQueryWrapper<FhzgSeConsumerCountDaily> deleteWrapper =
+                new LambdaQueryWrapper<FhzgSeConsumerCountDaily>()
+                        .eq(FhzgSeConsumerCountDaily::getStatDate, date);
+        consumerCountDailyMapper.delete(deleteWrapper);
+
+        // 批量插入
+        for (FhzgSeConsumerCountDaily record : records) {
+            consumerCountDailyMapper.insert(record);
+        }
+
+        String result = date + " 处理完成: " + records.size() + " 行 (county " + countByLevel(records, LEVEL_COUNTY)
+                + " + substation " + countByLevel(records, LEVEL_SUBSTATION)
+                + " + feeder " + countByLevel(records, LEVEL_FEEDER) + ")";
+        log.info("[用户数量日汇总] {}", result);
+        return result;
+    }
+
+    /**
+     * 按指定维度档聚合用户数(一条 SQL GROUP BY)
+     */
+    private List<FhzgSeConsumerCountDaily> aggregateLevel(LocalDate date, String level) {
+        QueryWrapper<FhzgSeConsumerRelation> qw = new QueryWrapper<>();
+        qw.select("county_id", "subs_id", "feeder_id", "COUNT(*) AS cnt")
+                .isNotNull("county_id")
+                .groupBy("county_id");
+
+        if (LEVEL_SUBSTATION.equals(level)) {
+            qw.isNotNull("subs_id")
+                    .groupBy("county_id", "subs_id");
+        } else if (LEVEL_FEEDER.equals(level)) {
+            qw.isNotNull("subs_id")
+                    .isNotNull("feeder_id")
+                    .groupBy("county_id", "subs_id", "feeder_id");
+        }
+
+        List<java.util.Map<String, Object>> rows = consumerRelationMapper.selectMaps(qw);
+
+        List<FhzgSeConsumerCountDaily> records = new ArrayList<>();
+        for (java.util.Map<String, Object> row : rows) {
+            String countyId = (String) row.get("county_id");
+            String subsId = (String) row.get("subs_id");
+            String feederId = (String) row.get("feeder_id");
+            Number cnt = (Number) row.getOrDefault("cnt", 0);
+
+            records.add(FhzgSeConsumerCountDaily.builder()
+                    .statDate(date)
+                    .dimLevel(level)
+                    .countyId(countyId)
+                    .subsId(subsId)
+                    .feederId(feederId)
+                    .consumerCount(cnt.intValue())
+                    .createTime(LocalDateTime.now())
+                    .updateTime(LocalDateTime.now())
+                    .build());
+        }
+        return records;
+    }
+
+    private long countByLevel(List<FhzgSeConsumerCountDaily> records, String level) {
+        return records.stream().filter(r -> level.equals(r.getDimLevel())).count();
+    }
+}

+ 146 - 0
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/scheduler/SeLineLossDailyScheduler.java

@@ -0,0 +1,146 @@
+package com.hdkj.lt.bf.scheduler;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.hdkj.lt.bf.entity.FhzgSeLineLossDaily;
+import com.hdkj.lt.bf.entity.FhzgSeLineLossDetail;
+import com.hdkj.lt.bf.mapper.FhzgSeLineLossDailyMapper;
+import com.hdkj.lt.bf.mapper.FhzgSeLineLossDetailMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 线路线损日汇总定时任务
+ * 每天凌晨1点,将昨天的线损明细聚合写入 fhzg_se_line_loss_daily
+ *
+ * @author lsl
+ * @since 2026-07-30
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class SeLineLossDailyScheduler {
+
+    private final FhzgSeLineLossDetailMapper lineLossDetailMapper;
+    private final FhzgSeLineLossDailyMapper lineLossDailyMapper;
+
+    /**
+     * 每天凌晨1点执行
+     */
+    @Scheduled(cron = "0 0 1 * * ?")
+    public void aggregateYesterdayLineLoss() {
+        LocalDate yesterday = LocalDate.now().minusDays(1);
+        log.info("[线路线损日汇总] 开始处理 {} 数据", yesterday);
+
+        try {
+            String result = aggregateForDate(yesterday);
+            log.info("[线路线损日汇总] {} {}", yesterday, result);
+        } catch (Exception e) {
+            log.error("[线路线损日汇总] {} 处理失败", yesterday, e);
+        }
+    }
+
+    /**
+     * 聚合指定日期的线损明细到日汇总表(手动触发时调用)
+     */
+    public String aggregateForDate(LocalDate date) {
+        LocalDateTime startTime = date.atStartOfDay();
+        LocalDateTime endTime = date.atTime(23, 59, 59);
+
+        // 查当天所有线损明细
+        LambdaQueryWrapper<FhzgSeLineLossDetail> wrapper = new LambdaQueryWrapper<FhzgSeLineLossDetail>()
+                .ge(FhzgSeLineLossDetail::getSnapTime, startTime)
+                .le(FhzgSeLineLossDetail::getSnapTime, endTime);
+
+        List<FhzgSeLineLossDetail> details = lineLossDetailMapper.selectList(wrapper);
+
+        if (details.isEmpty()) {
+            log.info("[线路线损日汇总] {} 无线损明细数据,跳过", date);
+            return date + " 无线损明细数据";
+        }
+
+        // 按馈线分组聚合
+        Map<String, DailyAccumulator> accumulatorMap = new HashMap<>();
+        for (FhzgSeLineLossDetail d : details) {
+            String feederId = d.getFeederId();
+            if (feederId == null) continue;
+
+            DailyAccumulator acc = accumulatorMap.computeIfAbsent(feederId, k -> new DailyAccumulator());
+            acc.countyId = d.getCountyId();
+            acc.subsId = d.getSubsId();
+
+            BigDecimal loss = d.getLineLossKw();
+            if (loss != null) {
+                // 线损电量 kWh = 线损功率 kW × 断面间隔(15min = 0.25h)
+                BigDecimal energy = loss.multiply(BigDecimal.valueOf(0.25));
+                acc.sumKwh = acc.sumKwh.add(energy);
+                if (loss.compareTo(acc.maxKw) > 0) {
+                    acc.maxKw = loss;
+                }
+                acc.sumKw = acc.sumKw.add(loss);
+                acc.count++;
+            }
+        }
+
+        // 先删当天已有数据(避免重复运行时叠加)
+        LambdaQueryWrapper<FhzgSeLineLossDaily> deleteWrapper = new LambdaQueryWrapper<FhzgSeLineLossDaily>()
+                .eq(FhzgSeLineLossDaily::getStatDate, date);
+        lineLossDailyMapper.delete(deleteWrapper);
+
+        // 批量插入
+        List<FhzgSeLineLossDaily> dailyRecords = new ArrayList<>();
+        for (Map.Entry<String, DailyAccumulator> entry : accumulatorMap.entrySet()) {
+            String feederId = entry.getKey();
+            DailyAccumulator acc = entry.getValue();
+
+            BigDecimal avgKw = acc.count > 0
+                    ? acc.sumKw.divide(BigDecimal.valueOf(acc.count), 4, RoundingMode.HALF_UP)
+                    : BigDecimal.ZERO;
+
+            FhzgSeLineLossDaily daily = FhzgSeLineLossDaily.builder()
+                    .statDate(date)
+                    .feederId(feederId)
+                    .feederName(null) // 日内无条件单独查名称
+                    .countyId(acc.countyId)
+                    .subsId(acc.subsId)
+                    .lineLossKwh(acc.sumKwh.setScale(2, RoundingMode.HALF_UP))
+                    .maxLineLossKw(acc.maxKw.compareTo(BigDecimal.ZERO) > 0 ? acc.maxKw.setScale(4, RoundingMode.HALF_UP) : BigDecimal.ZERO)
+                    .avgLineLossKw(avgKw.setScale(4, RoundingMode.HALF_UP))
+                    .totalSnapshots(acc.count)
+                    .createTime(LocalDateTime.now())
+                    .updateTime(LocalDateTime.now())
+                    .build();
+            dailyRecords.add(daily);
+        }
+
+        for (FhzgSeLineLossDaily record : dailyRecords) {
+            lineLossDailyMapper.insert(record);
+        }
+
+        String result = date + " 处理完成: " + dailyRecords.size() + " 条馈线线损日汇总";
+        log.info("[线路线损日汇总] {}", result);
+        return result;
+    }
+
+    /**
+     * 日聚合累加器
+     */
+    private static class DailyAccumulator {
+        String countyId;
+        String subsId;
+        BigDecimal sumKwh = BigDecimal.ZERO;
+        BigDecimal maxKw = BigDecimal.ZERO;
+        BigDecimal sumKw = BigDecimal.ZERO;
+        int count = 0;
+    }
+}

+ 10 - 0
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/service/SeIndicatorService.java

@@ -3,6 +3,7 @@ package com.hdkj.lt.bf.service;
 import com.hdkj.lt.bf.entity.vo.SeCapacityDashboardVO;
 import com.hdkj.lt.bf.entity.vo.SeOutageDashboardVO;
 import com.hdkj.lt.bf.entity.vo.SeVoltageDashboardVO;
+import com.hdkj.lt.bf.entity.vo.SeVoltageIndexVO;
 
 /**
  * 状估监测体系 三个查询接口 Service
@@ -29,6 +30,15 @@ public interface SeIndicatorService {
      */
     SeVoltageDashboardVO queryVoltage(String id, Integer type, String timeScope);
 
+    /**
+     * IEC 用户电压指数
+     *
+     * @param id       节点ID
+     * @param type     节点类型 2=区县 3=变电站 4=馈线
+     * @param timeScope day/month/year
+     */
+    SeVoltageIndexVO queryVoltageIndex(String id, Integer type, String timeScope);
+
     /**
      * 供电能力
      *

+ 6 - 4
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/service/impl/SeAlarmServiceImpl.java

@@ -1,6 +1,7 @@
 package com.hdkj.lt.bf.service.impl;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
 import com.hdkj.lt.bf.entity.FhzgSeCurrentEvent;
 import com.hdkj.lt.bf.entity.FhzgSeMonitorCurrent;
@@ -28,8 +29,7 @@ import java.time.Duration;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.format.DateTimeFormatter;
-import java.util.Arrays;
-import java.util.List;
+import java.util.*;
 import java.util.stream.Collectors;
 
 /**
@@ -137,13 +137,15 @@ public class SeAlarmServiceImpl implements SeAlarmService {
         applyOrderBy(wrapper, req.getOrderByTime(), FhzgSeVoltageEvent::getFirstOverTime);
 
         Page<FhzgSeVoltageEvent> result = voltageEventMapper.selectPage(mpPage, wrapper);
-        List<SeAlarmListVO> voList = result.getRecords().stream().map(this::toVoltageVO).collect(Collectors.toList());
+        List<SeAlarmListVO> voList = result.getRecords().stream()
+                .map(this::toVoltageVO)
+                .collect(Collectors.toList());
         return PageResult.of(result, voList);
     }
 
     private SeAlarmListVO toVoltageVO(FhzgSeVoltageEvent e) {
         String displayValue = resolveVoltageDirection(e.getAlarmType());
-        // 台区事件:查窗口内该配变下用户越限事件数(嵌套在配变窗口内的,不去重)
+        // 台区事件:查窗口内该配变下用户越限事件数(嵌套在配变窗口内的,不去重)
         Integer consumerCount = null;
         if ("mvtrans".equals(e.getAlarmLevel()) && e.getMvtransId() != null) {
             LocalDateTime windowEnd = (e.getStatus() != null && e.getStatus() == 2 && e.getRecoverTime() != null)

+ 295 - 47
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/service/impl/SeIndicatorServiceImpl.java

@@ -8,15 +8,20 @@ import com.hdkj.lt.bf.entity.FhzgDwmVoltageOverLimitDaily;
 import com.hdkj.lt.bf.entity.FhzgSeCapacityDaily;
 import com.hdkj.lt.bf.entity.FhzgSeVoltageEvent;
 import com.hdkj.lt.bf.entity.FhzgSeSnapshotDetail;
+import com.hdkj.lt.bf.entity.FhzgSeConsumerCountDaily;
+import com.hdkj.lt.bf.entity.FhzgSeConsumerRelation;
 import com.hdkj.lt.bf.entity.dto.TodayOutageData;
 import com.hdkj.lt.bf.entity.vo.SeCapacityDashboardVO;
 import com.hdkj.lt.bf.entity.vo.SeOutageDashboardVO;
 import com.hdkj.lt.bf.entity.vo.SeVoltageDashboardVO;
+import com.hdkj.lt.bf.entity.vo.SeVoltageIndexVO;
 import com.hdkj.lt.bf.mapper.FhzgSeCurrentEventMapper;
 import com.hdkj.lt.bf.mapper.FhzgDwmVoltageOverLimitDailyMapper;
 import com.hdkj.lt.bf.mapper.FhzgSeCapacityDailyMapper;
 import com.hdkj.lt.bf.mapper.FhzgSeVoltageEventMapper;
 import com.hdkj.lt.bf.mapper.FhzgSeSnapshotDetailMapper;
+import com.hdkj.lt.bf.mapper.FhzgSeConsumerCountDailyMapper;
+import com.hdkj.lt.bf.mapper.FhzgSeConsumerRelationMapper;
 import com.hdkj.lt.bf.service.SeIndicatorService;
 import com.hdkj.lt.bf.service.SjztPwwyyqxtPrmPdrOutageMvDsService;
 import com.hdkj.fhzg.optimization.response.FeederGroupTreeNode;
@@ -30,6 +35,7 @@ import java.math.RoundingMode;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.format.DateTimeFormatter;
+import java.time.temporal.ChronoUnit;
 import java.util.*;
 import java.util.stream.Collectors;
 
@@ -50,10 +56,15 @@ public class SeIndicatorServiceImpl implements SeIndicatorService {
     private final FhzgSeSnapshotDetailMapper snapshotDetailMapper;
     private final FhzgSeCapacityDailyMapper capacityDailyMapper;
     private final FhzgSeCurrentEventMapper currentEventMapper;
+    private final FhzgSeConsumerCountDailyMapper consumerCountDailyMapper;
+    private final FhzgSeConsumerRelationMapper consumerRelationMapper;
     private final FeederGroupApplication feederGroupApplication;
 
     private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyyMMdd");
 
+    /** 每天小时数(分母时户数换算用) */
+    private static final BigDecimal HOURS_24 = new BigDecimal("24");
+
     // ============================================================
     // 1. 停电时户数(复用已有逻辑,无时间维度)
     // ============================================================
@@ -132,31 +143,32 @@ public class SeIndicatorServiceImpl implements SeIndicatorService {
      */
     private int[] countVoltageByAlarmLevel(String id, Integer type, String alarmLevel,
                                             LocalDateTime start, LocalDateTime end) {
-        LambdaQueryWrapper<FhzgSeVoltageEvent> wrapper = new LambdaQueryWrapper<FhzgSeVoltageEvent>()
-                .eq(FhzgSeVoltageEvent::getAlarmLevel, alarmLevel)
-                .ge(FhzgSeVoltageEvent::getFirstOverTime, start)
-                .lt(FhzgSeVoltageEvent::getFirstOverTime, end);
-        applyNodeFilter(wrapper, id, type);
-
-        List<FhzgSeVoltageEvent> events = voltageEventMapper.selectList(wrapper);
-
-        Set<String> overDeviceIds = new HashSet<>();
-        Set<String> underDeviceIds = new HashSet<>();
-        int overTimes = 0, underTimes = 0;
-
-        for (FhzgSeVoltageEvent e : events) {
-            String t = e.getAlarmType();
-            if (t == null) continue;
-            if (t.contains("over")) {
-                if (e.getDeviceId() != null) overDeviceIds.add(e.getDeviceId());
-                overTimes++;
-            } else if (t.contains("under")) {
-                if (e.getDeviceId() != null) underDeviceIds.add(e.getDeviceId());
-                underTimes++;
-            }
+        QueryWrapper<FhzgSeVoltageEvent> qw = new QueryWrapper<>();
+        qw.select(
+                "COUNT(DISTINCT CASE WHEN alarm_type LIKE '%over%' THEN device_id END) AS over_cnt",
+                "COUNT(DISTINCT CASE WHEN alarm_type LIKE '%under%' THEN device_id END) AS under_cnt",
+                "COUNT(CASE WHEN alarm_type LIKE '%over%' THEN 1 END) AS over_times",
+                "COUNT(CASE WHEN alarm_type LIKE '%under%' THEN 1 END) AS under_times")
+                .eq("alarm_level", alarmLevel)
+                .ge("first_over_time", start)
+                .lt("first_over_time", end);
+        if (type == 2) {
+            qw.eq("county_id", id);
+        } else if (type == 3) {
+            qw.eq("subs_id", id);
+        } else if (type == 4) {
+            qw.eq("feeder_id", id);
         }
 
-        return new int[]{overDeviceIds.size(), underDeviceIds.size(), overTimes, underTimes};
+        List<Map<String, Object>> rows = voltageEventMapper.selectMaps(qw);
+        if (rows.isEmpty()) return new int[]{0, 0, 0, 0};
+        Map<String, Object> row = rows.get(0);
+        return new int[]{
+                ((Number) row.getOrDefault("over_cnt", 0)).intValue(),
+                ((Number) row.getOrDefault("under_cnt", 0)).intValue(),
+                ((Number) row.getOrDefault("over_times", 0)).intValue(),
+                ((Number) row.getOrDefault("under_times", 0)).intValue()
+        };
     }
 
     /**
@@ -189,7 +201,8 @@ public class SeIndicatorServiceImpl implements SeIndicatorService {
     /**
      * 历史电压:查 ads_mrt_voltage_over_limit_daily_result,SUM 聚合到 SQL
      * 当月 = 月初~昨天;全年 = 1.1~昨天
-     * 17:00前查历史当月/全年 = 中台[到前天] + 我们alarm_event[昨天];17:00后 = 中台[到昨天]
+     * 自适应:查中台表 MAX(over_date) 判断数据最新到哪天,
+     * 中台未到昨天时 → 中台[到前天] + 事件表补[昨天];中台已到昨天 → 中台[到昨天]
      */
     private SeVoltageDashboardVO queryVoltageHistory(String id, Integer type, String timeScope) {
         LocalDate today = LocalDate.now();
@@ -202,12 +215,17 @@ public class SeIndicatorServiceImpl implements SeIndicatorService {
             startDate = LocalDate.of(today.getYear(), 1, 1);
         }
 
-        // 17:00前:中台到前天 + 我们昨天;17:00后:中台到昨天
-        int hour = LocalDateTime.now().getHour();
-        if (hour < 17) {
-            endDate = today.minusDays(2);
+        String dimLevel = resolveDimLevel(type);
+
+        // 查中台日汇总表最新日期,判断是否需要补昨日
+        String maxOverDate = queryDailyMaxOverDate(dimLevel, id, type);
+        String yesterdayStr = today.minusDays(1).format(DATE_FMT);
+        boolean needFillYesterday = (maxOverDate == null || maxOverDate.compareTo(yesterdayStr) < 0);
+
+        if (needFillYesterday) {
+            endDate = today.minusDays(2); // 中台只到T-2,补昨日T-1
         } else {
-            endDate = today.minusDays(1);
+            endDate = today.minusDays(1); // 中台已有昨天
         }
 
         // 如果 endDate < startDate,返回空
@@ -215,8 +233,6 @@ public class SeIndicatorServiceImpl implements SeIndicatorService {
             return SeVoltageDashboardVO.builder().build();
         }
 
-        String dimLevel = resolveDimLevel(type);
-
         // 一条 SQL 聚合 16个count字段 + total_over_limit_hours
         QueryWrapper<FhzgDwmVoltageOverLimitDaily> qw = new QueryWrapper<>();
         qw.select(
@@ -272,22 +288,48 @@ public class SeIndicatorServiceImpl implements SeIndicatorService {
         BigDecimal totalHours = r != null && r.getTotalOverLimitHours() != null
                 ? r.getTotalOverLimitHours() : BigDecimal.ZERO;
 
-        // 17:00前补昨日数据(从我们alarm_event查昨天)
-        if (hour < 17) {
+        // 补昨日:中台无昨日数据时,从事件表SQL聚合,按 alarm_level 分流
+        if (needFillYesterday) {
             LocalDateTime yesterdayStart = today.minusDays(1).atStartOfDay();
             LocalDateTime yesterdayEnd = today.minusDays(1).atTime(23, 59, 59);
-            LambdaQueryWrapper<FhzgSeVoltageEvent> yesterdayWrapper = new LambdaQueryWrapper<FhzgSeVoltageEvent>()
-                    .ge(FhzgSeVoltageEvent::getTriggerTime, yesterdayStart)
-                    .le(FhzgSeVoltageEvent::getTriggerTime, yesterdayEnd)
-                    .eq(FhzgSeVoltageEvent::getStatus, 2);
-            applyNodeFilter(yesterdayWrapper, id, type);
-            List<FhzgSeVoltageEvent> yesterdayEvents = voltageEventMapper.selectList(yesterdayWrapper);
-            for (FhzgSeVoltageEvent e : yesterdayEvents) {
-                if ("voltage_over".equals(e.getAlarmType()) || e.getAlarmType() != null && e.getAlarmType().contains("over")) {
-                    overUpperMildTg++;
-                } else if (e.getAlarmType() != null && e.getAlarmType().contains("under")) {
-                    overLowerMildTg++;
+
+            QueryWrapper<FhzgSeVoltageEvent> agg = new QueryWrapper<>();
+            agg.select("alarm_level", "alarm_type", "COUNT(*) AS cnt")
+                    .ge("trigger_time", yesterdayStart)
+                    .le("trigger_time", yesterdayEnd)
+                    .eq("status", 2)
+                    .groupBy("alarm_level", "alarm_type");
+            if (type == 2) agg.eq("county_id", id);
+            else if (type == 3) agg.eq("subs_id", id);
+            else if (type == 4) agg.eq("feeder_id", id);
+
+            List<Map<String, Object>> aggRows = voltageEventMapper.selectMaps(agg);
+            for (Map<String, Object> row : aggRows) {
+                String alarmLevel = (String) row.get("alarm_level");
+                String alarmType = (String) row.get("alarm_type");
+                int cnt = ((Number) row.getOrDefault("cnt", 0)).intValue();
+                if (alarmType == null) continue;
+                boolean isOver = alarmType.contains("over");
+                boolean isUnder = alarmType.contains("under");
+                if (!isOver && !isUnder) continue;
+                boolean isSevere = alarmType.contains("severe");
+
+                if ("consumer".equals(alarmLevel)) {
+                    if (isOver) {
+                        if (isSevere) overUpperSevereCust += cnt;
+                        else overUpperMildCust += cnt;
+                    } else {
+                        if (isSevere) overLowerSevereCust += cnt;
+                        else overLowerMildCust += cnt;
+                    }
+                } else if ("mvtrans".equals(alarmLevel)) {
+                    if (isOver) overUpperMildTg += cnt;
+                    else overLowerMildTg += cnt;
+                } else if ("feeder".equals(alarmLevel)) {
+                    if (isOver) overUpperMildFeeder += cnt;
+                    else overLowerMildFeeder += cnt;
                 }
+                // generatrix → 跳过
             }
         }
 
@@ -419,12 +461,11 @@ public class SeIndicatorServiceImpl implements SeIndicatorService {
                 if (d.getAvgLoadRate() != null) {
                     sumAvg = sumAvg.add(d.getAvgLoadRate());
                 }
+                // 重过载事件数:日统计表存的就是当日事件表事件数,直接累加
                 if (d.getHeavyCount() != null) sumHeavy += d.getHeavyCount();
                 if (d.getOverloadCount() != null) sumOverload += d.getOverloadCount();
             }
-            BigDecimal avgLoadRate = !dailyRecords.isEmpty()
-                    ? sumAvg.divide(BigDecimal.valueOf(dailyRecords.size()), 2, RoundingMode.HALF_UP)
-                    : BigDecimal.ZERO;
+            BigDecimal avgLoadRate = sumAvg.divide(BigDecimal.valueOf(dailyRecords.size()), 2, RoundingMode.HALF_UP);
             int totalEvents = (int) (sumHeavy + sumOverload);
             vo = SeCapacityDashboardVO.builder()
                     .maxLoadRate(maxLoadRate)
@@ -531,4 +572,211 @@ public class SeIndicatorServiceImpl implements SeIndicatorService {
     private int safeInt(Integer v) {
         return v == null ? 0 : v;
     }
+
+    /**
+     * 查中台日汇总表该维度的最新 over_date
+     */
+    private String queryDailyMaxOverDate(String dimLevel, String id, Integer type) {
+        QueryWrapper<FhzgDwmVoltageOverLimitDaily> qw = new QueryWrapper<>();
+        qw.select("MAX(over_date) AS max_over_date")
+                .eq("dim_level", dimLevel);
+        if (type == 2) qw.eq("maintainer_id", id);
+        else if (type == 3) qw.eq("substation_id", id);
+        else if (type == 4) qw.eq("feeder_id", id);
+        qw.last("LIMIT 1");
+        List<Map<String, Object>> records = voltageDailyMapper.selectMaps(qw);
+        if (records != null && !records.isEmpty()) {
+            Object val = records.get(0).get("max_over_date");
+            return val != null ? val.toString() : null;
+        }
+        return null;
+    }
+
+    // ============================================================
+    // 4. IEC 用户电压指数
+    // ============================================================
+
+    /**
+     * IEC 用户电压指数 = 用户越限总时长(上+下) / (总用户数 × 周期天数 × 24h) × 100%
+     * <p>
+     * 分子(组合):
+     * 历史 = 中台 ads_mrt_voltage_over_limit_daily_result.total_over_limit_hours(SQL SUM);
+     * 昨日/今日 = 事件表 fhzg_se_voltage_event(alarm_level='consumer') Σ(recover_time - first_over_time)。
+     * 中台最新日期动态自适应(MAX(over_date)),事件表只补中台未覆盖的区间。
+     * <p>
+     * 分母 = 分层分级用户数 × 周期天数 × 24(时户数);
+     * 用户数取最近一天日表值(用户数日变化可忽略,无需逐日累加/历史回填)。
+     */
+    @Override
+    public SeVoltageIndexVO queryVoltageIndex(String id, Integer type, String timeScope) {
+        if (StringUtils.isBlank(id) || type == null || StringUtils.isBlank(timeScope)) {
+            return null;
+        }
+
+        LocalDate today = LocalDate.now();
+        LocalDate yesterday = today.minusDays(1);
+
+        BigDecimal overLimitHours;
+        BigDecimal consumerHours;
+        BigDecimal avgConsumerCount;
+
+        if ("day".equalsIgnoreCase(timeScope)) {
+            // day:分子 = 今日事件表时长;分母 = 最近一天用户数 × 24
+            overLimitHours = sumConsumerEventHours(id, type,
+                    today.atStartOfDay(), today.plusDays(1).atStartOfDay());
+            int count = latestConsumerCount(id, type, yesterday);
+            avgConsumerCount = BigDecimal.valueOf(count);
+            consumerHours = avgConsumerCount.multiply(HOURS_24);
+        } else {
+            // month/year:分子 = 中台[start~endDate] + 事件表[endDate+1~今天]
+            // 分母 = 最近一天用户数 × 实际天数 × 24
+            LocalDate startDate;
+            if ("month".equalsIgnoreCase(timeScope)) {
+                startDate = today.withDayOfMonth(1);
+            } else {
+                startDate = LocalDate.of(today.getYear(), 1, 1);
+            }
+
+            // 中台覆盖到哪天(自适应)
+            String dimLevel = resolveDimLevel(type);
+            String maxOverDate = queryDailyMaxOverDate(dimLevel, id, type);
+            LocalDate endDate = yesterday;
+            if (maxOverDate != null) {
+                try {
+                    endDate = LocalDate.parse(maxOverDate, DATE_FMT);
+                } catch (Exception e) {
+                    log.warn("中台 over_date 解析失败: {}, 用昨天", maxOverDate);
+                }
+            }
+            if (endDate.isAfter(yesterday)) {
+                endDate = yesterday;
+            }
+
+            // 分子:中台区间 + 事件表补区间
+            BigDecimal zhongtaiHours = sumZhongtaiOverLimitHours(id, type, startDate, endDate);
+            BigDecimal eventHours = endDate.isBefore(today)
+                    ? sumConsumerEventHours(id, type,
+                    endDate.plusDays(1).atStartOfDay(), today.plusDays(1).atStartOfDay())
+                    : BigDecimal.ZERO;
+            overLimitHours = zhongtaiHours.add(eventHours);
+
+            // 分母:最近一天用户数 × 实际天数 × 24(用户数日变化可忽略,无需逐日累加)
+            int count = latestConsumerCount(id, type, yesterday);
+            long days = ChronoUnit.DAYS.between(startDate, today) + 1;
+            avgConsumerCount = BigDecimal.valueOf(count);
+            consumerHours = BigDecimal.valueOf(count)
+                    .multiply(BigDecimal.valueOf(days))
+                    .multiply(HOURS_24);
+        }
+
+        BigDecimal voltageIndex = null;
+        if (consumerHours.compareTo(BigDecimal.ZERO) > 0) {
+            voltageIndex = overLimitHours
+                    .divide(consumerHours, 6, RoundingMode.HALF_UP)
+                    .multiply(new BigDecimal("100"))
+                    .setScale(2, RoundingMode.HALF_UP);
+        }
+
+        return SeVoltageIndexVO.builder()
+                .voltageIndex(voltageIndex)
+                .overLimitHours(overLimitHours.setScale(2, RoundingMode.HALF_UP))
+                .consumerHours(consumerHours.setScale(0, RoundingMode.HALF_UP))
+                .avgConsumerCount(avgConsumerCount)
+                .build();
+    }
+
+    /**
+     * 中台表越限总时长 SUM(分子历史部分)
+     * <p>
+     * 注意:total_over_limit_hours 为中台提供的当日越限总时长(台区低压侧口径,近似用户维度)
+     */
+    private BigDecimal sumZhongtaiOverLimitHours(String id, Integer type,
+                                                 LocalDate startDate, LocalDate endDate) {
+        if (startDate.isAfter(endDate)) {
+            return BigDecimal.ZERO;
+        }
+        QueryWrapper<FhzgDwmVoltageOverLimitDaily> qw = new QueryWrapper<>();
+        qw.select("COALESCE(SUM(total_over_limit_hours),0) AS total_hours")
+                .eq("dim_level", resolveDimLevel(type))
+                .ge("over_date", startDate.format(DATE_FMT))
+                .le("over_date", endDate.format(DATE_FMT));
+        if (type == 2) {
+            qw.eq("maintainer_id", id);
+        } else if (type == 3) {
+            qw.eq("substation_id", id);
+        } else if (type == 4) {
+            qw.eq("feeder_id", id);
+        }
+        List<Map<String, Object>> rows = voltageDailyMapper.selectMaps(qw);
+        if (rows == null || rows.isEmpty() || rows.get(0).get("total_hours") == null) {
+            return BigDecimal.ZERO;
+        }
+        return new BigDecimal(rows.get(0).get("total_hours").toString());
+    }
+
+    /**
+     * 事件表用户越限总时长(小时):Σ(recover_time - first_over_time)
+     * 进行中事件(recover_time 为空)用当前时间兜底
+     */
+    private BigDecimal sumConsumerEventHours(String id, Integer type,
+                                             LocalDateTime start, LocalDateTime end) {
+        QueryWrapper<FhzgSeVoltageEvent> qw = new QueryWrapper<>();
+        qw.select("COALESCE(SUM(TIMESTAMPDIFF(SECOND, first_over_time, COALESCE(recover_time, NOW()))),0) AS secs")
+                .eq("alarm_level", "consumer")
+                .ge("first_over_time", start)
+                .lt("first_over_time", end);
+        if (type == 2) {
+            qw.eq("county_id", id);
+        } else if (type == 3) {
+            qw.eq("subs_id", id);
+        } else if (type == 4) {
+            qw.eq("feeder_id", id);
+        }
+        List<Map<String, Object>> rows = voltageEventMapper.selectMaps(qw);
+        if (rows == null || rows.isEmpty() || rows.get(0).get("secs") == null) {
+            return BigDecimal.ZERO;
+        }
+        long secs = ((Number) rows.get(0).get("secs")).longValue();
+        return BigDecimal.valueOf(secs).divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
+    }
+
+    /**
+     * 日表最近一天的用户数(&lt;= beforeDate)
+     * 日表无数据时兜底实时查用户关系表(仅首次部署/日表为空时触发,正常路径不实时查)
+     */
+    private int latestConsumerCount(String id, Integer type, LocalDate beforeDate) {
+        QueryWrapper<FhzgSeConsumerCountDaily> qw = new QueryWrapper<>();
+        qw.select("consumer_count")
+                .eq("dim_level", resolveDimLevel(type))
+                .le("stat_date", beforeDate)
+                .orderByDesc("stat_date")
+                .last("LIMIT 1");
+        if (type == 2) {
+            qw.eq("county_id", id);
+        } else if (type == 3) {
+            qw.eq("subs_id", id);
+        } else if (type == 4) {
+            qw.eq("feeder_id", id);
+        }
+        List<Map<String, Object>> rows = consumerCountDailyMapper.selectMaps(qw);
+        if (rows != null && !rows.isEmpty() && rows.get(0).get("consumer_count") != null) {
+            return ((Number) rows.get(0).get("consumer_count")).intValue();
+        }
+
+        // 兜底:日表未刷时实时 COUNT
+        QueryWrapper<FhzgSeConsumerRelation> cw = new QueryWrapper<>();
+        cw.select("COUNT(*) AS cnt");
+        if (type == 2) {
+            cw.eq("county_id", id);
+        } else if (type == 3) {
+            cw.eq("subs_id", id);
+        } else if (type == 4) {
+            cw.eq("feeder_id", id);
+        }
+        List<Map<String, Object>> crows = consumerRelationMapper.selectMaps(cw);
+        if (crows != null && !crows.isEmpty() && crows.get(0).get("cnt") != null) {
+            return ((Number) crows.get(0).get("cnt")).intValue();
+        }
+        return 0;
+    }
 }

+ 144 - 6
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/service/impl/SeSnapshotServiceImpl.java

@@ -4,24 +4,29 @@ import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.hdkj.lt.bf.application.FeederGroupApplication;
 import com.hdkj.lt.bf.common.SeMonitorThreshold;
 import com.hdkj.lt.bf.common.SeSnapshotConstants;
 import com.hdkj.lt.bf.entity.DwdShbDsTransformerBase;
 import com.hdkj.lt.bf.entity.FhzgSeCurrentEvent;
+import com.hdkj.lt.bf.entity.FhzgSeLineLossDetail;
 import com.hdkj.lt.bf.entity.FhzgSeMonitorCurrent;
 import com.hdkj.lt.bf.entity.FhzgSeMonitorVoltage;
 import com.hdkj.lt.bf.entity.FhzgSeSnapshotDetail;
 import com.hdkj.lt.bf.entity.FhzgSeVoltageEvent;
 import com.hdkj.lt.bf.event.MvtransOverVoltageEvent;
 import com.hdkj.lt.bf.event.ReconTriggerEvent;
+import com.hdkj.lt.bf.mapper.FeederTopoMapper;
 import com.hdkj.lt.bf.mapper.FhzgSeCurrentEventMapper;
+import com.hdkj.lt.bf.mapper.FhzgSeLineLossDetailMapper;
 import com.hdkj.lt.bf.mapper.FhzgSeMonitorCurrentMapper;
 import com.hdkj.lt.bf.mapper.FhzgSeMonitorVoltageMapper;
 import com.hdkj.lt.bf.mapper.FhzgSeSnapshotDetailMapper;
 import com.hdkj.lt.bf.mapper.FhzgSeVoltageEventMapper;
 import com.hdkj.lt.bf.mapper.DwdShbDsTransformerBaseMapper;
 import com.hdkj.lt.bf.service.SeSnapshotService;
+import com.hdkj.lt.core.bizms.modle.po.FeederTopo;
 import com.hdkj.fhzg.optimization.response.FeederGroupTreeNode;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -49,6 +54,8 @@ public class SeSnapshotServiceImpl implements SeSnapshotService {
     private final DwdShbDsTransformerBaseMapper transformerBaseMapper;
     private final FeederGroupApplication feederGroupApplication;
     private final ApplicationEventPublisher eventPublisher;
+    private final FeederTopoMapper feederTopoMapper;
+    private final FhzgSeLineLossDetailMapper lineLossDetailMapper;
 
     private static final DateTimeFormatter DT_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
 
@@ -109,6 +116,12 @@ public class SeSnapshotServiceImpl implements SeSnapshotService {
             }
         }
 
+        // === 3. 线路线段 → 线损计算 ===
+        JSONArray segments = root.getJSONArray("periodSegmentSeResult");
+        if (segments != null && !segments.isEmpty()) {
+            processSegmentsForLineLoss(segments, snapTime, feederMap);
+        }
+
         if (!batch.isEmpty()) {
             // 防重:查该断面时刻已有的所有 device_id(精确到秒,无需再 IN 过滤)
             Set<String> existingIds = new HashSet<>();
@@ -118,11 +131,15 @@ public class SeSnapshotServiceImpl implements SeSnapshotService {
                             .eq(FhzgSeSnapshotDetail::getSnapshotTime, snapTime)
             ).forEach(e -> existingIds.add(e.getDeviceId()));
 
-            int inserted = 0;
+            List<FhzgSeSnapshotDetail> toInsert = new ArrayList<>();
             for (FhzgSeSnapshotDetail d : batch) {
                 if (existingIds.contains(d.getDeviceId())) continue;
-                snapshotDetailMapper.insert(d);
-                inserted++;
+                toInsert.add(d);
+            }
+
+            int inserted = 0;
+            if (!toInsert.isEmpty()) {
+                inserted = snapshotDetailMapper.insertBatch(toInsert);
             }
             log.info("状估断面处理完成 snapTime={}, 写入{}条(去重跳过{}条)",
                     snapTime, inserted, batch.size() - inserted);
@@ -199,9 +216,9 @@ public class SeSnapshotServiceImpl implements SeSnapshotService {
             if (isOverLimit) {
                 if (state.getFirstOverTime() == null) state.setFirstOverTime(snapTime);
                 if (state.getFirstOverTime() != null
-                        && Duration.between(state.getFirstOverTime(), snapTime).toMinutes() >= SeMonitorThreshold.TRIGGER_DURATION_MINUTES
+                        && Duration.between(state.getFirstOverTime(), snapTime).toMinutes() >= SeMonitorThreshold.CURRENT_TRIGGER_DURATION_MINUTES
                         && state.getAlarmTriggered() == 0) {
-                    // 触发时往回看1小时窗口:有任意断面≥100%算过载,否则重载
+                    // 触发时往回看持续窗口:有任意断面≥100%算过载,否则重载
                     boolean hasOverload = hasOverloadSnapshotInWindow(feederId, state.getFirstOverTime(), snapTime);
                     String alarmType = hasOverload ? "current_overload" : "current_heavy";
                     String countyName = lookupCountyName(countyId, feederMap);
@@ -435,6 +452,123 @@ public class SeSnapshotServiceImpl implements SeSnapshotService {
                         .eq(FhzgSeMonitorVoltage::getDeviceType, "consumer"));
     }
 
+    // ============================================================
+    // 线路线损计算
+    // ============================================================
+
+    /**
+     * 处理线路线段断面数据,计算逐段线损功率并写入明细表
+     * <p>
+     * line_loss_kW = 3000 × I_kA² × r_Ω_per_km × (L_m / 1000)
+     * 电阻率: dxd(导线段)=0.4132Ω/km, 0201(电缆段)=0.313Ω/km
+     * 段长度从 t_feeder_topo.sb_length 取(单位:米)
+     * SE 返回的 seoIs 为安培,先 /1000 换算成 kA
+     */
+    private void processSegmentsForLineLoss(JSONArray segments, LocalDateTime snapTime, Map<String, String[]> feederMap) {
+        // 1. 收集所有 psrId
+        List<String> psrIds = new ArrayList<>();
+        for (int i = 0; i < segments.size(); i++) {
+            String psrId = segments.getJSONObject(i).getString("psrId");
+            if (psrId != null) psrIds.add(psrId);
+        }
+        if (psrIds.isEmpty()) return;
+
+        // 2. 批量查 topo 表取 sbLength
+        Map<String, Double> lengthMap = new HashMap<>();
+        try {
+            List<FeederTopo> topoList = feederTopoMapper.selectList(
+                    new QueryWrapper<FeederTopo>()
+                            .in("psr_id", psrIds)
+                            .select("psr_id", "sb_length"));
+            for (FeederTopo t : topoList) {
+                if (t.getSbLength() != null) {
+                    lengthMap.put(t.getPsrId(), t.getSbLength());
+                }
+            }
+        } catch (Exception e) {
+            log.warn("查询线路线损段长度失败", e);
+        }
+
+        // 3. 逐段计算线损
+        List<FhzgSeLineLossDetail> batch = new ArrayList<>();
+        LocalDateTime now = LocalDateTime.now();
+        for (int i = 0; i < segments.size(); i++) {
+            JSONObject seg = segments.getJSONObject(i);
+            String psrId = seg.getString("psrId");
+            String psrType = seg.getString("psrType");
+            String feederId = seg.getString("feederId");
+            String[] loc = feederMap.getOrDefault(feederId, new String[]{"", "", ""});
+
+            BigDecimal current = firstValue(seg.getJSONArray("seoIs"));
+            if (current == null || current.compareTo(BigDecimal.ZERO) <= 0) continue;
+
+            Double sbLength = lengthMap.get(psrId);
+            if (sbLength == null || sbLength <= 0) continue;
+
+            // 电阻率
+            BigDecimal resistivity;
+            if ("0201".equals(psrType)) {
+                resistivity = new BigDecimal("0.313");      // 电缆段
+            } else if ("dxd".equals(psrType)) {
+                resistivity = new BigDecimal("0.4132");     // 导线段
+            } else {
+                log.debug("未知段类型跳过 psrType={}, psrId={}", psrType, psrId);
+                continue;
+            }
+
+            // SE 返回电流为安培,换算成 kA:I_kA = I_A / 1000
+            BigDecimal currentKa = current.divide(BigDecimal.valueOf(1000), 8, RoundingMode.HALF_UP);
+
+            // 3000 × I_kA² × r × (L_m / 1000) = 3 × I_kA² × r × L_m
+            BigDecimal lengthKm = BigDecimal.valueOf(sbLength / 1000.0);
+            BigDecimal loss = BigDecimal.valueOf(3000)
+                    .multiply(currentKa.pow(2))
+                    .multiply(resistivity)
+                    .multiply(lengthKm)
+                    .setScale(4, RoundingMode.HALF_UP);
+
+            FhzgSeLineLossDetail detail = FhzgSeLineLossDetail.builder()
+                    .snapTime(snapTime)
+                    .feederId(feederId)
+                    .psrId(psrId)
+                    .psrType(psrType)
+                    .countyId(loc.length > 1 ? loc[1] : "")
+                    .subsId(loc.length > 0 ? loc[0] : "")
+                    .currentKa(currentKa)
+                    .sbLengthM(BigDecimal.valueOf(sbLength))
+                    .resistivity(resistivity)
+                    .lineLossKw(loss)
+                    .createTime(now)
+                    .build();
+            batch.add(detail);
+        }
+
+        if (!batch.isEmpty()) {
+            // 防重:查该断面时刻已有的段(避免重复跑同一断面时叠加)
+            Set<String> existingPsrIds = new HashSet<>();
+            lineLossDetailMapper.selectList(
+                    new LambdaQueryWrapper<FhzgSeLineLossDetail>()
+                            .select(FhzgSeLineLossDetail::getPsrId)
+                            .eq(FhzgSeLineLossDetail::getSnapTime, snapTime)
+            ).forEach(e -> existingPsrIds.add(e.getPsrId()));
+
+            List<FhzgSeLineLossDetail> toInsert = new ArrayList<>();
+            for (FhzgSeLineLossDetail d : batch) {
+                if (existingPsrIds.contains(d.getPsrId())) continue;
+                toInsert.add(d);
+            }
+
+            int inserted = 0;
+            if (!toInsert.isEmpty()) {
+                inserted = lineLossDetailMapper.insertBatch(toInsert);
+            }
+            int skippedNoData = segments.size() - batch.size();
+            int skippedDup = batch.size() - toInsert.size();
+            log.info("线路线损计算完成 snapTime={}, 写入{}条, 跳过{}条(无数据{}条/重复{}条)",
+                    snapTime, inserted, skippedNoData + skippedDup, skippedNoData, skippedDup);
+        }
+    }
+
     // ============================================================
     // 查询辅助
     // ============================================================
@@ -650,7 +784,11 @@ public class SeSnapshotServiceImpl implements SeSnapshotService {
         int deleted = snapshotDetailMapper.delete(
                 new LambdaQueryWrapper<FhzgSeSnapshotDetail>()
                         .eq(FhzgSeSnapshotDetail::getSnapshotTime, snapTime));
-        log.info("状估覆盖更新 snapTime={}, 删除存量{}条", snapTime, deleted);
+        // 线损明细同步删除(否则防重会跳过插入,保留旧值导致与断面不一致)
+        int deletedLoss = lineLossDetailMapper.delete(
+                new LambdaQueryWrapper<FhzgSeLineLossDetail>()
+                        .eq(FhzgSeLineLossDetail::getSnapTime, snapTime));
+        log.info("状估覆盖更新 snapTime={}, 删除断面{}条, 删除线损{}条", snapTime, deleted, deletedLoss);
 
         // 重新走正常流程写入(此时防重逻辑不会跳过任何记录)
         processSnapshot(snapshotJson);

+ 35 - 31
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/service/staticgrid/impl/StaticGridDashboardServiceImpl.java

@@ -7,10 +7,12 @@ import com.hdkj.lt.bf.entity.staticgrid.StaticIndicatorMetric;
 import com.hdkj.lt.bf.entity.staticgrid.StaticIndicatorResult;
 import com.hdkj.lt.bf.entity.staticgrid.StaticProblemDetail;
 import com.hdkj.lt.bf.entity.vo.StaticGridDashboardVO;
+import com.hdkj.lt.bf.entity.vo.SeVoltageIndexVO;
 import com.hdkj.lt.bf.mapper.FhzgSeCapacityDailyMapper;
 import com.hdkj.lt.bf.mapper.staticgrid.StaticIndicatorMetricMapper;
 import com.hdkj.lt.bf.mapper.staticgrid.StaticIndicatorResultMapper;
 import com.hdkj.lt.bf.mapper.staticgrid.StaticProblemDetailMapper;
+import com.hdkj.lt.bf.service.SeIndicatorService;
 import com.hdkj.lt.bf.service.SjztPwwyyqxtPrmPdrOutageMvDsService;
 import com.hdkj.lt.bf.service.staticgrid.StaticGridDashboardService;
 import com.alibaba.fastjson.JSON;
@@ -54,6 +56,7 @@ public class StaticGridDashboardServiceImpl
     private final SjztPwwyyqxtPrmPdrOutageMvDsService outageDurationService;
     private final FhzgSeCapacityDailyMapper capacityDailyMapper;
     private final StaticProblemDetailMapper problemDetailMapper;
+    private final SeIndicatorService seIndicatorService;
 
     // ============================================================
     // 指标聚合映射表:county 级 metric_code → feeder 级 metric_code
@@ -148,6 +151,8 @@ public class StaticGridDashboardServiceImpl
                 ym.atEndOfMonth().atTime(23, 59, 59)).getTotalHouseholds();
         StaticGridDashboardVO vo = buildDashboardVO(result, metricMap, outage);
         fillCapacityMetrics(vo, result.getCountyId(), null, null, ym);
+        // IEC用户电压指数:与运行态本月同源同算法(type=2 区县维度)
+        vo.setIecUserVoltageIndex(queryVoltageIndexValue(maintOrgId, 2));
         vo.setProblemDetails(buildProblemDetails(result.getId()));
         return vo;
     }
@@ -179,6 +184,8 @@ public class StaticGridDashboardServiceImpl
                 ym.atEndOfMonth().atTime(23, 59, 59)).getTotalHouseholds();
         StaticGridDashboardVO vo = buildDashboardVO(result, metricMap, outage);
         fillCapacityMetrics(vo, null, substationId, null, ym);
+        // IEC用户电压指数:与运行态本月同源同算法(type=3 变电站维度)
+        vo.setIecUserVoltageIndex(queryVoltageIndexValue(substationId, 3));
         vo.setProblemDetails(buildProblemDetails(resultId));
         return vo;
     }
@@ -210,10 +217,25 @@ public class StaticGridDashboardServiceImpl
                 ym.atEndOfMonth().atTime(23, 59, 59)).getTotalHouseholds();
         StaticGridDashboardVO vo = buildDashboardVO(result, metricMap, outage);
         fillCapacityMetrics(vo, null, null, feederId, ym);
+        // IEC用户电压指数:与运行态本月同源同算法(type=4 馈线维度)
+        vo.setIecUserVoltageIndex(queryVoltageIndexValue(feederId, 4));
         vo.setProblemDetails(buildProblemDetails(resultId));
         return vo;
     }
 
+    /**
+     * 计算 IEC 用户电压指数(运行态本月,网架图与运行态同源同算法)
+     */
+    private BigDecimal queryVoltageIndexValue(String id, int type) {
+        try {
+            SeVoltageIndexVO vo = seIndicatorService.queryVoltageIndex(id, type, "month");
+            return vo != null ? vo.getVoltageIndex() : null;
+        } catch (Exception e) {
+            log.warn("IEC用户电压指数计算失败 id={} type={}", id, type, e);
+            return null;
+        }
+    }
+
     // ============================================================
     // 聚合逻辑:feeder 级指标 → county 级格式指标 Map
     // ============================================================
@@ -325,9 +347,7 @@ public class StaticGridDashboardServiceImpl
                 .sameBusInterconnectionIssueCount(toInteger(metricMap.get("same_bus_interconnection_issue_count")))
                 .excessiveTiePointFeederCount(toInteger(metricMap.get("excessive_tie_point_feeder_count")))
                 .singleRadialFeederCount(toInteger(metricMap.get("single_radial_feeder_count")))
-                .noConnectionFeederCount(calcNoConnectionFeederCount(
-                        metricMap.get("feeder_total_count"),
-                        metricMap.get("tied_feeder_total_count")))
+                .noConnectionFeederCount(toInteger(metricMap.get("single_radial_feeder_count")))
                 .largeBranchFeederCount(toInteger(metricMap.get("large_branch_issue_feeder_count")))
                 .unreasonableSegmentCount(toInteger(metricMap.get("unreasonable_segment_count_feeder_count")))
                 .mainTrunkSegmentDenseCount(toInteger(metricMap.get("main_trunk_lantern_issue_feeder_count")))
@@ -441,16 +461,6 @@ public class StaticGridDashboardServiceImpl
         }
     }
 
-    /**
-     * 无联络线路数 = 馈线总条数 - 有联络馈线总条数
-     */
-    private Integer calcNoConnectionFeederCount(String feederTotalStr, String tiedFeederTotalStr) {
-        Integer total = toInteger(feederTotalStr);
-        Integer tied = toInteger(tiedFeederTotalStr);
-        if (total == null || tied == null) return null;
-        return total - tied;
-    }
-
     /**
      * 查询问题详情列表
      */
@@ -554,24 +564,18 @@ public class StaticGridDashboardServiceImpl
             vo.setRegionalAvgLoadRate(BigDecimal.valueOf(avg).setScale(2, RoundingMode.HALF_UP));
         }
 
-        // 线路重过载条数(重载 ≥80% + 过载 ≥100%)
-        if (feederId != null) {
-            // 单馈线:该月有任意重载或过载记录即 1
-            boolean hasHeavyOrOverload = list.stream()
-                    .anyMatch(d -> (d.getHeavyCount() != null && d.getHeavyCount() > 0)
-                            || (d.getOverloadCount() != null && d.getOverloadCount() > 0));
-            vo.setLineOverloadCount(hasHeavyOrOverload ? 1 : 0);
-        } else {
-            // 区县/变电站:去重有重载或过载记录的馈线数
-            long count = list.stream()
-                    .filter(d -> (d.getHeavyCount() != null && d.getHeavyCount() > 0)
-                            || (d.getOverloadCount() != null && d.getOverloadCount() > 0))
-                    .map(FhzgSeCapacityDaily::getFeederId)
-                    .distinct()
-                    .count();
-            if (count > 0) {
-                vo.setLineOverloadCount((int) count);
-            }
+        // 线路重过载事件数(与运行态口径一致:日统计表存当日事件数,按月累加)
+        long sumHeavy = list.stream()
+                .filter(d -> d.getHeavyCount() != null)
+                .mapToLong(FhzgSeCapacityDaily::getHeavyCount)
+                .sum();
+        long sumOverload = list.stream()
+                .filter(d -> d.getOverloadCount() != null)
+                .mapToLong(FhzgSeCapacityDaily::getOverloadCount)
+                .sum();
+        long totalEvents = sumHeavy + sumOverload;
+        if (totalEvents > 0) {
+            vo.setLineOverloadCount((int) totalEvents);
         }
     }
 }

+ 19 - 0
services/load-transfer-bf/src/main/resources/mapper/FhzgSeLineLossDetailMapper.xml

@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.hdkj.lt.bf.mapper.FhzgSeLineLossDetailMapper">
+
+    <!-- 批量插入线损明细 -->
+    <insert id="insertBatch">
+        INSERT INTO fhzg_se_line_loss_detail (
+            snap_time, feeder_id, psr_id, psr_type, county_id, subs_id,
+            current_ka, sb_length_m, resistivity, line_loss_kw, create_time
+        ) VALUES
+        <foreach collection="list" item="item" separator=",">
+            (
+            #{item.snapTime}, #{item.feederId}, #{item.psrId}, #{item.psrType}, #{item.countyId}, #{item.subsId},
+            #{item.currentKa}, #{item.sbLengthM}, #{item.resistivity}, #{item.lineLossKw}, #{item.createTime}
+            )
+        </foreach>
+    </insert>
+
+</mapper>

+ 23 - 0
services/load-transfer-bf/src/main/resources/mapper/FhzgSeSnapshotDetailMapper.xml

@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.hdkj.lt.bf.mapper.FhzgSeSnapshotDetailMapper">
+
+    <!-- 批量插入断面明细 -->
+    <insert id="insertBatch">
+        INSERT INTO fhzg_se_snapshot_detail (
+            snapshot_time, device_id, device_type, feeder_id, feeder_name,
+            subs_id, county_id, mvtrans_id, consumer_id, consumer_name,
+            active_power, reactive_power, current_value, voltage_value, load_rate,
+            lv_voltage_a, capacity, voltage_ratio, current_status, voltage_status, create_time
+        ) VALUES
+        <foreach collection="list" item="item" separator=",">
+            (
+            #{item.snapshotTime}, #{item.deviceId}, #{item.deviceType}, #{item.feederId}, #{item.feederName},
+            #{item.subsId}, #{item.countyId}, #{item.mvtransId}, #{item.consumerId}, #{item.consumerName},
+            #{item.activePower}, #{item.reactivePower}, #{item.currentValue}, #{item.voltageValue}, #{item.loadRate},
+            #{item.lvVoltageA}, #{item.capacity}, #{item.voltageRatio}, #{item.currentStatus}, #{item.voltageStatus}, #{item.createTime}
+            )
+        </foreach>
+    </insert>
+
+</mapper>

+ 108 - 0
services/load-transfer-si/src/main/java/com/hdkj/lt/si/config/StaticGridThresholdProp.java

@@ -0,0 +1,108 @@
+package com.hdkj.lt.si.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 静态网架评估算法阈值配置(algorithm_config.static_grid_thresholds)
+ * <p>
+ * 对应数据契约文档《数据契约样例说明》中 static_grid_thresholds 的 15 个阈值字段。
+ * 未配置的字段不传入请求体,由算法服务沿用其默认值。
+ * 配置前缀:estimation.static-grid-thresholds(Nacos application-{profile}.yaml 公共配置)
+ * 传入后算法侧不使用旧缓存(force_recalculate 已传 true)。
+ *
+ * @author lsl
+ * @since 2026-07-30
+ */
+@Component
+@ConfigurationProperties(prefix = "estimation.static-grid-thresholds")
+@Data
+public class StaticGridThresholdProp {
+
+    /** 供电半径超标阈值(km),默认 5 */
+    private Double supplyRadiusLimitKm;
+
+    /** 超长馈线阈值(km),默认 100 */
+    private Double extraLongFeederLimitKm;
+
+    /** 主干线合理分段范围下限(段),默认 3 */
+    private Integer mainTrunkSegmentMin;
+
+    /** 主干线合理分段范围上限(段),默认 6 */
+    private Integer mainTrunkSegmentMax;
+
+    /** 主干挂灯笼风险的配变密度(台/段),默认 5 */
+    private Integer mainTrunkLanternTransformersPerSegment;
+
+    /** 联络点过多阈值(个),默认 3 */
+    private Integer maximumTiePointCount;
+
+    /** 联络点首端问题的最大分段数,默认 2 */
+    private Integer feederHeadTieSegmentMax;
+
+    /** 大分支低压用户数阈值(户),默认 2000 */
+    private Integer largeBranchLvUserLimit;
+
+    /** 负荷组容量异常阈值(MVA),默认 4 */
+    private Double loadGroupCapacityLimitMva;
+
+    /** 负荷组配变台数异常阈值(台),默认 20 */
+    private Integer loadGroupTransformerLimit;
+
+    /** 配变容量估算负荷系数,默认 0.5 */
+    private Double estimatedLoadFactor;
+
+    /** N-1 转供备用裕度(%),默认 10 */
+    private Double nMinus1BackupMarginPercent;
+
+    /** 主干卡脖子负载率阈值(%),默认 80 */
+    private Double mainTrunkBottleneckLoadRatePercent;
+
+    /** 架空线热稳定电流(A),默认 380 */
+    private Double overheadThermalCurrentA;
+
+    /** 电缆热稳定电流(A),默认 530 */
+    private Double cableThermalCurrentA;
+
+    /**
+     * 转成 algorithm_config.static_grid_thresholds 结构(null 字段不传,沿用算法默认值)
+     */
+    public Map<String, Object> toStaticGridThresholdMap() {
+        Map<String, Object> map = new HashMap<>();
+        putIfNotNull(map, "supply_radius_limit_km", supplyRadiusLimitKm);
+        putIfNotNull(map, "extra_long_feeder_limit_km", extraLongFeederLimitKm);
+        putIfNotNull(map, "main_trunk_segment_min", mainTrunkSegmentMin);
+        putIfNotNull(map, "main_trunk_segment_max", mainTrunkSegmentMax);
+        putIfNotNull(map, "main_trunk_lantern_transformers_per_segment", mainTrunkLanternTransformersPerSegment);
+        putIfNotNull(map, "maximum_tie_point_count", maximumTiePointCount);
+        putIfNotNull(map, "feeder_head_tie_segment_max", feederHeadTieSegmentMax);
+        putIfNotNull(map, "large_branch_lv_user_limit", largeBranchLvUserLimit);
+        putIfNotNull(map, "load_group_capacity_limit_mva", loadGroupCapacityLimitMva);
+        putIfNotNull(map, "load_group_transformer_limit", loadGroupTransformerLimit);
+        putIfNotNull(map, "estimated_load_factor", estimatedLoadFactor);
+        putIfNotNull(map, "n_minus_1_backup_margin_percent", nMinus1BackupMarginPercent);
+        putIfNotNull(map, "main_trunk_bottleneck_load_rate_percent", mainTrunkBottleneckLoadRatePercent);
+        putIfNotNull(map, "overhead_thermal_current_a", overheadThermalCurrentA);
+        putIfNotNull(map, "cable_thermal_current_a", cableThermalCurrentA);
+        return map;
+    }
+
+    /**
+     * 转成外层 algorithm_config 结构(静态网架只含 static_grid_thresholds)
+     */
+    public Map<String, Object> toAlgorithmConfigMap() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("static_grid_thresholds", toStaticGridThresholdMap());
+        return config;
+    }
+
+    private void putIfNotNull(Map<String, Object> map, String key, Object value) {
+        if (value != null) {
+            map.put(key, value);
+        }
+    }
+}

+ 6 - 0
services/load-transfer-si/src/main/java/com/hdkj/lt/si/service/staticgrid/impl/StaticIndicatorResultServiceImpl.java

@@ -5,6 +5,7 @@ import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.hdkj.lt.si.config.StaticGridThresholdProp;
 import com.hdkj.lt.si.dao.StaticIndicatorMetricMapper;
 import com.hdkj.lt.si.dao.StaticIndicatorResultMapper;
 import com.hdkj.lt.si.dao.StaticProblemDetailMapper;
@@ -52,6 +53,9 @@ public class StaticIndicatorResultServiceImpl
     @Resource(type = AlgorithmRemoteCallServiceImpl.class)
     private AlgorithmRemoteCallServiceImpl algorithmRemoteCallService;
 
+    @Resource
+    private StaticGridThresholdProp staticGridThresholdProp;
+
     @Override
     @Transactional(rollbackFor = Exception.class)
     public Long saveStaticGridResult(String batchId, String countyId, String countySid, String countyName, String topologyVersion) {
@@ -65,6 +69,8 @@ public class StaticIndicatorResultServiceImpl
         requestBody.put("scope", "county");
         requestBody.put("topology_version", topologyVersion != null ? topologyVersion : "2026-07-01");
         requestBody.put("force_recalculate", Boolean.TRUE);
+        // 算法判定阈值(未配置的字段沿用算法默认值,见 StaticGridThresholdProp)
+        requestBody.put("algorithm_config", staticGridThresholdProp.toAlgorithmConfigMap());
 
         // 2. 调算法
         String rawResponse = algorithmRemoteCallService.queryStaticGridIndicators(requestBody.toJSONString());