gaoluyang
2026-11-04 f81b963f9d1b7d4757446970830cb72264c6dc0c
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/salary/HrmSalaryCalculationServiceImpl.java
@@ -5,6 +5,10 @@
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.hrm.controller.admin.salary.vo.HrmSalaryCalculationPageReqVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.approval.HrmLeaveApplicationDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.approval.HrmOvertimeApplicationDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceRecordDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmEmployeeScheduleDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmShiftDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.salary.HrmEmployeeSalaryDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.salary.HrmEmployeeSocialSecurityDO;
@@ -13,7 +17,12 @@
import cn.iocoder.yudao.module.hrm.dal.dataobject.salary.HrmSalaryPaymentDetailDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.salary.HrmSalaryPaymentDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.salary.HrmSocialSecuritySchemeDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.salary.HrmSalaryStructureDO;
import cn.iocoder.yudao.module.hrm.dal.mysql.approval.HrmLeaveApplicationMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.attendance.HrmEmployeeScheduleMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.attendance.HrmShiftMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.approval.HrmOvertimeApplicationMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.attendance.HrmAttendanceRecordMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.salary.HrmEmployeeSalaryMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.salary.HrmEmployeeSocialSecurityMapper;
@@ -22,8 +31,11 @@
import cn.iocoder.yudao.module.hrm.dal.mysql.salary.HrmSalaryPaymentDetailMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.salary.HrmSalaryPaymentMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.salary.HrmSocialSecuritySchemeMapper;
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.hrm.enums.HrmAttendanceClockTypeEnum;
import cn.iocoder.yudao.module.hrm.enums.HrmOvertimeTypeEnum;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -34,8 +46,12 @@
import java.time.LocalDate;
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;
import java.util.Set;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
@@ -44,7 +60,7 @@
/**
 * 酬核算 Service 实现类
 *
 * @author 芋道源码
 * @author 超级管理员
 */
@Service
@Validated
@@ -74,6 +90,8 @@
    @Resource
    private HrmSocialSecuritySchemeMapper socialSecuritySchemeMapper;
    @Resource
    private HrmSocialSecuritySchemeService socialSecuritySchemeService;
    @Resource
    private HrmNoRedisDAO noRedisDAO;
    @Resource
    private DeptApi deptApi;
@@ -82,11 +100,21 @@
    @Resource
    private HrmLeaveApplicationMapper leaveApplicationMapper;
    @Resource
    private HrmOvertimeApplicationMapper overtimeApplicationMapper;
    @Resource
    private HrmSalaryStructureMapper salaryStructureMapper;
    @Resource
    private HrmLeaveTypeConfigMapper leaveTypeConfigMapper;
    @Resource
    private HrmSalaryPaymentMapper salaryPaymentMapper;
    @Resource
    private HrmSalaryPaymentDetailMapper salaryPaymentDetailMapper;
    @Resource
    private HrmEmployeeScheduleMapper employeeScheduleMapper;
    @Resource
    private HrmShiftMapper shiftMapper;
    @Resource
    private HrmAttendanceRecordMapper attendanceRecordMapper;
    @Override
    public PageResult<HrmSalaryCalculationDO> getSalaryCalculationPage(HrmSalaryCalculationPageReqVO pageReqVO) {
@@ -120,6 +148,8 @@
                .toList();
        // 2. 遍历员工进行薪酬核算
        // 部门 -> 方案 的解析结果在本次核算内缓存,避免逐员工重复查库
        Map<Long, HrmSocialSecuritySchemeDO> deptSchemeCache = new HashMap<>();
        for (HrmEmployeeDO employee : employees) {
            // employeeId 是员工ID,用于关联薪酬档案、社保档案、核算记录
            Long employeeId = employee.getId();
@@ -130,7 +160,10 @@
                if (existCalculations.size() > 1) {
                    throw exception(SALARY_CALCULATION_DUPLICATE, employee.getName(), period);
                }
                continue; // 已存在则跳过
                // 已确认或已发放的记录不可自动改写;待确认记录允许重算,以吸收后来审批通过的加班。
                if (existCalculations.get(0).getStatus() != 0) {
                    continue;
                }
            }
            // 2.2 获取员工薪酬档案(按员工ID查询)
@@ -142,24 +175,26 @@
            // 2.3 获取员工社保公积金档案(按员工ID查询,取最新生效的)
            HrmEmployeeSocialSecurityDO socialSecurity = employeeSocialSecurityMapper.selectLatestByUserId(employeeId);
            // 2.4 如果有社保档案,获取绑定的方案
            HrmSocialSecuritySchemeDO scheme = null;
            if (socialSecurity != null && socialSecurity.getSocialSecuritySchemeId() != null) {
                scheme = socialSecuritySchemeMapper.selectById(socialSecurity.getSocialSecuritySchemeId());
            }
            // 2.4 获取方案:员工档案指定 > 所在部门绑定 > 全局默认
            HrmSocialSecuritySchemeDO scheme = resolveScheme(socialSecurity, employee, effectiveDate, deptSchemeCache);
            // 2.5 构建核算记录(包含扣除计算,通过方案获取比例)
            HrmSalaryCalculationDO calculation = buildSalaryCalculation(period, employeeId, employee, employeeSalary, socialSecurity, scheme, effectiveDate);
            // 2.6 生成核算单号
            String no = noRedisDAO.generateSalaryCalculationNo();
            while (salaryCalculationMapper.selectByNo(no) != null) {
                no = noRedisDAO.generateSalaryCalculationNo();
            // 2.6 保存:首次生成单号;已有待确认记录则保留单号并覆盖重算结果
            if (CollUtil.isNotEmpty(existCalculations)) {
                HrmSalaryCalculationDO existing = existCalculations.get(0);
                calculation.setId(existing.getId());
                calculation.setNo(existing.getNo());
                salaryCalculationMapper.updateById(calculation);
            } else {
                String no = noRedisDAO.generateSalaryCalculationNo();
                while (salaryCalculationMapper.selectByNo(no) != null) {
                    no = noRedisDAO.generateSalaryCalculationNo();
                }
                calculation.setNo(no);
                salaryCalculationMapper.insert(calculation);
            }
            calculation.setNo(no);
            // 2.7 保存核算记录
            salaryCalculationMapper.insert(calculation);
        }
    }
@@ -174,13 +209,14 @@
    }
    /**
     * 构建默认的员工薪酬档案(基于员工表的基本工资)
     * 构建默认的员工薪酬档案。基本工资统一取员工档案,绩效工资默认不自动生成。
     */
    private HrmEmployeeSalaryDO buildDefaultEmployeeSalary(Long employeeId, HrmEmployeeDO employee) {
        BigDecimal baseSalary = employee.getBaseSalary() != null ? employee.getBaseSalary() : BigDecimal.ZERO;
        return HrmEmployeeSalaryDO.builder()
                .userId(employeeId) // 使用员工ID
                .salaryStructureId(employee.getSalaryStructureId())
                .baseSalary(employee.getBaseSalary() != null ? employee.getBaseSalary() : BigDecimal.ZERO)
                .baseSalary(baseSalary)
                .performanceSalary(BigDecimal.ZERO)
                .mealAllowance(BigDecimal.ZERO)
                .transportAllowance(BigDecimal.ZERO)
@@ -204,19 +240,82 @@
        BigDecimal mealAllowance = employeeSalary.getMealAllowance() != null ? employeeSalary.getMealAllowance() : BigDecimal.ZERO;
        BigDecimal transportAllowance = employeeSalary.getTransportAllowance() != null ? employeeSalary.getTransportAllowance() : BigDecimal.ZERO;
        BigDecimal otherAllowance = employeeSalary.getOtherAllowance() != null ? employeeSalary.getOtherAllowance() : BigDecimal.ZERO;
        BigDecimal overtimePay = BigDecimal.ZERO;
        // 应发工资总额
        BigDecimal totalIncome = baseSalary.add(performanceSalary).add(mealAllowance)
                .add(transportAllowance).add(otherAllowance).add(overtimePay);
        // ========== 九泓工资表:标准项取自员工档案 ==========
        BigDecimal standardSalary = nvl(employee.getStandardSalary());
        BigDecimal postSalary = nvl(employee.getPostSalary());
        BigDecimal overtimeHourlyRate = employee.getOvertimeHourlyRate();
        BigDecimal safetyAwardStandard = nvl(employee.getSafetyAwardStandard());
        BigDecimal performanceStandard = nvl(employee.getPerformanceStandard());
        // 标准工资未单独维护时,按「基本工资 + 岗位工资」合成
        if (standardSalary.signum() == 0 && (baseSalary.signum() > 0 || postSalary.signum() > 0)) {
            standardSalary = baseSalary.add(postSalary);
        }
        // 获取社保基数(员工档案中的个性化基数)
        // 按排班累计应出勤天数与标准工时(无排班时回退 22 天 × 8 小时)
        ScheduleStat scheduleStat = calculateScheduleStat(employeeId, period);
        Integer workDays = scheduleStat.scheduledDays() > 0 ? scheduleStat.scheduledDays() : 22;
        BigDecimal totalWorkHours = scheduleStat.totalWorkHours();
        BigDecimal standardWorkHours = scheduleStat.scheduledDays() > 0
                ? scheduleStat.totalWorkHours()
                : BigDecimal.valueOf(workDays).multiply(BigDecimal.valueOf(8));
        // 计薪出勤 = 标准出勤天数 - 缺勤天数(考勤记录中缺卡的天数,按天去重)
        int absentDays = countAbsentDays(employeeId, period);
        BigDecimal paidAttendanceDays = BigDecimal.valueOf(Math.max(workDays - absentDays, 0));
        // 加班工资 / 法定假工资(加班工资标准为空时回退到 基本工资 / 21.75 / 8 × 倍率)
        OvertimeSalary overtimeSalary = calculateOvertimeSalary(employee, employeeSalary, period, baseSalary, overtimeHourlyRate);
        BigDecimal overtimeHours = overtimeSalary.hours();
        BigDecimal overtimePay = overtimeSalary.pay();
        BigDecimal legalHolidayHours = overtimeSalary.legalHolidayHours();
        BigDecimal legalHolidayPay = overtimeSalary.legalHolidayPay();
        // 出勤折算比例,用于按实际计薪出勤折算标准项
        BigDecimal attendanceRatio = workDays > 0
                ? paidAttendanceDays.divide(BigDecimal.valueOf(workDays), 6, RoundingMode.HALF_UP)
                : BigDecimal.ZERO;
        BigDecimal standardAttendancePay = standardSalary.multiply(attendanceRatio).setScale(2, RoundingMode.HALF_UP);
        BigDecimal safetyAward = safetyAwardStandard.multiply(attendanceRatio).setScale(2, RoundingMode.HALF_UP);
        BigDecimal floatingPerformancePay = performanceStandard.multiply(attendanceRatio).setScale(2, RoundingMode.HALF_UP);
        // 应付工资 = 标准出勤工资 + 加班工资 + 法定假工资 + 安全环保奖 + 浮动绩效工资
        BigDecimal payableSalary = standardAttendancePay.add(overtimePay).add(legalHolidayPay)
                .add(safetyAward).add(floatingPerformancePay);
        // 加项(工龄工资/夜班补助/学历补贴/体检/优秀员工奖/其他补助/部门绩效)与扣项
        // (漏打卡/迟到早退/其他扣款)生成时默认为 0,核算页可手工调整后保存
        BigDecimal seniorityPay = BigDecimal.ZERO;
        BigDecimal nightShiftAllowance = BigDecimal.ZERO;
        BigDecimal educationAllowance = BigDecimal.ZERO;
        BigDecimal healthCheckAllowance = BigDecimal.ZERO;
        BigDecimal excellentEmployeeAward = BigDecimal.ZERO;
        BigDecimal otherSubsidy = BigDecimal.ZERO;
        BigDecimal deptPerformance = BigDecimal.ZERO;
        BigDecimal additionTotal = BigDecimal.ZERO;
        BigDecimal missedPunchDeduction = BigDecimal.ZERO;
        BigDecimal lateEarlyDeduction = BigDecimal.ZERO;
        BigDecimal deductionTotal = BigDecimal.ZERO;
        // 计税口径:应付工资 + 加项合计 - 扣款合计
        BigDecimal totalIncome = payableSalary.add(additionTotal).subtract(deductionTotal);
        // 获取社保基数:员工社保档案 > 方案默认缴费基数 > 员工基本工资
        BigDecimal socialSecurityBase = (socialSecurity != null && socialSecurity.getSocialSecurityBase() != null)
                ? socialSecurity.getSocialSecurityBase() : baseSalary;
                ? socialSecurity.getSocialSecurityBase()
                : (scheme != null && scheme.getDefaultSocialSecurityBase() != null
                        ? scheme.getDefaultSocialSecurityBase() : baseSalary);
        // 获取公积金基数(员工档案中的个性化基数)
        BigDecimal housingFundBase = (socialSecurity != null && socialSecurity.getHousingFundBase() != null)
                ? socialSecurity.getHousingFundBase() : baseSalary;
        // 获取公积金基数:员工社保档案 > 方案默认公积金基数(大于 0 时)> 社保基数
        BigDecimal housingFundBase;
        if (socialSecurity != null && socialSecurity.getHousingFundBase() != null) {
            housingFundBase = socialSecurity.getHousingFundBase();
        } else if (scheme != null && scheme.getDefaultHousingFundBase() != null
                && scheme.getDefaultHousingFundBase().compareTo(BigDecimal.ZERO) > 0) {
            housingFundBase = scheme.getDefaultHousingFundBase();
        } else {
            housingFundBase = socialSecurityBase;
        }
        // 从方案中获取缴纳比例(优先使用方案中的比例,否则使用默认比例)
        BigDecimal pensionPersonalRatio = (scheme != null && scheme.getPensionPersonalRatio() != null)
@@ -238,8 +337,8 @@
        // 公积金扣除 = 基数 × 公积金个人比例
        BigDecimal housingFundDeduction = housingFundBase.multiply(housingFundPersonalRatio).setScale(2, RoundingMode.HALF_UP);
        // 计算请假扣款
        BigDecimal leaveDeduction = calculateLeaveDeduction(employee, period, baseSalary);
        // 请假扣款:新版工资表按「计薪出勤」折减标准出勤工资,不再单独重复扣一次,保留字段兼容旧数据
        BigDecimal leaveDeduction = BigDecimal.ZERO;
        // 计算个税扣除
        // 应纳税所得额 = 应发工资 - 社保扣除 - 公积金扣除 - 起征点(5000)
@@ -255,15 +354,43 @@
        BigDecimal otherDeduction = BigDecimal.ZERO;
        // 计算实发工资 = 应发 - 扣除
        BigDecimal totalDeduction = socialSecurityDeduction.add(housingFundDeduction)
                .add(taxDeduction).add(otherDeduction).add(leaveDeduction);
        BigDecimal actualSalary = totalIncome.subtract(totalDeduction);
        // 税后工资 = 应付工资 + 加项合计 - 扣款合计 - 社保 - 公积金 - 个税
        BigDecimal actualSalary = payableSalary.add(additionTotal).subtract(deductionTotal)
                .subtract(socialSecurityDeduction).subtract(housingFundDeduction)
                .subtract(taxDeduction);
        return HrmSalaryCalculationDO.builder()
                .period(period)
                .userId(employeeId) // 使用员工ID
                .deptId(employee.getDeptId())
                // 九泓工资表:标准项
                .standardSalary(standardSalary)
                .postSalary(postSalary)
                .overtimeHourlyRate(overtimeHourlyRate)
                .safetyAwardStandard(safetyAwardStandard)
                .performanceStandard(performanceStandard)
                // 九泓工资表:出勤与计薪
                .paidAttendanceDays(paidAttendanceDays)
                .standardWorkHours(standardWorkHours)
                .legalHolidayHours(legalHolidayHours)
                .standardAttendancePay(standardAttendancePay)
                .legalHolidayPay(legalHolidayPay)
                .safetyAward(safetyAward)
                .floatingPerformancePay(floatingPerformancePay)
                .payableSalary(payableSalary)
                // 九泓工资表:加项 / 扣项
                .seniorityPay(seniorityPay)
                .nightShiftAllowance(nightShiftAllowance)
                .educationAllowance(educationAllowance)
                .healthCheckAllowance(healthCheckAllowance)
                .excellentEmployeeAward(excellentEmployeeAward)
                .otherSubsidy(otherSubsidy)
                .deptPerformance(deptPerformance)
                .additionTotal(additionTotal)
                .missedPunchDeduction(missedPunchDeduction)
                .lateEarlyDeduction(lateEarlyDeduction)
                .deductionTotal(deductionTotal)
                // 兼容旧字段
                .baseSalary(baseSalary)
                .performanceSalary(performanceSalary)
                .overtimePay(overtimePay)
@@ -276,10 +403,35 @@
                .otherDeduction(otherDeduction)
                .leaveDeduction(leaveDeduction)
                .actualSalary(actualSalary)
                .workDays(22) // 默认工作日
                .overtimeHours(BigDecimal.ZERO)
                .workDays(workDays) // 按排班累计应出勤天数,无排班时回退 22
                .overtimeHours(overtimeHours)
                .totalWorkHours(totalWorkHours)
                .status(0) // 待确认
                .build();
    }
    /**
     * 解析员工核算用的社保公积金方案
     *
     * 优先级:员工社保档案指定的方案(个别覆盖)> 员工所在部门绑定的方案 > 全局默认方案。
     * 都取不到时返回 null,由 {@link #buildSalaryCalculation} 使用默认比例常量。
     *
     * @param deptSchemeCache 本次核算内「部门 -> 方案」的缓存(允许 null 值,用于缓存"该部门无方案")
     */
    private HrmSocialSecuritySchemeDO resolveScheme(HrmEmployeeSocialSecurityDO socialSecurity, HrmEmployeeDO employee,
                                                    LocalDate periodDate,
                                                    Map<Long, HrmSocialSecuritySchemeDO> deptSchemeCache) {
        if (socialSecurity != null && socialSecurity.getSocialSecuritySchemeId() != null) {
            HrmSocialSecuritySchemeDO scheme = socialSecuritySchemeMapper.selectById(socialSecurity.getSocialSecuritySchemeId());
            if (scheme != null) {
                return scheme;
            }
        }
        Long deptId = employee.getDeptId();
        if (!deptSchemeCache.containsKey(deptId)) {
            deptSchemeCache.put(deptId, socialSecuritySchemeService.resolveSchemeByDeptId(deptId, periodDate));
        }
        return deptSchemeCache.get(deptId);
    }
    /**
@@ -332,6 +484,123 @@
        }
        return totalLeaveDeduction;
    }
    /**
     * 汇总当月审批通过的加班申请。
     *
     * 员工档案维护了「加班工资标准(元/小时)」时:加班工资 = 加班时长 × 单价,
     * 法定假工资 = 法定假日时长 × 单价 × 法定节假日倍率(默认 3)。
     * 未维护单价时回退旧口径:基本工资 / 21.75 / 8 × 各类型倍率。
     *
     * 法定节假日加班单独统计,不计入「加班时长」列,避免与「法定假工资」重复计薪。
     */
    private OvertimeSalary calculateOvertimeSalary(HrmEmployeeDO employee, HrmEmployeeSalaryDO employeeSalary,
                                                    String period, BigDecimal baseSalary, BigDecimal overtimeHourlyRate) {
        if (employee.getUserId() == null) {
            return OvertimeSalary.EMPTY;
        }
        YearMonth month = YearMonth.parse(period);
        List<HrmOvertimeApplicationDO> applications = overtimeApplicationMapper.selectApprovedByUserAndDateRange(
                employee.getUserId(), month.atDay(1), month.atEndOfMonth());
        if (CollUtil.isEmpty(applications)) {
            return OvertimeSalary.EMPTY;
        }
        Long structureId = employeeSalary.getSalaryStructureId() != null
                ? employeeSalary.getSalaryStructureId() : employee.getSalaryStructureId();
        HrmSalaryStructureDO structure = structureId == null ? null : salaryStructureMapper.selectById(structureId);
        BigDecimal workdayMultiplier = structure != null && structure.getOvertimePayRatio() != null
                ? structure.getOvertimePayRatio() : HrmOvertimeTypeEnum.WORKDAY.getSalaryMultiplier();
        BigDecimal restDayMultiplier = structure != null && structure.getRestDayOvertimePayRatio() != null
                ? structure.getRestDayOvertimePayRatio() : HrmOvertimeTypeEnum.REST_DAY.getSalaryMultiplier();
        BigDecimal legalHolidayMultiplier = structure != null && structure.getLegalHolidayOvertimePayRatio() != null
                ? structure.getLegalHolidayOvertimePayRatio() : HrmOvertimeTypeEnum.LEGAL_HOLIDAY.getSalaryMultiplier();
        BigDecimal hourlySalary = overtimeHourlyRate != null && overtimeHourlyRate.signum() > 0
                ? overtimeHourlyRate
                : baseSalary.divide(new BigDecimal("21.75"), 8, RoundingMode.HALF_UP)
                        .divide(new BigDecimal("8"), 8, RoundingMode.HALF_UP);
        BigDecimal totalHours = BigDecimal.ZERO;
        BigDecimal totalPay = BigDecimal.ZERO;
        BigDecimal legalHolidayHours = BigDecimal.ZERO;
        BigDecimal legalHolidayPay = BigDecimal.ZERO;
        for (HrmOvertimeApplicationDO application : applications) {
            BigDecimal hours = application.getDuration() == null ? BigDecimal.ZERO : application.getDuration();
            HrmOvertimeTypeEnum type = HrmOvertimeTypeEnum.valueOfType(application.getOvertimeType());
            if (type == null || hours.signum() <= 0) {
                continue;
            }
            if (type == HrmOvertimeTypeEnum.LEGAL_HOLIDAY) {
                legalHolidayHours = legalHolidayHours.add(hours);
                legalHolidayPay = legalHolidayPay.add(hourlySalary.multiply(hours).multiply(legalHolidayMultiplier));
                continue;
            }
            BigDecimal multiplier = type == HrmOvertimeTypeEnum.WORKDAY ? workdayMultiplier : restDayMultiplier;
            totalHours = totalHours.add(hours);
            totalPay = totalPay.add(hourlySalary.multiply(hours).multiply(multiplier));
        }
        return new OvertimeSalary(totalHours.setScale(1, RoundingMode.HALF_UP),
                totalPay.setScale(2, RoundingMode.HALF_UP),
                legalHolidayHours.setScale(1, RoundingMode.HALF_UP),
                legalHolidayPay.setScale(2, RoundingMode.HALF_UP));
    }
    /**
     * 统计当月考勤记录中缺卡的天数(同一日期多次缺卡只算一天)
     */
    private int countAbsentDays(Long employeeId, String period) {
        YearMonth month = YearMonth.parse(period);
        List<HrmAttendanceRecordDO> records = attendanceRecordMapper.selectListByUserIdAndDateBetween(
                employeeId, month.atDay(1), month.atEndOfMonth());
        if (CollUtil.isEmpty(records)) {
            return 0;
        }
        return (int) records.stream()
                .filter(record -> HrmAttendanceClockTypeEnum.MISSING.getType().equals(record.getClockInType())
                        || HrmAttendanceClockTypeEnum.MISSING.getType().equals(record.getClockOutType()))
                .map(HrmAttendanceRecordDO::getDate)
                .distinct()
                .count();
    }
    private static BigDecimal nvl(BigDecimal value) {
        return value == null ? BigDecimal.ZERO : value;
    }
    private record OvertimeSalary(BigDecimal hours, BigDecimal pay,
                                  BigDecimal legalHolidayHours, BigDecimal legalHolidayPay) {
        private static final OvertimeSalary EMPTY = new OvertimeSalary(
                BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO);
    }
    private record ScheduleStat(int scheduledDays, BigDecimal totalWorkHours) {}
    /**
     * 按排班表累计员工的出勤天数与排班总工时。
     * 仅统计有班次的排班记录,班次标准工时之和即为排班工时。
     */
    private ScheduleStat calculateScheduleStat(Long employeeId, String period) {
        YearMonth yearMonth = YearMonth.parse(period);
        List<HrmEmployeeScheduleDO> schedules = employeeScheduleMapper.selectListByEmployeeIdAndPeriod(
                employeeId, yearMonth.atDay(1), yearMonth.atEndOfMonth());
        if (CollUtil.isEmpty(schedules)) {
            return new ScheduleStat(0, BigDecimal.ZERO);
        }
        Set<Long> shiftIds = schedules.stream().map(HrmEmployeeScheduleDO::getShiftId)
                .filter(Objects::nonNull).collect(Collectors.toSet());
        Map<Long, HrmShiftDO> shiftMap = CollUtil.isEmpty(shiftIds) ? Collections.emptyMap()
                : shiftMapper.selectBatchIds(shiftIds).stream()
                        .collect(Collectors.toMap(HrmShiftDO::getId, shift -> shift, (a, b) -> a));
        int scheduledDays = 0;
        BigDecimal totalWorkHours = BigDecimal.ZERO;
        for (HrmEmployeeScheduleDO schedule : schedules) {
            HrmShiftDO shift = schedule.getShiftId() == null ? null : shiftMap.get(schedule.getShiftId());
            if (shift == null) {
                continue;
            }
            scheduledDays++;
            totalWorkHours = totalWorkHours.add(shift.getWorkHours() != null ? shift.getWorkHours() : BigDecimal.ZERO);
        }
        return new ScheduleStat(scheduledDays, totalWorkHours.setScale(2, RoundingMode.HALF_UP));
    }
    @Override
@@ -416,6 +685,8 @@
        // 2. 遍历员工实时计算薪酬
        List<HrmSalaryCalculationDO> resultList = new java.util.ArrayList<>();
        // 部门 -> 方案 的解析结果在本次预览内缓存,避免逐员工重复查库
        Map<Long, HrmSocialSecuritySchemeDO> deptSchemeCache = new HashMap<>();
        for (HrmEmployeeDO employee : employees) {
            Long employeeId = employee.getId();
@@ -428,11 +699,8 @@
            // 2.2 获取员工社保公积金档案(按员工ID查询,取最新生效的)
            HrmEmployeeSocialSecurityDO socialSecurity = employeeSocialSecurityMapper.selectLatestByUserId(employeeId);
            // 2.3 如果有社保档案,获取绑定的方案
            HrmSocialSecuritySchemeDO scheme = null;
            if (socialSecurity != null && socialSecurity.getSocialSecuritySchemeId() != null) {
                scheme = socialSecuritySchemeMapper.selectById(socialSecurity.getSocialSecuritySchemeId());
            }
            // 2.3 获取方案:员工档案指定 > 所在部门绑定 > 全局默认
            HrmSocialSecuritySchemeDO scheme = resolveScheme(socialSecurity, employee, effectiveDate, deptSchemeCache);
            // 2.4 构建核算记录(实时计算,不存储)
            HrmSalaryCalculationDO calculation = buildSalaryCalculation(period, employeeId, employee, employeeSalary, socialSecurity, scheme, effectiveDate);
@@ -445,6 +713,7 @@
    @Transactional(rollbackFor = Exception.class)
    public void saveSalaryCalculationList(List<HrmSalaryCalculationDO> calculationList) {
        for (HrmSalaryCalculationDO calculation : calculationList) {
            normalizeSalaryTotals(calculation);
            if (calculation.getId() == null) {
                // 新增记录,生成核算单号
                String no = noRedisDAO.generateSalaryCalculationNo();
@@ -459,6 +728,50 @@
                salaryCalculationMapper.updateById(calculation);
            }
        }
    }
    /**
     * 保存前按九泓工资表口径重算合计,避免前端漏算或旧客户端传入不一致的合计值。
     *
     * 加项合计 = 工龄工资 + 夜班补助 + 学历补贴 + 体检 + 优秀员工奖 + 其他补助 + 部门绩效
     * 扣款合计 = 漏打卡 + 迟到早退 + 其他扣款
     * 税后工资 = 应付工资 + 加项合计 - 扣款合计 - 社保 - 公积金 - 个税
     *
     * 仅当本次记录带有「应付工资」时重算;只有旧字段的老数据保持原样,避免把未维护的新字段清成 0。
     */
    private void normalizeSalaryTotals(HrmSalaryCalculationDO calculation) {
        if (calculation.getPayableSalary() == null) {
            return;
        }
        // 应付工资 = 标准出勤工资 + 加班工资 + 法定假工资 + 安全环保奖 + 浮动绩效工资
        // 仅当本次记录带新版标准项时重算,避免旧数据(无这些列)被清零
        if (calculation.getStandardAttendancePay() != null) {
            calculation.setPayableSalary(nvl(calculation.getStandardAttendancePay())
                    .add(nvl(calculation.getOvertimePay()))
                    .add(nvl(calculation.getLegalHolidayPay()))
                    .add(nvl(calculation.getSafetyAward()))
                    .add(nvl(calculation.getFloatingPerformancePay()))
                    .setScale(2, RoundingMode.HALF_UP));
        }
        BigDecimal additionTotal = nvl(calculation.getSeniorityPay())
                .add(nvl(calculation.getNightShiftAllowance()))
                .add(nvl(calculation.getEducationAllowance()))
                .add(nvl(calculation.getHealthCheckAllowance()))
                .add(nvl(calculation.getExcellentEmployeeAward()))
                .add(nvl(calculation.getOtherSubsidy()))
                .add(nvl(calculation.getDeptPerformance()));
        BigDecimal deductionTotal = nvl(calculation.getMissedPunchDeduction())
                .add(nvl(calculation.getLateEarlyDeduction()))
                .add(nvl(calculation.getOtherDeduction()));
        calculation.setAdditionTotal(additionTotal.setScale(2, RoundingMode.HALF_UP));
        calculation.setDeductionTotal(deductionTotal.setScale(2, RoundingMode.HALF_UP));
        calculation.setActualSalary(calculation.getPayableSalary()
                .add(calculation.getAdditionTotal())
                .subtract(calculation.getDeductionTotal())
                .subtract(nvl(calculation.getSocialSecurityDeduction()))
                .subtract(nvl(calculation.getHousingFundDeduction()))
                .subtract(nvl(calculation.getTaxDeduction()))
                .setScale(2, RoundingMode.HALF_UP));
    }
    /**
@@ -577,4 +890,4 @@
        salaryCalculationMapper.deleteById(id);
    }
}
}