| | |
| | | |
| | | import com.baomidou.mybatisplus.core.toolkit.Wrappers; |
| | | import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; |
| | | 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.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.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; |
| | | import com.ruoyi.other.api.vo.CompanyChargingUserVO; |
| | | import com.ruoyi.other.mapper.TCompanyRechargeRecordMapper; |
| | | import com.ruoyi.other.mapper.TFleetMapper; |
| | | import com.ruoyi.other.mapper.TGroupCompanyMapper; |
| | |
| | | import com.ruoyi.system.api.domain.SysUser; |
| | | import com.ruoyi.system.api.feignClient.SysRoleClient; |
| | | import com.ruoyi.system.api.feignClient.SysUserClient; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.stereotype.Service; |
| | | import org.springframework.transaction.annotation.Transactional; |
| | | |
| | |
| | | import java.math.RoundingMode; |
| | | import java.time.LocalDate; |
| | | import java.time.LocalDateTime; |
| | | import java.util.ArrayList; |
| | | import java.util.Collections; |
| | | import java.util.Date; |
| | | import java.util.HashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.Objects; |
| | | import java.util.stream.Collectors; |
| | | |
| | | /** |
| | | * <p> |
| | |
| | | * @author mxcd |
| | | * @since 2026-09-22 |
| | | */ |
| | | @Slf4j |
| | | @Service |
| | | public class TGroupCompanyServiceImpl extends ServiceImpl<TGroupCompanyMapper, TGroupCompany> implements TGroupCompanyService { |
| | | |
| | | /** 金额保留小数位(与 t_company_recharge_record.arrival_amount 一致) */ |
| | | private static final int AMOUNT_SCALE = 2; |
| | | |
| | | /** 公司账号角色标识(内置角色 role_key=company) */ |
| | | private static final String COMPANY_ROLE_KEY = "company"; |
| | | /** 公司管理员账号角色标识(内置角色 role_key=company_admin) */ |
| | | private static final String COMPANY_ROLE_KEY = "company_admin"; |
| | | |
| | | /** 公司账号角色类型(sys_user.roleType=5) */ |
| | | private static final int COMPANY_ROLE_TYPE = 5; |
| | | |
| | | /** 账户模式:共享模式(新增企业未指定时按此建档) */ |
| | | private static final int ACCOUNT_MODE_SHARED = 1; |
| | | |
| | | /** 账户模式:分配模式 */ |
| | | private static final int ACCOUNT_MODE_ALLOCATION = 2; |
| | | |
| | | /** 充电用户类型:VIN码 */ |
| | | private static final int USER_TYPE_VIN = 1; |
| | | |
| | | /** 充电用户类型:刷卡 */ |
| | | private static final int USER_TYPE_CARD = 2; |
| | | |
| | | /** 充电用户类型:白名单 */ |
| | | private static final int USER_TYPE_WHITELIST = 3; |
| | | |
| | | /** 账户状态:正常 */ |
| | | private static final int ACCOUNT_STATUS_NORMAL = 1; |
| | | |
| | | /** 账户状态:欠款 */ |
| | | private static final int ACCOUNT_STATUS_OVERDUE = 2; |
| | | |
| | | /** |
| | | * 车队 Mapper。 |
| | |
| | | @Resource |
| | | private SysRoleClient sysRoleClient; |
| | | |
| | | /** VIN管理远程客户端(公司详情-充电用户信息聚合使用) */ |
| | | @Resource |
| | | private VinManagementClient vinManagementClient; |
| | | |
| | | /** 充电卡远程客户端(公司详情-充电用户信息聚合使用) */ |
| | | @Resource |
| | | private ChargingCardClient chargingCardClient; |
| | | |
| | | /** 白名单远程客户端(公司详情-充电用户信息聚合使用) */ |
| | | @Resource |
| | | private AppUserWhitelistClient appUserWhitelistClient; |
| | | |
| | | /** 企业额度分配记录远程客户端(结算时写消费流水、幂等判定使用) */ |
| | | @Resource |
| | | private CompanyAllocateRecordClient companyAllocateRecordClient; |
| | | |
| | | /** 充电订单远程客户端(账户模式切换前进行中订单校验使用) */ |
| | | @Resource |
| | | private ChargingOrderClient chargingOrderClient; |
| | | |
| | | @Override |
| | | public TGroupCompany getDetail(Integer id) { |
| | | if (id == null) { |
| | |
| | | // 累计充值到账金额:作为公司总账户余额展示(额度分配弹窗使用),仅覆盖响应对象,不落库 |
| | | company.setTotalRechargeAmount(sumRechargeAmount(id)); |
| | | return company; |
| | | } |
| | | |
| | | @Override |
| | | public TGroupCompany getDetailWithChargingUsers(Integer id) { |
| | | TGroupCompany company = getDetail(id); |
| | | if (company == null) { |
| | | return null; |
| | | } |
| | | List<TVinManagement> vins = fetchVins(id); |
| | | // VIN码车辆数量:按公司统计未删除车辆(与 VIN 列表接口同口径),降级时返回 0 |
| | | company.setVinCount(vins.size()); |
| | | company.setChargingUsers(buildChargingUsers(company, vins)); |
| | | return company; |
| | | } |
| | | |
| | | /** |
| | | * 聚合公司充电用户信息:VIN码、刷卡、白名单三类数据按统一结构返回。 |
| | | * 仅公司详情使用;remoteGet 远程调用保持 getDetail(不聚合),避免跨服务循环调用。 |
| | | */ |
| | | private List<CompanyChargingUserVO> buildChargingUsers(TGroupCompany company, List<TVinManagement> vins) { |
| | | Integer companyId = company.getId(); |
| | | Integer accountMode = company.getAccountMode(); |
| | | List<CompanyChargingUserVO> result = new ArrayList<>(); |
| | | |
| | | List<TChargingCard> cards = fetchCards(companyId); |
| | | List<TAppUserWhitelist> whitelists = fetchWhitelists(companyId); |
| | | |
| | | List<Integer> fleetIds = new ArrayList<>(); |
| | | for (TVinManagement vin : vins) { |
| | | if (vin.getFleetId() != null) { |
| | | fleetIds.add(vin.getFleetId()); |
| | | } |
| | | } |
| | | for (TChargingCard card : cards) { |
| | | if (card.getFleetId() != null) { |
| | | fleetIds.add(card.getFleetId()); |
| | | } |
| | | } |
| | | for (TAppUserWhitelist whitelist : whitelists) { |
| | | if (whitelist.getFleetId() != null) { |
| | | fleetIds.add(whitelist.getFleetId()); |
| | | } |
| | | } |
| | | Map<Integer, String> fleetNameMap = loadFleetNameMap(fleetIds); |
| | | |
| | | String companyName = company.getName(); |
| | | BigDecimal companyBalance = company.getAvailableBalance(); |
| | | for (TVinManagement vin : vins) { |
| | | result.add(buildRow(USER_TYPE_VIN + "-" + vin.getId(), USER_TYPE_VIN, vin.getVin(), |
| | | companyName, fleetNameMap.get(vin.getFleetId()), accountMode, |
| | | vin.getBalance(), companyBalance, vin.getStatus())); |
| | | } |
| | | for (TChargingCard card : cards) { |
| | | result.add(buildRow(USER_TYPE_CARD + "-" + card.getId(), USER_TYPE_CARD, card.getCardNo(), |
| | | companyName, fleetNameMap.get(card.getFleetId()), accountMode, |
| | | card.getBalance(), companyBalance, card.getStatus())); |
| | | } |
| | | for (TAppUserWhitelist whitelist : whitelists) { |
| | | result.add(buildRow(USER_TYPE_WHITELIST + "-" + whitelist.getId(), USER_TYPE_WHITELIST, whitelist.getPhone(), |
| | | companyName, fleetNameMap.get(whitelist.getFleetId()), accountMode, |
| | | whitelist.getBalance(), companyBalance, whitelist.getStatus())); |
| | | } |
| | | return result; |
| | | } |
| | | |
| | | /** |
| | | * 组装单行充电用户信息。可用余额:分配模式取子账户余额/初始分配额度,共享模式取公司可用余额。 |
| | | * 超额/账户状态按余额正负推算:余额为负时超额=|余额|、账户状态=欠款;否则无超额、状态正常。 |
| | | */ |
| | | private CompanyChargingUserVO buildRow(String rowKey, Integer userType, String userInfo, |
| | | String companyName, String fleetName, Integer accountMode, |
| | | BigDecimal accountBalance, BigDecimal companyBalance, Integer status) { |
| | | CompanyChargingUserVO vo = new CompanyChargingUserVO(); |
| | | vo.setRowKey(rowKey); |
| | | vo.setUserType(userType); |
| | | vo.setUserInfo(userInfo); |
| | | vo.setCompanyName(companyName); |
| | | vo.setFleetName(fleetName); |
| | | vo.setAccountMode(accountMode); |
| | | vo.setBalance(resolveBalance(accountMode, accountBalance, companyBalance)); |
| | | vo.setOverrun(null); |
| | | vo.setAccountStatus(ACCOUNT_STATUS_NORMAL); |
| | | BigDecimal balance = vo.getBalance(); |
| | | if (balance != null && balance.compareTo(BigDecimal.ZERO) < 0) { |
| | | vo.setOverrun(balance.abs().setScale(AMOUNT_SCALE, RoundingMode.HALF_UP)); |
| | | vo.setAccountStatus(ACCOUNT_STATUS_OVERDUE); |
| | | } |
| | | vo.setStatus(status); |
| | | return vo; |
| | | } |
| | | |
| | | /** |
| | | * 计算可用余额:分配模式=子账户余额/初始分配额度,共享模式=公司可用余额 |
| | | */ |
| | | private BigDecimal resolveBalance(Integer accountMode, BigDecimal accountBalance, BigDecimal companyBalance) { |
| | | if (Integer.valueOf(ACCOUNT_MODE_ALLOCATION).equals(accountMode)) { |
| | | return accountBalance; |
| | | } |
| | | if (Integer.valueOf(ACCOUNT_MODE_SHARED).equals(accountMode)) { |
| | | return companyBalance; |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * 远程查询公司VIN码列表(降级返回空列表,保证详情主流程不受影响) |
| | | */ |
| | | private List<TVinManagement> fetchVins(Integer companyId) { |
| | | try { |
| | | R<List<TVinManagement>> response = vinManagementClient.listByCompany(companyId); |
| | | return (response != null && R.isSuccess(response) && response.getData() != null) |
| | | ? response.getData() : new ArrayList<>(); |
| | | } catch (Exception e) { |
| | | return new ArrayList<>(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 远程查询公司卡片列表(降级返回空列表) |
| | | */ |
| | | private List<TChargingCard> fetchCards(Integer companyId) { |
| | | try { |
| | | R<List<TChargingCard>> response = chargingCardClient.listByCompany(companyId); |
| | | return (response != null && R.isSuccess(response) && response.getData() != null) |
| | | ? response.getData() : new ArrayList<>(); |
| | | } catch (Exception e) { |
| | | return new ArrayList<>(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 远程查询公司白名单列表(降级返回空列表) |
| | | */ |
| | | private List<TAppUserWhitelist> fetchWhitelists(Integer companyId) { |
| | | try { |
| | | R<List<TAppUserWhitelist>> response = appUserWhitelistClient.listByCompany(companyId); |
| | | return (response != null && R.isSuccess(response) && response.getData() != null) |
| | | ? response.getData() : new ArrayList<>(); |
| | | } catch (Exception e) { |
| | | return new ArrayList<>(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 批量查询车队名称(本地库 t_fleet,逻辑删除由 @TableLogic 自动过滤) |
| | | */ |
| | | private Map<Integer, String> loadFleetNameMap(List<Integer> fleetIds) { |
| | | Map<Integer, String> map = new HashMap<>(); |
| | | if (fleetIds == null || fleetIds.isEmpty()) { |
| | | return map; |
| | | } |
| | | List<Integer> distinctIds = fleetIds.stream() |
| | | .filter(Objects::nonNull) |
| | | .distinct() |
| | | .collect(Collectors.toList()); |
| | | if (distinctIds.isEmpty()) { |
| | | return map; |
| | | } |
| | | List<TFleet> fleets = fleetMapper.selectList( |
| | | Wrappers.<TFleet>lambdaQuery().in(TFleet::getId, distinctIds)); |
| | | for (TFleet fleet : fleets) { |
| | | map.put(fleet.getId(), fleet.getFleetName()); |
| | | } |
| | | return map; |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * 查询公司默认角色ID(内置角色 role_key=company) |
| | | * 查询公司默认角色ID(内置角色 role_key=company_admin) |
| | | */ |
| | | private Long resolveCompanyRoleId() { |
| | | R<Long> roleR = sysRoleClient.getRoleIdByRoleKey(COMPANY_ROLE_KEY); |
| | |
| | | public AjaxResult addCompany(TGroupCompany company) { |
| | | if (!StringUtils.hasText(company.getAccount())) { |
| | | return AjaxResult.error("登录账号不能为空"); |
| | | } |
| | | // 添加企业弹窗不再配置账户模式:未指定时按共享模式建档,后续可在“账户模式”弹窗切换 |
| | | if (company.getAccountMode() == null) { |
| | | company.setAccountMode(ACCOUNT_MODE_SHARED); |
| | | } |
| | | if (!StringUtils.hasText(company.getPassword())) { |
| | | return AjaxResult.error("登录密码不能为空"); |
| | |
| | | if (old == null) { |
| | | return AjaxResult.error("公司不存在"); |
| | | } |
| | | // 编辑时账号只读(前端禁用),此处兼容外部调用方传入账号的场景:唯一性校验(排除自身) |
| | | // 编辑支持修改登录账号:公司表唯一性校验(排除自身) |
| | | if (StringUtils.hasText(company.getAccount())) { |
| | | if (accountExists(company.getAccount(), company.getId())) { |
| | | return AjaxResult.error("登录账号不能重复"); |
| | |
| | | } |
| | | String account = StringUtils.hasText(company.getAccount()) ? company.getAccount() : old.getAccount(); |
| | | if (StringUtils.hasText(account)) { |
| | | syncCompanyUserOnEdit(company, account); |
| | | String error = syncCompanyUserOnEdit(company, old.getAccount(), account); |
| | | if (error != null) { |
| | | return AjaxResult.error(error); |
| | | } |
| | | } |
| | | updateById(company); |
| | | return AjaxResult.success(); |
| | | } |
| | | |
| | | /** |
| | | * 编辑公司时同步登录账号:存在则仅更新账号资料(不修改密码),不存在则补建 |
| | | * 编辑公司时同步登录账号:按旧账号定位用户后更新为新账号(支持改账号); |
| | | * 密码有值时重置(留空不修改);用户不存在则按新账号补建 |
| | | * |
| | | * @return 冲突等业务错误信息,null 表示成功 |
| | | */ |
| | | private void syncCompanyUserOnEdit(TGroupCompany company, String account) { |
| | | R<SysUser> userR = sysUserClient.queryUserByUserName(account); |
| | | private String syncCompanyUserOnEdit(TGroupCompany company, String oldAccount, String newAccount) { |
| | | // 账号变更时校验新账号未被其他用户占用(updateSysUser 无唯一性校验,需在此前置拦截) |
| | | if (StringUtils.hasText(oldAccount) && !newAccount.equals(oldAccount)) { |
| | | R<SysUser> occupantR = sysUserClient.queryUserByUserName(newAccount); |
| | | SysUser occupant = occupantR == null ? null : occupantR.getData(); |
| | | if (occupant != null) { |
| | | R<SysUser> ownerR = sysUserClient.queryUserByUserName(oldAccount); |
| | | SysUser owner = ownerR == null ? null : ownerR.getData(); |
| | | if (owner == null || !occupant.getUserId().equals(owner.getUserId())) { |
| | | return "登录账号不能重复"; |
| | | } |
| | | } |
| | | } |
| | | // 按旧账号定位既有用户(账号未变时与新账号一致),避免改名后误走补建导致双账号 |
| | | R<SysUser> userR = sysUserClient.queryUserByUserName(StringUtils.hasText(oldAccount) ? oldAccount : newAccount); |
| | | SysUser user = userR == null ? null : userR.getData(); |
| | | if (user == null) { |
| | | if (!StringUtils.hasText(company.getPassword())) { |
| | |
| | | } |
| | | Long roleId = resolveCompanyRoleId(); |
| | | SysUser newUser = new SysUser(); |
| | | newUser.setUserName(account); |
| | | newUser.setUserName(newAccount); |
| | | newUser.setNickName(company.getName()); |
| | | newUser.setPhonenumber(company.getContactPhone()); |
| | | newUser.setPassword(company.getPassword()); |
| | |
| | | newUser.setRoleType(COMPANY_ROLE_TYPE); |
| | | newUser.setObjectId(company.getId()); |
| | | newUser.setPassWordUpdate(new Date()); |
| | | R<Long> r = sysUserClient.addSysUser(newUser); |
| | | if (r == null || 200 != r.getCode()) { |
| | | throw new RuntimeException(r == null ? "创建登录账号失败" : r.getMsg()); |
| | | R<Long> addR = sysUserClient.addSysUser(newUser); |
| | | if (addR == null || 200 != addR.getCode()) { |
| | | throw new RuntimeException(addR == null ? "创建登录账号失败" : addR.getMsg()); |
| | | } |
| | | return; |
| | | return null; |
| | | } |
| | | // 远程查询返回的密码字段可能是哈希或脱敏值,显式清空避免再次写回 |
| | | user.setPassword(null); |
| | | user.setPassWordUpdate(null); |
| | | user.setUserName(account); |
| | | user.setUserName(newAccount); |
| | | user.setNickName(company.getName()); |
| | | user.setPhonenumber(company.getContactPhone()); |
| | | R<Boolean> r = sysUserClient.updateSysUser(user); |
| | | if (r == null || 200 != r.getCode()) { |
| | | throw new RuntimeException(r == null ? "更新登录账号失败" : r.getMsg()); |
| | | R<Boolean> updateR = sysUserClient.updateSysUser(user); |
| | | if (updateR == null || 200 != updateR.getCode()) { |
| | | throw new RuntimeException(updateR == null ? "更新登录账号失败" : updateR.getMsg()); |
| | | } |
| | | // 密码有值时重置(前端已 HMAC-MD5,由 system 侧 BCrypt 落库,与新增链路一致) |
| | | if (StringUtils.hasText(company.getPassword())) { |
| | | SysUser pwdUser = new SysUser(); |
| | | pwdUser.setUserId(user.getUserId()); |
| | | pwdUser.setPassword(company.getPassword()); |
| | | R pwdR = sysUserClient.resetPassword(pwdUser); |
| | | if (pwdR == null || 200 != pwdR.getCode()) { |
| | | throw new RuntimeException(pwdR == null ? "重置登录密码失败" : pwdR.getMsg()); |
| | | } |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | @Override |
| | |
| | | return AjaxResult.success(); |
| | | } |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public AjaxResult updateAccountMode(Integer id, Integer accountMode) { |
| | | if (id == null) { |
| | | return AjaxResult.error("公司ID不能为空"); |
| | | } |
| | | if (accountMode == null |
| | | || (accountMode != ACCOUNT_MODE_SHARED && accountMode != ACCOUNT_MODE_ALLOCATION)) { |
| | | return AjaxResult.error("账户模式不合法"); |
| | | } |
| | | TGroupCompany company = getById(id); |
| | | 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(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 |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public BigDecimal settleBalance(Integer companyId, BigDecimal lockedAmount, BigDecimal actualAmount, String orderCode, |
| | | String userInfo, String userName, String remark) { |
| | | if (companyId == null) { |
| | | throw new ServiceException("公司ID不能为空"); |
| | | } |
| | | BigDecimal lock = lockedAmount == null ? BigDecimal.ZERO : lockedAmount; |
| | | BigDecimal actual = actualAmount == null ? BigDecimal.ZERO : actualAmount; |
| | | if (lock.compareTo(BigDecimal.ZERO) < 0 || actual.compareTo(BigDecimal.ZERO) < 0) { |
| | | throw new ServiceException("锁定金额与实际消费金额不能为负数"); |
| | | } |
| | | boolean hasOrderCode = StringUtils.isNotEmpty(orderCode); |
| | | // 幂等:账户侧已落该订单消费流水 → 已结算过,直接返回当前余额(结算失败回退订单状态后的补偿重试不会重复结算) |
| | | if (hasOrderCode && isOrderConsumed(orderCode)) { |
| | | TGroupCompany existed = getById(companyId); |
| | | log.info("[公司结算] 订单已结算,幂等跳过:orderCode={}, companyId={}", orderCode, companyId); |
| | | return existed == null || existed.getAvailableBalance() == null ? BigDecimal.ZERO |
| | | : existed.getAvailableBalance().setScale(AMOUNT_SCALE, RoundingMode.HALF_UP); |
| | | } |
| | | boolean updated = lambdaUpdate() |
| | | .eq(TGroupCompany::getId, companyId) |
| | | .setSql("locked_amount = GREATEST(IFNULL(locked_amount, 0) - " + lock.toPlainString() + ", 0)" |
| | | + ", available_balance = IFNULL(available_balance, 0) + " + lock.toPlainString() |
| | | + " - " + actual.toPlainString()) |
| | | .update(); |
| | | if (!updated) { |
| | | throw new ServiceException("公司不存在或已删除"); |
| | | } |
| | | TGroupCompany latest = getById(companyId); |
| | | BigDecimal after = latest == null || latest.getAvailableBalance() == null ? BigDecimal.ZERO |
| | | : latest.getAvailableBalance().setScale(AMOUNT_SCALE, RoundingMode.HALF_UP); |
| | | if (hasOrderCode) { |
| | | // 公司余额在公司库、消费流水在账户库,无法同事务:流水尽力写入,写入成功即作为该订单的结算幂等标记 |
| | | BigDecimal before = after.add(actual).subtract(lock); |
| | | writeConsumeQuietly(latest, orderCode, userInfo, userName, before, actual.negate(), remark); |
| | | } |
| | | return after; |
| | | } |
| | | |
| | | /** |
| | | * 判定账户侧是否已存在该订单的消费流水(查询异常按「未结算」处理,交由订单侧兜底扫描兜住) |
| | | */ |
| | | private boolean isOrderConsumed(String orderCode) { |
| | | try { |
| | | R<List<String>> result = companyAllocateRecordClient.listConsumedOrderCodes( |
| | | Collections.singletonList(orderCode)); |
| | | return result != null && R.SUCCESS == result.getCode() && result.getData() != null |
| | | && result.getData().contains(orderCode); |
| | | } catch (Exception ex) { |
| | | log.error("[公司结算] 查询消费流水失败,按未结算处理:orderCode={}", orderCode, ex); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 写企业额度分配记录-消费流水(共享模式记企业账户)。失败只记日志,不影响公司余额结算主流程 |
| | | */ |
| | | private void writeConsumeQuietly(TGroupCompany company, String orderCode, String userInfo, String userName, |
| | | BigDecimal before, BigDecimal amount, String remark) { |
| | | if (company == null || company.getId() == null) { |
| | | log.error("[公司结算] 缺少公司信息,无法写消费流水:orderCode={}", orderCode); |
| | | return; |
| | | } |
| | | String userInfoValue = StringUtils.isNotEmpty(userInfo) ? userInfo : company.getName(); |
| | | if (StringUtils.isEmpty(userInfoValue)) { |
| | | userInfoValue = String.valueOf(company.getId()); |
| | | } |
| | | String userNameValue = StringUtils.isNotEmpty(userName) ? userName : company.getName(); |
| | | try { |
| | | R<Boolean> result = companyAllocateRecordClient.recordConsume(company.getId(), |
| | | TCompanyAllocateRecord.USER_TYPE_COMPANY, userInfoValue, userNameValue, before, amount, |
| | | orderCode, remark); |
| | | if (result == null || R.SUCCESS != result.getCode() || !Boolean.TRUE.equals(result.getData())) { |
| | | log.error("[公司结算] 消费流水写入失败:orderCode={}, companyId={}, msg={}", orderCode, company.getId(), |
| | | result == null ? "null" : result.getMsg()); |
| | | } |
| | | } catch (Exception ex) { |
| | | log.error("[公司结算] 消费流水写入异常:orderCode={}, companyId={}", orderCode, company.getId(), ex); |
| | | } |
| | | } |
| | | |
| | | } |