yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/salary/HrmSalaryCalculationServiceImpl.java
@@ -6,6 +6,9 @@
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.HrmAttendanceSummaryDO;
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;
@@ -16,6 +19,9 @@
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.HrmAttendanceSummaryMapper;
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.employee.HrmEmployeeMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.salary.HrmEmployeeSalaryMapper;
@@ -28,6 +34,11 @@
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.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import cn.iocoder.yudao.module.hrm.dal.dataobject.salary.HrmOvertimeWageConfigDO;
import cn.iocoder.yudao.module.hrm.dal.mysql.salary.HrmOvertimeWageConfigMapper;
import cn.iocoder.yudao.module.hrm.enums.HrmEmployeeCategoryEnum;
import cn.iocoder.yudao.module.hrm.enums.HrmOvertimeTypeEnum;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
@@ -39,8 +50,11 @@
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.util.Collections;
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;
@@ -91,11 +105,19 @@
    @Resource
    private HrmSalaryStructureMapper salaryStructureMapper;
    @Resource
    private HrmOvertimeWageConfigMapper overtimeWageConfigMapper;
    @Resource
    private HrmLeaveTypeConfigMapper leaveTypeConfigMapper;
    @Resource
    private HrmSalaryPaymentMapper salaryPaymentMapper;
    @Resource
    private HrmSalaryPaymentDetailMapper salaryPaymentDetailMapper;
    @Resource
    private HrmEmployeeScheduleMapper employeeScheduleMapper;
    @Resource
    private HrmShiftMapper shiftMapper;
    @Resource
    private HrmAttendanceSummaryMapper attendanceSummaryMapper;
    @Override
    public PageResult<HrmSalaryCalculationDO> getSalaryCalculationPage(HrmSalaryCalculationPageReqVO pageReqVO) {
@@ -217,35 +239,63 @@
                                                          HrmSocialSecuritySchemeDO scheme,
                                                          LocalDate effectiveDate) {
        // 计算应发薪资
        BigDecimal baseSalary = employeeSalary.getBaseSalary() != null ? employeeSalary.getBaseSalary() : BigDecimal.ZERO;
        // 基本工资统一取「员工信息」中维护的基本薪资(薪酬档案里的 baseSalary 不再作为核算来源,
        // 避免员工信息与薪酬档案不一致时,核算结果与员工档案对不上)
        BigDecimal baseSalary = employee.getBaseSalary() != null ? employee.getBaseSalary() : BigDecimal.ZERO;
        BigDecimal performanceSalary = employeeSalary.getPerformanceSalary() != null ? employeeSalary.getPerformanceSalary() : BigDecimal.ZERO;
        // 岗位工资 / 工龄工资:与基本工资同源,取员工信息中维护的金额
        BigDecimal postSalary = employee.getPostSalary() != null ? employee.getPostSalary() : BigDecimal.ZERO;
        BigDecimal senioritySalary = employee.getSenioritySalary() != null ? employee.getSenioritySalary() : BigDecimal.ZERO;
        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;
        OvertimeSalary overtimeSalary = calculateOvertimeSalary(employee, employeeSalary, period, baseSalary);
        BigDecimal overtimePay = overtimeSalary.pay();
        // 应发工资总额
        BigDecimal totalIncome = baseSalary.add(performanceSalary).add(mealAllowance)
                .add(transportAllowance).add(otherAllowance).add(overtimePay);
        // 应出天数:优先取考勤汇总里的「应出勤天数」(客户按实际月份维护,如 30 天),
        // 没有汇总时退回排班累计天数,再退回默认 22
        ScheduleStat scheduleStat = calculateScheduleStat(employeeId, period);
        Integer workDays = scheduleStat.scheduledDays() > 0 ? scheduleStat.scheduledDays() : 22;
        BigDecimal totalWorkHours = scheduleStat.totalWorkHours();
        // 实出天数:取自考勤汇总。
        // 注意:null 表示该月尚未生成考勤汇总,此时不做「缺勤扣款」(视为满勤,只按请假单扣),
        //       否则一重算就会把所有没录考勤的员工工资扣光。
        Integer actualDays = null;
        HrmAttendanceSummaryDO attendanceSummary =
                attendanceSummaryMapper.selectByUserIdAndYearMonth(employeeId, period);
        if (attendanceSummary != null) {
            if (attendanceSummary.getActualDays() != null) {
                actualDays = attendanceSummary.getActualDays();
            }
            if (attendanceSummary.getWorkDays() != null && attendanceSummary.getWorkDays() > 0) {
                workDays = attendanceSummary.getWorkDays();
            }
        }
        // 全勤奖:与其他津贴一致,由人工在核算单中维护,新建时默认 0
        BigDecimal fullAttendanceBonus = BigDecimal.ZERO;
        // 应发工资总额(收入项口径必须与 calcActualSalary 保持一致)
        BigDecimal totalIncome = baseSalary.add(performanceSalary).add(postSalary).add(senioritySalary)
                .add(fullAttendanceBonus)
                .add(mealAllowance).add(transportAllowance).add(otherAllowance).add(overtimePay);
        // 获取社保基数:员工社保档案 > 员工基本工资
        BigDecimal socialSecurityBase = (socialSecurity != null && socialSecurity.getSocialSecurityBase() != null)
                ? socialSecurity.getSocialSecurityBase() : baseSalary;
        // 获取公积金基数:员工社保档案 > 员工基本工资
        BigDecimal housingFundBase = (socialSecurity != null && socialSecurity.getHousingFundBase() != null)
                ? socialSecurity.getHousingFundBase() : baseSalary;
        // 从方案中获取缴纳比例(优先使用方案中的比例,否则使用默认比例)
        // 从方案中获取社保缴纳比例(方案未配置时回退默认比例)
        BigDecimal pensionPersonalRatio = (scheme != null && scheme.getPensionPersonalRatio() != null)
                ? scheme.getPensionPersonalRatio() : DEFAULT_PENSION_PERSONAL_RATIO;
        BigDecimal medicalPersonalRatio = (scheme != null && scheme.getMedicalPersonalRatio() != null)
                ? scheme.getMedicalPersonalRatio() : DEFAULT_MEDICAL_PERSONAL_RATIO;
        BigDecimal unemploymentPersonalRatio = (scheme != null && scheme.getUnemploymentPersonalRatio() != null)
                ? scheme.getUnemploymentPersonalRatio() : DEFAULT_UNEMPLOYMENT_PERSONAL_RATIO;
        BigDecimal housingFundPersonalRatio = (scheme != null && scheme.getHousingFundPersonalRatio() != null)
                ? scheme.getHousingFundPersonalRatio() : DEFAULT_HOUSING_FUND_PERSONAL_RATIO;
        // 公积金个人比例:只取方案配置值,不再回退默认比例。
        // 否则「公司没配公积金方案」时会被按默认 12% 扣款(这就是客户反馈的问题)。
        BigDecimal housingFundPersonalRatio = scheme != null ? scheme.getHousingFundPersonalRatio() : null;
        // 社保扣除 = 基数 × (养老个人比例 + 医疗个人比例 + 失业个人比例)
        BigDecimal socialSecurityDeduction = socialSecurityBase
@@ -254,11 +304,19 @@
                .add(socialSecurityBase.multiply(unemploymentPersonalRatio))
                .setScale(2, RoundingMode.HALF_UP);
        // 公积金扣除 = 基数 × 公积金个人比例
        BigDecimal housingFundDeduction = housingFundBase.multiply(housingFundPersonalRatio).setScale(2, RoundingMode.HALF_UP);
        // 公积金扣除:必须同时满足「方案配置了公积金比例 > 0」且「员工档案配置了公积金基数 > 0」才扣款,
        // 任一不满足都视为公司未缴纳公积金,扣款为 0。
        BigDecimal housingFundDeduction = BigDecimal.ZERO;
        BigDecimal housingFundBase = socialSecurity != null ? socialSecurity.getHousingFundBase() : null;
        if (housingFundPersonalRatio != null && housingFundPersonalRatio.signum() > 0
                && housingFundBase != null && housingFundBase.signum() > 0) {
            housingFundDeduction = housingFundBase.multiply(housingFundPersonalRatio)
                    .setScale(2, RoundingMode.HALF_UP);
        }
        // 计算请假扣款
        BigDecimal leaveDeduction = calculateLeaveDeduction(employee, period, baseSalary);
        // 计算缺勤扣款(请假 + 旷工)
        BigDecimal leaveDeduction = calculateAttendanceDeduction(employee, period, baseSalary, workDays, actualDays);
        // 计算个税扣除
        // 应纳税所得额 = 应发工资 - 社保扣除 - 公积金扣除 - 起征点(5000)
@@ -274,88 +332,117 @@
        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())
                .baseSalary(baseSalary)
                .performanceSalary(performanceSalary)
                .postSalary(postSalary)
                .senioritySalary(senioritySalary)
                .overtimePay(overtimePay)
                .mealAllowance(mealAllowance)
                .transportAllowance(transportAllowance)
                .otherAllowance(otherAllowance)
                .fullAttendanceBonus(fullAttendanceBonus)
                .socialSecurityDeduction(socialSecurityDeduction)
                .housingFundDeduction(housingFundDeduction)
                .taxDeduction(taxDeduction)
                .otherDeduction(otherDeduction)
                .leaveDeduction(leaveDeduction)
                .actualSalary(actualSalary)
                .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;
    }
    /**
     * 汇总当月审批通过的加班申请。小时工资按基本工资 / 21.75 / 8 计算;
     * 工作日默认 1.5 倍、休息日默认 2 倍、法定节假日默认 3 倍,均可由薪酬结构覆盖。
     * 汇总当月审批通过的加班申请,计算加班工资与加班时长。
     *
     * <p>按「员工类别」差异化核算:
     * <ul>
     *     <li>技工:加班薪资按 1:1 核算,与平时时薪一致(基本工资 / 21.75 / 8);</li>
     *     <li>普通员工:按岗位配置的固定加班时薪(默认 20 元/小时)核算;</li>
     *     <li>管理:不计加班工资;</li>
     * </ul>
     * 命中岗位配置时,按配置的最大加班时长上限对加班时长封顶;
     * 未命中任何岗位配置且非技工/管理时,回退旧逻辑(薪酬结构倍率 / 加班类型默认倍率)。
     */
    private OvertimeSalary calculateOvertimeSalary(HrmEmployeeDO employee, HrmEmployeeSalaryDO employeeSalary,
                                                    String period, BigDecimal baseSalary) {
@@ -368,38 +455,141 @@
        if (CollUtil.isEmpty(applications)) {
            return new OvertimeSalary(BigDecimal.ZERO, BigDecimal.ZERO);
        }
        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 = baseSalary.divide(new BigDecimal("21.75"), 8, RoundingMode.HALF_UP)
        // 管理类别不计加班工资
        HrmEmployeeCategoryEnum category = HrmEmployeeCategoryEnum.valueOfCategory(employee.getEmployeeCategory());
        if (category == HrmEmployeeCategoryEnum.MANAGER) {
            return new OvertimeSalary(BigDecimal.ZERO, BigDecimal.ZERO);
        }
        // 员工岗位命中的加班工资配置(取时薪最高、上限最宽的一条)
        OvertimeWageRule wageRule = resolveOvertimeWageRule(employee);
        // 平时时薪 = 基本工资 / 21.75 / 8
        BigDecimal baseHourlySalary = 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;
        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 (category == HrmEmployeeCategoryEnum.TECHNICIAN || wageRule != null) {
            // 技工:1:1(与平时时薪一致);普通员工命中配置:按配置固定时薪,均不区分加班类型倍率
            BigDecimal hourlySalary = category == HrmEmployeeCategoryEnum.TECHNICIAN
                    ? baseHourlySalary : wageRule.hourlyRate();
            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;
                }
                totalHours = totalHours.add(hours);
            }
            BigDecimal multiplier = switch (type) {
                case WORKDAY -> workdayMultiplier;
                case REST_DAY -> restDayMultiplier;
                case LEGAL_HOLIDAY -> legalHolidayMultiplier;
            };
            totalHours = totalHours.add(hours);
            totalPay = totalPay.add(hourlySalary.multiply(hours).multiply(multiplier));
            // 按岗位配置封顶加班时长
            BigDecimal paidHours = wageRule != null && wageRule.maxOvertimeHours() != null
                    && totalHours.compareTo(wageRule.maxOvertimeHours()) > 0
                    ? wageRule.maxOvertimeHours() : totalHours;
            totalPay = hourlySalary.multiply(paidHours);
        } else {
            // 兼容旧逻辑:无岗位配置、且非技工/管理
            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();
            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;
                }
                BigDecimal multiplier = switch (type) {
                    case WORKDAY -> workdayMultiplier;
                    case REST_DAY -> restDayMultiplier;
                    case LEGAL_HOLIDAY -> legalHolidayMultiplier;
                };
                totalHours = totalHours.add(hours);
                totalPay = totalPay.add(baseHourlySalary.multiply(hours).multiply(multiplier));
            }
        }
        return new OvertimeSalary(totalHours.setScale(1, RoundingMode.HALF_UP),
                totalPay.setScale(2, RoundingMode.HALF_UP));
    }
    /**
     * 解析员工岗位对应的加班工资规则:时薪取命中配置中的最大值;
     * 上限取“最宽”(存在未设置上限的配置则视为不限,否则取最大值)。
     */
    private OvertimeWageRule resolveOvertimeWageRule(HrmEmployeeDO employee) {
        if (StrUtil.isEmpty(employee.getPostIds())) {
            return null;
        }
        List<Long> postIds;
        try {
            postIds = JSONUtil.toList(employee.getPostIds(), Long.class);
        } catch (Exception ex) {
            return null;
        }
        if (CollUtil.isEmpty(postIds)) {
            return null;
        }
        List<HrmOvertimeWageConfigDO> configs = overtimeWageConfigMapper.selectEnabledListByPostIds(postIds);
        if (CollUtil.isEmpty(configs)) {
            return null;
        }
        BigDecimal hourlyRate = null;
        BigDecimal maxHours = null;
        boolean unlimited = false;
        for (HrmOvertimeWageConfigDO config : configs) {
            if (config.getHourlyRate() != null
                    && (hourlyRate == null || config.getHourlyRate().compareTo(hourlyRate) > 0)) {
                hourlyRate = config.getHourlyRate();
            }
            if (config.getMaxOvertimeHours() == null) {
                unlimited = true;
            } else if (!unlimited && (maxHours == null || config.getMaxOvertimeHours().compareTo(maxHours) > 0)) {
                maxHours = config.getMaxOvertimeHours();
            }
        }
        if (hourlyRate == null) {
            return null;
        }
        return new OvertimeWageRule(hourlyRate, unlimited ? null : maxHours);
    }
    private record OvertimeWageRule(BigDecimal hourlyRate, BigDecimal maxOvertimeHours) {}
    private record OvertimeSalary(BigDecimal hours, BigDecimal pay) {}
    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
    @Transactional(rollbackFor = Exception.class)
@@ -512,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();
@@ -526,6 +719,102 @@
                salaryCalculationMapper.updateById(calculation);
            }
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateSalaryCalculation(HrmSalaryCalculationDO calculation) {
        HrmSalaryCalculationDO exist = salaryCalculationMapper.selectById(calculation.getId());
        if (exist == null) {
            throw exception(SALARY_CALCULATION_NOT_EXISTS);
        }
        // 仅待确认状态可修改:已确认需先撤销;已发放不可改
        if (exist.getStatus() == null || !Objects.equals(exist.getStatus(), 0)) {
            throw exception(SALARY_CALCULATION_UPDATE_FAIL);
        }
        // 以下字段不允许通过修改接口变更,一律以库中为准
        calculation.setNo(exist.getNo());
        calculation.setPeriod(exist.getPeriod());
        calculation.setUserId(exist.getUserId());
        calculation.setDeptId(exist.getDeptId());
        calculation.setStatus(exist.getStatus());
        calculation.setConfirmTime(exist.getConfirmTime());
        calculation.setConfirmUserId(exist.getConfirmUserId());
        calculation.setPaymentTime(exist.getPaymentTime());
        // 金额字段为 null 时统一按 0 处理:MyBatis-Plus 的 updateById 默认忽略 null,
        // 否则会出现「实发工资按 0 计算、但明细仍保留旧值」的不一致
        calculation.setBaseSalary(nvl(calculation.getBaseSalary()));
        calculation.setPerformanceSalary(nvl(calculation.getPerformanceSalary()));
        calculation.setPostSalary(nvl(calculation.getPostSalary()));
        calculation.setSenioritySalary(nvl(calculation.getSenioritySalary()));
        calculation.setOvertimePay(nvl(calculation.getOvertimePay()));
        calculation.setMealAllowance(nvl(calculation.getMealAllowance()));
        calculation.setTransportAllowance(nvl(calculation.getTransportAllowance()));
        calculation.setOtherAllowance(nvl(calculation.getOtherAllowance()));
        calculation.setFullAttendanceBonus(nvl(calculation.getFullAttendanceBonus()));
        calculation.setSocialSecurityDeduction(nvl(calculation.getSocialSecurityDeduction()));
        calculation.setHousingFundDeduction(nvl(calculation.getHousingFundDeduction()));
        calculation.setTaxDeduction(nvl(calculation.getTaxDeduction()));
        calculation.setOtherDeduction(nvl(calculation.getOtherDeduction()));
        // 缺勤扣款(请假 + 旷工)与实发工资统一由后端重算,
        // 保证列表里改「应出天数 / 实出天数」后金额跟着变,且不受前端传入的 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));
    }
    /**
     * 计算实发工资 = 收入项合计 - 扣除项合计,保留两位小数
     *
     * 收入项必须与 buildSalaryCalculation 中 totalIncome 的口径完全一致,
     * 否则会出现「新建核算算得对、修改一次核算单实发就变少」的不一致。
     *
     * 实发工资不允许为负:缺勤扣款等已封顶为基本工资,但社保/公积金/个税叠加后仍可能为负,
     * 业务上按「本月无工资」处理(置 0),避免出现员工倒欠公司的情况。
     */
    private static BigDecimal calcActualSalary(HrmSalaryCalculationDO c) {
        BigDecimal income = nvl(c.getBaseSalary())
                .add(nvl(c.getPostSalary()))
                .add(nvl(c.getSenioritySalary()))
                .add(nvl(c.getPerformanceSalary()))
                .add(nvl(c.getFullAttendanceBonus()))
                .add(nvl(c.getOvertimePay()))
                .add(nvl(c.getMealAllowance()))
                .add(nvl(c.getTransportAllowance()))
                .add(nvl(c.getOtherAllowance()));
        BigDecimal deduction = nvl(c.getSocialSecurityDeduction())
                .add(nvl(c.getHousingFundDeduction()))
                .add(nvl(c.getTaxDeduction()))
                .add(nvl(c.getOtherDeduction()))
                .add(nvl(c.getLeaveDeduction()));
        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) {
        return v == null ? BigDecimal.ZERO : v;
    }
    /**
@@ -644,4 +933,40 @@
        salaryCalculationMapper.deleteById(id);
    }
    /**
     * 批量删除核算记录(全选后一键删除)
     *
     * 说明:
     * 1. 按业务要求,批量删除不限制核算单状态,勾选即删;
     * 2. 不存在的 ID 直接跳过,不报错(避免前端勾选列表与库不一致时整体失败);
     * 3. 已被「工资发放明细」引用的记录会跳过不删,避免发放明细变成孤儿数据。
     *
     * @return 实际删除的条数(可能小于传入条数)
     */
    @Override
    public Integer deleteSalaryCalculationList(List<Long> ids) {
        if (CollUtil.isEmpty(ids)) {
            return 0;
        }
        List<Long> existIds = salaryCalculationMapper.selectBatchIds(ids).stream()
                .map(HrmSalaryCalculationDO::getId)
                .collect(Collectors.toList());
        if (CollUtil.isEmpty(existIds)) {
            return 0;
        }
        // 引用保护:已进入工资发放流程的核算单不允许删除
        Set<Long> referencedIds = salaryPaymentDetailMapper.selectListByCalculationIds(existIds).stream()
                .map(HrmSalaryPaymentDetailDO::getCalculationId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        List<Long> deletableIds = existIds.stream()
                .filter(id -> !referencedIds.contains(id))
                .collect(Collectors.toList());
        if (CollUtil.isEmpty(deletableIds)) {
            return 0;
        }
        salaryCalculationMapper.deleteByIds(deletableIds);
        return deletableIds.size();
    }
}