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

fix(timecard): 修复审批回写与工时汇总异常

malk месяцев назад: 2
Родитель
Сommit
3c87d2a1d7
25 измененных файлов с 2604 добавлено и 42 удалено
  1. 51 2
      mjava-akdsbeisen/src/main/java/com/malk/controller/ApprovalController.java
  2. 46 0
      mjava-akdsbeisen/src/main/java/com/malk/controller/TimeCardController.java
  3. 62 0
      mjava-akdsbeisen/src/main/java/com/malk/server/workhours/ReSubmitApprovalResult.java
  4. 18 0
      mjava-akdsbeisen/src/main/java/com/malk/server/workhours/TimeCardApprovalStartRequest.java
  5. 34 0
      mjava-akdsbeisen/src/main/java/com/malk/server/workhours/TimeCardDuplicateCleanupRequest.java
  6. 22 0
      mjava-akdsbeisen/src/main/java/com/malk/server/workhours/TimeCardDuplicateCleanupResult.java
  7. 27 0
      mjava-akdsbeisen/src/main/java/com/malk/server/workhours/TimeCardSummaryUpsertRequest.java
  8. 2 4
      mjava-akdsbeisen/src/main/java/com/malk/server/workhours/WHConf.java
  9. 212 26
      mjava-akdsbeisen/src/main/java/com/malk/service/workhours/ApprovalWriteBackService.java
  10. 1213 0
      mjava-akdsbeisen/src/main/java/com/malk/service/workhours/ReSubmitApprovalService.java
  11. 206 0
      mjava-akdsbeisen/src/main/java/com/malk/service/workhours/TimeCardSummaryService.java
  12. 1 0
      mjava-akdsbeisen/src/main/java/com/malk/service/workhours/WorkHoursCalcService.java
  13. 140 0
      mjava-akdsbeisen/src/main/java/com/malk/timer/ApprovalWriteBackTimer.java
  14. 91 0
      mjava-akdsbeisen/src/main/java/com/malk/timer/ReSubmitApprovalTimer.java
  15. 10 1
      mjava-akdsbeisen/src/main/resources/application-dev.yml
  16. 10 4
      mjava-akdsbeisen/src/main/resources/application-prod.yml
  17. 87 0
      mjava-akdsbeisen/src/test/java/com/malk/service/workhours/ApprovalWriteBackServiceTest.java
  18. 81 0
      mjava-akdsbeisen/src/test/java/com/malk/service/workhours/ReSubmitApprovalServiceTest.java
  19. 168 0
      mjava-akdsbeisen/src/test/java/com/malk/service/workhours/TimeCardSummaryServiceTest.java
  20. 23 0
      mjava-akdsbeisen/src/test/java/com/malk/timer/WorkHoursTimerScheduleTest.java
  21. 2 1
      mjava/src/main/java/com/malk/server/aliwork/YDConf.java
  22. 3 0
      mjava/src/main/java/com/malk/server/aliwork/YDParam.java
  23. 10 1
      mjava/src/main/java/com/malk/service/aliwork/impl/YDClientImpl.java
  24. 12 3
      mjava/src/main/java/com/malk/service/aliwork/impl/YDServiceImpl.java
  25. 73 0
      mjava/src/test/java/com/malk/service/aliwork/impl/YDServiceImplTest.java

+ 51 - 2
mjava-akdsbeisen/src/main/java/com/malk/controller/ApprovalController.java

@@ -2,7 +2,10 @@ package com.malk.controller;
 
 import com.malk.server.common.McR;
 import com.malk.server.workhours.ApprovalWriteBackResult;
+import com.malk.server.workhours.ReSubmitApprovalResult;
+import com.malk.server.workhours.TimeCardApprovalStartRequest;
 import com.malk.service.workhours.ApprovalWriteBackService;
+import com.malk.service.workhours.ReSubmitApprovalService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
@@ -21,6 +24,9 @@ public class ApprovalController {
     @Autowired
     private ApprovalWriteBackService approvalWriteBackService;
 
+    @Autowired
+    private ReSubmitApprovalService reSubmitApprovalService;
+
     /**
      * 审批结果回写工时汇总表
      * GET /approval/writeback?instanceId=xxx&result=1
@@ -29,10 +35,53 @@ public class ApprovalController {
      * @param result     审批结果:0=拒绝/撤销,1=同意
      */
     @PostMapping("/writeback")
-    public McR<ApprovalWriteBackResult> writeback(@RequestParam String instanceId,
-                                                  @RequestParam int result) {
+    public McR<Boolean> writeback(@RequestParam String instanceId,
+                                  @RequestParam int result) {
 
         log.info("writeback, {}, {}", instanceId, result);
+        approvalWriteBackService.writeBackAsync(instanceId, result);
+        return McR.success(true);
+    }
+
+    /**
+     * 同步执行单张历史审批回写,供受控数据修复使用。
+     *
+     * @param instanceId 审批表单实例 ID
+     * @param result     审批结果:0=拒绝/撤销,1=同意
+     * @return 实际命中和更新统计
+     */
+    @PostMapping("/writeback/sync")
+    public McR<ApprovalWriteBackResult> writebackSync(@RequestParam String instanceId,
+                                                       @RequestParam int result) {
+        log.info("writebackSync, {}, {}", instanceId, result);
         return McR.success(approvalWriteBackService.writeBack(instanceId, result));
     }
+
+    /**
+     * 补发起工时审批(bug 1 手工触发)
+     * POST /approval/resubmit?monthText=202606[&submitterUserId=xxx]
+     * <p>
+     * 用于:①第一次全量补齐历史数据 ②Scheduler 上线前手工回归验证
+     * 逻辑参考前端 submitApprovalProcess:按 PM 分组 → queryProcess(RUNNING) → update / create
+     *
+     * @param monthText       yyyyMM
+     * @param submitterUserId 可空; 指定则仅处理该员工, 空则该月全员
+     */
+    @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));
+    }
+
+    /**
+     * 使用配置的真人 System 账号发起 TimeCard 审批。
+     *
+     * @param request 审批表单、流程编码、部门和表单数据
+     * @return 发起结果
+     */
+    @PostMapping("/start")
+    public McR<Boolean> start(@RequestBody TimeCardApprovalStartRequest request) {
+        return McR.success(reSubmitApprovalService.startApproval(request));
+    }
 }

+ 46 - 0
mjava-akdsbeisen/src/main/java/com/malk/controller/TimeCardController.java

@@ -0,0 +1,46 @@
+package com.malk.controller;
+
+import com.malk.server.common.McR;
+import com.malk.server.workhours.TimeCardDuplicateCleanupRequest;
+import com.malk.server.workhours.TimeCardDuplicateCleanupResult;
+import com.malk.server.workhours.TimeCardSummaryUpsertRequest;
+import com.malk.service.workhours.TimeCardSummaryService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * TimeCard 工时汇总接口。
+ */
+@RestController
+@RequestMapping("/timecard")
+public class TimeCardController {
+
+    @Autowired
+    private TimeCardSummaryService timeCardSummaryService;
+
+    /**
+     * 按填报人 ID + 日期原子写入单日工时汇总。
+     *
+     * @param request 单日汇总数据
+     * @return 宜搭写入结果
+     */
+    @PostMapping("/summary/upsert")
+    public McR<Boolean> upsertSummary(@RequestBody TimeCardSummaryUpsertRequest request) {
+        return McR.success(timeCardSummaryService.upsert(request));
+    }
+
+    /**
+     * 按显式清单受控清理重复汇总记录,默认只校验不删除。
+     *
+     * @param request 重复记录清理请求
+     * @return 校验、删除和复查统计
+     */
+    @PostMapping("/summary/cleanup-duplicates")
+    public McR<TimeCardDuplicateCleanupResult> cleanupDuplicates(
+            @RequestBody TimeCardDuplicateCleanupRequest request) {
+        return McR.success(timeCardSummaryService.cleanupDuplicates(request));
+    }
+}

+ 62 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/workhours/ReSubmitApprovalResult.java

@@ -0,0 +1,62 @@
+package com.malk.server.workhours;
+
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * 补发起工时审批结果统计
+ * <p>
+ * 覆盖两类补发起:
+ * - 工时审批 (Billable + Non Billable, 按 PM 分组): 每个 PM 一张单
+ * - 其他工时审批 (Other, 按发起人的部门主管兜底): 一人一张单
+ * <p>
+ * 设计文档: 后端/阿科德斯/审批结果回写工时汇总表/02-程序实现与接口.md
+ */
+@Data
+@Builder
+public class ReSubmitApprovalResult {
+
+    /**
+     * 目标月份 yyyyMM
+     */
+    private String monthText;
+
+    /**
+     * 扫描员工数(指定月份查询到的独立填报人)
+     */
+    private int submitterCount;
+
+    /**
+     * 工时审批 create(没有 RUNNING 或 COMPLETED 覆盖时新建)
+     */
+    private int billableCreated;
+
+    /**
+     * 工时审批 update(查到 RUNNING 单 → 合并明细并累加主表工时)
+     */
+    private int billableUpdated;
+
+    /**
+     * 其他工时审批 create
+     */
+    private int otherCreated;
+
+    /**
+     * 其他工时审批 update
+     */
+    private int otherUpdated;
+
+    /**
+     * 跳过数(无待发起 delta)
+     */
+    private int skipped;
+
+    /**
+     * 失败数(startInstance / updateFormInstance 抛错)
+     */
+    private int failed;
+
+    /**
+     * 汇总表状态字段回写: 已发起(补发起成功后, 汇总表状态仍为"审批中", 无需改档)
+     */
+}

+ 18 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/workhours/TimeCardApprovalStartRequest.java

@@ -0,0 +1,18 @@
+package com.malk.server.workhours;
+
+import lombok.Data;
+
+/**
+ * TimeCard 发起审批请求。
+ */
+@Data
+public class TimeCardApprovalStartRequest {
+
+    private String formUuid;
+
+    private String processCode;
+
+    private String deptId;
+
+    private String formDataJson;
+}

+ 34 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/workhours/TimeCardDuplicateCleanupRequest.java

@@ -0,0 +1,34 @@
+package com.malk.server.workhours;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * TimeCard 汇总重复记录受控清理请求。
+ */
+@Data
+public class TimeCardDuplicateCleanupRequest {
+
+    /**
+     * 默认仅校验,不删除。
+     */
+    private boolean dryRun = true;
+
+    /**
+     * 单批最多 30 条,符合宜搭批量数据操作约束。
+     */
+    private List<Target> targets;
+
+    @Data
+    public static class Target {
+
+        private String userId;
+
+        private String dayText;
+
+        private String deleteInstanceId;
+
+        private String keepInstanceId;
+    }
+}

+ 22 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/workhours/TimeCardDuplicateCleanupResult.java

@@ -0,0 +1,22 @@
+package com.malk.server.workhours;
+
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * TimeCard 汇总重复记录受控清理结果。
+ */
+@Data
+@Builder
+public class TimeCardDuplicateCleanupResult {
+
+    private boolean dryRun;
+
+    private int requested;
+
+    private int validated;
+
+    private int deleted;
+
+    private int verified;
+}

+ 27 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/workhours/TimeCardSummaryUpsertRequest.java

@@ -0,0 +1,27 @@
+package com.malk.server.workhours;
+
+import lombok.Data;
+
+import java.util.Map;
+
+/**
+ * TimeCard 工时汇总单日原子 upsert 请求。
+ */
+@Data
+public class TimeCardSummaryUpsertRequest {
+
+    /**
+     * 填报人钉钉 userId。
+     */
+    private String submitterUserId;
+
+    /**
+     * 工时日期文本,格式 yyyyMMdd。
+     */
+    private String dayText;
+
+    /**
+     * 工时汇总表完整写入数据。
+     */
+    private Map<String, Object> formData;
+}

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

@@ -59,12 +59,10 @@ public class WHConf {
     // 其他工时审批流程 processCode
     private String otherApprovalProcessCode;
 
-    // 补发起 startInstance 的发起人 userId
-    // fixme 前端 v2.35 已确认: yida_pub_account 虚拟账号不触发钉钉待办通知(审批人收不到红点),
-    //       必须改用真人钉钉账号 userId(建议命名 System); 查询/更新类操作仍可用 pub_account
+    // 补发起 startInstance 的真人发起人 userId;yida_pub_account 不触发钉钉待办
     private String approvalOriginatorUserId;
 
-    // 补发起 deptId(可空)
+    // 补发起发起人部门 ID(可空)
     private String approvalOriginatorDeptId;
 
     // 汇总表: 审批状态字段(SelectField, 3 档 "暂存/审批中/已审批")

+ 212 - 26
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/ApprovalWriteBackService.java

@@ -5,6 +5,7 @@ import com.google.common.util.concurrent.RateLimiter;
 import com.malk.server.aliwork.YDConf;
 import com.malk.server.aliwork.YDParam;
 import com.malk.server.common.McException;
+import com.malk.server.dingtalk.DDR_New;
 import com.malk.server.workhours.ApprovalWriteBackResult;
 import com.malk.server.workhours.WHConf;
 import com.malk.service.aliwork.YDClient;
@@ -30,7 +31,7 @@ import java.util.concurrent.atomic.AtomicInteger;
  * - 同意 → 对应子表行「已审批工时」= 该明细工时
  * 主表合计按子表行重算(幂等,撤销/重发不翻倍)。
  * <p>
- * 设计文档:后端/阿科德斯/审批结果回写工时汇总表.md
+ * 设计文档:后端/阿科德斯/审批结果回写工时汇总表/02-程序实现与接口.md
  */
 @Slf4j
 @Service
@@ -119,6 +120,17 @@ public class ApprovalWriteBackService {
         boolean approve;  // 有效意见:true=同意, false=拒绝
     }
 
+    /**
+     * 异步触发审批回写,供流程结束自动化调用。
+     *
+     * @param formInstanceId 审批表单实例 ID
+     * @param result         审批结果:0=拒绝/撤销,1=同意
+     */
+    @Async
+    public void writeBackAsync(String formInstanceId, int result) {
+        writeBack(formInstanceId, result);
+    }
+
     /**
      * 审批结果回写工时汇总表
      *
@@ -126,32 +138,33 @@ public class ApprovalWriteBackService {
      * @param result            审批结果:0=拒绝/撤销,1=同意
      * @return 回写结果统计
      */
-    @Async
     public ApprovalWriteBackResult writeBack(String formInstanceId, int result) {
         McException.assertAccessException(StringUtils.isBlank(formInstanceId), "实例ID不能为空");
 
-        // 1. 按实例ID查审批单表单详情(表单接口 retrieve_id),含主表 + 内联子表(子表内联上限 50)
-        Map approvalFormData = ydClient.queryData(YDParam.builder()
-                .appType(whConf.getYidaAppType())
-                .systemToken(whConf.getYidaSystemToken())
-                .formInstanceId(formInstanceId)
-                .build(), YDConf.FORM_QUERY.retrieve_id).getFormData();
+        // 1. 流程详情接口优先,流程表单可直接返回完整子表;旧回调传表单实例ID时回退表单详情接口。
+        Map approvalFormData = queryApprovalFormData(formInstanceId);
         log.info("[审批回写] 审批单详情 formInstanceId={} keys={}", formInstanceId,
                 approvalFormData == null ? null : approvalFormData.keySet());
         McException.assertAccessException(approvalFormData == null || approvalFormData.isEmpty(),
                 "未查询到审批单数据,实例ID=" + formInstanceId);
 
-        // 2. 类型判定:工时审批含「项目工时明细」子表 tableField_mmae8t99,其他工时审批没有
-        boolean isOther = !approvalFormData.containsKey(AP_BIL_TABLE);
+        // 2. 类型判定:不能只看项目工时子表;仅填非项目工时的工时审批也可能没有 AP_BIL_TABLE。
+        boolean isOther = isOtherApprovalForm(approvalFormData);
+        String approvalFormUuid = isOther
+                ? whConf.getFormUuidOtherApproval()
+                : whConf.getFormUuidApproval();
         boolean approveWhole = (result == 1);
 
         // 3. 取审批单明细(内联子表 <50 直接用,==50 才 queryDetails 递归取全,避免无效请求)
         List<DetailRow> rows = new ArrayList<>();
         if (isOther) {
-            rows.addAll(loadDetailRows(formInstanceId, approvalFormData, Cat.OTHER, approveWhole));
+            rows.addAll(loadDetailRows(
+                    formInstanceId, approvalFormUuid, approvalFormData, Cat.OTHER, approveWhole));
         } else {
-            rows.addAll(loadDetailRows(formInstanceId, approvalFormData, Cat.BILLABLE, approveWhole));
-            rows.addAll(loadDetailRows(formInstanceId, approvalFormData, Cat.NON_BILLABLE, approveWhole));
+            rows.addAll(loadDetailRows(
+                    formInstanceId, approvalFormUuid, approvalFormData, Cat.BILLABLE, approveWhole));
+            rows.addAll(loadDetailRows(
+                    formInstanceId, approvalFormUuid, approvalFormData, Cat.NON_BILLABLE, approveWhole));
         }
 
         // 4. 按汇总表记录键(填报人 + 工时日期文本)分组
@@ -182,12 +195,16 @@ public class ApprovalWriteBackService {
                         try {
                             int[] r = applyToRecord(userId, dayText, groupRows, limiter);
                             if (r == null) {
-                                // 未命中汇总表记录
-                                break;
+                                // fixme 审批明细存在但汇总表日记录缺失属于失败,不能把审批单标记为「全部成功」。
+                                failRecords.incrementAndGet();
+                                return;
                             }
                             hitRecords.incrementAndGet();
                             updatedRows.addAndGet(r[0]);
                             missRows.addAndGet(r[1]);
+                            if (r[1] > 0) {
+                                failRecords.incrementAndGet();
+                            }
                             return;
                         } catch (Exception ex) {
                             if (retry < MAX_RETRY) {
@@ -241,6 +258,113 @@ public class ApprovalWriteBackService {
         return stats;
     }
 
+    private Map queryApprovalFormData(String instanceId) {
+        try {
+            DDR_New processDetail = ydClient.queryData(YDParam.builder()
+                    .appType(whConf.getYidaAppType())
+                    .systemToken(whConf.getYidaSystemToken())
+                    .processInstanceId(instanceId)
+                    .build(), YDConf.FORM_QUERY.retrieve_search_process_id);
+            Map processFormData = extractProcessFormData(processDetail);
+            if (processFormData != null && !processFormData.isEmpty()) {
+                return processFormData;
+            }
+        } catch (Exception ex) {
+            log.warn("[审批回写] 流程详情查询失败,回退表单详情 instanceId={}", instanceId, ex);
+        }
+        try {
+            DDR_New legacyProcessDetail = ydClient.queryData(YDParam.builder()
+                    .appType(whConf.getYidaAppType())
+                    .systemToken(whConf.getYidaSystemToken())
+                    .processInstanceId(instanceId)
+                    .build(), YDConf.FORM_QUERY.retrieve_process_legacy_id);
+            Map legacyFormData = extractProcessFormData(legacyProcessDetail);
+            if (legacyFormData != null && !legacyFormData.isEmpty()) {
+                return legacyFormData;
+            }
+        } catch (Exception ex) {
+            log.warn("[审批回写] 旧版流程详情查询失败,回退表单详情 instanceId={}", instanceId, ex);
+        }
+        DDR_New formDetail = ydClient.queryData(YDParam.builder()
+                .appType(whConf.getYidaAppType())
+                .systemToken(whConf.getYidaSystemToken())
+                .formInstanceId(instanceId)
+                .build(), YDConf.FORM_QUERY.retrieve_id);
+        return formDetail == null ? null : formDetail.getFormData();
+    }
+
+    @SuppressWarnings("unchecked")
+    static Map extractProcessFormData(DDR_New processDetail) {
+        if (processDetail == null) {
+            return null;
+        }
+        if (processDetail.getFormData() != null && !processDetail.getFormData().isEmpty()) {
+            return processDetail.getFormData();
+        }
+        if (processDetail.getResult() instanceof Map) {
+            Map result = (Map) processDetail.getResult();
+            Object nested = result.get("formData");
+            if (!(nested instanceof Map)) {
+                nested = result.get("data");
+            }
+            if (nested instanceof Map) {
+                return (Map) nested;
+            }
+            if (result.containsKey(AP_BIL_TABLE)
+                    || result.containsKey(AP_NON_TABLE)
+                    || result.containsKey(AP_OTH_TABLE)) {
+                return result;
+            }
+        }
+        if (!(processDetail.getData() instanceof Map)) {
+            return null;
+        }
+        Map data = (Map) processDetail.getData();
+        Object nested = data.get("formData");
+        if (!(nested instanceof Map)) {
+            nested = data.get("data");
+        }
+        if (nested instanceof Map) {
+            return (Map) nested;
+        }
+        if (data.containsKey(AP_BIL_TABLE)
+                || data.containsKey(AP_NON_TABLE)
+                || data.containsKey(AP_OTH_TABLE)) {
+            return data;
+        }
+        return null;
+    }
+
+    /**
+     * 区分「工时审批」与「其他工时审批」。
+     * 两者共用 tableField_mmd1wu9h,但明细键分别为项目编号和 Activity。
+     */
+    @SuppressWarnings("unchecked")
+    static boolean isOtherApprovalForm(Map approvalFormData) {
+        if (approvalFormData == null) {
+            return false;
+        }
+        if (approvalFormData.containsKey(AP_BIL_TABLE)) {
+            return false;
+        }
+        Object sharedRows = approvalFormData.get(AP_NON_TABLE);
+        if (sharedRows instanceof List) {
+            for (Object item : (List<?>) sharedRows) {
+                if (!(item instanceof Map)) {
+                    continue;
+                }
+                Map row = (Map) item;
+                if (row.containsKey(AP_NON_KEY)) {
+                    return false;
+                }
+                if (row.containsKey(AP_OTH_KEY)) {
+                    return true;
+                }
+            }
+        }
+        return true;
+    }
+
     /**
      * 把同步状态/同步总数/失败数量回写到原审批单(fieldId 走 WHConf 配置)
      * fixme 三个 fieldId 任一为空则跳过该类别回写,允许业务方分阶段在宜搭后台建字段
@@ -290,16 +414,25 @@ public class ApprovalWriteBackService {
                         SUMMARY_SUBMITTER_ID + ", " + SUMMARY_DAY_TEXT, userId, dayText)))
                 .build(), YDConf.FORM_QUERY.retrieve_search_form).getData();
 
-        String summaryInstId = null;
-        for (Map item : found) {
+        List<String> matchingIds = new ArrayList<>();
+        for (Map item : found == null ? Collections.<Map>emptyList() : found) {
             Map fd = (Map) item.get("formData");
             if (fd == null) continue;
             if (userId.equals(String.valueOf(fd.get(SUMMARY_SUBMITTER_ID)))
                     && dayText.equals(String.valueOf(fd.get(SUMMARY_DAY_TEXT)))) {
-                summaryInstId = String.valueOf(item.get("formInstanceId"));
-                break;
+                Object id = item.get("formInstanceId");
+                if (id == null) {
+                    id = item.get("formInstId");
+                }
+                if (id != null && StringUtils.isNotBlank(String.valueOf(id))) {
+                    matchingIds.add(String.valueOf(id));
+                }
             }
         }
+        McException.assertAccessException(
+                matchingIds.size() > 1,
+                "工时汇总表唯一键重复,拒绝回写 userId=" + userId + ", day=" + dayText);
+        String summaryInstId = matchingIds.isEmpty() ? null : matchingIds.get(0);
         if (StringUtils.isBlank(summaryInstId)) {
             log.warn("[审批回写] 未命中汇总表记录 userId={} day={}", userId, dayText);
             return null;
@@ -337,7 +470,11 @@ public class ApprovalWriteBackService {
 
             // 子表取数控制:内联 <50 直接用,==50 才递归取全,避免全量覆盖时漏行被删
             List<Map> rebuilt = rebuildRows(
-                    resolveDetailRows(summaryInstId, subTable, (List<Map>) formData.get(subTable)));
+                    resolveDetailRows(
+                            summaryInstId,
+                            whConf.getFormUuidWorkHoursSummary(),
+                            subTable,
+                            (List<Map>) formData.get(subTable)));
 
             for (DetailRow dr : e.getValue()) {
                 Map target = null;
@@ -388,6 +525,15 @@ public class ApprovalWriteBackService {
         updateData.put(M_OTH_APPROVED, othApproved);
         updateData.put(M_DAY_SUBMITTED, bilSubmitted + nonSubmitted + othSubmitted);
         updateData.put(M_DAY_APPROVED, bilApproved + nonApproved + othApproved);
+        // prd 审批回写完成后同步记录级审批状态,避免数字已回写但汇总状态仍停留在“审批中”
+        String approvalStatusField = whConf.getSummaryApprovalStatusField();
+        if (StringUtils.isNotBlank(approvalStatusField)) {
+            updateData.put(
+                    approvalStatusField,
+                    resolveSummaryApprovalStatus(
+                            bilSubmitted + nonSubmitted + othSubmitted,
+                            bilApproved + nonApproved + othApproved));
+        }
 
         // e. 全量覆盖更新(子表整组替换 + 主表合计)
         limiter.acquire();
@@ -403,12 +549,40 @@ public class ApprovalWriteBackService {
         return new int[]{updated, miss};
     }
 
+    /**
+     * 计算工时汇总记录的审批状态。
+     *
+     * @param submitted 已提交合计
+     * @param approved  已审批合计
+     * @return 已审批 / 审批中 / 暂存
+     */
+    static String resolveSummaryApprovalStatus(double submitted, double approved) {
+        if (submitted > approved + 1e-6) {
+            return "审批中";
+        }
+        if (approved > 0) {
+            return "已审批";
+        }
+        if (submitted > 0) {
+            return "审批中";
+        }
+        return "暂存";
+    }
+
     /**
      * 加载审批单某类别明细(内联子表优先,必要时递归取全)
      */
-    private List<DetailRow> loadDetailRows(String formInstanceId, Map approvalFormData, Cat cat, boolean approveWhole) {
+    private List<DetailRow> loadDetailRows(String formInstanceId,
+                                           String formUuid,
+                                           Map approvalFormData,
+                                           Cat cat,
+                                           boolean approveWhole) {
         String tableId = apTable(cat);
-        List<Map> raw = resolveDetailRows(formInstanceId, tableId, (List<Map>) approvalFormData.get(tableId));
+        List<Map> raw = resolveDetailRows(
+                formInstanceId,
+                formUuid,
+                tableId,
+                (List<Map>) approvalFormData.get(tableId));
 
         List<DetailRow> list = new ArrayList<>();
         for (Map row : raw) {
@@ -419,13 +593,17 @@ public class ApprovalWriteBackService {
             dr.key = str(row, apKey(cat));
             dr.hours = num(row, apHours(cat));
             String opinion = str(row, apOpinion(cat));
-            // 有效意见:整单拒绝/撤销 → 全拒绝;整单同意 → 看单条明细意见
-            dr.approve = approveWhole && OPINION_APPROVE.equals(opinion);
+            // fixme 历史审批单可能没有逐行意见:整单同意时空意见按同意处理,仅显式非同意值按拒绝处理。
+            dr.approve = isDetailApproved(approveWhole, opinion);
             list.add(dr);
         }
         return list;
     }
 
+    static boolean isDetailApproved(boolean approveWhole, String opinion) {
+        return approveWhole && (StringUtils.isBlank(opinion) || OPINION_APPROVE.equals(opinion));
+    }
+
     /**
      * 子表取数控制:主表详情已内联返回子表时,行数 &lt;50 视为完整直接用;
      * ==50(可能被宜搭截断)才调 {@link YDService#queryDetails} 递归取全,避免无效请求。
@@ -435,7 +613,10 @@ public class ApprovalWriteBackService {
      * 直接返回空 list,不再调 queryDetails——基座 _queryDetails 对宜搭返回 data=null 的空子表查询会触发
      * ArrayList.addAll(null) NPE。queryDetails 返回 null 同样兜底空 list。
      */
-    private List<Map> resolveDetailRows(String formInstanceId, String tableId, List<Map> inlineRows) {
+    private List<Map> resolveDetailRows(String formInstanceId,
+                                        String formUuid,
+                                        String tableId,
+                                        List<Map> inlineRows) {
         if (inlineRows == null) {
             return Collections.emptyList();
         }
@@ -445,11 +626,16 @@ public class ApprovalWriteBackService {
         List<Map> full = ydService.queryDetails(YDParam.builder()
                 .appType(whConf.getYidaAppType())
                 .systemToken(whConf.getYidaSystemToken())
+                .formUuid(formUuid)
                 .formInstanceId(formInstanceId)
                 .tableFieldId(tableId)
                 .pageNumber(1)
                 .build());
-        return full != null ? full : Collections.emptyList();
+        McException.assertAccessException(
+                full == null || full.size() < inlineRows.size(),
+                "审批单子表完整数据获取失败 instanceId=" + formInstanceId
+                        + ", tableFieldId=" + tableId);
+        return full;
     }
 
     /**

Разница между файлами не показана из-за своего большого размера
+ 1213 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/ReSubmitApprovalService.java


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

@@ -0,0 +1,206 @@
+package com.malk.service.workhours;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.common.McException;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.server.workhours.TimeCardDuplicateCleanupRequest;
+import com.malk.server.workhours.TimeCardDuplicateCleanupResult;
+import com.malk.server.workhours.TimeCardSummaryUpsertRequest;
+import com.malk.server.workhours.WHConf;
+import com.malk.service.aliwork.YDClient;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * TimeCard 工时汇总写入服务。
+ */
+@Service
+public class TimeCardSummaryService {
+
+    private static final String SUBMITTER_ID_FIELD = "textField_mmbffvd9";
+    private static final String DAY_TEXT_FIELD = "textField_mmbffvdb";
+
+    @Autowired
+    private YDClient ydClient;
+
+    @Autowired
+    private WHConf whConf;
+
+    /**
+     * 以填报人 ID + 日期为唯一条件执行宜搭原子 upsert。
+     *
+     * @param request 单日汇总写入请求
+     * @return true 表示宜搭接口调用成功
+     */
+    public boolean upsert(TimeCardSummaryUpsertRequest request) {
+        McException.assertAccessException(request == null, "请求不能为空");
+        McException.assertAccessException(
+                StringUtils.isBlank(request.getSubmitterUserId()),
+                "填报人ID不能为空");
+        McException.assertAccessException(
+                StringUtils.isBlank(request.getDayText())
+                        || !request.getDayText().matches("\\d{8}"),
+                "工时日期必须为yyyyMMdd");
+        McException.assertAccessException(
+                request.getFormData() == null || request.getFormData().isEmpty(),
+                "工时数据不能为空");
+
+        Map<String, Object> formData = new LinkedHashMap<>(request.getFormData());
+        // fixme 唯一键由服务端覆盖,防止请求条件与实际写入字段不一致。
+        formData.put(SUBMITTER_ID_FIELD, request.getSubmitterUserId());
+        formData.put(DAY_TEXT_FIELD, request.getDayText());
+
+        Map<String, Object> searchCondition = new LinkedHashMap<>();
+        searchCondition.put(SUBMITTER_ID_FIELD, request.getSubmitterUserId());
+        searchCondition.put(DAY_TEXT_FIELD, request.getDayText());
+
+        ydClient.operateData(YDParam.builder()
+                .appType(whConf.getYidaAppType())
+                .systemToken(whConf.getYidaSystemToken())
+                .formUuid(whConf.getFormUuidWorkHoursSummary())
+                .searchCondition(JSON.toJSONString(searchCondition))
+                .formDataJson(JSON.toJSONString(formData))
+                .noExecuteExpression(false)
+                .build(), YDConf.FORM_OPERATION.upsert);
+        return true;
+    }
+
+    /**
+     * 按显式清单校验并清理重复汇总记录;默认 dry-run,单批最多 30 条。
+     *
+     * @param request 重复记录清理请求
+     * @return 校验、删除和复查统计
+     */
+    public TimeCardDuplicateCleanupResult cleanupDuplicates(
+            TimeCardDuplicateCleanupRequest request) {
+        validateCleanupRequest(request);
+        List<String> deleteIds = new ArrayList<>();
+        for (TimeCardDuplicateCleanupRequest.Target target : request.getTargets()) {
+            List<String> matchingIds = queryMatchingInstanceIds(
+                    target.getUserId(), target.getDayText());
+            validateCleanupTarget(target, matchingIds);
+            deleteIds.add(target.getDeleteInstanceId());
+        }
+
+        if (!request.isDryRun()) {
+            ydClient.operateData(YDParam.builder()
+                    .appType(whConf.getYidaAppType())
+                    .systemToken(whConf.getYidaSystemToken())
+                    .formUuid(whConf.getFormUuidWorkHoursSummary())
+                    .formInstanceIdList(deleteIds)
+                    .build(), YDConf.FORM_OPERATION.delete_batch);
+        }
+
+        int verified = request.isDryRun()
+                ? 0
+                : verifyKeepers(request.getTargets());
+        return TimeCardDuplicateCleanupResult.builder()
+                .dryRun(request.isDryRun())
+                .requested(request.getTargets().size())
+                .validated(deleteIds.size())
+                .deleted(request.isDryRun() ? 0 : deleteIds.size())
+                .verified(verified)
+                .build();
+    }
+
+    private void validateCleanupRequest(TimeCardDuplicateCleanupRequest request) {
+        McException.assertAccessException(request == null, "请求不能为空");
+        List<TimeCardDuplicateCleanupRequest.Target> targets = request.getTargets();
+        McException.assertAccessException(
+                targets == null || targets.isEmpty(),
+                "重复记录清单不能为空");
+        McException.assertAccessException(
+                targets.size() > 30,
+                "单批最多清理30条重复记录");
+        Set<String> deleteIds = new HashSet<>();
+        for (TimeCardDuplicateCleanupRequest.Target target : targets) {
+            McException.assertAccessException(
+                    target == null || StringUtils.isAnyBlank(
+                            target.getUserId(),
+                            target.getDayText(),
+                            target.getDeleteInstanceId(),
+                            target.getKeepInstanceId()),
+                    "重复记录清单字段不能为空");
+            McException.assertAccessException(
+                    !target.getDayText().matches("\\d{8}"),
+                    "工时日期必须为yyyyMMdd");
+            McException.assertAccessException(
+                    target.getDeleteInstanceId().equals(target.getKeepInstanceId()),
+                    "删除实例与保留实例不能相同");
+            McException.assertAccessException(
+                    !deleteIds.add(target.getDeleteInstanceId()),
+                    "删除实例ID重复");
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<String> queryMatchingInstanceIds(String userId, String dayText) {
+        Map<String, Object> search = new LinkedHashMap<>();
+        search.put(SUBMITTER_ID_FIELD, userId);
+        search.put(DAY_TEXT_FIELD, dayText);
+        DDR_New result = ydClient.queryData(YDParam.builder()
+                .appType(whConf.getYidaAppType())
+                .systemToken(whConf.getYidaSystemToken())
+                .formUuid(whConf.getFormUuidWorkHoursSummary())
+                .searchFieldJson(JSON.toJSONString(search))
+                .pageNumber(1)
+                .pageSize(30)
+                .build(), YDConf.FORM_QUERY.retrieve_search_form);
+        List<Map> data = result == null ? null : (List<Map>) result.getData();
+        List<String> matchingIds = new ArrayList<>();
+        for (Map item : data == null ? new ArrayList<Map>() : data) {
+            Map formData = (Map) item.get("formData");
+            if (formData == null
+                    || !userId.equals(String.valueOf(formData.get(SUBMITTER_ID_FIELD)))
+                    || !dayText.equals(String.valueOf(formData.get(DAY_TEXT_FIELD)))) {
+                continue;
+            }
+            Object id = item.get("formInstanceId");
+            if (id == null) {
+                id = item.get("formInstId");
+            }
+            if (id != null) {
+                matchingIds.add(String.valueOf(id));
+            }
+        }
+        return matchingIds;
+    }
+
+    static void validateCleanupTarget(TimeCardDuplicateCleanupRequest.Target target,
+                                      List<String> matchingIds) {
+        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());
+    }
+
+    private int verifyKeepers(List<TimeCardDuplicateCleanupRequest.Target> targets) {
+        int verified = 0;
+        for (TimeCardDuplicateCleanupRequest.Target target : targets) {
+            List<String> matchingIds = queryMatchingInstanceIds(
+                    target.getUserId(), target.getDayText());
+            McException.assertAccessException(
+                    matchingIds.size() != 1
+                            || !matchingIds.contains(target.getKeepInstanceId()),
+                    "重复记录删除后复查失败 userId="
+                            + target.getUserId() + ", day=" + target.getDayText());
+            verified++;
+        }
+        return verified;
+    }
+}

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

@@ -1916,6 +1916,7 @@ public class WorkHoursCalcService {
             List<Map> full = ydService.queryDetails(YDParam.builder()
                     .appType(whConf.getYidaAppType())
                     .systemToken(whConf.getYidaSystemToken())
+                    .formUuid(whConf.getFormUuidProject())
                     .formInstanceId(projectInstId)
                     .tableFieldId(PROJECT_SUB_TABLE)
                     .pageNumber(1)

+ 140 - 0
mjava-akdsbeisen/src/main/java/com/malk/timer/ApprovalWriteBackTimer.java

@@ -0,0 +1,140 @@
+package com.malk.timer;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.server.workhours.WHConf;
+import com.malk.service.aliwork.YDClient;
+import com.malk.service.workhours.ApprovalWriteBackService;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.annotation.Scheduled;
+
+import java.text.SimpleDateFormat;
+import java.util.*;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * 审批回写兜底定时(bug 2 修复)
+ * <p>
+ * 每天凌晨 04:20 触发,先扫两类审批单近 3 天有修改、instanceStatus=COMPLETED、
+ * 同步状态 selectField_mq58cd5p 为空的实例,主动调 ApprovalWriteBackService.writeBack 补齐工时汇总表回写。
+ * <p>
+ * 触发原因场景:
+ * 1. 审批完成时宜搭端未回调,writeBack 从未被调
+ * 2. writeBack 调用但网络/宜搭异常,回写失败(会被 syncStatus 置为"部分失败"或"全部失败",此定时兜底不会重复处理)
+ * <p>
+ * 幂等:writeBack 内部 REJECT→0 / APPROVE←hours, 重复执行结果一致
+ * <p>
+ * 设计文档:后端/阿科德斯/审批结果回写工时汇总表.md v0.8
+ */
+@Slf4j
+@Configuration
+@EnableScheduling
+@ConditionalOnProperty(prefix = "enable", name = "scheduling", havingValue = "true", matchIfMissing = false)
+public class ApprovalWriteBackTimer {
+
+    @Autowired
+    private YDClient ydClient;
+
+    @Autowired
+    private WHConf whConf;
+
+    @Autowired
+    private ApprovalWriteBackService approvalWriteBackService;
+
+    private static final AtomicBoolean RUNNING = new AtomicBoolean(false);
+
+    /**
+     * 每天凌晨 04:20 兜底扫描审批单
+     */
+    @Scheduled(cron = "0 20 4 * * ?")
+    public void scanAndWriteBack() {
+        if (!RUNNING.compareAndSet(false, true)) {
+            log.warn("[审批回写兜底] 上次执行未结束,跳过本次触发");
+            return;
+        }
+        try {
+            log.info("[审批回写兜底] 开始扫描 syncStatus 空的 COMPLETED 审批单");
+            AtomicInteger totalScanned = new AtomicInteger(0);
+            AtomicInteger totalTriggered = new AtomicInteger(0);
+            AtomicInteger totalFailed = new AtomicInteger(0);
+            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);
+            }
+            log.info("[审批回写兜底] 完成 scanned={} triggered={} failed={}",
+                    totalScanned.get(), totalTriggered.get(), totalFailed.get());
+        } catch (Exception e) {
+            log.error("[审批回写兜底] 定时任务异常", e);
+        } finally {
+            RUNNING.set(false);
+        }
+    }
+
+    private void scanForm(String tag, String formUuid,
+                          AtomicInteger scanned, AtomicInteger triggered, AtomicInteger failed) {
+        // 近 3 天 gmtModified 窗口(减少扫描面)
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+        Calendar cal = Calendar.getInstance();
+        String toDate = sdf.format(new Date(cal.getTimeInMillis() + 24L * 3600_000));
+        cal.add(Calendar.DAY_OF_MONTH, -3);
+        String fromDate = sdf.format(cal.getTime());
+        // 状态字段: 已知宜搭对 SelectField 支持 IsEmpty 特殊查询,受项目实测差异保守起见改扫全 COMPLETED,内存过滤 syncStatus
+        int page = 1;
+        while (page <= 20) {
+            DDR_New r = ydClient.queryData(YDParam.builder()
+                    .appType(whConf.getYidaAppType())
+                    .systemToken(whConf.getYidaSystemToken())
+                    .formUuid(formUuid)
+                    .instanceStatus("COMPLETED")
+                    .modifiedFromTimeGMT(fromDate)
+                    .modifiedToTimeGMT(toDate)
+                    .pageNumber(page)
+                    .pageSize(100)
+                    .build(), YDConf.FORM_QUERY.retrieve_search_process);
+            List<Map> data = (List<Map>) r.getData();
+            if (data == null || data.isEmpty()) {
+                break;
+            }
+            for (Map item : data) {
+                scanned.incrementAndGet();
+                Map fd = (Map) item.get("formData");
+                if (fd == null) {
+                    continue;
+                }
+                String syncStatus = String.valueOf(fd.getOrDefault(whConf.getApprovalSyncStatusField(), ""));
+                if (StringUtils.isNotBlank(syncStatus) && !"null".equals(syncStatus)) {
+                    continue;
+                }
+                String pid = String.valueOf(item.get("processInstanceId"));
+                Object fInstObj = item.get("formInstanceId");
+                String finstId = fInstObj != null ? String.valueOf(fInstObj) : pid;
+                String approvedResult = String.valueOf(item.getOrDefault("approvedResult", "agree"));
+                int resultCode = "agree".equals(approvedResult) ? 1 : 0;
+                try {
+                    approvalWriteBackService.writeBack(finstId, resultCode);
+                    triggered.incrementAndGet();
+                    log.info("[审批回写兜底] 触发 writeBack tag={} formInstanceId={} result={}",
+                            tag, finstId, resultCode);
+                } catch (Exception ex) {
+                    failed.incrementAndGet();
+                    log.error("[审批回写兜底] writeBack 失败 tag={} formInstanceId={}", tag, finstId, ex);
+                }
+            }
+            if (data.size() < 100) {
+                break;
+            }
+            page++;
+        }
+    }
+}

+ 91 - 0
mjava-akdsbeisen/src/main/java/com/malk/timer/ReSubmitApprovalTimer.java

@@ -0,0 +1,91 @@
+package com.malk.timer;
+
+import com.malk.server.workhours.ReSubmitApprovalResult;
+import com.malk.service.workhours.ReSubmitApprovalService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+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.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * 补发起工时审批定时(bug 1 修复)
+ * <p>
+ * 每天凌晨 05:20 触发;先让 04:20 回写兜底消化已完成审批,再扫描汇总表状态="审批中"的记录,
+ * 按 (userId, month) 分组,
+ * 走 ReSubmitApprovalService.resubmit 逻辑:queryProcess(RUNNING) 有则 update / 无则 create。
+ * <p>
+ * COMPLETED 明细由覆盖索引拦截;若汇总尚未回写,留给 04:20 回写兜底处理。
+ * <p>
+ * 触发原因场景:
+ * 1. 前端 submitApprovalProcess 内 collectApprovalData 空跑(汇总表已 submitted 但 UI 状态未同步)
+ * 2. startInstance 抛错但 catch 只 toast 不 rollback,导致 submitted 已存但流程未发起
+ * <p>
+ * 设计文档:后端/阿科德斯/审批结果回写工时汇总表/02-程序实现与接口.md
+ */
+@Slf4j
+@Configuration
+@EnableScheduling
+@ConditionalOnProperty(prefix = "enable", name = "scheduling", havingValue = "true", matchIfMissing = false)
+public class ReSubmitApprovalTimer {
+
+    @Autowired
+    private ReSubmitApprovalService reSubmitApprovalService;
+
+    private static final AtomicBoolean RUNNING = new AtomicBoolean(false);
+
+    /**
+     * 每天凌晨 05:20 兜底补发起
+     */
+    @Scheduled(cron = "0 20 5 * * ?")
+    public void scanAndReSubmit() {
+        if (!RUNNING.compareAndSet(false, true)) {
+            log.warn("[补发起兜底] 上次执行未结束,跳过本次触发");
+            return;
+        }
+        try {
+            // 近 3 天覆盖:当前月 + 上月月尾场景
+            Set<String> months = recentMonths(3);
+            log.info("[补发起兜底] 开始扫描 months={}", months);
+            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);
+                }
+            }
+            log.info("[补发起兜底] 完成 created={} updated={} failed={}", totalCreated, totalUpdated, totalFailed);
+        } catch (Exception e) {
+            log.error("[补发起兜底] 定时任务异常", e);
+        } finally {
+            RUNNING.set(false);
+        }
+    }
+
+    /**
+     * 返回最近 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;
+    }
+}

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

@@ -90,6 +90,15 @@ workhours:
   otherApprovalSyncStatusField: "selectField_mq58cd5p"
   otherApprovalSyncTotalField: "numberField_mq58cd5q"
   otherApprovalSyncFailField: "numberField_mq58cd5r"
+  # 补发起(bug1): processCode + 真人发起账号 + 汇总表状态字段
+  # fixme 生产 processCode 与测试环境不同, 上线时切正式值:
+  #       正式 approvalProcessCode: TPROC--4HC666B14XE3T3TOGXEC1A7TY7RM211B8DAMM9
+  #       正式 otherApprovalProcessCode: TPROC--14E66UA1Q2R38711L6SYK62QXEYR3574VAEMM9
+  approvalProcessCode: "TPROC--YRD66CC1..."          # 测试环境, 上线切正式
+  otherApprovalProcessCode: "TPROC--AO666S81..."     # 测试环境, 上线切正式
+  approvalOriginatorUserId: "${AKDS_APPROVAL_ORIGINATOR_USER_ID:}"
+  approvalOriginatorDeptId: "${AKDS_APPROVAL_ORIGINATOR_DEPT_ID:}"
+  summaryApprovalStatusField: "selectField_mre1xz9g"
 
 # 天眼查开放平台 (客户档案·工商信息回填, 2026-07-10)
 tianyancha:
@@ -105,7 +114,7 @@ customer:
   yidaFormUuid: "FORM-743EC9F3012146B3831594126EE6117F12U8"
   tycCompanyUrl: "https://www.tianyancha.com/company/{id}"
   fields:
-    customerName: "textField_mjmje451"
+    customerName: "selectField_mrnnj1vh"
     creditCode: "textField_mjmlh2y8"
     legalPerson: "textField_mjmlh2y3"
     companyType: "textField_mjmlh2y9"

+ 10 - 4
mjava-akdsbeisen/src/main/resources/application-prod.yml

@@ -60,10 +60,10 @@ workhours:
   formUuidProject: "FORM-A4A04FCDE2024B23B386504954760F55HIB9"
   # 项目变更审批:子表复用项目档案 tableField_mkowyn6d 结构 + 原项目经理 employeeField_mrd9y8i9
   formUuidProjectChange: "FORM-B86D895C90AE460B9749CC15E9079457PC79"
-  # 审批回写:工时汇总表 + 两类审批单(当前指向测试环境,正式见注释)
+  # 审批回写:工时汇总表 + 当前正式两类审批单
   formUuidWorkHoursSummary: "FORM-87E51B67EBC1461C8247ED7D5D3E3DD829KC"
-  formUuidApproval: "FORM-230BB68C42304492A242CA278EF4A853LYIY"        # 正式 FORM-DDA1683645954101890AF6BA723705F9M0XV
-  formUuidOtherApproval: "FORM-4828E0E40CD34038825E8C6E25417B2718NF"   # 正式 FORM-BA14F6322DBD470F8CF84CCE131DEA31TIJS
+  formUuidApproval: "FORM-DDA1683645954101890AF6BA723705F9M0XV"
+  formUuidOtherApproval: "FORM-BA14F6322DBD470F8CF84CCE131DEA31TIJS"
   yidaAppType: "APP_ZQ3I7XO2RSHDJ4QDEVNB"
   yidaSystemToken: "FOD66381NOS25MERLN2UK92FY96Y21UMHD7LM36S"
   # 审批回写同步状态字段:两类审批表 fieldId 一致(业务方建字段时同名同 ID)
@@ -74,6 +74,12 @@ workhours:
   otherApprovalSyncStatusField: "selectField_mq58cd5p"
   otherApprovalSyncTotalField: "numberField_mq58cd5q"
   otherApprovalSyncFailField: "numberField_mq58cd5r"
+  # 补发起(bug1) - 生产正式 processCode;真人 System 账号用于生成钉钉审批待办
+  approvalProcessCode: "TPROC--4HC666B14XE3T3TOGXEC1A7TY7RM211B8DAMM9"
+  otherApprovalProcessCode: "TPROC--14E66UA1Q2R38711L6SYK62QXEYR3574VAEMM9"
+  approvalOriginatorUserId: "${AKDS_APPROVAL_ORIGINATOR_USER_ID:477737964}"
+  approvalOriginatorDeptId: "${AKDS_APPROVAL_ORIGINATOR_DEPT_ID:1054043453}"
+  summaryApprovalStatusField: "selectField_mre1xz9g"
 
 # 天眼查开放平台 (客户档案·工商信息回填, 2026-07-10)
 tianyancha:
@@ -87,7 +93,7 @@ customer:
   yidaFormUuid: "FORM-743EC9F3012146B3831594126EE6117F12U8"
   tycCompanyUrl: "https://www.tianyancha.com/company/{id}"
   fields:
-    customerName: "textField_mjmje451"
+    customerName: "selectField_mrnnj1vh"
     creditCode: "textField_mjmlh2y8"
     legalPerson: "textField_mjmlh2y3"
     companyType: "textField_mjmlh2y9"

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

@@ -0,0 +1,87 @@
+package com.malk.service.workhours;
+
+import com.malk.server.dingtalk.DDR_New;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class ApprovalWriteBackServiceTest {
+
+    @Test
+    void shouldPreferApprovedStatus() {
+        assertEquals("已审批", ApprovalWriteBackService.resolveSummaryApprovalStatus(8, 8));
+        assertEquals("已审批", ApprovalWriteBackService.resolveSummaryApprovalStatus(4, 4));
+        assertEquals("已审批", ApprovalWriteBackService.resolveSummaryApprovalStatus(0, 4));
+    }
+
+    @Test
+    void shouldUseInApprovalWhenSubmittedHoursRemain() {
+        assertEquals("审批中", ApprovalWriteBackService.resolveSummaryApprovalStatus(8, 0));
+        assertEquals("审批中", ApprovalWriteBackService.resolveSummaryApprovalStatus(8, 4));
+    }
+
+    @Test
+    void shouldFallBackToDraftWhenNoSubmittedOrApprovedHours() {
+        assertEquals("暂存", ApprovalWriteBackService.resolveSummaryApprovalStatus(0, 0));
+    }
+
+    @Test
+    void shouldFallBackToWholeApprovalWhenHistoricalDetailOpinionIsBlank() {
+        assertEquals(true, ApprovalWriteBackService.isDetailApproved(true, ""));
+        assertEquals(true, ApprovalWriteBackService.isDetailApproved(true, null));
+        assertEquals(true, ApprovalWriteBackService.isDetailApproved(true, "同意"));
+        assertEquals(false, ApprovalWriteBackService.isDetailApproved(true, "拒绝"));
+        assertEquals(false, ApprovalWriteBackService.isDetailApproved(false, ""));
+    }
+
+    @Test
+    void shouldExtractFullFormDataFromProcessDetail() {
+        DDR_New response = new DDR_New();
+        Map<String, Object> formData = new HashMap<>();
+        formData.put("tableField_mmae8t99", new ArrayList<>(63));
+        response.setData(formData);
+
+        Map extracted = ApprovalWriteBackService.extractProcessFormData(response);
+
+        assertEquals(formData, extracted);
+    }
+
+    @Test
+    void shouldExtractFullFormDataFromLegacyProcessResult() {
+        DDR_New response = new DDR_New();
+        Map<String, Object> formData = new HashMap<>();
+        formData.put("tableField_mmae8t99", new ArrayList<>(63));
+        Map<String, Object> result = new HashMap<>();
+        result.put("data", formData);
+        response.setResult(result);
+
+        Map extracted = ApprovalWriteBackService.extractProcessFormData(response);
+
+        assertEquals(formData, extracted);
+    }
+
+    @Test
+    void shouldRecognizeWorkApprovalWithOnlyNonBillableRows() {
+        Map<String, Object> row = new HashMap<>();
+        row.put("textField_mmd1wu9d", "PROJECT-1");
+        Map<String, Object> formData = new HashMap<>();
+        formData.put("tableField_mmd1wu9h", Collections.singletonList(row));
+
+        assertEquals(false, ApprovalWriteBackService.isOtherApprovalForm(formData));
+    }
+
+    @Test
+    void shouldRecognizeOtherApprovalByActivityField() {
+        Map<String, Object> row = new HashMap<>();
+        row.put("selectField_mmeakgie", "培训");
+        Map<String, Object> formData = new HashMap<>();
+        formData.put("tableField_mmd1wu9h", Collections.singletonList(row));
+
+        assertEquals(true, ApprovalWriteBackService.isOtherApprovalForm(formData));
+    }
+}

+ 81 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/ReSubmitApprovalServiceTest.java

@@ -0,0 +1,81 @@
+package com.malk.service.workhours;
+
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+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.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+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.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+class ReSubmitApprovalServiceTest {
+
+    @Test
+    void shouldTreatBlankHistoricalOpinionAsCompletedApprovalCoverage() {
+        assertTrue(ReSubmitApprovalService.isCompletedApprovedRow("COMPLETED", "agree", ""));
+        assertTrue(ReSubmitApprovalService.isCompletedApprovedRow("COMPLETED", "agree", null));
+        assertTrue(ReSubmitApprovalService.isCompletedApprovedRow("COMPLETED", "agree", "同意"));
+        assertFalse(ReSubmitApprovalService.isCompletedApprovedRow("COMPLETED", "agree", "拒绝"));
+        assertFalse(ReSubmitApprovalService.isCompletedApprovedRow("RUNNING", "agree", ""));
+    }
+
+    @Test
+    void shouldSkipRowsAlreadyInRunningApproval() {
+        ReSubmitApprovalService.ApprovalCoverage coverage =
+                new ReSubmitApprovalService.ApprovalCoverage();
+        String key = ReSubmitApprovalService.approvalKey(
+                "billable", "user-1", "20260611", "P1");
+        coverage.runningKeys.add(key);
+
+        assertTrue(coverage.shouldSkip(key, 0));
+    }
+
+    @Test
+    void shouldRouteUnaccountedCompletedApprovalToWriteBackInsteadOfResubmit() {
+        ReSubmitApprovalService.ApprovalCoverage coverage =
+                new ReSubmitApprovalService.ApprovalCoverage();
+        String key = ReSubmitApprovalService.approvalKey(
+                "billable", "user-1", "20260611", "P1");
+        coverage.completedAgreeHours.put(key, 8d);
+
+        assertTrue(coverage.shouldSkip(key, 0));
+        assertFalse(coverage.shouldSkip(key, 8));
+    }
+
+    @Test
+    void shouldStartApprovalWithConfiguredHumanOriginator() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf whConf = new WHConf();
+        whConf.setYidaAppType("APP");
+        whConf.setYidaSystemToken("TOKEN");
+        whConf.setFormUuidApproval("FORM-APPROVAL");
+        whConf.setApprovalProcessCode("PROCESS-APPROVAL");
+        whConf.setApprovalOriginatorUserId("system-user");
+        whConf.setApprovalOriginatorDeptId("dept-default");
+
+        ReSubmitApprovalService service = new ReSubmitApprovalService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", whConf);
+
+        TimeCardApprovalStartRequest request = new TimeCardApprovalStartRequest();
+        request.setFormUuid("FORM-APPROVAL");
+        request.setProcessCode("PROCESS-APPROVAL");
+        request.setDeptId("dept-request");
+        request.setFormDataJson("{\"textField_test\":\"value\"}");
+
+        assertTrue(service.startApproval(request));
+
+        ArgumentCaptor<YDParam> captor = ArgumentCaptor.forClass(YDParam.class);
+        verify(ydClient).operateData(captor.capture(), eq(YDConf.FORM_OPERATION.start));
+        assertEquals("system-user", captor.getValue().getUserId());
+        assertEquals("dept-request", captor.getValue().getDeptId());
+    }
+}

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

@@ -0,0 +1,168 @@
+package com.malk.service.workhours;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.server.workhours.TimeCardDuplicateCleanupRequest;
+import com.malk.server.workhours.TimeCardDuplicateCleanupResult;
+import com.malk.server.workhours.TimeCardSummaryUpsertRequest;
+import com.malk.server.workhours.WHConf;
+import com.malk.service.aliwork.YDClient;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+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.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class TimeCardSummaryServiceTest {
+
+    @Test
+    void shouldUpsertBySubmitterIdAndDayText() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf whConf = new WHConf();
+        whConf.setYidaAppType("APP");
+        whConf.setYidaSystemToken("TOKEN");
+        whConf.setFormUuidWorkHoursSummary("FORM-SUMMARY");
+
+        TimeCardSummaryService service = new TimeCardSummaryService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", whConf);
+
+        TimeCardSummaryUpsertRequest request = new TimeCardSummaryUpsertRequest();
+        request.setSubmitterUserId("user-1");
+        request.setDayText("20260716");
+        request.setFormData(Collections.singletonMap("numberField_hours", 8));
+
+        assertTrue(service.upsert(request));
+
+        ArgumentCaptor<YDParam> captor = ArgumentCaptor.forClass(YDParam.class);
+        verify(ydClient).operateData(captor.capture(), eq(YDConf.FORM_OPERATION.upsert));
+        YDParam param = captor.getValue();
+        Map<String, Object> condition = JSON.parseObject(param.getSearchCondition(), Map.class);
+        Map<String, Object> formData = JSON.parseObject(param.getFormDataJson(), Map.class);
+
+        assertEquals("user-1", condition.get("textField_mmbffvd9"));
+        assertEquals("20260716", condition.get("textField_mmbffvdb"));
+        assertEquals("user-1", formData.get("textField_mmbffvd9"));
+        assertEquals("20260716", formData.get("textField_mmbffvdb"));
+        assertEquals(8, ((Number) formData.get("numberField_hours")).intValue());
+    }
+
+    @Test
+    void shouldValidateDuplicateCleanupWithoutDeletingInDryRun() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf whConf = new WHConf();
+        whConf.setYidaAppType("APP");
+        whConf.setYidaSystemToken("TOKEN");
+        whConf.setFormUuidWorkHoursSummary("FORM-SUMMARY");
+
+        TimeCardSummaryService service = new TimeCardSummaryService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", whConf);
+
+        DDR_New queryResult = mock(DDR_New.class);
+        when(queryResult.getData()).thenReturn(Arrays.asList(
+                summaryItem("DELETE-ID", "user-1", "20260507"),
+                summaryItem("KEEP-ID", "user-1", "20260507")));
+        when(ydClient.queryData(
+                any(YDParam.class),
+                eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(queryResult);
+
+        TimeCardDuplicateCleanupResult result =
+                service.cleanupDuplicates(cleanupRequest(true));
+
+        assertEquals(1, result.getValidated());
+        assertEquals(0, result.getDeleted());
+        verify(ydClient, never()).operateData(
+                any(YDParam.class),
+                eq(YDConf.FORM_OPERATION.delete_batch));
+    }
+
+    @Test
+    void shouldRejectCleanupWhenCurrentDuplicateSetChanged() {
+        TimeCardDuplicateCleanupRequest.Target target =
+                cleanupRequest(true).getTargets().get(0);
+        assertThrows(
+                RuntimeException.class,
+                () -> TimeCardSummaryService.validateCleanupTarget(
+                        target, Collections.singletonList("KEEP-ID")));
+    }
+
+    @Test
+    void shouldDeleteValidatedTargetAndVerifyKeeper() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf whConf = new WHConf();
+        whConf.setYidaAppType("APP");
+        whConf.setYidaSystemToken("TOKEN");
+        whConf.setFormUuidWorkHoursSummary("FORM-SUMMARY");
+
+        TimeCardSummaryService service = new TimeCardSummaryService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", whConf);
+
+        DDR_New duplicateResult = mock(DDR_New.class);
+        when(duplicateResult.getData()).thenReturn(Arrays.asList(
+                summaryItem("DELETE-ID", "user-1", "20260507"),
+                summaryItem("KEEP-ID", "user-1", "20260507")));
+        DDR_New keeperResult = mock(DDR_New.class);
+        when(keeperResult.getData()).thenReturn(Collections.singletonList(
+                summaryItem("KEEP-ID", "user-1", "20260507")));
+        when(ydClient.queryData(
+                any(YDParam.class),
+                eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(duplicateResult, keeperResult);
+
+        TimeCardDuplicateCleanupResult result =
+                service.cleanupDuplicates(cleanupRequest(false));
+
+        ArgumentCaptor<YDParam> captor = ArgumentCaptor.forClass(YDParam.class);
+        verify(ydClient).operateData(
+                captor.capture(),
+                eq(YDConf.FORM_OPERATION.delete_batch));
+        assertEquals(Collections.singletonList("DELETE-ID"),
+                captor.getValue().getFormInstanceIdList());
+        assertEquals(1, result.getDeleted());
+        assertEquals(1, result.getVerified());
+    }
+
+    private static TimeCardDuplicateCleanupRequest cleanupRequest(boolean dryRun) {
+        TimeCardDuplicateCleanupRequest.Target target =
+                new TimeCardDuplicateCleanupRequest.Target();
+        target.setUserId("user-1");
+        target.setDayText("20260507");
+        target.setDeleteInstanceId("DELETE-ID");
+        target.setKeepInstanceId("KEEP-ID");
+        TimeCardDuplicateCleanupRequest request =
+                new TimeCardDuplicateCleanupRequest();
+        request.setDryRun(dryRun);
+        request.setTargets(Collections.singletonList(target));
+        return request;
+    }
+
+    private static Map<String, Object> summaryItem(String instanceId,
+                                                   String userId,
+                                                   String dayText) {
+        Map<String, Object> formData = new LinkedHashMap<>();
+        formData.put("textField_mmbffvd9", userId);
+        formData.put("textField_mmbffvdb", dayText);
+        Map<String, Object> item = new LinkedHashMap<>();
+        item.put("formInstanceId", instanceId);
+        item.put("formData", formData);
+        return item;
+    }
+}

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

@@ -47,6 +47,29 @@ public class WorkHoursTimerScheduleTest {
         assertEquals(false, condition.matchIfMissing());
     }
 
+    @Test
+    public void approvalWriteBackShouldRunBeforeApprovalResubmit() throws Exception {
+        Method writeBack = ApprovalWriteBackTimer.class.getMethod("scanAndWriteBack");
+        Method resubmit = ReSubmitApprovalTimer.class.getMethod("scanAndReSubmit");
+
+        assertEquals(setOf("0 20 4 * * ?"), scheduledCrons(writeBack));
+        assertEquals(setOf("0 20 5 * * ?"), scheduledCrons(resubmit));
+    }
+
+    @Test
+    public void approvalFallbackTimersShouldRespectGlobalSchedulingSwitch() {
+        assertSchedulingSwitch(ApprovalWriteBackTimer.class);
+        assertSchedulingSwitch(ReSubmitApprovalTimer.class);
+    }
+
+    private static void assertSchedulingSwitch(Class<?> timerClass) {
+        ConditionalOnProperty condition = timerClass.getAnnotation(ConditionalOnProperty.class);
+        assertEquals("enable", condition.prefix());
+        assertEquals(Arrays.asList("scheduling"), Arrays.asList(condition.name()));
+        assertEquals("true", condition.havingValue());
+        assertEquals(false, condition.matchIfMissing());
+    }
+
     private static Set<String> scheduledCrons(Method method) {
         Set<String> crons = new HashSet<>();
         for (Scheduled scheduled : method.getAnnotationsByType(Scheduled.class)) {

+ 2 - 1
mjava/src/main/java/com/malk/server/aliwork/YDConf.java

@@ -57,7 +57,8 @@ public class YDConf {
         retrieve_search_process,            // 流程列表
         retrieve_search_form,               // 表单列表
 
-        retrieve_search_process_id,         // 流程列表
+        retrieve_search_process_id,         // 根据流程实例ID查询流程详情
+        retrieve_process_legacy_id,         // 旧版服务端API查询完整流程表单数据
         retrieve_search_form_id,             // 表单列表
 
         retrieve_details,             // 子表数据[表单]

+ 3 - 0
mjava/src/main/java/com/malk/server/aliwork/YDParam.java

@@ -65,6 +65,9 @@ public class YDParam extends BaseDto {
     @NotNull(message = "流程编码不能为空", groups = Create_Process.class)
     private String processCode;
 
+    // ppExt startInstance 发起流程时的部门ID(可空); 若为空宜搭按发起人默认部门
+    private String deptId;
+
     // todo: 成员组件未匹配, 接口可以新增 [系统会忽略], 导入会提示无法匹配
     @NotNull(message = "新增内容不能为空", groups = {Create.class, Create_Process.class})
     public String formDataJson;

+ 10 - 1
mjava/src/main/java/com/malk/service/aliwork/impl/YDClientImpl.java

@@ -116,7 +116,16 @@ public class YDClientImpl implements YDClient {
                 break;
 
             case retrieve_search_process_id:
-                ddr_new = DDR_New.doPost(getRequestUrl("/processes/instanceIds", ydParam.getProcessInstanceId()), ddClient.initTokenHeader(), param, param);
+                ddr_new = DDR_New.doGet(
+                        getRequestUrl("/processes/instancesInfos", ydParam.getProcessInstanceId()),
+                        ddClient.initTokenHeader(),
+                        param);
+                break;
+            case retrieve_process_legacy_id:
+                ddr_new = DDR_New.doGet(
+                        "https://api.dingtalk.com/yida_vpc/process/getInstanceById.json",
+                        ddClient.initTokenHeader(),
+                        param);
                 break;
 
             case retrieve_search_form:

+ 12 - 3
mjava/src/main/java/com/malk/service/aliwork/impl/YDServiceImpl.java

@@ -69,6 +69,11 @@ public class YDServiceImpl implements YDService {
      */
     @Override
     public List<Map> queryDetails(YDParam ydParam) {
+        // fixme 宜搭「获取子表组件数据」接口必须同时传 formUuid;
+        // 仅传实例 ID 时部分流程表单会返回 data=null,导致 50 行后的明细无法获取。
+        McException.assertAccessException(
+                StringUtils.isBlank(ydParam.getFormUuid()),
+                "查询子表组件数据必须指定formUuid");
         return _queryDetails(ydParam, new ArrayList<>());
     }
 
@@ -76,10 +81,14 @@ public class YDServiceImpl implements YDService {
     private List<Map> _queryDetails(YDParam ydParam, List<Map> details) {
         ydParam.setPageSize(50);
         DDR_New ddr_new = ydClient.queryData(ydParam, YDConf.FORM_QUERY.retrieve_details);
-        details.addAll((List<Map>) ddr_new.getData());
-        if (ddr_new.getTotalCount() != details.size()) {
+        List<Map> pageData = ddr_new == null ? null : (List<Map>) ddr_new.getData();
+        if (pageData == null || pageData.isEmpty()) {
+            return details;
+        }
+        details.addAll(pageData);
+        if (ddr_new.getTotalCount() > details.size() && pageData.size() == 50) {
             ydParam.setPageNumber(ydParam.getPageNumber() + 1);
-            _queryDetails(ydParam, details);
+            return _queryDetails(ydParam, details);
         }
         return details;
     }

+ 73 - 0
mjava/src/test/java/com/malk/service/aliwork/impl/YDServiceImplTest.java

@@ -0,0 +1,73 @@
+package com.malk.service.aliwork.impl;
+
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.dingtalk.DDR_New;
+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.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class YDServiceImplTest {
+
+    @Test
+    void shouldStopSafelyWhenSubformPageDataIsNull() {
+        YDClient ydClient = mock(YDClient.class);
+        DDR_New response = new DDR_New();
+        response.setTotalCount(63);
+        response.setData(null);
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_details)))
+                .thenReturn(response);
+
+        YDServiceImpl service = new YDServiceImpl();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+
+        List<Map> rows = service.queryDetails(YDParam.builder()
+                .formUuid("FORM-TEST")
+                .pageNumber(1)
+                .build());
+
+        assertEquals(0, rows.size());
+    }
+
+    @Test
+    void shouldLoadAllSubformPages() {
+        YDClient ydClient = mock(YDClient.class);
+        DDR_New first = response(63, 50);
+        DDR_New second = response(63, 13);
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_details)))
+                .thenReturn(first, second);
+
+        YDServiceImpl service = new YDServiceImpl();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        YDParam param = YDParam.builder()
+                .formUuid("FORM-TEST")
+                .pageNumber(1)
+                .build();
+
+        List<Map> rows = service.queryDetails(param);
+
+        assertEquals(63, rows.size());
+        assertEquals(2, param.getPageNumber());
+    }
+
+    private DDR_New response(long totalCount, int size) {
+        DDR_New response = new DDR_New();
+        response.setTotalCount(totalCount);
+        response.setData(new ArrayList<Map>(size));
+        List<Map> rows = (List<Map>) response.getData();
+        for (int i = 0; i < size; i++) {
+            rows.add(new java.util.HashMap<>());
+        }
+        return response;
+    }
+}