From 3650ce7f5d42d1bef58abea9693f20d1f209d3b0 Mon Sep 17 00:00:00 2001
From: puzhibing <393733352@qq.com>
Date: 星期六, 10 十月 2026 14:02:27 +0800
Subject: [PATCH] 修改bug
---
ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TCompanyAllocateRecordServiceImplTest.java | 142 ++++++++
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TVinChargingOrderMapper.java | 5
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TCompanyAllocateRecordController.java | 33 +
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderServiceImpl.java | 8
ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TChargingCardMapper.xml | 11
ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TVinChargingOrderMapper.xml | 2
ruoyi-service/ruoyi-other/src/test/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImplTest.java | 218 ++++++++++++
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TCompanyAllocateRecordServiceImpl.java | 232 +++++++++++++
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImpl.java | 164 +++++++++
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/vo/CompanyAllocateSummaryVO.java | 31 +
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TVinChargingOrderAppletServiceTest.java | 30 +
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TAppUserWhitelistMapper.java | 5
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/mapper/VinChargingOrderMapperStructureTest.java | 1
ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/factory/ChargingOrderFallbackFactory.java | 5
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/model/TCompanyAllocateRecord.java | 2
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/feignClient/CompanyAllocateRecordClient.java | 28 +
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TVinManagementMapper.java | 2
ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/feignClient/ChargingOrderClient.java | 10
ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TVinManagementMapper.xml | 5
ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TAppUserWhitelistMapper.xml | 5
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TCompanyAllocateRecordService.java | 23 +
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TChargingOrderController.java | 36 +
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/factory/CompanyAllocateRecordFallbackFactory.java | 12
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TChargingCardMapper.java | 5
24 files changed, 997 insertions(+), 18 deletions(-)
diff --git a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/factory/CompanyAllocateRecordFallbackFactory.java b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/factory/CompanyAllocateRecordFallbackFactory.java
index 2f2fe1e..1800f0d 100644
--- a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/factory/CompanyAllocateRecordFallbackFactory.java
+++ b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/factory/CompanyAllocateRecordFallbackFactory.java
@@ -1,6 +1,7 @@
package com.ruoyi.account.api.factory;
import com.ruoyi.account.api.feignClient.CompanyAllocateRecordClient;
+import com.ruoyi.account.api.vo.CompanyAllocateSummaryVO;
import com.ruoyi.common.core.domain.R;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -35,6 +36,17 @@
public R<List<String>> listConsumedOrderCodes(List<String> orderCodes) {
return R.fail("查询消费流水订单号失败:" + throwable.getMessage());
}
+
+ @Override
+ public R<CompanyAllocateSummaryVO> sumAllocatedBalance(Integer companyId) {
+ return R.fail("统计公司分配余额失败:" + throwable.getMessage());
+ }
+
+ @Override
+ public R<BigDecimal> recycleAllocatedBalance(Integer companyId, String operatorName, String companyName,
+ BigDecimal companyBalanceBefore) {
+ return R.fail("回收公司分配余额失败:" + throwable.getMessage());
+ }
};
}
}
diff --git a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/feignClient/CompanyAllocateRecordClient.java b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/feignClient/CompanyAllocateRecordClient.java
index 72239b8..80b6af4 100644
--- a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/feignClient/CompanyAllocateRecordClient.java
+++ b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/feignClient/CompanyAllocateRecordClient.java
@@ -1,6 +1,7 @@
package com.ruoyi.account.api.feignClient;
import com.ruoyi.account.api.factory.CompanyAllocateRecordFallbackFactory;
+import com.ruoyi.account.api.vo.CompanyAllocateSummaryVO;
import com.ruoyi.common.core.constant.ServiceNameConstants;
import com.ruoyi.common.core.domain.R;
import org.springframework.cloud.openfeign.FeignClient;
@@ -51,4 +52,29 @@
*/
@PostMapping("/company-allocate-record/inner/list-consumed-order-codes")
R<List<String>> listConsumedOrderCodes(@RequestBody List<String> orderCodes);
- }
+
+ /**
+ * 统计公司名下三类主体(VIN码/刷卡/白名单手机号)可用余额与充电锁定金额合计(只读,
+ * 分配模式切换为共享模式前的校验使用)。
+ *
+ * @param companyId 所属公司ID
+ */
+ @PostMapping("/company-allocate-record/inner/sum-allocated-balance")
+ R<CompanyAllocateSummaryVO> sumAllocatedBalance(@RequestParam("companyId") Integer companyId);
+
+ /**
+ * 回收公司名下三类主体全部分配余额至公司账户(分配模式切换为共享模式时调用)。
+ * 幂等:余额已清零的主体不重复回收、不重复写流水;返回本次实际回收总额。
+ *
+ * @param companyId 所属公司ID
+ * @param operatorName 操作人账号
+ * @param companyName 公司名称(企业账户流水 userInfo 使用,可为空)
+ * @param companyBalanceBefore 回退前公司可用余额(企业账户正数流水 beforeBalance 使用)
+ * @return 本次实际回收总额(元,无余额时返回 0)
+ */
+ @PostMapping("/company-allocate-record/inner/recycle-allocated-balance")
+ R<BigDecimal> recycleAllocatedBalance(@RequestParam("companyId") Integer companyId,
+ @RequestParam("operatorName") String operatorName,
+ @RequestParam(value = "companyName", required = false) String companyName,
+ @RequestParam("companyBalanceBefore") BigDecimal companyBalanceBefore);
+}
diff --git a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/model/TCompanyAllocateRecord.java b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/model/TCompanyAllocateRecord.java
index b653e84..77ede78 100644
--- a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/model/TCompanyAllocateRecord.java
+++ b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/model/TCompanyAllocateRecord.java
@@ -38,6 +38,8 @@
public static final int SOURCE_EDIT = 2;
/** 变更来源:额度分配(额度分配弹窗) */
public static final int SOURCE_ALLOCATE = 3;
+ /** 变更来源:账户模式切换(分配模式切换为共享模式,回收用户余额至公司账户) */
+ public static final int SOURCE_MODE_SWITCH = 4;
/** 变更类型:分配(历史数据默认值,金额由公司划拨到用户账户) */
public static final int CHANGE_TYPE_ALLOCATE = 1;
diff --git a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/vo/CompanyAllocateSummaryVO.java b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/vo/CompanyAllocateSummaryVO.java
new file mode 100644
index 0000000..b81f1a3
--- /dev/null
+++ b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/vo/CompanyAllocateSummaryVO.java
@@ -0,0 +1,31 @@
+package com.ruoyi.account.api.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+/**
+ * 公司名下分配余额汇总(VIN码/刷卡卡/白名单手机号三类主体余额与锁定金额合计),
+ * 供「分配模式切换为共享模式」时校验可回退金额与在途锁定。
+ */
+@Data
+@ApiModel(value = "公司分配余额汇总")
+public class CompanyAllocateSummaryVO implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ @ApiModelProperty(value = "所属公司ID")
+ private Integer companyId;
+
+ @ApiModelProperty(value = "三类主体可用余额合计(元)")
+ private BigDecimal totalBalance;
+
+ @ApiModelProperty(value = "三类主体充电锁定金额合计(元)")
+ private BigDecimal totalLocked;
+
+ @ApiModelProperty(value = "余额大于0的主体数量")
+ private Integer balanceUserCount;
+}
diff --git a/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/factory/ChargingOrderFallbackFactory.java b/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/factory/ChargingOrderFallbackFactory.java
index 7a30556..e36fa94 100644
--- a/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/factory/ChargingOrderFallbackFactory.java
+++ b/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/factory/ChargingOrderFallbackFactory.java
@@ -259,6 +259,11 @@
public R<DeviceStartChargingVO> deviceStartCharging(DeviceStartChargingDTO dto) {
return R.fail("设备主动发起充电鉴权建单失败:" + throwable.getMessage());
}
+
+ @Override
+ public R<Integer> countActiveByCompany(Integer companyId) {
+ return R.fail("统计公司进行中充电订单失败:" + throwable.getMessage());
+ }
};
}
}
diff --git a/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/feignClient/ChargingOrderClient.java b/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/feignClient/ChargingOrderClient.java
index 1f09cd7..d762d88 100644
--- a/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/feignClient/ChargingOrderClient.java
+++ b/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/feignClient/ChargingOrderClient.java
@@ -373,4 +373,14 @@
*/
@PostMapping("/t-charging-order/inner/device-start-charging")
R<DeviceStartChargingVO> deviceStartCharging(@RequestBody DeviceStartChargingDTO dto);
+
+ /**
+ * 统计公司名下进行中的充电订单数(三表合计,状态 2=启动中/3=充电中/4=停止中,
+ * 账户模式切换:分配→共享 前校验使用)
+ *
+ * @param companyId 所属公司ID
+ * @return 进行中订单数
+ */
+ @PostMapping("/t-charging-order/inner/count-active-by-company")
+ R<Integer> countActiveByCompany(@RequestParam("companyId") Integer companyId);
}
diff --git a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TCompanyAllocateRecordController.java b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TCompanyAllocateRecordController.java
index 7733a5d..53efbf8 100644
--- a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TCompanyAllocateRecordController.java
+++ b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TCompanyAllocateRecordController.java
@@ -3,6 +3,7 @@
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.ruoyi.account.api.dto.CompanyAllocateRecordQueryDTO;
import com.ruoyi.account.api.model.TCompanyAllocateRecord;
+import com.ruoyi.account.api.vo.CompanyAllocateSummaryVO;
import com.ruoyi.account.service.TCompanyAllocateRecordService;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.web.controller.BaseController;
@@ -70,4 +71,36 @@
return R.fail("查询消费流水订单号失败:" + ex.getMessage());
}
}
+
+ /**
+ * 内部接口:统计公司名下三类主体可用余额与充电锁定金额合计(账户模式切换前校验使用,只读)
+ */
+ @ApiOperation(value = "内部-统计公司分配余额", tags = {"内部调用"})
+ @PostMapping("/inner/sum-allocated-balance")
+ public R<CompanyAllocateSummaryVO> sumAllocatedBalance(@RequestParam("companyId") Integer companyId) {
+ try {
+ return R.ok(companyAllocateRecordService.sumAllocatedBalance(companyId));
+ } catch (RuntimeException ex) {
+ logger.error("统计公司分配余额失败:companyId={}", companyId, ex);
+ return R.fail("统计公司分配余额失败:" + ex.getMessage());
+ }
+ }
+
+ /**
+ * 内部接口:回收公司名下三类主体全部分配余额(账户模式切换:分配→共享,幂等)
+ */
+ @ApiOperation(value = "内部-回收公司分配余额", tags = {"内部调用"})
+ @PostMapping("/inner/recycle-allocated-balance")
+ public R<BigDecimal> recycleAllocatedBalance(@RequestParam("companyId") Integer companyId,
+ @RequestParam("operatorName") String operatorName,
+ @RequestParam(value = "companyName", required = false) String companyName,
+ @RequestParam("companyBalanceBefore") BigDecimal companyBalanceBefore) {
+ try {
+ return R.ok(companyAllocateRecordService.recycleAllocatedBalance(companyId, operatorName, companyName,
+ companyBalanceBefore));
+ } catch (RuntimeException ex) {
+ logger.error("回收公司分配余额失败:companyId={}", companyId, ex);
+ return R.fail("回收公司分配余额失败:" + ex.getMessage());
+ }
+ }
}
diff --git a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TAppUserWhitelistMapper.java b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TAppUserWhitelistMapper.java
index 90f530d..1496cc3 100644
--- a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TAppUserWhitelistMapper.java
+++ b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TAppUserWhitelistMapper.java
@@ -36,4 +36,9 @@
* @return 更新行数
*/
int addBalance(@Param("id") Long id, @Param("amount") BigDecimal amount, @Param("updateBy") String updateBy);
+
+ /**
+ * 原子清零余额(账户模式切换回收专用:仅当余额>0时清零,返回受影响行数,天然幂等)
+ */
+ int clearBalance(@Param("id") Long id, @Param("updateBy") String updateBy);
}
diff --git a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TChargingCardMapper.java b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TChargingCardMapper.java
index bdf9fde..3c336ac 100644
--- a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TChargingCardMapper.java
+++ b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TChargingCardMapper.java
@@ -59,4 +59,9 @@
* 批量查询已存在的充电扣款流水订单号。
*/
List<String> selectDeductedOrderCodes(@Param("orderCodes") List<String> orderCodes);
+
+ /**
+ * 原子清零余额(账户模式切换回收专用:仅当余额>0时清零,返回受影响行数,天然幂等)
+ */
+ int clearBalance(@Param("id") Long id, @Param("updateBy") Long updateBy);
}
diff --git a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TVinManagementMapper.java b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TVinManagementMapper.java
index 2969f09..45a1ebb 100644
--- a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TVinManagementMapper.java
+++ b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TVinManagementMapper.java
@@ -16,6 +16,8 @@
int countByVin(@Param("vin") String vin, @Param("excludeId") Long excludeId);
/** 余额原子累加(额度分配专用,null 视为 0) */
int addBalance(@Param("id") Long id, @Param("amount") BigDecimal amount, @Param("updateBy") Long updateBy);
+ /** 原子清零余额(账户模式切换回收专用:仅当余额>0时清零,返回受影响行数,天然幂等) */
+ int clearBalance(@Param("id") Long id, @Param("updateBy") Long updateBy);
List<TVinManagement> selectAppletVehicleList(@Param("appUserId") Long appUserId);
TVinManagement selectEnabledVehicle(@Param("keyword") String keyword);
int bindAppletVehicle(@Param("id") Long id, @Param("appUserId") Long appUserId);
diff --git a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TCompanyAllocateRecordService.java b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TCompanyAllocateRecordService.java
index 169c3ab..95e71de 100644
--- a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TCompanyAllocateRecordService.java
+++ b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TCompanyAllocateRecordService.java
@@ -4,6 +4,7 @@
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.account.api.dto.CompanyAllocateRecordQueryDTO;
import com.ruoyi.account.api.model.TCompanyAllocateRecord;
+import com.ruoyi.account.api.vo.CompanyAllocateSummaryVO;
import java.math.BigDecimal;
@@ -70,4 +71,26 @@
* @return 已存在消费流水的订单号集合,无匹配返回空集合
*/
java.util.List<String> listConsumedOrderCodes(java.util.List<String> orderCodes);
+
+ /**
+ * 统计公司名下三类主体(VIN码/刷卡/白名单手机号)可用余额与充电锁定金额合计(只读,
+ * 账户模式从分配切换为共享前的校验与预加回使用)。
+ *
+ * @param companyId 所属公司ID
+ */
+ CompanyAllocateSummaryVO sumAllocatedBalance(Integer companyId);
+
+ /**
+ * 回收公司名下三类主体全部分配余额至公司账户(账户模式:分配→共享)。
+ * <p>内部按固定顺序抢占 VIN/卡/白名单三把公司级并发锁,与额度分配操作串行;
+ * 余额清零与流水写入同事务,幂等(重复调用时无余额可回收,返回 0,不重复写流水)。</p>
+ *
+ * @param companyId 所属公司ID
+ * @param operatorName 操作人账号
+ * @param companyName 公司名称(企业账户流水 userInfo,可为空)
+ * @param companyBalanceBefore 回退前公司可用余额(企业账户正数流水 beforeBalance)
+ * @return 本次实际回收总额(元,无余额时返回 0)
+ */
+ BigDecimal recycleAllocatedBalance(Integer companyId, String operatorName, String companyName,
+ BigDecimal companyBalanceBefore);
}
diff --git a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TCompanyAllocateRecordServiceImpl.java b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TCompanyAllocateRecordServiceImpl.java
index 0490675..794d49d 100644
--- a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TCompanyAllocateRecordServiceImpl.java
+++ b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TCompanyAllocateRecordServiceImpl.java
@@ -4,18 +4,32 @@
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.account.api.dto.CompanyAllocateRecordQueryDTO;
+import com.ruoyi.account.api.model.TAppUserWhitelist;
+import com.ruoyi.account.api.model.TChargingCard;
import com.ruoyi.account.api.model.TCompanyAllocateRecord;
+import com.ruoyi.account.api.model.TVinManagement;
+import com.ruoyi.account.api.vo.CompanyAllocateSummaryVO;
+import com.ruoyi.account.mapper.TAppUserWhitelistMapper;
+import com.ruoyi.account.mapper.TChargingCardMapper;
import com.ruoyi.account.mapper.TCompanyAllocateRecordMapper;
+import com.ruoyi.account.mapper.TVinManagementMapper;
import com.ruoyi.account.service.TCompanyAllocateRecordService;
+import com.ruoyi.common.core.exception.ServiceException;
import com.ruoyi.common.core.utils.StringUtils;
import com.ruoyi.common.security.utils.SecurityUtils;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import javax.annotation.Resource;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
/**
* 公司额度分配记录服务实现:对外提供分页查询与统一流水写入。
@@ -34,6 +48,27 @@
private static final int MAX_REMARK_LENGTH = 255;
/** charging_order_code 列长度(varchar(64)) */
private static final int MAX_ORDER_CODE_LENGTH = 64;
+
+ /** 模式切换回收锁:与额度分配同前缀、按公司串行,防止切换与分配并发导致资金不一致 */
+ private static final String RECYCLE_LOCK_PREFIX_VIN = "VIN_QUOTA:COMPANY:";
+ private static final String RECYCLE_LOCK_PREFIX_CARD = "CHARGING_CARD_QUOTA:COMPANY:";
+ private static final String RECYCLE_LOCK_PREFIX_WHITELIST = "APP_USER_WHITELIST_QUOTA:COMPANY:";
+ /** 获取锁最长等待时间(秒) */
+ private static final long LOCK_WAIT_SECONDS = 5L;
+ /** 锁自动释放时间(秒),兜住异常未解锁的场景 */
+ private static final long LOCK_LEASE_SECONDS = 30L;
+
+ @Resource
+ private RedissonClient redissonClient;
+
+ @Resource
+ private TVinManagementMapper vinManagementMapper;
+
+ @Resource
+ private TChargingCardMapper chargingCardMapper;
+
+ @Resource
+ private TAppUserWhitelistMapper appUserWhitelistMapper;
@Override
public IPage<TCompanyAllocateRecord> selectRecordPage(CompanyAllocateRecordQueryDTO query) {
@@ -131,6 +166,203 @@
return consumed;
}
+ @Override
+ public CompanyAllocateSummaryVO sumAllocatedBalance(Integer companyId) {
+ CompanyAllocateSummaryVO summary = new CompanyAllocateSummaryVO();
+ summary.setCompanyId(companyId);
+ BigDecimal totalBalance = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
+ BigDecimal totalLocked = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
+ int balanceUserCount = 0;
+ if (companyId == null) {
+ return summary;
+ }
+ // VIN码(delFlag 逻辑删除由 @TableLogic 自动过滤)
+ List<TVinManagement> vins = vinManagementMapper.selectList(
+ new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TVinManagement>()
+ .eq(TVinManagement::getCompanyId, companyId)
+ .gt(TVinManagement::getBalance, BigDecimal.ZERO));
+ if (vins != null) {
+ for (TVinManagement vin : vins) {
+ totalBalance = totalBalance.add(normalizeMoney(vin.getBalance()));
+ totalLocked = totalLocked.add(normalizeMoney(vin.getLockedAmount()));
+ balanceUserCount++;
+ }
+ }
+ // 刷卡卡
+ List<TChargingCard> cards = chargingCardMapper.selectList(
+ new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TChargingCard>()
+ .eq(TChargingCard::getCompanyId, companyId)
+ .gt(TChargingCard::getBalance, BigDecimal.ZERO));
+ if (cards != null) {
+ for (TChargingCard card : cards) {
+ totalBalance = totalBalance.add(normalizeMoney(card.getBalance()));
+ totalLocked = totalLocked.add(normalizeMoney(card.getLockedAmount()));
+ balanceUserCount++;
+ }
+ }
+ // 白名单手机号
+ List<TAppUserWhitelist> whitelists = appUserWhitelistMapper.selectList(
+ new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TAppUserWhitelist>()
+ .eq(TAppUserWhitelist::getCompanyId, companyId)
+ .gt(TAppUserWhitelist::getBalance, BigDecimal.ZERO));
+ if (whitelists != null) {
+ for (TAppUserWhitelist whitelist : whitelists) {
+ totalBalance = totalBalance.add(normalizeMoney(whitelist.getBalance()));
+ totalLocked = totalLocked.add(normalizeMoney(whitelist.getLockedAmount()));
+ balanceUserCount++;
+ }
+ }
+ summary.setTotalBalance(totalBalance);
+ summary.setTotalLocked(totalLocked);
+ summary.setBalanceUserCount(balanceUserCount);
+ return summary;
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public BigDecimal recycleAllocatedBalance(Integer companyId, String operatorName, String companyName,
+ BigDecimal companyBalanceBefore) {
+ if (companyId == null) {
+ throw new ServiceException("公司ID不能为空");
+ }
+ String operator = truncate(operatorName, MAX_USER_TEXT_LENGTH);
+ // 按固定顺序抢占三把公司级锁(与三类额度分配操作互斥,顺序固定避免死锁)
+ RLock vinLock = redissonClient.getLock(RECYCLE_LOCK_PREFIX_VIN + companyId);
+ RLock cardLock = redissonClient.getLock(RECYCLE_LOCK_PREFIX_CARD + companyId);
+ RLock whitelistLock = redissonClient.getLock(RECYCLE_LOCK_PREFIX_WHITELIST + companyId);
+ boolean vinLocked = tryRecycleLock(vinLock);
+ boolean cardLocked = false;
+ boolean whitelistLocked = false;
+ try {
+ cardLocked = tryRecycleLock(cardLock);
+ try {
+ whitelistLocked = tryRecycleLock(whitelistLock);
+ return doRecycleAllocatedBalance(companyId, operator, companyName, companyBalanceBefore);
+ } finally {
+ unlockRecycle(whitelistLock, whitelistLocked);
+ }
+ } finally {
+ unlockRecycle(cardLock, cardLocked);
+ unlockRecycle(vinLock, vinLocked);
+ }
+ }
+
+ /**
+ * 锁内回收实现:清零三类主体余额并写流水(与余额清零同事务,异常整体回滚),返回实际回收总额。
+ */
+ private BigDecimal doRecycleAllocatedBalance(Integer companyId, String operator, String companyName,
+ BigDecimal companyBalanceBefore) {
+ Long operatorId = currentUserId();
+ BigDecimal total = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
+ String remark = "账户模式切换为共享模式,回收分配余额至公司账户";
+ // VIN码
+ List<TVinManagement> vins = vinManagementMapper.selectList(
+ new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TVinManagement>()
+ .eq(TVinManagement::getCompanyId, companyId)
+ .gt(TVinManagement::getBalance, BigDecimal.ZERO));
+ if (vins != null) {
+ for (TVinManagement vin : vins) {
+ BigDecimal before = normalizeMoney(vin.getBalance());
+ if (vinManagementMapper.clearBalance(vin.getId(), operatorId) == 1) {
+ total = total.add(before);
+ recordModeSwitchRecord(companyId, TCompanyAllocateRecord.USER_TYPE_VIN, vin.getVin(),
+ vin.getPlateNumber(), operator, operatorId, before, before.negate(), remark);
+ }
+ }
+ }
+ // 刷卡卡
+ List<TChargingCard> cards = chargingCardMapper.selectList(
+ new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TChargingCard>()
+ .eq(TChargingCard::getCompanyId, companyId)
+ .gt(TChargingCard::getBalance, BigDecimal.ZERO));
+ if (cards != null) {
+ for (TChargingCard card : cards) {
+ BigDecimal before = normalizeMoney(card.getBalance());
+ if (chargingCardMapper.clearBalance(card.getId(), operatorId) == 1) {
+ total = total.add(before);
+ recordModeSwitchRecord(companyId, TCompanyAllocateRecord.USER_TYPE_CARD, card.getCardNo(),
+ null, operator, operatorId, before, before.negate(), remark);
+ }
+ }
+ }
+ // 白名单手机号
+ List<TAppUserWhitelist> whitelists = appUserWhitelistMapper.selectList(
+ new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TAppUserWhitelist>()
+ .eq(TAppUserWhitelist::getCompanyId, companyId)
+ .gt(TAppUserWhitelist::getBalance, BigDecimal.ZERO));
+ if (whitelists != null) {
+ for (TAppUserWhitelist whitelist : whitelists) {
+ BigDecimal before = normalizeMoney(whitelist.getBalance());
+ if (appUserWhitelistMapper.clearBalance(whitelist.getId(), operator) == 1) {
+ total = total.add(before);
+ recordModeSwitchRecord(companyId, TCompanyAllocateRecord.USER_TYPE_WHITELIST,
+ whitelist.getPhone(), whitelist.getName(), operator, operatorId, before,
+ before.negate(), remark);
+ }
+ }
+ }
+ // 企业账户正数流水:公司余额加回留痕(与用户负流水同事务)
+ if (total.signum() > 0) {
+ String companyInfo = StringUtils.isNotBlank(companyName) ? companyName : "企业账户";
+ recordModeSwitchRecord(companyId, TCompanyAllocateRecord.USER_TYPE_COMPANY, companyInfo, null,
+ operator, operatorId, normalizeMoney(companyBalanceBefore), total, remark);
+ }
+ return total;
+ }
+
+ /** 写入一条模式切换回退流水(change_source=4),写入失败抛出异常由事务回滚保证与余额清零一致 */
+ private void recordModeSwitchRecord(Integer companyId, int userType, String userInfo, String userName,
+ String operator, Long operatorId, BigDecimal beforeBalance,
+ BigDecimal amount, String remark) {
+ BigDecimal before = normalizeMoney(beforeBalance);
+ BigDecimal change = normalizeMoney(amount);
+ TCompanyAllocateRecord record = new TCompanyAllocateRecord();
+ record.setCompanyId(companyId);
+ record.setUserType(userType);
+ record.setUserInfo(truncate(userInfo, MAX_USER_TEXT_LENGTH));
+ record.setUserName(truncate(userName, MAX_USER_TEXT_LENGTH));
+ record.setChangeSource(TCompanyAllocateRecord.SOURCE_MODE_SWITCH);
+ record.setChangeType(TCompanyAllocateRecord.CHANGE_TYPE_ALLOCATE);
+ record.setBeforeBalance(before);
+ record.setAmount(change);
+ record.setAfterBalance(before.add(change));
+ record.setOperatorId(operatorId);
+ record.setOperatorName(operator);
+ record.setOperateTime(LocalDateTime.now());
+ record.setRemark(truncate(remark, MAX_REMARK_LENGTH));
+ record.setCreateTime(record.getOperateTime());
+ if (!save(record)) {
+ throw new ServiceException("模式切换回退流水写入失败");
+ }
+ }
+
+ /** 抢占回收并发锁,抢占失败抛出业务异常 */
+ private boolean tryRecycleLock(RLock lock) {
+ try {
+ if (!lock.tryLock(LOCK_WAIT_SECONDS, LOCK_LEASE_SECONDS, TimeUnit.SECONDS)) {
+ throw new ServiceException("公司额度变更操作正在处理中,请稍后重试");
+ }
+ return true;
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ throw new ServiceException("公司额度变更操作正在处理中,请稍后重试");
+ }
+ }
+
+ /** 释放回收并发锁(仅释放当前线程持有的锁,异常仅记录日志) */
+ private void unlockRecycle(RLock lock, boolean locked) {
+ if (!locked) {
+ return;
+ }
+ try {
+ if (lock.isHeldByCurrentThread()) {
+ lock.unlock();
+ }
+ } catch (Exception ex) {
+ log.warn("模式切换回收锁释放失败,lockName={}", lock.getName(), ex);
+ }
+ }
+
/**
* 消费流水写入公共实现
*
diff --git a/ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TAppUserWhitelistMapper.xml b/ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TAppUserWhitelistMapper.xml
index afcef64..12133d3 100644
--- a/ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TAppUserWhitelistMapper.xml
+++ b/ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TAppUserWhitelistMapper.xml
@@ -59,4 +59,9 @@
set balance = IFNULL(balance, 0) + #{amount}, update_time = now(), update_by = #{updateBy}
where id = #{id} and del_flag = 0
</update>
+ <update id="clearBalance">
+ update t_app_user_whitelist
+ set balance = 0, update_time = now(), update_by = #{updateBy}
+ where id = #{id} and del_flag = 0 and balance > 0
+ </update>
</mapper>
diff --git a/ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TChargingCardMapper.xml b/ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TChargingCardMapper.xml
index fcb5732..134713b 100644
--- a/ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TChargingCardMapper.xml
+++ b/ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TChargingCardMapper.xml
@@ -147,4 +147,15 @@
</foreach>
</select>
+ <!-- 原子清零余额:账户模式切换回收专用,仅当余额>0时清零,天然幂等 -->
+ <update id="clearBalance">
+ UPDATE t_charging_card
+ SET balance = 0,
+ update_time = NOW(),
+ update_by = #{updateBy}
+ WHERE id = #{id}
+ AND del_flag = 0
+ AND balance > 0
+ </update>
+
</mapper>
diff --git a/ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TVinManagementMapper.xml b/ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TVinManagementMapper.xml
index 25964a5..ea14caa 100644
--- a/ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TVinManagementMapper.xml
+++ b/ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TVinManagementMapper.xml
@@ -64,4 +64,9 @@
SET balance = IFNULL(balance, 0) + #{amount}, update_time = NOW(), update_by = #{updateBy}
WHERE id = #{id} AND del_flag = 0
</update>
+ <update id="clearBalance">
+ UPDATE t_vin_management
+ SET balance = 0, update_time = NOW(), update_by = #{updateBy}
+ WHERE id = #{id} AND del_flag = 0 AND balance > 0
+ </update>
</mapper>
diff --git a/ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TCompanyAllocateRecordServiceImplTest.java b/ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TCompanyAllocateRecordServiceImplTest.java
index b55cf0d..b391f11 100644
--- a/ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TCompanyAllocateRecordServiceImplTest.java
+++ b/ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TCompanyAllocateRecordServiceImplTest.java
@@ -3,9 +3,17 @@
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.api.dto.CompanyAllocateRecordQueryDTO;
+import com.ruoyi.account.api.model.TAppUserWhitelist;
+import com.ruoyi.account.api.model.TChargingCard;
import com.ruoyi.account.api.model.TCompanyAllocateRecord;
+import com.ruoyi.account.api.model.TVinManagement;
+import com.ruoyi.account.api.vo.CompanyAllocateSummaryVO;
+import com.ruoyi.account.mapper.TAppUserWhitelistMapper;
+import com.ruoyi.account.mapper.TChargingCardMapper;
import com.ruoyi.account.mapper.TCompanyAllocateRecordMapper;
+import com.ruoyi.account.mapper.TVinManagementMapper;
import com.ruoyi.common.core.context.SecurityContextHolder;
+import com.ruoyi.common.core.exception.ServiceException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -13,17 +21,26 @@
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
@@ -35,13 +52,23 @@
class TCompanyAllocateRecordServiceImplTest {
@Mock private TCompanyAllocateRecordMapper mapper;
+ @Mock private TVinManagementMapper vinMapper;
+ @Mock private TChargingCardMapper cardMapper;
+ @Mock private TAppUserWhitelistMapper whitelistMapper;
+ @Mock private RedissonClient redissonClient;
+ @Mock private RLock lock;
private TCompanyAllocateRecordServiceImpl service;
@BeforeEach
void setUp() {
service = spy(new TCompanyAllocateRecordServiceImpl());
ReflectionTestUtils.setField(service, "baseMapper", mapper);
+ ReflectionTestUtils.setField(service, "vinManagementMapper", vinMapper);
+ ReflectionTestUtils.setField(service, "chargingCardMapper", cardMapper);
+ ReflectionTestUtils.setField(service, "appUserWhitelistMapper", whitelistMapper);
+ ReflectionTestUtils.setField(service, "redissonClient", redissonClient);
lenient().when(mapper.insert(any(TCompanyAllocateRecord.class))).thenReturn(1);
+ lenient().when(redissonClient.getLock(anyString())).thenReturn(lock);
SecurityContextHolder.setUserId("123");
SecurityContextHolder.setUserName("admin");
}
@@ -161,6 +188,121 @@
return captor.getValue();
}
+ // ============ 账户模式切换回收相关 ============
+
+ @Test
+ void sumAllocatedBalanceAggregatesThreeTypes() {
+ TVinManagement vin = new TVinManagement();
+ vin.setId(1L);
+ vin.setBalance(new BigDecimal("10.50"));
+ vin.setLockedAmount(new BigDecimal("2.00"));
+ TVinManagement vin2 = new TVinManagement();
+ vin2.setId(2L);
+ vin2.setBalance(new BigDecimal("5.25"));
+ vin2.setLockedAmount(BigDecimal.ZERO);
+ when(vinMapper.selectList(any())).thenReturn(Arrays.asList(vin, vin2));
+
+ TChargingCard card = new TChargingCard();
+ card.setId(3L);
+ card.setBalance(new BigDecimal("8.00"));
+ card.setLockedAmount(new BigDecimal("1.50"));
+ when(cardMapper.selectList(any())).thenReturn(Collections.singletonList(card));
+
+ TAppUserWhitelist whitelist = new TAppUserWhitelist();
+ whitelist.setId(4L);
+ whitelist.setBalance(new BigDecimal("6.00"));
+ whitelist.setLockedAmount(BigDecimal.ZERO);
+ when(whitelistMapper.selectList(any())).thenReturn(Collections.singletonList(whitelist));
+
+ CompanyAllocateSummaryVO summary = service.sumAllocatedBalance(9);
+
+ assertEquals(9, summary.getCompanyId());
+ assertEquals(new BigDecimal("29.75"), summary.getTotalBalance());
+ assertEquals(new BigDecimal("3.50"), summary.getTotalLocked());
+ assertEquals(4, summary.getBalanceUserCount());
+ }
+
+ @Test
+ void recycleAllocatedBalanceClearsAndWritesBothSides() throws InterruptedException {
+ when(lock.tryLock(5L, 30L, TimeUnit.SECONDS)).thenReturn(true);
+ when(lock.isHeldByCurrentThread()).thenReturn(true);
+
+ TVinManagement vin = new TVinManagement();
+ vin.setId(1L);
+ vin.setBalance(new BigDecimal("10.00"));
+ vin.setVin("LVIN0001");
+ vin.setPlateNumber("粤A12345");
+ when(vinMapper.selectList(any())).thenReturn(Collections.singletonList(vin));
+ when(vinMapper.clearBalance(eq(1L), eq(123L))).thenReturn(1);
+
+ TChargingCard card = new TChargingCard();
+ card.setId(2L);
+ card.setBalance(new BigDecimal("20.00"));
+ card.setCardNo("CARD0001");
+ when(cardMapper.selectList(any())).thenReturn(Collections.singletonList(card));
+ when(cardMapper.clearBalance(eq(2L), eq(123L))).thenReturn(1);
+
+ TAppUserWhitelist whitelist = new TAppUserWhitelist();
+ whitelist.setId(3L);
+ whitelist.setBalance(new BigDecimal("30.00"));
+ whitelist.setPhone("13800000000");
+ whitelist.setName("张三");
+ when(whitelistMapper.selectList(any())).thenReturn(Collections.singletonList(whitelist));
+ when(whitelistMapper.clearBalance(eq(3L), eq("admin"))).thenReturn(1);
+
+ BigDecimal recycled = service.recycleAllocatedBalance(9, "admin", "测试公司", new BigDecimal("100.00"));
+
+ assertEquals(new BigDecimal("60.00"), recycled);
+ verify(vinMapper).clearBalance(1L, 123L);
+ verify(cardMapper).clearBalance(2L, 123L);
+ verify(whitelistMapper).clearBalance(3L, "admin");
+ // 3 条用户负流水 + 1 条企业账户正流水
+ verify(mapper, times(4)).insert(any(TCompanyAllocateRecord.class));
+ ArgumentCaptor<TCompanyAllocateRecord> captor = ArgumentCaptor.forClass(TCompanyAllocateRecord.class);
+ verify(mapper, times(4)).insert(captor.capture());
+ List<TCompanyAllocateRecord> records = captor.getAllValues();
+ TCompanyAllocateRecord companyRecord = records.get(records.size() - 1);
+ assertEquals(TCompanyAllocateRecord.USER_TYPE_COMPANY, companyRecord.getUserType());
+ assertEquals("测试公司", companyRecord.getUserInfo());
+ assertEquals(TCompanyAllocateRecord.SOURCE_MODE_SWITCH, companyRecord.getChangeSource());
+ assertEquals(new BigDecimal("100.00"), companyRecord.getBeforeBalance());
+ assertEquals(new BigDecimal("60.00"), companyRecord.getAmount());
+ assertEquals(new BigDecimal("160.00"), companyRecord.getAfterBalance());
+ for (int i = 0; i < 3; i++) {
+ assertEquals(TCompanyAllocateRecord.SOURCE_MODE_SWITCH, records.get(i).getChangeSource());
+ assertTrue(records.get(i).getAmount().signum() < 0);
+ }
+ }
+
+ @Test
+ void recycleAllocatedBalanceIsIdempotentWhenNoBalanceLeft() throws InterruptedException {
+ when(lock.tryLock(5L, 30L, TimeUnit.SECONDS)).thenReturn(true);
+ when(lock.isHeldByCurrentThread()).thenReturn(true);
+ when(vinMapper.selectList(any())).thenReturn(Collections.emptyList());
+ when(cardMapper.selectList(any())).thenReturn(Collections.emptyList());
+ when(whitelistMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+ BigDecimal recycled = service.recycleAllocatedBalance(9, "admin", "测试公司", new BigDecimal("100.00"));
+
+ assertEquals(new BigDecimal("0.00"), recycled);
+ verify(mapper, never()).insert(any(TCompanyAllocateRecord.class));
+ }
+
+ @Test
+ void recycleAllocatedBalanceThrowsWhenLockBusy() throws InterruptedException {
+ when(lock.tryLock(5L, 30L, TimeUnit.SECONDS)).thenReturn(false);
+
+ assertThrows(ServiceException.class,
+ () -> service.recycleAllocatedBalance(9, "admin", "测试公司", new BigDecimal("100.00")));
+ verify(vinMapper, never()).selectList(any());
+ }
+
+ @Test
+ void recycleAllocatedBalanceRejectsMissingCompany() {
+ assertThrows(ServiceException.class, () -> service.recycleAllocatedBalance(null, "admin", null, null));
+ verify(mapper, never()).insert(any(TCompanyAllocateRecord.class));
+ }
+
private String repeat(char value, int count) {
StringBuilder builder = new StringBuilder(count);
for (int i = 0; i < count; i++) {
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TChargingOrderController.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TChargingOrderController.java
index 0ec1b7d..40ef089 100644
--- a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TChargingOrderController.java
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TChargingOrderController.java
@@ -2432,10 +2432,38 @@
}
return R.ok(result);
}
-
-
-
-
+
+ /**
+ * 内部接口:统计公司名下进行中充电订单数(三表合计,状态 2=启动中/3=充电中/4=停止中,
+ * 账户模式切换:分配→共享 前校验使用)
+ */
+ @PostMapping("/inner/count-active-by-company")
+ public R<Integer> countActiveByCompany(@RequestParam("companyId") Integer companyId) {
+ if (companyId == null) {
+ return R.ok(0);
+ }
+ try {
+ List<Integer> activeStatuses = Arrays.asList(2, 3, 4);
+ long count = 0L;
+ count += chargingOrderService.lambdaQuery()
+ .eq(TChargingOrder::getCompanyId, companyId)
+ .in(TChargingOrder::getStatus, activeStatuses)
+ .count();
+ count += cardChargingOrderService.lambdaQuery()
+ .eq(TCardChargingOrder::getCompanyId, companyId)
+ .in(TCardChargingOrder::getStatus, activeStatuses)
+ .count();
+ count += vinChargingOrderService.lambdaQuery()
+ .eq(TVinChargingOrder::getCompanyId, companyId)
+ .in(TVinChargingOrder::getStatus, activeStatuses)
+ .count();
+ return R.ok((int) count);
+ } catch (RuntimeException ex) {
+ log.error("统计公司进行中充电订单失败:companyId={}", companyId, ex);
+ return R.fail("统计公司进行中充电订单失败:" + ex.getMessage());
+ }
+ }
+
@RequiresPermissions(value = {"/chargingOperationAnalysis"}, logical = Logical.OR)
@ResponseBody
@PostMapping(value = "/charging/statistics")
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TVinChargingOrderMapper.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TVinChargingOrderMapper.java
index ec8ffb6..1a280a0 100644
--- a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TVinChargingOrderMapper.java
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TVinChargingOrderMapper.java
@@ -19,9 +19,10 @@
long count(@Param("query") VinChargingOrderQuery query);
- long countAppletOrders(@Param("vinIds") List<Long> vinIds);
+ long countAppletOrders(@Param("appUserId") Long appUserId, @Param("vinIds") List<Long> vinIds);
- List<MyVinChargingOrderList> pageAppletOrders(@Param("vinIds") List<Long> vinIds,
+ List<MyVinChargingOrderList> pageAppletOrders(@Param("appUserId") Long appUserId,
+ @Param("vinIds") List<Long> vinIds,
@Param("offset") long offset,
@Param("pageSize") long pageSize);
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderServiceImpl.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderServiceImpl.java
index 800454b..a1d3852 100644
--- a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderServiceImpl.java
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderServiceImpl.java
@@ -251,10 +251,10 @@
page.setRecords(Collections.emptyList());
return page;
}
- long total = baseMapper.countAppletOrders(vinIds);
+ long total = baseMapper.countAppletOrders(appUserId, vinIds);
page.setTotal(total);
List<MyVinChargingOrderList> records = total == 0 ? Collections.emptyList()
- : baseMapper.pageAppletOrders(vinIds, (long) (current - 1) * size, size);
+ : baseMapper.pageAppletOrders(appUserId, vinIds, (long) (current - 1) * size, size);
fillAppletExtra(records);
page.setRecords(records);
return page;
@@ -482,7 +482,9 @@
tokenService.getLoginUserApplet().getUserId());
List<Long> vinIds = response == null ? Collections.emptyList()
: Optional.ofNullable(response.getData()).orElse(Collections.emptyList());
- return vinIds.contains(order.getVinId()) ? order : null;
+ // VIN 可换绑:除校验 VIN 当前归属外,还必须校验订单下单用户与当前登录用户一致,防止新绑定用户查看他人历史订单
+ Long appUserId = tokenService.getLoginUserApplet().getUserId();
+ return vinIds.contains(order.getVinId()) && appUserId.equals(order.getAppUserId()) ? order : null;
}
private Long toEpochMilli(LocalDateTime time) {
diff --git a/ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TVinChargingOrderMapper.xml b/ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TVinChargingOrderMapper.xml
index 38d4d93..93ffe57 100644
--- a/ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TVinChargingOrderMapper.xml
+++ b/ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TVinChargingOrderMapper.xml
@@ -42,6 +42,7 @@
SELECT COUNT(1)
FROM t_vin_charging_order o
WHERE o.del_flag = 0
+ AND o.app_user_id = #{appUserId}
AND o.vin_id IN
<foreach collection="vinIds" item="vinId" open="(" separator="," close=")">
#{vinId}
@@ -63,6 +64,7 @@
) THEN 2 ELSE 1 END AS evaluate
FROM t_vin_charging_order o
WHERE o.del_flag = 0
+ AND o.app_user_id = #{appUserId}
AND o.vin_id IN
<foreach collection="vinIds" item="vinId" open="(" separator="," close=")">
#{vinId}
diff --git a/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/mapper/VinChargingOrderMapperStructureTest.java b/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/mapper/VinChargingOrderMapperStructureTest.java
index 9133b7d..31376dd 100644
--- a/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/mapper/VinChargingOrderMapperStructureTest.java
+++ b/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/mapper/VinChargingOrderMapperStructureTest.java
@@ -37,6 +37,7 @@
assertTrue(mapper.contains("group by charging_order_id"));
assertTrue(mapper.contains("pageappletorders"));
assertTrue(mapper.contains("o.vin_id in"));
+ assertTrue(mapper.contains("o.app_user_id = #{appuserid}"));
assertTrue(mapper.contains("order by o.create_time desc, o.id desc"));
assertTrue(mapper.contains("limit #{offset}, #{pagesize}"));
assertTrue(mapper.contains("getchargingvinstatus"));
diff --git a/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TVinChargingOrderAppletServiceTest.java b/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TVinChargingOrderAppletServiceTest.java
index ec0ba6a..2f39d2d 100644
--- a/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TVinChargingOrderAppletServiceTest.java
+++ b/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TVinChargingOrderAppletServiceTest.java
@@ -27,6 +27,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -61,10 +62,10 @@
loginUser.setUserId(88L);
when(tokenService.getLoginUserApplet()).thenReturn(loginUser);
when(vinManagementClient.getVinIdsByAppUserId(88L)).thenReturn(R.ok(Arrays.asList(11L, 22L)));
- when(mapper.countAppletOrders(Arrays.asList(11L, 22L))).thenReturn(1L);
+ when(mapper.countAppletOrders(88L, Arrays.asList(11L, 22L))).thenReturn(1L);
MyVinChargingOrderList order = new MyVinChargingOrderList();
order.setId(1001L);
- when(mapper.pageAppletOrders(Arrays.asList(11L, 22L), 10L, 10L))
+ when(mapper.pageAppletOrders(88L, Arrays.asList(11L, 22L), 10L, 10L))
.thenReturn(Collections.singletonList(order));
PageInfo<MyVinChargingOrderList> result = service.pageAppletOrders(2, 10);
@@ -72,7 +73,8 @@
assertEquals(1L, result.getTotal());
assertEquals(1001L, result.getRecords().get(0).getId());
verify(vinManagementClient).getVinIdsByAppUserId(88L);
- verify(mapper).pageAppletOrders(Arrays.asList(11L, 22L), 10L, 10L);
+ verify(mapper).countAppletOrders(88L, Arrays.asList(11L, 22L));
+ verify(mapper).pageAppletOrders(88L, Arrays.asList(11L, 22L), 10L, 10L);
}
@Test
@@ -86,8 +88,8 @@
assertEquals(0L, result.getTotal());
assertEquals(0, result.getRecords().size());
- verify(mapper, never()).countAppletOrders(anyList());
- verify(mapper, never()).pageAppletOrders(anyList(), eq(0L), eq(10L));
+ verify(mapper, never()).countAppletOrders(anyLong(), anyList());
+ verify(mapper, never()).pageAppletOrders(anyLong(), anyList(), eq(0L), eq(10L));
}
@Test
@@ -99,6 +101,7 @@
TVinChargingOrder order = new TVinChargingOrder();
order.setId(1001L);
order.setVinId(11L);
+ order.setAppUserId(88L);
order.setCode("VD_1001");
order.setOrderAmount(new BigDecimal("18.50"));
order.setElectrovalence(new BigDecimal("12.30"));
@@ -129,4 +132,21 @@
assertTrue(service.appletDetail(1001L).isEmpty());
verify(mapper, never()).accountingStrategyList(1001L);
}
+
+ @Test
+ void returnsEmptyDetailWhenVinReboundAndOrderBelongsToPreviousUser() {
+ LoginUserApplet loginUser = new LoginUserApplet();
+ loginUser.setUserId(88L);
+ when(tokenService.getLoginUserApplet()).thenReturn(loginUser);
+ // VIN 11 当前绑定用户 88,但该订单是换绑前用户 99 下单的,不能查看
+ when(vinManagementClient.getVinIdsByAppUserId(88L)).thenReturn(R.ok(Collections.singletonList(11L)));
+ TVinChargingOrder order = new TVinChargingOrder();
+ order.setId(1001L);
+ order.setVinId(11L);
+ order.setAppUserId(99L);
+ when(mapper.selectById(1001L)).thenReturn(order);
+
+ assertTrue(service.appletDetail(1001L).isEmpty());
+ verify(mapper, never()).accountingStrategyList(1001L);
+ }
}
diff --git a/ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImpl.java b/ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImpl.java
index 28eb07a..844d3fb 100644
--- a/ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImpl.java
+++ b/ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImpl.java
@@ -10,10 +10,13 @@
import com.ruoyi.account.api.model.TChargingCard;
import com.ruoyi.account.api.model.TCompanyAllocateRecord;
import com.ruoyi.account.api.model.TVinManagement;
+import com.ruoyi.account.api.vo.CompanyAllocateSummaryVO;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.exception.ServiceException;
import com.ruoyi.common.core.utils.StringUtils;
import com.ruoyi.common.core.web.domain.AjaxResult;
+import com.ruoyi.common.security.utils.SecurityUtils;
+import com.ruoyi.order.api.feignClient.ChargingOrderClient;
import com.ruoyi.other.api.domain.TFleet;
import com.ruoyi.other.api.domain.TGroupCompany;
import com.ruoyi.other.api.model.TCompanyRechargeRecord;
@@ -120,6 +123,10 @@
/** 企业额度分配记录远程客户端(结算时写消费流水、幂等判定使用) */
@Resource
private CompanyAllocateRecordClient companyAllocateRecordClient;
+
+ /** 充电订单远程客户端(账户模式切换前进行中订单校验使用) */
+ @Resource
+ private ChargingOrderClient chargingOrderClient;
@Override
public TGroupCompany getDetail(Integer id) {
@@ -548,11 +555,162 @@
if (company == null) {
return AjaxResult.error("公司不存在");
}
+ // 目标模式与当前一致:幂等直接返回
+ if (Objects.equals(company.getAccountMode(), accountMode)) {
+ return AjaxResult.success();
+ }
+ // 统一校验:无论切换方向,存在进行中充电订单均拒绝切换
+ AjaxResult chargingCheck = ensureNoActiveChargingOrder(id);
+ if (chargingCheck != null) {
+ return chargingCheck;
+ }
+ // 共享 → 分配:仅切换模式,不涉及资金回退
+ if (accountMode == ACCOUNT_MODE_ALLOCATION) {
+ TGroupCompany update = new TGroupCompany();
+ update.setId(id);
+ update.setAccountMode(accountMode);
+ updateById(update);
+ return AjaxResult.success();
+ }
+ // 分配 → 共享:校验在途业务后回收全部分配余额
+ return switchAllocationToShared(id, company);
+ }
+
+ /**
+ * 校验公司无进行中充电订单(三表合计,状态 2=启动中/3=充电中/4=停止中)。
+ * 通过返回 null;不通过返回可直接回给前端的错误结果。
+ * 账户模式双向切换均须先通过本校验。
+ */
+ private AjaxResult ensureNoActiveChargingOrder(Integer id) {
+ R<Integer> activeCountResult;
+ try {
+ activeCountResult = chargingOrderClient.countActiveByCompany(id);
+ } catch (RuntimeException ex) {
+ log.error("[模式切换] 统计进行中充电订单异常 companyId={}", id, ex);
+ return AjaxResult.error("校验充电状态失败,请稍后重试");
+ }
+ if (activeCountResult == null || R.SUCCESS != activeCountResult.getCode()) {
+ return AjaxResult.error("校验充电状态失败,请稍后重试");
+ }
+ Integer activeCount = activeCountResult.getData();
+ if (activeCount != null && activeCount > 0) {
+ return AjaxResult.error("该公司存在正在充电的订单,暂不允许切换账户模式");
+ }
+ return null;
+ }
+
+ /**
+ * 分配模式切换为共享模式:
+ * <ol>
+ * <li>校验公司与三类主体均无未释放充电锁定额</li>
+ * <li>公司余额预加回回收总额 + 切换为共享模式(本地事务)</li>
+ * <li>远程回收三类主体全部余额并写双向流水(account 侧锁内事务,幂等)</li>
+ * <li>按实际回收额与预加回额差额校准公司余额(并发分配竞态兜底)</li>
+ * </ol>
+ * 进行中充电订单校验已在 updateAccountMode 公共入口完成。
+ */
+ private AjaxResult switchAllocationToShared(Integer id, TGroupCompany company) {
+ // 1. 公司锁定额校验(在途充电锁定未释放)
+ BigDecimal companyLocked = company.getLockedAmount();
+ if (companyLocked != null && companyLocked.signum() > 0) {
+ return AjaxResult.error("公司存在未释放的充电锁定金额,暂不允许切换为共享模式");
+ }
+ // 2. 三类主体余额与锁定额统计
+ R<CompanyAllocateSummaryVO> summaryResult;
+ try {
+ summaryResult = companyAllocateRecordClient.sumAllocatedBalance(id);
+ } catch (RuntimeException ex) {
+ log.error("[模式切换] 统计分配余额异常 companyId={}", id, ex);
+ return AjaxResult.error("查询分配余额失败,请稍后重试");
+ }
+ if (summaryResult == null || R.SUCCESS != summaryResult.getCode() || summaryResult.getData() == null) {
+ return AjaxResult.error("查询分配余额失败,请稍后重试");
+ }
+ CompanyAllocateSummaryVO summary = summaryResult.getData();
+ if (summary.getTotalLocked() != null && summary.getTotalLocked().signum() > 0) {
+ return AjaxResult.error("存在充电锁定金额未释放,暂不允许切换为共享模式");
+ }
+ BigDecimal total = summary.getTotalBalance() == null ? BigDecimal.ZERO : summary.getTotalBalance();
+ BigDecimal companyBalanceBefore = company.getAvailableBalance() == null
+ ? BigDecimal.ZERO : company.getAvailableBalance();
+ // 3. 公司余额加回 + 模式切换(本地事务)
+ if (!updateCompanyToShared(id, total)) {
+ throw new ServiceException("公司余额更新失败");
+ }
+ // 4. 远程回收三类主体余额(account 侧三把公司级锁串行、事务、幂等),失败回滚公司余额与模式
+ BigDecimal actual;
+ try {
+ actual = recycleAllocatedBalanceRemote(id, company, companyBalanceBefore);
+ } catch (RuntimeException ex) {
+ log.error("[模式切换] 远程回收分配余额异常 companyId={}", id, ex);
+ rollbackModeSwitch(id, total);
+ throw new ServiceException("回收分配余额失败,请稍后重试");
+ }
+ // 5. 差额调整:回收与统计间存在并发分配时,按实际回收额校准公司余额
+ BigDecimal diff = actual.subtract(total);
+ if (diff.signum() != 0) {
+ boolean adjusted = lambdaUpdate()
+ .eq(TGroupCompany::getId, id)
+ .setSql("available_balance = IFNULL(available_balance, 0) + (" + diff.toPlainString() + ")")
+ .update();
+ if (!adjusted) {
+ log.error("[模式切换] 公司余额差额调整失败 companyId={} diff={}", id, diff.toPlainString());
+ }
+ }
+ log.info("[模式切换] 分配→共享成功 companyId={} 回收总额={}", id, actual.toPlainString());
+ return AjaxResult.success("账户模式已切换为共享模式,回收分配余额 " + actual.toPlainString() + " 元");
+ }
+
+ /** 更新公司余额(加回回收额)并切换为共享模式,返回是否更新成功 */
+ private boolean updateCompanyToShared(Integer id, BigDecimal total) {
+ if (total.signum() > 0) {
+ return lambdaUpdate()
+ .eq(TGroupCompany::getId, id)
+ .setSql("available_balance = IFNULL(available_balance, 0) + " + total.toPlainString())
+ .set(TGroupCompany::getAccountMode, ACCOUNT_MODE_SHARED)
+ .update();
+ }
TGroupCompany update = new TGroupCompany();
update.setId(id);
- update.setAccountMode(accountMode);
- updateById(update);
- return AjaxResult.success();
+ update.setAccountMode(ACCOUNT_MODE_SHARED);
+ return updateById(update);
+ }
+
+ /** 远程回收三类主体分配余额,失败(远程失败码/返回体异常)抛出 ServiceException 由调用方补偿 */
+ private BigDecimal recycleAllocatedBalanceRemote(Integer id, TGroupCompany company,
+ BigDecimal companyBalanceBefore) {
+ String operatorName = null;
+ try {
+ operatorName = SecurityUtils.getLoginUser().getUsername();
+ } catch (Exception ignored) {
+ // 无安全上下文时(内部调用)操作人留空
+ }
+ R<BigDecimal> recycleResult = companyAllocateRecordClient.recycleAllocatedBalance(id, operatorName,
+ company.getName(), companyBalanceBefore);
+ if (recycleResult == null || R.SUCCESS != recycleResult.getCode() || recycleResult.getData() == null) {
+ String msg = recycleResult == null ? "远程调用失败" : recycleResult.getMsg();
+ log.error("[模式切换] 回收分配余额失败 companyId={} msg={}", id, msg);
+ throw new ServiceException("回收分配余额失败:" + msg);
+ }
+ return recycleResult.getData();
+ }
+
+ /**
+ * 回收失败补偿:公司余额减回预加回额、模式恢复为分配模式。
+ * 仅当模式仍为共享时执行恢复,避免覆盖后续并发操作;恢复失败仅记录日志(下轮对账/重试兜底)。
+ */
+ private void rollbackModeSwitch(Integer id, BigDecimal total) {
+ try {
+ boolean rollbacked = lambdaUpdate()
+ .eq(TGroupCompany::getId, id)
+ .eq(TGroupCompany::getAccountMode, ACCOUNT_MODE_SHARED)
+ .setSql("available_balance = IFNULL(available_balance, 0) - " + total.toPlainString())
+ .set(TGroupCompany::getAccountMode, ACCOUNT_MODE_ALLOCATION)
+ .update();
+ log.warn("[模式切换] 回收失败已回滚 companyId={} rollbacked={}", id, rollbacked);
+ } catch (RuntimeException ex) {
+ log.error("[模式切换] 回收失败回滚异常 companyId={}", id, ex);
+ }
}
@Override
diff --git a/ruoyi-service/ruoyi-other/src/test/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImplTest.java b/ruoyi-service/ruoyi-other/src/test/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImplTest.java
index 44165c6..d86354a 100644
--- a/ruoyi-service/ruoyi-other/src/test/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImplTest.java
+++ b/ruoyi-service/ruoyi-other/src/test/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImplTest.java
@@ -2,17 +2,22 @@
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.extension.conditions.update.LambdaUpdateChainWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.ruoyi.account.api.feignClient.AppUserWhitelistClient;
import com.ruoyi.account.api.feignClient.ChargingCardClient;
+import com.ruoyi.account.api.feignClient.CompanyAllocateRecordClient;
import com.ruoyi.account.api.feignClient.VinManagementClient;
import com.ruoyi.account.api.model.TVinManagement;
+import com.ruoyi.account.api.vo.CompanyAllocateSummaryVO;
import com.ruoyi.common.core.domain.R;
+import com.ruoyi.common.core.exception.ServiceException;
import com.ruoyi.common.core.web.domain.AjaxResult;
import com.ruoyi.other.api.domain.TGroupCompany;
import com.ruoyi.other.api.model.TCompanyRechargeRecord;
import com.ruoyi.other.mapper.TCompanyRechargeRecordMapper;
import com.ruoyi.other.mapper.TFleetMapper;
+import com.ruoyi.order.api.feignClient.ChargingOrderClient;
import com.ruoyi.system.api.domain.SysUser;
import com.ruoyi.system.api.feignClient.SysRoleClient;
import com.ruoyi.system.api.feignClient.SysUserClient;
@@ -33,6 +38,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
@@ -302,8 +308,11 @@
void updateAccountModeOnlyUpdatesAccountModeColumn() {
TGroupCompany company = new TGroupCompany();
company.setId(2);
+ ChargingOrderClient orderClient = Mockito.mock(ChargingOrderClient.class);
+ Mockito.when(orderClient.countActiveByCompany(2)).thenReturn(R.ok(0));
TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class),
Mockito.mock(TCompanyRechargeRecordMapper.class));
+ ReflectionTestUtils.setField(service, "chargingOrderClient", orderClient);
Mockito.doReturn(true).when(service).updateById(Mockito.any());
assertEquals(200, code(service.updateAccountMode(2, 2)));
@@ -320,6 +329,215 @@
}
/**
+ * 分配→共享切换成功:加回公司余额、切换模式、远程回收、差额一致无需调整
+ */
+ @Test
+ void switchAllocationToSharedRecyclesBalanceAndWritesRecords() {
+ TGroupCompany company = new TGroupCompany();
+ company.setId(2);
+ company.setName("测试公司");
+ company.setAccountMode(2);
+ company.setAvailableBalance(new BigDecimal("100.00"));
+ company.setLockedAmount(new BigDecimal("0.00"));
+
+ CompanyAllocateRecordClient allocateClient = Mockito.mock(CompanyAllocateRecordClient.class);
+ CompanyAllocateSummaryVO summary = new CompanyAllocateSummaryVO();
+ summary.setCompanyId(2);
+ summary.setTotalBalance(new BigDecimal("30.50"));
+ summary.setTotalLocked(new BigDecimal("0.00"));
+ summary.setBalanceUserCount(2);
+ Mockito.when(allocateClient.sumAllocatedBalance(2)).thenReturn(R.ok(summary));
+ Mockito.when(allocateClient.recycleAllocatedBalance(Mockito.eq(2), Mockito.any(), Mockito.eq("测试公司"),
+ Mockito.eq(new BigDecimal("100.00")))).thenReturn(R.ok(new BigDecimal("30.50")));
+
+ ChargingOrderClient orderClient = Mockito.mock(ChargingOrderClient.class);
+ Mockito.when(orderClient.countActiveByCompany(2)).thenReturn(R.ok(0));
+
+ TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class),
+ Mockito.mock(TCompanyRechargeRecordMapper.class));
+ ReflectionTestUtils.setField(service, "companyAllocateRecordClient", allocateClient);
+ ReflectionTestUtils.setField(service, "chargingOrderClient", orderClient);
+ stubLambdaUpdate(service, true);
+
+ assertEquals(200, code(service.updateAccountMode(2, 1)));
+
+ // 远程回收携带回退前公司余额,写入企业账户正数流水与用户负数流水由 account 侧完成
+ Mockito.verify(allocateClient).recycleAllocatedBalance(2, null, "测试公司", new BigDecimal("100.00"));
+ Mockito.verify(orderClient).countActiveByCompany(2);
+ }
+
+ /**
+ * 存在进行中充电订单:拒绝切换且不触发回收
+ */
+ @Test
+ void switchAllocationToSharedRejectsWhenChargingInProgress() {
+ TGroupCompany company = new TGroupCompany();
+ company.setId(3);
+ company.setName("充电中公司");
+ company.setAccountMode(2);
+ company.setAvailableBalance(new BigDecimal("50.00"));
+ company.setLockedAmount(new BigDecimal("0.00"));
+
+ ChargingOrderClient orderClient = Mockito.mock(ChargingOrderClient.class);
+ Mockito.when(orderClient.countActiveByCompany(3)).thenReturn(R.ok(2));
+ CompanyAllocateRecordClient allocateClient = Mockito.mock(CompanyAllocateRecordClient.class);
+
+ TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class),
+ Mockito.mock(TCompanyRechargeRecordMapper.class));
+ ReflectionTestUtils.setField(service, "companyAllocateRecordClient", allocateClient);
+ ReflectionTestUtils.setField(service, "chargingOrderClient", orderClient);
+
+ AjaxResult result = service.updateAccountMode(3, 1);
+
+ assertEquals(500, code(result));
+ assertTrue(String.valueOf(result.get("msg")).contains("正在充电"));
+ Mockito.verify(allocateClient, Mockito.never()).sumAllocatedBalance(Mockito.anyInt());
+ Mockito.verify(allocateClient, Mockito.never()).recycleAllocatedBalance(Mockito.anyInt(), Mockito.any(),
+ Mockito.any(), Mockito.any());
+ Mockito.verify(service, Mockito.never()).updateById(Mockito.any());
+ }
+
+ /**
+ * 存在未释放充电锁定额:拒绝切换且不触发回收
+ */
+ @Test
+ void switchAllocationToSharedRejectsWhenLockedAmountNotReleased() {
+ TGroupCompany company = new TGroupCompany();
+ company.setId(4);
+ company.setName("锁定残留公司");
+ company.setAccountMode(2);
+ company.setAvailableBalance(new BigDecimal("50.00"));
+ company.setLockedAmount(new BigDecimal("0.00"));
+
+ ChargingOrderClient orderClient = Mockito.mock(ChargingOrderClient.class);
+ Mockito.when(orderClient.countActiveByCompany(4)).thenReturn(R.ok(0));
+ CompanyAllocateRecordClient allocateClient = Mockito.mock(CompanyAllocateRecordClient.class);
+ CompanyAllocateSummaryVO summary = new CompanyAllocateSummaryVO();
+ summary.setCompanyId(4);
+ summary.setTotalBalance(new BigDecimal("10.00"));
+ summary.setTotalLocked(new BigDecimal("3.00"));
+ Mockito.when(allocateClient.sumAllocatedBalance(4)).thenReturn(R.ok(summary));
+
+ TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class),
+ Mockito.mock(TCompanyRechargeRecordMapper.class));
+ ReflectionTestUtils.setField(service, "companyAllocateRecordClient", allocateClient);
+ ReflectionTestUtils.setField(service, "chargingOrderClient", orderClient);
+
+ AjaxResult result = service.updateAccountMode(4, 1);
+
+ assertEquals(500, code(result));
+ assertTrue(String.valueOf(result.get("msg")).contains("锁定金额"));
+ Mockito.verify(allocateClient, Mockito.never()).recycleAllocatedBalance(Mockito.anyInt(), Mockito.any(),
+ Mockito.any(), Mockito.any());
+ Mockito.verify(service, Mockito.never()).updateById(Mockito.any());
+ }
+
+ /**
+ * 回收失败:抛业务异常并回滚公司余额与模式(避免资金总账丢失)
+ */
+ @Test
+ void switchAllocationToSharedRollsBackWhenRecycleFails() {
+ TGroupCompany company = new TGroupCompany();
+ company.setId(5);
+ company.setName("回滚公司");
+ company.setAccountMode(2);
+ company.setAvailableBalance(new BigDecimal("80.00"));
+ company.setLockedAmount(new BigDecimal("0.00"));
+
+ ChargingOrderClient orderClient = Mockito.mock(ChargingOrderClient.class);
+ Mockito.when(orderClient.countActiveByCompany(5)).thenReturn(R.ok(0));
+ CompanyAllocateRecordClient allocateClient = Mockito.mock(CompanyAllocateRecordClient.class);
+ CompanyAllocateSummaryVO summary = new CompanyAllocateSummaryVO();
+ summary.setCompanyId(5);
+ summary.setTotalBalance(new BigDecimal("20.00"));
+ summary.setTotalLocked(new BigDecimal("0.00"));
+ Mockito.when(allocateClient.sumAllocatedBalance(5)).thenReturn(R.ok(summary));
+ Mockito.when(allocateClient.recycleAllocatedBalance(Mockito.eq(5), Mockito.any(), Mockito.any(),
+ Mockito.any())).thenReturn(R.fail("回收失败"));
+
+ TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class),
+ Mockito.mock(TCompanyRechargeRecordMapper.class));
+ ReflectionTestUtils.setField(service, "companyAllocateRecordClient", allocateClient);
+ ReflectionTestUtils.setField(service, "chargingOrderClient", orderClient);
+ stubLambdaUpdate(service, true);
+
+ assertThrows(ServiceException.class, () -> service.updateAccountMode(5, 1));
+ // 加回与回滚各一次:第二次(回滚)将模式恢复为分配模式
+ Mockito.verify(service, Mockito.times(2)).lambdaUpdate();
+ }
+
+ /**
+ * 共享→分配无进行中充电订单:通过充电校验后仅切换模式,不做任何资金操作
+ */
+ @Test
+ void switchSharedToAllocationChecksChargingThenOnlyUpdatesMode() {
+ TGroupCompany company = new TGroupCompany();
+ company.setId(6);
+ company.setName("切分配公司");
+ company.setAccountMode(1);
+ company.setAvailableBalance(new BigDecimal("60.00"));
+
+ ChargingOrderClient orderClient = Mockito.mock(ChargingOrderClient.class);
+ Mockito.when(orderClient.countActiveByCompany(6)).thenReturn(R.ok(0));
+ CompanyAllocateRecordClient allocateClient = Mockito.mock(CompanyAllocateRecordClient.class);
+
+ TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class),
+ Mockito.mock(TCompanyRechargeRecordMapper.class));
+ ReflectionTestUtils.setField(service, "companyAllocateRecordClient", allocateClient);
+ ReflectionTestUtils.setField(service, "chargingOrderClient", orderClient);
+ Mockito.doReturn(true).when(service).updateById(Mockito.any());
+
+ assertEquals(200, code(service.updateAccountMode(6, 2)));
+
+ Mockito.verify(orderClient).countActiveByCompany(6);
+ Mockito.verify(allocateClient, Mockito.never()).sumAllocatedBalance(Mockito.anyInt());
+ Mockito.verify(allocateClient, Mockito.never()).recycleAllocatedBalance(Mockito.anyInt(), Mockito.any(),
+ Mockito.any(), Mockito.any());
+ Mockito.verify(service).updateById(Mockito.any(TGroupCompany.class));
+ }
+
+ /**
+ * 共享→分配存在进行中充电订单:拒绝切换且不改模式
+ */
+ @Test
+ void switchSharedToAllocationRejectsWhenChargingInProgress() {
+ TGroupCompany company = new TGroupCompany();
+ company.setId(7);
+ company.setName("充电中切分配公司");
+ company.setAccountMode(1);
+ company.setAvailableBalance(new BigDecimal("60.00"));
+
+ ChargingOrderClient orderClient = Mockito.mock(ChargingOrderClient.class);
+ Mockito.when(orderClient.countActiveByCompany(7)).thenReturn(R.ok(3));
+ CompanyAllocateRecordClient allocateClient = Mockito.mock(CompanyAllocateRecordClient.class);
+
+ TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class),
+ Mockito.mock(TCompanyRechargeRecordMapper.class));
+ ReflectionTestUtils.setField(service, "companyAllocateRecordClient", allocateClient);
+ ReflectionTestUtils.setField(service, "chargingOrderClient", orderClient);
+
+ AjaxResult result = service.updateAccountMode(7, 2);
+
+ assertEquals(500, code(result));
+ assertTrue(String.valueOf(result.get("msg")).contains("正在充电"));
+ Mockito.verify(allocateClient, Mockito.never()).sumAllocatedBalance(Mockito.anyInt());
+ Mockito.verify(service, Mockito.never()).updateById(Mockito.any());
+ }
+
+ /**
+ * 打桩 lambdaUpdate 链:eq/setSql/set 返回自身,update 返回指定结果
+ */
+ @SuppressWarnings("unchecked")
+ private void stubLambdaUpdate(TGroupCompanyServiceImpl service, boolean updateResult) {
+ LambdaUpdateChainWrapper<TGroupCompany> chain = Mockito.mock(LambdaUpdateChainWrapper.class);
+ Mockito.when(chain.eq(Mockito.any(), Mockito.any())).thenReturn(chain);
+ Mockito.when(chain.setSql(Mockito.anyString())).thenReturn(chain);
+ Mockito.when(chain.set(Mockito.any(), Mockito.any())).thenReturn(chain);
+ Mockito.when(chain.update()).thenReturn(updateResult);
+ Mockito.doReturn(chain).when(service).lambdaUpdate();
+ }
+
+ /**
* 构建关注建档字段的 spy:远程账号相关调用全部打桩,不依赖 Spring 容器与数据库
*/
private TGroupCompanyServiceImpl buildAddCompanyService() {
--
Gitblit v1.7.1