From 40c34fa49cf1ee5e927f20a33ab181df8974ea86 Mon Sep 17 00:00:00 2001 From: jsowell <123@jsowell.com> Date: Wed, 12 Aug 2026 16:17:31 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=97=A0=E4=BA=A4?= =?UTF-8?q?=E6=98=93=E8=AE=B0=E5=BD=95=E8=87=AA=E5=8A=A8=E7=BB=93=E7=AE=97?= =?UTF-8?q?=E5=B9=B6=E5=A2=9E=E5=8A=A0=E5=88=86=E6=97=B6=E7=94=B5=E9=87=8F?= =?UTF-8?q?=E8=A7=82=E6=B5=8B=E5=AF=B9=E6=AF=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用 settleOrder 主路径纠正状态/锁/实时数据取值等问题,补充 mock 单测与 sys_job SQL;交易记录到达时按实时曲线估算尖峰平谷仅打日志对比,不参与结算。 --- .../auto_settle_no_transaction_sys_job.sql | 60 +++ .../common/config/AutoSettleConfig.java | 159 +----- .../TransactionRecordsRequestHandler.java | 11 + jsowell-pile/pom.xml | 7 + .../jsowell/pile/config/AutoSettleConfig.java | 48 +- .../pile/mapper/OrderBasicInfoMapper.java | 13 - .../service/TouElectricityObserveService.java | 120 +++++ .../impl/OrderBasicInfoServiceImpl.java | 506 +++++++++--------- .../pile/util/SettlementDataConverter.java | 235 ++++---- .../util/TouElectricityEstimateResult.java | 67 +++ .../pile/util/TouElectricityEstimator.java | 204 +++++++ .../mapper/pile/OrderBasicInfoMapper.xml | 47 +- .../pile/config/AutoSettleConfigTest.java | 6 +- ...tleOrdersWithoutTransactionRecordTest.java | 293 ++++++++++ .../util/SettlementDataConverterTest.java | 60 +++ .../util/TouElectricityEstimatorTest.java | 62 +++ 16 files changed, 1278 insertions(+), 620 deletions(-) create mode 100644 docs/sql/auto_settle_no_transaction_sys_job.sql create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/service/TouElectricityObserveService.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/util/TouElectricityEstimateResult.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/util/TouElectricityEstimator.java create mode 100644 jsowell-pile/src/test/java/com/jsowell/pile/service/impl/AutoSettleOrdersWithoutTransactionRecordTest.java create mode 100644 jsowell-pile/src/test/java/com/jsowell/pile/util/SettlementDataConverterTest.java create mode 100644 jsowell-pile/src/test/java/com/jsowell/pile/util/TouElectricityEstimatorTest.java diff --git a/docs/sql/auto_settle_no_transaction_sys_job.sql b/docs/sql/auto_settle_no_transaction_sys_job.sql new file mode 100644 index 000000000..a54da3066 --- /dev/null +++ b/docs/sql/auto_settle_no_transaction_sys_job.sql @@ -0,0 +1,60 @@ +-- ============================================================ +-- 无交易记录自动结算 - 定时任务初始化 +-- 表:sys_job(若依 Quartz) +-- 调用目标:jsowellTask.autoSettleOrdersWithoutTransactionRecord() +-- +-- 说明: +-- 1) 默认 status=1(暂停),灰度验证后再改为 0(正常) +-- 2) concurrent=1(禁止并发) +-- 3) 可重复执行:已存在同名调用目标则跳过插入 +-- 4) 纯 SQL 插入后,若应用已在运行,建议: +-- - 重启应用,或 +-- - 到「定时任务」页面对该任务执行一次「修改保存/恢复」以同步到调度器 +-- 5) 业务总开关仍由 auto-settle.enabled 控制,任务跑起来但 enabled=false 只会打“功能未启用”日志 +-- ============================================================ + +-- 可选:执行前先查是否已存在 +-- SELECT job_id, job_name, invoke_target, cron_expression, concurrent, status +-- FROM sys_job +-- WHERE invoke_target = 'jsowellTask.autoSettleOrdersWithoutTransactionRecord()'; + +INSERT INTO sys_job ( + job_name, + job_group, + invoke_target, + cron_expression, + misfire_policy, + concurrent, + status, + create_by, + create_time, + remark +) +SELECT + '无交易记录自动结算', + 'DEFAULT', + 'jsowellTask.autoSettleOrdersWithoutTransactionRecord()', + '0 0/10 * * * ?', -- 每 10 分钟 + '3', -- 错过不立即补跑(等下一周期) + '1', -- 禁止并发 + '1', -- 暂停(灰度验证后再启用) + 'admin', + NOW(), + '扫描停止超阈值且无交易记录的待结算订单,按最后实时数据自动结算。业务开关:auto-settle.enabled;建议先灰度站点。' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 + FROM sys_job + WHERE invoke_target = 'jsowellTask.autoSettleOrdersWithoutTransactionRecord()' +); + +-- 启用任务(灰度/配置验证通过后执行) +-- UPDATE sys_job +-- SET status = '0', +-- update_by = 'admin', +-- update_time = NOW() +-- WHERE invoke_target = 'jsowellTask.autoSettleOrdersWithoutTransactionRecord()'; + +-- 回滚/删除(如需) +-- DELETE FROM sys_job +-- WHERE invoke_target = 'jsowellTask.autoSettleOrdersWithoutTransactionRecord()'; diff --git a/jsowell-common/src/main/java/com/jsowell/common/config/AutoSettleConfig.java b/jsowell-common/src/main/java/com/jsowell/common/config/AutoSettleConfig.java index d6db4614d..509106752 100644 --- a/jsowell-common/src/main/java/com/jsowell/common/config/AutoSettleConfig.java +++ b/jsowell-common/src/main/java/com/jsowell/common/config/AutoSettleConfig.java @@ -1,163 +1,8 @@ package com.jsowell.common.config; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -import java.util.ArrayList; -import java.util.List; - /** - * 无交易记录自动结算配置 - * - * @author jsowell + * @deprecated 已迁移至 {@code com.jsowell.pile.config.AutoSettleConfig},保留空类避免历史引用编译失败。 */ -@Component -@ConfigurationProperties(prefix = "auto-settle") +@Deprecated public class AutoSettleConfig { - - /** - * 总开关,默认关闭 - */ - private boolean enabled = false; - - /** - * 停止充电超时阈值(分钟),超过此时间未收到交易记录则触发自动结算 - */ - private int timeoutMinutes = 10; - - /** - * 定时任务扫描周期(Cron表达式),每10分钟扫描一次 - */ - private String interval = "0 */10 * * * ?"; - - /** - * 灰度站点白名单(站点ID列表),仅对这些站点启用自动结算 - */ - private List grayscaleStationIds = new ArrayList<>(); - - /** - * 金额异常阈值倍数,chargingAmount > payAmount * 此值时告警跳过 - */ - private double amountThresholdRatio = 1.5; - - /** - * 单次扫描订单数量上限,防止一次查询过多订单 - */ - private int batchSize = 100; - - /** - * 实时数据新鲜度阈值(分钟),数据超过此时间未更新视为桩假在线 - */ - private int dataFreshnessMinutes = 30; - - /** - * 告警开关 - */ - private boolean alertEnabled = true; - - /** - * Redis分布式锁超时时间(秒) - */ - private int lockTimeoutSeconds = 60; - - /** - * 失败是否重试,建议false等待下一轮扫描 - */ - private boolean retryOnFailure = false; - - public boolean isEnabled() { - return enabled; - } - - public void setEnabled(boolean enabled) { - this.enabled = enabled; - } - - public int getTimeoutMinutes() { - return timeoutMinutes; - } - - public void setTimeoutMinutes(int timeoutMinutes) { - this.timeoutMinutes = timeoutMinutes; - } - - public String getInterval() { - return interval; - } - - public void setInterval(String interval) { - this.interval = interval; - } - - public List getGrayscaleStationIds() { - return grayscaleStationIds; - } - - public void setGrayscaleStationIds(List grayscaleStationIds) { - this.grayscaleStationIds = grayscaleStationIds; - } - - public double getAmountThresholdRatio() { - return amountThresholdRatio; - } - - public void setAmountThresholdRatio(double amountThresholdRatio) { - this.amountThresholdRatio = amountThresholdRatio; - } - - public int getBatchSize() { - return batchSize; - } - - public void setBatchSize(int batchSize) { - this.batchSize = batchSize; - } - - public int getDataFreshnessMinutes() { - return dataFreshnessMinutes; - } - - public void setDataFreshnessMinutes(int dataFreshnessMinutes) { - this.dataFreshnessMinutes = dataFreshnessMinutes; - } - - public boolean isAlertEnabled() { - return alertEnabled; - } - - public void setAlertEnabled(boolean alertEnabled) { - this.alertEnabled = alertEnabled; - } - - public int getLockTimeoutSeconds() { - return lockTimeoutSeconds; - } - - public void setLockTimeoutSeconds(int lockTimeoutSeconds) { - this.lockTimeoutSeconds = lockTimeoutSeconds; - } - - public boolean isRetryOnFailure() { - return retryOnFailure; - } - - public void setRetryOnFailure(boolean retryOnFailure) { - this.retryOnFailure = retryOnFailure; - } - - @Override - public String toString() { - return "AutoSettleConfig{" + - "enabled=" + enabled + - ", timeoutMinutes=" + timeoutMinutes + - ", interval='" + interval + '\'' + - ", grayscaleStationIds=" + grayscaleStationIds + - ", amountThresholdRatio=" + amountThresholdRatio + - ", batchSize=" + batchSize + - ", dataFreshnessMinutes=" + dataFreshnessMinutes + - ", alertEnabled=" + alertEnabled + - ", lockTimeoutSeconds=" + lockTimeoutSeconds + - ", retryOnFailure=" + retryOnFailure + - '}'; - } } diff --git a/jsowell-netty/src/main/java/com/jsowell/netty/handler/yunkuaichong/TransactionRecordsRequestHandler.java b/jsowell-netty/src/main/java/com/jsowell/netty/handler/yunkuaichong/TransactionRecordsRequestHandler.java index 0a87a1276..d2fbe05f3 100644 --- a/jsowell-netty/src/main/java/com/jsowell/netty/handler/yunkuaichong/TransactionRecordsRequestHandler.java +++ b/jsowell-netty/src/main/java/com/jsowell/netty/handler/yunkuaichong/TransactionRecordsRequestHandler.java @@ -24,6 +24,7 @@ import com.jsowell.pile.domain.OrderBasicInfo; import com.jsowell.pile.domain.PileBasicInfo; import com.jsowell.pile.dto.SavePileMsgDTO; import com.jsowell.pile.service.*; +import com.jsowell.pile.service.TouElectricityObserveService; import com.jsowell.pile.service.programlogic.AbstractProgramLogic; import com.jsowell.pile.service.programlogic.ProgramLogicFactory; import com.jsowell.thirdparty.common.CommonService; @@ -76,6 +77,9 @@ public class TransactionRecordsRequestHandler extends AbstractYkcHandler { @Autowired private OrderBasicInfoService orderBasicInfoService; + @Autowired + private TouElectricityObserveService touElectricityObserveService; + @Autowired private PileMerchantInfoService pileMerchantInfoService; @@ -685,6 +689,13 @@ public class TransactionRecordsRequestHandler extends AbstractYkcHandler { log.error("结算订单发生异常 orderCode:{}", orderBasicInfo.getOrderCode(), e); } + // 观测:平台用实时曲线估算尖峰平谷,与桩端交易记录对比(不影响结算) + try { + touElectricityObserveService.logCompareWithTransactionRecord(orderBasicInfo, data); + } catch (Exception ignore) { + log.warn("分时电量估算对比调用失败(不影响结算), orderCode:{}", orderBasicInfo.getOrderCode()); + } + OrderBasicInfo finalOrderBasicInfo = orderBasicInfo; // TODO 异步推送第三方平台订单信息 diff --git a/jsowell-pile/pom.xml b/jsowell-pile/pom.xml index e010872e5..3dce13f9b 100644 --- a/jsowell-pile/pom.xml +++ b/jsowell-pile/pom.xml @@ -61,6 +61,13 @@ junit + + org.mockito + mockito-core + test + + + org.springframework spring-test diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/config/AutoSettleConfig.java b/jsowell-pile/src/main/java/com/jsowell/pile/config/AutoSettleConfig.java index 55a480874..a4dcfbbea 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/config/AutoSettleConfig.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/config/AutoSettleConfig.java @@ -4,12 +4,13 @@ import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; +import java.util.ArrayList; import java.util.List; /** - * 无交易记录自动结算配置类 + * 无交易记录自动结算配置 * - * @author jsowell + * 配置前缀:auto-settle */ @Data @Component @@ -17,78 +18,65 @@ import java.util.List; public class AutoSettleConfig { /** - * 功能总开关 - * 默认:false(关闭) + * 功能总开关,默认关闭 */ private Boolean enabled = false; /** * 停止超时阈值(分钟) - * 订单停止超过该时间才触发自动结算 - * 默认:10分钟 */ private Integer timeoutMinutes = 10; /** - * 定时任务执行间隔(cron表达式) - * 默认:每10分钟执行一次 + * 定时任务 cron(仅作文档/配置同步,实际调度以 sys_job 为准) */ private String interval = "0 */10 * * * ?"; /** - * 灰度站点ID列表 - * 为空表示全量上线 + * 灰度站点 ID 列表;空表示全量 */ - private List grayscaleStationIds; + private List grayscaleStationIds = new ArrayList<>(); /** * 单次扫描订单数量上限 - * 防止一次性处理过多订单 - * 默认:100 */ private Integer batchSize = 100; /** - * 异常金额阈值比例 - * chargingAmount > payAmount * 该比例时告警跳过 - * 默认:1.5倍 + * 异常金额阈值倍数:chargingAmount > payAmount * ratio 时跳过 */ private Double amountThresholdRatio = 1.5; /** - * 实时数据新鲜度阈值(分钟) - * 最后一条实时数据距当前时间超过该阈值则认为桩"假在线" - * 默认:30分钟 + * 实时数据新鲜度阈值(分钟)。仅兜底路径(无 chargeEndTime)使用 */ private Integer dataFreshnessMinutes = 30; /** * 告警开关 - * 异常情况是否发送告警 - * 默认:true */ private Boolean alertEnabled = true; /** - * Redis锁超时时间(秒) - * 防止分布式锁死锁 - * 默认:300秒(5分钟) + * Redis 分布式锁超时时间(秒) */ - private Long lockTimeout = 300L; + private Integer lockTimeoutSeconds = 60; /** - * 是否启用灰度(根据灰度站点列表判断) + * 单笔失败是否在本轮立即重试(建议 false,等下一轮扫描) */ + private Boolean retryOnFailure = false; + public boolean isGrayscaleEnabled() { return grayscaleStationIds != null && !grayscaleStationIds.isEmpty(); } - /** - * 判断站点是否在灰度范围内 - */ public boolean isStationInGrayscale(Long stationId) { if (!isGrayscaleEnabled()) { - return true; // 未启用灰度,所有站点都算在范围内 + return true; + } + if (stationId == null) { + return false; } return grayscaleStationIds.contains(stationId); } diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/mapper/OrderBasicInfoMapper.java b/jsowell-pile/src/main/java/com/jsowell/pile/mapper/OrderBasicInfoMapper.java index 8f6472638..52bf9a67b 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/mapper/OrderBasicInfoMapper.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/mapper/OrderBasicInfoMapper.java @@ -49,19 +49,6 @@ public interface OrderBasicInfoMapper { @Param("stationIds") List stationIds, @Param("limit") int limit); - /** - * 使用乐观锁更新订单(用于自动结算) - * - * @param order 订单信息 - * @param orderId 订单ID - * @param expectedStatus 期望的订单状态 - * @param expectedSettlementTime 期望的结算时间(null表示未结算) - * @return 更新行数 - */ - int updateOrderWithOptimisticLock(@Param("order") OrderBasicInfo order, - @Param("orderId") Integer orderId, - @Param("expectedStatus") String expectedStatus, - @Param("expectedSettlementTime") java.util.Date expectedSettlementTime); /** * insert record to table diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/service/TouElectricityObserveService.java b/jsowell-pile/src/main/java/com/jsowell/pile/service/TouElectricityObserveService.java new file mode 100644 index 000000000..32417fe37 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/service/TouElectricityObserveService.java @@ -0,0 +1,120 @@ +package com.jsowell.pile.service; + +import com.jsowell.common.core.domain.ykc.RealTimeMonitorData; +import com.jsowell.common.core.domain.ykc.TransactionRecordsData; +import com.jsowell.common.util.StringUtils; +import com.jsowell.pile.domain.OrderBasicInfo; +import com.jsowell.pile.util.TouElectricityEstimateResult; +import com.jsowell.pile.util.TouElectricityEstimator; +import com.jsowell.pile.vo.web.BillingTemplateVO; +import org.apache.commons.collections4.CollectionUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.List; + +/** + * 平台分时电量估算观测:只打日志,不参与结算/退款/改单。 + */ +@Service +public class TouElectricityObserveService { + + private static final Logger log = LoggerFactory.getLogger(TouElectricityObserveService.class); + + @Autowired + private OrderBasicInfoService orderBasicInfoService; + + @Autowired + private PileBillingTemplateService pileBillingTemplateService; + + /** + * 收到交易记录后调用:用实时曲线估算尖峰平谷,与桩端交易记录对比打一行日志。 + * 任意异常都吞掉,绝不影响结算主流程。 + */ + public void logCompareWithTransactionRecord(OrderBasicInfo order, TransactionRecordsData pileData) { + try { + if (order == null || pileData == null || StringUtils.isBlank(order.getTransactionCode())) { + return; + } + List samples = orderBasicInfoService.getChargingRealTimeData(order.getTransactionCode()); + BillingTemplateVO billingTemplate = null; + if (StringUtils.isNotBlank(order.getPileSn())) { + try { + billingTemplate = pileBillingTemplateService.selectBillingTemplateDetailByPileSn(order.getPileSn()); + } catch (Exception e) { + log.debug("分时估算取计费模板失败, orderCode:{}", order.getOrderCode(), e); + } + } + + TouElectricityEstimateResult est = TouElectricityEstimator.estimate(samples, billingTemplate); + + BigDecimal pileSharp = dec(pileData.getSharpUsedElectricity()); + BigDecimal pilePeak = dec(pileData.getPeakUsedElectricity()); + BigDecimal pileFlat = dec(pileData.getFlatUsedElectricity()); + BigDecimal pileValley = dec(pileData.getValleyUsedElectricity()); + BigDecimal pileTotal = dec(pileData.getTotalElectricity()); + if (pileTotal.compareTo(BigDecimal.ZERO) == 0) { + pileTotal = pileSharp.add(pilePeak).add(pileFlat).add(pileValley); + } + + BigDecimal platSharp = scale(est.getSharp()); + BigDecimal platPeak = scale(est.getPeak()); + BigDecimal platFlat = scale(est.getFlat()); + BigDecimal platValley = scale(est.getValley()); + BigDecimal platTotalDelta = scale(est.getTotalFromDeltas()); + BigDecimal platTotalLast = scale(est.getTotalFromLastSample()); + + // 一行结构化日志,便于 grep / 后期导入分析 + log.info("【分时电量估算对比】orderCode={}, transactionCode={}, pileSn={}, " + + "pile(sharp/peak/flat/valley/total)={}/{}/{}/{}/{}, " + + "platform(sharp/peak/flat/valley/deltaSum/lastTotal)={}/{}/{}/{}/{}/{}, " + + "diff(sharp/peak/flat/valley/deltaSumVsPileTotal)={}/{}/{}/{}/{}, " + + "samples={}, posDelta={}, nonPosDelta={}, unmatchedPeriod={}, note={}, realtimeEmpty={}", + order.getOrderCode(), + order.getTransactionCode(), + order.getPileSn(), + pileSharp, pilePeak, pileFlat, pileValley, pileTotal, + platSharp, platPeak, platFlat, platValley, platTotalDelta, platTotalLast, + diff(platSharp, pileSharp), + diff(platPeak, pilePeak), + diff(platFlat, pileFlat), + diff(platValley, pileValley), + diff(platTotalDelta, pileTotal), + est.getSampleCount(), + est.getPositiveDeltaCount(), + est.getNegativeOrZeroDeltaCount(), + est.getUnmatchedPeriodCount(), + est.getNote(), + CollectionUtils.isEmpty(samples)); + } catch (Exception e) { + log.warn("【分时电量估算对比】观测日志失败(不影响结算), orderCode:{}, err:{}", + order == null ? null : order.getOrderCode(), e.getMessage()); + } + } + + private static BigDecimal dec(String raw) { + if (StringUtils.isBlank(raw)) { + return BigDecimal.ZERO.setScale(4, RoundingMode.HALF_UP); + } + try { + return new BigDecimal(raw.trim()).setScale(4, RoundingMode.HALF_UP); + } catch (Exception e) { + return BigDecimal.ZERO.setScale(4, RoundingMode.HALF_UP); + } + } + + private static BigDecimal scale(BigDecimal v) { + if (v == null) { + return BigDecimal.ZERO.setScale(4, RoundingMode.HALF_UP); + } + return v.setScale(4, RoundingMode.HALF_UP); + } + + private static BigDecimal diff(BigDecimal platform, BigDecimal pile) { + return scale(platform).subtract(scale(pile)); + } +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/OrderBasicInfoServiceImpl.java b/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/OrderBasicInfoServiceImpl.java index 45dd649a4..953e8937c 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/OrderBasicInfoServiceImpl.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/OrderBasicInfoServiceImpl.java @@ -20,6 +20,7 @@ import com.jsowell.adapay.response.*; import com.jsowell.adapay.service.AdapayService; import com.jsowell.adapay.vo.OrderSplitResult; import com.jsowell.adapay.vo.PaymentInfo; +import com.jsowell.common.YouDianUtils; import com.jsowell.common.constant.CacheConstants; import com.jsowell.common.constant.Constants; import com.jsowell.common.core.domain.vo.AuthorizedDeptVO; @@ -56,6 +57,7 @@ import com.jsowell.pile.transaction.dto.OrderTransactionDTO; import com.jsowell.pile.transaction.service.TransactionService; import com.jsowell.pile.util.ChargeAmountUtils; import com.jsowell.pile.util.MerchantUtils; +import com.jsowell.pile.util.SettlementDataConverter; import com.jsowell.pile.util.UserUtils; import com.jsowell.pile.vo.OrderInfoDetailVO; import com.jsowell.pile.vo.OrderPayRecordVO; @@ -7441,27 +7443,26 @@ public class OrderBasicInfoServiceImpl implements OrderBasicInfoService { @Override public void autoSettleOrdersWithoutTransactionRecord() { - // 1. 检查功能总开关 - if (!autoSettleConfig.getEnabled()) { + if (!Boolean.TRUE.equals(autoSettleConfig.getEnabled())) { logger.debug("【无交易记录自动结算】功能未启用,跳过执行"); return; } - logger.info("【无交易记录自动结算】开始执行,超时阈值:{}分钟,批次大小:{}", - autoSettleConfig.getTimeoutMinutes(), autoSettleConfig.getBatchSize()); + int timeoutMinutes = autoSettleConfig.getTimeoutMinutes() == null ? 10 : autoSettleConfig.getTimeoutMinutes(); + int batchSize = autoSettleConfig.getBatchSize() == null ? 100 : autoSettleConfig.getBatchSize(); + int lockTimeoutSeconds = autoSettleConfig.getLockTimeoutSeconds() == null ? 60 : autoSettleConfig.getLockTimeoutSeconds(); + int dataFreshnessMinutes = autoSettleConfig.getDataFreshnessMinutes() == null ? 30 : autoSettleConfig.getDataFreshnessMinutes(); + double amountThresholdRatio = autoSettleConfig.getAmountThresholdRatio() == null ? 1.5D : autoSettleConfig.getAmountThresholdRatio(); - // 2. 计算截止时间 - LocalDateTime cutoffTime = LocalDateTime.now().minusMinutes(autoSettleConfig.getTimeoutMinutes()); + logger.info("【无交易记录自动结算】开始执行,超时阈值:{}分钟,批次大小:{}", timeoutMinutes, batchSize); - // 3. 获取灰度站点列表(若启用灰度) - List grayscaleStationIds = autoSettleConfig.isGrayscaleEnabled() ? - autoSettleConfig.getGrayscaleStationIds() : null; + LocalDateTime cutoffTime = LocalDateTime.now().minusMinutes(timeoutMinutes); + List grayscaleStationIds = autoSettleConfig.isGrayscaleEnabled() + ? autoSettleConfig.getGrayscaleStationIds() : null; - // 4. 查询待结算订单 List pendingOrders = orderBasicInfoMapper.selectPendingAutoSettleOrders( - cutoffTime, grayscaleStationIds, autoSettleConfig.getBatchSize()); - - if (pendingOrders == null || pendingOrders.isEmpty()) { + cutoffTime, grayscaleStationIds, batchSize); + if (CollectionUtils.isEmpty(pendingOrders)) { logger.info("【无交易记录自动结算】本次扫描未发现符合条件的订单"); return; } @@ -7472,258 +7473,179 @@ public class OrderBasicInfoServiceImpl implements OrderBasicInfoService { int skipCount = 0; int failCount = 0; - // 5. 逐单处理 for (OrderBasicInfo order : pendingOrders) { - String lockKey = "settle_order_" + order.getId(); + String orderCode = order.getOrderCode(); + String transactionCode = order.getTransactionCode(); + if (StringUtils.isBlank(transactionCode)) { + logger.warn("【无交易记录自动结算】订单 {} 缺少交易流水号,跳过", orderCode); + skipCount++; + continue; + } + + // 与交易记录结算共用同一把锁,避免并发重复结算 + String lockKey = "settle_order_" + transactionCode; + String requestId = IdUtils.fastUUID(); boolean lockAcquired = false; try { - // 5.1 获取分布式锁 - redisCache.setCacheObject(lockKey, "1", - autoSettleConfig.getLockTimeout().intValue(), TimeUnit.SECONDS); - - // 尝试获取锁(检查是否成功设置) - String lockValue = redisCache.getCacheObject(lockKey); - lockAcquired = "1".equals(lockValue); - + Boolean locked = redisCache.setnx(lockKey, requestId, lockTimeoutSeconds); + lockAcquired = Boolean.TRUE.equals(locked); if (!lockAcquired) { - logger.warn("【无交易记录自动结算】订单 {} 正在被其他线程处理,跳过", order.getOrderCode()); + logger.warn("【无交易记录自动结算】订单 {} 正在被其他线程处理,跳过", orderCode); skipCount++; continue; } - // 5.2 重新查询订单最新状态(防止并发问题 + 乐观锁) OrderBasicInfo latestOrder = orderBasicInfoMapper.selectOrderBasicInfoById(Long.valueOf(order.getId())); - if (latestOrder == null || !"3".equals(latestOrder.getOrderStatus()) || latestOrder.getSettlementTime() != null) { - logger.warn("【无交易记录自动结算】订单 {} 状态已变更或已结算,跳过", order.getOrderCode()); + if (latestOrder == null + || !StringUtils.equals(OrderStatusEnum.STAY_SETTLEMENT.getValue(), latestOrder.getOrderStatus()) + || latestOrder.getSettlementTime() != null) { + logger.warn("【无交易记录自动结算】订单 {} 状态已变更或已结算,跳过", orderCode); skipCount++; continue; } - - // 5.3 检查灰度范围 - Long stationIdLong = latestOrder.getStationId() != null ? Long.parseLong(latestOrder.getStationId()) : null; - if (stationIdLong != null && !autoSettleConfig.isStationInGrayscale(stationIdLong)) { - logger.debug("【无交易记录自动结算】订单 {} 所属站点不在灰度范围内,跳过", order.getOrderCode()); + if (StringUtils.isBlank(latestOrder.getTransactionCode())) { + logger.warn("【无交易记录自动结算】订单 {} 最新数据缺少交易流水号,跳过", orderCode); skipCount++; continue; } + transactionCode = latestOrder.getTransactionCode(); - // 5.4 检查充电桩状态(从 Redis 获取实时状态) - String pileStatusKey = "pile_status_" + latestOrder.getPileSn(); - Object pileStatusObj = redisCache.getCacheObject(pileStatusKey); - if (pileStatusObj != null && "0".equals(pileStatusObj.toString())) { - logger.warn("【无交易记录自动结算】订单 {} 对应充电桩 {} 当前离线,跳过", - order.getOrderCode(), latestOrder.getPileSn()); - skipCount++; - continue; - } - - // 5.5 获取实时数据(OrderMonitorData,不是RealTimeMonitorData) - List realTimeDataList = getChargingRealTimeData(latestOrder.getOrderCode()); - if (realTimeDataList == null || realTimeDataList.isEmpty()) { - logger.warn("【无交易记录自动结算】订单 {} 未找到实时数据,跳过", order.getOrderCode()); - skipCount++; - continue; - } - - // 取最后一条实时数据 - RealTimeMonitorData lastRealTimeData = realTimeDataList.get(realTimeDataList.size() - 1); - - // 5.6 检查数据新鲜度 - 暂时跳过,因为RealTimeMonitorData没有createTime字段 - // 使用dateTime字段判断 - if (lastRealTimeData.getDateTime() != null) { + Long stationIdLong = null; + if (StringUtils.isNotBlank(latestOrder.getStationId())) { try { - LocalDateTime dataTime = LocalDateTime.parse(lastRealTimeData.getDateTime(), - java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); - long minutesSinceLastData = java.time.Duration.between(dataTime, LocalDateTime.now()).toMinutes(); - if (minutesSinceLastData > autoSettleConfig.getDataFreshnessMinutes()) { - logger.warn("【无交易记录自动结算】订单 {} 实时数据不新鲜({}分钟前),疑似桩假在线,跳过", - order.getOrderCode(), minutesSinceLastData); - skipCount++; - continue; - } - } catch (Exception e) { - logger.warn("【无交易记录自动结算】订单 {} 解析实时数据时间失败,跳过", order.getOrderCode(), e); + stationIdLong = Long.parseLong(latestOrder.getStationId()); + } catch (NumberFormatException e) { + logger.warn("【无交易记录自动结算】订单 {} 站点ID格式错误:{}", orderCode, latestOrder.getStationId()); + } + } + if (!autoSettleConfig.isStationInGrayscale(stationIdLong)) { + logger.debug("【无交易记录自动结算】订单 {} 所属站点不在灰度范围内,跳过", orderCode); + skipCount++; + continue; + } + + if (YouDianUtils.isEBikePileSn(latestOrder.getPileSn())) { + logger.warn("【无交易记录自动结算】订单 {} 为电单车订单,本期不自动结算,跳过", orderCode); + skipCount++; + continue; + } + + if (!isConnectorOnlineForAutoSettle(latestOrder)) { + logger.warn("【无交易记录自动结算】订单 {} 对应枪口离线,跳过", orderCode); + skipCount++; + continue; + } + + List realTimeDataList = getChargingRealTimeData(transactionCode); + if (CollectionUtils.isEmpty(realTimeDataList)) { + logger.warn("【无交易记录自动结算】订单 {} 未找到实时数据,跳过", orderCode); + skipCount++; + continue; + } + // Redis 路径通常倒序;DB 回源可能正序。统一按 dateTime 取最新一条 + RealTimeMonitorData lastRealTimeData = selectLatestRealTimeData(realTimeDataList); + + LocalDateTime latestDataTime = parseRealTimeDateTime(lastRealTimeData.getDateTime()); + if (!isStoppedLongEnoughForAutoSettle(latestOrder, latestDataTime, timeoutMinutes)) { + logger.debug("【无交易记录自动结算】订单 {} 停止未超过超时阈值,跳过", orderCode); + skipCount++; + continue; + } + + // 无 chargeEndTime 的兜底路径:数据过旧视为假在线,跳过 + if (latestOrder.getChargeEndTime() == null) { + if (latestDataTime == null) { + logger.warn("【无交易记录自动结算】订单 {} 无结束时间且实时数据时间无法解析,跳过", orderCode); + skipCount++; + continue; + } + long minutesSinceLastData = java.time.Duration.between(latestDataTime, LocalDateTime.now()).toMinutes(); + if (minutesSinceLastData > dataFreshnessMinutes) { + logger.warn("【无交易记录自动结算】订单 {} 实时数据不新鲜({}分钟前),疑似桩假在线,跳过", + orderCode, minutesSinceLastData); skipCount++; continue; } } - // 5.7 构造结算数据(从实时数据转换为交易记录格式) - TransactionRecordsData settlementData = new TransactionRecordsData(); - settlementData.setStartTime(com.jsowell.common.util.DateUtils.parseDateToStr( - com.jsowell.common.util.DateUtils.YYYY_MM_DD_HH_MM_SS, - latestOrder.getChargeStartTime())); - settlementData.setEndTime(com.jsowell.common.util.DateUtils.parseDateToStr( - com.jsowell.common.util.DateUtils.YYYY_MM_DD_HH_MM_SS, - latestOrder.getChargeEndTime())); - - // 从实时数据中提取充电信息 - String chargingDegree = lastRealTimeData.getChargingDegree(); - String lossDegree = lastRealTimeData.getLossDegree(); - String chargingAmount = lastRealTimeData.getChargingAmount(); - - // 数据校验:检查充电电量是否为空或0 - if (chargingDegree == null || chargingDegree.trim().isEmpty()) { - logger.warn("【无交易记录自动结算】订单 {} 充电电量为空,跳过", order.getOrderCode()); + if (!SettlementDataConverter.isValidForSettlement(lastRealTimeData)) { + alertAutoSettle("数据异常", String.format( + "订单号:%s,站点ID:%s,桩:%s,degree:%s,amount:%s", + orderCode, latestOrder.getStationId(), latestOrder.getPileSn(), + lastRealTimeData.getChargingDegree(), lastRealTimeData.getChargingAmount())); skipCount++; continue; } - try { - BigDecimal chargingDegreeDecimal = new BigDecimal(chargingDegree); - if (chargingDegreeDecimal.compareTo(BigDecimal.ZERO) <= 0) { - logger.warn("【无交易记录自动结算】订单 {} 充电电量为0或负数({}),跳过", - order.getOrderCode(), chargingDegree); - skipCount++; - continue; - } - } catch (NumberFormatException e) { - logger.error("【无交易记录自动结算】订单 {} 充电电量格式错误({}),跳过", - order.getOrderCode(), chargingDegree, e); - skipCount++; - continue; - } - - // 数据校验:检查充电金额格式 - if (chargingAmount == null || chargingAmount.trim().isEmpty()) { - logger.warn("【无交易记录自动结算】订单 {} 充电金额为空,跳过", order.getOrderCode()); - skipCount++; - continue; - } - - try { - new BigDecimal(chargingAmount); - } catch (NumberFormatException e) { - logger.error("【无交易记录自动结算】订单 {} 充电金额格式错误({}),跳过", - order.getOrderCode(), chargingAmount, e); - skipCount++; - continue; - } - - // 设置总电量和消费金额 - settlementData.setTotalElectricity(chargingDegree); - settlementData.setPlanLossTotalElectricity(lossDegree != null ? lossDegree : chargingDegree); - settlementData.setConsumptionAmount(chargingAmount); - - // 将所有电量归为平段 - settlementData.setFlatUsedElectricity(chargingDegree); - settlementData.setFlatPlanLossElectricity(lossDegree != null ? lossDegree : chargingDegree); - settlementData.setFlatAmount(chargingAmount); - - // 其他时段设为0 - settlementData.setSharpUsedElectricity("0.0000"); - settlementData.setSharpAmount("0.0000"); - settlementData.setPeakUsedElectricity("0.0000"); - settlementData.setPeakAmount("0.0000"); - settlementData.setValleyUsedElectricity("0.0000"); - settlementData.setValleyAmount("0.0000"); - - // 停止原因 - settlementData.setStopReasonMsg("无交易记录自动结算"); - - // 5.9 金额异常检测 - BigDecimal chargingAmountDecimal = new BigDecimal(chargingAmount); - if (latestOrder.getPayAmount() != null) { + BigDecimal chargingAmountDecimal = new BigDecimal(lastRealTimeData.getChargingAmount()); + if (latestOrder.getPayAmount() != null + && latestOrder.getPayAmount().compareTo(BigDecimal.ZERO) > 0) { BigDecimal threshold = latestOrder.getPayAmount() - .multiply(BigDecimal.valueOf(autoSettleConfig.getAmountThresholdRatio())); + .multiply(BigDecimal.valueOf(amountThresholdRatio)); if (chargingAmountDecimal.compareTo(threshold) > 0) { - String alertMsg = String.format( - "【无交易记录自动结算-金额异常】订单号:%s,站点ID:%s,充电桩:%s," + - "实时充电金额:%.2f 元,已支付金额:%.2f 元,阈值倍数:%.1f," + - "充电电量:%s kWh,充电时长:%d 分钟", - order.getOrderCode(), + alertAutoSettle("金额异常", String.format( + "订单号:%s,站点ID:%s,充电桩:%s,实时充电金额:%s 元,已支付金额:%s 元,阈值倍数:%s,充电电量:%s kWh", + orderCode, latestOrder.getStationId(), latestOrder.getPileSn(), - chargingAmountDecimal, - latestOrder.getPayAmount(), - autoSettleConfig.getAmountThresholdRatio(), - chargingDegree, - java.time.Duration.between( - java.time.LocalDateTime.ofInstant( - latestOrder.getChargeStartTime().toInstant(), - java.time.ZoneId.systemDefault()), - java.time.LocalDateTime.ofInstant( - latestOrder.getChargeEndTime().toInstant(), - java.time.ZoneId.systemDefault()) - ).toMinutes() - ); - logger.error(alertMsg); - if (autoSettleConfig.getAlertEnabled()) { - // TODO: 发送告警通知(对接现有告警系统) - // 可以在这里发送邮件、短信、钉钉、企业微信等告警 - } + chargingAmountDecimal.toPlainString(), + latestOrder.getPayAmount().toPlainString(), + amountThresholdRatio, + lastRealTimeData.getChargingDegree())); skipCount++; continue; } } - // 5.10 执行结算(使用乐观锁) + TransactionRecordsData settlementData = SettlementDataConverter.convertFromRealTimeData( + latestOrder, lastRealTimeData); + logger.info("【无交易记录自动结算】开始结算订单:{},充电电量:{}kWh,结算金额:{}元", - order.getOrderCode(), chargingDegree, chargingAmount); + orderCode, settlementData.getTotalElectricity(), settlementData.getConsumptionAmount()); - // 更新订单基础信息 - returnUpdateOrderBasicInfo(latestOrder, settlementData); - - // 更新订单明细 - returnUpdateOrderDetail(latestOrder, settlementData); - - // 使用 MyBatis 传统方式更新(带乐观锁条件) - OrderBasicInfo updateOrder = new OrderBasicInfo(); - updateOrder.setId(latestOrder.getId()); - updateOrder.setOrderStatus(latestOrder.getOrderStatus()); - updateOrder.setOrderAmount(latestOrder.getOrderAmount()); - updateOrder.setVirtualAmount(latestOrder.getVirtualAmount()); - updateOrder.setSettleAmount(latestOrder.getSettleAmount()); - updateOrder.setActualReceivedAmount(latestOrder.getActualReceivedAmount()); - updateOrder.setReason(latestOrder.getReason()); - updateOrder.setSettlementTime(latestOrder.getSettlementTime()); - updateOrder.setRefundAmount(latestOrder.getRefundAmount()); - - int updateCount = orderBasicInfoMapper.updateOrderWithOptimisticLock( - updateOrder, - latestOrder.getId(), - "3", // 必须是待结算状态 - null); // 结算时间必须为null - - if (updateCount == 0) { - logger.warn("【无交易记录自动结算】订单 {} 乐观锁更新失败,可能已被其他线程结算,跳过", order.getOrderCode()); - skipCount++; + String mode = pileMerchantInfoService.getDelayModeByMerchantId(latestOrder.getMerchantId()); + AbstractProgramLogic orderLogic = ProgramLogicFactory.getProgramLogic(mode); + if (orderLogic == null) { + alertAutoSettle("配置异常", String.format( + "订单号:%s 未找到结算逻辑,merchantId:%s,mode:%s", + orderCode, latestOrder.getMerchantId(), mode)); + failCount++; continue; } - // 5.11 处理退款(如果结算金额 < 已支付金额) - BigDecimal consumptionAmountDecimal = new BigDecimal(settlementData.getConsumptionAmount()); - if (consumptionAmountDecimal != null && latestOrder.getPayAmount() != null) { - BigDecimal refundAmount = latestOrder.getPayAmount().subtract(consumptionAmountDecimal); - if (refundAmount.compareTo(BigDecimal.ZERO) > 0) { - logger.info("【无交易记录自动结算】订单 {} 需退款:{}元", order.getOrderCode(), refundAmount); - // 调用退款逻辑(与正常结算流程一致) - handleAutoSettleRefund(latestOrder, refundAmount); - } + // 复用正常结算主路径(金额计算、落库、退款、解锁、实时数据落库等) + orderLogic.settleOrder(settlementData, latestOrder); + + // 结算后复核,防止支付校验失败等导致“假成功” + OrderBasicInfo afterOrder = orderBasicInfoMapper.selectOrderBasicInfoById(Long.valueOf(latestOrder.getId())); + if (afterOrder != null + && StringUtils.equals(OrderStatusEnum.ORDER_COMPLETE.getValue(), afterOrder.getOrderStatus()) + && afterOrder.getSettlementTime() != null) { + successCount++; + logger.info("【无交易记录自动结算】订单 {} 结算成功", orderCode); + } else { + failCount++; + logger.warn("【无交易记录自动结算】订单 {} 调用 settleOrder 后仍未完成,status:{},settlementTime:{}", + orderCode, + afterOrder == null ? null : afterOrder.getOrderStatus(), + afterOrder == null ? null : afterOrder.getSettlementTime()); } - - successCount++; - logger.info("【无交易记录自动结算】订单 {} 结算成功", order.getOrderCode()); - } catch (Exception e) { failCount++; - String errorMsg = String.format( - "【无交易记录自动结算-处理异常】订单号:%s,站点ID:%s,充电桩:%s,异常信息:%s", - order.getOrderCode(), - order.getStationId(), - order.getPileSn(), - e.getMessage() - ); - logger.error(errorMsg, e); - if (autoSettleConfig.getAlertEnabled()) { - // TODO: 发送告警通知 - // 建议包含异常堆栈信息前3行,方便快速定位问题 - } + alertAutoSettle("处理异常", String.format( + "订单号:%s,站点ID:%s,充电桩:%s,异常信息:%s", + orderCode, order.getStationId(), order.getPileSn(), e.getMessage()), e); } finally { - // 5.12 释放分布式锁 if (lockAcquired) { - redisCache.deleteObject(lockKey); + try { + Object current = redisCache.getCacheObject(lockKey); + if (current != null && requestId.equals(String.valueOf(current))) { + redisCache.unLock(lockKey); + } + } catch (Exception unlockEx) { + logger.warn("【无交易记录自动结算】释放锁失败, orderCode:{}, lockKey:{}", orderCode, lockKey, unlockEx); + } } } } @@ -7732,33 +7654,131 @@ public class OrderBasicInfoServiceImpl implements OrderBasicInfoService { successCount, skipCount, failCount); } + /** - * 处理自动结算的退款 + * 自动结算统一告警出口。 + *

当前落 ERROR/WARN 日志;后续对接钉钉/企微/邮件只改这里,避免业务分支散落通知代码。

*/ - private void handleAutoSettleRefund(OrderBasicInfo order, BigDecimal refundAmount) { - try { - // 根据支付方式调用不同的退款接口 - String payMode = order.getPayMode(); - if ("1".equals(payMode)) { - // 微信支付退款 - // TODO: 调用微信退款接口 - logger.info("【无交易记录自动结算】订单 {} 微信退款:{}元", order.getOrderCode(), refundAmount); - } else if ("2".equals(payMode)) { - // 支付宝退款 - // TODO: 调用支付宝退款接口 - logger.info("【无交易记录自动结算】订单 {} 支付宝退款:{}元", order.getOrderCode(), refundAmount); - } else if ("3".equals(payMode)) { - // 汇付支付退款 - // TODO: 调用汇付退款接口 - logger.info("【无交易记录自动结算】订单 {} 汇付支付退款:{}元", order.getOrderCode(), refundAmount); - } else if ("4".equals(payMode)) { - // 余额支付退款(直接返还会员钱包) - logger.info("【无交易记录自动结算】订单 {} 余额退款:{}元", order.getOrderCode(), refundAmount); - // TODO: 调用钱包退款接口 + private void alertAutoSettle(String scene, String message) { + alertAutoSettle(scene, message, null); + } + + private void alertAutoSettle(String scene, String message, Throwable error) { + String fullMessage = "【无交易记录自动结算-" + scene + "】" + message; + boolean alertEnabled = autoSettleConfig != null && Boolean.TRUE.equals(autoSettleConfig.getAlertEnabled()); + if (alertEnabled) { + if (error != null) { + logger.error(fullMessage, error); + } else { + logger.error(fullMessage); } - } catch (Exception e) { - logger.error("【无交易记录自动结算】订单 {} 退款处理失败:{}元", order.getOrderCode(), refundAmount, e); - throw new RuntimeException("退款处理失败", e); + // TODO 对接现有告警通道(钉钉/企微/邮件)时仅在此处扩展 + } else if (error != null) { + logger.warn(fullMessage, error); + } else { + logger.warn(fullMessage); } } + + private RealTimeMonitorData selectLatestRealTimeData(List realTimeDataList) { + if (CollectionUtils.isEmpty(realTimeDataList)) { + return null; + } + RealTimeMonitorData latest = realTimeDataList.get(0); + LocalDateTime latestTime = parseRealTimeDateTime(latest.getDateTime()); + for (int i = 1; i < realTimeDataList.size(); i++) { + RealTimeMonitorData candidate = realTimeDataList.get(i); + LocalDateTime candidateTime = parseRealTimeDateTime(candidate.getDateTime()); + if (candidateTime == null) { + continue; + } + if (latestTime == null || candidateTime.isAfter(latestTime)) { + latest = candidate; + latestTime = candidateTime; + } + } + // 全部无法解析时间时,兼容 Redis 倒序约定取第一条 + return latest; + } + + /** + * 枪口/桩是否在线:先 checkPileOffLine,再看枪状态缓存/DB。 + */ + private boolean isConnectorOnlineForAutoSettle(OrderBasicInfo order) { + String pileSn = order.getPileSn(); + String connectorCode = order.getConnectorCode(); + if (StringUtils.isBlank(pileSn)) { + logger.warn("【无交易记录自动结算】订单 {} 缺少桩号,无法判断在线状态", order.getOrderCode()); + return false; + } + + // 优先使用平台统一的桩离线判定(最后通信时间) + try { + if (pileConnectorInfoService.checkPileOffLine(pileSn)) { + return false; + } + } catch (Exception e) { + logger.warn("【无交易记录自动结算】检查桩离线状态失败, orderCode:{}, pileSn:{}", + order.getOrderCode(), pileSn, e); + return false; + } + + if (StringUtils.isBlank(connectorCode)) { + // 桩在线但无枪号时,允许继续(后续仍有实时数据/新鲜度校验) + return true; + } + + String pileConnectorCode = pileSn + connectorCode; + String redisKey = CacheConstants.PILE_CONNECTOR_STATUS_KEY + pileConnectorCode; + Object statusObj = redisCache.getCacheObject(redisKey); + String status = statusObj == null ? null : String.valueOf(statusObj); + + if (StringUtils.isBlank(status)) { + try { + PileConnectorInfoVO connectorInfo = pileConnectorInfoService.getPileConnectorInfoByConnectorCode(pileConnectorCode); + if (connectorInfo != null && connectorInfo.getStatus() != null) { + status = String.valueOf(connectorInfo.getStatus()); + } + } catch (Exception e) { + logger.warn("【无交易记录自动结算】查询枪口状态失败, orderCode:{}, pileConnectorCode:{}", + order.getOrderCode(), pileConnectorCode, e); + } + } + + if (StringUtils.isBlank(status)) { + // 桩通信在线且枪状态缺失时放行,避免误杀;由实时数据校验兜底 + return true; + } + return !StringUtils.equals(PileConnectorDataBaseStatusEnum.OFF_NETWORK.getValue(), status); + } + + private boolean isStoppedLongEnoughForAutoSettle(OrderBasicInfo order, + LocalDateTime latestDataTime, + int timeoutMinutes) { + LocalDateTime now = LocalDateTime.now(); + if (order.getChargeEndTime() != null) { + LocalDateTime endTime = DateUtils.date2LocalDateTime(order.getChargeEndTime()); + if (endTime == null) { + return false; + } + return !endTime.isAfter(now.minusMinutes(timeoutMinutes)); + } + if (latestDataTime == null) { + return false; + } + return !latestDataTime.isAfter(now.minusMinutes(timeoutMinutes)); + } + + private LocalDateTime parseRealTimeDateTime(String dateTime) { + if (StringUtils.isBlank(dateTime)) { + return null; + } + try { + return LocalDateTime.parse(dateTime, java.time.format.DateTimeFormatter.ofPattern(DateUtils.YYYY_MM_DD_HH_MM_SS)); + } catch (Exception e) { + logger.warn("解析实时数据时间失败: {}", dateTime); + return null; + } + } + } diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/util/SettlementDataConverter.java b/jsowell-pile/src/main/java/com/jsowell/pile/util/SettlementDataConverter.java index 480d45d83..991ab2feb 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/util/SettlementDataConverter.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/util/SettlementDataConverter.java @@ -3,158 +3,80 @@ package com.jsowell.pile.util; import com.jsowell.common.core.domain.ykc.RealTimeMonitorData; import com.jsowell.common.core.domain.ykc.TransactionRecordsData; import com.jsowell.common.util.DateUtils; -import org.apache.commons.lang3.StringUtils; +import com.jsowell.common.util.StringUtils; +import com.jsowell.pile.domain.OrderBasicInfo; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; +import java.util.Date; /** - * 结算数据转换工具类 - * 用于将实时监测数据转换为交易记录数据 - * - * @author jsowell + * 将实时监测数据转换为结算用交易记录数据。 + *

+ * 重要:不要填充尖峰平谷分时电量。settleOrder 在 sumUsedElectricity=0 时 + * 会保留 consumptionAmount(与人工结算无交易记录场景一致,金额以桩端为准)。 */ -public class SettlementDataConverter { +public final class SettlementDataConverter { private static final Logger logger = LoggerFactory.getLogger(SettlementDataConverter.class); - private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private SettlementDataConverter() { + } /** - * 将实时监测数据转换为结算用的交易记录数据 - * - * @param realTimeData 最后一条实时监测数据 - * @param startTime 订单开始时间 - * @return 交易记录数据 + * 从订单 + 最后一条实时数据构造结算数据 */ - public static TransactionRecordsData convertToTransactionData(RealTimeMonitorData realTimeData, LocalDateTime startTime) { - if (realTimeData == null) { - logger.warn("实时监测数据为空,无法转换为交易记录"); - return null; + public static TransactionRecordsData convertFromRealTimeData(OrderBasicInfo order, + RealTimeMonitorData realTimeData) { + if (order == null || realTimeData == null) { + throw new IllegalArgumentException("order/realTimeData 不能为空"); } - if (StringUtils.isBlank(realTimeData.getTransactionCode())) { - logger.warn("实时监测数据缺少交易流水号,无法转换"); - return null; + TransactionRecordsData data = new TransactionRecordsData(); + String transactionCode = StringUtils.isNotBlank(realTimeData.getTransactionCode()) + ? realTimeData.getTransactionCode() + : order.getTransactionCode(); + data.setTransactionCode(transactionCode); + data.setPileSn(StringUtils.isNotBlank(realTimeData.getPileSn()) ? realTimeData.getPileSn() : order.getPileSn()); + data.setConnectorCode(StringUtils.isNotBlank(realTimeData.getConnectorCode()) + ? realTimeData.getConnectorCode() + : order.getConnectorCode()); + + String startTime = formatDate(order.getChargeStartTime()); + String endTime = formatDate(order.getChargeEndTime()); + if (StringUtils.isBlank(endTime) && StringUtils.isNotBlank(realTimeData.getDateTime())) { + endTime = realTimeData.getDateTime(); } + if (StringUtils.isBlank(endTime)) { + endTime = DateUtils.getDateTime(); + } + data.setStartTime(startTime); + data.setEndTime(endTime); + data.setTransactionTime(endTime); - logger.info("开始转换实时数据为结算数据, transactionCode: {}, chargingDegree: {}, chargingAmount: {}", - realTimeData.getTransactionCode(), - realTimeData.getChargingDegree(), - realTimeData.getChargingAmount()); - - TransactionRecordsData transactionData = new TransactionRecordsData(); - - // 基础信息 - transactionData.setTransactionCode(realTimeData.getTransactionCode()); - transactionData.setPileSn(realTimeData.getPileSn()); - transactionData.setConnectorCode(realTimeData.getConnectorCode()); - - // 时间信息 - String startTimeStr = startTime != null ? startTime.format(FORMATTER) : null; - String endTimeStr = StringUtils.isNotBlank(realTimeData.getDateTime()) - ? realTimeData.getDateTime() - : LocalDateTime.now().format(FORMATTER); - - transactionData.setStartTime(startTimeStr); - transactionData.setEndTime(endTimeStr); - transactionData.setTransactionTime(endTimeStr); - - // 电量和金额信息(使用实时数据的累计值) String chargingDegree = defaultIfBlank(realTimeData.getChargingDegree(), "0.0000"); - String lossDegree = defaultIfBlank(realTimeData.getLossDegree(), chargingDegree); String chargingAmount = defaultIfBlank(realTimeData.getChargingAmount(), "0.0000"); + String lossDegree = defaultIfBlank(realTimeData.getLossDegree(), chargingDegree); - // 设置总电量(不区分时段,全部归为平段) - transactionData.setTotalElectricity(chargingDegree); - transactionData.setPlanLossTotalElectricity(lossDegree); - transactionData.setConsumptionAmount(chargingAmount); + // 仅设置总量,不填尖峰平谷,确保 settleOrder 以桩端金额为准 + data.setTotalElectricity(chargingDegree); + data.setPlanLossTotalElectricity(lossDegree); + data.setConsumptionAmount(chargingAmount); - // 平段数据(将所有数据归为平段) - transactionData.setFlatUsedElectricity(chargingDegree); - transactionData.setFlatPlanLossElectricity(lossDegree); - transactionData.setFlatAmount(chargingAmount); + data.setStopReasonCode("FF"); + data.setStopReasonMsg("无交易记录自动结算"); + data.setTransactionIdentifier("00"); + data.setVinCode(""); + data.setLogicCard(""); - // 计算平段单价(避免除零) - if (isPositive(lossDegree) && isPositive(chargingAmount)) { - BigDecimal price = new BigDecimal(chargingAmount).divide(new BigDecimal(lossDegree), 5, BigDecimal.ROUND_HALF_UP); - transactionData.setFlatPrice(price.toPlainString()); - } else { - transactionData.setFlatPrice("0.00000"); - } - - // 其他时段设为0 - transactionData.setSharpUsedElectricity("0.0000"); - transactionData.setSharpPlanLossElectricity("0.0000"); - transactionData.setSharpAmount("0.0000"); - transactionData.setSharpPrice("0.00000"); - - transactionData.setPeakUsedElectricity("0.0000"); - transactionData.setPeakPlanLossElectricity("0.0000"); - transactionData.setPeakAmount("0.0000"); - transactionData.setPeakPrice("0.00000"); - - transactionData.setValleyUsedElectricity("0.0000"); - transactionData.setValleyPlanLossElectricity("0.0000"); - transactionData.setValleyAmount("0.0000"); - transactionData.setValleyPrice("0.00000"); - - // 电表读数(无实际数据) - transactionData.setAmmeterTotalStart("0.0000"); - transactionData.setAmmeterTotalEnd(chargingDegree); - - // 停止原因:无交易记录自动结算 - transactionData.setStopReasonCode("0xFF"); - transactionData.setStopReasonMsg("无交易记录自动结算"); - - // 交易标识(未知) - transactionData.setTransactionIdentifier("0x00"); - - // VIN码和物理卡号(无) - transactionData.setVinCode(""); - transactionData.setLogicCard(""); - - // 电费和服务费(无法拆分,设为空由业务层处理) - transactionData.setTotalElectricityAmount(null); - transactionData.setTotalServiceAmount(null); - - logger.info("实时数据转换为结算数据完成, transactionCode: {}, totalElectricity: {}, consumptionAmount: {}", - transactionData.getTransactionCode(), - transactionData.getTotalElectricity(), - transactionData.getConsumptionAmount()); - - return transactionData; + logger.info("实时数据转换为结算数据完成, orderCode:{}, transactionCode:{}, totalElectricity:{}, consumptionAmount:{}", + order.getOrderCode(), data.getTransactionCode(), data.getTotalElectricity(), data.getConsumptionAmount()); + return data; } /** - * 返回默认值(如果为空) - */ - private static String defaultIfBlank(String value, String defaultValue) { - return StringUtils.isNotBlank(value) ? value : defaultValue; - } - - /** - * 判断字符串数值是否为正数 - */ - private static boolean isPositive(String value) { - if (StringUtils.isBlank(value)) { - return false; - } - try { - return new BigDecimal(value).compareTo(BigDecimal.ZERO) > 0; - } catch (Exception e) { - return false; - } - } - - /** - * 校验实时数据是否有效(用于自动结算前的数据校验) - * - * @param realTimeData 实时监测数据 - * @return 是否有效 + * 自动结算前的实时数据有效性校验 */ public static boolean isValidForSettlement(RealTimeMonitorData realTimeData) { if (realTimeData == null) { @@ -162,39 +84,62 @@ public class SettlementDataConverter { return false; } - if (StringUtils.isBlank(realTimeData.getTransactionCode())) { - logger.warn("实时数据缺少交易流水号"); - return false; - } - - // 充电度数必须大于0 String chargingDegree = realTimeData.getChargingDegree(); - if (StringUtils.isBlank(chargingDegree) || !isPositive(chargingDegree)) { - logger.warn("充电度数无效, transactionCode: {}, chargingDegree: {}", - realTimeData.getTransactionCode(), chargingDegree); - return false; - } - - // 充电金额必须 >= 0(可以为0,如白名单免费) String chargingAmount = realTimeData.getChargingAmount(); - if (StringUtils.isBlank(chargingAmount)) { - logger.warn("充电金额为空, transactionCode: {}", realTimeData.getTransactionCode()); + + BigDecimal degree; + BigDecimal amount; + try { + if (StringUtils.isBlank(chargingDegree)) { + logger.warn("充电度数为空, transactionCode:{}", realTimeData.getTransactionCode()); + return false; + } + degree = new BigDecimal(chargingDegree); + if (degree.compareTo(BigDecimal.ZERO) <= 0) { + logger.warn("充电度数无效, transactionCode:{}, chargingDegree:{}", + realTimeData.getTransactionCode(), chargingDegree); + return false; + } + } catch (Exception e) { + logger.warn("充电度数格式错误, transactionCode:{}, chargingDegree:{}", + realTimeData.getTransactionCode(), chargingDegree, e); return false; } try { - BigDecimal amount = new BigDecimal(chargingAmount); + if (StringUtils.isBlank(chargingAmount)) { + logger.warn("充电金额为空, transactionCode:{}", realTimeData.getTransactionCode()); + return false; + } + amount = new BigDecimal(chargingAmount); if (amount.compareTo(BigDecimal.ZERO) < 0) { - logger.warn("充电金额为负数, transactionCode: {}, chargingAmount: {}", + logger.warn("充电金额为负数, transactionCode:{}, chargingAmount:{}", realTimeData.getTransactionCode(), chargingAmount); return false; } } catch (Exception e) { - logger.warn("充电金额格式错误, transactionCode: {}, chargingAmount: {}", + logger.warn("充电金额格式错误, transactionCode:{}, chargingAmount:{}", realTimeData.getTransactionCode(), chargingAmount, e); return false; } + // 电量>0 但金额=0:数据异常(与测试计划一致) + if (degree.compareTo(BigDecimal.ZERO) > 0 && amount.compareTo(BigDecimal.ZERO) == 0) { + logger.warn("充电电量>0但金额=0, transactionCode:{}, degree:{}, amount:{}", + realTimeData.getTransactionCode(), chargingDegree, chargingAmount); + return false; + } return true; } + + private static String formatDate(Date date) { + if (date == null) { + return null; + } + return DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD_HH_MM_SS, date); + } + + private static String defaultIfBlank(String value, String defaultValue) { + return StringUtils.isNotBlank(value) ? value : defaultValue; + } } diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/util/TouElectricityEstimateResult.java b/jsowell-pile/src/main/java/com/jsowell/pile/util/TouElectricityEstimateResult.java new file mode 100644 index 000000000..55e651a3f --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/util/TouElectricityEstimateResult.java @@ -0,0 +1,67 @@ +package com.jsowell.pile.util; + +import java.math.BigDecimal; + +/** + * 平台根据实时监测曲线估算的尖峰平谷电量(仅观测,不参与结算)。 + */ +public class TouElectricityEstimateResult { + + private BigDecimal sharp = BigDecimal.ZERO; + private BigDecimal peak = BigDecimal.ZERO; + private BigDecimal flat = BigDecimal.ZERO; + private BigDecimal valley = BigDecimal.ZERO; + /** 最后一条实时累计电量 */ + private BigDecimal totalFromLastSample = BigDecimal.ZERO; + /** 各时段差分电量之和 */ + private BigDecimal totalFromDeltas = BigDecimal.ZERO; + private int sampleCount; + private int positiveDeltaCount; + private int negativeOrZeroDeltaCount; + private int unmatchedPeriodCount; + private String note; + + public BigDecimal getSharp() { return sharp; } + public void setSharp(BigDecimal sharp) { this.sharp = sharp; } + public BigDecimal getPeak() { return peak; } + public void setPeak(BigDecimal peak) { this.peak = peak; } + public BigDecimal getFlat() { return flat; } + public void setFlat(BigDecimal flat) { this.flat = flat; } + public BigDecimal getValley() { return valley; } + public void setValley(BigDecimal valley) { this.valley = valley; } + public BigDecimal getTotalFromLastSample() { return totalFromLastSample; } + public void setTotalFromLastSample(BigDecimal totalFromLastSample) { this.totalFromLastSample = totalFromLastSample; } + public BigDecimal getTotalFromDeltas() { return totalFromDeltas; } + public void setTotalFromDeltas(BigDecimal totalFromDeltas) { this.totalFromDeltas = totalFromDeltas; } + public int getSampleCount() { return sampleCount; } + public void setSampleCount(int sampleCount) { this.sampleCount = sampleCount; } + public int getPositiveDeltaCount() { return positiveDeltaCount; } + public void setPositiveDeltaCount(int positiveDeltaCount) { this.positiveDeltaCount = positiveDeltaCount; } + public int getNegativeOrZeroDeltaCount() { return negativeOrZeroDeltaCount; } + public void setNegativeOrZeroDeltaCount(int negativeOrZeroDeltaCount) { this.negativeOrZeroDeltaCount = negativeOrZeroDeltaCount; } + public int getUnmatchedPeriodCount() { return unmatchedPeriodCount; } + public void setUnmatchedPeriodCount(int unmatchedPeriodCount) { this.unmatchedPeriodCount = unmatchedPeriodCount; } + public String getNote() { return note; } + public void setNote(String note) { this.note = note; } + + public void addToType(String timeType, BigDecimal delta) { + if (delta == null) { + return; + } + if ("1".equals(timeType)) { + sharp = sharp.add(delta); + } else if ("2".equals(timeType)) { + peak = peak.add(delta); + } else if ("3".equals(timeType)) { + flat = flat.add(delta); + } else if ("4".equals(timeType)) { + valley = valley.add(delta); + } else { + // 未知时段先记入平,并计数 + flat = flat.add(delta); + unmatchedPeriodCount++; + } + totalFromDeltas = totalFromDeltas.add(delta); + positiveDeltaCount++; + } +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/util/TouElectricityEstimator.java b/jsowell-pile/src/main/java/com/jsowell/pile/util/TouElectricityEstimator.java new file mode 100644 index 000000000..f81f9d511 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/util/TouElectricityEstimator.java @@ -0,0 +1,204 @@ +package com.jsowell.pile.util; + +import com.jsowell.common.core.domain.ykc.RealTimeMonitorData; +import com.jsowell.common.enums.ykc.BillingTimeTypeEnum; +import com.jsowell.common.util.DateUtils; +import com.jsowell.common.util.StringUtils; +import com.jsowell.pile.vo.web.BillingTemplateVO; +import org.apache.commons.collections4.CollectionUtils; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * 用实时监测累计电量差分,按计费模板时段归入尖峰平谷。 + *

仅用于观测对比,不参与任何结算。

+ */ +public final class TouElectricityEstimator { + + private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern(DateUtils.YYYY_MM_DD_HH_MM_SS); + + private TouElectricityEstimator() { + } + + public static TouElectricityEstimateResult estimate(List rawSamples, + BillingTemplateVO billingTemplate) { + TouElectricityEstimateResult result = new TouElectricityEstimateResult(); + if (CollectionUtils.isEmpty(rawSamples)) { + result.setNote("no_realtime_samples"); + return result; + } + + List samples = new ArrayList<>(); + for (RealTimeMonitorData item : rawSamples) { + if (item == null) { + continue; + } + LocalDateTime dt = parseDateTime(item.getDateTime()); + BigDecimal degree = parseDecimal(item.getChargingDegree()); + if (dt == null || degree == null) { + continue; + } + samples.add(new Sample(dt, degree)); + } + samples.sort(Comparator.comparing(s -> s.dateTime)); + // 同一时间点保留最后一条 + List compact = new ArrayList<>(); + for (Sample s : samples) { + if (!compact.isEmpty() && compact.get(compact.size() - 1).dateTime.equals(s.dateTime)) { + compact.set(compact.size() - 1, s); + } else { + compact.add(s); + } + } + result.setSampleCount(compact.size()); + if (compact.isEmpty()) { + result.setNote("no_valid_samples"); + return result; + } + result.setTotalFromLastSample(compact.get(compact.size() - 1).degree); + + if (compact.size() < 2) { + result.setNote("single_sample_cannot_diff"); + return result; + } + + PeriodRules rules = PeriodRules.from(billingTemplate); + for (int i = 1; i < compact.size(); i++) { + Sample prev = compact.get(i - 1); + Sample curr = compact.get(i); + BigDecimal delta = curr.degree.subtract(prev.degree); + if (delta.compareTo(BigDecimal.ZERO) <= 0) { + result.setNegativeOrZeroDeltaCount(result.getNegativeOrZeroDeltaCount() + 1); + continue; + } + // 区间电量归属用终点时刻(与分钟采样对齐更直观) + String timeType = rules.resolveTimeType(curr.dateTime.toLocalTime()); + if (timeType == null) { + // 模板未覆盖的时刻:记入平并标记 + result.addToType(BillingTimeTypeEnum.FLAT.getValue(), delta); + result.setUnmatchedPeriodCount(result.getUnmatchedPeriodCount() + 1); + } else { + result.addToType(timeType, delta); + } + } + result.setNote(rules.hasAnyRule() ? "ok" : "no_billing_periods_fallback_flat"); + return result; + } + + private static LocalDateTime parseDateTime(String value) { + if (StringUtils.isBlank(value)) { + return null; + } + try { + return LocalDateTime.parse(value.trim(), DATE_TIME); + } catch (Exception e) { + return null; + } + } + + private static BigDecimal parseDecimal(String value) { + if (StringUtils.isBlank(value)) { + return null; + } + try { + return new BigDecimal(value.trim()); + } catch (Exception e) { + return null; + } + } + + private static final class Sample { + private final LocalDateTime dateTime; + private final BigDecimal degree; + + private Sample(LocalDateTime dateTime, BigDecimal degree) { + this.dateTime = dateTime; + this.degree = degree; + } + } + + /** + * 尖峰平谷适用时段规则(来自计费模板 applyDate,逗号分隔多段 HH:mm-HH:mm) + */ + static final class PeriodRules { + private final List windows = new ArrayList<>(); + + static PeriodRules from(BillingTemplateVO template) { + PeriodRules rules = new PeriodRules(); + if (template == null) { + return rules; + } + rules.addWindows(BillingTimeTypeEnum.SHARP.getValue(), template.getSharpApplyDate()); + rules.addWindows(BillingTimeTypeEnum.PEAK.getValue(), template.getPeakApplyDate()); + rules.addWindows(BillingTimeTypeEnum.FLAT.getValue(), template.getFlatApplyDate()); + rules.addWindows(BillingTimeTypeEnum.VALLEY.getValue(), template.getValleyApplyDate()); + return rules; + } + + boolean hasAnyRule() { + return !windows.isEmpty(); + } + + String resolveTimeType(LocalTime time) { + if (time == null || windows.isEmpty()) { + return null; + } + for (PeriodWindow w : windows) { + if (w.contains(time)) { + return w.timeType; + } + } + return null; + } + + private void addWindows(String timeType, String applyDateCsv) { + if (StringUtils.isBlank(applyDateCsv)) { + return; + } + String[] parts = applyDateCsv.split(","); + for (String part : parts) { + if (StringUtils.isBlank(part)) { + continue; + } + String range = part.trim(); + String[] se = range.split("-"); + if (se.length != 2) { + continue; + } + LocalTime start = DateUtils.getLocalTime(se[0].trim()); + LocalTime end = DateUtils.getLocalTime(se[1].trim()); + if (start == null || end == null) { + continue; + } + windows.add(new PeriodWindow(timeType, start, end)); + } + } + } + + private static final class PeriodWindow { + private final String timeType; + private final LocalTime start; + private final LocalTime end; + + private PeriodWindow(String timeType, LocalTime start, LocalTime end) { + this.timeType = timeType; + this.start = start; + this.end = end; + } + + /** 闭区间 [start, end];若 end<start 视为跨午夜。 */ + private boolean contains(LocalTime time) { + if (!end.isBefore(start)) { + return !time.isBefore(start) && !time.isAfter(end); + } + // 跨午夜:time >= start || time <= end + return !time.isBefore(start) || !time.isAfter(end); + } + } +} diff --git a/jsowell-pile/src/main/resources/mapper/pile/OrderBasicInfoMapper.xml b/jsowell-pile/src/main/resources/mapper/pile/OrderBasicInfoMapper.xml index 31e493a09..6380595c7 100644 --- a/jsowell-pile/src/main/resources/mapper/pile/OrderBasicInfoMapper.xml +++ b/jsowell-pile/src/main/resources/mapper/pile/OrderBasicInfoMapper.xml @@ -3770,49 +3770,38 @@ - + - - - UPDATE order_basic_info - SET order_status = #{order.orderStatus}, - order_amount = #{order.orderAmount}, - virtual_amount = #{order.virtualAmount}, - settle_amount = #{order.settleAmount}, - actual_received_amount = #{order.actualReceivedAmount}, - reason = #{order.reason}, - settlement_time = #{order.settlementTime}, - refund_amount = #{order.refundAmount} - WHERE id = #{orderId} - AND order_status = #{expectedStatus} - - - AND settlement_time IS NULL - - - AND settlement_time = #{expectedSettlementTime} - - - diff --git a/jsowell-pile/src/test/java/com/jsowell/pile/config/AutoSettleConfigTest.java b/jsowell-pile/src/test/java/com/jsowell/pile/config/AutoSettleConfigTest.java index f85312711..f9aa27aea 100644 --- a/jsowell-pile/src/test/java/com/jsowell/pile/config/AutoSettleConfigTest.java +++ b/jsowell-pile/src/test/java/com/jsowell/pile/config/AutoSettleConfigTest.java @@ -33,7 +33,7 @@ public class AutoSettleConfigTest { Assert.assertEquals("金额阈值比例默认1.5", Double.valueOf(1.5), config.getAmountThresholdRatio()); Assert.assertEquals("数据新鲜度默认30分钟", Integer.valueOf(30), config.getDataFreshnessMinutes()); Assert.assertTrue("告警开关默认开启", config.getAlertEnabled()); - Assert.assertEquals("锁超时默认300秒", Long.valueOf(300L), config.getLockTimeout()); + Assert.assertEquals("锁超时默认60秒", Integer.valueOf(60), config.getLockTimeoutSeconds()); Assert.assertEquals("默认cron表达式", "0 */10 * * * ?", config.getInterval()); System.out.println("✅ 默认值测试通过"); @@ -83,7 +83,7 @@ public class AutoSettleConfigTest { config.setAmountThresholdRatio(2.0); config.setDataFreshnessMinutes(60); config.setAlertEnabled(false); - config.setLockTimeout(600L); + config.setLockTimeoutSeconds(600); Assert.assertTrue(config.getEnabled()); Assert.assertEquals(Integer.valueOf(20), config.getTimeoutMinutes()); @@ -92,7 +92,7 @@ public class AutoSettleConfigTest { Assert.assertEquals(Double.valueOf(2.0), config.getAmountThresholdRatio()); Assert.assertEquals(Integer.valueOf(60), config.getDataFreshnessMinutes()); Assert.assertFalse(config.getAlertEnabled()); - Assert.assertEquals(Long.valueOf(600L), config.getLockTimeout()); + Assert.assertEquals(Integer.valueOf(600), config.getLockTimeoutSeconds()); System.out.println("✅ Setter/Getter测试通过"); } diff --git a/jsowell-pile/src/test/java/com/jsowell/pile/service/impl/AutoSettleOrdersWithoutTransactionRecordTest.java b/jsowell-pile/src/test/java/com/jsowell/pile/service/impl/AutoSettleOrdersWithoutTransactionRecordTest.java new file mode 100644 index 000000000..975265dc3 --- /dev/null +++ b/jsowell-pile/src/test/java/com/jsowell/pile/service/impl/AutoSettleOrdersWithoutTransactionRecordTest.java @@ -0,0 +1,293 @@ +package com.jsowell.pile.service.impl; + +import com.jsowell.common.core.domain.ykc.RealTimeMonitorData; +import com.jsowell.common.core.domain.ykc.TransactionRecordsData; +import com.jsowell.common.core.redis.RedisCache; +import com.jsowell.common.enums.adapay.MerchantDelayModeEnum; +import com.jsowell.common.enums.ykc.OrderStatusEnum; +import com.jsowell.common.enums.ykc.PileConnectorDataBaseStatusEnum; +import com.jsowell.pile.config.AutoSettleConfig; +import com.jsowell.pile.domain.OrderBasicInfo; +import com.jsowell.pile.mapper.OrderBasicInfoMapper; +import com.jsowell.pile.service.PileConnectorInfoService; +import com.jsowell.pile.service.PileMerchantInfoService; +import com.jsowell.pile.service.programlogic.AbstractProgramLogic; +import com.jsowell.pile.service.programlogic.ProgramLogicFactory; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.objenesis.ObjenesisStd; +import org.slf4j.LoggerFactory; + +import java.lang.reflect.Field; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Collections; +import java.util.Date; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 无交易记录自动结算主流程 mock 单测(不连库、不启 Spring)。 + */ +public class AutoSettleOrdersWithoutTransactionRecordTest { + + private static final String MODE = MerchantDelayModeEnum.NOT_DELAY.getValue(); + private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private OrderBasicInfoServiceImpl service; + private OrderBasicInfoMapper orderBasicInfoMapper; + private RedisCache redisCache; + private PileMerchantInfoService pileMerchantInfoService; + private PileConnectorInfoService pileConnectorInfoService; + private AutoSettleConfig autoSettleConfig; + private AbstractProgramLogic orderLogic; + + @Before + public void setUp() { + orderBasicInfoMapper = mock(OrderBasicInfoMapper.class); + redisCache = mock(RedisCache.class); + pileMerchantInfoService = mock(PileMerchantInfoService.class); + pileConnectorInfoService = mock(PileConnectorInfoService.class); + autoSettleConfig = new AutoSettleConfig(); + autoSettleConfig.setEnabled(true); + autoSettleConfig.setTimeoutMinutes(10); + autoSettleConfig.setBatchSize(100); + autoSettleConfig.setAmountThresholdRatio(1.5D); + autoSettleConfig.setDataFreshnessMinutes(30); + autoSettleConfig.setAlertEnabled(true); + autoSettleConfig.setLockTimeoutSeconds(60); + + OrderBasicInfoServiceImpl raw = new ObjenesisStd().newInstance(OrderBasicInfoServiceImpl.class); + setField(raw, "logger", LoggerFactory.getLogger(AutoSettleOrdersWithoutTransactionRecordTest.class)); + setField(raw, "orderBasicInfoMapper", orderBasicInfoMapper); + setField(raw, "redisCache", redisCache); + setField(raw, "pileMerchantInfoService", pileMerchantInfoService); + setField(raw, "pileConnectorInfoService", pileConnectorInfoService); + setField(raw, "autoSettleConfig", autoSettleConfig); + + service = Mockito.spy(raw); + + orderLogic = mock(AbstractProgramLogic.class); + ProgramLogicFactory.register(MODE, orderLogic); + } + + @After + public void tearDown() { + ProgramLogicFactory.register(MODE, mock(AbstractProgramLogic.class)); + } + + @Test + public void disabled_shouldSkipAll() { + autoSettleConfig.setEnabled(false); + service.autoSettleOrdersWithoutTransactionRecord(); + verify(orderBasicInfoMapper, never()).selectPendingAutoSettleOrders(any(), any(), anyInt()); + verify(orderLogic, never()).settleOrder(any(), any()); + } + + @Test + public void emptyPending_shouldNotSettle() { + when(orderBasicInfoMapper.selectPendingAutoSettleOrders(any(), any(), anyInt())) + .thenReturn(Collections.emptyList()); + service.autoSettleOrdersWithoutTransactionRecord(); + verify(orderLogic, never()).settleOrder(any(), any()); + } + + @Test + public void lockFail_shouldSkip() { + OrderBasicInfo pending = basePendingOrder(); + when(orderBasicInfoMapper.selectPendingAutoSettleOrders(any(), any(), anyInt())) + .thenReturn(Collections.singletonList(pending)); + when(redisCache.setnx(eq("settle_order_T001"), anyString(), anyLong())).thenReturn(false); + + service.autoSettleOrdersWithoutTransactionRecord(); + + verify(orderLogic, never()).settleOrder(any(), any()); + verify(orderBasicInfoMapper, never()).selectOrderBasicInfoById(anyLong()); + } + + @Test + public void amountOverThreshold_shouldSkipAndNotSettle() { + OrderBasicInfo pending = basePendingOrder(); + pending.setPayAmount(new BigDecimal("10.00")); + stubCommonHappyPath(pending, "12.0000", "20.00"); + + service.autoSettleOrdersWithoutTransactionRecord(); + + verify(orderLogic, never()).settleOrder(any(), any()); + verify(redisCache).unLock("settle_order_T001"); + } + + @Test + public void offlinePile_shouldSkip() { + OrderBasicInfo pending = basePendingOrder(); + when(orderBasicInfoMapper.selectPendingAutoSettleOrders(any(), any(), anyInt())) + .thenReturn(Collections.singletonList(pending)); + stubLock("settle_order_T001"); + when(orderBasicInfoMapper.selectOrderBasicInfoById(1L)).thenReturn(copy(pending)); + when(pileConnectorInfoService.checkPileOffLine("32010600000001")).thenReturn(true); + + service.autoSettleOrdersWithoutTransactionRecord(); + + verify(orderLogic, never()).settleOrder(any(), any()); + verify(redisCache).unLock("settle_order_T001"); + } + + @Test + public void grayscaleNotMatch_shouldSkip() { + autoSettleConfig.setGrayscaleStationIds(Collections.singletonList(1001L)); + OrderBasicInfo pending = basePendingOrder(); + pending.setStationId("2002"); + when(orderBasicInfoMapper.selectPendingAutoSettleOrders(any(), any(), anyInt())) + .thenReturn(Collections.singletonList(pending)); + stubLock("settle_order_T001"); + when(orderBasicInfoMapper.selectOrderBasicInfoById(1L)).thenReturn(copy(pending)); + + service.autoSettleOrdersWithoutTransactionRecord(); + + verify(orderLogic, never()).settleOrder(any(), any()); + verify(pileConnectorInfoService, never()).checkPileOffLine(anyString()); + } + + @Test + public void invalidRealtime_shouldSkip() { + OrderBasicInfo pending = basePendingOrder(); + stubCommonHappyPath(pending, "1.0000", "0"); + + service.autoSettleOrdersWithoutTransactionRecord(); + + verify(orderLogic, never()).settleOrder(any(), any()); + } + + @Test + public void success_shouldCallSettleOrderWithPileAmount() { + OrderBasicInfo pending = basePendingOrder(); + stubCommonHappyPath(pending, "12.3456", "23.45"); + + AtomicInteger selectCount = new AtomicInteger(); + when(orderBasicInfoMapper.selectOrderBasicInfoById(1L)).thenAnswer(inv -> { + int n = selectCount.incrementAndGet(); + OrderBasicInfo o = copy(pending); + if (n >= 2) { + o.setOrderStatus(OrderStatusEnum.ORDER_COMPLETE.getValue()); + o.setSettlementTime(new Date()); + } + return o; + }); + doAnswer(inv -> null).when(orderLogic).settleOrder(any(TransactionRecordsData.class), any(OrderBasicInfo.class)); + + service.autoSettleOrdersWithoutTransactionRecord(); + + ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(TransactionRecordsData.class); + verify(orderLogic, times(1)).settleOrder(dataCaptor.capture(), any(OrderBasicInfo.class)); + TransactionRecordsData data = dataCaptor.getValue(); + Assert.assertEquals("23.45", data.getConsumptionAmount()); + Assert.assertEquals("12.3456", data.getTotalElectricity()); + Assert.assertNull("不应填尖峰平谷,保证以桩端金额为准", data.getFlatUsedElectricity()); + Assert.assertEquals("无交易记录自动结算", data.getStopReasonMsg()); + verify(redisCache).unLock("settle_order_T001"); + } + + @Test + public void settleOrderNotComplete_shouldStillUnlock() { + OrderBasicInfo pending = basePendingOrder(); + stubCommonHappyPath(pending, "12.3456", "23.45"); + when(orderBasicInfoMapper.selectOrderBasicInfoById(1L)).thenReturn(copy(pending)); + doAnswer(inv -> null).when(orderLogic).settleOrder(any(TransactionRecordsData.class), any(OrderBasicInfo.class)); + + service.autoSettleOrdersWithoutTransactionRecord(); + + verify(orderLogic, times(1)).settleOrder(any(TransactionRecordsData.class), any(OrderBasicInfo.class)); + verify(redisCache).unLock("settle_order_T001"); + } + + private static void setField(Object target, String name, Object value) { + try { + Field field = null; + Class type = target.getClass(); + while (type != null) { + try { + field = type.getDeclaredField(name); + break; + } catch (NoSuchFieldException ex) { + type = type.getSuperclass(); + } + } + if (field == null) { + throw new NoSuchFieldException(name); + } + field.setAccessible(true); + field.set(target, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private void stubLock(String lockKey) { + final String[] owner = new String[1]; + when(redisCache.setnx(eq(lockKey), anyString(), anyLong())).thenAnswer(inv -> { + owner[0] = inv.getArgument(1); + return true; + }); + when(redisCache.getCacheObject(lockKey)).thenAnswer(inv -> owner[0]); + } + + private void stubCommonHappyPath(OrderBasicInfo pending, String degree, String amount) { + when(orderBasicInfoMapper.selectPendingAutoSettleOrders(any(), any(), anyInt())) + .thenReturn(Collections.singletonList(pending)); + stubLock("settle_order_T001"); + when(orderBasicInfoMapper.selectOrderBasicInfoById(1L)).thenReturn(copy(pending)); + when(pileConnectorInfoService.checkPileOffLine("32010600000001")).thenReturn(false); + when(redisCache.getCacheObject(contains("pile_connector_status:"))) + .thenReturn(PileConnectorDataBaseStatusEnum.FREE.getValue()); + when(pileMerchantInfoService.getDelayModeByMerchantId(anyString())).thenReturn(MODE); + + RealTimeMonitorData realtime = new RealTimeMonitorData(); + realtime.setTransactionCode(pending.getTransactionCode()); + realtime.setChargingDegree(degree); + realtime.setChargingAmount(amount); + realtime.setLossDegree(degree); + realtime.setDateTime(LocalDateTime.now().minusMinutes(15).format(FMT)); + doReturn(Collections.singletonList(realtime)).when(service).getChargingRealTimeData(pending.getTransactionCode()); + } + + private OrderBasicInfo basePendingOrder() { + OrderBasicInfo order = new OrderBasicInfo(); + order.setId(1); + order.setOrderCode("O001"); + order.setTransactionCode("T001"); + order.setOrderStatus(OrderStatusEnum.STAY_SETTLEMENT.getValue()); + order.setStationId("1001"); + order.setMerchantId("M001"); + order.setPileSn("32010600000001"); + order.setConnectorCode("01"); + order.setPayAmount(new BigDecimal("50.00")); + order.setChargeStartTime(new Date(System.currentTimeMillis() - 60L * 60L * 1000L)); + order.setChargeEndTime(new Date(System.currentTimeMillis() - 20L * 60L * 1000L)); + order.setSettlementTime(null); + return order; + } + + private OrderBasicInfo copy(OrderBasicInfo src) { + OrderBasicInfo order = new OrderBasicInfo(); + order.setId(src.getId()); + order.setOrderCode(src.getOrderCode()); + order.setTransactionCode(src.getTransactionCode()); + order.setOrderStatus(src.getOrderStatus()); + order.setStationId(src.getStationId()); + order.setMerchantId(src.getMerchantId()); + order.setPileSn(src.getPileSn()); + order.setConnectorCode(src.getConnectorCode()); + order.setPayAmount(src.getPayAmount()); + order.setChargeStartTime(src.getChargeStartTime()); + order.setChargeEndTime(src.getChargeEndTime()); + order.setSettlementTime(src.getSettlementTime()); + return order; + } +} diff --git a/jsowell-pile/src/test/java/com/jsowell/pile/util/SettlementDataConverterTest.java b/jsowell-pile/src/test/java/com/jsowell/pile/util/SettlementDataConverterTest.java new file mode 100644 index 000000000..5caa648bc --- /dev/null +++ b/jsowell-pile/src/test/java/com/jsowell/pile/util/SettlementDataConverterTest.java @@ -0,0 +1,60 @@ +package com.jsowell.pile.util; + +import com.jsowell.common.core.domain.ykc.RealTimeMonitorData; +import com.jsowell.common.core.domain.ykc.TransactionRecordsData; +import com.jsowell.pile.domain.OrderBasicInfo; +import org.junit.Assert; +import org.junit.Test; + +import java.util.Date; + +/** + * SettlementDataConverter 单元测试 + */ +public class SettlementDataConverterTest { + + @Test + public void testConvertKeepsPileAmountWithoutTouBreakdown() { + OrderBasicInfo order = new OrderBasicInfo(); + order.setOrderCode("O001"); + order.setTransactionCode("T001"); + order.setPileSn("32010600000001"); + order.setConnectorCode("01"); + order.setChargeStartTime(new Date(System.currentTimeMillis() - 3600_000L)); + order.setChargeEndTime(new Date(System.currentTimeMillis() - 1200_000L)); + + RealTimeMonitorData realTime = new RealTimeMonitorData(); + realTime.setTransactionCode("T001"); + realTime.setChargingDegree("12.3456"); + realTime.setChargingAmount("23.45"); + realTime.setLossDegree("12.3456"); + realTime.setDateTime("2026-08-11 12:00:00"); + + TransactionRecordsData data = SettlementDataConverter.convertFromRealTimeData(order, realTime); + Assert.assertEquals("23.45", data.getConsumptionAmount()); + Assert.assertEquals("12.3456", data.getTotalElectricity()); + Assert.assertNull("不应填充尖段电量,否则 settleOrder 会改用平台计价", data.getSharpUsedElectricity()); + Assert.assertNull(data.getPeakUsedElectricity()); + Assert.assertNull(data.getFlatUsedElectricity()); + Assert.assertNull(data.getValleyUsedElectricity()); + Assert.assertEquals("无交易记录自动结算", data.getStopReasonMsg()); + } + + @Test + public void testIsValidForSettlement() { + RealTimeMonitorData ok = new RealTimeMonitorData(); + ok.setChargingDegree("1.0"); + ok.setChargingAmount("1.2"); + Assert.assertTrue(SettlementDataConverter.isValidForSettlement(ok)); + + RealTimeMonitorData zeroAmount = new RealTimeMonitorData(); + zeroAmount.setChargingDegree("1.0"); + zeroAmount.setChargingAmount("0"); + Assert.assertFalse(SettlementDataConverter.isValidForSettlement(zeroAmount)); + + RealTimeMonitorData zeroDegree = new RealTimeMonitorData(); + zeroDegree.setChargingDegree("0"); + zeroDegree.setChargingAmount("1.2"); + Assert.assertFalse(SettlementDataConverter.isValidForSettlement(zeroDegree)); + } +} diff --git a/jsowell-pile/src/test/java/com/jsowell/pile/util/TouElectricityEstimatorTest.java b/jsowell-pile/src/test/java/com/jsowell/pile/util/TouElectricityEstimatorTest.java new file mode 100644 index 000000000..6bd57a740 --- /dev/null +++ b/jsowell-pile/src/test/java/com/jsowell/pile/util/TouElectricityEstimatorTest.java @@ -0,0 +1,62 @@ +package com.jsowell.pile.util; + +import com.jsowell.common.core.domain.ykc.RealTimeMonitorData; +import com.jsowell.pile.vo.web.BillingTemplateVO; +import org.junit.Assert; +import org.junit.Test; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.Collections; + +public class TouElectricityEstimatorTest { + + @Test + public void emptySamples() { + TouElectricityEstimateResult r = TouElectricityEstimator.estimate(Collections.emptyList(), null); + Assert.assertEquals("no_realtime_samples", r.getNote()); + Assert.assertEquals(0, r.getSampleCount()); + } + + @Test + public void splitByPeriodRules() { + BillingTemplateVO template = new BillingTemplateVO(); + // 08:00-12:00 峰;其余若未配置则 unmatched 记入平 + template.setPeakApplyDate("08:00:00-12:00:00"); + template.setFlatApplyDate("00:00:00-08:00:00,12:00:00-23:59:59"); + + RealTimeMonitorData s1 = sample("2026-08-12 07:50:00", "1.0000"); + RealTimeMonitorData s2 = sample("2026-08-12 08:10:00", "2.0000"); // delta 1.0 -> peak? end 08:10 peak + // wait: delta from 07:50 to 08:10 attributed to 08:10 -> peak + RealTimeMonitorData s3 = sample("2026-08-12 09:00:00", "5.0000"); // delta 3.0 peak + RealTimeMonitorData s4 = sample("2026-08-12 12:30:00", "6.5000"); // delta 1.5 flat (12:30 in flat) + + TouElectricityEstimateResult r = TouElectricityEstimator.estimate(Arrays.asList(s1, s2, s3, s4), template); + Assert.assertEquals(4, r.getSampleCount()); + Assert.assertEquals(0, new BigDecimal("4.0000").compareTo(r.getPeak().setScale(4))); // 1+3 + Assert.assertEquals(0, new BigDecimal("1.5000").compareTo(r.getFlat().setScale(4))); + Assert.assertEquals(0, new BigDecimal("5.5000").compareTo(r.getTotalFromDeltas().setScale(4))); + Assert.assertEquals(0, new BigDecimal("6.5000").compareTo(r.getTotalFromLastSample().setScale(4))); + Assert.assertEquals("ok", r.getNote()); + } + + @Test + public void negativeDeltaIgnored() { + BillingTemplateVO template = new BillingTemplateVO(); + template.setFlatApplyDate("00:00:00-23:59:59"); + RealTimeMonitorData s1 = sample("2026-08-12 10:00:00", "2.0000"); + RealTimeMonitorData s2 = sample("2026-08-12 10:01:00", "1.5000"); // neg + RealTimeMonitorData s3 = sample("2026-08-12 10:02:00", "3.0000"); // +1.5 from 1.5 + + TouElectricityEstimateResult r = TouElectricityEstimator.estimate(Arrays.asList(s1, s2, s3), template); + Assert.assertEquals(1, r.getNegativeOrZeroDeltaCount()); + Assert.assertEquals(0, new BigDecimal("1.5000").compareTo(r.getFlat().setScale(4))); + } + + private static RealTimeMonitorData sample(String dt, String degree) { + RealTimeMonitorData d = new RealTimeMonitorData(); + d.setDateTime(dt); + d.setChargingDegree(degree); + return d; + } +}