Quellcode durchsuchen

fix(timecard): complete repair submission and writeback

malk vor 1 Woche
Ursprung
Commit
c4b2433859

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

@@ -30,11 +30,14 @@ public class WHConf {
     // prd 补填独立工时汇总表;正常 TimeCard 永远不写入此表
     // prd 补填独立工时汇总表;正常 TimeCard 永远不写入此表
     private String formUuidRepairWorkHoursSummary;
     private String formUuidRepairWorkHoursSummary;
 
 
-    // prd 审批单工时类型与补填元数据字段
+    // prd 审批单工时类型与补填元数据;两张审批单的 fieldId 不同。
     private String approvalFillTypeField;
     private String approvalFillTypeField;
     private String approvalRepairSubmitDateField;
     private String approvalRepairSubmitDateField;
+    private String otherApprovalRepairSubmitDateField;
     private String approvalRepairBatchField;
     private String approvalRepairBatchField;
-    private String approvalSourceMonthField;
+    private String otherApprovalRepairBatchField;
+    private String approvalRepairSourceMonthField;
+    private String otherApprovalRepairSourceMonthField;
     private String repairSummaryFillTypeField;
     private String repairSummaryFillTypeField;
     private String repairSummarySubmitDateField;
     private String repairSummarySubmitDateField;
     private String repairSummaryBatchField;
     private String repairSummaryBatchField;

Datei-Diff unterdrückt, da er zu groß ist
+ 208 - 54
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/RepairApprovalWriteBackService.java


+ 237 - 64
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/TimeCardRepairService.java

@@ -32,13 +32,21 @@ public class TimeCardRepairService {
     private static final String R_SUBMITTER = "textField_mmbffvd9";
     private static final String R_SUBMITTER = "textField_mmbffvd9";
     private static final String R_DAY = "textField_mmbffvdb";
     private static final String R_DAY = "textField_mmbffvdb";
     private static final String R_MONTH = "textField_mmbffvda";
     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_SUBMITTER_EMP = "employeeField_mmacxew4";
     private static final String R_MONTH_DATE = "dateField_mmacxewf";
     private static final String R_MONTH_DATE = "dateField_mmacxewf";
     private static final String R_DAY_DATE = "dateField_mmbffvd8";
     private static final String R_DAY_DATE = "dateField_mmbffvd8";
     private static final String R_BIL_TABLE = "tableField_mmczo634";
     private static final String R_BIL_TABLE = "tableField_mmczo634";
     private static final String R_NON_TABLE = "tableField_mmczo63h";
     private static final String R_NON_TABLE = "tableField_mmczo63h";
     private static final String R_OTHER_TABLE = "tableField_mmeakgid";
     private static final String R_OTHER_TABLE = "tableField_mmeakgid";
+    private static final String R_DAILY_DRAFT = "numberField_mmd1smel";
+    private static final String R_DAILY_SUBMITTED = "numberField_mmd1smem";
+    private static final String R_DAILY_APPROVED = "numberField_mmeakgi4";
+    private static final String R_DAILY_CHANGE = "numberField_mmeakgi5";
+    private static final List<String> R_TOTAL_FIELDS = Arrays.asList(
+            "numberField_mmacxew9", "numberField_mmacxewa", "numberField_mmacxewg", "numberField_mmad6jbc",
+            "numberField_mmad6jbx", "numberField_mmad6jbz", "numberField_mmad6jc1", "numberField_mmeakgia",
+            "numberField_mmd1smeo", "numberField_mmd1smeq", "numberField_mmd1smes", "numberField_mmeakgic",
+            R_DAILY_DRAFT, R_DAILY_SUBMITTED, R_DAILY_APPROVED, R_DAILY_CHANGE);
 
 
     @Autowired private YDClient ydClient;
     @Autowired private YDClient ydClient;
     @Autowired private WHConf whConf;
     @Autowired private WHConf whConf;
@@ -73,18 +81,18 @@ public class TimeCardRepairService {
         validateMonthRequest(request.getSubmitterUserId(), monthText);
         validateMonthRequest(request.getSubmitterUserId(), monthText);
         McException.assertAccessException(!monthClosingService.isClosed(monthText), "补填只能保存已封账月份");
         McException.assertAccessException(!monthClosingService.isClosed(monthText), "补填只能保存已封账月份");
         McException.assertAccessException(StringUtils.isBlank(whConf.getFormUuidRepairWorkHoursSummary()), "补填汇总表未配置");
         McException.assertAccessException(StringUtils.isBlank(whConf.getFormUuidRepairWorkHoursSummary()), "补填汇总表未配置");
+        McException.assertAccessException(StringUtils.isBlank(whConf.getRepairSummarySyncStatusField()), "补填汇总状态字段未配置");
         McException.assertAccessException(request.getFormData() == null, "补填工时数据不能为空");
         McException.assertAccessException(request.getFormData() == null, "补填工时数据不能为空");
         assertSourceDateAvailable(request.getSubmitterUserId(), request.getDayText());
         assertSourceDateAvailable(request.getSubmitterUserId(), request.getDayText());
-        assertRepairRecordMutable(request.getSubmitterUserId(), request.getDayText());
         Map<String, Object> data = new LinkedHashMap<>(request.getFormData());
         Map<String, Object> data = new LinkedHashMap<>(request.getFormData());
         data.put(R_SUBMITTER, request.getSubmitterUserId());
         data.put(R_SUBMITTER, request.getSubmitterUserId());
         data.put(R_DAY, request.getDayText());
         data.put(R_DAY, request.getDayText());
         data.put(R_MONTH, request.getDayText().substring(0, 6));
         data.put(R_MONTH, request.getDayText().substring(0, 6));
-        data.put(R_STATUS, "暂存");
+        data.put(whConf.getRepairSummarySyncStatusField(), "暂存");
         ydClient.operateData(YDParam.builder().appType(whConf.getYidaAppType())
         ydClient.operateData(YDParam.builder().appType(whConf.getYidaAppType())
                 .systemToken(whConf.getYidaSystemToken()).formUuid(whConf.getFormUuidRepairWorkHoursSummary())
                 .systemToken(whConf.getYidaSystemToken()).formUuid(whConf.getFormUuidRepairWorkHoursSummary())
                 .searchCondition(JSON.toJSONString(mapOf(R_SUBMITTER, request.getSubmitterUserId(), R_DAY, request.getDayText())))
                 .searchCondition(JSON.toJSONString(mapOf(R_SUBMITTER, request.getSubmitterUserId(), R_DAY, request.getDayText())))
-                .formDataJson(JSON.toJSONString(data)).noExecuteExpression(true).build(), YDConf.FORM_OPERATION.upsert);
+                .formDataJson(JSON.toJSONString(data)).noExecuteExpression(false).build(), YDConf.FORM_OPERATION.upsert);
         return true;
         return true;
     }
     }
 
 
@@ -121,7 +129,7 @@ public class TimeCardRepairService {
     }
     }
 
 
     /**
     /**
-     * 补填审批补发起:只扫描近期修改的补填汇总,按批次补齐缺失的审批分组
+     * 补填审批补发起:只扫描近期修改的补填汇总,按员工、月份和补填提交日期补齐缺失明细
      * 正常 TimeCard 补发逻辑仍由 ReSubmitApprovalService 独立处理。
      * 正常 TimeCard 补发逻辑仍由 ReSubmitApprovalService 独立处理。
      *
      *
      * @param lookbackDays 最近修改自然日数量
      * @param lookbackDays 最近修改自然日数量
@@ -132,22 +140,14 @@ public class TimeCardRepairService {
         List<Map> records = queryModified(
         List<Map> records = queryModified(
                 whConf.getFormUuidRepairWorkHoursSummary(), window[0], window[1]);
                 whConf.getFormUuidRepairWorkHoursSummary(), window[0], window[1]);
         Map<String, RepairBatch> batches = buildPendingBatches(records);
         Map<String, RepairBatch> batches = buildPendingBatches(records);
+        Map<String, Set<String>> coverageByMonth = new HashMap<>();
         int started = 0;
         int started = 0;
         for (RepairBatch batch : batches.values()) {
         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;
-                }
-            }
+            Set<String> covered = coverageByMonth.computeIfAbsent(
+                    batch.monthText, this::loadRepairApprovalDetailCoverage);
+            removeCoveredRows(batch, covered);
+            Map<String, Object> missingProjects = batch.projectGroups;
+            Map<String, Object> missingOther = batch.otherGroup;
             if (missingProjects.isEmpty() && missingOther.isEmpty()) continue;
             if (missingProjects.isEmpty() && missingOther.isEmpty()) continue;
             TimeCardRepairSubmitRequest request = new TimeCardRepairSubmitRequest();
             TimeCardRepairSubmitRequest request = new TimeCardRepairSubmitRequest();
             request.setSourceMonthText(batch.monthText);
             request.setSourceMonthText(batch.monthText);
@@ -164,16 +164,24 @@ public class TimeCardRepairService {
     }
     }
 
 
     private void upsertRepairRow(TimeCardRepairSubmitRequest request, Map row, String category,
     private void upsertRepairRow(TimeCardRepairSubmitRequest request, Map row, String category,
-                                 String managerId,
-                                 String batch, long submitDate) {
+                                 String managerId, String batch, long submitDate) {
         String day = text(row.get("dateStr"));
         String day = text(row.get("dateStr"));
         if (StringUtils.isBlank(day)) day = text(row.get("dayText"));
         if (StringUtils.isBlank(day)) day = text(row.get("dayText"));
         String key = day;
         String key = day;
+        List<Map> found = query(whConf.getFormUuidRepairWorkHoursSummary(),
+                mapOf(R_SUBMITTER, request.getSubmitterUserId(), R_DAY, key));
+        McException.assertAccessException(found.size() != 1, "补填汇总完整记录不存在或重复,请先保存后提交 userId="
+                + request.getSubmitterUserId() + ", day=" + key);
+        String instanceId = text(found.get(0).get("formInstId"));
+        if (StringUtils.isBlank(instanceId)) instanceId = text(found.get(0).get("formInstanceId"));
+        McException.assertAccessException(StringUtils.isBlank(instanceId), "补填汇总实例ID为空");
+        Map existing = detail(instanceId);
+
         Map<String, Object> data = new LinkedHashMap<>();
         Map<String, Object> data = new LinkedHashMap<>();
         data.put(R_SUBMITTER, request.getSubmitterUserId());
         data.put(R_SUBMITTER, request.getSubmitterUserId());
         data.put(R_DAY, day);
         data.put(R_DAY, day);
         data.put(R_MONTH, request.getSourceMonthText());
         data.put(R_MONTH, request.getSourceMonthText());
-        data.put(R_STATUS, "审批中");
+        data.put(whConf.getRepairSummarySyncStatusField(), "审批中");
         data.put(whConf.getRepairSummaryFillTypeField(), "补填");
         data.put(whConf.getRepairSummaryFillTypeField(), "补填");
         data.put(whConf.getRepairSummarySubmitDateField(), submitDate);
         data.put(whConf.getRepairSummarySubmitDateField(), submitDate);
         data.put(whConf.getRepairSummaryBatchField(), batch);
         data.put(whConf.getRepairSummaryBatchField(), batch);
@@ -218,24 +226,66 @@ public class TimeCardRepairService {
                 data.put("employeeField_mh8xhqc3", Collections.singletonList(managerId));
                 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)));
-        }
+        List<Map> current = copyRows(existing.get(tableField));
         String submittedField = "billableRows".equals(category)
         String submittedField = "billableRows".equals(category)
                 ? "numberField_mmczo636"
                 ? "numberField_mmczo636"
                 : ("nonBillableRows".equals(category) ? "numberField_mmczo63c" : "numberField_mmeakgij");
                 : ("nonBillableRows".equals(category) ? "numberField_mmczo63c" : "numberField_mmeakgij");
+        // prd 与原 TimeCard 一致:提交值覆盖 submitted,draft 保留供拒绝后恢复编辑。
         mergeSubmittedRow(current, child, keyField, submittedField, hours);
         mergeSubmittedRow(current, child, keyField, submittedField, hours);
         data.put(tableField, current);
         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);
+        Map snapshot = new LinkedHashMap(existing);
+        snapshot.put(tableField, current);
+        applySummaryTotals(data, snapshot);
+        final String targetInstanceId = instanceId;
+        operateAndVerify(
+                () -> ydClient.operateData(YDParam.builder().appType(whConf.getYidaAppType())
+                        .systemToken(whConf.getYidaSystemToken()).formInstanceId(targetInstanceId)
+                        .updateFormDataJson(JSON.toJSONString(data)).ignoreEmpty(false).useLatestVersion(true).build(),
+                        YDConf.FORM_OPERATION.update),
+                () -> verifySubmittedRepairRow(targetInstanceId, tableField, keyField,
+                        text(child.get(keyField)), submittedField, hours, data));
+    }
+
+    private void verifySubmittedRepairRow(String instanceId, String tableField, String keyField,
+                                          String rowKey, String submittedField, double hours,
+                                          Map<String, Object> expected) {
+        Map actual = detail(instanceId);
+        Map actualRow = null;
+        for (Map row : list(actual.get(tableField))) {
+            if (rowKey.equals(text(row.get(keyField)))) {
+                actualRow = row;
+                break;
+            }
+        }
+        McException.assertAccessException(actualRow == null
+                        || !sameHours(num(actualRow.get(submittedField)), hours),
+                "补填汇总提交子表回读不一致 instanceId=" + instanceId + ", key=" + rowKey);
+        for (String field : R_TOTAL_FIELDS) {
+            McException.assertAccessException(!sameHours(num(actual.get(field)), num(expected.get(field))),
+                    "补填汇总主表合计回读不一致 instanceId=" + instanceId + ", field=" + field);
+        }
+        String statusField = whConf.getRepairSummarySyncStatusField();
+        McException.assertAccessException(!text(expected.get(statusField)).equals(text(actual.get(statusField))),
+                "补填汇总审批状态回读不一致 instanceId=" + instanceId);
+    }
+
+    private void operateAndVerify(Runnable operation, Runnable verification) {
+        try {
+            operation.run();
+        } catch (RuntimeException operationException) {
+            try {
+                verification.run();
+                return;
+            } catch (RuntimeException verificationException) {
+                operationException.addSuppressed(verificationException);
+                throw operationException;
+            }
+        }
+        verification.run();
+    }
+
+    private static boolean sameHours(double actual, double expected) {
+        return Math.abs(actual - expected) <= 1e-6;
     }
     }
 
 
     private Map detail(String id) {
     private Map detail(String id) {
@@ -256,7 +306,7 @@ public class TimeCardRepairService {
             fd.put(whConf.getApprovalFillTypeField(), "补填");
             fd.put(whConf.getApprovalFillTypeField(), "补填");
             fd.put(whConf.getApprovalRepairSubmitDateField(), submitDate);
             fd.put(whConf.getApprovalRepairSubmitDateField(), submitDate);
             fd.put(whConf.getApprovalRepairBatchField(), batch);
             fd.put(whConf.getApprovalRepairBatchField(), batch);
-            fd.put(whConf.getApprovalSourceMonthField(), request.getSourceMonthText());
+            fd.put(whConf.getApprovalRepairSourceMonthField(), request.getSourceMonthText());
             fd.put("textField_mmd2lv0y", request.getSourceMonthText());
             fd.put("textField_mmd2lv0y", request.getSourceMonthText());
             fd.put("numberField_mmd2z155", sumRows(group.get("billableRows")));
             fd.put("numberField_mmd2z155", sumRows(group.get("billableRows")));
             fd.put("numberField_mmd2z156", sumRows(group.get("nonBillableRows")));
             fd.put("numberField_mmd2z156", sumRows(group.get("nonBillableRows")));
@@ -272,9 +322,9 @@ public class TimeCardRepairService {
             fd.put("dateField_mmacxewf", parseMonth(request.getSourceMonthText()));
             fd.put("dateField_mmacxewf", parseMonth(request.getSourceMonthText()));
             fd.put("employeeField_moi9pvt1", Collections.singletonList(request.getSubmitterUserId()));
             fd.put("employeeField_moi9pvt1", Collections.singletonList(request.getSubmitterUserId()));
             fd.put(whConf.getApprovalFillTypeField(), "补填");
             fd.put(whConf.getApprovalFillTypeField(), "补填");
-            fd.put(whConf.getApprovalRepairSubmitDateField(), submitDate);
-            fd.put(whConf.getApprovalRepairBatchField(), batch);
-            fd.put(whConf.getApprovalSourceMonthField(), request.getSourceMonthText());
+            fd.put(whConf.getOtherApprovalRepairSubmitDateField(), submitDate);
+            fd.put(whConf.getOtherApprovalRepairBatchField(), batch);
+            fd.put(whConf.getOtherApprovalRepairSourceMonthField(), request.getSourceMonthText());
             fd.put("textField_mmd2lv0y", request.getSourceMonthText());
             fd.put("textField_mmd2lv0y", request.getSourceMonthText());
             fd.put("numberField_mmd5b5gl", sumRows(other.get("otherRows")));
             fd.put("numberField_mmd5b5gl", sumRows(other.get("otherRows")));
             fd.put("employeeField_mh8xhqc3", Collections.singletonList(
             fd.put("employeeField_mh8xhqc3", Collections.singletonList(
@@ -336,7 +386,7 @@ public class TimeCardRepairService {
         }
         }
     }
     }
 
 
-    private void validateRepairRow(TimeCardRepairSubmitRequest request, Map row) {
+    private void validateRepairRow(TimeCardRepairSubmitRequest request, Map row, String category) {
         String day = text(row.get("dateStr"));
         String day = text(row.get("dateStr"));
         if (StringUtils.isBlank(day)) day = text(row.get("dayText"));
         if (StringUtils.isBlank(day)) day = text(row.get("dayText"));
         McException.assertAccessException(!day.matches("\\d{8}")
         McException.assertAccessException(!day.matches("\\d{8}")
@@ -349,7 +399,7 @@ public class TimeCardRepairService {
         double hours = num(row.get("hours"));
         double hours = num(row.get("hours"));
         McException.assertAccessException(hours <= 0d || hours > 24d, "补填工时必须大于0且不超过24小时");
         McException.assertAccessException(hours <= 0d || hours > 24d, "补填工时必须大于0且不超过24小时");
         assertSourceDateAvailable(request.getSubmitterUserId(), day);
         assertSourceDateAvailable(request.getSubmitterUserId(), day);
-        assertRepairRecordMutable(request.getSubmitterUserId(), day);
+        assertRepairRowMutable(request.getSubmitterUserId(), day, row, category);
     }
     }
 
 
     private int validateSubmitRows(TimeCardRepairSubmitRequest request,
     private int validateSubmitRows(TimeCardRepairSubmitRequest request,
@@ -362,14 +412,14 @@ public class TimeCardRepairService {
             for (String category : new String[]{"billableRows", "nonBillableRows"}) {
             for (String category : new String[]{"billableRows", "nonBillableRows"}) {
                 for (Object rawRow : list(group.get(category))) {
                 for (Object rawRow : list(group.get(category))) {
                     if (!(rawRow instanceof Map)) continue;
                     if (!(rawRow instanceof Map)) continue;
-                    validateRepairRow(request, (Map) rawRow);
+                    validateRepairRow(request, (Map) rawRow, category);
                     count++;
                     count++;
                 }
                 }
             }
             }
         }
         }
         for (Object rawRow : list(otherGroup.get("otherRows"))) {
         for (Object rawRow : list(otherGroup.get("otherRows"))) {
             if (!(rawRow instanceof Map)) continue;
             if (!(rawRow instanceof Map)) continue;
-            validateRepairRow(request, (Map) rawRow);
+            validateRepairRow(request, (Map) rawRow, "otherRows");
             count++;
             count++;
         }
         }
         return count;
         return count;
@@ -380,15 +430,32 @@ public class TimeCardRepairService {
         McException.assertAccessException(original.size() > 0, "来源日期已有原工时记录,不能补填 userId=" + userId + ", day=" + day);
         McException.assertAccessException(original.size() > 0, "来源日期已有原工时记录,不能补填 userId=" + userId + ", day=" + day);
     }
     }
 
 
-    private void assertRepairRecordMutable(String userId, String day) {
+    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 = query(whConf.getFormUuidRepairWorkHoursSummary(), mapOf(R_SUBMITTER, userId, R_DAY, day));
         McException.assertAccessException(existing.size() > 1, "补填汇总记录重复 userId=" + userId + ", day=" + day);
         McException.assertAccessException(existing.size() > 1, "补填汇总记录重复 userId=" + userId + ", day=" + day);
         if (existing.size() != 1) return;
         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),
-                "该日期已提交或审批完成,不能再次补填");
+        String id = text(existing.get(0).get("formInstId"));
+        if (StringUtils.isBlank(id)) id = text(existing.get(0).get("formInstanceId"));
+        Map fd = detail(id);
+        String projectCode = text(row.get("projectCode"));
+        String activityName = text(row.get("activityName"));
+        boolean other = "otherRows".equals(category);
+        String table = other ? R_OTHER_TABLE
+                : ("billableRows".equals(category) ? R_BIL_TABLE : R_NON_TABLE);
+        String keyField = other ? "selectField_mmeakgie"
+                : (R_BIL_TABLE.equals(table) ? "textField_mmacxew3" : "textField_mmczo639");
+        String submittedField = other ? "numberField_mmeakgij"
+                : (R_BIL_TABLE.equals(table) ? "numberField_mmczo636" : "numberField_mmczo63c");
+        String approvedField = other ? "numberField_mmeakgii"
+                : (R_BIL_TABLE.equals(table) ? "numberField_mmczo637" : "numberField_mmczo63d");
+        String key = other ? activityName : projectCode;
+        for (Map child : list(fd.get(table))) {
+            if (key.equals(text(child.get(keyField)))) {
+                McException.assertAccessException(num(child.get(submittedField)) > 1e-6
+                                || num(child.get(approvedField)) > 1e-6,
+                        "该补填明细已提交或审批完成,不能再次补填 day=" + day + ", key=" + key);
+            }
+        }
     }
     }
 
 
     private long parseMonth(String month) {
     private long parseMonth(String month) {
@@ -400,20 +467,19 @@ public class TimeCardRepairService {
         Map<String, RepairBatch> batches = new LinkedHashMap<>();
         Map<String, RepairBatch> batches = new LinkedHashMap<>();
         for (Map item : records) {
         for (Map item : records) {
             Map fd = formData(item);
             Map fd = formData(item);
-            if (!"审批中".equals(text(fd.get(R_STATUS)))) continue;
-            String batchId = text(fd.get(whConf.getRepairSummaryBatchField()));
+            if (!"审批中".equals(text(fd.get(whConf.getRepairSummarySyncStatusField())))) continue;
             String userId = text(fd.get(R_SUBMITTER));
             String userId = text(fd.get(R_SUBMITTER));
             String monthText = text(fd.get(R_MONTH));
             String monthText = text(fd.get(R_MONTH));
-            if (StringUtils.isAnyBlank(batchId, userId, monthText)) continue;
-            RepairBatch batch = batches.computeIfAbsent(batchId, key -> {
+            String batchId = text(fd.get(whConf.getRepairSummaryBatchField()));
+            long submitDate = (long) num(fd.get(whConf.getRepairSummarySubmitDateField()));
+            if (StringUtils.isAnyBlank(userId, monthText, batchId) || submitDate <= 0) continue;
+            String submissionKey = userId + "|" + monthText + "|" + batchId;
+            RepairBatch batch = batches.computeIfAbsent(submissionKey, key -> {
                 RepairBatch value = new RepairBatch();
                 RepairBatch value = new RepairBatch();
                 value.batchId = batchId;
                 value.batchId = batchId;
                 value.userId = userId;
                 value.userId = userId;
                 value.monthText = monthText;
                 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.submitDate = submitDate;
                 value.submitterName = text(fd.get("textField_mmoazo1b"));
                 value.submitterName = text(fd.get("textField_mmoazo1b"));
                 return value;
                 return value;
             });
             });
@@ -468,23 +534,85 @@ public class TimeCardRepairService {
         }
         }
     }
     }
 
 
-    private Set<String> loadRepairApprovalGroupCoverage(String monthText, String batchId) {
+    private Set<String> loadRepairApprovalDetailCoverage(String monthText) {
         Set<String> covered = new HashSet<>();
         Set<String> covered = new HashSet<>();
         Map<String, Object> search = mapOf(
         Map<String, Object> search = mapOf(
                 whConf.getApprovalFillTypeField(), "补填",
                 whConf.getApprovalFillTypeField(), "补填",
-                whConf.getApprovalRepairBatchField(), batchId,
-                whConf.getApprovalSourceMonthField(), monthText);
+                "textField_mmd2lv0y", monthText);
         for (Map item : queryProcesses(whConf.getFormUuidApproval(), search)) {
         for (Map item : queryProcesses(whConf.getFormUuidApproval(), search)) {
-            Map fd = formData(item);
-            covered.add("P|" + firstUserId(fd, "employeeField_mkoxpswf"));
+            collectRepairApprovalCoverage(item, false, covered);
         }
         }
         for (Map item : queryProcesses(whConf.getFormUuidOtherApproval(), search)) {
         for (Map item : queryProcesses(whConf.getFormUuidOtherApproval(), search)) {
-            Map fd = formData(item);
-            covered.add("O|" + firstUserId(fd, "employeeField_mh8xhqc3"));
+            collectRepairApprovalCoverage(item, true, covered);
         }
         }
         return covered;
         return covered;
     }
     }
 
 
+    private void collectRepairApprovalCoverage(Map item, boolean other, Set<String> covered) {
+        String status = text(item.get("instanceStatus")).toUpperCase(Locale.ROOT);
+        String approvedResult = text(item.get("approvedResult")).toLowerCase(Locale.ROOT);
+        if (!"RUNNING".equals(status)
+                && !("COMPLETED".equals(status) && "agree".equals(approvedResult))) {
+            return;
+        }
+        Map fd = formData(item);
+        if (other) {
+            addCoverageRows(covered, list(fd.get("tableField_mmd1wu9h")), "other",
+                    "textField_mmd1wu9f", "textField_mmd8g655", "selectField_mmeakgie",
+                    "radioField_mpwhk294", status);
+            return;
+        }
+        addCoverageRows(covered, list(fd.get("tableField_mmae8t99")), "billable",
+                "textField_mmd1ozk4", "textField_mmd8g654", "textField_mmacxew3",
+                "radioField_mpwhk294", status);
+        addCoverageRows(covered, list(fd.get("tableField_mmd1wu9h")), "nonBillable",
+                "textField_mmd1wu9f", "textField_mmd8g655", "textField_mmd1wu9d",
+                "radioField_mpwhk295", status);
+    }
+
+    private void addCoverageRows(Set<String> covered, List<Map> rows, String category,
+                                 String userField, String dayField, String keyField,
+                                 String opinionField, String status) {
+        for (Map row : rows) {
+            if ("COMPLETED".equals(status)
+                    && row.containsKey(opinionField)
+                    && !"同意".equals(text(row.get(opinionField)))) {
+                continue;
+            }
+            covered.add(coverageKey(category, text(row.get(userField)),
+                    text(row.get(dayField)), text(row.get(keyField))));
+        }
+    }
+
+    private void removeCoveredRows(RepairBatch batch, Set<String> covered) {
+        Iterator<Map.Entry<String, Object>> iterator = batch.projectGroups.entrySet().iterator();
+        while (iterator.hasNext()) {
+            Map group = (Map) iterator.next().getValue();
+            removeCoveredProjectRows(batch.userId, list(group.get("billableRows")),
+                    "billable", covered);
+            removeCoveredProjectRows(batch.userId, list(group.get("nonBillableRows")),
+                    "nonBillable", covered);
+            if (list(group.get("billableRows")).isEmpty()
+                    && list(group.get("nonBillableRows")).isEmpty()) {
+                iterator.remove();
+            }
+        }
+        List<Map> otherRows = list(batch.otherGroup.get("otherRows"));
+        otherRows.removeIf(row -> covered.contains(coverageKey("other", batch.userId,
+                text(row.get("dateStr")), text(row.get("activityName")))));
+        if (otherRows.isEmpty()) batch.otherGroup.clear();
+    }
+
+    private void removeCoveredProjectRows(String userId, List<Map> rows,
+                                          String category, Set<String> covered) {
+        rows.removeIf(row -> covered.contains(coverageKey(category, userId,
+                text(row.get("dateStr")), text(row.get("projectCode")))));
+    }
+
+    static String coverageKey(String category, String userId, String day, String detailKey) {
+        return category + "|" + userId + "|" + day + "|" + detailKey;
+    }
+
     private List<Map> queryModified(String formUuid, String fromDate, String toDate) {
     private List<Map> queryModified(String formUuid, String fromDate, String toDate) {
         List<Map> all = new ArrayList<>();
         List<Map> all = new ArrayList<>();
         for (int page = 1; page <= 200; page++) {
         for (int page = 1; page <= 200; page++) {
@@ -551,6 +679,51 @@ public class TimeCardRepairService {
         current.add(child);
         current.add(child);
     }
     }
 
 
+    static void applySummaryTotals(Map<String, Object> update, Map snapshot) {
+        putCategoryTotals(update, snapshot, R_BIL_TABLE,
+                "numberField_mmczo635", "numberField_mmczo636", "numberField_mmczo637", "numberField_mmd80zae",
+                "numberField_mmacxew9", "numberField_mmacxewa", "numberField_mmacxewg", "numberField_mmad6jbc");
+        putCategoryTotals(update, snapshot, R_NON_TABLE,
+                "numberField_mmczo63b", "numberField_mmczo63c", "numberField_mmczo63d", "numberField_mmeakgi9",
+                "numberField_mmad6jbx", "numberField_mmad6jbz", "numberField_mmad6jc1", "numberField_mmeakgia");
+        putCategoryTotals(update, snapshot, R_OTHER_TABLE,
+                "numberField_mmeakgik", "numberField_mmeakgij", "numberField_mmeakgii", "numberField_mmeakgim",
+                "numberField_mmd1smeo", "numberField_mmd1smeq", "numberField_mmd1smes", "numberField_mmeakgic");
+        update.put(R_DAILY_DRAFT, categoryTotal(snapshot, R_BIL_TABLE, "numberField_mmczo635")
+                + categoryTotal(snapshot, R_NON_TABLE, "numberField_mmczo63b")
+                + categoryTotal(snapshot, R_OTHER_TABLE, "numberField_mmeakgik"));
+        update.put(R_DAILY_SUBMITTED, categoryTotal(snapshot, R_BIL_TABLE, "numberField_mmczo636")
+                + categoryTotal(snapshot, R_NON_TABLE, "numberField_mmczo63c")
+                + categoryTotal(snapshot, R_OTHER_TABLE, "numberField_mmeakgij"));
+        update.put(R_DAILY_APPROVED, categoryTotal(snapshot, R_BIL_TABLE, "numberField_mmczo637")
+                + categoryTotal(snapshot, R_NON_TABLE, "numberField_mmczo63d")
+                + categoryTotal(snapshot, R_OTHER_TABLE, "numberField_mmeakgii"));
+        update.put(R_DAILY_CHANGE, categoryTotal(snapshot, R_BIL_TABLE, "numberField_mmd80zae")
+                + categoryTotal(snapshot, R_NON_TABLE, "numberField_mmeakgi9")
+                + categoryTotal(snapshot, R_OTHER_TABLE, "numberField_mmeakgim"));
+    }
+
+    private static void putCategoryTotals(Map<String, Object> update, Map snapshot, String table,
+                                          String draftChild, String submittedChild, String approvedChild, String changeChild,
+                                          String draftMain, String submittedMain, String approvedMain, String changeMain) {
+        update.put(draftMain, categoryTotal(snapshot, table, draftChild));
+        update.put(submittedMain, categoryTotal(snapshot, table, submittedChild));
+        update.put(approvedMain, categoryTotal(snapshot, table, approvedChild));
+        update.put(changeMain, categoryTotal(snapshot, table, changeChild));
+    }
+
+    private static double categoryTotal(Map snapshot, String table, String field) {
+        double total = 0d;
+        for (Map row : list(snapshot.get(table))) total += num(row.get(field));
+        return total;
+    }
+
+    private List<Map> copyRows(Object raw) {
+        List<Map> rows = new ArrayList<>();
+        for (Map row : list(raw)) rows.add(new LinkedHashMap(row));
+        return rows;
+    }
+
     private static class RepairBatch {
     private static class RepairBatch {
         private String batchId;
         private String batchId;
         private String userId;
         private String userId;

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

@@ -109,8 +109,11 @@ workhours:
   summaryApprovalStatusField: "selectField_mre1xz9g"
   summaryApprovalStatusField: "selectField_mre1xz9g"
   approvalFillTypeField: "radioField_mtksjp3i"
   approvalFillTypeField: "radioField_mtksjp3i"
   approvalRepairSubmitDateField: "dateField_tcqr1klh9"
   approvalRepairSubmitDateField: "dateField_tcqr1klh9"
+  otherApprovalRepairSubmitDateField: "dateField_tcqr1klh9"
   approvalRepairBatchField: "textField_tcqr21o6f"
   approvalRepairBatchField: "textField_tcqr21o6f"
-  approvalSourceMonthField: "textField_tcqr352x3"
+  otherApprovalRepairBatchField: "textField_tcqr21o6f"
+  approvalRepairSourceMonthField: "textField_tcqr352x3"
+  otherApprovalRepairSourceMonthField: "textField_tcqr352x3"
   repairSummaryFillTypeField: "radioField_rtc81we6k"
   repairSummaryFillTypeField: "radioField_rtc81we6k"
   repairSummarySubmitDateField: "dateField_rtc92wx04"
   repairSummarySubmitDateField: "dateField_rtc92wx04"
   repairSummaryBatchField: "textField_rtc931nlr"
   repairSummaryBatchField: "textField_rtc931nlr"

+ 6 - 3
mjava-akdsbeisen/src/main/resources/application-prod.yml

@@ -89,9 +89,12 @@ workhours:
   approvalResubmitModifiedLookbackDays: 7
   approvalResubmitModifiedLookbackDays: 7
   summaryApprovalStatusField: "selectField_mre1xz9g"
   summaryApprovalStatusField: "selectField_mre1xz9g"
   approvalFillTypeField: "radioField_mtksjp3i"
   approvalFillTypeField: "radioField_mtksjp3i"
-  approvalRepairSubmitDateField: "dateField_rk7s1y9hy"
-  approvalRepairBatchField: "textField_rk7s2ce5o"
-  approvalSourceMonthField: "textField_rk7t3bhyw"
+  approvalRepairSubmitDateField: "dateField_raxt178mm"
+  otherApprovalRepairSubmitDateField: "dateField_rk7s1y9hy"
+  approvalRepairBatchField: "textField_raxu2ta31"
+  otherApprovalRepairBatchField: "textField_rk7s2ce5o"
+  approvalRepairSourceMonthField: "textField_raxv34uy9"
+  otherApprovalRepairSourceMonthField: "textField_rk7t3bhyw"
   repairSummaryFillTypeField: "radioField_rtc81we6k"
   repairSummaryFillTypeField: "radioField_rtc81we6k"
   repairSummarySubmitDateField: "dateField_rtc92wx04"
   repairSummarySubmitDateField: "dateField_rtc92wx04"
   repairSummaryBatchField: "textField_rtc931nlr"
   repairSummaryBatchField: "textField_rtc931nlr"

+ 114 - 1
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/ApprovalWriteBackServiceTest.java

@@ -6,6 +6,7 @@ import org.junit.jupiter.api.Test;
 import java.util.ArrayList;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Map;
 
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -137,10 +138,12 @@ class ApprovalWriteBackServiceTest {
     }
     }
 
 
     @Test
     @Test
-    void shouldOverwriteSavedDraftHoursWhenRepairIsSubmitted() {
+    void shouldPreserveSavedDraftHoursWhenRepairIsSubmitted() {
         Map<String, Object> existing = new HashMap<>();
         Map<String, Object> existing = new HashMap<>();
         existing.put("projectCode", "P-001");
         existing.put("projectCode", "P-001");
+        existing.put("draftHours", 4d);
         existing.put("submittedHours", 4d);
         existing.put("submittedHours", 4d);
+        existing.put("clientName", "Client A");
         java.util.List<Map> rows = new java.util.ArrayList<>();
         java.util.List<Map> rows = new java.util.ArrayList<>();
         rows.add(existing);
         rows.add(existing);
 
 
@@ -151,9 +154,119 @@ class ApprovalWriteBackServiceTest {
                 rows, submitted, "projectCode", "submittedHours", 4d);
                 rows, submitted, "projectCode", "submittedHours", 4d);
 
 
         assertEquals(1, rows.size());
         assertEquals(1, rows.size());
+        assertEquals(4d, rows.get(0).get("draftHours"));
         assertEquals(4d, rows.get(0).get("submittedHours"));
         assertEquals(4d, rows.get(0).get("submittedHours"));
+        assertEquals("Client A", rows.get(0).get("clientName"));
     }
     }
 
 
+    @Test
+    void shouldRecalculateRepairMainTotalsFromAllChildTables() {
+        Map<String, Object> billable = new HashMap<>();
+        billable.put("numberField_mmczo635", 0d);
+        billable.put("numberField_mmczo636", 4d);
+        billable.put("numberField_mmczo637", 0d);
+        Map<String, Object> nonBillable = new HashMap<>();
+        nonBillable.put("numberField_mmczo63b", 1d);
+        nonBillable.put("numberField_mmczo63c", 0d);
+        Map<String, Object> other = new HashMap<>();
+        other.put("numberField_mmeakgik", 0d);
+        other.put("numberField_mmeakgij", 2d);
+        Map<String, Object> snapshot = new HashMap<>();
+        snapshot.put("tableField_mmczo634", Collections.singletonList(billable));
+        snapshot.put("tableField_mmczo63h", Collections.singletonList(nonBillable));
+        snapshot.put("tableField_mmeakgid", Collections.singletonList(other));
+        Map<String, Object> update = new HashMap<>();
+
+        TimeCardRepairService.applySummaryTotals(update, snapshot);
+
+        assertEquals(4d, update.get("numberField_mmacxewa"));
+        assertEquals(1d, update.get("numberField_mmad6jbx"));
+        assertEquals(2d, update.get("numberField_mmd1smeq"));
+        assertEquals(1d, update.get("numberField_mmd1smel"));
+        assertEquals(6d, update.get("numberField_mmd1smem"));
+    }
+
+    @Test
+    void shouldUseDetailIdentityForRepairResubmitCoverage() {
+        assertEquals("billable|u1|20260805|P-001",
+                TimeCardRepairService.coverageKey("billable", "u1", "20260805", "P-001"));
+    }
+
+    @Test
+    void shouldRecalculateRepairTotalsFromUpdatedApprovalSnapshot() {
+        Map<String, Object> billable = new HashMap<>();
+        billable.put("textField_mmacxew3", "P-001");
+        billable.put("numberField_mmczo635", 8d);
+        billable.put("numberField_mmczo636", 8d);
+        billable.put("numberField_mmczo637", 0d);
+        billable.put("numberField_mmczo637_value", 0d);
+        billable.put("textField_mkov5ozd", "Project D4001");
+        Map<String, Object> other = new HashMap<>();
+        other.put("selectField_mmeakgie", "Time off in lieu of overtime");
+        other.put("numberField_mmeakgik", 8d);
+        other.put("numberField_mmeakgij", 8d);
+        other.put("numberField_mmeakgii", 8d);
+        Map<String, Object> detail = new HashMap<>();
+        detail.put("tableField_mmczo634", Collections.singletonList(billable));
+        detail.put("tableField_mmeakgid", Collections.singletonList(other));
+
+        Map<String, Object> update = RepairApprovalWriteBackService.buildRepairSummaryUpdate(
+                detail, false, true, "P-001", 8d, true);
+
+        assertEquals(8d, update.get("numberField_mmacxewa"));
+        assertEquals(8d, update.get("numberField_mmacxewg"));
+        assertEquals(16d, update.get("numberField_mmd1smem"));
+        assertEquals(16d, update.get("numberField_mmeakgi4"));
+        assertEquals(16d, update.get("numberField_mmd1smel"));
+        assertEquals("全部成功", RepairApprovalWriteBackService.resolveRepairSummaryStatus(update));
+        Map updatedBillable = ((java.util.List<Map>) update.get("tableField_mmczo634")).get(0);
+        assertEquals(8d, updatedBillable.get("numberField_mmczo637"));
+        assertFalse(updatedBillable.containsKey("numberField_mmczo637_value"));
+        assertEquals("Project D4001", updatedBillable.get("textField_mkov5ozd"));
+    }
+
+    @Test
+    void shouldAggregateBillableAndNonBillableChangesByProjectCode() {
+        List<RepairApprovalWriteBackService.Row> rows = new ArrayList<>();
+        rows.add(repairRow("P-001", "Project A", 4d, true));
+        rows.add(repairRow("P-001", "Project A", 6d, true));
+        rows.add(repairRow("P-001", "Project A", 3d, false));
+        rows.add(repairRow("P-002", "Project B", 5d, false));
+
+        List<Map> billable = RepairApprovalWriteBackService.childChanges(rows, true);
+        List<Map> nonBillable = RepairApprovalWriteBackService.childChanges(rows, false);
+
+        assertEquals(1, billable.size());
+        assertEquals("P-001", billable.get(0).get("textField_mmacxew3"));
+        assertEquals("Project A", billable.get(0).get("selectField_mmad6jci"));
+        assertEquals(10d, billable.get(0).get("numberField_mmd80zae"));
+        assertEquals(2, nonBillable.size());
+        assertEquals("P-001", nonBillable.get(0).get("textField_mmczo639"));
+        assertEquals("Project A", nonBillable.get(0).get("selectField_mmczo638"));
+        assertEquals(3d, nonBillable.get(0).get("numberField_mmeakgi9"));
+        assertEquals("P-002", nonBillable.get(1).get("textField_mmczo639"));
+        assertEquals(5d, nonBillable.get(1).get("numberField_mmeakgi9"));
+    }
+
+    private static RepairApprovalWriteBackService.Row repairRow(
+            String projectCode, String projectName, double hours, boolean billable) {
+        RepairApprovalWriteBackService.Row row = new RepairApprovalWriteBackService.Row();
+        row.key = projectCode;
+        row.projectName = projectName;
+        row.hours = hours;
+        row.billable = billable;
+        row.approve = true;
+        return row;
+    }
+
+    @Test
+    void shouldKeepRepairSummaryPendingUntilAllCategoriesAreApproved() {
+        Map<String, Object> totals = new HashMap<>();
+        totals.put("numberField_mmd1smem", 16d);
+        totals.put("numberField_mmeakgi4", 8d);
+
+        assertEquals("审批中", RepairApprovalWriteBackService.resolveRepairSummaryStatus(totals));
+    }
 
 
     @Test
     @Test
     void shouldNeverTreatEmptyOrMalformedApprovalDetailsAsSuccess() {
     void shouldNeverTreatEmptyOrMalformedApprovalDetailsAsSuccess() {