xiaoyi
2026-10-30 871312356092fe49022e58baf530ba15f00f53f9
feat(hrm): 打通请假审批与考勤,请假当天可不打卡也不算异常

## 一、修复请假审批无法提交(BPM 配置缺失)

现象:请假申请「提交审批」报 1030001006,前端拿不到可选流程。

根因:
1. 代码里 HRM 四个审批模块各要求一个分类码:
hrm_leave_approve / overtime_approve / hrm_resignation_approve / hrm_transfer_approve
而 bpm_category 里这四个分类一个都没有。
2. 库里已部署的请假流程(key=qingjia v1/v2)被误挂在
「考勤异常审批 hrm_attendance_approve」分类下,按 hrm_leave_approve 过滤查不到。

修复:补建 4 个分类;为 qingjia 各版本复制一份 bpm_process_definition_info
记录到 hrm_leave_approve,保留原有 model_id / 表单 / 流程配置;原绑定不动。

验证:approve-process-definition-list 返回 qingjia v2(已激活)。

## 二、考勤与请假打通

改造前的问题:
- 请假当天照样可以打卡、照样算迟到
- 请假当天不打卡会被算成「缺卡」,反而扣分
- 考勤记录里没有任何字段能体现「这天请假了」
- 月度统计的 leaveHours 写死为 BigDecimal.ZERO,从未计算

改动:
1. hrm_attendance_record 新增 leave_flag / leave_hours / leave_types 三列
2. HrmAttendanceClockTypeEnum 新增 LEAVE(5, "请假")
3. 新增 HrmLeaveQueryService:只读查某员工某天的请假单
HrmLeaveApplicationMapper 新增 selectListByUserIdAndOverlapDate
(按时间区间【交集】查,支持跨天请假;原有的 selectApprovedListByUserIdAndTimeRange
是【全包含】语义,不适用于按天判断)
4. getMyStatus:返回 onLeave / leaveHours / leaveTypeNames,
请假当天提示「今日已请假(xx),如需上班可正常打卡」,但打卡按钮保持可用
5. clockIn / clockOut:请假当天打卡一律记为正常,不判迟到/早退;
下班打卡时若上班卡为「缺卡」则改为「请假」
6. 月度统计:按考勤规则工作时段与请假单求交集累计 leaveHours(精确到 0.01 小时);
请假当天不计迟到/早退/缺卡;补算「有请假单但当天没打卡」的日子
7. 修正字典 hrm_clock_type:原字典 2=迟到/早退、3=缺卡,与枚举
(2=迟到 3=早退 4=缺卡)不一致,导致早退被显示成缺卡、缺卡显示为空

## 三、顺带修复的 ID 语义混乱

clockIn/clockOut 的入参实际是「员工档案 ID」,但考勤记录表和请假表存的是
「系统用户 ID」。原代码用入参直接查记录,导致多员工数据可能错位。
现在统一用 employee.getUserId() 查记录与请假;getMyStatus 改用 loginUserId。

## 验证(本地 IDEA + 生产环境)

本地:
onLeave=true leaveHours=5(10:00-15:00 与 10:00-19:30 交集)
leaveTypeNames=事假 可打卡=true
打卡后 clockInType=1(正常) leaveFlag=true leaveHours=5 leaveTypes=1
月度统计 请假工时=5 缺卡=0

生产(宋春歌,请假 10-13~12-31 覆盖今天):
onLeave=True leaveHours=4.0 leaveTypeNames=产假
可打卡=True 提示「今日已请假(产假),如需上班可正常打卡」
(4.0 是因为部门规则已禁用,回退到全局「长辉2026下午」15:30-19:30,
与请假 09:30-19:30 交集正好 4 小时,计算无误)
已修改9个文件
已添加6个文件
806 ■■■■■ 文件已修改
scripts/field-consistency-check.mjs 188 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/attendance_leave_link_20261030.sql 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/fix_dict_hrm_clock_type_20261030.sql 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/fix_hrm_bpm_category_20261030.sql 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/HrmAttendanceClockTypeEnum.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceMyStatusRespVO.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRecordPageReqVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRecordRespVO.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/attendance/HrmAttendanceRecordDO.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/approval/HrmLeaveApplicationMapper.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/attendance/HrmAttendanceRecordMapper.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/approval/HrmLeaveQueryService.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/approval/HrmLeaveQueryServiceImpl.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordServiceImpl.java 160 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceSummaryServiceImpl.java 128 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/field-consistency-check.mjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,188 @@
#!/usr/bin/env node
/**
 * å­—段一致性检查脚本
 *
 * ç”¨é€”:改完代码后跑一次,检查「前端能填、后端存不下 / è¯»ä¸å‡ºã€çš„链路断裂。
 *
 * ç”¨æ³•:
 *   node scripts/field-consistency-check.mjs [后端路径] [schema.json]
 *
 * schema.json å¯ä»Žç”Ÿäº§åº“导出:
 *   SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.columns
 *    WHERE table_schema='mom-chdl' ORDER BY TABLE_NAME, ORDINAL_POSITION;
 *   å¯¼å‡ºä¸º {"表名": ["col1","col2"]} çš„ JSON
 *
 * é€€å‡ºç ï¼š0 = æ— é—®é¢˜ï¼›1 = å‘现问题
 */
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { basename, join } from 'node:path';
const BACKEND = process.argv[2] || '.';
const SCHEMA_FILE = process.argv[3] || '.local-test/db-schema.json';
const COMMON = new Set(['id', 'creator', 'createTime', 'updater', 'updateTime', 'deleted', 'tenantId']);
const camel2snake = (s) => s.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, '');
const isColl = (t) => /List|Set|Collection|\[\]|Map/.test(t);
function listJava(dir) {
  const out = [];
  const walk = (d) => {
    let ents = [];
    try { ents = readdirSync(d, { withFileTypes: true }); } catch { return; }
    for (const e of ents) {
      const p = join(d, e.name);
      if (e.isDirectory()) {
        if (!['target', 'node_modules', '.git'].includes(e.name)) walk(p);
      } else if (e.name.endsWith('.java')) out.push(p);
    }
  };
  walk(dir);
  return out;
}
/** åªå–顶层字段(跳过内部类),并记录 @TableField æŒ‡å®šçš„真实列名 */
function topFields(file) {
  const t = readFileSync(file, 'utf8');
  const out = [];
  let depth = 0;
  let anno = '';
  for (const line of t.split(/\r?\n/)) {
    const trimmed = line.trim();
    if (/^@(com\.baomidou\.mybatisplus\.annotation\.)?TableField/.test(trimmed)) {
      anno += trimmed;
      continue;
    }
    if (depth === 1) {
      const m = trimmed.match(/^private\s+(?:static\s+|final\s+)?([\w<>\[\].?, ]+?)\s+(\w+)\s*;/);
      if (m) {
        if (/exist\s*=\s*false/.test(anno)) { anno = ''; }
        else {
          const colM = anno.match(/@(?:com\.baomidou\.mybatisplus\.annotation\.)?TableField\(\s*(?:value\s*=\s*)?"`?([^"`)]+)`?"/);
          out.push({ type: m[1].trim(), name: m[2], col: colM ? colM[1] : null });
          anno = '';
        }
        continue;
      }
    }
    const clean = trimmed.replace(/\/\/.*$/, '').replace(/\/\*.*?\*\//g, '').replace(/"[^"]*"/g, '""');
    for (const ch of clean) { if (ch === '{') depth++; else if (ch === '}') depth--; }
    if (trimmed && !trimmed.startsWith('@')) anno = '';
  }
  return out;
}
const javaFiles = readdirSync(BACKEND, { withFileTypes: true })
  .filter((e) => e.isDirectory() && e.name.startsWith('yudao-module-'))
  .flatMap((e) => listJava(join(BACKEND, e.name, 'src', 'main', 'java')));
let schema = {};
try { schema = JSON.parse(readFileSync(SCHEMA_FILE, 'utf8')); }
catch { console.warn('⚠ æœªæ‰¾åˆ° schema æ–‡ä»¶ï¼Œè·³è¿‡æ£€æŸ¥ C(DO vs æ•°æ®åº“列)'); }
const doMap = new Map();      // base -> { cls, table, fields }
const saveMap = new Map();    // base -> { cls, fields }
const respMap = new Map();    // base -> { cls, fields }
const srcTexts = [];
for (const f of javaFiles) {
  const bn = basename(f);
  if (bn.endsWith('DO.java')) {
    const base = bn.replace(/DO\.java$/, '');
    const txt = readFileSync(f, 'utf8');
    const tm = txt.match(/@TableName\(\s*"([^"]+)"/);
    const d = doMap.get(base) || { cls: bn.replace(/\.java$/, ''), table: tm ? tm[1] : null, fields: [] };
    d.fields.push(...topFields(f));
    doMap.set(base, d);
  } else if (bn.endsWith('SaveReqVO.java')) {
    const base = bn.replace(/SaveReqVO\.java$/, '');
    const s = saveMap.get(base) || { cls: bn.replace(/\.java$/, ''), fields: [] };
    s.fields.push(...topFields(f));
    saveMap.set(base, s);
  } else if (bn.endsWith('RespVO.java')) {
    const base = bn.replace(/RespVO\.java$/, '');
    const r = respMap.get(base) || { cls: bn.replace(/\.java$/, ''), fields: [] };
    r.fields.push(...topFields(f));
    respMap.set(base, r);
  }
  if (/Controller\.java$|ServiceImpl\.java$|Convert\.java$|Service\.java$/.test(bn) && !bn.includes('Api')) {
    srcTexts.push(readFileSync(f, 'utf8'));
  }
}
const allSrc = srcTexts.join('\n');
const assigned = (name) => {
  const cap = name[0].toUpperCase() + name.slice(1);
  return allSrc.includes('set' + cap + '(') || allSrc.includes('::set' + cap);
};
const problems = [];
// A. SaveReqVO æœ‰ / RespVO æ²¡æœ‰
for (const [base, s] of saveMap) {
  const r = respMap.get(base);
  if (!r) continue;
  const rf = new Set(r.fields.map((x) => x.name));
  for (const f of s.fields) {
    if (COMMON.has(f.name) || isColl(f.type) || rf.has(f.name)) continue;
    problems.push({ kind: 'A', msg: `${base}: SaveReqVO æœ‰ ${f.name},RespVO æ²¡æœ‰ â†’ å­˜å¾—进但读不出` });
  }
}
// B. SaveReqVO æœ‰ / DO æ²¡æœ‰ / Service ä¹Ÿæ²¡æ‰‹å·¥æ˜ å°„
for (const [base, s] of saveMap) {
  const d = doMap.get(base);
  if (!d) continue;
  const df = new Set(d.fields.map((x) => x.name));
  for (const f of s.fields) {
    if (COMMON.has(f.name) || isColl(f.type) || df.has(f.name)) continue;
    if (assigned(f.name)) continue;
    problems.push({ kind: 'B', msg: `${d.cls}: SaveReqVO æœ‰ ${f.name},DO æ²¡æœ‰ â†’ æäº¤è¢«é™é»˜ä¸¢å¼ƒ` });
  }
}
// C. DO æœ‰ / æ•°æ®åº“表没这列
for (const [, d] of doMap) {
  if (!d.table || !schema[d.table]) continue;
  const cols = new Set(schema[d.table]);
  for (const f of d.fields) {
    const col = f.col || camel2snake(f.name);
    if (!cols.has(col)) problems.push({ kind: 'C', msg: `${d.cls}: å­—段 ${f.name} å¯¹åº”列 ${col} åœ¨è¡¨ ${d.table} ä¸å­˜åœ¨ â†’ SQL æŠ¥é”™` });
  }
}
// D. RespVO æœ‰ / DO æ²¡æœ‰ / å…¨ä»£ç åº“从未赋值
for (const [base, r] of respMap) {
  const d = doMap.get(base);
  if (!d) continue;
  const df = new Set(d.fields.map((x) => x.name));
  for (const f of r.fields) {
    if (COMMON.has(f.name) || isColl(f.type) || df.has(f.name)) continue;
    if (assigned(f.name)) continue;
    problems.push({ kind: 'D', msg: `${r.cls}: ${f.name} ä»Žæœªè¢«èµ‹å€¼ â†’ å‰ç«¯æ°¸è¿œæ˜¾ç¤ºç©º` });
  }
}
const byKind = { A: [], B: [], C: [], D: [] };
for (const p of problems) byKind[p.kind].push(p.msg);
const titles = {
  A: 'A. SaveReqVO æœ‰ / RespVO æ²¡æœ‰ï¼ˆå­˜å¾—进、读不出)',
  B: 'B. SaveReqVO æœ‰ / DO æ²¡æœ‰ï¼ˆæäº¤è¢«ä¸¢å¼ƒï¼‰',
  C: 'C. DO æœ‰ / æ•°æ®åº“表没这列(SQL æŠ¥é”™ï¼‰',
  D: 'D. RespVO æœ‰ / ä»Žæœªèµ‹å€¼ï¼ˆæ°¸è¿œæ˜¾ç¤ºç©ºï¼‰',
};
let total = 0;
for (const k of ['A', 'B', 'C', 'D']) {
  console.log('\n=== ' + titles[k] + ' ===');
  if (byKind[k].length === 0) console.log('  (无)');
  else { console.log('  â— ' + byKind[k].length + ' é¡¹'); byKind[k].forEach((m) => console.log('  - ' + m)); }
  total += byKind[k].length;
}
console.log('\n合计: ' + total + ' é¡¹');
if (total > 0) {
  console.log('\n提示:A/B/C é€šå¸¸æ˜¯çœŸé—®é¢˜ï¼›D é‡Œæ´¾ç”Ÿå±•示字段较多,需人工确认。');
  process.exit(1);
}
console.log('✅ æœªå‘现字段链路断裂');
sql/mysql/attendance_leave_link_20261030.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,59 @@
-- ============================================================
-- è€ƒå‹¤è®°å½•表增加请假相关字段,打通「请假 â†’ è€ƒå‹¤ã€
-- ç”Ÿæˆæ—¶é—´ï¼š2026-10-30
--
-- èƒŒæ™¯ï¼šè¯·å‡å•与考勤完全脱节——
--   Â· è¯·å‡å½“天照样可以打卡、照样算迟到
--   Â· è¯·å‡å½“天不打卡会被算成「缺卡」
--   Â· è€ƒå‹¤è®°å½•里没有任何字段能体现「这天请假了」
--   Â· æœˆåº¦ç»Ÿè®¡çš„ leaveHours å­—段写死为 0,从未计算
--
-- åšæ³•:在 hrm_attendance_record ä¸Šå¢žåŠ ä¸‰ä¸ªå­—æ®µâ€”â€”
--   leave_flag   å½“天是否请假(有审批通过的请假单覆盖当天即为 1)
--   leave_hours  å½“天请假工时(按请假单的起止时间与考勤规则的工作时段求交集,精确到分钟)
--   leave_types  å½“天涉及的请假类型,逗号分隔(支持一天内多段请假、不同类型)
--
-- ç²’度:按小时(0.01 å°æ—¶ = 36 ç§’),与 hrm_leave_application.duration ç²¾åº¦ä¸€è‡´
-- å¹‚等:先查 information_schema å† ALTER
-- ============================================================
DROP PROCEDURE IF EXISTS sp_attendance_add_leave_col;
DELIMITER $$
CREATE PROCEDURE sp_attendance_add_leave_col(IN p_col VARCHAR(64), IN p_def VARCHAR(255),
                                             IN p_comment VARCHAR(255), IN p_after VARCHAR(64))
BEGIN
  IF NOT EXISTS (SELECT 1 FROM information_schema.columns
                  WHERE table_schema = DATABASE()
                    AND table_name = 'hrm_attendance_record'
                    AND column_name = p_col) THEN
    SET @s = CONCAT('ALTER TABLE `hrm_attendance_record` ADD COLUMN `', p_col, '` ', p_def,
                    ' COMMENT ''', p_comment, ''' AFTER `', p_after, '`');
    PREPARE st FROM @s; EXECUTE st; DEALLOCATE PREPARE st;
  END IF;
END$$
DELIMITER ;
CALL sp_attendance_add_leave_col('leave_flag', 'bit(1) NOT NULL DEFAULT b''0''',
    '当天是否请假(有审批通过的请假单覆盖当天)', 'overtime_hours');
CALL sp_attendance_add_leave_col('leave_hours', 'decimal(6,2) NOT NULL DEFAULT 0.00',
    '当天请假工时(按请假单与工作时段交集计算,精确到 0.01 å°æ—¶ï¼‰', 'leave_flag');
CALL sp_attendance_add_leave_col('leave_types', 'varchar(64) DEFAULT NULL',
    '当天请假类型,逗号分隔(如 1,2 è¡¨ç¤ºæ—¢æœ‰äº‹å‡åˆæœ‰ç—…假)', 'leave_hours');
DROP PROCEDURE IF EXISTS sp_attendance_add_leave_col;
-- ---------- ç´¢å¼•:按日期+请假标记查询更快 ----------
SET @idx = (SELECT COUNT(*) FROM information_schema.statistics
            WHERE table_schema = DATABASE() AND table_name = 'hrm_attendance_record'
              AND index_name = 'idx_leave_flag');
SET @s2 = IF(@idx = 0,
            'CREATE INDEX `idx_leave_flag` ON `hrm_attendance_record` (`leave_flag`)',
            'SELECT 1');
PREPARE st2 FROM @s2; EXECUTE st2; DEALLOCATE PREPARE st2;
-- ---------- æ ¡éªŒ ----------
SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_COMMENT
  FROM information_schema.columns
 WHERE table_schema = DATABASE() AND table_name = 'hrm_attendance_record'
   AND COLUMN_NAME IN ('leave_flag', 'leave_hours', 'leave_types')
 ORDER BY ORDINAL_POSITION;
sql/mysql/fix_dict_hrm_clock_type_20261030.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,41 @@
-- ============================================================
-- ä¿®æ­£å­—å…¸ hrm_clock_type,与后端枚举 HrmAttendanceClockTypeEnum å¯¹é½
-- ç”Ÿæˆæ—¶é—´ï¼š2026-10-30
--
-- é—®é¢˜ï¼šå­—典与枚举不一致,导致前端筛选/标签显示错乱
--
--   æžšä¸¾å®šä¹‰ï¼ˆåŽç«¯å”¯ä¸€çœŸç›¸ï¼‰ï¼š
--     NORMAL(1, "正常")  LATE(2, "迟到")  EARLY(3, "早退")
--     MISSING(4, "缺卡")  LEAVE(5, "请假")   â† LEAVE ä¸ºæœ¬æ¬¡æ–°å¢ž
--
--   åŽŸå­—å…¸ï¼ˆé”™çš„ï¼‰ï¼š
--     1=正常  2=迟到/早退  3=缺卡
--     â†’ å€¼ä¸º 3(早退)的打卡记录会被显示成「缺卡」
--     â†’ å€¼ä¸º 4(缺卡)的打卡记录在字典里找不到,显示空
--
-- åšæ³•:按枚举重建字典数据(幂等,先删后插,只影响 hrm_clock_type)
-- ============================================================
-- ---------- 1. å…ˆçœ‹ä¿®æ­£å‰ ----------
SELECT sort, label, value FROM system_dict_data
 WHERE dict_type = 'hrm_clock_type' AND deleted = b'0' ORDER BY sort;
-- ---------- 2. è½¯åˆ æ—§æ•°æ®ï¼ˆä¿ç•™å¯å›žæ»šï¼‰ ----------
UPDATE system_dict_data
   SET deleted = b'1', updater = '1', update_time = NOW()
 WHERE dict_type = 'hrm_clock_type' AND deleted = b'0';
-- ---------- 3. æŒ‰æžšä¸¾é‡æ–°æ’å…¥ 5 é¡¹ ----------
INSERT INTO `system_dict_data`
  (`sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`,
   `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES
  (1, '正常', '1', 'hrm_clock_type', 0, 'success', '', '按时打卡',                    '1', NOW(), '1', NOW(), b'0'),
  (2, '迟到', '2', 'hrm_clock_type', 0, 'warning', '', '上班打卡晚于规定时间',        '1', NOW(), '1', NOW(), b'0'),
  (3, '早退', '3', 'hrm_clock_type', 0, 'warning', '', '下班打卡早于规定时间',        '1', NOW(), '1', NOW(), b'0'),
  (4, '缺卡', '4', 'hrm_clock_type', 0, 'danger',  '', '应打卡但未打卡',              '1', NOW(), '1', NOW(), b'0'),
  (5, '请假', '5', 'hrm_clock_type', 0, 'info',    '', '当天有审批通过的请假单,不计考勤异常', '1', NOW(), '1', NOW(), b'0');
-- ---------- 4. æ ¡éªŒ ----------
SELECT sort, label, value, color_type FROM system_dict_data
 WHERE dict_type = 'hrm_clock_type' AND deleted = b'0' ORDER BY sort;
sql/mysql/fix_hrm_bpm_category_20261030.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,79 @@
-- ============================================================
-- HRM å®¡æ‰¹æµç¨‹åˆ†ç±»è¡¥é½ + è¯·å‡æµç¨‹ç»‘定到正确分类
-- ç”Ÿæˆæ—¶é—´ï¼š2026-10-30
--
-- é—®é¢˜ï¼šè¯·å‡ç”³è¯·ã€Œæäº¤å®¡æ‰¹ã€æŠ¥ 1030001006,前端拿不到可选流程
--
-- æ ¹å› ï¼š
--   1. ä»£ç é‡Œ HRM å››ä¸ªå®¡æ‰¹æ¨¡å—各要求一个分类码:
--        hrm_leave_approve / overtime_approve / hrm_resignation_approve / hrm_transfer_approve
--      è€Œ bpm_category é‡Œè¿™å››ä¸ªåˆ†ç±»ä¸€ä¸ªéƒ½æ²¡æœ‰
--   2. åº“里已部署的请假流程(key=qingjia)被挂在
--      ã€Œè€ƒå‹¤å¼‚常审批 hrm_attendance_approve」分类下,按 hrm_leave_approve è¿‡æ»¤æŸ¥ä¸åˆ°
--
-- åšæ³•:
--   1. è¡¥å»º 4 ä¸ªåˆ†ç±»ï¼ˆå¹‚等)
--   2. ä¸º qingjia çš„æ¯ä¸ªå·²éƒ¨ç½²ç‰ˆæœ¬ï¼Œå¤åˆ¶ä¸€ä»½ bpm_process_definition_info
--      è®°å½•到 hrm_leave_approve åˆ†ç±»ï¼Œä¿ç•™åŽŸè®°å½•çš„ model_id / è¡¨å• / æµç¨‹é…ç½®
--   3. åŽŸæœ‰ hrm_attendance_approve ç»‘定保留不动(不影响其它功能)
-- ============================================================
-- ---------- 1. è¡¥å»º 4 ä¸ªåˆ†ç±» ----------
INSERT INTO `bpm_category` (`name`, `code`, `description`, `status`, `sort`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT '请假审批', 'hrm_leave_approve', '员工请假申请的审批流程分类', 0, 15, '1', NOW(), '1', NOW(), b'0'
 WHERE NOT EXISTS (SELECT 1 FROM `bpm_category` WHERE `code` = 'hrm_leave_approve' AND `deleted` = b'0');
INSERT INTO `bpm_category` (`name`, `code`, `description`, `status`, `sort`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT '加班审批', 'overtime_approve', '员工加班申请的审批流程分类', 0, 16, '1', NOW(), '1', NOW(), b'0'
 WHERE NOT EXISTS (SELECT 1 FROM `bpm_category` WHERE `code` = 'overtime_approve' AND `deleted` = b'0');
INSERT INTO `bpm_category` (`name`, `code`, `description`, `status`, `sort`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT '离职审批', 'hrm_resignation_approve', '员工离职申请的审批流程分类', 0, 17, '1', NOW(), '1', NOW(), b'0'
 WHERE NOT EXISTS (SELECT 1 FROM `bpm_category` WHERE `code` = 'hrm_resignation_approve' AND `deleted` = b'0');
INSERT INTO `bpm_category` (`name`, `code`, `description`, `status`, `sort`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT '调岗审批', 'hrm_transfer_approve', '员工调岗申请的审批流程分类', 0, 18, '1', NOW(), '1', NOW(), b'0'
 WHERE NOT EXISTS (SELECT 1 FROM `bpm_category` WHERE `code` = 'hrm_transfer_approve' AND `deleted` = b'0');
-- ---------- 2. å¤åˆ¶ qingjia çš„æµç¨‹å®šä¹‰ä¿¡æ¯åˆ° hrm_leave_approve ----------
-- ç”¨åŽŸè®°å½•åšæ¨¡æ¿ï¼Œä¿è¯è¡¨å•/流程配置完整带过去;model_id åŠ åŽç¼€é¿å…ä¸Žæºè®°å½•å†²çª
INSERT INTO `bpm_process_definition_info`
  (`process_definition_id`, `model_id`, `model_type`, `category`, `icon`, `description`,
   `form_type`, `form_id`, `form_conf`, `form_fields`, `form_custom_create_path`, `form_custom_view_path`,
   `simple_model`, `visible`, `sort`, `start_user_ids`, `start_dept_ids`, `manager_user_ids`,
   `allow_cancel_running_process`, `allow_withdraw_task`, `process_id_rule`, `auto_approval_type`,
   `title_setting`, `summary_setting`,
   `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT p.`ID_`,
       CONCAT(src.`model_id`, '-leave'),
       src.`model_type`,
       'hrm_leave_approve',
       src.`icon`, src.`description`,
       src.`form_type`, src.`form_id`, src.`form_conf`, src.`form_fields`,
       src.`form_custom_create_path`, src.`form_custom_view_path`,
       src.`simple_model`, src.`visible`, src.`sort`, src.`start_user_ids`, src.`start_dept_ids`, src.`manager_user_ids`,
       src.`allow_cancel_running_process`, src.`allow_withdraw_task`, src.`process_id_rule`, src.`auto_approval_type`,
       src.`title_setting`, src.`summary_setting`,
       '1', NOW(), '1', NOW(), b'0'
  FROM `act_re_procdef` p
  JOIN `bpm_process_definition_info` src
    ON src.`process_definition_id` = p.`ID_` AND src.`deleted` = b'0'
 WHERE p.`key_` = 'qingjia'
   AND NOT EXISTS (
     SELECT 1 FROM `bpm_process_definition_info` x
      WHERE x.`process_definition_id` = p.`ID_`
        AND x.`category` = 'hrm_leave_approve' AND x.`deleted` = b'0'
   );
-- ---------- 3. æ ¡éªŒ ----------
SELECT id, name, code, status, sort FROM `bpm_category`
 WHERE code IN ('hrm_leave_approve','overtime_approve','hrm_resignation_approve','hrm_transfer_approve')
   AND deleted = b'0' ORDER BY sort;
SELECT info.`category`, info.`process_definition_id`, p.`name_`, p.`version_`,
       p.`suspension_state_` AS çŠ¶æ€_1激活,
       CASE WHEN info.`form_fields` IS NULL THEN '无表单' ELSE '有表单' END AS è¡¨å•
  FROM `bpm_process_definition_info` info
  JOIN `act_re_procdef` p ON p.`ID_` = info.`process_definition_id`
 WHERE info.`category` = 'hrm_leave_approve' AND info.`deleted` = b'0'
 ORDER BY p.`version_`;
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/HrmAttendanceClockTypeEnum.java
@@ -16,7 +16,14 @@
    NORMAL(1, "正常"),
    LATE(2, "迟到"),
    EARLY(3, "早退"),
    MISSING(4, "缺卡");
    MISSING(4, "缺卡"),
    /**
     * è¯·å‡
     *
     * å½“天有审批通过的请假单时为该类型。请假当天仍允许打卡,
     * ä½†æ‰“卡不会被判定为迟到/早退,未打卡也不会判定为缺卡。
     */
    LEAVE(5, "请假");
    private final Integer type;
    private final String name;
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceMyStatusRespVO.java
@@ -117,6 +117,15 @@
    @Schema(description = "数据来源(兼容旧版客户端)")
    private Integer dataSource;
    @Schema(description = "当天是否请假", example = "false")
    private Boolean onLeave;
    @Schema(description = "当天请假工时(小时),未请假为 0", example = "3.5")
    private BigDecimal leaveHours;
    @Schema(description = "当天请假类型名称,多段用顿号分隔", example = "事假、病假")
    private String leaveTypeNames;
    @Schema(description = "当前可打卡状态")
    private Clockable clockable;
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRecordPageReqVO.java
@@ -32,4 +32,7 @@
    @Schema(description = "下班打卡类型", example = "1")
    private Integer clockOutType;
    @Schema(description = "是否请假:true-已请假,false-未请假,不传则不过滤", example = "true")
    private Boolean leaveFlag;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRecordRespVO.java
@@ -108,6 +108,17 @@
    @DictFormat("hrm_data_source")
    private Integer dataSource;
    @Schema(description = "当天是否请假", example = "false")
    @ExcelProperty("是否请假")
    private Boolean leaveFlag;
    @Schema(description = "当天请假工时(小时)", example = "3.5")
    @ExcelProperty("请假工时")
    private BigDecimal leaveHours;
    @Schema(description = "当天请假类型,逗号分隔", example = "1,2")
    private String leaveTypes;
    @Schema(description = "创建时间")
    @ExcelProperty("创建时间")
    private LocalDateTime createTime;
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/attendance/HrmAttendanceRecordDO.java
@@ -79,6 +79,28 @@
    private BigDecimal overtimeHours;
    /**
     * å½“天是否请假
     *
     * å½“天存在「审批通过」的请假单且与工作时段有重叠时为 true。
     * è¯·å‡å½“天仍允许打卡,但不算迟到/早退,也不算缺卡。
     */
    private Boolean leaveFlag;
    /**
     * å½“天请假工时(小时)
     *
     * æŒ‰è¯·å‡å•起止时间与考勤规则工作时段求交集计算,精确到 0.01 å°æ—¶ã€‚
     */
    private BigDecimal leaveHours;
    /**
     * å½“天请假类型,逗号分隔
     *
     * å¯¹åº” HrmLeaveTypeEnum çš„ type,如 "1,2" è¡¨ç¤ºå½“天既请了事假又请了病假。
     */
    private String leaveTypes;
    /**
     * æ‰“卡地点
     */
    private String location;
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/approval/HrmLeaveApplicationMapper.java
@@ -46,6 +46,31 @@
     * @param endTime   ç»“束时间
     * @return è¯·å‡è®°å½•列表
     */
    /**
     * æŸ¥è¯¢ä¸ŽæŒ‡å®šæ—¶é—´åŒºé—´ã€æœ‰äº¤é›†ã€‘的请假单
     *
     * <p>语义与 selectApprovedListByUserIdAndTimeRange ä¸åŒï¼šé‚£è¾¹è¦æ±‚请假单完整落在区间内,
     * è¿™é‡Œåªè¦æœ‰é‡å å°±ç®—命中。考勤判断「某天是否请假」必须用这个:
     * è·¨å¤©çš„请假单(如 9/24 18:00 ~ 9/25 18:00)在 9/25 è¿™å¤©ä¹Ÿç®—请假。</p>
     *
     * <p>交集条件:start_time &lt; rangeEnd ä¸” end_time &gt;= rangeStart</p>
     *
     * @param userId    å‘˜å·¥ID(hrm_leave_application.user_id)
     * @param status    å®¡æ‰¹çŠ¶æ€ï¼Œä¼  null ä¸è¿‡æ»¤
     * @param rangeStart åŒºé—´å¼€å§‹ï¼ˆå«ï¼‰
     * @param rangeEnd   åŒºé—´ç»“束(不含)
     * @return ä¸ŽåŒºé—´æœ‰äº¤é›†çš„请假单
     */
    default List<HrmLeaveApplicationDO> selectListByUserIdAndOverlapDate(
            Long userId, Integer status, LocalDateTime rangeStart, LocalDateTime rangeEnd) {
        return selectList(new LambdaQueryWrapperX<HrmLeaveApplicationDO>()
                .eq(HrmLeaveApplicationDO::getUserId, userId)
                .eqIfPresent(HrmLeaveApplicationDO::getStatus, status)
                .lt(HrmLeaveApplicationDO::getStartTime, rangeEnd)
                .ge(HrmLeaveApplicationDO::getEndTime, rangeStart)
                .orderByAsc(HrmLeaveApplicationDO::getStartTime));
    }
    default List<HrmLeaveApplicationDO> selectApprovedListByUserIdAndTimeRange(Long userId, LocalDateTime startTime, LocalDateTime endTime) {
        return selectList(new LambdaQueryWrapperX<HrmLeaveApplicationDO>()
                .eq(HrmLeaveApplicationDO::getUserId, userId)
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/attendance/HrmAttendanceRecordMapper.java
@@ -26,6 +26,7 @@
                .leIfPresent(HrmAttendanceRecordDO::getDate, reqVO.getEndDate())
                .eqIfPresent(HrmAttendanceRecordDO::getClockInType, reqVO.getClockInType())
                .eqIfPresent(HrmAttendanceRecordDO::getClockOutType, reqVO.getClockOutType())
                .eqIfPresent(HrmAttendanceRecordDO::getLeaveFlag, reqVO.getLeaveFlag())
                .orderByDesc(HrmAttendanceRecordDO::getDate));
    }
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/approval/HrmLeaveQueryService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,35 @@
package cn.iocoder.yudao.module.hrm.service.approval;
import cn.iocoder.yudao.module.hrm.dal.dataobject.approval.HrmLeaveApplicationDO;
import java.time.LocalDate;
import java.util.List;
/**
 * è¯·å‡æŸ¥è¯¢æœåŠ¡ï¼šä¾›è€ƒå‹¤æ¨¡å—åˆ¤æ–­æŸå‘˜å·¥æŸå¤©æ˜¯å¦è¯·å‡
 *
 * <p>独立于 {@link HrmLeaveApplicationService},避免考勤模块依赖审批模块的写操作,
 * åªæš´éœ²åªè¯»æŸ¥è¯¢ï¼Œä¾¿äºŽå•测和复用。</p>
 *
 * @author è¶…级管理员
 */
public interface HrmLeaveQueryService {
    /**
     * æŸ¥è¯¢æŸå‘˜å·¥åœ¨æŒ‡å®šæ—¥æœŸå†…、审批通过的请假单
     *
     * <p>只返回 status = å®¡æ‰¹é€šè¿‡(20) çš„单据;驳回、撤销、审批中都不算。</p>
     *
     * @param userId å‘˜å·¥ç¼–号(对应 hrm_leave_application.user_id)
     * @param date   æ—¥æœŸ
     * @return è¦†ç›–该日期的请假单列表,无则空列表
     */
    List<HrmLeaveApplicationDO> getApprovedLeaveList(Long userId, LocalDate date);
    /**
     * åˆ¤æ–­æŸå‘˜å·¥æŸå¤©æ˜¯å¦è¯·å‡ï¼ˆæ˜¯å¦æœ‰å®¡æ‰¹é€šè¿‡çš„请假单覆盖该天)
     */
    default boolean isOnLeave(Long userId, LocalDate date) {
        return !getApprovedLeaveList(userId, date).isEmpty();
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/approval/HrmLeaveQueryServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
package cn.iocoder.yudao.module.hrm.service.approval;
import cn.iocoder.yudao.module.hrm.dal.dataobject.approval.HrmLeaveApplicationDO;
import cn.iocoder.yudao.module.hrm.dal.mysql.approval.HrmLeaveApplicationMapper;
import cn.iocoder.yudao.module.hrm.enums.HrmAuditStatusEnum;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/**
 * è¯·å‡æŸ¥è¯¢æœåŠ¡å®žçŽ°
 *
 * @author è¶…级管理员
 */
@Service
public class HrmLeaveQueryServiceImpl implements HrmLeaveQueryService {
    @Resource
    private HrmLeaveApplicationMapper leaveApplicationMapper;
    @Override
    public List<HrmLeaveApplicationDO> getApprovedLeaveList(Long userId, LocalDate date) {
        if (userId == null || date == null) {
            return List.of();
        }
        // è¯·å‡å•与当天有交集即为命中:start_time < æ¬¡æ—¥ 00:00 ä¸” end_time >= å½“天 00:00
        LocalDateTime dayStart = date.atStartOfDay();
        LocalDateTime dayEnd = date.plusDays(1).atStartOfDay();
        return leaveApplicationMapper.selectListByUserIdAndOverlapDate(
                userId, HrmAuditStatusEnum.APPROVE.getStatus(), dayStart, dayEnd);
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordServiceImpl.java
@@ -57,6 +57,8 @@
    private HrmAttendanceHolidayService attendanceHolidayService;
    @Resource
    private DeptApi deptApi;
    @Resource
    private cn.iocoder.yudao.module.hrm.service.approval.HrmLeaveQueryService leaveQueryService;
    @Override
    public PageResult<HrmAttendanceRecordDO> getAttendanceRecordPage(HrmAttendanceRecordPageReqVO pageReqVO) {
@@ -123,14 +125,34 @@
        }
        // 4. å½“日考勤记录
        HrmAttendanceRecordDO record = attendanceRecordMapper.selectByUserIdAndDate(employee.getId(), queryDate);
        // è€ƒå‹¤è®°å½•表存的是系统用户 ID,这里用 loginUserId è€Œä¸æ˜¯å‘˜å·¥æ¡£æ¡ˆ ID
        HrmAttendanceRecordDO record = attendanceRecordMapper.selectByUserIdAndDate(loginUserId, queryDate);
        if (record != null) {
            respVO.setRecord(buildRecordRespVO(record, employee));
            fillCompatibleRecordFields(respVO, record);
        }
        // 4.5 å½“天请假情况(请假仍允许打卡,但不算迟到/早退/缺卡)
        List<cn.iocoder.yudao.module.hrm.dal.dataobject.approval.HrmLeaveApplicationDO> leaves =
                leaveQueryService.getApprovedLeaveList(loginUserId, queryDate);
        boolean onLeave = !leaves.isEmpty();
        java.math.BigDecimal leaveHours = onLeave ? calcLeaveHours(leaves, rule, queryDate) : java.math.BigDecimal.ZERO;
        respVO.setOnLeave(onLeave);
        respVO.setLeaveHours(leaveHours);
        if (onLeave) {
            List<String> typeNames = leaves.stream()
                    .map(l -> cn.iocoder.yudao.module.hrm.enums.HrmLeaveTypeEnum.getNameByStatus(l.getLeaveType()))
                    .distinct().toList();
            respVO.setLeaveTypeNames(String.join("、", typeNames));
        }
        // 5. å½“前可打卡状态
        HrmAttendanceMyStatusRespVO.Clockable clockable = buildClockable(workDay, rule, record, queryDate);
        HrmAttendanceMyStatusRespVO.Clockable clockable = buildClockable(workDay, rule, record, queryDate, onLeave);
        // 5.1 è¯·å‡å½“天:把「可打卡」的提示换成请假说明,但依然允许打卡
        if (onLeave) {
            clockable.setMessage("今日已请假(" + respVO.getLeaveTypeNames() + "),如需上班可正常打卡");
        }
        respVO.setClockable(clockable);
        respVO.setClockInDone(record != null && record.getClockInTime() != null);
        respVO.setClockOutDone(record != null && record.getClockOutTime() != null);
@@ -164,6 +186,75 @@
        respVO.setOvertimeHours(record.getOvertimeHours());
        respVO.setLocation(record.getLocation());
        respVO.setDataSource(record.getDataSource());
    }
    /**
     * æŠŠè¯·å‡ä¿¡æ¯å†™å…¥è€ƒå‹¤è®°å½•(leave_flag / leave_hours / leave_types)
     *
     * @param record è€ƒå‹¤è®°å½•
     * @param leaves å½“天审批通过的请假单
     * @param rule   è€ƒå‹¤è§„则(用于计算工作时段交集)
     * @param date   è€ƒå‹¤æ—¥æœŸ
     */
    private void applyLeaveInfo(HrmAttendanceRecordDO record,
                                List<cn.iocoder.yudao.module.hrm.dal.dataobject.approval.HrmLeaveApplicationDO> leaves,
                                HrmAttendanceRuleDO rule, LocalDate date) {
        if (leaves == null || leaves.isEmpty()) {
            record.setLeaveFlag(false);
            record.setLeaveHours(BigDecimal.ZERO);
            record.setLeaveTypes(null);
            return;
        }
        record.setLeaveFlag(true);
        record.setLeaveHours(calcLeaveHours(leaves, rule, date));
        record.setLeaveTypes(leaves.stream()
                .map(l -> String.valueOf(l.getLeaveType()))
                .distinct()
                .collect(java.util.stream.Collectors.joining(",")));
    }
    /**
     * è®¡ç®—某天的请假工时
     *
     * <p>把每张请假单的 [start_time, end_time] ä¸Žè€ƒå‹¤è§„则的工作时段求交集后累加,
     * ç²¾ç¡®åˆ° 0.01 å°æ—¶ï¼ˆ36 ç§’)。无规则时按整天的请假时长折算。</p>
     *
     * <p>例:规则 08:30-17:30,请假单 14:00-18:00 â†’ äº¤é›† 14:00-17:30 = 3.5 å°æ—¶ã€‚</p>
     */
    private java.math.BigDecimal calcLeaveHours(
            List<cn.iocoder.yudao.module.hrm.dal.dataobject.approval.HrmLeaveApplicationDO> leaves,
            HrmAttendanceRuleDO rule, LocalDate date) {
        LocalDateTime dayStart = date.atStartOfDay();
        LocalDateTime dayEnd = date.plusDays(1).atStartOfDay();
        // å·¥ä½œæ—¥æ—¶æ®µï¼šä¼˜å…ˆç”¨è€ƒå‹¤è§„则,没配规则就按整天
        LocalDateTime workStart;
        LocalDateTime workEnd;
        if (rule != null && rule.getWorkStartTime() != null && rule.getWorkEndTime() != null) {
            workStart = LocalDateTime.of(date, rule.getWorkStartTime());
            workEnd = LocalDateTime.of(date, rule.getWorkEndTime());
            // è·¨é›¶ç‚¹ç­æ¬¡ï¼ˆå¦‚ 20:00-次日 04:00)
            if (!workEnd.isAfter(workStart)) {
                workEnd = workEnd.plusDays(1);
            }
        } else {
            workStart = dayStart;
            workEnd = dayEnd;
        }
        java.math.BigDecimal total = java.math.BigDecimal.ZERO;
        for (var leave : leaves) {
            if (leave.getStartTime() == null || leave.getEndTime() == null) {
                continue;
            }
            LocalDateTime from = leave.getStartTime().isAfter(workStart) ? leave.getStartTime() : workStart;
            LocalDateTime to = leave.getEndTime().isBefore(workEnd) ? leave.getEndTime() : workEnd;
            if (!to.isAfter(from)) {
                continue;
            }
            long minutes = java.time.Duration.between(from, to).toMinutes();
            total = total.add(java.math.BigDecimal.valueOf(minutes)
                    .divide(java.math.BigDecimal.valueOf(60), 2, java.math.RoundingMode.HALF_UP));
        }
        return total;
    }
    /**
@@ -210,7 +301,8 @@
     * æ ¹æ®å·¥ä½œæ—¥ã€è€ƒå‹¤è§„则与当日记录计算可打卡状态
     */
    private HrmAttendanceMyStatusRespVO.Clockable buildClockable(boolean workDay, HrmAttendanceRuleDO rule,
                                                                 HrmAttendanceRecordDO record, LocalDate date) {
                                                                 HrmAttendanceRecordDO record, LocalDate date,
                                                                 boolean onLeave) {
        HrmAttendanceMyStatusRespVO.Clockable clockable = new HrmAttendanceMyStatusRespVO.Clockable();
        if (!workDay) {
            clockable.setClockIn(false);
@@ -240,9 +332,9 @@
            clockable.setClockOut(false);
            clockable.setNextAction("in");
            clockable.setMessage("可上班打卡");
            // ä»…当天查询时提示已迟到时长
            // ä»…当天查询时提示已迟到时长;请假当天不提示迟到
            LocalDateTime now = LocalDateTime.now();
            if (rule.getWorkStartTime() != null && date.equals(now.toLocalDate())) {
            if (!onLeave && rule.getWorkStartTime() != null && date.equals(now.toLocalDate())) {
                LocalDateTime workStart = LocalDateTime.of(date, rule.getWorkStartTime());
                int lateAllowMinutes = rule.getLateAllowMinutes() != null ? rule.getLateAllowMinutes() : 0;
                if (now.isAfter(workStart.plusMinutes(lateAllowMinutes))) {
@@ -358,44 +450,61 @@
            throw exception(ATTENDANCE_CLOCK_FAIL_NOT_WORKDAY);
        }
        // 2. èŽ·å–å‘˜å·¥ä¿¡æ¯å’Œè€ƒå‹¤è§„åˆ™
        //    æ³¨æ„ï¼šå…¥å‚ userId å®žé™…传的是「员工档案 ID」,
        //    è€Œè€ƒå‹¤è®°å½•表 / è¯·å‡è¡¨çš„ user_id å­˜çš„æ˜¯ã€Œç³»ç»Ÿç”¨æˆ· ID」,必须区分。
        HrmEmployeeDO employee = employeeService.getEmployee(userId);
        if (employee == null) {
            throw exception(EMPLOYEE_NOT_EXISTS);
        }
        Long systemUserId = employee.getUserId();
        HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(employee.getDeptId());
        if (rule == null) {
            throw exception(ATTENDANCE_CLOCK_FAIL_NO_RULE);
        }
        // 3. æŸ¥è¯¢å½“日考勤记录
        HrmAttendanceRecordDO record = attendanceRecordMapper.selectByUserIdAndDate(userId, clockDate);
        // 3. æŸ¥è¯¢å½“日考勤记录(用系统用户 ID)
        HrmAttendanceRecordDO record = attendanceRecordMapper.selectByUserIdAndDate(systemUserId, clockDate);
        if (record != null && record.getClockInTime() != null) {
            throw exception(ATTENDANCE_CLOCK_ALREADY_CLOCKED);
        }
        // åœ¨ä»»ä½•写库操作前完成定位校验,旧版地点字符串不能绕过范围限制。
        ClockLocation clockLocation = validateClockLocation(rule, reqVO);
        // 3.1 å½“天请假情况:请假当天允许打卡,但不判定为迟到
        //     æ³¨æ„ï¼šæ–¹æ³•入参 userId å®žä¸ºã€Œå‘˜å·¥æ¡£æ¡ˆ ID」,而请假表存的是「系统用户 ID」,
        //     å¿…须用 employee.getUserId() æ‰èƒ½å¯¹ä¸Šã€‚
        List<cn.iocoder.yudao.module.hrm.dal.dataobject.approval.HrmLeaveApplicationDO> leaves =
                leaveQueryService.getApprovedLeaveList(employee.getUserId(), clockDate);
        boolean onLeave = !leaves.isEmpty();
        // 4. åˆ¤æ–­æ‰“卡类型
        LocalTime workStartTime = rule.getWorkStartTime();
        int lateAllowMinutes = rule.getLateAllowMinutes() != null ? rule.getLateAllowMinutes() : 0;
        LocalTime lateThreshold = workStartTime.plusMinutes(lateAllowMinutes);
        LocalTime clockTimeOnly = clockTime.toLocalTime();
        Integer clockInType = clockTimeOnly.isBefore(lateThreshold) || clockTimeOnly.equals(lateThreshold)
                ? HrmAttendanceClockTypeEnum.NORMAL.getType()
                : HrmAttendanceClockTypeEnum.LATE.getType();
        Integer clockInType;
        if (onLeave) {
            // è¯·å‡å½“天:只要有打卡就算正常,迟到与否不影响考勤
            clockInType = HrmAttendanceClockTypeEnum.NORMAL.getType();
        } else {
            clockInType = clockTimeOnly.isBefore(lateThreshold) || clockTimeOnly.equals(lateThreshold)
                    ? HrmAttendanceClockTypeEnum.NORMAL.getType()
                    : HrmAttendanceClockTypeEnum.LATE.getType();
        }
        // 5. åˆ›å»ºæˆ–更新考勤记录
        if (record == null) {
            record = HrmAttendanceRecordDO.builder()
                    .userId(userId)
                    .userId(systemUserId)
                    .deptId(employee.getDeptId())
                    .date(clockDate)
                    .clockInTime(clockTime)
                    .clockInType(clockInType)
                    .dataSource(HrmAttendanceDataSourceEnum.CLOCK.getType())
                    .build();
            applyLeaveInfo(record, leaves, rule, clockDate);
            applyClockLocation(record, rule, clockLocation, legacyLocation, true);
            attendanceRecordMapper.insert(record);
        } else {
            record.setClockInTime(clockTime);
            record.setClockInType(clockInType);
            applyLeaveInfo(record, leaves, rule, clockDate);
            applyClockLocation(record, rule, clockLocation, legacyLocation, true);
            attendanceRecordMapper.updateById(record);
        }
@@ -417,12 +526,13 @@
        if (employee == null) {
            throw exception(EMPLOYEE_NOT_EXISTS);
        }
        Long systemUserId = employee.getUserId();
        HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(employee.getDeptId());
        if (rule == null) {
            throw exception(ATTENDANCE_CLOCK_FAIL_NO_RULE);
        }
        // 3. æŸ¥è¯¢å½“日考勤记录
        HrmAttendanceRecordDO record = attendanceRecordMapper.selectByUserIdAndDate(userId, clockDate);
        // 3. æŸ¥è¯¢å½“日考勤记录(系统用户 ID)
        HrmAttendanceRecordDO record = attendanceRecordMapper.selectByUserIdAndDate(systemUserId, clockDate);
        if (record != null && record.getClockOutTime() != null) {
            throw exception(ATTENDANCE_CLOCK_ALREADY_CLOCKED);
        }
@@ -430,24 +540,40 @@
        ClockLocation clockLocation = validateClockLocation(rule, reqVO);
        if (record == null) {
            record = HrmAttendanceRecordDO.builder()
                    .userId(userId)
                    .userId(systemUserId)
                    .deptId(employee.getDeptId())
                    .date(clockDate)
                    .clockInType(HrmAttendanceClockTypeEnum.MISSING.getType())
                    .dataSource(HrmAttendanceDataSourceEnum.CLOCK.getType())
                    .build();
        }
        // 3.1 å½“天请假情况:请假当天允许打卡,不判定为早退,也不算缺上班卡
        //     åŒä¸Šï¼šç”¨ç³»ç»Ÿç”¨æˆ· ID æŸ¥è¯·å‡è¡¨
        List<cn.iocoder.yudao.module.hrm.dal.dataobject.approval.HrmLeaveApplicationDO> leaves =
                leaveQueryService.getApprovedLeaveList(employee.getUserId(), clockDate);
        boolean onLeave = !leaves.isEmpty();
        if (onLeave && HrmAttendanceClockTypeEnum.MISSING.getType().equals(record.getClockInType())) {
            // è¯·å‡å½“天没打上班卡属于正常(人没来),不应记缺卡
            record.setClockInType(HrmAttendanceClockTypeEnum.LEAVE.getType());
        }
        // 4. åˆ¤æ–­æ‰“卡类型
        LocalTime workEndTime = rule.getWorkEndTime();
        int earlyAllowMinutes = rule.getEarlyAllowMinutes() != null ? rule.getEarlyAllowMinutes() : 0;
        LocalTime earlyThreshold = workEndTime.minusMinutes(earlyAllowMinutes);
        LocalTime clockTimeOnly = clockTime.toLocalTime();
        Integer clockOutType = clockTimeOnly.isAfter(earlyThreshold) || clockTimeOnly.equals(earlyThreshold)
                ? HrmAttendanceClockTypeEnum.NORMAL.getType()
                : HrmAttendanceClockTypeEnum.EARLY.getType();
        Integer clockOutType;
        if (onLeave) {
            // è¯·å‡å½“天:有打卡就算正常
            clockOutType = HrmAttendanceClockTypeEnum.NORMAL.getType();
        } else {
            clockOutType = clockTimeOnly.isAfter(earlyThreshold) || clockTimeOnly.equals(earlyThreshold)
                    ? HrmAttendanceClockTypeEnum.NORMAL.getType()
                    : HrmAttendanceClockTypeEnum.EARLY.getType();
        }
        // 5. æ›´æ–°è€ƒå‹¤è®°å½•
        record.setClockOutTime(clockTime);
        record.setClockOutType(clockOutType);
        applyLeaveInfo(record, leaves, rule, clockDate);
        applyClockLocation(record, rule, clockLocation, legacyLocation, false);
        // 6. è®¡ç®—工作时长
        if (record.getClockInTime() != null) {
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceSummaryServiceImpl.java
@@ -5,11 +5,13 @@
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceSummaryPageReqVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceRecordDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceRuleDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceSummaryDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO;
import cn.iocoder.yudao.module.hrm.dal.mysql.attendance.HrmAttendanceRecordMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.attendance.HrmAttendanceSummaryMapper;
import cn.iocoder.yudao.module.hrm.enums.HrmAttendanceClockTypeEnum;
import cn.iocoder.yudao.module.hrm.enums.HrmAuditStatusEnum;
import cn.iocoder.yudao.module.hrm.service.employee.HrmEmployeeService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
@@ -19,6 +21,7 @@
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.util.List;
@@ -40,6 +43,10 @@
    private HrmEmployeeService employeeService;
    @Resource
    private HrmAttendanceHolidayService attendanceHolidayService;
    @Resource
    private cn.iocoder.yudao.module.hrm.dal.mysql.approval.HrmLeaveApplicationMapper leaveApplicationMapper;
    @Resource
    private HrmAttendanceRuleService attendanceRuleService;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -59,6 +66,14 @@
        // 3. æŸ¥è¯¢è¯¥æœˆæ‰€æœ‰è€ƒå‹¤è®°å½•
        List<HrmAttendanceRecordDO> records = attendanceRecordMapper.selectListByUserIdAndDateBetween(userId, startDate, endDate);
        // 3.1 æŸ¥è¯¢è¯¥æœˆæ‰€æœ‰å®¡æ‰¹é€šè¿‡çš„请假单
        //     è€ƒå‹¤è®°å½•只在「打卡」时才创建,请假当天没来上班就不会有记录,
        //     æ‰€ä»¥å¿…须单独查请假单,否则 leaveHours ä¼šæ¼ç®—。
        List<cn.iocoder.yudao.module.hrm.dal.dataobject.approval.HrmLeaveApplicationDO> monthLeaves =
                leaveApplicationMapper.selectListByUserIdAndOverlapDate(
                        userId, HrmAuditStatusEnum.APPROVE.getStatus(),
                        startDate.atStartOfDay(), endDate.plusDays(1).atStartOfDay());
        // 4. è®¡ç®—应出勤天数(工作日数量)
        int workDays = calculateWorkDays(startDate, endDate);
@@ -67,31 +82,76 @@
        int earlyCount = 0;
        int absentCount = 0;
        BigDecimal overtimeHours = BigDecimal.ZERO;
        BigDecimal leaveHours = BigDecimal.ZERO;
        int actualDays = 0;
        for (HrmAttendanceRecordDO record : records) {
            // ç»Ÿè®¡è¿Ÿåˆ°
            if (HrmAttendanceClockTypeEnum.LATE.getType().equals(record.getClockInType())) {
                lateCount++;
            // è¯·å‡æ ‡è®°ï¼šå®¡æ‰¹é€šè¿‡çš„请假单落库时已写入 record.leaveFlag
            boolean onLeave = Boolean.TRUE.equals(record.getLeaveFlag());
            // ç´¯è®¡è¯·å‡å·¥æ—¶
            if (onLeave && record.getLeaveHours() != null) {
                leaveHours = leaveHours.add(record.getLeaveHours());
            }
            // ç»Ÿè®¡æ—©é€€
            if (HrmAttendanceClockTypeEnum.EARLY.getType().equals(record.getClockOutType())) {
                earlyCount++;
            }
            // ç»Ÿè®¡ç¼ºå¡
            if (HrmAttendanceClockTypeEnum.MISSING.getType().equals(record.getClockInType())) {
                absentCount++;
            }
            if (HrmAttendanceClockTypeEnum.MISSING.getType().equals(record.getClockOutType())) {
                absentCount++;
            // è¯·å‡å½“天不计迟到、早退、缺卡(请假类型本身是 LEAVE,不会命中下面的判断)
            if (!onLeave) {
                // ç»Ÿè®¡è¿Ÿåˆ°
                if (HrmAttendanceClockTypeEnum.LATE.getType().equals(record.getClockInType())) {
                    lateCount++;
                }
                // ç»Ÿè®¡æ—©é€€
                if (HrmAttendanceClockTypeEnum.EARLY.getType().equals(record.getClockOutType())) {
                    earlyCount++;
                }
                // ç»Ÿè®¡ç¼ºå¡ï¼šè¯·å‡å½“天没打卡属正常,不算缺卡
                if (HrmAttendanceClockTypeEnum.MISSING.getType().equals(record.getClockInType())) {
                    absentCount++;
                }
                if (HrmAttendanceClockTypeEnum.MISSING.getType().equals(record.getClockOutType())) {
                    absentCount++;
                }
            }
            // ç´¯è®¡åŠ ç­æ—¶é•¿
            if (record.getOvertimeHours() != null) {
                overtimeHours = overtimeHours.add(record.getOvertimeHours());
            }
            // ç»Ÿè®¡å®žé™…出勤天数(有正常打卡记录)
            // ç»Ÿè®¡å®žé™…出勤天数(有打卡记录即算;纯请假未打卡不算出勤)
            if (record.getClockInTime() != null || record.getClockOutTime() != null) {
                actualDays++;
            }
        }
        // 5.1 è¡¥ç®—「有请假单但没有考勤记录」的日子
        //     è¿™ç±»æƒ…况是请假当天完全没来打卡,不会生成 record,
        //     ä¸Šé¢å¾ªçŽ¯è¦†ç›–ä¸åˆ°ï¼Œéœ€è¦å•ç‹¬æŒ‰è¯·å‡å•ç´¯è®¡å·¥æ—¶ã€‚
        if (!monthLeaves.isEmpty()) {
            java.util.Set<LocalDate> recordDates = records.stream()
                    .map(HrmAttendanceRecordDO::getDate).collect(java.util.stream.Collectors.toSet());
            HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(employee.getDeptId());
            // æŒ‰å¤©å½’组请假单
            java.util.Map<LocalDate, List<cn.iocoder.yudao.module.hrm.dal.dataobject.approval.HrmLeaveApplicationDO>> byDate
                    = new java.util.LinkedHashMap<>();
            for (var leave : monthLeaves) {
                if (leave.getStartTime() == null || leave.getEndTime() == null) {
                    continue;
                }
                LocalDate d = leave.getStartTime().toLocalDate();
                LocalDate last = leave.getEndTime().toLocalDate();
                if (last.isAfter(endDate)) {
                    last = endDate;
                }
                for (LocalDate cur = d; !cur.isAfter(last); cur = cur.plusDays(1)) {
                    if (cur.isBefore(startDate)) {
                        continue;
                    }
                    byDate.computeIfAbsent(cur, k -> new java.util.ArrayList<>()).add(leave);
                }
            }
            for (var entry : byDate.entrySet()) {
                // å·²æœ‰è€ƒå‹¤è®°å½•的日期,请假工时已在 record.leaveHours é‡Œç®—过
                if (recordDates.contains(entry.getKey())) {
                    continue;
                }
                leaveHours = leaveHours.add(calcLeaveHoursByDay(entry.getValue(), rule, entry.getKey()));
            }
        }
@@ -108,7 +168,7 @@
                    .earlyCount(earlyCount)
                    .absentCount(absentCount)
                    .overtimeHours(overtimeHours)
                    .leaveHours(BigDecimal.ZERO)
                    .leaveHours(leaveHours)
                    .build();
            attendanceSummaryMapper.insert(summary);
        } else {
@@ -118,6 +178,7 @@
            summary.setEarlyCount(earlyCount);
            summary.setAbsentCount(absentCount);
            summary.setOvertimeHours(overtimeHours);
            summary.setLeaveHours(leaveHours); // è¯·å‡å·¥æ—¶ä¹Ÿè¦æ›´æ–°ï¼Œå¦åˆ™é‡ç®—时永远停在上一次
            attendanceSummaryMapper.updateById(summary);
        }
    }
@@ -155,6 +216,43 @@
    /**
     * è®¡ç®—应出勤天数(工作日数量)
     */
    /**
     * è®¡ç®—某一天的请假工时(与考勤记录里的算法保持一致)
     *
     * <p>取每张请假单与考勤规则工作时段的交集累加,精确到 0.01 å°æ—¶ã€‚</p>
     */
    private BigDecimal calcLeaveHoursByDay(
            List<cn.iocoder.yudao.module.hrm.dal.dataobject.approval.HrmLeaveApplicationDO> leaves,
            HrmAttendanceRuleDO rule, LocalDate date) {
        LocalDateTime workStart;
        LocalDateTime workEnd;
        if (rule != null && rule.getWorkStartTime() != null && rule.getWorkEndTime() != null) {
            workStart = LocalDateTime.of(date, rule.getWorkStartTime());
            workEnd = LocalDateTime.of(date, rule.getWorkEndTime());
            if (!workEnd.isAfter(workStart)) {
                workEnd = workEnd.plusDays(1);
            }
        } else {
            workStart = date.atStartOfDay();
            workEnd = date.plusDays(1).atStartOfDay();
        }
        BigDecimal total = BigDecimal.ZERO;
        for (var leave : leaves) {
            if (leave.getStartTime() == null || leave.getEndTime() == null) {
                continue;
            }
            LocalDateTime from = leave.getStartTime().isAfter(workStart) ? leave.getStartTime() : workStart;
            LocalDateTime to = leave.getEndTime().isBefore(workEnd) ? leave.getEndTime() : workEnd;
            if (!to.isAfter(from)) {
                continue;
            }
            long minutes = java.time.Duration.between(from, to).toMinutes();
            total = total.add(BigDecimal.valueOf(minutes)
                    .divide(BigDecimal.valueOf(60), 2, java.math.RoundingMode.HALF_UP));
        }
        return total;
    }
    private int calculateWorkDays(LocalDate startDate, LocalDate endDate) {
        int workDays = 0;
        LocalDate current = startDate;