feat: 售后工单按条绑定部门与原因类型;新增加班工资管理;员工附件留档

- 售后工单明细新增 itemNo/deptId/reasonType/reason,按条生成独立单号、部门与原因类型
- 新增加班工资管理(岗位时薪/技工1:1/封顶)与员工类别字段
- 员工附件留档(recordType=hrm_employee,上传/回显)
- 含 SQL: sql/mysql/hrm_overtime_wage_config_init.sql、aftersales_ticket_item_enhance.sql
已添加10个文件
已修改12个文件
826 ■■■■■ 文件已修改
sql/mysql/aftersales_ticket_item_enhance.sql 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/hrm_overtime_wage_config_init.sql 89 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/AfterSaleTicketController.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketRespVO.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketSaveReqVO.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/dataobject/ticket/AfterSaleTicketItemDO.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleTicketServiceImpl.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/HrmEmployeeCategoryEnum.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeRespVO.java 9 ●●●●● 补丁 | 查看 | 原始文档 | 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/salary/HrmOvertimeWageConfigController.java 112 ●●●●● 补丁 | 查看 | 原始文档 | 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/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/mysql/salary/HrmOvertimeWageConfigMapper.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeServiceImpl.java 12 ●●●●● 补丁 | 查看 | 原始文档 | 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/HrmSalaryCalculationServiceImpl.java 135 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/aftersales_ticket_item_enhance.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,44 @@
-- =============================================
-- å”®åŽå·¥å•明细增强 SQL åˆå§‹åŒ–脚本(可重复执行)
-- éœ€æ±‚:售后退货/工单每条明细独立售后单号,每条选部门、原因类型并填具体原因
-- åŒ…含:after_sale_ticket_item åŠ å­—æ®µ + aftersale_reason_type å­—å…¸
-- =============================================
-- =============================================
-- 1. å·¥å•明细表新增字段(存在性判断,保证可重复执行)
-- =============================================
SET @col_exists := (SELECT COUNT(*) FROM information_schema.COLUMNS
                    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'after_sale_ticket_item' AND COLUMN_NAME = 'item_no');
SET @ddl := IF(@col_exists = 0,
    'ALTER TABLE `after_sale_ticket_item`
        ADD COLUMN `item_no` varchar(64) NULL COMMENT ''明细售后单号(工单号-序号)'' AFTER `ticket_id`,
        ADD COLUMN `dept_id` bigint NULL COMMENT ''部门ID,关联 system_dept.id'' AFTER `need_return`,
        ADD COLUMN `reason_type` tinyint NULL COMMENT ''原因类型,字典 aftersale_reason_type'' AFTER `dept_id`,
        ADD COLUMN `reason` varchar(500) NULL COMMENT ''具体原因'' AFTER `reason_type`',
    'SELECT 1');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- =============================================
-- 2. æ•°æ®å­—典:售后原因类型 aftersale_reason_type
--    ï¼ˆå–值可由业务在「系统管理 - å­—典管理」中自行维护)
-- =============================================
INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES
(1065307, '售后原因类型', 'aftersale_reason_type', 0, '售后工单明细原因分类', '', NOW(), '', NOW(), b'0')
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `type` = VALUES(`type`), `status` = VALUES(`status`), `deleted` = b'0';
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES
(1065352, 1, '外观缺陷', '1', 'aftersale_reason_type', 0, 'primary', NULL, '', NOW(), '', NOW(), b'0'),
(1065353, 2, '功能故障', '2', 'aftersale_reason_type', 0, 'warning', NULL, '', NOW(), '', NOW(), b'0'),
(1065354, 3, '尺寸偏差', '3', 'aftersale_reason_type', 0, 'info', NULL, '', NOW(), '', NOW(), b'0'),
(1065355, 4, '包装破损', '4', 'aftersale_reason_type', 0, 'warning', NULL, '', NOW(), '', NOW(), b'0'),
(1065356, 5, '物流损坏', '5', 'aftersale_reason_type', 0, 'danger', NULL, '', NOW(), '', NOW(), b'0'),
(1065357, 6, '缺件少发', '6', 'aftersale_reason_type', 0, 'danger', NULL, '', NOW(), '', NOW(), b'0'),
(1065358, 7, '规格型号不符', '7', 'aftersale_reason_type', 0, 'info', NULL, '', NOW(), '', NOW(), b'0'),
(1065359, 8, '使用不当', '8', 'aftersale_reason_type', 0, 'default', NULL, '', NOW(), '', NOW(), b'0'),
(1065360, 9, '其他', '9', 'aftersale_reason_type', 0, 'default', NULL, '', NOW(), '', NOW(), b'0')
ON DUPLICATE KEY UPDATE `sort` = VALUES(`sort`), `label` = VALUES(`label`), `value` = VALUES(`value`),
    `dict_type` = VALUES(`dict_type`), `status` = VALUES(`status`), `color_type` = VALUES(`color_type`), `deleted` = b'0';
SELECT '售后工单明细增强初始化完成' AS result;
sql/mysql/hrm_overtime_wage_config_init.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,89 @@
-- =============================================
-- HRM åŠ ç­å·¥èµ„ç®¡ç† SQL åˆå§‹åŒ–脚本(可重复执行)
-- åŒ…含:岗位加班工资配置表 + å‘˜å·¥ã€Œå‘˜å·¥ç±»åˆ«ã€å­—段 + æ•°æ®å­—å…¸ + èœå•/权限
-- å¯¹åº”需求:人力资源 > è–ªé…¬ç®¡ç† > åŠ ç­å·¥èµ„ç®¡ç†
--   - ä¸åŒå²—位加班工资不同(普通员工按岗位时薪;技工 1:1;管理不计)
--   - æŒ‰å·¥ç§è®¾ç½®æœ€å¤§åŠ ç­æ—¶é•¿ä¸Šé™
-- =============================================
-- =============================================
-- 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-普通员工,2-技工,3-管理)
-- è¯´æ˜Žï¼šADD COLUMN ä¸æ”¯æŒ IF NOT EXISTS,这里用 information_schema åšå­˜åœ¨æ€§åˆ¤æ–­ï¼Œä¿è¯å¯é‡å¤æ‰§è¡Œ
-- =============================================
SET @col_exists := (SELECT COUNT(*) FROM information_schema.COLUMNS
                    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'hrm_employee' AND COLUMN_NAME = 'employee_category');
SET @ddl := IF(@col_exists = 0,
    'ALTER TABLE `hrm_employee` ADD COLUMN `employee_category` tinyint NOT NULL DEFAULT 1 COMMENT ''员工类别:1-普通员工,2-技工,3-管理'' AFTER `employee_status`',
    'SELECT 1');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- =============================================
-- 3. æ•°æ®å­—典:员工类别 hrm_employee_category
-- =============================================
INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES
(1061300, 'HRM å‘˜å·¥ç±»åˆ«', 'hrm_employee_category', 0, '加班工资差异核算用', '', NOW(), '', NOW(), b'0')
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `type` = VALUES(`type`), `status` = VALUES(`status`);
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES
(1061301, 1, '普通员工', '1', 'hrm_employee_category', 0, 'primary', '按岗位配置的加班时薪核算', '', NOW(), '', NOW(), b'0'),
(1061302, 2, '技工', '2', 'hrm_employee_category', 0, 'warning', '加班按 1:1,与平时时薪一致', '', NOW(), '', NOW(), b'0'),
(1061303, 3, '管理', '3', 'hrm_employee_category', 0, 'default', '不计加班工资', '', NOW(), '', NOW(), b'0')
ON DUPLICATE KEY UPDATE `label` = VALUES(`label`), `value` = VALUES(`value`), `dict_type` = VALUES(`dict_type`), `status` = VALUES(`status`), `color_type` = VALUES(`color_type`);
-- =============================================
-- 4. èœå•与权限(挂载到 6020 è–ªé…¬ç®¡ç† ä¸‹ï¼‰
-- =============================================
INSERT INTO `system_menu`
(`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`,
 `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES
(6026, '加班工资管理', '', 2, 4, 6020, 'overtime-wage', 'ep:clock',
 'hrm/salary/overtime-wage/index', 'HrmOvertimeWage', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(60261, '加班工资查询', 'hrm:overtime-wage:query', 3, 1, 6026, '', '', '', NULL,
 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(60262, '加班工资新增', 'hrm:overtime-wage:create', 3, 2, 6026, '', '', '', NULL,
 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(60263, '加班工资修改', 'hrm:overtime-wage:update', 3, 3, 6026, '', '', '', NULL,
 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(60264, '加班工资删除', 'hrm:overtime-wage:delete', 3, 4, 6026, '', '', '', NULL,
 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0')
ON DUPLICATE KEY UPDATE
`name` = VALUES(`name`), `permission` = VALUES(`permission`), `parent_id` = VALUES(`parent_id`),
`path` = VALUES(`path`), `icon` = VALUES(`icon`), `component` = VALUES(`component`),
`component_name` = VALUES(`component_name`), `status` = VALUES(`status`), `deleted` = b'0';
-- æŽˆäºˆè¶…级管理员(role_id = 1)
INSERT INTO `system_role_menu`
(`role_id`, `menu_id`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT 1, menu_id, '1', NOW(), '1', NOW(), b'0'
FROM (
    SELECT 6026 AS menu_id UNION ALL SELECT 60261 UNION ALL SELECT 60262
    UNION ALL SELECT 60263 UNION ALL SELECT 60264
) menu_ids
WHERE NOT EXISTS (
    SELECT 1 FROM `system_role_menu` role_menu
    WHERE role_menu.`role_id` = 1 AND role_menu.`menu_id` = menu_ids.menu_id AND role_menu.`deleted` = b'0'
);
SELECT 'HRM åŠ ç­å·¥èµ„ç®¡ç†åˆå§‹åŒ–å®Œæˆ' AS result;
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/AfterSaleTicketController.java
@@ -13,6 +13,8 @@
import cn.iocoder.yudao.module.aftersales.service.ticket.AfterSaleTicketService;
import cn.iocoder.yudao.module.crm.api.customer.CrmCustomerApi;
import cn.iocoder.yudao.module.crm.api.customer.dto.CrmCustomerRespDTO;
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 io.swagger.v3.oas.annotations.Operation;
@@ -43,6 +45,8 @@
    private CrmCustomerApi customerApi;
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private DeptApi deptApi;
    @Resource
    private cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi storageAttachmentApi;
@@ -80,7 +84,17 @@
        // æ‰¹é‡åŠ è½½å…³è”åç§°
        List<AfterSaleTicketRespVO> enrichedList = buildTicketDetailList(List.of(ticket));
        AfterSaleTicketRespVO respVO = enrichedList.get(0);
        respVO.setItems(BeanUtils.toBean(items, AfterSaleTicketRespVO.Item.class));
        List<AfterSaleTicketRespVO.Item> itemVOs = BeanUtils.toBean(items, AfterSaleTicketRespVO.Item.class);
        // å›žæ˜¾æ˜Žç»†éƒ¨é—¨åç§°
        Set<Long> deptIds = convertSet(itemVOs, AfterSaleTicketRespVO.Item::getDeptId);
        Map<Long, DeptRespDTO> deptMap = deptApi.getDeptMap(deptIds);
        itemVOs.forEach(itemVO -> {
            DeptRespDTO dept = deptMap.get(itemVO.getDeptId());
            if (dept != null) {
                itemVO.setDeptName(dept.getName());
            }
        });
        respVO.setItems(itemVOs);
        respVO.setAttachmentList(storageAttachmentApi.listAttachments("aftersales_after_sale_ticket", id));
        return success(respVO);
    }
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketRespVO.java
@@ -148,6 +148,21 @@
        @Schema(description = "是否需要退货", example = "true")
        private Boolean needReturn;
        @Schema(description = "明细售后单号", example = "AS20240101000001-01")
        private String itemNo;
        @Schema(description = "部门ID", example = "1")
        private Long deptId;
        @Schema(description = "部门名称")
        private String deptName;
        @Schema(description = "原因类型", example = "1")
        private Integer reasonType;
        @Schema(description = "具体原因", example = "产品表面有划痕")
        private String reason;
        @Schema(description = "备注")
        private String remark;
    }
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketSaveReqVO.java
@@ -94,6 +94,17 @@
        @Schema(description = "是否需要退货", example = "true")
        private Boolean needReturn;
        @Schema(description = "部门ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
        @NotNull(message = "部门不能为空")
        private Long deptId;
        @Schema(description = "原因类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
        @NotNull(message = "原因类型不能为空")
        private Integer reasonType;
        @Schema(description = "具体原因", example = "产品表面有划痕")
        private String reason;
        @Schema(description = "备注")
        private String remark;
    }
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/dataobject/ticket/AfterSaleTicketItemDO.java
@@ -31,6 +31,10 @@
     */
    private Long ticketId;
    /**
     * æ˜Žç»†å”®åŽå•号(工单号-序号)
     */
    private String itemNo;
    /**
     * é”€å”®è®¢å•行编号
     */
    private Long saleOrderItemId;
@@ -71,6 +75,20 @@
     */
    private Boolean needReturn;
    /**
     * éƒ¨é—¨ID,关联 system_dept.id
     */
    private Long deptId;
    /**
     * åŽŸå› ç±»åž‹
     *
     * å­—å…¸ aftersale_reason_type
     */
    private Integer reasonType;
    /**
     * å…·ä½“原因
     */
    private String reason;
    /**
     * å¤‡æ³¨
     */
    private String remark;
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleTicketServiceImpl.java
@@ -67,13 +67,14 @@
                .setOwnerUserId(userId);
        ticketMapper.insert(ticket);
        // 3. æ’入明细行
        // 3. æ’入明细行(每条生成独立售后单号:工单号-序号)
        if (CollUtil.isNotEmpty(createReqVO.getItems())) {
            createReqVO.getItems().forEach(item -> {
                AfterSaleTicketItemDO itemDO = BeanUtils.toBean(item, AfterSaleTicketItemDO.class);
            for (int i = 0; i < createReqVO.getItems().size(); i++) {
                AfterSaleTicketItemDO itemDO = BeanUtils.toBean(createReqVO.getItems().get(i), AfterSaleTicketItemDO.class);
                itemDO.setTicketId(ticket.getId());
                itemDO.setItemNo(buildItemNo(no, i + 1));
                ticketItemMapper.insert(itemDO);
            });
            }
        }
        // 4. ç»‘定附件
@@ -102,14 +103,15 @@
        AfterSaleTicketDO updateObj = BeanUtils.toBean(updateReqVO, AfterSaleTicketDO.class);
        ticketMapper.updateById(updateObj);
        // 3. æ›´æ–°æ˜Žç»†è¡Œï¼ˆå…ˆåˆ åŽå¢žï¼‰
        // 3. æ›´æ–°æ˜Žç»†è¡Œï¼ˆå…ˆåˆ åŽå¢žï¼Œæ¯æ¡ç”Ÿæˆç‹¬ç«‹å”®åŽå•号:工单号-序号)
        ticketItemMapper.deleteByTicketId(ticket.getId());
        if (CollUtil.isNotEmpty(updateReqVO.getItems())) {
            updateReqVO.getItems().forEach(item -> {
                AfterSaleTicketItemDO itemDO = BeanUtils.toBean(item, AfterSaleTicketItemDO.class);
            for (int i = 0; i < updateReqVO.getItems().size(); i++) {
                AfterSaleTicketItemDO itemDO = BeanUtils.toBean(updateReqVO.getItems().get(i), AfterSaleTicketItemDO.class);
                itemDO.setTicketId(ticket.getId());
                itemDO.setItemNo(buildItemNo(ticket.getNo(), i + 1));
                ticketItemMapper.insert(itemDO);
            });
            }
        }
        // 4. æ›´æ–°é™„ä»¶
@@ -204,6 +206,11 @@
    // ========== å†…部方法 ==========
    /** ç”Ÿæˆæ˜Žç»†å”®åŽå•号:工单号-两位序号 */
    private String buildItemNo(String ticketNo, int index) {
        return ticketNo + "-" + String.format("%02d", index);
    }
    private AfterSaleTicketDO validateTicketExists(Long id) {
        AfterSaleTicketDO ticket = ticketMapper.selectById(id);
        if (ticket == null) {
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java
@@ -123,6 +123,8 @@
    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, "该岗位已配置加班工资,请勿重复添加");
    // ========== ç­æ¬¡ç®¡ç† 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/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/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;
@@ -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;
@@ -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/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/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/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/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/employee/HrmEmployeeServiceImpl.java
@@ -31,6 +31,8 @@
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;
@@ -83,6 +85,8 @@
    @Resource
    private RoleApi roleApi;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @Resource
    @Lazy // è‡ªæ³¨å…¥éœ€å»¶è¿ŸåŠ è½½ï¼Œå¦åˆ™ bean è¢«ä»£ç†åŒ…装后注入原始版本导致启动失败
    private HrmEmployeeService self;
@@ -129,6 +133,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 -> {
@@ -210,6 +216,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) {
@@ -257,6 +265,8 @@
        educationMapper.deleteByEmployeeId(id);
        workHistoryMapper.deleteByEmployeeId(id);
        emergencyContactMapper.deleteByEmployeeId(id);
        // åˆ é™¤é™„ä»¶
        storageAttachmentApi.deleteAttachmentsByRecord("hrm_employee", id);
        // åˆ é™¤å‘˜å·¥
        employeeMapper.deleteById(id);
    }
@@ -311,6 +321,8 @@
                respVO.setRoleNames(roles.stream().map(RoleRespDTO::getName).collect(Collectors.joining(",")));
            }
        }
        // å¡«å……附件
        respVO.setAttachmentList(storageAttachmentApi.listAttachments("hrm_employee", id));
        return respVO;
    }
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/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
@@ -371,8 +378,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,37 +400,109 @@
        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) {}
    private record ScheduleStat(int scheduledDays, BigDecimal totalWorkHours) {}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java
@@ -55,6 +55,7 @@
    HRM_RESIGNATION_APPLICATION("hrm_resignation_application", "HRM ç¦»èŒç”³è¯·"),
    HRM_ATTENDANCE_EXCEPTION("hrm_attendance_exception", "HRM è€ƒå‹¤å¼‚常"),
    HRM_EMPLOYEE_CONTRACT("hrm_employee_contract", "HRM å‘˜å·¥åˆåŒ"),
    HRM_EMPLOYEE("hrm_employee", "HRM å‘˜å·¥"),
    // ===== å”®åŽ =====
    AFTER_SALE_TICKET("aftersales_after_sale_ticket", "售后工单"),