Преглед изворни кода

fix(workhours): 修复审批补发与重复汇总清理

malk пре 1 месец
родитељ
комит
f81fc8bdde

+ 7 - 4
mjava-akdsbeisen/src/main/java/com/malk/controller/ApprovalController.java

@@ -59,19 +59,22 @@ public class ApprovalController {
 
     /**
      * 补发起工时审批(bug 1 手工触发)
-     * POST /approval/resubmit?monthText=202606[&submitterUserId=xxx]
+     * POST /approval/resubmit?monthText=202606[&submitterUserId=xxx][&dryRun=true]
      * <p>
      * 用于:①第一次全量补齐历史数据 ②Scheduler 上线前手工回归验证
      * 逻辑参考前端 submitApprovalProcess:按 PM 分组 → queryProcess(RUNNING) → update / create
      *
      * @param monthText       yyyyMM
      * @param submitterUserId 可空; 指定则仅处理该员工, 空则该月全员
+     * @param dryRun          true 时只返回候选统计,不创建或更新审批
      */
     @PostMapping("/resubmit")
     public McR<ReSubmitApprovalResult> resubmit(@RequestParam String monthText,
-                                                @RequestParam(required = false) String submitterUserId) {
-        log.info("resubmit, monthText={}, submitterUserId={}", monthText, submitterUserId);
-        return McR.success(reSubmitApprovalService.resubmit(monthText, submitterUserId));
+                                                @RequestParam(required = false) String submitterUserId,
+                                                @RequestParam(defaultValue = "false") boolean dryRun) {
+        log.info("resubmit, monthText={}, submitterUserId={}, dryRun={}",
+                monthText, submitterUserId, dryRun);
+        return McR.success(reSubmitApprovalService.resubmit(monthText, submitterUserId, dryRun));
     }
 
     /**

+ 37 - 1
mjava-akdsbeisen/src/main/java/com/malk/server/workhours/ReSubmitApprovalResult.java

@@ -26,6 +26,31 @@ public class ReSubmitApprovalResult {
      */
     private int submitterCount;
 
+    /**
+     * 是否为只读预演。
+     */
+    private boolean dryRun;
+
+    /**
+     * 项目工时待补发明细数。
+     */
+    private int billableCandidates;
+
+    /**
+     * 项目工时待补发小时数。
+     */
+    private double billableCandidateHours;
+
+    /**
+     * 其他工时待补发明细数。
+     */
+    private int otherCandidates;
+
+    /**
+     * 其他工时待补发小时数。
+     */
+    private double otherCandidateHours;
+
     /**
      * 工时审批 create(没有 RUNNING 或 COMPLETED 覆盖时新建)
      */
@@ -57,6 +82,17 @@ public class ReSubmitApprovalResult {
     private int failed;
 
     /**
-     * 汇总表状态字段回写: 已发起(补发起成功后, 汇总表状态仍为"审批中", 无需改档)
+     * 汇总审批状态待校正记录数。
+     */
+    private int summaryStatusCandidates;
+
+    /**
+     * 按提交/审批总额校正三档状态的汇总记录数。
+     */
+    private int summaryStatusUpdated;
+
+    /**
+     * 汇总状态同步失败数;不影响审批已成功的事实。
      */
+    private int summaryStatusFailed;
 }

+ 4 - 1
mjava-akdsbeisen/src/main/java/com/malk/server/workhours/WHConf.java

@@ -65,7 +65,10 @@ public class WHConf {
     // 补发起发起人部门 ID(可空)
     private String approvalOriginatorDeptId;
 
+    // prd 每日补发起仅扫描最近 N 个自然日发生修改的汇总记录,再按记录实际月份 + 员工定向补发
+    private int approvalResubmitModifiedLookbackDays = 3;
+
     // 汇总表: 审批状态字段(SelectField, 3 档 "暂存/审批中/已审批")
-    // prd 前端在保存/提交时写入此字段, 后端补发起 Scheduler 按 "审批中" + 近 3 天 gmtModified 扫描兜底
+    // prd 前端在保存/提交时写入此字段,后端补发成功后同步为“审批中”
     private String summaryApprovalStatusField;
 }

+ 239 - 9
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/ReSubmitApprovalService.java

@@ -18,6 +18,8 @@ import org.apache.commons.lang3.StringUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
 import java.util.*;
 
 /**
@@ -61,6 +63,19 @@ public class ReSubmitApprovalService {
      */
     private static final String APPROVAL_OPINION_AGREE = "同意";
 
+    private static final String SUMMARY_STATUS_IN_APPROVAL = "审批中";
+
+    private static final String SUMMARY_STATUS_DRAFT = "暂存";
+
+    private static final String SUMMARY_STATUS_APPROVED = "已审批";
+
+    private static final String S_BIL_SUBMITTED_TOTAL = "numberField_mmacxewa";
+    private static final String S_BIL_APPROVED_TOTAL = "numberField_mmacxewg";
+    private static final String S_NON_SUBMITTED_TOTAL = "numberField_mmad6jbz";
+    private static final String S_NON_APPROVED_TOTAL = "numberField_mmad6jc1";
+    private static final String S_OTHER_SUBMITTED_TOTAL = "numberField_mmd1smeq";
+    private static final String S_OTHER_APPROVED_TOTAL = "numberField_mmd1smes";
+
     /**
      * 宜搭单个子表组件可稳定查询和覆盖更新的最大行数。
      */
@@ -226,6 +241,20 @@ public class ReSubmitApprovalService {
      * @return 结果统计
      */
     public ReSubmitApprovalResult resubmit(String monthText, String submitterUserId) {
+        return resubmit(monthText, submitterUserId, false);
+    }
+
+    /**
+     * 对指定月份、指定员工执行补发起或只读预演。
+     *
+     * @param monthText       yyyyMM
+     * @param submitterUserId 可空;空则处理该月所有员工
+     * @param dryRun          true 时只统计候选,不创建或更新审批
+     * @return 执行或预演统计
+     */
+    public ReSubmitApprovalResult resubmit(String monthText,
+                                           String submitterUserId,
+                                           boolean dryRun) {
         McException.assertAccessException(StringUtils.isBlank(monthText), "月份不能为空");
         McException.assertAccessException(StringUtils.isBlank(whConf.getApprovalProcessCode()),
                 "工时审批 processCode 未配置(workhours.approvalProcessCode)");
@@ -262,6 +291,8 @@ public class ReSubmitApprovalService {
         int billableCreated = 0, billableUpdated = 0;
         int otherCreated = 0, otherUpdated = 0;
         int skipped = 0, failed = 0;
+        int billableCandidates = 0, otherCandidates = 0;
+        double billableCandidateHours = 0, otherCandidateHours = 0;
 
         // 3) 每个员工:构造 managerGroups + otherRows,处理 processManagerApproval / processOtherApproval
         for (Map.Entry<String, List<Map>> e : perSubmitter.entrySet()) {
@@ -276,6 +307,11 @@ public class ReSubmitApprovalService {
                 }
                 // 3a) 项目工时:普通 PM 按人合单,Raymond 兜底按项目拆单
                 for (ManagerGroup mg : cr.managerGroups.values()) {
+                    billableCandidates += mg.billableRows.size() + mg.nonBillableRows.size();
+                    billableCandidateHours += mg.billableTotal + mg.nonBillableTotal;
+                    if (dryRun) {
+                        continue;
+                    }
                     try {
                         List<String> actions = processManagerApproval(mg, monthText, uid);
                         for (String action : actions) {
@@ -293,6 +329,13 @@ public class ReSubmitApprovalService {
                 }
                 // 3b) 其他工时:一人一张单,approver 用该员工部门主管(记录中带),否则兜底 Raymond
                 if (!cr.otherRows.isEmpty()) {
+                    otherCandidates += cr.otherRows.size();
+                    for (OtherRow row : cr.otherRows) {
+                        otherCandidateHours += row.hours;
+                    }
+                    if (dryRun) {
+                        continue;
+                    }
                     try {
                         List<String> actions = processOtherApproval(
                                 cr.otherRows, cr.deptManagerId, monthText, uid);
@@ -314,20 +357,76 @@ public class ReSubmitApprovalService {
             }
         }
 
+        // 4) 三档状态按汇总总额统一校正;覆盖无缺单但状态为空/错误的历史记录。
+        StatusSyncResult statusSync = reconcileSummaryApprovalStatuses(allRecords, dryRun);
+
         ReSubmitApprovalResult r = ReSubmitApprovalResult.builder()
                 .monthText(monthText)
                 .submitterCount(perSubmitter.size())
+                .dryRun(dryRun)
+                .billableCandidates(billableCandidates)
+                .billableCandidateHours(billableCandidateHours)
+                .otherCandidates(otherCandidates)
+                .otherCandidateHours(otherCandidateHours)
                 .billableCreated(billableCreated)
                 .billableUpdated(billableUpdated)
                 .otherCreated(otherCreated)
                 .otherUpdated(otherUpdated)
                 .skipped(skipped)
                 .failed(failed)
+                .summaryStatusCandidates(statusSync.candidates)
+                .summaryStatusUpdated(statusSync.updated)
+                .summaryStatusFailed(statusSync.failed)
                 .build();
         log.info("[补发起] 完成 {}", r);
         return r;
     }
 
+    /**
+     * 查询最近若干自然日发生修改的汇总记录,并按实际业务月份归集员工。
+     * <p>
+     * prd 定时兜底只跟踪增量修改,不按最近若干业务月份全量回扫;因此历史月份记录只要近期有变化也会命中。
+     *
+     * @param lookbackDays 最近自然日数量,最小按 1 天处理
+     * @return monthText -&gt; submitterUserId 集合
+     */
+    @SuppressWarnings("unchecked")
+    public Map<String, Set<String>> findRecentlyModifiedTargets(int lookbackDays) {
+        LocalDate today = LocalDate.now();
+        String[] window = modifiedDateWindow(lookbackDays, today);
+        List<Map> records = queryAllModified(
+                whConf.getFormUuidWorkHoursSummary(), window[0], window[1]);
+        Map<String, Set<String>> targets = new TreeMap<>();
+        for (Map record : records) {
+            Object rawFormData = record.get("formData");
+            if (!(rawFormData instanceof Map)) {
+                continue;
+            }
+            Map<String, Object> formData = (Map<String, Object>) rawFormData;
+            String monthText = str(formData, S_MONTH_TEXT);
+            String submitterUserId = str(formData, S_SUBMITTER_ID);
+            if (!monthText.matches("\\d{6}") || StringUtils.isBlank(submitterUserId)) {
+                continue;
+            }
+            targets.computeIfAbsent(monthText, key -> new LinkedHashSet<>()).add(submitterUserId);
+        }
+        log.info("[补发起兜底] 增量扫描 from={} to={} records={} months={} targets={}",
+                window[0], window[1], records.size(), targets.size(),
+                targets.values().stream().mapToInt(Set::size).sum());
+        return targets;
+    }
+
+    /**
+     * 计算最近 N 个自然日的宜搭修改时间查询窗口,结束日加 1 以覆盖当天全天。
+     */
+    static String[] modifiedDateWindow(int lookbackDays, LocalDate today) {
+        int safeDays = Math.max(1, lookbackDays);
+        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+        LocalDate fromDate = today.minusDays(safeDays - 1L);
+        LocalDate toDate = today.plusDays(1L);
+        return new String[]{fromDate.format(formatter), toDate.format(formatter)};
+    }
+
     /**
      * 使用真人 System 账号发起 TimeCard 工时审批。
      *
@@ -428,6 +527,84 @@ public class ReSubmitApprovalService {
         String deptManagerId;
     }
 
+    private static class StatusSyncResult {
+        int candidates;
+        int updated;
+        int failed;
+    }
+
+    /**
+     * 按主表提交/审批总额把状态统一校正为暂存、审批中或已审批。
+     */
+    @SuppressWarnings("unchecked")
+    private StatusSyncResult reconcileSummaryApprovalStatuses(List<Map> records,
+                                                               boolean dryRun) {
+        StatusSyncResult result = new StatusSyncResult();
+        String fieldId = whConf.getSummaryApprovalStatusField();
+        if (StringUtils.isBlank(fieldId)) {
+            return result;
+        }
+        for (Map record : records) {
+            Object rawFormData = record.get("formData");
+            if (!(rawFormData instanceof Map)) {
+                continue;
+            }
+            Map<String, Object> formData = (Map<String, Object>) rawFormData;
+            String targetStatus = expectedSummaryApprovalStatus(formData);
+            String currentStatus = str(formData, fieldId);
+            String formInstanceId = firstNonBlank(
+                    str(record, "formInstanceId"),
+                    str(record, "formInstId"));
+            if (StringUtils.isBlank(formInstanceId) || targetStatus.equals(currentStatus)) {
+                continue;
+            }
+            result.candidates++;
+            if (dryRun) {
+                continue;
+            }
+            try {
+                Map<String, Object> updateData = new HashMap<>();
+                updateData.put(fieldId, targetStatus);
+                ydClient.operateData(YDParam.builder()
+                        .appType(whConf.getYidaAppType())
+                        .systemToken(whConf.getYidaSystemToken())
+                        .formInstanceId(formInstanceId)
+                        .updateFormDataJson(JSON.toJSONString(updateData))
+                        .ignoreEmpty(false)
+                        .useLatestVersion(true)
+                        .build(), YDConf.FORM_OPERATION.update);
+                result.updated++;
+            } catch (Exception ex) {
+                result.failed++;
+                log.error("[补发起] 汇总三档状态校正失败 formInstanceId={} target={}",
+                        formInstanceId, targetStatus, ex);
+            }
+        }
+        return result;
+    }
+
+    /**
+     * 根据汇总主表三类已提交/已审批总额计算状态。
+     */
+    static String expectedSummaryApprovalStatus(Map<String, Object> formData) {
+        double submitted = num(formData, S_BIL_SUBMITTED_TOTAL)
+                + num(formData, S_NON_SUBMITTED_TOTAL)
+                + num(formData, S_OTHER_SUBMITTED_TOTAL);
+        double approved = num(formData, S_BIL_APPROVED_TOTAL)
+                + num(formData, S_NON_APPROVED_TOTAL)
+                + num(formData, S_OTHER_APPROVED_TOTAL);
+        if (submitted > approved + 1e-6) {
+            return SUMMARY_STATUS_IN_APPROVAL;
+        }
+        if (approved > 1e-6) {
+            return SUMMARY_STATUS_APPROVED;
+        }
+        if (submitted > 1e-6) {
+            return SUMMARY_STATUS_IN_APPROVAL;
+        }
+        return SUMMARY_STATUS_DRAFT;
+    }
+
     private CollectResult collectApprovalData(String uid,
                                               String monthText,
                                               List<Map> records,
@@ -462,7 +639,10 @@ public class ReSubmitApprovalService {
                     continue;
                 }
                 String projectCode = str(row, S_BIL_PROJECT_CODE);
-                String coverageKey = approvalKey("billable", uid, dayText, projectCode);
+                String projectName = firstNonBlank(
+                        str(row, S_BIL_PROJECT_NAME_TEXT), str(row, S_BIL_PROJECT_NAME));
+                String coverageKey = approvalKey("billable", uid, dayText,
+                        approvalDetailKey(projectCode, projectName));
                 if (!seenBillableKeys.add(coverageKey)
                         || approvalCoverage.shouldSkip(coverageKey, approved + change)) {
                     continue;
@@ -470,8 +650,6 @@ public class ReSubmitApprovalService {
                 String pmRaw = firstIdOrText(row, S_BIL_MANAGER);
                 final String pm = projectManagerCache.computeIfAbsent(
                         StringUtils.defaultString(pmRaw), this::resolveProjectManager);
-                String projectName = firstNonBlank(
-                        str(row, S_BIL_PROJECT_NAME_TEXT), str(row, S_BIL_PROJECT_NAME));
                 String groupKey = approvalGroupKey(pm, projectCode, projectName);
                 ManagerGroup mg = cr.managerGroups.computeIfAbsent(groupKey, k -> {
                     ManagerGroup m = new ManagerGroup();
@@ -502,7 +680,10 @@ public class ReSubmitApprovalService {
                     continue;
                 }
                 String projectCode = str(row, S_NON_PROJECT_CODE);
-                String coverageKey = approvalKey("nonBillable", uid, dayText, projectCode);
+                String projectName = firstNonBlank(
+                        str(row, S_NON_PROJECT_NAME_TEXT), str(row, S_NON_PROJECT_NAME));
+                String coverageKey = approvalKey("nonBillable", uid, dayText,
+                        approvalDetailKey(projectCode, projectName));
                 if (!seenNonBillableKeys.add(coverageKey)
                         || approvalCoverage.shouldSkip(coverageKey, approved + change)) {
                     continue;
@@ -510,8 +691,6 @@ public class ReSubmitApprovalService {
                 String pmRaw = firstIdOrText(row, S_NON_MANAGER);
                 final String pm = projectManagerCache.computeIfAbsent(
                         StringUtils.defaultString(pmRaw), this::resolveProjectManager);
-                String projectName = firstNonBlank(
-                        str(row, S_NON_PROJECT_NAME_TEXT), str(row, S_NON_PROJECT_NAME));
                 String groupKey = approvalGroupKey(pm, projectCode, projectName);
                 ManagerGroup mg = cr.managerGroups.computeIfAbsent(groupKey, k -> {
                     ManagerGroup m = new ManagerGroup();
@@ -542,7 +721,8 @@ public class ReSubmitApprovalService {
                     continue;
                 }
                 String activity = str(row, S_OTH_ACTIVITY);
-                String coverageKey = approvalKey("other", uid, dayText, activity);
+                String coverageKey = approvalKey("other", uid, dayText,
+                        approvalDetailKey(activity, ""));
                 if (!seenOtherKeys.add(coverageKey)
                         || approvalCoverage.shouldSkip(coverageKey, approved + change)) {
                     continue;
@@ -706,6 +886,7 @@ public class ReSubmitApprovalService {
                     O_SUB_SUBMITTER_UID,
                     O_SUB_DAY_TEXT,
                     O_SUB_ACTIVITY,
+                    null,
                     O_SUB_HOURS,
                     O_SUB_OPINION);
             return;
@@ -723,6 +904,7 @@ public class ReSubmitApprovalService {
                 A_BIL_SUBMITTER_UID,
                 A_BIL_DAY_TEXT,
                 A_BIL_PROJECT_CODE,
+                A_BIL_PROJECT_NAME,
                 A_BIL_HOURS,
                 A_BIL_OPINION);
         collectCoverageRows(
@@ -738,6 +920,7 @@ public class ReSubmitApprovalService {
                 A_NON_SUBMITTER_UID,
                 A_NON_DAY_TEXT,
                 A_NON_PROJECT_CODE,
+                A_NON_PROJECT_NAME,
                 A_NON_HOURS,
                 A_NON_OPINION);
     }
@@ -753,12 +936,15 @@ public class ReSubmitApprovalService {
                                      String uidField,
                                      String dayField,
                                      String detailKeyField,
+                                     String detailFallbackField,
                                      String hoursField,
                                      String opinionField) {
         for (Map row : rows) {
             String uid = str(row, uidField);
             String day = str(row, dayField);
-            String detailKey = str(row, detailKeyField);
+            String detailKey = approvalDetailKey(
+                    str(row, detailKeyField),
+                    detailFallbackField == null ? "" : str(row, detailFallbackField));
             if (StringUtils.isAnyBlank(uid, day, detailKey)) {
                 continue;
             }
@@ -837,6 +1023,20 @@ public class ReSubmitApprovalService {
         return category + "|" + uid + "|" + day + "|" + detailKey;
     }
 
+    /**
+     * 审批明细业务标识优先使用编号;历史数据编号为空时退回项目名称。
+     *
+     * @param primary 编号或活动类型
+     * @param fallback 项目名称兜底
+     * @return 带来源前缀的稳定业务标识
+     */
+    static String approvalDetailKey(String primary, String fallback) {
+        if (StringUtils.isNotBlank(primary)) {
+            return "id:" + primary;
+        }
+        return "name:" + StringUtils.defaultString(fallback);
+    }
+
     /**
      * processManagerApproval 等价 — queryProcess(RUNNING) 有则 update / 无则 create。
      * COMPLETED 覆盖已在 collectApprovalData 前过滤,避免历史已完成单被重复发起。
@@ -1578,6 +1778,36 @@ public class ReSubmitApprovalService {
         return all;
     }
 
+    /**
+     * 按宜搭 gmtModified 时间窗口分页查询表单实例。
+     */
+    @SuppressWarnings("unchecked")
+    private List<Map> queryAllModified(String formUuid, String fromDate, String toDate) {
+        List<Map> all = new ArrayList<>();
+        int page = 1;
+        while (page <= 200) {
+            DDR_New result = ydClient.queryData(YDParam.builder()
+                    .appType(whConf.getYidaAppType())
+                    .systemToken(whConf.getYidaSystemToken())
+                    .formUuid(formUuid)
+                    .modifiedFromTimeGMT(fromDate)
+                    .modifiedToTimeGMT(toDate)
+                    .currentPage(page)
+                    .pageSize(100)
+                    .build(), YDConf.FORM_QUERY.retrieve_search_form);
+            List<Map> data = result == null ? null : (List<Map>) result.getData();
+            if (data == null || data.isEmpty()) {
+                break;
+            }
+            all.addAll(data);
+            if (data.size() < 100) {
+                break;
+            }
+            page++;
+        }
+        return all;
+    }
+
     // ============================================================
     // 通用工具
     // ============================================================
@@ -1594,7 +1824,7 @@ public class ReSubmitApprovalService {
         return Collections.emptyList();
     }
 
-    private double num(Map m, String k) {
+    private static double num(Map m, String k) {
         Object v = m == null ? null : m.get(k);
         if (v == null || "".equals(v)) {
             return 0d;

+ 46 - 14
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/TimeCardSummaryService.java

@@ -15,6 +15,7 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -84,12 +85,17 @@ public class TimeCardSummaryService {
     public TimeCardDuplicateCleanupResult cleanupDuplicates(
             TimeCardDuplicateCleanupRequest request) {
         validateCleanupRequest(request);
+        Map<String, List<TimeCardDuplicateCleanupRequest.Target>> targetGroups =
+                groupCleanupTargets(request.getTargets());
         List<String> deleteIds = new ArrayList<>();
-        for (TimeCardDuplicateCleanupRequest.Target target : request.getTargets()) {
+        for (List<TimeCardDuplicateCleanupRequest.Target> targets : targetGroups.values()) {
+            TimeCardDuplicateCleanupRequest.Target target = targets.get(0);
             List<String> matchingIds = queryMatchingInstanceIds(
                     target.getUserId(), target.getDayText());
-            validateCleanupTarget(target, matchingIds);
-            deleteIds.add(target.getDeleteInstanceId());
+            validateCleanupTargets(targets, matchingIds);
+            for (TimeCardDuplicateCleanupRequest.Target item : targets) {
+                deleteIds.add(item.getDeleteInstanceId());
+            }
         }
 
         if (!request.isDryRun()) {
@@ -103,7 +109,7 @@ public class TimeCardSummaryService {
 
         int verified = request.isDryRun()
                 ? 0
-                : verifyKeepers(request.getTargets());
+                : verifyKeepers(targetGroups);
         return TimeCardDuplicateCleanupResult.builder()
                 .dryRun(request.isDryRun())
                 .requested(request.getTargets().size())
@@ -113,6 +119,16 @@ public class TimeCardSummaryService {
                 .build();
     }
 
+    private Map<String, List<TimeCardDuplicateCleanupRequest.Target>> groupCleanupTargets(
+            List<TimeCardDuplicateCleanupRequest.Target> targets) {
+        Map<String, List<TimeCardDuplicateCleanupRequest.Target>> groups = new LinkedHashMap<>();
+        for (TimeCardDuplicateCleanupRequest.Target target : targets) {
+            String key = target.getUserId() + "|" + target.getDayText();
+            groups.computeIfAbsent(key, ignored -> new ArrayList<>()).add(target);
+        }
+        return groups;
+    }
+
     private void validateCleanupRequest(TimeCardDuplicateCleanupRequest request) {
         McException.assertAccessException(request == null, "请求不能为空");
         List<TimeCardDuplicateCleanupRequest.Target> targets = request.getTargets();
@@ -178,20 +194,36 @@ public class TimeCardSummaryService {
 
     static void validateCleanupTarget(TimeCardDuplicateCleanupRequest.Target target,
                                       List<String> matchingIds) {
+        validateCleanupTargets(Collections.singletonList(target), matchingIds);
+    }
+
+    static void validateCleanupTargets(List<TimeCardDuplicateCleanupRequest.Target> targets,
+                                       List<String> matchingIds) {
+        TimeCardDuplicateCleanupRequest.Target first = targets.get(0);
+        String keepInstanceId = first.getKeepInstanceId();
+        Set<String> expectedIds = new HashSet<>();
+        expectedIds.add(keepInstanceId);
+        for (TimeCardDuplicateCleanupRequest.Target target : targets) {
+            McException.assertAccessException(
+                    !first.getUserId().equals(target.getUserId())
+                            || !first.getDayText().equals(target.getDayText())
+                            || !keepInstanceId.equals(target.getKeepInstanceId()),
+                    "同一重复组的员工、日期和保留实例必须一致");
+            expectedIds.add(target.getDeleteInstanceId());
+        }
         McException.assertAccessException(
-                matchingIds == null || matchingIds.size() != 2,
-                "目标唯一键当前不是恰好2条记录 userId="
-                        + target.getUserId() + ", day=" + target.getDayText());
-        McException.assertAccessException(
-                !matchingIds.contains(target.getDeleteInstanceId())
-                        || !matchingIds.contains(target.getKeepInstanceId()),
-                "目标实例与当前重复记录不一致 userId="
-                        + target.getUserId() + ", day=" + target.getDayText());
+                matchingIds == null
+                        || matchingIds.size() != expectedIds.size()
+                        || !new HashSet<>(matchingIds).equals(expectedIds),
+                "目标唯一键当前实例集合与清理清单不一致 userId="
+                        + first.getUserId() + ", day=" + first.getDayText());
     }
 
-    private int verifyKeepers(List<TimeCardDuplicateCleanupRequest.Target> targets) {
+    private int verifyKeepers(
+            Map<String, List<TimeCardDuplicateCleanupRequest.Target>> targetGroups) {
         int verified = 0;
-        for (TimeCardDuplicateCleanupRequest.Target target : targets) {
+        for (List<TimeCardDuplicateCleanupRequest.Target> targets : targetGroups.values()) {
+            TimeCardDuplicateCleanupRequest.Target target = targets.get(0);
             List<String> matchingIds = queryMatchingInstanceIds(
                     target.getUserId(), target.getDayText());
             McException.assertAccessException(

+ 25 - 32
mjava-akdsbeisen/src/main/java/com/malk/timer/ReSubmitApprovalTimer.java

@@ -1,6 +1,7 @@
 package com.malk.timer;
 
 import com.malk.server.workhours.ReSubmitApprovalResult;
+import com.malk.server.workhours.WHConf;
 import com.malk.service.workhours.ReSubmitApprovalService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -9,18 +10,15 @@ 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.Calendar;
-import java.util.Date;
-import java.util.LinkedHashSet;
+import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 /**
  * 补发起工时审批定时(bug 1 修复)
  * <p>
- * 每天凌晨 05:20 触发;先让 04:20 回写兜底消化已完成审批,再扫描汇总表状态="审批中"的记录,
- * 按 (userId, month) 分组,
+ * 每天凌晨 05:20 触发;先让 04:20 回写兜底消化已完成审批,再扫描最近 3 个自然日发生修改的汇总记录,
+ * 按实际业务 (month, userId) 去重,
  * 走 ReSubmitApprovalService.resubmit 逻辑:queryProcess(RUNNING) 有则 update / 无则 create。
  * <p>
  * COMPLETED 明细由覆盖索引拦截;若汇总尚未回写,留给 04:20 回写兜底处理。
@@ -40,6 +38,9 @@ public class ReSubmitApprovalTimer {
     @Autowired
     private ReSubmitApprovalService reSubmitApprovalService;
 
+    @Autowired
+    private WHConf whConf;
+
     private static final AtomicBoolean RUNNING = new AtomicBoolean(false);
 
     /**
@@ -52,21 +53,27 @@ public class ReSubmitApprovalTimer {
             return;
         }
         try {
-            // 近 3 天覆盖:当前月 + 上月月尾场景
-            Set<String> months = recentMonths(3);
-            log.info("[补发起兜底] 开始扫描 months={}", months);
+            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 (String m : months) {
-                try {
-                    ReSubmitApprovalResult r = reSubmitApprovalService.resubmit(m, null);
-                    totalCreated += (r.getBillableCreated() + r.getOtherCreated());
-                    totalUpdated += (r.getBillableUpdated() + r.getOtherUpdated());
-                    totalFailed += r.getFailed();
-                } catch (Exception ex) {
-                    log.error("[补发起兜底] 月度处理失败 month={}", m, ex);
+            for (Map.Entry<String, Set<String>> monthEntry : targets.entrySet()) {
+                for (String submitterUserId : monthEntry.getValue()) {
+                    try {
+                        ReSubmitApprovalResult result = reSubmitApprovalService.resubmit(
+                                monthEntry.getKey(), submitterUserId);
+                        totalCreated += result.getBillableCreated() + result.getOtherCreated();
+                        totalUpdated += result.getBillableUpdated() + result.getOtherUpdated();
+                        totalFailed += result.getFailed();
+                    } catch (Exception ex) {
+                        totalFailed++;
+                        log.error("[补发起兜底] 定向处理失败 month={} submitter={}",
+                                monthEntry.getKey(), submitterUserId, ex);
+                    }
                 }
             }
-            log.info("[补发起兜底] 完成 created={} updated={} failed={}", totalCreated, totalUpdated, totalFailed);
+            log.info("[补发起兜底] 完成 created={} updated={} failed={}",
+                    totalCreated, totalUpdated, totalFailed);
         } catch (Exception e) {
             log.error("[补发起兜底] 定时任务异常", e);
         } finally {
@@ -74,18 +81,4 @@ public class ReSubmitApprovalTimer {
         }
     }
 
-    /**
-     * 返回最近 N 天涉及的月份集合(yyyyMM)
-     * fixme 用 Set 而非 List 天然去重, 相邻月首/月尾同月只算一次
-     */
-    private Set<String> recentMonths(int days) {
-        Set<String> out = new LinkedHashSet<>();
-        SimpleDateFormat sdf = new SimpleDateFormat("yyyyMM");
-        Calendar cal = Calendar.getInstance();
-        for (int i = 0; i < days; i++) {
-            out.add(sdf.format(cal.getTime()));
-            cal.add(Calendar.DAY_OF_MONTH, -1);
-        }
-        return out;
-    }
 }

+ 1 - 0
mjava-akdsbeisen/src/main/resources/application-dev.yml

@@ -98,6 +98,7 @@ workhours:
   otherApprovalProcessCode: "TPROC--AO666S81..."     # 测试环境, 上线切正式
   approvalOriginatorUserId: "${AKDS_APPROVAL_ORIGINATOR_USER_ID:}"
   approvalOriginatorDeptId: "${AKDS_APPROVAL_ORIGINATOR_DEPT_ID:}"
+  approvalResubmitModifiedLookbackDays: 3
   summaryApprovalStatusField: "selectField_mre1xz9g"
 
 # 天眼查开放平台 (客户档案·工商信息回填, 2026-07-10)

+ 1 - 0
mjava-akdsbeisen/src/main/resources/application-prod.yml

@@ -79,6 +79,7 @@ workhours:
   otherApprovalProcessCode: "TPROC--14E66UA1Q2R38711L6SYK62QXEYR3574VAEMM9"
   approvalOriginatorUserId: "${AKDS_APPROVAL_ORIGINATOR_USER_ID:477737964}"
   approvalOriginatorDeptId: "${AKDS_APPROVAL_ORIGINATOR_DEPT_ID:1054043453}"
+  approvalResubmitModifiedLookbackDays: 3
   summaryApprovalStatusField: "selectField_mre1xz9g"
 
 # 天眼查开放平台 (客户档案·工商信息回填, 2026-07-10)

+ 45 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/ReSubmitApprovalModifiedWindowTest.java

@@ -0,0 +1,45 @@
+package com.malk.service.workhours;
+
+import org.junit.Test;
+
+import java.time.LocalDate;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+
+public class ReSubmitApprovalModifiedWindowTest {
+
+    @Test
+    public void shouldCoverLatestThreeCalendarDaysAndToday() {
+        assertArrayEquals(
+                new String[]{"2026-08-15", "2026-08-18"},
+                ReSubmitApprovalService.modifiedDateWindow(3, LocalDate.of(2026, 8, 17)));
+    }
+
+    @Test
+    public void shouldUseAtLeastOneCalendarDay() {
+        assertArrayEquals(
+                new String[]{"2026-08-17", "2026-08-18"},
+                ReSubmitApprovalService.modifiedDateWindow(0, LocalDate.of(2026, 8, 17)));
+    }
+
+    @Test
+    public void shouldDeriveThreeApprovalStatusesFromSummaryTotals() {
+        Map<String, Object> formData = new HashMap<>();
+        assertEquals("暂存", ReSubmitApprovalService.expectedSummaryApprovalStatus(formData));
+
+        formData.put("numberField_mmacxewa", 8);
+        assertEquals("审批中", ReSubmitApprovalService.expectedSummaryApprovalStatus(formData));
+
+        formData.put("numberField_mmacxewg", 8);
+        assertEquals("已审批", ReSubmitApprovalService.expectedSummaryApprovalStatus(formData));
+    }
+
+    @Test
+    public void shouldUseProjectNameWhenHistoricalProjectCodeIsBlank() {
+        assertEquals("id:P001", ReSubmitApprovalService.approvalDetailKey("P001", "项目一"));
+        assertEquals("name:历史项目", ReSubmitApprovalService.approvalDetailKey("", "历史项目"));
+    }
+}

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

@@ -13,6 +13,7 @@ import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
 import org.springframework.test.util.ReflectionTestUtils;
 
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.LinkedHashMap;
@@ -103,6 +104,25 @@ class TimeCardSummaryServiceTest {
                         target, Collections.singletonList("KEEP-ID")));
     }
 
+    @Test
+    void shouldValidateDuplicateGroupWithThreeDeleteTargets() {
+        TimeCardDuplicateCleanupRequest request = cleanupRequest(true);
+        request.setTargets(new ArrayList<>(request.getTargets()));
+        for (int index = 2; index <= 3; index++) {
+            TimeCardDuplicateCleanupRequest.Target target =
+                    new TimeCardDuplicateCleanupRequest.Target();
+            target.setUserId("user-1");
+            target.setDayText("20260507");
+            target.setDeleteInstanceId("DELETE-ID-" + index);
+            target.setKeepInstanceId("KEEP-ID");
+            request.getTargets().add(target);
+        }
+
+        TimeCardSummaryService.validateCleanupTargets(
+                request.getTargets(),
+                Arrays.asList("KEEP-ID", "DELETE-ID", "DELETE-ID-2", "DELETE-ID-3"));
+    }
+
     @Test
     void shouldDeleteValidatedTargetAndVerifyKeeper() {
         YDClient ydClient = mock(YDClient.class);