Pārlūkot izejas kodu

feat(timecard): add closed-month repair workflow

malk 2 mēneši atpakaļ
vecāks
revīzija
060773554a

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

@@ -5,7 +5,11 @@ import com.malk.server.workhours.TimeCardDuplicateCleanupRequest;
 import com.malk.server.workhours.TimeCardDuplicateCleanupResult;
 import com.malk.server.workhours.TimeCardInstanceDeleteRequest;
 import com.malk.server.workhours.TimeCardSummaryUpsertRequest;
+import com.malk.server.workhours.TimeCardRepairContextRequest;
+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 org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
@@ -22,6 +26,9 @@ public class TimeCardController {
     @Autowired
     private TimeCardSummaryService timeCardSummaryService;
 
+    @Autowired
+    private TimeCardRepairService timeCardRepairService;
+
     /**
      * 按填报人 ID + 日期原子写入单日工时汇总。
      *
@@ -56,4 +63,25 @@ public class TimeCardController {
             @RequestBody TimeCardInstanceDeleteRequest request) {
         return McR.success(timeCardSummaryService.deleteInstances(request));
     }
+
+    /** 补填页面上下文:只返回原汇总和补填汇总,不读取应报工时。 */
+    @PostMapping("/repair/context")
+    public McR<java.util.Map<String, Object>> repairContext(
+            @RequestBody TimeCardRepairContextRequest request) {
+        return McR.success(timeCardRepairService.context(request));
+    }
+
+    /** 补填草稿/提交前数据写入独立补填汇总表。 */
+    @PostMapping("/repair/summary/upsert")
+    public McR<Boolean> repairSummaryUpsert(
+            @RequestBody TimeCardRepairSummaryUpsertRequest request) {
+        return McR.success(timeCardRepairService.upsert(request));
+    }
+
+    /** 补填提交:固定提交日期和批次,并发起带“补填”标识的审批。 */
+    @PostMapping("/repair/submit")
+    public McR<java.util.Map<String, Object>> repairSubmit(
+            @RequestBody TimeCardRepairSubmitRequest request) {
+        return McR.success(timeCardRepairService.submit(request));
+    }
 }

+ 10 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/workhours/TimeCardRepairContextRequest.java

@@ -0,0 +1,10 @@
+package com.malk.server.workhours;
+
+import lombok.Data;
+
+/** 补填页面上下文查询请求。 */
+@Data
+public class TimeCardRepairContextRequest {
+    private String submitterUserId;
+    private String sourceMonthText;
+}

+ 14 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/workhours/TimeCardRepairSubmitRequest.java

@@ -0,0 +1,14 @@
+package com.malk.server.workhours;
+
+import lombok.Data;
+import java.util.Map;
+
+/** 补填审批提交请求。 */
+@Data
+public class TimeCardRepairSubmitRequest {
+    private String sourceMonthText;
+    private String submitterUserId;
+    private String submitterName;
+    private Map<String, Object> projectGroups;
+    private Map<String, Object> otherGroup;
+}

+ 12 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/workhours/TimeCardRepairSummaryUpsertRequest.java

@@ -0,0 +1,12 @@
+package com.malk.server.workhours;
+
+import lombok.Data;
+import java.util.Map;
+
+/** 补填汇总单日写入请求;表单 ID 由服务端固定。 */
+@Data
+public class TimeCardRepairSummaryUpsertRequest {
+    private String submitterUserId;
+    private String dayText;
+    private Map<String, Object> formData;
+}

+ 14 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/workhours/WHConf.java

@@ -27,6 +27,20 @@ public class WHConf {
     // prd 审批回写:工时汇总表 + 两类审批单
     private String formUuidWorkHoursSummary;
 
+    // prd 补填独立工时汇总表;正常 TimeCard 永远不写入此表
+    private String formUuidRepairWorkHoursSummary;
+
+    // prd 审批单工时类型与补填元数据字段
+    private String approvalFillTypeField;
+    private String approvalRepairSubmitDateField;
+    private String approvalRepairBatchField;
+    private String approvalSourceMonthField;
+    private String repairSummaryFillTypeField;
+    private String repairSummarySubmitDateField;
+    private String repairSummaryBatchField;
+    private String repairSummarySourceMonthField;
+    private String repairSummarySyncStatusField;
+
     // prd 通用后端异常日志表:业务异常只记录结构化摘要,不写入凭据和完整请求正文
     private String formUuidBackendExceptionLog;
 

+ 11 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/ApprovalWriteBackService.java

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

+ 21 - 8
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/MonthClosingService.java

@@ -104,14 +104,14 @@ public class MonthClosingService {
             return buildValidationResult(monthText, STATUS_CLOSED, closingStatus, 0, 0, 0, 0);
         }
 
-        List<Map> projectRunning = queryProcesses(
-                whConf.getFormUuidApproval(), APPROVAL_MONTH_FIELD, monthText, "RUNNING");
-        List<Map> otherRunning = queryProcesses(
-                whConf.getFormUuidOtherApproval(), APPROVAL_MONTH_FIELD, monthText, "RUNNING");
-        List<Map> projectCompleted = queryProcesses(
-                whConf.getFormUuidApproval(), APPROVAL_MONTH_FIELD, monthText, "COMPLETED");
-        List<Map> otherCompleted = queryProcesses(
-                whConf.getFormUuidOtherApproval(), APPROVAL_MONTH_FIELD, monthText, "COMPLETED");
+        List<Map> projectRunning = normalApprovalProcesses(queryProcesses(
+                whConf.getFormUuidApproval(), APPROVAL_MONTH_FIELD, monthText, "RUNNING"));
+        List<Map> otherRunning = normalApprovalProcesses(queryProcesses(
+                whConf.getFormUuidOtherApproval(), APPROVAL_MONTH_FIELD, monthText, "RUNNING"));
+        List<Map> projectCompleted = normalApprovalProcesses(queryProcesses(
+                whConf.getFormUuidApproval(), APPROVAL_MONTH_FIELD, monthText, "COMPLETED"));
+        List<Map> otherCompleted = normalApprovalProcesses(queryProcesses(
+                whConf.getFormUuidOtherApproval(), APPROVAL_MONTH_FIELD, monthText, "COMPLETED"));
 
         int writeBackPendingCount = countWriteBackPending(
                 projectCompleted, whConf.getApprovalSyncStatusField())
@@ -128,6 +128,19 @@ public class MonthClosingService {
                 summaryPendingCount);
     }
 
+    /** 补填审批不参与“正常填报是否全部结束”的封账前置校验。 */
+    private List<Map> normalApprovalProcesses(List<Map> processes) {
+        List<Map> result = new ArrayList<>();
+        String fillTypeField = whConf.getApprovalFillTypeField();
+        for (Map item : processes) {
+            Map data = extractFormData(item);
+            if (StringUtils.isBlank(fillTypeField) || !"补填".equals(text(data.get(fillTypeField)))) {
+                result.add(item);
+            }
+        }
+        return result;
+    }
+
     private MonthClosingValidationResult validateReopening(
             String monthText,
             MonthClosingStatusResult closingStatus) {

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 390 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/RepairApprovalWriteBackService.java


+ 568 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/TimeCardRepairService.java

@@ -0,0 +1,568 @@
+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.workhours.TimeCardApprovalStartRequest;
+import com.malk.server.workhours.TimeCardRepairContextRequest;
+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.time.LocalDate;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.YearMonth;
+import java.util.*;
+
+/** 补填 TimeCard 专用服务;不复用正常汇总表写入目标。 */
+@Slf4j
+@Service
+public class TimeCardRepairService {
+    private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
+    private static final DateTimeFormatter MONTH = DateTimeFormatter.ofPattern("yyyyMM");
+    private static final String DEFAULT_MANAGER_ID = "634735129";
+    // 宜搭查询接口对复制表新增的 mokwy 字段返回 field is null;沿用复制表中可检索的原字段。
+    private static final String R_SUBMITTER = "textField_mmbffvd9";
+    private static final String R_DAY = "textField_mmbffvdb";
+    private static final String R_MONTH = "textField_mmbffvda";
+    private static final String R_STATUS = "selectField_mre1xz9g";
+    private static final String R_SUBMITTER_EMP = "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_BIL_TABLE = "tableField_mmczo634";
+    private static final String R_NON_TABLE = "tableField_mmczo63h";
+    private static final String R_OTHER_TABLE = "tableField_mmeakgid";
+
+    @Autowired private YDClient ydClient;
+    @Autowired private WHConf whConf;
+    @Autowired private MonthClosingService monthClosingService;
+    @Autowired private ReSubmitApprovalService reSubmitApprovalService;
+
+    public Map<String, Object> context(TimeCardRepairContextRequest request) {
+        validateMonthRequest(request == null ? null : request.getSubmitterUserId(),
+                request == null ? null : request.getSourceMonthText());
+        Map<String, Object> result = new LinkedHashMap<>();
+        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(),
+                mapOf(R_SUBMITTER, request.getSubmitterUserId(), R_MONTH, request.getSourceMonthText()));
+        result.put("repairRecords", repairRecords);
+        // 兼容页面状态合并接口的 records 约定;原始双表结果仍通过 repairRecords 返回。
+        result.put("records", repairRecords);
+        return result;
+    }
+
+    public boolean upsert(TimeCardRepairSummaryUpsertRequest request) {
+        McException.assertAccessException(request == null, "请求不能为空");
+        McException.assertAccessException(StringUtils.isBlank(request.getDayText())
+                || !request.getDayText().matches("\\d{8}"), "日期必须为yyyyMMdd");
+        try {
+            LocalDate.parse(request.getDayText(), DateTimeFormatter.ofPattern("yyyyMMdd"));
+        } catch (RuntimeException ex) {
+            McException.assertAccessException(true, "日期必须为有效的yyyyMMdd");
+        }
+        String monthText = request.getDayText().substring(0, 6);
+        validateMonthRequest(request.getSubmitterUserId(), monthText);
+        McException.assertAccessException(!monthClosingService.isClosed(monthText), "补填只能保存已封账月份");
+        McException.assertAccessException(StringUtils.isBlank(whConf.getFormUuidRepairWorkHoursSummary()), "补填汇总表未配置");
+        McException.assertAccessException(request.getFormData() == null, "补填工时数据不能为空");
+        assertSourceDateAvailable(request.getSubmitterUserId(), request.getDayText());
+        assertRepairRecordMutable(request.getSubmitterUserId(), request.getDayText());
+        Map<String, Object> data = new LinkedHashMap<>(request.getFormData());
+        data.put(R_SUBMITTER, request.getSubmitterUserId());
+        data.put(R_DAY, request.getDayText());
+        data.put(R_MONTH, request.getDayText().substring(0, 6));
+        data.put(R_STATUS, "暂存");
+        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(true).build(), YDConf.FORM_OPERATION.upsert);
+        return true;
+    }
+
+    public Map<String, Object> submit(TimeCardRepairSubmitRequest request) {
+        McException.assertAccessException(request == null, "请求不能为空");
+        validateMonthRequest(request.getSubmitterUserId(), request.getSourceMonthText());
+        McException.assertAccessException(!monthClosingService.isClosed(request.getSourceMonthText()), "补填只能提交已封账月份");
+        String batch = UUID.randomUUID().toString();
+        long submitDate = LocalDate.now(ZONE).atStartOfDay(ZONE).toInstant().toEpochMilli();
+        Map<String, Object> projectGroups = request.getProjectGroups() == null ? Collections.emptyMap() : request.getProjectGroups();
+        Map other = request.getOtherGroup() == null ? Collections.emptyMap() : request.getOtherGroup();
+        int rowCount = validateSubmitRows(request, projectGroups, other);
+        McException.assertAccessException(rowCount == 0, "没有需要提交的补填工时");
+        for (Object rawGroup : projectGroups.values()) {
+            if (!(rawGroup instanceof Map)) continue;
+            Map group = (Map) rawGroup;
+            String managerId = normalizeManagerId(text(group.get("managerId")));
+            for (String category : new String[]{"billableRows", "nonBillableRows"}) {
+                for (Object rawRow : list(group.get(category))) {
+                    if (rawRow instanceof Map) {
+                        upsertRepairRow(request, (Map) rawRow, category, managerId, batch, submitDate);
+                    }
+                }
+            }
+        }
+        String otherManagerId = normalizeManagerId(text(other.get("managerId")));
+        for (Object rawRow : list(other.get("otherRows"))) {
+            if (rawRow instanceof Map) {
+                upsertRepairRow(request, (Map) rawRow, "otherRows", otherManagerId, batch, submitDate);
+            }
+        }
+        startApprovals(request, projectGroups, other, batch, submitDate);
+        return mapOf("batch", batch, "submitDate", submitDate, "status", "审批中");
+    }
+
+    /**
+     * 补填审批补发起:只扫描近期修改的补填汇总,按批次补齐缺失的审批分组。
+     * 正常 TimeCard 补发逻辑仍由 ReSubmitApprovalService 独立处理。
+     *
+     * @param lookbackDays 最近修改自然日数量
+     * @return 本次补发的审批分组数量
+     */
+    public int resubmitRecentlyModifiedPending(int lookbackDays) {
+        String[] window = modifiedDateWindow(lookbackDays);
+        List<Map> records = queryModified(
+                whConf.getFormUuidRepairWorkHoursSummary(), window[0], window[1]);
+        Map<String, RepairBatch> batches = buildPendingBatches(records);
+        int started = 0;
+        for (RepairBatch batch : batches.values()) {
+            Set<String> covered = loadRepairApprovalGroupCoverage(batch.monthText, batch.batchId);
+            Map<String, Object> missingProjects = new LinkedHashMap<>();
+            for (Map.Entry<String, Object> entry : batch.projectGroups.entrySet()) {
+                if (!covered.contains("P|" + entry.getKey())) {
+                    missingProjects.put(entry.getKey(), entry.getValue());
+                }
+            }
+            Map<String, Object> missingOther = Collections.emptyMap();
+            if (!batch.otherGroup.isEmpty()) {
+                String managerId = text(batch.otherGroup.get("managerId"));
+                if (!covered.contains("O|" + managerId)) {
+                    missingOther = batch.otherGroup;
+                }
+            }
+            if (missingProjects.isEmpty() && missingOther.isEmpty()) continue;
+            TimeCardRepairSubmitRequest request = new TimeCardRepairSubmitRequest();
+            request.setSourceMonthText(batch.monthText);
+            request.setSubmitterUserId(batch.userId);
+            request.setSubmitterName(batch.submitterName);
+            request.setProjectGroups(missingProjects);
+            request.setOtherGroup(missingOther);
+            startApprovals(request, missingProjects, missingOther, batch.batchId, batch.submitDate);
+            started += missingProjects.size() + (missingOther.isEmpty() ? 0 : 1);
+        }
+        log.info("[补填审批补发] from={} to={} records={} batches={} started={}",
+                window[0], window[1], records.size(), batches.size(), started);
+        return started;
+    }
+
+    private void upsertRepairRow(TimeCardRepairSubmitRequest request, Map row, String category,
+                                 String managerId,
+                                 String batch, long submitDate) {
+        String day = text(row.get("dateStr"));
+        if (StringUtils.isBlank(day)) day = text(row.get("dayText"));
+        String key = day;
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put(R_SUBMITTER, request.getSubmitterUserId());
+        data.put(R_DAY, day);
+        data.put(R_MONTH, request.getSourceMonthText());
+        data.put(R_STATUS, "审批中");
+        data.put(whConf.getRepairSummaryFillTypeField(), "补填");
+        data.put(whConf.getRepairSummarySubmitDateField(), submitDate);
+        data.put(whConf.getRepairSummaryBatchField(), batch);
+        data.put(whConf.getRepairSummarySourceMonthField(), request.getSourceMonthText());
+        data.put(R_SUBMITTER_EMP, Collections.singletonList(request.getSubmitterUserId()));
+        data.put(R_MONTH_DATE, LocalDate.parse(request.getSourceMonthText() + "01", DateTimeFormatter.ofPattern("yyyyMMdd"))
+                .atStartOfDay(ZONE).toInstant().toEpochMilli());
+        data.put(R_DAY_DATE, LocalDate.parse(day, DateTimeFormatter.ofPattern("yyyyMMdd"))
+                .atStartOfDay(ZONE).toInstant().toEpochMilli());
+        Map<String, Object> child = new LinkedHashMap<>();
+        double hours = num(row.get("hours"));
+        if ("billableRows".equals(category)) {
+            child.put("numberField_mmczo636", hours);
+            child.put("numberField_mmczo637", 0d);
+        } else if ("nonBillableRows".equals(category)) {
+            child.put("numberField_mmczo63c", hours);
+            child.put("numberField_mmczo63d", 0d);
+        } else {
+            child.put("numberField_mmeakgij", hours);
+            child.put("numberField_mmeakgii", 0d);
+        }
+        String tableField;
+        String keyField;
+        if ("billableRows".equals(category)) {
+            child.put("textField_mmacxew3", text(row.get("projectCode")));
+            child.put("textField_mmad6jcj", text(row.get("projectName")));
+            tableField = R_BIL_TABLE; keyField = "textField_mmacxew3";
+        } else if ("nonBillableRows".equals(category)) {
+            child.put("textField_mmczo639", text(row.get("projectCode")));
+            child.put("textField_mmczo63g", text(row.get("projectName")));
+            tableField = R_NON_TABLE; keyField = "textField_mmczo639";
+        } else {
+            child.put("selectField_mmeakgie", text(row.get("activityName")));
+            tableField = R_OTHER_TABLE; keyField = "selectField_mmeakgie";
+        }
+        if (StringUtils.isNotBlank(managerId)) {
+            if ("billableRows".equals(category)) {
+                child.put("employeeField_mkoxpswf", Collections.singletonList(managerId));
+            } else if ("nonBillableRows".equals(category)) {
+                child.put("employeeField_mmczo63a", Collections.singletonList(managerId));
+            } else {
+                data.put("employeeField_mh8xhqc3", Collections.singletonList(managerId));
+            }
+        }
+        List<Map> current = new ArrayList<>();
+        List<Map> found = query(whConf.getFormUuidRepairWorkHoursSummary(), mapOf(R_SUBMITTER, request.getSubmitterUserId(), R_DAY, key));
+        McException.assertAccessException(found.size() > 1, "补填汇总记录重复 userId="
+                + request.getSubmitterUserId() + ", day=" + key);
+        if (found.size() == 1) {
+            String id = text(found.get(0).get("formInstId"));
+            if (StringUtils.isBlank(id)) id = text(found.get(0).get("formInstanceId"));
+            current = new ArrayList<>(list(detail(id).get(tableField)));
+        }
+        String submittedField = "billableRows".equals(category)
+                ? "numberField_mmczo636"
+                : ("nonBillableRows".equals(category) ? "numberField_mmczo63c" : "numberField_mmeakgij");
+        mergeSubmittedRow(current, child, keyField, submittedField, hours);
+        data.put(tableField, current);
+        ydClient.operateData(YDParam.builder().appType(whConf.getYidaAppType()).systemToken(whConf.getYidaSystemToken())
+                .formUuid(whConf.getFormUuidRepairWorkHoursSummary())
+                .searchCondition(JSON.toJSONString(mapOf(R_SUBMITTER, request.getSubmitterUserId(), R_DAY, key)))
+                .formDataJson(JSON.toJSONString(data)).noExecuteExpression(true).build(), YDConf.FORM_OPERATION.upsert);
+    }
+
+    private Map detail(String id) {
+        if (StringUtils.isBlank(id)) return new HashMap();
+        com.malk.server.dingtalk.DDR_New result = ydClient.queryData(YDParam.builder()
+                .appType(whConf.getYidaAppType()).systemToken(whConf.getYidaSystemToken()).formInstanceId(id).build(), YDConf.FORM_QUERY.retrieve_id);
+        return result == null || result.getFormData() == null ? new HashMap() : result.getFormData();
+    }
+
+    private void startApprovals(TimeCardRepairSubmitRequest request, Map<String, Object> groups,
+                                Map<String, Object> other, String batch, long submitDate) {
+        for (Object raw : groups.values()) {
+            if (!(raw instanceof Map)) continue;
+            Map group = (Map) raw;
+            Map<String, Object> fd = new LinkedHashMap<>();
+            fd.put("dateField_mmacxewf", parseMonth(request.getSourceMonthText()));
+            fd.put("employeeField_moi9pvt1", Collections.singletonList(request.getSubmitterUserId()));
+            fd.put(whConf.getApprovalFillTypeField(), "补填");
+            fd.put(whConf.getApprovalRepairSubmitDateField(), submitDate);
+            fd.put(whConf.getApprovalRepairBatchField(), batch);
+            fd.put(whConf.getApprovalSourceMonthField(), request.getSourceMonthText());
+            fd.put("textField_mmd2lv0y", request.getSourceMonthText());
+            fd.put("numberField_mmd2z155", sumRows(group.get("billableRows")));
+            fd.put("numberField_mmd2z156", sumRows(group.get("nonBillableRows")));
+            fd.put("numberField_mmd5b5gl", sumRows(group.get("billableRows")) + sumRows(group.get("nonBillableRows")));
+            fd.put("employeeField_mkoxpswf", Collections.singletonList(
+                    normalizeManagerId(text(group.get("managerId")))));
+            fd.put("tableField_mmae8t99", approvalRows(group.get("billableRows"), true, request));
+            fd.put("tableField_mmd1wu9h", approvalRows(group.get("nonBillableRows"), false, request));
+            start(request.getSubmitterUserId(), whConf.getFormUuidApproval(), whConf.getApprovalProcessCode(), fd);
+        }
+        if (other != null && !list(other.get("otherRows")).isEmpty()) {
+            Map<String, Object> fd = new LinkedHashMap<>();
+            fd.put("dateField_mmacxewf", parseMonth(request.getSourceMonthText()));
+            fd.put("employeeField_moi9pvt1", Collections.singletonList(request.getSubmitterUserId()));
+            fd.put(whConf.getApprovalFillTypeField(), "补填");
+            fd.put(whConf.getApprovalRepairSubmitDateField(), submitDate);
+            fd.put(whConf.getApprovalRepairBatchField(), batch);
+            fd.put(whConf.getApprovalSourceMonthField(), request.getSourceMonthText());
+            fd.put("textField_mmd2lv0y", request.getSourceMonthText());
+            fd.put("numberField_mmd5b5gl", sumRows(other.get("otherRows")));
+            fd.put("employeeField_mh8xhqc3", Collections.singletonList(
+                    normalizeManagerId(text(other.get("managerId")))));
+            fd.put("tableField_mmd1wu9h", approvalRows(other.get("otherRows"), false, request));
+            start(request.getSubmitterUserId(), whConf.getFormUuidOtherApproval(), whConf.getOtherApprovalProcessCode(), fd);
+        }
+    }
+
+    private void start(String userId, String formUuid, String processCode, Map<String, Object> data) {
+        reSubmitApprovalService.startApproval(new TimeCardApprovalStartRequest() {{
+            setFormUuid(formUuid); setProcessCode(processCode); setFormDataJson(JSON.toJSONString(data));
+        }});
+    }
+
+    private List<Map<String, Object>> approvalRows(Object raw, boolean billable, TimeCardRepairSubmitRequest request) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (Object item : list(raw)) {
+            if (!(item instanceof Map)) continue;
+            Map row = (Map) item;
+            String day = text(row.get("dateStr"));
+            Map<String, Object> out = new LinkedHashMap<>();
+            out.put(billable ? "dateField_mmbffvd8" : "dateField_mmd2z150", parseDay(day));
+            out.put(billable ? "textField_mmd8g654" : "textField_mmd8g655", day);
+            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")));
+            out.put(billable ? "textField_mmacxew3" : "textField_mmd1wu9d", text(row.get("projectCode")));
+            if (billable) out.put("textField_mkov5ozd", text(row.get("projectName")));
+            else if (!text(row.get("activityName")).isEmpty()) {
+                out.put("selectField_mmeakgie", text(row.get("activityName")));
+                out.remove("textField_mmd1wu9d");
+            } else out.put("textField_mmd1wu9c", text(row.get("projectName")));
+            // 其他工时审批沿用 radioField_mpwhk294;只有 Non Billable 使用 295。
+            String opinionField = billable || out.containsKey("selectField_mmeakgie")
+                    ? "radioField_mpwhk294" : "radioField_mpwhk295";
+            out.put(opinionField, "同意");
+            result.add(out);
+        }
+        return result;
+    }
+
+    private long parseDay(String day) { return LocalDate.parse(day, DateTimeFormatter.ofPattern("yyyyMMdd")).atStartOfDay(ZONE).toInstant().toEpochMilli(); }
+    private List<Map> query(String formUuid, Map<String, Object> search) {
+        if (StringUtils.isBlank(formUuid)) return new ArrayList<>();
+        Object data = 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).getData();
+        return data instanceof List ? (List<Map>) data : new ArrayList<>();
+    }
+    private void validateMonthRequest(String userId, String month) {
+        McException.assertAccessException(StringUtils.isAnyBlank(userId, month)
+                || !month.matches("\\d{6}"), "员工和月份不能为空且月份必须为yyyyMM");
+        try {
+            YearMonth.parse(month, MONTH);
+        } catch (RuntimeException ex) {
+            McException.assertAccessException(true, "月份必须为有效的yyyyMM");
+        }
+    }
+
+    private void validateRepairRow(TimeCardRepairSubmitRequest request, Map row) {
+        String day = text(row.get("dateStr"));
+        if (StringUtils.isBlank(day)) day = text(row.get("dayText"));
+        McException.assertAccessException(!day.matches("\\d{8}")
+                || !day.startsWith(request.getSourceMonthText()), "补填日期必须属于来源月份");
+        try {
+            LocalDate.parse(day, DateTimeFormatter.ofPattern("yyyyMMdd"));
+        } catch (RuntimeException ex) {
+            McException.assertAccessException(true, "补填日期必须为有效的yyyyMMdd");
+        }
+        double hours = num(row.get("hours"));
+        McException.assertAccessException(hours <= 0d || hours > 24d, "补填工时必须大于0且不超过24小时");
+        assertSourceDateAvailable(request.getSubmitterUserId(), day);
+        assertRepairRecordMutable(request.getSubmitterUserId(), day);
+    }
+
+    private int validateSubmitRows(TimeCardRepairSubmitRequest request,
+                                   Map<String, Object> projectGroups,
+                                   Map<String, Object> otherGroup) {
+        int count = 0;
+        for (Object rawGroup : projectGroups.values()) {
+            if (!(rawGroup instanceof Map)) continue;
+            Map group = (Map) rawGroup;
+            for (String category : new String[]{"billableRows", "nonBillableRows"}) {
+                for (Object rawRow : list(group.get(category))) {
+                    if (!(rawRow instanceof Map)) continue;
+                    validateRepairRow(request, (Map) rawRow);
+                    count++;
+                }
+            }
+        }
+        for (Object rawRow : list(otherGroup.get("otherRows"))) {
+            if (!(rawRow instanceof Map)) continue;
+            validateRepairRow(request, (Map) rawRow);
+            count++;
+        }
+        return count;
+    }
+
+    private void assertSourceDateAvailable(String userId, String day) {
+        List<Map> original = query(whConf.getFormUuidWorkHoursSummary(), mapOf(R_SUBMITTER, userId, R_DAY, day));
+        McException.assertAccessException(original.size() > 0, "来源日期已有原工时记录,不能补填 userId=" + userId + ", day=" + day);
+    }
+
+    private void assertRepairRecordMutable(String userId, String day) {
+        List<Map> existing = query(whConf.getFormUuidRepairWorkHoursSummary(), mapOf(R_SUBMITTER, userId, R_DAY, day));
+        McException.assertAccessException(existing.size() > 1, "补填汇总记录重复 userId=" + userId + ", day=" + day);
+        if (existing.size() != 1) return;
+        Map fd = (Map) existing.get(0).get("formData");
+        if (fd == null) fd = existing.get(0);
+        String status = text(fd.get(R_STATUS));
+        McException.assertAccessException("审批中".equals(status) || "已审批".equals(status),
+                "该日期已提交或审批完成,不能再次补填");
+    }
+
+    private long parseMonth(String month) {
+        return LocalDate.parse(month + "01", DateTimeFormatter.ofPattern("yyyyMMdd"))
+                .atStartOfDay(ZONE).toInstant().toEpochMilli();
+    }
+
+    private Map<String, RepairBatch> buildPendingBatches(List<Map> records) {
+        Map<String, RepairBatch> batches = new LinkedHashMap<>();
+        for (Map item : records) {
+            Map fd = formData(item);
+            if (!"审批中".equals(text(fd.get(R_STATUS)))) continue;
+            String batchId = text(fd.get(whConf.getRepairSummaryBatchField()));
+            String userId = text(fd.get(R_SUBMITTER));
+            String monthText = text(fd.get(R_MONTH));
+            if (StringUtils.isAnyBlank(batchId, userId, monthText)) continue;
+            RepairBatch batch = batches.computeIfAbsent(batchId, key -> {
+                RepairBatch value = new RepairBatch();
+                value.batchId = batchId;
+                value.userId = userId;
+                value.monthText = monthText;
+                value.submitDate = (long) num(fd.get(whConf.getRepairSummarySubmitDateField()));
+                if (value.submitDate <= 0) {
+                    value.submitDate = LocalDate.now(ZONE).atStartOfDay(ZONE).toInstant().toEpochMilli();
+                }
+                value.submitterName = text(fd.get("textField_mmoazo1b"));
+                return value;
+            });
+            String dayText = text(fd.get(R_DAY));
+            collectPendingProjectRows(batch, fd, dayText, true);
+            collectPendingProjectRows(batch, fd, dayText, false);
+            collectPendingOtherRows(batch, fd, dayText);
+        }
+        return batches;
+    }
+
+    private void collectPendingProjectRows(RepairBatch batch, Map fd, String dayText, boolean billable) {
+        String tableField = billable ? R_BIL_TABLE : R_NON_TABLE;
+        String managerField = billable ? "employeeField_mkoxpswf" : "employeeField_mmczo63a";
+        String submittedField = billable ? "numberField_mmczo636" : "numberField_mmczo63c";
+        String approvedField = billable ? "numberField_mmczo637" : "numberField_mmczo63d";
+        String projectCodeField = billable ? "textField_mmacxew3" : "textField_mmczo639";
+        String projectNameField = billable ? "textField_mmad6jcj" : "textField_mmczo63g";
+        for (Map child : list(fd.get(tableField))) {
+            double hours = num(child.get(submittedField)) - num(child.get(approvedField));
+            if (hours <= 1e-6) continue;
+            String managerId = firstUserId(child, managerField);
+            if (StringUtils.isBlank(managerId)) managerId = DEFAULT_MANAGER_ID;
+            final String resolvedManagerId = managerId;
+            Map<String, Object> group = (Map<String, Object>) batch.projectGroups.computeIfAbsent(resolvedManagerId, key -> {
+                Map<String, Object> value = new LinkedHashMap<>();
+                value.put("managerId", resolvedManagerId);
+                value.put("billableRows", new ArrayList<Map>());
+                value.put("nonBillableRows", new ArrayList<Map>());
+                return value;
+            });
+            Map<String, Object> row = mapOf(
+                    "dateStr", dayText,
+                    "projectCode", text(child.get(projectCodeField)),
+                    "projectName", text(child.get(projectNameField)),
+                    "hours", hours);
+            ((List<Map<String, Object>>) group.get(billable ? "billableRows" : "nonBillableRows")).add(row);
+        }
+    }
+
+    private void collectPendingOtherRows(RepairBatch batch, Map fd, String dayText) {
+        for (Map child : list(fd.get(R_OTHER_TABLE))) {
+            double hours = num(child.get("numberField_mmeakgij")) - num(child.get("numberField_mmeakgii"));
+            if (hours <= 1e-6) continue;
+            String managerId = firstUserId(fd, "employeeField_mh8xhqc3");
+            if (StringUtils.isBlank(managerId)) managerId = DEFAULT_MANAGER_ID;
+            batch.otherGroup.put("managerId", managerId);
+            List<Map<String, Object>> rows = (List<Map<String, Object>>) batch.otherGroup.computeIfAbsent(
+                    "otherRows", key -> new ArrayList<Map<String, Object>>());
+            rows.add(mapOf("dateStr", dayText,
+                    "activityName", text(child.get("selectField_mmeakgie")), "hours", hours));
+        }
+    }
+
+    private Set<String> loadRepairApprovalGroupCoverage(String monthText, String batchId) {
+        Set<String> covered = new HashSet<>();
+        Map<String, Object> search = mapOf(
+                whConf.getApprovalFillTypeField(), "补填",
+                whConf.getApprovalRepairBatchField(), batchId,
+                whConf.getApprovalSourceMonthField(), monthText);
+        for (Map item : queryProcesses(whConf.getFormUuidApproval(), search)) {
+            Map fd = formData(item);
+            covered.add("P|" + firstUserId(fd, "employeeField_mkoxpswf"));
+        }
+        for (Map item : queryProcesses(whConf.getFormUuidOtherApproval(), search)) {
+            Map fd = formData(item);
+            covered.add("O|" + firstUserId(fd, "employeeField_mh8xhqc3"));
+        }
+        return covered;
+    }
+
+    private List<Map> queryModified(String formUuid, String fromDate, String toDate) {
+        List<Map> all = 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> data = raw instanceof List ? (List<Map>) raw : Collections.emptyList();
+            all.addAll(data);
+            if (data.size() < 100) break;
+        }
+        return all;
+    }
+
+    private List<Map> queryProcesses(String formUuid, Map<String, Object> search) {
+        List<Map> all = 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> data = raw instanceof List ? (List<Map>) raw : Collections.emptyList();
+            all.addAll(data);
+            if (data.size() < 100) break;
+        }
+        return all;
+    }
+
+    private String[] modifiedDateWindow(int lookbackDays) {
+        int safeDays = Math.max(1, lookbackDays);
+        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+        return new String[]{LocalDate.now(ZONE).minusDays(safeDays - 1L).format(formatter),
+                LocalDate.now(ZONE).plusDays(1L).format(formatter)};
+    }
+
+    private 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 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 String normalizeManagerId(String managerId) {
+        return StringUtils.isBlank(managerId) ? DEFAULT_MANAGER_ID : managerId;
+    }
+
+    /**
+     * 提交请求是页面当前值的完整快照;已有草稿应覆盖而不是再次累加。
+     */
+    static void mergeSubmittedRow(List<Map> current, Map child, String keyField,
+                                  String submittedField, double hours) {
+        for (Map existing : current) {
+            if (text(existing.get(keyField)).equals(text(child.get(keyField)))) {
+                existing.put(submittedField, hours);
+                return;
+            }
+        }
+        current.add(child);
+    }
+
+    private static class RepairBatch {
+        private String batchId;
+        private String userId;
+        private String submitterName;
+        private String monthText;
+        private long submitDate;
+        private final Map<String, Object> projectGroups = new LinkedHashMap<>();
+        private final Map<String, Object> otherGroup = new LinkedHashMap<>();
+    }
+    private static Map<String, Object> mapOf(Object... values) { Map<String, Object> m = new LinkedHashMap<>(); for (int i=0;i<values.length;i+=2) m.put(String.valueOf(values[i]), values[i+1]); return m; }
+    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 e) { return 0d; } }
+    private static double sumRows(Object raw) { double total=0d; for (Object item:list(raw)) if (item instanceof Map) total += num(((Map)item).get("hours")); return total; }
+}

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

@@ -33,6 +33,8 @@ import java.util.concurrent.atomic.AtomicInteger;
  * 2. writeBack 调用但网络/宜搭异常,回写失败(syncStatus 为“部分失败”或“全部失败”时继续补偿)
  * <p>
  * 幂等:writeBack 内部 REJECT→0 / APPROVE←hours, 重复执行结果一致
+ * 补填审批额外复用同步总数/失败数记录跨表回写进度,定时重试从未完成步骤继续,
+ * 不重新执行已经确认完成的原汇总或应报工时步骤。
  * <p>
  * 设计文档:后端/阿科德斯/审批结果回写工时汇总表.md v0.8
  */

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

@@ -4,6 +4,7 @@ import com.malk.server.workhours.ReSubmitApprovalResult;
 import com.malk.server.workhours.WHConf;
 import com.malk.service.workhours.MonthClosingService;
 import com.malk.service.workhours.ReSubmitApprovalService;
+import com.malk.service.workhours.TimeCardRepairService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -41,6 +42,10 @@ public class ReSubmitApprovalTimer {
     @Autowired
     private ReSubmitApprovalService reSubmitApprovalService;
 
+    // prd 补填审批使用独立汇总表和独立补发逻辑,不改变正常 TimeCard 补发数据源。
+    @Autowired
+    private TimeCardRepairService timeCardRepairService;
+
     @Autowired
     private WHConf whConf;
 
@@ -107,6 +112,12 @@ public class ReSubmitApprovalTimer {
                     }
                 }
             }
+            try {
+                totalCreated += timeCardRepairService.resubmitRecentlyModifiedPending(lookbackDays);
+            } catch (Exception ex) {
+                totalFailed++;
+                log.error("[补填审批补发] 执行失败", ex);
+            }
             log.info("[补发起兜底] 完成 created={} updated={} failed={}",
                     totalCreated, totalUpdated, totalFailed);
         } catch (Exception e) {

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

@@ -83,6 +83,7 @@ workhours:
   formUuidProjectChange: "FORM-B86D895C90AE460B9749CC15E9079457PC79"
   # 审批回写:工时汇总表 + 两类审批单(当前指向测试环境,正式见注释)
   formUuidWorkHoursSummary: "FORM-87E51B67EBC1461C8247ED7D5D3E3DD829KC"
+  formUuidRepairWorkHoursSummary: "FORM-6FEA0CCE5ADA4B9583A7D1E69CD685FBE5IY"
   formUuidBackendExceptionLog: "FORM-20F89FE062FC4726B540975E9E269075NFWS"
   formUuidApproval: "FORM-230BB68C42304492A242CA278EF4A853LYIY"        # 正式 FORM-DDA1683645954101890AF6BA723705F9M0XV
   formUuidOtherApproval: "FORM-4828E0E40CD34038825E8C6E25417B2718NF"   # 正式 FORM-BA14F6322DBD470F8CF84CCE131DEA31TIJS
@@ -106,6 +107,15 @@ workhours:
   approvalOriginatorDeptId: "${AKDS_APPROVAL_ORIGINATOR_DEPT_ID:}"
   approvalResubmitModifiedLookbackDays: 7
   summaryApprovalStatusField: "selectField_mre1xz9g"
+  approvalFillTypeField: "radioField_mtksjp3i"
+  approvalRepairSubmitDateField: "dateField_tcqr1klh9"
+  approvalRepairBatchField: "textField_tcqr21o6f"
+  approvalSourceMonthField: "textField_tcqr352x3"
+  repairSummaryFillTypeField: "radioField_rtc81we6k"
+  repairSummarySubmitDateField: "dateField_rtc92wx04"
+  repairSummaryBatchField: "textField_rtc931nlr"
+  repairSummarySourceMonthField: "textField_rtc943g93"
+  repairSummarySyncStatusField: "selectField_rtc956ecp"
 
 # prd 月度封账流程:TimeCard 实时锁定 + 封账提交前审批完整性校验
 month-closing:

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

@@ -67,6 +67,7 @@ workhours:
   formUuidProjectChange: "FORM-B86D895C90AE460B9749CC15E9079457PC79"
   # 审批回写:工时汇总表 + 当前正式两类审批单
   formUuidWorkHoursSummary: "FORM-87E51B67EBC1461C8247ED7D5D3E3DD829KC"
+  formUuidRepairWorkHoursSummary: "FORM-6FEA0CCE5ADA4B9583A7D1E69CD685FBE5IY"
   formUuidBackendExceptionLog: "FORM-20F89FE062FC4726B540975E9E269075NFWS"
   formUuidApproval: "FORM-DDA1683645954101890AF6BA723705F9M0XV"
   formUuidOtherApproval: "FORM-BA14F6322DBD470F8CF84CCE131DEA31TIJS"
@@ -87,6 +88,15 @@ workhours:
   approvalOriginatorDeptId: "${AKDS_APPROVAL_ORIGINATOR_DEPT_ID:1054043453}"
   approvalResubmitModifiedLookbackDays: 7
   summaryApprovalStatusField: "selectField_mre1xz9g"
+  approvalFillTypeField: "radioField_mtksjp3i"
+  approvalRepairSubmitDateField: "dateField_rk7s1y9hy"
+  approvalRepairBatchField: "textField_rk7s2ce5o"
+  approvalSourceMonthField: "textField_rk7t3bhyw"
+  repairSummaryFillTypeField: "radioField_rtc81we6k"
+  repairSummarySubmitDateField: "dateField_rtc92wx04"
+  repairSummaryBatchField: "textField_rtc931nlr"
+  repairSummarySourceMonthField: "textField_rtc943g93"
+  repairSummarySyncStatusField: "selectField_rtc956ecp"
 
 # prd 月度封账:TimeCard 实时锁定 + 封账/启封提交前校验
 month-closing:

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

@@ -9,6 +9,8 @@ import java.util.HashMap;
 import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 class ApprovalWriteBackServiceTest {
 
@@ -103,6 +105,56 @@ class ApprovalWriteBackServiceTest {
         assertEquals(true, ApprovalWriteBackService.isOtherApprovalForm(formData));
     }
 
+    @Test
+    void shouldReadOtherApprovalOpinionFromActualField() {
+        Map<String, Object> row = new HashMap<>();
+        row.put("selectField_mmeakgie", "培训");
+        row.put("radioField_mpwhk294", "不同意");
+
+        assertFalse(RepairApprovalWriteBackService.isApprovedRow(row, false, true));
+
+        row.put("radioField_mpwhk294", "同意");
+        assertTrue(RepairApprovalWriteBackService.isApprovedRow(row, false, true));
+    }
+
+    @Test
+    void shouldReadNonBillableApprovalOpinionFromActualField() {
+        Map<String, Object> row = new HashMap<>();
+        row.put("textField_mmd1wu9d", "PROJECT-1");
+        row.put("radioField_mpwhk295", "不同意");
+
+        assertFalse(RepairApprovalWriteBackService.isApprovedRow(row, false, true));
+    }
+
+    @Test
+    void shouldResumeRepairWriteBackFromPersistedCheckpoint() {
+        assertEquals(0, RepairApprovalWriteBackService.resolveCompletedSteps("", 0, 0, 4));
+        assertEquals(0, RepairApprovalWriteBackService.resolveCompletedSteps("部分失败", 3, 2, 4));
+        assertEquals(1, RepairApprovalWriteBackService.resolveCompletedSteps("部分失败", 4, 3, 4));
+        assertEquals(3, RepairApprovalWriteBackService.resolveCompletedSteps("部分失败", 4, 1, 4));
+        assertEquals(4, RepairApprovalWriteBackService.resolveCompletedSteps("部分失败", 4, 0, 4));
+        assertEquals(0, RepairApprovalWriteBackService.resolveCompletedSteps("全部成功", 4, 0, 4));
+    }
+
+    @Test
+    void shouldOverwriteSavedDraftHoursWhenRepairIsSubmitted() {
+        Map<String, Object> existing = new HashMap<>();
+        existing.put("projectCode", "P-001");
+        existing.put("submittedHours", 4d);
+        java.util.List<Map> rows = new java.util.ArrayList<>();
+        rows.add(existing);
+
+        Map<String, Object> submitted = new HashMap<>();
+        submitted.put("projectCode", "P-001");
+        submitted.put("submittedHours", 4d);
+        TimeCardRepairService.mergeSubmittedRow(
+                rows, submitted, "projectCode", "submittedHours", 4d);
+
+        assertEquals(1, rows.size());
+        assertEquals(4d, rows.get(0).get("submittedHours"));
+    }
+
+
     @Test
     void shouldNeverTreatEmptyOrMalformedApprovalDetailsAsSuccess() {
         assertEquals("全部失败", ApprovalWriteBackService.determineSyncStatus(0, 0));

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

@@ -4,6 +4,7 @@ import com.malk.server.workhours.ReSubmitApprovalResult;
 import com.malk.server.workhours.WHConf;
 import com.malk.service.workhours.MonthClosingService;
 import com.malk.service.workhours.ReSubmitApprovalService;
+import com.malk.service.workhours.TimeCardRepairService;
 import org.junit.jupiter.api.Test;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.test.util.ReflectionTestUtils;
@@ -35,6 +36,7 @@ class ReSubmitApprovalTimerTest {
     void shouldSkipAllTargetsWhenMonthIsClosed() {
         ReSubmitApprovalService reSubmitService = mock(ReSubmitApprovalService.class);
         MonthClosingService monthClosingService = mock(MonthClosingService.class);
+        TimeCardRepairService timeCardRepairService = mock(TimeCardRepairService.class);
         WHConf whConf = new WHConf();
         whConf.setApprovalResubmitModifiedLookbackDays(3);
 
@@ -50,6 +52,7 @@ class ReSubmitApprovalTimerTest {
         ReSubmitApprovalTimer timer = new ReSubmitApprovalTimer();
         ReflectionTestUtils.setField(timer, "reSubmitApprovalService", reSubmitService);
         ReflectionTestUtils.setField(timer, "monthClosingService", monthClosingService);
+        ReflectionTestUtils.setField(timer, "timeCardRepairService", timeCardRepairService);
         ReflectionTestUtils.setField(timer, "whConf", whConf);
 
         timer.scanAndReSubmit();
@@ -59,5 +62,6 @@ class ReSubmitApprovalTimerTest {
         verify(reSubmitService, never()).resubmit("202608", "user-1");
         verify(reSubmitService, never()).resubmit("202608", "user-2");
         verify(reSubmitService).resubmit("202609", "user-3");
+        verify(timeCardRepairService).resubmitRecentlyModifiedPending(3);
     }
 }