puzhibing
2026-09-28 d5a8e1c054431418fa895928ae2e3429295a9865
增加功能
2个文件已添加
18个文件已修改
596 ■■■■■ 已修改文件
doc/耗时统计.md 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/VinManagementAllocateDTO.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-other/src/main/java/com/ruoyi/other/api/domain/TGroupCompany.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/factory/SysRoleFallbackFactory.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/feignClient/SysRoleClient.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-modules/ruoyi-system/src/main/java/com/ruoyi/system/controller/SysRoleController.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-modules/ruoyi-system/src/main/java/com/ruoyi/system/controller/SysUserController.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-modules/ruoyi-system/src/test/java/com/ruoyi/system/service/PartnerTypeRoleServiceTest.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TVinManagementController.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TVinManagementService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TVinManagementServiceImpl.java 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/controller/TVinManagementControllerTest.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TVinManagementServiceImplTest.java 104 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/controller/TGroupCompanyController.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/TGroupCompanyService.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImpl.java 166 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-other/src/test/java/com/ruoyi/other/emind/service/EmindEnergyChainManagementServiceTest.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-other/src/test/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImplTest.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/20260928_company_account_role.sql 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/20260928_vin_unbind_auth.sql 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/耗时统计.md
@@ -13,3 +13,4 @@
| 2026-09-15 09:41:00(估算) | 2026-09-15 10:33:37 | 3157(估算) | 请继续执行未完成的任务。(续作:VIN 订单首末 SOC 回填) | 是:统一结算首次处理时按监控创建时间升/降序取首末 SOC,空数据或查询异常不阻断,重复账单保持幂等 | 在 VD 正常交易记录及充电超时补偿共用结算入口中回填 `start_soc/end_soc`;增加正常映射、空监控数据及重复账单不查询 Mongo 的回归断言 | `ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderServiceImpl.java`;`ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/CardVinSettlementTimeAlignmentTest.java`;`ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/CardVinOrderCompensationTest.java`;`doc/耗时统计.md` | UTF-8、IDE lint、`git diff --check` 通过;reactor `mvn -o -pl ruoyi-service/ruoyi-order -am install -DskipTests` BUILD SUCCESS;定向 20/20、ruoyi-order 全量 178/178 JUnit 5 全部通过;代码走查通过 | 开始时间依据当天分析记录估算,耗时包含方案分析及等待续作;首轮 Maven 在工作区根目录因 reactor 路径错误未执行构建,进入 `ChargingPile` 后重跑通过 |
| 2026-09-15 17:39:31 | 2026-09-15 17:52:02 | 751 | 请继续执行未完成的任务。(续作:SD 已结束未扣款补偿) | 是:账户侧新增批量扣款流水查询接口,订单侧扫描状态5且应扣款的刷卡订单,逐单复核无扣款流水后条件回退为停止中交由既有补偿结算;本轮补齐“已有流水不回退”“账户查询失败不回退”“无候选不远程调用”“未抢到锁不回退”“定时任务异常隔离”5 个回归用例 | 完成 SD 已结束(状态5)订单无充电扣款流水的定时兜底回退,并补齐测试与全量回归;默认 surefire 版本无法执行 JUnit 5,改用本地仓库 surefire 2.22.2 执行 | `ruoyi-api/ruoyi-api-account/.../feignClient/ChargingCardClient.java`;`ruoyi-api/ruoyi-api-account/.../factory/ChargingCardFallbackFactory.java`;`ruoyi-service/ruoyi-account/.../controller/TChargingCardController.java`;`.../mapper/TChargingCardMapper.java`;`.../service/TChargingCardService.java`;`.../service/impl/TChargingCardServiceImpl.java`;`.../resources/mapper/account/TChargingCardMapper.xml`;`.../test/.../TChargingCardControllerTest.java`;`.../test/.../TChargingCardServiceImplTest.java`;`ruoyi-service/ruoyi-order/.../service/TCardChargingOrderService.java`;`.../service/impl/TCardChargingOrderServiceImpl.java`;`.../service/impl/TVinChargingOrderServiceImpl.java`;`.../util/task/TaskUtil.java`;`.../test/.../TCardChargingOrderAmountFieldsTest.java`;`.../test/.../TaskUtilCompensationTest.java`;`doc/耗时统计.md` | `mvn -o -pl ruoyi-api/ruoyi-api-account,ruoyi-service/ruoyi-account,ruoyi-service/ruoyi-order -am test-compile -DskipTests` BUILD SUCCESS;surefire 2.22.2 定向 14/14、ruoyi-order 全量 185/185、ruoyi-account 全量 103/103 全部通过;IDE lint 0 条;`git diff --check` 通过;代码走查通过 | 开始时间取本次续作会话首个命令时间,耗时仅统计续作会话(751 秒);实现主体在当天更早会话完成、时间未记录,故整体任务耗时未合并统计;新增用例首轮因在 thenReturn 参数内调用 Mockito 桩方法触发 UnfinishedStubbing,提取局部变量后修复 |
| 2026-09-21 15:19:30(估算) | 2026-09-21 15:23:29 | 约 240(估算) | 分析代码,汇总下已完成三方对接的品牌:光伏:科士达、华为、隆基、晶科、通威;储能:利普芯储能、华为、宁德时代、阳光电源;监控:海康、萤石、大华;道闸:吉莲、顺捷 | 否(只读分析任务,未涉及代码修改,无需授权口令) | 全项目盘点光伏/储能/视频监控/道闸四类三方对接实现,逐品牌核对真实完成情况,区分「已完成对接 / 仅枚举占位 / 代码中不存在」,并核对数据表品牌字段与同步任务 | `.codebuddy/memory/2026-09-21.md`(新建);`.codebuddy/memory/MEMORY.md`;`doc/耗时统计.md`(未修改任何业务代码) | 只读分析,未执行构建与测试;通过全库关键词检索、类与 SQL 文件走查、调用链核对交叉验证(含 2 个子代理并行探索) | 开始时间因本会话首条命令时间戳不可得而估算;结论:已真实完成对接为科士达、华为(仅光伏)、利普芯(eMind2000)、萤石、通用 HTTP 道闸平台,以及清单外的优旦储能;阳光电源/古瑞瓦特/海康/大华/顺捷、吉联、舜杰、捷联均为枚举占位;隆基、晶科、通威、宁德时代、吉莲在代码中不存在 |
| 2026-09-28(估算,会话首条命令时间不可得) | 2026-09-28(估算) | 约 2400(估算) | 公司管理添加/编辑公司增加登录账号密码字段;定义 roleType=5 为公司账号(先定 3 后改 5) | 是:t_group_company 增加 account/password;内置角色 role_key=company 供公司账号共享;sys_user.roleType 语义统一为 1=平台 2=合作商 3=门店 4=修理厂 5=公司,遗留公司接口 roleType 2→5;公司增/改/删同步创建/更新/停用 sys_user 账号(前端密码 HMAC-MD5) | 公司管理账号密码字段与公司登录账号全链路 | `ruoyi-api/ruoyi-api-other/.../domain/TGroupCompany.java`;`ruoyi-api/ruoyi-api-system/.../feignClient/SysRoleClient.java`;`ruoyi-api/ruoyi-api-system/.../factory/SysRoleFallbackFactory.java`;`ruoyi-modules/ruoyi-system/.../controller/SysRoleController.java`;`ruoyi-modules/ruoyi-system/.../controller/SysUserController.java`;`ruoyi-modules/ruoyi-system/src/test/.../PartnerTypeRoleServiceTest.java`;`ruoyi-service/ruoyi-other/.../service/TGroupCompanyService.java`;`ruoyi-service/ruoyi-other/.../service/impl/TGroupCompanyServiceImpl.java`;`ruoyi-service/ruoyi-other/.../controller/TGroupCompanyController.java`;`ruoyi-service/ruoyi-other/src/test/.../TGroupCompanyServiceImplTest.java`;`ruoyi-service/ruoyi-other/src/test/.../EmindEnergyChainManagementServiceTest.java`;`sql/20260928_company_account_role.sql`;`qianduan/chargingPile_management/src/pages/company/index.jsx`;`doc/耗时统计.md` | 后端 4 模块 `mvn -am compile` 通过;surefire 2.22.2 跑 TGroupCompanyServiceImplTest 6/6、PartnerTypeRoleServiceTest 7/7 全部通过;前端 `tsc --noEmit` 通过;前端生产构建成功(dist 生成);IDE lint 0 条 | SQL 脚本未在数据库执行,待部署时执行;修复了 2 处历史遗留测试(EmindEnergyChainManagementServiceTest 构造参数不同步、TGroupCompanyServiceImplTest 断言与 getDetail 双查询不一致);前端构建需在脚本内设置 CODEBUDDY_SAFE_DELETE_ENABLED=0 且勿删除被占用日志文件 |
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/VinManagementAllocateDTO.java
@@ -19,6 +19,9 @@
    @ApiModelProperty(value = "分配明细:VIN记录ID与本次分配金额", required = true)
    private List<VinManagementAllocateItemDTO> items;
    @ApiModelProperty(value = "操作密码", required = true)
    /**
     * 操作密码:VIN码额度分配已取消操作密码校验,该字段仅保留向后兼容,前端无需传值
     */
    @ApiModelProperty(value = "操作密码(已废弃,额度分配不再校验)")
    private String operationPassword;
}
ruoyi-api/ruoyi-api-other/src/main/java/com/ruoyi/other/api/domain/TGroupCompany.java
@@ -54,6 +54,14 @@
    @TableField("contact_phone")
    private String contactPhone;
    @ApiModelProperty(value = "登录账号(对应sys_user.user_name,roleType=5公司账号)")
    @TableField("account")
    private String account;
    @ApiModelProperty(value = "登录密码(前端HMAC-MD5加密值,仅添加时写入,不回显)")
    @TableField("password")
    private String password;
    @ApiModelProperty(value = "账户模式(1=预充值,2=后付费)")
    @TableField("account_mode")
    private Integer accountMode;
ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/factory/SysRoleFallbackFactory.java
@@ -22,6 +22,11 @@
            public R<List<SysRole>> getSysRoleByIds(GetSysRoleByIds ids) {
                return R.fail("获取角色失败:" + cause.getMessage());
            }
            @Override
            public R<Long> getRoleIdByRoleKey(String roleKey) {
                return R.fail("获取角色失败:" + cause.getMessage());
            }
        };
    }
}
ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/feignClient/SysRoleClient.java
@@ -21,4 +21,10 @@
    @PostMapping("/role/getSysRoleByIds")
    public R<List<SysRole>> getSysRoleByIds(GetSysRoleByIds ids);
    /**
     * 根据角色标识查询启用且未删除的角色ID(公司账号分配默认角色使用)
     */
    @PostMapping("/role/getRoleIdByRoleKey")
    public R<Long> getRoleIdByRoleKey(String roleKey);
}
ruoyi-modules/ruoyi-system/src/main/java/com/ruoyi/system/controller/SysRoleController.java
@@ -69,6 +69,8 @@
    private static final String SITE_OWNER_ROLE_KEY = "site_owner";
    private static final String SERVICE_PROVIDER_ROLE_NAME = "服务商";
    private static final String SERVICE_PROVIDER_ROLE_KEY = "service_provider";
    private static final String COMPANY_ROLE_NAME = "公司";
    private static final String COMPANY_ROLE_KEY = "company";
    private static final String OWN_SITE_NAME = "自有电站";
    private static final String SYSTEM_ROLE_REMARK = "系统内置角色";
@@ -99,6 +101,7 @@
        ensureSystemRole(INDEPENDENT_PARTNER_ROLE_NAME, INDEPENDENT_PARTNER_ROLE_KEY, getIndependentPartnerMenuIds());
        ensureSystemRole(SITE_OWNER_ROLE_NAME, SITE_OWNER_ROLE_KEY, new HashSet<>());
        ensureSystemRole(SERVICE_PROVIDER_ROLE_NAME, SERVICE_PROVIDER_ROLE_KEY, new HashSet<>());
        ensureSystemRole(COMPANY_ROLE_NAME, COMPANY_ROLE_KEY, new HashSet<>());
    }
    private void ensureSystemRole(String roleName, String roleKey, Set<Long> menuIds) {
@@ -182,8 +185,13 @@
                || SERVICE_PROVIDER_ROLE_NAME.equals(role.getRoleName()));
    }
    private boolean isCompanyBuiltinRole(SysRole role) {
        return role != null && (COMPANY_ROLE_KEY.equals(role.getRoleKey())
                || COMPANY_ROLE_NAME.equals(role.getRoleName()));
    }
    private boolean isSystemBuiltinRole(SysRole role) {
        return role != null && (role.isAdmin() || isPartnerBuiltinRole(role));
        return role != null && (role.isAdmin() || isPartnerBuiltinRole(role) || isCompanyBuiltinRole(role));
    }
    private void fillRoleV2Info(SysRole role) {
@@ -460,4 +468,22 @@
        List<SysRole> sysRoleByIds = roleService.getSysRoleByIds(ids.getIds());
        return R.ok(sysRoleByIds);
    }
    /**
     * 根据角色标识查询启用且未删除的角色ID(远程接口,公司账号分配默认角色使用)
     *
     * @param roleKey 角色标识
     * @return 角色ID;不存在时返回 null
     */
    @ResponseBody
    @PostMapping(value = "/getRoleIdByRoleKey")
    public R<Long> getRoleIdByRoleKey(@RequestBody String roleKey) {
        SysRole role = roleService.getOne(new LambdaQueryWrapper<SysRole>()
                .eq(SysRole::getRoleKey, roleKey)
                .eq(SysRole::getDelFlag, 0)
                .eq(SysRole::getStatus, 0)
                .orderByAsc(SysRole::getRoleId)
                .last("LIMIT 1"));
        return R.ok(role == null ? null : role.getRoleId());
    }
}
ruoyi-modules/ruoyi-system/src/main/java/com/ruoyi/system/controller/SysUserController.java
@@ -349,7 +349,7 @@
        if (companySysUserReq.getName() != null && !"".equals(companySysUserReq.getName())) {
            sysUserLambdaQueryWrapper.like(SysUser::getUserName, companySysUserReq.getName()).or().like(SysUser::getPhonenumber, companySysUserReq.getName());
        }
        sysUserLambdaQueryWrapper.eq(SysUser::getRoleType,2);
        sysUserLambdaQueryWrapper.eq(SysUser::getRoleType,5);
        List<SysUser> list = userService.list(sysUserLambdaQueryWrapper);
        ArrayList<CompanyUserListVo> companyUserListVos = new ArrayList<>();
        for (SysUser sysUser : list) {
@@ -367,7 +367,7 @@
    @PostMapping("/getSysUserFromCompanyIdOne")
    public R<List<CompanyUserListVo>> getSysUserFromCompanyIdOne(@RequestBody CompanySysUserReq companySysUserReq) {
        LambdaQueryWrapper<SysUser> sysUserLambdaQueryWrapper = new LambdaQueryWrapper<>();
        sysUserLambdaQueryWrapper.eq(SysUser::getRoleType, 2);
        sysUserLambdaQueryWrapper.eq(SysUser::getRoleType, 5);
        if (companySysUserReq.getName() != null && !"".equals(companySysUserReq.getName())) {
            sysUserLambdaQueryWrapper.like(SysUser::getPhonenumber, companySysUserReq.getName());
        }
@@ -433,7 +433,7 @@
                    List<Long> ids = userService.getSysUserFromPhone(companyUserListVo.getPhoneNumber());
                SysUser sysUser = new SysUser();
                    if (ids.size() > 0) {
                        sysUser.setRoleType(2);
                        sysUser.setRoleType(5);
                        sysUser.setObjectId(dto.getCompanyId());
                        if(companyUserListVo.getPassword()!=null){
                            sysUser.setPassword(SecurityUtils.encryptPassword(companyUserListVo.getPassword()));
@@ -446,7 +446,7 @@
                        userIds.add(ids.get(0).intValue());
                    } else {
                        sysUser.setRoleType(2);
                        sysUser.setRoleType(5);
                        sysUser.setObjectId(dto.getCompanyId());
                        if(companyUserListVo.getPassword()!=null){
                            sysUser.setPassword(SecurityUtils.encryptPassword(companyUserListVo.getPassword()));
ruoyi-modules/ruoyi-system/src/test/java/com/ruoyi/system/service/PartnerTypeRoleServiceTest.java
@@ -75,6 +75,20 @@
    }
    @Test
    void doesNothingForCompanyAccount() {
        // roleType=5 为公司账号,不触发合作商站点级角色同步
        Fixture fixture = new Fixture(2);
        fixture.user.setRoleType(5);
        fixture.user.setObjectId(null);
        fixture.service.synchronize(fixture.user);
        verify(fixture.siteClient, never()).getPartnerR(any());
        verify(fixture.roleService, never()).list(any(Wrapper.class));
        verify(fixture.userRoleService, never()).save(any(SysUserRole.class));
    }
    @Test
    void failsClearlyWhenConfiguredRoleIsMissing() {
        Fixture fixture = new Fixture(3);
        when(fixture.roleService.list(any(Wrapper.class)))
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TVinManagementController.java
@@ -38,6 +38,9 @@
    @PutMapping public R<Boolean> edit(@RequestBody VinManagementSaveDTO dto) { return R.ok(service.updateVin(dto)); }
    @RequiresPermissions(value = "/vinManagement/delete", logical = Logical.OR)
    @DeleteMapping public R<Boolean> delete(@RequestBody List<Long> ids) { return R.ok(service.deleteVins(ids)); }
    @ApiOperation(value = "批量解绑车队(清空VIN车队归属,返回实际解绑数量)")
    @RequiresPermissions(value = {"/vinManagement/unbind"}, logical = Logical.OR)
    @PutMapping("/unbind-fleet") public R<Integer> unbindFleet(@RequestBody List<Long> ids) { return R.ok(service.unbindFleet(ids)); }
    @RequiresPermissions(value = "/vinManagement/status", logical = Logical.OR)
    @PutMapping("/status") public R<Boolean> status(@RequestBody VinManagementSaveDTO dto) { return R.ok(service.updateStatus(dto.getId(), dto.getStatus())); }
    @RequiresPermissions(value = "/vinManagement/export", logical = Logical.OR)
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TVinManagementService.java
@@ -15,6 +15,14 @@
    boolean saveVin(VinManagementSaveDTO dto);
    boolean updateVin(VinManagementSaveDTO dto);
    boolean deleteVins(List<Long> ids);
    /**
     * 批量解绑车队:仅清空车队归属(fleet_id 置空),公司、初始分配额度、状态等不受影响
     *
     * @return 实际解绑的VIN数量(未绑定车队的记录不计入)
     */
    int unbindFleet(List<Long> ids);
    boolean updateStatus(Long id, Integer status);
    List<VinManagementExcelDTO> selectExportList(VinManagementQueryDTO query);
    String importVins(List<VinManagementExcelDTO> items);
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TVinManagementServiceImpl.java
@@ -15,16 +15,12 @@
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.security.service.TokenService;
import com.ruoyi.common.security.utils.SecurityUtils;
import com.ruoyi.order.api.feignClient.ChargingOrderClient;
import com.ruoyi.order.api.vo.VinChargingStatusVO;
import com.ruoyi.other.api.domain.TFleet;
import com.ruoyi.other.api.domain.TGroupCompany;
import com.ruoyi.other.api.feignClient.GroupCompanyClient;
import com.ruoyi.system.api.domain.SysUser;
import com.ruoyi.system.api.feignClient.SysUserClient;
import com.ruoyi.system.api.model.LoginUser;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
@@ -70,12 +66,6 @@
    @Resource
    private GroupCompanyClient groupCompanyClient;
    @Resource
    private TokenService tokenService;
    @Resource
    private SysUserClient sysUserClient;
    @Resource
    private RedissonClient redissonClient;
@@ -166,6 +156,44 @@
        }
    }
    @Override public boolean deleteVins(List<Long> ids) { if (ids == null || ids.isEmpty()) throw new ServiceException("请选择需要删除的VIN码"); List<Long> distinct = new ArrayList<>(new HashSet<>(ids)); if (listByIds(distinct).size() != distinct.size()) throw new ServiceException("部分VIN码不存在或已删除"); return removeByIds(distinct); }
    /**
     * 批量解绑车队:仅清空车队归属(fleet_id 置空),公司、初始分配额度、状态等不受影响;
     * 未绑定车队的选中记录忽略不计
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int unbindFleet(List<Long> ids) {
        if (ids == null || ids.isEmpty()) {
            throw new ServiceException("请选择需要解绑车队的VIN码");
        }
        List<Long> distinct = ids.stream().filter(Objects::nonNull).distinct().collect(Collectors.toList());
        if (distinct.isEmpty()) {
            throw new ServiceException("请选择需要解绑车队的VIN码");
        }
        List<TVinManagement> records = listByIds(distinct);
        if (records.size() != distinct.size()) {
            throw new ServiceException("部分VIN码不存在或已删除");
        }
        List<Long> boundIds = records.stream()
                .filter(record -> record.getFleetId() != null)
                .map(TVinManagement::getId)
                .collect(Collectors.toList());
        if (boundIds.isEmpty()) {
            return 0;
        }
        // 显式 set null 清空车队归属(MyBatis-Plus 字段策略会忽略实体 null,需走 UpdateWrapper)
        boolean updated = lambdaUpdate()
                .in(TVinManagement::getId, boundIds)
                .set(TVinManagement::getFleetId, null)
                .set(TVinManagement::getUpdateTime, LocalDateTime.now())
                .set(TVinManagement::getUpdateBy, SecurityUtils.getUserId())
                .update();
        if (!updated) {
            throw new ServiceException("VIN码车队解绑失败,请重试");
        }
        return boundIds.size();
    }
    @Override public boolean updateStatus(Long id, Integer status) { if (id == null || !validStatus(status)) throw new ServiceException("VIN码状态参数错误"); if (getById(id) == null) throw new ServiceException("VIN码不存在或已删除"); TVinManagement item = new TVinManagement(); item.setId(id); item.setStatus(status); item.setUpdateTime(LocalDateTime.now()); item.setUpdateBy(SecurityUtils.getUserId()); return updateById(item); }
    @Override public List<VinManagementExcelDTO> selectExportList(VinManagementQueryDTO query) { return baseMapper.selectExportList(query == null ? new VinManagementQueryDTO() : query); }
    @Override public List<TVinManagement> selectAppletVehicleList(Long appUserId) {
@@ -235,7 +263,6 @@
    @Transactional(rollbackFor = Exception.class)
    public VinManagementAllocateVO allocateQuota(VinManagementAllocateDTO dto) {
        Map<Long, BigDecimal> amounts = validateAllocateRequest(dto);
        verifyOperationPassword(dto.getOperationPassword());
        Integer companyId = dto.getCompanyId();
        // 公司必须存在、启用且为分配模式(共享模式不支持额度分配)
        TGroupCompany company = loadAllocationCompany(companyId);
@@ -474,9 +501,6 @@
        if (dto.getItems().size() > MAX_ALLOCATE_VIN_COUNT) {
            throw new ServiceException("单次最多分配" + MAX_ALLOCATE_VIN_COUNT + "条VIN码");
        }
        if (StringUtils.isEmpty(dto.getOperationPassword())) {
            throw new ServiceException("请输入操作密码");
        }
        Map<Long, BigDecimal> amounts = new LinkedHashMap<>();
        for (VinManagementAllocateItemDTO item : dto.getItems()) {
            if (item == null || item.getVinId() == null) {
@@ -517,22 +541,6 @@
            return "公司可分配余额不足,无法完成本次分配";
        }
        return "公司可用余额扣减失败:" + msg;
    }
    /** 校验操作密码(与卡片管理一致,使用账号操作密码二次确认) */
    private void verifyOperationPassword(String operationPassword) {
        LoginUser loginUser = tokenService.getLoginUser();
        if (loginUser == null || loginUser.getUserid() == null) {
            throw new ServiceException("登录状态已失效,请重新登录");
        }
        R<SysUser> response = sysUserClient.getSysUser(loginUser.getUserid());
        SysUser user = response == null ? null : response.getData();
        if (user == null || StringUtils.isEmpty(user.getOperationPassword())) {
            throw new ServiceException("当前账号未设置操作密码");
        }
        if (!SecurityUtils.matchesPassword(operationPassword, user.getOperationPassword())) {
            throw new ServiceException("操作密码错误");
        }
    }
    /** 抢占并发锁,抢占失败抛出业务异常 */
ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/controller/TVinManagementControllerTest.java
@@ -109,6 +109,21 @@
    }
    @Test
    void unbindFleetReturnsAffectedCount() {
        TVinManagementService service = mock(TVinManagementService.class);
        when(service.unbindFleet(anyList())).thenReturn(2);
        TVinManagementController controller = buildController(service);
        List<Long> ids = Arrays.asList(1L, 2L);
        R<Integer> result = controller.unbindFleet(ids);
        assertEquals(200, result.getCode());
        assertEquals(Integer.valueOf(2), result.getData());
        verify(service).unbindFleet(ids);
    }
    @Test
    void statusDelegatesIdAndStatusFromDto() {
        TVinManagementService service = mock(TVinManagementService.class);
        when(service.updateStatus(9L, 2)).thenReturn(true);
ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TVinManagementServiceImplTest.java
@@ -17,15 +17,10 @@
import com.ruoyi.common.core.context.SecurityContextHolder;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.exception.ServiceException;
import com.ruoyi.common.security.service.TokenService;
import com.ruoyi.common.security.utils.SecurityUtils;
import com.ruoyi.order.api.feignClient.ChargingOrderClient;
import com.ruoyi.order.api.vo.VinChargingStatusVO;
import com.ruoyi.other.api.domain.TGroupCompany;
import com.ruoyi.other.api.feignClient.GroupCompanyClient;
import com.ruoyi.system.api.domain.SysUser;
import com.ruoyi.system.api.feignClient.SysUserClient;
import com.ruoyi.system.api.model.LoginUser;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
@@ -80,12 +75,6 @@
    private GroupCompanyClient groupCompanyClient;
    @Mock
    private TokenService tokenService;
    @Mock
    private SysUserClient sysUserClient;
    @Mock
    private RedissonClient redissonClient;
    @Mock
@@ -115,8 +104,6 @@
        ReflectionTestUtils.setField(service, "baseMapper", mapper);
        ReflectionTestUtils.setField(service, "chargingOrderClient", chargingOrderClient);
        ReflectionTestUtils.setField(service, "groupCompanyClient", groupCompanyClient);
        ReflectionTestUtils.setField(service, "tokenService", tokenService);
        ReflectionTestUtils.setField(service, "sysUserClient", sysUserClient);
        ReflectionTestUtils.setField(service, "redissonClient", redissonClient);
        lenient().when(redissonClient.getLock(anyString())).thenReturn(quotaLock);
        lenient().when(quotaLock.tryLock(anyLong(), anyLong(), any(TimeUnit.class))).thenReturn(true);
@@ -361,6 +348,56 @@
        assertEquals(2, idsCaptor.getValue().size());
        assertEquals(Long.valueOf(1L), idsCaptor.getValue().get(0));
        assertEquals(Long.valueOf(2L), idsCaptor.getValue().get(1));
    }
    // ---------------- unbindFleet ----------------
    @Test
    void unbindFleetRejectsNullOrEmptyIds() {
        ServiceException ex1 = assertThrows(ServiceException.class, () -> service.unbindFleet(null));
        assertEquals("请选择需要解绑车队的VIN码", ex1.getMessage());
        ServiceException ex2 = assertThrows(ServiceException.class, () -> service.unbindFleet(Collections.emptyList()));
        assertEquals("请选择需要解绑车队的VIN码", ex2.getMessage());
        verifyNoInteractions(mapper);
    }
    @Test
    void unbindFleetRejectsMissingRecords() {
        TVinManagement existing = new TVinManagement();
        existing.setId(1L);
        when(mapper.selectBatchIds(anyCollection())).thenReturn(Collections.singletonList(existing));
        ServiceException ex = assertThrows(ServiceException.class, () -> service.unbindFleet(Arrays.asList(1L, 2L)));
        assertEquals("部分VIN码不存在或已删除", ex.getMessage());
        verify(mapper, never()).update(isNull(), any(Wrapper.class));
    }
    @Test
    void unbindFleetClearsFleetIdAndIgnoresUnboundRecords() {
        TVinManagement bound = new TVinManagement();
        bound.setId(1L);
        bound.setFleetId(5);
        TVinManagement unbound = new TVinManagement();
        unbound.setId(2L);
        when(mapper.selectBatchIds(anyCollection())).thenReturn(Arrays.asList(bound, unbound));
        when(mapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
        int result = service.unbindFleet(Arrays.asList(1L, 2L, 2L, null));
        assertEquals(1, result);
        @SuppressWarnings("unchecked")
        ArgumentCaptor<List<Long>> idsCaptor = ArgumentCaptor.forClass(List.class);
        verify(mapper).selectBatchIds(idsCaptor.capture());
        assertEquals(Arrays.asList(1L, 2L), idsCaptor.getValue());
        ArgumentCaptor<Wrapper> wrapperCaptor = ArgumentCaptor.forClass(Wrapper.class);
        verify(mapper).update(isNull(), wrapperCaptor.capture());
        String sqlSet = wrapperCaptor.getValue().getSqlSet();
        assertTrue(sqlSet.contains("fleet_id="));
        assertTrue(sqlSet.contains("update_time="));
        assertTrue(sqlSet.contains("update_by="));
    }
    // ---------------- updateStatus ----------------
@@ -683,7 +720,6 @@
    @Test
    void allocateQuotaAccumulatesVinQuotaAndDeductsCompanyBalance() {
        mockOperationPassword();
        when(groupCompanyClient.getCompanyById(9)).thenReturn(R.ok(allocationCompany(9, "测试公司")));
        doReturn(Arrays.asList(allocateVehicle(1L, 9, "VIN-1", "10.00"), allocateVehicle(2L, 9, "VIN-2", "20.00")))
                .when(service).listByIds(anyCollection());
@@ -691,7 +727,7 @@
        when(mapper.addInitQuota(1L, new BigDecimal("10.00"), CURRENT_USER)).thenReturn(1);
        when(mapper.addInitQuota(2L, new BigDecimal("20.00"), CURRENT_USER)).thenReturn(1);
        VinManagementAllocateVO vo = service.allocateQuota(allocateDto(9, "pwd",
        VinManagementAllocateVO vo = service.allocateQuota(allocateDto(9,
                allocateItem(1L, "10.00"), allocateItem(2L, "20.00")));
        assertEquals(new BigDecimal("970.00"), vo.getAvailableBalance());
@@ -702,25 +738,13 @@
    }
    @Test
    void allocateQuotaRejectsWrongOperationPassword() {
        mockOperationPassword();
        ServiceException exception = assertThrows(ServiceException.class,
                () -> service.allocateQuota(allocateDto(9, "bad", allocateItem(1L, "10.00"))));
        assertEquals("操作密码错误", exception.getMessage());
        verifyNoInteractions(groupCompanyClient);
    }
    @Test
    void allocateQuotaRejectsSharedModeCompany() {
        mockOperationPassword();
        TGroupCompany shared = allocationCompany(9, "共享公司");
        shared.setAccountMode(1);
        when(groupCompanyClient.getCompanyById(9)).thenReturn(R.ok(shared));
        ServiceException exception = assertThrows(ServiceException.class,
                () -> service.allocateQuota(allocateDto(9, "pwd", allocateItem(1L, "10.00"))));
                () -> service.allocateQuota(allocateDto(9, allocateItem(1L, "10.00"))));
        assertEquals("该公司为共享模式,不支持额度分配", exception.getMessage());
        verify(groupCompanyClient, never()).deductBalance(any(Integer.class), any(BigDecimal.class));
@@ -728,13 +752,12 @@
    @Test
    void allocateQuotaRejectsVehicleOfOtherCompany() {
        mockOperationPassword();
        when(groupCompanyClient.getCompanyById(9)).thenReturn(R.ok(allocationCompany(9, "测试公司")));
        doReturn(Collections.singletonList(allocateVehicle(1L, 8, "VIN-1", "0.00")))
                .when(service).listByIds(anyCollection());
        ServiceException exception = assertThrows(ServiceException.class,
                () -> service.allocateQuota(allocateDto(9, "pwd", allocateItem(1L, "10.00"))));
                () -> service.allocateQuota(allocateDto(9, allocateItem(1L, "10.00"))));
        assertTrue(exception.getMessage().contains("不属于所选公司"));
        verify(groupCompanyClient, never()).deductBalance(any(Integer.class), any(BigDecimal.class));
@@ -742,14 +765,13 @@
    @Test
    void allocateQuotaRejectsDisabledVehicle() {
        mockOperationPassword();
        when(groupCompanyClient.getCompanyById(9)).thenReturn(R.ok(allocationCompany(9, "测试公司")));
        TVinManagement vehicle = allocateVehicle(1L, 9, "VIN-1", "0.00");
        vehicle.setStatus(2);
        doReturn(Collections.singletonList(vehicle)).when(service).listByIds(anyCollection());
        ServiceException exception = assertThrows(ServiceException.class,
                () -> service.allocateQuota(allocateDto(9, "pwd", allocateItem(1L, "10.00"))));
                () -> service.allocateQuota(allocateDto(9, allocateItem(1L, "10.00"))));
        assertTrue(exception.getMessage().contains("已禁用"));
        verify(groupCompanyClient, never()).deductBalance(any(Integer.class), any(BigDecimal.class));
@@ -757,13 +779,12 @@
    @Test
    void allocateQuotaRejectsQuotaOverflow() {
        mockOperationPassword();
        when(groupCompanyClient.getCompanyById(9)).thenReturn(R.ok(allocationCompany(9, "测试公司")));
        doReturn(Collections.singletonList(allocateVehicle(1L, 9, "VIN-1", "99999999.00")))
                .when(service).listByIds(anyCollection());
        ServiceException exception = assertThrows(ServiceException.class,
                () -> service.allocateQuota(allocateDto(9, "pwd", allocateItem(1L, "100.00"))));
                () -> service.allocateQuota(allocateDto(9, allocateItem(1L, "100.00"))));
        assertTrue(exception.getMessage().contains("超过上限"));
        verify(groupCompanyClient, never()).deductBalance(any(Integer.class), any(BigDecimal.class));
@@ -771,7 +792,6 @@
    @Test
    void allocateQuotaRefundsCompanyBalanceWhenQuotaUpdateFails() {
        mockOperationPassword();
        when(groupCompanyClient.getCompanyById(9)).thenReturn(R.ok(allocationCompany(9, "测试公司")));
        doReturn(Collections.singletonList(allocateVehicle(1L, 9, "VIN-1", "10.00")))
                .when(service).listByIds(anyCollection());
@@ -780,19 +800,10 @@
        when(groupCompanyClient.refundBalance(9, new BigDecimal("10.00"))).thenReturn(R.ok(new BigDecimal("1000.00")));
        ServiceException exception = assertThrows(ServiceException.class,
                () -> service.allocateQuota(allocateDto(9, "pwd", allocateItem(1L, "10.00"))));
                () -> service.allocateQuota(allocateDto(9, allocateItem(1L, "10.00"))));
        assertEquals("VIN码【VIN-1】额度分配失败,请重试", exception.getMessage());
        verify(groupCompanyClient).refundBalance(9, new BigDecimal("10.00"));
    }
    private void mockOperationPassword() {
        LoginUser loginUser = new LoginUser();
        loginUser.setUserid(CURRENT_USER);
        SysUser user = new SysUser();
        user.setOperationPassword(SecurityUtils.encryptPassword("pwd"));
        when(tokenService.getLoginUser()).thenReturn(loginUser);
        when(sysUserClient.getSysUser(CURRENT_USER)).thenReturn(R.ok(user));
    }
    private TGroupCompany allocationCompany(Integer id, String name) {
@@ -823,11 +834,10 @@
        return item;
    }
    private VinManagementAllocateDTO allocateDto(Integer companyId, String password, VinManagementAllocateItemDTO... items) {
    private VinManagementAllocateDTO allocateDto(Integer companyId, VinManagementAllocateItemDTO... items) {
        VinManagementAllocateDTO dto = new VinManagementAllocateDTO();
        dto.setCompanyId(companyId);
        dto.setItems(Arrays.asList(items));
        dto.setOperationPassword(password);
        return dto;
    }
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/controller/TGroupCompanyController.java
@@ -74,7 +74,7 @@
    }
    /**
     * 添加公司
     * 添加公司(同步创建登录账号,roleType=5)
     */
    @RequiresPermissions(value = {"/companyManagement/list"}, logical = Logical.OR)
    @PostMapping("/add")
@@ -87,17 +87,22 @@
        if (!StringUtils.hasText(company.getName())) {
            return AjaxResult.error("公司名称不能为空");
        }
        if (!StringUtils.hasText(company.getAccount())) {
            return AjaxResult.error("登录账号不能为空");
        }
        if (!StringUtils.hasText(company.getPassword())) {
            return AjaxResult.error("登录密码不能为空");
        }
        company.setId(null);
        company.setDelFlag(false);
        if (company.getStatus() == null) {
            company.setStatus(1);
        }
        groupCompanyService.save(company);
        return AjaxResult.success();
        return groupCompanyService.addCompany(company);
    }
    /**
     * 编辑公司
     * 编辑公司(同步登录账号资料,密码不可修改)
     */
    @RequiresPermissions(value = {"/companyManagement/list"}, logical = Logical.OR)
    @PostMapping("/edit")
@@ -107,12 +112,11 @@
        if (company.getId() == null) {
            return AjaxResult.error("公司ID不能为空");
        }
        groupCompanyService.updateById(company);
        return AjaxResult.success();
        return groupCompanyService.editCompany(company);
    }
    /**
     * 删除公司
     * 删除公司(同步停用其登录账号 delFlag=2)
     */
    @RequiresPermissions(value = {"/companyManagement/list"}, logical = Logical.OR)
    @DeleteMapping("/del")
@@ -122,8 +126,7 @@
        if (id == null) {
            return AjaxResult.error("公司ID不能为空");
        }
        groupCompanyService.removeById(id);
        return AjaxResult.success();
        return groupCompanyService.delCompany(id);
    }
    /**
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/TGroupCompanyService.java
@@ -1,6 +1,7 @@
package com.ruoyi.other.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.common.core.web.domain.AjaxResult;
import com.ruoyi.other.api.domain.TGroupCompany;
/**
@@ -21,4 +22,28 @@
     */
    TGroupCompany getDetail(Integer id);
    /**
     * 添加公司并同步创建登录账号(sys_user,roleType=5)
     *
     * @param company 公司(含账号、密码)
     * @return 操作结果
     */
    AjaxResult addCompany(TGroupCompany company);
    /**
     * 编辑公司并同步登录账号资料(不修改密码)
     *
     * @param company 公司
     * @return 操作结果
     */
    AjaxResult editCompany(TGroupCompany company);
    /**
     * 删除公司并停用其登录账号(delFlag=2)
     *
     * @param id 公司ID
     * @return 操作结果
     */
    AjaxResult delCompany(Integer id);
}
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImpl.java
@@ -2,6 +2,9 @@
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.utils.StringUtils;
import com.ruoyi.common.core.web.domain.AjaxResult;
import com.ruoyi.other.api.domain.TFleet;
import com.ruoyi.other.api.domain.TGroupCompany;
import com.ruoyi.other.api.model.TCompanyRechargeRecord;
@@ -9,13 +12,18 @@
import com.ruoyi.other.mapper.TFleetMapper;
import com.ruoyi.other.mapper.TGroupCompanyMapper;
import com.ruoyi.other.service.TGroupCompanyService;
import com.ruoyi.system.api.domain.SysUser;
import com.ruoyi.system.api.feignClient.SysRoleClient;
import com.ruoyi.system.api.feignClient.SysUserClient;
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.LocalDate;
import java.time.LocalDateTime;
import java.util.Date;
import java.util.List;
import java.util.Objects;
@@ -33,6 +41,12 @@
    /** 金额保留小数位(与 t_company_recharge_record.arrival_amount 一致) */
    private static final int AMOUNT_SCALE = 2;
    /** 公司账号角色标识(内置角色 role_key=company) */
    private static final String COMPANY_ROLE_KEY = "company";
    /** 公司账号角色类型(sys_user.roleType=5) */
    private static final int COMPANY_ROLE_TYPE = 5;
    /**
     * 车队 Mapper。
     * 此处注入 Mapper 而非 TFleetService:TFleetServiceImpl 已依赖 TGroupCompanyService,
@@ -44,6 +58,14 @@
    /** 公司充值记录 Mapper */
    @Resource
    private TCompanyRechargeRecordMapper companyRechargeRecordMapper;
    /** 系统用户远程客户端(公司登录账号同步) */
    @Resource
    private SysUserClient sysUserClient;
    /** 系统角色远程客户端(公司默认角色查询) */
    @Resource
    private SysRoleClient sysRoleClient;
    @Override
    public TGroupCompany getDetail(Integer id) {
@@ -116,4 +138,148 @@
                .setScale(AMOUNT_SCALE, RoundingMode.HALF_UP);
    }
    /**
     * 查询公司默认角色ID(内置角色 role_key=company)
     */
    private Long resolveCompanyRoleId() {
        R<Long> roleR = sysRoleClient.getRoleIdByRoleKey(COMPANY_ROLE_KEY);
        if (roleR == null || 200 != roleR.getCode() || roleR.getData() == null) {
            throw new RuntimeException("系统内置角色【" + COMPANY_ROLE_KEY + "】不存在或已停用,请先配置角色");
        }
        return roleR.getData();
    }
    /**
     * 检查登录账号在 t_group_company 中是否已存在(编辑时排除自身)
     */
    private boolean accountExists(String account, Integer excludeId) {
        Long count = count(Wrappers.<TGroupCompany>lambdaQuery()
                .eq(TGroupCompany::getAccount, account)
                .ne(excludeId != null, TGroupCompany::getId, excludeId));
        return count != null && count > 0;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public AjaxResult addCompany(TGroupCompany company) {
        if (!StringUtils.hasText(company.getAccount())) {
            return AjaxResult.error("登录账号不能为空");
        }
        if (!StringUtils.hasText(company.getPassword())) {
            return AjaxResult.error("登录密码不能为空");
        }
        if (accountExists(company.getAccount(), null)) {
            return AjaxResult.error("登录账号不能重复");
        }
        R<SysUser> existR = sysUserClient.queryUserByUserName(company.getAccount());
        SysUser existUser = existR == null ? null : existR.getData();
        if (existUser != null) {
            return AjaxResult.error("登录账号不能重复");
        }
        Long roleId = resolveCompanyRoleId();
        SysUser user = new SysUser();
        user.setUserName(company.getAccount());
        user.setNickName(company.getName());
        user.setPhonenumber(company.getContactPhone());
        user.setPassword(company.getPassword());
        user.setStatus("0");
        user.setDelFlag("0");
        user.setRoleId(roleId);
        user.setRoleType(COMPANY_ROLE_TYPE);
        user.setPassWordUpdate(new Date());
        R<Long> r = sysUserClient.addSysUser(user);
        if (r == null || 200 != r.getCode()) {
            throw new RuntimeException(r == null ? "创建登录账号失败" : r.getMsg());
        }
        save(company);
        // 回写 objectId=公司ID(账号创建时公司尚未保存)
        R<SysUser> createdR = sysUserClient.getSysUser(r.getData());
        if (createdR != null && 200 == createdR.getCode() && createdR.getData() != null) {
            SysUser created = createdR.getData();
            created.setObjectId(company.getId());
            sysUserClient.updateUser(created);
        }
        return AjaxResult.success();
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public AjaxResult editCompany(TGroupCompany company) {
        TGroupCompany old = getById(company.getId());
        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);
        }
        updateById(company);
        return AjaxResult.success();
    }
    /**
     * 编辑公司时同步登录账号:存在则仅更新账号资料(不修改密码),不存在则补建
     */
    private void syncCompanyUserOnEdit(TGroupCompany company, String account) {
        R<SysUser> userR = sysUserClient.queryUserByUserName(account);
        SysUser user = userR == null ? null : userR.getData();
        if (user == null) {
            if (!StringUtils.hasText(company.getPassword())) {
                throw new RuntimeException("补建登录账号时登录密码不能为空");
            }
            Long roleId = resolveCompanyRoleId();
            SysUser newUser = new SysUser();
            newUser.setUserName(account);
            newUser.setNickName(company.getName());
            newUser.setPhonenumber(company.getContactPhone());
            newUser.setPassword(company.getPassword());
            newUser.setStatus("0");
            newUser.setDelFlag("0");
            newUser.setRoleId(roleId);
            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());
            }
            return;
        }
        // 远程查询返回的密码字段可能是哈希或脱敏值,显式清空避免再次写回
        user.setPassword(null);
        user.setPassWordUpdate(null);
        user.setUserName(account);
        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());
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public AjaxResult delCompany(Integer id) {
        TGroupCompany company = getById(id);
        if (company == null) {
            return AjaxResult.error("公司不存在");
        }
        removeById(id);
        if (StringUtils.hasText(company.getAccount())) {
            R<SysUser> userR = sysUserClient.queryUserByUserName(company.getAccount());
            SysUser user = userR == null ? null : userR.getData();
            if (user != null) {
                user.setDelFlag("2");
                sysUserClient.updateUser(user);
            }
        }
        return AjaxResult.success();
    }
}
ruoyi-service/ruoyi-other/src/test/java/com/ruoyi/other/emind/service/EmindEnergyChainManagementServiceTest.java
@@ -34,12 +34,14 @@
    private EmindEnergyChainMapper mapper;
    @Mock
    private EmindStationMapper stationMapper;
    @Mock
    private EmindEnergyChainDetailService emindEnergyChainDetailService;
    private EmindEnergyChainManagementService service;
    @BeforeEach
    void setUp() {
        Clock clock = Clock.fixed(Instant.parse("2026-07-25T08:00:00Z"), ZoneOffset.UTC);
        service = new EmindEnergyChainManagementService(mapper, stationMapper, clock);
        service = new EmindEnergyChainManagementService(mapper, stationMapper, clock, emindEnergyChainDetailService);
    }
    @Test
ruoyi-service/ruoyi-other/src/test/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImplTest.java
@@ -100,14 +100,15 @@
        buildService(company, fleetMapper, rechargeMapper).getDetail(2);
        ArgumentCaptor<Wrapper<TCompanyRechargeRecord>> captor = ArgumentCaptor.forClass(Wrapper.class);
        Mockito.verify(rechargeMapper).selectList(captor.capture());
        // getDetail 会查询两次充值记录:本月 + 累计;首次调用为本月查询
        Mockito.verify(rechargeMapper, Mockito.times(2)).selectList(captor.capture());
        // 过滤条件:company_id = 2,且 recharge_time ∈ [本月1日 00:00:00, 次月1日 00:00:00)
        String sqlSegment = captor.getValue().getSqlSegment();
        String sqlSegment = captor.getAllValues().get(0).getSqlSegment();
        assertTrue(sqlSegment.contains("company_id"), sqlSegment);
        assertTrue(sqlSegment.contains("recharge_time"), sqlSegment);
        LocalDateTime start = TGroupCompanyServiceImpl.monthStart(LocalDate.now());
        Map<?, ?> paramValues = (Map<?, ?>) ReflectionTestUtils.getField(captor.getValue(), "paramNameValuePairs");
        Map<?, ?> paramValues = (Map<?, ?>) ReflectionTestUtils.getField(captor.getAllValues().get(0), "paramNameValuePairs");
        assertTrue(paramValues != null && paramValues.containsValue(2), String.valueOf(paramValues));
        assertTrue(paramValues != null && paramValues.containsValue(start), String.valueOf(paramValues));
        assertTrue(paramValues != null && paramValues.containsValue(start.plusMonths(1)), String.valueOf(paramValues));
sql/20260928_company_account_role.sql
New file
@@ -0,0 +1,68 @@
-- ------------------------------------------
-- 公司管理:登录账号/密码字段 + 内置公司角色 + sys_user.roleType 注释
-- 创建日期: 2026-09-28
-- 说明:
--   1. t_group_company 新增 account/password(存储前端 HMAC-MD5 加密后的登录密码)
--   2. sys_role 幂等补充内置角色 role_key=company(所有公司账号共享,菜单在角色管理配置)
--   3. sys_user.roleType 注释统一为 1=平台 2=合作商 3=门店 4=修理厂 5=公司
-- 库: charging_pile_other
-- ------------------------------------------
-- 1. t_group_company 增加登录账号/密码字段(幂等)
SET @gc_account_exists := (
    SELECT COUNT(1)
    FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE()
      AND TABLE_NAME = 't_group_company'
      AND COLUMN_NAME = 'account'
);
SET @gc_account_ddl := IF(
    @gc_account_exists = 0,
    'ALTER TABLE `t_group_company` ADD COLUMN `account` varchar(255) NULL DEFAULT NULL COMMENT ''登录账号'' AFTER `contact_phone`',
    'SELECT 1'
);
PREPARE gc_account_stmt FROM @gc_account_ddl;
EXECUTE gc_account_stmt;
DEALLOCATE PREPARE gc_account_stmt;
SET @gc_password_exists := (
    SELECT COUNT(1)
    FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE()
      AND TABLE_NAME = 't_group_company'
      AND COLUMN_NAME = 'password'
);
SET @gc_password_ddl := IF(
    @gc_password_exists = 0,
    'ALTER TABLE `t_group_company` ADD COLUMN `password` varchar(255) NULL DEFAULT NULL COMMENT ''登录密码(前端HMAC-MD5加密值)'' AFTER `account`',
    'SELECT 1'
);
PREPARE gc_password_stmt FROM @gc_password_ddl;
EXECUTE gc_password_stmt;
DEALLOCATE PREPARE gc_password_stmt;
-- 2. 内置公司角色(幂等)
INSERT INTO sys_role (role_name, role_key, data_scope, status, del_flag, create_by, create_time, remark)
SELECT '公司', 'company', '5', '0', '0', 'admin', NOW(), '系统内置角色'
WHERE NOT EXISTS (
    SELECT 1 FROM sys_role
    WHERE del_flag = '0' AND (role_name = '公司' OR role_key = 'company')
);
UPDATE sys_role
SET role_name = '公司',
    role_key = 'company',
    status = '0',
    del_flag = '0',
    remark = '系统内置角色',
    update_by = 'admin',
    update_time = NOW()
WHERE role_name = '公司' OR role_key = 'company';
-- 3. sys_user.roleType 字段注释统一(5=公司)
ALTER TABLE `sys_user`
    MODIFY COLUMN `roleType` int NULL DEFAULT 1 COMMENT '角色类型 1=平台 2=合作商 3=门店 4=修理厂 5=公司';
sql/20260928_vin_unbind_auth.sql
New file
@@ -0,0 +1,32 @@
-- VIN码管理-批量解绑 按钮权限增量脚本
-- 说明:本功能无表结构变更(复用 t_vin_management),
--       仅在 charging_pile_other 逻辑库新增按钮权限。
-- 幂等:已存在同名 perms 时自动跳过,可重复执行。
USE charging_pile_other;
-- 批量解绑按钮权限;父菜单按现有VIN码管理菜单定位(历史数据可能存在多个VIN码管理节点,逐一定位均已覆盖)。
INSERT INTO `sys_menu`
(`menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
SELECT permissions.menu_name, parent.menu_id, permissions.order_num, '#', '', '', 1, 0, 'F', '0', '0', permissions.perms, '#', 'admin', NOW(), '', NULL, ''
FROM `sys_menu` parent
JOIN (
    SELECT '批量解绑' AS menu_name, 10 AS order_num, '/vinManagement/unbind' AS perms
) permissions
WHERE parent.perms = '/vinManagement/list'
  AND NOT EXISTS (
      SELECT 1 FROM `sys_menu` existing WHERE existing.perms = permissions.perms
  );
-- 默认仅授予管理员角色;其他角色由后台按职责单独授权。
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`)
SELECT 1, child.menu_id
FROM `sys_menu` child
WHERE child.parent_id = (
    SELECT parent.menu_id FROM `sys_menu` parent WHERE parent.perms = '/vinManagement/list' LIMIT 1
  )
  AND child.perms = '/vinManagement/unbind'
  AND NOT EXISTS (
      SELECT 1 FROM `sys_role_menu` existing
      WHERE existing.role_id = 1 AND existing.menu_id = child.menu_id
  );