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.PersonalAttendanceRecordsMapper;
|
import com.ruoyi.staff.pojo.PersonalAttendanceRecords;
|
import lombok.RequiredArgsConstructor;
|
import lombok.extern.slf4j.Slf4j;
|
import org.springframework.stereotype.Service;
|
|
import java.time.LocalDate;
|
import java.time.YearMonth;
|
import java.time.format.DateTimeFormatter;
|
import java.util.ArrayList;
|
import java.util.Collections;
|
import java.util.LinkedHashSet;
|
import java.util.List;
|
import java.util.Set;
|
|
/**
|
* 员工当月请假次数、缺勤次数、迟到次数、早退次数统计
|
* <p>
|
* 请假数据取协同审批中的请假申请(approval_instance.business_type 为 2「请假管理」或 14「请假审批」,
|
* 审批状态为已通过),按请假时间(表单中的开始/结束时间)与工资月份是否重叠判断归属月份,
|
* 老数据没有表单时间时退回按申请时间判断;
|
* 缺勤、迟到、早退数据取个人考勤记录表 personal_attendance_records 的 status 字段
|
* (0正常 1迟到 2早退 3迟到早退 4缺勤),系统定时任务每天凌晨会给没有打卡的在职员工自动补一条缺勤记录。
|
* </p>
|
*/
|
@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 = {"原因", "事由", "理由", "说明"};
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
|
private final PersonalAttendanceRecordsMapper personalAttendanceRecordsMapper;
|
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.setLeaveReason(null);
|
YearMonth month = parseMonth(salaryMonth);
|
if (staffOnJobId == null || month == null) {
|
return stat;
|
}
|
fillAttendanceStat(stat, staffOnJobId, month);
|
List<ApprovalInstance> leaveList = listLeaveInstances(staffName, month);
|
stat.setLeaveCount(leaveList.size());
|
stat.setLeaveReason(joinLeaveReason(leaveList));
|
return stat;
|
}
|
|
/**
|
* 考勤统计:缺勤次数、迟到次数、早退次数(迟到早退同时计入两边)
|
*/
|
private void fillAttendanceStat(SalaryStat stat, Long staffOnJobId, YearMonth month) {
|
List<PersonalAttendanceRecords> records = personalAttendanceRecordsMapper.selectList(
|
new LambdaQueryWrapper<PersonalAttendanceRecords>()
|
.eq(PersonalAttendanceRecords::getStaffOnJobId, staffOnJobId)
|
.ge(PersonalAttendanceRecords::getDate, month.atDay(1))
|
.le(PersonalAttendanceRecords::getDate, month.atEndOfMonth()));
|
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);
|
}
|
|
/**
|
* 查询员工归属当月的已通过请假申请,按请假时间升序
|
* <p>
|
* 先按申请人查出所有已通过的请假(请假管理、请假审批两种业务类型都兼容),
|
* 再按请假时间与工资月份是否重叠过滤:跨月请假(如上月27日到本月2日)在两个月的工资表里都能统计到。
|
* 老数据的审批表单没有时间字段时,退回按申请时间判断所属月份。
|
* </p>
|
*/
|
private List<ApprovalInstance> listLeaveInstances(String staffName, YearMonth month) {
|
List<Long> applicantIds = resolveApplicantIds(staffName);
|
if (applicantIds.isEmpty() && StringUtils.isEmpty(staffName)) {
|
return Collections.emptyList();
|
}
|
|
LambdaQueryWrapper<ApprovalInstance> 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<ApprovalInstance> allList = approvalInstanceMapper.selectList(wrapper);
|
|
List<ApprovalInstance> monthList = new ArrayList<>();
|
for (ApprovalInstance instance : allList) {
|
if (matchMonth(instance, month)) {
|
monthList.add(instance);
|
}
|
}
|
return monthList;
|
}
|
|
/**
|
* 判断请假是否属于当月:优先看请假时间与当月是否重叠,没有请假时间时看申请时间
|
*/
|
private boolean matchMonth(ApprovalInstance instance, YearMonth month) {
|
List<LocalDate> 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<LocalDate> extractLeaveDates(String formConfig) {
|
if (StringUtils.isEmpty(formConfig)) {
|
return Collections.emptyList();
|
}
|
List<LocalDate> 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<LocalDate> 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<Long> resolveApplicantIds(String staffName) {
|
if (StringUtils.isEmpty(staffName)) {
|
return Collections.emptyList();
|
}
|
List<SysUser> userList = sysUserMapper.selectList(new LambdaQueryWrapper<SysUser>()
|
.eq(SysUser::getUserName, staffName)
|
.eq(SysUser::getDelFlag, "0"));
|
List<Long> userIds = new ArrayList<>();
|
for (SysUser user : userList) {
|
if (user.getUserId() != null) {
|
userIds.add(user.getUserId());
|
}
|
}
|
return userIds;
|
}
|
|
/**
|
* 拼接请假原因,多次请假用;分割,并去重
|
*/
|
private String joinLeaveReason(List<ApprovalInstance> leaveList) {
|
Set<String> 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<String> reasonValues = new ArrayList<>();
|
List<String> 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<String> 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<String> 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;
|
}
|
}
|
}
|