| | |
| | | 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.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.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; |
| | |
| | | @Resource |
| | | private HrmSalaryStructureMapper salaryStructureMapper; |
| | | @Resource |
| | | private HrmOvertimeWageConfigMapper overtimeWageConfigMapper; |
| | | @Resource |
| | | private HrmLeaveTypeConfigMapper leaveTypeConfigMapper; |
| | | @Resource |
| | | private HrmSalaryPaymentMapper salaryPaymentMapper; |
| | |
| | | private HrmEmployeeScheduleMapper employeeScheduleMapper; |
| | | @Resource |
| | | private HrmShiftMapper shiftMapper; |
| | | @Resource |
| | | private HrmAttendanceSummaryMapper attendanceSummaryMapper; |
| | | |
| | | @Override |
| | | public PageResult<HrmSalaryCalculationDO> getSalaryCalculationPage(HrmSalaryCalculationPageReqVO pageReqVO) { |
| | |
| | | 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) |
| | | // 应出天数:优先取考勤汇总里的「应出勤天数」(客户按实际月份维护,如 30 天), |
| | | // 没有汇总时退回排班累计天数,再退回默认 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); |
| | | // 实出天数:取自考勤汇总。 |
| | | // 注意: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 |
| | |
| | | .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) |
| | |
| | | |
| | | 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(workDays) // 按排班累计应出勤天数,无排班时回退 22 |
| | | .workDays(workDays) // 应出天数:优先考勤汇总,其次排班累计,最后回退 22 |
| | | .actualDays(actualDays) // 实出天数:取自考勤汇总 |
| | | .overtimeHours(overtimeSalary.hours()) |
| | | .totalWorkHours(totalWorkHours) |
| | | .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) { |
| | |
| | | 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) {} |
| | | |
| | |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public void saveSalaryCalculationList(List<HrmSalaryCalculationDO> calculationList) { |
| | | for (HrmSalaryCalculationDO calculation : calculationList) { |
| | | // 缺勤扣款(请假 + 旷工)与实发工资统一由后端重算, |
| | | // 保证列表里改「应出天数 / 实出天数」后金额跟着变 |
| | | recalcAttendanceDeduction(calculation); |
| | | if (calculation.getId() == null) { |
| | | // 新增记录,生成核算单号 |
| | | String no = noRedisDAO.generateSalaryCalculationNo(); |
| | |
| | | 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; |
| | | } |
| | | |
| | | /** |
| | |
| | | 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(); |
| | | } |
| | | |
| | | } |