数智人事系统(十七):加班核算与薪资构成

数智人事系统(十七):加班核算与薪资构成

加班记录决定加班费与调休余额;月工资核算又要汇总加班费、扣除考勤扣款。本期实现两块计算:加班侧由四个时间点算出时长、按日期判定类型,再决定调休与加班费;薪资侧按构成公式汇总基础工资、奖金、补贴与加班费,并扣除考勤扣款、社保与公积金。

加班记录不走审批流程。流程引擎只承载请假审批(processes/ 下仅 leave.bpmn20.xml),加班记录由加班模块直接核算并写入。

前置准备

需完成第 13 期(考勤打卡)。本期用到 att_attendance 中的迟到、早退、旷工与休假状态;调休余额与审批通过后的扣减分别在第 14、16 期介绍,不了解可先照跑。

本期目标

  • 维护部门加班设置:按部门与加班类型配置计费类型、工资倍数、奖金与是否调休
  • 录入员工加班记录:由四个时间点算出时长,按日期判定类型,休息日满足条件时转为调休记录
  • 计算加班费:按小时或按天乘以部门倍数并加奖金,配置或薪资缺失时保留空值
  • 核算月工资:汇总基础工资、奖金、补贴与加班费,扣除四类考勤扣款、社保与公积金

加班的两张表

加班数据分成设置与记录两层。部门设置存放在 att_overtime(db/mysql/hrm.sql:204),每条配置对应一个部门的一种加班类型:

sql 复制代码
CREATE TABLE `att_overtime`  (
  `id` int UNSIGNED NOT NULL AUTO_INCREMENT,
  `salary_multiple` decimal(5, 2) NULL DEFAULT NULL COMMENT '工资倍数',
  `bonus` decimal(10, 3) UNSIGNED NOT NULL DEFAULT 0.000 COMMENT '加班奖金',
  `type_num` tinyint NULL DEFAULT NULL COMMENT '加班类型,0工作日加班,1节假日加班,2休息日加班',
  `dept_id` int NULL DEFAULT NULL COMMENT '部门id',
  `count_type` tinyint NULL DEFAULT NULL COMMENT '0小时,1天',
  ...
  `is_time_off` tinyint UNSIGNED NULL DEFAULT NULL COMMENT '休息日加班是否调休,0不补休,1补休',
  PRIMARY KEY (`id`) USING BTREE
) COMMENT = '加班表';

设置表没有唯一索引,按「部门 + 类型」维护一条配置是应用层约定:部门设置弹窗调用 OvertimeService.setOvertime(hrm-server/src/main/java/com/qiujie/overtime/service/OvertimeService.java:80),先按「部门 + 类型」更新,无记录再插入。

员工加班记录存放在 att_staff_overtime(db/mysql/hrm.sql:306):

sql 复制代码
CREATE TABLE `att_staff_overtime`  (
  `id` int UNSIGNED NOT NULL AUTO_INCREMENT,
  `staff_id` int NULL DEFAULT NULL COMMENT '员工id',
  `mor_start_time` time NULL DEFAULT NULL COMMENT '上午上班时间',
  `mor_end_time` time NULL DEFAULT NULL COMMENT '上午下班时间',
  `aft_start_time` time NULL DEFAULT NULL COMMENT '下午上班时间',
  `aft_end_time` time NULL DEFAULT NULL COMMENT '下午下班时间',
  `overtime_date` date NOT NULL COMMENT '加班日期',
  `total_overtime` decimal(3, 1) NULL DEFAULT NULL COMMENT '加班时长',
  `overtime_salary` decimal(10, 3) NULL DEFAULT NULL COMMENT '加班工资',
  `type_num` tinyint NULL DEFAULT NULL COMMENT '加班类型',
  `status` tinyint NULL DEFAULT NULL COMMENT '加班状态,0正常、1加班、2调休',
  ...
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE KEY `uk_overtime_staff_date_active` (((CASE WHEN `is_deleted` = 0 THEN `staff_id` END)), `overtime_date`),
  KEY `idx_overtime_staff_date` (`staff_id`, `overtime_date`),
  KEY `idx_overtime_staff_status_date` (`staff_id`, `status`, `overtime_date`)
) COMMENT = '员工加班表';

记录表的唯一索引只约束未删除行:索引键由 CASE WHEN is_deleted = 0 THEN staff_id END 与 overtime_date 组成,已删除行的表达式值为 NULL,同一员工同一天可以重新录入。

员工记录的 setOvertime 最终调用 saveOrUpdateByStaffAndDate(StaffOvertimeService.java:231-245):先按「员工 + 日期」更新,更新不到再插入;并发插入撞到唯一键时再按同一条件更新。新增接口直接调用 setOvertime;编辑接口先读取原记录,禁止修改员工与日期,再走同一路径,不再分别调用 save 或 updateById。

时长、类型与调休

上午与下午各算一段时长,相加后换算成小时(hrm-server/src/main/java/com/qiujie/overtime/util/OvertimeUtil.java:132):

java 复制代码
long morDiff = src.getMorEndTime().getTime() - src.getMorStartTime().getTime();
long aftDiff = src.getAftEndTime().getTime() - src.getAftStartTime().getTime();
return BigDecimal.valueOf((morDiff + aftDiff) / (1000 * 60 * 60.0));

四个时间点任一为空时,时长直接记 0(OvertimeUtil.java:133-136);服务端不校验起止先后,结束时间早于开始时间会得到负时长。

类型判定先看记录是否已带类型,否则按日期分类(OvertimeUtil.java:79):

java 复制代码
public static OvertimeEnum resolveType(StaffOvertime src, Predicate<Date> isHoliday) {
    if (src.getTypeNum() != null) {
        return src.getTypeNum();
    }
    return classifyType(src.getOvertimeDate(), isHoliday);
}

public static OvertimeEnum classifyType(Date date, Predicate<Date> isHoliday) {
    if (isHoliday.test(date)) {
        return OvertimeEnum.HOLIDAY_OVERTIME;
    }
    if (DateUtil.isWeekend(date)) {
        return OvertimeEnum.DAY_OFF_OVERTIME;
    }
    return OvertimeEnum.WORKDAY_OVERTIME;
}

记录上已有类型时直接采用,接口允许在请求里指定类型;没有类型时先查节假日、再查周末、其余归工作日。页面打开详情时,新记录按日期分类得到类型,已有记录沿用库中的值,输入框只读展示。节假日判断由调用方传入 DatetimeUtil::isHoliday,与第 13、16 期共用同一份配置。

三种类型在 OvertimeEnum 中定义为工作日加班(0)、节假日加班(1)、休息日加班(2),各自带倍数下限 1.5、3.0、2.0(hrm-server/src/main/java/com/qiujie/overtime/enums/OvertimeEnum.java:15-17)。下限只用于部门设置页的输入校验,实际计薪使用配置里的 salary_multiple。

调休判定只针对休息日加班(OvertimeUtil.java:101):

java 复制代码
private static boolean isTimeOff(OvertimeEnum type, Overtime config, BigDecimal total) {
    return type == OvertimeEnum.DAY_OFF_OVERTIME
            && config.getTimeOffFlag() != null && config.getTimeOffFlag() == 1
            && total.compareTo(MIN_DAILY_OVERTIME) >= 0;
}

三个条件同时成立才转为调休:休息日加班、部门开启调休、当日加班不少于 8 小时。调休记录状态记为调休(2),加班费记为 null,不进入计薪分支;工作日与节假日加班即使开了调休标记也不转调休。

计薪规则

调休判定不成立时进入计薪。计费类型由部门设置的 count_type 决定:0 或空表示按小时,1 表示按天(OvertimeUtil.java:107)。

按小时计薪要求当日加班不少于 2 小时,加班费 = 时薪 × 部门倍数 × 时长 + 奖金:

java 复制代码
if (total.compareTo(MIN_HOURLY_OVERTIME) < 0) {
    return BigDecimal.ZERO;
}
BigDecimal hourSalary = salary.getHourSalary();
if (hourSalary == null) {
    return null;
}
return hourSalary.multiply(config.getSalaryMultiple()).multiply(total).add(config.getBonus());

按天计薪要求当日加班不少于 8 小时,加班费 = 日薪 × 部门倍数 + 奖金,不乘时长:

java 复制代码
if (config.getCountType() != null && config.getCountType() == 1) {
    if (total.compareTo(MIN_DAILY_OVERTIME) < 0) {
        return BigDecimal.ZERO;
    }
    BigDecimal daySalary = salary.getDaySalary();
    if (daySalary == null) {
        return null;
    }
    return daySalary.multiply(config.getSalaryMultiple()).add(config.getBonus());
}

两段规则有两个共同点:

  • 时长未达门槛时返回 BigDecimal.ZERO,状态仍是加班,只是金额为 0;
  • 时薪或日薪为空时返回 null,与没有薪资记录同一种兜底;倍数与奖金来自部门设置,时薪与日薪来自最近一条薪资记录。

状态只由部门加班设置是否存在决定(OvertimeUtil.java:66-70):配置存在时状态为加班(1),金额可能因缺少薪资记录或时薪、日薪为空而为 null;配置不存在时状态为正常(0),金额同样为 null。缺少配置或薪资时,写入不会失败,金额需按 null 处理。

引用数据由调用方加载

单条录入时,员工记录的 setOvertime 依次加载员工、部门加班设置与最近一条薪资,并把节假日判定方法一并传入(StaffOvertimeService.java:204-214):

java 复制代码
Staff staff = staffMapper.selectById(staffOvertime.getStaffId());
OvertimeEnum type = OvertimeUtil.resolveType(staffOvertime, datetimeUtil::isHoliday);
Overtime config = overtimeMapper.selectOne(new QueryWrapper<Overtime>()
        .eq("type_num", type).eq("dept_id", staff.getDeptId()));
Salary latestSalary = salaryMapper.selectList(new QueryWrapper<Salary>()
        .eq("staff_id", staffOvertime.getStaffId()).orderByDesc("month"))
        .stream().findFirst().orElse(null);
Map<String, Object> result = OvertimeUtil.calculate(staffOvertime, staff, config,
        latestSalary, datetimeUtil::isHoliday);

先定类型再查配置;类型为空时配置查询匹配不到任何行,记录会按无配置处理。

批量导入走同一个计算入口,在批次开始时创建三个缓存 Map,按行按需填充,避免逐行重复查库(StaffOvertimeService.java:310-352):

  • staffMap:员工 id 到员工记录;
  • overtimeConfigCache:deptId_typeCode 到部门加班设置;
  • salaryCache:员工 id 到最近一条薪资记录。

每行先定类型,再用 deptId_typeCode 取缓存配置,最后调用同一个 OvertimeUtil.calculate,两条路径使用同一套规则。批量任务的分片与错行处理属于文件任务引擎,第 20 期展开。

调休余额

调休余额按调休记录条数计算(StaffOvertimeService.java:263):

java 复制代码
return this.staffOvertimeMapper.selectCount(new QueryWrapper<StaffOvertime>()
        .eq("staff_id", staffId).eq("status", OvertimeStatusEnum.TIME_OFF));

一条调休记录代表一天。请假提交时按工作日折算后的天数校验余额;审批通过后,每个工作日删除一条日期最早的调休记录(第 16 期)。薪资计算只读当月加班费汇总,不读调休记录。

薪资构成

薪资记录存放在 sal_salary(db/mysql/hrm.sql:887),按「员工 + 月份」维护一条记录:

sql 复制代码
CREATE TABLE `sal_salary`  (
  `id` int UNSIGNED NOT NULL AUTO_INCREMENT,
  `staff_id` int UNSIGNED NULL DEFAULT NULL COMMENT '员工id',
  `base_salary` decimal(10, 3) NULL DEFAULT NULL COMMENT '基础工资',
  `day_salary` decimal(10, 3) UNSIGNED NULL DEFAULT NULL COMMENT '平均日薪,基础工资 / 21.75',
  `hour_salary` decimal(10, 3) UNSIGNED NULL DEFAULT NULL COMMENT '平均时薪,基础工资 / (21.75 * 8)',
  `overtime_salary` decimal(10, 3) NULL DEFAULT NULL COMMENT '加班费',
  `subsidy` decimal(10, 3) UNSIGNED NULL DEFAULT NULL COMMENT '生活补贴',
  `bonus` decimal(10, 3) UNSIGNED NULL DEFAULT NULL COMMENT '奖金',
  `total_salary` decimal(10, 3) NULL DEFAULT NULL COMMENT '总工资',
  `late_deduct` decimal(10, 3) NULL DEFAULT NULL COMMENT '迟到扣款',
  `leave_deduct` decimal(10, 3) NULL DEFAULT NULL COMMENT '休假扣款',
  `leave_early_deduct` decimal(10, 3) NULL DEFAULT NULL COMMENT '早退扣款',
  `absenteeism_deduct` decimal(10, 3) NULL DEFAULT NULL COMMENT '旷工扣款',
  `month` char(6) NULL DEFAULT NULL COMMENT '月份',
  ...
  PRIMARY KEY (`id`) USING BTREE
) COMMENT = '薪资表';

日薪与时薪在设置工资时按基础工资折算:日薪 = 基础工资 / 21.75,时薪 = 基础工资 / 174(hrm-server/src/main/java/com/qiujie/salary/service/SalaryService.java:234-235)。

工资列表查询会重新计算四类扣款、加班费与最终工资,填入视图对象,查询本身不回写 sal_salary;保存工资弹窗时,setSalary 按「员工 + 月份」更新,无记录再插入,弹窗里的扣款金额随表单写入表中(SalaryService.java:231-240)。

扣款配置存放在 sal_salary_deduct(db/mysql/hrm.sql:980),按部门与扣款类型维护金额。类型由 DeductEnum 定义,四类各带一个默认金额(hrm-server/src/main/java/com/qiujie/salary/enums/DeductEnum.java:12-15):

java 复制代码
LATE_DEDUCT(0, "迟到扣款", 50),
LEAVE_EARLY_DEDUCT(1, "早退扣款", 50),
ABSENTEEISM_DEDUCT(2, "旷工扣款", 100),
LEAVE_DEDUCT(3, "休假扣款", 80);

部门没有配置某一类扣款时回退默认金额(hrm-server/src/main/java/com/qiujie/salary/util/SalaryUtil.java:94)。

月工资的构成公式(SalaryUtil.java:57-66):

java 复制代码
BigDecimal total = monthSalary.getBaseSalary()
        .add(monthSalary.getBonus())
        .add(monthSalary.getSubsidy())
        .add(overtime)
        .subtract(lateDeduct)
        .subtract(earlyDeduct)
        .subtract(absenteeismDeduct)
        .subtract(leaveDeduct)
        .subtract(nullToZero(vo.getSocialPay()))
        .subtract(nullToZero(vo.getHousePay()));

社保与公积金为空时按 0 处理。

扣款次数与金额

四类扣款的数据来源不同。迟到、早退、旷工按当月考勤记录中的状态次数统计(hrm-server/src/main/java/com/qiujie/salary/service/SalaryService.java:168-177):

java 复制代码
attendanceCounts.put(AttendanceStatusEnum.LATE.getCode(),
        this.attendanceMapper.countTimes(staffSalaryVO.getStaffId(),
                AttendanceStatusEnum.LATE.getCode(), startDate, endDate));
attendanceCounts.put(AttendanceStatusEnum.LEAVE_EARLY.getCode(), ...);
attendanceCounts.put(AttendanceStatusEnum.ABSENTEEISM.getCode(), ...);

countTimes 按员工、状态与日期区间统计 att_attendance 的行数(hrm-server/src/main/java/com/qiujie/attendance/mapper/AttendanceMapper.java:32)。区间取当月第一天到下月第一天,>= start 与 < end 不会漏掉月末,也不会多算下月第一天。

休假扣款不按次数统计,而是先查出当月所有休假日期,再跳过周六与周日(SalaryService.java:178-186):

java 复制代码
List<Date> leaveDateList = this.attendanceMapper.queryLeaveDate(staffSalaryVO.getStaffId(),
        AttendanceStatusEnum.LEAVE.getCode(), startDate, endDate);
for (Date date : leaveDateList) {
    if (!DateUtil.isWeekend(date)) {
        leaveWorkdayCount++;
    }
}

休假扣款按「扣除周末后的天数」乘以部门配置金额;法定节假日不跳过。

扣款金额按部门维度加载(SalaryService.java:187-191)。queryDeductRate 先查 sal_salary_deduct,查不到就用枚举默认值(SalaryService.java:204-211):

java 复制代码
SalaryDeduct salaryDeduct = this.salaryDeductService.getOne(new QueryWrapper<SalaryDeduct>()
        .eq("dept_id", deptId).eq("type_num", type));
return salaryDeduct != null ? salaryDeduct.getDeduct() : type.getDefaultValue();

迟到、早退、旷工三类次数装进 attendanceCounts,休假天数单独累计;四类扣款金额装进 deductRates。两个 Map 的键分别是 AttendanceStatusEnum 与 DeductEnum 的 code,两套编码不对齐------迟到在考勤枚举中是 1,在扣款枚举中是 0,混用会取到另一类扣款的金额,静默算错。

加班费、社保与公积金

加班费按当月加班记录汇总(hrm-server/src/main/java/com/qiujie/salary/mapper/SalaryMapper.java:40):

sql 复制代码
select sum(overtime_salary) from att_staff_overtime
where is_deleted = 0 and staff_id = #{id} and date_format(overtime_date,'%Y%m') = #{month}

sum 会忽略 null,调休记录不计入汇总。汇总结果只有在当月存在薪资记录时才计入最终工资(SalaryUtil.java:55-56);没有薪资记录时只填充四类扣款,不计算最终工资。

社保与公积金的金额不在薪资模块计算。工资列表查询时左连接 soc_insurance,直接取出个人社保与公积金金额(SalaryMapper.java:29-30):

sql 复制代码
from sys_staff ss
left join soc_insurance si on ss.id = si.staff_id

si.per_social_pay 对应 socialPay,si.per_house_pay 对应 housePay。薪资计算只做减法,不读取缴费基数与费率。

社保与公积金的金额在「五险一金」里维护。列表按员工展示社保与公积金的缴纳基数、个人与企业缴纳金额:

设置弹窗里填写缴费基数与比例,个人、企业缴纳金额按比例算出:

效果验证

部门设置:在部门管理的「加班设置」页签,为工作日加班配置计费类型、工资倍数与加班奖金。

休息日加班选中「调休」后,计费类型、工资倍数与加班奖金禁用。

加班录入:在加班管理选择月份,查看每日加班状态。新增记录通过「导入」上传 Excel,表头填写员工 id、加班日期,以及上午上班、上午下班、下午上班、下午下班四个时间列,由服务端折算;任务完成后刷新列表,单元格显示加班或调休状态。

点击带「加班」或「调休」标记的日期单元格,弹窗展示加班类型、时长与加班费:

查询加班记录,核对类型、状态、时长与金额:

sql 复制代码
SELECT staff_id, overtime_date, type_num, status, total_overtime, overtime_salary
FROM hrm.att_staff_overtime
WHERE staff_id = 1
ORDER BY overtime_date DESC
LIMIT 5;

工资核对:在工资管理选择同一月份,列表按扣款项目、缴纳项目、实发工资与最终工资分组。

点击「明细」查看基础工资、补贴、奖金、四类扣款、社保公积金与加班费:

sql 复制代码
SELECT staff_id, month, base_salary, day_salary, hour_salary, subsidy, bonus
FROM hrm.sal_salary
WHERE staff_id = 1 AND month = '202610';

SELECT staff_id, social_base, per_social_pay, house_base, per_house_pay
FROM hrm.soc_insurance
WHERE staff_id = 1;

sal_salary 保存基础工资、补贴、奖金与折算的日薪、时薪。扣款列在保存工资时随表单写入,列表展示的扣款与最终工资以查询时的计算为准;社保与公积金从 soc_insurance 核对。若结果不一致,依次检查当月薪资记录、部门扣款配置、加班费汇总与两套枚举的 code。

小坑提醒

  • 调休记录加班费为 null,不是 0 :调休时状态记为调休(2),overtime_salary 留空,sum 会忽略空值。改成 0 虽然当前汇总不变,但会和"时长不足门槛计 0 元"的记录混在一起,之后排查金额时无法区分。
  • 先定类型再查配置:配置按「部门 + 类型」查找,单条录入与批量导入都必须先定类型再取配置;顺序反了,查询时类型为空,配置落空,记录会按无配置处理,金额留空。
  • 考勤次数与扣款金额是两套 code :次数 Map 的键来自 AttendanceStatusEnum,金额 Map 的键来自 DeductEnum;迟到在考勤枚举中是 1、在扣款枚举中是 0,混用会取到另一类扣款的金额。
  • 休假扣款只跳过周末 :DateUtil.isWeekend 不含法定节假日,请假区间覆盖节假日时仍计入扣款天数;需要区分时应在调用方另行处理。
  • 批量导入先定类型再拼缓存键 :缓存键由 deptId 与类型编码组成,类型是键的一部分;每行必须先 resolveType,再拼键查缓存。
  • 逻辑删除与唯一索引一起设计 :调休扣减会逻辑删除最早的一条记录,唯一索引若不排除已删除行,同一天无法重新录入;表达式让已删除行返回 NULL,不参与约束。

小结

  • 加班分设置与记录两张表 :att_overtime 存部门级配置,att_staff_overtime 存员工每天的记录;
  • 四个时间点决定时长,日期决定类型:记录上已有类型时直接采用,否则先判节假日、再判周末、其余归工作日;
  • 调休只发生在休息日:休息日加班、部门允许调休、时长不少于 8 小时三个条件同时成立时,状态转为调休,加班费留空;
  • 计费类型分为按小时与按天:按小时不少于 2 小时、按天不少于 8 小时,金额按「时薪 × 时长」或「日薪」计算,再乘部门倍数并加奖金,缺少配置或薪资时保留空值;
  • 工资按构成公式汇总:基础工资、奖金、补贴与加班费相加,减去四类考勤扣款、社保与公积金;次数来自考勤,金额来自部门配置,缺少配置时回退默认值;
  • 社保与公积金只做减法 :金额由社保模块维护,薪资列表通过 soc_insurance 直接取个人缴纳部分,不在薪资模块重算费率。

下期进入文件存储:附件如何写入 MinIO、上传下载链路如何串联,以及合并分片时 composeObject 怎样避免把整个文件读进内存。

GitHub :github.com/quuuuj/hrm

相关推荐
captain3761 小时前
Spring IoC和DI(2)
java·后端·spring
小弥儿1 小时前
GitHub今日热榜 | 2026-10-20:智能体基建七席连庄,生成类项目回流
学习·开源·github
江厌012 小时前
两张卡部署两个模型:物理分卡、MIG、张量并行的选择与可复现配置
github·模型部署·vllm
梦想很大很大2 小时前
重新思考 Workrun:AI 时代,我们还需要手动编排 Workflow 吗?
前端·人工智能·后端
2501_933923252 小时前
SpringCloud组件:Nacos服务注册快速入门
后端·spring·spring cloud·nacos
问天_观心2 小时前
模型微调学习(二)
开发语言·人工智能·学习·语言模型·github
小羊没烦恼!2 小时前
深入浅出SQL Server中的死锁
java·开发语言·前端·数据库·后端·spring·c#
回眸&啤酒鸭2 小时前
【回眸】GitHub Copilot 智能编程辅助效果实测
github·copilot
柠檬味拥抱2 小时前
基于 YOLO11 的海底垃圾视觉巡检系统:水下 ROV + Streamlit 全流程实战
后端