Ver Fonte

补贴金额&响应量偏差价格调整

fuyuchuan há 1 dia atrás
pai
commit
6980b64f8f
15 ficheiros alterados com 402 adições e 79 exclusões
  1. 25 5
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/DrController.java
  2. 3 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppDrSubsidyPrediction.java
  3. 16 3
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppResponseDeviationPrice.java
  4. 9 2
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppDrSubsidyPredictionService.java
  5. 6 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppResponseDeviationPriceService.java
  6. 2 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppBaselineServiceImpl.java
  7. 135 27
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDrSubsidyPredictionServiceImpl.java
  8. 54 11
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppResponseDeviationPriceServiceImpl.java
  9. 19 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DrSubsidyPredictionCalculateRequest.java
  10. 17 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DrSubsidyPredictionRequest.java
  11. 2 2
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DrSubsidyPredictionVO.java
  12. 11 2
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ResponseDeviationPriceAdjustmentRequest.java
  13. 13 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ResponseDeviationPriceAdjustmentVO.java
  14. 75 11
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppResponseCoefficientHelper.java
  15. 15 11
      service-vpp/service-vpp-biz/src/main/resources/sql/vpp_schema.sql

+ 25 - 5
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/DrController.java

@@ -20,6 +20,7 @@ import com.usky.vpp.service.vo.DrInvitationVO;
 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.service.vo.DrSubsidyPredictionCalculateRequest;
 import com.usky.vpp.service.vo.DrSubsidyPredictionRequest;
 import com.usky.vpp.service.vo.DrSubsidyPredictionVO;
 import com.usky.vpp.service.vo.ResponseDeviationPriceAdjustmentRequest;
@@ -27,6 +28,7 @@ import com.usky.vpp.service.vo.ResponseDeviationPriceAdjustmentVO;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
 
+import java.math.BigDecimal;
 import java.util.Map;
 
 /**
@@ -205,7 +207,19 @@ public class DrController {
     }
 
     /**
-     * 新增补贴金额预测(站点/响应时间手动选择;时长、偏差率、完成率、补贴金额自动计算)
+     * 试算补贴金额预测(不落库)。
+     * <p>入参:邀约编号、实际响应容量(电网);其余计算字段由服务端按邀约/事件查询。
+     * 前端拿到结果后再调用新增接口入库。</p>
+     */
+    @PostMapping(value = "/subsidyPrediction/calculate")
+    public ApiResult<DrSubsidyPredictionVO> calculateSubsidyPrediction(
+            @RequestBody DrSubsidyPredictionCalculateRequest request) {
+        return ApiResult.success(vppDrSubsidyPredictionService.calculate(request));
+    }
+
+    /**
+     * 新增补贴金额预测。
+     * <p>按前端传参直接入库(通常携带试算结果),服务端不再重算。</p>
      */
     @PostMapping(value = "/subsidyPrediction")
     public ApiResult<Boolean> createSubsidyPrediction(@RequestBody DrSubsidyPredictionRequest request) {
@@ -214,7 +228,8 @@ public class DrController {
     }
 
     /**
-     * 修改补贴金额预测
+     * 修改补贴金额预测。
+     * <p>按前端传参直接入库,服务端不再重算。</p>
      */
     @PutMapping(value = "/subsidyPrediction")
     public ApiResult<Boolean> updateSubsidyPrediction(@RequestBody DrSubsidyPredictionRequest request) {
@@ -232,13 +247,18 @@ public class DrController {
     }
 
     // ==================== 响应量偏差价格调整 ====================
-    // 分页查询响应量偏差价格调整 responseCoefficient  响应系数(模糊匹配)
+    /**
+     * 分页查询响应量偏差价格调整。
+     * completionRate 可选,传入时按结构化区间匹配命中配置。
+     * current   页码
+     * size      页大小
+     */
     @GetMapping(value = "/responseDeviationPrice")
     public ApiResult<CommonPage<ResponseDeviationPriceAdjustmentVO>> pageResponseDeviationPriceAdjustment(
-            @RequestParam(value = "responseCoefficient", required = false) String responseCoefficient,
+            @RequestParam(value = "completionRate", required = false) BigDecimal completionRate,
             @RequestParam(value = "current", required = false, defaultValue = "1") Integer current,
             @RequestParam(value = "size", required = false, defaultValue = "20") Integer size) {
-        return ApiResult.success(responseDeviationPriceService.page(responseCoefficient, current, size));
+        return ApiResult.success(responseDeviationPriceService.page(completionRate, current, size));
     }
 
     @GetMapping(value = "/responseDeviationPrice/{id}")

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

@@ -75,13 +75,15 @@ public class VppDrSubsidyPrediction implements Serializable {
     @TableField("share_ratio")
     private BigDecimal shareRatio;
 
-    /** 按完成率命中响应量偏差价格调整表得到的价格调整系数。 */
+    /** 按完成率(比例)命中响应量偏差价格调整表得到的价格调整系数。 */
     @TableField("price_adjustment_coefficient")
     private BigDecimal priceAdjustmentCoefficient;
 
+    /** 完成率(百分比)= 平台实际响应量 / 申报出清量 × 100 */
     @TableField("completion_rate")
     private BigDecimal completionRate;
 
+    /** 偏差率(百分比)= (电网实际响应量 − 平台实际响应量) / 申报出清量 × 100 */
     @TableField("deviation_rate")
     private BigDecimal deviationRate;
 

+ 16 - 3
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppResponseDeviationPrice.java

@@ -1,5 +1,6 @@
 package com.usky.vpp.domain;
 
+import com.baomidou.mybatisplus.annotation.FieldStrategy;
 import com.baomidou.mybatisplus.annotation.IdType;
 import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
@@ -24,9 +25,21 @@ public class VppResponseDeviationPrice implements Serializable {
     @TableId(value = "id", type = IdType.AUTO)
     private Long id;
 
-    /** 响应量系数区间,例如 [0.8, 1.2]。 */
-    @TableField("response_coefficient")
-    private String responseCoefficient;
+    /** 响应量系数下限,null 表示无下界。 */
+    @TableField(value = "coeff_lower", updateStrategy = FieldStrategy.IGNORED)
+    private BigDecimal coeffLower;
+
+    /** 响应量系数上限,null 表示无上界。 */
+    @TableField(value = "coeff_upper", updateStrategy = FieldStrategy.IGNORED)
+    private BigDecimal coeffUpper;
+
+    /** 下限是否闭区间。 */
+    @TableField("lower_inclusive")
+    private Boolean lowerInclusive;
+
+    /** 上限是否闭区间。 */
+    @TableField("upper_inclusive")
+    private Boolean upperInclusive;
 
     /** 命中区间后用于补贴计算的价格调整系数。 */
     @TableField("price_adjustment_coefficient")

+ 9 - 2
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppDrSubsidyPredictionService.java

@@ -1,6 +1,7 @@
 package com.usky.vpp.service;
 
 import com.usky.common.core.bean.CommonPage;
+import com.usky.vpp.service.vo.DrSubsidyPredictionCalculateRequest;
 import com.usky.vpp.service.vo.DrSubsidyPredictionRequest;
 import com.usky.vpp.service.vo.DrSubsidyPredictionVO;
 
@@ -16,12 +17,18 @@ public interface VppDrSubsidyPredictionService {
                                            Integer current, Integer size);
 
     /**
-     * 新增补贴预测(站点、响应时间可选手动;时长/偏差率/完成率/补贴金额自动计算)
+     * 试算补贴预测(不落库)。
+     * <p>入参为邀约编号 + 电网实际响应容量;时长/平台响应量/申报出清量/补贴标准等从邀约与事件查询后计算。</p>
+     */
+    DrSubsidyPredictionVO calculate(DrSubsidyPredictionCalculateRequest request);
+
+    /**
+     * 新增补贴预测:按前端传参直接入库,服务端不再重算。
      */
     Boolean create(DrSubsidyPredictionRequest request);
 
     /**
-     * 修改补贴预测
+     * 修改补贴预测:按前端传参直接入库,服务端不再重算。
      */
     Boolean update(DrSubsidyPredictionRequest request);
 

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

@@ -4,12 +4,17 @@ import com.usky.common.core.bean.CommonPage;
 import com.usky.vpp.service.vo.ResponseDeviationPriceAdjustmentRequest;
 import com.usky.vpp.service.vo.ResponseDeviationPriceAdjustmentVO;
 
+import java.math.BigDecimal;
+
 /**
  * 响应量偏差价格调整配置服务。
  */
 public interface VppResponseDeviationPriceService {
 
-    CommonPage<ResponseDeviationPriceAdjustmentVO> page(String responseCoefficient, Integer current, Integer size);
+    /**
+     * 分页查询。completionRate 非空时按结构化区间匹配命中配置。
+     */
+    CommonPage<ResponseDeviationPriceAdjustmentVO> page(BigDecimal completionRate, Integer current, Integer size);
 
     ResponseDeviationPriceAdjustmentVO get(Long id);
 

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

@@ -239,9 +239,10 @@ public class VppBaselineServiceImpl implements VppBaselineService {
                 VppBaselineHelper.POWER_METRICS,
                 VppBaselineHelper.INTERVAL_MINUTES,
                 null);
+        // 查询至响应结束,供修正系数 K(响应前)与预估响应容量(响应时段内)共用
         Map<String, BigDecimal> todayActualByTime = VppBaselineHelper.buildActualLoadByTimePoint(
                 vppTsdbQueryService.queryDeviceMetricHistory(
-                        deviceUuids, responseDate.atStartOfDay(), startTime, VppBaselineHelper.POWER_METRICS),
+                        deviceUuids, responseDate.atStartOfDay(), endTime, VppBaselineHelper.POWER_METRICS),
                 VppBaselineHelper.INTERVAL_MINUTES);
         BigDecimal correctionFactorK = VppBaselineHelper.calculateCorrectionFactor(
                 startTime, todayActualByTime, correctionBaselineByTime);

+ 135 - 27
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDrSubsidyPredictionServiceImpl.java

@@ -12,9 +12,12 @@ import com.usky.vpp.domain.VppResponseDeviationPrice;
 import com.usky.vpp.domain.VppSite;
 import com.usky.vpp.mapper.*;
 import com.usky.vpp.mapper.VppResponseDeviationPriceMapper;
+import com.usky.vpp.service.VppBaselineService;
 import com.usky.vpp.service.VppDrSubsidyPredictionService;
+import com.usky.vpp.service.vo.DrSubsidyPredictionCalculateRequest;
 import com.usky.vpp.service.vo.DrSubsidyPredictionRequest;
 import com.usky.vpp.service.vo.DrSubsidyPredictionVO;
+import com.usky.vpp.service.vo.SiteDeclaredCapacityVO;
 import com.usky.vpp.util.VppAuditHelper;
 import com.usky.vpp.util.VppResponseCoefficientHelper;
 import org.slf4j.Logger;
@@ -29,6 +32,7 @@ import java.math.BigDecimal;
 import java.math.RoundingMode;
 import java.time.Duration;
 import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
@@ -44,6 +48,7 @@ public class VppDrSubsidyPredictionServiceImpl implements VppDrSubsidyPrediction
 
     private static final Logger log = LoggerFactory.getLogger(VppDrSubsidyPredictionServiceImpl.class);
     private static final BigDecimal MINUTES_PER_HOUR = BigDecimal.valueOf(60);
+    private static final DateTimeFormatter DATE_TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
 
     @Autowired
     private VppDrSubsidyPredictionMapper subsidyPredictionMapper;
@@ -55,6 +60,8 @@ public class VppDrSubsidyPredictionServiceImpl implements VppDrSubsidyPrediction
     private VppSiteMapper siteMapper;
     @Autowired
     private VppResponseDeviationPriceMapper priceAdjustmentMapper;
+    @Autowired
+    private VppBaselineService vppBaselineService;
 
     @Override
     public CommonPage<DrSubsidyPredictionVO> page(String siteName, String startTime, String endTime,
@@ -87,25 +94,62 @@ public class VppDrSubsidyPredictionServiceImpl implements VppDrSubsidyPrediction
     }
 
     @Override
-    @Transactional(rollbackFor = Exception.class)
-    public Boolean create(DrSubsidyPredictionRequest request) {
+    public DrSubsidyPredictionVO calculate(DrSubsidyPredictionCalculateRequest request) {
         if (request == null) {
             throw new BusinessException("请求参数不能为空");
         }
-        if (request.getSiteId() == null) {
-            throw new BusinessException("站点不能为空");
+        if (!StringUtils.hasText(request.getInvitationNo())) {
+            throw new BusinessException("邀约编号不能为空");
         }
-        if (request.getEventStartTime() == null || request.getEventEndTime() == null) {
-            throw new BusinessException("响应起止时间不能为空");
+        if (request.getActualResponseGridKw() == null) {
+            throw new BusinessException("实际响应容量(电网)不能为空");
         }
-        if (!request.getEventEndTime().isAfter(request.getEventStartTime())) {
-            throw new BusinessException("响应结束时间必须晚于开始时间");
+
+        VppDrInvitation invitation = findInvitationByNo(request.getInvitationNo().trim());
+        VppDrEvent event = invitation.getDrEventId() == null ? null : eventMapper.selectById(invitation.getDrEventId());
+        if (event == null || VppAuditHelper.isDeleted(event.getDeleteFlag())) {
+            throw new BusinessException("邀约关联的需求响应事件不存在");
         }
 
+        VppDrSubsidyPrediction entity = new VppDrSubsidyPrediction();
+        entity.setInvitationId(invitation.getId());
+        entity.setDrEventId(event.getId());
+        entity.setSiteId(invitation.getSiteId());
+        fillSiteName(entity);
+        entity.setEventStartTime(event.getStartTime());
+        entity.setEventEndTime(event.getEndTime());
+        entity.setSubsidyPrice(event.getSubsidyPrice());
+
+        BigDecimal declaredKw = invitation.getDeclaredCapacityKw() != null
+                ? invitation.getDeclaredCapacityKw()
+                : invitation.getActualClearedCapacityKw();
+        BigDecimal platformKw = resolvePlatformActualResponseKw(
+                invitation.getSiteId(), event.getStartTime(), event.getEndTime(),
+                invitation.getEstimatedResponseCapacityKw());
+
+        entity.setActualResponseGridKw(request.getActualResponseGridKw());
+        entity.setActualResponsePlatformKw(platformKw);
+        entity.setDeclaredClearedKw(declaredKw);
+        entity.setEstimatedCapacityKw(platformKw);
+        entity.setClearedCapacityKw(declaredKw);
+        entity.setTenantId(event.getTenantId() != null ? event.getTenantId() : invitation.getTenantId());
+
+        recalculate(entity, loadActivePriceAdjustments(
+                entity.getTenantId() != null ? entity.getTenantId() : SecurityUtils.getTenantId()));
+
+        Map<Long, String> invitationNoMap = Collections.singletonMap(invitation.getId(), invitation.getInvitationNo());
+        return toVo(entity, invitationNoMap);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Boolean create(DrSubsidyPredictionRequest request) {
+        if (request == null) {
+            throw new BusinessException("请求参数不能为空");
+        }
         VppDrSubsidyPrediction entity = new VppDrSubsidyPrediction();
         applyRequest(entity, request);
         fillSiteName(entity);
-        recalculate(entity, loadActivePriceAdjustments(SecurityUtils.getTenantId()));
         entity.setTenantId(SecurityUtils.getTenantId());
         entity.setCreateTime(LocalDateTime.now());
         entity.setUpdateTime(LocalDateTime.now());
@@ -131,7 +175,6 @@ public class VppDrSubsidyPredictionServiceImpl implements VppDrSubsidyPrediction
         if (request.getSiteId() != null) {
             fillSiteName(existing);
         }
-        recalculate(existing, loadActivePriceAdjustments(tenantId != null ? tenantId : existing.getTenantId()));
         existing.setUpdateTime(LocalDateTime.now());
         return subsidyPredictionMapper.updateById(existing) > 0;
     }
@@ -220,7 +263,9 @@ public class VppDrSubsidyPredictionServiceImpl implements VppDrSubsidyPrediction
         p.setEventEndTime(event.getEndTime());
         p.setSubsidyPrice(event.getSubsidyPrice());
 
-        BigDecimal platformKw = invitation.getEstimatedResponseCapacityKw();
+        BigDecimal platformKw = resolvePlatformActualResponseKw(
+                invitation.getSiteId(), event.getStartTime(), event.getEndTime(),
+                invitation.getEstimatedResponseCapacityKw());
         BigDecimal gridKw = invitation.getActualResponseCapacityKw();
         BigDecimal declaredKw = invitation.getDeclaredCapacityKw() != null
                 ? invitation.getDeclaredCapacityKw()
@@ -236,6 +281,46 @@ public class VppDrSubsidyPredictionServiceImpl implements VppDrSubsidyPrediction
         return p;
     }
 
+    /**
+     * 平台实际响应量:复用申报容量链路(可调峰资源点→设备→TSDB 响应时段有功功率均值)。
+     * TSDB 无数据时回退邀约上的预估响应容量。
+     */
+    private BigDecimal resolvePlatformActualResponseKw(Long siteId,
+                                                       LocalDateTime startTime,
+                                                       LocalDateTime endTime,
+                                                       BigDecimal fallbackKw) {
+        if (siteId == null || startTime == null || endTime == null || !endTime.isAfter(startTime)) {
+            return fallbackKw;
+        }
+        try {
+            SiteDeclaredCapacityVO capacity = vppBaselineService.getSiteDeclaredCapacity(
+                    siteId,
+                    startTime.format(DATE_TIME_FMT),
+                    endTime.format(DATE_TIME_FMT));
+            if (capacity != null && capacity.getEstimatedResponseCapacityKw() != null
+                    && capacity.getEstimatedResponseCapacityKw().compareTo(BigDecimal.ZERO) > 0) {
+                return capacity.getEstimatedResponseCapacityKw();
+            }
+        } catch (Exception ex) {
+            log.warn("查询平台实际响应量失败,回退邀约预估容量 siteId={}, err={}", siteId, ex.getMessage());
+        }
+        return fallbackKw;
+    }
+
+    private VppDrInvitation findInvitationByNo(String invitationNo) {
+        Integer tenantId = SecurityUtils.getTenantId();
+        VppDrInvitation invitation = invitationMapper.selectOne(
+                new LambdaQueryWrapper<VppDrInvitation>()
+                        .eq(VppDrInvitation::getInvitationNo, invitationNo)
+                        .eq(VppDrInvitation::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                        .eq(tenantId != null, VppDrInvitation::getTenantId, tenantId)
+                        .last("LIMIT 1"));
+        if (invitation == null) {
+            throw new BusinessException("邀约不存在:" + invitationNo);
+        }
+        return invitation;
+    }
+
     private void applyRequest(VppDrSubsidyPrediction entity, DrSubsidyPredictionRequest request) {
         if (request.getInvitationId() != null) {
             entity.setInvitationId(request.getInvitationId());
@@ -269,6 +354,21 @@ public class VppDrSubsidyPredictionServiceImpl implements VppDrSubsidyPrediction
         if (request.getShareRatio() != null) {
             entity.setShareRatio(normalizeShareRatio(request.getShareRatio()));
         }
+        if (request.getResponseDurationMin() != null) {
+            entity.setResponseDurationMin(request.getResponseDurationMin());
+        }
+        if (request.getDeviationRate() != null) {
+            entity.setDeviationRate(request.getDeviationRate());
+        }
+        if (request.getCompletionRate() != null) {
+            entity.setCompletionRate(request.getCompletionRate());
+        }
+        if (request.getPriceAdjustmentCoefficient() != null) {
+            entity.setPriceAdjustmentCoefficient(request.getPriceAdjustmentCoefficient());
+        }
+        if (request.getEstimatedSubsidyAmount() != null) {
+            entity.setEstimatedSubsidyAmount(request.getEstimatedSubsidyAmount());
+        }
     }
 
     private void fillSiteName(VppDrSubsidyPrediction entity) {
@@ -292,13 +392,17 @@ public class VppDrSubsidyPredictionServiceImpl implements VppDrSubsidyPrediction
                                                          List<VppResponseDeviationPrice> adjustments) {
         if (adjustments != null) {
             for (VppResponseDeviationPrice adjustment : adjustments) {
-                if (VppResponseCoefficientHelper.matches(adjustment.getResponseCoefficient(), completionRate)) {
+                if (VppResponseCoefficientHelper.matches(
+                        adjustment.getCoeffLower(),
+                        adjustment.getCoeffUpper(),
+                        adjustment.getLowerInclusive(),
+                        adjustment.getUpperInclusive(),
+                        completionRate)) {
                     return adjustment.getPriceAdjustmentCoefficient();
                 }
             }
         }
-        throw new BusinessException("完成率 " + completionRate.stripTrailingZeros().toPlainString()
-                + " 未匹配到响应量偏差价格调整配置");
+        throw new BusinessException("未匹配到响应量偏差价格调整配置!");
     }
 
     private Map<Long, String> loadInvitationNoMap(List<VppDrSubsidyPrediction> predictions) {
@@ -317,9 +421,9 @@ public class VppDrSubsidyPredictionServiceImpl implements VppDrSubsidyPrediction
     /**
      * 自动计算:响应时长、偏差率、完成率、补贴金额
      * <ul>
-     *   <li>偏差率 = (电网实际响应量 − 平台实际响应量) / 申报出清量</li>
-     *   <li>完成率 = 1 − 偏差率</li>
-     *   <li>补贴金额 = 实际响应量 × 响应时长(h) × 价格调整系数 × 补贴标准</li>
+     *   <li>完成率(%)= 平台实际响应量 / 申报出清量 × 100(≥0,正常 0~100,超发可 &gt;100)</li>
+     *   <li>偏差率(%)= (电网实际响应量 − 平台实际响应量) / 申报出清量 × 100(可正可负)</li>
+     *   <li>补贴金额 = 平台实际响应量 × 响应时长(h) × 价格调整系数 × 补贴标准</li>
      * </ul>
      */
     private void recalculate(VppDrSubsidyPrediction entity,
@@ -340,7 +444,7 @@ public class VppDrSubsidyPredictionServiceImpl implements VppDrSubsidyPrediction
                 : entity.getClearedCapacityKw();
 
         if (declaredKw == null || declaredKw.compareTo(BigDecimal.ZERO) <= 0
-                || gridKw == null || platformKw == null) {
+                || platformKw == null || gridKw == null) {
             entity.setDeviationRate(null);
             entity.setCompletionRate(null);
             entity.setPriceAdjustmentCoefficient(null);
@@ -348,20 +452,24 @@ public class VppDrSubsidyPredictionServiceImpl implements VppDrSubsidyPrediction
             return;
         }
 
-        BigDecimal deviationRate = gridKw.subtract(platformKw)
-                .divide(declaredKw, 4, RoundingMode.HALF_UP);
-        BigDecimal completionRate = BigDecimal.ONE.subtract(deviationRate)
-                .setScale(4, RoundingMode.HALF_UP);
-        BigDecimal priceAdjustmentCoefficient = resolvePriceAdjustmentCoefficient(completionRate, adjustments);
-        entity.setDeviationRate(deviationRate);
-        entity.setCompletionRate(completionRate);
+        // 比例:完成率用于匹配响应量偏差价格调整配置(区间按 0~1+ 配置)
+        BigDecimal completionRatio = platformKw.divide(declaredKw, 6, RoundingMode.HALF_UP);
+        if (completionRatio.compareTo(BigDecimal.ZERO) < 0) {
+            completionRatio = BigDecimal.ZERO;
+        }
+        BigDecimal deviationRatio = gridKw.subtract(platformKw)
+                .divide(declaredKw, 6, RoundingMode.HALF_UP);
+        BigDecimal priceAdjustmentCoefficient = resolvePriceAdjustmentCoefficient(completionRatio, adjustments);
+
+        // 对外/入库为百分比
+        entity.setCompletionRate(completionRatio.multiply(BigDecimal.valueOf(100)).setScale(4, RoundingMode.HALF_UP));
+        entity.setDeviationRate(deviationRatio.multiply(BigDecimal.valueOf(100)).setScale(4, RoundingMode.HALF_UP));
         entity.setPriceAdjustmentCoefficient(priceAdjustmentCoefficient);
 
-        BigDecimal actualResponseKw = gridKw;
         BigDecimal subsidyAmount = null;
         if (entity.getSubsidyPrice() != null && durationMin > 0) {
             BigDecimal hours = BigDecimal.valueOf(durationMin).divide(MINUTES_PER_HOUR, 6, RoundingMode.HALF_UP);
-            subsidyAmount = actualResponseKw
+            subsidyAmount = platformKw
                     .multiply(hours)
                     .multiply(priceAdjustmentCoefficient)
                     .multiply(entity.getSubsidyPrice())

+ 54 - 11
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppResponseDeviationPriceServiceImpl.java

@@ -16,7 +16,6 @@ import com.usky.vpp.util.VppResponseCoefficientHelper;
 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.math.BigDecimal;
 import java.util.HashMap;
@@ -34,7 +33,7 @@ public class VppResponseDeviationPriceServiceImpl implements VppResponseDeviatio
     private VppResponseDeviationPriceMapper adjustmentMapper;
 
     @Override
-    public CommonPage<ResponseDeviationPriceAdjustmentVO> page(String responseCoefficient,
+    public CommonPage<ResponseDeviationPriceAdjustmentVO> page(BigDecimal completionRate,
                                                                 Integer current,
                                                                 Integer size) {
         Map<String, Object> pageParams = new HashMap<>(2);
@@ -47,9 +46,8 @@ public class VppResponseDeviationPriceServiceImpl implements VppResponseDeviatio
                         .eq(VppResponseDeviationPrice::getDeleteFlag, VppAuditHelper.NOT_DELETED)
                         .orderByAsc(VppResponseDeviationPrice::getId);
         applyTenantFilter(wrapper);
-        if (StringUtils.hasText(responseCoefficient)) {
-            wrapper.like(VppResponseDeviationPrice::getResponseCoefficient,
-                    responseCoefficient.trim());
+        if (completionRate != null) {
+            applyCompletionRateFilter(wrapper, completionRate);
         }
 
         Page<VppResponseDeviationPrice> result = adjustmentMapper.selectPage(page, wrapper);
@@ -111,7 +109,11 @@ public class VppResponseDeviationPriceServiceImpl implements VppResponseDeviatio
 
     private void fillFromRequest(VppResponseDeviationPrice adjustment,
                                  ResponseDeviationPriceAdjustmentRequest request) {
-        adjustment.setResponseCoefficient(request.getResponseCoefficient().trim().replace(',', ','));
+        VppResponseCoefficientHelper.Range range = toRange(request);
+        adjustment.setCoeffLower(range.getLower());
+        adjustment.setCoeffUpper(range.getUpper());
+        adjustment.setLowerInclusive(range.isLowerInclusive());
+        adjustment.setUpperInclusive(range.isUpperInclusive());
         adjustment.setPriceAdjustmentCoefficient(request.getPriceAdjustmentCoefficient());
         adjustment.setRemark(request.getRemark());
     }
@@ -120,7 +122,7 @@ public class VppResponseDeviationPriceServiceImpl implements VppResponseDeviatio
         if (request == null) {
             throw new BusinessException("请求体不能为空");
         }
-        VppResponseCoefficientHelper.parse(request.getResponseCoefficient());
+        VppResponseCoefficientHelper.Range requestRange = toRange(request);
         if (request.getPriceAdjustmentCoefficient() == null) {
             throw new BusinessException("价格调整系数不能为空");
         }
@@ -135,17 +137,58 @@ public class VppResponseDeviationPriceServiceImpl implements VppResponseDeviatio
                         .eq(tenantId != null, VppResponseDeviationPrice::getTenantId, tenantId)
                         .ne(excludeId != null, VppResponseDeviationPrice::getId, excludeId));
         for (VppResponseDeviationPrice item : existing) {
-            if (VppResponseCoefficientHelper.overlaps(request.getResponseCoefficient(),
-                    item.getResponseCoefficient())) {
-                throw new BusinessException("响应量系数区间与已有配置重叠:" + item.getResponseCoefficient());
+            VppResponseCoefficientHelper.Range existingRange = toRange(item);
+            if (VppResponseCoefficientHelper.overlaps(requestRange, existingRange)) {
+                throw new BusinessException("响应量系数区间与已有配置重叠:"
+                        + VppResponseCoefficientHelper.format(existingRange));
             }
         }
     }
 
+    private VppResponseCoefficientHelper.Range toRange(ResponseDeviationPriceAdjustmentRequest request) {
+        return VppResponseCoefficientHelper.of(
+                request.getCoeffLower(),
+                request.getCoeffUpper(),
+                request.getLowerInclusive(),
+                request.getUpperInclusive());
+    }
+
+    private VppResponseCoefficientHelper.Range toRange(VppResponseDeviationPrice adjustment) {
+        return VppResponseCoefficientHelper.of(
+                adjustment.getCoeffLower(),
+                adjustment.getCoeffUpper(),
+                adjustment.getLowerInclusive(),
+                adjustment.getUpperInclusive());
+    }
+
+    /**
+     * SQL 层按完成率匹配结构化区间。
+     */
+    private void applyCompletionRateFilter(LambdaQueryWrapper<VppResponseDeviationPrice> wrapper,
+                                           BigDecimal completionRate) {
+        wrapper.apply(
+                "(coeff_lower IS NULL OR "
+                        + "(lower_inclusive = 1 AND {0} >= coeff_lower) OR "
+                        + "(lower_inclusive = 0 AND {0} > coeff_lower))"
+                        + " AND "
+                        + "(coeff_upper IS NULL OR "
+                        + "(upper_inclusive = 1 AND {0} <= coeff_upper) OR "
+                        + "(upper_inclusive = 0 AND {0} < coeff_upper))",
+                completionRate);
+    }
+
     private ResponseDeviationPriceAdjustmentVO toVo(VppResponseDeviationPrice adjustment) {
         ResponseDeviationPriceAdjustmentVO vo = new ResponseDeviationPriceAdjustmentVO();
         vo.setId(adjustment.getId());
-        vo.setResponseCoefficient(adjustment.getResponseCoefficient());
+        vo.setCoeffLower(adjustment.getCoeffLower());
+        vo.setCoeffUpper(adjustment.getCoeffUpper());
+        vo.setLowerInclusive(Boolean.TRUE.equals(adjustment.getLowerInclusive()));
+        vo.setUpperInclusive(Boolean.TRUE.equals(adjustment.getUpperInclusive()));
+        vo.setResponseCoefficientLabel(VppResponseCoefficientHelper.format(
+                adjustment.getCoeffLower(),
+                adjustment.getCoeffUpper(),
+                adjustment.getLowerInclusive(),
+                adjustment.getUpperInclusive()));
         vo.setPriceAdjustmentCoefficient(adjustment.getPriceAdjustmentCoefficient());
         vo.setRemark(adjustment.getRemark());
         vo.setCreateTime(adjustment.getCreateTime());

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

@@ -0,0 +1,19 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 补贴金额预测试算请求。
+ * <p>前端仅传邀约编号与电网实际响应容量,其余计算所需数据由服务端按邀约/事件查询。</p>
+ */
+@Data
+public class DrSubsidyPredictionCalculateRequest {
+
+    /** 邀约编号(vpp_dr_invitation.invitation_no) */
+    private String invitationNo;
+
+    /** 实际响应容量-电网(kW) */
+    private BigDecimal actualResponseGridKw;
+}

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

@@ -6,7 +6,8 @@ import java.math.BigDecimal;
 import java.time.LocalDateTime;
 
 /**
- * 补贴金额预测 请求VO(新增/编辑)
+ * 补贴金额预测 请求VO(新增/编辑)。
+ * <p>新增时由前端携带试算结果直接入库,服务端不再重算。</p>
  */
 @Data
 public class DrSubsidyPredictionRequest {
@@ -45,4 +46,19 @@ public class DrSubsidyPredictionRequest {
      * 分成比例(0~1,如 0.8 表示 80%)
      */
     private BigDecimal shareRatio;
+
+    /** 响应时长(分钟,试算结果) */
+    private Integer responseDurationMin;
+
+    /** 偏差率(百分比)= (电网−平台)/申报出清 ×100 */
+    private BigDecimal deviationRate;
+
+    /** 完成率(百分比)= 平台实际响应量/申报出清 ×100 */
+    private BigDecimal completionRate;
+
+    /** 价格调整系数(试算结果) */
+    private BigDecimal priceAdjustmentCoefficient;
+
+    /** 预估补贴金额(试算结果) */
+    private BigDecimal estimatedSubsidyAmount;
 }

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

@@ -55,10 +55,10 @@ public class DrSubsidyPredictionVO {
     /** 按完成率匹配得到的价格调整系数。 */
     private BigDecimal priceAdjustmentCoefficient;
 
-    /** 完成率(0~1) */
+    /** 完成率(百分比)= 平台实际响应量 / 申报出清量 × 100(≥0,正常 0~100,超发可 &gt;100) */
     private BigDecimal completionRate;
 
-    /** 偏差率(0~1) */
+    /** 偏差率(百分比)= (电网实际响应量 − 平台实际响应量) / 申报出清量 × 100(可正可负) */
     private BigDecimal deviationRate;
 
     /** 预估补贴金额(元) */

+ 11 - 2
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/ResponseDeviationPriceAdjustmentRequest.java

@@ -10,8 +10,17 @@ import java.math.BigDecimal;
 @Data
 public class ResponseDeviationPriceAdjustmentRequest {
 
-    /** 响应量系数区间,例如 <0.6、[0.6, 0.8)、(1.2, 1.4]、>1.4。 */
-    private String responseCoefficient;
+    /** 响应量系数下限,null 表示无下界(如 &lt;0.6)。 */
+    private BigDecimal coeffLower;
+
+    /** 响应量系数上限,null 表示无上界(如 &gt;1.4)。 */
+    private BigDecimal coeffUpper;
+
+    /** 下限是否闭区间;无下界时可忽略。 */
+    private Boolean lowerInclusive;
+
+    /** 上限是否闭区间;无上界时可忽略。 */
+    private Boolean upperInclusive;
 
     /** 价格调整系数。 */
     private BigDecimal priceAdjustmentCoefficient;

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

@@ -13,8 +13,20 @@ import java.time.LocalDateTime;
 public class ResponseDeviationPriceAdjustmentVO {
 
     private Long id;
-    private String responseCoefficient;
+
+    private BigDecimal coeffLower;
+
+    private BigDecimal coeffUpper;
+
+    private Boolean lowerInclusive;
+
+    private Boolean upperInclusive;
+
+    /** 只读展示串,如 [0.6,0.8)、&lt;0.6、&gt;1.4。 */
+    private String responseCoefficientLabel;
+
     private BigDecimal priceAdjustmentCoefficient;
+
     private String remark;
 
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")

+ 75 - 11
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppResponseCoefficientHelper.java

@@ -6,13 +6,56 @@ import org.springframework.util.StringUtils;
 import java.math.BigDecimal;
 
 /**
- * 解析并匹配响应量系数区间。
+ * 响应量系数区间:结构化边界校验、匹配、重叠判断与展示串拼装
  */
 public final class VppResponseCoefficientHelper {
 
     private VppResponseCoefficientHelper() {
     }
 
+    /**
+     * 由结构化边界构建区间并校验。
+     */
+    public static Range of(BigDecimal lower, BigDecimal upper,
+                           Boolean lowerInclusive, Boolean upperInclusive) {
+        if (lower == null && upper == null) {
+            throw new BusinessException("响应量系数下限与上限不能同时为空");
+        }
+        boolean lowerInc = lower != null && Boolean.TRUE.equals(lowerInclusive);
+        boolean upperInc = upper != null && Boolean.TRUE.equals(upperInclusive);
+        if (lower != null && upper != null && lower.compareTo(upper) >= 0) {
+            throw new BusinessException("响应量系数区间下限必须小于上限");
+        }
+        return new Range(lower, upper, lowerInc, upperInc);
+    }
+
+    public static boolean matches(BigDecimal lower, BigDecimal upper,
+                                  Boolean lowerInclusive, Boolean upperInclusive,
+                                  BigDecimal coefficient) {
+        return coefficient != null && of(lower, upper, lowerInclusive, upperInclusive).contains(coefficient);
+    }
+
+    public static boolean matches(Range range, BigDecimal coefficient) {
+        return coefficient != null && range != null && range.contains(coefficient);
+    }
+
+    public static boolean overlaps(Range left, Range right) {
+        return left != null && right != null && left.overlaps(right);
+    }
+
+    /** 拼装展示串,如 [0.6,0.8)、&lt;0.6、&gt;1.4。 */
+    public static String format(BigDecimal lower, BigDecimal upper,
+                                Boolean lowerInclusive, Boolean upperInclusive) {
+        return of(lower, upper, lowerInclusive, upperInclusive).format();
+    }
+
+    public static String format(Range range) {
+        return range == null ? null : range.format();
+    }
+
+    /**
+     * 解析历史字符串区间,仅供数据迁移使用。
+     */
     public static Range parse(String expression) {
         if (!StringUtils.hasText(expression)) {
             throw new BusinessException("响应量系数不能为空");
@@ -50,14 +93,6 @@ public final class VppResponseCoefficientHelper {
         throw new BusinessException("响应量系数格式无效,示例:[0.6,0.8)、<0.6、>1.4");
     }
 
-    public static boolean matches(String expression, BigDecimal coefficient) {
-        return coefficient != null && parse(expression).contains(coefficient);
-    }
-
-    public static boolean overlaps(String left, String right) {
-        return parse(left).overlaps(parse(right));
-    }
-
     public static final class Range {
         private final BigDecimal lower;
         private final BigDecimal upper;
@@ -71,7 +106,23 @@ public final class VppResponseCoefficientHelper {
             this.upperInclusive = upperInclusive;
         }
 
-        private boolean contains(BigDecimal value) {
+        public BigDecimal getLower() {
+            return lower;
+        }
+
+        public BigDecimal getUpper() {
+            return upper;
+        }
+
+        public boolean isLowerInclusive() {
+            return lowerInclusive;
+        }
+
+        public boolean isUpperInclusive() {
+            return upperInclusive;
+        }
+
+        public boolean contains(BigDecimal value) {
             if (lower != null) {
                 int comparison = value.compareTo(lower);
                 if (comparison < 0 || (comparison == 0 && !lowerInclusive)) {
@@ -87,11 +138,24 @@ public final class VppResponseCoefficientHelper {
             return true;
         }
 
-        private boolean overlaps(Range other) {
+        public boolean overlaps(Range other) {
             return upperAllows(other.lower, other.lowerInclusive)
                     && other.upperAllows(lower, lowerInclusive);
         }
 
+        public String format() {
+            if (lower == null && upper != null) {
+                return (upperInclusive ? "<=" : "<") + upper.stripTrailingZeros().toPlainString();
+            }
+            if (upper == null && lower != null) {
+                return (lowerInclusive ? ">=" : ">") + lower.stripTrailingZeros().toPlainString();
+            }
+            String left = lowerInclusive ? "[" : "(";
+            String right = upperInclusive ? "]" : ")";
+            return left + lower.stripTrailingZeros().toPlainString()
+                    + "," + upper.stripTrailingZeros().toPlainString() + right;
+        }
+
         private boolean upperAllows(BigDecimal otherLower, boolean otherLowerInclusive) {
             if (upper == null || otherLower == null) {
                 return true;

+ 15 - 11
service-vpp/service-vpp-biz/src/main/resources/sql/vpp_schema.sql

@@ -676,16 +676,16 @@ CREATE TABLE `vpp_dr_subsidy_prediction` (
     `event_start_time` DATETIME(3) NULL COMMENT '事件开始时间',
     `event_end_time` DATETIME(3) NULL COMMENT '事件结束时间',
     `response_duration_min` INT NULL COMMENT '响应时长 分钟',
-    `subsidy_price` DECIMAL(12,4) NULL COMMENT '补贴单价',
-    `estimated_capacity_kw` DECIMAL(12,4) NULL COMMENT '预估响应容量 kW',
-    `cleared_capacity_kw` DECIMAL(12,4) NULL COMMENT '出清容量 kW',
-    `actual_response_grid_kw` DECIMAL(12,4) NULL COMMENT '电网实际响应容量 kW',
-    `actual_response_platform_kw` DECIMAL(12,4) NULL COMMENT '平台实际响应容量 kW',
-    `declared_cleared_kw` DECIMAL(12,4) NULL COMMENT '申报出清容量 kW',
+    `subsidy_price` DECIMAL(5,2) NULL COMMENT '补贴单价',
+    `estimated_capacity_kw` DECIMAL(12,2) NULL COMMENT '预估响应容量 kW',
+    `cleared_capacity_kw` DECIMAL(12,2) NULL COMMENT '出清容量 kW',
+    `actual_response_grid_kw` DECIMAL(12,2) NULL COMMENT '电网实际响应容量 kW',
+    `actual_response_platform_kw` DECIMAL(12,2) NULL COMMENT '平台实际响应容量 kW',
+    `declared_cleared_kw` DECIMAL(12,2) NULL COMMENT '申报出清容量 kW',
     `share_ratio` DECIMAL(6,4) NULL COMMENT '分成比例',
     `price_adjustment_coefficient` DECIMAL(12,4) NULL COMMENT '价格调整系数',
-    `completion_rate` DECIMAL(12,4) NULL COMMENT '完成率',
-    `deviation_rate` DECIMAL(12,4) NULL COMMENT '偏差率',
+    `completion_rate` DECIMAL(12,4) NULL COMMENT '完成率(百分比)=平台实际响应量/申报出清量×100',
+    `deviation_rate` DECIMAL(12,4) NULL COMMENT '偏差率(百分比)=(电网实际响应量-平台实际响应量)/申报出清量×100',
     `estimated_subsidy_amount` DECIMAL(18,4) NULL COMMENT '预估补贴金额',
     `tenant_id` INT NULL COMMENT '租户ID',
     `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -697,8 +697,11 @@ CREATE TABLE `vpp_dr_subsidy_prediction` (
 
 CREATE TABLE `vpp_response_deviation_price` (
     `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
-    `response_coefficient` VARCHAR(32) NOT NULL COMMENT '响应量系数区间',
-    `price_adjustment_coefficient` DECIMAL(12,4) NOT NULL COMMENT '价格调整系数',
+    `coeff_lower` DECIMAL(5,2) NULL COMMENT '响应量系数下限,NULL表示无下界',
+    `coeff_upper` DECIMAL(5,2) NULL COMMENT '响应量系数上限,NULL表示无上界',
+    `lower_inclusive` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '下限是否闭区间 1闭 0开',
+    `upper_inclusive` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '上限是否闭区间 1闭 0开',
+    `price_adjustment_coefficient` DECIMAL(5,2) NOT NULL COMMENT '价格调整系数',
     `remark` VARCHAR(500) NULL COMMENT '备注',
     `tenant_id` INT NULL COMMENT '租户ID',
     `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -708,7 +711,8 @@ CREATE TABLE `vpp_response_deviation_price` (
     `delete_flag` INT(1) NOT NULL DEFAULT 0 COMMENT '删除标识 0未删除 1已删除',
     `deleted_at` DATETIME(3) NULL COMMENT '软删除时间',
     PRIMARY KEY (`id`),
-    UNIQUE KEY `uk_response_deviation_price` (`tenant_id`, `response_coefficient`),
+    KEY `idx_response_deviation_price_tenant` (`tenant_id`, `delete_flag`),
+    KEY `idx_response_deviation_price_bounds` (`coeff_lower`, `coeff_upper`),
     KEY `idx_response_deviation_price_deleted` (`delete_flag`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='响应量偏差价格调整表';