ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/MyVinChargingOrderList.java
@@ -1,5 +1,6 @@ package com.ruoyi.order.dto; import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import io.swagger.annotations.ApiModel; @@ -39,6 +40,7 @@ private BigDecimal payMoney; @ApiModelProperty("订单时间") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private LocalDateTime createTime; @ApiModelProperty("订单状态") ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TCardChargingOrderServiceImpl.java
@@ -316,8 +316,9 @@ result.put("endMode", order.getEndMode()); result.put("phone", order.getPhone()); result.put("licensePlate", order.getPlateNum()); result.put("startSoc", "-"); result.put("endSoc", "-"); // SOC 展示口径:优先展示结算回填的开始/结束 SOC;历史订单未采集时保持占位符 result.put("startSoc", order.getStartSoc() == null ? "-" : order.getStartSoc().toPlainString()); result.put("endSoc", order.getEndSoc() == null ? "-" : order.getEndSoc().toPlainString()); if (order.getStartTime() != null && order.getEndTime() != null) { result.put("chargingSecond", ChronoUnit.SECONDS.between(order.getStartTime(), order.getEndTime())); } @@ -1418,6 +1419,9 @@ final BigDecimal svcTotal = serviceChargeTotal; final BigDecimal discountElecTotal = discountElectricTotal; final BigDecimal discountSvcTotal = discountServiceTotal; // SOC 回填(与 VIN 结算同口径):取订单最早/最新实时监控上报,读取失败不阻断结算 final BigDecimal startSoc = getMonitoringSocQuietly(code, "asc"); final BigDecimal endSoc = getMonitoringSocQuietly(code, "desc"); // 剩余金额口径:可用金额基数(建单写入的 charge_amount) - 本单金额; // 改造前创建的老订单无基数时以本单金额兜底(剩余金额记 0),并顺带补齐 charge_amount final BigDecimal chargeBase = order.getChargeAmount() != null ? order.getChargeAmount() : orderAmount; @@ -1455,6 +1459,13 @@ update.setStatus(ORDER_STATUS_FINISHED); if (order.getEndMode() == null) { update.setEndMode(1); } // SOC 回填:监控数据缺失/查询异常时不覆盖既有值,与 VIN 结算行为一致 if (startSoc != null) { update.setStartSoc(startSoc); } if (endSoc != null) { update.setEndSoc(endSoc); } if (preferentialBackfill) { // 历史订单(快照为空)实时查询结果回填订单快照列,保证后续补偿重算口径一致 @@ -1498,6 +1509,24 @@ } /** * 容错读取订单最早/最新监控记录的 SOC(与 VIN 结算同口径): * Mongo 数据缺失、SOC 未上报或查询异常返回 null,不阻断主结算流程。 */ private BigDecimal getMonitoringSocQuietly(String code, String sort) { if (uploadRealTimeMonitoringDataService == null) { return null; } try { UploadRealTimeMonitoringData monitoringData = uploadRealTimeMonitoringDataService.getDataByOrderCode(code, sort); return monitoringData == null || monitoringData.getSoc() == null ? null : BigDecimal.valueOf(monitoringData.getSoc().longValue()); } catch (Exception ex) { log.error("[SD结算] 查询监控SOC失败 code={} sort={}", code, sort, ex); return null; } } /** * 结算刷卡账户资金(实扣冻结口径,与 VIN 链路同构): * 1) 分配模式:解锁卡片锁定额并按实际消费扣减(允许扣成负数),差额退回卡账户; * 2) 共享模式:解锁企业账户锁定额并按实际消费扣减,差额退回企业账户; ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java
@@ -2852,7 +2852,14 @@ .eq(TChargingOrder::getDelFlag, 0) .orderByDesc(TChargingOrder::getCreateTime) .last(" limit 0, 1")); if (null == one || !one.getAppUserId().equals(userId)) { if (null != one && !userId.equals(one.getAppUserId())) { one = null; } // 普通订单在充电中(2=启动中/3=充电中/4=停止中)时直接返回,保持原快路径不变; // 普通订单已结束(5)或不存在时才与 VIN 码订单比较:未结束订单优先,其次取创建时间更近的一笔。 // 避免同一把枪先有普通订单完成、随后 VIN 码充电时,充电页被历史完成订单抢占而误入 // 「充电完成」弹窗,导致用户无法停止充电。 if (null == one || !isActiveStatus(one.getStatus())) { // VIN 码充电订单兜底:VD 订单存于 t_vin_charging_order,且后付模式无支付动作, // 不按 rechargePaymentStatus 过滤;TVinChargingOrder 继承 TChargingOrder,后续组装原样复用 // 状态同样包含已结束(5),保证 VIN 订单结束后前端仍能拿到完成态 @@ -2863,10 +2870,12 @@ .eq(TVinChargingOrder::getDelFlag, 0) .orderByDesc(TVinChargingOrder::getCreateTime) .last(" limit 0, 1")); if (null == vinOrder) { return null; if (null != vinOrder && (isActiveStatus(vinOrder.getStatus()) || null == one || isNewerOrder(vinOrder, one))) { one = vinOrder; } one = vinOrder; } if (null == one) { return null; } ChargingDetails chargingDetails = new ChargingDetails(); chargingDetails.setId(one.getId().toString()); @@ -2911,6 +2920,18 @@ } /** 订单是否为未结束状态:2=启动中、3=充电中、4=停止中(5=已结束 不算未结束) */ private boolean isActiveStatus(Integer status) { return null != status && status >= 2 && status <= 4; } /** candidate 订单创建时间是否晚于 current(createTime 缺失时按“不更晚”处理,保持普通订单优先的历史行为) */ private boolean isNewerOrder(TChargingOrder candidate, TChargingOrder current) { return null != candidate.getCreateTime() && (null == current.getCreateTime() || candidate.getCreateTime().isAfter(current.getCreateTime())); } /** * 停止充电操作 * ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/CardVinSettlementTimeAlignmentTest.java
@@ -503,6 +503,129 @@ }); } // ===== SD:结算从监控数据回填开始/结束 SOC(与 VIN 链路同口径) ===== @Test @SuppressWarnings("unchecked") void cardSettlementBackfillsSocFromMonitoringData() throws InterruptedException { TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl(); ReflectionTestUtils.setField(service, "chargeAccountSupport", newChargeAccountSupport()); TCardChargingOrderMapper mapper = mock(TCardChargingOrderMapper.class); ChargingCardClient cardClient = mock(ChargingCardClient.class); ChargingGunClient gunClient = mock(ChargingGunClient.class); UploadRealTimeMonitoringDataService monitoringDataService = mock(UploadRealTimeMonitoringDataService.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, "uploadRealTimeMonitoringDataService", monitoringDataService); 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("SD1"); charging.setStatus(4); charging.setCardId(5L); charging.setChargingGunId(9); charging.setChargeAmount(new BigDecimal("100.00")); charging.setStartTime(LocalDateTime.of(2026, 9, 14, 7, 30, 0)); charging.setEndTime(LocalDateTime.of(2026, 9, 14, 21, 45, 0)); when(mapper.selectOne(any())).thenReturn(charging); when(mapper.updateById(any())).thenReturn(1); UploadRealTimeMonitoringData firstMonitoring = new UploadRealTimeMonitoringData(); firstMonitoring.setSoc(18); UploadRealTimeMonitoringData lastMonitoring = new UploadRealTimeMonitoringData(); lastMonitoring.setSoc(87); when(monitoringDataService.getDataByOrderCode("SD1", "asc")).thenReturn(firstMonitoring); when(monitoringDataService.getDataByOrderCode("SD1", "desc")).thenReturn(lastMonitoring); stubGunAndUpdate(gunClient, 9); stubCardDeductAndUnlock(cardClient); stubCardStrategyQuerySave(detailOrderService, strategyService, cardThreePeriodSnapshots()); stubTxExecute(txTemplate); TransactionRecordMessageVO vo = new TransactionRecordMessageVO(); vo.setTransaction_serial_number("SD1"); vo.setSpike_charge1(new BigDecimal("3")); vo.setFlat_charge2(new BigDecimal("5")); vo.setValley_charge3(new BigDecimal("8")); R result = service.cardEndChargeBillingCharge(vo); assertEquals(R.SUCCESS, result.getCode()); ArgumentCaptor<TCardChargingOrder> orderCaptor = ArgumentCaptor.forClass(TCardChargingOrder.class); verify(mapper).updateById(orderCaptor.capture()); assertEquals(0, new BigDecimal("18").compareTo(orderCaptor.getValue().getStartSoc())); assertEquals(0, new BigDecimal("87").compareTo(orderCaptor.getValue().getEndSoc())); verify(monitoringDataService).getDataByOrderCode("SD1", "asc"); verify(monitoringDataService).getDataByOrderCode("SD1", "desc"); } // ===== SD:监控数据缺失时不覆盖订单 SOC(保持历史行为) ===== @Test @SuppressWarnings("unchecked") void cardSettlementKeepsSocWhenMonitoringMissing() throws InterruptedException { TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl(); ReflectionTestUtils.setField(service, "chargeAccountSupport", newChargeAccountSupport()); TCardChargingOrderMapper mapper = mock(TCardChargingOrderMapper.class); ChargingCardClient cardClient = mock(ChargingCardClient.class); ChargingGunClient gunClient = mock(ChargingGunClient.class); UploadRealTimeMonitoringDataService monitoringDataService = mock(UploadRealTimeMonitoringDataService.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, "uploadRealTimeMonitoringDataService", monitoringDataService); 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("SD1"); charging.setStatus(4); charging.setCardId(5L); charging.setChargingGunId(9); charging.setChargeAmount(new BigDecimal("100.00")); charging.setStartTime(LocalDateTime.of(2026, 9, 14, 7, 30, 0)); charging.setEndTime(LocalDateTime.of(2026, 9, 14, 21, 45, 0)); when(mapper.selectOne(any())).thenReturn(charging); when(mapper.updateById(any())).thenReturn(1); // 首末监控记录均缺失(设备未上报或数据已过期) when(monitoringDataService.getDataByOrderCode("SD1", "asc")).thenReturn(null); when(monitoringDataService.getDataByOrderCode("SD1", "desc")).thenReturn(null); stubGunAndUpdate(gunClient, 9); stubCardDeductAndUnlock(cardClient); stubCardStrategyQuerySave(detailOrderService, strategyService, cardThreePeriodSnapshots()); stubTxExecute(txTemplate); TransactionRecordMessageVO vo = new TransactionRecordMessageVO(); vo.setTransaction_serial_number("SD1"); vo.setSpike_charge1(new BigDecimal("3")); vo.setFlat_charge2(new BigDecimal("5")); vo.setValley_charge3(new BigDecimal("8")); R result = service.cardEndChargeBillingCharge(vo); assertEquals(R.SUCCESS, result.getCode()); ArgumentCaptor<TCardChargingOrder> orderCaptor = ArgumentCaptor.forClass(TCardChargingOrder.class); verify(mapper).updateById(orderCaptor.capture()); assertNull(orderCaptor.getValue().getStartSoc()); assertNull(orderCaptor.getValue().getEndSoc()); } // ===== SD:订单 startTime/endTime 缺失时首末段保持快照兜底 ===== @Test ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TChargingOrderServiceImplVinFallbackTest.java
@@ -47,6 +47,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -303,6 +304,106 @@ assertStatusFilterCoversFinished(captor.getValue()); } @Test void getChargingDetailsPrefersActiveVinOrderOverFinishedNormalOrder() { // 同一把枪:普通订单已完成(5),随后 VIN 码订单充电中(3)。 // 充电页必须拿到未结束的 VIN 订单,否则会误入「充电完成」弹窗、用户无法停止充电 LoginUserApplet loginUser = new LoginUserApplet(); loginUser.setUserId(88L); when(tokenService.getLoginUserApplet()).thenReturn(loginUser); TChargingOrder finishedNormalOrder = new TChargingOrder(); finishedNormalOrder.setId(3003L); finishedNormalOrder.setAppUserId(88L); finishedNormalOrder.setChargingGunId(5); finishedNormalOrder.setStatus(5); finishedNormalOrder.setCode("CH20261014001"); finishedNormalOrder.setResidualAmount(new BigDecimal("2.06")); finishedNormalOrder.setCreateTime(LocalDateTime.of(2026, 10, 14, 14, 22, 49)); when(mapper.selectOne(any())).thenReturn(finishedNormalOrder); TVinChargingOrder activeVinOrder = new TVinChargingOrder(); activeVinOrder.setId(2002L); activeVinOrder.setAppUserId(88L); activeVinOrder.setChargingGunId(5); activeVinOrder.setStatus(3); activeVinOrder.setCode("VD20261014002"); activeVinOrder.setResidualAmount(new BigDecimal("3.44")); activeVinOrder.setCreateTime(LocalDateTime.of(2026, 10, 14, 14, 38, 30)); when(vinChargingOrderMapper.selectOne(any())).thenReturn(activeVinOrder); stubGunPileAndSite(); when(uploadRealTimeMonitoringDataService.getLastDataById("VD20261014002")).thenReturn(null); ChargingDetails details = service.getChargingDetails(5); assertNotNull(details, "普通订单已结束时不应定格完成态,应返回充电中的 VIN 订单"); assertEquals("2002", details.getId()); assertEquals(Integer.valueOf(3), details.getStatus()); assertEquals("VD20261014002", details.getCode()); assertEquals(0, new BigDecimal("3.44").compareTo(details.getChargingCost())); } @Test void getChargingDetailsKeepsActiveNormalOrderWithoutQueryingVinOrder() { // 普通订单充电中(3)时走原快路径直接返回,不再查询 VIN 订单表 LoginUserApplet loginUser = new LoginUserApplet(); loginUser.setUserId(88L); when(tokenService.getLoginUserApplet()).thenReturn(loginUser); TChargingOrder activeNormalOrder = new TChargingOrder(); activeNormalOrder.setId(3004L); activeNormalOrder.setAppUserId(88L); activeNormalOrder.setChargingGunId(5); activeNormalOrder.setStatus(3); activeNormalOrder.setCode("CH20261014002"); activeNormalOrder.setResidualAmount(new BigDecimal("8.80")); activeNormalOrder.setCreateTime(LocalDateTime.of(2026, 10, 14, 15, 0)); when(mapper.selectOne(any())).thenReturn(activeNormalOrder); stubGunPileAndSite(); when(uploadRealTimeMonitoringDataService.getLastDataById("CH20261014002")).thenReturn(null); ChargingDetails details = service.getChargingDetails(5); assertNotNull(details); assertEquals("3004", details.getId()); assertEquals(Integer.valueOf(3), details.getStatus()); assertEquals("CH20261014002", details.getCode()); verify(vinChargingOrderMapper, never()).selectOne(any()); } @Test void getChargingDetailsPrefersNewerFinishedOrderWhenBothFinished() { // 两笔均已结束(5)时取创建时间更近的一笔:VIN 订单结束后完成页应展示 VIN 订单,而非更早的普通订单 LoginUserApplet loginUser = new LoginUserApplet(); loginUser.setUserId(88L); when(tokenService.getLoginUserApplet()).thenReturn(loginUser); TChargingOrder finishedNormalOrder = new TChargingOrder(); finishedNormalOrder.setId(3003L); finishedNormalOrder.setAppUserId(88L); finishedNormalOrder.setChargingGunId(5); finishedNormalOrder.setStatus(5); finishedNormalOrder.setCode("CH20261014001"); finishedNormalOrder.setResidualAmount(new BigDecimal("2.06")); finishedNormalOrder.setCreateTime(LocalDateTime.of(2026, 10, 14, 14, 22, 49)); when(mapper.selectOne(any())).thenReturn(finishedNormalOrder); TVinChargingOrder finishedVinOrder = new TVinChargingOrder(); finishedVinOrder.setId(2003L); finishedVinOrder.setAppUserId(88L); finishedVinOrder.setChargingGunId(5); finishedVinOrder.setStatus(5); finishedVinOrder.setCode("VD20261014003"); finishedVinOrder.setResidualAmount(new BigDecimal("5.20")); finishedVinOrder.setCreateTime(LocalDateTime.of(2026, 10, 14, 14, 42, 26)); when(vinChargingOrderMapper.selectOne(any())).thenReturn(finishedVinOrder); stubGunPileAndSite(); when(uploadRealTimeMonitoringDataService.getLastDataById("VD20261014003")).thenReturn(null); ChargingDetails details = service.getChargingDetails(5); assertNotNull(details); assertEquals("2003", details.getId()); assertEquals(Integer.valueOf(5), details.getStatus()); assertEquals("VD20261014003", details.getCode()); assertEquals(0, new BigDecimal("5.20").compareTo(details.getChargingCost())); } /** getChargingDetails 组装链路依赖的充电枪/充电桩/站点远程调用 stub */ private void stubGunPileAndSite() { TChargingGun gun = new TChargingGun();