package com.ruoyi.staff.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.ruoyi.approve.mapper.ApprovalInstanceMapper; import com.ruoyi.approve.pojo.ApprovalInstance; import com.ruoyi.common.enums.TypeEnums; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.project.system.domain.SysUser; import com.ruoyi.project.system.mapper.SysUserMapper; import com.ruoyi.staff.dto.SalaryStat; import com.ruoyi.staff.mapper.PersonalAttendanceLocationConfigMapper; import com.ruoyi.staff.mapper.PersonalAttendanceRecordsMapper; import com.ruoyi.staff.mapper.PersonalShiftMapper; import com.ruoyi.staff.pojo.PersonalAttendanceLocationConfig; import com.ruoyi.staff.pojo.PersonalAttendanceRecords; import com.ruoyi.staff.pojo.PersonalShift; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.YearMonth; import java.time.format.DateTimeFormatter; 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; /** * 员工当月请假次数、缺勤次数、迟到次数、早退次数统计 *

* 请假数据取协同审批中的请假申请(approval_instance.business_type 为 2「请假管理」或 14「请假审批」, * 审批状态为已通过),按请假时间(表单中的开始/结束时间)与工资月份是否重叠判断归属月份, * 老数据没有表单时间时退回按申请时间判断; * 缺勤、迟到、早退数据取个人考勤记录表 personal_attendance_records 的 status 字段 * (0正常 1迟到 2早退 3迟到早退 4缺勤),系统定时任务每天凌晨会给没有打卡的在职员工自动补一条缺勤记录。 *

*/ @Slf4j @Service @RequiredArgsConstructor public class SalaryStatServiceImpl { /** 考勤状态:迟到 */ private static final int ATTENDANCE_STATUS_LATE = 1; /** 考勤状态:早退 */ private static final int ATTENDANCE_STATUS_EARLY = 2; /** 考勤状态:迟到早退 */ private static final int ATTENDANCE_STATUS_LATE_AND_EARLY = 3; /** 考勤状态:缺勤(定时任务为未打卡员工自动生成) */ private static final int ATTENDANCE_STATUS_ABSENCE = 4; /** 同一员工当月多次请假时,请假原因拼接分隔符 */ private static final String LEAVE_REASON_SEPARATOR = ";"; /** staff_salary_detail.remark 字段长度,超长截断,避免写库报错 */ private static final int REMARK_MAX_LENGTH = 200; /** 请假审批已通过状态 */ private static final String LEAVE_STATUS_APPROVED = "APPROVED"; /** 审批逻辑删除:未删除 */ private static final Byte NOT_DELETED = (byte) 0; private static final DateTimeFormatter MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM"); /** 表单中不需要拼接进请假原因的字段关键字(时间范围、附件等) */ private static final String[] IGNORE_LABEL_KEYWORDS = {"时间", "日期", "申请人", "部门", "附件", "图片", "上传"}; /** 请假原因字段关键字,命中则优先作为请假原因 */ private static final String[] REASON_LABEL_KEYWORDS = {"原因", "事由", "理由", "说明"}; /** 加班起算分钟数:早到、晚走均需满 60 分钟才计入加班时长 */ private static final long OVERTIME_MIN_MINUTES = 60L; /** 分钟换算小时的除数 */ private static final BigDecimal MINUTES_PER_HOUR = BigDecimal.valueOf(60); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private final PersonalAttendanceRecordsMapper personalAttendanceRecordsMapper; private final PersonalShiftMapper personalShiftMapper; private final PersonalAttendanceLocationConfigMapper personalAttendanceLocationConfigMapper; private final ApprovalInstanceMapper approvalInstanceMapper; private final SysUserMapper sysUserMapper; /** * 统计员工指定月份的请假次数、缺勤次数、迟到次数、早退次数、加班时长和请假原因 * * @param staffOnJobId staff_on_job 表主键 * @param staffName 员工姓名(用于匹配审批申请人) * @param salaryMonth 工资月份,格式 yyyy-MM * @return 统计结果,参数不合法时返回次数为 0 的结果 */ public SalaryStat stat(Long staffOnJobId, String staffName, String salaryMonth) { SalaryStat stat = new SalaryStat(); stat.setLeaveCount(0); stat.setAbsenceCount(0); stat.setLateCount(0); stat.setEarlyCount(0); stat.setOvertimeHours(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)); stat.setLeaveReason(null); YearMonth month = parseMonth(salaryMonth); if (staffOnJobId == null || month == null) { return stat; } List records = listAttendanceRecords(staffOnJobId, month); fillAttendanceStat(stat, records); fillOvertimeStat(stat, staffOnJobId, month, records); List leaveList = listLeaveInstances(staffName, month); stat.setLeaveCount(leaveList.size()); stat.setLeaveReason(joinLeaveReason(leaveList)); return stat; } /** * 查询员工指定月份的打卡记录 */ private List listAttendanceRecords(Long staffOnJobId, YearMonth month) { return personalAttendanceRecordsMapper.selectList( new LambdaQueryWrapper() .eq(PersonalAttendanceRecords::getStaffOnJobId, staffOnJobId) .ge(PersonalAttendanceRecords::getDate, month.atDay(1)) .le(PersonalAttendanceRecords::getDate, month.atEndOfMonth())); } /** * 考勤统计:缺勤次数、迟到次数、早退次数(迟到早退同时计入两边) */ private void fillAttendanceStat(SalaryStat stat, List records) { int absenceCount = 0; int lateCount = 0; int earlyCount = 0; for (PersonalAttendanceRecords record : records) { Integer status = record.getStatus(); if (status == null) { continue; } if (status == ATTENDANCE_STATUS_ABSENCE) { absenceCount++; } else if (status == ATTENDANCE_STATUS_LATE) { lateCount++; } else if (status == ATTENDANCE_STATUS_EARLY) { earlyCount++; } else if (status == ATTENDANCE_STATUS_LATE_AND_EARLY) { lateCount++; earlyCount++; } } stat.setAbsenceCount(absenceCount); stat.setLateCount(lateCount); stat.setEarlyCount(earlyCount); } /** * 加班时长统计: * 按员工当月每天排班对应班次的上下班时间,与当天的打卡时间比较; * 早到(排班上班时间 - 实际打卡上班时间)满 60 分钟才计入, * 晚走(实际打卡下班时间 - 排班下班时间)满 60 分钟才计入, * 两段分钟数累加后换算成小时,保留两位小数。 */ private void fillOvertimeStat(SalaryStat stat, Long staffOnJobId, YearMonth month, List records) { stat.setOvertimeHours(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)); if (records.isEmpty()) { return; } List shifts = personalShiftMapper.selectList( new LambdaQueryWrapper() .eq(PersonalShift::getStaffOnJobId, staffOnJobId) .isNotNull(PersonalShift::getPersonalAttendanceLocationConfigId) .ge(PersonalShift::getWorkTime, month.atDay(1).atStartOfDay()) .lt(PersonalShift::getWorkTime, month.plusMonths(1).atDay(1).atStartOfDay())); if (shifts.isEmpty()) { return; } Set configIds = shifts.stream() .map(PersonalShift::getPersonalAttendanceLocationConfigId) .filter(Objects::nonNull) .collect(Collectors.toSet()); if (configIds.isEmpty()) { return; } Map configMap = personalAttendanceLocationConfigMapper .selectBatchIds(configIds).stream() .collect(Collectors.toMap(PersonalAttendanceLocationConfig::getId, config -> config, (first, second) -> first)); Map recordMap = records.stream() .filter(record -> record.getDate() != null) .collect(Collectors.toMap(PersonalAttendanceRecords::getDate, record -> record, (first, second) -> first)); Map shiftMap = new HashMap<>(); for (PersonalShift shift : shifts) { if (shift.getWorkTime() != null) { shiftMap.putIfAbsent(shift.getWorkTime().toLocalDate(), shift); } } long overtimeMinutes = 0L; for (Map.Entry entry : shiftMap.entrySet()) { LocalDate workDate = entry.getKey(); PersonalAttendanceRecords record = recordMap.get(workDate); PersonalAttendanceLocationConfig config = configMap.get(entry.getValue().getPersonalAttendanceLocationConfigId()); if (record == null || config == null) { continue; } LocalTime startAt = config.getStartAt(); LocalTime endAt = config.getEndAt(); if (startAt != null && record.getWorkStartAt() != null) { long earlyMinutes = Duration.between(record.getWorkStartAt(), LocalDateTime.of(workDate, startAt)).toMinutes(); if (earlyMinutes >= OVERTIME_MIN_MINUTES) { overtimeMinutes += earlyMinutes; } } if (endAt != null && record.getWorkEndAt() != null) { LocalDateTime scheduledEnd = LocalDateTime.of(workDate, endAt); // 跨天班次(如 22:00-次日 06:00)下班时间按次日计算 if (startAt == null || !endAt.isAfter(startAt)) { scheduledEnd = scheduledEnd.plusDays(1); } long lateMinutes = Duration.between(scheduledEnd, record.getWorkEndAt()).toMinutes(); if (lateMinutes >= OVERTIME_MIN_MINUTES) { overtimeMinutes += lateMinutes; } } } if (overtimeMinutes > 0) { stat.setOvertimeHours(BigDecimal.valueOf(overtimeMinutes) .divide(MINUTES_PER_HOUR, 2, RoundingMode.HALF_UP)); } } /** * 查询员工归属当月的已通过请假申请,按请假时间升序 *

* 先按申请人查出所有已通过的请假(请假管理、请假审批两种业务类型都兼容), * 再按请假时间与工资月份是否重叠过滤:跨月请假(如上月27日到本月2日)在两个月的工资表里都能统计到。 * 老数据的审批表单没有时间字段时,退回按申请时间判断所属月份。 *

*/ private List listLeaveInstances(String staffName, YearMonth month) { List applicantIds = resolveApplicantIds(staffName); if (applicantIds.isEmpty() && StringUtils.isEmpty(staffName)) { return Collections.emptyList(); } LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.in(ApprovalInstance::getBusinessType, TypeEnums.LEAVE.getCode(), TypeEnums.LEAVE_APPROVAL.getCode()) .eq(ApprovalInstance::getStatus, LEAVE_STATUS_APPROVED) .eq(ApprovalInstance::getDeleted, NOT_DELETED) .orderByAsc(ApprovalInstance::getApplyTime); if (applicantIds.isEmpty()) { wrapper.eq(ApprovalInstance::getApplicantName, staffName); } else { String name = staffName; wrapper.and(item -> item.in(ApprovalInstance::getApplicantId, applicantIds) .or().eq(ApprovalInstance::getApplicantName, name)); } List allList = approvalInstanceMapper.selectList(wrapper); List monthList = new ArrayList<>(); for (ApprovalInstance instance : allList) { if (matchMonth(instance, month)) { monthList.add(instance); } } return monthList; } /** * 判断请假是否属于当月:优先看请假时间与当月是否重叠,没有请假时间时看申请时间 */ private boolean matchMonth(ApprovalInstance instance, YearMonth month) { List leaveDates = extractLeaveDates(instance.getFormConfig()); if (!leaveDates.isEmpty()) { LocalDate begin = Collections.min(leaveDates); LocalDate end = Collections.max(leaveDates); return !end.isBefore(month.atDay(1)) && !begin.isAfter(month.atEndOfMonth()); } if (instance.getApplyTime() == null) { return false; } return YearMonth.from(instance.getApplyTime()).equals(month); } /** * 从审批表单的填报值里取出请假开始/结束时间 */ private List extractLeaveDates(String formConfig) { if (StringUtils.isEmpty(formConfig)) { return Collections.emptyList(); } List dates = new ArrayList<>(); try { JsonNode payload = OBJECT_MAPPER.readTree(formConfig).path("formPayload"); if (!payload.isObject()) { return dates; } payload.fields().forEachRemaining(entry -> collectDates(entry.getValue(), dates)); } catch (Exception e) { log.warn("解析请假审批表单时间失败:{}", e.getMessage()); } return dates; } private void collectDates(JsonNode node, List dates) { if (node == null || node.isNull() || node.isMissingNode()) { return; } if (node.isArray()) { for (JsonNode item : node) { collectDates(item, dates); } return; } LocalDate date = parseDate(node.asText("")); if (date != null) { dates.add(date); } } /** * 解析 yyyy-MM-dd / yyyy-MM-dd HH:mm:ss / yyyy/M/d 等格式的日期 */ private LocalDate parseDate(String text) { if (StringUtils.isEmpty(text)) { return null; } String value = text.trim().replace('/', '-'); if (value.length() < 8) { return null; } String head = value.length() > 10 ? value.substring(0, 10) : value; String[] parts = head.split("-"); if (parts.length != 3) { return null; } try { int year = Integer.parseInt(parts[0]); int month = Integer.parseInt(parts[1]); int day = Integer.parseInt(parts[2]); return LocalDate.of(year, month, day); } catch (Exception e) { return null; } } /** * 通过员工姓名匹配系统用户(审批申请人 = 系统用户) */ private List resolveApplicantIds(String staffName) { if (StringUtils.isEmpty(staffName)) { return Collections.emptyList(); } List userList = sysUserMapper.selectList(new LambdaQueryWrapper() .eq(SysUser::getUserName, staffName) .eq(SysUser::getDelFlag, "0")); List userIds = new ArrayList<>(); for (SysUser user : userList) { if (user.getUserId() != null) { userIds.add(user.getUserId()); } } return userIds; } /** * 拼接请假原因,多次请假用;分割,并去重 */ private String joinLeaveReason(List leaveList) { Set reasonSet = new LinkedHashSet<>(); for (ApprovalInstance instance : leaveList) { String reason = extractLeaveReason(instance); if (StringUtils.isNotEmpty(reason)) { reasonSet.add(reason); } } if (reasonSet.isEmpty()) { return null; } String joined = String.join(LEAVE_REASON_SEPARATOR, reasonSet); if (joined.length() > REMARK_MAX_LENGTH) { joined = joined.substring(0, REMARK_MAX_LENGTH - 1) + "…"; } return joined; } /** * 从审批表单中提取请假原因: * 1. 优先取表单里「原因/事由/理由/说明」类字段; * 2. 没有原因字段时,取表单里除时间、申请人、附件以外的文本字段; * 3. 表单为空(老数据)时,退回审批标题。 */ private String extractLeaveReason(ApprovalInstance instance) { String formConfig = instance.getFormConfig(); if (StringUtils.isNotEmpty(formConfig)) { try { JsonNode root = OBJECT_MAPPER.readTree(formConfig); JsonNode payload = root.path("formPayload"); if (payload.isObject() && payload.size() > 0) { JsonNode fields = root.path("fields"); List reasonValues = new ArrayList<>(); List otherValues = new ArrayList<>(); if (fields.isArray()) { for (JsonNode field : fields) { String key = field.path("key").asText(""); if (StringUtils.isEmpty(key) || !payload.has(key)) { continue; } String label = field.path("label").asText(""); String type = field.path("type").asText(""); String value = nodeText(payload.get(key)); if (StringUtils.isEmpty(value) || isIgnoredField(label, type)) { continue; } if (isReasonField(label)) { reasonValues.add(value); } else { otherValues.add(value); } } } List values = reasonValues.isEmpty() ? otherValues : reasonValues; String joined = String.join(",", values); if (StringUtils.isNotEmpty(joined)) { return joined; } } } catch (Exception e) { log.warn("解析请假审批表单失败,instanceId={},原因:{}", instance.getId(), e.getMessage()); } } return cleanTitle(instance.getTitle()); } /** * 表单值转文本,数组用、拼接 */ private String nodeText(JsonNode node) { if (node == null || node.isNull() || node.isMissingNode()) { return null; } if (node.isArray()) { List texts = new ArrayList<>(); for (JsonNode item : node) { String text = nodeText(item); if (StringUtils.isNotEmpty(text)) { texts.add(text); } } return texts.isEmpty() ? null : String.join("、", texts); } String text = node.asText(""); if (StringUtils.isEmpty(text) || "null".equals(text)) { return null; } return text.trim(); } private boolean isIgnoredField(String label, String type) { String typeText = type == null ? "" : type.toLowerCase(); if (typeText.contains("date") || typeText.contains("time") || typeText.contains("file") || typeText.contains("image") || typeText.contains("upload")) { return true; } return containsAny(label, IGNORE_LABEL_KEYWORDS); } private boolean isReasonField(String label) { return containsAny(label, REASON_LABEL_KEYWORDS); } private boolean containsAny(String text, String[] keywords) { if (StringUtils.isEmpty(text)) { return false; } for (String keyword : keywords) { if (text.contains(keyword)) { return true; } } return false; } /** * 审批标题去掉「请假审批」前缀作为原因兜底,如「请假审批-调休 1 天」→「调休 1 天」 */ private String cleanTitle(String title) { if (StringUtils.isEmpty(title)) { return null; } String text = title.trim(); String[] prefixes = {"请假审批-", "请假审批", "请假申请-", "请假申请", "请假-"}; for (String prefix : prefixes) { if (text.startsWith(prefix)) { text = text.substring(prefix.length()).trim(); break; } } return text.isEmpty() ? null : text; } private YearMonth parseMonth(String salaryMonth) { if (StringUtils.isEmpty(salaryMonth)) { return null; } String text = salaryMonth.trim(); if (text.length() > 7) { text = text.substring(0, 7); } try { return YearMonth.parse(text, MONTH_FORMATTER); } catch (Exception e) { log.warn("工资月份格式不是 yyyy-MM,无法统计请假/考勤次数:{}", salaryMonth); return null; } } }