IndirectPersonnelOvertimeReportHandler.java 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  1. package com.kingdee.eas.custom.perfweb.handler;
  2. import com.alibaba.fastjson.JSONObject;
  3. import com.kingdee.bos.BOSException;
  4. import com.kingdee.bos.Context;
  5. import com.kingdee.bos.ctrl.swing.StringUtils;
  6. import com.kingdee.jdbc.rowset.IRowSet;
  7. import com.kingdee.eas.custom.perfweb.util.ReportDateUtil;
  8. import com.kingdee.eas.custom.perfweb.util.ReportFilterUtil;
  9. import com.kingdee.eas.custom.perfweb.util.ReportOrgUtil;
  10. import com.kingdee.eas.util.app.DbUtil;
  11. import com.kingdee.shr.base.syssetting.context.SHRContext;
  12. import com.kingdee.shr.base.syssetting.exception.ShrWebBizException;
  13. import com.kingdee.shr.base.syssetting.json.GridDataEntity;
  14. import com.kingdee.shr.base.syssetting.web.handler.ListHandler;
  15. import org.apache.log4j.Logger;
  16. import org.springframework.ui.ModelMap;
  17. import javax.servlet.http.HttpServletRequest;
  18. import javax.servlet.http.HttpServletResponse;
  19. import java.math.BigDecimal;
  20. import java.math.RoundingMode;
  21. import java.sql.SQLException;
  22. import java.sql.Timestamp;
  23. import java.text.SimpleDateFormat;
  24. import java.util.*;
  25. /**
  26. * 类名称: IndirectPersonnelOvertimeReportHandler
  27. * 功能描述: 间接人员加班工时报表(打卡计算平时/周末加班工时)
  28. * 创建日期: 2026-06-23
  29. * 作 者: 青梧
  30. * 版 本: 1.0
  31. * <p>
  32. * 返回字段说明:
  33. * month - 月份(合计行显示合计)
  34. * personNumber - 员工工号
  35. * centerName - 所属中心名称(沿FParentID向上找,FLevel=2,列表列名沿用centerName)
  36. * deptName - 所属部门名称
  37. * personName - 员工姓名
  38. * weekdayOtHours - 平时加班工时(最晚打卡-排班下班,0.5h取整)
  39. * weekendOtHours - 周末加班工时(最晚-最早,0.5h取整)
  40. * totalOtHours - 加班工时合计
  41. * _sortDept - 内部排序字段(部门)
  42. * _sortName - 内部排序字段(姓名)
  43. * <p>
  44. * 核心数据结构说明:
  45. * personMap : Map<personId, PersonRow> 间接人员基础信息
  46. * punchMap : Map<personId, Map<dayKey, List<打卡时间>>> 当月全员打卡,按人+日分组
  47. * offDutyMap : Map<personId, Map<dayKey, 排班下班时间>> 当月全员排班下班时间
  48. * dayKey : 日期字符串 yyyy-MM-dd,作为按天计算的键
  49. */
  50. public class IndirectPersonnelOvertimeReportHandler extends ListHandler {
  51. private static Logger logger = Logger.getLogger(IndirectPersonnelOvertimeReportHandler.class);
  52. Context ctx = SHRContext.getInstance().getContext();
  53. /**
  54. * 间接人员类别:person.CFRylb=2
  55. */
  56. private static final int INDIRECT_RYLB = 2;
  57. /**
  58. * 周末最早打卡早于13:15时扣除的午休时长(小时)
  59. */
  60. private static final double LUNCH_DEDUCT_HOURS = 1.5D;
  61. /**
  62. * 午休扣除阈值:13:15(分钟数)
  63. */
  64. private static final int AFTERNOON_THRESHOLD_MINUTES = 13 * 60 + 15;
  65. /**
  66. * 中心组织层级(T_ORG_Admin.FLevel)。
  67. * TODO(待业务确认): 当前按2级作为中心,若组织架构调整需同步校验。
  68. */
  69. private static final int CENTER_ORG_LEVEL = 2;
  70. /**
  71. * 列表查询入口:按日期范围/组织/人员筛选间接人员,计算加班工时并追加合计行。
  72. */
  73. @Override
  74. protected GridDataEntity getGridRequestData(HttpServletRequest request, HttpServletResponse response, ModelMap modelMap) throws ShrWebBizException {
  75. logger.error("IndirectPersonnelOvertimeReportHandler getGridRequestData------------");
  76. GridDataEntity gridDataEntity = new GridDataEntity();
  77. try {
  78. // 快筛: 行政组织adminOrg + 人员person + 日期month
  79. String filterItem = ReportFilterUtil.getFilterItems(request);
  80. JSONObject fastFilterItemsJson = ReportFilterUtil.parseFastFilterJson(request);
  81. logger.error("filterItem: " + filterItem);
  82. logger.error("fastFilterItemsJson: " + fastFilterItemsJson);
  83. Map<String, String> paramMap = ReportFilterUtil.parseAdminOrgParams(fastFilterItemsJson, logger);
  84. String adminOrg = paramMap.get("adminOrg");
  85. List<String> personIds = ReportFilterUtil.parseF7IdList(fastFilterItemsJson, "person");
  86. // dateRange: 开始/结束日期,优先快筛字段(name=month),未传则默认当月
  87. ReportFilterUtil.DateRange dateRange = ReportFilterUtil.resolveQueryDateRange(filterItem, fastFilterItemsJson, "month", logger);
  88. logger.error("查询日期: " + dateRange.getStartDate() + " ~ " + dateRange.getEndDate() + ", 组织: " + adminOrg + ", 人员: " + personIds);
  89. // personMap: key=人员FID, value=人员基础信息(工号/姓名/部门/中心)
  90. Map<String, PersonRow> personMap = loadIndirectPersons(adminOrg, personIds);
  91. // dataList: 最终返回给前端的报表行列表
  92. List<Map<String, Object>> dataList = buildReportRows(dateRange, personMap, adminOrg);
  93. dataList.sort(new RowComparator());
  94. dataList.add(calculateSummary(dataList));
  95. gridDataEntity.setRows(dataList);
  96. gridDataEntity.setTotal(dataList.size());
  97. gridDataEntity.setPage(1);
  98. gridDataEntity.setRecords(dataList.size());
  99. logger.error("间接人员加班报表数据查询成功,总记录数: " + dataList.size());
  100. } catch (Exception e) {
  101. e.fillInStackTrace();
  102. throw new ShrWebBizException(e.getMessage());
  103. }
  104. ReportFilterUtil.applyGridUserData(request, gridDataEntity);
  105. return gridDataEntity;
  106. }
  107. /**
  108. * 按日期范围遍历间接人员,计算加班工时。
  109. * 平时加班=最晚打卡-排班下班;周末加班=最晚-最早,若最早早于13:15扣1.5h,均按0.5h取整。
  110. * @param dateRange 查询日期范围(yyyy-MM-dd),快筛字段(name=month)
  111. */
  112. private List<Map<String, Object>> buildReportRows(ReportFilterUtil.DateRange dateRange, Map<String, PersonRow> personMap, String adminOrg) throws BOSException, SQLException {
  113. List<Map<String, Object>> dataList = new ArrayList<Map<String, Object>>();
  114. if (personMap.isEmpty() || dateRange == null) {
  115. return dataList;
  116. }
  117. String queryStart = dateRange.getStartDate();
  118. String queryEnd = dateRange.getEndDate();
  119. // punchMap: 人员ID -> (日期 -> 当天全部打卡时间列表,已排序)
  120. Map<String, Map<String, List<Timestamp>>> punchMap = loadPunchRecords(queryStart, queryEnd, adminOrg, personMap.keySet());
  121. // offDutyMap: 人员ID -> (日期 -> 排班下班时间)
  122. Map<String, Map<String, Timestamp>> offDutyMap = loadScheduleOffDutyTimes(queryStart, queryEnd, adminOrg, personMap.keySet());
  123. // ---------- 遍历间接人员 ----------
  124. for (PersonRow person : personMap.values()) {
  125. // dayPunchMap: 当前人员 日期->打卡列表
  126. Map<String, List<Timestamp>> dayPunchMap = punchMap.get(person.personId);
  127. // dayOffDutyMap: 当前人员 日期->下班时间
  128. Map<String, Timestamp> dayOffDutyMap = offDutyMap.get(person.personId);
  129. if (dayPunchMap == null || dayPunchMap.isEmpty()) {
  130. continue;
  131. }
  132. // monthHoursMap: yyyy-MM -> 当月加班工时(平时+周末)
  133. Map<String, MonthOtHours> monthHoursMap = new LinkedHashMap<String, MonthOtHours>();
  134. // ---------- 按天遍历打卡 ----------
  135. for (Map.Entry<String, List<Timestamp>> entry : dayPunchMap.entrySet()) {
  136. // dayKey: 日期字符串 yyyy-MM-dd
  137. String dayKey = entry.getKey();
  138. if (dayKey.compareTo(queryStart) < 0 || dayKey.compareTo(queryEnd) > 0) {
  139. continue;
  140. }
  141. // punches: 当天全部打卡(已排序,index0=最早,尾=最晚)
  142. List<Timestamp> punches = entry.getValue();
  143. if (punches == null || punches.size() < 2) {
  144. continue;
  145. }
  146. Timestamp earliest = punches.get(0);
  147. Timestamp latest = punches.get(punches.size() - 1);
  148. if (earliest == null || latest == null || !latest.after(earliest)) {
  149. continue;
  150. }
  151. // yearMonthKey: 所属月份 yyyy-MM,用于按月汇总
  152. String yearMonthKey = dayKey.substring(0, 7);
  153. MonthOtHours monthHours = monthHoursMap.get(yearMonthKey);
  154. if (monthHours == null) {
  155. monthHours = new MonthOtHours();
  156. monthHoursMap.put(yearMonthKey, monthHours);
  157. }
  158. if (isWeekend(dayKey)) {
  159. double rawHours = hoursBetween(earliest, latest);
  160. if (isBeforeAfternoonThreshold(earliest)) {
  161. rawHours -= LUNCH_DEDUCT_HOURS;
  162. }
  163. monthHours.weekendHours = monthHours.weekendHours.add(roundHalfHour(rawHours));
  164. } else {
  165. Timestamp offDuty = dayOffDutyMap != null ? dayOffDutyMap.get(dayKey) : null;
  166. if (offDuty == null) {
  167. continue;
  168. }
  169. double rawHours = hoursBetween(offDuty, latest);
  170. monthHours.weekdayHours = monthHours.weekdayHours.add(roundHalfHour(rawHours));
  171. }
  172. }
  173. for (Map.Entry<String, MonthOtHours> monthEntry : monthHoursMap.entrySet()) {
  174. MonthOtHours hours = monthEntry.getValue();
  175. BigDecimal totalHours = hours.weekdayHours.add(hours.weekendHours);
  176. if (totalHours.compareTo(BigDecimal.ZERO) <= 0) {
  177. continue;
  178. }
  179. Map<String, Object> row = new LinkedHashMap<String, Object>();
  180. row.put("month", ReportDateUtil.formatMonthLabelFromDayKey(monthEntry.getKey() + "-01")); // month: 月份
  181. row.put("personNumber", person.personNumber); // personNumber: 工号
  182. row.put("centerName", person.centerName); // centerName: 中心名称(列表列名沿用centerName)
  183. row.put("deptName", person.deptName); // deptName: 部门
  184. row.put("personName", person.personName); // personName: 姓名
  185. row.put("weekdayOtHours", hours.weekdayHours); // weekdayOtHours: 平时加班
  186. row.put("weekendOtHours", hours.weekendHours); // weekendOtHours: 周末加班
  187. row.put("totalOtHours", totalHours); // totalOtHours: 加班合计
  188. row.put("_sortDept", person.deptName == null ? "" : person.deptName); // _sortDept: 排序用部门
  189. row.put("_sortName", person.personName == null ? "" : person.personName); // _sortName: 排序用姓名
  190. dataList.add(row);
  191. }
  192. }
  193. return dataList;
  194. }
  195. /**
  196. * 加载间接人员列表(CFRylb=2, 未离职),并解析所属中心名称。
  197. *
  198. * @return personMap: key=人员FID, value=PersonRow
  199. */
  200. private Map<String, PersonRow> loadIndirectPersons(String adminOrg, List<String> personIds) throws BOSException, SQLException {
  201. Map<String, PersonRow> personMap = new LinkedHashMap<String, PersonRow>();
  202. // centerNameCache: key=部门FID, value=所属中心名称(避免重复查询)
  203. Map<String, String> centerNameCache = new HashMap<String, String>();
  204. StringBuilder sql = new StringBuilder();
  205. sql.append("SELECT person.FID AS personId, person.FNUMBER AS personNumber, person.FNAME_L2 AS personName, ");
  206. sql.append("dept.FID AS deptId, dept.FNAME_L2 AS deptName, dept.FLONGNUMBER AS deptLongNumber ");
  207. sql.append("FROM T_BD_PERSON person ");
  208. sql.append("LEFT JOIN T_HR_PersonPosition personPos ON person.FID = personPos.FPERSONID ");
  209. sql.append("LEFT JOIN T_ORG_Admin dept ON dept.FID = personPos.FPersonDep ");
  210. sql.append("WHERE person.CFRylb = ").append(INDIRECT_RYLB).append(" ");
  211. sql.append("AND personPos.FLeftDate IS NULL ");
  212. ReportFilterUtil.appendSqlInClause(sql, "person.FID", personIds);
  213. ReportOrgUtil.appendOrgFilter(sql, ctx, adminOrg, "personPos.FPersonDep");
  214. sql.append("ORDER BY person.FNUMBER ");
  215. logger.error("间接人员SQL: " + sql);
  216. IRowSet rs = DbUtil.executeQuery(ctx, sql.toString());
  217. while (rs.next()) {
  218. // 人员ID
  219. String pid = rs.getString("personId");
  220. if (StringUtils.isEmpty(pid) || personMap.containsKey(pid)) {
  221. continue;
  222. }
  223. PersonRow row = new PersonRow();
  224. row.personId = pid;
  225. // 工号
  226. row.personNumber = rs.getString("personNumber");
  227. // 姓名
  228. row.personName = rs.getString("personName");
  229. // 部门名称
  230. row.deptName = rs.getString("deptName");
  231. // 部门FID,用于向上查找中心名称
  232. String deptId = rs.getString("deptId");
  233. row.centerName = resolveCenterName(deptId, centerNameCache);
  234. personMap.put(pid, row);
  235. }
  236. return personMap;
  237. }
  238. /**
  239. * 沿组织树向上查找中心名称(FParentID 递归,FLevel=CENTER_ORG_LEVEL)。
  240. * 列表列名 centerName 沿用中心报表字段名。
  241. * TODO(待业务确认): 若中心层级不是 CENTER_ORG_LEVEL,需同步调整。
  242. *
  243. * @param deptId 人员所属部门 FID
  244. * @param cache 部门FID->中心名称缓存,避免重复查询
  245. */
  246. private String resolveCenterName(String deptId, Map<String, String> cache) throws BOSException, SQLException {
  247. if (StringUtils.isEmpty(deptId)) {
  248. return "";
  249. }
  250. if (cache.containsKey(deptId)) {
  251. return cache.get(deptId);
  252. }
  253. String currentId = deptId;
  254. // 所属中心名称(列表列名centerName)
  255. String centerName = "";
  256. for (int depth = 0; depth < 20; depth++) {
  257. StringBuilder sql = new StringBuilder();
  258. sql.append("SELECT FNAME_L2 AS orgName, FLevel AS orgLevel, FParentID AS parentId ");
  259. sql.append("FROM T_ORG_Admin WHERE FID = ? AND FIsSealUp = 0 ");
  260. IRowSet rs = DbUtil.executeQuery(ctx, sql.toString(), new Object[]{currentId});
  261. if (!rs.next()) {
  262. break;
  263. }
  264. int orgLevel = rs.getInt("orgLevel");
  265. String orgName = rs.getString("orgName");
  266. // 找到中心层级(CENTER_ORG_LEVEL)即停止
  267. if (orgLevel == CENTER_ORG_LEVEL) {
  268. centerName = orgName == null ? "" : orgName;
  269. break;
  270. }
  271. String parentId = rs.getString("parentId");
  272. if (StringUtils.isEmpty(parentId)) {
  273. break;
  274. }
  275. currentId = parentId;
  276. }
  277. cache.put(deptId, centerName);
  278. return centerName;
  279. }
  280. /**
  281. * 加载打卡记录,按人员+日期分组。
  282. *
  283. * @return Map结构: personId -> (dayKey -> 当天打卡时间列表,已排序)
  284. */
  285. private Map<String, Map<String, List<Timestamp>>> loadPunchRecords(String monthStart, String monthEnd, String adminOrg, Set<String> personIds) throws BOSException, SQLException {
  286. // result: 外层key=personId, 内层key=dayKey(yyyy-MM-dd), value=打卡时间列表
  287. Map<String, Map<String, List<Timestamp>>> result = new HashMap<String, Map<String, List<Timestamp>>>();
  288. StringBuilder sql = new StringBuilder();
  289. sql.append("SELECT punch.FProposerID AS personId, punch.FPunchCardDate AS punchDate, punch.FPunchCardTime AS punchTime ");
  290. sql.append("FROM T_HR_ATS_PunchCardRecord punch ");
  291. sql.append("INNER JOIN T_BD_PERSON person ON person.FID = punch.FProposerID ");
  292. sql.append("LEFT JOIN T_HR_PersonPosition personPos ON person.FID = personPos.FPERSONID ");
  293. sql.append("WHERE person.CFRylb = ").append(INDIRECT_RYLB).append(" ");
  294. sql.append("AND punch.FPunchCardDate >= '").append(monthStart).append("' ");
  295. sql.append("AND punch.FPunchCardDate <= '").append(monthEnd).append("' ");
  296. sql.append("AND punch.FPunchCardTime IS NOT NULL ");
  297. ReportOrgUtil.appendOrgFilter(sql, ctx, adminOrg, "personPos.FPersonDep");
  298. sql.append("ORDER BY punch.FProposerID, punch.FPunchCardDate, punch.FPunchCardTime ");
  299. logger.error("打卡记录SQL[" + monthStart + "~" + monthEnd + "]: " + sql);
  300. IRowSet rs = DbUtil.executeQuery(ctx, sql.toString());
  301. SimpleDateFormat dayFormat = new SimpleDateFormat("yyyy-MM-dd");
  302. while (rs.next()) {
  303. // 人员ID
  304. String personId = rs.getString("personId");
  305. if (personIds != null && !personIds.isEmpty() && !personIds.contains(personId)) {
  306. continue;
  307. }
  308. // 打卡日期
  309. Date punchDate = rs.getDate("punchDate");
  310. // 打卡时间
  311. Timestamp punchTime = rs.getTimestamp("punchTime");
  312. if (punchDate == null || punchTime == null) {
  313. continue;
  314. }
  315. // dayKey: 日期字符串,作为内层Map的key
  316. String dayKey = dayFormat.format(punchDate);
  317. // dayMap: 当前人员 日期->打卡列表 的内层Map
  318. Map<String, List<Timestamp>> dayMap = result.get(personId);
  319. if (dayMap == null) {
  320. dayMap = new HashMap<String, List<Timestamp>>();
  321. result.put(personId, dayMap);
  322. }
  323. // punches: 当天打卡时间列表
  324. List<Timestamp> punches = dayMap.get(dayKey);
  325. if (punches == null) {
  326. punches = new ArrayList<Timestamp>();
  327. dayMap.put(dayKey, punches);
  328. }
  329. punches.add(punchTime);
  330. }
  331. return result;
  332. }
  333. /**
  334. * 加载排班下班时间(FNextDateTime),按人员+日期分组。
  335. * 排班明细(ScheduleShiftItem)通过 FGroupID 关联主表(ScheduleShift.FID)。
  336. *
  337. * @return Map结构: personId -> (dayKey -> 排班下班时间)
  338. */
  339. private Map<String, Map<String, Timestamp>> loadScheduleOffDutyTimes(String monthStart, String monthEnd, String adminOrg, Set<String> personIds) throws BOSException, SQLException {
  340. // result: 外层key=personId, 内层key=dayKey(yyyy-MM-dd), value=下班时间
  341. Map<String, Map<String, Timestamp>> result = new HashMap<String, Map<String, Timestamp>>();
  342. StringBuilder sql = new StringBuilder();
  343. sql.append("SELECT sch.FProposerID AS personId, sch.FAttendDate AS attendDate, MAX(item.FNextDateTime) AS offDutyTime ");
  344. sql.append("FROM T_HR_ATS_ScheduleShift sch ");
  345. // 班次明细: item.FGroupID -> sch.FID
  346. sql.append("INNER JOIN T_HR_ATS_ScheduleShiftItem item ON item.FGroupID = sch.FID ");
  347. sql.append("INNER JOIN T_BD_PERSON person ON person.FID = sch.FProposerID ");
  348. sql.append("LEFT JOIN T_HR_PersonPosition personPos ON person.FID = personPos.FPERSONID ");
  349. sql.append("WHERE person.CFRylb = ").append(INDIRECT_RYLB).append(" ");
  350. sql.append("AND sch.FAttendDate >= '").append(monthStart).append("' ");
  351. sql.append("AND sch.FAttendDate <= '").append(monthEnd).append("' ");
  352. sql.append("AND item.FNextDateTime IS NOT NULL ");
  353. ReportOrgUtil.appendOrgFilter(sql, ctx, adminOrg, "personPos.FPersonDep");
  354. sql.append("GROUP BY sch.FProposerID, sch.FAttendDate ");
  355. logger.error("排班下班SQL[" + monthStart + "~" + monthEnd + "]: " + sql);
  356. IRowSet rs = DbUtil.executeQuery(ctx, sql.toString());
  357. SimpleDateFormat dayFormat = new SimpleDateFormat("yyyy-MM-dd");
  358. while (rs.next()) {
  359. // 人员ID
  360. String personId = rs.getString("personId");
  361. if (personIds != null && !personIds.isEmpty() && !personIds.contains(personId)) {
  362. continue;
  363. }
  364. // 出勤日期
  365. Date attendDate = rs.getDate("attendDate");
  366. // 排班下班时间(FNextDateTime)
  367. Timestamp offDutyTime = rs.getTimestamp("offDutyTime");
  368. if (attendDate == null || offDutyTime == null) {
  369. continue;
  370. }
  371. // dayKey: 日期字符串
  372. String dayKey = dayFormat.format(attendDate);
  373. // dayMap: 当前人员 日期->下班时间 的内层Map
  374. Map<String, Timestamp> dayMap = result.get(personId);
  375. if (dayMap == null) {
  376. dayMap = new HashMap<String, Timestamp>();
  377. result.put(personId, dayMap);
  378. }
  379. // 同一天取最晚的下班时间
  380. Timestamp existing = dayMap.get(dayKey);
  381. if (existing == null || offDutyTime.after(existing)) {
  382. dayMap.put(dayKey, offDutyTime);
  383. }
  384. }
  385. return result;
  386. }
  387. /**
  388. * 判断是否为周末(周六/周日),使用Calendar实现。
  389. */
  390. private boolean isWeekend(String dayKey) {
  391. try {
  392. SimpleDateFormat dayFormat = new SimpleDateFormat("yyyy-MM-dd");
  393. Date date = dayFormat.parse(dayKey);
  394. Calendar calendar = Calendar.getInstance();
  395. calendar.setTime(date);
  396. int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK);
  397. return dayOfWeek == Calendar.SATURDAY || dayOfWeek == Calendar.SUNDAY;
  398. } catch (Exception e) {
  399. logger.error("日期解析失败: " + dayKey, e);
  400. return false;
  401. }
  402. }
  403. /**
  404. * 判断打卡时间是否早于13:15
  405. */
  406. private boolean isBeforeAfternoonThreshold(Timestamp punchTime) {
  407. Calendar calendar = Calendar.getInstance();
  408. calendar.setTime(punchTime);
  409. int minutes = calendar.get(Calendar.HOUR_OF_DAY) * 60 + calendar.get(Calendar.MINUTE);
  410. return minutes < AFTERNOON_THRESHOLD_MINUTES;
  411. }
  412. /**
  413. * 计算两个时间戳之间的小时差
  414. */
  415. private double hoursBetween(Timestamp start, Timestamp end) {
  416. long millis = end.getTime() - start.getTime();
  417. if (millis <= 0) {
  418. return 0D;
  419. }
  420. return millis / (1000D * 60D * 60D);
  421. }
  422. /**
  423. * 按0.5小时向下取整,不足0.5则为0
  424. */
  425. private BigDecimal roundHalfHour(double hours) {
  426. if (hours <= 0D) {
  427. return BigDecimal.ZERO.setScale(1, RoundingMode.HALF_UP);
  428. }
  429. double rounded = Math.floor(hours / 0.5D) * 0.5D;
  430. if (rounded < 0.5D) {
  431. rounded = 0D;
  432. }
  433. return BigDecimal.valueOf(rounded).setScale(1, RoundingMode.HALF_UP);
  434. }
  435. /**
  436. * 合计行:汇总全部加班工时
  437. */
  438. private Map<String, Object> calculateSummary(List<Map<String, Object>> dataList) {
  439. Map<String, Object> summary = new LinkedHashMap<String, Object>();
  440. summary.put("month", "合计"); // month: 合计行
  441. summary.put("personNumber", ""); // personNumber: 合计行留空
  442. summary.put("centerName", ""); // centerName: 合计行留空
  443. summary.put("deptName", ""); // deptName: 合计行留空
  444. summary.put("personName", ""); // personName: 合计行留空
  445. BigDecimal totalWeekday = BigDecimal.ZERO;
  446. BigDecimal totalWeekend = BigDecimal.ZERO;
  447. BigDecimal totalHours = BigDecimal.ZERO;
  448. for (Map<String, Object> row : dataList) {
  449. totalWeekday = totalWeekday.add((BigDecimal) row.get("weekdayOtHours"));
  450. totalWeekend = totalWeekend.add((BigDecimal) row.get("weekendOtHours"));
  451. totalHours = totalHours.add((BigDecimal) row.get("totalOtHours"));
  452. }
  453. summary.put("weekdayOtHours", totalWeekday); // weekdayOtHours: 平时加班合计
  454. summary.put("weekendOtHours", totalWeekend); // weekendOtHours: 周末加班合计
  455. summary.put("totalOtHours", totalHours); // totalOtHours: 加班合计
  456. return summary;
  457. }
  458. /** 按月加班工时(按 yyyy-MM 汇总) */
  459. private static class MonthOtHours {
  460. BigDecimal weekdayHours = BigDecimal.ZERO;
  461. BigDecimal weekendHours = BigDecimal.ZERO;
  462. }
  463. /**
  464. * 间接人员行数据,承载报表行中的工号/部门/姓名/中心。
  465. */
  466. private static class PersonRow {
  467. /**
  468. * 人员FID,作为 punchMap/offDutyMap 的外层key
  469. */
  470. String personId;
  471. /**
  472. * 工号 -> personNumber
  473. */
  474. String personNumber;
  475. /**
  476. * 姓名 -> personName
  477. */
  478. String personName;
  479. /**
  480. * 部门名称 -> deptName
  481. */
  482. String deptName;
  483. /**
  484. * 所属中心名称 -> centerName(列表列名沿用centerName)
  485. */
  486. String centerName;
  487. }
  488. /**
  489. * 排序:月份 -> 部门 -> 姓名
  490. */
  491. private static class RowComparator implements Comparator<Map<String, Object>> {
  492. public int compare(Map<String, Object> left, Map<String, Object> right) {
  493. String leftMonth = String.valueOf(left.get("month"));
  494. String rightMonth = String.valueOf(right.get("month"));
  495. int monthCompare = leftMonth.compareTo(rightMonth);
  496. if (monthCompare != 0) {
  497. return monthCompare;
  498. }
  499. String leftDept = String.valueOf(left.get("_sortDept"));
  500. String rightDept = String.valueOf(right.get("_sortDept"));
  501. int deptCompare = leftDept.compareTo(rightDept);
  502. if (deptCompare != 0) {
  503. return deptCompare;
  504. }
  505. String leftName = String.valueOf(left.get("_sortName"));
  506. String rightName = String.valueOf(right.get("_sortName"));
  507. return leftName.compareTo(rightName);
  508. }
  509. }
  510. }