Kaynağa Gözat

feat(workhours): 增加重复数据安全清理接口

malk 3 hafta önce
ebeveyn
işleme
baaeaf627b

+ 24 - 0
mjava-akdsbeisen/src/main/java/com/malk/controller/WorkHoursController.java

@@ -148,6 +148,30 @@ public class WorkHoursController {
         return result;
     }
 
+    /**
+     * 清理同一员工同一日期的重复应填报工时(一次性接口,默认仅预览)
+     * GET /workhours/cleanup-duplicates             (仅预览)
+     * GET /workhours/cleanup-duplicates?dryRun=false(实际删除)
+     */
+    @GetMapping("/cleanup-duplicates")
+    public Map<String, Object> cleanupDuplicates(@RequestParam(defaultValue = "true") boolean dryRun) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            long start = System.currentTimeMillis();
+            Map<String, Object> stats = workHoursCalcService.cleanupDuplicateHours(dryRun);
+            long cost = System.currentTimeMillis() - start;
+            result.put("success", true);
+            result.put("message", dryRun ? "重复工时清理预览完成(未删除)" : "重复工时清理完成");
+            result.put("stats", stats);
+            result.put("costMs", cost);
+        } catch (Exception e) {
+            log.error("重复工时清理失败", e);
+            result.put("success", false);
+            result.put("message", e.getMessage());
+        }
+        return result;
+    }
+
     /**
      * 清理已离职员工「离职日之后」的历史应报工时(一次性接口)
      * GET /workhours/cleanup-after-offline            (实际删除)

+ 201 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/WorkHoursCalcService.java

@@ -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 清掉);

+ 108 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/WorkHoursCalcServiceTest.java

@@ -17,6 +17,7 @@ import java.util.Collections;
 import java.util.List;
 import java.util.LinkedHashMap;
 import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
@@ -96,6 +97,66 @@ public class WorkHoursCalcServiceTest {
         verify(ydClient, never()).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.delete_batch));
     }
 
+    @Test
+    public void cleanupDuplicateHoursDryRunShouldReportOneDeletionWithoutDeleting() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf conf = requiredHoursConf();
+        DDR_New<Object> personnelPage = pageOf(Collections.singletonList(personnelRecord("employee-1")));
+        DDR_New<Object> duplicatePage = pageOf(Arrays.asList(
+                requiredHoursRecord("sparse", "employee-1", LocalDate.of(2026, 7, 15)),
+                completeRequiredHoursRecord("complete", "employee-1", LocalDate.of(2026, 7, 15))));
+        DDR_New<Object> emptyPage = pageOf(Collections.emptyList());
+        AtomicBoolean requiredHoursReturned = new AtomicBoolean(false);
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenAnswer(invocation -> {
+                    YDParam param = (YDParam) invocation.getArguments()[0];
+                    if (conf.getFormUuidPersonnel().equals(param.getFormUuid())) return personnelPage;
+                    if (requiredHoursReturned.compareAndSet(false, true)) return duplicatePage;
+                    return emptyPage;
+                });
+
+        WorkHoursCalcService service = new WorkHoursCalcService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+
+        Map<String, Object> stats = service.cleanupDuplicateHours(true);
+
+        assertEquals(1, stats.get("duplicateGroups"));
+        assertEquals(1, stats.get("toDelete"));
+        assertEquals(0, stats.get("deleted"));
+        verify(ydClient, never()).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.delete_batch));
+    }
+
+    @Test
+    public void cleanupDuplicateHoursShouldExcludeRecordsAfterOfflineDate() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf conf = requiredHoursConf();
+        DDR_New<Object> personnelPage = pageOf(Collections.singletonList(
+                personnelRecord("employee-1", LocalDate.of(2026, 7, 14))));
+        DDR_New<Object> duplicatePage = pageOf(Arrays.asList(
+                requiredHoursRecord("post-offline-1", "employee-1", LocalDate.of(2026, 7, 15)),
+                requiredHoursRecord("post-offline-2", "employee-1", LocalDate.of(2026, 7, 15))));
+        DDR_New<Object> emptyPage = pageOf(Collections.emptyList());
+        AtomicBoolean requiredHoursReturned = new AtomicBoolean(false);
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenAnswer(invocation -> {
+                    YDParam param = (YDParam) invocation.getArguments()[0];
+                    if (conf.getFormUuidPersonnel().equals(param.getFormUuid())) return personnelPage;
+                    if (requiredHoursReturned.compareAndSet(false, true)) return duplicatePage;
+                    return emptyPage;
+                });
+
+        WorkHoursCalcService service = new WorkHoursCalcService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+
+        Map<String, Object> stats = service.cleanupDuplicateHours(true);
+
+        assertEquals(2, stats.get("excludedAfterOffline"));
+        assertEquals(0, stats.get("duplicateGroups"));
+        assertEquals(0, stats.get("toDelete"));
+    }
+
     @Test
     public void computeDailyPmsShouldMergeDistinctManagersAndIncludeOfflineDate() throws Exception {
         WorkHoursCalcService service = new WorkHoursCalcService();
@@ -149,4 +210,51 @@ public class WorkHoursCalcServiceTest {
         record.put("formData", formData);
         return record;
     }
+
+    private static Map<String, Object> completeRequiredHoursRecord(String instanceId,
+                                                                    String employeeId,
+                                                                    LocalDate workDay) {
+        Map<String, Object> record = requiredHoursRecord(instanceId, employeeId, workDay);
+        Map<String, Object> formData = (Map<String, Object>) record.get("formData");
+        formData.put("numberField_mmd8onl6", 8);
+        formData.put("employeeField_mh8xhqc3_id", Collections.singletonList("manager-1"));
+        formData.put("textField_mh8xhqc1", "E001");
+        formData.put("radioField_mkow4ydo", "内部");
+        formData.put("departmentSelectField_mkow4ydr_id", Collections.singletonList("dept-1"));
+        formData.put("textField_mmekrcji", "上海");
+        formData.put("textField_mpp7a2k7", "否");
+        return record;
+    }
+
+    private static Map<String, Object> personnelRecord(String employeeId) {
+        return personnelRecord(employeeId, null);
+    }
+
+    private static Map<String, Object> personnelRecord(String employeeId, LocalDate offlineDate) {
+        Map<String, Object> formData = new LinkedHashMap<>();
+        formData.put("employeeField_mkow4ydp_id", Collections.singletonList(employeeId));
+        if (offlineDate != null) {
+            formData.put("dateField_mh8xhqc7",
+                    offlineDate.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli());
+        }
+        Map<String, Object> record = new LinkedHashMap<>();
+        record.put("formData", formData);
+        return record;
+    }
+
+    private static DDR_New<Object> pageOf(List<Map<String, Object>> records) {
+        DDR_New<Object> page = new DDR_New<>();
+        page.setTotalCount(records.size());
+        page.setData(records);
+        return page;
+    }
+
+    private static WHConf requiredHoursConf() {
+        WHConf conf = new WHConf();
+        conf.setYidaAppType("app");
+        conf.setYidaSystemToken("token");
+        conf.setFormUuidPersonnel("personnel");
+        conf.setFormUuidRequiredHours("required-hours");
+        return conf;
+    }
 }