tuqin
5 天以前 e2a6bb12ebb7ee07fbd5a428603995dbaccd1ce9
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/salary/HrmSalaryCalculationServiceImpl.java
@@ -6,6 +6,7 @@
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;
@@ -18,6 +19,7 @@
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;
@@ -114,6 +116,8 @@
    private HrmEmployeeScheduleMapper employeeScheduleMapper;
    @Resource
    private HrmShiftMapper shiftMapper;
    @Resource
    private HrmAttendanceSummaryMapper attendanceSummaryMapper;
    @Override
    public PageResult<HrmSalaryCalculationDO> getSalaryCalculationPage(HrmSalaryCalculationPageReqVO pageReqVO) {
@@ -235,40 +239,55 @@
                                                          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();
        // 按排班累计出勤天数与排班总工时(无排班时出勤天数回退默认 22)
        // 按排班累计应出天数与排班总工时(无排班时应出天数回退默认 22)
        ScheduleStat scheduleStat = calculateScheduleStat(employeeId, period);
        Integer workDays = scheduleStat.scheduledDays() > 0 ? scheduleStat.scheduledDays() : 22;
        BigDecimal totalWorkHours = scheduleStat.totalWorkHours();
        // 应发工资总额
        BigDecimal totalIncome = baseSalary.add(performanceSalary).add(mealAllowance)
                .add(transportAllowance).add(otherAllowance).add(overtimePay);
        // 实出天数:取自考勤汇总(无汇总记录时按 0 处理)
        Integer actualDays = 0;
        HrmAttendanceSummaryDO attendanceSummary =
                attendanceSummaryMapper.selectByUserIdAndYearMonth(employeeId, period);
        if (attendanceSummary != null && attendanceSummary.getActualDays() != null) {
            actualDays = attendanceSummary.getActualDays();
        }
        // 全勤奖:与其他津贴一致,由人工在核算单中维护,新建时默认 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
@@ -277,8 +296,15 @@
                .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);
@@ -308,19 +334,23 @@
                .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(workDays) // 按排班累计应出勤天数,无排班时回退 22
                .workDays(workDays) // 应出天数:按排班累计,无排班时回退 22
                .actualDays(actualDays) // 实出天数:取自考勤汇总
                .overtimeHours(overtimeSalary.hours())
                .totalWorkHours(totalWorkHours)
                .totalWorkHours(totalWorkHours) // 保留历史字段,业务已改用 actualDays
                .status(0) // 待确认
                .build();
    }
@@ -683,6 +713,22 @@
        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()));
        calculation.setLeaveDeduction(nvl(calculation.getLeaveDeduction()));
        // 实发工资由后端重算,避免前端传入值与明细不一致
        calculation.setActualSalary(calcActualSalary(calculation));
        salaryCalculationMapper.updateById(calculation);
@@ -690,10 +736,16 @@
    /**
     * 计算实发工资 = 收入项合计 - 扣除项合计,保留两位小数
     *
     * 收入项必须与 buildSalaryCalculation 中 totalIncome 的口径完全一致,
     * 否则会出现「新建核算算得对、修改一次核算单实发就变少」的不一致。
     */
    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()))
@@ -826,4 +878,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();
    }
}