tuqin
13 小时以前 52feee3e55403d2858da59605173ec055f601bfa
feat(hrm): 同步新疆中亿装配的人力资源模块到北方科技

代码来源:dev_新疆_中亿装配 分支的 yudao-module-hrm / yudao-module-hrm-api,
带来加班工资配置、合同文档生成、员工类别、社保大病/长期护理比例、
考勤规则生效日期区间、APP 打卡状态接口、薪资计算增强等能力。

配套 SQL(必须同步执行,否则 HRM 功能不可用):
- sql/mysql/hrm_sync_from_zyjc_20261024.sql
新建 hrm_overtime_wage_config;hrm_employee 增加 employee_category(默认 1-普通员工);
hrm_attendance_rule 增加生效日期区间;社保方案增加大病医疗/长期护理比例;
放开 hrm_attendance_holiday.start_date/end_date 非空约束
- sql/mysql/hrm_menu_sync_from_zyjc_20261024.sql
补 35 条菜单与按钮权限(HRM 权限点 72 → 106),默认不自动授权给角色

同时修复两个问题:
1) 打卡提示文案失效:EMPLOYEE_NOT_EXISTS 的错误码文案不含 {} 占位符,
原写法 exception(code, 长文案) 被 ServiceExceptionUtil 判为「参数过多」并丢弃
自定义文案,前端只能看到「员工不存在」。改用 ServiceException 直接构造。
2) 节假日配置不可用:北方库的 start_date/end_date 为 NOT NULL,而中亿版 DO 只写
单日 date 字段,新增节假日会抛 Field 'start_date' doesn't have a default value。
通过 DDL 放宽为可空解决。

已知差异(相对北方科技原有实现,本次未保留):
- 操作日志(LogRecordConstants 已无任何引用)
- 全局免审批
- 节假日时间段配置(回退为单日 date)
已修改42个文件
已添加20个文件
2418 ■■■■■ 文件已修改
sql/mysql/hrm_menu_sync_from_zyjc_20261024.sql 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/hrm_sync_from_zyjc_20261024.sql 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/HrmAttendanceClockTypeEnum.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/HrmEmployeeCategoryEnum.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/pom.xml 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/HrmAttendanceRecordController.java 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceHolidayPageReqVO.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceHolidayRespVO.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceHolidaySaveReqVO.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRecordRespVO.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRuleRespVO.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRuleSaveReqVO.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/HrmEmployeeContractController.java 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/HrmEmployeeController.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeImportExcelVO.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeRespVO.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeSaveReqVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeSimpleRespVO.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/HrmOvertimeWageConfigController.java 112 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/HrmSalaryCalculationController.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/HrmSocialSecurityConfigController.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/vo/HrmOvertimeWageConfigRespVO.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/vo/HrmOvertimeWageConfigSaveReqVO.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/vo/HrmSocialSecuritySchemeRespVO.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/vo/HrmSocialSecuritySchemeSaveReqVO.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/attendance/HrmAttendanceHolidayDO.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/attendance/HrmAttendanceRuleDO.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/employee/HrmEmployeeDO.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/salary/HrmOvertimeWageConfigDO.java 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/salary/HrmSocialSecuritySchemeDO.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/attendance/HrmAttendanceHolidayMapper.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/attendance/HrmAttendanceRuleMapper.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/salary/HrmOvertimeWageConfigMapper.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/approval/HrmLeaveApplicationServiceImpl.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/approval/HrmOvertimeApplicationServiceImpl.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/approval/HrmResignationApplicationServiceImpl.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/approval/HrmTransferApplicationServiceImpl.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceExceptionServiceImpl.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceHolidayService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceHolidayServiceImpl.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordService.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordServiceImpl.java 371 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRuleService.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRuleServiceImpl.java 136 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeContractServiceImpl.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeService.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeServiceImpl.java 194 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/contract/ByteArrayMultipartFile.java 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/contract/ContractDocumentService.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/contract/ContractDocumentServiceImpl.java 204 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/salary/HrmOvertimeWageConfigService.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/salary/HrmOvertimeWageConfigServiceImpl.java 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/salary/HrmSalaryCalculationService.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/salary/HrmSalaryCalculationServiceImpl.java 199 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/resources/template/contract/contract_labor_simple.docx 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/resources/template/contract/contract_labor_standard.docx 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/resources/template/contract/contract_service.docx 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/resources/template/contract/original/labor_simple.docx 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/resources/template/contract/original/labor_standard.docx 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/resources/template/contract/original/retire.docx 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/resources/template/contract/original/service.doc 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/hrm_menu_sync_from_zyjc_20261024.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
-- ============================================================
-- ä»Žã€Œæ–°ç–†ä¸­äº¿è£…配」同步 HRM èƒ½åŠ›åˆ°ã€Œæ–°ç–†åŒ—æ–¹ç§‘æŠ€ã€â€”â€” èœå•数据
-- ç”Ÿæˆæ—¥æœŸï¼š2026-10-24
--
-- è¯´æ˜Žï¼š
--   1. å…± 1 ä¸ªèœå•页(加班工资管理)+ 34 ä¸ªæŒ‰é’®æƒé™ç‚¹ï¼Œå…¨éƒ¨ä¸º HRM æ¨¡å—
--   2. èœå• ID ä¸Žä¸­äº¿è£…配保持一致(已确认北方科技库中这些 ID å‡æœªè¢«å ç”¨ï¼‰ï¼Œ
--      ä¾¿äºŽåŽç»­ä¸¤ç«¯å¯¹ç…§ä¸ŽåŒæ­¥
--   3. ä½¿ç”¨ INSERT IGNORE:重复执行不会报主键冲突
--   4. ã€é»˜è®¤ä¸è‡ªåŠ¨æŽˆæƒç»™ä»»ä½•è§’è‰²ã€‘
--      - è¶…级管理员(super_admin)本来就不受角色菜单限制,可直接看到
--      - å…¶ä½™è§’色请在【系统管理 â†’ è§’色管理】中按需勾选,避免把薪酬/考勤
--        ç®¡ç†æƒé™ä¸€æ¬¡æ€§å¼€æ”¾ç»™å…¨éƒ¨æ™®é€šå‘˜å·¥
--   5. è‹¥ç¡®éœ€ç”¨ SQL æ‰¹é‡æŽˆæƒï¼Œå‚考文件末尾的注释模板
-- ============================================================
INSERT IGNORE 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 (6026,'加班工资管理','',2,4,6020,'overtime-wage','ep:clock','hrm/salary/overtime-wage/index','HrmOvertimeWage',0,0x01,0x01,0x01,'1','2026-10-13 17:13:58','1','2026-10-14 10:52:27',0x00),(60261,'加班工资查询','hrm:overtime-wage:query',3,1,6026,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-13 17:13:58','1','2026-10-14 10:52:27',0x00),(60262,'加班工资新增','hrm:overtime-wage:create',3,2,6026,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-13 17:13:58','1','2026-10-14 10:52:28',0x00),(60263,'加班工资修改','hrm:overtime-wage:update',3,3,6026,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-13 17:13:58','1','2026-10-14 10:52:29',0x00),(60264,'加班工资删除','hrm:overtime-wage:delete',3,4,6026,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-13 17:13:58','1','2026-10-14 10:52:31',0x00),(602800,'考勤规则查询','hrm:attendance-rule:query',3,10,6013,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602801,'考勤记录查询','hrm:attendance-record:query',3,10,6011,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602802,'考勤异常查询','hrm:attendance-exception:query',3,10,6012,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602803,'考勤异常修改','hrm:attendance-exception:update',3,11,6012,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602804,'考勤异常删除','hrm:attendance-exception:delete',3,12,6012,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602805,'节假日配置查询','hrm:attendance-holiday:query',3,10,6014,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602806,'考勤统计查询','hrm:attendance-summary:query',3,10,6015,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602807,'请假申请导出','hrm:leave:export',3,10,6003,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602808,'调岗申请查询','hrm:transfer:query',3,10,6004,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602809,'调岗申请导出','hrm:transfer:export',3,11,6004,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602810,'离职申请查询','hrm:resignation:query',3,10,6005,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602811,'离职申请导出','hrm:resignation:export',3,11,6005,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602812,'加班申请查询','hrm:overtime:query',3,10,1065211,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602813,'合同导入','hrm:employee-contract:import',3,10,1063105,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602814,'薪酬核算查询','hrm:salary-calculation:query',3,10,6021,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602815,'薪酬核算删除','hrm:salary-calculation:delete',3,11,6021,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602816,'薪资发放查询','hrm:salary-payment:query',3,10,6022,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602817,'薪资发放生成','hrm:salary-payment:generate',3,11,6022,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602818,'薪资发放删除','hrm:salary-payment:delete',3,12,6022,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602819,'社保档案查询','hrm:employee-social-security:query',3,10,6023,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602820,'社保方案查询','hrm:social-security-scheme:query',3,10,602445,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602821,'社保配置查询','hrm:social-security-config:query',3,30,6020,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602822,'社保配置新增','hrm:social-security-config:create',3,31,6020,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602823,'社保配置修改','hrm:social-security-config:update',3,32,6020,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602824,'社保配置删除','hrm:social-security-config:delete',3,33,6020,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602825,'个税税率查询','hrm:tax-rate-config:query',3,10,6024,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602826,'薪酬结构查询','hrm:salary-structure:query',3,40,6020,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602827,'薪酬结构新增','hrm:salary-structure:create',3,41,6020,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602828,'薪酬结构修改','hrm:salary-structure:update',3,42,6020,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00),(602829,'薪酬结构删除','hrm:salary-structure:delete',3,43,6020,'','','',NULL,0,0x01,0x01,0x01,'1','2026-10-22 18:41:02','1','2026-10-22 18:41:02',0x00);
-- ------------------------------------------------------------
-- å¯é€‰ï¼šæ‰¹é‡æŽˆæƒç»™æŒ‡å®šè§’色(默认注释,按需启用)
-- æ³¨æ„ system_role_menu æœ‰ tenant_id å­—段,必须显式写 1,
-- å¦åˆ™è¯¥è§’色在租户 1 ä¸‹ä»ç„¶æŸ¥ä¸åˆ°è¿™äº›èœå•。
-- ------------------------------------------------------------
-- INSERT IGNORE INTO `system_role_menu` (`role_id`, `menu_id`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `tenant_id`)
-- SELECT 2, m.`id`, '1', NOW(), '1', NOW(), b'0', 1
-- FROM `system_menu` m
-- WHERE m.`id` IN (6026,60261,60262,60263,60264,602800,602801,602802,602803,602804,
--                  602805,602806,602807,602808,602809,602810,602811,602812,602813,
--                  602814,602815,602816,602817,602818,602819,602820,602821,602822,
--                  602823,602824,602825,602826,602827,602828,602829);
sql/mysql/hrm_sync_from_zyjc_20261024.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,70 @@
-- ============================================================
-- ä»Žã€Œæ–°ç–†ä¸­äº¿è£…配」同步 HRM èƒ½åŠ›åˆ°ã€Œæ–°ç–†åŒ—æ–¹ç§‘æŠ€ã€â€”â€” è¡¨ç»“构变更
-- ç”Ÿæˆæ—¥æœŸï¼š2026-10-24
--
-- è¯´æ˜Žï¼š
--   1. æœ¬è„šæœ¬ã€åªåšè¡¨ç»“æž„å˜æ›´ã€‘ï¼Œä¸å«ä»»ä½•ä¸šåŠ¡æ•°æ®
--   2. ä¸šåŠ¡æ•°æ®ï¼ˆå‘˜å·¥ã€éƒ¨é—¨ã€å²—ä½ã€è€ƒå‹¤è§„åˆ™ã€è–ªèµ„ã€ç¤¾ä¿æ–¹æ¡ˆç­‰ï¼‰
--      ä¿ç•™åŒ—方科技自己的,不从其他企业迁移
--   3. é…å¥—代码:yudao-module-hrm / yudao-module-hrm-api ç”±ä¸­äº¿è£…配分支同步
--   4. ã€æ‰§è¡Œä¸€æ¬¡å³å¯ã€‘ALTER TABLE ADD COLUMN ä¸å¹‚等,重复执行会报
--      Duplicate column name,属正常现象;CREATE TABLE å·²ç”¨ IF NOT EXISTS
--   5. å­˜é‡æ•°æ®è‡ªåŠ¨å…¼å®¹ï¼š
--      - employee_category é»˜è®¤ 1(普通员工)
--      - è€ƒå‹¤è§„则 effective_*_date ä¸ºç©ºè¡¨ç¤ºä¸é™ï¼ŒåŽŸè§„åˆ™ç»§ç»­ç”Ÿæ•ˆ
--      - ç¤¾ä¿æ–°æ¯”例字段为空,不影响原有计算
-- ============================================================
-- ------------------------------------------------------------
-- 1. åŠ ç­å·¥èµ„é…ç½®è¡¨ï¼ˆæ–°å¢žåŠŸèƒ½ï¼šè–ªé…¬ â†’ åŠ ç­å·¥èµ„ï¼‰
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `hrm_overtime_wage_config` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '配置ID',
  `post_id` bigint NOT NULL COMMENT '岗位ID,关联 system_post.id',
  `hourly_rate` decimal(10,2) NOT NULL DEFAULT '20.00' COMMENT '加班时薪(元/小时),默认 20',
  `max_overtime_hours` decimal(6,1) DEFAULT NULL COMMENT '每月最大加班时长上限(小时),为空表示不限',
  `status` tinyint NOT NULL DEFAULT '0' COMMENT '状态:0-启用,1-禁用',
  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
  `creator` varchar(64) DEFAULT '' COMMENT '创建者',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `updater` varchar(64) DEFAULT '' COMMENT '更新者',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_post_id` (`post_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='HRM岗位加班工资配置表';
-- ------------------------------------------------------------
-- 2. å‘˜å·¥ç±»åˆ«ï¼ˆé»˜è®¤ 1-普通员工,存量 168 æ¡è‡ªåŠ¨å¡«å……ä¸ºæ™®é€šå‘˜å·¥ï¼‰
-- ------------------------------------------------------------
ALTER TABLE `hrm_employee`
  ADD COLUMN `employee_category` tinyint NOT NULL DEFAULT 1 COMMENT '员工类别:1-普通员工,2-技工,3-管理' AFTER `employee_status`;
-- ------------------------------------------------------------
-- 3. è€ƒå‹¤è§„则生效日期区间(空表示不限,存量规则按原逻辑继续生效)
-- ------------------------------------------------------------
ALTER TABLE `hrm_attendance_rule`
  ADD COLUMN `effective_start_date` date DEFAULT NULL COMMENT '生效开始日期(空表示不限)' AFTER `overtime_threshold`,
  ADD COLUMN `effective_end_date` date DEFAULT NULL COMMENT '生效结束日期(空表示不限)' AFTER `effective_start_date`;
-- ------------------------------------------------------------
-- 4. ç¤¾ä¿å…¬ç§¯é‡‘方案:新增大病医疗、长期护理两项保险比例
-- ------------------------------------------------------------
ALTER TABLE `hrm_social_security_scheme`
  ADD COLUMN `serious_illness_company_ratio` decimal(10,4) DEFAULT NULL COMMENT '大病医疗企业缴纳比例' AFTER `maternity_ratio`,
  ADD COLUMN `serious_illness_personal_ratio` decimal(10,4) DEFAULT NULL COMMENT '大病医疗个人缴纳比例' AFTER `serious_illness_company_ratio`,
  ADD COLUMN `long_term_care_company_ratio` decimal(10,4) DEFAULT NULL COMMENT '长期护理企业缴纳比例' AFTER `serious_illness_personal_ratio`,
  ADD COLUMN `long_term_care_personal_ratio` decimal(10,4) DEFAULT NULL COMMENT '长期护理个人缴纳比例' AFTER `long_term_care_company_ratio`;
-- ------------------------------------------------------------
-- 5. ã€å¿…须】节假日表放开 start_date / end_date éžç©ºçº¦æŸ
--
-- èƒŒæ™¯ï¼šåŒ—方科技原先支持「节假日时间段」(start_date/end_date,NOT NULL),
--       ä¸­äº¿è£…配的 HrmAttendanceHolidayDO åªæœ‰å•æ—¥ date å­—段,不写这两个列。
--       è‹¥ä¿ç•™ NOT NULL,新增/导入节假日会直接抛
--         Field 'start_date' doesn't have a default value  â†’ å‰ç«¯æ˜¾ç¤ºã€Œç³»ç»Ÿå¼‚常」
-- çŽ°æœ‰æ•°æ®ï¼šä¸¤è¾¹ hrm_attendance_holiday å‡ä¸º 0 æ¡ï¼Œæ”¾å¼€çº¦æŸä¸æ¶‰åŠæ•°æ®è¿ç§»
-- ------------------------------------------------------------
ALTER TABLE `hrm_attendance_holiday`
  MODIFY COLUMN `start_date` date DEFAULT NULL COMMENT '开始日期(中亿版代码不写此列,保留兼容北方原区间结构)',
  MODIFY COLUMN `end_date` date DEFAULT NULL COMMENT '结束日期(中亿版代码不写此列,保留兼容北方原区间结构)';
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java
@@ -83,10 +83,9 @@
    ErrorCode ATTENDANCE_EXCEPTION_SUBMIT_FAIL_NOT_DRAFT = new ErrorCode(1_030_004_004, "考勤异常申请提交失败,原因:不是草稿状态");
    ErrorCode ATTENDANCE_RULE_NOT_EXISTS = new ErrorCode(1_030_004_005, "考勤规则不存在");
    ErrorCode ATTENDANCE_RULE_NAME_EXISTS = new ErrorCode(1_030_004_006, "考勤规则名称已存在");
    // å·²åºŸå¼ƒï¼ˆ2026-10-16):同一个部门允许配置多条考勤规则(多班次),不再校验部门唯一,保留该错误码仅为兼容历史引用
    ErrorCode ATTENDANCE_RULE_DEPT_EXISTS = new ErrorCode(1_030_004_007, "该部门已配置考勤规则");
    ErrorCode ATTENDANCE_HOLIDAY_NOT_EXISTS = new ErrorCode(1_030_004_008, "节假日配置不存在(ID={}),可能已被他人删除。请刷新列表后重试");
    ErrorCode ATTENDANCE_HOLIDAY_RANGE_OVERLAP = new ErrorCode(1_030_004_009, "日期区间【{} ~ {}】与已有配置「{}」({} ~ {})重叠,无法保存。请调整本次日期区间避开该配置,或直接编辑/删除已有配置后再保存");
    ErrorCode ATTENDANCE_HOLIDAY_NOT_EXISTS = new ErrorCode(1_030_004_008, "节假日配置不存在");
    ErrorCode ATTENDANCE_HOLIDAY_DATE_EXISTS = new ErrorCode(1_030_004_009, "该日期节假日配置已存在");
    ErrorCode ATTENDANCE_CLOCK_FAIL_NO_RULE = new ErrorCode(1_030_004_010, "打卡失败,未找到考勤规则");
    ErrorCode ATTENDANCE_CLOCK_FAIL_HOLIDAY = new ErrorCode(1_030_004_011, "打卡失败,当日为休息日");
    ErrorCode ATTENDANCE_CLOCK_FAIL_NOT_WORKDAY = new ErrorCode(1_030_004_012, "打卡失败,当日不是工作日");
@@ -94,7 +93,7 @@
    ErrorCode ATTENDANCE_EXCEPTION_UPDATE_AUDIT_STATUS_FAIL = new ErrorCode(1_030_004_014, "更新考勤异常审批状态失败,原因:不是审批中状态");
    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, "结束日期【{}】不能早于开始日期【{}】,无法保存。请重新选择正确的日期区间");
    ErrorCode ATTENDANCE_RULE_EFFECTIVE_DATE_INVALID = new ErrorCode(1_030_004_017, "生效开始日期不能晚于生效结束日期");
    // ========== è–ªé…¬ç®¡ç† 1-030-005-000 ==========
    ErrorCode SALARY_STRUCTURE_NOT_EXISTS = new ErrorCode(1_030_005_000, "薪酬结构不存在");
@@ -125,6 +124,12 @@
    ErrorCode SALARY_PAYMENT_HAS_PAID_DETAIL = new ErrorCode(1_030_005_024, "删除失败,原因:存在已发放明细,请先撤销");
    ErrorCode SALARY_STRUCTURE_NAME_EXISTS = new ErrorCode(1_030_005_025, "薪酬结构名称已存在");
    ErrorCode SALARY_STRUCTURE_IN_USE = new ErrorCode(1_030_005_026, "薪酬结构正在被员工使用,无法删除");
    ErrorCode OVERTIME_WAGE_CONFIG_NOT_EXISTS = new ErrorCode(1_030_005_027, "岗位加班工资配置不存在");
    ErrorCode OVERTIME_WAGE_CONFIG_POST_EXISTS = new ErrorCode(1_030_005_028, "该岗位已配置加班工资,请勿重复添加");
    ErrorCode CONTRACT_TEMPLATE_TYPE_NOT_SUPPORT = new ErrorCode(1_030_005_029, "不支持的合同模板类型");
    ErrorCode CONTRACT_TEMPLATE_LOAD_FAILED = new ErrorCode(1_030_005_030, "合同模板加载失败");
    ErrorCode EMPLOYEE_REGULARIZE_NEED_LABOR_CONTRACT = new ErrorCode(1_030_005_031, "该员工当前合同不是劳动合同,无法转正");
    ErrorCode SALARY_CALCULATION_UPDATE_FAIL = new ErrorCode(1_030_005_032, "修改失败,原因:仅待确认状态可修改,请先撤销确认");
    // ========== ç­æ¬¡ç®¡ç† 1-030-009-000 ==========
    ErrorCode SHIFT_NOT_EXISTS = new ErrorCode(1_030_009_000, "班次不存在");
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/HrmAttendanceClockTypeEnum.java
@@ -23,13 +23,8 @@
    public static final Integer[] ARRAYS = Arrays.stream(values()).map(HrmAttendanceClockTypeEnum::getType).toArray(Integer[]::new);
    @Override
    public Integer[] array() {
        return ARRAYS;
    }
    /**
     * æ ¹æ®ç±»åž‹èŽ·å–åç§°ï¼ŒæœªçŸ¥ç±»åž‹è¿”å›ž null
     * æ ¹æ®ç±»åž‹èŽ·å¾—åç§°ï¼ˆç”¨äºŽç•Œé¢å±•ç¤ºï¼Œå¦‚ my-status è¿”回)
     */
    public static String getNameByType(Integer type) {
        if (type == null) {
@@ -43,4 +38,9 @@
        return null;
    }
    @Override
    public Integer[] array() {
        return ARRAYS;
    }
}
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/HrmEmployeeCategoryEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,40 @@
package cn.iocoder.yudao.module.hrm.enums;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Arrays;
/**
 * å‘˜å·¥ç±»åˆ«æžšä¸¾ï¼šç”¨äºŽåŠ ç­å·¥èµ„çš„å·®å¼‚åŒ–æ ¸ç®—ã€‚
 *
 * <ul>
 *     <li>普通员工:加班工资按岗位配置的固定时薪(默认 20 å…ƒ/小时)核算</li>
 *     <li>技工:加班薪资按 1:1 æ ¸ç®—,与平时时薪一致(基本工资 / 21.75 / 8)</li>
 *     <li>管理:不计加班工资</li>
 * </ul>
 */
@Getter
@RequiredArgsConstructor
public enum HrmEmployeeCategoryEnum {
    NORMAL(1, "普通员工"),
    TECHNICIAN(2, "技工"),
    MANAGER(3, "管理");
    private final Integer category;
    private final String name;
    public static HrmEmployeeCategoryEnum valueOfCategory(Integer category) {
        if (category == null) {
            return null;
        }
        return Arrays.stream(values()).filter(item -> item.category.equals(category)).findFirst().orElse(null);
    }
    public static String getName(Integer category) {
        HrmEmployeeCategoryEnum value = valueOfCategory(category);
        return value == null ? "" : value.name;
    }
}
yudao-module-hrm/pom.xml
@@ -70,6 +70,13 @@
            <artifactId>yudao-spring-boot-starter-excel</artifactId>
        </dependency>
        <!-- Word åˆåŒæ¨¡æ¿æ¸²æŸ“(poi-tl) -->
        <dependency>
            <groupId>com.deepoove</groupId>
            <artifactId>poi-tl</artifactId>
            <version>1.12.2</version>
        </dependency>
        <!-- Test æµ‹è¯•相关 -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/HrmAttendanceRecordController.java
@@ -2,34 +2,31 @@
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
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.HrmAttendanceMyStatusRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRecordImportExcelVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRecordImportReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRecordPageReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRecordRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceMyStatusRespVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceRecordDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO;
import cn.iocoder.yudao.module.hrm.enums.HrmAttendanceClockTypeEnum;
import cn.iocoder.yudao.module.hrm.service.attendance.HrmAttendanceRecordService;
import cn.iocoder.yudao.module.hrm.service.employee.HrmEmployeeService;
import cn.iocoder.yudao.module.system.api.dept.DeptApi;
import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import com.mzt.logapi.starter.annotation.LogRecord;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@@ -43,9 +40,7 @@
import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.EXPORT;
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.LogRecordConstants.*;
@Tag(name = "管理后台 - HRM è€ƒå‹¤è®°å½•")
@RestController
@@ -64,46 +59,16 @@
    @PostMapping("/clock-in")
    @Operation(summary = "上班打卡")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-record:clock')")
    public CommonResult<HrmAttendanceRecordRespVO> clockIn(@RequestParam("location") String location) {
        Long loginUserId = getLoginUserId();
        HrmEmployeeDO employee = employeeService.getEmployeeByUserId(loginUserId);
        if (employee == null) {
            throw exception(EMPLOYEE_NOT_EXISTS,
                    "当前登录账号未关联员工档案,无法打卡。请联系管理员在【员工管理】中将该登录账号绑定到员工。");
            throw new ServiceException(EMPLOYEE_NOT_EXISTS.getCode(),
                    "当前登录账号未关联员工信息,无法打卡。请联系管理员在【员工管理】中将该登录账号绑定到员工。");
        }
        HrmAttendanceRecordDO record = attendanceRecordService.clockIn(employee.getId(), LocalDateTime.now(), location);
        return success(buildClockRespVO(record, employee));
    }
    @PostMapping("/clock-out")
    @Operation(summary = "下班打卡")
    public CommonResult<HrmAttendanceRecordRespVO> clockOut(@RequestParam("location") String location) {
        Long loginUserId = getLoginUserId();
        HrmEmployeeDO employee = employeeService.getEmployeeByUserId(loginUserId);
        if (employee == null) {
            throw exception(EMPLOYEE_NOT_EXISTS,
                    "当前登录账号未关联员工档案,无法打卡。请联系管理员在【员工管理】中将该登录账号绑定到员工。");
        }
        HrmAttendanceRecordDO record = attendanceRecordService.clockOut(employee.getId(), LocalDateTime.now(), location);
        return success(buildClockRespVO(record, employee));
    }
    @GetMapping("/my-status")
    @Operation(summary = "获得当前登录人的考勤状态")
    @Parameter(name = "date", description = "考勤日期,格式 yyyy-MM-dd,默认服务器当天", example = "2026-10-17")
    public CommonResult<HrmAttendanceMyStatusRespVO> getMyStatus(
            @RequestParam(value = "date", required = false)
            @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY) LocalDate date) {
        return success(attendanceRecordService.getMyStatus(getLoginUserId(), date));
    }
    /**
     * æž„建打卡返回 VO,字段与 my-status.record ä¿æŒä¸€è‡´
     */
    private HrmAttendanceRecordRespVO buildClockRespVO(HrmAttendanceRecordDO record, HrmEmployeeDO employee) {
        HrmAttendanceRecordRespVO respVO = BeanUtils.toBean(record, HrmAttendanceRecordRespVO.class);
        respVO.setClockInTypeName(HrmAttendanceClockTypeEnum.getNameByType(record.getClockInType()));
        respVO.setClockOutTypeName(HrmAttendanceClockTypeEnum.getNameByType(record.getClockOutType()));
        respVO.setUserName(employee.getName());
        if (record.getDeptId() != null) {
            DeptRespDTO dept = deptApi.getDept(record.getDeptId());
@@ -111,12 +76,42 @@
                respVO.setDeptName(dept.getName());
            }
        }
        return respVO;
        return success(respVO);
    }
    @PostMapping("/clock-out")
    @Operation(summary = "下班打卡")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-record:clock')")
    public CommonResult<HrmAttendanceRecordRespVO> clockOut(@RequestParam("location") String location) {
        Long loginUserId = getLoginUserId();
        HrmEmployeeDO employee = employeeService.getEmployeeByUserId(loginUserId);
        if (employee == null) {
            throw new ServiceException(EMPLOYEE_NOT_EXISTS.getCode(),
                    "当前登录账号未关联员工信息,无法打卡。请联系管理员在【员工管理】中将该登录账号绑定到员工。");
        }
        HrmAttendanceRecordDO record = attendanceRecordService.clockOut(employee.getId(), LocalDateTime.now(), location);
        HrmAttendanceRecordRespVO respVO = BeanUtils.toBean(record, HrmAttendanceRecordRespVO.class);
        respVO.setUserName(employee.getName());
        if (record.getDeptId() != null) {
            DeptRespDTO dept = deptApi.getDept(record.getDeptId());
            if (dept != null) {
                respVO.setDeptName(dept.getName());
            }
        }
        return success(respVO);
    }
    @GetMapping("/my-status")
    @Operation(summary = "获得当前登录人的考勤状态", description = "App æ‰“卡页使用:返回当日考勤规则、打卡记录与可打卡状态")
    @Parameter(name = "date", description = "考勤日期,格式 yyyy-MM-dd,默认服务器当天", example = "2026-10-17")
    public CommonResult<HrmAttendanceMyStatusRespVO> getMyStatus(
            @RequestParam(value = "date", required = false)
            @org.springframework.format.annotation.DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date) {
        return success(attendanceRecordService.getMyStatus(getLoginUserId(), date));
    }
    private Long getLoginUserId() {
        return cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId();
    }
        return cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId();    }
    @GetMapping("/page")
    @Operation(summary = "获得考勤记录分页")
@@ -222,18 +217,12 @@
    @PostMapping("/import")
    @Operation(summary = "导入考勤记录")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-record:import')")
    @LogRecord(type = HRM_ATTENDANCE_RECORD_TYPE, subType = HRM_ATTENDANCE_RECORD_IMPORT_SUB_TYPE, bizNo = "0",
            success = HRM_ATTENDANCE_RECORD_IMPORT_SUCCESS)
    public CommonResult<String> importExcel(@Valid HrmAttendanceRecordImportReqVO importReqVO) throws IOException {
        List<HrmAttendanceRecordImportExcelVO> list = ExcelUtils.read(importReqVO.getFile(), HrmAttendanceRecordImportExcelVO.class);
        if (CollUtil.isEmpty(list)) {
            // è®°å½•导入结果汇总,供 @LogRecord çš„ success æ–‡æ¡ˆå¼•用
            OperateLogUtils.putImportSummary(0, null, null);
            return success("没有可导入的数据");
        }
        int successCount = 0;
        int createCount = 0; // æ–°å¢žçš„记录数
        int updateCount = 0; // æ›´æ–°çš„记录数(仅勾选「支持更新」时才会走更新)
        Set<String> missingNames = new LinkedHashSet<>();
        Set<String> duplicateNames = new LinkedHashSet<>();
        Set<String> invalidRows = new LinkedHashSet<>();
@@ -279,7 +268,6 @@
                        .location(item.getLocation()).remark(item.getRemark())
                        .dataSource(2).build();
                attendanceRecordService.createImportedRecord(record);
                createCount++;
            } else if (Boolean.TRUE.equals(importReqVO.getUpdateSupport())) {
                record.setClockInTime(item.getClockInTime());
                record.setClockOutTime(item.getClockOutTime());
@@ -288,7 +276,6 @@
                record.setLocation(item.getLocation());
                record.setRemark(item.getRemark());
                attendanceRecordService.updateImportedRecord(record);
                updateCount++;
            }
            successCount++;
        }
@@ -302,9 +289,6 @@
        if (!invalidRows.isEmpty()) {
            msg.append(";以下记录数据不完整,未能导入:").append(String.join("、", invalidRows));
        }
        // è®°å½•导入结果汇总,供 @LogRecord çš„ success æ–‡æ¡ˆå¼•用
        OperateLogUtils.putImportSummary(createCount, updateCount,
                missingNames.size() + duplicateNames.size() + invalidRows.size());
        return success(msg.toString());
    }
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceHolidayPageReqVO.java
@@ -20,10 +20,10 @@
    @Schema(description = "类型:1-休息日,2-工作日", example = "1")
    private Integer type;
    @Schema(description = "是否法定节假日:0-否,1-是", example = "1")
    private Integer isLegal;
    @Schema(description = "开始日期", example = "2024-01-01")
    private LocalDate startDate;
    @Schema(description = "日期区间,传 [开始日期, ç»“束日期],返回与配置时间段有交集的数据", example = "[\"2026-01-01\", \"2026-12-31\"]")
    private LocalDate[] date;
    @Schema(description = "结束日期", example = "2024-12-31")
    private LocalDate endDate;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceHolidayRespVO.java
@@ -23,13 +23,9 @@
    @ExcelProperty("节假日名称")
    private String name;
    @Schema(description = "开始日期")
    @ExcelProperty("开始日期")
    private LocalDate startDate;
    @Schema(description = "结束日期")
    @ExcelProperty("结束日期")
    private LocalDate endDate;
    @Schema(description = "日期")
    @ExcelProperty("日期")
    private LocalDate date;
    @Schema(description = "类型:1-休息日,2-工作日")
    @ExcelProperty(value = "类型", converter = DictConvert.class)
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceHolidaySaveReqVO.java
@@ -18,13 +18,9 @@
    @NotBlank(message = "节假日名称不能为空")
    private String name;
    @Schema(description = "开始日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2024-02-10")
    @NotNull(message = "开始日期不能为空")
    private LocalDate startDate;
    @Schema(description = "结束日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2024-02-17")
    @NotNull(message = "结束日期不能为空")
    private LocalDate endDate;
    @Schema(description = "日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2024-02-10")
    @NotNull(message = "日期不能为空")
    private LocalDate date;
    @Schema(description = "类型:1-休息日,2-工作日", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "类型不能为空")
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRecordRespVO.java
@@ -55,15 +55,15 @@
    @DictFormat("hrm_clock_type")
    private Integer clockInType;
    @Schema(description = "上班打卡类型名称", example = "正常")
    private String clockInTypeName;
    @Schema(description = "下班打卡类型")
    @ExcelProperty(value = "下班打卡类型", converter = DictConvert.class)
    @DictFormat("hrm_clock_type")
    private Integer clockOutType;
    @Schema(description = "下班打卡类型名称", example = "早退")
    @Schema(description = "上班打卡类型名称(正常/迟到/缺卡)")
    private String clockInTypeName;
    @Schema(description = "下班打卡类型名称(正常/早退/缺卡)")
    private String clockOutTypeName;
    @Schema(description = "工作时长(小时)")
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRuleRespVO.java
@@ -8,6 +8,7 @@
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
@@ -51,6 +52,14 @@
    @ExcelProperty("加班起算时长")
    private BigDecimal overtimeThreshold;
    @Schema(description = "生效开始日期(空表示不限)")
    @ExcelProperty("生效开始日期")
    private LocalDate effectiveStartDate;
    @Schema(description = "生效结束日期(空表示不限)")
    @ExcelProperty("生效结束日期")
    private LocalDate effectiveEndDate;
    @Schema(description = "状态:0-启用,1-禁用")
    @ExcelProperty(value = "状态", converter = DictConvert.class)
    @DictFormat("hrm_rule_status")
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRuleSaveReqVO.java
@@ -6,6 +6,7 @@
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalTime;
@Schema(description = "管理后台 - è€ƒå‹¤è§„则新增/修改 Request VO")
@@ -39,6 +40,12 @@
    @Schema(description = "加班起算时长(小时)", example = "1")
    private BigDecimal overtimeThreshold;
    @Schema(description = "生效开始日期(空表示不限)", example = "2026-01-01")
    private LocalDate effectiveStartDate;
    @Schema(description = "生效结束日期(空表示不限)", example = "2026-12-31")
    private LocalDate effectiveEndDate;
    @Schema(description = "状态:0-启用,1-禁用", example = "0")
    private Integer status;
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/HrmEmployeeContractController.java
@@ -11,6 +11,11 @@
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeContractSaveReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeContractTerminateReqVO;
import cn.iocoder.yudao.module.hrm.service.employee.HrmEmployeeContractService;
import cn.iocoder.yudao.module.hrm.service.employee.contract.ContractDocumentService;
import cn.iocoder.yudao.module.hrm.service.employee.contract.ByteArrayMultipartFile;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import cn.iocoder.yudao.module.system.service.storage.SystemStorageBlobService;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -22,12 +27,19 @@
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.hrm.enums.ErrorCodeConstants.CONTRACT_TEMPLATE_LOAD_FAILED;
import static cn.iocoder.yudao.module.hrm.enums.ErrorCodeConstants.CONTRACT_TEMPLATE_TYPE_NOT_SUPPORT;
@Tag(name = "管理后台 - HRM å‘˜å·¥åˆåŒ")
@RestController
@@ -37,6 +49,12 @@
    @Resource
    private HrmEmployeeContractService contractService;
    @Resource
    private ContractDocumentService contractDocumentService;
    @Resource
    private SystemStorageBlobService storageBlobService;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @PostMapping("/create")
    @Operation(summary = "创建员工合同")
@@ -92,6 +110,76 @@
        return success(contractService.getExpiringCount());
    }
    @PostMapping("/generate")
    @Operation(summary = "生成合同并回存为合同附件")
    @Parameter(name = "id", description = "合同ID", required = true)
    @Parameter(name = "templateType", description = "模板类型:1-劳动合同(简版) 2-劳动合同(标准) 3-劳务合同", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:employee-contract:query')")
    public CommonResult<Long> generateContract(@RequestParam("id") Long id,
                                               @RequestParam("templateType") Integer templateType) {
        byte[] bytes = contractDocumentService.generateDocx(id, templateType);
        String fileName = contractDocumentService.buildFileName(id, templateType);
        MultipartFile file = new ByteArrayMultipartFile("file", fileName,
                "application/vnd.openxmlformats-officedocument.wordprocessingml.document", bytes);
        Long blobId = storageBlobService.upload(Collections.singletonList(file), false).get(0).getId();
        storageAttachmentApi.bindAttachments("file", "hrm_employee_contract", id, Collections.singletonList(blobId));
        return success(blobId);
    }
    @GetMapping("/generate-docx")
    @Operation(summary = "生成并下载合同文档(docx)")
    @Parameter(name = "id", description = "合同ID", required = true)
    @Parameter(name = "templateType", description = "模板类型:1-劳动合同(简版) 2-劳动合同(标准) 3-劳务合同", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:employee-contract:query')")
    public void generateDocx(@RequestParam("id") Long id,
                             @RequestParam("templateType") Integer templateType,
                             HttpServletResponse response) throws IOException {
        byte[] bytes = contractDocumentService.generateDocx(id, templateType);
        String fileName = URLEncoder.encode(contractDocumentService.buildFileName(id, templateType), StandardCharsets.UTF_8);
        response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
        response.setHeader("Content-Disposition", "attachment; filename=" + fileName + "; filename*=UTF-8''" + fileName);
        response.getOutputStream().write(bytes);
    }
    @GetMapping("/download-template")
    @Operation(summary = "下载合同空白模板", description = "1-劳动合同(简版) 2-劳动合同(标准) 3-劳务合同 4-退休返聘合同")
    @Parameter(name = "type", description = "模板类型:1-劳动合同(简版) 2-劳动合同(标准) 3-劳务合同 4-退休返聘合同", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:employee-contract:query')")
    public void downloadTemplate(@RequestParam("type") Integer type, HttpServletResponse response) throws IOException {
        String path;
        String fileName;
        String contentType;
        if (type == 1) {
            path = "template/contract/original/labor_simple.docx";
            fileName = "劳动合同(简版).docx";
            contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
        } else if (type == 2) {
            path = "template/contract/original/labor_standard.docx";
            fileName = "劳动合同(标准).docx";
            contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
        } else if (type == 3) {
            path = "template/contract/original/service.doc";
            fileName = "劳务合同.doc";
            contentType = "application/msword";
        } else if (type == 4) {
            path = "template/contract/original/retire.docx";
            fileName = "退休返聘合同.docx";
            contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
        } else {
            throw exception(CONTRACT_TEMPLATE_TYPE_NOT_SUPPORT);
        }
        try (InputStream in = getClass().getClassLoader().getResourceAsStream(path)) {
            if (in == null) {
                throw exception(CONTRACT_TEMPLATE_LOAD_FAILED);
            }
            byte[] bytes = in.readAllBytes();
            String enc = URLEncoder.encode(fileName, StandardCharsets.UTF_8);
            response.setContentType(contentType);
            response.setHeader("Content-Disposition", "attachment; filename=" + enc + "; filename*=UTF-8''" + enc);
            response.getOutputStream().write(bytes);
        }
    }
    @GetMapping("/import-template")
    @Operation(summary = "下载员工合同导入模板")
    @PreAuthorize("@ss.hasPermission('hrm:employee-contract:import')")
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/HrmEmployeeController.java
@@ -205,8 +205,8 @@
        List<HrmEmployeeImportExcelVO> list = Arrays.asList(
                HrmEmployeeImportExcelVO.builder().name("张三").gender(1).phone("13800138000")
                        .deptName("研发部").postName("部门经理,项目经理").roleNames("普通管理员")
                        .hireDate(LocalDate.of(2020, 3, 1)).employeeStatus(1)
                        .idCard("320102199001150011").birthday(LocalDate.of(1990, 1, 15))
                        .hireDate("2020-03-01").employeeStatus(1)
                        .idCard("320102199001150011").birthday("1990-01-15")
                        .nation("汉族").marriageStatus("已婚").age(36).education("本科").politicalStatus("中共党员")
                        .email("zhangsan@example.com").build()
        );
@@ -222,4 +222,22 @@
        return success(employeeService.importEmployeeList(list, importReqVO));
    }
    @PutMapping("/regular")
    @Operation(summary = "员工转正", description = "仅『劳动合同』员工可转正;劳务合同不转正。转正只改状态+转正日期,不含调岗")
    @Parameter(name = "id", description = "员工ID", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:employee:update')")
    public CommonResult<Boolean> regularizeEmployee(@RequestParam("id") Long id) {
        employeeService.regularizeEmployee(id);
        return success(true);
    }
    @GetMapping("/regularize-reminders")
    @Operation(summary = "获得待转正提醒列表", description = "试用中、且当前为劳动合同、试用期在 days å¤©å†…到期的员工")
    @PreAuthorize("@ss.hasPermission('hrm:employee:query')")
    public CommonResult<List<HrmEmployeeRespVO>> getRegularizeReminders(
            @RequestParam(value = "days", required = false, defaultValue = "7") Integer days) {
        List<HrmEmployeeDO> list = employeeService.getRegularizeReminderList(days);
        return success(BeanUtils.toBean(list, HrmEmployeeRespVO.class));
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeImportExcelVO.java
@@ -8,6 +8,7 @@
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
@@ -39,17 +40,21 @@
    private String roleNames;
    @ExcelProperty("入职日期")
    private LocalDate hireDate;
    private String hireDate;
    @ExcelProperty(value = "员工状态", converter = DictConvert.class)
    @DictFormat("hrm_employee_status")
    private Integer employeeStatus;
    @ExcelProperty(value = "员工类别", converter = DictConvert.class)
    @DictFormat("hrm_employee_category")
    private Integer employeeCategory;
    @ExcelProperty("身份证号")
    private String idCard;
    @ExcelProperty("出生日期")
    private LocalDate birthday;
    private String birthday;
    @ExcelProperty("民族")
    private String nation;
@@ -72,4 +77,16 @@
    @ExcelProperty("备注")
    private String remark;
    @ExcelProperty("基本工资")
    private BigDecimal baseSalary;
    @ExcelProperty("开户银行")
    private String bankName;
    @ExcelProperty("银行账号")
    private String bankAccount;
    @ExcelProperty("居住地址")
    private String address;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeRespVO.java
@@ -4,6 +4,7 @@
import cn.idev.excel.annotation.ExcelProperty;
import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat;
import cn.iocoder.yudao.framework.excel.core.convert.DictConvert;
import cn.iocoder.yudao.module.system.api.storage.dto.StorageBlobRespDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -45,7 +46,7 @@
    @Schema(description = "性别")
    @ExcelProperty(value = "性别", converter = DictConvert.class)
    @DictFormat("system_user_sex")
    @DictFormat("hrm_gender")
    private Integer gender;
    @Schema(description = "出生日期")
@@ -53,7 +54,7 @@
    private LocalDate birthday;
    @Schema(description = "手机号码")
    @ExcelProperty("手机号码")
    @ExcelProperty("手机号")
    private String phone;
    @Schema(description = "邮箱")
@@ -101,6 +102,11 @@
    @DictFormat("hrm_employee_status")
    private Integer employeeStatus;
    @Schema(description = "员工类别")
    @ExcelProperty(value = "员工类别", converter = DictConvert.class)
    @DictFormat("hrm_employee_category")
    private Integer employeeCategory;
    @Schema(description = "年假余额(天)")
    @ExcelProperty("年假余额")
    private BigDecimal annualLeaveBalance;
@@ -120,7 +126,7 @@
    private List<Long> roleIds;
    @Schema(description = "角色名称列表")
    @ExcelProperty("角色")
    @ExcelProperty("用户角色")
    private String roleNames;
    @Schema(description = "开户银行")
@@ -143,4 +149,7 @@
    @ExcelProperty("创建时间")
    private LocalDateTime createTime;
    @Schema(description = "附件列表")
    private List<StorageBlobRespDTO> attachmentList;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeSaveReqVO.java
@@ -78,6 +78,9 @@
    @NotNull(message = "员工状态不能为空")
    private Integer employeeStatus;
    @Schema(description = "员工类别:1-普通员工,2-技工,3-管理", example = "1")
    private Integer employeeCategory;
    @Schema(description = "年假余额(天)", example = "5")
    private BigDecimal annualLeaveBalance;
@@ -119,4 +122,7 @@
    @Valid
    private List<HrmEmployeeEmergencyContactSaveReqVO> emergencyContacts;
    @Schema(description = "附件 blobId åˆ—表", example = "[1, 2, 3]")
    private List<Long> blobIds;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeSimpleRespVO.java
@@ -3,6 +3,8 @@
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
@Schema(description = "管理后台 - å‘˜å·¥ç®€å•信息 Response VO")
@Data
public class HrmEmployeeSimpleRespVO {
@@ -16,9 +18,18 @@
    @Schema(description = "员工姓名")
    private String name;
    @Schema(description = "部门ID")
    private Long deptId;
    @Schema(description = "部门名称")
    private String deptName;
    @Schema(description = "岗位ID列表")
    private List<Long> postIds;
    @Schema(description = "岗位名称(多个用逗号分隔)")
    private String postName;
    @Schema(description = "员工状态")
    private Integer employeeStatus;
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/HrmOvertimeWageConfigController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,112 @@
package cn.iocoder.yudao.module.hrm.controller.admin.salary;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.hrm.controller.admin.salary.vo.HrmOvertimeWageConfigRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.salary.vo.HrmOvertimeWageConfigSaveReqVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.salary.HrmOvertimeWageConfigDO;
import cn.iocoder.yudao.module.hrm.service.salary.HrmOvertimeWageConfigService;
import cn.iocoder.yudao.module.system.api.dept.PostApi;
import cn.iocoder.yudao.module.system.api.dept.dto.PostRespDTO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@Tag(name = "管理后台 - HRM åŠ ç­å·¥èµ„ç®¡ç†")
@RestController
@RequestMapping("/hrm/salary/overtime-wage")
@Validated
public class HrmOvertimeWageConfigController {
    @Resource
    private HrmOvertimeWageConfigService overtimeWageConfigService;
    @Resource
    private PostApi postApi;
    @PostMapping("/create")
    @Operation(summary = "创建岗位加班工资配置")
    @PreAuthorize("@ss.hasPermission('hrm:overtime-wage:create')")
    public CommonResult<Long> createOvertimeWageConfig(@Valid @RequestBody HrmOvertimeWageConfigSaveReqVO reqVO) {
        return success(overtimeWageConfigService.createOvertimeWageConfig(
                BeanUtils.toBean(reqVO, HrmOvertimeWageConfigDO.class)));
    }
    @PutMapping("/update")
    @Operation(summary = "更新岗位加班工资配置")
    @PreAuthorize("@ss.hasPermission('hrm:overtime-wage:update')")
    public CommonResult<Boolean> updateOvertimeWageConfig(@Valid @RequestBody HrmOvertimeWageConfigSaveReqVO reqVO) {
        overtimeWageConfigService.updateOvertimeWageConfig(BeanUtils.toBean(reqVO, HrmOvertimeWageConfigDO.class));
        return success(true);
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除岗位加班工资配置")
    @Parameter(name = "id", description = "配置编号", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:overtime-wage:delete')")
    public CommonResult<Boolean> deleteOvertimeWageConfig(@RequestParam("id") Long id) {
        overtimeWageConfigService.deleteOvertimeWageConfig(id);
        return success(true);
    }
    @GetMapping("/get")
    @Operation(summary = "获得岗位加班工资配置")
    @Parameter(name = "id", description = "配置编号", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:overtime-wage:query')")
    public CommonResult<HrmOvertimeWageConfigRespVO> getOvertimeWageConfig(@RequestParam("id") Long id) {
        return success(convert(overtimeWageConfigService.getOvertimeWageConfig(id)));
    }
    @GetMapping("/list")
    @Operation(summary = "获得岗位加班工资配置列表")
    @PreAuthorize("@ss.hasPermission('hrm:overtime-wage:query')")
    public CommonResult<List<HrmOvertimeWageConfigRespVO>> getOvertimeWageConfigList(
            @RequestParam(value = "postId", required = false) Long postId,
            @RequestParam(value = "status", required = false) Integer status) {
        return success(convertList(overtimeWageConfigService.getOvertimeWageConfigList(postId, status)));
    }
    private HrmOvertimeWageConfigRespVO convert(HrmOvertimeWageConfigDO config) {
        if (config == null) {
            return null;
        }
        return convertList(Collections.singletonList(config)).get(0);
    }
    private List<HrmOvertimeWageConfigRespVO> convertList(List<HrmOvertimeWageConfigDO> list) {
        List<HrmOvertimeWageConfigRespVO> voList = BeanUtils.toBean(list, HrmOvertimeWageConfigRespVO.class);
        List<Long> postIds = voList.stream().map(HrmOvertimeWageConfigRespVO::getPostId)
                .filter(Objects::nonNull).distinct().collect(Collectors.toList());
        if (CollUtil.isNotEmpty(postIds)) {
            Map<Long, PostRespDTO> postMap = postApi.getPostMap(postIds);
            voList.forEach(vo -> {
                PostRespDTO post = postMap.get(vo.getPostId());
                if (post != null) {
                    vo.setPostName(post.getName());
                }
            });
        }
        return voList;
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/HrmSalaryCalculationController.java
@@ -200,7 +200,7 @@
    @PostMapping("/save")
    @Operation(summary = "批量保存薪酬核算记录")
    @PreAuthorize("@ss.hasPermission('hrm:salary-calculation:save')")
    @PreAuthorize("@ss.hasPermission('hrm:salary-calculation:calculate')")
    public CommonResult<Boolean> saveSalaryCalculation(@Valid @RequestBody HrmSalaryCalculationSaveReqVO saveReqVO) {
        // è½¬æ¢ä¸º DO åˆ—表
        List<HrmSalaryCalculationDO> calculationList = saveReqVO.getList().stream()
@@ -215,4 +215,13 @@
        return success(true);
    }
    @PutMapping("/update")
    @Operation(summary = "修改薪酬核算记录", description = "仅待确认状态可修改;实发工资由后端按收入/扣除项重算")
    @PreAuthorize("@ss.hasPermission('hrm:salary-calculation:calculate')")
    public CommonResult<Boolean> updateSalaryCalculation(@Valid @RequestBody HrmSalaryCalculationRespVO updateReqVO) {
        HrmSalaryCalculationDO calculation = BeanUtils.toBean(updateReqVO, HrmSalaryCalculationDO.class);
        salaryCalculationService.updateSalaryCalculation(calculation);
        return success(true);
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/HrmSocialSecurityConfigController.java
@@ -31,7 +31,7 @@
    @PostMapping("/create")
    @Operation(summary = "创建社保公积金配置")
    @PreAuthorize("@ss.hasPermission('hrm:social-security:create')")
    @PreAuthorize("@ss.hasPermission('hrm:social-security-config:create')")
    public CommonResult<Long> createSocialSecurityConfig(@Valid @RequestBody HrmSocialSecurityConfigSaveReqVO createReqVO) {
        HrmSocialSecurityConfigDO config = BeanUtils.toBean(createReqVO, HrmSocialSecurityConfigDO.class);
        return success(socialSecurityConfigService.createSocialSecurityConfig(config));
@@ -39,7 +39,7 @@
    @PutMapping("/update")
    @Operation(summary = "更新社保公积金配置")
    @PreAuthorize("@ss.hasPermission('hrm:social-security:update')")
    @PreAuthorize("@ss.hasPermission('hrm:social-security-config:update')")
    public CommonResult<Boolean> updateSocialSecurityConfig(@Valid @RequestBody HrmSocialSecurityConfigSaveReqVO updateReqVO) {
        HrmSocialSecurityConfigDO config = BeanUtils.toBean(updateReqVO, HrmSocialSecurityConfigDO.class);
        socialSecurityConfigService.updateSocialSecurityConfig(config);
@@ -49,7 +49,7 @@
    @DeleteMapping("/delete")
    @Operation(summary = "删除社保公积金配置")
    @Parameter(name = "id", description = "配置ID", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:social-security:delete')")
    @PreAuthorize("@ss.hasPermission('hrm:social-security-config:delete')")
    public CommonResult<Boolean> deleteSocialSecurityConfig(@RequestParam("id") Long id) {
        socialSecurityConfigService.deleteSocialSecurityConfig(id);
        return success(true);
@@ -58,7 +58,7 @@
    @GetMapping("/get")
    @Operation(summary = "获取社保公积金配置")
    @Parameter(name = "id", description = "配置ID", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:social-security:query')")
    @PreAuthorize("@ss.hasPermission('hrm:social-security-config:query')")
    public CommonResult<HrmSocialSecurityConfigRespVO> getSocialSecurityConfig(@RequestParam("id") Long id) {
        HrmSocialSecurityConfigDO config = socialSecurityConfigService.getSocialSecurityConfig(id);
        return success(BeanUtils.toBean(config, HrmSocialSecurityConfigRespVO.class));
@@ -66,7 +66,7 @@
    @GetMapping("/list")
    @Operation(summary = "获取社保公积金配置列表")
    @PreAuthorize("@ss.hasPermission('hrm:social-security:query')")
    @PreAuthorize("@ss.hasPermission('hrm:social-security-config:query')")
    public CommonResult<List<HrmSocialSecurityConfigRespVO>> getSocialSecurityConfigList() {
        List<HrmSocialSecurityConfigDO> list = socialSecurityConfigService.getSocialSecurityConfigList();
        return success(BeanUtils.toBean(list, HrmSocialSecurityConfigRespVO.class));
@@ -75,7 +75,7 @@
    @GetMapping("/get-effective")
    @Operation(summary = "获取生效的社保公积金配置")
    @Parameter(name = "effectiveDate", description = "生效日期", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:social-security:query')")
    @PreAuthorize("@ss.hasPermission('hrm:social-security-config:query')")
    public CommonResult<HrmSocialSecurityConfigRespVO> getEffectiveSocialSecurityConfig(
            @RequestParam("effectiveDate") LocalDate effectiveDate) {
        HrmSocialSecurityConfigDO config = socialSecurityConfigService.getEffectiveSocialSecurityConfig(effectiveDate);
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/vo/HrmOvertimeWageConfigRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,37 @@
package cn.iocoder.yudao.module.hrm.controller.admin.salary.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - å²—位加班工资配置 Response VO")
@Data
public class HrmOvertimeWageConfigRespVO {
    @Schema(description = "配置编号")
    private Long id;
    @Schema(description = "岗位ID")
    private Long postId;
    @Schema(description = "岗位名称")
    private String postName;
    @Schema(description = "加班时薪(元/小时)")
    private BigDecimal hourlyRate;
    @Schema(description = "每月最大加班时长上限(小时),为空表示不限")
    private BigDecimal maxOvertimeHours;
    @Schema(description = "状态:0-启用,1-禁用")
    private Integer status;
    @Schema(description = "备注")
    private String remark;
    @Schema(description = "创建时间")
    private LocalDateTime createTime;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/vo/HrmOvertimeWageConfigSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,39 @@
package cn.iocoder.yudao.module.hrm.controller.admin.salary.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.math.BigDecimal;
@Schema(description = "管理后台 - å²—位加班工资配置新增/修改 Request VO")
@Data
public class HrmOvertimeWageConfigSaveReqVO {
    @Schema(description = "配置编号", example = "1")
    private Long id;
    @Schema(description = "岗位ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "岗位不能为空")
    private Long postId;
    @Schema(description = "加班时薪(元/小时),默认 20", requiredMode = Schema.RequiredMode.REQUIRED, example = "20.00")
    @NotNull(message = "加班时薪不能为空")
    @DecimalMin(value = "0", message = "加班时薪不能小于 0")
    private BigDecimal hourlyRate;
    @Schema(description = "每月最大加班时长上限(小时),为空表示不限", example = "36.0")
    @DecimalMin(value = "0", message = "最大加班时长不能小于 0")
    private BigDecimal maxOvertimeHours;
    @Schema(description = "状态:0-启用,1-禁用", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
    @NotNull(message = "状态不能为空")
    private Integer status;
    @Schema(description = "备注")
    @Size(max = 500, message = "备注不能超过 500 ä¸ªå­—符")
    private String remark;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/vo/HrmSocialSecuritySchemeRespVO.java
@@ -49,6 +49,20 @@
    @Schema(description = "生育保险企业缴纳比例", example = "0.005")
    private BigDecimal maternityRatio;
    // ========== å¤§ç—…医疗比例 ==========
    @Schema(description = "大病医疗企业缴纳比例", example = "0.005")
    private BigDecimal seriousIllnessCompanyRatio;
    @Schema(description = "大病医疗个人缴纳比例", example = "0.005")
    private BigDecimal seriousIllnessPersonalRatio;
    // ========== é•¿æœŸæŠ¤ç†æ¯”例 ==========
    @Schema(description = "长期护理企业缴纳比例", example = "0.003")
    private BigDecimal longTermCareCompanyRatio;
    @Schema(description = "长期护理个人缴纳比例", example = "0.003")
    private BigDecimal longTermCarePersonalRatio;
    // ========== å…¬ç§¯é‡‘比例 ==========
    @Schema(description = "公积金企业缴纳比例", example = "0.12")
    private BigDecimal housingFundCompanyRatio;
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/vo/HrmSocialSecuritySchemeSaveReqVO.java
@@ -52,6 +52,20 @@
    @Schema(description = "生育保险企业缴纳比例", example = "0.005")
    private BigDecimal maternityRatio;
    // ========== å¤§ç—…医疗比例 ==========
    @Schema(description = "大病医疗企业缴纳比例", example = "0.005")
    private BigDecimal seriousIllnessCompanyRatio;
    @Schema(description = "大病医疗个人缴纳比例", example = "0.005")
    private BigDecimal seriousIllnessPersonalRatio;
    // ========== é•¿æœŸæŠ¤ç†æ¯”例 ==========
    @Schema(description = "长期护理企业缴纳比例", example = "0.003")
    private BigDecimal longTermCareCompanyRatio;
    @Schema(description = "长期护理个人缴纳比例", example = "0.003")
    private BigDecimal longTermCarePersonalRatio;
    // ========== å…¬ç§¯é‡‘比例 ==========
    @Schema(description = "公积金企业缴纳比例", example = "0.12")
    private BigDecimal housingFundCompanyRatio;
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/attendance/HrmAttendanceHolidayDO.java
@@ -35,19 +35,9 @@
    private String name;
    /**
     * æ—¥æœŸï¼ˆå…¼å®¹å­˜é‡ NOT NULL åˆ—的冗余字段,始终写入 startDate)
     * æ—¥æœŸ
     */
    private LocalDate date;
    /**
     * å¼€å§‹æ—¥æœŸ
     */
    private LocalDate startDate;
    /**
     * ç»“束日期
     */
    private LocalDate endDate;
    /**
     * ç±»åž‹ï¼š1-休息日,2-工作日
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/attendance/HrmAttendanceRuleDO.java
@@ -68,6 +68,16 @@
    private BigDecimal overtimeThreshold;
    /**
     * ç”Ÿæ•ˆå¼€å§‹æ—¥æœŸï¼ˆç©ºè¡¨ç¤ºä¸é™ï¼‰
     */
    private LocalDate effectiveStartDate;
    /**
     * ç”Ÿæ•ˆç»“束日期(空表示不限)
     */
    private LocalDate effectiveEndDate;
    /**
     * çŠ¶æ€ï¼š0-启用,1-禁用
     */
    private Integer status;
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/employee/HrmEmployeeDO.java
@@ -136,6 +136,13 @@
    private Integer employeeStatus;
    /**
     * å‘˜å·¥ç±»åˆ«
     *
     * 1-普通员工,2-技工,3-管理;用于加班工资差异化核算
     */
    private Integer employeeCategory;
    /**
     * å¹´å‡ä½™é¢ï¼ˆå¤©ï¼‰
     */
    private BigDecimal annualLeaveBalance;
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/salary/HrmOvertimeWageConfigDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,64 @@
package cn.iocoder.yudao.module.hrm.dal.dataobject.salary;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.math.BigDecimal;
/**
 * å²—位加班工资配置 DO
 *
 * <p>用于「加班工资管理」:不同岗位(工种)配置不同的加班时薪与最大加班时长上限。
 * æ ¸ç®—时普通员工按此配置的固定时薪计加班工资。
 *
 * @author è¶…级管理员
 */
@TableName("hrm_overtime_wage_config")
@KeySequence("hrm_overtime_wage_config_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class HrmOvertimeWageConfigDO extends BaseDO {
    /**
     * é…ç½®ID
     */
    @TableId
    private Long id;
    /**
     * å²—位ID
     *
     * å…³è” PostDO#getId()
     */
    private Long postId;
    /**
     * åŠ ç­æ—¶è–ªï¼ˆå…ƒ/小时),默认 20
     */
    private BigDecimal hourlyRate;
    /**
     * æ¯æœˆæœ€å¤§åŠ ç­æ—¶é•¿ä¸Šé™ï¼ˆå°æ—¶ï¼‰
     *
     * ä¸ºç©ºè¡¨ç¤ºä¸é™åˆ¶
     */
    private BigDecimal maxOvertimeHours;
    /**
     * çŠ¶æ€ï¼š0-启用,1-禁用
     */
    private Integer status;
    /**
     * å¤‡æ³¨
     */
    private String remark;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/salary/HrmSocialSecuritySchemeDO.java
@@ -84,6 +84,26 @@
     */
    private BigDecimal maternityRatio;
    // ========== å¤§ç—…医疗比例 ==========
    /**
     * å¤§ç—…医疗企业缴纳比例
     */
    private BigDecimal seriousIllnessCompanyRatio;
    /**
     * å¤§ç—…医疗个人缴纳比例
     */
    private BigDecimal seriousIllnessPersonalRatio;
    // ========== é•¿æœŸæŠ¤ç†æ¯”例 ==========
    /**
     * é•¿æœŸæŠ¤ç†ä¼ä¸šç¼´çº³æ¯”例
     */
    private BigDecimal longTermCareCompanyRatio;
    /**
     * é•¿æœŸæŠ¤ç†ä¸ªäººç¼´çº³æ¯”例
     */
    private BigDecimal longTermCarePersonalRatio;
    // ========== å…¬ç§¯é‡‘比例 ==========
    /**
     * å…¬ç§¯é‡‘企业缴纳比例
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/attendance/HrmAttendanceHolidayMapper.java
@@ -19,30 +19,22 @@
public interface HrmAttendanceHolidayMapper extends BaseMapperX<HrmAttendanceHolidayDO> {
    default PageResult<HrmAttendanceHolidayDO> selectPage(HrmAttendanceHolidayPageReqVO reqVO) {
        LambdaQueryWrapperX<HrmAttendanceHolidayDO> query = new LambdaQueryWrapperX<HrmAttendanceHolidayDO>()
        return selectPage(reqVO, new LambdaQueryWrapperX<HrmAttendanceHolidayDO>()
                .likeIfPresent(HrmAttendanceHolidayDO::getName, reqVO.getName())
                .eqIfPresent(HrmAttendanceHolidayDO::getType, reqVO.getType())
                .eqIfPresent(HrmAttendanceHolidayDO::getIsLegal, reqVO.getIsLegal());
        // æ—¥æœŸç­›é€‰ï¼šé…ç½®æ—¶é—´æ®µä¸ŽæŸ¥è¯¢åŒºé—´æœ‰äº¤é›†å³å‘½ä¸­
        LocalDate[] dateRange = reqVO.getDate();
        if (dateRange != null && dateRange.length >= 2 && dateRange[0] != null && dateRange[1] != null) {
            query.le(HrmAttendanceHolidayDO::getStartDate, dateRange[1])
                    .ge(HrmAttendanceHolidayDO::getEndDate, dateRange[0]);
        }
        return selectPage(reqVO, query.orderByDesc(HrmAttendanceHolidayDO::getStartDate)
                .orderByDesc(HrmAttendanceHolidayDO::getId));
                .betweenIfPresent(HrmAttendanceHolidayDO::getDate, reqVO.getStartDate(), reqVO.getEndDate())
                .orderByDesc(HrmAttendanceHolidayDO::getDate));
    }
    /**
     * æŸ¥è¯¢æ—¶é—´æ®µä¸Ž [startDate, endDate] æœ‰äº¤é›†çš„配置
     * ä¼ å…¥åŒä¸€å¤©ï¼ˆdate, date)时,即查询覆盖该日的配置
     */
    default List<HrmAttendanceHolidayDO> selectListByDateRange(LocalDate startDate, LocalDate endDate) {
    default HrmAttendanceHolidayDO selectByDate(LocalDate date) {
        return selectOne(HrmAttendanceHolidayDO::getDate, date);
    }
    default List<HrmAttendanceHolidayDO> selectListByDateBetween(LocalDate startDate, LocalDate endDate) {
        return selectList(new LambdaQueryWrapperX<HrmAttendanceHolidayDO>()
                .le(HrmAttendanceHolidayDO::getStartDate, endDate)
                .ge(HrmAttendanceHolidayDO::getEndDate, startDate)
                .orderByAsc(HrmAttendanceHolidayDO::getStartDate)
                .orderByAsc(HrmAttendanceHolidayDO::getId));
                .ge(HrmAttendanceHolidayDO::getDate, startDate)
                .le(HrmAttendanceHolidayDO::getDate, endDate)
                .orderByAsc(HrmAttendanceHolidayDO::getDate));
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/attendance/HrmAttendanceRuleMapper.java
@@ -37,16 +37,4 @@
                .orderByAsc(HrmAttendanceRuleDO::getName));
    }
    /**
     * æŸ¥è¯¢éƒ¨é—¨ä¸‹å·²å¯ç”¨çš„规则,最新创建的排在前面
     *
     * åŒä¸€éƒ¨é—¨å¯é…ç½®å¤šæ¡å¯ç”¨è§„则(多班次),由调用方按打卡时间匹配班次
     */
    default List<HrmAttendanceRuleDO> selectEnabledListByDeptId(Long deptId) {
        return selectList(new LambdaQueryWrapperX<HrmAttendanceRuleDO>()
                .eq(HrmAttendanceRuleDO::getDeptId, deptId)
                .eq(HrmAttendanceRuleDO::getStatus, 0)
                .orderByDesc(HrmAttendanceRuleDO::getId));
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/salary/HrmOvertimeWageConfigMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,39 @@
package cn.iocoder.yudao.module.hrm.dal.mysql.salary;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.hrm.dal.dataobject.salary.HrmOvertimeWageConfigDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.Collection;
import java.util.List;
/**
 * å²—位加班工资配置 Mapper
 *
 * @author è¶…级管理员
 */
@Mapper
public interface HrmOvertimeWageConfigMapper extends BaseMapperX<HrmOvertimeWageConfigDO> {
    default List<HrmOvertimeWageConfigDO> selectListByCondition(Long postId, Integer status) {
        return selectList(new LambdaQueryWrapperX<HrmOvertimeWageConfigDO>()
                .eqIfPresent(HrmOvertimeWageConfigDO::getPostId, postId)
                .eqIfPresent(HrmOvertimeWageConfigDO::getStatus, status)
                .orderByDesc(HrmOvertimeWageConfigDO::getId));
    }
    default HrmOvertimeWageConfigDO selectByPostId(Long postId) {
        return selectOne(HrmOvertimeWageConfigDO::getPostId, postId);
    }
    /**
     * æŸ¥è¯¢å¤šä¸ªå²—位下「启用」的加班工资配置,用于薪酬核算时按员工岗位匹配。
     */
    default List<HrmOvertimeWageConfigDO> selectEnabledListByPostIds(Collection<Long> postIds) {
        return selectList(new LambdaQueryWrapperX<HrmOvertimeWageConfigDO>()
                .inIfPresent(HrmOvertimeWageConfigDO::getPostId, postIds)
                .eq(HrmOvertimeWageConfigDO::getStatus, 0));
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/approval/HrmLeaveApplicationServiceImpl.java
@@ -223,17 +223,6 @@
        }
        // 2. æ ¡éªŒåˆ†ç±»å’Œæµç¨‹å®šä¹‰æ˜¯å¦å­˜åœ¨
        // å…¨å±€å…å®¡æ‰¹ï¼šä¸šåŠ¡æ ¡éªŒé€šè¿‡åŽç›´æŽ¥æ‰§è¡Œå®¡æ‰¹é€šè¿‡é€»è¾‘ï¼Œæ— éœ€æµç¨‹æ¨¡åž‹ã€‚
        if (bpmProcessInstanceApi.isGlobalAutoApprove()) {
            leaveApplicationMapper.updateById(HrmLeaveApplicationDO.builder()
                    .id(id)
                    .processInstanceId("")
                    .status(HrmAuditStatusEnum.PROCESS.getStatus())
                    .build());
            updateLeaveApplicationAuditStatus(id, HrmAuditStatusEnum.APPROVE.getStatus());
            return;
        }
        validateApproveCategoryAndProcessDefinition();
        // 3. æ ¡éªŒä¼ å…¥çš„æµç¨‹å®šä¹‰ Key æ˜¯å¦å±žäºŽè¯¥åˆ†ç±»ä¸”处于激活状态
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/approval/HrmOvertimeApplicationServiceImpl.java
@@ -117,14 +117,6 @@
    @Transactional(rollbackFor = Exception.class)
    public void submit(Long id, String processDefinitionKey, Long loginUserId) {
        HrmOvertimeApplicationDO application = requireOwnedDraft(id, loginUserId);
        // å…¨å±€å…å®¡æ‰¹ï¼šä¸šåŠ¡æ ¡éªŒé€šè¿‡åŽç›´æŽ¥æ‰§è¡Œå®¡æ‰¹é€šè¿‡é€»è¾‘ï¼Œæ— éœ€æµç¨‹æ¨¡åž‹ã€‚
        if (bpmProcessInstanceApi.isGlobalAutoApprove()) {
            mapper.updateById(HrmOvertimeApplicationDO.builder().id(id).processInstanceId("")
                    .status(HrmAuditStatusEnum.PROCESS.getStatus()).build());
            updateAuditStatus(id, HrmAuditStatusEnum.APPROVE.getStatus());
            return;
        }
        validateProcessDefinitions();
        ProcessDefinition definition = bpmProcessDefinitionService.getActiveProcessDefinition(processDefinitionKey);
        if (definition == null || !BPM_PROCESS_DEFINITION_KEY.equals(definition.getKey())) {
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/approval/HrmResignationApplicationServiceImpl.java
@@ -244,17 +244,6 @@
        if (ObjUtil.notEqual(application.getStatus(), HrmAuditStatusEnum.DRAFT.getStatus())) {
            throw exception(RESIGNATION_APPLICATION_SUBMIT_FAIL_NOT_DRAFT);
        }
        // å…¨å±€å…å®¡æ‰¹ï¼šä¸šåŠ¡æ ¡éªŒé€šè¿‡åŽç›´æŽ¥æ‰§è¡Œå®¡æ‰¹é€šè¿‡é€»è¾‘ï¼Œæ— éœ€æµç¨‹æ¨¡åž‹ã€‚
        if (bpmProcessInstanceApi.isGlobalAutoApprove()) {
            resignationApplicationMapper.updateById(HrmResignationApplicationDO.builder()
                    .id(id)
                    .processInstanceId("")
                    .status(HrmAuditStatusEnum.PROCESS.getStatus())
                    .build());
            updateResignationApplicationAuditStatus(id, HrmAuditStatusEnum.APPROVE.getStatus());
            return;
        }
        validateApproveCategoryAndProcessDefinition();
        ProcessDefinition processDefinition = bpmProcessDefinitionService.getActiveProcessDefinition(processDefinitionKey);
        if (processDefinition == null) {
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/approval/HrmTransferApplicationServiceImpl.java
@@ -13,7 +13,9 @@
import cn.iocoder.yudao.module.hrm.controller.admin.approval.vo.HrmTransferApplicationRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.approval.vo.HrmTransferApplicationSaveReqVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.approval.HrmTransferApplicationDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO;
import cn.iocoder.yudao.module.hrm.dal.mysql.approval.HrmTransferApplicationMapper;
import cn.iocoder.yudao.module.hrm.service.employee.HrmEmployeeService;
import cn.iocoder.yudao.module.hrm.dal.redis.HrmNoRedisDAO;
import cn.iocoder.yudao.module.hrm.enums.HrmAuditStatusEnum;
import cn.iocoder.yudao.module.hrm.enums.HrmTransferTypeEnum;
@@ -66,6 +68,8 @@
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private HrmEmployeeService employeeService;
    @Resource
    private BpmProcessInstanceApi bpmProcessInstanceApi;
    @Resource
    private BpmCategoryService bpmCategoryService;
@@ -87,14 +91,14 @@
        }
        // 3. ç”Ÿæˆç”³è¯·å•号
        String no = generateUniqueNo();
        // 4. è®¾ç½®ç”³è¯·äººä¿¡æ¯
        // 4. è®¾ç½®è°ƒå²—人(员工ID):为空时取当前登录用户对应的员工
        Long userId = createReqVO.getUserId();
        if (userId == null) {
            userId = getCurrentUserId();
            HrmEmployeeDO currentEmployee = employeeService.getEmployeeByUserId(getCurrentUserId());
            userId = currentEmployee != null ? currentEmployee.getId() : null;
        }
        AdminUserRespDTO user = adminUserApi.getUser(userId);
        if (user == null) {
            userId = getCurrentUserId();
        if (userId != null && employeeService.getEmployee(userId) == null) {
            throw exception(EMPLOYEE_NOT_EXISTS);
        }
        // 5. æ’å…¥
        HrmTransferApplicationDO application = BeanUtils.toBean(createReqVO, HrmTransferApplicationDO.class);
@@ -168,9 +172,9 @@
        }
        HrmTransferApplicationRespVO respVO = BeanUtils.toBean(application, HrmTransferApplicationRespVO.class);
        if (application.getUserId() != null) {
            AdminUserRespDTO user = adminUserApi.getUser(application.getUserId());
            if (user != null) {
                respVO.setUserName(user.getNickname());
            HrmEmployeeDO employee = employeeService.getEmployee(application.getUserId());
            if (employee != null) {
                respVO.setUserName(employee.getName());
            }
        }
        fillDeptNames(respVO, application);
@@ -215,7 +219,14 @@
                .map(HrmTransferApplicationDO::getUserId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(userIds);
        // userId å­˜çš„æ˜¯å‘˜å·¥ID,按员工表查询姓名
        Map<Long, String> employeeNameMap = new HashMap<>();
        for (Long userId : userIds) {
            HrmEmployeeDO employee = employeeService.getEmployee(userId);
            if (employee != null) {
                employeeNameMap.put(userId, employee.getName());
            }
        }
        Set<Long> deptIds = pageResult.getList().stream()
                .flatMap(app -> Stream.of(app.getCurrentDeptId(), app.getTargetDeptId()))
                .filter(Objects::nonNull)
@@ -228,8 +239,8 @@
        Map<Long, PostRespDTO> postMap = postApi.getPostMap(postIds);
        List<HrmTransferApplicationRespVO> list = pageResult.getList().stream().map(app -> {
            HrmTransferApplicationRespVO vo = BeanUtils.toBean(app, HrmTransferApplicationRespVO.class);
            if (app.getUserId() != null && userMap.containsKey(app.getUserId())) {
                vo.setUserName(userMap.get(app.getUserId()).getNickname());
            if (app.getUserId() != null && employeeNameMap.containsKey(app.getUserId())) {
                vo.setUserName(employeeNameMap.get(app.getUserId()));
            }
            if (app.getCurrentDeptId() != null && deptMap.containsKey(app.getCurrentDeptId())) {
                vo.setCurrentDeptName(deptMap.get(app.getCurrentDeptId()).getName());
@@ -257,17 +268,6 @@
        if (ObjUtil.notEqual(application.getStatus(), HrmAuditStatusEnum.DRAFT.getStatus())) {
            throw exception(TRANSFER_APPLICATION_SUBMIT_FAIL_NOT_DRAFT);
        }
        // å…¨å±€å…å®¡æ‰¹ï¼šä¸šåŠ¡æ ¡éªŒé€šè¿‡åŽç›´æŽ¥æ‰§è¡Œå®¡æ‰¹é€šè¿‡é€»è¾‘ï¼Œæ— éœ€æµç¨‹æ¨¡åž‹ã€‚
        if (bpmProcessInstanceApi.isGlobalAutoApprove()) {
            transferApplicationMapper.updateById(HrmTransferApplicationDO.builder()
                    .id(id)
                    .processInstanceId("")
                    .status(HrmAuditStatusEnum.PROCESS.getStatus())
                    .build());
            updateTransferApplicationAuditStatus(id, HrmAuditStatusEnum.APPROVE.getStatus());
            return;
        }
        validateApproveCategoryAndProcessDefinition();
        ProcessDefinition processDefinition = bpmProcessDefinitionService.getActiveProcessDefinition(processDefinitionKey);
        if (processDefinition == null) {
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceExceptionServiceImpl.java
@@ -102,17 +102,6 @@
        }
        // 2. æ ¡éªŒæµç¨‹å®šä¹‰å­˜åœ¨ä¸”为激活状态
        // å…¨å±€å…å®¡æ‰¹ï¼šä¸šåŠ¡æ ¡éªŒé€šè¿‡åŽç›´æŽ¥æ‰§è¡Œå®¡æ‰¹é€šè¿‡é€»è¾‘ï¼Œæ— éœ€æµç¨‹æ¨¡åž‹ã€‚
        if (bpmProcessInstanceApi.isGlobalAutoApprove()) {
            attendanceExceptionMapper.updateById(HrmAttendanceExceptionDO.builder()
                    .id(id)
                    .processInstanceId("")
                    .status(HrmAuditStatusEnum.PROCESS.getStatus())
                    .build());
            updateAttendanceExceptionAuditStatus(id, HrmAuditStatusEnum.APPROVE.getStatus());
            return;
        }
        if (processDefinitionKey != null) {
            bpmProcessDefinitionService.getActiveProcessDefinition(processDefinitionKey);
        }
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceHolidayService.java
@@ -55,7 +55,7 @@
    PageResult<HrmAttendanceHolidayDO> getAttendanceHolidayPage(HrmAttendanceHolidayPageReqVO pageReqVO);
    /**
     * èŽ·å¾—æ—¶é—´æ®µä¸ŽæŒ‡å®šæ—¥æœŸèŒƒå›´æœ‰äº¤é›†çš„èŠ‚å‡æ—¥åˆ—è¡¨
     * èŽ·å¾—æŒ‡å®šæ—¥æœŸèŒƒå›´çš„èŠ‚å‡æ—¥åˆ—è¡¨
     *
     * @param startDate å¼€å§‹æ—¥æœŸ
     * @param endDate ç»“束日期
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceHolidayServiceImpl.java
@@ -32,10 +32,9 @@
    @Override
    public Long createAttendanceHoliday(HrmAttendanceHolidaySaveReqVO createReqVO) {
        validateDateRange(createReqVO);
        validateDateRangeNoOverlap(createReqVO.getStartDate(), createReqVO.getEndDate(), null);
        // æ ¡éªŒæ—¥æœŸæ˜¯å¦å·²å­˜åœ¨é…ç½®
        validateAttendanceHolidayDateExists(createReqVO.getDate());
        HrmAttendanceHolidayDO attendanceHoliday = BeanUtils.toBean(createReqVO, HrmAttendanceHolidayDO.class);
        attendanceHoliday.setDate(createReqVO.getStartDate());
        if (attendanceHoliday.getIsLegal() == null) {
            attendanceHoliday.setIsLegal(0);
        }
@@ -46,10 +45,12 @@
    @Override
    public void updateAttendanceHoliday(HrmAttendanceHolidaySaveReqVO updateReqVO) {
        validateAttendanceHolidayExists(updateReqVO.getId());
        validateDateRange(updateReqVO);
        validateDateRangeNoOverlap(updateReqVO.getStartDate(), updateReqVO.getEndDate(), updateReqVO.getId());
        // å¦‚果日期变更,校验新日期是否已存在配置
        HrmAttendanceHolidayDO oldHoliday = attendanceHolidayMapper.selectById(updateReqVO.getId());
        if (!oldHoliday.getDate().equals(updateReqVO.getDate())) {
            validateAttendanceHolidayDateExists(updateReqVO.getDate());
        }
        HrmAttendanceHolidayDO updateObj = BeanUtils.toBean(updateReqVO, HrmAttendanceHolidayDO.class);
        updateObj.setDate(updateReqVO.getStartDate());
        attendanceHolidayMapper.updateById(updateObj);
    }
@@ -61,23 +62,13 @@
    private void validateAttendanceHolidayExists(Long id) {
        if (attendanceHolidayMapper.selectById(id) == null) {
            throw exception(ATTENDANCE_HOLIDAY_NOT_EXISTS, id);
            throw exception(ATTENDANCE_HOLIDAY_NOT_EXISTS);
        }
    }
    private void validateDateRange(HrmAttendanceHolidaySaveReqVO reqVO) {
        if (reqVO.getEndDate().isBefore(reqVO.getStartDate())) {
            throw exception(ATTENDANCE_HOLIDAY_DATE_RANGE_INVALID, reqVO.getEndDate(), reqVO.getStartDate());
        }
    }
    private void validateDateRangeNoOverlap(LocalDate startDate, LocalDate endDate, Long excludeId) {
        for (HrmAttendanceHolidayDO exist : attendanceHolidayMapper.selectListByDateRange(startDate, endDate)) {
            if (excludeId != null && excludeId.equals(exist.getId())) {
                continue;
            }
            throw exception(ATTENDANCE_HOLIDAY_RANGE_OVERLAP, startDate, endDate,
                    exist.getName(), exist.getStartDate(), exist.getEndDate());
    private void validateAttendanceHolidayDateExists(LocalDate date) {
        if (attendanceHolidayMapper.selectByDate(date) != null) {
            throw exception(ATTENDANCE_HOLIDAY_DATE_EXISTS);
        }
    }
@@ -93,16 +84,16 @@
    @Override
    public List<HrmAttendanceHolidayDO> getAttendanceHolidayListByDateRange(LocalDate startDate, LocalDate endDate) {
        return attendanceHolidayMapper.selectListByDateRange(startDate, endDate);
        return attendanceHolidayMapper.selectListByDateBetween(startDate, endDate);
    }
    @Override
    public boolean isWorkDay(LocalDate date) {
        // 1. æŸ¥è¯¢è¦†ç›–该日期的节假日配置(配置时间段不允许重叠,至多命中一条)
        List<HrmAttendanceHolidayDO> holidays = attendanceHolidayMapper.selectListByDateRange(date, date);
        if (!holidays.isEmpty()) {
        // 1. æŸ¥è¯¢èŠ‚å‡æ—¥é…ç½®
        HrmAttendanceHolidayDO holiday = attendanceHolidayMapper.selectByDate(date);
        if (holiday != null) {
            // æœ‰é…ç½®æ—¶ï¼ŒæŒ‰é…ç½®åˆ¤æ–­
            return HrmAttendanceHolidayTypeEnum.WORK_DAY.getType().equals(holidays.get(0).getType());
            return HrmAttendanceHolidayTypeEnum.WORK_DAY.getType().equals(holiday.getType());
        }
        // 2. æ— é…ç½®æ—¶ï¼ŒæŒ‰å‘¨æœ«åˆ¤æ–­
        DayOfWeek dayOfWeek = date.getDayOfWeek();
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordService.java
@@ -1,7 +1,6 @@
package cn.iocoder.yudao.module.hrm.service.attendance;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceMyStatusRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRecordPageReqVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceRecordDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceExceptionDO;
@@ -38,15 +37,6 @@
     */
    HrmAttendanceRecordDO getAttendanceRecordByUserIdAndDate(Long userId, LocalDate date);
    /**
     * èŽ·å¾—å½“å‰ç™»å½•äººçš„è€ƒå‹¤çŠ¶æ€ï¼šè€ƒå‹¤è§„åˆ™ + æŒ‡å®šæ—¥æœŸçš„æ‰“卡状态
     *
     * @param loginUserId ç™»å½•用户ID(系统用户ID,不是员工ID)
     * @param date        è€ƒå‹¤æ—¥æœŸï¼Œä¸ºç©ºæ—¶å–服务器当天
     * @return æˆ‘的考勤状态
     */
    HrmAttendanceMyStatusRespVO getMyStatus(Long loginUserId, LocalDate date);
    /** åˆ›å»ºå¯¼å…¥çš„考勤记录 */
    void createImportedRecord(HrmAttendanceRecordDO record);
@@ -80,4 +70,14 @@
     */
    void supplementClockIn(HrmAttendanceExceptionDO exception);
    /**
     * èŽ·å¾—å½“å‰ç™»å½•äººçš„è€ƒå‹¤çŠ¶æ€ï¼ˆApp æ‰“卡页使用)
     *
     * @param loginUserId ç™»å½•用户ID(系统用户,非员工ID)
     * @param date        è€ƒå‹¤æ—¥æœŸï¼Œä¸ºç©ºæ—¶å–服务器当天
     * @return æˆ‘的考勤状态
     */
    cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceMyStatusRespVO
    getMyStatus(Long loginUserId, LocalDate date);
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordServiceImpl.java
@@ -1,12 +1,16 @@
package cn.iocoder.yudao.module.hrm.service.attendance;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceMyStatusRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRecordPageReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceMyStatusRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRecordRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRuleRespVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceHolidayDO;
import cn.iocoder.yudao.module.hrm.enums.HrmAttendanceHolidayTypeEnum;
import cn.iocoder.yudao.module.system.api.dept.DeptApi;
import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO;
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.HrmAttendanceExceptionDO;
@@ -15,10 +19,7 @@
import cn.iocoder.yudao.module.hrm.enums.HrmAttendanceClockTypeEnum;
import cn.iocoder.yudao.module.hrm.enums.HrmAttendanceDataSourceEnum;
import cn.iocoder.yudao.module.hrm.enums.HrmAttendanceExceptionTypeEnum;
import cn.iocoder.yudao.module.hrm.enums.HrmAttendanceHolidayTypeEnum;
import cn.iocoder.yudao.module.hrm.service.employee.HrmEmployeeService;
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 org.springframework.transaction.annotation.Transactional;
@@ -77,162 +78,6 @@
    }
    @Override
    public HrmAttendanceMyStatusRespVO getMyStatus(Long loginUserId, LocalDate date) {
        LocalDate queryDate = date != null ? date : LocalDate.now();
        // 1. ç™»å½•用户 -> å‘˜å·¥æ¡£æ¡ˆ
        HrmEmployeeDO employee = employeeService.getEmployeeByUserId(loginUserId);
        if (employee == null) {
            throw exception(EMPLOYEE_NOT_EXISTS,
                    "当前登录账号未关联员工档案,无法打卡。请联系管理员在【员工管理】中将该登录账号绑定到员工。");
        }
        HrmAttendanceMyStatusRespVO respVO = new HrmAttendanceMyStatusRespVO();
        respVO.setUserId(loginUserId);
        respVO.setEmployeeId(employee.getId());
        respVO.setEmployeeName(employee.getName());
        respVO.setDeptId(employee.getDeptId());
        if (employee.getDeptId() != null) {
            DeptRespDTO dept = deptApi.getDept(employee.getDeptId());
            if (dept != null) {
                respVO.setDeptName(dept.getName());
            }
        }
        respVO.setDate(queryDate);
        respVO.setServerTime(LocalDateTime.now());
        // 2. æ˜¯å¦å·¥ä½œæ—¥ + ä¼‘息原因
        boolean workDay = attendanceHolidayService.isWorkDay(queryDate);
        respVO.setWorkDay(workDay);
        if (!workDay) {
            respVO.setRestDayReason(getRestDayReason(queryDate));
        }
        // 3. å½“前生效规则(部门规则优先,其次全局规则)
        HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(employee.getDeptId());
        if (rule != null) {
            HrmAttendanceRuleRespVO ruleRespVO = BeanUtils.toBean(rule, HrmAttendanceRuleRespVO.class);
            if (rule.getDeptId() != null) {
                DeptRespDTO ruleDept = deptApi.getDept(rule.getDeptId());
                if (ruleDept != null) {
                    ruleRespVO.setDeptName(ruleDept.getName());
                }
            }
            respVO.setRule(ruleRespVO);
        }
        // 4. å½“日考勤记录
        HrmAttendanceRecordDO record = attendanceRecordMapper.selectByUserIdAndDate(employee.getId(), queryDate);
        if (record != null) {
            respVO.setRecord(buildRecordRespVO(record, employee));
        }
        // 5. å½“前可打卡状态
        respVO.setClockable(buildClockable(workDay, rule, record, queryDate));
        return respVO;
    }
    /**
     * ä¼‘息日原因:优先取节假日配置名称,未配置时按星期给出“周六/周日”
     */
    private String getRestDayReason(LocalDate date) {
        List<HrmAttendanceHolidayDO> holidays = attendanceHolidayService.getAttendanceHolidayListByDateRange(date, date);
        for (HrmAttendanceHolidayDO holiday : holidays) {
            if (HrmAttendanceHolidayTypeEnum.REST_DAY.getType().equals(holiday.getType())) {
                return holiday.getName();
            }
        }
        DayOfWeek dayOfWeek = date.getDayOfWeek();
        if (dayOfWeek == DayOfWeek.SATURDAY) {
            return "周六";
        }
        if (dayOfWeek == DayOfWeek.SUNDAY) {
            return "周日";
        }
        return null;
    }
    /**
     * æž„建打卡记录返回 VO,补齐打卡类型名称、员工与部门信息
     */
    private HrmAttendanceRecordRespVO buildRecordRespVO(HrmAttendanceRecordDO record, HrmEmployeeDO employee) {
        HrmAttendanceRecordRespVO recordRespVO = BeanUtils.toBean(record, HrmAttendanceRecordRespVO.class);
        recordRespVO.setClockInTypeName(HrmAttendanceClockTypeEnum.getNameByType(record.getClockInType()));
        recordRespVO.setClockOutTypeName(HrmAttendanceClockTypeEnum.getNameByType(record.getClockOutType()));
        if (employee != null) {
            recordRespVO.setUserName(employee.getName());
            recordRespVO.setPhone(employee.getPhone());
        }
        if (record.getDeptId() != null) {
            DeptRespDTO dept = deptApi.getDept(record.getDeptId());
            if (dept != null) {
                recordRespVO.setDeptName(dept.getName());
            }
        }
        return recordRespVO;
    }
    /**
     * æ ¹æ®å·¥ä½œæ—¥ã€è€ƒå‹¤è§„则与当日记录计算可打卡状态
     */
    private HrmAttendanceMyStatusRespVO.Clockable buildClockable(boolean workDay, HrmAttendanceRuleDO rule,
                                                                 HrmAttendanceRecordDO record, LocalDate date) {
        HrmAttendanceMyStatusRespVO.Clockable clockable = new HrmAttendanceMyStatusRespVO.Clockable();
        if (!workDay) {
            clockable.setClockIn(false);
            clockable.setClockOut(false);
            clockable.setNextAction("none");
            clockable.setMessage("今日休息,无需打卡");
            return clockable;
        }
        if (rule == null) {
            clockable.setClockIn(false);
            clockable.setClockOut(false);
            clockable.setNextAction("none");
            clockable.setMessage("未配置考勤规则,请联系管理员");
            return clockable;
        }
        // åªæœ‰ä¸‹ç­å¡ã€æ²¡æœ‰ä¸Šç­å¡ï¼šå½“日已结束,不再允许继续打卡
        if (record != null && record.getClockInTime() == null && record.getClockOutTime() != null) {
            clockable.setClockIn(false);
            clockable.setClockOut(false);
            clockable.setNextAction("none");
            clockable.setMessage("今日缺上班卡,已完成下班打卡");
            return clockable;
        }
        // ä»Šå¤©è¿˜æ²¡æ‰“上班卡
        if (record == null || record.getClockInTime() == null) {
            clockable.setClockIn(true);
            clockable.setClockOut(false);
            clockable.setNextAction("in");
            clockable.setMessage("可上班打卡");
            // ä»…当天查询时提示已迟到时长
            LocalDateTime now = LocalDateTime.now();
            if (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))) {
                    long lateMinutes = Duration.between(workStart, now).toMinutes();
                    clockable.setMessage("已超过上班时间 " + lateMinutes + " åˆ†é’Ÿ");
                }
            }
            return clockable;
        }
        // å·²æ‰“上班卡、未打下班卡
        if (record.getClockOutTime() == null) {
            clockable.setClockIn(false);
            clockable.setClockOut(true);
            clockable.setNextAction("out");
            clockable.setMessage("已上班打卡,可下班打卡");
            return clockable;
        }
        // ä¸Šä¸‹ç­å¡éƒ½å·²æ‰“
        clockable.setClockIn(false);
        clockable.setClockOut(false);
        clockable.setNextAction("none");
        clockable.setMessage("今日已完成打卡");
        return clockable;
    }
    @Override
    public void createImportedRecord(HrmAttendanceRecordDO record) {
        // æŒ‰è€ƒå‹¤è§„则重算打卡类型与时长,不信任 Excel å¡«å†™çš„值
        fillClockTypes(record);
@@ -254,31 +99,27 @@
        if (record.getDeptId() == null) {
            return;
        }
        // ä¸Šç­æ‰“卡类型:晚于 ä¸Šç­æ—¶é—´+迟到允许 è®°ä¸ºè¿Ÿåˆ°ï¼ˆæŒ‰ä¸Šç­æ‰“卡时间匹配班次)
        if (record.getClockInTime() != null) {
            HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(
                    record.getDeptId(), record.getClockInTime());
            if (rule != null && rule.getWorkStartTime() != null) {
                int lateAllowMinutes = rule.getLateAllowMinutes() != null ? rule.getLateAllowMinutes() : 0;
                LocalTime lateThreshold = rule.getWorkStartTime().plusMinutes(lateAllowMinutes);
                LocalTime clockInOnly = record.getClockInTime().toLocalTime();
                record.setClockInType(clockInOnly.isAfter(lateThreshold)
                        ? HrmAttendanceClockTypeEnum.LATE.getType()
                        : HrmAttendanceClockTypeEnum.NORMAL.getType());
            }
        HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(record.getDeptId(), record.getDate());
        if (rule == null) {
            return;
        }
        // ä¸‹ç­æ‰“卡类型:早于 ä¸‹ç­æ—¶é—´-早退允许 è®°ä¸ºæ—©é€€ï¼ˆæŒ‰ä¸‹ç­æ‰“卡时间匹配班次)
        if (record.getClockOutTime() != null) {
            HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(
                    record.getDeptId(), record.getClockOutTime());
            if (rule != null && rule.getWorkEndTime() != null) {
                int earlyAllowMinutes = rule.getEarlyAllowMinutes() != null ? rule.getEarlyAllowMinutes() : 0;
                LocalTime earlyThreshold = rule.getWorkEndTime().minusMinutes(earlyAllowMinutes);
                LocalTime clockOutOnly = record.getClockOutTime().toLocalTime();
                record.setClockOutType(clockOutOnly.isBefore(earlyThreshold)
                        ? HrmAttendanceClockTypeEnum.EARLY.getType()
                        : HrmAttendanceClockTypeEnum.NORMAL.getType());
            }
        // ä¸Šç­æ‰“卡类型:晚于 ä¸Šç­æ—¶é—´+迟到允许 è®°ä¸ºè¿Ÿåˆ°
        if (record.getClockInTime() != null && rule.getWorkStartTime() != null) {
            int lateAllowMinutes = rule.getLateAllowMinutes() != null ? rule.getLateAllowMinutes() : 0;
            LocalTime lateThreshold = rule.getWorkStartTime().plusMinutes(lateAllowMinutes);
            LocalTime clockInOnly = record.getClockInTime().toLocalTime();
            record.setClockInType(clockInOnly.isAfter(lateThreshold)
                    ? HrmAttendanceClockTypeEnum.LATE.getType()
                    : HrmAttendanceClockTypeEnum.NORMAL.getType());
        }
        // ä¸‹ç­æ‰“卡类型:早于 ä¸‹ç­æ—¶é—´-早退允许 è®°ä¸ºæ—©é€€
        if (record.getClockOutTime() != null && rule.getWorkEndTime() != null) {
            int earlyAllowMinutes = rule.getEarlyAllowMinutes() != null ? rule.getEarlyAllowMinutes() : 0;
            LocalTime earlyThreshold = rule.getWorkEndTime().minusMinutes(earlyAllowMinutes);
            LocalTime clockOutOnly = record.getClockOutTime().toLocalTime();
            record.setClockOutType(clockOutOnly.isBefore(earlyThreshold)
                    ? HrmAttendanceClockTypeEnum.EARLY.getType()
                    : HrmAttendanceClockTypeEnum.NORMAL.getType());
        }
    }
@@ -293,10 +134,9 @@
                    .divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP);
            record.setWorkHours(workHours);
        }
        // åŠ ç­æ—¶é•¿ï¼šæŒ‰è€ƒå‹¤è§„åˆ™çš„æ ‡å‡†ä¸‹ç­æ—¶é—´è®¡ç®—è¶…é¢éƒ¨åˆ†ï¼ˆæŒ‰ä¸‹ç­æ‰“å¡æ—¶é—´åŒ¹é…ç­æ¬¡ï¼‰
        // åŠ ç­æ—¶é•¿ï¼šæŒ‰è€ƒå‹¤è§„åˆ™çš„æ ‡å‡†ä¸‹ç­æ—¶é—´è®¡ç®—è¶…é¢éƒ¨åˆ†
        if (record.getClockOutTime() != null && record.getDeptId() != null) {
            HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(
                    record.getDeptId(), record.getClockOutTime());
            HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(record.getDeptId(), record.getDate());
            if (rule != null && rule.getWorkEndTime() != null) {
                LocalTime workEnd = rule.getWorkEndTime();
                LocalDate outDate = record.getClockOutTime().toLocalDate();
@@ -330,8 +170,7 @@
        if (employee == null) {
            throw exception(EMPLOYEE_NOT_EXISTS);
        }
        // æŒ‰æœ¬æ¬¡æ‰“卡时间匹配班次(同一个部门可能配置了多条规则)
        HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(employee.getDeptId(), clockTime);
        HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(employee.getDeptId(), clockDate);
        if (rule == null) {
            throw exception(ATTENDANCE_CLOCK_FAIL_NO_RULE);
        }
@@ -384,8 +223,7 @@
        if (employee == null) {
            throw exception(EMPLOYEE_NOT_EXISTS);
        }
        // æŒ‰æœ¬æ¬¡æ‰“卡时间匹配班次(同一个部门可能配置了多条规则)
        HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(employee.getDeptId(), clockTime);
        HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(employee.getDeptId(), clockDate);
        if (rule == null) {
            throw exception(ATTENDANCE_CLOCK_FAIL_NO_RULE);
        }
@@ -476,4 +314,155 @@
        attendanceRecordMapper.updateById(record);
    }
    // ==================== æˆ‘的考勤状态(App æ‰“卡页) ====================
    @Override
    public HrmAttendanceMyStatusRespVO getMyStatus(Long loginUserId, LocalDate date) {
        LocalDate queryDate = date != null ? date : LocalDate.now();
        // 1. ç™»å½•用户 -> å‘˜å·¥æ¡£æ¡ˆ
        HrmEmployeeDO employee = employeeService.getEmployeeByUserId(loginUserId);
        if (employee == null) {
            throw new ServiceException(EMPLOYEE_NOT_EXISTS.getCode(),
                    "当前登录账号未关联员工档案,无法打卡。请联系管理员在【员工管理】中将该登录账号绑定到员工。");
        }
        HrmAttendanceMyStatusRespVO respVO = new HrmAttendanceMyStatusRespVO();
        respVO.setUserId(loginUserId);
        respVO.setEmployeeId(employee.getId());
        respVO.setEmployeeName(employee.getName());
        respVO.setDeptId(employee.getDeptId());
        if (employee.getDeptId() != null) {
            DeptRespDTO dept = deptApi.getDept(employee.getDeptId());
            if (dept != null) {
                respVO.setDeptName(dept.getName());
            }
        }
        respVO.setDate(queryDate);
        respVO.setServerTime(LocalDateTime.now());
        // 2. æ˜¯å¦å·¥ä½œæ—¥ + ä¼‘息原因
        boolean workDay = attendanceHolidayService.isWorkDay(queryDate);
        respVO.setWorkDay(workDay);
        if (!workDay) {
            respVO.setRestDayReason(getRestDayReason(queryDate));
        }
        // 3. å½“前生效规则(部门规则优先,其次全局规则;按日期匹配生效区间)
        HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(employee.getDeptId(), queryDate);
        if (rule != null) {
            HrmAttendanceRuleRespVO ruleRespVO = BeanUtils.toBean(rule, HrmAttendanceRuleRespVO.class);
            if (rule.getDeptId() != null) {
                DeptRespDTO ruleDept = deptApi.getDept(rule.getDeptId());
                if (ruleDept != null) {
                    ruleRespVO.setDeptName(ruleDept.getName());
                }
            }
            respVO.setRule(ruleRespVO);
        }
        // 4. å½“日考勤记录
        HrmAttendanceRecordDO record = attendanceRecordMapper.selectByUserIdAndDate(employee.getId(), queryDate);
        if (record != null) {
            respVO.setRecord(buildRecordRespVO(record, employee));
        }
        // 5. å½“前可打卡状态
        respVO.setClockable(buildClockable(workDay, rule, record, queryDate));
        return respVO;
    }
    /**
     * ä¼‘息日原因:优先取节假日配置名称,未配置时按星期给出“周六/周日”
     */
    private String getRestDayReason(LocalDate date) {
        List<HrmAttendanceHolidayDO> holidays = attendanceHolidayService.getAttendanceHolidayListByDateRange(date, date);
        for (HrmAttendanceHolidayDO holiday : holidays) {
            if (HrmAttendanceHolidayTypeEnum.REST_DAY.getType().equals(holiday.getType())) {
                return holiday.getName();
            }
        }
        DayOfWeek dayOfWeek = date.getDayOfWeek();
        if (dayOfWeek == DayOfWeek.SATURDAY) {
            return "周六";
        }
        if (dayOfWeek == DayOfWeek.SUNDAY) {
            return "周日";
        }
        return null;
    }
    /**
     * æž„建打卡记录返回 VO,补齐打卡类型名称与员工信息
     */
    private HrmAttendanceRecordRespVO buildRecordRespVO(HrmAttendanceRecordDO record, HrmEmployeeDO employee) {
        HrmAttendanceRecordRespVO recordRespVO = BeanUtils.toBean(record, HrmAttendanceRecordRespVO.class);
        recordRespVO.setClockInTypeName(HrmAttendanceClockTypeEnum.getNameByType(record.getClockInType()));
        recordRespVO.setClockOutTypeName(HrmAttendanceClockTypeEnum.getNameByType(record.getClockOutType()));
        if (employee != null) {
            recordRespVO.setUserName(employee.getName());
        }
        return recordRespVO;
    }
    /**
     * è®¡ç®—当前可打卡状态
     */
    private HrmAttendanceMyStatusRespVO.Clockable buildClockable(boolean workDay, HrmAttendanceRuleDO rule,
                                                                 HrmAttendanceRecordDO record, LocalDate date) {
        HrmAttendanceMyStatusRespVO.Clockable clockable = new HrmAttendanceMyStatusRespVO.Clockable();
        if (!workDay) {
            clockable.setClockIn(false);
            clockable.setClockOut(false);
            clockable.setNextAction("none");
            clockable.setMessage("今日休息,无需打卡");
            return clockable;
        }
        if (rule == null) {
            clockable.setClockIn(false);
            clockable.setClockOut(false);
            clockable.setNextAction("none");
            clockable.setMessage("未配置考勤规则,请联系管理员");
            return clockable;
        }
        // åªæœ‰ä¸‹ç­å¡ã€æ²¡æœ‰ä¸Šç­å¡ï¼šå½“日已结束,不再允许继续打卡
        if (record != null && record.getClockInTime() == null && record.getClockOutTime() != null) {
            clockable.setClockIn(false);
            clockable.setClockOut(false);
            clockable.setNextAction("none");
            clockable.setMessage("今日缺上班卡,已完成下班打卡");
            return clockable;
        }
        // ä»Šå¤©è¿˜æ²¡æ‰“上班卡
        if (record == null || record.getClockInTime() == null) {
            clockable.setClockIn(true);
            clockable.setClockOut(false);
            clockable.setNextAction("in");
            clockable.setMessage("可上班打卡");
            // ä»…当天查询时提示已迟到时长
            LocalDateTime now = LocalDateTime.now();
            if (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))) {
                    long lateMinutes = Duration.between(workStart, now).toMinutes();
                    clockable.setMessage("已超过上班时间 " + lateMinutes + " åˆ†é’Ÿ");
                }
            }
            return clockable;
        }
        // å·²æ‰“上班卡、未打下班卡
        if (record.getClockOutTime() == null) {
            clockable.setClockIn(false);
            clockable.setClockOut(true);
            clockable.setNextAction("out");
            clockable.setMessage("已上班打卡,可下班打卡");
            return clockable;
        }
        // ä¸Šä¸‹ç­å¡éƒ½å·²æ‰“
        clockable.setClockIn(false);
        clockable.setClockOut(false);
        clockable.setNextAction("none");
        clockable.setMessage("今日已完成打卡");
        return clockable;
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRuleService.java
@@ -6,7 +6,6 @@
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceRuleDO;
import jakarta.validation.Valid;
import java.time.LocalDateTime;
import java.util.List;
/**
@@ -62,24 +61,14 @@
    List<HrmAttendanceRuleDO> getAttendanceRuleList();
    /**
     * æ ¹æ®éƒ¨é—¨ID获取考勤规则
     * ä¼˜å…ˆåŒ¹é…éƒ¨é—¨è§„则,其次查找全局规则(deptId=null)
     * æ ¹æ®éƒ¨é—¨ID + è€ƒå‹¤æ—¥æœŸèŽ·å–ç”Ÿæ•ˆçš„è€ƒå‹¤è§„åˆ™
     * ä¼˜å…ˆåŒ¹é…éƒ¨é—¨è§„则,其次查找全局规则(deptId=null);
     * åŒéƒ¨é—¨å­˜åœ¨å¤šæ¡è§„则时,取「生效开始日期」最晚且覆盖该日期的那条。
     *
     * @param deptId éƒ¨é—¨ID
     * @param date   è€ƒå‹¤æ—¥æœŸï¼ˆä¸ºç©ºæ—¶æŒ‰å½“天)
     * @return è€ƒå‹¤è§„则
     */
    HrmAttendanceRuleDO getAttendanceRuleByDeptId(Long deptId);
    /**
     * æ ¹æ®éƒ¨é—¨ID + æ‰“卡时间获取考勤规则
     *
     * åŒä¸€ä¸ªéƒ¨é—¨å…è®¸é…ç½®å¤šæ¡è€ƒå‹¤è§„则(多班次),此时按打卡时间匹配班次:
     * ä¼˜å…ˆå–班次时间窗口包含该打卡时间的规则,都不包含时取时间窗口最接近的一条。
     *
     * @param deptId    éƒ¨é—¨ID
     * @param clockTime æ‰“卡时间;为空时取该部门上班时间最早的一条规则
     * @return è€ƒå‹¤è§„则
     */
    HrmAttendanceRuleDO getAttendanceRuleByDeptId(Long deptId, LocalDateTime clockTime);
    HrmAttendanceRuleDO getAttendanceRuleByDeptId(Long deptId, java.time.LocalDate date);
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRuleServiceImpl.java
@@ -3,25 +3,17 @@
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.google.common.annotations.VisibleForTesting;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRulePageReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRuleSaveReqVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceRuleDO;
import cn.iocoder.yudao.module.hrm.dal.mysql.attendance.HrmAttendanceRuleMapper;
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 org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.Comparator;
import java.util.HashSet;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.hrm.enums.ErrorCodeConstants.*;
@@ -29,25 +21,23 @@
/**
 * è€ƒå‹¤è§„则配置 Service å®žçŽ°ç±»
 *
 * è¯´æ˜Žï¼šåŒä¸€éƒ¨é—¨å…è®¸é…ç½®å¤šæ¡è§„则,按「生效开始日期 / ç”Ÿæ•ˆç»“束日期」区间区分;
 * åŒ¹é…æ—¶å–覆盖目标日期、且生效开始日期最晚的那条(无日期的视为长期有效,优先级最低)。
 *
 * @author è¶…级管理员
 */
@Service
@Validated
public class HrmAttendanceRuleServiceImpl implements HrmAttendanceRuleService {
    /** è§„则状态:0-启用 */
    private static final Integer ENABLED_STATUS = 0;
    @Resource
    private HrmAttendanceRuleMapper attendanceRuleMapper;
    @Resource
    private DeptApi deptApi;
    @Override
    public Long createAttendanceRule(HrmAttendanceRuleSaveReqVO createReqVO) {
        // æ ¡éªŒè§„则名称是否重复
        // æ ¡éªŒè§„则名称是否重复(同一部门允许多条规则,不再限制部门唯一)
        validateAttendanceRuleNameUnique(null, createReqVO.getName());
        // æ³¨ï¼šåŒä¸€ä¸ªéƒ¨é—¨å…è®¸é…ç½®å¤šæ¡è§„则(多班次),这里不再校验部门唯一
        validateEffectiveDateRange(createReqVO.getEffectiveStartDate(), createReqVO.getEffectiveEndDate());
        HrmAttendanceRuleDO attendanceRule = BeanUtils.toBean(createReqVO, HrmAttendanceRuleDO.class);
        // è®¾ç½®é»˜è®¤å€¼
        if (attendanceRule.getLateAllowMinutes() == null) {
@@ -71,7 +61,7 @@
        validateAttendanceRuleExists(updateReqVO.getId());
        // æ ¡éªŒè§„则名称是否重复
        validateAttendanceRuleNameUnique(updateReqVO.getId(), updateReqVO.getName());
        // æ³¨ï¼šåŒä¸€ä¸ªéƒ¨é—¨å…è®¸é…ç½®å¤šæ¡è§„则(多班次),这里不再校验部门唯一
        validateEffectiveDateRange(updateReqVO.getEffectiveStartDate(), updateReqVO.getEffectiveEndDate());
        HrmAttendanceRuleDO updateObj = BeanUtils.toBean(updateReqVO, HrmAttendanceRuleDO.class);
        attendanceRuleMapper.updateById(updateObj);
    }
@@ -101,6 +91,15 @@
        }
    }
    /**
     * æ ¡éªŒç”Ÿæ•ˆæ—¥æœŸåŒºé—´ï¼šå¼€å§‹æ—¥æœŸä¸èƒ½æ™šäºŽç»“束日期
     */
    private void validateEffectiveDateRange(LocalDate startDate, LocalDate endDate) {
        if (startDate != null && endDate != null && startDate.isAfter(endDate)) {
            throw exception(ATTENDANCE_RULE_EFFECTIVE_DATE_INVALID);
        }
    }
    @Override
    public HrmAttendanceRuleDO getAttendanceRule(Long id) {
        return attendanceRuleMapper.selectById(id);
@@ -117,90 +116,47 @@
    }
    @Override
    public HrmAttendanceRuleDO getAttendanceRuleByDeptId(Long deptId) {
        return getAttendanceRuleByDeptId(deptId, null);
    }
    @Override
    public HrmAttendanceRuleDO getAttendanceRuleByDeptId(Long deptId, LocalDateTime clockTime) {
        // 1. ä»Žå‘˜å·¥æ‰€åœ¨éƒ¨é—¨å¼€å§‹é€çº§å‘上查找:配置在上级部门的规则对其子部门生效
        Set<Long> visitedDeptIds = new HashSet<>();
        Long currentDeptId = deptId;
        while (currentDeptId != null && currentDeptId != 0 && visitedDeptIds.add(currentDeptId)) {
            List<HrmAttendanceRuleDO> deptRules = attendanceRuleMapper.selectEnabledListByDeptId(currentDeptId);
    public HrmAttendanceRuleDO getAttendanceRuleByDeptId(Long deptId, LocalDate date) {
        LocalDate targetDate = date != null ? date : LocalDate.now();
        // 1. å…ˆæŸ¥æ‰¾éƒ¨é—¨ä¸“属规则(覆盖目标日期的,生效开始日期最晚的优先)
        if (deptId != null) {
            List<HrmAttendanceRuleDO> deptRules = selectEffectiveRules(deptId, targetDate);
            if (!deptRules.isEmpty()) {
                // åŒä¸€éƒ¨é—¨å¯é…ç½®å¤šæ¡è§„则(多班次),按打卡时间匹配班次
                return matchRuleByClockTime(deptRules, clockTime);
                return deptRules.get(0);
            }
            DeptRespDTO dept = deptApi.getDept(currentDeptId);
            if (dept == null) {
                break;
            }
            currentDeptId = dept.getParentId();
        }
        // 2. æŸ¥æ‰¾å…¨å±€è§„则(deptId ä¸º null)
        List<HrmAttendanceRuleDO> globalRules = attendanceRuleMapper.selectList(new LambdaQueryWrapperX<HrmAttendanceRuleDO>()
                .isNull(HrmAttendanceRuleDO::getDeptId)
                .eq(HrmAttendanceRuleDO::getStatus, ENABLED_STATUS)
                .orderByDesc(HrmAttendanceRuleDO::getId));
        List<HrmAttendanceRuleDO> globalRules = selectEffectiveRules(null, targetDate);
        if (!globalRules.isEmpty()) {
            return matchRuleByClockTime(globalRules, clockTime);
            return globalRules.get(0);
        }
        return null;
    }
    /**
     * å¤šæ¡è§„则(同一个部门多班次)时,按打卡时间选班次:
     * 1. æ‰“卡时间落在某个班次的 [上班时间, ä¸‹ç­æ—¶é—´] å†…,就取这一条
     * 2. éƒ½ä¸åœ¨çª—口内时,取班次时间窗口离打卡时间最近的一条
     * 3. ä»ç„¶å¹¶åˆ—时,按上班时间、编号排序,保证结果稳定(不会随数据库返回顺序漂移)
     * æ‰“卡时间为空(不关心班次)时,取上班时间最早的一条。
     * æŸ¥è¯¢åœ¨æŒ‡å®šæ—¥æœŸç”Ÿæ•ˆçš„规则
     *
     * @param deptId éƒ¨é—¨ID,null è¡¨ç¤ºå…¨å±€è§„则
     * @param date   ç›®æ ‡æ—¥æœŸ
     */
    @VisibleForTesting
    static HrmAttendanceRuleDO matchRuleByClockTime(List<HrmAttendanceRuleDO> rules, LocalDateTime clockTime) {
        if (rules.size() == 1) {
            return rules.get(0);
    private List<HrmAttendanceRuleDO> selectEffectiveRules(Long deptId, LocalDate date) {
        LambdaQueryWrapperX<HrmAttendanceRuleDO> wrapper = new LambdaQueryWrapperX<>();
        if (deptId == null) {
            wrapper.isNull(HrmAttendanceRuleDO::getDeptId);
        } else {
            wrapper.eq(HrmAttendanceRuleDO::getDeptId, deptId);
        }
        if (clockTime == null) {
            return rules.stream().min(Comparator
                            .comparing(HrmAttendanceRuleDO::getWorkStartTime,
                                    Comparator.nullsLast(Comparator.<LocalTime>naturalOrder()))
                            .thenComparing(HrmAttendanceRuleDO::getId))
                    .orElse(rules.get(0));
        }
        LocalTime time = clockTime.toLocalTime();
        return rules.stream().min(Comparator
                        .comparingLong((HrmAttendanceRuleDO rule) -> minutesToRuleWindow(rule, time))
                        .thenComparing(HrmAttendanceRuleDO::getWorkStartTime,
                                Comparator.nullsLast(Comparator.<LocalTime>naturalOrder()))
                        .thenComparing(HrmAttendanceRuleDO::getId))
                .orElse(rules.get(0));
        wrapper.eq(HrmAttendanceRuleDO::getStatus, 0);
        // ç”Ÿæ•ˆå¼€å§‹æ—¥æœŸä¸ºç©ºï¼ˆä¸é™ï¼‰æˆ– <= ç›®æ ‡æ—¥æœŸ
        wrapper.and(w -> w.isNull(HrmAttendanceRuleDO::getEffectiveStartDate)
                .or().le(HrmAttendanceRuleDO::getEffectiveStartDate, date));
        // ç”Ÿæ•ˆç»“束日期为空(不限)或 >= ç›®æ ‡æ—¥æœŸ
        wrapper.and(w -> w.isNull(HrmAttendanceRuleDO::getEffectiveEndDate)
                .or().ge(HrmAttendanceRuleDO::getEffectiveEndDate, date));
        // ç”Ÿæ•ˆå¼€å§‹æ—¥æœŸè¶Šæ™šä¼˜å…ˆçº§è¶Šé«˜ï¼›åŒå¼€å§‹æ—¥æœŸæ—¶å–先创建的
        wrapper.orderByDesc(HrmAttendanceRuleDO::getEffectiveStartDate);
        wrapper.orderByAsc(HrmAttendanceRuleDO::getId);
        return attendanceRuleMapper.selectList(wrapper);
    }
    /**
     * æ‰“卡时间到规则班次窗口的距离(分钟):落在窗口内为 0,班次时间未配置时记为最大。
     * æ”¯æŒè·¨å¤©ç­æ¬¡ï¼ˆå¦‚ 22:00-06:00,只要 end <= start å°±æŒ‰è·¨å¤©å¤„理)。
     */
    private static long minutesToRuleWindow(HrmAttendanceRuleDO rule, LocalTime time) {
        LocalTime start = rule.getWorkStartTime();
        LocalTime end = rule.getWorkEndTime();
        if (start == null || end == null) {
            return Long.MAX_VALUE;
        }
        boolean crossDay = !end.isAfter(start);
        boolean inWindow = crossDay
                ? !time.isBefore(start) || !time.isAfter(end)
                : !time.isBefore(start) && !time.isAfter(end);
        if (inWindow) {
            return 0;
        }
        return Math.min(minutesBetween(time, start), minutesBetween(time, end));
    }
    /** ä¸¤ä¸ªæ—¶åˆ»çš„分钟差,按一天 24 å°æ—¶å–最近的环回距离(23:50 ä¸Ž 00:10 åªå·® 20 åˆ†é’Ÿï¼‰ */
    private static long minutesBetween(LocalTime one, LocalTime other) {
        long minutes = Math.abs(Duration.between(one, other).toMinutes());
        return Math.min(minutes, 24 * 60 - minutes);
    }
}
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeContractServiceImpl.java
@@ -5,7 +5,6 @@
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.operatelog.core.util.OperateLogUtils;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeContractPageReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeContractRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeContractSaveReqVO;
@@ -21,7 +20,6 @@
import cn.iocoder.yudao.module.system.api.dept.DeptApi;
import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import com.mzt.logapi.starter.annotation.LogRecord;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
@@ -40,7 +38,6 @@
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.hrm.enums.ErrorCodeConstants.*;
import static cn.iocoder.yudao.module.hrm.enums.LogRecordConstants.*;
/**
 * å‘˜å·¥åˆåŒ Service å®žçŽ°ç±»
@@ -335,8 +332,6 @@
    }
    @Override
    @LogRecord(type = HRM_EMPLOYEE_CONTRACT_TYPE, subType = HRM_EMPLOYEE_CONTRACT_IMPORT_SUB_TYPE, bizNo = "0",
            success = HRM_EMPLOYEE_CONTRACT_IMPORT_SUCCESS)
    public HrmEmployeeContractImportRespVO importContractList(List<HrmEmployeeContractImportExcelVO> importContracts,
                                                              HrmEmployeeContractImportReqVO importReqVO) {
        // 1. è¿‡æ»¤å‘˜å·¥å§“名为空的行
@@ -386,9 +381,6 @@
                respVO.getFailureNames().put(name, ex.getMessage());
            }
        }
        // è®°å½•导入结果汇总,供 @LogRecord çš„ success æ–‡æ¡ˆå¼•用
        OperateLogUtils.putImportSummary(respVO.getCreateNames(), respVO.getUpdateNames(),
                respVO.getFailureNames());
        return respVO;
    }
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeService.java
@@ -115,4 +115,19 @@
    HrmEmployeeImportRespVO importEmployeeList(List<HrmEmployeeImportExcelVO> importEmployees,
                                               HrmEmployeeImportReqVO importReqVO);
    /**
     * å‘˜å·¥è½¬æ­£ï¼šä»…「劳动合同」可转正(劳务合同不可),状态 è¯•用 -> åœ¨èŒï¼Œè®°å½•转正日期
     *
     * @param id å‘˜å·¥ID
     */
    void regularizeEmployee(Long id);
    /**
     * èŽ·å¾—å¾…è½¬æ­£æé†’åˆ—è¡¨ï¼šè¯•ç”¨ä¸­ã€ä¸”å½“å‰ä¸ºåŠ³åŠ¨åˆåŒã€è¯•ç”¨æœŸåœ¨ days å¤©å†…到期的员工
     *
     * @param days æå‰å¤©æ•°
     * @return å‘˜å·¥åˆ—表
     */
    List<HrmEmployeeDO> getRegularizeReminderList(Integer days);
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeServiceImpl.java
@@ -6,7 +6,7 @@
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.operatelog.core.util.OperateLogUtils;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeEducationSaveReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeEmergencyContactSaveReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeImportExcelVO;
@@ -17,10 +17,12 @@
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeSaveReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeSimpleRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeWorkHistorySaveReqVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeContractDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeEducationDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeEmergencyContactDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeWorkHistoryDO;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeContractMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeEducationMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeEmergencyContactMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeMapper;
@@ -32,18 +34,21 @@
import cn.iocoder.yudao.module.system.api.dept.dto.PostRespDTO;
import cn.iocoder.yudao.module.system.api.permission.RoleApi;
import cn.iocoder.yudao.module.system.api.permission.dto.RoleRespDTO;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import cn.iocoder.yudao.module.system.api.storage.dto.StorageBlobRespDTO;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserCreateReqDTO;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserUpdateReqDTO;
import com.mzt.logapi.starter.annotation.LogRecord;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
@@ -56,7 +61,6 @@
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.filterList;
import static cn.iocoder.yudao.module.hrm.enums.ErrorCodeConstants.*;
import static cn.iocoder.yudao.module.hrm.enums.LogRecordConstants.*;
/**
 * å‘˜å·¥ä¿¡æ¯ Service å®žçŽ°ç±»
@@ -69,6 +73,8 @@
    @Resource
    private HrmEmployeeMapper employeeMapper;
    @Resource
    private HrmEmployeeContractMapper contractMapper;
    @Resource
    private HrmEmployeeEducationMapper educationMapper;
    @Resource
@@ -85,6 +91,8 @@
    private AdminUserApi adminUserApi;
    @Resource
    private RoleApi roleApi;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @Resource
    @Lazy // è‡ªæ³¨å…¥éœ€å»¶è¿ŸåŠ è½½ï¼Œå¦åˆ™ bean è¢«ä»£ç†åŒ…装后注入原始版本导致启动失败
    private HrmEmployeeService self;
@@ -132,6 +140,8 @@
        }
        employeeMapper.insert(employee);
        Long employeeId = employee.getId();
        // ç»‘定附件
        storageAttachmentApi.bindAttachments("file", "hrm_employee", employeeId, createReqVO.getBlobIds());
        // 7. åŒæ­¥æ·»åŠ æ•™è‚²ç»åŽ†
        if (CollUtil.isNotEmpty(createReqVO.getEducations())) {
            createReqVO.getEducations().forEach(educationVO -> {
@@ -213,6 +223,8 @@
        syncWorkHistoryList(employeeId, updateReqVO.getWorkHistories());
        // åŒæ­¥ç´§æ€¥è”系人
        syncEmergencyContactList(employeeId, updateReqVO.getEmergencyContacts());
        // æ›´æ–°é™„ä»¶
        storageAttachmentApi.updateAttachments("file", "hrm_employee", employeeId, updateReqVO.getBlobIds());
    }
    private void syncEducationList(Long employeeId, List<HrmEmployeeEducationSaveReqVO> newList) {
@@ -260,6 +272,8 @@
        educationMapper.deleteByEmployeeId(id);
        workHistoryMapper.deleteByEmployeeId(id);
        emergencyContactMapper.deleteByEmployeeId(id);
        // åˆ é™¤é™„ä»¶
        storageAttachmentApi.deleteAttachmentsByRecord("hrm_employee", id);
        // åˆ é™¤å‘˜å·¥
        employeeMapper.deleteById(id);
    }
@@ -314,6 +328,8 @@
                respVO.setRoleNames(roles.stream().map(RoleRespDTO::getName).collect(Collectors.joining(",")));
            }
        }
        // å¡«å……附件
        respVO.setAttachmentList(storageAttachmentApi.listAttachments("hrm_employee", id));
        return respVO;
    }
@@ -355,18 +371,67 @@
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        Map<Long, DeptRespDTO> deptMap = deptApi.getDeptMap(deptIds);
        // è§£æžå²—位ID(post_ids å­˜çš„æ˜¯ JSON æ•°ç»„字符串,如 [38]),并批量加载岗位名称
        Map<Long, List<Long>> empPostIdsMap = new LinkedHashMap<>();
        Set<Long> allPostIds = new LinkedHashSet<>();
        for (HrmEmployeeDO emp : list) {
            List<Long> postIdList = parseEmployeePostIds(emp.getPostIds());
            empPostIdsMap.put(emp.getId(), postIdList);
            allPostIds.addAll(postIdList);
        }
        Map<Long, PostRespDTO> postMap = CollUtil.isEmpty(allPostIds)
                ? Collections.emptyMap() : postApi.getPostMap(allPostIds);
        return list.stream().map(emp -> {
            HrmEmployeeSimpleRespVO vo = new HrmEmployeeSimpleRespVO();
            vo.setId(emp.getId());
            vo.setEmployeeNo(emp.getEmployeeNo());
            vo.setName(emp.getName());
            vo.setEmployeeStatus(emp.getEmployeeStatus());
            vo.setDeptId(emp.getDeptId());
            if (emp.getDeptId() != null && deptMap.containsKey(emp.getDeptId())) {
                vo.setDeptName(deptMap.get(emp.getDeptId()).getName());
            }
            // å²—位信息(用于调岗等场景自动带出原岗位)
            List<Long> postIdList = empPostIdsMap.getOrDefault(emp.getId(), Collections.emptyList());
            vo.setPostIds(postIdList);
            if (CollUtil.isNotEmpty(postIdList)) {
                vo.setPostName(postIdList.stream()
                        .map(postMap::get)
                        .filter(Objects::nonNull)
                        .map(PostRespDTO::getName)
                        .collect(Collectors.joining(",")));
            }
            return vo;
        }).collect(Collectors.toList());
    }
    /**
     * è§£æžå‘˜å·¥çš„岗位ID字符串(JSON æ•°ç»„格式,如 [38] æˆ– [1,2])
     */
    /**
     * è§£æžå‘˜å·¥å²—位ID字符串(post_ids å­˜ JSON æ•°ç»„,如 [38] æˆ– [1,2])
     */
    private java.util.List<Long> parseEmployeePostIds(String postIds) {
        if (StrUtil.isBlank(postIds)) {
            return Collections.emptyList();
        }
        // post_ids å½¢å¦‚ "[38]" æˆ– "[1,2]":去掉中括号后按逗号解析
        return Arrays.stream(postIds.replace("[", "").replace("]", "").split(","))
                .map(String::trim)
                .filter(StrUtil::isNotBlank)
                .map(s -> {
                    try {
                        return Long.valueOf(s);
                    } catch (NumberFormatException ex) {
                        return null;
                    }
                })
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
    }
    @Override
    public HrmEmployeeDO getEmployeeByUserId(Long userId) {
@@ -384,8 +449,6 @@
    }
    @Override
    @LogRecord(type = HRM_EMPLOYEE_TYPE, subType = HRM_EMPLOYEE_IMPORT_SUB_TYPE, bizNo = "0",
            success = HRM_EMPLOYEE_IMPORT_SUCCESS)
    public HrmEmployeeImportRespVO importEmployeeList(List<HrmEmployeeImportExcelVO> importEmployees,
                                                      HrmEmployeeImportReqVO importReqVO) {
        // 1. æ ¡éªŒéžç©º
@@ -435,9 +498,6 @@
                respVO.getFailureNames().put(name, ex.getMessage());
            }
        }
        // è®°å½•导入结果汇总,供 @LogRecord çš„ success æ–‡æ¡ˆå¼•用
        OperateLogUtils.putImportSummary(respVO.getCreateNames(), respVO.getUpdateNames(),
                respVO.getFailureNames());
        return respVO;
    }
@@ -449,8 +509,9 @@
        if (StrUtil.isBlank(importEmployee.getPhone())) {
            throw exception(EMPLOYEE_IMPORT_FIELD_REQUIRED, "手机号");
        }
        if (importEmployee.getHireDate() == null) {
            throw exception(EMPLOYEE_IMPORT_FIELD_REQUIRED, "入职日期");
        LocalDate hireDate = parseImportDate(importEmployee.getHireDate());
        if (hireDate == null) {
            throw exception(EMPLOYEE_IMPORT_FIELD_REQUIRED, "入职日期(格式:2015 æˆ– 2015-01-01)");
        }
        HrmEmployeeSaveReqVO reqVO = new HrmEmployeeSaveReqVO();
        reqVO.setName(importEmployee.getName());
@@ -459,11 +520,11 @@
        reqVO.setDeptId(deptId);
        reqVO.setPostIds(CollUtil.isEmpty(postIds) ? null : new ArrayList<>(postIds));
        reqVO.setRoleIds(CollUtil.isEmpty(roleIds) ? null : new ArrayList<>(roleIds));
        reqVO.setHireDate(importEmployee.getHireDate());
        reqVO.setHireDate(hireDate);
        reqVO.setEmployeeStatus(importEmployee.getEmployeeStatus() != null
                ? importEmployee.getEmployeeStatus() : 1); // é»˜è®¤åœ¨èŒ
        reqVO.setEmail(importEmployee.getEmail());
        reqVO.setBirthday(importEmployee.getBirthday());
        reqVO.setBirthday(parseImportDate(importEmployee.getBirthday()));
        reqVO.setIdCard(importEmployee.getIdCard());
        reqVO.setNation(importEmployee.getNation());
        reqVO.setMarriageStatus(importEmployee.getMarriageStatus());
@@ -471,6 +532,11 @@
        reqVO.setEducation(importEmployee.getEducation());
        reqVO.setPoliticalStatus(importEmployee.getPoliticalStatus());
        reqVO.setRemark(importEmployee.getRemark());
        reqVO.setEmployeeCategory(importEmployee.getEmployeeCategory());
        reqVO.setBaseSalary(importEmployee.getBaseSalary());
        reqVO.setBankName(importEmployee.getBankName());
        reqVO.setBankAccount(importEmployee.getBankAccount());
        reqVO.setAddress(importEmployee.getAddress());
        return reqVO;
    }
@@ -526,4 +592,108 @@
        return roleIds;
    }
    /**
     * å®¹é”™è§£æžå¯¼å…¥æ—¥æœŸæ–‡æœ¬ï¼Œæ”¯æŒï¼š2015 / 2015å¹´ / 2015-01 / 2015-01-02 / 2015/1/2 / 2015.1.2 /
     * 2015-01-02 00:00:00 / 20150102,以及 Excel æ—¥æœŸåºåˆ—号;解析不出返回 null。
     */
    private static LocalDate parseImportDate(String raw) {
        if (StrUtil.isBlank(raw)) {
            return null;
        }
        String s = raw.trim();
        // åŽ»æŽ‰æ—¶é—´éƒ¨åˆ†
        int sp = s.indexOf(' ');
        if (sp > 0) {
            s = s.substring(0, sp);
        }
        // ä¸­æ–‡å¹´æœˆæ—¥ / æ–œæ  / ç‚¹ / ä¸‹åˆ’线 ç»Ÿä¸€æˆ - åˆ†éš”
        s = s.replace("å¹´", "-").replace("月", "-").replace("日", "")
                .replace("/", "-").replace(".", "-").replace("_", "-").trim();
        s = s.replaceAll("^-+|-+$", "");
        if (StrUtil.isBlank(s)) {
            return null;
        }
        try {
            if (s.matches("\\d+")) {
                int len = s.length();
                if (len == 4) { // 2015
                    return LocalDate.of(Integer.parseInt(s), 1, 1);
                }
                if (len == 5) { // Excel æ—¥æœŸåºåˆ—号(1900 æ—¥æœŸç³»ç»Ÿï¼ŒåŸºå‡† 1899-12-30)
                    return LocalDate.of(1899, 12, 30).plusDays(Long.parseLong(s));
                }
                if (len == 6) { // 201506
                    return LocalDate.of(Integer.parseInt(s.substring(0, 4)), Integer.parseInt(s.substring(4, 6)), 1);
                }
                if (len == 8) { // 20150601
                    return LocalDate.of(Integer.parseInt(s.substring(0, 4)), Integer.parseInt(s.substring(4, 6)), Integer.parseInt(s.substring(6, 8)));
                }
                return null;
            }
            String[] parts = s.split("-");
            if (parts.length == 2) { // 2015-06
                return LocalDate.of(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]), 1);
            }
            if (parts.length >= 3) { // 2015-06-01
                return LocalDate.of(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]), Integer.parseInt(parts[2]));
            }
        } catch (Exception e) {
            return null;
        }
        return null;
    }
    // ========== è½¬æ­£ ==========
    @Override
    public void regularizeEmployee(Long id) {
        validateEmployee(id);
        HrmEmployeeContractDO contract = findCurrentContract(id);
        // ä»…「劳动合同」可转正;劳务合同不转正
        if (contract == null || !Integer.valueOf(1).equals(contract.getContractType())) {
            throw exception(EMPLOYEE_REGULARIZE_NEED_LABOR_CONTRACT);
        }
        HrmEmployeeDO updateObj = new HrmEmployeeDO();
        updateObj.setId(id);
        updateObj.setEmployeeStatus(1); // 1-在职
        updateObj.setRegularDate(LocalDate.now());
        employeeMapper.updateById(updateObj);
    }
    @Override
    public List<HrmEmployeeDO> getRegularizeReminderList(Integer days) {
        int aheadDays = days == null ? 7 : days;
        LocalDate limit = LocalDate.now().plusDays(aheadDays);
        // è¯•用中的员工
        List<HrmEmployeeDO> trials = employeeMapper.selectList(
                new LambdaQueryWrapperX<HrmEmployeeDO>().eq(HrmEmployeeDO::getEmployeeStatus, 2));
        List<HrmEmployeeDO> result = new ArrayList<>();
        for (HrmEmployeeDO employee : trials) {
            HrmEmployeeContractDO contract = findCurrentContract(employee.getId());
            // ä»…劳动合同、且试用期即将到期
            if (contract == null || !Integer.valueOf(1).equals(contract.getContractType())) {
                continue;
            }
            LocalDate probationEnd = contract.getProbationEndDate();
            if (probationEnd != null && !probationEnd.isAfter(limit)) {
                result.add(employee);
            }
        }
        return result;
    }
    /** æŸ¥è¯¢å‘˜å·¥å½“前生效合同 */
    private HrmEmployeeContractDO findCurrentContract(Long employeeId) {
        List<HrmEmployeeContractDO> list = contractMapper.selectListByEmployee(employeeId);
        if (CollUtil.isEmpty(list)) {
            return null;
        }
        for (HrmEmployeeContractDO contract : list) {
            if (Boolean.TRUE.equals(contract.getIsCurrent())) {
                return contract;
            }
        }
        return null;
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/contract/ByteArrayMultipartFile.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,71 @@
package cn.iocoder.yudao.module.hrm.service.employee.contract;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
/**
 * å­—节数组版 MultipartFile:把内存中的字节包装成 MultipartFile,
 * ä¾¿äºŽå¤ç”¨ system çš„ SystemStorageBlobService.upload(...) å­˜å‚¨æ–‡ä»¶å¹¶æ‹¿åˆ° blobId。
 */
public class ByteArrayMultipartFile implements MultipartFile {
    private final String name;
    private final String originalFilename;
    private final String contentType;
    private final byte[] content;
    public ByteArrayMultipartFile(String name, String originalFilename, String contentType, byte[] content) {
        this.name = name;
        this.originalFilename = originalFilename;
        this.contentType = contentType;
        this.content = content == null ? new byte[0] : content;
    }
    @Override
    public String getName() {
        return name;
    }
    @Override
    public String getOriginalFilename() {
        return originalFilename;
    }
    @Override
    public String getContentType() {
        return contentType;
    }
    @Override
    public boolean isEmpty() {
        return content.length == 0;
    }
    @Override
    public long getSize() {
        return content.length;
    }
    @Override
    public byte[] getBytes() {
        return content;
    }
    @Override
    public InputStream getInputStream() {
        return new ByteArrayInputStream(content);
    }
    @Override
    public void transferTo(File dest) throws IOException {
        try (FileOutputStream fos = new FileOutputStream(dest)) {
            fos.write(content);
        }
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/contract/ContractDocumentService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
package cn.iocoder.yudao.module.hrm.service.employee.contract;
/**
 * å‘˜å·¥åˆåŒæ–‡æ¡£ç”Ÿæˆ Service
 *
 * æŒ‰åˆåŒ + å‘˜å·¥æ•°æ®ï¼Œå¥—用 Word æ¨¡æ¿ç”ŸæˆåŠ³åŠ¨åˆåŒ / åŠ³åŠ¡åˆåŒ docx。
 *
 * @author è¶…级管理员
 */
public interface ContractDocumentService {
    /**
     * ç”ŸæˆåˆåŒ docx
     *
     * @param contractId   åˆåŒç¼–号
     * @param templateType æ¨¡æ¿ç±»åž‹ï¼š1-劳动合同(简版) 2-劳动合同(标准) 3-劳务合同
     * @return docx å­—节
     */
    byte[] generateDocx(Long contractId, Integer templateType);
    /**
     * ç”Ÿæˆæ–‡ä»¶å
     */
    String buildFileName(Long contractId, Integer templateType);
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/contract/ContractDocumentServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,204 @@
package cn.iocoder.yudao.module.hrm.service.employee.contract;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeContractDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeContractMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeMapper;
import com.deepoove.poi.XWPFTemplate;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.HashMap;
import java.util.Map;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.hrm.enums.ErrorCodeConstants.CONTRACT_TEMPLATE_LOAD_FAILED;
import static cn.iocoder.yudao.module.hrm.enums.ErrorCodeConstants.CONTRACT_TEMPLATE_TYPE_NOT_SUPPORT;
/**
 * å‘˜å·¥åˆåŒæ–‡æ¡£ç”Ÿæˆ Service å®žçŽ°ç±»
 *
 * @author è¶…级管理员
 */
@Slf4j
@Service
public class ContractDocumentServiceImpl implements ContractDocumentService {
    /** åŠ³åŠ¨åˆåŒï¼ˆç®€ç‰ˆÂ·ç¤¾ä¿æŠ˜çŽ°ï¼‰ */
    public static final int TYPE_LABOR_SIMPLE = 1;
    /** åŠ³åŠ¨åˆåŒï¼ˆæ ‡å‡†Â·å®˜æ–¹åˆ¶å¼ï¼‰ */
    public static final int TYPE_LABOR_STANDARD = 2;
    /** åŠ³åŠ¡åˆåŒ */
    public static final int TYPE_SERVICE = 3;
    private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyyå¹´MM月dd日");
    private static final Map<Integer, String> TEMPLATE_PATHS = Map.of(
            TYPE_LABOR_SIMPLE, "template/contract/contract_labor_simple.docx",
            TYPE_LABOR_STANDARD, "template/contract/contract_labor_standard.docx",
            TYPE_SERVICE, "template/contract/contract_service.docx");
    private static final Map<Integer, String> TEMPLATE_NAMES = Map.of(
            TYPE_LABOR_SIMPLE, "劳动合同(简版)",
            TYPE_LABOR_STANDARD, "劳动合同(标准)",
            TYPE_SERVICE, "劳务合同");
    @Resource
    private HrmEmployeeContractMapper contractMapper;
    @Resource
    private HrmEmployeeMapper employeeMapper;
    @Override
    public byte[] generateDocx(Long contractId, Integer templateType) {
        HrmEmployeeContractDO contract = contractMapper.selectById(contractId);
        if (contract == null) {
            throw exception(CONTRACT_TEMPLATE_LOAD_FAILED);
        }
        HrmEmployeeDO employee = contract.getEmployeeId() == null ? null
                : employeeMapper.selectById(contract.getEmployeeId());
        Map<String, Object> vars = buildVariables(contract, employee);
        String path = TEMPLATE_PATHS.get(templateType);
        if (path == null) {
            throw exception(CONTRACT_TEMPLATE_TYPE_NOT_SUPPORT);
        }
        try (InputStream in = getClass().getClassLoader().getResourceAsStream(path)) {
            if (in == null) {
                throw exception(CONTRACT_TEMPLATE_LOAD_FAILED);
            }
            XWPFTemplate template = XWPFTemplate.compile(in).render(vars);
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            template.write(out);
            template.close();
            return out.toByteArray();
        } catch (Exception e) {
            log.error("[generateDocx][合同({}) æ¨¡æ¿({}) ç”Ÿæˆå¤±è´¥]", contractId, templateType, e);
            throw exception(CONTRACT_TEMPLATE_LOAD_FAILED);
        }
    }
    @Override
    public String buildFileName(Long contractId, Integer templateType) {
        HrmEmployeeContractDO contract = contractMapper.selectById(contractId);
        String no = contract != null && contract.getContractNo() != null ? contract.getContractNo() : String.valueOf(contractId);
        String name = TEMPLATE_NAMES.getOrDefault(templateType, "合同");
        return no + "-" + name + ".docx";
    }
    private Map<String, Object> buildVariables(HrmEmployeeContractDO contract, HrmEmployeeDO employee) {
        Map<String, Object> vars = new HashMap<>();
        // ç”²æ–¹ï¼ˆå…¬å¸ï¼‰â€”— ç›®å‰å–签约主体,其余留空后续可接入公司配置
        vars.put("companyName", nvl(contract.getSignCompany()));
        vars.put("companyAddress", "");
        vars.put("legalPerson", "");
        vars.put("companyPhone", "");
        vars.put("creditCode", "");
        // ä¹™æ–¹ï¼ˆå‘˜å·¥ï¼‰
        vars.put("employeeName", employee != null ? nvl(employee.getName()) : "");
        vars.put("gender", employee != null ? genderText(employee.getGender()) : "");
        vars.put("age", employee != null ? nvl(employee.getAge()) : "");
        vars.put("idCard", employee != null ? nvl(employee.getIdCard()) : "");
        vars.put("householdAddress", employee != null ? nvl(employee.getAddress()) : "");
        vars.put("address", employee != null ? nvl(employee.getAddress()) : "");
        vars.put("phone", employee != null ? nvl(employee.getPhone()) : "");
        vars.put("emergencyPhone", "");
        // åˆåŒ
        vars.put("contractNo", nvl(contract.getContractNo()));
        vars.put("postName", "");
        vars.put("postDuty", "");
        vars.put("workPlace", "");
        vars.put("contractStart", formatDate(contract.getStartDate()));
        vars.put("contractEnd", formatDate(contract.getEndDate()));
        vars.put("contractMonths", monthsBetween(contract.getStartDate(), contract.getEndDate()));
        vars.put("probationStart", formatDate(contract.getProbationStartDate()));
        vars.put("probationEnd", formatDate(contract.getProbationEndDate()));
        vars.put("probationMonths", monthsBetween(contract.getProbationStartDate(), contract.getProbationEndDate()));
        vars.put("probationDays", daysBetween(contract.getProbationStartDate(), contract.getProbationEndDate()));
        vars.put("birthday", employee != null ? formatDate(employee.getBirthday()) : "");
        // å¹´æœˆæ—¥ï¼ˆä¾›ã€Œ___å¹´___月___日」逐格填写)
        vars.put("contractStartY", y(contract.getStartDate()));
        vars.put("contractStartM", mo(contract.getStartDate()));
        vars.put("contractStartD", d(contract.getStartDate()));
        vars.put("contractEndY", y(contract.getEndDate()));
        vars.put("contractEndM", mo(contract.getEndDate()));
        vars.put("contractEndD", d(contract.getEndDate()));
        vars.put("probationStartY", y(contract.getProbationStartDate()));
        vars.put("probationStartM", mo(contract.getProbationStartDate()));
        vars.put("probationStartD", d(contract.getProbationStartDate()));
        vars.put("probationEndY", y(contract.getProbationEndDate()));
        vars.put("probationEndM", mo(contract.getProbationEndDate()));
        vars.put("probationEndD", d(contract.getProbationEndDate()));
        vars.put("probationSalary", nvl(contract.getProbationSalary()));
        vars.put("regularSalary", nvl(contract.getRegularSalary()));
        vars.put("baseSalary", nvl(contract.getRegularSalary()));
        vars.put("performanceSalary", "");
        vars.put("socialSecuritySalary", "");
        vars.put("signDate", formatDate(contract.getSignDate()));
        // åŠ³åŠ¡åˆåŒé‡Œçš„å¯é€‰é¡¹
        vars.put("payType", "一");
        vars.put("payDay", "");
        vars.put("workVolume", "");
        vars.put("workAmount", "");
        vars.put("piecePrice", "");
        vars.put("noticeCompensation", "");
        vars.put("copies", "两");
        vars.put("eachCopies", "一");
        return vars;
    }
    private static String nvl(Object o) {
        return o == null ? "" : o.toString();
    }
    private static String formatDate(LocalDate d) {
        return d == null ? "" : d.format(DATE_FMT);
    }
    private static String monthsBetween(LocalDate start, LocalDate end) {
        if (start == null || end == null) {
            return "";
        }
        long months = ChronoUnit.MONTHS.between(start, end);
        return String.valueOf(Math.max(months, 0));
    }
    private static String daysBetween(LocalDate start, LocalDate end) {
        if (start == null || end == null) {
            return "";
        }
        long days = ChronoUnit.DAYS.between(start, end);
        return String.valueOf(Math.max(days, 0));
    }
    private static String y(LocalDate date) {
        return date == null ? "" : String.format("%04d", date.getYear());
    }
    private static String mo(LocalDate date) {
        return date == null ? "" : String.format("%02d", date.getMonthValue());
    }
    private static String d(LocalDate date) {
        return date == null ? "" : String.format("%02d", date.getDayOfMonth());
    }
    private static String genderText(Integer sex) {
        if (sex == null) {
            return "";
        }
        return sex == 1 ? "男" : sex == 2 ? "女" : "";
    }
    // å…¼å®¹ BigDecimal ç›´æŽ¥ toString
    private static String nvl(BigDecimal v) {
        return v == null ? "" : v.toPlainString();
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/salary/HrmOvertimeWageConfigService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,24 @@
package cn.iocoder.yudao.module.hrm.service.salary;
import cn.iocoder.yudao.module.hrm.dal.dataobject.salary.HrmOvertimeWageConfigDO;
import java.util.List;
/**
 * å²—位加班工资配置 Service
 *
 * @author è¶…级管理员
 */
public interface HrmOvertimeWageConfigService {
    Long createOvertimeWageConfig(HrmOvertimeWageConfigDO config);
    void updateOvertimeWageConfig(HrmOvertimeWageConfigDO config);
    void deleteOvertimeWageConfig(Long id);
    HrmOvertimeWageConfigDO getOvertimeWageConfig(Long id);
    List<HrmOvertimeWageConfigDO> getOvertimeWageConfigList(Long postId, Integer status);
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/salary/HrmOvertimeWageConfigServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,83 @@
package cn.iocoder.yudao.module.hrm.service.salary;
import cn.iocoder.yudao.module.hrm.dal.dataobject.salary.HrmOvertimeWageConfigDO;
import cn.iocoder.yudao.module.hrm.dal.mysql.salary.HrmOvertimeWageConfigMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.util.List;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.hrm.enums.ErrorCodeConstants.OVERTIME_WAGE_CONFIG_NOT_EXISTS;
import static cn.iocoder.yudao.module.hrm.enums.ErrorCodeConstants.OVERTIME_WAGE_CONFIG_POST_EXISTS;
/**
 * å²—位加班工资配置 Service å®žçŽ°ç±»
 *
 * @author è¶…级管理员
 */
@Service
@Validated
public class HrmOvertimeWageConfigServiceImpl implements HrmOvertimeWageConfigService {
    /**
     * é»˜è®¤åŠ ç­æ—¶è–ªï¼ˆå…ƒ/小时)
     */
    private static final BigDecimal DEFAULT_HOURLY_RATE = new BigDecimal("20");
    @Resource
    private HrmOvertimeWageConfigMapper overtimeWageConfigMapper;
    @Override
    public Long createOvertimeWageConfig(HrmOvertimeWageConfigDO config) {
        validatePostUnique(null, config.getPostId());
        // æœªå¡«å†™æ—¶è–ªæ—¶ï¼Œä½¿ç”¨é»˜è®¤ 20 å…ƒ/小时
        if (config.getHourlyRate() == null) {
            config.setHourlyRate(DEFAULT_HOURLY_RATE);
        }
        overtimeWageConfigMapper.insert(config);
        return config.getId();
    }
    @Override
    public void updateOvertimeWageConfig(HrmOvertimeWageConfigDO config) {
        validateExists(config.getId());
        validatePostUnique(config.getId(), config.getPostId());
        overtimeWageConfigMapper.updateById(config);
    }
    @Override
    public void deleteOvertimeWageConfig(Long id) {
        validateExists(id);
        overtimeWageConfigMapper.deleteById(id);
    }
    @Override
    public HrmOvertimeWageConfigDO getOvertimeWageConfig(Long id) {
        return overtimeWageConfigMapper.selectById(id);
    }
    @Override
    public List<HrmOvertimeWageConfigDO> getOvertimeWageConfigList(Long postId, Integer status) {
        return overtimeWageConfigMapper.selectListByCondition(postId, status);
    }
    private void validateExists(Long id) {
        if (id == null || overtimeWageConfigMapper.selectById(id) == null) {
            throw exception(OVERTIME_WAGE_CONFIG_NOT_EXISTS);
        }
    }
    private void validatePostUnique(Long id, Long postId) {
        if (postId == null) {
            return;
        }
        HrmOvertimeWageConfigDO config = overtimeWageConfigMapper.selectByPostId(postId);
        if (config != null && !config.getId().equals(id)) {
            throw exception(OVERTIME_WAGE_CONFIG_POST_EXISTS);
        }
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/salary/HrmSalaryCalculationService.java
@@ -81,4 +81,11 @@
     */
    void saveSalaryCalculationList(List<HrmSalaryCalculationDO> calculationList);
    /**
     * ä¿®æ”¹å•条薪酬核算记录(仅待确认状态可修改,实发工资由后端重算)
     *
     * @param calculation æ ¸ç®—记录
     */
    void updateSalaryCalculation(HrmSalaryCalculationDO calculation);
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/salary/HrmSalaryCalculationServiceImpl.java
@@ -32,6 +32,11 @@
import cn.iocoder.yudao.module.hrm.dal.mysql.salary.HrmSalaryStructureMapper;
import cn.iocoder.yudao.module.hrm.dal.redis.HrmNoRedisDAO;
import cn.iocoder.yudao.module.system.api.dept.DeptApi;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import cn.iocoder.yudao.module.hrm.dal.dataobject.salary.HrmOvertimeWageConfigDO;
import cn.iocoder.yudao.module.hrm.dal.mysql.salary.HrmOvertimeWageConfigMapper;
import cn.iocoder.yudao.module.hrm.enums.HrmEmployeeCategoryEnum;
import cn.iocoder.yudao.module.hrm.enums.HrmOvertimeTypeEnum;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
@@ -97,6 +102,8 @@
    private HrmOvertimeApplicationMapper overtimeApplicationMapper;
    @Resource
    private HrmSalaryStructureMapper salaryStructureMapper;
    @Resource
    private HrmOvertimeWageConfigMapper overtimeWageConfigMapper;
    @Resource
    private HrmLeaveTypeConfigMapper leaveTypeConfigMapper;
    @Resource
@@ -228,7 +235,9 @@
                                                          HrmSocialSecuritySchemeDO scheme,
                                                          LocalDate effectiveDate) {
        // è®¡ç®—应发薪资
        BigDecimal baseSalary = employeeSalary.getBaseSalary() != null ? employeeSalary.getBaseSalary() : BigDecimal.ZERO;
        // åŸºæœ¬å·¥èµ„统一取「员工信息」中维护的基本薪资(薪酬档案里的 baseSalary ä¸å†ä½œä¸ºæ ¸ç®—来源,
        // é¿å…å‘˜å·¥ä¿¡æ¯ä¸Žè–ªé…¬æ¡£æ¡ˆä¸ä¸€è‡´æ—¶ï¼Œæ ¸ç®—结果与员工档案对不上)
        BigDecimal baseSalary = employee.getBaseSalary() != null ? employee.getBaseSalary() : BigDecimal.ZERO;
        BigDecimal performanceSalary = employeeSalary.getPerformanceSalary() != null ? employeeSalary.getPerformanceSalary() : BigDecimal.ZERO;
        BigDecimal mealAllowance = employeeSalary.getMealAllowance() != null ? employeeSalary.getMealAllowance() : BigDecimal.ZERO;
        BigDecimal transportAllowance = employeeSalary.getTransportAllowance() != null ? employeeSalary.getTransportAllowance() : BigDecimal.ZERO;
@@ -371,8 +380,16 @@
    }
    /**
     * æ±‡æ€»å½“月审批通过的加班申请。小时工资按基本工资 / 21.75 / 8 è®¡ç®—ï¼›
     * å·¥ä½œæ—¥é»˜è®¤ 1.5 å€ã€ä¼‘息日默认 2 å€ã€æ³•定节假日默认 3 å€ï¼Œå‡å¯ç”±è–ªé…¬ç»“构覆盖。
     * æ±‡æ€»å½“月审批通过的加班申请,计算加班工资与加班时长。
     *
     * <p>按「员工类别」差异化核算:
     * <ul>
     *     <li>技工:加班薪资按 1:1 æ ¸ç®—,与平时时薪一致(基本工资 / 21.75 / 8);</li>
     *     <li>普通员工:按岗位配置的固定加班时薪(默认 20 å…ƒ/小时)核算;</li>
     *     <li>管理:不计加班工资;</li>
     * </ul>
     * å‘½ä¸­å²—位配置时,按配置的最大加班时长上限对加班时长封顶;
     * æœªå‘½ä¸­ä»»ä½•岗位配置且非技工/管理时,回退旧逻辑(薪酬结构倍率 / åŠ ç­ç±»åž‹é»˜è®¤å€çŽ‡ï¼‰ã€‚
     */
    private OvertimeSalary calculateOvertimeSalary(HrmEmployeeDO employee, HrmEmployeeSalaryDO employeeSalary,
                                                    String period, BigDecimal baseSalary) {
@@ -385,36 +402,108 @@
        if (CollUtil.isEmpty(applications)) {
            return new OvertimeSalary(BigDecimal.ZERO, BigDecimal.ZERO);
        }
        Long structureId = employeeSalary.getSalaryStructureId() != null
                ? employeeSalary.getSalaryStructureId() : employee.getSalaryStructureId();
        HrmSalaryStructureDO structure = structureId == null ? null : salaryStructureMapper.selectById(structureId);
        BigDecimal workdayMultiplier = structure != null && structure.getOvertimePayRatio() != null
                ? structure.getOvertimePayRatio() : HrmOvertimeTypeEnum.WORKDAY.getSalaryMultiplier();
        BigDecimal restDayMultiplier = structure != null && structure.getRestDayOvertimePayRatio() != null
                ? structure.getRestDayOvertimePayRatio() : HrmOvertimeTypeEnum.REST_DAY.getSalaryMultiplier();
        BigDecimal legalHolidayMultiplier = structure != null && structure.getLegalHolidayOvertimePayRatio() != null
                ? structure.getLegalHolidayOvertimePayRatio() : HrmOvertimeTypeEnum.LEGAL_HOLIDAY.getSalaryMultiplier();
        BigDecimal hourlySalary = baseSalary.divide(new BigDecimal("21.75"), 8, RoundingMode.HALF_UP)
        // ç®¡ç†ç±»åˆ«ä¸è®¡åŠ ç­å·¥èµ„
        HrmEmployeeCategoryEnum category = HrmEmployeeCategoryEnum.valueOfCategory(employee.getEmployeeCategory());
        if (category == HrmEmployeeCategoryEnum.MANAGER) {
            return new OvertimeSalary(BigDecimal.ZERO, BigDecimal.ZERO);
        }
        // å‘˜å·¥å²—位命中的加班工资配置(取时薪最高、上限最宽的一条)
        OvertimeWageRule wageRule = resolveOvertimeWageRule(employee);
        // å¹³æ—¶æ—¶è–ª = åŸºæœ¬å·¥èµ„ / 21.75 / 8
        BigDecimal baseHourlySalary = baseSalary.divide(new BigDecimal("21.75"), 8, RoundingMode.HALF_UP)
                .divide(new BigDecimal("8"), 8, RoundingMode.HALF_UP);
        BigDecimal totalHours = BigDecimal.ZERO;
        BigDecimal totalPay = BigDecimal.ZERO;
        for (HrmOvertimeApplicationDO application : applications) {
            BigDecimal hours = application.getDuration() == null ? BigDecimal.ZERO : application.getDuration();
            HrmOvertimeTypeEnum type = HrmOvertimeTypeEnum.valueOfType(application.getOvertimeType());
            if (type == null || hours.signum() <= 0) {
                continue;
        if (category == HrmEmployeeCategoryEnum.TECHNICIAN || wageRule != null) {
            // æŠ€å·¥ï¼š1:1(与平时时薪一致);普通员工命中配置:按配置固定时薪,均不区分加班类型倍率
            BigDecimal hourlySalary = category == HrmEmployeeCategoryEnum.TECHNICIAN
                    ? baseHourlySalary : wageRule.hourlyRate();
            for (HrmOvertimeApplicationDO application : applications) {
                BigDecimal hours = application.getDuration() == null ? BigDecimal.ZERO : application.getDuration();
                HrmOvertimeTypeEnum type = HrmOvertimeTypeEnum.valueOfType(application.getOvertimeType());
                if (type == null || hours.signum() <= 0) {
                    continue;
                }
                totalHours = totalHours.add(hours);
            }
            BigDecimal multiplier = switch (type) {
                case WORKDAY -> workdayMultiplier;
                case REST_DAY -> restDayMultiplier;
                case LEGAL_HOLIDAY -> legalHolidayMultiplier;
            };
            totalHours = totalHours.add(hours);
            totalPay = totalPay.add(hourlySalary.multiply(hours).multiply(multiplier));
            // æŒ‰å²—位配置封顶加班时长
            BigDecimal paidHours = wageRule != null && wageRule.maxOvertimeHours() != null
                    && totalHours.compareTo(wageRule.maxOvertimeHours()) > 0
                    ? wageRule.maxOvertimeHours() : totalHours;
            totalPay = hourlySalary.multiply(paidHours);
        } else {
            // å…¼å®¹æ—§é€»è¾‘:无岗位配置、且非技工/管理
            Long structureId = employeeSalary.getSalaryStructureId() != null
                    ? employeeSalary.getSalaryStructureId() : employee.getSalaryStructureId();
            HrmSalaryStructureDO structure = structureId == null ? null : salaryStructureMapper.selectById(structureId);
            BigDecimal workdayMultiplier = structure != null && structure.getOvertimePayRatio() != null
                    ? structure.getOvertimePayRatio() : HrmOvertimeTypeEnum.WORKDAY.getSalaryMultiplier();
            BigDecimal restDayMultiplier = structure != null && structure.getRestDayOvertimePayRatio() != null
                    ? structure.getRestDayOvertimePayRatio() : HrmOvertimeTypeEnum.REST_DAY.getSalaryMultiplier();
            BigDecimal legalHolidayMultiplier = structure != null && structure.getLegalHolidayOvertimePayRatio() != null
                    ? structure.getLegalHolidayOvertimePayRatio() : HrmOvertimeTypeEnum.LEGAL_HOLIDAY.getSalaryMultiplier();
            for (HrmOvertimeApplicationDO application : applications) {
                BigDecimal hours = application.getDuration() == null ? BigDecimal.ZERO : application.getDuration();
                HrmOvertimeTypeEnum type = HrmOvertimeTypeEnum.valueOfType(application.getOvertimeType());
                if (type == null || hours.signum() <= 0) {
                    continue;
                }
                BigDecimal multiplier = switch (type) {
                    case WORKDAY -> workdayMultiplier;
                    case REST_DAY -> restDayMultiplier;
                    case LEGAL_HOLIDAY -> legalHolidayMultiplier;
                };
                totalHours = totalHours.add(hours);
                totalPay = totalPay.add(baseHourlySalary.multiply(hours).multiply(multiplier));
            }
        }
        return new OvertimeSalary(totalHours.setScale(1, RoundingMode.HALF_UP),
                totalPay.setScale(2, RoundingMode.HALF_UP));
    }
    /**
     * è§£æžå‘˜å·¥å²—位对应的加班工资规则:时薪取命中配置中的最大值;
     * ä¸Šé™å–“最宽”(存在未设置上限的配置则视为不限,否则取最大值)。
     */
    private OvertimeWageRule resolveOvertimeWageRule(HrmEmployeeDO employee) {
        if (StrUtil.isEmpty(employee.getPostIds())) {
            return null;
        }
        List<Long> postIds;
        try {
            postIds = JSONUtil.toList(employee.getPostIds(), Long.class);
        } catch (Exception ex) {
            return null;
        }
        if (CollUtil.isEmpty(postIds)) {
            return null;
        }
        List<HrmOvertimeWageConfigDO> configs = overtimeWageConfigMapper.selectEnabledListByPostIds(postIds);
        if (CollUtil.isEmpty(configs)) {
            return null;
        }
        BigDecimal hourlyRate = null;
        BigDecimal maxHours = null;
        boolean unlimited = false;
        for (HrmOvertimeWageConfigDO config : configs) {
            if (config.getHourlyRate() != null
                    && (hourlyRate == null || config.getHourlyRate().compareTo(hourlyRate) > 0)) {
                hourlyRate = config.getHourlyRate();
            }
            if (config.getMaxOvertimeHours() == null) {
                unlimited = true;
            } else if (!unlimited && (maxHours == null || config.getMaxOvertimeHours().compareTo(maxHours) > 0)) {
                maxHours = config.getMaxOvertimeHours();
            }
        }
        if (hourlyRate == null) {
            return null;
        }
        return new OvertimeWageRule(hourlyRate, unlimited ? null : maxHours);
    }
    private record OvertimeWageRule(BigDecimal hourlyRate, BigDecimal maxOvertimeHours) {}
    private record OvertimeSalary(BigDecimal hours, BigDecimal pay) {}
@@ -576,6 +665,66 @@
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateSalaryCalculation(HrmSalaryCalculationDO calculation) {
        HrmSalaryCalculationDO exist = salaryCalculationMapper.selectById(calculation.getId());
        if (exist == null) {
            throw exception(SALARY_CALCULATION_NOT_EXISTS);
        }
        // ä»…待确认状态可修改:已确认需先撤销;已发放不可改
        if (exist.getStatus() == null || !Objects.equals(exist.getStatus(), 0)) {
            throw exception(SALARY_CALCULATION_UPDATE_FAIL);
        }
        // ä»¥ä¸‹å­—段不允许通过修改接口变更,一律以库中为准
        calculation.setNo(exist.getNo());
        calculation.setPeriod(exist.getPeriod());
        calculation.setUserId(exist.getUserId());
        calculation.setDeptId(exist.getDeptId());
        calculation.setStatus(exist.getStatus());
        calculation.setConfirmTime(exist.getConfirmTime());
        calculation.setConfirmUserId(exist.getConfirmUserId());
        calculation.setPaymentTime(exist.getPaymentTime());
        // é‡‘额字段为 null æ—¶ç»Ÿä¸€æŒ‰ 0 å¤„理:MyBatis-Plus çš„ updateById é»˜è®¤å¿½ç•¥ null,
        // å¦åˆ™ä¼šå‡ºçŽ°ã€Œå®žå‘å·¥èµ„æŒ‰ 0 è®¡ç®—、但明细仍保留旧值」的不一致
        calculation.setBaseSalary(nvl(calculation.getBaseSalary()));
        calculation.setPerformanceSalary(nvl(calculation.getPerformanceSalary()));
        calculation.setOvertimePay(nvl(calculation.getOvertimePay()));
        calculation.setMealAllowance(nvl(calculation.getMealAllowance()));
        calculation.setTransportAllowance(nvl(calculation.getTransportAllowance()));
        calculation.setOtherAllowance(nvl(calculation.getOtherAllowance()));
        calculation.setSocialSecurityDeduction(nvl(calculation.getSocialSecurityDeduction()));
        calculation.setHousingFundDeduction(nvl(calculation.getHousingFundDeduction()));
        calculation.setTaxDeduction(nvl(calculation.getTaxDeduction()));
        calculation.setOtherDeduction(nvl(calculation.getOtherDeduction()));
        calculation.setLeaveDeduction(nvl(calculation.getLeaveDeduction()));
        // å®žå‘工资由后端重算,避免前端传入值与明细不一致
        calculation.setActualSalary(calcActualSalary(calculation));
        salaryCalculationMapper.updateById(calculation);
    }
    /**
     * è®¡ç®—实发工资 = æ”¶å…¥é¡¹åˆè®¡ - æ‰£é™¤é¡¹åˆè®¡ï¼Œä¿ç•™ä¸¤ä½å°æ•°
     */
    private static BigDecimal calcActualSalary(HrmSalaryCalculationDO c) {
        BigDecimal income = nvl(c.getBaseSalary())
                .add(nvl(c.getPerformanceSalary()))
                .add(nvl(c.getOvertimePay()))
                .add(nvl(c.getMealAllowance()))
                .add(nvl(c.getTransportAllowance()))
                .add(nvl(c.getOtherAllowance()));
        BigDecimal deduction = nvl(c.getSocialSecurityDeduction())
                .add(nvl(c.getHousingFundDeduction()))
                .add(nvl(c.getTaxDeduction()))
                .add(nvl(c.getOtherDeduction()))
                .add(nvl(c.getLeaveDeduction()));
        return income.subtract(deduction).setScale(2, RoundingMode.HALF_UP);
    }
    private static BigDecimal nvl(BigDecimal v) {
        return v == null ? BigDecimal.ZERO : v;
    }
    /**
     * å†…部生成发放台账方法(供自动生成调用)
     */
yudao-module-hrm/src/main/resources/template/contract/contract_labor_simple.docx
Binary files differ
yudao-module-hrm/src/main/resources/template/contract/contract_labor_standard.docx
Binary files differ
yudao-module-hrm/src/main/resources/template/contract/contract_service.docx
Binary files differ
yudao-module-hrm/src/main/resources/template/contract/original/labor_simple.docx
Binary files differ
yudao-module-hrm/src/main/resources/template/contract/original/labor_standard.docx
Binary files differ
yudao-module-hrm/src/main/resources/template/contract/original/retire.docx
Binary files differ
yudao-module-hrm/src/main/resources/template/contract/original/service.doc
Binary files differ