简介:这是一份面向Java开发者的实用工具类代码资源,聚焦于「计算工作时间时排除节假日与双休日」这一常见业务需求,适用于考勤统计、工时核算、项目排期等场景,属于中初级开发者即可上手的技能层级。资源包内共1个PDF文件,大小约74KB,内容以代码讲解与实现思路为主,围绕CalculateHours类展开,涵盖上下班时间配置、每日与每周工时换算、完整星期数累加、工作日天数统计以及同天与跨天时段的差值处理等关键逻辑,并给出节假日与双休日的排除方案。目前已有4558人学习下载,说明该思路在实际开发中具有较高的参考价值。读者可从中获取可直接借鉴的工时计算实现代码、时间预处理与边界情况处理技巧,以及将业务规则落地为Java方法的完整思路,便于快速应用到考勤或项目管理系统之中。
1. 工时计算为什么总在节假日上翻车
做过考勤、OA、工单 SLA 或者项目排期的同行,大概率都写过一段“算两个时间点之间有多少个工作日”的代码。第一版通常很朴素:总时长减去周末天数,乘个每天 8 小时,收工。上线之后问题就来了——调休上班的周六被当成休息日扣掉了,法定节假日没扣,跨年的时候算法直接算错一天,用户拿着考勤表来对账,你只能一行行手算复现。这类需求看着简单,本质是「日历数据 + 区间遍历 + 时段裁剪」三件事叠在一起,任何一件偷懒都会在边界上翻车。
这篇讲的就是用 Java 把「计算工作时间、除去节假日以及双休日」这件事做扎实:怎么组织节假日数据、怎么处理调休、怎么把一天切成上午下午两段、跨天跨月跨年怎么算、半天请假怎么扣。适合正在写考勤/排期/SLA 的开发者,也适合已经写了一版但被边界 case 反复打脸、想重构的人。下面按「数据怎么来 → 核心算法怎么写 → 坑在哪 → 怎么验证」的顺序推。
2. 先把日历数据这层地基打对
很多人一上来就写循环,结果写到一半发现没有可靠的节假日数据源,只能硬编码几个日期,第二年就失效。工时计算的第一性问题不是算法,是数据。这一章把数据模型和加载方式定下来,后面所有计算都建立在这上面。
2.1 为什么不能只用 WeekFields 判断周末
java.time里的DayOfWeek和WeekFields能告诉你某天是周六还是周日,但它不知道「这个周六是不是调休上班」。国内的工作日安排里,法定节假日会通过调休把某些周末变成工作日,也会把某些工作日变成休息日。只按DayOfWeek.SATURDAY/SUNDAY判断,遇到调休必然算错。
常见做法是维护一张「特殊日期表」,只记录与默认规则不一致的日子,而不是把全年 365 天都列出来。默认规则是「周一到周五上班,周六周日休息」,特殊表里只放两类:法定节假日(本该上班却放假)和调休上班日(本该休息却上班)。这样数据量小,人工维护成本低,出错面也窄。
数据来源上,正规做法是从权威渠道获取每年发布的工作日安排,落到自己的库里或配置文件里。不要依赖第三方接口实时查询,接口挂了你的考勤就算不出来。落地成一张表最稳:
| 字段 | 类型 | 说明 |
|---|---|---|
| date | LocalDate | 日期,主键 |
| day_type | int | 0 工作日,1 休息日 |
| remark | varchar | 备注,如「春节调休」 |
day_type用 0/1 而不是布尔,是为了以后扩展半天班、特殊班次留余地。查询时按日期范围批量捞出来放进Map<LocalDate, Integer>,避免在循环里逐天查库。
2.2 用 Map 缓存特殊日期并做兜底
加载逻辑要处理「查不到」的情况。如果某天既不在特殊表里,也不是周末,那它就是普通工作日;如果是周末又不在特殊表里,就是普通休息日。这个兜底规则必须写死,否则数据缺失时算法行为不可预测。
import java.time.DayOfWeek; import java.time.LocalDate; import java.util.HashMap; import java.util.Map; public class WorkCalendar { // key: 日期, value: 0 工作日 / 1 休息日 private final Map<LocalDate, Integer> specialDays = new HashMap<>(); public void loadSpecialDays(Map<LocalDate, Integer> data) { specialDays.clear(); specialDays.putAll(data); } /** * 判断某天是否为工作日 * 优先查特殊表,查不到再按周末规则兜底 */ public boolean isWorkday(LocalDate date) { Integer type = specialDays.get(date); if (type != null) { return type == 0; } DayOfWeek dow = date.getDayOfWeek(); return dow != DayOfWeek.SATURDAY && dow != DayOfWeek.SUNDAY; } }这段代码的关键点是判断顺序:先查特殊表,命中就直接返回,没命中才走周末兜底。顺序反了的话,调休上班的周六会被周末规则判成休息日,直接算错。specialDays用HashMap而不是TreeMap,因为这里只做点查,不需要范围遍历;如果后面要做区间统计,再换成TreeMap用subMap批量取。
参数上唯一需要留意的是loadSpecialDays的入参。实际项目里它来自数据库或配置中心,建议按年加载,一次把当年数据全灌进来,而不是每次计算都查一遍。跨年计算时要把两年的数据都加载,否则 12 月 31 日到 1 月 1 日这段会漏掉特殊日期。
3. 核心算法:区间遍历与每日工时裁剪
数据层稳了,接下来是算法主体。这里要解决两个问题:一是怎么遍历日期区间,二是每天到底算几个小时。很多人只做了第一层,把每天固定按 8 小时算,结果遇到半天请假、上午入职、下午离职就全错。
3.1 按天遍历并累加有效工时
最直观也最不容易出错的思路是「逐天遍历,每天单独算有效工时,最后求和」。相比用总天数乘系数,逐天算的好处是每个边界都能单独处理,调试时能打印出每一天的判定结果,出问题一眼看出是哪天错了。
import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; public class WorkTimeCalculator { // 上午 09:00-12:00,下午 13:30-18:00 private static final LocalTime AM_START = LocalTime.of(9, 0); private static final LocalTime AM_END = LocalTime.of(12, 0); private static final LocalTime PM_START = LocalTime.of(13, 30); private static final LocalTime PM_END = LocalTime.of(18, 0); private final WorkCalendar calendar; public WorkTimeCalculator(WorkCalendar calendar) { this.calendar = calendar; } /** * 计算 [start, end] 区间内的工作时长(分钟) * 区间为左闭右开,end 不参与计算 */ public long calcMinutes(LocalDateTime start, LocalDateTime end) { if (!start.isBefore(end)) { return 0; } long total = 0; LocalDate curDate = start.toLocalDate(); LocalDate endDate = end.toLocalDate(); while (!curDate.isAfter(endDate)) { if (calendar.isWorkday(curDate)) { // 当天有效区间的起点 LocalDateTime dayStart = curDate.atStartOfDay(); LocalDateTime dayEnd = curDate.plusDays(1).atStartOfDay(); // 与传入区间求交集 LocalDateTime from = start.isAfter(dayStart) ? start : dayStart; LocalDateTime to = end.isBefore(dayEnd) ? end : dayEnd; total += workMinutesOfDay(from, to); } curDate = curDate.plusDays(1); } return total; } /** * 计算某一天内 [from, to] 落在工作时段里的分钟数 */ private long workMinutesOfDay(LocalDateTime from, LocalDateTime to) { long minutes = 0; minutes += overlapMinutes(from, to, AM_START, AM_END); minutes += overlapMinutes(from, to, PM_START, PM_END); return minutes; } /** * 求 [from, to] 与 [segStart, segEnd] 的重叠分钟数 */ private long overlapMinutes(LocalDateTime from, LocalDateTime to, LocalTime segStart, LocalTime segEnd) { LocalDate date = from.toLocalDate(); LocalDateTime s = date.atTime(segStart); LocalDateTime e = date.atTime(segEnd); LocalDateTime realStart = from.isAfter(s) ? from : s; LocalDateTime realEnd = to.isBefore(e) ? to : e; if (!realStart.isBefore(realEnd)) { return 0; } return Duration.between(realStart, realEnd).toMinutes(); } }逻辑分三层。第一层calcMinutes负责按天推进,把跨天区间拆成一天一段。第二层workMinutesOfDay把一天拆成上午、下午两个工作时段。第三层overlapMinutes做区间求交,这是整个算法的核心——不管传入的from/to落在哪,只要和某个工作时段有重叠,就取重叠部分。
参数上有几个点必须说清楚。AM_START/AM_END/PM_START/PM_END是硬编码的,实际项目里应该做成配置,因为不同公司、不同班次的上下班时间不一样。区间用左闭右开[start, end),这样相邻两段拼接时不会重复计算边界那一分钟。Duration.between(...).toMinutes()返回的是整数分钟,如果业务需要精确到秒,换成getSeconds()再除 60,但考勤场景一般分钟级就够。
3.2 半天请假和跨天场景怎么处理
上面的算法天然支持半天场景。比如某人上午请假,只算下午,那传入的start就是当天 13:30,overlapMinutes在上午段求交时realStart会大于realEnd,返回 0,下午段正常累加。不需要为半天写特殊分支,这是区间求交带来的好处。
跨天场景也一样。晚上加班到次日凌晨,calcMinutes会先算当天 18:00 之前的部分,再进入第二天,第二天如果是工作日,从 09:00 开始算到实际结束时间。如果第二天是休息日,isWorkday返回 false,直接跳过,加班时长只算到当天 18:00。这里有个业务分歧点:加班到休息日算不算工时,取决于你们公司的规则,算法层面只需要控制isWorkday的判定,不用改遍历逻辑。
跨月跨年不需要特殊处理,因为LocalDate.plusDays(1)会自动进位,isWorkday查的是具体日期,只要特殊日期表加载了对应年份的数据就没问题。这也是为什么前面强调要按年加载、跨年时加载两年。
3.3 用 TreeMap 优化大区间统计
逐天遍历在区间跨度大时会有性能问题。算一个人一年的工时,循环 365 次,每次两次求交,问题不大;但如果要做全公司几千人、每人几年的批量统计,逐天遍历就偏慢。这时候可以把特殊日期表换成TreeMap,用subMap一次性取出区间内的所有特殊日期,再结合总天数做批量计算。
import java.time.temporal.ChronoUnit; import java.util.TreeMap; public long countWorkdaysFast(LocalDate start, LocalDate end) { long totalDays = ChronoUnit.DAYS.between(start, end); // 先按周末规则粗算 long workdays = 0; // 这里仍需要遍历,但可以只遍历特殊日期做修正 // 思路:总天数 - 周末天数 + 调休上班天数 - 法定节假日天数 return workdays; }这个优化思路是「先按默认规则算,再用特殊日期做增减修正」,避免逐天判断。但修正逻辑要小心:调休上班的周六本来被周末规则扣掉了,要加回来;法定节假日本来被算成工作日,要减掉。特殊日期表里day_type=0的是调休上班,day_type=1的是法定节假日,分别统计数量做加减。这个版本适合批量统计,单次精确计算还是用逐天遍历更直观、更不容易错。
4. 避坑与排查:那些让工时算错的细节
算法写完了不代表就对了。下面这几条是我在实际项目里踩过的,每条都按「现象 → 原因 → 解决」说清楚,遇到对不上账的时候可以照着排查。
4.1 跨年计算少一天
现象:算 12 月 30 日到次年 1 月 3 日的工时,结果比手算少一天。原因:特殊日期表只加载了当年数据,次年的 1 月 1 日没查到,走了周末兜底,但 1 月 1 日可能是周三,兜底判成工作日,反而多算;如果 1 月 1 日是调休,又会少算。解决:加载数据时按区间年份加载,start.getYear()到end.getYear()之间的每一年都要加载,不要只加载起始年。
4.2 边界分钟被重复计算
现象:上午 12:00 结束、下午 13:30 开始,算出来比实际多一分钟或少一分钟。原因:区间开闭没统一。如果上午段用[9:00, 12:00]闭区间,下午段用[13:30, 18:00],中间不重叠没问题;但如果有人把结束时间设成 12:00 又用闭区间,和下一段的起点判断就会打架。解决:全篇统一左闭右开,overlapMinutes里用realStart.isBefore(realEnd)判断,相等时返回 0,边界那一分钟只归属一段。
4.3 调休上班日被当成休息日
现象:某个周六调休上班,工时算出来是 0。原因:isWorkday里先判断了周末再查特殊表,或者特殊表里这条数据day_type存成了 1。解决:确认判断顺序是先查特殊表,再走周末兜底;同时检查数据导入时调休日的day_type是不是 0。建议在isWorkday里加一行日志,把命中的特殊日期和判定结果打出来,排查时直接看日志。
4.4 时区导致日期偏移
现象:服务器在 UTC 时区,用户在东八区,晚上 8 点之后提交的工时算到了第二天。原因:LocalDateTime不带时区,如果从Instant或Date转换时没指定时区,会按服务器默认时区解释。解决:入口处统一用ZonedDateTime转成业务时区的LocalDateTime,转换时显式指定ZoneId,不要依赖ZoneId.systemDefault(),因为服务器时区可能和业务时区不一致。
4.5 半天请假的起止时间传反
现象:上午请假的人,传入start=13:30, end=18:00,算出来是 0。原因:调用方把请假区间当成了工作区间传进来,而算法期望的是「实际工作区间」。解决:在接口层明确约定入参语义,是「工作区间」还是「请假区间」,两者是互补关系。如果传的是请假区间,需要先做一次取反,或者干脆提供两个方法calcWorkMinutes和calcLeaveMinutes,避免调用方混淆。
5. 用测试用例把边界钉死
算法类代码最怕的是「看起来对」。工时计算涉及日期、时段、特殊规则三层组合,光靠人工点几下测不出问题。我的习惯是先把边界用例列出来,写成单元测试,跑绿了再上线。下面这张表是我一般会覆盖的场景,可以直接抄。
| 场景 | 起始时间 | 结束时间 | 预期 |
|---|---|---|---|
| 同一天正常工作时段 | 周一 10:00 | 周一 15:00 | 4.5 小时 |
| 跨午休 | 周一 11:00 | 周一 14:00 | 2.5 小时 |
| 跨天 | 周一 17:00 | 周二 10:00 | 2.5 小时 |
| 跨周末 | 周五 17:00 | 下周一 10:00 | 2.5 小时 |
| 含法定节假日 | 节前 17:00 | 节后 10:00 | 2.5 小时 |
| 含调休上班周六 | 周五 17:00 | 周六 10:00 | 2.5 小时 |
| 起止相同 | 周一 10:00 | 周一 10:00 | 0 |
| 起止颠倒 | 周一 15:00 | 周一 10:00 | 0 |
写测试时有个技巧:不要只断言总分钟数,把每天的明细也断言上。比如跨天场景,断言「周一贡献 60 分钟,周二贡献 90 分钟」,这样一旦算错,测试失败信息能直接告诉你是哪天错了,而不是只告诉你总数不对。实现上可以把calcMinutes拆一个返回每日明细的版本,测试用明细版,生产用汇总版。
import org.junit.jupiter.api.Test; import java.time.LocalDateTime; import static org.junit.jupiter.api.Assertions.assertEquals; public class WorkTimeCalculatorTest { @Test void crossLunchBreak() { WorkCalendar calendar = new WorkCalendar(); WorkTimeCalculator calc = new WorkTimeCalculator(calendar); LocalDateTime start = LocalDateTime.of(2025, 3, 3, 11, 0); // 周一 LocalDateTime end = LocalDateTime.of(2025, 3, 3, 14, 0); // 11:00-12:00 计 60 分钟,13:30-14:00 计 30 分钟 assertEquals(90, calc.calcMinutes(start, end)); } }这段测试的关键是注释里写清楚每一段贡献多少分钟,后面有人改算法时,一看注释就知道预期是怎么来的。日期选周一,避开周末和节假日干扰,让测试只验证「午休裁剪」这一个变量。如果要测节假日,就在calendar.loadSpecialDays里塞一条对应日期的数据,把外部依赖控制住,测试不依赖数据库。
上线之后也别把测试扔了。每年工作日安排发布后,第一件事是把新数据导进去,跑一遍全量测试用例,确认跨年、调休这些场景没退化。我一般还会留一个「对账脚本」,拿系统算出来的月工时和人工抽样的几天做比对,连续几个月对得上,才敢说这个模块稳了。工时计算这种事,宁可上线前多花两小时写用例,也别上线后被考勤对账追着跑。希望帮到你。
本文还有配套的精品资源,点击获取