gaoluyang
5 天以前 ce5f8a87bb5d411bae67c39b269d879677bb98da
feat(hrm): 请假导入、加班代申请、批量排班与考勤应出勤重算

- 请假申请支持 Excel 导入:按手机号匹配员工档案,逐行独立校验并返回精确失败原因,仅生成草稿不自动提交
- 加班申请支持代申请:新增 employeeId 与 hrm:overtime:manage 权限,持有人可查看/操作全部单据,流程以申请人身份发起和撤销
- 排班新增批量排班:部门(含下级)+ 岗位 + 员工状态,节假日配置优先于周末规则,跳过已审批请假并被计入统计
- 排班唯一键 uk_employee_date 不含 deleted,保存/批量排班前先查询含已删除行并复活旧行,避免唯一键冲突导致整批回滚
- 考勤汇总查询/生成时按最新节假日配置重算应出勤天数并回写
- 补齐 hrm_holiday_type、hrm_is_legal_holiday 字典及 hrm_attendance_holiday 时间段字段,修复节假日配置类型为空
- 新增菜单权限 SQL 与 config_export_all_20261108.sql

Co-Authored-By: Claude Code <noreply@anthropic.com>
已添加10个文件
已修改13个文件
12982 ■■■■■ 文件已修改
docs/hrm_leave_import_batch_schedule_20261108.md 163 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/config_export_all_20261108.sql 11587 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/hrm_20261108_holiday_dict.sql 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/hrm_20261108_menu_permissions.sql 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/approval/HrmLeaveApplicationController.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/approval/vo/HrmLeaveApplicationImportExcelVO.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/approval/vo/HrmLeaveApplicationImportFailureVO.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/approval/vo/HrmLeaveApplicationImportRespVO.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/approval/vo/HrmOvertimeApplicationSaveReqVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/HrmEmployeeScheduleController.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmEmployeeScheduleBatchAssignReqVO.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmEmployeeScheduleBatchAssignRespVO.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmEmployeeScheduleLeaveRespVO.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/approval/HrmLeaveApplicationMapper.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/attendance/HrmEmployeeScheduleMapper.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/employee/HrmEmployeeMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/approval/HrmLeaveApplicationService.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/approval/HrmLeaveApplicationServiceImpl.java 354 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/approval/HrmOvertimeApplicationServiceImpl.java 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceSummaryServiceImpl.java 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmEmployeeScheduleService.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmEmployeeScheduleServiceImpl.java 294 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/hrm_leave_import_batch_schedule_20261108.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,163 @@
# HRM è¯·å‡å¯¼å…¥ / åŠ ç­ä»£ç”³è¯· / æ‰¹é‡æŽ’班 / è€ƒå‹¤é‡ç®— â€”— è”调说明(2026-11-08)
## æ¶‰åŠé¡µé¢
- äººäº‹å®¡æ‰¹ â†’ è¯·å‡ç”³è¯·ï¼ˆ`hrm/leave/index`)
- äººäº‹å®¡æ‰¹ â†’ åŠ ç­ç”³è¯·ï¼ˆ`hrm/overtime/index`)
- è€ƒå‹¤ç®¡ç† â†’ æŽ’班管理(`hrm/schedule/index`)
- è€ƒå‹¤ç®¡ç† â†’ è€ƒå‹¤ç»Ÿè®¡æ±‡æ€»
## ä¸€ã€è¯·å‡ Excel å¯¼å…¥
### API
| æ–¹æ³• | è·¯å¾„ | æƒé™ | è¯´æ˜Ž |
|------|------|------|------|
| GET | `/hrm/leave/import-template` | `hrm:leave:import` | ä¸‹è½½å¯¼å…¥æ¨¡æ¿ï¼ˆå«ç¤ºä¾‹è¡Œï¼‰ |
| POST | `/hrm/leave/import` | `hrm:leave:import` | multipart ä¸Šä¼ ï¼Œå­—段名 `file` |
**模板列(顺序固定):**
| åˆ— | å¿…å¡« | è¯´æ˜Ž |
|----|------|------|
| æ‰‹æœºå· | æ˜¯ | æŒ‰æ‰‹æœºå·åŒ¹é…å‘˜å·¥æ¡£æ¡ˆï¼›å¡«å‘˜å·¥æ¡£æ¡ˆé‡Œç™»è®°çš„ 11 ä½æ‰‹æœºå· |
| å‘˜å·¥å§“名 | å¦ | å¡«å†™åŽä¼šä¸Žæ‰‹æœºå·åŒ¹é…åˆ°çš„员工姓名核对,不一致该行失败 |
| è¯·å‡ç±»åž‹ | æ˜¯ | äº‹å‡/病假/年假/婚假/产假/丧假,或数字 1-6 |
| å¼€å§‹æ—¶é—´ | æ˜¯ | `yyyy-MM-dd HH:mm` æˆ– `yyyy-MM-dd HH:mm:ss`;也可只填日期 |
| ç»“束时间 | æ˜¯ | å¿…须晚于开始时间 |
| è¯·å‡æ—¶é•¿(天) | æ˜¯ | å¤§äºŽ 0,支持 0.5 |
| è¯·å‡åŽŸå›  | æ˜¯ | â‰¤500 å­— |
| å¤‡æ³¨ | å¦ | â‰¤500 å­— |
**响应:**
```json
{
  "code": 0,
  "data": {
    "totalCount": 10,
    "successCount": 8,
    "failureCount": 2,
    "failures": [
      { "rowNum": 3, "phone": "13800138000", "employeeName": "张三",
        "reason": "手机号[13800138000]未匹配到员工档案,请先在「员工档案」中维护该手机号后重新导入" }
    ]
  }
}
```
### ä¸šåŠ¡è§„åˆ™
- **逐行独立处理**:某一行失败不影响其它行;失败原因精确到「维度 + å®žé™…值 + ä¸‹ä¸€æ­¥åŠ¨ä½œã€ã€‚
- **只生成草稿**:导入成功的数据状态为草稿(0),不会自动发起审批;需在列表逐条点「提交」。
- å‘˜å·¥æ¡£æ¡ˆå¿…须同时满足:手机号存在、已绑定系统账号、已设置部门。
- åŒä¸€æ–‡ä»¶å†…不做过重校验;如需防重,按「手机号 + å¼€å§‹æ—¶é—´ã€äººå·¥æ ¸å¯¹ã€‚
- è¡Œå· `rowNum` å«è¡¨å¤´ï¼ˆè¡¨å¤´ä¸ºç¬¬ 1 è¡Œï¼‰ã€‚
## äºŒã€åŠ ç­ä»£ç”³è¯·
### å˜æ›´
| é¡¹ç›® | è¯´æ˜Ž |
|------|------|
| æ–°å¢žæƒé™ | `hrm:overtime:manage`(菜单 1075664,已授予角色 166 ç”Ÿäº§ç®¡ç†å‘˜ã€168 ç»ç†ï¼‰ |
| `POST /hrm/overtime/create` | `SaveReqVO` æ–°å¢ž `employeeId`(申请人员工ID);不传=本人 |
| é¡µé¢ | è¡¨å•新增「代申请员工」选择器,仅持有 `hrm:overtime:manage` æ—¶æ˜¾ç¤º |
### æƒé™æ•ˆæžœ
| æ˜¯å¦æŒæœ‰æƒé™ | åˆ—表 | è¯¦æƒ… | ä¿®æ”¹/删除/提交/撤销 |
|--------------|------|------|---------------------|
| å¦ | ä»…本人单子 | æœ¬äººæˆ–流程参与人 | ä»…本人草稿/驳回单 |
| æ˜¯ | å…¨éƒ¨äººçš„单子 | å…¨éƒ¨ | å…¨éƒ¨äººçš„单子 |
### æ ¡éªŒä¸Žé”™è¯¯æç¤º
- æ— æƒé™å´æŒ‡å®šä»–人:`无权为其他员工代申请加班:当前账号未持有 hrm:overtime:manage æƒé™ï¼Œè¯·ç§»é™¤ç”³è¯·äººï¼ˆæ”¹ä¸ºæœ¬äººï¼‰æˆ–联系管理员授予该权限`
- æŒ‡å®šå‘˜å·¥ä¸å­˜åœ¨ï¼š`所选申请人的员工档案不存在,请刷新员工列表后重新选择`
- æ— æƒé™æ“ä½œä»–人单子:`无权操作该加班申请:当前账号既不是申请人,也未持有 hrm:overtime:manage æƒé™`
### æµç¨‹å‘起人
代申请提交后,BPM æµç¨‹å®žä¾‹ä»¥**申请人员工对应的系统账号**发起(不是代申请人),撤销同样以申请人身份执行,保证审批流中的申请人、待办归属正确。
## ä¸‰ã€æ‰¹é‡æŽ’班
### API
| æ–¹æ³• | è·¯å¾„ | æƒé™ | è¯´æ˜Ž |
|------|------|------|------|
| POST | `/hrm/employee-schedule/batch-assign` | `hrm:employee-schedule:save` | æŒ‰éƒ¨é—¨ï¼ˆå«ä¸‹çº§ï¼‰+ å²—位 + å‘˜å·¥çŠ¶æ€æ‰¹é‡æŽ’ç­ |
| GET | `/hrm/employee-schedule/leave-list` | ç™»å½•即可 | æŽ’班页请假标记(已审批,按天展开) |
**批量排班请求:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| deptId | Long | æ˜¯ | éƒ¨é—¨ID,后端自动包含所有下级部门 |
| postId | Long | å¦ | å²—位ID;按员工档案 `postIds` åˆ¤æ–­ï¼Œä¸ä¼ =不限 |
| employeeStatus | Integer | å¦ | 1 åœ¨èŒ / 2 è¯•用 / 3 ç¦»èŒï¼›ä¸ä¼ =不限 |
| startDate | LocalDate | æ˜¯ | å¼€å§‹æ—¥æœŸï¼ˆå«ï¼‰ |
| endDate | LocalDate | æ˜¯ | ç»“束日期(含),单次最多 62 å¤© |
| shiftId | Long | æ˜¯ | ç­æ¬¡ID |
| remark | String | å¦ | å†™å…¥æŽ’班的备注 |
**响应:**
```json
{
  "matchedEmployeeCount": 12,
  "scheduledCount": 240,
  "skippedLeaveCount": 3,
  "clearedRestDayCount": 96
}
```
### æŽ’班规则(按优先级)
1. **节假日配置优先**:`hrm_attendance_holiday` ä¸­é…ç½®è¦†ç›–的日期,以配置的类型为准(工作日/休息日);没有配置时才按周六日判断。
2. **工作日**:写入所选班次;已有排班则覆盖。
3. **休息日/节假日**:清空该员工当天的班次。
4. **已审批请假优先**:员工当天存在审批通过(status=20)的请假单时,**跳过该单元格**,不改动原有排班,也不计入写入/清空数量。
5. å•次日期区间超过 62 å¤©ä¼šæ‹’绝,分批执行。
### å”¯ä¸€é”®ä¸Žé€»è¾‘删除
`hrm_employee_schedule` çš„唯一键 `uk_employee_date(employee_id, date)` **不包含 `deleted`**。逻辑删除后的旧行仍占用唯一键。因此保存/批量排班时:
1. å…ˆç”¨åŒ…含已删除行的原始 SQL æŸ¥è¯¢è¯¥å‘˜å·¥è¯¥æ—¥æœŸçš„记录;
2. å¦‚果命中已删除行,执行 `UPDATE ... SET deleted = 0`「复活」并覆盖班次,**不再 INSERT**;
3. åªæœ‰å®Œå…¨ä¸å­˜åœ¨è®°å½•时才 INSERT。
这样避免「删过一次再排同一天」撞唯一键导致整批事务回滚。
## å››ã€æŽ’班页
| åŠŸèƒ½ | è¯´æ˜Ž |
|------|------|
| è¯·å‡æ ‡è®° | å·²å®¡æ‰¹è¯·å‡çš„单元格显示紫色「请假」标签,并在图例中增加「请假(已审批)」紫色说明;悬停显示请假类型 |
| æ”¹æ ¼å­å³ä¿å­˜ | é€‰å®Œç­æ¬¡åŽç«‹å³è°ƒç”¨ `/hrm/employee-schedule/save` ä¿å­˜è¯¥å•元格;失败时恢复原值并提示 |
| åˆ‡æ¢æ¡ä»¶ç¦æ­¢ä¿å­˜ | åˆ‡æ¢æœˆä»½/部门后若未重新点「查询排班」,保存按钮禁用、单元格不可编辑,并显示橙色提示条 |
| æ‰¹é‡æŽ’班入口 | å·¥å…·æ ã€Œæ‰¹é‡æŽ’班」弹窗:岗位、员工状态、日期区间、班次;执行后自动刷新当前页 |
## äº”、考勤汇总重算
- **查询时重算**:`/hrm/attendance/summary/page`、`/get`、部门月度列表在返回前,按**当前最新节假日配置**重算 `workDays`,与库中值不同时回写。
- **生成时重算**:`/hrm/attendance/summary/generate` åŽŸæœ¬å°±æŒ‰èŠ‚å‡æ—¥é…ç½®è®¡ç®—ï¼Œä¿æŒä¸å˜ã€‚
- åŒä¸€æ¬¡è¯·æ±‚内同一年月只计算一次,避免 N æ¬¡éåŽ†æ—¥åŽ†ã€‚
- å¹´æœˆçš„æ ¼å¼å¿…须为 `yyyy-MM`;脏数据会跳过并记录 WARN,不影响其它行返回。
## å…­ã€æ•°æ®ä¸Žé…ç½®å˜æ›´
- `docs/sql/config_export_all_20261108.sql`:在 20261106 ç‰ˆæœ¬åŸºç¡€ä¸Šè¿½åŠ  2026-11-08 æƒé™æ®µï¼ˆé˜¶æ®µäºŒï¼‰ã€‚
- `sql/mysql/hrm_20261108_menu_permissions.sql`:本次新增的两条权限、角色授权,可单独执行。
- æ–°å¢žèœå•:
  - 1075663 `请假申请-导入` / `hrm:leave:import`(父菜单 6003),授予角色 163-168
  - 1075664 `加班代申请管理` / `hrm:overtime:manage`(父菜单 1065211),授予角色 166、168
## æ³¨æ„äº‹é¡¹
- `hrm:overtime:manage`、`hrm:leave:import` å±žäºŽ**按钮权限**,需要重新登录(或刷新权限缓存)后前端按钮才会显示。
- è¯·å‡å¯¼å…¥åªåˆ›å»ºè‰ç¨¿ï¼Œé™„件无法通过 Excel å¸¦å…¥ï¼›å¦‚审批需要附件,请导入后在详情中补充。
- æ‰¹é‡æŽ’班会清空休息日已有班次,属于覆盖式操作;执行前请确认日期区间。
- æŽ’班页的「自动排班」仍是前端本地生成(按双休/单休规则),不会跳过请假;需要按节假日 + è¯·å‡è·³è¿‡æ—¶è¯·ä½¿ç”¨ã€Œæ‰¹é‡æŽ’班」。
docs/sql/config_export_all_20261108.sql
¶Ô±ÈÐÂÎļþ
ÎļþÌ«´ó
sql/mysql/hrm_20261108_holiday_dict.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,45 @@
-- ============================================================
-- 2026-11-08 HRM èŠ‚å‡æ—¥ç±»åž‹å­—å…¸
-- çŽ°è±¡ï¼šäººäº‹ â†’ è€ƒå‹¤ç®¡ç† â†’ èŠ‚å‡æ—¥é…ç½®ï¼Œæ–°å¢ž/编辑弹框「类型」为空(无可选项)
-- åŽŸå› ï¼šå‰ç«¯ä¸ŽåŽç«¯éƒ½ä½¿ç”¨å­—å…¸ hrm_holiday_type,但库里没有该字典类型与字典数据
-- å–值与后端枚举 HrmAttendanceHolidayTypeEnum å¯¹é½ï¼š
--   1 = ä¼‘息日(放假,不计应出勤)
--   2 = å·¥ä½œæ—¥ï¼ˆè°ƒä¼‘上班,计应出勤)
-- è¯´æ˜Žï¼šé¡¹ç›®ä¸ä½¿ç”¨å¤šç§Ÿæˆ·ï¼ŒSQL ä¸åŒ…含 tenant_id;幂等,可重复执行。
-- ============================================================
INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`,
                                `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES (1065418, '考勤节假日类型', 'hrm_holiday_type', 0, '休息日=放假;工作日=调休上班',
        '1', NOW(), '1', NOW(), 0)
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `status` = 0,
                        `remark` = VALUES(`remark`), `deleted` = 0, `update_time` = NOW();
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`,
                                `color_type`, `css_class`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES (1065526, 1, '休息日', '1', 'hrm_holiday_type', 0, '', '', '1', NOW(), '1', NOW(), 0),
       (1065527, 2, '工作日', '2', 'hrm_holiday_type', 0, '', '', '1', NOW(), '1', NOW(), 0)
ON DUPLICATE KEY UPDATE `sort` = VALUES(`sort`), `label` = VALUES(`label`), `value` = VALUES(`value`),
                        `status` = 0, `deleted` = 0, `update_time` = NOW();
-- ------------------------------------------------------------
-- æ˜¯å¦æ³•定节假日 hrm_is_legal_holiday(列表/导出用 @DictFormat ç¿»è¯‘)
-- ------------------------------------------------------------
INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`,
                                `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES (1065419, '是否法定节假日', 'hrm_is_legal_holiday', 0, '考勤节假日:0=否,1=是',
        '1', NOW(), '1', NOW(), 0)
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `status` = 0,
                        `remark` = VALUES(`remark`), `deleted` = 0, `update_time` = NOW();
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`,
                                `color_type`, `css_class`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES (1065528, 1, '否', '0', 'hrm_is_legal_holiday', 0, '', '', '1', NOW(), '1', NOW(), 0),
       (1065529, 2, '是', '1', 'hrm_is_legal_holiday', 0, '', '', '1', NOW(), '1', NOW(), 0)
ON DUPLICATE KEY UPDATE `sort` = VALUES(`sort`), `label` = VALUES(`label`), `value` = VALUES(`value`),
                        `status` = 0, `deleted` = 0, `update_time` = NOW();
-- ------------------------------------------------------------
-- é™„:hrm_attendance_holiday çš„æ—¶é—´æ®µç»“构(start_date / end_date)
-- è‹¥ç›®æ ‡åº“尚未执行过,请一并执行 sql/mysql/hrm_attendance_holiday_date_range_20261017.sql
-- ------------------------------------------------------------
sql/mysql/hrm_20261108_menu_permissions.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,35 @@
-- ============================================================
-- 2026-11-08 HRM æƒé™è¡¥å……
-- 1. hrm:leave:import       è¯·å‡ç”³è¯· Excel å¯¼å…¥ / æ¨¡æ¿ä¸‹è½½
-- 2. hrm:overtime:manage    åŠ ç­ä»£ç”³è¯·ï¼šæŸ¥çœ‹å¹¶æ“ä½œå…¨éƒ¨äººçš„åŠ ç­å•
-- è¯´æ˜Žï¼šé¡¹ç›®ä¸ä½¿ç”¨å¤šç§Ÿæˆ·ï¼ŒSQL ä¸åŒ…含 tenant_id。
-- ============================================================
-- 1. èœå•/权限
INSERT INTO system_menu (id, name, permission, type, sort, parent_id, path, icon, component,
                         component_name, status, visible, keep_alive, always_show,
                         creator, create_time, updater, update_time, deleted)
VALUES (1075663, '请假申请-导入', 'hrm:leave:import', 3, 91, 6003, '', '#', '', NULL,
        0, 1, 0, 1, '1', NOW(), '1', NOW(), 0)
ON DUPLICATE KEY UPDATE name = VALUES(name), permission = VALUES(permission), deleted = 0, update_time = NOW();
INSERT INTO system_menu (id, name, permission, type, sort, parent_id, path, icon, component,
                         component_name, status, visible, keep_alive, always_show,
                         creator, create_time, updater, update_time, deleted)
VALUES (1075664, '加班代申请管理', 'hrm:overtime:manage', 3, 6, 1065211, '', '#', '', NULL,
        0, 1, 0, 1, '1', NOW(), '1', NOW(), 0)
ON DUPLICATE KEY UPDATE name = VALUES(name), permission = VALUES(permission), deleted = 0, update_time = NOW();
-- 2. è§’色授权:请假导入授予 163-168;加班代申请管理授予 166 ç”Ÿäº§ç®¡ç†å‘˜ã€168 ç»ç†
INSERT INTO system_role_menu (role_id, menu_id, creator, create_time, updater, update_time, deleted)
SELECT r.role_id, 1075663, '1', NOW(), '1', NOW(), 0
FROM (SELECT 163 AS role_id UNION ALL SELECT 164 UNION ALL SELECT 165
      UNION ALL SELECT 166 UNION ALL SELECT 167 UNION ALL SELECT 168) r
WHERE NOT EXISTS (SELECT 1 FROM system_role_menu rm
                  WHERE rm.role_id = r.role_id AND rm.menu_id = 1075663 AND rm.deleted = 0);
INSERT INTO system_role_menu (role_id, menu_id, creator, create_time, updater, update_time, deleted)
SELECT r.role_id, 1075664, '1', NOW(), '1', NOW(), 0
FROM (SELECT 166 AS role_id UNION ALL SELECT 168) r
WHERE NOT EXISTS (SELECT 1 FROM system_role_menu rm
                  WHERE rm.role_id = r.role_id AND rm.menu_id = 1075664 AND rm.deleted = 0);
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java
@@ -40,6 +40,15 @@
    ErrorCode LEAVE_APPLICATION_NO_EXISTS = new ErrorCode(1_030_001_004, "生成请假申请单号重复,请重试");
    ErrorCode LEAVE_APPLICATION_DURATION_EXCEED = new ErrorCode(1_030_001_005, "请假时长超出年假余额");
    ErrorCode LEAVE_APPLICATION_BPM_PROCESS_DEFINITION_NOT_EXISTS = new ErrorCode(1_030_001_006, "请假审批流程不存在");
    ErrorCode LEAVE_APPLICATION_EMPLOYEE_NOT_EXISTS = new ErrorCode(1_030_001_007, "当前账号未关联员工档案");
    ErrorCode LEAVE_APPLICATION_APPLICANT_NOT_EXISTS = new ErrorCode(1_030_001_008, "所选申请人账号不存在");
    ErrorCode LEAVE_APPLICATION_SELECTED_EMPLOYEE_NOT_EXISTS = new ErrorCode(1_030_001_009, "所选申请人未关联员工档案");
    ErrorCode LEAVE_APPLICATION_UPDATE_FAIL_NOT_EDITABLE = new ErrorCode(1_030_001_010, "仅草稿或审批驳回状态的请假申请可以修改");
    ErrorCode LEAVE_APPLICATION_APPLICANT_IMMUTABLE = new ErrorCode(1_030_001_011, "请假申请人创建后不能修改");
    ErrorCode LEAVE_APPLICATION_OPERATOR_NOT_ALLOWED = new ErrorCode(1_030_001_012, "仅申请人或原代申请创建人可以操作该请假申请");
    ErrorCode LEAVE_APPLICATION_DEPT_MISMATCH = new ErrorCode(1_030_001_013, "请假申请部门与所选申请人员工档案部门不一致,请刷新后重试");
    ErrorCode LEAVE_APPLICATION_EMPLOYEE_DEPT_NOT_FOUND = new ErrorCode(1_030_001_014, "所选申请人员工档案未设置部门");
    ErrorCode LEAVE_APPLICATION_IMPORT_FILE_EMPTY = new ErrorCode(1_030_001_015, "导入文件没有可读取的数据行,请确认表头依次为:手机号、员工姓名、请假类型、开始时间、结束时间、请假时长(天)、请假原因、备注");
    // ========== è°ƒå²—申请 1-030-002-000 ==========
    ErrorCode TRANSFER_APPLICATION_NOT_EXISTS = new ErrorCode(1_030_002_000, "调岗申请不存在");
@@ -53,6 +62,11 @@
    ErrorCode TRANSFER_APPLICATION_APPLICANT_NOT_EXISTS = new ErrorCode(1_030_002_008, "调岗申请失败:所选员工未关联有效的系统账号");
    ErrorCode TRANSFER_APPLICATION_APPLICANT_DEPT_EMPTY = new ErrorCode(1_030_002_009, "调岗申请失败:所选员工未设置部门");
    ErrorCode TRANSFER_APPLICATION_APPLICANT_POST_EMPTY = new ErrorCode(1_030_002_010, "调岗申请失败:所选员工未设置岗位");
    ErrorCode TRANSFER_APPLICATION_EMPLOYEE_DEPT_NOT_FOUND = new ErrorCode(1_030_002_011, "所选员工未关联有效员工档案或档案未设置部门");
    ErrorCode TRANSFER_APPLICATION_CURRENT_DEPT_MISMATCH = new ErrorCode(1_030_002_012, "原部门与所选员工当前部门不一致,请刷新员工信息后重试");
    ErrorCode TRANSFER_APPLICATION_UPDATE_FAIL_NOT_EDITABLE = new ErrorCode(1_030_002_013, "仅草稿或审批驳回状态的调岗申请可以修改");
    ErrorCode TRANSFER_APPLICATION_APPLICANT_IMMUTABLE = new ErrorCode(1_030_002_014, "调岗申请人创建后不能修改");
    ErrorCode TRANSFER_APPLICATION_OPERATOR_NOT_ALLOWED = new ErrorCode(1_030_002_015, "仅申请人或原代申请创建人可以操作该调岗申请");
    // ========== ç¦»èŒç”³è¯· 1-030-003-000 ==========
    ErrorCode RESIGNATION_APPLICATION_NOT_EXISTS = new ErrorCode(1_030_003_000, "离职申请不存在");
@@ -74,6 +88,8 @@
    ErrorCode RESIGNATION_HANDOVER_CHANGE_AFTER_START = new ErrorCode(1_030_003_016, "工作交接已开始,不能更换交接员工");
    ErrorCode RESIGNATION_APPLICANT_NOT_EXISTS = new ErrorCode(1_030_003_017, "申请人不存在,请重新选择申请人");
    ErrorCode RESIGNATION_HANDOVER_STATUS_INVALID = new ErrorCode(1_030_003_018, "交接状态值不合法,仅支持 0未开始、10进行中、20已完成");
    ErrorCode RESIGNATION_APPLICATION_APPLICANT_IMMUTABLE = new ErrorCode(1_030_003_019, "离职申请人创建后不能修改");
    ErrorCode RESIGNATION_APPLICATION_OPERATOR_NOT_ALLOWED = new ErrorCode(1_030_003_020, "仅申请人或原代申请创建人可以操作该离职申请");
    // ========== åŠ ç­ç”³è¯· 1-030-008-000 ==========
    ErrorCode OVERTIME_APPLICATION_NOT_EXISTS = new ErrorCode(1_030_008_000, "加班申请不存在");
@@ -83,6 +99,9 @@
    ErrorCode OVERTIME_APPLICATION_BPM_PROCESS_DEFINITION_NOT_EXISTS = new ErrorCode(1_030_008_004, "加班审批流程不存在,请先在协同办公中配置并发布流程");
    ErrorCode OVERTIME_APPLICATION_EMPLOYEE_NOT_EXISTS = new ErrorCode(1_030_008_005, "当前账号未关联员工档案");
    ErrorCode OVERTIME_APPLICATION_TIME_INVALID = new ErrorCode(1_030_008_006, "加班时段或扣除时长不合法");
    ErrorCode OVERTIME_APPLICATION_SELECTED_EMPLOYEE_NOT_EXISTS = new ErrorCode(1_030_008_007, "所选申请人的员工档案不存在,请刷新员工列表后重新选择");
    ErrorCode OVERTIME_APPLICATION_PROXY_NOT_ALLOWED = new ErrorCode(1_030_008_008, "无权为其他员工代申请加班:当前账号未持有 hrm:overtime:manage æƒé™ï¼Œè¯·ç§»é™¤ç”³è¯·äººï¼ˆæ”¹ä¸ºæœ¬äººï¼‰æˆ–联系管理员授予该权限");
    ErrorCode OVERTIME_APPLICATION_OPERATOR_NOT_ALLOWED = new ErrorCode(1_030_008_009, "无权操作该加班申请:当前账号既不是申请人,也未持有 hrm:overtime:manage æƒé™");
    // ========== ç”¨æˆ·æ•°æ®äº¤æŽ¥ 1-030-007-000 ==========
    ErrorCode USER_HANDOVER_NOT_EXISTS = new ErrorCode(1_030_007_000, "用户交接记录不存在");
@@ -155,5 +174,8 @@
    ErrorCode SCHEDULE_ITEM_LIST_EMPTY = new ErrorCode(1_030_010_000, "排班数据不能为空");
    ErrorCode SCHEDULE_SHIFT_NOT_EXISTS = new ErrorCode(1_030_010_001, "排班引用的班次不存在");
    ErrorCode SCHEDULE_EMPLOYEE_NOT_EXISTS = new ErrorCode(1_030_010_002, "排班引用的员工不存在");
    ErrorCode SCHEDULE_BATCH_DATE_RANGE_INVALID = new ErrorCode(1_030_010_003, "批量排班日期区间不合法:结束日期[%s]早于开始日期[%s],请调整后重试");
    ErrorCode SCHEDULE_BATCH_RANGE_TOO_LONG = new ErrorCode(1_030_010_004, "批量排班日期区间为[%s ~ %s],共 %s å¤©ï¼Œè¶…过单次最多 62 å¤©çš„限制,请分段排班");
    ErrorCode SCHEDULE_BATCH_NO_EMPLOYEE = new ErrorCode(1_030_010_005, "部门[%s](含下级部门)下没有符合条件的员工(岗位:%s,员工状态:%s),未生成任何排班,请调整筛选条件后重试");
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/approval/HrmLeaveApplicationController.java
@@ -6,6 +6,8 @@
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.module.hrm.controller.admin.approval.vo.HrmLeaveApplicationImportExcelVO;
import cn.iocoder.yudao.module.hrm.controller.admin.approval.vo.HrmLeaveApplicationImportRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.approval.vo.HrmLeaveApplicationPageReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.approval.vo.HrmLeaveApplicationRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.approval.vo.HrmLeaveApplicationSaveReqVO;
@@ -19,6 +21,7 @@
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
@@ -91,6 +94,32 @@
        return success(leaveApplicationService.getApproveProcessDefinitionList());
    }
    @GetMapping("/import-template")
    @Operation(summary = "下载请假申请导入模板")
    @PreAuthorize("@ss.hasPermission('hrm:leave:import')")
    public void importTemplate(HttpServletResponse response) throws IOException {
        HrmLeaveApplicationImportExcelVO example = new HrmLeaveApplicationImportExcelVO();
        example.setPhone("13800138000");
        example.setEmployeeName("张三");
        example.setLeaveTypeName("事假");
        example.setStartTime("2026-11-10 09:00");
        example.setEndTime("2026-11-10 18:00");
        example.setDuration("1");
        example.setReason("个人事务");
        example.setRemark("示例行,导入前请删除");
        ExcelUtils.write(response, "请假申请导入模板.xls", "请假申请", HrmLeaveApplicationImportExcelVO.class,
                List.of(example));
    }
    @PostMapping("/import")
    @Operation(summary = "导入请假申请(仅生成草稿,不自动提交审批)")
    @PreAuthorize("@ss.hasPermission('hrm:leave:import')")
    public CommonResult<HrmLeaveApplicationImportRespVO> importExcel(@RequestParam("file") MultipartFile file)
            throws IOException {
        List<HrmLeaveApplicationImportExcelVO> list = ExcelUtils.read(file, HrmLeaveApplicationImportExcelVO.class);
        return success(leaveApplicationService.importLeaveApplications(list));
    }
    @GetMapping("/export-excel")
    @Operation(summary = "导出请假申请 Excel")
    @PreAuthorize("@ss.hasPermission('hrm:leave:export')")
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/approval/vo/HrmLeaveApplicationImportExcelVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,46 @@
package cn.iocoder.yudao.module.hrm.controller.admin.approval.vo;
import cn.idev.excel.annotation.ExcelProperty;
import cn.iocoder.yudao.framework.excel.core.handler.ExcelRequired;
import lombok.Data;
/**
 * è¯·å‡ç”³è¯· Excel å¯¼å…¥ VO
 *
 * è¯´æ˜Žï¼šæ‰€æœ‰å­—段均按字符串读取,由 Service é€è¡Œè§£æžå¹¶ç»™å‡ºç²¾ç¡®å¤±è´¥åŽŸå› ï¼Œ
 * é¿å…å•个单元格格式错误导致整个文件读取失败、无法定位到具体行。
 */
@Data
public class HrmLeaveApplicationImportExcelVO {
    @ExcelProperty("手机号")
    @ExcelRequired
    private String phone;
    @ExcelProperty("员工姓名")
    private String employeeName;
    @ExcelProperty("请假类型")
    @ExcelRequired
    private String leaveTypeName;
    @ExcelProperty("开始时间")
    @ExcelRequired
    private String startTime;
    @ExcelProperty("结束时间")
    @ExcelRequired
    private String endTime;
    @ExcelProperty("请假时长(天)")
    @ExcelRequired
    private String duration;
    @ExcelProperty("请假原因")
    @ExcelRequired
    private String reason;
    @ExcelProperty("备注")
    private String remark;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/approval/vo/HrmLeaveApplicationImportFailureVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
package cn.iocoder.yudao.module.hrm.controller.admin.approval.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
 * è¯·å‡ç”³è¯·å¯¼å…¥å¤±è´¥æ˜Žç»†
 */
@Schema(description = "管理后台 - è¯·å‡ç”³è¯·å¯¼å…¥å¤±è´¥æ˜Žç»†")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class HrmLeaveApplicationImportFailureVO {
    @Schema(description = "Excel è¡Œå·ï¼ˆå«è¡¨å¤´ï¼Œè¡¨å¤´ä¸ºç¬¬ 1 è¡Œï¼‰", example = "2")
    private Integer rowNum;
    @Schema(description = "手机号", example = "13800138000")
    private String phone;
    @Schema(description = "员工姓名", example = "张三")
    private String employeeName;
    @Schema(description = "失败原因", example = "手机号 13800138000 æœªåŒ¹é…åˆ°å‘˜å·¥æ¡£æ¡ˆï¼Œè¯·å…ˆåœ¨å‘˜å·¥æ¡£æ¡ˆä¸­ç»´æŠ¤è¯¥æ‰‹æœºå·")
    private String reason;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/approval/vo/HrmLeaveApplicationImportRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
package cn.iocoder.yudao.module.hrm.controller.admin.approval.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import java.util.List;
/**
 * è¯·å‡ç”³è¯·å¯¼å…¥ç»“æžœ
 */
@Schema(description = "管理后台 - è¯·å‡ç”³è¯·å¯¼å…¥ç»“æžœ")
@Data
@Builder
public class HrmLeaveApplicationImportRespVO {
    @Schema(description = "读取到的有效数据行数(不含全空行)", example = "10")
    private Integer totalCount;
    @Schema(description = "导入成功的行数", example = "8")
    private Integer successCount;
    @Schema(description = "导入失败的行数", example = "2")
    private Integer failureCount;
    @Schema(description = "失败明细,逐行说明失败原因")
    private List<HrmLeaveApplicationImportFailureVO> failures;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/approval/vo/HrmOvertimeApplicationSaveReqVO.java
@@ -14,6 +14,9 @@
@Data
public class HrmOvertimeApplicationSaveReqVO {
    private Long id;
    @Schema(description = "申请人员工ID;为空表示本人。为其他员工代申请需持有 hrm:overtime:manage æƒé™", example = "1001")
    private Long employeeId;
    @NotNull(message = "加班日期不能为空")
    private LocalDate overtimeDate;
    @NotNull(message = "开始时间不能为空")
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/HrmEmployeeScheduleController.java
@@ -1,6 +1,9 @@
package cn.iocoder.yudao.module.hrm.controller.admin.attendance;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmEmployeeScheduleBatchAssignReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmEmployeeScheduleBatchAssignRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmEmployeeScheduleLeaveRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmEmployeeScheduleListReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmEmployeeScheduleRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmEmployeeScheduleSaveReqVO;
@@ -33,6 +36,21 @@
        return success(employeeScheduleService.getScheduleList(reqVO));
    }
    @GetMapping("/leave-list")
    @Operation(summary = "获得排班页请假标记列表(已审批通过,按天展开)")
    public CommonResult<List<HrmEmployeeScheduleLeaveRespVO>> getScheduleLeaveList(
            @Valid HrmEmployeeScheduleListReqVO reqVO) {
        return success(employeeScheduleService.getScheduleLeaveList(reqVO));
    }
    @PostMapping("/batch-assign")
    @Operation(summary = "批量排班(按部门含下级 + å²—位 + å‘˜å·¥çŠ¶æ€ï¼Œè·³è¿‡å·²å®¡æ‰¹è¯·å‡ï¼‰")
    @PreAuthorize("@ss.hasPermission('hrm:employee-schedule:save')")
    public CommonResult<HrmEmployeeScheduleBatchAssignRespVO> batchAssign(
            @Valid @RequestBody HrmEmployeeScheduleBatchAssignReqVO reqVO) {
        return success(employeeScheduleService.batchAssign(reqVO));
    }
    @PostMapping("/save")
    @Operation(summary = "批量保存员工排班(整月覆盖式)")
    @PreAuthorize("@ss.hasPermission('hrm:employee-schedule:save')")
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmEmployeeScheduleBatchAssignReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,38 @@
package cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDate;
@Schema(description = "管理后台 - æ‰¹é‡æŽ’班 Request VO")
@Data
public class HrmEmployeeScheduleBatchAssignReqVO {
    @Schema(description = "部门ID(默认包含下级部门)", requiredMode = Schema.RequiredMode.REQUIRED, example = "100")
    @NotNull(message = "部门不能为空")
    private Long deptId;
    @Schema(description = "岗位ID;为空表示不限岗位", example = "10")
    private Long postId;
    @Schema(description = "员工状态:1-在职 2-试用 3-离职;为空表示不限状态", example = "1")
    private Integer employeeStatus;
    @Schema(description = "开始日期(含)", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-11-01")
    @NotNull(message = "开始日期不能为空")
    private LocalDate startDate;
    @Schema(description = "结束日期(含)", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-11-30")
    @NotNull(message = "结束日期不能为空")
    private LocalDate endDate;
    @Schema(description = "班次ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "班次不能为空")
    private Long shiftId;
    @Schema(description = "备注", example = "11 æœˆæ‰¹é‡æŽ’班")
    private String remark;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmEmployeeScheduleBatchAssignRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,24 @@
package cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
@Schema(description = "管理后台 - æ‰¹é‡æŽ’班结果 Response VO")
@Data
@Builder
public class HrmEmployeeScheduleBatchAssignRespVO {
    @Schema(description = "本次命中的员工数", example = "12")
    private Integer matchedEmployeeCount;
    @Schema(description = "写入的排班数(工作日)", example = "240")
    private Integer scheduledCount;
    @Schema(description = "因已审批请假而跳过、未改动的员工日期数", example = "3")
    private Integer skippedLeaveCount;
    @Schema(description = "休息日/节假日被清空的排班数", example = "96")
    private Integer clearedRestDayCount;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmEmployeeScheduleLeaveRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,31 @@
package cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
@Schema(description = "管理后台 - æŽ’班页请假标记 Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class HrmEmployeeScheduleLeaveRespVO {
    @Schema(description = "员工ID", example = "1001")
    private Long employeeId;
    @Schema(description = "员工姓名", example = "张三")
    private String employeeName;
    @Schema(description = "请假覆盖的日期", example = "2026-11-10")
    private LocalDate date;
    @Schema(description = "请假类型", example = "1")
    private Integer leaveType;
    @Schema(description = "请假类型名称", example = "事假")
    private String leaveTypeName;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/approval/HrmLeaveApplicationMapper.java
@@ -9,6 +9,7 @@
import org.apache.ibatis.annotations.Mapper;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
/**
@@ -54,4 +55,23 @@
                .le(HrmLeaveApplicationDO::getEndTime, endTime));
    }
    /**
     * æŸ¥è¯¢å¤šä¸ªå‘˜å·¥åœ¨æŒ‡å®šæ—¶é—´èŒƒå›´å†…已审批通过的请假记录(与时间范围有交集即命中)
     *
     * @param userIds   ç³»ç»Ÿç”¨æˆ·ID集合
     * @param startTime åŒºé—´å¼€å§‹æ—¶é—´
     * @param endTime   åŒºé—´ç»“束时间
     * @return è¯·å‡è®°å½•列表
     */
    default List<HrmLeaveApplicationDO> selectApprovedListByUserIdsAndTimeRange(Collection<Long> userIds,
                                                                                LocalDateTime startTime,
                                                                                LocalDateTime endTime) {
        return selectList(new LambdaQueryWrapperX<HrmLeaveApplicationDO>()
                .in(HrmLeaveApplicationDO::getUserId, userIds)
                .eq(HrmLeaveApplicationDO::getStatus, HrmAuditStatusEnum.APPROVE.getStatus())
                .le(HrmLeaveApplicationDO::getStartTime, endTime)
                .ge(HrmLeaveApplicationDO::getEndTime, startTime)
                .orderByAsc(HrmLeaveApplicationDO::getStartTime));
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/attendance/HrmEmployeeScheduleMapper.java
@@ -4,6 +4,9 @@
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmEmployeeScheduleDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDate;
import java.util.Collection;
@@ -23,6 +26,24 @@
                .eq(HrmEmployeeScheduleDO::getDate, date));
    }
    /**
     * æŸ¥è¯¢å‘˜å·¥æŸå¤©çš„æŽ’班,包含已被逻辑删除的行。
     *
     * è¡¨ä¸Šå”¯ä¸€é”® uk_employee_date(employee_id, date) ä¸å« deleted åˆ—,
     * é€»è¾‘删除后的旧行仍然占用唯一键;重新排班前必须先查到它并“复活”,否则插入会撞键导致整批回滚。
     */
    @Select("SELECT * FROM hrm_employee_schedule WHERE employee_id = #{employeeId} AND date = #{date} LIMIT 1")
    HrmEmployeeScheduleDO selectByEmployeeIdAndDateIncludeDeleted(@Param("employeeId") Long employeeId,
                                                                 @Param("date") LocalDate date);
    /**
     * å¤æ´»å·²é€»è¾‘删除的排班行并覆盖班次信息。
     */
    @Update("UPDATE hrm_employee_schedule SET deleted = 0, shift_id = #{shiftId}, remark = #{remark}, "
            + "updater = #{updater}, update_time = NOW() WHERE id = #{id}")
    int reviveById(@Param("id") Long id, @Param("shiftId") Long shiftId,
                   @Param("remark") String remark, @Param("updater") String updater);
    default List<HrmEmployeeScheduleDO> selectListByPeriod(LocalDate startDate, LocalDate endDate) {
        return selectList(new LambdaQueryWrapperX<HrmEmployeeScheduleDO>()
                .between(HrmEmployeeScheduleDO::getDate, startDate, endDate)
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/employee/HrmEmployeeMapper.java
@@ -45,6 +45,15 @@
        return selectList(HrmEmployeeDO::getDeptId, deptId);
    }
    /**
     * æŒ‰éƒ¨é—¨é›†åˆï¼ˆå«ä¸‹çº§éƒ¨é—¨ï¼‰æŸ¥è¯¢å‘˜å·¥
     */
    default List<HrmEmployeeDO> selectListByDeptIds(Collection<Long> deptIds) {
        return selectList(new LambdaQueryWrapperX<HrmEmployeeDO>()
                .in(HrmEmployeeDO::getDeptId, deptIds)
                .orderByAsc(HrmEmployeeDO::getName));
    }
    default List<HrmEmployeeDO> selectByEmployeeStatus(Integer employeeStatus) {
        return selectList(HrmEmployeeDO::getEmployeeStatus, employeeStatus);
    }
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/approval/HrmLeaveApplicationService.java
@@ -1,6 +1,8 @@
package cn.iocoder.yudao.module.hrm.service.approval;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.hrm.controller.admin.approval.vo.HrmLeaveApplicationImportExcelVO;
import cn.iocoder.yudao.module.hrm.controller.admin.approval.vo.HrmLeaveApplicationImportRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.approval.vo.HrmLeaveApplicationPageReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.approval.vo.HrmLeaveApplicationRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.approval.vo.HrmLeaveApplicationSaveReqVO;
@@ -36,6 +38,14 @@
    Long createLeaveApplication(@Valid HrmLeaveApplicationSaveReqVO createReqVO);
    /**
     * å¯¼å…¥è¯·å‡ç”³è¯·ï¼ˆæŒ‰æ‰‹æœºå·åŒ¹é…å‘˜å·¥æ¡£æ¡ˆï¼Œé€è¡Œç‹¬ç«‹å¤„理,仅生成草稿、不自动提交)
     *
     * @param list Excel æ•°æ®è¡Œ
     * @return å¯¼å…¥ç»“果(成功行数与逐行失败原因)
     */
    HrmLeaveApplicationImportRespVO importLeaveApplications(List<HrmLeaveApplicationImportExcelVO> list);
    /**
     * æ›´æ–°è¯·å‡ç”³è¯·
     *
     * @param updateReqVO æ›´æ–°ä¿¡æ¯
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/approval/HrmLeaveApplicationServiceImpl.java
@@ -2,19 +2,27 @@
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi;
import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO;
import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmProcessDefinitionInfoDO;
import cn.iocoder.yudao.module.bpm.framework.flowable.core.enums.BpmnVariableConstants;
import cn.iocoder.yudao.module.bpm.service.definition.BpmCategoryService;
import cn.iocoder.yudao.module.bpm.service.definition.BpmProcessDefinitionService;
import cn.iocoder.yudao.module.hrm.controller.admin.approval.vo.HrmLeaveApplicationImportExcelVO;
import cn.iocoder.yudao.module.hrm.controller.admin.approval.vo.HrmLeaveApplicationImportFailureVO;
import cn.iocoder.yudao.module.hrm.controller.admin.approval.vo.HrmLeaveApplicationImportRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.approval.vo.HrmLeaveApplicationPageReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.approval.vo.HrmLeaveApplicationRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.approval.vo.HrmLeaveApplicationSaveReqVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.approval.HrmLeaveApplicationDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO;
import cn.iocoder.yudao.module.hrm.dal.mysql.approval.HrmLeaveApplicationMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeMapper;
import cn.iocoder.yudao.module.hrm.dal.redis.HrmNoRedisDAO;
import cn.iocoder.yudao.module.hrm.enums.HrmAuditStatusEnum;
import cn.iocoder.yudao.module.hrm.enums.HrmLeaveTypeEnum;
@@ -30,6 +38,12 @@
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
@@ -37,6 +51,7 @@
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
@@ -53,8 +68,12 @@
@Slf4j
public class HrmLeaveApplicationServiceImpl implements HrmLeaveApplicationService {
    private static final DateTimeFormatter LEAVE_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
    @Resource
    private HrmLeaveApplicationMapper leaveApplicationMapper;
    @Resource
    private HrmEmployeeMapper employeeMapper;
    @Resource
    private HrmNoRedisDAO noRedisDAO;
    @Resource
@@ -73,22 +92,33 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createLeaveApplication(HrmLeaveApplicationSaveReqVO createReqVO) {
        // 1. ç”Ÿæˆç”³è¯·å•号
        String no = generateUniqueNo();
        // 2. è®¾ç½®ç”³è¯·äººä¿¡æ¯
        // 1. ç¡®è®¤ç”³è¯·äººå·²å…³è”员工档案
        Long userId = createReqVO.getUserId();
        if (userId == null) {
            userId = getCurrentUserId();
        }
        AdminUserRespDTO user = adminUserApi.getUser(userId);
        if (user == null) {
            userId = getCurrentUserId();
            throw exception(LEAVE_APPLICATION_APPLICANT_NOT_EXISTS);
        }
        HrmEmployeeDO employee = employeeMapper.selectByUserId(userId);
        if (employee == null) {
            throw exception(Objects.equals(userId, getCurrentUserId())
                    ? LEAVE_APPLICATION_EMPLOYEE_NOT_EXISTS : LEAVE_APPLICATION_SELECTED_EMPLOYEE_NOT_EXISTS);
        }
        if (employee.getDeptId() == null) {
            throw exception(LEAVE_APPLICATION_EMPLOYEE_DEPT_NOT_FOUND);
        }
        if (createReqVO.getDeptId() != null && !Objects.equals(createReqVO.getDeptId(), employee.getDeptId())) {
            throw exception(LEAVE_APPLICATION_DEPT_MISMATCH);
        }
        // 2. ç”Ÿæˆç”³è¯·å•号
        String no = generateUniqueNo();
        // 3. æ’å…¥
        HrmLeaveApplicationDO application = BeanUtils.toBean(createReqVO, HrmLeaveApplicationDO.class);
        application.setNo(no);
        application.setUserId(userId);
        application.setDeptId(createReqVO.getDeptId() != null ? createReqVO.getDeptId() : user.getDeptId());
        application.setDeptId(employee.getDeptId());
        application.setStatus(HrmAuditStatusEnum.DRAFT.getStatus());
        leaveApplicationMapper.insert(application);
        // ç»‘定附件
@@ -96,6 +126,242 @@
                application.getId(), createReqVO.getBlobIds());
        return application.getId();
    }
    @Override
    public HrmLeaveApplicationImportRespVO importLeaveApplications(List<HrmLeaveApplicationImportExcelVO> list) {
        if (CollUtil.isEmpty(list)) {
            throw exception(LEAVE_APPLICATION_IMPORT_FILE_EMPTY);
        }
        int totalCount = 0;
        int successCount = 0;
        List<HrmLeaveApplicationImportFailureVO> failures = new ArrayList<>();
        for (int index = 0; index < list.size(); index++) {
            HrmLeaveApplicationImportExcelVO row = list.get(index);
            // Excel ç¬¬ 1 è¡Œæ˜¯è¡¨å¤´ï¼Œç¬¬ index æ¡æ•°æ®å¯¹åº”第 index + 2 è¡Œ
            int rowNum = index + 2;
            if (isBlankRow(row)) {
                continue;
            }
            totalCount++;
            try {
                leaveApplicationMapper.insert(buildImportedDraft(row));
                successCount++;
            } catch (LeaveImportRowException e) {
                failures.add(new HrmLeaveApplicationImportFailureVO(rowNum, trimToNull(row.getPhone()),
                        trimToNull(row.getEmployeeName()), e.getMessage()));
            } catch (Exception e) {
                log.error("[importLeaveApplications][第 {} è¡Œå¯¼å…¥å¤±è´¥]", rowNum, e);
                failures.add(new HrmLeaveApplicationImportFailureVO(rowNum, trimToNull(row.getPhone()),
                        trimToNull(row.getEmployeeName()),
                        "写入数据库失败:" + StrUtil.blankToDefault(e.getMessage(), e.getClass().getSimpleName())
                                + ";请检查请假原因/备注长度后重新导入该行,仍失败请联系管理员"));
            }
        }
        if (totalCount == 0) {
            throw exception(LEAVE_APPLICATION_IMPORT_FILE_EMPTY);
        }
        return HrmLeaveApplicationImportRespVO.builder()
                .totalCount(totalCount)
                .successCount(successCount)
                .failureCount(failures.size())
                .failures(failures)
                .build();
    }
    private boolean isBlankRow(HrmLeaveApplicationImportExcelVO row) {
        return row == null || (StrUtil.isBlank(row.getPhone()) && StrUtil.isBlank(row.getEmployeeName())
                && StrUtil.isBlank(row.getLeaveTypeName()) && StrUtil.isBlank(row.getStartTime())
                && StrUtil.isBlank(row.getEndTime()) && StrUtil.isBlank(row.getDuration())
                && StrUtil.isBlank(row.getReason()));
    }
    /**
     * å°†ä¸€è¡Œ Excel æ•°æ®è½¬æ¢ä¸ºè¯·å‡ç”³è¯·è‰ç¨¿ï¼›ä»»ä¸€æ ¡éªŒä¸é€šè¿‡æ—¶æŠ›å‡ºå¸¦ç²¾ç¡®åŽŸå› çš„ {@link LeaveImportRowException}
     */
    private HrmLeaveApplicationDO buildImportedDraft(HrmLeaveApplicationImportExcelVO row) {
        // 1. æ‰‹æœºå·åŒ¹é…å‘˜å·¥æ¡£æ¡ˆ
        String phone = normalizeCell(row.getPhone());
        if (StrUtil.isBlank(phone)) {
            throw new LeaveImportRowException("手机号为空,请填写员工档案中登记的 11 ä½æ‰‹æœºå·");
        }
        if (!PHONE_PATTERN.matcher(phone).matches()) {
            throw new LeaveImportRowException("手机号[" + phone + "]不是有效的 11 ä½æ‰‹æœºå·ï¼Œè¯·æ£€æŸ¥æ˜¯å¦å¤šå¡«äº†ç©ºæ ¼ã€ç¬¦å·æˆ–小数位");
        }
        HrmEmployeeDO employee = employeeMapper.selectByPhone(phone);
        if (employee == null) {
            throw new LeaveImportRowException("手机号[" + phone + "]未匹配到员工档案,请先在「员工档案」中维护该手机号后重新导入");
        }
        String inputName = trimToNull(row.getEmployeeName());
        if (inputName != null && !Objects.equals(inputName, StrUtil.trim(employee.getName()))) {
            throw new LeaveImportRowException("手机号[" + phone + "]对应员工为[" + employee.getName()
                    + "],与填写的员工姓名[" + inputName + "]不一致,请核对手机号后重新导入");
        }
        if (employee.getUserId() == null) {
            throw new LeaveImportRowException("员工档案[" + employee.getName() + "](手机号 " + phone
                    + ")未关联系统登录账号,请先在员工档案中绑定账号后重新导入");
        }
        if (employee.getDeptId() == null) {
            throw new LeaveImportRowException("员工档案[" + employee.getName()
                    + "]未设置部门,请先在员工档案中补全部门后重新导入");
        }
        // 2. è¯·å‡ç±»åž‹
        Integer leaveType = parseLeaveType(row.getLeaveTypeName());
        // 3. èµ·æ­¢æ—¶é—´ä¸Žæ—¶é•¿
        LocalDateTime startTime = parseDateTime("开始时间", row.getStartTime());
        LocalDateTime endTime = parseDateTime("结束时间", row.getEndTime());
        if (!endTime.isAfter(startTime)) {
            throw new LeaveImportRowException("结束时间[" + formatDateTime(endTime) + "]必须晚于开始时间["
                    + formatDateTime(startTime) + "],请调整后重新导入");
        }
        BigDecimal duration = parseDuration(row.getDuration(), startTime, endTime);
        // 4. è¯·å‡åŽŸå› 
        String reason = trimToNull(row.getReason());
        if (reason == null) {
            throw new LeaveImportRowException("请假原因为空,请填写请假事由(将展示在审批单上)");
        }
        if (reason.length() > 500) {
            throw new LeaveImportRowException("请假原因长度为 " + reason.length()
                    + " ä¸ªå­—符,超过 500 å­—上限,请精简后重新导入");
        }
        String remark = trimToNull(row.getRemark());
        if (remark != null && remark.length() > 500) {
            throw new LeaveImportRowException("备注长度为 " + remark.length()
                    + " ä¸ªå­—符,超过 500 å­—上限,请精简后重新导入");
        }
        // 5. ç»„装草稿(不提交审批)
        HrmLeaveApplicationDO application = new HrmLeaveApplicationDO();
        application.setNo(generateUniqueNo());
        application.setUserId(employee.getUserId());
        application.setDeptId(employee.getDeptId());
        application.setLeaveType(leaveType);
        application.setStartTime(startTime);
        application.setEndTime(endTime);
        application.setDuration(duration);
        application.setReason(reason);
        application.setRemark(remark);
        application.setStatus(HrmAuditStatusEnum.DRAFT.getStatus());
        return application;
    }
    private Integer parseLeaveType(String raw) {
        String value = normalizeCell(raw);
        if (StrUtil.isBlank(value)) {
            throw new LeaveImportRowException("请假类型为空,请填写:事假、病假、年假、婚假、产假、丧假(或数字 1-6)");
        }
        for (HrmLeaveTypeEnum type : HrmLeaveTypeEnum.values()) {
            if (StrUtil.equals(value, type.getName()) || StrUtil.equals(value, String.valueOf(type.getType()))) {
                return type.getType();
            }
        }
        throw new LeaveImportRowException("请假类型[" + value
                + "]不在可选范围内,请填写:事假、病假、年假、婚假、产假、丧假(或数字 1-6)");
    }
    private LocalDateTime parseDateTime(String field, String raw) {
        String value = normalizeCell(raw);
        if (StrUtil.isBlank(value)) {
            throw new LeaveImportRowException(field + "为空,请填写格式如 2026-11-10 09:00 çš„æ—¶é—´");
        }
        // å…¼å®¹ Excel æŠŠæ—¥æœŸå­˜æˆæ•°å­—序列号(如 46000.375)
        if (EXCEL_SERIAL_PATTERN.matcher(value).matches()) {
            double serial = Double.parseDouble(value);
            if (serial > 20000 && serial < 80000) {
                return LocalDateTime.ofInstant(
                        org.apache.poi.ss.usermodel.DateUtil.getJavaDate(serial).toInstant(), ZoneId.systemDefault());
            }
        }
        for (DateTimeFormatter formatter : DATE_TIME_FORMATTERS) {
            try {
                return LocalDateTime.parse(value, formatter);
            } catch (DateTimeParseException ignored) {
                // å°è¯•下一种格式
            }
        }
        // åªå¡«æ—¥æœŸæ—¶æŒ‰å½“天 00:00 å¤„理
        for (DateTimeFormatter formatter : DATE_ONLY_FORMATTERS) {
            try {
                return java.time.LocalDate.parse(value, formatter).atStartOfDay();
            } catch (DateTimeParseException ignored) {
                // å°è¯•下一种格式
            }
        }
        throw new LeaveImportRowException(field + "[" + value
                + "]格式不正确,请使用 yyyy-MM-dd HH:mm æˆ– yyyy-MM-dd HH:mm:ss(例如 2026-11-10 09:00)");
    }
    private BigDecimal parseDuration(String raw, LocalDateTime startTime, LocalDateTime endTime) {
        String value = normalizeCell(raw);
        if (StrUtil.isBlank(value)) {
            throw new LeaveImportRowException("请假时长为空,请填写大于 0 çš„天数(支持 0.5 å¤©ï¼‰");
        }
        value = StrUtil.removeSuffix(value, "天").trim();
        BigDecimal duration;
        try {
            duration = new BigDecimal(value);
        } catch (NumberFormatException e) {
            throw new LeaveImportRowException("请假时长[" + value + "]不是有效数字,请填写大于 0 çš„天数(例如 0.5、1、2.5)");
        }
        if (duration.compareTo(BigDecimal.ZERO) <= 0) {
            throw new LeaveImportRowException("请假时长[" + duration.stripTrailingZeros().toPlainString()
                    + "]必须大于 0 å¤©ï¼Œè¯·æŒ‰å®žé™…请假天数填写");
        }
        // ä»…做提示,不阻断:时长与起止时间明显不符时给出可核对信息
        long minutes = java.time.Duration.between(startTime, endTime).toMinutes();
        BigDecimal expectedMax = BigDecimal.valueOf(minutes)
                .divide(BigDecimal.valueOf(60 * 24), 2, RoundingMode.HALF_UP);
        if (duration.compareTo(expectedMax) > 0) {
            throw new LeaveImportRowException("请假时长[" + duration.stripTrailingZeros().toPlainString()
                    + " å¤©]超过起止时间跨度[" + expectedMax.stripTrailingZeros().toPlainString()
                    + " å¤©],请核对开始/结束时间与时长后重新导入");
        }
        return duration;
    }
    private static String normalizeCell(String raw) {
        if (StrUtil.isBlank(raw)) {
            return null;
        }
        String value = raw.trim();
        // Excel æ•°å€¼å•元格可能被读成科学计数法或带 .0 çš„小数,统一还原为普通数字
        if (SCIENTIFIC_PATTERN.matcher(value).matches() || TRAILING_ZERO_PATTERN.matcher(value).matches()) {
            try {
                value = new BigDecimal(value).stripTrailingZeros().toPlainString();
            } catch (NumberFormatException ignored) {
                // ä¿æŒåŽŸå€¼
            }
        }
        return value;
    }
    private static String trimToNull(String value) {
        return StrUtil.isBlank(value) ? null : value.trim();
    }
    private static String formatDateTime(LocalDateTime value) {
        return value.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
    }
    /** å•行导入校验异常:message ç›´æŽ¥ä½œä¸ºè¯¥è¡Œçš„失败原因返回给用户 */
    private static class LeaveImportRowException extends RuntimeException {
        LeaveImportRowException(String message) {
            super(message);
        }
    }
    private static final Pattern PHONE_PATTERN = Pattern.compile("^1\\d{10}$");
    private static final Pattern EXCEL_SERIAL_PATTERN = Pattern.compile("^\\d{5}(\\.\\d+)?$");
    private static final Pattern SCIENTIFIC_PATTERN = Pattern.compile("^-?\\d+(\\.\\d+)?[eE][+-]?\\d+$");
    private static final Pattern TRAILING_ZERO_PATTERN = Pattern.compile("^-?\\d+\\.0+$");
    private static final DateTimeFormatter[] DATE_TIME_FORMATTERS = new DateTimeFormatter[]{
            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"),
            DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss"),
            DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm")
    };
    private static final DateTimeFormatter[] DATE_ONLY_FORMATTERS = new DateTimeFormatter[]{
            DateTimeFormatter.ofPattern("yyyy-MM-dd"),
            DateTimeFormatter.ofPattern("yyyy/MM/dd")
    };
    private Long getCurrentUserId() {
        return SecurityFrameworkUtils.getLoginUserId();
@@ -113,13 +379,20 @@
    public void updateLeaveApplication(HrmLeaveApplicationSaveReqVO updateReqVO) {
        // æ ¡éªŒå­˜åœ¨
        HrmLeaveApplicationDO application = validateLeaveApplicationExists(updateReqVO.getId());
        // æ ¡éªŒçŠ¶æ€ä¸ºè‰ç¨¿æˆ–å®¡æ‰¹ä¸­
        validateOperator(application, getCurrentUserId());
        // é©³å›žåŽå…è®¸ä¿®æ”¹å¹¶é‡æ–°æäº¤å®¡æ‰¹ã€‚
        if (!Objects.equals(application.getStatus(), HrmAuditStatusEnum.DRAFT.getStatus())
                && !Objects.equals(application.getStatus(), HrmAuditStatusEnum.PROCESS.getStatus())) {
            throw exception(LEAVE_APPLICATION_UPDATE_FAIL_NOT_DRAFT);
                && !Objects.equals(application.getStatus(), HrmAuditStatusEnum.REJECT.getStatus())) {
            throw exception(LEAVE_APPLICATION_UPDATE_FAIL_NOT_EDITABLE);
        }
        if (updateReqVO.getUserId() != null && !Objects.equals(updateReqVO.getUserId(), application.getUserId())) {
            throw exception(LEAVE_APPLICATION_APPLICANT_IMMUTABLE);
        }
        validateApplicantDept(application.getUserId(),
                updateReqVO.getDeptId() != null ? updateReqVO.getDeptId() : application.getDeptId());
        // æ›´æ–°
        HrmLeaveApplicationDO updateObj = BeanUtils.toBean(updateReqVO, HrmLeaveApplicationDO.class);
        updateObj.setUserId(null);
        leaveApplicationMapper.updateById(updateObj);
        storageAttachmentApi.updateAttachments("file", "hrm_leave_application", updateReqVO.getId(), updateReqVO.getBlobIds());
    }
@@ -128,6 +401,7 @@
    public void deleteLeaveApplication(Long id) {
        // æ ¡éªŒå­˜åœ¨
        HrmLeaveApplicationDO application = validateLeaveApplicationExists(id);
        validateOperator(application, getCurrentUserId());
        // æ ¡éªŒçŠ¶æ€ä¸ºè‰ç¨¿
        if (!Objects.equals(application.getStatus(), HrmAuditStatusEnum.DRAFT.getStatus())) {
            throw exception(LEAVE_APPLICATION_UPDATE_FAIL_NOT_DRAFT);
@@ -218,9 +492,12 @@
    public void submitLeaveApplication(Long id, String processDefinitionKey, Long userId) {
        // 1. æ ¡éªŒè¯·å‡ç”³è¯·æ˜¯å¦å­˜åœ¨
        HrmLeaveApplicationDO application = validateLeaveApplicationExists(id);
        if (ObjUtil.notEqual(application.getStatus(), HrmAuditStatusEnum.DRAFT.getStatus())) {
        validateOperator(application, userId);
        if (ObjUtil.notEqual(application.getStatus(), HrmAuditStatusEnum.DRAFT.getStatus())
                && ObjUtil.notEqual(application.getStatus(), HrmAuditStatusEnum.REJECT.getStatus())) {
            throw exception(LEAVE_APPLICATION_SUBMIT_FAIL_NOT_DRAFT);
        }
        validateApplicantDept(application.getUserId(), application.getDeptId());
        // 2. æ ¡éªŒåˆ†ç±»å’Œæµç¨‹å®šä¹‰æ˜¯å¦å­˜åœ¨
        // å…¨å±€å…å®¡æ‰¹ï¼šä¸šåŠ¡æ ¡éªŒé€šè¿‡åŽç›´æŽ¥æ‰§è¡Œå®¡æ‰¹é€šè¿‡é€»è¾‘ï¼Œæ— éœ€æµç¨‹æ¨¡åž‹ã€‚
@@ -237,21 +514,51 @@
        validateApproveCategoryAndProcessDefinition();
        // 3. æ ¡éªŒä¼ å…¥çš„æµç¨‹å®šä¹‰ Key æ˜¯å¦å±žäºŽè¯¥åˆ†ç±»ä¸”处于激活状态
        if (processDefinitionKey == null || processDefinitionKey.isBlank()) {
            throw exception(LEAVE_APPLICATION_BPM_PROCESS_DEFINITION_NOT_EXISTS);
        }
        ProcessDefinition processDefinition = bpmProcessDefinitionService.getActiveProcessDefinition(processDefinitionKey);
        if (processDefinition == null) {
        if (processDefinition == null || bpmProcessDefinitionService
                .getProcessDefinitionInfoListByCategory(APPROVE_CATEGORY_CODE).stream()
                .noneMatch(info -> Objects.equals(info.getProcessDefinitionId(), processDefinition.getId()))) {
            throw exception(LEAVE_APPLICATION_BPM_PROCESS_DEFINITION_NOT_EXISTS);
        }
        // 4. åˆ›å»ºå®¡æ‰¹æµç¨‹å®žä¾‹
        String processInstanceId = bpmProcessInstanceApi.createProcessInstance(userId, new BpmProcessInstanceCreateReqDTO()
                .setProcessDefinitionKey(processDefinitionKey).setBusinessKey(String.valueOf(id)));
        Map<String, Object> variables = new HashMap<>();
        variables.put(BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_BUSINESS_NO, application.getNo());
        AdminUserRespDTO applicant = adminUserApi.getUser(application.getUserId());
        variables.put("Fl55mt725q5yabc", applicant != null ? applicant.getNickname() : String.valueOf(application.getUserId()));
        variables.put("F5eqmt725u3naec", LEAVE_TIME_FORMAT.format(application.getStartTime())
                + " è‡³ " + LEAVE_TIME_FORMAT.format(application.getEndTime()));
        variables.put("F8wtmt726c3zahc", application.getDuration().toPlainString());
        String processInstanceId = bpmProcessInstanceApi.createProcessInstance(application.getUserId(), new BpmProcessInstanceCreateReqDTO()
                .setProcessDefinitionKey(processDefinitionKey).setBusinessKey(String.valueOf(id))
                .setVariables(variables));
        // 5. æ›´æ–°è¯·å‡ç”³è¯·å·¥ä½œæµç¼–号
        leaveApplicationMapper.updateById(HrmLeaveApplicationDO.builder()
                .id(id)
                .processInstanceId(processInstanceId)
                .status(HrmAuditStatusEnum.PROCESS.getStatus())
                .build());
        leaveApplicationMapper.update(null, new LambdaUpdateWrapper<HrmLeaveApplicationDO>()
                .eq(HrmLeaveApplicationDO::getId, id)
                .set(HrmLeaveApplicationDO::getProcessInstanceId, processInstanceId)
                .set(HrmLeaveApplicationDO::getStatus, HrmAuditStatusEnum.PROCESS.getStatus())
                .set(HrmLeaveApplicationDO::getApproveTime, null));
    }
    private void validateOperator(HrmLeaveApplicationDO application, Long operatorId) {
        if (!Objects.equals(application.getUserId(), operatorId)
                && !Objects.equals(application.getCreator(), String.valueOf(operatorId))) {
            throw exception(LEAVE_APPLICATION_OPERATOR_NOT_ALLOWED);
        }
    }
    private void validateApplicantDept(Long userId, Long deptId) {
        HrmEmployeeDO employee = employeeMapper.selectByUserId(userId);
        if (employee == null || employee.getDeptId() == null) {
            throw exception(LEAVE_APPLICATION_EMPLOYEE_DEPT_NOT_FOUND);
        }
        if (!Objects.equals(employee.getDeptId(), deptId)) {
            throw exception(LEAVE_APPLICATION_DEPT_MISMATCH);
        }
    }
    /**
@@ -331,11 +638,12 @@
            throw exception(LEAVE_APPLICATION_UPDATE_AUDIT_STATUS_FAIL_NOT_PROCESS);
        }
        // æ›´æ–°çŠ¶æ€
        leaveApplicationMapper.updateById(HrmLeaveApplicationDO.builder()
                .id(id)
                .status(status)
                .approveTime(java.time.LocalDateTime.now())
                .build());
        leaveApplicationMapper.update(null, new LambdaUpdateWrapper<HrmLeaveApplicationDO>()
                .eq(HrmLeaveApplicationDO::getId, id)
                .set(HrmLeaveApplicationDO::getStatus, status)
                .set(HrmLeaveApplicationDO::getApproveTime,
                        Objects.equals(status, HrmAuditStatusEnum.APPROVE.getStatus())
                                ? java.time.LocalDateTime.now() : null));
    }
    @Override
@@ -343,4 +651,4 @@
        return leaveApplicationMapper.selectByProcessInstanceId(processInstanceId);
    }
}
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/approval/HrmOvertimeApplicationServiceImpl.java
@@ -4,8 +4,10 @@
import cn.hutool.core.util.NumberUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService;
import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi;
import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO;
import cn.iocoder.yudao.module.bpm.controller.admin.task.vo.instance.BpmProcessInstanceCancelReqVO;
@@ -56,11 +58,12 @@
    @Resource private BpmTaskService bpmTaskService;
    @Resource private BpmCategoryService bpmCategoryService;
    @Resource private BpmProcessDefinitionService bpmProcessDefinitionService;
    @Resource private SecurityFrameworkService securityFrameworkService;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long create(HrmOvertimeApplicationSaveReqVO reqVO, Long loginUserId) {
        HrmEmployeeDO employee = requireEmployee(loginUserId);
        HrmEmployeeDO employee = resolveApplicant(reqVO.getEmployeeId(), loginUserId);
        HrmOvertimeApplicationDO application = BeanUtils.toBean(reqVO, HrmOvertimeApplicationDO.class);
        applyApplicantAndCalculatedTime(application, employee);
        application.setNo(noRedisDAO.generateOvertimeApplicationNo());
@@ -73,7 +76,9 @@
    public void update(HrmOvertimeApplicationSaveReqVO reqVO, Long loginUserId) {
        HrmOvertimeApplicationDO existing = requireOwnedDraft(reqVO.getId(), loginUserId);
        HrmOvertimeApplicationDO update = BeanUtils.toBean(reqVO, HrmOvertimeApplicationDO.class);
        applyApplicantAndCalculatedTime(update, requireEmployee(loginUserId));
        // æœªé‡æ–°é€‰æ‹©ç”³è¯·äººæ—¶ï¼Œæ²¿ç”¨åŽŸç”³è¯·äººï¼Œé¿å…ä»£ç”³è¯·å•è¢«æ”¹å›žç™»å½•äºº
        Long requestedEmployeeId = reqVO.getEmployeeId() != null ? reqVO.getEmployeeId() : existing.getEmployeeId();
        applyApplicantAndCalculatedTime(update, resolveApplicant(requestedEmployeeId, loginUserId));
        update.setId(existing.getId());
        mapper.updateById(update);
    }
@@ -83,7 +88,10 @@
        HrmOvertimeApplicationDO application = require(id);
        boolean deletable = Objects.equals(application.getStatus(), HrmAuditStatusEnum.DRAFT.getStatus())
                || Objects.equals(application.getStatus(), HrmAuditStatusEnum.CANCEL.getStatus());
        if (!Objects.equals(application.getUserId(), loginUserId) || !deletable) {
        if (!Objects.equals(application.getUserId(), loginUserId) && !canManageOvertime()) {
            throw exception(OVERTIME_APPLICATION_OPERATOR_NOT_ALLOWED);
        }
        if (!deletable) {
            throw exception(OVERTIME_APPLICATION_UPDATE_FAIL_NOT_DRAFT);
        }
        mapper.deleteById(id);
@@ -130,22 +138,30 @@
        if (definition == null || !BPM_PROCESS_DEFINITION_KEY.equals(definition.getKey())) {
            throw exception(OVERTIME_APPLICATION_BPM_PROCESS_DEFINITION_NOT_EXISTS);
        }
        String processInstanceId = bpmProcessInstanceApi.createProcessInstance(loginUserId,
        // ä»¥ç”³è¯·äººå‘˜å·¥å¯¹åº”的系统账号发起流程,保证代申请单的审批人能看到正确的申请人
        String processInstanceId = bpmProcessInstanceApi.createProcessInstance(application.getUserId(),
                new BpmProcessInstanceCreateReqDTO().setProcessDefinitionKey(processDefinitionKey)
                        .setBusinessKey(String.valueOf(id)));
        mapper.updateById(HrmOvertimeApplicationDO.builder().id(id).processInstanceId(processInstanceId)
                .status(HrmAuditStatusEnum.PROCESS.getStatus()).build());
                        .setBusinessKey(String.valueOf(id))
                        .setVariables(Map.of("businessNo", application.getNo())));
        mapper.update(null, new LambdaUpdateWrapper<HrmOvertimeApplicationDO>()
                .eq(HrmOvertimeApplicationDO::getId, id)
                .set(HrmOvertimeApplicationDO::getProcessInstanceId, processInstanceId)
                .set(HrmOvertimeApplicationDO::getStatus, HrmAuditStatusEnum.PROCESS.getStatus())
                .set(HrmOvertimeApplicationDO::getApproveTime, null));
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void cancel(Long id, Long loginUserId) {
        HrmOvertimeApplicationDO application = require(id);
        if (!Objects.equals(application.getUserId(), loginUserId)
                || !Objects.equals(application.getStatus(), HrmAuditStatusEnum.PROCESS.getStatus())) {
        if (!Objects.equals(application.getUserId(), loginUserId) && !canManageOvertime()) {
            throw exception(OVERTIME_APPLICATION_OPERATOR_NOT_ALLOWED);
        }
        if (!Objects.equals(application.getStatus(), HrmAuditStatusEnum.PROCESS.getStatus())) {
            throw exception(OVERTIME_APPLICATION_UPDATE_AUDIT_STATUS_FAIL_NOT_PROCESS);
        }
        bpmProcessInstanceService.cancelProcessInstanceByStartUser(loginUserId,
        // æµç¨‹ç”±ç”³è¯·äººå‘˜å·¥å‘起,撤销也必须以该员工账号作为发起人
        bpmProcessInstanceService.cancelProcessInstanceByStartUser(application.getUserId(),
                new BpmProcessInstanceCancelReqVO().setId(application.getProcessInstanceId()).setReason("申请人撤销加班申请"));
    }
@@ -173,8 +189,12 @@
        if (!Objects.equals(application.getStatus(), HrmAuditStatusEnum.PROCESS.getStatus())) {
            throw exception(OVERTIME_APPLICATION_UPDATE_AUDIT_STATUS_FAIL_NOT_PROCESS);
        }
        mapper.updateById(HrmOvertimeApplicationDO.builder().id(id).status(status)
                .approveTime(LocalDateTime.now()).build());
        mapper.update(null, new LambdaUpdateWrapper<HrmOvertimeApplicationDO>()
                .eq(HrmOvertimeApplicationDO::getId, id)
                .set(HrmOvertimeApplicationDO::getStatus, status)
                .set(HrmOvertimeApplicationDO::getApproveTime,
                        Objects.equals(status, HrmAuditStatusEnum.APPROVE.getStatus())
                                ? LocalDateTime.now() : null));
    }
    private void validateProcessDefinitions() {
@@ -225,13 +245,47 @@
    private HrmOvertimeApplicationDO requireOwnedDraft(Long id, Long userId) {
        HrmOvertimeApplicationDO value = require(id);
        if (!Objects.equals(value.getUserId(), userId)
                || !Objects.equals(value.getStatus(), HrmAuditStatusEnum.DRAFT.getStatus())) {
        // æŒæœ‰ hrm:overtime:manage æƒé™å¯æ“ä½œå…¨éƒ¨äººçš„单子;否则只能操作本人单子
        if (!Objects.equals(value.getUserId(), userId) && !canManageOvertime()) {
            throw exception(OVERTIME_APPLICATION_OPERATOR_NOT_ALLOWED);
        }
        if (!Objects.equals(value.getStatus(), HrmAuditStatusEnum.DRAFT.getStatus())
                && !Objects.equals(value.getStatus(), HrmAuditStatusEnum.REJECT.getStatus())) {
            throw exception(OVERTIME_APPLICATION_UPDATE_FAIL_NOT_DRAFT);
        }
        return value;
    }
    /**
     * è§£æžæœ¬æ¬¡ç”³è¯·å¯¹åº”的员工档案。
     *
     * @param requestedEmployeeId å‰ç«¯é€‰æ‹©çš„申请人员工ID;为空表示本人
     * @param loginUserId         å½“前登录用户ID
     * @return ç”³è¯·äººå¯¹åº”的员工档案
     */
    private HrmEmployeeDO resolveApplicant(Long requestedEmployeeId, Long loginUserId) {
        HrmEmployeeDO self = employeeMapper.selectByUserId(loginUserId);
        if (requestedEmployeeId == null || (self != null && Objects.equals(self.getId(), requestedEmployeeId))) {
            if (self == null) {
                throw exception(OVERTIME_APPLICATION_EMPLOYEE_NOT_EXISTS);
            }
            return self;
        }
        // ä»£ç”³è¯·ï¼šå¿…须有管理权限
        if (!canManageOvertime()) {
            throw exception(OVERTIME_APPLICATION_PROXY_NOT_ALLOWED);
        }
        HrmEmployeeDO target = employeeMapper.selectById(requestedEmployeeId);
        if (target == null) {
            throw exception(OVERTIME_APPLICATION_SELECTED_EMPLOYEE_NOT_EXISTS);
        }
        return target;
    }
    private boolean canManageOvertime() {
        return securityFrameworkService.hasPermission("hrm:overtime:manage");
    }
    private HrmEmployeeDO requireEmployee(Long userId) {
        HrmEmployeeDO employee = employeeMapper.selectByUserId(userId);
        if (employee == null) throw exception(OVERTIME_APPLICATION_EMPLOYEE_NOT_EXISTS);
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceSummaryServiceImpl.java
@@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.hrm.service.attendance;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceSummaryPageReqVO;
@@ -20,7 +21,10 @@
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
 * è€ƒå‹¤ç»Ÿè®¡æ±‡æ€» Service å®žçŽ°ç±»
@@ -168,23 +172,77 @@
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public PageResult<HrmAttendanceSummaryDO> getAttendanceSummaryPage(HrmAttendanceSummaryPageReqVO pageReqVO) {
        return attendanceSummaryMapper.selectPage(pageReqVO, new LambdaQueryWrapperX<HrmAttendanceSummaryDO>()
                .eqIfPresent(HrmAttendanceSummaryDO::getUserId, pageReqVO.getUserId())
                .eqIfPresent(HrmAttendanceSummaryDO::getDeptId, pageReqVO.getDeptId())
                .eqIfPresent(HrmAttendanceSummaryDO::getYearMonth, pageReqVO.getYearMonth())
                .orderByDesc(HrmAttendanceSummaryDO::getYearMonth)
                .orderByAsc(HrmAttendanceSummaryDO::getDeptId));
        PageResult<HrmAttendanceSummaryDO> pageResult = attendanceSummaryMapper.selectPage(pageReqVO,
                new LambdaQueryWrapperX<HrmAttendanceSummaryDO>()
                        .eqIfPresent(HrmAttendanceSummaryDO::getUserId, pageReqVO.getUserId())
                        .eqIfPresent(HrmAttendanceSummaryDO::getDeptId, pageReqVO.getDeptId())
                        .eqIfPresent(HrmAttendanceSummaryDO::getYearMonth, pageReqVO.getYearMonth())
                        .orderByDesc(HrmAttendanceSummaryDO::getYearMonth)
                        .orderByAsc(HrmAttendanceSummaryDO::getDeptId));
        refreshWorkDays(pageResult.getList());
        return pageResult;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public HrmAttendanceSummaryDO getMonthlySummary(Long userId, String yearMonth) {
        return attendanceSummaryMapper.selectByUserIdAndYearMonth(userId, yearMonth);
        HrmAttendanceSummaryDO summary = attendanceSummaryMapper.selectByUserIdAndYearMonth(userId, yearMonth);
        if (summary != null) {
            refreshWorkDays(List.of(summary));
        }
        return summary;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public List<HrmAttendanceSummaryDO> getDeptMonthlySummaryList(Long deptId, String yearMonth) {
        return attendanceSummaryMapper.selectListByDeptIdAndYearMonth(deptId, yearMonth);
        List<HrmAttendanceSummaryDO> list = attendanceSummaryMapper.selectListByDeptIdAndYearMonth(deptId, yearMonth);
        refreshWorkDays(list);
        return list;
    }
    /**
     * æŸ¥è¯¢/导出时按“最新节假日配置”重算应出勤天数,并把变化的记录回写。
     *
     * èŠ‚å‡æ—¥é…ç½®å¯èƒ½åœ¨ç”Ÿæˆæ±‡æ€»ä¹‹åŽè¢«è°ƒæ•´ï¼ˆå¦‚æ–°å¢žè°ƒä¼‘/法定假日),若只返回旧值,
     * é¡µé¢ä¸Šçš„应出勤天数会与节假日配置长期不一致。
     */
    private void refreshWorkDays(List<HrmAttendanceSummaryDO> list) {
        if (CollUtil.isEmpty(list)) {
            return;
        }
        // åŒä¸€æ¬¡è¯·æ±‚内同一年月只计算一次
        Map<String, Integer> workDaysCache = new HashMap<>();
        for (HrmAttendanceSummaryDO summary : list) {
            if (StrUtil.isBlank(summary.getYearMonth())) {
                continue;
            }
            Integer expected;
            try {
                expected = workDaysCache.computeIfAbsent(summary.getYearMonth(), yearMonth -> {
                    YearMonth parsed = YearMonth.parse(yearMonth);
                    return calculateWorkDays(parsed.atDay(1), parsed.atEndOfMonth());
                });
            } catch (Exception e) {
                log.warn("[refreshWorkDays][考勤汇总({}) å¹´æœˆ({}) æ— æ³•解析,跳过重算]",
                        summary.getId(), summary.getYearMonth(), e);
                continue;
            }
            if (!Objects.equals(summary.getWorkDays(), expected)) {
                log.info("[refreshWorkDays][考勤汇总({}) å¹´æœˆ({}) åº”出勤天数 {} -> {},按最新节假日配置回写]",
                        summary.getId(), summary.getYearMonth(), summary.getWorkDays(), expected);
                summary.setWorkDays(expected);
                try {
                    attendanceSummaryMapper.updateById(HrmAttendanceSummaryDO.builder()
                            .id(summary.getId()).workDays(expected).build());
                } catch (Exception e) {
                    // å›žå†™å¤±è´¥ä¸å½±å“æœ¬æ¬¡æŸ¥è¯¢ç»“果,保留内存中重算后的值
                    log.warn("[refreshWorkDays][考勤汇总({}) åº”出勤天数回写失败]", summary.getId(), e);
                }
            }
        }
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmEmployeeScheduleService.java
@@ -1,5 +1,8 @@
package cn.iocoder.yudao.module.hrm.service.attendance;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmEmployeeScheduleBatchAssignReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmEmployeeScheduleBatchAssignRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmEmployeeScheduleLeaveRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmEmployeeScheduleListReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmEmployeeScheduleRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmEmployeeScheduleSaveReqVO;
@@ -23,4 +26,20 @@
     */
    void saveSchedule(List<HrmEmployeeScheduleSaveReqVO> saveReqVOList);
    /**
     * æ‰¹é‡æŽ’班:按部门(含下级)+ å²—位 + å‘˜å·¥çŠ¶æ€ç­›é€‰å‘˜å·¥ï¼ŒæŒ‰æ—¥æœŸåŒºé—´ç»Ÿä¸€æŽ’ç­ã€‚
     *
     * è§„则:
     * 1. èŠ‚å‡æ—¥é…ç½®ä¼˜å…ˆäºŽå‘¨å…­æ—¥è§„åˆ™ï¼ˆç”± {@code isWorkDay} å†³å®šå·¥ä½œæ—¥/休息日);
     * 2. å·¥ä½œæ—¥å†™å…¥æŒ‡å®šç­æ¬¡ï¼Œä¼‘息日/节假日清空原有班次;
     * 3. å‘˜å·¥å½“天存在已审批通过的请假时跳过,不改动该单元格;
     * 4. é€»è¾‘删除后遗留的唯一键冲突通过“复活旧行”规避。
     */
    HrmEmployeeScheduleBatchAssignRespVO batchAssign(HrmEmployeeScheduleBatchAssignReqVO reqVO);
    /**
     * æŸ¥è¯¢æŽ’班页需要标记的请假日期(已审批通过,按天展开)
     */
    List<HrmEmployeeScheduleLeaveRespVO> getScheduleLeaveList(HrmEmployeeScheduleListReqVO reqVO);
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmEmployeeScheduleServiceImpl.java
@@ -1,24 +1,48 @@
package cn.iocoder.yudao.module.hrm.service.attendance;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmEmployeeScheduleBatchAssignReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmEmployeeScheduleBatchAssignRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmEmployeeScheduleLeaveRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmEmployeeScheduleListReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmEmployeeScheduleRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmEmployeeScheduleSaveReqVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.approval.HrmLeaveApplicationDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmEmployeeScheduleDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmShiftDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO;
import cn.iocoder.yudao.module.hrm.dal.mysql.approval.HrmLeaveApplicationMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.attendance.HrmEmployeeScheduleMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.attendance.HrmShiftMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeMapper;
import cn.iocoder.yudao.module.hrm.enums.HrmEmployeeStatusEnum;
import cn.iocoder.yudao.module.hrm.enums.HrmLeaveTypeEnum;
import cn.iocoder.yudao.module.system.api.dept.DeptApi;
import cn.iocoder.yudao.module.system.api.dept.PostApi;
import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO;
import cn.iocoder.yudao.module.system.api.dept.dto.PostRespDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.*;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -32,7 +56,11 @@
 */
@Service
@Validated
@Slf4j
public class HrmEmployeeScheduleServiceImpl implements HrmEmployeeScheduleService {
    /** æ‰¹é‡æŽ’班单次最多覆盖的天数,避免一次生成过多行拖垮请求 */
    private static final int BATCH_MAX_DAYS = 62;
    @Resource
    private HrmEmployeeScheduleMapper employeeScheduleMapper;
@@ -40,6 +68,14 @@
    private HrmEmployeeMapper employeeMapper;
    @Resource
    private HrmShiftMapper shiftMapper;
    @Resource
    private HrmLeaveApplicationMapper leaveApplicationMapper;
    @Resource
    private HrmAttendanceHolidayService attendanceHolidayService;
    @Resource
    private DeptApi deptApi;
    @Resource
    private PostApi postApi;
    @Override
    public List<HrmEmployeeScheduleRespVO> getScheduleList(HrmEmployeeScheduleListReqVO reqVO) {
@@ -120,24 +156,256 @@
                throw exception(SCHEDULE_SHIFT_NOT_EXISTS);
            }
        }
        // 3. é€æ¡ upsert:有班次则保存,无班次则删除
        // 3. é€æ¡ upsert:有班次则保存,无班次则清空
        for (HrmEmployeeScheduleSaveReqVO reqVO : saveReqVOList) {
            HrmEmployeeScheduleDO existing = employeeScheduleMapper.selectByEmployeeIdAndDate(
                    reqVO.getEmployeeId(), reqVO.getDate());
            if (reqVO.getShiftId() == null) {
                if (existing != null) {
                    employeeScheduleMapper.deleteById(existing.getId());
                }
                clearShift(reqVO.getEmployeeId(), reqVO.getDate());
            } else {
                if (existing == null) {
                    employeeScheduleMapper.insert(BeanUtils.toBean(reqVO, HrmEmployeeScheduleDO.class));
                } else {
                    existing.setShiftId(reqVO.getShiftId());
                    existing.setRemark(reqVO.getRemark());
                    employeeScheduleMapper.updateById(existing);
                upsertShift(reqVO.getEmployeeId(), reqVO.getDate(), reqVO.getShiftId(), reqVO.getRemark());
            }
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public HrmEmployeeScheduleBatchAssignRespVO batchAssign(HrmEmployeeScheduleBatchAssignReqVO reqVO) {
        // 1. æ ¡éªŒæ—¥æœŸåŒºé—´
        LocalDate startDate = reqVO.getStartDate();
        LocalDate endDate = reqVO.getEndDate();
        if (endDate.isBefore(startDate)) {
            throw exception(SCHEDULE_BATCH_DATE_RANGE_INVALID, endDate, startDate);
        }
        long days = ChronoUnit.DAYS.between(startDate, endDate) + 1;
        if (days > BATCH_MAX_DAYS) {
            throw exception(SCHEDULE_BATCH_RANGE_TOO_LONG, startDate, endDate, days);
        }
        // 2. æ ¡éªŒç­æ¬¡å­˜åœ¨
        HrmShiftDO shift = shiftMapper.selectById(reqVO.getShiftId());
        if (shift == null) {
            throw exception(SCHEDULE_SHIFT_NOT_EXISTS);
        }
        // 3. ç­›é€‰å‘˜å·¥ï¼šéƒ¨é—¨ï¼ˆå«ä¸‹çº§ï¼‰+ å²—位 + å‘˜å·¥çŠ¶æ€
        Set<Long> deptIds = new HashSet<>();
        deptIds.add(reqVO.getDeptId());
        List<DeptRespDTO> childDepts = deptApi.getChildDeptList(reqVO.getDeptId());
        if (CollUtil.isNotEmpty(childDepts)) {
            childDepts.forEach(dept -> deptIds.add(dept.getId()));
        }
        List<HrmEmployeeDO> employees = employeeMapper.selectListByDeptIds(deptIds).stream()
                .filter(employee -> reqVO.getEmployeeStatus() == null
                        || Objects.equals(reqVO.getEmployeeStatus(), employee.getEmployeeStatus()))
                .filter(employee -> matchesPost(employee, reqVO.getPostId()))
                .collect(Collectors.toList());
        if (CollUtil.isEmpty(employees)) {
            throw exception(SCHEDULE_BATCH_NO_EMPLOYEE,
                    resolveDeptName(reqVO.getDeptId()),
                    resolvePostName(reqVO.getPostId()),
                    resolveStatusName(reqVO.getEmployeeStatus()));
        }
        // 4. é¢„加载已审批请假,按“员工 + æ—¥æœŸâ€æ ‡è®°éœ€è¦è·³è¿‡çš„单元格
        Set<String> leaveKeys = buildApprovedLeaveKeys(employees, startDate, endDate);
        // 5. æŒ‰æ—¥æœŸé€å¤©æŽ’班:工作日写班次、休息日清空、已审批请假跳过
        int scheduledCount = 0;
        int skippedLeaveCount = 0;
        int clearedRestDayCount = 0;
        for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
            boolean workDay = attendanceHolidayService.isWorkDay(date);
            for (HrmEmployeeDO employee : employees) {
                if (leaveKeys.contains(buildKey(employee.getId(), date))) {
                    skippedLeaveCount++;
                    continue;
                }
                if (workDay) {
                    upsertShift(employee.getId(), date, reqVO.getShiftId(), reqVO.getRemark());
                    scheduledCount++;
                } else if (clearShift(employee.getId(), date)) {
                    clearedRestDayCount++;
                }
            }
        }
        log.info("[batchAssign][部门({}) å²—位({}) çŠ¶æ€({}) {}~{} ç­æ¬¡({}) å‘½ä¸­å‘˜å·¥ {} äººï¼Œå†™å…¥ {} æ¡ï¼Œè·³è¿‡è¯·å‡ {} æ¡ï¼Œæ¸…空休息日 {} æ¡]",
                reqVO.getDeptId(), reqVO.getPostId(), reqVO.getEmployeeStatus(), startDate, endDate,
                reqVO.getShiftId(), employees.size(), scheduledCount, skippedLeaveCount, clearedRestDayCount);
        return HrmEmployeeScheduleBatchAssignRespVO.builder()
                .matchedEmployeeCount(employees.size())
                .scheduledCount(scheduledCount)
                .skippedLeaveCount(skippedLeaveCount)
                .clearedRestDayCount(clearedRestDayCount)
                .build();
    }
    @Override
    public List<HrmEmployeeScheduleLeaveRespVO> getScheduleLeaveList(HrmEmployeeScheduleListReqVO reqVO) {
        YearMonth yearMonth = YearMonth.parse(reqVO.getYearMonth());
        LocalDate startDate = yearMonth.atDay(1);
        LocalDate endDate = yearMonth.atEndOfMonth();
        // 1. ç¡®å®šéœ€è¦æ ‡è®°çš„员工
        List<HrmEmployeeDO> employees;
        if (reqVO.getEmployeeId() != null) {
            HrmEmployeeDO employee = employeeMapper.selectById(reqVO.getEmployeeId());
            employees = employee == null ? Collections.emptyList() : List.of(employee);
        } else if (reqVO.getDeptId() != null) {
            employees = employeeMapper.selectByDeptId(reqVO.getDeptId());
        } else {
            employees = employeeMapper.selectList();
        }
        if (CollUtil.isEmpty(employees)) {
            return Collections.emptyList();
        }
        // 2. æŸ¥è¯¢å·²å®¡æ‰¹é€šè¿‡çš„请假(按系统用户ID)
        Map<Long, List<HrmEmployeeDO>> employeeByUserId = employees.stream()
                .filter(employee -> employee.getUserId() != null)
                .collect(Collectors.groupingBy(HrmEmployeeDO::getUserId));
        if (employeeByUserId.isEmpty()) {
            return Collections.emptyList();
        }
        List<HrmLeaveApplicationDO> leaves = leaveApplicationMapper.selectApprovedListByUserIdsAndTimeRange(
                employeeByUserId.keySet(), startDate.atStartOfDay(), endDate.atTime(23, 59, 59));
        if (CollUtil.isEmpty(leaves)) {
            return Collections.emptyList();
        }
        // 3. æŒ‰å¤©å±•开为排班页标记
        List<HrmEmployeeScheduleLeaveRespVO> result = new ArrayList<>();
        for (HrmLeaveApplicationDO leave : leaves) {
            List<HrmEmployeeDO> matchedEmployees = employeeByUserId.get(leave.getUserId());
            if (CollUtil.isEmpty(matchedEmployees)) {
                continue;
            }
            LocalDate from = leave.getStartTime().toLocalDate().isBefore(startDate)
                    ? startDate : leave.getStartTime().toLocalDate();
            LocalDate to = leave.getEndTime().toLocalDate().isAfter(endDate)
                    ? endDate : leave.getEndTime().toLocalDate();
            String leaveTypeName = HrmLeaveTypeEnum.getNameByStatus(leave.getLeaveType());
            for (LocalDate date = from; !date.isAfter(to); date = date.plusDays(1)) {
                for (HrmEmployeeDO employee : matchedEmployees) {
                    result.add(new HrmEmployeeScheduleLeaveRespVO(employee.getId(), employee.getName(), date,
                            leave.getLeaveType(), leaveTypeName));
                }
            }
        }
        result.sort((a, b) -> {
            int compare = a.getDate().compareTo(b.getDate());
            return compare != 0 ? compare : a.getEmployeeId().compareTo(b.getEmployeeId());
        });
        return result;
    }
    /**
     * å†™å…¥/覆盖班次;若该员工该日期存在逻辑删除的旧行,则先复活,规避唯一键冲突。
     */
    private void upsertShift(Long employeeId, LocalDate date, Long shiftId, String remark) {
        HrmEmployeeScheduleDO existing = employeeScheduleMapper.selectByEmployeeIdAndDateIncludeDeleted(employeeId, date);
        if (existing == null) {
            HrmEmployeeScheduleDO insert = new HrmEmployeeScheduleDO();
            insert.setEmployeeId(employeeId);
            insert.setDate(date);
            insert.setShiftId(shiftId);
            insert.setRemark(remark);
            employeeScheduleMapper.insert(insert);
            return;
        }
        if (Boolean.TRUE.equals(existing.getDeleted())) {
            employeeScheduleMapper.reviveById(existing.getId(), shiftId, remark, currentUpdater());
            return;
        }
        existing.setShiftId(shiftId);
        existing.setRemark(remark);
        employeeScheduleMapper.updateById(existing);
    }
    /**
     * æ¸…空班次(逻辑删除);返回本次是否真的清掉了一条非空班次。
     */
    private boolean clearShift(Long employeeId, LocalDate date) {
        HrmEmployeeScheduleDO existing = employeeScheduleMapper.selectByEmployeeIdAndDateIncludeDeleted(employeeId, date);
        if (existing == null || Boolean.TRUE.equals(existing.getDeleted()) || existing.getShiftId() == null) {
            return false;
        }
        employeeScheduleMapper.deleteById(existing.getId());
        return true;
    }
    /**
     * æž„造“员工 + æ—¥æœŸâ€çš„已审批请假集合
     */
    private Set<String> buildApprovedLeaveKeys(List<HrmEmployeeDO> employees, LocalDate startDate, LocalDate endDate) {
        Map<Long, List<HrmEmployeeDO>> employeeByUserId = employees.stream()
                .filter(employee -> employee.getUserId() != null)
                .collect(Collectors.groupingBy(HrmEmployeeDO::getUserId));
        if (employeeByUserId.isEmpty()) {
            return Collections.emptySet();
        }
        List<HrmLeaveApplicationDO> leaves = leaveApplicationMapper.selectApprovedListByUserIdsAndTimeRange(
                employeeByUserId.keySet(), startDate.atStartOfDay(), endDate.atTime(23, 59, 59));
        if (CollUtil.isEmpty(leaves)) {
            return Collections.emptySet();
        }
        Set<String> keys = new HashSet<>();
        for (HrmLeaveApplicationDO leave : leaves) {
            List<HrmEmployeeDO> matchedEmployees = employeeByUserId.get(leave.getUserId());
            if (CollUtil.isEmpty(matchedEmployees)) {
                continue;
            }
            LocalDate from = leave.getStartTime().toLocalDate().isBefore(startDate)
                    ? startDate : leave.getStartTime().toLocalDate();
            LocalDate to = leave.getEndTime().toLocalDate().isAfter(endDate)
                    ? endDate : leave.getEndTime().toLocalDate();
            for (LocalDate date = from; !date.isAfter(to); date = date.plusDays(1)) {
                for (HrmEmployeeDO employee : matchedEmployees) {
                    keys.add(buildKey(employee.getId(), date));
                }
            }
        }
        return keys;
    }
    private static String buildKey(Long employeeId, LocalDate date) {
        return employeeId + "_" + date;
    }
    private boolean matchesPost(HrmEmployeeDO employee, Long postId) {
        if (postId == null) {
            return true;
        }
        if (StrUtil.isBlank(employee.getPostIds())) {
            return false;
        }
        try {
            return JSONUtil.toList(employee.getPostIds(), Long.class).contains(postId);
        } catch (Exception e) {
            log.warn("[matchesPost][员工({}) çš„ postIds({}) ä¸æ˜¯åˆæ³• JSON,跳过岗位匹配]", employee.getId(), employee.getPostIds());
            return false;
        }
    }
    private String resolveDeptName(Long deptId) {
        DeptRespDTO dept = deptId == null ? null : deptApi.getDept(deptId);
        return dept == null ? "ID=" + deptId : dept.getName();
    }
    private String resolvePostName(Long postId) {
        if (postId == null) {
            return "不限";
        }
        List<PostRespDTO> posts = postApi.getPostList(Collections.singleton(postId));
        return CollUtil.isEmpty(posts) ? "ID=" + postId : posts.get(0).getName();
    }
    private String resolveStatusName(Integer employeeStatus) {
        if (employeeStatus == null) {
            return "不限";
        }
        for (HrmEmployeeStatusEnum status : HrmEmployeeStatusEnum.values()) {
            if (Objects.equals(status.getStatus(), employeeStatus)) {
                return status.getName();
            }
        }
        return "ID=" + employeeStatus;
    }
    private static String currentUpdater() {
        Long userId = SecurityFrameworkUtils.getLoginUserId();
        return userId == null ? "" : String.valueOf(userId);
    }
}