yuan
6 小时以前 ef1052e7bf9bf1765d61776bc49ce69a23c6cdee
feat: 添加加班时长和加班工资字段到员工工资明细
已修改9个文件
155 ■■■■■ 文件已修改
sql/20261019_staff_salary_detail_add_leave_attendance_deduct.sql 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/product-inventory-management-new-pro.sql 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/staff/dto/SalaryStat.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/staff/excel/StaffSalaryExportData.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/staff/pojo/StaffSalaryDetail.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/staff/service/impl/SalaryStatServiceImpl.java 117 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/staff/service/impl/SchemeApplicableStaffServiceImpl.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/staff/service/impl/StaffSalaryMainServiceImpl.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/staff/StaffSalaryDetailMapper.xml 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/20261019_staff_salary_detail_add_leave_attendance_deduct.sql
@@ -8,7 +8,7 @@
    ADD COLUMN `absence_count` int NULL DEFAULT 0 COMMENT '缺勤次数' AFTER `leave_count`,
    ADD COLUMN `late_count` int NULL DEFAULT 0 COMMENT '迟到次数' AFTER `absence_count`,
    ADD COLUMN `early_count` int NULL DEFAULT 0 COMMENT '早退次数' AFTER `late_count`,
    ADD COLUMN `manual_deduct` decimal(10, 2) NULL DEFAULT 0.00 COMMENT '应扣工资(手动填写)' AFTER `early_count`;
ALTER TABLE staff_salary_detail
MODIFY COLUMN remark text NULL COMMENT '备注';
    ADD COLUMN `manual_deduct` decimal(10, 2) NULL DEFAULT 0.00 COMMENT '应扣工资(手动填写)' AFTER `early_count`,
    ADD COLUMN `overtime_hours` decimal(10, 2) NULL DEFAULT 0.00 COMMENT '加班时长(小时)' AFTER `hourly_salary`,
    ADD COLUMN `overtime_pay` decimal(10, 2) NULL DEFAULT 0.00 COMMENT '加班工资(手动填写)' AFTER `overtime_hours`,
    MODIFY COLUMN remark text NULL COMMENT '备注';
sql/product-inventory-management-new-pro.sql
@@ -4666,6 +4666,8 @@
  `basic_salary` decimal(10, 2) NULL DEFAULT 0.00 COMMENT '基本工资',
  `piece_salary` decimal(10, 2) NULL DEFAULT 0.00 COMMENT '计件工资',
  `hourly_salary` decimal(10, 2) NULL DEFAULT 0.00 COMMENT '计时工资',
  `overtime_hours` decimal(10, 2) NULL DEFAULT 0.00 COMMENT '加班时长(小时)',
  `overtime_pay` decimal(10, 2) NULL DEFAULT 0.00 COMMENT '加班工资(手动填写)',
  `other_income` decimal(10, 2) NULL DEFAULT 0.00 COMMENT '其他收入',
  `social_personal` decimal(10, 2) NULL DEFAULT 0.00 COMMENT '社保个人',
  `fund_personal` decimal(10, 2) NULL DEFAULT 0.00 COMMENT '公积金个人',
src/main/java/com/ruoyi/staff/dto/SalaryStat.java
@@ -3,6 +3,8 @@
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
/**
 * 员工当月请假、考勤统计结果
 */
@@ -21,6 +23,9 @@
    @Schema(description = "早退次数")
    private Integer earlyCount;
    @Schema(description = "加班时长(小时)")
    private BigDecimal overtimeHours;
    @Schema(description = "请假原因,多次请假用;拼接")
    private String leaveReason;
}
src/main/java/com/ruoyi/staff/excel/StaffSalaryExportData.java
@@ -32,6 +32,12 @@
    @Excel(name = "计时工资")
    private BigDecimal hourlySalary;
    @Excel(name = "加班时长")
    private BigDecimal overtimeHours;
    @Excel(name = "加班工资")
    private BigDecimal overtimePay;
    @Excel(name = "其他收入")
    private BigDecimal otherIncome;
src/main/java/com/ruoyi/staff/pojo/StaffSalaryDetail.java
@@ -56,6 +56,12 @@
    @Schema(description = "计时工资")
    private BigDecimal hourlySalary;
    @Schema(description = "加班时长(小时)")
    private BigDecimal overtimeHours;
    @Schema(description = "加班工资(手动填写)")
    private BigDecimal overtimePay;
    @Schema(description = "其他收入")
    private BigDecimal otherIncome;
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>
     * 先按申请人查出所有已通过的请假(请假管理、请假审批两种业务类型都兼容),
src/main/java/com/ruoyi/staff/service/impl/SchemeApplicableStaffServiceImpl.java
@@ -174,6 +174,8 @@
                : calculateLatestDeptSocialSupplementAmount(staffOnJobDto);
        BigDecimal pieceSalary = normalizeMoney(getBigDecimal(map, "pieceSalary"));
        BigDecimal hourlySalary = normalizeMoney(getBigDecimal(map, "hourlySalary"));
        // 加班工资:手工填写,不按倍率自动计算
        BigDecimal overtimePay = normalizeMoney(getBigDecimal(map, "overtimePay"));
        BigDecimal socialPersonal = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
        BigDecimal fundPersonal = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
@@ -204,7 +206,7 @@
            }
        }
        BigDecimal grossSalary = basicSalary.add(pieceSalary).add(hourlySalary).add(otherIncome);
        BigDecimal grossSalary = basicSalary.add(pieceSalary).add(hourlySalary).add(overtimePay).add(otherIncome);
        BigDecimal salaryTax = TaxCalculator.calculateMonthlyTax(
                grossSalary,
                socialPersonal.add(socialSupplementAmount),
@@ -231,6 +233,8 @@
        map.put("basicSalary", basicSalary);
        map.put("pieceSalary", pieceSalary);
        map.put("hourlySalary", hourlySalary);
        map.put("overtimeHours", salaryStat.getOvertimeHours());
        map.put("overtimePay", overtimePay);
        map.put("otherIncome", otherIncome);
        map.put("socialPersonal", socialPersonal);
        map.put("fundPersonal", fundPersonal);
src/main/java/com/ruoyi/staff/service/impl/StaffSalaryMainServiceImpl.java
@@ -210,6 +210,7 @@
        }
        map.put("pieceSalary", staffSalaryCalculateDto.getPieceSalary());
        map.put("hourlySalary", staffSalaryCalculateDto.getHourlySalary());
        map.put("overtimePay", staffSalaryCalculateDto.getOvertimePay());
        map.put("otherIncome", staffSalaryCalculateDto.getOtherIncome());
        map.put("socialPersonal", staffSalaryCalculateDto.getSocialPersonal());
        map.put("fundPersonal", staffSalaryCalculateDto.getFundPersonal());
@@ -256,6 +257,8 @@
                exportData.setBasicSalary(detail.getBasicSalary());
                exportData.setPieceSalary(detail.getPieceSalary());
                exportData.setHourlySalary(detail.getHourlySalary());
                exportData.setOvertimeHours(detail.getOvertimeHours() == null ? BigDecimal.ZERO : detail.getOvertimeHours());
                exportData.setOvertimePay(detail.getOvertimePay() == null ? BigDecimal.ZERO : detail.getOvertimePay());
                exportData.setOtherIncome(detail.getOtherIncome());
                exportData.setSocialPersonal(detail.getSocialPersonal());
                exportData.setFundPersonal(detail.getFundPersonal());
src/main/resources/mapper/staff/StaffSalaryDetailMapper.xml
@@ -13,6 +13,8 @@
        <result column="basic_salary" property="basicSalary" />
        <result column="piece_salary" property="pieceSalary" />
        <result column="hourly_salary" property="hourlySalary" />
        <result column="overtime_hours" property="overtimeHours" />
        <result column="overtime_pay" property="overtimePay" />
        <result column="other_income" property="otherIncome" />
        <result column="social_personal" property="socialPersonal" />
        <result column="fund_personal" property="fundPersonal" />