yuan
5 小时以前 ef1052e7bf9bf1765d61776bc49ce69a23c6cdee
src/main/java/com/ruoyi/staff/service/impl/SalaryStatServiceImpl.java
@@ -10,20 +10,33 @@
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;
/**
 * 员工当月请假次数、缺勤次数、迟到次数、早退次数统计
@@ -61,14 +74,20 @@
    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    员工姓名(用于匹配审批申请人)
@@ -81,12 +100,15 @@
        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;
        }
        fillAttendanceStat(stat, staffOnJobId, month);
        List<PersonalAttendanceRecords> records = listAttendanceRecords(staffOnJobId, month);
        fillAttendanceStat(stat, records);
        fillOvertimeStat(stat, staffOnJobId, month, records);
        List<ApprovalInstance> leaveList = listLeaveInstances(staffName, month);
        stat.setLeaveCount(leaveList.size());
        stat.setLeaveReason(joinLeaveReason(leaveList));
@@ -94,14 +116,20 @@
    }
    /**
     * 考勤统计:缺勤次数、迟到次数、早退次数(迟到早退同时计入两边)
     * 查询员工指定月份的打卡记录
     */
    private void fillAttendanceStat(SalaryStat stat, Long staffOnJobId, YearMonth month) {
        List<PersonalAttendanceRecords> records = personalAttendanceRecordsMapper.selectList(
    private List<PersonalAttendanceRecords> listAttendanceRecords(Long staffOnJobId, YearMonth month) {
        return personalAttendanceRecordsMapper.selectList(
                new LambdaQueryWrapper<PersonalAttendanceRecords>()
                        .eq(PersonalAttendanceRecords::getStaffOnJobId, staffOnJobId)
                        .ge(PersonalAttendanceRecords::getDate, month.atDay(1))
                        .le(PersonalAttendanceRecords::getDate, month.atEndOfMonth()));
    }
    /**
     * 考勤统计:缺勤次数、迟到次数、早退次数(迟到早退同时计入两边)
     */
    private void fillAttendanceStat(SalaryStat stat, List<PersonalAttendanceRecords> records) {
        int absenceCount = 0;
        int lateCount = 0;
        int earlyCount = 0;
@@ -127,6 +155,85 @@
    }
    /**
     * 加班时长统计:
     * 按员工当月每天排班对应班次的上下班时间,与当天的打卡时间比较;
     * 早到(排班上班时间 - 实际打卡上班时间)满 60 分钟才计入,
     * 晚走(实际打卡下班时间 - 排班下班时间)满 60 分钟才计入,
     * 两段分钟数累加后换算成小时,保留两位小数。
     */
    private void fillOvertimeStat(SalaryStat stat, Long staffOnJobId, YearMonth month,
                                  List<PersonalAttendanceRecords> records) {
        stat.setOvertimeHours(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP));
        if (records.isEmpty()) {
            return;
        }
        List<PersonalShift> shifts = personalShiftMapper.selectList(
                new LambdaQueryWrapper<PersonalShift>()
                        .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<Integer> configIds = shifts.stream()
                .map(PersonalShift::getPersonalAttendanceLocationConfigId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        if (configIds.isEmpty()) {
            return;
        }
        Map<Integer, PersonalAttendanceLocationConfig> configMap = personalAttendanceLocationConfigMapper
                .selectBatchIds(configIds).stream()
                .collect(Collectors.toMap(PersonalAttendanceLocationConfig::getId, config -> config,
                        (first, second) -> first));
        Map<LocalDate, PersonalAttendanceRecords> recordMap = records.stream()
                .filter(record -> record.getDate() != null)
                .collect(Collectors.toMap(PersonalAttendanceRecords::getDate, record -> record,
                        (first, second) -> first));
        Map<LocalDate, PersonalShift> shiftMap = new HashMap<>();
        for (PersonalShift shift : shifts) {
            if (shift.getWorkTime() != null) {
                shiftMap.putIfAbsent(shift.getWorkTime().toLocalDate(), shift);
            }
        }
        long overtimeMinutes = 0L;
        for (Map.Entry<LocalDate, PersonalShift> 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));
        }
    }
    /**
     * 查询员工归属当月的已通过请假申请,按请假时间升序
     * <p>
     * 先按申请人查出所有已通过的请假(请假管理、请假审批两种业务类型都兼容),