Selaa lähdekoodia

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

hanzhengyi 4 päivää sitten
vanhempi
commit
b1f7ec5c34

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

@@ -10,7 +10,7 @@ public interface VppAliyunSmsService {
      *
      * @param phone        手机号
      * @param templateCode 模板 CODE
-     * @param templateParam JSON 模板变量,如 {"meet":"夏季削峰","time":"2026-07-08 14:00:00","room":"某某公司"}
+     * @param templateParam JSON 模板变量,如 {"name":"某某公司","responsetype":"日前","time":"2026-07-08 14:00:00~2026-07-08 15:00:00","number":"100","operationtype":"参与削峰"}
      */
     void sendTemplateSms(String phone, String templateCode, String templateParam);
 }

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

@@ -271,6 +271,8 @@ public class VppContractServiceImpl implements VppContractService {
         LambdaQueryWrapper<VppContract> queryWrapper = new LambdaQueryWrapper<>();
         queryWrapper.eq(VppContract::getDeleteFlag, 0)
                 .eq(tenantId != null, VppContract::getTenantId, tenantId)
+                .in(VppContract::getContractStatus, VppContractStatusHelper.PENDING,
+                        VppContractStatusHelper.EFFECTIVE, VppContractStatusHelper.EXPIRED)
                 .isNotNull(VppContract::getCustomerId)
                 .isNotNull(VppContract::getExpireDate)
                 .orderByDesc(VppContract::getCreateTime);

+ 15 - 1
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppContractTemplateServiceImpl.java

@@ -6,7 +6,9 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.usky.common.core.bean.CommonPage;
 import com.usky.common.core.exception.BusinessException;
 import com.usky.common.security.utils.SecurityUtils;
+import com.usky.vpp.domain.VppContract;
 import com.usky.vpp.domain.VppContractTemplate;
+import com.usky.vpp.mapper.VppContractMapper;
 import com.usky.vpp.mapper.VppContractTemplateMapper;
 import com.usky.vpp.service.VppContractTemplateService;
 import com.usky.vpp.service.vo.VppContractTemplateRequestVO;
@@ -41,6 +43,8 @@ public class VppContractTemplateServiceImpl implements VppContractTemplateServic
 
     @Autowired
     private VppContractTemplateMapper vppContractTemplateMapper;
+    @Autowired
+    private VppContractMapper vppContractMapper;
 
     private final RestTemplate restTemplate = new RestTemplate();
 
@@ -185,11 +189,21 @@ public class VppContractTemplateServiceImpl implements VppContractTemplateServic
     public Boolean delete(Long id) {
         LambdaQueryWrapper<VppContractTemplate> queryWrapper = new LambdaQueryWrapper<>();
         queryWrapper.eq(VppContractTemplate::getId, id)
+                .eq(VppContractTemplate::getDeleteFlag, 0)
                 .eq(VppContractTemplate::getTenantId, SecurityUtils.getTenantId());
         VppContractTemplate template = vppContractTemplateMapper.selectOne(queryWrapper);
 
         if (template == null) {
-            throw new BusinessException("合同模板不存在!");
+            throw new BusinessException("合同模板不存在或已被删除!");
+        }
+
+        // 模板下存在关联合同时不允许删除
+        LambdaQueryWrapper<VppContract> contractWrapper = new LambdaQueryWrapper<>();
+        contractWrapper.eq(VppContract::getTemplateId, id)
+                .eq(VppContract::getDeleteFlag, 0);
+        Integer relatedCount = vppContractMapper.selectCount(contractWrapper);
+        if (relatedCount != null && relatedCount > 0) {
+            throw new BusinessException("该合同模板下存在关联合同,无法删除!");
         }
 
         template.setDeleteFlag(1);

+ 5 - 6
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppCustomerServiceImpl.java

@@ -678,14 +678,13 @@ public class VppCustomerServiceImpl implements VppCustomerService {
     public Boolean deleteCustomer(Long id) {
         VppCustomer customer = findCustomerById(id);
 
-        // 检查是否存在非草稿状态的合同,存在则不允许删除客户
+        // 客户下存在合同(不论状态)则不允许删除
         LambdaQueryWrapper<VppContract> contractWrapper = new LambdaQueryWrapper<>();
         contractWrapper.eq(VppContract::getCustomerId, id)
-                .eq(VppContract::getDeleteFlag, 0)
-                .ne(VppContract::getContractStatus, VppContractStatusHelper.DRAFT);
-        Integer nonDraftCount = contractMapper.selectCount(contractWrapper);
-        if (nonDraftCount != null && nonDraftCount > 0) {
-            throw new BusinessException("该客户下存在非草稿状态的合同,无法删除!");
+                .eq(VppContract::getDeleteFlag, 0);
+        Integer contractCount = contractMapper.selectCount(contractWrapper);
+        if (contractCount != null && contractCount > 0) {
+            throw new BusinessException("该客户下存在合同,无法删除!");
         }
 
         VppAuditHelper.fillSoftDelete(customer);

+ 101 - 19
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppResponseMonitorServiceImpl.java

@@ -26,6 +26,7 @@ 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.VppBaselineHelper;
 import com.usky.vpp.util.VppCapabilityEvalHelper;
 import com.usky.vpp.util.VppResponseMonitorHelper;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -33,8 +34,10 @@ import org.springframework.stereotype.Service;
 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.DateTimeParseException;
 import java.util.ArrayList;
@@ -42,6 +45,7 @@ import java.util.Arrays;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -54,6 +58,7 @@ import java.util.stream.Collectors;
 public class VppResponseMonitorServiceImpl implements VppResponseMonitorService {
 
     private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+    private static final int EVENT_STATUS_ENDED = 3;
 
     @Autowired
     private VppSiteMapper siteMapper;
@@ -206,26 +211,86 @@ public class VppResponseMonitorServiceImpl implements VppResponseMonitorService
                                         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");
-            }
+        if (deviceUuids.isEmpty()) {
+            return new CurveBuildResult(
+                    VppResponseMonitorHelper.buildMockCurve(resources, devices, siteId, statDate, declaredCapacityKw),
+                    "mock");
         }
-        return new CurveBuildResult(
-                VppResponseMonitorHelper.buildMockCurve(resources, devices, siteId, statDate, declaredCapacityKw),
-                "mock");
+
+        // 确定响应时段(有事件用事件时段,无事件用全天)
+        LocalDateTime responseStart;
+        LocalDateTime responseEnd;
+        if (drContext.event != null && drContext.event.getStartTime() != null) {
+            responseStart = drContext.event.getStartTime();
+            responseEnd = drContext.event.getEndTime() != null
+                    ? drContext.event.getEndTime() : statDate.atTime(23, 59, 59);
+        } else {
+            responseStart = statDate.atStartOfDay();
+            responseEnd = statDate.atTime(23, 59, 59);
+        }
+
+        // 选取典型历史日(与基线管理一致:法定节假日 + 调休,工作日5天 / 非工作日3天)
+        Set<LocalDate> excludeDates = loadResponseHistoryDates();
+        boolean workday = VppBaselineHelper.isWorkdayResponse(statDate);
+        int requiredCount = workday
+                ? VppBaselineHelper.WEEKDAY_REFERENCE_COUNT
+                : VppBaselineHelper.NON_WEEKDAY_REFERENCE_COUNT;
+        List<LocalDate> referenceDates = VppBaselineHelper.selectReferenceDates(
+                statDate, workday, excludeDates, requiredCount);
+
+        int interval = VppResponseMonitorHelper.BUCKET_MINUTES;
+
+        // 查询原始基线(全天,多个典型历史日同时段均值)
+        Map<String, BigDecimal> originalBaseline = tsdbQueryService.queryBaselineAvgByTimePoint(
+                deviceUuids, referenceDates,
+                LocalTime.MIN, LocalTime.of(23, 59, 59),
+                VppBaselineHelper.POWER_METRICS, interval, null);
+        if (originalBaseline.isEmpty()) {
+            return new CurveBuildResult(
+                    VppResponseMonitorHelper.buildMockCurve(resources, devices, siteId, statDate, declaredCapacityKw),
+                    "mock");
+        }
+
+        // 今日实际负荷(全天)
+        Map<String, BigDecimal> todayActual = VppBaselineHelper.buildActualLoadByTimePoint(
+                tsdbQueryService.queryDeviceMetricHistory(
+                        deviceUuids, statDate.atStartOfDay(), statDate.atTime(23, 59, 59),
+                        VppBaselineHelper.POWER_METRICS),
+                interval);
+
+        // 修正系数 K:响应开始前2小时今日实测 / 同时段原始基线,限制 [0.7, 1.2]
+        LocalDateTime correctionWindowStart = responseStart.minusHours(VppBaselineHelper.CORRECTION_HOURS);
+        Map<String, BigDecimal> correctionBaseline = tsdbQueryService.queryBaselineAvgByTimePoint(
+                deviceUuids, referenceDates,
+                correctionWindowStart.toLocalTime(), responseStart.toLocalTime(),
+                VppBaselineHelper.POWER_METRICS, interval, null);
+        BigDecimal correctionFactorK = VppBaselineHelper.calculateCorrectionFactor(
+                responseStart, todayActual,
+                correctionBaseline.isEmpty() ? originalBaseline : correctionBaseline);
+
+        // 预测基线 = 原始基线 × 修正系数 K
+        Map<String, BigDecimal> predictedBaseline = new TreeMap<>();
+        for (Map.Entry<String, BigDecimal> entry : originalBaseline.entrySet()) {
+            predictedBaseline.put(entry.getKey(),
+                    entry.getValue().multiply(correctionFactorK).setScale(2, RoundingMode.HALF_UP));
+        }
+
+        // TSDB 申报容量(仅当有事件时计算)
+        BigDecimal tsdbDeclaredKw = BigDecimal.ZERO;
+        if (drContext.event != null) {
+            Map<String, BigDecimal> responseWindowBaseline = tsdbQueryService.queryBaselineAvgByTimePoint(
+                    deviceUuids, referenceDates,
+                    responseStart.toLocalTime(), responseEnd.toLocalTime(),
+                    VppBaselineHelper.POWER_METRICS, interval, null);
+            tsdbDeclaredKw = VppBaselineHelper.calculateDeclaredCapacityKw(
+                    responseWindowBaseline, responseStart, responseEnd, correctionFactorK);
+        }
+        BigDecimal finalDeclared = tsdbDeclaredKw.compareTo(BigDecimal.ZERO) > 0
+                ? tsdbDeclaredKw : declaredCapacityKw;
+
+        List<ResponseLoadPointVO> points = VppResponseMonitorHelper.buildCurveWithBaseline(
+                predictedBaseline, todayActual, statDate, finalDeclared, drContext.executionByTime, interval);
+        return new CurveBuildResult(points, "tsdb");
     }
 
     private DrContext resolveDrContext(Long siteId, LocalDate statDate, Long eventId) {
@@ -299,6 +364,23 @@ public class VppResponseMonitorServiceImpl implements VppResponseMonitorService
         return time.withMinute(minute).withSecond(0).withNano(0);
     }
 
+    /**
+     * 查询历史已结束的 DR 事件日期,用于基线测算时排除。
+     */
+    private Set<LocalDate> loadResponseHistoryDates() {
+        List<VppDrEvent> events = eventMapper.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 ResponseMonitorTreeNodeVO toSiteNode(VppSite site, Long parentId) {
         ResponseMonitorTreeNodeVO node = new ResponseMonitorTreeNodeVO();
         node.setNodeType("SITE");

+ 39 - 3
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppResponseMonitorHelper.java

@@ -102,6 +102,44 @@ public final class VppResponseMonitorHelper {
         return points;
     }
 
+    /**
+     * 基于预计算的预测基线和今日实际,构建曲线点(替代直接操作 TSDB 原始时序)。
+     */
+    public static List<ResponseLoadPointVO> buildCurveWithBaseline(
+            Map<String, BigDecimal> predictedBaseline,
+            Map<String, BigDecimal> todayActual,
+            LocalDate date,
+            BigDecimal declaredCapacityKw,
+            Map<LocalDateTime, BigDecimal> executionActualByTime,
+            int intervalMinutes) {
+        List<ResponseLoadPointVO> points = new ArrayList<>();
+        for (int minute = 0; minute < 1440; minute += intervalMinutes) {
+            LocalTime time = LocalTime.of(minute / 60, minute % 60);
+            String timeKey = time.format(TIME_FMT);
+            LocalDateTime bucketStart = date.atTime(time);
+
+            BigDecimal baseline = predictedBaseline.getOrDefault(timeKey, BigDecimal.ZERO);
+            BigDecimal actual = todayActual.getOrDefault(timeKey, BigDecimal.ZERO);
+
+            BigDecimal responseLoad = executionActualByTime != null
+                    ? executionActualByTime.getOrDefault(bucketStart, null)
+                    : null;
+            if (responseLoad == null) {
+                responseLoad = baseline.subtract(actual).max(BigDecimal.ZERO);
+            }
+
+            ResponseLoadPointVO point = new ResponseLoadPointVO();
+            point.setTime(timeKey);
+            point.setDatetime(bucketStart.format(DATETIME_FMT));
+            point.setBaselineLoadKw(scale(baseline));
+            point.setActualLoadKw(scale(actual));
+            point.setDeclaredCapacityKw(declaredCapacityKw != null ? scale(declaredCapacityKw) : BigDecimal.ZERO);
+            point.setActualResponseLoadKw(scale(responseLoad));
+            points.add(point);
+        }
+        return points;
+    }
+
     public static List<ResponseLoadStatRowVO> buildStatistics(List<ResponseLoadPointVO> points, LocalDate date) {
         if (points == null || points.isEmpty()) {
             return Collections.emptyList();
@@ -170,9 +208,7 @@ public final class VppResponseMonitorHelper {
             if (kw == null) {
                 continue;
             }
-            if (!actual) {
-                kw = kw.multiply(BigDecimal.valueOf(1.02 + pseudo(point.getTime()) * 0.05));
-            }
+            // baselineLoadKw 已是修正后的预测基线(原始基线 × K),无需伪随机调整
             allKw.add(kw);
             String hourKey = point.getTime().substring(0, 2) + ":00";
             hourlyBuckets.computeIfAbsent(hourKey, k -> new ArrayList<>()).add(kw);

+ 69 - 13
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppSmsTemplateHelper.java

@@ -5,12 +5,17 @@ import com.usky.vpp.domain.VppCustomerContact;
 import com.usky.vpp.domain.VppDrEvent;
 import com.usky.vpp.domain.VppDrInvitation;
 
+import java.math.BigDecimal;
 import java.time.format.DateTimeFormatter;
 
 /**
  * 邀约短信模板参数构建
  * <p>
- * 阿里云模板变量需与控制台一致,默认使用:meet、time、room(SMS_465362899)
+ * 阿里云模板变量需与控制台一致:name、responsetype、time、number、operationtype
+ * <pre>
+ * 尊敬的${name},您已通过电力需求响应审核。 响应类型:${responsetype} 响应时间:${time}
+ * 邀约规模: ${number}kW 请在规定时段内${operationtype}
+ * </pre>
  */
 public final class VppSmsTemplateHelper {
 
@@ -23,21 +28,72 @@ public final class VppSmsTemplateHelper {
                                             VppDrEvent event,
                                             VppCustomer customer,
                                             VppCustomerContact contact) {
-        String meet = event != null && event.getEventName() != null
-                ? event.getEventName()
-                : (event != null ? event.getEventId() : "");
-        String time = "";
-        if (event != null && event.getStartTime() != null) {
-            time = TIME_FORMAT.format(event.getStartTime());
-        } else if (invitation.getReplyDeadline() != null) {
-            time = TIME_FORMAT.format(invitation.getReplyDeadline());
-        }
-        String room = customer != null && customer.getCustomerName() != null
+        String name = customer != null && customer.getCustomerName() != null
                 ? customer.getCustomerName()
                 : "";
-        return "{\"meet\":\"" + escapeJson(meet)
+
+        Integer responseType = invitation != null && invitation.getResponseType() != null
+                ? invitation.getResponseType()
+                : (event != null ? event.getResponseType() : null);
+        String responsetype = VppCapabilityEvalHelper.responseTypeLabel(responseType);
+
+        String time = buildResponseTime(invitation, event);
+
+        BigDecimal capacity = invitation != null && invitation.getDemandCapacityKw() != null
+                ? invitation.getDemandCapacityKw()
+                : (event != null ? event.getTargetCapacityKw() : null);
+        String number = formatNumber(capacity);
+
+        Integer eventType = invitation != null && invitation.getTransactionType() != null
+                ? invitation.getTransactionType()
+                : (event != null ? event.getEventType() : null);
+        String operationtype = buildOperationType(eventType);
+
+        return "{\"name\":\"" + escapeJson(name)
+                + "\",\"responsetype\":\"" + escapeJson(responsetype)
                 + "\",\"time\":\"" + escapeJson(time)
-                + "\",\"room\":\"" + escapeJson(room) + "\"}";
+                + "\",\"number\":\"" + escapeJson(number)
+                + "\",\"operationtype\":\"" + escapeJson(operationtype) + "\"}";
+    }
+
+    private static String buildResponseTime(VppDrInvitation invitation, VppDrEvent event) {
+        if (event != null && event.getStartTime() != null) {
+            if (event.getEndTime() != null) {
+                return TIME_FORMAT.format(event.getStartTime()) + "~" + TIME_FORMAT.format(event.getEndTime());
+            }
+            return TIME_FORMAT.format(event.getStartTime());
+        }
+        if (invitation != null && invitation.getExecuteStartDate() != null) {
+            if (invitation.getExecuteEndDate() != null
+                    && !invitation.getExecuteStartDate().equals(invitation.getExecuteEndDate())) {
+                return invitation.getExecuteStartDate() + "~" + invitation.getExecuteEndDate();
+            }
+            return invitation.getExecuteStartDate().toString();
+        }
+        if (invitation != null && invitation.getReplyDeadline() != null) {
+            return TIME_FORMAT.format(invitation.getReplyDeadline());
+        }
+        return "";
+    }
+
+    private static String buildOperationType(Integer eventType) {
+        if (eventType == null) {
+            return "完成响应";
+        }
+        if (eventType == 1) {
+            return "参与削峰";
+        }
+        if (eventType == 2) {
+            return "参与填谷";
+        }
+        return "完成响应";
+    }
+
+    private static String formatNumber(BigDecimal value) {
+        if (value == null) {
+            return "0";
+        }
+        return value.stripTrailingZeros().toPlainString();
     }
 
     private static String escapeJson(String value) {