package com.klp.service.impl; 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.List; import java.util.Map; import java.util.stream.Collectors; @RequiredArgsConstructor @Service public class WmsAttendanceCheckServiceImpl implements IWmsAttendanceCheckService { private static final List STATUS_SEVERITY = java.util.Arrays.asList("normal", "late_warn", "early_warn", "late_one", "early_one", "late_two", "early_two", "absent_half"); 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 queryPageList(WmsAttendanceCheckBo bo, PageQuery pageQuery) { LambdaQueryWrapper lqw = buildQueryWrapper(bo); Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); return TableDataInfo.build(result); } @Override public List queryList(WmsAttendanceCheckBo bo) { LambdaQueryWrapper lqw = buildQueryWrapper(bo); return baseMapper.selectVoList(lqw); } @Override public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { return baseMapper.deleteBatchIds(ids) > 0; } private LambdaQueryWrapper buildQueryWrapper(WmsAttendanceCheckBo bo) { LambdaQueryWrapper 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.orderByDesc(WmsAttendanceCheck::getWorkDate); return lqw; } @Override @Transactional(rollbackFor = Exception.class) public void checkAttendance(AttendanceCheckBo bo) { WmsAttendanceScheduleBo scheduleBo = new WmsAttendanceScheduleBo(); scheduleBo.setStartDate(bo.getStartDate()); scheduleBo.setEndDate(bo.getEndDate()); List schedules = scheduleService.queryList(scheduleBo); if (schedules.isEmpty()) { return; } WmsAttendanceRule rule = getActiveRule(); LocalDate startLocal = toLocalDate(bo.getStartDate()); LocalDate endLocal = toLocalDate(bo.getEndDate()); List 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; } Map> recordsByEmployee = prefetchAttendanceRecords(toProcess); List scheduleIds = toProcess.stream() .map(WmsAttendanceScheduleVo::getScheduleId) .collect(Collectors.toList()); batchDeleteByScheduleIds(scheduleIds); List checksToInsert = new ArrayList<>(toProcess.size()); for (WmsAttendanceScheduleVo schedule : toProcess) { boolean crossDay = isCrossDayShift(schedule); List records = sliceRecordsForDay( recordsByEmployee.get(schedule.getEmployeeName()), schedule.getWorkDate(), crossDay); checksToInsert.add(buildCheck(schedule, rule, records)); } baseMapper.insertBatch(checksToInsert, BATCH_SIZE); updateContinuousAbsent(startLocal, endLocal); } private boolean isCrossDayShift(WmsAttendanceScheduleVo schedule) { return schedule.getShiftIsCrossDay() != null && schedule.getShiftIsCrossDay() == 1; } private WmsAttendanceRule getActiveRule() { LambdaQueryWrapper 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; } /** * 按员工姓名合并时间范围,每人只查一次打卡(与原先按排班逐条查询结果一致)。 */ private Map> prefetchAttendanceRecords(List toProcess) { Map minByName = new HashMap<>(); Map 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); } Map> out = new HashMap<>(minByName.size() * 2); for (Map.Entry e : minByName.entrySet()) { String name = e.getKey(); LocalDate minLd = e.getValue(); LocalDate maxLd = maxByName.get(name); out.put(name, fetchRecordsForNameRange(name, minLd, maxLd)); } return out; } private List fetchRecordsForNameRange(String employeeName, LocalDate minLd, LocalDate maxLd) { AttendanceRecordsBo recordsBo = new AttendanceRecordsBo(); recordsBo.setEname(employeeName); recordsBo.setChecktimeStart(toDate(minLd.atStartOfDay())); recordsBo.setChecktimeEnd(toDate(maxLd.plusDays(1).atTime(LocalTime.of(23, 59, 59)))); List voList = attendanceRecordsService.queryList(recordsBo); return voList.stream() .map(v -> { AttendanceRecords r = new AttendanceRecords(); r.setId(v.getId()); r.setEname(v.getEname()); r.setChecktime(v.getChecktime()); return r; }) .sorted(Comparator.comparing(AttendanceRecords::getChecktime)) .collect(Collectors.toList()); } /** * 与原先 {@code getRecords(ename, workDate, crossDay)} 的时间窗口一致。 */ private List sliceRecordsForDay(List prefetched, Date workDate, boolean crossDay) { if (prefetched == null || prefetched.isEmpty()) { return new ArrayList<>(); } LocalDate ld = toLocalDate(workDate); LocalDateTime rangeStart = ld.atStartOfDay(); LocalDateTime rangeEnd = crossDay ? 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 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 chunk = scheduleIds.subList(i, to); baseMapper.delete(Wrappers.lambdaQuery() .in(WmsAttendanceCheck::getScheduleId, chunk)); } } private WmsAttendanceCheck buildCheck(WmsAttendanceScheduleVo schedule, WmsAttendanceRule rule, List records) { 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 crossDay = isCrossDayShift(schedule); if (records.isEmpty()) { check.setOverallStatus("absent_full"); check.setAbsentType("full_day"); return check; } if (hasPeriod2) { LocalTime p1End = toLocalTime(schedule.getShiftEndTime()); LocalTime p2Start = toLocalTime(schedule.getShiftStartTime2()); LocalTime split = LocalTime.of( (p1End.getHour() + p2Start.getHour()) / 2, (p1End.getMinute() + p2Start.getMinute()) / 2); List p1Records = new ArrayList<>(); List p2Records = new ArrayList<>(); for (AttendanceRecords r : records) { LocalTime t = toLocalDateTime(r.getChecktime()).toLocalTime(); if (t.isBefore(split)) { p1Records.add(r); } else { p2Records.add(r); } } check.setP1StartTime(schedule.getShiftStartTime()); check.setP1EndTime(schedule.getShiftEndTime()); checkPeriod(check, rule, 1, p1Records, schedule.getShiftStartTime(), schedule.getShiftEndTime()); check.setP2StartTime(schedule.getShiftStartTime2()); check.setP2EndTime(schedule.getShiftEndTime2()); checkPeriod(check, rule, 2, p2Records, schedule.getShiftStartTime2(), schedule.getShiftEndTime2()); } else { List filtered = filterWindow(records, schedule.getWorkDate(), schedule.getShiftStartTime(), schedule.getShiftEndTime(), crossDay); check.setP1StartTime(schedule.getShiftStartTime()); check.setP1EndTime(schedule.getShiftEndTime()); checkPeriod(check, rule, 1, filtered, schedule.getShiftStartTime(), schedule.getShiftEndTime()); } calculateOverall(check, rule); return check; } private List filterWindow(List 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; 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 periodRecords, Date expectedStart, Date expectedEnd) { if (periodRecords.isEmpty()) { if (period == 1) { check.setP1Status("missed"); } else { check.setP2Status("missed"); } return; } LocalTime expStart = toLocalTime(expectedStart); LocalTime expEnd = toLocalTime(expectedEnd); AttendanceRecords firstRec = periodRecords.get(0); AttendanceRecords lastRec = periodRecords.get(periodRecords.size() - 1); LocalTime firstCheck = toLocalDateTime(firstRec.getChecktime()).toLocalTime(); LocalTime lastCheck = toLocalDateTime(lastRec.getChecktime()).toLocalTime(); int lateMinutes = 0; int earlyMinutes = 0; BigDecimal deduct = BigDecimal.ZERO; String status = "normal"; if (expStart != null && firstCheck.isAfter(expStart)) { lateMinutes = (int) Duration.between(expStart, firstCheck).toMinutes(); 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)) { int min = (int) Duration.between(lastCheck, expEnd).toMinutes(); if (min > rule.getAbsentHalfDay()) { status = maxSeverity(status, "absent_half"); earlyMinutes = min; } else if (min > rule.getLateOne()) { status = maxSeverity(status, "early_two"); deduct = deduct.add(rule.getDeductTwo()); earlyMinutes = min; } else if (min > rule.getLateWarn()) { status = maxSeverity(status, "early_one"); deduct = deduct.add(rule.getDeductOne()); earlyMinutes = min; } else { if ("normal".equals(status)) { status = "early_warn"; } earlyMinutes = min; } } if (period == 1) { check.setP1FirstCheck(firstRec.getChecktime()); check.setP1LastCheck(lastRec.getChecktime()); check.setP1LateMinutes(lateMinutes); check.setP1EarlyMinutes(earlyMinutes); check.setP1Status(status); check.setP1Deduct(deduct); } else { check.setP2FirstCheck(firstRec.getChecktime()); check.setP2LastCheck(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; } if ("missed".equals(check.getP1Status()) && check.getP2StartTime() != null && "missed".equals(check.getP2Status())) { check.setAbsentType("full_day"); check.setOverallStatus("absent_full"); 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) { List checks = baseMapper.selectList(Wrappers.lambdaQuery() .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 userIds = checks.stream() .map(WmsAttendanceCheck::getUserId) .distinct() .collect(Collectors.toList()); Map> historyByUserDate = loadAbsentHistoryByUser(userIds, endDate); List 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> loadAbsentHistoryByUser(List userIds, LocalDate endDate) { Map> out = new HashMap<>(); 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 chunk = userIds.subList(i, to); List rows = baseMapper.selectList(Wrappers.lambdaQuery() .select(WmsAttendanceCheck::getUserId, WmsAttendanceCheck::getWorkDate, WmsAttendanceCheck::getAbsentType, WmsAttendanceCheck::getCheckId, WmsAttendanceCheck::getDelFlag) .in(WmsAttendanceCheck::getUserId, chunk) .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> historyByUserDate) { Map 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()); } }