Переглянути джерело

fix(workhours): 内部员工免项目校验

malk 3 тижнів тому
батько
коміт
61d627d697

+ 27 - 35
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/WorkHoursCalcService.java

@@ -61,12 +61,12 @@ public class WorkHoursCalcService {
     // prd 应填报人显示名称文本,用于报表按姓名筛选;姓名取自人员档案成员组件显示值
     private static final String REQUIRED_HOURS_EMPLOYEE_NAME = "textField_ms4bzjkm";
 
-    // prd 全员项目参与关系 + 外部员工项目经理:项目档案字段
+    // prd 外部员工项目参与关系 + 项目经理:项目档案字段
     private static final String PROJECT_SUB_TABLE = "tableField_mkowyn6d";      // 成员子表(人员明细)
     private static final String PROJECT_SUB_MEMBER = "employeeField_mmbfe0ij";  // 子表-员工(成员)
     private static final String PROJECT_SUB_PM = "employeeField_mkoxpswf";      // 子表-项目经理
     private static final String PROJECT_SUB_OFFLINE = "dateField_mo6s11tc";     // 子表-下线时间
-    // prd 仅启用项目参与人员需要统计应填报工时
+    // prd 外部员工仅在参与启用项目且存在有效 PM 时统计应填报工时
     private static final String PROJECT_STATUS = "textField_mpwc5r0q";          // 主表-项目状态
     private static final String PROJECT_STATUS_ACTIVE = "启用";
 
@@ -96,11 +96,10 @@ public class WorkHoursCalcService {
             return stats;
         }
 
-        // 2. 预取 Manager + 启用项目参与关系;所有员工均须命中当天参与项目
+        // 2. 预取 Manager:内部员工取直属主管,外部员工取启用项目 assignments
         ManagerData managerData = queryManagerData(personnelMap);
         stats.put("internalMgrCount", managerData.internal.size());
         stats.put("externalWithProjects", managerData.external.size());
-        stats.put("projectParticipantCount", managerData.projects.size());
 
         // 3. 查询节假日规则
         Map<LocalDate, String> holidayRules = queryHolidayRules(String.valueOf(year));
@@ -148,7 +147,7 @@ public class WorkHoursCalcService {
             return;
         }
 
-        // 2. 预取 Manager + 全员启用项目 assignments
+        // 2. 预取 Manager:内部员工取直属主管,外部员工取启用项目 assignments
         ManagerData managerData = queryManagerData(personnelMap);
 
         // 3~4. 节假日 + 收窄到窗口内工作日 (跨月边界: 若窗口跨 6-30/7-1, 分别按各自月份的节假日规则)
@@ -239,15 +238,7 @@ public class WorkHoursCalcService {
             return result;
         }
 
-        // 2. 所有员工都必须参与启用项目;内部取直属主管,外部取当天参与项目的 PM 合并
-        Map<String, List<Assignment>> projectMap = queryProjectAssignments(Collections.singleton(userId));
-        List<Assignment> assignments = projectMap.get(userId);
-        if (!hasActiveProject(assignments, workDay)) {
-            result.put("success", false);
-            result.put("error", "员工当日未参与启用项目, 按业务规则跳过写入");
-            return result;
-        }
-
+        // 2. 内部员工不校验项目,外部员工取当天参与启用项目的 PM 合并
         boolean isInternal = "内部".equals(String.valueOf(info.get("radioField_mkow4ydo")));
         List<String> managerIds;
         if (isInternal) {
@@ -266,6 +257,13 @@ public class WorkHoursCalcService {
             }
             managerIds = mgrId == null ? Collections.emptyList() : Collections.singletonList(mgrId);
         } else {
+            Map<String, List<Assignment>> projectMap = queryProjectAssignments(Collections.singleton(userId));
+            List<Assignment> assignments = projectMap.get(userId);
+            if (!hasActiveProject(assignments, workDay)) {
+                result.put("success", false);
+                result.put("error", "外部员工当日未参与启用项目, 按业务规则跳过写入");
+                return result;
+            }
             // 外部员工: 按 workDay 算当天活跃项目 PM; 全空则按业务规则不写入
             managerIds = computeDailyPms(assignments, workDay);
             if (managerIds.isEmpty()) {
@@ -339,7 +337,6 @@ public class WorkHoursCalcService {
         ManagerData managerData = queryManagerData(subset);
         stats.put("internalMgrCount", managerData.internal.size());
         stats.put("externalWithProjects", managerData.external.size());
-        stats.put("projectParticipantCount", managerData.projects.size());
 
         // 4. 节假日 + 工作日
         Map<LocalDate, String> rules = queryHolidayRules(String.valueOf(year));
@@ -858,9 +855,9 @@ public class WorkHoursCalcService {
                 String empId = entry.getKey();
                 Map<String, Object> info = entry.getValue();
                 boolean isInternal = "内部".equals(String.valueOf(info.get("radioField_mkow4ydo")));
-                // 内部: 单值直属主管; 外部: assignments 交给内层按 workDay 算 PM
+                // 内部: 单值直属主管且不校验项目; 外部: assignments 交给内层按 workDay 算 PM
                 String internalMgrId = isInternal ? managerData.internal.get(empId) : null;
-                List<Assignment> projectAssignments = managerData.projects.get(empId);
+                List<Assignment> externalAssignments = isInternal ? null : managerData.external.get(empId);
                 // prd 离职时间: 若有离职时间, 该日之后的应报工时不再生成
                 LocalDate offlineDate = parseToLocalDate(info.get(PERSONNEL_OFFLINE_DATE));
                 // prd 入职时间: 入职日前不生成, 入职当天保留
@@ -894,8 +891,8 @@ public class WorkHoursCalcService {
                             skippedBeforeHired.incrementAndGet();
                             continue;
                         }
-                        // prd 所有员工均须在启用项目中且当日未下线;无项目不统计应填报工时
-                        if (!hasActiveProject(projectAssignments, workDay)) {
+                        // prd 外部员工须在启用项目中且当日未下线;内部员工不校验项目
+                        if (!isInternal && !hasActiveProject(externalAssignments, workDay)) {
                             skippedNoProject.incrementAndGet();
                             continue;
                         }
@@ -906,7 +903,7 @@ public class WorkHoursCalcService {
                                     ? Collections.emptyList()
                                     : Collections.singletonList(internalMgrId);
                         } else {
-                            managerIds = computeDailyPms(projectAssignments, workDay);
+                            managerIds = computeDailyPms(externalAssignments, workDay);
                             if (managerIds.isEmpty()) {
                                 skippedNoPm.incrementAndGet();
                                 continue;
@@ -962,7 +959,7 @@ public class WorkHoursCalcService {
                 log.info("入职日期过滤: 跳过{}条 workDay < hiredDate 的记录", skippedBeforeHired.get());
             }
             if (skippedNoProject.get() > 0) {
-                log.info("启用项目参与过滤: 跳过{}条 当天未参与启用项目的记录", skippedNoProject.get());
+                log.info("外部员工启用项目参与过滤: 跳过{}条 当天未参与启用项目的记录", skippedNoProject.get());
             }
             if (skippedNoPm.get() > 0) {
                 log.info("外部员工无 PM 过滤: 跳过{}条 当天无活跃项目 PM 的记录", skippedNoPm.get());
@@ -1697,12 +1694,12 @@ public class WorkHoursCalcService {
     /**
      * 预取 Manager 数据:
      * - 内部员工: 钉钉 API 取 manager_userid (直属主管, 单值)
-     * - 全体员工: 匹配启用项目 assignments,作为应填报工时准入条件
-     * - 外部员工: 再按天从 assignments 计算 PM,多项目合并去重
+     * - 外部员工: 匹配启用项目 assignments,再按天计算 PM,多项目合并去重
+     * - 内部员工: 不以项目参与关系作为应填报工时准入条件
      * ppExt: 与前端 TimeCard 刻意不同 - 不做 PM 离职探活、不兜底 Raymond (后端只记数据, 非审批找活人)
      *
      * @param personnelMap 全量人员档案
-     * @return ManagerData: internal=内部主管, projects=全员启用项目参与关系, external=外部员工项目参与关系
+     * @return ManagerData: internal=内部主管, external=外部员工启用项目参与关系
      */
     @SuppressWarnings("unchecked")
     private ManagerData queryManagerData(Map<String, Map<String, Object>> personnelMap) {
@@ -1734,17 +1731,13 @@ public class WorkHoursCalcService {
             }
         }
 
-        // prd 全体员工都必须匹配启用项目;外部员工另保留子集用于兼容现有统计字段
-        md.projects = queryProjectAssignments(personnelMap.keySet());
-        for (String externalId : externalIds) {
-            List<Assignment> assignments = md.projects.get(externalId);
-            if (assignments != null && !assignments.isEmpty()) {
-                md.external.put(externalId, assignments);
-            }
+        // prd 仅外部员工匹配启用项目;内部员工只按人员在职及日期边界生成应填报工时
+        if (!externalIds.isEmpty()) {
+            md.external = queryProjectAssignments(externalIds);
         }
 
-        log.info("Manager 预取完成: 内部{}人取直属主管落地{}, 全员启用项目参与者{}, 外部{}人中{}人有启用项目",
-                internalCount, md.internal.size(), md.projects.size(), externalIds.size(), md.external.size());
+        log.info("Manager 预取完成: 内部{}人取直属主管落地{}, 外部{}人中{}人有启用项目",
+                internalCount, md.internal.size(), externalIds.size(), md.external.size());
         return md;
     }
 
@@ -1842,10 +1835,9 @@ public class WorkHoursCalcService {
         return new ArrayList<>(pms);
     }
 
-    /** 内部员工 → 直属主管;projects → 全员启用项目;external → 外部员工启用项目子集。 */
+    /** 内部员工 → 直属主管;外部员工 → 启用项目 assignments。 */
     private static class ManagerData {
         Map<String, String> internal = new HashMap<>();
-        Map<String, List<Assignment>> projects = new HashMap<>();
         Map<String, List<Assignment>> external = new HashMap<>();
     }
 

+ 46 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/WorkHoursCalcServiceTest.java

@@ -60,6 +60,52 @@ public class WorkHoursCalcServiceTest {
         assertEquals("workDay 在 today 之后, 按业务规则不写入未来数据", result.get("error"));
     }
 
+    @Test
+    public void syncOneInternalEmployeeShouldNotRequireProject() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf conf = requiredHoursConf();
+        Map<String, Object> personnel = personnelRecord("employee-1", "在职", null);
+        Map<String, Object> formData = (Map<String, Object>) personnel.get("formData");
+        formData.put("radioField_mkow4ydo", "内部");
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(pageOf(Collections.singletonList(personnel)));
+
+        WorkHoursCalcService service = new WorkHoursCalcService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+
+        Map<String, Object> result = service.syncOneEmployeeOneDay(
+                "employee-1", LocalDate.now().minusDays(1));
+
+        assertEquals(true, result.get("success"));
+        assertEquals(Collections.emptyList(), result.get("managerIds"));
+        verify(ydClient).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.upsert));
+    }
+
+    @Test
+    public void syncOneExternalEmployeeShouldStillRequireEnabledProject() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf conf = requiredHoursConf();
+        Map<String, Object> personnel = personnelRecord("employee-1", "在职", null);
+        Map<String, Object> formData = (Map<String, Object>) personnel.get("formData");
+        formData.put("radioField_mkow4ydo", "外部");
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(
+                        pageOf(Collections.singletonList(personnel)),
+                        pageOf(Collections.emptyList()));
+
+        WorkHoursCalcService service = new WorkHoursCalcService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+
+        Map<String, Object> result = service.syncOneEmployeeOneDay(
+                "employee-1", LocalDate.now().minusDays(1));
+
+        assertEquals(false, result.get("success"));
+        assertEquals("外部员工当日未参与启用项目, 按业务规则跳过写入", result.get("error"));
+        verify(ydClient, never()).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.upsert));
+    }
+
     @Test
     public void isAfterOfflineDateShouldKeepOfflineDayAndRejectFollowingDay() {
         LocalDate offlineDate = LocalDate.of(2026, 7, 15);