浏览代码

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

fuyuchuan 1 天之前
父节点
当前提交
572deac6c8

+ 13 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/constant/VppDrEventType.java

@@ -0,0 +1,13 @@
+package com.usky.vpp.constant;
+
+/**
+ * 需求响应事件类型:1削峰 2填谷
+ */
+public final class VppDrEventType {
+
+    public static final int PEAK = 1;
+    public static final int VALLEY = 2;
+
+    private VppDrEventType() {
+    }
+}

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

@@ -40,7 +40,7 @@ public class DrMonitorController {
 
     /**
      * 响应记录分页(口径与 summary 一致,按邀约展开)
-     * <p>在事件字段基础上补充客户、站点;目标容量取申报出清容量,出清容量取实际响应容量。</p>
+     * <p>在事件字段基础上补充客户、站点;预估响应容量/实际出清取自邀约,并计算补贴金额预测。</p>
      */
     @GetMapping("/records")
     public ApiResult<CommonPage<DrMonitorRecordVO>> records(@RequestParam("startDate") String startDate,

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

@@ -83,11 +83,11 @@ public class VppDrSubsidyPrediction implements Serializable {
     @TableField("share_ratio")
     private BigDecimal shareRatio;
 
-    /** 按完成率(比例)命中响应量偏差价格调整表得到的价格调整系数。 */
+    /** 按响应量系数(比例)命中响应量偏差价格调整表得到的价格调整系数。 */
     @TableField("price_adjustment_coefficient")
     private BigDecimal priceAdjustmentCoefficient;
 
-    /** 完成率(百分比)= 实际响应容量(电网) / 实际出清 × 100 */
+    /** 完成率(百分比)= 实际出清 / 预估响应容量 × 100 */
     @TableField("completion_rate")
     private BigDecimal completionRate;
 
@@ -95,6 +95,7 @@ public class VppDrSubsidyPrediction implements Serializable {
     @TableField("deviation_rate")
     private BigDecimal deviationRate;
 
+    /** 预估补贴金额(元)= 预估响应容量 × 时长(h) × 竞价价格 × 价格调整系数 */
     @TableField("estimated_subsidy_amount")
     private BigDecimal estimatedSubsidyAmount;
 

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

@@ -19,7 +19,7 @@ public interface VppDrSubsidyPredictionService {
 
     /**
      * 试算补贴预测(不落库)。
-     * <p>入参为邀约编号 + 电网实际响应容量;时长/平台响应量/申报出清量/补贴标准等从邀约与事件查询后计算。</p>
+     * <p>入参为邀约编号 + 电网实际响应容量;补贴金额按统一计算器口径计算。</p>
      */
     DrSubsidyPredictionVO calculate(DrSubsidyPredictionCalculateRequest request);
 

+ 187 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppSubsidyPredictionCalculator.java

@@ -0,0 +1,187 @@
+package com.usky.vpp.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.usky.vpp.constant.VppDrEventType;
+import com.usky.vpp.domain.VppBiddingConfig;
+import com.usky.vpp.domain.VppDrEvent;
+import com.usky.vpp.domain.VppDrInvitation;
+import com.usky.vpp.domain.VppResponseDeviationPrice;
+import com.usky.vpp.mapper.VppBiddingConfigMapper;
+import com.usky.vpp.mapper.VppResponseDeviationPriceMapper;
+import com.usky.vpp.service.vo.SubsidyPredictionCalcResult;
+import com.usky.vpp.util.VppAuditHelper;
+import com.usky.vpp.util.VppResponseCoefficientHelper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 补贴金额预测统一计算器。
+ * <p>完成率/响应量系数 = 实际出清 / 预估响应容量(与偏差配置同一量纲,如 [0.6,0.8)),
+ * 命中区间后取价格调整系数。补贴金额 = 预估响应容量 × 响应时长(h)
+ * × 下浮系数对应竞价价格(削峰=最高价,填谷=最低价)× 价格调整系数。</p>
+ */
+@Component
+public class VppSubsidyPredictionCalculator {
+
+    private static final int SCALE = 4;
+    private static final int RATIO_SCALE = 6;
+    private static final BigDecimal MINUTES_PER_HOUR = BigDecimal.valueOf(60);
+    private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
+
+    @Autowired
+    private VppResponseDeviationPriceMapper priceAdjustmentMapper;
+    @Autowired
+    private VppBiddingConfigMapper biddingConfigMapper;
+
+    public List<VppResponseDeviationPrice> loadAdjustments(Integer tenantId) {
+        return priceAdjustmentMapper.selectList(new LambdaQueryWrapper<VppResponseDeviationPrice>()
+                .eq(VppResponseDeviationPrice::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .eq(tenantId != null, VppResponseDeviationPrice::getTenantId, tenantId)
+                .orderByAsc(VppResponseDeviationPrice::getId));
+    }
+
+    public List<VppBiddingConfig> loadBiddingConfigs(Integer tenantId) {
+        return biddingConfigMapper.selectList(new LambdaQueryWrapper<VppBiddingConfig>()
+                .eq(VppBiddingConfig::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .eq(tenantId != null, VppBiddingConfig::getTenantId, tenantId));
+    }
+
+    public SubsidyPredictionCalcResult calculate(VppDrInvitation invitation, VppDrEvent event) {
+        Integer tenantId = resolveTenantId(invitation, event);
+        return calculate(invitation, event, loadAdjustments(tenantId), loadBiddingConfigs(tenantId));
+    }
+
+    /**
+     * 按邀约 + 事件计算补贴预测。边界情况(预估为空/≤0、出清为空、区间未命中、竞价未唯一匹配等)金额记 0,不抛业务异常。
+     */
+    public SubsidyPredictionCalcResult calculate(VppDrInvitation invitation, VppDrEvent event,
+                                                 List<VppResponseDeviationPrice> adjustments,
+                                                 List<VppBiddingConfig> biddingConfigs) {
+        SubsidyPredictionCalcResult result = new SubsidyPredictionCalcResult();
+        LocalDateTime start = event == null ? null : event.getStartTime();
+        LocalDateTime end = event == null ? null : event.getEndTime();
+        int durationMin = resolveDurationMin(start, end);
+        result.setResponseDurationMin(durationMin);
+
+        BigDecimal estimatedKw = invitation == null ? null : invitation.getEstimatedResponseCapacityKw();
+        BigDecimal clearedKw = invitation == null ? null : invitation.getActualClearedCapacityKw();
+        // 预估为空或 ≤0 时无法计算完成率(分母),金额记 0
+        if (estimatedKw == null || estimatedKw.compareTo(BigDecimal.ZERO) <= 0) {
+            return result;
+        }
+        if (clearedKw == null) {
+            return result;
+        }
+
+        BigDecimal estimated = estimatedKw;
+        // 响应量系数/完成率口径:实际出清 ÷ 预估响应容量(如 60/100 = 0.6)
+        BigDecimal coefficient = clearedKw.divide(estimated, RATIO_SCALE, RoundingMode.HALF_UP);
+        result.setResponseCoefficient(coefficient);
+        result.setCompletionRate(coefficient.multiply(HUNDRED).setScale(SCALE, RoundingMode.HALF_UP));
+        result.setDeviationRate(BigDecimal.ONE.subtract(coefficient)
+                .multiply(HUNDRED).setScale(SCALE, RoundingMode.HALF_UP));
+
+        BigDecimal priceAdjustment = matchPriceAdjustment(coefficient, adjustments);
+        result.setPriceAdjustmentCoefficient(priceAdjustment);
+        if (priceAdjustment == null) {
+            return result;
+        }
+        if (priceAdjustment.compareTo(BigDecimal.ZERO) == 0) {
+            result.setEstimatedSubsidyAmount(BigDecimal.ZERO.setScale(SCALE, RoundingMode.HALF_UP));
+            return result;
+        }
+
+        BigDecimal subsidyPrice = resolveSubsidyPrice(event, invitation, biddingConfigs);
+        result.setSubsidyPrice(subsidyPrice);
+        if (subsidyPrice == null || durationMin <= 0) {
+            return result;
+        }
+
+        BigDecimal hours = BigDecimal.valueOf(durationMin).divide(MINUTES_PER_HOUR, RATIO_SCALE, RoundingMode.HALF_UP);
+        // 金额 = 预估响应容量 × 时长(h) × 竞价价格 × 价格调整系数(不得用完成率替代价格调整系数)
+        result.setEstimatedSubsidyAmount(estimated
+                .multiply(hours)
+                .multiply(subsidyPrice)
+                .multiply(priceAdjustment)
+                .setScale(SCALE, RoundingMode.HALF_UP));
+        return result;
+    }
+
+    private static int resolveDurationMin(LocalDateTime start, LocalDateTime end) {
+        if (start == null || end == null || !end.isAfter(start)) {
+            return 0;
+        }
+        return (int) Duration.between(start, end).toMinutes();
+    }
+
+    private static BigDecimal matchPriceAdjustment(BigDecimal coefficient,
+                                                   List<VppResponseDeviationPrice> adjustments) {
+        if (coefficient == null || adjustments == null) {
+            return null;
+        }
+        for (VppResponseDeviationPrice adjustment : adjustments) {
+            if (VppResponseCoefficientHelper.matches(
+                    adjustment.getCoeffLower(),
+                    adjustment.getCoeffUpper(),
+                    adjustment.getLowerInclusive(),
+                    adjustment.getUpperInclusive(),
+                    coefficient)) {
+                return adjustment.getPriceAdjustmentCoefficient();
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 用事件下浮系数匹配唯一竞价配置,再按削峰/填谷取最高价/最低价作为补贴标准。
+     */
+    private static BigDecimal resolveSubsidyPrice(VppDrEvent event, VppDrInvitation invitation,
+                                                  List<VppBiddingConfig> biddingConfigs) {
+        if (event == null || event.getFloatingCoefficient() == null || biddingConfigs == null) {
+            return null;
+        }
+        VppBiddingConfig matched = null;
+        for (VppBiddingConfig config : biddingConfigs) {
+            if (config.getTypicalCoefficient() == null) {
+                continue;
+            }
+            if (config.getTypicalCoefficient().compareTo(event.getFloatingCoefficient()) != 0) {
+                continue;
+            }
+            if (matched != null) {
+                return null;
+            }
+            matched = config;
+        }
+        if (matched == null) {
+            return null;
+        }
+        Integer eventType = event.getEventType();
+        if (eventType == null && invitation != null) {
+            eventType = invitation.getTransactionType();
+        }
+        if (eventType == null) {
+            return null;
+        }
+        if (eventType == VppDrEventType.PEAK) {
+            return matched.getMaxPrice();
+        }
+        if (eventType == VppDrEventType.VALLEY) {
+            return matched.getMinPrice();
+        }
+        return null;
+    }
+
+    private static Integer resolveTenantId(VppDrInvitation invitation, VppDrEvent event) {
+        if (event != null && event.getTenantId() != null) {
+            return event.getTenantId();
+        }
+        return invitation == null ? null : invitation.getTenantId();
+    }
+}

+ 18 - 32
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDrMonitorServiceImpl.java

@@ -6,26 +6,28 @@ 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.constant.VppDrEventStatus;
+import com.usky.vpp.domain.VppBiddingConfig;
 import com.usky.vpp.domain.VppCustomer;
 import com.usky.vpp.domain.VppDrEvent;
 import com.usky.vpp.domain.VppDrInvitation;
-import com.usky.vpp.domain.VppDrSubsidyPrediction;
 import com.usky.vpp.domain.VppResourcePoint;
+import com.usky.vpp.domain.VppResponseDeviationPrice;
 import com.usky.vpp.domain.VppSite;
 import com.usky.vpp.mapper.VppCustomerMapper;
 import com.usky.vpp.mapper.VppDrEventMapper;
 import com.usky.vpp.mapper.VppDrInvitationMapper;
-import com.usky.vpp.mapper.VppDrSubsidyPredictionMapper;
 import com.usky.vpp.mapper.VppResourcePointMapper;
 import com.usky.vpp.mapper.VppSiteMapper;
 import com.usky.vpp.service.VppBaselineService;
 import com.usky.vpp.service.VppDrMonitorService;
+import com.usky.vpp.service.VppSubsidyPredictionCalculator;
 import com.usky.vpp.service.vo.BaselinePointVO;
 import com.usky.vpp.service.vo.DrMonitorCurveVO;
 import com.usky.vpp.service.vo.DrMonitorRecordVO;
 import com.usky.vpp.service.vo.DrMonitorSiteResourceVO;
 import com.usky.vpp.service.vo.DrMonitorSummaryVO;
 import com.usky.vpp.service.vo.SiteBaselineVO;
+import com.usky.vpp.service.vo.SubsidyPredictionCalcResult;
 import com.usky.vpp.util.VppAuditHelper;
 import com.usky.vpp.util.VppPageHelper;
 import com.usky.vpp.util.VppResourceTypeHelper;
@@ -45,7 +47,6 @@ import java.time.format.DateTimeParseException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
-import java.util.HashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
@@ -83,7 +84,7 @@ public class VppDrMonitorServiceImpl implements VppDrMonitorService {
     @Autowired
     private VppBaselineService baselineService;
     @Autowired
-    private VppDrSubsidyPredictionMapper subsidyPredictionMapper;
+    private VppSubsidyPredictionCalculator subsidyPredictionCalculator;
 
     @Override
     public DrMonitorSummaryVO getSummary(String startDate, String endDate, List<Long> siteIds) {
@@ -131,8 +132,10 @@ public class VppDrMonitorServiceImpl implements VppDrMonitorService {
         Map<Long, VppDrEvent> eventMap = loadEventMapByIds(eventIds, tenantId);
         Map<Long, VppCustomer> customerMap = loadCustomerMap(invitations);
         Map<Long, VppSite> siteMap = loadSiteMap(invitations, tenantId);
-        Map<Long, BigDecimal> subsidyByEvent = loadSubsidyAmountByEvent(
-                eventIds, new LinkedHashSet<>(siteIds), tenantId);
+        List<VppResponseDeviationPrice> adjustments =
+                subsidyPredictionCalculator.loadAdjustments(tenantId);
+        List<VppBiddingConfig> biddingConfigs =
+                subsidyPredictionCalculator.loadBiddingConfigs(tenantId);
 
         List<DrMonitorRecordVO> records = invitations.stream()
                 .sorted(Comparator.comparing(
@@ -141,7 +144,7 @@ public class VppDrMonitorServiceImpl implements VppDrMonitorService {
                         Comparator.nullsLast(Comparator.reverseOrder())))
                 .map(inv -> toRecordVo(inv, eventMap.get(inv.getDrEventId()),
                         customerMap.get(inv.getCustomerId()), siteMap.get(inv.getSiteId()),
-                        subsidyByEvent.get(inv.getDrEventId())))
+                        adjustments, biddingConfigs))
                 .collect(Collectors.toList());
 
         long current = page.getCurrent();
@@ -288,27 +291,6 @@ public class VppDrMonitorServiceImpl implements VppDrMonitorService {
         return item;
     }
 
-    private Map<Long, BigDecimal> loadSubsidyAmountByEvent(Set<Long> eventIds, Set<Long> siteIds, Integer tenantId) {
-        if (CollectionUtils.isEmpty(eventIds)) {
-            return Collections.emptyMap();
-        }
-        LambdaQueryWrapper<VppDrSubsidyPrediction> wrapper = new LambdaQueryWrapper<>();
-        wrapper.in(VppDrSubsidyPrediction::getDrEventId, eventIds)
-                .eq(tenantId != null, VppDrSubsidyPrediction::getTenantId, tenantId);
-        if (!CollectionUtils.isEmpty(siteIds)) {
-            wrapper.in(VppDrSubsidyPrediction::getSiteId, siteIds);
-        }
-        List<VppDrSubsidyPrediction> list = subsidyPredictionMapper.selectList(wrapper);
-        Map<Long, BigDecimal> result = new HashMap<>();
-        for (VppDrSubsidyPrediction item : list) {
-            if (item.getDrEventId() == null || item.getEstimatedSubsidyAmount() == null) {
-                continue;
-            }
-            result.merge(item.getDrEventId(), item.getEstimatedSubsidyAmount(), BigDecimal::add);
-        }
-        return result;
-    }
-
     // ---------- loaders ----------
 
     private void requireSiteIds(List<Long> siteIds) {
@@ -388,7 +370,8 @@ public class VppDrMonitorServiceImpl implements VppDrMonitorService {
 
     private DrMonitorRecordVO toRecordVo(VppDrInvitation invitation, VppDrEvent event,
                                          VppCustomer customer, VppSite site,
-                                         BigDecimal estimatedSubsidyAmount) {
+                                         List<VppResponseDeviationPrice> adjustments,
+                                         List<VppBiddingConfig> biddingConfigs) {
         DrMonitorRecordVO vo = new DrMonitorRecordVO();
         if (event != null) {
             BeanUtils.copyProperties(event, vo);
@@ -402,11 +385,14 @@ public class VppDrMonitorServiceImpl implements VppDrMonitorService {
         if (site != null) {
             vo.setSiteName(site.getSiteName());
         }
-        vo.setTargetCapacityKw(invitation.getDeclaredCapacityKw());
-        vo.setClearedCapacityKw(invitation.getActualResponseCapacityKw());
+        vo.setEstimatedResponseCapacityKw(invitation.getEstimatedResponseCapacityKw());
+        vo.setClearedCapacityKw(invitation.getActualClearedCapacityKw());
         vo.setResponseCompletionRate(invitation.getResponseCompletionRate());
         vo.setQualified(isCompletionQualified(invitation.getResponseCompletionRate()));
-        vo.setEstimatedSubsidyAmount(estimatedSubsidyAmount);
+        SubsidyPredictionCalcResult subsidy = subsidyPredictionCalculator.calculate(
+                invitation, event, adjustments, biddingConfigs);
+        vo.setEstimatedSubsidyAmount(subsidy.getEstimatedSubsidyAmount());
+        vo.setSubsidyPrice(subsidy.getSubsidyPrice());
         return vo;
     }
 

+ 30 - 99
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDrSubsidyPredictionServiceImpl.java

@@ -5,23 +5,27 @@ 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.VppCustomer;
 import com.usky.vpp.domain.VppDrEvent;
 import com.usky.vpp.domain.VppDrInvitation;
 import com.usky.vpp.domain.VppDrSubsidyPrediction;
 import com.usky.vpp.domain.VppResponseDeviationPrice;
 import com.usky.vpp.domain.VppSite;
-import com.usky.vpp.mapper.*;
 import com.usky.vpp.mapper.VppCustomerMapper;
-import com.usky.vpp.mapper.VppResponseDeviationPriceMapper;
+import com.usky.vpp.mapper.VppDrEventMapper;
+import com.usky.vpp.mapper.VppDrInvitationMapper;
+import com.usky.vpp.mapper.VppDrSubsidyPredictionMapper;
+import com.usky.vpp.mapper.VppSiteMapper;
 import com.usky.vpp.service.VppBaselineService;
 import com.usky.vpp.service.VppDrSubsidyPredictionService;
+import com.usky.vpp.service.VppSubsidyPredictionCalculator;
 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.service.vo.SubsidyPredictionCalcResult;
 import com.usky.vpp.util.VppAuditHelper;
-import com.usky.vpp.util.VppResponseCoefficientHelper;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -32,7 +36,6 @@ import org.springframework.util.StringUtils;
 
 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;
@@ -49,7 +52,6 @@ import java.util.stream.Collectors;
 public class VppDrSubsidyPredictionServiceImpl implements VppDrSubsidyPredictionService {
 
     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
@@ -63,9 +65,9 @@ public class VppDrSubsidyPredictionServiceImpl implements VppDrSubsidyPrediction
     @Autowired
     private VppCustomerMapper customerMapper;
     @Autowired
-    private VppResponseDeviationPriceMapper priceAdjustmentMapper;
-    @Autowired
     private VppBaselineService vppBaselineService;
+    @Autowired
+    private VppSubsidyPredictionCalculator subsidyPredictionCalculator;
 
     @Override
     public CommonPage<DrSubsidyPredictionVO> page(String siteName, String customerName,
@@ -128,14 +130,11 @@ public class VppDrSubsidyPredictionServiceImpl implements VppDrSubsidyPrediction
         fillCustomerName(entity);
         entity.setEventStartTime(event.getStartTime());
         entity.setEventEndTime(event.getEndTime());
-        entity.setSubsidyPrice(event.getSubsidyPrice());
 
         BigDecimal declaredKw = invitation.getDeclaredCapacityKw() != null
                 ? invitation.getDeclaredCapacityKw()
                 : invitation.getActualClearedCapacityKw();
-        BigDecimal actualClearedKw = invitation.getActualClearedCapacityKw() != null
-                ? invitation.getActualClearedCapacityKw()
-                : declaredKw;
+        BigDecimal actualClearedKw = invitation.getActualClearedCapacityKw();
         BigDecimal platformKw = resolvePlatformActualResponseKw(
                 invitation.getSiteId(), event.getStartTime(), event.getEndTime(),
                 invitation.getEstimatedResponseCapacityKw());
@@ -143,12 +142,11 @@ public class VppDrSubsidyPredictionServiceImpl implements VppDrSubsidyPrediction
         entity.setActualResponseGridKw(request.getActualResponseGridKw());
         entity.setActualResponsePlatformKw(platformKw);
         entity.setDeclaredClearedKw(declaredKw);
-        entity.setEstimatedCapacityKw(platformKw);
+        entity.setEstimatedCapacityKw(invitation.getEstimatedResponseCapacityKw());
         entity.setClearedCapacityKw(actualClearedKw);
         entity.setTenantId(event.getTenantId() != null ? event.getTenantId() : invitation.getTenantId());
 
-        recalculate(entity, loadActivePriceAdjustments(
-                entity.getTenantId() != null ? entity.getTenantId() : SecurityUtils.getTenantId()));
+        applyCalcResult(entity, subsidyPredictionCalculator.calculate(invitation, event));
 
         Map<Long, String> invitationNoMap = Collections.singletonMap(invitation.getId(), invitation.getInvitationNo());
         return toVo(entity, invitationNoMap);
@@ -246,7 +244,7 @@ public class VppDrSubsidyPredictionServiceImpl implements VppDrSubsidyPrediction
         }
 
         VppDrEvent event = eventMapper.selectById(drEventId);
-        if (event == null) {
+        if (event == null || VppAuditHelper.isDeleted(event.getDeleteFlag())) {
             log.warn("补贴预测生成跳过:事件不存在 drEventId={}", drEventId);
             return;
         }
@@ -275,11 +273,13 @@ public class VppDrSubsidyPredictionServiceImpl implements VppDrSubsidyPrediction
                 .collect(Collectors.toSet());
         Map<Long, String> customerNameMap = loadCustomerNameMap(customerIds);
 
-        List<VppResponseDeviationPrice> adjustments = loadActivePriceAdjustments(event.getTenantId());
+        List<VppResponseDeviationPrice> adjustments = subsidyPredictionCalculator.loadAdjustments(event.getTenantId());
+        List<VppBiddingConfig> biddingConfigs =
+                subsidyPredictionCalculator.loadBiddingConfigs(event.getTenantId());
 
         for (VppDrInvitation invitation : invitations) {
             VppDrSubsidyPrediction prediction = buildPrediction(
-                    event, invitation, siteNameMap, customerNameMap, adjustments);
+                    event, invitation, siteNameMap, customerNameMap, adjustments, biddingConfigs);
 
             VppDrSubsidyPrediction existing = subsidyPredictionMapper.selectOne(
                     new LambdaQueryWrapper<VppDrSubsidyPrediction>()
@@ -303,7 +303,8 @@ public class VppDrSubsidyPredictionServiceImpl implements VppDrSubsidyPrediction
     private VppDrSubsidyPrediction buildPrediction(VppDrEvent event, VppDrInvitation invitation,
                                                     Map<Long, String> siteNameMap,
                                                     Map<Long, String> customerNameMap,
-                                                    List<VppResponseDeviationPrice> adjustments) {
+                                                    List<VppResponseDeviationPrice> adjustments,
+                                                    List<VppBiddingConfig> biddingConfigs) {
         VppDrSubsidyPrediction p = new VppDrSubsidyPrediction();
         p.setInvitationId(invitation.getId());
         p.setDrEventId(event.getId());
@@ -313,7 +314,6 @@ public class VppDrSubsidyPredictionServiceImpl implements VppDrSubsidyPrediction
         p.setCustomerName(invitation.getCustomerId() == null ? null : customerNameMap.get(invitation.getCustomerId()));
         p.setEventStartTime(event.getStartTime());
         p.setEventEndTime(event.getEndTime());
-        p.setSubsidyPrice(event.getSubsidyPrice());
 
         BigDecimal platformKw = resolvePlatformActualResponseKw(
                 invitation.getSiteId(), event.getStartTime(), event.getEndTime(),
@@ -322,17 +322,16 @@ public class VppDrSubsidyPredictionServiceImpl implements VppDrSubsidyPrediction
         BigDecimal declaredKw = invitation.getDeclaredCapacityKw() != null
                 ? invitation.getDeclaredCapacityKw()
                 : invitation.getActualClearedCapacityKw();
-        BigDecimal actualClearedKw = invitation.getActualClearedCapacityKw() != null
-                ? invitation.getActualClearedCapacityKw()
-                : declaredKw;
+        BigDecimal actualClearedKw = invitation.getActualClearedCapacityKw();
 
         p.setActualResponsePlatformKw(platformKw);
         p.setActualResponseGridKw(gridKw);
         p.setDeclaredClearedKw(declaredKw);
-        p.setEstimatedCapacityKw(platformKw);
+        p.setEstimatedCapacityKw(invitation.getEstimatedResponseCapacityKw());
         p.setClearedCapacityKw(actualClearedKw);
         p.setTenantId(event.getTenantId());
-        recalculate(p, adjustments);
+        applyCalcResult(p, subsidyPredictionCalculator.calculate(
+                invitation, event, adjustments, biddingConfigs));
         return p;
     }
 
@@ -473,29 +472,13 @@ public class VppDrSubsidyPredictionServiceImpl implements VppDrSubsidyPrediction
                 .collect(Collectors.toMap(VppCustomer::getId, VppCustomer::getCustomerName, (a, b) -> a));
     }
 
-    private List<VppResponseDeviationPrice> loadActivePriceAdjustments(Integer tenantId) {
-        return priceAdjustmentMapper.selectList(
-                new LambdaQueryWrapper<VppResponseDeviationPrice>()
-                        .eq(VppResponseDeviationPrice::getDeleteFlag, VppAuditHelper.NOT_DELETED)
-                        .eq(tenantId != null, VppResponseDeviationPrice::getTenantId, tenantId)
-                        .orderByAsc(VppResponseDeviationPrice::getId));
-    }
-
-    private BigDecimal resolvePriceAdjustmentCoefficient(BigDecimal completionRate,
-                                                         List<VppResponseDeviationPrice> adjustments) {
-        if (adjustments != null) {
-            for (VppResponseDeviationPrice adjustment : adjustments) {
-                if (VppResponseCoefficientHelper.matches(
-                        adjustment.getCoeffLower(),
-                        adjustment.getCoeffUpper(),
-                        adjustment.getLowerInclusive(),
-                        adjustment.getUpperInclusive(),
-                        completionRate)) {
-                    return adjustment.getPriceAdjustmentCoefficient();
-                }
-            }
-        }
-        throw new BusinessException("未匹配到响应量偏差价格调整配置!");
+    private void applyCalcResult(VppDrSubsidyPrediction entity, SubsidyPredictionCalcResult calc) {
+        entity.setResponseDurationMin(calc.getResponseDurationMin());
+        entity.setPriceAdjustmentCoefficient(calc.getPriceAdjustmentCoefficient());
+        entity.setCompletionRate(calc.getCompletionRate());
+        entity.setDeviationRate(calc.getDeviationRate());
+        entity.setSubsidyPrice(calc.getSubsidyPrice());
+        entity.setEstimatedSubsidyAmount(calc.getEstimatedSubsidyAmount());
     }
 
     private Map<Long, String> loadInvitationNoMap(List<VppDrSubsidyPrediction> predictions) {
@@ -511,58 +494,6 @@ public class VppDrSubsidyPredictionServiceImpl implements VppDrSubsidyPrediction
                         (left, right) -> left));
     }
 
-    /**
-     * 自动计算:响应时长、偏差率、完成率、补贴金额
-     * <ul>
-     *   <li>完成率(%)= 实际响应容量(电网) / 实际出清 × 100(实际出清为 null 或 ≤0 时不计算)</li>
-     *   <li>偏差率(%)= (1 − 完成率) × 100</li>
-     *   <li>价格调整系数:按完成率比例匹配响应量偏差价格调整配置</li>
-     *   <li>补贴金额 = 电网实际响应量 × 响应时长(h) × 价格调整系数 × 补贴标准</li>
-     * </ul>
-     */
-    private void recalculate(VppDrSubsidyPrediction entity,
-                             List<VppResponseDeviationPrice> adjustments) {
-        int durationMin = 0;
-        if (entity.getEventStartTime() != null && entity.getEventEndTime() != null
-                && entity.getEventEndTime().isAfter(entity.getEventStartTime())) {
-            durationMin = (int) Duration.between(entity.getEventStartTime(), entity.getEventEndTime()).toMinutes();
-        }
-        entity.setResponseDurationMin(durationMin);
-
-        BigDecimal gridKw = entity.getActualResponseGridKw();
-        BigDecimal clearedKw = entity.getClearedCapacityKw() != null
-                ? entity.getClearedCapacityKw()
-                : entity.getDeclaredClearedKw();
-
-        if (clearedKw == null || clearedKw.compareTo(BigDecimal.ZERO) <= 0 || gridKw == null) {
-            entity.setDeviationRate(null);
-            entity.setCompletionRate(null);
-            entity.setPriceAdjustmentCoefficient(null);
-            entity.setEstimatedSubsidyAmount(null);
-            return;
-        }
-
-        BigDecimal completionRatio = gridKw.divide(clearedKw, 6, RoundingMode.HALF_UP);
-        BigDecimal deviationRatio = BigDecimal.ONE.subtract(completionRatio);
-        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 subsidyAmount = null;
-        if (entity.getSubsidyPrice() != null && durationMin > 0) {
-            BigDecimal hours = BigDecimal.valueOf(durationMin).divide(MINUTES_PER_HOUR, 6, RoundingMode.HALF_UP);
-            subsidyAmount = gridKw
-                    .multiply(hours)
-                    .multiply(priceAdjustmentCoefficient)
-                    .multiply(entity.getSubsidyPrice())
-                    .setScale(4, RoundingMode.HALF_UP);
-        }
-        entity.setEstimatedSubsidyAmount(subsidyAmount);
-    }
-
     /** 分成比例统一为 0~1;若传入值 &gt; 1 则按百分数换算 */
     private BigDecimal normalizeShareRatio(BigDecimal raw) {
         if (raw == null) {

+ 4 - 5
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DrMonitorRecordVO.java

@@ -23,7 +23,6 @@ public class DrMonitorRecordVO {
     private Long siteId;
     private String siteName;
     private Integer responseType;
-    private Integer eventType;
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     private LocalDateTime startTime;
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@@ -35,10 +34,10 @@ public class DrMonitorRecordVO {
     /** 邀约范围 */
     private String inviteScope;
 
-    /** 目标容量:邀约申报出清容量 declared_capacity_kw */
-    private BigDecimal targetCapacityKw;
+    /** 预估响应容量:邀约 estimated_response_capacity_kw */
+    private BigDecimal estimatedResponseCapacityKw;
 
-    /** 出清容量:邀约实际响应容量 actual_response_capacity_kw */
+    /** 实际出清:邀约 actual_cleared_capacity_kw */
     private BigDecimal clearedCapacityKw;
     private String remark;
     private String attachmentUrl;
@@ -60,6 +59,6 @@ public class DrMonitorRecordVO {
     private BigDecimal responseCompletionRate;
     /** 是否达标:完成率 >= 100% */
     private Boolean qualified;
-    /** 补贴金额预测) */
+    /** 补贴金额预测(元= 预估响应容量 × 时长(h) × 竞价价格 × 价格调整系数 */
     private BigDecimal estimatedSubsidyAmount;
 }

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

@@ -40,10 +40,10 @@ public class DrSubsidyPredictionVO {
     /** 补贴标准(元/kWh) */
     private BigDecimal subsidyPrice;
 
-    /** 预估响应容量(kW,兼容) */
+    /** 预估响应容量(kW,取自邀约) */
     private BigDecimal estimatedCapacityKw;
 
-    /** 实际出清(kW,兼容) */
+    /** 实际出清(kW,取自邀约) */
     private BigDecimal clearedCapacityKw;
 
     /** 实际响应量-电网(kW) */
@@ -58,16 +58,16 @@ public class DrSubsidyPredictionVO {
     /** 分成比例(0~1) */
     private BigDecimal shareRatio;
 
-    /** 按完成率匹配得到的价格调整系数。 */
+    /** 用完成率对应的响应量系数命中偏差配置后的价格调整系数。 */
     private BigDecimal priceAdjustmentCoefficient;
 
-    /** 完成率(百分比)= 实际响应容量(电网) / 实际出清 × 100 */
+    /** 完成率(百分比)= 实际出清 / 预估响应容量 × 100 */
     private BigDecimal completionRate;
 
     /** 偏差率(百分比)= (1 − 完成率) × 100 */
     private BigDecimal deviationRate;
 
-    /** 预估补贴金额(元) */
+    /** 预估补贴金额(元)= 预估响应容量 × 时长(h) × 竞价价格 × 价格调整系数 */
     private BigDecimal estimatedSubsidyAmount;
 
     private LocalDateTime createTime;

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

@@ -0,0 +1,27 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 补贴金额预测统一计算结果。
+ */
+@Data
+public class SubsidyPredictionCalcResult {
+
+    /** 响应时长(分钟) */
+    private Integer responseDurationMin = 0;
+    /** 响应量系数(实际出清 / 预估响应容量,比例) */
+    private BigDecimal responseCoefficient;
+    /** 完成率(百分比)= 响应量系数 × 100 */
+    private BigDecimal completionRate;
+    /** 偏差率(百分比)= (1 − 响应量系数) × 100 */
+    private BigDecimal deviationRate;
+    /** 价格调整系数;未命中区间时为空 */
+    private BigDecimal priceAdjustmentCoefficient;
+    /** 竞价价格(元/kWh):下浮系数命中唯一配置后,削峰取最高价、填谷取最低价 */
+    private BigDecimal subsidyPrice;
+    /** 补贴金额预测(元)= 预估响应容量 × 时长(h) × 竞价价格 × 价格调整系数;边界情况为 0 */
+    private BigDecimal estimatedSubsidyAmount = BigDecimal.ZERO;
+}