tuqin
昨天 1466672f09a4fc1037c78cc2bb096fca551c1683
feat(hrm): 薪酬核算新增全勤奖与实出天数,修正实发工资口径

需求:
1. 核算单新增「全勤奖」,与其他津贴一致人工维护,计入应发与实发
2. 「出勤天数」更名为「应出天数」(语义不变,仍是按排班累计的应出勤天数)
3. 「排班工时」改为「实出天数」,取自考勤汇总 hrm_attendance_summary.actual_days;
原 total_work_hours 列保留但不再参与业务(历史数据不删除)

同时修复 3 处会导致实发工资算错的既有缺陷(新建核算正确、但改一次核算单实发就变少):
- 后端 calcActualSalary 收入项漏掉 postSalary / senioritySalary
- 前端 calculateActualSalary 同样漏掉 postSalary / senioritySalary
- HrmSalaryCalculationSaveReqVO.CalculationItem 未声明 postSalary / senioritySalary,
预览保存时这两个金额会被丢弃
并显式对齐 totalIncome 与 calcActualSalary 的收入项口径,两者今后必须一致。

另修复既有隐患:
- HrmAttendanceSummaryDO.yearMonth 未转义,year_month 是 MySQL 保留字,
MyBatis-Plus 生成的 SQL 语法错误(该 Mapper 方法此前从未被调用,本次核算取实出天数时暴露)

SQL:hrm_salary_add_full_attendance_bonus.sql(幂等,含历史数据回填实出天数)

自测(mom-zyjc 实测):
- 核算:应出 22 / 实出 21(取自考勤汇总)/ 全勤奖默认 0 / 实发 = 收入 - 扣除 ✔
- 修改核算单:基本 9000 + 岗位 1000 + 工龄 500 + 全勤奖 500 - 社保 550.83 = 10449.17 ✔
已修改5个文件
已添加1个文件
129 ■■■■■ 文件已修改
sql/mysql/hrm_salary_add_full_attendance_bonus.sql 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/vo/HrmSalaryCalculationRespVO.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/vo/HrmSalaryCalculationSaveReqVO.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/attendance/HrmAttendanceSummaryDO.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/salary/HrmSalaryCalculationDO.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/salary/HrmSalaryCalculationServiceImpl.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/hrm_salary_add_full_attendance_bonus.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,49 @@
-- =============================================
-- è–ªé…¬æ ¸ç®—:新增「全勤奖」「实出天数」
--
-- èƒŒæ™¯ï¼š
--   1) å…¨å‹¤å¥–:与其他津贴口径一致,由人工在核算单中维护(默认 0),计入应发/实发
--   2) å®žå‡ºå¤©æ•°ï¼šå–自考勤汇总表 hrm_attendance_summary.actual_days
--      ã€Œåº”出天数」沿用原 work_days(按排班累计的应出勤天数,无排班回退 22),语义不变,仅前端改名
--      ã€ŒæŽ’班工时」原列 total_work_hours ä¿ç•™ä½†ä¸å†ä½¿ç”¨ï¼ˆåŽ†å²æ•°æ®ä¸åˆ é™¤ï¼‰
--
-- è„šæœ¬å¯é‡å¤æ‰§è¡Œï¼ˆå¹‚ç­‰ï¼‰
-- =============================================
SET NAMES utf8mb4;
SET @sch := DATABASE();
-- 1. å…¨å‹¤å¥–
SET @exists := (SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = @sch AND TABLE_NAME = 'hrm_salary_calculation' AND COLUMN_NAME = 'full_attendance_bonus');
SET @sql := IF(@exists = 0,
    'ALTER TABLE `hrm_salary_calculation` ADD COLUMN `full_attendance_bonus` decimal(10,2) DEFAULT ''0.00'' COMMENT ''全勤奖'' AFTER `other_allowance`',
    'SELECT ''full_attendance_bonus å·²å­˜åœ¨'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 2. å®žå‡ºå¤©æ•°
SET @exists := (SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = @sch AND TABLE_NAME = 'hrm_salary_calculation' AND COLUMN_NAME = 'actual_days');
SET @sql := IF(@exists = 0,
    'ALTER TABLE `hrm_salary_calculation` ADD COLUMN `actual_days` int DEFAULT 0 COMMENT ''实出天数(取自考勤汇总)'' AFTER `work_days`',
    'SELECT ''actual_days å·²å­˜åœ¨'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 3. åŽ†å²æ•°æ®å›žå¡«å®žå‡ºå¤©æ•°ï¼ˆæŒ‰åŒå‘˜å·¥åŒå‘¨æœŸä»Žè€ƒå‹¤æ±‡æ€»å¸¦ä¸€æ¬¡ï¼Œé¿å…åŽ†å²å•å…¨æ˜¯ 0)
UPDATE `hrm_salary_calculation` c
JOIN `hrm_attendance_summary` s
  ON s.`user_id` = c.`user_id` AND s.`year_month` = c.`period` AND s.`deleted` = b'0'
SET c.`actual_days` = s.`actual_days`
WHERE c.`deleted` = b'0' AND (c.`actual_days` IS NULL OR c.`actual_days` = 0)
  AND s.`actual_days` IS NOT NULL;
-- 4. æ ¸å¯¹
SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_DEFAULT, COLUMN_COMMENT
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @sch AND TABLE_NAME = 'hrm_salary_calculation'
  AND COLUMN_NAME IN ('work_days','actual_days','full_attendance_bonus','total_work_hours')
ORDER BY ORDINAL_POSITION;
SELECT COUNT(*) AS æœ‰å®žå‡ºå¤©æ•°çš„æ ¸ç®—单数 FROM `hrm_salary_calculation` WHERE `deleted` = b'0' AND `actual_days` > 0;
SELECT '薪酬核算 å…¨å‹¤å¥–/实出天数 å­—段初始化完成' AS result;
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/vo/HrmSalaryCalculationRespVO.java
@@ -73,6 +73,10 @@
    @ExcelProperty("其他补贴")
    private BigDecimal otherAllowance;
    @Schema(description = "全勤奖", example = "200.00")
    @ExcelProperty("全勤奖")
    private BigDecimal fullAttendanceBonus;
    @Schema(description = "社保扣除")
    @ExcelProperty("社保扣除")
    private BigDecimal socialSecurityDeduction;
@@ -97,10 +101,14 @@
    @ExcelProperty("实发工资")
    private BigDecimal actualSalary;
    @Schema(description = "出勤天数")
    @ExcelProperty("出勤天数")
    @Schema(description = "应出天数")
    @ExcelProperty("应出天数")
    private Integer workDays;
    @Schema(description = "实出天数(取自考勤汇总)", example = "21")
    @ExcelProperty("实出天数")
    private Integer actualDays;
    @Schema(description = "加班时长")
    @ExcelProperty("加班时长")
    private BigDecimal overtimeHours;
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/vo/HrmSalaryCalculationSaveReqVO.java
@@ -41,6 +41,12 @@
        @NotNull(message = "基本工资不能为空")
        private BigDecimal baseSalary;
        @Schema(description = "岗位工资")
        private BigDecimal postSalary;
        @Schema(description = "工龄工资")
        private BigDecimal senioritySalary;
        @Schema(description = "绩效工资")
        private BigDecimal performanceSalary;
@@ -55,6 +61,9 @@
        @Schema(description = "其他补贴")
        private BigDecimal otherAllowance;
        @Schema(description = "全勤奖")
        private BigDecimal fullAttendanceBonus;
        @Schema(description = "社保扣除")
        private BigDecimal socialSecurityDeduction;
@@ -72,9 +81,12 @@
        @NotNull(message = "实发工资不能为空")
        private BigDecimal actualSalary;
        @Schema(description = "出勤天数")
        @Schema(description = "应出天数")
        private Integer workDays;
        @Schema(description = "实出天数")
        private Integer actualDays;
        @Schema(description = "加班时长")
        private BigDecimal overtimeHours;
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/attendance/HrmAttendanceSummaryDO.java
@@ -2,6 +2,7 @@
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
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.*;
@@ -43,7 +44,11 @@
    /**
     * å¹´æœˆï¼Œæ ¼å¼ï¼šyyyy-MM
     *
     * æ³¨æ„ï¼šyear_month æ˜¯ MySQL çš„保留字(YEAR_MONTH ä¸º interval å•位),
     * å¿…须用反引号包裹,否则 MyBatis-Plus æ‹¼å‡ºçš„ SQL ä¼šè¯­æ³•错误。
     */
    @TableField("`year_month`")
    private String yearMonth;
    /**
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/salary/HrmSalaryCalculationDO.java
@@ -91,6 +91,12 @@
     * å…¶ä»–补贴
     */
    private BigDecimal otherAllowance;
    /**
     * å…¨å‹¤å¥–
     *
     * ä¸Žå…¶ä»–津贴一致,由人工在核算单中维护,默认 0
     */
    private BigDecimal fullAttendanceBonus;
    /**
     * ç¤¾ä¿æ‰£é™¤
@@ -123,11 +129,19 @@
    private BigDecimal actualSalary;
    /**
     * å‡ºå‹¤å¤©æ•°
     * åº”出天数(按排班累计的应出勤天数,无排班时回退 22)
     */
    private Integer workDays;
    /**
     * å®žå‡ºå¤©æ•°
     *
     * å–自考勤汇总(hrm_attendance_summary.actual_days),无汇总记录时为 0。
     * è¯´æ˜Žï¼šåŽŸã€ŒæŽ’ç­æ€»å·¥æ—¶ã€å­—æ®µä¿ç•™ä½†ä¸å†ä½¿ç”¨ï¼ˆåŽ†å²æ•°æ®ä¸åˆ é™¤ï¼‰ã€‚
     */
    private Integer actualDays;
    /**
     * åŠ ç­æ—¶é•¿
     */
    private BigDecimal overtimeHours;
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/salary/HrmSalaryCalculationServiceImpl.java
@@ -6,6 +6,7 @@
import cn.iocoder.yudao.module.hrm.controller.admin.salary.vo.HrmSalaryCalculationPageReqVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.approval.HrmLeaveApplicationDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.approval.HrmOvertimeApplicationDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceSummaryDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmEmployeeScheduleDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmShiftDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO;
@@ -18,6 +19,7 @@
import cn.iocoder.yudao.module.hrm.dal.dataobject.salary.HrmSocialSecuritySchemeDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.salary.HrmSalaryStructureDO;
import cn.iocoder.yudao.module.hrm.dal.mysql.approval.HrmLeaveApplicationMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.attendance.HrmAttendanceSummaryMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.attendance.HrmEmployeeScheduleMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.attendance.HrmShiftMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.approval.HrmOvertimeApplicationMapper;
@@ -114,6 +116,8 @@
    private HrmEmployeeScheduleMapper employeeScheduleMapper;
    @Resource
    private HrmShiftMapper shiftMapper;
    @Resource
    private HrmAttendanceSummaryMapper attendanceSummaryMapper;
    @Override
    public PageResult<HrmSalaryCalculationDO> getSalaryCalculationPage(HrmSalaryCalculationPageReqVO pageReqVO) {
@@ -248,13 +252,25 @@
        OvertimeSalary overtimeSalary = calculateOvertimeSalary(employee, employeeSalary, period, baseSalary);
        BigDecimal overtimePay = overtimeSalary.pay();
        // æŒ‰æŽ’班累计出勤天数与排班总工时(无排班时出勤天数回退默认 22)
        // æŒ‰æŽ’班累计应出天数与排班总工时(无排班时应出天数回退默认 22)
        ScheduleStat scheduleStat = calculateScheduleStat(employeeId, period);
        Integer workDays = scheduleStat.scheduledDays() > 0 ? scheduleStat.scheduledDays() : 22;
        BigDecimal totalWorkHours = scheduleStat.totalWorkHours();
        // åº”发工资总额
        // å®žå‡ºå¤©æ•°ï¼šå–自考勤汇总(无汇总记录时按 0 å¤„理)
        Integer actualDays = 0;
        HrmAttendanceSummaryDO attendanceSummary =
                attendanceSummaryMapper.selectByUserIdAndYearMonth(employeeId, period);
        if (attendanceSummary != null && attendanceSummary.getActualDays() != null) {
            actualDays = attendanceSummary.getActualDays();
        }
        // å…¨å‹¤å¥–:与其他津贴一致,由人工在核算单中维护,新建时默认 0
        BigDecimal fullAttendanceBonus = BigDecimal.ZERO;
        // åº”发工资总额(收入项口径必须与 calcActualSalary ä¿æŒä¸€è‡´ï¼‰
        BigDecimal totalIncome = baseSalary.add(performanceSalary).add(postSalary).add(senioritySalary)
                .add(fullAttendanceBonus)
                .add(mealAllowance).add(transportAllowance).add(otherAllowance).add(overtimePay);
        // èŽ·å–ç¤¾ä¿åŸºæ•°ï¼šå‘˜å·¥ç¤¾ä¿æ¡£æ¡ˆ > å‘˜å·¥åŸºæœ¬å·¥èµ„
@@ -319,15 +335,17 @@
                .mealAllowance(mealAllowance)
                .transportAllowance(transportAllowance)
                .otherAllowance(otherAllowance)
                .fullAttendanceBonus(fullAttendanceBonus)
                .socialSecurityDeduction(socialSecurityDeduction)
                .housingFundDeduction(housingFundDeduction)
                .taxDeduction(taxDeduction)
                .otherDeduction(otherDeduction)
                .leaveDeduction(leaveDeduction)
                .actualSalary(actualSalary)
                .workDays(workDays) // æŒ‰æŽ’班累计应出勤天数,无排班时回退 22
                .workDays(workDays) // åº”出天数:按排班累计,无排班时回退 22
                .actualDays(actualDays) // å®žå‡ºå¤©æ•°ï¼šå–自考勤汇总
                .overtimeHours(overtimeSalary.hours())
                .totalWorkHours(totalWorkHours)
                .totalWorkHours(totalWorkHours) // ä¿ç•™åŽ†å²å­—æ®µï¼Œä¸šåŠ¡å·²æ”¹ç”¨ actualDays
                .status(0) // å¾…确认
                .build();
    }
@@ -700,6 +718,7 @@
        calculation.setMealAllowance(nvl(calculation.getMealAllowance()));
        calculation.setTransportAllowance(nvl(calculation.getTransportAllowance()));
        calculation.setOtherAllowance(nvl(calculation.getOtherAllowance()));
        calculation.setFullAttendanceBonus(nvl(calculation.getFullAttendanceBonus()));
        calculation.setSocialSecurityDeduction(nvl(calculation.getSocialSecurityDeduction()));
        calculation.setHousingFundDeduction(nvl(calculation.getHousingFundDeduction()));
        calculation.setTaxDeduction(nvl(calculation.getTaxDeduction()));
@@ -712,10 +731,16 @@
    /**
     * è®¡ç®—实发工资 = æ”¶å…¥é¡¹åˆè®¡ - æ‰£é™¤é¡¹åˆè®¡ï¼Œä¿ç•™ä¸¤ä½å°æ•°
     *
     * æ”¶å…¥é¡¹å¿…须与 buildSalaryCalculation ä¸­ totalIncome çš„口径完全一致,
     * å¦åˆ™ä¼šå‡ºçŽ°ã€Œæ–°å»ºæ ¸ç®—ç®—å¾—å¯¹ã€ä¿®æ”¹ä¸€æ¬¡æ ¸ç®—å•å®žå‘å°±å˜å°‘ã€çš„ä¸ä¸€è‡´ã€‚
     */
    private static BigDecimal calcActualSalary(HrmSalaryCalculationDO c) {
        BigDecimal income = nvl(c.getBaseSalary())
                .add(nvl(c.getPostSalary()))
                .add(nvl(c.getSenioritySalary()))
                .add(nvl(c.getPerformanceSalary()))
                .add(nvl(c.getFullAttendanceBonus()))
                .add(nvl(c.getOvertimePay()))
                .add(nvl(c.getMealAllowance()))
                .add(nvl(c.getTransportAllowance()))