Parcourir la source

fix(timecard): correct overtime and external repair validation

Exclude change hours from overtime and preserve internal/external repair guards. Merge native closing queries and retire background closing-cache warmup. Track formal-page actions/render sources and regression checks.
malk il y a 1 semaine
Parent
commit
591a7ea0da

+ 6 - 0
mjava-akdsbeisen/src/main/java/com/malk/controller/MonthClosingController.java

@@ -26,10 +26,16 @@ public class MonthClosingController {
     /**
      * 查询目标月份是否已封账。
      *
+     * @deprecated prd 2026-10-23:TimeCard、补填和变更页面改为宜搭原生流程查询,
+     * 页面不再使用此中转入口。历史客户端暂留兼容;不得移除MonthClosingService,
+     * 该服务仍承担保存、提交、审批补发与封账流程的业务保护。
+     * 追溯:Tech/宜搭/阿科德斯/TimeCard/开发/封账查询评估-20261023.md。
+     *
      * @param request 月份请求
      * @param response HTTP 响应
      * @return 封账状态
      */
+    @Deprecated
     @PostMapping("/status")
     public McR<MonthClosingStatusResult> status(
             @RequestBody MonthClosingCheckRequest request,

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

@@ -7,4 +7,6 @@ import lombok.Data;
 public class TimeCardRepairContextRequest {
     private String submitterUserId;
     private String sourceMonthText;
+    /** 页面已核实属性,仅用于缓存隔离;服务端仍独立查档案核验。 */
+    private String staffType;
 }

+ 5 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/MonthClosingService.java

@@ -39,6 +39,8 @@ import java.util.function.LongSupplier;
  * 月度封账统一校验服务。
  *
  * <p>TimeCard、补填、变更与封账流程均调用本服务,避免各业务维护不同口径。</p>
+ * <p>prd 2026-10-23:页面准入状态改为宜搭原生查询;本服务仍承担后端保存、
+ * 提交、审批补发及封账校验,不能随旧页面查询接口作废而停用。</p>
  */
 @Service
 public class MonthClosingService {
@@ -86,8 +88,11 @@ public class MonthClosingService {
     /**
      * 一次读取全部封账流程,预热实际封账月份、当前月和上月的五分钟缓存。
      *
+     * @deprecated prd 2026-10-23:旧页面查询预热功能作废,对应定时任务已关闭。
+     * 仅保留历史方法兼容,不再自动调用;业务按需缓存及并发合并保持原样。
      * @return 本次写入缓存的月份数量
      */
+    @Deprecated
     public int warmUpClosingStatusCache() {
         List<Map> processes = queryProcesses(closingConf.getFormUuid(), null, null, null);
         Set<String> months = new LinkedHashSet<>();

+ 10 - 3
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/TimeCardPersonnelPolicy.java

@@ -27,6 +27,13 @@ public class TimeCardPersonnelPolicy {
 
     /** 仅档案中明确为内部的人员可以正常填报,查询异常不得视为内部。 */
     public void assertNormalUser(String userId) {
+        String attribute = getStaffType(userId);
+        // prd 外部人员按客户确认工时通过补填上报,不再使用普通 TimeCard。
+        McException.assertAccessException("外部".equals(attribute), "外部人员请通过TimeCard补填上报工时");
+    }
+
+    /** 核实唯一人员档案,返回明确的内部/外部属性;异常不得放行。 */
+    public String getStaffType(String userId) {
         McException.assertAccessException(StringUtils.isBlank(userId), "审批明细填报人ID不能为空");
         McException.assertAccessException(StringUtils.isBlank(whConf.getFormUuidPersonnel()), "人员档案表未配置");
         DDR_New response = ydClient.queryData(YDParam.builder()
@@ -49,9 +56,9 @@ public class TimeCardPersonnelPolicy {
         Map data = (Map) raw;
         McException.assertAccessException(!userId.equals(memberId(data, EMPLOYEE)), "人员档案身份不一致");
         String attribute = String.valueOf(data.get(ATTRIBUTE));
-        // prd 外部人员按客户确认工时通过补填上报,不再使用普通 TimeCard。
-        McException.assertAccessException("外部".equals(attribute), "外部人员请通过TimeCard补填上报工时");
-        McException.assertAccessException(!"内部".equals(attribute), "人员属性缺失或异常,不能正常填报");
+        McException.assertAccessException(!"内部".equals(attribute) && !"外部".equals(attribute),
+                "人员属性缺失或异常,不能正常填报");
+        return attribute;
     }
 
     /** 先检查全部明细填报人,再发起任何分片,防止混合审批部分成功。 */

+ 35 - 11
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/TimeCardRepairService.java

@@ -77,13 +77,17 @@ public class TimeCardRepairService {
     @Autowired private WHConf whConf;
     @Autowired private MonthClosingService monthClosingService;
     @Autowired private ReSubmitApprovalService reSubmitApprovalService;
+    @Autowired private TimeCardPersonnelPolicy personnelPolicy;
 
     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;
+        String staffType = text(request.getStaffType());
+        McException.assertAccessException(!staffType.isEmpty()
+                && !"内部".equals(staffType) && !"外部".equals(staffType), "人员属性无效");
+        String requestKey = userId + "|" + monthText + "|" + staffType;
         Map<String, Object> cachedResult = getCachedContext(requestKey);
         if (cachedResult != null) {
             CompletableFuture<Map<String, Object>> completedRequest = contextRequests.get(requestKey);
@@ -93,7 +97,13 @@ public class TimeCardRepairService {
             return cachedResult;
         }
         CompletableFuture<Map<String, Object>> contextFuture = contextRequests.computeIfAbsent(
-                requestKey, ignored -> loadContextAsync(requestKey, userId, monthText));
+                requestKey, ignored -> CompletableFuture.supplyAsync(
+                        () -> personnelPolicy.getStaffType(userId), CONTEXT_QUERY_EXECUTOR)
+                        .thenCompose(actualType -> {
+                            McException.assertAccessException(!staffType.isEmpty() && !staffType.equals(actualType),
+                                    "人员属性已变化,请刷新后重试");
+                            return loadContextAsync(requestKey, userId, monthText, actualType);
+                        }));
         if (!contextFuture.isDone()) return pendingContext();
         contextRequests.remove(requestKey, contextFuture);
         return completedContext(contextFuture, userId, monthText);
@@ -101,8 +111,12 @@ public class TimeCardRepairService {
 
     private CompletableFuture<Map<String, Object>> loadContextAsync(String requestKey,
                                                                      String userId,
-                                                                     String monthText) {
-        CompletableFuture<List<Map>> originalFuture = CompletableFuture.supplyAsync(
+                                                                     String monthText,
+                                                                     String staffType) {
+        // prd 外部补填独立于已冲销但保留的原汇总;不发起原表查询。
+        CompletableFuture<List<Map>> originalFuture = "外部".equals(staffType)
+                ? 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(
@@ -111,6 +125,8 @@ public class TimeCardRepairService {
         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("originalRecords", compactContextRecords(originalRecords));
             result.put("repairRecords", compactContextRecords(repairRecords));
             // 兼容页面状态合并接口的 records 约定;原始双表结果仍通过 repairRecords 返回。
@@ -151,7 +167,8 @@ public class TimeCardRepairService {
     }
 
     private void invalidateContext(String userId, String monthText) {
-        contextCache.remove(userId + "|" + monthText);
+        String prefix = userId + "|" + monthText + "|";
+        contextCache.keySet().removeIf(key -> key.startsWith(prefix));
     }
 
     private static List<Map<String, Object>> compactContextRecords(List<Map> records) {
@@ -183,7 +200,8 @@ public class TimeCardRepairService {
         McException.assertAccessException(StringUtils.isBlank(whConf.getRepairSummaryFillTypeField()), "补填汇总类型字段未配置");
         McException.assertAccessException(StringUtils.isBlank(whConf.getRepairSummarySyncStatusField()), "补填汇总状态字段未配置");
         McException.assertAccessException(request.getFormData() == null, "补填工时数据不能为空");
-        assertSourceDateAvailable(request.getSubmitterUserId(), request.getDayText());
+        assertSourceDateAvailable(request.getSubmitterUserId(), request.getDayText(),
+                "外部".equals(personnelPolicy.getStaffType(request.getSubmitterUserId())));
         List<Map> existing = queryRepairSummaries(
                 mapOf(R_SUBMITTER, request.getSubmitterUserId(), R_DAY, request.getDayText()));
         McException.assertAccessException(existing.size() > 1,
@@ -645,7 +663,8 @@ public class TimeCardRepairService {
         }
     }
 
-    private void validateRepairRow(TimeCardRepairSubmitRequest request, Map row, String category) {
+    private void validateRepairRow(TimeCardRepairSubmitRequest request, Map row, String category,
+                                   boolean external) {
         String day = text(row.get("dateStr"));
         if (StringUtils.isBlank(day)) day = text(row.get("dayText"));
         McException.assertAccessException(!day.matches("\\d{8}")
@@ -661,7 +680,7 @@ public class TimeCardRepairService {
             McException.assertAccessException(StringUtils.isBlank(text(row.get("projectCode"))),
                     ("billableRows".equals(category) ? "Billable" : "Non Billable") + "工时必须填写项目编号");
         }
-        assertSourceDateAvailable(request.getSubmitterUserId(), day);
+        assertSourceDateAvailable(request.getSubmitterUserId(), day, external);
         assertRepairRowMutable(request.getSubmitterUserId(), day, row, category);
     }
 
@@ -669,26 +688,31 @@ public class TimeCardRepairService {
                                    Map<String, Object> projectGroups,
                                    Map<String, Object> otherGroup) {
         int count = 0;
+        String staffType = null;
         for (Object rawGroup : projectGroups.values()) {
             if (!(rawGroup instanceof Map)) continue;
             Map group = (Map) rawGroup;
             for (String category : new String[]{"billableRows", "nonBillableRows"}) {
                 for (Object rawRow : list(group.get(category))) {
                     if (!(rawRow instanceof Map)) continue;
-                    validateRepairRow(request, (Map) rawRow, category);
+                    if (staffType == null) staffType = personnelPolicy.getStaffType(request.getSubmitterUserId());
+                    validateRepairRow(request, (Map) rawRow, category, "外部".equals(staffType));
                     count++;
                 }
             }
         }
         for (Object rawRow : list(otherGroup.get("otherRows"))) {
             if (!(rawRow instanceof Map)) continue;
-            validateRepairRow(request, (Map) rawRow, "otherRows");
+            if (staffType == null) staffType = personnelPolicy.getStaffType(request.getSubmitterUserId());
+            validateRepairRow(request, (Map) rawRow, "otherRows", "外部".equals(staffType));
             count++;
         }
         return count;
     }
 
-    private void assertSourceDateAvailable(String userId, String day) {
+    private void assertSourceDateAvailable(String userId, String day, boolean external) {
+        // prd 外部来源日由补填记录保护;内部继续核实原表锁定。
+        if (external) return;
         List<Map> original = query(whConf.getFormUuidWorkHoursSummary(), mapOf(R_SUBMITTER, userId, R_DAY, day));
         boolean hasApprovedOrChangedHours = original.stream()
                 .anyMatch(TimeCardSummaryService::isFormalRecordLocked);

+ 25 - 36
mjava-akdsbeisen/src/main/java/com/malk/timer/MonthClosingCacheWarmupTimer.java

@@ -1,52 +1,41 @@
 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;
-
-/** 定时预热月度封账状态缓存,避免页面首次查询承担宜搭冷调用耗时。 */
+/**
+ * 定时预热月度封账状态缓存,避免页面首次查询承担宜搭冷调用耗时。
+ *
+ * @deprecated prd 2026-10-23:三个TimeCard入口已改为宜搭前端原生查询,
+ * 原预热仍受SSL/令牌异常影响,不能稳定解决连接器超时,故永久停止自动预热。
+ * 本类不再注册为Spring配置,不创建线程,也不受全局enable.scheduling开关控制。
+ * 历史方法仅留空实现,避免误调用重新启动;其他定时任务及后端封账保护不受影响。
+ * 追溯:TimeCard/开发/封账查询前端直查-实施发布记录-20261023.md。
+ */
+@Deprecated
 @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
+    /**
+     * 使用独立线程启动预热,避免被其他长耗时定时任务阻塞。
+     * @deprecated 原调度已作废;保留方法但不启动线程或查询。
+     */
+    @Deprecated
     public void start() {
-        scheduler.scheduleWithFixedDelay(
-                this::warmUpClosingStatusCache, 5L, 3L * 60L, TimeUnit.SECONDS);
+        log.info("[月度封账缓存] 页面已迁移宜搭原生查询,旧预热任务已作废,不再启动");
     }
 
-    /** 容器停止时终止后续预热。 */
-    @PreDestroy
+    /**
+     * 容器停止时终止后续预热。
+     * @deprecated 不再创建调度器,无需停止资源。
+     */
+    @Deprecated
     public void stop() {
-        scheduler.shutdownNow();
+        // prd 已作废:无调度器和后台线程,保留历史生命周期入口。
     }
 
+    /** @deprecated 旧预热入口已作废;不再发起宜搭查询或刷新缓存。 */
+    @Deprecated
     public void warmUpClosingStatusCache() {
-        try {
-            int monthCount = monthClosingService.warmUpClosingStatusCache();
-            log.info("[月度封账缓存] 预热完成 months={}", monthCount);
-        } catch (Exception ex) {
-            log.error("[月度封账缓存] 预热失败,保留现有缓存并等待下次调度", ex);
-        }
+        // ppExt 后端status/isLocked/validate仍按需查询;不得停用共享封账校验服务。
     }
 }

+ 7 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/TimeCardPersonnelPolicyTest.java

@@ -15,6 +15,13 @@ import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.*;
 
 class TimeCardPersonnelPolicyTest {
+    @Test void shouldResolveExternalAttributeWithoutAllowingNormalTimecard() {
+        YDClient client = mock(YDClient.class);
+        when(client.queryData(any(), any())).thenReturn(response(person("u", "外部")));
+        TimeCardPersonnelPolicy policy = policy(client);
+        assertEquals("外部", policy.getStaffType("u"));
+        assertThrows(McException.class, () -> policy.assertNormalUser("u"));
+    }
     private static TimeCardPersonnelPolicy policy(YDClient client) {
         TimeCardPersonnelPolicy policy = new TimeCardPersonnelPolicy();
         WHConf conf = new WHConf();

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

@@ -46,6 +46,160 @@ import static org.mockito.Mockito.when;
 
 class TimeCardRepairServiceTest {
 
+    private static TimeCardPersonnelPolicy internalPolicy() {
+        TimeCardPersonnelPolicy policy = mock(TimeCardPersonnelPolicy.class);
+        when(policy.getStaffType(any())).thenReturn("内部");
+        return policy;
+    }
+
+    @Test
+    void shouldReturnPendingWhilePersonnelLookupIsStillRunning() throws Exception {
+        YDClient client = mock(YDClient.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, mock(MonthClosingService.class), repairContextConf());
+        CountDownLatch started = new CountDownLatch(1);
+        CountDownLatch release = new CountDownLatch(1);
+        TimeCardPersonnelPolicy policy = mock(TimeCardPersonnelPolicy.class);
+        when(policy.getStaffType("user-1")).thenAnswer(invocation -> {
+            started.countDown();
+            assertTrue(release.await(2, TimeUnit.SECONDS));
+            return "外部";
+        });
+        ReflectionTestUtils.setField(service, "personnelPolicy", policy);
+        try {
+            assertEquals(false, service.context(repairContextRequest()).get("ready"));
+            assertTrue(started.await(1, TimeUnit.SECONDS));
+            verifyNoInteractions(client);
+        } finally {
+            release.countDown();
+        }
+        assertEquals("repair-only", awaitReadyContext(service, repairContextRequest()).get("summaryReadMode"));
+    }
+
+    @Test
+    void shouldRejectForgedExternalAttributeBeforeAnySummaryQuery() throws Exception {
+        YDClient client = mock(YDClient.class);
+        TimeCardRepairService service = repairContextService(client, mock(MonthClosingService.class), repairContextConf());
+        TimeCardRepairContextRequest request = repairContextRequest();
+        request.setStaffType("外部");
+        assertTrue(awaitContextFailure(service, request).getMessage().contains("人员属性已变化"));
+        verifyNoInteractions(client);
+    }
+
+    @Test
+    void shouldQueryOnlyRepairSummaryForConfirmedExternalUser() throws Exception {
+        YDClient client = mock(YDClient.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, mock(MonthClosingService.class), repairContextConf());
+        TimeCardPersonnelPolicy policy = mock(TimeCardPersonnelPolicy.class);
+        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("外部", context.get("staffType"));
+        assertTrue(((List<?>) context.get("originalRecords")).isEmpty());
+        verify(client, never()).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));
+    }
+
+    @Test
+    void shouldSeparateContextCacheWhenPersonnelAttributeChanges() throws Exception {
+        YDClient client = mock(YDClient.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, mock(MonthClosingService.class), repairContextConf());
+        TimeCardPersonnelPolicy policy = mock(TimeCardPersonnelPolicy.class);
+        when(policy.getStaffType("user-1")).thenReturn("内部");
+        ReflectionTestUtils.setField(service, "personnelPolicy", policy);
+        TimeCardRepairContextRequest request = repairContextRequest();
+        request.setStaffType("内部");
+        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(
+                p -> "original-summary".equals(p.getFormUuid())), eq(YDConf.FORM_QUERY.retrieve_search_form));
+    }
+
+    @Test
+    void shouldKeepRepairDayLockedForExternalSaveWithoutReadingOriginal() {
+        YDClient client = mock(YDClient.class);
+        MonthClosingService closing = mock(MonthClosingService.class);
+        when(closing.isLocked("202609")).thenReturn(true);
+        when(closing.status(any(MonthClosingCheckRequest.class)))
+                .thenReturn(MonthClosingStatusResult.builder().build());
+        WHConf conf = repairContextConf();
+        conf.setRepairSummarySyncStatusField("sync");
+        TimeCardRepairService service = repairContextService(client, closing, conf);
+        TimeCardPersonnelPolicy policy = mock(TimeCardPersonnelPolicy.class);
+        when(policy.getStaffType("user-1")).thenReturn("外部");
+        ReflectionTestUtils.setField(service, "personnelPolicy", policy);
+        Map<String, Object> fd = new HashMap<>();
+        fd.put("fill-type", "补填");
+        fd.put("sync", "审批中");
+        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);
+        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(
+                p -> "original-summary".equals(p.getFormUuid())), eq(YDConf.FORM_QUERY.retrieve_search_form));
+    }
+
+    @Test
+    void shouldSkipOnlyOriginalSourceGuardForExternalSubmission() {
+        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));
+    }
+
+    @Test
+    void shouldCheckRepairRowsButNeverOriginalDuringExternalSubmitValidation() {
+        YDClient client = mock(YDClient.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, mock(MonthClosingService.class), repairContextConf());
+        TimeCardPersonnelPolicy policy = mock(TimeCardPersonnelPolicy.class);
+        when(policy.getStaffType("user-1")).thenReturn("外部");
+        ReflectionTestUtils.setField(service, "personnelPolicy", policy);
+        TimeCardRepairSubmitRequest request = new TimeCardRepairSubmitRequest();
+        request.setSubmitterUserId("user-1");
+        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("billableRows", Collections.singletonList(row));
+        Object count = ReflectionTestUtils.invokeMethod(service, "validateSubmitRows", request,
+                Collections.singletonMap("manager", group), Collections.emptyMap());
+        assertEquals(1, count);
+        verify(policy, times(1)).getStaffType("user-1");
+        verify(client, never()).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 shouldReturnPendingImmediatelyThenExposeCompletedContext() throws Exception {
         YDClient client = mock(YDClient.class);
@@ -198,6 +352,7 @@ class TimeCardRepairServiceTest {
                                                                MonthClosingService closingService,
                                                                WHConf conf) {
         TimeCardRepairService service = new TimeCardRepairService();
+        ReflectionTestUtils.setField(service, "personnelPolicy", internalPolicy());
         ReflectionTestUtils.setField(service, "ydClient", client);
         ReflectionTestUtils.setField(service, "monthClosingService", closingService);
         ReflectionTestUtils.setField(service, "whConf", conf);
@@ -230,6 +385,7 @@ class TimeCardRepairServiceTest {
                 .thenReturn(MonthClosingStatusResult.builder().closed(closed)
                         .runningClosingCount(closed ? 0 : 1).build());
         TimeCardRepairService service = new TimeCardRepairService();
+        ReflectionTestUtils.setField(service, "personnelPolicy", internalPolicy());
         ReflectionTestUtils.setField(service, "monthClosingService", closingService);
         TimeCardRepairSummaryUpsertRequest request = new TimeCardRepairSummaryUpsertRequest();
         request.setSubmitterUserId("user-1");
@@ -251,6 +407,7 @@ class TimeCardRepairServiceTest {
                     .build();
         });
         TimeCardRepairService service = new TimeCardRepairService();
+        ReflectionTestUtils.setField(service, "personnelPolicy", internalPolicy());
         ReflectionTestUtils.setField(service, "monthClosingService", closingService);
         ReflectionTestUtils.setField(service, "ydClient", client);
 
@@ -265,6 +422,7 @@ class TimeCardRepairServiceTest {
         MonthClosingService closingService = mock(MonthClosingService.class);
         YDClient client = mock(YDClient.class);
         TimeCardRepairService service = new TimeCardRepairService();
+        ReflectionTestUtils.setField(service, "personnelPolicy", internalPolicy());
         ReflectionTestUtils.setField(service, "monthClosingService", closingService);
         ReflectionTestUtils.setField(service, "ydClient", client);
         TimeCardRepairSummaryUpsertRequest draft = new TimeCardRepairSummaryUpsertRequest();
@@ -299,6 +457,7 @@ class TimeCardRepairServiceTest {
         when(closingService.status(any(MonthClosingCheckRequest.class)))
                 .thenReturn(MonthClosingStatusResult.builder().monthText(currentMonth).build());
         TimeCardRepairService service = new TimeCardRepairService();
+        ReflectionTestUtils.setField(service, "personnelPolicy", internalPolicy());
         ReflectionTestUtils.setField(service, "monthClosingService", closingService);
         ReflectionTestUtils.setField(service, "ydClient", client);
 
@@ -319,6 +478,7 @@ class TimeCardRepairServiceTest {
                 .thenReturn(MonthClosingStatusResult.builder()
                         .closed(closed).runningClosingCount(runningCount).build());
         TimeCardRepairService service = new TimeCardRepairService();
+        ReflectionTestUtils.setField(service, "personnelPolicy", internalPolicy());
         ReflectionTestUtils.setField(service, "monthClosingService", closingService);
         ReflectionTestUtils.setField(service, "ydClient", client);
 
@@ -399,6 +559,7 @@ class TimeCardRepairServiceTest {
         when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
                 .thenReturn(noOriginal, approvedRepair);
         TimeCardRepairService service = new TimeCardRepairService();
+        ReflectionTestUtils.setField(service, "personnelPolicy", internalPolicy());
         ReflectionTestUtils.setField(service, "ydClient", client);
         ReflectionTestUtils.setField(service, "whConf", conf);
         ReflectionTestUtils.setField(service, "monthClosingService", closingService);
@@ -446,6 +607,7 @@ class TimeCardRepairServiceTest {
         storedDetail.setFormData(stored);
         when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_id))).thenReturn(storedDetail);
         TimeCardRepairService service = new TimeCardRepairService();
+        ReflectionTestUtils.setField(service, "personnelPolicy", internalPolicy());
         ReflectionTestUtils.setField(service, "ydClient", client);
         ReflectionTestUtils.setField(service, "whConf", conf);
         ReflectionTestUtils.setField(service, "monthClosingService", closingService);
@@ -495,6 +657,7 @@ class TimeCardRepairServiceTest {
         when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
                 .thenReturn(summaries);
         TimeCardRepairService service = new TimeCardRepairService();
+        ReflectionTestUtils.setField(service, "personnelPolicy", internalPolicy());
         ReflectionTestUtils.setField(service, "ydClient", client);
         ReflectionTestUtils.setField(service, "whConf", conf);
         ReflectionTestUtils.setField(service, "reSubmitApprovalService", approvalService);
@@ -559,6 +722,7 @@ class TimeCardRepairServiceTest {
         when(approvalService.startApproval(any(TimeCardApprovalStartRequest.class)))
                 .thenThrow(new IllegalStateException("temporary failure"));
         TimeCardRepairService service = new TimeCardRepairService();
+        ReflectionTestUtils.setField(service, "personnelPolicy", internalPolicy());
         ReflectionTestUtils.setField(service, "ydClient", client);
         ReflectionTestUtils.setField(service, "whConf", conf);
         ReflectionTestUtils.setField(service, "reSubmitApprovalService", approvalService);

+ 65 - 0
mjava-akdsbeisen/src/test/java/com/malk/timer/MonthClosingCacheWarmupTimerTest.java

@@ -0,0 +1,65 @@
+package com.malk.timer;
+
+import com.malk.controller.MonthClosingController;
+import com.malk.server.workhours.MonthClosingCheckRequest;
+import com.malk.service.workhours.MonthClosingService;
+import org.junit.jupiter.api.Test;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.web.bind.annotation.PostMapping;
+
+import javax.annotation.PostConstruct;
+import javax.servlet.http.HttpServletResponse;
+import java.lang.reflect.Method;
+import java.util.concurrent.ExecutorService;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class MonthClosingCacheWarmupTimerTest {
+
+    @Test
+    void retiredTimerMustNotRegisterOrSchedule() {
+        assertTrue(MonthClosingCacheWarmupTimer.class.isAnnotationPresent(Deprecated.class));
+        assertFalse(MonthClosingCacheWarmupTimer.class.isAnnotationPresent(Configuration.class));
+        assertFalse(MonthClosingCacheWarmupTimer.class.isAnnotationPresent(Component.class));
+        for (Method method : MonthClosingCacheWarmupTimer.class.getDeclaredMethods()) {
+            assertFalse(method.isAnnotationPresent(PostConstruct.class));
+            assertFalse(method.isAnnotationPresent(Scheduled.class));
+        }
+        assertTrue(java.util.Arrays.stream(MonthClosingCacheWarmupTimer.class.getDeclaredFields())
+                .noneMatch(field -> ExecutorService.class.isAssignableFrom(field.getType())
+                        || MonthClosingService.class.isAssignableFrom(field.getType())));
+    }
+
+    @Test
+    void historicalMethodsMustNotCreateWarmupThread() {
+        MonthClosingCacheWarmupTimer timer = new MonthClosingCacheWarmupTimer();
+        timer.start();
+        timer.warmUpClosingStatusCache();
+        timer.stop();
+        assertTrue(Thread.getAllStackTraces().keySet().stream()
+                .noneMatch(thread -> thread.getName().equals("month-closing-cache-1")));
+    }
+
+    @Test
+    void onlyOldPageEndpointAndWarmupAreDeprecated() throws NoSuchMethodException {
+        Method status = MonthClosingController.class.getMethod("status",
+                MonthClosingCheckRequest.class, HttpServletResponse.class);
+        assertTrue(status.isAnnotationPresent(Deprecated.class));
+        assertArrayEquals(new String[]{"/status"}, status.getAnnotation(PostMapping.class).value());
+        Method validate = MonthClosingController.class.getMethod("validate",
+                MonthClosingCheckRequest.class, HttpServletResponse.class);
+        assertFalse(validate.isAnnotationPresent(Deprecated.class));
+        assertArrayEquals(new String[]{"/validate"}, validate.getAnnotation(PostMapping.class).value());
+        assertTrue(MonthClosingService.class.getMethod("warmUpClosingStatusCache")
+                .isAnnotationPresent(Deprecated.class));
+        assertFalse(MonthClosingService.class.isAnnotationPresent(Deprecated.class));
+        assertFalse(MonthClosingService.class.getMethod("status", MonthClosingCheckRequest.class)
+                .isAnnotationPresent(Deprecated.class));
+        assertFalse(MonthClosingService.class.getMethod("isLocked", String.class)
+                .isAnnotationPresent(Deprecated.class));
+    }
+}

Fichier diff supprimé car celui-ci est trop grand
+ 27 - 3
pages/README.md


Fichier diff supprimé car celui-ci est trop grand
+ 468 - 262
pages/src/timecard-change.oyd.jsx


Fichier diff supprimé car celui-ci est trop grand
+ 4404 - 0
pages/src/timecard-main.actions.js


+ 683 - 0
pages/src/timecard-main.render.jsx

@@ -0,0 +1,683 @@
+function render() {
+  // prd 月度工时填报页面 — JSX 渲染层
+  // 版本:v2.0 | 日期:2026-03-05
+
+  if (!window._this || !window._this.state || !window._this.state.selectedYear) {
+    var bootText = (window._this && window._this.getTexts) ? window._this.getTexts().loading : 'loading...';
+    return <div style={{ padding: 40, textAlign: 'center', color: '#888' }}>{bootText}</div>;
+  }
+
+  const _this = window._this;
+  const T = _this.getTexts();
+  const {
+    selectedYear, selectedMonth,
+    projectRows = [],
+    billableData = {}, nonBillableData = {}, otherData = {},
+    hourDetailMap = { billable: {}, nonBillable: {}, other: {} },
+    cellStatusMap = {},
+    lockedDays = {},
+    userInfo = {},
+    userId = '',
+    isExternalUser = false,
+    isViewMode = false,
+    isExternalStaff = false,
+    personnelTypeVerified = false,
+    isRestrictedExternalProjectMonth = false,
+    isMobile = false,
+    holidayMap = {},
+    inputDefaultVersion = 0,
+  } = _this.state;
+  const normalTimecardBlocked = !personnelTypeVerified || isExternalStaff;
+  const normalBlockMessage = !personnelTypeVerified ? T.personnelTypeCheckFailed
+    : isExternalStaff ? T.externalStaffTimecardBlocked
+    : isRestrictedExternalProjectMonth ? T.restrictedExternalProjectMonth : '';
+
+  // prd 其他工时分类(与 JS 常量保持一致)
+  const OTHER_CATEGORIES = [
+    { name: 'Unassigned' }, { name: 'Annual Leave' }, { name: 'Sick Leave' },
+    { name: 'Personal Leave' }, { name: 'Time off in lieu of overtime' },
+    { name: 'Marketing' }, { name: 'Business Development' }, { name: 'Training' },
+    { name: 'Recruiting' }, { name: 'Internal Project' },
+    { name: 'Administrative Activities' }, { name: 'Travel non billable' }, { name: 'Others' },
+  ];
+
+  // ── 用户信息 ─────────────────────────────────────────────
+  const loginUser = window.loginUser || {};
+  // prd URL 指定成员时只显示该成员信息,档案缺失也不回退到当前登录人。
+  const userName = userInfo.userName || (isExternalUser ? (userInfo.userId || userId || '—') : (loginUser.userName || '—'));
+  const deptName = userInfo.deptName || (isExternalUser ? '—' : (loginUser.deptName || '—'));
+  const workNo = userInfo.workNo || (isExternalUser ? '—' : (loginUser.businessWorkNo || '—'));
+
+  const MONTH_NAMES = T.monthNames;
+  const WD = T.weekdays;
+
+  // ── 日期与周分组 ─────────────────────────────────────────
+  const daysInMonth = new Date(selectedYear, selectedMonth, 0).getDate();
+  const allDays = [];
+  for (let d = 1; d <= daysInMonth; d++) {
+    const dt = new Date(selectedYear, selectedMonth - 1, d);
+    const dow = dt.getDay();
+    // prd 综合节假日判断:调休→休息日,加班→工作日,未维护→原逻辑
+    const hType = holidayMap[d];
+    const isNaturalWe = dow === 0 || dow === 6;
+    const isDayOff = hType === '调休' ? true : hType === '加班' ? false : isNaturalWe;
+    allDays.push({ day: d, dow, wl: WD[dow], isWe: isDayOff, holidayType: hType || null });
+  }
+
+  const weeks = [];
+  let cw = [];
+  allDays.forEach((d) => { cw.push(d); if (d.dow === 0) { weeks.push(cw); cw = []; } });
+  if (cw.length > 0) weeks.push(cw);
+
+  // ── 布局常量 ─────────────────────────────────────────────
+  // prd 移动端缩窄首列与日期列,让一屏可视面积更合理;输入区域同步加高(详见 inpS)
+  const FW = isMobile ? 128 : 220;
+  const DW = isMobile ? 42 : 44;
+  const HW = isMobile ? 46 : 52;
+  const totalDataCols = weeks.reduce((s, w) => s + w.length + 1, 0) + 1;
+
+  // ── 工具函数 ─────────────────────────────────────────────
+  const fmt = (n) => (n > 0 ? n : '');
+  const gv = (data, ri, day) => {
+    const v = data[ri] && data[ri][day];
+    return v !== undefined ? v : '';
+  };
+
+  // prd 单元格编辑状态:section_rowIndex_day
+  // prd 周末可编辑(加班工时),不再返回 'weekend' 禁用
+  const editSt = (section, ri, day, isWe) => {
+    if (lockedDays[day]) return lockedDays[day];
+    // prd 入职当日可填,入职日期之前的所有工时区域禁止填写
+    if (_this.isBeforeEmploymentDate(selectedYear, selectedMonth, day)) return 'preEmployment';
+    // prd 指定项目的外部成员,正常 TimeCard 整月不可填写
+    if (normalTimecardBlocked || isRestrictedExternalProjectMonth) return 'disabled';
+    // prd 外部人员:非项目工时和其他工时不可编辑
+    if (isExternalStaff && (section === 'nonBillable' || section === 'other')) return 'disabled';
+    // prd 成员下线:billable/nonBillable 行 day > offlineDay 的单元格返回 offline(灰底 + 不可编辑)
+    if (section === 'billable' || section === 'nonBillable') {
+      const proj = projectRows[ri] || {};
+      const key = section + '_' + ri + '_' + day;
+      const st = cellStatusMap[key];
+      if (st === 'approved') return 'approved';
+      if (st === 'submitted') return 'submitted';
+      if (proj.lockedAllDays || proj.isHistoryOnly) return 'offline';
+      if (proj.offlineDay != null && day > proj.offlineDay) return 'offline';
+    }
+    const key = section + '_' + ri + '_' + day;
+    const st = cellStatusMap[key];
+    if (st === 'approved') return 'approved';
+    if (st === 'submitted') return 'submitted';
+    return 'editable';
+  };
+
+  const cellBg = (es) => ({
+    approved: '#fffbe6', submitted: '#e6f7ff', disabled: '#f0f0f0',
+    offline: '#f0f0f0', preEmployment: '#f0f0f0',
+  }[es] || '#fff');
+
+  // ── 计算 ─────────────────────────────────────────────────
+  // fixme 所有取值统一用 safeNum 兼容 null/undefined/NaN
+  const safeNum = (v) => { const n = parseFloat(v); return isNaN(n) ? 0 : n; };
+  const rowSum = (data, ri) => {
+    const r = data[ri] || {};
+    return Object.keys(r).reduce((s, d) => s + safeNum(r[d]), 0);
+  };
+  const rowWkSum = (data, ri, wk) => {
+    const r = data[ri] || {};
+    return wk.reduce((s, d) => s + safeNum(r[d.day]), 0);
+  };
+  const secDay = (data, day) =>
+    Object.keys(data).reduce((s, r) => s + safeNum(data[r] && data[r][day]), 0);
+  const secWk = (data, wk) => wk.reduce((s, d) => s + secDay(data, d.day), 0);
+  const secMonth = (data) =>
+    Object.keys(data).reduce((s, r) => {
+      const row = data[r] || {};
+      return s + Object.keys(row).reduce((rs, d) => rs + safeNum(row[d]), 0);
+    }, 0);
+  const dayGrand = (day) => secDay(billableData, day) + secDay(nonBillableData, day) + secDay(otherData, day);
+  const wkGrand = (wk) => wk.reduce((s, d) => s + dayGrand(d.day), 0);
+  const detailFor = (section, ri, day) => {
+    const rows = hourDetailMap[section] || {};
+    return (rows[ri] && rows[ri][day]) || { approved: 0, change: 0 };
+  };
+  const selectDisplayBase = (approved, submitted, draft, change) => {
+    // prd 与动作层一致,审批/变更存在时基数只取已审批,不混入暂存或已提交。
+    if (approved !== 0 || change !== 0) return approved;
+    return approved + change > 0 ? approved : (submitted > 0 ? submitted : draft);
+  };
+  const selectDisplayBaseStatus = (approved, submitted, draft, change) => {
+    if (approved !== 0 || change !== 0) return 'approved';
+    return approved + change > 0 ? 'approved' : (submitted > 0 ? 'submitted' : (draft > 0 ? 'draft' : ''));
+  };
+  const detailSum = (section, ri, days) => {
+    const rows = hourDetailMap[section] || {};
+    const rowIndexes = ri === undefined ? Object.keys(rows) : [String(ri)];
+    return rowIndexes.reduce((result, rowIndex) => {
+      const row = rows[rowIndex] || {};
+      days.forEach((day) => {
+        const detail = row[day.day || day] || {};
+        const approved = safeNum(detail.approved);
+        const submitted = safeNum(detail.submitted);
+        const draft = safeNum(detail.draft);
+        const change = safeNum(detail.change);
+        const base = selectDisplayBase(approved, submitted, draft, change);
+        const baseStatus = selectDisplayBaseStatus(approved, submitted, draft, change);
+        result.summary += base;
+        result.change += change;
+        if (baseStatus === 'approved' || (baseStatus === 'submitted' && result.baseStatus !== 'approved')
+          || (baseStatus === 'draft' && !result.baseStatus)) {
+          result.baseStatus = baseStatus;
+        }
+      });
+      return result;
+    }, { summary: 0, change: 0, baseStatus: '' });
+  };
+  const allDetailSum = (days) => ['billable', 'nonBillable', 'other'].reduce((result, section) => {
+    const sectionTotal = detailSum(section, undefined, days);
+    result.summary += sectionTotal.summary;
+    result.change += sectionTotal.change;
+    if (sectionTotal.baseStatus === 'approved' || (sectionTotal.baseStatus === 'submitted' && result.baseStatus !== 'approved')
+      || (sectionTotal.baseStatus === 'draft' && !result.baseStatus)) {
+      result.baseStatus = sectionTotal.baseStatus;
+    }
+    return result;
+  }, { summary: 0, change: 0, baseStatus: '' });
+  const monthDays = allDays.map((d) => d.day);
+  const monthDetailTotal = allDetailSum(monthDays);
+  const monthGrandTotal = monthDetailTotal.summary + monthDetailTotal.change;
+  const formatHours = (n) => {
+    const value = safeNum(n);
+    return value === 0 ? '0' : String(Number(value.toFixed(2)));
+  };
+  const formatPair = (base, change) => {
+    const baseText = formatHours(base);
+    const changeText = formatHours(change);
+    return change !== 0 ? baseText + '/' + changeText : baseText;
+  };
+  const formatStoredPair = (detail) => {
+    const approved = safeNum(detail && detail.approved);
+    const submitted = safeNum(detail && detail.submitted);
+    const draft = safeNum(detail && detail.draft);
+    const change = safeNum(detail && detail.change);
+    const base = selectDisplayBase(approved, submitted, draft, change);
+    return base !== 0 || change !== 0 ? formatPair(base, change) : '';
+  };
+  const formatAggregate = (fallback, detail) => {
+    if (detail.summary !== 0 || detail.change !== 0) return formatPair(detail.summary, detail.change);
+    return fmt(fallback);
+  };
+  // prd 含「工时/变更」的值略缩小字号,避免拆分后在窄列中显得拥挤
+  const renderHourText = (value, fontSize, baseStatus) => {
+    const text = value === undefined || value === null ? '' : String(value);
+    const pairSize = text.indexOf('/') >= 0 ? Math.max(8, fontSize - 1) : fontSize;
+    if (text.indexOf('/') < 0) return <span style={{ fontSize: pairSize }}>{text}</span>;
+    const parts = text.split('/');
+    const baseColor = baseStatus === 'draft' ? '#1a1a1a' : (baseStatus === 'submitted' ? '#1677ff' : '');
+    return (
+      <span style={{ fontSize: pairSize }}>
+        <span style={baseColor ? { color: baseColor } : {}}>{parts[0]}</span>
+        /
+        <span>{parts.slice(1).join('/')}</span>
+      </span>
+    );
+  };
+  const cellDisplay = (section, ri, day, value, status) => {
+    if (status === 'approved') return formatStoredPair(detailFor(section, ri, day));
+    return fmt(value);
+  };
+
+  // ── 样式 ─────────────────────────────────────────────────
+  const btnS = (p, disabled) => ({
+    display: 'inline-flex', alignItems: 'center', gap: 4,
+    padding: '6px 16px', borderRadius: 6, fontSize: 13, cursor: disabled ? 'not-allowed' : 'pointer',
+    border: p ? 'none' : '1px solid #d9d9d9',
+    background: disabled ? '#f5f5f5' : (p ? '#d4380d' : '#fff'),
+    color: disabled ? '#bfbfbf' : (p ? '#fff' : '#333'),
+    fontWeight: p ? 600 : 400,
+    opacity: disabled ? 0.72 : 1,
+  });
+
+  // prd 全局样式:
+  //   1. 隐藏 number input 上下箭头(避免移动端误触)
+  //   2. 滚动容器开启 iOS 惯性滚动 + 双向 pan
+  //   3. 输入框统一 touch-action: manipulation,去掉 300ms 点击延迟
+  //   4. 输入框保持与桌面端一致的字号和高度,避免移动端显示被放大
+  const hideSpinnerCSS =
+    'input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}' +
+    'input[type=number]{-moz-appearance:textfield}' +
+    '.tc-scroll{-webkit-overflow-scrolling:touch;overflow-x:auto;overflow-y:visible;touch-action:pan-x pan-y;}' +
+    '.tc-input{touch-action:manipulation;-webkit-tap-highlight-color:transparent;}' +
+    '.tc-input:focus{border-color:#1677ff !important;}' +
+    '@media (max-width:768px){.tc-header-bar{flex-wrap:wrap;gap:8px;padding:12px 14px !important;}.tc-header-actions button{padding:8px 14px !important;font-size:14px !important;}.tc-info-grid{grid-template-columns:repeat(2,1fr) !important;gap:10px 16px !important;padding:12px 14px !important;}.tc-rules-bar{padding:10px 14px !important;}.tc-rules-status{flex-wrap:wrap;gap:8px !important;}}';
+
+  const inpS = (es) => ({
+    // prd 输入框统一保持 36×24,移动端不放大字体
+    width: 36, height: 24, textAlign: 'center',
+    border: '1px solid ' + (es === 'editable' ? '#d9d9d9' : 'transparent'),
+    borderRadius: 3, fontSize: 12, outline: 'none',
+    background: cellBg(es), color: es === 'editable' ? '#1a1a1a' : '#999',
+    cursor: es === 'editable' ? 'text' : 'not-allowed', padding: 0,
+    MozAppearance: 'textfield',
+  });
+
+  const fixTd = (bg) => ({
+    padding: '5px 8px', borderBottom: '1px solid #f0f0f0', borderRight: '1px solid #e8e8e8',
+    verticalAlign: 'middle', position: 'sticky', left: 0, zIndex: 2,
+    background: bg || '#fff', overflow: 'hidden', textOverflow: 'ellipsis',
+    whiteSpace: 'nowrap', width: FW, minWidth: FW, fontSize: 12,
+  });
+
+  const dayTd = (isWe, err, offline, status) => ({
+    padding: 1, textAlign: 'center', borderBottom: '1px solid #f0f0f0',
+    borderRight: '1px solid #f5f5f5', minWidth: DW,
+    background: offline ? '#f0f0f0' : (status && status !== 'editable'
+      ? cellBg(status)
+      : (isWe ? '#f0f0f0' : (err ? '#fff2f0' : '#fff'))),
+  });
+
+  const hrsTd = (bg) => ({
+    padding: '4px 2px', textAlign: 'center', fontWeight: 700, fontSize: 12,
+    borderBottom: '1px solid #e8e8e8', borderRight: '2px solid #d9d9d9',
+    background: bg || '#f0f5ff', color: '#2f54eb', minWidth: HW,
+  });
+
+  // ── Wk分组行 + 日期列头行 ────────────────────────────────
+  // fixme 宜搭环境无 React.Fragment,改用数组返回多行
+  const renderWkAndDayHeaders = () => [
+    <tr key="wk_row">
+      <td style={{
+        padding: '4px 8px', fontSize: 11, fontWeight: 600, color: '#595959',
+        background: '#fff', borderBottom: '1px solid #d9d9d9', borderRight: '1px solid #e8e8e8',
+        position: 'sticky', left: 0, zIndex: 3, width: FW, minWidth: FW,
+      }}>&nbsp;</td>
+      {weeks.map((wk, wi) => (
+        <td key={'wk_' + wi} colSpan={wk.length + 1} style={{
+          padding: '3px 0', textAlign: 'center', fontSize: 11, fontWeight: 700,
+          background: '#fff', color: '#2B5173',
+          borderBottom: '1px solid #d9d9d9', borderRight: '2px solid #e8e8e8',
+        }}>Wk{wi + 1}</td>
+      ))}
+      <td style={{
+        padding: '3px 2px', textAlign: 'center', fontSize: 10, fontWeight: 700,
+        background: '#fff', color: '#2B5173', borderBottom: '1px solid #d9d9d9',
+      }}>Hours</td>
+    </tr>,
+    <tr key="day_row">
+      <td style={{
+        padding: '4px 8px', fontSize: 11, fontWeight: 600, color: '#595959',
+        background: '#f5f5f5', borderBottom: '2px solid #e8e8e8', borderRight: '1px solid #e8e8e8',
+        position: 'sticky', left: 0, zIndex: 3, width: FW, minWidth: FW,
+      }}>{T.colProjectCategory}</td>
+      {weeks.map((wk, wi) => {
+        const cells = wk.map((d) => {
+          // prd 加班日(补班)用蓝色主题,调休和原周末用红色主题
+          const isWork = d.holidayType === '加班';
+          const bgColor = isWork ? '#e6f0ff' : (d.isWe ? '#fff1f0' : '#fafafa');
+          const txColor = isWork ? '#3389FF' : (d.isWe ? '#cf1322' : '#595959');
+          return (
+            <td key={d.day} style={{
+              padding: '2px 1px', textAlign: 'center', fontSize: 10, fontWeight: 600,
+              background: bgColor, color: txColor,
+              borderBottom: '2px solid #e8e8e8', borderRight: '1px solid #f0f0f0', minWidth: DW,
+            }}>
+              <div style={{ fontSize: 13, fontWeight: 700, lineHeight: 1.1 }}>{d.day}</div>
+              <div style={{ fontSize: 9, lineHeight: 1.1 }}>{d.wl}</div>
+              {d.isWe && !d.holidayType && <div style={{ fontSize: 8, color: '#cf1322', lineHeight: 1 }}>{T.dayOff}</div>}
+              {d.holidayType === '调休' && <div style={{ fontSize: 8, color: '#cf1322', lineHeight: 1 }}>{T.dayOff}</div>}
+              {d.holidayType === '加班' && <div style={{ fontSize: 8, color: '#3389FF', lineHeight: 1 }}>{T.dayOT}</div>}
+            </td>
+          );
+        });
+        cells.push(
+          <td key={'h_' + wi} style={{
+            padding: '2px 2px', textAlign: 'center', fontSize: 10, fontWeight: 700,
+            background: '#e8ecf8', color: '#2f54eb',
+            borderBottom: '2px solid #e8e8e8', borderRight: '2px solid #d9d9d9', minWidth: HW,
+          }}>Hours</td>
+        );
+        return cells;
+      })}
+      <td style={{
+        padding: '2px 2px', textAlign: 'center', fontSize: 9, fontWeight: 700,
+        background: '#e8ecf8', color: '#1d39c4', borderBottom: '2px solid #e8e8e8',
+      }}>Subtotal</td>
+    </tr>,
+  ];
+
+  // ── 数据行渲染 ──────────────────────────────────────────
+  const renderDataRow = (key, section, label, data, ri, bg) => (
+    <tr key={key} style={{ background: bg || '#fff' }}>
+      <td style={fixTd(bg)}>{label}</td>
+      {weeks.map((wk, wi) => {
+        const cells = wk.map(({ day, isWe }) => {
+          const es = editSt(section, ri, day, isWe);
+          const dis = es !== 'editable';
+          const val = gv(data, ri, day);
+          const displayDetail = detailFor(section, ri, day);
+          const displayValue = cellDisplay(section, ri, day, val, es);
+          // prd 仅可编辑工作日校验不足8h标红,禁用日保持灰色状态
+          const err = !isExternalStaff && !isWe && es === 'editable'
+            && dayGrand(day) > 0 && dayGrand(day) < 8 && val > 0;
+          return (
+            <td key={day} style={dayTd(isWe, err, es === 'offline' || es === 'preEmployment', es)}>
+              {es === 'approved' ? (
+                <span style={{ display: 'inline-block', minWidth: isMobile ? 38 : 36, color: '#ad6800', fontWeight: 700 }}>
+                  {renderHourText(displayValue, 12, selectDisplayBaseStatus(
+                    safeNum(displayDetail.approved), safeNum(displayDetail.submitted),
+                    safeNum(displayDetail.draft), safeNum(displayDetail.change)
+                  ))}
+                </span>
+              ) : (
+                  <input key={selectedYear + '_' + selectedMonth + '_' + inputDefaultVersion + '_' + section + '_' + ri + '_' + day}
+                    className="tc-input"
+                    type="number" min="0" max="24" step="0.5"
+                    /* prd 移动端弹出数字键盘:inputMode=decimal(带小数点)+ pattern 兜底老安卓 */
+                    inputMode="decimal"
+                    pattern="[0-9.]*"
+                    disabled={dis} defaultValue={val}
+                    title={es === 'preEmployment' ? T.preEmploymentLocked : ''}
+                    /* ppExt 桌面 onBlur 即可;移动端键盘"完成"按钮也会触发 blur;额外 onChange 兜底输入法不触发 blur 的场景 */
+                    onChange={(e) => {
+                      if (!dis) {
+                        _this.updateCellValue(section, ri, day, e.target.value);
+                      }
+                    }}
+                    onBlur={(e) => {
+                      if (!dis) {
+                        _this.updateCellValue(section, ri, day, e.target.value);
+                        _this.$('JSX_timecard').forceUpdate();
+                      }
+                    }}
+                    style={inpS(es)} />
+                )}
+            </td>
+          );
+        });
+        const weekDetail = detailSum(section, ri, wk.map((item) => item.day));
+        cells.push(<td key={'wh_' + wi} style={hrsTd()}>{renderHourText(formatAggregate(
+          rowWkSum(data, ri, wk), weekDetail
+        ), 12)}</td>);
+        return cells;
+      })}
+      {(() => {
+        const monthDetail = detailSum(section, ri, monthDays);
+        return <td style={{ ...hrsTd('#e8ecf8'), fontSize: 13, color: '#1d39c4' }}>{renderHourText(formatAggregate(
+          rowSum(data, ri), monthDetail
+        ), 13)}</td>;
+      })()}
+    </tr>
+  );
+
+  // ── 小计行渲染 ──────────────────────────────────────────
+  const renderSubRow = (label, data, bg, lc, section) => {
+    const mt = secMonth(data);
+    return (
+      <tr style={{ background: bg }}>
+        <td style={{ ...fixTd(bg), fontWeight: 700, fontSize: 12, color: lc }}>{label}</td>
+        {weeks.map((wk, wi) => {
+          const cells = wk.map(({ day, isWe }) => {
+            const n = secDay(data, day);
+            const dayDetail = detailSum(section, undefined, [day]);
+            return (
+              <td key={day} style={{
+                padding: '4px 2px', textAlign: 'center', fontWeight: 700, fontSize: 12,
+                color: n > 0 ? lc : '#ccc', background: isWe ? '#f0f0f0' : bg,
+                borderBottom: '1px solid #e8e8e8', borderRight: '1px solid #f0f0f0',
+              }}>{renderHourText(formatAggregate(n, dayDetail), 12)}</td>
+            );
+          });
+          const ws = secWk(data, wk);
+          const weekDetail = detailSum(section, undefined, wk.map((item) => item.day));
+          cells.push(<td key={'ws_' + wi} style={{ ...hrsTd(bg), color: ws > 0 ? lc : '#ccc' }}>{renderHourText(formatAggregate(
+            ws, weekDetail
+          ), 12)}</td>);
+          return cells;
+        })}
+        {(() => {
+          const monthDetail = detailSum(section, undefined, monthDays);
+          return <td style={{ ...hrsTd(bg), color: mt > 0 ? lc : '#ccc', fontSize: 13 }}>{renderHourText(formatAggregate(
+            mt, monthDetail
+          ), 13)}</td>;
+        })()}
+      </tr>
+    );
+  };
+
+  // ── 区域标题行 ──────────────────────────────────────────
+  const renderSecHdr = (label, bgColor) => (
+    <tr>
+      {/* fixme colSpan 全宽 td 上的 sticky 无效(包含块=整行,无横向移动空间),标题文字须包内层 sticky div 才能左右滚动时锁定 */}
+      <td colSpan={1 + totalDataCols} style={{
+        padding: 0, fontWeight: 700, fontSize: 12,
+        background: bgColor, color: '#fff',
+        borderBottom: '1px solid ' + bgColor, letterSpacing: 0.5,
+      }}>
+        <div style={{ position: 'sticky', left: 0, display: 'inline-block', padding: '6px 14px' }}>{label}</div>
+      </td>
+    </tr>
+  );
+
+  // ══════════════════════════════════════════════════════════
+  // 主渲染
+  // ══════════════════════════════════════════════════════════
+  return (
+    <div style={{
+      fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif',
+      fontSize: 13, background: '#f0f2f5', minHeight: '100vh',
+    }}>
+      <style dangerouslySetInnerHTML={{ __html: hideSpinnerCSS }} />
+
+      {/* ══ 顶部:标题 + 按钮 ══ */}
+      <div style={{ background: '#fff', boxShadow: '0 1px 6px rgba(0,0,0,0.08)', marginBottom: 8 }}>
+        <div className="tc-header-bar" style={{
+          display: 'flex', justifyContent: 'space-between', alignItems: 'center',
+          padding: '18px 24px', borderBottom: '1px solid #f0f0f0',
+        }}>
+          <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
+            <h1 style={{ fontSize: isMobile ? 17 : 22, fontWeight: 700, margin: 0, letterSpacing: -0.5 }}>
+              Timecard – {selectedYear} {MONTH_NAMES[selectedMonth - 1]}
+            </h1>
+            <button style={btnS(false, false)} onClick={() => _this.prevMonth()}>◀</button>
+            <button style={btnS(false, false)} onClick={() => _this.nextMonth()}>▶</button>
+          </div>
+          {/* prd URL同时含userId和month参数时为查看模式,隐藏操作按钮 */}
+          {!isViewMode && (
+            <div className="tc-header-actions" style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
+              {(() => {
+                const actionDisabled = _this.state._checkingMonthClosed
+                  || _this.state.isMonthClosed
+                  || normalTimecardBlocked
+                  || isRestrictedExternalProjectMonth
+                  || _this.state._savingDraft
+                  || _this.state._submitting;
+                return [
+                  <button key="save" style={btnS(false, actionDisabled)} disabled={actionDisabled} onClick={() => _this.saveDraft()}>💾 {T.btnSaveDraft}</button>,
+                  <button key="submit" style={btnS(true, actionDisabled)} disabled={actionDisabled} onClick={() => _this.submitForApproval()}>📤 {T.btnSubmit}</button>,
+                ];
+              })()}
+            </div>
+          )}
+        </div>
+
+        {normalBlockMessage && (
+          <div style={{
+            padding: '10px 24px', background: '#fff2f0', color: '#a8071a',
+            borderBottom: '1px solid #ffccc7', fontSize: 13, fontWeight: 600, lineHeight: 1.6,
+          }}>
+            {normalBlockMessage}
+          </div>
+        )}
+
+        {/* 员工信息 — prd 移动端 4列 → 2列(CSS @media 覆盖) */}
+        <div className="tc-info-grid" style={{
+          display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)',
+          gap: '0 24px', padding: '16px 24px',
+        }}>
+          <div>
+            <div style={{ fontSize: 11, color: '#8c8c8c', marginBottom: 3 }}>{T.empName}</div>
+            <div style={{ fontWeight: 600, fontSize: 14 }}>{userName}</div>
+          </div>
+          <div>
+            <div style={{ fontSize: 11, color: '#8c8c8c', marginBottom: 3 }}>{T.empWorkNo}</div>
+            <div style={{ fontWeight: 600, fontSize: 14 }}>{workNo}</div>
+          </div>
+          <div>
+            <div style={{ fontSize: 11, color: '#8c8c8c', marginBottom: 3 }}>{T.empDept}</div>
+            <div style={{ fontWeight: 600, fontSize: 14 }}>{deptName}</div>
+          </div>
+          <div className="tc-month-summary-grid" style={{
+            display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
+            gap: '0 24px', minWidth: 0,
+          }}>
+            <div>
+              <div style={{ fontSize: 11, color: '#8c8c8c', marginBottom: 3, whiteSpace: 'nowrap' }}>{T.monthSummaryTotal}</div>
+              <div style={{ fontWeight: 700, fontSize: 16, color: '#1677ff' }}>{monthDetailTotal.summary !== 0 ? formatHours(monthDetailTotal.summary) + 'h' : '—'}</div>
+            </div>
+            <div>
+              <div style={{ fontSize: 11, color: '#8c8c8c', marginBottom: 3, whiteSpace: 'nowrap' }}>{T.monthChangeTotal}</div>
+              <div style={{ fontWeight: 700, fontSize: 16, color: '#ad6800' }}>{monthDetailTotal.change !== 0 ? formatHours(monthDetailTotal.change) + 'h' : '—'}</div>
+            </div>
+            <div>
+              <div style={{ fontSize: 11, color: '#8c8c8c', marginBottom: 3, whiteSpace: 'nowrap' }}>{T.monthGrandTotal}</div>
+              <div style={{ fontWeight: 700, fontSize: 16, color: '#262626' }}>{monthGrandTotal !== 0 ? formatHours(monthGrandTotal) + 'h' : '—'}</div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      {/* ══ 填报规则 + 工时状态 ══ */}
+      <div className="tc-rules-bar" style={{
+        background: '#fffbe6', padding: '10px 24px', fontSize: 12, color: '#876800',
+        borderTop: '1px solid #ffe58f', borderBottom: '1px solid #ffe58f', marginBottom: 12,
+      }}>
+        <div style={{ marginBottom: 6 }}>
+          <strong>{T.rulesTitle}</strong>
+          {T.ruleWorkday}<strong>8h</strong>
+          &nbsp;&nbsp;{T.ruleHoliday}<strong>36h</strong>
+        </div>
+        <div className="tc-rules-status" style={{ display: 'flex', alignItems: 'center', gap: 16, color: '#595959', flexWrap: 'wrap' }}>
+          <strong style={{ color: '#876800' }}>{T.statusTitle}</strong>
+          <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
+            <span style={{ display: 'inline-block', width: 14, height: 14, borderRadius: 2, background: '#fff', border: '1px solid #d9d9d9' }} />
+            {T.statusEditable}
+          </span>
+          <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
+            <span style={{ display: 'inline-block', width: 14, height: 14, borderRadius: 2, background: '#e6f7ff', border: '1px solid #91d5ff' }} />
+            {T.statusSubmitted}
+          </span>
+          <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
+            <span style={{ display: 'inline-block', width: 14, height: 14, borderRadius: 2, background: '#fffbe6', border: '1px solid #ffe58f' }} />
+            {T.statusApproved}
+          </span>
+          <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
+            <span style={{ display: 'inline-block', width: 14, height: 14, borderRadius: 2, background: '#f0f0f0', border: '1px solid #d9d9d9' }} />
+            {T.statusReadonly}
+          </span>
+        </div>
+      </div>
+
+      {/* ══ 主表格 — prd 滚动容器加 tc-scroll 启用 iOS 惯性滚动 + touch-action ══ */}
+      <div className="tc-scroll" style={{
+        background: '#fff',
+        boxShadow: '0 1px 6px rgba(0,0,0,0.08)', borderRadius: 8, position: 'relative',
+      }}>
+        <table style={{
+          borderCollapse: 'collapse', tableLayout: 'fixed',
+          minWidth: FW + weeks.reduce((s, w) => s + w.length * DW + HW, 0) + HW,
+        }}>
+          <colgroup>
+            <col style={{ width: FW }} />
+            {weeks.map((wk, wi) => {
+              const cols = wk.map((_, di) => <col key={wi + '_' + di} style={{ width: DW }} />);
+              cols.push(<col key={'wh_' + wi} style={{ width: HW }} />);
+              return cols;
+            })}
+            <col style={{ width: HW }} />
+          </colgroup>
+
+          <tbody>
+            {/* ════ 项目工时 Project Hours ════ */}
+            {renderSecHdr(T.sectionBillable, '#2B5173')}
+            {renderWkAndDayHeaders()}
+            {projectRows.length === 0 && (
+              <tr><td colSpan={1 + totalDataCols} style={{ padding: '8px 20px', color: '#bbb', fontSize: 12, fontStyle: 'italic' }}>{T.noProjectData}</td></tr>
+            )}
+            {projectRows.map((row, ri) =>
+              renderDataRow('b_' + ri, 'billable',
+                <span>
+                  <span style={{ color: '#8c8c8c', marginRight: 6, fontFamily: 'monospace', fontSize: 11 }}>{row.code || ''}</span>
+                  {row.name || ''}
+                </span>,
+                billableData, ri)
+            )}
+            {renderSubRow('Sub total (Billable)', billableData, '#e6f7ff', '#1677ff', 'billable')}
+
+            {/* ════ 非项目工时 Non-Project Hours ════ */}
+            {renderSecHdr(T.sectionNonBillable, '#2B5173')}
+            {renderWkAndDayHeaders()}
+            {projectRows.length === 0 && (
+              <tr><td colSpan={1 + totalDataCols} style={{ padding: '8px 20px', color: '#bbb', fontSize: 12, fontStyle: 'italic' }}>{T.noProjectData}</td></tr>
+            )}
+            {projectRows.map((row, ri) =>
+              renderDataRow('nb_' + ri, 'nonBillable',
+                <span>
+                  <span style={{ color: '#8c8c8c', marginRight: 6, fontFamily: 'monospace', fontSize: 11 }}>{row.code || ''}</span>
+                  {row.name || ''}
+                </span>,
+                nonBillableData, ri)
+            )}
+            {renderSubRow('Sub total (Non Billable)', nonBillableData, '#fff7e6', '#d46b08', 'nonBillable')}
+
+            {/* ════ 其他工时 Other Activities ════ */}
+            {renderSecHdr(T.sectionOther, '#2B5173')}
+            {renderWkAndDayHeaders()}
+            {OTHER_CATEGORIES.map((cat, ri) =>
+              renderDataRow('o_' + ri, 'other',
+                <span>{cat.name}</span>,
+                otherData, ri)
+            )}
+            {renderSubRow('Sub total (Other)', otherData, '#f9f0ff', '#531dab', 'other')}
+
+            {/* ════ Hours Summary / Change ════ */}
+            <tr style={{ background: '#141414' }}>
+              <td style={{
+                padding: '8px 14px', fontWeight: 700, fontSize: 14, color: '#fff',
+                position: 'sticky', left: 0, background: '#141414',
+                borderTop: '2px solid #333', zIndex: 2, width: FW, minWidth: FW,
+              }}>Hours / Change</td>
+              {weeks.map((wk, wi) => {
+                const cells = wk.map(({ day, isWe }) => {
+                  const n = dayGrand(day);
+                  const dayDetail = allDetailSum([day]);
+                  return (
+                    <td key={day} style={{
+                      padding: '7px 1px', textAlign: 'center', fontWeight: 700, fontSize: 12,
+                      color: n > 0 ? '#fff' : '#444',
+                      background: isWe ? '#1a1a1a' : '#141414', borderTop: '2px solid #333', borderRight: '1px solid #333',
+                    }}>{renderHourText(formatAggregate(n, dayDetail), 12)}</td>
+                  );
+                });
+                const wt = wkGrand(wk);
+                const weekDetail = allDetailSum(wk.map((item) => item.day));
+                cells.push(
+                  <td key={'gw_' + wi} style={{
+                    padding: '7px 2px', textAlign: 'center', fontWeight: 700, fontSize: 13,
+                    color: wt > 0 ? '#ffd666' : '#444',
+                    background: '#1a1a1a', borderTop: '2px solid #333', borderRight: '2px solid #333',
+                  }}>{renderHourText(formatAggregate(wt, weekDetail), 13)}</td>
+                );
+                return cells;
+              })}
+              <td style={{
+                padding: '7px 4px', textAlign: 'center', fontWeight: 700, fontSize: 15,
+                color: '#ffd666', background: '#141414', borderTop: '2px solid #333',
+              }}>{renderHourText(monthDetailTotal.summary !== 0 || monthDetailTotal.change !== 0
+                ? formatPair(monthDetailTotal.summary, monthDetailTotal.change)
+                : '—', 15)}</td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+  );
+}

+ 276 - 34
pages/src/timecard-repair-lifecycle.oyd.jsx

@@ -1447,9 +1447,20 @@ export function loadBeisenLeaveDefaults(userId, year, month, requestKey) {
  */
 export function loadTimecardData(userId, year, month, repairContext) {
   var monthStr = this.getRepairMonthText(year, month);
+  const loadedType = (this.state.userInfo || {}).staffType || '';
+  if (repairContext && repairContext.staffType && loadedType && repairContext.staffType !== loadedType) {
+    return Promise.reject(new Error(this.getTexts().staffTypeCheckFailed));
+  }
+  const external = repairContext && repairContext.staffType
+    ? repairContext.staffType === '外部' : this.state.isExternalStaff;
   // fixme 原生查询可能被权限过滤为空;后端完整上下文已返回时不可丢弃已有补填工时。
   if (repairContext && Array.isArray(repairContext.originalRecords) && Array.isArray(repairContext.repairRecords)) {
-    return Promise.resolve(this.prepareTimecardRecords(repairContext.originalRecords, repairContext.repairRecords));
+    return Promise.resolve(this.prepareTimecardRecords(external ? [] : repairContext.originalRecords, repairContext.repairRecords));
+  }
+  // prd 外部补填不读取原表;上下文失败时也不能回退到原表。
+  if (external) {
+    return this.queryAllTimecardRecords(userId, monthStr, 1, [], FORM_IDS.repairSummary,
+      TIME_FIELD_IDS.submitter, TIME_FIELD_IDS.monthText).then(records => this.prepareTimecardRecords([], records));
   }
   // prd 补填资格以原工时汇总为准,先查询正式汇总,再查询工时汇总 调整中的补填记录并合并展示。
   return this.queryAllTimecardRecords(userId, monthStr, 1, [], FORM_IDS.timeSummary, TIME_FIELD_IDS.submitter, TIME_FIELD_IDS.monthText).then(function (originalResult) {
@@ -1466,7 +1477,7 @@ export function loadTimecardData(userId, year, month, repairContext) {
  * @returns {Array<Object>} 可直接解析回显和整日锁定的记录
  */
 export function prepareTimecardRecords(originalResult, repairResult) {
-  var originalRecords = (originalResult || []).map(item => {
+  var originalRecords = (this.state.isExternalStaff ? [] : originalResult || []).map(item => {
     var copy = Object.assign({}, item);
     copy.formInstId = item.formInstId || item.formInstanceId || '';
     copy._timecardRecordSource = 'original';
@@ -1517,7 +1528,9 @@ export function queryAllTimecardRecords(userId, monthStr, currentPage, accumulat
       return condition;
     }())
   }).then(function (res) {
+    if (res.success === false || !Array.isArray(res.data)) throw new Error('工时查询失败,请重试');
     var list = res.data || [];
+    if (list.length === 0 && acc.length < (res.totalCount || 0)) throw new Error('工时分页数据不完整');
     acc.push.apply(acc, list);
     if (acc.length < (res.totalCount || 0)) {
       return this.queryAllTimecardRecords(userId, monthStr, page + 1, acc, formUuid, submitterField, monthField, extraSearchFields);
@@ -1722,6 +1735,8 @@ export function processTimecardRecords(records, year, month, holidayMap, loadedP
   // fixme 已提交补填记录即使子表异常缺失,也不能再自动填入8小时。
   var blockedDays = Object.assign({}, originalBlockedDays, repairBlockedDays);
   var autoFilledBillableDays = this.applyDefaultWorkdayHours(year, month, holidayMap, projectRows, billableData, nonBillableData, otherData, isViewMode === true, blockedDays);
+  const overtimeDisplayDayTotals = this.getOvertimeDisplayTotals(billableData, nonBillableData, otherData);
+  const overtimeBaseDayTotals = this.buildOvertimeBaseline(records, year, month, overtimeDisplayDayTotals, true);
   this.setState({
     projectRows: projectRows,
     projectInfoMap: projectInfoMap,
@@ -1732,6 +1747,8 @@ export function processTimecardRecords(records, year, month, holidayMap, loadedP
     baselineNonBillableData: baselineNonBillableData,
     baselineOtherData: baselineOtherData,
     overtimeChangeAdjustments: overtimeChangeAdjustments,
+    overtimeDisplayDayTotals: overtimeDisplayDayTotals,
+    overtimeBaseDayTotals: overtimeBaseDayTotals,
     cellStatusMap: cellStatusMap,
     recordMap: recordMap,
     originalBlockedDays: originalBlockedDays,
@@ -2275,6 +2292,99 @@ export function getCellEditState(section, rowIndex, day) {
  * @param {boolean} includeDraft - 是否允许回退暂存值
  * @returns {number} 正负变更对展示值的实际影响
  */
+/**
+ * 汇总当前页面三类输入,作为后续未保存输入的比较基线。
+ * @param {Object} billable - Billable输入
+ * @param {Object} nonBillable - Non Billable输入
+ * @param {Object} other - 其他输入
+ * @returns {Object.<number, number>} 每日显示合计
+ */
+export function getOvertimeDisplayTotals(billable, nonBillable, other) {
+  const totals = {};
+  [billable, nonBillable, other].forEach(data => {
+    Object.keys(data || {}).forEach(row => {
+      Object.keys(data[row] || {}).forEach(day => {
+        totals[day] = (totals[day] || 0) + (parseFloat(data[row][day]) || 0);
+      });
+    });
+  });
+  return totals;
+}
+
+/**
+ * 读取实际发生日的基础工时;三种状态择一,正负变更均不参与。
+ * @param {Object} data - 单日表单数据
+ * @returns {{hours: number, committed: boolean}} 基础工时及提交状态
+ */
+export function resolveOvertimeRecord(data) {
+  let hours = 0;
+  let committed = false;
+  [BILLABLE_SUB, NON_BILLABLE_SUB, OTHER_SUB].forEach(fields => {
+    const keys = {};
+    (data[fields.table] || []).forEach(row => {
+      const approved = parseFloat(row[fields.approved]) || 0;
+      const submitted = parseFloat(row[fields.submitted]) || 0;
+      const draft = parseFloat(row[fields.draft]) || 0;
+      const change = parseFloat(row[fields.change]) || 0;
+      const key = String(row[fields.projectCode || fields.activity] || '').trim();
+      if (!key && approved === 0 && submitted === 0 && draft === 0 && change === 0) return;
+      if (!key || keys[key]) throw new Error('工时明细缺少编号或重复,无法核实加班工时');
+      keys[key] = true;
+      committed = committed || approved !== 0 || submitted !== 0;
+      hours += approved !== 0 ? approved : (submitted > 0 ? submitted : Math.max(0, draft));
+    });
+  });
+  return { hours: hours, committed: committed };
+}
+
+/**
+ * 按实际日期选择原表或补填来源,不将冲销/调整入账当作当天工作。
+ * @param {Array<Object>} records - 标注original/repair来源的完整记录
+ * @param {number} year - 年份
+ * @param {number} month - 月份
+ * @param {Object} displayTotals - 页面首次显示的每日合计
+ * @param {boolean} repairMode - 是否使用补填页草稿
+ * @returns {Object.<number, number>} 不含变更的加班计算日基线
+ */
+export function buildOvertimeBaseline(records, year, month, displayTotals, repairMode) {
+  const monthText = String(year) + ('0' + month).slice(-2);
+  const original = {};
+  const repair = {};
+  (records || []).forEach(item => {
+    const data = item.formData || item;
+    const source = item._timecardRecordSource === 'repair' ? 'repair' : 'original';
+    const type = data.radioField_rtc81we6k || '';
+    if (source === 'repair' && type === '变更') return;
+    const dateText = String(data[TIME_FIELD_IDS.dayText] || data.textField_mokwyspz || '');
+    if (!/^\d{8}$/.test(dateText) || dateText.slice(0, 6) !== monthText) {
+      throw new Error('工时日期与月份不一致,无法核实加班工时');
+    }
+    const day = parseInt(dateText.slice(6), 10);
+    if (day < 1 || day > new Date(year, month, 0).getDate()) throw new Error('工时日期无效');
+    const value = this.resolveOvertimeRecord(data);
+    if (source === 'repair' && !type && value.hours === 0) return;
+    if (source === 'repair' && type !== '补填') throw new Error('补填记录类型不明确');
+    const target = source === 'repair' ? repair : original;
+    if (target[day]) throw new Error('同日工时记录重复,无法核实加班工时');
+    target[day] = value;
+  });
+  const totals = Object.assign({}, displayTotals || {});
+  const days = Object.assign({}, original, repair);
+  Object.keys(days).forEach(day => {
+    const normal = original[day];
+    const supplement = repair[day];
+    if (normal && normal.committed && supplement && supplement.committed) {
+      throw new Error('同日正常及补填工时均已提交,请核实后再提交');
+    }
+    const selected = supplement && !(normal && normal.committed)
+      && (supplement.committed || repairMode) ? supplement : normal;
+    if (selected) totals[day] = selected.hours;
+    else if (supplement) totals[day] = 0;
+  });
+  return totals;
+}
+
+
 export function getOvertimeChangeAdjustment(row, fields, displayedHours, includeDraft) {
   var base = this.resolveHourValue(parseFloat(row[fields.approved]) || 0, parseFloat(row[fields.submitted]) || 0, parseFloat(row[fields.draft]) || 0, 0, includeDraft);
   return displayedHours - base.value;
@@ -2286,6 +2396,11 @@ export function getOvertimeChangeAdjustment(row, fields, displayedHours, include
  * @returns {number} 排除变更后的单日工时
  */
 export function getOvertimeDayTotal(day) {
+  const base = this.state.overtimeBaseDayTotals;
+  const displayed = this.state.overtimeDisplayDayTotals;
+  if (base && displayed) {
+    return Math.max(0, (base[day] || 0) + this.getDayTotal(day) - (displayed[day] || 0));
+  }
   var adjustments = this.state.overtimeChangeAdjustments || {};
   var suffix = '_' + day;
   var adjustment = Object.keys(adjustments).reduce((total, key) => {
@@ -2541,7 +2656,7 @@ export function waitForRepairContextRetry(delayMillis) {
       resolve: resolve
     };
     entry.timerId = setTimeout(function () {
-      this._tcRepairContextPollTimers = (this._tcRepairContextPollTimers || []).filter(function (item) {
+      this._tcRepairContextPollTimers = (this._tcRepairContextPollTimers || []).filter(item => {
         return item !== entry;
       });
       resolve();
@@ -2568,6 +2683,7 @@ export function loadTimecardRepairStatus(userId, year, month) {
   this._tcRepairContextRequestSequence = (this._tcRepairContextRequestSequence || 0) + 1;
   var requestKey = String(userId || '') + '|' + monthText + '|' + this._tcRepairContextRequestSequence;
   this._tcRepairContextRequestKey = requestKey;
+  const staffType = (this.state && this.state.userInfo || {}).staffType || '';
   var buildInputs = function () {
     return {
       inputs: JSON.stringify({
@@ -2579,7 +2695,8 @@ export function loadTimecardRepairStatus(userId, year, month) {
         },
         Body: {
           submitterUserId: userId || '',
-          sourceMonthText: monthText
+          sourceMonthText: monthText,
+          staffType: staffType
         }
       })
     };
@@ -2601,6 +2718,8 @@ export function loadTimecardRepairStatus(userId, year, month) {
       }
       return {
         configured: true,
+        staffType: result.staffType || '',
+        summaryReadMode: result.summaryReadMode || '',
         originalRecords: result.originalRecords,
         repairRecords: result.repairRecords,
         records: Array.isArray(result.records) ? result.records : []
@@ -2759,40 +2878,163 @@ export async function submitTimecardRepairRequest() {
 }
 
 /**
- * 通过阿科德斯后端实时查询月份封账状态。
+ * prd 2026-10-23:页面封账查询改为宜搭原生接口,替代已废弃的getMonthClosingStatus连接器调用。
+ * ppExt 只归一化原生查询结果;原有准入函数、保存提交及后端业务封账保护保持原样。
+ * @param {number|string} year 年份
+ * @param {number|string} month 月份1-12
+ * @returns {string} 上海业务月份yyyyMM
+ */
+export function closingNativeMonthText(year, month) {
+  const yearText = String(year).trim();
+  const monthValue = String(month).trim();
+  const monthText = monthValue.length === 1 ? '0' + monthValue : monthValue;
+  if (!/^\d{4}$/.test(yearText) || !/^\d{2}$/.test(monthText)
+    || Number(yearText) < 1 || Number(monthText) < 1 || Number(monthText) > 12) {
+    throw new Error('封账查询月份无效');
+  }
+  return yearText + monthText;
+}
+
+/** @param {*} value 秒、毫秒或ISO时间 @returns {number|null} 毫秒时间戳 */
+export function closingNativeTimestamp(value) {
+  if (typeof value === 'number' && Number.isFinite(value)) {
+    return String(Math.abs(value)).length <= 10 ? value * 1000 : value;
+  }
+  const text = String(value === undefined || value === null ? '' : value).trim();
+  if (/^\d{10}$|^\d{13}$/.test(text)) return Number(text) * (text.length === 10 ? 1000 : 1);
+  if (!/^\d{4}-\d{2}-\d{2}T/.test(text)) return null;
+  const timestamp = Date.parse(text);
+  return Number.isFinite(timestamp) ? timestamp : null;
+}
+
+/** @param {Object} record 原生/已归一化流程实例 @returns {Object} 业务字段 */
+export function closingNativeRecordData(record) {
+  if (record.formData && typeof record.formData === 'object') return record.formData;
+  const data = record.data;
+  if (!data || typeof data !== 'object') return {};
+  return data.formData && typeof data.formData === 'object' ? data.formData : data;
+}
+
+/** @param {*} value 月份字段 @returns {string} yyyyMM或空串 */
+export function closingNativeRecordMonth(value) {
+  const raw = String(value === undefined || value === null ? '' : value).trim();
+  if (/^\d{6}$|^\d{8}$/.test(raw)) {
+    return this.closingNativeMonthText(raw.substring(0, 4), raw.substring(4, 6));
+  }
+  const timestamp = this.closingNativeTimestamp(value);
+  if (timestamp === null) return '';
+  const businessDate = new Date(timestamp + 8 * 60 * 60 * 1000);
+  return this.closingNativeMonthText(businessDate.getUTCFullYear(), businessDate.getUTCMonth() + 1);
+}
+
+/** @param {*} response 原生API响应 @returns {{data:Array<Object>,totalCount:number}} 完整分页契约 */
+export function closingNativePage(response) {
+  let payload = response;
+  for (let depth = 0; depth < 5; depth += 1) {
+    if (!payload || typeof payload !== 'object' || payload.success === false || payload.errorCode) {
+      throw new Error('封账流程查询失败');
+    }
+    if (Array.isArray(payload.data)) {
+      const count = Number(payload.totalCount);
+      if (payload.totalCount === undefined || payload.totalCount === null || payload.totalCount === ''
+        || !Number.isInteger(count) || count < 0 || count < payload.data.length) {
+        throw new Error('封账流程分页响应不完整');
+      }
+      return { data: payload.data, totalCount: count };
+    }
+    payload = payload.content || payload.result || payload.data;
+  }
+  throw new Error('封账流程响应格式异常');
+}
+
+/**
+ * ppExt 与MonthClosingService.summarizeClosingStatus一致:在途单独计数,仅已完成同意动作决定最新状态。
+ * @param {string} monthText 目标月份
+ * @param {Array<Object>} records 已完整分页的流程实例
+ * @returns {Object} 与旧后端一致的六字段封账状态
+ */
+export function summarizeNativeMonthClosing(monthText, records) {
+  const result = { monthText: monthText, closed: false, currentStatus: '',
+    approvedClosingCount: 0, approvedReopeningCount: 0, runningClosingCount: 0 };
+  let latestTime = null;
+  records.forEach(record => {
+    const data = this.closingNativeRecordData(record);
+    const recordMonth = this.closingNativeRecordMonth(data.dateField_mt6wzb17);
+    if (!recordMonth) throw new Error('封账流程月份字段不可读');
+    if (recordMonth !== monthText) return;
+    const status = String(record.instanceStatus || record.processInstanceStatus || '').toUpperCase();
+    if (status === 'RUNNING') { result.runningClosingCount += 1; return; }
+    if (status !== 'COMPLETED' || String(record.approvedResult || '').toLowerCase() !== 'agree') return;
+    const action = String(data.radioField_mt6wzb18 || '').trim();
+    if (action === '封账') result.approvedClosingCount += 1;
+    else if (action === '启封') result.approvedReopeningCount += 1;
+    else return;
+    const timeFields = ['modifiedTimeGMT', 'gmtModified', 'finishTimeGMT', 'finishTime',
+      'modifiedTime', 'createTimeGMT', 'createTime'];
+    let eventTime = null;
+    for (let index = 0; index < timeFields.length; index += 1) {
+      eventTime = this.closingNativeTimestamp(record[timeFields[index]]);
+      if (eventTime !== null) break;
+    }
+    if (!result.currentStatus || (latestTime === null && eventTime !== null)
+      || (latestTime !== null && eventTime !== null && eventTime > latestTime)) {
+      result.currentStatus = action;
+      latestTime = eventTime;
+    }
+  });
+  result.closed = result.currentStatus === '封账';
+  return result;
+}
+
+/**
+ * fixme 全员实例可查看权限由宜搭校验;不回退旧连接器或旧缓存,异常由原调用方保持禁止写入。
+ * @param {number|string} year 年份
+ * @param {number|string} month 月份1-12
+ * @returns {Promise<Object>} 原后端六字段契约
+ */
+export async function queryNativeMonthClosingStatus(year, month) {
+  const monthText = this.closingNativeMonthText(year, month);
+  const api = this.utils && this.utils.yida;
+  if (!api || typeof api.getProcessInstances !== 'function') throw new Error('宜搭原生流程查询不可用');
+  const start = Date.parse(monthText.substring(0, 4) + '-' + monthText.substring(4) + '-01T00:00:00+08:00');
+  const nextMonth = new Date(start + 8 * 60 * 60 * 1000);
+  nextMonth.setUTCMonth(nextMonth.getUTCMonth() + 1);
+  const end = nextMonth.getTime() - 8 * 60 * 60 * 1000 - 1;
+  const search = [{ key: 'dateField_mt6wzb17', componentName: 'DateField', type: 'DOUBLE',
+    operator: 'between', value: [start, end] }];
+  const records = [];
+  const seen = {};
+  let totalCount = null;
+  for (let page = 1; page <= 1000; page += 1) {
+    const response = await api.getProcessInstances({ appType: APP_TYPE,
+      formUuid: 'FORM-275BDAFE67FE4226B6BCF2BC6EEFFD4B4215', currentPage: page,
+      pageSize: 100, searchFieldJson: JSON.stringify(search) });
+    const current = this.closingNativePage(response);
+    if (totalCount !== null && totalCount !== current.totalCount) throw new Error('封账流程分页期间数据变化,请重试');
+    totalCount = current.totalCount;
+    current.data.forEach(record => {
+      const id = record && record.processInstanceId;
+      if (!id || Object.prototype.hasOwnProperty.call(seen, id)) throw new Error('封账流程分页记录异常');
+      seen[id] = true;
+      records.push(record);
+    });
+    if (records.length === totalCount) return this.summarizeNativeMonthClosing(monthText, records);
+    if (records.length > totalCount || current.data.length === 0) throw new Error('封账流程分页响应不完整');
+  }
+  throw new Error('封账流程查询超过分页上限');
+}
+
+/**
+ * 通过宜搭原生流程接口查询月份封账状态;旧后端页面查询入口已废弃。
  * @param {number} year - 年份
  * @param {number} month - 月份 1-12
  * @returns {Promise<{monthText: string, closed: boolean, currentStatus: string, approvedClosingCount: number, approvedReopeningCount: number, runningClosingCount: number}>}
  */
 export async function loadMonthClosingStatus(year, month) {
-  // fixme 初始化、切月、保存和提交共享同一数据源,所有load串行执行,失败不阻断下一次独立查询。
-  var run = async function () {
-    var dataSource = this.getCallableDataSource('getMonthClosingStatus');
-    if (!dataSource) throw new Error('连接器 getMonthClosingStatus 未配置');
-    var monthText = this.getRepairMonthText(year, month);
-    var response = await dataSource.load({
-      inputs: JSON.stringify({
-        Headers: {
-          'Content-Type': 'application/json'
-        },
-        // fixme 时间戳只用于明确绕过中间层 GET/页面缓存;业务月份仍只从 body 读取。
-        Query: {
-          _t: Date.now()
-        },
-        Body: {
-          monthText: monthText
-        }
-      })
-    });
-    var result = this.unwrapConnectorResponse(response, '封账状态查询失败');
-    // fixme 连接器可能在下游接口 404 时仍返回空成功响应;缺少明确封账状态必须 fail-closed。
-    if (!result || typeof result.closed !== 'boolean' || String(result.monthText || '') !== monthText) {
-      throw new Error('封账状态响应格式异常');
-    }
-    return result;
-  }.bind(this);
-  var previous = this._monthClosingLoad || Promise.resolve();
-  var current = previous.then(run, run);
+  // ppExt 保留原页面串行队列和调用时序,仅替换封账查询传输方式。
+  const run = function () { return this.queryNativeMonthClosingStatus(year, month); }.bind(this);
+  const previous = this._monthClosingLoad || Promise.resolve();
+  const current = previous.then(run, run);
   this._monthClosingLoad = current;
   return current;
 }
@@ -5575,7 +5817,7 @@ export function didUnmount() {
   this._tcDisposed = true;
   this._tcBeisenLeaveRequestKey = '__TIMECARD_PAGE_DISPOSED__';
   this._tcRepairContextRequestKey = '__TIMECARD_PAGE_DISPOSED__';
-  (this._tcRepairContextPollTimers || []).forEach(function (entry) {
+  (this._tcRepairContextPollTimers || []).forEach(entry => {
     clearTimeout(entry.timerId);
     entry.resolve();
   });

+ 277 - 35
pages/src/timecard-repair.oyd.jsx

@@ -474,7 +474,6 @@ function resolveRepairMonth(monthParam, currentDate) {
       month: parseInt(monthParam.substring(4, 6), 10)
     };
   }
-
   var previousMonth = new Date(today.getFullYear(), today.getMonth(), 1);
   previousMonth.setMonth(previousMonth.getMonth() - 1);
   return {
@@ -1448,9 +1447,20 @@ export function loadBeisenLeaveDefaults(userId, year, month, requestKey) {
  */
 export function loadTimecardData(userId, year, month, repairContext) {
   var monthStr = this.getRepairMonthText(year, month);
+  const loadedType = (this.state.userInfo || {}).staffType || '';
+  if (repairContext && repairContext.staffType && loadedType && repairContext.staffType !== loadedType) {
+    return Promise.reject(new Error(this.getTexts().staffTypeCheckFailed));
+  }
+  const external = repairContext && repairContext.staffType
+    ? repairContext.staffType === '外部' : this.state.isExternalStaff;
   // fixme 原生查询可能被权限过滤为空;后端完整上下文已返回时不可丢弃已有补填工时。
   if (repairContext && Array.isArray(repairContext.originalRecords) && Array.isArray(repairContext.repairRecords)) {
-    return Promise.resolve(this.prepareTimecardRecords(repairContext.originalRecords, repairContext.repairRecords));
+    return Promise.resolve(this.prepareTimecardRecords(external ? [] : repairContext.originalRecords, repairContext.repairRecords));
+  }
+  // prd 外部补填不读取原表;上下文失败时也不能回退到原表。
+  if (external) {
+    return this.queryAllTimecardRecords(userId, monthStr, 1, [], FORM_IDS.repairSummary,
+      TIME_FIELD_IDS.submitter, TIME_FIELD_IDS.monthText).then(records => this.prepareTimecardRecords([], records));
   }
   // prd 补填资格以原工时汇总为准,先查询正式汇总,再查询工时汇总 调整中的补填记录并合并展示。
   return this.queryAllTimecardRecords(userId, monthStr, 1, [], FORM_IDS.timeSummary, TIME_FIELD_IDS.submitter, TIME_FIELD_IDS.monthText).then(function (originalResult) {
@@ -1467,7 +1477,7 @@ export function loadTimecardData(userId, year, month, repairContext) {
  * @returns {Array<Object>} 可直接解析回显和整日锁定的记录
  */
 export function prepareTimecardRecords(originalResult, repairResult) {
-  var originalRecords = (originalResult || []).map(item => {
+  var originalRecords = (this.state.isExternalStaff ? [] : originalResult || []).map(item => {
     var copy = Object.assign({}, item);
     copy.formInstId = item.formInstId || item.formInstanceId || '';
     copy._timecardRecordSource = 'original';
@@ -1518,7 +1528,9 @@ export function queryAllTimecardRecords(userId, monthStr, currentPage, accumulat
       return condition;
     }())
   }).then(function (res) {
+    if (res.success === false || !Array.isArray(res.data)) throw new Error('工时查询失败,请重试');
     var list = res.data || [];
+    if (list.length === 0 && acc.length < (res.totalCount || 0)) throw new Error('工时分页数据不完整');
     acc.push.apply(acc, list);
     if (acc.length < (res.totalCount || 0)) {
       return this.queryAllTimecardRecords(userId, monthStr, page + 1, acc, formUuid, submitterField, monthField, extraSearchFields);
@@ -1723,6 +1735,8 @@ export function processTimecardRecords(records, year, month, holidayMap, loadedP
   // fixme 已提交补填记录即使子表异常缺失,也不能再自动填入8小时。
   var blockedDays = Object.assign({}, originalBlockedDays, repairBlockedDays);
   var autoFilledBillableDays = this.applyDefaultWorkdayHours(year, month, holidayMap, projectRows, billableData, nonBillableData, otherData, isViewMode === true, blockedDays);
+  const overtimeDisplayDayTotals = this.getOvertimeDisplayTotals(billableData, nonBillableData, otherData);
+  const overtimeBaseDayTotals = this.buildOvertimeBaseline(records, year, month, overtimeDisplayDayTotals, true);
   this.setState({
     projectRows: projectRows,
     projectInfoMap: projectInfoMap,
@@ -1733,6 +1747,8 @@ export function processTimecardRecords(records, year, month, holidayMap, loadedP
     baselineNonBillableData: baselineNonBillableData,
     baselineOtherData: baselineOtherData,
     overtimeChangeAdjustments: overtimeChangeAdjustments,
+    overtimeDisplayDayTotals: overtimeDisplayDayTotals,
+    overtimeBaseDayTotals: overtimeBaseDayTotals,
     cellStatusMap: cellStatusMap,
     recordMap: recordMap,
     originalBlockedDays: originalBlockedDays,
@@ -2276,6 +2292,99 @@ export function getCellEditState(section, rowIndex, day) {
  * @param {boolean} includeDraft - 是否允许回退暂存值
  * @returns {number} 正负变更对展示值的实际影响
  */
+/**
+ * 汇总当前页面三类输入,作为后续未保存输入的比较基线。
+ * @param {Object} billable - Billable输入
+ * @param {Object} nonBillable - Non Billable输入
+ * @param {Object} other - 其他输入
+ * @returns {Object.<number, number>} 每日显示合计
+ */
+export function getOvertimeDisplayTotals(billable, nonBillable, other) {
+  const totals = {};
+  [billable, nonBillable, other].forEach(data => {
+    Object.keys(data || {}).forEach(row => {
+      Object.keys(data[row] || {}).forEach(day => {
+        totals[day] = (totals[day] || 0) + (parseFloat(data[row][day]) || 0);
+      });
+    });
+  });
+  return totals;
+}
+
+/**
+ * 读取实际发生日的基础工时;三种状态择一,正负变更均不参与。
+ * @param {Object} data - 单日表单数据
+ * @returns {{hours: number, committed: boolean}} 基础工时及提交状态
+ */
+export function resolveOvertimeRecord(data) {
+  let hours = 0;
+  let committed = false;
+  [BILLABLE_SUB, NON_BILLABLE_SUB, OTHER_SUB].forEach(fields => {
+    const keys = {};
+    (data[fields.table] || []).forEach(row => {
+      const approved = parseFloat(row[fields.approved]) || 0;
+      const submitted = parseFloat(row[fields.submitted]) || 0;
+      const draft = parseFloat(row[fields.draft]) || 0;
+      const change = parseFloat(row[fields.change]) || 0;
+      const key = String(row[fields.projectCode || fields.activity] || '').trim();
+      if (!key && approved === 0 && submitted === 0 && draft === 0 && change === 0) return;
+      if (!key || keys[key]) throw new Error('工时明细缺少编号或重复,无法核实加班工时');
+      keys[key] = true;
+      committed = committed || approved !== 0 || submitted !== 0;
+      hours += approved !== 0 ? approved : (submitted > 0 ? submitted : Math.max(0, draft));
+    });
+  });
+  return { hours: hours, committed: committed };
+}
+
+/**
+ * 按实际日期选择原表或补填来源,不将冲销/调整入账当作当天工作。
+ * @param {Array<Object>} records - 标注original/repair来源的完整记录
+ * @param {number} year - 年份
+ * @param {number} month - 月份
+ * @param {Object} displayTotals - 页面首次显示的每日合计
+ * @param {boolean} repairMode - 是否使用补填页草稿
+ * @returns {Object.<number, number>} 不含变更的加班计算日基线
+ */
+export function buildOvertimeBaseline(records, year, month, displayTotals, repairMode) {
+  const monthText = String(year) + ('0' + month).slice(-2);
+  const original = {};
+  const repair = {};
+  (records || []).forEach(item => {
+    const data = item.formData || item;
+    const source = item._timecardRecordSource === 'repair' ? 'repair' : 'original';
+    const type = data.radioField_rtc81we6k || '';
+    if (source === 'repair' && type === '变更') return;
+    const dateText = String(data[TIME_FIELD_IDS.dayText] || data.textField_mokwyspz || '');
+    if (!/^\d{8}$/.test(dateText) || dateText.slice(0, 6) !== monthText) {
+      throw new Error('工时日期与月份不一致,无法核实加班工时');
+    }
+    const day = parseInt(dateText.slice(6), 10);
+    if (day < 1 || day > new Date(year, month, 0).getDate()) throw new Error('工时日期无效');
+    const value = this.resolveOvertimeRecord(data);
+    if (source === 'repair' && !type && value.hours === 0) return;
+    if (source === 'repair' && type !== '补填') throw new Error('补填记录类型不明确');
+    const target = source === 'repair' ? repair : original;
+    if (target[day]) throw new Error('同日工时记录重复,无法核实加班工时');
+    target[day] = value;
+  });
+  const totals = Object.assign({}, displayTotals || {});
+  const days = Object.assign({}, original, repair);
+  Object.keys(days).forEach(day => {
+    const normal = original[day];
+    const supplement = repair[day];
+    if (normal && normal.committed && supplement && supplement.committed) {
+      throw new Error('同日正常及补填工时均已提交,请核实后再提交');
+    }
+    const selected = supplement && !(normal && normal.committed)
+      && (supplement.committed || repairMode) ? supplement : normal;
+    if (selected) totals[day] = selected.hours;
+    else if (supplement) totals[day] = 0;
+  });
+  return totals;
+}
+
+
 export function getOvertimeChangeAdjustment(row, fields, displayedHours, includeDraft) {
   var base = this.resolveHourValue(parseFloat(row[fields.approved]) || 0, parseFloat(row[fields.submitted]) || 0, parseFloat(row[fields.draft]) || 0, 0, includeDraft);
   return displayedHours - base.value;
@@ -2287,6 +2396,11 @@ export function getOvertimeChangeAdjustment(row, fields, displayedHours, include
  * @returns {number} 排除变更后的单日工时
  */
 export function getOvertimeDayTotal(day) {
+  const base = this.state.overtimeBaseDayTotals;
+  const displayed = this.state.overtimeDisplayDayTotals;
+  if (base && displayed) {
+    return Math.max(0, (base[day] || 0) + this.getDayTotal(day) - (displayed[day] || 0));
+  }
   var adjustments = this.state.overtimeChangeAdjustments || {};
   var suffix = '_' + day;
   var adjustment = Object.keys(adjustments).reduce((total, key) => {
@@ -2542,7 +2656,7 @@ export function waitForRepairContextRetry(delayMillis) {
       resolve: resolve
     };
     entry.timerId = setTimeout(function () {
-      this._tcRepairContextPollTimers = (this._tcRepairContextPollTimers || []).filter(function (item) {
+      this._tcRepairContextPollTimers = (this._tcRepairContextPollTimers || []).filter(item => {
         return item !== entry;
       });
       resolve();
@@ -2569,6 +2683,7 @@ export function loadTimecardRepairStatus(userId, year, month) {
   this._tcRepairContextRequestSequence = (this._tcRepairContextRequestSequence || 0) + 1;
   var requestKey = String(userId || '') + '|' + monthText + '|' + this._tcRepairContextRequestSequence;
   this._tcRepairContextRequestKey = requestKey;
+  const staffType = (this.state && this.state.userInfo || {}).staffType || '';
   var buildInputs = function () {
     return {
       inputs: JSON.stringify({
@@ -2580,7 +2695,8 @@ export function loadTimecardRepairStatus(userId, year, month) {
         },
         Body: {
           submitterUserId: userId || '',
-          sourceMonthText: monthText
+          sourceMonthText: monthText,
+          staffType: staffType
         }
       })
     };
@@ -2602,6 +2718,8 @@ export function loadTimecardRepairStatus(userId, year, month) {
       }
       return {
         configured: true,
+        staffType: result.staffType || '',
+        summaryReadMode: result.summaryReadMode || '',
         originalRecords: result.originalRecords,
         repairRecords: result.repairRecords,
         records: Array.isArray(result.records) ? result.records : []
@@ -2760,40 +2878,163 @@ export async function submitTimecardRepairRequest() {
 }
 
 /**
- * 通过阿科德斯后端实时查询月份封账状态。
+ * prd 2026-10-23:页面封账查询改为宜搭原生接口,替代已废弃的getMonthClosingStatus连接器调用。
+ * ppExt 只归一化原生查询结果;原有准入函数、保存提交及后端业务封账保护保持原样。
+ * @param {number|string} year 年份
+ * @param {number|string} month 月份1-12
+ * @returns {string} 上海业务月份yyyyMM
+ */
+export function closingNativeMonthText(year, month) {
+  const yearText = String(year).trim();
+  const monthValue = String(month).trim();
+  const monthText = monthValue.length === 1 ? '0' + monthValue : monthValue;
+  if (!/^\d{4}$/.test(yearText) || !/^\d{2}$/.test(monthText)
+    || Number(yearText) < 1 || Number(monthText) < 1 || Number(monthText) > 12) {
+    throw new Error('封账查询月份无效');
+  }
+  return yearText + monthText;
+}
+
+/** @param {*} value 秒、毫秒或ISO时间 @returns {number|null} 毫秒时间戳 */
+export function closingNativeTimestamp(value) {
+  if (typeof value === 'number' && Number.isFinite(value)) {
+    return String(Math.abs(value)).length <= 10 ? value * 1000 : value;
+  }
+  const text = String(value === undefined || value === null ? '' : value).trim();
+  if (/^\d{10}$|^\d{13}$/.test(text)) return Number(text) * (text.length === 10 ? 1000 : 1);
+  if (!/^\d{4}-\d{2}-\d{2}T/.test(text)) return null;
+  const timestamp = Date.parse(text);
+  return Number.isFinite(timestamp) ? timestamp : null;
+}
+
+/** @param {Object} record 原生/已归一化流程实例 @returns {Object} 业务字段 */
+export function closingNativeRecordData(record) {
+  if (record.formData && typeof record.formData === 'object') return record.formData;
+  const data = record.data;
+  if (!data || typeof data !== 'object') return {};
+  return data.formData && typeof data.formData === 'object' ? data.formData : data;
+}
+
+/** @param {*} value 月份字段 @returns {string} yyyyMM或空串 */
+export function closingNativeRecordMonth(value) {
+  const raw = String(value === undefined || value === null ? '' : value).trim();
+  if (/^\d{6}$|^\d{8}$/.test(raw)) {
+    return this.closingNativeMonthText(raw.substring(0, 4), raw.substring(4, 6));
+  }
+  const timestamp = this.closingNativeTimestamp(value);
+  if (timestamp === null) return '';
+  const businessDate = new Date(timestamp + 8 * 60 * 60 * 1000);
+  return this.closingNativeMonthText(businessDate.getUTCFullYear(), businessDate.getUTCMonth() + 1);
+}
+
+/** @param {*} response 原生API响应 @returns {{data:Array<Object>,totalCount:number}} 完整分页契约 */
+export function closingNativePage(response) {
+  let payload = response;
+  for (let depth = 0; depth < 5; depth += 1) {
+    if (!payload || typeof payload !== 'object' || payload.success === false || payload.errorCode) {
+      throw new Error('封账流程查询失败');
+    }
+    if (Array.isArray(payload.data)) {
+      const count = Number(payload.totalCount);
+      if (payload.totalCount === undefined || payload.totalCount === null || payload.totalCount === ''
+        || !Number.isInteger(count) || count < 0 || count < payload.data.length) {
+        throw new Error('封账流程分页响应不完整');
+      }
+      return { data: payload.data, totalCount: count };
+    }
+    payload = payload.content || payload.result || payload.data;
+  }
+  throw new Error('封账流程响应格式异常');
+}
+
+/**
+ * ppExt 与MonthClosingService.summarizeClosingStatus一致:在途单独计数,仅已完成同意动作决定最新状态。
+ * @param {string} monthText 目标月份
+ * @param {Array<Object>} records 已完整分页的流程实例
+ * @returns {Object} 与旧后端一致的六字段封账状态
+ */
+export function summarizeNativeMonthClosing(monthText, records) {
+  const result = { monthText: monthText, closed: false, currentStatus: '',
+    approvedClosingCount: 0, approvedReopeningCount: 0, runningClosingCount: 0 };
+  let latestTime = null;
+  records.forEach(record => {
+    const data = this.closingNativeRecordData(record);
+    const recordMonth = this.closingNativeRecordMonth(data.dateField_mt6wzb17);
+    if (!recordMonth) throw new Error('封账流程月份字段不可读');
+    if (recordMonth !== monthText) return;
+    const status = String(record.instanceStatus || record.processInstanceStatus || '').toUpperCase();
+    if (status === 'RUNNING') { result.runningClosingCount += 1; return; }
+    if (status !== 'COMPLETED' || String(record.approvedResult || '').toLowerCase() !== 'agree') return;
+    const action = String(data.radioField_mt6wzb18 || '').trim();
+    if (action === '封账') result.approvedClosingCount += 1;
+    else if (action === '启封') result.approvedReopeningCount += 1;
+    else return;
+    const timeFields = ['modifiedTimeGMT', 'gmtModified', 'finishTimeGMT', 'finishTime',
+      'modifiedTime', 'createTimeGMT', 'createTime'];
+    let eventTime = null;
+    for (let index = 0; index < timeFields.length; index += 1) {
+      eventTime = this.closingNativeTimestamp(record[timeFields[index]]);
+      if (eventTime !== null) break;
+    }
+    if (!result.currentStatus || (latestTime === null && eventTime !== null)
+      || (latestTime !== null && eventTime !== null && eventTime > latestTime)) {
+      result.currentStatus = action;
+      latestTime = eventTime;
+    }
+  });
+  result.closed = result.currentStatus === '封账';
+  return result;
+}
+
+/**
+ * fixme 全员实例可查看权限由宜搭校验;不回退旧连接器或旧缓存,异常由原调用方保持禁止写入。
+ * @param {number|string} year 年份
+ * @param {number|string} month 月份1-12
+ * @returns {Promise<Object>} 原后端六字段契约
+ */
+export async function queryNativeMonthClosingStatus(year, month) {
+  const monthText = this.closingNativeMonthText(year, month);
+  const api = this.utils && this.utils.yida;
+  if (!api || typeof api.getProcessInstances !== 'function') throw new Error('宜搭原生流程查询不可用');
+  const start = Date.parse(monthText.substring(0, 4) + '-' + monthText.substring(4) + '-01T00:00:00+08:00');
+  const nextMonth = new Date(start + 8 * 60 * 60 * 1000);
+  nextMonth.setUTCMonth(nextMonth.getUTCMonth() + 1);
+  const end = nextMonth.getTime() - 8 * 60 * 60 * 1000 - 1;
+  const search = [{ key: 'dateField_mt6wzb17', componentName: 'DateField', type: 'DOUBLE',
+    operator: 'between', value: [start, end] }];
+  const records = [];
+  const seen = {};
+  let totalCount = null;
+  for (let page = 1; page <= 1000; page += 1) {
+    const response = await api.getProcessInstances({ appType: APP_TYPE,
+      formUuid: 'FORM-275BDAFE67FE4226B6BCF2BC6EEFFD4B4215', currentPage: page,
+      pageSize: 100, searchFieldJson: JSON.stringify(search) });
+    const current = this.closingNativePage(response);
+    if (totalCount !== null && totalCount !== current.totalCount) throw new Error('封账流程分页期间数据变化,请重试');
+    totalCount = current.totalCount;
+    current.data.forEach(record => {
+      const id = record && record.processInstanceId;
+      if (!id || Object.prototype.hasOwnProperty.call(seen, id)) throw new Error('封账流程分页记录异常');
+      seen[id] = true;
+      records.push(record);
+    });
+    if (records.length === totalCount) return this.summarizeNativeMonthClosing(monthText, records);
+    if (records.length > totalCount || current.data.length === 0) throw new Error('封账流程分页响应不完整');
+  }
+  throw new Error('封账流程查询超过分页上限');
+}
+
+/**
+ * 通过宜搭原生流程接口查询月份封账状态;旧后端页面查询入口已废弃。
  * @param {number} year - 年份
  * @param {number} month - 月份 1-12
  * @returns {Promise<{monthText: string, closed: boolean, currentStatus: string, approvedClosingCount: number, approvedReopeningCount: number, runningClosingCount: number}>}
  */
 export async function loadMonthClosingStatus(year, month) {
-  // fixme 初始化、切月、保存和提交共享同一数据源,所有load串行执行,失败不阻断下一次独立查询。
-  var run = async function () {
-    var dataSource = this.getCallableDataSource('getMonthClosingStatus');
-    if (!dataSource) throw new Error('连接器 getMonthClosingStatus 未配置');
-    var monthText = this.getRepairMonthText(year, month);
-    var response = await dataSource.load({
-      inputs: JSON.stringify({
-        Headers: {
-          'Content-Type': 'application/json'
-        },
-        // fixme 时间戳只用于明确绕过中间层 GET/页面缓存;业务月份仍只从 body 读取。
-        Query: {
-          _t: Date.now()
-        },
-        Body: {
-          monthText: monthText
-        }
-      })
-    });
-    var result = this.unwrapConnectorResponse(response, '封账状态查询失败');
-    // fixme 连接器可能在下游接口 404 时仍返回空成功响应;缺少明确封账状态必须 fail-closed。
-    if (!result || typeof result.closed !== 'boolean' || String(result.monthText || '') !== monthText) {
-      throw new Error('封账状态响应格式异常');
-    }
-    return result;
-  }.bind(this);
-  var previous = this._monthClosingLoad || Promise.resolve();
-  var current = previous.then(run, run);
+  // ppExt 保留原页面串行队列和调用时序,仅替换封账查询传输方式。
+  const run = function () { return this.queryNativeMonthClosingStatus(year, month); }.bind(this);
+  const previous = this._monthClosingLoad || Promise.resolve();
+  const current = previous.then(run, run);
   this._monthClosingLoad = current;
   return current;
 }
@@ -4677,6 +4918,7 @@ export function refreshTimecardView() {
   }
 }
 export function renderJsx() {
+  var self = this;
   // prd 月度工时填报页面 — JSX 渲染层
   // 版本:v2.0 | 日期:2026-03-05
 
@@ -5575,7 +5817,7 @@ export function didUnmount() {
   this._tcDisposed = true;
   this._tcBeisenLeaveRequestKey = '__TIMECARD_PAGE_DISPOSED__';
   this._tcRepairContextRequestKey = '__TIMECARD_PAGE_DISPOSED__';
-  (this._tcRepairContextPollTimers || []).forEach(function (entry) {
+  (this._tcRepairContextPollTimers || []).forEach(entry => {
     clearTimeout(entry.timerId);
     entry.resolve();
   });

+ 123 - 0
pages/tests/verify-native-closing.cjs

@@ -0,0 +1,123 @@
+'use strict';
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+const babel = require('/Users/malk/.nvm/versions/node/v22.12.0/lib/node_modules/openyida/node_modules/@babel/standalone');
+const helperNames = ['closingNativeMonthText', 'closingNativeTimestamp', 'closingNativeRecordData',
+  'closingNativeRecordMonth', 'closingNativePage', 'summarizeNativeMonthClosing', 'queryNativeMonthClosingStatus'];
+const APP_TYPE = 'APP_ZQ3I7XO2RSHDJ4QDEVNB';
+
+/** @param {string} source @returns {Map<string,string>} Exact exported function bodies. */
+function functions(source) {
+  const ast = babel.transform(source, { ast: true, code: false, parserOpts: { sourceType: 'module', plugins: ['jsx'] } }).ast;
+  return new Map(ast.program.body.filter(node => node.type === 'ExportNamedDeclaration'
+    && node.declaration && node.declaration.type === 'FunctionDeclaration')
+    .map(node => [node.declaration.id.name, source.slice(node.start, node.end)]));
+}
+
+/** @param {string} source @returns {Object} Isolated frontend methods. */
+function methods(source) {
+  const exported = functions(source);
+  const names = helperNames.concat(['loadMonthClosingStatus', 'isMonthLocked', 'isRepairMonthBlocked', 'isCurrentMonthBlocked'])
+    .filter(name => exported.has(name));
+  const code = names.map(name => exported.get(name).replace(/^export /, '')).join('\n');
+  return vm.runInNewContext(code + '\n({' + names.join(',') + '})', { APP_TYPE });
+}
+
+/** @param {Object} data @returns {*} Plain JSON for cross-context assertions. */
+function plain(data) { return JSON.parse(JSON.stringify(data)); }
+
+/** @param {string} id @param {string} status @param {string} action @param {number} time @param {string} approved @returns {Object} Fixture. */
+function record(id, status = 'COMPLETED', action = '封账', time = 1790000000000, approved = 'agree') {
+  return { processInstanceId: id, instanceStatus: status, approvedResult: approved, gmtModified: time,
+    data: { dateField_mt6wzb17: 1785513600000, radioField_mt6wzb18: action } };
+}
+
+/** @param {Object} m @param {Array<*>} replies @returns {Object} Native API stub and call log. */
+function context(m, replies) {
+  const calls = [];
+  const obj = Object.assign({}, m, { getCurrentBusinessMonthText: () => '202610', calls,
+    utils: { yida: { getProcessInstances: async params => {
+      calls.push(params);
+      const reply = replies[Math.min(calls.length - 1, replies.length - 1)];
+      if (reply instanceof Error) throw reply;
+      return reply;
+    } } }, getCallableDataSource: () => { throw new Error('不得调用旧封账连接器'); } });
+  return obj;
+}
+
+/** @param {string} filename @returns {Promise<void>} Exercise state, paging and failure contract. */
+async function verify(filename) {
+  const source = fs.readFileSync(filename, 'utf8');
+  const m = methods(source);
+  const count = record('closed');
+  const ctx = context(m, [{ success: true, content: { data: [count], totalCount: 1 } }]);
+  const closed = plain(await ctx.loadMonthClosingStatus(2026, 8));
+  assert.deepEqual(closed, { monthText: '202608', closed: true, currentStatus: '封账',
+    approvedClosingCount: 1, approvedReopeningCount: 0, runningClosingCount: 0 });
+  const params = ctx.calls[0];
+  assert.equal(params.pageSize, 100);
+  assert.equal(params.formUuid, 'FORM-275BDAFE67FE4226B6BCF2BC6EEFFD4B4215');
+  assert.deepEqual(JSON.parse(params.searchFieldJson)[0].value, [1785513600000, 1788191999999]);
+  const empty = plain(await context(m, [{ data: [], totalCount: 0 }]).loadMonthClosingStatus('2026', '10'));
+  assert.equal(empty.closed, false);
+  assert.equal(empty.monthText, '202610');
+  const running = ctx.summarizeNativeMonthClosing('202608', [record('run', 'RUNNING', '启封')]);
+  assert.equal(running.runningClosingCount, 1);
+  assert.equal(running.closed, false);
+  const reopened = ctx.summarizeNativeMonthClosing('202608', [count, record('open', 'COMPLETED', '启封', count.gmtModified + 1)]);
+  assert.equal(reopened.closed, false);
+  assert.equal(reopened.approvedClosingCount, 1);
+  assert.equal(reopened.approvedReopeningCount, 1);
+  const closedAgain = ctx.summarizeNativeMonthClosing('202608', [count, record('open', 'COMPLETED', '启封', count.gmtModified + 1),
+    record('closed-again', 'COMPLETED', '封账', count.gmtModified + 2)]);
+  assert.equal(closedAgain.closed, true);
+  const ignored = ctx.summarizeNativeMonthClosing('202608', [record('rejected', 'COMPLETED', '封账', undefined, 'disagree'),
+    record('terminated', 'TERMINATED'), record('error', 'ERROR')]);
+  assert.equal(ignored.approvedClosingCount, 0);
+  assert.equal(ignored.closed, false);
+  const otherMonth = record('other'); otherMonth.data.dateField_mt6wzb17 = 1788192000000;
+  assert.equal(ctx.summarizeNativeMonthClosing('202608', [otherMonth]).approvedClosingCount, 0);
+  assert.equal(ctx.closingNativeTimestamp('1790000000'), 1790000000000);
+  assert.equal(ctx.closingNativeTimestamp('2026-08-01T00:00:00+08:00'), 1785513600000);
+  assert.equal(ctx.closingNativeRecordMonth('20260801'), '202608');
+  const priority = record('priority'); priority.modifiedTimeGMT = count.gmtModified - 1;
+  assert.equal(ctx.summarizeNativeMonthClosing('202608', [record('open-first', 'COMPLETED', '启封', count.gmtModified), priority]).closed, false);
+  const tied = ctx.summarizeNativeMonthClosing('202608', [count, record('tie-open', 'COMPLETED', '启封', count.gmtModified)]);
+  assert.equal(tied.closed, true, '与后端一致,时间相等不覆盖先出现动作');
+  const firstPage = Array.from({ length: 100 }, (_, index) => record('p-' + index));
+  const paged = context(m, [{ data: firstPage, totalCount: 101 }, { data: [record('last')], totalCount: 101 }]);
+  assert.equal((await paged.loadMonthClosingStatus(2026, 8)).approvedClosingCount, 101);
+  assert.equal(paged.calls.length, 2);
+  for (const reply of [null, {}, { success: false, data: [], totalCount: 0 }, { errorCode: 'NO_PERMISSION' },
+    { data: [] }, { data: [], totalCount: null }, { data: [], totalCount: -1 }, new Error('timeout')]) {
+    await assert.rejects(context(m, [reply]).loadMonthClosingStatus(2026, 8));
+  }
+  await assert.rejects(context(m, [{ data: firstPage, totalCount: 101 }, { data: [], totalCount: 101 }]).loadMonthClosingStatus(2026, 8));
+  await assert.rejects(context(m, [{ data: firstPage, totalCount: 101 }, { data: [count], totalCount: 102 }]).loadMonthClosingStatus(2026, 8));
+  await assert.rejects(context(m, [{ data: [count, count], totalCount: 2 }]).loadMonthClosingStatus(2026, 8));
+  await assert.rejects(context(m, [{ data: [{ processInstanceId: 'hidden', data: {} }], totalCount: 1 }]).loadMonthClosingStatus(2026, 8));
+  await assert.rejects(context(m, [{ data: [], totalCount: 0 }]).loadMonthClosingStatus(2026, 13));
+  const failed = context(m, [new Error('timeout'), { data: [], totalCount: 0 }]);
+  await assert.rejects(failed.loadMonthClosingStatus(2026, 10));
+  assert.equal((await failed.loadMonthClosingStatus(2026, 10)).closed, false, '失败不阻断后续串行队列');
+  const openCurrent = Object.assign({}, empty, { monthText: '202610' });
+  if (m.isMonthLocked) { assert.equal(ctx.isMonthLocked(closed), true); assert.equal(ctx.isMonthLocked(running), true); assert.equal(ctx.isMonthLocked(empty), false); }
+  if (m.isCurrentMonthBlocked) { assert.equal(ctx.isCurrentMonthBlocked(openCurrent), false); assert.equal(ctx.isCurrentMonthBlocked(closed), true); }
+  if (m.isRepairMonthBlocked) {
+    assert.equal(ctx.isRepairMonthBlocked(closed), false);
+    assert.equal(ctx.isRepairMonthBlocked(Object.assign({}, running, { monthText: '202608' })), false);
+    assert.equal(ctx.isRepairMonthBlocked(openCurrent), filename.includes('change') ? false : true);
+  }
+  process.stdout.write('PASS 原生封账契约、状态归约、分页、异常保护、原准入规则:' + path.basename(filename) + '\n');
+}
+
+/** @returns {Promise<void>} Verify requested sources. */
+async function main() {
+  const files = process.argv.slice(2);
+  if (!files.length) files.push(...['timecard-repair-lifecycle.oyd.jsx', 'timecard-repair.oyd.jsx', 'timecard-change.oyd.jsx']
+    .map(name => path.resolve(__dirname, '../src', name)));
+  for (const file of files) await verify(file);
+}
+main().catch(error => { console.error(error); process.exitCode = 1; });

+ 135 - 0
pages/tests/verify-overtime.cjs

@@ -0,0 +1,135 @@
+'use strict';
+const assert = require('node:assert/strict');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+const mainPath = process.argv[2] || path.resolve(__dirname, '../src/timecard-main.actions.js');
+const repairPath = path.resolve(__dirname, '../src/timecard-repair.oyd.jsx');
+const evidence = process.argv[3] || '/Users/malk/Desktop/Tech/1. malk/宜搭/阿科德斯/TimeCard/project/.cache/openyida/overtime-evaluation-20261023/样本.json';
+
+/** @param {string} source @param {string} name @returns {string} */
+function extract(source, name) {
+  const found = new RegExp('export function ' + name + '\\([^]*?\\n}').exec(source);
+  assert.ok(found, '缺少 ' + name);
+  return found[0].replace('export ', '');
+}
+
+/** @param {string} file @returns {object} */
+function methods(file) {
+  const source = fs.readFileSync(file, 'utf8');
+  const names = ['resolveHourValue', 'getDayTotal', 'isWeekend', 'isDayOff',
+    'getOvertimeDisplayTotals', 'resolveOvertimeRecord', 'buildOvertimeBaseline',
+    'getOvertimeDayTotal', 'validateMonthlyOvertime', 'processTimecardRecords'];
+  if (source.includes('export function getOvertimeChangeAdjustment(')) names.push('getOvertimeChangeAdjustment');
+  const fields = {draft:'draft',submitted:'submitted',approved:'approved',change:'change',projectCode:'key'};
+  const context = {VALIDATION:{maxHoursPerDay:8,maxOvertimePerMonth:36},
+    BILLABLE_SUB:{...fields,table:'billable'}, NON_BILLABLE_SUB:{...fields,table:'nonBillable'},
+    OTHER_SUB:{...fields,table:'other',projectCode:undefined,activity:'key'},TIME_FIELD_IDS:{dayText:'day'}};
+  const snapshot = JSON.parse(fs.readFileSync(evidence,'utf8'));
+  context.OTHER_CATEGORIES = [...new Set(snapshot.people.flatMap(p=>p.originalOctober.flatMap(r=>
+    r.lines.filter(x=>x.section==='other').map(x=>x.key))))].map(name=>({name}));
+  vm.createContext(context);
+  vm.runInContext(names.map(n=>extract(source,n)).join('\n'), context);
+  return context;
+}
+
+/** @param {object} row @param {string} source @returns {object} */
+function adapt(row, source) {
+  const data = {day:row.day,radioField_rtc81we6k:row.fillType};
+  for (const section of ['billable','nonBillable','other']) data[section] = row.lines.filter(x=>x.section===section);
+  return {formData:data,_timecardRecordSource:source};
+}
+
+/** @param {object} lib @param {Array<object>} originals @param {Array<object>} repairs @param {string} month @returns {object} */
+function setup(lib, originals, repairs, month='202610') {
+  const rows = originals.map(x=>adapt(x,'original'));
+  const state = {selectedYear:+month.slice(0,4),selectedMonth:+month.slice(4),isExternalStaff:false,
+    billableData:{},nonBillableData:{},otherData:{},holidayMap:{}};
+  for (const r of originals) for (const line of r.lines) {
+    const key = line.section + 'Data';
+    state[key][line.key] ||= {};
+    state[key][line.key][+r.day.slice(-2)] = lib.resolveHourValue(+line.approved||0,+line.submitted||0,+line.draft||0,+line.change||0,true).value;
+  }
+  const env = {...lib,state,isBeforeEmploymentDate:()=>false,getTexts:()=>({overtimeExceeded:n=>String(n)}),
+    utils:{toast:()=>{}},showTimecardError:()=>{}};
+  state.overtimeDisplayDayTotals = env.getOvertimeDisplayTotals(state.billableData,state.nonBillableData,state.otherData);
+  state.overtimeBaseDayTotals = env.buildOvertimeBaseline(rows.concat(repairs.map(x=>adapt(x,'repair'))),
+    state.selectedYear,state.selectedMonth,state.overtimeDisplayDayTotals,false);
+  return env;
+}
+
+/** @returns {Promise<void>} */
+async function main() {
+  const snapshot = JSON.parse(fs.readFileSync(evidence,'utf8'));
+  const outcomes = [];
+  for (const file of [mainPath,repairPath]) {
+    const lib = methods(file);
+    const values = [];
+    for (const p of snapshot.people) {
+      const env = setup(lib,p.originalOctober,p.repairAll.filter(r=>r.day.startsWith('202610')));
+      snapshot.holidays.filter(x=>x.day.startsWith('202610')).forEach(x=>{env.state.holidayMap[+x.day.slice(-2)]=x.type;});
+      let total = 0;
+      for(let d=1;d<=31;d++) total += env.isDayOff(2026,10,d) ? env.getOvertimeDayTotal(d) : Math.max(0,env.getOvertimeDayTotal(d)-8);
+      values.push(total);
+      assert.equal(env.validateMonthlyOvertime(),p.name!=='周璐');
+      const integrated = {...lib,state:{selectedYear:2026,selectedMonth:10},
+        getDayApprovalLock:()=>'',applyDefaultWorkdayHours:()=>({}),
+        isBeforeEmploymentDate:()=>false,getTexts:()=>({overtimeExceeded:n=>String(n)}),
+        utils:{toast:()=>{}},showTimecardError:()=>{}};
+      integrated.setState = update=>Object.assign(integrated.state,update);
+      const records = p.originalOctober.map(r=>adapt(r,'original'));
+      const projects = [...new Set(p.originalOctober.flatMap(r=>r.lines.filter(x=>x.section!=='other').map(x=>x.key)))].map(code=>({code,name:code}));
+      integrated.processTimecardRecords(records,2026,10,env.state.holidayMap,projects,false,false,false);
+      assert.equal(integrated.validateMonthlyOvertime(),p.name!=='周璐','完整processTimecardRecords链路回放');
+      if(p.name==='王赏') {
+        env.state.billableData.extra={11:4};
+        assert.equal(env.getOvertimeDayTotal(11),4,'未保存周日4h必须参与校验');
+      }
+    }
+    assert.deepEqual(values,[40,0,0]);
+    const env = setup(lib,[],[]);
+    const row = {day:'20261022',fillType:'',lines:[{section:'billable',key:'P',draft:8,approved:0,submitted:0,change:168}]};
+    const restored = setup(lib,[row],[]);
+    assert.equal(restored.getOvertimeDayTotal(22),8);
+    restored.state.billableData.P[22]=172;
+    assert.equal(restored.getOvertimeDayTotal(22),12,'新增输入必须在还原基础上计算');
+    const repair = structuredClone(row);
+    repair.fillType='补填';repair.lines[0].approved=8;repair.lines[0].change=0;
+    assert.equal(env.buildOvertimeBaseline([adapt(row,'original'),adapt(repair,'repair')],2026,10,{},false)[22],8);
+    repair.lines[0].key='另一项目';
+    assert.equal(env.buildOvertimeBaseline([adapt(row,'original'),adapt(repair,'repair')],2026,10,{},false)[22],8);
+    repair.fillType='变更';repair.lines[0].approved=168;
+    assert.equal(Object.keys(env.buildOvertimeBaseline([adapt(repair,'repair')],2026,10,{},false)).length,0);
+    row.lines[0].change=-168;
+    assert.equal(setup(lib,[row],[]).getOvertimeDayTotal(22),8,'负变更同样排除');
+    assert.throws(()=>env.buildOvertimeBaseline([adapt(row,'original'),adapt(row,'original')],2026,10,{},false),/重复/);
+    row.lines[0].submitted=8;repair.fillType='补填';repair.lines[0].approved=8;
+    assert.throws(()=>env.buildOvertimeBaseline([adapt(row,'original'),adapt(repair,'repair')],2026,10,{},false),/均已提交/);
+    for(const hours of [36,36.5]) {
+      const boundary = setup(lib,[],[]);
+      boundary.state.billableData.P={11:hours/2,18:hours/2};
+      assert.equal(boundary.validateMonthlyOvertime(),hours===36);
+    }
+    outcomes.push({file:path.basename(file),hours:values});
+  }
+  const source = fs.readFileSync(repairPath,'utf8');
+  const scope = {FORM_IDS:{timeSummary:'original',repairSummary:'repair'},TIME_FIELD_IDS:{submitter:'user',monthText:'month'},
+    REPAIR_FILL_TYPE_FIELD:'radioField_rtc81we6k'};
+  vm.createContext(scope);
+  vm.runInContext(['loadTimecardData','prepareTimecardRecords'].map(n=>extract(source,n)).join('\n'),scope);
+  const calls=[];
+  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'],'外部原生回退不得查询原表');
+  calls.length=0;
+  await env.loadTimecardData('external',2026,9,{staffType:'外部',originalRecords:[{formData:{}}],repairRecords:[]});
+  assert.equal(calls.length,0);
+  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'}));
+}
+main().catch(error=>{console.error(error);process.exitCode=1;});