Kaynağa Gözat

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

James 1 gün önce
ebeveyn
işleme
14795c7ca7
97 değiştirilmiş dosya ile 6355 ekleme ve 225 silme
  1. 4 2
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/controller/SipPushController.java
  2. 10 1
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/impl/SipRtspPushService.java
  3. 8 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/config/SipClientProperties.java
  4. 1 1
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/config/SipServerProperties.java
  5. 101 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/device/Gb28181CatalogBuilder.java
  6. 162 14
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/device/Gb28181DeviceSimulator.java
  7. 8 1
      service-cdi/service-cdi-biz/src/main/resources/application-gb28181-video.yml
  8. 12 1
      service-vpp/service-vpp-biz/pom.xml
  9. 17 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/config/VppReportProperties.java
  10. 14 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/constant/VppTsdbConstants.java
  11. 85 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/BiddingConfigController.java
  12. 3 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/ContractController.java
  13. 10 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/CustomerController.java
  14. 2 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/DrController.java
  15. 62 29
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/ReportInfoController.java
  16. 10 10
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/ResourceController.java
  17. 56 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/ResponseMonitorController.java
  18. 76 4
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/SettlementController.java
  19. 91 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/SiteController.java
  20. 62 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppBiddingConfig.java
  21. 1 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppContract.java
  22. 2 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppCustomer.java
  23. 6 3
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppCustomerAccess.java
  24. 0 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppDevice.java
  25. 3 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppDrParticipation.java
  26. 11 2
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppEnergyReadingMonthly.java
  27. 2 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppReportTask.java
  28. 20 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppSettlementBill.java
  29. 2 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppSite.java
  30. 10 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/mapper/VppBiddingConfigMapper.java
  31. 35 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppBaselineService.java
  32. 27 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppBiddingConfigService.java
  33. 5 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppCustomerService.java
  34. 2 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppDrService.java
  35. 28 4
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppReportInfoService.java
  36. 19 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppResponseMonitorService.java
  37. 23 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppSettlementService.java
  38. 16 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppTsdbQueryService.java
  39. 4 2
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppArchiveServiceImpl.java
  40. 380 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppBaselineServiceImpl.java
  41. 253 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppBiddingConfigServiceImpl.java
  42. 13 8
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppContractServiceImpl.java
  43. 244 89
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppCustomerServiceImpl.java
  44. 0 3
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDeviceServiceImpl.java
  45. 3 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDrInvitationServiceImpl.java
  46. 98 11
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDrServiceImpl.java
  47. 897 3
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppReportInfoServiceImpl.java
  48. 86 13
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppResourceBoardServiceImpl.java
  49. 116 11
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppResourceOverviewServiceImpl.java
  50. 423 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppResponseMonitorServiceImpl.java
  51. 484 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppSettlementServiceImpl.java
  52. 9 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppSiteServiceImpl.java
  53. 221 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppTsdbQueryServiceImpl.java
  54. 2 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppUnDrSyncServiceImpl.java
  55. 21 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/BaselinePointVO.java
  56. 28 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/BiddingConfigRequest.java
  57. 13 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/BiddingConfigSiteVO.java
  58. 34 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/BiddingConfigVO.java
  59. 10 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/CustomerAccessRequestVO.java
  60. 21 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/CustomerAccessResponseVO.java
  61. 19 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DeclaredCapacityPointVO.java
  62. 27 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DrEventDetailVO.java
  63. 25 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DrParticipationVO.java
  64. 1 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/EnergyTodayVO.java
  65. 16 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportAuditLogVO.java
  66. 11 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportAuditRequest.java
  67. 12 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportDataSaveRequest.java
  68. 19 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportPrefillVO.java
  69. 30 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportRecordVO.java
  70. 27 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportTaskCreateRequest.java
  71. 21 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportTaskCreateResultVO.java
  72. 24 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportTaskVO.java
  73. 14 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportValidateErrorVO.java
  74. 13 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportValidateResultVO.java
  75. 1 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ResourceBoardItemVO.java
  76. 24 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ResponseCompareSeriesVO.java
  77. 18 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ResponseCompareVO.java
  78. 24 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ResponseLoadAnalysisVO.java
  79. 19 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ResponseLoadPointVO.java
  80. 24 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ResponseLoadStatRowVO.java
  81. 20 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ResponseMonitorTreeNodeVO.java
  82. 31 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/SiteBaselineVO.java
  83. 35 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/SiteDeclaredCapacityVO.java
  84. 2 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/SiteListVO.java
  85. 2 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/SiteRequest.java
  86. 518 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppBaselineHelper.java
  87. 56 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppBiddingConfigHelper.java
  88. 92 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppContractStatusHelper.java
  89. 37 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppDrParticipationHelper.java
  90. 320 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppReportHelper.java
  91. 340 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppResponseMonitorHelper.java
  92. 61 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppWorkdayHelper.java
  93. 2 0
      service-vpp/service-vpp-biz/src/main/resources/bootstrap.yml
  94. 35 0
      service-vpp/service-vpp-biz/src/main/resources/sql/vpp_bidding_config_migration.sql
  95. 24 0
      service-vpp/service-vpp-biz/src/main/resources/sql/vpp_dr_participation_completion_rate_migration.sql
  96. 13 0
      service-vpp/service-vpp-biz/src/main/resources/sql/vpp_report_task_site_ids_migration.sql
  97. 32 4
      service-vpp/service-vpp-biz/src/main/resources/sql/vpp_schema.sql

+ 4 - 2
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/controller/SipPushController.java

@@ -24,8 +24,10 @@ public class SipPushController {
 
     /** 手动注册设备到 GB28181 平台(deviceId 动态传入,20 位数字) */
     @PostMapping("/device/register")
-    public Map<String, Object> registerDevice(@RequestParam String deviceId) {
-        return sipRtspPushService.registerDevice(deviceId);
+    public Map<String, Object> registerDevice(
+            @RequestParam String deviceId,
+            @RequestParam(required = false) String channelId) {
+        return sipRtspPushService.registerDevice(deviceId, channelId);
     }
 
     /** 手动注销设备(REGISTER Expires=0) */

+ 10 - 1
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/impl/SipRtspPushService.java

@@ -36,8 +36,17 @@ public class SipRtspPushService {
     }
 
     public Map<String, Object> registerDevice(String deviceId) {
+        return registerDevice(deviceId, null);
+    }
+
+    public Map<String, Object> registerDevice(String deviceId, String channelId) {
         try {
-            return deviceSimulator.register(deviceId);
+            Map<String, Object> status = deviceSimulator.register(deviceId, channelId);
+            if (!Boolean.TRUE.equals(status.get("registered"))) {
+                Object err = status.get("lastRegisterError");
+                throw new BusinessException(err != null ? err.toString() : "GB28181 注册失败,请检查密码与平台设备配置");
+            }
+            return status;
         } catch (IllegalArgumentException | IllegalStateException e) {
             throw new BusinessException(e.getMessage());
         }

+ 8 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/config/SipClientProperties.java

@@ -25,8 +25,16 @@ public class SipClientProperties {
     private String password = "jkjj_wlgz";
     private String sipTransport = "tcp";
     private int registerExpires = 3600;
+    /** 等待 REGISTER 最终响应超时(秒) */
+    private int registerTimeout = 15;
+    /**
+     * REGISTER Request-URI 用户部分:platform=国标默认(平台编码),device=部分平台兼容模式(设备编码)
+     */
+    private String registerUriUser = "platform";
 
     private int inviteWaitSeconds = 300;
     private int defaultDurationSeconds = 60;
     private String rtspTransport = "tcp";
+    /** 通道名称(Catalog 上报),默认 Channel-1 */
+    private String channelName = "Channel-1";
 }

+ 1 - 1
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/config/SipServerProperties.java

@@ -10,7 +10,7 @@ import org.springframework.stereotype.Component;
 public class SipServerProperties {
 
     private boolean enabled = true;
-    private String host = "0.0.0.0";
+    private String host = "192.168.0.100";
     private int port = 5060;
     private String localIp;
     private String platformId = "34020000002000000001";

+ 101 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/device/Gb28181CatalogBuilder.java

@@ -0,0 +1,101 @@
+package com.usky.cdi.service.sip.device;
+
+import org.springframework.util.StringUtils;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * GB28181 MANSCDP+xml 目录应答(Catalog / DeviceInfo)。
+ */
+final class Gb28181CatalogBuilder {
+
+    private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
+
+    private Gb28181CatalogBuilder() {
+    }
+
+    static String buildCatalogResponse(String sn, String deviceId, String channelId, String channelName) {
+        String now = LocalDateTime.now().format(TIME_FMT);
+        String name = StringUtils.hasText(channelName) ? channelName : "Channel-1";
+        return "<?xml version=\"1.0\" encoding=\"GB2312\"?>\r\n"
+                + "<Response>\r\n"
+                + "<CmdType>Catalog</CmdType>\r\n"
+                + "<SN>" + escapeXml(sn) + "</SN>\r\n"
+                + "<DeviceID>" + escapeXml(deviceId) + "</DeviceID>\r\n"
+                + "<SumNum>1</SumNum>\r\n"
+                + "<DeviceList Num=\"1\">\r\n"
+                + "<Item>\r\n"
+                + "<DeviceID>" + escapeXml(channelId) + "</DeviceID>\r\n"
+                + "<Name>" + escapeXml(name) + "</Name>\r\n"
+                + "<Manufacturer>USky</Manufacturer>\r\n"
+                + "<Model>Gb28181Simulator</Model>\r\n"
+                + "<Owner>Owner</Owner>\r\n"
+                + "<CivilCode>" + civilCode(deviceId) + "</CivilCode>\r\n"
+                + "<Address>Simulator</Address>\r\n"
+                + "<Parental>0</Parental>\r\n"
+                + "<ParentID>" + escapeXml(deviceId) + "</ParentID>\r\n"
+                + "<SafetyWay>0</SafetyWay>\r\n"
+                + "<RegisterWay>1</RegisterWay>\r\n"
+                + "<Secrecy>0</Secrecy>\r\n"
+                + "<Status>ON</Status>\r\n"
+                + "<Event>ADD</Event>\r\n"
+                + "<RegisterTime>" + now + "</RegisterTime>\r\n"
+                + "</Item>\r\n"
+                + "</DeviceList>\r\n"
+                + "</Response>\r\n";
+    }
+
+    static String buildDeviceInfoResponse(String sn, String deviceId) {
+        String now = LocalDateTime.now().format(TIME_FMT);
+        return "<?xml version=\"1.0\" encoding=\"GB2312\"?>\r\n"
+                + "<Response>\r\n"
+                + "<CmdType>DeviceInfo</CmdType>\r\n"
+                + "<SN>" + escapeXml(sn) + "</SN>\r\n"
+                + "<DeviceID>" + escapeXml(deviceId) + "</DeviceID>\r\n"
+                + "<Result>OK</Result>\r\n"
+                + "<DeviceName>Gb28181Simulator</DeviceName>\r\n"
+                + "<Manufacturer>USky</Manufacturer>\r\n"
+                + "<Model>Gb28181Simulator</Model>\r\n"
+                + "<Firmware>1.0</Firmware>\r\n"
+                + "<Channel>1</Channel>\r\n"
+                + "<RegisterTime>" + now + "</RegisterTime>\r\n"
+                + "</Response>\r\n";
+    }
+
+    static String extractXmlValue(String xml, String tag) {
+        if (!StringUtils.hasText(xml) || !StringUtils.hasText(tag)) {
+            return null;
+        }
+        String open = "<" + tag + ">";
+        String close = "</" + tag + ">";
+        int start = xml.indexOf(open);
+        if (start < 0) {
+            return null;
+        }
+        start += open.length();
+        int end = xml.indexOf(close, start);
+        if (end < 0) {
+            return null;
+        }
+        return xml.substring(start, end).trim();
+    }
+
+    private static String civilCode(String deviceId) {
+        if (deviceId != null && deviceId.length() >= 6) {
+            return deviceId.substring(0, 6);
+        }
+        return "340200";
+    }
+
+    private static String escapeXml(String value) {
+        if (value == null) {
+            return "";
+        }
+        return value.replace("&", "&amp;")
+                .replace("<", "&lt;")
+                .replace(">", "&gt;")
+                .replace("\"", "&quot;")
+                .replace("'", "&apos;");
+    }
+}

+ 162 - 14
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/device/Gb28181DeviceSimulator.java

@@ -67,6 +67,10 @@ public class Gb28181DeviceSimulator implements SipListener {
     private volatile Future<?> streamingTask;
     private volatile ScheduledFuture<?> reRegisterTask;
     private volatile String activeDeviceId;
+    /** Catalog 上报的通道 ID,IPC 默认与 deviceId 相同 */
+    private volatile String activeChannelId;
+    private volatile CountDownLatch registerLatch;
+    private volatile String lastRegisterError;
 
     public Gb28181DeviceSimulator(SipClientProperties properties) {
         this.properties = properties;
@@ -98,9 +102,21 @@ public class Gb28181DeviceSimulator implements SipListener {
      * @param deviceId 20 位设备编码(动态传入)
      */
     public synchronized Map<String, Object> register(String deviceId) {
+        return register(deviceId, null);
+    }
+
+    /**
+     * 注册到 GB28181 平台。
+     *
+     * @param deviceId  20 位设备编码
+     * @param channelId 可选通道编码;为空时与 deviceId 相同(单路 IPC)
+     */
+    public synchronized Map<String, Object> register(String deviceId, String channelId) {
         validateDeviceId(deviceId);
-        if (registered.get() && deviceId.equals(activeDeviceId)) {
-            log.info("设备已注册: deviceId={}", activeDeviceId);
+        String resolvedChannelId = resolveChannelId(deviceId, channelId);
+        if (registered.get() && deviceId.equals(activeDeviceId)
+                && resolvedChannelId.equals(activeChannelId)) {
+            log.info("设备已注册: deviceId={}, channelId={}", activeDeviceId, activeChannelId);
             return status();
         }
         if (sipStack != null) {
@@ -108,16 +124,22 @@ public class Gb28181DeviceSimulator implements SipListener {
         }
         try {
             activeDeviceId = deviceId.trim();
+            activeChannelId = resolvedChannelId;
             initStack();
             registerAuthSent.set(false);
             authChallenge.set(null);
+            lastRegisterError = null;
+            registerLatch = new CountDownLatch(1);
             sendRegister(false, properties.getRegisterExpires());
             scheduleReRegister();
-            log.info("GB28181 注册请求已发送: deviceId={}, platform={}@{}:{}, transport={}",
-                    activeDeviceId, properties.getPlatformId(),
-                    properties.getServerHost(), properties.getServerPort(), sipTransport);
+            log.info("GB28181 注册请求已发送: deviceId={}, channelId={}, platform={}@{}:{}, transport={}, passwordLen={}",
+                    activeDeviceId, activeChannelId, properties.getPlatformId(),
+                    properties.getServerHost(), properties.getServerPort(), sipTransport,
+                    passwordLength());
+            awaitRegisterResult();
         } catch (Exception e) {
             activeDeviceId = null;
+            activeChannelId = null;
             shutdownStack();
             sipStack = null;
             sipProvider = null;
@@ -149,6 +171,7 @@ public class Gb28181DeviceSimulator implements SipListener {
         }
         registered.set(false);
         activeDeviceId = null;
+        activeChannelId = null;
         boundRtspUrl.set(null);
         pendingStream.set(null);
         shutdownStack();
@@ -197,6 +220,7 @@ public class Gb28181DeviceSimulator implements SipListener {
         Map<String, Object> map = new HashMap<>();
         map.put("registered", registered.get());
         map.put("deviceId", activeDeviceId);
+        map.put("channelId", activeChannelId);
         map.put("platformId", properties.getPlatformId());
         map.put("serverHost", properties.getServerHost());
         map.put("serverPort", properties.getServerPort());
@@ -205,9 +229,44 @@ public class Gb28181DeviceSimulator implements SipListener {
         map.put("localSipAddress", localIp != null ? localIp + ":" + localSipPort : null);
         map.put("boundRtspUrl", boundRtspUrl.get());
         map.put("streaming", streamingTask != null && !streamingTask.isDone());
+        map.put("lastRegisterError", lastRegisterError);
+        map.put("passwordConfigured", StringUtils.hasText(properties.getPassword()));
         return map;
     }
 
+    private void awaitRegisterResult() {
+        CountDownLatch latch = registerLatch;
+        if (latch == null) {
+            return;
+        }
+        try {
+            int timeout = Math.max(5, properties.getRegisterTimeout());
+            if (!latch.await(timeout, TimeUnit.SECONDS)) {
+                lastRegisterError = "注册超时(" + timeout + "s 内未收到平台最终响应)";
+                log.warn(lastRegisterError);
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            lastRegisterError = "注册等待被中断";
+        }
+    }
+
+    private void completeRegisterAttempt(int status, String error) {
+        if (status == Response.OK) {
+            lastRegisterError = null;
+        } else if (StringUtils.hasText(error)) {
+            lastRegisterError = error;
+        }
+        CountDownLatch latch = registerLatch;
+        if (latch != null) {
+            latch.countDown();
+        }
+    }
+
+    private int passwordLength() {
+        return properties.getPassword() != null ? properties.getPassword().length() : 0;
+    }
+
     private void initStack() throws Exception {
         SipFactory sipFactory = SipFactory.getInstance();
         sipFactory.setPathName("gov.nist");
@@ -255,15 +314,15 @@ public class Gb28181DeviceSimulator implements SipListener {
 
     private void sendRegister(boolean withAuth, int expires) throws Exception {
         ensureActiveDeviceId();
-        SipURI requestUri = createUri(activeDeviceId, properties.getDomain());
-        requestUri.setPort(properties.getServerPort());
-        requestUri.setTransportParam(sipTransport);
+        String uriUser = resolveRegisterUriUser();
+        SipURI requestUri = createUri(uriUser, properties.getDomain());
+        String digestUri = "sip:" + uriUser + "@" + properties.getDomain();
 
         Request register = buildRequest(Request.REGISTER, requestUri, activeDeviceId, "reg-tag");
         register.addHeader(headerFactory.createExpiresHeader(expires));
 
         if (withAuth) {
-            AuthorizationHeader auth = buildAuthorization(Request.REGISTER, requestUri.toString());
+            AuthorizationHeader auth = buildAuthorization(Request.REGISTER, digestUri);
             if (auth != null) {
                 register.addHeader(auth);
             }
@@ -271,7 +330,16 @@ public class Gb28181DeviceSimulator implements SipListener {
 
         ClientTransaction ct = sipProvider.getNewClientTransaction(register);
         ct.sendRequest();
-        log.debug("发送 REGISTER{} -> {}:{}", withAuth ? "(鉴权)" : "", properties.getServerHost(), properties.getServerPort());
+        log.info("发送 REGISTER{} uri={} mode={} deviceId={} -> {}:{}",
+                withAuth ? "(鉴权)" : "", digestUri, properties.getRegisterUriUser(), activeDeviceId,
+                properties.getServerHost(), properties.getServerPort());
+    }
+
+    private String resolveRegisterUriUser() {
+        if ("device".equalsIgnoreCase(properties.getRegisterUriUser())) {
+            return activeDeviceId;
+        }
+        return properties.getPlatformId();
     }
 
     private void handleIncomingInvite(RequestEvent event) throws Exception {
@@ -414,6 +482,51 @@ public class Gb28181DeviceSimulator implements SipListener {
         });
     }
 
+    private void handleIncomingMessage(RequestEvent event) throws Exception {
+        Request request = event.getRequest();
+        String body = extractSdp(request);
+        if (!StringUtils.hasText(body)) {
+            sendResponse(event, Response.BAD_REQUEST);
+            return;
+        }
+
+        String cmdType = Gb28181CatalogBuilder.extractXmlValue(body, "CmdType");
+        String sn = Gb28181CatalogBuilder.extractXmlValue(body, "SN");
+        if (!StringUtils.hasText(sn)) {
+            sn = "1";
+        }
+
+        sendResponse(event, Response.OK);
+
+        if ("Catalog".equalsIgnoreCase(cmdType)) {
+            String catalogXml = Gb28181CatalogBuilder.buildCatalogResponse(
+                    sn, activeDeviceId, activeChannelId, properties.getChannelName());
+            sendManscdpMessage(catalogXml);
+            log.info("已应答 Catalog 查询: deviceId={}, channelId={}", activeDeviceId, activeChannelId);
+        } else if ("DeviceInfo".equalsIgnoreCase(cmdType)) {
+            String infoXml = Gb28181CatalogBuilder.buildDeviceInfoResponse(sn, activeDeviceId);
+            sendManscdpMessage(infoXml);
+            log.info("已应答 DeviceInfo 查询: deviceId={}", activeDeviceId);
+        } else {
+            log.debug("忽略未处理的 MESSAGE CmdType={}", cmdType);
+        }
+    }
+
+    private void sendManscdpMessage(String xmlBody) throws Exception {
+        ensureActiveDeviceId();
+        SipURI requestUri = createUri(properties.getPlatformId(), properties.getDomain());
+        requestUri.setPort(properties.getServerPort());
+        requestUri.setTransportParam(sipTransport);
+
+        Request message = buildRequest(Request.MESSAGE, requestUri, activeDeviceId, "msg-tag");
+        message.setContent(xmlBody.getBytes(StandardCharsets.UTF_8),
+                headerFactory.createContentTypeHeader("Application", "MANSCDP+xml"));
+
+        ClientTransaction ct = sipProvider.getNewClientTransaction(message);
+        ct.sendRequest();
+        log.debug("发送 MESSAGE (MANSCDP+xml) -> {}:{}", properties.getServerHost(), properties.getServerPort());
+    }
+
     private void handleIncomingBye(RequestEvent event) throws Exception {
         log.info("收到平台 BYE");
         if (streamingTask != null) {
@@ -467,9 +580,10 @@ public class Gb28181DeviceSimulator implements SipListener {
         }
         String cnonce = UUID.randomUUID().toString().replace("-", "").substring(0, 8);
         String nc = "00000001";
+        String qop = resolveQop(challenge.getQop());
         String response = SipDigestAuth.computeResponse(
                 activeDeviceId, properties.getPassword(), challenge.getRealm(),
-                method, uri, challenge.getNonce(), nc, cnonce, challenge.getQop());
+                method, uri, challenge.getNonce(), nc, cnonce, qop);
 
         AuthorizationHeader auth = headerFactory.createAuthorizationHeader("Digest");
         auth.setUsername(activeDeviceId);
@@ -478,14 +592,22 @@ public class Gb28181DeviceSimulator implements SipListener {
         auth.setURI(addressFactory.createURI(uri));
         auth.setResponse(response);
         auth.setAlgorithm("MD5");
-        if (challenge.getQop() != null && !challenge.getQop().isEmpty()) {
-            auth.setQop(challenge.getQop());
+        if (qop != null && !qop.isEmpty()) {
+            auth.setQop(qop);
             auth.setCNonce(cnonce);
             auth.setNonceCount(Integer.parseInt(nc, 16));
         }
         return auth;
     }
 
+    private static String resolveQop(String qop) {
+        if (qop == null || qop.isEmpty()) {
+            return null;
+        }
+        int comma = qop.indexOf(',');
+        return comma > 0 ? qop.substring(0, comma).trim() : qop.trim();
+    }
+
     private void sendResponse(RequestEvent event, int code) throws Exception {
         Response response = messageFactory.createResponse(code, event.getRequest());
         if (code >= 200) {
@@ -561,6 +683,14 @@ public class Gb28181DeviceSimulator implements SipListener {
         }
     }
 
+    private static String resolveChannelId(String deviceId, String channelId) {
+        if (StringUtils.hasText(channelId)) {
+            validateDeviceId(channelId);
+            return channelId.trim();
+        }
+        return deviceId.trim();
+    }
+
     private void shutdownStack() {
         try {
             if (sipProvider != null) {
@@ -589,6 +719,7 @@ public class Gb28181DeviceSimulator implements SipListener {
                 challenge = (WWWAuthenticateHeader) event.getResponse().getHeader(ProxyAuthenticateHeader.NAME);
             }
             authChallenge.set(challenge);
+            log.info("收到 REGISTER 401 挑战, realm={}", challenge != null ? challenge.getRealm() : "unknown");
             if (!registerAuthSent.getAndSet(true)) {
                 try {
                     ExpiresHeader expires = (ExpiresHeader) ct.getRequest().getHeader(ExpiresHeader.NAME);
@@ -607,9 +738,24 @@ public class Gb28181DeviceSimulator implements SipListener {
                 registered.set(true);
                 log.info("GB28181 注册成功: deviceId={}(平台应可见设备在线)", activeDeviceId);
             }
+            completeRegisterAttempt(status, null);
         } else if (status >= 300) {
             registered.set(false);
-            log.warn("GB28181 注册失败: deviceId={}, status={}", activeDeviceId, status);
+            String error;
+            if (status == Response.FORBIDDEN) {
+                error = "平台拒绝注册(403):密码错误或设备未在平台添加,当前 passwordLen="
+                        + passwordLength() + " registerUriUser=" + properties.getRegisterUriUser();
+                log.warn("GB28181 注册失败 403: deviceId={}, platformId={}, domain={}, passwordLen={}。"
+                        + " 请核对:① sip.client.password 与平台该设备密码一致"
+                        + " ② 平台已添加设备 {} ③ domain/platformId 与海康配置一致"
+                        + " ④ 仍失败可试 sip.client.register-uri-user=device",
+                        activeDeviceId, properties.getPlatformId(), properties.getDomain(),
+                        passwordLength(), activeDeviceId);
+            } else {
+                error = "平台拒绝注册(" + status + ")";
+                log.warn("GB28181 注册失败: deviceId={}, status={}", activeDeviceId, status);
+            }
+            completeRegisterAttempt(status, error);
         }
     }
 
@@ -624,6 +770,8 @@ public class Gb28181DeviceSimulator implements SipListener {
                 handleIncomingAck(event);
             } else if (Request.BYE.equals(method)) {
                 handleIncomingBye(event);
+            } else if (Request.MESSAGE.equals(method)) {
+                handleIncomingMessage(event);
             }
         } catch (Exception e) {
             log.error("处理 {} 失败: {}", method, e.getMessage(), e);

+ 8 - 1
service-cdi/service-cdi-biz/src/main/resources/application-gb28181-video.yml

@@ -8,13 +8,20 @@ sip:
   client:
     enabled: true
     auto-start: false
-    server-host: 192.168.20.166
+    server-host: 114.80.201.142
     server-port: 15060
     domain: 3402000000
     platform-id: 34020000002000000001
+    # 必须与平台侧该设备的 SIP 密码完全一致(Nacos 会覆盖此值)
     password: jkjj_wlgz
     sip-transport: tcp
     register-expires: 3600
+    register-uri-user: platform
     invite-wait-seconds: 300
     default-duration-seconds: 60
     rtsp-transport: tcp
+    local-ip: 192.168.0.100
+    # 注册超时时间,单位秒,建议设置为10~15秒,适配高延迟链路
+    register-timeout: 15
+    # TCP长连接开启保活,避免网络波动导致连接中断
+    tcp-keep-alive: true

+ 12 - 1
service-vpp/service-vpp-biz/pom.xml

@@ -75,9 +75,20 @@
         </dependency>
         <dependency>
             <groupId>cn.hutool</groupId>
-            <artifactId>hutool-crypto</artifactId>
+            <artifactId>hutool-all</artifactId>
+            <version>5.8.22</version>
+        </dependency>
+        <dependency>
+            <groupId>cn.hutool</groupId>
+            <artifactId>hutool-core</artifactId>
             <version>5.8.41</version>
         </dependency>
+        <!-- 中国法定节假日 + 调休(Hutool core 无 ChineseHoliday API) -->
+        <dependency>
+            <groupId>io.github.shawyeok</groupId>
+            <artifactId>chinese-workday-calendar</artifactId>
+            <version>2026.0.0</version>
+        </dependency>
 
         <!-- TDengine JDBC(设备运行曲线) -->
         <dependency>

+ 17 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/config/VppReportProperties.java

@@ -0,0 +1,17 @@
+package com.usky.vpp.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * 监管信息报送配置
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "vpp.report")
+public class VppReportProperties {
+
+    /** 报送失败最大重试次数 */
+    private Integer retryMax = 3;
+}

+ 14 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/constant/VppTsdbConstants.java

@@ -31,5 +31,19 @@ public final class VppTsdbConstants {
             "downCapacityKw"
     );
 
+    /** 资源看板实时指标 */
+    public static final List<String> BOARD_REALTIME_METRICS = Arrays.asList(
+            "device_status",
+            "p",
+            "ua",
+            "ia"
+    );
+
+    /** 日发电量(光伏) */
+    public static final String METRIC_EDAY = "eday";
+
+    /** 正向总有功电能(用电累计) */
+    public static final String METRIC_EPP = "epp";
+
     public static final String TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
 }

+ 85 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/BiddingConfigController.java

@@ -0,0 +1,85 @@
+package com.usky.vpp.controller.web;
+
+import com.usky.common.core.bean.ApiResult;
+import com.usky.common.core.bean.CommonPage;
+import com.usky.vpp.service.VppBiddingConfigService;
+import com.usky.vpp.service.vo.BiddingConfigRequest;
+import com.usky.vpp.service.vo.BiddingConfigVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * 竞价配置接口
+ * 网关前缀: /prod-api/service-vpp/bidding
+ */
+@RestController
+@RequestMapping("/bidding")
+public class BiddingConfigController {
+
+    private static final DateTimeFormatter DATE_TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+    @Autowired
+    private VppBiddingConfigService biddingConfigService;
+
+    /**
+     * 分页查询竞价配置
+     */
+    @GetMapping("/config")
+    public ApiResult<CommonPage<BiddingConfigVO>> page(
+            @RequestParam(value = "configName", required = false) String configName,
+            @RequestParam(value = "effectiveStart", required = false) String effectiveStart,
+            @RequestParam(value = "effectiveEnd", required = false) String effectiveEnd,
+            @RequestParam(value = "current", required = false, defaultValue = "1") Integer current,
+            @RequestParam(value = "size", required = false, defaultValue = "20") Integer size) {
+        return ApiResult.success(biddingConfigService.page(
+                configName,
+                parseDateTime(effectiveStart),
+                parseDateTime(effectiveEnd),
+                current,
+                size));
+    }
+
+    /**
+     * 竞价配置详情
+     */
+    @GetMapping("/config/{id}")
+    public ApiResult<BiddingConfigVO> get(@PathVariable("id") Long id) {
+        return ApiResult.success(biddingConfigService.get(id));
+    }
+
+    /**
+     * 新增竞价配置
+     */
+    @PostMapping("/config")
+    public ApiResult<Long> create(@RequestBody BiddingConfigRequest body) {
+        return ApiResult.success(biddingConfigService.create(body));
+    }
+
+    /**
+     * 更新竞价配置
+     */
+    @PutMapping("/config/{id}")
+    public ApiResult<Void> update(@PathVariable("id") Long id, @RequestBody BiddingConfigRequest body) {
+        biddingConfigService.update(id, body);
+        return ApiResult.success();
+    }
+
+    /**
+     * 删除竞价配置(软删除)
+     */
+    @DeleteMapping("/config/{id}")
+    public ApiResult<Void> delete(@PathVariable("id") Long id) {
+        biddingConfigService.delete(id);
+        return ApiResult.success();
+    }
+
+    private LocalDateTime parseDateTime(String value) {
+        if (!org.springframework.util.StringUtils.hasText(value)) {
+            return null;
+        }
+        return LocalDateTime.parse(value.trim(), DATE_TIME_FMT);
+    }
+}

+ 3 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/ContractController.java

@@ -86,6 +86,7 @@ public class ContractController {
 
     /**
      * 新增合同
+     * <p>合同状态根据生效/到期日期自动计算,无需手动传入生命周期状态。</p>
      */
     @PostMapping
     public ApiResult<Boolean> create(@RequestBody VppContractRequestVO vo) {
@@ -94,6 +95,8 @@ public class ContractController {
 
     /**
      * 修改合同
+     * <p>合同状态根据生效/到期日期自动计算:未到生效时间为待生效,已过生效时间为已生效,已过到期时间为已到期;
+     * 草稿、审核中、已驳回、已终止状态不受日期影响。</p>
      */
     @PutMapping
     public ApiResult<Boolean> update(@RequestBody VppContractRequestVO vo) {

+ 10 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/CustomerController.java

@@ -81,6 +81,16 @@ public class CustomerController {
         return ApiResult.success();
     }
 
+    /**
+     * 修改准入申请(仅已驳回状态可修改,修改后重新进入待审核)
+     */
+    @PutMapping("/access")
+    public ApiResult<Boolean> updateAccess(@RequestBody CustomerAccessRequestVO vo) {
+        return vppCustomerService.updateAccess(vo)
+                ? ApiResult.success(true)
+                : ApiResult.error("修改准入申请失败,请重试!");
+    }
+
     /**
      * 删除准入申请(软删除)
      */

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

@@ -8,6 +8,7 @@ import com.usky.vpp.domain.VppDrStrategy;
 import com.usky.vpp.service.VppDrInvitationService;
 import com.usky.vpp.service.VppDrService;
 import com.usky.vpp.service.vo.DrClearingRequest;
+import com.usky.vpp.service.vo.DrEventDetailVO;
 import com.usky.vpp.service.vo.DrEventRequest;
 import com.usky.vpp.service.vo.DrInterveneRequest;
 import com.usky.vpp.service.vo.DrInvitationNotifyRequest;
@@ -41,7 +42,7 @@ public class DrController {
     }
 
     @GetMapping(value = "/event/{id}")
-    public ApiResult<VppDrEvent> getEvent(@PathVariable("id") Long id) {
+    public ApiResult<DrEventDetailVO> getEvent(@PathVariable("id") Long id) {
         return ApiResult.success(vppDrService.getEvent(id));
     }
 

+ 62 - 29
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/ReportInfoController.java

@@ -1,13 +1,31 @@
 package com.usky.vpp.controller.web;
 
 import com.usky.common.core.bean.ApiResult;
+import com.usky.common.core.bean.CommonPage;
 import com.usky.vpp.service.VppReportInfoService;
+import com.usky.vpp.service.vo.ReportAuditRequest;
+import com.usky.vpp.service.vo.ReportDataSaveRequest;
+import com.usky.vpp.service.vo.ReportPrefillVO;
+import com.usky.vpp.service.vo.ReportRecordVO;
+import com.usky.vpp.service.vo.ReportTaskCreateRequest;
+import com.usky.vpp.service.vo.ReportTaskCreateResultVO;
+import com.usky.vpp.service.vo.ReportTaskVO;
+import com.usky.vpp.service.vo.ReportValidateResultVO;
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.web.bind.annotation.*;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
 
 /**
- * 虚拟电厂 - ReportInfo 接口
- * 网关前缀: /prod-api/service-vpp
+ * 监管信息报送接口
+ * <p>网关前缀: /prod-api/service-vpp/report</p>
  */
 @RestController
 @RequestMapping("/report")
@@ -16,40 +34,55 @@ public class ReportInfoController {
     @Autowired
     private VppReportInfoService vppReportInfoService;
 
-    @GetMapping(value = "/task")
-    public ApiResult<Object> pageTask(@RequestParam(required = false) java.util.Map<String, Object> params) {
-        return ApiResult.success(null);
+    @GetMapping("/task")
+    public ApiResult<CommonPage<ReportTaskVO>> pageTask(@RequestParam(required = false) Map<String, Object> params) {
+        return ApiResult.success(vppReportInfoService.pageTask(params));
     }
-    @GetMapping(value = "/record/{id}")
-    public ApiResult<Object> getRecord(@PathVariable("id") Long id, @RequestParam(required = false) java.util.Map<String, Object> params) {
-        return ApiResult.success(null);
+
+    @PostMapping("/task")
+    public ApiResult<ReportTaskCreateResultVO> createTask(@RequestBody ReportTaskCreateRequest body) {
+        return ApiResult.success(vppReportInfoService.createTask(body));
     }
-    @GetMapping(value = "/record/{id}/prefill")
-    public ApiResult<Object> prefill(@PathVariable("id") Long id, @RequestParam(required = false) java.util.Map<String, Object> params) {
-        return ApiResult.success(null);
+
+    @GetMapping("/record/{id}")
+    public ApiResult<ReportRecordVO> getRecord(@PathVariable("id") Long id) {
+        return ApiResult.success(vppReportInfoService.getRecord(id));
     }
-    @PostMapping(value = "/record/{id}/data")
-    public ApiResult<Void> saveData(@PathVariable("id") Long id, @RequestBody(required = false) Object body) {
-        return ApiResult.success();
+
+    @GetMapping("/record/{id}/prefill")
+    public ApiResult<ReportPrefillVO> prefill(@PathVariable("id") Long id) {
+        return ApiResult.success(vppReportInfoService.prefill(id));
     }
-    @PostMapping(value = "/record/{id}/validate")
-    public ApiResult<Object> validateData(@PathVariable("id") Long id, @RequestBody(required = false) Object body) {
-        return ApiResult.success(null);
+
+    @PutMapping("/record/{id}/data")
+    public ApiResult<Map<String, Object>> saveData(@PathVariable("id") Long id,
+                                                   @RequestBody ReportDataSaveRequest body) {
+        return ApiResult.success(vppReportInfoService.saveData(id, body));
     }
-    @PostMapping(value = "/record/{id}/submit")
-    public ApiResult<Void> submitAudit(@PathVariable("id") Long id, @RequestBody(required = false) Object body) {
-        return ApiResult.success();
+
+    @PostMapping("/record/{id}/validate")
+    public ApiResult<ReportValidateResultVO> validateData(@PathVariable("id") Long id) {
+        return ApiResult.success(vppReportInfoService.validateData(id));
     }
-    @PutMapping(value = "/record/{id}/audit")
-    public ApiResult<Void> audit(@PathVariable("id") Long id, @RequestBody(required = false) Object body) {
-        return ApiResult.success();
+
+    @PostMapping("/record/{id}/submitAudit")
+    public ApiResult<Map<String, Object>> submitAudit(@PathVariable("id") Long id) {
+        return ApiResult.success(vppReportInfoService.submitAudit(id));
     }
-    @PostMapping(value = "/record/{id}/submit-external")
-    public ApiResult<Void> submitExternal(@PathVariable("id") Long id, @RequestBody(required = false) Object body) {
+
+    @PutMapping("/record/{id}/audit")
+    public ApiResult<Void> audit(@PathVariable("id") Long id, @RequestBody ReportAuditRequest body) {
+        vppReportInfoService.audit(id, body);
         return ApiResult.success();
     }
-    @GetMapping(value = "/history")
-    public ApiResult<Object> history(@RequestParam(required = false) java.util.Map<String, Object> params) {
-        return ApiResult.success(null);
+
+    @PostMapping("/record/{id}/submit")
+    public ApiResult<ReportRecordVO> submit(@PathVariable("id") Long id) {
+        return ApiResult.success(vppReportInfoService.submit(id));
+    }
+
+    @GetMapping("/history")
+    public ApiResult<CommonPage<ReportRecordVO>> history(@RequestParam(required = false) Map<String, Object> params) {
+        return ApiResult.success(vppReportInfoService.pageHistory(params));
     }
 }

+ 10 - 10
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/ResourceController.java

@@ -35,7 +35,7 @@ public class ResourceController {
     private VppResourceOverviewService vppResourceOverviewService;
 
     /**
-     * 资源看板 - 实时运行数据列表(模拟数据)
+     * 资源看板 - 实时运行数据列表
      * <p>展示所有资源点的实时功率、电压、电流、储能SOC、充电桩充电功率;
      * 支持按 resourceType(资源类型)、province/city/district(区域)、status(normal/abnormal)筛选;
      * abnormal=true 的资源需前端红色高亮。</p>
@@ -66,21 +66,21 @@ public class ResourceController {
 
     /**
      * 资源概览 - 今日电能概况
-     * <p>总发电量、总用电量、绿电消纳比例(模拟数据)。</p>
+     * <p>总发电量、总用电量、绿电消纳比例(TSDB 今日 eday/epp 汇总)。</p>
      */
     @GetMapping("/overview/energy-today")
     public ApiResult<EnergyTodayVO> resourceOverviewEnergyToday() {
         return ApiResult.success(vppResourceOverviewService.getEnergyToday());
     }
 
-    /**
-     * 资源概览 - 实时告警统计
-     * <p>按紧急/重要/一般统计告警数量,含跳转告警列表路径(模拟数据)。</p>
-     */
-    @GetMapping("/overview/alarm-stats")
-    public ApiResult<List<AlarmLevelStatVO>> resourceOverviewAlarmStats() {
-        return ApiResult.success(vppResourceOverviewService.getAlarmStats());
-    }
+//    /**
+//     * 资源概览 - 实时告警统计
+//     * <p>按紧急/重要/一般统计告警数量,含跳转告警列表路径(模拟数据)。</p>
+//     */
+//    @GetMapping("/overview/alarm-stats")
+//    public ApiResult<List<AlarmLevelStatVO>> resourceOverviewAlarmStats() {
+//        return ApiResult.success(vppResourceOverviewService.getAlarmStats());
+//    }
 
     /**
      * 资源点管理 - 分页列表

+ 56 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/ResponseMonitorController.java

@@ -0,0 +1,56 @@
+package com.usky.vpp.controller.web;
+
+import com.usky.common.core.bean.ApiResult;
+import com.usky.vpp.service.VppResponseMonitorService;
+import com.usky.vpp.service.vo.ResponseCompareVO;
+import com.usky.vpp.service.vo.ResponseLoadAnalysisVO;
+import com.usky.vpp.service.vo.ResponseMonitorTreeNodeVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 响应监控接口(虚拟电厂驾驶舱)
+ * <p>网关前缀: /prod-api/service-vpp/response-monitor</p>
+ */
+@RestController
+@RequestMapping("/response-monitor")
+public class ResponseMonitorController {
+
+    @Autowired
+    private VppResponseMonitorService responseMonitorService;
+
+    /**
+     * 左侧资源树(客户 → 站点)
+     */
+    @GetMapping("/tree")
+    public ApiResult<List<ResponseMonitorTreeNodeVO>> tree(
+            @RequestParam(value = "keyword", required = false) String keyword) {
+        return ApiResult.success(responseMonitorService.getResourceTree(keyword));
+    }
+
+    /**
+     * 负荷分析:四曲线 + 用电量统计表
+     */
+    @GetMapping("/load-analysis")
+    public ApiResult<ResponseLoadAnalysisVO> loadAnalysis(
+            @RequestParam("siteId") Long siteId,
+            @RequestParam(value = "date", required = false) String date,
+            @RequestParam(value = "eventId", required = false) Long eventId) {
+        return ApiResult.success(responseMonitorService.getLoadAnalysis(siteId, date, eventId));
+    }
+
+    /**
+     * 对比分析:多站点实际负荷曲线对比
+     */
+    @GetMapping("/compare")
+    public ApiResult<ResponseCompareVO> compare(
+            @RequestParam("siteIds") String siteIds,
+            @RequestParam(value = "date", required = false) String date) {
+        return ApiResult.success(responseMonitorService.compare(siteIds, date));
+    }
+}

+ 76 - 4
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/SettlementController.java

@@ -1,10 +1,15 @@
 package com.usky.vpp.controller.web;
 
 import com.usky.common.core.bean.ApiResult;
+import com.usky.common.core.bean.CommonPage;
+import com.usky.vpp.domain.VppEnergyReadingMonthly;
+import com.usky.vpp.domain.VppSettlementBill;
 import com.usky.vpp.service.VppSettlementService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
 
+import javax.servlet.http.HttpServletResponse;
+
 /**
  * 虚拟电厂 - Settlement 接口
  * 网关前缀: /prod-api/service-vpp
@@ -16,34 +21,101 @@ public class SettlementController {
     @Autowired
     private VppSettlementService vppSettlementService;
 
+    /**
+     * 分页查询powermeter数据
+     * <ul>
+     *   <li>id           主键ID 精确匹配</li>
+     *   <li>customerName 客户名称 模糊匹配</li>
+     *   <li>calcCycle    计算周期 模糊匹配</li>
+     *   <li>current      页码</li>
+     *   <li>size         页大小</li>
+     * </ul>
+     */
     @GetMapping(value = "/reading")
-    public ApiResult<Object> pageReading(@RequestParam(required = false) java.util.Map<String, Object> params) {
-        return ApiResult.success(null);
+    public ApiResult<CommonPage<VppEnergyReadingMonthly>> pageReading(
+            @RequestParam(value = "id", required = false) Long id,
+            @RequestParam(value = "customerName", required = false) String customerName,
+            @RequestParam(value = "calcCycle", required = false) String calcCycle,
+            @RequestParam(value = "current", required = false, defaultValue = "1") Integer current,
+            @RequestParam(value = "size", required = false, defaultValue = "20") Integer size) {
+        return ApiResult.success(vppSettlementService.pageReading(id, customerName, calcCycle, current, size));
+    }
+
+    /**
+     * 导出月度电量核算数据(Excel)
+     * <ul>
+     *   <li>scope = "current":导出当前页,需传 current + size</li>
+     *   <li>scope = "all":导出当前租户全部未删除月度电量核算记录</li>
+     *   <li>scope = "filtered":导出筛选结果,需至少传一个筛选条件</li>
+     * </ul>
+     */
+    @GetMapping(value = "/reading/export")
+    public void exportReading(
+            @RequestParam("scope") String scope,
+            @RequestParam("fileName") String fileName,
+            @RequestParam(value = "id", required = false) Long id,
+            @RequestParam(value = "customerName", required = false) String customerName,
+            @RequestParam(value = "calcCycle", required = false) String calcCycle,
+            @RequestParam(value = "current", required = false) Integer current,
+            @RequestParam(value = "size", required = false) Integer size,
+            HttpServletResponse response) {
+        vppSettlementService.exportReading(scope, fileName, id, customerName, calcCycle, current, size, response);
     }
+
     @PostMapping(value = "/reading/calculate")
     public ApiResult<Void> calculateReading(@RequestBody(required = false) Object body) {
         return ApiResult.success();
     }
+
     @GetMapping(value = "/bill")
-    public ApiResult<Object> pageBill(@RequestParam(required = false) java.util.Map<String, Object> params) {
-        return ApiResult.success(null);
+    public ApiResult<CommonPage<VppSettlementBill>> pageBill(
+            @RequestParam(value = "id", required = false) Long id,
+            @RequestParam(value = "customerName", required = false) String customerName,
+            @RequestParam(value = "current", required = false, defaultValue = "1") Integer current,
+            @RequestParam(value = "size", required = false, defaultValue = "20") Integer size) {
+        return ApiResult.success(vppSettlementService.pageBill(id, customerName, current, size));
     }
+
+    /**
+     * 导出电费账单数据(Excel)
+     * <ul>
+     *   <li>scope = "current":导出当前页,需传 current + size</li>
+     *   <li>scope = "all":导出当前租户全部未删除电费账单</li>
+     *   <li>scope = "filtered":导出筛选结果,需至少传一个筛选条件</li>
+     * </ul>
+     */
+    @GetMapping(value = "/bill/export")
+    public void exportBill(
+            @RequestParam("scope") String scope,
+            @RequestParam("fileName") String fileName,
+            @RequestParam(value = "id", required = false) Long id,
+            @RequestParam(value = "customerName", required = false) String customerName,
+            @RequestParam(value = "current", required = false) Integer current,
+            @RequestParam(value = "size", required = false) Integer size,
+            HttpServletResponse response) {
+        vppSettlementService.exportBill(scope, fileName, id, customerName, current, size, response);
+    }
+
     @PostMapping(value = "/bill")
     public ApiResult<Object> generateBill(@RequestBody(required = false) Object body) {
         return ApiResult.success(null);
     }
+
     @GetMapping(value = "/bill/{id}")
     public ApiResult<Object> getBill(@PathVariable("id") Long id, @RequestParam(required = false) java.util.Map<String, Object> params) {
         return ApiResult.success(null);
     }
+
     @GetMapping(value = "/bill/{id}/download")
     public ApiResult<Object> downloadBill(@PathVariable("id") Long id) {
         return ApiResult.success(null);
     }
+
     @GetMapping(value = "/payment")
     public ApiResult<Object> pagePayment(@RequestParam(required = false) java.util.Map<String, Object> params) {
         return ApiResult.success(null);
     }
+
     @PostMapping(value = "/payment")
     public ApiResult<Object> recordPayment(@RequestBody(required = false) Object body) {
         return ApiResult.success(null);

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

@@ -5,11 +5,14 @@ import com.usky.common.core.bean.CommonPage;
 import com.usky.vpp.domain.VppDevice;
 import com.usky.vpp.domain.VppResourcePoint;
 import com.usky.vpp.domain.VppSite;
+import com.usky.vpp.service.VppBaselineService;
 import com.usky.vpp.service.VppDeviceService;
 import com.usky.vpp.service.VppResourcePointService;
 import com.usky.vpp.service.VppSiteService;
 import com.usky.vpp.service.vo.DeviceRequest;
 import com.usky.vpp.service.vo.ResourcePointRequest;
+import com.usky.vpp.service.vo.SiteBaselineVO;
+import com.usky.vpp.service.vo.SiteDeclaredCapacityVO;
 import com.usky.vpp.service.vo.SiteConfigDetailVO;
 import com.usky.vpp.service.vo.SiteConfigRequest;
 import com.usky.vpp.service.vo.SiteListVO;
@@ -30,6 +33,8 @@ public class SiteController {
     @Autowired
     private VppSiteService vppSiteService;
     @Autowired
+    private VppBaselineService vppBaselineService;
+    @Autowired
     private VppResourcePointService vppResourcePointService;
     @Autowired
     private VppDeviceService vppDeviceService;
@@ -114,6 +119,92 @@ public class SiteController {
         return ApiResult.success(vppDeviceService.createDevice(siteId, body));
     }
 
+    /**
+     * 基线管理 - 负荷曲线
+     * <p><b>GET</b> /site/{siteId}/baseline</p>
+     * <p>完整路径:GET /prod-api/service-vpp/site/{siteId}/baseline</p>
+     *
+     * <p>根据站点 ID 与响应开始时间,返回需求响应基线管理页面所需的三条负荷曲线:</p>
+     * <ul>
+     *   <li>预测基线(黄色):典型历史日同时段各时间点负荷均值 × 修正系数 K</li>
+     *   <li>今日实际(绿色):响应日当天 00:00 至响应开始时间的实测负荷</li>
+     *   <li>昨日实际(蓝色):响应日前一日全天实测负荷</li>
+     * </ul>
+     *
+     * <p><b>路径参数</b></p>
+     * <ul>
+     *   <li>siteId — 站点 ID</li>
+     * </ul>
+     *
+     * <p><b>Query 参数</b></p>
+     * <ul>
+     *   <li>responseStartTime — 响应开始时间,必填,格式 yyyy-MM-dd HH:mm:ss</li>
+     * </ul>
+     *
+     * <p><b>典型历史日选取规则</b>(基于 Hutool 法定节假日 + 调休判定)</p>
+     * <ul>
+     *   <li>响应日为工作日:取邀约日前最近 5 个工作日;剔除响应当日、历史响应日(vpp_dr_event 已结束)</li>
+     *   <li>响应日为非工作日:取邀约日前最近 3 个非工作日(含法定节假日);剔除响应当日、历史响应日</li>
+     * </ul>
+     *
+     * <p><b>修正系数 K</b>:取响应开始时间前连续 2 小时今日实测与同时段原始基线之比,限制在 0.7 ~ 1.2。</p>
+     * <p>原始基线在本模块内对多历史日同时段各时间点(默认 10 分钟粒度)求平均,
+     * 逐日调用 service-tsdb {@code queryHistoryDeviceData} 获取设备时序后聚合;
+     * 设备范围为站点下 is_control=1 的资源点关联设备。</p>
+     *
+     * <p><b>响应 data:SiteBaselineVO</b></p>
+     * <ul>
+     *   <li>siteId / siteName — 站点信息</li>
+     *   <li>responseStartTime — 响应开始时间</li>
+     *   <li>responseDayType — WORKDAY(工作日)/ NON_WORKDAY(非工作日)</li>
+     *   <li>responseDayTypeLabel — 工作日 / 非工作日</li>
+     *   <li>referenceDates — 参与计算的典型历史日(yyyy-MM-dd)</li>
+     *   <li>correctionFactorK — 日内修正系数 K</li>
+     *   <li>dataSource — tsdb(时序库)/ mock(联调模拟)</li>
+     *   <li>points — 曲线点列表(BaselinePointVO:time、predictedBaselineKw、todayActualKw、yesterdayActualKw)</li>
+     * </ul>
+     *
+     * <p><b>示例</b>:GET /site/1/baseline?responseStartTime=2026-07-08 19:00:00</p>
+     */
+    @GetMapping("/{siteId}/baseline")
+    public ApiResult<SiteBaselineVO> getSiteBaseline(@PathVariable("siteId") Long siteId,
+                                                     @RequestParam("responseStartTime") String responseStartTime) {
+        return ApiResult.success(vppBaselineService.getSiteBaseline(siteId, responseStartTime));
+    }
+
+    /**
+     * 响应申报容量
+     * <p><b>GET</b> /site/{siteId}/declared-capacity</p>
+     * <p>完整路径:GET /prod-api/service-vpp/site/{siteId}/declared-capacity</p>
+     *
+     * <p>根据站点 ID、响应开始/结束时间,计算需求响应申报容量:</p>
+     * <p><b>申报容量 = 响应时段内同时间段历史负荷均值 × 修正系数 K × 执行时段小时数 / 2</b></p>
+     *
+     * <p><b>Query 参数</b></p>
+     * <ul>
+     *   <li>responseStartTime — 响应开始时间,必填,格式 yyyy-MM-dd HH:mm:ss</li>
+     *   <li>responseEndTime — 响应结束时间,必填,格式 yyyy-MM-dd HH:mm:ss(需与开始时间同一天)</li>
+     * </ul>
+     *
+     * <p>典型历史日、修正系数 K 规则与基线管理接口一致;历史均值仅统计响应开始至结束时段。</p>
+     *
+     * <p><b>响应 data:SiteDeclaredCapacityVO</b></p>
+     * <ul>
+     *   <li>declaredCapacityKw — 申报容量 kW</li>
+     *   <li>correctionFactorK — 修正系数 K</li>
+     *   <li>referenceDates — 典型历史日</li>
+     *   <li>points — 响应时段各时间点申报容量(DeclaredCapacityPointVO)</li>
+     * </ul>
+     *
+     * <p><b>示例</b>:GET /site/1/declared-capacity?responseStartTime=2026-07-08 19:00:00&responseEndTime=2026-07-08 21:00:00</p>
+     */
+    @GetMapping("/{siteId}/declared-capacity")
+    public ApiResult<SiteDeclaredCapacityVO> getSiteDeclaredCapacity(@PathVariable("siteId") Long siteId,
+                                                                   @RequestParam("responseStartTime") String responseStartTime,
+                                                                   @RequestParam("responseEndTime") String responseEndTime) {
+        return ApiResult.success(vppBaselineService.getSiteDeclaredCapacity(siteId, responseStartTime, responseEndTime));
+    }
+
     /**
      * 站点下资源点列表
      */

+ 62 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppBiddingConfig.java

@@ -0,0 +1,62 @@
+package com.usky.vpp.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+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;
+
+/**
+ * vpp_bidding_config 竞价配置
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+@TableName("vpp_bidding_config")
+public class VppBiddingConfig implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+    @TableField("config_name")
+    private String configName;
+    @TableField("effective_start")
+    private LocalDateTime effectiveStart;
+    @TableField("effective_end")
+    private LocalDateTime effectiveEnd;
+    @TableField("attachment_url")
+    private String attachmentUrl;
+    @TableField("attachment_name")
+    private String attachmentName;
+    @TableField("bidding_mode")
+    private Integer biddingMode;
+    @TableField("typical_coefficient")
+    private BigDecimal typicalCoefficient;
+    @TableField("advance_close_hours")
+    private Integer advanceCloseHours;
+    @TableField("max_price")
+    private BigDecimal maxPrice;
+    @TableField("min_price")
+    private BigDecimal minPrice;
+    @TableField("site_ids")
+    private String siteIds;
+    @TableField("tenant_id")
+    private Integer tenantId;
+    @TableField("create_time")
+    private LocalDateTime createTime;
+    @TableField("update_time")
+    private LocalDateTime updateTime;
+    @TableField("created_by")
+    private String createdBy;
+    @TableField("updated_by")
+    private String updatedBy;
+    @TableField("delete_flag")
+    private Integer deleteFlag;
+    @TableField("deleted_at")
+    private LocalDateTime deletedAt;
+}

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

@@ -52,7 +52,7 @@ public class VppContract implements Serializable {
     private String contractName;
 
     /**
-     * 0草稿 1审核中 2已生效 3已到期 4已终止
+     * 0草稿 1审核中 2待生效 3已生效 4已驳回 5已到期 6已终止
      */
     @NotNull(message = "合同状态不能为空!")
     @TableField("contract_status")

+ 2 - 1
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppCustomer.java

@@ -32,7 +32,7 @@ public class VppCustomer implements Serializable {
     private String customerName;
 
     /**
-     * 1中高压 2低压商用 3居民充电桩 4自有资产
+     * 客户管理1.虚拟电厂2.售电客户
      */
     @TableField("customer_type")
     private Integer customerType;
@@ -104,6 +104,7 @@ public class VppCustomer implements Serializable {
     @TableField("delete_flag")
     private Integer deleteFlag;
     @TableField("deleted_at")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     private LocalDateTime deletedAt;
 
     /**

+ 6 - 3
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppCustomerAccess.java

@@ -49,9 +49,6 @@ public class VppCustomerAccess implements Serializable {
     @TableField("customer_id")
     private Long customerId;
 
-    @TableField(exist = false)
-    private LocalDateTime signingDate;
-
     @TableField(exist = false)
     private String contactName;
 
@@ -61,6 +58,12 @@ public class VppCustomerAccess implements Serializable {
     @TableField(exist = false)
     private String contactEmail;
 
+    @TableField(exist = false)
+    private String address;
+
+    @TableField(exist = false)
+    private LocalDateTime signingDate;
+
     @TableField("apply_at")
     private LocalDateTime applyAt;
     @TableField("tenant_id")

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

@@ -28,7 +28,6 @@ public class VppDevice implements Serializable {
     private String deviceUuid;
     @TableField("device_name")
     private String deviceName;
-    /** 所属站点ID,关�vpp_site.id */
     @TableField("site_id")
     private Long siteId;
     @TableField("device_type")

+ 3 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppDrParticipation.java

@@ -34,6 +34,9 @@ public class VppDrParticipation implements Serializable {
     private BigDecimal declaredCapacityKw;
     @TableField("cleared_capacity_kw")
     private BigDecimal clearedCapacityKw;
+    /** 完成率 = 出清容量 / 申报容量 */
+    @TableField("completion_rate")
+    private BigDecimal completionRate;
     @TableField("declared_at")
     private LocalDateTime declaredAt;
     @TableField("tenant_id")

+ 11 - 2
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppEnergyReadingMonthly.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;
@@ -24,8 +25,16 @@ public class VppEnergyReadingMonthly implements Serializable {
     private Long id;
     @TableField("customer_id")
     private Long customerId;
-    @TableField("resource_id")
-    private Long resourceId;
+
+    @TableField(exist = false)
+    private String customerName;
+
+    @TableField("site_id")
+    private Long siteId;
+
+    @TableField(exist = false)
+    private String siteName;
+
     @TableField("settle_year")
     private String settleYear;
     @TableField("settle_month")

+ 2 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppReportTask.java

@@ -38,6 +38,8 @@ public class VppReportTask implements Serializable {
     private Long assigneeId;
     @TableField("remind_days")
     private Integer remindDays;
+    @TableField("site_ids")
+    private String siteIds;
     @TableField("tenant_id")
     private Integer tenantId;
     @TableField("create_time")

+ 20 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppSettlementBill.java

@@ -27,16 +27,36 @@ public class VppSettlementBill implements Serializable {
     private String billNo;
     @TableField("customer_id")
     private Long customerId;
+
+    @TableField(exist = false)
+    private String customerName;
+
+    @TableField("site_id")
+    private Long siteId;
+
+    @TableField(exist = false)
+    private String siteName;
+
     @TableField("settle_year")
     private String settleYear;
     @TableField("settle_month")
     private Integer settleMonth;
     @TableField("total_energy_kwh")
     private BigDecimal totalEnergyKwh;
+    @TableField(exist = false)
+    private BigDecimal contractPrice;
+
     @TableField("total_amount")
     private BigDecimal totalAmount;
+
+    /**
+     * 已付金额,默认0
+     */
     @TableField("paid_amount")
     private BigDecimal paidAmount;
+    /**
+     * 0未缴 1部分 2已缴 3逾期
+     */
     @TableField("payment_status")
     private Integer paymentStatus;
     @TableField("due_date")

+ 2 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppSite.java

@@ -60,4 +60,6 @@ public class VppSite implements Serializable {
     private Integer deleteFlag;
     @TableField("deleted_at")
     private LocalDateTime deletedAt;
+    private String accountNo;
+    private String streetTown;
 }

+ 10 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/mapper/VppBiddingConfigMapper.java

@@ -0,0 +1,10 @@
+package com.usky.vpp.mapper;
+
+import com.usky.common.mybatis.core.CrudMapper;
+import com.usky.vpp.domain.VppBiddingConfig;
+
+/**
+ * vpp_bidding_config Mapper
+ */
+public interface VppBiddingConfigMapper extends CrudMapper<VppBiddingConfig> {
+}

+ 35 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppBaselineService.java

@@ -0,0 +1,35 @@
+package com.usky.vpp.service;
+
+import com.usky.vpp.service.vo.SiteBaselineVO;
+import com.usky.vpp.service.vo.SiteDeclaredCapacityVO;
+
+/**
+ * 站点基线管理
+ */
+public interface VppBaselineService {
+
+    /**
+     * 查询站点基线负荷曲线(预测基线 / 今日实际 / 昨日实际)。
+     *
+     * <p>详见 {@link com.usky.vpp.service.impl.VppBaselineServiceImpl#getSiteBaseline(Long, String)} 实现说明。</p>
+     *
+     * @param siteId            站点 ID
+     * @param responseStartTime 响应开始时间,格式 yyyy-MM-dd HH:mm:ss
+     * @return 基线曲线及修正系数等信息
+     */
+    SiteBaselineVO getSiteBaseline(Long siteId, String responseStartTime);
+
+    /**
+     * 查询站点响应申报容量。
+     *
+     * <p>响应时段内同时间段历史负荷均值 × 修正系数 K × 执行时段小时数 / 2。</p>
+     *
+     * @param siteId            站点 ID
+     * @param responseStartTime 响应开始时间,格式 yyyy-MM-dd HH:mm:ss
+     * @param responseEndTime   响应结束时间,格式 yyyy-MM-dd HH:mm:ss
+     * @return 申报容量及响应时段明细
+     */
+    SiteDeclaredCapacityVO getSiteDeclaredCapacity(Long siteId,
+                                                   String responseStartTime,
+                                                   String responseEndTime);
+}

+ 27 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppBiddingConfigService.java

@@ -0,0 +1,27 @@
+package com.usky.vpp.service;
+
+import com.usky.common.core.bean.CommonPage;
+import com.usky.vpp.service.vo.BiddingConfigRequest;
+import com.usky.vpp.service.vo.BiddingConfigVO;
+
+import java.time.LocalDateTime;
+
+/**
+ * 竞价配置服务
+ */
+public interface VppBiddingConfigService {
+
+    CommonPage<BiddingConfigVO> page(String configName,
+                                     LocalDateTime effectiveStart,
+                                     LocalDateTime effectiveEnd,
+                                     Integer current,
+                                     Integer size);
+
+    BiddingConfigVO get(Long id);
+
+    Long create(BiddingConfigRequest request);
+
+    void update(Long id, BiddingConfigRequest request);
+
+    void delete(Long id);
+}

+ 5 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppCustomerService.java

@@ -40,6 +40,11 @@ public interface VppCustomerService {
      */
     void auditAccess(CustomerAccessAuditRequest request);
 
+    /**
+     * 修改已驳回的准入申请(修改后重新进入待审核)
+     */
+    Boolean updateAccess(CustomerAccessRequestVO request);
+
     /**
      * 删除准入申请(软删除)
      */

+ 2 - 1
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppDrService.java

@@ -5,6 +5,7 @@ import com.usky.vpp.domain.VppDrEvent;
 import com.usky.vpp.domain.VppDrEvaluation;
 import com.usky.vpp.domain.VppDrStrategy;
 import com.usky.vpp.service.vo.DrClearingRequest;
+import com.usky.vpp.service.vo.DrEventDetailVO;
 import com.usky.vpp.service.vo.DrEventRequest;
 import com.usky.vpp.service.vo.DrInterveneRequest;
 import com.usky.vpp.service.vo.DrParticipateRequest;
@@ -20,7 +21,7 @@ public interface VppDrService {
 
     CommonPage<VppDrEvent> pageEvent(Map<String, Object> params);
 
-    VppDrEvent getEvent(Long id);
+    DrEventDetailVO getEvent(Long id);
 
     VppDrEvent createEvent(DrEventRequest request);
 

+ 28 - 4
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppReportInfoService.java

@@ -1,15 +1,39 @@
 package com.usky.vpp.service;
 
 import com.usky.common.core.bean.CommonPage;
+import com.usky.vpp.service.vo.ReportAuditRequest;
+import com.usky.vpp.service.vo.ReportDataSaveRequest;
+import com.usky.vpp.service.vo.ReportPrefillVO;
+import com.usky.vpp.service.vo.ReportRecordVO;
+import com.usky.vpp.service.vo.ReportTaskCreateRequest;
+import com.usky.vpp.service.vo.ReportTaskCreateResultVO;
+import com.usky.vpp.service.vo.ReportTaskVO;
+import com.usky.vpp.service.vo.ReportValidateResultVO;
 
 import java.util.Map;
 
 /**
- * VppReportInfoService 业务接口
+ * 监管信息报送业务接口
  */
 public interface VppReportInfoService {
 
-    default Object stub(String action, Map<String, Object> params) {
-        return null;
-    }
+    CommonPage<ReportTaskVO> pageTask(Map<String, Object> params);
+
+    ReportTaskCreateResultVO createTask(ReportTaskCreateRequest request);
+
+    ReportRecordVO getRecord(Long id);
+
+    ReportPrefillVO prefill(Long id);
+
+    Map<String, Object> saveData(Long id, ReportDataSaveRequest request);
+
+    ReportValidateResultVO validateData(Long id);
+
+    Map<String, Object> submitAudit(Long id);
+
+    void audit(Long id, ReportAuditRequest request);
+
+    ReportRecordVO submit(Long id);
+
+    CommonPage<ReportRecordVO> pageHistory(Map<String, Object> params);
 }

+ 19 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppResponseMonitorService.java

@@ -0,0 +1,19 @@
+package com.usky.vpp.service;
+
+import com.usky.vpp.service.vo.ResponseCompareVO;
+import com.usky.vpp.service.vo.ResponseLoadAnalysisVO;
+import com.usky.vpp.service.vo.ResponseMonitorTreeNodeVO;
+
+import java.util.List;
+
+/**
+ * 响应监控业务接口
+ */
+public interface VppResponseMonitorService {
+
+    List<ResponseMonitorTreeNodeVO> getResourceTree(String keyword);
+
+    ResponseLoadAnalysisVO getLoadAnalysis(Long siteId, String date, Long eventId);
+
+    ResponseCompareVO compare(String siteIds, String date);
+}

+ 23 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppSettlementService.java

@@ -1,7 +1,10 @@
 package com.usky.vpp.service;
 
 import com.usky.common.core.bean.CommonPage;
+import com.usky.vpp.domain.VppEnergyReadingMonthly;
+import com.usky.vpp.domain.VppSettlementBill;
 
+import javax.servlet.http.HttpServletResponse;
 import java.util.Map;
 
 /**
@@ -12,4 +15,24 @@ public interface VppSettlementService {
     default Object stub(String action, Map<String, Object> params) {
         return null;
     }
+
+    CommonPage<VppEnergyReadingMonthly> pageReading(Long id, String customerName, String calcCycle,
+                                                    Integer current, Integer size);
+
+    /**
+     * 导出月度电量核算数据(Excel)
+     */
+    void exportReading(String scope, String fileName, Long id, String customerName, String calcCycle,
+                       Integer current, Integer size, HttpServletResponse response);
+
+    /**
+     * 分页查询电费账单
+     */
+    CommonPage<VppSettlementBill> pageBill(Long id, String customerName, Integer current, Integer size);
+
+    /**
+     * 导出电费账单数据(Excel)
+     */
+    void exportBill(String scope, String fileName, Long id, String customerName,
+                    Integer current, Integer size, HttpServletResponse response);
 }

+ 16 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppTsdbQueryService.java

@@ -1,7 +1,9 @@
 package com.usky.vpp.service;
 
 import java.math.BigDecimal;
+import java.time.LocalDate;
 import java.time.LocalDateTime;
+import java.time.LocalTime;
 import java.util.List;
 import java.util.Map;
 import java.util.TreeMap;
@@ -28,4 +30,18 @@ public interface VppTsdbQueryService {
      * @return deviceUuid -> metric -> value
      */
     Map<String, Map<String, BigDecimal>> queryLatestMetrics(List<String> deviceUuids, List<String> metrics);
+
+    /**
+     * 多历史日同时段各时间点负荷求平均(原始/预测基线)。
+     * <p>逐日调用 {@link #queryDeviceMetricHistory}(service-tsdb queryHistoryDeviceData)后在本模块聚合。</p>
+     *
+     * @return time(HH:mm) -> 平均负荷 kW
+     */
+    Map<String, BigDecimal> queryBaselineAvgByTimePoint(List<String> deviceUuids,
+                                                        List<LocalDate> referenceDates,
+                                                        LocalTime periodStart,
+                                                        LocalTime periodEnd,
+                                                        List<String> metrics,
+                                                        Integer intervalMinutes,
+                                                        BigDecimal correctionFactor);
 }

+ 4 - 2
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppArchiveServiceImpl.java

@@ -11,6 +11,7 @@ import com.usky.common.security.utils.SecurityUtils;
 import com.usky.vpp.domain.VppFileArchive;
 import com.usky.vpp.mapper.VppFileArchiveMapper;
 import com.usky.vpp.service.VppArchiveService;
+import com.usky.vpp.service.VppSiteService;
 import com.usky.vpp.service.vo.VppFileArchiveRequestVO;
 import com.usky.vpp.service.vo.VppFileArchiveResponseVO;
 import org.apache.commons.lang3.StringUtils;
@@ -57,6 +58,8 @@ public class VppArchiveServiceImpl implements VppArchiveService {
     private FilesMapper filesMapper;
     @Autowired
     private VppFileArchiveMapper vppFileArchiveMapper;
+    @Autowired
+    private VppSiteService vppSiteService;
 
     /**
      * 连接池化的 RestTemplate,避免每次创建新连接
@@ -157,8 +160,7 @@ public class VppArchiveServiceImpl implements VppArchiveService {
             responseVO.setUpdatedBy(entity.getUpdatedBy());
             responseVO.setVersion(entity.getVersion());
             responseVO.setTenantId(entity.getTenantId());
-            // TODO 查询站点名称
-            // responseVO.setSiteName();
+            responseVO.setSiteName(vppSiteService.getSite(entity.getSiteId()).getSiteName());
             return responseVO;
         }).collect(java.util.stream.Collectors.toList());
 

+ 380 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppBaselineServiceImpl.java

@@ -0,0 +1,380 @@
+package com.usky.vpp.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.usky.common.core.exception.BusinessException;
+import com.usky.vpp.domain.VppDevice;
+import com.usky.vpp.domain.VppDrEvent;
+import com.usky.vpp.domain.VppResourcePoint;
+import com.usky.vpp.domain.VppSite;
+import com.usky.vpp.mapper.VppDeviceMapper;
+import com.usky.vpp.mapper.VppDrEventMapper;
+import com.usky.vpp.mapper.VppResourcePointMapper;
+import com.usky.vpp.mapper.VppSiteMapper;
+import com.usky.vpp.service.VppBaselineService;
+import com.usky.vpp.service.VppTsdbQueryService;
+import com.usky.vpp.service.vo.BaselinePointVO;
+import com.usky.vpp.service.vo.SiteBaselineVO;
+import com.usky.vpp.service.vo.SiteDeclaredCapacityVO;
+import com.usky.vpp.util.VppAuditHelper;
+import com.usky.vpp.util.VppBaselineHelper;
+import com.usky.vpp.util.VppWorkdayHelper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.stream.Collectors;
+
+@Service
+public class VppBaselineServiceImpl implements VppBaselineService {
+
+    private static final int EVENT_STATUS_ENDED = 3;
+    private static final DateTimeFormatter DATE_TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+    @Autowired
+    private VppSiteMapper siteMapper;
+    @Autowired
+    private VppResourcePointMapper resourcePointMapper;
+    @Autowired
+    private VppDeviceMapper deviceMapper;
+    @Autowired
+    private VppDrEventMapper drEventMapper;
+    @Autowired
+    private VppTsdbQueryService vppTsdbQueryService;
+
+    /**
+     * 查询站点基线负荷曲线(预测基线 / 今日实际 / 昨日实际)。
+     *
+     * <p><b>入参</b></p>
+     * <ul>
+     *   <li>siteId — 站点 ID,校验站点存在且未软删除</li>
+     *   <li>responseStartTime — 响应开始时间,格式 yyyy-MM-dd HH:mm:ss</li>
+     * </ul>
+     *
+     * <p><b>处理流程</b></p>
+     * <ol>
+     *   <li>查询站点下 is_control=1 的资源点关联设备 UUID;无设备则返回 mock 数据</li>
+     *   <li>按 Hutool 法定节假日规则判定响应日类型(工作日 / 非工作日),选取典型历史日:
+     *       工作日 5 天 / 非工作日 3 天,剔除历史响应日</li>
+     *   <li>本模块按典型历史日逐日调用 TSDB {@code queryHistoryDeviceData},聚合计算全天原始基线(10 分钟粒度)</li>
+     *   <li>取响应开始时间前连续 2 小时的今日实时负荷(绿色曲线)与同时段原始基线比值,计算修正系数 K(0.7 ~ 1.2)</li>
+     *   <li>原始基线 × K 得到预测基线;并查询今日 / 昨日实测曲线</li>
+     *   <li>组装 {@link SiteBaselineVO},dataSource 为 tsdb;TSDB 无数据时降级为 mock</li>
+     * </ol>
+     *
+     * @param siteId            站点 ID
+     * @param responseStartTime 响应开始时间
+     * @return 基线曲线及修正系数等信息
+     * @throws BusinessException 站点不存在、响应开始时间为空或格式无效
+     */
+    @Override
+    public SiteBaselineVO getSiteBaseline(Long siteId, String responseStartTime) {
+        VppSite site = resolveSite(siteId);
+        LocalDateTime startTime = parseResponseStartTime(responseStartTime);
+        LocalDate responseDate = startTime.toLocalDate();
+
+        List<String> deviceUuids = listControllableDeviceUuids(siteId);
+        SiteBaselineVO result = new SiteBaselineVO();
+        result.setSiteId(siteId);
+        result.setSiteName(site.getSiteName());
+        result.setResponseStartTime(startTime);
+
+        if (deviceUuids.isEmpty()) {
+            return buildMockResult(result, siteId, responseDate, startTime);
+        }
+
+        Set<LocalDate> excludeDates = loadResponseHistoryDates();
+        boolean workday = VppBaselineHelper.isWorkdayResponse(responseDate);
+        result.setResponseDayType(VppWorkdayHelper.resolveDayType(responseDate));
+        result.setResponseDayTypeLabel(VppWorkdayHelper.resolveDayTypeLabel(responseDate));
+        int requiredCount = workday
+                ? VppBaselineHelper.WEEKDAY_REFERENCE_COUNT
+                : VppBaselineHelper.NON_WEEKDAY_REFERENCE_COUNT;
+        List<LocalDate> referenceDates = VppBaselineHelper.selectReferenceDates(
+                responseDate, workday, excludeDates, requiredCount);
+        result.setReferenceDates(referenceDates.stream()
+                .map(VppBaselineHelper::formatDate)
+                .collect(Collectors.toList()));
+
+        //原始基线
+        Map<String, BigDecimal> originalBaselineByTime = vppTsdbQueryService.queryBaselineAvgByTimePoint(
+                deviceUuids,
+                referenceDates,
+                LocalTime.MIN,
+                LocalTime.of(23, 59, 59),
+                VppBaselineHelper.POWER_METRICS,
+                VppBaselineHelper.INTERVAL_MINUTES,
+                null);
+        if (originalBaselineByTime.isEmpty()) {
+            return buildMockResult(result, siteId, responseDate, startTime);
+        }
+
+        //历史日的响应开始时间前两个小时原始基线
+        LocalDateTime correctionWindowStart = startTime.minusHours(VppBaselineHelper.CORRECTION_HOURS);
+        Map<String, BigDecimal> correctionBaselineByTime = vppTsdbQueryService.queryBaselineAvgByTimePoint(
+                deviceUuids,
+                referenceDates,
+                correctionWindowStart.toLocalTime(),
+                startTime.toLocalTime(),
+                VppBaselineHelper.POWER_METRICS,
+                VppBaselineHelper.INTERVAL_MINUTES,
+                null);
+
+        LocalDate yesterday = responseDate.minusDays(1);
+        //响应开始时间日数据线
+        Map<String, BigDecimal> todayActualByTime = VppBaselineHelper.buildActualLoadByTimePoint(
+                vppTsdbQueryService.queryDeviceMetricHistory(
+                        deviceUuids, responseDate.atStartOfDay(), startTime, VppBaselineHelper.POWER_METRICS),
+                VppBaselineHelper.INTERVAL_MINUTES);
+        //响应开始时间日前一天数据线
+        Map<String, BigDecimal> yesterdayActualByTime = VppBaselineHelper.buildActualLoadByTimePoint(
+                vppTsdbQueryService.queryDeviceMetricHistory(
+                        deviceUuids, yesterday.atStartOfDay(), yesterday.atTime(23, 59, 59), VppBaselineHelper.POWER_METRICS),
+                VppBaselineHelper.INTERVAL_MINUTES);
+
+        //计算修正系数
+        BigDecimal correctionFactorK = VppBaselineHelper.calculateCorrectionFactor(
+                startTime, todayActualByTime, correctionBaselineByTime.isEmpty()
+                        ? originalBaselineByTime : correctionBaselineByTime);
+
+        //预测基线
+        Map<String, BigDecimal> predictedBaselineByTime = vppTsdbQueryService.queryBaselineAvgByTimePoint(
+                deviceUuids,
+                referenceDates,
+                LocalTime.MIN,
+                LocalTime.of(23, 59, 59),
+                VppBaselineHelper.POWER_METRICS,
+                VppBaselineHelper.INTERVAL_MINUTES,
+                correctionFactorK);
+
+        List<BaselinePointVO> points = VppBaselineHelper.buildBaselinePoints(
+                responseDate,
+                startTime,
+                predictedBaselineByTime.isEmpty() ? applyCorrection(originalBaselineByTime, correctionFactorK)
+                        : predictedBaselineByTime,
+                todayActualByTime,
+                yesterdayActualByTime);
+
+        result.setCorrectionFactorK(correctionFactorK);
+        result.setPoints(points);
+        result.setDataSource("tsdb");
+        return result;
+    }
+
+    /**
+     * 查询站点响应申报容量。
+     *
+     * <p>公式:响应时段内同时间段历史负荷均值 × 修正系数 K × 执行时段小时数 / 2。</p>
+     * <p>典型历史日、修正系数 K 计算规则与基线管理一致;历史均值仅取响应开始至结束时段。</p>
+     */
+    @Override
+    public SiteDeclaredCapacityVO getSiteDeclaredCapacity(Long siteId,
+                                                          String responseStartTime,
+                                                          String responseEndTime) {
+        VppSite site = resolveSite(siteId);
+        LocalDateTime startTime = parseResponseStartTime(responseStartTime);
+        LocalDateTime endTime = parseResponseEndTime(responseEndTime, startTime);
+        LocalDate responseDate = startTime.toLocalDate();
+
+        SiteDeclaredCapacityVO result = new SiteDeclaredCapacityVO();
+        result.setSiteId(siteId);
+        result.setSiteName(site.getSiteName());
+        result.setResponseStartTime(startTime);
+        result.setResponseEndTime(endTime);
+
+        List<String> deviceUuids = listControllableDeviceUuids(siteId);
+        if (deviceUuids.isEmpty()) {
+            return buildMockDeclaredCapacityResult(result, siteId, responseDate, startTime, endTime);
+        }
+
+        Set<LocalDate> excludeDates = loadResponseHistoryDates();
+        boolean workday = VppBaselineHelper.isWorkdayResponse(responseDate);
+        result.setResponseDayType(VppWorkdayHelper.resolveDayType(responseDate));
+        result.setResponseDayTypeLabel(VppWorkdayHelper.resolveDayTypeLabel(responseDate));
+        int requiredCount = workday
+                ? VppBaselineHelper.WEEKDAY_REFERENCE_COUNT
+                : VppBaselineHelper.NON_WEEKDAY_REFERENCE_COUNT;
+        List<LocalDate> referenceDates = VppBaselineHelper.selectReferenceDates(
+                responseDate, workday, excludeDates, requiredCount);
+        result.setReferenceDates(referenceDates.stream()
+                .map(VppBaselineHelper::formatDate)
+                .collect(Collectors.toList()));
+
+        LocalDateTime correctionWindowStart = startTime.minusHours(VppBaselineHelper.CORRECTION_HOURS);
+        Map<String, BigDecimal> correctionBaselineByTime = vppTsdbQueryService.queryBaselineAvgByTimePoint(
+                deviceUuids,
+                referenceDates,
+                correctionWindowStart.toLocalTime(),
+                startTime.toLocalTime(),
+                VppBaselineHelper.POWER_METRICS,
+                VppBaselineHelper.INTERVAL_MINUTES,
+                null);
+        Map<String, BigDecimal> todayActualByTime = VppBaselineHelper.buildActualLoadByTimePoint(
+                vppTsdbQueryService.queryDeviceMetricHistory(
+                        deviceUuids, responseDate.atStartOfDay(), startTime, VppBaselineHelper.POWER_METRICS),
+                VppBaselineHelper.INTERVAL_MINUTES);
+        BigDecimal correctionFactorK = VppBaselineHelper.calculateCorrectionFactor(
+                startTime, todayActualByTime, correctionBaselineByTime);
+
+        Map<String, BigDecimal> historicalAvgByTime = vppTsdbQueryService.queryBaselineAvgByTimePoint(
+                deviceUuids,
+                referenceDates,
+                startTime.toLocalTime(),
+                endTime.toLocalTime(),
+                VppBaselineHelper.POWER_METRICS,
+                VppBaselineHelper.INTERVAL_MINUTES,
+                null);
+        if (historicalAvgByTime.isEmpty()) {
+            return buildMockDeclaredCapacityResult(result, siteId, responseDate, startTime, endTime);
+        }
+
+        result.setCorrectionFactorK(correctionFactorK);
+        result.setDeclaredCapacityKw(VppBaselineHelper.calculateDeclaredCapacityKw(
+                historicalAvgByTime, startTime, endTime, correctionFactorK));
+        result.setPoints(VppBaselineHelper.buildDeclaredCapacityPoints(
+                historicalAvgByTime, startTime, endTime, correctionFactorK));
+        result.setDataSource("tsdb");
+        return result;
+    }
+
+    private SiteDeclaredCapacityVO buildMockDeclaredCapacityResult(SiteDeclaredCapacityVO result,
+                                                                 Long siteId,
+                                                                 LocalDate responseDate,
+                                                                 LocalDateTime startTime,
+                                                                 LocalDateTime endTime) {
+        BigDecimal correctionFactorK = VppBaselineHelper.mockCorrectionFactor(siteId, responseDate);
+        result.setReferenceDates(new ArrayList<>());
+        result.setResponseDayType(VppWorkdayHelper.resolveDayType(responseDate));
+        result.setResponseDayTypeLabel(VppWorkdayHelper.resolveDayTypeLabel(responseDate));
+        result.setCorrectionFactorK(correctionFactorK);
+        result.setDeclaredCapacityKw(VppBaselineHelper.mockDeclaredCapacityKw(
+                siteId, responseDate, startTime, endTime, correctionFactorK));
+        result.setPoints(VppBaselineHelper.buildMockDeclaredCapacityPoints(
+                siteId, responseDate, startTime, endTime, correctionFactorK));
+        result.setDataSource("mock");
+        return result;
+    }
+
+    private Map<String, BigDecimal> applyCorrection(Map<String, BigDecimal> originalBaselineByTime,
+                                                    BigDecimal correctionFactorK) {
+        Map<String, BigDecimal> corrected = new TreeMap<>();
+        for (Map.Entry<String, BigDecimal> entry : originalBaselineByTime.entrySet()) {
+            BigDecimal value = entry.getValue() != null ? entry.getValue() : BigDecimal.ZERO;
+            corrected.put(entry.getKey(), value.multiply(correctionFactorK).setScale(2, java.math.RoundingMode.HALF_UP));
+        }
+        return corrected;
+    }
+
+    private SiteBaselineVO buildMockResult(SiteBaselineVO result,
+                                           Long siteId,
+                                           LocalDate responseDate,
+                                           LocalDateTime responseStartTime) {
+        result.setReferenceDates(new ArrayList<>());
+        result.setResponseDayType(VppWorkdayHelper.resolveDayType(responseDate));
+        result.setResponseDayTypeLabel(VppWorkdayHelper.resolveDayTypeLabel(responseDate));
+        result.setCorrectionFactorK(VppBaselineHelper.mockCorrectionFactor(siteId, responseDate));
+        result.setPoints(VppBaselineHelper.buildMockBaselinePoints(siteId, responseDate, responseStartTime));
+        result.setDataSource("mock");
+        return result;
+    }
+
+    private List<String> listControllableDeviceUuids(Long siteId) {
+        List<VppResourcePoint> resources = resourcePointMapper.selectList(
+                new LambdaQueryWrapper<VppResourcePoint>()
+                        .eq(VppResourcePoint::getSiteId, siteId)
+                        .eq(VppResourcePoint::getIsControl, 1)
+                        .eq(VppResourcePoint::getDeleteFlag, VppAuditHelper.NOT_DELETED));
+        if (CollectionUtils.isEmpty(resources)) {
+            return new ArrayList<>();
+        }
+
+        Set<Long> deviceIds = resources.stream()
+                .map(VppResourcePoint::getDeviceId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        if (deviceIds.isEmpty()) {
+            return new ArrayList<>();
+        }
+
+        List<VppDevice> devices = deviceMapper.selectBatchIds(deviceIds).stream()
+                .filter(device -> !VppAuditHelper.isDeleted(device.getDeleteFlag()))
+                .collect(Collectors.toList());
+
+        Set<String> uuids = new LinkedHashSet<>();
+        for (VppDevice device : devices) {
+            if (StringUtils.hasText(device.getDeviceUuid())) {
+                uuids.add(device.getDeviceUuid().trim());
+            }
+        }
+        return new ArrayList<>(uuids);
+    }
+
+    private Set<LocalDate> loadResponseHistoryDates() {
+        List<VppDrEvent> events = drEventMapper.selectList(
+                new LambdaQueryWrapper<VppDrEvent>()
+                        .eq(VppDrEvent::getEventStatus, EVENT_STATUS_ENDED)
+                        .eq(VppDrEvent::getDeleteFlag, VppAuditHelper.NOT_DELETED));
+        Set<LocalDate> dates = new HashSet<>();
+        for (VppDrEvent event : events) {
+            if (event.getStartTime() != null) {
+                dates.add(event.getStartTime().toLocalDate());
+            }
+        }
+        return dates;
+    }
+
+    private VppSite resolveSite(Long siteId) {
+        if (siteId == null) {
+            throw new BusinessException("站点不存在");
+        }
+        VppSite site = siteMapper.selectById(siteId);
+        if (site == null || VppAuditHelper.isDeleted(site.getDeleteFlag())) {
+            throw new BusinessException("站点不存在");
+        }
+        return site;
+    }
+
+    private LocalDateTime parseResponseStartTime(String responseStartTime) {
+        if (!StringUtils.hasText(responseStartTime)) {
+            throw new BusinessException("响应开始时间不能为空");
+        }
+        try {
+            return LocalDateTime.parse(responseStartTime.trim(), DATE_TIME_FMT);
+        } catch (DateTimeParseException ex) {
+            throw new BusinessException("响应开始时间格式无效,请使用 yyyy-MM-dd HH:mm:ss");
+        }
+    }
+
+    private LocalDateTime parseResponseEndTime(String responseEndTime, LocalDateTime responseStartTime) {
+        if (!StringUtils.hasText(responseEndTime)) {
+            throw new BusinessException("响应结束时间不能为空");
+        }
+        LocalDateTime endTime;
+        try {
+            endTime = LocalDateTime.parse(responseEndTime.trim(), DATE_TIME_FMT);
+        } catch (DateTimeParseException ex) {
+            throw new BusinessException("响应结束时间格式无效,请使用 yyyy-MM-dd HH:mm:ss");
+        }
+        if (endTime.isBefore(responseStartTime)) {
+            throw new BusinessException("响应结束时间不能早于响应开始时间");
+        }
+        if (!endTime.toLocalDate().equals(responseStartTime.toLocalDate())) {
+            throw new BusinessException("响应结束时间需与响应开始时间在同一天");
+        }
+        return endTime;
+    }
+}

+ 253 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppBiddingConfigServiceImpl.java

@@ -0,0 +1,253 @@
+package com.usky.vpp.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+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.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
+public class VppBiddingConfigServiceImpl implements VppBiddingConfigService {
+
+    @Autowired
+    private VppBiddingConfigMapper biddingConfigMapper;
+    @Autowired
+    private VppSiteMapper siteMapper;
+
+    @Override
+    public CommonPage<BiddingConfigVO> page(String configName,
+                                              LocalDateTime effectiveStart,
+                                              LocalDateTime effectiveEnd,
+                                              Integer current,
+                                              Integer size) {
+        Map<String, Object> pageParams = new HashMap<>(2);
+        pageParams.put("current", current);
+        pageParams.put("size", size);
+        Page<VppBiddingConfig> page = VppPageHelper.of(pageParams);
+        LambdaQueryWrapper<VppBiddingConfig> wrapper = new LambdaQueryWrapper<VppBiddingConfig>()
+                .eq(VppBiddingConfig::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .orderByDesc(VppBiddingConfig::getUpdateTime);
+        applyTenantFilter(wrapper);
+
+        if (StringUtils.hasText(configName)) {
+            wrapper.like(VppBiddingConfig::getConfigName, configName.trim());
+        }
+        if (effectiveStart != null) {
+            wrapper.ge(VppBiddingConfig::getEffectiveEnd, effectiveStart);
+        }
+        if (effectiveEnd != null) {
+            wrapper.le(VppBiddingConfig::getEffectiveStart, effectiveEnd);
+        }
+
+        Page<VppBiddingConfig> result = biddingConfigMapper.selectPage(page, wrapper);
+        List<BiddingConfigVO> records = result.getRecords().stream()
+                .map(config -> toVo(config, false))
+                .collect(Collectors.toList());
+        return new CommonPage<>(records, result.getTotal(), result.getCurrent(), result.getSize());
+    }
+
+    @Override
+    public BiddingConfigVO get(Long id) {
+        return toVo(requireConfig(id), true);
+    }
+
+    @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);
+        VppAuditHelper.fillCreate(config);
+        biddingConfigMapper.insert(config);
+        return config.getId();
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void update(Long id, BiddingConfigRequest request) {
+        VppBiddingConfig config = requireConfig(id);
+        validateRequest(request);
+        List<Long> siteIds = validateAndNormalizeSiteIds(request.getSiteIds(), resolveCurrentTenantId());
+
+        fillConfigFromRequest(config, request, siteIds);
+        VppAuditHelper.fillUpdate(config);
+        biddingConfigMapper.updateById(config);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void delete(Long id) {
+        VppBiddingConfig config = requireConfig(id);
+        VppAuditHelper.fillSoftDelete(config);
+        biddingConfigMapper.updateById(config);
+    }
+
+    private VppBiddingConfig requireConfig(Long id) {
+        VppBiddingConfig config = biddingConfigMapper.selectById(id);
+        if (config == null || VppAuditHelper.isDeleted(config.getDeleteFlag())) {
+            throw new BusinessException("竞价配置不存在");
+        }
+        return config;
+    }
+
+    private void fillConfigFromRequest(VppBiddingConfig config,
+                                       BiddingConfigRequest request,
+                                       List<Long> siteIds) {
+        config.setConfigName(request.getConfigName().trim());
+        config.setEffectiveStart(request.getEffectiveStart());
+        config.setEffectiveEnd(request.getEffectiveEnd());
+        config.setAttachmentUrl(request.getAttachmentUrl());
+        config.setAttachmentName(request.getAttachmentName());
+        config.setBiddingMode(request.getBiddingMode());
+        config.setTypicalCoefficient(request.getTypicalCoefficient());
+        config.setAdvanceCloseHours(request.getAdvanceCloseHours());
+        config.setMaxPrice(request.getMaxPrice());
+        config.setMinPrice(request.getMinPrice());
+        config.setSiteIds(VppReportHelper.toJson(siteIds));
+    }
+
+    private BiddingConfigVO toVo(VppBiddingConfig config, boolean withSites) {
+        BiddingConfigVO vo = new BiddingConfigVO();
+        vo.setId(config.getId());
+        vo.setConfigName(config.getConfigName());
+        vo.setEffectiveStart(config.getEffectiveStart());
+        vo.setEffectiveEnd(config.getEffectiveEnd());
+        vo.setEffectivePeriod(VppBiddingConfigHelper.formatEffectivePeriod(
+                config.getEffectiveStart(), config.getEffectiveEnd()));
+        vo.setAttachmentUrl(config.getAttachmentUrl());
+        vo.setAttachmentName(config.getAttachmentName());
+        vo.setBiddingMode(config.getBiddingMode());
+        vo.setBiddingModeName(VppBiddingConfigHelper.resolveBiddingModeName(config.getBiddingMode()));
+        vo.setTypicalCoefficient(config.getTypicalCoefficient());
+        vo.setAdvanceCloseHours(config.getAdvanceCloseHours());
+        vo.setAdvanceCloseHoursLabel(
+                VppBiddingConfigHelper.resolveAdvanceCloseHoursLabel(config.getAdvanceCloseHours()));
+        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("请求体不能为空");
+        }
+        if (!StringUtils.hasText(request.getConfigName())) {
+            throw new BusinessException("配置名称不能为空");
+        }
+        if (request.getEffectiveStart() == null || request.getEffectiveEnd() == null) {
+            throw new BusinessException("适用时间段不能为空");
+        }
+        if (request.getEffectiveStart().isAfter(request.getEffectiveEnd())) {
+            throw new BusinessException("适用开始时间不能晚于结束时间");
+        }
+        if (request.getBiddingMode() == null
+                || (request.getBiddingMode() != VppBiddingConfigHelper.BIDDING_MODE_TIME_SLOT
+                && request.getBiddingMode() != VppBiddingConfigHelper.BIDDING_MODE_WHOLE)) {
+            throw new BusinessException("竞价模式无效,取值 1 或 2");
+        }
+        if (request.getTypicalCoefficient() == null) {
+            throw new BusinessException("典型系数不能为空");
+        }
+        if (request.getAdvanceCloseHours() == null || request.getAdvanceCloseHours() <= 0) {
+            throw new BusinessException("提前截标时间须为正整数(小时)");
+        }
+        if (request.getMaxPrice() == null) {
+            throw new BusinessException("最高价格不能为空");
+        }
+        if (request.getMinPrice() == null) {
+            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() {
+        try {
+            return SecurityUtils.getTenantId();
+        } catch (Exception ex) {
+            return null;
+        }
+    }
+
+    private void applyTenantFilter(LambdaQueryWrapper<VppBiddingConfig> wrapper) {
+        Integer tenantId = resolveCurrentTenantId();
+        if (tenantId != null) {
+            wrapper.eq(VppBiddingConfig::getTenantId, tenantId);
+        }
+    }
+}

+ 13 - 8
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppContractServiceImpl.java

@@ -15,6 +15,7 @@ import com.usky.vpp.service.VppContractService;
 import com.usky.vpp.service.vo.VppContractRequestVO;
 import com.usky.vpp.service.vo.VppContractResponseVO;
 import com.usky.vpp.util.VppAuditHelper;
+import com.usky.vpp.util.VppContractStatusHelper;
 import com.usky.vpp.util.VppFieldValidator;
 import org.apache.commons.lang3.StringUtils;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -73,7 +74,8 @@ public class VppContractServiceImpl implements VppContractService {
         contract.setTemplateId(vo.getTemplateId());
         contract.setContractType(vo.getContractType());
         contract.setContractName(vo.getContractName());
-        contract.setContractStatus(vo.getContractStatus());
+        contract.setContractStatus(VppContractStatusHelper.resolveForPersist(
+                vo.getContractStatus(), vo.getEffectiveDate(), vo.getExpireDate()));
         contract.setSignDate(vo.getSignDate());
         contract.setEffectiveDate(vo.getEffectiveDate());
         contract.setExpireDate(vo.getExpireDate());
@@ -117,9 +119,6 @@ public class VppContractServiceImpl implements VppContractService {
         if (StringUtils.isNotBlank(vo.getContractName())) {
             existingRecord.setContractName(vo.getContractName());
         }
-        if (vo.getContractStatus() != null) {
-            existingRecord.setContractStatus(vo.getContractStatus());
-        }
         if (vo.getSignDate() != null) {
             existingRecord.setSignDate(vo.getSignDate());
         }
@@ -145,6 +144,11 @@ public class VppContractServiceImpl implements VppContractService {
             existingRecord.setRemark(vo.getRemark());
         }
 
+        Integer requestedStatus = vo.getContractStatus() != null
+                ? vo.getContractStatus() : existingRecord.getContractStatus();
+        existingRecord.setContractStatus(VppContractStatusHelper.resolveForPersist(
+                requestedStatus, existingRecord.getEffectiveDate(), existingRecord.getExpireDate()));
+
         VppAuditHelper.fillUpdate(existingRecord);
 
         int result = vppContractMapper.updateById(existingRecord);
@@ -164,9 +168,9 @@ public class VppContractServiceImpl implements VppContractService {
                 .le(signDateEnd != null, VppContract::getSignDate, signDateEnd)
                 .eq(templateId != null, VppContract::getTemplateId, templateId)
                 .eq(contractType != null, VppContract::getContractType, contractType)
-                .eq(customerId != null, VppContract::getCustomerId, customerId)
-                .eq(contractStatus != null, VppContract::getContractStatus, contractStatus)
-                .orderByDesc(VppContract::getCreateTime);
+                .eq(customerId != null, VppContract::getCustomerId, customerId);
+        VppContractStatusHelper.applyStatusFilter(queryWrapper, contractStatus);
+        queryWrapper.orderByDesc(VppContract::getCreateTime);
         page = vppContractMapper.selectPage(page, queryWrapper);
 
         List<VppContract> records = page.getRecords();
@@ -197,7 +201,8 @@ public class VppContractServiceImpl implements VppContractService {
             vo.setTemplateName(templateNameMap.get(entity.getTemplateId()));
             vo.setContractType(entity.getContractType());
             vo.setContractName(entity.getContractName());
-            vo.setContractStatus(entity.getContractStatus());
+            vo.setContractStatus(VppContractStatusHelper.resolve(
+                    entity.getContractStatus(), entity.getEffectiveDate(), entity.getExpireDate()));
             vo.setSignDate(entity.getSignDate());
             vo.setEffectiveDate(entity.getEffectiveDate());
             vo.setExpireDate(entity.getExpireDate());

+ 244 - 89
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppCustomerServiceImpl.java

@@ -15,6 +15,7 @@ import com.usky.vpp.mapper.VppCustomerAccessMapper;
 import com.usky.vpp.mapper.VppCustomerContactMapper;
 import com.usky.vpp.mapper.VppCustomerMapper;
 import com.usky.vpp.service.VppCustomerService;
+import com.usky.vpp.service.VppSiteService;
 import com.usky.vpp.service.vo.CustomerAccessAuditRequest;
 import com.usky.vpp.service.vo.CustomerAccessRequestVO;
 import com.usky.vpp.service.vo.CustomerAccessResponseVO;
@@ -22,6 +23,7 @@ import com.usky.vpp.service.vo.CustomerContactRequest;
 import com.usky.vpp.service.vo.CustomerRequestVO;
 import com.usky.vpp.service.vo.CustomerResponseVO;
 import com.usky.vpp.util.VppAuditHelper;
+import com.usky.vpp.util.VppContractStatusHelper;
 import com.usky.vpp.util.VppFieldValidator;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
@@ -78,37 +80,17 @@ public class VppCustomerServiceImpl implements VppCustomerService {
     @Transactional(rollbackFor = Exception.class)
     public Boolean submitAccess(CustomerAccessRequestVO request) {
         validateAccessRequest(request);
-
-        // 校验同租户下 accountNo 唯一
-        LambdaQueryWrapper<VppCustomerAccess> dupWrapper = new LambdaQueryWrapper<>();
-        dupWrapper.eq(VppCustomerAccess::getAccountNo, request.getAccountNo())
-                .eq(VppCustomerAccess::getTenantId, SecurityUtils.getTenantId())
-                .eq(VppCustomerAccess::getDeleteFlag, VppAuditHelper.NOT_DELETED);
-        Integer accessCount = accessMapper.selectCount(dupWrapper);
-        if (accessCount != null && accessCount > 0) {
-            throw new BusinessException("该电力户号已存在准入申请,请勿重复提交!");
-        }
+        checkCustomerNameUnique(request.getCustomerName(), null);
+        checkAccessCustomerNameUnique(request.getCustomerName(), null);
 
         VppCustomerAccess access = new VppCustomerAccess();
         access.setApplyNo(VppAuditHelper.nextApplyNo());
-        access.setAccountNo(request.getAccountNo());
-        access.setCustomerName(request.getCustomerName());
-        access.setCustomerType(request.getCustomerType());
-        access.setPowerCompany(request.getPowerCompany());
-        access.setContractCapacity(request.getContractCapacity());
-        access.setBusinessLicenseUrl(request.getBusinessLicenseUrl());
-        access.setCreditStatus(request.getCreditStatus());
+        applyAccessFromRequest(access, request);
         access.setAccessStatus(ACCESS_PENDING);
         access.setApplyAt(LocalDateTime.now());
-        // 暂存联系人信息(审核通过时写入联系人表)
-        access.setContactName(request.getContactName());
-        access.setContactPhone(request.getContactPhone());
-        access.setContactEmail(request.getContactEmail());
-        access.setSigningDate(request.getSigningDate());
         VppAuditHelper.fillCreate(access);
 
-        int insert = accessMapper.insert(access);
-        return insert > 0;
+        return accessMapper.insert(access) > 0;
     }
 
     @Override
@@ -134,9 +116,17 @@ public class VppCustomerServiceImpl implements VppCustomerService {
             return new CommonPage<>(Collections.emptyList(), page.getTotal(), pageSize, pageNum);
         }
 
+        Map<Long, VppCustomer> customerMap = loadCustomerMap(records.stream()
+                .map(VppCustomerAccess::getCustomerId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet()));
+        Map<Long, VppCustomerContact> primaryContactMap = loadPrimaryContactMap(customerMap.keySet());
+
         List<CustomerAccessResponseVO> list = records.stream().map(entity -> {
             CustomerAccessResponseVO vo = new CustomerAccessResponseVO();
             BeanUtils.copyProperties(entity, vo);
+            fillAccessContactInfo(vo, customerMap.get(entity.getCustomerId()),
+                    primaryContactMap.get(entity.getCustomerId()));
             return vo;
         }).collect(Collectors.toList());
 
@@ -163,35 +153,14 @@ public class VppCustomerServiceImpl implements VppCustomerService {
         access.setAuditAt(LocalDateTime.now());
 
         if (request.getResult() == ACCESS_PASSED) {
-            // 审核通过:校验同租户下 accountNo 和 customerName 唯一
-            Integer tenantId = SecurityUtils.getTenantId();
-            LambdaQueryWrapper<VppCustomer> dupWrapper = new LambdaQueryWrapper<>();
-            dupWrapper.eq(VppCustomer::getTenantId, tenantId)
-                    .eq(VppCustomer::getDeleteFlag, VppAuditHelper.NOT_DELETED)
-                    .and(w -> w.eq(VppCustomer::getAccountNo, access.getAccountNo())
-                            .or().eq(VppCustomer::getCustomerName, access.getCustomerName()));
-            Integer dupCount = customerMapper.selectCount(dupWrapper);
-            if (dupCount != null && dupCount > 0) {
-                throw new BusinessException("同租户下已存在相同电力户号或客户名称的客户!");
+            if (access.getCustomerId() == null) {
+                checkCustomerNameUnique(access.getCustomerName(), null);
+                VppCustomer customer = createCustomerFromAccess(access);
+                access.setCustomerId(customer.getId());
+            } else {
+                checkCustomerNameUnique(access.getCustomerName(), access.getCustomerId());
             }
-
             access.setAccessStatus(ACCESS_PASSED);
-            VppCustomer customer = buildCustomerFromAccess(access);
-            VppAuditHelper.fillCreate(customer);
-            customerMapper.insert(customer);
-            access.setCustomerId(customer.getId());
-
-            // 审核通过时,同步创建主联系人
-            if (StringUtils.isNotBlank(access.getContactName())) {
-                VppCustomerContact contact = new VppCustomerContact();
-                contact.setCustomerId(customer.getId());
-                contact.setContactName(access.getContactName());
-                contact.setContactPhone(access.getContactPhone());
-                contact.setContactEmail(access.getContactEmail());
-                contact.setIsPrimary(1);
-                VppAuditHelper.fillCreate(contact);
-                contactMapper.insert(contact);
-            }
         } else {
             access.setAccessStatus(ACCESS_REJECTED);
         }
@@ -200,6 +169,39 @@ public class VppCustomerServiceImpl implements VppCustomerService {
         accessMapper.updateById(access);
     }
 
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Boolean updateAccess(CustomerAccessRequestVO request) {
+        if (request.getId() == null) {
+            throw new BusinessException("准入申请ID不能为空!");
+        }
+        validateAccessRequest(request);
+
+        VppCustomerAccess access = findAccessById(request.getId());
+        if (access.getAccessStatus() != ACCESS_REJECTED) {
+            throw new BusinessException("仅已驳回的准入申请允许修改!");
+        }
+        checkCustomerNameUnique(request.getCustomerName(), access.getCustomerId());
+        checkAccessCustomerNameUnique(request.getCustomerName(), access.getId());
+
+        applyAccessFromRequest(access, request);
+        access.setAccessStatus(ACCESS_PENDING);
+        access.setAuditOpinion(null);
+        access.setAuditAt(null);
+        access.setApplyAt(LocalDateTime.now());
+
+        if (access.getCustomerId() != null) {
+            VppCustomer customer = findCustomerById(access.getCustomerId());
+            applyCustomerFromAccessRequest(customer, request);
+            VppAuditHelper.fillUpdate(customer);
+            customerMapper.updateById(customer);
+            upsertPrimaryContact(customer.getId(), request);
+        }
+
+        VppAuditHelper.fillUpdate(access);
+        return accessMapper.updateById(access) > 0;
+    }
+
     @Override
     @Transactional(rollbackFor = Exception.class)
     public Boolean deleteAccess(Long id) {
@@ -354,9 +356,9 @@ public class VppCustomerServiceImpl implements VppCustomerService {
     @Transactional(rollbackFor = Exception.class)
     public Boolean createCustomer(CustomerRequestVO vo) {
         // 必填校验
-        if (StringUtils.isBlank(vo.getAccountNo())) {
-            throw new BusinessException("电力户号不能为空!");
-        }
+        // if (StringUtils.isBlank(vo.getAccountNo())) {
+        //     throw new BusinessException("电力户号不能为空!");
+        // }
         if (StringUtils.isBlank(vo.getCustomerName())) {
             throw new BusinessException("客户名称不能为空!");
         }
@@ -370,17 +372,9 @@ public class VppCustomerServiceImpl implements VppCustomerService {
             throw new BusinessException("用电容量不能为空!");
         }
 
-        // 同租户下 accountNo 和 customerName 唯一校验
-        Integer tenantId = SecurityUtils.getTenantId();
-        LambdaQueryWrapper<VppCustomer> dupWrapper = new LambdaQueryWrapper<>();
-        dupWrapper.eq(VppCustomer::getTenantId, tenantId)
-                .eq(VppCustomer::getDeleteFlag, VppAuditHelper.NOT_DELETED)
-                .and(w -> w.eq(VppCustomer::getAccountNo, vo.getAccountNo())
-                        .or().eq(VppCustomer::getCustomerName, vo.getCustomerName()));
-        Integer dupCount = customerMapper.selectCount(dupWrapper);
-        if (dupCount != null && dupCount > 0) {
-            throw new BusinessException("同租户下已存在相同电力户号或客户名称的客户!");
-        }
+        // 电力户号全局唯一校验
+        // checkAccountNoUnique(vo.getAccountNo(), null);
+        checkCustomerNameUnique(vo.getAccountNo(), null);
 
         // 构建客户
         VppCustomer customer = new VppCustomer();
@@ -554,18 +548,13 @@ public class VppCustomerServiceImpl implements VppCustomerService {
         VppCustomer existing = findCustomerById(id);
 
         // 仅更新非空字段
-        if (StringUtils.isNotBlank(vo.getCustomerName())) {
-            if (!vo.getCustomerName().equals(existing.getCustomerName())) {
-                // 校验同租户下客户名称唯一
-                LambdaQueryWrapper<VppCustomer> uniqueWrapper = new LambdaQueryWrapper<>();
-                uniqueWrapper.eq(VppCustomer::getTenantId, SecurityUtils.getTenantId())
-                        .eq(VppCustomer::getCustomerName, vo.getCustomerName())
-                        .eq(VppCustomer::getDeleteFlag, VppAuditHelper.NOT_DELETED);
-                Integer count = customerMapper.selectCount(uniqueWrapper);
-                if (count != null && count > 0) {
-                    throw new BusinessException("客户名称已存在,请更换!");
-                }
+        if (StringUtils.isNotBlank(vo.getAccountNo())) {
+            if (!vo.getAccountNo().equals(existing.getAccountNo())) {
+                // checkAccountNoUnique(vo.getAccountNo(), id);
             }
+            existing.setAccountNo(vo.getAccountNo());
+        }
+        if (StringUtils.isNotBlank(vo.getCustomerName())) {
             existing.setCustomerName(vo.getCustomerName());
         }
         if (vo.getCustomerType() != null) {
@@ -806,7 +795,7 @@ public class VppCustomerServiceImpl implements VppCustomerService {
                 setCell(row, 0, rowIdx - 1);                       // 序号
                 setCell(row, 1, c.getAccountNo());
                 setCell(row, 2, c.getCustomerName());
-                setCell(row, 3, typeLabel(c.getCustomerType(), "中高压", "低压商用", "居民充电桩", "自有资产"));
+                setCell(row, 3, typeLabel(c.getCustomerType(), "虚拟电厂", "售电客户"));
                 setCell(row, 4, c.getPowerCompany());
                 setCell(row, 5, numberStr(c.getContractCapacity()));
                 setCell(row, 6, typeLabel(c.getCreditStatus(), "正常", "关注", "不良"));
@@ -931,28 +920,136 @@ public class VppCustomerServiceImpl implements VppCustomerService {
 
     // ==================== 内部辅助方法 ====================
 
-    private VppCustomer buildCustomerFromAccess(VppCustomerAccess access) {
+    private void applyAccessFromRequest(VppCustomerAccess access, CustomerAccessRequestVO request) {
+        access.setAccountNo(request.getAccountNo());
+        access.setCustomerName(request.getCustomerName());
+        access.setCustomerType(request.getCustomerType());
+        access.setPowerCompany(request.getPowerCompany());
+        access.setContractCapacity(request.getContractCapacity());
+        access.setBusinessLicenseUrl(request.getBusinessLicenseUrl());
+        access.setCreditStatus(request.getCreditStatus());
+        access.setContactName(request.getContactName());
+        access.setContactPhone(request.getContactPhone());
+        access.setContactEmail(request.getContactEmail());
+        access.setAddress(request.getAddress());
+    }
+
+    private CustomerAccessRequestVO toAccessRequest(VppCustomerAccess access) {
+        CustomerAccessRequestVO request = new CustomerAccessRequestVO();
+        request.setAccountNo(access.getAccountNo());
+        request.setCustomerName(access.getCustomerName());
+        request.setCustomerType(access.getCustomerType());
+        request.setPowerCompany(access.getPowerCompany());
+        request.setContractCapacity(access.getContractCapacity());
+        request.setBusinessLicenseUrl(access.getBusinessLicenseUrl());
+        request.setCreditStatus(access.getCreditStatus());
+        request.setContactName(access.getContactName());
+        request.setContactPhone(access.getContactPhone());
+        request.setContactEmail(access.getContactEmail());
+        request.setAddress(access.getAddress());
+        return request;
+    }
+
+    private VppCustomer createCustomerFromAccess(VppCustomerAccess access) {
+        CustomerAccessRequestVO request = toAccessRequest(access);
+        VppCustomer customer = buildCustomerFromAccessRequest(request);
+        VppAuditHelper.fillCreate(customer);
+        customerMapper.insert(customer);
+        savePrimaryContact(customer.getId(), request);
+        return customer;
+    }
+
+    private VppCustomer buildCustomerFromAccessRequest(CustomerAccessRequestVO request) {
         VppCustomer customer = new VppCustomer();
-        customer.setAccountNo(access.getAccountNo());
-        customer.setCustomerName(access.getCustomerName());
-        customer.setCustomerType(access.getCustomerType());
-        customer.setPowerCompany(access.getPowerCompany());
-        customer.setContractCapacity(access.getContractCapacity());
-        customer.setCreditStatus(access.getCreditStatus());
-        customer.setBusinessLicenseUrl(access.getBusinessLicenseUrl());
+        applyCustomerFromAccessRequest(customer, request);
         customer.setLifecycleStatus(LIFECYCLE_ADMITTED);
         customer.setDrNotifyMinutes(30);
-        customer.setSigningDate(access.getSigningDate());
+        customer.setSigningDate(LocalDateTime.now());
         return customer;
     }
 
+    private void applyCustomerFromAccessRequest(VppCustomer customer, CustomerAccessRequestVO request) {
+        customer.setAccountNo(request.getAccountNo());
+        customer.setCustomerName(request.getCustomerName());
+        customer.setCustomerType(request.getCustomerType());
+        customer.setPowerCompany(request.getPowerCompany());
+        customer.setContractCapacity(request.getContractCapacity());
+        customer.setCreditStatus(request.getCreditStatus());
+        customer.setBusinessLicenseUrl(request.getBusinessLicenseUrl());
+        customer.setAddress(request.getAddress());
+    }
+
+    private void savePrimaryContact(Long customerId, CustomerAccessRequestVO request) {
+        VppCustomerContact contact = new VppCustomerContact();
+        contact.setCustomerId(customerId);
+        contact.setContactName(request.getContactName());
+        contact.setContactPhone(request.getContactPhone());
+        contact.setContactEmail(request.getContactEmail());
+        contact.setIsPrimary(1);
+        VppAuditHelper.fillCreate(contact);
+        contactMapper.insert(contact);
+    }
+
+    private void upsertPrimaryContact(Long customerId, CustomerAccessRequestVO request) {
+        LambdaQueryWrapper<VppCustomerContact> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(VppCustomerContact::getCustomerId, customerId)
+                .eq(VppCustomerContact::getTenantId, SecurityUtils.getTenantId())
+                .eq(VppCustomerContact::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .eq(VppCustomerContact::getIsPrimary, 1);
+        VppCustomerContact contact = contactMapper.selectOne(wrapper);
+        if (contact == null) {
+            savePrimaryContact(customerId, request);
+            return;
+        }
+        contact.setContactName(request.getContactName());
+        contact.setContactPhone(request.getContactPhone());
+        contact.setContactEmail(request.getContactEmail());
+        VppAuditHelper.fillUpdate(contact);
+        contactMapper.updateById(contact);
+    }
+
+    private Map<Long, VppCustomer> loadCustomerMap(Set<Long> customerIds) {
+        if (customerIds == null || customerIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        return customerMapper.selectBatchIds(customerIds).stream()
+                .filter(customer -> !VppAuditHelper.isDeleted(customer.getDeleteFlag()))
+                .collect(Collectors.toMap(VppCustomer::getId, customer -> customer, (a, b) -> a));
+    }
+
+    private Map<Long, VppCustomerContact> loadPrimaryContactMap(Set<Long> customerIds) {
+        if (customerIds == null || customerIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        LambdaQueryWrapper<VppCustomerContact> wrapper = new LambdaQueryWrapper<>();
+        wrapper.in(VppCustomerContact::getCustomerId, customerIds)
+                .eq(VppCustomerContact::getTenantId, SecurityUtils.getTenantId())
+                .eq(VppCustomerContact::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .eq(VppCustomerContact::getIsPrimary, 1);
+        return contactMapper.selectList(wrapper).stream()
+                .collect(Collectors.toMap(VppCustomerContact::getCustomerId, contact -> contact, (a, b) -> a));
+    }
+
+    private void fillAccessContactInfo(CustomerAccessResponseVO vo, VppCustomer customer,
+                                       VppCustomerContact contact) {
+        if (StringUtils.isBlank(vo.getAddress()) && customer != null) {
+            vo.setAddress(customer.getAddress());
+        }
+        if (StringUtils.isBlank(vo.getContactName()) && contact != null) {
+            vo.setContactName(contact.getContactName());
+            vo.setContactPhone(contact.getContactPhone());
+            vo.setContactEmail(contact.getContactEmail());
+        }
+    }
+
     private CustomerResponseVO.ContractBriefVO toContractBrief(VppContract contract) {
         CustomerResponseVO.ContractBriefVO brief = new CustomerResponseVO.ContractBriefVO();
         brief.setId(contract.getId());
         brief.setContractNo(contract.getContractNo());
         brief.setContractName(contract.getContractName());
         brief.setContractType(contract.getContractType());
-        brief.setContractStatus(contract.getContractStatus());
+        brief.setContractStatus(VppContractStatusHelper.resolve(
+                contract.getContractStatus(), contract.getEffectiveDate(), contract.getExpireDate()));
         brief.setSignDate(contract.getSignDate());
         brief.setEffectiveDate(contract.getEffectiveDate());
         brief.setExpireDate(contract.getExpireDate());
@@ -999,9 +1096,54 @@ public class VppCustomerServiceImpl implements VppCustomerService {
         return contact;
     }
 
+    /**
+     * 电力户号全局唯一校验(不限租户)
+     */
+    private void checkAccountNoUnique(String accountNo, Long excludeId) {
+        LambdaQueryWrapper<VppCustomer> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(VppCustomer::getAccountNo, accountNo)
+                .eq(VppCustomer::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .ne(excludeId != null, VppCustomer::getId, excludeId);
+        Integer count = customerMapper.selectCount(wrapper);
+        if (count != null && count > 0) {
+            throw new BusinessException("电力户号已存在,请更换!");
+        }
+    }
+
+    /**
+     * 客户名称全局唯一校验(当前租户)
+     */
+    private void checkCustomerNameUnique(String customerName, Long excludeId) {
+        LambdaQueryWrapper<VppCustomer> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(VppCustomer::getCustomerName, customerName)
+                .eq(VppCustomer::getTenantId, SecurityUtils.getTenantId())
+                .eq(VppCustomer::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .ne(excludeId != null, VppCustomer::getId, excludeId);
+        Integer count = customerMapper.selectCount(wrapper);
+        if (count != null && count > 0) {
+            throw new BusinessException("客户名称已存在,请更换!");
+        }
+    }
+
+    /**
+     * 待审核/已驳回准入申请的客户名称唯一校验(当前租户)
+     */
+    private void checkAccessCustomerNameUnique(String customerName, Long excludeAccessId) {
+        LambdaQueryWrapper<VppCustomerAccess> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(VppCustomerAccess::getCustomerName, customerName)
+                .eq(VppCustomerAccess::getTenantId, SecurityUtils.getTenantId())
+                .eq(VppCustomerAccess::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .in(VppCustomerAccess::getAccessStatus, ACCESS_PENDING, ACCESS_REJECTED)
+                .ne(excludeAccessId != null, VppCustomerAccess::getId, excludeAccessId);
+        Integer count = accessMapper.selectCount(wrapper);
+        if (count != null && count > 0) {
+            throw new BusinessException("客户名称已存在待处理准入申请,请更换!");
+        }
+    }
+
     private void validateAccessRequest(CustomerAccessRequestVO request) {
-        if (request == null || StringUtils.isBlank(request.getAccountNo())) {
-            throw new BusinessException("电力户号不能为空!");
+        if (request == null) {
+            throw new BusinessException("准入申请参数不能为空!");
         }
         if (StringUtils.isBlank(request.getCustomerName())) {
             throw new BusinessException("客户名称不能为空!");
@@ -1015,6 +1157,19 @@ public class VppCustomerServiceImpl implements VppCustomerService {
         if (request.getContractCapacity() == null) {
             throw new BusinessException("用电容量不能为空!");
         }
+        if (StringUtils.isBlank(request.getContactName())) {
+            throw new BusinessException("联系人不能为空!");
+        }
+        if (StringUtils.isBlank(request.getContactPhone())) {
+            throw new BusinessException("联系电话不能为空!");
+        }
+        VppFieldValidator.validatePhone(request.getContactPhone());
+        if (StringUtils.isBlank(request.getAddress())) {
+            throw new BusinessException("地址不能为空!");
+        }
+        if (StringUtils.isNotBlank(request.getContactEmail())) {
+            VppFieldValidator.validateEmail(request.getContactEmail());
+        }
     }
 
     private void validateContactRequest(CustomerContactRequest request) {

+ 0 - 3
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDeviceServiceImpl.java

@@ -155,9 +155,6 @@ public class VppDeviceServiceImpl implements VppDeviceService {
         if (!StringUtils.hasText(request.getDeviceName())) {
             throw new BusinessException("设备名称不能为空");
         }
-        if (!StringUtils.hasText(request.getDeviceType())) {
-            throw new BusinessException("设备类型不能为空");
-        }
         ensureUniqueField(VppDevice::getDeviceCode, request.getDeviceCode(), excludeId, "设备编号已存在");
     }
 

+ 3 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDrInvitationServiceImpl.java

@@ -23,6 +23,7 @@ import com.usky.vpp.service.vo.DrInvitationReplyRequest;
 import com.usky.vpp.service.vo.DrInvitationRequest;
 import com.usky.vpp.service.vo.DrInvitationVO;
 import com.usky.vpp.util.VppAuditHelper;
+import com.usky.vpp.util.VppDrParticipationHelper;
 import com.usky.vpp.util.VppPageHelper;
 import com.usky.vpp.util.VppSmsTemplateHelper;
 import org.slf4j.Logger;
@@ -334,6 +335,7 @@ public class VppDrInvitationServiceImpl implements VppDrInvitationService {
             existing.setParticipateStatus(PARTICIPATE_ACCEPT);
             existing.setDeclaredCapacityKw(declaredCapacityKw);
             existing.setDeclaredAt(LocalDateTime.now());
+            VppDrParticipationHelper.refreshCompletionRate(existing);
             VppAuditHelper.fillUpdate(existing);
             participationMapper.updateById(existing);
             return existing;
@@ -345,6 +347,7 @@ public class VppDrInvitationServiceImpl implements VppDrInvitationService {
         participation.setParticipateStatus(PARTICIPATE_ACCEPT);
         participation.setDeclaredCapacityKw(declaredCapacityKw);
         participation.setDeclaredAt(LocalDateTime.now());
+        VppDrParticipationHelper.refreshCompletionRate(participation);
         VppAuditHelper.fillCreate(participation);
         participationMapper.insert(participation);
         return participation;

+ 98 - 11
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDrServiceImpl.java

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 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.vpp.domain.VppCustomer;
 import com.usky.vpp.domain.VppDrEvaluation;
 import com.usky.vpp.domain.VppDrEvent;
 import com.usky.vpp.domain.VppDrExecution;
@@ -11,6 +12,7 @@ import com.usky.vpp.domain.VppDrParticipation;
 import com.usky.vpp.domain.VppDrStrategy;
 import com.usky.vpp.domain.VppDrStrategyResource;
 import com.usky.vpp.domain.VppResourcePoint;
+import com.usky.vpp.mapper.VppCustomerMapper;
 import com.usky.vpp.mapper.VppDrEvaluationMapper;
 import com.usky.vpp.mapper.VppDrEventMapper;
 import com.usky.vpp.mapper.VppDrExecutionMapper;
@@ -21,12 +23,15 @@ import com.usky.vpp.mapper.VppResourcePointMapper;
 import com.usky.vpp.service.VppDrService;
 import com.usky.vpp.service.VppUnIntegrationService;
 import com.usky.vpp.service.vo.DrClearingRequest;
+import com.usky.vpp.service.vo.DrEventDetailVO;
 import com.usky.vpp.service.vo.DrEventRequest;
+import com.usky.vpp.service.vo.DrParticipationVO;
 import com.usky.vpp.service.vo.DrInterveneRequest;
 import com.usky.vpp.service.vo.DrParticipateRequest;
 import com.usky.vpp.service.vo.DrStrategyRequest;
 import com.usky.vpp.service.vo.DrStrategyVO;
 import com.usky.vpp.util.VppAuditHelper;
+import com.usky.vpp.util.VppDrParticipationHelper;
 import com.usky.vpp.util.VppPageHelper;
 import com.usky.vpp.util.VppSiteResourceHelper;
 import org.slf4j.Logger;
@@ -64,6 +69,8 @@ public class VppDrServiceImpl implements VppDrService {
     @Autowired
     private VppDrParticipationMapper participationMapper;
     @Autowired
+    private VppCustomerMapper customerMapper;
+    @Autowired
     private VppDrStrategyMapper strategyMapper;
     @Autowired
     private VppDrStrategyResourceMapper strategyResourceMapper;
@@ -108,7 +115,12 @@ public class VppDrServiceImpl implements VppDrService {
     }
 
     @Override
-    public VppDrEvent getEvent(Long id) {
+    public DrEventDetailVO getEvent(Long id) {
+        VppDrEvent event = requireEvent(id);
+        return toEventDetailVo(event);
+    }
+
+    private VppDrEvent requireEvent(Long id) {
         VppDrEvent event = eventMapper.selectById(id);
         if (event == null || VppAuditHelper.isDeleted(event.getDeleteFlag())) {
             throw new BusinessException("需求响应事件不存在");
@@ -145,7 +157,7 @@ public class VppDrServiceImpl implements VppDrService {
     @Override
     @Transactional(rollbackFor = Exception.class)
     public void updateEvent(Long id, DrEventRequest request) {
-        VppDrEvent event = getEvent(id);
+        VppDrEvent event = requireEvent(id);
         if (event.getEventStatus() != EVENT_STATUS_PENDING) {
             throw new BusinessException("仅待参与状态的事件可修改");
         }
@@ -170,7 +182,7 @@ public class VppDrServiceImpl implements VppDrService {
     @Override
     @Transactional(rollbackFor = Exception.class)
     public void deleteEvent(Long id) {
-        VppDrEvent event = getEvent(id);
+        VppDrEvent event = requireEvent(id);
         if (event.getEventStatus() != EVENT_STATUS_PENDING && event.getEventStatus() != EVENT_STATUS_CANCELLED) {
             throw new BusinessException("仅待参与或已取消状态的事件可删除");
         }
@@ -184,7 +196,7 @@ public class VppDrServiceImpl implements VppDrService {
 
     @Override
     public Object assessCapability(Long eventId) {
-        VppDrEvent event = getEvent(eventId);
+        VppDrEvent event = requireEvent(eventId);
 
         LambdaQueryWrapper<VppResourcePoint> resourceWrapper = new LambdaQueryWrapper<VppResourcePoint>()
                 .eq(VppResourcePoint::getDeleteFlag, VppAuditHelper.NOT_DELETED)
@@ -232,7 +244,7 @@ public class VppDrServiceImpl implements VppDrService {
     @Override
     @Transactional(rollbackFor = Exception.class)
     public void participate(Long eventId, DrParticipateRequest request) {
-        VppDrEvent event = getEvent(eventId);
+        VppDrEvent event = requireEvent(eventId);
 
         if (event.getEventStatus() != EVENT_STATUS_PENDING) {
             throw new BusinessException("当前状态不允许参与");
@@ -303,6 +315,7 @@ public class VppDrServiceImpl implements VppDrService {
                 participation.setParticipateStatus(PARTICIPATE_STATUS_ACCEPT);
                 participation.setDeclaredCapacityKw(resource.getDeclaredCapacityKw());
                 participation.setDeclaredAt(LocalDateTime.now());
+                VppDrParticipationHelper.refreshCompletionRate(participation);
                 VppAuditHelper.fillCreate(participation);
                 participationMapper.insert(participation);
             }
@@ -330,7 +343,7 @@ public class VppDrServiceImpl implements VppDrService {
     @Override
     @Transactional(rollbackFor = Exception.class)
     public void clearing(Long eventId, DrClearingRequest request) {
-        VppDrEvent event = getEvent(eventId);
+        VppDrEvent event = requireEvent(eventId);
 
         if (event.getEventStatus() != EVENT_STATUS_DECLARED) {
             throw new BusinessException("当前状态不允许出清分拆");
@@ -384,6 +397,7 @@ public class VppDrServiceImpl implements VppDrService {
                 throw new BusinessException("出清容量不能超过申报容量");
             }
             participation.setClearedCapacityKw(resource.getClearedCapacityKw());
+            VppDrParticipationHelper.refreshCompletionRate(participation);
             VppAuditHelper.fillUpdate(participation);
             participationMapper.updateById(participation);
         }
@@ -418,7 +432,7 @@ public class VppDrServiceImpl implements VppDrService {
 
     @Override
     public Object monitorExecution(Long eventId) {
-        VppDrEvent event = getEvent(eventId);
+        VppDrEvent event = requireEvent(eventId);
 
         List<VppDrExecution> executions = executionMapper.selectList(
                 new LambdaQueryWrapper<VppDrExecution>()
@@ -464,7 +478,7 @@ public class VppDrServiceImpl implements VppDrService {
     @Override
     @Transactional(rollbackFor = Exception.class)
     public void intervene(Long eventId, DrInterveneRequest request) {
-        VppDrEvent event = getEvent(eventId);
+        VppDrEvent event = requireEvent(eventId);
 
         if (event.getEventStatus() != EVENT_STATUS_EXECUTING) {
             throw new BusinessException("仅执行中的事件允许干预");
@@ -523,7 +537,7 @@ public class VppDrServiceImpl implements VppDrService {
     @Override
     @Transactional(rollbackFor = Exception.class)
     public void completeEvent(Long eventId) {
-        VppDrEvent event = getEvent(eventId);
+        VppDrEvent event = requireEvent(eventId);
         if (event.getEventStatus() != EVENT_STATUS_EXECUTING) {
             throw new BusinessException("仅执行中的事件可结束");
         }
@@ -586,7 +600,7 @@ public class VppDrServiceImpl implements VppDrService {
     @Override
     @Transactional(rollbackFor = Exception.class)
     public void acknowledgeClearing(Long eventId) {
-        VppDrEvent event = getEvent(eventId);
+        VppDrEvent event = requireEvent(eventId);
         if (event.getEventStatus() != EVENT_STATUS_DECLARED) {
             throw new BusinessException("当前状态不允许确认出清公示");
         }
@@ -595,7 +609,7 @@ public class VppDrServiceImpl implements VppDrService {
 
     @Override
     public VppDrEvaluation getEvaluation(Long eventId) {
-        VppDrEvent event = getEvent(eventId);
+        VppDrEvent event = requireEvent(eventId);
 
         if (event.getEventStatus() != EVENT_STATUS_ENDED) {
             throw new BusinessException("事件未结束,暂无评估结果");
@@ -827,6 +841,79 @@ public class VppDrServiceImpl implements VppDrService {
                         .eq(VppDrEvaluation::getDeleteFlag, VppAuditHelper.NOT_DELETED)) > 0;
     }
 
+    private DrEventDetailVO toEventDetailVo(VppDrEvent event) {
+        DrEventDetailVO vo = new DrEventDetailVO();
+        vo.setId(event.getId());
+        vo.setEventId(event.getEventId());
+        vo.setEventName(event.getEventName());
+        vo.setResponseType(event.getResponseType());
+        vo.setEventType(event.getEventType());
+        vo.setStartTime(event.getStartTime());
+        vo.setEndTime(event.getEndTime());
+        vo.setTargetCapacityKw(event.getTargetCapacityKw());
+        vo.setClearedCapacityKw(event.getClearedCapacityKw());
+        vo.setSubsidyPrice(event.getSubsidyPrice());
+        vo.setEventStatus(event.getEventStatus());
+        vo.setParticipations(loadParticipationVos(event.getId()));
+        return vo;
+    }
+
+    private List<DrParticipationVO> loadParticipationVos(Long eventId) {
+        List<VppDrParticipation> participations = participationMapper.selectList(
+                new LambdaQueryWrapper<VppDrParticipation>()
+                        .eq(VppDrParticipation::getEventId, eventId)
+                        .eq(VppDrParticipation::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                        .orderByAsc(VppDrParticipation::getDeclaredAt, VppDrParticipation::getId));
+        if (participations.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        Set<Long> customerIds = participations.stream()
+                .map(VppDrParticipation::getCustomerId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        Set<Long> resourceIds = participations.stream()
+                .map(VppDrParticipation::getResourceId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+
+        Map<Long, VppCustomer> customerMap = customerIds.isEmpty()
+                ? Collections.emptyMap()
+                : customerMapper.selectBatchIds(customerIds).stream()
+                .filter(c -> !VppAuditHelper.isDeleted(c.getDeleteFlag()))
+                .collect(Collectors.toMap(VppCustomer::getId, c -> c, (a, b) -> a));
+
+        Map<Long, VppResourcePoint> resourceMap = resourceIds.isEmpty()
+                ? Collections.emptyMap()
+                : resourcePointMapper.selectBatchIds(resourceIds).stream()
+                .filter(r -> !VppAuditHelper.isDeleted(r.getDeleteFlag()))
+                .collect(Collectors.toMap(VppResourcePoint::getId, r -> r, (a, b) -> a));
+
+        return participations.stream().map(p -> {
+            DrParticipationVO item = new DrParticipationVO();
+            item.setId(p.getId());
+            item.setCustomerId(p.getCustomerId());
+            item.setResourceId(p.getResourceId());
+            item.setParticipateStatus(p.getParticipateStatus());
+            item.setDeclaredCapacityKw(p.getDeclaredCapacityKw());
+            item.setClearedCapacityKw(p.getClearedCapacityKw());
+            item.setCompletionRate(p.getCompletionRate() != null
+                    ? p.getCompletionRate()
+                    : VppDrParticipationHelper.calcCompletionRate(
+                            p.getClearedCapacityKw(), p.getDeclaredCapacityKw()));
+            item.setDeclaredAt(p.getDeclaredAt());
+            VppCustomer customer = customerMap.get(p.getCustomerId());
+            if (customer != null) {
+                item.setCustomerName(customer.getCustomerName());
+            }
+            VppResourcePoint resource = resourceMap.get(p.getResourceId());
+            if (resource != null) {
+                item.setResourceName(resource.getResourceName());
+            }
+            return item;
+        }).collect(Collectors.toList());
+    }
+
     private DrStrategyVO toStrategyVo(VppDrStrategy strategy) {
         DrStrategyVO vo = new DrStrategyVO();
         vo.setId(strategy.getId());

+ 897 - 3
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppReportInfoServiceImpl.java

@@ -1,11 +1,905 @@
 package com.usky.vpp.service.impl;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.usky.common.core.bean.CommonPage;
+import com.usky.common.security.utils.SecurityUtils;
+import com.usky.common.core.exception.BusinessException;
+import com.usky.vpp.config.VppReportProperties;
+import com.usky.vpp.domain.VppCustomer;
+import com.usky.vpp.domain.VppDrEvaluation;
+import com.usky.vpp.domain.VppDrEvent;
+import com.usky.vpp.domain.VppEnergySummaryDaily;
+import com.usky.vpp.domain.VppReportAuditLog;
+import com.usky.vpp.domain.VppReportData;
+import com.usky.vpp.domain.VppReportLog;
+import com.usky.vpp.domain.VppReportRecord;
+import com.usky.vpp.domain.VppReportTask;
+import com.usky.vpp.domain.VppResourcePoint;
+import com.usky.vpp.domain.VppSite;
+import com.usky.vpp.mapper.VppCustomerMapper;
+import com.usky.vpp.mapper.VppDrEvaluationMapper;
+import com.usky.vpp.mapper.VppDrEventMapper;
+import com.usky.vpp.mapper.VppEnergySummaryDailyMapper;
+import com.usky.vpp.mapper.VppReportAuditLogMapper;
+import com.usky.vpp.mapper.VppReportDataMapper;
+import com.usky.vpp.mapper.VppReportLogMapper;
+import com.usky.vpp.mapper.VppReportRecordMapper;
+import com.usky.vpp.mapper.VppReportTaskMapper;
+import com.usky.vpp.mapper.VppResourcePointMapper;
+import com.usky.vpp.mapper.VppSiteMapper;
 import com.usky.vpp.service.VppReportInfoService;
+import com.usky.vpp.service.vo.ReportAuditLogVO;
+import com.usky.vpp.service.vo.ReportAuditRequest;
+import com.usky.vpp.service.vo.ReportDataSaveRequest;
+import com.usky.vpp.service.vo.ReportPrefillVO;
+import com.usky.vpp.service.vo.ReportRecordVO;
+import com.usky.vpp.service.vo.ReportTaskCreateRequest;
+import com.usky.vpp.service.vo.ReportTaskCreateResultVO;
+import com.usky.vpp.service.vo.ReportTaskVO;
+import com.usky.vpp.service.vo.ReportValidateResultVO;
+import com.usky.vpp.util.VppAuditHelper;
+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.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
 
-/**
- * VppReportInfoService 默认实现(待按业务完善)
- */
 @Service
 public class VppReportInfoServiceImpl implements VppReportInfoService {
+
+    private static final DateTimeFormatter DEADLINE_FMT = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
+
+    @Autowired
+    private VppReportTaskMapper taskMapper;
+    @Autowired
+    private VppReportRecordMapper recordMapper;
+    @Autowired
+    private VppReportDataMapper dataMapper;
+    @Autowired
+    private VppReportAuditLogMapper auditLogMapper;
+    @Autowired
+    private VppReportLogMapper reportLogMapper;
+    @Autowired
+    private VppCustomerMapper customerMapper;
+    @Autowired
+    private VppResourcePointMapper resourcePointMapper;
+    @Autowired
+    private VppSiteMapper siteMapper;
+    @Autowired
+    private VppEnergySummaryDailyMapper energySummaryDailyMapper;
+    @Autowired
+    private VppDrEventMapper drEventMapper;
+    @Autowired
+    private VppDrEvaluationMapper drEvaluationMapper;
+    @Autowired
+    private VppReportProperties reportProperties;
+
+    @Override
+    public CommonPage<ReportTaskVO> pageTask(Map<String, Object> params) {
+        Page<VppReportTask> page = VppPageHelper.of(params);
+        LambdaQueryWrapper<VppReportTask> wrapper = activeTaskWrapper();
+        applyTaskFilters(wrapper, params);
+        wrapper.orderByAsc(VppReportTask::getDeadline);
+        Page<VppReportTask> result = taskMapper.selectPage(page, wrapper);
+        List<ReportTaskVO> records = result.getRecords().stream()
+                .map(this::toTaskVo)
+                .collect(Collectors.toList());
+        refreshOverdueTasks(result.getRecords());
+        return new CommonPage<>(records, result.getTotal(), result.getCurrent(), result.getSize());
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public ReportTaskCreateResultVO createTask(ReportTaskCreateRequest request) {
+        validateCreateTaskRequest(request);
+        Integer tenantId = resolveCurrentTenantId();
+        List<Long> siteIds = validateAndNormalizeSiteIds(request.getSiteIds(), tenantId);
+
+        String taskNo = VppReportHelper.nextTaskNo();
+        ensureTaskNoUnique(taskNo);
+
+        VppReportTask task = new VppReportTask();
+        task.setTaskNo(taskNo);
+        task.setTaskName(request.getTaskName().trim());
+        task.setReportOrg(request.getReportOrg());
+        task.setReportCycle(request.getReportCycle());
+        task.setReportType(request.getReportType().trim());
+        task.setDeadline(request.getDeadline());
+        task.setTaskStatus(VppReportHelper.TASK_STATUS_PENDING_FILL);
+        task.setAssigneeId(request.getAssigneeId());
+        task.setRemindDays(request.getRemindDays() != null ? request.getRemindDays() : 3);
+        task.setSiteIds(VppReportHelper.toJson(siteIds));
+        VppAuditHelper.fillCreate(task);
+        taskMapper.insert(task);
+
+        String reportPeriod = resolveReportPeriod(request);
+        String recordNo = VppReportHelper.nextRecordNo(reportPeriod);
+        ensureRecordNoUnique(recordNo);
+
+        VppReportRecord record = new VppReportRecord();
+        record.setTaskId(task.getId());
+        record.setRecordNo(recordNo);
+        record.setReportPeriod(reportPeriod);
+        record.setSubmitMode(request.getSubmitMode() != null
+                ? request.getSubmitMode() : VppReportHelper.SUBMIT_MODE_API);
+        record.setRecordStatus(VppReportHelper.RECORD_STATUS_DRAFT);
+        record.setRetryCount(0);
+        VppAuditHelper.fillCreate(record);
+        recordMapper.insert(record);
+
+        ReportTaskCreateResultVO result = new ReportTaskCreateResultVO();
+        result.setTaskId(task.getId());
+        result.setTaskNo(task.getTaskNo());
+        result.setRecordId(record.getId());
+        result.setRecordNo(record.getRecordNo());
+        result.setReportPeriod(record.getReportPeriod());
+        result.setTaskStatus(task.getTaskStatus());
+        result.setRecordStatus(record.getRecordStatus());
+        result.setSiteIds(siteIds);
+        return result;
+    }
+
+    @Override
+    public ReportRecordVO getRecord(Long id) {
+        VppReportRecord record = requireRecord(id);
+        VppReportTask task = requireTask(record.getTaskId());
+        VppReportData data = loadReportData(record.getId());
+        return toRecordVo(record, task, data, loadAuditLogs(record.getId()));
+    }
+
+    @Override
+    public ReportPrefillVO prefill(Long id) {
+        VppReportRecord record = requireRecord(id);
+        VppReportTask task = requireTask(record.getTaskId());
+        return buildPrefillData(record, task);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> saveData(Long id, ReportDataSaveRequest request) {
+        if (request == null || request.getFormData() == null || request.getFormData().isEmpty()) {
+            throw new BusinessException("填报数据不能为空");
+        }
+        if (request.getIsDraft() == null) {
+            throw new BusinessException("isDraft 不能为空");
+        }
+        VppReportRecord record = requireRecord(id);
+        if (record.getRecordStatus() != VppReportHelper.RECORD_STATUS_DRAFT
+                && record.getRecordStatus() != VppReportHelper.RECORD_STATUS_FAILED) {
+            throw new BusinessException("当前状态不允许修改填报数据");
+        }
+
+        VppReportData data = loadReportData(record.getId());
+        String formJson = VppReportHelper.toJson(request.getFormData());
+        if (data == null) {
+            data = new VppReportData();
+            data.setRecordId(record.getId());
+            data.setFormData(formJson);
+            data.setIsDraft(Boolean.TRUE.equals(request.getIsDraft()) ? 1 : 0);
+            VppAuditHelper.fillCreate(data);
+            dataMapper.insert(data);
+        } else {
+            data.setFormData(formJson);
+            data.setIsDraft(Boolean.TRUE.equals(request.getIsDraft()) ? 1 : 0);
+            data.setValidationErrors(null);
+            VppAuditHelper.fillUpdate(data);
+            dataMapper.updateById(data);
+        }
+
+        if (record.getRecordStatus() == VppReportHelper.RECORD_STATUS_FAILED) {
+            record.setRecordStatus(VppReportHelper.RECORD_STATUS_DRAFT);
+            VppAuditHelper.fillUpdate(record);
+            recordMapper.updateById(record);
+        }
+
+        Map<String, Object> result = new HashMap<>(2);
+        result.put("recordId", record.getId());
+        result.put("isDraft", request.getIsDraft());
+        return result;
+    }
+
+    @Override
+    public ReportValidateResultVO validateData(Long id) {
+        VppReportRecord record = requireRecord(id);
+        VppReportTask task = requireTask(record.getTaskId());
+        VppReportData data = requireReportData(record.getId());
+        Map<String, Object> formData = VppReportHelper.parseFormData(data.getFormData());
+        Integer tenantId = resolveTenantId(record, task);
+        List<Long> siteIds = resolveScopedSiteIds(task);
+        int systemPvCount = countResourceType("PV", tenantId, siteIds);
+        ReportValidateResultVO result = VppReportHelper.validateFormData(formData, systemPvCount, true);
+        data.setValidationErrors(VppReportHelper.toJson(result.getErrors()));
+        VppAuditHelper.fillUpdate(data);
+        dataMapper.updateById(data);
+        return result;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> submitAudit(Long id) {
+        VppReportRecord record = requireRecord(id);
+        if (record.getRecordStatus() != VppReportHelper.RECORD_STATUS_DRAFT
+                && record.getRecordStatus() != VppReportHelper.RECORD_STATUS_FAILED) {
+            throw new BusinessException("仅草稿或失败状态可提交审核");
+        }
+        VppReportData data = requireReportData(record.getId());
+        if (data.getIsDraft() != null && data.getIsDraft() == 1) {
+            throw new BusinessException("请先定稿后再提交审核");
+        }
+        ReportValidateResultVO validation = validateData(id);
+        if (!validation.isValid()) {
+            throw new BusinessException("填报数据校验未通过,请先修正");
+        }
+
+        record.setRecordStatus(VppReportHelper.RECORD_STATUS_PENDING_AUDIT);
+        VppAuditHelper.fillUpdate(record);
+        recordMapper.updateById(record);
+
+        VppReportTask task = requireTask(record.getTaskId());
+        if (task.getTaskStatus() == VppReportHelper.TASK_STATUS_PENDING_FILL) {
+            task.setTaskStatus(VppReportHelper.TASK_STATUS_PENDING_AUDIT);
+            VppAuditHelper.fillUpdate(task);
+            taskMapper.updateById(task);
+        }
+
+        saveAuditLog(record.getId(), 1, VppReportHelper.AUDIT_ACTION_SUBMIT, "提交审核");
+
+        Map<String, Object> result = new HashMap<>(2);
+        result.put("recordId", record.getId());
+        result.put("recordStatus", record.getRecordStatus());
+        return result;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void audit(Long id, ReportAuditRequest request) {
+        if (request == null || request.getPassed() == null) {
+            throw new BusinessException("审核结果不能为空");
+        }
+        if (request.getAuditLevel() == null) {
+            throw new BusinessException("auditLevel 不能为空");
+        }
+        VppReportRecord record = requireRecord(id);
+        if (record.getRecordStatus() != VppReportHelper.RECORD_STATUS_PENDING_AUDIT) {
+            throw new BusinessException("仅待审核状态可审核");
+        }
+
+        int action = Boolean.TRUE.equals(request.getPassed())
+                ? VppReportHelper.AUDIT_ACTION_PASS
+                : VppReportHelper.AUDIT_ACTION_REJECT;
+        saveAuditLog(record.getId(), request.getAuditLevel(), action, request.getOpinion());
+
+        if (Boolean.TRUE.equals(request.getPassed())) {
+            return;
+        }
+        record.setRecordStatus(VppReportHelper.RECORD_STATUS_DRAFT);
+        VppAuditHelper.fillUpdate(record);
+        recordMapper.updateById(record);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public ReportRecordVO submit(Long id) {
+        VppReportRecord record = requireRecord(id);
+        if (record.getRecordStatus() != VppReportHelper.RECORD_STATUS_PENDING_AUDIT) {
+            throw new BusinessException("仅待审核且已通过审批的记录可执行报送");
+        }
+        if (!hasAuditPass(record.getId())) {
+            throw new BusinessException("请先完成审核通过后再报送");
+        }
+        requireReportData(record.getId());
+
+        int retryMax = reportProperties.getRetryMax() != null ? reportProperties.getRetryMax() : 3;
+        int retryCount = record.getRetryCount() != null ? record.getRetryCount() : 0;
+        if (retryCount >= retryMax) {
+            throw new BusinessException("已达最大重试次数");
+        }
+
+        record.setRecordStatus(VppReportHelper.RECORD_STATUS_SUBMITTING);
+        VppAuditHelper.fillUpdate(record);
+        recordMapper.updateById(record);
+
+        VppReportTask task = requireTask(record.getTaskId());
+        task.setTaskStatus(VppReportHelper.TASK_STATUS_SUBMITTING);
+        VppAuditHelper.fillUpdate(task);
+        taskMapper.updateById(task);
+
+        boolean success = executeExternalSubmit(record, task);
+        LocalDateTime now = LocalDateTime.now();
+        if (success) {
+            record.setRecordStatus(VppReportHelper.RECORD_STATUS_SUCCESS);
+            record.setSubmittedAt(now);
+            record.setReportFileUrl("/report/files/" + record.getId() + ".xlsx");
+            record.setReceiptFileUrl("/report/receipts/" + record.getId() + ".pdf");
+            record.setArchivedAt(now);
+            task.setTaskStatus(VppReportHelper.TASK_STATUS_COMPLETED);
+        } else {
+            record.setRecordStatus(VppReportHelper.RECORD_STATUS_FAILED);
+            record.setRetryCount(retryCount + 1);
+        }
+        VppAuditHelper.fillUpdate(record);
+        recordMapper.updateById(record);
+        VppAuditHelper.fillUpdate(task);
+        taskMapper.updateById(task);
+
+        return toRecordVo(record, task, loadReportData(record.getId()), loadAuditLogs(record.getId()));
+    }
+
+    @Override
+    public CommonPage<ReportRecordVO> pageHistory(Map<String, Object> params) {
+        Page<VppReportRecord> page = VppPageHelper.of(params);
+        LambdaQueryWrapper<VppReportRecord> wrapper = activeRecordWrapper();
+        applyHistoryFilters(wrapper, params);
+        wrapper.orderByDesc(VppReportRecord::getSubmittedAt, VppReportRecord::getCreateTime);
+        Page<VppReportRecord> result = recordMapper.selectPage(page, wrapper);
+
+        Map<Long, VppReportTask> taskMap = loadTaskMap(result.getRecords());
+        List<ReportRecordVO> records = result.getRecords().stream()
+                .map(record -> toHistoryVo(record, taskMap.get(record.getTaskId())))
+                .collect(Collectors.toList());
+        return new CommonPage<>(records, result.getTotal(), result.getCurrent(), result.getSize());
+    }
+
+    private ReportPrefillVO buildPrefillData(VppReportRecord record, VppReportTask task) {
+        Integer tenantId = resolveTenantId(record, task);
+        List<Long> scopedSiteIds = resolveScopedSiteIds(task);
+        VppReportHelper.PeriodRange period = VppReportHelper.parseReportPeriod(record.getReportPeriod());
+
+        List<VppSite> sites = listActiveSites(tenantId, scopedSiteIds);
+        List<VppCustomer> customers = listScopedCustomers(tenantId, sites);
+        List<VppResourcePoint> resources = listScopedResources(tenantId, sites);
+        List<Long> resourceIds = resources.stream()
+                .map(VppResourcePoint::getId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toList());
+
+        List<VppEnergySummaryDaily> energyRows = listEnergyRows(period, resourceIds);
+        List<VppDrEvent> drEvents = listDrEvents(period, tenantId);
+        List<VppDrEvaluation> evaluations = listDrEvaluations(drEvents);
+        return VppReportHelper.buildPrefill(record, task, customers, resources, sites,
+                energyRows, drEvents, evaluations);
+    }
+
+    private boolean executeExternalSubmit(VppReportRecord record, VppReportTask task) {
+        VppReportLog log = new VppReportLog();
+        log.setReportType(task.getReportType());
+        log.setRequestId(record.getRecordNo());
+        log.setPayloadSize(0);
+        log.setReportedAt(LocalDateTime.now());
+        VppAuditHelper.fillCreate(log);
+        try {
+            log.setReportStatus(VppReportHelper.RECORD_STATUS_SUCCESS);
+            log.setResponseCode(200);
+            reportLogMapper.insert(log);
+            return true;
+        } catch (Exception ex) {
+            log.setReportStatus(VppReportHelper.RECORD_STATUS_FAILED);
+            log.setResponseCode(500);
+            log.setErrorMessage(ex.getMessage());
+            reportLogMapper.insert(log);
+            return false;
+        }
+    }
+
+    private boolean hasAuditPass(Long recordId) {
+        LambdaQueryWrapper<VppReportAuditLog> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(VppReportAuditLog::getRecordId, recordId)
+                .eq(VppReportAuditLog::getAction, VppReportHelper.AUDIT_ACTION_PASS)
+                .orderByDesc(VppReportAuditLog::getCreateTime)
+                .last("LIMIT 1");
+        return auditLogMapper.selectOne(wrapper) != null;
+    }
+
+    private void saveAuditLog(Long recordId, int auditLevel, int action, String opinion) {
+        VppReportAuditLog log = new VppReportAuditLog();
+        log.setRecordId(recordId);
+        log.setAuditLevel(auditLevel);
+        log.setAction(action);
+        log.setOpinion(opinion);
+        VppAuditHelper.fillCreate(log);
+        auditLogMapper.insert(log);
+    }
+
+    private void refreshOverdueTasks(List<VppReportTask> tasks) {
+        LocalDateTime now = LocalDateTime.now();
+        for (VppReportTask task : tasks) {
+            if (task.getDeadline() != null
+                    && task.getDeadline().isBefore(now)
+                    && task.getTaskStatus() != null
+                    && task.getTaskStatus() < VppReportHelper.TASK_STATUS_COMPLETED) {
+                task.setTaskStatus(VppReportHelper.TASK_STATUS_OVERDUE);
+                VppAuditHelper.fillUpdate(task);
+                taskMapper.updateById(task);
+            }
+        }
+    }
+
+    private ReportTaskVO toTaskVo(VppReportTask task) {
+        ReportTaskVO vo = new ReportTaskVO();
+        vo.setId(task.getId());
+        vo.setTaskNo(task.getTaskNo());
+        vo.setTaskName(task.getTaskName());
+        vo.setReportOrg(task.getReportOrg());
+        vo.setReportOrgName(VppReportHelper.resolveReportOrgName(task.getReportOrg()));
+        vo.setReportCycle(task.getReportCycle());
+        vo.setReportType(task.getReportType());
+        vo.setDeadline(task.getDeadline());
+        vo.setTaskStatus(task.getTaskStatus());
+        vo.setAssigneeId(task.getAssigneeId());
+        vo.setAssigneeName(task.getCreatedBy());
+        vo.setRemindDays(task.getRemindDays());
+        vo.setSiteIds(resolveScopedSiteIds(task));
+        return vo;
+    }
+
+    private ReportRecordVO toRecordVo(VppReportRecord record,
+                                      VppReportTask task,
+                                      VppReportData data,
+                                      List<VppReportAuditLog> auditLogs) {
+        ReportRecordVO vo = new ReportRecordVO();
+        vo.setId(record.getId());
+        vo.setTaskId(record.getTaskId());
+        vo.setTaskName(task.getTaskName());
+        vo.setRecordNo(record.getRecordNo());
+        vo.setReportPeriod(record.getReportPeriod());
+        vo.setReportOrg(task.getReportOrg());
+        vo.setReportOrgName(VppReportHelper.resolveReportOrgName(task.getReportOrg()));
+        vo.setSubmitMode(record.getSubmitMode());
+        vo.setSiteIds(resolveScopedSiteIds(task));
+        vo.setRecordStatus(record.getRecordStatus());
+        vo.setReportFileUrl(record.getReportFileUrl());
+        vo.setReceiptFileUrl(record.getReceiptFileUrl());
+        vo.setRetryCount(record.getRetryCount());
+        vo.setSubmittedAt(record.getSubmittedAt());
+        if (data != null) {
+            vo.setFormData(VppReportHelper.parseFormData(data.getFormData()));
+            vo.setIsDraft(data.getIsDraft() != null && data.getIsDraft() == 1);
+        } else {
+            vo.setIsDraft(true);
+        }
+        vo.setAuditLogs(auditLogs.stream().map(this::toAuditLogVo).collect(Collectors.toList()));
+        return vo;
+    }
+
+    private ReportRecordVO toHistoryVo(VppReportRecord record, VppReportTask task) {
+        ReportRecordVO vo = new ReportRecordVO();
+        vo.setId(record.getId());
+        vo.setRecordNo(record.getRecordNo());
+        vo.setReportPeriod(record.getReportPeriod());
+        vo.setRecordStatus(record.getRecordStatus());
+        vo.setSubmittedAt(record.getSubmittedAt());
+        vo.setReceiptFileUrl(record.getReceiptFileUrl());
+        if (task != null) {
+            vo.setTaskName(task.getTaskName());
+            vo.setReportOrg(task.getReportOrg());
+            vo.setReportOrgName(VppReportHelper.resolveReportOrgName(task.getReportOrg()));
+        }
+        return vo;
+    }
+
+    private ReportAuditLogVO toAuditLogVo(VppReportAuditLog log) {
+        ReportAuditLogVO vo = new ReportAuditLogVO();
+        vo.setId(log.getId());
+        vo.setAuditLevel(log.getAuditLevel());
+        vo.setAction(log.getAction());
+        vo.setOpinion(log.getOpinion());
+        vo.setOperatorName(log.getCreatedBy());
+        vo.setOperatedAt(log.getCreateTime());
+        return vo;
+    }
+
+    private Map<Long, VppReportTask> loadTaskMap(List<VppReportRecord> records) {
+        if (records == null || records.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        List<Long> taskIds = records.stream()
+                .map(VppReportRecord::getTaskId)
+                .filter(Objects::nonNull)
+                .distinct()
+                .collect(Collectors.toList());
+        if (taskIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        LambdaQueryWrapper<VppReportTask> wrapper = activeTaskWrapper();
+        wrapper.in(VppReportTask::getId, taskIds);
+        return taskMapper.selectList(wrapper).stream()
+                .collect(Collectors.toMap(VppReportTask::getId, task -> task, (a, b) -> a));
+    }
+
+    private List<VppReportAuditLog> loadAuditLogs(Long recordId) {
+        LambdaQueryWrapper<VppReportAuditLog> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(VppReportAuditLog::getRecordId, recordId)
+                .orderByAsc(VppReportAuditLog::getCreateTime);
+        return auditLogMapper.selectList(wrapper);
+    }
+
+    private VppReportData loadReportData(Long recordId) {
+        LambdaQueryWrapper<VppReportData> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(VppReportData::getRecordId, recordId)
+                .eq(VppReportData::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .last("LIMIT 1");
+        return dataMapper.selectOne(wrapper);
+    }
+
+    private VppReportData requireReportData(Long recordId) {
+        VppReportData data = loadReportData(recordId);
+        if (data == null) {
+            throw new BusinessException("请先保存填报数据");
+        }
+        return data;
+    }
+
+    private VppReportRecord requireRecord(Long id) {
+        if (id == null) {
+            throw new BusinessException("报送记录ID不能为空");
+        }
+        LambdaQueryWrapper<VppReportRecord> wrapper = activeRecordWrapper();
+        wrapper.eq(VppReportRecord::getId, id);
+        VppReportRecord record = recordMapper.selectOne(wrapper);
+        if (record == null) {
+            throw new BusinessException("报送记录不存在");
+        }
+        return record;
+    }
+
+    private VppReportTask requireTask(Long taskId) {
+        if (taskId == null) {
+            throw new BusinessException("报送任务不存在");
+        }
+        LambdaQueryWrapper<VppReportTask> wrapper = activeTaskWrapper();
+        wrapper.eq(VppReportTask::getId, taskId);
+        VppReportTask task = taskMapper.selectOne(wrapper);
+        if (task == null) {
+            throw new BusinessException("报送任务不存在");
+        }
+        return task;
+    }
+
+    private LambdaQueryWrapper<VppReportTask> activeTaskWrapper() {
+        LambdaQueryWrapper<VppReportTask> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(VppReportTask::getDeleteFlag, VppAuditHelper.NOT_DELETED);
+        return wrapper;
+    }
+
+    private LambdaQueryWrapper<VppReportRecord> activeRecordWrapper() {
+        LambdaQueryWrapper<VppReportRecord> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(VppReportRecord::getDeleteFlag, VppAuditHelper.NOT_DELETED);
+        return wrapper;
+    }
+
+    private void applyTaskFilters(LambdaQueryWrapper<VppReportTask> wrapper, Map<String, Object> params) {
+        if (params == null) {
+            return;
+        }
+        Integer taskStatus = parseInt(params.get("taskStatus"));
+        if (taskStatus != null) {
+            wrapper.eq(VppReportTask::getTaskStatus, taskStatus);
+        }
+        Integer reportOrg = parseInt(params.get("reportOrg"));
+        if (reportOrg != null) {
+            wrapper.eq(VppReportTask::getReportOrg, reportOrg);
+        }
+        Integer reportCycle = parseInt(params.get("reportCycle"));
+        if (reportCycle != null) {
+            wrapper.eq(VppReportTask::getReportCycle, reportCycle);
+        }
+        LocalDateTime deadlineStart = parseDateTime(params.get("deadlineStart"));
+        if (deadlineStart != null) {
+            wrapper.ge(VppReportTask::getDeadline, deadlineStart);
+        }
+        LocalDateTime deadlineEnd = parseDateTime(params.get("deadlineEnd"));
+        if (deadlineEnd != null) {
+            wrapper.le(VppReportTask::getDeadline, deadlineEnd);
+        }
+    }
+
+    private void applyHistoryFilters(LambdaQueryWrapper<VppReportRecord> wrapper, Map<String, Object> params) {
+        if (params == null) {
+            return;
+        }
+        Integer recordStatus = parseInt(params.get("recordStatus"));
+        if (recordStatus != null) {
+            wrapper.eq(VppReportRecord::getRecordStatus, recordStatus);
+        }
+        String reportPeriod = parseString(params.get("reportPeriod"));
+        if (StringUtils.hasText(reportPeriod)) {
+            wrapper.eq(VppReportRecord::getReportPeriod, reportPeriod);
+        }
+        Integer reportOrg = parseInt(params.get("reportOrg"));
+        if (reportOrg != null) {
+            List<Long> taskIds = listTaskIdsByOrg(reportOrg);
+            if (taskIds.isEmpty()) {
+                wrapper.eq(VppReportRecord::getTaskId, -1L);
+            } else {
+                wrapper.in(VppReportRecord::getTaskId, taskIds);
+            }
+        }
+    }
+
+    private List<Long> listTaskIdsByOrg(Integer reportOrg) {
+        LambdaQueryWrapper<VppReportTask> wrapper = activeTaskWrapper();
+        wrapper.eq(VppReportTask::getReportOrg, reportOrg)
+                .select(VppReportTask::getId);
+        return taskMapper.selectList(wrapper).stream()
+                .map(VppReportTask::getId)
+                .collect(Collectors.toList());
+    }
+
+    private Integer resolveTenantId(VppReportRecord record, VppReportTask task) {
+        if (record != null && record.getTenantId() != null) {
+            return record.getTenantId();
+        }
+        if (task != null && task.getTenantId() != null) {
+            return task.getTenantId();
+        }
+        try {
+            return SecurityUtils.getTenantId();
+        } catch (Exception ex) {
+            return null;
+        }
+    }
+
+    private List<VppCustomer> listScopedCustomers(Integer tenantId, List<VppSite> sites) {
+        Set<Long> customerIds = sites.stream()
+                .map(VppSite::getCustomerId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toCollection(HashSet::new));
+        if (!customerIds.isEmpty()) {
+            LambdaQueryWrapper<VppCustomer> wrapper = new LambdaQueryWrapper<>();
+            wrapper.eq(VppCustomer::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                    .in(VppCustomer::getId, customerIds);
+            applyTenantFilter(wrapper, tenantId, VppCustomer::getTenantId);
+            return customerMapper.selectList(wrapper);
+        }
+        return listActiveCustomers(tenantId);
+    }
+
+    private List<VppCustomer> listActiveCustomers(Integer tenantId) {
+        LambdaQueryWrapper<VppCustomer> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(VppCustomer::getDeleteFlag, VppAuditHelper.NOT_DELETED);
+        applyTenantFilter(wrapper, tenantId, VppCustomer::getTenantId);
+        return customerMapper.selectList(wrapper);
+    }
+
+    private List<VppResourcePoint> listScopedResources(Integer tenantId, List<VppSite> sites) {
+        List<Long> siteIds = sites.stream()
+                .map(VppSite::getId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toList());
+        if (siteIds.isEmpty()) {
+            return listActiveResources(tenantId);
+        }
+        LambdaQueryWrapper<VppResourcePoint> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(VppResourcePoint::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .in(VppResourcePoint::getSiteId, siteIds);
+        applyTenantFilter(wrapper, tenantId, VppResourcePoint::getTenantId);
+        return resourcePointMapper.selectList(wrapper);
+    }
+
+    private List<VppResourcePoint> listActiveResources(Integer tenantId) {
+        LambdaQueryWrapper<VppResourcePoint> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(VppResourcePoint::getDeleteFlag, VppAuditHelper.NOT_DELETED);
+        applyTenantFilter(wrapper, tenantId, VppResourcePoint::getTenantId);
+        return resourcePointMapper.selectList(wrapper);
+    }
+
+    private List<VppSite> listActiveSites(Integer tenantId, List<Long> scopedSiteIds) {
+        LambdaQueryWrapper<VppSite> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(VppSite::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .orderByAsc(VppSite::getSiteName);
+        applyTenantFilter(wrapper, tenantId, VppSite::getTenantId);
+        if (scopedSiteIds != null && !scopedSiteIds.isEmpty()) {
+            wrapper.in(VppSite::getId, scopedSiteIds);
+        }
+        return siteMapper.selectList(wrapper);
+    }
+
+    private int countResourceType(String resourceType, Integer tenantId, List<Long> siteIds) {
+        LambdaQueryWrapper<VppResourcePoint> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(VppResourcePoint::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .eq(VppResourcePoint::getResourceType, resourceType);
+        applyTenantFilter(wrapper, tenantId, VppResourcePoint::getTenantId);
+        if (siteIds != null && !siteIds.isEmpty()) {
+            wrapper.in(VppResourcePoint::getSiteId, siteIds);
+        }
+        return resourcePointMapper.selectCount(wrapper).intValue();
+    }
+
+    private List<VppEnergySummaryDaily> listEnergyRows(VppReportHelper.PeriodRange period, List<Long> resourceIds) {
+        if (period == null) {
+            return Collections.emptyList();
+        }
+        LambdaQueryWrapper<VppEnergySummaryDaily> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(VppEnergySummaryDaily::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .ge(VppEnergySummaryDaily::getSummaryDate, period.getStart())
+                .le(VppEnergySummaryDaily::getSummaryDate, period.getEnd());
+        if (resourceIds != null && !resourceIds.isEmpty()) {
+            wrapper.in(VppEnergySummaryDaily::getResourceId, resourceIds);
+        } else {
+            return Collections.emptyList();
+        }
+        return energySummaryDailyMapper.selectList(wrapper);
+    }
+
+    private List<VppDrEvent> listDrEvents(VppReportHelper.PeriodRange period, Integer tenantId) {
+        if (period == null) {
+            return Collections.emptyList();
+        }
+        LocalDateTime start = period.getStart().atStartOfDay();
+        LocalDateTime end = period.getEnd().atTime(23, 59, 59);
+        LambdaQueryWrapper<VppDrEvent> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(VppDrEvent::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .ge(VppDrEvent::getStartTime, start)
+                .le(VppDrEvent::getStartTime, end);
+        applyTenantFilter(wrapper, tenantId, VppDrEvent::getTenantId);
+        return drEventMapper.selectList(wrapper);
+    }
+
+    private <T> void applyTenantFilter(LambdaQueryWrapper<T> wrapper, Integer tenantId,
+                                       com.baomidou.mybatisplus.core.toolkit.support.SFunction<T, ?> column) {
+        if (tenantId != null) {
+            wrapper.eq(column, tenantId);
+        }
+    }
+
+    private List<VppDrEvaluation> listDrEvaluations(List<VppDrEvent> events) {
+        if (events == null || events.isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<Long> eventIds = events.stream().map(VppDrEvent::getId).collect(Collectors.toList());
+        LambdaQueryWrapper<VppDrEvaluation> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(VppDrEvaluation::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .in(VppDrEvaluation::getEventId, eventIds);
+        return drEvaluationMapper.selectList(wrapper);
+    }
+
+    private Integer parseInt(Object value) {
+        if (value == null || !StringUtils.hasText(value.toString())) {
+            return null;
+        }
+        return Integer.parseInt(value.toString());
+    }
+
+    private String parseString(Object value) {
+        return value == null ? null : value.toString();
+    }
+
+    private LocalDateTime parseDateTime(Object value) {
+        if (value == null || !StringUtils.hasText(value.toString())) {
+            return null;
+        }
+        String text = value.toString();
+        try {
+            return LocalDateTime.parse(text, DEADLINE_FMT);
+        } catch (DateTimeParseException ex) {
+            try {
+                return LocalDate.parse(text).atStartOfDay();
+            } catch (DateTimeParseException ignored) {
+                return null;
+            }
+        }
+    }
+
+    private void validateCreateTaskRequest(ReportTaskCreateRequest request) {
+        if (request == null) {
+            throw new BusinessException("请求体不能为空");
+        }
+        if (!StringUtils.hasText(request.getTaskName())) {
+            throw new BusinessException("任务名称不能为空");
+        }
+        if (request.getReportOrg() == null || request.getReportOrg() < 1 || request.getReportOrg() > 4) {
+            throw new BusinessException("报送机构无效,取值 1~4");
+        }
+        if (request.getReportCycle() == null || request.getReportCycle() < 1 || request.getReportCycle() > 3) {
+            throw new BusinessException("报送周期无效,取值 1~3");
+        }
+        if (!StringUtils.hasText(request.getReportType())) {
+            throw new BusinessException("报表类型编码不能为空");
+        }
+        if (request.getDeadline() == null) {
+            throw new BusinessException("截止日期不能为空");
+        }
+        if (request.getSubmitMode() != null
+                && request.getSubmitMode() != VppReportHelper.SUBMIT_MODE_API
+                && request.getSubmitMode() != VppReportHelper.SUBMIT_MODE_MANUAL) {
+            throw new BusinessException("报送方式无效,取值 1 或 2");
+        }
+        String reportPeriod = resolveReportPeriod(request);
+        if (request.getReportCycle() == 1 && !reportPeriod.matches("\\d{4}-\\d{2}")) {
+            throw new BusinessException("月报报送周期须为 YYYY-MM 格式");
+        }
+        if (request.getReportCycle() == 2 && !reportPeriod.matches("\\d{4}")) {
+            throw new BusinessException("年报报送周期须为 YYYY 格式");
+        }
+        if (request.getSiteIds() == null || request.getSiteIds().isEmpty()) {
+            throw new BusinessException("请至少选择一个关联站点");
+        }
+    }
+
+    private Integer resolveCurrentTenantId() {
+        try {
+            return SecurityUtils.getTenantId();
+        } catch (Exception ex) {
+            return null;
+        }
+    }
+
+    private List<Long> resolveScopedSiteIds(VppReportTask task) {
+        if (task == null) {
+            return Collections.emptyList();
+        }
+        return VppReportHelper.parseSiteIds(task.getSiteIds());
+    }
+
+    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("请至少选择一个关联站点");
+        }
+        List<VppSite> sites = siteMapper.selectBatchIds(distinct);
+        Map<Long, VppSite> siteMap = sites.stream()
+                .filter(site -> site.getDeleteFlag() == null
+                        || Objects.equals(site.getDeleteFlag(), VppAuditHelper.NOT_DELETED))
+                .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 String resolveReportPeriod(ReportTaskCreateRequest request) {
+        if (StringUtils.hasText(request.getReportPeriod())) {
+            return request.getReportPeriod().trim();
+        }
+        if (request.getDeadline() != null) {
+            if (request.getReportCycle() != null && request.getReportCycle() == 2) {
+                return String.valueOf(request.getDeadline().getYear());
+            }
+            return String.format("%04d-%02d", request.getDeadline().getYear(), request.getDeadline().getMonthValue());
+        }
+        throw new BusinessException("报送周期标识不能为空");
+    }
+
+    private void ensureTaskNoUnique(String taskNo) {
+        LambdaQueryWrapper<VppReportTask> wrapper = activeTaskWrapper();
+        wrapper.eq(VppReportTask::getTaskNo, taskNo);
+        if (taskMapper.selectCount(wrapper) > 0) {
+            throw new BusinessException("任务编号冲突,请重试");
+        }
+    }
+
+    private void ensureRecordNoUnique(String recordNo) {
+        LambdaQueryWrapper<VppReportRecord> wrapper = activeRecordWrapper();
+        wrapper.eq(VppReportRecord::getRecordNo, recordNo);
+        if (recordMapper.selectCount(wrapper) > 0) {
+            throw new BusinessException("报送记录编号冲突,请重试");
+        }
+    }
 }

+ 86 - 13
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppResourceBoardServiceImpl.java

@@ -4,20 +4,23 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 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.VppDevice;
 import com.usky.vpp.domain.VppResourcePoint;
 import com.usky.vpp.domain.VppSite;
 import com.usky.vpp.mapper.VppDeviceMapper;
 import com.usky.vpp.mapper.VppResourcePointMapper;
 import com.usky.vpp.mapper.VppSiteMapper;
+import com.usky.vpp.constant.VppTsdbConstants;
 import com.usky.vpp.service.VppResourceBoardService;
+import com.usky.vpp.service.VppTsdbQueryService;
 import com.usky.vpp.service.vo.ResourceBoardDetailVO;
 import com.usky.vpp.service.vo.ResourceBoardItemVO;
 import com.usky.vpp.util.VppAuditHelper;
-import com.usky.vpp.util.VppPageHelper;
 import com.usky.vpp.util.VppResourceMockHelper;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
 import org.springframework.util.StringUtils;
 
 import java.math.BigDecimal;
@@ -34,6 +37,8 @@ public class VppResourceBoardServiceImpl implements VppResourceBoardService {
     private VppSiteMapper siteMapper;
     @Autowired
     private VppDeviceMapper deviceMapper;
+    @Autowired
+    private VppTsdbQueryService vppTsdbQueryService;
 
     @Override
     public CommonPage<ResourceBoardItemVO> pageBoard(Map<String, Object> params) {
@@ -58,8 +63,9 @@ public class VppResourceBoardServiceImpl implements VppResourceBoardService {
         }
         VppSite site = siteMapper.selectById(resource.getSiteId());
         VppDevice device = loadDeviceForResource(resource);
+        Map<String, BigDecimal> tsdbMetrics = loadTsdbMetrics(device);
         ResourceBoardDetailVO detail = new ResourceBoardDetailVO();
-        fillBoardItem(detail, resource, site, device);
+        fillBoardItem(detail, resource, site, device, tsdbMetrics);
         detail.setDeviceId(resource.getDeviceId());
         detail.setDeviceName(device != null ? device.getDeviceName() : null);
         detail.setDeviceCode(device != null ? device.getDeviceCode() : null);
@@ -73,7 +79,7 @@ public class VppResourceBoardServiceImpl implements VppResourceBoardService {
             detail.setContactPhone(site.getContactPhone());
         }
         if (Boolean.TRUE.equals(detail.getAbnormal())) {
-            detail.setAbnormalReason(VppResourceMockHelper.mockAbnormalReason(resource, device));
+            detail.setAbnormalReason(resolveAbnormalReason(tsdbMetrics));
         }
         detail.setDataTime(LocalDateTime.now());
         return detail;
@@ -82,6 +88,7 @@ public class VppResourceBoardServiceImpl implements VppResourceBoardService {
     private List<VppResourcePoint> loadFilteredResources(Map<String, Object> params) {
         LambdaQueryWrapper<VppResourcePoint> wrapper = new LambdaQueryWrapper<VppResourcePoint>()
                 .eq(VppResourcePoint::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .eq(VppResourcePoint::getTenantId, SecurityUtils.getTenantId())
                 .orderByDesc(VppResourcePoint::getCreateTime);
         if (params != null) {
             if (params.get("resourceType") != null) {
@@ -138,11 +145,14 @@ public class VppResourceBoardServiceImpl implements VppResourceBoardService {
                 .filter(d -> !VppAuditHelper.isDeleted(d.getDeleteFlag()))
                 .collect(Collectors.toMap(VppDevice::getId, d -> d, (a, b) -> a));
 
+        Map<String, Map<String, BigDecimal>> tsdbMetricsMap = loadTsdbMetricsMap(deviceMap.values());
+
         List<ResourceBoardItemVO> items = new ArrayList<>();
         for (VppResourcePoint resource : resources) {
             VppDevice device = resolveDeviceForResource(resource, deviceMap);
+            Map<String, BigDecimal> tsdbMetrics = resolveTsdbMetrics(device, tsdbMetricsMap);
             ResourceBoardItemVO item = new ResourceBoardItemVO();
-            fillBoardItem(item, resource, siteMap.get(resource.getSiteId()), device);
+            fillBoardItem(item, resource, siteMap.get(resource.getSiteId()), device, tsdbMetrics);
             items.add(item);
         }
         return items;
@@ -168,7 +178,8 @@ public class VppResourceBoardServiceImpl implements VppResourceBoardService {
         return device != null && siteId != null && siteId.equals(device.getSiteId());
     }
 
-    private void fillBoardItem(ResourceBoardItemVO item, VppResourcePoint resource, VppSite site, VppDevice device) {
+    private void fillBoardItem(ResourceBoardItemVO item, VppResourcePoint resource, VppSite site, VppDevice device,
+                               Map<String, BigDecimal> tsdbMetrics) {
         item.setResourceId(resource.getId());
         item.setResourceCode(resource.getResourceCode());
         item.setResourceName(resource.getResourceName());
@@ -182,18 +193,80 @@ public class VppResourceBoardServiceImpl implements VppResourceBoardService {
             item.setCity(site.getCity());
             item.setDistrict(site.getDistrict());
         }
-        boolean abnormal = VppResourceMockHelper.mockAbnormal(resource, device);
-        item.setAbnormal(abnormal);
-        item.setRunStatusLabel(VppResourceMockHelper.runStatusLabel(device, abnormal));
-        BigDecimal power = VppResourceMockHelper.mockRealtimePowerKw(resource, device);
-        BigDecimal voltage = VppResourceMockHelper.mockVoltageV(resource);
-        item.setRealtimePowerKw(power);
-        item.setVoltageV(voltage);
-        item.setCurrentA(VppResourceMockHelper.mockCurrentA(resource, power, voltage));
+        applyTsdbMetrics(item, tsdbMetrics);
         item.setSocPercent(VppResourceMockHelper.mockSocPercent(resource));
         item.setEvcsChargePowerKw(VppResourceMockHelper.mockEvcsChargePowerKw(resource));
     }
 
+    private void applyTsdbMetrics(ResourceBoardItemVO item, Map<String, BigDecimal> tsdbMetrics) {
+        BigDecimal deviceStatus = getMetric(tsdbMetrics, "device_status");
+        boolean abnormal = deviceStatus != null && deviceStatus.compareTo(BigDecimal.ZERO) == 0;
+        item.setAbnormal(abnormal);
+        item.setRunStatusLabel(resolveRunStatusLabel(deviceStatus));
+        item.setRealtimePowerKw(getMetric(tsdbMetrics, "p"));
+        item.setVoltageV(getMetric(tsdbMetrics, "ua"));
+        item.setCurrentA(getMetric(tsdbMetrics, "ia"));
+    }
+
+    private Map<String, Map<String, BigDecimal>> loadTsdbMetricsMap(Collection<VppDevice> devices) {
+        List<String> deviceUuids = devices.stream()
+                .map(VppDevice::getDeviceUuid)
+                .filter(StringUtils::hasText)
+                .distinct()
+                .collect(Collectors.toList());
+        if (deviceUuids.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        return vppTsdbQueryService.queryLatestMetrics(deviceUuids, VppTsdbConstants.BOARD_REALTIME_METRICS);
+    }
+
+    private Map<String, BigDecimal> loadTsdbMetrics(VppDevice device) {
+        if (device == null || !StringUtils.hasText(device.getDeviceUuid())) {
+            return Collections.emptyMap();
+        }
+        Map<String, Map<String, BigDecimal>> metricsMap = vppTsdbQueryService.queryLatestMetrics(
+                Collections.singletonList(device.getDeviceUuid()),
+                VppTsdbConstants.BOARD_REALTIME_METRICS);
+        return metricsMap.getOrDefault(device.getDeviceUuid(), Collections.emptyMap());
+    }
+
+    private Map<String, BigDecimal> resolveTsdbMetrics(VppDevice device, Map<String, Map<String, BigDecimal>> tsdbMetricsMap) {
+        if (device == null || !StringUtils.hasText(device.getDeviceUuid())) {
+            return Collections.emptyMap();
+        }
+        Map<String, BigDecimal> metrics = tsdbMetricsMap.get(device.getDeviceUuid());
+        return metrics != null ? metrics : Collections.emptyMap();
+    }
+
+    private BigDecimal getMetric(Map<String, BigDecimal> tsdbMetrics, String metric) {
+        if (CollectionUtils.isEmpty(tsdbMetrics)) {
+            return null;
+        }
+        return tsdbMetrics.get(metric);
+    }
+
+    private String resolveRunStatusLabel(BigDecimal deviceStatus) {
+        if (deviceStatus == null) {
+            return "未知";
+        }
+        int status = deviceStatus.intValue();
+        if (status == 0) {
+            return "离线";
+        }
+        if (status == 1) {
+            return "在线";
+        }
+        return "未知";
+    }
+
+    private String resolveAbnormalReason(Map<String, BigDecimal> tsdbMetrics) {
+        BigDecimal deviceStatus = getMetric(tsdbMetrics, "device_status");
+        if (deviceStatus != null && deviceStatus.compareTo(BigDecimal.ZERO) == 0) {
+            return "设备通信离线";
+        }
+        return "设备运行异常";
+    }
+
     private List<ResourceBoardItemVO> filterByStatus(List<ResourceBoardItemVO> items, Map<String, Object> params) {
         if (params == null || params.get("status") == null) {
             return items;

+ 116 - 11
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppResourceOverviewServiceImpl.java

@@ -1,9 +1,14 @@
 package com.usky.vpp.service.impl;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.usky.common.security.utils.SecurityUtils;
+import com.usky.vpp.constant.VppTsdbConstants;
+import com.usky.vpp.domain.VppDevice;
 import com.usky.vpp.domain.VppResourcePoint;
+import com.usky.vpp.mapper.VppDeviceMapper;
 import com.usky.vpp.mapper.VppResourcePointMapper;
 import com.usky.vpp.service.VppResourceOverviewService;
+import com.usky.vpp.service.VppTsdbQueryService;
 import com.usky.vpp.service.vo.AlarmLevelStatVO;
 import com.usky.vpp.service.vo.EnergyTodayVO;
 import com.usky.vpp.service.vo.ResourceTypeStatVO;
@@ -11,14 +16,24 @@ import com.usky.vpp.util.VppAuditHelper;
 import com.usky.vpp.util.VppResourceMockHelper;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
 
 import java.math.BigDecimal;
 import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.stream.Collectors;
 
 @Service
 public class VppResourceOverviewServiceImpl implements VppResourceOverviewService {
@@ -27,6 +42,10 @@ public class VppResourceOverviewServiceImpl implements VppResourceOverviewServic
 
     @Autowired
     private VppResourcePointMapper resourcePointMapper;
+    @Autowired
+    private VppDeviceMapper deviceMapper;
+    @Autowired
+    private VppTsdbQueryService vppTsdbQueryService;
 
     @Override
     public List<ResourceTypeStatVO> getTypeStats() {
@@ -35,7 +54,9 @@ public class VppResourceOverviewServiceImpl implements VppResourceOverviewServic
 
     @Override
     public EnergyTodayVO getEnergyToday() {
-        return buildEnergyTodayMock(listActiveResources());
+        List<VppResourcePoint> resources = listActiveResources();
+        Map<Long, VppDevice> deviceMap = loadDeviceMap(resources);
+        return buildEnergyToday(resources, deviceMap);
     }
 
     @Override
@@ -46,7 +67,8 @@ public class VppResourceOverviewServiceImpl implements VppResourceOverviewServic
     private List<VppResourcePoint> listActiveResources() {
         return resourcePointMapper.selectList(
                 new LambdaQueryWrapper<VppResourcePoint>()
-                        .eq(VppResourcePoint::getDeleteFlag, VppAuditHelper.NOT_DELETED));
+                        .eq(VppResourcePoint::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                        .eq(VppResourcePoint::getTenantId, SecurityUtils.getTenantId()));
     }
 
     private List<ResourceTypeStatVO> buildTypeStats(List<VppResourcePoint> resources) {
@@ -81,19 +103,28 @@ public class VppResourceOverviewServiceImpl implements VppResourceOverviewServic
         return new ArrayList<>(statMap.values());
     }
 
-    private EnergyTodayVO buildEnergyTodayMock(List<VppResourcePoint> resources) {
-        BigDecimal pvCapacity = resources.stream()
+    private EnergyTodayVO buildEnergyToday(List<VppResourcePoint> resources, Map<Long, VppDevice> deviceMap) {
+        List<VppResourcePoint> pvResources = resources.stream()
                 .filter(r -> "PV".equalsIgnoreCase(r.getResourceType()))
-                .map(r -> r.getCapacityKw() != null ? r.getCapacityKw() : BigDecimal.ZERO)
-                .reduce(BigDecimal.ZERO, BigDecimal::add);
-        BigDecimal loadCapacity = resources.stream()
+                .collect(Collectors.toList());
+        List<VppResourcePoint> loadResources = resources.stream()
                 .filter(r -> !"PV".equalsIgnoreCase(r.getResourceType()))
-                .map(r -> r.getCapacityKw() != null ? r.getCapacityKw() : BigDecimal.ZERO)
-                .reduce(BigDecimal.ZERO, BigDecimal::add);
+                .collect(Collectors.toList());
+
+        List<String> pvDeviceUuids = resolveDeviceUuids(pvResources, deviceMap);
+        List<String> loadDeviceUuids = resolveDeviceUuids(loadResources, deviceMap);
+
+        LocalDateTime startTime = LocalDate.now().atStartOfDay();
+        LocalDateTime endTime = LocalDateTime.now();
+
+        BigDecimal totalGeneration = queryTodayEnergy(pvDeviceUuids, startTime, endTime,
+                Collections.singletonList(VppTsdbConstants.METRIC_EDAY), true);
+        BigDecimal totalConsumption = queryTodayEnergy(loadDeviceUuids, startTime, endTime,
+                Collections.singletonList(VppTsdbConstants.METRIC_EPP), false);
 
         EnergyTodayVO energy = new EnergyTodayVO();
-        energy.setTotalGenerationKwh(pvCapacity.multiply(BigDecimal.valueOf(4.2)).setScale(2, RoundingMode.HALF_UP));
-        energy.setTotalConsumptionKwh(loadCapacity.multiply(BigDecimal.valueOf(3.6)).setScale(2, RoundingMode.HALF_UP));
+        energy.setTotalGenerationKwh(totalGeneration.setScale(2, RoundingMode.HALF_UP));
+        energy.setTotalConsumptionKwh(totalConsumption.setScale(2, RoundingMode.HALF_UP));
         if (energy.getTotalConsumptionKwh().compareTo(BigDecimal.ZERO) > 0) {
             BigDecimal ratio = energy.getTotalGenerationKwh()
                     .multiply(BigDecimal.valueOf(100))
@@ -105,6 +136,80 @@ public class VppResourceOverviewServiceImpl implements VppResourceOverviewServic
         return energy;
     }
 
+    private BigDecimal queryTodayEnergy(List<String> deviceUuids, LocalDateTime startTime, LocalDateTime endTime,
+                                          List<String> metrics, boolean useLatestValue) {
+        if (CollectionUtils.isEmpty(deviceUuids)) {
+            return BigDecimal.ZERO;
+        }
+        Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> history =
+                vppTsdbQueryService.queryDeviceMetricHistory(deviceUuids, startTime, endTime, metrics);
+        if (CollectionUtils.isEmpty(history)) {
+            return BigDecimal.ZERO;
+        }
+        String metric = metrics.get(0);
+        return useLatestValue ? sumLatestMetric(history, metric) : sumDiffMetric(history, metric);
+    }
+
+    private BigDecimal sumLatestMetric(Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> history, String metric) {
+        BigDecimal total = BigDecimal.ZERO;
+        for (Map<String, TreeMap<LocalDateTime, BigDecimal>> deviceMetrics : history.values()) {
+            TreeMap<LocalDateTime, BigDecimal> series = deviceMetrics.get(metric);
+            if (series == null || series.isEmpty()) {
+                continue;
+            }
+            total = total.add(series.lastEntry().getValue());
+        }
+        return total;
+    }
+
+    private BigDecimal sumDiffMetric(Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> history, String metric) {
+        BigDecimal total = BigDecimal.ZERO;
+        for (Map<String, TreeMap<LocalDateTime, BigDecimal>> deviceMetrics : history.values()) {
+            TreeMap<LocalDateTime, BigDecimal> series = deviceMetrics.get(metric);
+            if (series == null || series.isEmpty()) {
+                continue;
+            }
+            BigDecimal first = series.firstEntry().getValue();
+            BigDecimal last = series.lastEntry().getValue();
+            BigDecimal diff = last.subtract(first);
+            if (diff.compareTo(BigDecimal.ZERO) > 0) {
+                total = total.add(diff);
+            }
+        }
+        return total;
+    }
+
+    private Map<Long, VppDevice> loadDeviceMap(List<VppResourcePoint> resources) {
+        Set<Long> deviceIds = resources.stream()
+                .map(VppResourcePoint::getDeviceId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        if (deviceIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        return deviceMapper.selectBatchIds(deviceIds).stream()
+                .filter(d -> !VppAuditHelper.isDeleted(d.getDeleteFlag()))
+                .collect(Collectors.toMap(VppDevice::getId, d -> d, (a, b) -> a));
+    }
+
+    private List<String> resolveDeviceUuids(List<VppResourcePoint> resources, Map<Long, VppDevice> deviceMap) {
+        if (CollectionUtils.isEmpty(resources) || CollectionUtils.isEmpty(deviceMap)) {
+            return Collections.emptyList();
+        }
+        Set<String> uuids = new LinkedHashSet<>();
+        for (VppResourcePoint resource : resources) {
+            if (resource.getDeviceId() == null) {
+                continue;
+            }
+            VppDevice device = deviceMap.get(resource.getDeviceId());
+            if (device == null || !StringUtils.hasText(device.getDeviceUuid())) {
+                continue;
+            }
+            uuids.add(device.getDeviceUuid().trim());
+        }
+        return new ArrayList<>(uuids);
+    }
+
     private List<AlarmLevelStatVO> buildAlarmStatsMock() {
         List<AlarmLevelStatVO> stats = new ArrayList<>();
         stats.add(buildAlarmStat(1, "紧急", 3L));

+ 423 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppResponseMonitorServiceImpl.java

@@ -0,0 +1,423 @@
+package com.usky.vpp.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.usky.common.core.exception.BusinessException;
+import com.usky.vpp.constant.VppTsdbConstants;
+import com.usky.vpp.domain.VppCustomer;
+import com.usky.vpp.domain.VppDevice;
+import com.usky.vpp.domain.VppDrEvent;
+import com.usky.vpp.domain.VppDrExecution;
+import com.usky.vpp.domain.VppDrParticipation;
+import com.usky.vpp.domain.VppResourcePoint;
+import com.usky.vpp.domain.VppSite;
+import com.usky.vpp.mapper.VppCustomerMapper;
+import com.usky.vpp.mapper.VppDeviceMapper;
+import com.usky.vpp.mapper.VppDrEventMapper;
+import com.usky.vpp.mapper.VppDrExecutionMapper;
+import com.usky.vpp.mapper.VppDrParticipationMapper;
+import com.usky.vpp.mapper.VppResourcePointMapper;
+import com.usky.vpp.mapper.VppSiteMapper;
+import com.usky.vpp.service.VppResponseMonitorService;
+import com.usky.vpp.service.VppTsdbQueryService;
+import com.usky.vpp.service.vo.ResponseCompareSeriesVO;
+import com.usky.vpp.service.vo.ResponseCompareVO;
+import com.usky.vpp.service.vo.ResponseLoadAnalysisVO;
+import com.usky.vpp.service.vo.ResponseLoadPointVO;
+import com.usky.vpp.service.vo.ResponseLoadStatRowVO;
+import com.usky.vpp.service.vo.ResponseMonitorTreeNodeVO;
+import com.usky.vpp.util.VppAuditHelper;
+import com.usky.vpp.util.VppCapabilityEvalHelper;
+import com.usky.vpp.util.VppResponseMonitorHelper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.stream.Collectors;
+
+@Service
+public class VppResponseMonitorServiceImpl implements VppResponseMonitorService {
+
+    private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+    @Autowired
+    private VppSiteMapper siteMapper;
+    @Autowired
+    private VppCustomerMapper customerMapper;
+    @Autowired
+    private VppResourcePointMapper resourcePointMapper;
+    @Autowired
+    private VppDeviceMapper deviceMapper;
+    @Autowired
+    private VppDrEventMapper eventMapper;
+    @Autowired
+    private VppDrParticipationMapper participationMapper;
+    @Autowired
+    private VppDrExecutionMapper executionMapper;
+    @Autowired
+    private VppTsdbQueryService tsdbQueryService;
+
+    @Override
+    public List<ResponseMonitorTreeNodeVO> getResourceTree(String keyword) {
+        List<VppSite> sites = siteMapper.selectList(new LambdaQueryWrapper<VppSite>()
+                .eq(VppSite::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .orderByAsc(VppSite::getSiteName));
+        if (sites.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        Set<Long> customerIds = sites.stream()
+                .map(VppSite::getCustomerId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        Map<Long, VppCustomer> customerMap = customerIds.isEmpty()
+                ? Collections.emptyMap()
+                : customerMapper.selectBatchIds(customerIds).stream()
+                .filter(c -> !VppAuditHelper.isDeleted(c.getDeleteFlag()))
+                .collect(Collectors.toMap(VppCustomer::getId, c -> c, (a, b) -> a));
+
+        Map<Long, List<VppSite>> sitesByCustomer = new LinkedHashMap<>();
+        List<VppSite> orphanSites = new ArrayList<>();
+        for (VppSite site : sites) {
+            if (site.getCustomerId() == null) {
+                orphanSites.add(site);
+                continue;
+            }
+            sitesByCustomer.computeIfAbsent(site.getCustomerId(), k -> new ArrayList<>()).add(site);
+        }
+
+        List<ResponseMonitorTreeNodeVO> tree = new ArrayList<>();
+        for (Map.Entry<Long, List<VppSite>> entry : sitesByCustomer.entrySet()) {
+            VppCustomer customer = customerMap.get(entry.getKey());
+            String customerName = customer != null ? customer.getCustomerName() : "未命名客户";
+            if (!matchesKeyword(customerName, keyword) && !anySiteMatches(entry.getValue(), keyword)) {
+                continue;
+            }
+            ResponseMonitorTreeNodeVO parent = new ResponseMonitorTreeNodeVO();
+            parent.setNodeType("CUSTOMER");
+            parent.setId(entry.getKey());
+            parent.setName(customerName);
+            for (VppSite site : entry.getValue()) {
+                if (!matchesKeyword(site.getSiteName(), keyword) && !matchesKeyword(customerName, keyword)) {
+                    continue;
+                }
+                parent.getChildren().add(toSiteNode(site, entry.getKey()));
+            }
+            if (!parent.getChildren().isEmpty()) {
+                tree.add(parent);
+            }
+        }
+
+        for (VppSite site : orphanSites) {
+            if (!matchesKeyword(site.getSiteName(), keyword)) {
+                continue;
+            }
+            ResponseMonitorTreeNodeVO node = toSiteNode(site, null);
+            node.setNodeType("SITE");
+            tree.add(node);
+        }
+        return tree;
+    }
+
+    @Override
+    public ResponseLoadAnalysisVO getLoadAnalysis(Long siteId, String date, Long eventId) {
+        if (siteId == null) {
+            throw new BusinessException("请选择站点");
+        }
+        VppSite site = requireSite(siteId);
+        LocalDate statDate = parseDate(date);
+
+        ResponseLoadAnalysisVO result = new ResponseLoadAnalysisVO();
+        result.setSiteId(siteId);
+        result.setSiteName(site.getSiteName());
+        result.setDate(statDate.format(DATE_FMT));
+
+        List<VppResourcePoint> resources = listSiteResources(siteId);
+        List<VppDevice> devices = loadDevices(resources);
+        DrContext drContext = resolveDrContext(siteId, statDate, eventId);
+        result.setEventId(drContext.event != null ? drContext.event.getId() : null);
+        result.setEventName(drContext.event != null ? drContext.event.getEventName() : null);
+
+        BigDecimal declared = VppResponseMonitorHelper.resolveDeclaredCapacity(
+                drContext.event, drContext.participations, drContext.executions);
+
+        CurveBuildResult curve = buildCurve(resources, devices, siteId, statDate, declared, drContext);
+        result.setPoints(curve.points);
+        result.setDataSource(curve.dataSource);
+        result.setStatistics(VppResponseMonitorHelper.buildStatistics(curve.points, statDate));
+        return result;
+    }
+
+    @Override
+    public ResponseCompareVO compare(String siteIds, String date) {
+        List<Long> ids = parseSiteIds(siteIds);
+        if (ids.isEmpty()) {
+            throw new BusinessException("请至少选择一个站点");
+        }
+        LocalDate statDate = parseDate(date);
+        ResponseCompareVO result = new ResponseCompareVO();
+        result.setDate(statDate.format(DATE_FMT));
+        String dataSource = "mock";
+
+        for (Long siteId : ids) {
+            VppSite site = requireSite(siteId);
+            List<VppResourcePoint> resources = listSiteResources(siteId);
+            List<VppDevice> devices = loadDevices(resources);
+            CurveBuildResult curve = buildCurve(resources, devices, siteId, statDate, BigDecimal.ZERO, DrContext.empty());
+            if ("tsdb".equals(curve.dataSource)) {
+                dataSource = "tsdb";
+            }
+
+            ResponseCompareSeriesVO series = new ResponseCompareSeriesVO();
+            series.setSiteId(siteId);
+            series.setSiteName(site.getSiteName());
+            for (ResponseLoadPointVO point : curve.points) {
+                ResponseCompareSeriesVO.ResponseComparePointVO item =
+                        new ResponseCompareSeriesVO.ResponseComparePointVO();
+                item.setTime(point.getDatetime());
+                item.setValue(point.getActualLoadKw());
+                series.getPoints().add(item);
+            }
+            result.getSeries().add(series);
+        }
+        result.setDataSource(dataSource);
+        return result;
+    }
+
+    private CurveBuildResult buildCurve(List<VppResourcePoint> resources,
+                                        List<VppDevice> devices,
+                                        Long siteId,
+                                        LocalDate statDate,
+                                        BigDecimal declaredCapacityKw,
+                                        DrContext drContext) {
+        List<String> deviceUuids = resolveDeviceUuids(resources, devices);
+        if (!deviceUuids.isEmpty()) {
+            LocalDateTime dayStart = statDate.atStartOfDay();
+            LocalDateTime dayEnd = statDate.atTime(23, 59, 59);
+            LocalDate baselineDate = statDate.minusDays(VppResponseMonitorHelper.BASELINE_DAYS_AGO);
+            List<String> powerMetrics = new ArrayList<>(VppTsdbConstants.ACTIVE_POWER_METRICS);
+            Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> actualData =
+                    tsdbQueryService.queryDeviceMetricHistory(deviceUuids, dayStart, dayEnd, powerMetrics);
+            if (VppCapabilityEvalHelper.hasTsdbPowerData(actualData, powerMetrics)) {
+                Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> baselineData =
+                        tsdbQueryService.queryDeviceMetricHistory(deviceUuids,
+                                baselineDate.atStartOfDay(), baselineDate.atTime(23, 59, 59), powerMetrics);
+                List<ResponseLoadPointVO> points = VppResponseMonitorHelper.buildCurveFromTsdb(
+                        actualData, baselineData, statDate, powerMetrics, declaredCapacityKw,
+                        drContext.executionByTime);
+                return new CurveBuildResult(points, "tsdb");
+            }
+        }
+        return new CurveBuildResult(
+                VppResponseMonitorHelper.buildMockCurve(resources, devices, siteId, statDate, declaredCapacityKw),
+                "mock");
+    }
+
+    private DrContext resolveDrContext(Long siteId, LocalDate statDate, Long eventId) {
+        Set<Long> resourceIds = listSiteResources(siteId).stream()
+                .map(VppResourcePoint::getId)
+                .collect(Collectors.toSet());
+        if (resourceIds.isEmpty()) {
+            return DrContext.empty();
+        }
+
+        VppDrEvent event;
+        if (eventId != null) {
+            event = eventMapper.selectById(eventId);
+            if (event == null || VppAuditHelper.isDeleted(event.getDeleteFlag())) {
+                throw new BusinessException("需求响应事件不存在");
+            }
+        } else {
+            LocalDateTime dayStart = statDate.atStartOfDay();
+            LocalDateTime dayEnd = statDate.atTime(23, 59, 59);
+            event = eventMapper.selectList(new LambdaQueryWrapper<VppDrEvent>()
+                            .eq(VppDrEvent::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                            .ge(VppDrEvent::getStartTime, dayStart)
+                            .le(VppDrEvent::getStartTime, dayEnd)
+                            .orderByDesc(VppDrEvent::getStartTime)
+                            .last("LIMIT 1"))
+                    .stream().findFirst().orElse(null);
+        }
+        if (event == null) {
+            return DrContext.empty();
+        }
+
+        List<VppDrParticipation> participations = participationMapper.selectList(
+                new LambdaQueryWrapper<VppDrParticipation>()
+                        .eq(VppDrParticipation::getEventId, event.getId())
+                        .and(w -> {
+                            if (!resourceIds.isEmpty()) {
+                                w.in(VppDrParticipation::getResourceId, resourceIds);
+                            }
+                            VppSite site = siteMapper.selectById(siteId);
+                            if (site != null && site.getCustomerId() != null) {
+                                if (!resourceIds.isEmpty()) {
+                                    w.or().eq(VppDrParticipation::getCustomerId, site.getCustomerId());
+                                } else {
+                                    w.eq(VppDrParticipation::getCustomerId, site.getCustomerId());
+                                }
+                            }
+                        })
+                        .eq(VppDrParticipation::getDeleteFlag, VppAuditHelper.NOT_DELETED));
+        List<VppDrExecution> executions = executionMapper.selectList(
+                new LambdaQueryWrapper<VppDrExecution>()
+                        .eq(VppDrExecution::getEventId, event.getId())
+                        .in(VppDrExecution::getResourceId, resourceIds)
+                        .eq(VppDrExecution::getDeleteFlag, VppAuditHelper.NOT_DELETED));
+
+        Map<LocalDateTime, BigDecimal> executionByTime = new HashMap<>();
+        for (VppDrExecution execution : executions) {
+            if (execution.getActualKw() == null) {
+                continue;
+            }
+            LocalDateTime key = execution.getStartedAt() != null
+                    ? alignBucket(execution.getStartedAt())
+                    : statDate.atStartOfDay();
+            executionByTime.merge(key, execution.getActualKw(), BigDecimal::add);
+        }
+        return new DrContext(event, participations, executions, executionByTime);
+    }
+
+    private static LocalDateTime alignBucket(LocalDateTime time) {
+        int minute = (time.getMinute() / VppResponseMonitorHelper.BUCKET_MINUTES)
+                * VppResponseMonitorHelper.BUCKET_MINUTES;
+        return time.withMinute(minute).withSecond(0).withNano(0);
+    }
+
+    private ResponseMonitorTreeNodeVO toSiteNode(VppSite site, Long parentId) {
+        ResponseMonitorTreeNodeVO node = new ResponseMonitorTreeNodeVO();
+        node.setNodeType("SITE");
+        node.setId(site.getId());
+        node.setName(site.getSiteName());
+        node.setParentId(parentId);
+        return node;
+    }
+
+    private boolean anySiteMatches(List<VppSite> sites, String keyword) {
+        if (!StringUtils.hasText(keyword)) {
+            return true;
+        }
+        return sites.stream().anyMatch(s -> matchesKeyword(s.getSiteName(), keyword));
+    }
+
+    private boolean matchesKeyword(String text, String keyword) {
+        return !StringUtils.hasText(keyword)
+                || (text != null && text.toLowerCase().contains(keyword.toLowerCase()));
+    }
+
+    private VppSite requireSite(Long siteId) {
+        VppSite site = siteMapper.selectById(siteId);
+        if (site == null || VppAuditHelper.isDeleted(site.getDeleteFlag())) {
+            throw new BusinessException("站点不存在");
+        }
+        return site;
+    }
+
+    private List<VppResourcePoint> listSiteResources(Long siteId) {
+        return resourcePointMapper.selectList(new LambdaQueryWrapper<VppResourcePoint>()
+                .eq(VppResourcePoint::getSiteId, siteId)
+                .eq(VppResourcePoint::getDeleteFlag, VppAuditHelper.NOT_DELETED));
+    }
+
+    private List<VppDevice> loadDevices(List<VppResourcePoint> resources) {
+        Set<Long> deviceIds = resources.stream()
+                .map(VppResourcePoint::getDeviceId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        if (deviceIds.isEmpty()) {
+            return Collections.emptyList();
+        }
+        return deviceMapper.selectBatchIds(deviceIds).stream()
+                .filter(d -> !VppAuditHelper.isDeleted(d.getDeleteFlag()))
+                .collect(Collectors.toList());
+    }
+
+    private List<String> resolveDeviceUuids(List<VppResourcePoint> resources, List<VppDevice> devices) {
+        if (resources.isEmpty() || devices.isEmpty()) {
+            return Collections.emptyList();
+        }
+        Map<Long, VppDevice> deviceMap = devices.stream()
+                .collect(Collectors.toMap(VppDevice::getId, d -> d, (a, b) -> a));
+        List<String> uuids = new ArrayList<>();
+        for (VppResourcePoint resource : resources) {
+            if (resource.getDeviceId() == null) {
+                continue;
+            }
+            VppDevice device = deviceMap.get(resource.getDeviceId());
+            if (device != null && StringUtils.hasText(device.getDeviceUuid())) {
+                uuids.add(device.getDeviceUuid());
+            }
+        }
+        return uuids;
+    }
+
+    private LocalDate parseDate(String date) {
+        if (!StringUtils.hasText(date)) {
+            return LocalDate.now();
+        }
+        try {
+            return LocalDate.parse(date, DATE_FMT);
+        } catch (DateTimeParseException ex) {
+            throw new BusinessException("日期格式错误,请使用 yyyy-MM-dd");
+        }
+    }
+
+    private List<Long> parseSiteIds(String siteIds) {
+        if (!StringUtils.hasText(siteIds)) {
+            return Collections.emptyList();
+        }
+        return Arrays.stream(siteIds.split(","))
+                .map(String::trim)
+                .filter(StringUtils::hasText)
+                .map(Long::parseLong)
+                .distinct()
+                .sorted(Comparator.naturalOrder())
+                .collect(Collectors.toList());
+    }
+
+    private static class CurveBuildResult {
+        private final List<ResponseLoadPointVO> points;
+        private final String dataSource;
+
+        private CurveBuildResult(List<ResponseLoadPointVO> points, String dataSource) {
+            this.points = points;
+            this.dataSource = dataSource;
+        }
+    }
+
+    private static class DrContext {
+        private final VppDrEvent event;
+        private final List<VppDrParticipation> participations;
+        private final List<VppDrExecution> executions;
+        private final Map<LocalDateTime, BigDecimal> executionByTime;
+
+        private DrContext(VppDrEvent event,
+                          List<VppDrParticipation> participations,
+                          List<VppDrExecution> executions,
+                          Map<LocalDateTime, BigDecimal> executionByTime) {
+            this.event = event;
+            this.participations = participations;
+            this.executions = executions;
+            this.executionByTime = executionByTime;
+        }
+
+        private static DrContext empty() {
+            return new DrContext(null, Collections.emptyList(), Collections.emptyList(), Collections.emptyMap());
+        }
+    }
+}

+ 484 - 1
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppSettlementServiceImpl.java

@@ -1,11 +1,494 @@
 package com.usky.vpp.service.impl;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+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.VppCustomer;
+import com.usky.vpp.domain.VppEnergyReadingMonthly;
+import com.usky.vpp.domain.VppSettlementBill;
+import com.usky.vpp.domain.VppSite;
+import com.usky.vpp.mapper.VppCustomerMapper;
+import com.usky.vpp.mapper.VppEnergyReadingMonthlyMapper;
+import com.usky.vpp.mapper.VppSettlementBillMapper;
+import com.usky.vpp.mapper.VppSiteMapper;
 import com.usky.vpp.service.VppSettlementService;
+import com.usky.vpp.util.VppAuditHelper;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
 /**
- * VppSettlementService 默认实现(待按业务完善)
+ * VppSettlementService 默认实现
  */
 @Service
 public class VppSettlementServiceImpl implements VppSettlementService {
+
+    @Autowired
+    private VppCustomerMapper vppCustomerMapper;
+    @Autowired
+    private VppEnergyReadingMonthlyMapper energyReadingMonthlyMapper;
+    @Autowired
+    private VppSiteMapper siteMapper;
+    @Autowired
+    private VppSettlementBillMapper settlementBillMapper;
+
+    @Override
+    public CommonPage<VppEnergyReadingMonthly> pageReading(Long id, String customerName, String calcCycle,
+                                                           Integer current, Integer size) {
+        IPage<VppEnergyReadingMonthly> page = new Page<>(current, size);
+        LambdaQueryWrapper<VppEnergyReadingMonthly> wrapper = buildReadingQueryWrapper(id, customerName, calcCycle);
+        if (wrapper == null) {
+            return new CommonPage<>(Collections.emptyList(), 0L, size, current);
+        }
+        page = energyReadingMonthlyMapper.selectPage(page, wrapper);
+        List<VppEnergyReadingMonthly> records = page.getRecords();
+        fillCustomerNames(records);
+        fillSiteNames(records);
+        return new CommonPage<>(records, page.getTotal(), size, current);
+    }
+
+    @Override
+    public void exportReading(String scope, String fileName, Long id, String customerName, String calcCycle,
+                              Integer current, Integer size, HttpServletResponse response) {
+        List<VppEnergyReadingMonthly> readingList;
+
+        switch (scope) {
+            case "current":
+                if (current == null || size == null) {
+                    throw new BusinessException("current 模式下分页参数 current 和 size 不能为空!");
+                }
+                IPage<VppEnergyReadingMonthly> page = new Page<>(current, size);
+                LambdaQueryWrapper<VppEnergyReadingMonthly> currWrapper = baseReadingQueryWrapper();
+                currWrapper.orderByDesc(VppEnergyReadingMonthly::getSettleYear)
+                        .orderByDesc(VppEnergyReadingMonthly::getSettleMonth);
+                page = energyReadingMonthlyMapper.selectPage(page, currWrapper);
+                readingList = page.getRecords();
+                break;
+
+            case "all":
+                LambdaQueryWrapper<VppEnergyReadingMonthly> allWrapper = baseReadingQueryWrapper();
+                allWrapper.orderByDesc(VppEnergyReadingMonthly::getSettleYear)
+                        .orderByDesc(VppEnergyReadingMonthly::getSettleMonth);
+                readingList = energyReadingMonthlyMapper.selectList(allWrapper);
+                break;
+
+            case "filtered":
+                boolean hasFilter = id != null || StringUtils.isNotBlank(customerName)
+                        || StringUtils.isNotBlank(calcCycle);
+                if (!hasFilter) {
+                    throw new BusinessException("filtered 模式下请至少提供一个筛选条件(id / customerName / calcCycle)!");
+                }
+                LambdaQueryWrapper<VppEnergyReadingMonthly> filteredWrapper = buildReadingQueryWrapper(
+                        id, customerName, calcCycle);
+                if (filteredWrapper == null) {
+                    readingList = Collections.emptyList();
+                    break;
+                }
+                readingList = energyReadingMonthlyMapper.selectList(filteredWrapper);
+                break;
+
+            default:
+                throw new BusinessException("不支持的导出范围:" + scope + ",请使用 current / all / filtered");
+        }
+
+        if (readingList == null) {
+            readingList = Collections.emptyList();
+        }
+        fillCustomerNames(readingList);
+        fillSiteNames(readingList);
+        writeReadingExcel(fileName, readingList, response);
+    }
+
+    @Override
+    public CommonPage<VppSettlementBill> pageBill(Long id, String customerName, Integer current, Integer size) {
+        IPage<VppSettlementBill> page = new Page<>(current, size);
+        LambdaQueryWrapper<VppSettlementBill> wrapper = buildBillQueryWrapper(id, customerName);
+        if (wrapper == null) {
+            return new CommonPage<>(Collections.emptyList(), 0L, size, current);
+        }
+        page = settlementBillMapper.selectPage(page, wrapper);
+        List<VppSettlementBill> records = page.getRecords();
+        fillBillCustomerNames(records);
+        fillBillSiteNames(records);
+        fillBillContractPrice(records);
+        return new CommonPage<>(records, page.getTotal(), size, current);
+    }
+
+    @Override
+    public void exportBill(String scope, String fileName, Long id, String customerName,
+                           Integer current, Integer size, HttpServletResponse response) {
+        List<VppSettlementBill> billList;
+
+        switch (scope) {
+            case "current":
+                if (current == null || size == null) {
+                    throw new BusinessException("current 模式下分页参数 current 和 size 不能为空!");
+                }
+                IPage<VppSettlementBill> page = new Page<>(current, size);
+                LambdaQueryWrapper<VppSettlementBill> currWrapper = baseBillQueryWrapper();
+                currWrapper.orderByDesc(VppSettlementBill::getSettleYear)
+                        .orderByDesc(VppSettlementBill::getSettleMonth);
+                page = settlementBillMapper.selectPage(page, currWrapper);
+                billList = page.getRecords();
+                break;
+
+            case "all":
+                LambdaQueryWrapper<VppSettlementBill> allWrapper = baseBillQueryWrapper();
+                allWrapper.orderByDesc(VppSettlementBill::getSettleYear)
+                        .orderByDesc(VppSettlementBill::getSettleMonth);
+                billList = settlementBillMapper.selectList(allWrapper);
+                break;
+
+            case "filtered":
+                boolean hasFilter = id != null || StringUtils.isNotBlank(customerName);
+                if (!hasFilter) {
+                    throw new BusinessException("filtered 模式下请至少提供一个筛选条件(id / customerName)!");
+                }
+                LambdaQueryWrapper<VppSettlementBill> filteredWrapper = buildBillQueryWrapper(id, customerName);
+                if (filteredWrapper == null) {
+                    billList = Collections.emptyList();
+                    break;
+                }
+                billList = settlementBillMapper.selectList(filteredWrapper);
+                break;
+
+            default:
+                throw new BusinessException("不支持的导出范围:" + scope + ",请使用 current / all / filtered");
+        }
+
+        if (billList == null) {
+            billList = Collections.emptyList();
+        }
+        fillBillCustomerNames(billList);
+        fillBillSiteNames(billList);
+        fillBillContractPrice(billList);
+        writeBillExcel(fileName, billList, response);
+    }
+
+    private LambdaQueryWrapper<VppEnergyReadingMonthly> baseReadingQueryWrapper() {
+        return new LambdaQueryWrapper<VppEnergyReadingMonthly>()
+                .eq(VppEnergyReadingMonthly::getTenantId, SecurityUtils.getTenantId())
+                .eq(VppEnergyReadingMonthly::getDeleteFlag, VppAuditHelper.NOT_DELETED);
+    }
+
+    private LambdaQueryWrapper<VppEnergyReadingMonthly> buildReadingQueryWrapper(Long customerId,
+                                                                                 String customerName,
+                                                                                 String calcCycle) {
+        Map<Long, String> customerNameMap = resolveCustomerNameMap(customerName);
+        if (StringUtils.isNotBlank(customerName) && customerNameMap.isEmpty()) {
+            return null;
+        }
+
+        Map<String, Object> cycleMap = null;
+        if (StringUtils.isNotBlank(calcCycle)) {
+            cycleMap = resolveCalcCycle(calcCycle);
+        }
+
+        return baseReadingQueryWrapper()
+                .eq(customerId != null, VppEnergyReadingMonthly::getCustomerId, customerId)
+                .in(StringUtils.isNotBlank(customerName), VppEnergyReadingMonthly::getCustomerId, customerNameMap.keySet())
+                .eq(cycleMap != null, VppEnergyReadingMonthly::getSettleYear,
+                        cycleMap != null ? cycleMap.get("settleYear") : null)
+                .eq(cycleMap != null, VppEnergyReadingMonthly::getSettleMonth,
+                        cycleMap != null ? cycleMap.get("settleMonth") : null)
+                .orderByDesc(VppEnergyReadingMonthly::getSettleYear)
+                .orderByDesc(VppEnergyReadingMonthly::getSettleMonth);
+    }
+
+    private LambdaQueryWrapper<VppSettlementBill> baseBillQueryWrapper() {
+        return new LambdaQueryWrapper<VppSettlementBill>()
+                .eq(VppSettlementBill::getTenantId, SecurityUtils.getTenantId())
+                .eq(VppSettlementBill::getDeleteFlag, VppAuditHelper.NOT_DELETED);
+    }
+
+    private LambdaQueryWrapper<VppSettlementBill> buildBillQueryWrapper(Long customerId, String customerName) {
+        Map<Long, String> customerNameMap = resolveCustomerNameMap(customerName);
+        if (StringUtils.isNotBlank(customerName) && customerNameMap.isEmpty()) {
+            return null;
+        }
+
+        return baseBillQueryWrapper()
+                .eq(customerId != null, VppSettlementBill::getCustomerId, customerId)
+                .in(StringUtils.isNotBlank(customerName), VppSettlementBill::getCustomerId, customerNameMap.keySet())
+                .orderByDesc(VppSettlementBill::getSettleYear)
+                .orderByDesc(VppSettlementBill::getSettleMonth);
+    }
+
+    private Map<Long, String> resolveCustomerNameMap(String customerName) {
+        LambdaQueryWrapper<VppCustomer> customerQueryWrapper = new LambdaQueryWrapper<VppCustomer>()
+                .eq(VppCustomer::getTenantId, SecurityUtils.getTenantId())
+                .eq(VppCustomer::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .like(StringUtils.isNotBlank(customerName), VppCustomer::getCustomerName, customerName);
+        return vppCustomerMapper.selectList(customerQueryWrapper)
+                .stream()
+                .collect(Collectors.toMap(VppCustomer::getId, VppCustomer::getCustomerName, (a, b) -> a));
+    }
+
+    private void fillCustomerNames(List<VppEnergyReadingMonthly> records) {
+        if (records == null || records.isEmpty()) {
+            return;
+        }
+        Set<Long> customerIds = records.stream()
+                .map(VppEnergyReadingMonthly::getCustomerId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        if (customerIds.isEmpty()) {
+            return;
+        }
+        Map<Long, String> customerNameMap = vppCustomerMapper.selectBatchIds(customerIds).stream()
+                .collect(Collectors.toMap(VppCustomer::getId, VppCustomer::getCustomerName, (a, b) -> a));
+        records.forEach(reading -> reading.setCustomerName(customerNameMap.get(reading.getCustomerId())));
+    }
+
+    private void fillSiteNames(List<VppEnergyReadingMonthly> records) {
+        if (records == null || records.isEmpty()) {
+            return;
+        }
+        Map<Long, String> siteNameMap = loadSiteNameMap(records.stream()
+                .map(VppEnergyReadingMonthly::getSiteId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet()));
+        records.forEach(reading -> reading.setSiteName(resolveSiteName(reading.getSiteId(), siteNameMap)));
+    }
+
+    private void fillBillSiteNames(List<VppSettlementBill> records) {
+        if (records == null || records.isEmpty()) {
+            return;
+        }
+        Map<Long, String> siteNameMap = loadSiteNameMap(records.stream()
+                .map(VppSettlementBill::getSiteId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet()));
+        records.forEach(bill -> bill.setSiteName(resolveSiteName(bill.getSiteId(), siteNameMap)));
+    }
+
+    private Map<Long, String> loadSiteNameMap(Set<Long> siteIds) {
+        if (siteIds == null || siteIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        return siteMapper.selectBatchIds(siteIds).stream()
+                .collect(Collectors.toMap(VppSite::getId, VppSite::getSiteName, (a, b) -> a));
+    }
+
+    private String resolveSiteName(Long siteId, Map<Long, String> siteNameMap) {
+        if (siteId == null) {
+            return "户号级";
+        }
+        return siteNameMap.get(siteId);
+    }
+
+    private void fillBillCustomerNames(List<VppSettlementBill> records) {
+        if (records == null || records.isEmpty()) {
+            return;
+        }
+        Set<Long> customerIds = records.stream()
+                .map(VppSettlementBill::getCustomerId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        if (customerIds.isEmpty()) {
+            return;
+        }
+        Map<Long, String> customerNameMap = vppCustomerMapper.selectBatchIds(customerIds).stream()
+                .collect(Collectors.toMap(VppCustomer::getId, VppCustomer::getCustomerName, (a, b) -> a));
+        records.forEach(bill -> bill.setCustomerName(customerNameMap.get(bill.getCustomerId())));
+    }
+
+    private void fillBillContractPrice(List<VppSettlementBill> records) {
+        if (records == null || records.isEmpty()) {
+            return;
+        }
+        records.forEach(bill -> bill.setContractPrice(calcContractPrice(bill.getTotalEnergyKwh(), bill.getTotalAmount())));
+    }
+
+    private BigDecimal calcContractPrice(BigDecimal totalEnergyKwh, BigDecimal totalAmount) {
+        if (totalEnergyKwh == null || totalAmount == null
+                || totalEnergyKwh.compareTo(BigDecimal.ZERO) <= 0) {
+            return null;
+        }
+        return totalAmount.divide(totalEnergyKwh, 4, RoundingMode.HALF_UP);
+    }
+
+    private Map<String, Object> resolveCalcCycle(String calcCycle) {
+        Map<String, Object> calcCycleMap = new HashMap<>();
+        String[] split = calcCycle.split("-");
+        try {
+            calcCycleMap.put("settleYear", split[0]);
+            calcCycleMap.put("settleMonth", Integer.valueOf(split[1]));
+        } catch (Exception e) {
+            throw new BusinessException("解析核算周期失败!请核对参数!");
+        }
+        return calcCycleMap;
+    }
+
+    private void writeReadingExcel(String fileName, List<VppEnergyReadingMonthly> readingList,
+                                   HttpServletResponse response) {
+        try (org.apache.poi.xssf.usermodel.XSSFWorkbook workbook = new org.apache.poi.xssf.usermodel.XSSFWorkbook()) {
+            org.apache.poi.xssf.usermodel.XSSFSheet sheet = workbook.createSheet("月度电量核算");
+
+            org.apache.poi.xssf.usermodel.XSSFCellStyle headerStyle = workbook.createCellStyle();
+            headerStyle.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.GREY_25_PERCENT.getIndex());
+            headerStyle.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
+            org.apache.poi.xssf.usermodel.XSSFFont headerFont = workbook.createFont();
+            headerFont.setBold(true);
+            headerStyle.setFont(headerFont);
+
+            String[] headers = {
+                    "序号", "客户名称", "站点名称", "核算周期",
+                    "总用电量(kWh)", "峰电量(kWh)", "平电量(kWh)", "谷电量(kWh)", "尖电量(kWh)",
+                    "发电量(kWh)", "核算状态", "核算时间", "创建时间"
+            };
+            org.apache.poi.xssf.usermodel.XSSFRow headerRow = sheet.createRow(0);
+            for (int i = 0; i < headers.length; i++) {
+                org.apache.poi.xssf.usermodel.XSSFCell cell = headerRow.createCell(i);
+                cell.setCellValue(headers[i]);
+                cell.setCellStyle(headerStyle);
+            }
+
+            int rowIdx = 1;
+            for (VppEnergyReadingMonthly reading : readingList) {
+                org.apache.poi.xssf.usermodel.XSSFRow row = sheet.createRow(rowIdx++);
+                setCell(row, 0, rowIdx - 1);
+                setCell(row, 1, reading.getCustomerName());
+                setCell(row, 2, reading.getSiteName());
+                setCell(row, 3, settleCycleLabel(reading.getSettleYear(), reading.getSettleMonth()));
+                setCell(row, 4, numberStr(reading.getTotalEnergyKwh()));
+                setCell(row, 5, numberStr(reading.getPeakEnergyKwh()));
+                setCell(row, 6, numberStr(reading.getFlatEnergyKwh()));
+                setCell(row, 7, numberStr(reading.getValleyEnergyKwh()));
+                setCell(row, 8, numberStr(reading.getSharpEnergyKwh()));
+                setCell(row, 9, numberStr(reading.getGenEnergyKwh()));
+                setCell(row, 10, calcStatusLabel(reading.getCalcStatus()));
+                setCell(row, 11, dateTimeStr(reading.getCalcAt()));
+                setCell(row, 12, dateTimeStr(reading.getCreateTime()));
+            }
+
+            for (int i = 0; i < headers.length; i++) {
+                sheet.autoSizeColumn(i);
+                int width = sheet.getColumnWidth(i);
+                if (width > 15000) {
+                    sheet.setColumnWidth(i, 15000);
+                }
+            }
+
+            writeExcelResponse(fileName, workbook, response);
+        } catch (IOException e) {
+            throw new BusinessException("导出月度电量核算数据失败: " + e.getMessage());
+        }
+    }
+
+    private void writeBillExcel(String fileName, List<VppSettlementBill> billList, HttpServletResponse response) {
+        try (org.apache.poi.xssf.usermodel.XSSFWorkbook workbook = new org.apache.poi.xssf.usermodel.XSSFWorkbook()) {
+            org.apache.poi.xssf.usermodel.XSSFSheet sheet = workbook.createSheet("电费账单");
+
+            org.apache.poi.xssf.usermodel.XSSFCellStyle headerStyle = workbook.createCellStyle();
+            headerStyle.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.GREY_25_PERCENT.getIndex());
+            headerStyle.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
+            org.apache.poi.xssf.usermodel.XSSFFont headerFont = workbook.createFont();
+            headerFont.setBold(true);
+            headerStyle.setFont(headerFont);
+
+            String[] headers = {
+                    "序号", "客户名称", "站点名称", "结算周期", "用电量(kWh)", "合同电价(元/kWh)",
+                    "电费(元)", "结算时间"
+            };
+            org.apache.poi.xssf.usermodel.XSSFRow headerRow = sheet.createRow(0);
+            for (int i = 0; i < headers.length; i++) {
+                org.apache.poi.xssf.usermodel.XSSFCell cell = headerRow.createCell(i);
+                cell.setCellValue(headers[i]);
+                cell.setCellStyle(headerStyle);
+            }
+
+            int rowIdx = 1;
+            for (VppSettlementBill bill : billList) {
+                org.apache.poi.xssf.usermodel.XSSFRow row = sheet.createRow(rowIdx++);
+                setCell(row, 0, rowIdx - 1);
+                setCell(row, 1, bill.getCustomerName());
+                setCell(row, 2, bill.getSiteName());
+                setCell(row, 3, settleCycleLabel(bill.getSettleYear(), bill.getSettleMonth()));
+                setCell(row, 4, numberStr(bill.getTotalEnergyKwh()));
+                setCell(row, 5, numberStr(bill.getContractPrice()));
+                setCell(row, 6, numberStr(bill.getTotalAmount()));
+                setCell(row, 7, dateTimeStr(bill.getGeneratedAt()));
+            }
+
+            for (int i = 0; i < headers.length; i++) {
+                sheet.autoSizeColumn(i);
+                int width = sheet.getColumnWidth(i);
+                if (width > 15000) {
+                    sheet.setColumnWidth(i, 15000);
+                }
+            }
+
+            writeExcelResponse(fileName, workbook, response);
+        } catch (IOException e) {
+            throw new BusinessException("导出电费账单数据失败: " + e.getMessage());
+        }
+    }
+
+    private void writeExcelResponse(String fileName, org.apache.poi.xssf.usermodel.XSSFWorkbook workbook,
+                                    HttpServletResponse response) throws IOException {
+        String encodedFileName = java.net.URLEncoder.encode(fileName + ".xlsx", StandardCharsets.UTF_8.name())
+                .replace("+", "%20");
+        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+        response.setHeader("Content-Disposition", "attachment; filename=" + encodedFileName
+                + "; filename*=UTF-8''" + encodedFileName);
+        workbook.write(response.getOutputStream());
+        response.getOutputStream().flush();
+    }
+
+    private static void setCell(org.apache.poi.xssf.usermodel.XSSFRow row, int col, Object value) {
+        if (value != null) {
+            row.createCell(col).setCellValue(String.valueOf(value));
+        } else {
+            row.createCell(col).setCellValue("");
+        }
+    }
+
+    private static String numberStr(Object value) {
+        return value == null ? "" : value.toString();
+    }
+
+    private static String dateTimeStr(Object value) {
+        return value == null ? "" : value.toString();
+    }
+
+    private static String settleCycleLabel(String settleYear, Integer settleMonth) {
+        if (StringUtils.isBlank(settleYear) || settleMonth == null) {
+            return "";
+        }
+        return settleYear + "-" + String.format("%02d", settleMonth);
+    }
+
+    private static String calcStatusLabel(Integer calcStatus) {
+        if (calcStatus == null) {
+            return "";
+        }
+        switch (calcStatus) {
+            case 0:
+                return "待核算";
+            case 1:
+                return "已核算";
+            case 2:
+                return "异常";
+            default:
+                return String.valueOf(calcStatus);
+        }
+    }
 }

+ 9 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppSiteServiceImpl.java

@@ -178,6 +178,9 @@ public class VppSiteServiceImpl implements VppSiteService {
             if (params.get("district") != null) {
                 wrapper.eq(VppSite::getDistrict, params.get("district").toString());
             }
+            if (params.get("streetTown") != null) {
+                wrapper.eq(VppSite::getStreetTown, params.get("streetTown").toString());
+            }
         }
         return wrapper;
     }
@@ -236,6 +239,8 @@ public class VppSiteServiceImpl implements VppSiteService {
                     .filter(Objects::nonNull)
                     .distinct()
                     .collect(Collectors.toList()));
+            vo.setAccountNo(site.getAccountNo());
+            vo.setStreetTown(site.getStreetTown());
             voList.add(vo);
         }
         return new CommonPage<>(voList, page.getTotal(), page.getCurrent(), page.getSize());
@@ -302,6 +307,10 @@ public class VppSiteServiceImpl implements VppSiteService {
         site.setResponsePriority(request.getResponsePriority());
         site.setUnResourceId(request.getUnResourceId());
         site.setRemark(request.getRemark());
+        site.setAccountNo(request.getAccountNo());
+        if(request.getStreetTown() != null) {
+            site.setStreetTown(request.getStreetTown());
+        }
     }
 
     private void applySiteConfig(VppSiteConfig config, SiteConfigRequest request) {

+ 221 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppTsdbQueryServiceImpl.java

@@ -1,170 +1,391 @@
 package com.usky.vpp.service.impl;
 
+
+
 import com.usky.common.core.bean.ApiResult;
+
 import com.usky.demo.RemoteTsdbProxyService;
+
 import com.usky.demo.domain.HistorysInnerRequestVO;
+
 import com.usky.demo.domain.HistorysInnerResultVO;
+
 import com.usky.demo.domain.LastInnerQueryVO;
+
 import com.usky.demo.domain.LastInnerResultVO;
+
 import com.usky.demo.domain.MetricVO;
+
 import com.usky.vpp.constant.VppTsdbConstants;
+
 import com.usky.vpp.service.VppTsdbQueryService;
+
+import com.usky.vpp.util.VppBaselineHelper;
+
 import org.slf4j.Logger;
+
 import org.slf4j.LoggerFactory;
+
 import org.springframework.beans.factory.annotation.Autowired;
+
 import org.springframework.stereotype.Service;
+
 import org.springframework.util.CollectionUtils;
+
 import org.springframework.util.StringUtils;
 
+
+
 import java.math.BigDecimal;
+
 import java.math.RoundingMode;
+
+import java.time.LocalDate;
+
 import java.time.LocalDateTime;
+
+import java.time.LocalTime;
+
 import java.time.format.DateTimeFormatter;
+
 import java.time.format.DateTimeFormatterBuilder;
+
 import java.time.temporal.ChronoField;
+
 import java.util.ArrayList;
+
 import java.util.Collections;
+
 import java.util.HashMap;
+
 import java.util.LinkedHashSet;
+
 import java.util.List;
+
 import java.util.Map;
+
 import java.util.TreeMap;
 
+
+
 /**
+
  * TDengine 时序数据查询(通过 service-tsdb Feign,与 service-ems 一致)
+
  */
+
 @Service
+
 public class VppTsdbQueryServiceImpl implements VppTsdbQueryService {
 
+
+
     private static final Logger log = LoggerFactory.getLogger(VppTsdbQueryServiceImpl.class);
 
+
+
     private static final DateTimeFormatter TSDB_FLEXIBLE_TIME_FORMAT = new DateTimeFormatterBuilder()
+
             .appendPattern("yyyy-MM-dd HH:mm:ss")
+
             .optionalStart()
+
             .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true)
+
             .optionalEnd()
+
             .toFormatter();
 
+
+
     @Autowired
+
     private RemoteTsdbProxyService remoteTsdbProxyService;
 
+
+
     @Override
+
     public Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> queryDeviceMetricHistory(
+
             List<String> deviceUuids,
+
             LocalDateTime startTime,
+
             LocalDateTime endTime,
+
             List<String> metrics) {
+
         if (CollectionUtils.isEmpty(deviceUuids) || startTime == null || endTime == null) {
+
             return Collections.emptyMap();
+
         }
 
+
+
         HistorysInnerRequestVO request = new HistorysInnerRequestVO();
+
         request.setDeviceuuid(new ArrayList<>(new LinkedHashSet<>(deviceUuids)));
+
         request.setStartTime(startTime.format(DateTimeFormatter.ofPattern(VppTsdbConstants.TIME_FORMAT)));
+
         request.setEndTime(endTime.format(DateTimeFormatter.ofPattern(VppTsdbConstants.TIME_FORMAT)));
+
         if (!CollectionUtils.isEmpty(metrics)) {
+
             request.setMetrics(new ArrayList<>(metrics));
+
         }
 
+
+
         try {
+
             ApiResult<List<HistorysInnerResultVO>> result = remoteTsdbProxyService.queryHistoryDeviceData(request);
+
             if (result == null || !result.isSuccess() || CollectionUtils.isEmpty(result.getData())) {
+
                 log.warn("TDengine 历史查询无数据, devices={}, start={}, end={}",
+
                         deviceUuids.size(), request.getStartTime(), request.getEndTime());
+
                 return Collections.emptyMap();
+
             }
+
             return parseHistoryResult(result.getData());
+
         } catch (Exception ex) {
+
             log.error("TDengine 历史查询失败: {}", ex.getMessage(), ex);
+
             return Collections.emptyMap();
+
         }
+
     }
 
+
+
     @Override
+
     public Map<String, Map<String, BigDecimal>> queryLatestMetrics(List<String> deviceUuids, List<String> metrics) {
+
         if (CollectionUtils.isEmpty(deviceUuids)) {
+
             return Collections.emptyMap();
+
         }
 
+
+
         LastInnerQueryVO request = new LastInnerQueryVO();
+
         request.setDeviceuuid(new ArrayList<>(new LinkedHashSet<>(deviceUuids)));
+
         if (!CollectionUtils.isEmpty(metrics)) {
+
             request.setMetrics(new ArrayList<>(metrics));
+
         }
 
+
+
         try {
+
             ApiResult<List<LastInnerResultVO>> result = remoteTsdbProxyService.queryLastDeviceData(request);
+
             if (result == null || !result.isSuccess() || CollectionUtils.isEmpty(result.getData())) {
+
                 return Collections.emptyMap();
+
             }
+
             return parseLatestResult(result.getData());
+
         } catch (Exception ex) {
+
             log.error("TDengine 最新值查询失败: {}", ex.getMessage(), ex);
+
             return Collections.emptyMap();
+
         }
+
+    }
+
+
+
+    @Override
+
+    public Map<String, BigDecimal> queryBaselineAvgByTimePoint(List<String> deviceUuids,
+
+                                                              List<LocalDate> referenceDates,
+
+                                                              LocalTime periodStart,
+
+                                                              LocalTime periodEnd,
+
+                                                              List<String> metrics,
+
+                                                              Integer intervalMinutes,
+
+                                                              BigDecimal correctionFactor) {
+
+        if (CollectionUtils.isEmpty(deviceUuids) || CollectionUtils.isEmpty(referenceDates)) {
+
+            return Collections.emptyMap();
+
+        }
+
+        int interval = intervalMinutes != null && intervalMinutes > 0
+
+                ? intervalMinutes : VppBaselineHelper.INTERVAL_MINUTES;
+
+        return VppBaselineHelper.aggregateBaselineAvgByTimePoint(
+
+                referenceDates,
+
+                periodStart,
+
+                periodEnd,
+
+                interval,
+
+                correctionFactor,
+
+                (start, end) -> queryDeviceMetricHistory(deviceUuids, start, end, metrics));
+
     }
 
+
+
     private Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> parseHistoryResult(
+
             List<HistorysInnerResultVO> historyDataList) {
+
         Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> deviceMetricMap = new HashMap<>();
+
         for (HistorysInnerResultVO result : historyDataList) {
+
             if (result == null || !StringUtils.hasText(result.getDeviceuuid()) || CollectionUtils.isEmpty(result.getMetrics())) {
+
                 continue;
+
             }
+
             String deviceUuid = result.getDeviceuuid();
+
             Map<String, TreeMap<LocalDateTime, BigDecimal>> metricMap =
+
                     deviceMetricMap.computeIfAbsent(deviceUuid, key -> new HashMap<>());
 
+
+
             for (MetricVO metric : result.getMetrics()) {
+
                 if (metric == null || !StringUtils.hasText(metric.getMetric()) || CollectionUtils.isEmpty(metric.getMetricItems())) {
+
                     continue;
+
                 }
+
                 TreeMap<LocalDateTime, BigDecimal> timeValueMap =
+
                         metricMap.computeIfAbsent(metric.getMetric(), key -> new TreeMap<>());
+
                 for (Map<String, Object> point : metric.getMetricItems()) {
+
                     if (point == null || point.get("timestamp") == null || point.get("value") == null) {
+
                         continue;
+
                     }
+
                     try {
+
                         LocalDateTime timestamp = parseTimestamp(point.get("timestamp").toString());
+
                         BigDecimal value = new BigDecimal(point.get("value").toString());
+
                         timeValueMap.put(timestamp, value);
+
                     } catch (Exception ignored) {
+
                         // skip invalid point
+
                     }
+
                 }
+
             }
+
         }
+
         return deviceMetricMap;
+
     }
 
+
+
     private Map<String, Map<String, BigDecimal>> parseLatestResult(List<LastInnerResultVO> latestList) {
+
         Map<String, Map<String, BigDecimal>> result = new HashMap<>();
+
         for (LastInnerResultVO item : latestList) {
+
             if (item == null || !StringUtils.hasText(item.getDeviceuuid()) || item.getMetrics() == null) {
+
                 continue;
+
             }
+
             Map<String, BigDecimal> metricValues = new HashMap<>();
+
             item.getMetrics().forEach((metric, value) -> {
+
                 if (value == null) {
+
                     return;
+
                 }
+
                 try {
+
                     metricValues.put(metric, new BigDecimal(value.toString()).setScale(4, RoundingMode.HALF_UP));
+
                 } catch (Exception ignored) {
+
                     // skip invalid value
+
                 }
+
             });
+
             result.put(item.getDeviceuuid(), metricValues);
+
         }
+
         return result;
+
     }
 
+
+
     private LocalDateTime parseTimestamp(String timestampStr) {
+
         String normalized = timestampStr.trim();
+
         if (normalized.endsWith(".0")) {
+
             normalized = normalized.substring(0, normalized.length() - 2);
+
         }
+
         return LocalDateTime.parse(normalized, TSDB_FLEXIBLE_TIME_FORMAT);
+
     }
+
 }
+
+

+ 2 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppUnDrSyncServiceImpl.java

@@ -12,6 +12,7 @@ import com.usky.vpp.mapper.VppResourcePointMapper;
 import com.usky.vpp.mapper.VppSiteMapper;
 import com.usky.vpp.service.VppUnDrSyncService;
 import com.usky.vpp.util.VppAuditHelper;
+import com.usky.vpp.util.VppDrParticipationHelper;
 import com.usky.vpp.util.VppUnEventParser;
 import com.usky.vpp.util.VppUnPayloadHelper;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -112,6 +113,7 @@ public class VppUnDrSyncServiceImpl implements VppUnDrSyncService {
         } else {
             participation.setDeclaredCapacityKw(load.abs());
         }
+        VppDrParticipationHelper.refreshCompletionRate(participation);
         VppAuditHelper.fillUpdate(participation);
         if (participation.getId() == null) {
             participationMapper.insert(participation);

+ 21 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/BaselinePointVO.java

@@ -0,0 +1,21 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 基线管理曲线 - 单时间点
+ */
+@Data
+public class BaselinePointVO {
+
+    /** 时刻 HH:mm */
+    private String time;
+    /** 预测基线 kW(黄色曲线) */
+    private BigDecimal predictedBaselineKw;
+    /** 今日实际 kW(绿色曲线) */
+    private BigDecimal todayActualKw;
+    /** 昨日实际 kW(蓝色曲线) */
+    private BigDecimal yesterdayActualKw;
+}

+ 28 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/BiddingConfigRequest.java

@@ -0,0 +1,28 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 竞价配置创建/更新请求
+ */
+@Data
+public class BiddingConfigRequest {
+
+    private String configName;
+    private LocalDateTime effectiveStart;
+    private LocalDateTime effectiveEnd;
+    private String attachmentUrl;
+    private String attachmentName;
+    /** 1分时段竞价 2整体竞价 */
+    private Integer biddingMode;
+    private BigDecimal typicalCoefficient;
+    /** 提前截标时间(小时),如 24 表示 >24 小时 */
+    private Integer advanceCloseHours;
+    private BigDecimal maxPrice;
+    private BigDecimal minPrice;
+    private List<Long> siteIds;
+}

+ 13 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/BiddingConfigSiteVO.java

@@ -0,0 +1,13 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+/**
+ * 竞价配置关联站点
+ */
+@Data
+public class BiddingConfigSiteVO {
+
+    private Long siteId;
+    private String siteName;
+}

+ 34 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/BiddingConfigVO.java

@@ -0,0 +1,34 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 竞价配置详情/列表项
+ */
+@Data
+public class BiddingConfigVO {
+
+    private Long id;
+    private String configName;
+    private LocalDateTime effectiveStart;
+    private LocalDateTime effectiveEnd;
+    /** 适用时间段展示,如 2025-01-01 00:00:00 - 2025-06-30 23:59:59 */
+    private String effectivePeriod;
+    private String attachmentUrl;
+    private String attachmentName;
+    private Integer biddingMode;
+    private String biddingModeName;
+    private BigDecimal typicalCoefficient;
+    private Integer advanceCloseHours;
+    /** 提前截标时间展示,如 >24 */
+    private String advanceCloseHoursLabel;
+    private BigDecimal maxPrice;
+    private BigDecimal minPrice;
+    private List<Long> siteIds;
+    private List<BiddingConfigSiteVO> sites;
+    private LocalDateTime updateTime;
+}

+ 10 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/CustomerAccessRequestVO.java

@@ -16,6 +16,11 @@ import java.time.LocalDateTime;
 @Data
 public class CustomerAccessRequestVO {
 
+    /**
+     * 主键(修改已驳回准入申请时必传)
+     */
+    private Long id;
+
     /**
      * 电力户号
      */
@@ -70,4 +75,9 @@ public class CustomerAccessRequestVO {
      * 联系邮箱
      */
     private String contactEmail;
+
+    /**
+     * 地址
+     */
+    private String address;
 }

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

@@ -73,10 +73,30 @@ public class CustomerAccessResponseVO {
     private LocalDateTime auditAt;
 
     /**
-     * 关联客户ID(审核通过后生成)
+     * 关联客户ID
      */
     private Long customerId;
 
+    /**
+     * 联系人
+     */
+    private String contactName;
+
+    /**
+     * 联系电话
+     */
+    private String contactPhone;
+
+    /**
+     * 联系邮箱
+     */
+    private String contactEmail;
+
+    /**
+     * 地址
+     */
+    private String address;
+
     /**
      * 申请时间
      */

+ 19 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DeclaredCapacityPointVO.java

@@ -0,0 +1,19 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 响应申报容量 - 单时间点
+ */
+@Data
+public class DeclaredCapacityPointVO {
+
+    /** 时刻 HH:mm */
+    private String time;
+    /** 同时间段历史负荷均值 kW */
+    private BigDecimal historicalAvgKw;
+    /** 申报容量 kW = 历史均值 × K × 执行时段小时数 / 2 */
+    private BigDecimal declaredCapacityKw;
+}

+ 27 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DrEventDetailVO.java

@@ -0,0 +1,27 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 需求响应事件详情
+ */
+@Data
+public class DrEventDetailVO {
+
+    private Long id;
+    private String eventId;
+    private String eventName;
+    private Integer responseType;
+    private Integer eventType;
+    private LocalDateTime startTime;
+    private LocalDateTime endTime;
+    private BigDecimal targetCapacityKw;
+    private BigDecimal clearedCapacityKw;
+    private BigDecimal subsidyPrice;
+    private Integer eventStatus;
+    private List<DrParticipationVO> participations;
+}

+ 25 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DrParticipationVO.java

@@ -0,0 +1,25 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 需求响应参与记录(事件详情嵌套)
+ */
+@Data
+public class DrParticipationVO {
+
+    private Long id;
+    private Long customerId;
+    private String customerName;
+    private Long resourceId;
+    private String resourceName;
+    private Integer participateStatus;
+    private BigDecimal declaredCapacityKw;
+    private BigDecimal clearedCapacityKw;
+    /** 完成率 = 出清容量 / 申报容量 */
+    private BigDecimal completionRate;
+    private LocalDateTime declaredAt;
+}

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

@@ -5,7 +5,7 @@ import lombok.Data;
 import java.math.BigDecimal;
 
 /**
- * 今日电能概况(模拟数据
+ * 今日电能概况(TSDB 实时汇总
  */
 @Data
 public class EnergyTodayVO {

+ 16 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportAuditLogVO.java

@@ -0,0 +1,16 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+public class ReportAuditLogVO {
+
+    private Long id;
+    private Integer auditLevel;
+    private Integer action;
+    private String opinion;
+    private String operatorName;
+    private LocalDateTime operatedAt;
+}

+ 11 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportAuditRequest.java

@@ -0,0 +1,11 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+@Data
+public class ReportAuditRequest {
+
+    private Boolean passed;
+    private String opinion;
+    private Integer auditLevel;
+}

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

@@ -0,0 +1,12 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.util.Map;
+
+@Data
+public class ReportDataSaveRequest {
+
+    private Map<String, Object> formData;
+    private Boolean isDraft;
+}

+ 19 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportPrefillVO.java

@@ -0,0 +1,19 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Map;
+
+@Data
+public class ReportPrefillVO {
+
+    private String reportPeriod;
+    private Integer customerCount;
+    private BigDecimal totalCapacityKw;
+    private BigDecimal totalGenKwh;
+    private BigDecimal totalUseKwh;
+    private Integer drEventCount;
+    private BigDecimal drTotalSubsidy;
+    private Map<String, Object> formData;
+}

+ 30 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportRecordVO.java

@@ -0,0 +1,30 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+@Data
+public class ReportRecordVO {
+
+    private Long id;
+    private Long taskId;
+    private String taskName;
+    private String recordNo;
+    private String reportPeriod;
+    private Integer reportOrg;
+    private String reportOrgName;
+    private Integer submitMode;
+    private Integer recordStatus;
+    private String reportFileUrl;
+    private String receiptFileUrl;
+    private Integer retryCount;
+    private LocalDateTime submittedAt;
+    private List<ReportAuditLogVO> auditLogs;
+    private Map<String, Object> formData;
+    private Boolean isDraft;
+    /** 任务关联站点,预填报与校验均按此范围汇总 */
+    private List<Long> siteIds;
+}

+ 27 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportTaskCreateRequest.java

@@ -0,0 +1,27 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 新增报送任务请求
+ */
+@Data
+public class ReportTaskCreateRequest {
+
+    private String taskName;
+    private Integer reportOrg;
+    private Integer reportCycle;
+    private String reportType;
+    private LocalDateTime deadline;
+    private Long assigneeId;
+    private Integer remindDays;
+    /** 报送周期标识,如 2026-05 */
+    private String reportPeriod;
+    /** 报送方式:1接口推送 2手动上传,默认 1 */
+    private Integer submitMode;
+    /** 关联站点 ID 列表,至少选择一个 */
+    private List<Long> siteIds;
+}

+ 21 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportTaskCreateResultVO.java

@@ -0,0 +1,21 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 新增报送任务响应
+ */
+@Data
+public class ReportTaskCreateResultVO {
+
+    private Long taskId;
+    private String taskNo;
+    private Long recordId;
+    private String recordNo;
+    private String reportPeriod;
+    private Integer taskStatus;
+    private Integer recordStatus;
+    private List<Long> siteIds;
+}

+ 24 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportTaskVO.java

@@ -0,0 +1,24 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Data
+public class ReportTaskVO {
+
+    private Long id;
+    private String taskNo;
+    private String taskName;
+    private Integer reportOrg;
+    private String reportOrgName;
+    private Integer reportCycle;
+    private String reportType;
+    private LocalDateTime deadline;
+    private Integer taskStatus;
+    private Long assigneeId;
+    private String assigneeName;
+    private Integer remindDays;
+    private List<Long> siteIds;
+}

+ 14 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportValidateErrorVO.java

@@ -0,0 +1,14 @@
+package com.usky.vpp.service.vo;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class ReportValidateErrorVO {
+
+    private String field;
+    private String message;
+}

+ 13 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportValidateResultVO.java

@@ -0,0 +1,13 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Data
+public class ReportValidateResultVO {
+
+    private boolean valid;
+    private List<ReportValidateErrorVO> errors = new ArrayList<>();
+}

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

@@ -5,7 +5,7 @@ import lombok.Data;
 import java.math.BigDecimal;
 
 /**
- * 资源看板列表项(含模拟实时运行数据)
+ * 资源看板列表项(含 TSDB 实时运行数据)
  */
 @Data
 public class ResourceBoardItemVO {

+ 24 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ResponseCompareSeriesVO.java

@@ -0,0 +1,24 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 对比分析序列
+ */
+@Data
+public class ResponseCompareSeriesVO {
+
+    private Long siteId;
+    private String siteName;
+    private List<ResponseComparePointVO> points = new ArrayList<>();
+
+    @Data
+    public static class ResponseComparePointVO {
+        private String time;
+        private BigDecimal value;
+    }
+}

+ 18 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ResponseCompareVO.java

@@ -0,0 +1,18 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 对比分析(响应监控 - 对比分析 Tab)
+ */
+@Data
+public class ResponseCompareVO {
+
+    private String date;
+    private String granularity = "15m";
+    private String dataSource;
+    private List<ResponseCompareSeriesVO> series = new ArrayList<>();
+}

+ 24 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ResponseLoadAnalysisVO.java

@@ -0,0 +1,24 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 负荷分析(响应监控 - 负荷分析 Tab)
+ */
+@Data
+public class ResponseLoadAnalysisVO {
+
+    private Long siteId;
+    private String siteName;
+    private String date;
+    private Long eventId;
+    private String eventName;
+    /** tsdb / mock */
+    private String dataSource;
+    private String granularity = "15m";
+    private List<ResponseLoadPointVO> points = new ArrayList<>();
+    private List<ResponseLoadStatRowVO> statistics = new ArrayList<>();
+}

+ 19 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ResponseLoadPointVO.java

@@ -0,0 +1,19 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 响应监控负荷曲线点(15 分钟粒度)
+ */
+@Data
+public class ResponseLoadPointVO {
+
+    private String time;
+    private String datetime;
+    private BigDecimal baselineLoadKw;
+    private BigDecimal actualLoadKw;
+    private BigDecimal declaredCapacityKw;
+    private BigDecimal actualResponseLoadKw;
+}

+ 24 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ResponseLoadStatRowVO.java

@@ -0,0 +1,24 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 负荷统计表行(实际/预测用电量)
+ */
+@Data
+public class ResponseLoadStatRowVO {
+
+    /** ACTUAL / PREDICTED */
+    private String dataType;
+    private String dataTypeLabel;
+    private BigDecimal total;
+    private BigDecimal max;
+    private BigDecimal min;
+    private BigDecimal avg;
+    /** 小时列 HH:00 -> kWh */
+    private Map<String, BigDecimal> hourlyValues = new LinkedHashMap<>();
+}

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

@@ -0,0 +1,20 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 响应监控资源树节点
+ */
+@Data
+public class ResponseMonitorTreeNodeVO {
+
+    /** CUSTOMER / SITE */
+    private String nodeType;
+    private Long id;
+    private String name;
+    private Long parentId;
+    private List<ResponseMonitorTreeNodeVO> children = new ArrayList<>();
+}

+ 31 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/SiteBaselineVO.java

@@ -0,0 +1,31 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 站点基线管理查询结果
+ */
+@Data
+public class SiteBaselineVO {
+
+    private Long siteId;
+    private String siteName;
+    /** 响应开始时间 */
+    private LocalDateTime responseStartTime;
+    /** 日内修正系数 K(0.7 ~ 1.2) */
+    private BigDecimal correctionFactorK;
+    /** 典型历史日(yyyy-MM-dd) */
+    private List<String> referenceDates;
+    /** 响应日类型:WORKDAY / NON_WORKDAY */
+    private String responseDayType;
+    /** 响应日类型中文 */
+    private String responseDayTypeLabel;
+    /** 数据来源:tsdb / mock */
+    private String dataSource;
+    /** 全天曲线(整点) */
+    private List<BaselinePointVO> points;
+}

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

@@ -0,0 +1,35 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 站点响应申报容量查询结果
+ */
+@Data
+public class SiteDeclaredCapacityVO {
+
+    private Long siteId;
+    private String siteName;
+    /** 响应开始时间 */
+    private LocalDateTime responseStartTime;
+    /** 响应结束时间 */
+    private LocalDateTime responseEndTime;
+    /** 日内修正系数 K(0.7 ~ 1.2) */
+    private BigDecimal correctionFactorK;
+    /** 响应申报容量 kW(历史均值 × K × 执行时段小时数 / 2) */
+    private BigDecimal declaredCapacityKw;
+    /** 典型历史日(yyyy-MM-dd) */
+    private List<String> referenceDates;
+    /** 响应日类型:WORKDAY / NON_WORKDAY */
+    private String responseDayType;
+    /** 响应日类型中文 */
+    private String responseDayTypeLabel;
+    /** 数据来源:tsdb / mock */
+    private String dataSource;
+    /** 响应时段内各时间点申报容量 */
+    private List<DeclaredCapacityPointVO> points;
+}

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

@@ -31,4 +31,6 @@ public class SiteListVO {
     /** 站点下资源点数量 */
     private Long resourceCount;
     private LocalDateTime createTime;
+    private String accountNo;
+    private String streetTown;
 }

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

@@ -25,4 +25,6 @@ public class SiteRequest {
     private Integer responsePriority;
     private String unResourceId;
     private String remark;
+    private String accountNo;
+    private String streetTown;
 }

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

@@ -0,0 +1,518 @@
+package com.usky.vpp.util;
+
+import com.usky.vpp.constant.VppTsdbConstants;
+import com.usky.vpp.service.vo.BaselinePointVO;
+import com.usky.vpp.service.vo.DeclaredCapacityPointVO;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.function.BiFunction;
+
+/**
+ * 基线管理计算辅助(典型日筛选、原始基线、修正系数、曲线构建)
+ */
+public final class VppBaselineHelper {
+
+    public static final int WEEKDAY_REFERENCE_COUNT = 5;
+    public static final int NON_WEEKDAY_REFERENCE_COUNT = 3;
+    public static final int CORRECTION_HOURS = 2;
+    public static final int INTERVAL_MINUTES = 5;
+    public static final BigDecimal K_MIN = new BigDecimal("0.7");
+    public static final BigDecimal K_MAX = new BigDecimal("1.2");
+    public static final BigDecimal K_DEFAULT = BigDecimal.ONE;
+
+    /** 总有功功率指标(文档 p + 存量字段兼容) */
+    public static final List<String> POWER_METRICS;
+
+    private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("HH:mm");
+    private static final DateTimeFormatter TIME_WITH_SEC_FMT = DateTimeFormatter.ofPattern("HH:mm:ss");
+    private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+    static {
+        List<String> metrics = new ArrayList<>();
+        metrics.add("p");
+        metrics.addAll(VppTsdbConstants.ACTIVE_POWER_METRICS);
+        POWER_METRICS = Collections.unmodifiableList(metrics);
+    }
+
+    private VppBaselineHelper() {
+    }
+
+    /**
+     * 响应日是否为工作日(Hutool 法定节假日 + 调休规则)
+     */
+    public static boolean isWorkdayResponse(LocalDate date) {
+        return VppWorkdayHelper.isWorkday(date);
+    }
+
+    /**
+     * @deprecated 使用 {@link #isWorkdayResponse(LocalDate)}
+     */
+    @Deprecated
+    public static boolean isWeekday(LocalDate date) {
+        return isWorkdayResponse(date);
+    }
+
+    /**
+     * 选取典型历史日:工作日取前 5 个工作日,非工作日取前 3 个非工作日;
+     * 仅剔除响应当日与历史响应日(不含节假日前后日期)。
+     */
+    public static List<LocalDate> selectReferenceDates(LocalDate responseDate,
+                                                       boolean workday,
+                                                       Set<LocalDate> excludeDates,
+                                                       int requiredCount) {
+        Set<LocalDate> excludes = new HashSet<>(excludeDates);
+        excludes.add(responseDate);
+
+        List<LocalDate> result = new ArrayList<>();
+        LocalDate cursor = responseDate.minusDays(1);
+        int guard = 0;
+        while (result.size() < requiredCount && guard < 400) {
+            if (excludes.contains(cursor)) {
+                cursor = cursor.minusDays(1);
+                guard++;
+                continue;
+            }
+            if (VppWorkdayHelper.isWorkday(cursor) == workday) {
+                result.add(cursor);
+            }
+            cursor = cursor.minusDays(1);
+            guard++;
+        }
+        return result;
+    }
+
+    /**
+     * 多历史日同时段各时间点负荷求平均(先按日汇总设备负荷,再跨日取均值)。
+     * <p>通过 historyLoader 按日调用 TSDB {@code queryHistoryDeviceData} 获取原始时序后在本模块聚合。</p>
+     */
+    public static Map<String, BigDecimal> aggregateBaselineAvgByTimePoint(
+            List<LocalDate> referenceDates,
+            LocalTime periodStart,
+            LocalTime periodEnd,
+            int intervalMinutes,
+            BigDecimal correctionFactor,
+            BiFunction<LocalDateTime, LocalDateTime, Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>>> historyLoader) {
+        if (referenceDates == null || referenceDates.isEmpty() || historyLoader == null) {
+            return Collections.emptyMap();
+        }
+
+        LocalTime dayStart = periodStart != null ? periodStart : LocalTime.MIN;
+        LocalTime dayEnd = periodEnd != null ? periodEnd : LocalTime.of(23, 59, 59);
+        int interval = intervalMinutes > 0 ? intervalMinutes : INTERVAL_MINUTES;
+
+        Map<String, List<BigDecimal>> samplesByTime = new TreeMap<>();
+        for (LocalDate date : referenceDates) {
+            LocalDateTime start = LocalDateTime.of(date, dayStart);
+            LocalDateTime end = LocalDateTime.of(date, dayEnd);
+            if (end.isBefore(start)) {
+                continue;
+            }
+            Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> dayData = historyLoader.apply(start, end);
+            Map<String, BigDecimal> dayBuckets = buildActualLoadByTimePoint(dayData, interval);
+            if (dayBuckets.isEmpty()) {
+                continue;
+            }
+            for (Map.Entry<String, BigDecimal> entry : dayBuckets.entrySet()) {
+                samplesByTime.computeIfAbsent(entry.getKey(), key -> new ArrayList<>()).add(entry.getValue());
+            }
+        }
+
+        Map<String, BigDecimal> result = new LinkedHashMap<>();
+        for (Map.Entry<String, List<BigDecimal>> entry : samplesByTime.entrySet()) {
+            BigDecimal avg = average(entry.getValue());
+            if (correctionFactor != null) {
+                avg = avg.multiply(correctionFactor);
+            }
+            result.put(entry.getKey(), scale(avg));
+        }
+        return result;
+    }
+
+    /**
+     * 将设备历史时序聚合为 HH:mm 时间点总负荷。
+     */
+    public static Map<String, BigDecimal> buildActualLoadByTimePoint(
+            Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> deviceData,
+            int intervalMinutes) {
+        if (deviceData == null || deviceData.isEmpty()) {
+            return Collections.emptyMap();
+        }
+
+        Map<String, List<BigDecimal>> samples = new TreeMap<>();
+        for (Map<String, TreeMap<LocalDateTime, BigDecimal>> metricMap : deviceData.values()) {
+            TreeMap<LocalDateTime, BigDecimal> series = pickSeries(metricMap);
+            if (series == null || series.isEmpty()) {
+                continue;
+            }
+            Map<String, List<BigDecimal>> deviceBuckets = bucketSeries(series, intervalMinutes);
+            for (Map.Entry<String, List<BigDecimal>> entry : deviceBuckets.entrySet()) {
+                BigDecimal deviceAvg = average(entry.getValue());
+                samples.computeIfAbsent(entry.getKey(), key -> new ArrayList<>()).add(deviceAvg);
+            }
+        }
+
+        Map<String, BigDecimal> result = new LinkedHashMap<>();
+        for (Map.Entry<String, List<BigDecimal>> entry : samples.entrySet()) {
+            result.put(entry.getKey(), scale(sum(entry.getValue())));
+        }
+        return result;
+    }
+
+    /**
+     * 计算日内修正系数 K = 响应开始前 2 小时今日实测 / 同时段原始基线,并限制在 [0.7, 1.2]。
+     */
+    public static BigDecimal calculateCorrectionFactor(LocalDateTime responseStartTime,
+                                                       Map<String, BigDecimal> todayActualByTime,
+                                                       Map<String, BigDecimal> originalBaselineByTime) {
+        LocalDateTime windowStart = responseStartTime.minusHours(CORRECTION_HOURS);
+        BigDecimal todayTotal = sumValuesInWindow(todayActualByTime, windowStart, responseStartTime);
+        BigDecimal baselineTotal = sumValuesInWindow(originalBaselineByTime, windowStart, responseStartTime);
+        if (baselineTotal == null || baselineTotal.compareTo(BigDecimal.ZERO) <= 0) {
+            return K_DEFAULT;
+        }
+        if (todayTotal == null || todayTotal.compareTo(BigDecimal.ZERO) <= 0) {
+            return K_DEFAULT;
+        }
+        BigDecimal k = todayTotal.divide(baselineTotal, 6, RoundingMode.HALF_UP);
+        if (k.compareTo(K_MIN) < 0) {
+            return K_MIN;
+        }
+        if (k.compareTo(K_MAX) > 0) {
+            return K_MAX;
+        }
+        return k.setScale(4, RoundingMode.HALF_UP);
+    }
+
+    public static List<BaselinePointVO> buildBaselinePoints(LocalDate responseDate,
+                                                            LocalDateTime responseStartTime,
+                                                            Map<String, BigDecimal> predictedBaselineByTime,
+                                                            Map<String, BigDecimal> todayActualByTime,
+                                                            Map<String, BigDecimal> yesterdayActualByTime) {
+        List<BaselinePointVO> points = new ArrayList<>();
+        for (Map.Entry<String, BigDecimal> entry : predictedBaselineByTime.entrySet()) {
+            String timeKey = entry.getKey();
+            LocalTime time = parseTimeKey(timeKey);
+            LocalDateTime pointTime = LocalDateTime.of(responseDate, time);
+
+            BaselinePointVO point = new BaselinePointVO();
+            point.setTime(timeKey);
+            point.setPredictedBaselineKw(scale(entry.getValue()));
+
+            if (!pointTime.isAfter(responseStartTime)) {
+                point.setTodayActualKw(scale(todayActualByTime.get(timeKey)));
+            }
+            point.setYesterdayActualKw(scale(yesterdayActualByTime.get(timeKey)));
+            points.add(point);
+        }
+        return points;
+    }
+
+    public static boolean hasTsdbPowerData(Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> deviceMetricData) {
+        return VppCapabilityEvalHelper.hasTsdbPowerData(deviceMetricData, POWER_METRICS);
+    }
+
+    public static List<BaselinePointVO> buildMockBaselinePoints(Long siteId,
+                                                                LocalDate responseDate,
+                                                                LocalDateTime responseStartTime) {
+        List<BaselinePointVO> points = new ArrayList<>();
+        long seed = (siteId != null ? siteId : 0L) + responseDate.toEpochDay();
+        for (int minute = 0; minute < 1440; minute += INTERVAL_MINUTES) {
+            LocalTime time = LocalTime.of(minute / 60, minute % 60);
+            String timeKey = time.format(TIME_FMT);
+            double hourFactor = mockHourFactor(time);
+            double noise = 0.9 + pseudoRandom(seed, minute, 0) * 0.2;
+
+            BigDecimal yesterday = scale(BigDecimal.valueOf(900 * hourFactor * noise));
+            BigDecimal original = yesterday.multiply(BigDecimal.valueOf(0.98 + pseudoRandom(seed, minute, 1) * 0.04))
+                    .setScale(2, RoundingMode.HALF_UP);
+            BigDecimal predicted = original.multiply(BigDecimal.valueOf(1.03)).setScale(2, RoundingMode.HALF_UP);
+
+            BaselinePointVO point = new BaselinePointVO();
+            point.setTime(timeKey);
+            point.setPredictedBaselineKw(predicted);
+            point.setYesterdayActualKw(yesterday);
+            if (!responseDate.atTime(time).isAfter(responseStartTime)) {
+                point.setTodayActualKw(scale(original.multiply(BigDecimal.valueOf(1.01 + pseudoRandom(seed, minute, 2) * 0.05))));
+            }
+            points.add(point);
+        }
+        return points;
+    }
+
+    public static BigDecimal mockCorrectionFactor(Long siteId, LocalDate responseDate) {
+        long seed = (siteId != null ? siteId : 0L) + responseDate.toEpochDay();
+        double raw = 0.85 + pseudoRandom(seed, 99, 3) * 0.25;
+        BigDecimal k = BigDecimal.valueOf(raw).setScale(4, RoundingMode.HALF_UP);
+        if (k.compareTo(K_MIN) < 0) {
+            return K_MIN;
+        }
+        if (k.compareTo(K_MAX) > 0) {
+            return K_MAX;
+        }
+        return k;
+    }
+
+    public static String formatDate(LocalDate date) {
+        return date.format(DATE_FMT);
+    }
+
+    private static final BigDecimal DECLARED_CAPACITY_DIVISOR = new BigDecimal("2");
+    private static final BigDecimal MINUTES_PER_HOUR = new BigDecimal("60");
+
+    /**
+     * 响应执行时段小时数(执行开始至结束)。
+     */
+    public static BigDecimal responseWindowHours(LocalDateTime windowStart, LocalDateTime windowEnd) {
+        if (windowStart == null || windowEnd == null || windowEnd.isBefore(windowStart)) {
+            return BigDecimal.ZERO;
+        }
+        long minutes = java.time.Duration.between(windowStart, windowEnd).toMinutes();
+        return BigDecimal.valueOf(minutes).divide(MINUTES_PER_HOUR, 4, RoundingMode.HALF_UP);
+    }
+
+    /**
+     * 响应申报容量 = 响应时段内历史负荷均值 × 修正系数 K × 执行时段小时数 / 2。
+     */
+    public static BigDecimal calculateDeclaredCapacityKw(Map<String, BigDecimal> historicalAvgByTime,
+                                                       LocalDateTime windowStart,
+                                                       LocalDateTime windowEnd,
+                                                       BigDecimal correctionFactorK) {
+        BigDecimal historicalAvg = averageValuesInWindow(historicalAvgByTime, windowStart, windowEnd);
+        if (historicalAvg == null || historicalAvg.compareTo(BigDecimal.ZERO) <= 0) {
+            return BigDecimal.ZERO;
+        }
+        return applyDeclaredCapacityFormula(historicalAvg, correctionFactorK, windowStart, windowEnd);
+    }
+
+    private static BigDecimal applyDeclaredCapacityFormula(BigDecimal historicalAvg,
+                                                           BigDecimal correctionFactorK,
+                                                           LocalDateTime windowStart,
+                                                           LocalDateTime windowEnd) {
+        BigDecimal k = correctionFactorK != null ? correctionFactorK : K_DEFAULT;
+        BigDecimal windowHours = responseWindowHours(windowStart, windowEnd);
+        return scale(historicalAvg.multiply(k).multiply(windowHours)
+                .divide(DECLARED_CAPACITY_DIVISOR, 6, RoundingMode.HALF_UP));
+    }
+
+    public static List<DeclaredCapacityPointVO> buildDeclaredCapacityPoints(Map<String, BigDecimal> historicalAvgByTime,
+                                                                          LocalDateTime windowStart,
+                                                                          LocalDateTime windowEnd,
+                                                                          BigDecimal correctionFactorK) {
+        List<DeclaredCapacityPointVO> points = new ArrayList<>();
+        if (historicalAvgByTime == null || historicalAvgByTime.isEmpty()) {
+            return points;
+        }
+        LocalDate date = windowStart.toLocalDate();
+        for (Map.Entry<String, BigDecimal> entry : historicalAvgByTime.entrySet()) {
+            LocalDateTime pointTime = LocalDateTime.of(date, parseTimeKey(entry.getKey()));
+            if (pointTime.isBefore(windowStart) || pointTime.isAfter(windowEnd)) {
+                continue;
+            }
+            BigDecimal historicalAvg = entry.getValue() != null ? entry.getValue() : BigDecimal.ZERO;
+            DeclaredCapacityPointVO point = new DeclaredCapacityPointVO();
+            point.setTime(entry.getKey());
+            point.setHistoricalAvgKw(scale(historicalAvg));
+            point.setDeclaredCapacityKw(applyDeclaredCapacityFormula(
+                    historicalAvg, correctionFactorK, windowStart, windowEnd));
+            points.add(point);
+        }
+        return points;
+    }
+
+    public static BigDecimal mockDeclaredCapacityKw(Long siteId,
+                                                    LocalDate responseDate,
+                                                    LocalDateTime windowStart,
+                                                    LocalDateTime windowEnd,
+                                                    BigDecimal correctionFactorK) {
+        return calculateDeclaredCapacityKw(
+                buildMockHistoricalWindow(siteId, responseDate, windowStart, windowEnd),
+                windowStart,
+                windowEnd,
+                correctionFactorK);
+    }
+
+    public static List<DeclaredCapacityPointVO> buildMockDeclaredCapacityPoints(Long siteId,
+                                                                                LocalDate responseDate,
+                                                                                LocalDateTime windowStart,
+                                                                                LocalDateTime windowEnd,
+                                                                                BigDecimal correctionFactorK) {
+        return buildDeclaredCapacityPoints(
+                buildMockHistoricalWindow(siteId, responseDate, windowStart, windowEnd),
+                windowStart,
+                windowEnd,
+                correctionFactorK);
+    }
+
+    private static Map<String, BigDecimal> buildMockHistoricalWindow(Long siteId,
+                                                                     LocalDate responseDate,
+                                                                     LocalDateTime windowStart,
+                                                                     LocalDateTime windowEnd) {
+        Map<String, BigDecimal> mockHistorical = new TreeMap<>();
+        long seed = (siteId != null ? siteId : 0L) + responseDate.toEpochDay();
+        LocalDateTime cursor = windowStart;
+        while (!cursor.isAfter(windowEnd)) {
+            double hourFactor = mockHourFactor(cursor.toLocalTime());
+            double noise = 0.9 + pseudoRandom(seed, cursor.getHour() * 60 + cursor.getMinute(), 4) * 0.2;
+            mockHistorical.put(cursor.toLocalTime().format(TIME_FMT),
+                    BigDecimal.valueOf(900 * hourFactor * noise).setScale(2, RoundingMode.HALF_UP));
+            cursor = cursor.plusMinutes(INTERVAL_MINUTES);
+        }
+        return mockHistorical;
+    }
+
+    private static BigDecimal averageValuesInWindow(Map<String, BigDecimal> valuesByTime,
+                                                    LocalDateTime windowStart,
+                                                    LocalDateTime windowEnd) {
+        if (valuesByTime == null || valuesByTime.isEmpty()) {
+            return null;
+        }
+        List<BigDecimal> values = new ArrayList<>();
+        LocalDate date = windowStart.toLocalDate();
+        for (Map.Entry<String, BigDecimal> entry : valuesByTime.entrySet()) {
+            LocalDateTime pointTime = LocalDateTime.of(date, parseTimeKey(entry.getKey()));
+            if (pointTime.isBefore(windowStart) || pointTime.isAfter(windowEnd)) {
+                continue;
+            }
+            if (entry.getValue() != null) {
+                values.add(entry.getValue());
+            }
+        }
+        return values.isEmpty() ? null : average(values);
+    }
+
+    private static Map<String, List<BigDecimal>> bucketSeries(TreeMap<LocalDateTime, BigDecimal> series,
+                                                              int intervalMinutes) {
+        Map<String, List<BigDecimal>> buckets = new TreeMap<>();
+        for (Map.Entry<LocalDateTime, BigDecimal> entry : series.entrySet()) {
+            if (entry.getValue() == null) {
+                continue;
+            }
+            String bucket = toBucketKey(entry.getKey(), intervalMinutes);
+            buckets.computeIfAbsent(bucket, key -> new ArrayList<>()).add(entry.getValue());
+        }
+        return buckets;
+    }
+
+    private static String toBucketKey(LocalDateTime timestamp, int intervalMinutes) {
+        int totalMinutes = timestamp.getHour() * 60 + timestamp.getMinute();
+        int bucketMinutes = (totalMinutes / intervalMinutes) * intervalMinutes;
+        return LocalTime.of(bucketMinutes / 60, bucketMinutes % 60).format(TIME_FMT);
+    }
+
+    private static LocalTime parseTimeKey(String timeKey) {
+        if (timeKey.length() == 5) {
+            return LocalTime.parse(timeKey + ":00", TIME_WITH_SEC_FMT);
+        }
+        return LocalTime.parse(timeKey, TIME_WITH_SEC_FMT);
+    }
+
+    private static BigDecimal sumValuesInWindow(Map<String, BigDecimal> valuesByTime,
+                                                LocalDateTime windowStart,
+                                                LocalDateTime windowEnd) {
+        if (valuesByTime == null || valuesByTime.isEmpty()) {
+            return null;
+        }
+        BigDecimal total = BigDecimal.ZERO;
+        int count = 0;
+        LocalDate date = windowStart.toLocalDate();
+        for (Map.Entry<String, BigDecimal> entry : valuesByTime.entrySet()) {
+            LocalDateTime pointTime = LocalDateTime.of(date, parseTimeKey(entry.getKey()));
+            if (pointTime.isBefore(windowStart) || pointTime.isAfter(windowEnd)) {
+                continue;
+            }
+            if (entry.getValue() != null) {
+                total = total.add(entry.getValue());
+                count++;
+            }
+        }
+        return count == 0 ? null : total;
+    }
+
+    private static TreeMap<LocalDateTime, BigDecimal> pickSeries(Map<String, TreeMap<LocalDateTime, BigDecimal>> metricMap) {
+        if (metricMap == null || metricMap.isEmpty()) {
+            return null;
+        }
+        for (String metric : POWER_METRICS) {
+            TreeMap<LocalDateTime, BigDecimal> series = metricMap.get(metric);
+            if (series != null && !series.isEmpty()) {
+                return series;
+            }
+        }
+        for (TreeMap<LocalDateTime, BigDecimal> series : metricMap.values()) {
+            if (series != null && !series.isEmpty()) {
+                return series;
+            }
+        }
+        return null;
+    }
+
+    private static BigDecimal sum(List<BigDecimal> values) {
+        if (values == null || values.isEmpty()) {
+            return BigDecimal.ZERO;
+        }
+        BigDecimal total = BigDecimal.ZERO;
+        for (BigDecimal value : values) {
+            if (value != null) {
+                total = total.add(value);
+            }
+        }
+        return total;
+    }
+
+    private static BigDecimal average(List<BigDecimal> values) {
+        if (values == null || values.isEmpty()) {
+            return BigDecimal.ZERO;
+        }
+        return sum(values).divide(BigDecimal.valueOf(values.size()), 4, RoundingMode.HALF_UP);
+    }
+
+    private static BigDecimal scale(BigDecimal value) {
+        if (value == null) {
+            return null;
+        }
+        return value.setScale(2, RoundingMode.HALF_UP);
+    }
+
+    private static double mockHourFactor(LocalTime time) {
+        int hour = time.getHour();
+        int minute = time.getMinute();
+        double h = hour + minute / 60.0;
+        if (h < 6) {
+            return 0.45 + h / 6 * 0.15;
+        }
+        if (h < 9) {
+            return 0.6 + (h - 6) / 3 * 0.35;
+        }
+        if (h < 12) {
+            return 0.95 + (h - 9) / 3 * 0.05;
+        }
+        if (h < 14) {
+            return 0.85;
+        }
+        if (h < 17) {
+            return 0.9 + (h - 14) / 3 * 0.1;
+        }
+        if (h < 21) {
+            return 1.0 - (h - 17) / 4 * 0.35;
+        }
+        return 0.5 - (h - 21) / 3 * 0.15;
+    }
+
+    private static double pseudoRandom(long seed, int minuteOfDay, int seriesSalt) {
+        long mixed = seed * 31L + minuteOfDay * 17L + seriesSalt * 13L;
+        return (mixed % 1000) / 1000.0;
+    }
+}

+ 56 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppBiddingConfigHelper.java

@@ -0,0 +1,56 @@
+package com.usky.vpp.util;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * 竞价配置工具
+ */
+public final class VppBiddingConfigHelper {
+
+    public static final int BIDDING_MODE_TIME_SLOT = 1;
+    public static final int BIDDING_MODE_WHOLE = 2;
+
+    private static final DateTimeFormatter PERIOD_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+    private VppBiddingConfigHelper() {
+    }
+
+    public static String resolveBiddingModeName(Integer biddingMode) {
+        if (biddingMode == null) {
+            return null;
+        }
+        switch (biddingMode) {
+            case BIDDING_MODE_TIME_SLOT:
+                return "分时段竞价";
+            case BIDDING_MODE_WHOLE:
+                return "整体竞价";
+            default:
+                return "未知模式";
+        }
+    }
+
+    public static String resolveAdvanceCloseHoursLabel(Integer advanceCloseHours) {
+        if (advanceCloseHours == null) {
+            return null;
+        }
+        return ">" + advanceCloseHours;
+    }
+
+    public static String formatEffectivePeriod(LocalDateTime start, LocalDateTime end) {
+        if (start == null || end == null) {
+            return null;
+        }
+        return PERIOD_FMT.format(start) + " - " + PERIOD_FMT.format(end);
+    }
+
+    public static void validatePriceRange(BigDecimal minPrice, BigDecimal maxPrice) {
+        if (minPrice == null || maxPrice == null) {
+            return;
+        }
+        if (minPrice.compareTo(maxPrice) > 0) {
+            throw new com.usky.common.core.exception.BusinessException("最低价格不能高于最高价格");
+        }
+    }
+}

+ 92 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppContractStatusHelper.java

@@ -0,0 +1,92 @@
+package com.usky.vpp.util;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.usky.vpp.domain.VppContract;
+
+import java.time.LocalDate;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * 合同状态工具:根据生效/到期日期自动解析生命周期状态
+ */
+public final class VppContractStatusHelper {
+
+    public static final int DRAFT = 0;
+    public static final int AUDITING = 1;
+    public static final int PENDING = 2;
+    public static final int EFFECTIVE = 3;
+    public static final int REJECTED = 4;
+    public static final int EXPIRED = 5;
+    public static final int TERMINATED = 6;
+
+    private static final Set<Integer> MANUAL_STATUSES = new HashSet<>(Arrays.asList(
+            DRAFT, AUDITING, REJECTED, TERMINATED
+    ));
+
+    private VppContractStatusHelper() {
+    }
+
+    /**
+     * 根据生效/到期日期解析合同状态;草稿、审核中、已驳回、已终止保持原状态不变。
+     */
+    public static Integer resolve(Integer storedStatus, LocalDate effectiveDate, LocalDate expireDate) {
+        if (storedStatus != null && MANUAL_STATUSES.contains(storedStatus)) {
+            return storedStatus;
+        }
+        return resolveByDates(effectiveDate, expireDate, storedStatus);
+    }
+
+    /**
+     * 创建/更新时根据日期计算应写入的状态;未提供日期时回退为待生效。
+     */
+    public static Integer resolveForPersist(Integer requestedStatus, LocalDate effectiveDate, LocalDate expireDate) {
+        if (requestedStatus != null && MANUAL_STATUSES.contains(requestedStatus)) {
+            return requestedStatus;
+        }
+        Integer resolved = resolveByDates(effectiveDate, expireDate, requestedStatus);
+        return resolved != null ? resolved : PENDING;
+    }
+
+    private static Integer resolveByDates(LocalDate effectiveDate, LocalDate expireDate, Integer fallback) {
+        LocalDate today = LocalDate.now();
+        if (expireDate != null && today.isAfter(expireDate)) {
+            return EXPIRED;
+        }
+        if (effectiveDate != null) {
+            return today.isBefore(effectiveDate) ? PENDING : EFFECTIVE;
+        }
+        return fallback;
+    }
+
+    /**
+     * 分页筛选:将生命周期状态(待生效/已生效/已到期)转换为日期条件。
+     */
+    public static void applyStatusFilter(LambdaQueryWrapper<VppContract> wrapper, Integer contractStatus) {
+        if (contractStatus == null) {
+            return;
+        }
+        LocalDate today = LocalDate.now();
+        if (contractStatus == PENDING) {
+            wrapper.and(w -> w.in(VppContract::getContractStatus, PENDING, EFFECTIVE, EXPIRED)
+                    .gt(VppContract::getEffectiveDate, today)
+                    .and(inner -> inner.isNull(VppContract::getExpireDate)
+                            .or().ge(VppContract::getExpireDate, today)));
+            return;
+        }
+        if (contractStatus == EFFECTIVE) {
+            wrapper.and(w -> w.in(VppContract::getContractStatus, PENDING, EFFECTIVE, EXPIRED)
+                    .le(VppContract::getEffectiveDate, today)
+                    .and(inner -> inner.isNull(VppContract::getExpireDate)
+                            .or().ge(VppContract::getExpireDate, today)));
+            return;
+        }
+        if (contractStatus == EXPIRED) {
+            wrapper.and(w -> w.in(VppContract::getContractStatus, PENDING, EFFECTIVE, EXPIRED)
+                    .lt(VppContract::getExpireDate, today));
+            return;
+        }
+        wrapper.eq(VppContract::getContractStatus, contractStatus);
+    }
+}

+ 37 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppDrParticipationHelper.java

@@ -0,0 +1,37 @@
+package com.usky.vpp.util;
+
+import com.usky.vpp.domain.VppDrParticipation;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+
+/**
+ * 需求响应参与记录工具
+ */
+public final class VppDrParticipationHelper {
+
+    private static final int RATE_SCALE = 4;
+
+    private VppDrParticipationHelper() {
+    }
+
+    /**
+     * 完成率 = 出清容量 / 申报容量;申报容量为空或 ≤0、出清容量为空时返回 null。
+     */
+    public static BigDecimal calcCompletionRate(BigDecimal clearedCapacityKw, BigDecimal declaredCapacityKw) {
+        if (clearedCapacityKw == null || declaredCapacityKw == null
+                || declaredCapacityKw.compareTo(BigDecimal.ZERO) <= 0) {
+            return null;
+        }
+        return clearedCapacityKw.divide(declaredCapacityKw, RATE_SCALE, RoundingMode.HALF_UP);
+    }
+
+    public static void refreshCompletionRate(VppDrParticipation participation) {
+        if (participation == null) {
+            return;
+        }
+        participation.setCompletionRate(calcCompletionRate(
+                participation.getClearedCapacityKw(),
+                participation.getDeclaredCapacityKw()));
+    }
+}

+ 320 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppReportHelper.java

@@ -0,0 +1,320 @@
+package com.usky.vpp.util;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.usky.vpp.domain.VppCustomer;
+import com.usky.vpp.domain.VppDrEvaluation;
+import com.usky.vpp.domain.VppDrEvent;
+import com.usky.vpp.domain.VppEnergySummaryDaily;
+import com.usky.vpp.domain.VppReportRecord;
+import com.usky.vpp.domain.VppReportTask;
+import com.usky.vpp.domain.VppResourcePoint;
+import com.usky.vpp.domain.VppSite;
+import com.usky.vpp.service.vo.ReportPrefillVO;
+import com.usky.vpp.service.vo.ReportValidateErrorVO;
+import com.usky.vpp.service.vo.ReportValidateResultVO;
+import org.springframework.util.StringUtils;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.YearMonth;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 监管信息报送工具
+ */
+public final class VppReportHelper {
+
+    public static final int TASK_STATUS_PENDING_FILL = 0;
+    public static final int TASK_STATUS_PENDING_AUDIT = 1;
+    public static final int TASK_STATUS_SUBMITTING = 2;
+    public static final int TASK_STATUS_COMPLETED = 3;
+    public static final int TASK_STATUS_OVERDUE = 4;
+
+    public static final int RECORD_STATUS_DRAFT = 0;
+    public static final int RECORD_STATUS_PENDING_AUDIT = 1;
+    public static final int RECORD_STATUS_SUBMITTING = 2;
+    public static final int RECORD_STATUS_SUCCESS = 3;
+    public static final int RECORD_STATUS_FAILED = 4;
+
+    public static final int SUBMIT_MODE_API = 1;
+    public static final int SUBMIT_MODE_MANUAL = 2;
+
+    public static final int AUDIT_ACTION_SUBMIT = 1;
+    public static final int AUDIT_ACTION_PASS = 2;
+    public static final int AUDIT_ACTION_REJECT = 3;
+
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+    private static final DateTimeFormatter RECORD_NO_FMT = DateTimeFormatter.ofPattern("yyyyMM");
+
+    private VppReportHelper() {
+    }
+
+    public static String resolveReportOrgName(Integer reportOrg) {
+        if (reportOrg == null) {
+            return null;
+        }
+        switch (reportOrg) {
+            case 1:
+                return "交易中心";
+            case 2:
+                return "能监局";
+            case 3:
+                return "经信委";
+            case 4:
+                return "国网上海";
+            default:
+                return "未知机构";
+        }
+    }
+
+    public static String nextRecordNo(String reportPeriod) {
+        String period = StringUtils.hasText(reportPeriod) ? reportPeriod.replace("-", "") : RECORD_NO_FMT.format(LocalDate.now());
+        return "REC-" + period + "-" + System.currentTimeMillis() % 1000;
+    }
+
+    public static String nextTaskNo() {
+        return "RPT-" + RECORD_NO_FMT.format(LocalDate.now()) + "-" + System.currentTimeMillis() % 1000;
+    }
+
+    public static Map<String, Object> parseFormData(String json) {
+        if (!StringUtils.hasText(json)) {
+            return null;
+        }
+        try {
+            return OBJECT_MAPPER.readValue(json, new TypeReference<Map<String, Object>>() {
+            });
+        } catch (Exception ex) {
+            return null;
+        }
+    }
+
+    public static String toJson(Object value) {
+        if (value == null) {
+            return null;
+        }
+        try {
+            return OBJECT_MAPPER.writeValueAsString(value);
+        } catch (Exception ex) {
+            return null;
+        }
+    }
+
+    public static List<Long> parseSiteIds(String json) {
+        if (!StringUtils.hasText(json)) {
+            return Collections.emptyList();
+        }
+        try {
+            return OBJECT_MAPPER.readValue(json, new TypeReference<List<Long>>() {
+            });
+        } catch (Exception ex) {
+            return Collections.emptyList();
+        }
+    }
+
+    public static ReportPrefillVO buildPrefill(VppReportRecord record,
+                                             VppReportTask task,
+                                             List<VppCustomer> customers,
+                                             List<VppResourcePoint> resources,
+                                             List<VppSite> sites,
+                                             List<VppEnergySummaryDaily> energyRows,
+                                             List<VppDrEvent> drEvents,
+                                             List<VppDrEvaluation> evaluations) {
+        ReportPrefillVO prefill = new ReportPrefillVO();
+        prefill.setReportPeriod(record.getReportPeriod());
+
+        int customerCount = customers != null ? customers.size() : 0;
+        prefill.setCustomerCount(customerCount);
+
+        BigDecimal totalCapacity = BigDecimal.ZERO;
+        int pvCount = 0;
+        int essCount = 0;
+        if (resources != null) {
+            for (VppResourcePoint resource : resources) {
+                if (resource.getCapacityKw() != null) {
+                    totalCapacity = totalCapacity.add(resource.getCapacityKw());
+                }
+                if ("PV".equalsIgnoreCase(resource.getResourceType())) {
+                    pvCount++;
+                } else if ("ESS".equalsIgnoreCase(resource.getResourceType())) {
+                    essCount++;
+                }
+            }
+        }
+        prefill.setTotalCapacityKw(totalCapacity);
+
+        BigDecimal totalGen = BigDecimal.ZERO;
+        BigDecimal totalUse = BigDecimal.ZERO;
+        if (energyRows != null) {
+            for (VppEnergySummaryDaily row : energyRows) {
+                if (row.getGenEnergyKwh() != null) {
+                    totalGen = totalGen.add(row.getGenEnergyKwh());
+                }
+                if (row.getUseEnergyKwh() != null) {
+                    totalUse = totalUse.add(row.getUseEnergyKwh());
+                }
+            }
+        }
+        prefill.setTotalGenKwh(totalGen);
+        prefill.setTotalUseKwh(totalUse);
+        prefill.setDrEventCount(drEvents != null ? drEvents.size() : 0);
+
+        BigDecimal drSubsidy = BigDecimal.ZERO;
+        if (evaluations != null) {
+            for (VppDrEvaluation evaluation : evaluations) {
+                if (evaluation.getSubsidyAmount() != null) {
+                    drSubsidy = drSubsidy.add(evaluation.getSubsidyAmount());
+                }
+            }
+        }
+        prefill.setDrTotalSubsidy(drSubsidy);
+
+        String vppName = resolveVppName(sites, customers, task);
+        Map<String, Object> formData = new LinkedHashMap<>();
+        Map<String, Object> basicInfo = new LinkedHashMap<>();
+        basicInfo.put("vppName", vppName);
+        basicInfo.put("reportMonth", record.getReportPeriod());
+        formData.put("basicInfo", basicInfo);
+
+        Map<String, Object> resourceStats = new LinkedHashMap<>();
+        resourceStats.put("pvCount", pvCount);
+        resourceStats.put("essCount", essCount);
+        resourceStats.put("totalCapacityKw", totalCapacity);
+        formData.put("resourceStats", resourceStats);
+
+        Map<String, Object> energyData = new LinkedHashMap<>();
+        energyData.put("genKwh", totalGen);
+        energyData.put("useKwh", totalUse);
+        formData.put("energyData", energyData);
+
+        Map<String, Object> drData = new LinkedHashMap<>();
+        drData.put("eventCount", prefill.getDrEventCount());
+        drData.put("totalSubsidy", drSubsidy);
+        formData.put("drData", drData);
+
+        prefill.setFormData(formData);
+        return prefill;
+    }
+
+    public static ReportValidateResultVO validateFormData(Map<String, Object> formData,
+                                                          int systemPvCount,
+                                                          boolean strict) {
+        ReportValidateResultVO result = new ReportValidateResultVO();
+        List<ReportValidateErrorVO> errors = new ArrayList<>();
+        if (formData == null || formData.isEmpty()) {
+            errors.add(new ReportValidateErrorVO("formData", "填报数据不能为空"));
+            result.setValid(false);
+            result.setErrors(errors);
+            return result;
+        }
+
+        JsonNode root = OBJECT_MAPPER.valueToTree(formData);
+        JsonNode basicInfo = root.path("basicInfo");
+        if (strict) {
+            if (!StringUtils.hasText(basicInfo.path("vppName").asText(null))) {
+                errors.add(new ReportValidateErrorVO("basicInfo.vppName", "虚拟电厂名称不能为空"));
+            }
+            if (!StringUtils.hasText(basicInfo.path("reportMonth").asText(null))) {
+                errors.add(new ReportValidateErrorVO("basicInfo.reportMonth", "报送月份不能为空"));
+            }
+        }
+
+        JsonNode energyData = root.path("energyData");
+        BigDecimal genKwh = readDecimal(energyData.path("genKwh"));
+        BigDecimal useKwh = readDecimal(energyData.path("useKwh"));
+        if (genKwh != null && genKwh.compareTo(BigDecimal.ZERO) < 0) {
+            errors.add(new ReportValidateErrorVO("energyData.genKwh", "发电量不能为负数"));
+        }
+        if (useKwh != null && useKwh.compareTo(BigDecimal.ZERO) < 0) {
+            errors.add(new ReportValidateErrorVO("energyData.useKwh", "用电量不能为负数"));
+        }
+
+        JsonNode resourceStats = root.path("resourceStats");
+        if (resourceStats.has("pvCount") && !resourceStats.path("pvCount").isNull()) {
+            int formPvCount = resourceStats.path("pvCount").asInt(-1);
+            if (formPvCount >= 0 && formPvCount != systemPvCount) {
+                errors.add(new ReportValidateErrorVO("resourceStats.pvCount", "光伏数量与系统统计不一致"));
+            }
+        }
+
+        result.setErrors(errors);
+        result.setValid(errors.isEmpty());
+        return result;
+    }
+
+    public static PeriodRange parseReportPeriod(String reportPeriod) {
+        if (!StringUtils.hasText(reportPeriod)) {
+            return null;
+        }
+        try {
+            YearMonth yearMonth = YearMonth.parse(reportPeriod, DateTimeFormatter.ofPattern("yyyy-MM"));
+            return new PeriodRange(yearMonth.atDay(1), yearMonth.atEndOfMonth());
+        } catch (DateTimeParseException ex) {
+            return null;
+        }
+    }
+
+    private static String resolveVppName(List<VppSite> sites, List<VppCustomer> customers, VppReportTask task) {
+        if (sites != null) {
+            for (VppSite site : sites) {
+                if (StringUtils.hasText(site.getOwnerName())) {
+                    return site.getOwnerName();
+                }
+            }
+        }
+        if (customers != null && customers.size() == 1) {
+            String name = customers.get(0).getCustomerName();
+            if (StringUtils.hasText(name)) {
+                return name;
+            }
+        }
+        if (task != null && StringUtils.hasText(task.getTaskName())) {
+            return task.getTaskName();
+        }
+        return "虚拟电厂";
+    }
+
+    private static BigDecimal readDecimal(JsonNode node) {
+        if (node == null || node.isNull() || node.isMissingNode()) {
+            return null;
+        }
+        if (node.isNumber()) {
+            return node.decimalValue();
+        }
+        String text = node.asText(null);
+        if (!StringUtils.hasText(text)) {
+            return null;
+        }
+        try {
+            return new BigDecimal(text);
+        } catch (NumberFormatException ex) {
+            return null;
+        }
+    }
+
+    public static final class PeriodRange {
+        private final LocalDate start;
+        private final LocalDate end;
+
+        public PeriodRange(LocalDate start, LocalDate end) {
+            this.start = start;
+            this.end = end;
+        }
+
+        public LocalDate getStart() {
+            return start;
+        }
+
+        public LocalDate getEnd() {
+            return end;
+        }
+    }
+}

+ 340 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppResponseMonitorHelper.java

@@ -0,0 +1,340 @@
+package com.usky.vpp.util;
+
+import com.usky.vpp.domain.VppDevice;
+import com.usky.vpp.domain.VppDrEvent;
+import com.usky.vpp.domain.VppDrExecution;
+import com.usky.vpp.domain.VppDrParticipation;
+import com.usky.vpp.domain.VppResourcePoint;
+import com.usky.vpp.service.vo.ResponseLoadPointVO;
+import com.usky.vpp.service.vo.ResponseLoadStatRowVO;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * 响应监控辅助(15 分钟粒度曲线 + 统计表)
+ */
+public final class VppResponseMonitorHelper {
+
+    public static final int BUCKET_MINUTES = 15;
+    public static final int BASELINE_DAYS_AGO = 7;
+
+    private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("HH:mm");
+    private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+    private static final DateTimeFormatter HOUR_FMT = DateTimeFormatter.ofPattern("HH:00");
+
+    private VppResponseMonitorHelper() {
+    }
+
+    public static List<ResponseLoadPointVO> buildCurveFromTsdb(
+            Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> actualData,
+            Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> baselineData,
+            LocalDate date,
+            List<String> powerMetrics,
+            BigDecimal declaredCapacityKw,
+            Map<LocalDateTime, BigDecimal> executionActualByTime) {
+        List<ResponseLoadPointVO> points = new ArrayList<>();
+        for (int minute = 0; minute < 1440; minute += BUCKET_MINUTES) {
+            LocalTime time = LocalTime.of(minute / 60, minute % 60);
+            LocalDateTime bucketStart = date.atTime(time);
+            LocalDateTime bucketEnd = bucketStart.plusMinutes(BUCKET_MINUTES).minusSeconds(1);
+
+            BigDecimal actual = sumBucketPower(actualData, powerMetrics, bucketStart, bucketEnd);
+            BigDecimal baseline = sumBucketPower(baselineData, powerMetrics,
+                    date.minusDays(BASELINE_DAYS_AGO).atTime(time),
+                    date.minusDays(BASELINE_DAYS_AGO).atTime(time).plusMinutes(BUCKET_MINUTES).minusSeconds(1));
+            if (baseline == null || baseline.compareTo(BigDecimal.ZERO) <= 0) {
+                baseline = actual;
+            }
+
+            BigDecimal responseLoad = executionActualByTime != null
+                    ? executionActualByTime.getOrDefault(bucketStart, null)
+                    : null;
+            if (responseLoad == null) {
+                responseLoad = baseline.subtract(actual).max(BigDecimal.ZERO);
+            }
+
+            ResponseLoadPointVO point = new ResponseLoadPointVO();
+            point.setTime(time.format(TIME_FMT));
+            point.setDatetime(bucketStart.format(DATETIME_FMT));
+            point.setBaselineLoadKw(scale(baseline));
+            point.setActualLoadKw(scale(actual));
+            point.setDeclaredCapacityKw(declaredCapacityKw != null ? scale(declaredCapacityKw) : BigDecimal.ZERO);
+            point.setActualResponseLoadKw(scale(responseLoad));
+            points.add(point);
+        }
+        return points;
+    }
+
+    public static List<ResponseLoadPointVO> buildMockCurve(List<VppResourcePoint> resources,
+                                                         List<VppDevice> devices,
+                                                         Long siteId,
+                                                         LocalDate date,
+                                                         BigDecimal declaredCapacityKw) {
+        List<ResponseLoadPointVO> points = new ArrayList<>();
+        long seed = (siteId != null ? siteId : 0L) + date.toEpochDay();
+        for (int minute = 0; minute < 1440; minute += BUCKET_MINUTES) {
+            LocalTime time = LocalTime.of(minute / 60, minute % 60);
+            double hourFactor = dayTimeFactor(time);
+            BigDecimal baseline = mockAggregate(resources, devices, seed, minute, hourFactor, 0);
+            BigDecimal actual = mockAggregate(resources, devices, seed, minute, hourFactor, 1);
+            BigDecimal response = baseline.subtract(actual).max(BigDecimal.ZERO);
+
+            ResponseLoadPointVO point = new ResponseLoadPointVO();
+            point.setTime(time.format(TIME_FMT));
+            point.setDatetime(date.atTime(time).format(DATETIME_FMT));
+            point.setBaselineLoadKw(baseline);
+            point.setActualLoadKw(actual);
+            point.setDeclaredCapacityKw(declaredCapacityKw != null ? declaredCapacityKw : BigDecimal.ZERO);
+            point.setActualResponseLoadKw(response);
+            points.add(point);
+        }
+        return points;
+    }
+
+    public static List<ResponseLoadStatRowVO> buildStatistics(List<ResponseLoadPointVO> points, LocalDate date) {
+        if (points == null || points.isEmpty()) {
+            return Collections.emptyList();
+        }
+        ResponseLoadStatRowVO actualRow = buildStatRow(points, "ACTUAL", "实际用电量", true);
+        ResponseLoadStatRowVO predictedRow = buildStatRow(points, "PREDICTED", "预测用电量", false);
+        List<ResponseLoadStatRowVO> rows = new ArrayList<>(2);
+        rows.add(actualRow);
+        rows.add(predictedRow);
+        return rows;
+    }
+
+    public static BigDecimal resolveDeclaredCapacity(VppDrEvent event,
+                                                     List<VppDrParticipation> participations,
+                                                     List<VppDrExecution> executions) {
+        if (executions != null && !executions.isEmpty()) {
+            BigDecimal total = BigDecimal.ZERO;
+            for (VppDrExecution execution : executions) {
+                if (execution.getTargetKw() != null) {
+                    total = total.add(execution.getTargetKw());
+                }
+            }
+            if (total.compareTo(BigDecimal.ZERO) > 0) {
+                return total;
+            }
+        }
+        if (participations != null && !participations.isEmpty()) {
+            BigDecimal total = BigDecimal.ZERO;
+            for (VppDrParticipation participation : participations) {
+                BigDecimal cap = participation.getClearedCapacityKw() != null
+                        ? participation.getClearedCapacityKw()
+                        : participation.getDeclaredCapacityKw();
+                if (cap != null) {
+                    total = total.add(cap);
+                }
+            }
+            if (total.compareTo(BigDecimal.ZERO) > 0) {
+                return total;
+            }
+        }
+        if (event != null && event.getClearedCapacityKw() != null) {
+            return event.getClearedCapacityKw();
+        }
+        if (event != null && event.getTargetCapacityKw() != null) {
+            return event.getTargetCapacityKw();
+        }
+        return BigDecimal.ZERO;
+    }
+
+    private static ResponseLoadStatRowVO buildStatRow(List<ResponseLoadPointVO> points,
+                                                      String dataType,
+                                                      String label,
+                                                      boolean actual) {
+        ResponseLoadStatRowVO row = new ResponseLoadStatRowVO();
+        row.setDataType(dataType);
+        row.setDataTypeLabel(label);
+
+        Map<String, List<BigDecimal>> hourlyBuckets = new LinkedHashMap<>();
+        for (int h = 0; h < 24; h++) {
+            hourlyBuckets.put(LocalTime.of(h, 0).format(HOUR_FMT), new ArrayList<>());
+        }
+
+        List<BigDecimal> allKw = new ArrayList<>();
+        for (ResponseLoadPointVO point : points) {
+            BigDecimal kw = actual ? point.getActualLoadKw() : point.getBaselineLoadKw();
+            if (kw == null) {
+                continue;
+            }
+            if (!actual) {
+                kw = kw.multiply(BigDecimal.valueOf(1.02 + pseudo(point.getTime()) * 0.05));
+            }
+            allKw.add(kw);
+            String hourKey = point.getTime().substring(0, 2) + ":00";
+            hourlyBuckets.computeIfAbsent(hourKey, k -> new ArrayList<>()).add(kw);
+        }
+
+        Map<String, BigDecimal> hourlyValues = new LinkedHashMap<>();
+        BigDecimal dayTotalKwh = BigDecimal.ZERO;
+        for (Map.Entry<String, List<BigDecimal>> entry : hourlyBuckets.entrySet()) {
+            BigDecimal hourKwh = average(entry.getValue())
+                    .multiply(BigDecimal.valueOf(BUCKET_MINUTES))
+                    .divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP);
+            hourlyValues.put(entry.getKey(), hourKwh);
+            dayTotalKwh = dayTotalKwh.add(hourKwh);
+        }
+
+        row.setHourlyValues(hourlyValues);
+        row.setTotal(dayTotalKwh.setScale(2, RoundingMode.HALF_UP));
+        row.setMax(max(allKw));
+        row.setMin(min(allKw));
+        row.setAvg(average(allKw));
+        return row;
+    }
+
+    private static BigDecimal mockAggregate(List<VppResourcePoint> resources,
+                                            List<VppDevice> devices,
+                                            long seed,
+                                            int minuteOfDay,
+                                            double hourFactor,
+                                            int seriesSalt) {
+        if (resources == null || resources.isEmpty()) {
+            double base = 580 * hourFactor + pseudoRandom(seed, 0L, minuteOfDay, seriesSalt) * 80;
+            return BigDecimal.valueOf(base).setScale(2, RoundingMode.HALF_UP);
+        }
+        BigDecimal total = BigDecimal.ZERO;
+        for (VppResourcePoint resource : resources) {
+            BigDecimal cap = resource.getCapacityKw() != null ? resource.getCapacityKw() : BigDecimal.valueOf(100);
+            double noise = 0.85 + pseudoRandom(seed, resource.getId() != null ? resource.getId() : 0L,
+                    minuteOfDay, seriesSalt) * 0.3;
+            double value = cap.doubleValue() * hourFactor * 0.6 * noise;
+            total = total.add(BigDecimal.valueOf(value));
+        }
+        if (seriesSalt == 1) {
+            total = total.multiply(BigDecimal.valueOf(0.95 + pseudoRandom(seed, 999, minuteOfDay, seriesSalt) * 0.08));
+        }
+        return total.setScale(2, RoundingMode.HALF_UP);
+    }
+
+    private static BigDecimal sumBucketPower(Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> deviceData,
+                                             List<String> metricCandidates,
+                                             LocalDateTime bucketStart,
+                                             LocalDateTime bucketEnd) {
+        if (deviceData == null || deviceData.isEmpty()) {
+            return BigDecimal.ZERO;
+        }
+        BigDecimal total = BigDecimal.ZERO;
+        for (Map<String, TreeMap<LocalDateTime, BigDecimal>> metricMap : deviceData.values()) {
+            TreeMap<LocalDateTime, BigDecimal> series = pickSeries(metricMap, metricCandidates);
+            if (series == null || series.isEmpty()) {
+                continue;
+            }
+            BigDecimal avg = averageInRange(series, bucketStart, bucketEnd);
+            if (avg != null) {
+                total = total.add(avg);
+            }
+        }
+        return total;
+    }
+
+    private static TreeMap<LocalDateTime, BigDecimal> pickSeries(Map<String, TreeMap<LocalDateTime, BigDecimal>> metricMap,
+                                                                 List<String> candidates) {
+        if (metricMap == null) {
+            return null;
+        }
+        for (String metric : candidates) {
+            TreeMap<LocalDateTime, BigDecimal> series = metricMap.get(metric);
+            if (series != null && !series.isEmpty()) {
+                return series;
+            }
+        }
+        return null;
+    }
+
+    private static BigDecimal averageInRange(TreeMap<LocalDateTime, BigDecimal> series,
+                                              LocalDateTime start,
+                                              LocalDateTime end) {
+        BigDecimal sum = BigDecimal.ZERO;
+        int count = 0;
+        for (Map.Entry<LocalDateTime, BigDecimal> entry : series.subMap(start, true, end, true).entrySet()) {
+            if (entry.getValue() != null) {
+                sum = sum.add(entry.getValue());
+                count++;
+            }
+        }
+        if (count == 0) {
+            return null;
+        }
+        return sum.divide(BigDecimal.valueOf(count), 4, RoundingMode.HALF_UP);
+    }
+
+    private static BigDecimal scale(BigDecimal value) {
+        if (value == null) {
+            return BigDecimal.ZERO;
+        }
+        return value.setScale(2, RoundingMode.HALF_UP);
+    }
+
+    private static BigDecimal average(List<BigDecimal> values) {
+        if (values == null || values.isEmpty()) {
+            return BigDecimal.ZERO;
+        }
+        BigDecimal sum = BigDecimal.ZERO;
+        for (BigDecimal v : values) {
+            sum = sum.add(v);
+        }
+        return sum.divide(BigDecimal.valueOf(values.size()), 2, RoundingMode.HALF_UP);
+    }
+
+    private static BigDecimal max(List<BigDecimal> values) {
+        return values.stream().max(BigDecimal::compareTo).orElse(BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP);
+    }
+
+    private static BigDecimal min(List<BigDecimal> values) {
+        return values.stream().min(BigDecimal::compareTo).orElse(BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP);
+    }
+
+    private static double pseudo(String time) {
+        if (time == null) {
+            return 0.5;
+        }
+        return (time.hashCode() & 0x7FFFFFFF) % 100 / 100.0;
+    }
+
+    private static double pseudoRandom(long seed, long a, int b, int c) {
+        long x = seed * 31 + a * 17 + b * 13L + c;
+        x = ((x >> 16) ^ x) * 0x45d9f3b;
+        x = ((x >> 16) ^ x) * 0x45d9f3b;
+        x = (x >> 16) ^ x;
+        return (x & 0x7FFFFFFF) / (double) 0x7FFFFFFF;
+    }
+
+    private static double dayTimeFactor(LocalTime time) {
+        int hour = time.getHour();
+        int minute = time.getMinute();
+        double h = hour + minute / 60.0;
+        if (h < 6) {
+            return 0.45 + h / 6 * 0.15;
+        }
+        if (h < 9) {
+            return 0.6 + (h - 6) / 3 * 0.35;
+        }
+        if (h < 12) {
+            return 0.95 + (h - 9) / 3 * 0.05;
+        }
+        if (h < 14) {
+            return 0.85;
+        }
+        if (h < 18) {
+            return 0.75 + (h - 14) / 4 * 0.25;
+        }
+        if (h < 21) {
+            return 1.0 - (h - 18) / 3 * 0.2;
+        }
+        return 0.55 - (h - 21) / 3 * 0.15;
+    }
+}

+ 61 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppWorkdayHelper.java

@@ -0,0 +1,61 @@
+package com.usky.vpp.util;
+
+import cn.hutool.core.date.DateUtil;
+import io.github.shawyeok.chinese.workday.WorkdayCalendar;
+
+import java.time.LocalDate;
+import java.util.Date;
+
+/**
+ * 工作日 / 非工作日判定(法定节假日 + 调休)
+ * <p>Hutool {@link DateUtil} 负责日期转换;节假日数据由 chinese-workday-calendar 提供
+ * (Hutool core 未内置 {@code ChineseHoliday})。</p>
+ */
+public final class VppWorkdayHelper {
+
+    public static final String DAY_TYPE_WORKDAY = "WORKDAY";
+    public static final String DAY_TYPE_NON_WORKDAY = "NON_WORKDAY";
+
+    private static final WorkdayCalendar WORKDAY_CALENDAR = WorkdayCalendar.builder().build();
+
+    private VppWorkdayHelper() {
+    }
+
+    /**
+     * 是否为工作日(含调休上班,排除法定节假日与正常周末)
+     */
+    public static boolean isWorkday(LocalDate date) {
+        if (date == null) {
+            return false;
+        }
+        Date targetDate = DateUtil.date(date);
+        boolean isHoliday = isRestDay(targetDate, date);
+        boolean isAdjustWork = isMakeupWorkday(targetDate, date);
+        return !isHoliday || isAdjustWork;
+    }
+
+    /**
+     * 是否为非工作日(周末或法定节假日,不含调休上班)
+     */
+    public static boolean isNonWorkday(LocalDate date) {
+        return !isWorkday(date);
+    }
+
+    public static String resolveDayType(LocalDate date) {
+        return isWorkday(date) ? DAY_TYPE_WORKDAY : DAY_TYPE_NON_WORKDAY;
+    }
+
+    public static String resolveDayTypeLabel(LocalDate date) {
+        return isWorkday(date) ? "工作日" : "非工作日";
+    }
+
+    /** 是否为休息日(法定节假日或正常周末,不含调休上班) */
+    private static boolean isRestDay(Date targetDate, LocalDate localDate) {
+        return !WORKDAY_CALENDAR.isWorkday(localDate);
+    }
+
+    /** 是否为调休上班日(周末补班) */
+    private static boolean isMakeupWorkday(Date targetDate, LocalDate localDate) {
+        return DateUtil.isWeekend(targetDate) && WORKDAY_CALENDAR.isWorkday(localDate);
+    }
+}

+ 2 - 0
service-vpp/service-vpp-biz/src/main/resources/bootstrap.yml

@@ -36,3 +36,5 @@ vpp:
     enabled: true
     sign-name: 上海永天科技股份有限公司
     invitation-template-code: SMS_465362899
+  report:
+    retry-max: 3

+ 35 - 0
service-vpp/service-vpp-biz/src/main/resources/sql/vpp_bidding_config_migration.sql

@@ -0,0 +1,35 @@
+-- 竞价配置表(已有库执行,可重复执行)
+
+SET @sql = IF(
+    (SELECT COUNT(*) FROM information_schema.tables
+     WHERE table_schema = DATABASE()
+       AND table_name = 'vpp_bidding_config') = 0,
+    'CREATE TABLE `vpp_bidding_config` (
+        `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT ''主键'',
+        `config_name` VARCHAR(200) NOT NULL COMMENT ''配置名称'',
+        `effective_start` DATETIME(3) NOT NULL COMMENT ''适用开始时间'',
+        `effective_end` DATETIME(3) NOT NULL COMMENT ''适用结束时间'',
+        `attachment_url` VARCHAR(500) NULL COMMENT ''附件URL'',
+        `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小时'',
+        `max_price` DECIMAL(12,4) NOT NULL COMMENT ''最高价格(元)'',
+        `min_price` DECIMAL(12,4) NOT NULL COMMENT ''最低价格(元)'',
+        `site_ids` JSON NULL COMMENT ''关联站点ID列表'',
+        `tenant_id` INT NULL COMMENT ''租户ID'',
+        `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间'',
+        `update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间'',
+        `created_by` VARCHAR(30) NULL COMMENT ''创建人'',
+        `updated_by` VARCHAR(30) NULL COMMENT ''更新人'',
+        `delete_flag` INT(1) NOT NULL DEFAULT 0 COMMENT ''删除标识 0未删除 1已删除'',
+        `deleted_at` DATETIME(3) NULL COMMENT ''软删除时间'',
+        PRIMARY KEY (`id`),
+        KEY `idx_bidding_config_effective` (effective_start, effective_end),
+        KEY `idx_bidding_config_name` (config_name)
+    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT=''竞价配置''',
+    'SELECT 1'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;

+ 24 - 0
service-vpp/service-vpp-biz/src/main/resources/sql/vpp_dr_participation_completion_rate_migration.sql

@@ -0,0 +1,24 @@
+-- 事件参与记录增加完成率(已有库执行,可重复执行)
+-- completion_rate = cleared_capacity_kw / declared_capacity_kw
+
+SET @sql = IF(
+    (SELECT COUNT(*) FROM information_schema.columns
+     WHERE table_schema = DATABASE()
+       AND table_name = 'vpp_dr_participation'
+       AND column_name = 'completion_rate') = 0,
+    'ALTER TABLE `vpp_dr_participation` ADD COLUMN `completion_rate` DECIMAL(12,4) NULL COMMENT ''完成率=出清容量/申报容量'' AFTER `cleared_capacity_kw`',
+    'SELECT 1'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 回填已有出清数据
+UPDATE `vpp_dr_participation`
+SET `completion_rate` = ROUND(`cleared_capacity_kw` / `declared_capacity_kw`, 4)
+WHERE `delete_flag` = 0
+  AND `cleared_capacity_kw` IS NOT NULL
+  AND `declared_capacity_kw` IS NOT NULL
+  AND `declared_capacity_kw` > 0
+  AND (`completion_rate` IS NULL
+       OR `completion_rate` <> ROUND(`cleared_capacity_kw` / `declared_capacity_kw`, 4));

+ 13 - 0
service-vpp/service-vpp-biz/src/main/resources/sql/vpp_report_task_site_ids_migration.sql

@@ -0,0 +1,13 @@
+-- 报送任务关联站点(已有库执行,可重复执行)
+
+SET @sql = IF(
+    (SELECT COUNT(*) FROM information_schema.columns
+     WHERE table_schema = DATABASE()
+       AND table_name = 'vpp_report_task'
+       AND column_name = 'site_ids') = 0,
+    'ALTER TABLE `vpp_report_task` ADD COLUMN `site_ids` JSON NULL COMMENT ''关联站点ID列表'' AFTER `remind_days`',
+    'SELECT 1'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;

+ 32 - 4
service-vpp/service-vpp-biz/src/main/resources/sql/vpp_schema.sql

@@ -45,7 +45,7 @@ CREATE TABLE `vpp_customer_access` (
     `access_status` TINYINT NOT NULL COMMENT '0待审核 1通过 2驳回',
     `audit_opinion` VARCHAR(500) NULL COMMENT '审核意见',
     `audit_at` DATETIME(3) NULL COMMENT '审核时间',
-    `customer_id` BIGINT NULL COMMENT '通过后关联客户ID',
+    `customer_id` BIGINT NULL COMMENT '关联客户ID',
     `apply_at` DATETIME(3) NOT NULL COMMENT '申请时间',
     `tenant_id` INT NULL COMMENT '租户ID',
     `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -239,7 +239,7 @@ CREATE TABLE `vpp_device_control_log` (
 CREATE TABLE `vpp_energy_reading_monthly` (
     `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
     `customer_id` BIGINT NOT NULL COMMENT '客户ID',
-    `resource_id` BIGINT NULL COMMENT '资源点ID,可空表示户号级',
+    `site_id` BIGINT NULL COMMENT '站点ID,可空表示户号级',
     `settle_year` SMALLINT NOT NULL COMMENT '结算年',
     `settle_month` TINYINT NOT NULL COMMENT '结算月',
     `total_energy_kwh` DECIMAL(18,4) NOT NULL COMMENT '总用电量 kWh',
@@ -258,7 +258,7 @@ CREATE TABLE `vpp_energy_reading_monthly` (
     `delete_flag` INT(1) NOT NULL DEFAULT 0 COMMENT '删除标识 0未删除 1已删除',
     `deleted_at` DATETIME(3) NULL COMMENT '软删除时间',
     PRIMARY KEY (`id`),
-    UNIQUE KEY `uk_energy_monthly` (customer_id, resource_id, settle_year, settle_month),
+    UNIQUE KEY `uk_energy_monthly` (customer_id, site_id, settle_year, settle_month),
     KEY `idx_energy_period` (settle_year, settle_month)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='月度电量核算';
 
@@ -288,6 +288,7 @@ CREATE TABLE `vpp_settlement_bill` (
     `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
     `bill_no` VARCHAR(64) NOT NULL COMMENT '账单编号',
     `customer_id` BIGINT NOT NULL COMMENT '客户ID',
+    `site_id` BIGINT NULL COMMENT '站点ID,可空表示户号级',
     `settle_year` SMALLINT NOT NULL COMMENT '结算年',
     `settle_month` TINYINT NOT NULL COMMENT '结算月',
     `total_energy_kwh` DECIMAL(18,4) NOT NULL COMMENT '总用电量',
@@ -348,6 +349,7 @@ CREATE TABLE `vpp_report_task` (
     `task_status` TINYINT NOT NULL COMMENT '0待填报 1待审核 2报送中 3已完成 4已逾期',
     `assignee_id` BIGINT NULL COMMENT '填报人',
     `remind_days` INT NULL COMMENT '提前提醒天数',
+    `site_ids` JSON NULL COMMENT '关联站点ID列表',
     `tenant_id` INT NULL COMMENT '租户ID',
     `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
     `update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
@@ -449,6 +451,7 @@ CREATE TABLE `vpp_dr_participation` (
     `participate_status` TINYINT NOT NULL COMMENT '0待确认 1参与 2拒绝',
     `declared_capacity_kw` DECIMAL(12,4) NULL COMMENT '申报容量',
     `cleared_capacity_kw` DECIMAL(12,4) NULL COMMENT '出清容量',
+    `completion_rate` DECIMAL(12,4) NULL COMMENT '完成率=出清容量/申报容量',
     `declared_at` DATETIME(3) NULL COMMENT '申报时间',
     `tenant_id` INT NULL COMMENT '租户ID',
     `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -620,4 +623,29 @@ CREATE TABLE vpp_file_archive (
     deleted_at  DATETIME(3)    NULL COMMENT '软删除时间',
     PRIMARY KEY (id),
     KEY idx_file_archive_biz (biz_type, biz_id)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='电子档案';
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='电子档案';
+
+CREATE TABLE `vpp_bidding_config` (
+    `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
+    `config_name` VARCHAR(200) NOT NULL COMMENT '配置名称',
+    `effective_start` DATETIME(3) NOT NULL COMMENT '适用开始时间',
+    `effective_end` DATETIME(3) NOT NULL COMMENT '适用结束时间',
+    `attachment_url` VARCHAR(500) NULL COMMENT '附件URL',
+    `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小时',
+    `max_price` DECIMAL(12,4) NOT NULL COMMENT '最高价格(元)',
+    `min_price` DECIMAL(12,4) NOT NULL COMMENT '最低价格(元)',
+    `site_ids` JSON NULL COMMENT '关联站点ID列表',
+    `tenant_id` INT NULL COMMENT '租户ID',
+    `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+    `update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+    `created_by` VARCHAR(30) NULL COMMENT '创建人',
+    `updated_by` VARCHAR(30) NULL COMMENT '更新人',
+    `delete_flag` INT(1) NOT NULL DEFAULT 0 COMMENT '删除标识 0未删除 1已删除',
+    `deleted_at` DATETIME(3) NULL COMMENT '软删除时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_bidding_config_effective` (effective_start, effective_end),
+    KEY `idx_bidding_config_name` (config_name)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='竞价配置';