tuqin
2026-11-03 155319d1e4f8e61f5f3b60a783be035f03b47c66
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/salary/HrmSalaryCalculationServiceImpl.java
@@ -32,6 +32,9 @@
import cn.iocoder.yudao.module.hrm.dal.mysql.salary.HrmSalaryStructureMapper;
import cn.iocoder.yudao.module.hrm.dal.redis.HrmNoRedisDAO;
import cn.iocoder.yudao.module.system.api.dept.DeptApi;
import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi;
import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO;
import lombok.extern.slf4j.Slf4j;
import cn.iocoder.yudao.module.hrm.enums.HrmOvertimeTypeEnum;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
@@ -44,6 +47,7 @@
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -60,6 +64,7 @@
 */
@Service
@Validated
@Slf4j
public class HrmSalaryCalculationServiceImpl implements HrmSalaryCalculationService {
    /**
@@ -90,6 +95,8 @@
    @Resource
    private DeptApi deptApi;
    @Resource
    private NotifyMessageSendApi notifyMessageSendApi;
    @Resource
    private HrmDeductionService deductionService;
    @Resource
    private HrmLeaveApplicationMapper leaveApplicationMapper;
@@ -111,6 +118,27 @@
    @Override
    public PageResult<HrmSalaryCalculationDO> getSalaryCalculationPage(HrmSalaryCalculationPageReqVO pageReqVO) {
        return salaryCalculationMapper.selectPage(pageReqVO);
    }
    @Override
    public PageResult<HrmSalaryCalculationDO> getMySalaryCalculationPage(Long userId, HrmSalaryCalculationPageReqVO pageReqVO) {
        // 强制限定为当前登录人:忽略前端传入的 userId / deptId,防止越权查看他人工资
        pageReqVO.setUserId(userId);
        pageReqVO.setDeptId(null);
        return salaryCalculationMapper.selectPage(pageReqVO);
    }
    @Override
    public HrmSalaryCalculationDO getMySalaryCalculation(Long id, Long userId) {
        HrmSalaryCalculationDO calculation = salaryCalculationMapper.selectById(id);
        if (calculation == null) {
            return null;
        }
        // 不属于本人的记录一律视为不存在,避免通过猜 id 越权查看他人工资
        if (!java.util.Objects.equals(calculation.getUserId(), userId)) {
            return null;
        }
        return calculation;
    }
    @Override
@@ -141,10 +169,17 @@
        // 2. 遍历员工进行薪酬核算
        for (HrmEmployeeDO employee : employees) {
            // employeeId 是员工ID,用于关联薪酬档案、社保档案、核算记录
            // employeeId 是员工档案主键(hrm_employee.id),仅用于关联排班
            // userId 是系统账号 ID(system_users.id),核算记录 / 薪酬档案 / 社保档案
            // 全部按它关联 —— 员工登录后查「我的薪资」用的也是这个 ID
            Long employeeId = employee.getId();
            // 2.1 检查该员工当月是否已存在核算记录(按员工ID查询)
            List<HrmSalaryCalculationDO> existCalculations = salaryCalculationMapper.selectListByUserIdAndPeriod(employeeId, period);
            Long userId = employee.getUserId();
            if (userId == null) {
                log.warn("[calculateSalary] 员工({}) 未关联系统账号,跳过核算", employee.getName());
                continue;
            }
            // 2.1 检查该员工当月是否已存在核算记录(按系统账号 ID 查询)
            List<HrmSalaryCalculationDO> existCalculations = salaryCalculationMapper.selectListByUserIdAndPeriod(userId, period);
            if (CollUtil.isNotEmpty(existCalculations)) {
                // 如果存在多条记录,提示异常数据
                if (existCalculations.size() > 1) {
@@ -156,15 +191,15 @@
                }
            }
            // 2.2 获取员工薪酬档案(按员工ID查询)
            HrmEmployeeSalaryDO employeeSalary = employeeSalaryMapper.selectLatestByUserId(employeeId);
            // 2.2 获取员工薪酬档案(按系统账号 ID 查询)
            HrmEmployeeSalaryDO employeeSalary = employeeSalaryMapper.selectLatestByUserId(userId);
            if (employeeSalary == null) {
                // 如果没有薪酬档案,使用员工表中的基本工资创建默认数据
                employeeSalary = buildDefaultEmployeeSalary(employeeId, employee);
                employeeSalary = buildDefaultEmployeeSalary(userId, employee);
            }
            // 2.3 获取员工社保公积金档案(按员工ID查询,取最新生效的)
            HrmEmployeeSocialSecurityDO socialSecurity = employeeSocialSecurityMapper.selectLatestByUserId(employeeId);
            // 2.3 获取员工社保公积金档案(按系统账号 ID 查询,取最新生效的)
            HrmEmployeeSocialSecurityDO socialSecurity = employeeSocialSecurityMapper.selectLatestByUserId(userId);
            // 2.4 如果有社保档案,获取绑定的方案
            HrmSocialSecuritySchemeDO scheme = null;
            if (socialSecurity != null && socialSecurity.getSocialSecuritySchemeId() != null) {
@@ -172,7 +207,7 @@
            }
            // 2.5 构建核算记录(包含扣除计算,通过方案获取比例)
            HrmSalaryCalculationDO calculation = buildSalaryCalculation(period, employeeId, employee, employeeSalary, socialSecurity, scheme, effectiveDate);
            HrmSalaryCalculationDO calculation = buildSalaryCalculation(period, userId, employee, employeeSalary, socialSecurity, scheme, effectiveDate);
            // 2.6 保存:首次生成单号;已有待确认记录则保留单号并覆盖重算结果
            if (CollUtil.isNotEmpty(existCalculations)) {
@@ -203,11 +238,13 @@
    /**
     * 构建默认的员工薪酬档案。基本工资统一取员工档案,绩效工资默认不自动生成。
     *
     * @param userId 系统账号 ID(system_users.id),不是 hrm_employee.id
     */
    private HrmEmployeeSalaryDO buildDefaultEmployeeSalary(Long employeeId, HrmEmployeeDO employee) {
    private HrmEmployeeSalaryDO buildDefaultEmployeeSalary(Long userId, HrmEmployeeDO employee) {
        BigDecimal baseSalary = employee.getBaseSalary() != null ? employee.getBaseSalary() : BigDecimal.ZERO;
        return HrmEmployeeSalaryDO.builder()
                .userId(employeeId) // 使用员工ID
                .userId(userId)
                .salaryStructureId(employee.getSalaryStructureId())
                .baseSalary(baseSalary)
                .performanceSalary(BigDecimal.ZERO)
@@ -221,8 +258,10 @@
    /**
     * 构建薪酬核算记录(包含社保、公积金、个税、请假扣除计算)
     * 通过员工社保档案绑定的方案获取缴纳比例
     *
     * @param userId 系统账号 ID(system_users.id),写入核算记录的 user_id
     */
    private HrmSalaryCalculationDO buildSalaryCalculation(String period, Long employeeId, HrmEmployeeDO employee,
    private HrmSalaryCalculationDO buildSalaryCalculation(String period, Long userId, HrmEmployeeDO employee,
                                                          HrmEmployeeSalaryDO employeeSalary,
                                                          HrmEmployeeSocialSecurityDO socialSecurity,
                                                          HrmSocialSecuritySchemeDO scheme,
@@ -237,7 +276,8 @@
        BigDecimal overtimePay = overtimeSalary.pay();
        // 按排班累计出勤天数与排班总工时(无排班时出勤天数回退默认 22)
        ScheduleStat scheduleStat = calculateScheduleStat(employeeId, period);
        // 注意:排班表按员工档案 ID(hrm_employee.id)关联,与核算记录的 userId 不同
        ScheduleStat scheduleStat = calculateScheduleStat(employee.getId(), period);
        Integer workDays = scheduleStat.scheduledDays() > 0 ? scheduleStat.scheduledDays() : 22;
        BigDecimal totalWorkHours = scheduleStat.totalWorkHours();
@@ -297,7 +337,7 @@
        return HrmSalaryCalculationDO.builder()
                .period(period)
                .userId(employeeId) // 使用员工ID
                .userId(userId)
                .deptId(employee.getDeptId())
                .baseSalary(baseSalary)
                .performanceSalary(performanceSalary)
@@ -470,6 +510,8 @@
            salaryCalculationMapper.updateById(salaryCalculation);
            // 记录周期
            period = salaryCalculation.getPeriod();
            // 确认后给该员工发送工资条通知(失败不影响确认主流程)
            sendPayslipNotice(salaryCalculation);
        }
        // 检查该周期是否所有核算记录都已确认,自动生成发放台账
@@ -481,6 +523,39 @@
    /**
     * 检查该周期是否所有核算记录都已确认,如果全部确认则自动生成发放台账
     */
    /**
     * 工资条通知的站内信模板编码
     *
     * 对应 system_notify_template.code = salary_payslip_notice
     */
    private static final String TEMPLATE_CODE_PAYSLIP = "salary_payslip_notice";
    /**
     * 给员工发送工资条通知(APP 站内信)
     *
     * 通知失败只记日志,不影响核算确认主流程
     */
    private void sendPayslipNotice(HrmSalaryCalculationDO calculation) {
        if (calculation == null || calculation.getUserId() == null) {
            return;
        }
        try {
            NotifySendSingleToUserReqDTO reqDTO = new NotifySendSingleToUserReqDTO();
            reqDTO.setUserId(calculation.getUserId());
            reqDTO.setTemplateCode(TEMPLATE_CODE_PAYSLIP);
            Map<String, Object> params = new HashMap<>();
            params.put("period", calculation.getPeriod());
            params.put("actualSalary", calculation.getActualSalary());
            reqDTO.setTemplateParams(params);
            notifyMessageSendApi.sendSingleMessageToAdmin(reqDTO);
            log.info("[sendPayslipNotice] 工资条通知已发送,userId={}, period={}",
                    calculation.getUserId(), calculation.getPeriod());
        } catch (Exception e) {
            log.warn("[sendPayslipNotice] 工资条通知发送失败,userId={}, period={}",
                    calculation.getUserId(), calculation.getPeriod(), e);
        }
    }
    private void autoGeneratePaymentIfAllConfirmed(String period) {
        // 查询该周期所有核算记录
        List<HrmSalaryCalculationDO> allCalculations = salaryCalculationMapper.selectListByPeriod(period);
@@ -532,17 +607,23 @@
        // 2. 遍历员工实时计算薪酬
        List<HrmSalaryCalculationDO> resultList = new java.util.ArrayList<>();
        for (HrmEmployeeDO employee : employees) {
            Long employeeId = employee.getId();
            // 2.1 获取员工薪酬档案(按员工ID查询)
            HrmEmployeeSalaryDO employeeSalary = employeeSalaryMapper.selectLatestByUserId(employeeId);
            if (employeeSalary == null) {
                // 如果没有薪酬档案,使用员工表中的基本工资创建默认数据
                employeeSalary = buildDefaultEmployeeSalary(employeeId, employee);
            // userId 是系统账号 ID(system_users.id),薪酬档案 / 社保档案按它关联;
            // 排班表则按 hrm_employee.id 关联,两者不同
            Long userId = employee.getUserId();
            if (userId == null) {
                log.warn("[previewSalaryCalculation] 员工({}) 未关联系统账号,跳过", employee.getName());
                continue;
            }
            // 2.2 获取员工社保公积金档案(按员工ID查询,取最新生效的)
            HrmEmployeeSocialSecurityDO socialSecurity = employeeSocialSecurityMapper.selectLatestByUserId(employeeId);
            // 2.1 获取员工薪酬档案(按系统账号 ID 查询)
            HrmEmployeeSalaryDO employeeSalary = employeeSalaryMapper.selectLatestByUserId(userId);
            if (employeeSalary == null) {
                // 如果没有薪酬档案,使用员工表中的基本工资创建默认数据
                employeeSalary = buildDefaultEmployeeSalary(userId, employee);
            }
            // 2.2 获取员工社保公积金档案(按系统账号 ID 查询,取最新生效的)
            HrmEmployeeSocialSecurityDO socialSecurity = employeeSocialSecurityMapper.selectLatestByUserId(userId);
            // 2.3 如果有社保档案,获取绑定的方案
            HrmSocialSecuritySchemeDO scheme = null;
            if (socialSecurity != null && socialSecurity.getSocialSecuritySchemeId() != null) {
@@ -550,7 +631,7 @@
            }
            // 2.4 构建核算记录(实时计算,不存储)
            HrmSalaryCalculationDO calculation = buildSalaryCalculation(period, employeeId, employee, employeeSalary, socialSecurity, scheme, effectiveDate);
            HrmSalaryCalculationDO calculation = buildSalaryCalculation(period, userId, employee, employeeSalary, socialSecurity, scheme, effectiveDate);
            resultList.add(calculation);
        }
        return resultList;
@@ -561,7 +642,24 @@
    public void saveSalaryCalculationList(List<HrmSalaryCalculationDO> calculationList) {
        for (HrmSalaryCalculationDO calculation : calculationList) {
            if (calculation.getId() == null) {
                // 新增记录,生成核算单号
                // 新增前先查重:同一员工(系统账号 ID)+ 同一周期 只允许存在一条记录。
                // 前端「预览数据 → 保存核算」走的就是这里,不查重会重复插入。
                List<HrmSalaryCalculationDO> existList = salaryCalculationMapper
                        .selectListByUserIdAndPeriod(calculation.getUserId(), calculation.getPeriod());
                if (CollUtil.isNotEmpty(existList)) {
                    HrmSalaryCalculationDO existing = existList.get(0);
                    // 已确认/已发放的记录不允许被覆盖,直接跳过
                    if (existing.getStatus() != null && existing.getStatus() != 0) {
                        continue;
                    }
                    // 待确认的记录:覆盖重算结果,保留原单号
                    calculation.setId(existing.getId());
                    calculation.setNo(existing.getNo());
                    calculation.setStatus(0);
                    salaryCalculationMapper.updateById(calculation);
                    continue;
                }
                // 确实是新增,生成核算单号
                String no = noRedisDAO.generateSalaryCalculationNo();
                while (salaryCalculationMapper.selectByNo(no) != null) {
                    no = noRedisDAO.generateSalaryCalculationNo();