Parcourir la source

fix(workhours): 按启用项目参与关系统计应报工时

malk il y a 3 semaines
Parent
commit
908d36d97b

+ 71 - 25
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/WorkHoursCalcService.java

@@ -61,11 +61,14 @@ 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 仅启用项目参与人员需要统计应填报工时
+    private static final String PROJECT_STATUS = "textField_mpwc5r0q";          // 主表-项目状态
+    private static final String PROJECT_STATUS_ACTIVE = "启用";
 
     /**
      * 主入口:计算指定月份每个工作日的应填报工时并写入宜搭(按天维度,每条记录8h)
@@ -93,10 +96,11 @@ public class WorkHoursCalcService {
             return stats;
         }
 
-        // 2. 预取 Manager 数据: 内部员工=钉钉直属主管, 外部员工=项目 assignments (按天算 PM 在 concurrentUpsert 内层)
+        // 2. 预取 Manager + 启用项目参与关系;所有员工均须命中当天参与项目
         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));
@@ -144,7 +148,7 @@ public class WorkHoursCalcService {
             return;
         }
 
-        // 2. 预取 Manager (内部=钉钉主管, 外部=项目 assignments)
+        // 2. 预取 Manager + 全员启用项目 assignments
         ManagerData managerData = queryManagerData(personnelMap);
 
         // 3~4. 节假日 + 收窄到窗口内工作日 (跨月边界: 若窗口跨 6-30/7-1, 分别按各自月份的节假日规则)
@@ -235,7 +239,15 @@ public class WorkHoursCalcService {
             return result;
         }
 
-        // 2. Manager 取值: 内部=[钉钉直属主管], 外部=当天参与项目的 PM 合并
+        // 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;
+        }
+
         boolean isInternal = "内部".equals(String.valueOf(info.get("radioField_mkow4ydo")));
         List<String> managerIds;
         if (isInternal) {
@@ -255,8 +267,7 @@ public class WorkHoursCalcService {
             managerIds = mgrId == null ? Collections.emptyList() : Collections.singletonList(mgrId);
         } else {
             // 外部员工: 按 workDay 算当天活跃项目 PM; 全空则按业务规则不写入
-            Map<String, List<Assignment>> extMap = queryProjectAssignments(Collections.singleton(userId));
-            managerIds = computeDailyPms(extMap.get(userId), workDay);
+            managerIds = computeDailyPms(assignments, workDay);
             if (managerIds.isEmpty()) {
                 result.put("success", false);
                 result.put("error", "外部员工当日无可用项目 PM (无项目 / 项目已下线 / PM 为空), 按业务规则跳过写入");
@@ -328,6 +339,7 @@ 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));
@@ -839,6 +851,7 @@ public class WorkHoursCalcService {
             AtomicInteger skippedExisting = new AtomicInteger(0);
             AtomicInteger skippedOffline = new AtomicInteger(0);
             AtomicInteger skippedBeforeHired = new AtomicInteger(0);
+            AtomicInteger skippedNoProject = new AtomicInteger(0);
             AtomicInteger skippedNoPm = new AtomicInteger(0);
             AtomicInteger skippedInconsistent = new AtomicInteger(0);
             for (Map.Entry<String, Map<String, Object>> entry : personnelMap.entrySet()) {
@@ -847,7 +860,7 @@ public class WorkHoursCalcService {
                 boolean isInternal = "内部".equals(String.valueOf(info.get("radioField_mkow4ydo")));
                 // 内部: 单值直属主管; 外部: assignments 交给内层按 workDay 算 PM
                 String internalMgrId = isInternal ? managerData.internal.get(empId) : null;
-                List<Assignment> externalAssignments = isInternal ? null : managerData.external.get(empId);
+                List<Assignment> projectAssignments = managerData.projects.get(empId);
                 // prd 离职时间: 若有离职时间, 该日之后的应报工时不再生成
                 LocalDate offlineDate = parseToLocalDate(info.get(PERSONNEL_OFFLINE_DATE));
                 // prd 入职时间: 入职日前不生成, 入职当天保留
@@ -881,6 +894,11 @@ public class WorkHoursCalcService {
                             skippedBeforeHired.incrementAndGet();
                             continue;
                         }
+                        // prd 所有员工均须在启用项目中且当日未下线;无项目不统计应填报工时
+                        if (!hasActiveProject(projectAssignments, workDay)) {
+                            skippedNoProject.incrementAndGet();
+                            continue;
+                        }
                         // prd 外部员工: 当天参与项目的 PM 集合为空(无项目/PM 全空/项目全下线) → 不生成记录
                         List<String> managerIds;
                         if (isInternal) {
@@ -888,7 +906,7 @@ public class WorkHoursCalcService {
                                     ? Collections.emptyList()
                                     : Collections.singletonList(internalMgrId);
                         } else {
-                            managerIds = computeDailyPms(externalAssignments, workDay);
+                            managerIds = computeDailyPms(projectAssignments, workDay);
                             if (managerIds.isEmpty()) {
                                 skippedNoPm.incrementAndGet();
                                 continue;
@@ -943,6 +961,9 @@ public class WorkHoursCalcService {
             if (skippedBeforeHired.get() > 0) {
                 log.info("入职日期过滤: 跳过{}条 workDay < hiredDate 的记录", skippedBeforeHired.get());
             }
+            if (skippedNoProject.get() > 0) {
+                log.info("启用项目参与过滤: 跳过{}条 当天未参与启用项目的记录", skippedNoProject.get());
+            }
             if (skippedNoPm.get() > 0) {
                 log.info("外部员工无 PM 过滤: 跳过{}条 当天无活跃项目 PM 的记录", skippedNoPm.get());
             }
@@ -1676,11 +1697,12 @@ public class WorkHoursCalcService {
     /**
      * 预取 Manager 数据:
      * - 内部员工: 钉钉 API 取 manager_userid (直属主管, 单值)
-     * - 外部员工: 项目档案 assignments (按天算 PM 在 concurrentUpsert 内层, 多项目合并去重, 全空则不写入)
+     * - 全体员工: 匹配启用项目 assignments,作为应填报工时准入条件
+     * - 外部员工: 再按天从 assignments 计算 PM,多项目合并去重
      * ppExt: 与前端 TimeCard 刻意不同 - 不做 PM 离职探活、不兜底 Raymond (后端只记数据, 非审批找活人)
      *
      * @param personnelMap 全量人员档案
-     * @return ManagerData: internal=Map<userId, mgrUserId>, external=Map<userId, List<Assignment>>
+     * @return ManagerData: internal=内部主管, projects=全员启用项目参与关系, external=外部员工项目参与关系
      */
     @SuppressWarnings("unchecked")
     private ManagerData queryManagerData(Map<String, Map<String, Object>> personnelMap) {
@@ -1689,7 +1711,7 @@ public class WorkHoursCalcService {
         String accessToken = ddClient.getAccessToken();
         int internalCount = 0;
 
-        // 内部员工:钉钉直属主管;外部员工:先收集起来批量匹配项目档案 PM
+        // 内部员工取钉钉直属主管;同时记录外部员工集合,供项目匹配统计与 PM 计算
         for (Map.Entry<String, Map<String, Object>> entry : personnelMap.entrySet()) {
             String empId = entry.getKey();
             Object attr = entry.getValue().get("radioField_mkow4ydo");
@@ -1712,27 +1734,31 @@ public class WorkHoursCalcService {
             }
         }
 
-        // 外部员工: 预取项目 assignments (供 concurrentUpsert 按天算 PM)
-        if (!externalIds.isEmpty()) {
-            md.external = queryProjectAssignments(externalIds);
+        // 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);
+            }
         }
 
-        log.info("Manager 预取完成: 内部{}人取直属主管落地{}, 外部{}人预取项目档案({}人有项目)",
-                internalCount, md.internal.size(), externalIds.size(), md.external.size());
+        log.info("Manager 预取完成: 内部{}人取直属主管落地{}, 全员启用项目参与者{}, 外部{}人中{}人有启用项目",
+                internalCount, md.internal.size(), md.projects.size(), externalIds.size(), md.external.size());
         return md;
     }
 
     /**
-     * 扫项目档案全量, 收集外部员工的项目 assignments (含下线日期 + PM), 按天算 PM 见 computeDailyPms
+     * 查询状态为“启用”的项目,收集指定人员的 assignments(含下线日期 + PM)。
      * ppExt: 子表 &lt;50 行用内联、==50 走 ydService.queryDetails 取全 (与 ApprovalWriteBackService.resolveDetailRows 同款)
      *
-     * @param externalIds 外部员工 userId 集合
-     * @return Map<外部员工 userId, List<Assignment>>, 仅含在项目子表出现过的外部员工
+     * @param personnelIds 待匹配人员 userId 集合
+     * @return Map<人员 userId, List<Assignment>>, 仅含启用项目子表中出现过的人员
      */
     @SuppressWarnings("unchecked")
-    private Map<String, List<Assignment>> queryProjectAssignments(Set<String> externalIds) {
+    private Map<String, List<Assignment>> queryProjectAssignments(Set<String> personnelIds) {
         Map<String, List<Assignment>> result = new HashMap<>();
-        if (externalIds == null || externalIds.isEmpty()) {
+        if (personnelIds == null || personnelIds.isEmpty()) {
             return result;
         }
 
@@ -1748,9 +1774,11 @@ public class WorkHoursCalcService {
                     .appType(appType)
                     .systemToken(systemToken)
                     .formUuid(formUuidProject)
+                    .searchFieldJson(JSON.toJSONString(
+                            Collections.singletonMap(PROJECT_STATUS, PROJECT_STATUS_ACTIVE)))
                     .currentPage(currentPage)
                     .pageSize(pageSize)
-                    .build(), YDConf.FORM_QUERY.retrieve_list_all);
+                    .build(), YDConf.FORM_QUERY.retrieve_search_form);
 
             totalCount = page.getTotalCount();
             List<Map> dataList = (List<Map>) page.getData();
@@ -1758,16 +1786,18 @@ public class WorkHoursCalcService {
 
             for (Map item : dataList) {
                 Object instIdObj = item.get("formInstanceId");
+                if (instIdObj == null) instIdObj = item.get("formInstId");
                 if (instIdObj == null) continue;
                 String projectInstId = String.valueOf(instIdObj);
 
                 Map<String, Object> formData = (Map<String, Object>) item.get("formData");
                 if (formData == null) continue;
+                if (!PROJECT_STATUS_ACTIVE.equals(String.valueOf(formData.get(PROJECT_STATUS)).trim())) continue;
 
                 List<Map> subRows = resolveProjectMembers(projectInstId, (List<Map>) formData.get(PROJECT_SUB_TABLE));
                 for (Map row : subRows) {
                     String memberId = extractEmployeeId(row, PROJECT_SUB_MEMBER);
-                    if (memberId == null || !externalIds.contains(memberId)) continue;
+                    if (memberId == null || !personnelIds.contains(memberId)) continue;
 
                     LocalDate offlineDate = parseToLocalDate(row.get(PROJECT_SUB_OFFLINE));
                     String pmId = extractEmployeeId(row, PROJECT_SUB_PM);
@@ -1781,6 +1811,21 @@ public class WorkHoursCalcService {
         return result;
     }
 
+    /** 当天至少存在一条尚未下线的启用项目参与关系。 */
+    private boolean hasActiveProject(List<Assignment> assignments, LocalDate workDay) {
+        if (assignments == null || assignments.isEmpty()) return false;
+        for (Assignment assignment : assignments) {
+            if (isAssignmentActive(assignment, workDay)) return true;
+        }
+        return false;
+    }
+
+    private boolean isAssignmentActive(Assignment assignment, LocalDate workDay) {
+        return assignment != null
+                && workDay != null
+                && (assignment.offlineDate == null || !workDay.isAfter(assignment.offlineDate));
+    }
+
     /**
      * 计算某员工在 workDay 当天参与的活跃项目 PM 集合 (去重, 排除下线日期已过 或 PM 为空的项目)
      * ppExt: 只用下线日期判定 — 下线日期为 null 或 workDay &lt;= offlineDate 视为当天在项目
@@ -1791,15 +1836,16 @@ public class WorkHoursCalcService {
         if (assignments == null || assignments.isEmpty()) return Collections.emptyList();
         Set<String> pms = new LinkedHashSet<>();
         for (Assignment a : assignments) {
-            if (a.offlineDate != null && workDay.isAfter(a.offlineDate)) continue;
+            if (!isAssignmentActive(a, workDay)) continue;
             if (a.pmId != null && !a.pmId.isEmpty()) pms.add(a.pmId);
         }
         return new ArrayList<>(pms);
     }
 
-    /** 内部员工 → 直属主管 userId; 外部员工 → 项目 assignments 明细. 供 concurrentUpsert 按天算 Manager */
+    /** 内部员工 → 直属主管;projects → 全员启用项目;external → 外部员工启用项目子集。 */
     private static class ManagerData {
         Map<String, String> internal = new HashMap<>();
+        Map<String, List<Assignment>> projects = new HashMap<>();
         Map<String, List<Assignment>> external = new HashMap<>();
     }
 

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

@@ -6,6 +6,7 @@ import com.malk.server.dingtalk.DDR_New;
 import com.malk.server.workhours.WHConf;
 import com.malk.service.aliwork.YDClient;
 import org.junit.Test;
+import org.mockito.ArgumentCaptor;
 import org.springframework.test.util.ReflectionTestUtils;
 
 import java.lang.reflect.Constructor;
@@ -277,6 +278,50 @@ public class WorkHoursCalcServiceTest {
         assertEquals(Collections.emptyList(), managers);
     }
 
+    @Test
+    public void queryProjectAssignmentsShouldOnlyUseEnabledProjects() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf conf = requiredHoursConf();
+        conf.setFormUuidProject("projects");
+        DDR_New<Object> projects = pageOf(Arrays.asList(
+                projectRecord("enabled-project", "启用", "employee-1", "manager-1", null),
+                projectRecord("disabled-project", "停用", "employee-1", "manager-2", null)));
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(projects);
+
+        WorkHoursCalcService service = new WorkHoursCalcService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+
+        Map<String, List<?>> assignments = ReflectionTestUtils.invokeMethod(
+                service, "queryProjectAssignments", Collections.singleton("employee-1"));
+
+        assertTrue(assignments.containsKey("employee-1"));
+        assertEquals(1, assignments.get("employee-1").size());
+        ArgumentCaptor<YDParam> paramCaptor = ArgumentCaptor.forClass(YDParam.class);
+        verify(ydClient).queryData(paramCaptor.capture(), eq(YDConf.FORM_QUERY.retrieve_search_form));
+        assertEquals("{\"textField_mpwc5r0q\":\"启用\"}", paramCaptor.getValue().getSearchFieldJson());
+    }
+
+    @Test
+    public void hasActiveProjectShouldRejectMissingOrOfflineAssignments() throws Exception {
+        WorkHoursCalcService service = new WorkHoursCalcService();
+        LocalDate offlineDate = LocalDate.of(2026, 8, 26);
+        List<Object> assignments = Collections.singletonList(
+                assignment("project-1", offlineDate, "manager-1"));
+
+        Boolean onOfflineDate = ReflectionTestUtils.invokeMethod(
+                service, "hasActiveProject", assignments, offlineDate);
+        Boolean afterOfflineDate = ReflectionTestUtils.invokeMethod(
+                service, "hasActiveProject", assignments, offlineDate.plusDays(1));
+        Boolean withoutProject = ReflectionTestUtils.invokeMethod(
+                service, "hasActiveProject", Collections.emptyList(), offlineDate);
+
+        assertTrue(onOfflineDate);
+        assertFalse(afterOfflineDate);
+        assertFalse(withoutProject);
+    }
+
     private static Object assignment(String projectInstanceId,
                                      LocalDate offlineDate,
                                      String managerId) throws Exception {
@@ -288,6 +333,27 @@ public class WorkHoursCalcServiceTest {
         return constructor.newInstance(projectInstanceId, offlineDate, managerId);
     }
 
+    private static Map<String, Object> projectRecord(String instanceId,
+                                                      String status,
+                                                      String employeeId,
+                                                      String managerId,
+                                                      LocalDate offlineDate) {
+        Map<String, Object> member = new LinkedHashMap<>();
+        member.put("employeeField_mmbfe0ij_id", Collections.singletonList(employeeId));
+        member.put("employeeField_mkoxpswf_id", Collections.singletonList(managerId));
+        if (offlineDate != null) {
+            member.put("dateField_mo6s11tc",
+                    offlineDate.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli());
+        }
+        Map<String, Object> formData = new LinkedHashMap<>();
+        formData.put("textField_mpwc5r0q", status);
+        formData.put("tableField_mkowyn6d", Collections.singletonList(member));
+        Map<String, Object> record = new LinkedHashMap<>();
+        record.put("formInstId", instanceId);
+        record.put("formData", formData);
+        return record;
+    }
+
     private static Map<String, Object> requiredHoursRecord(String instanceId,
                                                             String employeeId,
                                                             LocalDate workDay) {