package cn.iocoder.yudao.module.hrm.service.attendance;
|
|
import cn.hutool.core.collection.CollUtil;
|
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceSummaryPageReqVO;
|
import cn.iocoder.yudao.module.hrm.dal.dataobject.approval.HrmLeaveApplicationDO;
|
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceRecordDO;
|
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceSummaryDO;
|
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO;
|
import cn.iocoder.yudao.module.hrm.dal.mysql.approval.HrmLeaveApplicationMapper;
|
import cn.iocoder.yudao.module.hrm.dal.mysql.attendance.HrmAttendanceRecordMapper;
|
import cn.iocoder.yudao.module.hrm.dal.mysql.attendance.HrmAttendanceSummaryMapper;
|
import cn.iocoder.yudao.module.hrm.enums.HrmAttendanceClockTypeEnum;
|
import cn.iocoder.yudao.module.hrm.service.employee.HrmEmployeeService;
|
import jakarta.annotation.Resource;
|
import lombok.extern.slf4j.Slf4j;
|
import org.springframework.stereotype.Service;
|
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.validation.annotation.Validated;
|
|
import java.math.BigDecimal;
|
import java.time.LocalDate;
|
import java.time.YearMonth;
|
import java.time.temporal.TemporalAdjusters;
|
import java.util.ArrayList;
|
import java.util.Collections;
|
import java.util.HashMap;
|
import java.util.LinkedHashSet;
|
import java.util.List;
|
import java.util.Map;
|
import java.util.Objects;
|
import java.util.Set;
|
import java.util.stream.Collectors;
|
|
/**
|
* 考勤统计汇总 Service 实现类
|
*
|
* @author 超级管理员
|
*/
|
@Service
|
@Validated
|
@Slf4j
|
public class HrmAttendanceSummaryServiceImpl implements HrmAttendanceSummaryService {
|
|
/** 员工状态:离职(1-在职,2-试用,3-离职) */
|
private static final Integer EMPLOYEE_STATUS_RESIGNED = 3;
|
|
@Resource
|
private HrmAttendanceSummaryMapper attendanceSummaryMapper;
|
@Resource
|
private HrmAttendanceRecordMapper attendanceRecordMapper;
|
@Resource
|
private HrmEmployeeService employeeService;
|
@Resource
|
private HrmAttendanceHolidayService attendanceHolidayService;
|
@Resource
|
private HrmLeaveApplicationMapper leaveApplicationMapper;
|
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public void generateMonthlySummary(Long userId, String yearMonth) {
|
// 1. 解析年月,获取该月起始日期
|
YearMonth ym = YearMonth.parse(yearMonth);
|
LocalDate startDate = ym.atDay(1);
|
LocalDate endDate = ym.atEndOfMonth();
|
|
// 2. 获取员工信息
|
HrmEmployeeDO employee = employeeService.getEmployee(userId);
|
if (employee == null) {
|
log.warn("[generateMonthlySummary] 员工不存在: {}", userId);
|
return;
|
}
|
|
// 3. 查询该月所有考勤记录
|
List<HrmAttendanceRecordDO> records = attendanceRecordMapper.selectListByUserIdAndDateBetween(userId, startDate, endDate);
|
|
// 4. 计算应出勤天数(工作日数量)
|
int workDays = calculateWorkDays(startDate, endDate);
|
|
// 5. 统计各项数据
|
int lateCount = 0;
|
int earlyCount = 0;
|
int absentCount = 0;
|
BigDecimal overtimeHours = BigDecimal.ZERO;
|
// 实际出勤天数按「日期」去重:同一天可能有多条打卡记录(补卡、重复打卡),
|
// 不能每条记录都 +1,否则实出天数会翻倍,直接影响薪酬的缺勤扣款
|
Set<LocalDate> actualDates = new LinkedHashSet<>();
|
|
for (HrmAttendanceRecordDO record : records) {
|
// 统计迟到
|
if (HrmAttendanceClockTypeEnum.LATE.getType().equals(record.getClockInType())) {
|
lateCount++;
|
}
|
// 统计早退
|
if (HrmAttendanceClockTypeEnum.EARLY.getType().equals(record.getClockOutType())) {
|
earlyCount++;
|
}
|
// 统计缺卡
|
if (HrmAttendanceClockTypeEnum.MISSING.getType().equals(record.getClockInType())) {
|
absentCount++;
|
}
|
if (HrmAttendanceClockTypeEnum.MISSING.getType().equals(record.getClockOutType())) {
|
absentCount++;
|
}
|
// 累计加班时长
|
if (record.getOvertimeHours() != null) {
|
overtimeHours = overtimeHours.add(record.getOvertimeHours());
|
}
|
// 统计实际出勤天数(按日期去重)
|
if (record.getClockInTime() != null || record.getClockOutTime() != null) {
|
LocalDate attendDate = record.getDate();
|
if (attendDate == null && record.getClockInTime() != null) {
|
attendDate = record.getClockInTime().toLocalDate();
|
}
|
if (attendDate != null) {
|
actualDates.add(attendDate);
|
}
|
}
|
}
|
// 实出天数不应超过应出天数
|
int actualDays = Math.min(actualDates.size(), workDays);
|
|
// 6. 保存或更新汇总记录
|
HrmAttendanceSummaryDO summary = attendanceSummaryMapper.selectByUserIdAndYearMonth(userId, yearMonth);
|
if (summary == null) {
|
summary = HrmAttendanceSummaryDO.builder()
|
.userId(userId)
|
.deptId(employee.getDeptId())
|
.yearMonth(yearMonth)
|
.workDays(workDays)
|
.actualDays(actualDays)
|
.lateCount(lateCount)
|
.earlyCount(earlyCount)
|
.absentCount(absentCount)
|
.overtimeHours(overtimeHours)
|
.leaveHours(BigDecimal.ZERO)
|
.build();
|
attendanceSummaryMapper.insert(summary);
|
} else {
|
summary.setWorkDays(workDays);
|
summary.setActualDays(actualDays);
|
summary.setLateCount(lateCount);
|
summary.setEarlyCount(earlyCount);
|
summary.setAbsentCount(absentCount);
|
summary.setOvertimeHours(overtimeHours);
|
attendanceSummaryMapper.updateById(summary);
|
}
|
}
|
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public void generateDeptMonthlySummary(Long deptId, String yearMonth) {
|
// 获取部门所有在职员工
|
List<HrmEmployeeDO> deptEmployees = getActiveEmployeeList().stream()
|
.filter(e -> deptId.equals(e.getDeptId()))
|
.toList();
|
for (HrmEmployeeDO employee : deptEmployees) {
|
generateMonthlySummary(employee.getId(), yearMonth);
|
}
|
}
|
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public void generateAllMonthlySummary(String yearMonth) {
|
// 获取所有在职员工
|
for (HrmEmployeeDO employee : getActiveEmployeeList()) {
|
generateMonthlySummary(employee.getId(), yearMonth);
|
}
|
}
|
|
/**
|
* 获取参与考勤统计的员工(在职 + 试用,排除已离职)
|
*
|
* <p>注意:不能用 {@code getEmployeeList(null)},MyBatis-Plus 的 selectByIds(null)
|
* 会抛 "The expression 'coll' evaluated to a null value",导致「生成统计」报系统异常。
|
*/
|
private List<HrmEmployeeDO> getActiveEmployeeList() {
|
List<HrmEmployeeDO> employees = employeeService.getEmployeeListByStatus(null);
|
if (CollUtil.isEmpty(employees)) {
|
return Collections.emptyList();
|
}
|
return employees.stream()
|
.filter(e -> !Objects.equals(e.getEmployeeStatus(), EMPLOYEE_STATUS_RESIGNED))
|
.toList();
|
}
|
|
/**
|
* 计算应出勤天数(工作日数量)
|
*/
|
private int calculateWorkDays(LocalDate startDate, LocalDate endDate) {
|
int workDays = 0;
|
LocalDate current = startDate;
|
while (!current.isAfter(endDate)) {
|
if (attendanceHolidayService.isWorkDay(current)) {
|
workDays++;
|
}
|
current = current.plusDays(1);
|
}
|
return workDays;
|
}
|
|
@Override
|
public PageResult<HrmAttendanceSummaryDO> getAttendanceSummaryPage(HrmAttendanceSummaryPageReqVO pageReqVO) {
|
// 传了开始日期 → 按日区间实时聚合打卡记录(客户要求「按日筛选,不是固定本月」)
|
if (pageReqVO.getStartDate() != null) {
|
return getAttendanceSummaryByDateRange(pageReqVO);
|
}
|
return attendanceSummaryMapper.selectPage(pageReqVO, new LambdaQueryWrapperX<HrmAttendanceSummaryDO>()
|
.eqIfPresent(HrmAttendanceSummaryDO::getUserId, pageReqVO.getUserId())
|
.eqIfPresent(HrmAttendanceSummaryDO::getDeptId, pageReqVO.getDeptId())
|
.eqIfPresent(HrmAttendanceSummaryDO::getYearMonth, pageReqVO.getYearMonth())
|
.orderByDesc(HrmAttendanceSummaryDO::getYearMonth)
|
.orderByAsc(HrmAttendanceSummaryDO::getDeptId));
|
}
|
|
/**
|
* 按日区间实时统计考勤(不落库,仅用于列表查询)
|
*
|
* <p>口径与月度汇总保持一致:应出勤天数取区间内工作日数,实出勤按打卡日期去重,
|
* 迟到/早退/缺卡按打卡记录计数,加班时长累加,请假时长按每天 8 小时折算。
|
*/
|
private PageResult<HrmAttendanceSummaryDO> getAttendanceSummaryByDateRange(HrmAttendanceSummaryPageReqVO reqVO) {
|
LocalDate startDate = reqVO.getStartDate();
|
LocalDate endDate = reqVO.getEndDate() != null
|
? reqVO.getEndDate() : startDate.with(TemporalAdjusters.lastDayOfMonth());
|
// 起止日期填反时自动纠正,避免客户误操作查不到数据
|
if (endDate.isBefore(startDate)) {
|
LocalDate tmp = startDate;
|
startDate = endDate;
|
endDate = tmp;
|
}
|
|
// 1. 确定员工范围(在职 + 试用),支持部门 / 员工筛选
|
List<HrmEmployeeDO> employees = getActiveEmployeeList();
|
if (reqVO.getDeptId() != null) {
|
employees = employees.stream()
|
.filter(e -> reqVO.getDeptId().equals(e.getDeptId()))
|
.toList();
|
}
|
if (reqVO.getUserId() != null) {
|
employees = employees.stream()
|
.filter(e -> reqVO.getUserId().equals(e.getId()))
|
.toList();
|
}
|
if (CollUtil.isEmpty(employees)) {
|
return PageResult.empty();
|
}
|
|
// 2. 应出勤天数(区间内工作日)
|
int workDays = calculateWorkDays(startDate, endDate);
|
|
// 3. 批量查询区间内打卡记录,按员工分组
|
Set<Long> employeeIds = employees.stream().map(HrmEmployeeDO::getId).collect(Collectors.toSet());
|
List<HrmAttendanceRecordDO> records = attendanceRecordMapper
|
.selectListByUserIdsAndDateBetween(employeeIds, startDate, endDate);
|
Map<Long, List<HrmAttendanceRecordDO>> recordMap = records.stream()
|
.collect(Collectors.groupingBy(HrmAttendanceRecordDO::getUserId));
|
|
// 4. 批量查询区间内已审批通过的请假单(请假单的 user_id 是 system_users.id)
|
Set<Long> systemUserIds = employees.stream().map(HrmEmployeeDO::getUserId)
|
.filter(Objects::nonNull).collect(Collectors.toSet());
|
List<HrmLeaveApplicationDO> leaves = leaveApplicationMapper.selectApprovedListByUserIdsAndTimeRange(
|
systemUserIds, startDate.atStartOfDay(), endDate.atTime(23, 59, 59));
|
Map<Long, BigDecimal> leaveDaysMap = new HashMap<>();
|
for (HrmLeaveApplicationDO leave : leaves) {
|
if (leave.getUserId() == null || leave.getDuration() == null) {
|
continue;
|
}
|
leaveDaysMap.merge(leave.getUserId(), leave.getDuration(), BigDecimal::add);
|
}
|
|
// 5. 逐员工组装统计结果
|
List<HrmAttendanceSummaryDO> list = new ArrayList<>(employees.size());
|
for (HrmEmployeeDO employee : employees) {
|
List<HrmAttendanceRecordDO> empRecords = recordMap.getOrDefault(employee.getId(), Collections.emptyList());
|
int lateCount = 0;
|
int earlyCount = 0;
|
int absentCount = 0;
|
BigDecimal overtimeHours = BigDecimal.ZERO;
|
Set<LocalDate> actualDates = new LinkedHashSet<>();
|
for (HrmAttendanceRecordDO record : empRecords) {
|
if (HrmAttendanceClockTypeEnum.LATE.getType().equals(record.getClockInType())) {
|
lateCount++;
|
}
|
if (HrmAttendanceClockTypeEnum.EARLY.getType().equals(record.getClockOutType())) {
|
earlyCount++;
|
}
|
if (HrmAttendanceClockTypeEnum.MISSING.getType().equals(record.getClockInType())) {
|
absentCount++;
|
}
|
if (HrmAttendanceClockTypeEnum.MISSING.getType().equals(record.getClockOutType())) {
|
absentCount++;
|
}
|
if (record.getOvertimeHours() != null) {
|
overtimeHours = overtimeHours.add(record.getOvertimeHours());
|
}
|
if (record.getClockInTime() != null || record.getClockOutTime() != null) {
|
LocalDate attendDate = record.getDate();
|
if (attendDate == null && record.getClockInTime() != null) {
|
attendDate = record.getClockInTime().toLocalDate();
|
}
|
if (attendDate != null) {
|
actualDates.add(attendDate);
|
}
|
}
|
}
|
// 请假天数折算为小时(按每天 8 小时),与月度汇总的 leave_hours 口径一致
|
BigDecimal leaveDays = employee.getUserId() == null ? BigDecimal.ZERO
|
: leaveDaysMap.getOrDefault(employee.getUserId(), BigDecimal.ZERO);
|
list.add(HrmAttendanceSummaryDO.builder()
|
// 实时统计没有落库主键,用员工ID充当列表行 key,避免前端行 key 冲突
|
.id(employee.getId())
|
.userId(employee.getId())
|
.deptId(employee.getDeptId())
|
// 区间统计时,年月列展示统计区间
|
.yearMonth(startDate + " ~ " + endDate)
|
.workDays(workDays)
|
.actualDays(Math.min(actualDates.size(), workDays))
|
.lateCount(lateCount)
|
.earlyCount(earlyCount)
|
.absentCount(absentCount)
|
.overtimeHours(overtimeHours)
|
.leaveHours(leaveDays.multiply(BigDecimal.valueOf(8)))
|
.build());
|
}
|
|
// 6. 内存分页(区间统计不落库,无法走数据库分页)
|
int total = list.size();
|
int pageNo = reqVO.getPageNo() == null || reqVO.getPageNo() < 1 ? 1 : reqVO.getPageNo();
|
int pageSize = reqVO.getPageSize() == null || reqVO.getPageSize() < 1 ? 10 : reqVO.getPageSize();
|
int fromIndex = Math.min((pageNo - 1) * pageSize, total);
|
int toIndex = Math.min(fromIndex + pageSize, total);
|
return new PageResult<>(list.subList(fromIndex, toIndex), (long) total);
|
}
|
|
@Override
|
public HrmAttendanceSummaryDO getMonthlySummary(Long userId, String yearMonth) {
|
return attendanceSummaryMapper.selectByUserIdAndYearMonth(userId, yearMonth);
|
}
|
|
@Override
|
public List<HrmAttendanceSummaryDO> getDeptMonthlySummaryList(Long deptId, String yearMonth) {
|
return attendanceSummaryMapper.selectListByDeptIdAndYearMonth(deptId, yearMonth);
|
}
|
|
}
|