Explorar el Código

fix(timecard): 完善审批明细容量分片

malk hace 3 semanas
padre
commit
b7d76a0b8e

+ 214 - 34
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/ReSubmitApprovalService.java

@@ -60,7 +60,7 @@ public class ReSubmitApprovalService {
     /**
      * 批量历史补偿的目标分片大小;以员工整组为边界,允许单个员工整组略超目标值。
      */
-    private static final int TARGET_APPROVAL_SUBTABLE_ROWS = 100;
+    private static final int TARGET_APPROVAL_SUBTABLE_ROWS = 300;
 
     /**
      * 兜底部门主管 userId(Raymond,前端 v2.42 保持一致)
@@ -261,11 +261,13 @@ public class ReSubmitApprovalService {
                 // 3a) 项目工时:每个 PM 一张单
                 for (ManagerGroup mg : cr.managerGroups.values()) {
                     try {
-                        String action = processManagerApproval(mg, monthText, uid);
-                        if ("created".equals(action)) {
-                            billableCreated++;
-                        } else if ("updated".equals(action)) {
-                            billableUpdated++;
+                        List<String> actions = processManagerApproval(mg, monthText, uid);
+                        for (String action : actions) {
+                            if ("created".equals(action)) {
+                                billableCreated++;
+                            } else if ("updated".equals(action)) {
+                                billableUpdated++;
+                            }
                         }
                     } catch (Exception ex) {
                         failed++;
@@ -275,11 +277,14 @@ public class ReSubmitApprovalService {
                 // 3b) 其他工时:一人一张单,approver 用该员工部门主管(记录中带),否则兜底 Raymond
                 if (!cr.otherRows.isEmpty()) {
                     try {
-                        String action = processOtherApproval(cr.otherRows, cr.deptManagerId, monthText, uid);
-                        if ("created".equals(action)) {
-                            otherCreated++;
-                        } else if ("updated".equals(action)) {
-                            otherUpdated++;
+                        List<String> actions = processOtherApproval(
+                                cr.otherRows, cr.deptManagerId, monthText, uid);
+                        for (String action : actions) {
+                            if ("created".equals(action)) {
+                                otherCreated++;
+                            } else if ("updated".equals(action)) {
+                                otherUpdated++;
+                            }
                         }
                     } catch (Exception ex) {
                         failed++;
@@ -331,14 +336,70 @@ public class ReSubmitApprovalService {
 
         Map<String, Object> formData = JSON.parseObject(
                 request.getFormDataJson(), Map.class);
-        startProcessInstance(
-                request.getFormUuid(),
-                request.getProcessCode(),
-                formData,
-                request.getDeptId());
+        List<Map<String, Object>> shards = billable
+                ? splitApprovalStartFormData(formData)
+                : splitOtherApprovalStartFormData(formData);
+        for (Map<String, Object> shard : shards) {
+            startProcessInstance(
+                    request.getFormUuid(),
+                    request.getProcessCode(),
+                    shard,
+                    request.getDeptId());
+        }
         return true;
     }
 
+    private List<Map<String, Object>> splitApprovalStartFormData(Map<String, Object> formData) {
+        List<Map> billableRows = listOrEmpty(formData, A_BIL_TABLE);
+        List<Map> nonBillableRows = listOrEmpty(formData, A_NON_TABLE);
+        List<RowShard> rowShards = splitRowsByHardLimit(billableRows, nonBillableRows);
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (RowShard rowShard : rowShards) {
+            Map<String, Object> shard = new HashMap<>(formData);
+            shard.remove(A_BIL_TABLE);
+            shard.remove(A_NON_TABLE);
+            putRowsIfPresent(shard, A_BIL_TABLE, rowShard.firstRows);
+            putRowsIfPresent(shard, A_NON_TABLE, rowShard.secondRows);
+            double billableHours = sumHours(rowShard.firstRows, A_BIL_HOURS);
+            double nonBillableHours = sumHours(rowShard.secondRows, A_NON_HOURS);
+            shard.put(A_PROJECT_HOURS, billableHours);
+            shard.put(A_NON_PROJECT_HOURS, nonBillableHours);
+            shard.put(A_TOTAL_HOURS, billableHours + nonBillableHours);
+            result.add(shard);
+        }
+        return result;
+    }
+
+    private List<Map<String, Object>> splitOtherApprovalStartFormData(Map<String, Object> formData) {
+        List<Map> rows = listOrEmpty(formData, O_SUB_TABLE);
+        List<RowShard> rowShards = splitRowsByHardLimit(rows, Collections.emptyList());
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (RowShard rowShard : rowShards) {
+            Map<String, Object> shard = new HashMap<>(formData);
+            shard.remove(O_SUB_TABLE);
+            putRowsIfPresent(shard, O_SUB_TABLE, rowShard.firstRows);
+            shard.put(O_TOTAL_HOURS, sumHours(rowShard.firstRows, O_SUB_HOURS));
+            result.add(shard);
+        }
+        return result;
+    }
+
+    private void putRowsIfPresent(Map<String, Object> formData,
+                                  String tableFieldId,
+                                  List<Map> rows) {
+        if (!rows.isEmpty()) {
+            formData.put(tableFieldId, rows);
+        }
+    }
+
+    private double sumHours(List<Map> rows, String hoursFieldId) {
+        double total = 0;
+        for (Map row : rows) {
+            total += num(row, hoursFieldId);
+        }
+        return total;
+    }
+
     /**
      * collectApprovalData + collectOtherApprovalData 的等价:
      * 遍历该员工该月每天记录,按项目工时 / 其他工时反构造 delta 行,按 PM 分组
@@ -654,7 +715,19 @@ public class ReSubmitApprovalService {
      * processManagerApproval 等价 — queryProcess(RUNNING) 有则 update / 无则 create。
      * COMPLETED 覆盖已在 collectApprovalData 前过滤,避免历史已完成单被重复发起。
      */
-    private String processManagerApproval(ManagerGroup mg, String monthText, String submitterUid) {
+    private List<String> processManagerApproval(ManagerGroup mg,
+                                                String monthText,
+                                                String submitterUid) {
+        List<String> actions = new ArrayList<>();
+        for (ManagerGroup shard : splitManagerGroupByHardLimit(mg)) {
+            actions.add(processManagerApprovalShard(shard, monthText, submitterUid));
+        }
+        return actions;
+    }
+
+    private String processManagerApprovalShard(ManagerGroup mg,
+                                               String monthText,
+                                               String submitterUid) {
         Map<String, Object> searchFieldJson = new HashMap<>();
         searchFieldJson.put(A_MONTH_TEXT, monthText);
         // fixme employeeField 查询用数组: 单值 [uid]
@@ -671,8 +744,9 @@ public class ReSubmitApprovalService {
             if (!managerApprovalHasCapacity(finstId, mg, submitterUid, monthText, existingFormData)) {
                 continue;
             }
-            updateApprovalInstance(finstId, mg, monthText, submitterUid, existingFormData);
-            return "updated";
+            if (updateApprovalInstance(finstId, mg, monthText, submitterUid, existingFormData)) {
+                return "updated";
+            }
         }
         // fixme: 单张审批子表达到 500 行后必须新建分片,继续覆盖会丢失第 501 行后的明细。
         createApprovalInstance(mg, monthText, submitterUid);
@@ -706,8 +780,9 @@ public class ReSubmitApprovalService {
                 existBil, A_BIL_SUBMITTER_UID, A_BIL_DAY_TEXT, A_BIL_PROJECT_CODE);
         int existingNonBillableCount = countUniqueRows(
                 existNon, A_NON_SUBMITTER_UID, A_NON_DAY_TEXT, A_NON_PROJECT_CODE);
-        return canAppendToApprovalShard(existingBillableCount, billable.rows.size())
-                && canAppendToApprovalShard(existingNonBillableCount, nonBillable.rows.size());
+        int existingCount = existingBillableCount + existingNonBillableCount;
+        int mergedCount = billable.rows.size() + nonBillable.rows.size();
+        return canAppendToApprovalShard(existingCount, mergedCount);
     }
 
     /**
@@ -715,9 +790,9 @@ public class ReSubmitApprovalService {
      */
     private void createApprovalInstance(ManagerGroup mg, String monthText, String submitterUid) {
         McException.assertAccessException(
-                !hasSubtableCapacity(mg.billableRows.size())
-                        || !hasSubtableCapacity(mg.nonBillableRows.size()),
-                "单个员工的审批明细超过宜搭子表 500 行限制");
+                !hasSubtableCapacity(
+                        mg.billableRows.size() + mg.nonBillableRows.size()),
+                "单张工时审批明细不能超过 500 行");
         Map<String, Object> formData = new HashMap<>();
         formData.put(A_MONTH_DATE, monthToTs(monthText));
         formData.put(A_MONTH_TEXT, monthText);
@@ -755,8 +830,9 @@ public class ReSubmitApprovalService {
     /**
      * 累计更新已有 RUNNING 工时审批单(按唯一键 userId + dayText + projectCode 去重合并)
      */
-    private void updateApprovalInstance(String formInstanceId, ManagerGroup mg,
-                                        String monthText, String submitterUid, Map existingFormData) {
+    private boolean updateApprovalInstance(String formInstanceId, ManagerGroup mg,
+                                           String monthText, String submitterUid,
+                                           Map existingFormData) {
         List<Map> existBil = resolveFullRows(
                 formInstanceId,
                 whConf.getFormUuidApproval(),
@@ -775,9 +851,12 @@ public class ReSubmitApprovalService {
                 A_NON_SUBMITTER_UID, A_NON_DAY_TEXT, A_NON_PROJECT_CODE,
                 (br) -> buildNonBillableApprovalRow(br, submitterUid));
 
+        if (!hasSubtableCapacity(mrBil.rows.size() + mrNon.rows.size())) {
+            return false;
+        }
         if (mrBil.addedHours == 0 && mrNon.addedHours == 0) {
             log.info("[补发起] 已存在等效明细,跳过更新 formInstanceId={}", formInstanceId);
-            return;
+            return true;
         }
 
         double existProject = num(existingFormData, A_PROJECT_HOURS);
@@ -833,13 +912,28 @@ public class ReSubmitApprovalService {
                 .build(), YDConf.FORM_OPERATION.update);
         log.info("[补发起] 累计更新工时审批单 formInstanceId={} pm={} addedBil={} addedNon={}",
                 formInstanceId, mg.managerId, mrBil.addedHours, mrNon.addedHours);
+        return true;
     }
 
     /**
      * 其他工时审批处理(与 processManagerApproval 同构,但 approver 为员工的部门主管)
      */
-    private String processOtherApproval(List<OtherRow> otherRows, String deptManagerId,
-                                        String monthText, String submitterUid) {
+    private List<String> processOtherApproval(List<OtherRow> otherRows,
+                                              String deptManagerId,
+                                              String monthText,
+                                              String submitterUid) {
+        List<String> actions = new ArrayList<>();
+        for (List<OtherRow> shard : splitListByHardLimit(otherRows)) {
+            actions.add(processOtherApprovalShard(
+                    shard, deptManagerId, monthText, submitterUid));
+        }
+        return actions;
+    }
+
+    private String processOtherApprovalShard(List<OtherRow> otherRows,
+                                             String deptManagerId,
+                                             String monthText,
+                                             String submitterUid) {
         Map<String, Object> searchFieldJson = new HashMap<>();
         searchFieldJson.put(O_MONTH_TEXT, monthText);
         searchFieldJson.put(O_DEPT_MANAGER, Collections.singletonList(deptManagerId));
@@ -854,8 +948,10 @@ public class ReSubmitApprovalService {
             if (!otherApprovalHasCapacity(finstId, otherRows, submitterUid, existingFormData)) {
                 continue;
             }
-            updateOtherApprovalInstance(finstId, otherRows, monthText, submitterUid, existingFormData);
-            return "updated";
+            if (updateOtherApprovalInstance(
+                    finstId, otherRows, monthText, submitterUid, existingFormData)) {
+                return "updated";
+            }
         }
         createOtherApprovalInstance(otherRows, deptManagerId, monthText, submitterUid);
         return "created";
@@ -914,8 +1010,11 @@ public class ReSubmitApprovalService {
                 submitterUid, deptManagerId, monthText, total, sub.size());
     }
 
-    private void updateOtherApprovalInstance(String formInstanceId, List<OtherRow> otherRows,
-                                             String monthText, String submitterUid, Map existingFormData) {
+    private boolean updateOtherApprovalInstance(String formInstanceId,
+                                                List<OtherRow> otherRows,
+                                                String monthText,
+                                                String submitterUid,
+                                                Map existingFormData) {
         List<Map> existRows = resolveFullRows(
                 formInstanceId,
                 whConf.getFormUuidOtherApproval(),
@@ -943,9 +1042,12 @@ public class ReSubmitApprovalService {
             merged.add(buildOtherApprovalRow(or, submitterUid));
             addedHours += or.hours;
         }
+        if (!hasSubtableCapacity(merged.size())) {
+            return false;
+        }
         if (addedHours == 0) {
             log.info("[补发起] 其他工时无新增明细 formInstanceId={}", formInstanceId);
-            return;
+            return true;
         }
         double existTotal = num(existingFormData, O_TOTAL_HOURS);
         Map<String, Object> updateData = new HashMap<>();
@@ -982,6 +1084,7 @@ public class ReSubmitApprovalService {
                 .useLatestVersion(true)
                 .build(), YDConf.FORM_OPERATION.update);
         log.info("[补发起] 累计更新其他工时审批单 formInstanceId={} addedHours={}", formInstanceId, addedHours);
+        return true;
     }
 
     // ============================================================
@@ -1038,6 +1141,83 @@ public class ReSubmitApprovalService {
         double addedHours = 0;
     }
 
+    private static class RowShard {
+        List<Map> firstRows = new ArrayList<>();
+        List<Map> secondRows = new ArrayList<>();
+
+        int size() {
+            return firstRows.size() + secondRows.size();
+        }
+    }
+
+    private List<RowShard> splitRowsByHardLimit(List<Map> firstRows, List<Map> secondRows) {
+        List<RowShard> shards = new ArrayList<>();
+        shards.add(new RowShard());
+        for (Map row : firstRows) {
+            appendRowToShard(shards, row, true);
+        }
+        for (Map row : secondRows) {
+            appendRowToShard(shards, row, false);
+        }
+        return shards;
+    }
+
+    private void appendRowToShard(List<RowShard> shards, Map row, boolean firstTable) {
+        RowShard current = shards.get(shards.size() - 1);
+        if (current.size() >= MAX_APPROVAL_SUBTABLE_ROWS) {
+            current = new RowShard();
+            shards.add(current);
+        }
+        (firstTable ? current.firstRows : current.secondRows).add(row);
+    }
+
+    private List<ManagerGroup> splitManagerGroupByHardLimit(ManagerGroup source) {
+        if (source.billableRows.size() + source.nonBillableRows.size()
+                <= MAX_APPROVAL_SUBTABLE_ROWS) {
+            return Collections.singletonList(source);
+        }
+        List<ManagerGroup> shards = new ArrayList<>();
+        shards.add(newManagerGroup(source.managerId));
+        for (BillRow row : source.billableRows) {
+            appendManagerRow(shards, row, true);
+        }
+        for (BillRow row : source.nonBillableRows) {
+            appendManagerRow(shards, row, false);
+        }
+        return shards;
+    }
+
+    private ManagerGroup newManagerGroup(String managerId) {
+        ManagerGroup group = new ManagerGroup();
+        group.managerId = managerId;
+        return group;
+    }
+
+    private void appendManagerRow(List<ManagerGroup> shards, BillRow row, boolean billable) {
+        ManagerGroup current = shards.get(shards.size() - 1);
+        if (current.billableRows.size() + current.nonBillableRows.size()
+                >= MAX_APPROVAL_SUBTABLE_ROWS) {
+            current = newManagerGroup(current.managerId);
+            shards.add(current);
+        }
+        if (billable) {
+            current.billableRows.add(row);
+            current.billableTotal += row.hours;
+        } else {
+            current.nonBillableRows.add(row);
+            current.nonBillableTotal += row.hours;
+        }
+    }
+
+    private <T> List<List<T>> splitListByHardLimit(List<T> rows) {
+        List<List<T>> shards = new ArrayList<>();
+        for (int from = 0; from < rows.size(); from += MAX_APPROVAL_SUBTABLE_ROWS) {
+            int to = Math.min(from + MAX_APPROVAL_SUBTABLE_ROWS, rows.size());
+            shards.add(new ArrayList<>(rows.subList(from, to)));
+        }
+        return shards;
+    }
+
     static boolean hasSubtableCapacity(int rowCount) {
         return rowCount <= MAX_APPROVAL_SUBTABLE_ROWS;
     }

+ 125 - 4
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/ReSubmitApprovalServiceTest.java

@@ -11,6 +11,7 @@ import org.springframework.test.util.ReflectionTestUtils;
 
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -127,9 +128,129 @@ class ReSubmitApprovalServiceTest {
     void shouldCreateApprovalShardWhenSubtableWouldExceedLimit() {
         assertTrue(ReSubmitApprovalService.hasSubtableCapacity(500));
         assertFalse(ReSubmitApprovalService.hasSubtableCapacity(501));
-        assertTrue(ReSubmitApprovalService.canAppendToApprovalShard(80, 100));
-        assertFalse(ReSubmitApprovalService.canAppendToApprovalShard(90, 110));
-        assertTrue(ReSubmitApprovalService.canAppendToApprovalShard(0, 120));
-        assertTrue(ReSubmitApprovalService.canAppendToApprovalShard(120, 120));
+        assertTrue(ReSubmitApprovalService.canAppendToApprovalShard(250, 300));
+        assertFalse(ReSubmitApprovalService.canAppendToApprovalShard(250, 301));
+        assertTrue(ReSubmitApprovalService.canAppendToApprovalShard(0, 450));
+        assertTrue(ReSubmitApprovalService.canAppendToApprovalShard(450, 450));
+        assertFalse(ReSubmitApprovalService.canAppendToApprovalShard(0, 501));
+    }
+
+    @Test
+    void shouldKeepSingleSubmitterRowsTogetherWithinHardLimit() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf whConf = new WHConf();
+        whConf.setYidaAppType("APP");
+        whConf.setYidaSystemToken("TOKEN");
+        whConf.setFormUuidApproval("FORM-APPROVAL");
+        whConf.setApprovalProcessCode("PROCESS-APPROVAL");
+        whConf.setApprovalOriginatorUserId("system-user");
+
+        ReSubmitApprovalService service = new ReSubmitApprovalService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", whConf);
+
+        Map<String, Object> formData = new HashMap<>();
+        List<Map<String, Object>> rows = new ArrayList<>();
+        for (int index = 0; index < 450; index++) {
+            rows.add(Collections.singletonMap("numberField_mmacxew9", 1));
+        }
+        formData.put("tableField_mmae8t99", rows);
+
+        TimeCardApprovalStartRequest request = new TimeCardApprovalStartRequest();
+        request.setFormUuid("FORM-APPROVAL");
+        request.setProcessCode("PROCESS-APPROVAL");
+        request.setFormDataJson(com.alibaba.fastjson.JSON.toJSONString(formData));
+
+        assertTrue(service.startApproval(request));
+
+        ArgumentCaptor<YDParam> captor = ArgumentCaptor.forClass(YDParam.class);
+        verify(ydClient).operateData(captor.capture(), eq(YDConf.FORM_OPERATION.start));
+        Map started = com.alibaba.fastjson.JSON.parseObject(
+                captor.getValue().getFormDataJson(), Map.class);
+        assertEquals(450, ((List) started.get("tableField_mmae8t99")).size());
+    }
+
+    @Test
+    void shouldSplitSingleSubmitterOnlyWhenStartRowsExceedHardLimit() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf whConf = new WHConf();
+        whConf.setYidaAppType("APP");
+        whConf.setYidaSystemToken("TOKEN");
+        whConf.setFormUuidApproval("FORM-APPROVAL");
+        whConf.setApprovalProcessCode("PROCESS-APPROVAL");
+        whConf.setApprovalOriginatorUserId("system-user");
+
+        ReSubmitApprovalService service = new ReSubmitApprovalService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", whConf);
+
+        Map<String, Object> formData = new HashMap<>();
+        List<Map<String, Object>> rows = new ArrayList<>();
+        for (int index = 0; index < 501; index++) {
+            Map<String, Object> row = new HashMap<>();
+            row.put("numberField_mmacxew9", 1);
+            rows.add(row);
+        }
+        formData.put("tableField_mmae8t99", rows);
+
+        TimeCardApprovalStartRequest request = new TimeCardApprovalStartRequest();
+        request.setFormUuid("FORM-APPROVAL");
+        request.setProcessCode("PROCESS-APPROVAL");
+        request.setFormDataJson(com.alibaba.fastjson.JSON.toJSONString(formData));
+
+        assertTrue(service.startApproval(request));
+
+        ArgumentCaptor<YDParam> captor = ArgumentCaptor.forClass(YDParam.class);
+        verify(ydClient, org.mockito.Mockito.times(2))
+                .operateData(captor.capture(), eq(YDConf.FORM_OPERATION.start));
+        Map first = com.alibaba.fastjson.JSON.parseObject(
+                captor.getAllValues().get(0).getFormDataJson(), Map.class);
+        Map second = com.alibaba.fastjson.JSON.parseObject(
+                captor.getAllValues().get(1).getFormDataJson(), Map.class);
+        assertEquals(500, ((List) first.get("tableField_mmae8t99")).size());
+        assertEquals(1, ((List) second.get("tableField_mmae8t99")).size());
+        assertEquals(500d, ((Number) first.get("numberField_mmd5b5gl")).doubleValue());
+        assertEquals(1d, ((Number) second.get("numberField_mmd5b5gl")).doubleValue());
+    }
+
+    @Test
+    void shouldSplitOtherApprovalRowsAtHardLimit() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf whConf = new WHConf();
+        whConf.setYidaAppType("APP");
+        whConf.setYidaSystemToken("TOKEN");
+        whConf.setFormUuidOtherApproval("FORM-OTHER");
+        whConf.setOtherApprovalProcessCode("PROCESS-OTHER");
+        whConf.setApprovalOriginatorUserId("system-user");
+
+        ReSubmitApprovalService service = new ReSubmitApprovalService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", whConf);
+
+        Map<String, Object> formData = new HashMap<>();
+        List<Map<String, Object>> rows = new ArrayList<>();
+        for (int index = 0; index < 501; index++) {
+            rows.add(Collections.singletonMap("numberField_mmd1wu9e", 2));
+        }
+        formData.put("tableField_mmd1wu9h", rows);
+
+        TimeCardApprovalStartRequest request = new TimeCardApprovalStartRequest();
+        request.setFormUuid("FORM-OTHER");
+        request.setProcessCode("PROCESS-OTHER");
+        request.setFormDataJson(com.alibaba.fastjson.JSON.toJSONString(formData));
+
+        assertTrue(service.startApproval(request));
+
+        ArgumentCaptor<YDParam> captor = ArgumentCaptor.forClass(YDParam.class);
+        verify(ydClient, org.mockito.Mockito.times(2))
+                .operateData(captor.capture(), eq(YDConf.FORM_OPERATION.start));
+        Map first = com.alibaba.fastjson.JSON.parseObject(
+                captor.getAllValues().get(0).getFormDataJson(), Map.class);
+        Map second = com.alibaba.fastjson.JSON.parseObject(
+                captor.getAllValues().get(1).getFormDataJson(), Map.class);
+        assertEquals(500, ((List) first.get("tableField_mmd1wu9h")).size());
+        assertEquals(1, ((List) second.get("tableField_mmd1wu9h")).size());
+        assertEquals(1000d, ((Number) first.get("numberField_mmd5b5gl")).doubleValue());
+        assertEquals(2d, ((Number) second.get("numberField_mmd5b5gl")).doubleValue());
     }
 }