浏览代码

Merge branch 'vpp-zyj' into server-165

james 5 天之前
父节点
当前提交
2823da6a91
共有 100 个文件被更改,包括 5825 次插入258 次删除
  1. 3 1
      service-alarm/service-alarm-biz/src/main/java/com/usky/alarm/service/listener/MqttListener.java
  2. 1 0
      service-alarm/service-alarm-biz/src/main/java/com/usky/alarm/service/mqtt/alarm/Alarm.java
  3. 4 2
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/controller/SipPushController.java
  4. 10 1
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/impl/SipRtspPushService.java
  5. 8 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/config/SipClientProperties.java
  6. 1 1
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/config/SipServerProperties.java
  7. 101 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/device/Gb28181CatalogBuilder.java
  8. 162 14
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/device/Gb28181DeviceSimulator.java
  9. 8 1
      service-cdi/service-cdi-biz/src/main/resources/application-gb28181-video.yml
  10. 11 0
      service-fire/service-fire-biz/src/main/java/com/usky/fire/controller/web/PatrolInspectionPlanController.java
  11. 5 0
      service-fire/service-fire-biz/src/main/java/com/usky/fire/service/PatrolInspectionPlanService.java
  12. 121 0
      service-fire/service-fire-biz/src/main/java/com/usky/fire/service/impl/PatrolInspectionPlanServiceImpl.java
  13. 6 0
      service-job/pom.xml
  14. 11 0
      service-job/src/main/java/com/ruoyi/job/task/RyTask.java
  15. 9 0
      service-tsdb/service-tsdb-api/src/main/java/com/usky/demo/RemoteTsdbProxyService.java
  16. 23 0
      service-tsdb/service-tsdb-api/src/main/java/com/usky/demo/domain/DeviceCsvImportResultVO.java
  17. 6 0
      service-tsdb/service-tsdb-api/src/main/java/com/usky/demo/factory/RemoteTsdbProxyFallbackFactory.java
  18. 10 0
      service-tsdb/service-tsdb-biz/src/main/java/com/usky/demo/controller/api/DataTsdbProxyControllerApi.java
  19. 6 0
      service-tsdb/service-tsdb-biz/src/main/java/com/usky/demo/service/QueryTdengineDataService.java
  20. 187 0
      service-tsdb/service-tsdb-biz/src/main/java/com/usky/demo/service/impl/QueryTdengineDataServiceImpl.java
  21. 12 0
      service-vpp/service-vpp-api/src/main/java/com/usky/vpp/RemoteVppTaskService.java
  22. 22 0
      service-vpp/service-vpp-api/src/main/java/com/usky/vpp/factory/RemoteVppTaskFactory.java
  23. 12 1
      service-vpp/service-vpp-biz/pom.xml
  24. 17 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/config/VppReportProperties.java
  25. 20 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/constant/VppTsdbConstants.java
  26. 18 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/api/ServiceVppTaskApi.java
  27. 85 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/BiddingConfigController.java
  28. 3 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/ContractController.java
  29. 10 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/CustomerController.java
  30. 2 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/DrController.java
  31. 62 29
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/ReportInfoController.java
  32. 22 7
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/ResourceController.java
  33. 56 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/ResponseMonitorController.java
  34. 76 4
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/SettlementController.java
  35. 91 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/SiteController.java
  36. 16 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/VppDeviceController.java
  37. 39 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/DmpDeviceStatus.java
  38. 62 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppBiddingConfig.java
  39. 1 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppContract.java
  40. 2 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppCustomer.java
  41. 6 3
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppCustomerAccess.java
  42. 3 5
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppDevice.java
  43. 3 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppDrParticipation.java
  44. 11 2
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppEnergyReadingMonthly.java
  45. 2 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppReportTask.java
  46. 20 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppSettlementBill.java
  47. 6 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppSite.java
  48. 9 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/mapper/DmpDeviceStatusMapper.java
  49. 10 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/mapper/VppBiddingConfigMapper.java
  50. 35 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppBaselineService.java
  51. 27 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppBiddingConfigService.java
  52. 5 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppCustomerService.java
  53. 4 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppDeviceService.java
  54. 2 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppDrService.java
  55. 28 4
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppReportInfoService.java
  56. 6 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppResourceOverviewService.java
  57. 19 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppResponseMonitorService.java
  58. 23 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppSettlementService.java
  59. 12 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppSiteCompletionRateTaskService.java
  60. 16 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppTsdbQueryService.java
  61. 4 2
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppArchiveServiceImpl.java
  62. 380 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppBaselineServiceImpl.java
  63. 253 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppBiddingConfigServiceImpl.java
  64. 0 3
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppCapabilityEvalServiceImpl.java
  65. 13 8
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppContractServiceImpl.java
  66. 244 89
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppCustomerServiceImpl.java
  67. 90 27
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDeviceServiceImpl.java
  68. 3 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDrInvitationServiceImpl.java
  69. 98 11
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDrServiceImpl.java
  70. 897 3
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppReportInfoServiceImpl.java
  71. 86 13
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppResourceBoardServiceImpl.java
  72. 356 19
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppResourceOverviewServiceImpl.java
  73. 423 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppResponseMonitorServiceImpl.java
  74. 484 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppSettlementServiceImpl.java
  75. 278 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppSiteCompletionRateTaskServiceImpl.java
  76. 13 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppSiteServiceImpl.java
  77. 221 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppTsdbQueryServiceImpl.java
  78. 2 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppUnDrSyncServiceImpl.java
  79. 21 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/BaselinePointVO.java
  80. 28 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/BiddingConfigRequest.java
  81. 13 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/BiddingConfigSiteVO.java
  82. 34 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/BiddingConfigVO.java
  83. 10 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/CustomerAccessRequestVO.java
  84. 21 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/CustomerAccessResponseVO.java
  85. 19 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DeclaredCapacityPointVO.java
  86. 19 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DeviceTsdbImportResultVO.java
  87. 27 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DrEventDetailVO.java
  88. 25 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DrParticipationVO.java
  89. 7 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/EnergyTodayVO.java
  90. 16 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportAuditLogVO.java
  91. 11 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportAuditRequest.java
  92. 12 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportDataSaveRequest.java
  93. 19 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportPrefillVO.java
  94. 30 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportRecordVO.java
  95. 27 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportTaskCreateRequest.java
  96. 21 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportTaskCreateResultVO.java
  97. 24 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportTaskVO.java
  98. 14 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportValidateErrorVO.java
  99. 13 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ReportValidateResultVO.java
  100. 1 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ResourceBoardItemVO.java

+ 3 - 1
service-alarm/service-alarm-biz/src/main/java/com/usky/alarm/service/listener/MqttListener.java

@@ -44,6 +44,7 @@ public class MqttListener {
             String[] code5 = TopListener.SMOKE_ALERT.getCode().split("/");
             String[] code6 = TopListener.ELECTRICAL_ALERT.getCode().split("/");
             String[] code7 = TopListener.LIQUID_ALERT.getCode().split("/");
+            String[] code8 = TopListener.LR_SMOKE_ALERT.getCode().split("/");
             Object mqttReceivedTopic = message.getHeaders().get("mqtt_receivedTopic");
             if (null != mqttReceivedTopic) {
                 String topic = mqttReceivedTopic.toString();
@@ -55,7 +56,8 @@ public class MqttListener {
                         (topic.indexOf(code4[2]) != -1 && topic.indexOf(code4[5]) != -1) ||
                         (topic.indexOf(code5[2]) != -1 && topic.indexOf(code5[5]) != -1) ||
                         (topic.indexOf(code6[2]) != -1 && topic.indexOf(code6[5]) != -1) ||
-                        (topic.indexOf(code7[2]) != -1 && topic.indexOf(code7[5]) != -1)) {
+                        (topic.indexOf(code7[2]) != -1 && topic.indexOf(code7[5]) != -1) ||
+                        (topic.indexOf(code8[2]) != -1 && topic.indexOf(code8[5]) != -1)) {
                     mqttBaseVO.setDescribe("alarm");
                 }else {
                     mqttBaseVO.setDescribe("fireInfoAndAlarm");

+ 1 - 0
service-alarm/service-alarm-biz/src/main/java/com/usky/alarm/service/mqtt/alarm/Alarm.java

@@ -51,6 +51,7 @@ public class Alarm implements MqttStrategy {
         String[] code4 = TopListener.VIDEO_ALERT.getCode().split("/");
         LambdaQueryWrapper<DmpDevice> queryWrapper1 = Wrappers.lambdaQuery();
         queryWrapper1.select(DmpDevice::getProductCode,DmpDevice::getDeviceUuid)
+                .eq(DmpDevice::getCompanyCode,topic.split("/")[3])
                 .eq(DmpDevice::getDeviceId, map.get("devId").toString())
                 .eq(DmpDevice::getDeleteFlag, 0);
         List<DmpDevice> list = dmpDeviceService.list(queryWrapper1);

+ 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

+ 11 - 0
service-fire/service-fire-biz/src/main/java/com/usky/fire/controller/web/PatrolInspectionPlanController.java

@@ -123,5 +123,16 @@ public class PatrolInspectionPlanController {
         return ApiResult.success();
     }
 
+    /**
+     * 巡查自检-近七天子计划缺失补插(已存在则跳过)
+     *
+     * @return
+     */
+    @GetMapping("supplementNearSevenDaysPlanSon")
+    public ApiResult<Void> supplementNearSevenDaysPlanSon() {
+        patrolInspectionPlanService.supplementNearSevenDaysPlanSon();
+        return ApiResult.success();
+    }
+
 }
 

+ 5 - 0
service-fire/service-fire-biz/src/main/java/com/usky/fire/service/PatrolInspectionPlanService.java

@@ -28,6 +28,11 @@ public interface PatrolInspectionPlanService extends CrudService<PatrolInspectio
 
     void addPatrolInspectionPlanSon();
 
+    /**
+     * 查询近七天符合时间段的主计划,检查其近七天子计划是否存在;不存在则补插,已存在则跳过
+     */
+    void supplementNearSevenDaysPlanSon();
+
     void delPatrolInspectionPlan(Integer id);
 
     CommonPage<PatrolInspectionPlanDataVo> patrolInspectionPlanList(String planName,Integer planType, Integer areaId,Integer pageNum, Integer pageSize, Integer id);

+ 121 - 0
service-fire/service-fire-biz/src/main/java/com/usky/fire/service/impl/PatrolInspectionPlanServiceImpl.java

@@ -572,6 +572,127 @@ public class PatrolInspectionPlanServiceImpl extends AbstractCrudService<PatrolI
         }
     }
 
+    /**
+     * 查询近七天(今天 ~ 今天+6)符合时间段的主计划,按日检查子计划是否存在;
+     * 不存在则补插子计划及地点关联,已存在则跳过
+     */
+    @Override
+    @Transactional
+    public void supplementNearSevenDaysPlanSon() {
+        String todayStr = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
+        LocalDate startOfNearSeven = LocalDate.now();
+        LocalDate endOfNearSeven = OnlineMethod.getDate(todayStr, "yyyy-MM-dd", true, 6);
+
+        // 查询近七天时间范围内生效的主计划:开始日期 <= 近七天末天,结束日期 >= 今天
+        LambdaQueryWrapper<PatrolInspectionPlan> queryWrapper = Wrappers.lambdaQuery();
+        queryWrapper.le(PatrolInspectionPlan::getStartDate, endOfNearSeven)
+                .ge(PatrolInspectionPlan::getEndDate, startOfNearSeven)
+                .eq(PatrolInspectionPlan::getEnable, 1)
+                .eq(PatrolInspectionPlan::getPlanType, 1);
+        List<PatrolInspectionPlan> list = this.list(queryWrapper);
+        if (CollectionUtils.isEmpty(list)) {
+            return;
+        }
+
+        List<Integer> planIdList = new ArrayList<>();
+        for (int i = 0; i < list.size(); i++) {
+            planIdList.add(list.get(i).getId());
+        }
+
+        // 巡检日程关联信息
+        LambdaQueryWrapper<PatrolInspectionPlanSchedule> queryWrapper1 = Wrappers.lambdaQuery();
+        queryWrapper1.in(PatrolInspectionPlanSchedule::getPlanId, planIdList);
+        List<PatrolInspectionPlanSchedule> scheduleList = planScheduleService.list(queryWrapper1);
+        if (CollectionUtils.isEmpty(scheduleList)) {
+            throw new BusinessException("无巡检日程关联信息");
+        }
+
+        // 主计划地点关联
+        LambdaQueryWrapper<PatrolInspectionPlanSite> queryWrapper2 = Wrappers.lambdaQuery();
+        queryWrapper2.in(PatrolInspectionPlanSite::getPlanId, planIdList);
+        List<PatrolInspectionPlanSite> siteList = planSiteService.list(queryWrapper2);
+        if (CollectionUtils.isEmpty(siteList)) {
+            throw new BusinessException("无主计划地点关联信息");
+        }
+
+        // 查询近七天已存在的子计划(按主计划id + 巡检日期判断是否存在)
+        LambdaQueryWrapper<PatrolInspectionPlanSon> queryWrapper3 = Wrappers.lambdaQuery();
+        queryWrapper3.select(PatrolInspectionPlanSon::getPlanId, PatrolInspectionPlanSon::getInspectionDate)
+                .in(PatrolInspectionPlanSon::getPlanId, planIdList)
+                .ge(PatrolInspectionPlanSon::getInspectionDate, startOfNearSeven)
+                .le(PatrolInspectionPlanSon::getInspectionDate, endOfNearSeven);
+        List<PatrolInspectionPlanSon> planSonList = patrolInspectionPlanSonService.list(queryWrapper3);
+        List<String> existPlanSonKeyList = new ArrayList<>();
+        if (CollectionUtils.isNotEmpty(planSonList)) {
+            for (int i = 0; i < planSonList.size(); i++) {
+                existPlanSonKeyList.add(planSonList.get(i).getPlanId() + "_" + planSonList.get(i).getInspectionDate());
+            }
+        }
+
+        // 逐日检查并补插缺失的子计划
+        for (int dayOffset = 0; dayOffset < 7; dayOffset++) {
+            LocalDate targetDate = dayOffset == 0 ? startOfNearSeven
+                    : OnlineMethod.getDate(todayStr, "yyyy-MM-dd", true, dayOffset);
+            Date date = Date.from(targetDate.atStartOfDay(ZoneOffset.ofHours(8)).toInstant());
+            String week = OnlineMethod.getWeekOfDate(date);
+
+            for (int m = 0; m < list.size(); m++) {
+                PatrolInspectionPlan plan = list.get(m);
+                // 目标日期需落在该主计划有效期内
+                if (plan.getStartDate().isAfter(targetDate) || plan.getEndDate().isBefore(targetDate)) {
+                    continue;
+                }
+                String existKey = plan.getId() + "_" + targetDate;
+                // 已存在则不做任何操作
+                if (existPlanSonKeyList.contains(existKey)) {
+                    continue;
+                }
+                // 休息日不生成子计划
+                if (plan.getRestDay() != null && plan.getRestDay().contains(week)) {
+                    continue;
+                }
+
+                for (int j = 0; j < scheduleList.size(); j++) {
+                    if (!plan.getId().equals(scheduleList.get(j).getPlanId())) {
+                        continue;
+                    }
+                    PatrolInspectionPlanSon patrolInspectionPlanSon = new PatrolInspectionPlanSon();
+                    patrolInspectionPlanSon.setPlanId(plan.getId());
+                    patrolInspectionPlanSon.setInspectionDate(targetDate);
+                    patrolInspectionPlanSon.setStartTime(scheduleList.get(j).getStartTime());
+                    patrolInspectionPlanSon.setEndTime(scheduleList.get(j).getEndTime());
+                    patrolInspectionPlanSon.setPersonnelId(scheduleList.get(j).getPersonnelId());
+                    patrolInspectionPlanSon.setAreaId(plan.getAreaId());
+                    patrolInspectionPlanSon.setPlanType(plan.getPlanType());
+                    patrolInspectionPlanSon.setPlanCycle(plan.getPlanCycle());
+                    patrolInspectionPlanSon.setPlanFrequency(plan.getPlanFrequency());
+                    patrolInspectionPlanSon.setLapTime(plan.getLapTime());
+                    patrolInspectionPlanSon.setIntervalTime(plan.getIntervalTime());
+                    patrolInspectionPlanSon.setPlanDescribe(plan.getPlanDescribe());
+                    patrolInspectionPlanSon.setCompletion(0);
+                    patrolInspectionPlanSon.setCreateTime(LocalDateTime.now());
+                    patrolInspectionPlanSon.setCreator(plan.getCreator());
+                    patrolInspectionPlanSon.setAlternateField("0");
+                    patrolInspectionPlanSon.setTenantId(plan.getTenantId());
+                    patrolInspectionPlanSon.setCompanyId(plan.getCompanyId());
+                    patrolInspectionPlanSonService.save(patrolInspectionPlanSon);
+                    Integer zfid = patrolInspectionPlanSon.getId();
+                    for (int k = 0; k < siteList.size(); k++) {
+                        if (plan.getId().equals(siteList.get(k).getPlanId())) {
+                            PatrolInspectionPlanSiteSon planSiteSon = new PatrolInspectionPlanSiteSon();
+                            planSiteSon.setPlanId(zfid);
+                            planSiteSon.setSiteId(siteList.get(k).getSiteId());
+                            planSiteSon.setInspectionStatus(1);
+                            planSiteSonService.save(planSiteSon);
+                        }
+                    }
+                }
+                // 补插后加入已存在标记,避免同日重复创建
+                existPlanSonKeyList.add(existKey);
+            }
+        }
+    }
+
     @Override
     @Transactional
     public void delPatrolInspectionPlan(Integer id) {

+ 6 - 0
service-job/pom.xml

@@ -94,6 +94,12 @@
             <version>0.0.1</version>
             <scope>compile</scope>
         </dependency>
+        <dependency>
+            <groupId>com.usky</groupId>
+            <artifactId>service-vpp-api</artifactId>
+            <version>0.0.1</version>
+            <scope>compile</scope>
+        </dependency>
     </dependencies>
 
     <build>

+ 11 - 0
service-job/src/main/java/com/ruoyi/job/task/RyTask.java

@@ -7,6 +7,7 @@ import com.usky.eg.RemoteEgService;
 import com.usky.fire.RemotePlanSonService;
 import com.usky.meeting.RemoteMeetingService;
 import com.usky.ems.RemoteEmsTaskService;
+import com.usky.vpp.RemoteVppTaskService;
 import com.usky.fire.RemoteFireService;
 import com.usky.iot.RemoteIotTaskService;
 import com.usky.pm.RemotePmService;
@@ -47,6 +48,10 @@ public class RyTask {
     @Autowired
     private RemotePlanSonService remotePlanSonService;
 
+    @Autowired
+    private RemoteVppTaskService remoteVppTaskService;
+
+
     public void ryMultipleParams(String s, Boolean b, Long l, Double d, Integer i) {
         System.out.println(StringUtils.format("执行多参方法: 字符串类型{},布尔类型{},长整型{},浮点型{},整形{}", s, b, l, d, i));
     }
@@ -140,4 +145,10 @@ public class RyTask {
         remotePlanSonService.addPlanSon();
     }
 
+    // 刷新站点平均完成率
+    public void refreshSiteAvgCompletionRate() {
+        System.out.println("refreshSiteAvgCompletionRate start......");
+        remoteVppTaskService.refreshSiteAvgCompletionRate();
+	}
+
 }

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

@@ -6,7 +6,9 @@ import com.usky.demo.domain.*;
 import com.usky.demo.factory.RemoteTsdbProxyFallbackFactory;
 import com.usky.demo.feign.RemoteTsdbProxyFeignConfiguration;
 import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.http.MediaType;
 import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
 
 import java.util.List;
 import java.util.Map;
@@ -105,4 +107,11 @@ public interface RemoteTsdbProxyService {
      */
     @PostMapping("/energyItemTrend")
     EnergyItemTrendResultVO sumEnergyItemTrend(@RequestBody EnergyItemTrendQueryVO requestVO);
+
+    /**
+     * 解析 CSV 并导入设备时序数据:INSERT INTO _{deviceUuid} (ts, p) VALUES ...
+     */
+    @PostMapping(value = "/importDeviceCsv", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+    ApiResult<DeviceCsvImportResultVO> importDeviceCsv(@RequestParam("deviceUuid") String deviceUuid,
+                                                       @RequestPart("file") MultipartFile file);
 }

+ 23 - 0
service-tsdb/service-tsdb-api/src/main/java/com/usky/demo/domain/DeviceCsvImportResultVO.java

@@ -0,0 +1,23 @@
+package com.usky.demo.domain;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * TDengine CSV 导入结果
+ */
+@Data
+public class DeviceCsvImportResultVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 设备 UUID(入参) */
+    private String deviceUuid;
+
+    /** 实际写入的子表名(通常为 _{deviceUuid}) */
+    private String tableName;
+
+    /** 成功导入的数据行数 */
+    private Integer importedRows;
+}

+ 6 - 0
service-tsdb/service-tsdb-api/src/main/java/com/usky/demo/factory/RemoteTsdbProxyFallbackFactory.java

@@ -104,6 +104,12 @@ public class RemoteTsdbProxyFallbackFactory implements FallbackFactory<RemoteTsd
             {
                 throw new BusinessException("能耗分项趋势查询:" + throwable.getMessage());
             }
+
+            @Override
+            public ApiResult<DeviceCsvImportResultVO> importDeviceCsv(String deviceUuid, org.springframework.web.multipart.MultipartFile file)
+            {
+                throw new BusinessException("设备时序 CSV 导入:" + throwable.getMessage());
+            }
         };
     }
 }

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

@@ -16,6 +16,7 @@ import com.usky.system.RemoteUserService;
 import com.usky.system.domain.SysUserVO;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.MediaType;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
 
@@ -175,4 +176,13 @@ public class DataTsdbProxyControllerApi implements RemoteTsdbProxyService {
         return queryTdengineDataService.sumEnergyItemTrend(requestVO);
     }
 
+    @Override
+    public ApiResult<DeviceCsvImportResultVO> importDeviceCsv(@RequestParam("deviceUuid") String deviceUuid,
+                                                              @RequestPart("file") MultipartFile file) {
+        if (!"taos".equals(sourcetype)) {
+            throw new BusinessException("当前数据源不支持 TDengine CSV 导入");
+        }
+        return ApiResult.success(queryTdengineDataService.importDeviceCsv(deviceUuid, file));
+    }
+
 }

+ 6 - 0
service-tsdb/service-tsdb-biz/src/main/java/com/usky/demo/service/QueryTdengineDataService.java

@@ -6,6 +6,7 @@ import com.usky.demo.service.vo.SuperTableVO;
 import org.apache.ibatis.annotations.Param;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.multipart.MultipartFile;
 
 import java.util.List;
 import java.util.Map;
@@ -48,4 +49,9 @@ public interface QueryTdengineDataService extends CrudService<QueryTdengineData>
      * 能耗分项趋势:按时间粒度 INTERVAL 聚合,返回 time/value 列表
      */
     EnergyItemTrendResultVO sumEnergyItemTrend(EnergyItemTrendQueryVO requestVO);
+
+    /**
+     * 从 CSV 解析并导入设备子表时序:INSERT INTO _{deviceUuid} (ts, p) VALUES ...
+     */
+    DeviceCsvImportResultVO importDeviceCsv(String deviceUuid, MultipartFile file);
 }

+ 187 - 0
service-tsdb/service-tsdb-biz/src/main/java/com/usky/demo/service/impl/QueryTdengineDataServiceImpl.java

@@ -23,12 +23,21 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.cache.annotation.Cacheable;
 import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
 
 import javax.sql.DataSource;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
 import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
 import java.sql.*;
 import java.text.ParseException;
 import java.text.SimpleDateFormat;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
 import java.util.*;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
@@ -47,6 +56,16 @@ public class QueryTdengineDataServiceImpl extends AbstractCrudService<QueryTdeng
 
     private static final Pattern SQL_IDENTIFIER_PATTERN = Pattern.compile("^[a-zA-Z_][a-zA-Z0-9_]*$");
 
+    private static final Pattern DEVICE_UUID_PATTERN = Pattern.compile("^[a-zA-Z0-9_-]+$");
+
+    private static final int IMPORT_BATCH_SIZE = 500;
+
+    private static final DateTimeFormatter TS_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+    private static final List<String> TS_HEADER_ALIASES = Arrays.asList("ts", "timestamp", "time");
+
+    private static final List<String> P_HEADER_ALIASES = Arrays.asList("p", "power", "active_power", "value");
+
     @Autowired
     private TsdbUtils tsdbUtils;
     @Autowired
@@ -460,4 +479,172 @@ public class QueryTdengineDataServiceImpl extends AbstractCrudService<QueryTdeng
             throw new BusinessException(paramName + " 格式非法,仅允许字母、数字与下划线,且不能以数字开头");
         }
     }
+
+    @Override
+    public DeviceCsvImportResultVO importDeviceCsv(String deviceUuid, MultipartFile file) {
+        if (StringUtils.isBlank(deviceUuid)) {
+            throw new BusinessException("设备UUID不能为空");
+        }
+        if (!DEVICE_UUID_PATTERN.matcher(deviceUuid.trim()).matches()) {
+            throw new BusinessException("设备UUID格式无效");
+        }
+        if (file == null || file.isEmpty()) {
+            throw new BusinessException("导入文件不能为空");
+        }
+        String originalFilename = file.getOriginalFilename();
+        if (StringUtils.isBlank(originalFilename) || !originalFilename.toLowerCase(Locale.ROOT).endsWith(".csv")) {
+            throw new BusinessException("仅支持 CSV 文件");
+        }
+
+        String normalizedUuid = deviceUuid.trim();
+        String tableName = buildDeviceSubTableName(normalizedUuid);
+        try {
+            List<TsdbCsvRow> rows = parseCsvRows(file);
+            batchInsertRows(tableName, rows);
+
+            DeviceCsvImportResultVO result = new DeviceCsvImportResultVO();
+            result.setDeviceUuid(normalizedUuid);
+            result.setTableName(tableName);
+            result.setImportedRows(rows.size());
+            return result;
+        } catch (IOException ex) {
+            throw new BusinessException("解析 CSV 文件失败: " + ex.getMessage());
+        } catch (SQLException ex) {
+            throw new BusinessException("时序数据导入失败: " + ex.getMessage());
+        }
+    }
+
+    private List<TsdbCsvRow> parseCsvRows(MultipartFile file) throws IOException {
+        List<TsdbCsvRow> rows = new ArrayList<>();
+        try (BufferedReader reader = new BufferedReader(
+                new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) {
+            String line;
+            int tsIndex = 0;
+            int pIndex = 1;
+            boolean headerResolved = false;
+            int lineNo = 0;
+            while ((line = reader.readLine()) != null) {
+                lineNo++;
+                line = line.trim();
+                if (line.isEmpty()) {
+                    continue;
+                }
+                String[] parts = splitCsvLine(line);
+                if (!headerResolved) {
+                    if (isHeaderRow(parts)) {
+                        tsIndex = findColumnIndex(parts, TS_HEADER_ALIASES, "ts");
+                        pIndex = findColumnIndex(parts, P_HEADER_ALIASES, "p");
+                        headerResolved = true;
+                        continue;
+                    }
+                    headerResolved = true;
+                }
+                if (parts.length <= Math.max(tsIndex, pIndex)) {
+                    throw new BusinessException("CSV 第 " + lineNo + " 行列数不足");
+                }
+                rows.add(new TsdbCsvRow(
+                        parseTimestamp(parts[tsIndex], lineNo),
+                        parseMetricValue(parts[pIndex], lineNo)));
+            }
+        }
+        if (rows.isEmpty()) {
+            throw new BusinessException("CSV 无有效数据行");
+        }
+        return rows;
+    }
+
+    private void batchInsertRows(String tableName, List<TsdbCsvRow> rows) throws SQLException {
+        try (Connection connection = dataSource.getConnection();
+             Statement statement = connection.createStatement()) {
+            for (int start = 0; start < rows.size(); start += IMPORT_BATCH_SIZE) {
+                int end = Math.min(start + IMPORT_BATCH_SIZE, rows.size());
+                StringBuilder sql = new StringBuilder("INSERT INTO ")
+                        .append(tableName)
+                        .append(" (ts, p) VALUES ");
+                for (int i = start; i < end; i++) {
+                    TsdbCsvRow row = rows.get(i);
+                    if (i > start) {
+                        sql.append(' ');
+                    }
+                    sql.append('(').append(row.tsMillis).append(',').append(row.pValue).append(')');
+                }
+                statement.execute(sql.toString());
+            }
+        }
+    }
+
+    private static String[] splitCsvLine(String line) {
+        return Arrays.stream(line.split(",", -1))
+                .map(String::trim)
+                .toArray(String[]::new);
+    }
+
+    private static boolean isHeaderRow(String[] parts) {
+        for (String part : parts) {
+            String normalized = part.trim().toLowerCase(Locale.ROOT);
+            if (TS_HEADER_ALIASES.contains(normalized) || P_HEADER_ALIASES.contains(normalized)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static int findColumnIndex(String[] headers, List<String> aliases, String columnLabel) {
+        for (int i = 0; i < headers.length; i++) {
+            if (aliases.contains(headers[i].trim().toLowerCase(Locale.ROOT))) {
+                return i;
+            }
+        }
+        throw new BusinessException("CSV 缺少 " + columnLabel + " 列");
+    }
+
+    private static long parseTimestamp(String rawValue, int lineNo) {
+        String value = rawValue == null ? "" : rawValue.trim();
+        if (value.isEmpty()) {
+            throw new BusinessException("CSV 第 " + lineNo + " 行 ts 为空");
+        }
+        if (value.matches("\\d+")) {
+            long epoch = Long.parseLong(value);
+            return value.length() <= 10 ? epoch * 1000L : epoch;
+        }
+        try {
+            return LocalDateTime.parse(value, TS_FORMAT)
+                    .atZone(ZoneId.systemDefault())
+                    .toInstant()
+                    .toEpochMilli();
+        } catch (DateTimeParseException ignored) {
+            // fall through
+        }
+        try {
+            return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss").parse(value).getTime();
+        } catch (ParseException ex) {
+            throw new BusinessException("CSV 第 " + lineNo + " 行 ts 格式无效: " + value);
+        }
+    }
+
+    private static double parseMetricValue(String rawValue, int lineNo) {
+        String value = rawValue == null ? "" : rawValue.trim();
+        if (value.isEmpty()) {
+            throw new BusinessException("CSV 第 " + lineNo + " 行 p 为空");
+        }
+        try {
+            return new BigDecimal(value).doubleValue();
+        } catch (NumberFormatException ex) {
+            throw new BusinessException("CSV 第 " + lineNo + " 行 p 不是有效数值: " + value);
+        }
+    }
+
+    private static String buildDeviceSubTableName(String deviceUuid) {
+        return "_" + deviceUuid;
+    }
+
+    private static final class TsdbCsvRow {
+        private final long tsMillis;
+        private final double pValue;
+
+        private TsdbCsvRow(long tsMillis, double pValue) {
+            this.tsMillis = tsMillis;
+            this.pValue = pValue;
+        }
+    }
 }

+ 12 - 0
service-vpp/service-vpp-api/src/main/java/com/usky/vpp/RemoteVppTaskService.java

@@ -0,0 +1,12 @@
+package com.usky.vpp;
+
+import com.usky.vpp.factory.RemoteVppTaskFactory;
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.web.bind.annotation.GetMapping;
+
+@FeignClient(contextId = "remoteVppTaskService", value = "service-vpp", fallbackFactory = RemoteVppTaskFactory.class)
+public interface RemoteVppTaskService {
+
+    @GetMapping("/refreshSiteAvgCompletionRate")
+    void refreshSiteAvgCompletionRate();
+}

+ 22 - 0
service-vpp/service-vpp-api/src/main/java/com/usky/vpp/factory/RemoteVppTaskFactory.java

@@ -0,0 +1,22 @@
+package com.usky.vpp.factory;
+
+import com.usky.common.core.exception.FeignBadRequestException;
+import com.usky.vpp.RemoteVppTaskService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.cloud.openfeign.FallbackFactory;
+import org.springframework.stereotype.Component;
+
+@Component
+public class RemoteVppTaskFactory implements FallbackFactory<RemoteVppTaskService> {
+
+    private static final Logger log = LoggerFactory.getLogger(RemoteVppTaskFactory.class);
+
+    @Override
+    public RemoteVppTaskService create(Throwable throwable) {
+        log.error("VPP 定时任务服务调用失败: {}", throwable.getMessage());
+        return () -> {
+            throw new FeignBadRequestException(500, "定时刷新站点平均完成率异常" + throwable.getMessage());
+        };
+    }
+}

+ 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;
+}

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

@@ -31,5 +31,25 @@ 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";
+
+    /** 总有功功率 kW */
+    public static final String METRIC_P = "p";
+
+    /** 储能 SOC % */
+    public static final String METRIC_SOC = "soc";
+
     public static final String TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
 }

+ 18 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/api/ServiceVppTaskApi.java

@@ -0,0 +1,18 @@
+package com.usky.vpp.controller.api;
+
+import com.usky.vpp.RemoteVppTaskService;
+import com.usky.vpp.service.VppSiteCompletionRateTaskService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+public class ServiceVppTaskApi implements RemoteVppTaskService {
+
+    @Autowired
+    private VppSiteCompletionRateTaskService siteCompletionRateTaskService;
+
+    @Override
+    public void refreshSiteAvgCompletionRate() {
+        siteCompletionRateTaskService.refreshSiteAvgCompletionRate();
+    }
+}

+ 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));
     }
 }

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

@@ -8,6 +8,7 @@ import com.usky.vpp.service.VppResourceOverviewService;
 import com.usky.vpp.service.VppResourcePointService;
 import com.usky.vpp.service.vo.AlarmLevelStatVO;
 import com.usky.vpp.service.vo.EnergyTodayVO;
+import com.usky.vpp.service.vo.ResourceOverviewRealtimeCurveVO;
 import com.usky.vpp.service.vo.ResourceBoardDetailVO;
 import com.usky.vpp.service.vo.ResourceBoardItemVO;
 import com.usky.vpp.service.vo.ResourcePointListVO;
@@ -35,7 +36,7 @@ public class ResourceController {
     private VppResourceOverviewService vppResourceOverviewService;
 
     /**
-     * 资源看板 - 实时运行数据列表(模拟数据)
+     * 资源看板 - 实时运行数据列表
      * <p>展示所有资源点的实时功率、电压、电流、储能SOC、充电桩充电功率;
      * 支持按 resourceType(资源类型)、province/city/district(区域)、status(normal/abnormal)筛选;
      * abnormal=true 的资源需前端红色高亮。</p>
@@ -66,7 +67,7 @@ public class ResourceController {
 
     /**
      * 资源概览 - 今日电能概况
-     * <p>总发电量、总用电量、绿电消纳比例(模拟数据)。</p>
+     * <p>总发电量、总用电量、绿电消纳比例(TSDB 今日 eday/epp 汇总)。</p>
      */
     @GetMapping("/overview/energy-today")
     public ApiResult<EnergyTodayVO> resourceOverviewEnergyToday() {
@@ -74,14 +75,28 @@ public class ResourceController {
     }
 
     /**
-     * 资源概览 - 实时告警统计
-     * <p>按紧急/重要/一般统计告警数量,含跳转告警列表路径(模拟数据)。</p>
+     * 首页 - 实时曲线
+     * <p>租户下设备按时间点 TSDB 汇总:实时负荷、光伏出力、储能 SOC。</p>
+     *
+     * @param startTime 开始时间 yyyy-MM-dd HH:mm:ss,默认当日 00:00:00
+     * @param endTime   结束时间 yyyy-MM-dd HH:mm:ss,默认当前时刻
      */
-    @GetMapping("/overview/alarm-stats")
-    public ApiResult<List<AlarmLevelStatVO>> resourceOverviewAlarmStats() {
-        return ApiResult.success(vppResourceOverviewService.getAlarmStats());
+    @GetMapping("/overview/realtime-curves")
+    public ApiResult<ResourceOverviewRealtimeCurveVO> resourceOverviewRealtimeCurves(
+            @RequestParam(value = "startTime", required = false) String startTime,
+            @RequestParam(value = "endTime", required = false) String endTime) {
+        return ApiResult.success(vppResourceOverviewService.getRealtimeCurves(startTime, endTime));
     }
 
+//    /**
+//     * 资源概览 - 实时告警统计
+//     * <p>按紧急/重要/一般统计告警数量,含跳转告警列表路径(模拟数据)。</p>
+//     */
+//    @GetMapping("/overview/alarm-stats")
+//    public ApiResult<List<AlarmLevelStatVO>> resourceOverviewAlarmStats() {
+//        return ApiResult.success(vppResourceOverviewService.getAlarmStats());
+//    }
+
     /**
      * 资源点管理 - 分页列表
      * <p>支持按 siteName(站点名称)、deviceName(设备名称)、resourceType、siteId 搜索。</p>

+ 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));
+    }
+
     /**
      * 站点下资源点列表
      */

+ 16 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/VppDeviceController.java

@@ -5,8 +5,11 @@ import com.usky.common.core.bean.CommonPage;
 import com.usky.vpp.domain.VppDevice;
 import com.usky.vpp.service.VppDeviceService;
 import com.usky.vpp.service.vo.DeviceRequest;
+import com.usky.vpp.service.vo.DeviceTsdbImportResultVO;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
 import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
 
 import java.util.Map;
 
@@ -47,4 +50,17 @@ public class VppDeviceController {
         vppDeviceService.deleteDevice(id);
         return ApiResult.success();
     }
+
+    /**
+     * 解析 CSV 并导入设备时序数据到 TDengine。
+     * <p>CSV 需包含 ts(时间戳)与 p(数值)列,支持表头;时间支持毫秒/秒时间戳或 yyyy-MM-dd HH:mm:ss。</p>
+     *
+     * @param deviceUuid 设备 UUID,对应子表 _{deviceUuid}
+     * @param file       CSV 文件
+     */
+    @PostMapping(value = "/import-tsdb", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+    public ApiResult<DeviceTsdbImportResultVO> importTsdbData(@RequestParam("deviceUuid") String deviceUuid,
+                                                             @RequestPart("file") MultipartFile file) {
+        return ApiResult.success(vppDeviceService.importTsdbData(deviceUuid, file));
+    }
 }

+ 39 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/DmpDeviceStatus.java

@@ -0,0 +1,39 @@
+package com.usky.vpp.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 物联网设备状态表(dmp_device_status)
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+@TableName("dmp_device_status")
+public class DmpDeviceStatus implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Integer id;
+
+    private String deviceId;
+
+    private Integer productId;
+
+    /** 1:在线,2:离线 */
+    private Integer deviceStatus;
+
+    private LocalDateTime lastOnlineTime;
+
+    private LocalDateTime lastOfflineTime;
+
+    private String productCode;
+
+    private String deviceUuid;
+}

+ 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")

+ 3 - 5
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")
@@ -37,10 +36,6 @@ public class VppDevice implements Serializable {
     private String model;
     @TableField("rated_power_kw")
     private BigDecimal ratedPowerKw;
-    @TableField("comm_status")
-    private Integer commStatus;
-    @TableField("run_status")
-    private Integer runStatus;
     @TableField("firmware_version")
     private String firmwareVersion;
     @TableField("last_online_at")
@@ -62,4 +57,7 @@ public class VppDevice implements Serializable {
     private Integer deleteFlag;
     @TableField("deleted_at")
     private LocalDateTime deletedAt;
+    @TableField(exist = false)
+    /** 1:在线,2:离线 */
+    private Integer deviceStatus;
 }

+ 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")

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

@@ -60,4 +60,10 @@ public class VppSite implements Serializable {
     private Integer deleteFlag;
     @TableField("deleted_at")
     private LocalDateTime deletedAt;
+    private String accountNo;
+    private String streetTown;
+    @TableField("avg_completion_rate")
+    private BigDecimal avgCompletionRate;
+    @TableField("run_status")
+    private Integer runStatus;
 }

+ 9 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/mapper/DmpDeviceStatusMapper.java

@@ -0,0 +1,9 @@
+package com.usky.vpp.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.usky.vpp.domain.DmpDeviceStatus;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface DmpDeviceStatusMapper extends BaseMapper<DmpDeviceStatus> {
+}

+ 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);
+
     /**
      * 删除准入申请(软删除)
      */

+ 4 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppDeviceService.java

@@ -3,6 +3,8 @@ package com.usky.vpp.service;
 import com.usky.common.core.bean.CommonPage;
 import com.usky.vpp.domain.VppDevice;
 import com.usky.vpp.service.vo.DeviceRequest;
+import com.usky.vpp.service.vo.DeviceTsdbImportResultVO;
+import org.springframework.web.multipart.MultipartFile;
 
 import java.util.Map;
 
@@ -24,4 +26,6 @@ public interface VppDeviceService {
     void updateDevice(Long id, DeviceRequest request);
 
     void deleteDevice(Long id);
+
+    DeviceTsdbImportResultVO importTsdbData(String deviceUuid, MultipartFile file);
 }

+ 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);
 }

+ 6 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppResourceOverviewService.java

@@ -2,6 +2,7 @@ package com.usky.vpp.service;
 
 import com.usky.vpp.service.vo.AlarmLevelStatVO;
 import com.usky.vpp.service.vo.EnergyTodayVO;
+import com.usky.vpp.service.vo.ResourceOverviewRealtimeCurveVO;
 import com.usky.vpp.service.vo.ResourceTypeStatVO;
 
 import java.util.List;
@@ -21,6 +22,11 @@ public interface VppResourceOverviewService {
      */
     EnergyTodayVO getEnergyToday();
 
+    /**
+     * 资源概览实时曲线(租户下设备 TSDB 按时间点汇总)
+     */
+    ResourceOverviewRealtimeCurveVO getRealtimeCurves(String startTime, String endTime);
+
     /**
      * 实时告警统计(紧急/重要/一般,含跳转路径,模拟数据)
      */

+ 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);
 }

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

@@ -0,0 +1,12 @@
+package com.usky.vpp.service;
+
+/**
+ * 站点平均完成率定时汇总
+ */
+public interface VppSiteCompletionRateTaskService {
+
+    /**
+     * 根据已结束需求响应事件的参与完成率,汇总更新各站点 avg_completion_rate。
+     */
+    void refreshSiteAvgCompletionRate();
+}

+ 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);
+        }
+    }
+}

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

@@ -315,9 +315,6 @@ public class VppCapabilityEvalServiceImpl implements VppCapabilityEvalService {
             if (device == null || !StringUtils.hasText(device.getDeviceUuid())) {
                 continue;
             }
-            if (device.getCommStatus() != null && device.getCommStatus() == 0) {
-                continue;
-            }
             uuids.add(device.getDeviceUuid().trim());
         }
         return new ArrayList<>(uuids);

+ 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) {

+ 90 - 27
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDeviceServiceImpl.java

@@ -2,43 +2,53 @@ 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.ApiResult;
 import com.usky.common.core.bean.CommonPage;
 import com.usky.common.core.exception.BusinessException;
 import com.usky.common.core.util.UUIDUtils;
 import com.usky.common.security.utils.SecurityUtils;
-import com.usky.iot.RemoteIotService;
+import com.usky.demo.RemoteTsdbProxyService;
+import com.usky.demo.domain.DeviceCsvImportResultVO;
 import com.usky.iot.RemoteIotTaskService;
+import com.usky.vpp.domain.DmpDeviceStatus;
 import com.usky.vpp.domain.VppDevice;
+import com.usky.vpp.mapper.DmpDeviceStatusMapper;
 import com.usky.vpp.mapper.VppDeviceMapper;
 import com.usky.vpp.service.VppDeviceService;
 import com.usky.vpp.service.VppSiteService;
 import com.usky.vpp.service.vo.DeviceRequest;
+import com.usky.vpp.service.vo.DeviceTsdbImportResultVO;
 import com.usky.vpp.util.VppAuditHelper;
 import com.usky.vpp.util.VppPageHelper;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.CollectionUtils;
 import org.springframework.util.StringUtils;
+import org.springframework.web.multipart.MultipartFile;
 
+import java.util.List;
 import java.util.Map;
+import java.util.stream.Collectors;
 
 @Service
 public class VppDeviceServiceImpl implements VppDeviceService {
 
-    private static final int COMM_OFFLINE = 0;
-    private static final int RUN_STOPPED = 0;
-
     @Autowired
     private VppDeviceMapper deviceMapper;
     @Autowired
+    private DmpDeviceStatusMapper dmpDeviceStatusMapper;
+    @Autowired
     private VppSiteService siteService;
     @Autowired
     private RemoteIotTaskService remoteIotTaskService;
+    @Autowired
+    private RemoteTsdbProxyService remoteTsdbProxyService;
 
     @Override
     public CommonPage<VppDevice> pageDevice(Map<String, Object> params) {
         Page<VppDevice> page = VppPageHelper.of(params);
-        return toCommonPage(deviceMapper.selectPage(page, buildQueryWrapper(params)));
+        return selectDevicePage(page, buildQueryWrapper(params), params);
     }
 
     @Override
@@ -47,7 +57,15 @@ public class VppDeviceServiceImpl implements VppDeviceService {
         Page<VppDevice> page = VppPageHelper.of(params);
         LambdaQueryWrapper<VppDevice> wrapper = buildQueryWrapper(params)
                 .eq(VppDevice::getSiteId, siteId);
-        return toCommonPage(deviceMapper.selectPage(page, wrapper));
+        return selectDevicePage(page, wrapper, params);
+    }
+
+    private CommonPage<VppDevice> selectDevicePage(Page<VppDevice> page,
+                                                   LambdaQueryWrapper<VppDevice> wrapper,
+                                                   Map<String, Object> params) {
+        Page<VppDevice> result = deviceMapper.selectPage(page, wrapper);
+        fillDeviceStatus(result.getRecords(), params);
+        return toCommonPage(result);
     }
 
     @Override
@@ -84,16 +102,11 @@ public class VppDeviceServiceImpl implements VppDeviceService {
         device.setDeviceUuid(UUIDUtils.uuid().substring(0, 16));
         applyRequest(device, request);
         device.setTenantId(SecurityUtils.getTenantId());
-        if (device.getCommStatus() == null) {
-            device.setCommStatus(COMM_OFFLINE);
-        }
-        if (device.getRunStatus() == null) {
-            device.setRunStatus(RUN_STOPPED);
-        }
         VppAuditHelper.fillCreate(device);
         deviceMapper.insert(device);
 
-        remoteIotTaskService.addDeviceInfo(request.getProductCode(),device.getDeviceUuid(),device.getDeviceCode(),device.getDeviceName(),"",2);
+        remoteIotTaskService.addDeviceInfo(request.getProductCode(), device.getDeviceUuid(), device.getDeviceCode(),
+                device.getDeviceName(), "", 2);
         return device;
     }
 
@@ -117,9 +130,29 @@ public class VppDeviceServiceImpl implements VppDeviceService {
         remoteIotTaskService.deleteDeviceInfo(device.getDeviceUuid());
     }
 
+    @Override
+    public DeviceTsdbImportResultVO importTsdbData(String deviceUuid, MultipartFile file) {
+        getByDeviceUuid(deviceUuid);
+        if (file == null || file.isEmpty()) {
+            throw new BusinessException("导入文件不能为空");
+        }
+        ApiResult<DeviceCsvImportResultVO> result = remoteTsdbProxyService.importDeviceCsv(deviceUuid, file);
+        if (result == null || !result.isSuccess() || result.getData() == null) {
+            throw new BusinessException(result != null && StringUtils.hasText(result.getMsg())
+                    ? result.getMsg() : "时序数据导入失败");
+        }
+        DeviceCsvImportResultVO data = result.getData();
+        DeviceTsdbImportResultVO vo = new DeviceTsdbImportResultVO();
+        vo.setDeviceUuid(data.getDeviceUuid());
+        vo.setTableName(data.getTableName());
+        vo.setImportedRows(data.getImportedRows());
+        return vo;
+    }
+
     private LambdaQueryWrapper<VppDevice> buildQueryWrapper(Map<String, Object> params) {
         LambdaQueryWrapper<VppDevice> wrapper = new LambdaQueryWrapper<VppDevice>()
                 .eq(VppDevice::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .eq(VppDevice::getTenantId, SecurityUtils.getTenantId())
                 .orderByDesc(VppDevice::getCreateTime);
         if (params == null) {
             return wrapper;
@@ -139,13 +172,52 @@ public class VppDeviceServiceImpl implements VppDeviceService {
         if (params.get("deviceType") != null) {
             wrapper.eq(VppDevice::getDeviceType, params.get("deviceType").toString());
         }
-        if (params.get("commStatus") != null) {
-            wrapper.eq(VppDevice::getCommStatus, Integer.parseInt(params.get("commStatus").toString()));
+        applyRunStatusFilter(wrapper, params);
+        return wrapper;
+    }
+
+    private void applyRunStatusFilter(LambdaQueryWrapper<VppDevice> wrapper, Map<String, Object> params) {
+        Integer runStatus = resolveRunStatus(params);
+        if (runStatus == null) {
+            return;
+        }
+        wrapper.isNotNull(VppDevice::getDeviceUuid)
+                .ne(VppDevice::getDeviceUuid, "")
+                .apply("EXISTS (SELECT 1 FROM dmp_device_status ds WHERE ds.device_uuid = vpp_device.device_uuid AND ds.device_status = {0})", runStatus);
+    }
+
+    private Integer resolveRunStatus(Map<String, Object> params) {
+        if (params == null || params.get("runStatus") == null) {
+            return null;
         }
-        if (params.get("runStatus") != null) {
-            wrapper.eq(VppDevice::getRunStatus, Integer.parseInt(params.get("runStatus").toString()));
+        return Integer.parseInt(params.get("runStatus").toString());
+    }
+
+    private void fillDeviceStatus(List<VppDevice> devices, Map<String, Object> params) {
+        if (CollectionUtils.isEmpty(devices)) {
+            return;
         }
-        return wrapper;
+        Integer runStatus = resolveRunStatus(params);
+        if (runStatus != null) {
+            devices.forEach(device -> device.setDeviceStatus(runStatus));
+            return;
+        }
+        List<String> deviceUuids = devices.stream()
+                .map(VppDevice::getDeviceUuid)
+                .filter(StringUtils::hasText)
+                .distinct()
+                .collect(Collectors.toList());
+        if (deviceUuids.isEmpty()) {
+            return;
+        }
+        Map<String, Integer> statusByUuid = dmpDeviceStatusMapper.selectList(
+                        new LambdaQueryWrapper<DmpDeviceStatus>()
+                                .select(DmpDeviceStatus::getDeviceUuid, DmpDeviceStatus::getDeviceStatus)
+                                .in(DmpDeviceStatus::getDeviceUuid, deviceUuids))
+                .stream()
+                .filter(status -> StringUtils.hasText(status.getDeviceUuid()))
+                .collect(Collectors.toMap(DmpDeviceStatus::getDeviceUuid, DmpDeviceStatus::getDeviceStatus, (a, b) -> a));
+        devices.forEach(device -> device.setDeviceStatus(statusByUuid.get(device.getDeviceUuid())));
     }
 
     private void validateDeviceRequest(DeviceRequest request, Long excludeId) {
@@ -155,9 +227,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, "设备编号已存在");
     }
 
@@ -181,12 +250,6 @@ public class VppDeviceServiceImpl implements VppDeviceService {
         device.setManufacturer(request.getManufacturer());
         device.setModel(request.getModel());
         device.setRatedPowerKw(request.getRatedPowerKw());
-        if (request.getCommStatus() != null) {
-            device.setCommStatus(request.getCommStatus());
-        }
-        if (request.getRunStatus() != null) {
-            device.setRunStatus(request.getRunStatus());
-        }
         device.setFirmwareVersion(request.getFirmwareVersion());
         device.setGatewayId(request.getGatewayId());
         device.setRemark(request.getRemark());

+ 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;

+ 356 - 19
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppResourceOverviewServiceImpl.java

@@ -1,32 +1,61 @@
 package com.usky.vpp.service.impl;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.usky.common.core.exception.BusinessException;
+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.ResourceCurvePointVO;
+import com.usky.vpp.service.vo.ResourceCurveSeriesVO;
+import com.usky.vpp.service.vo.ResourceOverviewRealtimeCurveVO;
 import com.usky.vpp.service.vo.ResourceTypeStatVO;
 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.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.time.temporal.ChronoUnit;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
 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 {
 
     private static final List<String> RESOURCE_TYPES = Arrays.asList("PV", "ESS", "EVCS", "IND_LOAD", "COM_BLDG");
+    private static final DateTimeFormatter TIME_HH_MM = DateTimeFormatter.ofPattern("HH:mm");
+    private static final DateTimeFormatter TSDB_TIME = DateTimeFormatter.ofPattern(VppTsdbConstants.TIME_FORMAT);
 
     @Autowired
     private VppResourcePointMapper resourcePointMapper;
+    @Autowired
+    private VppDeviceMapper deviceMapper;
+    @Autowired
+    private VppTsdbQueryService vppTsdbQueryService;
 
     @Override
     public List<ResourceTypeStatVO> getTypeStats() {
@@ -35,7 +64,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
@@ -43,10 +74,202 @@ public class VppResourceOverviewServiceImpl implements VppResourceOverviewServic
         return buildAlarmStatsMock();
     }
 
+    @Override
+    public ResourceOverviewRealtimeCurveVO getRealtimeCurves(String startTime, String endTime) {
+        LocalDateTime end = parseDateTime(endTime, LocalDateTime.now());
+        LocalDateTime start = parseDateTime(startTime, LocalDate.now().atStartOfDay());
+        if (start.isAfter(end)) {
+            throw new BusinessException("开始时间不能晚于结束时间");
+        }
+
+        List<VppDevice> devices = listActiveDevices();
+        Map<Long, String> deviceResourceTypeMap = buildDeviceResourceTypeMap();
+        List<String> allDeviceUuids = resolveDeviceUuids(devices);
+        List<String> pvDeviceUuids = resolveDeviceUuidsByResourceType(devices, deviceResourceTypeMap, "PV");
+        List<String> essDeviceUuids = resolveDeviceUuidsByResourceType(devices, deviceResourceTypeMap, "ESS");
+
+        ResourceOverviewRealtimeCurveVO curve = new ResourceOverviewRealtimeCurveVO();
+        curve.setLoadCurve(buildPowerCurveSeries("实时负荷曲线", "kW", allDeviceUuids, start, end));
+        curve.setPvOutputCurve(buildPowerCurveSeries("光伏出力曲线", "kW", pvDeviceUuids, start, end));
+        curve.setEssSocCurve(buildSocCurveSeries("储能SOC曲线", essDeviceUuids, start, end));
+        return curve;
+    }
+
     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<VppDevice> listActiveDevices() {
+        return deviceMapper.selectList(
+                new LambdaQueryWrapper<VppDevice>()
+                        .eq(VppDevice::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                        .eq(VppDevice::getTenantId, SecurityUtils.getTenantId()));
+    }
+
+    private Map<Long, String> buildDeviceResourceTypeMap() {
+        Map<Long, String> deviceResourceTypeMap = new HashMap<>();
+        for (VppResourcePoint resource : listActiveResources()) {
+            if (resource.getDeviceId() == null || !StringUtils.hasText(resource.getResourceType())) {
+                continue;
+            }
+            deviceResourceTypeMap.putIfAbsent(resource.getDeviceId(), resource.getResourceType().toUpperCase());
+        }
+        return deviceResourceTypeMap;
+    }
+
+    private ResourceCurveSeriesVO buildPowerCurveSeries(String name, String unit, List<String> deviceUuids,
+                                                        LocalDateTime start, LocalDateTime end) {
+        Map<LocalDateTime, BigDecimal> aggregated = queryAggregatedSum(deviceUuids, start, end,
+                Collections.singletonList(VppTsdbConstants.METRIC_P));
+        return toCurveSeries(name, unit, aggregated);
+    }
+
+    private ResourceCurveSeriesVO buildSocCurveSeries(String name, List<String> deviceUuids,
+                                                      LocalDateTime start, LocalDateTime end) {
+        Map<LocalDateTime, BigDecimal> aggregated = queryAggregatedAverage(deviceUuids, start, end,
+                Collections.singletonList(VppTsdbConstants.METRIC_SOC));
+        return toCurveSeries(name, "%", aggregated);
+    }
+
+    private Map<LocalDateTime, BigDecimal> queryAggregatedSum(List<String> deviceUuids, LocalDateTime start,
+                                                              LocalDateTime end, List<String> metrics) {
+        if (CollectionUtils.isEmpty(deviceUuids)) {
+            return Collections.emptyMap();
+        }
+        Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> history =
+                vppTsdbQueryService.queryDeviceMetricHistory(deviceUuids, start, end, metrics);
+        return aggregateSumByTime(history, metrics.get(0));
+    }
+
+    private Map<LocalDateTime, BigDecimal> queryAggregatedAverage(List<String> deviceUuids, LocalDateTime start,
+                                                                  LocalDateTime end, List<String> metrics) {
+        if (CollectionUtils.isEmpty(deviceUuids)) {
+            return Collections.emptyMap();
+        }
+        Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> history =
+                vppTsdbQueryService.queryDeviceMetricHistory(deviceUuids, start, end, metrics);
+        return aggregateAverageByTime(history, metrics.get(0));
+    }
+
+    private Map<LocalDateTime, BigDecimal> aggregateSumByTime(
+            Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> history, String metric) {
+        if (CollectionUtils.isEmpty(history)) {
+            return Collections.emptyMap();
+        }
+        Map<LocalDateTime, BigDecimal> result = new TreeMap<>();
+        for (Map<String, TreeMap<LocalDateTime, BigDecimal>> deviceMetrics : history.values()) {
+            TreeMap<LocalDateTime, BigDecimal> series = deviceMetrics.get(metric);
+            if (series == null || series.isEmpty()) {
+                continue;
+            }
+            for (Map.Entry<LocalDateTime, BigDecimal> entry : series.entrySet()) {
+                result.merge(entry.getKey(), entry.getValue(), BigDecimal::add);
+            }
+        }
+        return result;
+    }
+
+    private Map<LocalDateTime, BigDecimal> aggregateAverageByTime(
+            Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> history, String metric) {
+        if (CollectionUtils.isEmpty(history)) {
+            return Collections.emptyMap();
+        }
+        Map<LocalDateTime, List<BigDecimal>> samples = new TreeMap<>();
+        for (Map<String, TreeMap<LocalDateTime, BigDecimal>> deviceMetrics : history.values()) {
+            TreeMap<LocalDateTime, BigDecimal> series = deviceMetrics.get(metric);
+            if (series == null || series.isEmpty()) {
+                continue;
+            }
+            for (Map.Entry<LocalDateTime, BigDecimal> entry : series.entrySet()) {
+                samples.computeIfAbsent(entry.getKey(), key -> new ArrayList<>()).add(entry.getValue());
+            }
+        }
+        Map<LocalDateTime, BigDecimal> result = new TreeMap<>();
+        samples.forEach((timestamp, values) -> {
+            BigDecimal avg = averageValues(values);
+            if (avg != null) {
+                result.put(timestamp, avg);
+            }
+        });
+        return result;
+    }
+
+    private BigDecimal averageValues(List<BigDecimal> values) {
+        if (CollectionUtils.isEmpty(values)) {
+            return null;
+        }
+        List<BigDecimal> nonNullValues = values.stream()
+                .filter(Objects::nonNull)
+                .collect(Collectors.toList());
+        if (nonNullValues.isEmpty()) {
+            return null;
+        }
+        BigDecimal sum = nonNullValues.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
+        return sum.divide(BigDecimal.valueOf(nonNullValues.size()), 4, RoundingMode.HALF_UP);
+    }
+
+    private ResourceCurveSeriesVO toCurveSeries(String name, String unit, Map<LocalDateTime, BigDecimal> aggregated) {
+        ResourceCurveSeriesVO series = new ResourceCurveSeriesVO();
+        series.setName(name);
+        series.setUnit(unit);
+        List<ResourceCurvePointVO> points = new ArrayList<>();
+        for (Map.Entry<LocalDateTime, BigDecimal> entry : aggregated.entrySet()) {
+            ResourceCurvePointVO point = new ResourceCurvePointVO();
+            point.setTime(entry.getKey().format(TIME_HH_MM));
+            point.setValue(entry.getValue().setScale(2, RoundingMode.HALF_UP));
+            points.add(point);
+        }
+        series.setPoints(points);
+        series.setCurrentValue(aggregated.entrySet().stream()
+                .max(Map.Entry.comparingByKey())
+                .map(entry -> entry.getValue().setScale(2, RoundingMode.HALF_UP))
+                .orElse(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)));
+        return series;
+    }
+
+    private List<String> resolveDeviceUuids(List<VppDevice> devices) {
+        if (CollectionUtils.isEmpty(devices)) {
+            return Collections.emptyList();
+        }
+        return devices.stream()
+                .map(VppDevice::getDeviceUuid)
+                .filter(StringUtils::hasText)
+                .map(String::trim)
+                .distinct()
+                .collect(Collectors.toList());
+    }
+
+    private List<String> resolveDeviceUuidsByResourceType(List<VppDevice> devices,
+                                                          Map<Long, String> deviceResourceTypeMap,
+                                                          String resourceType) {
+        if (CollectionUtils.isEmpty(devices) || !StringUtils.hasText(resourceType)) {
+            return Collections.emptyList();
+        }
+        String targetType = resourceType.toUpperCase();
+        Set<String> uuids = new LinkedHashSet<>();
+        for (VppDevice device : devices) {
+            if (device.getId() == null || !StringUtils.hasText(device.getDeviceUuid())) {
+                continue;
+            }
+            if (targetType.equals(deviceResourceTypeMap.get(device.getId()))) {
+                uuids.add(device.getDeviceUuid().trim());
+            }
+        }
+        return new ArrayList<>(uuids);
+    }
+
+    private LocalDateTime parseDateTime(String value, LocalDateTime defaultValue) {
+        if (!StringUtils.hasText(value)) {
+            return defaultValue;
+        }
+        try {
+            return LocalDateTime.parse(value.trim(), TSDB_TIME);
+        } catch (DateTimeParseException ex) {
+            throw new BusinessException("时间格式无效,请使用 yyyy-MM-dd HH:mm:ss");
+        }
     }
 
     private List<ResourceTypeStatVO> buildTypeStats(List<VppResourcePoint> resources) {
@@ -81,30 +304,144 @@ 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 todayStart = LocalDate.now().atStartOfDay();
+        LocalDateTime now = LocalDateTime.now();
+        LocalDateTime yesterdayStart = todayStart.minusDays(1);
+        LocalDateTime yesterdayEnd = yesterdayStart.plus(ChronoUnit.MINUTES.between(todayStart, now), ChronoUnit.MINUTES);
+
+        BigDecimal totalGeneration = queryTodayEnergy(pvDeviceUuids, todayStart, now,
+                Collections.singletonList(VppTsdbConstants.METRIC_EDAY), true);
+        BigDecimal totalConsumption = queryTodayEnergy(loadDeviceUuids, todayStart, now,
+                Collections.singletonList(VppTsdbConstants.METRIC_EPP), false);
+        BigDecimal yesterdayGeneration = queryTodayEnergy(pvDeviceUuids, yesterdayStart, yesterdayEnd,
+                Collections.singletonList(VppTsdbConstants.METRIC_EDAY), true);
+        BigDecimal yesterdayConsumption = queryTodayEnergy(loadDeviceUuids, yesterdayStart, yesterdayEnd,
+                Collections.singletonList(VppTsdbConstants.METRIC_EPP), false);
+
+        BigDecimal totalGenerationScaled = totalGeneration.setScale(2, RoundingMode.HALF_UP);
+        BigDecimal totalConsumptionScaled = totalConsumption.setScale(2, RoundingMode.HALF_UP);
+        BigDecimal greenEnergyRatio = calculateGreenEnergyRatio(totalGenerationScaled, totalConsumptionScaled);
+        BigDecimal yesterdayGreenEnergyRatio = calculateGreenEnergyRatio(
+                yesterdayGeneration.setScale(2, RoundingMode.HALF_UP),
+                yesterdayConsumption.setScale(2, RoundingMode.HALF_UP));
 
         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));
-        if (energy.getTotalConsumptionKwh().compareTo(BigDecimal.ZERO) > 0) {
-            BigDecimal ratio = energy.getTotalGenerationKwh()
-                    .multiply(BigDecimal.valueOf(100))
-                    .divide(energy.getTotalConsumptionKwh(), 2, RoundingMode.HALF_UP);
-            energy.setGreenEnergyRatioPercent(ratio.min(BigDecimal.valueOf(100)));
-        } else {
-            energy.setGreenEnergyRatioPercent(BigDecimal.ZERO);
-        }
+        energy.setTotalGenerationKwh(totalGenerationScaled);
+        energy.setTotalGenerationKwhRingRatio(calculateRingRatio(totalGeneration, yesterdayGeneration));
+        energy.setTotalConsumptionKwh(totalConsumptionScaled);
+        energy.setTotalConsumptionKwhRingRatio(calculateRingRatio(totalConsumption, yesterdayConsumption));
+        energy.setGreenEnergyRatioPercent(greenEnergyRatio);
+        energy.setGreenEnergyRatioPercentRingRatio(calculateRingRatio(greenEnergyRatio, yesterdayGreenEnergyRatio));
         return energy;
     }
 
+    private BigDecimal calculateGreenEnergyRatio(BigDecimal generationKwh, BigDecimal consumptionKwh) {
+        if (consumptionKwh == null || consumptionKwh.compareTo(BigDecimal.ZERO) <= 0) {
+            return BigDecimal.ZERO;
+        }
+        BigDecimal generation = generationKwh != null ? generationKwh : BigDecimal.ZERO;
+        return generation.multiply(BigDecimal.valueOf(100))
+                .divide(consumptionKwh, 2, RoundingMode.HALF_UP)
+                .min(BigDecimal.valueOf(100));
+    }
+
+    private BigDecimal calculateRingRatio(BigDecimal current, BigDecimal previous) {
+        if (current == null) {
+            current = BigDecimal.ZERO;
+        }
+        if (previous == null || previous.compareTo(BigDecimal.ZERO) == 0) {
+            return BigDecimal.ZERO;
+        }
+        return current.subtract(previous)
+                .multiply(BigDecimal.valueOf(100))
+                .divide(previous, 2, RoundingMode.HALF_UP);
+    }
+
+    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);
+        }
+    }
 }

+ 278 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppSiteCompletionRateTaskServiceImpl.java

@@ -0,0 +1,278 @@
+package com.usky.vpp.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.usky.common.security.utils.SecurityUtils;
+import com.usky.vpp.domain.VppDevice;
+import com.usky.vpp.domain.VppDrEvent;
+import com.usky.vpp.domain.VppDrParticipation;
+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.VppDrParticipationMapper;
+import com.usky.vpp.mapper.VppResourcePointMapper;
+import com.usky.vpp.mapper.VppSiteMapper;
+import com.usky.vpp.service.VppSiteCompletionRateTaskService;
+import com.usky.vpp.service.VppTsdbQueryService;
+import com.usky.vpp.util.VppAuditHelper;
+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.util.ArrayList;
+import java.util.Collection;
+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;
+
+@Service
+public class VppSiteCompletionRateTaskServiceImpl implements VppSiteCompletionRateTaskService {
+
+    private static final Logger log = LoggerFactory.getLogger(VppSiteCompletionRateTaskServiceImpl.class);
+
+    private static final int EVENT_STATUS_ENDED = 3;
+    private static final int SITE_STATUS_ONLINE = 0;
+    private static final int SITE_STATUS_OFFLINE = 1;
+    private static final int SITE_STATUS_FAULT = 2;
+    private static final int TSDB_DEVICE_STATUS_OFFLINE = 0;
+    private static final int TSDB_DEVICE_STATUS_ONLINE = 1;
+
+    @Autowired
+    private VppDrEventMapper drEventMapper;
+    @Autowired
+    private VppDrParticipationMapper participationMapper;
+    @Autowired
+    private VppResourcePointMapper resourcePointMapper;
+    @Autowired
+    private VppDeviceMapper deviceMapper;
+    @Autowired
+    private VppSiteMapper siteMapper;
+    @Autowired
+    private VppTsdbQueryService vppTsdbQueryService;
+
+    @Override
+    public void refreshSiteAvgCompletionRate() {
+        log.info("refreshSiteAvgCompletionRate start");
+
+        Map<Long, BigDecimal> rateList = loadResourceCompletionRateMap();
+        List<VppResourcePoint> resources = loadActiveResources();
+        if (resources.isEmpty()) {
+            log.info("refreshSiteAvgCompletionRate skipped: no active resource points");
+            return;
+        }
+
+        Map<Long, List<VppResourcePoint>> siteResourcesMap = groupResourcesBySite(resources);
+        Map<Long, VppDevice> deviceMap = loadDeviceMap(resources);
+        Map<String, Map<String, BigDecimal>> tsdbMetricsMap = loadTsdbMetricsMap(deviceMap.values());
+
+        int updatedCount = 0;
+        for (Map.Entry<Long, List<VppResourcePoint>> entry : siteResourcesMap.entrySet()) {
+            Long siteId = entry.getKey();
+            List<VppResourcePoint> siteResources = entry.getValue();
+
+            Integer runStatus = resolveSiteRunStatus(siteResources, deviceMap, tsdbMetricsMap);
+            BigDecimal avgRate = averageCompletionRate(siteResources, rateList);
+
+            LambdaUpdateWrapper<VppSite> updateWrapper = new LambdaUpdateWrapper<VppSite>()
+                    .eq(VppSite::getId, siteId)
+                    .set(VppSite::getRunStatus, runStatus);
+            if (avgRate != null) {
+                updateWrapper.set(VppSite::getAvgCompletionRate, avgRate);
+            }
+            siteMapper.update(null, updateWrapper);
+            updatedCount++;
+        }
+
+        log.info("refreshSiteAvgCompletionRate finished, updatedSites={}", updatedCount);
+    }
+
+    private Map<Long, BigDecimal> loadResourceCompletionRateMap() {
+        LambdaQueryWrapper<VppDrEvent> eventWrapper = new LambdaQueryWrapper<VppDrEvent>()
+                .select(VppDrEvent::getId)
+                .eq(VppDrEvent::getEventStatus, EVENT_STATUS_ENDED)
+                .eq(VppDrEvent::getDeleteFlag, VppAuditHelper.NOT_DELETED);
+        Integer tenantId = SecurityUtils.getTenantId();
+        if (tenantId != null) {
+            eventWrapper.eq(VppDrEvent::getTenantId, tenantId);
+        }
+        List<Long> endedEventIds = drEventMapper.selectList(eventWrapper)
+                .stream()
+                .map(VppDrEvent::getId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toList());
+        if (endedEventIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+
+        List<VppDrParticipation> participations = participationMapper.selectList(
+                new LambdaQueryWrapper<VppDrParticipation>()
+                        .select(VppDrParticipation::getResourceId, VppDrParticipation::getCompletionRate)
+                        .in(VppDrParticipation::getEventId, endedEventIds)
+                        .isNotNull(VppDrParticipation::getResourceId)
+                        .isNotNull(VppDrParticipation::getCompletionRate)
+                        .eq(VppDrParticipation::getDeleteFlag, VppAuditHelper.NOT_DELETED));
+        if (CollectionUtils.isEmpty(participations)) {
+            return Collections.emptyMap();
+        }
+
+        Map<Long, List<BigDecimal>> groupedRates = new HashMap<>();
+        for (VppDrParticipation participation : participations) {
+            groupedRates.computeIfAbsent(participation.getResourceId(), key -> new ArrayList<>())
+                    .add(participation.getCompletionRate());
+        }
+
+        Map<Long, BigDecimal> rateList = new HashMap<>();
+        groupedRates.forEach((resourceId, rates) -> {
+            BigDecimal avg = averageValues(rates);
+            if (avg != null) {
+                rateList.put(resourceId, avg);
+            }
+        });
+        return rateList;
+    }
+
+    private List<VppResourcePoint> loadActiveResources() {
+        LambdaQueryWrapper<VppResourcePoint> wrapper = new LambdaQueryWrapper<VppResourcePoint>()
+                .eq(VppResourcePoint::getDeleteFlag, VppAuditHelper.NOT_DELETED);
+        Integer tenantId = SecurityUtils.getTenantId();
+        if (tenantId != null) {
+            wrapper.eq(VppResourcePoint::getTenantId, tenantId);
+        }
+        return resourcePointMapper.selectList(wrapper);
+    }
+
+    private Map<Long, List<VppResourcePoint>> groupResourcesBySite(List<VppResourcePoint> resources) {
+        Map<Long, List<VppResourcePoint>> siteResourcesMap = new HashMap<>();
+        for (VppResourcePoint resource : resources) {
+            if (resource.getSiteId() == null) {
+                continue;
+            }
+            siteResourcesMap.computeIfAbsent(resource.getSiteId(), key -> new ArrayList<>()).add(resource);
+        }
+        return siteResourcesMap;
+    }
+
+    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(device -> !VppAuditHelper.isDeleted(device.getDeleteFlag()))
+                .collect(Collectors.toMap(VppDevice::getId, device -> device, (a, b) -> a));
+    }
+
+    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,
+                Collections.singletonList("device_status"));
+    }
+
+    private Integer resolveSiteRunStatus(List<VppResourcePoint> siteResources,
+                                         Map<Long, VppDevice> deviceMap,
+                                         Map<String, Map<String, BigDecimal>> tsdbMetricsMap) {
+        List<Integer> deviceStatuses = new ArrayList<>();
+        for (VppResourcePoint resource : siteResources) {
+            VppDevice device = resolveDeviceForResource(resource, deviceMap);
+            Map<String, BigDecimal> tsdbMetrics = resolveTsdbMetrics(device, tsdbMetricsMap);
+            deviceStatuses.add(resolveDeviceOnlineStatus(tsdbMetrics));
+        }
+        return resolveSiteRunStatus(deviceStatuses);
+    }
+
+    private VppDevice resolveDeviceForResource(VppResourcePoint resource, Map<Long, VppDevice> deviceMap) {
+        if (resource.getDeviceId() == null) {
+            return null;
+        }
+        VppDevice device = deviceMap.get(resource.getDeviceId());
+        return matchesResourceSite(device, resource.getSiteId()) ? device : null;
+    }
+
+    private boolean matchesResourceSite(VppDevice device, Long siteId) {
+        return device != null && siteId != null && siteId.equals(device.getSiteId());
+    }
+
+    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 Integer resolveDeviceOnlineStatus(Map<String, BigDecimal> tsdbMetrics) {
+        if (CollectionUtils.isEmpty(tsdbMetrics) || tsdbMetrics.get("device_status") == null) {
+            return null;
+        }
+        int status = tsdbMetrics.get("device_status").intValue();
+        if (status == TSDB_DEVICE_STATUS_OFFLINE) {
+            return TSDB_DEVICE_STATUS_OFFLINE;
+        }
+        if (status == TSDB_DEVICE_STATUS_ONLINE) {
+            return TSDB_DEVICE_STATUS_ONLINE;
+        }
+        return null;
+    }
+
+    private Integer resolveSiteRunStatus(List<Integer> deviceStatuses) {
+        if (CollectionUtils.isEmpty(deviceStatuses)) {
+            return SITE_STATUS_FAULT;
+        }
+        boolean allOnline = deviceStatuses.stream().allMatch(status -> status != null && status == TSDB_DEVICE_STATUS_ONLINE);
+        boolean allOffline = deviceStatuses.stream().allMatch(status -> status != null && status == TSDB_DEVICE_STATUS_OFFLINE);
+        if (allOnline) {
+            return SITE_STATUS_ONLINE;
+        }
+        if (allOffline) {
+            return SITE_STATUS_OFFLINE;
+        }
+        return SITE_STATUS_FAULT;
+    }
+
+    private BigDecimal averageCompletionRate(List<VppResourcePoint> siteResources, Map<Long, BigDecimal> rateList) {
+        if (CollectionUtils.isEmpty(siteResources) || CollectionUtils.isEmpty(rateList)) {
+            return null;
+        }
+        List<BigDecimal> matchedRates = siteResources.stream()
+                .map(VppResourcePoint::getId)
+                .map(rateList::get)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toList());
+        return averageValues(matchedRates);
+    }
+
+    private BigDecimal averageValues(List<BigDecimal> values) {
+        if (CollectionUtils.isEmpty(values)) {
+            return null;
+        }
+        List<BigDecimal> nonNullValues = values.stream()
+                .filter(Objects::nonNull)
+                .collect(Collectors.toList());
+        if (nonNullValues.isEmpty()) {
+            return null;
+        }
+        BigDecimal sum = nonNullValues.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
+        return sum.divide(BigDecimal.valueOf(nonNullValues.size()), 4, RoundingMode.HALF_UP);
+    }
+}

+ 13 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppSiteServiceImpl.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.common.security.utils.SecurityUtils;
 import com.usky.vpp.domain.VppCustomer;
 import com.usky.vpp.domain.VppSite;
 import com.usky.vpp.domain.VppSiteConfig;
@@ -158,6 +159,7 @@ public class VppSiteServiceImpl implements VppSiteService {
     private LambdaQueryWrapper<VppSite> buildSiteWrapper(Map<String, Object> params) {
         LambdaQueryWrapper<VppSite> wrapper = new LambdaQueryWrapper<VppSite>()
                 .eq(VppSite::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .eq(VppSite::getTenantId, SecurityUtils.getTenantId())
                 .orderByDesc(VppSite::getCreateTime);
         if (params != null) {
             if (params.get("customerId") != null) {
@@ -178,6 +180,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 +241,10 @@ public class VppSiteServiceImpl implements VppSiteService {
                     .filter(Objects::nonNull)
                     .distinct()
                     .collect(Collectors.toList()));
+            vo.setAccountNo(site.getAccountNo());
+            vo.setStreetTown(site.getStreetTown());
+            vo.setAvgCompletionRate(site.getAvgCompletionRate());
+            vo.setRunStatus(site.getRunStatus());
             voList.add(vo);
         }
         return new CommonPage<>(voList, page.getTotal(), page.getCurrent(), page.getSize());
@@ -302,6 +311,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;
+}

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

@@ -0,0 +1,19 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+/**
+ * TDengine CSV 导入结果
+ */
+@Data
+public class DeviceTsdbImportResultVO {
+
+    /** 设备 UUID */
+    private String deviceUuid;
+
+    /** TDengine 子表名(_{deviceUuid}) */
+    private String tableName;
+
+    /** 成功导入的数据行数 */
+    private Integer importedRows;
+}

+ 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;
+}

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

@@ -5,15 +5,21 @@ import lombok.Data;
 import java.math.BigDecimal;
 
 /**
- * 今日电能概况(模拟数据
+ * 今日电能概况(TSDB 实时汇总
  */
 @Data
 public class EnergyTodayVO {
 
     /** 总发电量 kWh */
     private BigDecimal totalGenerationKwh;
+    /** 总发电量环比 % */
+    private BigDecimal totalGenerationKwhRingRatio;
     /** 总用电量 kWh */
     private BigDecimal totalConsumptionKwh;
+    /** 总用电量环比 % */
+    private BigDecimal totalConsumptionKwhRingRatio;
     /** 绿电消纳比例 % */
     private BigDecimal greenEnergyRatioPercent;
+    /** 绿电消纳比例环比 % */
+    private BigDecimal greenEnergyRatioPercentRingRatio;
 }

+ 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 {

部分文件因为文件数量过多而无法显示