Merge branch 'feature/auto-settle-no-transaction' into dev

This commit is contained in:
jsowell
2026-08-12 16:19:59 +08:00
25 changed files with 3901 additions and 23 deletions

View File

@@ -61,6 +61,13 @@
<artifactId>junit</artifactId>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>

View File

@@ -0,0 +1,83 @@
package com.jsowell.pile.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* 无交易记录自动结算配置
*
* 配置前缀auto-settle
*/
@Data
@Component
@ConfigurationProperties(prefix = "auto-settle")
public class AutoSettleConfig {
/**
* 功能总开关,默认关闭
*/
private Boolean enabled = false;
/**
* 停止超时阈值(分钟)
*/
private Integer timeoutMinutes = 10;
/**
* 定时任务 cron仅作文档/配置同步,实际调度以 sys_job 为准)
*/
private String interval = "0 */10 * * * ?";
/**
* 灰度站点 ID 列表;空表示全量
*/
private List<Long> grayscaleStationIds = new ArrayList<>();
/**
* 单次扫描订单数量上限
*/
private Integer batchSize = 100;
/**
* 异常金额阈值倍数chargingAmount > payAmount * ratio 时跳过
*/
private Double amountThresholdRatio = 1.5;
/**
* 实时数据新鲜度阈值(分钟)。仅兜底路径(无 chargeEndTime使用
*/
private Integer dataFreshnessMinutes = 30;
/**
* 告警开关
*/
private Boolean alertEnabled = true;
/**
* Redis 分布式锁超时时间(秒)
*/
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;
}
if (stationId == null) {
return false;
}
return grayscaleStationIds.contains(stationId);
}
}

View File

@@ -31,6 +31,25 @@ public interface OrderBasicInfoMapper {
*/
int deleteByPrimaryKey(Integer id);
/**
* 查询待自动结算的订单列表
* 条件:
* 1. 订单状态为待结算
* 2. 无交易流水号transactionCode为空
* 3. 充电结束时间在指定时间之前stopTime < cutoffTime
* 4. 支付状态为已支付
* 5. 灰度站点范围内(如果指定)
*
* @param cutoffTime 截止时间(当前时间 - 超时阈值)
* @param stationIds 灰度站点ID列表为空则不限制站点
* @param limit 查询数量限制
* @return 待自动结算的订单列表
*/
List<OrderBasicInfo> selectPendingAutoSettleOrders(@Param("cutoffTime") LocalDateTime cutoffTime,
@Param("stationIds") List<Long> stationIds,
@Param("limit") int limit);
/**
* insert record to table
*

View File

@@ -753,10 +753,16 @@ public interface OrderBasicInfoService{
List<String> merchantIdList, List<String> stationIdList);
/**
* 大数据平台-今日充电电量Java汇总
* 大数据平台-今日充电电量(Java汇总)
*
* @return 今日已完成订单的充电电量合计kWh
* @return 今日已完成订单的充电电量合计(kWh)
*/
java.math.BigDecimal getTodayElectricity(String startTime, String endTime,
List<String> merchantIdList, List<String> stationIdList);
/**
* 无交易记录自动结算
* 扫描停止超时的待结算订单,如果有实时数据但无交易记录,则自动结算
*/
void autoSettleOrdersWithoutTransactionRecord();
}

View File

@@ -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<RealTimeMonitorData> 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));
}
}

View File

@@ -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;
@@ -213,6 +215,9 @@ public class OrderBasicInfoServiceImpl implements OrderBasicInfoService {
@Autowired
private com.jsowell.pile.mq.producer.PointsRewardProducer pointsRewardProducer;
@Autowired
private com.jsowell.pile.config.AutoSettleConfig autoSettleConfig;
@Override
public int deleteByPrimaryKey(Integer id) {
return orderBasicInfoMapper.deleteByPrimaryKey(id);
@@ -7435,4 +7440,345 @@ public class OrderBasicInfoServiceImpl implements OrderBasicInfoService {
}
return total;
}
@Override
public void autoSettleOrdersWithoutTransactionRecord() {
if (!Boolean.TRUE.equals(autoSettleConfig.getEnabled())) {
logger.debug("【无交易记录自动结算】功能未启用,跳过执行");
return;
}
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();
logger.info("【无交易记录自动结算】开始执行,超时阈值:{}分钟,批次大小:{}", timeoutMinutes, batchSize);
LocalDateTime cutoffTime = LocalDateTime.now().minusMinutes(timeoutMinutes);
List<Long> grayscaleStationIds = autoSettleConfig.isGrayscaleEnabled()
? autoSettleConfig.getGrayscaleStationIds() : null;
List<OrderBasicInfo> pendingOrders = orderBasicInfoMapper.selectPendingAutoSettleOrders(
cutoffTime, grayscaleStationIds, batchSize);
if (CollectionUtils.isEmpty(pendingOrders)) {
logger.info("【无交易记录自动结算】本次扫描未发现符合条件的订单");
return;
}
logger.info("【无交易记录自动结算】本次扫描到 {} 条待处理订单", pendingOrders.size());
int successCount = 0;
int skipCount = 0;
int failCount = 0;
for (OrderBasicInfo order : pendingOrders) {
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 {
Boolean locked = redisCache.setnx(lockKey, requestId, lockTimeoutSeconds);
lockAcquired = Boolean.TRUE.equals(locked);
if (!lockAcquired) {
logger.warn("【无交易记录自动结算】订单 {} 正在被其他线程处理,跳过", orderCode);
skipCount++;
continue;
}
OrderBasicInfo latestOrder = orderBasicInfoMapper.selectOrderBasicInfoById(Long.valueOf(order.getId()));
if (latestOrder == null
|| !StringUtils.equals(OrderStatusEnum.STAY_SETTLEMENT.getValue(), latestOrder.getOrderStatus())
|| latestOrder.getSettlementTime() != null) {
logger.warn("【无交易记录自动结算】订单 {} 状态已变更或已结算,跳过", orderCode);
skipCount++;
continue;
}
if (StringUtils.isBlank(latestOrder.getTransactionCode())) {
logger.warn("【无交易记录自动结算】订单 {} 最新数据缺少交易流水号,跳过", orderCode);
skipCount++;
continue;
}
transactionCode = latestOrder.getTransactionCode();
Long stationIdLong = null;
if (StringUtils.isNotBlank(latestOrder.getStationId())) {
try {
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<RealTimeMonitorData> 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;
}
}
if (!SettlementDataConverter.isValidForSettlement(lastRealTimeData)) {
alertAutoSettle("数据异常", String.format(
"订单号:%s站点ID%s%sdegree%samount%s",
orderCode, latestOrder.getStationId(), latestOrder.getPileSn(),
lastRealTimeData.getChargingDegree(), lastRealTimeData.getChargingAmount()));
skipCount++;
continue;
}
BigDecimal chargingAmountDecimal = new BigDecimal(lastRealTimeData.getChargingAmount());
if (latestOrder.getPayAmount() != null
&& latestOrder.getPayAmount().compareTo(BigDecimal.ZERO) > 0) {
BigDecimal threshold = latestOrder.getPayAmount()
.multiply(BigDecimal.valueOf(amountThresholdRatio));
if (chargingAmountDecimal.compareTo(threshold) > 0) {
alertAutoSettle("金额异常", String.format(
"订单号:%s站点ID%s充电桩%s实时充电金额%s 元,已支付金额:%s 元,阈值倍数:%s充电电量%s kWh",
orderCode,
latestOrder.getStationId(),
latestOrder.getPileSn(),
chargingAmountDecimal.toPlainString(),
latestOrder.getPayAmount().toPlainString(),
amountThresholdRatio,
lastRealTimeData.getChargingDegree()));
skipCount++;
continue;
}
}
TransactionRecordsData settlementData = SettlementDataConverter.convertFromRealTimeData(
latestOrder, lastRealTimeData);
logger.info("【无交易记录自动结算】开始结算订单:{},充电电量:{}kWh结算金额{}元",
orderCode, settlementData.getTotalElectricity(), settlementData.getConsumptionAmount());
String mode = pileMerchantInfoService.getDelayModeByMerchantId(latestOrder.getMerchantId());
AbstractProgramLogic orderLogic = ProgramLogicFactory.getProgramLogic(mode);
if (orderLogic == null) {
alertAutoSettle("配置异常", String.format(
"订单号:%s 未找到结算逻辑merchantId%smode%s",
orderCode, latestOrder.getMerchantId(), mode));
failCount++;
continue;
}
// 复用正常结算主路径(金额计算、落库、退款、解锁、实时数据落库等)
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());
}
} catch (Exception e) {
failCount++;
alertAutoSettle("处理异常", String.format(
"订单号:%s站点ID%s充电桩%s异常信息%s",
orderCode, order.getStationId(), order.getPileSn(), e.getMessage()), e);
} finally {
if (lockAcquired) {
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);
}
}
}
}
logger.info("【无交易记录自动结算】执行完成,成功:{},跳过:{},失败:{}",
successCount, skipCount, failCount);
}
/**
* 自动结算统一告警出口。
* <p>当前落 ERROR/WARN 日志;后续对接钉钉/企微/邮件只改这里,避免业务分支散落通知代码。</p>
*/
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);
}
// TODO 对接现有告警通道(钉钉/企微/邮件)时仅在此处扩展
} else if (error != null) {
logger.warn(fullMessage, error);
} else {
logger.warn(fullMessage);
}
}
private RealTimeMonitorData selectLatestRealTimeData(List<RealTimeMonitorData> 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;
}
}
}

View File

@@ -0,0 +1,145 @@
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 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.util.Date;
/**
* 将实时监测数据转换为结算用交易记录数据。
* <p>
* 重要不要填充尖峰平谷分时电量。settleOrder 在 sumUsedElectricity=0 时
* 会保留 consumptionAmount与人工结算无交易记录场景一致金额以桩端为准
*/
public final class SettlementDataConverter {
private static final Logger logger = LoggerFactory.getLogger(SettlementDataConverter.class);
private SettlementDataConverter() {
}
/**
* 从订单 + 最后一条实时数据构造结算数据
*/
public static TransactionRecordsData convertFromRealTimeData(OrderBasicInfo order,
RealTimeMonitorData realTimeData) {
if (order == null || realTimeData == null) {
throw new IllegalArgumentException("order/realTimeData 不能为空");
}
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);
String chargingDegree = defaultIfBlank(realTimeData.getChargingDegree(), "0.0000");
String chargingAmount = defaultIfBlank(realTimeData.getChargingAmount(), "0.0000");
String lossDegree = defaultIfBlank(realTimeData.getLossDegree(), chargingDegree);
// 仅设置总量,不填尖峰平谷,确保 settleOrder 以桩端金额为准
data.setTotalElectricity(chargingDegree);
data.setPlanLossTotalElectricity(lossDegree);
data.setConsumptionAmount(chargingAmount);
data.setStopReasonCode("FF");
data.setStopReasonMsg("无交易记录自动结算");
data.setTransactionIdentifier("00");
data.setVinCode("");
data.setLogicCard("");
logger.info("实时数据转换为结算数据完成, orderCode:{}, transactionCode:{}, totalElectricity:{}, consumptionAmount:{}",
order.getOrderCode(), data.getTransactionCode(), data.getTotalElectricity(), data.getConsumptionAmount());
return data;
}
/**
* 自动结算前的实时数据有效性校验
*/
public static boolean isValidForSettlement(RealTimeMonitorData realTimeData) {
if (realTimeData == null) {
logger.warn("实时数据为空");
return false;
}
String chargingDegree = realTimeData.getChargingDegree();
String chargingAmount = realTimeData.getChargingAmount();
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 {
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:{}",
realTimeData.getTransactionCode(), chargingAmount);
return false;
}
} catch (Exception e) {
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;
}
}

View File

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

View File

@@ -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;
/**
* 用实时监测累计电量差分,按计费模板时段归入尖峰平谷。
* <p>仅用于观测对比,不参与任何结算。</p>
*/
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<RealTimeMonitorData> rawSamples,
BillingTemplateVO billingTemplate) {
TouElectricityEstimateResult result = new TouElectricityEstimateResult();
if (CollectionUtils.isEmpty(rawSamples)) {
result.setNote("no_realtime_samples");
return result;
}
List<Sample> 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<Sample> 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<PeriodWindow> 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&lt;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);
}
}
}

View File

@@ -3769,4 +3769,39 @@
</foreach>
</if>
</select>
<!-- 查询待自动结算的订单列表(无交易记录自动结算)
说明:
1) order_status=2 待结算(不是 3 待补缴)
2) 启动时已有 transaction_code“无交易记录”指未完成结算不是流水号为空
3) 双依据预筛选:
- charge_end_time 已超过截止时间
- 或无 charge_end_time但 charge_start_time 已超过截止时间Java 再用实时数据时间兜底确认)
-->
<select id="selectPendingAutoSettleOrders" resultMap="BaseResultMap">
SELECT
<include refid="Base_Column_List"/>
FROM order_basic_info
WHERE del_flag = '0'
AND order_status = '2'
AND pay_status IN ('1', '2')
AND settlement_time IS NULL
AND transaction_code IS NOT NULL
AND transaction_code != ''
AND (
(charge_end_time IS NOT NULL AND charge_end_time <![CDATA[ <= ]]> #{cutoffTime})
OR (charge_end_time IS NULL AND charge_start_time IS NOT NULL AND charge_start_time <![CDATA[ <= ]]> #{cutoffTime})
)
<if test="stationIds != null and stationIds.size() > 0">
AND station_id IN
<foreach collection="stationIds" item="stationId" open="(" separator="," close=")">
#{stationId}
</foreach>
</if>
ORDER BY COALESCE(charge_end_time, charge_start_time) ASC
<if test="limit > 0">
LIMIT #{limit}
</if>
</select>
</mapper>

View File

@@ -0,0 +1,99 @@
package com.jsowell.pile.config;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
* AutoSettleConfig 配置类单元测试
*
* @author jsowell
* @date 2026-08-11
*/
public class AutoSettleConfigTest {
private AutoSettleConfig config;
@Before
public void setUp() {
config = new AutoSettleConfig();
}
@Test
public void testDefaultValues() {
System.out.println("【测试1】默认值测试");
Assert.assertFalse("功能开关默认应该是关闭", config.getEnabled());
Assert.assertEquals("超时阈值默认10分钟", Integer.valueOf(10), config.getTimeoutMinutes());
Assert.assertEquals("批次大小默认100", Integer.valueOf(100), config.getBatchSize());
Assert.assertEquals("金额阈值比例默认1.5", Double.valueOf(1.5), config.getAmountThresholdRatio());
Assert.assertEquals("数据新鲜度默认30分钟", Integer.valueOf(30), config.getDataFreshnessMinutes());
Assert.assertTrue("告警开关默认开启", config.getAlertEnabled());
Assert.assertEquals("锁超时默认60秒", Integer.valueOf(60), config.getLockTimeoutSeconds());
Assert.assertEquals("默认cron表达式", "0 */10 * * * ?", config.getInterval());
System.out.println("✅ 默认值测试通过");
}
@Test
public void testGrayscaleDisabled() {
System.out.println("\n【测试2】灰度未启用测试");
config.setGrayscaleStationIds(null);
Assert.assertFalse("灰度站点为null应该未启用", config.isGrayscaleEnabled());
config.setGrayscaleStationIds(Collections.emptyList());
Assert.assertFalse("灰度站点为空列表,应该未启用", config.isGrayscaleEnabled());
// 未启用灰度时,所有站点都应该在范围内
Assert.assertTrue("未启用灰度站点1应在范围内", config.isStationInGrayscale(1L));
Assert.assertTrue("未启用灰度站点999应在范围内", config.isStationInGrayscale(999L));
System.out.println("✅ 灰度未启用测试通过");
}
@Test
public void testGrayscaleEnabled() {
System.out.println("\n【测试3】灰度已启用测试");
List<Long> stationIds = Arrays.asList(1001L, 1002L, 1003L);
config.setGrayscaleStationIds(stationIds);
Assert.assertTrue("灰度站点列表非空,应该已启用", config.isGrayscaleEnabled());
Assert.assertTrue("站点1001在灰度列表中", config.isStationInGrayscale(1001L));
Assert.assertTrue("站点1002在灰度列表中", config.isStationInGrayscale(1002L));
Assert.assertTrue("站点1003在灰度列表中", config.isStationInGrayscale(1003L));
Assert.assertFalse("站点9999不在灰度列表中", config.isStationInGrayscale(9999L));
System.out.println("✅ 灰度已启用测试通过");
}
@Test
public void testSettersAndGetters() {
System.out.println("\n【测试4】Setter/Getter测试");
config.setEnabled(true);
config.setTimeoutMinutes(20);
config.setInterval("0 */5 * * * ?");
config.setBatchSize(200);
config.setAmountThresholdRatio(2.0);
config.setDataFreshnessMinutes(60);
config.setAlertEnabled(false);
config.setLockTimeoutSeconds(600);
Assert.assertTrue(config.getEnabled());
Assert.assertEquals(Integer.valueOf(20), config.getTimeoutMinutes());
Assert.assertEquals("0 */5 * * * ?", config.getInterval());
Assert.assertEquals(Integer.valueOf(200), config.getBatchSize());
Assert.assertEquals(Double.valueOf(2.0), config.getAmountThresholdRatio());
Assert.assertEquals(Integer.valueOf(60), config.getDataFreshnessMinutes());
Assert.assertFalse(config.getAlertEnabled());
Assert.assertEquals(Integer.valueOf(600), config.getLockTimeoutSeconds());
System.out.println("✅ Setter/Getter测试通过");
}
}

View File

@@ -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<TransactionRecordsData> 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;
}
}

View File

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

View File

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