Selaa lähdekoodia

fix(workhours): defer scheduled Yida retries until batch completion

malk 1 kuukausi sitten
vanhempi
commit
58397c78c9

+ 82 - 45
mjava-akdsbeisen/src/main/java/com/malk/service/personnel/impl/PersonnelSyncServiceImpl.java

@@ -873,44 +873,44 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
         if (actions.isEmpty()) return stats;
 
         ExecutorService pool = Executors.newFixedThreadPool(Math.max(1, conf.getConcurrency()));
-        List<Future<?>> futures = new ArrayList<>();
+        List<Future<Boolean>> futures = new ArrayList<>();
         for (Action a : actions) {
-            futures.add(pool.submit(() -> executeAction(a, stats)));
+            futures.add(pool.submit(() -> writeAction(a, stats)));
         }
-        for (Future<?> f : futures) {
-            try { f.get(); } catch (Exception ex) { log.warn("[PersonnelSync] future 异常", ex); }
+        List<Action> retryQueue = new ArrayList<>();
+        for (int index = 0; index < futures.size(); index++) {
+            try {
+                if (!futures.get(index).get()) retryQueue.add(actions.get(index));
+            } catch (Exception ex) {
+                retryQueue.add(actions.get(index));
+                log.warn("[PersonnelSync] future 异常 userid={}", actions.get(index).userid, ex);
+            }
         }
         pool.shutdown();
         try { pool.awaitTermination(10, TimeUnit.MINUTES); } catch (InterruptedException ignored) {}
+        for (Action action : retryQueue) {
+            retryAction(action, stats);
+        }
         return stats;
     }
 
     private void executeAction(Action a, WriteStats stats) {
-        int attempt = 0;
-        while (true) {
-            // 指数退避: 第 1 次不等, 之后 1s/2s/4s... 最长 8s
-            if (attempt > 0) {
-                long backoffMs = Math.min(1000L * (1L << (attempt - 1)), 8000L);
-                try {
-                    Thread.sleep(backoffMs);
-                } catch (InterruptedException ie) {
-                    Thread.currentThread().interrupt();
-                    stats.failed.incrementAndGet();
-                    return;
-                }
-            }
-            yidaRateLimiter.acquire();
-            try {
-                if (ACTION_CREATE.equals(a.type)) {
-                    ydClient.operateData(YDParam.builder()
+        if (!writeAction(a, stats)) retryAction(a, stats);
+    }
+
+    private boolean writeAction(Action a, WriteStats stats) {
+        yidaRateLimiter.acquire();
+        try {
+            if (ACTION_CREATE.equals(a.type)) {
+                ydClient.operateData(YDParam.builder()
                             .appType(conf.getYidaAppType())
                             .systemToken(conf.getYidaSystemToken())
                             .formUuid(conf.getFormUuidPersonnel())
                             .formDataJson(JSON.toJSONString(a.formData))
                             .build(), YDConf.FORM_OPERATION.create);
-                    stats.created.incrementAndGet();
-                } else {
-                    ydClient.operateData(YDParam.builder()
+                stats.created.incrementAndGet();
+            } else {
+                ydClient.operateData(YDParam.builder()
                             .appType(conf.getYidaAppType())
                             .systemToken(conf.getYidaSystemToken())
                             .formUuid(conf.getFormUuidPersonnel())
@@ -919,22 +919,46 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
                             .ignoreEmpty(false)
                             .useLatestVersion(true)
                             .build(), YDConf.FORM_OPERATION.update);
-                    if (ACTION_MARK_OFF.equals(a.type)) {
-                        stats.markedInactive.incrementAndGet();
-                    } else {
-                        stats.updated.incrementAndGet();
-                    }
+                if (ACTION_MARK_OFF.equals(a.type)) {
+                    stats.markedInactive.incrementAndGet();
+                } else {
+                    stats.updated.incrementAndGet();
                 }
-                return;
-            } catch (Exception ex) {
-                attempt++;
-                if (attempt > conf.getMaxRetry()) {
-                    log.warn("[PersonnelSync] 写入失败 userid={} action={} err={}", a.userid, a.type, ex.getMessage());
-                    stats.failed.incrementAndGet();
-                    return;
+            }
+            return true;
+        } catch (Exception ex) {
+            log.warn("[PersonnelSync] 写入失败 userid={} action={} err={}", a.userid, a.type, ex.getMessage());
+            return false;
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private void retryAction(Action action, WriteStats stats) {
+        try {
+            Map<String, Object> found = probeYidaByUserid(action.userid);
+            int count = ((Number) found.get("count")).intValue();
+            if (count != 1) {
+                if (count > 1 || !ACTION_CREATE.equals(action.type)) {
+                    throw new IllegalStateException("人员档案唯一性校验失败 count=" + count);
                 }
-                log.info("[PersonnelSync] 写入重试 userid={} action={} attempt={} err={}", a.userid, a.type, attempt, ex.getMessage());
+                if (!writeAction(action, stats)) stats.failed.incrementAndGet();
+                return;
+            }
+            Map<String, Object> record = ((List<Map<String, Object>>) found.get("records")).get(0);
+            Map<String, Object> current = (Map<String, Object>) record.get("formData");
+            if (isSameAsYida(action.formData, current)) {
+                if (ACTION_CREATE.equals(action.type)) stats.created.incrementAndGet();
+                else if (ACTION_MARK_OFF.equals(action.type)) stats.markedInactive.incrementAndGet();
+                else stats.updated.incrementAndGet();
+                log.info("[PersonnelSync] 首次写入已落地 userid={} action={}", action.userid, action.type);
+                return;
             }
+            Action update = new Action(ACTION_CREATE.equals(action.type) ? ACTION_UPDATE : action.type,
+                    action.userid, String.valueOf(record.get("instanceId")), action.formData);
+            if (!writeAction(update, stats)) stats.failed.incrementAndGet();
+        } catch (Exception ex) {
+            stats.failed.incrementAndGet();
+            log.error("[PersonnelSync] 本轮补试失败 userid={} action={}", action.userid, action.type, ex);
         }
     }
 
@@ -992,21 +1016,25 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
         String token = ddClient.getAccessToken();
         int filled = 0;
         int failed = 0;
+        List<Map> retryQueue = new ArrayList<>();
         for (Map u : dingUserMap.values()) {
             if (u.get("manager_userid") != null) { filled++; continue; }
             Object uid = u.get("userid");
             if (uid == null) continue;
             try {
-                ddRateLimiter.acquire();
-                Map detail = ddClient_contacts.getUserInfoById(token, String.valueOf(uid));
-                Object mgr = detail == null ? null : detail.get("manager_userid");
-                if (mgr != null && notBlank(String.valueOf(mgr))) {
-                    u.put("manager_userid", String.valueOf(mgr).trim());
-                    filled++;
-                }
+                if (fillManager(u, token)) filled++;
+            } catch (Exception ex) {
+                retryQueue.add(u);
+                log.warn("[PersonnelSync] 取 manager_userid 首次失败 userid={} err={}", uid, ex.getMessage());
+            }
+        }
+        for (Map user : retryQueue) {
+            try {
+                if (fillManager(user, token)) filled++;
             } catch (Exception ex) {
                 failed++;
-                log.warn("[PersonnelSync] 取 manager_userid 失败 userid={} err={}", uid, ex.getMessage());
+                log.warn("[PersonnelSync] 取 manager_userid 本轮补试失败 userid={} err={}",
+                        user.get("userid"), ex.getMessage());
             }
         }
         log.info("[PersonnelSync] manager_userid 已就绪 {}/{} 人, 查询失败{}人",
@@ -1014,6 +1042,15 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
         return failed;
     }
 
+    private boolean fillManager(Map user, String token) {
+        ddRateLimiter.acquire();
+        Map detail = ddClient_contacts.getUserInfoById(token, String.valueOf(user.get("userid")));
+        Object manager = detail == null ? null : detail.get("manager_userid");
+        if (manager == null || !notBlank(String.valueOf(manager))) return false;
+        user.put("manager_userid", String.valueOf(manager).trim());
+        return true;
+    }
+
     private Map<String, Long> countActions(List<Action> actions) {
         Map<String, Long> stats = new HashMap<>();
         for (Action a : actions) {

+ 16 - 22
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/ApprovalWriteBackService.java

@@ -50,7 +50,6 @@ public class ApprovalWriteBackService {
     private RepairApprovalWriteBackService repairApprovalWriteBackService;
 
     private static final int THREAD_POOL_SIZE = 10;
-    private static final int MAX_RETRY = 2;
 
     /**
      * 明细审批意见「同意」取值(单选,已确认)
@@ -204,29 +203,23 @@ public class ApprovalWriteBackService {
                 String userId = groupRows.get(0).userId;
                 String dayText = groupRows.get(0).dayText;
                 futures.add(executor.submit(() -> {
-                    for (int retry = 0; retry <= MAX_RETRY; retry++) {
-                        try {
-                            int[] r = applyToRecord(userId, dayText, groupRows, limiter);
-                            if (r == null) {
-                                // fixme 审批明细存在但汇总表日记录缺失属于失败,不能把审批单标记为「全部成功」。
-                                failRecords.incrementAndGet();
-                                return;
-                            }
-                            hitRecords.incrementAndGet();
-                            updatedRows.addAndGet(r[0]);
-                            missRows.addAndGet(r[1]);
-                            if (r[1] > 0) {
-                                failRecords.incrementAndGet();
-                            }
+                    try {
+                        int[] dayResult = applyToRecord(userId, dayText, groupRows, limiter);
+                        if (dayResult == null) {
+                            // fixme 审批明细存在但汇总表日记录缺失属于失败,不能把审批单标记为「全部成功」。
+                            failRecords.incrementAndGet();
                             return;
-                        } catch (Exception ex) {
-                            if (retry < MAX_RETRY) {
-                                sleep(1000L * (retry + 1));
-                            } else {
-                                failRecords.incrementAndGet();
-                                log.error("[审批回写] 记录处理失败 userId={} day={}", userId, dayText, ex);
-                            }
                         }
+                        hitRecords.incrementAndGet();
+                        updatedRows.addAndGet(dayResult[0]);
+                        missRows.addAndGet(dayResult[1]);
+                        if (dayResult[1] > 0) {
+                            failRecords.incrementAndGet();
+                        }
+                    } catch (Exception ex) {
+                        failRecords.incrementAndGet();
+                        log.error("[审批回写] 记录处理失败,交由定时器本轮结束后补试 userId={} day={}",
+                                userId, dayText, ex);
                     }
                 }));
             }
@@ -419,6 +412,7 @@ public class ApprovalWriteBackService {
                     isOther, formInstanceId, syncStatus, syncTotal, failCount);
         } catch (Exception ex) {
             log.error("[审批回写] 审批单同步字段回写失败 isOther={} formInstanceId={}", isOther, formInstanceId, ex);
+            throw new IllegalStateException("审批单同步字段回写失败 formInstanceId=" + formInstanceId, ex);
         }
     }
 

+ 58 - 20
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/TimeCardRepairService.java

@@ -197,34 +197,72 @@ public class TimeCardRepairService {
      * 正常 TimeCard 补发逻辑仍由 ReSubmitApprovalService 独立处理。
      *
      * @param lookbackDays 最近修改自然日数量
-     * @return 本次补发的审批分组数量
+     * @return 本次补发的审批分组数量及补试后失败批次数
      */
-    public int resubmitRecentlyModifiedPending(int lookbackDays) {
+    public RepairResubmitResult resubmitRecentlyModifiedPending(int lookbackDays) {
         String[] window = modifiedDateWindow(lookbackDays);
         List<Map> records = queryModified(
                 whConf.getFormUuidRepairWorkHoursSummary(), window[0], window[1]);
         Map<String, RepairBatch> batches = buildPendingBatches(records);
         Map<String, Set<String>> coverageByMonth = new HashMap<>();
         int started = 0;
+        int failed = 0;
+        List<RepairBatch> retryQueue = new ArrayList<>();
         for (RepairBatch batch : batches.values()) {
-            Set<String> covered = coverageByMonth.computeIfAbsent(
-                    batch.monthText, this::loadRepairApprovalDetailCoverage);
-            removeCoveredRows(batch, covered);
-            Map<String, Object> missingProjects = batch.projectGroups;
-            Map<String, Object> missingOther = batch.otherGroup;
-            if (missingProjects.isEmpty() && missingOther.isEmpty()) continue;
-            TimeCardRepairSubmitRequest request = new TimeCardRepairSubmitRequest();
-            request.setSourceMonthText(batch.monthText);
-            request.setSubmitterUserId(batch.userId);
-            request.setSubmitterName(batch.submitterName);
-            request.setProjectGroups(missingProjects);
-            request.setOtherGroup(missingOther);
-            startApprovals(request, missingProjects, missingOther, batch.batchId, batch.submitDate);
-            started += missingProjects.size() + (missingOther.isEmpty() ? 0 : 1);
-        }
-        log.info("[补填审批补发] from={} to={} records={} batches={} started={}",
-                window[0], window[1], records.size(), batches.size(), started);
-        return started;
+            try {
+                Set<String> covered = coverageByMonth.computeIfAbsent(
+                        batch.monthText, this::loadRepairApprovalDetailCoverage);
+                started += submitMissingRepairApprovals(batch, covered);
+            } catch (Exception ex) {
+                retryQueue.add(batch);
+                log.warn("[补填审批补发] 首次处理失败,待本轮扫描结束补试 batch={}", batch.batchId, ex);
+            }
+        }
+        for (RepairBatch batch : retryQueue) {
+            try {
+                started += submitMissingRepairApprovals(
+                        batch, loadRepairApprovalDetailCoverage(batch.monthText));
+            } catch (Exception ex) {
+                failed++;
+                log.error("[补填审批补发] 本轮补试失败 batch={}", batch.batchId, ex);
+            }
+        }
+        log.info("[补填审批补发] from={} to={} records={} batches={} started={} failed={}",
+                window[0], window[1], records.size(), batches.size(), started, failed);
+        return new RepairResubmitResult(started, failed);
+    }
+
+    public static final class RepairResubmitResult {
+        private final int started;
+        private final int failed;
+
+        public RepairResubmitResult(int started, int failed) {
+            this.started = started;
+            this.failed = failed;
+        }
+
+        public int getStarted() {
+            return started;
+        }
+
+        public int getFailed() {
+            return failed;
+        }
+    }
+
+    private int submitMissingRepairApprovals(RepairBatch batch, Set<String> covered) {
+        removeCoveredRows(batch, covered);
+        Map<String, Object> missingProjects = batch.projectGroups;
+        Map<String, Object> missingOther = batch.otherGroup;
+        if (missingProjects.isEmpty() && missingOther.isEmpty()) return 0;
+        TimeCardRepairSubmitRequest request = new TimeCardRepairSubmitRequest();
+        request.setSourceMonthText(batch.monthText);
+        request.setSubmitterUserId(batch.userId);
+        request.setSubmitterName(batch.submitterName);
+        request.setProjectGroups(missingProjects);
+        request.setOtherGroup(missingOther);
+        startApprovals(request, missingProjects, missingOther, batch.batchId, batch.submitDate);
+        return missingProjects.size() + (missingOther.isEmpty() ? 0 : 1);
     }
 
     private void upsertRepairRow(TimeCardRepairSubmitRequest request, Map row, String category,

+ 74 - 47
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/WorkHoursCalcService.java

@@ -896,32 +896,32 @@ public class WorkHoursCalcService {
                             skippedBeforeHired.incrementAndGet();
                             continue;
                         }
-                        // prd 外部员工须在启用项目中且当日未下线;内部员工不校验项目
-                        if (!isInternal && !hasActiveProject(externalAssignments, workDay)) {
-                            skippedNoProject.incrementAndGet();
-                            continue;
-                        }
                         // prd 内部员工允许空 Manager;外部员工仍必须存在启用项目及有效项目 PM
-                        List<String> managerIds;
-                        if (isInternal) {
-                            managerIds = (internalMgrId == null || internalMgrId.isEmpty())
-                                    ? Collections.emptyList()
-                                    : Collections.singletonList(internalMgrId);
-                        } else {
-                            managerIds = computeDailyPms(externalAssignments, workDay);
-                            if (managerIds.isEmpty()) {
-                                skippedNoPm.incrementAndGet();
+                        List<String> managerIds = null;
+                        try {
+                            // prd 外部员工须在启用项目中且当日未下线;内部员工不校验项目
+                            if (!isInternal && !hasActiveProject(externalAssignments, workDay)) {
+                                skippedNoProject.incrementAndGet();
                                 continue;
                             }
-                        }
-                        try {
+                            if (isInternal) {
+                                managerIds = (internalMgrId == null || internalMgrId.isEmpty())
+                                        ? Collections.emptyList()
+                                        : Collections.singletonList(internalMgrId);
+                            } else {
+                                managerIds = computeDailyPms(externalAssignments, workDay);
+                                if (managerIds.isEmpty()) {
+                                    skippedNoPm.incrementAndGet();
+                                    continue;
+                                }
+                            }
                             yidaLimiter.acquire();
                             upsertDailyHours(empId, managerIds, workDay, info);
                             successCount.incrementAndGet();
                         } catch (Exception e) {
                             boolean rateLimited = isRateLimitError(e);
-                            deferredWrites.add(new DeferredWrite(empId, managerIds, workDay, info));
-                            log.warn("员工{} {} 首轮写入失败,已记录并跳过,待首轮全部完成后查询再补写: rateLimited={}, code={}, message={}",
+                            deferredWrites.add(new DeferredWrite(empId, managerIds, workDay, info, externalAssignments));
+                            log.warn("员工{} {} 首轮处理失败,已记录并跳过,待首轮全部完成后查询再补写: rateLimited={}, code={}, message={}",
                                     empId, workDay, rateLimited, queryErrorCode(e), e.getMessage());
                             if (rateLimited) {
                                 sleepAfterRateLimit(empId, workDay);
@@ -935,6 +935,7 @@ public class WorkHoursCalcService {
                 try {
                     f.get();
                 } catch (Exception e) {
+                    failCount.incrementAndGet();
                     log.error("线程执行异常", e);
                 }
             }
@@ -1008,8 +1009,19 @@ public class WorkHoursCalcService {
             }
 
             try {
+                if (item.managerIds == null && !hasActiveProject(item.externalAssignments, item.workDay)) {
+                    log.info("员工{} {} 补试计算后无有效项目,跳过写入", item.employeeId, item.workDay);
+                    continue;
+                }
+                List<String> managerIds = item.managerIds == null
+                        ? computeDailyPms(item.externalAssignments, item.workDay)
+                        : item.managerIds;
+                if (managerIds.isEmpty() && item.managerIds == null) {
+                    log.info("员工{} {} 补试计算后无有效项目 PM,跳过写入", item.employeeId, item.workDay);
+                    continue;
+                }
                 yidaLimiter.acquire();
-                upsertDailyHours(item.employeeId, item.managerIds, item.workDay, item.personnelInfo);
+                upsertDailyHours(item.employeeId, managerIds, item.workDay, item.personnelInfo);
                 successCount.incrementAndGet();
                 log.info("员工{} {} 延后补写成功", item.employeeId, item.workDay);
             } catch (Exception e) {
@@ -1792,17 +1804,20 @@ public class WorkHoursCalcService {
     private static final class DeferredWrite {
         private final String employeeId;
         private final List<String> managerIds;
+        private final List<Assignment> externalAssignments;
         private final LocalDate workDay;
         private final Map<String, Object> personnelInfo;
 
         private DeferredWrite(String employeeId,
                               List<String> managerIds,
                               LocalDate workDay,
-                              Map<String, Object> personnelInfo) {
+                              Map<String, Object> personnelInfo,
+                              List<Assignment> externalAssignments) {
             this.employeeId = employeeId;
             this.managerIds = managerIds == null
-                    ? Collections.emptyList()
+                    ? null
                     : new ArrayList<>(managerIds);
+            this.externalAssignments = externalAssignments;
             this.workDay = workDay;
             this.personnelInfo = personnelInfo;
         }
@@ -2188,6 +2203,7 @@ public class WorkHoursCalcService {
         AtomicInteger fail = new AtomicInteger(0);
         ZoneId zone = ZoneId.systemDefault();
         RateLimiter yidaLimiter = RateLimiter.create(20.0);
+        Map<String, List<String>> retryManagers = new LinkedHashMap<>();
 
         for (Map.Entry<String, LocalDate> e : memberEffectiveDate.entrySet()) {
             String memberId = e.getKey();
@@ -2238,38 +2254,38 @@ public class WorkHoursCalcService {
                         continue;
                     }
 
-                    JSONObject upd = new JSONObject();
-                    upd.put("employeeField_mh8xhqc3", newPmIds);
-                    boolean ok = false;
-                    for (int retry = 0; retry <= MAX_RETRY && !ok; retry++) {
-                        try {
-                            yidaLimiter.acquire();
-                            ydClient.operateData(YDParam.builder()
-                                    .appType(appType).systemToken(systemToken)
-                                    .formUuid(whConf.getFormUuidRequiredHours())
-                                    .formInstanceId(String.valueOf(instId))
-                                    .updateFormDataJson(upd.toJSONString())
-                                    .ignoreEmpty(false)
-                                    .useLatestVersion(true)
-                                    .build(), YDConf.FORM_OPERATION.update);
-                            ok = true;
-                        } catch (Exception ex) {
-                            if (retry < MAX_RETRY) {
-                                try { Thread.sleep(1000L * (retry + 1)); }
-                                catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
-                            } else {
-                                fail.incrementAndGet();
-                                log.error("[项目变更同步] 更新 Manager 失败 instId={} memberId={} workDay={}",
-                                        instId, memberId, workDay, ex);
-                            }
-                        }
+                    String requiredId = String.valueOf(instId);
+                    try {
+                        updateRequiredManager(requiredId, newPmIds, yidaLimiter);
+                        updated.incrementAndGet();
+                    } catch (Exception ex) {
+                        retryManagers.put(requiredId, newPmIds);
+                        log.warn("[项目变更同步] 首次更新失败,待本轮扫描结束补试 instId={} memberId={} workDay={}",
+                                requiredId, memberId, workDay, ex);
                     }
-                    if (ok) updated.incrementAndGet();
                 }
                 subPage++;
             } while ((long) (subPage - 1) * pageSize < subTotal);
         }
 
+        for (Map.Entry<String, List<String>> retry : retryManagers.entrySet()) {
+            try {
+                DDR_New current = ydClient.queryData(YDParam.builder()
+                        .appType(appType).systemToken(systemToken)
+                        .formInstanceId(retry.getKey()).build(), YDConf.FORM_QUERY.retrieve_id);
+                if (current == null || current.getFormData() == null) {
+                    throw new IllegalStateException("应报工时回读为空");
+                }
+                if (!setEquals(extractIdList(current.getFormData(), "employeeField_mh8xhqc3"), retry.getValue())) {
+                    updateRequiredManager(retry.getKey(), retry.getValue(), yidaLimiter);
+                }
+                updated.incrementAndGet();
+            } catch (Exception ex) {
+                fail.incrementAndGet();
+                log.error("[项目变更同步] 本轮补试失败 instId={}", retry.getKey(), ex);
+            }
+        }
+
         stats.put("totalScanned", totalScanned.get());
         stats.put("updated", updated.get());
         stats.put("skippedSame", skippedSame.get());
@@ -2282,6 +2298,17 @@ public class WorkHoursCalcService {
         return stats;
     }
 
+    private void updateRequiredManager(String instanceId, List<String> managerIds, RateLimiter limiter) {
+        JSONObject update = new JSONObject();
+        update.put("employeeField_mh8xhqc3", managerIds);
+        limiter.acquire();
+        ydClient.operateData(YDParam.builder()
+                .appType(whConf.getYidaAppType()).systemToken(whConf.getYidaSystemToken())
+                .formUuid(whConf.getFormUuidRequiredHours()).formInstanceId(instanceId)
+                .updateFormDataJson(update.toJSONString()).ignoreEmpty(false)
+                .useLatestVersion(true).build(), YDConf.FORM_OPERATION.update);
+    }
+
     private static boolean isInternalEmployee(Map<String, Object> info) {
         return info != null && "内部".equals(String.valueOf(info.get("radioField_mkow4ydo")));
     }

+ 236 - 17
mjava-akdsbeisen/src/main/java/com/malk/timer/ApprovalWriteBackTimer.java

@@ -76,15 +76,43 @@ public class ApprovalWriteBackTimer {
             AtomicInteger totalScanned = new AtomicInteger(0);
             AtomicInteger totalTriggered = new AtomicInteger(0);
             AtomicInteger totalFailed = new AtomicInteger(0);
+            Map<String, FailedApproval> retryQueue = new LinkedHashMap<>();
+            Map<String, FailedScan> scanRetryQueue = new LinkedHashMap<>();
+            Map<String, MissingPage> missingPages = new LinkedHashMap<>();
             for (String tag : new String[]{"bill", "other"}) {
                 String formUuid = "bill".equals(tag) ? whConf.getFormUuidApproval() : whConf.getFormUuidOtherApproval();
                 if (StringUtils.isBlank(formUuid)) {
                     continue;
                 }
-                scanForm(tag, formUuid, totalScanned, totalTriggered, totalFailed);
+                try {
+                    scanForm(tag, formUuid, 1, totalScanned, totalTriggered, totalFailed, retryQueue, missingPages);
+                } catch (ScanFailure ex) {
+                    totalFailed.incrementAndGet();
+                    scanRetryQueue.put(tag, new FailedScan(tag, formUuid, ex.page));
+                    log.error("[审批回写兜底] 扫描失败,待本轮结束补扫 tag={} page={}", tag, ex.page, ex);
+                    recordTimerException(ex);
+                } catch (Exception ex) {
+                    totalFailed.incrementAndGet();
+                    log.error("[审批回写兜底] 扫描失败 tag={}", tag, ex);
+                    recordTimerException(ex);
+                }
+            }
+            for (FailedScan failedScan : scanRetryQueue.values()) {
+                try {
+                    scanForm(failedScan.tag, failedScan.formUuid, failedScan.page,
+                            totalScanned, totalTriggered, totalFailed, retryQueue, missingPages);
+                    totalFailed.decrementAndGet();
+                } catch (Exception ex) {
+                    log.error("[审批回写兜底] 本轮补扫仍失败 tag={} page={}",
+                            failedScan.tag, failedScan.page, ex);
+                    recordTimerException(ex);
+                }
             }
-            log.info("[审批回写兜底] 完成 scanned={} triggered={} failed={}",
-                    totalScanned.get(), totalTriggered.get(), totalFailed.get());
+            retryMissingPages(missingPages, totalTriggered, totalFailed, retryQueue);
+            int recovered = retryFailedApprovals(retryQueue, totalFailed);
+            log.info("[审批回写兜底] 完成 scanned={} triggered={} scanRetried={} missingPageRetried={} retried={} recovered={} failed={}",
+                    totalScanned.get(), totalTriggered.get(), scanRetryQueue.size(), missingPages.size(),
+                    retryQueue.size(), recovered, totalFailed.get());
         } catch (Exception e) {
             log.error("[审批回写兜底] 定时任务异常", e);
             recordTimerException(e);
@@ -93,21 +121,29 @@ public class ApprovalWriteBackTimer {
         }
     }
 
-    private void scanForm(String tag, String formUuid,
-                          AtomicInteger scanned, AtomicInteger triggered, AtomicInteger failed) {
+    private void scanForm(String tag, String formUuid, int startPage,
+                          AtomicInteger scanned, AtomicInteger triggered, AtomicInteger failed,
+                          Map<String, FailedApproval> retryQueue,
+                          Map<String, MissingPage> missingPages) {
         // prd 不限制 gmtModified 时间窗,避免服务停机超过窗口后永久漏扫;同步状态非空的历史单仅做内存过滤。
         // 状态字段: 已知宜搭对 SelectField 支持 IsEmpty 特殊查询,受项目实测差异保守起见改扫全 COMPLETED,内存过滤 syncStatus
-        int page = 1;
+        int page = startPage;
         while (true) {
-            DDR_New r = ydClient.queryData(YDParam.builder()
-                    .appType(whConf.getYidaAppType())
-                    .systemToken(whConf.getYidaSystemToken())
-                    .formUuid(formUuid)
-                    .instanceStatus("COMPLETED")
-                    .pageNumber(page)
-                    .pageSize(PAGE_SIZE)
-                    .build(), YDConf.FORM_QUERY.retrieve_search_process);
-            List<Map> data = (List<Map>) r.getData();
+            DDR_New response;
+            List<Map> data;
+            try {
+                response = ydClient.queryData(YDParam.builder()
+                        .appType(whConf.getYidaAppType())
+                        .systemToken(whConf.getYidaSystemToken())
+                        .formUuid(formUuid)
+                        .instanceStatus("COMPLETED")
+                        .pageNumber(page)
+                        .pageSize(PAGE_SIZE)
+                        .build(), YDConf.FORM_QUERY.retrieve_search_process);
+                data = (List<Map>) response.getData();
+            } catch (Exception ex) {
+                throw new ScanFailure(page, ex);
+            }
             if (data == null || data.isEmpty()) {
                 break;
             }
@@ -121,6 +157,7 @@ public class ApprovalWriteBackTimer {
                 Map fd = extractFormData(item);
                 if (fd == null) {
                     failed.incrementAndGet();
+                    queueIncompletePage(missingPages, tag, formUuid, page, finstId);
                     log.warn("[审批回写兜底] 流程实例缺少表单数据 keys={}", item.keySet());
                     recordMissingFormData(tag, finstId, item);
                     continue;
@@ -133,7 +170,8 @@ public class ApprovalWriteBackTimer {
                 Integer resultCode = resolveResultCode(item.get("approvedResult"));
                 if (resultCode == null) {
                     failed.incrementAndGet();
-                    log.warn("[审批回写兜底] 审批结果缺失或未知,跳过并等待下轮 tag={} formInstanceId={} approvedResult={}",
+                    queueIncompletePage(missingPages, tag, formUuid, page, finstId);
+                    log.warn("[审批回写兜底] 审批结果缺失或未知,待本轮结束补扫 tag={} formInstanceId={} approvedResult={}",
                             tag, finstId, item.get("approvedResult"));
                     continue;
                 }
@@ -143,23 +181,204 @@ public class ApprovalWriteBackTimer {
                     triggered.incrementAndGet();
                     if (result == null || !"全部成功".equals(result.getSyncStatus())) {
                         failed.incrementAndGet();
+                        retryQueue.put(finstId, new FailedApproval(tag, finstId, resultCode));
                         recordWriteBackFailure(tag, finstId, result);
                     }
                     log.info("[审批回写兜底] 触发 writeBack tag={} formInstanceId={} result={}",
                             tag, finstId, resultCode);
                 } catch (Exception ex) {
                     failed.incrementAndGet();
+                    retryQueue.put(finstId, new FailedApproval(tag, finstId, resultCode));
                     log.error("[审批回写兜底] writeBack 失败 tag={} formInstanceId={}", tag, finstId, ex);
                     recordWriteBackException(tag, finstId, ex);
                 }
             }
-            if (!hasNextPage(page, PAGE_SIZE, r.getTotalCount(), data.size())) {
+            if (!hasNextPage(page, PAGE_SIZE, response.getTotalCount(), data.size())) {
                 break;
             }
             page++;
         }
     }
 
+    private static final class FailedScan {
+        private final String tag;
+        private final String formUuid;
+        private final int page;
+
+        private FailedScan(String tag, String formUuid, int page) {
+            this.tag = tag;
+            this.formUuid = formUuid;
+            this.page = page;
+        }
+    }
+
+    private static final class ScanFailure extends RuntimeException {
+        private final int page;
+
+        private ScanFailure(int page, Exception cause) {
+            super(cause);
+            this.page = page;
+        }
+    }
+
+    private static final class MissingPage {
+        private final String tag;
+        private final String formUuid;
+        private final int page;
+        private final Set<String> formInstanceIds = new LinkedHashSet<>();
+
+        private MissingPage(String tag, String formUuid, int page) {
+            this.tag = tag;
+            this.formUuid = formUuid;
+            this.page = page;
+        }
+    }
+
+    private static void queueIncompletePage(Map<String, MissingPage> missingPages,
+                                            String tag, String formUuid, int page,
+                                            String formInstanceId) {
+        MissingPage missingPage = missingPages.get(tag + ":" + page);
+        if (missingPage == null) {
+            missingPage = new MissingPage(tag, formUuid, page);
+            missingPages.put(tag + ":" + page, missingPage);
+        }
+        missingPage.formInstanceIds.add(formInstanceId);
+    }
+
+    @SuppressWarnings("unchecked")
+    private void retryMissingPages(Map<String, MissingPage> missingPages,
+                                   AtomicInteger triggered, AtomicInteger failed,
+                                   Map<String, FailedApproval> retryQueue) {
+        for (MissingPage missingPage : missingPages.values()) {
+            try {
+                DDR_New response = ydClient.queryData(YDParam.builder()
+                        .appType(whConf.getYidaAppType())
+                        .systemToken(whConf.getYidaSystemToken())
+                        .formUuid(missingPage.formUuid)
+                        .instanceStatus("COMPLETED")
+                        .pageNumber(missingPage.page)
+                        .pageSize(PAGE_SIZE)
+                        .build(), YDConf.FORM_QUERY.retrieve_search_process);
+                List<Map> items = (List<Map>) response.getData();
+                Set<String> unresolved = new LinkedHashSet<>(missingPage.formInstanceIds);
+                if (items != null) {
+                    for (Map item : items) {
+                        String formInstanceId = formInstanceId(item);
+                        if (!unresolved.contains(formInstanceId)) continue;
+                        if (retryIncompleteItem(missingPage.tag, item, triggered, failed, retryQueue)) {
+                            unresolved.remove(formInstanceId);
+                        }
+                    }
+                }
+                for (String formInstanceId : unresolved) {
+                    log.error("[审批回写兜底] 本轮补扫仍缺少表单数据、审批结果或目标实例 tag={} formInstanceId={}",
+                            missingPage.tag, formInstanceId);
+                }
+            } catch (Exception ex) {
+                log.error("[审批回写兜底] 缺数据页面本轮补扫失败 tag={} page={}",
+                        missingPage.tag, missingPage.page, ex);
+                recordTimerException(ex);
+            }
+        }
+    }
+
+    private boolean retryIncompleteItem(String tag, Map item,
+                                        AtomicInteger triggered, AtomicInteger failed,
+                                        Map<String, FailedApproval> retryQueue) {
+        Map formData = extractFormData(item);
+        if (formData == null) return false;
+        String formInstanceId = formInstanceId(item);
+        String status = String.valueOf(formData.getOrDefault(resolveSyncStatusField(tag), ""));
+        if (!shouldRetrySyncStatus(status)) {
+            failed.decrementAndGet();
+            return true;
+        }
+        Integer resultCode = resolveResultCode(item.get("approvedResult"));
+        if (resultCode == null) return false;
+        try {
+            ApprovalWriteBackResult result = approvalWriteBackService.writeBack(formInstanceId, resultCode);
+            triggered.incrementAndGet();
+            if (result != null && "全部成功".equals(result.getSyncStatus())) {
+                failed.decrementAndGet();
+            } else {
+                retryQueue.put(formInstanceId, new FailedApproval(tag, formInstanceId, resultCode));
+                recordWriteBackFailure(tag, formInstanceId, result);
+            }
+        } catch (Exception ex) {
+            retryQueue.put(formInstanceId, new FailedApproval(tag, formInstanceId, resultCode));
+            recordWriteBackException(tag, formInstanceId, ex);
+        }
+        return true;
+    }
+
+    private static String formInstanceId(Map item) {
+        Object instanceId = item.get("formInstanceId");
+        if (instanceId == null) instanceId = item.get("processInstanceId");
+        return instanceId == null ? "" : String.valueOf(instanceId);
+    }
+
+    private int retryFailedApprovals(Map<String, FailedApproval> retryQueue, AtomicInteger failed) {
+        int recovered = 0;
+        for (FailedApproval target : retryQueue.values()) {
+            try {
+                if (approvalStatusAlreadySuccess(target)) {
+                    failed.decrementAndGet();
+                    recovered++;
+                    continue;
+                }
+                ApprovalWriteBackResult result = approvalWriteBackService.writeBack(
+                        target.formInstanceId, target.resultCode);
+                if (result != null && "全部成功".equals(result.getSyncStatus())) {
+                    failed.decrementAndGet();
+                    recovered++;
+                    log.info("[审批回写兜底] 延后重试成功 tag={} formInstanceId={}",
+                            target.tag, target.formInstanceId);
+                } else {
+                    log.warn("[审批回写兜底] 延后重试仍未完成 tag={} formInstanceId={}",
+                            target.tag, target.formInstanceId);
+                    recordWriteBackFailure(target.tag, target.formInstanceId, result);
+                }
+            } catch (Exception ex) {
+                log.error("[审批回写兜底] 延后重试失败 tag={} formInstanceId={}",
+                        target.tag, target.formInstanceId, ex);
+                recordWriteBackException(target.tag, target.formInstanceId, ex);
+            }
+        }
+        return recovered;
+    }
+
+    private boolean approvalStatusAlreadySuccess(FailedApproval target) {
+        try {
+            DDR_New response = ydClient.queryData(YDParam.builder()
+                    .appType(whConf.getYidaAppType())
+                    .systemToken(whConf.getYidaSystemToken())
+                    .formInstanceId(target.formInstanceId)
+                    .build(), YDConf.FORM_QUERY.retrieve_id);
+            Map formData = response == null ? null : response.getFormData();
+            if (formData == null && response != null && response.getData() instanceof Map) {
+                formData = (Map) response.getData();
+            }
+            return formData != null && "全部成功".equals(String.valueOf(
+                    formData.get(resolveSyncStatusField(target.tag))));
+        } catch (Exception ex) {
+            log.warn("[审批回写兜底] 补试前状态回读失败,继续执行幂等回写 formInstanceId={}",
+                    target.formInstanceId, ex);
+            return false;
+        }
+    }
+
+    private static final class FailedApproval {
+        private final String tag;
+        private final String formInstanceId;
+        private final int resultCode;
+
+        private FailedApproval(String tag, String formInstanceId, int resultCode) {
+            this.tag = tag;
+            this.formInstanceId = formInstanceId;
+            this.resultCode = resultCode;
+        }
+    }
+
     // fixme 不设置固定页数上限,避免全量扫描超过 2000 条时漏掉历史未回写审批。
     static boolean hasNextPage(int pageNumber, int pageSize, long totalCount, int returnedSize) {
         if (returnedSize < pageSize) {

+ 10 - 18
mjava-akdsbeisen/src/main/java/com/malk/timer/PersonnelSyncTimer.java

@@ -49,28 +49,19 @@ public class PersonnelSyncTimer {
         try {
             for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
                 try {
-                    java.util.Map<String, Object> stats = personnelSyncService.fullSync(null);
+                    Map<String, Object> stats = personnelSyncService.fullSync(null);
                     if (hasSyncFailures(stats)) {
-                        log.warn("[PersonnelSync] 定时同步存在未完成记录 tag={} attempt={}/{} {}",
-                                tag, attempt, MAX_ATTEMPTS, stats);
-                        if (attempt < MAX_ATTEMPTS) {
-                            if (!waitBeforeRetry(tag)) return;
-                            continue;
-                        }
-                        log.error("[PersonnelSync] 定时同步连续 {} 次仍有未完成记录,等待下一次 cron tag={} {}",
-                                MAX_ATTEMPTS, tag, stats);
-                        return;
+                        log.error("[PersonnelSync] 本轮补试后仍有未完成记录,等待下一次 cron tag={} {}", tag, stats);
+                    } else {
+                        log.info("[PersonnelSync] 定时同步任务完成 tag={} {}", tag, stats);
                     }
-                    log.info("[PersonnelSync] 定时同步任务完成 tag={} attempt={}/{} {}", tag, attempt, MAX_ATTEMPTS, stats);
                     return;
-                } catch (Exception e) {
-                    log.error("[PersonnelSync] 定时同步任务失败 tag={} attempt={}/{}", tag, attempt, MAX_ATTEMPTS, e);
-                    if (attempt < MAX_ATTEMPTS) {
-                        if (!waitBeforeRetry(tag)) return;
-                    }
+                } catch (Exception ex) {
+                    log.error("[PersonnelSync] 定时同步任务异常 tag={} attempt={}/{}",
+                            tag, attempt, MAX_ATTEMPTS, ex);
+                    if (attempt < MAX_ATTEMPTS && !waitBeforeRetry(tag)) return;
                 }
             }
-            log.error("[PersonnelSync] 定时同步任务连续 {} 次失败,放弃本轮等待下一次 cron 触发 tag={}", MAX_ATTEMPTS, tag);
         } finally {
             running.set(false);
         }
@@ -93,10 +84,11 @@ public class PersonnelSyncTimer {
         try {
             Thread.sleep(RETRY_DELAY_MS);
             return true;
-        } catch (InterruptedException ie) {
+        } catch (InterruptedException ex) {
             Thread.currentThread().interrupt();
             log.warn("[PersonnelSync] 重试等待被中断,放弃本轮 tag={}", tag);
             return false;
         }
     }
+
 }

+ 8 - 2
mjava-akdsbeisen/src/main/java/com/malk/timer/ProjectChangeSyncTimer.java

@@ -8,6 +8,8 @@ import org.springframework.context.annotation.Configuration;
 import org.springframework.scheduling.annotation.EnableScheduling;
 import org.springframework.scheduling.annotation.Scheduled;
 
+import java.util.Map;
+
 /**
  * 项目变更审批增量同步定时器
  * <p>
@@ -32,8 +34,12 @@ public class ProjectChangeSyncTimer {
     public void syncProjectChanges() {
         log.info("开始执行项目变更审批增量同步任务");
         try {
-            workHoursCalcService.syncProjectChanges(7);
-            log.info("项目变更审批增量同步任务执行完成");
+            Map<String, Object> stats = workHoursCalcService.syncProjectChanges(7);
+            if (stats.get("fail") instanceof Number && ((Number) stats.get("fail")).intValue() > 0) {
+                log.error("项目变更审批增量同步任务本轮补试后仍有失败项 {}", stats);
+            } else {
+                log.info("项目变更审批增量同步任务执行完成 {}", stats);
+            }
         } catch (Exception e) {
             log.error("项目变更审批增量同步任务执行失败", e);
         }

+ 107 - 29
mjava-akdsbeisen/src/main/java/com/malk/timer/ReSubmitApprovalTimer.java

@@ -16,6 +16,9 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
 
 import java.util.Map;
 import java.util.Set;
+import java.util.List;
+import java.util.ArrayList;
+import java.util.AbstractMap;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 /**
@@ -81,42 +84,72 @@ public class ReSubmitApprovalTimer {
         }
         try {
             int lookbackDays = whConf.getApprovalResubmitModifiedLookbackDays();
-            Map<String, Set<String>> targets = reSubmitApprovalService.findRecentlyModifiedTargets(lookbackDays);
-            log.info("[补发起兜底] 开始处理 lookbackDays={} targets={}", lookbackDays, targets);
             int totalCreated = 0, totalUpdated = 0, totalFailed = 0;
-            for (Map.Entry<String, Set<String>> monthEntry : targets.entrySet()) {
-                String monthText = monthEntry.getKey();
+            List<Map.Entry<String, String>> retryQueue = new ArrayList<>();
+            boolean targetScanFailed = false;
+            try {
+                Map<String, Set<String>> targets = reSubmitApprovalService.findRecentlyModifiedTargets(lookbackDays);
+                log.info("[补发起兜底] 开始处理 lookbackDays={} targets={}", lookbackDays, targets);
+                int[] counts = processTargets(targets, retryQueue);
+                totalCreated += counts[0];
+                totalUpdated += counts[1];
+                totalFailed += counts[2];
+            } catch (Exception ex) {
+                totalFailed++;
+                targetScanFailed = true;
+                log.error("[补发起兜底] 目标扫描失败,先继续补填审批扫描,待本轮结束补扫", ex);
+            }
+            boolean repairScanFailed = false;
+            try {
+                TimeCardRepairService.RepairResubmitResult repairResult =
+                        timeCardRepairService.resubmitRecentlyModifiedPending(lookbackDays);
+                totalCreated += repairResult.getStarted();
+                totalFailed += repairResult.getFailed();
+            } catch (Exception ex) {
+                totalFailed++;
+                repairScanFailed = true;
+                log.error("[补填审批补发] 执行失败", ex);
+            }
+            if (targetScanFailed) {
                 try {
-                    if (monthClosingService.isClosed(monthText)) {
-                        log.info("[补发起兜底] 月份已封账,跳过本月全部目标 month={} submitters={}",
-                                monthText, monthEntry.getValue().size());
-                        continue;
-                    }
+                    Map<String, Set<String>> targets = reSubmitApprovalService.findRecentlyModifiedTargets(lookbackDays);
+                    int[] counts = processTargets(targets, retryQueue);
+                    totalCreated += counts[0];
+                    totalUpdated += counts[1];
+                    totalFailed += counts[2] - 1;
                 } catch (Exception ex) {
-                    totalFailed += monthEntry.getValue().size();
-                    log.error("[补发起兜底] 封账状态查询失败,为避免封账后补发,跳过本月 month={}",
-                            monthText, ex);
-                    continue;
+                    log.error("[补发起兜底] 本轮目标补扫仍失败", ex);
                 }
-                for (String submitterUserId : monthEntry.getValue()) {
-                    try {
-                        ReSubmitApprovalResult result = reSubmitApprovalService.resubmit(
-                                monthText, submitterUserId);
-                        totalCreated += result.getBillableCreated() + result.getOtherCreated();
-                        totalUpdated += result.getBillableUpdated() + result.getOtherUpdated();
-                        totalFailed += result.getFailed();
-                    } catch (Exception ex) {
-                        totalFailed++;
-                        log.error("[补发起兜底] 定向处理失败 month={} submitter={}",
-                                monthText, submitterUserId, ex);
+            }
+            for (Map.Entry<String, String> target : retryQueue) {
+                try {
+                    if (monthClosingService.isClosed(target.getKey())) {
+                        log.warn("[补发起兜底] 补试前月份已封账,跳过 month={} submitter={}",
+                                target.getKey(), target.getValue());
+                        continue;
                     }
+                    ReSubmitApprovalResult retried = reSubmitApprovalService.resubmit(
+                            target.getKey(), target.getValue());
+                    totalCreated += retried.getBillableCreated() + retried.getOtherCreated();
+                    totalUpdated += retried.getBillableUpdated() + retried.getOtherUpdated();
+                    if (!hasPendingFailures(retried)) totalFailed--;
+                    else log.error("[补发起兜底] 本轮补试仍失败 month={} submitter={} result={}",
+                            target.getKey(), target.getValue(), retried);
+                } catch (Exception ex) {
+                    log.error("[补发起兜底] 本轮补试失败 month={} submitter={}",
+                            target.getKey(), target.getValue(), ex);
                 }
             }
-            try {
-                totalCreated += timeCardRepairService.resubmitRecentlyModifiedPending(lookbackDays);
-            } catch (Exception ex) {
-                totalFailed++;
-                log.error("[补填审批补发] 执行失败", ex);
+            if (repairScanFailed) {
+                try {
+                    TimeCardRepairService.RepairResubmitResult repairResult =
+                            timeCardRepairService.resubmitRecentlyModifiedPending(lookbackDays);
+                    totalCreated += repairResult.getStarted();
+                    totalFailed += repairResult.getFailed();
+                    totalFailed--;
+                } catch (Exception ex) {
+                    log.error("[补填审批补发] 本轮补试仍失败", ex);
+                }
             }
             log.info("[补发起兜底] 完成 created={} updated={} failed={}",
                     totalCreated, totalUpdated, totalFailed);
@@ -127,6 +160,51 @@ public class ReSubmitApprovalTimer {
         }
     }
 
+    private int[] processTargets(Map<String, Set<String>> targets,
+                                 List<Map.Entry<String, String>> retryQueue) {
+        int totalCreated = 0, totalUpdated = 0, totalFailed = 0;
+        for (Map.Entry<String, Set<String>> monthEntry : targets.entrySet()) {
+            String monthText = monthEntry.getKey();
+            try {
+                if (monthClosingService.isClosed(monthText)) {
+                    log.info("[补发起兜底] 月份已封账,跳过本月全部目标 month={} submitters={}",
+                            monthText, monthEntry.getValue().size());
+                    continue;
+                }
+            } catch (Exception ex) {
+                totalFailed += monthEntry.getValue().size();
+                for (String submitterUserId : monthEntry.getValue()) {
+                    retryQueue.add(new AbstractMap.SimpleImmutableEntry<>(monthText, submitterUserId));
+                }
+                log.error("[补发起兜底] 封账状态查询失败,为避免封账后补发,跳过本月 month={}",
+                        monthText, ex);
+                continue;
+            }
+            for (String submitterUserId : monthEntry.getValue()) {
+                try {
+                    ReSubmitApprovalResult result = reSubmitApprovalService.resubmit(
+                            monthText, submitterUserId);
+                    totalCreated += result.getBillableCreated() + result.getOtherCreated();
+                    totalUpdated += result.getBillableUpdated() + result.getOtherUpdated();
+                    if (hasPendingFailures(result)) {
+                        totalFailed++;
+                        retryQueue.add(new AbstractMap.SimpleImmutableEntry<>(monthText, submitterUserId));
+                    }
+                } catch (Exception ex) {
+                    totalFailed++;
+                    retryQueue.add(new AbstractMap.SimpleImmutableEntry<>(monthText, submitterUserId));
+                    log.error("[补发起兜底] 定向处理失败 month={} submitter={}",
+                            monthText, submitterUserId, ex);
+                }
+            }
+        }
+        return new int[]{totalCreated, totalUpdated, totalFailed};
+    }
+
+    private static boolean hasPendingFailures(ReSubmitApprovalResult result) {
+        return result == null || result.getFailed() > 0 || result.getSummaryStatusFailed() > 0;
+    }
+
     /**
      * prd 每天 16:00 再执行一次补发兜底,与中午时段的应报工时任务错开。
      * 封账月份仍由 {@link #scanAndReSubmit()} 内的实时封账校验统一跳过。

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

@@ -11,12 +11,16 @@ import org.junit.Test;
 
 import java.lang.reflect.Field;
 import java.lang.reflect.Method;
+import java.lang.reflect.Constructor;
 import java.time.LocalDate;
 import java.time.ZoneId;
 import java.util.Collections;
+import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
@@ -138,6 +142,55 @@ public class PersonnelSyncServiceImplTest {
         assertEquals(1, service.enrichManagers(users));
     }
 
+    @Test
+    public void failedCreateShouldRetryAfterAllFirstPassWritesAndQueryUserid() throws Exception {
+        YDClient ydClient = mock(YDClient.class);
+        PersonnelSyncConf conf = testConf();
+        conf.setConcurrency(1);
+        PersonnelSyncServiceImpl service = new PersonnelSyncServiceImpl();
+        setField(service, "ydClient", ydClient);
+        setField(service, "conf", conf);
+        invokeNoArg(service, "initRateLimiters");
+        List<String> order = new ArrayList<>();
+        AtomicInteger firstUserWrites = new AtomicInteger();
+        when(ydClient.operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.create)))
+                .thenAnswer(invocation -> {
+                    YDParam param = invocation.getArgument(0);
+                    String userid = param.formDataJson.contains("employee-1") ? "employee-1" : "employee-2";
+                    order.add("write:" + userid);
+                    if ("employee-1".equals(userid) && firstUserWrites.incrementAndGet() == 1) {
+                        throw new IllegalStateException("temporary error");
+                    }
+                    return "ok";
+                });
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenAnswer(invocation -> {
+                    order.add("query:employee-1");
+                    DDR_New<Object> result = new DDR_New<>();
+                    result.setData(Collections.emptyList());
+                    return result;
+                });
+        Class<?> actionClass = Class.forName(PersonnelSyncServiceImpl.class.getName() + "$Action");
+        Constructor<?> constructor = actionClass.getDeclaredConstructor(
+                String.class, String.class, String.class, Map.class);
+        constructor.setAccessible(true);
+        List<Object> actions = Arrays.asList(
+                constructor.newInstance("CREATE", "employee-1", null,
+                        Collections.singletonMap(conf.getFieldEmployee(), Collections.singletonList("employee-1"))),
+                constructor.newInstance("CREATE", "employee-2", null,
+                        Collections.singletonMap(conf.getFieldEmployee(), Collections.singletonList("employee-2"))));
+        Method concurrentWrite = PersonnelSyncServiceImpl.class.getDeclaredMethod("concurrentWrite", List.class);
+        concurrentWrite.setAccessible(true);
+
+        Object stats = concurrentWrite.invoke(service, actions);
+
+        assertEquals(Arrays.asList("write:employee-1", "write:employee-2",
+                "query:employee-1", "write:employee-1"), order);
+        Field failedField = stats.getClass().getDeclaredField("failed");
+        failedField.setAccessible(true);
+        assertEquals(0, ((AtomicInteger) failedField.get(stats)).get());
+    }
+
     private static PersonnelSyncConf testConf() {
         PersonnelSyncConf conf = new PersonnelSyncConf();
         conf.setYidaAppType("test-app");

+ 71 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/ApprovalWriteBackServiceTest.java

@@ -1,7 +1,13 @@
 package com.malk.service.workhours;
 
 import com.malk.server.dingtalk.DDR_New;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.workhours.ApprovalWriteBackResult;
+import com.malk.server.workhours.WHConf;
+import com.malk.service.aliwork.YDClient;
 import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
 
 import java.util.ArrayList;
 import java.util.Collections;
@@ -12,9 +18,74 @@ import java.util.Map;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 class ApprovalWriteBackServiceTest {
 
+    @Test
+    void shouldReportApprovalStatusWriteFailureToTimer() {
+        YDClient client = mock(YDClient.class);
+        WHConf conf = new WHConf();
+        conf.setOtherApprovalSyncStatusField("status-field");
+        conf.setOtherApprovalSyncTotalField("total-field");
+        conf.setOtherApprovalSyncFailField("fail-field");
+        Map<String, Object> approvalRow = new HashMap<>();
+        approvalRow.put("textField_mmd1wu9f", "user-1");
+        approvalRow.put("textField_mmd8g655", "20260818");
+        approvalRow.put("selectField_mmeakgie", "培训");
+        approvalRow.put("numberField_mmd1wu9e", 8d);
+        Map<String, Object> approvalData = new HashMap<>();
+        approvalData.put("tableField_mmd1wu9h", Collections.singletonList(approvalRow));
+        DDR_New<Object> response = new DDR_New<>();
+        response.setData(approvalData);
+        when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process_id)))
+                .thenReturn(response);
+        when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenThrow(new IllegalStateException("day record unavailable"));
+        when(client.operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.update)))
+                .thenThrow(new IllegalStateException("status update failed"));
+        ApprovalWriteBackService service = new ApprovalWriteBackService();
+        ReflectionTestUtils.setField(service, "ydClient", client);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+
+        assertThrows(IllegalStateException.class, () -> service.writeBack("approval-1", 1));
+    }
+
+    @Test
+    void shouldLeaveFailedDayForDeferredTimerRetryWithoutImmediateRetry() {
+        YDClient client = mock(YDClient.class);
+        WHConf conf = new WHConf();
+        conf.setFormUuidOtherApproval("other-approval");
+        Map<String, Object> approvalRow = new HashMap<>();
+        approvalRow.put("textField_mmd1wu9f", "user-1");
+        approvalRow.put("textField_mmd8g655", "20260818");
+        approvalRow.put("selectField_mmeakgie", "培训");
+        approvalRow.put("numberField_mmd1wu9e", 8d);
+        Map<String, Object> approvalData = new HashMap<>();
+        approvalData.put("tableField_mmd1wu9h", Collections.singletonList(approvalRow));
+        DDR_New<Object> approvalResponse = new DDR_New<>();
+        approvalResponse.setData(approvalData);
+        when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process_id)))
+                .thenReturn(approvalResponse);
+        when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenThrow(new IllegalStateException("temporary query failure"));
+        ApprovalWriteBackService service = new ApprovalWriteBackService();
+        ReflectionTestUtils.setField(service, "ydClient", client);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+
+        ApprovalWriteBackResult result = service.writeBack("approval-1", 1);
+
+        assertEquals(1, result.getFailCount());
+        verify(client, times(1)).queryData(
+                any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form));
+    }
+
     @Test
     void shouldPreferApprovedStatus() {
         assertEquals("已审批", ApprovalWriteBackService.resolveSummaryApprovalStatus(8, 8));

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

@@ -1,12 +1,27 @@
 package com.malk.service.workhours;
 
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.server.workhours.TimeCardApprovalStartRequest;
+import com.malk.server.workhours.WHConf;
+import com.malk.service.aliwork.YDClient;
 import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
 
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 class TimeCardRepairServiceTest {
 
@@ -37,4 +52,56 @@ class TimeCardRepairServiceTest {
 
         assertFalse(TimeCardRepairService.hasApprovedOrChangedHours(formData));
     }
+
+    @Test
+    void shouldReportRepairBatchStillFailedAfterDeferredRetry() {
+        YDClient client = mock(YDClient.class);
+        ReSubmitApprovalService approvalService = mock(ReSubmitApprovalService.class);
+        WHConf conf = new WHConf();
+        conf.setFormUuidRepairWorkHoursSummary("repair-summary");
+        conf.setFormUuidApproval("bill-approval");
+        conf.setFormUuidOtherApproval("other-approval");
+        conf.setRepairSummarySyncStatusField("repair-status");
+        conf.setRepairSummaryBatchField("repair-batch");
+        conf.setRepairSummarySubmitDateField("repair-submit-date");
+        conf.setApprovalFillTypeField("fill-type");
+        conf.setOtherApprovalRepairSubmitDateField("other-submit-date");
+        conf.setOtherApprovalRepairBatchField("other-batch");
+        conf.setOtherApprovalRepairSourceMonthField("other-source-month");
+        Map<String, Object> otherRow = new HashMap<>();
+        otherRow.put("numberField_mmeakgij", 8d);
+        otherRow.put("numberField_mmeakgii", 0d);
+        otherRow.put("selectField_mmeakgie", "培训");
+        Map<String, Object> formData = new HashMap<>();
+        formData.put("repair-status", "审批中");
+        formData.put("repair-batch", "batch-1");
+        formData.put("repair-submit-date", 1787000000000L);
+        formData.put("textField_mmbffvd9", "user-1");
+        formData.put("textField_mmbffvda", "202608");
+        formData.put("textField_mmbffvdb", "20260818");
+        formData.put("tableField_mmeakgid", Collections.singletonList(otherRow));
+        Map<String, Object> record = new HashMap<>();
+        record.put("formData", formData);
+        DDR_New<Object> summaries = new DDR_New<>();
+        summaries.setData(Collections.singletonList(record));
+        DDR_New<Object> noApprovals = new DDR_New<>();
+        noApprovals.setData(Collections.emptyList());
+        when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(summaries);
+        when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process)))
+                .thenReturn(noApprovals);
+        when(approvalService.startApproval(any(TimeCardApprovalStartRequest.class)))
+                .thenThrow(new IllegalStateException("temporary failure"));
+        TimeCardRepairService service = new TimeCardRepairService();
+        ReflectionTestUtils.setField(service, "ydClient", client);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+        ReflectionTestUtils.setField(service, "reSubmitApprovalService", approvalService);
+
+        TimeCardRepairService.RepairResubmitResult result =
+                service.resubmitRecentlyModifiedPending(7);
+
+        assertEquals(0, result.getStarted());
+        assertEquals(1, result.getFailed());
+        verify(approvalService, times(2)).startApproval(any(TimeCardApprovalStartRequest.class));
+    }
 }

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

@@ -15,13 +15,16 @@ import java.lang.reflect.Constructor;
 import java.time.LocalDate;
 import java.time.ZoneId;
 import java.util.ArrayList;
+import java.util.AbstractList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
+import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
@@ -227,6 +230,55 @@ public class WorkHoursCalcServiceTest {
         verify(ydClient).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.upsert));
     }
 
+    @Test
+    @SuppressWarnings("unchecked")
+    public void concurrentUpsertShouldDeferFailedManagerCalculationUntilFirstPassEnds() throws Exception {
+        YDClient ydClient = mock(YDClient.class);
+        WorkHoursCalcService service = workHoursService(ydClient);
+        Object managerData = ReflectionTestUtils.invokeMethod(
+                service, "queryManagerData", Collections.emptyMap());
+        Map<String, List<Object>> external = (Map<String, List<Object>>)
+                ReflectionTestUtils.getField(managerData, "external");
+        Object projectAssignment = assignment("project-1", null, "manager-1");
+        AtomicInteger scans = new AtomicInteger();
+        List<Object> assignments = new AbstractList<Object>() {
+            @Override
+            public Object get(int index) {
+                return projectAssignment;
+            }
+
+            @Override
+            public int size() {
+                return 1;
+            }
+
+            @Override
+            public Iterator<Object> iterator() {
+                if (scans.incrementAndGet() == 2) {
+                    throw new IllegalStateException("temporary manager calculation failure");
+                }
+                return Collections.singletonList(projectAssignment).iterator();
+            }
+        };
+        external.put("employee-1", assignments);
+        Map<String, Object> personnel = new HashMap<>();
+        personnel.put("radioField_mkow4ydo", "外部");
+        personnel.put("radioField_mp1sngq1", "在职");
+        Map<String, Map<String, Object>> personnelMap = new HashMap<>();
+        personnelMap.put("employee-1", personnel);
+        LocalDate workDay = LocalDate.now().minusDays(1);
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(pageOf(Collections.emptyList()));
+
+        int[] result = ReflectionTestUtils.invokeMethod(service, "concurrentUpsert",
+                personnelMap, managerData, Collections.singletonList(workDay), Collections.emptySet());
+
+        assertEquals(1, result[0]);
+        assertEquals(0, result[1]);
+        assertEquals(4, scans.get());
+        verify(ydClient, times(1)).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.upsert));
+    }
+
     @Test
     public void concurrentUpsertShouldQueryBeforeRetryWhenFirstResponseIsAmbiguous() {
         YDClient ydClient = mock(YDClient.class);

+ 91 - 0
mjava-akdsbeisen/src/test/java/com/malk/timer/ReSubmitApprovalTimerTest.java

@@ -18,9 +18,11 @@ import java.util.Set;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
+import org.mockito.InOrder;
 
 class ReSubmitApprovalTimerTest {
 
@@ -48,6 +50,8 @@ class ReSubmitApprovalTimerTest {
         when(monthClosingService.isClosed("202609")).thenReturn(false);
         when(reSubmitService.resubmit("202609", "user-3"))
                 .thenReturn(ReSubmitApprovalResult.builder().build());
+        when(timeCardRepairService.resubmitRecentlyModifiedPending(3))
+                .thenReturn(new TimeCardRepairService.RepairResubmitResult(0, 0));
 
         ReSubmitApprovalTimer timer = new ReSubmitApprovalTimer();
         ReflectionTestUtils.setField(timer, "reSubmitApprovalService", reSubmitService);
@@ -64,4 +68,91 @@ class ReSubmitApprovalTimerTest {
         verify(reSubmitService).resubmit("202609", "user-3");
         verify(timeCardRepairService).resubmitRecentlyModifiedPending(3);
     }
+
+    @Test
+    void shouldRetryFailedTargetOnlyAfterOtherTargetsAndRepairScan() {
+        ReSubmitApprovalService reSubmitService = mock(ReSubmitApprovalService.class);
+        MonthClosingService monthClosingService = mock(MonthClosingService.class);
+        TimeCardRepairService repairService = mock(TimeCardRepairService.class);
+        WHConf conf = new WHConf();
+        Map<String, Set<String>> targets = new LinkedHashMap<>();
+        targets.put("202609", new LinkedHashSet<>(Arrays.asList("user-1", "user-2")));
+        when(reSubmitService.findRecentlyModifiedTargets(7)).thenReturn(targets);
+        when(reSubmitService.resubmit("202609", "user-1"))
+                .thenReturn(ReSubmitApprovalResult.builder().failed(1).build())
+                .thenReturn(ReSubmitApprovalResult.builder().otherCreated(1).build());
+        when(reSubmitService.resubmit("202609", "user-2"))
+                .thenReturn(ReSubmitApprovalResult.builder().build());
+        when(repairService.resubmitRecentlyModifiedPending(7))
+                .thenReturn(new TimeCardRepairService.RepairResubmitResult(0, 0));
+        ReSubmitApprovalTimer timer = new ReSubmitApprovalTimer();
+        ReflectionTestUtils.setField(timer, "reSubmitApprovalService", reSubmitService);
+        ReflectionTestUtils.setField(timer, "monthClosingService", monthClosingService);
+        ReflectionTestUtils.setField(timer, "timeCardRepairService", repairService);
+        ReflectionTestUtils.setField(timer, "whConf", conf);
+
+        timer.scanAndReSubmit();
+
+        InOrder order = inOrder(reSubmitService, repairService);
+        order.verify(reSubmitService).resubmit("202609", "user-1");
+        order.verify(reSubmitService).resubmit("202609", "user-2");
+        order.verify(repairService).resubmitRecentlyModifiedPending(7);
+        order.verify(reSubmitService).resubmit("202609", "user-1");
+    }
+
+    @Test
+    void shouldRetrySummaryStatusFailuresAfterRepairScan() {
+        ReSubmitApprovalService reSubmitService = mock(ReSubmitApprovalService.class);
+        MonthClosingService monthClosingService = mock(MonthClosingService.class);
+        TimeCardRepairService repairService = mock(TimeCardRepairService.class);
+        Map<String, Set<String>> targets = new LinkedHashMap<>();
+        targets.put("202609", new LinkedHashSet<>(Arrays.asList("user-1")));
+        when(reSubmitService.findRecentlyModifiedTargets(7)).thenReturn(targets);
+        when(reSubmitService.resubmit("202609", "user-1"))
+                .thenReturn(ReSubmitApprovalResult.builder().summaryStatusFailed(1).build())
+                .thenReturn(ReSubmitApprovalResult.builder().build());
+        when(repairService.resubmitRecentlyModifiedPending(7))
+                .thenReturn(new TimeCardRepairService.RepairResubmitResult(0, 0));
+        ReSubmitApprovalTimer timer = new ReSubmitApprovalTimer();
+        ReflectionTestUtils.setField(timer, "reSubmitApprovalService", reSubmitService);
+        ReflectionTestUtils.setField(timer, "monthClosingService", monthClosingService);
+        ReflectionTestUtils.setField(timer, "timeCardRepairService", repairService);
+        ReflectionTestUtils.setField(timer, "whConf", new WHConf());
+
+        timer.scanAndReSubmit();
+
+        InOrder order = inOrder(reSubmitService, repairService);
+        order.verify(reSubmitService).resubmit("202609", "user-1");
+        order.verify(repairService).resubmitRecentlyModifiedPending(7);
+        order.verify(reSubmitService).resubmit("202609", "user-1");
+    }
+
+    @Test
+    void shouldContinueRepairScanBeforeRetryingFailedTargetDiscovery() {
+        ReSubmitApprovalService reSubmitService = mock(ReSubmitApprovalService.class);
+        MonthClosingService monthClosingService = mock(MonthClosingService.class);
+        TimeCardRepairService repairService = mock(TimeCardRepairService.class);
+        Map<String, Set<String>> targets = new LinkedHashMap<>();
+        targets.put("202609", new LinkedHashSet<>(Arrays.asList("user-1")));
+        when(reSubmitService.findRecentlyModifiedTargets(7))
+                .thenThrow(new IllegalStateException("temporary scan failure"))
+                .thenReturn(targets);
+        when(reSubmitService.resubmit("202609", "user-1"))
+                .thenReturn(ReSubmitApprovalResult.builder().build());
+        when(repairService.resubmitRecentlyModifiedPending(7))
+                .thenReturn(new TimeCardRepairService.RepairResubmitResult(0, 0));
+        ReSubmitApprovalTimer timer = new ReSubmitApprovalTimer();
+        ReflectionTestUtils.setField(timer, "reSubmitApprovalService", reSubmitService);
+        ReflectionTestUtils.setField(timer, "monthClosingService", monthClosingService);
+        ReflectionTestUtils.setField(timer, "timeCardRepairService", repairService);
+        ReflectionTestUtils.setField(timer, "whConf", new WHConf());
+
+        timer.scanAndReSubmit();
+
+        InOrder order = inOrder(reSubmitService, repairService);
+        order.verify(reSubmitService).findRecentlyModifiedTargets(7);
+        order.verify(repairService).resubmitRecentlyModifiedPending(7);
+        order.verify(reSubmitService).findRecentlyModifiedTargets(7);
+        order.verify(reSubmitService).resubmit("202609", "user-1");
+    }
 }

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

@@ -1,24 +1,39 @@
 package com.malk.timer;
 
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.server.workhours.ApprovalWriteBackResult;
 import com.malk.service.workhours.WorkHoursCalcService;
 import com.malk.server.workhours.WHConf;
+import com.malk.service.aliwork.YDClient;
+import com.malk.service.workhours.ApprovalWriteBackService;
+import com.malk.service.workhours.BackendExceptionLogService;
 import org.junit.Test;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.test.util.ReflectionTestUtils;
 
 import java.lang.reflect.Method;
+import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
 
 public class WorkHoursTimerScheduleTest {
 
@@ -163,6 +178,181 @@ public class WorkHoursTimerScheduleTest {
         assertEquals(null, ApprovalWriteBackTimer.resolveResultCode("unknown"));
     }
 
+    @Test
+    public void approvalWriteBackShouldRetryFailedItemsAfterBothFormsFinish() {
+        YDClient client = mock(YDClient.class);
+        ApprovalWriteBackService service = mock(ApprovalWriteBackService.class);
+        ApprovalWriteBackTimer timer = approvalTimer(client, service);
+        when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process)))
+                .thenReturn(approvalResponse("bill-id"), approvalResponse("other-id"));
+        ApprovalWriteBackResult success = ApprovalWriteBackResult.builder().syncStatus("全部成功").build();
+        List<String> calls = new ArrayList<>();
+        AtomicInteger billCalls = new AtomicInteger();
+        when(service.writeBack("bill-id", 1)).thenAnswer(invocation -> {
+            calls.add("bill");
+            return billCalls.incrementAndGet() == 1
+                    ? ApprovalWriteBackResult.builder().syncStatus("部分失败").build() : success;
+        });
+        when(service.writeBack("other-id", 1)).thenAnswer(invocation -> {
+            calls.add("other");
+            return success;
+        });
+
+        timer.scanAndWriteBack();
+
+        assertEquals(Arrays.asList("bill", "other", "bill"), calls);
+        verify(service, times(2)).writeBack("bill-id", 1);
+    }
+
+    @Test
+    public void approvalWriteBackShouldStopAfterOneDeferredRetry() {
+        YDClient client = mock(YDClient.class);
+        ApprovalWriteBackService service = mock(ApprovalWriteBackService.class);
+        ApprovalWriteBackTimer timer = approvalTimer(client, service);
+        when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process)))
+                .thenReturn(approvalResponse("bill-id"), approvalResponse("other-id"));
+        when(service.writeBack("bill-id", 1)).thenThrow(new IllegalStateException("temporary failure"));
+        when(service.writeBack("other-id", 1))
+                .thenReturn(ApprovalWriteBackResult.builder().syncStatus("全部成功").build());
+
+        timer.scanAndWriteBack();
+
+        verify(service, times(2)).writeBack("bill-id", 1);
+        verify(service).writeBack("other-id", 1);
+    }
+
+    @Test
+    public void approvalWriteBackShouldRetryFailedScanAfterBothForms() {
+        YDClient client = mock(YDClient.class);
+        ApprovalWriteBackService service = mock(ApprovalWriteBackService.class);
+        ApprovalWriteBackTimer timer = approvalTimer(client, service);
+        List<String> calls = new ArrayList<>();
+        AtomicInteger billScans = new AtomicInteger();
+        when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process)))
+                .thenAnswer(invocation -> {
+                    YDParam param = invocation.getArgument(0);
+                    String formUuid = param.getFormUuid();
+                    calls.add("scan:" + formUuid);
+                    if ("bill-form".equals(formUuid) && billScans.incrementAndGet() == 1) {
+                        throw new IllegalStateException("temporary query failure");
+                    }
+                    return approvalResponse("bill-form".equals(formUuid) ? "bill-id" : "other-id");
+                });
+        ApprovalWriteBackResult success = ApprovalWriteBackResult.builder().syncStatus("全部成功").build();
+        when(service.writeBack("bill-id", 1)).thenAnswer(invocation -> {
+            calls.add("write:bill");
+            return success;
+        });
+        when(service.writeBack("other-id", 1)).thenAnswer(invocation -> {
+            calls.add("write:other");
+            return success;
+        });
+
+        timer.scanAndWriteBack();
+
+        assertEquals(Arrays.asList("scan:bill-form", "scan:other-form", "write:other",
+                "scan:bill-form", "write:bill"), calls);
+        verify(service).writeBack("bill-id", 1);
+        verify(service).writeBack("other-id", 1);
+    }
+
+    @Test
+    public void approvalWriteBackShouldRescanMissingFormDataAfterBothForms() {
+        YDClient client = mock(YDClient.class);
+        ApprovalWriteBackService service = mock(ApprovalWriteBackService.class);
+        ApprovalWriteBackTimer timer = approvalTimer(client, service);
+        DDR_New<Object> missing = approvalResponse("bill-id");
+        ((Map) ((List) missing.getData()).get(0)).remove("data");
+        List<String> calls = new ArrayList<>();
+        when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process)))
+                .thenAnswer(invocation -> {
+                    YDParam param = invocation.getArgument(0);
+                    calls.add("scan:" + param.getFormUuid());
+                    if (calls.size() == 1) return missing;
+                    return approvalResponse("bill-form".equals(param.getFormUuid()) ? "bill-id" : "other-id");
+                });
+        ApprovalWriteBackResult success = ApprovalWriteBackResult.builder().syncStatus("全部成功").build();
+        when(service.writeBack("bill-id", 1)).thenAnswer(invocation -> {
+            calls.add("write:bill");
+            return success;
+        });
+        when(service.writeBack("other-id", 1)).thenAnswer(invocation -> {
+            calls.add("write:other");
+            return success;
+        });
+
+        timer.scanAndWriteBack();
+
+        assertEquals(Arrays.asList("scan:bill-form", "scan:other-form", "write:other",
+                "scan:bill-form", "write:bill"), calls);
+    }
+
+    @Test
+    public void approvalWriteBackShouldRescanUnknownResultWithoutGuessing() {
+        YDClient client = mock(YDClient.class);
+        ApprovalWriteBackService service = mock(ApprovalWriteBackService.class);
+        ApprovalWriteBackTimer timer = approvalTimer(client, service);
+        DDR_New<Object> unknown = approvalResponse("bill-id");
+        ((Map) ((List) unknown.getData()).get(0)).remove("approvedResult");
+        when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process)))
+                .thenReturn(unknown, approvalResponse("other-id"), approvalResponse("bill-id"));
+        when(service.writeBack(any(String.class), eq(1)))
+                .thenReturn(ApprovalWriteBackResult.builder().syncStatus("全部成功").build());
+
+        timer.scanAndWriteBack();
+
+        verify(service, times(1)).writeBack("bill-id", 1);
+        verify(service, times(1)).writeBack("other-id", 1);
+    }
+
+    @Test
+    public void approvalWriteBackShouldSkipRetryWhenStatusAlreadySucceeded() {
+        YDClient client = mock(YDClient.class);
+        ApprovalWriteBackService service = mock(ApprovalWriteBackService.class);
+        ApprovalWriteBackTimer timer = approvalTimer(client, service);
+        ((WHConf) ReflectionTestUtils.getField(timer, "whConf"))
+                .setApprovalSyncStatusField("selectField_mq58cd5p");
+        when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process)))
+                .thenReturn(approvalResponse("bill-id"), approvalResponse("other-id"));
+        DDR_New<Object> current = new DDR_New<>();
+        current.setFormData(Collections.singletonMap("selectField_mq58cd5p", "全部成功"));
+        when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_id)))
+                .thenReturn(current);
+        when(service.writeBack("bill-id", 1))
+                .thenReturn(ApprovalWriteBackResult.builder().syncStatus("部分失败").build());
+        when(service.writeBack("other-id", 1))
+                .thenReturn(ApprovalWriteBackResult.builder().syncStatus("全部成功").build());
+
+        timer.scanAndWriteBack();
+
+        verify(service, times(1)).writeBack("bill-id", 1);
+    }
+
+    private static ApprovalWriteBackTimer approvalTimer(YDClient client, ApprovalWriteBackService service) {
+        WHConf conf = new WHConf();
+        conf.setFormUuidApproval("bill-form");
+        conf.setFormUuidOtherApproval("other-form");
+        ApprovalWriteBackTimer timer = new ApprovalWriteBackTimer();
+        ReflectionTestUtils.setField(timer, "ydClient", client);
+        ReflectionTestUtils.setField(timer, "whConf", conf);
+        ReflectionTestUtils.setField(timer, "approvalWriteBackService", service);
+        ReflectionTestUtils.setField(timer, "backendExceptionLogService", mock(BackendExceptionLogService.class));
+        return timer;
+    }
+
+    private static DDR_New<Object> approvalResponse(String instanceId) {
+        Map<String, Object> formData = new HashMap<>();
+        formData.put("selectField_mq58cd5p", "部分失败");
+        Map<String, Object> process = new HashMap<>();
+        process.put("formInstanceId", instanceId);
+        process.put("approvedResult", "agree");
+        process.put("data", formData);
+        DDR_New<Object> response = new DDR_New<>();
+        response.setData(Collections.singletonList(process));
+        response.setTotalCount(1);
+        return response;
+    }
+
     private static void assertSchedulingSwitch(Class<?> timerClass) {
         ConditionalOnProperty condition = timerClass.getAnnotation(ConditionalOnProperty.class);
         assertEquals("enable", condition.prefix());