From 9010b5b3d735abc84c61a88eb2f1483c3acf440f Mon Sep 17 00:00:00 2001
From: yuan <123@>
Date: 星期一, 19 十月 2026 17:12:16 +0800
Subject: [PATCH] feat: 添加员工工资明细中的请假、缺勤、迟到、早退次数及手动扣工资字段

---
 sql/20261019_staff_salary_detail_add_leave_attendance_deduct.sql                 |   14 +
 src/main/java/com/ruoyi/staff/pojo/StaffSalaryDetail.java                        |   15 +
 src/main/resources/mapper/staff/StaffSalaryDetailMapper.xml                      |    7 
 src/main/java/com/ruoyi/staff/service/impl/StaffSalaryMainServiceImpl.java       |   11 +
 src/main/java/com/ruoyi/staff/service/impl/SchemeApplicableStaffServiceImpl.java |   17 +
 src/main/java/com/ruoyi/staff/dto/SalaryStat.java                                |   26 ++
 sql/product-inventory-management-new-pro.sql                                     |    5 
 src/main/java/com/ruoyi/staff/excel/StaffSalaryExportData.java                   |   15 +
 src/main/java/com/ruoyi/staff/service/impl/SalaryStatServiceImpl.java            |  415 ++++++++++++++++++++++++++++++++++++++++++++++
 9 files changed, 524 insertions(+), 1 deletions(-)

diff --git a/sql/20261019_staff_salary_detail_add_leave_attendance_deduct.sql b/sql/20261019_staff_salary_detail_add_leave_attendance_deduct.sql
new file mode 100644
index 0000000..f9a8b06
--- /dev/null
+++ b/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 '鎵e伐璧勶紙鎵嬪姩濉啓锛�' AFTER `early_count`;
+
+ALTER TABLE staff_salary_detail
+MODIFY COLUMN remark text NULL COMMENT '澶囨敞';
diff --git a/sql/product-inventory-management-new-pro.sql b/sql/product-inventory-management-new-pro.sql
index 71d483b..20746ca 100644
--- a/sql/product-inventory-management-new-pro.sql
+++ b/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 '鎵e伐璧勶紙鎵嬪姩濉啓锛�',
   `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 '鍒涘缓鏃堕棿',
diff --git a/src/main/java/com/ruoyi/staff/dto/SalaryStat.java b/src/main/java/com/ruoyi/staff/dto/SalaryStat.java
new file mode 100644
index 0000000..f107a17
--- /dev/null
+++ b/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;
+}
diff --git a/src/main/java/com/ruoyi/staff/excel/StaffSalaryExportData.java b/src/main/java/com/ruoyi/staff/excel/StaffSalaryExportData.java
index c1f2725..e466241 100644
--- a/src/main/java/com/ruoyi/staff/excel/StaffSalaryExportData.java
+++ b/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 = "鎵e伐璧�")
+    private BigDecimal manualDeduct;
+
     @Excel(name = "瀹炲彂宸ヨ祫")
     private BigDecimal netSalary;
 
diff --git a/src/main/java/com/ruoyi/staff/pojo/StaffSalaryDetail.java b/src/main/java/com/ruoyi/staff/pojo/StaffSalaryDetail.java
index e616c2b..a5841c1 100644
--- a/src/main/java/com/ruoyi/staff/pojo/StaffSalaryDetail.java
+++ b/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 = "鎵e伐璧勶紙鎵嬪姩濉啓锛�")
+    private BigDecimal manualDeduct;
+
     @Schema(description = "瀹炲彂宸ヨ祫")
     private BigDecimal netSalary;
 
diff --git a/src/main/java/com/ruoyi/staff/service/impl/SalaryStatServiceImpl.java b/src/main/java/com/ruoyi/staff/service/impl/SalaryStatServiceImpl.java
new file mode 100644
index 0000000..85df88c
--- /dev/null
+++ b/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姝e父 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("瑙f瀽璇峰亣瀹℃壒琛ㄥ崟鏃堕棿澶辫触锛歿}", 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);
+        }
+    }
+
+    /**
+     * 瑙f瀽 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("瑙f瀽璇峰亣瀹℃壒琛ㄥ崟澶辫触锛宨nstanceId={}锛屽師鍥狅細{}", 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;
+        }
+    }
+}
diff --git a/src/main/java/com/ruoyi/staff/service/impl/SchemeApplicableStaffServiceImpl.java b/src/main/java/com/ruoyi/staff/service/impl/SchemeApplicableStaffServiceImpl.java
index 60607ba..989af81 100644
--- a/src/main/java/com/ruoyi/staff/service/impl/SchemeApplicableStaffServiceImpl.java
+++ b/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);
+        // 鎵e伐璧勶紙鎵嬪姩濉啓锛夛紝浠庡疄鍙戝伐璧勪腑鎵i櫎
+        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());
+        }
     }
 
     /**
diff --git a/src/main/java/com/ruoyi/staff/service/impl/StaffSalaryMainServiceImpl.java b/src/main/java/com/ruoyi/staff/service/impl/StaffSalaryMainServiceImpl.java
index 41f8af3..f15b9b9 100644
--- a/src/main/java/com/ruoyi/staff/service/impl/StaffSalaryMainServiceImpl.java
+++ b/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);
diff --git a/src/main/resources/mapper/staff/StaffSalaryDetailMapper.xml b/src/main/resources/mapper/staff/StaffSalaryDetailMapper.xml
index 7c8004a..f029b09 100644
--- a/src/main/resources/mapper/staff/StaffSalaryDetailMapper.xml
+++ b/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>

--
Gitblit v1.9.3