|
|
@@ -930,6 +930,207 @@ public class WorkHoursCalcService {
|
|
|
return new int[]{successCount.get(), failCount.get()};
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 清理同一员工同一日期的重复应报工时(一次性接口)。
|
|
|
+ * prd: dryRun 默认由 Controller 设为 true;离职日后的记录交由 cleanupAfterOffline 独立处理,避免重复删除。
|
|
|
+ *
|
|
|
+ * @param dryRun true 仅统计不删除
|
|
|
+ * @return 重复组、待删除数量、删除结果及抽样
|
|
|
+ */
|
|
|
+ public Map<String, Object> cleanupDuplicateHours(boolean dryRun) {
|
|
|
+ Map<String, LocalDate> offlineMap = buildPersonnelOfflineMap();
|
|
|
+ DuplicateScan scan = scanDuplicateHours(offlineMap);
|
|
|
+ WorkHoursDuplicateResolver.Resolution resolution = WorkHoursDuplicateResolver.resolve(scan.candidates);
|
|
|
+ List<String> toDelete = resolution.getDeleteInstanceIds();
|
|
|
+
|
|
|
+ Map<String, Object> stats = buildDuplicateStats(scan, resolution);
|
|
|
+ if (dryRun) {
|
|
|
+ stats.put("deleted", 0);
|
|
|
+ stats.put("fail", 0);
|
|
|
+ stats.put("dryRun", true);
|
|
|
+ log.info("重复应报工时清理预览: 扫描{}, 重复组{}, 待删除{}, 离职后排除{}",
|
|
|
+ scan.total, resolution.getGroups().size(), toDelete.size(), scan.excludedAfterOffline);
|
|
|
+ return stats;
|
|
|
+ }
|
|
|
+
|
|
|
+ int[] result = deleteRequiredHoursInstances(toDelete);
|
|
|
+ stats.put("deleted", result[0]);
|
|
|
+ stats.put("fail", result[1]);
|
|
|
+ stats.put("dryRun", false);
|
|
|
+ log.info("重复应报工时清理完成: 扫描{}, 删除{}, 失败{}", scan.total, result[0], result[1]);
|
|
|
+ return stats;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, LocalDate> buildPersonnelOfflineMap() {
|
|
|
+ Map<String, LocalDate> offlineMap = new HashMap<>();
|
|
|
+ Map<String, Map<String, Object>> personnelMap = queryAllPersonnelDetails();
|
|
|
+ for (Map.Entry<String, Map<String, Object>> entry : personnelMap.entrySet()) {
|
|
|
+ LocalDate offlineDate = parseToLocalDate(entry.getValue().get(PERSONNEL_OFFLINE_DATE));
|
|
|
+ if (offlineDate != null) offlineMap.put(entry.getKey(), offlineDate);
|
|
|
+ }
|
|
|
+ return offlineMap;
|
|
|
+ }
|
|
|
+
|
|
|
+ private DuplicateScan scanDuplicateHours(Map<String, LocalDate> offlineMap) {
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+ log.info("重复应报工时扫描[{}]: 累计扫描{}, 离职后排除{}",
|
|
|
+ monthCursor, scan.total, scan.excludedAfterOffline);
|
|
|
+ monthCursor = monthCursor.plusMonths(1);
|
|
|
+ }
|
|
|
+ return scan;
|
|
|
+ }
|
|
|
+
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private List<Map<String, Object>> queryRequiredHoursMonth(LocalDate monthStart) {
|
|
|
+ List<Map<String, Object>> records = new ArrayList<>();
|
|
|
+ ZoneId zone = ZoneId.systemDefault();
|
|
|
+ long startMs = monthStart.atStartOfDay(zone).toInstant().toEpochMilli();
|
|
|
+ long endMs = monthStart.plusMonths(1).atStartOfDay(zone).toInstant().toEpochMilli() - 1;
|
|
|
+ Map<String, Object> dateRange = new HashMap<>();
|
|
|
+ dateRange.put("dateField_mmd8onl5", Arrays.asList(startMs, endMs));
|
|
|
+
|
|
|
+ int currentPage = 1;
|
|
|
+ long totalCount;
|
|
|
+ do {
|
|
|
+ DDR_New result = ydClient.queryData(YDParam.builder()
|
|
|
+ .appType(whConf.getYidaAppType())
|
|
|
+ .systemToken(whConf.getYidaSystemToken())
|
|
|
+ .formUuid(whConf.getFormUuidRequiredHours())
|
|
|
+ .searchFieldJson(JSON.toJSONString(dateRange))
|
|
|
+ .currentPage(currentPage)
|
|
|
+ .pageSize(YDConf.PAGE_SIZE_LIMIT)
|
|
|
+ .build(), YDConf.FORM_QUERY.retrieve_search_form);
|
|
|
+ totalCount = result.getTotalCount();
|
|
|
+ List<Map<String, Object>> page = (List<Map<String, Object>>) result.getData();
|
|
|
+ if (page == null || page.isEmpty()) break;
|
|
|
+ records.addAll(page);
|
|
|
+ currentPage++;
|
|
|
+ } while ((long) (currentPage - 1) * YDConf.PAGE_SIZE_LIMIT < totalCount);
|
|
|
+ return records;
|
|
|
+ }
|
|
|
+
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private void collectDuplicateCandidate(Map<String, Object> item,
|
|
|
+ Map<String, LocalDate> offlineMap,
|
|
|
+ DuplicateScan scan) {
|
|
|
+ scan.total++;
|
|
|
+ Object instanceIdValue = item.get("formInstanceId");
|
|
|
+ Object formDataValue = item.get("formData");
|
|
|
+ if (instanceIdValue == null || !(formDataValue instanceof Map)) {
|
|
|
+ scan.candidates.add(new WorkHoursDuplicateResolver.Candidate("", null, 0, 0, 0));
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ Map<String, Object> formData = (Map<String, Object>) formDataValue;
|
|
|
+ String employeeId = extractEmployeeId(formData, "employeeField_mmd8onl4");
|
|
|
+ LocalDate workDay = parseToLocalDate(formData.get("dateField_mmd8onl5"));
|
|
|
+ LocalDate offlineDate = employeeId == null ? null : offlineMap.get(employeeId);
|
|
|
+ if (isAfterOfflineDate(workDay, offlineDate)) {
|
|
|
+ scan.excludedAfterOffline++;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ String key = employeeId == null || workDay == null ? null : employeeId + "|" + workDay;
|
|
|
+ scan.candidates.add(new WorkHoursDuplicateResolver.Candidate(
|
|
|
+ String.valueOf(instanceIdValue), key, scoreRequiredHoursCompleteness(formData),
|
|
|
+ parseSortTimestamp(item.get("gmtModified")), parseSortTimestamp(item.get("gmtCreate"))));
|
|
|
+ }
|
|
|
+
|
|
|
+ private int scoreRequiredHoursCompleteness(Map<String, Object> formData) {
|
|
|
+ String[] fields = {
|
|
|
+ "numberField_mmd8onl6", "employeeField_mh8xhqc3", "textField_mh8xhqc1",
|
|
|
+ "radioField_mkow4ydo", "departmentSelectField_mkow4ydr",
|
|
|
+ "textField_mmekrcji", "textField_mpp7a2k7"
|
|
|
+ };
|
|
|
+ int score = 0;
|
|
|
+ for (String field : fields) {
|
|
|
+ if (hasMeaningfulValue(formData.get(field)) || hasMeaningfulValue(formData.get(field + "_id"))) {
|
|
|
+ score++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return score;
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean hasMeaningfulValue(Object value) {
|
|
|
+ if (value == null) return false;
|
|
|
+ if (value instanceof Collection) return !((Collection<?>) value).isEmpty();
|
|
|
+ if (value instanceof Map) return !((Map<?, ?>) value).isEmpty();
|
|
|
+ return !String.valueOf(value).trim().isEmpty();
|
|
|
+ }
|
|
|
+
|
|
|
+ private long parseSortTimestamp(Object value) {
|
|
|
+ if (value == null) return 0L;
|
|
|
+ String text = String.valueOf(value).trim();
|
|
|
+ if (text.isEmpty()) return 0L;
|
|
|
+ try {
|
|
|
+ if (text.matches("\\d+")) return Long.parseLong(text);
|
|
|
+ return Instant.parse(text).toEpochMilli();
|
|
|
+ } catch (Exception ignored) {
|
|
|
+ try {
|
|
|
+ return java.time.OffsetDateTime.parse(text).toInstant().toEpochMilli();
|
|
|
+ } catch (Exception ignoredAgain) {
|
|
|
+ return 0L;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> buildDuplicateStats(DuplicateScan scan,
|
|
|
+ WorkHoursDuplicateResolver.Resolution resolution) {
|
|
|
+ Map<String, Object> stats = new LinkedHashMap<>();
|
|
|
+ int duplicateRecords = resolution.getDeleteInstanceIds().size() + resolution.getGroups().size();
|
|
|
+ List<Map<String, Object>> samples = new ArrayList<>();
|
|
|
+ for (WorkHoursDuplicateResolver.DuplicateGroup group : resolution.getGroups()) {
|
|
|
+ if (samples.size() >= 5) break;
|
|
|
+ Map<String, Object> sample = new LinkedHashMap<>();
|
|
|
+ sample.put("key", group.getKey());
|
|
|
+ sample.put("keepInstanceId", group.getKeepInstanceId());
|
|
|
+ sample.put("deleteInstanceIds", group.getDeleteInstanceIds());
|
|
|
+ samples.add(sample);
|
|
|
+ }
|
|
|
+ stats.put("total", scan.total);
|
|
|
+ stats.put("uniqueKeys", resolution.getUniqueKeys());
|
|
|
+ stats.put("duplicateGroups", resolution.getGroups().size());
|
|
|
+ stats.put("duplicateRecords", duplicateRecords);
|
|
|
+ stats.put("toDelete", resolution.getDeleteInstanceIds().size());
|
|
|
+ stats.put("skippedInvalidKey", resolution.getSkippedInvalidKey());
|
|
|
+ stats.put("excludedAfterOffline", scan.excludedAfterOffline);
|
|
|
+ stats.put("samples", samples);
|
|
|
+ return stats;
|
|
|
+ }
|
|
|
+
|
|
|
+ private int[] deleteRequiredHoursInstances(List<String> instanceIds) {
|
|
|
+ int deleted = 0;
|
|
|
+ int fail = 0;
|
|
|
+ for (int i = 0; i < instanceIds.size(); i += 100) {
|
|
|
+ List<String> batch = new ArrayList<>(instanceIds.subList(i, Math.min(i + 100, instanceIds.size())));
|
|
|
+ try {
|
|
|
+ ydClient.operateData(YDParam.builder()
|
|
|
+ .appType(whConf.getYidaAppType())
|
|
|
+ .systemToken(whConf.getYidaSystemToken())
|
|
|
+ .formUuid(whConf.getFormUuidRequiredHours())
|
|
|
+ .formInstanceIdList(batch)
|
|
|
+ .build(), YDConf.FORM_OPERATION.delete_batch);
|
|
|
+ deleted += batch.size();
|
|
|
+ } catch (Exception e) {
|
|
|
+ fail += batch.size();
|
|
|
+ log.error("重复应报工时批次删除失败, offset={}, size={}", i, batch.size(), e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return new int[]{deleted, fail};
|
|
|
+ }
|
|
|
+
|
|
|
+ private static final class DuplicateScan {
|
|
|
+ private final List<WorkHoursDuplicateResolver.Candidate> candidates = new ArrayList<>();
|
|
|
+ private int total;
|
|
|
+ private int excludedAfterOffline;
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 清理应报工时中已离职员工「离职日之后」的历史记录(一次性接口)
|
|
|
* ppExt: 与 concurrentUpsert 的 offline 过滤逻辑对齐 (离职后不新增 + 已存在的历史 workDay > offlineDate 清掉);
|