Просмотр исходного кода

fix(workhours): 完善审批兜底调度与封账保护

malk недель назад: 3
Родитель
Сommit
82365a96fe

+ 7 - 0
AGENTS.md

@@ -0,0 +1,7 @@
+# 阿科德斯项目规则
+
+## 异常统计口径
+
+- 林壹(userId:`022809001011844866`)和叶清(userId:`17663832762093930`)为开发、测试人员。
+- 涉及上述人员的数据不计入业务异常数量、异常工时、异常清单或客户同步内容。
+- 原始只读审计证据可以保留,但生成统计结果和对外材料时必须先按姓名及 userId 排除。

+ 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;
 }

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

@@ -67,6 +67,19 @@ public class MonthClosingService {
         return queryClosingStatus(monthText);
     }
 
+    /**
+     * 实时查询目标月份是否已封账;每次调用均回读宜搭,不缓存状态。
+     *
+     * @param monthText 月份,格式 yyyyMM
+     * @return true 表示最新已审批动作是封账
+     */
+    public boolean isClosed(String monthText) {
+        MonthClosingCheckRequest request = MonthClosingCheckRequest.builder()
+                .monthText(monthText)
+                .build();
+        return status(request).isClosed();
+    }
+
     /**
      * 校验目标月份是否允许发起封账。
      *

+ 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;

+ 4 - 2
mjava-akdsbeisen/src/main/java/com/malk/timer/ApprovalWriteBackTimer.java

@@ -22,7 +22,7 @@ import java.util.concurrent.atomic.AtomicInteger;
 /**
  * 审批回写兜底定时(bug 2 修复)
  * <p>
- * 每天凌晨 04:20 触发,扫描两类审批单 instanceStatus=COMPLETED、
+ * 每天 04:20、12:20 触发,扫描两类审批单 instanceStatus=COMPLETED、
  * 同步状态 selectField_mq58cd5p 为空的实例,主动调 ApprovalWriteBackService.writeBack 补齐工时汇总表回写。
  * <p>
  * 触发原因场景:
@@ -53,9 +53,11 @@ public class ApprovalWriteBackTimer {
     private static final AtomicBoolean RUNNING = new AtomicBoolean(false);
 
     /**
-     * 每天凌晨 04:20 兜底扫描审批单
+     * 每天 04:20、12:20 兜底扫描审批单。
+     * prd 中午回写先于 12:45 审批补发执行,先消化已完成审批,再检查剩余漏发记录。
      */
     @Scheduled(cron = "0 20 4 * * ?")
+    @Scheduled(cron = "0 20 12 * * ?")
     public void scanAndWriteBack() {
         if (!RUNNING.compareAndSet(false, true)) {
             log.warn("[审批回写兜底] 上次执行未结束,跳过本次触发");

+ 47 - 28
mjava-akdsbeisen/src/main/java/com/malk/timer/ReSubmitApprovalTimer.java

@@ -1,6 +1,8 @@
 package com.malk.timer;
 
 import com.malk.server.workhours.ReSubmitApprovalResult;
+import com.malk.server.workhours.WHConf;
+import com.malk.service.workhours.MonthClosingService;
 import com.malk.service.workhours.ReSubmitApprovalService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -9,21 +11,18 @@ 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、12:45 触发;先让 04:20、12:20 回写兜底消化已完成审批,再扫描最近 3 个自然日发生修改的汇总记录,
+ * 按实际业务 (month, userId) 去重,
  * 走 ReSubmitApprovalService.resubmit 逻辑:queryProcess(RUNNING) 有则 update / 无则 create。
  * <p>
- * COMPLETED 明细由覆盖索引拦截;若汇总尚未回写,留给 04:20 回写兜底处理。
+ * COMPLETED 明细由覆盖索引拦截;若汇总尚未回写,留给 04:20、12:20 回写兜底处理。
  * <p>
  * 触发原因场景:
  * 1. 前端 submitApprovalProcess 内 collectApprovalData 空跑(汇总表已 submitted 但 UI 状态未同步)
@@ -40,6 +39,12 @@ public class ReSubmitApprovalTimer {
     @Autowired
     private ReSubmitApprovalService reSubmitApprovalService;
 
+    @Autowired
+    private WHConf whConf;
+
+    @Autowired
+    private MonthClosingService monthClosingService;
+
     private static final AtomicBoolean RUNNING = new AtomicBoolean(false);
 
     /**
@@ -52,21 +57,40 @@ 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) {
+            for (Map.Entry<String, Set<String>> monthEntry : targets.entrySet()) {
+                String monthText = monthEntry.getKey();
                 try {
-                    ReSubmitApprovalResult r = reSubmitApprovalService.resubmit(m, null);
-                    totalCreated += (r.getBillableCreated() + r.getOtherCreated());
-                    totalUpdated += (r.getBillableUpdated() + r.getOtherUpdated());
-                    totalFailed += r.getFailed();
+                    if (monthClosingService.isClosed(monthText)) {
+                        log.info("[补发起兜底] 月份已封账,跳过本月全部目标 month={} submitters={}",
+                                monthText, monthEntry.getValue().size());
+                        continue;
+                    }
                 } catch (Exception ex) {
-                    log.error("[补发起兜底] 月度处理失败 month={}", m, ex);
+                    totalFailed += monthEntry.getValue().size();
+                    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();
+                        totalFailed += result.getFailed();
+                    } catch (Exception ex) {
+                        totalFailed++;
+                        log.error("[补发起兜底] 定向处理失败 month={} submitter={}",
+                                monthText, 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 {
@@ -75,17 +99,12 @@ public class ReSubmitApprovalTimer {
     }
 
     /**
-     * 返回最近 N 天涉及的月份集合(yyyyMM)
-     * fixme 用 Set 而非 List 天然去重, 相邻月首/月尾同月只算一次
+     * prd 每天中午 12:45 再执行一次补发兜底,缩短上午提交到日终封账之间的漏发窗口。
+     * 封账月份仍由 {@link #scanAndReSubmit()} 内的实时封账校验统一跳过。
      */
-    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;
+    @Scheduled(cron = "0 45 12 * * ?")
+    public void scanAndReSubmitAtNoon() {
+        scanAndReSubmit();
     }
+
 }

+ 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"
 
 # prd 月度封账流程:TimeCard 实时锁定 + 封账提交前审批完整性校验

+ 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"
 
 # prd 月度封账:TimeCard 实时锁定 + 封账/启封提交前校验

+ 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("", "历史项目"));
+    }
+}

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

@@ -0,0 +1,63 @@
+package com.malk.timer;
+
+import com.malk.server.workhours.ReSubmitApprovalResult;
+import com.malk.server.workhours.WHConf;
+import com.malk.service.workhours.MonthClosingService;
+import com.malk.service.workhours.ReSubmitApprovalService;
+import org.junit.jupiter.api.Test;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class ReSubmitApprovalTimerTest {
+
+    @Test
+    void shouldScheduleNoonFallbackAtTwelveFortyFive() throws NoSuchMethodException {
+        Method method = ReSubmitApprovalTimer.class.getDeclaredMethod("scanAndReSubmitAtNoon");
+        Scheduled scheduled = method.getAnnotation(Scheduled.class);
+
+        assertEquals("0 45 12 * * ?", scheduled.cron());
+    }
+
+    @Test
+    void shouldSkipAllTargetsWhenMonthIsClosed() {
+        ReSubmitApprovalService reSubmitService = mock(ReSubmitApprovalService.class);
+        MonthClosingService monthClosingService = mock(MonthClosingService.class);
+        WHConf whConf = new WHConf();
+        whConf.setApprovalResubmitModifiedLookbackDays(3);
+
+        Map<String, Set<String>> targets = new LinkedHashMap<>();
+        targets.put("202608", new LinkedHashSet<>(Arrays.asList("user-1", "user-2")));
+        targets.put("202609", new LinkedHashSet<>(Arrays.asList("user-3")));
+        when(reSubmitService.findRecentlyModifiedTargets(3)).thenReturn(targets);
+        when(monthClosingService.isClosed("202608")).thenReturn(true);
+        when(monthClosingService.isClosed("202609")).thenReturn(false);
+        when(reSubmitService.resubmit("202609", "user-3"))
+                .thenReturn(ReSubmitApprovalResult.builder().build());
+
+        ReSubmitApprovalTimer timer = new ReSubmitApprovalTimer();
+        ReflectionTestUtils.setField(timer, "reSubmitApprovalService", reSubmitService);
+        ReflectionTestUtils.setField(timer, "monthClosingService", monthClosingService);
+        ReflectionTestUtils.setField(timer, "whConf", whConf);
+
+        timer.scanAndReSubmit();
+
+        verify(monthClosingService).isClosed("202608");
+        verify(monthClosingService).isClosed("202609");
+        verify(reSubmitService, never()).resubmit("202608", "user-1");
+        verify(reSubmitService, never()).resubmit("202608", "user-2");
+        verify(reSubmitService).resubmit("202609", "user-3");
+    }
+}

+ 3 - 1
mjava-akdsbeisen/src/test/java/com/malk/timer/WorkHoursTimerScheduleTest.java

@@ -55,9 +55,11 @@ public class WorkHoursTimerScheduleTest {
     public void approvalWriteBackShouldRunBeforeApprovalResubmit() throws Exception {
         Method writeBack = ApprovalWriteBackTimer.class.getMethod("scanAndWriteBack");
         Method resubmit = ReSubmitApprovalTimer.class.getMethod("scanAndReSubmit");
+        Method noonResubmit = ReSubmitApprovalTimer.class.getMethod("scanAndReSubmitAtNoon");
 
-        assertEquals(setOf("0 20 4 * * ?"), scheduledCrons(writeBack));
+        assertEquals(setOf("0 20 4 * * ?", "0 20 12 * * ?"), scheduledCrons(writeBack));
         assertEquals(setOf("0 20 5 * * ?"), scheduledCrons(resubmit));
+        assertEquals(setOf("0 45 12 * * ?"), scheduledCrons(noonResubmit));
     }
 
     @Test