ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TCardChargingOrderServiceImpl.java
@@ -78,7 +78,6 @@ import java.util.Objects; import java.util.Optional; import java.util.concurrent.TimeUnit; import java.util.function.Function; import java.util.stream.Collectors; @Service @@ -852,29 +851,36 @@ return R.ok("订单已关闭,跳过结算"); } // 分时电量(以平台快照计价,V0.2 定稿) Map<Integer, BigDecimal> stageElectricity = TransactionStageParser.parseStageElectricity(vo); BigDecimal totalElectricity = stageElectricity.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add); List<TransactionStageParser.PeriodElectricity> reportedPeriods = TransactionStageParser.parsePeriodElectricity(vo); BigDecimal totalElectricity = reportedPeriods.stream() .map(TransactionStageParser.PeriodElectricity::getElectricity) .reduce(BigDecimal.ZERO, BigDecimal::add); if (totalElectricity.compareTo(BigDecimal.ZERO) <= 0 && vo.getTotal_electricity() != null) { totalElectricity = vo.getTotal_electricity(); } // 快照明细(1=尖 2=峰 3=平 4=谷) Map<Integer, TCardAccountingStrategyDetailOrder> snapshotByType = cardAccountingStrategyDetailOrderService List<TCardAccountingStrategyDetailOrder> snapshots = new ArrayList<>(cardAccountingStrategyDetailOrderService .lambdaQuery().eq(TCardAccountingStrategyDetailOrder::getChargingOrderId, order.getId()) .list().stream() .filter(item -> item.getType() != null) .collect(Collectors.toMap(TCardAccountingStrategyDetailOrder::getType, Function.identity(), (a, b) -> a)); .list()); snapshots.removeIf(item -> item.getType() == null); snapshots.sort(Comparator.comparing(TCardAccountingStrategyDetailOrder::getStartTime, Comparator.nullsLast(String::compareTo))); BigDecimal electrovalenceTotal = BigDecimal.ZERO; BigDecimal serviceChargeTotal = BigDecimal.ZERO; List<TCardChargingOrderAccountingStrategy> details = new ArrayList<>(); for (Map.Entry<Integer, BigDecimal> stage : stageElectricity.entrySet()) { TCardAccountingStrategyDetailOrder snapshot = snapshotByType.get(stage.getKey()); BigDecimal capacity = stage.getValue(); BigDecimal electricPrice = snapshot == null || snapshot.getElectrovalence() == null ? BigDecimal.ZERO : snapshot.getElectrovalence(); BigDecimal servicePrice = snapshot == null || snapshot.getServiceCharge() == null ? BigDecimal.ZERO : snapshot.getServiceCharge(); BigDecimal originalServicePrice = snapshot == null || snapshot.getCostServiceCharge() == null // 严格对齐 CD:遍历按 start_time 排序后的策略,第 N 段只读取该策略类型对应的 chargeN。 for (int snapshotIndex = 0; snapshotIndex < snapshots.size(); snapshotIndex++) { TCardAccountingStrategyDetailOrder snapshot = snapshots.get(snapshotIndex); BigDecimal capacity = TransactionStageParser.getPeriodElectricity( vo, snapshot.getType(), snapshotIndex + 1); if (capacity == null || capacity.compareTo(BigDecimal.ZERO) <= 0) { continue; } BigDecimal electricPrice = snapshot.getElectrovalence() == null ? BigDecimal.ZERO : snapshot.getElectrovalence(); BigDecimal servicePrice = snapshot.getServiceCharge() == null ? BigDecimal.ZERO : snapshot.getServiceCharge(); BigDecimal originalServicePrice = snapshot.getCostServiceCharge() == null ? BigDecimal.ZERO : snapshot.getCostServiceCharge(); BigDecimal electricAmount = capacity.multiply(electricPrice).setScale(2, BigDecimal.ROUND_HALF_UP); BigDecimal serviceAmount = capacity.multiply(servicePrice).setScale(2, BigDecimal.ROUND_HALF_UP); @@ -884,7 +890,7 @@ TCardChargingOrderAccountingStrategy detail = new TCardChargingOrderAccountingStrategy(); detail.setChargingOrderId(order.getId()); detail.setType(stage.getKey()); detail.setType(snapshot.getType()); detail.setElectrovalence(electricPrice); detail.setServiceCharge(servicePrice); detail.setCostServiceCharge(originalServicePrice); @@ -904,21 +910,20 @@ detail.setCreateTime(LocalDateTime.now()); details.add(detail); } if (snapshotByType.isEmpty()) { if (snapshots.isEmpty()) { log.error("[SD结算] 订单无计费策略快照,按0价结算并需人工核对 code={} totalElectricity={}", code, totalElectricity); } // 与 CD 充电 TaskUtil.endOrder 同口径:details 按 type 升序对齐业务时段顺序, // 首段 start_time 用订单实际开始时间的 HH:mm 覆盖(对应 CD 的时段内首条监控数据 HH:mm), // 末段 end_time 用订单实际结束时间的 HH:mm 覆盖(对应 CD 的时段内末条监控数据 HH:mm)。 // 若 order.startTime/endTime 为空(结算事务内才回填),则跳过覆盖以保留快照时段位兜底。 // 首段使用订单实际开始时间;订单尚未写入结束时间时,末段使用交易记录设备结束时间。 LocalDateTime settlementStartTime = order.getStartTime() != null ? order.getStartTime() : parseDeviceTime(vo.getStart_time(), null); LocalDateTime settlementEndTime = parseDeviceTime(vo.getEnd_time(), order.getEndTime()); if (!details.isEmpty()) { details.sort(Comparator.comparingInt(TCardChargingOrderAccountingStrategy::getType)); DateTimeFormatter hhmm = DateTimeFormatter.ofPattern("HH:mm"); if (order.getStartTime() != null) { details.get(0).setStartTime(order.getStartTime().format(hhmm)); if (settlementStartTime != null) { details.get(0).setStartTime(settlementStartTime.format(hhmm)); } if (order.getEndTime() != null) { details.get(details.size() - 1).setEndTime(order.getEndTime().format(hhmm)); if (settlementEndTime != null) { details.get(details.size() - 1).setEndTime(settlementEndTime.format(hhmm)); } } BigDecimal orderAmount = electrovalenceTotal.add(serviceChargeTotal); @@ -1160,11 +1165,30 @@ // (结算锁/状态幂等/扣款幂等/解锁/复位全部复用,满足文档13.3"所有路径汇聚同一结算入口") BigDecimal capacity = order.getChargingCapacity() != null ? order.getChargingCapacity() : Optional.ofNullable(order.getElectricity()).orElse(BigDecimal.ZERO); int flatPeriodIndex = resolveCompensationFlatPeriodIndex(order.getId()); TransactionRecordMessageVO vo = new TransactionRecordMessageVO(); vo.setTransaction_serial_number(order.getCode()); vo.setFlat_charge1(capacity); TransactionStageParser.setPeriodElectricity(vo, 3, flatPeriodIndex, capacity); vo.setTotal_electricity(capacity); log.warn("[SD补偿] 充电超时兜底结算 code={} 按平段计价 capacity={}", order.getCode(), capacity); log.warn("[SD补偿] 充电超时兜底结算 code={} 按平段计价 periodIndex={} capacity={}", order.getCode(), flatPeriodIndex, capacity); cardEndChargeBillingCharge(vo); } private int resolveCompensationFlatPeriodIndex(Long orderId) { List<TCardAccountingStrategyDetailOrder> snapshots = Optional.ofNullable(cardAccountingStrategyDetailOrderService .lambdaQuery().eq(TCardAccountingStrategyDetailOrder::getChargingOrderId, orderId).list()) .orElse(Collections.emptyList()).stream() .filter(item -> item.getType() != null) .sorted(Comparator.comparing(TCardAccountingStrategyDetailOrder::getStartTime, Comparator.nullsLast(String::compareTo))) .collect(Collectors.toList()); for (int i = 0; i < snapshots.size(); i++) { if (Integer.valueOf(3).equals(snapshots.get(i).getType())) { return i + 1; } } log.error("[SD补偿] 订单计费策略无平段,回退构造 flat_charge1 orderId={}", orderId); return 1; } } ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderServiceImpl.java
@@ -988,28 +988,36 @@ return R.ok("订单已关闭,跳过结算"); } // 分时电量(以平台快照计价) java.util.Map<Integer, BigDecimal> stageElectricity = TransactionStageParser.parseStageElectricity(vo); BigDecimal totalElectricity = stageElectricity.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add); List<TransactionStageParser.PeriodElectricity> reportedPeriods = TransactionStageParser.parsePeriodElectricity(vo); BigDecimal totalElectricity = reportedPeriods.stream() .map(TransactionStageParser.PeriodElectricity::getElectricity) .reduce(BigDecimal.ZERO, BigDecimal::add); if (totalElectricity.compareTo(BigDecimal.ZERO) <= 0 && vo.getTotal_electricity() != null) { totalElectricity = vo.getTotal_electricity(); } Map<Integer, TVinAccountingStrategyDetailOrder> snapshotByType = vinAccountingStrategyDetailOrderService List<TVinAccountingStrategyDetailOrder> snapshots = new ArrayList<>(vinAccountingStrategyDetailOrderService .lambdaQuery().eq(TVinAccountingStrategyDetailOrder::getChargingOrderId, order.getId()) .list().stream() .filter(item -> item.getType() != null) .collect(Collectors.toMap(TVinAccountingStrategyDetailOrder::getType, java.util.function.Function.identity(), (a, b) -> a)); .list()); snapshots.removeIf(item -> item.getType() == null); snapshots.sort(Comparator.comparing(TVinAccountingStrategyDetailOrder::getStartTime, Comparator.nullsLast(String::compareTo))); BigDecimal electrovalenceTotal = BigDecimal.ZERO; BigDecimal serviceChargeTotal = BigDecimal.ZERO; List<TVinChargingOrderAccountingStrategy> details = new ArrayList<>(); for (Map.Entry<Integer, BigDecimal> stage : stageElectricity.entrySet()) { TVinAccountingStrategyDetailOrder snapshot = snapshotByType.get(stage.getKey()); BigDecimal capacity = stage.getValue(); BigDecimal electricPrice = snapshot == null || snapshot.getElectrovalence() == null ? BigDecimal.ZERO : snapshot.getElectrovalence(); BigDecimal servicePrice = snapshot == null || snapshot.getServiceCharge() == null ? BigDecimal.ZERO : snapshot.getServiceCharge(); BigDecimal originalServicePrice = snapshot == null || snapshot.getCostServiceCharge() == null // 严格对齐 CD:遍历按 start_time 排序后的策略,第 N 段只读取该策略类型对应的 chargeN。 for (int snapshotIndex = 0; snapshotIndex < snapshots.size(); snapshotIndex++) { TVinAccountingStrategyDetailOrder snapshot = snapshots.get(snapshotIndex); BigDecimal capacity = TransactionStageParser.getPeriodElectricity( vo, snapshot.getType(), snapshotIndex + 1); if (capacity == null || capacity.compareTo(BigDecimal.ZERO) <= 0) { continue; } BigDecimal electricPrice = snapshot.getElectrovalence() == null ? BigDecimal.ZERO : snapshot.getElectrovalence(); BigDecimal servicePrice = snapshot.getServiceCharge() == null ? BigDecimal.ZERO : snapshot.getServiceCharge(); BigDecimal originalServicePrice = snapshot.getCostServiceCharge() == null ? BigDecimal.ZERO : snapshot.getCostServiceCharge(); BigDecimal electricAmount = capacity.multiply(electricPrice).setScale(2, BigDecimal.ROUND_HALF_UP); BigDecimal serviceAmount = capacity.multiply(servicePrice).setScale(2, BigDecimal.ROUND_HALF_UP); @@ -1019,7 +1027,7 @@ TVinChargingOrderAccountingStrategy detail = new TVinChargingOrderAccountingStrategy(); detail.setChargingOrderId(order.getId()); detail.setType(stage.getKey()); detail.setType(snapshot.getType()); detail.setElectrovalence(electricPrice); detail.setServiceCharge(servicePrice); detail.setCostServiceCharge(originalServicePrice); @@ -1039,20 +1047,20 @@ detail.setCreateTime(LocalDateTime.now()); details.add(detail); } if (snapshotByType.isEmpty()) { if (snapshots.isEmpty()) { log.error("[VD结算] 订单无计费策略快照,按0价结算并需人工核对 code={} totalElectricity={}", code, totalElectricity); } // 与 CD 充电 TaskUtil.endOrder 同口径:details 按 type 升序对齐业务时段顺序, // 首段 start_time 用订单实际开始时间的 HH:mm 覆盖(对应 CD 的时段内首条监控数据 HH:mm), // 末段 end_time 用订单实际结束时间的 HH:mm 覆盖(对应 CD 的时段内末条监控数据 HH:mm)。 // 首段使用订单实际开始时间;订单尚未写入结束时间时,末段使用交易记录设备结束时间。 LocalDateTime settlementStartTime = order.getStartTime() != null ? order.getStartTime() : parseDeviceTime(vo.getStart_time(), null); LocalDateTime settlementEndTime = parseDeviceTime(vo.getEnd_time(), order.getEndTime()); if (!details.isEmpty()) { details.sort(Comparator.comparingInt(TVinChargingOrderAccountingStrategy::getType)); DateTimeFormatter hhmm = DateTimeFormatter.ofPattern("HH:mm"); if (order.getStartTime() != null) { details.get(0).setStartTime(order.getStartTime().format(hhmm)); if (settlementStartTime != null) { details.get(0).setStartTime(settlementStartTime.format(hhmm)); } if (order.getEndTime() != null) { details.get(details.size() - 1).setEndTime(order.getEndTime().format(hhmm)); if (settlementEndTime != null) { details.get(details.size() - 1).setEndTime(settlementEndTime.format(hhmm)); } } BigDecimal orderAmount = electrovalenceTotal.add(serviceChargeTotal); @@ -1275,11 +1283,30 @@ // (结算锁/状态幂等/枪复位全部复用,满足文档13.3"所有路径汇聚同一结算入口") BigDecimal capacity = order.getChargingCapacity() != null ? order.getChargingCapacity() : Optional.ofNullable(order.getElectricity()).orElse(BigDecimal.ZERO); int flatPeriodIndex = resolveCompensationFlatPeriodIndex(order.getId()); TransactionRecordMessageVO vo = new TransactionRecordMessageVO(); vo.setTransaction_serial_number(order.getCode()); vo.setFlat_charge1(capacity); TransactionStageParser.setPeriodElectricity(vo, 3, flatPeriodIndex, capacity); vo.setTotal_electricity(capacity); log.warn("[VD补偿] 充电超时兜底结算 code={} 按平段计价 capacity={}", order.getCode(), capacity); log.warn("[VD补偿] 充电超时兜底结算 code={} 按平段计价 periodIndex={} capacity={}", order.getCode(), flatPeriodIndex, capacity); vinEndChargeBillingCharge(vo); } private int resolveCompensationFlatPeriodIndex(Long orderId) { List<TVinAccountingStrategyDetailOrder> snapshots = Optional.ofNullable(vinAccountingStrategyDetailOrderService .lambdaQuery().eq(TVinAccountingStrategyDetailOrder::getChargingOrderId, orderId).list()) .orElse(Collections.emptyList()).stream() .filter(item -> item.getType() != null) .sorted(Comparator.comparing(TVinAccountingStrategyDetailOrder::getStartTime, Comparator.nullsLast(String::compareTo))) .collect(Collectors.toList()); for (int i = 0; i < snapshots.size(); i++) { if (Integer.valueOf(3).equals(snapshots.get(i).getType())) { return i + 1; } } log.error("[VD补偿] 订单计费策略无平段,回退构造 flat_charge1 orderId={}", orderId); return 1; } } ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/util/TransactionStageParser.java
@@ -4,15 +4,17 @@ import java.lang.reflect.Field; import java.math.BigDecimal; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 设备交易记录(charging_pile_transaction_record)分时电量解析工具。 * <p> * 协议中尖/峰/平/谷四类时段电量分组上送(spike_chargeN / peak_chargeN / flat_chargeN / valley_chargeN,N=1..12), * 本工具按阶段类型汇总电量,供订单结算按平台快照单价计费使用。 * 本工具同时保留协议时段序号并提供按阶段类型汇总能力,供订单结算按平台快照单价计费使用。 * * @author codebuddy * @since 2026-09-09 @@ -26,7 +28,7 @@ private static final int MAX_GROUPS = 12; static { Map<Integer, String> prefix = new HashMap<>(8); Map<Integer, String> prefix = new LinkedHashMap<>(8); prefix.put(1, "spike"); prefix.put(2, "peak"); prefix.put(3, "flat"); @@ -45,33 +47,99 @@ * @return 阶段类型 -> 电量合计(无有效数据时返回空Map) */ public static Map<Integer, BigDecimal> parseStageElectricity(TransactionRecordMessageVO vo) { Map<Integer, BigDecimal> result = new HashMap<>(8); Map<Integer, BigDecimal> result = new LinkedHashMap<>(8); for (PeriodElectricity period : parsePeriodElectricity(vo)) { result.merge(period.getType(), period.getElectricity(), BigDecimal::add); } return result; } /** * 按协议计费时段序号解析电量。chargeN 中的 N 对应平台计费模型按开始时间排序后的第 N 段, * 不能只按阶段类型汇总,否则同一种类型在一天内出现多次时会匹配到错误价格。 * * @param vo 设备交易记录 * @return 按时段序号升序排列的有效电量 */ public static List<PeriodElectricity> parsePeriodElectricity(TransactionRecordMessageVO vo) { List<PeriodElectricity> result = new ArrayList<>(); if (vo == null) { return result; } for (Map.Entry<Integer, String> entry : TYPE_PREFIX.entrySet()) { BigDecimal total = readGroupTotal(vo, entry.getValue()); if (total != null && total.compareTo(BigDecimal.ZERO) > 0) { result.put(entry.getKey(), total); for (int periodIndex = 1; periodIndex <= MAX_GROUPS; periodIndex++) { for (Integer type : TYPE_PREFIX.keySet()) { BigDecimal value = getPeriodElectricity(vo, type, periodIndex); if (value != null && value.compareTo(BigDecimal.ZERO) > 0) { result.add(new PeriodElectricity(periodIndex, type, value)); } } } return result; } /** * 读取指定前缀分组的电量合计(spike_charge1..spike_charge12)。 * 读取指定策略类型在指定计费时段序号上的电量。 * CD 订单结算按排序后策略的类型读取同序号 chargeN,VD、SD 必须使用相同口径。 * * @param vo 设备交易记录 * @param type 阶段类型(1=尖,2=峰,3=平,4=谷) * @param periodIndex 计费时段序号(1..12) * @return 对应电量;字段无值或参数无效时返回 null */ private static BigDecimal readGroupTotal(TransactionRecordMessageVO vo, String prefix) { BigDecimal total = BigDecimal.ZERO; boolean hasValue = false; for (int i = 1; i <= MAX_GROUPS; i++) { BigDecimal value = readField(vo, prefix + "_charge" + i); if (value != null) { hasValue = true; total = total.add(value); } public static BigDecimal getPeriodElectricity(TransactionRecordMessageVO vo, Integer type, int periodIndex) { String prefix = TYPE_PREFIX.get(type); if (vo == null || prefix == null || periodIndex < 1 || periodIndex > MAX_GROUPS) { return null; } return hasValue ? total : null; return readField(vo, prefix + "_charge" + periodIndex); } /** * 向指定策略类型和计费时段序号写入电量,供无设备账单的补偿结算构造与策略位置一致的 chargeN。 * * @return 写入成功返回 true,参数无效或字段不存在返回 false */ public static boolean setPeriodElectricity(TransactionRecordMessageVO vo, Integer type, int periodIndex, BigDecimal electricity) { String prefix = TYPE_PREFIX.get(type); if (vo == null || prefix == null || periodIndex < 1 || periodIndex > MAX_GROUPS) { return false; } try { Field field = vo.getClass().getDeclaredField(prefix + "_charge" + periodIndex); field.setAccessible(true); field.set(vo, electricity); return true; } catch (NoSuchFieldException | IllegalAccessException ex) { return false; } } /** * 单个协议计费时段的电量。 */ public static final class PeriodElectricity { private final int periodIndex; private final int type; private final BigDecimal electricity; private PeriodElectricity(int periodIndex, int type, BigDecimal electricity) { this.periodIndex = periodIndex; this.type = type; this.electricity = electricity; } public int getPeriodIndex() { return periodIndex; } public int getType() { return type; } public BigDecimal getElectricity() { return electricity; } } private static BigDecimal readField(TransactionRecordMessageVO vo, String fieldName) { ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/CardVinOrderCompensationTest.java
@@ -35,6 +35,7 @@ import java.math.BigDecimal; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; @@ -46,8 +47,10 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -56,7 +59,8 @@ * SD/VD 定时补偿与结算锁行为测试(文档13.2/13.3): * 1) 启动超时关单(状态-1+结束方式0+解锁卡片+复位枪,沿用 t_charging_order 枚举约定); * 2) 结算入口并发锁抢占失败快速返回,不触达订单数据; * 3) 已结算订单重复账单幂等跳过,不再扣款/解锁。 * 3) 已结算订单重复账单幂等跳过,不再扣款/解锁; * 4) 超时补偿按排序后平段的真实序号构造 flat_chargeN。 */ class CardVinOrderCompensationTest { @@ -456,4 +460,74 @@ assertEquals("00:00", saved.get(0).getStartTime()); assertEquals("06:00", saved.get(0).getEndTime()); } @Test @SuppressWarnings("unchecked") void cardCompensationWritesElectricityToActualFlatPeriodIndex() { TCardChargingOrderServiceImpl service = spy(new TCardChargingOrderServiceImpl()); TCardAccountingStrategyDetailOrderService detailOrderService = mock(TCardAccountingStrategyDetailOrderService.class); ReflectionTestUtils.setField(service, "cardAccountingStrategyDetailOrderService", detailOrderService); TCardAccountingStrategyDetailOrder valley = new TCardAccountingStrategyDetailOrder(); valley.setType(4); valley.setStartTime("00:00"); TCardAccountingStrategyDetailOrder flat = new TCardAccountingStrategyDetailOrder(); flat.setType(3); flat.setStartTime("08:00"); LambdaQueryChainWrapper<TCardAccountingStrategyDetailOrder> queryWrapper = mock(LambdaQueryChainWrapper.class); when(detailOrderService.lambdaQuery()).thenReturn(queryWrapper); when(queryWrapper.eq(any(), any())).thenReturn(queryWrapper); when(queryWrapper.list()).thenReturn(Arrays.asList(flat, valley)); doReturn(R.ok()).when(service).cardEndChargeBillingCharge(any(TransactionRecordMessageVO.class)); TCardChargingOrder order = new TCardChargingOrder(); order.setId(1L); order.setCode("SD-COMPENSATION-SLOT-2"); order.setChargingCapacity(new BigDecimal("17.36")); service.compensationSettle(order); ArgumentCaptor<TransactionRecordMessageVO> captor = ArgumentCaptor.forClass(TransactionRecordMessageVO.class); verify(service).cardEndChargeBillingCharge(captor.capture()); TransactionRecordMessageVO settlement = captor.getValue(); assertEquals("SD-COMPENSATION-SLOT-2", settlement.getTransaction_serial_number()); assertEquals(null, settlement.getFlat_charge1()); assertEquals(0, new BigDecimal("17.36").compareTo(settlement.getFlat_charge2())); assertEquals(0, new BigDecimal("17.36").compareTo(settlement.getTotal_electricity())); } @Test @SuppressWarnings("unchecked") void vinCompensationWritesElectricityToActualFlatPeriodIndex() { TVinChargingOrderServiceImpl service = spy(new TVinChargingOrderServiceImpl()); TVinAccountingStrategyDetailOrderService detailOrderService = mock(TVinAccountingStrategyDetailOrderService.class); ReflectionTestUtils.setField(service, "vinAccountingStrategyDetailOrderService", detailOrderService); TVinAccountingStrategyDetailOrder valley = new TVinAccountingStrategyDetailOrder(); valley.setType(4); valley.setStartTime("00:00"); TVinAccountingStrategyDetailOrder flat = new TVinAccountingStrategyDetailOrder(); flat.setType(3); flat.setStartTime("08:00"); LambdaQueryChainWrapper<TVinAccountingStrategyDetailOrder> queryWrapper = mock(LambdaQueryChainWrapper.class); when(detailOrderService.lambdaQuery()).thenReturn(queryWrapper); when(queryWrapper.eq(any(), any())).thenReturn(queryWrapper); when(queryWrapper.list()).thenReturn(Arrays.asList(flat, valley)); doReturn(R.ok()).when(service).vinEndChargeBillingCharge(any(TransactionRecordMessageVO.class)); TVinChargingOrder order = new TVinChargingOrder(); order.setId(2L); order.setCode("VD-COMPENSATION-SLOT-2"); order.setChargingCapacity(new BigDecimal("9.87")); service.compensationSettle(order); ArgumentCaptor<TransactionRecordMessageVO> captor = ArgumentCaptor.forClass(TransactionRecordMessageVO.class); verify(service).vinEndChargeBillingCharge(captor.capture()); TransactionRecordMessageVO settlement = captor.getValue(); assertEquals("VD-COMPENSATION-SLOT-2", settlement.getTransaction_serial_number()); assertEquals(null, settlement.getFlat_charge1()); assertEquals(0, new BigDecimal("9.87").compareTo(settlement.getFlat_charge2())); assertEquals(0, new BigDecimal("9.87").compareTo(settlement.getTotal_electricity())); } } ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/CardVinSettlementTimeAlignmentTest.java
@@ -55,7 +55,7 @@ /** * VIN/刷卡充电结算明细开始/结束时间口径对齐测试(与 CD 充电 TaskUtil.endOrder 一致): * 1) 循环内:start_time/end_time 截取快照时段位 HH:mm; * 2) 循环外:details 按 type 升序排序,首段 start_time 用订单实际开始时间 HH:mm 覆盖, * 2) 循环外:details 按策略开始时间顺序生成,首段 start_time 用订单实际开始时间 HH:mm 覆盖, * 末段 end_time 用订单实际结束时间 HH:mm 覆盖; * 3) 订单实际时间缺失时保持快照时段位兜底。 * @@ -145,6 +145,50 @@ valley.setCostServiceCharge(new BigDecimal("0.4000")); return Arrays.asList(spike, flat, valley); } private List<TVinAccountingStrategyDetailOrder> vinSixPeriodSnapshots() { return Arrays.asList( vinSnapshot(948524, 4, "00:00", "08:00"), vinSnapshot(948525, 3, "08:00", "10:00"), vinSnapshot(948526, 2, "10:00", "12:00"), vinSnapshot(948527, 3, "12:00", "17:00"), vinSnapshot(948528, 2, "17:00", "22:00"), vinSnapshot(948529, 4, "22:00", "00:00")); } private TVinAccountingStrategyDetailOrder vinSnapshot(int id, int type, String start, String end) { TVinAccountingStrategyDetailOrder snapshot = new TVinAccountingStrategyDetailOrder(); snapshot.setId(id); snapshot.setType(type); snapshot.setStartTime(start); snapshot.setEndTime(end); snapshot.setElectrovalence(new BigDecimal("1.0000")); snapshot.setServiceCharge(new BigDecimal("1.0000")); snapshot.setCostServiceCharge(new BigDecimal("1.0000")); return snapshot; } private List<TCardAccountingStrategyDetailOrder> cardSixPeriodSnapshots() { return Arrays.asList( cardSnapshot(31, 4, "00:00", "08:00"), cardSnapshot(32, 3, "08:00", "10:00"), cardSnapshot(33, 2, "10:00", "12:00"), cardSnapshot(34, 3, "12:00", "17:00"), cardSnapshot(35, 2, "17:00", "22:00"), cardSnapshot(36, 4, "22:00", "00:00")); } private TCardAccountingStrategyDetailOrder cardSnapshot(int id, int type, String start, String end) { TCardAccountingStrategyDetailOrder snapshot = new TCardAccountingStrategyDetailOrder(); snapshot.setId(id); snapshot.setType(type); snapshot.setStartTime(start); snapshot.setEndTime(end); snapshot.setElectrovalence(new BigDecimal("1.0000")); snapshot.setServiceCharge(new BigDecimal("1.0000")); snapshot.setCostServiceCharge(new BigDecimal("1.0000")); return snapshot; } private void stubVinStrategyQuerySave(TVinAccountingStrategyDetailOrderService detailOrderService, @@ -252,8 +296,8 @@ TransactionRecordMessageVO vo = new TransactionRecordMessageVO(); vo.setTransaction_serial_number("VD1"); vo.setSpike_charge1(new BigDecimal("3")); vo.setFlat_charge1(new BigDecimal("5")); vo.setValley_charge1(new BigDecimal("8")); vo.setFlat_charge2(new BigDecimal("5")); vo.setValley_charge3(new BigDecimal("8")); R result = service.vinEndChargeBillingCharge(vo); assertEquals(R.SUCCESS, result.getCode()); @@ -318,8 +362,8 @@ TransactionRecordMessageVO vo = new TransactionRecordMessageVO(); vo.setTransaction_serial_number("VD1"); vo.setSpike_charge1(new BigDecimal("3")); vo.setFlat_charge1(new BigDecimal("5")); vo.setValley_charge1(new BigDecimal("8")); vo.setFlat_charge2(new BigDecimal("5")); vo.setValley_charge3(new BigDecimal("8")); R result = service.vinEndChargeBillingCharge(vo); assertEquals(R.SUCCESS, result.getCode()); @@ -375,8 +419,8 @@ TransactionRecordMessageVO vo = new TransactionRecordMessageVO(); vo.setTransaction_serial_number("SD1"); vo.setSpike_charge1(new BigDecimal("3")); vo.setFlat_charge1(new BigDecimal("5")); vo.setValley_charge1(new BigDecimal("8")); vo.setFlat_charge2(new BigDecimal("5")); vo.setValley_charge3(new BigDecimal("8")); R result = service.cardEndChargeBillingCharge(vo); assertEquals(R.SUCCESS, result.getCode()); @@ -445,8 +489,8 @@ TransactionRecordMessageVO vo = new TransactionRecordMessageVO(); vo.setTransaction_serial_number("SD1"); vo.setSpike_charge1(new BigDecimal("3")); vo.setFlat_charge1(new BigDecimal("5")); vo.setValley_charge1(new BigDecimal("8")); vo.setFlat_charge2(new BigDecimal("5")); vo.setValley_charge3(new BigDecimal("8")); R result = service.cardEndChargeBillingCharge(vo); assertEquals(R.SUCCESS, result.getCode()); @@ -521,4 +565,224 @@ assertEquals("00:00", saved.get(0).getStartTime()); assertEquals("06:00", saved.get(0).getEndTime()); } @Test @SuppressWarnings("unchecked") void vinFlatCharge4UsesSnapshot948527ForReportedOrder() throws InterruptedException { TVinChargingOrderServiceImpl service = new TVinChargingOrderServiceImpl(); TVinChargingOrderMapper mapper = mock(TVinChargingOrderMapper.class); ChargingGunClient gunClient = mock(ChargingGunClient.class); TVinAccountingStrategyDetailOrderService detailOrderService = mock(TVinAccountingStrategyDetailOrderService.class); TVinChargingOrderAccountingStrategyService strategyService = mock(TVinChargingOrderAccountingStrategyService.class); TransactionTemplate txTemplate = mock(TransactionTemplate.class); ReflectionTestUtils.setField(service, "baseMapper", mapper); ReflectionTestUtils.setField(service, "chargingGunClient", gunClient); ReflectionTestUtils.setField(service, "vinAccountingStrategyDetailOrderService", detailOrderService); ReflectionTestUtils.setField(service, "vinChargingOrderAccountingStrategyService", strategyService); ReflectionTestUtils.setField(service, "transactionTemplate", txTemplate); ReflectionTestUtils.setField(service, "redissonClient", mockedRedisson(mockedLock(true))); TVinChargingOrder charging = new TVinChargingOrder(); charging.setId(2099403373421596674L); charging.setCode("VD10220260914154223913"); charging.setStatus(4); charging.setChargingGunId(7); charging.setStartTime(LocalDateTime.of(2026, 9, 14, 15, 41, 49)); when(mapper.selectOne(any())).thenReturn(charging); when(mapper.updateById(any())).thenReturn(1); stubGunAndUpdate(gunClient, 7); List<TVinAccountingStrategyDetailOrder> snapshots = new ArrayList<>(vinSixPeriodSnapshots()); snapshots.get(1).setElectrovalence(new BigDecimal("0.1000")); snapshots.get(3).setElectrovalence(new BigDecimal("1.0000")); Collections.reverse(snapshots); stubVinStrategyQuerySave(detailOrderService, strategyService, snapshots); stubTxExecute(txTemplate); TransactionRecordMessageVO vo = new TransactionRecordMessageVO(); vo.setTransaction_serial_number("VD10220260914154223913"); vo.setFlat_charge4(new BigDecimal("0.167")); vo.setTotal_electricity(new BigDecimal("0.167")); vo.setStart_time("2026-09-14 15:41:49.00"); vo.setEnd_time("2026-09-14 15:43:45.00"); R result = service.vinEndChargeBillingCharge(vo); assertEquals(R.SUCCESS, result.getCode()); ArgumentCaptor<Collection<TVinChargingOrderAccountingStrategy>> captor = ArgumentCaptor.forClass(Collection.class); verify(strategyService).saveBatch(captor.capture()); List<TVinChargingOrderAccountingStrategy> saved = new ArrayList<>(captor.getValue()); assertEquals(1, saved.size()); assertEquals(Integer.valueOf(948527), saved.get(0).getAccountingStrategyDetailId()); assertEquals(Integer.valueOf(3), saved.get(0).getType()); assertEquals("15:41", saved.get(0).getStartTime()); assertEquals("15:43", saved.get(0).getEndTime()); assertEquals(0, new BigDecimal("0.167").compareTo(saved.get(0).getChargingCapacity())); assertEquals(0, new BigDecimal("1.0000").compareTo(saved.get(0).getElectrovalence())); assertEquals(0, new BigDecimal("0.17").compareTo(saved.get(0).getPeriodElectricPrice())); } @Test @SuppressWarnings("unchecked") void cardFlatCharge4UsesFourthSnapshotInsteadOfFirstFlatSnapshot() throws InterruptedException { TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl(); TCardChargingOrderMapper mapper = mock(TCardChargingOrderMapper.class); ChargingCardClient cardClient = mock(ChargingCardClient.class); ChargingGunClient gunClient = mock(ChargingGunClient.class); TCardAccountingStrategyDetailOrderService detailOrderService = mock(TCardAccountingStrategyDetailOrderService.class); TCardChargingOrderAccountingStrategyService strategyService = mock(TCardChargingOrderAccountingStrategyService.class); TransactionTemplate txTemplate = mock(TransactionTemplate.class); ReflectionTestUtils.setField(service, "baseMapper", mapper); ReflectionTestUtils.setField(service, "chargingCardClient", cardClient); ReflectionTestUtils.setField(service, "chargingGunClient", gunClient); ReflectionTestUtils.setField(service, "cardAccountingStrategyDetailOrderService", detailOrderService); ReflectionTestUtils.setField(service, "cardChargingOrderAccountingStrategyService", strategyService); ReflectionTestUtils.setField(service, "transactionTemplate", txTemplate); ReflectionTestUtils.setField(service, "redissonClient", mockedRedisson(mockedLock(true))); TCardChargingOrder charging = new TCardChargingOrder(); charging.setId(1L); charging.setCode("SD-SLOT-4"); charging.setStatus(4); charging.setCardId(5L); charging.setChargingGunId(9); charging.setChargeAmount(new BigDecimal("100.00")); charging.setStartTime(LocalDateTime.of(2026, 9, 14, 15, 41, 49)); when(mapper.selectOne(any())).thenReturn(charging); when(mapper.updateById(any())).thenReturn(1); stubGunAndUpdate(gunClient, 9); stubCardDeductAndUnlock(cardClient); List<TCardAccountingStrategyDetailOrder> snapshots = new ArrayList<>(cardSixPeriodSnapshots()); snapshots.get(1).setElectrovalence(new BigDecimal("0.1000")); snapshots.get(3).setElectrovalence(new BigDecimal("1.2000")); Collections.reverse(snapshots); stubCardStrategyQuerySave(detailOrderService, strategyService, snapshots); stubTxExecute(txTemplate); TransactionRecordMessageVO vo = new TransactionRecordMessageVO(); vo.setTransaction_serial_number("SD-SLOT-4"); vo.setFlat_charge4(new BigDecimal("0.167")); vo.setTotal_electricity(new BigDecimal("0.167")); vo.setStart_time("2026-09-14 15:41:49.00"); vo.setEnd_time("2026-09-14 15:43:45.00"); R result = service.cardEndChargeBillingCharge(vo); assertEquals(R.SUCCESS, result.getCode()); ArgumentCaptor<Collection<TCardChargingOrderAccountingStrategy>> captor = ArgumentCaptor.forClass(Collection.class); verify(strategyService).saveBatch(captor.capture()); List<TCardChargingOrderAccountingStrategy> saved = new ArrayList<>(captor.getValue()); assertEquals(1, saved.size()); assertEquals(Integer.valueOf(34), saved.get(0).getAccountingStrategyDetailId()); assertEquals(Integer.valueOf(3), saved.get(0).getType()); assertEquals("15:41", saved.get(0).getStartTime()); assertEquals("15:43", saved.get(0).getEndTime()); assertEquals(0, new BigDecimal("0.167").compareTo(saved.get(0).getChargingCapacity())); assertEquals(0, new BigDecimal("1.2000").compareTo(saved.get(0).getElectrovalence())); assertEquals(0, new BigDecimal("0.20").compareTo(saved.get(0).getPeriodElectricPrice())); } @Test @SuppressWarnings("unchecked") void vinPeakCharge5UsesFifthSnapshotAndDeviceEndTime() throws InterruptedException { TVinChargingOrderServiceImpl service = new TVinChargingOrderServiceImpl(); TVinChargingOrderMapper mapper = mock(TVinChargingOrderMapper.class); ChargingGunClient gunClient = mock(ChargingGunClient.class); TVinAccountingStrategyDetailOrderService detailOrderService = mock(TVinAccountingStrategyDetailOrderService.class); TVinChargingOrderAccountingStrategyService strategyService = mock(TVinChargingOrderAccountingStrategyService.class); TransactionTemplate txTemplate = mock(TransactionTemplate.class); ReflectionTestUtils.setField(service, "baseMapper", mapper); ReflectionTestUtils.setField(service, "chargingGunClient", gunClient); ReflectionTestUtils.setField(service, "vinAccountingStrategyDetailOrderService", detailOrderService); ReflectionTestUtils.setField(service, "vinChargingOrderAccountingStrategyService", strategyService); ReflectionTestUtils.setField(service, "transactionTemplate", txTemplate); ReflectionTestUtils.setField(service, "redissonClient", mockedRedisson(mockedLock(true))); TVinChargingOrder charging = new TVinChargingOrder(); charging.setId(2L); charging.setCode("VD-SLOT-5"); charging.setStatus(4); charging.setChargingGunId(7); charging.setStartTime(LocalDateTime.of(2026, 9, 11, 19, 46, 15)); when(mapper.selectOne(any())).thenReturn(charging); when(mapper.updateById(any())).thenReturn(1); stubGunAndUpdate(gunClient, 7); stubVinStrategyQuerySave(detailOrderService, strategyService, vinSixPeriodSnapshots()); stubTxExecute(txTemplate); TransactionRecordMessageVO vo = new TransactionRecordMessageVO(); vo.setTransaction_serial_number("VD-SLOT-5"); vo.setPeak_charge5(new BigDecimal("0.182")); vo.setStart_time("2026-09-11 19:46:15.00"); vo.setEnd_time("2026-09-11 19:53:44.00"); R result = service.vinEndChargeBillingCharge(vo); assertEquals(R.SUCCESS, result.getCode()); ArgumentCaptor<Collection<TVinChargingOrderAccountingStrategy>> captor = ArgumentCaptor.forClass(Collection.class); verify(strategyService).saveBatch(captor.capture()); List<TVinChargingOrderAccountingStrategy> saved = new ArrayList<>(captor.getValue()); assertEquals(1, saved.size()); assertEquals(Integer.valueOf(948528), saved.get(0).getAccountingStrategyDetailId()); assertEquals(Integer.valueOf(2), saved.get(0).getType()); assertEquals("19:46", saved.get(0).getStartTime()); assertEquals("19:53", saved.get(0).getEndTime()); assertEquals(0, new BigDecimal("0.182").compareTo(saved.get(0).getChargingCapacity())); } @Test @SuppressWarnings("unchecked") void cardPeakCharge5UsesFifthSnapshotAndDeviceEndTime() throws InterruptedException { TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl(); TCardChargingOrderMapper mapper = mock(TCardChargingOrderMapper.class); ChargingCardClient cardClient = mock(ChargingCardClient.class); ChargingGunClient gunClient = mock(ChargingGunClient.class); TCardAccountingStrategyDetailOrderService detailOrderService = mock(TCardAccountingStrategyDetailOrderService.class); TCardChargingOrderAccountingStrategyService strategyService = mock(TCardChargingOrderAccountingStrategyService.class); TransactionTemplate txTemplate = mock(TransactionTemplate.class); ReflectionTestUtils.setField(service, "baseMapper", mapper); ReflectionTestUtils.setField(service, "chargingCardClient", cardClient); ReflectionTestUtils.setField(service, "chargingGunClient", gunClient); ReflectionTestUtils.setField(service, "cardAccountingStrategyDetailOrderService", detailOrderService); ReflectionTestUtils.setField(service, "cardChargingOrderAccountingStrategyService", strategyService); ReflectionTestUtils.setField(service, "transactionTemplate", txTemplate); ReflectionTestUtils.setField(service, "redissonClient", mockedRedisson(mockedLock(true))); TCardChargingOrder charging = new TCardChargingOrder(); charging.setId(1L); charging.setCode("SD-SLOT-5"); charging.setStatus(4); charging.setCardId(5L); charging.setChargingGunId(9); charging.setChargeAmount(new BigDecimal("100.00")); charging.setStartTime(LocalDateTime.of(2026, 9, 11, 20, 16, 48)); when(mapper.selectOne(any())).thenReturn(charging); when(mapper.updateById(any())).thenReturn(1); stubGunAndUpdate(gunClient, 9); stubCardDeductAndUnlock(cardClient); stubCardStrategyQuerySave(detailOrderService, strategyService, cardSixPeriodSnapshots()); stubTxExecute(txTemplate); TransactionRecordMessageVO vo = new TransactionRecordMessageVO(); vo.setTransaction_serial_number("SD-SLOT-5"); vo.setPeak_charge5(new BigDecimal("0.079")); vo.setStart_time("2026-09-11 20:16:48.00"); vo.setEnd_time("2026-09-11 20:20:54.00"); R result = service.cardEndChargeBillingCharge(vo); assertEquals(R.SUCCESS, result.getCode()); ArgumentCaptor<Collection<TCardChargingOrderAccountingStrategy>> captor = ArgumentCaptor.forClass(Collection.class); verify(strategyService).saveBatch(captor.capture()); List<TCardChargingOrderAccountingStrategy> saved = new ArrayList<>(captor.getValue()); assertEquals(1, saved.size()); assertEquals(Integer.valueOf(35), saved.get(0).getAccountingStrategyDetailId()); assertEquals(Integer.valueOf(2), saved.get(0).getType()); assertEquals("20:16", saved.get(0).getStartTime()); assertEquals("20:20", saved.get(0).getEndTime()); assertEquals(0, new BigDecimal("0.079").compareTo(saved.get(0).getChargingCapacity())); } } ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/util/TransactionStageParserTest.java
@@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test; import java.math.BigDecimal; import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -56,8 +57,48 @@ } @Test void preservesProtocolPeriodIndexForRepeatedStageType() { TransactionRecordMessageVO vo = new TransactionRecordMessageVO(); vo.setPeak_charge3(new BigDecimal("1.25")); vo.setPeak_charge5(new BigDecimal("2.75")); List<TransactionStageParser.PeriodElectricity> periods = TransactionStageParser.parsePeriodElectricity(vo); assertEquals(2, periods.size()); assertEquals(3, periods.get(0).getPeriodIndex()); assertEquals(2, periods.get(0).getType()); assertEquals(0, new BigDecimal("1.25").compareTo(periods.get(0).getElectricity())); assertEquals(5, periods.get(1).getPeriodIndex()); assertEquals(2, periods.get(1).getType()); assertEquals(0, new BigDecimal("2.75").compareTo(periods.get(1).getElectricity())); } @Test void readsAndWritesElectricityByExactTypeAndPeriodIndex() { TransactionRecordMessageVO vo = new TransactionRecordMessageVO(); assertTrue(TransactionStageParser.setPeriodElectricity(vo, 3, 4, new BigDecimal("0.167"))); assertEquals(0, new BigDecimal("0.167").compareTo( TransactionStageParser.getPeriodElectricity(vo, 3, 4))); assertEquals(null, TransactionStageParser.getPeriodElectricity(vo, 3, 1)); assertEquals(null, TransactionStageParser.getPeriodElectricity(vo, 2, 4)); } @Test void rejectsInvalidTypeOrPeriodIndex() { TransactionRecordMessageVO vo = new TransactionRecordMessageVO(); assertTrue(!TransactionStageParser.setPeriodElectricity(vo, 3, 0, BigDecimal.ONE)); assertTrue(!TransactionStageParser.setPeriodElectricity(vo, 5, 1, BigDecimal.ONE)); assertEquals(null, TransactionStageParser.getPeriodElectricity(vo, 3, 13)); } @Test void returnsEmptyForNullVo() { assertTrue(TransactionStageParser.parseStageElectricity(null).isEmpty()); assertTrue(TransactionStageParser.parsePeriodElectricity(null).isEmpty()); assertEquals(null, TransactionStageParser.getPeriodElectricity(null, 3, 1)); } @Test