package com.ruoyi.order.service.impl; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper; import com.baomidou.mybatisplus.extension.conditions.update.LambdaUpdateChainWrapper; import com.ruoyi.account.api.dto.ChargingCardAuthDTO; import com.ruoyi.account.api.dto.ChargingCardDeductDTO; import com.ruoyi.account.api.feignClient.ChargingCardClient; import com.ruoyi.account.api.feignClient.CompanyAllocateRecordClient; import com.ruoyi.account.api.model.TChargingCard; import com.ruoyi.account.api.model.TCompanyAllocateRecord; import com.ruoyi.account.api.vo.ChargingCardDeductVO; import com.ruoyi.chargingPile.api.feignClient.AccountingStrategyClient; import com.ruoyi.chargingPile.api.feignClient.AccountingStrategyDetailClient; import com.ruoyi.chargingPile.api.feignClient.BillingTemplateClient; import com.ruoyi.chargingPile.api.feignClient.ChargingGunClient; import com.ruoyi.chargingPile.api.model.TAccountingStrategy; import com.ruoyi.chargingPile.api.model.TAccountingStrategyDetail; import com.ruoyi.chargingPile.api.model.TChargingGun; import com.ruoyi.chargingPile.api.vo.GetChargingGunByCode; import com.ruoyi.common.core.domain.R; import com.ruoyi.order.api.dto.DeviceStartChargingDTO; import com.ruoyi.order.api.model.TCardAccountingStrategyDetailOrder; import com.ruoyi.order.api.model.TCardAccountingStrategyOrder; import com.ruoyi.order.api.model.TCardChargingOrder; import com.ruoyi.order.api.model.TCardChargingOrderAccountingStrategy; import com.ruoyi.order.api.query.UploadRealTimeMonitoringDataQuery; import com.ruoyi.order.api.vo.DeviceStartChargingVO; import com.ruoyi.order.api.vo.TransactionRecordMessageVO; import com.ruoyi.order.mapper.TCardChargingOrderMapper; import com.ruoyi.order.service.TCardAccountingStrategyDetailOrderService; import com.ruoyi.order.service.TCardAccountingStrategyOrderService; import com.ruoyi.order.service.TCardChargingOrderAccountingStrategyService; import com.ruoyi.order.support.ChargeAccountSupport; import com.ruoyi.other.api.domain.TGroupCompany; import com.ruoyi.other.api.feignClient.ChargeAuthorizationClient; import com.ruoyi.other.api.feignClient.CompanyPreferentialClient; import com.ruoyi.other.api.feignClient.GroupCompanyClient; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.TransactionStatus; import org.springframework.transaction.support.TransactionCallback; import org.springframework.transaction.support.TransactionTemplate; import java.math.BigDecimal; import java.util.Collection; import java.util.Collections; import java.util.concurrent.TimeUnit; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** * 刷卡订单金额字段单测(charge_amount / residual_amount / balance_pay_amount): * 1) 建单写入可用金额基数与启动剩余金额(与返回设备的余额同源); * 2) 监控按"可用金额基数 - 设备已充金额"刷新剩余金额; * 3) 结算写入剩余金额(基数 - 本单金额)与余额支付金额,并同步回写内存订单对象。 */ class TCardChargingOrderAmountFieldsTest { /** * 纯单测无 Spring 容器,MyBatis-Plus 的 lambda 列缓存需手工初始化, * 否则服务实现里的 LambdaQueryWrapper/LambdaUpdateWrapper 解析列名时抛 "can not find lambda cache" */ @BeforeAll static void initMybatisPlusTableInfo() { MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); TableInfoHelper.initTableInfo(assistant, TCardChargingOrder.class); } /** * 账户支持组件(与生产同源):企业校验/授权窗口/优惠装载/分配流水等公共逻辑已抽取到该组件。 * 纯单测不启 Spring 容器,此处内联装配,远程依赖以宽松 mock 兜底(内部均有判空与兜底), * 需要走真实分支的用例可再覆盖具体依赖字段。 */ /** 账户侧分配流水客户端(与 ChargeAccountSupport 内持有的是同一 mock,便于补偿双判据用例 stub 与校验) */ private CompanyAllocateRecordClient companyAllocateRecordClient; private ChargeAccountSupport newChargeAccountSupport() { ChargeAccountSupport support = new ChargeAccountSupport(); ReflectionTestUtils.setField(support, "accountingStrategyDetailClient", mock(AccountingStrategyDetailClient.class)); ReflectionTestUtils.setField(support, "groupCompanyClient", mock(GroupCompanyClient.class)); ReflectionTestUtils.setField(support, "companyPreferentialClient", mock(CompanyPreferentialClient.class)); ReflectionTestUtils.setField(support, "billingTemplateClient", mock(BillingTemplateClient.class)); companyAllocateRecordClient = mock(CompanyAllocateRecordClient.class); ReflectionTestUtils.setField(support, "companyAllocateRecordClient", companyAllocateRecordClient); return support; } /** 企业侧无消费流水(补偿双判据默认:卡侧与企业侧均可确认未结算) */ private void mockNoCompanyConsumeRecord() { R> empty = okWithData(Collections.emptyList()); when(companyAllocateRecordClient.listConsumedOrderCodes(any())).thenReturn(empty); } private RLock mockedLock(boolean tryLockResult) throws InterruptedException { RLock lock = mock(RLock.class); when(lock.tryLock(anyLong(), anyLong(), any(TimeUnit.class))).thenReturn(tryLockResult); when(lock.isHeldByCurrentThread()).thenReturn(false); return lock; } private RedissonClient mockedRedisson(RLock lock) { RedissonClient redisson = mock(RedissonClient.class); when(redisson.getLock(anyString())).thenReturn(lock); return redisson; } @SuppressWarnings("unchecked") private R okWithData(T data) { R result = mock(R.class); when(result.getCode()).thenReturn(R.SUCCESS); when(result.getData()).thenReturn(data); return result; } // ===== 建单:charge_amount / residual_amount = 卡余额 ===== @Test void writesChargeAndResidualAmountOnCardOrderCreation() throws InterruptedException { TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl(); TCardChargingOrderMapper mapper = mock(TCardChargingOrderMapper.class); ChargingCardClient cardClient = mock(ChargingCardClient.class); ChargingGunClient gunClient = mock(ChargingGunClient.class); GroupCompanyClient groupCompanyClient = mock(GroupCompanyClient.class); ChargeAuthorizationClient chargeAuthorizationClient = mock(ChargeAuthorizationClient.class); ReflectionTestUtils.setField(service, "baseMapper", mapper); ReflectionTestUtils.setField(service, "chargingCardClient", cardClient); ReflectionTestUtils.setField(service, "chargingGunClient", gunClient); ReflectionTestUtils.setField(service, "chargeAuthorizationClient", chargeAuthorizationClient); ReflectionTestUtils.setField(service, "groupCompanyClient", groupCompanyClient); // 账户支持组件与订单服务共用同一企业账户 mock,保证「企业校验 + 锁定企业账户」走同一返回值 ChargeAccountSupport chargeAccountSupport = newChargeAccountSupport(); ReflectionTestUtils.setField(chargeAccountSupport, "groupCompanyClient", groupCompanyClient); ReflectionTestUtils.setField(service, "chargeAccountSupport", chargeAccountSupport); ReflectionTestUtils.setField(service, "redissonClient", mockedRedisson(mockedLock(true))); // 订单主键在真实库里由 MyBatis-Plus 回填,纯单测手工回填,保证策略快照与锁定额度回写能拿到订单ID when(mapper.insert(any(TCardChargingOrder.class))).thenAnswer(invocation -> { TCardChargingOrder entity = invocation.getArgument(0); entity.setId(100L); return 1; }); TChargingGun gun = new TChargingGun(); gun.setId(9); gun.setStatus(2); gun.setAccountingStrategyId(31); R gunResult = okWithData(gun); when(gunClient.getChargingGunByCode(any(GetChargingGunByCode.class))).thenReturn(gunResult); TChargingCard card = new TChargingCard(); card.setId(5L); card.setCardNo("C001"); card.setCompanyId(77); card.setBalance(new BigDecimal("100.00")); R verifyResult = okWithData(card); when(cardClient.verifyCharging(any(ChargingCardAuthDTO.class))).thenReturn(verifyResult); // 共享账户模式(默认):企业状态启用、可用余额即下发给设备的可用金额,未配置充电授权按放行处理 TGroupCompany company = new TGroupCompany(); company.setId(77); company.setStatus(1); company.setAccountMode(1); company.setAvailableBalance(new BigDecimal("100.00")); R companyResult = okWithData(company); when(groupCompanyClient.getCompanyById(77)).thenReturn(companyResult); R lockBalanceResult = okWithData(new BigDecimal("100.00")); when(groupCompanyClient.lockBalance(any(), any())).thenReturn(lockBalanceResult); R lockResult = okWithData(true); when(cardClient.lockForCharging(anyLong(), anyString())).thenReturn(lockResult); // 点5 改造后建单必须先成功写入计费策略快照(否则回滚删单):枪配置策略 31,策略与明细均可查且快照落库成功 AccountingStrategyClient accountingStrategyClient = mock(AccountingStrategyClient.class); AccountingStrategyDetailClient accountingStrategyDetailClient = mock(AccountingStrategyDetailClient.class); TCardAccountingStrategyOrderService strategyOrderService = mock(TCardAccountingStrategyOrderService.class); TCardAccountingStrategyDetailOrderService strategyDetailOrderService = mock(TCardAccountingStrategyDetailOrderService.class); ReflectionTestUtils.setField(service, "accountingStrategyClient", accountingStrategyClient); ReflectionTestUtils.setField(service, "accountingStrategyDetailClient", accountingStrategyDetailClient); ReflectionTestUtils.setField(service, "cardAccountingStrategyOrderService", strategyOrderService); ReflectionTestUtils.setField(service, "cardAccountingStrategyDetailOrderService", strategyDetailOrderService); R strategyResult = okWithData(new TAccountingStrategy()); when(accountingStrategyClient.getAccountingStrategyById(31)).thenReturn(strategyResult); TAccountingStrategyDetail strategyDetail = new TAccountingStrategyDetail(); strategyDetail.setType(1); R> strategyDetailResult = okWithData(Collections.singletonList(strategyDetail)); when(accountingStrategyDetailClient.getListByAccountingStrategyId(31)).thenReturn(strategyDetailResult); when(strategyOrderService.save(any(TCardAccountingStrategyOrder.class))).thenReturn(true); when(strategyDetailOrderService.saveBatch(any(Collection.class))).thenReturn(true); DeviceStartChargingDTO dto = new DeviceStartChargingDTO(); dto.setChargingPileCode("P1"); dto.setChargingGunCode("G1"); dto.setAccount("C001"); DeviceStartChargingVO result = service.startCardCharging(dto); assertEquals(1, result.getAuthentication()); assertEquals(new BigDecimal("100.00"), result.getBalance()); ArgumentCaptor captor = ArgumentCaptor.forClass(TCardChargingOrder.class); verify(mapper).insert(captor.capture()); assertEquals(new BigDecimal("100.00"), captor.getValue().getChargeAmount()); assertEquals(new BigDecimal("100.00"), captor.getValue().getResidualAmount()); } // ===== 监控:residual_amount = charge_amount - paid_amount ===== @Test void updatesResidualAmountFromPaidAmountWhileMonitoring() { TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl(); ReflectionTestUtils.setField(service, "chargeAccountSupport", newChargeAccountSupport()); TCardChargingOrderMapper mapper = mock(TCardChargingOrderMapper.class); ReflectionTestUtils.setField(service, "baseMapper", mapper); TCardChargingOrder charging = new TCardChargingOrder(); charging.setId(1L); charging.setCode("SD1"); charging.setStatus(3); charging.setChargeAmount(new BigDecimal("100.00")); when(mapper.selectOne(any())).thenReturn(charging); when(mapper.update(any(TCardChargingOrder.class), any())).thenReturn(1); UploadRealTimeMonitoringDataQuery query = new UploadRealTimeMonitoringDataQuery(); query.setTransaction_serial_number("SD1"); query.setPaid_amount(new BigDecimal("12.34")); service.cardChargeMonitoring(query); ArgumentCaptor captor = ArgumentCaptor.forClass(TCardChargingOrder.class); verify(mapper).update(captor.capture(), any()); assertEquals(new BigDecimal("87.66"), captor.getValue().getResidualAmount()); } // ===== 结算:residual_amount = charge_amount - orderAmount;balance_pay_amount = orderAmount ===== @Test @SuppressWarnings("unchecked") void writesResidualChargeAndBalancePayAmountOnSettlement() 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); 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("SD1"); charging.setStatus(4); charging.setCardId(5L); charging.setChargingGunId(9); charging.setChargeAmount(new BigDecimal("100.00")); when(mapper.selectOne(any())).thenReturn(charging); when(mapper.updateById(any(TCardChargingOrder.class))).thenReturn(1); TChargingGun gun = new TChargingGun(); gun.setId(9); gun.setStatus(4); R gunResult = okWithData(gun); when(gunClient.getChargingGunById(any())).thenReturn(gunResult); R gunUpdateResult = mock(R.class); when(gunUpdateResult.getCode()).thenReturn(R.SUCCESS); when(gunClient.updateChargingGunById(any(TChargingGun.class))).thenReturn(gunUpdateResult); R unlockResult = okWithData(true); when(cardClient.unlockForCharging(anyLong(), anyString())).thenReturn(unlockResult); ChargingCardDeductVO deductVO = new ChargingCardDeductVO(); deductVO.setDuplicated(false); R deductResult = okWithData(deductVO); when(cardClient.deductForCharging(any(ChargingCardDeductDTO.class))).thenReturn(deductResult); // 计费策略快照:尖段电费 1.0 元/度、服务费 0.5 元/度 TCardAccountingStrategyDetailOrder snapshot = new TCardAccountingStrategyDetailOrder(); snapshot.setType(1); snapshot.setElectrovalence(new BigDecimal("1.0000")); snapshot.setServiceCharge(new BigDecimal("0.5000")); snapshot.setCostServiceCharge(new BigDecimal("0.5000")); LambdaQueryChainWrapper queryWrapper = mock(LambdaQueryChainWrapper.class); when(detailOrderService.lambdaQuery()).thenReturn(queryWrapper); when(queryWrapper.eq(any(), any())).thenReturn(queryWrapper); when(queryWrapper.list()).thenReturn(Collections.singletonList(snapshot)); LambdaUpdateChainWrapper updateWrapper = mock(LambdaUpdateChainWrapper.class); when(strategyService.lambdaUpdate()).thenReturn(updateWrapper); when(updateWrapper.eq(any(), any())).thenReturn(updateWrapper); when(updateWrapper.remove()).thenReturn(true); when(strategyService.saveBatch(any(Collection.class))).thenReturn(true); when(txTemplate.execute(any())).thenAnswer(invocation -> { TransactionCallback callback = invocation.getArgument(0); return callback.doInTransaction(mock(TransactionStatus.class)); }); TransactionRecordMessageVO vo = new TransactionRecordMessageVO(); vo.setTransaction_serial_number("SD1"); vo.setSpike_charge1(new BigDecimal("10")); R result = service.cardEndChargeBillingCharge(vo); assertEquals(R.SUCCESS, result.getCode()); ArgumentCaptor captor = ArgumentCaptor.forClass(TCardChargingOrder.class); verify(mapper).updateById(captor.capture()); TCardChargingOrder update = captor.getValue(); // 尖段 10 度 * (1.0 + 0.5) = 15.00 元 assertEquals(new BigDecimal("15.00"), update.getOrderAmount()); assertEquals(new BigDecimal("15.00"), update.getBalancePayAmount()); assertEquals(new BigDecimal("100.00"), update.getChargeAmount()); assertEquals(new BigDecimal("85.00"), update.getResidualAmount()); // 内存订单对象同步回写,保证结算后监管推送字段完整 assertEquals(new BigDecimal("85.00"), charging.getResidualAmount()); assertEquals(new BigDecimal("15.00"), charging.getBalancePayAmount()); // 余额扣款金额与余额支付金额同源 ArgumentCaptor deductCaptor = ArgumentCaptor.forClass(ChargingCardDeductDTO.class); verify(cardClient).deductForCharging(deductCaptor.capture()); assertEquals(new BigDecimal("15.00"), deductCaptor.getValue().getAmount()); } @Test @SuppressWarnings("unchecked") void deductFailureRollsFinishedOrderBackToStopping() 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); 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-FAIL"); charging.setStatus(4); charging.setCardId(5L); charging.setChargingGunId(9); charging.setChargeAmount(new BigDecimal("100.00")); when(mapper.selectOne(any())).thenReturn(charging); when(mapper.updateById(any(TCardChargingOrder.class))).thenReturn(1); when(mapper.update(any(), any())).thenReturn(1); R deductFailure = mock(R.class); when(deductFailure.getCode()).thenReturn(500); when(deductFailure.getMsg()).thenReturn("account unavailable"); when(cardClient.deductForCharging(any(ChargingCardDeductDTO.class))).thenReturn(deductFailure); TCardAccountingStrategyDetailOrder snapshot = new TCardAccountingStrategyDetailOrder(); snapshot.setType(1); snapshot.setElectrovalence(new BigDecimal("1.0000")); snapshot.setServiceCharge(new BigDecimal("0.5000")); snapshot.setCostServiceCharge(new BigDecimal("0.5000")); LambdaQueryChainWrapper queryWrapper = mock(LambdaQueryChainWrapper.class); when(detailOrderService.lambdaQuery()).thenReturn(queryWrapper); when(queryWrapper.eq(any(), any())).thenReturn(queryWrapper); when(queryWrapper.list()).thenReturn(Collections.singletonList(snapshot)); LambdaUpdateChainWrapper updateWrapper = mock(LambdaUpdateChainWrapper.class); when(strategyService.lambdaUpdate()).thenReturn(updateWrapper); when(updateWrapper.eq(any(), any())).thenReturn(updateWrapper); when(updateWrapper.remove()).thenReturn(true); when(strategyService.saveBatch(any(Collection.class))).thenReturn(true); when(txTemplate.execute(any())).thenAnswer(invocation -> { TransactionCallback callback = invocation.getArgument(0); return callback.doInTransaction(mock(TransactionStatus.class)); }); TransactionRecordMessageVO vo = new TransactionRecordMessageVO(); vo.setTransaction_serial_number("SD-FAIL"); vo.setSpike_charge1(BigDecimal.ONE); R result = service.cardEndChargeBillingCharge(vo); assertEquals(500, result.getCode()); verify(mapper).update(any(), any()); verify(cardClient, never()).unlockForCharging(anyLong(), anyString()); verify(gunClient, never()).getChargingGunById(any()); } @Test void compensationRollsBackOnlyFinishedOrdersWithoutDeductRecord() throws InterruptedException { TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl(); ReflectionTestUtils.setField(service, "chargeAccountSupport", newChargeAccountSupport()); TCardChargingOrderMapper mapper = mock(TCardChargingOrderMapper.class); ChargingCardClient cardClient = mock(ChargingCardClient.class); RLock lock = mockedLock(true); ReflectionTestUtils.setField(service, "baseMapper", mapper); ReflectionTestUtils.setField(service, "chargingCardClient", cardClient); ReflectionTestUtils.setField(service, "redissonClient", mockedRedisson(lock)); TCardChargingOrder missing = new TCardChargingOrder(); missing.setId(1L); missing.setCode("SD-MISSING"); missing.setStatus(5); missing.setCardId(5L); missing.setOrderAmount(BigDecimal.ONE); when(mapper.selectList(any())).thenReturn(Collections.singletonList(missing)); when(mapper.update(any(), any())).thenReturn(1); R> noDeductRecords = okWithData(Collections.emptyList()); when(cardClient.listDeductedOrderCodes(any())).thenReturn(noDeductRecords); mockNoCompanyConsumeRecord(); assertEquals(1, service.recoverFinishedOrdersWithoutDeduct()); verify(cardClient, times(2)).listDeductedOrderCodes(any()); verify(mapper).update(any(), any()); } @Test void compensationSkipsFinishedOrdersHavingDeductRecord() { TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl(); ReflectionTestUtils.setField(service, "chargeAccountSupport", newChargeAccountSupport()); TCardChargingOrderMapper mapper = mock(TCardChargingOrderMapper.class); ChargingCardClient cardClient = mock(ChargingCardClient.class); ReflectionTestUtils.setField(service, "baseMapper", mapper); ReflectionTestUtils.setField(service, "chargingCardClient", cardClient); TCardChargingOrder deducted = new TCardChargingOrder(); deducted.setId(2L); deducted.setCode("SD-DEDUCTED"); deducted.setStatus(5); deducted.setCardId(5L); deducted.setOrderAmount(BigDecimal.ONE); when(mapper.selectList(any())).thenReturn(Collections.singletonList(deducted)); // 批量查询结果已包含该订单号时不再逐单复核,也不回退状态 R> deductedRecords = okWithData(Collections.singletonList("SD-DEDUCTED")); when(cardClient.listDeductedOrderCodes(any())).thenReturn(deductedRecords); assertEquals(0, service.recoverFinishedOrdersWithoutDeduct()); verify(cardClient, times(1)).listDeductedOrderCodes(any()); verify(mapper, never()).update(any(), any()); } @Test void compensationKeepsFinishedStatusWhenDeductQueryFails() { TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl(); ReflectionTestUtils.setField(service, "chargeAccountSupport", newChargeAccountSupport()); TCardChargingOrderMapper mapper = mock(TCardChargingOrderMapper.class); ChargingCardClient cardClient = mock(ChargingCardClient.class); ReflectionTestUtils.setField(service, "baseMapper", mapper); ReflectionTestUtils.setField(service, "chargingCardClient", cardClient); TCardChargingOrder candidate = new TCardChargingOrder(); candidate.setId(3L); candidate.setCode("SD-UNKNOWN"); candidate.setStatus(5); candidate.setCardId(5L); candidate.setOrderAmount(BigDecimal.ONE); when(mapper.selectList(any())).thenReturn(Collections.singletonList(candidate)); // 账户服务不可用时无法确认扣款结果,必须保持原状态等待下轮,不能误回退 when(cardClient.listDeductedOrderCodes(any())).thenReturn(R.fail("account unavailable")); assertEquals(0, service.recoverFinishedOrdersWithoutDeduct()); verify(mapper, never()).update(any(), any()); } @Test void compensationSkipsRemoteQueryWhenNoFinishedCandidate() { TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl(); ReflectionTestUtils.setField(service, "chargeAccountSupport", newChargeAccountSupport()); TCardChargingOrderMapper mapper = mock(TCardChargingOrderMapper.class); ChargingCardClient cardClient = mock(ChargingCardClient.class); ReflectionTestUtils.setField(service, "baseMapper", mapper); ReflectionTestUtils.setField(service, "chargingCardClient", cardClient); when(mapper.selectList(any())).thenReturn(Collections.emptyList()); assertEquals(0, service.recoverFinishedOrdersWithoutDeduct()); verify(cardClient, never()).listDeductedOrderCodes(any()); verify(mapper, never()).update(any(), any()); } @Test void compensationSkipsOrderWhenSettleLockNotAcquired() throws InterruptedException { TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl(); ReflectionTestUtils.setField(service, "chargeAccountSupport", newChargeAccountSupport()); TCardChargingOrderMapper mapper = mock(TCardChargingOrderMapper.class); ChargingCardClient cardClient = mock(ChargingCardClient.class); ReflectionTestUtils.setField(service, "baseMapper", mapper); ReflectionTestUtils.setField(service, "chargingCardClient", cardClient); // 结算锁被其他线程持有:本轮回退必须让行,交由持锁方完成结算 ReflectionTestUtils.setField(service, "redissonClient", mockedRedisson(mockedLock(false))); TCardChargingOrder candidate = new TCardChargingOrder(); candidate.setId(4L); candidate.setCode("SD-LOCKED"); candidate.setStatus(5); candidate.setCardId(5L); candidate.setOrderAmount(BigDecimal.ONE); when(mapper.selectList(any())).thenReturn(Collections.singletonList(candidate)); R> noDeductRecords = okWithData(Collections.emptyList()); when(cardClient.listDeductedOrderCodes(any())).thenReturn(noDeductRecords); mockNoCompanyConsumeRecord(); assertEquals(0, service.recoverFinishedOrdersWithoutDeduct()); verify(cardClient, times(1)).listDeductedOrderCodes(any()); verify(mapper, never()).update(any(), any()); } @Test void compensationSkipsFinishedOrderWithCompanyConsumeRecord() { // 共享模式订单的消费流水落在企业侧:卡侧无流水但企业侧命中时必须保持已完成状态,不得回退造成重复扣款 TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl(); ReflectionTestUtils.setField(service, "chargeAccountSupport", newChargeAccountSupport()); TCardChargingOrderMapper mapper = mock(TCardChargingOrderMapper.class); ChargingCardClient cardClient = mock(ChargingCardClient.class); ReflectionTestUtils.setField(service, "baseMapper", mapper); ReflectionTestUtils.setField(service, "chargingCardClient", cardClient); TCardChargingOrder shared = new TCardChargingOrder(); shared.setId(6L); shared.setCode("SD-SHARED"); shared.setStatus(5); shared.setCardId(5L); shared.setOrderAmount(BigDecimal.ONE); when(mapper.selectList(any())).thenReturn(Collections.singletonList(shared)); R> noDeductRecords = okWithData(Collections.emptyList()); when(cardClient.listDeductedOrderCodes(any())).thenReturn(noDeductRecords); R> consumeRecords = okWithData(Collections.singletonList("SD-SHARED")); when(companyAllocateRecordClient.listConsumedOrderCodes(any())).thenReturn(consumeRecords); assertEquals(0, service.recoverFinishedOrdersWithoutDeduct()); verify(cardClient, times(1)).listDeductedOrderCodes(any()); verify(mapper, never()).update(any(), any()); } @Test void compensationKeepsFinishedStatusWhenCompanyConsumeQueryFails() { // 企业侧流水查询失败说明无法确认结算结果,必须保持原状态等待下轮,避免误回退后重复扣款 TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl(); ReflectionTestUtils.setField(service, "chargeAccountSupport", newChargeAccountSupport()); TCardChargingOrderMapper mapper = mock(TCardChargingOrderMapper.class); ChargingCardClient cardClient = mock(ChargingCardClient.class); ReflectionTestUtils.setField(service, "baseMapper", mapper); ReflectionTestUtils.setField(service, "chargingCardClient", cardClient); TCardChargingOrder candidate = new TCardChargingOrder(); candidate.setId(7L); candidate.setCode("SD-CONSUME-UNKNOWN"); candidate.setStatus(5); candidate.setCardId(5L); candidate.setOrderAmount(BigDecimal.ONE); when(mapper.selectList(any())).thenReturn(Collections.singletonList(candidate)); R> noDeductRecords = okWithData(Collections.emptyList()); when(cardClient.listDeductedOrderCodes(any())).thenReturn(noDeductRecords); when(companyAllocateRecordClient.listConsumedOrderCodes(any())).thenReturn(R.fail("account unavailable")); assertEquals(0, service.recoverFinishedOrdersWithoutDeduct()); verify(mapper, never()).update(any(), any()); } @Test void rollsBackOrderWhenNoAccountingStrategyConfigured() throws InterruptedException { // 点5:枪与站点均未配置计费策略(strategyId 为空)时必须回滚删单并回告启动失败,禁止产生无策略订单 TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl(); TCardChargingOrderMapper mapper = mock(TCardChargingOrderMapper.class); ChargingCardClient cardClient = mock(ChargingCardClient.class); ChargingGunClient gunClient = mock(ChargingGunClient.class); GroupCompanyClient groupCompanyClient = mock(GroupCompanyClient.class); ChargeAuthorizationClient chargeAuthorizationClient = mock(ChargeAuthorizationClient.class); ReflectionTestUtils.setField(service, "baseMapper", mapper); ReflectionTestUtils.setField(service, "chargingCardClient", cardClient); ReflectionTestUtils.setField(service, "chargingGunClient", gunClient); ReflectionTestUtils.setField(service, "chargeAuthorizationClient", chargeAuthorizationClient); ReflectionTestUtils.setField(service, "groupCompanyClient", groupCompanyClient); ChargeAccountSupport chargeAccountSupport = newChargeAccountSupport(); ReflectionTestUtils.setField(chargeAccountSupport, "groupCompanyClient", groupCompanyClient); ReflectionTestUtils.setField(service, "chargeAccountSupport", chargeAccountSupport); ReflectionTestUtils.setField(service, "redissonClient", mockedRedisson(mockedLock(true))); when(mapper.insert(any(TCardChargingOrder.class))).thenAnswer(invocation -> { TCardChargingOrder entity = invocation.getArgument(0); entity.setId(101L); return 1; }); when(mapper.deleteById(anyLong())).thenReturn(1); TChargingGun gun = new TChargingGun(); gun.setId(9); gun.setStatus(2); R gunResult = okWithData(gun); when(gunClient.getChargingGunByCode(any(GetChargingGunByCode.class))).thenReturn(gunResult); TChargingCard card = new TChargingCard(); card.setId(5L); card.setCardNo("C001"); card.setCompanyId(77); card.setBalance(new BigDecimal("100.00")); R verifyResult = okWithData(card); when(cardClient.verifyCharging(any(ChargingCardAuthDTO.class))).thenReturn(verifyResult); TGroupCompany company = new TGroupCompany(); company.setId(77); company.setStatus(1); company.setAccountMode(1); company.setAvailableBalance(new BigDecimal("100.00")); R companyResult = okWithData(company); when(groupCompanyClient.getCompanyById(77)).thenReturn(companyResult); DeviceStartChargingDTO dto = new DeviceStartChargingDTO(); dto.setChargingPileCode("P1"); dto.setChargingGunCode("G1"); dto.setAccount("C001"); DeviceStartChargingVO result = service.startCardCharging(dto); assertEquals(0, result.getAuthentication()); // 订单回滚删除,且未锁定账户额度、未锁卡 verify(mapper).deleteById(anyLong()); verify(groupCompanyClient, never()).lockBalance(any(), any()); verify(cardClient, never()).lockForCharging(anyLong(), anyString()); } @Test void sharedModeSettlementPassesOrderCodeAndSkipsLocalConsumeRecord() { // 共享模式:结算传订单号启用账户侧幂等(账户侧写企业消费流水),订单侧不得再写一份导致重复记账 TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl(); ReflectionTestUtils.setField(service, "chargeAccountSupport", newChargeAccountSupport()); GroupCompanyClient groupCompanyClient = mock(GroupCompanyClient.class); ReflectionTestUtils.setField(service, "groupCompanyClient", groupCompanyClient); TCardChargingOrder order = new TCardChargingOrder(); order.setId(8L); order.setCode("SD-SHARED-SETTLE"); order.setCardId(5L); order.setCardNo("C001"); order.setCompanyId(77); order.setAccountMode(ChargeAccountSupport.ACCOUNT_MODE_SHARED); order.setLockedAmount(new BigDecimal("200.00")); R settleResult = okWithData(new BigDecimal("663.50")); when(groupCompanyClient.settleBalance(eq(77), eq(new BigDecimal("200.00")), eq(new BigDecimal("36.50")), eq("SD-SHARED-SETTLE"), eq("C001"), isNull(), eq("刷卡充电结算"))).thenReturn(settleResult); Boolean settled = ReflectionTestUtils.invokeMethod(service, "settleCardChargeAccount", order, new BigDecimal("36.50"), "SD-SHARED-SETTLE"); assertTrue(settled); verify(companyAllocateRecordClient, never()).recordConsume(any(), any(), any(), any(), any(), any(), any(), any()); } @Test void allocatedModeSettlementWritesCardConsumeRecord() { // 分配模式:扣的是卡账户余额,消费流水仍由订单侧记到该卡上(user_type=卡) TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl(); ReflectionTestUtils.setField(service, "chargeAccountSupport", newChargeAccountSupport()); ChargingCardClient cardClient = mock(ChargingCardClient.class); ReflectionTestUtils.setField(service, "chargingCardClient", cardClient); TCardChargingOrder order = new TCardChargingOrder(); order.setId(9L); order.setCode("SD-ALLOC-SETTLE"); order.setCardId(5L); order.setCardNo("C001"); order.setCompanyId(77); order.setAccountMode(ChargeAccountSupport.ACCOUNT_MODE_ALLOCATED); order.setLockedAmount(new BigDecimal("200.00")); ChargingCardDeductVO deductVO = new ChargingCardDeductVO(); deductVO.setDuplicated(false); deductVO.setAfterBalance(new BigDecimal("63.50")); R settleCardResult = okWithData(deductVO); when(cardClient.settleForCharging(any(ChargingCardDeductDTO.class))).thenReturn(settleCardResult); Boolean settled = ReflectionTestUtils.invokeMethod(service, "settleCardChargeAccount", order, new BigDecimal("36.50"), "SD-ALLOC-SETTLE"); assertTrue(settled); verify(companyAllocateRecordClient).recordConsume(eq(77), eq(TCompanyAllocateRecord.USER_TYPE_CARD), eq("C001"), eq("C001"), any(), eq(new BigDecimal("-36.50")), eq("SD-ALLOC-SETTLE"), eq("刷卡充电结算")); } @Test void skipsUnlockWhenLockReleasedFlagAlreadySet() { // 点2-D:lock_released 抢占未命中(标记已是 1)说明锁定额已释放过,禁止重复解锁 TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl(); TCardChargingOrderMapper mapper = mock(TCardChargingOrderMapper.class); GroupCompanyClient groupCompanyClient = mock(GroupCompanyClient.class); ChargingCardClient cardClient = mock(ChargingCardClient.class); ReflectionTestUtils.setField(service, "baseMapper", mapper); ReflectionTestUtils.setField(service, "groupCompanyClient", groupCompanyClient); ReflectionTestUtils.setField(service, "chargingCardClient", cardClient); when(mapper.update(any(), any())).thenReturn(0); TCardChargingOrder order = new TCardChargingOrder(); order.setId(10L); order.setCode("SD-RELEASED"); order.setCompanyId(77); order.setAccountMode(ChargeAccountSupport.ACCOUNT_MODE_SHARED); order.setLockedAmount(new BigDecimal("200.00")); ReflectionTestUtils.invokeMethod(service, "unlockCardChargeContextQuietly", order, "启动关单"); verify(groupCompanyClient, never()).unlockBalance(any(), any()); } }