Browse Source

feat(timecard): add work hours change workflow

malk 4 weeks ago
parent
commit
323c9f4d6d
20 changed files with 2758 additions and 33 deletions
  1. 18 0
      mjava-akdsbeisen/src/main/java/com/malk/controller/TimeCardController.java
  2. 7 2
      mjava-akdsbeisen/src/main/java/com/malk/service/workhours/ApprovalWriteBackService.java
  3. 13 10
      mjava-akdsbeisen/src/main/java/com/malk/service/workhours/MonthClosingService.java
  4. 9 7
      mjava-akdsbeisen/src/main/java/com/malk/service/workhours/ReSubmitApprovalService.java
  5. 3 3
      mjava-akdsbeisen/src/main/java/com/malk/service/workhours/RepairApprovalWriteBackService.java
  6. 703 0
      mjava-akdsbeisen/src/main/java/com/malk/service/workhours/TimeCardChangeApprovalWriteBackService.java
  7. 104 0
      mjava-akdsbeisen/src/main/java/com/malk/service/workhours/TimeCardChangePolicy.java
  8. 964 0
      mjava-akdsbeisen/src/main/java/com/malk/service/workhours/TimeCardChangeService.java
  9. 48 9
      mjava-akdsbeisen/src/main/java/com/malk/service/workhours/TimeCardRepairService.java
  10. 4 1
      mjava-akdsbeisen/src/main/java/com/malk/service/workhours/TimeCardSummaryService.java
  11. 31 0
      mjava-akdsbeisen/src/main/java/com/malk/timer/ReSubmitApprovalTimer.java
  12. 27 0
      mjava-akdsbeisen/src/test/java/com/malk/service/workhours/ApprovalWriteBackServiceTest.java
  13. 31 1
      mjava-akdsbeisen/src/test/java/com/malk/service/workhours/MonthClosingServiceTest.java
  14. 29 0
      mjava-akdsbeisen/src/test/java/com/malk/service/workhours/ReSubmitApprovalServiceTest.java
  15. 222 0
      mjava-akdsbeisen/src/test/java/com/malk/service/workhours/TimeCardChangeApprovalWriteBackServiceTest.java
  16. 101 0
      mjava-akdsbeisen/src/test/java/com/malk/service/workhours/TimeCardChangePolicyTest.java
  17. 339 0
      mjava-akdsbeisen/src/test/java/com/malk/service/workhours/TimeCardChangeServiceTest.java
  18. 47 0
      mjava-akdsbeisen/src/test/java/com/malk/service/workhours/TimeCardRepairServiceTest.java
  19. 31 0
      mjava-akdsbeisen/src/test/java/com/malk/service/workhours/TimeCardSummaryServiceTest.java
  20. 27 0
      mjava-akdsbeisen/src/test/java/com/malk/timer/ReSubmitApprovalTimerTest.java

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

@@ -10,6 +10,7 @@ import com.malk.server.workhours.TimeCardRepairSubmitRequest;
 import com.malk.server.workhours.TimeCardRepairSummaryUpsertRequest;
 import com.malk.service.workhours.TimeCardSummaryService;
 import com.malk.service.workhours.TimeCardRepairService;
+import com.malk.service.workhours.TimeCardChangeService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
@@ -29,6 +30,9 @@ public class TimeCardController {
     @Autowired
     private TimeCardRepairService timeCardRepairService;
 
+    @Autowired
+    private TimeCardChangeService timeCardChangeService;
+
     /**
      * 按填报人 ID + 日期原子写入单日工时汇总。
      *
@@ -84,4 +88,18 @@ public class TimeCardController {
             @RequestBody TimeCardRepairSubmitRequest request) {
         return McR.success(timeCardRepairService.submit(request));
     }
+
+    /** 保存变更后的目标工时草稿,不修改补填记录。 */
+    @PostMapping("/change/summary/upsert")
+    public McR<Boolean> changeSummaryUpsert(
+            @RequestBody TimeCardRepairSummaryUpsertRequest request) {
+        return McR.success(timeCardChangeService.upsert(request));
+    }
+
+    /** 提交变更审批,审批单工时字段保存变更后的目标结果。 */
+    @PostMapping("/change/submit")
+    public McR<java.util.Map<String, Object>> changeSubmit(
+            @RequestBody TimeCardRepairSubmitRequest request) {
+        return McR.success(timeCardChangeService.submit(request));
+    }
 }

+ 7 - 2
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/ApprovalWriteBackService.java

@@ -49,6 +49,9 @@ public class ApprovalWriteBackService {
     @Autowired
     private RepairApprovalWriteBackService repairApprovalWriteBackService;
 
+    @Autowired
+    private TimeCardChangeApprovalWriteBackService changeApprovalWriteBackService;
+
     private static final int THREAD_POOL_SIZE = 10;
 
     /**
@@ -150,13 +153,15 @@ public class ApprovalWriteBackService {
         McException.assertAccessException(approvalFormData == null || approvalFormData.isEmpty(),
                 "未查询到审批单数据,实例ID=" + formInstanceId);
 
-        // prd 补填审批只走独立回写:来源日写补填汇总,提交日写原汇总变更工时。
-        // 正常审批(工时类型为空/正常)继续执行下方原有逻辑。
+        // prd 补填和变更走各自独立回写;正常审批(工时类型为空/正常)继续执行下方原有逻辑。
         String fillType = whConf.getApprovalFillTypeField() == null
                 ? "" : String.valueOf(approvalFormData.get(whConf.getApprovalFillTypeField()));
         if ("补填".equals(fillType) && repairApprovalWriteBackService != null) {
             return repairApprovalWriteBackService.writeBack(formInstanceId, result);
         }
+        if ("变更".equals(fillType) && changeApprovalWriteBackService != null) {
+            return changeApprovalWriteBackService.writeBack(formInstanceId, result);
+        }
 
         // 2. 类型判定:不能只看项目工时子表;仅填非项目工时的工时审批也可能没有 AP_BIL_TABLE。
         boolean isOther = isOtherApprovalForm(approvalFormData);

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

@@ -125,13 +125,13 @@ public class MonthClosingService {
                 whConf.getFormUuidApproval(), APPROVAL_MONTH_FIELD, monthText, "COMPLETED"));
         List<Map> otherCompleted = normalApprovalProcesses(queryProcesses(
                 whConf.getFormUuidOtherApproval(), APPROVAL_MONTH_FIELD, monthText, "COMPLETED"));
-        projectRunning.addAll(repairApprovalProcesses(whConf.getFormUuidApproval(),
+        projectRunning.addAll(adjustmentApprovalProcesses(whConf.getFormUuidApproval(),
                 whConf.getApprovalRepairSubmitDateField(), monthText, "RUNNING"));
-        otherRunning.addAll(repairApprovalProcesses(whConf.getFormUuidOtherApproval(),
+        otherRunning.addAll(adjustmentApprovalProcesses(whConf.getFormUuidOtherApproval(),
                 whConf.getOtherApprovalRepairSubmitDateField(), monthText, "RUNNING"));
-        projectCompleted.addAll(repairApprovalProcesses(whConf.getFormUuidApproval(),
+        projectCompleted.addAll(adjustmentApprovalProcesses(whConf.getFormUuidApproval(),
                 whConf.getApprovalRepairSubmitDateField(), monthText, "COMPLETED"));
-        otherCompleted.addAll(repairApprovalProcesses(whConf.getFormUuidOtherApproval(),
+        otherCompleted.addAll(adjustmentApprovalProcesses(whConf.getFormUuidOtherApproval(),
                 whConf.getOtherApprovalRepairSubmitDateField(), monthText, "COMPLETED"));
 
         int writeBackPendingCount = countWriteBackPending(
@@ -156,22 +156,25 @@ public class MonthClosingService {
         String fillTypeField = whConf.getApprovalFillTypeField();
         for (Map item : processes) {
             Map data = extractFormData(item);
-            if (StringUtils.isBlank(fillTypeField) || !"补填".equals(text(data.get(fillTypeField)))) {
+            String fillType = text(data.get(fillTypeField));
+            if (StringUtils.isBlank(fillTypeField)
+                    || (!"补填".equals(fillType) && !"变更".equals(fillType))) {
                 result.add(item);
             }
         }
         return result;
     }
 
-    /** 补填审批按提交日期归属封账月份,来源月份仅用于定位被补录的工时。 */
-    private List<Map> repairApprovalProcesses(String formUuid, String submitDateField,
-                                               String monthText, String instanceStatus) {
+    /** 补填和变更审批按提交日期归属封账月份,目标月份只用于定位被调整的工时。 */
+    private List<Map> adjustmentApprovalProcesses(String formUuid, String submitDateField,
+                                                   String monthText, String instanceStatus) {
         McException.assertAccessException(StringUtils.isAnyBlank(
-                whConf.getApprovalFillTypeField(), submitDateField), "补填审批查询配置不完整");
+                whConf.getApprovalFillTypeField(), submitDateField), "补填/变更审批查询配置不完整");
         List<Map> result = new ArrayList<>();
         for (Map item : queryProcesses(formUuid, null, null, instanceStatus)) {
             Map data = extractFormData(item);
-            if ("补填".equals(text(data.get(whConf.getApprovalFillTypeField())))
+            String fillType = text(data.get(whConf.getApprovalFillTypeField()));
+            if (("补填".equals(fillType) || "变更".equals(fillType))
                     && monthText.equals(monthFromDateValue(data.get(submitDateField)))) {
                 result.add(item);
             }

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

@@ -469,13 +469,13 @@ public class ReSubmitApprovalService {
         Map<String, Object> formData = JSON.parseObject(
                 request.getFormDataJson(), Map.class);
         McException.assertAccessException(formData == null, "审批表单数据不能为空");
-        // prd 正常审批看填报月份,补填审批看提交月份;两者共用本发起入口。
+        // prd 正常审批看填报月份,补填/变更审批看提交月份;三者共用本发起入口。
         assertMonthOpen(approvalGuardMonth(request.getFormUuid(), formData));
         String fillTypeField = whConf.getApprovalFillTypeField();
-        boolean repair = StringUtils.isNotBlank(fillTypeField)
-                && "补填".equals(str(formData, fillTypeField));
-        String guardKey = repair ? null : approvalStartGuardKey(request, formData);
-        ApprovalStartGuard guard = repair ? null : acquireApprovalStartGuard(guardKey);
+        String fillType = StringUtils.isBlank(fillTypeField) ? "" : str(formData, fillTypeField);
+        boolean adjustment = "补填".equals(fillType) || "变更".equals(fillType);
+        String guardKey = adjustment ? null : approvalStartGuardKey(request, formData);
+        ApprovalStartGuard guard = adjustment ? null : acquireApprovalStartGuard(guardKey);
         if (guard != null && guard.completed) {
             log.info("[TimeCard审批发起] 相同请求已成功发起,跳过重复调用");
             return true;
@@ -1787,13 +1787,15 @@ public class ReSubmitApprovalService {
      * @return 需要校验封账的业务月份
      */
     private String approvalGuardMonth(String formUuid, Map<String, Object> formData) {
-        if (!"补填".equals(str(formData, whConf.getApprovalFillTypeField()))) {
+        String fillType = str(formData, whConf.getApprovalFillTypeField());
+        if (!"补填".equals(fillType) && !"变更".equals(fillType)) {
             return str(formData, A_MONTH_TEXT);
         }
         String submitDateField = whConf.getFormUuidApproval().equals(formUuid)
                 ? whConf.getApprovalRepairSubmitDateField()
                 : whConf.getOtherApprovalRepairSubmitDateField();
-        McException.assertAccessException(StringUtils.isBlank(submitDateField), "补填提交日期字段未配置");
+        McException.assertAccessException(StringUtils.isBlank(submitDateField),
+                "补填/变更提交日期字段未配置");
         return MonthClosingService.monthFromDateValue(formData.get(submitDateField));
     }
 

File diff suppressed because it is too large
+ 3 - 3
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/RepairApprovalWriteBackService.java


+ 703 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/TimeCardChangeApprovalWriteBackService.java

@@ -0,0 +1,703 @@
+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.ApprovalWriteBackResult;
+import com.malk.server.workhours.WHConf;
+import com.malk.service.aliwork.YDClient;
+import lombok.extern.slf4j.Slf4j;
+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.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.IntConsumer;
+
+/** 变更审批回写:共享汇总保存审批结果,正式汇总只累计变更工时。 */
+@Slf4j
+@Service
+public class TimeCardChangeApprovalWriteBackService {
+    private static final String CHANGE_TYPE = "变更";
+    private static final String SYNC_SUCCESS = "全部成功";
+    private static final String SYNC_PARTIAL = "部分失败";
+    private static final String SUMMARY_PARTIAL = "部分成功";
+    private static final double EPSILON = 1e-6d;
+    private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
+    private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("yyyyMMdd");
+    // fixme 不新增表单字段:锁和本地进度只覆盖当前服务进程内的并发与补偿重试。
+    private static final Object[] WRITE_BACK_LOCKS = createWriteBackLocks(128);
+    private static final ConcurrentHashMap<String, Integer> LOCAL_COMPLETED_STEPS =
+            new ConcurrentHashMap<>();
+
+    private static final String R_USER = "textField_mmbffvd9";
+    private static final String R_DAY = "textField_mmbffvdb";
+    private static final String R_MONTH = "textField_mmbffvda";
+    private static final String R_USER_EMPLOYEE = "employeeField_mmacxew4";
+    private static final String R_MONTH_DATE = "dateField_mmacxewf";
+    private static final String R_DAY_DATE = "dateField_mmbffvd8";
+    private static final String R_BILLABLE_TABLE = "tableField_mmczo634";
+    private static final String R_NON_BILLABLE_TABLE = "tableField_mmczo63h";
+    private static final String R_OTHER_TABLE = "tableField_mmeakgid";
+
+    private static final String ORIG_BILLABLE_CHANGE = "numberField_mmad6jbc";
+    private static final String ORIG_NON_BILLABLE_CHANGE = "numberField_mmeakgia";
+    private static final String ORIG_OTHER_CHANGE = "numberField_mmeakgic";
+    private static final String ORIG_DAILY_CHANGE = "numberField_mmeakgi5";
+    private static final String REQUIRED_USER = "employeeField_mmd8onl4";
+    private static final String REQUIRED_DAY = "dateField_mmd8onl5";
+    private static final String REQUIRED_HOURS = "numberField_mmd8onl6";
+
+    static final TableDef BILLABLE = new TableDef(
+            Category.BILLABLE, R_BILLABLE_TABLE, "textField_mmacxew3", "textField_mmad6jcj",
+            "numberField_mmczo636", "numberField_mmczo637", "numberField_mmd80zae");
+    static final TableDef NON_BILLABLE = new TableDef(
+            Category.NON_BILLABLE, R_NON_BILLABLE_TABLE, "textField_mmczo639", "textField_mmczo63g",
+            "numberField_mmczo63c", "numberField_mmczo63d", "numberField_mmeakgi9");
+    static final TableDef OTHER = new TableDef(
+            Category.OTHER, R_OTHER_TABLE, "selectField_mmeakgie", "",
+            "numberField_mmeakgij", "numberField_mmeakgii", "numberField_mmeakgim");
+
+    @Autowired private YDClient ydClient;
+    @Autowired private WHConf whConf;
+    @Autowired private WorkHoursCalcService workHoursCalcService;
+
+    /** 回写一张变更审批单;重复调用由审批单现有同步状态和步骤计数续跑。 */
+    public ApprovalWriteBackResult writeBack(String instanceId, int result) {
+        Map initialForm = queryProcessData(instanceId);
+        McException.assertAccessException(initialForm == null || initialForm.isEmpty(), "未查询到变更审批单");
+        boolean initialOther = RepairApprovalWriteBackService.isOtherApprovalForm(initialForm);
+        if (SYNC_SUCCESS.equals(text(initialForm.get(syncStatusField(initialOther))))) {
+            LOCAL_COMPLETED_STEPS.remove(instanceId);
+            return successResult(instanceId, result, 0, 0);
+        }
+        List<ChangeRow> initialRows = readRows(initialForm, initialOther, result == 1);
+        McException.assertAccessException(initialRows.isEmpty(), "变更审批单没有可回写明细");
+        String user = singleApprovalUser(initialRows);
+        synchronized (writeBackLock(user)) {
+            return writeBackLocked(instanceId, result);
+        }
+    }
+
+    private ApprovalWriteBackResult writeBackLocked(String instanceId, int result) {
+        Map form = queryProcessData(instanceId);
+        McException.assertAccessException(form == null || form.isEmpty(), "未查询到变更审批单");
+        boolean other = RepairApprovalWriteBackService.isOtherApprovalForm(form);
+        if (SYNC_SUCCESS.equals(text(form.get(syncStatusField(other))))) {
+            LOCAL_COMPLETED_STEPS.remove(instanceId);
+            return successResult(instanceId, result, 0, 0);
+        }
+
+        String batchField = other
+                ? whConf.getOtherApprovalRepairBatchField() : whConf.getApprovalRepairBatchField();
+        String submitDateField = other
+                ? whConf.getOtherApprovalRepairSubmitDateField() : whConf.getApprovalRepairSubmitDateField();
+        McException.assertAccessException(StringUtils.isAnyBlank(batchField, submitDateField),
+                "变更审批批次或提交日期字段未配置");
+        String batch = text(form.get(batchField));
+        long submitDate = (long) num(form.get(submitDateField));
+        McException.assertAccessException(StringUtils.isBlank(batch) || submitDate <= 0,
+                "变更审批缺少批次或提交日期");
+
+        List<ChangeRow> rows = readRows(form, other, result == 1);
+        McException.assertAccessException(rows.isEmpty(), "变更审批单没有可回写明细");
+        loadPersistedDeltas(rows, batch);
+        updateChangeSummaries(rows, batch, SUMMARY_PARTIAL);
+        resumeApprovedAdjustments(form, other, instanceId, submitDate, rows);
+        updateChangeSummaries(rows, batch, null);
+        writeApprovalSync(other, instanceId, SYNC_SUCCESS, rows.size(), 0);
+        LOCAL_COMPLETED_STEPS.remove(instanceId);
+        return successResult(instanceId, result, rows.size(), rows.size());
+    }
+
+    private List<ChangeRow> readRows(Map form, boolean other, boolean wholeApproved) {
+        List<ChangeRow> rows = new ArrayList<>();
+        if (other) {
+            rows.addAll(readTableRows(list(form.get("tableField_mmd1wu9h")), OTHER, wholeApproved));
+        } else {
+            rows.addAll(readTableRows(list(form.get("tableField_mmae8t99")), BILLABLE, wholeApproved));
+            rows.addAll(readTableRows(list(form.get("tableField_mmd1wu9h")), NON_BILLABLE, wholeApproved));
+        }
+        return rows;
+    }
+
+    private List<ChangeRow> readTableRows(List<Map> rawRows, TableDef table, boolean wholeApproved) {
+        List<ChangeRow> rows = new ArrayList<>();
+        for (Map raw : rawRows) {
+            ChangeRow row = new ChangeRow();
+            row.table = table;
+            boolean billable = table.category == Category.BILLABLE;
+            row.user = text(raw.get(billable ? "textField_mmd1ozk4" : "textField_mmd1wu9f"));
+            row.day = text(raw.get(billable ? "textField_mmd8g654" : "textField_mmd8g655"));
+            row.key = text(raw.get(table.category == Category.OTHER
+                    ? "selectField_mmeakgie"
+                    : (billable ? "textField_mmacxew3" : "textField_mmd1wu9d")));
+            row.name = text(raw.get(billable ? "textField_mkov5ozd" : "textField_mmd1wu9c"));
+            row.target = num(raw.get(billable ? "numberField_mmacxew9" : "numberField_mmd1wu9e"));
+            row.approved = RepairApprovalWriteBackService.isApprovedRow(raw, billable, wholeApproved);
+            McException.assertAccessException(StringUtils.isAnyBlank(row.user, row.day, row.key),
+                    "变更审批明细缺少人员、日期或项目/Activity");
+            rows.add(row);
+        }
+        return rows;
+    }
+
+    private void loadPersistedDeltas(List<ChangeRow> rows, String batch) {
+        Map<String, SummarySnapshot> snapshots = loadSummarySnapshots(rows, batch);
+        for (ChangeRow row : rows) {
+            SummarySnapshot snapshot = snapshots.get(summaryKey(row));
+            Map child = findRow(list(snapshot.data.get(row.table.tableField)), row.table.keyField, row.key);
+            McException.assertAccessException(child == null,
+                    "变更汇总明细不存在 day=" + row.day + ", key=" + row.key);
+            row.delta = num(child.get(row.table.changeField));
+        }
+    }
+
+    private Map<String, SummarySnapshot> loadSummarySnapshots(List<ChangeRow> rows, String batch) {
+        Map<String, SummarySnapshot> snapshots = new LinkedHashMap<>();
+        for (ChangeRow row : rows) {
+            String key = summaryKey(row);
+            if (snapshots.containsKey(key)) continue;
+            Map<String, Object> search = mapOf(R_USER, row.user, R_DAY, row.day,
+                    whConf.getRepairSummaryFillTypeField(), CHANGE_TYPE);
+            List<Map> found = query(whConf.getFormUuidRepairWorkHoursSummary(), search);
+            McException.assertAccessException(found.size() != 1,
+                    "变更汇总记录不存在或重复 userId=" + row.user + ", day=" + row.day);
+            String id = instanceId(found.get(0));
+            Map detail = detail(id);
+            McException.assertAccessException(!CHANGE_TYPE.equals(
+                            text(detail.get(whConf.getRepairSummaryFillTypeField())))
+                            || !batch.equals(text(detail.get(whConf.getRepairSummaryBatchField()))),
+                    "变更审批批次与汇总记录不一致 userId=" + row.user + ", day=" + row.day);
+            snapshots.put(key, new SummarySnapshot(id, detail));
+        }
+        return snapshots;
+    }
+
+    private void updateChangeSummaries(List<ChangeRow> rows, String batch, String forcedStatus) {
+        Map<String, List<ChangeRow>> grouped = groupBySummary(rows);
+        Map<String, SummarySnapshot> snapshots = loadSummarySnapshots(rows, batch);
+        for (Map.Entry<String, List<ChangeRow>> entry : grouped.entrySet()) {
+            SummarySnapshot snapshot = snapshots.get(entry.getKey());
+            Map<String, Object> update = buildChangeSummaryUpdate(snapshot.data, entry.getValue());
+            update.put(whConf.getRepairSummaryBatchField(), batch);
+            update.put(whConf.getRepairSummarySyncStatusField(), StringUtils.isBlank(forcedStatus)
+                    ? resolveSummaryStatus(update) : forcedStatus);
+            updateAndVerifySummary(snapshot.id, update, entry.getValue());
+        }
+    }
+
+    static Map<String, Object> buildChangeSummaryUpdate(Map detail, List<ChangeRow> rows) {
+        Map<String, Object> snapshot = new LinkedHashMap(detail);
+        Map<String, Object> update = new LinkedHashMap<>();
+        for (TableDef table : Arrays.asList(BILLABLE, NON_BILLABLE, OTHER)) {
+            List<Map> children = writableRows(detail.get(table.tableField));
+            for (ChangeRow row : rows) {
+                if (row.table.category != table.category) continue;
+                Map child = findRow(children, table.keyField, row.key);
+                McException.assertAccessException(child == null,
+                        "变更汇总子表行不存在 key=" + row.key);
+                if (row.approved) {
+                    child.put(table.approvedField, row.target);
+                    child.put(table.submittedField, row.target);
+                } else {
+                    child.put(table.submittedField, num(child.get(table.approvedField)));
+                    child.put(table.changeField, 0d);
+                }
+            }
+            update.put(table.tableField, children);
+            snapshot.put(table.tableField, children);
+        }
+        TimeCardRepairService.applySummaryTotals(update, snapshot);
+        return update;
+    }
+
+    private void resumeApprovedAdjustments(Map form, boolean other, String instanceId,
+                                           long submitDate, List<ChangeRow> rows) {
+        List<ChangeRow> approvedRows = new ArrayList<>();
+        Map<String, Double> targetDayDeltas = new LinkedHashMap<>();
+        for (ChangeRow row : rows) {
+            if (!row.approved || Math.abs(row.delta) <= EPSILON) continue;
+            approvedRows.add(row);
+            targetDayDeltas.merge(row.day, row.delta, Double::sum);
+        }
+        if (approvedRows.isEmpty()) return;
+
+        String submitDay = new Date(submitDate).toInstant().atZone(ZONE).toLocalDate().format(DAY);
+        Map<String, Double> requiredAdjustments = requiredAdjustments(targetDayDeltas, submitDay);
+        int totalSteps = 1 + requiredAdjustments.size();
+        int completedSteps = Math.max(resolveCompletedSteps(form, other, totalSteps),
+                localCompletedSteps(instanceId));
+        writeApprovalCheckpoint(other, instanceId, totalSteps, completedSteps);
+        int step = 0;
+        completedSteps = runStep(other, instanceId, totalSteps, completedSteps, step++,
+                () -> updateOriginalSubmissionDay(submitDay, rows.get(0).user, approvedRows));
+        for (Map.Entry<String, Double> adjustment : requiredAdjustments.entrySet()) {
+            String day = adjustment.getKey();
+            double delta = adjustment.getValue();
+            boolean targetDay = !submitDay.equals(day);
+            completedSteps = runStep(other, instanceId, totalSteps, completedSteps, step++,
+                    () -> adjustRequired(rows.get(0).user, day, delta, targetDay));
+        }
+    }
+
+    static Map<String, Double> requiredAdjustments(Map<String, Double> targetDayDeltas,
+                                                    String submitDay) {
+        Map<String, Double> adjustments = new LinkedHashMap<>();
+        double total = 0d;
+        for (Map.Entry<String, Double> entry : targetDayDeltas.entrySet()) {
+            adjustments.merge(entry.getKey(), -entry.getValue(), Double::sum);
+            total += entry.getValue();
+        }
+        adjustments.merge(submitDay, total, Double::sum);
+        adjustments.entrySet().removeIf(entry -> Math.abs(entry.getValue()) <= EPSILON);
+        return adjustments;
+    }
+
+    private int runStep(boolean other, String instanceId, int totalSteps, int completedSteps,
+                        int stepIndex, Runnable action) {
+        return runIdempotentStep(instanceId, completedSteps, stepIndex, action,
+                next -> writeApprovalCheckpoint(other, instanceId, totalSteps, next));
+    }
+
+    static int runIdempotentStep(String progressKey, int completedSteps, int stepIndex,
+                                 Runnable action, IntConsumer checkpointWriter) {
+        int effectiveCompleted = Math.max(completedSteps, localCompletedSteps(progressKey));
+        if (effectiveCompleted > stepIndex) {
+            checkpointWriter.accept(effectiveCompleted);
+            return effectiveCompleted;
+        }
+        action.run();
+        int next = stepIndex + 1;
+        LOCAL_COMPLETED_STEPS.merge(progressKey, next, Math::max);
+        checkpointWriter.accept(next);
+        return next;
+    }
+
+    static void clearLocalProgress(String progressKey) {
+        LOCAL_COMPLETED_STEPS.remove(progressKey);
+    }
+
+    private static int localCompletedSteps(String progressKey) {
+        return LOCAL_COMPLETED_STEPS.getOrDefault(progressKey, 0);
+    }
+
+    static Object writeBackLock(String user) {
+        return WRITE_BACK_LOCKS[Math.floorMod(user.hashCode(), WRITE_BACK_LOCKS.length)];
+    }
+
+    private static Object[] createWriteBackLocks(int size) {
+        Object[] locks = new Object[size];
+        for (int index = 0; index < size; index++) locks[index] = new Object();
+        return locks;
+    }
+
+    private static String singleApprovalUser(List<ChangeRow> rows) {
+        String user = rows.get(0).user;
+        for (ChangeRow row : rows) {
+            McException.assertAccessException(!user.equals(row.user),
+                    "变更审批明细包含多个填报人,停止回写");
+        }
+        return user;
+    }
+
+    private int resolveCompletedSteps(Map form, boolean other, int totalSteps) {
+        String status = text(form.get(syncStatusField(other)));
+        double total = num(form.get(syncTotalField(other)));
+        double remaining = num(form.get(syncFailField(other)));
+        return RepairApprovalWriteBackService.resolveCompletedSteps(status, total, remaining, totalSteps);
+    }
+
+    private void writeApprovalCheckpoint(boolean other, String instanceId,
+                                         int totalSteps, int completedSteps) {
+        writeApprovalSync(other, instanceId, SYNC_PARTIAL, totalSteps, totalSteps - completedSteps);
+    }
+
+    private void updateOriginalSubmissionDay(String submitDay, String user, List<ChangeRow> rows) {
+        List<Map> found = query(whConf.getFormUuidWorkHoursSummary(), mapOf(R_USER, user, R_DAY, submitDay));
+        Map<String, Object> update = new LinkedHashMap<>();
+        if (found.isEmpty()) {
+            update.put(R_USER, user);
+            update.put(R_DAY, submitDay);
+            update.put(R_MONTH, submitDay.substring(0, 6));
+            update.put(R_USER_EMPLOYEE, Collections.singletonList(user));
+            update.put(R_MONTH_DATE, parseDay(submitDay.substring(0, 6) + "01"));
+            update.put(R_DAY_DATE, parseDay(submitDay));
+            RepairApprovalWriteBackService.putDraftSummaryStatus(
+                    update, whConf.getSummaryApprovalStatusField());
+            update.put(R_BILLABLE_TABLE, deltaRows(rows, BILLABLE));
+            update.put(R_NON_BILLABLE_TABLE, deltaRows(rows, NON_BILLABLE));
+            update.put(R_OTHER_TABLE, deltaRows(rows, OTHER));
+            applyOriginalChangeTotals(update);
+            ydClient.operateData(YDParam.builder().appType(whConf.getYidaAppType())
+                    .systemToken(whConf.getYidaSystemToken())
+                    .formUuid(whConf.getFormUuidWorkHoursSummary())
+                    .formDataJson(JSON.toJSONString(update)).noExecuteExpression(true).build(),
+                    YDConf.FORM_OPERATION.create);
+            verifyOriginalSubmissionDay(user, submitDay, update);
+            return;
+        }
+        McException.assertAccessException(found.size() != 1, "原工时汇总提交日记录重复");
+        String id = instanceId(found.get(0));
+        Map detail = detail(id);
+        update.put(R_BILLABLE_TABLE,
+                mergeDeltaRows(list(detail.get(R_BILLABLE_TABLE)), rows, BILLABLE));
+        update.put(R_NON_BILLABLE_TABLE,
+                mergeDeltaRows(list(detail.get(R_NON_BILLABLE_TABLE)), rows, NON_BILLABLE));
+        update.put(R_OTHER_TABLE,
+                mergeDeltaRows(list(detail.get(R_OTHER_TABLE)), rows, OTHER));
+        applyOriginalChangeTotals(update);
+        ydClient.operateData(YDParam.builder().appType(whConf.getYidaAppType())
+                .systemToken(whConf.getYidaSystemToken()).formInstanceId(id)
+                .updateFormDataJson(JSON.toJSONString(update)).ignoreEmpty(false).useLatestVersion(true).build(),
+                YDConf.FORM_OPERATION.update);
+        verifyOriginalSubmissionDay(user, submitDay, update);
+    }
+
+    static List<Map> deltaRows(List<ChangeRow> rows, TableDef table) {
+        Map<String, Map> grouped = new LinkedHashMap<>();
+        for (ChangeRow row : rows) {
+            if (!row.approved || row.table.category != table.category
+                    || Math.abs(row.delta) <= EPSILON) continue;
+            Map child = grouped.computeIfAbsent(row.key, ignored -> new LinkedHashMap());
+            child.put(table.keyField, row.key);
+            if (StringUtils.isNotBlank(table.nameField)) child.put(table.nameField, row.name);
+            child.put(table.changeField, num(child.get(table.changeField)) + row.delta);
+        }
+        return new ArrayList<>(grouped.values());
+    }
+
+    static List<Map> mergeDeltaRows(List<Map> existing, List<ChangeRow> rows, TableDef table) {
+        List<Map> result = writableRows(existing);
+        for (Map addition : deltaRows(rows, table)) {
+            String key = text(addition.get(table.keyField));
+            Map target = findRow(result, table.keyField, key);
+            if (target == null) result.add(addition);
+            else target.put(table.changeField,
+                    num(target.get(table.changeField)) + num(addition.get(table.changeField)));
+        }
+        return result;
+    }
+
+    private void applyOriginalChangeTotals(Map<String, Object> update) {
+        double billable = sum(update.get(R_BILLABLE_TABLE), BILLABLE.changeField);
+        double nonBillable = sum(update.get(R_NON_BILLABLE_TABLE), NON_BILLABLE.changeField);
+        double other = sum(update.get(R_OTHER_TABLE), OTHER.changeField);
+        update.put(ORIG_BILLABLE_CHANGE, billable);
+        update.put(ORIG_NON_BILLABLE_CHANGE, nonBillable);
+        update.put(ORIG_OTHER_CHANGE, other);
+        update.put(ORIG_DAILY_CHANGE, billable + nonBillable + other);
+    }
+
+    private void adjustRequired(String user, String day, double delta, boolean targetDay) {
+        long timestamp = parseDay(day);
+        Map<String, Object> search = mapOf(REQUIRED_USER, Collections.singletonList(user),
+                REQUIRED_DAY, Arrays.asList(timestamp, timestamp + 86_400_000L - 1));
+        List<Map> found = query(whConf.getFormUuidRequiredHours(), search);
+        if (targetDay && found.isEmpty()) {
+            log.info("[变更审批回写] 目标日无应报工时记录,跳过调整 userId={} day={}", user, day);
+            return;
+        }
+        if (!targetDay && found.isEmpty()) {
+            Map<String, Object> created = workHoursCalcService.syncOneEmployeeOneDayForRepair(
+                    user, LocalDate.parse(day, DAY));
+            McException.assertAccessException(!Boolean.TRUE.equals(created.get("success")),
+                    "变更提交日应报工时创建失败 userId=" + user + ", day=" + day
+                            + ", error=" + text(created.get("error")));
+            found = query(whConf.getFormUuidRequiredHours(), search);
+        }
+        McException.assertAccessException(found.size() != 1,
+                "应报工时记录不存在或重复 userId=" + user + ", day=" + day);
+        Map item = formData(found.get(0));
+        double expected = num(item.get(REQUIRED_HOURS)) + delta;
+        McException.assertAccessException(expected < -EPSILON,
+                "调整后应报工时不能小于0 userId=" + user + ", day=" + day);
+        String id = instanceId(found.get(0));
+        ydClient.operateData(YDParam.builder().appType(whConf.getYidaAppType())
+                .systemToken(whConf.getYidaSystemToken()).formInstanceId(id)
+                .updateFormDataJson(JSON.toJSONString(mapOf(REQUIRED_HOURS, expected)))
+                .useLatestVersion(true).build(), YDConf.FORM_OPERATION.update);
+        Map actual = detail(id);
+        McException.assertAccessException(!sameHours(num(actual.get(REQUIRED_HOURS)), expected),
+                "应报工时回读不一致 userId=" + user + ", day=" + day);
+    }
+
+    private void updateAndVerifySummary(String id, Map<String, Object> update, List<ChangeRow> rows) {
+        ydClient.operateData(YDParam.builder().appType(whConf.getYidaAppType())
+                .systemToken(whConf.getYidaSystemToken()).formInstanceId(id)
+                .updateFormDataJson(JSON.toJSONString(update)).ignoreEmpty(false).useLatestVersion(true).build(),
+                YDConf.FORM_OPERATION.update);
+        Map actual = detail(id);
+        for (ChangeRow row : rows) {
+            Map child = findRow(list(actual.get(row.table.tableField)), row.table.keyField, row.key);
+            McException.assertAccessException(child == null
+                            || !sameHours(num(child.get(row.table.submittedField)),
+                            row.approved ? row.target : num(child.get(row.table.approvedField)))
+                            || (row.approved
+                            && !sameHours(num(child.get(row.table.approvedField)), row.target)),
+                    "变更汇总回读不一致 instanceId=" + id + ", key=" + row.key);
+        }
+        String statusField = whConf.getRepairSummarySyncStatusField();
+        McException.assertAccessException(!text(update.get(statusField)).equals(text(actual.get(statusField))),
+                "变更汇总状态回读不一致 instanceId=" + id);
+    }
+
+    private void verifyOriginalSubmissionDay(String user, String day, Map<String, Object> expected) {
+        List<Map> found = query(whConf.getFormUuidWorkHoursSummary(), mapOf(R_USER, user, R_DAY, day));
+        McException.assertAccessException(found.size() != 1,
+                "原工时汇总回读不存在或重复 userId=" + user + ", day=" + day);
+        Map actual = detail(instanceId(found.get(0)));
+        for (String field : Arrays.asList(ORIG_BILLABLE_CHANGE, ORIG_NON_BILLABLE_CHANGE,
+                ORIG_OTHER_CHANGE, ORIG_DAILY_CHANGE)) {
+            McException.assertAccessException(!sameHours(num(actual.get(field)), num(expected.get(field))),
+                    "原工时汇总变更工时回读不一致 field=" + field);
+        }
+        verifyOriginalTableRows(actual, expected, BILLABLE);
+        verifyOriginalTableRows(actual, expected, NON_BILLABLE);
+        verifyOriginalTableRows(actual, expected, OTHER);
+    }
+
+    private void verifyOriginalTableRows(Map actual, Map<String, Object> expected, TableDef table) {
+        for (Map expectedRow : list(expected.get(table.tableField))) {
+            String key = text(expectedRow.get(table.keyField));
+            Map actualRow = findRow(list(actual.get(table.tableField)), table.keyField, key);
+            McException.assertAccessException(actualRow == null
+                            || !sameHours(num(actualRow.get(table.changeField)),
+                            num(expectedRow.get(table.changeField)))
+                            || !sameHours(num(actualRow.get(table.submittedField)),
+                            num(expectedRow.get(table.submittedField)))
+                            || !sameHours(num(actualRow.get(table.approvedField)),
+                            num(expectedRow.get(table.approvedField))),
+                    "原工时汇总明细回读不一致 table=" + table.tableField + ", key=" + key);
+        }
+    }
+
+    private void writeApprovalSync(boolean other, String instanceId,
+                                   String status, int total, int failed) {
+        String statusField = syncStatusField(other);
+        String totalField = syncTotalField(other);
+        String failField = syncFailField(other);
+        if (StringUtils.isAnyBlank(statusField, totalField, failField)) return;
+        Map<String, Object> update = mapOf(statusField, status, totalField, total, failField, failed);
+        ydClient.operateData(YDParam.builder().appType(whConf.getYidaAppType())
+                .systemToken(whConf.getYidaSystemToken()).formInstanceId(instanceId)
+                .updateFormDataJson(JSON.toJSONString(update)).ignoreEmpty(false).useLatestVersion(true).build(),
+                YDConf.FORM_OPERATION.update);
+    }
+
+    private String syncStatusField(boolean other) {
+        return other ? whConf.getOtherApprovalSyncStatusField() : whConf.getApprovalSyncStatusField();
+    }
+
+    private String syncTotalField(boolean other) {
+        return other ? whConf.getOtherApprovalSyncTotalField() : whConf.getApprovalSyncTotalField();
+    }
+
+    private String syncFailField(boolean other) {
+        return other ? whConf.getOtherApprovalSyncFailField() : whConf.getApprovalSyncFailField();
+    }
+
+    private ApprovalWriteBackResult successResult(String instanceId, int result, int total, int updated) {
+        return ApprovalWriteBackResult.builder().formType("变更审批").formInstanceId(instanceId)
+                .result(result).detailRows(total).groups(total).syncStatus(SYNC_SUCCESS)
+                .syncTotal(total).failCount(0).hitRecords(updated).updatedRows(updated).build();
+    }
+
+    private Map queryProcessData(String id) {
+        try {
+            DDR_New response = ydClient.queryData(YDParam.builder().appType(whConf.getYidaAppType())
+                    .systemToken(whConf.getYidaSystemToken()).processInstanceId(id).build(),
+                    YDConf.FORM_QUERY.retrieve_search_process_id);
+            if (response != null && response.getFormData() != null
+                    && !response.getFormData().isEmpty()) return response.getFormData();
+        } catch (RuntimeException ignored) {
+            log.debug("流程详情查询失败,回退表单实例详情 instanceId={}", id);
+        }
+        return detail(id);
+    }
+
+    private List<Map> query(String formUuid, Map<String, Object> search) {
+        McException.assertAccessException(StringUtils.isBlank(formUuid), "工时表单未配置");
+        DDR_New response = ydClient.queryData(YDParam.builder().appType(whConf.getYidaAppType())
+                .systemToken(whConf.getYidaSystemToken()).formUuid(formUuid)
+                .searchFieldJson(JSON.toJSONString(search)).currentPage(1).pageSize(100).build(),
+                YDConf.FORM_QUERY.retrieve_search_form);
+        Object data = response == null ? null : response.getData();
+        McException.assertAccessException(!(data instanceof List), "查询工时数据失败");
+        List<Map> rows = (List<Map>) data;
+        McException.assertAccessException(rows.size() >= 100, "工时记录超过单次查询上限");
+        return rows;
+    }
+
+    private Map detail(String id) {
+        McException.assertAccessException(StringUtils.isBlank(id), "工时实例ID为空");
+        DDR_New response = ydClient.queryData(YDParam.builder().appType(whConf.getYidaAppType())
+                .systemToken(whConf.getYidaSystemToken()).formInstanceId(id).build(),
+                YDConf.FORM_QUERY.retrieve_id);
+        McException.assertAccessException(response == null || response.getFormData() == null,
+                "工时实例详情不存在 id=" + id);
+        return response.getFormData();
+    }
+
+    private static Map<String, List<ChangeRow>> groupBySummary(List<ChangeRow> rows) {
+        Map<String, List<ChangeRow>> grouped = new LinkedHashMap<>();
+        for (ChangeRow row : rows) {
+            grouped.computeIfAbsent(summaryKey(row), ignored -> new ArrayList<>()).add(row);
+        }
+        return grouped;
+    }
+
+    private static String summaryKey(ChangeRow row) {
+        return row.user + "\u0001" + row.day;
+    }
+
+    private static String resolveSummaryStatus(Map<String, Object> data) {
+        for (TableDef table : Arrays.asList(BILLABLE, NON_BILLABLE, OTHER)) {
+            for (Map child : list(data.get(table.tableField))) {
+                if (!sameHours(num(child.get(table.submittedField)),
+                        num(child.get(table.approvedField)))) return "审批中";
+            }
+        }
+        return SYNC_SUCCESS;
+    }
+
+    private static List<Map> writableRows(Object raw) {
+        List<Map> result = new ArrayList<>();
+        for (Map source : list(raw)) {
+            Map copy = new LinkedHashMap(source);
+            copy.keySet().removeIf(key -> text(key).startsWith("numberField_")
+                    && text(key).endsWith("_value"));
+            result.add(copy);
+        }
+        return result;
+    }
+
+    private static Map findRow(List<Map> rows, String keyField, String key) {
+        for (Map row : rows) if (key.equals(text(row.get(keyField)))) return row;
+        return null;
+    }
+
+    private static String instanceId(Map item) {
+        String id = text(item.get("formInstId"));
+        return StringUtils.isBlank(id) ? text(item.get("formInstanceId")) : id;
+    }
+
+    private static Map formData(Map item) {
+        Object data = item == null ? null : item.get("formData");
+        return data instanceof Map ? (Map) data : item;
+    }
+
+    private static double sum(Object rawRows, String field) {
+        double total = 0d;
+        for (Map row : list(rawRows)) total += num(row.get(field));
+        return total;
+    }
+
+    private static long parseDay(String day) {
+        return LocalDate.parse(day, DAY).atStartOfDay(ZONE).toInstant().toEpochMilli();
+    }
+
+    private static boolean sameHours(double left, double right) {
+        return Math.abs(left - right) <= EPSILON;
+    }
+
+    private static List<Map> list(Object raw) {
+        return raw instanceof List ? (List<Map>) raw : new ArrayList<>();
+    }
+
+    private static Map<String, Object> mapOf(Object... values) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        for (int index = 0; index < values.length; index += 2) {
+            result.put(String.valueOf(values[index]), values[index + 1]);
+        }
+        return result;
+    }
+
+    private static String text(Object value) {
+        return value == null ? "" : String.valueOf(value);
+    }
+
+    private static double num(Object value) {
+        try {
+            return value == null ? 0d : Double.parseDouble(String.valueOf(value));
+        } catch (NumberFormatException ex) {
+            return 0d;
+        }
+    }
+
+    enum Category {
+        BILLABLE,
+        NON_BILLABLE,
+        OTHER
+    }
+
+    static final class TableDef {
+        private final Category category;
+        private final String tableField;
+        private final String keyField;
+        private final String nameField;
+        private final String submittedField;
+        private final String approvedField;
+        private final String changeField;
+
+        private TableDef(Category category, String tableField, String keyField, String nameField,
+                         String submittedField, String approvedField, String changeField) {
+            this.category = category;
+            this.tableField = tableField;
+            this.keyField = keyField;
+            this.nameField = nameField;
+            this.submittedField = submittedField;
+            this.approvedField = approvedField;
+            this.changeField = changeField;
+        }
+    }
+
+    static final class ChangeRow {
+        private TableDef table;
+        private String user;
+        private String day;
+        private String key;
+        private String name;
+        private double target;
+        private double delta;
+        private boolean approved;
+
+        ChangeRow(TableDef table, String user, String day, String key, String name,
+                  double target, double delta, boolean approved) {
+            this.table = table;
+            this.user = user;
+            this.day = day;
+            this.key = key;
+            this.name = name;
+            this.target = target;
+            this.delta = delta;
+            this.approved = approved;
+        }
+
+        private ChangeRow() {
+        }
+    }
+
+    private static final class SummarySnapshot {
+        private final String id;
+        private final Map data;
+
+        private SummarySnapshot(String id, Map data) {
+            this.id = id;
+            this.data = data;
+        }
+    }
+}

+ 104 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/TimeCardChangePolicy.java

@@ -0,0 +1,104 @@
+package com.malk.service.workhours;
+
+import com.malk.server.workhours.MonthClosingStatusResult;
+import lombok.Builder;
+import lombok.Data;
+
+import java.time.LocalDate;
+import java.time.YearMonth;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.time.format.ResolverStyle;
+
+/** 变更发起准入策略:只以当前发起月封账状态作为月份门槛。 */
+public final class TimeCardChangePolicy {
+    private static final DateTimeFormatter DAY_FORMATTER =
+            DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT);
+    private static final DateTimeFormatter MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyyMM");
+
+    private TimeCardChangePolicy() {
+    }
+
+    /**
+     * 评估变更是否可发起。目标日期可为历史、当前或未来,不读取目标月封账状态。
+     *
+     * @param today              当前业务日期
+     * @param targetDayText      目标日期,yyyyMMdd
+     * @param currentMonthStatus 当前发起月的封账状态
+     * @param approvedDailyHours 目标日期正式汇总的已审批工时(日)
+     * @return 准入结果和目标日期类型
+     */
+    public static Availability evaluate(LocalDate today, String targetDayText,
+                                        MonthClosingStatusResult currentMonthStatus,
+                                        Double approvedDailyHours) {
+        if (today == null) {
+            throw new IllegalArgumentException("当前业务日期不能为空");
+        }
+        LocalDate targetDay = parseTargetDay(targetDayText);
+        String currentMonthText = YearMonth.from(today).format(MONTH_FORMATTER);
+        TargetPeriod targetPeriod = targetDay.isBefore(today)
+                ? TargetPeriod.HISTORY
+                : (targetDay.isAfter(today) ? TargetPeriod.FUTURE : TargetPeriod.CURRENT);
+
+        if (currentMonthStatus == null
+                || !currentMonthText.equals(currentMonthStatus.getMonthText())) {
+            return unavailable(currentMonthText, targetDayText, targetPeriod,
+                    "当前月封账状态查询失败");
+        }
+        if (currentMonthStatus.isClosed()) {
+            return unavailable(currentMonthText, targetDayText, targetPeriod,
+                    "当前月已封账,不可发起工时变更");
+        }
+        if (currentMonthStatus.getRunningClosingCount() > 0) {
+            return unavailable(currentMonthText, targetDayText, targetPeriod,
+                    "当前月封账或启封流程进行中,不可发起工时变更");
+        }
+        if (approvedDailyHours == null || !Double.isFinite(approvedDailyHours)
+                || approvedDailyHours <= 0d) {
+            return unavailable(currentMonthText, targetDayText, targetPeriod,
+                    "目标日期已审批工时(日)必须大于0,不能发起工时变更");
+        }
+        return Availability.builder()
+                .available(true)
+                .currentMonthText(currentMonthText)
+                .targetDayText(targetDayText)
+                .targetPeriod(targetPeriod)
+                .reason("")
+                .build();
+    }
+
+    private static Availability unavailable(String currentMonthText, String targetDayText,
+                                              TargetPeriod targetPeriod, String reason) {
+        return Availability.builder()
+                .available(false)
+                .currentMonthText(currentMonthText)
+                .targetDayText(targetDayText)
+                .targetPeriod(targetPeriod)
+                .reason(reason)
+                .build();
+    }
+
+    private static LocalDate parseTargetDay(String targetDayText) {
+        try {
+            return LocalDate.parse(targetDayText, DAY_FORMATTER);
+        } catch (DateTimeParseException | NullPointerException ex) {
+            throw new IllegalArgumentException("目标日期必须为有效的yyyyMMdd", ex);
+        }
+    }
+
+    public enum TargetPeriod {
+        HISTORY,
+        CURRENT,
+        FUTURE
+    }
+
+    @Data
+    @Builder
+    public static class Availability {
+        private boolean available;
+        private String currentMonthText;
+        private String targetDayText;
+        private TargetPeriod targetPeriod;
+        private String reason;
+    }
+}

+ 964 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/TimeCardChangeService.java

@@ -0,0 +1,964 @@
+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.MonthClosingCheckRequest;
+import com.malk.server.workhours.MonthClosingStatusResult;
+import com.malk.server.workhours.TimeCardApprovalStartRequest;
+import com.malk.server.workhours.TimeCardRepairSubmitRequest;
+import com.malk.server.workhours.TimeCardRepairSummaryUpsertRequest;
+import com.malk.server.workhours.WHConf;
+import com.malk.service.aliwork.YDClient;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.LocalDate;
+import java.time.YearMonth;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+
+/** TimeCard 变更专用服务;只操作共用汇总表中工时类型为“变更”的记录。 */
+@Slf4j
+@Service
+public class TimeCardChangeService {
+    private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
+    private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("yyyyMMdd");
+    private static final DateTimeFormatter MONTH = DateTimeFormatter.ofPattern("yyyyMM");
+    private static final String CHANGE_TYPE = "变更";
+    private static final String DEFAULT_MANAGER_ID = "634735129";
+    private static final double EPSILON = 1e-6d;
+    private static final long SUBMIT_GUARD_TTL_MILLIS = 30_000L;
+    private static final ConcurrentHashMap<String, SubmitGuard> SUBMIT_GUARDS = new ConcurrentHashMap<>();
+
+    private static final String R_USER = "textField_mmbffvd9";
+    private static final String R_DAY = "textField_mmbffvdb";
+    private static final String R_MONTH = "textField_mmbffvda";
+    private static final String R_USER_EMPLOYEE = "employeeField_mmacxew4";
+    private static final String R_MONTH_DATE = "dateField_mmacxewf";
+    private static final String R_DAY_DATE = "dateField_mmbffvd8";
+    private static final String R_DAILY_APPROVED = "numberField_mmeakgi4";
+    private static final String R_BILLABLE_TABLE = "tableField_mmczo634";
+    private static final String R_NON_BILLABLE_TABLE = "tableField_mmczo63h";
+    private static final String R_OTHER_TABLE = "tableField_mmeakgid";
+
+    private static final TableDef BILLABLE = new TableDef(
+            R_BILLABLE_TABLE, "textField_mmacxew3", "textField_mmad6jcj", "employeeField_mkoxpswf",
+            "numberField_mmczo635", "numberField_mmczo636", "numberField_mmczo637", "numberField_mmd80zae");
+    private static final TableDef NON_BILLABLE = new TableDef(
+            R_NON_BILLABLE_TABLE, "textField_mmczo639", "textField_mmczo63g", "employeeField_mmczo63a",
+            "numberField_mmczo63b", "numberField_mmczo63c", "numberField_mmczo63d", "numberField_mmeakgi9");
+    private static final TableDef OTHER = new TableDef(
+            R_OTHER_TABLE, "selectField_mmeakgie", "", "",
+            "numberField_mmeakgik", "numberField_mmeakgij", "numberField_mmeakgii", "numberField_mmeakgim");
+    private static final List<TableDef> TABLES = Arrays.asList(BILLABLE, NON_BILLABLE, OTHER);
+
+    @Autowired private YDClient ydClient;
+    @Autowired private WHConf whConf;
+    @Autowired private MonthClosingService monthClosingService;
+    @Autowired private ReSubmitApprovalService reSubmitApprovalService;
+
+    /** 保存变更草稿;共享表中的已审批工时始终保存当前最终结果。 */
+    public boolean upsert(TimeCardRepairSummaryUpsertRequest request) {
+        validateUpsertRequest(request);
+        Map original = loadEligibleOriginal(request.getSubmitterUserId(), request.getDayText());
+        List<Map> existing = queryChangeRecords(request.getSubmitterUserId(), null, request.getDayText());
+        McException.assertAccessException(existing.size() > 1, duplicateMessage(request));
+        Map current = existing.isEmpty() ? Collections.emptyMap() : detail(instanceId(existing.get(0)));
+        assertDraftMutable(current, request.getSubmitterUserId(), request.getDayText());
+        Map baseline = current.isEmpty() ? original : current;
+        Map<String, Object> data = buildDraftData(request.getFormData(), baseline, current.isEmpty(),
+                request.getSubmitterUserId(), request.getDayText());
+        upsertChangeSummary(request.getSubmitterUserId(), request.getDayText(), data);
+        return true;
+    }
+
+    /** 提交变更审批;审批单现有工时字段直接保存目标结果。 */
+    public Map<String, Object> submit(TimeCardRepairSubmitRequest request) {
+        McException.assertAccessException(request == null, "请求不能为空");
+        validateMonthRequest(request.getSubmitterUserId(), request.getSourceMonthText());
+        assertCurrentMonthOpen();
+        Map<String, Object> projectGroups = safeMap(request.getProjectGroups());
+        Map otherGroup = safeMap(request.getOtherGroup());
+        String guardKey = submitGuardKey(request, projectGroups, otherGroup);
+        SubmitGuard guard = acquireSubmitGuard(guardKey);
+        Map<String, Object> cached = guard.cachedResult();
+        if (cached != null) return cached;
+        try {
+            Map<String, Object> result = doSubmit(request, projectGroups, otherGroup);
+            guard.complete(result);
+            return result;
+        } catch (RuntimeException ex) {
+            SUBMIT_GUARDS.remove(guardKey, guard);
+            throw ex;
+        }
+    }
+
+    private Map<String, Object> doSubmit(TimeCardRepairSubmitRequest request,
+                                         Map<String, Object> projectGroups, Map otherGroup) {
+        String batch = UUID.randomUUID().toString();
+        LocalDate submitDay = LocalDate.now(ZONE);
+        long submitDate = submitDay.atStartOfDay(ZONE).toInstant().toEpochMilli();
+        int changedRows = prepareSubmittedRows(request, projectGroups, otherGroup, batch, submitDate);
+        McException.assertAccessException(changedRows == 0, "没有发生变化的工时");
+        startApprovals(request, projectGroups, otherGroup, batch, submitDate);
+        return mapOf("batch", batch, "submitDate", submitDate, "status", "审批中");
+    }
+
+    private int prepareSubmittedRows(TimeCardRepairSubmitRequest request,
+                                     Map<String, Object> projectGroups, Map otherGroup,
+                                     String batch, long submitDate) {
+        int changed = 0;
+        Iterator<Map.Entry<String, Object>> groups = projectGroups.entrySet().iterator();
+        while (groups.hasNext()) {
+            Map.Entry<String, Object> entry = groups.next();
+            Object rawGroup = entry.getValue();
+            if (!(rawGroup instanceof Map)) {
+                groups.remove();
+                continue;
+            }
+            Map group = new LinkedHashMap((Map) rawGroup);
+            entry.setValue(group);
+            String managerId = normalizeManagerId(text(group.get("managerId")));
+            List<Map> billableRows = prepareCategoryRows(request, list(group.get("billableRows")),
+                    BILLABLE, managerId, batch, submitDate);
+            List<Map> nonBillableRows = prepareCategoryRows(request, list(group.get("nonBillableRows")),
+                    NON_BILLABLE, managerId, batch, submitDate);
+            group.put("billableRows", billableRows);
+            group.put("nonBillableRows", nonBillableRows);
+            changed += billableRows.size() + nonBillableRows.size();
+            if (billableRows.isEmpty() && nonBillableRows.isEmpty()) groups.remove();
+        }
+        String managerId = normalizeManagerId(text(otherGroup.get("managerId")));
+        List<Map> otherRows = prepareCategoryRows(request, list(otherGroup.get("otherRows")),
+                OTHER, managerId, batch, submitDate);
+        otherGroup.put("otherRows", otherRows);
+        changed += otherRows.size();
+        if (otherRows.isEmpty()) otherGroup.clear();
+        return changed;
+    }
+
+    private List<Map> prepareCategoryRows(TimeCardRepairSubmitRequest request, List<Map> rows,
+                                          TableDef table, String managerId,
+                                          String batch, long submitDate) {
+        List<Map> changed = new ArrayList<>();
+        for (Map row : rows) {
+            if (prepareSubmittedRow(request, row, table, managerId, batch, submitDate)) {
+                changed.add(row);
+            }
+        }
+        return changed;
+    }
+
+    private boolean prepareSubmittedRow(TimeCardRepairSubmitRequest request, Map row,
+                                        TableDef table, String managerId,
+                                        String batch, long submitDate) {
+        String dayText = rowDay(row);
+        double target = validateTargetRow(request, row, table, dayText);
+        Map original = loadEligibleOriginal(request.getSubmitterUserId(), dayText);
+        List<Map> records = queryChangeRecords(request.getSubmitterUserId(), null, dayText);
+        McException.assertAccessException(records.size() != 1,
+                "变更汇总完整记录不存在或重复,请先保存后提交 userId="
+                        + request.getSubmitterUserId() + ", day=" + dayText);
+        String id = instanceId(records.get(0));
+        Map summary = detail(id);
+        assertDraftMutable(summary, request.getSubmitterUserId(), dayText, batch);
+        Map child = findRow(list(summary.get(table.tableField)), table.keyField, targetKey(row, table));
+        McException.assertAccessException(child == null, "变更汇总明细不存在 day=" + dayText
+                + ", key=" + targetKey(row, table));
+        double baseline = num(child.get(table.approvedField));
+        double delta = target - baseline;
+        if (Math.abs(delta) <= EPSILON) return false;
+        updateSubmittedSummary(request, summary, child, table, target, delta,
+                managerId, dayText, batch, submitDate, original, id);
+        return true;
+    }
+
+    private void updateSubmittedSummary(TimeCardRepairSubmitRequest request, Map summary,
+                                        Map child, TableDef table, double target, double delta,
+                                        String managerId, String dayText, String batch,
+                                        long submitDate, Map original, String id) {
+        List<Map> children = writableRows(summary.get(table.tableField));
+        Map writableChild = findRow(children, table.keyField, text(child.get(table.keyField)));
+        writableChild.put(table.submittedField, target);
+        writableChild.put(table.changeField, delta);
+        putManager(writableChild, table, managerId);
+        Map<String, Object> update = commonSubmittedFields(request, dayText, batch, submitDate, original);
+        update.put(table.tableField, children);
+        Map snapshot = new LinkedHashMap(summary);
+        snapshot.put(table.tableField, children);
+        TimeCardRepairService.applySummaryTotals(update, snapshot);
+        updateAndVerifySummary(id, update, table, text(child.get(table.keyField)), target, delta);
+    }
+
+    private Map<String, Object> commonSubmittedFields(TimeCardRepairSubmitRequest request,
+                                                      String dayText, String batch,
+                                                      long submitDate, Map original) {
+        Map<String, Object> update = new LinkedHashMap<>();
+        update.put(R_USER, request.getSubmitterUserId());
+        update.put(R_DAY, dayText);
+        update.put(R_MONTH, dayText.substring(0, 6));
+        update.put(whConf.getRepairSummaryFillTypeField(), CHANGE_TYPE);
+        update.put(whConf.getRepairSummarySyncStatusField(), "审批中");
+        update.put(whConf.getRepairSummarySubmitDateField(), submitDate);
+        update.put(whConf.getRepairSummaryBatchField(), batch);
+        update.put(whConf.getRepairSummarySourceMonthField(), request.getSourceMonthText());
+        update.put(R_USER_EMPLOYEE, Collections.singletonList(request.getSubmitterUserId()));
+        update.put(R_MONTH_DATE, parseDay(dayText.substring(0, 6) + "01"));
+        update.put(R_DAY_DATE, parseDay(dayText));
+        copyIfPresent(original, update, "employeeField_mh8xhqc3");
+        copyIfPresent(original, update, "textField_mmoazo1b");
+        return update;
+    }
+
+    private double validateTargetRow(TimeCardRepairSubmitRequest request, Map row,
+                                     TableDef table, String dayText) {
+        McException.assertAccessException(!dayText.matches("\\d{8}")
+                        || !dayText.startsWith(request.getSourceMonthText()),
+                "变更日期必须属于目标月份");
+        parseDay(dayText);
+        double target = num(row.get("hours"));
+        McException.assertAccessException(!Double.isFinite(target) || target < 0d || target > 24d,
+                "变更后工时必须在0到24小时之间");
+        McException.assertAccessException(StringUtils.isBlank(targetKey(row, table)),
+                table == OTHER ? "其他工时必须填写Activity" : "项目工时必须填写项目编号");
+        return target;
+    }
+
+    private Map<String, Object> buildDraftData(Map<String, Object> requested, Map baseline,
+                                               boolean originalBaseline, String userId,
+                                               String dayText) {
+        McException.assertAccessException(requested == null, "变更工时数据不能为空");
+        Map<String, Object> data = copyMainFields(requested);
+        for (TableDef table : TABLES) {
+            data.put(table.tableField, buildDraftRows(
+                    requested.get(table.tableField), baseline.get(table.tableField), table, originalBaseline));
+        }
+        TimeCardRepairService.applySummaryTotals(data, data);
+        data.put(R_USER, userId);
+        data.put(R_DAY, dayText);
+        data.put(R_MONTH, dayText.substring(0, 6));
+        data.put(R_USER_EMPLOYEE, Collections.singletonList(userId));
+        data.put(R_MONTH_DATE, parseDay(dayText.substring(0, 6) + "01"));
+        data.put(R_DAY_DATE, parseDay(dayText));
+        data.put(whConf.getRepairSummaryFillTypeField(), CHANGE_TYPE);
+        data.put(whConf.getRepairSummarySyncStatusField(), "暂存");
+        return data;
+    }
+
+    private List<Map> buildDraftRows(Object requestedRaw, Object baselineRaw,
+                                     TableDef table, boolean originalBaseline) {
+        Map<String, Map> baselines = indexRows(baselineRaw, table.keyField);
+        List<Map> result = new ArrayList<>();
+        for (Map requested : list(requestedRaw)) {
+            String key = text(requested.get(table.keyField));
+            if (StringUtils.isBlank(key)) continue;
+            Map source = baselines.get(key);
+            double approved = source == null ? 0d : num(source.get(table.approvedField));
+            if (originalBaseline && source != null) approved += num(source.get(table.changeField));
+            Map row = writableRow(requested);
+            mergeMissingMetadata(row, source, table);
+            row.put(table.draftField, num(requested.get(table.draftField)));
+            row.put(table.submittedField, approved);
+            row.put(table.approvedField, approved);
+            row.put(table.changeField, 0d);
+            result.add(row);
+        }
+        return result;
+    }
+
+    private Map<String, Object> copyMainFields(Map<String, Object> requested) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        for (Map.Entry<String, Object> entry : requested.entrySet()) {
+            String key = entry.getKey();
+            if (!key.startsWith("tableField_") && !key.startsWith("numberField_")
+                    && !key.endsWith("_value")) {
+                data.put(key, entry.getValue());
+            }
+        }
+        return data;
+    }
+
+    private void mergeMissingMetadata(Map target, Map source, TableDef table) {
+        if (source == null) return;
+        copyIfMissing(source, target, table.keyField);
+        copyIfMissing(source, target, table.nameField);
+        copyIfMissing(source, target, table.managerField);
+        for (String field : Arrays.asList("selectField_mmad6jci", "selectField_mmczo638",
+                "textField_mkov5oz9", "radioField_mkov5oz7", "textField_mmczo63e",
+                "radioField_mmczo63f")) {
+            copyIfMissing(source, target, field);
+        }
+    }
+
+    private void upsertChangeSummary(String userId, String dayText, Map<String, Object> data) {
+        Map<String, Object> search = mapOf(R_USER, userId, R_DAY, dayText,
+                whConf.getRepairSummaryFillTypeField(), CHANGE_TYPE);
+        ydClient.operateData(YDParam.builder().appType(whConf.getYidaAppType())
+                .systemToken(whConf.getYidaSystemToken())
+                .formUuid(whConf.getFormUuidRepairWorkHoursSummary())
+                .searchCondition(JSON.toJSONString(search)).formDataJson(JSON.toJSONString(data))
+                .noExecuteExpression(false).build(), YDConf.FORM_OPERATION.upsert);
+    }
+
+    private Map loadEligibleOriginal(String userId, String dayText) {
+        MonthClosingStatusResult status = currentMonthStatus();
+        List<Map> records = query(whConf.getFormUuidWorkHoursSummary(), mapOf(R_USER, userId, R_DAY, dayText));
+        McException.assertAccessException(records.size() != 1,
+                "目标日期正式工时汇总不存在或重复 userId=" + userId + ", day=" + dayText);
+        Map original = detail(instanceId(records.get(0)));
+        TimeCardChangePolicy.Availability availability = TimeCardChangePolicy.evaluate(
+                LocalDate.now(ZONE), dayText, status, nullableNumber(original.get(R_DAILY_APPROVED)));
+        McException.assertAccessException(!availability.isAvailable(), availability.getReason());
+        return original;
+    }
+
+    private MonthClosingStatusResult currentMonthStatus() {
+        String monthText = YearMonth.now(ZONE).format(MONTH);
+        return monthClosingService.status(MonthClosingCheckRequest.builder().monthText(monthText).build());
+    }
+
+    private void assertCurrentMonthOpen() {
+        MonthClosingStatusResult status = currentMonthStatus();
+        McException.assertAccessException(status == null || status.isClosed()
+                        || status.getRunningClosingCount() > 0,
+                "当前月份已封账或封账/启封流程审批中,不可发起工时变更");
+    }
+
+    private void validateUpsertRequest(TimeCardRepairSummaryUpsertRequest request) {
+        McException.assertAccessException(request == null, "请求不能为空");
+        String dayText = request.getDayText();
+        McException.assertAccessException(StringUtils.isBlank(dayText) || !dayText.matches("\\d{8}"),
+                "日期必须为yyyyMMdd");
+        parseDay(dayText);
+        validateMonthRequest(request.getSubmitterUserId(), dayText.substring(0, 6));
+        assertCurrentMonthOpen();
+        McException.assertAccessException(StringUtils.isAnyBlank(
+                whConf.getFormUuidWorkHoursSummary(), whConf.getFormUuidRepairWorkHoursSummary(),
+                whConf.getRepairSummaryFillTypeField(), whConf.getRepairSummarySyncStatusField()),
+                "变更汇总配置不完整");
+    }
+
+    private void assertDraftMutable(Map summary, String userId, String dayText) {
+        assertDraftMutable(summary, userId, dayText, null);
+    }
+
+    private void assertDraftMutable(Map summary, String userId, String dayText, String currentBatch) {
+        if (summary == null || summary.isEmpty()) return;
+        String status = text(summary.get(whConf.getRepairSummarySyncStatusField()));
+        boolean sameSubmittingBatch = "审批中".equals(status)
+                && StringUtils.isNotBlank(currentBatch)
+                && currentBatch.equals(text(summary.get(whConf.getRepairSummaryBatchField())));
+        McException.assertAccessException(("审批中".equals(status) && !sameSubmittingBatch)
+                        || "部分成功".equals(status),
+                "该日期工时变更正在审批或回写中,不能再次修改 userId=" + userId + ", day=" + dayText);
+    }
+
+    private List<Map> queryChangeRecords(String userId, String monthText, String dayText) {
+        Map<String, Object> search = mapOf(R_USER, userId,
+                whConf.getRepairSummaryFillTypeField(), CHANGE_TYPE);
+        if (StringUtils.isNotBlank(monthText)) search.put(R_MONTH, monthText);
+        if (StringUtils.isNotBlank(dayText)) search.put(R_DAY, dayText);
+        return query(whConf.getFormUuidRepairWorkHoursSummary(), search);
+    }
+
+    private void startApprovals(TimeCardRepairSubmitRequest request, Map<String, Object> groups,
+                                Map other, String batch, long submitDate) {
+        for (Object raw : groups.values()) {
+            if (!(raw instanceof Map)) continue;
+            Map group = (Map) raw;
+            startProjectApproval(request, group, batch, submitDate);
+        }
+        if (other != null && !list(other.get("otherRows")).isEmpty()) {
+            startOtherApproval(request, other, batch, submitDate);
+        }
+    }
+
+    private void startProjectApproval(TimeCardRepairSubmitRequest request, Map group,
+                                      String batch, long submitDate) {
+        Map<String, Object> data = approvalMainFields(request, batch, submitDate, false);
+        double billable = sumTargets(group.get("billableRows"));
+        double nonBillable = sumTargets(group.get("nonBillableRows"));
+        data.put("numberField_mmd2z155", billable);
+        data.put("numberField_mmd2z156", nonBillable);
+        data.put("numberField_mmd5b5gl", billable + nonBillable);
+        data.put("employeeField_mkoxpswf", Collections.singletonList(
+                normalizeManagerId(text(group.get("managerId")))));
+        data.put("tableField_mmae8t99", approvalRows(group.get("billableRows"), BILLABLE, request));
+        data.put("tableField_mmd1wu9h", approvalRows(group.get("nonBillableRows"), NON_BILLABLE, request));
+        startApproval(whConf.getFormUuidApproval(), whConf.getApprovalProcessCode(), data);
+    }
+
+    private void startOtherApproval(TimeCardRepairSubmitRequest request, Map other,
+                                    String batch, long submitDate) {
+        Map<String, Object> data = approvalMainFields(request, batch, submitDate, true);
+        data.put("numberField_mmd5b5gl", sumTargets(other.get("otherRows")));
+        data.put("employeeField_mh8xhqc3", Collections.singletonList(
+                normalizeManagerId(text(other.get("managerId")))));
+        data.put("tableField_mmd1wu9h", approvalRows(other.get("otherRows"), OTHER, request));
+        startApproval(whConf.getFormUuidOtherApproval(), whConf.getOtherApprovalProcessCode(), data);
+    }
+
+    private Map<String, Object> approvalMainFields(TimeCardRepairSubmitRequest request,
+                                                   String batch, long submitDate, boolean other) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("dateField_mmacxewf", parseDay(request.getSourceMonthText() + "01"));
+        data.put("employeeField_moi9pvt1", Collections.singletonList(request.getSubmitterUserId()));
+        data.put(whConf.getApprovalFillTypeField(), CHANGE_TYPE);
+        data.put(other ? whConf.getOtherApprovalRepairSubmitDateField()
+                : whConf.getApprovalRepairSubmitDateField(), submitDate);
+        data.put(other ? whConf.getOtherApprovalRepairBatchField()
+                : whConf.getApprovalRepairBatchField(), batch);
+        data.put(other ? whConf.getOtherApprovalRepairSourceMonthField()
+                : whConf.getApprovalRepairSourceMonthField(), request.getSourceMonthText());
+        data.put("textField_mmd2lv0y", request.getSourceMonthText());
+        return data;
+    }
+
+    private List<Map<String, Object>> approvalRows(Object raw, TableDef table,
+                                                   TimeCardRepairSubmitRequest request) {
+        List<Map<String, Object>> rows = new ArrayList<>();
+        for (Map row : list(raw)) {
+            String dayText = rowDay(row);
+            Map<String, Object> out = new LinkedHashMap<>();
+            boolean billable = table == BILLABLE;
+            out.put(billable ? "dateField_mmbffvd8" : "dateField_mmd2z150", parseDay(dayText));
+            out.put(billable ? "textField_mmd8g654" : "textField_mmd8g655", dayText);
+            out.put(billable ? "textField_mmd1ozk4" : "textField_mmd1wu9f", request.getSubmitterUserId());
+            out.put(billable ? "employeeField_mmacxew4" : "employeeField_mmd1wu9b",
+                    Collections.singletonList(request.getSubmitterUserId()));
+            out.put(billable ? "textField_mmdbi2n2" : "textField_mmdbi2n0", request.getSourceMonthText());
+            out.put(billable ? "numberField_mmacxew9" : "numberField_mmd1wu9e", num(row.get("hours")));
+            putApprovalIdentity(out, row, table);
+            out.put(table == NON_BILLABLE ? "radioField_mpwhk295" : "radioField_mpwhk294", "同意");
+            rows.add(out);
+        }
+        return rows;
+    }
+
+    private void putApprovalIdentity(Map<String, Object> out, Map row, TableDef table) {
+        if (table == BILLABLE) {
+            out.put("textField_mmacxew3", text(row.get("projectCode")));
+            out.put("textField_mkov5ozd", text(row.get("projectName")));
+        } else if (table == NON_BILLABLE) {
+            out.put("textField_mmd1wu9d", text(row.get("projectCode")));
+            out.put("textField_mmd1wu9c", text(row.get("projectName")));
+        } else {
+            out.put("selectField_mmeakgie", text(row.get("activityName")));
+        }
+    }
+
+    private void startApproval(String formUuid, String processCode, Map<String, Object> data) {
+        TimeCardApprovalStartRequest request = new TimeCardApprovalStartRequest();
+        request.setFormUuid(formUuid);
+        request.setProcessCode(processCode);
+        request.setFormDataJson(JSON.toJSONString(data));
+        reSubmitApprovalService.startApproval(request);
+    }
+
+    /** 变更审批补发:只扫描工时类型为“变更”的近期审批中记录。 */
+    public ChangeResubmitResult resubmitRecentlyModifiedPending(int lookbackDays) {
+        String[] window = modifiedDateWindow(lookbackDays);
+        List<Map> records = queryModified(whConf.getFormUuidRepairWorkHoursSummary(), window[0], window[1]);
+        Map<String, ChangeBatch> batches = buildPendingBatches(records);
+        int started = 0;
+        int failed = 0;
+        for (ChangeBatch batch : batches.values()) {
+            try {
+                Set<String> covered = loadApprovalCoverage(batch);
+                started += submitMissingApprovals(batch, covered);
+            } catch (RuntimeException ex) {
+                failed++;
+                log.error("[变更审批补发] 处理失败 batch={}", batch.batchId, ex);
+            }
+        }
+        return new ChangeResubmitResult(started, failed);
+    }
+
+    private Map<String, ChangeBatch> buildPendingBatches(List<Map> records) {
+        Map<String, ChangeBatch> batches = new LinkedHashMap<>();
+        for (Map item : records) {
+            Map data = formData(item);
+            if (!CHANGE_TYPE.equals(text(data.get(whConf.getRepairSummaryFillTypeField())))
+                    || !"审批中".equals(text(data.get(whConf.getRepairSummarySyncStatusField())))) continue;
+            ChangeBatch batch = pendingBatch(batches, data);
+            if (batch == null) continue;
+            collectPendingRows(batch, data, BILLABLE);
+            collectPendingRows(batch, data, NON_BILLABLE);
+            collectPendingRows(batch, data, OTHER);
+        }
+        return batches;
+    }
+
+    private ChangeBatch pendingBatch(Map<String, ChangeBatch> batches, Map data) {
+        String userId = text(data.get(R_USER));
+        String monthText = text(data.get(R_MONTH));
+        String batchId = text(data.get(whConf.getRepairSummaryBatchField()));
+        long submitDate = (long) num(data.get(whConf.getRepairSummarySubmitDateField()));
+        if (StringUtils.isAnyBlank(userId, monthText, batchId) || submitDate <= 0) return null;
+        String key = userId + "|" + monthText + "|" + batchId;
+        return batches.computeIfAbsent(key, ignored -> new ChangeBatch(
+                batchId, userId, text(data.get("textField_mmoazo1b")), monthText, submitDate));
+    }
+
+    private void collectPendingRows(ChangeBatch batch, Map data, TableDef table) {
+        for (Map child : list(data.get(table.tableField))) {
+            double submitted = num(child.get(table.submittedField));
+            double approved = num(child.get(table.approvedField));
+            if (Math.abs(submitted - approved) <= EPSILON) continue;
+            if (table == OTHER) {
+                String manager = normalizeManagerId(firstUserId(data, "employeeField_mh8xhqc3"));
+                batch.otherGroup.put("managerId", manager);
+                changeRows(batch.otherGroup, "otherRows").add(mapOf(
+                        "dateStr", text(data.get(R_DAY)), "activityName", text(child.get(table.keyField)),
+                        "hours", submitted));
+                continue;
+            }
+            String manager = normalizeManagerId(firstUserId(child, table.managerField));
+            Map<String, Object> group = changeGroup(batch, manager);
+            changeRows(group, table == BILLABLE ? "billableRows" : "nonBillableRows").add(mapOf(
+                    "dateStr", text(data.get(R_DAY)), "projectCode", text(child.get(table.keyField)),
+                    "projectName", text(child.get(table.nameField)), "hours", submitted));
+        }
+    }
+
+    private int submitMissingApprovals(ChangeBatch batch, Set<String> covered) {
+        removeCoveredRows(batch, covered);
+        if (batch.projectGroups.isEmpty() && batch.otherGroup.isEmpty()) return 0;
+        TimeCardRepairSubmitRequest request = new TimeCardRepairSubmitRequest();
+        request.setSourceMonthText(batch.monthText);
+        request.setSubmitterUserId(batch.userId);
+        request.setSubmitterName(batch.submitterName);
+        request.setProjectGroups(batch.projectGroups);
+        request.setOtherGroup(batch.otherGroup);
+        startApprovals(request, batch.projectGroups, batch.otherGroup, batch.batchId, batch.submitDate);
+        return batch.projectGroups.size() + (batch.otherGroup.isEmpty() ? 0 : 1);
+    }
+
+    private Set<String> loadApprovalCoverage(ChangeBatch batch) {
+        Set<String> covered = new HashSet<>();
+        Map<String, Object> search = mapOf(whConf.getApprovalFillTypeField(), CHANGE_TYPE,
+                "textField_mmd2lv0y", batch.monthText);
+        collectApprovalCoverage(queryProcesses(whConf.getFormUuidApproval(), search), false,
+                batch.batchId, covered);
+        collectApprovalCoverage(queryProcesses(whConf.getFormUuidOtherApproval(), search), true,
+                batch.batchId, covered);
+        return covered;
+    }
+
+    private void collectApprovalCoverage(List<Map> items, boolean other,
+                                         String batchId, Set<String> covered) {
+        for (Map item : items) {
+            String status = text(item.get("instanceStatus")).toUpperCase(Locale.ROOT);
+            if (!"RUNNING".equals(status) && !"COMPLETED".equals(status)) continue;
+            Map data = formData(item);
+            String approvalBatch = text(data.get(other
+                    ? whConf.getOtherApprovalRepairBatchField()
+                    : whConf.getApprovalRepairBatchField()));
+            if (!batchId.equals(approvalBatch)) continue;
+            if (other) {
+                addCoverage(covered, list(data.get("tableField_mmd1wu9h")), "other",
+                        "textField_mmd1wu9f", "textField_mmd8g655", "selectField_mmeakgie");
+            } else {
+                addCoverage(covered, list(data.get("tableField_mmae8t99")), "billable",
+                        "textField_mmd1ozk4", "textField_mmd8g654", "textField_mmacxew3");
+                addCoverage(covered, list(data.get("tableField_mmd1wu9h")), "nonBillable",
+                        "textField_mmd1wu9f", "textField_mmd8g655", "textField_mmd1wu9d");
+            }
+        }
+    }
+
+    private void addCoverage(Set<String> covered, List<Map> rows, String category,
+                             String userField, String dayField, String keyField) {
+        for (Map row : rows) {
+            covered.add(coverageKey(category, text(row.get(userField)),
+                    text(row.get(dayField)), text(row.get(keyField))));
+        }
+    }
+
+    private void removeCoveredRows(ChangeBatch batch, Set<String> covered) {
+        Iterator<Map.Entry<String, Object>> groups = batch.projectGroups.entrySet().iterator();
+        while (groups.hasNext()) {
+            Map group = (Map) groups.next().getValue();
+            removeCovered(batch.userId, list(group.get("billableRows")), "billable", covered);
+            removeCovered(batch.userId, list(group.get("nonBillableRows")), "nonBillable", covered);
+            if (list(group.get("billableRows")).isEmpty()
+                    && list(group.get("nonBillableRows")).isEmpty()) groups.remove();
+        }
+        removeCovered(batch.userId, list(batch.otherGroup.get("otherRows")), "other", covered);
+        if (list(batch.otherGroup.get("otherRows")).isEmpty()) batch.otherGroup.clear();
+    }
+
+    private void removeCovered(String userId, List<Map> rows, String category, Set<String> covered) {
+        rows.removeIf(row -> covered.contains(coverageKey(category, userId, rowDay(row),
+                "other".equals(category) ? text(row.get("activityName")) : text(row.get("projectCode")))));
+    }
+
+    private static String coverageKey(String category, String userId, String day, String key) {
+        return category + "|" + userId + "|" + day + "|" + key;
+    }
+
+    private void updateAndVerifySummary(String id, Map<String, Object> update, TableDef table,
+                                        String key, double submitted, double delta) {
+        ydClient.operateData(YDParam.builder().appType(whConf.getYidaAppType())
+                .systemToken(whConf.getYidaSystemToken()).formInstanceId(id)
+                .updateFormDataJson(JSON.toJSONString(update)).ignoreEmpty(false).useLatestVersion(true).build(),
+                YDConf.FORM_OPERATION.update);
+        Map actual = detail(id);
+        Map row = findRow(list(actual.get(table.tableField)), table.keyField, key);
+        McException.assertAccessException(row == null
+                        || !sameHours(num(row.get(table.submittedField)), submitted)
+                        || !sameHours(num(row.get(table.changeField)), delta),
+                "变更汇总提交回读不一致 instanceId=" + id + ", key=" + key);
+    }
+
+    private List<Map> query(String formUuid, Map<String, Object> search) {
+        McException.assertAccessException(StringUtils.isBlank(formUuid), "工时表单未配置");
+        DDR_New response = ydClient.queryData(YDParam.builder().appType(whConf.getYidaAppType())
+                .systemToken(whConf.getYidaSystemToken()).formUuid(formUuid)
+                .searchFieldJson(JSON.toJSONString(search)).currentPage(1).pageSize(100).build(),
+                YDConf.FORM_QUERY.retrieve_search_form);
+        McException.assertAccessException(response == null || !(response.getData() instanceof List),
+                "查询工时数据失败");
+        List<Map> records = (List<Map>) response.getData();
+        McException.assertAccessException(records.size() >= 100, "工时记录超过单次查询上限");
+        return records;
+    }
+
+    private Map detail(String id) {
+        McException.assertAccessException(StringUtils.isBlank(id), "工时实例ID为空");
+        DDR_New response = ydClient.queryData(YDParam.builder().appType(whConf.getYidaAppType())
+                .systemToken(whConf.getYidaSystemToken()).formInstanceId(id).build(),
+                YDConf.FORM_QUERY.retrieve_id);
+        McException.assertAccessException(response == null || response.getFormData() == null,
+                "工时实例详情不存在 id=" + id);
+        return response.getFormData();
+    }
+
+    private List<Map> queryModified(String formUuid, String fromDate, String toDate) {
+        List<Map> result = new ArrayList<>();
+        for (int page = 1; page <= 200; page++) {
+            Object raw = 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).getData();
+            List<Map> rows = raw instanceof List ? (List<Map>) raw : Collections.emptyList();
+            result.addAll(rows);
+            if (rows.size() < 100) break;
+        }
+        return result;
+    }
+
+    private List<Map> queryProcesses(String formUuid, Map<String, Object> search) {
+        List<Map> result = new ArrayList<>();
+        for (int page = 1; page <= 200; page++) {
+            Object raw = ydClient.queryData(YDParam.builder().appType(whConf.getYidaAppType())
+                    .systemToken(whConf.getYidaSystemToken()).formUuid(formUuid)
+                    .searchFieldJson(JSON.toJSONString(search)).pageNumber(page).pageSize(100).build(),
+                    YDConf.FORM_QUERY.retrieve_search_process).getData();
+            List<Map> rows = raw instanceof List ? (List<Map>) raw : Collections.emptyList();
+            result.addAll(rows);
+            if (rows.size() < 100) break;
+        }
+        return result;
+    }
+
+    private String[] modifiedDateWindow(int lookbackDays) {
+        int days = Math.max(1, lookbackDays);
+        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+        LocalDate today = LocalDate.now(ZONE);
+        return new String[]{today.minusDays(days - 1L).format(formatter), today.plusDays(1L).format(formatter)};
+    }
+
+    private String submitGuardKey(TimeCardRepairSubmitRequest request,
+                                  Map<String, Object> groups, Map other) {
+        String payload = request.getSubmitterUserId() + "|" + request.getSourceMonthText()
+                + "|" + JSON.toJSONString(groups) + "|" + JSON.toJSONString(other);
+        try {
+            byte[] digest = MessageDigest.getInstance("SHA-256")
+                    .digest(payload.getBytes(StandardCharsets.UTF_8));
+            StringBuilder hex = new StringBuilder(digest.length * 2);
+            for (byte value : digest) hex.append(String.format("%02x", value));
+            return hex.toString();
+        } catch (NoSuchAlgorithmException ex) {
+            throw new IllegalStateException("变更提交指纹计算失败", ex);
+        }
+    }
+
+    private SubmitGuard acquireSubmitGuard(String key) {
+        long now = System.currentTimeMillis();
+        synchronized (SUBMIT_GUARDS) {
+            SUBMIT_GUARDS.entrySet().removeIf(entry -> entry.getValue().expiresAt <= now);
+            SubmitGuard existing = SUBMIT_GUARDS.get(key);
+            if (existing != null) {
+                if (existing.result != null) return existing;
+                McException.assertAccessException(true, "变更提交正在处理中,请勿重复提交");
+            }
+            SubmitGuard guard = new SubmitGuard(now + SUBMIT_GUARD_TTL_MILLIS);
+            SUBMIT_GUARDS.put(key, guard);
+            return guard;
+        }
+    }
+
+    private void validateMonthRequest(String userId, String monthText) {
+        McException.assertAccessException(StringUtils.isAnyBlank(userId, monthText)
+                || !monthText.matches("\\d{6}"), "员工和月份不能为空且月份必须为yyyyMM");
+        try {
+            YearMonth.parse(monthText, MONTH);
+        } catch (RuntimeException ex) {
+            McException.assertAccessException(true, "月份必须为有效的yyyyMM");
+        }
+    }
+
+    private static Map<String, Map> indexRows(Object raw, String keyField) {
+        Map<String, Map> result = new LinkedHashMap<>();
+        for (Map row : list(raw)) {
+            String key = text(row.get(keyField));
+            if (StringUtils.isNotBlank(key)) result.put(key, row);
+        }
+        return result;
+    }
+
+    private static List<Map> writableRows(Object raw) {
+        List<Map> result = new ArrayList<>();
+        for (Map row : list(raw)) result.add(writableRow(row));
+        return result;
+    }
+
+    private static Map writableRow(Map source) {
+        Map result = new LinkedHashMap();
+        for (Object rawKey : source.keySet()) {
+            String key = text(rawKey);
+            if (key.endsWith("_value")) continue;
+            if (key.endsWith("_id") && key.startsWith("employeeField_")) {
+                result.put(key.substring(0, key.length() - 3), source.get(rawKey));
+            } else if (!key.startsWith("employeeField_") || source.get(key + "_id") == null) {
+                result.put(key, source.get(rawKey));
+            }
+        }
+        return result;
+    }
+
+    private static Map findRow(List<Map> rows, String keyField, String key) {
+        for (Map row : rows) if (key.equals(text(row.get(keyField)))) return row;
+        return null;
+    }
+
+    private static String targetKey(Map row, TableDef table) {
+        return table == OTHER ? text(row.get("activityName")) : text(row.get("projectCode"));
+    }
+
+    private static String rowDay(Map row) {
+        String day = text(row.get("dateStr"));
+        return StringUtils.isBlank(day) ? text(row.get("dayText")) : day;
+    }
+
+    private static void putManager(Map row, TableDef table, String managerId) {
+        if (table != OTHER && StringUtils.isNotBlank(managerId)) {
+            row.put(table.managerField, Collections.singletonList(managerId));
+        }
+    }
+
+    private static Map<String, Object> safeMap(Map<String, Object> value) {
+        return value == null ? new LinkedHashMap<>() : new LinkedHashMap<>(value);
+    }
+
+    private static void copyIfPresent(Map source, Map<String, Object> target, String field) {
+        if (source != null && source.get(field) != null) target.put(field, source.get(field));
+    }
+
+    private static void copyIfMissing(Map source, Map target, String field) {
+        if (StringUtils.isNotBlank(field) && target.get(field) == null && source.get(field) != null) {
+            target.put(field, source.get(field));
+        }
+    }
+
+    private static Map<String, Object> changeGroup(ChangeBatch batch, String managerId) {
+        return (Map<String, Object>) batch.projectGroups.computeIfAbsent(managerId, ignored -> {
+            Map<String, Object> group = new LinkedHashMap<>();
+            group.put("managerId", managerId);
+            group.put("billableRows", new ArrayList<Map>());
+            group.put("nonBillableRows", new ArrayList<Map>());
+            return group;
+        });
+    }
+
+    private static List<Map<String, Object>> changeRows(Map group, String key) {
+        return (List<Map<String, Object>>) group.computeIfAbsent(key, ignored -> new ArrayList<Map<String, Object>>());
+    }
+
+    private static String instanceId(Map item) {
+        String id = text(item.get("formInstId"));
+        return StringUtils.isBlank(id) ? text(item.get("formInstanceId")) : id;
+    }
+
+    private static Map formData(Map item) {
+        Object data = item == null ? null : item.get("formData");
+        if (!(data instanceof Map)) data = item == null ? null : item.get("data");
+        return data instanceof Map ? (Map) data : (item == null ? Collections.emptyMap() : item);
+    }
+
+    private static String firstUserId(Map data, String field) {
+        Object value = data == null ? null : data.get(field + "_id");
+        if (!(value instanceof List) || ((List) value).isEmpty()) value = data == null ? null : data.get(field);
+        if (value instanceof List && !((List) value).isEmpty()) return text(((List) value).get(0));
+        return text(value);
+    }
+
+    private static String normalizeManagerId(String managerId) {
+        return StringUtils.isBlank(managerId) ? DEFAULT_MANAGER_ID : managerId;
+    }
+
+    private static long parseDay(String dayText) {
+        try {
+            return LocalDate.parse(dayText, DAY).atStartOfDay(ZONE).toInstant().toEpochMilli();
+        } catch (RuntimeException ex) {
+            throw new IllegalArgumentException("日期必须为有效的yyyyMMdd", ex);
+        }
+    }
+
+    private static Double nullableNumber(Object value) {
+        if (value == null || StringUtils.isBlank(text(value))) return null;
+        try {
+            return Double.valueOf(text(value));
+        } catch (NumberFormatException ex) {
+            return null;
+        }
+    }
+
+    private static boolean sameHours(double left, double right) {
+        return Math.abs(left - right) <= EPSILON;
+    }
+
+    private static double sumTargets(Object raw) {
+        double total = 0d;
+        for (Map row : list(raw)) total += num(row.get("hours"));
+        return total;
+    }
+
+    private static String duplicateMessage(TimeCardRepairSummaryUpsertRequest request) {
+        return "变更汇总记录重复,已停止保存 userId=" + request.getSubmitterUserId()
+                + ", day=" + request.getDayText();
+    }
+
+    private static Map<String, Object> mapOf(Object... values) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        for (int i = 0; i < values.length; i += 2) result.put(String.valueOf(values[i]), values[i + 1]);
+        return result;
+    }
+
+    private static List<Map> list(Object raw) {
+        return raw instanceof List ? (List<Map>) raw : new ArrayList<>();
+    }
+
+    private static String text(Object value) {
+        return value == null ? "" : String.valueOf(value);
+    }
+
+    private static double num(Object value) {
+        try {
+            return value == null ? 0d : Double.parseDouble(String.valueOf(value));
+        } catch (NumberFormatException ex) {
+            return 0d;
+        }
+    }
+
+    private static final class TableDef {
+        private final String tableField;
+        private final String keyField;
+        private final String nameField;
+        private final String managerField;
+        private final String draftField;
+        private final String submittedField;
+        private final String approvedField;
+        private final String changeField;
+
+        private TableDef(String tableField, String keyField, String nameField, String managerField,
+                         String draftField, String submittedField, String approvedField, String changeField) {
+            this.tableField = tableField;
+            this.keyField = keyField;
+            this.nameField = nameField;
+            this.managerField = managerField;
+            this.draftField = draftField;
+            this.submittedField = submittedField;
+            this.approvedField = approvedField;
+            this.changeField = changeField;
+        }
+    }
+
+    private static final class SubmitGuard {
+        private final long expiresAt;
+        private Map<String, Object> result;
+
+        private SubmitGuard(long expiresAt) {
+            this.expiresAt = expiresAt;
+        }
+
+        private synchronized void complete(Map<String, Object> value) {
+            result = new LinkedHashMap<>(value);
+        }
+
+        private synchronized Map<String, Object> cachedResult() {
+            return result == null ? null : new LinkedHashMap<>(result);
+        }
+    }
+
+    private static final class ChangeBatch {
+        private final String batchId;
+        private final String userId;
+        private final String submitterName;
+        private final String monthText;
+        private final long submitDate;
+        private final Map<String, Object> projectGroups = new LinkedHashMap<>();
+        private final Map<String, Object> otherGroup = new LinkedHashMap<>();
+
+        private ChangeBatch(String batchId, String userId, String submitterName,
+                            String monthText, long submitDate) {
+            this.batchId = batchId;
+            this.userId = userId;
+            this.submitterName = submitterName;
+            this.monthText = monthText;
+            this.submitDate = submitDate;
+        }
+    }
+
+    public static final class ChangeResubmitResult {
+        private final int started;
+        private final int failed;
+
+        public ChangeResubmitResult(int started, int failed) {
+            this.started = started;
+            this.failed = failed;
+        }
+
+        public int getStarted() {
+            return started;
+        }
+
+        public int getFailed() {
+            return failed;
+        }
+    }
+}

+ 48 - 9
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/TimeCardRepairService.java

@@ -33,6 +33,7 @@ import java.util.concurrent.ConcurrentHashMap;
 public class TimeCardRepairService {
     private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
     private static final DateTimeFormatter MONTH = DateTimeFormatter.ofPattern("yyyyMM");
+    private static final String REPAIR_TYPE = "补填";
     private static final String DEFAULT_MANAGER_ID = "634735129";
     // 宜搭查询接口对复制表新增的 mokwy 字段返回 field is null;沿用复制表中可检索的原字段。
     private static final String R_SUBMITTER = "textField_mmbffvd9";
@@ -68,7 +69,7 @@ public class TimeCardRepairService {
         result.put("closed", monthClosingService.isClosed(request.getSourceMonthText()));
         result.put("originalRecords", query(whConf.getFormUuidWorkHoursSummary(),
                 mapOf("textField_mmbffvd9", request.getSubmitterUserId(), "textField_mmbffvda", request.getSourceMonthText())));
-        List<Map> repairRecords = query(whConf.getFormUuidRepairWorkHoursSummary(),
+        List<Map> repairRecords = queryRepairSummaries(
                 mapOf(R_SUBMITTER, request.getSubmitterUserId(), R_MONTH, request.getSourceMonthText()));
         result.put("repairRecords", repairRecords);
         // 兼容页面状态合并接口的 records 约定;原始双表结果仍通过 repairRecords 返回。
@@ -90,10 +91,11 @@ public class TimeCardRepairService {
         McException.assertAccessException(!monthClosingService.isClosed(monthText), "补填只能保存已封账月份");
         McException.assertAccessException(StringUtils.isBlank(whConf.getFormUuidWorkHoursSummary()), "原工时汇总表未配置");
         McException.assertAccessException(StringUtils.isBlank(whConf.getFormUuidRepairWorkHoursSummary()), "补填汇总表未配置");
+        McException.assertAccessException(StringUtils.isBlank(whConf.getRepairSummaryFillTypeField()), "补填汇总类型字段未配置");
         McException.assertAccessException(StringUtils.isBlank(whConf.getRepairSummarySyncStatusField()), "补填汇总状态字段未配置");
         McException.assertAccessException(request.getFormData() == null, "补填工时数据不能为空");
         assertSourceDateAvailable(request.getSubmitterUserId(), request.getDayText());
-        List<Map> existing = query(whConf.getFormUuidRepairWorkHoursSummary(),
+        List<Map> existing = queryRepairSummaries(
                 mapOf(R_SUBMITTER, request.getSubmitterUserId(), R_DAY, request.getDayText()));
         McException.assertAccessException(existing.size() > 1,
                 "补填汇总记录重复,已停止保存 userId=" + request.getSubmitterUserId() + ", day=" + request.getDayText());
@@ -115,11 +117,9 @@ public class TimeCardRepairService {
         data.put(R_SUBMITTER, request.getSubmitterUserId());
         data.put(R_DAY, request.getDayText());
         data.put(R_MONTH, request.getDayText().substring(0, 6));
+        data.put(whConf.getRepairSummaryFillTypeField(), REPAIR_TYPE);
         data.put(whConf.getRepairSummarySyncStatusField(), "暂存");
-        ydClient.operateData(YDParam.builder().appType(whConf.getYidaAppType())
-                .systemToken(whConf.getYidaSystemToken()).formUuid(whConf.getFormUuidRepairWorkHoursSummary())
-                .searchCondition(JSON.toJSONString(mapOf(R_SUBMITTER, request.getSubmitterUserId(), R_DAY, request.getDayText())))
-                .formDataJson(JSON.toJSONString(data)).noExecuteExpression(false).build(), YDConf.FORM_OPERATION.upsert);
+        saveRepairDraft(existing, data, request.getSubmitterUserId(), request.getDayText());
         return true;
     }
 
@@ -299,7 +299,7 @@ public class TimeCardRepairService {
         String day = text(row.get("dateStr"));
         if (StringUtils.isBlank(day)) day = text(row.get("dayText"));
         String key = day;
-        List<Map> found = query(whConf.getFormUuidRepairWorkHoursSummary(),
+        List<Map> found = queryRepairSummaries(
                 mapOf(R_SUBMITTER, request.getSubmitterUserId(), R_DAY, key));
         McException.assertAccessException(found.size() != 1, "补填汇总完整记录不存在或重复,请先保存后提交 userId="
                 + request.getSubmitterUserId() + ", day=" + key);
@@ -580,7 +580,7 @@ public class TimeCardRepairService {
     }
 
     private void assertRepairRowMutable(String userId, String day, Map row, String category) {
-        List<Map> existing = query(whConf.getFormUuidRepairWorkHoursSummary(), mapOf(R_SUBMITTER, userId, R_DAY, day));
+        List<Map> existing = queryRepairSummaries(mapOf(R_SUBMITTER, userId, R_DAY, day));
         McException.assertAccessException(existing.size() > 1, "补填汇总记录重复 userId=" + userId + ", day=" + day);
         if (existing.size() != 1) return;
         String id = text(existing.get(0).get("formInstId"));
@@ -616,6 +616,7 @@ public class TimeCardRepairService {
         Map<String, RepairBatch> batches = new LinkedHashMap<>();
         for (Map item : records) {
             Map fd = formData(item);
+            if (!isRepairSummaryRecord(fd, whConf.getRepairSummaryFillTypeField())) continue;
             if (!"审批中".equals(text(fd.get(whConf.getRepairSummarySyncStatusField())))) continue;
             String userId = text(fd.get(R_SUBMITTER));
             String monthText = text(fd.get(R_MONTH));
@@ -776,6 +777,32 @@ public class TimeCardRepairService {
         return all;
     }
 
+    private List<Map> queryRepairSummaries(Map<String, Object> search) {
+        List<Map> records = new ArrayList<>(query(whConf.getFormUuidRepairWorkHoursSummary(), search));
+        records.removeIf(record -> !isRepairSummaryRecord(record, whConf.getRepairSummaryFillTypeField()));
+        return records;
+    }
+
+    private void saveRepairDraft(List<Map> existing, Map<String, Object> data,
+                                 String userId, String dayText) {
+        YDParam.YDParamBuilder builder = YDParam.builder()
+                .appType(whConf.getYidaAppType())
+                .systemToken(whConf.getYidaSystemToken());
+        if (existing.isEmpty()) {
+            Map<String, Object> search = mapOf(R_SUBMITTER, userId, R_DAY, dayText,
+                    whConf.getRepairSummaryFillTypeField(), REPAIR_TYPE);
+            ydClient.operateData(builder.formUuid(whConf.getFormUuidRepairWorkHoursSummary())
+                    .searchCondition(JSON.toJSONString(search)).formDataJson(JSON.toJSONString(data))
+                    .noExecuteExpression(false).build(), YDConf.FORM_OPERATION.upsert);
+            return;
+        }
+        String instanceId = instanceId(existing.get(0));
+        McException.assertAccessException(StringUtils.isBlank(instanceId), "补填汇总实例ID为空");
+        ydClient.operateData(builder.formInstanceId(instanceId)
+                .updateFormDataJson(JSON.toJSONString(data)).ignoreEmpty(false).useLatestVersion(true).build(),
+                YDConf.FORM_OPERATION.update);
+    }
+
     private List<Map> queryProcesses(String formUuid, Map<String, Object> search) {
         List<Map> all = new ArrayList<>();
         for (int page = 1; page <= 200; page++) {
@@ -797,12 +824,24 @@ public class TimeCardRepairService {
                 LocalDate.now(ZONE).plusDays(1L).format(formatter)};
     }
 
-    private Map formData(Map item) {
+    static boolean isRepairSummaryRecord(Map item, String fillTypeField) {
+        if (StringUtils.isBlank(fillTypeField)) return true;
+        Map data = formData(item);
+        String fillType = text(data.get(fillTypeField));
+        return StringUtils.isBlank(fillType) || REPAIR_TYPE.equals(fillType);
+    }
+
+    private static Map formData(Map item) {
         Object fd = item == null ? null : item.get("formData");
         if (!(fd instanceof Map)) fd = item == null ? null : item.get("data");
         return fd instanceof Map ? (Map) fd : (item == null ? Collections.emptyMap() : item);
     }
 
+    private static String instanceId(Map item) {
+        String id = text(item == null ? null : item.get("formInstId"));
+        return StringUtils.isBlank(id) ? text(item == null ? null : item.get("formInstanceId")) : id;
+    }
+
     private String firstUserId(Map data, String field) {
         Object value = data == null ? null : data.get(field + "_id");
         if (!(value instanceof List) || ((List) value).isEmpty()) value = data == null ? null : data.get(field);

+ 4 - 1
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/TimeCardSummaryService.java

@@ -77,7 +77,10 @@ public class TimeCardSummaryService {
                 .stream().anyMatch(TimeCardSummaryService::isFormalRecordLocked);
         if (!approved && StringUtils.isNotBlank(whConf.getFormUuidRepairWorkHoursSummary())) {
             approved = queryDailyRecords(whConf.getFormUuidRepairWorkHoursSummary(), userId, dayText)
-                    .stream().anyMatch(record -> isRepairRecordLocked(record,
+                    .stream()
+                    .filter(record -> TimeCardRepairService.isRepairSummaryRecord(
+                            record, whConf.getRepairSummaryFillTypeField()))
+                    .anyMatch(record -> isRepairRecordLocked(record,
                             whConf.getRepairSummarySyncStatusField()));
         }
         McException.assertAccessException(approved,

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

@@ -5,6 +5,7 @@ import com.malk.server.workhours.WHConf;
 import com.malk.service.workhours.MonthClosingService;
 import com.malk.service.workhours.ReSubmitApprovalService;
 import com.malk.service.workhours.TimeCardRepairService;
+import com.malk.service.workhours.TimeCardChangeService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -49,6 +50,10 @@ public class ReSubmitApprovalTimer {
     @Autowired
     private TimeCardRepairService timeCardRepairService;
 
+    // prd 变更审批只扫描“工时类型=变更”的共用汇总记录。
+    @Autowired
+    private TimeCardChangeService timeCardChangeService;
+
     @Autowired
     private WHConf whConf;
 
@@ -151,6 +156,32 @@ public class ReSubmitApprovalTimer {
                     log.error("[补填审批补发] 本轮补试仍失败", ex);
                 }
             }
+            boolean changeScanFailed = false;
+            try {
+                if (timeCardChangeService == null) {
+                    log.debug("[变更审批补发] 服务未启用,跳过扫描");
+                } else {
+                    TimeCardChangeService.ChangeResubmitResult changeResult =
+                            timeCardChangeService.resubmitRecentlyModifiedPending(lookbackDays);
+                    totalCreated += changeResult.getStarted();
+                    totalFailed += changeResult.getFailed();
+                }
+            } catch (Exception ex) {
+                totalFailed++;
+                changeScanFailed = true;
+                log.error("[变更审批补发] 执行失败", ex);
+            }
+            if (changeScanFailed && timeCardChangeService != null) {
+                try {
+                    TimeCardChangeService.ChangeResubmitResult changeResult =
+                            timeCardChangeService.resubmitRecentlyModifiedPending(lookbackDays);
+                    totalCreated += changeResult.getStarted();
+                    totalFailed += changeResult.getFailed();
+                    totalFailed--;
+                } catch (Exception ex) {
+                    log.error("[变更审批补发] 本轮补试仍失败", ex);
+                }
+            }
             log.info("[补发起兜底] 完成 created={} updated={} failed={}",
                     totalCreated, totalUpdated, totalFailed);
         } catch (Exception e) {

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

@@ -28,6 +28,33 @@ import static org.mockito.Mockito.when;
 
 class ApprovalWriteBackServiceTest {
 
+    @Test
+    void shouldDispatchChangeApprovalWithoutEnteringNormalOrRepairWriteBack() {
+        YDClient client = mock(YDClient.class);
+        WHConf conf = new WHConf();
+        conf.setApprovalFillTypeField("fill-type");
+        Map<String, Object> approvalData = new HashMap<>();
+        approvalData.put("fill-type", "变更");
+        DDR_New<Object> response = new DDR_New<>();
+        response.setFormData(approvalData);
+        when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process_id)))
+                .thenReturn(response);
+        TimeCardChangeApprovalWriteBackService changeService =
+                mock(TimeCardChangeApprovalWriteBackService.class);
+        ApprovalWriteBackResult expected = ApprovalWriteBackResult.builder()
+                .formType("变更审批").build();
+        when(changeService.writeBack("approval-change", 1)).thenReturn(expected);
+        ApprovalWriteBackService service = new ApprovalWriteBackService();
+        ReflectionTestUtils.setField(service, "ydClient", client);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+        ReflectionTestUtils.setField(service, "changeApprovalWriteBackService", changeService);
+
+        ApprovalWriteBackResult actual = service.writeBack("approval-change", 1);
+
+        assertEquals(expected, actual);
+        verify(changeService).writeBack("approval-change", 1);
+    }
+
     @Test
     void shouldReportApprovalStatusWriteFailureToTimer() {
         YDClient client = mock(YDClient.class);

+ 31 - 1
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/MonthClosingServiceTest.java

@@ -260,6 +260,30 @@ class MonthClosingServiceTest {
         assertTrue(august.isAllowed());
     }
 
+    @Test
+    void shouldAssignRunningChangeApprovalToSubmitMonthInsteadOfTargetMonth() {
+        YDClient ydClient = mock(YDClient.class);
+        when(ydClient.queryData(any(YDParam.class), any(YDConf.FORM_QUERY.class)))
+                .thenAnswer(invocation -> {
+                    YDParam param = invocation.getArgument(0);
+                    if (PROJECT_FORM.equals(param.getFormUuid())
+                            && "RUNNING".equals(param.getInstanceStatus())) {
+                        return response(Collections.singletonList(
+                                adjustmentApproval(PROJECT_REPAIR_SUBMIT_DATE_FIELD,
+                                        "2026-10-07", "", "", "变更")));
+                    }
+                    return response(Collections.emptyList());
+                });
+        MonthClosingService service = service(ydClient);
+
+        MonthClosingValidationResult october = service.validate(request("202610"));
+        MonthClosingValidationResult december = service.validate(request("202612"));
+
+        assertFalse(october.isAllowed());
+        assertEquals(1, october.getProjectRunningCount());
+        assertTrue(december.isAllowed());
+    }
+
     @Test
     void shouldBlockClosingWhenOtherRepairApprovalAwaitsWriteBack() {
         YDClient ydClient = mock(YDClient.class);
@@ -366,9 +390,15 @@ class MonthClosingServiceTest {
     private static Map<String, Object> repairApproval(String submitDateField,
                                                        String submitDate, String result,
                                                        String syncStatus) {
+        return adjustmentApproval(submitDateField, submitDate, result, syncStatus, "补填");
+    }
+
+    private static Map<String, Object> adjustmentApproval(String submitDateField,
+                                                           String submitDate, String result,
+                                                           String syncStatus, String fillType) {
         Map<String, Object> formData = new HashMap<>();
         formData.put(APPROVAL_MONTH_FIELD, "202608");
-        formData.put(FILL_TYPE_FIELD, "补填");
+        formData.put(FILL_TYPE_FIELD, fillType);
         formData.put(submitDateField, submitDate);
         formData.put(SYNC_STATUS_FIELD, syncStatus);
         Map<String, Object> item = new HashMap<>();

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

@@ -288,6 +288,35 @@ class ReSubmitApprovalServiceTest {
         verifyNoInteractions(ydClient);
     }
 
+    @Test
+    void shouldBlockChangeApprovalBySubmitMonthWhileTargetMonthIsOpen() {
+        YDClient ydClient = mock(YDClient.class);
+        MonthClosingService monthClosingService = mock(MonthClosingService.class);
+        when(monthClosingService.isLocked("202610")).thenReturn(true);
+        WHConf whConf = new WHConf();
+        whConf.setFormUuidApproval("FORM-APPROVAL");
+        whConf.setApprovalProcessCode("PROCESS-APPROVAL");
+        whConf.setApprovalOriginatorUserId("system-user");
+        whConf.setApprovalFillTypeField("radioField_fill_type");
+        whConf.setApprovalRepairSubmitDateField("dateField_repair_submit");
+        ReSubmitApprovalService service = new ReSubmitApprovalService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", whConf);
+        ReflectionTestUtils.setField(service, "monthClosingService", monthClosingService);
+
+        TimeCardApprovalStartRequest request = new TimeCardApprovalStartRequest();
+        request.setFormUuid("FORM-APPROVAL");
+        request.setProcessCode("PROCESS-APPROVAL");
+        request.setFormDataJson("{\"textField_mmd2lv0y\":\"202612\","
+                + "\"radioField_fill_type\":\"变更\","
+                + "\"dateField_repair_submit\":\"2026-10-07\"}");
+
+        assertThrows(McException.class, () -> service.startApproval(request));
+        verify(monthClosingService).isLocked("202610");
+        verify(monthClosingService, org.mockito.Mockito.never()).isLocked("202612");
+        verifyNoInteractions(ydClient);
+    }
+
     @Test
     void shouldKeepExplicitRepairFillTypeWhenStartingApproval() {
         YDClient ydClient = mock(YDClient.class);

+ 222 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/TimeCardChangeApprovalWriteBackServiceTest.java

@@ -0,0 +1,222 @@
+package com.malk.service.workhours;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class TimeCardChangeApprovalWriteBackServiceTest {
+
+    @Test
+    void shouldSaveApprovalResultInSharedChangeSummary() {
+        Map<String, Object> approved = billable("P-001", 8d, 6d, 8d, -2d);
+        Map<String, Object> rejected = billable("P-002", 4d, 10d, 4d, 6d);
+        Map<String, Object> detail = summary(approved, rejected);
+        List<TimeCardChangeApprovalWriteBackService.ChangeRow> rows = Arrays.asList(
+                row("P-001", 6d, -2d, true),
+                row("P-002", 10d, 6d, false));
+
+        Map<String, Object> update =
+                TimeCardChangeApprovalWriteBackService.buildChangeSummaryUpdate(detail, rows);
+
+        List<Map> actual = (List<Map>) update.get("tableField_mmczo634");
+        assertEquals(6d, actual.get(0).get("numberField_mmczo636"));
+        assertEquals(6d, actual.get(0).get("numberField_mmczo637"));
+        assertEquals(-2d, actual.get(0).get("numberField_mmd80zae"));
+        assertEquals(4d, actual.get(1).get("numberField_mmczo636"));
+        assertEquals(4d, actual.get(1).get("numberField_mmczo637"));
+        assertEquals(0d, actual.get(1).get("numberField_mmd80zae"));
+        assertEquals(10d, update.get("numberField_mmeakgi4"));
+    }
+
+    @Test
+    void shouldOnlyAccumulateChangeHoursInOriginalSummaryRows() {
+        Map<String, Object> original = billable("P-001", 8d, 8d, 8d, 1d);
+        List<TimeCardChangeApprovalWriteBackService.ChangeRow> rows = Collections.singletonList(
+                row("P-001", 6d, -2d, true));
+
+        List<Map> merged = TimeCardChangeApprovalWriteBackService.mergeDeltaRows(
+                Collections.singletonList(original), rows,
+                TimeCardChangeApprovalWriteBackService.BILLABLE);
+
+        assertEquals(8d, merged.get(0).get("numberField_mmczo636"));
+        assertEquals(8d, merged.get(0).get("numberField_mmczo637"));
+        assertEquals(-1d, merged.get(0).get("numberField_mmd80zae"));
+    }
+
+    @Test
+    void shouldGroupNonBillableDeltasByProjectWithoutChangingApprovedHours() {
+        Map<String, Object> original = nonBillable("P-100", 4d, 4d, 0.5d);
+        List<TimeCardChangeApprovalWriteBackService.ChangeRow> rows = Arrays.asList(
+                row(TimeCardChangeApprovalWriteBackService.NON_BILLABLE,
+                        "P-100", 3d, -1d, true),
+                row(TimeCardChangeApprovalWriteBackService.NON_BILLABLE,
+                        "P-100", 7d, 3d, true),
+                row(TimeCardChangeApprovalWriteBackService.NON_BILLABLE,
+                        "P-100", 8d, 4d, false));
+
+        List<Map> merged = TimeCardChangeApprovalWriteBackService.mergeDeltaRows(
+                Collections.singletonList(original), rows,
+                TimeCardChangeApprovalWriteBackService.NON_BILLABLE);
+
+        assertEquals(1, merged.size());
+        assertEquals("P-100", merged.get(0).get("textField_mmczo639"));
+        assertEquals(4d, merged.get(0).get("numberField_mmczo63c"));
+        assertEquals(4d, merged.get(0).get("numberField_mmczo63d"));
+        assertEquals(2.5d, merged.get(0).get("numberField_mmeakgi9"));
+    }
+
+    @Test
+    void shouldGroupOtherDeltasByActivityWithoutChangingApprovedHours() {
+        Map<String, Object> original = other("Annual Leave", 8d, 8d, -1d);
+        List<TimeCardChangeApprovalWriteBackService.ChangeRow> rows = Arrays.asList(
+                row(TimeCardChangeApprovalWriteBackService.OTHER,
+                        "Annual Leave", 10d, 2d, true),
+                row(TimeCardChangeApprovalWriteBackService.OTHER,
+                        "Annual Leave", 7.5d, -0.5d, true),
+                row(TimeCardChangeApprovalWriteBackService.OTHER,
+                        "Annual Leave", 12d, 4d, false));
+
+        List<Map> merged = TimeCardChangeApprovalWriteBackService.mergeDeltaRows(
+                Collections.singletonList(original), rows,
+                TimeCardChangeApprovalWriteBackService.OTHER);
+
+        assertEquals(1, merged.size());
+        assertEquals("Annual Leave", merged.get(0).get("selectField_mmeakgie"));
+        assertEquals(8d, merged.get(0).get("numberField_mmeakgij"));
+        assertEquals(8d, merged.get(0).get("numberField_mmeakgii"));
+        assertEquals(0.5d, merged.get(0).get("numberField_mmeakgim"));
+    }
+
+    @Test
+    void shouldMoveNegativeDeltaFromTargetDayToSubmitDay() {
+        Map<String, Double> targetDeltas = new LinkedHashMap<>();
+        targetDeltas.put("20260818", -2d);
+
+        Map<String, Double> adjustments =
+                TimeCardChangeApprovalWriteBackService.requiredAdjustments(
+                        targetDeltas, "20261007");
+
+        assertEquals(2d, adjustments.get("20260818"));
+        assertEquals(-2d, adjustments.get("20261007"));
+    }
+
+    @Test
+    void shouldLeaveRequiredHoursUnchangedForSameDayNetZeroRedistribution() {
+        Map<String, Double> targetDeltas = new LinkedHashMap<>();
+        targetDeltas.put("20260818", 0d);
+
+        Map<String, Double> adjustments =
+                TimeCardChangeApprovalWriteBackService.requiredAdjustments(
+                        targetDeltas, "20261007");
+
+        assertTrue(adjustments.isEmpty());
+    }
+
+    @Test
+    void shouldKeepSignedLineDeltasForSubmissionDaySummary() {
+        List<TimeCardChangeApprovalWriteBackService.ChangeRow> rows = Arrays.asList(
+                row("P-001", 0d, -8d, true),
+                row("P-002", 8d, 8d, true));
+
+        List<Map> changes = TimeCardChangeApprovalWriteBackService.deltaRows(
+                rows, TimeCardChangeApprovalWriteBackService.BILLABLE);
+
+        assertEquals(2, changes.size());
+        assertEquals(-8d, changes.get(0).get("numberField_mmd80zae"));
+        assertEquals(8d, changes.get(1).get("numberField_mmd80zae"));
+    }
+
+    @Test
+    void shouldNotRepeatBusinessWriteWhenCheckpointRetryIsNeeded() {
+        String progressKey = "approval-retry-1";
+        int[] actionCount = {0};
+        int[] checkpointCount = {0};
+        TimeCardChangeApprovalWriteBackService.clearLocalProgress(progressKey);
+        try {
+            assertThrows(IllegalStateException.class,
+                    () -> TimeCardChangeApprovalWriteBackService.runIdempotentStep(
+                            progressKey, 0, 0, () -> actionCount[0]++, completed -> {
+                                checkpointCount[0]++;
+                                throw new IllegalStateException("temporary checkpoint failure");
+                            }));
+
+            int completed = TimeCardChangeApprovalWriteBackService.runIdempotentStep(
+                    progressKey, 0, 0, () -> actionCount[0]++, completedSteps -> checkpointCount[0]++);
+
+            assertEquals(1, actionCount[0]);
+            assertEquals(2, checkpointCount[0]);
+            assertEquals(1, completed);
+        } finally {
+            TimeCardChangeApprovalWriteBackService.clearLocalProgress(progressKey);
+        }
+    }
+
+    @Test
+    void shouldSerializeChangeWriteBackForTheSameUser() {
+        assertSame(TimeCardChangeApprovalWriteBackService.writeBackLock("user-1"),
+                TimeCardChangeApprovalWriteBackService.writeBackLock("user-1"));
+    }
+
+    private static TimeCardChangeApprovalWriteBackService.ChangeRow row(
+            String key, double target, double delta, boolean approved) {
+        return row(TimeCardChangeApprovalWriteBackService.BILLABLE,
+                key, target, delta, approved);
+    }
+
+    private static TimeCardChangeApprovalWriteBackService.ChangeRow row(
+            TimeCardChangeApprovalWriteBackService.TableDef table, String key,
+            double target, double delta, boolean approved) {
+        return new TimeCardChangeApprovalWriteBackService.ChangeRow(
+                table,
+                "user-1", "20260818", key, "Project " + key, target, delta, approved);
+    }
+
+    private static Map<String, Object> billable(String key, double draft, double submitted,
+                                                 double approved, double change) {
+        Map<String, Object> row = new HashMap<>();
+        row.put("textField_mmacxew3", key);
+        row.put("numberField_mmczo635", draft);
+        row.put("numberField_mmczo636", submitted);
+        row.put("numberField_mmczo637", approved);
+        row.put("numberField_mmd80zae", change);
+        return row;
+    }
+
+    private static Map<String, Object> nonBillable(String key, double submitted,
+                                                    double approved, double change) {
+        Map<String, Object> row = new HashMap<>();
+        row.put("textField_mmczo639", key);
+        row.put("numberField_mmczo63c", submitted);
+        row.put("numberField_mmczo63d", approved);
+        row.put("numberField_mmeakgi9", change);
+        return row;
+    }
+
+    private static Map<String, Object> other(String activity, double submitted,
+                                              double approved, double change) {
+        Map<String, Object> row = new HashMap<>();
+        row.put("selectField_mmeakgie", activity);
+        row.put("numberField_mmeakgij", submitted);
+        row.put("numberField_mmeakgii", approved);
+        row.put("numberField_mmeakgim", change);
+        return row;
+    }
+
+    private static Map<String, Object> summary(Map<String, Object>... billableRows) {
+        Map<String, Object> detail = new HashMap<>();
+        detail.put("tableField_mmczo634", Arrays.asList(billableRows));
+        detail.put("tableField_mmczo63h", Collections.emptyList());
+        detail.put("tableField_mmeakgid", Collections.emptyList());
+        return detail;
+    }
+}

+ 101 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/TimeCardChangePolicyTest.java

@@ -0,0 +1,101 @@
+package com.malk.service.workhours;
+
+import com.malk.server.workhours.MonthClosingStatusResult;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDate;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class TimeCardChangePolicyTest {
+    private static final LocalDate TODAY = LocalDate.of(2026, 10, 6);
+
+    @Test
+    void shouldAllowHistoryCurrentAndFutureWhenCurrentMonthIsOpen() {
+        MonthClosingStatusResult open = status(false, 0);
+
+        assertAllowed("20260831", TimeCardChangePolicy.TargetPeriod.HISTORY, open);
+        assertAllowed("20261006", TimeCardChangePolicy.TargetPeriod.CURRENT, open);
+        assertAllowed("20261101", TimeCardChangePolicy.TargetPeriod.FUTURE, open);
+    }
+
+    @Test
+    void shouldBlockEveryTargetPeriodWhenCurrentMonthIsClosed() {
+        TimeCardChangePolicy.Availability history =
+                TimeCardChangePolicy.evaluate(TODAY, "20260831", status(true, 0), 8d);
+        TimeCardChangePolicy.Availability future =
+                TimeCardChangePolicy.evaluate(TODAY, "20261101", status(true, 0), 8d);
+
+        assertFalse(history.isAvailable());
+        assertFalse(future.isAvailable());
+        assertEquals("当前月已封账,不可发起工时变更", history.getReason());
+    }
+
+    @Test
+    void shouldFailClosedWhenClosingStatusIsMissingOrMismatched() {
+        assertFalse(TimeCardChangePolicy.evaluate(TODAY, "20261101", null, 8d).isAvailable());
+        MonthClosingStatusResult wrongMonth = MonthClosingStatusResult.builder()
+                .monthText("202609")
+                .build();
+        assertFalse(TimeCardChangePolicy.evaluate(TODAY, "20261101", wrongMonth, 8d).isAvailable());
+    }
+
+    @Test
+    void shouldBlockWhenClosingOrReopeningProcessIsRunning() {
+        TimeCardChangePolicy.Availability result =
+                TimeCardChangePolicy.evaluate(TODAY, "20261101", status(false, 1), 8d);
+
+        assertFalse(result.isAvailable());
+        assertEquals("当前月封账或启封流程进行中,不可发起工时变更", result.getReason());
+    }
+
+    @Test
+    void shouldBlockWhenApprovedDailyHoursAreNotPositive() {
+        TimeCardChangePolicy.Availability zero =
+                TimeCardChangePolicy.evaluate(TODAY, "20260831", status(false, 0), 0d);
+        TimeCardChangePolicy.Availability negative =
+                TimeCardChangePolicy.evaluate(TODAY, "20260831", status(false, 0), -1d);
+        TimeCardChangePolicy.Availability missing =
+                TimeCardChangePolicy.evaluate(TODAY, "20260831", status(false, 0), null);
+        TimeCardChangePolicy.Availability nan =
+                TimeCardChangePolicy.evaluate(TODAY, "20260831", status(false, 0), Double.NaN);
+        TimeCardChangePolicy.Availability infinite =
+                TimeCardChangePolicy.evaluate(TODAY, "20260831", status(false, 0),
+                        Double.POSITIVE_INFINITY);
+
+        assertFalse(zero.isAvailable());
+        assertFalse(negative.isAvailable());
+        assertFalse(missing.isAvailable());
+        assertFalse(nan.isAvailable());
+        assertFalse(infinite.isAvailable());
+        assertEquals("目标日期已审批工时(日)必须大于0,不能发起工时变更", zero.getReason());
+    }
+
+    @Test
+    void shouldReportMonthLockBeforeApprovedHoursFailure() {
+        TimeCardChangePolicy.Availability result =
+                TimeCardChangePolicy.evaluate(TODAY, "20260831", status(true, 0), 0d);
+
+        assertFalse(result.isAvailable());
+        assertEquals("当前月已封账,不可发起工时变更", result.getReason());
+    }
+
+    private static void assertAllowed(String targetDay,
+                                      TimeCardChangePolicy.TargetPeriod targetPeriod,
+                                      MonthClosingStatusResult status) {
+        TimeCardChangePolicy.Availability result =
+                TimeCardChangePolicy.evaluate(TODAY, targetDay, status, 8d);
+        assertTrue(result.isAvailable());
+        assertEquals(targetPeriod, result.getTargetPeriod());
+    }
+
+    private static MonthClosingStatusResult status(boolean closed, int runningCount) {
+        return MonthClosingStatusResult.builder()
+                .monthText("202610")
+                .closed(closed)
+                .runningClosingCount(runningCount)
+                .build();
+    }
+}

+ 339 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/TimeCardChangeServiceTest.java

@@ -0,0 +1,339 @@
+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.MonthClosingCheckRequest;
+import com.malk.server.workhours.MonthClosingStatusResult;
+import com.malk.server.workhours.TimeCardApprovalStartRequest;
+import com.malk.server.workhours.TimeCardRepairSubmitRequest;
+import com.malk.server.workhours.TimeCardRepairSummaryUpsertRequest;
+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.time.YearMonth;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+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 TimeCardChangeServiceTest {
+    private static final String USER = "user-1";
+    private static final String DAY = "20260818";
+    private static final String NEXT_DAY = "20260819";
+
+    @Test
+    void shouldSaveZeroTargetAsChangeDraftWithoutTouchingRepairType() {
+        Fixture fixture = fixture();
+        when(fixture.client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(records(record("formal-1")), records());
+        when(fixture.client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_id)))
+                .thenReturn(detail(formalSummary(8d)));
+
+        TimeCardRepairSummaryUpsertRequest request = new TimeCardRepairSummaryUpsertRequest();
+        request.setSubmitterUserId(USER);
+        request.setDayText(DAY);
+        request.setFormData(summaryWithBillableRows(billable("P-001", 0d, 0d, 0d, 0d)));
+
+        assertTrue(fixture.service.upsert(request));
+
+        ArgumentCaptor<YDParam> captor = ArgumentCaptor.forClass(YDParam.class);
+        verify(fixture.client).operateData(captor.capture(), eq(YDConf.FORM_OPERATION.upsert));
+        Map<String, Object> condition = JSON.parseObject(captor.getValue().getSearchCondition(), Map.class);
+        Map<String, Object> written = JSON.parseObject(captor.getValue().getFormDataJson(), Map.class);
+        List<Map> rows = (List<Map>) written.get("tableField_mmczo634");
+        assertEquals("变更", condition.get("fill-type"));
+        assertEquals("变更", written.get("fill-type"));
+        assertEquals("暂存", written.get("sync-status"));
+        assertEquals(0d, ((Number) rows.get(0).get("numberField_mmczo635")).doubleValue());
+        assertEquals(8d, ((Number) rows.get(0).get("numberField_mmczo637")).doubleValue());
+        assertEquals(0d, ((Number) rows.get(0).get("numberField_mmd80zae")).doubleValue());
+    }
+
+    @Test
+    void shouldRejectChangeWhenFormalDailyApprovedHoursAreNotPositive() {
+        Fixture fixture = fixture();
+        when(fixture.client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(records(record("formal-1")));
+        when(fixture.client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_id)))
+                .thenReturn(detail(formalSummary(0d)));
+        TimeCardRepairSummaryUpsertRequest request = new TimeCardRepairSummaryUpsertRequest();
+        request.setSubmitterUserId(USER);
+        request.setDayText(DAY);
+        request.setFormData(summaryWithBillableRows(billable("P-001", 6d, 0d, 0d, 0d)));
+
+        McException error = assertThrows(McException.class, () -> fixture.service.upsert(request));
+
+        assertEquals("目标日期已审批工时(日)必须大于0,不能发起工时变更", error.getMessage());
+        verify(fixture.client, never()).operateData(any(YDParam.class), any(YDConf.FORM_OPERATION.class));
+    }
+
+    @Test
+    void shouldUseLatestApprovedChangeAsBaselineForRepeatedChange() {
+        Fixture fixture = fixture();
+        Map<String, Object> previousChange = summaryWithBillableRows(
+                billable("P-001", 6d, 6d, 6d, -2d));
+        previousChange.put("sync-status", "全部成功");
+        when(fixture.client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(records(record("formal-1")), records(record("change-1")));
+        when(fixture.client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_id)))
+                .thenReturn(detail(formalSummary(8d)), detail(previousChange));
+        TimeCardRepairSummaryUpsertRequest request = new TimeCardRepairSummaryUpsertRequest();
+        request.setSubmitterUserId(USER);
+        request.setDayText(DAY);
+        request.setFormData(summaryWithBillableRows(billable("P-001", 5d, 0d, 0d, 0d)));
+
+        assertTrue(fixture.service.upsert(request));
+
+        ArgumentCaptor<YDParam> captor = ArgumentCaptor.forClass(YDParam.class);
+        verify(fixture.client).operateData(captor.capture(), eq(YDConf.FORM_OPERATION.upsert));
+        Map<String, Object> written = JSON.parseObject(captor.getValue().getFormDataJson(), Map.class);
+        List<Map> rows = (List<Map>) written.get("tableField_mmczo634");
+        assertEquals(5d, ((Number) rows.get(0).get("numberField_mmczo635")).doubleValue());
+        assertEquals(6d, ((Number) rows.get(0).get("numberField_mmczo637")).doubleValue());
+        assertEquals(0d, ((Number) rows.get(0).get("numberField_mmd80zae")).doubleValue());
+    }
+
+    @Test
+    void shouldSubmitOnlyChangedRowsAndKeepZeroTarget() {
+        Fixture fixture = fixture();
+        Map<String, Object> baseline = summaryWithBillableRows(
+                billable("P-001", 8d, 8d, 8d, 0d),
+                billable("P-002", 4d, 4d, 4d, 0d));
+        baseline.put("sync-status", "暂存");
+        Map<String, Object> submitted = summaryWithBillableRows(
+                billable("P-001", 0d, 0d, 8d, -8d),
+                billable("P-002", 4d, 4d, 4d, 0d));
+        submitted.put("sync-status", "暂存");
+        when(fixture.client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(records(record("formal-1")), records(record("change-1")),
+                        records(record("formal-1")), records(record("change-1")));
+        when(fixture.client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_id)))
+                .thenReturn(detail(formalSummary(8d)), detail(baseline), detail(submitted),
+                        detail(formalSummary(8d)), detail(submitted));
+        Map<String, Object> group = new LinkedHashMap<>();
+        group.put("managerId", "manager-1");
+        group.put("billableRows", Arrays.asList(
+                changeRow("P-001", 0d), changeRow("P-002", 4d)));
+        group.put("nonBillableRows", Collections.emptyList());
+        Map<String, Object> groups = new LinkedHashMap<>();
+        groups.put("manager-1", group);
+        TimeCardRepairSubmitRequest request = new TimeCardRepairSubmitRequest();
+        request.setSubmitterUserId(USER);
+        request.setSubmitterName("Test User");
+        request.setSourceMonthText("202608");
+        request.setProjectGroups(groups);
+        request.setOtherGroup(Collections.emptyMap());
+
+        Map<String, Object> result = fixture.service.submit(request);
+
+        assertEquals("审批中", result.get("status"));
+        ArgumentCaptor<TimeCardApprovalStartRequest> approvalCaptor =
+                ArgumentCaptor.forClass(TimeCardApprovalStartRequest.class);
+        verify(fixture.approvalService).startApproval(approvalCaptor.capture());
+        Map<String, Object> approval = JSON.parseObject(
+                approvalCaptor.getValue().getFormDataJson(), Map.class);
+        List<Map> approvalRows = (List<Map>) approval.get("tableField_mmae8t99");
+        assertEquals("变更", approval.get("approval-fill-type"));
+        assertEquals(1, approvalRows.size());
+        assertEquals("P-001", approvalRows.get(0).get("textField_mmacxew3"));
+        assertEquals(0d, ((Number) approvalRows.get(0).get("numberField_mmacxew9")).doubleValue());
+    }
+
+    @Test
+    void shouldSubmitOppositeDayChangesWhenNetDeltaIsZero() {
+        Fixture fixture = fixture();
+        Map<String, Object> firstBaseline = summaryWithBillableRows(
+                billable("P-001", 0d, 8d, 8d, 0d));
+        firstBaseline.put("sync-status", "暂存");
+        Map<String, Object> firstSubmitted = summaryWithBillableRows(
+                billable("P-001", 0d, 0d, 8d, -8d));
+        firstSubmitted.put("sync-status", "审批中");
+        Map<String, Object> secondBaseline = summaryWithBillableRows(
+                billable("P-001", 8d, 8d, 8d, 0d),
+                billable("P-002", 0d, 0d, 0d, 0d));
+        secondBaseline.put("sync-status", "暂存");
+        Map<String, Object> secondSubmitted = summaryWithBillableRows(
+                billable("P-001", 8d, 8d, 8d, 0d),
+                billable("P-002", 8d, 8d, 0d, 8d));
+        secondSubmitted.put("sync-status", "审批中");
+        when(fixture.client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(records(record("formal-1")), records(record("change-1")),
+                        records(record("formal-2")), records(record("change-2")));
+        when(fixture.client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_id)))
+                .thenReturn(detail(formalSummary(8d)), detail(firstBaseline), detail(firstSubmitted),
+                        detail(formalSummary(8d)), detail(secondBaseline), detail(secondSubmitted));
+        Map<String, Object> group = new LinkedHashMap<>();
+        group.put("managerId", "manager-1");
+        group.put("billableRows", Arrays.asList(
+                changeRow(DAY, "P-001", 0d), changeRow(NEXT_DAY, "P-002", 8d)));
+        group.put("nonBillableRows", Collections.emptyList());
+        Map<String, Object> groups = new LinkedHashMap<>();
+        groups.put("manager-1", group);
+        TimeCardRepairSubmitRequest request = new TimeCardRepairSubmitRequest();
+        request.setSubmitterUserId(USER);
+        request.setSubmitterName("Test User");
+        request.setSourceMonthText("202608");
+        request.setProjectGroups(groups);
+        request.setOtherGroup(Collections.emptyMap());
+
+        Map<String, Object> result = fixture.service.submit(request);
+
+        assertEquals("审批中", result.get("status"));
+        ArgumentCaptor<TimeCardApprovalStartRequest> approvalCaptor =
+                ArgumentCaptor.forClass(TimeCardApprovalStartRequest.class);
+        verify(fixture.approvalService).startApproval(approvalCaptor.capture());
+        Map<String, Object> approval = JSON.parseObject(
+                approvalCaptor.getValue().getFormDataJson(), Map.class);
+        List<Map> approvalRows = (List<Map>) approval.get("tableField_mmae8t99");
+        assertEquals(2, approvalRows.size());
+        assertEquals(0d, ((Number) approvalRows.get(0).get("numberField_mmacxew9")).doubleValue());
+        assertEquals(8d, ((Number) approvalRows.get(1).get("numberField_mmacxew9")).doubleValue());
+    }
+
+    @Test
+    void shouldIgnoreRepairRowsDuringChangeResubmitScan() {
+        Fixture fixture = fixture();
+        Map<String, Object> repair = new LinkedHashMap<>();
+        repair.put("fill-type", "补填");
+        repair.put("sync-status", "审批中");
+        when(fixture.client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(records(Collections.singletonMap("formData", repair)));
+
+        TimeCardChangeService.ChangeResubmitResult result =
+                fixture.service.resubmitRecentlyModifiedPending(7);
+
+        assertEquals(0, result.getStarted());
+        assertEquals(0, result.getFailed());
+        verify(fixture.approvalService, never()).startApproval(any(TimeCardApprovalStartRequest.class));
+    }
+
+    private static Fixture fixture() {
+        YDClient client = mock(YDClient.class);
+        MonthClosingService closingService = mock(MonthClosingService.class);
+        ReSubmitApprovalService approvalService = mock(ReSubmitApprovalService.class);
+        when(closingService.status(any(MonthClosingCheckRequest.class)))
+                .thenReturn(MonthClosingStatusResult.builder()
+                        .monthText(currentMonth()).closed(false).runningClosingCount(0).build());
+        WHConf conf = new WHConf();
+        conf.setYidaAppType("APP");
+        conf.setYidaSystemToken("TOKEN");
+        conf.setFormUuidWorkHoursSummary("formal-summary");
+        conf.setFormUuidRepairWorkHoursSummary("shared-summary");
+        conf.setRepairSummaryFillTypeField("fill-type");
+        conf.setRepairSummarySyncStatusField("sync-status");
+        conf.setRepairSummarySubmitDateField("submit-date");
+        conf.setRepairSummaryBatchField("batch");
+        conf.setRepairSummarySourceMonthField("source-month");
+        conf.setApprovalFillTypeField("approval-fill-type");
+        conf.setFormUuidApproval("project-approval");
+        conf.setApprovalProcessCode("project-process");
+        conf.setApprovalRepairSubmitDateField("approval-submit-date");
+        conf.setApprovalRepairBatchField("approval-batch");
+        conf.setApprovalRepairSourceMonthField("approval-source-month");
+        conf.setFormUuidOtherApproval("other-approval");
+        conf.setOtherApprovalProcessCode("other-process");
+        conf.setOtherApprovalRepairSubmitDateField("other-submit-date");
+        conf.setOtherApprovalRepairBatchField("other-batch");
+        conf.setOtherApprovalRepairSourceMonthField("other-source-month");
+        TimeCardChangeService service = new TimeCardChangeService();
+        ReflectionTestUtils.setField(service, "ydClient", client);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+        ReflectionTestUtils.setField(service, "monthClosingService", closingService);
+        ReflectionTestUtils.setField(service, "reSubmitApprovalService", approvalService);
+        return new Fixture(service, client, approvalService);
+    }
+
+    private static String currentMonth() {
+        return YearMonth.now(ZoneId.of("Asia/Shanghai"))
+                .format(DateTimeFormatter.ofPattern("yyyyMM"));
+    }
+
+    private static Map<String, Object> formalSummary(double dailyApproved) {
+        Map<String, Object> result = summaryWithBillableRows(
+                billable("P-001", 0d, 8d, 8d, 0d),
+                billable("P-002", 0d, 4d, 4d, 0d));
+        result.put("numberField_mmeakgi4", dailyApproved);
+        return result;
+    }
+
+    private static Map<String, Object> summaryWithBillableRows(Map<String, Object>... rows) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("tableField_mmczo634", Arrays.asList(rows));
+        result.put("tableField_mmczo63h", Collections.emptyList());
+        result.put("tableField_mmeakgid", Collections.emptyList());
+        return result;
+    }
+
+    private static Map<String, Object> billable(String projectCode, double draft,
+                                                 double submitted, double approved, double change) {
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put("textField_mmacxew3", projectCode);
+        row.put("textField_mmad6jcj", "Project " + projectCode);
+        row.put("numberField_mmczo635", draft);
+        row.put("numberField_mmczo636", submitted);
+        row.put("numberField_mmczo637", approved);
+        row.put("numberField_mmd80zae", change);
+        return row;
+    }
+
+    private static Map<String, Object> changeRow(String projectCode, double hours) {
+        return changeRow(DAY, projectCode, hours);
+    }
+
+    private static Map<String, Object> changeRow(String day, String projectCode, double hours) {
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put("dateStr", day);
+        row.put("projectCode", projectCode);
+        row.put("projectName", "Project " + projectCode);
+        row.put("hours", hours);
+        return row;
+    }
+
+    private static Map<String, Object> record(String id) {
+        return Collections.singletonMap("formInstId", id);
+    }
+
+    private static DDR_New<Object> records(Map<String, Object>... items) {
+        DDR_New<Object> result = new DDR_New<>();
+        result.setData(Arrays.asList(items));
+        return result;
+    }
+
+    private static DDR_New<Object> detail(Map<String, Object> data) {
+        DDR_New<Object> result = new DDR_New<>();
+        result.setFormData(data);
+        return result;
+    }
+
+    private static final class Fixture {
+        private final TimeCardChangeService service;
+        private final YDClient client;
+        private final ReSubmitApprovalService approvalService;
+
+        private Fixture(TimeCardChangeService service, YDClient client,
+                        ReSubmitApprovalService approvalService) {
+            this.service = service;
+            this.client = client;
+            this.approvalService = approvalService;
+        }
+    }
+}

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

@@ -138,6 +138,7 @@ class TimeCardRepairServiceTest {
         WHConf conf = new WHConf();
         conf.setFormUuidWorkHoursSummary("original-summary");
         conf.setFormUuidRepairWorkHoursSummary("repair-summary");
+        conf.setRepairSummaryFillTypeField("fill-type");
         conf.setRepairSummarySyncStatusField("repair-status");
         when(closingService.isClosed("202608")).thenReturn(true);
         DDR_New<Object> noOriginal = new DDR_New<>();
@@ -175,6 +176,7 @@ class TimeCardRepairServiceTest {
         WHConf conf = new WHConf();
         conf.setFormUuidWorkHoursSummary("original-summary");
         conf.setFormUuidRepairWorkHoursSummary("repair-summary");
+        conf.setRepairSummaryFillTypeField("fill-type");
         conf.setRepairSummarySyncStatusField("repair-status");
         when(closingService.isClosed("202608")).thenReturn(true);
         DDR_New<Object> empty = new DDR_New<>();
@@ -199,12 +201,55 @@ class TimeCardRepairServiceTest {
         assertTrue(service.upsert(request));
         ArgumentCaptor<YDParam> captor = ArgumentCaptor.forClass(YDParam.class);
         verify(client).operateData(captor.capture(), eq(YDConf.FORM_OPERATION.upsert));
+        Map<String, Object> condition = JSON.parseObject(captor.getValue().getSearchCondition(), Map.class);
         Map<String, Object> written = JSON.parseObject(captor.getValue().getFormDataJson(), Map.class);
+        assertEquals("补填", condition.get("fill-type"));
         assertEquals(5d, ((Number) written.get("numberField_mmd1smel")).doubleValue());
         assertEquals(0d, ((Number) written.get("numberField_mmeakgi4")).doubleValue());
+        assertEquals("补填", written.get("fill-type"));
         assertEquals("暂存", written.get("repair-status"));
     }
 
+    @Test
+    void shouldIgnoreChangeRowsDuringRepairResubmitScan() {
+        YDClient client = mock(YDClient.class);
+        ReSubmitApprovalService approvalService = mock(ReSubmitApprovalService.class);
+        WHConf conf = new WHConf();
+        conf.setFormUuidRepairWorkHoursSummary("shared-summary");
+        conf.setRepairSummaryFillTypeField("fill-type");
+        conf.setRepairSummarySyncStatusField("sync-status");
+        Map<String, Object> change = new HashMap<>();
+        change.put("fill-type", "变更");
+        change.put("sync-status", "审批中");
+        Map<String, Object> record = new HashMap<>();
+        record.put("formData", change);
+        DDR_New<Object> summaries = new DDR_New<>();
+        summaries.setData(Collections.singletonList(record));
+        when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(summaries);
+        TimeCardRepairService service = new TimeCardRepairService();
+        ReflectionTestUtils.setField(service, "ydClient", client);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+        ReflectionTestUtils.setField(service, "reSubmitApprovalService", approvalService);
+
+        TimeCardRepairService.RepairResubmitResult result =
+                service.resubmitRecentlyModifiedPending(7);
+
+        assertEquals(0, result.getStarted());
+        assertEquals(0, result.getFailed());
+        verifyNoInteractions(approvalService);
+    }
+
+    @Test
+    void shouldTreatBlankLegacyTypeAndRepairTypeAsRepairOnly() {
+        assertTrue(TimeCardRepairService.isRepairSummaryRecord(
+                Collections.emptyMap(), "fill-type"));
+        assertTrue(TimeCardRepairService.isRepairSummaryRecord(
+                Collections.singletonMap("fill-type", "补填"), "fill-type"));
+        assertFalse(TimeCardRepairService.isRepairSummaryRecord(
+                Collections.singletonMap("fill-type", "变更"), "fill-type"));
+    }
+
     @Test
     void shouldReportRepairBatchStillFailedAfterDeferredRetry() {
         YDClient client = mock(YDClient.class);
@@ -213,6 +258,7 @@ class TimeCardRepairServiceTest {
         conf.setFormUuidRepairWorkHoursSummary("repair-summary");
         conf.setFormUuidApproval("bill-approval");
         conf.setFormUuidOtherApproval("other-approval");
+        conf.setRepairSummaryFillTypeField("fill-type");
         conf.setRepairSummarySyncStatusField("repair-status");
         conf.setRepairSummaryBatchField("repair-batch");
         conf.setRepairSummarySubmitDateField("repair-submit-date");
@@ -225,6 +271,7 @@ class TimeCardRepairServiceTest {
         otherRow.put("numberField_mmeakgii", 0d);
         otherRow.put("selectField_mmeakgie", "培训");
         Map<String, Object> formData = new HashMap<>();
+        formData.put("fill-type", "补填");
         formData.put("repair-status", "审批中");
         formData.put("repair-batch", "batch-1");
         formData.put("repair-submit-date", 1787000000000L);

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

@@ -123,6 +123,37 @@ class TimeCardSummaryServiceTest {
         verify(ydClient, never()).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.upsert));
     }
 
+    @Test
+    void shouldIgnoreChangeRecordWhenCheckingRepairConflict() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf whConf = new WHConf();
+        whConf.setFormUuidWorkHoursSummary("FORM-SUMMARY");
+        whConf.setFormUuidRepairWorkHoursSummary("FORM-ADJUSTMENT");
+        whConf.setRepairSummaryFillTypeField("fill-type");
+        whConf.setRepairSummarySyncStatusField("repair-status");
+        TimeCardSummaryService service = new TimeCardSummaryService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", whConf);
+        DDR_New<Object> empty = new DDR_New<>();
+        empty.setData(Collections.emptyList());
+        Map<String, Object> change = summaryItem("CHANGE-ID", "user-1", "20260716");
+        Map<String, Object> changeData = (Map<String, Object>) change.get("formData");
+        changeData.put("fill-type", "变更");
+        changeData.put("repair-status", "审批中");
+        DDR_New<Object> shared = new DDR_New<>();
+        shared.setData(Collections.singletonList(change));
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(empty, shared);
+        TimeCardSummaryUpsertRequest request = new TimeCardSummaryUpsertRequest();
+        request.setSubmitterUserId("user-1");
+        request.setDayText("20260716");
+        request.setFormData(Collections.singletonMap("numberField_mmd1smel", 8));
+
+        assertTrue(service.upsert(request));
+
+        verify(ydClient).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.upsert));
+    }
+
     @Test
     void shouldLockWholeFormalDayWhenOneChildIsSubmitted() {
         Map<String, Object> record = summaryItem("SUBMITTED-ID", "user-1", "20260716");

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

@@ -5,6 +5,7 @@ import com.malk.server.workhours.WHConf;
 import com.malk.service.workhours.MonthClosingService;
 import com.malk.service.workhours.ReSubmitApprovalService;
 import com.malk.service.workhours.TimeCardRepairService;
+import com.malk.service.workhours.TimeCardChangeService;
 import org.junit.jupiter.api.Test;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.test.util.ReflectionTestUtils;
@@ -26,6 +27,32 @@ import org.mockito.InOrder;
 
 class ReSubmitApprovalTimerTest {
 
+    @Test
+    void shouldScanNormalRepairAndChangeSources() {
+        ReSubmitApprovalService reSubmitService = mock(ReSubmitApprovalService.class);
+        TimeCardRepairService repairService = mock(TimeCardRepairService.class);
+        TimeCardChangeService changeService = mock(TimeCardChangeService.class);
+        WHConf conf = new WHConf();
+        conf.setApprovalResubmitModifiedLookbackDays(7);
+        when(reSubmitService.findRecentlyModifiedTargets(7)).thenReturn(new LinkedHashMap<>());
+        when(repairService.resubmitRecentlyModifiedPending(7))
+                .thenReturn(new TimeCardRepairService.RepairResubmitResult(0, 0));
+        when(changeService.resubmitRecentlyModifiedPending(7))
+                .thenReturn(new TimeCardChangeService.ChangeResubmitResult(0, 0));
+        ReSubmitApprovalTimer timer = new ReSubmitApprovalTimer();
+        ReflectionTestUtils.setField(timer, "reSubmitApprovalService", reSubmitService);
+        ReflectionTestUtils.setField(timer, "timeCardRepairService", repairService);
+        ReflectionTestUtils.setField(timer, "timeCardChangeService", changeService);
+        ReflectionTestUtils.setField(timer, "monthClosingService", mock(MonthClosingService.class));
+        ReflectionTestUtils.setField(timer, "whConf", conf);
+
+        timer.scanAndReSubmit();
+
+        verify(reSubmitService).findRecentlyModifiedTargets(7);
+        verify(repairService).resubmitRecentlyModifiedPending(7);
+        verify(changeService).resubmitRecentlyModifiedPending(7);
+    }
+
     @Test
     void shouldScheduleAfternoonFallbackAtSixteenHundred() throws NoSuchMethodException {
         Method method = ReSubmitApprovalTimer.class.getDeclaredMethod("scheduledAfternoonResubmit");