ソースを参照

fix(timecard): isolate missing-hour repair from confirmed reentry

malk 8 時間 前
親
コミット
8de9b9d432

+ 2 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/workhours/TimeCardRepairContextRequest.java

@@ -9,4 +9,6 @@ public class TimeCardRepairContextRequest {
     private String sourceMonthText;
     /** 页面已核实属性,仅用于缓存隔离;服务端仍独立查档案核验。 */
     private String staffType;
+    /** 缺省普通漏填;REENTRY仅在服务端核实有效冲销记录后开放。 */
+    private String repairMode;
 }

+ 2 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/workhours/TimeCardRepairSubmitRequest.java

@@ -9,6 +9,8 @@ public class TimeCardRepairSubmitRequest {
     private String sourceMonthText;
     private String submitterUserId;
     private String submitterName;
+    /** 普通漏填NORMAL/已确认冲销重录REENTRY,不以人员属性代替场景。 */
+    private String repairMode;
     private Map<String, Object> projectGroups;
     private Map<String, Object> otherGroup;
 }

+ 2 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/workhours/TimeCardRepairSummaryUpsertRequest.java

@@ -8,5 +8,7 @@ import java.util.Map;
 public class TimeCardRepairSummaryUpsertRequest {
     private String submitterUserId;
     private String dayText;
+    /** 普通漏填NORMAL/已确认冲销重录REENTRY,服务端重新核实资格。 */
+    private String repairMode;
     private Map<String, Object> formData;
 }

+ 73 - 13
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/TimeCardRepairService.java

@@ -39,6 +39,10 @@ public class TimeCardRepairService {
     private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
     private static final DateTimeFormatter MONTH = DateTimeFormatter.ofPattern("yyyyMM");
     private static final String REPAIR_TYPE = "补填";
+    private static final String NORMAL_MODE = "NORMAL";
+    private static final String REENTRY_MODE = "REENTRY";
+    // prd 仅沿用已确认的历史冲销批次;回退或删除后不再构成重录资格,不硬编码人员名单。
+    private static final String CONFIRMED_REENTRY_BATCH = "10月调账-EXT-20261020";
     private static final String DEFAULT_MANAGER_ID = "634735129";
     // 宜搭查询接口对复制表新增的 mokwy 字段返回 field is null;沿用复制表中可检索的原字段。
     private static final String R_SUBMITTER = "textField_mmbffvd9";
@@ -85,9 +89,10 @@ public class TimeCardRepairService {
         String userId = request.getSubmitterUserId();
         String monthText = request.getSourceMonthText();
         String staffType = text(request.getStaffType());
+        String repairMode = repairMode(request.getRepairMode());
         McException.assertAccessException(!staffType.isEmpty()
                 && !"内部".equals(staffType) && !"外部".equals(staffType), "人员属性无效");
-        String requestKey = userId + "|" + monthText + "|" + staffType;
+        String requestKey = userId + "|" + monthText + "|" + staffType + "|" + repairMode;
         Map<String, Object> cachedResult = getCachedContext(requestKey);
         if (cachedResult != null) {
             CompletableFuture<Map<String, Object>> completedRequest = contextRequests.get(requestKey);
@@ -102,7 +107,7 @@ public class TimeCardRepairService {
                         .thenCompose(actualType -> {
                             McException.assertAccessException(!staffType.isEmpty() && !staffType.equals(actualType),
                                     "人员属性已变化,请刷新后重试");
-                            return loadContextAsync(requestKey, userId, monthText, actualType);
+                            return loadContextAsync(requestKey, userId, monthText, actualType, repairMode);
                         }));
         if (!contextFuture.isDone()) return pendingContext();
         contextRequests.remove(requestKey, contextFuture);
@@ -112,25 +117,33 @@ public class TimeCardRepairService {
     private CompletableFuture<Map<String, Object>> loadContextAsync(String requestKey,
                                                                      String userId,
                                                                      String monthText,
-                                                                     String staffType) {
-        // prd 外部补填独立于已冲销但保留的原汇总;不发起原表查询。
-        CompletableFuture<List<Map>> originalFuture = "外部".equals(staffType)
-                ? CompletableFuture.completedFuture(Collections.emptyList())
-                : CompletableFuture.supplyAsync(
+                                                                     String staffType, String repairMode) {
+        // fixme 普通外部补填不能免查原表;确认冲销重录继续保留独立来源,避免回退上次交付。
+        CompletableFuture<List<Map>> originalFuture = REENTRY_MODE.equals(repairMode)
+                ? CompletableFuture.completedFuture(Collections.emptyList()) : CompletableFuture.supplyAsync(
                 () -> query(whConf.getFormUuidWorkHoursSummary(),
                         mapOf(R_SUBMITTER, userId, R_MONTH, monthText)), CONTEXT_QUERY_EXECUTOR);
         CompletableFuture<List<Map>> repairFuture = CompletableFuture.supplyAsync(
                 () -> queryRepairSummaries(mapOf(R_SUBMITTER, userId, R_MONTH, monthText)),
                 CONTEXT_QUERY_EXECUTOR);
+        CompletableFuture<Set<String>> reentryFuture = "外部".equals(staffType)
+                ? CompletableFuture.supplyAsync(() -> confirmedReentryDays(userId, monthText, null), CONTEXT_QUERY_EXECUTOR)
+                : CompletableFuture.completedFuture(Collections.emptySet());
         return originalFuture.thenCombine(repairFuture, (originalRecords, repairRecords) -> {
             Map<String, Object> result = new LinkedHashMap<>();
             result.put("ready", true);
             result.put("staffType", staffType);
-            result.put("summaryReadMode", "外部".equals(staffType) ? "repair-only" : "original-and-repair");
+            result.put("repairMode", repairMode);
+            result.put("summaryReadMode", REENTRY_MODE.equals(repairMode) ? "repair-only" : "original-and-repair");
             result.put("originalRecords", compactContextRecords(originalRecords));
             result.put("repairRecords", compactContextRecords(repairRecords));
             // 兼容页面状态合并接口的 records 约定;原始双表结果仍通过 repairRecords 返回。
             result.put("records", compactContextRecords(repairRecords));
+            return result;
+        }).thenCombine(reentryFuture, (result, allowedDays) -> {
+            McException.assertAccessException(REENTRY_MODE.equals(repairMode) && allowedDays.isEmpty(),
+                    "未找到仍有效的已确认冲销记录,不能切换冲销后重录");
+            result.put("reentryAllowedDays", new ArrayList<>(allowedDays));
             contextCache.put(requestKey,
                     new ContextCacheEntry(result, System.currentTimeMillis() + CONTEXT_CACHE_TTL_MILLIS));
             return result;
@@ -201,8 +214,8 @@ public class TimeCardRepairService {
         McException.assertAccessException(StringUtils.isBlank(whConf.getRepairSummarySyncStatusField()), "补填汇总状态字段未配置");
         McException.assertAccessException(request.getFormData() == null, "补填工时数据不能为空");
         personnelPolicy.assertEmploymentDate(request.getSubmitterUserId(), request.getDayText());
-        assertSourceDateAvailable(request.getSubmitterUserId(), request.getDayText(),
-                "外部".equals(personnelPolicy.getStaffType(request.getSubmitterUserId())));
+        assertRepairSourceDateAvailable(request.getSubmitterUserId(), request.getDayText(),
+                request.getRepairMode(), "外部".equals(personnelPolicy.getStaffType(request.getSubmitterUserId())));
         List<Map> existing = queryRepairSummaries(
                 mapOf(R_SUBMITTER, request.getSubmitterUserId(), R_DAY, request.getDayText()));
         McException.assertAccessException(existing.size() > 1,
@@ -311,6 +324,7 @@ public class TimeCardRepairService {
     private String buildSubmitGuardKey(TimeCardRepairSubmitRequest request,
                                        Map<String, Object> projectGroups, Map other) {
         String payload = request.getSubmitterUserId() + "|" + request.getSourceMonthText()
+                + "|" + repairMode(request.getRepairMode())
                 + "|" + JSON.toJSONString(projectGroups) + "|" + JSON.toJSONString(other);
         try {
             byte[] digest = MessageDigest.getInstance("SHA-256")
@@ -681,7 +695,7 @@ public class TimeCardRepairService {
             McException.assertAccessException(StringUtils.isBlank(text(row.get("projectCode"))),
                     ("billableRows".equals(category) ? "Billable" : "Non Billable") + "工时必须填写项目编号");
         }
-        assertSourceDateAvailable(request.getSubmitterUserId(), day, external);
+        assertRepairSourceDateAvailable(request.getSubmitterUserId(), day, request.getRepairMode(), external);
         assertRepairRowMutable(request.getSubmitterUserId(), day, row, category);
     }
 
@@ -721,9 +735,55 @@ public class TimeCardRepairService {
         return StringUtils.isBlank(day) ? text(row.get("dayText")) : day;
     }
 
+    /** @param value 请求场景 @return 缺省为普通漏填,未知场景拒绝。 */
+    private static String repairMode(String value) {
+        String mode = StringUtils.isBlank(value) ? NORMAL_MODE : value.trim();
+        McException.assertAccessException(!NORMAL_MODE.equals(mode) && !REENTRY_MODE.equals(mode), "补填场景无效");
+        return mode;
+    }
+
+    /** 实时资格不读缓存;只有已确认批次中仍存在、已全部回写的负数冲销才允许重录。 */
+    private Set<String> confirmedReentryDays(String userId, String month, String day) {
+        McException.assertAccessException(StringUtils.isAnyBlank(whConf.getRepairSummaryFillTypeField(),
+                whConf.getRepairSummaryBatchField(), whConf.getRepairSummarySyncStatusField()), "重录资格字段未配置");
+        Map<String, Object> search = mapOf(R_SUBMITTER, userId, R_MONTH, month,
+                whConf.getRepairSummaryFillTypeField(), "变更",
+                whConf.getRepairSummaryBatchField(), CONFIRMED_REENTRY_BATCH);
+        if (day != null) search.put(R_DAY, day);
+        Set<String> days = new TreeSet<>();
+        for (Map record : query(whConf.getFormUuidRepairWorkHoursSummary(), search)) {
+            Map data = formData(record);
+            String sourceDay = text(data.get(R_DAY));
+            boolean eligible = userId.equals(text(data.get(R_SUBMITTER))) && month.equals(text(data.get(R_MONTH)))
+                    && "变更".equals(text(data.get(whConf.getRepairSummaryFillTypeField())))
+                    && CONFIRMED_REENTRY_BATCH.equals(text(data.get(whConf.getRepairSummaryBatchField())))
+                    && "全部成功".equals(text(data.get(whConf.getRepairSummarySyncStatusField())))
+                    && Double.isFinite(num(data.get(R_DAILY_APPROVED))) && num(data.get(R_DAILY_APPROVED)) < -1e-6
+                    && sourceDay.matches("\\d{8}") && sourceDay.startsWith(month)
+                    && (day == null || day.equals(sourceDay));
+            if (!eligible) continue;
+            try {
+                LocalDate.parse(sourceDay, DateTimeFormatter.ofPattern("yyyyMMdd"));
+            } catch (RuntimeException invalidDate) {
+                throw new McException("REENTRY_DATE_INVALID", "冲销来源日期异常,不能重录");
+            }
+            McException.assertAccessException(!days.add(sourceDay), "冲销来源日重复,不能核实重录资格");
+        }
+        return days;
+    }
+
+    private void assertRepairSourceDateAvailable(String userId, String day, String requestedMode, boolean external) {
+        if (REENTRY_MODE.equals(repairMode(requestedMode))) {
+            McException.assertAccessException(!external, "冲销后重录仅适用于已确认的外部人员");
+            McException.assertAccessException(!confirmedReentryDays(userId, day.substring(0, 6), day).contains(day),
+                    "来源日期没有仍有效的已确认冲销记录,不能重录 userId=" + userId + ", day=" + day);
+            return;
+        }
+        assertSourceDateAvailable(userId, day, external);
+    }
+
     private void assertSourceDateAvailable(String userId, String day, boolean external) {
-        // prd 外部来源日由补填记录保护;内部继续核实原表锁定。
-        if (external) return;
+        // fixme 普通外部补填同样核实正式来源日;重录资格由独立场景校验,不凭外部身份放开。
         List<Map> original = query(whConf.getFormUuidWorkHoursSummary(), mapOf(R_SUBMITTER, userId, R_DAY, day));
         boolean hasApprovedOrChangedHours = original.stream()
                 .anyMatch(TimeCardSummaryService::isFormalRecordLocked);

+ 112 - 17
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/TimeCardRepairServiceTest.java

@@ -108,7 +108,7 @@ class TimeCardRepairServiceTest {
         } finally {
             release.countDown();
         }
-        assertEquals("repair-only", awaitReadyContext(service, repairContextRequest()).get("summaryReadMode"));
+        assertEquals("original-and-repair", awaitReadyContext(service, repairContextRequest()).get("summaryReadMode"));
     }
 
     @Test
@@ -122,7 +122,7 @@ class TimeCardRepairServiceTest {
     }
 
     @Test
-    void shouldQueryOnlyRepairSummaryForConfirmedExternalUser() throws Exception {
+    void shouldQueryBothSummariesForConfirmedExternalUser() throws Exception {
         YDClient client = mock(YDClient.class);
         DDR_New<Object> response = new DDR_New<>();
         response.setData(Collections.emptyList());
@@ -133,12 +133,12 @@ class TimeCardRepairServiceTest {
         when(policy.getStaffType("user-1")).thenReturn("外部");
         ReflectionTestUtils.setField(service, "personnelPolicy", policy);
         Map<String, Object> context = awaitReadyContext(service, repairContextRequest());
-        assertEquals("repair-only", context.get("summaryReadMode"));
+        assertEquals("original-and-repair", context.get("summaryReadMode"));
         assertEquals("外部", context.get("staffType"));
         assertTrue(((List<?>) context.get("originalRecords")).isEmpty());
-        verify(client, never()).queryData(org.mockito.ArgumentMatchers.argThat(
+        verify(client, times(1)).queryData(org.mockito.ArgumentMatchers.argThat(
                 p -> "original-summary".equals(p.getFormUuid())), eq(YDConf.FORM_QUERY.retrieve_search_form));
-        verify(client, times(1)).queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form));
+        verify(client, times(3)).queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form));
     }
 
     @Test
@@ -156,13 +156,13 @@ class TimeCardRepairServiceTest {
         assertEquals("original-and-repair", awaitReadyContext(service, request).get("summaryReadMode"));
         when(policy.getStaffType("user-1")).thenReturn("外部");
         request.setStaffType("外部");
-        assertEquals("repair-only", awaitReadyContext(service, request).get("summaryReadMode"));
-        verify(client, times(1)).queryData(org.mockito.ArgumentMatchers.argThat(
+        assertEquals("original-and-repair", awaitReadyContext(service, request).get("summaryReadMode"));
+        verify(client, times(2)).queryData(org.mockito.ArgumentMatchers.argThat(
                 p -> "original-summary".equals(p.getFormUuid())), eq(YDConf.FORM_QUERY.retrieve_search_form));
     }
 
     @Test
-    void shouldKeepRepairDayLockedForExternalSaveWithoutReadingOriginal() {
+    void shouldKeepRepairDayLockedForExternalSaveAfterCheckingOriginal() {
         YDClient client = mock(YDClient.class);
         MonthClosingService closing = mock(MonthClosingService.class);
         when(closing.isLocked("202609")).thenReturn(true);
@@ -180,33 +180,39 @@ class TimeCardRepairServiceTest {
         fd.put("numberField_mmd1smem", 8d);
         DDR_New<Object> response = new DDR_New<>();
         response.setData(Collections.singletonList(Collections.singletonMap("formData", fd)));
-        when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))).thenReturn(response);
+        when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))).thenAnswer(invocation -> {
+            YDParam param = invocation.getArgument(0);
+            if ("repair-summary".equals(param.getFormUuid())) return response;
+            DDR_New<Object> original = new DDR_New<>();
+            original.setData(Collections.emptyList());
+            return original;
+        });
         TimeCardRepairSummaryUpsertRequest request = new TimeCardRepairSummaryUpsertRequest();
         request.setSubmitterUserId("user-1");
         request.setDayText("20260930");
         request.setFormData(new HashMap<>());
         assertTrue(assertThrows(McException.class, () -> service.upsert(request)).getMessage().contains("已提交或已审批"));
-        verify(client, never()).queryData(org.mockito.ArgumentMatchers.argThat(
+        verify(client, times(1)).queryData(org.mockito.ArgumentMatchers.argThat(
                 p -> "original-summary".equals(p.getFormUuid())), eq(YDConf.FORM_QUERY.retrieve_search_form));
     }
 
-    @Test
-    void shouldSkipOnlyOriginalSourceGuardForExternalSubmission() {
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void shouldCheckOriginalSourceGuardForEveryStaffType(boolean external) {
         YDClient client = mock(YDClient.class);
         TimeCardRepairService service = repairContextService(client, mock(MonthClosingService.class), repairContextConf());
-        ReflectionTestUtils.invokeMethod(service, "assertSourceDateAvailable", "user-1", "20260930", true);
-        verifyNoInteractions(client);
         Map<String, Object> fd = new HashMap<>();
         fd.put("numberField_mmd1smem", 8d);
         DDR_New<Object> response = new DDR_New<>();
         response.setData(Collections.singletonList(Collections.singletonMap("formData", fd)));
         when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))).thenReturn(response);
         assertThrows(McException.class, () -> ReflectionTestUtils.invokeMethod(
-                service, "assertSourceDateAvailable", "user-1", "20260930", false));
+                service, "assertSourceDateAvailable", "user-1", "20260930", external));
+        verify(client, times(1)).queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form));
     }
 
     @Test
-    void shouldCheckRepairRowsButNeverOriginalDuringExternalSubmitValidation() {
+    void shouldCheckBothTablesDuringExternalSubmitValidation() {
         YDClient client = mock(YDClient.class);
         DDR_New<Object> response = new DDR_New<>();
         response.setData(Collections.emptyList());
@@ -227,12 +233,99 @@ class TimeCardRepairServiceTest {
                 Collections.singletonMap("manager", group), Collections.emptyMap());
         assertEquals(1, count);
         verify(policy, times(1)).getStaffType("user-1");
-        verify(client, never()).queryData(org.mockito.ArgumentMatchers.argThat(
+        verify(client, times(1)).queryData(org.mockito.ArgumentMatchers.argThat(
                 p -> "original-summary".equals(p.getFormUuid())), eq(YDConf.FORM_QUERY.retrieve_search_form));
         verify(client, times(1)).queryData(org.mockito.ArgumentMatchers.argThat(
                 p -> "repair-summary".equals(p.getFormUuid())), eq(YDConf.FORM_QUERY.retrieve_search_form));
     }
 
+    @Test
+    void shouldSeparateNormalAndConfirmedReentryContextsAndKeepOriginalProtection() throws Exception {
+        YDClient client = mock(YDClient.class);
+        Map<String, Object> reversal = reversalRecord();
+        when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenAnswer(invocation -> {
+                    YDParam param = invocation.getArgument(0);
+                    Map<String, Object> data = "original-summary".equals(param.getFormUuid())
+                            ? Collections.singletonMap("numberField_mmd1smem", 8d) : reversal;
+                    DDR_New<Object> response = new DDR_New<>();
+                    response.setData(Collections.singletonList(Collections.singletonMap("formData", data)));
+                    return response;
+                });
+        TimeCardRepairService service = repairContextService(client, mock(MonthClosingService.class), repairContextConf());
+        TimeCardPersonnelPolicy policy = mock(TimeCardPersonnelPolicy.class);
+        when(policy.getStaffType("user-1")).thenReturn("外部");
+        ReflectionTestUtils.setField(service, "personnelPolicy", policy);
+        TimeCardRepairContextRequest request = repairContextRequest();
+        Map<String, Object> normal = awaitReadyContext(service, request);
+        assertEquals(1, ((List<?>) normal.get("originalRecords")).size());
+        request.setRepairMode("REENTRY");
+        Map<String, Object> reentry = awaitReadyContext(service, request);
+        assertEquals("repair-only", reentry.get("summaryReadMode"));
+        assertTrue(((List<?>) reentry.get("originalRecords")).isEmpty());
+        assertEquals(Collections.singletonList("20260930"), reentry.get("reentryAllowedDays"));
+        assertTrue(((List<?>) reentry.get("repairRecords")).isEmpty(), "冲销变更不能成为补填审批锁");
+        verify(client, times(1)).queryData(org.mockito.ArgumentMatchers.argThat(
+                p -> "original-summary".equals(p.getFormUuid())), eq(YDConf.FORM_QUERY.retrieve_search_form));
+        assertThrows(McException.class, () -> ReflectionTestUtils.invokeMethod(service,
+                "assertRepairSourceDateAvailable", "user-1", "20260930", "NORMAL", true));
+        ReflectionTestUtils.invokeMethod(service, "assertRepairSourceDateAvailable", "user-1", "20260930", "REENTRY", true);
+    }
+
+    @Test
+    void shouldRejectRolledBackUnapprovedOrForgedReentryBeforeWrites() {
+        for (String invalid : new String[]{"missing", "batch", "sync", "user", "day", "positive", "internal", "mode"}) {
+            YDClient client = mock(YDClient.class);
+            Map<String, Object> reversal = reversalRecord();
+            if ("batch".equals(invalid)) reversal.put("batch", "unconfirmed");
+            if ("sync".equals(invalid)) reversal.put("sync", "部分成功");
+            if ("user".equals(invalid)) reversal.put("textField_mmbffvd9", "other-user");
+            if ("day".equals(invalid)) reversal.put("textField_mmbffvdb", "20260929");
+            if ("positive".equals(invalid)) reversal.put("numberField_mmeakgi4", 8d);
+            DDR_New<Object> response = new DDR_New<>();
+            response.setData("missing".equals(invalid) ? Collections.emptyList()
+                    : Collections.singletonList(Collections.singletonMap("formData", reversal)));
+            when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))).thenReturn(response);
+            TimeCardRepairService service = repairContextService(client, mock(MonthClosingService.class), repairContextConf());
+            assertThrows(McException.class, () -> ReflectionTestUtils.invokeMethod(service,
+                    "assertRepairSourceDateAvailable", "user-1", "20260930", "mode".equals(invalid) ? "FAKE" : "REENTRY",
+                    !"internal".equals(invalid)), invalid);
+            verify(client, never()).operateData(any(YDParam.class), any());
+        }
+    }
+
+    @Test
+    void shouldBlockNormalExternalSaveAndAllSubmitCategoriesOnOriginalApprovedDay() {
+        for (String category : new String[]{"billableRows", "nonBillableRows", "otherRows"}) {
+            YDClient client = mock(YDClient.class);
+            DDR_New<Object> response = new DDR_New<>();
+            response.setData(Collections.singletonList(Collections.singletonMap("formData",
+                    Collections.singletonMap("numberField_mmeakgi4", 8d))));
+            when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))).thenReturn(response);
+            TimeCardRepairService service = repairContextService(client, mock(MonthClosingService.class), repairContextConf());
+            TimeCardPersonnelPolicy policy = mock(TimeCardPersonnelPolicy.class);
+            when(policy.getStaffType("user-1")).thenReturn("外部");
+            ReflectionTestUtils.setField(service, "personnelPolicy", policy);
+            TimeCardRepairSubmitRequest request = repairSubmitRequest();
+            request.setSourceMonthText("202609");
+            Map<String, Object> row = new HashMap<>();
+            row.put("dateStr", "20260930"); row.put("projectCode", "P"); row.put("hours", 8d);
+            Map<String, Object> group = Collections.singletonMap(category, Collections.singletonList(row));
+            assertThrows(McException.class, () -> ReflectionTestUtils.invokeMethod(service, "validateSubmitRows", request,
+                    "otherRows".equals(category) ? Collections.emptyMap() : Collections.singletonMap("manager", group),
+                    "otherRows".equals(category) ? group : Collections.emptyMap()));
+            verify(client, never()).operateData(any(YDParam.class), any());
+        }
+    }
+
+    private static Map<String, Object> reversalRecord() {
+        Map<String, Object> data = new HashMap<>();
+        data.put("fill-type", "变更"); data.put("batch", "10月调账-EXT-20261020"); data.put("sync", "全部成功");
+        data.put("textField_mmbffvd9", "user-1"); data.put("textField_mmbffvda", "202609");
+        data.put("textField_mmbffvdb", "20260930"); data.put("numberField_mmeakgi4", -8d);
+        return data;
+    }
+
     @Test
     void shouldReturnPendingImmediatelyThenExposeCompletedContext() throws Exception {
         YDClient client = mock(YDClient.class);
@@ -399,6 +492,8 @@ class TimeCardRepairServiceTest {
         conf.setFormUuidWorkHoursSummary("original-summary");
         conf.setFormUuidRepairWorkHoursSummary("repair-summary");
         conf.setRepairSummaryFillTypeField("fill-type");
+        conf.setRepairSummaryBatchField("batch");
+        conf.setRepairSummarySyncStatusField("sync");
         return conf;
     }
 

File diff suppressed because it is too large
+ 68 - 17
pages/src/timecard-repair-lifecycle.oyd.jsx


File diff suppressed because it is too large
+ 68 - 17
pages/src/timecard-repair.oyd.jsx


+ 4 - 3
pages/tests/verify-overtime.cjs

@@ -121,15 +121,16 @@ async function main() {
   const env={...scope,state:{isExternalStaff:true},getRepairMonthText:()=> '202609',
     queryAllTimecardRecords:async (...args)=>{calls.push(args[4]);return[];}};
   await env.loadTimecardData('external',2026,9);
-  assert.deepEqual(calls,['repair'],'外部原生回退不得查询原表');
+  assert.deepEqual(calls,['original','repair'],'外部漏填必须核对正式与补填两表');
   calls.length=0;
-  await env.loadTimecardData('external',2026,9,{staffType:'外部',originalRecords:[{formData:{}}],repairRecords:[]});
+  const original = await env.loadTimecardData('external',2026,9,{staffType:'外部',summaryReadMode:'original-and-repair',originalRecords:[{formData:{}}],repairRecords:[]});
   assert.equal(calls.length,0);
+  assert.equal(original.length,1,'外部已提交正式工时不得丢弃');
   env.state.isExternalStaff=false;
   await env.loadTimecardData('internal',2026,9);
   assert.deepEqual(calls,['original','repair']);
   const change={formData:{radioField_rtc81we6k:'变更'}};
   assert.equal(env.prepareTimecardRecords([], [change]).length,0);
-  console.log(JSON.stringify({status:'passed',outcomes,externalFallback:'repair-only',internalFallback:'two-tables'}));
+  console.log(JSON.stringify({status:'passed',outcomes,externalFallback:'two-tables',internalFallback:'two-tables'}));
 }
 main().catch(error=>{console.error(error);process.exitCode=1;});

+ 1 - 0
pages/tests/verify-repair-context.cjs

@@ -39,6 +39,7 @@ const methods = vm.runInNewContext(
 /** @param {*} response @returns {object} */
 function createContext(response) {
   return {
+    state: {},
     getTexts: () => ({ repairContextTimeout: '补填工时加载超时,请稍后重试', errUnknown: '未知错误' }),
     getRepairContextErrorMessage: methods.getRepairContextErrorMessage,
     isRepairContextRetryableError: methods.isRepairContextRetryableError,

+ 109 - 0
pages/tests/verify-repair-scenarios.cjs

@@ -0,0 +1,109 @@
+'use strict';
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+
+/** @param {string} source 源码 @param {string} name 方法名 @returns {string} 可执行方法。 */
+function extract(source, name) {
+  const match = new RegExp('export (?:async )?function ' + name + '\\([\\s\\S]*?\\n}').exec(source);
+  assert.ok(match, '缺少方法 ' + name);
+  return match[0].replace('export ', '');
+}
+
+/** @param {string} file 源文件 @returns {object} 隔离运行态。 */
+function library(file) {
+  const source = fs.readFileSync(file, 'utf8');
+  const names = ['getRepairMonthText', 'getDayApprovalLock', 'resolveHourValue', 'getOvertimeChangeAdjustment',
+    'getOvertimeDisplayTotals', 'resolveOvertimeRecord', 'buildOvertimeBaseline', 'applyDefaultWorkdayHours',
+    'processTimecardRecords', 'getAdjustmentSummary', 'collectApprovalData', 'collectOtherApprovalData',
+    'buildApprovalGroupKey', 'isRepairDayLocked', 'isRepairDayAllowed', 'loadTimecardData',
+    'prepareTimecardRecords', 'updateCellValue', 'buildTimecardRepairRequest'];
+  const scope = {};
+  vm.createContext(scope);
+  vm.runInContext(source.slice(0, source.indexOf('export function didMount'))
+    + names.map(name => extract(source, name)).join('\n'), scope);
+  return scope;
+}
+
+/** @param {object} lib 方法与字段 @param {string} mode 场景 @returns {object} 页面实例。 */
+function instance(lib, mode = 'NORMAL') {
+  const env = { ...lib, state: { selectedYear: 2026, selectedMonth: 8, userId: 'user',
+    isExternalStaff: true, repairMode: mode, userInfo: {staffType: '外部'}, reentryAllowedDays: [] },
+    isBeforeEmploymentDate: () => false,
+    getTexts: () => ({ repairContextIncomplete: 'incomplete', staffTypeCheckFailed: 'staff mismatch' }) };
+  env.setState = update => Object.assign(env.state, update);
+  return env;
+}
+
+/** @param {object} lib 字段映射 @param {number} day 日期 @param {number} hours 工时 @param {boolean} repair 补填 @param {boolean} approved 已审批 @returns {object} 测试实例。 */
+function record(lib, day, hours, repair = false, approved = true) {
+  const main = lib.TIME_FIELD_IDS;
+  const row = lib.BILLABLE_SUB;
+  const data = {};
+  data[main.dayText] = '202608' + String(day).padStart(2, '0');
+  data[main.dailyDraftTotal] = hours;
+  data[main.dailySubmittedTotal] = approved ? hours : 0;
+  data[main.dailyApprovedTotal] = approved ? hours : 0;
+  data[repair ? lib.REPAIR_APPROVAL_STATUS_FIELD : main.approvalStatus] = approved ? '已审批' : '暂存';
+  data[row.table] = [{ [row.projectCode]: 'P', [row.draft]: hours,
+    [row.submitted]: approved ? hours : 0, [row.approved]: approved ? hours : 0 }];
+  if (repair) data[lib.REPAIR_FILL_TYPE_FIELD] = '补填';
+  return { formInstId: (repair ? 'repair-' : 'original-') + day, formData: data };
+}
+
+/** @param {object} env 页面 @param {Array<object>} original 正式记录 @param {Array<object>} repair 补填记录 @returns {void} 完整加载处理链。 */
+function process(env, original, repair) {
+  env.processTimecardRecords(env.prepareTimecardRecords(original, repair), 2026, 8, {},
+    [{code: 'P', name: 'Project', managerId: 'manager'}], false, false);
+}
+
+/** @returns {Promise<void>} 验证真实问题形态与重录隔离。 */
+async function main() {
+  for (const file of ['timecard-repair.oyd.jsx', 'timecard-repair-lifecycle.oyd.jsx']) {
+    const lib = library(path.resolve(__dirname, '../src', file));
+    const days = [3,4,5,6,7,8,10,11,12,13,14,17,18,19,20,21,22,24,25,26,27,28,29];
+    const original = days.map(day => record(lib, day, day === 8 ? 4 : [6,7,11,12,13,18].includes(day) ? 10 : 8));
+    const drafts = days.filter(day => new Date(2026,7,day).getDay() % 6 !== 0).map(day => record(lib, day, 0, true, false));
+    const env = instance(lib);
+    process(env, original, drafts);
+    const summary = env.getAdjustmentSummary();
+    assert.equal(summary.baseline, 192);
+    assert.equal(summary.delta, 8);
+    assert.equal(summary.total, 200);
+    for (const day of days) {
+      assert.equal(env.state.cellStatusMap['billable_0_' + day], 'approved');
+      assert.equal(env.state.cellStatusMap['nonBillable_0_' + day], 'approved');
+      assert.equal(env.state.cellStatusMap['other_0_' + day], 'approved');
+      const before = env.state.billableData[0][day];
+      env.updateCellValue('billable', 0, day, 99);
+      assert.equal(env.state.billableData[0][day], before);
+    }
+    let groups = env.collectApprovalData();
+    let rows = Object.values(groups).flatMap(group => group.billableRows);
+    assert.deepEqual(Array.from(rows, row => row.dateStr), ['20260831']);
+    const request = await env.buildTimecardRepairRequest();
+    assert.equal(request.repairMode, 'NORMAL');
+    process(env, original, drafts.concat([record(lib,31,8,true)]));
+    assert.equal(env.isRepairDayLocked(31), true);
+    assert.equal(Object.keys(env.collectApprovalData()).length, 0, '31日已审批补填不可再次提交');
+
+    const reentry = instance(lib, 'REENTRY');
+    reentry.state.reentryAllowedDays = ['20260803'];
+    process(reentry, original, []);
+    assert.equal(reentry.getAdjustmentSummary().baseline, 0, '重录不回带已冲销原审批基线');
+    assert.equal(reentry.state.billableData[0][3], 8);
+    assert.equal(Object.keys(reentry.state.billableData[0]).length, 1, '范围外日期不得默认8h');
+    assert.equal(reentry.isRepairDayLocked(4), true);
+    rows = Object.values(reentry.collectApprovalData()).flatMap(group => group.billableRows);
+    assert.deepEqual(Array.from(rows, row => row.dateStr), ['20260803']);
+    await assert.rejects(reentry.loadTimecardData('user',2026,8), /incomplete/, '重录不能原生回退绕过资格');
+    await assert.rejects(env.loadTimecardData('user',2026,8,
+      {staffType:'外部',repairMode:'REENTRY',summaryReadMode:'repair-only',originalRecords:[],repairRecords:[]}), /incomplete/);
+    const change = record(lib,4,-8,true);
+    change.formData[lib.REPAIR_FILL_TYPE_FIELD] = '变更';
+    assert.equal(env.prepareTimecardRecords([], [change]).length,0);
+  }
+  console.log('PASS 双补填入口:192h基线、零草稿覆盖保护、只提交31日8h、已审批补填锁定、重录范围及模式隔离');
+}
+main().catch(error => { console.error(error); process.exitCode = 1; });