| | |
| | | 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.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; |
| | |
| | | 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.employee.HrmEmployeeMapper; |
| | | import cn.iocoder.yudao.module.hrm.dal.mysql.salary.HrmEmployeeSalaryMapper; |
| | | import cn.iocoder.yudao.module.hrm.dal.mysql.salary.HrmEmployeeSocialSecurityMapper; |
| | |
| | | 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.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; |
| | | import org.springframework.transaction.annotation.Transactional; |
| | |
| | | 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; |
| | |
| | | @Resource |
| | | private HrmLeaveApplicationMapper leaveApplicationMapper; |
| | | @Resource |
| | | private HrmOvertimeApplicationMapper overtimeApplicationMapper; |
| | | @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; |
| | | |
| | | @Override |
| | | public PageResult<HrmSalaryCalculationDO> getSalaryCalculationPage(HrmSalaryCalculationPageReqVO pageReqVO) { |
| | |
| | | if (existCalculations.size() > 1) { |
| | | throw exception(SALARY_CALCULATION_DUPLICATE, employee.getName(), period); |
| | | } |
| | | continue; // 已存在则跳过 |
| | | // 已确认或已发放的记录不可自动改写;待确认记录允许重算,以吸收后来审批通过的加班。 |
| | | if (existCalculations.get(0).getStatus() != 0) { |
| | | continue; |
| | | } |
| | | } |
| | | |
| | | // 2.2 获取员工薪酬档案(按员工ID查询) |
| | |
| | | // 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); |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | /** |
| | | * 构建默认的员工薪酬档案(基于员工表的基本工资) |
| | | * 构建默认的员工薪酬档案。基本工资统一取员工档案,绩效工资默认不自动生成。 |
| | | */ |
| | | 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) |
| | |
| | | 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; |
| | | OvertimeSalary overtimeSalary = calculateOvertimeSalary(employee, employeeSalary, period, baseSalary); |
| | | BigDecimal overtimePay = overtimeSalary.pay(); |
| | | |
| | | // 按排班累计出勤天数与排班总工时(无排班时出勤天数回退默认 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); |
| | | |
| | | // 获取社保基数(员工档案中的个性化基数) |
| | | // 获取社保基数:员工社保档案 > 员工基本工资 |
| | | BigDecimal socialSecurityBase = (socialSecurity != null && socialSecurity.getSocialSecurityBase() != null) |
| | | ? socialSecurity.getSocialSecurityBase() : baseSalary; |
| | | |
| | | // 获取公积金基数(员工档案中的个性化基数) |
| | | // 获取公积金基数:员工社保档案 > 员工基本工资 |
| | | BigDecimal housingFundBase = (socialSecurity != null && socialSecurity.getHousingFundBase() != null) |
| | | ? socialSecurity.getHousingFundBase() : baseSalary; |
| | | |
| | |
| | | .otherDeduction(otherDeduction) |
| | | .leaveDeduction(leaveDeduction) |
| | | .actualSalary(actualSalary) |
| | | .workDays(22) // 默认工作日 |
| | | .overtimeHours(BigDecimal.ZERO) |
| | | .workDays(workDays) // 按排班累计应出勤天数,无排班时回退 22 |
| | | .overtimeHours(overtimeSalary.hours()) |
| | | .totalWorkHours(totalWorkHours) |
| | | .status(0) // 待确认 |
| | | .build(); |
| | | } |
| | |
| | | } |
| | | |
| | | return totalLeaveDeduction; |
| | | } |
| | | |
| | | /** |
| | | * 汇总当月审批通过的加班申请,计算加班工资与加班时长。 |
| | | * |
| | | * <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 (employee.getUserId() == null) { |
| | | return new OvertimeSalary(BigDecimal.ZERO, BigDecimal.ZERO); |
| | | } |
| | | YearMonth month = YearMonth.parse(period); |
| | | List<HrmOvertimeApplicationDO> applications = overtimeApplicationMapper.selectApprovedByUserAndDateRange( |
| | | employee.getUserId(), month.atDay(1), month.atEndOfMonth()); |
| | | if (CollUtil.isEmpty(applications)) { |
| | | return new OvertimeSalary(BigDecimal.ZERO, BigDecimal.ZERO); |
| | | } |
| | | // 管理类别不计加班工资 |
| | | 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; |
| | | 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 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 |
| | |
| | | salaryCalculationMapper.deleteById(id); |
| | | } |
| | | |
| | | } |
| | | } |