1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
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;
        }
    }
}