Bladeren bron

fix(timecard): stabilize closing queries and adjustment pages

malk 2 weken geleden
bovenliggende
commit
9854738d1d

+ 160 - 14
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/MonthClosingService.java

@@ -15,22 +15,30 @@ import org.apache.commons.lang3.StringUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
-import java.time.YearMonth;
 import java.time.Instant;
+import java.time.YearMonth;
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeParseException;
 import java.time.format.ResolverStyle;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.HashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.FutureTask;
+import java.util.function.LongSupplier;
 
 /**
  * 月度封账统一校验服务。
  *
- * <p>TimeCard 与封账流程提交前均调用本服务,避免页面各自维护不同口径。</p>
+ * <p>TimeCard、补填、变更与封账流程均调用本服务,避免各业务维护不同口径。</p>
  */
 @Service
 public class MonthClosingService {
@@ -44,10 +52,17 @@ public class MonthClosingService {
     private static final String WRITE_BACK_SUCCESS = "全部成功";
     private static final String STATUS_CLOSED = "封账";
     private static final String STATUS_REOPENED = "启封";
+    private static final long CLOSING_STATUS_CACHE_MILLIS = 5 * 60 * 1000L;
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
     private static final DateTimeFormatter MONTH_FORMATTER =
             DateTimeFormatter.ofPattern("uuuuMM").withResolverStyle(ResolverStyle.STRICT);
 
+    private final ConcurrentMap<String, FutureTask<MonthClosingStatusResult>> closingStatusRequests =
+            new ConcurrentHashMap<>();
+    private final ConcurrentMap<String, CachedClosingStatus> closingStatusCache =
+            new ConcurrentHashMap<>();
+    private LongSupplier currentTimeMillis = System::currentTimeMillis;
+
     @Autowired
     private YDClient ydClient;
 
@@ -65,11 +80,39 @@ public class MonthClosingService {
      */
     public MonthClosingStatusResult status(MonthClosingCheckRequest request) {
         String monthText = validateRequest(request);
-        return queryClosingStatus(monthText);
+        return queryClosingStatusCached(monthText);
+    }
+
+    /**
+     * 一次读取全部封账流程,预热实际封账月份、当前月和上月的五分钟缓存。
+     *
+     * @return 本次写入缓存的月份数量
+     */
+    public int warmUpClosingStatusCache() {
+        List<Map> processes = queryProcesses(closingConf.getFormUuid(), null, null, null);
+        Set<String> months = new LinkedHashSet<>();
+        for (Map item : processes) {
+            String monthText = monthFromDateValue(
+                    extractFormData(item).get(closingConf.getMonthDateField()));
+            if (StringUtils.isNotBlank(monthText)) {
+                months.add(monthText);
+            }
+        }
+        YearMonth currentMonth = YearMonth.now(BUSINESS_ZONE);
+        months.add(currentMonth.format(MONTH_FORMATTER));
+        months.add(currentMonth.minusMonths(1).format(MONTH_FORMATTER));
+
+        long expiresAt = currentTimeMillis.getAsLong() + CLOSING_STATUS_CACHE_MILLIS;
+        for (String monthText : months) {
+            closingStatusCache.put(monthText, new CachedClosingStatus(
+                    summarizeClosingStatus(
+                            monthText, filterClosingMonth(processes, monthText)), expiresAt));
+        }
+        return months.size();
     }
 
     /**
-     * 实时查询目标月份是否已封账;每次调用均回读宜搭,不缓存状态。
+     * 查询目标月份是否已封账;同月查询结果在五分钟内由后端复用。
      *
      * @param monthText 月份,格式 yyyyMM
      * @return true 表示最新已审批动作是封账
@@ -102,7 +145,7 @@ public class MonthClosingService {
     public MonthClosingValidationResult validate(MonthClosingCheckRequest request) {
         String monthText = validateRequest(request);
         String requestedStatus = validateClosingStatus(request.getClosingStatus());
-        MonthClosingStatusResult closingStatus = queryClosingStatus(monthText);
+        MonthClosingStatusResult closingStatus = queryClosingStatusCached(monthText);
 
         if (STATUS_REOPENED.equals(requestedStatus)) {
             return validateReopening(monthText, closingStatus);
@@ -227,16 +270,103 @@ public class MonthClosingService {
                 .build();
     }
 
+    private MonthClosingStatusResult queryClosingStatusCached(String monthText) {
+        MonthClosingStatusResult cached = getCachedClosingStatus(monthText);
+        if (cached != null) {
+            return cached;
+        }
+        return queryClosingStatusCoalesced(monthText);
+    }
+
+    private MonthClosingStatusResult queryClosingStatusCoalesced(String monthText) {
+        // fixme 缓存未命中时,同月并发请求共享一次宜搭查询。
+        FutureTask<MonthClosingStatusResult> newRequest = new FutureTask<>(() -> {
+            MonthClosingStatusResult cached = getCachedClosingStatus(monthText);
+            if (cached != null) {
+                return cached;
+            }
+            MonthClosingStatusResult result = queryClosingStatus(monthText);
+            closingStatusCache.put(monthText, new CachedClosingStatus(
+                    result, currentTimeMillis.getAsLong() + CLOSING_STATUS_CACHE_MILLIS));
+            return result;
+        });
+        FutureTask<MonthClosingStatusResult> activeRequest =
+                closingStatusRequests.putIfAbsent(monthText, newRequest);
+        boolean ownsRequest = activeRequest == null;
+        if (ownsRequest) {
+            activeRequest = newRequest;
+            activeRequest.run();
+        }
+        try {
+            return activeRequest.get();
+        } catch (InterruptedException ex) {
+            Thread.currentThread().interrupt();
+            throw new IllegalStateException("月度封账状态查询被中断", ex);
+        } catch (ExecutionException ex) {
+            Throwable cause = ex.getCause();
+            if (cause instanceof RuntimeException) {
+                throw (RuntimeException) cause;
+            }
+            if (cause instanceof Error) {
+                throw (Error) cause;
+            }
+            throw new IllegalStateException("月度封账状态查询失败", cause);
+        } finally {
+            if (ownsRequest) {
+                closingStatusRequests.remove(monthText, newRequest);
+            }
+        }
+    }
+
+    private MonthClosingStatusResult getCachedClosingStatus(String monthText) {
+        while (true) {
+            CachedClosingStatus cached = closingStatusCache.get(monthText);
+            if (cached == null) {
+                return null;
+            }
+            if (currentTimeMillis.getAsLong() < cached.expiresAtMillis) {
+                return cached.result;
+            }
+            if (closingStatusCache.remove(monthText, cached)) {
+                return null;
+            }
+        }
+    }
+
+    private static final class CachedClosingStatus {
+
+        private final MonthClosingStatusResult result;
+        private final long expiresAtMillis;
+
+        private CachedClosingStatus(MonthClosingStatusResult result, long expiresAtMillis) {
+            this.result = result;
+            this.expiresAtMillis = expiresAtMillis;
+        }
+    }
+
     private MonthClosingStatusResult queryClosingStatus(String monthText) {
-        List<Map> completed = filterClosingMonth(
-                queryProcesses(closingConf.getFormUuid(), null, null, "COMPLETED"), monthText);
-        List<Map> running = filterClosingMonth(
-                queryProcesses(closingConf.getFormUuid(), null, null, "RUNNING"), monthText);
+        List<Long> monthRange = closingMonthRange(monthText);
+        List<Map> processes = filterClosingMonth(
+                queryProcesses(closingConf.getFormUuid(), closingConf.getMonthDateField(),
+                        monthRange, null), monthText);
+        return summarizeClosingStatus(monthText, processes);
+    }
+
+    private MonthClosingStatusResult summarizeClosingStatus(String monthText, List<Map> processes) {
         int approvedClosingCount = 0;
         int approvedReopeningCount = 0;
+        int runningClosingCount = 0;
         String currentStatus = "";
         Long latestTime = null;
-        for (Map item : completed) {
+        for (Map item : processes) {
+            String instanceStatus = processInstanceStatus(item);
+            if ("RUNNING".equals(instanceStatus)) {
+                runningClosingCount++;
+                continue;
+            }
+            if (!"COMPLETED".equals(instanceStatus)) {
+                continue;
+            }
             if (!isApproved(item)) {
                 continue;
             }
@@ -262,7 +392,7 @@ public class MonthClosingService {
                 .currentStatus(currentStatus)
                 .approvedClosingCount(approvedClosingCount)
                 .approvedReopeningCount(approvedReopeningCount)
-                .runningClosingCount(running.size())
+                .runningClosingCount(runningClosingCount)
                 .build();
     }
 
@@ -331,14 +461,14 @@ public class MonthClosingService {
     private List<Map> queryProcesses(
             String formUuid,
             String monthField,
-            String monthText,
+            Object monthValue,
             String instanceStatus) {
         McException.assertAccessException(
                 StringUtils.isBlank(formUuid),
                 "月度封账流程查询配置不完整");
         Map<String, Object> search = new HashMap<>();
-        if (StringUtils.isNotBlank(monthField) && StringUtils.isNotBlank(monthText)) {
-            search.put(monthField, monthText);
+        if (StringUtils.isNotBlank(monthField) && monthValue != null) {
+            search.put(monthField, monthValue);
         }
         List<Map> result = new ArrayList<>();
         int page = 1;
@@ -441,6 +571,14 @@ public class MonthClosingService {
         return value;
     }
 
+    static List<Long> closingMonthRange(String monthText) {
+        YearMonth month = YearMonth.parse(validateMonthText(monthText), MONTH_FORMATTER);
+        long start = month.atDay(1).atStartOfDay(BUSINESS_ZONE).toInstant().toEpochMilli();
+        long end = month.plusMonths(1).atDay(1).atStartOfDay(BUSINESS_ZONE)
+                .toInstant().toEpochMilli() - 1L;
+        return Arrays.asList(start, end);
+    }
+
     static String monthFromDateValue(Object value) {
         if (value == null) {
             return "";
@@ -563,6 +701,14 @@ public class MonthClosingService {
         return "agree".equals(text(item.get("approvedResult")).toLowerCase(Locale.ROOT));
     }
 
+    private static String processInstanceStatus(Map item) {
+        String status = text(item == null ? null : item.get("instanceStatus"));
+        if (StringUtils.isBlank(status)) {
+            status = text(item == null ? null : item.get("processInstanceStatus"));
+        }
+        return status.toUpperCase(Locale.ROOT);
+    }
+
     private static String buildMessage(
             String monthText,
             String requestedStatus,

+ 108 - 9
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/TimeCardRepairService.java

@@ -25,7 +25,12 @@ import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.time.YearMonth;
 import java.util.*;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicInteger;
 
 /** 补填 TimeCard 专用服务;不复用正常汇总表写入目标。 */
 @Slf4j
@@ -50,7 +55,18 @@ public class TimeCardRepairService {
     private static final String R_DAILY_APPROVED = "numberField_mmeakgi4";
     private static final String R_DAILY_CHANGE = "numberField_mmeakgi5";
     private static final long SUBMIT_GUARD_TTL_MILLIS = 30_000L;
+    private static final long CONTEXT_CACHE_TTL_MILLIS = 60_000L;
     private static final ConcurrentHashMap<String, SubmitGuard> SUBMIT_GUARDS = new ConcurrentHashMap<>();
+    private static final AtomicInteger CONTEXT_THREAD_SEQUENCE = new AtomicInteger();
+    private static final ExecutorService CONTEXT_QUERY_EXECUTOR = Executors.newFixedThreadPool(8, runnable -> {
+        Thread thread = new Thread(runnable,
+                "timecard-repair-context-" + CONTEXT_THREAD_SEQUENCE.incrementAndGet());
+        thread.setDaemon(true);
+        return thread;
+    });
+    private final ConcurrentHashMap<String, CompletableFuture<Map<String, Object>>> contextRequests =
+            new ConcurrentHashMap<>();
+    private final ConcurrentHashMap<String, ContextCacheEntry> contextCache = new ConcurrentHashMap<>();
     private static final List<String> R_TOTAL_FIELDS = Arrays.asList(
             "numberField_mmacxew9", "numberField_mmacxewa", "numberField_mmacxewg", "numberField_mmad6jbc",
             "numberField_mmad6jbx", "numberField_mmad6jbz", "numberField_mmad6jc1", "numberField_mmeakgia",
@@ -65,19 +81,90 @@ public class TimeCardRepairService {
     public Map<String, Object> context(TimeCardRepairContextRequest request) {
         validateMonthRequest(request == null ? null : request.getSubmitterUserId(),
                 request == null ? null : request.getSourceMonthText());
+        String userId = request.getSubmitterUserId();
+        String monthText = request.getSourceMonthText();
+        String requestKey = userId + "|" + monthText;
+        Map<String, Object> cachedResult = getCachedContext(requestKey);
+        if (cachedResult != null) {
+            CompletableFuture<Map<String, Object>> completedRequest = contextRequests.get(requestKey);
+            if (completedRequest != null && completedRequest.isDone()) {
+                contextRequests.remove(requestKey, completedRequest);
+            }
+            return cachedResult;
+        }
+        CompletableFuture<Map<String, Object>> contextFuture = contextRequests.computeIfAbsent(
+                requestKey, ignored -> loadContextAsync(requestKey, userId, monthText));
+        if (!contextFuture.isDone()) return pendingContext();
+        contextRequests.remove(requestKey, contextFuture);
+        return completedContext(contextFuture, userId, monthText);
+    }
+
+    private CompletableFuture<Map<String, Object>> loadContextAsync(String requestKey,
+                                                                     String userId,
+                                                                     String monthText) {
+        CompletableFuture<List<Map>> originalFuture = 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);
+        return originalFuture.thenCombine(repairFuture, (originalRecords, repairRecords) -> {
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("ready", true);
+            result.put("originalRecords", compactContextRecords(originalRecords));
+            result.put("repairRecords", compactContextRecords(repairRecords));
+            // 兼容页面状态合并接口的 records 约定;原始双表结果仍通过 repairRecords 返回。
+            result.put("records", compactContextRecords(repairRecords));
+            contextCache.put(requestKey,
+                    new ContextCacheEntry(result, System.currentTimeMillis() + CONTEXT_CACHE_TTL_MILLIS));
+            return result;
+        });
+    }
+
+    private Map<String, Object> completedContext(CompletableFuture<Map<String, Object>> contextFuture,
+                                                  String userId,
+                                                  String monthText) {
+        try {
+            return contextFuture.join();
+        } catch (CompletionException error) {
+            Throwable cause = error.getCause();
+            if (cause instanceof RuntimeException) throw (RuntimeException) cause;
+            log.error("补填上下文查询失败: userId={}, month={}", userId, monthText, cause);
+            throw new McException("REPAIR_CONTEXT_QUERY_FAILED", "补填工时上下文查询失败");
+        }
+    }
+
+    private static Map<String, Object> pendingContext() {
         Map<String, Object> result = new LinkedHashMap<>();
-        // prd 补填来源月的封账准入:封账审批中与已通过均视为已封账。
-        result.put("closed", monthClosingService.isLocked(request.getSourceMonthText()));
-        result.put("originalRecords", query(whConf.getFormUuidWorkHoursSummary(),
-                mapOf("textField_mmbffvd9", request.getSubmitterUserId(), "textField_mmbffvda", request.getSourceMonthText())));
-        List<Map> repairRecords = queryRepairSummaries(
-                mapOf(R_SUBMITTER, request.getSubmitterUserId(), R_MONTH, request.getSourceMonthText()));
-        result.put("repairRecords", repairRecords);
-        // 兼容页面状态合并接口的 records 约定;原始双表结果仍通过 repairRecords 返回。
-        result.put("records", repairRecords);
+        result.put("ready", false);
+        result.put("retryAfterMillis", 2_000L);
         return result;
     }
 
+    private Map<String, Object> getCachedContext(String requestKey) {
+        long now = System.currentTimeMillis();
+        ContextCacheEntry cached = contextCache.get(requestKey);
+        if (cached == null) return null;
+        if (cached.expiresAt > now) return cached.result;
+        contextCache.remove(requestKey, cached);
+        return null;
+    }
+
+    private void invalidateContext(String userId, String monthText) {
+        contextCache.remove(userId + "|" + monthText);
+    }
+
+    private static List<Map<String, Object>> compactContextRecords(List<Map> records) {
+        List<Map<String, Object>> compact = new ArrayList<>();
+        for (Map record : records) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("formInstId", instanceId(record));
+            item.put("formData", formData(record));
+            compact.add(item);
+        }
+        return compact;
+    }
+
     public boolean upsert(TimeCardRepairSummaryUpsertRequest request) {
         McException.assertAccessException(request == null, "请求不能为空");
         McException.assertAccessException(StringUtils.isBlank(request.getDayText())
@@ -130,6 +217,7 @@ public class TimeCardRepairService {
             data.put(whConf.getRepairSummarySourceMonthField(), monthText);
         }
         saveRepairDraft(existing, data, request.getSubmitterUserId(), request.getDayText());
+        invalidateContext(request.getSubmitterUserId(), monthText);
         verifyRepairDraftDate(request.getSubmitterUserId(), request.getDayText(), data);
         return true;
     }
@@ -172,6 +260,7 @@ public class TimeCardRepairService {
             long submitDate = submitDay.atStartOfDay(ZONE).toInstant().toEpochMilli();
             int rowCount = validateSubmitRows(request, projectGroups, other);
             McException.assertAccessException(rowCount == 0, "没有需要提交的补填工时");
+            invalidateContext(request.getSubmitterUserId(), request.getSourceMonthText());
             for (Object rawGroup : projectGroups.values()) {
                 if (!(rawGroup instanceof Map)) continue;
                 Map group = (Map) rawGroup;
@@ -247,6 +336,16 @@ public class TimeCardRepairService {
         }
     }
 
+    private static final class ContextCacheEntry {
+        private final Map<String, Object> result;
+        private final long expiresAt;
+
+        private ContextCacheEntry(Map<String, Object> result, long expiresAt) {
+            this.result = result;
+            this.expiresAt = expiresAt;
+        }
+    }
+
     /**
      * 补填审批补发起:只扫描近期修改的补填汇总,按员工、月份和补填提交日期补齐缺失明细。
      * 正常 TimeCard 补发逻辑仍由 ReSubmitApprovalService 独立处理。

+ 52 - 0
mjava-akdsbeisen/src/main/java/com/malk/timer/MonthClosingCacheWarmupTimer.java

@@ -0,0 +1,52 @@
+package com.malk.timer;
+
+import com.malk.service.workhours.MonthClosingService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Configuration;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.PreDestroy;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+/** 定时预热月度封账状态缓存,避免页面首次查询承担宜搭冷调用耗时。 */
+@Slf4j
+@Configuration
+@ConditionalOnProperty(prefix = "enable", name = "scheduling", havingValue = "true", matchIfMissing = false)
+public class MonthClosingCacheWarmupTimer {
+
+    @Autowired
+    private MonthClosingService monthClosingService;
+
+    private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(
+            runnable -> {
+                Thread thread = new Thread(runnable, "month-closing-cache-1");
+                thread.setDaemon(true);
+                return thread;
+            });
+
+    /** 使用独立线程启动预热,避免被其他长耗时定时任务阻塞。 */
+    @PostConstruct
+    public void start() {
+        scheduler.scheduleWithFixedDelay(
+                this::warmUpClosingStatusCache, 5L, 3L * 60L, TimeUnit.SECONDS);
+    }
+
+    /** 容器停止时终止后续预热。 */
+    @PreDestroy
+    public void stop() {
+        scheduler.shutdownNow();
+    }
+
+    public void warmUpClosingStatusCache() {
+        try {
+            int monthCount = monthClosingService.warmUpClosingStatusCache();
+            log.info("[月度封账缓存] 预热完成 months={}", monthCount);
+        } catch (Exception ex) {
+            log.error("[月度封账缓存] 预热失败,保留现有缓存并等待下次调度", ex);
+        }
+    }
+}

+ 149 - 8
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/MonthClosingServiceTest.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;
@@ -20,14 +21,21 @@ import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.LongSupplier;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 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.times;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 
@@ -97,11 +105,11 @@ class MonthClosingServiceTest {
         when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process)))
                 .thenAnswer(invocation -> {
                     YDParam param = invocation.getArgument(0);
-                    if (CLOSING_FORM.equals(param.getFormUuid())
-                            && "RUNNING".equals(param.getInstanceStatus())) {
+                    if (CLOSING_FORM.equals(param.getFormUuid())) {
                         Map<String, Object> data = new HashMap<>();
                         data.put(CLOSING_MONTH_FIELD, "2026-09-01");
                         Map<String, Object> item = new HashMap<>();
+                        item.put("instanceStatus", "RUNNING");
                         item.put("formData", data);
                         return response(Collections.singletonList(item));
                     }
@@ -119,12 +127,12 @@ class MonthClosingServiceTest {
         when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process)))
                 .thenAnswer(invocation -> {
                     YDParam param = invocation.getArgument(0);
-                    if (CLOSING_FORM.equals(param.getFormUuid())
-                            && "COMPLETED".equals(param.getInstanceStatus())) {
+                    if (CLOSING_FORM.equals(param.getFormUuid())) {
                         Map<String, Object> formData = new HashMap<>();
                         formData.put(CLOSING_MONTH_FIELD, "2026-08-01");
                         formData.put(CLOSING_STATUS_FIELD, "封账");
                         Map<String, Object> item = new HashMap<>();
+                        item.put("instanceStatus", "COMPLETED");
                         item.put("approvedResult", "agree");
                         item.put("modifiedTimeGMT", 1_777_590_000_000L);
                         item.put("formData", formData);
@@ -147,8 +155,7 @@ class MonthClosingServiceTest {
         when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process)))
                 .thenAnswer(invocation -> {
                     YDParam param = invocation.getArgument(0);
-                    if (CLOSING_FORM.equals(param.getFormUuid())
-                            && "COMPLETED".equals(param.getInstanceStatus())) {
+                    if (CLOSING_FORM.equals(param.getFormUuid())) {
                         return response(Arrays.asList(
                                 closingAction("启封", 1_777_676_400_000L),
                                 closingAction("封账", 1_777_590_000_000L)));
@@ -171,8 +178,7 @@ class MonthClosingServiceTest {
         when(ydClient.queryData(any(YDParam.class), any(YDConf.FORM_QUERY.class)))
                 .thenAnswer(invocation -> {
                     YDParam param = invocation.getArgument(0);
-                    if (CLOSING_FORM.equals(param.getFormUuid())
-                            && "COMPLETED".equals(param.getInstanceStatus())) {
+                    if (CLOSING_FORM.equals(param.getFormUuid())) {
                         return response(Collections.singletonList(
                                 closingAction("封账", 1_777_590_000_000L)));
                     }
@@ -209,6 +215,140 @@ class MonthClosingServiceTest {
                         java.time.Instant.parse("2026-08-01T00:00:00Z").toEpochMilli()));
     }
 
+    @Test
+    void shouldQueryClosingProcessesWithTargetMonthDateRange() {
+        YDClient ydClient = mock(YDClient.class);
+        List<YDParam> closingQueries = new ArrayList<>();
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process)))
+                .thenAnswer(invocation -> {
+                    YDParam param = invocation.getArgument(0);
+                    if (CLOSING_FORM.equals(param.getFormUuid())) {
+                        closingQueries.add(param);
+                    }
+                    return response(Collections.emptyList());
+                });
+        MonthClosingService service = service(ydClient);
+
+        service.status(request("202608"));
+
+        List<Long> expectedRange = MonthClosingService.closingMonthRange("202608");
+        assertEquals(1, closingQueries.size());
+        for (YDParam query : closingQueries) {
+            assertNull(query.getInstanceStatus());
+            Map<String, Object> search = JSON.parseObject(query.getSearchFieldJson(), Map.class);
+            List<?> actualRange = (List<?>) search.get(CLOSING_MONTH_FIELD);
+            assertEquals(expectedRange.get(0).longValue(), ((Number) actualRange.get(0)).longValue());
+            assertEquals(expectedRange.get(1).longValue(), ((Number) actualRange.get(1)).longValue());
+        }
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    void shouldReuseInFlightClosingStatusRequestForSameMonth() {
+        YDClient ydClient = mock(YDClient.class);
+        MonthClosingService service = service(ydClient);
+        MonthClosingStatusResult sharedResult = MonthClosingStatusResult.builder()
+                .monthText("202608")
+                .closed(false)
+                .runningClosingCount(0)
+                .build();
+        FutureTask<MonthClosingStatusResult> activeRequest =
+                new FutureTask<>(() -> sharedResult);
+        activeRequest.run();
+        ConcurrentMap<String, FutureTask<MonthClosingStatusResult>> requests =
+                (ConcurrentMap<String, FutureTask<MonthClosingStatusResult>>)
+                        ReflectionTestUtils.getField(service, "closingStatusRequests");
+        requests.put("202608", activeRequest);
+
+        MonthClosingStatusResult result = service.status(request("202608"));
+
+        assertEquals("202608", result.getMonthText());
+        assertFalse(result.isClosed());
+        verifyNoInteractions(ydClient);
+    }
+
+    @Test
+    void shouldReuseSuccessfulClosingStatusResultForFiveMinutes() {
+        YDClient ydClient = mock(YDClient.class);
+        when(ydClient.queryData(any(YDParam.class), any(YDConf.FORM_QUERY.class)))
+                .thenReturn(response(Collections.emptyList()));
+        MonthClosingService service = service(ydClient);
+
+        service.status(request("202608"));
+        service.isLocked("202608");
+
+        verify(ydClient, times(1))
+                .queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process));
+    }
+
+    @Test
+    void shouldWarmExistingClosingMonthsWithSingleQuery() {
+        YDClient ydClient = mock(YDClient.class);
+        Map<String, Object> augustData = new HashMap<>();
+        augustData.put(CLOSING_MONTH_FIELD, "2026-08-01");
+        augustData.put(CLOSING_STATUS_FIELD, "封账");
+        Map<String, Object> august = new HashMap<>();
+        august.put("instanceStatus", "COMPLETED");
+        august.put("approvedResult", "agree");
+        august.put("modifiedTimeGMT", 1_777_590_000_000L);
+        august.put("formData", augustData);
+
+        Map<String, Object> septemberData = new HashMap<>();
+        septemberData.put(CLOSING_MONTH_FIELD, "2026-09-01");
+        Map<String, Object> september = new HashMap<>();
+        september.put("instanceStatus", "RUNNING");
+        september.put("formData", septemberData);
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process)))
+                .thenReturn(response(Arrays.asList(august, september)));
+        MonthClosingService service = service(ydClient);
+
+        int warmedMonths = service.warmUpClosingStatusCache();
+        MonthClosingStatusResult augustStatus = service.status(request("202608"));
+        MonthClosingStatusResult septemberStatus = service.status(request("202609"));
+
+        assertTrue(warmedMonths >= 2);
+        assertTrue(augustStatus.isClosed());
+        assertEquals(0, augustStatus.getRunningClosingCount());
+        assertFalse(septemberStatus.isClosed());
+        assertEquals(1, septemberStatus.getRunningClosingCount());
+        verify(ydClient, times(1))
+                .queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process));
+    }
+
+    @Test
+    void shouldRefreshClosingStatusAfterFiveMinutes() {
+        YDClient ydClient = mock(YDClient.class);
+        when(ydClient.queryData(any(YDParam.class), any(YDConf.FORM_QUERY.class)))
+                .thenReturn(response(Collections.emptyList()));
+        MonthClosingService service = service(ydClient);
+        AtomicLong now = new AtomicLong(1_000L);
+        ReflectionTestUtils.setField(
+                service, "currentTimeMillis", (LongSupplier) now::get);
+
+        service.status(request("202608"));
+        now.addAndGet(5 * 60 * 1000L + 1L);
+        service.status(request("202608"));
+
+        verify(ydClient, times(2))
+                .queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process));
+    }
+
+    @Test
+    void shouldNotCacheFailedClosingStatusQuery() {
+        YDClient ydClient = mock(YDClient.class);
+        when(ydClient.queryData(any(YDParam.class), any(YDConf.FORM_QUERY.class)))
+                .thenThrow(new IllegalStateException("query failed"))
+                .thenReturn(response(Collections.emptyList()));
+        MonthClosingService service = service(ydClient);
+
+        assertThrows(IllegalStateException.class, () -> service.status(request("202608")));
+        MonthClosingStatusResult result = service.status(request("202608"));
+
+        assertFalse(result.isClosed());
+        verify(ydClient, times(2))
+                .queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process));
+    }
+
     @Test
     void shouldBlockCompletedApprovalWhoseWriteBackIsNotSuccessful() {
         YDClient ydClient = mock(YDClient.class);
@@ -465,6 +605,7 @@ class MonthClosingServiceTest {
         formData.put(CLOSING_MONTH_FIELD, "2026-08-01");
         formData.put(CLOSING_STATUS_FIELD, status);
         Map<String, Object> item = new HashMap<>();
+        item.put("instanceStatus", "COMPLETED");
         item.put("approvedResult", "agree");
         item.put("modifiedTimeGMT", modifiedTime);
         item.put("formData", formData);

+ 179 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/TimeCardRepairServiceTest.java

@@ -8,6 +8,7 @@ import com.malk.server.dingtalk.DDR_New;
 import com.malk.server.workhours.MonthClosingCheckRequest;
 import com.malk.server.workhours.MonthClosingStatusResult;
 import com.malk.server.workhours.TimeCardApprovalStartRequest;
+import com.malk.server.workhours.TimeCardRepairContextRequest;
 import com.malk.server.workhours.TimeCardRepairSubmitRequest;
 import com.malk.server.workhours.TimeCardRepairSummaryUpsertRequest;
 import com.malk.server.workhours.WHConf;
@@ -24,7 +25,10 @@ import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -42,6 +46,181 @@ import static org.mockito.Mockito.when;
 
 class TimeCardRepairServiceTest {
 
+    @Test
+    void shouldReturnPendingImmediatelyThenExposeCompletedContext() throws Exception {
+        YDClient client = mock(YDClient.class);
+        MonthClosingService closingService = mock(MonthClosingService.class);
+        WHConf conf = repairContextConf();
+        CountDownLatch queriesStarted = new CountDownLatch(2);
+        CountDownLatch releaseQueries = new CountDownLatch(1);
+        when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenAnswer(invocation -> {
+                    YDParam param = invocation.getArgument(0);
+                    queriesStarted.countDown();
+                    if (!releaseQueries.await(2, TimeUnit.SECONDS)) {
+                        throw new IllegalStateException("parallel context queries did not start");
+                    }
+                    Map<String, Object> formData = new HashMap<>();
+                    if ("repair-summary".equals(param.getFormUuid())) formData.put("fill-type", "补填");
+                    Map<String, Object> record = new HashMap<>();
+                    record.put("formInstId", param.getFormUuid() + "-instance");
+                    record.put("formData", formData);
+                    record.put("instValue", "redundant payload");
+                    DDR_New<Object> response = new DDR_New<>();
+                    response.setData(Collections.singletonList(record));
+                    return response;
+                });
+        TimeCardRepairService service = repairContextService(client, closingService, conf);
+        TimeCardRepairContextRequest request = repairContextRequest();
+
+        Map<String, Object> first = service.context(request);
+        assertEquals(false, first.get("ready"));
+        assertEquals(2_000L, first.get("retryAfterMillis"));
+        assertTrue(queriesStarted.await(2, TimeUnit.SECONDS));
+        Map<String, Object> second = service.context(request);
+        assertEquals(false, second.get("ready"));
+        releaseQueries.countDown();
+
+        Map<String, Object> completed = awaitReadyContext(service, request);
+        assertEquals(true, completed.get("ready"));
+        assertEquals(1, ((List<?>) completed.get("originalRecords")).size());
+        assertEquals(1, ((List<?>) completed.get("repairRecords")).size());
+        Map<?, ?> compactRecord = (Map<?, ?>) ((List<?>) completed.get("originalRecords")).get(0);
+        assertEquals("original-summary-instance", compactRecord.get("formInstId"));
+        assertFalse(compactRecord.containsKey("instValue"));
+        assertFalse(completed.containsKey("closed"));
+        verify(client, times(2)).queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form));
+        verifyNoInteractions(closingService);
+    }
+
+    @Test
+    void shouldReuseCompletedRepairContextWithinTtl() throws Exception {
+        YDClient client = mock(YDClient.class);
+        MonthClosingService closingService = mock(MonthClosingService.class);
+        DDR_New<Object> response = new DDR_New<>();
+        response.setData(Collections.emptyList());
+        when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(response);
+        TimeCardRepairService service = repairContextService(client, closingService, repairContextConf());
+
+        TimeCardRepairContextRequest request = repairContextRequest();
+        service.context(request);
+        Map<String, Object> first = awaitReadyContext(service, request);
+        Map<String, Object> second = service.context(request);
+
+        assertEquals(first, second);
+        verify(client, times(2)).queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form));
+        verifyNoInteractions(closingService);
+    }
+
+    @Test
+    void shouldStartDifferentUserContextsWithoutWaitingForEachOther() throws Exception {
+        YDClient client = mock(YDClient.class);
+        MonthClosingService closingService = mock(MonthClosingService.class);
+        CountDownLatch queriesStarted = new CountDownLatch(4);
+        CountDownLatch releaseQueries = new CountDownLatch(1);
+        when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenAnswer(invocation -> {
+                    queriesStarted.countDown();
+                    if (!releaseQueries.await(2, TimeUnit.SECONDS)) {
+                        throw new IllegalStateException("different user context queries did not start");
+                    }
+                    DDR_New<Object> response = new DDR_New<>();
+                    response.setData(Collections.emptyList());
+                    return response;
+                });
+        TimeCardRepairService service = repairContextService(client, closingService, repairContextConf());
+        TimeCardRepairContextRequest firstRequest = repairContextRequest();
+        TimeCardRepairContextRequest secondRequest = repairContextRequest();
+        secondRequest.setSubmitterUserId("user-2");
+
+        assertEquals(false, service.context(firstRequest).get("ready"));
+        assertEquals(false, service.context(secondRequest).get("ready"));
+        assertTrue(queriesStarted.await(2, TimeUnit.SECONDS));
+        releaseQueries.countDown();
+
+        assertEquals(true, awaitReadyContext(service, firstRequest).get("ready"));
+        assertEquals(true, awaitReadyContext(service, secondRequest).get("ready"));
+        verify(client, times(4)).queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form));
+        verifyNoInteractions(closingService);
+    }
+
+    private static Map<String, Object> awaitReadyContext(TimeCardRepairService service,
+                                                         TimeCardRepairContextRequest request) throws InterruptedException {
+        for (int attempt = 0; attempt < 200; attempt++) {
+            Map<String, Object> result = service.context(request);
+            if (Boolean.TRUE.equals(result.get("ready"))) return result;
+            Thread.sleep(10L);
+        }
+        throw new AssertionError("context did not become ready");
+    }
+
+    @Test
+    void shouldPropagateRepairContextQueryFailureWithoutPartialResult() throws Exception {
+        YDClient client = mock(YDClient.class);
+        MonthClosingService closingService = mock(MonthClosingService.class);
+        CountDownLatch queryStarted = new CountDownLatch(1);
+        CountDownLatch releaseQuery = new CountDownLatch(1);
+        when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenAnswer(invocation -> {
+                    queryStarted.countDown();
+                    if (!releaseQuery.await(2, TimeUnit.SECONDS)) {
+                        throw new IllegalStateException("failed context query was not released");
+                    }
+                    throw new McException("TEMPORARY_FAILURE", "宜搭查询超时");
+                });
+        TimeCardRepairService service = repairContextService(client, closingService, repairContextConf());
+
+        TimeCardRepairContextRequest request = repairContextRequest();
+        assertEquals(false, service.context(request).get("ready"));
+        assertTrue(queryStarted.await(2, TimeUnit.SECONDS));
+        releaseQuery.countDown();
+        McException error = awaitContextFailure(service, request);
+
+        assertEquals("宜搭查询超时", error.getMessage());
+        verifyNoInteractions(closingService);
+    }
+
+    private static McException awaitContextFailure(TimeCardRepairService service,
+                                                   TimeCardRepairContextRequest request) throws InterruptedException {
+        for (int attempt = 0; attempt < 200; attempt++) {
+            try {
+                service.context(request);
+            } catch (McException error) {
+                return error;
+            }
+            Thread.sleep(10L);
+        }
+        throw new AssertionError("context failure was not propagated");
+    }
+
+    private static TimeCardRepairService repairContextService(YDClient client,
+                                                               MonthClosingService closingService,
+                                                               WHConf conf) {
+        TimeCardRepairService service = new TimeCardRepairService();
+        ReflectionTestUtils.setField(service, "ydClient", client);
+        ReflectionTestUtils.setField(service, "monthClosingService", closingService);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+        return service;
+    }
+
+    private static WHConf repairContextConf() {
+        WHConf conf = new WHConf();
+        conf.setYidaAppType("app");
+        conf.setYidaSystemToken("token");
+        conf.setFormUuidWorkHoursSummary("original-summary");
+        conf.setFormUuidRepairWorkHoursSummary("repair-summary");
+        conf.setRepairSummaryFillTypeField("fill-type");
+        return conf;
+    }
+
+    private static TimeCardRepairContextRequest repairContextRequest() {
+        TimeCardRepairContextRequest request = new TimeCardRepairContextRequest();
+        request.setSubmitterUserId("user-1");
+        request.setSourceMonthText("202609");
+        return request;
+    }
+
     @ParameterizedTest
     @ValueSource(booleans = {false, true})
     void shouldBlockSavingWhenCurrentMonthClosingRunsOrCompleted(boolean closed) {

+ 9 - 1
pages/README.md

@@ -1,7 +1,11 @@
-# TimeCard 补填页面源码
+# TimeCard 页面源码
 
 `src/timecard-repair.oyd.jsx` 为阿科德斯 `Timecard_补填` 的维护源码,采用宜搭现有平台 JSX 模式。主管规则为内部员工取人员档案 Manager,外部员工取所选月份有效项目经理,只有业务数据缺失时才兜底 Raymond。
 
+`src/timecard-repair-lifecycle.oyd.jsx` 以 2026-10-21 线上版本52为基线,保留切页生命周期修复,并加入补填上下文异步就绪轮询与卸载清理;作为本次版本53发布源码。主维护源码仍包含其他未发布改动,后续发布前必须重新与线上版本合并,不能直接覆盖。
+
+`src/timecard-change.oyd.jsx` 为 `Timecard_变更` 版本24的维护源码,包含频繁切页时的实例隔离、resize监听卸载和异步回写终止。后续发布仍须先回读线上最新Schema,再把修改合入线上基线。
+
 其他工时补填请求必须携带 `otherGroup.managerId`。`buildTimecardRepairRequest` 和调用方需要完整等待主管解析,再发起后端请求。
 
 ## 验证
@@ -10,6 +14,9 @@
 
 ```sh
 node pages/tests/verify-repair-manager.cjs
+node pages/tests/verify-repair-lifecycle.cjs
+node pages/tests/verify-repair-context.cjs pages/src/timecard-repair-lifecycle.oyd.jsx
+node pages/tests/verify-change-lifecycle.cjs
 ```
 
 8 个用例覆盖主管缓存、缓存缺失、内外部员工规则、业务兜底、纯项目工时、连接器实际请求及主管获取失败。也可验证回读 Schema 的源码或编译代码:
@@ -22,6 +29,7 @@ node pages/tests/verify-repair-manager.cjs /absolute/path/to/readback-schema.jso
 
 - 目标应用:`APP_ZQ3I7XO2RSHDJ4QDEVNB`。
 - 补填页面:`FORM-C7FA6060445B4820AF93668DE6AD9C4FY0CY`。
+- 变更页面:`FORM-03AD081995774FC38E148AFE8C60D2FECK7K`。
 - 发布前回读最新线上 Schema,将修改合入线上基线,再执行 `openyida check-page`、`compile`、`publish --health-check`。发布后核对源码、原有功能与页面数据源。
 - 正式 `Timecard` 主页面仍由用户手工发布,本目录源码不能用于覆盖该页面。
 - 登录态沿用已绑定阿科德斯组织的项目环境,不提交凭据、Schema 快照、编译产物或缓存。

File diff suppressed because it is too large
+ 5586 - 0
pages/src/timecard-change.oyd.jsx


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


+ 69 - 0
pages/tests/verify-change-lifecycle.cjs

@@ -0,0 +1,69 @@
+'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 extractFunction(source, name) {
+  const pattern = new RegExp('export function ' + name + '\\(');
+  const match = pattern.exec(source);
+  assert.ok(match, '缺少方法 ' + name);
+  const nextExport = source.indexOf('\nexport function ', match.index + match[0].length);
+  return source.slice(match.index, nextExport < 0 ? source.length : nextExport).replace(/^export /, '');
+}
+
+const sourcePath = process.argv[2]
+  ? path.resolve(process.argv[2])
+  : path.resolve(__dirname, '../src/timecard-change.oyd.jsx');
+const source = fs.readFileSync(sourcePath, 'utf8');
+
+assert.doesNotMatch(source, /window\._this/, '变更页渲染不得依赖跨页面共享的 window._this');
+assert.match(source, /var _this = this;[\s\S]*if \(!_this \|\| !_this\.state/,
+  'renderJsx 应使用当前页面实例');
+
+let removedEvent = null;
+let removedHandler = null;
+const methods = vm.runInNewContext(
+  ['refreshTimecardView', 'forceUpdate', 'didUnmount']
+    .map((name) => extractFunction(source, name)).join('\n')
+    + '\n({refreshTimecardView, forceUpdate, didUnmount})',
+  {
+    window: {
+      removeEventListener(event, handler) {
+        removedEvent = event;
+        removedHandler = handler;
+      },
+    },
+  },
+);
+
+const resizeHandler = () => {};
+let setStateCount = 0;
+let forceUpdateCount = 0;
+const context = {
+  _tcDisposed: false,
+  _tcBeisenLeaveRequestKey: 'active-request',
+  _tcResizeHandler: resizeHandler,
+  setState() { setStateCount += 1; },
+  forceUpdate() { forceUpdateCount += 1; },
+};
+
+methods.didUnmount.call(context);
+assert.equal(context._tcDisposed, true);
+assert.equal(context._tcBeisenLeaveRequestKey, '__TIMECARD_PAGE_DISPOSED__');
+assert.equal(context._tcResizeHandler, null);
+assert.equal(removedEvent, 'resize');
+assert.equal(removedHandler, resizeHandler);
+
+methods.refreshTimecardView.call(context);
+methods.forceUpdate.call(context);
+assert.equal(forceUpdateCount, 0, '卸载后不得继续 forceUpdate');
+assert.equal(setStateCount, 0, '卸载后不得继续 setState');
+
+console.log('PASS 变更页面切换时清理实例、监听和异步刷新');

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

@@ -0,0 +1,153 @@
+'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 extractFunction(source, name) {
+  const pattern = new RegExp('export (?:async )?function ' + name + '\\(');
+  const match = pattern.exec(source);
+  assert.ok(match, '缺少方法 ' + name);
+  const end = source.indexOf('\n/**', match.index + match[0].length);
+  return source.slice(match.index, end < 0 ? source.length : end).replace(/^export /, '');
+}
+
+const defaultSourcePath = path.resolve(__dirname, '../src/timecard-repair-lifecycle.oyd.jsx');
+const sourcePath = process.argv[2] ? path.resolve(process.argv[2]) : defaultSourcePath;
+const source = fs.readFileSync(sourcePath, 'utf8');
+let holdTimer = false;
+let pendingTimer = null;
+const methods = vm.runInNewContext(
+  ['getRepairContextErrorMessage', 'isRepairContextRetryableError', 'waitForRepairContextRetry', 'loadTimecardRepairStatus']
+    .map((name) => extractFunction(source, name)).join('\n')
+    + '\n({getRepairContextErrorMessage, isRepairContextRetryableError, waitForRepairContextRetry, loadTimecardRepairStatus})',
+  {
+    REPAIR_DATA_SOURCES: { status: 'getTimecardRepairStatus' },
+    setTimeout: (callback) => {
+      if (!holdTimer) {
+        callback();
+        return { immediate: true };
+      }
+      pendingTimer = { callback, cleared: false };
+      return pendingTimer;
+    },
+  },
+);
+
+/** @param {*} response @returns {object} */
+function createContext(response) {
+  return {
+    getTexts: () => ({ repairContextTimeout: '补填工时加载超时,请稍后重试', errUnknown: '未知错误' }),
+    getRepairContextErrorMessage: methods.getRepairContextErrorMessage,
+    isRepairContextRetryableError: methods.isRepairContextRetryableError,
+    waitForRepairContextRetry: methods.waitForRepairContextRetry,
+    getRepairMonthText: (year, month) => String(year) + String(month).padStart(2, '0'),
+    getCallableDataSource: () => ({ load: async () => response }),
+    unwrapConnectorResponse: (value) => value,
+  };
+}
+
+/** @returns {Promise<void>} */
+async function main() {
+  const errorContext = createContext(null);
+  assert.equal(methods.getRepairContextErrorMessage.call(errorContext, new Error('Read timed out')),
+    '补填工时加载超时,请稍后重试');
+  assert.equal(methods.getRepairContextErrorMessage.call(errorContext, { errorCode: 'ETIMEDOUT', code: 'ETIMEDOUT' }),
+    '补填工时加载超时,请稍后重试');
+  assert.equal(methods.getRepairContextErrorMessage.call(errorContext, new Error('接口返回 500')), '接口返回 500');
+  assert.equal(methods.getRepairContextErrorMessage.call(errorContext, {}), '未知错误');
+
+  const response = { originalRecords: [{ id: 'original' }], repairRecords: [{ id: 'repair' }], records: [] };
+  const result = await methods.loadTimecardRepairStatus.call(createContext(response), 'user-1', 2026, 9);
+  assert.equal(result.configured, true);
+  assert.equal(result.originalRecords[0].id, 'original');
+  assert.equal(result.repairRecords[0].id, 'repair');
+  assert.equal(Object.hasOwn(result, 'closed'), false);
+
+  let retryCount = 0;
+  const retryContext = createContext(response);
+  retryContext.getCallableDataSource = () => ({
+    load: async () => {
+      retryCount += 1;
+      if (retryCount === 1) throw new Error('Read timed out');
+      return response;
+    },
+  });
+  const retryResult = await methods.loadTimecardRepairStatus.call(retryContext, 'user-1', 2026, 9);
+  assert.equal(retryCount, 2);
+  assert.equal(retryResult.originalRecords[0].id, 'original');
+
+  let pendingCount = 0;
+  const pendingContext = createContext(response);
+  pendingContext.getCallableDataSource = () => ({
+    load: async () => {
+      pendingCount += 1;
+      return pendingCount < 3 ? { ready: false, retryAfterMillis: 500 } : response;
+    },
+  });
+  const pendingResult = await methods.loadTimecardRepairStatus.call(pendingContext, 'user-1', 2026, 9);
+  assert.equal(pendingCount, 3);
+  assert.equal(pendingResult.repairRecords[0].id, 'repair');
+
+  let emptyCount = 0;
+  const emptyContext = createContext(response);
+  emptyContext.getCallableDataSource = () => ({
+    load: async () => {
+      emptyCount += 1;
+      return emptyCount === 1 ? null : response;
+    },
+  });
+  const emptyRetryResult = await methods.loadTimecardRepairStatus.call(emptyContext, 'user-1', 2026, 9);
+  assert.equal(emptyCount, 2);
+  assert.equal(emptyRetryResult.originalRecords[0].id, 'original');
+
+  let exhaustedCount = 0;
+  const exhaustedContext = createContext(null);
+  exhaustedContext.getCallableDataSource = () => ({
+    load: async () => {
+      exhaustedCount += 1;
+      return { originalRecords: [] };
+    },
+  });
+  await assert.rejects(
+    methods.loadTimecardRepairStatus.call(exhaustedContext, 'user-1', 2026, 9),
+    /补填工时加载超时,请稍后重试/,
+  );
+  assert.equal(exhaustedCount, 45);
+
+  let businessErrorCount = 0;
+  const businessErrorContext = createContext(null);
+  businessErrorContext.getCallableDataSource = () => ({
+    load: async () => {
+      businessErrorCount += 1;
+      throw new Error('无权限访问');
+    },
+  });
+  await assert.rejects(
+    methods.loadTimecardRepairStatus.call(businessErrorContext, 'user-1', 2026, 9),
+    /无权限访问/,
+  );
+  assert.equal(businessErrorCount, 1);
+
+  let releasedOnUnmount = false;
+  const waitContext = {
+    _tcDisposed: false,
+    _tcRepairContextPollTimers: [],
+  };
+  holdTimer = true;
+  methods.waitForRepairContextRetry.call(waitContext, 2000).then(() => { releasedOnUnmount = true; });
+  assert.equal(waitContext._tcRepairContextPollTimers.length, 1);
+  const entry = waitContext._tcRepairContextPollTimers[0];
+  assert.equal(entry.timerId, pendingTimer);
+  pendingTimer.cleared = true;
+  entry.resolve();
+  await Promise.resolve();
+  assert.equal(pendingTimer.cleared, true);
+  assert.equal(releasedOnUnmount, true);
+  holdTimer = false;
+  console.log('PASS 补填上下文 pending 轮询、空响应重试、总超时和业务错误识别');
+}
+
+main().catch((error) => { console.error(error); process.exitCode = 1; });

+ 81 - 0
pages/tests/verify-repair-lifecycle.cjs

@@ -0,0 +1,81 @@
+'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 extractFunction(source, name) {
+  const pattern = new RegExp('export function ' + name + '\\(');
+  const match = pattern.exec(source);
+  assert.ok(match, '缺少方法 ' + name);
+  const nextExport = source.indexOf('\nexport function ', match.index + match[0].length);
+  return source.slice(match.index, nextExport < 0 ? source.length : nextExport).replace(/^export /, '');
+}
+
+const defaultSource = path.resolve(__dirname, '../src/timecard-repair-lifecycle.oyd.jsx');
+const sourcePath = process.argv[2] ? path.resolve(process.argv[2]) : defaultSource;
+const source = fs.readFileSync(sourcePath, 'utf8');
+
+assert.doesNotMatch(source, /window\._this/, '渲染不得依赖跨页面共享的 window._this');
+assert.match(source, /var _this = this;[\s\S]*if \(!_this \|\| !_this\.state/, 'renderJsx 应使用当前页面实例');
+
+let removedEvent = null;
+let removedHandler = null;
+let clearedTimer = null;
+let releasedTimer = false;
+const methods = vm.runInNewContext(
+  ['refreshTimecardView', 'forceUpdate', 'didUnmount']
+    .map((name) => extractFunction(source, name)).join('\n')
+    + '\n({refreshTimecardView, forceUpdate, didUnmount})',
+  {
+    window: {
+      removeEventListener(event, handler) {
+        removedEvent = event;
+        removedHandler = handler;
+      },
+    },
+    clearTimeout(timer) {
+      clearedTimer = timer;
+    },
+  },
+);
+
+const resizeHandler = () => {};
+let setStateCount = 0;
+let forceUpdateCount = 0;
+const context = {
+  _tcDisposed: false,
+  _tcBeisenLeaveRequestKey: 'active-request',
+  _tcRepairContextRequestKey: 'active-repair-request',
+  _tcRepairContextPollTimers: [{
+    timerId: 123,
+    resolve() { releasedTimer = true; },
+  }],
+  _tcResizeHandler: resizeHandler,
+  setState() { setStateCount += 1; },
+  forceUpdate() { forceUpdateCount += 1; },
+};
+
+methods.didUnmount.call(context);
+assert.equal(context._tcDisposed, true);
+assert.equal(context._tcBeisenLeaveRequestKey, '__TIMECARD_PAGE_DISPOSED__');
+assert.equal(context._tcRepairContextRequestKey, '__TIMECARD_PAGE_DISPOSED__');
+assert.equal(context._tcRepairContextPollTimers.length, 0);
+assert.equal(clearedTimer, 123);
+assert.equal(releasedTimer, true);
+assert.equal(context._tcResizeHandler, null);
+assert.equal(removedEvent, 'resize');
+assert.equal(removedHandler, resizeHandler);
+
+methods.refreshTimecardView.call(context);
+methods.forceUpdate.call(context);
+assert.equal(forceUpdateCount, 0, '卸载后不得继续 forceUpdate');
+assert.equal(setStateCount, 0, '卸载后不得继续 setState');
+
+console.log('PASS 补填页面切换时清理实例、监听和异步刷新');