puzhibing
2026-09-30 ab268d1525563fc8f22af808a58bc13f125f2796
增加充值记录
5个文件已添加
4个文件已修改
631 ■■■■■ 已修改文件
ruoyi-api/ruoyi-api-other/src/main/java/com/ruoyi/other/api/dto/CompanyRechargeRecordExcelDTO.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-other/src/main/java/com/ruoyi/other/api/dto/CompanyRechargeRecordQueryDTO.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-other/src/main/java/com/ruoyi/other/api/model/TCompanyRechargeRecord.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/controller/TCompanyRechargeController.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/TCompanyRechargeRecordService.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TCompanyRechargeRecordServiceImpl.java 144 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-other/src/test/java/com/ruoyi/other/service/impl/TCompanyRechargeRecordServiceImplTest.java 226 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/20260930_company_recharge_record_columns.sql 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/20260930_company_recharge_record_menu.sql 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-other/src/main/java/com/ruoyi/other/api/dto/CompanyRechargeRecordExcelDTO.java
New file
@@ -0,0 +1,43 @@
package com.ruoyi.other.api.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.core.annotation.Excel;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
 * 公司充值记录 Excel 导出 DTO(仅导出使用)
 */
@Data
public class CompanyRechargeRecordExcelDTO implements Serializable {
    private static final long serialVersionUID = 1L;
    @Excel(name = "充值流水号", sort = 1, width = 24)
    private String serialNo;
    @Excel(name = "公司", sort = 2, width = 24)
    private String companyName;
    @Excel(name = "实付金额", sort = 3, width = 14, scale = 2)
    private BigDecimal payAmount;
    @Excel(name = "赠送金额", sort = 4, width = 14, scale = 2)
    private BigDecimal giftAmount;
    @Excel(name = "到账金额", sort = 5, width = 14, scale = 2)
    private BigDecimal arrivalAmount;
    @Excel(name = "充值状态", sort = 6, width = 12, readConverterExp = "1=成功,2=失败,3=已退款")
    private Integer status;
    @Excel(name = "充值人", sort = 7, width = 16)
    private String operatorName;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    @Excel(name = "充值时间", sort = 8, width = 20, dateFormat = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime rechargeTime;
}
ruoyi-api/ruoyi-api-other/src/main/java/com/ruoyi/other/api/dto/CompanyRechargeRecordQueryDTO.java
New file
@@ -0,0 +1,39 @@
package com.ruoyi.other.api.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
 * 公司充值记录查询条件(公司管理-充值记录列表页)
 */
@Data
@ApiModel(value = "CompanyRechargeRecordQueryDTO", description = "公司充值记录查询条件")
public class CompanyRechargeRecordQueryDTO implements Serializable {
    private static final long serialVersionUID = 1L;
    @ApiModelProperty(value = "充值流水号(精确匹配)")
    private String serialNo;
    @ApiModelProperty(value = "公司ID")
    private Integer companyId;
    @ApiModelProperty(value = "充值状态 1=成功 2=失败 3=已退款")
    private Integer status;
    @ApiModelProperty(value = "充值时间起(含)")
    private LocalDateTime rechargeTimeStart;
    @ApiModelProperty(value = "充值时间止(含)")
    private LocalDateTime rechargeTimeEnd;
    @ApiModelProperty(value = "页码(从1开始)")
    private Integer current = 1;
    @ApiModelProperty(value = "每页大小")
    private Integer size = 10;
}
ruoyi-api/ruoyi-api-other/src/main/java/com/ruoyi/other/api/model/TCompanyRechargeRecord.java
@@ -54,6 +54,14 @@
    @TableField("arrival_amount")
    private BigDecimal arrivalAmount;
    @ApiModelProperty(value = "充值流水号")
    @TableField("serial_no")
    private String serialNo;
    @ApiModelProperty(value = "充值状态 1=成功 2=失败 3=已退款")
    @TableField("status")
    private Integer status;
    @ApiModelProperty(value = "优惠方式(1=不优惠 2=按比例优惠 3=按优惠单价收费)")
    @TableField("preferential_type")
    private Integer preferentialType;
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/controller/TCompanyRechargeController.java
@@ -1,22 +1,24 @@
package com.ruoyi.other.controller;
import com.ruoyi.common.core.utils.poi.ExcelUtil;
import com.ruoyi.common.core.web.domain.AjaxResult;
import com.ruoyi.common.core.web.page.PageInfo;
import com.ruoyi.common.log.annotation.Log;
import com.ruoyi.common.log.enums.BusinessType;
import com.ruoyi.common.security.annotation.Logical;
import com.ruoyi.common.security.annotation.RequiresPermissions;
import com.ruoyi.other.api.dto.CompanyRechargeRecordExcelDTO;
import com.ruoyi.other.api.dto.CompanyRechargeRecordQueryDTO;
import com.ruoyi.other.api.model.TCompanyRechargeRecord;
import com.ruoyi.other.service.TCompanyRechargeRecordService;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;
/**
 * <p>
@@ -34,9 +36,9 @@
    private TCompanyRechargeRecordService rechargeRecordService;
    /**
     * 公司充值
     * 公司充值(公司管理主页面「充值」按钮触发)
     */
    @RequiresPermissions(value = {"/companyManagement/list"}, logical = Logical.OR)
    @RequiresPermissions("/companyManagement/list")
    @PostMapping("/recharge")
    @ApiOperation(tags = {"管理后台-公司管理"}, value = "公司充值")
    @Log(title = "【公司管理】公司充值", businessType = BusinessType.INSERT)
@@ -45,14 +47,23 @@
    }
    /**
     * 充值记录分页
     * 充值记录分页(公司管理-充值记录)
     */
    @RequiresPermissions(value = {"/companyManagement/list"}, logical = Logical.OR)
    @GetMapping("/page")
    @RequiresPermissions("/company/rechargeRecord")
    @PostMapping("/page")
    @ApiOperation(tags = {"管理后台-公司管理"}, value = "公司充值记录分页")
    public AjaxResult<PageInfo<TCompanyRechargeRecord>> page(@RequestParam(value = "current", defaultValue = "1") Integer current,
                                                             @RequestParam(value = "size", defaultValue = "10") Integer size,
                                                             @RequestParam(value = "companyId", required = false) Integer companyId) {
        return AjaxResult.success(rechargeRecordService.pageList(current, size, companyId, null));
    public AjaxResult<PageInfo<TCompanyRechargeRecord>> page(@RequestBody CompanyRechargeRecordQueryDTO query) {
        return AjaxResult.success(rechargeRecordService.pageList(query));
    }
    /**
     * 充值记录导出(公司管理-充值记录)
     */
    @RequiresPermissions("/company/rechargeRecord/export")
    @PutMapping("/export")
    @ApiOperation(tags = {"管理后台-公司管理"}, value = "公司充值记录导出")
    public void export(HttpServletResponse response, @RequestBody(required = false) CompanyRechargeRecordQueryDTO query) {
        ExcelUtil<CompanyRechargeRecordExcelDTO> util = new ExcelUtil<>(CompanyRechargeRecordExcelDTO.class);
        util.exportExcel(response, rechargeRecordService.selectExportList(query), "公司充值记录");
    }
}
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/TCompanyRechargeRecordService.java
@@ -1,16 +1,17 @@
package com.ruoyi.other.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.other.api.model.TCompanyRechargeRecord;
import com.ruoyi.common.core.web.page.PageInfo;
import com.ruoyi.other.api.dto.CompanyRechargeRecordExcelDTO;
import com.ruoyi.other.api.dto.CompanyRechargeRecordQueryDTO;
import com.ruoyi.other.api.model.TCompanyRechargeRecord;
import java.util.List;
/**
 * <p>
 * 公司充值记录 服务类
 * </p>
 *
 * @author mxcd
 * @since 2026-09-23
 */
public interface TCompanyRechargeRecordService extends IService<TCompanyRechargeRecord> {
@@ -22,7 +23,12 @@
    Integer recharge(TCompanyRechargeRecord record);
    /**
     * 充值记录分页
     * 充值记录分页(支持流水号/公司/状态/时间范围,公司账号自动限定本公司)
     */
    PageInfo<TCompanyRechargeRecord> pageList(Integer current, Integer size, Integer companyId, String companyName);
    PageInfo<TCompanyRechargeRecord> pageList(CompanyRechargeRecordQueryDTO query);
    /**
     * 充值记录导出列表(复用相同查询条件,无分页)
     */
    List<CompanyRechargeRecordExcelDTO> selectExportList(CompanyRechargeRecordQueryDTO query);
}
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TCompanyRechargeRecordServiceImpl.java
@@ -4,33 +4,54 @@
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.web.page.PageInfo;
import com.ruoyi.common.security.utils.SecurityUtils;
import com.ruoyi.other.api.domain.TGroupCompany;
import com.ruoyi.other.api.dto.CompanyRechargeRecordExcelDTO;
import com.ruoyi.other.api.dto.CompanyRechargeRecordQueryDTO;
import com.ruoyi.other.api.model.TCompanyRechargeRecord;
import com.ruoyi.other.mapper.TCompanyRechargeRecordMapper;
import com.ruoyi.other.mapper.TGroupCompanyMapper;
import com.ruoyi.other.service.CompanyAccountAccessService;
import com.ruoyi.other.service.TCompanyRechargeRecordService;
import com.ruoyi.system.api.domain.SysUser;
import com.ruoyi.system.api.feignClient.SysUserClient;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;
/**
 * <p>
 * 公司充值记录 服务实现类
 * </p>
 *
 * @author mxcd
 * @since 2026-09-23
 */
@Slf4j
@Service
public class TCompanyRechargeRecordServiceImpl extends ServiceImpl<TCompanyRechargeRecordMapper, TCompanyRechargeRecord> implements TCompanyRechargeRecordService {
    private static final DateTimeFormatter SERIAL_DTF = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
    /** 充值状态枚举:1=成功 2=失败 3=已退款 */
    public static final int RECHARGE_STATUS_SUCCESS = 1;
    @Resource
    private TGroupCompanyMapper groupCompanyMapper;
    @Resource
    private CompanyAccountAccessService companyAccountAccessService;
    @Resource
    private SysUserClient sysUserClient;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -54,9 +75,12 @@
        record.setId(null);
        record.setDelFlag(false);
        record.setRechargeTime(LocalDateTime.now());
        record.setSerialNo(buildSerialNo());
        record.setStatus(RECHARGE_STATUS_SUCCESS);
        try {
            record.setOperatorId(SecurityUtils.getLoginUser().getUserid());
        } catch (Exception ignored) {
            // 非登录上下文(如内部调用)允许为空
        }
        // 更新公司余额(原子加):拼接 toString 防止 SQL 注入,BigDecimal 是受控数值
        groupCompanyMapper.update(null, Wrappers.lambdaUpdate(TGroupCompany.class)
@@ -67,16 +91,120 @@
    }
    @Override
    public PageInfo<TCompanyRechargeRecord> pageList(Integer current, Integer size, Integer companyId, String companyName) {
    public PageInfo<TCompanyRechargeRecord> pageList(CompanyRechargeRecordQueryDTO query) {
        int current = query.getCurrent() == null || query.getCurrent() < 1 ? 1 : query.getCurrent();
        int size = query.getSize() == null || query.getSize() < 1 ? 10 : query.getSize();
        Page<TCompanyRechargeRecord> page = new Page<>(current, size);
        LambdaQueryWrapper<TCompanyRechargeRecord> wrapper = Wrappers.lambdaQuery(TCompanyRechargeRecord.class)
                .eq(TCompanyRechargeRecord::getDelFlag, false)
                .eq(companyId != null, TCompanyRechargeRecord::getCompanyId, companyId)
                .orderByDesc(TCompanyRechargeRecord::getId);
        LambdaQueryWrapper<TCompanyRechargeRecord> wrapper = buildQueryWrapper(query);
        Page<TCompanyRechargeRecord> result = this.page(page, wrapper);
        fillNames(result.getRecords());
        PageInfo<TCompanyRechargeRecord> pageInfo = new PageInfo<>(result.getCurrent(), result.getSize());
        pageInfo.setRecords(result.getRecords());
        pageInfo.setTotal(result.getTotal());
        return pageInfo;
    }
    @Override
    public List<CompanyRechargeRecordExcelDTO> selectExportList(CompanyRechargeRecordQueryDTO query) {
        LambdaQueryWrapper<TCompanyRechargeRecord> wrapper = buildQueryWrapper(query);
        List<TCompanyRechargeRecord> records = this.list(wrapper);
        fillNames(records);
        return records.stream().map(this::toExcelDto).collect(Collectors.toList());
    }
    /**
     * 构建查询 wrapper:参数为 null 时不参与条件;delFlag=0;按 id 倒序
     */
    private LambdaQueryWrapper<TCompanyRechargeRecord> buildQueryWrapper(CompanyRechargeRecordQueryDTO query) {
        Integer companyId = companyAccountAccessService.resolveCompanyFilter(query.getCompanyId());
        return Wrappers.lambdaQuery(TCompanyRechargeRecord.class)
                .eq(TCompanyRechargeRecord::getDelFlag, false)
                .eq(companyId != null, TCompanyRechargeRecord::getCompanyId, companyId)
                .eq(query.getStatus() != null, TCompanyRechargeRecord::getStatus, query.getStatus())
                .eq(query.getSerialNo() != null && !query.getSerialNo().isEmpty(),
                        TCompanyRechargeRecord::getSerialNo, query.getSerialNo())
                .ge(query.getRechargeTimeStart() != null, TCompanyRechargeRecord::getRechargeTime, query.getRechargeTimeStart())
                .le(query.getRechargeTimeEnd() != null, TCompanyRechargeRecord::getRechargeTime, query.getRechargeTimeEnd())
                .orderByDesc(TCompanyRechargeRecord::getId);
    }
    /**
     * 列表结果批量回填公司名称与操作人名称:单次查表 + 按需远程调用 sys_user
     */
    private void fillNames(List<TCompanyRechargeRecord> records) {
        if (records == null || records.isEmpty()) {
            return;
        }
        Set<Integer> companyIds = records.stream()
                .map(TCompanyRechargeRecord::getCompanyId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        Map<Integer, String> companyNameMap = new HashMap<>();
        if (!companyIds.isEmpty()) {
            try {
                List<TGroupCompany> companies = groupCompanyMapper.selectBatchIds(companyIds);
                if (companies != null) {
                    for (TGroupCompany c : companies) {
                        companyNameMap.put(c.getId(), c.getName());
                    }
                }
            } catch (Exception e) {
                log.warn("批量查询公司名称失败:ids={}", companyIds, e);
            }
        }
        Set<Long> operatorIds = records.stream()
                .map(TCompanyRechargeRecord::getOperatorId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        Map<Long, String> operatorNameMap = new HashMap<>();
        for (Long id : operatorIds) {
            try {
                R<SysUser> result = sysUserClient.getSysUser(id);
                if (result != null && result.getData() != null
                        && result.getData().getUserName() != null
                        && !result.getData().getUserName().isEmpty()) {
                    operatorNameMap.put(id, result.getData().getUserName());
                }
            } catch (Exception e) {
                log.warn("查询操作人失败:id={}", id, e);
            }
        }
        for (TCompanyRechargeRecord r : records) {
            r.setCompanyName(companyNameMap.get(r.getCompanyId()));
            r.setOperatorName(operatorNameMap.get(r.getOperatorId()));
        }
    }
    private CompanyRechargeRecordExcelDTO toExcelDto(TCompanyRechargeRecord r) {
        CompanyRechargeRecordExcelDTO dto = new CompanyRechargeRecordExcelDTO();
        dto.setSerialNo(r.getSerialNo());
        dto.setCompanyName(r.getCompanyName());
        dto.setPayAmount(r.getPayAmount());
        dto.setGiftAmount(r.getGiftAmount());
        dto.setArrivalAmount(r.getArrivalAmount());
        dto.setStatus(r.getStatus());
        dto.setOperatorName(r.getOperatorName());
        dto.setRechargeTime(r.getRechargeTime());
        return dto;
    }
    /**
     * 生成充值流水号:CR + yyyyMMddHHmmss + 3 位随机数;唯一约束 uk_serial_no 兜底。
     * 同一秒内重复概率约 1/1000,重复时由 catch DuplicateKeyException 重试。
     */
    private String buildSerialNo() {
        String prefix = "CR" + LocalDateTime.now().format(SERIAL_DTF);
        for (int attempt = 0; attempt < 5; attempt++) {
            String suffix = String.format("%03d", ThreadLocalRandom.current().nextInt(0, 1000));
            String candidate = prefix + suffix;
            if (this.lambdaQuery()
                    .eq(TCompanyRechargeRecord::getSerialNo, candidate)
                    .eq(TCompanyRechargeRecord::getDelFlag, false)
                    .count() == 0) {
                return candidate;
            }
        }
        // 重试耗尽:使用基于当前纳秒的后缀,仍受唯一索引保护
        return prefix + String.format("%06d", System.nanoTime() % 1000000);
    }
}
ruoyi-service/ruoyi-other/src/test/java/com/ruoyi/other/service/impl/TCompanyRechargeRecordServiceImplTest.java
New file
@@ -0,0 +1,226 @@
package com.ruoyi.other.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.other.api.domain.TGroupCompany;
import com.ruoyi.other.api.dto.CompanyRechargeRecordExcelDTO;
import com.ruoyi.other.api.dto.CompanyRechargeRecordQueryDTO;
import com.ruoyi.other.api.model.TCompanyRechargeRecord;
import com.ruoyi.other.mapper.TCompanyRechargeRecordMapper;
import com.ruoyi.other.mapper.TGroupCompanyMapper;
import com.ruoyi.other.service.CompanyAccountAccessService;
import com.ruoyi.system.api.domain.SysUser;
import com.ruoyi.system.api.feignClient.SysUserClient;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
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.eq;
/**
 * 公司充值记录 服务实现类 单测:覆盖充值流水号生成、状态默认值、公司隔离、名称回填、Excel DTO 映射。
 */
class TCompanyRechargeRecordServiceImplTest {
    @BeforeAll
    static void initTableInfo() {
        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
                TCompanyRechargeRecord.class);
    }
    @Test
    void rechargeGeneratesSerialNoAndSetsStatusSuccess() {
        TGroupCompany company = new TGroupCompany();
        company.setId(100);
        company.setName("M公司");
        TGroupCompanyMapper groupCompanyMapper = Mockito.mock(TGroupCompanyMapper.class);
        Mockito.when(groupCompanyMapper.selectById(100)).thenReturn(company);
        TCompanyRechargeRecordMapper mapper = Mockito.mock(TCompanyRechargeRecordMapper.class);
        Mockito.when(mapper.insert(any())).thenAnswer(invocation -> {
            TCompanyRechargeRecord r = invocation.getArgument(0);
            r.setId(555);
            return 1;
        });
        CompanyAccountAccessService access = Mockito.mock(CompanyAccountAccessService.class);
        SysUserClient userClient = Mockito.mock(SysUserClient.class);
        TCompanyRechargeRecordServiceImpl service = new TCompanyRechargeRecordServiceImpl();
        ReflectionTestUtils.setField(service, "groupCompanyMapper", groupCompanyMapper);
        ReflectionTestUtils.setField(service, "companyAccountAccessService", access);
        ReflectionTestUtils.setField(service, "sysUserClient", userClient);
        ReflectionTestUtils.setField(service, "baseMapper", mapper);
        TCompanyRechargeRecord in = new TCompanyRechargeRecord();
        in.setCompanyId(100);
        in.setPayAmount(new BigDecimal("1000.00"));
        in.setGiftAmount(new BigDecimal("200.00"));
        Integer newId = service.recharge(in);
        assertEquals(555, newId);
        assertEquals(1, in.getStatus());
        assertNotNull(in.getSerialNo());
        assertTrue(in.getSerialNo().startsWith("CR"));
        assertEquals(LocalDateTime.now().toLocalDate(), in.getRechargeTime().toLocalDate());
        assertEquals(new BigDecimal("1200.00"), in.getArrivalAmount());
        // 验证公司余额原子加 setSql 触发
        Mockito.verify(groupCompanyMapper, Mockito.times(1))
                .update(eq(null), any());
    }
    @Test
    void rechargeFailsWhenCompanyNotExists() {
        TGroupCompanyMapper groupCompanyMapper = Mockito.mock(TGroupCompanyMapper.class);
        Mockito.when(groupCompanyMapper.selectById(999)).thenReturn(null);
        TCompanyRechargeRecordMapper mapper = Mockito.mock(TCompanyRechargeRecordMapper.class);
        CompanyAccountAccessService access = Mockito.mock(CompanyAccountAccessService.class);
        SysUserClient userClient = Mockito.mock(SysUserClient.class);
        TCompanyRechargeRecordServiceImpl service = new TCompanyRechargeRecordServiceImpl();
        ReflectionTestUtils.setField(service, "groupCompanyMapper", groupCompanyMapper);
        ReflectionTestUtils.setField(service, "companyAccountAccessService", access);
        ReflectionTestUtils.setField(service, "sysUserClient", userClient);
        ReflectionTestUtils.setField(service, "baseMapper", mapper);
        TCompanyRechargeRecord in = new TCompanyRechargeRecord();
        in.setCompanyId(999);
        in.setPayAmount(new BigDecimal("100"));
        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> service.recharge(in));
        assertTrue(ex.getMessage().contains("公司不存在"));
        Mockito.verifyNoInteractions(mapper);
    }
    @Test
    @SuppressWarnings({"rawtypes", "unchecked"})
    void pageListAppliesCompanyFilterAndFillsNames() {
        TCompanyRechargeRecord r1 = new TCompanyRechargeRecord();
        r1.setId(1);
        r1.setCompanyId(100);
        r1.setOperatorId(9001L);
        r1.setPayAmount(new BigDecimal("100"));
        TCompanyRechargeRecord r2 = new TCompanyRechargeRecord();
        r2.setId(2);
        r2.setCompanyId(200);
        r2.setOperatorId(9002L);
        r2.setPayAmount(new BigDecimal("50"));
        Page pageResult = new Page(1, 10);
        pageResult.setRecords(Arrays.asList(r1, r2));
        pageResult.setTotal(2L);
        TCompanyRechargeRecordMapper mapper = Mockito.mock(TCompanyRechargeRecordMapper.class);
        Mockito.when(mapper.selectPage(any(Page.class), any())).thenReturn(pageResult);
        TGroupCompanyMapper groupCompanyMapper = Mockito.mock(TGroupCompanyMapper.class);
        TGroupCompany c1 = new TGroupCompany();
        c1.setId(100);
        c1.setName("A公司");
        TGroupCompany c2 = new TGroupCompany();
        c2.setId(200);
        c2.setName("B公司");
        Mockito.when(groupCompanyMapper.selectBatchIds(any())).thenReturn(Arrays.asList(c1, c2));
        SysUserClient userClient = Mockito.mock(SysUserClient.class);
        SysUser u1 = new SysUser();
        u1.setUserId(9001L);
        u1.setUserName("张三");
        SysUser u2 = new SysUser();
        u2.setUserId(9002L);
        u2.setUserName("李四");
        Mockito.when(userClient.getSysUser(9001L)).thenReturn(R.ok(u1));
        Mockito.when(userClient.getSysUser(9002L)).thenReturn(R.ok(u2));
        CompanyAccountAccessService access = Mockito.mock(CompanyAccountAccessService.class);
        // 当前请求携带 companyId=null,且当前账号非公司账号 → resolveCompanyFilter 返回 null(不限)
        Mockito.when(access.resolveCompanyFilter(null)).thenReturn(null);
        TCompanyRechargeRecordServiceImpl service = new TCompanyRechargeRecordServiceImpl();
        ReflectionTestUtils.setField(service, "baseMapper", mapper);
        ReflectionTestUtils.setField(service, "groupCompanyMapper", groupCompanyMapper);
        ReflectionTestUtils.setField(service, "sysUserClient", userClient);
        ReflectionTestUtils.setField(service, "companyAccountAccessService", access);
        CompanyRechargeRecordQueryDTO query = new CompanyRechargeRecordQueryDTO();
        query.setCurrent(1);
        query.setSize(10);
        com.ruoyi.common.core.web.page.PageInfo<TCompanyRechargeRecord> page = service.pageList(query);
        assertEquals(2L, page.getTotal());
        assertEquals(2, page.getRecords().size());
        assertEquals("A公司", page.getRecords().get(0).getCompanyName());
        assertEquals("张三", page.getRecords().get(0).getOperatorName());
        assertEquals("B公司", page.getRecords().get(1).getCompanyName());
        assertEquals("李四", page.getRecords().get(1).getOperatorName());
    }
    @Test
    @SuppressWarnings("unchecked")
    void selectExportListMapsToExcelDto() {
        TCompanyRechargeRecord r1 = new TCompanyRechargeRecord();
        r1.setId(1);
        r1.setSerialNo("CR20260930101500001");
        r1.setCompanyId(100);
        r1.setPayAmount(new BigDecimal("100"));
        r1.setGiftAmount(new BigDecimal("20"));
        r1.setArrivalAmount(new BigDecimal("120"));
        r1.setStatus(1);
        r1.setOperatorId(9001L);
        r1.setCompanyName("A公司");
        r1.setOperatorName("张三");
        r1.setRechargeTime(LocalDateTime.of(2026, 9, 30, 10, 15, 0));
        TCompanyRechargeRecordMapper mapper = Mockito.mock(TCompanyRechargeRecordMapper.class);
        Mockito.when(mapper.selectList(any())).thenReturn(Collections.singletonList(r1));
        // fillNames 会重新覆盖 companyName/operatorName,必须 stub 远程查询
        TGroupCompanyMapper groupCompanyMapper = Mockito.mock(TGroupCompanyMapper.class);
        TGroupCompany c1 = new TGroupCompany();
        c1.setId(100);
        c1.setName("A公司");
        Mockito.when(groupCompanyMapper.selectBatchIds(any())).thenReturn(Collections.singletonList(c1));
        SysUserClient userClient = Mockito.mock(SysUserClient.class);
        SysUser u1 = new SysUser();
        u1.setUserId(9001L);
        u1.setUserName("张三");
        Mockito.when(userClient.getSysUser(9001L)).thenReturn(R.ok(u1));
        TCompanyRechargeRecordServiceImpl service = new TCompanyRechargeRecordServiceImpl();
        ReflectionTestUtils.setField(service, "baseMapper", mapper);
        ReflectionTestUtils.setField(service, "groupCompanyMapper", groupCompanyMapper);
        ReflectionTestUtils.setField(service, "sysUserClient", userClient);
        ReflectionTestUtils.setField(service, "companyAccountAccessService",
                Mockito.mock(CompanyAccountAccessService.class));
        CompanyRechargeRecordQueryDTO query = new CompanyRechargeRecordQueryDTO();
        query.setStatus(1);
        List<CompanyRechargeRecordExcelDTO> list = service.selectExportList(query);
        assertEquals(1, list.size());
        CompanyRechargeRecordExcelDTO dto = list.get(0);
        assertEquals("CR20260930101500001", dto.getSerialNo());
        assertEquals("A公司", dto.getCompanyName());
        assertEquals(new BigDecimal("120"), dto.getArrivalAmount());
        assertEquals(1, dto.getStatus());
        assertEquals("张三", dto.getOperatorName());
        assertNotEquals(null, dto.getRechargeTime());
    }
}
sql/20260930_company_recharge_record_columns.sql
New file
@@ -0,0 +1,51 @@
-- =============================================================================
-- 2026-09-30 公司充值记录表新增字段:serial_no(充值流水号)、status(充值状态)
-- -----------------------------------------------------------------------------
-- 前置说明:
--   1) Mycat 2.0 代理可广播 DDL,无需逐分表执行;ALTER 拆分为单条语句便于重复执行排错
--   2) status 历史回填为 1(成功);serial_no 历史回填为 CR + id 左补 0(10 位)
--   3) 新增唯一索引 uk_serial_no + 普通索引 idx_status
--   4) 状态枚举:1=成功 2=失败 3=已退款
-- =============================================================================
SET NAMES utf8mb4;
USE charging_pile_other;
-- -----------------------------------------------------------------------------
-- 小节 A: DDL
-- -----------------------------------------------------------------------------
ALTER TABLE t_company_recharge_record
    ADD COLUMN serial_no VARCHAR(32) DEFAULT NULL COMMENT '充值流水号' AFTER id;
ALTER TABLE t_company_recharge_record
    ADD COLUMN status TINYINT(1) NOT NULL DEFAULT 1 COMMENT '充值状态 1=成功 2=失败 3=已退款' AFTER arrival_amount;
ALTER TABLE t_company_recharge_record
    ADD UNIQUE KEY uk_serial_no (serial_no);
ALTER TABLE t_company_recharge_record
    ADD KEY idx_status (status);
-- -----------------------------------------------------------------------------
-- 小节 B: 历史数据回填
-- -----------------------------------------------------------------------------
UPDATE t_company_recharge_record
SET serial_no = CONCAT('CR', LPAD(id, 10, '0'))
WHERE serial_no IS NULL OR serial_no = '';
-- -----------------------------------------------------------------------------
-- 小节 C: 回查
--   C1. 列存在 + 注释
--     SELECT column_name, column_type, is_nullable, column_default, column_comment
--     FROM information_schema.columns
--     WHERE table_schema = 'charging_pile_other' AND table_name = 't_company_recharge_record'
--       AND column_name IN ('serial_no','status');
--   C2. 索引存在
--     SELECT index_name, non_unique, column_name
--     FROM information_schema.statistics
--     WHERE table_schema = 'charging_pile_other' AND table_name = 't_company_recharge_record';
--   C3. 历史回填全填(含 NULL 不允许)
--     SELECT COUNT(*) AS empty_serial FROM charging_pile_other.t_company_recharge_record
--     WHERE serial_no IS NULL OR serial_no = '';
--     期望:empty_serial=0
-- =============================================================================
sql/20260930_company_recharge_record_menu.sql
New file
@@ -0,0 +1,67 @@
-- =============================================================================
-- 2026-09-30 公司管理-充值记录二级菜单与导出按钮
-- -----------------------------------------------------------------------------
-- 前置说明:
--   1) 父菜单「单位管理」path='/company'(menu_id=51,测试库已建)
--   2) 全部语句幂等,可重复执行
--   3) 权限键与前端 accessKey、后端 @RequiresPermissions 一致:
--        C 列表页 path = /company/rechargeRecord
--        F 导出按钮   path = '#'  perms = /company/rechargeRecord/export
--   4) 默认授权 admin(role_id=1)和 company_admin(role_id=75)
--   5) 依赖前置脚本:20260930_company_admin_role_menu.sql(提供 company_admin 角色)
-- =============================================================================
SET NAMES utf8mb4;
USE charging_pile_other;
-- -----------------------------------------------------------------------------
-- 1. 充值记录(菜单 C)
-- -----------------------------------------------------------------------------
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, remark)
SELECT '充值记录', 51, 4, '/company/rechargeRecord', 'company/rechargeRecord/index', NULL, 1, 0, 'C', '0', '0', '/company/rechargeRecord', 'wallet', 'admin', NOW(), '公司管理-充值记录'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_type = 'C' AND perms = '/company/rechargeRecord');
-- -----------------------------------------------------------------------------
-- 2. 导出按钮(F)
-- -----------------------------------------------------------------------------
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, remark)
SELECT '导出', (
    SELECT menu_id FROM sys_menu WHERE menu_type = 'C' AND perms = '/company/rechargeRecord' LIMIT 1
), 1, '#', NULL, NULL, 1, 0, 'F', '0', '0', '/company/rechargeRecord/export', '#', 'admin', NOW(), '公司管理-充值记录-导出'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = '/company/rechargeRecord/export');
-- -----------------------------------------------------------------------------
-- 3. 默认授权 admin 角色(role_id=1)
-- -----------------------------------------------------------------------------
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT 1, m.menu_id
FROM sys_menu m
WHERE (m.perms = '/company/rechargeRecord' OR m.perms = '/company/rechargeRecord/export')
  AND NOT EXISTS (SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = 1 AND rm.menu_id = m.menu_id);
-- -----------------------------------------------------------------------------
-- 4. 默认授权 company_admin(role_id=75;该角色由 20260930_company_admin_role_menu.sql 创建)
-- -----------------------------------------------------------------------------
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT 75, m.menu_id
FROM sys_menu m
WHERE (m.perms = '/company/rechargeRecord' OR m.perms = '/company/rechargeRecord/export')
  AND NOT EXISTS (SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = 75 AND rm.menu_id = m.menu_id);
-- -----------------------------------------------------------------------------
-- 5. 回查
--   R1. C 菜单与 F 按钮已建:
--     SELECT menu_id, menu_name, path, perms, menu_type, order_num, parent_id
--     FROM charging_pile_other.sys_menu
--     WHERE perms IN ('/company/rechargeRecord','/company/rechargeRecord/export');
--   R2. 已授 admin(1) 与 company_admin(75):
--     SELECT rm.role_id, r.role_name, m.menu_name, m.perms
--     FROM charging_pile_other.sys_role_menu rm
--     JOIN charging_pile_other.sys_role r ON rm.role_id = r.role_id
--     JOIN charging_pile_other.sys_menu m ON rm.menu_id = m.menu_id
--     WHERE m.perms IN ('/company/rechargeRecord','/company/rechargeRecord/export')
--     ORDER BY rm.role_id, m.perms;
--   R3. 幂等复跑:以上 INSERT 命中 0 行(NOT EXISTS 已存在)
-- =============================================================================