feat: 添加员工工资明细中的请假、缺勤、迟到、早退次数及手动扣工资字段
已添加3个文件
已修改6个文件
525 ■■■■■ 文件已修改
sql/20261019_staff_salary_detail_add_leave_attendance_deduct.sql 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/product-inventory-management-new-pro.sql 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/staff/dto/SalaryStat.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/staff/excel/StaffSalaryExportData.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/staff/pojo/StaffSalaryDetail.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/staff/service/impl/SalaryStatServiceImpl.java 415 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/staff/service/impl/SchemeApplicableStaffServiceImpl.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/staff/service/impl/StaffSalaryMainServiceImpl.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/staff/StaffSalaryDetailMapper.xml 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/20261019_staff_salary_detail_add_leave_attendance_deduct.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,14 @@
-- ----------------------------
-- å‘˜å·¥å·¥èµ„明细新增:请假次数、缺勤次数、迟到次数、早退次数、扣工资(手填)
-- åˆ—位置:应扣工资(deduct_salary) ä¹‹åŽã€å®žå‘工资(net_salary) ä¹‹å‰
-- æ‰§è¡ŒçŽ¯å¢ƒï¼šå·²æœ‰åº“å‡çº§ï¼ˆå…¨é‡è„šæœ¬ product-inventory-management-new-pro.sql å·²åŒæ­¥ï¼‰
-- ----------------------------
ALTER TABLE `staff_salary_detail`
    ADD COLUMN `leave_count` int NULL DEFAULT 0 COMMENT '请假次数' AFTER `deduct_salary`,
    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 '备注';
sql/product-inventory-management-new-pro.sql
@@ -4673,6 +4673,11 @@
  `salary_tax` decimal(10, 2) NULL DEFAULT 0.00 COMMENT '工资个税',
  `gross_salary` decimal(10, 2) NULL DEFAULT 0.00 COMMENT '应发工资',
  `deduct_salary` decimal(10, 2) NULL DEFAULT 0.00 COMMENT '应扣工资',
  `leave_count` int NULL DEFAULT 0 COMMENT '请假次数',
  `absence_count` int NULL DEFAULT 0 COMMENT '缺勤次数',
  `late_count` int NULL DEFAULT 0 COMMENT '迟到次数',
  `early_count` int NULL DEFAULT 0 COMMENT '早退次数',
  `manual_deduct` decimal(10, 2) NULL DEFAULT 0.00 COMMENT '扣工资(手动填写)',
  `net_salary` decimal(10, 2) NULL DEFAULT 0.00 COMMENT '实发工资',
  `remark` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '备注',
  `create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
src/main/java/com/ruoyi/staff/dto/SalaryStat.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
package com.ruoyi.staff.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
 * å‘˜å·¥å½“月请假、考勤统计结果
 */
@Data
public class SalaryStat {
    @Schema(description = "请假次数")
    private Integer leaveCount;
    @Schema(description = "缺勤次数")
    private Integer absenceCount;
    @Schema(description = "迟到次数")
    private Integer lateCount;
    @Schema(description = "早退次数")
    private Integer earlyCount;
    @Schema(description = "请假原因,多次请假用;拼接")
    private String leaveReason;
}
src/main/java/com/ruoyi/staff/excel/StaffSalaryExportData.java
@@ -56,6 +56,21 @@
    @Excel(name = "应扣工资")
    private BigDecimal deductSalary;
    @Excel(name = "请假次数")
    private Integer leaveCount;
    @Excel(name = "缺勤次数")
    private Integer absenceCount;
    @Excel(name = "迟到次数")
    private Integer lateCount;
    @Excel(name = "早退次数")
    private Integer earlyCount;
    @Excel(name = "扣工资")
    private BigDecimal manualDeduct;
    @Excel(name = "实发工资")
    private BigDecimal netSalary;
src/main/java/com/ruoyi/staff/pojo/StaffSalaryDetail.java
@@ -80,6 +80,21 @@
    @Schema(description = "应扣工资")
    private BigDecimal deductSalary;
    @Schema(description = "请假次数")
    private Integer leaveCount;
    @Schema(description = "缺勤次数")
    private Integer absenceCount;
    @Schema(description = "迟到次数")
    private Integer lateCount;
    @Schema(description = "早退次数")
    private Integer earlyCount;
    @Schema(description = "扣工资(手动填写)")
    private BigDecimal manualDeduct;
    @Schema(description = "实发工资")
    private BigDecimal netSalary;
src/main/java/com/ruoyi/staff/service/impl/SalaryStatServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,415 @@
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;
        }
    }
}
src/main/java/com/ruoyi/staff/service/impl/SchemeApplicableStaffServiceImpl.java
@@ -13,6 +13,7 @@
import com.ruoyi.project.system.mapper.SysDeptMapper;
import com.ruoyi.project.system.mapper.SysUserMapper;
import com.ruoyi.staff.controller.TaxCalculator;
import com.ruoyi.staff.dto.SalaryStat;
import com.ruoyi.staff.mapper.SchemeApplicableStaffMapper;
import com.ruoyi.staff.mapper.SchemeInsuranceDetailMapper;
import com.ruoyi.staff.mapper.StaffOnJobMapper;
@@ -50,6 +51,7 @@
    private final SysDeptMapper sysDeptMapper;
    private final StaffOnJobMapper staffOnJobMapper;
    private final SalesLedgerProductionAccountingService salesLedgerProductionAccountingService;
    private final SalaryStatServiceImpl salaryStatService;
    @Override
@@ -213,7 +215,12 @@
                .add(socialPersonal)
                .add(socialSupplementAmount)
                .add(otherDeduct);
        BigDecimal netSalary = grossSalary.subtract(deductSalary);
        // æ‰£å·¥èµ„(手动填写),从实发工资中扣除
        BigDecimal manualDeduct = normalizeMoney(getBigDecimal(map, "manualDeduct"));
        BigDecimal netSalary = grossSalary.subtract(deductSalary).subtract(manualDeduct);
        // è¯·å‡æ¬¡æ•°ã€ç¼ºå‹¤æ¬¡æ•°ã€è¿Ÿåˆ°æ¬¡æ•°ã€æ—©é€€æ¬¡æ•°ï¼Œè¯·å‡åŽŸå› è‡ªåŠ¨å¸¦å…¥å¤‡æ³¨ï¼ˆå¤‡æ³¨å·²å¡«å†™æ—¶ä¿ç•™åŽŸå¤‡æ³¨ï¼‰
        SalaryStat salaryStat = salaryStatService.stat(staffId, staffOnJobDto.getStaffName(), date);
        if (!hasText(map, "staffName")) {
            map.put("staffName", staffOnJobDto.getStaffName());
@@ -232,7 +239,15 @@
        map.put("grossSalary", grossSalary);
        map.put("salaryTax", salaryTax);
        map.put("deductSalary", deductSalary);
        map.put("leaveCount", salaryStat.getLeaveCount());
        map.put("absenceCount", salaryStat.getAbsenceCount());
        map.put("lateCount", salaryStat.getLateCount());
        map.put("earlyCount", salaryStat.getEarlyCount());
        map.put("manualDeduct", manualDeduct);
        map.put("netSalary", netSalary);
        if (!hasText(map, "remark") && StringUtils.isNotEmpty(salaryStat.getLeaveReason())) {
            map.put("remark", salaryStat.getLeaveReason());
        }
    }
    /**
src/main/java/com/ruoyi/staff/service/impl/StaffSalaryMainServiceImpl.java
@@ -25,6 +25,7 @@
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
@@ -217,6 +218,11 @@
        map.put("salaryTax", staffSalaryCalculateDto.getSalaryTax());
        map.put("grossSalary", staffSalaryCalculateDto.getGrossSalary());
        map.put("deductSalary", staffSalaryCalculateDto.getDeductSalary());
        map.put("leaveCount", staffSalaryCalculateDto.getLeaveCount());
        map.put("absenceCount", staffSalaryCalculateDto.getAbsenceCount());
        map.put("lateCount", staffSalaryCalculateDto.getLateCount());
        map.put("earlyCount", staffSalaryCalculateDto.getEarlyCount());
        map.put("manualDeduct", staffSalaryCalculateDto.getManualDeduct());
        map.put("netSalary", staffSalaryCalculateDto.getNetSalary());
        map.put("remark", staffSalaryCalculateDto.getRemark());
        schemeApplicableStaffService.calculateByEmployeeId(
@@ -258,6 +264,11 @@
                exportData.setSalaryTax(detail.getSalaryTax());
                exportData.setGrossSalary(detail.getGrossSalary());
                exportData.setDeductSalary(detail.getDeductSalary());
                exportData.setLeaveCount(detail.getLeaveCount() == null ? 0 : detail.getLeaveCount());
                exportData.setAbsenceCount(detail.getAbsenceCount() == null ? 0 : detail.getAbsenceCount());
                exportData.setLateCount(detail.getLateCount() == null ? 0 : detail.getLateCount());
                exportData.setEarlyCount(detail.getEarlyCount() == null ? 0 : detail.getEarlyCount());
                exportData.setManualDeduct(detail.getManualDeduct() == null ? BigDecimal.ZERO : detail.getManualDeduct());
                exportData.setNetSalary(detail.getNetSalary());
                exportData.setRemark(detail.getRemark());
                exportDataList.add(exportData);
src/main/resources/mapper/staff/StaffSalaryDetailMapper.xml
@@ -17,15 +17,22 @@
        <result column="social_personal" property="socialPersonal" />
        <result column="fund_personal" property="fundPersonal" />
        <result column="other_deduct" property="otherDeduct" />
        <result column="social_supplement_amount" property="socialSupplementAmount" />
        <result column="salary_tax" property="salaryTax" />
        <result column="gross_salary" property="grossSalary" />
        <result column="deduct_salary" property="deductSalary" />
        <result column="leave_count" property="leaveCount" />
        <result column="absence_count" property="absenceCount" />
        <result column="late_count" property="lateCount" />
        <result column="early_count" property="earlyCount" />
        <result column="manual_deduct" property="manualDeduct" />
        <result column="net_salary" property="netSalary" />
        <result column="remark" property="remark" />
        <result column="create_time" property="createTime" />
        <result column="create_user" property="createUser" />
        <result column="update_time" property="updateTime" />
        <result column="update_user" property="updateUser" />
        <result column="dept_id" property="deptId" />
    </resultMap>
</mapper>