package cn.iocoder.yudao.module.hrm.service.salary;
|
|
import cn.hutool.core.collection.CollUtil;
|
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
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.HrmAttendanceRecordDO;
|
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.HrmLeaveTypeConfigDO;
|
import cn.iocoder.yudao.module.hrm.dal.dataobject.salary.HrmSalaryCalculationDO;
|
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.attendance.HrmAttendanceRecordMapper;
|
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.HrmLeaveTypeConfigMapper;
|
import cn.iocoder.yudao.module.hrm.dal.mysql.salary.HrmSalaryCalculationMapper;
|
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.iocoder.yudao.module.hrm.enums.HrmAttendanceClockTypeEnum;
|
import cn.iocoder.yudao.module.hrm.enums.HrmOvertimeTypeEnum;
|
import jakarta.annotation.Resource;
|
import org.springframework.stereotype.Service;
|
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.validation.annotation.Validated;
|
|
import java.math.BigDecimal;
|
import java.math.RoundingMode;
|
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;
|
import static cn.iocoder.yudao.module.hrm.enums.ErrorCodeConstants.*;
|
|
/**
|
* 酬核算 Service 实现类
|
*
|
* @author 超级管理员
|
*/
|
@Service
|
@Validated
|
public class HrmSalaryCalculationServiceImpl implements HrmSalaryCalculationService {
|
|
/**
|
* 个税起征点(每月5000元)
|
*/
|
private static final BigDecimal TAX_THRESHOLD = new BigDecimal("5000");
|
|
/**
|
* 默认缴纳比例(当员工社保档案中没有配置或方案中没有配置时使用)
|
*/
|
private static final BigDecimal DEFAULT_PENSION_PERSONAL_RATIO = new BigDecimal("0.08");
|
private static final BigDecimal DEFAULT_MEDICAL_PERSONAL_RATIO = new BigDecimal("0.02");
|
private static final BigDecimal DEFAULT_UNEMPLOYMENT_PERSONAL_RATIO = new BigDecimal("0.005");
|
private static final BigDecimal DEFAULT_HOUSING_FUND_PERSONAL_RATIO = new BigDecimal("0.12");
|
|
@Resource
|
private HrmSalaryCalculationMapper salaryCalculationMapper;
|
@Resource
|
private HrmEmployeeSalaryMapper employeeSalaryMapper;
|
@Resource
|
private HrmEmployeeMapper employeeMapper;
|
@Resource
|
private HrmEmployeeSocialSecurityMapper employeeSocialSecurityMapper;
|
@Resource
|
private HrmSocialSecuritySchemeMapper socialSecuritySchemeMapper;
|
@Resource
|
private HrmSocialSecuritySchemeService socialSecuritySchemeService;
|
@Resource
|
private HrmNoRedisDAO noRedisDAO;
|
@Resource
|
private DeptApi deptApi;
|
@Resource
|
private HrmDeductionService deductionService;
|
@Resource
|
private HrmLeaveApplicationMapper leaveApplicationMapper;
|
@Resource
|
private HrmOvertimeApplicationMapper overtimeApplicationMapper;
|
@Resource
|
private HrmSalaryStructureMapper salaryStructureMapper;
|
@Resource
|
private HrmLeaveTypeConfigMapper leaveTypeConfigMapper;
|
@Resource
|
private HrmSalaryPaymentMapper salaryPaymentMapper;
|
@Resource
|
private HrmSalaryPaymentDetailMapper salaryPaymentDetailMapper;
|
@Resource
|
private HrmEmployeeScheduleMapper employeeScheduleMapper;
|
@Resource
|
private HrmShiftMapper shiftMapper;
|
@Resource
|
private HrmAttendanceRecordMapper attendanceRecordMapper;
|
|
@Override
|
public PageResult<HrmSalaryCalculationDO> getSalaryCalculationPage(HrmSalaryCalculationPageReqVO pageReqVO) {
|
return salaryCalculationMapper.selectPage(pageReqVO);
|
}
|
|
@Override
|
public HrmSalaryCalculationDO getSalaryCalculation(Long id) {
|
return salaryCalculationMapper.selectById(id);
|
}
|
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public void calculateSalary(String period) {
|
calculateSalary(period, null);
|
}
|
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public void calculateSalary(String period, Long deptId) {
|
// 解析核算周期的日期
|
LocalDate effectiveDate = parsePeriodToDate(period);
|
|
// 1. 获取所有在职员工(员工状态:1-在职,2-试用)
|
List<HrmEmployeeDO> employees = employeeMapper.selectListByStatus(null);
|
// 过滤在职和试用期员工
|
employees = employees.stream()
|
.filter(e -> e.getEmployeeStatus() != null
|
&& (e.getEmployeeStatus() == 1 || e.getEmployeeStatus() == 2))
|
.filter(e -> deptId == null || deptId.equals(e.getDeptId()))
|
.toList();
|
|
// 2. 遍历员工进行薪酬核算
|
// 部门 -> 方案 的解析结果在本次核算内缓存,避免逐员工重复查库
|
Map<Long, HrmSocialSecuritySchemeDO> deptSchemeCache = new HashMap<>();
|
for (HrmEmployeeDO employee : employees) {
|
// employeeId 是员工ID,用于关联薪酬档案、社保档案、核算记录
|
Long employeeId = employee.getId();
|
// 2.1 检查该员工当月是否已存在核算记录(按员工ID查询)
|
List<HrmSalaryCalculationDO> existCalculations = salaryCalculationMapper.selectListByUserIdAndPeriod(employeeId, period);
|
if (CollUtil.isNotEmpty(existCalculations)) {
|
// 如果存在多条记录,提示异常数据
|
if (existCalculations.size() > 1) {
|
throw exception(SALARY_CALCULATION_DUPLICATE, employee.getName(), period);
|
}
|
// 已确认或已发放的记录不可自动改写;待确认记录允许重算,以吸收后来审批通过的加班。
|
if (existCalculations.get(0).getStatus() != 0) {
|
continue;
|
}
|
}
|
|
// 2.2 获取员工薪酬档案(按员工ID查询)
|
HrmEmployeeSalaryDO employeeSalary = employeeSalaryMapper.selectLatestByUserId(employeeId);
|
if (employeeSalary == null) {
|
// 如果没有薪酬档案,使用员工表中的基本工资创建默认数据
|
employeeSalary = buildDefaultEmployeeSalary(employeeId, employee);
|
}
|
|
// 2.3 获取员工社保公积金档案(按员工ID查询,取最新生效的)
|
HrmEmployeeSocialSecurityDO socialSecurity = employeeSocialSecurityMapper.selectLatestByUserId(employeeId);
|
// 2.4 获取方案:员工档案指定 > 所在部门绑定 > 全局默认
|
HrmSocialSecuritySchemeDO scheme = resolveScheme(socialSecurity, employee, effectiveDate, deptSchemeCache);
|
|
// 2.5 构建核算记录(包含扣除计算,通过方案获取比例)
|
HrmSalaryCalculationDO calculation = buildSalaryCalculation(period, employeeId, employee, employeeSalary, socialSecurity, scheme, effectiveDate);
|
|
// 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);
|
}
|
}
|
}
|
|
/**
|
* 解析核算周期为日期(如 2024-01 -> 2024-01-01)
|
*/
|
private LocalDate parsePeriodToDate(String period) {
|
String[] parts = period.split("-");
|
int year = Integer.parseInt(parts[0]);
|
int month = Integer.parseInt(parts[1]);
|
return LocalDate.of(year, month, 1);
|
}
|
|
/**
|
* 构建默认的员工薪酬档案。基本工资统一取员工档案,绩效工资默认不自动生成。
|
*/
|
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(baseSalary)
|
.performanceSalary(BigDecimal.ZERO)
|
.mealAllowance(BigDecimal.ZERO)
|
.transportAllowance(BigDecimal.ZERO)
|
.otherAllowance(BigDecimal.ZERO)
|
.status(0)
|
.build();
|
}
|
|
/**
|
* 构建薪酬核算记录(包含社保、公积金、个税、请假扣除计算)
|
* 通过员工社保档案绑定的方案获取缴纳比例
|
*/
|
private HrmSalaryCalculationDO buildSalaryCalculation(String period, Long employeeId, HrmEmployeeDO employee,
|
HrmEmployeeSalaryDO employeeSalary,
|
HrmEmployeeSocialSecurityDO socialSecurity,
|
HrmSocialSecuritySchemeDO scheme,
|
LocalDate effectiveDate) {
|
// 计算应发薪资
|
BigDecimal baseSalary = employeeSalary.getBaseSalary() != null ? employeeSalary.getBaseSalary() : BigDecimal.ZERO;
|
BigDecimal performanceSalary = employeeSalary.getPerformanceSalary() != null ? employeeSalary.getPerformanceSalary() : 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 standardSalary = nvl(employee.getStandardSalary());
|
BigDecimal postSalary = nvl(employee.getPostSalary());
|
BigDecimal overtimeHourlyRate = employee.getOvertimeHourlyRate();
|
BigDecimal safetyAwardStandard = nvl(employee.getSafetyAwardStandard());
|
BigDecimal performanceStandard = nvl(employee.getPerformanceStandard());
|
// 标准工资未单独维护时,按「基本工资 + 岗位工资」合成
|
if (standardSalary.signum() == 0 && (baseSalary.signum() > 0 || postSalary.signum() > 0)) {
|
standardSalary = baseSalary.add(postSalary);
|
}
|
|
// 按排班累计应出勤天数与标准工时(无排班时回退 22 天 × 8 小时)
|
ScheduleStat scheduleStat = calculateScheduleStat(employeeId, period);
|
Integer workDays = scheduleStat.scheduledDays() > 0 ? scheduleStat.scheduledDays() : 22;
|
BigDecimal totalWorkHours = scheduleStat.totalWorkHours();
|
BigDecimal standardWorkHours = scheduleStat.scheduledDays() > 0
|
? scheduleStat.totalWorkHours()
|
: BigDecimal.valueOf(workDays).multiply(BigDecimal.valueOf(8));
|
|
// 计薪出勤 = 标准出勤天数 - 缺勤天数(考勤记录中缺卡的天数,按天去重)
|
int absentDays = countAbsentDays(employeeId, period);
|
BigDecimal paidAttendanceDays = BigDecimal.valueOf(Math.max(workDays - absentDays, 0));
|
|
// 加班工资 / 法定假工资(加班工资标准为空时回退到 基本工资 / 21.75 / 8 × 倍率)
|
OvertimeSalary overtimeSalary = calculateOvertimeSalary(employee, employeeSalary, period, baseSalary, overtimeHourlyRate);
|
BigDecimal overtimeHours = overtimeSalary.hours();
|
BigDecimal overtimePay = overtimeSalary.pay();
|
BigDecimal legalHolidayHours = overtimeSalary.legalHolidayHours();
|
BigDecimal legalHolidayPay = overtimeSalary.legalHolidayPay();
|
|
// 出勤折算比例,用于按实际计薪出勤折算标准项
|
BigDecimal attendanceRatio = workDays > 0
|
? paidAttendanceDays.divide(BigDecimal.valueOf(workDays), 6, RoundingMode.HALF_UP)
|
: BigDecimal.ZERO;
|
BigDecimal standardAttendancePay = standardSalary.multiply(attendanceRatio).setScale(2, RoundingMode.HALF_UP);
|
BigDecimal safetyAward = safetyAwardStandard.multiply(attendanceRatio).setScale(2, RoundingMode.HALF_UP);
|
BigDecimal floatingPerformancePay = performanceStandard.multiply(attendanceRatio).setScale(2, RoundingMode.HALF_UP);
|
|
// 应付工资 = 标准出勤工资 + 加班工资 + 法定假工资 + 安全环保奖 + 浮动绩效工资
|
BigDecimal payableSalary = standardAttendancePay.add(overtimePay).add(legalHolidayPay)
|
.add(safetyAward).add(floatingPerformancePay);
|
|
// 加项(工龄工资/夜班补助/学历补贴/体检/优秀员工奖/其他补助/部门绩效)与扣项
|
// (漏打卡/迟到早退/其他扣款)生成时默认为 0,核算页可手工调整后保存
|
BigDecimal seniorityPay = BigDecimal.ZERO;
|
BigDecimal nightShiftAllowance = BigDecimal.ZERO;
|
BigDecimal educationAllowance = BigDecimal.ZERO;
|
BigDecimal healthCheckAllowance = BigDecimal.ZERO;
|
BigDecimal excellentEmployeeAward = BigDecimal.ZERO;
|
BigDecimal otherSubsidy = BigDecimal.ZERO;
|
BigDecimal deptPerformance = BigDecimal.ZERO;
|
BigDecimal additionTotal = BigDecimal.ZERO;
|
BigDecimal missedPunchDeduction = BigDecimal.ZERO;
|
BigDecimal lateEarlyDeduction = BigDecimal.ZERO;
|
BigDecimal deductionTotal = BigDecimal.ZERO;
|
|
// 计税口径:应付工资 + 加项合计 - 扣款合计
|
BigDecimal totalIncome = payableSalary.add(additionTotal).subtract(deductionTotal);
|
|
// 获取社保基数:员工社保档案 > 方案默认缴费基数 > 员工基本工资
|
BigDecimal socialSecurityBase = (socialSecurity != null && socialSecurity.getSocialSecurityBase() != null)
|
? socialSecurity.getSocialSecurityBase()
|
: (scheme != null && scheme.getDefaultSocialSecurityBase() != null
|
? scheme.getDefaultSocialSecurityBase() : baseSalary);
|
|
// 获取公积金基数:员工社保档案 > 方案默认公积金基数(大于 0 时)> 社保基数
|
BigDecimal housingFundBase;
|
if (socialSecurity != null && socialSecurity.getHousingFundBase() != null) {
|
housingFundBase = socialSecurity.getHousingFundBase();
|
} else if (scheme != null && scheme.getDefaultHousingFundBase() != null
|
&& scheme.getDefaultHousingFundBase().compareTo(BigDecimal.ZERO) > 0) {
|
housingFundBase = scheme.getDefaultHousingFundBase();
|
} else {
|
housingFundBase = socialSecurityBase;
|
}
|
|
// 从方案中获取缴纳比例(优先使用方案中的比例,否则使用默认比例)
|
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;
|
|
// 社保扣除 = 基数 × (养老个人比例 + 医疗个人比例 + 失业个人比例)
|
BigDecimal socialSecurityDeduction = socialSecurityBase
|
.multiply(pensionPersonalRatio)
|
.add(socialSecurityBase.multiply(medicalPersonalRatio))
|
.add(socialSecurityBase.multiply(unemploymentPersonalRatio))
|
.setScale(2, RoundingMode.HALF_UP);
|
|
// 公积金扣除 = 基数 × 公积金个人比例
|
BigDecimal housingFundDeduction = housingFundBase.multiply(housingFundPersonalRatio).setScale(2, RoundingMode.HALF_UP);
|
|
// 请假扣款:新版工资表按「计薪出勤」折减标准出勤工资,不再单独重复扣一次,保留字段兼容旧数据
|
BigDecimal leaveDeduction = BigDecimal.ZERO;
|
|
// 计算个税扣除
|
// 应纳税所得额 = 应发工资 - 社保扣除 - 公积金扣除 - 起征点(5000)
|
BigDecimal taxableIncome = totalIncome
|
.subtract(socialSecurityDeduction)
|
.subtract(housingFundDeduction)
|
.subtract(TAX_THRESHOLD);
|
// 如果应纳税所得额小于0,则不缴纳个税
|
if (taxableIncome.compareTo(BigDecimal.ZERO) < 0) {
|
taxableIncome = BigDecimal.ZERO;
|
}
|
BigDecimal taxDeduction = deductionService.calculateTaxDeduction(taxableIncome, effectiveDate);
|
|
BigDecimal otherDeduction = BigDecimal.ZERO;
|
|
// 税后工资 = 应付工资 + 加项合计 - 扣款合计 - 社保 - 公积金 - 个税
|
BigDecimal actualSalary = payableSalary.add(additionTotal).subtract(deductionTotal)
|
.subtract(socialSecurityDeduction).subtract(housingFundDeduction)
|
.subtract(taxDeduction);
|
|
return HrmSalaryCalculationDO.builder()
|
.period(period)
|
.userId(employeeId) // 使用员工ID
|
.deptId(employee.getDeptId())
|
// 九泓工资表:标准项
|
.standardSalary(standardSalary)
|
.postSalary(postSalary)
|
.overtimeHourlyRate(overtimeHourlyRate)
|
.safetyAwardStandard(safetyAwardStandard)
|
.performanceStandard(performanceStandard)
|
// 九泓工资表:出勤与计薪
|
.paidAttendanceDays(paidAttendanceDays)
|
.standardWorkHours(standardWorkHours)
|
.legalHolidayHours(legalHolidayHours)
|
.standardAttendancePay(standardAttendancePay)
|
.legalHolidayPay(legalHolidayPay)
|
.safetyAward(safetyAward)
|
.floatingPerformancePay(floatingPerformancePay)
|
.payableSalary(payableSalary)
|
// 九泓工资表:加项 / 扣项
|
.seniorityPay(seniorityPay)
|
.nightShiftAllowance(nightShiftAllowance)
|
.educationAllowance(educationAllowance)
|
.healthCheckAllowance(healthCheckAllowance)
|
.excellentEmployeeAward(excellentEmployeeAward)
|
.otherSubsidy(otherSubsidy)
|
.deptPerformance(deptPerformance)
|
.additionTotal(additionTotal)
|
.missedPunchDeduction(missedPunchDeduction)
|
.lateEarlyDeduction(lateEarlyDeduction)
|
.deductionTotal(deductionTotal)
|
// 兼容旧字段
|
.baseSalary(baseSalary)
|
.performanceSalary(performanceSalary)
|
.overtimePay(overtimePay)
|
.mealAllowance(mealAllowance)
|
.transportAllowance(transportAllowance)
|
.otherAllowance(otherAllowance)
|
.socialSecurityDeduction(socialSecurityDeduction)
|
.housingFundDeduction(housingFundDeduction)
|
.taxDeduction(taxDeduction)
|
.otherDeduction(otherDeduction)
|
.leaveDeduction(leaveDeduction)
|
.actualSalary(actualSalary)
|
.workDays(workDays) // 按排班累计应出勤天数,无排班时回退 22
|
.overtimeHours(overtimeHours)
|
.totalWorkHours(totalWorkHours)
|
.status(0) // 待确认
|
.build();
|
}
|
|
/**
|
* 解析员工核算用的社保公积金方案
|
*
|
* 优先级:员工社保档案指定的方案(个别覆盖)> 员工所在部门绑定的方案 > 全局默认方案。
|
* 都取不到时返回 null,由 {@link #buildSalaryCalculation} 使用默认比例常量。
|
*
|
* @param deptSchemeCache 本次核算内「部门 -> 方案」的缓存(允许 null 值,用于缓存"该部门无方案")
|
*/
|
private HrmSocialSecuritySchemeDO resolveScheme(HrmEmployeeSocialSecurityDO socialSecurity, HrmEmployeeDO employee,
|
LocalDate periodDate,
|
Map<Long, HrmSocialSecuritySchemeDO> deptSchemeCache) {
|
if (socialSecurity != null && socialSecurity.getSocialSecuritySchemeId() != null) {
|
HrmSocialSecuritySchemeDO scheme = socialSecuritySchemeMapper.selectById(socialSecurity.getSocialSecuritySchemeId());
|
if (scheme != null) {
|
return scheme;
|
}
|
}
|
Long deptId = employee.getDeptId();
|
if (!deptSchemeCache.containsKey(deptId)) {
|
deptSchemeCache.put(deptId, socialSecuritySchemeService.resolveSchemeByDeptId(deptId, periodDate));
|
}
|
return deptSchemeCache.get(deptId);
|
}
|
|
/**
|
* 计算请假扣款
|
*
|
* @param employee 员工信息
|
* @param period 核算周期(如2026-02)
|
* @param baseSalary 基本工资
|
* @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);
|
|
// 查询员工在该月已审批通过的请假记录
|
// 注意:请假申请的 userId 是系统用户ID,需要通过员工表获取
|
Long systemUserId = employee.getUserId();
|
if (systemUserId == null) {
|
return BigDecimal.ZERO;
|
}
|
List<HrmLeaveApplicationDO> leaveApplications = leaveApplicationMapper
|
.selectApprovedListByUserIdAndTimeRange(systemUserId, monthStart, monthEnd);
|
|
if (leaveApplications.isEmpty()) {
|
return BigDecimal.ZERO;
|
}
|
|
// 获取所有请假类型配置
|
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);
|
}
|
|
return totalLeaveDeduction;
|
}
|
|
/**
|
* 汇总当月审批通过的加班申请。
|
*
|
* 员工档案维护了「加班工资标准(元/小时)」时:加班工资 = 加班时长 × 单价,
|
* 法定假工资 = 法定假日时长 × 单价 × 法定节假日倍率(默认 3)。
|
* 未维护单价时回退旧口径:基本工资 / 21.75 / 8 × 各类型倍率。
|
*
|
* 法定节假日加班单独统计,不计入「加班时长」列,避免与「法定假工资」重复计薪。
|
*/
|
private OvertimeSalary calculateOvertimeSalary(HrmEmployeeDO employee, HrmEmployeeSalaryDO employeeSalary,
|
String period, BigDecimal baseSalary, BigDecimal overtimeHourlyRate) {
|
if (employee.getUserId() == null) {
|
return OvertimeSalary.EMPTY;
|
}
|
YearMonth month = YearMonth.parse(period);
|
List<HrmOvertimeApplicationDO> applications = overtimeApplicationMapper.selectApprovedByUserAndDateRange(
|
employee.getUserId(), month.atDay(1), month.atEndOfMonth());
|
if (CollUtil.isEmpty(applications)) {
|
return OvertimeSalary.EMPTY;
|
}
|
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 = overtimeHourlyRate != null && overtimeHourlyRate.signum() > 0
|
? overtimeHourlyRate
|
: 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;
|
BigDecimal legalHolidayHours = BigDecimal.ZERO;
|
BigDecimal legalHolidayPay = 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 (type == HrmOvertimeTypeEnum.LEGAL_HOLIDAY) {
|
legalHolidayHours = legalHolidayHours.add(hours);
|
legalHolidayPay = legalHolidayPay.add(hourlySalary.multiply(hours).multiply(legalHolidayMultiplier));
|
continue;
|
}
|
BigDecimal multiplier = type == HrmOvertimeTypeEnum.WORKDAY ? workdayMultiplier : restDayMultiplier;
|
totalHours = totalHours.add(hours);
|
totalPay = totalPay.add(hourlySalary.multiply(hours).multiply(multiplier));
|
}
|
return new OvertimeSalary(totalHours.setScale(1, RoundingMode.HALF_UP),
|
totalPay.setScale(2, RoundingMode.HALF_UP),
|
legalHolidayHours.setScale(1, RoundingMode.HALF_UP),
|
legalHolidayPay.setScale(2, RoundingMode.HALF_UP));
|
}
|
|
/**
|
* 统计当月考勤记录中缺卡的天数(同一日期多次缺卡只算一天)
|
*/
|
private int countAbsentDays(Long employeeId, String period) {
|
YearMonth month = YearMonth.parse(period);
|
List<HrmAttendanceRecordDO> records = attendanceRecordMapper.selectListByUserIdAndDateBetween(
|
employeeId, month.atDay(1), month.atEndOfMonth());
|
if (CollUtil.isEmpty(records)) {
|
return 0;
|
}
|
return (int) records.stream()
|
.filter(record -> HrmAttendanceClockTypeEnum.MISSING.getType().equals(record.getClockInType())
|
|| HrmAttendanceClockTypeEnum.MISSING.getType().equals(record.getClockOutType()))
|
.map(HrmAttendanceRecordDO::getDate)
|
.distinct()
|
.count();
|
}
|
|
private static BigDecimal nvl(BigDecimal value) {
|
return value == null ? BigDecimal.ZERO : value;
|
}
|
|
private record OvertimeSalary(BigDecimal hours, BigDecimal pay,
|
BigDecimal legalHolidayHours, BigDecimal legalHolidayPay) {
|
private static final OvertimeSalary EMPTY = new OvertimeSalary(
|
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO);
|
}
|
|
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) {
|
if (ids == null || ids.isEmpty()) {
|
return;
|
}
|
LocalDateTime confirmTime = LocalDateTime.now();
|
String period = null;
|
for (Long id : ids) {
|
HrmSalaryCalculationDO salaryCalculation = salaryCalculationMapper.selectById(id);
|
if (salaryCalculation == null) {
|
throw exception(SALARY_CALCULATION_NOT_EXISTS);
|
}
|
if (salaryCalculation.getStatus() != 0) {
|
throw exception(SALARY_CALCULATION_CONFIRM_FAIL);
|
}
|
salaryCalculation.setStatus(10); // 已确认
|
salaryCalculation.setConfirmTime(confirmTime);
|
salaryCalculationMapper.updateById(salaryCalculation);
|
// 记录周期
|
period = salaryCalculation.getPeriod();
|
}
|
|
// 检查该周期是否所有核算记录都已确认,自动生成发放台账
|
if (period != null) {
|
autoGeneratePaymentIfAllConfirmed(period);
|
}
|
}
|
|
/**
|
* 检查该周期是否所有核算记录都已确认,如果全部确认则自动生成发放台账
|
*/
|
private void autoGeneratePaymentIfAllConfirmed(String period) {
|
// 查询该周期所有核算记录
|
List<HrmSalaryCalculationDO> allCalculations = salaryCalculationMapper.selectListByPeriod(period);
|
if (CollUtil.isEmpty(allCalculations)) {
|
return;
|
}
|
// 检查是否全部已确认(状态=10)
|
boolean allConfirmed = allCalculations.stream()
|
.allMatch(c -> c.getStatus() == 10);
|
if (!allConfirmed) {
|
return;
|
}
|
// 检查是否已存在发放台账
|
HrmSalaryPaymentDO existPayment = salaryPaymentMapper.selectByPeriod(period);
|
if (existPayment != null) {
|
return;
|
}
|
// 自动生成发放台账
|
generatePaymentInternal(period, allCalculations);
|
}
|
|
private HrmSalaryCalculationDO validateSalaryCalculationExists(Long id) {
|
HrmSalaryCalculationDO salaryCalculation = salaryCalculationMapper.selectById(id);
|
if (salaryCalculation == null) {
|
throw exception(SALARY_CALCULATION_NOT_EXISTS);
|
}
|
return salaryCalculation;
|
}
|
|
@Override
|
public List<HrmSalaryCalculationDO> getSalaryCalculationListByPeriod(String period) {
|
return salaryCalculationMapper.selectListByPeriod(period);
|
}
|
|
@Override
|
public List<HrmSalaryCalculationDO> previewSalaryCalculation(String period, Long deptId) {
|
// 解析核算周期的日期
|
LocalDate effectiveDate = parsePeriodToDate(period);
|
|
// 1. 获取所有在职员工(员工状态:1-在职,2-试用)
|
List<HrmEmployeeDO> employees = employeeMapper.selectListByStatus(null);
|
// 过滤在职和试用期员工
|
employees = employees.stream()
|
.filter(e -> e.getEmployeeStatus() != null
|
&& (e.getEmployeeStatus() == 1 || e.getEmployeeStatus() == 2))
|
.filter(e -> deptId == null || deptId.equals(e.getDeptId()))
|
.toList();
|
|
// 2. 遍历员工实时计算薪酬
|
List<HrmSalaryCalculationDO> resultList = new java.util.ArrayList<>();
|
// 部门 -> 方案 的解析结果在本次预览内缓存,避免逐员工重复查库
|
Map<Long, HrmSocialSecuritySchemeDO> deptSchemeCache = new HashMap<>();
|
for (HrmEmployeeDO employee : employees) {
|
Long employeeId = employee.getId();
|
|
// 2.1 获取员工薪酬档案(按员工ID查询)
|
HrmEmployeeSalaryDO employeeSalary = employeeSalaryMapper.selectLatestByUserId(employeeId);
|
if (employeeSalary == null) {
|
// 如果没有薪酬档案,使用员工表中的基本工资创建默认数据
|
employeeSalary = buildDefaultEmployeeSalary(employeeId, employee);
|
}
|
|
// 2.2 获取员工社保公积金档案(按员工ID查询,取最新生效的)
|
HrmEmployeeSocialSecurityDO socialSecurity = employeeSocialSecurityMapper.selectLatestByUserId(employeeId);
|
// 2.3 获取方案:员工档案指定 > 所在部门绑定 > 全局默认
|
HrmSocialSecuritySchemeDO scheme = resolveScheme(socialSecurity, employee, effectiveDate, deptSchemeCache);
|
|
// 2.4 构建核算记录(实时计算,不存储)
|
HrmSalaryCalculationDO calculation = buildSalaryCalculation(period, employeeId, employee, employeeSalary, socialSecurity, scheme, effectiveDate);
|
resultList.add(calculation);
|
}
|
return resultList;
|
}
|
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public void saveSalaryCalculationList(List<HrmSalaryCalculationDO> calculationList) {
|
for (HrmSalaryCalculationDO calculation : calculationList) {
|
normalizeSalaryTotals(calculation);
|
if (calculation.getId() == null) {
|
// 新增记录,生成核算单号
|
String no = noRedisDAO.generateSalaryCalculationNo();
|
while (salaryCalculationMapper.selectByNo(no) != null) {
|
no = noRedisDAO.generateSalaryCalculationNo();
|
}
|
calculation.setNo(no);
|
calculation.setStatus(0); // 待确认
|
salaryCalculationMapper.insert(calculation);
|
} else {
|
// 更新已有记录
|
salaryCalculationMapper.updateById(calculation);
|
}
|
}
|
}
|
|
/**
|
* 保存前补齐合计字段。
|
*
|
* 工资表的每个数值字段都允许在自动计算后手工调整,因此这里**只补空值、不覆盖提交值**:
|
* 前端预览页已经按同口径重算好应付工资/加项合计/扣款合计/税后工资并一起提交,
|
* 直接落库才能保住用户手工改过的数;只有字段为空(旧客户端、接口直调)时才按口径补算。
|
*/
|
private void normalizeSalaryTotals(HrmSalaryCalculationDO calculation) {
|
if (calculation.getPayableSalary() == null && calculation.getStandardAttendancePay() != null) {
|
calculation.setPayableSalary(nvl(calculation.getStandardAttendancePay())
|
.add(nvl(calculation.getOvertimePay()))
|
.add(nvl(calculation.getLegalHolidayPay()))
|
.add(nvl(calculation.getSafetyAward()))
|
.add(nvl(calculation.getFloatingPerformancePay()))
|
.setScale(2, RoundingMode.HALF_UP));
|
}
|
if (calculation.getAdditionTotal() == null) {
|
BigDecimal additionTotal = nvl(calculation.getSeniorityPay())
|
.add(nvl(calculation.getNightShiftAllowance()))
|
.add(nvl(calculation.getEducationAllowance()))
|
.add(nvl(calculation.getHealthCheckAllowance()))
|
.add(nvl(calculation.getExcellentEmployeeAward()))
|
.add(nvl(calculation.getOtherSubsidy()))
|
.add(nvl(calculation.getDeptPerformance()));
|
calculation.setAdditionTotal(additionTotal.setScale(2, RoundingMode.HALF_UP));
|
}
|
if (calculation.getDeductionTotal() == null) {
|
BigDecimal deductionTotal = nvl(calculation.getMissedPunchDeduction())
|
.add(nvl(calculation.getLateEarlyDeduction()))
|
.add(nvl(calculation.getOtherDeduction()));
|
calculation.setDeductionTotal(deductionTotal.setScale(2, RoundingMode.HALF_UP));
|
}
|
if (calculation.getActualSalary() == null && calculation.getPayableSalary() != null) {
|
calculation.setActualSalary(calculation.getPayableSalary()
|
.add(nvl(calculation.getAdditionTotal()))
|
.subtract(nvl(calculation.getDeductionTotal()))
|
.subtract(nvl(calculation.getSocialSecurityDeduction()))
|
.subtract(nvl(calculation.getHousingFundDeduction()))
|
.subtract(nvl(calculation.getTaxDeduction()))
|
.setScale(2, RoundingMode.HALF_UP));
|
}
|
}
|
|
/**
|
* 内部生成发放台账方法(供自动生成调用)
|
*/
|
private void generatePaymentInternal(String period, List<HrmSalaryCalculationDO> calculationList) {
|
// 生成发放台账
|
String no = generatePaymentNo();
|
BigDecimal totalAmount = calculationList.stream()
|
.map(c -> c.getActualSalary() != null ? c.getActualSalary() : BigDecimal.ZERO)
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
Integer totalCount = calculationList.size();
|
|
HrmSalaryPaymentDO payment = HrmSalaryPaymentDO.builder()
|
.no(no)
|
.period(period)
|
.totalAmount(totalAmount)
|
.totalCount(totalCount)
|
.status(0) // 待发放
|
.build();
|
salaryPaymentMapper.insert(payment);
|
|
// 生成发放明细(不更新核算状态)
|
for (HrmSalaryCalculationDO calculation : calculationList) {
|
HrmSalaryPaymentDetailDO detail = HrmSalaryPaymentDetailDO.builder()
|
.paymentId(payment.getId())
|
.calculationId(calculation.getId())
|
.userId(calculation.getUserId())
|
.deptId(calculation.getDeptId())
|
.actualSalary(calculation.getActualSalary())
|
.status(0) // 待发放
|
.build();
|
salaryPaymentDetailMapper.insert(detail);
|
}
|
}
|
|
private String generatePaymentNo() {
|
String no = noRedisDAO.generateSalaryPaymentNo();
|
while (salaryPaymentMapper.selectByNo(no) != null) {
|
no = noRedisDAO.generateSalaryPaymentNo();
|
}
|
return no;
|
}
|
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public void revokeSalaryCalculation(List<Long> ids) {
|
if (ids == null || ids.isEmpty()) {
|
return;
|
}
|
for (Long id : ids) {
|
HrmSalaryCalculationDO calculation = salaryCalculationMapper.selectById(id);
|
if (calculation == null) {
|
throw exception(SALARY_CALCULATION_NOT_EXISTS);
|
}
|
// 校验状态必须是已确认(10)
|
if (calculation.getStatus() != 10) {
|
throw exception(SALARY_CALCULATION_REVOKE_FAIL);
|
}
|
// 检查是否已发放(状态=20)
|
if (calculation.getStatus() == 20) {
|
throw exception(SALARY_CALCULATION_ALREADY_PAY);
|
}
|
// 删除关联的发放明细
|
HrmSalaryPaymentDetailDO detail = salaryPaymentDetailMapper.selectByCalculationId(id);
|
if (detail != null) {
|
// 如果明细已发放,不允许撤销
|
if (detail.getStatus() == 10) {
|
throw exception(SALARY_CALCULATION_ALREADY_PAY);
|
}
|
salaryPaymentDetailMapper.deleteById(detail.getId());
|
// 更新发放台账的总金额和总人数
|
updatePaymentStatistics(detail.getPaymentId());
|
}
|
// 恢复核算状态为待确认
|
calculation.setStatus(0);
|
calculation.setConfirmTime(null);
|
salaryCalculationMapper.updateById(calculation);
|
}
|
}
|
|
/**
|
* 更新发放台账的统计信息
|
*/
|
private void updatePaymentStatistics(Long paymentId) {
|
HrmSalaryPaymentDO payment = salaryPaymentMapper.selectById(paymentId);
|
if (payment == null) {
|
return;
|
}
|
// 重新计算总金额和总人数
|
List<HrmSalaryPaymentDetailDO> details = salaryPaymentDetailMapper.selectListByPaymentId(paymentId);
|
if (CollUtil.isEmpty(details)) {
|
// 如果没有明细了,删除发放台账
|
salaryPaymentMapper.deleteById(paymentId);
|
return;
|
}
|
BigDecimal totalAmount = details.stream()
|
.map(d -> d.getActualSalary() != null ? d.getActualSalary() : BigDecimal.ZERO)
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
payment.setTotalAmount(totalAmount);
|
payment.setTotalCount(details.size());
|
salaryPaymentMapper.updateById(payment);
|
}
|
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public void deleteSalaryCalculation(Long id) {
|
HrmSalaryCalculationDO calculation = salaryCalculationMapper.selectById(id);
|
if (calculation == null) {
|
throw exception(SALARY_CALCULATION_NOT_EXISTS);
|
}
|
// 仅待确认状态可删除
|
if (calculation.getStatus() != 0) {
|
throw exception(SALARY_CALCULATION_DELETE_FAIL);
|
}
|
salaryCalculationMapper.deleteById(id);
|
}
|
|
}
|