Parcourir la source

feat(timecard): enforce employment dates and overseas calendars

malk il y a 3 jours
Parent
commit
327c2a402a

+ 92 - 1
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/TimeCardPersonnelPolicy.java

@@ -16,6 +16,11 @@ import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.LinkedHashMap;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 
 /** 普通 TimeCard 的人员准入;补填和变更由各自服务校验。 */
 @Service
@@ -34,6 +39,11 @@ public class TimeCardPersonnelPolicy {
 
     /** 核实唯一人员档案,返回明确的内部/外部属性;异常不得放行。 */
     public String getStaffType(String userId) {
+        return String.valueOf(getPersonnelData(userId).get(ATTRIBUTE));
+    }
+
+    /** 每次写入前读取唯一、身份及属性均有效的人员档案,不使用会话缓存。 */
+    private Map getPersonnelData(String userId) {
         McException.assertAccessException(StringUtils.isBlank(userId), "审批明细填报人ID不能为空");
         McException.assertAccessException(StringUtils.isBlank(whConf.getFormUuidPersonnel()), "人员档案表未配置");
         DDR_New response = ydClient.queryData(YDParam.builder()
@@ -58,7 +68,59 @@ public class TimeCardPersonnelPolicy {
         String attribute = String.valueOf(data.get(ATTRIBUTE));
         McException.assertAccessException(!"内部".equals(attribute) && !"外部".equals(attribute),
                 "人员属性缺失或异常,不能正常填报");
-        return attribute;
+        return data;
+    }
+
+    /** @param userId 被填报人ID @return 上海业务日的任职闭区间,未维护日期不设边界 */
+    public Map<String, LocalDate> getEmploymentDates(String userId) {
+        Map data = getPersonnelData(userId);
+        Map<String, LocalDate> dates = new LinkedHashMap<>();
+        LocalDate start = employmentDate(data.get("dateField_mh8xhqc6"), "入职时间");
+        LocalDate end = employmentDate(data.get("dateField_mh8xhqc7"), "离职时间");
+        McException.assertAccessException(start != null && end != null && start.isAfter(end),
+                "人员档案入职时间晚于离职时间,请核实");
+        dates.put("start", start);
+        dates.put("end", end);
+        return dates;
+    }
+
+    /** @param userId 被填报人ID @param dayText 实际工时日期yyyyMMdd */
+    public void assertEmploymentDate(String userId, String dayText) {
+        assertDateWithinEmployment(getEmploymentDates(userId), dayText);
+    }
+
+    /** @param dates 已核实的任职闭区间 @param dayText 实际工时日期,不能使用当前记账日 */
+    static void assertDateWithinEmployment(Map<String, LocalDate> dates, String dayText) {
+        LocalDate day;
+        try {
+            day = LocalDate.parse(dayText, DateTimeFormatter.BASIC_ISO_DATE);
+        } catch (RuntimeException ex) {
+            throw new McException("INVALID_WORK_DATE", "实际工时日期必须为有效的yyyyMMdd");
+        }
+        LocalDate start = dates == null ? null : dates.get("start");
+        LocalDate end = dates == null ? null : dates.get("end");
+        McException.assertAccessException(start != null && day.isBefore(start)
+                        || end != null && day.isAfter(end),
+                "实际工时日期不在任职期间(入职前或离职后),不可保存或提交:" + dayText);
+    }
+
+    /** 空值保留兼容;非法日期必须显式失败,时间戳统一转上海业务日。 */
+    static LocalDate employmentDate(Object raw, String label) {
+        if (raw == null || String.valueOf(raw).trim().isEmpty()) return null;
+        String value = String.valueOf(raw).trim();
+        try {
+            if (value.matches("\\d{4}[-/]\\d{1,2}[-/]\\d{1,2}")) {
+                String[] parts = value.split("[-/]");
+                return LocalDate.of(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]),
+                        Integer.parseInt(parts[2]));
+            }
+            Instant instant = raw instanceof Number || value.matches("\\d+")
+                    ? Instant.ofEpochMilli(raw instanceof Number ? ((Number) raw).longValue() : Long.parseLong(value))
+                    : Instant.parse(value);
+            return instant.atZone(ZoneId.of("Asia/Shanghai")).toLocalDate();
+        } catch (RuntimeException ex) {
+            throw new McException("INVALID_EMPLOYMENT_DATE", "人员档案" + label + "异常,请核实");
+        }
     }
 
     /** 先检查全部明细填报人,再发起任何分片,防止混合审批部分成功。 */
@@ -69,6 +131,35 @@ public class TimeCardPersonnelPolicy {
         collectUsers(data, "tableField_mmd1wu9h", "textField_mmd1wu9f", "employeeField_mmd1wu9b", users);
         McException.assertAccessException(users.isEmpty(), "审批明细填报人ID不能为空");
         users.forEach(this::assertNormalUser);
+        // prd 全部日期预检完成后调用方才能开始任何审批分片,防止范围外草稿部分提交。
+        Map<String, Map<String, LocalDate>> dates = new LinkedHashMap<>();
+        if (projectApproval) assertApprovalDates(data, "tableField_mmae8t99", "textField_mmd1ozk4",
+                "employeeField_mmacxew4", "textField_mmd8g654", "dateField_mmbffvd8", dates);
+        assertApprovalDates(data, "tableField_mmd1wu9h", "textField_mmd1wu9f",
+                "employeeField_mmd1wu9b", "textField_mmd8g655", "dateField_mmd2z150", dates);
+    }
+
+    private void assertApprovalDates(Map data, String table, String userField, String memberField,
+                                     String textField, String dateField,
+                                     Map<String, Map<String, LocalDate>> profiles) {
+        Object rows = data.get(table);
+        if (!(rows instanceof List)) return;
+        for (Object item : (List) rows) {
+            Map row = (Map) item;
+            if (row.isEmpty()) continue;
+            String user = valueId(row.get(userField));
+            if (StringUtils.isBlank(user)) user = memberId(row, memberField);
+            if (!profiles.containsKey(user)) profiles.put(user, getEmploymentDates(user));
+            String day = valueId(row.get(textField));
+            LocalDate actual = employmentDate(row.get(dateField), "审批明细工时日期");
+            McException.assertAccessException(actual != null && StringUtils.isNotBlank(day)
+                            && !actual.format(DateTimeFormatter.BASIC_ISO_DATE).equals(day),
+                    "审批明细工时日期与日期文本不一致");
+            if (StringUtils.isBlank(day)) {
+                day = actual == null ? "" : actual.format(DateTimeFormatter.BASIC_ISO_DATE);
+            }
+            assertDateWithinEmployment(profiles.get(user), day);
+        }
     }
 
     /** 成员实时值优先,空值回退同行 _id;对象统一按标准顺序取人员ID。 */

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

@@ -200,6 +200,7 @@ public class TimeCardRepairService {
         McException.assertAccessException(StringUtils.isBlank(whConf.getRepairSummaryFillTypeField()), "补填汇总类型字段未配置");
         McException.assertAccessException(StringUtils.isBlank(whConf.getRepairSummarySyncStatusField()), "补填汇总状态字段未配置");
         McException.assertAccessException(request.getFormData() == null, "补填工时数据不能为空");
+        personnelPolicy.assertEmploymentDate(request.getSubmitterUserId(), request.getDayText());
         assertSourceDateAvailable(request.getSubmitterUserId(), request.getDayText(),
                 "外部".equals(personnelPolicy.getStaffType(request.getSubmitterUserId())));
         List<Map> existing = queryRepairSummaries(
@@ -689,6 +690,7 @@ public class TimeCardRepairService {
                                    Map<String, Object> otherGroup) {
         int count = 0;
         String staffType = null;
+        Map<String, LocalDate> employmentDates = personnelPolicy.getEmploymentDates(request.getSubmitterUserId());
         for (Object rawGroup : projectGroups.values()) {
             if (!(rawGroup instanceof Map)) continue;
             Map group = (Map) rawGroup;
@@ -696,6 +698,8 @@ public class TimeCardRepairService {
                 for (Object rawRow : list(group.get(category))) {
                     if (!(rawRow instanceof Map)) continue;
                     if (staffType == null) staffType = personnelPolicy.getStaffType(request.getSubmitterUserId());
+                    TimeCardPersonnelPolicy.assertDateWithinEmployment(employmentDates,
+                            repairRowDay((Map) rawRow));
                     validateRepairRow(request, (Map) rawRow, category, "外部".equals(staffType));
                     count++;
                 }
@@ -704,12 +708,19 @@ public class TimeCardRepairService {
         for (Object rawRow : list(otherGroup.get("otherRows"))) {
             if (!(rawRow instanceof Map)) continue;
             if (staffType == null) staffType = personnelPolicy.getStaffType(request.getSubmitterUserId());
+            TimeCardPersonnelPolicy.assertDateWithinEmployment(employmentDates, repairRowDay((Map) rawRow));
             validateRepairRow(request, (Map) rawRow, "otherRows", "外部".equals(staffType));
             count++;
         }
         return count;
     }
 
+    /** 补填只校验来源实际工时日,不限制离职后的提交日及当前月记账日。 */
+    private static String repairRowDay(Map row) {
+        String day = text(row.get("dateStr"));
+        return StringUtils.isBlank(day) ? text(row.get("dayText")) : day;
+    }
+
     private void assertSourceDateAvailable(String userId, String day, boolean external) {
         // prd 外部来源日由补填记录保护;内部继续核实原表锁定。
         if (external) return;

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

@@ -77,6 +77,7 @@ public class TimeCardSummaryService {
         String userId = request.getSubmitterUserId();
         String dayText = request.getDayText();
         personnelPolicy.assertNormalUser(userId);
+        personnelPolicy.assertEmploymentDate(userId, dayText);
         List<Map> existing = queryDailyRecords(whConf.getFormUuidWorkHoursSummary(), userId, dayText);
         McException.assertAccessException(existing.size() > 1, "正式工时汇总记录重复,已停止保存");
         boolean approved = existing

+ 62 - 4
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/TimeCardPersonnelPolicyTest.java

@@ -10,6 +10,7 @@ import java.util.Arrays;
 import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.Map;
+import java.time.LocalDate;
 import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.*;
@@ -98,15 +99,22 @@ class TimeCardPersonnelPolicyTest {
     @Test void shouldCollectActualUsersFromAllThreeCategoriesBeforeStarting() {
         TimeCardPersonnelPolicy policy = spy(new TimeCardPersonnelPolicy());
         doNothing().when(policy).assertNormalUser(any());
+        doReturn(Collections.emptyMap()).when(policy).getEmploymentDates(any());
         Map<String, Object> data = new LinkedHashMap<>();
-        data.put("tableField_mmae8t99", Collections.singletonList(Collections.singletonMap("textField_mmd1ozk4", "bil")));
-        data.put("tableField_mmd1wu9h", Collections.singletonList(Collections.singletonMap("employeeField_mmd1wu9b",
-                Collections.singletonList(Collections.singletonMap("key", "non")))));
+        Map<String, Object> billable = new LinkedHashMap<>();
+        billable.put("textField_mmd1ozk4", "bil");
+        billable.put("textField_mmd8g654", "20261001");
+        data.put("tableField_mmae8t99", Collections.singletonList(billable));
+        Map<String, Object> other = new LinkedHashMap<>();
+        other.put("employeeField_mmd1wu9b", Collections.singletonList(Collections.singletonMap("key", "non")));
+        other.put("textField_mmd8g655", "20261001");
+        data.put("tableField_mmd1wu9h", Collections.singletonList(other));
         policy.assertNormalApproval(data, true);
         verify(policy).assertNormalUser("bil");
         verify(policy).assertNormalUser("non");
         data.remove("tableField_mmae8t99");
-        data.put("tableField_mmd1wu9h", Collections.singletonList(Collections.singletonMap("textField_mmd1wu9f", "other")));
+        other.remove("employeeField_mmd1wu9b");
+        other.put("textField_mmd1wu9f", "other");
         policy.assertNormalApproval(data, false);
         verify(policy).assertNormalUser("other");
     }
@@ -138,4 +146,54 @@ class TimeCardPersonnelPolicyTest {
         data.put("member", Collections.singletonList("姓名"));
         assertEquals("fallback", TimeCardPersonnelPolicy.memberId(data, "member"));
     }
+
+    @Test void shouldAllowEmploymentBoundaryDaysAndBlockDatesOutside() {
+        YDClient client = mock(YDClient.class);
+        Map record = person("u", "内部");
+        Map data = (Map) record.get("formData");
+        data.put("dateField_mh8xhqc6", "2026-10-01");
+        data.put("dateField_mh8xhqc7", "2026-10-15");
+        when(client.queryData(any(), any())).thenReturn(response(record));
+        TimeCardPersonnelPolicy policy = policy(client);
+        assertDoesNotThrow(() -> policy.assertEmploymentDate("u", "20261001"));
+        assertDoesNotThrow(() -> policy.assertEmploymentDate("u", "20261015"));
+        assertThrows(McException.class, () -> policy.assertEmploymentDate("u", "20260930"));
+        assertThrows(McException.class, () -> policy.assertEmploymentDate("u", "20261016"));
+        assertThrows(McException.class, () -> policy.assertEmploymentDate("u", "20260230"));
+        data.remove("dateField_mh8xhqc6");
+        data.remove("dateField_mh8xhqc7");
+        assertDoesNotThrow(() -> policy.assertEmploymentDate("u", "20270101"));
+        data.put("dateField_mh8xhqc7", "invalid");
+        assertThrows(McException.class, () -> policy.assertEmploymentDate("u", "20261001"));
+        data.put("dateField_mh8xhqc6", "2026-10-16");
+        data.put("dateField_mh8xhqc7", "2026-10-15");
+        assertThrows(McException.class, () -> policy.getEmploymentDates("u"));
+    }
+
+    @Test void shouldInterpretTimestampAsShanghaiBusinessDate() {
+        assertEquals(LocalDate.of(2026, 10, 15), TimeCardPersonnelPolicy.employmentDate(
+                java.time.Instant.parse("2026-10-14T16:00:00Z").toEpochMilli(), "离职时间"));
+        assertEquals(LocalDate.of(2026, 10, 15), TimeCardPersonnelPolicy.employmentDate(
+                "2026-10-14T16:00:00Z", "离职时间"));
+        assertThrows(McException.class, () -> TimeCardPersonnelPolicy.employmentDate("2026-02-30", "离职时间"));
+    }
+
+    @Test void shouldRejectWholeApprovalWhenAnySourceDayIsOutsideEmployment() {
+        TimeCardPersonnelPolicy policy = spy(new TimeCardPersonnelPolicy());
+        doNothing().when(policy).assertNormalUser(any());
+        doReturn(Collections.singletonMap("end", LocalDate.of(2026, 10, 15)))
+                .when(policy).getEmploymentDates("u");
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put("textField_mmd1ozk4", "u");
+        row.put("textField_mmd8g654", "20261016");
+        Map<String, Object> data = Collections.singletonMap("tableField_mmae8t99", Collections.singletonList(row));
+        assertThrows(McException.class, () -> policy.assertNormalApproval(data, true));
+        row.put("textField_mmd8g654", "20261015");
+        assertDoesNotThrow(() -> policy.assertNormalApproval(data, true));
+        row.put("dateField_mmbffvd8", "2026-10-16");
+        assertThrows(McException.class, () -> policy.assertNormalApproval(data, true));
+        row.put("dateField_mmbffvd8", "2026-10-15");
+        row.remove("textField_mmd8g654");
+        assertDoesNotThrow(() -> policy.assertNormalApproval(data, true));
+    }
 }

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

@@ -46,6 +46,39 @@ import static org.mockito.Mockito.when;
 
 class TimeCardRepairServiceTest {
 
+    @Test
+    void shouldCheckSourceEmploymentDatesForAllRepairCategoriesAndAllowLaterSubmission() {
+        for (String category : new String[]{"billableRows", "nonBillableRows", "otherRows"}) {
+            YDClient client = mock(YDClient.class);
+            DDR_New<Object> response = new DDR_New<>();
+            response.setData(Collections.emptyList());
+            when(client.queryData(any(YDParam.class), any())).thenReturn(response);
+            TimeCardRepairService service = repairContextService(client, mock(MonthClosingService.class), repairContextConf());
+            TimeCardPersonnelPolicy policy = mock(TimeCardPersonnelPolicy.class);
+            when(policy.getStaffType("user-1")).thenReturn("外部");
+            when(policy.getEmploymentDates("user-1")).thenReturn(Collections.singletonMap(
+                    "end", java.time.LocalDate.of(2026, 9, 15)));
+            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", "20260916");
+            row.put("projectCode", "P");
+            row.put("hours", 8d);
+            Map<String, Object> group = Collections.singletonMap(category, Collections.singletonList(row));
+            Map projects = "otherRows".equals(category) ? Collections.emptyMap() : Collections.singletonMap("manager", group);
+            Map other = "otherRows".equals(category) ? group : Collections.emptyMap();
+            assertThrows(McException.class, () -> ReflectionTestUtils.invokeMethod(service,
+                    "validateSubmitRows", request, projects, other));
+            verifyNoInteractions(client);
+            row.remove("dateStr");
+            row.put("dayText", "20260915");
+            assertEquals(1, (int) ReflectionTestUtils.invokeMethod(service,
+                    "validateSubmitRows", request, projects, other));
+        }
+    }
+
     private static TimeCardPersonnelPolicy internalPolicy() {
         TimeCardPersonnelPolicy policy = mock(TimeCardPersonnelPolicy.class);
         when(policy.getStaffType(any())).thenReturn("内部");

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

@@ -32,6 +32,16 @@ import static org.mockito.Mockito.when;
 
 class TimeCardSummaryServiceTest {
 
+    @Test void shouldRejectOutsideEmploymentBeforeAnySummaryReadOrWrite() {
+        SaveFixture fixture = new SaveFixture();
+        TimeCardPersonnelPolicy policy = mock(TimeCardPersonnelPolicy.class);
+        org.mockito.Mockito.doThrow(new McException("OUTSIDE_EMPLOYMENT", "实际工时日期不在任职期间"))
+                .when(policy).assertEmploymentDate(any(), any());
+        ReflectionTestUtils.setField(fixture.service, "personnelPolicy", policy);
+        assertThrows(McException.class, () -> fixture.service.upsert(fixture.request));
+        org.mockito.Mockito.verifyNoInteractions(fixture.client);
+    }
+
     @Test void shouldRejectExternalSaveBeforeAnySummaryWrite() {
         SaveFixture fixture = new SaveFixture();
         TimeCardPersonnelPolicy policy = mock(TimeCardPersonnelPolicy.class);

+ 31 - 6
pages/src/timecard-change.oyd.jsx

@@ -47,6 +47,8 @@ var PROFILE_FIELD_IDS = {
   employmentStatus: 'radioField_mp1sngq1',
   // prd 人员档案入职时间(DateField,毫秒时间戳);入职当日可填,之前日期禁止填报
   employmentStartDate: 'dateField_mh8xhqc6',
+  employmentEndDate: 'dateField_mh8xhqc7',
+  overseasStaff: 'radioField_mvxqpaxw',
   // ppExt 主管 Manager(EmployeeField)— 用于 PM 离职兜底反查替补审批人(v2.42)
   manager: 'employeeField_mh8xhqc3'
 };
@@ -631,6 +633,8 @@ export function initPageData(userId, year, month) {
     // ppExt 当前登录用户:基础信息用页面全局变量,staffType/workNo/cfStaff 从人员档案获取
     var loginInfo = this.buildLoginUserInfo();
     loginInfo.staffType = profileInfo.staffType || '';
+      loginInfo.isOverseasStaff = profileInfo.isOverseasStaff === true;
+      loginInfo.employmentEndDate = profileInfo.employmentEndDate || '';
     if (profileInfo.workNo) loginInfo.workNo = profileInfo.workNo;
     if (profileInfo.cfStaff) loginInfo.cfStaff = profileInfo.cfStaff;
     if (profileInfo.managerId) loginInfo.managerId = profileInfo.managerId;
@@ -795,7 +799,9 @@ export function loadUserProfile(userId) {
       cfStaff: fd[PROFILE_FIELD_IDS.cfStaff] || '',
       managerId: (fd[PROFILE_FIELD_IDS.manager + '_id'] || [])[0] || '',
       managerName: (fd[PROFILE_FIELD_IDS.manager] || [])[0] || '',
-      employmentStartDate: fd[PROFILE_FIELD_IDS.employmentStartDate] || ''
+      employmentStartDate: fd[PROFILE_FIELD_IDS.employmentStartDate] || '',
+      employmentEndDate: fd[PROFILE_FIELD_IDS.employmentEndDate] || '',
+      isOverseasStaff: fd[PROFILE_FIELD_IDS.overseasStaff] === '是'
     };
   }.bind(this));
 }
@@ -982,6 +988,11 @@ export async function queryFullDetails(formUuid, formInstId, tableFieldId) {
  * @returns {Promise<Object>} holidayMap { day(1-31): '调休'|'加班' }
  */
 export function loadHolidays(year, month) {
+  // prd 海外员工只按自然周;清除国内全年缓存,供跨月请假及日期类型统一复用。
+  if ((this.state.userInfo || {}).isOverseasStaff === true) {
+    this._tcHolidayCalendarByYear = {};
+    return Promise.resolve({});
+  }
   // prd 按年份字段查询节假日,每年不超过100条,再在 JS 中过滤当月
   var holidaySearchFields = {};
   holidaySearchFields[HOLIDAY_FIELD_IDS.year] = String(year);
@@ -1094,6 +1105,7 @@ export function parseBeisenLeaveDate(value) {
  * @returns {boolean} 是否工作日
  */
 export function isBeisenLeaveWorkday(year, month, day) {
+  if ((this.state.userInfo || {}).isOverseasStaff === true) return !this.isWeekend(year, month, day);
   var dateKey = String(year) + toTwoDigitText(month) + toTwoDigitText(day);
   var yearCalendar = (this._tcHolidayCalendarByYear || {})[year] || {};
   var holidayType = yearCalendar[dateKey];
@@ -1706,7 +1718,7 @@ export function applyDefaultWorkdayHours(year, month, holidayMap, projectRows, b
   var billable = billableData || {};
   var nonBillable = nonBillableData || {};
   var other = otherData || {};
-  var holidays = holidayMap || {};
+  var holidays = (this.state.userInfo || {}).isOverseasStaff === true ? {} : holidayMap || {};
   var blocked = blockedDays || {};
   var daysInMonth = new Date(year, month, 0).getDate();
   var today = new Date();
@@ -2281,7 +2293,7 @@ export function saveDraft() {
   });
   this.showLoading();
   // prd 变更保存与提交一致:先实时确认当月开放及来源月准入,再校验员工和 yyyyMM 月份。
-  this.assertRepairMonthAvailable().then(function () {
+  this.assertChangeCalendarCurrent().then(() => this.assertRepairMonthAvailable()).then(function () {
     var contextError = this.getRepairSubmissionContextError();
     if (contextError) throw new Error(contextError);
     return this.saveTimecardData('draft');
@@ -2359,7 +2371,7 @@ export function submitForApproval() {
   });
   this.showLoading();
   // prd 提交动作第一步必须实时查询封账状态,当月封账后任何来源月均不可变更。
-  this.assertRepairMonthAvailable().then(function () {
+  this.assertChangeCalendarCurrent().then(() => this.assertRepairMonthAvailable()).then(function () {
     var contextError = this.getRepairSubmissionContextError();
     if (contextError) throw new Error(contextError);
     return Promise.resolve().then(function () {
@@ -3285,7 +3297,7 @@ export function saveTimecardData(saveType) {
 
     // prd 写入日期类型:先取常规值(工作日/周末),再按节假日覆盖
     var dayTypeVal = this.isWeekend(year, month, d) ? '周末' : '工作日';
-    var hType = (this.state.holidayMap || {})[d];
+    var hType = this.getHolidayType(d);
     if (hType === '加班') dayTypeVal = '节假日-加班';
     if (hType === '调休') dayTypeVal = '节假日-调休';
     formData[TIME_FIELD_IDS.dayType] = dayTypeVal;
@@ -3665,6 +3677,7 @@ export function isWeekend(year, month, day) {
  * @returns {boolean} 是否休息日
  */
 export function isDayOff(year, month, day) {
+  if ((this.state.userInfo || {}).isOverseasStaff === true) return this.isWeekend(year, month, day);
   var holidayMap = this.state.holidayMap || {};
   var hType = holidayMap[day];
   if (hType === '加班') return false;
@@ -3678,6 +3691,7 @@ export function isDayOff(year, month, day) {
  * @returns {string|null} '调休' | '加班' | null
  */
 export function getHolidayType(day) {
+  if ((this.state.userInfo || {}).isOverseasStaff === true) return null;
   var holidayMap = this.state.holidayMap || {};
   return holidayMap[day] || null;
 }
@@ -4966,7 +4980,7 @@ export function renderJsx() {
     var dt = new Date(selectedYear, selectedMonth - 1, d);
     var dow = dt.getDay();
     // prd 综合节假日判断:调休→休息日,加班→工作日,未维护→原逻辑
-    var hType = holidayMap[d];
+    var hType = userInfo.isOverseasStaff === true ? null : holidayMap[d];
     var isNaturalWe = dow === 0 || dow === 6;
     var isDayOff = hType === '调休' ? true : hType === '加班' ? false : isNaturalWe;
     allDays.push({
@@ -5790,3 +5804,14 @@ export function didUnmount() {
   }
   this._tcResizeHandler = null;
 }
+
+/** @returns {Promise<void>} 保存或提交变更前核实目标人员海外标签 */
+export function assertChangeCalendarCurrent() {
+  const userId = this.state.userId;
+  return this.loadUserProfile(userId).then(profile => {
+    if (this.state.userId !== userId || !profile || !profile.userName) throw new Error('人员档案核验失败 / Employee profile unavailable');
+    if (!!profile.isOverseasStaff !== !!(this.state.userInfo || {}).isOverseasStaff) {
+      throw new Error('海外员工标签已更新,请刷新页面 / Overseas status changed. Reload the page.');
+    }
+  });
+}

+ 97 - 31
pages/src/timecard-main.actions.js

@@ -32,6 +32,8 @@ const PROFILE_FIELD_IDS = {
   employmentStatus: 'radioField_mp1sngq1',
   // prd 人员档案入职时间(DateField,毫秒时间戳);入职当日可填,之前日期禁止填报
   employmentStartDate: 'dateField_mh8xhqc6',
+  employmentEndDate: 'dateField_mh8xhqc7',
+  overseasStaff: 'radioField_mvxqpaxw',
   // ppExt 主管 Manager(EmployeeField)— 用于 PM 离职兜底反查替补审批人(v2.42)
   manager: 'employeeField_mh8xhqc3',
 };
@@ -320,8 +322,11 @@ var TC_I18N = {
     statusEditable: '可编辑',
     statusSubmitted: '已提交(不可修改)',
     statusApproved: '已审批(不可修改)',
-    statusReadonly: '不可填写(外部 / 已下线 / 入职前)',
-    preEmploymentLocked: '入职日期之前不可填写',
+    statusReadonly: '不可填写(外部 / 已下线 / 任职期外)',
+    preEmploymentLocked: '任职期间以外不可填写(入职前/离职后)',
+    employmentInvalid: '人员档案入离职日期异常,请核实后重试',
+    employmentDraftOutside: '以下日期不在任职期间,草稿不能保存或提交:',
+    personnelCalendarChanged: '人员档案日期或海外标签已更新,请刷新页面重新加载后再保存或提交',
     sectionBillable: '▸ Billable 工时',
     sectionNonBillable: '▸ Non Billable 工时',
     sectionOther: '▸ 其他工时 Other Activities',
@@ -370,8 +375,11 @@ var TC_I18N = {
     statusEditable: 'Editable',
     statusSubmitted: 'Submitted (Locked)',
     statusApproved: 'Approved (Locked)',
-    statusReadonly: 'N/A (External / Offline / Before employment)',
-    preEmploymentLocked: 'Dates before the employment start date cannot be entered.',
+    statusReadonly: 'N/A (External / Offline / Outside employment)',
+    preEmploymentLocked: 'Dates outside the employment period cannot be entered.',
+    employmentInvalid: 'Invalid employment dates. Please check the employee profile.',
+    employmentDraftOutside: 'Draft hours outside the employment period cannot be saved or submitted: ',
+    personnelCalendarChanged: 'Employment dates or overseas status changed. Reload the page before saving or submitting.',
     sectionBillable: '▸ Billable Hours',
     sectionNonBillable: '▸ Non-Billable Hours',
     sectionOther: '▸ Other Activities',
@@ -562,6 +570,8 @@ export function initPageData(userId, year, month) {
       // ppExt 当前登录用户:基础信息用页面全局变量,staffType/workNo/cfStaff 从人员档案获取
       var loginInfo = this.buildLoginUserInfo();
       loginInfo.staffType = profileInfo.staffType || '';
+      loginInfo.isOverseasStaff = profileInfo.isOverseasStaff === true;
+      loginInfo.employmentEndDate = profileInfo.employmentEndDate || '';
       loginInfo.personnelTypeVerified = profileInfo.personnelTypeVerified === true;
       if (profileInfo.workNo) loginInfo.workNo = profileInfo.workNo;
       if (profileInfo.cfStaff) loginInfo.cfStaff = profileInfo.cfStaff;
@@ -741,6 +751,8 @@ export function loadUserProfile(userId) {
       managerId: (fd[PROFILE_FIELD_IDS.manager + '_id'] || [])[0] || '',
       managerName: (fd[PROFILE_FIELD_IDS.manager] || [])[0] || '',
       employmentStartDate: fd[PROFILE_FIELD_IDS.employmentStartDate] || '',
+      employmentEndDate: fd[PROFILE_FIELD_IDS.employmentEndDate] || '',
+      isOverseasStaff: fd[PROFILE_FIELD_IDS.overseasStaff] === '是',
     };
   }.bind(this));
 }
@@ -770,6 +782,7 @@ export async function assertNormalTimecardOpen() {
   this.setState({ personnelTypeVerified: verified, isExternalStaff: !!profile && profile.staffType === '外部' });
   if (!verified) throw new Error(this.getTexts().personnelTypeCheckFailed);
   if (profile.staffType === '外部') throw new Error(this.getTexts().externalStaffTimecardBlocked);
+  this.assertPersonnelSnapshot(profile);
   return true;
 }
 
@@ -1060,6 +1073,11 @@ export async function queryFullDetails(formUuid, formInstId, tableFieldId) {
  * @returns {Promise<Object>} holidayMap { day(1-31): '调休'|'加班' }
  */
 export function loadHolidays(year, month) {
+  // prd 海外员工只按自然周;清除国内全年缓存,供跨月请假及日期类型统一复用。
+  if ((this.state.userInfo || {}).isOverseasStaff === true) {
+    this._tcHolidayCalendarByYear = {};
+    return Promise.resolve({});
+  }
   // prd 按年份字段查询节假日,每年不超过100条,再在 JS 中过滤当月
   var searchFieldJson = JSON.stringify({
     [HOLIDAY_FIELD_IDS.year]: String(year),
@@ -1172,6 +1190,7 @@ export function parseBeisenLeaveDate(value) {
  * @returns {boolean} 是否工作日
  */
 export function isBeisenLeaveWorkday(year, month, day) {
+  if ((this.state.userInfo || {}).isOverseasStaff === true) return !this.isWeekend(year, month, day);
   const dateKey = String(year) + String(month).padStart(2, '0') + String(day).padStart(2, '0');
   const yearCalendar = (this._tcHolidayCalendarByYear || {})[year] || {};
   const holidayType = yearCalendar[dateKey];
@@ -1777,7 +1796,7 @@ export function applyDefaultWorkdayHours(
   var billable = billableData || {};
   var nonBillable = nonBillableData || {};
   var other = otherData || {};
-  var holidays = holidayMap || {};
+  var holidays = (this.state.userInfo || {}).isOverseasStaff === true ? {} : holidayMap || {};
   var daysInMonth = new Date(year, month, 0).getDate();
   var today = new Date();
   var targetMonthKey = year * 12 + month;
@@ -1937,36 +1956,16 @@ export function nextMonth() {
 // ============================================================
 
 /**
- * 判断指定日期是否早于当前员工入职日期。
- * prd 入职当日可填写;人员档案未维护或日期无效时保持原行为。
+ * 兼容入口:判断指定日期是否位于当前员工任职期间外。
+ * prd 入职和离职当日可填写;空日期不设边界,非法日期显式报错。
  * @param {number} year - 年份
  * @param {number} month - 月份 1-12
  * @param {number} day - 日 1-31
- * @returns {boolean} 是否为入职前日期
+ * @returns {boolean} 是否为任职期间外日期
  */
 export function isBeforeEmploymentDate(year, month, day) {
-  var raw = this.state && this.state.userInfo
-    ? this.state.userInfo.employmentStartDate
-    : '';
-  if (raw === undefined || raw === null || raw === '') return false;
-
-  var employmentDate;
-  if (typeof raw === 'string' && /^\d{4}[-/]\d{1,2}[-/]\d{1,2}/.test(raw)) {
-    var parts = raw.substring(0, 10).split(/[-/]/);
-    employmentDate = new Date(
-      parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10)
-    );
-  } else {
-    var timestamp = typeof raw === 'string' ? parseInt(raw, 10) : raw;
-    employmentDate = new Date(timestamp);
-  }
-  if (isNaN(employmentDate.getTime())) return false;
-
-  var employmentKey = employmentDate.getFullYear() * 10000
-    + (employmentDate.getMonth() + 1) * 100
-    + employmentDate.getDate();
-  var targetKey = year * 10000 + month * 100 + day;
-  return targetKey < employmentKey;
+  // ppExt 兼容已有输入、默认值、保存及审批入口;现在统一限制任职闭区间。
+  return this.isOutsideEmploymentDates(year, month, day);
 }
 
 /**
@@ -2814,7 +2813,7 @@ export async function saveTimecardData(saveType) {
 
     // prd 写入日期类型:先取常规值(工作日/周末),再按节假日覆盖
     var dayTypeVal = this.isWeekend(year, month, d) ? '周末' : '工作日';
-    var hType = (this.state.holidayMap || {})[d];
+    var hType = this.getHolidayType(d);
     if (hType === '加班') dayTypeVal = '节假日-加班';
     if (hType === '调休') dayTypeVal = '节假日-调休';
     formData[TIME_FIELD_IDS.dayType] = dayTypeVal;
@@ -3199,6 +3198,7 @@ export function isWeekend(year, month, day) {
  * @returns {boolean} 是否休息日
  */
 export function isDayOff(year, month, day) {
+  if ((this.state.userInfo || {}).isOverseasStaff === true) return this.isWeekend(year, month, day);
   var holidayMap = this.state.holidayMap || {};
   var hType = holidayMap[day];
   if (hType === '加班') return false;
@@ -3212,6 +3212,7 @@ export function isDayOff(year, month, day) {
  * @returns {string|null} '调休' | '加班' | null
  */
 export function getHolidayType(day) {
+  if ((this.state.userInfo || {}).isOverseasStaff === true) return null;
   var holidayMap = this.state.holidayMap || {};
   return holidayMap[day] || null;
 }
@@ -4402,3 +4403,68 @@ export async function createOtherApprovalInstance(deptManagerId, otherGroup, mon
   }
   return results.length === 1 ? results[0] : results;
 }
+
+
+/** @param {number|string} raw 日期字段值 @returns {number} 上海业务日期yyyyMMdd,空值为0 */
+export function getEmploymentDateKey(raw) {
+  if (raw === undefined || raw === null || raw === '') return 0;
+  const text = String(raw).trim();
+  let date;
+  if (/^\d{4}[-/]\d{1,2}[-/]\d{1,2}$/.test(text)) {
+    const parts = text.split(/[-/]/).map(value => Number(value));
+    date = new Date(Date.UTC(parts[0], parts[1] - 1, parts[2]));
+    if (date.getUTCFullYear() !== parts[0] || date.getUTCMonth() + 1 !== parts[1] || date.getUTCDate() !== parts[2]) date = new Date(NaN);
+  } else {
+    const epoch = /^\d+$/.test(text) ? Number(text) : Date.parse(text);
+    date = new Date(epoch + 8 * 60 * 60 * 1000);
+  }
+  if (!Number.isFinite(date.getTime())) throw new Error(this.getTexts().employmentInvalid);
+  return date.getUTCFullYear() * 10000 + (date.getUTCMonth() + 1) * 100 + date.getUTCDate();
+}
+
+/** @param {Object} profile 人员档案 @returns {Array<number>} 任职闭区间,0表示未维护 */
+export function getEmploymentBounds(profile) {
+  const info = profile || {};
+  const start = this.getEmploymentDateKey(info.employmentStartDate);
+  const end = this.getEmploymentDateKey(info.employmentEndDate);
+  if (start && end && start > end) throw new Error(this.getTexts().employmentInvalid);
+  return [start, end];
+}
+
+/** @param {number} year 年 @param {number} month 月 @param {number} day 日 @returns {boolean} 任职期间外 */
+export function isOutsideEmploymentDates(year, month, day) {
+  const bounds = this.getEmploymentBounds((this.state || {}).userInfo);
+  const target = year * 10000 + month * 100 + day;
+  return !!((bounds[0] && target < bounds[0]) || (bounds[1] && target > bounds[1]));
+}
+
+/** @returns {void} 明确阻止范围外草稿被静默过滤后部分提交,已锁定历史记录只读保留 */
+export function assertEmploymentInputs() {
+  const year = this.state.selectedYear;
+  const month = this.state.selectedMonth;
+  const invalid = [];
+  for (let day = 1; day <= new Date(year, month, 0).getDate(); day++) {
+    if (!this.isOutsideEmploymentDates(year, month, day)) continue;
+    if ((this.state.lockedDays || {})[day] || (this.state.originalBlockedDays || {})[day] || (this.state.repairBlockedDays || {})[day]) continue;
+    const hasDraft = ['billable', 'nonBillable', 'other'].some(section => {
+      const data = this.state[section + 'Data'] || {};
+      return Object.keys(data).some(row => {
+        const status = (this.state.cellStatusMap || {})[section + '_' + row + '_' + day];
+        return status !== 'approved' && status !== 'submitted' && Math.abs(Number((data[row] || {})[day]) || 0) > 0;
+      });
+    });
+    if (hasDraft) invalid.push(String(year * 10000 + month * 100 + day));
+  }
+  if (invalid.length) throw new Error(this.getTexts().employmentDraftOutside + invalid.join(', '));
+}
+
+/** @param {Object} profile 实时人员档案 @returns {void} 档案变化必须重新加载,避免旧默认值和日历继续提交 */
+export function assertPersonnelSnapshot(profile) {
+  const current = (this.state || {}).userInfo || {};
+  const before = this.getEmploymentBounds(current);
+  const after = this.getEmploymentBounds(profile);
+  if (before.join(',') !== after.join(',') || !!current.isOverseasStaff !== !!profile.isOverseasStaff) {
+    throw new Error(this.getTexts().personnelCalendarChanged);
+  }
+  this.assertEmploymentInputs();
+}

+ 1 - 1
pages/src/timecard-main.render.jsx

@@ -58,7 +58,7 @@ function render() {
     const dt = new Date(selectedYear, selectedMonth - 1, d);
     const dow = dt.getDay();
     // prd 综合节假日判断:调休→休息日,加班→工作日,未维护→原逻辑
-    const hType = holidayMap[d];
+    const hType = userInfo.isOverseasStaff === true ? null : 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 });

+ 109 - 28
pages/src/timecard-repair-lifecycle.oyd.jsx

@@ -37,6 +37,8 @@ var PROFILE_FIELD_IDS = {
   employmentStatus: 'radioField_mp1sngq1',
   // prd 人员档案入职时间(DateField,毫秒时间戳);入职当日可填,之前日期禁止填报
   employmentStartDate: 'dateField_mh8xhqc6',
+  employmentEndDate: 'dateField_mh8xhqc7',
+  overseasStaff: 'radioField_mvxqpaxw',
   // ppExt 主管 Manager(EmployeeField)— 用于 PM 离职兜底反查替补审批人(v2.42)
   manager: 'employeeField_mh8xhqc3'
 };
@@ -342,8 +344,11 @@ var TC_I18N = {
     statusEditable: '可编辑',
     statusSubmitted: '已提交(不可修改)',
     statusApproved: '已审批(不可修改)',
-    statusReadonly: '不可填写(外部 / 已下线 / 入职前)',
-    preEmploymentLocked: '入职日期之前不可填写',
+    statusReadonly: '不可填写(外部 / 已下线 / 任职期外)',
+    preEmploymentLocked: '任职期间以外不可填写(入职前/离职后)',
+    employmentInvalid: '人员档案入离职日期异常,请核实后重试',
+    employmentDraftOutside: '以下日期不在任职期间,草稿不能保存或提交:',
+    personnelCalendarChanged: '人员档案日期或海外标签已更新,请刷新页面重新加载后再保存或提交',
     sectionBillable: '▸ Billable 工时',
     sectionNonBillable: '▸ Non Billable 工时',
     sectionOther: '▸ 其他工时 Other Activities',
@@ -403,8 +408,11 @@ var TC_I18N = {
     statusEditable: 'Editable',
     statusSubmitted: 'Submitted (Locked)',
     statusApproved: 'Approved (Locked)',
-    statusReadonly: 'N/A (External / Offline / Before employment)',
-    preEmploymentLocked: 'Dates before the employment start date cannot be entered.',
+    statusReadonly: 'N/A (External / Offline / Outside employment)',
+    preEmploymentLocked: 'Dates outside the employment period cannot be entered.',
+    employmentInvalid: 'Invalid employment dates. Please check the employee profile.',
+    employmentDraftOutside: 'Draft hours outside the employment period cannot be saved or submitted: ',
+    personnelCalendarChanged: 'Employment dates or overseas status changed. Reload the page before saving or submitting.',
     sectionBillable: '▸ Billable Hours',
     sectionNonBillable: '▸ Non-Billable Hours',
     sectionOther: '▸ Other Activities',
@@ -628,6 +636,8 @@ export function initPageData(userId, year, month) {
     // ppExt 当前登录用户:基础信息用页面全局变量,staffType/workNo/cfStaff 从人员档案获取
     var loginInfo = this.buildLoginUserInfo();
     loginInfo.staffType = profileInfo.staffType || '';
+      loginInfo.isOverseasStaff = profileInfo.isOverseasStaff === true;
+      loginInfo.employmentEndDate = profileInfo.employmentEndDate || '';
     if (profileInfo.workNo) loginInfo.workNo = profileInfo.workNo;
     if (profileInfo.cfStaff) loginInfo.cfStaff = profileInfo.cfStaff;
     if (profileInfo.managerId) loginInfo.managerId = profileInfo.managerId;
@@ -839,7 +849,9 @@ export function loadUserProfile(userId) {
       cfStaff: fd[PROFILE_FIELD_IDS.cfStaff] || '',
       managerId: (fd[PROFILE_FIELD_IDS.manager + '_id'] || [])[0] || '',
       managerName: (fd[PROFILE_FIELD_IDS.manager] || [])[0] || '',
-      employmentStartDate: fd[PROFILE_FIELD_IDS.employmentStartDate] || ''
+      employmentStartDate: fd[PROFILE_FIELD_IDS.employmentStartDate] || '',
+      employmentEndDate: fd[PROFILE_FIELD_IDS.employmentEndDate] || '',
+      isOverseasStaff: fd[PROFILE_FIELD_IDS.overseasStaff] === '是'
     };
   }.bind(this));
 }
@@ -1026,6 +1038,11 @@ export async function queryFullDetails(formUuid, formInstId, tableFieldId) {
  * @returns {Promise<Object>} holidayMap { day(1-31): '调休'|'加班' }
  */
 export function loadHolidays(year, month) {
+  // prd 海外员工只按自然周;清除国内全年缓存,供跨月请假及日期类型统一复用。
+  if ((this.state.userInfo || {}).isOverseasStaff === true) {
+    this._tcHolidayCalendarByYear = {};
+    return Promise.resolve({});
+  }
   // prd 按年份字段查询节假日,每年不超过100条,再在 JS 中过滤当月
   var holidaySearchFields = {};
   holidaySearchFields[HOLIDAY_FIELD_IDS.year] = String(year);
@@ -1138,6 +1155,7 @@ export function parseBeisenLeaveDate(value) {
  * @returns {boolean} 是否工作日
  */
 export function isBeisenLeaveWorkday(year, month, day) {
+  if ((this.state.userInfo || {}).isOverseasStaff === true) return !this.isWeekend(year, month, day);
   var dateKey = String(year) + toTwoDigitText(month) + toTwoDigitText(day);
   var yearCalendar = (this._tcHolidayCalendarByYear || {})[year] || {};
   var holidayType = yearCalendar[dateKey];
@@ -1784,7 +1802,7 @@ export function applyDefaultWorkdayHours(year, month, holidayMap, projectRows, b
   var billable = billableData || {};
   var nonBillable = nonBillableData || {};
   var other = otherData || {};
-  var holidays = holidayMap || {};
+  var holidays = (this.state.userInfo || {}).isOverseasStaff === true ? {} : holidayMap || {};
   var blocked = blockedDays || {};
   var daysInMonth = new Date(year, month, 0).getDate();
   var today = new Date();
@@ -1977,9 +1995,14 @@ export function getRepairSubmitterStaffType() {
     var staffType = data[PROFILE_FIELD_IDS.staffType];
     // fixme 唯一记录与真实被填报人 ID 必须同时成立,属性缺失或重复时不得豁免暂存校验。
     if (ids.length !== 1 || ids[0] !== userId || ['内部', '外部'].indexOf(staffType) < 0) throw new Error(failureText);
+    this.assertPersonnelSnapshot({
+      employmentStartDate: data[PROFILE_FIELD_IDS.employmentStartDate] || '',
+      employmentEndDate: data[PROFILE_FIELD_IDS.employmentEndDate] || '',
+      isOverseasStaff: data[PROFILE_FIELD_IDS.overseasStaff] === '是'
+    });
     return staffType;
-  }).catch(() => {
-    throw new Error(failureText);
+  }).catch(error => {
+    throw new Error(error && error.message || failureText);
   });
 }
 
@@ -1993,6 +2016,9 @@ export function getMissingOriginalSummaryDay() {
     if (String(this.state.userId || '').trim() !== submitterUserId) throw new Error(this.getTexts().staffTypeCheckFailed);
     // prd 外部人员通过补填填报,只豁免当天正常 TimeCard 暂存工时校验。
     if (staffType === '外部') return '';
+    var businessToday = new Date(Date.now() + 8 * 60 * 60 * 1000);
+    // prd 任职期外提交日无需正常TimeCard暂存,仍严格校验补填来源日期。
+    if (this.isOutsideEmploymentDates(businessToday.getUTCFullYear(), businessToday.getUTCMonth() + 1, businessToday.getUTCDate())) return '';
     var today = new Date();
     var monthText = this.getRepairMonthText(today.getFullYear(), today.getMonth() + 1);
     var dayText = monthText + toTwoDigitText(today.getDate());
@@ -2072,28 +2098,16 @@ export function nextMonth() {
 // ============================================================
 
 /**
- * 判断指定日期是否早于当前员工入职日期。
- * prd 入职当日可填写;人员档案未维护或日期无效时保持原行为。
+ * 兼容入口:判断指定日期是否位于当前员工任职期间外。
+ * prd 入职和离职当日可填写;空日期不设边界,非法日期显式报错。
  * @param {number} year - 年份
  * @param {number} month - 月份 1-12
  * @param {number} day - 日 1-31
- * @returns {boolean} 是否为入职前日期
+ * @returns {boolean} 是否为任职期间外日期
  */
 export function isBeforeEmploymentDate(year, month, day) {
-  var raw = this.state && this.state.userInfo ? this.state.userInfo.employmentStartDate : '';
-  if (raw === undefined || raw === null || raw === '') return false;
-  var employmentDate;
-  if (typeof raw === 'string' && /^\d{4}[-/]\d{1,2}[-/]\d{1,2}/.test(raw)) {
-    var parts = raw.substring(0, 10).split(/[-/]/);
-    employmentDate = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10));
-  } else {
-    var timestamp = typeof raw === 'string' ? parseInt(raw, 10) : raw;
-    employmentDate = new Date(timestamp);
-  }
-  if (isNaN(employmentDate.getTime())) return false;
-  var employmentKey = employmentDate.getFullYear() * 10000 + (employmentDate.getMonth() + 1) * 100 + employmentDate.getDate();
-  var targetKey = year * 10000 + month * 100 + day;
-  return targetKey < employmentKey;
+  // ppExt 兼容已有输入、默认值、保存及审批入口;现在统一限制任职闭区间。
+  return this.isOutsideEmploymentDates(year, month, day);
 }
 
 /**
@@ -2457,7 +2471,7 @@ export function saveDraft() {
   });
   this.showLoading();
   // prd 补填保存与提交一致:先实时确认月份已封账,再校验员工和 yyyyMM 月份。
-  this.assertRepairMonthAvailable().then(function () {
+  this.getRepairSubmitterStaffType().then(() => this.assertRepairMonthAvailable()).then(function () {
     var contextError = this.getRepairSubmissionContextError();
     if (contextError) throw new Error(contextError);
     return this.saveTimecardData('draft');
@@ -3304,7 +3318,7 @@ export function saveTimecardData(saveType) {
 
     // prd 写入日期类型:先取常规值(工作日/周末),再按节假日覆盖
     var dayTypeVal = this.isWeekend(year, month, d) ? '周末' : '工作日';
-    var hType = (this.state.holidayMap || {})[d];
+    var hType = this.getHolidayType(d);
     if (hType === '加班') dayTypeVal = '节假日-加班';
     if (hType === '调休') dayTypeVal = '节假日-调休';
     formData[TIME_FIELD_IDS.dayType] = dayTypeVal;
@@ -3698,6 +3712,7 @@ export function isWeekend(year, month, day) {
  * @returns {boolean} 是否休息日
  */
 export function isDayOff(year, month, day) {
+  if ((this.state.userInfo || {}).isOverseasStaff === true) return this.isWeekend(year, month, day);
   var holidayMap = this.state.holidayMap || {};
   var hType = holidayMap[day];
   if (hType === '加班') return false;
@@ -3711,6 +3726,7 @@ export function isDayOff(year, month, day) {
  * @returns {string|null} '调休' | '加班' | null
  */
 export function getHolidayType(day) {
+  if ((this.state.userInfo || {}).isOverseasStaff === true) return null;
   var holidayMap = this.state.holidayMap || {};
   return holidayMap[day] || null;
 }
@@ -4997,7 +5013,7 @@ export function renderJsx() {
     var dt = new Date(selectedYear, selectedMonth - 1, d);
     var dow = dt.getDay();
     // prd 综合节假日判断:调休→休息日,加班→工作日,未维护→原逻辑
-    var hType = holidayMap[d];
+    var hType = userInfo.isOverseasStaff === true ? null : holidayMap[d];
     var isNaturalWe = dow === 0 || dow === 6;
     var isDayOff = hType === '调休' ? true : hType === '加班' ? false : isNaturalWe;
     allDays.push({
@@ -5827,3 +5843,68 @@ export function didUnmount() {
   }
   this._tcResizeHandler = null;
 }
+
+
+/** @param {number|string} raw 日期字段值 @returns {number} 上海业务日期yyyyMMdd,空值为0 */
+export function getEmploymentDateKey(raw) {
+  if (raw === undefined || raw === null || raw === '') return 0;
+  const text = String(raw).trim();
+  let date;
+  if (/^\d{4}[-/]\d{1,2}[-/]\d{1,2}$/.test(text)) {
+    const parts = text.split(/[-/]/).map(value => Number(value));
+    date = new Date(Date.UTC(parts[0], parts[1] - 1, parts[2]));
+    if (date.getUTCFullYear() !== parts[0] || date.getUTCMonth() + 1 !== parts[1] || date.getUTCDate() !== parts[2]) date = new Date(NaN);
+  } else {
+    const epoch = /^\d+$/.test(text) ? Number(text) : Date.parse(text);
+    date = new Date(epoch + 8 * 60 * 60 * 1000);
+  }
+  if (!Number.isFinite(date.getTime())) throw new Error(this.getTexts().employmentInvalid);
+  return date.getUTCFullYear() * 10000 + (date.getUTCMonth() + 1) * 100 + date.getUTCDate();
+}
+
+/** @param {Object} profile 人员档案 @returns {Array<number>} 任职闭区间,0表示未维护 */
+export function getEmploymentBounds(profile) {
+  const info = profile || {};
+  const start = this.getEmploymentDateKey(info.employmentStartDate);
+  const end = this.getEmploymentDateKey(info.employmentEndDate);
+  if (start && end && start > end) throw new Error(this.getTexts().employmentInvalid);
+  return [start, end];
+}
+
+/** @param {number} year 年 @param {number} month 月 @param {number} day 日 @returns {boolean} 任职期间外 */
+export function isOutsideEmploymentDates(year, month, day) {
+  const bounds = this.getEmploymentBounds((this.state || {}).userInfo);
+  const target = year * 10000 + month * 100 + day;
+  return !!((bounds[0] && target < bounds[0]) || (bounds[1] && target > bounds[1]));
+}
+
+/** @returns {void} 明确阻止范围外草稿被静默过滤后部分提交,已锁定历史记录只读保留 */
+export function assertEmploymentInputs() {
+  const year = this.state.selectedYear;
+  const month = this.state.selectedMonth;
+  const invalid = [];
+  for (let day = 1; day <= new Date(year, month, 0).getDate(); day++) {
+    if (!this.isOutsideEmploymentDates(year, month, day)) continue;
+    if ((this.state.lockedDays || {})[day] || (this.state.originalBlockedDays || {})[day] || (this.state.repairBlockedDays || {})[day]) continue;
+    const hasDraft = ['billable', 'nonBillable', 'other'].some(section => {
+      const data = this.state[section + 'Data'] || {};
+      return Object.keys(data).some(row => {
+        const status = (this.state.cellStatusMap || {})[section + '_' + row + '_' + day];
+        return status !== 'approved' && status !== 'submitted' && Math.abs(Number((data[row] || {})[day]) || 0) > 0;
+      });
+    });
+    if (hasDraft) invalid.push(String(year * 10000 + month * 100 + day));
+  }
+  if (invalid.length) throw new Error(this.getTexts().employmentDraftOutside + invalid.join(', '));
+}
+
+/** @param {Object} profile 实时人员档案 @returns {void} 档案变化必须重新加载,避免旧默认值和日历继续提交 */
+export function assertPersonnelSnapshot(profile) {
+  const current = (this.state || {}).userInfo || {};
+  const before = this.getEmploymentBounds(current);
+  const after = this.getEmploymentBounds(profile);
+  if (before.join(',') !== after.join(',') || !!current.isOverseasStaff !== !!profile.isOverseasStaff) {
+    throw new Error(this.getTexts().personnelCalendarChanged);
+  }
+  this.assertEmploymentInputs();
+}

+ 109 - 28
pages/src/timecard-repair.oyd.jsx

@@ -37,6 +37,8 @@ var PROFILE_FIELD_IDS = {
   employmentStatus: 'radioField_mp1sngq1',
   // prd 人员档案入职时间(DateField,毫秒时间戳);入职当日可填,之前日期禁止填报
   employmentStartDate: 'dateField_mh8xhqc6',
+  employmentEndDate: 'dateField_mh8xhqc7',
+  overseasStaff: 'radioField_mvxqpaxw',
   // ppExt 主管 Manager(EmployeeField)— 用于 PM 离职兜底反查替补审批人(v2.42)
   manager: 'employeeField_mh8xhqc3'
 };
@@ -342,8 +344,11 @@ var TC_I18N = {
     statusEditable: '可编辑',
     statusSubmitted: '已提交(不可修改)',
     statusApproved: '已审批(不可修改)',
-    statusReadonly: '不可填写(外部 / 已下线 / 入职前)',
-    preEmploymentLocked: '入职日期之前不可填写',
+    statusReadonly: '不可填写(外部 / 已下线 / 任职期外)',
+    preEmploymentLocked: '任职期间以外不可填写(入职前/离职后)',
+    employmentInvalid: '人员档案入离职日期异常,请核实后重试',
+    employmentDraftOutside: '以下日期不在任职期间,草稿不能保存或提交:',
+    personnelCalendarChanged: '人员档案日期或海外标签已更新,请刷新页面重新加载后再保存或提交',
     sectionBillable: '▸ Billable 工时',
     sectionNonBillable: '▸ Non Billable 工时',
     sectionOther: '▸ 其他工时 Other Activities',
@@ -403,8 +408,11 @@ var TC_I18N = {
     statusEditable: 'Editable',
     statusSubmitted: 'Submitted (Locked)',
     statusApproved: 'Approved (Locked)',
-    statusReadonly: 'N/A (External / Offline / Before employment)',
-    preEmploymentLocked: 'Dates before the employment start date cannot be entered.',
+    statusReadonly: 'N/A (External / Offline / Outside employment)',
+    preEmploymentLocked: 'Dates outside the employment period cannot be entered.',
+    employmentInvalid: 'Invalid employment dates. Please check the employee profile.',
+    employmentDraftOutside: 'Draft hours outside the employment period cannot be saved or submitted: ',
+    personnelCalendarChanged: 'Employment dates or overseas status changed. Reload the page before saving or submitting.',
     sectionBillable: '▸ Billable Hours',
     sectionNonBillable: '▸ Non-Billable Hours',
     sectionOther: '▸ Other Activities',
@@ -628,6 +636,8 @@ export function initPageData(userId, year, month) {
     // ppExt 当前登录用户:基础信息用页面全局变量,staffType/workNo/cfStaff 从人员档案获取
     var loginInfo = this.buildLoginUserInfo();
     loginInfo.staffType = profileInfo.staffType || '';
+      loginInfo.isOverseasStaff = profileInfo.isOverseasStaff === true;
+      loginInfo.employmentEndDate = profileInfo.employmentEndDate || '';
     if (profileInfo.workNo) loginInfo.workNo = profileInfo.workNo;
     if (profileInfo.cfStaff) loginInfo.cfStaff = profileInfo.cfStaff;
     if (profileInfo.managerId) loginInfo.managerId = profileInfo.managerId;
@@ -839,7 +849,9 @@ export function loadUserProfile(userId) {
       cfStaff: fd[PROFILE_FIELD_IDS.cfStaff] || '',
       managerId: (fd[PROFILE_FIELD_IDS.manager + '_id'] || [])[0] || '',
       managerName: (fd[PROFILE_FIELD_IDS.manager] || [])[0] || '',
-      employmentStartDate: fd[PROFILE_FIELD_IDS.employmentStartDate] || ''
+      employmentStartDate: fd[PROFILE_FIELD_IDS.employmentStartDate] || '',
+      employmentEndDate: fd[PROFILE_FIELD_IDS.employmentEndDate] || '',
+      isOverseasStaff: fd[PROFILE_FIELD_IDS.overseasStaff] === '是'
     };
   }.bind(this));
 }
@@ -1026,6 +1038,11 @@ export async function queryFullDetails(formUuid, formInstId, tableFieldId) {
  * @returns {Promise<Object>} holidayMap { day(1-31): '调休'|'加班' }
  */
 export function loadHolidays(year, month) {
+  // prd 海外员工只按自然周;清除国内全年缓存,供跨月请假及日期类型统一复用。
+  if ((this.state.userInfo || {}).isOverseasStaff === true) {
+    this._tcHolidayCalendarByYear = {};
+    return Promise.resolve({});
+  }
   // prd 按年份字段查询节假日,每年不超过100条,再在 JS 中过滤当月
   var holidaySearchFields = {};
   holidaySearchFields[HOLIDAY_FIELD_IDS.year] = String(year);
@@ -1138,6 +1155,7 @@ export function parseBeisenLeaveDate(value) {
  * @returns {boolean} 是否工作日
  */
 export function isBeisenLeaveWorkday(year, month, day) {
+  if ((this.state.userInfo || {}).isOverseasStaff === true) return !this.isWeekend(year, month, day);
   var dateKey = String(year) + toTwoDigitText(month) + toTwoDigitText(day);
   var yearCalendar = (this._tcHolidayCalendarByYear || {})[year] || {};
   var holidayType = yearCalendar[dateKey];
@@ -1784,7 +1802,7 @@ export function applyDefaultWorkdayHours(year, month, holidayMap, projectRows, b
   var billable = billableData || {};
   var nonBillable = nonBillableData || {};
   var other = otherData || {};
-  var holidays = holidayMap || {};
+  var holidays = (this.state.userInfo || {}).isOverseasStaff === true ? {} : holidayMap || {};
   var blocked = blockedDays || {};
   var daysInMonth = new Date(year, month, 0).getDate();
   var today = new Date();
@@ -1977,9 +1995,14 @@ export function getRepairSubmitterStaffType() {
     var staffType = data[PROFILE_FIELD_IDS.staffType];
     // fixme 唯一记录与真实被填报人 ID 必须同时成立,属性缺失或重复时不得豁免暂存校验。
     if (ids.length !== 1 || ids[0] !== userId || ['内部', '外部'].indexOf(staffType) < 0) throw new Error(failureText);
+    this.assertPersonnelSnapshot({
+      employmentStartDate: data[PROFILE_FIELD_IDS.employmentStartDate] || '',
+      employmentEndDate: data[PROFILE_FIELD_IDS.employmentEndDate] || '',
+      isOverseasStaff: data[PROFILE_FIELD_IDS.overseasStaff] === '是'
+    });
     return staffType;
-  }).catch(() => {
-    throw new Error(failureText);
+  }).catch(error => {
+    throw new Error(error && error.message || failureText);
   });
 }
 
@@ -1993,6 +2016,9 @@ export function getMissingOriginalSummaryDay() {
     if (String(this.state.userId || '').trim() !== submitterUserId) throw new Error(this.getTexts().staffTypeCheckFailed);
     // prd 外部人员通过补填填报,只豁免当天正常 TimeCard 暂存工时校验。
     if (staffType === '外部') return '';
+    var businessToday = new Date(Date.now() + 8 * 60 * 60 * 1000);
+    // prd 任职期外提交日无需正常TimeCard暂存,仍严格校验补填来源日期。
+    if (this.isOutsideEmploymentDates(businessToday.getUTCFullYear(), businessToday.getUTCMonth() + 1, businessToday.getUTCDate())) return '';
     var today = new Date();
     var monthText = this.getRepairMonthText(today.getFullYear(), today.getMonth() + 1);
     var dayText = monthText + toTwoDigitText(today.getDate());
@@ -2072,28 +2098,16 @@ export function nextMonth() {
 // ============================================================
 
 /**
- * 判断指定日期是否早于当前员工入职日期。
- * prd 入职当日可填写;人员档案未维护或日期无效时保持原行为。
+ * 兼容入口:判断指定日期是否位于当前员工任职期间外。
+ * prd 入职和离职当日可填写;空日期不设边界,非法日期显式报错。
  * @param {number} year - 年份
  * @param {number} month - 月份 1-12
  * @param {number} day - 日 1-31
- * @returns {boolean} 是否为入职前日期
+ * @returns {boolean} 是否为任职期间外日期
  */
 export function isBeforeEmploymentDate(year, month, day) {
-  var raw = this.state && this.state.userInfo ? this.state.userInfo.employmentStartDate : '';
-  if (raw === undefined || raw === null || raw === '') return false;
-  var employmentDate;
-  if (typeof raw === 'string' && /^\d{4}[-/]\d{1,2}[-/]\d{1,2}/.test(raw)) {
-    var parts = raw.substring(0, 10).split(/[-/]/);
-    employmentDate = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10));
-  } else {
-    var timestamp = typeof raw === 'string' ? parseInt(raw, 10) : raw;
-    employmentDate = new Date(timestamp);
-  }
-  if (isNaN(employmentDate.getTime())) return false;
-  var employmentKey = employmentDate.getFullYear() * 10000 + (employmentDate.getMonth() + 1) * 100 + employmentDate.getDate();
-  var targetKey = year * 10000 + month * 100 + day;
-  return targetKey < employmentKey;
+  // ppExt 兼容已有输入、默认值、保存及审批入口;现在统一限制任职闭区间。
+  return this.isOutsideEmploymentDates(year, month, day);
 }
 
 /**
@@ -2457,7 +2471,7 @@ export function saveDraft() {
   });
   this.showLoading();
   // prd 补填保存与提交一致:先实时确认月份已封账,再校验员工和 yyyyMM 月份。
-  this.assertRepairMonthAvailable().then(function () {
+  this.getRepairSubmitterStaffType().then(() => this.assertRepairMonthAvailable()).then(function () {
     var contextError = this.getRepairSubmissionContextError();
     if (contextError) throw new Error(contextError);
     return this.saveTimecardData('draft');
@@ -3304,7 +3318,7 @@ export function saveTimecardData(saveType) {
 
     // prd 写入日期类型:先取常规值(工作日/周末),再按节假日覆盖
     var dayTypeVal = this.isWeekend(year, month, d) ? '周末' : '工作日';
-    var hType = (this.state.holidayMap || {})[d];
+    var hType = this.getHolidayType(d);
     if (hType === '加班') dayTypeVal = '节假日-加班';
     if (hType === '调休') dayTypeVal = '节假日-调休';
     formData[TIME_FIELD_IDS.dayType] = dayTypeVal;
@@ -3698,6 +3712,7 @@ export function isWeekend(year, month, day) {
  * @returns {boolean} 是否休息日
  */
 export function isDayOff(year, month, day) {
+  if ((this.state.userInfo || {}).isOverseasStaff === true) return this.isWeekend(year, month, day);
   var holidayMap = this.state.holidayMap || {};
   var hType = holidayMap[day];
   if (hType === '加班') return false;
@@ -3711,6 +3726,7 @@ export function isDayOff(year, month, day) {
  * @returns {string|null} '调休' | '加班' | null
  */
 export function getHolidayType(day) {
+  if ((this.state.userInfo || {}).isOverseasStaff === true) return null;
   var holidayMap = this.state.holidayMap || {};
   return holidayMap[day] || null;
 }
@@ -4997,7 +5013,7 @@ export function renderJsx() {
     var dt = new Date(selectedYear, selectedMonth - 1, d);
     var dow = dt.getDay();
     // prd 综合节假日判断:调休→休息日,加班→工作日,未维护→原逻辑
-    var hType = holidayMap[d];
+    var hType = userInfo.isOverseasStaff === true ? null : holidayMap[d];
     var isNaturalWe = dow === 0 || dow === 6;
     var isDayOff = hType === '调休' ? true : hType === '加班' ? false : isNaturalWe;
     allDays.push({
@@ -5827,3 +5843,68 @@ export function didUnmount() {
   }
   this._tcResizeHandler = null;
 }
+
+
+/** @param {number|string} raw 日期字段值 @returns {number} 上海业务日期yyyyMMdd,空值为0 */
+export function getEmploymentDateKey(raw) {
+  if (raw === undefined || raw === null || raw === '') return 0;
+  const text = String(raw).trim();
+  let date;
+  if (/^\d{4}[-/]\d{1,2}[-/]\d{1,2}$/.test(text)) {
+    const parts = text.split(/[-/]/).map(value => Number(value));
+    date = new Date(Date.UTC(parts[0], parts[1] - 1, parts[2]));
+    if (date.getUTCFullYear() !== parts[0] || date.getUTCMonth() + 1 !== parts[1] || date.getUTCDate() !== parts[2]) date = new Date(NaN);
+  } else {
+    const epoch = /^\d+$/.test(text) ? Number(text) : Date.parse(text);
+    date = new Date(epoch + 8 * 60 * 60 * 1000);
+  }
+  if (!Number.isFinite(date.getTime())) throw new Error(this.getTexts().employmentInvalid);
+  return date.getUTCFullYear() * 10000 + (date.getUTCMonth() + 1) * 100 + date.getUTCDate();
+}
+
+/** @param {Object} profile 人员档案 @returns {Array<number>} 任职闭区间,0表示未维护 */
+export function getEmploymentBounds(profile) {
+  const info = profile || {};
+  const start = this.getEmploymentDateKey(info.employmentStartDate);
+  const end = this.getEmploymentDateKey(info.employmentEndDate);
+  if (start && end && start > end) throw new Error(this.getTexts().employmentInvalid);
+  return [start, end];
+}
+
+/** @param {number} year 年 @param {number} month 月 @param {number} day 日 @returns {boolean} 任职期间外 */
+export function isOutsideEmploymentDates(year, month, day) {
+  const bounds = this.getEmploymentBounds((this.state || {}).userInfo);
+  const target = year * 10000 + month * 100 + day;
+  return !!((bounds[0] && target < bounds[0]) || (bounds[1] && target > bounds[1]));
+}
+
+/** @returns {void} 明确阻止范围外草稿被静默过滤后部分提交,已锁定历史记录只读保留 */
+export function assertEmploymentInputs() {
+  const year = this.state.selectedYear;
+  const month = this.state.selectedMonth;
+  const invalid = [];
+  for (let day = 1; day <= new Date(year, month, 0).getDate(); day++) {
+    if (!this.isOutsideEmploymentDates(year, month, day)) continue;
+    if ((this.state.lockedDays || {})[day] || (this.state.originalBlockedDays || {})[day] || (this.state.repairBlockedDays || {})[day]) continue;
+    const hasDraft = ['billable', 'nonBillable', 'other'].some(section => {
+      const data = this.state[section + 'Data'] || {};
+      return Object.keys(data).some(row => {
+        const status = (this.state.cellStatusMap || {})[section + '_' + row + '_' + day];
+        return status !== 'approved' && status !== 'submitted' && Math.abs(Number((data[row] || {})[day]) || 0) > 0;
+      });
+    });
+    if (hasDraft) invalid.push(String(year * 10000 + month * 100 + day));
+  }
+  if (invalid.length) throw new Error(this.getTexts().employmentDraftOutside + invalid.join(', '));
+}
+
+/** @param {Object} profile 实时人员档案 @returns {void} 档案变化必须重新加载,避免旧默认值和日历继续提交 */
+export function assertPersonnelSnapshot(profile) {
+  const current = (this.state || {}).userInfo || {};
+  const before = this.getEmploymentBounds(current);
+  const after = this.getEmploymentBounds(profile);
+  if (before.join(',') !== after.join(',') || !!current.isOverseasStaff !== !!profile.isOverseasStaff) {
+    throw new Error(this.getTexts().personnelCalendarChanged);
+  }
+  this.assertEmploymentInputs();
+}

+ 92 - 0
pages/tests/verify-employment-calendar.cjs

@@ -0,0 +1,92 @@
+'use strict';
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+
+/** @param {string} source 页面源码 @param {string} name 方法名 @returns {string} 可执行方法 */
+function extract(source, name) {
+  const match = new RegExp('export function ' + name + '\\([^]*?\\n}').exec(source);
+  assert.ok(match, name);
+  return match[0].replace('export ', '');
+}
+
+/** @param {string} file 页面文件 @param {boolean} employment 是否验证任职控制 @returns {object} 独立页面上下文 */
+function page(file, employment) {
+  const source = fs.readFileSync(path.resolve(__dirname, '../src', file), 'utf8');
+  const names = ['isWeekend', 'isDayOff', 'getHolidayType', 'isBeisenLeaveWorkday', 'loadHolidays'];
+  if (employment) names.push('getEmploymentDateKey', 'getEmploymentBounds', 'isOutsideEmploymentDates',
+    'isBeforeEmploymentDate', 'assertEmploymentInputs', 'assertPersonnelSnapshot',
+    'getDayTotal', 'getOvertimeDayTotal', 'validateMonthlyOvertime', 'applyDefaultWorkdayHours');
+  const scope = {APP_TYPE:'app', FORM_IDS:{holiday:'calendar'},
+    HOLIDAY_FIELD_IDS:{year:'year',date:'date',type:'type'},
+    VALIDATION:{maxHoursPerDay:8,maxOvertimePerMonth:36}};
+  vm.createContext(scope);
+  vm.runInContext(names.map(name => extract(source, name)).join('\n'), scope);
+  const env = {...scope, state:{selectedYear:2026,selectedMonth:10,userInfo:{},
+    billableData:{},nonBillableData:{},otherData:{},holidayMap:{1:'调休',10:'加班'}},
+    getTexts:()=>({employmentInvalid:'invalid dates',employmentDraftOutside:'outside ',
+      personnelCalendarChanged:'reload',overtimeExceeded:()=> 'over 36'}),
+    utils:{toast:()=>{}},showTimecardError:()=>{},isNormalTimecardBlocked:()=>false};
+  return env;
+}
+
+/** @returns {Promise<void>} 实际业务方法的日期、日历、默认值和提交前复核测试 */
+async function main() {
+  const files = ['timecard-main.actions.js','timecard-repair.oyd.jsx','timecard-repair-lifecycle.oyd.jsx','timecard-change.oyd.jsx'];
+  for (const file of files) {
+    const employment = !file.includes('change');
+    const env = page(file, employment);
+    let queries = 0;
+    env.utils.yida = {searchFormDatas:async () => { queries++; return {data:[]}; }};
+    assert.equal(env.isDayOff(2026,10,1),true,'国内调休为休息日');
+    assert.equal(env.isDayOff(2026,10,10),false,'国内周末补班为工作日');
+    env.state.userInfo.isOverseasStaff = true;
+    env._tcHolidayCalendarByYear = {2026:{20261001:'调休',20261010:'加班'}};
+    assert.equal(env.isDayOff(2026,10,1),false);
+    assert.equal(env.isDayOff(2026,10,10),true);
+    assert.equal(env.isBeisenLeaveWorkday(2026,10,1),true);
+    assert.equal(env.isBeisenLeaveWorkday(2026,10,10),false);
+    assert.equal(env.getHolidayType(1),null);
+    assert.equal(Object.keys(await env.loadHolidays(2026,10)).length,0);
+    assert.equal(Object.keys(env._tcHolidayCalendarByYear).length,0);
+    assert.equal(queries,0,'海外员工不请求国内日历');
+    env.state.userInfo.isOverseasStaff = false;
+    await env.loadHolidays(2026,10);
+    assert.equal(queries,1,'切换回国内仍查询日历');
+    if (!employment) continue;
+    env.state.userInfo = {employmentStartDate:'2026-10-01',employmentEndDate:'2026-10-15',isOverseasStaff:true};
+    assert.equal(env.isBeforeEmploymentDate(2026,9,30),true);
+    assert.equal(env.isBeforeEmploymentDate(2026,10,1),false);
+    assert.equal(env.isBeforeEmploymentDate(2026,10,15),false);
+    assert.equal(env.isBeforeEmploymentDate(2026,10,16),true);
+    assert.equal(env.getEmploymentDateKey(Date.parse('2026-10-14T16:00:00Z')),20261015);
+    assert.equal(env.getEmploymentDateKey('2026-10-14T16:00:00Z'),20261015);
+    assert.throws(()=> env.getEmploymentDateKey('2026-02-30'),/invalid/);
+    assert.throws(()=> env.getEmploymentBounds({employmentStartDate:'2026-10-16',employmentEndDate:'2026-10-15'}),/invalid/);
+    env.state.billableData = {0:{16:8}};
+    assert.throws(()=> env.assertEmploymentInputs(),/20261016/,'范围外草稿不静默过滤');
+    env.state.cellStatusMap = {billable_0_16:'approved'};
+    env.assertEmploymentInputs();
+    env.state.billableData = {};
+    env.assertPersonnelSnapshot({...env.state.userInfo});
+    assert.throws(()=> env.assertPersonnelSnapshot({...env.state.userInfo,isOverseasStaff:false}),/reload/);
+    assert.throws(()=> env.assertPersonnelSnapshot({...env.state.userInfo,employmentEndDate:'2026-10-14'}),/reload/);
+    const billable = {};
+    env.applyDefaultWorkdayHours(2026,10,env.state.holidayMap,[{code:'P'}],billable,{}, {},false,{});
+    assert.equal(billable[0][1],8,'海外节假日中的自然工作日默认8小时');
+    assert.equal(billable[0][10],undefined,'海外周末补班不默认8小时');
+    assert.equal(billable[0][16],undefined,'离职后不默认工时');
+    env.state.userInfo = {isOverseasStaff:true};
+    env.state.billableData = {0:{1:8,10:8}};
+    assert.equal(env.validateMonthlyOvertime(),true);
+    for (const hours of [36,36.5]) {
+      env.state.billableData = {0:{3:hours/2,4:hours/2}};
+      assert.equal(env.validateMonthlyOvertime(),hours===36,'保留36小时边界');
+    }
+    env.state.isExternalStaff = true;
+    assert.equal(env.validateMonthlyOvertime(),true,'保留外部员工原豁免');
+  }
+  console.log('PASS 四入口海外自然周、国内回退、任职闭区间、上海时区、默认值、草稿保护、档案变更及36小时边界');
+}
+main().catch(error => {console.error(error);process.exitCode=1;});