package com.njcn.rdms.module.project.service.project;

import com.njcn.rdms.framework.common.exception.ServiceException;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectGroupPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectGroupPageRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectGroupRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectLifecycleActionRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectOverviewSummaryRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectSaveReqVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectStatusActionReqVO;
import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScope;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScopeService;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductDO;
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectStatusLogDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
import com.njcn.rdms.module.project.dal.mysql.product.ProductMapper;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectStatusLogMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
import com.njcn.rdms.module.system.api.dict.DictDataApi;
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.api.permission.dto.ObjectMenuRespDTO;
import com.njcn.rdms.module.system.api.permission.dto.ObjectRolePermissionRespDTO;
import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;

import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

class ProjectServiceImplTest extends BaseMockitoUnitTest {

    @InjectMocks
    private ProjectServiceImpl projectService;
    @Mock
    private ProjectMapper projectMapper;
    @Mock
    private ProjectStatusLogMapper projectStatusLogMapper;
    @Mock
    private ProductMapper productMapper;
    @Mock
    private BizAuditLogMapper bizAuditLogMapper;
    @Mock
    private ObjectStatusTransitionMapper objectStatusTransitionMapper;
    @Mock
    private ObjectStatusModelMapper objectStatusModelMapper;
    @Mock
    private UserObjectRoleMapper userObjectRoleMapper;
    @Mock
    private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService;
    @Mock
    private com.njcn.rdms.module.project.service.member.CurrentUserRoleResolver currentUserRoleResolver;
    @Mock
    private com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementModuleMapper projectRequirementModuleMapper;
    @Mock
    private ProjectStatusViewService projectStatusViewService;
    @Mock
    private ObjectPermissionApi objectPermissionApi;
    @Mock
    private AdminUserApi adminUserApi;
    @Mock
    private DictDataApi dictDataApi;
    @Mock
    private ObjectDataScopeService objectDataScopeService;
    @Mock
    private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver;
    @Mock
    private com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper projectTaskMapper;
    @Mock
    private com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper projectExecutionMapper;
    @Mock
    private com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementMapper projectRequirementMapper;
    @Mock
    private com.njcn.rdms.module.project.framework.notify.TeamNotifyRecipientResolver teamNotifyRecipientResolver;
    @Mock
    private org.springframework.context.ApplicationEventPublisher applicationEventPublisher;

    @Test
    void getProjectDetail_shouldFillProductNameAndManagerNickname() {
        Long projectId = 3001L;
        Long productId = 1001L;
        Long managerUserId = 2001L;

        ProjectDO project = new ProjectDO();
        project.setId(projectId);
        project.setProjectCode("CNPJ2026001");
        project.setProjectName("研发管理系统");
        project.setProductId(productId);
        project.setManagerUserId(managerUserId);

        ProductDO product = new ProductDO();
        product.setId(productId);
        product.setName("统一交付平台");

        AdminUserRespDTO manager = new AdminUserRespDTO();
        manager.setId(managerUserId);
        manager.setNickname("张三");

        when(projectMapper.selectById(projectId)).thenReturn(project);
        when(productMapper.selectById(productId)).thenReturn(product);
        when(adminUserApi.getUser(managerUserId)).thenReturn(success(manager));

        ProjectRespVO result = projectService.getProjectDetail(projectId);

        assertEquals(projectId, result.getId());
        assertEquals("统一交付平台", result.getProductName());
        assertEquals("张三", result.getManagerUserNickname());
    }

    @Test
    void getProjectListByProductId_shouldReturnProjectsUnderCurrentProduct() {
        Long productId = 1001L;

        ProductDO product = new ProductDO();
        product.setId(productId);
        product.setName("统一交付平台");

        ProjectDO project1 = createProject(3001L, productId, "项目A", 2001L, "pending");
        ProjectDO project2 = createProject(3002L, productId, "项目B", 2002L, "active");

        AdminUserRespDTO manager1 = new AdminUserRespDTO();
        manager1.setId(2001L);
        manager1.setNickname("张三");
        AdminUserRespDTO manager2 = new AdminUserRespDTO();
        manager2.setId(2002L);
        manager2.setNickname("李四");

        when(productMapper.selectById(productId)).thenReturn(product);
        when(projectMapper.selectListByProductId(productId)).thenReturn(List.of(project1, project2));
        when(adminUserApi.getUser(2001L)).thenReturn(success(manager1));
        when(adminUserApi.getUser(2002L)).thenReturn(success(manager2));

        List<ProjectRespVO> result = projectService.getProjectListByProductId(productId);

        assertEquals(2, result.size());
        assertEquals(productId, result.get(0).getProductId());
        assertEquals("统一交付平台", result.get(0).getProductName());
        assertEquals("张三", result.get(0).getManagerUserNickname());
        assertEquals("李四", result.get(1).getManagerUserNickname());
    }

    @Test
    void createProject_withManualCode_shouldInsertPendingProjectInitManagerAndWriteAudit() {
        ProjectSaveReqVO reqVO = createReqVO("CNPJ-MANUAL", 1001L, "合同交付项目", 2001L);
        ProductDO product = new ProductDO();
        product.setId(1001L);
        product.setDirectionCode("product_direction");
        when(projectMapper.selectByCode("CNPJ-MANUAL")).thenReturn(null);
        when(projectMapper.selectActiveByProductIdAndName(1001L, "合同交付项目", "cancelled")).thenReturn(null);
        when(productMapper.selectById(1001L)).thenReturn(product);
        when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.PROJECT_TYPE, List.of("delivery"))).thenReturn(success(true));
        when(adminUserApi.validateUserList(any())).thenReturn(success(true));
        when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("project")).thenReturn(createStatus("pending", true, true));
        when(objectPermissionApi.getObjectRoleByCode("project_manager", "object", "project"))
                .thenReturn(success(createRole(3101L, "project_manager", "项目经理")));

        try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(3001L, "创建人")) {
            projectService.createProject(reqVO);
        }

        ArgumentCaptor<ProjectDO> projectCaptor = ArgumentCaptor.forClass(ProjectDO.class);
        verify(projectMapper).insert(projectCaptor.capture());
        assertEquals("pending", projectCaptor.getValue().getStatusCode());
        assertEquals("CNPJ-MANUAL", projectCaptor.getValue().getProjectCode());
        assertEquals("product_direction", projectCaptor.getValue().getDirectionCode());

        ArgumentCaptor<UserObjectRoleDO> memberCaptor = ArgumentCaptor.forClass(UserObjectRoleDO.class);
        verify(userObjectRoleMapper).insert(memberCaptor.capture());
        assertEquals("project", memberCaptor.getValue().getObjectType());
        assertEquals(3101L, memberCaptor.getValue().getRoleId());
        assertEquals(2001L, memberCaptor.getValue().getUserId());

        ArgumentCaptor<BizAuditLogDO> auditCaptor = ArgumentCaptor.forClass(BizAuditLogDO.class);
        verify(bizAuditLogMapper, times(3)).insert(auditCaptor.capture());
        assertTrue(auditCaptor.getAllValues().stream().anyMatch(log ->
                "project".equals(log.getBizType()) && "create".equals(log.getActionType())
                        && "pending".equals(log.getToStatus())));
    }

    @Test
    void getProjectOverviewSummary_shouldReturnFixedStatusCountsAndIgnoreUnknownStatus() {
        try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
            mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
            when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.all());
            when(objectStatusModelMapper.selectListByObjectType("project")).thenReturn(List.of(
                    createBoardStatusModel("pending", "待开始", 10, 0, false),
                    createBoardStatusModel("active", "进行中", 20, 0, false),
                    createBoardStatusModel("archived", "已归档", 60, 0, true),
                    createBoardStatusModel("disabled", "已禁用", 70, 1, false)
            ));
            when(projectMapper.selectStatusCountList()).thenReturn(List.of(
                    Map.of("statusCode", "pending", "countValue", 2L),
                    Map.of("statusCode", "active", "countValue", 3L),
                    Map.of("statusCode", "archived", "countValue", 4L),
                    Map.of("statusCode", "unknown", "countValue", 99L)
            ));
            when(objectStatusModelMapper.selectListByObjectTypeEnabled("project")).thenReturn(projectStatusModels());
            when(projectMapper.selectCount(any(LambdaQueryWrapperX.class))).thenReturn(1L);

            ProjectOverviewSummaryRespVO respVO = projectService.getProjectOverviewSummary();

            // 旧字段兼容：仅启用状态进 statusCounts，未知状态忽略
            assertEquals(3, respVO.getStatusCounts().size());
            assertEquals(2L, respVO.getStatusCounts().get("pending"));
            assertEquals(3L, respVO.getStatusCounts().get("active"));
            assertEquals(4L, respVO.getStatusCounts().get("archived"));
            // 新状态看板：仅启用状态、按 sort 升序、归档计入「全部」、includeInAll 恒 true
            assertEquals(3, respVO.getItems().size());
            assertEquals("pending", respVO.getItems().get(0).getStatusCode());
            assertEquals("待开始", respVO.getItems().get(0).getStatusName());
            assertEquals(2L, respVO.getItems().get(0).getCount());
            assertEquals(Boolean.FALSE, respVO.getItems().get(0).getTerminal());
            assertEquals(Boolean.TRUE, respVO.getItems().get(0).getIncludeInAll());
            assertEquals("archived", respVO.getItems().get(2).getStatusCode());
            assertEquals(Boolean.TRUE, respVO.getItems().get(2).getTerminal());
            assertEquals(9L, respVO.getTotal()); // 2+3+4，归档计入「全部」总数
            assertEquals(1L, respVO.getOrphanCount());
        }
    }

    @Test
    void createProject_whenNullProductNameExists_shouldThrowDuplicate() {
        ProjectSaveReqVO reqVO = createReqVO(null, null, "内部项目", 2001L);
        when(projectMapper.selectActiveByProductIdAndName(null, "内部项目", "cancelled"))
                .thenReturn(createProject(1001L, null, "内部项目", 2001L, "pending"));

        ServiceException ex = assertThrows(ServiceException.class, () -> projectService.createProject(reqVO));

        assertEquals(ErrorCodeConstants.PROJECT_NAME_DUPLICATE.getCode(), ex.getCode());
        verify(projectMapper, never()).insert(any(ProjectDO.class));
    }

    @Test
    void createProject_whenOnlyCancelledProjectNameExists_shouldAllowReuseName() {
        ProjectSaveReqVO reqVO = createReqVO("CNPJ-REUSE", 1001L, "研发管理系统", 2001L);
        ProductDO product = new ProductDO();
        product.setId(1001L);
        product.setDirectionCode("product_direction");
        when(projectMapper.selectByCode("CNPJ-REUSE")).thenReturn(null);
        when(projectMapper.selectActiveByProductIdAndName(1001L, "研发管理系统", "cancelled")).thenReturn(null);
        when(productMapper.selectById(1001L)).thenReturn(product);
        when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.PROJECT_TYPE, List.of("delivery"))).thenReturn(success(true));
        when(adminUserApi.validateUserList(any())).thenReturn(success(true));
        when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("project")).thenReturn(createStatus("pending", true, true));
        when(objectPermissionApi.getObjectRoleByCode("project_manager", "object", "project"))
                .thenReturn(success(createRole(3101L, "project_manager", "项目经理")));

        try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(3001L, "创建人")) {
            projectService.createProject(reqVO);
        }

        verify(projectMapper).insert(any(ProjectDO.class));
    }

    @Test
    void createProject_withoutProductAndDirection_shouldThrowInvalidParam() {
        ProjectSaveReqVO reqVO = createReqVO("CNPJ-DIRECTION", null, "方向校验项目", 2001L);
        reqVO.setDirectionCode(null);
        when(projectMapper.selectByCode("CNPJ-DIRECTION")).thenReturn(null);
        when(projectMapper.selectActiveByProductIdAndName(null, "方向校验项目", "cancelled")).thenReturn(null);
        when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.PROJECT_TYPE, List.of("delivery"))).thenReturn(success(true));

        ServiceException ex = assertThrows(ServiceException.class, () -> projectService.createProject(reqVO));

        assertEquals("项目方向不能为空", ex.getMessage());
        verify(projectMapper, never()).insert(any(ProjectDO.class));
    }

    @Test
    void createProject_whenActiveMainlineExists_shouldThrowDuplicate() {
        ProjectSaveReqVO reqVO = createReqVO("CNPJ-MAINLINE", 1001L, "产品主线项目", 2001L);
        reqVO.setProjectType("mainline");
        ProductDO product = new ProductDO();
        product.setId(1001L);
        product.setDirectionCode("product_direction");
        when(projectMapper.selectByCode("CNPJ-MAINLINE")).thenReturn(null);
        when(projectMapper.selectActiveByProductIdAndName(1001L, "产品主线项目", "cancelled")).thenReturn(null);
        when(productMapper.selectById(1001L)).thenReturn(product);
        when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.PROJECT_TYPE, List.of("mainline"))).thenReturn(success(true));
        when(projectMapper.selectActiveMainlineListByProductId(1001L,
                Set.of("mainline", "main", "product_mainline", "主线"), "cancelled"))
                .thenReturn(List.of(createProject(1001L, 1001L, "已有主线项目", 2001L, "active")));

        ServiceException ex = assertThrows(ServiceException.class, () -> projectService.createProject(reqVO));

        assertEquals(ErrorCodeConstants.PROJECT_MAINLINE_DUPLICATE.getCode(), ex.getCode());
        verify(projectMapper, never()).insert(any(ProjectDO.class));
    }

    @Test
    void updateProject_withoutProduct_shouldUpdateDirectionCode() {
        Long projectId = 1004L;
        ProjectDO project = createProject(projectId, null, "内部项目", 2001L, "pending");
        project.setDirectionCode("old_direction");
        ProjectSaveReqVO reqVO = createReqVO("CNPJ2026001", null, "内部项目", 2001L);
        reqVO.setId(projectId);
        reqVO.setDirectionCode("new_direction");
        when(projectMapper.selectById(projectId)).thenReturn(project);
        when(projectMapper.selectActiveByProductIdAndName(null, "内部项目", "cancelled")).thenReturn(project);
        when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.PROJECT_TYPE, List.of("delivery"))).thenReturn(success(true));
        when(dictDataApi.validateDictDataList("rdms_object_direction", List.of("new_direction"))).thenReturn(success(true));
        when(adminUserApi.validateUserList(any())).thenReturn(success(true));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
                .thenReturn(createStatus("pending", true, true));

        projectService.updateProject(reqVO);

        assertEquals("new_direction", project.getDirectionCode());
    }

    @Test
    void updateProject_withProduct_shouldUseProductDirectionCode() {
        Long projectId = 1005L;
        ProjectDO project = createProject(projectId, 1001L, "产品关联项目", 2001L, "pending");
        project.setDirectionCode("old_direction");
        ProjectSaveReqVO reqVO = createReqVO("CNPJ2026001", 1001L, "产品关联项目", 2001L);
        reqVO.setId(projectId);
        reqVO.setDirectionCode("manual_direction");
        ProductDO product = new ProductDO();
        product.setId(1001L);
        product.setDirectionCode("product_direction");
        when(projectMapper.selectById(projectId)).thenReturn(project);
        when(projectMapper.selectActiveByProductIdAndName(1001L, "产品关联项目", "cancelled")).thenReturn(project);
        when(productMapper.selectById(1001L)).thenReturn(product);
        when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.PROJECT_TYPE, List.of("delivery"))).thenReturn(success(true));
        when(adminUserApi.validateUserList(any())).thenReturn(success(true));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
                .thenReturn(createStatus("pending", true, true));

        projectService.updateProject(reqVO);

        assertEquals("product_direction", project.getDirectionCode());
    }

    @Test
    void getProjectContext_whenCreatorHasNoObjectRole_shouldReturnVisitorResources() {
        // 改造后：无显式角色 + scope 命中 → 返回 implicit_observer_project 上下文（原 visitor 兜底已由 scope.contains 替代）
        Long projectId = 1002L;
        Long loginUserId = 3002L;
        Long observerRoleId = 9202L;
        ProjectDO project = createProject(projectId, null, "创建人项目", 2001L, "pending");
        project.setCreator(String.valueOf(loginUserId));
        ObjectRolePermissionRespDTO detail = new ObjectRolePermissionRespDTO();
        detail.setCurrentRole(createRole(observerRoleId, "implicit_observer_project", "项目隐式观察者"));
        detail.setMenus(List.of(
                createMenu(9301L, "概览", null, 2, 10, "/project/project/overview", "mdi:home-outline", true)
        ));
        detail.setPermissions(Set.of());
        detail.setAdditionalRoleNames(Collections.emptyList());
        when(projectMapper.selectById(projectId)).thenReturn(project);
        when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
                .thenReturn(Collections.emptyList());
        // scope 命中该项目 id
        when(objectDataScopeService.compute(loginUserId, "project"))
                .thenReturn(ObjectDataScope.idList(Set.of(projectId), Set.of()));
        when(objectPermissionApi.getObjectRoleByCode("implicit_observer_project", "object", "project"))
                .thenReturn(success(createRole(observerRoleId, "implicit_observer_project", "项目隐式观察者")));
        when(objectPermissionApi.getObjectRolePermissionDetailMerged(List.of(observerRoleId), "object", "project"))
                .thenReturn(success(detail));
        when(projectStatusViewService.getLifecycle("pending"))
                .thenReturn(new ProjectStatusViewService.ProjectLifecycleView(
                        "待开始", false, true, List.of(
                        createLifecycleAction("cancel", "取消", true)
                )));

        ProjectContextRespVO respVO;
        try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "创建人")) {
            respVO = projectService.getProjectContext(projectId);
        }

        assertNotNull(respVO.getCurrentProject());
        assertEquals("待开始", respVO.getCurrentProject().getStatusName());
        assertEquals(Boolean.FALSE, respVO.getCurrentProject().getTerminal());
        assertEquals(Boolean.TRUE, respVO.getCurrentProject().getAllowEdit());
        assertEquals(1, respVO.getCurrentProject().getAvailableActions().size());
        assertEquals("cancel", respVO.getCurrentProject().getAvailableActions().get(0).getActionCode());
        assertEquals(Boolean.TRUE, respVO.getCurrentRole().getGuestFlag());
        assertEquals(observerRoleId, respVO.getCurrentRole().getRoleId());
        assertEquals("implicit_observer_project", respVO.getCurrentRole().getRoleCode());
        assertEquals("项目隐式观察者", respVO.getCurrentRole().getRoleName());
        assertEquals(1, respVO.getNavs().size());
        assertEquals(9301L, respVO.getNavs().get(0).getId());
        assertEquals(List.of(), respVO.getButtons());
    }

    @Test
    void getProjectContext_returnsImplicitObserver_whenNoExplicitRoleButScopeContains() {
        Long projectId = 100L;
        Long loginUserId = 1L;
        Long observerRoleId = 9203L;
        ProjectDO project = createProject(projectId, null, "观察者项目", 2001L, "active");
        project.setDirectionCode("dc_obs");
        ObjectRolePermissionRespDTO detail = new ObjectRolePermissionRespDTO();
        detail.setCurrentRole(createRole(observerRoleId, "implicit_observer_project", "项目隐式观察者"));
        detail.setMenus(List.of(
                createMenu(9401L, "任务", null, 2, 20, "/project/project/task", "mdi:format-list-checks", true)
        ));
        detail.setPermissions(Set.of());
        detail.setAdditionalRoleNames(Collections.emptyList());
        when(projectMapper.selectById(projectId)).thenReturn(project);
        when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
                .thenReturn(Collections.emptyList());
        when(objectDataScopeService.compute(loginUserId, "project"))
                .thenReturn(ObjectDataScope.idList(Set.of(projectId), Set.of()));
        when(objectPermissionApi.getObjectRoleByCode("implicit_observer_project", "object", "project"))
                .thenReturn(success(createRole(observerRoleId, "implicit_observer_project", "项目隐式观察者")));
        when(objectPermissionApi.getObjectRolePermissionDetailMerged(List.of(observerRoleId), "object", "project"))
                .thenReturn(success(detail));
        when(projectStatusViewService.getLifecycle("active"))
                .thenReturn(new ProjectStatusViewService.ProjectLifecycleView(
                        "进行中", false, true, List.of()
                ));

        ProjectContextRespVO respVO;
        try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "观察者")) {
            respVO = projectService.getProjectContext(projectId);
        }

        assertNotNull(respVO);
        assertEquals(Boolean.TRUE, respVO.getCurrentRole().getGuestFlag());
        assertEquals("项目隐式观察者", respVO.getCurrentRole().getRoleName());
        assertEquals("implicit_observer_project", respVO.getCurrentRole().getRoleCode());
        assertEquals(1, respVO.getNavs().size());
        assertEquals(List.of(), respVO.getButtons());
    }

    @Test
    void getProjectContext_throws_whenNoExplicitRoleAndScopeMisses() {
        Long projectId = 100L;
        Long loginUserId = 1L;
        ProjectDO project = createProject(projectId, null, "无权限项目", 2001L, "active");
        project.setDirectionCode("dc_miss");
        when(projectMapper.selectById(projectId)).thenReturn(project);
        when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
                .thenReturn(Collections.emptyList());
        when(objectDataScopeService.compute(loginUserId, "project"))
                .thenReturn(ObjectDataScope.empty());

        ServiceException ex;
        try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "无关用户")) {
            ex = assertThrows(ServiceException.class, () -> projectService.getProjectContext(projectId));
        }

        assertEquals(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode());
    }

    @Test
    void getProjectPage_returnsEmpty_whenScopeIsEmpty() {
        try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
            mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
            when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.empty());

            PageResult<ProjectRespVO> result = projectService.getProjectPage(new ProjectPageReqVO());

            assertThat(result.getList()).isEmpty();
            verify(projectMapper, never()).selectPage(any(ProjectPageReqVO.class), any());
        }
    }

    @Test
    void getProjectPage_passesIdListIntoSql_whenScopeIsIdList() {
        try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
            mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
            when(objectDataScopeService.compute(1L, "project"))
                    .thenReturn(ObjectDataScope.idList(Set.of(101L), Set.of()));
            when(projectMapper.selectPage(any(ProjectPageReqVO.class), any())).thenReturn(new PageResult<>());

            projectService.getProjectPage(new ProjectPageReqVO());

            verify(projectMapper, times(1)).selectPage(any(ProjectPageReqVO.class), any());
        }
    }

    @Test
    void getProjectPage_skipsScopeFilter_whenScopeIsAll() {
        try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
            mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
            when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.all());
            when(projectMapper.selectPage(any(ProjectPageReqVO.class), any())).thenReturn(new PageResult<>());

            projectService.getProjectPage(new ProjectPageReqVO());

            verify(projectMapper, times(1)).selectPage(any(ProjectPageReqVO.class), any());
        }
    }

    @Test
    void getProjectPage_fillsProductNameAndManagerNicknameInBatch() {
        try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
            mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
            when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.all());

            ProjectDO p1 = new ProjectDO();
            p1.setId(101L);
            p1.setProductId(11L);
            p1.setManagerUserId(21L);
            ProjectDO p2 = new ProjectDO();
            p2.setId(102L); // 游离且无负责人，回填应安全跳过
            when(projectMapper.selectPage(any(ProjectPageReqVO.class), any()))
                    .thenReturn(new PageResult<>(List.of(p1, p2), 2L));

            ProductDO product = new ProductDO();
            product.setId(11L);
            product.setName("智能网关");
            when(productMapper.selectBatchIds(Set.of(11L))).thenReturn(List.of(product));

            AdminUserRespDTO manager = new AdminUserRespDTO();
            manager.setId(21L);
            manager.setNickname("张三");
            when(adminUserApi.getUserMap(Set.of(21L))).thenReturn(Map.of(21L, manager));

            PageResult<ProjectRespVO> result = projectService.getProjectPage(new ProjectPageReqVO());

            assertEquals(2, result.getList().size());
            assertEquals("智能网关", result.getList().get(0).getProductName());
            assertEquals("张三", result.getList().get(0).getManagerUserNickname());
            assertEquals(102L, result.getList().get(1).getId());
            // 批量回填：用户 API 只允许调用一次
            verify(adminUserApi, times(1)).getUserMap(any());
        }
    }

    @Test
    void getProjectGroupPage_returnsEmpty_whenScopesEmpty() {
        try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
            mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
            when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.empty());
            when(objectDataScopeService.compute(1L, "product")).thenReturn(ObjectDataScope.empty());

            ProjectGroupPageRespVO respVO = projectService.getProjectGroupPage(new ProjectGroupPageReqVO());

            assertEquals(0L, respVO.getTotal());
            assertEquals(0L, respVO.getProjectTotal());
            assertEquals(0L, respVO.getOrphanTotal());
            assertEquals(0, respVO.getDirectionCount());
            assertThat(respVO.getList()).isEmpty();
            verify(projectMapper, never()).selectList(any(LambdaQueryWrapperX.class));
        }
    }

    @Test
    void getProjectGroupPage_groupsByProduct_orphanLast_topNTruncated() {
        try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
            mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
            when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.all());
            when(objectDataScopeService.compute(1L, "product")).thenReturn(ObjectDataScope.all());
            when(objectStatusModelMapper.selectListByObjectTypeEnabled("project")).thenReturn(projectStatusModels());
            when(objectStatusModelMapper.selectListByObjectTypeEnabled("product")).thenReturn(productStatusModels());

            // 产品 11（方向 dir_a）3 个项目、产品 12（方向 dir_b）1 个项目、游离 1 个
            ProjectDO p1 = createGroupProject(101L, 11L, "main", LocalDateTime.of(2026, 6, 1, 10, 0));
            ProjectDO p2 = createGroupProject(102L, 11L, "contract", LocalDateTime.of(2026, 6, 3, 10, 0));
            ProjectDO p3 = createGroupProject(103L, 11L, "contract", LocalDateTime.of(2026, 6, 2, 10, 0));
            ProjectDO p4 = createGroupProject(104L, 12L, "tech_support", LocalDateTime.of(2026, 6, 1, 10, 0));
            ProjectDO p5 = createGroupProject(105L, null, "contract", LocalDateTime.of(2026, 6, 1, 10, 0));
            // selectList 调用次序与实现一致：① 命中项目 ② typeCounts（不传 statusCode 时按"全部"口径） ③ hasBaseline（主线）
            when(projectMapper.selectList(any(LambdaQueryWrapperX.class)))
                    .thenReturn(List.of(p1, p2, p3, p4, p5),
                            List.of(p1, p2, p3, p4, p5),
                            List.of(p1));
            ProductDO prod11 = createGroupProduct(11L, "智能网关", "dir_a", 21L);
            ProductDO prod12 = createGroupProduct(12L, "边缘平台", "dir_b", null);
            when(productMapper.selectList(any(LambdaQueryWrapperX.class))).thenReturn(List.of(prod11, prod12));
            when(adminUserApi.getUserMap(any())).thenReturn(Collections.emptyMap());

            ProjectGroupPageReqVO reqVO = new ProjectGroupPageReqVO();
            reqVO.setTopN(2);
            ProjectGroupPageRespVO respVO = projectService.getProjectGroupPage(reqVO);

            assertEquals(3L, respVO.getTotal()); // 两个产品组 + 游离组
            assertEquals(5L, respVO.getProjectTotal());
            assertEquals(1L, respVO.getOrphanTotal());
            assertEquals(2, respVO.getDirectionCount());
            assertEquals(3, respVO.getList().size());

            ProjectGroupRespVO group11 = respVO.getList().get(0); // dir_a 在 dir_b 前
            assertEquals(11L, group11.getProductId());
            assertEquals(3L, group11.getProjectTotal());
            assertEquals(2, group11.getProjects().size()); // topN 截断
            assertEquals(102L, group11.getProjects().get(0).getId()); // 更新时间倒序
            assertEquals(103L, group11.getProjects().get(1).getId());
            assertEquals(Boolean.TRUE, group11.getHasBaseline());
            assertEquals(Map.of("main", 1L, "contract", 2L), group11.getTypeCounts());

            ProjectGroupRespVO orphanGroup = respVO.getList().get(2); // 游离组殿后
            assertEquals(Boolean.TRUE, orphanGroup.getOrphan());
            assertEquals("游离项目", orphanGroup.getProductName());
            assertEquals(1L, orphanGroup.getProjectTotal());
            assertEquals(Map.of("contract", 1L), orphanGroup.getTypeCounts());
            assertEquals(Boolean.FALSE, orphanGroup.getHasBaseline());
        }
    }

    @Test
    void getProjectGroupPage_appendsZeroProjectGroups_onDefaultScope() {
        try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
            mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
            when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.all());
            when(objectDataScopeService.compute(1L, "product")).thenReturn(ObjectDataScope.all());
            when(objectStatusModelMapper.selectListByObjectTypeEnabled("project")).thenReturn(projectStatusModels());
            when(objectStatusModelMapper.selectListByObjectTypeEnabled("product")).thenReturn(productStatusModels());

            ProjectDO p1 = createGroupProject(101L, 11L, "contract", LocalDateTime.of(2026, 6, 1, 10, 0));
            when(projectMapper.selectList(any(LambdaQueryWrapperX.class)))
                    .thenReturn(List.of(p1), List.of(p1), Collections.emptyList());
            ProductDO prod11 = createGroupProduct(11L, "智能网关", "dir_a", null);
            ProductDO prod12 = createGroupProduct(12L, "边缘平台", "dir_b", null);
            when(productMapper.selectList(any(LambdaQueryWrapperX.class))).thenReturn(List.of(prod11, prod12));

            ProjectGroupPageRespVO respVO = projectService.getProjectGroupPage(new ProjectGroupPageReqVO());

            assertEquals(2L, respVO.getTotal()); // 产品 11 命中组 + 产品 12 零项目占位组，无游离组
            ProjectGroupRespVO zeroGroup = respVO.getList().get(1);
            assertEquals(12L, zeroGroup.getProductId());
            assertEquals(0L, zeroGroup.getProjectTotal());
            assertThat(zeroGroup.getProjects()).isEmpty();
        }
    }

    @Test
    void getProjectGroupPage_skipsZeroGroupsAndMisses_whenStatusFiltered() {
        try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
            mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
            when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.all());
            when(objectDataScopeService.compute(1L, "product")).thenReturn(ObjectDataScope.all());
            // 显式传 statusCode 时不再推导项目"全部"状态集，仅 stub 产品状态机
            when(objectStatusModelMapper.selectListByObjectTypeEnabled("product")).thenReturn(productStatusModels());

            ProjectDO p1 = createGroupProject(101L, 11L, "contract", LocalDateTime.of(2026, 6, 1, 10, 0));
            when(projectMapper.selectList(any(LambdaQueryWrapperX.class)))
                    .thenReturn(List.of(p1), List.of(p1), Collections.emptyList());
            ProductDO prod11 = createGroupProduct(11L, "智能网关", "dir_a", null);
            ProductDO prod12 = createGroupProduct(12L, "边缘平台", "dir_b", null);
            when(productMapper.selectList(any(LambdaQueryWrapperX.class))).thenReturn(List.of(prod11, prod12));

            ProjectGroupPageReqVO reqVO = new ProjectGroupPageReqVO();
            reqVO.setStatusCode("active");
            ProjectGroupPageRespVO respVO = projectService.getProjectGroupPage(reqVO);

            assertEquals(1L, respVO.getTotal()); // 仅产品 11；产品 12 不补占位；游离无命中不返回
            assertEquals(11L, respVO.getList().get(0).getProductId());
            assertEquals(Map.of("contract", 1L), respVO.getList().get(0).getTypeCounts());

            // typeCounts 与 projectTotal 同口径：传 statusCode 时全程不推导项目"全部"状态集，
            // 即 typeCounts 统计不可能回退到"全部"口径
            verify(objectStatusModelMapper, never()).selectListByObjectTypeEnabled("project");
        }
    }

    @Test
    void getProjectOverviewSummary_includesOrphanCount() {
        try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
            mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
            when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.all());
            when(projectMapper.selectStatusCountList()).thenReturn(Collections.emptyList());
            when(objectStatusModelMapper.selectListByObjectType("project")).thenReturn(Collections.emptyList());
            when(objectStatusModelMapper.selectListByObjectTypeEnabled("project")).thenReturn(projectStatusModels());
            when(projectMapper.selectCount(any(LambdaQueryWrapperX.class))).thenReturn(3L);

            ProjectOverviewSummaryRespVO respVO = projectService.getProjectOverviewSummary();

            assertEquals(3L, respVO.getOrphanCount());
        }
    }

    @Test
    void changeProjectStatus_shouldUpdateStatusAndWriteStatusAndAuditLogs() {
        Long projectId = 1003L;
        ProjectDO project = createProject(projectId, null, "状态项目", 2001L, "active");
        ProjectStatusActionReqVO reqVO = new ProjectStatusActionReqVO();
        reqVO.setId(projectId);
        reqVO.setActionCode("pause");
        reqVO.setReason("环境受限");
        when(projectMapper.selectById(projectId)).thenReturn(project);
        when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("project", "active", "pause"))
                .thenReturn(createTransition("pause", "paused", true));
        when(projectMapper.updateStatusByIdAndStatus(projectId, "active", "paused", "环境受限")).thenReturn(1);

        try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(3003L, "操作人")) {
            projectService.changeProjectStatus(reqVO);
        }

        verify(projectMapper).updateStatusByIdAndStatus(projectId, "active", "paused", "环境受限");
        ArgumentCaptor<ProjectStatusLogDO> statusCaptor = ArgumentCaptor.forClass(ProjectStatusLogDO.class);
        verify(projectStatusLogMapper).insert(statusCaptor.capture());
        assertEquals("pause", statusCaptor.getValue().getActionType());
        assertEquals("active", statusCaptor.getValue().getFromStatus());
        assertEquals("paused", statusCaptor.getValue().getToStatus());
    }

    @Test
    void changeProjectStatus_complete_whenChildrenNotAllTerminal_listsAllGaps() {
        Long projectId = 3001L;
        ProjectDO project = createProject(projectId, 1001L, "项目X", 2001L, "active");
        when(projectMapper.selectById(projectId)).thenReturn(project);
        // 终态码按 object_type 查 DB（这里直接 stub），count 决定缺口
        when(objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled("task"))
                .thenReturn(List.of("completed", "cancelled"));
        when(objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled("execution"))
                .thenReturn(List.of("completed", "cancelled"));
        when(objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled("project_requirement"))
                .thenReturn(List.of("closed", "cancelled", "rejected"));
        when(projectTaskMapper.countNonTerminalByProjectId(any(), any())).thenReturn(2);
        when(projectExecutionMapper.countNonTerminalByProjectId(any(), any())).thenReturn(1);
        when(projectRequirementMapper.countNonTerminalByProjectId(any(), any())).thenReturn(1);

        ProjectStatusActionReqVO reqVO = new ProjectStatusActionReqVO();
        reqVO.setId(projectId);
        reqVO.setActionCode("complete");

        ServiceException ex = assertThrows(ServiceException.class,
                () -> projectService.changeProjectStatus(reqVO));
        assertEquals(ErrorCodeConstants.PROJECT_COMPLETE_PRECONDITION_NOT_MET.getCode(), ex.getCode());
        // 一次性列全三类缺口
        assertTrue(ex.getMessage().contains("2 个任务"));
        assertTrue(ex.getMessage().contains("1 个执行"));
        assertTrue(ex.getMessage().contains("1 个需求"));
        // 脱敏：不得泄漏表名 / status_code
        assertTrue(!ex.getMessage().contains("rdms_"));
        assertTrue(!ex.getMessage().contains("status_code"));
        // 校验不通过：不得写库
        verify(projectMapper, never()).updateStatusByIdAndStatus(any(), any(), any(), any());
    }

    @Test
    void changeProjectStatus_complete_whenAllChildrenTerminal_passes() {
        Long projectId = 3002L;
        ProjectDO project = createProject(projectId, 1001L, "项目Y", 2001L, "active");
        when(projectMapper.selectById(projectId)).thenReturn(project);
        when(objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled(any()))
                .thenReturn(List.of("completed", "cancelled"));
        when(projectTaskMapper.countNonTerminalByProjectId(any(), any())).thenReturn(0);
        when(projectExecutionMapper.countNonTerminalByProjectId(any(), any())).thenReturn(0);
        when(projectRequirementMapper.countNonTerminalByProjectId(any(), any())).thenReturn(0);
        when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("project", "active", "complete"))
                .thenReturn(createTransition("complete", "completed", false));
        when(projectMapper.updateStatusByIdAndStatus(eq(projectId), eq("active"), eq("completed"), any()))
                .thenReturn(1);

        ProjectStatusActionReqVO reqVO = new ProjectStatusActionReqVO();
        reqVO.setId(projectId);
        reqVO.setActionCode("complete");

        try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(3003L, "操作人")) {
            projectService.changeProjectStatus(reqVO);
        }

        verify(projectMapper, times(1))
                .updateStatusByIdAndStatus(eq(projectId), eq("active"), eq("completed"), any());
    }

    @Test
    void publishProjectCreatedNotify_perMemberWithRole() {
        try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
            mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(9L);
            ProjectDO project = new ProjectDO();
            project.setId(10L);
            project.setProjectName("灿能项目");
            when(teamNotifyRecipientResolver.resolveActiveExcludingCreator(
                    eq(ProjectObjectConstants.OBJECT_TYPE), eq(10L), anyString(), anyString()))
                    .thenReturn(List.of(
                            new com.njcn.rdms.module.project.framework.notify.TeamRecipient(5L, "项目经理"),
                            new com.njcn.rdms.module.project.framework.notify.TeamRecipient(6L, "项目观察者")));

            ArgumentCaptor<com.njcn.rdms.module.project.framework.notify.NotifySendEvent> captor =
                    ArgumentCaptor.forClass(com.njcn.rdms.module.project.framework.notify.NotifySendEvent.class);
            projectService.publishProjectCreatedNotify(project);
            verify(applicationEventPublisher, times(2)).publishEvent(captor.capture());
            // 逐成员一条事件、各带自己的角色名
            com.njcn.rdms.module.project.framework.notify.NotifySendEvent first = captor.getAllValues().get(0);
            assertEquals("project_created", first.getTemplateCode());
            assertEquals(9L, first.getOperatorUserId());
            assertEquals(com.njcn.rdms.module.project.framework.notify.NotifyJumpConstants.JUMP_TYPE_PROJECT,
                    first.getParams().get(com.njcn.rdms.module.project.framework.notify.NotifyJumpConstants.PARAM_JUMP_TYPE));
            assertEquals("10",
                    first.getParams().get(com.njcn.rdms.module.project.framework.notify.NotifyJumpConstants.PARAM_PROJECT_ID));
            assertEquals("项目经理", first.getParams().get("roleName"));
            assertEquals("灿能项目", first.getParams().get("projectName"));
            assertTrue(first.getUserIds().contains(5L));
        }
    }

    private ProjectSaveReqVO createReqVO(String code, Long productId, String projectName, Long managerUserId) {
        ProjectSaveReqVO reqVO = new ProjectSaveReqVO();
        reqVO.setProjectCode(code);
        reqVO.setProductId(productId);
        reqVO.setProjectName(projectName);
        reqVO.setProjectType("delivery");
        reqVO.setManagerUserId(managerUserId);
        reqVO.setDirectionCode("manual_direction");
        return reqVO;
    }

    private ProjectDO createProject(Long id, Long productId, String projectName, Long managerUserId, String statusCode) {
        ProjectDO project = new ProjectDO();
        project.setId(id);
        project.setProjectCode("CNPJ" + LocalDate.now().getYear() + "001");
        project.setProductId(productId);
        project.setProjectName(projectName);
        project.setProjectType("delivery");
        project.setManagerUserId(managerUserId);
        project.setStatusCode(statusCode);
        return project;
    }

    private ObjectRoleRespDTO createRole(Long roleId, String roleCode, String roleName) {
        ObjectRoleRespDTO role = new ObjectRoleRespDTO();
        role.setId(roleId);
        role.setCode(roleCode);
        role.setName(roleName);
        role.setScopeType("object");
        role.setObjectType("project");
        return role;
    }

    private ObjectMenuRespDTO createMenu(Long id, String name, String permission, Integer type, Integer sort,
                                         String path, String icon, Boolean visible) {
        ObjectMenuRespDTO menu = new ObjectMenuRespDTO();
        menu.setId(id);
        menu.setName(name);
        menu.setPermission(permission);
        menu.setType(type);
        menu.setSort(sort);
        menu.setPath(path);
        menu.setIcon(icon);
        menu.setVisible(visible);
        return menu;
    }

    private ObjectStatusTransitionDO createTransition(String actionCode, String toStatus, boolean needReason) {
        ObjectStatusTransitionDO transition = new ObjectStatusTransitionDO();
        transition.setActionCode(actionCode);
        transition.setToStatusCode(toStatus);
        transition.setNeedReason(needReason);
        return transition;
    }

    private ProjectLifecycleActionRespVO createLifecycleAction(String actionCode, String actionName, boolean needReason) {
        ProjectLifecycleActionRespVO action = new ProjectLifecycleActionRespVO();
        action.setActionCode(actionCode);
        action.setActionName(actionName);
        action.setNeedReason(needReason);
        return action;
    }

    private ObjectStatusModelDO createStatus(String statusCode, boolean initialFlag, boolean allowEdit) {
        ObjectStatusModelDO statusModel = new ObjectStatusModelDO();
        statusModel.setObjectType("project");
        statusModel.setStatusCode(statusCode);
        statusModel.setInitialFlag(initialFlag);
        statusModel.setAllowEdit(allowEdit);
        return statusModel;
    }

    private MockedStatic<SecurityFrameworkUtils> mockLoginUser(Long loginUserId, String nickname) {
        MockedStatic<SecurityFrameworkUtils> mockedStatic = mockStatic(SecurityFrameworkUtils.class);
        mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);
        mockedStatic.when(SecurityFrameworkUtils::getLoginUserNickname).thenReturn(nickname);
        return mockedStatic;
    }

    /** 状态看板测试用状态机行：带展示名 / 终态标识。 */
    private ObjectStatusModelDO createBoardStatusModel(String statusCode, String statusName,
                                                       int sort, int status, boolean terminalFlag) {
        ObjectStatusModelDO statusModel = new ObjectStatusModelDO();
        statusModel.setObjectType("project");
        statusModel.setStatusCode(statusCode);
        statusModel.setStatusName(statusName);
        statusModel.setSort(sort);
        statusModel.setStatus(status);
        statusModel.setTerminalFlag(terminalFlag);
        return statusModel;
    }

    /**
     * 分组接口测试用状态机行：status=0（启用）。"全部"口径与产品存续状态均由状态机推导，测试必须配齐。
     */
    private ObjectStatusModelDO createGroupStatusModel(String objectType, String statusCode, boolean terminalFlag) {
        ObjectStatusModelDO statusModel = new ObjectStatusModelDO();
        statusModel.setObjectType(objectType);
        statusModel.setStatusCode(statusCode);
        statusModel.setStatus(0);
        statusModel.setTerminalFlag(terminalFlag);
        return statusModel;
    }

    /** 项目域状态机 stub：启用状态全集（含 cancelled/archived；2026-06-11 起「全部」口径不再排除）。 */
    private List<ObjectStatusModelDO> projectStatusModels() {
        return List.of(
                createGroupStatusModel("project", "pending", false),
                createGroupStatusModel("project", "active", false),
                createGroupStatusModel("project", "paused", false),
                createGroupStatusModel("project", "completed", true),
                createGroupStatusModel("project", "cancelled", true),
                createGroupStatusModel("project", "archived", true));
    }

    /** 产品域状态机 stub：启用/暂停为非终态，归档/废弃为终态（推导应只取非终态）。 */
    private List<ObjectStatusModelDO> productStatusModels() {
        return List.of(
                createGroupStatusModel("product", "active", false),
                createGroupStatusModel("product", "paused", false),
                createGroupStatusModel("product", "archived", true),
                createGroupStatusModel("product", "abandoned", true));
    }

    private ProjectDO createGroupProject(Long id, Long productId, String projectType, LocalDateTime updateTime) {
        ProjectDO project = new ProjectDO();
        project.setId(id);
        project.setProductId(productId);
        project.setProjectType(projectType);
        project.setStatusCode("active");
        project.setUpdateTime(updateTime);
        return project;
    }

    private ProductDO createGroupProduct(Long id, String name, String directionCode, Long managerUserId) {
        ProductDO product = new ProductDO();
        product.setId(id);
        product.setName(name);
        product.setDirectionCode(directionCode);
        product.setStatusCode("active");
        product.setManagerUserId(managerUserId);
        product.setCreateTime(LocalDateTime.of(2026, 5, 1, 0, 0));
        return product;
    }

}
