liu
9 天以前 82155dd6f2b16fbfb718e636a54e2c9dae31246b
feat(hrm,bi): 移植长辉电力的考勤请假联动与 BI 大屏图表

参考 dev_新疆_长辉 分支移植(与康意分叉仅 17 个提交):
考勤与请假打通、我的考勤状态、员工选择器 API,以及人资办公/
采购销售大屏的出勤与库存采购图表。

后端 HRM(21 个文件):
- 考勤记录新增请假关联字段 leaveFlag/leaveHours/leaveTypes
- 新增 HrmLeaveQueryService:按日期区间查询员工请假
- 考勤记录服务:打卡/补卡与请假状态联动,导入模板与导入解析增强
- 新增 HrmEmployeeApi / HrmEmployeeSimpleDTO:供跨模块查询员工
- 考勤汇总服务:按请假数据修正应出勤/实际出勤口径
- 新增错误码 ATTENDANCE_EMPLOYEE_NOT_BOUND
- 删除已废弃的 HrmAttendanceClockRespVO
- 保留康意原有的加班/排班/班次与 500 空值修复

后端 BI:仅新增 SQL 种子脚本(无 Java 代码变更)

数据库:
- sql/mysql/hrm_attendance_leave_fields_20261030.sql(补 3 列,幂等)
- sql/mysql/bi_add_attendance_stock_20261030.sql(放开人资大屏菜单 + 新增图表,幂等)

已在生产库 101.245.92.159:9000/mom-kyzn 执行成功,执行前备份至
mom-kyzn-chdl-backup-20261030.sql。

Co-Authored-By: Claude <noreply@anthropic.com>
已添加7个文件
已删除1个文件
已修改13个文件
854 ■■■■■ 文件已修改
sql/mysql/bi_add_attendance_stock_20261030.sql 129 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/hrm_attendance_leave_fields_20261030.sql 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/api/employee/HrmEmployeeApi.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/api/employee/dto/HrmEmployeeSimpleDTO.java 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java 9 ●●●●● 补丁 | 查看 | 原始文档 | 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/api/employee/HrmEmployeeApiImpl.java 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/HrmAttendanceRecordController.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceClockRespVO.java 20 ●●●●● 补丁 | 查看 | 原始文档 | 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/dataobject/attendance/HrmAttendanceSummaryDO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | 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/HrmLeaveApplicationService.java 5 ●●●●● 补丁 | 查看 | 原始文档 | 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 163 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceSummaryServiceImpl.java 128 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/bi_add_attendance_stock_20261030.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,129 @@
-- ============================================================
-- BI å¤§å±æ–°å¢žï¼šå‡ºå‹¤æŒ‡æ ‡ï¼ˆäººèµ„办公大屏)+ åº“存采购(采购销售大屏)
-- ç”Ÿæˆæ—¶é—´ï¼š2026-10-30
--
-- 1. æ”¾å¼€ã€Œäººèµ„办公大屏」菜单(原来 visible=0 éšè—ï¼Œå¯¼è‡´çœ‹ä¸åˆ°ï¼‰
-- 2. äººèµ„办公大屏 å¢žåŠ  å‡ºå‹¤çއ / è¿Ÿåˆ° / ç¼ºå¡ / è¯·å‡å·¥æ—¶ / å‡ºå‹¤è¶‹åŠ¿
-- 3. é‡‡è´­é”€å”®å¤§å± å¢žåŠ  åº“存可用量 / åº“存品种 / å·²å…¥åº“量 / åº“å­˜TOP10
--
-- å›¾è¡¨çº¦å®šï¼šnumber ç±»åž‹ SQL è¿”回单列 value;bar/pie è¿”回 label + value
-- å¹‚等:按 name+dashboard_id åˆ¤é‡ï¼Œå¯é‡å¤æ‰§è¡Œ
-- ============================================================
-- ---------- 1. æ”¾å¼€äººèµ„办公大屏菜单 ----------
UPDATE system_menu SET visible = b'1', updater = '1', update_time = NOW()
 WHERE id = 1063005 AND deleted = b'0';
-- ---------- 2. äººèµ„办公大屏:出勤指标 ----------
-- å¸ƒå±€ï¼šnumber å¡ç‰‡å ç”¨ y=0 è¡Œ x=12..23(原有 4 å¼ åªç”¨åˆ° x=0..11)
INSERT INTO `bi_chart_config`
  (`dashboard_id`, `name`, `chart_type`, `data_source_id`, `query_sql`, `position`,
   `chart_options`, `refresh_interval`, `sort`, `status`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT d.id, '本月出勤率', 'number', NULL,
  'SELECT ROUND(SUM(actual_days)/NULLIF(SUM(work_days),0)*100,1) AS value FROM hrm_attendance_summary WHERE deleted=0 AND `year_month`=DATE_FORMAT(NOW(),''%Y-%m'')',
  '{"h": 4, "w": 3, "x": 12, "y": 0}', NULL, 30, 20, 1, '1', NOW(), '1', NOW(), b'0'
  FROM `bi_dashboard` d
 WHERE d.`code` = 'hr_oa' AND d.deleted = b'0'
   AND NOT EXISTS (SELECT 1 FROM `bi_chart_config` c WHERE c.`dashboard_id` = d.id AND c.`name` = '本月出勤率' AND c.deleted = b'0');
INSERT INTO `bi_chart_config`
  (`dashboard_id`, `name`, `chart_type`, `data_source_id`, `query_sql`, `position`,
   `chart_options`, `refresh_interval`, `sort`, `status`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT d.id, '本月迟到次数', 'number', NULL,
  'SELECT IFNULL(SUM(late_count),0) AS value FROM hrm_attendance_summary WHERE deleted=0 AND `year_month`=DATE_FORMAT(NOW(),''%Y-%m'')',
  '{"h": 4, "w": 3, "x": 15, "y": 0}', NULL, 30, 21, 1, '1', NOW(), '1', NOW(), b'0'
  FROM `bi_dashboard` d
 WHERE d.`code` = 'hr_oa' AND d.deleted = b'0'
   AND NOT EXISTS (SELECT 1 FROM `bi_chart_config` c WHERE c.`dashboard_id` = d.id AND c.`name` = '本月迟到次数' AND c.deleted = b'0');
INSERT INTO `bi_chart_config`
  (`dashboard_id`, `name`, `chart_type`, `data_source_id`, `query_sql`, `position`,
   `chart_options`, `refresh_interval`, `sort`, `status`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT d.id, '本月缺卡次数', 'number', NULL,
  'SELECT IFNULL(SUM(absent_count),0) AS value FROM hrm_attendance_summary WHERE deleted=0 AND `year_month`=DATE_FORMAT(NOW(),''%Y-%m'')',
  '{"h": 4, "w": 3, "x": 18, "y": 0}', NULL, 30, 22, 1, '1', NOW(), '1', NOW(), b'0'
  FROM `bi_dashboard` d
 WHERE d.`code` = 'hr_oa' AND d.deleted = b'0'
   AND NOT EXISTS (SELECT 1 FROM `bi_chart_config` c WHERE c.`dashboard_id` = d.id AND c.`name` = '本月缺卡次数' AND c.deleted = b'0');
INSERT INTO `bi_chart_config`
  (`dashboard_id`, `name`, `chart_type`, `data_source_id`, `query_sql`, `position`,
   `chart_options`, `refresh_interval`, `sort`, `status`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT d.id, '本月请假工时', 'number', NULL,
  'SELECT ROUND(IFNULL(SUM(leave_hours),0),1) AS value FROM hrm_attendance_summary WHERE deleted=0 AND `year_month`=DATE_FORMAT(NOW(),''%Y-%m'')',
  '{"h": 4, "w": 3, "x": 21, "y": 0}', NULL, 30, 23, 1, '1', NOW(), '1', NOW(), b'0'
  FROM `bi_dashboard` d
 WHERE d.`code` = 'hr_oa' AND d.deleted = b'0'
   AND NOT EXISTS (SELECT 1 FROM `bi_chart_config` c WHERE c.`dashboard_id` = d.id AND c.`name` = '本月请假工时' AND c.deleted = b'0');
-- å‡ºå‹¤è¶‹åŠ¿ï¼ˆæœˆåº¦å‡ºå‹¤çŽ‡æŠ˜çº¿ï¼Œå  x=12..23 åŒºåŸŸï¼‰
INSERT INTO `bi_chart_config`
  (`dashboard_id`, `name`, `chart_type`, `data_source_id`, `query_sql`, `position`,
   `chart_options`, `refresh_interval`, `sort`, `status`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT d.id, '出勤率趋势', 'line', NULL,
  'SELECT `year_month` AS label, ROUND(SUM(actual_days)/NULLIF(SUM(work_days),0)*100,1) AS value FROM hrm_attendance_summary WHERE deleted=0 GROUP BY `year_month` ORDER BY `year_month`',
  '{"h": 8, "w": 12, "x": 12, "y": 4}', NULL, 60, 24, 1, '1', NOW(), '1', NOW(), b'0'
  FROM `bi_dashboard` d
 WHERE d.`code` = 'hr_oa' AND d.deleted = b'0'
   AND NOT EXISTS (SELECT 1 FROM `bi_chart_config` c WHERE c.`dashboard_id` = d.id AND c.`name` = '出勤率趋势' AND c.deleted = b'0');
-- ---------- 3. é‡‡è´­é”€å”®å¤§å±ï¼šåº“存采购 ----------
INSERT INTO `bi_chart_config`
  (`dashboard_id`, `name`, `chart_type`, `data_source_id`, `query_sql`, `position`,
   `chart_options`, `refresh_interval`, `sort`, `status`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT d.id, '库存可用总量', 'number', NULL,
  'SELECT ROUND(IFNULL(SUM(quantity-frozen_quantity-reserved_quantity),0),0) AS value FROM mes_wm_material_stock WHERE deleted=0 AND frozen=0',
  '{"h": 4, "w": 3, "x": 12, "y": 0}', NULL, 60, 20, 1, '1', NOW(), '1', NOW(), b'0'
  FROM `bi_dashboard` d
 WHERE d.`code` = 'purchase_sales' AND d.deleted = b'0'
   AND NOT EXISTS (SELECT 1 FROM `bi_chart_config` c WHERE c.`dashboard_id` = d.id AND c.`name` = '库存可用总量' AND c.deleted = b'0');
INSERT INTO `bi_chart_config`
  (`dashboard_id`, `name`, `chart_type`, `data_source_id`, `query_sql`, `position`,
   `chart_options`, `refresh_interval`, `sort`, `status`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT d.id, '库存品种数', 'number', NULL,
  'SELECT COUNT(DISTINCT item_id) AS value FROM mes_wm_material_stock WHERE deleted=0',
  '{"h": 4, "w": 3, "x": 15, "y": 0}', NULL, 60, 21, 1, '1', NOW(), '1', NOW(), b'0'
  FROM `bi_dashboard` d
 WHERE d.`code` = 'purchase_sales' AND d.deleted = b'0'
   AND NOT EXISTS (SELECT 1 FROM `bi_chart_config` c WHERE c.`dashboard_id` = d.id AND c.`name` = '库存品种数' AND c.deleted = b'0');
INSERT INTO `bi_chart_config`
  (`dashboard_id`, `name`, `chart_type`, `data_source_id`, `query_sql`, `position`,
   `chart_options`, `refresh_interval`, `sort`, `status`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT d.id, '冻结库存量', 'number', NULL,
  'SELECT ROUND(IFNULL(SUM(frozen_quantity+reserved_quantity),0),0) AS value FROM mes_wm_material_stock WHERE deleted=0',
  '{"h": 4, "w": 3, "x": 18, "y": 0}', NULL, 60, 22, 1, '1', NOW(), '1', NOW(), b'0'
  FROM `bi_dashboard` d
 WHERE d.`code` = 'purchase_sales' AND d.deleted = b'0'
   AND NOT EXISTS (SELECT 1 FROM `bi_chart_config` c WHERE c.`dashboard_id` = d.id AND c.`name` = '冻结库存量' AND c.deleted = b'0');
INSERT INTO `bi_chart_config`
  (`dashboard_id`, `name`, `chart_type`, `data_source_id`, `query_sql`, `position`,
   `chart_options`, `refresh_interval`, `sort`, `status`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT d.id, '供应商到货批次', 'number', NULL,
  'SELECT COUNT(*) AS value FROM mes_wm_material_stock WHERE deleted=0 AND vendor_id IS NOT NULL',
  '{"h": 4, "w": 3, "x": 21, "y": 0}', NULL, 60, 23, 1, '1', NOW(), '1', NOW(), b'0'
  FROM `bi_dashboard` d
 WHERE d.`code` = 'purchase_sales' AND d.deleted = b'0'
   AND NOT EXISTS (SELECT 1 FROM `bi_chart_config` c WHERE c.`dashboard_id` = d.id AND c.`name` = '供应商到货批次' AND c.deleted = b'0');
-- åº“å­˜ TOP10(横向柱状)
INSERT INTO `bi_chart_config`
  (`dashboard_id`, `name`, `chart_type`, `data_source_id`, `query_sql`, `position`,
   `chart_options`, `refresh_interval`, `sort`, `status`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT d.id, '库存数量 TOP10', 'bar', NULL,
  'SELECT i.name AS label, ROUND(SUM(s.quantity-s.frozen_quantity-s.reserved_quantity),0) AS value FROM mes_wm_material_stock s JOIN mes_md_item i ON i.id=s.item_id AND i.deleted=0 WHERE s.deleted=0 GROUP BY i.name ORDER BY value DESC LIMIT 10',
  '{"h": 8, "w": 12, "x": 12, "y": 4}', NULL, 120, 24, 1, '1', NOW(), '1', NOW(), b'0'
  FROM `bi_dashboard` d
 WHERE d.`code` = 'purchase_sales' AND d.deleted = b'0'
   AND NOT EXISTS (SELECT 1 FROM `bi_chart_config` c WHERE c.`dashboard_id` = d.id AND c.`name` = '库存数量 TOP10' AND c.deleted = b'0');
-- ---------- 4. æ ¡éªŒ ----------
SELECT d.`code` AS å¤§å±, COUNT(*) AS å›¾è¡¨æ•°
  FROM bi_chart_config c JOIN bi_dashboard d ON d.id = c.dashboard_id
 WHERE d.`code` IN ('hr_oa','purchase_sales') AND c.deleted = b'0'
 GROUP BY d.`code`;
SELECT id, name, visible+0 AS vis FROM system_menu WHERE id = 1063005;
sql/mysql/hrm_attendance_leave_fields_20261030.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,31 @@
-- ============================================================================
-- è€ƒå‹¤è®°å½•补充请假关联字段(随长辉电力分支移植)
--
-- ç”¨é€”:考勤记录展示请假状态、请假工时,打卡面板提示已请假
-- å¯¹åº”实体:HrmAttendanceRecordDO#leaveFlag / leaveHours / leaveTypes
-- ç‰¹æ€§ï¼šå¹‚等,可重复执行
-- ============================================================================
SET @exists := (SELECT COUNT(*) FROM information_schema.columns
                WHERE table_schema = DATABASE() AND table_name = 'hrm_attendance_record'
                  AND column_name = 'leave_flag');
SET @sql := IF(@exists = 0,
    "ALTER TABLE `hrm_attendance_record` ADD COLUMN `leave_flag` bit(1) NOT NULL DEFAULT b'0' COMMENT '当天是否请假'",
    "SELECT 1");
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @exists := (SELECT COUNT(*) FROM information_schema.columns
                WHERE table_schema = DATABASE() AND table_name = 'hrm_attendance_record'
                  AND column_name = 'leave_hours');
SET @sql := IF(@exists = 0,
    "ALTER TABLE `hrm_attendance_record` ADD COLUMN `leave_hours` decimal(10,2) DEFAULT NULL COMMENT '当天请假工时'",
    "SELECT 1");
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @exists := (SELECT COUNT(*) FROM information_schema.columns
                WHERE table_schema = DATABASE() AND table_name = 'hrm_attendance_record'
                  AND column_name = 'leave_types');
SET @sql := IF(@exists = 0,
    "ALTER TABLE `hrm_attendance_record` ADD COLUMN `leave_types` varchar(255) DEFAULT NULL COMMENT '当天请假类型,多个以逗号分隔'",
    "SELECT 1");
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/api/employee/HrmEmployeeApi.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,42 @@
package cn.iocoder.yudao.module.hrm.api.employee;
import cn.iocoder.yudao.module.hrm.api.employee.dto.HrmEmployeeSimpleDTO;
import java.util.Collection;
import java.util.List;
/**
 * å‘˜å·¥æ¡£æ¡ˆ API æŽ¥å£
 *
 * <p>提供给其他模块跨模块查询员工精简信息,
 * ä¾‹å¦‚安全生产模块的特种作业证需要关联持证人。</p>
 *
 * @author è¶…级管理员
 */
public interface HrmEmployeeApi {
    /**
     * èŽ·å–å‘˜å·¥ç²¾ç®€ä¿¡æ¯
     *
     * @param id å‘˜å·¥ç¼–号
     * @return å‘˜å·¥ä¿¡æ¯ï¼Œä¸å­˜åœ¨è¿”回 null
     */
    HrmEmployeeSimpleDTO getEmployee(Long id);
    /**
     * æ‰¹é‡èŽ·å–å‘˜å·¥ç²¾ç®€ä¿¡æ¯
     *
     * @param ids å‘˜å·¥ç¼–号集合
     * @return å‘˜å·¥ä¿¡æ¯åˆ—表
     */
    List<HrmEmployeeSimpleDTO> getEmployeeList(Collection<Long> ids);
    /**
     * æ ¡éªŒå‘˜å·¥æ˜¯å¦å­˜åœ¨ï¼ˆä¸”未删除)
     *
     * @param id å‘˜å·¥ç¼–号
     * @return æ˜¯å¦å­˜åœ¨
     */
    boolean validateEmployeeExists(Long id);
}
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/api/employee/dto/HrmEmployeeSimpleDTO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,57 @@
package cn.iocoder.yudao.module.hrm.api.employee.dto;
import lombok.Data;
/**
 * å‘˜å·¥ç²¾ç®€ä¿¡æ¯ DTO
 *
 * <p>供其他模块(如安全生产模块的特种作业证)关联员工档案时使用。</p>
 *
 * @author è¶…级管理员
 */
@Data
public class HrmEmployeeSimpleDTO {
    /**
     * å‘˜å·¥ç¼–号
     */
    private Long id;
    /**
     * å…³è”的系统用户编号
     *
     * å…³è” AdminUserDO#getId()
     */
    private Long userId;
    /**
     * å‘˜å·¥ç¼–号(工号)
     */
    private String employeeNo;
    /**
     * å‘˜å·¥å§“名
     */
    private String name;
    /**
     * éƒ¨é—¨ç¼–号
     */
    private Long deptId;
    /**
     * éƒ¨é—¨åç§°
     */
    private String deptName;
    /**
     * æ‰‹æœºå·ç 
     */
    private String phone;
    /**
     * å‘˜å·¥çŠ¶æ€ï¼š1-在职,2-试用,3-离职
     */
    private Integer employeeStatus;
}
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java
@@ -94,6 +94,15 @@
    ErrorCode ATTENDANCE_EXCEPTION_BPM_PROCESS_DEFINITION_NOT_EXISTS = new ErrorCode(1_030_004_015, "考勤异常审批流程不存在");
    ErrorCode ATTENDANCE_CLOCK_ALREADY_CLOCKED = new ErrorCode(1_030_004_016, "已打过卡,请勿重复打卡");
    ErrorCode ATTENDANCE_HOLIDAY_DATE_RANGE_INVALID = new ErrorCode(1_030_004_017, "结束日期【{}】不能早于开始日期【{}】,无法保存。请重新选择正确的日期区间");
    /**
     * ç™»å½•账号未绑定员工档案。
     *
     * æ³¨æ„ï¼šä¸è¦å¤ç”¨é€šç”¨çš„ EMPLOYEE_NOT_EXISTS(1_030_000_000)去传自定义文案 â€”—
     * è¯¥é”™è¯¯ç çš„æ¶ˆæ¯æ˜¯ã€Œå‘˜å·¥ä¸å­˜åœ¨ã€ï¼Œä¸å« {} å ä½ç¬¦ï¼ŒServiceExceptionUtil ä¼šæ‰“
     * ã€Œå‚数过多」警告并丢弃传入的文案,用户只能看到「员工不存在」,
     * æ‹¿ä¸åˆ°ã€Œè¯·è”系管理员绑定员工档案」这句可操作的提示。
     */
    ErrorCode ATTENDANCE_EMPLOYEE_NOT_BOUND = new ErrorCode(1_030_004_018, "当前登录账号未关联员工档案,无法打卡。请联系管理员在【员工管理】中将该登录账号绑定到员工");
    ErrorCode ATTENDANCE_LOCATION_RULE_INVALID = new ErrorCode(1_030_004_050, "考勤规则地点配置不完整,请联系管理员");
    ErrorCode ATTENDANCE_LOCATION_REQUIRED = new ErrorCode(1_030_004_051, "请开启定位并提供经纬度、精度和坐标系后打卡");
    ErrorCode ATTENDANCE_LOCATION_COORDINATE_INVALID = new ErrorCode(1_030_004_052, "打卡经纬度超出有效范围");
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/api/employee/HrmEmployeeApiImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,82 @@
package cn.iocoder.yudao.module.hrm.api.employee;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.hrm.api.employee.dto.HrmEmployeeSimpleDTO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeMapper;
import cn.iocoder.yudao.module.system.api.dept.DeptApi;
import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
 * å‘˜å·¥æ¡£æ¡ˆ API å®žçŽ°ç±»
 *
 * @author è¶…级管理员
 */
@Service
public class HrmEmployeeApiImpl implements HrmEmployeeApi {
    @Resource
    private HrmEmployeeMapper employeeMapper;
    @Resource
    private DeptApi deptApi;
    @Override
    public HrmEmployeeSimpleDTO getEmployee(Long id) {
        if (id == null) {
            return null;
        }
        HrmEmployeeDO employee = employeeMapper.selectById(id);
        if (employee == null) {
            return null;
        }
        return build(employee, getDeptName(employee.getDeptId()));
    }
    @Override
    public List<HrmEmployeeSimpleDTO> getEmployeeList(Collection<Long> ids) {
        if (ids == null || ids.isEmpty()) {
            return List.of();
        }
        List<HrmEmployeeDO> employees = employeeMapper.selectBatchIds(ids);
        if (employees.isEmpty()) {
            return List.of();
        }
        // æ‰¹é‡æŸ¥éƒ¨é—¨ï¼Œé¿å… N+1
        List<Long> deptIds = employees.stream().map(HrmEmployeeDO::getDeptId)
                .filter(Objects::nonNull).distinct().toList();
        Map<Long, DeptRespDTO> deptMap = deptIds.isEmpty() ? Map.of() : deptApi.getDeptMap(deptIds);
        return employees.stream()
                .map(e -> {
                    DeptRespDTO dept = deptMap.get(e.getDeptId());
                    return build(e, dept == null ? null : dept.getName());
                })
                .toList();
    }
    @Override
    public boolean validateEmployeeExists(Long id) {
        return id != null && employeeMapper.selectById(id) != null;
    }
    private String getDeptName(Long deptId) {
        if (deptId == null) {
            return null;
        }
        DeptRespDTO dept = deptApi.getDept(deptId);
        return dept == null ? null : dept.getName();
    }
    private static HrmEmployeeSimpleDTO build(HrmEmployeeDO employee, String deptName) {
        HrmEmployeeSimpleDTO dto = BeanUtils.toBean(employee, HrmEmployeeSimpleDTO.class);
        dto.setDeptName(deptName);
        return dto;
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/HrmAttendanceRecordController.java
@@ -9,7 +9,6 @@
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.framework.operatelog.core.util.OperateLogUtils;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceClockReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceClockRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceMyStatusRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRecordImportExcelVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRecordImportReqVO;
@@ -47,7 +46,7 @@
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY;
import static cn.iocoder.yudao.module.hrm.enums.ErrorCodeConstants.EMPLOYEE_NOT_EXISTS;
import static cn.iocoder.yudao.module.hrm.enums.ErrorCodeConstants.ATTENDANCE_EMPLOYEE_NOT_BOUND;
import static cn.iocoder.yudao.module.hrm.enums.LogRecordConstants.*;
@Tag(name = "管理后台 - HRM è€ƒå‹¤è®°å½•")
@@ -67,44 +66,39 @@
    @PostMapping("/clock-in")
    @Operation(summary = "上班打卡")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-record:clock')")
    @ApiAccessLog(requestEnable = false)
    public CommonResult<HrmAttendanceClockRespVO> clockIn(
    public CommonResult<HrmAttendanceRecordRespVO> clockIn(
            @Valid @RequestBody(required = false) HrmAttendanceClockReqVO reqVO,
            @RequestParam(value = "location", required = false) String legacyLocation) {
        Long loginUserId = getLoginUserId();
        HrmEmployeeDO employee = employeeService.getEmployeeByUserId(loginUserId);
        if (employee == null) {
            throw exception(EMPLOYEE_NOT_EXISTS,
                    "当前登录账号未关联员工档案,无法打卡。请联系管理员在【员工管理】中将该登录账号绑定到员工。");
            throw exception(ATTENDANCE_EMPLOYEE_NOT_BOUND);
        }
        HrmAttendanceClockResult result = attendanceRecordService.clockIn(
                employee.getId(), LocalDateTime.now(), reqVO, legacyLocation);
        return success(buildClockResultVO(result, employee));
        return success(buildClockRespVO(result.record(), employee));
    }
    @PostMapping("/clock-out")
    @Operation(summary = "下班打卡")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-record:clock')")
    @ApiAccessLog(requestEnable = false)
    public CommonResult<HrmAttendanceClockRespVO> clockOut(
    public CommonResult<HrmAttendanceRecordRespVO> clockOut(
            @Valid @RequestBody(required = false) HrmAttendanceClockReqVO reqVO,
            @RequestParam(value = "location", required = false) String legacyLocation) {
        Long loginUserId = getLoginUserId();
        HrmEmployeeDO employee = employeeService.getEmployeeByUserId(loginUserId);
        if (employee == null) {
            throw exception(EMPLOYEE_NOT_EXISTS,
                    "当前登录账号未关联员工档案,无法打卡。请联系管理员在【员工管理】中将该登录账号绑定到员工。");
            throw exception(ATTENDANCE_EMPLOYEE_NOT_BOUND);
        }
        HrmAttendanceClockResult result = attendanceRecordService.clockOut(
                employee.getId(), LocalDateTime.now(), reqVO, legacyLocation);
        return success(buildClockResultVO(result, employee));
        return success(buildClockRespVO(result.record(), employee));
    }
    @GetMapping("/my-status")
    @Operation(summary = "获得当前登录人的考勤状态")
    @Parameter(name = "date", description = "考勤日期,格式 yyyy-MM-dd,默认服务器当天", example = "2026-10-17")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-record:clock')")
    public CommonResult<HrmAttendanceMyStatusRespVO> getMyStatus(
            @RequestParam(value = "date", required = false)
            @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY) LocalDate date) {
@@ -124,19 +118,6 @@
            if (dept != null) {
                respVO.setDeptName(dept.getName());
            }
        }
        return respVO;
    }
    private HrmAttendanceClockRespVO buildClockResultVO(HrmAttendanceClockResult result,
                                                         HrmEmployeeDO employee) {
        HrmAttendanceClockRespVO respVO = new HrmAttendanceClockRespVO();
        respVO.setRecord(buildClockRespVO(result.record(), employee));
        respVO.setLocationName(result.rule().getLocationName());
        if (Boolean.TRUE.equals(result.rule().getLocationRequired())) {
            respVO.setDistanceMeters(result.distanceMeters());
            respVO.setRadiusMeters(result.rule().getRadiusMeters());
            respVO.setWithinRange(true);
        }
        return respVO;
    }
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceClockRespVO.java
ÎļþÒÑɾ³ý
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/dataobject/attendance/HrmAttendanceSummaryDO.java
@@ -45,7 +45,9 @@
    /**
     * å¹´æœˆï¼Œæ ¼å¼ï¼šyyyy-MM
     *
     * æ³¨æ„ï¼šyear_month æ˜¯ MySQL ä¿ç•™å­—,必须显式加反引号,否则 SELECT è¯­å¥ä¼šæŠ¥è¯­æ³•错误
     * YEAR_MONTH åœ¨ MySQL 8 ä¸­æ˜¯ä¿ç•™å…³é”®å­—(日期单位),
     * ä¸åŠ åå¼•å·ä¼šå¯¼è‡´ MyBatis-Plus ç”Ÿæˆçš„ SQL æŠ¥ 1064 è¯­æ³•错误,
     * å› æ­¤è¿™é‡Œæ˜¾å¼æŒ‡å®šå¸¦åå¼•号的列名。
     */
    @TableField("`year_month`")
    private String yearMonth;
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/HrmLeaveApplicationService.java
@@ -24,8 +24,11 @@
    /**
     * BPM è¯·å‡å®¡æ‰¹åˆ†ç±»ç¼–码
     *
     * è¯·å‡æµç¨‹ç»Ÿä¸€å½’在「考勤异常审批」分类下,与考勤异常共用同一个审批分类,
     * ä¸å†å•独建 hrm_leave_approve åˆ†ç±»ã€‚
     */
    String APPROVE_CATEGORY_CODE = "hrm_leave_approve";
    String APPROVE_CATEGORY_CODE = "hrm_attendance_approve";
    /**
     * åˆ›å»ºè¯·å‡ç”³è¯·
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) {
@@ -84,8 +86,7 @@
        // 1. ç™»å½•用户 -> å‘˜å·¥æ¡£æ¡ˆ
        HrmEmployeeDO employee = employeeService.getEmployeeByUserId(loginUserId);
        if (employee == null) {
            throw exception(EMPLOYEE_NOT_EXISTS,
                    "当前登录账号未关联员工档案,无法打卡。请联系管理员在【员工管理】中将该登录账号绑定到员工。");
            throw exception(ATTENDANCE_EMPLOYEE_NOT_BOUND);
        }
        HrmAttendanceMyStatusRespVO respVO = new HrmAttendanceMyStatusRespVO();
        respVO.setUserId(loginUserId);
@@ -124,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);
@@ -165,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;
    }
    /**
@@ -211,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);
@@ -241,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))) {
@@ -359,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);
        }
@@ -418,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);
        }
@@ -431,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;