| | |
| | | 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.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.HrmSalaryStructureMapper; |
| | | import cn.iocoder.yudao.module.hrm.dal.redis.HrmNoRedisDAO; |
| | | import cn.iocoder.yudao.module.system.api.dept.DeptApi; |
| | | import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi; |
| | | import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import cn.iocoder.yudao.module.hrm.enums.HrmOvertimeTypeEnum; |
| | | import jakarta.annotation.Resource; |
| | | import org.springframework.stereotype.Service; |
| | |
| | | import java.time.LocalDate; |
| | | import java.time.LocalDateTime; |
| | | import java.time.YearMonth; |
| | | import java.util.Collections; |
| | | import java.util.HashMap; |
| | | 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; |
| | |
| | | */ |
| | | @Service |
| | | @Validated |
| | | @Slf4j |
| | | public class HrmSalaryCalculationServiceImpl implements HrmSalaryCalculationService { |
| | | |
| | | /** |
| | |
| | | @Resource |
| | | private DeptApi deptApi; |
| | | @Resource |
| | | private NotifyMessageSendApi notifyMessageSendApi; |
| | | @Resource |
| | | private HrmDeductionService deductionService; |
| | | @Resource |
| | | private HrmLeaveApplicationMapper leaveApplicationMapper; |
| | |
| | | private HrmSalaryPaymentMapper salaryPaymentMapper; |
| | | @Resource |
| | | private HrmSalaryPaymentDetailMapper salaryPaymentDetailMapper; |
| | | @Resource |
| | | private HrmEmployeeScheduleMapper employeeScheduleMapper; |
| | | @Resource |
| | | private HrmShiftMapper shiftMapper; |
| | | |
| | | @Override |
| | | public PageResult<HrmSalaryCalculationDO> getSalaryCalculationPage(HrmSalaryCalculationPageReqVO pageReqVO) { |
| | | return salaryCalculationMapper.selectPage(pageReqVO); |
| | | } |
| | | |
| | | @Override |
| | | public PageResult<HrmSalaryCalculationDO> getMySalaryCalculationPage(Long userId, HrmSalaryCalculationPageReqVO pageReqVO) { |
| | | // 强制限定为当前登录人:忽略前端传入的 userId / deptId,防止越权查看他人工资 |
| | | pageReqVO.setUserId(userId); |
| | | pageReqVO.setDeptId(null); |
| | | return salaryCalculationMapper.selectPage(pageReqVO); |
| | | } |
| | | |
| | | @Override |
| | | public HrmSalaryCalculationDO getMySalaryCalculation(Long id, Long userId) { |
| | | HrmSalaryCalculationDO calculation = salaryCalculationMapper.selectById(id); |
| | | if (calculation == null) { |
| | | return null; |
| | | } |
| | | // 不属于本人的记录一律视为不存在,避免通过猜 id 越权查看他人工资 |
| | | if (!java.util.Objects.equals(calculation.getUserId(), userId)) { |
| | | return null; |
| | | } |
| | | return calculation; |
| | | } |
| | | |
| | | @Override |
| | |
| | | |
| | | // 2. 遍历员工进行薪酬核算 |
| | | for (HrmEmployeeDO employee : employees) { |
| | | // employeeId 是员工ID,用于关联薪酬档案、社保档案、核算记录 |
| | | // employeeId 是员工档案主键(hrm_employee.id),仅用于关联排班 |
| | | // userId 是系统账号 ID(system_users.id),核算记录 / 薪酬档案 / 社保档案 |
| | | // 全部按它关联 —— 员工登录后查「我的薪资」用的也是这个 ID |
| | | Long employeeId = employee.getId(); |
| | | // 2.1 检查该员工当月是否已存在核算记录(按员工ID查询) |
| | | List<HrmSalaryCalculationDO> existCalculations = salaryCalculationMapper.selectListByUserIdAndPeriod(employeeId, period); |
| | | Long userId = employee.getUserId(); |
| | | if (userId == null) { |
| | | log.warn("[calculateSalary] 员工({}) 未关联系统账号,跳过核算", employee.getName()); |
| | | continue; |
| | | } |
| | | // 2.1 检查该员工当月是否已存在核算记录(按系统账号 ID 查询) |
| | | List<HrmSalaryCalculationDO> existCalculations = salaryCalculationMapper.selectListByUserIdAndPeriod(userId, period); |
| | | if (CollUtil.isNotEmpty(existCalculations)) { |
| | | // 如果存在多条记录,提示异常数据 |
| | | if (existCalculations.size() > 1) { |
| | |
| | | } |
| | | } |
| | | |
| | | // 2.2 获取员工薪酬档案(按员工ID查询) |
| | | HrmEmployeeSalaryDO employeeSalary = employeeSalaryMapper.selectLatestByUserId(employeeId); |
| | | // 2.2 获取员工薪酬档案(按系统账号 ID 查询) |
| | | HrmEmployeeSalaryDO employeeSalary = employeeSalaryMapper.selectLatestByUserId(userId); |
| | | if (employeeSalary == null) { |
| | | // 如果没有薪酬档案,使用员工表中的基本工资创建默认数据 |
| | | employeeSalary = buildDefaultEmployeeSalary(employeeId, employee); |
| | | employeeSalary = buildDefaultEmployeeSalary(userId, employee); |
| | | } |
| | | |
| | | // 2.3 获取员工社保公积金档案(按员工ID查询,取最新生效的) |
| | | HrmEmployeeSocialSecurityDO socialSecurity = employeeSocialSecurityMapper.selectLatestByUserId(employeeId); |
| | | // 2.3 获取员工社保公积金档案(按系统账号 ID 查询,取最新生效的) |
| | | HrmEmployeeSocialSecurityDO socialSecurity = employeeSocialSecurityMapper.selectLatestByUserId(userId); |
| | | // 2.4 如果有社保档案,获取绑定的方案 |
| | | HrmSocialSecuritySchemeDO scheme = null; |
| | | if (socialSecurity != null && socialSecurity.getSocialSecuritySchemeId() != null) { |
| | |
| | | } |
| | | |
| | | // 2.5 构建核算记录(包含扣除计算,通过方案获取比例) |
| | | HrmSalaryCalculationDO calculation = buildSalaryCalculation(period, employeeId, employee, employeeSalary, socialSecurity, scheme, effectiveDate); |
| | | HrmSalaryCalculationDO calculation = buildSalaryCalculation(period, userId, employee, employeeSalary, socialSecurity, scheme, effectiveDate); |
| | | |
| | | // 2.6 保存:首次生成单号;已有待确认记录则保留单号并覆盖重算结果 |
| | | if (CollUtil.isNotEmpty(existCalculations)) { |
| | |
| | | |
| | | /** |
| | | * 构建默认的员工薪酬档案。基本工资统一取员工档案,绩效工资默认不自动生成。 |
| | | * |
| | | * @param userId 系统账号 ID(system_users.id),不是 hrm_employee.id |
| | | */ |
| | | private HrmEmployeeSalaryDO buildDefaultEmployeeSalary(Long employeeId, HrmEmployeeDO employee) { |
| | | private HrmEmployeeSalaryDO buildDefaultEmployeeSalary(Long userId, HrmEmployeeDO employee) { |
| | | BigDecimal baseSalary = employee.getBaseSalary() != null ? employee.getBaseSalary() : BigDecimal.ZERO; |
| | | return HrmEmployeeSalaryDO.builder() |
| | | .userId(employeeId) // 使用员工ID |
| | | .userId(userId) |
| | | .salaryStructureId(employee.getSalaryStructureId()) |
| | | .baseSalary(baseSalary) |
| | | .performanceSalary(BigDecimal.ZERO) |
| | |
| | | /** |
| | | * 构建薪酬核算记录(包含社保、公积金、个税、请假扣除计算) |
| | | * 通过员工社保档案绑定的方案获取缴纳比例 |
| | | * |
| | | * @param userId 系统账号 ID(system_users.id),写入核算记录的 user_id |
| | | */ |
| | | private HrmSalaryCalculationDO buildSalaryCalculation(String period, Long employeeId, HrmEmployeeDO employee, |
| | | private HrmSalaryCalculationDO buildSalaryCalculation(String period, Long userId, HrmEmployeeDO employee, |
| | | HrmEmployeeSalaryDO employeeSalary, |
| | | HrmEmployeeSocialSecurityDO socialSecurity, |
| | | HrmSocialSecuritySchemeDO scheme, |
| | |
| | | BigDecimal otherAllowance = employeeSalary.getOtherAllowance() != null ? employeeSalary.getOtherAllowance() : BigDecimal.ZERO; |
| | | OvertimeSalary overtimeSalary = calculateOvertimeSalary(employee, employeeSalary, period, baseSalary); |
| | | BigDecimal overtimePay = overtimeSalary.pay(); |
| | | |
| | | // 按排班累计出勤天数与排班总工时(无排班时出勤天数回退默认 22) |
| | | // 注意:排班表按员工档案 ID(hrm_employee.id)关联,与核算记录的 userId 不同 |
| | | ScheduleStat scheduleStat = calculateScheduleStat(employee.getId(), period); |
| | | Integer workDays = scheduleStat.scheduledDays() > 0 ? scheduleStat.scheduledDays() : 22; |
| | | BigDecimal totalWorkHours = scheduleStat.totalWorkHours(); |
| | | |
| | | // 应发工资总额 |
| | | BigDecimal totalIncome = baseSalary.add(performanceSalary).add(mealAllowance) |
| | |
| | | |
| | | return HrmSalaryCalculationDO.builder() |
| | | .period(period) |
| | | .userId(employeeId) // 使用员工ID |
| | | .userId(userId) |
| | | .deptId(employee.getDeptId()) |
| | | .baseSalary(baseSalary) |
| | | .performanceSalary(performanceSalary) |
| | |
| | | .otherDeduction(otherDeduction) |
| | | .leaveDeduction(leaveDeduction) |
| | | .actualSalary(actualSalary) |
| | | .workDays(22) // 默认工作日 |
| | | .workDays(workDays) // 按排班累计应出勤天数,无排班时回退 22 |
| | | .overtimeHours(overtimeSalary.hours()) |
| | | .totalWorkHours(totalWorkHours) |
| | | .status(0) // 待确认 |
| | | .build(); |
| | | } |
| | |
| | | |
| | | 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) |
| | | public void confirmSalaryCalculation(List<Long> ids) { |
| | |
| | | salaryCalculationMapper.updateById(salaryCalculation); |
| | | // 记录周期 |
| | | period = salaryCalculation.getPeriod(); |
| | | // 确认后给该员工发送工资条通知(失败不影响确认主流程) |
| | | sendPayslipNotice(salaryCalculation); |
| | | } |
| | | |
| | | // 检查该周期是否所有核算记录都已确认,自动生成发放台账 |
| | |
| | | /** |
| | | * 检查该周期是否所有核算记录都已确认,如果全部确认则自动生成发放台账 |
| | | */ |
| | | /** |
| | | * 工资条通知的站内信模板编码 |
| | | * |
| | | * 对应 system_notify_template.code = salary_payslip_notice |
| | | */ |
| | | private static final String TEMPLATE_CODE_PAYSLIP = "salary_payslip_notice"; |
| | | |
| | | /** |
| | | * 给员工发送工资条通知(APP 站内信) |
| | | * |
| | | * 通知失败只记日志,不影响核算确认主流程 |
| | | */ |
| | | private void sendPayslipNotice(HrmSalaryCalculationDO calculation) { |
| | | if (calculation == null || calculation.getUserId() == null) { |
| | | return; |
| | | } |
| | | try { |
| | | NotifySendSingleToUserReqDTO reqDTO = new NotifySendSingleToUserReqDTO(); |
| | | reqDTO.setUserId(calculation.getUserId()); |
| | | reqDTO.setTemplateCode(TEMPLATE_CODE_PAYSLIP); |
| | | Map<String, Object> params = new HashMap<>(); |
| | | params.put("period", calculation.getPeriod()); |
| | | params.put("actualSalary", calculation.getActualSalary()); |
| | | reqDTO.setTemplateParams(params); |
| | | notifyMessageSendApi.sendSingleMessageToAdmin(reqDTO); |
| | | log.info("[sendPayslipNotice] 工资条通知已发送,userId={}, period={}", |
| | | calculation.getUserId(), calculation.getPeriod()); |
| | | } catch (Exception e) { |
| | | log.warn("[sendPayslipNotice] 工资条通知发送失败,userId={}, period={}", |
| | | calculation.getUserId(), calculation.getPeriod(), e); |
| | | } |
| | | } |
| | | |
| | | private void autoGeneratePaymentIfAllConfirmed(String period) { |
| | | // 查询该周期所有核算记录 |
| | | List<HrmSalaryCalculationDO> allCalculations = salaryCalculationMapper.selectListByPeriod(period); |
| | |
| | | // 2. 遍历员工实时计算薪酬 |
| | | List<HrmSalaryCalculationDO> resultList = new java.util.ArrayList<>(); |
| | | for (HrmEmployeeDO employee : employees) { |
| | | Long employeeId = employee.getId(); |
| | | |
| | | // 2.1 获取员工薪酬档案(按员工ID查询) |
| | | HrmEmployeeSalaryDO employeeSalary = employeeSalaryMapper.selectLatestByUserId(employeeId); |
| | | if (employeeSalary == null) { |
| | | // 如果没有薪酬档案,使用员工表中的基本工资创建默认数据 |
| | | employeeSalary = buildDefaultEmployeeSalary(employeeId, employee); |
| | | // userId 是系统账号 ID(system_users.id),薪酬档案 / 社保档案按它关联; |
| | | // 排班表则按 hrm_employee.id 关联,两者不同 |
| | | Long userId = employee.getUserId(); |
| | | if (userId == null) { |
| | | log.warn("[previewSalaryCalculation] 员工({}) 未关联系统账号,跳过", employee.getName()); |
| | | continue; |
| | | } |
| | | |
| | | // 2.2 获取员工社保公积金档案(按员工ID查询,取最新生效的) |
| | | HrmEmployeeSocialSecurityDO socialSecurity = employeeSocialSecurityMapper.selectLatestByUserId(employeeId); |
| | | // 2.1 获取员工薪酬档案(按系统账号 ID 查询) |
| | | HrmEmployeeSalaryDO employeeSalary = employeeSalaryMapper.selectLatestByUserId(userId); |
| | | if (employeeSalary == null) { |
| | | // 如果没有薪酬档案,使用员工表中的基本工资创建默认数据 |
| | | employeeSalary = buildDefaultEmployeeSalary(userId, employee); |
| | | } |
| | | |
| | | // 2.2 获取员工社保公积金档案(按系统账号 ID 查询,取最新生效的) |
| | | HrmEmployeeSocialSecurityDO socialSecurity = employeeSocialSecurityMapper.selectLatestByUserId(userId); |
| | | // 2.3 如果有社保档案,获取绑定的方案 |
| | | HrmSocialSecuritySchemeDO scheme = null; |
| | | if (socialSecurity != null && socialSecurity.getSocialSecuritySchemeId() != null) { |
| | |
| | | } |
| | | |
| | | // 2.4 构建核算记录(实时计算,不存储) |
| | | HrmSalaryCalculationDO calculation = buildSalaryCalculation(period, employeeId, employee, employeeSalary, socialSecurity, scheme, effectiveDate); |
| | | HrmSalaryCalculationDO calculation = buildSalaryCalculation(period, userId, employee, employeeSalary, socialSecurity, scheme, effectiveDate); |
| | | resultList.add(calculation); |
| | | } |
| | | return resultList; |