又到了月底排班的日子。以前我用Excel手排,几十号人、四五个班次来回拖拽,排一次少说两三个小时,每次排完还得让班长逐行核对有没有人同一天排了两个班、有没有人连着上了七八天夜班。后来索性用Java写了一套企业排班系统,把这摊事彻底从手工操作里解放出来。这套系统从需求梳理、数据库设计到规则引擎和自动排班算法,踩了不少坑,也沉淀了不少经验,这篇文章把整个项目的核心设计思路和关键实现细节都捋一遍。
整套系统围绕一个朴素的目标:把"排班"这件事从Excel表格里搬到系统里,让排班规则可配置、排班结果可校验、班次调整有审批、工时统计自动算。对正打算做类似业务系统、或者面试准备阶段想找个完整Java项目练手的开发者来说,这篇内容能直接作为项目参考。
1. 排班系统到底在解决什么问题
1.1 手工排班的三大痛点
第一是排班效率低。门店、工厂、客服中心这些岗位,班次种类多,早班、中班、晚班、夜班,还有轮休、调休、节假日值班。用Excel一个个格子填,填完还要用条件格式标出冲突,操作步骤多不说,一个不留神就会漏看。第二是规则靠人肉记忆。谁上周连续上了五个夜班,谁这周休息不够,谁月底工时还差多少,全都装在班长的脑子里,换一个人排班,规则就变了。第三是排班结果不透明。员工对班次有疑问的时候,没有历史记录可查,争议常年说不清。
这套系统就是把这三类问题拆成一个个具体功能模块:基础员工和班次管理、日历视图排班、自动排班引擎、换班调班审批、工时统计和异常提醒。
1.2 系统功能的边界怎么定
做企业级系统最容易犯的错误就是上来就想做得"大而全"。我一开始也列了一大堆需求,后来反复精简,最终落地的是这么几块:
| 功能模块 | 具体内容 | 核心价值 |
|---|---|---|
| 基础数据 | 员工信息、角色权限、班次定义 | 排班的对象和口径统一 |
| 排班管理 | 日历拖拽排班、按模板批量排班、自动排班 | 替代Excel手工操作 |
| 审批流程 | 换班申请、调班申请、加班申请 | 留痕、合规、可追溯 |
| 考勤统计 | 工时汇总、出勤天数、异常标记 | 月底报表一键导出 |
这个边界定下来之后,系统的开发量大概在一个Java工程师两到三周的工作量。后端用Spring Boot,前端用Vue,数据库用MySQL,部署在内部服务器上,完全够用。如果是给几十人规模的小团队用,这套架构三五年不用担心性能问题。
2. 技术选型与整体架构设计
2.1 为什么是Spring Boot加MyBatis-Plus
这个项目没有用更复杂的微服务架构,单机单体应用就够了。Spring Boot 2.x全家桶是Java企业开发的标配,内嵌Tomcat让部署变成一条java -jar命令。选MyBatis-Plus而不是原生MyBatis或JPA,主要是看中它内置的分页插件和代码生成器,排班这种CRUD占比很高的业务,能省掉大量重复的Mapper XML编写。
权限认证我用的Spring Security加JWT,没有引入Spring Cloud那一套,也没有用Shiro。Shiro虽然轻量,但Spring Security和Spring Boot的整合更顺滑,注解式权限控制写起来也更简洁。JWT的好处是前后端完全分离,小程序端后续如果要接入,直接复用同一套认证逻辑就行。
2.2 后端模块划分与前端页面结构
后端按业务域拆包,不是按三层架构拆:
com.company.schedule ├── controller # 接口层 ├── service # 业务逻辑 ├── mapper # MyBatis-Plus数据访问 ├── entity # 数据库实体 ├── dto # 接口传输对象 ├── enums # 班次类型、审批状态等枚举 ├── strategy # 排班规则策略实现 └── utils这种按业务域划分的方式,对排班这种业务逻辑比较复杂、又需要挂载多种规则的系统来说,找代码的时间成本和扩展成本都更低。比如后面想新增一个"连续夜班不超过三天"的规则,只需要在strategy包里加一个实现类,然后注册到规则链里就行,不用动原有的核心排班逻辑。
前端页面我划分成五个模块:排班日历、班次管理、员工管理、审批中心、统计报表。排班日历用FullCalendar改的,支持周视图和月视图切换,拖拽班次块就可以完成排班和换班操作。其他模块用Vue加Element UI做CRUD页面,整个前端代码量不算大,但实际体验很接近商业排班软件。
2.3 核心数据库表设计
表是整个系统的地基,表设计一旦有缺陷,后面写多少代码都补不回来。我实际落地的核心表一共六张:
| 表名 | 用途 | 关键字段 |
|---|---|---|
| employee | 员工表 | id, name, department_id, role_type |
| shift | 班次定义表 | id, shift_name, start_time, end_time, work_hours |
| schedule | 排班记录表 | id, employee_id, shift_id, work_date, status |
| swap_request | 换班申请表 | id, from_employee, to_employee, from_date, to_date, status |
| attendance | 考勤表 | id, employee_id, work_date, check_in, check_out, status |
| leave | 请假表 | id, employee_id, start_date, end_date, type, status |
排班记录表是核心中的核心,我额外加了一个唯一索引(employee_id, work_date),这个索引在后面的冲突检测和并发控制里立了大功。班次定义表里一定要存work_hours,不要用开始时间减结束时间去算,因为不同的班次可能时长一样但工作内容不同,而且夜班跨天的情况计算逻辑比较复杂,直接存好工时数字是最省心的方式。
3. 排班管理的数据模型与核心流程
3.1 用Java枚举管理班次和审批状态
Java的枚举在这个项目里用得非常多。班次类型、排班记录状态、审批状态、考勤异常类型,这些字段如果用字符串零散地写在业务代码里,后期维护就是一场灾难。我在enums包里定义了几个核心枚举类,例如排班状态:
public enum ScheduleStatus { DRAFT(0, "草稿"), PUBLISHED(1, "已发布"), ADJUSTED(2, "已调整"), CONFIRMED(3, "已确认"); private final int code; private final String desc; ScheduleStatus(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } public String getDesc() { return desc; } }审批状态也一样,PENDING、APPROVED、REJECTED三个枚举值,配合状态流转的判断方法,比如只有处于PENDING状态时才能调用approve方法,从底层避免一些非法的状态跳转。
3.2 自动排班的规则引擎设计
自动排班是整个系统最核心也最复杂的模块。排班本质上是一个约束满足问题,企业里用得最多的是两类约束:硬约束和软约束。硬约束是绝对不能违反的,比如一个人同一天不能排两个班次、法定节假日必须有人值守但不能安排员工连续工作超过七天。软约束是尽量满足的,比如每个员工的夜班次数尽量均衡、每个月的总工时尽量接近。
我采用策略模式把这两类约束封装成独立的规则类:
public interface ScheduleRule { boolean isSatisfied(ScheduleContext context, ScheduleCandidate candidate); int getWeight(); }每个规则实现类只负责一件事,比如MaxConsecutiveWorkDaysRule检查连续工作日上限,NightShiftBalanceRule检查夜班分布均衡度。自动排班算法遍历候选员工时,对所有规则逐个判断,每条软约束规则都会给出一个评分,最后选评分最高的员工填入排班记录。
3.3 排班生成与冲突检测算法
第一版自动排班我用的是贪心算法加回溯修正。大致思路是:先按日期逐天生成排班需求,每天每个班次需要几个人,然后从可用的员工池里筛选符合硬约束的人选,按软约束得分排序,取出得分最高的人填入。
伪代码如下:
for date in schedule_range: for shift in shifts: required = shift.required_count assigned = 0 candidates = get_eligible_employees(date, shift) candidates.sort(key=lambda e: score(e, date, shift), reverse=True) for emp in candidates: if assigned >= required: break if is_conflict(emp, date, shift): continue assign(emp, date, shift) assigned += 1这个逻辑在绝大多数场景下都能跑出可用的结果。如果某个班次始终填不满,说明硬约束过于严格,这时候不是算法的问题,是规则配置的问题。系统的做法是把这种情况作为异常暴露给排班管理员,让管理员手动调整规则参数,而不是让算法无限重试。
3.4 换班审批流程怎么设计
员工日常使用频率最高的功能其实是换班。A和B某天都有排班,但A临时有事想和B互换,不能直接私下换了就完事,因为换班可能违反工时规定。系统的换班流程是这样处理的:A发起换班申请,选择希望互换的日期和班次,系统校验目标日期B的班次是否和A的班次兼容,校验通过后生成待审批的申请单,推送给主管,主管在审批中心里同意后,系统才真正交换两人的排班记录。
审批过程中的并发问题很关键,我实际遇到过两人同时发起换班、互换同一个班次,结果一人两张票全被批准的情况。解决办法是在swap_request表上增加一个幂等判断,同一对员工同一对日期的申请单,如果在PENDING状态下就不允许重复创建,同时排班记录更新操作放到事务里执行,保证要么都不更新,要么都更新成功。
4. 考勤统计与工时汇总的实现细节
4.1 工时计算里最容易被坑的跨天问题
夜班是排班系统绕不开的坎。晚班从晚上九点上到第二天早上六点,开始时间和结束时间跨了午夜,如果直接拿结束时间减去开始时间会算出负数。处理方法是在shift表设计时增加一个is_cross_day字段,计算时统一换算成分钟数,跨天班次在结束时间上加24小时再减:
private int calcWorkMinutes(LocalTime start, LocalTime end, boolean crossDay) { int startMinute = start.getHour() * 60 + start.getMinute(); int endMinute = end.getHour() * 60 + end.getMinute(); if (crossDay) { endMinute += 24 * 60; } return endMinute - startMinute; }这个方法看着简单,但实际测试的时候不踩一次坑是想不到的。比如结束时间是00:30,不跨天处理的话结束分钟就是30,减去开始时间21:00对应的1260分钟,得到-1230,存进数据库会直接报错或者产生一条负数工时的脏数据。加了这个跨天标志之后,统计就稳了。
4.2 考勤异常自动标记
考勤数据来源通常有两种,一种是员工在签到设备上打卡,系统定时同步过来;另一种是排班系统本身提供一个手工补录入口。不管是哪种来源,系统要做的事情是比对实际打卡时间和排班班次的上下班时间,然后自动划分正常、迟到、早退、缺勤、异常这几类。
具体逻辑是:排班要求九点上班,实际打卡时间在九点到九点十分之间算正常,九点十分到三十分钟算迟到,超过三十分钟且没有请假记录就算缺勤。这个判定逻辑放在service层而不是数据库SQL里,因为涉及很多业务规则,比如迟到三十分钟内不扣工时、月度累计三次迟到算一次缺勤之类,用代码表达更灵活。
4.3 月度统计报表的实现
统计报表我用了一个比较取巧的方式:不实时计算,而是用Spring的@Scheduled定时任务在每个月月末跑一次汇总任务,把统计结果写进一张报表缓存表。这样做的好处是月底导出报表的时候接口响应时间从秒级降到毫秒级,不会因为几十个部门同时导报表把数据库拖垮。
报表的核心SQL是围绕schedule表和attendance表做的关联分组查询,但真正复杂的是各种口径的定义。比如"出勤天数",国定节假日放假但员工没被排班,算不算出勤?我最终按公司的考勤制度设定为:排了班且正常打卡算1天,排了班但请假算0.5天,没排班但有节假日值班通知算1天。这些口径我必须和业务方一条条核对清楚再去写统计逻辑,否则做出来就是数字对不上的废表。
5. 常见问题与排查技巧实录
5.1 并发重复排班导致数据错乱
第一次上线的时候,两个管理员同时打开排班日历,给同一个人同一个日期排了不同的班次,结果最后一条覆盖了前一条。排查下来发现是没有做并发控制。后来我在schedule表加了(employee_id, work_date)唯一索引,同时在新增排班记录的时候捕获DuplicateKeyException,把它翻译成友好的提示返回给前端。
try { scheduleMapper.insert(record); } catch (DuplicateKeyException e) { throw new BizException("该员工当天已存在排班记录,请勿重复操作"); }这个方案比用分布式锁或者select-for-update都简单有效,因为唯一索引是在数据库层面做的约束,不管并发请求从哪个入口进来,最终都会被数据库拦住,不会出现漏网之鱼。
5.2 批量排班性能偏低怎么办
自动排班一次性生成整个月的排班记录,一开始用单条insert逐条查询逐条插入,生成三百条记录要跑好几秒。后来优化成批量插入,MyBatis-Plus的saveBatch默认分批大小是1000,直接把耗时降到几百毫秒。还有一个优化是把预校验逻辑前置,先用一条SQL查出所有员工的所有已有排班记录,加载到内存里做HashMap缓存,而不是在一个循环里反复查询数据库。
5.3 排班日历和数据库时区不一致
前端同事反馈说日历上显示的日期和数据库存的日期对不上,排查了半天,最后发现是数据库连接的serverTimezone参数配的不是Asia/Shanghai,导致使用日期字段时系统自动做了时区转换。这是个非常经典的环境配置问题,检查清单里的第一项永远是数据库连接串的时区参数。
5.4 权限控制与操作留痕
排班系统里操作权限的粒度其实很细。普通员工只能查看自己的排班并发起换班申请,班组长可以排自己组的班次但改不了其他组的,HR可以看全公司的班次但审批要主管来做。Spring Security加自定义权限注解,可以很方便地实现这些规则:
@PreAuthorize("hasRole('ADMIN') or @scheduleAuthService.canEdit(#employeeId)") public void updateSchedule(Long employeeId, ScheduleDTO dto) { // ... }关于操作留痕,我在切面里做了一层操作日志记录,用AOP拦截controller层的写操作,把操作人、操作时间、被修改的排班记录前后值都存进日志表。后来真的有员工对班次调整提出异议,全靠这份日志还原了操作过程,避免了扯皮。
6. 排班算法优化方向与后续扩展
第一版贪心算法上线后,大部分时候表现都还不错,但遇到规则特别多、班次特别密集的制造型企业,比如一线工人需要三班倒加轮休,贪心结果经常出现局部最优,某个员工连续被排了很重的班次。这时候就需要升级算法。
我调研过用遗传算法解决排班问题的方案,思路很清晰:用一条染色体表示一整月的排班方案,基因位是某个员工某天的班次编码;初始化时生成多个随机可行的排班方案作为初始种群;适应度函数就是把硬约束和软约束的违反程度换算成惩罚分,惩罚分越低代表排班方案越好;迭代过程中通过选择、交叉、变异不断逼近更优方案。
实际落地时有一个很实用的技巧:把贪心算法的结果作为初始种群里的一个个体,而不是全部随机初始化,这样算法一开始的起点就比较高,收敛速度大幅提升。这个项目后来因为业务规模不大,贪心加人工微调已经完全够用,遗传算法就作为一个实验性模块放在后台,但对标真正的商业化排班软件,这个方向是绕不开的。
我个人在实际操作中的体会是,做这类企业业务系统,业务规则的理解比技术选型重要得多。技术上的难题,比如跨天班次、并发冲突、批量插入优化,都有成熟的解决方案,多花点时间都能搞定。反倒是排班规则的口径确认,比如什么情况算正常出勤、换班审批的时效要求、周休至少保证几天,这些不花时间跟业务方一条条理清楚,代码写得再漂亮也是空中楼阁。这套Java排班系统我从头跟到尾,最大的收获不是学会了某个框架的某个用法,而是明白了业务系统真正的复杂度从来不在代码里,而在那些随时可能变化的规则中。系统上线后的第一周,我每天守在电脑前盯着日志和用户反馈,接连接了十几个电话,改了不少细节。等系统真正稳定下来的时候,那种踏实感比写完任何一段炫技代码都强。