Browse Source

fix(workhours): harden summary change initialization

malk 1 month ago
parent
commit
c8232f9f8e

+ 94 - 18
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/WorkHoursCalcService.java

@@ -20,6 +20,7 @@ import java.time.Instant;
 import java.time.LocalDate;
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
 import java.util.*;
 import java.util.concurrent.*;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -230,6 +231,20 @@ public class WorkHoursCalcService {
             result.put("error", "workDay 在 today 之后, 按业务规则不写入未来数据");
             return result;
         }
+        try {
+            Set<String> existKeys = queryExistingHoursKeys(workDay, workDay);
+            if (existKeys.contains(requiredHoursKey(userId, workDay))) {
+                result.put("success", true);
+                result.put("skippedExisting", true);
+                log.info("单条验证跳过已有应报工时: userId={}, workDay={}", userId, workDay);
+                return result;
+            }
+        } catch (Exception e) {
+            log.error("单条验证查询已有应报工时失败: userId={}, workDay={}", userId, workDay, e);
+            result.put("success", false);
+            result.put("error", e.getMessage());
+            return result;
+        }
 
         // 1. 全量查人员档案,取目标员工信息
         Map<String, Map<String, Object>> personnelMap = queryAllPersonnelDetails();
@@ -364,12 +379,14 @@ public class WorkHoursCalcService {
         List<LocalDate> workingDays = getWorkingDays(year, month, rules);
         stats.put("workingDays", workingDays.size());
 
-        // 5. 并发写入 (小批量验证入口: existKeys 传空集, 保持"必写"语义便于回归验证)
+        // 5. 并发写入:与日常任务保持补漏语义,已有记录只跳过、不覆盖
         LocalDate monthStart = LocalDate.of(year, month, 1);
         LocalDate monthEnd = monthStart.plusMonths(1).minusDays(1);
+        Set<String> existKeys = queryExistingHoursKeys(monthStart, monthEnd);
         Map<String, Double> summaryChangeHours = querySummaryChangeHours(monthStart, monthEnd);
         int[] counts = concurrentUpsert(
-                subset, managerData, workingDays, Collections.emptySet(), summaryChangeHours);
+                subset, managerData, workingDays, existKeys, summaryChangeHours);
+        stats.put("existingCount", existKeys.size());
         stats.put("success", counts[0]);
         stats.put("fail", counts[1]);
         log.info("小批量同步完成: 成功{}条, 失败{}条({}名员工 × {}个工作日)",
@@ -1644,17 +1661,8 @@ public class WorkHoursCalcService {
         long totalCount;
         Map<String, Integer> matchedCounts = new HashMap<>();
         do {
-            DDR_New result = ydClient.queryData(YDParam.builder()
-                    .appType(whConf.getYidaAppType())
-                    .systemToken(whConf.getYidaSystemToken())
-                    .formUuid(whConf.getFormUuidWorkHoursSummary())
-                    .searchFieldJson(JSON.toJSONString(search))
-                    .currentPage(currentPage)
-                    .pageSize(pageSize)
-                    .build(), YDConf.FORM_QUERY.retrieve_search_form);
-            if (result == null) {
-                throw new IllegalStateException("查询工时汇总变更工时返回空响应");
-            }
+            DDR_New result = querySummaryChangeHoursPage(
+                    JSON.toJSONString(search), fromDate, toDate, currentPage, pageSize);
 
             totalCount = result.getTotalCount();
             List<Map> dataList = (List<Map>) result.getData();
@@ -1662,14 +1670,22 @@ public class WorkHoursCalcService {
                 break;
             }
             for (Map item : dataList) {
-                Map<String, Object> formData = (Map<String, Object>) item.get("formData");
-                if (formData == null) continue;
+                Object instanceId = summaryInstanceId(item);
+                Object rawFormData = item == null ? null : item.get("formData");
+                if (!(rawFormData instanceof Map)) {
+                    throw new IllegalStateException("工时汇总数据异常: instanceId=" + instanceId
+                            + ", formData 缺失");
+                }
+                Map<String, Object> formData = (Map<String, Object>) rawFormData;
                 String employeeId = textValue(formData.get(SUMMARY_SUBMITTER_ID));
                 String dayText = textValue(formData.get(SUMMARY_DAY_TEXT));
-                if (employeeId.isEmpty() || !dayText.matches("\\d{8}")) continue;
+                if (employeeId.isEmpty()) {
+                    throw new IllegalStateException("工时汇总填报人异常: instanceId=" + instanceId
+                            + ", dayText=" + dayText + ", employeeId 为空");
+                }
 
-                String key = employeeId + "|" + LocalDate.parse(
-                        dayText, DateTimeFormatter.BASIC_ISO_DATE);
+                LocalDate summaryDay = parseSummaryDay(item, employeeId, dayText, fromDate, toDate);
+                String key = requiredHoursKey(employeeId, summaryDay);
                 double dailyChange = hoursValue(formData.get(SUMMARY_DAILY_CHANGE));
                 changeHours.merge(key, dailyChange, Double::sum);
                 matchedCounts.merge(key, 1, Integer::sum);
@@ -1685,6 +1701,66 @@ public class WorkHoursCalcService {
         return changeHours;
     }
 
+    private DDR_New querySummaryChangeHoursPage(String searchFieldJson,
+                                                LocalDate fromDate,
+                                                LocalDate toDate,
+                                                int currentPage,
+                                                int pageSize) {
+        for (int attempt = 1; attempt <= EXISTING_QUERY_MAX_ATTEMPTS; attempt++) {
+            try {
+                DDR_New result = ydClient.queryData(YDParam.builder()
+                        .appType(whConf.getYidaAppType())
+                        .systemToken(whConf.getYidaSystemToken())
+                        .formUuid(whConf.getFormUuidWorkHoursSummary())
+                        .searchFieldJson(searchFieldJson)
+                        .currentPage(currentPage)
+                        .pageSize(pageSize)
+                        .build(), YDConf.FORM_QUERY.retrieve_search_form);
+                if (result == null) {
+                    throw new McException("TEMPORARY_FAILURE", "查询工时汇总变更工时返回空响应");
+                }
+                if (attempt > 1) {
+                    log.info("查询汇总变更工时重试成功: range=[{} ~ {}], page={}, attempt={}/{}",
+                            fromDate, toDate, currentPage, attempt, EXISTING_QUERY_MAX_ATTEMPTS);
+                }
+                return result;
+            } catch (Exception e) {
+                boolean retryable = isRetryableExistingQueryError(e);
+                log.warn("查询汇总变更工时失败: range=[{} ~ {}], page={}, pageSize={}, attempt={}/{}, retryable={}, code={}, source={}, message={}",
+                        fromDate, toDate, currentPage, pageSize, attempt, EXISTING_QUERY_MAX_ATTEMPTS,
+                        retryable, queryErrorCode(e), queryErrorSource(e), e.getMessage());
+                if (!retryable || attempt >= EXISTING_QUERY_MAX_ATTEMPTS) throw e;
+                sleepBeforeExistingQueryRetry(EXISTING_QUERY_RETRY_DELAYS_MS[attempt - 1]);
+            }
+        }
+        throw new IllegalStateException("查询汇总变更工时重试流程异常结束");
+    }
+
+    private LocalDate parseSummaryDay(Map item,
+                                      String employeeId,
+                                      String dayText,
+                                      LocalDate fromDate,
+                                      LocalDate toDate) {
+        Object instanceId = summaryInstanceId(item);
+        try {
+            LocalDate day = LocalDate.parse(dayText, DateTimeFormatter.BASIC_ISO_DATE);
+            if (day.isBefore(fromDate) || day.isAfter(toDate)) {
+                throw new DateTimeParseException("日期文本与查询范围不一致", dayText, 0);
+            }
+            return day;
+        } catch (DateTimeParseException e) {
+            throw new IllegalStateException("工时汇总日期异常: instanceId=" + instanceId
+                    + ", employeeId=" + employeeId + ", dayText=" + dayText
+                    + ", expectedRange=[" + fromDate + " ~ " + toDate + "]", e);
+        }
+    }
+
+    private Object summaryInstanceId(Map item) {
+        if (item == null) return null;
+        return item.get("formInstanceId") != null
+                ? item.get("formInstanceId") : item.get("formInstId");
+    }
+
     static double resolveInitialRequiredHours(double baseHours, double changeHours) {
         return baseHours + changeHours;
     }

+ 215 - 4
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/WorkHoursCalcServiceTest.java

@@ -80,6 +80,161 @@ public class WorkHoursCalcServiceTest {
         assertEquals(2d, result.get("employee-2|2026-09-30"), 0.000001d);
     }
 
+    @Test
+    @SuppressWarnings("unchecked")
+    public void summaryChangeHoursShouldRetryOnlyFailedPage() {
+        YDClient ydClient = mock(YDClient.class);
+        LocalDate workDay = LocalDate.of(2026, 9, 30);
+        long totalCount = YDConf.PAGE_SIZE_LIMIT + 1L;
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(pageOf(Collections.singletonList(
+                        summaryRecord("employee-1", workDay, 1d)), totalCount))
+                .thenThrow(new McException("TEMPORARY_FAILURE", "temporary failure"))
+                .thenReturn(pageOf(Collections.singletonList(
+                        summaryRecord("employee-1", workDay, 2d)), totalCount));
+
+        WorkHoursCalcService service = workHoursService(ydClient);
+        List<Long> retryDelays = new ArrayList<>();
+        ReflectionTestUtils.setField(service, "existingQueryRetrySleeper",
+                (WorkHoursCalcService.RetrySleeper) retryDelays::add);
+
+        Map<String, Double> result = ReflectionTestUtils.invokeMethod(
+                service, "querySummaryChangeHours", workDay, workDay);
+
+        assertEquals(3d, result.get("employee-1|2026-09-30"), 0.000001d);
+        assertEquals(Collections.singletonList(2_000L), retryDelays);
+        verify(ydClient, times(3)).queryData(
+                any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form));
+    }
+
+    @Test
+    public void summaryChangeHoursShouldNotRetryPermanentFailure() {
+        YDClient ydClient = mock(YDClient.class);
+        LocalDate workDay = LocalDate.of(2026, 9, 30);
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenThrow(new McException("INVALID_PARAMETER", "formUuid 参数错误"));
+        WorkHoursCalcService service = workHoursService(ydClient);
+
+        try {
+            ReflectionTestUtils.invokeMethod(service, "querySummaryChangeHours", workDay, workDay);
+            fail("永久性错误应立即抛出");
+        } catch (McException e) {
+            assertEquals("INVALID_PARAMETER", e.getCode());
+        }
+
+        verify(ydClient, times(1)).queryData(
+                any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form));
+    }
+
+    @Test
+    public void summaryChangeHoursShouldRejectInvalidDayBeforeWriting() {
+        YDClient ydClient = mock(YDClient.class);
+        LocalDate workDay = LocalDate.of(2026, 9, 30);
+        Map<String, Object> invalid = summaryRecord("employee-1", workDay, 4d);
+        ((Map<String, Object>) invalid.get("formData")).put("textField_mmbffvdb", "20260230");
+        invalid.put("formInstanceId", "summary-invalid-1");
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(pageOf(Collections.singletonList(invalid)));
+        WorkHoursCalcService service = workHoursService(ydClient);
+
+        try {
+            ReflectionTestUtils.invokeMethod(service, "querySummaryChangeHours", workDay, workDay);
+            fail("非法汇总日期应停止本轮任务");
+        } catch (IllegalStateException e) {
+            assertTrue(e.getMessage().contains("summary-invalid-1"));
+            assertTrue(e.getMessage().contains("20260230"));
+        }
+
+        verify(ydClient, never()).operateData(any(YDParam.class), any(YDConf.FORM_OPERATION.class));
+    }
+
+    @Test
+    public void summaryChangeHoursShouldRejectEmptyDayBeforeWriting() {
+        YDClient ydClient = mock(YDClient.class);
+        LocalDate workDay = LocalDate.of(2026, 9, 30);
+        Map<String, Object> invalid = summaryRecord("employee-1", workDay, 4d);
+        ((Map<String, Object>) invalid.get("formData")).put("textField_mmbffvdb", "");
+        invalid.put("formInstanceId", "summary-empty-day-1");
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(pageOf(Collections.singletonList(invalid)));
+        WorkHoursCalcService service = workHoursService(ydClient);
+
+        try {
+            ReflectionTestUtils.invokeMethod(service, "querySummaryChangeHours", workDay, workDay);
+            fail("汇总日期为空时应停止本轮任务");
+        } catch (IllegalStateException e) {
+            assertTrue(e.getMessage().contains("summary-empty-day-1"));
+            assertTrue(e.getMessage().contains("dayText="));
+        }
+
+        verify(ydClient, never()).operateData(any(YDParam.class), any(YDConf.FORM_OPERATION.class));
+    }
+
+    @Test
+    public void summaryChangeHoursShouldRejectMissingEmployeeBeforeWriting() {
+        YDClient ydClient = mock(YDClient.class);
+        LocalDate workDay = LocalDate.of(2026, 9, 30);
+        Map<String, Object> invalid = summaryRecord("", workDay, 4d);
+        invalid.put("formInstanceId", "summary-empty-employee-1");
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(pageOf(Collections.singletonList(invalid)));
+        WorkHoursCalcService service = workHoursService(ydClient);
+
+        try {
+            ReflectionTestUtils.invokeMethod(service, "querySummaryChangeHours", workDay, workDay);
+            fail("汇总填报人为空时应停止本轮任务");
+        } catch (IllegalStateException e) {
+            assertTrue(e.getMessage().contains("summary-empty-employee-1"));
+            assertTrue(e.getMessage().contains("employeeId 为空"));
+        }
+
+        verify(ydClient, never()).operateData(any(YDParam.class), any(YDConf.FORM_OPERATION.class));
+    }
+
+    @Test
+    public void summaryChangeHoursShouldRejectDayOutsideQueryRange() {
+        YDClient ydClient = mock(YDClient.class);
+        LocalDate workDay = LocalDate.of(2026, 9, 30);
+        Map<String, Object> mismatched = summaryRecord("employee-1", workDay.plusDays(1), 4d);
+        mismatched.put("formInstanceId", "summary-mismatch-1");
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(pageOf(Collections.singletonList(mismatched)));
+        WorkHoursCalcService service = workHoursService(ydClient);
+
+        try {
+            ReflectionTestUtils.invokeMethod(service, "querySummaryChangeHours", workDay, workDay);
+            fail("汇总日期文本与查询范围不一致时应停止本轮任务");
+        } catch (IllegalStateException e) {
+            assertTrue(e.getMessage().contains("summary-mismatch-1"));
+            assertTrue(e.getMessage().contains("20261001"));
+        }
+    }
+
+    @Test
+    public void repairRequiredHoursCreationShouldNotReadSummaryChanges() {
+        YDClient ydClient = mock(YDClient.class);
+        LocalDate workDay = LocalDate.of(2026, 9, 30);
+        Map<String, Object> personnel = personnelRecord("employee-1", "在职", null);
+        Map<String, Object> personnelData = (Map<String, Object>) personnel.get("formData");
+        personnelData.put("radioField_mkow4ydo", "内部");
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(pageOf(Collections.emptyList()))
+                .thenReturn(pageOf(Collections.emptyList()))
+                .thenReturn(pageOf(Collections.singletonList(personnel)));
+        WorkHoursCalcService service = workHoursService(ydClient);
+
+        Map<String, Object> result = service.syncOneEmployeeOneDayForRepair("employee-1", workDay);
+
+        assertEquals(true, result.get("success"));
+        verify(ydClient, times(3)).queryData(
+                any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form));
+        ArgumentCaptor<YDParam> paramCaptor = ArgumentCaptor.forClass(YDParam.class);
+        verify(ydClient).operateData(paramCaptor.capture(), eq(YDConf.FORM_OPERATION.upsert));
+        Map<String, Object> written = JSON.parseObject(
+                paramCaptor.getValue().getFormDataJson(), Map.class);
+        assertEquals(8d, ((Number) written.get("numberField_mmd8onl6")).doubleValue(), 0.000001d);
+    }
+
     @Test
     public void concurrentUpsertShouldWriteBasePlusSummaryChange() {
         YDClient ydClient = mock(YDClient.class);
@@ -158,6 +313,50 @@ public class WorkHoursCalcServiceTest {
         assertEquals("workDay 在 today 之后, 按业务规则不写入未来数据", result.get("error"));
     }
 
+    @Test
+    public void syncOneEmployeeOneDayShouldSkipExistingRequiredHours() {
+        YDClient ydClient = mock(YDClient.class);
+        LocalDate workDay = LocalDate.now().minusDays(1);
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(pageOf(Collections.singletonList(
+                        requiredHoursRecord("required-1", "employee-1", workDay))));
+        WorkHoursCalcService service = workHoursService(ydClient);
+
+        Map<String, Object> result = service.syncOneEmployeeOneDay("employee-1", workDay);
+
+        assertEquals(true, result.get("success"));
+        assertEquals(true, result.get("skippedExisting"));
+        verify(ydClient, never()).operateData(any(YDParam.class), any(YDConf.FORM_OPERATION.class));
+    }
+
+    @Test
+    public void syncBatchEmployeesShouldSkipExistingRequiredHours() {
+        YDClient ydClient = mock(YDClient.class);
+        LocalDate targetMonth = LocalDate.now().withDayOfMonth(1);
+        Map<String, Object> personnel = personnelRecord("employee-1", "在职", null);
+        Map<String, Object> personnelData = (Map<String, Object>) personnel.get("formData");
+        personnelData.put("radioField_mkow4ydo", "内部");
+        List<Map<String, Object>> existing = new ArrayList<>();
+        LocalDate monthEnd = targetMonth.plusMonths(1).minusDays(1);
+        for (LocalDate day = targetMonth; !day.isAfter(monthEnd); day = day.plusDays(1)) {
+            if (day.getDayOfWeek().getValue() <= 5) {
+                existing.add(requiredHoursRecord("required-" + day, "employee-1", day));
+            }
+        }
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(pageOf(Collections.singletonList(personnel)))
+                .thenReturn(pageOf(Collections.emptyList()))
+                .thenReturn(pageOf(existing))
+                .thenReturn(pageOf(Collections.emptyList()));
+        WorkHoursCalcService service = workHoursService(ydClient);
+
+        Map<String, Object> result = service.syncBatchEmployees(1, targetMonth);
+
+        assertEquals(existing.size(), result.get("existingCount"));
+        assertEquals(0, result.get("success"));
+        verify(ydClient, never()).operateData(any(YDParam.class), any(YDConf.FORM_OPERATION.class));
+    }
+
     @Test
     public void syncOneInternalEmployeeShouldNotRequireProject() {
         YDClient ydClient = mock(YDClient.class);
@@ -167,7 +366,9 @@ public class WorkHoursCalcServiceTest {
         formData.put("radioField_mkow4ydo", "内部");
         formData.put("employeeField_mh8xhqc3_id", Collections.singletonList("manager-1"));
         when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
-                .thenReturn(pageOf(Collections.singletonList(personnel)));
+                .thenReturn(pageOf(Collections.emptyList()))
+                .thenReturn(pageOf(Collections.singletonList(personnel)))
+                .thenReturn(pageOf(Collections.emptyList()));
 
         WorkHoursCalcService service = new WorkHoursCalcService();
         ReflectionTestUtils.setField(service, "ydClient", ydClient);
@@ -190,7 +391,9 @@ public class WorkHoursCalcServiceTest {
         formData.put("radioField_mkow4ydo", "内部");
         formData.put("employeeField_mh8xhqc3_id", Collections.singletonList("manager-from-personnel"));
         when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
-                .thenReturn(pageOf(Collections.singletonList(personnel)));
+                .thenReturn(pageOf(Collections.emptyList()))
+                .thenReturn(pageOf(Collections.singletonList(personnel)))
+                .thenReturn(pageOf(Collections.emptyList()));
 
         WorkHoursCalcService service = new WorkHoursCalcService();
         ReflectionTestUtils.setField(service, "ydClient", ydClient);
@@ -212,7 +415,9 @@ public class WorkHoursCalcServiceTest {
         Map<String, Object> formData = (Map<String, Object>) personnel.get("formData");
         formData.put("radioField_mkow4ydo", "内部");
         when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
-                .thenReturn(pageOf(Collections.singletonList(personnel)));
+                .thenReturn(pageOf(Collections.emptyList()))
+                .thenReturn(pageOf(Collections.singletonList(personnel)))
+                .thenReturn(pageOf(Collections.emptyList()));
 
         WorkHoursCalcService service = new WorkHoursCalcService();
         ReflectionTestUtils.setField(service, "ydClient", ydClient);
@@ -511,6 +716,7 @@ public class WorkHoursCalcServiceTest {
         formData.put("radioField_mkow4ydo", "外部");
         when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
                 .thenReturn(
+                        pageOf(Collections.emptyList()),
                         pageOf(Collections.singletonList(personnel)),
                         pageOf(Collections.emptyList()));
 
@@ -1029,8 +1235,12 @@ public class WorkHoursCalcServiceTest {
     }
 
     private static DDR_New<Object> pageOf(List<Map<String, Object>> records) {
+        return pageOf(records, records.size());
+    }
+
+    private static DDR_New<Object> pageOf(List<Map<String, Object>> records, long totalCount) {
         DDR_New<Object> page = new DDR_New<>();
-        page.setTotalCount(records.size());
+        page.setTotalCount(totalCount);
         page.setData(records);
         return page;
     }
@@ -1039,6 +1249,7 @@ public class WorkHoursCalcServiceTest {
         WHConf conf = new WHConf();
         conf.setYidaAppType("app");
         conf.setYidaSystemToken("token");
+        conf.setFormUuidHoliday("holiday");
         conf.setFormUuidPersonnel("personnel");
         conf.setFormUuidRequiredHours("required-hours");
         conf.setFormUuidWorkHoursSummary("work-hours-summary");