mirror of
https://codeup.aliyun.com/67c68d4e484ca2f0a13ac3c1/ydc/jsowell-charger-web.git
synced 2026-08-13 01:33:42 +08:00
fix: 修复无交易记录自动结算并增加分时电量观测对比
复用 settleOrder 主路径纠正状态/锁/实时数据取值等问题,补充 mock 单测与 sys_job SQL;交易记录到达时按实时曲线估算尖峰平谷仅打日志对比,不参与结算。
This commit is contained in:
60
docs/sql/auto_settle_no_transaction_sys_job.sql
Normal file
60
docs/sql/auto_settle_no_transaction_sys_job.sql
Normal file
@@ -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()';
|
||||
@@ -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<Long> 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<Long> getGrayscaleStationIds() {
|
||||
return grayscaleStationIds;
|
||||
}
|
||||
|
||||
public void setGrayscaleStationIds(List<Long> 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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 异步推送第三方平台订单信息
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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<Long> grayscaleStationIds;
|
||||
private List<Long> 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);
|
||||
}
|
||||
|
||||
@@ -49,19 +49,6 @@ public interface OrderBasicInfoMapper {
|
||||
@Param("stationIds") List<Long> 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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<Long> grayscaleStationIds = autoSettleConfig.isGrayscaleEnabled() ?
|
||||
autoSettleConfig.getGrayscaleStationIds() : null;
|
||||
LocalDateTime cutoffTime = LocalDateTime.now().minusMinutes(timeoutMinutes);
|
||||
List<Long> grayscaleStationIds = autoSettleConfig.isGrayscaleEnabled()
|
||||
? autoSettleConfig.getGrayscaleStationIds() : null;
|
||||
|
||||
// 4. 查询待结算订单
|
||||
List<OrderBasicInfo> 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<RealTimeMonitorData> 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<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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 处理自动结算的退款
|
||||
* 自动结算统一告警出口。
|
||||
* <p>当前落 ERROR/WARN 日志;后续对接钉钉/企微/邮件只改这里,避免业务分支散落通知代码。</p>
|
||||
*/
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* 将实时监测数据转换为结算用交易记录数据。
|
||||
* <p>
|
||||
* 重要:不要填充尖峰平谷分时电量。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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3770,49 +3770,38 @@
|
||||
</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 = '3' -- 待结算
|
||||
AND pay_status = '1' -- 已支付
|
||||
AND (transaction_code IS NULL OR transaction_code = '') -- 无交易流水号
|
||||
AND charge_end_time IS NOT NULL
|
||||
AND charge_end_time <![CDATA[ < ]]> #{cutoffTime} -- 停止超过阈值时间
|
||||
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 charge_end_time ASC
|
||||
ORDER BY COALESCE(charge_end_time, charge_start_time) ASC
|
||||
<if test="limit > 0">
|
||||
LIMIT #{limit}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<!-- 使用乐观锁更新订单(用于自动结算) -->
|
||||
<update id="updateOrderWithOptimisticLock">
|
||||
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}
|
||||
<choose>
|
||||
<when test="expectedSettlementTime == null">
|
||||
AND settlement_time IS NULL
|
||||
</when>
|
||||
<otherwise>
|
||||
AND settlement_time = #{expectedSettlementTime}
|
||||
</otherwise>
|
||||
</choose>
|
||||
</update>
|
||||
</mapper>
|
||||
|
||||
@@ -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测试通过");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user