2 次代碼提交 5d4795afc5 ... c8232f9f8e

作者 SHA1 備註 提交日期
  malk c8232f9f8e fix(workhours): harden summary change initialization 1 月之前
  malk 0d2170a1e2 fix(timecard): preserve submitted-hours edit locks 1 月之前

+ 32 - 10
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/TimeCardRepairService.java

@@ -88,11 +88,30 @@ public class TimeCardRepairService {
         String monthText = request.getDayText().substring(0, 6);
         validateMonthRequest(request.getSubmitterUserId(), monthText);
         McException.assertAccessException(!monthClosingService.isClosed(monthText), "补填只能保存已封账月份");
+        McException.assertAccessException(StringUtils.isBlank(whConf.getFormUuidWorkHoursSummary()), "原工时汇总表未配置");
         McException.assertAccessException(StringUtils.isBlank(whConf.getFormUuidRepairWorkHoursSummary()), "补填汇总表未配置");
         McException.assertAccessException(StringUtils.isBlank(whConf.getRepairSummarySyncStatusField()), "补填汇总状态字段未配置");
         McException.assertAccessException(request.getFormData() == null, "补填工时数据不能为空");
         assertSourceDateAvailable(request.getSubmitterUserId(), request.getDayText());
+        List<Map> existing = query(whConf.getFormUuidRepairWorkHoursSummary(),
+                mapOf(R_SUBMITTER, request.getSubmitterUserId(), R_DAY, request.getDayText()));
+        McException.assertAccessException(existing.size() > 1,
+                "补填汇总记录重复,已停止保存 userId=" + request.getSubmitterUserId() + ", day=" + request.getDayText());
+        McException.assertAccessException(existing.stream().anyMatch(record ->
+                        TimeCardSummaryService.isRepairRecordLocked(record, whConf.getRepairSummarySyncStatusField())),
+                "该日期补填工时已提交或已审批,不能再填写 userId=" + request.getSubmitterUserId()
+                        + ", day=" + request.getDayText());
         Map<String, Object> data = new LinkedHashMap<>(request.getFormData());
+        // fixme 草稿仅接收明细暂存工时;汇总由服务端重算,不能信任页面传入的审批合计。
+        for (String table : Arrays.asList(R_BIL_TABLE, R_NON_TABLE, R_OTHER_TABLE)) {
+            for (Map child : list(data.get(table))) {
+                McException.assertAccessException(
+                        TimeCardSummaryService.hasSubmittedOrApprovedHours(mapOf(table,
+                                Collections.singletonList(child))),
+                        "补填草稿不能包含已提交、已审批或变更工时");
+            }
+        }
+        TimeCardRepairService.applySummaryTotals(data, data);
         data.put(R_SUBMITTER, request.getSubmitterUserId());
         data.put(R_DAY, request.getDayText());
         data.put(R_MONTH, request.getDayText().substring(0, 6));
@@ -484,9 +503,15 @@ public class TimeCardRepairService {
     private long parseDay(String day) { return LocalDate.parse(day, DateTimeFormatter.ofPattern("yyyyMMdd")).atStartOfDay(ZONE).toInstant().toEpochMilli(); }
     private List<Map> query(String formUuid, Map<String, Object> search) {
         if (StringUtils.isBlank(formUuid)) return new ArrayList<>();
-        Object data = ydClient.queryData(YDParam.builder().appType(whConf.getYidaAppType()).systemToken(whConf.getYidaSystemToken())
-                .formUuid(formUuid).searchFieldJson(JSON.toJSONString(search)).currentPage(1).pageSize(100).build(), YDConf.FORM_QUERY.retrieve_search_form).getData();
-        return data instanceof List ? (List<Map>) data : new ArrayList<>();
+        com.malk.server.dingtalk.DDR_New result = ydClient.queryData(YDParam.builder()
+                .appType(whConf.getYidaAppType()).systemToken(whConf.getYidaSystemToken())
+                .formUuid(formUuid).searchFieldJson(JSON.toJSONString(search))
+                .currentPage(1).pageSize(100).build(), YDConf.FORM_QUERY.retrieve_search_form);
+        McException.assertAccessException(result == null || !(result.getData() instanceof List),
+                "查询现有工时失败,已停止保存");
+        List<Map> records = (List<Map>) result.getData();
+        McException.assertAccessException(records.size() >= 100, "现有工时记录过多,已停止保存");
+        return records;
     }
     private void validateMonthRequest(String userId, String month) {
         McException.assertAccessException(StringUtils.isAnyBlank(userId, month)
@@ -544,17 +569,14 @@ public class TimeCardRepairService {
     private void assertSourceDateAvailable(String userId, String day) {
         List<Map> original = query(whConf.getFormUuidWorkHoursSummary(), mapOf(R_SUBMITTER, userId, R_DAY, day));
         boolean hasApprovedOrChangedHours = original.stream()
-                .anyMatch(TimeCardRepairService::hasApprovedOrChangedHours);
+                .anyMatch(TimeCardSummaryService::isFormalRecordLocked);
         McException.assertAccessException(hasApprovedOrChangedHours,
-                "来源日期已有已审批或变更工时,不能补填 userId=" + userId + ", day=" + day);
+                "来源日期已有已提交、已审批或变更工时,不能补填 userId=" + userId + ", day=" + day);
     }
 
-    /** 仅原汇总当天已审批与变更工时的实际合计大于 0 时禁止补填;暂存草稿不占用补填资格。 */
+    /** 原汇总当天任一已审批或变更工时均禁止补填;暂存草稿不占用补填资格。 */
     static boolean hasApprovedOrChangedHours(Map record) {
-        Object nested = record == null ? null : record.get("formData");
-        Map formData = nested instanceof Map ? (Map) nested : record;
-        if (formData == null) return false;
-        return num(formData.get(R_DAILY_APPROVED)) + num(formData.get(R_DAILY_CHANGE)) > 1e-6;
+        return TimeCardSummaryService.hasApprovedOrChangedHours(record);
     }
 
     private void assertRepairRowMutable(String userId, String day, Map row, String category) {

+ 118 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/TimeCardSummaryService.java

@@ -31,6 +31,20 @@ public class TimeCardSummaryService {
 
     private static final String SUBMITTER_ID_FIELD = "textField_mmbffvd9";
     private static final String DAY_TEXT_FIELD = "textField_mmbffvdb";
+    private static final String DAILY_APPROVED_FIELD = "numberField_mmeakgi4";
+    private static final String DAILY_CHANGE_FIELD = "numberField_mmeakgi5";
+    private static final String[] APPROVED_FIELDS = {
+            DAILY_APPROVED_FIELD, "numberField_mmacxewg", "numberField_mmad6jc1", "numberField_mmd1smes"};
+    private static final String[] CHANGE_FIELDS = {
+            DAILY_CHANGE_FIELD, "numberField_mmad6jbc", "numberField_mmeakgia", "numberField_mmeakgic"};
+    private static final String[] CHILD_TABLES = {
+            "tableField_mmczo634", "tableField_mmczo63h", "tableField_mmeakgid"};
+    private static final String[] CHILD_APPROVED_FIELDS = {
+            "numberField_mmczo637", "numberField_mmczo63d", "numberField_mmeakgii"};
+    private static final String[] CHILD_CHANGE_FIELDS = {
+            "numberField_mmd80zae", "numberField_mmeakgi9", "numberField_mmeakgim"};
+    private static final String[] CHILD_SUBMITTED_FIELDS = {
+            "numberField_mmczo636", "numberField_mmczo63c", "numberField_mmeakgij"};
 
     @Autowired
     private YDClient ydClient;
@@ -57,6 +71,18 @@ public class TimeCardSummaryService {
                 request.getFormData() == null || request.getFormData().isEmpty(),
                 "工时数据不能为空");
 
+        String userId = request.getSubmitterUserId();
+        String dayText = request.getDayText();
+        boolean approved = queryDailyRecords(whConf.getFormUuidWorkHoursSummary(), userId, dayText)
+                .stream().anyMatch(TimeCardSummaryService::isFormalRecordLocked);
+        if (!approved && StringUtils.isNotBlank(whConf.getFormUuidRepairWorkHoursSummary())) {
+            approved = queryDailyRecords(whConf.getFormUuidRepairWorkHoursSummary(), userId, dayText)
+                    .stream().anyMatch(record -> isRepairRecordLocked(record,
+                            whConf.getRepairSummarySyncStatusField()));
+        }
+        McException.assertAccessException(approved,
+                "该日期已有已提交或已审批工时,不能再填写 userId=" + userId + ", day=" + dayText);
+
         Map<String, Object> formData = new LinkedHashMap<>(request.getFormData());
         // fixme 唯一键由服务端覆盖,防止请求条件与实际写入字段不一致。
         formData.put(SUBMITTER_ID_FIELD, request.getSubmitterUserId());
@@ -77,6 +103,98 @@ public class TimeCardSummaryService {
         return true;
     }
 
+    @SuppressWarnings("unchecked")
+    List<Map> queryDailyRecords(String formUuid, String userId, String dayText) {
+        Map<String, Object> search = new LinkedHashMap<>();
+        search.put(SUBMITTER_ID_FIELD, userId);
+        search.put(DAY_TEXT_FIELD, dayText);
+        DDR_New result = ydClient.queryData(YDParam.builder()
+                .appType(whConf.getYidaAppType())
+                .systemToken(whConf.getYidaSystemToken())
+                .formUuid(formUuid)
+                .searchFieldJson(JSON.toJSONString(search))
+                .pageNumber(1)
+                .pageSize(100)
+                .build(), YDConf.FORM_QUERY.retrieve_search_form);
+        McException.assertAccessException(result == null || !(result.getData() instanceof List),
+                "查询现有工时失败,已停止保存");
+        List<Map> records = (List<Map>) result.getData();
+        McException.assertAccessException(records.size() >= 100,
+                "现有工时记录过多,已停止保存");
+        List<Map> matching = new ArrayList<>();
+        for (Map record : records) {
+            Map data = formData(record);
+            if (userId.equals(String.valueOf(data.get(SUBMITTER_ID_FIELD)))
+                    && dayText.equals(String.valueOf(data.get(DAY_TEXT_FIELD)))) {
+                matching.add(record);
+            }
+        }
+        return matching;
+    }
+
+    static boolean hasApprovedOrChangedHours(Map record) {
+        Map data = formData(record);
+        if ("已审批".equals(String.valueOf(data.get("selectField_mre1xz9g")))) return true;
+        if (hasHours(data, APPROVED_FIELDS) || hasHours(data, CHANGE_FIELDS)) return true;
+        for (int index = 0; index < CHILD_TABLES.length; index++) {
+            Object children = data.get(CHILD_TABLES[index]);
+            if (!(children instanceof List)) continue;
+            for (Object child : (List) children) {
+                if (!(child instanceof Map)) continue;
+                Map row = (Map) child;
+                if (hasHours(row, CHILD_APPROVED_FIELDS[index])
+                        || hasHours(row, CHILD_CHANGE_FIELDS[index])) return true;
+            }
+        }
+        return false;
+    }
+
+    static boolean isRepairRecordLocked(Map record, String statusField) {
+        Map data = formData(record);
+        String status = String.valueOf(data.get(statusField));
+        if ("审批中".equals(status) || "已审批".equals(status)) return true;
+        return hasSubmittedOrApprovedHours(data);
+    }
+
+    static boolean isFormalRecordLocked(Map record) {
+        Map data = formData(record);
+        String status = String.valueOf(data.get("selectField_mre1xz9g"));
+        return "审批中".equals(status) || "已审批".equals(status)
+                || hasSubmittedOrApprovedHours(data);
+    }
+
+    static boolean hasSubmittedOrApprovedHours(Map record) {
+        Map data = formData(record);
+        if (hasApprovedOrChangedHours(data) || hasHours(data, "numberField_mmd1smem",
+                "numberField_mmacxewa", "numberField_mmad6jbz", "numberField_mmd1smeq")) return true;
+        for (int index = 0; index < CHILD_TABLES.length; index++) {
+            Object children = data.get(CHILD_TABLES[index]);
+            if (!(children instanceof List)) continue;
+            for (Object child : (List) children) {
+                if (child instanceof Map && hasHours((Map) child, CHILD_SUBMITTED_FIELDS[index])) return true;
+            }
+        }
+        return false;
+    }
+
+    private static Map formData(Map record) {
+        Object nested = record == null ? null : record.get("formData");
+        return nested instanceof Map ? (Map) nested : record == null ? Collections.emptyMap() : record;
+    }
+
+    private static boolean hasHours(Map data, String... fields) {
+        for (String field : fields) {
+            Object raw = data.get(field);
+            if (raw == null) continue;
+            try {
+                if (Math.abs(Double.parseDouble(String.valueOf(raw))) > 1e-6) return true;
+            } catch (NumberFormatException ignored) {
+                // 非数值不表示已有工时;宜搭字段校验会处理非法输入。
+            }
+        }
+        return false;
+    }
+
     /**
      * 按显式清单校验并清理重复汇总记录;默认 dry-run,单批最多 30 条。
      *

+ 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;
     }

+ 87 - 2
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/TimeCardRepairServiceTest.java

@@ -1,5 +1,6 @@
 package com.malk.service.workhours;
 
+import com.alibaba.fastjson.JSON;
 import com.malk.server.aliwork.YDConf;
 import com.malk.server.aliwork.YDParam;
 import com.malk.server.common.McException;
@@ -8,9 +9,11 @@ import com.malk.server.workhours.MonthClosingCheckRequest;
 import com.malk.server.workhours.MonthClosingStatusResult;
 import com.malk.server.workhours.TimeCardApprovalStartRequest;
 import com.malk.server.workhours.TimeCardRepairSubmitRequest;
+import com.malk.server.workhours.TimeCardRepairSummaryUpsertRequest;
 import com.malk.server.workhours.WHConf;
 import com.malk.service.aliwork.YDClient;
 import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
 import org.springframework.test.util.ReflectionTestUtils;
 
 import java.time.YearMonth;
@@ -27,6 +30,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoInteractions;
@@ -112,12 +116,93 @@ class TimeCardRepairServiceTest {
     }
 
     @Test
-    void shouldAllowRepairWhenChangeFullyOffsetsApprovedHours() {
+    void shouldBlockRepairWhenChangeFullyOffsetsApprovedHours() {
         Map<String, Object> formData = new HashMap<>();
         formData.put("numberField_mmeakgi4", 8d);
         formData.put("numberField_mmeakgi5", -8d);
 
-        assertFalse(TimeCardRepairService.hasApprovedOrChangedHours(formData));
+        assertTrue(TimeCardRepairService.hasApprovedOrChangedHours(formData));
+    }
+
+    @Test
+    void shouldBlockRepairWhenOriginalApprovalStatusIsApproved() {
+        Map<String, Object> formData = new HashMap<>();
+        formData.put("selectField_mre1xz9g", "已审批");
+        assertTrue(TimeCardRepairService.hasApprovedOrChangedHours(formData));
+    }
+
+    @Test
+    void shouldNotSaveDraftOverApprovedRepairWithCorruptedDraftStatus() {
+        YDClient client = mock(YDClient.class);
+        MonthClosingService closingService = mock(MonthClosingService.class);
+        WHConf conf = new WHConf();
+        conf.setFormUuidWorkHoursSummary("original-summary");
+        conf.setFormUuidRepairWorkHoursSummary("repair-summary");
+        conf.setRepairSummarySyncStatusField("repair-status");
+        when(closingService.isClosed("202608")).thenReturn(true);
+        DDR_New<Object> noOriginal = new DDR_New<>();
+        noOriginal.setData(Collections.emptyList());
+        Map<String, Object> child = new HashMap<>();
+        child.put("numberField_mmeakgii", 8d);
+        Map<String, Object> formData = new HashMap<>();
+        formData.put("repair-status", "暂存");
+        formData.put("textField_mmbffvd9", "user-1");
+        formData.put("textField_mmbffvdb", "20260818");
+        formData.put("tableField_mmeakgid", Collections.singletonList(child));
+        Map<String, Object> record = new HashMap<>();
+        record.put("formData", formData);
+        DDR_New<Object> approvedRepair = new DDR_New<>();
+        approvedRepair.setData(Collections.singletonList(record));
+        when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(noOriginal, approvedRepair);
+        TimeCardRepairService service = new TimeCardRepairService();
+        ReflectionTestUtils.setField(service, "ydClient", client);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+        ReflectionTestUtils.setField(service, "monthClosingService", closingService);
+        TimeCardRepairSummaryUpsertRequest request = new TimeCardRepairSummaryUpsertRequest();
+        request.setSubmitterUserId("user-1");
+        request.setDayText("20260818");
+        request.setFormData(Collections.singletonMap("numberField_mmd1smel", 8d));
+
+        assertThrows(McException.class, () -> service.upsert(request));
+        verify(client, never()).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.upsert));
+    }
+
+    @Test
+    void shouldCalculateRepairDraftTotalsFromRowsInsteadOfPageTotals() {
+        YDClient client = mock(YDClient.class);
+        MonthClosingService closingService = mock(MonthClosingService.class);
+        WHConf conf = new WHConf();
+        conf.setFormUuidWorkHoursSummary("original-summary");
+        conf.setFormUuidRepairWorkHoursSummary("repair-summary");
+        conf.setRepairSummarySyncStatusField("repair-status");
+        when(closingService.isClosed("202608")).thenReturn(true);
+        DDR_New<Object> empty = new DDR_New<>();
+        empty.setData(Collections.emptyList());
+        when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(empty);
+        TimeCardRepairService service = new TimeCardRepairService();
+        ReflectionTestUtils.setField(service, "ydClient", client);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+        ReflectionTestUtils.setField(service, "monthClosingService", closingService);
+        Map<String, Object> child = new HashMap<>();
+        child.put("numberField_mmczo635", 5d);
+        Map<String, Object> payload = new HashMap<>();
+        payload.put("tableField_mmczo634", Collections.singletonList(child));
+        payload.put("numberField_mmeakgi4", 9d);
+        payload.put("numberField_mmd1smel", 99d);
+        TimeCardRepairSummaryUpsertRequest request = new TimeCardRepairSummaryUpsertRequest();
+        request.setSubmitterUserId("user-1");
+        request.setDayText("20260818");
+        request.setFormData(payload);
+
+        assertTrue(service.upsert(request));
+        ArgumentCaptor<YDParam> captor = ArgumentCaptor.forClass(YDParam.class);
+        verify(client).operateData(captor.capture(), eq(YDConf.FORM_OPERATION.upsert));
+        Map<String, Object> written = JSON.parseObject(captor.getValue().getFormDataJson(), Map.class);
+        assertEquals(5d, ((Number) written.get("numberField_mmd1smel")).doubleValue());
+        assertEquals(0d, ((Number) written.get("numberField_mmeakgi4")).doubleValue());
+        assertEquals("暂存", written.get("repair-status"));
     }
 
     @Test

+ 71 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/TimeCardSummaryServiceTest.java

@@ -4,6 +4,7 @@ import com.alibaba.fastjson.JSON;
 import com.malk.server.aliwork.YDConf;
 import com.malk.server.aliwork.YDParam;
 import com.malk.server.dingtalk.DDR_New;
+import com.malk.server.common.McException;
 import com.malk.server.workhours.TimeCardDuplicateCleanupRequest;
 import com.malk.server.workhours.TimeCardDuplicateCleanupResult;
 import com.malk.server.workhours.TimeCardSummaryUpsertRequest;
@@ -47,6 +48,10 @@ class TimeCardSummaryServiceTest {
         request.setSubmitterUserId("user-1");
         request.setDayText("20260716");
         request.setFormData(Collections.singletonMap("numberField_hours", 8));
+        DDR_New<Object> empty = new DDR_New<>();
+        empty.setData(Collections.emptyList());
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(empty);
 
         assertTrue(service.upsert(request));
 
@@ -63,6 +68,72 @@ class TimeCardSummaryServiceTest {
         assertEquals(8, ((Number) formData.get("numberField_hours")).intValue());
     }
 
+    @Test
+    void shouldNotOverwriteApprovedSummaryEvenWhenDailyTotalIsZero() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf whConf = new WHConf();
+        whConf.setFormUuidWorkHoursSummary("FORM-SUMMARY");
+        TimeCardSummaryService service = new TimeCardSummaryService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", whConf);
+
+        Map<String, Object> item = summaryItem("APPROVED-ID", "user-1", "20260716");
+        Map<String, Object> data = (Map<String, Object>) item.get("formData");
+        data.put("numberField_mmeakgi4", 0);
+        Map<String, Object> child = new LinkedHashMap<>();
+        child.put("numberField_mmczo637", 8);
+        data.put("tableField_mmczo634", Collections.singletonList(child));
+        DDR_New<Object> existing = new DDR_New<>();
+        existing.setData(Collections.singletonList(item));
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(existing);
+        TimeCardSummaryUpsertRequest request = new TimeCardSummaryUpsertRequest();
+        request.setSubmitterUserId("user-1");
+        request.setDayText("20260716");
+        request.setFormData(Collections.singletonMap("numberField_mmd1smel", 8));
+
+        assertThrows(McException.class, () -> service.upsert(request));
+        verify(ydClient, never()).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.upsert));
+    }
+
+    @Test
+    void shouldNotOverwriteApprovedRepairFromNormalSummary() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf whConf = new WHConf();
+        whConf.setFormUuidWorkHoursSummary("FORM-SUMMARY");
+        whConf.setFormUuidRepairWorkHoursSummary("FORM-REPAIR");
+        whConf.setRepairSummarySyncStatusField("repair-status");
+        TimeCardSummaryService service = new TimeCardSummaryService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", whConf);
+        DDR_New<Object> empty = new DDR_New<>();
+        empty.setData(Collections.emptyList());
+        Map<String, Object> repair = summaryItem("REPAIR-ID", "user-1", "20260716");
+        ((Map<String, Object>) repair.get("formData")).put("repair-status", "已审批");
+        DDR_New<Object> approved = new DDR_New<>();
+        approved.setData(Collections.singletonList(repair));
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(empty, approved);
+        TimeCardSummaryUpsertRequest request = new TimeCardSummaryUpsertRequest();
+        request.setSubmitterUserId("user-1");
+        request.setDayText("20260716");
+        request.setFormData(Collections.singletonMap("numberField_mmd1smel", 8));
+
+        assertThrows(McException.class, () -> service.upsert(request));
+        verify(ydClient, never()).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.upsert));
+    }
+
+    @Test
+    void shouldLockWholeFormalDayWhenOneChildIsSubmitted() {
+        Map<String, Object> record = summaryItem("SUBMITTED-ID", "user-1", "20260716");
+        Map<String, Object> child = new LinkedHashMap<>();
+        child.put("numberField_mmczo636", 8);
+        ((Map<String, Object>) record.get("formData"))
+                .put("tableField_mmczo634", Collections.singletonList(child));
+
+        assertTrue(TimeCardSummaryService.isFormalRecordLocked(record));
+    }
+
     @Test
     void shouldValidateDuplicateCleanupWithoutDeletingInDryRun() {
         YDClient ydClient = mock(YDClient.class);

+ 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");