Java工时计算避坑指南:节假日扣减与时间吸附逻辑详解
简介这是一份面向Java开发者的实用工具类代码资源聚焦于「计算工作时间时排除节假日与双休日」这一常见业务需求适合需要处理考勤统计、工时核算、项目排期等场景的中初级开发者参考。资源包内共1个PDF文件大小约74KB内容以代码讲解与实例演示为主完整呈现了CalculateHours类的实现思路先定义上午、下午的上下班时间换算出每日与每周工时再通过Date对象转换、时间预处理、完整星期数累加、工作日天数循环判断等步骤逐步剔除周末与节假日影响并针对起止时间是否同一天分别处理午休扣除逻辑。读者可从中获取可直接借鉴的工时计算代码框架、时间边界处理技巧以及日期循环判断的排错思路便于按自身考勤规则调整上下班时间参数后快速落地。目前已有4558人学习下载具备一定的参考价值。1. 从一张考勤表说起Java 算工时为啥总在节假日上翻车月初核算项目工时财务丢过来一张考勤表要求把每个人在项目周期内的有效工作时长算出来双休日和法定节假日一律剔除。很多人第一反应是拿两个时间戳相减再除以 3600结果一跑就发现数字对不上——跨周末的区间多算了十几个小时撞上节假日的区间更是离谱。这类需求在工时统计、SLA 计时、工单超时判定里反复出现核心难点从来不是减法而是「哪些时间段算工作时间」这件事本身没有统一答案。这份 Java 资源给的就是一套可落地的计算思路把每天拆成上午、下午两个工作段先按自然时间算出毛工时再扣掉周末和一份硬编码的节假日清单。它适合做内部管理系统、外包结算、项目排期的后端同学直接拿去改也适合刚接触日期计算、想搞明白Calendar和Date边界行为的人对照着读。下面我按「先跑通、再拆解、最后避坑」的顺序把这份代码从头到尾过一遍。2. 把 CalculateHours 跑起来环境、入口与第一次输出2.1 类结构与依赖一个文件搞定不引第三方包这份代码只有一个类CalculateHours放在common.util包下依赖全是 JDK 自带的java.text、java.util包没有引入任何第三方日期库。这一点对老项目很友好——很多内部系统还停留在 JDK 7/8引入 Joda-Time 或 java.time 反而要改构建配置。你只需要把源码复制进项目改一下包名就能用。类里定义了两组关键字段。第一组是上下班时间点用小时和分钟分开存int abh 9; // 上午上班 9 点 int abm 0; // 上午上班 0 分 int aeh 12; // 上午下班 12 点 int aem 0; int pbh 13; // 下午上班 13 点 int pbm 0; int peh 18; // 下午下班 18 点 int pem 0;第二组是由这些时间点换算出来的浮点小时数h1到h4以及派生量hoursPerDay每天工时、daysPerWeek每周工作天数、milsecPerDay一天的毫秒数、hoursPerWeek每周工时。这里用float而不是double是因为工时通常保留一位小数float够用且省内存但要注意后面Math.round(a*10)/10的舍入方式。提示abh这类字段是实例字段而非static意味着每次new CalculateHours()都可以带一套独立的作息配置。如果你的系统里不同部门作息不同可以把它改成构造器传参而不是改源码。2.2 主流程三步走预处理、整周折算、尾段补齐calculateHours(String beginTime, String endTime)是整个类的入口逻辑分三步。第一步字符串转Date并做预处理。stringToDate用SimpleDateFormat(yyyy-MM-dd HH:mm:ss)解析解析失败会打印堆栈并返回null——这是个隐患后面避坑章节会讲。拿到t1、t2后分别调用processBeginTime和processEndTime把落在非工作时间的时间点「吸附」到最近的工作时段边界。第二步算完整星期数并折算。用(t2 - t1) / (一天毫秒 * 7)得到跨越的完整周数weekCount直接乘以hoursPerWeek累加进totalHours然后把t2往前挪weekCount周让首尾落在同一个星期周期内。第三步逐日推进补齐尾段。while循环每次把t1往后推一天推完再调一次processBeginTime做吸附如果推完还早于t2就计入一个工作日dayCounts否则跳出。最后根据首尾是否同一天分别用hh2 - hh1或tt1 tt2的公式补上零头中间还要减掉午休时段(h3 - h2)。跑一次main方法验证public static void main(String[] args) throws ParseException { String beginTime 2018-6-1 9:00:00; String endTime 2018-6-4 10:10:00; CalculateHours ch new CalculateHours(); float b ch.calculateHours(beginTime, endTime); System.out.println(b); float a CalculateHours.CalculateHour(beginTime, endTime); System.out.println(a); }2018-6-1是周五2018-6-4是周一。calculateHours算的是含周末的毛工时CalculateHour在此基础上扣掉节假日。两个输出一对比就能看出节假日扣减是否生效。2.3 节假日扣减CalculateHour 与 arrContrast 的配合CalculateHour是静态方法先调calculateHours拿到毛工时a再用printDay生成起止日期之间的所有日期字符串和硬编码的holiday数组做比对。String[] holiday new String[]{01-01,01-02,01-03,05-01,05-02,05-03, 10-01,10-02,10-03,10-04,10-05,10-06,02-08,02-09,02-10};注意这里只存了「月-日」年份是运行时用Calendar.getInstance().get(Calendar.YEAR)取的当前年份拼上去的。这意味着它只能处理「今年」的节假日跨年区间会算错。arrContrast负责去重和计数先把workday数组去重放进LinkedList再遍历holiday数组命中一个就remove并count返回的count乘以 8 就是应扣的节假日工时。printDay用Calendar逐日加一从startDay推到endDay把中间每一天格式化成yyyy-MM-dd存进数组。注意它的循环是先add(Calendar.DATE, 1)再判断是否到达终点所以起止当天都不在数组里——这个边界行为直接影响节假日扣减的准确性后面会专门说。3. 时间预处理的两个方法processBeginTime 与 processEndTime 怎么吸附3.1 processBeginTime把开始时间推到工作时段内processBeginTime处理的是区间左端点目标是把任何时间点映射到「不早于它的最近工作时刻」。逻辑按优先级分四层。第一层如果开始时间晚于下午下班h h4说明当天已经下班直接加一天时间设为上午上班abh:abm。第二层如果落在午休区间[h2, h3]设为下午上班pbh:pbm。第三层如果早于上午上班t.getHours() h1设为上午上班。第四层处理周末周六加两天、周日加一天时间都设为上午上班。if (h h4) { t.setTime(t.getTime() milsecPerDay); t.setHours(abh); t.setMinutes(abm); } else if (h h2 h h3) { t.setHours(pbh); t.setMinutes(pbm); } else if (t.getHours() h1) { t.setHours(abh); t.setMinutes(abm); }这里有个细节值得注意判断「早于上午上班」用的是t.getHours() h1而h1是浮点。当开始时间是 9:30 时getHours()返回 99 9.0为真会被错误地吸附到 9:00把已经过去的半小时又算回来。正确做法应该是用h h1比较浮点小时数。这是这份代码里比较隐蔽的一个坑。3.2 processEndTime把结束时间拉回工作时段内processEndTime是镜像逻辑处理区间右端点目标是把时间映射到「不晚于它的最近工作时刻」。晚于下午下班就设为下午下班落在午休就设为上午下班早于上午上班则先减一天再设为下午下班。周末处理是往前推周日减两天、周六减一天。if (h h4) { t.setHours(peh); t.setMinutes(pem); } else if (h h2 h h3) { t.setHours(aeh); t.setMinutes(aem); } else if (t.getHours() h1) { t.setTime(t.getTime() - milsecPerDay); t.setHours(peh); t.setMinutes(pem); }周末判断那段代码写得有点绕if (t.getDay() 0) { t.setTime(t.getTime() - milsecPerDay * (t.getDay() 6 ? 1 : 2)); ... } if (t.getDay() 6) { t.setTime(t.getTime() - milsecPerDay * (t.getDay() 6 ? 1 : 2)); ... }两个if里的三元表达式在各自分支下都是常量第一个if里getDay()恒为 0三元取 2第二个if里恒为 6三元取 1。写成三元纯属冗余直接写- milsecPerDay * 2和- milsecPerDay更清楚。功能上没问题但读代码的人容易在这里卡壳。3.3 吸附逻辑的边界为什么首尾要单独处理预处理的意义在于把「任意时间点」规整成「工作时段内的点」这样后面的整周折算和尾段补齐才能用统一公式。但吸附本身会改变时间如果首尾都落在非工作时间吸附后可能出现t1 t2的情况——代码里用if (t1.getTime() t2.getTime()) return 0;兜底返回 0 而不是负数这个处理是合理的。需要留意的是吸附只保证时间点落在工作时段边界上不保证落在工作日。比如开始时间是周六上午 10 点processBeginTime会把它推到周一上午 9 点结束时间是周日上午 10 点processEndTime会把它拉回周五下午 18 点。此时t1 t2返回 0。对于「周末提交、周末结束」的工单这个结果是符合预期的。4. 避坑与排查这份工时计算代码最容易翻车的五个点4.1 现象跨年区间工时算出来偏大。原因节假日年份取的是当前年。解决把年份作为参数传入。CalculateHour里int year now.get(Calendar.YEAR)取的是系统当前年份然后拼成2018-01-01这样的完整日期去比对。如果你在 2024 年算 2023 年 12 月到 2024 年 1 月的工时2023 年的元旦、国庆都不会被扣掉。常见做法是把节假日配置改成MapInteger, ListString按年份存或者干脆从数据库/配置文件读别硬编码在类里。4.2 现象开始时间 9:30 被当成 9:00多算半小时。原因getHours()返回整数和浮点h1比较时精度丢失。解决统一用浮点小时数比较。前面提过processBeginTime里t.getHours() h1在 9:30 时成立因为getHours()是 9。改成h h1就对了h是t.getHours() (float)t.getMinutes()/609:30 对应 9.59.5 9.0为假不会被误吸附。同理processEndTime里也有同样的写法一并改掉。4.3 现象printDay生成的日期数组不含起止当天节假日漏扣。原因循环先加一天再判断终点。解决把起止当天显式加入。printDay的逻辑是currentPrintDay.add(Calendar.DATE, 1)之后才判断是否等于endDay等于就break所以startDay和endDay都不在结果里。如果节假日正好落在起止当天arrContrast就匹配不到。修法是在循环前先把startDay加进list循环条件改成currentPrintDay.before(endDay)时继续加最后再把endDay补上。4.4 现象stringToDate解析失败返回null后续NullPointerException。原因异常被吞掉只打印堆栈。解决抛出自定义异常或返回Optional。private Date stringToDate(String time){ try { return format.parse(time); } catch (ParseException e) { e.printStackTrace(); return null; } }调用方拿到null后直接processBeginTime(null)在t.getHours()处炸掉。生产环境里printStackTrace基本等于没日志。建议改成抛IllegalArgumentException把原始字符串带上让上层能定位是哪条数据格式不对。4.5 现象SimpleDateFormat在多线程下解析出错乱结果。原因SimpleDateFormat非线程安全。解决每次新建或用ThreadLocal。类里的format是实例字段FORMATTER是静态字段两者都是SimpleDateFormat。如果CalculateHours被多个线程共享format.parse和format.format会互相干扰出现解析出错误日期甚至抛NumberFormatException。常见做法是用ThreadLocalSimpleDateFormat包一层或者直接换java.time的DateTimeFormatter后者是线程安全的。5. 进阶改造把硬编码节假日换成可配置并补一套自测用例5.1 用配置文件替代硬编码数组硬编码节假日最大的问题是每年都要改代码重新发版。我一般会把它抽成一个holiday.properties按年份分组202401-01,02-10,02-11,02-12,04-04,05-01,10-01,10-02,10-03 202501-01,01-28,01-29,01-30,04-04,05-01,10-01,10-02,10-03读取时按区间跨越的年份分别加载再合并去重。这样运维改配置就行不用动 Java 代码。如果系统已经有数据库直接建一张holiday_config表字段year、month_day、remark查询时按年份过滤比 properties 更灵活。5.2 用参数化测试覆盖边界场景这份代码没有测试改起来心里没底。我习惯用 JUnit 的参数化测试把边界钉死ParameterizedTest CsvSource({ 2024-01-01 09:00:00, 2024-01-01 18:00:00, 8.0, // 工作日全天 2024-01-06 09:00:00, 2024-01-06 18:00:00, 0.0, // 周六全天 2024-01-05 17:00:00, 2024-01-08 10:00:00, 2.0, // 跨周末 2024-01-01 09:00:00, 2024-01-01 18:00:00, 0.0 // 元旦 }) void testCalculateHour(String begin, String end, float expected) { assertEquals(expected, CalculateHours.CalculateHour(begin, end), 0.01); }参数说明第一列开始时间第二列结束时间第三列期望工时。assertEquals的第三个参数是浮点误差容忍度工时保留一位小数的话给 0.01 就够。跑之前记得把节假日配置改成测试年份否则元旦那条会失败。5.3 一个容易忽略的验证习惯改完吸附逻辑后我习惯拿「同一天内跨午休」「跨一个完整周」「起止都在周末」这三组数据各跑一遍对比手算结果。手算时把每天拆成[9:00,12:00]和[13:00,18:00]两段逐段累加比直接看代码输出靠谱。从那以后我每次动时间计算相关的代码都强制走一遍这三组用例再补一组跨年数据基本能挡住大部分回归问题。希望帮到你。本文还有配套的精品资源点击获取