yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/salary/HrmSalaryCalculationServiceImpl.java
@@ -252,17 +252,25 @@
        OvertimeSalary overtimeSalary = calculateOvertimeSalary(employee, employeeSalary, period, baseSalary);
        BigDecimal overtimePay = overtimeSalary.pay();
        // 按排班累计应出天数与排班总工时(无排班时应出天数回退默认 22)
        // 应出天数:优先取考勤汇总里的「应出勤天数」(客户按实际月份维护,如 30 天),
        // 没有汇总时退回排班累计天数,再退回默认 22
        ScheduleStat scheduleStat = calculateScheduleStat(employeeId, period);
        Integer workDays = scheduleStat.scheduledDays() > 0 ? scheduleStat.scheduledDays() : 22;
        BigDecimal totalWorkHours = scheduleStat.totalWorkHours();
        // 实出天数:取自考勤汇总(无汇总记录时按 0 处理)
        Integer actualDays = 0;
        // 实出天数:取自考勤汇总。
        // 注意:null 表示该月尚未生成考勤汇总,此时不做「缺勤扣款」(视为满勤,只按请假单扣),
        //       否则一重算就会把所有没录考勤的员工工资扣光。
        Integer actualDays = null;
        HrmAttendanceSummaryDO attendanceSummary =
                attendanceSummaryMapper.selectByUserIdAndYearMonth(employeeId, period);
        if (attendanceSummary != null && attendanceSummary.getActualDays() != null) {
            actualDays = attendanceSummary.getActualDays();
        if (attendanceSummary != null) {
            if (attendanceSummary.getActualDays() != null) {
                actualDays = attendanceSummary.getActualDays();
            }
            if (attendanceSummary.getWorkDays() != null && attendanceSummary.getWorkDays() > 0) {
                workDays = attendanceSummary.getWorkDays();
            }
        }
        // 全勤奖:与其他津贴一致,由人工在核算单中维护,新建时默认 0
@@ -307,7 +315,8 @@
        }
        // 计算请假扣款
        BigDecimal leaveDeduction = calculateLeaveDeduction(employee, period, baseSalary);
        // 计算缺勤扣款(请假 + 旷工)
        BigDecimal leaveDeduction = calculateAttendanceDeduction(employee, period, baseSalary, workDays, actualDays);
        // 计算个税扣除
        // 应纳税所得额 = 应发工资 - 社保扣除 - 公积金扣除 - 起征点(5000)
@@ -323,12 +332,10 @@
        BigDecimal otherDeduction = BigDecimal.ZERO;
        // 计算实发工资 = 应发 - 扣除
        BigDecimal totalDeduction = socialSecurityDeduction.add(housingFundDeduction)
                .add(taxDeduction).add(otherDeduction).add(leaveDeduction);
        BigDecimal actualSalary = totalIncome.subtract(totalDeduction);
        // 说明:实发工资统一走 calcActualSalary 计算(收入 - 扣除,且不允许为负),
        // 与「修改核算单 / 批量保存」保持同一口径,避免出现负数工资
        return HrmSalaryCalculationDO.builder()
        HrmSalaryCalculationDO calculation = HrmSalaryCalculationDO.builder()
                .period(period)
                .userId(employeeId) // 使用员工ID
                .deptId(employee.getDeptId())
@@ -346,65 +353,83 @@
                .taxDeduction(taxDeduction)
                .otherDeduction(otherDeduction)
                .leaveDeduction(leaveDeduction)
                .actualSalary(actualSalary)
                .workDays(workDays) // 应出天数:按排班累计,无排班时回退 22
                .workDays(workDays) // 应出天数:优先考勤汇总,其次排班累计,最后回退 22
                .actualDays(actualDays) // 实出天数:取自考勤汇总
                .overtimeHours(overtimeSalary.hours())
                .totalWorkHours(totalWorkHours) // 保留历史字段,业务已改用 actualDays
                .status(0) // 待确认
                .build();
        calculation.setActualSalary(calcActualSalary(calculation));
        return calculation;
    }
    /**
     * 计算请假扣款
     * 计算「缺勤扣款」= 请假扣款 + 旷工扣款
     *
     * <p>规则:
     * <ol>
     *     <li>日工资 = 基本工资 / 应出天数(与应出天数口径一致,避免缺勤天数超过计薪天数时扣超);</li>
     *     <li>请假天数按「请假扣款配置」的比例扣:调休等未配置的类型比例视为 0(不扣),病假按配置比例扣;</li>
     *     <li>若该月已生成考勤汇总(actualDays 不为 null):
     *         旷工天数 = 应出天数 − 实出天数 − 请假天数(不为负),按 100% 扣;</li>
     *     <li>若未生成考勤汇总(actualDays 为 null),视为满勤,不做旷工扣款,只按请假单扣;</li>
     *     <li>扣款总额封顶为基本工资,避免出现负工资。</li>
     * </ol>
     *
     * @param employee   员工信息
     * @param period     核算周期(如2026-02)
     * @param period     核算周期(如 2026-02)
     * @param baseSalary 基本工资
     * @return 请假扣款金额
     * @param workDays   应出天数(排班累计,无排班时传 22)
     * @param actualDays 实出天数(取自考勤汇总;null 表示该月未生成汇总)
     * @return 缺勤扣款金额
     */
    private BigDecimal calculateLeaveDeduction(HrmEmployeeDO employee, String period, BigDecimal baseSalary) {
        // 解析周期获取月份开始和结束时间
        YearMonth yearMonth = YearMonth.parse(period);
        LocalDateTime monthStart = yearMonth.atDay(1).atStartOfDay();
        LocalDateTime monthEnd = yearMonth.atEndOfMonth().atTime(23, 59, 59);
    private BigDecimal calculateAttendanceDeduction(HrmEmployeeDO employee, String period,
                                                    BigDecimal baseSalary, Integer workDays, Integer actualDays) {
        int shouldDays = (workDays != null && workDays > 0) ? workDays : 22;
        // 日工资 = 基本工资 / 应出天数(保留 4 位,最后统一保留 2 位)
        BigDecimal dailySalary = baseSalary.divide(new BigDecimal(shouldDays), 4, RoundingMode.HALF_UP);
        // 查询员工在该月已审批通过的请假记录
        // 注意:请假申请的 userId 是系统用户ID,需要通过员工表获取
        BigDecimal totalDeduction = BigDecimal.ZERO;
        BigDecimal leaveDays = BigDecimal.ZERO;
        // 1. 请假扣款:按请假类型的配置比例
        Long systemUserId = employee.getUserId();
        if (systemUserId == null) {
            return BigDecimal.ZERO;
        }
        List<HrmLeaveApplicationDO> leaveApplications = leaveApplicationMapper
                .selectApprovedListByUserIdAndTimeRange(systemUserId, monthStart, monthEnd);
        if (leaveApplications.isEmpty()) {
            return BigDecimal.ZERO;
        if (systemUserId != null) {
            YearMonth yearMonth = YearMonth.parse(period);
            LocalDateTime monthStart = yearMonth.atDay(1).atStartOfDay();
            LocalDateTime monthEnd = yearMonth.atEndOfMonth().atTime(23, 59, 59);
            List<HrmLeaveApplicationDO> leaveApplications = leaveApplicationMapper
                    .selectApprovedListByUserIdAndTimeRange(systemUserId, monthStart, monthEnd);
            if (CollUtil.isNotEmpty(leaveApplications)) {
                List<HrmLeaveTypeConfigDO> configList = leaveTypeConfigMapper.selectList();
                Map<Integer, BigDecimal> configMap = configList.stream()
                        .filter(c -> c.getStatus() != null && c.getStatus() == 0) // 只取启用的配置
                        .collect(Collectors.toMap(HrmLeaveTypeConfigDO::getLeaveType,
                                HrmLeaveTypeConfigDO::getDeductionRatio));
                for (HrmLeaveApplicationDO application : leaveApplications) {
                    BigDecimal duration = application.getDuration() != null
                            ? application.getDuration() : BigDecimal.ZERO;
                    // 未配置的类型(如调休)比例按 0 处理 = 不扣款
                    BigDecimal deductionRatio = configMap.getOrDefault(application.getLeaveType(), BigDecimal.ZERO);
                    totalDeduction = totalDeduction.add(
                            dailySalary.multiply(duration).multiply(deductionRatio));
                    leaveDays = leaveDays.add(duration);
                }
            }
        }
        // 获取所有请假类型配置
        List<HrmLeaveTypeConfigDO> configList = leaveTypeConfigMapper.selectList();
        Map<Integer, BigDecimal> configMap = configList.stream()
                .filter(c -> c.getStatus() == 0) // 只取启用的配置
                .collect(Collectors.toMap(HrmLeaveTypeConfigDO::getLeaveType, HrmLeaveTypeConfigDO::getDeductionRatio));
        // 计算日工资(基本工资 / 21.75,按国家规定的工作日计算)
        BigDecimal dailySalary = baseSalary.divide(new BigDecimal("21.75"), 2, RoundingMode.HALF_UP);
        // 累计请假扣款
        BigDecimal totalLeaveDeduction = BigDecimal.ZERO;
        for (HrmLeaveApplicationDO application : leaveApplications) {
            Integer leaveType = application.getLeaveType();
            BigDecimal duration = application.getDuration() != null ? application.getDuration() : BigDecimal.ZERO;
            BigDecimal deductionRatio = configMap.getOrDefault(leaveType, BigDecimal.ZERO);
            // 请假扣款 = 日工资 × 请假天数 × 扣款比例
            BigDecimal deduction = dailySalary.multiply(duration).multiply(deductionRatio)
                    .setScale(2, RoundingMode.HALF_UP);
            totalLeaveDeduction = totalLeaveDeduction.add(deduction);
        // 2. 旷工扣款:缺勤天数中未被请假单覆盖的部分,按 100% 扣(仅当有考勤汇总时)
        if (actualDays != null) {
            BigDecimal absentDays = new BigDecimal(shouldDays - actualDays).subtract(leaveDays);
            if (absentDays.signum() > 0) {
                totalDeduction = totalDeduction.add(dailySalary.multiply(absentDays));
            }
        }
        return totalLeaveDeduction;
        // 3. 封顶:扣款不超过基本工资
        BigDecimal result = totalDeduction.setScale(2, RoundingMode.HALF_UP);
        BigDecimal cap = baseSalary.setScale(2, RoundingMode.HALF_UP);
        return result.compareTo(cap) > 0 ? cap : result;
    }
    /**
@@ -677,6 +702,9 @@
    @Transactional(rollbackFor = Exception.class)
    public void saveSalaryCalculationList(List<HrmSalaryCalculationDO> calculationList) {
        for (HrmSalaryCalculationDO calculation : calculationList) {
            // 缺勤扣款(请假 + 旷工)与实发工资统一由后端重算,
            // 保证列表里改「应出天数 / 实出天数」后金额跟着变
            recalcAttendanceDeduction(calculation);
            if (calculation.getId() == null) {
                // 新增记录,生成核算单号
                String no = noRedisDAO.generateSalaryCalculationNo();
@@ -728,10 +756,33 @@
        calculation.setHousingFundDeduction(nvl(calculation.getHousingFundDeduction()));
        calculation.setTaxDeduction(nvl(calculation.getTaxDeduction()));
        calculation.setOtherDeduction(nvl(calculation.getOtherDeduction()));
        calculation.setLeaveDeduction(nvl(calculation.getLeaveDeduction()));
        // 实发工资由后端重算,避免前端传入值与明细不一致
        calculation.setActualSalary(calcActualSalary(calculation));
        // 缺勤扣款(请假 + 旷工)与实发工资统一由后端重算,
        // 保证列表里改「应出天数 / 实出天数」后金额跟着变,且不受前端传入的 leaveDeduction 影响
        recalcAttendanceDeduction(calculation);
        salaryCalculationMapper.updateById(calculation);
    }
    /**
     * 重算「缺勤扣款」与「实发工资」
     *
     * <p>核算单 userId 存的是员工ID(hrm_employee.id)。按「应出天数 / 实出天数 + 请假单」重算
     * 缺勤扣款(请假按配置比例扣、旷工按 100% 扣、封顶基本工资),再重算实发工资(不可为负)。
     * 查不到员工或核算周期时,保留原缺勤扣款,只重算实发。
     */
    private void recalcAttendanceDeduction(HrmSalaryCalculationDO calculation) {
        HrmEmployeeDO employee = calculation.getUserId() != null
                ? employeeMapper.selectById(calculation.getUserId()) : null;
        if (employee != null && calculation.getPeriod() != null) {
            BigDecimal deduction = calculateAttendanceDeduction(
                    employee, calculation.getPeriod(),
                    nvl(calculation.getBaseSalary()),
                    calculation.getWorkDays(),
                    calculation.getActualDays());
            calculation.setLeaveDeduction(deduction);
        } else {
            calculation.setLeaveDeduction(nvl(calculation.getLeaveDeduction()));
        }
        calculation.setActualSalary(calcActualSalary(calculation));
    }
    /**
@@ -739,6 +790,9 @@
     *
     * 收入项必须与 buildSalaryCalculation 中 totalIncome 的口径完全一致,
     * 否则会出现「新建核算算得对、修改一次核算单实发就变少」的不一致。
     *
     * 实发工资不允许为负:缺勤扣款等已封顶为基本工资,但社保/公积金/个税叠加后仍可能为负,
     * 业务上按「本月无工资」处理(置 0),避免出现员工倒欠公司的情况。
     */
    private static BigDecimal calcActualSalary(HrmSalaryCalculationDO c) {
        BigDecimal income = nvl(c.getBaseSalary())
@@ -755,7 +809,8 @@
                .add(nvl(c.getTaxDeduction()))
                .add(nvl(c.getOtherDeduction()))
                .add(nvl(c.getLeaveDeduction()));
        return income.subtract(deduction).setScale(2, RoundingMode.HALF_UP);
        BigDecimal actual = income.subtract(deduction).setScale(2, RoundingMode.HALF_UP);
        return actual.signum() < 0 ? BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP) : actual;
    }
    private static BigDecimal nvl(BigDecimal v) {