Forráskód Böngészése

fix(workhours): split Raymond approvals by project

malk 1 hónapja%!(EXTRA string=óta)
szülő
commit
ff099e1037

+ 100 - 18
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/ReSubmitApprovalService.java

@@ -27,8 +27,9 @@ import java.util.*;
  * 即前端 submitApprovalProcess 链路失败(startInstance 挂了/静默丢),导致部分员工工时从未进入审批。
  * <p>
  * 逻辑完全参考前端 TimeCard 的 submitApprovalProcess:
- * 1. collectApprovalData — 遍历汇总表每天子表,delta = submitted - approved - change, delta>0 才 push,按 PM 分组
- * 2. processManagerApproval — queryProcess(month + PM + RUNNING) 有则 update / 无则 create
+ * 1. collectApprovalData — 遍历汇总表每天子表,delta = submitted - approved - change, delta>0 才 push;
+ *    普通 PM 按审批人分组,Raymond 兜底按项目拆组
+ * 2. processManagerApproval — queryProcess(month + PM + RUNNING) 有同项目可用分片则 update / 无则 create
  *    COMPLETED 明细先进入覆盖索引;汇总尚未落值时归回写处理,不重复发起
  * 3. collectOtherApprovalData / processOtherApproval — 其他工时按当前员工的部门主管作 approver,同样 query→update/create
  * <p>
@@ -205,10 +206,12 @@ public class ReSubmitApprovalService {
     }
 
     /**
-     * 某员工某月按 PM 聚合的项目工时组
+     * 某员工某月的项目工时组;Raymond 兜底组带项目标识做项目隔离
      */
     private static class ManagerGroup {
         String managerId;
+        String projectCode;
+        String projectName;
         List<BillRow> billableRows = new ArrayList<>();
         List<BillRow> nonBillableRows = new ArrayList<>();
         double billableTotal = 0;
@@ -271,7 +274,7 @@ public class ReSubmitApprovalService {
                     skipped++;
                     continue;
                 }
-                // 3a) 项目工时:每个 PM 一张
+                // 3a) 项目工时:普通 PM 按人合单,Raymond 兜底按项目拆
                 for (ManagerGroup mg : cr.managerGroups.values()) {
                     try {
                         List<String> actions = processManagerApproval(mg, monthText, uid);
@@ -284,7 +287,8 @@ public class ReSubmitApprovalService {
                         }
                     } catch (Exception ex) {
                         failed++;
-                        log.error("[补发起] 项目工时发起失败 uid={} pm={} month={}", uid, mg.managerId, monthText, ex);
+                        log.error("[补发起] 项目工时发起失败 uid={} pm={} project={} month={}",
+                                uid, mg.managerId, mg.projectCode, monthText, ex);
                     }
                 }
                 // 3b) 其他工时:一人一张单,approver 用该员工部门主管(记录中带),否则兜底 Raymond
@@ -415,7 +419,8 @@ public class ReSubmitApprovalService {
 
     /**
      * collectApprovalData + collectOtherApprovalData 的等价:
-     * 遍历该员工该月每天记录,按项目工时 / 其他工时反构造 delta 行,按 PM 分组
+     * 遍历该员工该月每天记录,按项目工时 / 其他工时反构造 delta 行。
+     * 普通 PM 按审批人分组,Raymond 兜底按项目拆组;项目编号为空时使用项目名称。
      */
     private static class CollectResult {
         Map<String, ManagerGroup> managerGroups = new LinkedHashMap<>();
@@ -465,16 +470,21 @@ public class ReSubmitApprovalService {
                 String pmRaw = firstIdOrText(row, S_BIL_MANAGER);
                 final String pm = projectManagerCache.computeIfAbsent(
                         StringUtils.defaultString(pmRaw), this::resolveProjectManager);
-                ManagerGroup mg = cr.managerGroups.computeIfAbsent(pm, k -> {
+                String projectName = firstNonBlank(
+                        str(row, S_BIL_PROJECT_NAME_TEXT), str(row, S_BIL_PROJECT_NAME));
+                String groupKey = approvalGroupKey(pm, projectCode, projectName);
+                ManagerGroup mg = cr.managerGroups.computeIfAbsent(groupKey, k -> {
                     ManagerGroup m = new ManagerGroup();
                     m.managerId = pm;
+                    m.projectCode = DEFAULT_MANAGER_ID.equals(pm) ? projectCode : "";
+                    m.projectName = DEFAULT_MANAGER_ID.equals(pm) ? projectName : "";
                     return m;
                 });
                 BillRow br = new BillRow();
                 br.dayText = dayText;
                 br.dayTs = dayTs;
                 br.projectCode = projectCode;
-                br.projectName = firstNonBlank(str(row, S_BIL_PROJECT_NAME_TEXT), str(row, S_BIL_PROJECT_NAME));
+                br.projectName = projectName;
                 br.hours = delta;
                 br.submitterUid = uid;
                 br.monthText = monthText;
@@ -500,16 +510,21 @@ public class ReSubmitApprovalService {
                 String pmRaw = firstIdOrText(row, S_NON_MANAGER);
                 final String pm = projectManagerCache.computeIfAbsent(
                         StringUtils.defaultString(pmRaw), this::resolveProjectManager);
-                ManagerGroup mg = cr.managerGroups.computeIfAbsent(pm, k -> {
+                String projectName = firstNonBlank(
+                        str(row, S_NON_PROJECT_NAME_TEXT), str(row, S_NON_PROJECT_NAME));
+                String groupKey = approvalGroupKey(pm, projectCode, projectName);
+                ManagerGroup mg = cr.managerGroups.computeIfAbsent(groupKey, k -> {
                     ManagerGroup m = new ManagerGroup();
                     m.managerId = pm;
+                    m.projectCode = DEFAULT_MANAGER_ID.equals(pm) ? projectCode : "";
+                    m.projectName = DEFAULT_MANAGER_ID.equals(pm) ? projectName : "";
                     return m;
                 });
                 BillRow br = new BillRow();
                 br.dayText = dayText;
                 br.dayTs = dayTs;
                 br.projectCode = projectCode;
-                br.projectName = firstNonBlank(str(row, S_NON_PROJECT_NAME_TEXT), str(row, S_NON_PROJECT_NAME));
+                br.projectName = projectName;
                 br.hours = delta;
                 br.submitterUid = uid;
                 br.monthText = monthText;
@@ -548,6 +563,24 @@ public class ReSubmitApprovalService {
         return cr;
     }
 
+    /**
+     * 生成项目工时审批分组键。
+     *
+     * @param managerId  审批人 userId
+     * @param projectCode 项目编号
+     * @param projectName 项目名称
+     * @return 普通 PM 返回 managerId;Raymond 返回项目隔离键,编号为空时用名称
+     */
+    static String approvalGroupKey(String managerId, String projectCode, String projectName) {
+        if (DEFAULT_MANAGER_ID.equals(managerId)) {
+            String projectIdentity = StringUtils.isNotBlank(projectCode)
+                    ? projectCode
+                    : "name:" + StringUtils.defaultString(projectName);
+            return managerId + "|project|" + projectIdentity;
+        }
+        return managerId;
+    }
+
     /**
      * prd 项目经理离职时,改由人员档案中的在职主管审批;仅在整条替补链路不可用时兜底 Raymond。
      */
@@ -873,6 +906,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);
+        if (!approvalMatchesProject(existBil, existNon, mg.projectCode, mg.projectName)) {
+            return false;
+        }
         int existingCount = existingBillableCount + existingNonBillableCount;
         int mergedCount = billable.rows.size() + nonBillable.rows.size();
         return canAppendToApprovalShard(existingCount, mergedCount);
@@ -915,8 +951,8 @@ public class ReSubmitApprovalService {
                 whConf.getApprovalProcessCode(),
                 formData,
                 whConf.getApprovalOriginatorDeptId());
-        log.info("[补发起] 新建工时审批单 submitterUid={} pm={} month={} bilTotal={} nonBilTotal={} rows={}+{}",
-                submitterUid, mg.managerId, monthText,
+        log.info("[补发起] 新建工时审批单 submitterUid={} pm={} project={} month={} bilTotal={} nonBilTotal={} rows={}+{}",
+                submitterUid, mg.managerId, mg.projectCode, monthText,
                 mg.billableTotal, mg.nonBillableTotal, billableSub.size(), nonBillableSub.size());
     }
 
@@ -1003,11 +1039,55 @@ public class ReSubmitApprovalService {
                 .ignoreEmpty(false)
                 .useLatestVersion(true)
                 .build(), YDConf.FORM_OPERATION.update);
-        log.info("[补发起] 累计更新工时审批单 formInstanceId={} pm={} addedBil={} addedNon={}",
-                formInstanceId, mg.managerId, mrBil.addedHours, mrNon.addedHours);
+        log.info("[补发起] 累计更新工时审批单 formInstanceId={} pm={} project={} addedBil={} addedNon={}",
+                formInstanceId, mg.managerId, mg.projectCode, mrBil.addedHours, mrNon.addedHours);
         return true;
     }
 
+    /**
+     * 判断已有 Raymond 审批单是否只包含目标项目。
+     *
+     * @param billableRows Billable 明细
+     * @param nonBillableRows Non Billable 明细
+     * @param projectCode Raymond 组的项目编号
+     * @param projectName Raymond 组的项目名称;编号为空时用于项目隔离,两者均空表示普通 PM 组
+     * @return 可否追加到该审批单
+     */
+    static boolean approvalMatchesProject(List<Map> billableRows,
+                                          List<Map> nonBillableRows,
+                                          String projectCode,
+                                          String projectName) {
+        if (StringUtils.isBlank(projectCode) && StringUtils.isBlank(projectName)) {
+            return true;
+        }
+        int rowCount = 0;
+        for (Map row : billableRows) {
+            rowCount++;
+            if (!matchesProject(row, A_BIL_PROJECT_CODE, A_BIL_PROJECT_NAME, projectCode, projectName)) {
+                return false;
+            }
+        }
+        for (Map row : nonBillableRows) {
+            rowCount++;
+            if (!matchesProject(row, A_NON_PROJECT_CODE, A_NON_PROJECT_NAME, projectCode, projectName)) {
+                return false;
+            }
+        }
+        return rowCount > 0;
+    }
+
+    private static boolean matchesProject(Map row,
+                                          String codeField,
+                                          String nameField,
+                                          String projectCode,
+                                          String projectName) {
+        String rowProjectCode = str(row, codeField);
+        if (StringUtils.isNotBlank(projectCode)) {
+            return projectCode.equals(rowProjectCode);
+        }
+        return StringUtils.isBlank(rowProjectCode) && StringUtils.equals(projectName, str(row, nameField));
+    }
+
     /**
      * 其他工时审批处理(与 processManagerApproval 同构,但 approver 为员工的部门主管)
      */
@@ -1270,7 +1350,7 @@ public class ReSubmitApprovalService {
             return Collections.singletonList(source);
         }
         List<ManagerGroup> shards = new ArrayList<>();
-        shards.add(newManagerGroup(source.managerId));
+        shards.add(newManagerGroup(source.managerId, source.projectCode, source.projectName));
         for (BillRow row : source.billableRows) {
             appendManagerRow(shards, row, true);
         }
@@ -1280,9 +1360,11 @@ public class ReSubmitApprovalService {
         return shards;
     }
 
-    private ManagerGroup newManagerGroup(String managerId) {
+    private ManagerGroup newManagerGroup(String managerId, String projectCode, String projectName) {
         ManagerGroup group = new ManagerGroup();
         group.managerId = managerId;
+        group.projectCode = projectCode;
+        group.projectName = projectName;
         return group;
     }
 
@@ -1290,7 +1372,7 @@ public class ReSubmitApprovalService {
         ManagerGroup current = shards.get(shards.size() - 1);
         if (current.billableRows.size() + current.nonBillableRows.size()
                 >= MAX_APPROVAL_SUBTABLE_ROWS) {
-            current = newManagerGroup(current.managerId);
+            current = newManagerGroup(current.managerId, current.projectCode, current.projectName);
             shards.add(current);
         }
         if (billable) {
@@ -1528,7 +1610,7 @@ public class ReSubmitApprovalService {
         return (long) num(m, k);
     }
 
-    private String str(Map m, String k) {
+    private static String str(Map m, String k) {
         Object v = m == null ? null : m.get(k);
         return v == null ? "" : String.valueOf(v);
     }

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

@@ -141,6 +141,66 @@ class ReSubmitApprovalServiceTest {
         assertFalse(ReSubmitApprovalService.canAppendToApprovalShard(0, 501));
     }
 
+    @Test
+    void shouldSplitFallbackManagerGroupByProject() {
+        String projectA = ReSubmitApprovalService.approvalGroupKey(DEFAULT_MANAGER_ID, "P-A", "项目 A");
+        String projectB = ReSubmitApprovalService.approvalGroupKey(DEFAULT_MANAGER_ID, "P-B", "项目 B");
+
+        assertFalse(projectA.equals(projectB));
+        assertEquals(DEFAULT_MANAGER_ID + "|project|P-A", projectA);
+    }
+
+    @Test
+    void shouldKeepNormalManagerGroupAcrossProjects() {
+        assertEquals(
+                ReSubmitApprovalService.approvalGroupKey("manager-1", "P-A", "项目 A"),
+                ReSubmitApprovalService.approvalGroupKey("manager-1", "P-B", "项目 B"));
+    }
+
+    @Test
+    void shouldAppendFallbackApprovalOnlyToMatchingProject() {
+        Map<String, Object> projectABillable = new HashMap<>();
+        projectABillable.put("textField_mmacxew3", "P-A");
+        Map<String, Object> projectANonBillable = new HashMap<>();
+        projectANonBillable.put("textField_mmd1wu9d", "P-A");
+        Map<String, Object> projectBRow = new HashMap<>();
+        projectBRow.put("textField_mmacxew3", "P-B");
+
+        assertTrue(ReSubmitApprovalService.approvalMatchesProject(
+                Collections.singletonList((Map) projectABillable),
+                Collections.singletonList((Map) projectANonBillable),
+                "P-A", "项目 A"));
+        assertFalse(ReSubmitApprovalService.approvalMatchesProject(
+                Collections.singletonList((Map) projectABillable),
+                Collections.singletonList((Map) projectBRow),
+                "P-A", "项目 A"));
+        assertFalse(ReSubmitApprovalService.approvalMatchesProject(
+                Collections.emptyList(), Collections.emptyList(), "P-A", "项目 A"));
+        assertTrue(ReSubmitApprovalService.approvalMatchesProject(
+                Collections.singletonList((Map) projectABillable),
+                Collections.singletonList((Map) projectBRow),
+                "", ""));
+    }
+
+    @Test
+    void shouldUseProjectNameWhenFallbackProjectCodeIsBlank() {
+        assertFalse(ReSubmitApprovalService.approvalGroupKey(
+                DEFAULT_MANAGER_ID, "", "项目 A").equals(
+                ReSubmitApprovalService.approvalGroupKey(DEFAULT_MANAGER_ID, "", "项目 B")));
+
+        Map<String, Object> projectA = new HashMap<>();
+        projectA.put("textField_mmacxew3", "");
+        projectA.put("textField_mkov5ozd", "项目 A");
+        Map<String, Object> projectB = new HashMap<>();
+        projectB.put("textField_mmacxew3", "");
+        projectB.put("textField_mkov5ozd", "项目 B");
+
+        assertTrue(ReSubmitApprovalService.approvalMatchesProject(
+                Collections.singletonList((Map) projectA), Collections.emptyList(), "", "项目 A"));
+        assertFalse(ReSubmitApprovalService.approvalMatchesProject(
+                Collections.singletonList((Map) projectB), Collections.emptyList(), "", "项目 A"));
+    }
+
     @Test
     void shouldKeepSingleSubmitterRowsTogetherWithinHardLimit() {
         YDClient ydClient = mock(YDClient.class);