Files
klp-oa/klp-wms/src/main/java/com/klp/service/impl/WmsAttendanceCheckServiceImpl.java
Joshi 7e07b6f970 feat(wms/attendance-check): 新增异常考勤筛选功能
1. 在考勤审核查询条件中增加异常标识字段abnormal,用于筛选异常考勤记录
2. 在服务层实现异常筛选逻辑:当abnormal为true时,查询整体状态不为"normal"的考勤记录

调整前,考勤审核查询无法直接筛选异常考勤;调整后,支持通过abnormal字段快速过滤出异常状态的考勤记录,提升审核效率。
2026-06-06 10:13:41 +08:00

806 lines
36 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package com.klp.service.impl;
import cn.hutool.core.bean.BeanUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.klp.common.core.domain.PageQuery;
import com.klp.common.core.page.TableDataInfo;
import com.klp.common.utils.StringUtils;
import com.klp.domain.AttendanceRecords;
import com.klp.domain.WmsAttendanceCheck;
import com.klp.domain.WmsAttendanceRule;
import com.klp.domain.bo.AttendanceCheckBo;
import com.klp.domain.bo.AttendanceRecordsBo;
import com.klp.domain.bo.WmsAttendanceCheckBo;
import com.klp.domain.bo.WmsAttendanceScheduleBo;
import com.klp.domain.vo.AttendanceRecordsVo;
import com.klp.domain.vo.WmsAttendanceCheckVo;
import com.klp.domain.vo.WmsAttendanceScheduleVo;
import com.klp.mapper.WmsAttendanceCheckMapper;
import com.klp.mapper.WmsAttendanceRuleMapper;
import com.klp.service.IAttendanceRecordsService;
import com.klp.service.IWmsAttendanceCheckService;
import com.klp.service.IWmsAttendanceScheduleService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@RequiredArgsConstructor
@Service
public class WmsAttendanceCheckServiceImpl implements IWmsAttendanceCheckService {
private static final List<String> STATUS_SEVERITY = java.util.Arrays.asList("normal", "late_warn", "early_warn",
"late_one", "early_one", "late_two", "early_two", "absent_half", "missed_start", "missed_end", "missed");
private static final long LATE_EARLY_MAX_SECONDS = 120 * 60L;
private static final int BATCH_SIZE = 500;
private final WmsAttendanceCheckMapper baseMapper;
private final IWmsAttendanceScheduleService scheduleService;
private final IAttendanceRecordsService attendanceRecordsService;
private final WmsAttendanceRuleMapper ruleMapper;
@Override
public WmsAttendanceCheckVo queryById(Long checkId) {
return baseMapper.selectVoById(checkId);
}
@Override
public TableDataInfo<WmsAttendanceCheckVo> queryPageList(WmsAttendanceCheckBo bo, PageQuery pageQuery) {
LambdaQueryWrapper<WmsAttendanceCheck> lqw = buildQueryWrapper(bo);
Page<WmsAttendanceCheckVo> result = baseMapper.selectVoPage(pageQuery.build(), lqw);
return TableDataInfo.build(result);
}
@Override
public List<WmsAttendanceCheckVo> queryList(WmsAttendanceCheckBo bo) {
LambdaQueryWrapper<WmsAttendanceCheck> lqw = buildQueryWrapper(bo);
return baseMapper.selectVoList(lqw);
}
@Override
public Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid) {
return baseMapper.deleteBatchIds(ids) > 0;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean updateByBo(WmsAttendanceCheckBo bo) {
WmsAttendanceCheck update = BeanUtil.toBean(bo, WmsAttendanceCheck.class);
return baseMapper.updateById(update) > 0;
}
private LambdaQueryWrapper<WmsAttendanceCheck> buildQueryWrapper(WmsAttendanceCheckBo bo) {
LambdaQueryWrapper<WmsAttendanceCheck> lqw = Wrappers.lambdaQuery();
lqw.eq(bo.getUserId() != null, WmsAttendanceCheck::getUserId, bo.getUserId());
lqw.like(StringUtils.isNotBlank(bo.getEmployeeName()), WmsAttendanceCheck::getEmployeeName, bo.getEmployeeName());
lqw.eq(bo.getShiftId() != null, WmsAttendanceCheck::getShiftId, bo.getShiftId());
lqw.ge(bo.getStartDate() != null, WmsAttendanceCheck::getWorkDate, bo.getStartDate());
lqw.le(bo.getEndDate() != null, WmsAttendanceCheck::getWorkDate, bo.getEndDate());
lqw.in(bo.getUserIds() != null, WmsAttendanceCheck::getUserId, bo.getUserIds());
if (Boolean.TRUE.equals(bo.getAbnormal())){
lqw.ne(WmsAttendanceCheck::getOverallStatus, "normal");
}
lqw.orderByDesc(WmsAttendanceCheck::getWorkDate);
return lqw;
}
/**
* 检查员工考勤情况
*
* @param bo 考勤检查业务对象包含开始日期、结束日期和用户ID等信息
*/
@Override
@Transactional(rollbackFor = Exception.class) // 使用事务注解,确保方法内所有操作要么全部成功,要么全部回滚
public void checkAttendance(AttendanceCheckBo bo) {
// 创建考勤计划业务对象,并设置查询参数
WmsAttendanceScheduleBo scheduleBo = new WmsAttendanceScheduleBo();
scheduleBo.setStartDate(bo.getStartDate()); // 设置开始日期
scheduleBo.setEndDate(bo.getEndDate()); // 设置结束日期
scheduleBo.setUserIds(bo.getUserIds()); // 设置用户ID列表
// 根据查询条件获取考勤计划列表
List<WmsAttendanceScheduleVo> schedules = scheduleService.queryList(scheduleBo);
// 如果没有考勤计划,直接返回
if (schedules.isEmpty()) {
return;
}
// 获取当前有效的考勤规则
WmsAttendanceRule rule = getActiveRule();
// // 将日期转换为LocalDate类型
// LocalDate startLocal = toLocalDate(bo.getStartDate());
// LocalDate endLocal = toLocalDate(bo.getEndDate());
// 创建待处理的考勤计划列表
List<WmsAttendanceScheduleVo> toProcess = new ArrayList<>();
// 遍历考勤计划,过滤掉无效数据
for (WmsAttendanceScheduleVo schedule : schedules) {
if (schedule.getEmployeeName() == null) { // 跳过员工名为空的记录
continue;
}
if (schedule.getShiftStartTime() == null && schedule.getShiftEndTime() == null) { // 跳过上下班时间为空的记录
continue;
}
toProcess.add(schedule); // 添加到待处理列表
}
// 如果过滤后没有有效的考勤计划,直接返回
if (toProcess.isEmpty()) {
return;
}
// 倒班日重叠检测:若某天有正向跨天夜班(19:00→07:00),且次日存在同员工、同上班时间的反向跨天班(夜转白 19:00→13:00)
// 则正向跨天班的下班时间校验跳过(实际下班按反向跨天班 13:00 算),但上班打卡仍然校验
// 创建映射表,记录被反向跨天班覆盖的记录
Map<String, LocalTime> backwardCoveredByEmployee = new HashMap<>();
for (WmsAttendanceScheduleVo s : toProcess) {
// 如果是跨天班且是反向跨天班,记录该员工在特定日期的上班时间
if (isCrossDayShift(s) && isBackwardCrossDay(s) && s.getEmployeeName() != null) {
LocalDate workLd = toLocalDate(s.getWorkDate());
String key = s.getEmployeeName() + "_" + workLd.minusDays(1);
backwardCoveredByEmployee.put(key, toLocalTime(s.getShiftStartTime()));
}
}
// 记录需要覆盖下班检查的考勤计划ID
Set<Long> endCheckOverridden = new HashSet<>();
for (WmsAttendanceScheduleVo s : toProcess) {
// 如果是正向跨天班,检查是否有对应的反向跨天班覆盖
if (isCrossDayShift(s) && !isBackwardCrossDay(s) && s.getEmployeeName() != null && s.getShiftStartTime() != null) {
LocalDate workLd = toLocalDate(s.getWorkDate());
String key = s.getEmployeeName() + "_" + workLd;
LocalTime backwardStart = backwardCoveredByEmployee.get(key);
if (backwardStart != null && backwardStart.equals(toLocalTime(s.getShiftStartTime()))) {
endCheckOverridden.add(s.getScheduleId());
}
}
}
// 获取受影响的用户ID集合
Set<Long> affectedUserIds = toProcess.stream()
.map(WmsAttendanceScheduleVo::getUserId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
// 按员工预取考勤记录
Map<String, List<AttendanceRecords>> recordsByEmployee = prefetchAttendanceRecords(toProcess);
// 获取所有待处理考勤计划的ID列表
List<Long> scheduleIds = toProcess.stream()
.map(WmsAttendanceScheduleVo::getScheduleId)
.collect(Collectors.toList());
// 批量删除这些考勤计划对应的考勤检查记录
batchDeleteByScheduleIds(scheduleIds);
// 创建要插入的考勤检查记录列表
List<WmsAttendanceCheck> checksToInsert = new ArrayList<>(toProcess.size());
// 遍历待处理的考勤计划,构建考勤检查记录
for (WmsAttendanceScheduleVo schedule : toProcess) {
boolean crossDay = isCrossDayShift(schedule);
boolean backward = isBackwardCrossDay(schedule);
// 根据考勤计划和考勤记录,按天切片考勤记录
List<AttendanceRecords> records = sliceRecordsForDay(
recordsByEmployee.get(schedule.getEmployeeName()),
schedule.getWorkDate(),
crossDay, backward);
// 构建考勤检查记录并添加到插入列表
checksToInsert.add(buildCheck(schedule, rule, records, crossDay, endCheckOverridden));
}
// 批量插入考勤检查记录
baseMapper.insertBatch(checksToInsert, BATCH_SIZE);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void recalcContinuousAbsent(AttendanceCheckBo bo) {
LocalDate startLocal = toLocalDate(bo.getStartDate());
LocalDate endLocal = toLocalDate(bo.getEndDate());
Set<Long> affectedUserIds;
if (bo.getUserIds() != null && !bo.getUserIds().isEmpty()) {
affectedUserIds = new HashSet<>(bo.getUserIds());
} else {
List<WmsAttendanceCheck> checks = baseMapper.selectList(Wrappers.<WmsAttendanceCheck>lambdaQuery()
.select(WmsAttendanceCheck::getUserId)
.ge(WmsAttendanceCheck::getWorkDate, toDate(startLocal.atStartOfDay()))
.le(WmsAttendanceCheck::getWorkDate, toDate(endLocal.atTime(LocalTime.of(23, 59, 59))))
.eq(WmsAttendanceCheck::getDelFlag, 0));
affectedUserIds = checks.stream()
.map(WmsAttendanceCheck::getUserId)
.collect(Collectors.toSet());
}
updateContinuousAbsent(startLocal, endLocal, affectedUserIds);
}
private boolean isCrossDayShift(WmsAttendanceScheduleVo schedule) {
return schedule.getShiftIsCrossDay() != null && schedule.getShiftIsCrossDay() == 1;
}
private boolean isBackwardCrossDay(WmsAttendanceScheduleVo schedule) {
if (!isCrossDayShift(schedule)) return false;
if (schedule.getShiftEndTime() == null) return false;
return toLocalTime(schedule.getShiftEndTime()).getHour() >= 12;
}
private WmsAttendanceRule getActiveRule() {
LambdaQueryWrapper<WmsAttendanceRule> wrapper = Wrappers.lambdaQuery();
wrapper.eq(WmsAttendanceRule::getDelFlag, 0);
wrapper.last("LIMIT 1");
WmsAttendanceRule rule = ruleMapper.selectOne(wrapper);
if (rule == null) {
rule = new WmsAttendanceRule();
rule.setLateWarn(3L);
rule.setLateOne(5L);
rule.setLateTwo(15L);
rule.setDeductOne(new BigDecimal("10"));
rule.setDeductTwo(new BigDecimal("30"));
rule.setAbsentHalfDay(15L);
rule.setContinuousAbsentDays(3L);
}
return rule;
}
/**
* 一次 SQL 批量查询所有员工在日期范围内的打卡记录(用 IN/eq 精确匹配,走索引)。
*/
private Map<String, List<AttendanceRecords>> prefetchAttendanceRecords(List<WmsAttendanceScheduleVo> toProcess) {
Map<String, LocalDate> minByName = new HashMap<>();
Map<String, LocalDate> maxByName = new HashMap<>();
for (WmsAttendanceScheduleVo s : toProcess) {
String name = s.getEmployeeName();
LocalDate ld = toLocalDate(s.getWorkDate());
minByName.merge(name, ld, (a, b) -> a.isBefore(b) ? a : b);
maxByName.merge(name, ld, (a, b) -> a.isAfter(b) ? a : b);
}
if (minByName.isEmpty()) {
return new HashMap<>();
}
LocalDate globalMin = minByName.values().stream().min(LocalDate::compareTo).get();
LocalDate globalMax = maxByName.values().stream().max(LocalDate::compareTo).get();
Date rangeStart = toDate(globalMin.atStartOfDay());
Date rangeEnd = toDate(globalMax.plusDays(1).atTime(LocalTime.of(23, 59, 59)));
List<String> allNames = new ArrayList<>(minByName.keySet());
List<AttendanceRecords> allRecords = attendanceRecordsService.queryListByEnamesAndDateRange(
allNames, rangeStart, rangeEnd);
Map<String, List<AttendanceRecords>> out = new HashMap<>();
for (AttendanceRecords r : allRecords) {
out.computeIfAbsent(r.getEname(), k -> new ArrayList<>()).add(r);
}
for (String name : allNames) {
out.computeIfAbsent(name, k -> new ArrayList<>());
}
return out;
}
private List<AttendanceRecords> sliceRecordsForDay(List<AttendanceRecords> prefetched, Date workDate, boolean crossDay, boolean backward) {
if (prefetched == null || prefetched.isEmpty()) {
return new ArrayList<>();
}
LocalDate ld = toLocalDate(workDate);
LocalDateTime rangeStart = backward
? ld.minusDays(1).atStartOfDay()
: ld.atStartOfDay();
LocalDateTime rangeEnd = crossDay && !backward
? ld.plusDays(1).atTime(LocalTime.of(23, 59, 59))
: ld.atTime(LocalTime.of(23, 59, 59));
return prefetched.stream()
.filter(r -> {
LocalDateTime ct = toLocalDateTime(r.getChecktime());
return ct != null && !ct.isBefore(rangeStart) && !ct.isAfter(rangeEnd);
})
.sorted(Comparator.comparing(AttendanceRecords::getChecktime))
.collect(Collectors.toList());
}
private void batchDeleteByScheduleIds(List<Long> scheduleIds) {
if (scheduleIds.isEmpty()) {
return;
}
for (int i = 0; i < scheduleIds.size(); i += BATCH_SIZE) {
int to = Math.min(i + BATCH_SIZE, scheduleIds.size());
List<Long> chunk = scheduleIds.subList(i, to);
baseMapper.delete(Wrappers.<WmsAttendanceCheck>lambdaQuery()
.in(WmsAttendanceCheck::getScheduleId, chunk));
}
}
/**
* 构建考勤检查对象,根据排班信息和考勤记录生成考勤结果
*
* @param schedule 排班信息对象
* @param rule 考勤规则对象
* @param records 考勤记录列表
* @param crossDay 是否跨天
* @param endCheckOverridden 已覆盖的结束检查集合
* @return 构建好的考勤检查对象
*/
private WmsAttendanceCheck buildCheck(WmsAttendanceScheduleVo schedule, WmsAttendanceRule rule,
List<AttendanceRecords> records, boolean crossDay,
Set<Long> endCheckOverridden) {
// 创建考勤检查对象
WmsAttendanceCheck check = new WmsAttendanceCheck();
// 设置排班基本信息
check.setScheduleId(schedule.getScheduleId());
check.setUserId(schedule.getUserId());
check.setEmployeeName(schedule.getEmployeeName());
check.setWorkDate(schedule.getWorkDate());
check.setShiftId(schedule.getShiftId());
check.setShiftName(schedule.getShiftName());
check.setShiftType(schedule.getShiftType());
// 判断是否有第二时段
boolean hasPeriod2 = schedule.getShiftStartTime2() != null && schedule.getShiftEndTime2() != null;
// 判断是否为向后跨天
boolean backward = isBackwardCrossDay(schedule);
// 处理时段设置
if (hasPeriod2) {
// 设置第一时段开始时间,考虑跨天情况
check.setP1StartTime(crossDay && backward
? combinePrevDay(schedule.getWorkDate(), schedule.getShiftStartTime())
: combineTime(schedule.getWorkDate(), schedule.getShiftStartTime()));
// 设置第一时段结束时间,考虑跨天情况
check.setP1EndTime(crossDay && !backward
? combineNextDay(schedule.getWorkDate(), schedule.getShiftEndTime())
: combineTime(schedule.getWorkDate(), schedule.getShiftEndTime()));
check.setP2StartTime(combineTime(schedule.getWorkDate(), schedule.getShiftStartTime2()));
check.setP2EndTime(combineTime(schedule.getWorkDate(), schedule.getShiftEndTime2()));
} else {
check.setP1StartTime(crossDay && backward
? combinePrevDay(schedule.getWorkDate(), schedule.getShiftStartTime())
: combineTime(schedule.getWorkDate(), schedule.getShiftStartTime()));
check.setP1EndTime(crossDay && !backward
? combineNextDay(schedule.getWorkDate(), schedule.getShiftEndTime())
: combineTime(schedule.getWorkDate(), schedule.getShiftEndTime()));
}
if (records.isEmpty()) {
check.setP1Status("missed");
if (hasPeriod2) {
check.setP2Status("missed");
}
check.setOverallStatus("absent_full");
check.setAbsentType("full_day");
return check;
}
if (hasPeriod2) {
LocalTime p1End = toLocalTime(schedule.getShiftEndTime());
LocalTime p2Start = toLocalTime(schedule.getShiftStartTime2());
// 按理论时间范围分割p1End之前、中间区间、p2Start之后
List<AttendanceRecords> beforeGap = new ArrayList<>();
List<AttendanceRecords> inGap = new ArrayList<>();
List<AttendanceRecords> afterGap = new ArrayList<>();
for (AttendanceRecords r : records) {
LocalTime t = toLocalDateTime(r.getChecktime()).toLocalTime();
if (!t.isAfter(p1End)) {
beforeGap.add(r);
} else if (!t.isBefore(p2Start)) {
afterGap.add(r);
} else {
inGap.add(r);
}
}
List<AttendanceRecords> p1Records = new ArrayList<>();
List<AttendanceRecords> p2Records = new ArrayList<>();
boolean lunchPatternFound = false;
// 检测[p1End, p2Start]区间内是否有午休打卡(取最远两条判断>10分钟按最大连续间隔切开
if (inGap.size() >= 2) {
LocalDateTime firstInGap = toLocalDateTime(inGap.get(0).getChecktime());
LocalDateTime lastInGap = toLocalDateTime(inGap.get(inGap.size() - 1).getChecktime());
if (Duration.between(firstInGap, lastInGap).getSeconds() > 600) {
int splitIdx = 0;
long maxGap = 0;
for (int i = 0; i < inGap.size() - 1; i++) {
LocalDateTime t1 = toLocalDateTime(inGap.get(i).getChecktime());
LocalDateTime t2 = toLocalDateTime(inGap.get(i + 1).getChecktime());
long gap = Duration.between(t1, t2).getSeconds();
if (gap > maxGap) {
maxGap = gap;
splitIdx = i;
}
if (gap > 600) {
break;
}
}
p1Records = new ArrayList<>(beforeGap);
p1Records.addAll(inGap.subList(0, splitIdx + 1));
p2Records = new ArrayList<>(inGap.subList(splitIdx + 1, inGap.size()));
p2Records.addAll(afterGap);
lunchPatternFound = true;
}
}
// 检测边界beforeGap最后一条 与 inGap第一条 间隔>10分钟提前下班+午饭回来)
if (!lunchPatternFound && !beforeGap.isEmpty() && !inGap.isEmpty()) {
LocalDateTime lastBefore = toLocalDateTime(beforeGap.get(beforeGap.size() - 1).getChecktime());
LocalDateTime firstIn = toLocalDateTime(inGap.get(0).getChecktime());
if (Duration.between(lastBefore, firstIn).getSeconds() > 600) {
p1Records = new ArrayList<>(beforeGap);
p2Records = new ArrayList<>(inGap);
p2Records.addAll(afterGap);
lunchPatternFound = true;
}
}
// 检测边界inGap最后一条 与 afterGap第一条 间隔>10分钟午休晚归
if (!lunchPatternFound && !inGap.isEmpty() && !afterGap.isEmpty()) {
LocalDateTime lastIn = toLocalDateTime(inGap.get(inGap.size() - 1).getChecktime());
LocalDateTime firstAfter = toLocalDateTime(afterGap.get(0).getChecktime());
if (Duration.between(lastIn, firstAfter).getSeconds() > 600) {
p1Records = new ArrayList<>(beforeGap);
p1Records.addAll(inGap);
p2Records = new ArrayList<>(afterGap);
lunchPatternFound = true;
}
}
if (!lunchPatternFound) {
// 没有午休打卡模式,回退原中值法
LocalTime split = LocalTime.of(
(p1End.getHour() + p2Start.getHour()) / 2,
(p1End.getMinute() + p2Start.getMinute()) / 2);
p1Records = new ArrayList<>();
p2Records = new ArrayList<>();
for (AttendanceRecords r : records) {
LocalTime t = toLocalDateTime(r.getChecktime()).toLocalTime();
if (t.isBefore(split)) {
p1Records.add(r);
} else {
p2Records.add(r);
}
}
}
checkPeriod(check, rule, 1, p1Records, check.getP1StartTime(), check.getP1EndTime());
checkPeriod(check, rule, 2, p2Records, check.getP2StartTime(), check.getP2EndTime());
} else {
List<AttendanceRecords> filtered = filterWindow(records, schedule.getWorkDate(),
schedule.getShiftStartTime(), schedule.getShiftEndTime(), crossDay);
Date actualEnd = endCheckOverridden.contains(schedule.getScheduleId())
? null : check.getP1EndTime();
checkPeriod(check, rule, 1, filtered, check.getP1StartTime(), actualEnd);
}
calculateOverall(check, rule);
return check;
}
private List<AttendanceRecords> filterWindow(List<AttendanceRecords> records, Date workDate,
Date expectedStart, Date expectedEnd, boolean crossDay) {
LocalDate ld = toLocalDate(workDate);
LocalTime st = toLocalTime(expectedStart);
LocalTime et = toLocalTime(expectedEnd);
LocalDateTime windowStart;
LocalDateTime windowEnd;
boolean backward = crossDay && et != null && et.getHour() >= 12;
if (crossDay && backward) {
windowStart = st != null
? LocalDateTime.of(ld.minusDays(1), st).minusHours(2)
: LocalDateTime.of(ld, et).minusHours(2);
windowEnd = LocalDateTime.of(ld, et).plusHours(2);
} else if (crossDay) {
windowStart = LocalDateTime.of(ld, st).minusHours(2);
windowEnd = LocalDateTime.of(ld.plusDays(1), et).plusHours(2);
} else if (st != null && et != null) {
windowStart = LocalDateTime.of(ld, st).minusHours(2);
windowEnd = LocalDateTime.of(ld, et).plusHours(2);
} else if (st != null) {
windowStart = LocalDateTime.of(ld, st).minusHours(2);
windowEnd = LocalDateTime.of(ld, st).plusHours(2);
} else {
windowStart = LocalDateTime.of(ld, et).minusHours(2);
windowEnd = LocalDateTime.of(ld, et).plusHours(2);
}
LocalDateTime finalStart = windowStart;
LocalDateTime finalEnd = windowEnd;
return records.stream()
.filter(r -> {
LocalDateTime ct = toLocalDateTime(r.getChecktime());
return !ct.isBefore(finalStart) && !ct.isAfter(finalEnd);
})
.collect(Collectors.toList());
}
private void checkPeriod(WmsAttendanceCheck check, WmsAttendanceRule rule, int period,
List<AttendanceRecords> periodRecords, Date expectedStart, Date expectedEnd) {
// 旷工0条打卡记录
if (periodRecords.isEmpty()) {
if (period == 1) {
check.setP1Status("missed");
} else {
check.setP2Status("missed");
}
return;
}
LocalDateTime expStart = toLocalDateTime(expectedStart);
LocalDateTime expEnd = toLocalDateTime(expectedEnd);
AttendanceRecords firstRec = periodRecords.get(0);
AttendanceRecords lastRec = periodRecords.get(periodRecords.size() - 1);
LocalDateTime firstCheck = toLocalDateTime(firstRec.getChecktime());
LocalDateTime lastCheck = toLocalDateTime(lastRec.getChecktime());
int lateMinutes = 0;
int earlyMinutes = 0;
BigDecimal deduct = BigDecimal.ZERO;
String status = "normal";
boolean startMissed = false;
boolean endMissed = false;
// 上班检测:晚于理论上班时间俩小时以上 → 漏打卡
if (expStart != null) {
long lateSecs = Duration.between(expStart, firstCheck).getSeconds();
if (lateSecs > LATE_EARLY_MAX_SECONDS) {
startMissed = true;
} else if (lateSecs > 0) {
lateMinutes = (int) (lateSecs / 60);
if (lateMinutes > rule.getAbsentHalfDay()) {
status = "absent_half";
} else if (lateMinutes > rule.getLateOne()) {
status = "late_two";
deduct = deduct.add(rule.getDeductTwo());
} else if (lateMinutes > rule.getLateWarn()) {
status = "late_one";
deduct = deduct.add(rule.getDeductOne());
} else {
status = "late_warn";
}
}
}
// 下班检测:早于理论下班时间俩小时以上 → 漏打卡
if (expEnd != null && lastCheck.isBefore(expEnd)) {
long earlySecs = Duration.between(lastCheck, expEnd).getSeconds();
if (earlySecs > LATE_EARLY_MAX_SECONDS) {
endMissed = true;
} else if (earlySecs > 0) {
earlyMinutes = (int) (earlySecs / 60);
if (earlyMinutes > rule.getAbsentHalfDay()) {
status = maxSeverity(status, "absent_half");
} else if (earlyMinutes > rule.getLateOne()) {
status = maxSeverity(status, "early_two");
deduct = deduct.add(rule.getDeductTwo());
} else if (earlyMinutes > rule.getLateWarn()) {
status = maxSeverity(status, "early_one");
deduct = deduct.add(rule.getDeductOne());
} else {
if ("normal".equals(status)) {
status = "early_warn";
}
}
}
}
// 上班漏打卡或下班漏打卡会覆盖原有状态
if (startMissed && endMissed) {
status = "missed";
} else if (startMissed) {
status = maxSeverity(status, "missed_start");
} else if (endMissed) {
status = maxSeverity(status, "missed_end");
}
if (period == 1) {
check.setP1FirstCheck(startMissed ? null : firstRec.getChecktime());
check.setP1LastCheck(endMissed ? null : lastRec.getChecktime());
check.setP1LateMinutes(lateMinutes);
check.setP1EarlyMinutes(earlyMinutes);
check.setP1Status(status);
check.setP1Deduct(deduct);
} else {
check.setP2FirstCheck(startMissed ? null : firstRec.getChecktime());
check.setP2LastCheck(endMissed ? null : lastRec.getChecktime());
check.setP2LateMinutes(lateMinutes);
check.setP2EarlyMinutes(earlyMinutes);
check.setP2Status(status);
check.setP2Deduct(deduct);
}
}
private String maxSeverity(String a, String b) {
int ai = STATUS_SEVERITY.indexOf(a);
int bi = STATUS_SEVERITY.indexOf(b);
return STATUS_SEVERITY.get(Math.max(ai, bi));
}
private void calculateOverall(WmsAttendanceCheck check, WmsAttendanceRule rule) {
BigDecimal total = BigDecimal.ZERO;
boolean hasAbsentHalf = false;
if (check.getP1Deduct() != null) total = total.add(check.getP1Deduct());
if (check.getP2Deduct() != null) total = total.add(check.getP2Deduct());
check.setTotalDeduct(total);
if ("absent_half".equals(check.getP1Status()) || "absent_half".equals(check.getP2Status())) {
hasAbsentHalf = true;
}
boolean p1Missed = "missed".equals(check.getP1Status());
boolean p2Missed = check.getP2Status() != null && "missed".equals(check.getP2Status());
boolean hasP2 = check.getP2StartTime() != null;
if (p1Missed) {
if (hasP2 && !p2Missed) {
check.setAbsentType("half_day");
check.setOverallStatus("absent_half");
return;
} else {
check.setAbsentType("full_day");
check.setOverallStatus("absent_full");
return;
}
}
if (p2Missed) {
check.setAbsentType("half_day");
check.setOverallStatus("absent_half");
return;
}
if (check.getP1StartTime() == null && check.getP2StartTime() == null) {
check.setAbsentType("full_day");
check.setOverallStatus("absent_full");
return;
}
if (hasAbsentHalf) {
check.setAbsentType("half_day");
check.setOverallStatus("absent_half");
return;
}
boolean abnormal = !"normal".equals(check.getP1Status()) || (check.getP2Status() != null && !"normal".equals(check.getP2Status()));
check.setOverallStatus(abnormal ? "abnormal" : "normal");
}
private void updateContinuousAbsent(LocalDate startDate, LocalDate endDate, Set<Long> affectedUserIds) {
if (affectedUserIds.isEmpty()) {
return;
}
List<WmsAttendanceCheck> checks = baseMapper.selectList(Wrappers.<WmsAttendanceCheck>lambdaQuery()
.in(WmsAttendanceCheck::getUserId, affectedUserIds)
.ge(WmsAttendanceCheck::getWorkDate, toDate(startDate.atStartOfDay()))
.le(WmsAttendanceCheck::getWorkDate, toDate(endDate.atTime(LocalTime.of(23, 59, 59))))
.eq(WmsAttendanceCheck::getDelFlag, 0));
if (checks.isEmpty()) {
return;
}
List<Long> userIds = checks.stream()
.map(WmsAttendanceCheck::getUserId)
.distinct()
.collect(Collectors.toList());
Map<Long, Map<LocalDate, WmsAttendanceCheck>> historyByUserDate = loadAbsentHistoryByUser(userIds, endDate);
List<WmsAttendanceCheck> toUpdate = new ArrayList<>();
for (WmsAttendanceCheck check : checks) {
if (check.getAbsentType() != null) {
int continuous = countContinuousAbsentCached(
check.getUserId(), toLocalDate(check.getWorkDate()), historyByUserDate);
check.setContinuousAbsentDays(continuous);
toUpdate.add(check);
}
}
if (!toUpdate.isEmpty()) {
baseMapper.updateBatchById(toUpdate, BATCH_SIZE);
}
}
/**
* 加载各员工在 endDate 及之前的考勤结果,用于内存计算连续旷工(与按天 selectOne 结果一致)。
*/
private Map<Long, Map<LocalDate, WmsAttendanceCheck>> loadAbsentHistoryByUser(List<Long> userIds, LocalDate endDate) {
Map<Long, Map<LocalDate, WmsAttendanceCheck>> out = new HashMap<>();
LocalDate startDate = endDate.minusDays(100);
Date start = toDate(startDate.atStartOfDay());
Date end = toDate(endDate.atTime(LocalTime.of(23, 59, 59)));
for (int i = 0; i < userIds.size(); i += BATCH_SIZE) {
int to = Math.min(i + BATCH_SIZE, userIds.size());
List<Long> chunk = userIds.subList(i, to);
List<WmsAttendanceCheck> rows = baseMapper.selectList(Wrappers.<WmsAttendanceCheck>lambdaQuery()
.select(WmsAttendanceCheck::getUserId, WmsAttendanceCheck::getWorkDate, WmsAttendanceCheck::getAbsentType,
WmsAttendanceCheck::getCheckId, WmsAttendanceCheck::getDelFlag)
.in(WmsAttendanceCheck::getUserId, chunk)
.ge(WmsAttendanceCheck::getWorkDate, start)
.le(WmsAttendanceCheck::getWorkDate, end)
.eq(WmsAttendanceCheck::getDelFlag, 0));
for (WmsAttendanceCheck row : rows) {
LocalDate wd = toLocalDate(row.getWorkDate());
out.computeIfAbsent(row.getUserId(), k -> new HashMap<>()).put(wd, row);
}
}
return out;
}
private int countContinuousAbsentCached(Long userId, LocalDate workDate,
Map<Long, Map<LocalDate, WmsAttendanceCheck>> historyByUserDate) {
Map<LocalDate, WmsAttendanceCheck> userMap = historyByUserDate.get(userId);
if (userMap == null) {
return 0;
}
int count = 0;
LocalDate date = workDate.minusDays(1);
while (true) {
WmsAttendanceCheck prev = userMap.get(date);
if (prev != null && prev.getAbsentType() != null) {
count++;
date = date.minusDays(1);
} else {
break;
}
}
return count;
}
private static LocalDate toLocalDate(Date date) {
return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
}
private static LocalTime toLocalTime(Date date) {
if (date == null) return null;
return date.toInstant().atZone(ZoneId.systemDefault()).toLocalTime();
}
private static LocalDateTime toLocalDateTime(Date date) {
if (date == null) return null;
return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
}
private static Date toDate(LocalDateTime ldt) {
return Date.from(ldt.atZone(ZoneId.systemDefault()).toInstant());
}
private static Date combineTime(Date workDate, Date shiftTime) {
if (workDate == null || shiftTime == null) return null;
LocalDate ld = toLocalDate(workDate);
LocalTime lt = toLocalTime(shiftTime);
return toDate(LocalDateTime.of(ld, lt));
}
private static Date combineNextDay(Date workDate, Date shiftTime) {
if (workDate == null || shiftTime == null) return null;
LocalDate ld = toLocalDate(workDate).plusDays(1);
LocalTime lt = toLocalTime(shiftTime);
return toDate(LocalDateTime.of(ld, lt));
}
private static Date combinePrevDay(Date workDate, Date shiftTime) {
if (workDate == null || shiftTime == null) return null;
LocalDate ld = toLocalDate(workDate).minusDays(1);
LocalTime lt = toLocalTime(shiftTime);
return toDate(LocalDateTime.of(ld, lt));
}
}