gaoluyang
5 小时以前 b5ca4fa6acd8c7ea8ed1400b133eb2eb6b5cb8ba
feat(hrm): 社保公积金方案支持按部门绑定,修比例输入精度

原方案只能逐个员工在社保公积金档案里绑定,改为方案挂「适用部门」,薪资核算按
员工所在部门取方案;员工档案保留为个别覆盖(优先级最高)。

- 新增 hrm_social_security_scheme_dept 关联表 + 方案表两个默认缴费基数列
- 取数优先级:员工档案方案 > 部门绑定方案 > 全局默认方案(未绑部门) > 代码默认比例
- 基数优先级:档案 > 方案默认社保/公积金基数 > 员工基本工资
- 一个部门只能绑一个方案,冲突时返回含双方名称与处理动作的精确报错
- 单元内按 deptId 缓存方案解析结果,避免逐员工重复查库

改动前后 mom-wtxc 2026-10 试算逐行一致(存量方案未绑部门时行为不变)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
已添加4个文件
已修改9个文件
616 ■■■■■ 文件已修改
docs/hrm_social_security_scheme_dept_frontend_integration.md 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/config_export_all_20260928.sql 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/hrm_social_security_scheme_dept.sql 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/HrmSocialSecuritySchemeController.java 52 ●●●●● 补丁 | 查看 | 原始文档 | 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 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/salary/HrmSocialSecuritySchemeDO.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/salary/HrmSocialSecuritySchemeDeptDO.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/salary/HrmSocialSecuritySchemeDeptMapper.java 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/salary/HrmSalaryCalculationServiceImpl.java 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/salary/HrmSocialSecuritySchemeService.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/salary/HrmSocialSecuritySchemeServiceImpl.java 157 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/hrm_social_security_scheme_dept_frontend_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,93 @@
# ç¤¾ä¿å…¬ç§¯é‡‘方案改为「按部门绑定」 - å‰ç«¯è”调方案
## å˜æ›´èƒŒæ™¯
原来社保公积金方案只能一个员工一个员工地在「员工社保公积金档案」里绑定,几百号人逐个维护不现实。
现在方案本身可以挂「适用部门」,薪资核算时按员工所在部门取方案;原来的员工档案保留作个别覆盖。
## æ¶‰åŠé¡µé¢
- ç¤¾ä¿å…¬ç§¯é‡‘方案(`/hrm/salary/social-security-scheme`):表单新增「适用部门」「默认社保基数」「默认公积金基数」,列表新增「适用部门」列
- è–ªèµ„核算(`/hrm/salary/calculation`):无需改造,接口入参本来就有 `deptId`,行为自动生效
- å‘˜å·¥ç¤¾ä¿å…¬ç§¯é‡‘档案:不改,继续作为个别覆盖入口
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| POST | /hrm/salary/social-security-scheme/create | æ–°å¢žæ–¹æ¡ˆï¼Œå¯å¸¦é€‚用部门与默认基数 |
| PUT | /hrm/salary/social-security-scheme/update | ä¿®æ”¹æ–¹æ¡ˆï¼Œé€‚用部门为整组替换(传空数组即解除全部部门) |
| GET | /hrm/salary/social-security-scheme/get | è¯¦æƒ…,返回 `deptIds` / `deptNames` |
| GET | /hrm/salary/social-security-scheme/list | åˆ—表,返回 `deptIds` / `deptNames` |
| GET | /hrm/salary/calculation/preview | è¯•算,入参 `period` + å¯é€‰ `deptId` |
**请求参数新增(create / update):**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| deptIds | Long[] | å¦ | é€‚用部门ID列表;空数组或不传 = ä¸é™å®šéƒ¨é—¨ï¼ˆå…¨å±€é»˜è®¤æ–¹æ¡ˆï¼‰ |
| defaultSocialSecurityBase | Decimal | å¦ | é»˜è®¤ç¤¾ä¿ç¼´è´¹åŸºæ•°ï¼›ç•™ç©ºåˆ™ç”¨å‘˜å·¥åŸºæœ¬å·¥èµ„ |
| defaultHousingFundBase | Decimal | å¦ | é»˜è®¤å…¬ç§¯é‡‘缴费基数;留空或 0 åˆ™ç”¨ç¤¾ä¿åŸºæ•° |
**响应字段新增(get / list):**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| deptIds | Long[] | é€‚用部门ID列表,未绑定返回 `[]` |
| deptNames | String | é€‚用部门名称,多个用「、」连接;未绑定返回空串。名称解析失败时回退 `ID=xxx` |
| defaultSocialSecurityBase | Decimal | é»˜è®¤ç¤¾ä¿ç¼´è´¹åŸºæ•°ï¼Œå¯ä¸º null |
| defaultHousingFundBase | Decimal | é»˜è®¤å…¬ç§¯é‡‘缴费基数,可为 null |
**响应示例:**
```json
{
  "code": 0,
  "data": {
    "id": 3,
    "name": "2026年宁夏社保标准",
    "effectiveDate": "2026-01-01",
    "deptIds": [119, 129],
    "deptNames": "生产部、仓储部",
    "defaultSocialSecurityBase": 5023.00,
    "defaultHousingFundBase": 0.00,
    "status": 0
  }
}
```
**部门冲突报错(code 1030005027):**
```json
{
  "code": 1030005027,
  "data": null,
  "msg": "部门【生产部】已绑定方案【2025年宁夏社保标准】,无法再绑定到方案【2026年宁夏社保标准】。请先在原方案上取消该部门,或改选其它部门"
}
```
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| é€‚用部门 | æ–¹æ¡ˆè¡¨å•(多选树) | ä¸é€‰è¡¨ç¤ºå…¨å±€é»˜è®¤æ–¹æ¡ˆï¼Œplaceholder å·²å†™æ˜Žã€Œåªå¯¹æ‰€é€‰éƒ¨é—¨æœ¬çº§çš„员工生效」 |
| é€‚用部门 | æ–¹æ¡ˆåˆ—表 | ç›´æŽ¥å±•示后端 `deptNames`;为空时显示「全局默认」标签 |
| é»˜è®¤ç¤¾ä¿åŸºæ•° / é»˜è®¤å…¬ç§¯é‡‘基数 | æ–¹æ¡ˆè¡¨å• | `InputNumber`,`min: 0`、`precision: 2`,允许留空 |
| ç¤¾ä¿/公积金扣款 | æ ¸ç®—列表、试算弹窗 | æ— éœ€æ”¹åŠ¨ï¼ŒåŽç«¯æŒ‰æ–°ä¼˜å…ˆçº§å–æ•° |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| æ¯”例来源优先级 | â‘  å‘˜å·¥ç¤¾ä¿æ¡£æ¡ˆæŒ‡å®šçš„æ–¹æ¡ˆ â‘¡ å‘˜å·¥æ‰€åœ¨éƒ¨é—¨ç»‘定的启用方案(生效日期 â‰¤ æ ¸ç®—期间,取最新) â‘¢ æœªç»‘定任何部门的启用方案中生效日期最新者(全局默认) â‘£ éƒ½æ²¡æœ‰åˆ™ç”¨ä»£ç é»˜è®¤æ¯”例 |
| ç¼´è´¹åŸºæ•°ä¼˜å…ˆçº§ | ç¤¾ä¿åŸºæ•°ï¼šæ¡£æ¡ˆ â†’ æ–¹æ¡ˆé»˜è®¤ç¤¾ä¿åŸºæ•° â†’ å‘˜å·¥åŸºæœ¬å·¥èµ„;公积金基数:档案 â†’ æ–¹æ¡ˆé»˜è®¤å…¬ç§¯é‡‘基数(>0 æ‰ç”¨ï¼‰â†’ ç¤¾ä¿åŸºæ•° |
| éƒ¨é—¨è¦†ç›–范围 | åªè¦†ç›–所选部门**本级**的员工,不含子部门 |
| ä¸€ä¸ªéƒ¨é—¨çš„归属 | ä¸€ä¸ªéƒ¨é—¨åŒæ—¶åªèƒ½ç»‘定一个方案;保存时若该部门已被别的方案占用,返回上面那条精确报错 |
| åˆ é™¤æ–¹æ¡ˆ | æ–¹æ¡ˆåˆ é™¤æ—¶å…¶éƒ¨é—¨å…³è”一并解除,部门随之空闲 |
## æ³¨æ„äº‹é¡¹
- æ–¹æ¡ˆçš„「适用部门」是**整组替换**语义:编辑时要把当前绑定的部门全部回显到多选框里再提交,不能只提交本次新增的部门。
- å­˜é‡æ–¹æ¡ˆé»˜è®¤æ²¡æœ‰ç»‘定任何部门,因此都是「全局默认」候选;此时核算结果与改造前完全一致(万通 23 åå‘˜å·¥å£å¾„已逐行核对一致),要生效必须先给方案填「默认缴费基数」并把方案绑到部门。
- å‘˜å·¥ç¤¾ä¿å…¬ç§¯é‡‘档案里已指定方案的员工,永远优先走档案,方案绑定部门不会改变他们的金额。
- æ¯”例输入框走的是「百分比显示、小数存储」:界面填 8.5 è¡¨ç¤º 8.5%,落库 0.085;显示保留 2 ä½ç™¾åˆ†æ¯”小数,与数据库 `decimal(5,4)` ç²¾åº¦ä¸€ä¸€å¯¹åº”。个税税率配置页的表单与列表同口径。
docs/sql/config_export_all_20260928.sql
@@ -3674,6 +3674,8 @@
  `maternity_ratio` decimal(5,4) NOT NULL DEFAULT '0.0050' COMMENT '生育保险企业缴纳比例',
  `housing_fund_company_ratio` decimal(5,4) NOT NULL DEFAULT '0.1200' COMMENT '公积金企业缴纳比例',
  `housing_fund_personal_ratio` decimal(5,4) NOT NULL DEFAULT '0.1200' COMMENT '公积金个人缴纳比例',
  `default_social_security_base` decimal(12,2) DEFAULT NULL COMMENT '默认社保缴费基数(空则用员工基本工资)',
  `default_housing_fund_base` decimal(12,2) DEFAULT NULL COMMENT '默认公积金缴费基数(空或 0 åˆ™ç”¨ç¤¾ä¿ç¼´è´¹åŸºæ•°ï¼‰',
  `status` tinyint NOT NULL DEFAULT '0' COMMENT '状态:0-启用,1-禁用',
  `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
  `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者',
@@ -3685,6 +3687,22 @@
  KEY `idx_effective_date` (`effective_date`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='HRM社保公积金方案表';
-- ============ è¡¨ï¼šhrm_social_security_scheme_dept(仅 DDL,无数据) ============
DROP TABLE IF EXISTS `hrm_social_security_scheme_dept`;
CREATE TABLE `hrm_social_security_scheme_dept` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
  `scheme_id` bigint NOT NULL COMMENT '社保公积金方案ID',
  `dept_id` bigint NOT NULL COMMENT '适用部门ID',
  `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci 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`) USING BTREE,
  KEY `idx_scheme_id` (`scheme_id`) USING BTREE,
  KEY `idx_dept_id` (`dept_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='HRM社保公积金方案适用部门表';
-- ============ è¡¨ï¼šhrm_tax_rate_config(仅 DDL,无数据) ============
DROP TABLE IF EXISTS `hrm_tax_rate_config`;
CREATE TABLE `hrm_tax_rate_config` (
sql/mysql/hrm_social_security_scheme_dept.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,43 @@
-- ç¤¾ä¿å…¬ç§¯é‡‘方案:改为「按部门绑定」
-- 1) æ–¹æ¡ˆè¡¨å¢žåŠ é»˜è®¤ç¼´è´¹åŸºæ•°ï¼ˆä¸é€‰éƒ¨é—¨çš„å‘˜å·¥æŒ‰å®ƒè®¡ç®—ï¼‰
-- 2) æ–°å¢žã€Œæ–¹æ¡ˆ - é€‚用部门」关联表(一个方案可绑定多个部门;一个部门只能绑定一个方案,唯一性在服务层校验)
-- è¯´æ˜Žï¼šALTER TABLE ADD COLUMN ä¸æ”¯æŒ IF NOT EXISTS,这里先查 information_schema å†å†³å®šæ˜¯å¦æ‰§è¡Œ
SET @db := DATABASE();
-- 1.1 é»˜è®¤ç¤¾ä¿ç¼´è´¹åŸºæ•°
SET @exists := (SELECT COUNT(*) FROM information_schema.columns
                WHERE table_schema = @db AND table_name = 'hrm_social_security_scheme'
                  AND column_name = 'default_social_security_base');
SET @sql := IF(@exists = 0,
    'ALTER TABLE `hrm_social_security_scheme` ADD COLUMN `default_social_security_base` decimal(12,2) DEFAULT NULL COMMENT ''默认社保缴费基数(空则用员工基本工资)'' AFTER `housing_fund_personal_ratio`',
    'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 1.2 é»˜è®¤å…¬ç§¯é‡‘缴费基数
SET @exists := (SELECT COUNT(*) FROM information_schema.columns
                WHERE table_schema = @db AND table_name = 'hrm_social_security_scheme'
                  AND column_name = 'default_housing_fund_base');
SET @sql := IF(@exists = 0,
    'ALTER TABLE `hrm_social_security_scheme` ADD COLUMN `default_housing_fund_base` decimal(12,2) DEFAULT NULL COMMENT ''默认公积金缴费基数(空或 0 åˆ™ç”¨ç¤¾ä¿ç¼´è´¹åŸºæ•°ï¼‰'' AFTER `default_social_security_base`',
    'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 2. æ–¹æ¡ˆ - é€‚用部门关联表
CREATE TABLE IF NOT EXISTS `hrm_social_security_scheme_dept` (
    `id`          bigint      NOT NULL AUTO_INCREMENT COMMENT '主键',
    `scheme_id`   bigint      NOT NULL COMMENT '社保公积金方案ID',
    `dept_id`     bigint      NOT NULL COMMENT '适用部门ID',
    `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`),
    KEY `idx_scheme_id` (`scheme_id`),
    KEY `idx_dept_id` (`dept_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT '社保公积金方案适用部门';
-- å›žæ»šï¼ˆå¦‚需):
-- ALTER TABLE `hrm_social_security_scheme` DROP COLUMN `default_social_security_base`, DROP COLUMN `default_housing_fund_base`;
-- DROP TABLE IF EXISTS `hrm_social_security_scheme_dept`;
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java
@@ -124,6 +124,7 @@
    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 SOCIAL_SECURITY_SCHEME_DEPT_EXISTS = new ErrorCode(1_030_005_027, "部门【{}】已绑定方案【{}】,无法再绑定到方案【{}】。请先在原方案上取消该部门,或改选其它部门");
    // ========== ç­æ¬¡ç®¡ç† 1-030-009-000 ==========
    ErrorCode SHIFT_NOT_EXISTS = new ErrorCode(1_030_009_000, "班次不存在");
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/HrmSocialSecuritySchemeController.java
@@ -1,11 +1,14 @@
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.HrmSocialSecuritySchemeRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.salary.vo.HrmSocialSecuritySchemeSaveReqVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.salary.HrmSocialSecuritySchemeDO;
import cn.iocoder.yudao.module.hrm.service.salary.HrmSocialSecuritySchemeService;
import cn.iocoder.yudao.module.system.api.dept.DeptApi;
import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -15,7 +18,10 @@
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@@ -28,20 +34,21 @@
    @Resource
    private HrmSocialSecuritySchemeService schemeService;
    @Resource
    private DeptApi deptApi;
    @PostMapping("/create")
    @Operation(summary = "创建社保公积金方案")
    @PreAuthorize("@ss.hasPermission('hrm:social-security-scheme:create')")
    public CommonResult<Long> createScheme(@Valid @RequestBody HrmSocialSecuritySchemeSaveReqVO createReqVO) {
        HrmSocialSecuritySchemeDO scheme = BeanUtils.toBean(createReqVO, HrmSocialSecuritySchemeDO.class);
        return success(schemeService.createScheme(scheme));
        return success(schemeService.createScheme(createReqVO));
    }
    @PutMapping("/update")
    @Operation(summary = "更新社保公积金方案")
    @PreAuthorize("@ss.hasPermission('hrm:social-security-scheme:update')")
    public CommonResult<Boolean> updateScheme(@Valid @RequestBody HrmSocialSecuritySchemeSaveReqVO updateReqVO) {
        HrmSocialSecuritySchemeDO scheme = BeanUtils.toBean(updateReqVO, HrmSocialSecuritySchemeDO.class);
        schemeService.updateScheme(scheme);
        schemeService.updateScheme(updateReqVO);
        return success(true);
    }
@@ -60,7 +67,13 @@
    @PreAuthorize("@ss.hasPermission('hrm:social-security-scheme:query')")
    public CommonResult<HrmSocialSecuritySchemeRespVO> getScheme(@RequestParam("id") Long id) {
        HrmSocialSecuritySchemeDO scheme = schemeService.getScheme(id);
        return success(BeanUtils.toBean(scheme, HrmSocialSecuritySchemeRespVO.class));
        if (scheme == null) {
            return success(null);
        }
        HrmSocialSecuritySchemeRespVO respVO = BeanUtils.toBean(scheme, HrmSocialSecuritySchemeRespVO.class);
        // å¡«å……适用部门
        fillDeptInfo(List.of(respVO));
        return success(respVO);
    }
    @GetMapping("/list")
@@ -70,7 +83,34 @@
    public CommonResult<List<HrmSocialSecuritySchemeRespVO>> getSchemeList(
            @RequestParam(value = "status", required = false) Integer status) {
        List<HrmSocialSecuritySchemeDO> list = schemeService.getSchemeList(status);
        return success(BeanUtils.toBean(list, HrmSocialSecuritySchemeRespVO.class));
        List<HrmSocialSecuritySchemeRespVO> respList = BeanUtils.toBean(list, HrmSocialSecuritySchemeRespVO.class);
        // å¡«å……适用部门
        fillDeptInfo(respList);
        return success(respList);
    }
    /**
     * å¡«å……方案的适用部门ID与部门名称
     */
    private void fillDeptInfo(List<HrmSocialSecuritySchemeRespVO> list) {
        if (CollUtil.isEmpty(list)) {
            return;
        }
        List<Long> schemeIds = list.stream().map(HrmSocialSecuritySchemeRespVO::getId).toList();
        Map<Long, List<Long>> schemeDeptIdMap = schemeService.getSchemeDeptIdMap(schemeIds);
        List<Long> allDeptIds = schemeDeptIdMap.values().stream().flatMap(List::stream).distinct().toList();
        Map<Long, DeptRespDTO> deptMap = CollUtil.isEmpty(allDeptIds)
                ? Collections.emptyMap() : deptApi.getDeptMap(allDeptIds);
        for (HrmSocialSecuritySchemeRespVO respVO : list) {
            List<Long> deptIds = schemeDeptIdMap.getOrDefault(respVO.getId(), Collections.emptyList());
            respVO.setDeptIds(deptIds);
            respVO.setDeptNames(deptIds.stream()
                    .map(deptId -> {
                        DeptRespDTO dept = deptMap.get(deptId);
                        return dept != null ? dept.getName() : "ID=" + deptId;
                    })
                    .collect(Collectors.joining("、")));
        }
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/vo/HrmSocialSecuritySchemeRespVO.java
@@ -6,6 +6,7 @@
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "管理后台 - ç¤¾ä¿å…¬ç§¯é‡‘方案 Response VO")
@Data
@@ -56,6 +57,19 @@
    @Schema(description = "公积金个人缴纳比例", example = "0.12")
    private BigDecimal housingFundPersonalRatio;
    // ========== ç¼´è´¹åŸºæ•° ==========
    @Schema(description = "默认社保缴费基数(空则用员工基本工资)", example = "5023.00")
    private BigDecimal defaultSocialSecurityBase;
    @Schema(description = "默认公积金缴费基数(空或 0 åˆ™ç”¨ç¤¾ä¿ç¼´è´¹åŸºæ•°ï¼‰", example = "5023.00")
    private BigDecimal defaultHousingFundBase;
    @Schema(description = "适用部门ID列表(为空表示全局默认方案)", example = "[119,129]")
    private List<Long> deptIds;
    @Schema(description = "适用部门名称(多个用、分隔,为空表示全局默认)", example = "生产部、仓储部")
    private String deptNames;
    @Schema(description = "状态:0-启用,1-禁用", example = "0")
    private Integer status;
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/vo/HrmSocialSecuritySchemeSaveReqVO.java
@@ -7,6 +7,7 @@
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
@Schema(description = "管理后台 - ç¤¾ä¿å…¬ç§¯é‡‘方案新增/修改 Request VO")
@Data
@@ -59,6 +60,16 @@
    @Schema(description = "公积金个人缴纳比例", example = "0.12")
    private BigDecimal housingFundPersonalRatio;
    // ========== ç¼´è´¹åŸºæ•° ==========
    @Schema(description = "默认社保缴费基数(空则用员工基本工资)", example = "5023.00")
    private BigDecimal defaultSocialSecurityBase;
    @Schema(description = "默认公积金缴费基数(空或 0 åˆ™ç”¨ç¤¾ä¿ç¼´è´¹åŸºæ•°ï¼‰", example = "5023.00")
    private BigDecimal defaultHousingFundBase;
    @Schema(description = "适用部门ID列表(为空表示全局默认方案)", example = "[119,129]")
    private List<Long> deptIds;
    @Schema(description = "状态:0-启用,1-禁用", example = "0")
    private Integer status;
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/salary/HrmSocialSecuritySchemeDO.java
@@ -1,7 +1,9 @@
package cn.iocoder.yudao.module.hrm.dal.dataobject.salary;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
@@ -94,6 +96,22 @@
     */
    private BigDecimal housingFundPersonalRatio;
    // ========== ç¼´è´¹åŸºæ•° ==========
    /**
     * é»˜è®¤ç¤¾ä¿ç¼´è´¹åŸºæ•°ï¼ˆç©ºåˆ™ç”¨å‘˜å·¥åŸºæœ¬å·¥èµ„)
     *
     * updateStrategy=ALWAYS:清空该字段时要能写回 NULL
     */
    @TableField(updateStrategy = FieldStrategy.ALWAYS)
    private BigDecimal defaultSocialSecurityBase;
    /**
     * é»˜è®¤å…¬ç§¯é‡‘缴费基数(空或 0 åˆ™ç”¨ç¤¾ä¿ç¼´è´¹åŸºæ•°ï¼‰
     *
     * updateStrategy åŒ {@link #defaultSocialSecurityBase}
     */
    @TableField(updateStrategy = FieldStrategy.ALWAYS)
    private BigDecimal defaultHousingFundBase;
    /**
     * çŠ¶æ€ï¼š0-启用,1-禁用
     */
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/salary/HrmSocialSecuritySchemeDeptDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,42 @@
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.*;
/**
 * ç¤¾ä¿å…¬ç§¯é‡‘方案适用部门 DO
 *
 * ä¸€ä¸ªæ–¹æ¡ˆå¯ç»‘定多个部门;一个部门只能绑定一个方案(在 Service å±‚校验)
 *
 * @author è¶…级管理员
 */
@TableName("hrm_social_security_scheme_dept")
@KeySequence("hrm_social_security_scheme_dept_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class HrmSocialSecuritySchemeDeptDO extends BaseDO {
    /**
     * ä¸»é”®
     */
    @TableId
    private Long id;
    /**
     * ç¤¾ä¿å…¬ç§¯é‡‘方案ID
     */
    private Long schemeId;
    /**
     * é€‚用部门ID
     */
    private Long deptId;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/salary/HrmSocialSecuritySchemeDeptMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,58 @@
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.HrmSocialSecuritySchemeDeptDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
 * ç¤¾ä¿å…¬ç§¯é‡‘方案适用部门 Mapper
 *
 * @author è¶…级管理员
 */
@Mapper
public interface HrmSocialSecuritySchemeDeptMapper extends BaseMapperX<HrmSocialSecuritySchemeDeptDO> {
    /**
     * æŒ‰æ–¹æ¡ˆæ•´ç»„替换:先删除该方案的全部(逻辑删除)关联行
     */
    default void deleteBySchemeId(Long schemeId) {
        delete(new LambdaQueryWrapperX<HrmSocialSecuritySchemeDeptDO>()
                .eq(HrmSocialSecuritySchemeDeptDO::getSchemeId, schemeId));
    }
    default List<HrmSocialSecuritySchemeDeptDO> selectListBySchemeId(Long schemeId) {
        return selectList(new LambdaQueryWrapperX<HrmSocialSecuritySchemeDeptDO>()
                .eq(HrmSocialSecuritySchemeDeptDO::getSchemeId, schemeId));
    }
    default List<HrmSocialSecuritySchemeDeptDO> selectListBySchemeIds(Collection<Long> schemeIds) {
        return selectList(new LambdaQueryWrapperX<HrmSocialSecuritySchemeDeptDO>()
                .inIfPresent(HrmSocialSecuritySchemeDeptDO::getSchemeId, schemeIds));
    }
    default List<HrmSocialSecuritySchemeDeptDO> selectListByDeptIds(Collection<Long> deptIds) {
        return selectList(new LambdaQueryWrapperX<HrmSocialSecuritySchemeDeptDO>()
                .inIfPresent(HrmSocialSecuritySchemeDeptDO::getDeptId, deptIds));
    }
    default List<HrmSocialSecuritySchemeDeptDO> selectListByDeptId(Long deptId) {
        return selectList(new LambdaQueryWrapperX<HrmSocialSecuritySchemeDeptDO>()
                .eq(HrmSocialSecuritySchemeDeptDO::getDeptId, deptId));
    }
    /**
     * æŸ¥è¯¢å…¨éƒ¨å·²ç»‘定部门的方案ID(用于区分「部门专属方案」与「全局默认方案」)
     */
    default Set<Long> selectAllBoundSchemeIds() {
        return selectObjs(new LambdaQueryWrapperX<HrmSocialSecuritySchemeDeptDO>()
                .select(HrmSocialSecuritySchemeDeptDO::getSchemeId))
                .stream().map(obj -> (Long) obj).collect(Collectors.toSet());
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/salary/HrmSalaryCalculationServiceImpl.java
@@ -44,6 +44,7 @@
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -85,6 +86,8 @@
    private HrmEmployeeSocialSecurityMapper employeeSocialSecurityMapper;
    @Resource
    private HrmSocialSecuritySchemeMapper socialSecuritySchemeMapper;
    @Resource
    private HrmSocialSecuritySchemeService socialSecuritySchemeService;
    @Resource
    private HrmNoRedisDAO noRedisDAO;
    @Resource
@@ -140,6 +143,8 @@
                .toList();
        // 2. éåŽ†å‘˜å·¥è¿›è¡Œè–ªé…¬æ ¸ç®—
        // éƒ¨é—¨ -> æ–¹æ¡ˆ çš„解析结果在本次核算内缓存,避免逐员工重复查库
        Map<Long, HrmSocialSecuritySchemeDO> deptSchemeCache = new HashMap<>();
        for (HrmEmployeeDO employee : employees) {
            // employeeId æ˜¯å‘˜å·¥ID,用于关联薪酬档案、社保档案、核算记录
            Long employeeId = employee.getId();
@@ -165,11 +170,8 @@
            // 2.3 èŽ·å–å‘˜å·¥ç¤¾ä¿å…¬ç§¯é‡‘æ¡£æ¡ˆï¼ˆæŒ‰å‘˜å·¥ID查询,取最新生效的)
            HrmEmployeeSocialSecurityDO socialSecurity = employeeSocialSecurityMapper.selectLatestByUserId(employeeId);
            // 2.4 å¦‚果有社保档案,获取绑定的方案
            HrmSocialSecuritySchemeDO scheme = null;
            if (socialSecurity != null && socialSecurity.getSocialSecuritySchemeId() != null) {
                scheme = socialSecuritySchemeMapper.selectById(socialSecurity.getSocialSecuritySchemeId());
            }
            // 2.4 èŽ·å–æ–¹æ¡ˆï¼šå‘˜å·¥æ¡£æ¡ˆæŒ‡å®š > æ‰€åœ¨éƒ¨é—¨ç»‘定 > å…¨å±€é»˜è®¤
            HrmSocialSecuritySchemeDO scheme = resolveScheme(socialSecurity, employee, effectiveDate, deptSchemeCache);
            // 2.5 æž„建核算记录(包含扣除计算,通过方案获取比例)
            HrmSalaryCalculationDO calculation = buildSalaryCalculation(period, employeeId, employee, employeeSalary, socialSecurity, scheme, effectiveDate);
@@ -245,13 +247,22 @@
        BigDecimal totalIncome = baseSalary.add(performanceSalary).add(mealAllowance)
                .add(transportAllowance).add(otherAllowance).add(overtimePay);
        // èŽ·å–ç¤¾ä¿åŸºæ•°ï¼šå‘˜å·¥ç¤¾ä¿æ¡£æ¡ˆ > å‘˜å·¥åŸºæœ¬å·¥èµ„
        // èŽ·å–ç¤¾ä¿åŸºæ•°ï¼šå‘˜å·¥ç¤¾ä¿æ¡£æ¡ˆ > æ–¹æ¡ˆé»˜è®¤ç¼´è´¹åŸºæ•° > å‘˜å·¥åŸºæœ¬å·¥èµ„
        BigDecimal socialSecurityBase = (socialSecurity != null && socialSecurity.getSocialSecurityBase() != null)
                ? socialSecurity.getSocialSecurityBase() : baseSalary;
                ? socialSecurity.getSocialSecurityBase()
                : (scheme != null && scheme.getDefaultSocialSecurityBase() != null
                        ? scheme.getDefaultSocialSecurityBase() : baseSalary);
        // èŽ·å–å…¬ç§¯é‡‘åŸºæ•°ï¼šå‘˜å·¥ç¤¾ä¿æ¡£æ¡ˆ > å‘˜å·¥åŸºæœ¬å·¥èµ„
        BigDecimal housingFundBase = (socialSecurity != null && socialSecurity.getHousingFundBase() != null)
                ? socialSecurity.getHousingFundBase() : baseSalary;
        // èŽ·å–å…¬ç§¯é‡‘åŸºæ•°ï¼šå‘˜å·¥ç¤¾ä¿æ¡£æ¡ˆ > æ–¹æ¡ˆé»˜è®¤å…¬ç§¯é‡‘基数(大于 0 æ—¶ï¼‰> ç¤¾ä¿åŸºæ•°
        BigDecimal housingFundBase;
        if (socialSecurity != null && socialSecurity.getHousingFundBase() != null) {
            housingFundBase = socialSecurity.getHousingFundBase();
        } else if (scheme != null && scheme.getDefaultHousingFundBase() != null
                && scheme.getDefaultHousingFundBase().compareTo(BigDecimal.ZERO) > 0) {
            housingFundBase = scheme.getDefaultHousingFundBase();
        } else {
            housingFundBase = socialSecurityBase;
        }
        // ä»Žæ–¹æ¡ˆä¸­èŽ·å–ç¼´çº³æ¯”ä¾‹ï¼ˆä¼˜å…ˆä½¿ç”¨æ–¹æ¡ˆä¸­çš„æ¯”ä¾‹ï¼Œå¦åˆ™ä½¿ç”¨é»˜è®¤æ¯”ä¾‹ï¼‰
        BigDecimal pensionPersonalRatio = (scheme != null && scheme.getPensionPersonalRatio() != null)
@@ -316,6 +327,30 @@
                .totalWorkHours(totalWorkHours)
                .status(0) // å¾…确认
                .build();
    }
    /**
     * è§£æžå‘˜å·¥æ ¸ç®—用的社保公积金方案
     *
     * ä¼˜å…ˆçº§ï¼šå‘˜å·¥ç¤¾ä¿æ¡£æ¡ˆæŒ‡å®šçš„æ–¹æ¡ˆï¼ˆä¸ªåˆ«è¦†ç›–)> å‘˜å·¥æ‰€åœ¨éƒ¨é—¨ç»‘定的方案 > å…¨å±€é»˜è®¤æ–¹æ¡ˆã€‚
     * éƒ½å–不到时返回 null,由 {@link #buildSalaryCalculation} ä½¿ç”¨é»˜è®¤æ¯”例常量。
     *
     * @param deptSchemeCache æœ¬æ¬¡æ ¸ç®—内「部门 -> æ–¹æ¡ˆã€çš„缓存(允许 null å€¼ï¼Œç”¨äºŽç¼“å­˜"该部门无方案")
     */
    private HrmSocialSecuritySchemeDO resolveScheme(HrmEmployeeSocialSecurityDO socialSecurity, HrmEmployeeDO employee,
                                                    LocalDate periodDate,
                                                    Map<Long, HrmSocialSecuritySchemeDO> deptSchemeCache) {
        if (socialSecurity != null && socialSecurity.getSocialSecuritySchemeId() != null) {
            HrmSocialSecuritySchemeDO scheme = socialSecuritySchemeMapper.selectById(socialSecurity.getSocialSecuritySchemeId());
            if (scheme != null) {
                return scheme;
            }
        }
        Long deptId = employee.getDeptId();
        if (!deptSchemeCache.containsKey(deptId)) {
            deptSchemeCache.put(deptId, socialSecuritySchemeService.resolveSchemeByDeptId(deptId, periodDate));
        }
        return deptSchemeCache.get(deptId);
    }
    /**
@@ -531,6 +566,8 @@
        // 2. éåŽ†å‘˜å·¥å®žæ—¶è®¡ç®—è–ªé…¬
        List<HrmSalaryCalculationDO> resultList = new java.util.ArrayList<>();
        // éƒ¨é—¨ -> æ–¹æ¡ˆ çš„解析结果在本次预览内缓存,避免逐员工重复查库
        Map<Long, HrmSocialSecuritySchemeDO> deptSchemeCache = new HashMap<>();
        for (HrmEmployeeDO employee : employees) {
            Long employeeId = employee.getId();
@@ -543,11 +580,8 @@
            // 2.2 èŽ·å–å‘˜å·¥ç¤¾ä¿å…¬ç§¯é‡‘æ¡£æ¡ˆï¼ˆæŒ‰å‘˜å·¥ID查询,取最新生效的)
            HrmEmployeeSocialSecurityDO socialSecurity = employeeSocialSecurityMapper.selectLatestByUserId(employeeId);
            // 2.3 å¦‚果有社保档案,获取绑定的方案
            HrmSocialSecuritySchemeDO scheme = null;
            if (socialSecurity != null && socialSecurity.getSocialSecuritySchemeId() != null) {
                scheme = socialSecuritySchemeMapper.selectById(socialSecurity.getSocialSecuritySchemeId());
            }
            // 2.3 èŽ·å–æ–¹æ¡ˆï¼šå‘˜å·¥æ¡£æ¡ˆæŒ‡å®š > æ‰€åœ¨éƒ¨é—¨ç»‘定 > å…¨å±€é»˜è®¤
            HrmSocialSecuritySchemeDO scheme = resolveScheme(socialSecurity, employee, effectiveDate, deptSchemeCache);
            // 2.4 æž„建核算记录(实时计算,不存储)
            HrmSalaryCalculationDO calculation = buildSalaryCalculation(period, employeeId, employee, employeeSalary, socialSecurity, scheme, effectiveDate);
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/salary/HrmSocialSecuritySchemeService.java
@@ -1,8 +1,12 @@
package cn.iocoder.yudao.module.hrm.service.salary;
import cn.iocoder.yudao.module.hrm.controller.admin.salary.vo.HrmSocialSecuritySchemeSaveReqVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.salary.HrmSocialSecuritySchemeDO;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/**
 * ç¤¾ä¿å…¬ç§¯é‡‘方案 Service æŽ¥å£
@@ -12,19 +16,19 @@
public interface HrmSocialSecuritySchemeService {
    /**
     * åˆ›å»ºç¤¾ä¿å…¬ç§¯é‡‘方案
     * åˆ›å»ºç¤¾ä¿å…¬ç§¯é‡‘方案(含适用部门)
     *
     * @param scheme æ–¹æ¡ˆä¿¡æ¯
     * @param createReqVO æ–¹æ¡ˆä¿¡æ¯
     * @return æ–¹æ¡ˆID
     */
    Long createScheme(HrmSocialSecuritySchemeDO scheme);
    Long createScheme(HrmSocialSecuritySchemeSaveReqVO createReqVO);
    /**
     * æ›´æ–°ç¤¾ä¿å…¬ç§¯é‡‘方案
     * æ›´æ–°ç¤¾ä¿å…¬ç§¯é‡‘方案(含适用部门,整组替换)
     *
     * @param scheme æ–¹æ¡ˆä¿¡æ¯
     * @param updateReqVO æ–¹æ¡ˆä¿¡æ¯
     */
    void updateScheme(HrmSocialSecuritySchemeDO scheme);
    void updateScheme(HrmSocialSecuritySchemeSaveReqVO updateReqVO);
    /**
     * åˆ é™¤ç¤¾ä¿å…¬ç§¯é‡‘方案
@@ -49,4 +53,31 @@
     */
    List<HrmSocialSecuritySchemeDO> getSchemeList(Integer status);
}
    /**
     * èŽ·å–æ–¹æ¡ˆç»‘å®šçš„é€‚ç”¨éƒ¨é—¨ID列表
     *
     * @param schemeId æ–¹æ¡ˆID
     * @return éƒ¨é—¨ID列表
     */
    List<Long> getSchemeDeptIds(Long schemeId);
    /**
     * æ‰¹é‡èŽ·å–æ–¹æ¡ˆç»‘å®šçš„é€‚ç”¨éƒ¨é—¨ID(方案ID -> éƒ¨é—¨ID列表)
     *
     * @param schemeIds æ–¹æ¡ˆID集合
     * @return æ–¹æ¡ˆID -> éƒ¨é—¨ID列表
     */
    Map<Long, List<Long>> getSchemeDeptIdMap(Collection<Long> schemeIds);
    /**
     * æŒ‰éƒ¨é—¨è§£æžæ ¸ç®—用的社保公积金方案
     *
     * ä¼˜å…ˆçº§ï¼šè¯¥éƒ¨é—¨ç»‘定的方案(生效日期不晚于核算期间,取最新)> å…¨å±€é»˜è®¤æ–¹æ¡ˆï¼ˆæœªç»‘定任何部门的方案中生效日期最新者)
     *
     * @param deptId      å‘˜å·¥æ‰€åœ¨éƒ¨é—¨ID(可为空)
     * @param periodDate  æ ¸ç®—期间的月初日期
     * @return æ–¹æ¡ˆï¼›æŸ¥ä¸åˆ°è¿”回 null,由调用方使用默认比例常量
     */
    HrmSocialSecuritySchemeDO resolveSchemeByDeptId(Long deptId, LocalDate periodDate);
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/salary/HrmSocialSecuritySchemeServiceImpl.java
@@ -1,18 +1,36 @@
package cn.iocoder.yudao.module.hrm.service.salary;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.hrm.controller.admin.salary.vo.HrmSocialSecuritySchemeSaveReqVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.salary.HrmSocialSecuritySchemeDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.salary.HrmSocialSecuritySchemeDeptDO;
import cn.iocoder.yudao.module.hrm.dal.mysql.salary.HrmSocialSecuritySchemeDeptMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.salary.HrmSocialSecuritySchemeMapper;
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;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.hrm.enums.ErrorCodeConstants.SOCIAL_SECURITY_SCHEME_DEPT_EXISTS;
import static cn.iocoder.yudao.module.hrm.enums.ErrorCodeConstants.SOCIAL_SECURITY_SCHEME_NOT_EXISTS;
/**
 * ç¤¾ä¿å…¬ç§¯é‡‘方案 Service å®žçŽ°ç±»
 *
 * æ–¹æ¡ˆçš„缴纳比例按「方案 - é€‚用部门」关联表生效;一个部门只能绑定一个方案,未绑定任何部门的方案作为全局默认方案。
 *
 * @author è¶…级管理员
 */
@@ -20,24 +38,42 @@
@Validated
public class HrmSocialSecuritySchemeServiceImpl implements HrmSocialSecuritySchemeService {
    /**
     * å¯ç”¨çŠ¶æ€
     */
    private static final Integer STATUS_ENABLE = 0;
    @Resource
    private HrmSocialSecuritySchemeMapper schemeMapper;
    @Resource
    private HrmSocialSecuritySchemeDeptMapper schemeDeptMapper;
    @Resource
    private DeptApi deptApi;
    @Override
    public Long createScheme(HrmSocialSecuritySchemeDO scheme) {
    @Transactional(rollbackFor = Exception.class)
    public Long createScheme(HrmSocialSecuritySchemeSaveReqVO createReqVO) {
        HrmSocialSecuritySchemeDO scheme = BeanUtils.toBean(createReqVO, HrmSocialSecuritySchemeDO.class);
        schemeMapper.insert(scheme);
        saveSchemeDepts(scheme.getId(), createReqVO.getName(), createReqVO.getDeptIds());
        return scheme.getId();
    }
    @Override
    public void updateScheme(HrmSocialSecuritySchemeDO scheme) {
        validateSchemeExists(scheme.getId());
        schemeMapper.updateById(scheme);
    @Transactional(rollbackFor = Exception.class)
    public void updateScheme(HrmSocialSecuritySchemeSaveReqVO updateReqVO) {
        validateSchemeExists(updateReqVO.getId());
        schemeMapper.updateById(BeanUtils.toBean(updateReqVO, HrmSocialSecuritySchemeDO.class));
        saveSchemeDepts(updateReqVO.getId(), updateReqVO.getName(), updateReqVO.getDeptIds());
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteScheme(Long id) {
        validateSchemeExists(id);
        schemeDeptMapper.deleteBySchemeId(id);
        schemeMapper.deleteById(id);
    }
@@ -51,10 +87,115 @@
        return schemeMapper.selectListByStatus(status);
    }
    private void validateSchemeExists(Long id) {
        if (schemeMapper.selectById(id) == null) {
            throw exception(SOCIAL_SECURITY_SCHEME_NOT_EXISTS);
    @Override
    public List<Long> getSchemeDeptIds(Long schemeId) {
        return schemeDeptMapper.selectListBySchemeId(schemeId).stream()
                .map(HrmSocialSecuritySchemeDeptDO::getDeptId)
                .toList();
    }
    @Override
    public Map<Long, List<Long>> getSchemeDeptIdMap(Collection<Long> schemeIds) {
        if (CollUtil.isEmpty(schemeIds)) {
            return Map.of();
        }
        return schemeDeptMapper.selectListBySchemeIds(schemeIds).stream()
                .collect(Collectors.groupingBy(HrmSocialSecuritySchemeDeptDO::getSchemeId,
                        Collectors.mapping(HrmSocialSecuritySchemeDeptDO::getDeptId, Collectors.toList())));
    }
    @Override
    public HrmSocialSecuritySchemeDO resolveSchemeByDeptId(Long deptId, LocalDate periodDate) {
        // 1. ä¼˜å…ˆå–该部门绑定的方案
        if (deptId != null) {
            List<Long> boundSchemeIds = schemeDeptMapper.selectListByDeptId(deptId).stream()
                    .map(HrmSocialSecuritySchemeDeptDO::getSchemeId)
                    .toList();
            HrmSocialSecuritySchemeDO scheme = pickLatestEnabled(boundSchemeIds, periodDate);
            if (scheme != null) {
                return scheme;
            }
        }
        // 2. å…œåº•取全局默认方案:未绑定任何部门的方案中生效日期最新者
        Set<Long> boundSchemeIds = schemeDeptMapper.selectAllBoundSchemeIds();
        List<Long> globalSchemeIds = schemeMapper.selectListByStatus(STATUS_ENABLE).stream()
                .map(HrmSocialSecuritySchemeDO::getId)
                .filter(id -> !boundSchemeIds.contains(id))
                .toList();
        return pickLatestEnabled(globalSchemeIds, periodDate);
    }
    /**
     * ä»Žå€™é€‰æ–¹æ¡ˆä¸­å–「生效日期不晚于核算期间」的最新一条启用方案
     *
     * è‹¥å…¨éƒ¨å€™é€‰æ–¹æ¡ˆéƒ½æ™šäºŽæ ¸ç®—期间(例如刚建好下月才生效的方案就提前核算),则退化为取其中生效日期最新的一条,
     * é¿å…æ ¸ç®—金额静默落到代码里的默认比例上。
     */
    private HrmSocialSecuritySchemeDO pickLatestEnabled(Collection<Long> schemeIds, LocalDate periodDate) {
        if (CollUtil.isEmpty(schemeIds)) {
            return null;
        }
        List<HrmSocialSecuritySchemeDO> schemes = schemeMapper.selectList(
                new LambdaQueryWrapperX<HrmSocialSecuritySchemeDO>()
                        .in(HrmSocialSecuritySchemeDO::getId, schemeIds)
                        .eq(HrmSocialSecuritySchemeDO::getStatus, STATUS_ENABLE)
                        .orderByDesc(HrmSocialSecuritySchemeDO::getEffectiveDate)
                        .orderByDesc(HrmSocialSecuritySchemeDO::getId));
        if (CollUtil.isEmpty(schemes)) {
            return null;
        }
        return schemes.stream()
                .filter(scheme -> scheme.getEffectiveDate() == null
                        || !scheme.getEffectiveDate().isAfter(periodDate))
                .findFirst()
                .orElse(schemes.get(0));
    }
    /**
     * æ•´ç»„替换方案的适用部门:先校验,再删旧行、插新行
     */
    private void saveSchemeDepts(Long schemeId, String schemeName, List<Long> deptIds) {
        List<Long> distinctDeptIds = CollUtil.isEmpty(deptIds) ? List.of()
                : deptIds.stream().filter(Objects::nonNull).distinct().toList();
        if (CollUtil.isNotEmpty(distinctDeptIds)) {
            // éƒ¨é—¨å¿…须存在
            deptApi.validateDeptList(distinctDeptIds);
            // ä¸€ä¸ªéƒ¨é—¨åªèƒ½ç»‘定一个方案
            validateDeptNotBoundByOtherScheme(schemeId, schemeName, distinctDeptIds);
        }
        schemeDeptMapper.deleteBySchemeId(schemeId);
        distinctDeptIds.forEach(deptId -> schemeDeptMapper.insert(
                HrmSocialSecuritySchemeDeptDO.builder().schemeId(schemeId).deptId(deptId).build()));
    }
    /**
     * æ ¡éªŒè¿™äº›éƒ¨é—¨æ²¡æœ‰è¢«åˆ«çš„æ–¹æ¡ˆå ç”¨
     */
    private void validateDeptNotBoundByOtherScheme(Long schemeId, String schemeName, List<Long> deptIds) {
        List<HrmSocialSecuritySchemeDeptDO> bindings = schemeDeptMapper.selectListByDeptIds(deptIds);
        if (CollUtil.isEmpty(bindings)) {
            return;
        }
        // åç§°è§£æžåªæ”¾åœ¨å¼‚常分支,避免正常保存多查表
        for (HrmSocialSecuritySchemeDeptDO binding : bindings) {
            if (Objects.equals(binding.getSchemeId(), schemeId)) {
                continue;
            }
            HrmSocialSecuritySchemeDO otherScheme = schemeMapper.selectById(binding.getSchemeId());
            DeptRespDTO dept = deptApi.getDept(binding.getDeptId());
            throw exception(SOCIAL_SECURITY_SCHEME_DEPT_EXISTS,
                    dept != null ? dept.getName() : "ID=" + binding.getDeptId(),
                    otherScheme != null ? otherScheme.getName() : "ID=" + binding.getSchemeId(),
                    schemeName);
        }
    }
}
    private HrmSocialSecuritySchemeDO validateSchemeExists(Long id) {
        HrmSocialSecuritySchemeDO scheme = schemeMapper.selectById(id);
        if (scheme == null) {
            throw exception(SOCIAL_SECURITY_SCHEME_NOT_EXISTS);
        }
        return scheme;
    }
}