Bläddra i källkod

Merge branch 'feature/service-vpp-20260701' of uskycloud/usky-modules into master

fuyuchuan 5 dagar sedan
förälder
incheckning
d2da97e0fd
25 ändrade filer med 378 tillägg och 201 borttagningar
  1. 9 0
      service-tsdb/service-tsdb-api/pom.xml
  2. 1 1
      service-tsdb/service-tsdb-api/src/main/java/com/usky/demo/RemoteTsdbProxyService.java
  3. 2 1
      service-tsdb/service-tsdb-api/src/main/java/com/usky/demo/factory/RemoteTsdbProxyFallbackFactory.java
  4. 8 0
      service-tsdb/service-tsdb-api/src/main/java/com/usky/demo/feign/RemoteTsdbProxyFeignConfiguration.java
  5. 7 3
      service-tsdb/service-tsdb-biz/src/main/java/com/usky/demo/controller/api/DataTsdbProxyControllerApi.java
  6. 2 1
      service-tsdb/service-tsdb-biz/src/main/java/com/usky/demo/service/QueryTdengineDataService.java
  7. 96 28
      service-tsdb/service-tsdb-biz/src/main/java/com/usky/demo/service/impl/QueryTdengineDataServiceImpl.java
  8. 19 2
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/ContractController.java
  9. 1 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/SiteController.java
  10. 19 4
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppBiddingConfig.java
  11. 1 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppDrEvent.java
  12. 12 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppContractService.java
  13. 7 6
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppBaselineServiceImpl.java
  14. 18 74
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppBiddingConfigServiceImpl.java
  15. 63 3
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppContractServiceImpl.java
  16. 27 53
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppCustomerServiceImpl.java
  17. 12 4
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/BiddingConfigRequest.java
  18. 12 7
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/BiddingConfigVO.java
  19. 2 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/SiteDeclaredCapacityVO.java
  20. 20 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/VppContractExpireStatVO.java
  21. 1 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/VppContractResponseVO.java
  22. 13 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppBaselineHelper.java
  23. 11 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppContractStatusHelper.java
  24. 13 10
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppFieldValidator.java
  25. 2 1
      service-vpp/service-vpp-biz/src/main/resources/sql/vpp_schema.sql

+ 9 - 0
service-tsdb/service-tsdb-api/pom.xml

@@ -20,6 +20,15 @@
             <groupId>com.usky</groupId>
             <artifactId>usky-common-core</artifactId>
         </dependency>
+        <dependency>
+            <groupId>org.springframework</groupId>
+            <artifactId>spring-web</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.github.openfeign.form</groupId>
+            <artifactId>feign-form-spring</artifactId>
+            <version>3.8.0</version>
+        </dependency>
     </dependencies>
 
     <build>

+ 1 - 1
service-tsdb/service-tsdb-api/src/main/java/com/usky/demo/RemoteTsdbProxyService.java

@@ -112,6 +112,6 @@ public interface RemoteTsdbProxyService {
      * 解析 CSV 并导入设备时序数据:INSERT INTO _{deviceUuid} (ts, p) VALUES ...
      */
     @PostMapping(value = "/importDeviceCsv", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
-    ApiResult<DeviceCsvImportResultVO> importDeviceCsv(@RequestParam("deviceUuid") String deviceUuid,
+    ApiResult<DeviceCsvImportResultVO> importDeviceCsv(@RequestPart("deviceUuid") String deviceUuid,
                                                        @RequestPart("file") MultipartFile file);
 }

+ 2 - 1
service-tsdb/service-tsdb-api/src/main/java/com/usky/demo/factory/RemoteTsdbProxyFallbackFactory.java

@@ -108,7 +108,8 @@ public class RemoteTsdbProxyFallbackFactory implements FallbackFactory<RemoteTsd
             @Override
             public ApiResult<DeviceCsvImportResultVO> importDeviceCsv(String deviceUuid, org.springframework.web.multipart.MultipartFile file)
             {
-                throw new BusinessException("设备时序 CSV 导入:" + throwable.getMessage());
+                log.error("设备时序 CSV 导入失败: {}", throwable.getMessage());
+                return ApiResult.error("设备时序 CSV 导入:" + throwable.getMessage());
             }
         };
     }

+ 8 - 0
service-tsdb/service-tsdb-api/src/main/java/com/usky/demo/feign/RemoteTsdbProxyFeignConfiguration.java

@@ -2,10 +2,13 @@ package com.usky.demo.feign;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
 import feign.codec.Decoder;
+import feign.codec.Encoder;
+import feign.form.spring.SpringFormEncoder;
 import org.springframework.beans.factory.ObjectFactory;
 import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.boot.autoconfigure.http.HttpMessageConverters;
 import org.springframework.cloud.openfeign.support.SpringDecoder;
+import org.springframework.cloud.openfeign.support.SpringEncoder;
 import org.springframework.context.annotation.Bean;
 
 /**
@@ -13,6 +16,11 @@ import org.springframework.context.annotation.Bean;
  */
 public class RemoteTsdbProxyFeignConfiguration {
 
+    @Bean
+    public Encoder feignEncoder(ObjectFactory<HttpMessageConverters> messageConverters) {
+        return new SpringFormEncoder(new SpringEncoder(messageConverters));
+    }
+
     @Bean
     public Decoder feignDecoder(
             ObjectFactory<HttpMessageConverters> messageConverters,

+ 7 - 3
service-tsdb/service-tsdb-biz/src/main/java/com/usky/demo/controller/api/DataTsdbProxyControllerApi.java

@@ -14,6 +14,7 @@ import com.usky.demo.service.QueryTdengineDataService;
 import com.usky.demo.service.SysUserService;
 import com.usky.system.RemoteUserService;
 import com.usky.system.domain.SysUserVO;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.http.MediaType;
@@ -28,6 +29,7 @@ import java.util.Map;
 import java.util.Optional;
 
 @RestController
+@Slf4j
 public class DataTsdbProxyControllerApi implements RemoteTsdbProxyService {
     @Value("${spring.sourcetype}")
     private String sourcetype;
@@ -177,12 +179,14 @@ public class DataTsdbProxyControllerApi implements RemoteTsdbProxyService {
     }
 
     @Override
-    public ApiResult<DeviceCsvImportResultVO> importDeviceCsv(@RequestParam("deviceUuid") String deviceUuid,
+    @PostMapping(value = "/importDeviceCsv", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+    public ApiResult<DeviceCsvImportResultVO> importDeviceCsv(@RequestPart("deviceUuid") String deviceUuid,
                                                               @RequestPart("file") MultipartFile file) {
         if (!"taos".equals(sourcetype)) {
-            throw new BusinessException("当前数据源不支持 TDengine CSV 导入");
+            log.error("当前数据源不支持 TDengine CSV 导入, sourcetype={}", sourcetype);
+            return ApiResult.error("当前数据源不支持 TDengine CSV 导入");
         }
-        return ApiResult.success(queryTdengineDataService.importDeviceCsv(deviceUuid, file));
+        return queryTdengineDataService.importDeviceCsv(deviceUuid, file);
     }
 
 }

+ 2 - 1
service-tsdb/service-tsdb-biz/src/main/java/com/usky/demo/service/QueryTdengineDataService.java

@@ -1,5 +1,6 @@
 package com.usky.demo.service;
 
+import com.usky.common.core.bean.ApiResult;
 import com.usky.common.mybatis.core.CrudService;
 import com.usky.demo.domain.*;
 import com.usky.demo.service.vo.SuperTableVO;
@@ -53,5 +54,5 @@ public interface QueryTdengineDataService extends CrudService<QueryTdengineData>
     /**
      * 从 CSV 解析并导入设备子表时序:INSERT INTO _{deviceUuid} (ts, p) VALUES ...
      */
-    DeviceCsvImportResultVO importDeviceCsv(String deviceUuid, MultipartFile file);
+    ApiResult<DeviceCsvImportResultVO> importDeviceCsv(String deviceUuid, MultipartFile file);
 }

+ 96 - 28
service-tsdb/service-tsdb-biz/src/main/java/com/usky/demo/service/impl/QueryTdengineDataServiceImpl.java

@@ -4,6 +4,7 @@ import com.baomidou.dynamic.datasource.annotation.DS;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.usky.common.core.bean.ApiResult;
 import com.usky.common.core.exception.BusinessException;
 import com.usky.common.mybatis.core.AbstractCrudService;
 import com.usky.demo.domain.*;
@@ -481,36 +482,53 @@ public class QueryTdengineDataServiceImpl extends AbstractCrudService<QueryTdeng
     }
 
     @Override
-    public DeviceCsvImportResultVO importDeviceCsv(String deviceUuid, MultipartFile file) {
+    public ApiResult<DeviceCsvImportResultVO> importDeviceCsv(String deviceUuid, MultipartFile file) {
         if (StringUtils.isBlank(deviceUuid)) {
-            throw new BusinessException("设备UUID不能为空");
+            log.error("设备UUID不能为空");
+            return ApiResult.error("设备UUID不能为空");
         }
         if (!DEVICE_UUID_PATTERN.matcher(deviceUuid.trim()).matches()) {
-            throw new BusinessException("设备UUID格式无效");
+            log.error("设备UUID格式无效, deviceUuid=" + deviceUuid);
+            return ApiResult.error("设备UUID格式无效");
         }
         if (file == null || file.isEmpty()) {
-            throw new BusinessException("导入文件不能为空");
+            log.error("导入文件不能为空, deviceUuid=" + deviceUuid);
+            return ApiResult.error("导入文件不能为空");
         }
         String originalFilename = file.getOriginalFilename();
         if (StringUtils.isBlank(originalFilename) || !originalFilename.toLowerCase(Locale.ROOT).endsWith(".csv")) {
-            throw new BusinessException("仅支持 CSV 文件");
+            log.error("仅支持 CSV 文件, deviceUuid=" + deviceUuid + ", filename=" + originalFilename);
+            return ApiResult.error("仅支持 CSV 文件");
         }
 
         String normalizedUuid = deviceUuid.trim();
         String tableName = buildDeviceSubTableName(normalizedUuid);
         try {
             List<TsdbCsvRow> rows = parseCsvRows(file);
-            batchInsertRows(tableName, rows);
+            if (rows == null) {
+                return ApiResult.error("CSV 解析失败");
+            }
+            if (rows.isEmpty()) {
+                log.error("CSV 无有效数据行, deviceUuid=" + normalizedUuid + ", tableName=" + tableName);
+                return ApiResult.error("CSV 无有效可导入数据行");
+            }
+            int importedRows = batchInsertRows(tableName, rows);
+            if (importedRows == 0) {
+                log.error("CSV 无有效可导入数据行, deviceUuid=" + normalizedUuid + ", tableName=" + tableName);
+                return ApiResult.error("CSV 无有效可导入数据行");
+            }
 
             DeviceCsvImportResultVO result = new DeviceCsvImportResultVO();
             result.setDeviceUuid(normalizedUuid);
             result.setTableName(tableName);
-            result.setImportedRows(rows.size());
-            return result;
+            result.setImportedRows(importedRows);
+            return ApiResult.success(result);
         } catch (IOException ex) {
-            throw new BusinessException("解析 CSV 文件失败: " + ex.getMessage());
+            log.error("解析 CSV 文件失败, deviceUuid=" + normalizedUuid + ", tableName=" + tableName + ", error=" + ex.getMessage());
+            return ApiResult.error("解析 CSV 文件失败: " + ex.getMessage());
         } catch (SQLException ex) {
-            throw new BusinessException("时序数据导入失败: " + ex.getMessage());
+            log.error("时序数据导入失败, deviceUuid=" + normalizedUuid + ", tableName=" + tableName + ", error=" + ex.getMessage());
+            return ApiResult.error("时序数据导入失败: " + ex.getMessage());
         }
     }
 
@@ -533,27 +551,48 @@ public class QueryTdengineDataServiceImpl extends AbstractCrudService<QueryTdeng
                 if (!headerResolved) {
                     if (isHeaderRow(parts)) {
                         tsIndex = findColumnIndex(parts, TS_HEADER_ALIASES, "ts");
+                        if (tsIndex < 0) {
+                            return null;
+                        }
                         pIndex = findColumnIndex(parts, P_HEADER_ALIASES, "p");
+                        if (pIndex < 0) {
+                            return null;
+                        }
                         headerResolved = true;
                         continue;
                     }
                     headerResolved = true;
                 }
                 if (parts.length <= Math.max(tsIndex, pIndex)) {
-                    throw new BusinessException("CSV 第 " + lineNo + " 行列数不足");
+                    log.error("CSV 第 " + lineNo + " 行列数不足,已跳过");
+                    continue;
+                }
+                String tsRaw = parts[tsIndex];
+                String pRaw = parts[pIndex];
+                if (StringUtils.isBlank(tsRaw) || StringUtils.isBlank(pRaw)) {
+                    log.error("CSV 第 " + lineNo + " 行 ts 或 p 存在空值,已跳过");
+                    continue;
+                }
+                Long tsMillis = parseTimestamp(tsRaw, lineNo);
+                if (tsMillis == null) {
+                    continue;
+                }
+                Double pValue = parseMetricValue(pRaw, lineNo);
+                if (pValue == null) {
+                    continue;
                 }
-                rows.add(new TsdbCsvRow(
-                        parseTimestamp(parts[tsIndex], lineNo),
-                        parseMetricValue(parts[pIndex], lineNo)));
+                TsdbCsvRow row = new TsdbCsvRow(lineNo, tsMillis, pValue);
+                if (!isImportableRow(row)) {
+                    continue;
+                }
+                rows.add(row);
             }
         }
-        if (rows.isEmpty()) {
-            throw new BusinessException("CSV 无有效数据行");
-        }
         return rows;
     }
 
-    private void batchInsertRows(String tableName, List<TsdbCsvRow> rows) throws SQLException {
+    private int batchInsertRows(String tableName, List<TsdbCsvRow> rows) throws SQLException {
+        int importedRows = 0;
         try (Connection connection = dataSource.getConnection();
              Statement statement = connection.createStatement()) {
             for (int start = 0; start < rows.size(); start += IMPORT_BATCH_SIZE) {
@@ -561,16 +600,38 @@ public class QueryTdengineDataServiceImpl extends AbstractCrudService<QueryTdeng
                 StringBuilder sql = new StringBuilder("INSERT INTO ")
                         .append(tableName)
                         .append(" (ts, p) VALUES ");
+                int batchCount = 0;
                 for (int i = start; i < end; i++) {
                     TsdbCsvRow row = rows.get(i);
-                    if (i > start) {
+                    if (!isImportableRow(row)) {
+                        continue;
+                    }
+                    if (batchCount > 0) {
                         sql.append(' ');
                     }
                     sql.append('(').append(row.tsMillis).append(',').append(row.pValue).append(')');
+                    batchCount++;
+                }
+                if (batchCount == 0) {
+                    continue;
                 }
                 statement.execute(sql.toString());
+                importedRows += batchCount;
             }
         }
+        return importedRows;
+    }
+
+    private boolean isImportableRow(TsdbCsvRow row) {
+        if (row.tsMillis <= 0) {
+            log.error("CSV 第 " + row.lineNo + " 行 ts 无效,已跳过");
+            return false;
+        }
+        if (!Double.isFinite(row.pValue)) {
+            log.error("CSV 第 " + row.lineNo + " 行 p 不是有效数值,已跳过");
+            return false;
+        }
+        return true;
     }
 
     private static String[] splitCsvLine(String line) {
@@ -589,19 +650,21 @@ public class QueryTdengineDataServiceImpl extends AbstractCrudService<QueryTdeng
         return false;
     }
 
-    private static int findColumnIndex(String[] headers, List<String> aliases, String columnLabel) {
+    private int findColumnIndex(String[] headers, List<String> aliases, String columnLabel) {
         for (int i = 0; i < headers.length; i++) {
             if (aliases.contains(headers[i].trim().toLowerCase(Locale.ROOT))) {
                 return i;
             }
         }
-        throw new BusinessException("CSV 缺少 " + columnLabel + " 列");
+        log.error("CSV 缺少 " + columnLabel + " 列");
+        return -1;
     }
 
-    private static long parseTimestamp(String rawValue, int lineNo) {
+    private Long parseTimestamp(String rawValue, int lineNo) {
         String value = rawValue == null ? "" : rawValue.trim();
         if (value.isEmpty()) {
-            throw new BusinessException("CSV 第 " + lineNo + " 行 ts 为空");
+            log.error("CSV 第 " + lineNo + " 行 ts 为空,已跳过");
+            return null;
         }
         if (value.matches("\\d+")) {
             long epoch = Long.parseLong(value);
@@ -618,19 +681,22 @@ public class QueryTdengineDataServiceImpl extends AbstractCrudService<QueryTdeng
         try {
             return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss").parse(value).getTime();
         } catch (ParseException ex) {
-            throw new BusinessException("CSV 第 " + lineNo + " 行 ts 格式无效: " + value);
+            log.error("CSV 第 " + lineNo + " 行 ts 格式无效: " + value + ",已跳过");
+            return null;
         }
     }
 
-    private static double parseMetricValue(String rawValue, int lineNo) {
+    private Double parseMetricValue(String rawValue, int lineNo) {
         String value = rawValue == null ? "" : rawValue.trim();
         if (value.isEmpty()) {
-            throw new BusinessException("CSV 第 " + lineNo + " 行 p 为空");
+            log.error("CSV 第 " + lineNo + " 行 p 为空,已跳过");
+            return null;
         }
         try {
             return new BigDecimal(value).doubleValue();
         } catch (NumberFormatException ex) {
-            throw new BusinessException("CSV 第 " + lineNo + " 行 p 不是有效数值: " + value);
+            log.error("CSV 第 " + lineNo + " 行 p 不是有效数值: " + value + ",已跳过");
+            return null;
         }
     }
 
@@ -639,10 +705,12 @@ public class QueryTdengineDataServiceImpl extends AbstractCrudService<QueryTdeng
     }
 
     private static final class TsdbCsvRow {
+        private final int lineNo;
         private final long tsMillis;
         private final double pValue;
 
-        private TsdbCsvRow(long tsMillis, double pValue) {
+        private TsdbCsvRow(int lineNo, long tsMillis, double pValue) {
+            this.lineNo = lineNo;
             this.tsMillis = tsMillis;
             this.pValue = pValue;
         }

+ 19 - 2
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/ContractController.java

@@ -4,6 +4,7 @@ import com.usky.common.core.bean.ApiResult;
 import com.usky.common.core.bean.CommonPage;
 import com.usky.vpp.service.VppContractService;
 import com.usky.vpp.service.VppContractTemplateService;
+import com.usky.vpp.service.vo.VppContractExpireStatVO;
 import com.usky.vpp.service.vo.VppContractRequestVO;
 import com.usky.vpp.service.vo.VppContractResponseVO;
 import com.usky.vpp.service.vo.VppContractTemplateRequestVO;
@@ -111,7 +112,10 @@ public class ContractController {
      * templateId     模板ID
      * contractType   合同类型
      * customerId     客户ID
-     * contractStatus 合同状态
+     * customerName   客户名称 模糊匹配
+     * signDateStart  签订时间起始
+     * signDateEnd    签订时间截止
+     * contractStatus 合同状态 0草稿 1审核中 2待生效 3已生效 4已驳回 5已到期 6已终止 7即将到期(三月内过期)
      * current        页码
      * size           页大小
      */
@@ -123,11 +127,24 @@ public class ContractController {
             @RequestParam(value = "templateId", required = false) Long templateId,
             @RequestParam(value = "contractType", required = false) Integer contractType,
             @RequestParam(value = "customerId", required = false) Long customerId,
+            @RequestParam(value = "customerName", required = false) String customerName,
+            @RequestParam(value = "signDateStart", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate signDateStart,
+            @RequestParam(value = "signDateEnd", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate signDateEnd,
             @RequestParam(value = "contractStatus", required = false) Integer contractStatus,
             @RequestParam(value = "current", required = false, defaultValue = "1") Integer current,
             @RequestParam(value = "size", required = false, defaultValue = "20") Integer size) {
         return ApiResult.success(vppContractService.page(id, startDate, endDate,
-                templateId, contractType, customerId, contractStatus, current, size));
+                templateId, contractType, customerId, customerName, signDateStart, signDateEnd, contractStatus, current, size));
+    }
+
+    /**
+     * 按客户最新合同统计已到期 / 即将到期数量
+     * <p>租户隔离;每个客户仅取创建时间最新的一份合同,返回已到期、即将到期(近三个月)两类数量。
+     * 列表请调用分页接口:contractStatus=5 查已到期,contractStatus=7 查即将到期。</p>
+     */
+    @GetMapping("/expireStat")
+    public ApiResult<VppContractExpireStatVO> expireStat() {
+        return ApiResult.success(vppContractService.expireStat());
     }
 
     /**

+ 1 - 1
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/SiteController.java

@@ -173,7 +173,7 @@ public class SiteController {
     }
 
     /**
-     * 响应申报容量
+     * 响应申报容量和预估响应容量
      * <p><b>GET</b> /site/{siteId}/declared-capacity</p>
      * <p>完整路径:GET /prod-api/service-vpp/site/{siteId}/declared-capacity</p>
      *

+ 19 - 4
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppBiddingConfig.java

@@ -34,18 +34,33 @@ public class VppBiddingConfig implements Serializable {
     private String attachmentUrl;
     @TableField("attachment_name")
     private String attachmentName;
+
+    /** 1 中长期响应
+     *  2 日内响应
+     *  3 快速响应
+     * */
     @TableField("bidding_mode")
     private Integer biddingMode;
+
     @TableField("typical_coefficient")
     private BigDecimal typicalCoefficient;
-    @TableField("advance_close_hours")
-    private Integer advanceCloseHours;
+
+    /**
+     * 提前通知最小小时数(下限,不含本值,如 8 表示 >8)
+     */
+    @TableField("notice_min_hours")
+    private BigDecimal noticeMinHours;
+
+    /**
+     * 提前通知最大小时数(上限,含本值,如 24 表示 ≤24)
+     */
+    @TableField("notice_max_hours")
+    private BigDecimal noticeMaxHours;
+
     @TableField("max_price")
     private BigDecimal maxPrice;
     @TableField("min_price")
     private BigDecimal minPrice;
-    @TableField("site_ids")
-    private String siteIds;
     @TableField("tenant_id")
     private Integer tenantId;
 

+ 1 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppDrEvent.java

@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
+
 import java.io.Serializable;
 import java.math.BigDecimal;
 import java.time.LocalDateTime;

+ 12 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppContractService.java

@@ -1,6 +1,7 @@
 package com.usky.vpp.service;
 
 import com.usky.common.core.bean.CommonPage;
+import com.usky.vpp.service.vo.VppContractExpireStatVO;
 import com.usky.vpp.service.vo.VppContractRequestVO;
 import com.usky.vpp.service.vo.VppContractResponseVO;
 
@@ -35,12 +36,16 @@ public interface VppContractService {
      * @param templateId     模板ID
      * @param contractType   合同类型
      * @param customerId     客户ID
+     * @param customerName   客户名称(模糊匹配)
+     * @param signDateStart  签订时间起始
+     * @param signDateEnd    签订时间截止
      * @param contractStatus 合同状态
      * @param pageNum        页码
      * @param pageSize       页大小
      */
     CommonPage<VppContractResponseVO> page(Long id, LocalDate signDateStart, LocalDate signDateEnd,
                                            Long templateId, Integer contractType, Long customerId,
+                                           String customerName, LocalDate signDateRangeStart, LocalDate signDateRangeEnd,
                                            Integer contractStatus, Integer pageNum, Integer pageSize);
 
     /**
@@ -52,4 +57,11 @@ public interface VppContractService {
      * 下载合同文件
      */
     void download(Long id, HttpServletResponse response);
+
+    /**
+     * 按客户最新合同统计已到期 / 即将到期(近三个月)数量
+     * <p>租户隔离;每个客户仅取创建时间最新的一份合同,再按到期日分类统计数量。
+     * 列表请走分页接口,按 contractStatus=5(已到期)/ 7(即将到期)查询。</p>
+     */
+    VppContractExpireStatVO expireStat();
 }

+ 7 - 6
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppBaselineServiceImpl.java

@@ -94,7 +94,7 @@ public class VppBaselineServiceImpl implements VppBaselineService {
         result.setResponseStartTime(startTime);
 
         if (deviceUuids.isEmpty()) {
-            return buildMockResult(result, siteId, responseDate, startTime);
+            return result;
         }
 
         Set<LocalDate> excludeDates = loadResponseHistoryDates();
@@ -120,7 +120,7 @@ public class VppBaselineServiceImpl implements VppBaselineService {
                 VppBaselineHelper.INTERVAL_MINUTES,
                 null);
         if (originalBaselineByTime.isEmpty()) {
-            return buildMockResult(result, siteId, responseDate, startTime);
+            return result;
         }
 
         //历史日的响应开始时间前两个小时原始基线
@@ -176,7 +176,7 @@ public class VppBaselineServiceImpl implements VppBaselineService {
     }
 
     /**
-     * 查询站点响应申报容量。
+     * 查询站点响应申报容量和预估响应容量
      *
      * <p>公式:响应时段内同时间段历史负荷均值 × 修正系数 K × 执行时段小时数 / 2。</p>
      * <p>典型历史日、修正系数 K 计算规则与基线管理一致;历史均值仅取响应开始至结束时段。</p>
@@ -198,7 +198,7 @@ public class VppBaselineServiceImpl implements VppBaselineService {
 
         List<String> deviceUuids = listControllableDeviceUuids(siteId);
         if (deviceUuids.isEmpty()) {
-            return buildMockDeclaredCapacityResult(result, siteId, responseDate, startTime, endTime);
+            return result;
         }
 
         Set<LocalDate> excludeDates = loadResponseHistoryDates();
@@ -239,12 +239,13 @@ public class VppBaselineServiceImpl implements VppBaselineService {
                 VppBaselineHelper.INTERVAL_MINUTES,
                 null);
         if (historicalAvgByTime.isEmpty()) {
-            return buildMockDeclaredCapacityResult(result, siteId, responseDate, startTime, endTime);
+            return result;
         }
 
         result.setCorrectionFactorK(correctionFactorK);
         result.setDeclaredCapacityKw(VppBaselineHelper.calculateDeclaredCapacityKw(
                 historicalAvgByTime, startTime, endTime, correctionFactorK));
+        result.setEstimatedResponseCapacityKw(VppBaselineHelper.calculateEstimatedResponseCapacityKw(todayActualByTime, startTime, endTime));
         result.setPoints(VppBaselineHelper.buildDeclaredCapacityPoints(
                 historicalAvgByTime, startTime, endTime, correctionFactorK));
         result.setDataSource("tsdb");
@@ -296,7 +297,7 @@ public class VppBaselineServiceImpl implements VppBaselineService {
         List<VppResourcePoint> resources = resourcePointMapper.selectList(
                 new LambdaQueryWrapper<VppResourcePoint>()
                         .eq(VppResourcePoint::getSiteId, siteId)
-                        .eq(VppResourcePoint::getIsControl, 1)
+                        .eq(VppResourcePoint::getIsSupportPeak, 1)
                         .eq(VppResourcePoint::getDeleteFlag, VppAuditHelper.NOT_DELETED));
         if (CollectionUtils.isEmpty(resources)) {
             return new ArrayList<>();

+ 18 - 74
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppBiddingConfigServiceImpl.java

@@ -6,28 +6,22 @@ import com.usky.common.core.bean.CommonPage;
 import com.usky.common.core.exception.BusinessException;
 import com.usky.common.security.utils.SecurityUtils;
 import com.usky.vpp.domain.VppBiddingConfig;
-import com.usky.vpp.domain.VppSite;
 import com.usky.vpp.mapper.VppBiddingConfigMapper;
-import com.usky.vpp.mapper.VppSiteMapper;
 import com.usky.vpp.service.VppBiddingConfigService;
 import com.usky.vpp.service.vo.BiddingConfigRequest;
-import com.usky.vpp.service.vo.BiddingConfigSiteVO;
 import com.usky.vpp.service.vo.BiddingConfigVO;
 import com.usky.vpp.util.VppAuditHelper;
 import com.usky.vpp.util.VppBiddingConfigHelper;
 import com.usky.vpp.util.VppPageHelper;
-import com.usky.vpp.util.VppReportHelper;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.StringUtils;
 
 import java.time.LocalDateTime;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
 import java.util.stream.Collectors;
 
 @Service
@@ -35,8 +29,6 @@ public class VppBiddingConfigServiceImpl implements VppBiddingConfigService {
 
     @Autowired
     private VppBiddingConfigMapper biddingConfigMapper;
-    @Autowired
-    private VppSiteMapper siteMapper;
 
     @Override
     public CommonPage<BiddingConfigVO> page(String configName,
@@ -65,24 +57,23 @@ public class VppBiddingConfigServiceImpl implements VppBiddingConfigService {
 
         Page<VppBiddingConfig> result = biddingConfigMapper.selectPage(page, wrapper);
         List<BiddingConfigVO> records = result.getRecords().stream()
-                .map(config -> toVo(config, false))
+                .map(this::toVo)
                 .collect(Collectors.toList());
         return new CommonPage<>(records, result.getTotal(), result.getCurrent(), result.getSize());
     }
 
     @Override
     public BiddingConfigVO get(Long id) {
-        return toVo(requireConfig(id), true);
+        return toVo(requireConfig(id));
     }
 
     @Override
     @Transactional(rollbackFor = Exception.class)
     public Long create(BiddingConfigRequest request) {
         validateRequest(request);
-        List<Long> siteIds = validateAndNormalizeSiteIds(request.getSiteIds(), resolveCurrentTenantId());
 
         VppBiddingConfig config = new VppBiddingConfig();
-        fillConfigFromRequest(config, request, siteIds);
+        fillConfigFromRequest(config, request);
         VppAuditHelper.fillCreate(config);
         biddingConfigMapper.insert(config);
         return config.getId();
@@ -93,9 +84,8 @@ public class VppBiddingConfigServiceImpl implements VppBiddingConfigService {
     public void update(Long id, BiddingConfigRequest request) {
         VppBiddingConfig config = requireConfig(id);
         validateRequest(request);
-        List<Long> siteIds = validateAndNormalizeSiteIds(request.getSiteIds(), resolveCurrentTenantId());
 
-        fillConfigFromRequest(config, request, siteIds);
+        fillConfigFromRequest(config, request);
         VppAuditHelper.fillUpdate(config);
         biddingConfigMapper.updateById(config);
     }
@@ -117,8 +107,7 @@ public class VppBiddingConfigServiceImpl implements VppBiddingConfigService {
     }
 
     private void fillConfigFromRequest(VppBiddingConfig config,
-                                       BiddingConfigRequest request,
-                                       List<Long> siteIds) {
+                                       BiddingConfigRequest request) {
         config.setConfigName(request.getConfigName().trim());
         config.setEffectiveStart(request.getEffectiveStart());
         config.setEffectiveEnd(request.getEffectiveEnd());
@@ -126,13 +115,13 @@ public class VppBiddingConfigServiceImpl implements VppBiddingConfigService {
         config.setAttachmentName(request.getAttachmentName());
         config.setBiddingMode(request.getBiddingMode());
         config.setTypicalCoefficient(request.getTypicalCoefficient());
-        config.setAdvanceCloseHours(request.getAdvanceCloseHours());
+        config.setNoticeMinHours(request.getNoticeMinHours());
+        config.setNoticeMaxHours(request.getNoticeMaxHours());
         config.setMaxPrice(request.getMaxPrice());
         config.setMinPrice(request.getMinPrice());
-        config.setSiteIds(VppReportHelper.toJson(siteIds));
     }
 
-    private BiddingConfigVO toVo(VppBiddingConfig config, boolean withSites) {
+    private BiddingConfigVO toVo(VppBiddingConfig config) {
         BiddingConfigVO vo = new BiddingConfigVO();
         vo.setId(config.getId());
         vo.setConfigName(config.getConfigName());
@@ -143,37 +132,16 @@ public class VppBiddingConfigServiceImpl implements VppBiddingConfigService {
         vo.setAttachmentUrl(config.getAttachmentUrl());
         vo.setAttachmentName(config.getAttachmentName());
         vo.setBiddingMode(config.getBiddingMode());
-        vo.setBiddingModeName(VppBiddingConfigHelper.resolveBiddingModeName(config.getBiddingMode()));
+        // vo.setBiddingModeName(VppBiddingConfigHelper.resolveBiddingModeName(config.getBiddingMode()));
         vo.setTypicalCoefficient(config.getTypicalCoefficient());
-        vo.setAdvanceCloseHours(config.getAdvanceCloseHours());
-        vo.setAdvanceCloseHoursLabel(
-                VppBiddingConfigHelper.resolveAdvanceCloseHoursLabel(config.getAdvanceCloseHours()));
+        vo.setNoticeMinHours(config.getNoticeMinHours());
+        vo.setNoticeMaxHours(config.getNoticeMaxHours());
         vo.setMaxPrice(config.getMaxPrice());
         vo.setMinPrice(config.getMinPrice());
-        List<Long> siteIds = VppReportHelper.parseSiteIds(config.getSiteIds());
-        vo.setSiteIds(siteIds);
         vo.setUpdateTime(config.getUpdateTime());
-        if (withSites) {
-            vo.setSites(loadSiteBriefs(siteIds));
-        }
         return vo;
     }
 
-    private List<BiddingConfigSiteVO> loadSiteBriefs(List<Long> siteIds) {
-        if (siteIds == null || siteIds.isEmpty()) {
-            return Collections.emptyList();
-        }
-        Map<Long, String> siteNameMap = siteMapper.selectBatchIds(siteIds).stream()
-                .filter(site -> !VppAuditHelper.isDeleted(site.getDeleteFlag()))
-                .collect(Collectors.toMap(VppSite::getId, VppSite::getSiteName, (left, right) -> left));
-        return siteIds.stream().map(siteId -> {
-            BiddingConfigSiteVO item = new BiddingConfigSiteVO();
-            item.setSiteId(siteId);
-            item.setSiteName(siteNameMap.get(siteId));
-            return item;
-        }).collect(Collectors.toList());
-    }
-
     private void validateRequest(BiddingConfigRequest request) {
         if (request == null) {
             throw new BusinessException("请求体不能为空");
@@ -195,8 +163,13 @@ public class VppBiddingConfigServiceImpl implements VppBiddingConfigService {
         if (request.getTypicalCoefficient() == null) {
             throw new BusinessException("典型系数不能为空");
         }
-        if (request.getAdvanceCloseHours() == null || request.getAdvanceCloseHours() <= 0) {
-            throw new BusinessException("提前截标时间须为正整数(小时)");
+        if (request.getNoticeMinHours() != null
+                && request.getNoticeMinHours().compareTo(new java.math.BigDecimal("0.5")) < 0) {
+            throw new BusinessException("提前截标最小通知时间不能小于0.5小时!");
+        }
+        if (request.getNoticeMaxHours() != null
+                && request.getNoticeMaxHours().compareTo(new java.math.BigDecimal("0.5")) < 0) {
+            throw new BusinessException("提前截标最大通知时间不能小于0.5小时!");
         }
         if (request.getMaxPrice() == null) {
             throw new BusinessException("最高价格不能为空");
@@ -205,35 +178,6 @@ public class VppBiddingConfigServiceImpl implements VppBiddingConfigService {
             throw new BusinessException("最低价格不能为空");
         }
         VppBiddingConfigHelper.validatePriceRange(request.getMinPrice(), request.getMaxPrice());
-        if (request.getSiteIds() == null || request.getSiteIds().isEmpty()) {
-            throw new BusinessException("请至少选择一个关联站点");
-        }
-    }
-
-    private List<Long> validateAndNormalizeSiteIds(List<Long> siteIds, Integer tenantId) {
-        if (siteIds == null || siteIds.isEmpty()) {
-            throw new BusinessException("请至少选择一个关联站点");
-        }
-        List<Long> distinct = siteIds.stream()
-                .filter(Objects::nonNull)
-                .distinct()
-                .collect(Collectors.toList());
-        if (distinct.isEmpty()) {
-            throw new BusinessException("请至少选择一个关联站点");
-        }
-        Map<Long, VppSite> siteMap = siteMapper.selectBatchIds(distinct).stream()
-                .filter(site -> !VppAuditHelper.isDeleted(site.getDeleteFlag()))
-                .collect(Collectors.toMap(VppSite::getId, site -> site, (left, right) -> left));
-        for (Long siteId : distinct) {
-            VppSite site = siteMap.get(siteId);
-            if (site == null) {
-                throw new BusinessException("站点不存在或已删除: " + siteId);
-            }
-            if (tenantId != null && site.getTenantId() != null && !tenantId.equals(site.getTenantId())) {
-                throw new BusinessException("站点不属于当前租户: " + siteId);
-            }
-        }
-        return distinct;
     }
 
     private Integer resolveCurrentTenantId() {

+ 63 - 3
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppContractServiceImpl.java

@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.usky.common.core.bean.CommonPage;
 import com.usky.common.core.exception.BusinessException;
+import com.usky.common.security.utils.SecurityUtils;
 import com.usky.vpp.domain.VppContract;
 import com.usky.vpp.domain.VppContractTemplate;
 import com.usky.vpp.domain.VppCustomer;
@@ -12,6 +13,7 @@ import com.usky.vpp.mapper.VppContractMapper;
 import com.usky.vpp.mapper.VppContractTemplateMapper;
 import com.usky.vpp.mapper.VppCustomerMapper;
 import com.usky.vpp.service.VppContractService;
+import com.usky.vpp.service.vo.VppContractExpireStatVO;
 import com.usky.vpp.service.vo.VppContractRequestVO;
 import com.usky.vpp.service.vo.VppContractResponseVO;
 import com.usky.vpp.util.VppAuditHelper;
@@ -32,13 +34,12 @@ import java.io.UnsupportedEncodingException;
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
 import java.time.LocalDate;
-import java.time.LocalDateTime;
 import java.util.Collections;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
-import java.util.function.Function;
 import java.util.stream.Collectors;
 
 /**
@@ -162,17 +163,35 @@ public class VppContractServiceImpl implements VppContractService {
     @Override
     public CommonPage<VppContractResponseVO> page(Long id, LocalDate signDateStart, LocalDate signDateEnd,
                                                    Long templateId, Integer contractType, Long customerId,
+                                                   String customerName, LocalDate signDateRangeStart, LocalDate signDateRangeEnd,
                                                    Integer contractStatus, Integer pageNum, Integer pageSize) {
         IPage<VppContract> page = new Page<>(pageNum, pageSize);
 
+        // 客户名称模糊匹配:先反查符合条件的客户ID
+        List<Long> customerIdsByName = null;
+        if (StringUtils.isNotBlank(customerName)) {
+            LambdaQueryWrapper<VppCustomer> customerWrapper = new LambdaQueryWrapper<>();
+            customerWrapper.like(VppCustomer::getCustomerName, customerName)
+                    .eq(VppCustomer::getDeleteFlag, 0)
+                    .select(VppCustomer::getId);
+            List<VppCustomer> matchedCustomers = vppCustomerMapper.selectList(customerWrapper);
+            customerIdsByName = matchedCustomers.stream().map(VppCustomer::getId).collect(Collectors.toList());
+            if (customerIdsByName.isEmpty()) {
+                return new CommonPage<>(Collections.emptyList(), 0L, pageSize, pageNum);
+            }
+        }
+
         LambdaQueryWrapper<VppContract> queryWrapper = new LambdaQueryWrapper<>();
         queryWrapper.eq(VppContract::getDeleteFlag, 0)
                 .eq(id != null, VppContract::getId, id)
                 .ge(signDateStart != null, VppContract::getSignDate, signDateStart)
                 .le(signDateEnd != null, VppContract::getSignDate, signDateEnd)
+                .ge(signDateRangeStart != null, VppContract::getSignDate, signDateRangeStart)
+                .le(signDateRangeEnd != null, VppContract::getSignDate, signDateRangeEnd)
                 .eq(templateId != null, VppContract::getTemplateId, templateId)
                 .eq(contractType != null, VppContract::getContractType, contractType)
-                .eq(customerId != null, VppContract::getCustomerId, customerId);
+                .eq(customerId != null, VppContract::getCustomerId, customerId)
+                .in(customerIdsByName != null, VppContract::getCustomerId, customerIdsByName);
         VppContractStatusHelper.applyStatusFilter(queryWrapper, contractStatus);
         queryWrapper.orderByDesc(VppContract::getCreateTime);
         page = vppContractMapper.selectPage(page, queryWrapper);
@@ -244,6 +263,47 @@ public class VppContractServiceImpl implements VppContractService {
         return i > 0;
     }
 
+    @Override
+    public VppContractExpireStatVO expireStat() {
+        VppContractExpireStatVO result = new VppContractExpireStatVO();
+        Integer tenantId = SecurityUtils.getTenantId();
+
+        LambdaQueryWrapper<VppContract> queryWrapper = new LambdaQueryWrapper<>();
+        queryWrapper.eq(VppContract::getDeleteFlag, 0)
+                .eq(tenantId != null, VppContract::getTenantId, tenantId)
+                .isNotNull(VppContract::getCustomerId)
+                .isNotNull(VppContract::getExpireDate)
+                .orderByDesc(VppContract::getCreateTime);
+        List<VppContract> allContracts = vppContractMapper.selectList(queryWrapper);
+        if (allContracts == null || allContracts.isEmpty()) {
+            return result;
+        }
+
+        // 每个客户仅保留创建时间最新的一份合同
+        Map<Long, VppContract> latestByCustomer = new LinkedHashMap<>();
+        for (VppContract contract : allContracts) {
+            latestByCustomer.putIfAbsent(contract.getCustomerId(), contract);
+        }
+
+        LocalDate today = LocalDate.now();
+        LocalDate upcomingDeadline = today.plusMonths(3);
+        long expiredCount = 0L;
+        long upcomingExpireCount = 0L;
+        for (VppContract contract : latestByCustomer.values()) {
+            LocalDate expireDate = contract.getExpireDate();
+            if (today.isAfter(expireDate)) {
+                expiredCount++;
+            } else if (!expireDate.isAfter(upcomingDeadline)) {
+                // today <= expireDate <= today+3个月
+                upcomingExpireCount++;
+            }
+        }
+
+        result.setExpiredCount(expiredCount);
+        result.setUpcomingExpireCount(upcomingExpireCount);
+        return result;
+    }
+
     @Override
     public void download(Long id, HttpServletResponse response) {
         LambdaQueryWrapper<VppContract> queryWrapper = new LambdaQueryWrapper<>();

+ 27 - 53
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppCustomerServiceImpl.java

@@ -678,6 +678,16 @@ public class VppCustomerServiceImpl implements VppCustomerService {
     public Boolean deleteCustomer(Long id) {
         VppCustomer customer = findCustomerById(id);
 
+        // 检查是否存在非草稿状态的合同,存在则不允许删除客户
+        LambdaQueryWrapper<VppContract> contractWrapper = new LambdaQueryWrapper<>();
+        contractWrapper.eq(VppContract::getCustomerId, id)
+                .eq(VppContract::getDeleteFlag, 0)
+                .ne(VppContract::getContractStatus, VppContractStatusHelper.DRAFT);
+        Integer nonDraftCount = contractMapper.selectCount(contractWrapper);
+        if (nonDraftCount != null && nonDraftCount > 0) {
+            throw new BusinessException("该客户下存在非草稿状态的合同,无法删除!");
+        }
+
         VppAuditHelper.fillSoftDelete(customer);
         int result = customerMapper.updateById(customer);
         return result > 0;
@@ -736,23 +746,12 @@ public class VppCustomerServiceImpl implements VppCustomerService {
             customers = Collections.emptyList();
         }
 
-        // 批量查询关联合同
+        // 批量查询关联主联系人
         final Set<Long> customerIds = customers.stream()
                 .map(VppCustomer::getId)
                 .filter(Objects::nonNull)
                 .collect(Collectors.toSet());
-
-        final Map<Long, List<VppContract>> contractMap;
-        if (!customerIds.isEmpty()) {
-            LambdaQueryWrapper<VppContract> contractWrapper = new LambdaQueryWrapper<>();
-            contractWrapper.in(VppContract::getCustomerId, customerIds)
-                    .eq(VppContract::getDeleteFlag, 0);
-            List<VppContract> allContracts = contractMapper.selectList(contractWrapper);
-            contractMap = allContracts.stream()
-                    .collect(Collectors.groupingBy(VppContract::getCustomerId));
-        } else {
-            contractMap = Collections.emptyMap();
-        }
+        final Map<Long, VppCustomerContact> contactMap = loadPrimaryContactMap(customerIds);
 
         // 生成 Excel
         try (org.apache.poi.xssf.usermodel.XSSFWorkbook workbook = new org.apache.poi.xssf.usermodel.XSSFWorkbook()) {
@@ -768,13 +767,8 @@ public class VppCustomerServiceImpl implements VppCustomerService {
 
             // 表头
             String[] headers = {
-                    "序号", "电力户号", "客户名称", "客户类型", "供电公司",
-                    "签约容量(kW)", "信用状态", "签约状态",
-                    "省", "市", "区/县", "地址", "用电地址",
-                    "是否VPP资源", "VPP分类", "需求响应资源分类",
-                    "VPP代理编码", "所属行业", "所属产业", "运行容量(kW)",
-                    "生产经营开始", "生产经营结束", "合同编号列表",
-                    "营业执照", "签约日期", "备注", "创建时间"
+                    "序号", "客户名称", "客户类型", "联系人", "联系电话",
+                    "供电单位", "用电容量", "信用状况", "企业地址", "营业执照"
             };
             org.apache.poi.xssf.usermodel.XSSFRow headerRow = sheet.createRow(0);
             for (int i = 0; i < headers.length; i++) {
@@ -787,50 +781,30 @@ public class VppCustomerServiceImpl implements VppCustomerService {
             int rowIdx = 1;
             for (VppCustomer c : customers) {
                 org.apache.poi.xssf.usermodel.XSSFRow row = sheet.createRow(rowIdx++);
-                List<VppContract> contracts = contractMap.getOrDefault(c.getId(), Collections.emptyList());
-                String contractNos = contracts.stream()
-                        .map(VppContract::getContractNo)
-                        .collect(Collectors.joining("; "));
+                VppCustomerContact contact = contactMap.get(c.getId());
 
                 setCell(row, 0, rowIdx - 1);                       // 序号
-                setCell(row, 1, c.getAccountNo());
-                setCell(row, 2, c.getCustomerName());
-                setCell(row, 3, typeLabel(c.getCustomerType(), "虚拟电厂", "售电客户"));
-                setCell(row, 4, c.getPowerCompany());
-                setCell(row, 5, numberStr(c.getContractCapacity()));
-                setCell(row, 6, typeLabel(c.getCreditStatus(), "正常", "关注", "不良"));
-                setCell(row, 7, typeLabel(c.getLifecycleStatus(), "待签约", "已签约", "履约中", "待续约", "已续约", "已解约"));
-                setCell(row, 8, c.getProvince());
-                setCell(row, 9, c.getCity());
-                setCell(row, 10, c.getDistrict());
-                setCell(row, 11, c.getAddress());
-                setCell(row, 12, c.getPowerAddress());
-                setCell(row, 13, c.getIsVppResource() != null && c.getIsVppResource() == 1 ? "是" : "否");
-                setCell(row, 14, c.getVppCategory());
-                setCell(row, 15, c.getDrResourceType());
-                setCell(row, 16, c.getVppProxyCode());
-                setCell(row, 17, c.getIndustry());
-                setCell(row, 18, c.getIndustrySector());
-                setCell(row, 19, numberStr(c.getRunningCapacity()));
-                setCell(row, 20, timeStr(c.getProductionStartDate()));
-                setCell(row, 21, timeStr(c.getProductionEndDate()));
-                setCell(row, 22, contractNos);
-                insertLicenseImage(workbook, sheet, row, 23, c.getBusinessLicenseUrl());
-                setCell(row, 24, dateStr(c.getSigningDate()));
-                setCell(row, 25, c.getRemark());
-                setCell(row, 26, dateTimeStr(c.getCreateTime()));
+                setCell(row, 1, c.getCustomerName());                // 客户名称
+                setCell(row, 2, typeLabel(c.getCustomerType(), "虚拟电厂", "售电客户")); // 客户类型
+                setCell(row, 3, contact != null ? contact.getContactName() : "");   // 联系人
+                setCell(row, 4, contact != null ? contact.getContactPhone() : "");  // 联系电话
+                setCell(row, 5, c.getPowerCompany());                // 供电单位
+                setCell(row, 6, numberStr(c.getContractCapacity())); // 用电容量
+                setCell(row, 7, typeLabel(c.getCreditStatus(), "正常", "关注", "不良")); // 信用状况
+                setCell(row, 8, c.getAddress());                     // 企业地址
+                insertLicenseImage(workbook, sheet, row, 9, c.getBusinessLicenseUrl()); // 营业执照
             }
 
             // 设置营业执照列宽度(固定宽度以适配图片)
-            sheet.setColumnWidth(23, 28 * 256); // 约 28 字符宽
+            sheet.setColumnWidth(9, 28 * 256); // 约 28 字符宽
 
             // 自动调整列宽
             for (int i = 0; i < headers.length; i++) {
-                if (i == 23) {
+                if (i == 9) {
                     continue; // 营业执照列已手动设置宽度
                 }
                 sheet.autoSizeColumn(i);
-                // 限制最大宽度,避免营业执照URL列过宽
+                // 限制最大宽度
                 int width = sheet.getColumnWidth(i);
                 if (width > 15000) {
                     sheet.setColumnWidth(i, 15000);

+ 12 - 4
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/BiddingConfigRequest.java

@@ -1,11 +1,11 @@
 package com.usky.vpp.service.vo;
 
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.fasterxml.jackson.annotation.JsonFormat;
 import lombok.Data;
 
 import java.math.BigDecimal;
 import java.time.LocalDateTime;
-import java.util.List;
 
 /**
  * 竞价配置创建/更新请求
@@ -23,9 +23,17 @@ public class BiddingConfigRequest {
     /** 1分时段竞价 2整体竞价 */
     private Integer biddingMode;
     private BigDecimal typicalCoefficient;
-    /** 提前截标时间(小时),如 24 表示 >24 小时 */
-    private Integer advanceCloseHours;
+
+    /**
+     * 提前通知最小小时数(下限,不含本值,如 8 表示 >8)
+     */
+    private BigDecimal noticeMinHours;
+
+    /**
+     * 提前通知最大小时数(上限,含本值,如 24 表示 ≤24)
+     */
+    private BigDecimal noticeMaxHours;
+
     private BigDecimal maxPrice;
     private BigDecimal minPrice;
-    private List<Long> siteIds;
 }

+ 12 - 7
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/BiddingConfigVO.java

@@ -1,11 +1,11 @@
 package com.usky.vpp.service.vo;
 
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.fasterxml.jackson.annotation.JsonFormat;
 import lombok.Data;
 
 import java.math.BigDecimal;
 import java.time.LocalDateTime;
-import java.util.List;
 
 /**
  * 竞价配置详情/列表项
@@ -23,16 +23,21 @@ public class BiddingConfigVO {
     private String effectivePeriod;
     private String attachmentUrl;
     private String attachmentName;
+
     private Integer biddingMode;
-    private String biddingModeName;
+    // private String biddingModeName;
     private BigDecimal typicalCoefficient;
-    private Integer advanceCloseHours;
-    /** 提前截标时间展示,如 >24 */
-    private String advanceCloseHoursLabel;
+    /**
+     * 提前通知最小小时数(下限,不含本值,如 8 表示 >8)
+     */
+    private BigDecimal noticeMinHours;
+
+    /**
+     * 提前通知最大小时数(上限,含本值,如 24 表示 ≤24)
+     */
+    private BigDecimal noticeMaxHours;
     private BigDecimal maxPrice;
     private BigDecimal minPrice;
-    private List<Long> siteIds;
-    private List<BiddingConfigSiteVO> sites;
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     private LocalDateTime updateTime;
 }

+ 2 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/SiteDeclaredCapacityVO.java

@@ -22,6 +22,8 @@ public class SiteDeclaredCapacityVO {
     private BigDecimal correctionFactorK;
     /** 响应申报容量 kW(历史均值 × K × 执行时段小时数 / 2) */
     private BigDecimal declaredCapacityKw;
+    /** 预估响应容量 kW(estimated_response_capacity_kw) */
+    private BigDecimal estimatedResponseCapacityKw;
     /** 典型历史日(yyyy-MM-dd) */
     private List<String> referenceDates;
     /** 响应日类型:WORKDAY / NON_WORKDAY */

+ 20 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/VppContractExpireStatVO.java

@@ -0,0 +1,20 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+/**
+ * 合同到期统计:按客户最新合同的到期时间统计已到期 / 即将到期数量
+ */
+@Data
+public class VppContractExpireStatVO {
+
+    /**
+     * 已到期数量
+     */
+    private Long expiredCount = 0L;
+
+    /**
+     * 即将到期数量(近三个月)
+     */
+    private Long upcomingExpireCount = 0L;
+}

+ 1 - 1
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/VppContractResponseVO.java

@@ -53,7 +53,7 @@ public class VppContractResponseVO {
     private String contractName;
 
     /**
-     * 合同状态
+     * 合同状态(0草稿 1审核中 2待生效 3已生效 4已驳回 5已到期 6已终止)
      */
     private Integer contractStatus;
 

+ 13 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppBaselineHelper.java

@@ -297,6 +297,19 @@ public final class VppBaselineHelper {
         return applyDeclaredCapacityFormula(historicalAvg, correctionFactorK, windowStart, windowEnd);
     }
 
+    /**
+     * 预估响应容量 = 响应时段内今日负荷均值。
+     */
+    public static BigDecimal calculateEstimatedResponseCapacityKw(Map<String, BigDecimal> historicalAvgByTime,
+                                                         LocalDateTime windowStart,
+                                                         LocalDateTime windowEnd) {
+        BigDecimal historicalAvg = averageValuesInWindow(historicalAvgByTime, windowStart, windowEnd);
+        if (historicalAvg == null || historicalAvg.compareTo(BigDecimal.ZERO) <= 0) {
+            return BigDecimal.ZERO;
+        }
+        return historicalAvg;
+    }
+
     private static BigDecimal applyDeclaredCapacityFormula(BigDecimal historicalAvg,
                                                            BigDecimal correctionFactorK,
                                                            LocalDateTime windowStart,

+ 11 - 1
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppContractStatusHelper.java

@@ -20,6 +20,8 @@ public final class VppContractStatusHelper {
     public static final int REJECTED = 4;
     public static final int EXPIRED = 5;
     public static final int TERMINATED = 6;
+    /** 查询专用:即将到期(近三个月内过期,非落库状态) */
+    public static final int UPCOMING_EXPIRE = 7;
 
     private static final Set<Integer> MANUAL_STATUSES = new HashSet<>(Arrays.asList(
             DRAFT, AUDITING, REJECTED, TERMINATED
@@ -61,7 +63,8 @@ public final class VppContractStatusHelper {
     }
 
     /**
-     * 分页筛选:将生命周期状态(待生效/已生效/已到期)转换为日期条件。
+     * 分页筛选:将生命周期状态(待生效/已生效/已到期/即将到期)转换为日期条件。
+     * <p>其中 {@link #UPCOMING_EXPIRE} 仅用于查询,不会写入数据库。</p>
      */
     public static void applyStatusFilter(LambdaQueryWrapper<VppContract> wrapper, Integer contractStatus) {
         if (contractStatus == null) {
@@ -87,6 +90,13 @@ public final class VppContractStatusHelper {
                     .lt(VppContract::getExpireDate, today));
             return;
         }
+        if (contractStatus == UPCOMING_EXPIRE) {
+            LocalDate upcomingDeadline = today.plusMonths(3);
+            wrapper.and(w -> w.in(VppContract::getContractStatus, PENDING, EFFECTIVE, EXPIRED)
+                    .ge(VppContract::getExpireDate, today)
+                    .le(VppContract::getExpireDate, upcomingDeadline));
+            return;
+        }
         wrapper.eq(VppContract::getContractStatus, contractStatus);
     }
 }

+ 13 - 10
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppFieldValidator.java

@@ -33,6 +33,9 @@ public final class VppFieldValidator {
 
     private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
 
+    /** JSON 字符串最大长度:64KB,防止大 JSON DoS 攻击 */
+    private static final int JSON_MAX_LENGTH = 65536;
+
     private VppFieldValidator() {
         // 工具类禁止实例化
     }
@@ -60,27 +63,27 @@ public final class VppFieldValidator {
     /**
      * 校验 JSON 格式字符串的合法性:
      * <ul>
-     *   <li>null:视为合法,字段允许为空</li>
-     *   <li>空字符串:不允许,抛出异常</li>
-     *   <li>非空时:必须能解析为合法的 JSON 对象或数组</li>
+     *   <li>null 或空字符串:视为合法,返回 null</li>
+     *   <li>非空时:长度 ≤ 64KB,且必须为合法的 JSON 对象({...})</li>
+     *   <li>数组([...])、原始值(字符串/数字/布尔/null)均不通过</li>
      * </ul>
      *
      * @param json      待校验的 JSON 字符串
      * @param fieldName 字段名称(用于错误提示)
-     * @return 规范化后的值:null → null;合法 JSON → 原值
-     * @throws BusinessException 空字符串或 JSON 格式不合法时抛出
+     * @return 规范化后的值:null → null;合法 JSON 对象 → 原值
+     * @throws BusinessException 超出长度限制或 JSON 格式不合法时抛出
      */
     public static String validateJson(String json, String fieldName) {
-        if (json == null) {
+        if (json == null || json.trim().isEmpty()) {
             return null;
         }
-        if (json.trim().isEmpty()) {
-            throw new BusinessException(fieldName + "不能为空字符串!");
+        if (json.length() > JSON_MAX_LENGTH) {
+            throw new BusinessException(fieldName + "内容过大,最大支持64KB!");
         }
         try {
             JsonNode node = OBJECT_MAPPER.readTree(json);
-            if (!node.isObject() && !node.isArray()) {
-                throw new BusinessException(fieldName + "格式错误:仅支持JSON对象({})或数组([])格式!");
+            if (!node.isObject()) {
+                throw new BusinessException(fieldName + "格式错误:必须是JSON对象({})格式,不允许数组或原始值!");
             }
         } catch (BusinessException e) {
             throw e;

+ 2 - 1
service-vpp/service-vpp-biz/src/main/resources/sql/vpp_schema.sql

@@ -643,7 +643,8 @@ CREATE TABLE `vpp_bidding_config` (
     `attachment_name` VARCHAR(200) NULL COMMENT '附件原始文件名',
     `bidding_mode` TINYINT NOT NULL COMMENT '1分时段竞价 2整体竞价',
     `typical_coefficient` DECIMAL(6,4) NOT NULL COMMENT '典型系数',
-    `advance_close_hours` INT NOT NULL COMMENT '提前截标时间(小时),如24表示>24小时',
+    `notice_min_hours` decimal(5,1) DEFAULT NULL COMMENT '提前通知最小小时数(下限,不含本值,如 8 表示 >8)',
+    `notice_max_hours` decimal(5,1) DEFAULT NULL COMMENT '提前通知最大小时数(上限,含本值,如 24 表示 ≤24)',
     `max_price` DECIMAL(12,4) NOT NULL COMMENT '最高价格(元)',
     `min_price` DECIMAL(12,4) NOT NULL COMMENT '最低价格(元)',
     `site_ids` JSON NULL COMMENT '关联站点ID列表',