Explorar el Código

fix(workhours): 完善入职边界与审批回写兜底

malk hace 23 horas
padre
commit
5d04d4182e

+ 23 - 0
mjava-akdsbeisen/src/main/java/com/malk/controller/WorkHoursController.java

@@ -196,6 +196,29 @@ public class WorkHoursController {
         return result;
     }
 
+    /**
+     * 清理员工入职日期之前的历史应报工时,入职当天保留(默认仅预览)。
+     * GET /workhours/cleanup-before-hired-date?dryRun=true|false
+     */
+    @GetMapping("/cleanup-before-hired-date")
+    public Map<String, Object> cleanupBeforeHiredDate(
+            @RequestParam(defaultValue = "true") boolean dryRun) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            long start = System.currentTimeMillis();
+            Map<String, Object> stats = workHoursCalcService.cleanupBeforeHiredDate(dryRun);
+            result.put("success", true);
+            result.put("message", dryRun ? "入职前工时清理预览完成(未删除)" : "入职前工时清理完成");
+            result.put("stats", stats);
+            result.put("costMs", System.currentTimeMillis() - start);
+        } catch (Exception e) {
+            log.error("入职前工时清理失败 dryRun={}", dryRun, e);
+            result.put("success", false);
+            result.put("message", e.getMessage());
+        }
+        return result;
+    }
+
     /**
      * 清理已写入的"未来"应报工时 (workDay > cutoff, cutoff 缺省 today)
      * GET /workhours/cleanup-future                    (实际删除, cutoff=today)

+ 8 - 5
mjava-akdsbeisen/src/main/java/com/malk/service/personnel/impl/PersonnelSyncServiceImpl.java

@@ -77,6 +77,11 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
         return LocalDate.now(CST).atStartOfDay(CST).toInstant().toEpochMilli();
     }
 
+    /** 新建档案时写创建当天;更新档案时不覆盖既有入职日期。 */
+    static Long hiredDateForAction(String action, long currentDayStart) {
+        return ACTION_CREATE.equals(action) ? currentDayStart : null;
+    }
+
     @Override
     public Map<String, Object> fullSync(Integer limitOverride) {
         long start = System.currentTimeMillis();
@@ -704,12 +709,10 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
             }
         }
 
-        // 入职时间 <- hired_date (毫秒时间戳, 需要钉钉花名册权限才返回)
+        // prd 入职时间以人员档案创建日期为准:新建时写当天,后续同步不再被钉钉 hired_date 覆盖
         if (notBlank(conf.getFieldHiredDate())) {
-            Object hired = ding.get("hired_date");
-            if (hired instanceof Number) {
-                formData.put(conf.getFieldHiredDate(), ((Number) hired).longValue());
-            }
+            Long hiredDate = hiredDateForAction(action, todayCstStartMillis());
+            if (hiredDate != null) formData.put(conf.getFieldHiredDate(), hiredDate);
         }
 
         // Manager <- manager_userid (EmployeeField, 数组格式)

+ 132 - 7
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/WorkHoursCalcService.java

@@ -53,6 +53,8 @@ public class WorkHoursCalcService {
 
     // prd 离职时间字段(人员档案侧, 用于过滤离职后不再生成/清理历史应报工时)
     private static final String PERSONNEL_OFFLINE_DATE = "dateField_mh8xhqc7";
+    // prd 入职时间字段(以人员档案创建日期为准,入职日前不生成应报工时,入职当天保留)
+    private static final String PERSONNEL_HIRED_DATE = "dateField_mh8xhqc6";
     // prd 在职状态字段 + 离职取值 (防御开关: status=离职 且 offlineDate=空 → 数据不一致, 该员工不写工时)
     private static final String PERSONNEL_STATUS = "radioField_mp1sngq1";
     private static final String STATUS_INACTIVE = "离职";
@@ -226,6 +228,13 @@ public class WorkHoursCalcService {
             return result;
         }
 
+        LocalDate hiredDate = parseToLocalDate(info.get(PERSONNEL_HIRED_DATE));
+        if (isBeforeHiredDate(workDay, hiredDate)) {
+            result.put("success", false);
+            result.put("error", "workDay 在入职日期之前(hiredDate=" + hiredDate + "), 按业务规则跳过写入");
+            return result;
+        }
+
         // 2. Manager 取值: 内部=[钉钉直属主管], 外部=当天参与项目的 PM 合并
         boolean isInternal = "内部".equals(String.valueOf(info.get("radioField_mkow4ydo")));
         List<String> managerIds;
@@ -796,6 +805,11 @@ public class WorkHoursCalcService {
         return workDay != null && offlineDate != null && workDay.isAfter(offlineDate);
     }
 
+    /** 入职当天保留,仅入职日期之前视为无效。 */
+    static boolean isBeforeHiredDate(LocalDate workDay, LocalDate hiredDate) {
+        return workDay != null && hiredDate != null && workDay.isBefore(hiredDate);
+    }
+
     // ==================== 多线程并发写入 ====================
 
     /**
@@ -824,6 +838,7 @@ public class WorkHoursCalcService {
             AtomicInteger skippedFuture = new AtomicInteger(0);
             AtomicInteger skippedExisting = new AtomicInteger(0);
             AtomicInteger skippedOffline = new AtomicInteger(0);
+            AtomicInteger skippedBeforeHired = new AtomicInteger(0);
             AtomicInteger skippedNoPm = new AtomicInteger(0);
             AtomicInteger skippedInconsistent = new AtomicInteger(0);
             for (Map.Entry<String, Map<String, Object>> entry : personnelMap.entrySet()) {
@@ -835,6 +850,8 @@ public class WorkHoursCalcService {
                 List<Assignment> externalAssignments = isInternal ? null : managerData.external.get(empId);
                 // prd 离职时间: 若有离职时间, 该日之后的应报工时不再生成
                 LocalDate offlineDate = parseToLocalDate(info.get(PERSONNEL_OFFLINE_DATE));
+                // prd 入职时间: 入职日前不生成, 入职当天保留
+                LocalDate hiredDate = parseToLocalDate(info.get(PERSONNEL_HIRED_DATE));
                 // prd 防御开关: 人员档案 status=离职 但 offlineDate 空 → 数据不一致态, 该员工整月跳过, 避免误写
                 boolean inconsistentOffline = STATUS_INACTIVE.equals(String.valueOf(info.get(PERSONNEL_STATUS)))
                         && offlineDate == null;
@@ -860,6 +877,10 @@ public class WorkHoursCalcService {
                             skippedOffline.incrementAndGet();
                             continue;
                         }
+                        if (isBeforeHiredDate(workDay, hiredDate)) {
+                            skippedBeforeHired.incrementAndGet();
+                            continue;
+                        }
                         // prd 外部员工: 当天参与项目的 PM 集合为空(无项目/PM 全空/项目全下线) → 不生成记录
                         List<String> managerIds;
                         if (isInternal) {
@@ -919,6 +940,9 @@ public class WorkHoursCalcService {
             if (skippedOffline.get() > 0) {
                 log.info("离职员工过滤: 跳过{}条 workDay > offlineDate 的记录", skippedOffline.get());
             }
+            if (skippedBeforeHired.get() > 0) {
+                log.info("入职日期过滤: 跳过{}条 workDay < hiredDate 的记录", skippedBeforeHired.get());
+            }
             if (skippedNoPm.get() > 0) {
                 log.info("外部员工无 PM 过滤: 跳过{}条 当天无活跃项目 PM 的记录", skippedNoPm.get());
             }
@@ -941,7 +965,8 @@ public class WorkHoursCalcService {
      */
     public Map<String, Object> cleanupDuplicateHours(boolean dryRun) {
         Map<String, LocalDate> offlineMap = buildPersonnelOfflineMap();
-        DuplicateScan scan = scanDuplicateHours(offlineMap);
+        Map<String, LocalDate> hiredMap = buildPersonnelHiredMap();
+        DuplicateScan scan = scanDuplicateHours(offlineMap, hiredMap);
         WorkHoursDuplicateResolver.Resolution resolution = WorkHoursDuplicateResolver.resolve(scan.candidates);
         List<String> toDelete = resolution.getDeleteInstanceIds();
 
@@ -950,8 +975,9 @@ public class WorkHoursCalcService {
             stats.put("deleted", 0);
             stats.put("fail", 0);
             stats.put("dryRun", true);
-            log.info("重复应报工时清理预览: 扫描{}, 重复组{}, 待删除{}, 离职后排除{}",
-                    scan.total, resolution.getGroups().size(), toDelete.size(), scan.excludedAfterOffline);
+            log.info("重复应报工时清理预览: 扫描{}, 重复组{}, 待删除{}, 入职前排除{}, 离职后排除{}",
+                    scan.total, resolution.getGroups().size(), toDelete.size(),
+                    scan.excludedBeforeHired, scan.excludedAfterOffline);
             return stats;
         }
 
@@ -973,16 +999,17 @@ public class WorkHoursCalcService {
         return offlineMap;
     }
 
-    private DuplicateScan scanDuplicateHours(Map<String, LocalDate> offlineMap) {
+    private DuplicateScan scanDuplicateHours(Map<String, LocalDate> offlineMap,
+                                             Map<String, LocalDate> hiredMap) {
         DuplicateScan scan = new DuplicateScan();
         LocalDate monthCursor = LocalDate.of(2026, 4, 1);
         LocalDate scanEnd = LocalDate.now().withDayOfMonth(1).plusMonths(1);
         while (monthCursor.isBefore(scanEnd)) {
             for (Map<String, Object> item : queryRequiredHoursMonth(monthCursor)) {
-                collectDuplicateCandidate(item, offlineMap, scan);
+                collectDuplicateCandidate(item, offlineMap, hiredMap, scan);
             }
-            log.info("重复应报工时扫描[{}]: 累计扫描{}, 离职后排除{}",
-                    monthCursor, scan.total, scan.excludedAfterOffline);
+            log.info("重复应报工时扫描[{}]: 累计扫描{}, 入职前排除{}, 离职后排除{}",
+                    monthCursor, scan.total, scan.excludedBeforeHired, scan.excludedAfterOffline);
             monthCursor = monthCursor.plusMonths(1);
         }
         return scan;
@@ -1020,6 +1047,7 @@ public class WorkHoursCalcService {
     @SuppressWarnings("unchecked")
     private void collectDuplicateCandidate(Map<String, Object> item,
                                            Map<String, LocalDate> offlineMap,
+                                           Map<String, LocalDate> hiredMap,
                                            DuplicateScan scan) {
         scan.total++;
         Object instanceIdValue = item.get("formInstanceId");
@@ -1033,6 +1061,11 @@ public class WorkHoursCalcService {
         String employeeId = extractEmployeeId(formData, "employeeField_mmd8onl4");
         LocalDate workDay = parseToLocalDate(formData.get("dateField_mmd8onl5"));
         LocalDate offlineDate = employeeId == null ? null : offlineMap.get(employeeId);
+        LocalDate hiredDate = employeeId == null ? null : hiredMap.get(employeeId);
+        if (isBeforeHiredDate(workDay, hiredDate)) {
+            scan.excludedBeforeHired++;
+            return;
+        }
         if (isAfterOfflineDate(workDay, offlineDate)) {
             scan.excludedAfterOffline++;
             return;
@@ -1102,6 +1135,7 @@ public class WorkHoursCalcService {
         stats.put("toDelete", resolution.getDeleteInstanceIds().size());
         stats.put("skippedInvalidKey", resolution.getSkippedInvalidKey());
         stats.put("excludedAfterOffline", scan.excludedAfterOffline);
+        stats.put("excludedBeforeHired", scan.excludedBeforeHired);
         stats.put("samples", samples);
         return stats;
     }
@@ -1131,6 +1165,95 @@ public class WorkHoursCalcService {
         private final List<WorkHoursDuplicateResolver.Candidate> candidates = new ArrayList<>();
         private int total;
         private int excludedAfterOffline;
+        private int excludedBeforeHired;
+    }
+
+    /**
+     * 清理入职日期之前的历史应报工时;入职当天保留。
+     *
+     * @param dryRun true 时仅统计,不删除
+     * @return 人员及应报工时扫描、删除统计和抽样
+     */
+    public Map<String, Object> cleanupBeforeHiredDate(boolean dryRun) {
+        Map<String, LocalDate> hiredMap = buildPersonnelHiredMap();
+        HiredDateScan scan = scanBeforeHiredDate(hiredMap);
+        Map<String, Object> stats = new LinkedHashMap<>();
+        stats.put("dryRun", dryRun);
+        stats.put("hiredEmployees", hiredMap.size());
+        stats.put("total", scan.total);
+        stats.put("toDelete", scan.instanceIds.size());
+        stats.put("skippedMissingHiredDate", scan.skippedMissingHiredDate);
+        stats.put("skippedInvalidRecord", scan.skippedInvalidRecord);
+        stats.put("samples", scan.samples);
+        if (dryRun) {
+            stats.put("deleted", 0);
+            stats.put("fail", 0);
+            return stats;
+        }
+        int[] result = deleteRequiredHoursInstances(scan.instanceIds);
+        stats.put("deleted", result[0]);
+        stats.put("fail", result[1]);
+        return stats;
+    }
+
+    private Map<String, LocalDate> buildPersonnelHiredMap() {
+        Map<String, LocalDate> hiredMap = new HashMap<>();
+        Map<String, Map<String, Object>> personnelMap = queryAllPersonnelDetails();
+        for (Map.Entry<String, Map<String, Object>> entry : personnelMap.entrySet()) {
+            LocalDate hiredDate = parseToLocalDate(entry.getValue().get(PERSONNEL_HIRED_DATE));
+            if (hiredDate != null) hiredMap.put(entry.getKey(), hiredDate);
+        }
+        return hiredMap;
+    }
+
+    @SuppressWarnings("unchecked")
+    private HiredDateScan scanBeforeHiredDate(Map<String, LocalDate> hiredMap) {
+        HiredDateScan scan = new HiredDateScan();
+        LocalDate monthCursor = LocalDate.of(2026, 4, 1);
+        LocalDate scanEnd = LocalDate.now().withDayOfMonth(1).plusMonths(1);
+        while (monthCursor.isBefore(scanEnd)) {
+            for (Map<String, Object> item : queryRequiredHoursMonth(monthCursor)) {
+                scan.total++;
+                Map<String, Object> formData = (Map<String, Object>) item.get("formData");
+                Object instanceId = item.get("formInstanceId");
+                if (formData == null || instanceId == null) {
+                    scan.skippedInvalidRecord++;
+                    continue;
+                }
+                String empId = extractEmployeeId(formData, "employeeField_mmd8onl4");
+                LocalDate hiredDate = empId == null ? null : hiredMap.get(empId);
+                if (hiredDate == null) {
+                    scan.skippedMissingHiredDate++;
+                    continue;
+                }
+                LocalDate workDay = parseToLocalDate(formData.get("dateField_mmd8onl5"));
+                if (!isBeforeHiredDate(workDay, hiredDate)) continue;
+                scan.instanceIds.add(String.valueOf(instanceId));
+                addHiredDateSample(scan.samples, instanceId, empId, workDay, hiredDate);
+            }
+            log.info("清理入职前工时扫描[{}]: 累计扫描{}, 待删除{}", monthCursor, scan.total, scan.instanceIds.size());
+            monthCursor = monthCursor.plusMonths(1);
+        }
+        return scan;
+    }
+
+    private void addHiredDateSample(List<Map<String, Object>> samples, Object instanceId,
+                                    String empId, LocalDate workDay, LocalDate hiredDate) {
+        if (samples.size() >= 5) return;
+        Map<String, Object> sample = new LinkedHashMap<>();
+        sample.put("instanceId", String.valueOf(instanceId));
+        sample.put("empId", empId);
+        sample.put("workDay", workDay == null ? null : workDay.toString());
+        sample.put("hiredDate", hiredDate.toString());
+        samples.add(sample);
+    }
+
+    private static final class HiredDateScan {
+        private final List<String> instanceIds = new ArrayList<>();
+        private final List<Map<String, Object>> samples = new ArrayList<>();
+        private int total;
+        private int skippedMissingHiredDate;
+        private int skippedInvalidRecord;
     }
 
     /**
@@ -1537,6 +1660,8 @@ public class WorkHoursCalcService {
                             normalizeEmployeeName(formData.get("employeeField_mkow4ydp")));
                     // prd 离职时间: 携带原值供 concurrentUpsert 过滤 workDay > offlineDate
                     info.put(PERSONNEL_OFFLINE_DATE, formData.get(PERSONNEL_OFFLINE_DATE));
+                    // prd 入职时间: 携带原值供 concurrentUpsert 过滤 workDay < hiredDate
+                    info.put(PERSONNEL_HIRED_DATE, formData.get(PERSONNEL_HIRED_DATE));
                     // prd 在职状态: 携带供 concurrentUpsert 防御 (status=离职 且 offlineDate=空 时跳过写入)
                     info.put(PERSONNEL_STATUS, formData.get(PERSONNEL_STATUS));
                     personnelMap.put(empId, info);

+ 36 - 14
mjava-akdsbeisen/src/main/java/com/malk/timer/ApprovalWriteBackTimer.java

@@ -15,7 +15,6 @@ import org.springframework.context.annotation.Configuration;
 import org.springframework.scheduling.annotation.EnableScheduling;
 import org.springframework.scheduling.annotation.Scheduled;
 
-import java.text.SimpleDateFormat;
 import java.util.*;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -23,7 +22,7 @@ import java.util.concurrent.atomic.AtomicInteger;
 /**
  * 审批回写兜底定时(bug 2 修复)
  * <p>
- * 每天凌晨 04:20 触发,扫两类审批单近 3 天有修改、instanceStatus=COMPLETED、
+ * 每天凌晨 04:20 触发,扫两类审批单 instanceStatus=COMPLETED、
  * 同步状态 selectField_mq58cd5p 为空的实例,主动调 ApprovalWriteBackService.writeBack 补齐工时汇总表回写。
  * <p>
  * 触发原因场景:
@@ -40,6 +39,8 @@ import java.util.concurrent.atomic.AtomicInteger;
 @ConditionalOnProperty(prefix = "enable", name = "scheduling", havingValue = "true", matchIfMissing = false)
 public class ApprovalWriteBackTimer {
 
+    private static final int PAGE_SIZE = 100;
+
     @Autowired
     private YDClient ydClient;
 
@@ -83,24 +84,17 @@ public class ApprovalWriteBackTimer {
 
     private void scanForm(String tag, String formUuid,
                           AtomicInteger scanned, AtomicInteger triggered, AtomicInteger failed) {
-        // 近 3 天 gmtModified 窗口(减少扫描面)
-        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
-        Calendar cal = Calendar.getInstance();
-        String toDate = sdf.format(new Date(cal.getTimeInMillis() + 24L * 3600_000));
-        cal.add(Calendar.DAY_OF_MONTH, -3);
-        String fromDate = sdf.format(cal.getTime());
+        // prd 不限制 gmtModified 时间窗,避免服务停机超过窗口后永久漏扫;同步状态非空的历史单仅做内存过滤。
         // 状态字段: 已知宜搭对 SelectField 支持 IsEmpty 特殊查询,受项目实测差异保守起见改扫全 COMPLETED,内存过滤 syncStatus
         int page = 1;
-        while (page <= 20) {
+        while (true) {
             DDR_New r = ydClient.queryData(YDParam.builder()
                     .appType(whConf.getYidaAppType())
                     .systemToken(whConf.getYidaSystemToken())
                     .formUuid(formUuid)
                     .instanceStatus("COMPLETED")
-                    .modifiedFromTimeGMT(fromDate)
-                    .modifiedToTimeGMT(toDate)
                     .pageNumber(page)
-                    .pageSize(100)
+                    .pageSize(PAGE_SIZE)
                     .build(), YDConf.FORM_QUERY.retrieve_search_process);
             List<Map> data = (List<Map>) r.getData();
             if (data == null || data.isEmpty()) {
@@ -108,8 +102,10 @@ public class ApprovalWriteBackTimer {
             }
             for (Map item : data) {
                 scanned.incrementAndGet();
-                Map fd = (Map) item.get("formData");
+                // fixme getInstances 当前返回字段名为 data;兼容旧响应中的 formData。
+                Map fd = extractFormData(item);
                 if (fd == null) {
+                    log.warn("[审批回写兜底] 流程实例缺少表单数据 keys={}", item.keySet());
                     continue;
                 }
                 String syncStatus = String.valueOf(fd.getOrDefault(whConf.getApprovalSyncStatusField(), ""));
@@ -131,10 +127,36 @@ public class ApprovalWriteBackTimer {
                     log.error("[审批回写兜底] writeBack 失败 tag={} formInstanceId={}", tag, finstId, ex);
                 }
             }
-            if (data.size() < 100) {
+            if (!hasNextPage(page, PAGE_SIZE, r.getTotalCount(), data.size())) {
                 break;
             }
             page++;
         }
     }
+
+    // fixme 不设置固定页数上限,避免全量扫描超过 2000 条时漏掉历史未回写审批。
+    static boolean hasNextPage(int pageNumber, int pageSize, long totalCount, int returnedSize) {
+        if (returnedSize < pageSize) {
+            return false;
+        }
+        return totalCount <= 0 || (long) pageNumber * pageSize < totalCount;
+    }
+
+    @SuppressWarnings("unchecked")
+    static Map extractFormData(Map item) {
+        if (item == null) {
+            return null;
+        }
+        Object formData = item.get("formData");
+        if (formData instanceof Map) {
+            return (Map) formData;
+        }
+        Object data = item.get("data");
+        if (!(data instanceof Map)) {
+            return null;
+        }
+        Map dataMap = (Map) data;
+        Object nestedFormData = dataMap.get("formData");
+        return nestedFormData instanceof Map ? (Map) nestedFormData : dataMap;
+    }
 }

+ 12 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/personnel/impl/PersonnelSyncServiceImplTest.java

@@ -19,6 +19,7 @@ import java.util.List;
 import java.util.Map;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.fail;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
@@ -31,6 +32,16 @@ import static org.mockito.Mockito.when;
 
 public class PersonnelSyncServiceImplTest {
 
+    @Test
+    public void hiredDateShouldUsePersonnelCreateDayOnlyWhenCreating() {
+        long createDay = LocalDate.of(2026, 8, 6)
+                .atStartOfDay(ZoneId.of("Asia/Shanghai")).toInstant().toEpochMilli();
+
+        assertEquals(Long.valueOf(createDay),
+                PersonnelSyncServiceImpl.hiredDateForAction("CREATE", createDay));
+        assertNull(PersonnelSyncServiceImpl.hiredDateForAction("UPDATE", createDay));
+    }
+
     @Test
     public void fetchAllDingUsersShouldAbortWhenExternalDepartmentCannotBeLoaded() throws Exception {
         DDClient ddClient = mock(DDClient.class);
@@ -101,6 +112,7 @@ public class PersonnelSyncServiceImplTest {
         assertEquals(conf.getStatusValueActive(), formData.get(conf.getFieldStatus()));
         assertTrue(formData.containsKey(conf.getFieldOfflineDate()));
         assertNull(formData.get(conf.getFieldOfflineDate()));
+        assertFalse(formData.containsKey(conf.getFieldHiredDate()));
     }
 
     private static PersonnelSyncConf testConf() {

+ 51 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/WorkHoursCalcServiceTest.java

@@ -21,6 +21,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
@@ -67,6 +68,15 @@ public class WorkHoursCalcServiceTest {
         assertFalse(WorkHoursCalcService.isAfterOfflineDate(offlineDate.plusDays(1), null));
     }
 
+    @Test
+    public void isBeforeHiredDateShouldRejectEarlierDayAndKeepHiredDay() {
+        LocalDate hiredDate = LocalDate.of(2026, 8, 6);
+
+        assertTrue(WorkHoursCalcService.isBeforeHiredDate(hiredDate.minusDays(1), hiredDate));
+        assertFalse(WorkHoursCalcService.isBeforeHiredDate(hiredDate, hiredDate));
+        assertFalse(WorkHoursCalcService.isBeforeHiredDate(hiredDate.minusDays(1), null));
+    }
+
     @Test
     public void normalizeEmployeeNameShouldReadMemberDisplayNameAndRemoveInactiveMarker() {
         assertEquals("张三/San Zhang", WorkHoursCalcService.normalizeEmployeeName(
@@ -204,6 +214,38 @@ public class WorkHoursCalcServiceTest {
         verify(ydClient, never()).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.delete_batch));
     }
 
+    @Test
+    public void cleanupBeforeHiredDateDryRunShouldKeepHiredDay() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf conf = requiredHoursConf();
+        DDR_New<Object> personnelPage = pageOf(Collections.singletonList(
+                personnelRecordWithHiredDate("employee-1", LocalDate.of(2026, 7, 15))));
+        DDR_New<Object> requiredHoursPage = pageOf(Arrays.asList(
+                requiredHoursRecord("before-hired-date", "employee-1", LocalDate.of(2026, 7, 14)),
+                requiredHoursRecord("on-hired-date", "employee-1", LocalDate.of(2026, 7, 15))));
+        DDR_New<Object> emptyPage = pageOf(Collections.emptyList());
+        AtomicBoolean requiredHoursReturned = new AtomicBoolean(false);
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenAnswer(invocation -> {
+                    YDParam param = (YDParam) invocation.getArguments()[0];
+                    if (conf.getFormUuidPersonnel().equals(param.getFormUuid())) return personnelPage;
+                    if (requiredHoursReturned.compareAndSet(false, true)) return requiredHoursPage;
+                    return emptyPage;
+                });
+
+        WorkHoursCalcService service = new WorkHoursCalcService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+
+        Map<String, Object> stats = service.cleanupBeforeHiredDate(true);
+
+        assertEquals(1, stats.get("toDelete"));
+        assertEquals(0, stats.get("deleted"));
+        List<Map<String, Object>> samples = (List<Map<String, Object>>) stats.get("samples");
+        assertEquals("before-hired-date", samples.get(0).get("instanceId"));
+        verify(ydClient, never()).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.delete_batch));
+    }
+
     @Test
     public void computeDailyPmsShouldMergeDistinctManagersAndIncludeOfflineDate() throws Exception {
         WorkHoursCalcService service = new WorkHoursCalcService();
@@ -298,6 +340,15 @@ public class WorkHoursCalcServiceTest {
         return record;
     }
 
+    private static Map<String, Object> personnelRecordWithHiredDate(String employeeId,
+                                                                     LocalDate hiredDate) {
+        Map<String, Object> record = personnelRecord(employeeId);
+        Map<String, Object> formData = (Map<String, Object>) record.get("formData");
+        formData.put("dateField_mh8xhqc6",
+                hiredDate.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli());
+        return record;
+    }
+
     private static DDR_New<Object> pageOf(List<Map<String, Object>> records) {
         DDR_New<Object> page = new DDR_New<>();
         page.setTotalCount(records.size());

+ 31 - 0
mjava-akdsbeisen/src/test/java/com/malk/timer/WorkHoursTimerScheduleTest.java

@@ -6,10 +6,14 @@ import org.springframework.scheduling.annotation.Scheduled;
 
 import java.lang.reflect.Method;
 import java.util.Arrays;
+import java.util.HashMap;
 import java.util.HashSet;
+import java.util.Map;
 import java.util.Set;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
 
 public class WorkHoursTimerScheduleTest {
 
@@ -62,6 +66,33 @@ public class WorkHoursTimerScheduleTest {
         assertSchedulingSwitch(ReSubmitApprovalTimer.class);
     }
 
+    @Test
+    public void approvalWriteBackShouldReadCurrentProcessDataShape() {
+        Map<String, Object> formData = new HashMap<>();
+        formData.put("selectField_mq58cd5p", "全部成功");
+        Map<String, Object> process = new HashMap<>();
+        process.put("data", formData);
+
+        assertEquals(formData, ApprovalWriteBackTimer.extractFormData(process));
+    }
+
+    @Test
+    public void approvalWriteBackShouldRemainCompatibleWithLegacyFormDataShape() {
+        Map<String, Object> formData = new HashMap<>();
+        formData.put("selectField_mq58cd5p", "全部成功");
+        Map<String, Object> process = new HashMap<>();
+        process.put("formData", formData);
+
+        assertEquals(formData, ApprovalWriteBackTimer.extractFormData(process));
+    }
+
+    @Test
+    public void approvalWriteBackShouldContinueAfterLegacyTwentyPageLimit() {
+        assertTrue(ApprovalWriteBackTimer.hasNextPage(20, 100, 2500, 100));
+        assertFalse(ApprovalWriteBackTimer.hasNextPage(25, 100, 2500, 100));
+        assertFalse(ApprovalWriteBackTimer.hasNextPage(20, 100, 2500, 99));
+    }
+
     private static void assertSchedulingSwitch(Class<?> timerClass) {
         ConditionalOnProperty condition = timerClass.getAnnotation(ConditionalOnProperty.class);
         assertEquals("enable", condition.prefix());