package com.kingdee.eas.custom.perfweb.handler; import com.alibaba.fastjson.JSONObject; import com.kingdee.bos.BOSException; import com.kingdee.bos.Context; import com.kingdee.bos.ctrl.swing.StringUtils; import com.kingdee.jdbc.rowset.IRowSet; import com.kingdee.eas.custom.perfweb.util.ReportDateUtil; import com.kingdee.eas.custom.perfweb.util.ReportFilterUtil; import com.kingdee.eas.custom.perfweb.util.ReportOrgUtil; import com.kingdee.eas.util.app.DbUtil; import com.kingdee.shr.base.syssetting.context.SHRContext; import com.kingdee.shr.base.syssetting.exception.ShrWebBizException; import com.kingdee.shr.base.syssetting.json.GridDataEntity; import com.kingdee.shr.base.syssetting.web.handler.ListHandler; import org.apache.log4j.Logger; import org.springframework.ui.ModelMap; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.math.BigDecimal; import java.math.RoundingMode; import java.sql.SQLException; import java.sql.Timestamp; import java.text.SimpleDateFormat; import java.util.*; /** * 类名称: IndirectPersonnelOvertimeReportHandler * 功能描述: 间接人员加班工时报表(打卡计算平时/周末加班工时) * 创建日期: 2026-06-23 * 作 者: 青梧 * 版 本: 1.0 *

* 返回字段说明: * month - 月份(合计行显示合计) * personNumber - 员工工号 * centerName - 所属中心名称(沿FParentID向上找,FLevel=2,列表列名沿用centerName) * deptName - 所属部门名称 * personName - 员工姓名 * weekdayOtHours - 平时加班工时(最晚打卡-排班下班,0.5h取整) * weekendOtHours - 周末加班工时(最晚-最早,0.5h取整) * totalOtHours - 加班工时合计 * _sortDept - 内部排序字段(部门) * _sortName - 内部排序字段(姓名) *

* 核心数据结构说明: * personMap : Map 间接人员基础信息 * punchMap : Map>> 当月全员打卡,按人+日分组 * offDutyMap : Map> 当月全员排班下班时间 * dayKey : 日期字符串 yyyy-MM-dd,作为按天计算的键 */ public class IndirectPersonnelOvertimeReportHandler extends ListHandler { private static Logger logger = Logger.getLogger(IndirectPersonnelOvertimeReportHandler.class); Context ctx = SHRContext.getInstance().getContext(); /** * 间接人员类别:person.CFRylb=2 */ private static final int INDIRECT_RYLB = 2; /** * 周末最早打卡早于13:15时扣除的午休时长(小时) */ private static final double LUNCH_DEDUCT_HOURS = 1.5D; /** * 午休扣除阈值:13:15(分钟数) */ private static final int AFTERNOON_THRESHOLD_MINUTES = 13 * 60 + 15; /** * 中心组织层级(T_ORG_Admin.FLevel)。 * TODO(待业务确认): 当前按2级作为中心,若组织架构调整需同步校验。 */ private static final int CENTER_ORG_LEVEL = 2; /** * 列表查询入口:按日期范围/组织/人员筛选间接人员,计算加班工时并追加合计行。 */ @Override protected GridDataEntity getGridRequestData(HttpServletRequest request, HttpServletResponse response, ModelMap modelMap) throws ShrWebBizException { logger.error("IndirectPersonnelOvertimeReportHandler getGridRequestData------------"); GridDataEntity gridDataEntity = new GridDataEntity(); try { // 快筛: 行政组织adminOrg + 人员person + 日期month String filterItem = ReportFilterUtil.getFilterItems(request); JSONObject fastFilterItemsJson = ReportFilterUtil.parseFastFilterJson(request); logger.error("filterItem: " + filterItem); logger.error("fastFilterItemsJson: " + fastFilterItemsJson); Map paramMap = ReportFilterUtil.parseAdminOrgParams(fastFilterItemsJson, logger); String adminOrg = paramMap.get("adminOrg"); List personIds = ReportFilterUtil.parseF7IdList(fastFilterItemsJson, "person"); // dateRange: 开始/结束日期,优先快筛字段(name=month),未传则默认当月 ReportFilterUtil.DateRange dateRange = ReportFilterUtil.resolveQueryDateRange(filterItem, fastFilterItemsJson, "month", logger); logger.error("查询日期: " + dateRange.getStartDate() + " ~ " + dateRange.getEndDate() + ", 组织: " + adminOrg + ", 人员: " + personIds); // personMap: key=人员FID, value=人员基础信息(工号/姓名/部门/中心) Map personMap = loadIndirectPersons(adminOrg, personIds); // dataList: 最终返回给前端的报表行列表 List> dataList = buildReportRows(dateRange, personMap, adminOrg); dataList.sort(new RowComparator()); dataList.add(calculateSummary(dataList)); gridDataEntity.setRows(dataList); gridDataEntity.setTotal(dataList.size()); gridDataEntity.setPage(1); gridDataEntity.setRecords(dataList.size()); logger.error("间接人员加班报表数据查询成功,总记录数: " + dataList.size()); } catch (Exception e) { e.fillInStackTrace(); throw new ShrWebBizException(e.getMessage()); } ReportFilterUtil.applyGridUserData(request, gridDataEntity); return gridDataEntity; } /** * 按日期范围遍历间接人员,计算加班工时。 * 平时加班=最晚打卡-排班下班;周末加班=最晚-最早,若最早早于13:15扣1.5h,均按0.5h取整。 * @param dateRange 查询日期范围(yyyy-MM-dd),快筛字段(name=month) */ private List> buildReportRows(ReportFilterUtil.DateRange dateRange, Map personMap, String adminOrg) throws BOSException, SQLException { List> dataList = new ArrayList>(); if (personMap.isEmpty() || dateRange == null) { return dataList; } String queryStart = dateRange.getStartDate(); String queryEnd = dateRange.getEndDate(); // punchMap: 人员ID -> (日期 -> 当天全部打卡时间列表,已排序) Map>> punchMap = loadPunchRecords(queryStart, queryEnd, adminOrg, personMap.keySet()); // offDutyMap: 人员ID -> (日期 -> 排班下班时间) Map> offDutyMap = loadScheduleOffDutyTimes(queryStart, queryEnd, adminOrg, personMap.keySet()); // ---------- 遍历间接人员 ---------- for (PersonRow person : personMap.values()) { // dayPunchMap: 当前人员 日期->打卡列表 Map> dayPunchMap = punchMap.get(person.personId); // dayOffDutyMap: 当前人员 日期->下班时间 Map dayOffDutyMap = offDutyMap.get(person.personId); if (dayPunchMap == null || dayPunchMap.isEmpty()) { continue; } // monthHoursMap: yyyy-MM -> 当月加班工时(平时+周末) Map monthHoursMap = new LinkedHashMap(); // ---------- 按天遍历打卡 ---------- for (Map.Entry> entry : dayPunchMap.entrySet()) { // dayKey: 日期字符串 yyyy-MM-dd String dayKey = entry.getKey(); if (dayKey.compareTo(queryStart) < 0 || dayKey.compareTo(queryEnd) > 0) { continue; } // punches: 当天全部打卡(已排序,index0=最早,尾=最晚) List punches = entry.getValue(); if (punches == null || punches.size() < 2) { continue; } Timestamp earliest = punches.get(0); Timestamp latest = punches.get(punches.size() - 1); if (earliest == null || latest == null || !latest.after(earliest)) { continue; } // yearMonthKey: 所属月份 yyyy-MM,用于按月汇总 String yearMonthKey = dayKey.substring(0, 7); MonthOtHours monthHours = monthHoursMap.get(yearMonthKey); if (monthHours == null) { monthHours = new MonthOtHours(); monthHoursMap.put(yearMonthKey, monthHours); } if (isWeekend(dayKey)) { double rawHours = hoursBetween(earliest, latest); if (isBeforeAfternoonThreshold(earliest)) { rawHours -= LUNCH_DEDUCT_HOURS; } monthHours.weekendHours = monthHours.weekendHours.add(roundHalfHour(rawHours)); } else { Timestamp offDuty = dayOffDutyMap != null ? dayOffDutyMap.get(dayKey) : null; if (offDuty == null) { continue; } double rawHours = hoursBetween(offDuty, latest); monthHours.weekdayHours = monthHours.weekdayHours.add(roundHalfHour(rawHours)); } } for (Map.Entry monthEntry : monthHoursMap.entrySet()) { MonthOtHours hours = monthEntry.getValue(); BigDecimal totalHours = hours.weekdayHours.add(hours.weekendHours); if (totalHours.compareTo(BigDecimal.ZERO) <= 0) { continue; } Map row = new LinkedHashMap(); row.put("month", ReportDateUtil.formatMonthLabelFromDayKey(monthEntry.getKey() + "-01")); // month: 月份 row.put("personNumber", person.personNumber); // personNumber: 工号 row.put("centerName", person.centerName); // centerName: 中心名称(列表列名沿用centerName) row.put("deptName", person.deptName); // deptName: 部门 row.put("personName", person.personName); // personName: 姓名 row.put("weekdayOtHours", hours.weekdayHours); // weekdayOtHours: 平时加班 row.put("weekendOtHours", hours.weekendHours); // weekendOtHours: 周末加班 row.put("totalOtHours", totalHours); // totalOtHours: 加班合计 row.put("_sortDept", person.deptName == null ? "" : person.deptName); // _sortDept: 排序用部门 row.put("_sortName", person.personName == null ? "" : person.personName); // _sortName: 排序用姓名 dataList.add(row); } } return dataList; } /** * 加载间接人员列表(CFRylb=2, 未离职),并解析所属中心名称。 * * @return personMap: key=人员FID, value=PersonRow */ private Map loadIndirectPersons(String adminOrg, List personIds) throws BOSException, SQLException { Map personMap = new LinkedHashMap(); // centerNameCache: key=部门FID, value=所属中心名称(避免重复查询) Map centerNameCache = new HashMap(); StringBuilder sql = new StringBuilder(); sql.append("SELECT person.FID AS personId, person.FNUMBER AS personNumber, person.FNAME_L2 AS personName, "); sql.append("dept.FID AS deptId, dept.FNAME_L2 AS deptName, dept.FLONGNUMBER AS deptLongNumber "); sql.append("FROM T_BD_PERSON person "); sql.append("LEFT JOIN T_HR_PersonPosition personPos ON person.FID = personPos.FPERSONID "); sql.append("LEFT JOIN T_ORG_Admin dept ON dept.FID = personPos.FPersonDep "); sql.append("WHERE person.CFRylb = ").append(INDIRECT_RYLB).append(" "); sql.append("AND personPos.FLeftDate IS NULL "); ReportFilterUtil.appendSqlInClause(sql, "person.FID", personIds); ReportOrgUtil.appendOrgFilter(sql, ctx, adminOrg, "personPos.FPersonDep"); sql.append("ORDER BY person.FNUMBER "); logger.error("间接人员SQL: " + sql); IRowSet rs = DbUtil.executeQuery(ctx, sql.toString()); while (rs.next()) { // 人员ID String pid = rs.getString("personId"); if (StringUtils.isEmpty(pid) || personMap.containsKey(pid)) { continue; } PersonRow row = new PersonRow(); row.personId = pid; // 工号 row.personNumber = rs.getString("personNumber"); // 姓名 row.personName = rs.getString("personName"); // 部门名称 row.deptName = rs.getString("deptName"); // 部门FID,用于向上查找中心名称 String deptId = rs.getString("deptId"); row.centerName = resolveCenterName(deptId, centerNameCache); personMap.put(pid, row); } return personMap; } /** * 沿组织树向上查找中心名称(FParentID 递归,FLevel=CENTER_ORG_LEVEL)。 * 列表列名 centerName 沿用中心报表字段名。 * TODO(待业务确认): 若中心层级不是 CENTER_ORG_LEVEL,需同步调整。 * * @param deptId 人员所属部门 FID * @param cache 部门FID->中心名称缓存,避免重复查询 */ private String resolveCenterName(String deptId, Map cache) throws BOSException, SQLException { if (StringUtils.isEmpty(deptId)) { return ""; } if (cache.containsKey(deptId)) { return cache.get(deptId); } String currentId = deptId; // 所属中心名称(列表列名centerName) String centerName = ""; for (int depth = 0; depth < 20; depth++) { StringBuilder sql = new StringBuilder(); sql.append("SELECT FNAME_L2 AS orgName, FLevel AS orgLevel, FParentID AS parentId "); sql.append("FROM T_ORG_Admin WHERE FID = ? AND FIsSealUp = 0 "); IRowSet rs = DbUtil.executeQuery(ctx, sql.toString(), new Object[]{currentId}); if (!rs.next()) { break; } int orgLevel = rs.getInt("orgLevel"); String orgName = rs.getString("orgName"); // 找到中心层级(CENTER_ORG_LEVEL)即停止 if (orgLevel == CENTER_ORG_LEVEL) { centerName = orgName == null ? "" : orgName; break; } String parentId = rs.getString("parentId"); if (StringUtils.isEmpty(parentId)) { break; } currentId = parentId; } cache.put(deptId, centerName); return centerName; } /** * 加载打卡记录,按人员+日期分组。 * * @return Map结构: personId -> (dayKey -> 当天打卡时间列表,已排序) */ private Map>> loadPunchRecords(String monthStart, String monthEnd, String adminOrg, Set personIds) throws BOSException, SQLException { // result: 外层key=personId, 内层key=dayKey(yyyy-MM-dd), value=打卡时间列表 Map>> result = new HashMap>>(); StringBuilder sql = new StringBuilder(); sql.append("SELECT punch.FProposerID AS personId, punch.FPunchCardDate AS punchDate, punch.FPunchCardTime AS punchTime "); sql.append("FROM T_HR_ATS_PunchCardRecord punch "); sql.append("INNER JOIN T_BD_PERSON person ON person.FID = punch.FProposerID "); sql.append("LEFT JOIN T_HR_PersonPosition personPos ON person.FID = personPos.FPERSONID "); sql.append("WHERE person.CFRylb = ").append(INDIRECT_RYLB).append(" "); sql.append("AND punch.FPunchCardDate >= '").append(monthStart).append("' "); sql.append("AND punch.FPunchCardDate <= '").append(monthEnd).append("' "); sql.append("AND punch.FPunchCardTime IS NOT NULL "); ReportOrgUtil.appendOrgFilter(sql, ctx, adminOrg, "personPos.FPersonDep"); sql.append("ORDER BY punch.FProposerID, punch.FPunchCardDate, punch.FPunchCardTime "); logger.error("打卡记录SQL[" + monthStart + "~" + monthEnd + "]: " + sql); IRowSet rs = DbUtil.executeQuery(ctx, sql.toString()); SimpleDateFormat dayFormat = new SimpleDateFormat("yyyy-MM-dd"); while (rs.next()) { // 人员ID String personId = rs.getString("personId"); if (personIds != null && !personIds.isEmpty() && !personIds.contains(personId)) { continue; } // 打卡日期 Date punchDate = rs.getDate("punchDate"); // 打卡时间 Timestamp punchTime = rs.getTimestamp("punchTime"); if (punchDate == null || punchTime == null) { continue; } // dayKey: 日期字符串,作为内层Map的key String dayKey = dayFormat.format(punchDate); // dayMap: 当前人员 日期->打卡列表 的内层Map Map> dayMap = result.get(personId); if (dayMap == null) { dayMap = new HashMap>(); result.put(personId, dayMap); } // punches: 当天打卡时间列表 List punches = dayMap.get(dayKey); if (punches == null) { punches = new ArrayList(); dayMap.put(dayKey, punches); } punches.add(punchTime); } return result; } /** * 加载排班下班时间(FNextDateTime),按人员+日期分组。 * 排班明细(ScheduleShiftItem)通过 FGroupID 关联主表(ScheduleShift.FID)。 * * @return Map结构: personId -> (dayKey -> 排班下班时间) */ private Map> loadScheduleOffDutyTimes(String monthStart, String monthEnd, String adminOrg, Set personIds) throws BOSException, SQLException { // result: 外层key=personId, 内层key=dayKey(yyyy-MM-dd), value=下班时间 Map> result = new HashMap>(); StringBuilder sql = new StringBuilder(); sql.append("SELECT sch.FProposerID AS personId, sch.FAttendDate AS attendDate, MAX(item.FNextDateTime) AS offDutyTime "); sql.append("FROM T_HR_ATS_ScheduleShift sch "); // 班次明细: item.FGroupID -> sch.FID sql.append("INNER JOIN T_HR_ATS_ScheduleShiftItem item ON item.FGroupID = sch.FID "); sql.append("INNER JOIN T_BD_PERSON person ON person.FID = sch.FProposerID "); sql.append("LEFT JOIN T_HR_PersonPosition personPos ON person.FID = personPos.FPERSONID "); sql.append("WHERE person.CFRylb = ").append(INDIRECT_RYLB).append(" "); sql.append("AND sch.FAttendDate >= '").append(monthStart).append("' "); sql.append("AND sch.FAttendDate <= '").append(monthEnd).append("' "); sql.append("AND item.FNextDateTime IS NOT NULL "); ReportOrgUtil.appendOrgFilter(sql, ctx, adminOrg, "personPos.FPersonDep"); sql.append("GROUP BY sch.FProposerID, sch.FAttendDate "); logger.error("排班下班SQL[" + monthStart + "~" + monthEnd + "]: " + sql); IRowSet rs = DbUtil.executeQuery(ctx, sql.toString()); SimpleDateFormat dayFormat = new SimpleDateFormat("yyyy-MM-dd"); while (rs.next()) { // 人员ID String personId = rs.getString("personId"); if (personIds != null && !personIds.isEmpty() && !personIds.contains(personId)) { continue; } // 出勤日期 Date attendDate = rs.getDate("attendDate"); // 排班下班时间(FNextDateTime) Timestamp offDutyTime = rs.getTimestamp("offDutyTime"); if (attendDate == null || offDutyTime == null) { continue; } // dayKey: 日期字符串 String dayKey = dayFormat.format(attendDate); // dayMap: 当前人员 日期->下班时间 的内层Map Map dayMap = result.get(personId); if (dayMap == null) { dayMap = new HashMap(); result.put(personId, dayMap); } // 同一天取最晚的下班时间 Timestamp existing = dayMap.get(dayKey); if (existing == null || offDutyTime.after(existing)) { dayMap.put(dayKey, offDutyTime); } } return result; } /** * 判断是否为周末(周六/周日),使用Calendar实现。 */ private boolean isWeekend(String dayKey) { try { SimpleDateFormat dayFormat = new SimpleDateFormat("yyyy-MM-dd"); Date date = dayFormat.parse(dayKey); Calendar calendar = Calendar.getInstance(); calendar.setTime(date); int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK); return dayOfWeek == Calendar.SATURDAY || dayOfWeek == Calendar.SUNDAY; } catch (Exception e) { logger.error("日期解析失败: " + dayKey, e); return false; } } /** * 判断打卡时间是否早于13:15 */ private boolean isBeforeAfternoonThreshold(Timestamp punchTime) { Calendar calendar = Calendar.getInstance(); calendar.setTime(punchTime); int minutes = calendar.get(Calendar.HOUR_OF_DAY) * 60 + calendar.get(Calendar.MINUTE); return minutes < AFTERNOON_THRESHOLD_MINUTES; } /** * 计算两个时间戳之间的小时差 */ private double hoursBetween(Timestamp start, Timestamp end) { long millis = end.getTime() - start.getTime(); if (millis <= 0) { return 0D; } return millis / (1000D * 60D * 60D); } /** * 按0.5小时向下取整,不足0.5则为0 */ private BigDecimal roundHalfHour(double hours) { if (hours <= 0D) { return BigDecimal.ZERO.setScale(1, RoundingMode.HALF_UP); } double rounded = Math.floor(hours / 0.5D) * 0.5D; if (rounded < 0.5D) { rounded = 0D; } return BigDecimal.valueOf(rounded).setScale(1, RoundingMode.HALF_UP); } /** * 合计行:汇总全部加班工时 */ private Map calculateSummary(List> dataList) { Map summary = new LinkedHashMap(); summary.put("month", "合计"); // month: 合计行 summary.put("personNumber", ""); // personNumber: 合计行留空 summary.put("centerName", ""); // centerName: 合计行留空 summary.put("deptName", ""); // deptName: 合计行留空 summary.put("personName", ""); // personName: 合计行留空 BigDecimal totalWeekday = BigDecimal.ZERO; BigDecimal totalWeekend = BigDecimal.ZERO; BigDecimal totalHours = BigDecimal.ZERO; for (Map row : dataList) { totalWeekday = totalWeekday.add((BigDecimal) row.get("weekdayOtHours")); totalWeekend = totalWeekend.add((BigDecimal) row.get("weekendOtHours")); totalHours = totalHours.add((BigDecimal) row.get("totalOtHours")); } summary.put("weekdayOtHours", totalWeekday); // weekdayOtHours: 平时加班合计 summary.put("weekendOtHours", totalWeekend); // weekendOtHours: 周末加班合计 summary.put("totalOtHours", totalHours); // totalOtHours: 加班合计 return summary; } /** 按月加班工时(按 yyyy-MM 汇总) */ private static class MonthOtHours { BigDecimal weekdayHours = BigDecimal.ZERO; BigDecimal weekendHours = BigDecimal.ZERO; } /** * 间接人员行数据,承载报表行中的工号/部门/姓名/中心。 */ private static class PersonRow { /** * 人员FID,作为 punchMap/offDutyMap 的外层key */ String personId; /** * 工号 -> personNumber */ String personNumber; /** * 姓名 -> personName */ String personName; /** * 部门名称 -> deptName */ String deptName; /** * 所属中心名称 -> centerName(列表列名沿用centerName) */ String centerName; } /** * 排序:月份 -> 部门 -> 姓名 */ private static class RowComparator implements Comparator> { public int compare(Map left, Map right) { String leftMonth = String.valueOf(left.get("month")); String rightMonth = String.valueOf(right.get("month")); int monthCompare = leftMonth.compareTo(rightMonth); if (monthCompare != 0) { return monthCompare; } String leftDept = String.valueOf(left.get("_sortDept")); String rightDept = String.valueOf(right.get("_sortDept")); int deptCompare = leftDept.compareTo(rightDept); if (deptCompare != 0) { return deptCompare; } String leftName = String.valueOf(left.get("_sortName")); String rightName = String.valueOf(right.get("_sortName")); return leftName.compareTo(rightName); } } }