|
|
@@ -53,6 +53,8 @@ public class WorkHoursCalcService {
|
|
|
|
|
|
// prd 离职时间字段(人员档案侧, 用于过滤离职后不再生成/清理历史应报工时)
|
|
|
private static final String PERSONNEL_OFFLINE_DATE = "dateField_mh8xhqc7";
|
|
|
+ // prd 入职时间字段(以人员档案创建日期为准,入职日前不生成应报工时,入职当天保留)
|
|
|
+ private static final String PERSONNEL_HIRED_DATE = "dateField_mh8xhqc6";
|
|
|
// prd 在职状态字段 + 离职取值 (防御开关: status=离职 且 offlineDate=空 → 数据不一致, 该员工不写工时)
|
|
|
private static final String PERSONNEL_STATUS = "radioField_mp1sngq1";
|
|
|
private static final String STATUS_INACTIVE = "离职";
|
|
|
@@ -226,6 +228,13 @@ public class WorkHoursCalcService {
|
|
|
return result;
|
|
|
}
|
|
|
|
|
|
+ LocalDate hiredDate = parseToLocalDate(info.get(PERSONNEL_HIRED_DATE));
|
|
|
+ if (isBeforeHiredDate(workDay, hiredDate)) {
|
|
|
+ result.put("success", false);
|
|
|
+ result.put("error", "workDay 在入职日期之前(hiredDate=" + hiredDate + "), 按业务规则跳过写入");
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
// 2. Manager 取值: 内部=[钉钉直属主管], 外部=当天参与项目的 PM 合并
|
|
|
boolean isInternal = "内部".equals(String.valueOf(info.get("radioField_mkow4ydo")));
|
|
|
List<String> managerIds;
|
|
|
@@ -796,6 +805,11 @@ public class WorkHoursCalcService {
|
|
|
return workDay != null && offlineDate != null && workDay.isAfter(offlineDate);
|
|
|
}
|
|
|
|
|
|
+ /** 入职当天保留,仅入职日期之前视为无效。 */
|
|
|
+ static boolean isBeforeHiredDate(LocalDate workDay, LocalDate hiredDate) {
|
|
|
+ return workDay != null && hiredDate != null && workDay.isBefore(hiredDate);
|
|
|
+ }
|
|
|
+
|
|
|
// ==================== 多线程并发写入 ====================
|
|
|
|
|
|
/**
|
|
|
@@ -824,6 +838,7 @@ public class WorkHoursCalcService {
|
|
|
AtomicInteger skippedFuture = new AtomicInteger(0);
|
|
|
AtomicInteger skippedExisting = new AtomicInteger(0);
|
|
|
AtomicInteger skippedOffline = new AtomicInteger(0);
|
|
|
+ AtomicInteger skippedBeforeHired = new AtomicInteger(0);
|
|
|
AtomicInteger skippedNoPm = new AtomicInteger(0);
|
|
|
AtomicInteger skippedInconsistent = new AtomicInteger(0);
|
|
|
for (Map.Entry<String, Map<String, Object>> entry : personnelMap.entrySet()) {
|
|
|
@@ -835,6 +850,8 @@ public class WorkHoursCalcService {
|
|
|
List<Assignment> externalAssignments = isInternal ? null : managerData.external.get(empId);
|
|
|
// prd 离职时间: 若有离职时间, 该日之后的应报工时不再生成
|
|
|
LocalDate offlineDate = parseToLocalDate(info.get(PERSONNEL_OFFLINE_DATE));
|
|
|
+ // prd 入职时间: 入职日前不生成, 入职当天保留
|
|
|
+ LocalDate hiredDate = parseToLocalDate(info.get(PERSONNEL_HIRED_DATE));
|
|
|
// prd 防御开关: 人员档案 status=离职 但 offlineDate 空 → 数据不一致态, 该员工整月跳过, 避免误写
|
|
|
boolean inconsistentOffline = STATUS_INACTIVE.equals(String.valueOf(info.get(PERSONNEL_STATUS)))
|
|
|
&& offlineDate == null;
|
|
|
@@ -860,6 +877,10 @@ public class WorkHoursCalcService {
|
|
|
skippedOffline.incrementAndGet();
|
|
|
continue;
|
|
|
}
|
|
|
+ if (isBeforeHiredDate(workDay, hiredDate)) {
|
|
|
+ skippedBeforeHired.incrementAndGet();
|
|
|
+ continue;
|
|
|
+ }
|
|
|
// prd 外部员工: 当天参与项目的 PM 集合为空(无项目/PM 全空/项目全下线) → 不生成记录
|
|
|
List<String> managerIds;
|
|
|
if (isInternal) {
|
|
|
@@ -919,6 +940,9 @@ public class WorkHoursCalcService {
|
|
|
if (skippedOffline.get() > 0) {
|
|
|
log.info("离职员工过滤: 跳过{}条 workDay > offlineDate 的记录", skippedOffline.get());
|
|
|
}
|
|
|
+ if (skippedBeforeHired.get() > 0) {
|
|
|
+ log.info("入职日期过滤: 跳过{}条 workDay < hiredDate 的记录", skippedBeforeHired.get());
|
|
|
+ }
|
|
|
if (skippedNoPm.get() > 0) {
|
|
|
log.info("外部员工无 PM 过滤: 跳过{}条 当天无活跃项目 PM 的记录", skippedNoPm.get());
|
|
|
}
|
|
|
@@ -941,7 +965,8 @@ public class WorkHoursCalcService {
|
|
|
*/
|
|
|
public Map<String, Object> cleanupDuplicateHours(boolean dryRun) {
|
|
|
Map<String, LocalDate> offlineMap = buildPersonnelOfflineMap();
|
|
|
- DuplicateScan scan = scanDuplicateHours(offlineMap);
|
|
|
+ Map<String, LocalDate> hiredMap = buildPersonnelHiredMap();
|
|
|
+ DuplicateScan scan = scanDuplicateHours(offlineMap, hiredMap);
|
|
|
WorkHoursDuplicateResolver.Resolution resolution = WorkHoursDuplicateResolver.resolve(scan.candidates);
|
|
|
List<String> toDelete = resolution.getDeleteInstanceIds();
|
|
|
|
|
|
@@ -950,8 +975,9 @@ public class WorkHoursCalcService {
|
|
|
stats.put("deleted", 0);
|
|
|
stats.put("fail", 0);
|
|
|
stats.put("dryRun", true);
|
|
|
- log.info("重复应报工时清理预览: 扫描{}, 重复组{}, 待删除{}, 离职后排除{}",
|
|
|
- scan.total, resolution.getGroups().size(), toDelete.size(), scan.excludedAfterOffline);
|
|
|
+ log.info("重复应报工时清理预览: 扫描{}, 重复组{}, 待删除{}, 入职前排除{}, 离职后排除{}",
|
|
|
+ scan.total, resolution.getGroups().size(), toDelete.size(),
|
|
|
+ scan.excludedBeforeHired, scan.excludedAfterOffline);
|
|
|
return stats;
|
|
|
}
|
|
|
|
|
|
@@ -973,16 +999,17 @@ public class WorkHoursCalcService {
|
|
|
return offlineMap;
|
|
|
}
|
|
|
|
|
|
- private DuplicateScan scanDuplicateHours(Map<String, LocalDate> offlineMap) {
|
|
|
+ private DuplicateScan scanDuplicateHours(Map<String, LocalDate> offlineMap,
|
|
|
+ Map<String, LocalDate> hiredMap) {
|
|
|
DuplicateScan scan = new DuplicateScan();
|
|
|
LocalDate monthCursor = LocalDate.of(2026, 4, 1);
|
|
|
LocalDate scanEnd = LocalDate.now().withDayOfMonth(1).plusMonths(1);
|
|
|
while (monthCursor.isBefore(scanEnd)) {
|
|
|
for (Map<String, Object> item : queryRequiredHoursMonth(monthCursor)) {
|
|
|
- collectDuplicateCandidate(item, offlineMap, scan);
|
|
|
+ collectDuplicateCandidate(item, offlineMap, hiredMap, scan);
|
|
|
}
|
|
|
- log.info("重复应报工时扫描[{}]: 累计扫描{}, 离职后排除{}",
|
|
|
- monthCursor, scan.total, scan.excludedAfterOffline);
|
|
|
+ log.info("重复应报工时扫描[{}]: 累计扫描{}, 入职前排除{}, 离职后排除{}",
|
|
|
+ monthCursor, scan.total, scan.excludedBeforeHired, scan.excludedAfterOffline);
|
|
|
monthCursor = monthCursor.plusMonths(1);
|
|
|
}
|
|
|
return scan;
|
|
|
@@ -1020,6 +1047,7 @@ public class WorkHoursCalcService {
|
|
|
@SuppressWarnings("unchecked")
|
|
|
private void collectDuplicateCandidate(Map<String, Object> item,
|
|
|
Map<String, LocalDate> offlineMap,
|
|
|
+ Map<String, LocalDate> hiredMap,
|
|
|
DuplicateScan scan) {
|
|
|
scan.total++;
|
|
|
Object instanceIdValue = item.get("formInstanceId");
|
|
|
@@ -1033,6 +1061,11 @@ public class WorkHoursCalcService {
|
|
|
String employeeId = extractEmployeeId(formData, "employeeField_mmd8onl4");
|
|
|
LocalDate workDay = parseToLocalDate(formData.get("dateField_mmd8onl5"));
|
|
|
LocalDate offlineDate = employeeId == null ? null : offlineMap.get(employeeId);
|
|
|
+ LocalDate hiredDate = employeeId == null ? null : hiredMap.get(employeeId);
|
|
|
+ if (isBeforeHiredDate(workDay, hiredDate)) {
|
|
|
+ scan.excludedBeforeHired++;
|
|
|
+ return;
|
|
|
+ }
|
|
|
if (isAfterOfflineDate(workDay, offlineDate)) {
|
|
|
scan.excludedAfterOffline++;
|
|
|
return;
|
|
|
@@ -1102,6 +1135,7 @@ public class WorkHoursCalcService {
|
|
|
stats.put("toDelete", resolution.getDeleteInstanceIds().size());
|
|
|
stats.put("skippedInvalidKey", resolution.getSkippedInvalidKey());
|
|
|
stats.put("excludedAfterOffline", scan.excludedAfterOffline);
|
|
|
+ stats.put("excludedBeforeHired", scan.excludedBeforeHired);
|
|
|
stats.put("samples", samples);
|
|
|
return stats;
|
|
|
}
|
|
|
@@ -1131,6 +1165,95 @@ public class WorkHoursCalcService {
|
|
|
private final List<WorkHoursDuplicateResolver.Candidate> candidates = new ArrayList<>();
|
|
|
private int total;
|
|
|
private int excludedAfterOffline;
|
|
|
+ private int excludedBeforeHired;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 清理入职日期之前的历史应报工时;入职当天保留。
|
|
|
+ *
|
|
|
+ * @param dryRun true 时仅统计,不删除
|
|
|
+ * @return 人员及应报工时扫描、删除统计和抽样
|
|
|
+ */
|
|
|
+ public Map<String, Object> cleanupBeforeHiredDate(boolean dryRun) {
|
|
|
+ Map<String, LocalDate> hiredMap = buildPersonnelHiredMap();
|
|
|
+ HiredDateScan scan = scanBeforeHiredDate(hiredMap);
|
|
|
+ Map<String, Object> stats = new LinkedHashMap<>();
|
|
|
+ stats.put("dryRun", dryRun);
|
|
|
+ stats.put("hiredEmployees", hiredMap.size());
|
|
|
+ stats.put("total", scan.total);
|
|
|
+ stats.put("toDelete", scan.instanceIds.size());
|
|
|
+ stats.put("skippedMissingHiredDate", scan.skippedMissingHiredDate);
|
|
|
+ stats.put("skippedInvalidRecord", scan.skippedInvalidRecord);
|
|
|
+ stats.put("samples", scan.samples);
|
|
|
+ if (dryRun) {
|
|
|
+ stats.put("deleted", 0);
|
|
|
+ stats.put("fail", 0);
|
|
|
+ return stats;
|
|
|
+ }
|
|
|
+ int[] result = deleteRequiredHoursInstances(scan.instanceIds);
|
|
|
+ stats.put("deleted", result[0]);
|
|
|
+ stats.put("fail", result[1]);
|
|
|
+ return stats;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, LocalDate> buildPersonnelHiredMap() {
|
|
|
+ Map<String, LocalDate> hiredMap = new HashMap<>();
|
|
|
+ Map<String, Map<String, Object>> personnelMap = queryAllPersonnelDetails();
|
|
|
+ for (Map.Entry<String, Map<String, Object>> entry : personnelMap.entrySet()) {
|
|
|
+ LocalDate hiredDate = parseToLocalDate(entry.getValue().get(PERSONNEL_HIRED_DATE));
|
|
|
+ if (hiredDate != null) hiredMap.put(entry.getKey(), hiredDate);
|
|
|
+ }
|
|
|
+ return hiredMap;
|
|
|
+ }
|
|
|
+
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private HiredDateScan scanBeforeHiredDate(Map<String, LocalDate> hiredMap) {
|
|
|
+ HiredDateScan scan = new HiredDateScan();
|
|
|
+ LocalDate monthCursor = LocalDate.of(2026, 4, 1);
|
|
|
+ LocalDate scanEnd = LocalDate.now().withDayOfMonth(1).plusMonths(1);
|
|
|
+ while (monthCursor.isBefore(scanEnd)) {
|
|
|
+ for (Map<String, Object> item : queryRequiredHoursMonth(monthCursor)) {
|
|
|
+ scan.total++;
|
|
|
+ Map<String, Object> formData = (Map<String, Object>) item.get("formData");
|
|
|
+ Object instanceId = item.get("formInstanceId");
|
|
|
+ if (formData == null || instanceId == null) {
|
|
|
+ scan.skippedInvalidRecord++;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ String empId = extractEmployeeId(formData, "employeeField_mmd8onl4");
|
|
|
+ LocalDate hiredDate = empId == null ? null : hiredMap.get(empId);
|
|
|
+ if (hiredDate == null) {
|
|
|
+ scan.skippedMissingHiredDate++;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ LocalDate workDay = parseToLocalDate(formData.get("dateField_mmd8onl5"));
|
|
|
+ if (!isBeforeHiredDate(workDay, hiredDate)) continue;
|
|
|
+ scan.instanceIds.add(String.valueOf(instanceId));
|
|
|
+ addHiredDateSample(scan.samples, instanceId, empId, workDay, hiredDate);
|
|
|
+ }
|
|
|
+ log.info("清理入职前工时扫描[{}]: 累计扫描{}, 待删除{}", monthCursor, scan.total, scan.instanceIds.size());
|
|
|
+ monthCursor = monthCursor.plusMonths(1);
|
|
|
+ }
|
|
|
+ return scan;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void addHiredDateSample(List<Map<String, Object>> samples, Object instanceId,
|
|
|
+ String empId, LocalDate workDay, LocalDate hiredDate) {
|
|
|
+ if (samples.size() >= 5) return;
|
|
|
+ Map<String, Object> sample = new LinkedHashMap<>();
|
|
|
+ sample.put("instanceId", String.valueOf(instanceId));
|
|
|
+ sample.put("empId", empId);
|
|
|
+ sample.put("workDay", workDay == null ? null : workDay.toString());
|
|
|
+ sample.put("hiredDate", hiredDate.toString());
|
|
|
+ samples.add(sample);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static final class HiredDateScan {
|
|
|
+ private final List<String> instanceIds = new ArrayList<>();
|
|
|
+ private final List<Map<String, Object>> samples = new ArrayList<>();
|
|
|
+ private int total;
|
|
|
+ private int skippedMissingHiredDate;
|
|
|
+ private int skippedInvalidRecord;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -1537,6 +1660,8 @@ public class WorkHoursCalcService {
|
|
|
normalizeEmployeeName(formData.get("employeeField_mkow4ydp")));
|
|
|
// prd 离职时间: 携带原值供 concurrentUpsert 过滤 workDay > offlineDate
|
|
|
info.put(PERSONNEL_OFFLINE_DATE, formData.get(PERSONNEL_OFFLINE_DATE));
|
|
|
+ // prd 入职时间: 携带原值供 concurrentUpsert 过滤 workDay < hiredDate
|
|
|
+ info.put(PERSONNEL_HIRED_DATE, formData.get(PERSONNEL_HIRED_DATE));
|
|
|
// prd 在职状态: 携带供 concurrentUpsert 防御 (status=离职 且 offlineDate=空 时跳过写入)
|
|
|
info.put(PERSONNEL_STATUS, formData.get(PERSONNEL_STATUS));
|
|
|
personnelMap.put(empId, info);
|