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

import com.google.common.annotations.VisibleForTesting;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.common.util.json.JsonUtils;
import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
import com.njcn.rdms.module.project.constant.ObjectRoleConstants;
import com.njcn.rdms.module.project.constant.ProductObjectConstants;
import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
import com.njcn.rdms.module.project.constant.ProjectExecutionConstants;
import com.njcn.rdms.module.project.constant.ProjectRequirementConstants;
import com.njcn.rdms.module.project.controller.admin.common.vo.CurrentUserRoleVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberSaveReqVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextNavRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextProjectRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextRoleRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectCreateWithTeamReqVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectDeleteReqVO;
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.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.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.ProjectRequirementModuleDO;
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.constant.ProjectTaskConstants;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementModuleMapper;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectStatusLogMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskWorklogMapper;
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.project.framework.notify.NotifyJumpConstants;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScope;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScopeService;
import com.njcn.rdms.module.project.service.status.StatusActionTextResolver;
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 com.njcn.rdms.module.system.enums.DictTypeConstants;
import com.njcn.rdms.module.system.enums.permission.MenuTypeEnum;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;

import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;

/**
 * 项目 Service 实现类
 */
@Service
public
class ProjectServiceImpl implements ProjectService {

    @Resource
    private ProjectMapper projectMapper;
    @Resource
    private ProjectStatusLogMapper projectStatusLogMapper;
    @Resource
    private ProductMapper productMapper;
    @Resource
    private BizAuditLogMapper bizAuditLogMapper;
    @Resource
    private ObjectStatusTransitionMapper objectStatusTransitionMapper;
    @Resource
    private ObjectStatusModelMapper objectStatusModelMapper;
    @Resource
    private UserObjectRoleMapper userObjectRoleMapper;
    @Resource
    private ProjectStatusViewService projectStatusViewService;
    @Resource
    private ObjectPermissionApi objectPermissionApi;
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private DictDataApi dictDataApi;
    @Resource
    private ProjectRequirementModuleMapper projectRequirementModuleMapper;
    @Resource
    private ProjectTaskMapper projectTaskMapper;
    @Resource
    private TaskWorklogMapper taskWorklogMapper;
    @Resource
    private com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper projectExecutionMapper;
    @Resource
    private com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementMapper projectRequirementMapper;
    @Resource
    private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService;
    @Resource
    private com.njcn.rdms.module.project.service.member.CurrentUserRoleResolver currentUserRoleResolver;
    @Resource
    private ObjectDataScopeService objectDataScopeService;
    @Resource
    private StatusActionTextResolver statusActionTextResolver;
    /** 站内信事件发布：创建项目后发「项目创建」通知，由 NotifySendEventListener 事务提交后统一发送。 */
    @Resource
    private org.springframework.context.ApplicationEventPublisher applicationEventPublisher;
    /** 团队通知接收人解析：仅 active、排除创建者角色、逐成员带代表角色中文名。 */
    @Resource
    private com.njcn.rdms.module.project.framework.notify.TeamNotifyRecipientResolver teamNotifyRecipientResolver;

    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createProject(ProjectSaveReqVO createReqVO) {
        validateCreateReqVO(createReqVO);
        validateProjectType(createReqVO.getProjectType());
        String finalDirectionCode = resolveProjectDirectionCode(createReqVO.getProductId(), createReqVO.getDirectionCode());
        validateMainlineProjectUnique(null, createReqVO.getProductId(), createReqVO.getProjectType());
        validateManagerUser(createReqVO.getManagerUserId());

        ProjectDO project = new ProjectDO();
        project.setProjectCode(generateProjectCode(createReqVO.getProjectCode()));
        project.setProjectName(createReqVO.getProjectName().trim());
        project.setProjectType(createReqVO.getProjectType().trim());
        project.setDirectionCode(finalDirectionCode);
        project.setProjectSetId(createReqVO.getProjectSetId());
        project.setProductId(createReqVO.getProductId());
        project.setProductVersionId(createReqVO.getProductVersionId());
        project.setManagerUserId(createReqVO.getManagerUserId());
        String initialStatus = getInitialStatusCode();
        project.setStatusCode(initialStatus);
        project.setPlannedStartDate(createReqVO.getPlannedStartDate());
        project.setPlannedEndDate(createReqVO.getPlannedEndDate());
        project.setActualStartDate(createReqVO.getActualStartDate());
        project.setActualEndDate(createReqVO.getActualEndDate());
        project.setProgressRate(BigDecimal.ZERO);
        project.setProjectDesc(normalizeNullableText(createReqVO.getProjectDesc()));
        projectMapper.insert(project);

        initManagerMemberRelation(project);
        // 多角色支持：自动落 creator 行（即使 creator == manager 也独立写一条，确保创建者身份不丢）
        objectRoleAutoAssignService.assignCreator(ProjectObjectConstants.OBJECT_TYPE, project.getId(),
                SecurityFrameworkUtils.getLoginUserId(),
                com.njcn.rdms.module.system.enums.permission.RoleCodeEnum.PROJECT_CREATOR.getCode());
        initDefaultRequirementModule(project);
        writeBizAuditLog(project, ObjectActivityConstants.PROJECT_ACTION_CREATE, null, initialStatus,
                buildProjectFieldChanges(null, project), null);
        // 团队成员已全部落库，发「项目创建」通知（操作人由监听器统一排除）
        publishProjectCreatedNotify(project);
        return project.getId();
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createProjectWithTeam(ProjectCreateWithTeamReqVO reqVO) {
        ProjectSaveReqVO projectReq = reqVO.getProject();
        List<ProjectMemberSaveReqVO> members = reqVO.getMembers();

        // 1) 复用旧 create 接口的项目资料校验（name/code 唯一、计划/实际日期范围、字典类型、主线唯一、负责人有效）
        validateCreateReqVO(projectReq);
        validateProjectType(projectReq.getProjectType());
        // 注意：新接口对方向使用严格校验——productId 非空时 directionCode 必须与产品方向一致；旧 create 是覆盖式，保留不动
        String finalDirectionCode = resolveDirectionCodeStrict(projectReq.getProductId(), projectReq.getDirectionCode());
        validateMainlineProjectUnique(null, projectReq.getProductId(), projectReq.getProjectType());
        validateManagerUser(projectReq.getManagerUserId());

        // 2) 初始团队校验：包含经理、userId 不重复、roleId 合法（含经理那条必须为项目经理角色）
        validateInitialMembers(projectReq.getManagerUserId(), members);

        // 3) 写入项目基础资料：与 createProject 完全一致，唯一差异是不调用 initManagerMemberRelation —— 经理成员由 members 显式提供，避免重复插入
        ProjectDO project = new ProjectDO();
        project.setProjectCode(generateProjectCode(projectReq.getProjectCode()));
        project.setProjectName(projectReq.getProjectName().trim());
        project.setProjectType(projectReq.getProjectType().trim());
        project.setDirectionCode(finalDirectionCode);
        project.setProjectSetId(projectReq.getProjectSetId());
        project.setProductId(projectReq.getProductId());
        project.setProductVersionId(projectReq.getProductVersionId());
        project.setManagerUserId(projectReq.getManagerUserId());
        String initialStatus = getInitialStatusCode();
        project.setStatusCode(initialStatus);
        project.setPlannedStartDate(projectReq.getPlannedStartDate());
        project.setPlannedEndDate(projectReq.getPlannedEndDate());
        // 新增模式不应携带 actualStartDate / actualEndDate；前端不传时即为 null，无需特殊处理
        project.setActualStartDate(projectReq.getActualStartDate());
        project.setActualEndDate(projectReq.getActualEndDate());
        project.setProgressRate(BigDecimal.ZERO);
        project.setProjectDesc(normalizeNullableText(projectReq.getProjectDesc()));
        projectMapper.insert(project);

        // 4) 遍历写入初始成员，含成员维度审计
        LocalDateTime now = LocalDateTime.now();
        for (ProjectMemberSaveReqVO memberReq : members) {
            UserObjectRoleDO member = new UserObjectRoleDO();
            member.setUserId(memberReq.getUserId());
            member.setObjectType(ProjectObjectConstants.OBJECT_TYPE);
            member.setObjectId(project.getId());
            member.setRoleId(memberReq.getRoleId());
            member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
            member.setJoinedTime(now);
            member.setLeftTime(null);
            member.setRemark(normalizeNullableText(memberReq.getRemark()));
            userObjectRoleMapper.insert(member);
            writeMemberAuditLog(member, ObjectActivityConstants.MEMBER_ACTION_ADD, null, member, null);
        }

        // 5) 项目维度的"指定经理"审计：与旧 initManagerMemberRelation 末尾一致，保留活动时间线一致性
        writeManagerChangeAuditLog(project.getId(), null, project.getManagerUserId(), null);

        // 6) 多角色支持：自动落 creator 行（不论 creator 是否在 members 列表里，都独立写一条 creator 角色）
        objectRoleAutoAssignService.assignCreator(ProjectObjectConstants.OBJECT_TYPE, project.getId(),
                SecurityFrameworkUtils.getLoginUserId(),
                com.njcn.rdms.module.system.enums.permission.RoleCodeEnum.PROJECT_CREATOR.getCode());

        // 7) 关心人批量落地（watcherUserIds 可空；可与 members 的 user 重叠 —— 多角色合法）
        objectRoleAutoAssignService.assignWatchers(ProjectObjectConstants.OBJECT_TYPE, project.getId(),
                reqVO.getWatcherUserIds(),
                com.njcn.rdms.module.system.enums.permission.RoleCodeEnum.PROJECT_WATCHER.getCode());

        // 8) 项目创建审计
        writeBizAuditLog(project, ObjectActivityConstants.PROJECT_ACTION_CREATE, null, initialStatus,
                buildProjectFieldChanges(null, project), null);

        // 9) 初始化项目需求的根模块
        initDefaultRequirementModule(project);
        // 10) 团队成员已全部落库，发「项目创建」通知（操作人由监听器统一排除）
        publishProjectCreatedNotify(project);
        return project.getId();
    }

    /**
     * 创建项目后逐成员发「项目创建」站内信：显式团队成员（排除创建者角色），每人带自己的角色名；
     * 操作人自己由监听器统一排除。仅 publish，发送由 NotifySendEventListener 事务提交后处理。
     */
    @VisibleForTesting
    void publishProjectCreatedNotify(ProjectDO project) {
        Long operatorId = SecurityFrameworkUtils.getLoginUserId();
        List<com.njcn.rdms.module.project.framework.notify.TeamRecipient> recipients =
                teamNotifyRecipientResolver.resolveActiveExcludingCreator(
                        ProjectObjectConstants.OBJECT_TYPE, project.getId(),
                        com.njcn.rdms.module.system.enums.permission.RoleCodeEnum.PROJECT_CREATOR.getCode(),
                        ProjectObjectConstants.MANAGER_ROLE_CODE);
        for (com.njcn.rdms.module.project.framework.notify.TeamRecipient r : recipients) {
            // 逐成员一条事件：每人带自己的代表角色中文名
            Map<String, Object> params = new HashMap<>();
            params.put("projectName", project.getProjectName());
            params.put("roleName", r.roleName());
            params.put(NotifyJumpConstants.PARAM_JUMP_TYPE, NotifyJumpConstants.JUMP_TYPE_PROJECT);
            params.put(NotifyJumpConstants.PARAM_PROJECT_ID, String.valueOf(project.getId()));
            applicationEventPublisher.publishEvent(
                    com.njcn.rdms.module.project.framework.notify.NotifySendEvent.of(
                            java.util.List.of(r.userId()),
                            com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants.PROJECT_CREATED,
                            params,
                            com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants.NORMAL, operatorId));
        }
    }

    /**
     * 新接口专用：严格的方向解析。
     *
     * <p>与 {@link #resolveProjectDirectionCode(Long, String)} 的差异：当 {@code productId}
     * 非空且前端显式传入 {@code directionCode} 时，必须与所属产品方向一致；
     * 不一致直接抛 {@link ErrorCodeConstants#PROJECT_DIRECTION_NOT_MATCH_PRODUCT}，
     * 而非旧接口的覆盖式行为。前端正常流程会强制把 directionCode 设为产品方向后锁定，
     * 此校验仅作为防御，防止接口被绕过前端直接调用。
     */
    @VisibleForTesting
    String resolveDirectionCodeStrict(Long productId, String directionCode) {
        if (productId == null) {
            if (!StringUtils.hasText(directionCode)) {
                throw invalidParamException("项目方向不能为空");
            }
            String normalizedDirectionCode = directionCode.trim();
            if (normalizedDirectionCode.length() > 32) {
                throw invalidParamException("项目方向长度不能超过32个字符");
            }
            validateProjectDirection(normalizedDirectionCode);
            return normalizedDirectionCode;
        }
        ProductDO product = validateProductUsable(productId);
        String productDirectionCode = normalizeNullableText(product == null ? null : product.getDirectionCode());
        if (!StringUtils.hasText(productDirectionCode)) {
            throw exception(ErrorCodeConstants.PROJECT_DIRECTION_INVALID);
        }
        if (StringUtils.hasText(directionCode)
                && !Objects.equals(directionCode.trim(), productDirectionCode)) {
            throw exception(ErrorCodeConstants.PROJECT_DIRECTION_NOT_MATCH_PRODUCT);
        }
        return productDirectionCode;
    }

    /**
     * 校验初始团队成员列表。
     *
     * <p>校验维度：
     * <ol>
     *   <li>members 中 userId 不可重复</li>
     *   <li>members 必须包含项目经理本人（userId == managerUserId）</li>
     *   <li>所有 roleId 必须落在 scopeType=object, objectType=project 且启用</li>
     *   <li>经理那条 members 的 roleId 必须是 {@code project_manager} 角色（数据一致性兜底）</li>
     * </ol>
     */
    @VisibleForTesting
    void validateInitialMembers(Long managerUserId, List<ProjectMemberSaveReqVO> members) {
        if (members == null || members.isEmpty()) {
            // @NotEmpty 已挡，此处兜底
            throw exception(ErrorCodeConstants.PROJECT_INITIAL_TEAM_MANAGER_REQUIRED);
        }

        Set<Long> userIds = new HashSet<>();
        for (ProjectMemberSaveReqVO m : members) {
            if (!userIds.add(m.getUserId())) {
                throw exception(ErrorCodeConstants.PROJECT_INITIAL_TEAM_MEMBER_DUPLICATE);
            }
        }
        if (!userIds.contains(managerUserId)) {
            throw exception(ErrorCodeConstants.PROJECT_INITIAL_TEAM_MANAGER_REQUIRED);
        }

        Set<Long> roleIds = members.stream().map(ProjectMemberSaveReqVO::getRoleId).collect(Collectors.toSet());
        List<ObjectRoleRespDTO> roles = objectPermissionApi
                .getObjectRoleList(roleIds, ObjectRoleConstants.ROLE_SCOPE_OBJECT, ProjectObjectConstants.OBJECT_TYPE)
                .getCheckedData();
        Map<Long, ObjectRoleRespDTO> roleMap = roles == null ? Collections.emptyMap()
                : roles.stream().collect(Collectors.toMap(ObjectRoleRespDTO::getId, Function.identity()));
        for (Long roleId : roleIds) {
            if (!roleMap.containsKey(roleId)) {
                throw exception(ErrorCodeConstants.PROJECT_INITIAL_TEAM_ROLE_INVALID);
            }
        }

        // 经理那条 members 的 roleId 必须对应 project_manager 角色，否则 project.managerUserId 与成员表角色语义会错乱
        ProjectMemberSaveReqVO managerMember = members.stream()
                .filter(m -> Objects.equals(m.getUserId(), managerUserId))
                .findFirst()
                .orElseThrow(() -> exception(ErrorCodeConstants.PROJECT_INITIAL_TEAM_MANAGER_REQUIRED));
        ObjectRoleRespDTO managerRole = roleMap.get(managerMember.getRoleId());
        if (managerRole == null
                || !Objects.equals(ProjectObjectConstants.MANAGER_ROLE_CODE, managerRole.getCode())) {
            throw exception(ErrorCodeConstants.PROJECT_INITIAL_TEAM_ROLE_INVALID);
        }
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    @com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE,
            objectId = "#updateReqVO.id", permission = ProjectObjectConstants.PERMISSION_UPDATE)
    public void updateProject(ProjectSaveReqVO updateReqVO) {
        if (updateReqVO.getId() == null) {
            throw invalidParamException("项目编号不能为空");
        }
        ProjectDO project = validateProjectExists(updateReqVO.getId());
        validateProjectEditable(project);
        validateProjectCodeUnchanged(project, updateReqVO.getProjectCode());
        validateProductUnchanged(project, updateReqVO.getProductId());
        validateProjectType(updateReqVO.getProjectType());
        validateManagerUser(updateReqVO.getManagerUserId());
        validateProjectNameUnique(project.getId(), project.getProductId(), updateReqVO.getProjectName());
        validateMainlineProjectUnique(project.getId(), project.getProductId(), updateReqVO.getProjectType());
        validateDateRange(updateReqVO.getPlannedStartDate(), updateReqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期");
        validateDateRange(updateReqVO.getActualStartDate(), updateReqVO.getActualEndDate(), "实际结束日期不能早于实际开始日期");
        String finalDirectionCode = resolveProjectDirectionCode(project.getProductId(), updateReqVO.getDirectionCode());

        ProjectDO before = cloneProject(project);
        project.setProjectName(updateReqVO.getProjectName().trim());
        project.setProjectType(updateReqVO.getProjectType().trim());
        project.setDirectionCode(finalDirectionCode);
        project.setProjectSetId(updateReqVO.getProjectSetId());
        project.setProductVersionId(updateReqVO.getProductVersionId());
        project.setPlannedStartDate(updateReqVO.getPlannedStartDate());
        project.setPlannedEndDate(updateReqVO.getPlannedEndDate());
        project.setActualStartDate(updateReqVO.getActualStartDate());
        project.setActualEndDate(updateReqVO.getActualEndDate());
        project.setProjectDesc(normalizeNullableText(updateReqVO.getProjectDesc()));
        if (!Objects.equals(project.getManagerUserId(), updateReqVO.getManagerUserId())) {
            changeManager(project, updateReqVO.getManagerUserId(), null);
        }
        project.setManagerUserId(updateReqVO.getManagerUserId());
        projectMapper.updateById(project);

        writeBizAuditLog(project, ObjectActivityConstants.PROJECT_ACTION_UPDATE, before.getStatusCode(), project.getStatusCode(),
                buildProjectFieldChanges(before, project), null);
    }

    @Override
    public ProjectDO getProject(Long id) {
        return validateProjectExists(id);
    }

    @Override
    public ProjectRespVO getProjectDetail(Long id) {
        ProjectDO project = validateProjectExists(id);
        return buildProjectRespVO(project);
    }

    @Override
    // 对象域鉴权：是该产品的显式成员即可拿"实现项目"派发下拉数据（memberOnly，不查权限码、不依赖 DB 权限配置）
    @com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE,
            objectId = "#productId", memberOnly = true)
    public List<ProjectRespVO> getProjectListByProductId(Long productId) {
        validateProductUsable(productId);
        return projectMapper.selectListByProductId(productId).stream()
                .map(this::buildProjectRespVO)
                .collect(Collectors.toList());
    }

    @Override
    public ProjectContextRespVO getProjectContext(Long id) {
        ProjectDO project = validateProjectExists(id);
        Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
        // 多角色支持：拿全部 ACTIVE 角色 ID 传聚合 API；主角色按 sort 升序由 API 挑选 + 附 additionalRoleNames
        List<UserObjectRoleDO> userRoles = userObjectRoleMapper
                .selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, id, loginUserId);
        if (!userRoles.isEmpty()) {
            List<Long> roleIds = userRoles.stream().map(UserObjectRoleDO::getRoleId).distinct().toList();
            return buildProjectContext(project, roleIds, false, null);
        }

        // 显式角色为空：走 scope.contains 判定隐式 observer 兜底（设计文档 2.1 节末段）
        ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProjectObjectConstants.OBJECT_TYPE);
        if (!scope.contains(id, project.getDirectionCode())) {
            throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED);
        }
        return buildImplicitObserverContext(project);
    }

    /**
     * 隐式 observer 兜底上下文：用户无显式项目角色但在 scope 范围内，按 implicit_observer_project 角色渲染菜单/权限。
     */
    private ProjectContextRespVO buildImplicitObserverContext(ProjectDO project) {
        ObjectRoleRespDTO observerRole = objectPermissionApi
                .getObjectRoleByCode(
                        com.njcn.rdms.module.system.enums.permission.ObjectRoleConstants.IMPLICIT_OBSERVER_PROJECT.getCode(),
                        ObjectRoleConstants.ROLE_SCOPE_OBJECT,
                        ProjectObjectConstants.OBJECT_TYPE)
                .getCheckedData();
        if (observerRole == null || observerRole.getId() == null) {
            // 角色未配置，降级为无菜单的 guest 上下文（不应发生，属配置缺失）
            return buildProjectContextWithoutMenus(project, true);
        }
        return buildProjectContext(project, List.of(observerRole.getId()), true, observerRole);
    }

    private ProjectContextRespVO buildProjectContext(ProjectDO project, List<Long> roleIds, boolean guestFlag,
                                                     ObjectRoleRespDTO fallbackRole) {
        ProjectContextRespVO respVO = new ProjectContextRespVO();
        respVO.setCurrentProject(buildCurrentProject(project));

        ObjectRolePermissionRespDTO permissionDetail = objectPermissionApi
                .getObjectRolePermissionDetailMerged(roleIds, ObjectRoleConstants.ROLE_SCOPE_OBJECT,
                        ProjectObjectConstants.OBJECT_TYPE)
                .getCheckedData();
        ObjectRoleRespDTO currentRole = permissionDetail == null || permissionDetail.getCurrentRole() == null
                ? fallbackRole
                : permissionDetail.getCurrentRole();
        List<ObjectMenuRespDTO> menus = permissionDetail == null || permissionDetail.getMenus() == null
                ? Collections.emptyList()
                : permissionDetail.getMenus();
        List<String> additionalRoleNames = permissionDetail == null || permissionDetail.getAdditionalRoleNames() == null
                ? Collections.emptyList()
                : permissionDetail.getAdditionalRoleNames();
        Long primaryRoleId = currentRole != null ? currentRole.getId() : (roleIds.isEmpty() ? null : roleIds.get(0));
        respVO.setCurrentRole(buildCurrentRole(primaryRoleId, currentRole, guestFlag, additionalRoleNames));
        respVO.setNavs(buildContextNavs(menus));
        respVO.setButtons(buildContextButtons(menus));
        return respVO;
    }

    @Override
    public PageResult<ProjectRespVO> getProjectPage(ProjectPageReqVO pageReqVO) {
        // 计算当前用户在 project 域的数据权限范围
        Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
        ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProjectObjectConstants.OBJECT_TYPE);
        if (scope.getState() == ObjectDataScope.State.EMPTY) {
            return PageResult.empty();
        }

        // 保留原有业务过滤条件（同 ProjectMapper.selectPage 默认方法）
        LambdaQueryWrapperX<ProjectDO> wrapper = new LambdaQueryWrapperX<>();
        if (StringUtils.hasText(pageReqVO.getKeyword())) {
            wrapper.and(w -> w.like(ProjectDO::getProjectCode, pageReqVO.getKeyword())
                    .or()
                    .like(ProjectDO::getProjectName, pageReqVO.getKeyword()));
        }
        wrapper.eqIfPresent(ProjectDO::getProjectType, pageReqVO.getProjectType())
                .eqIfPresent(ProjectDO::getDirectionCode, pageReqVO.getDirectionCode())
                .eqIfPresent(ProjectDO::getProductId, pageReqVO.getProductId())
                .eqIfPresent(ProjectDO::getManagerUserId, pageReqVO.getManagerUserId())
                .betweenIfPresent(BaseDO::getUpdateTime, pageReqVO.getUpdateTime())
                // 列表按更新时间倒序，与分组接口组内排序一致（2026-06-10 由创建时间倒序调整）
                .orderByDesc(BaseDO::getUpdateTime);
        // 状态筛选：多值集合优先于单值
        if (pageReqVO.getStatusCodes() != null && !pageReqVO.getStatusCodes().isEmpty()) {
            wrapper.in(ProjectDO::getStatusCode, pageReqVO.getStatusCodes());
        } else {
            wrapper.eqIfPresent(ProjectDO::getStatusCode, pageReqVO.getStatusCode());
        }
        // 游离项目筛选：仅未挂产品
        if (Boolean.TRUE.equals(pageReqVO.getOrphanOnly())) {
            wrapper.isNull(ProjectDO::getProductId);
        }
        // 注入 scope 数据权限过滤条件（在所有业务条件之后；ALL 状态不加条件）
        applyProjectScopeCondition(wrapper, scope);

        PageResult<ProjectDO> pageResult = projectMapper.selectPage(pageReqVO, wrapper);
        return new PageResult<>(convertProjectListWithNames(pageResult.getList()), pageResult.getTotal());
    }

    @Override
    public ProjectOverviewSummaryRespVO getProjectOverviewSummary() {
        // 与列表对称：按当前用户的 ObjectDataScope 过滤统计数字（超管 ALL 走全表 SQL，普通用户走 scope 过滤）
        Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
        ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProjectObjectConstants.OBJECT_TYPE);
        List<Map<String, Object>> rows = buildStatusCountRows(scope);
        List<ObjectStatusModelDO> statusModels = objectStatusModelMapper
                .selectListByObjectType(ProjectObjectConstants.OBJECT_TYPE);

        ProjectOverviewSummaryRespVO respVO = new ProjectOverviewSummaryRespVO();
        Map<String, Long> statusCounts = buildProjectStatusCounts(statusModels, rows);
        respVO.setStatusCounts(statusCounts);
        respVO.setItems(buildStatusBoardItems(statusModels, statusCounts));
        respVO.setTotal(respVO.getItems().stream()
                .mapToLong(ProjectOverviewSummaryRespVO.StatusBoardItemVO::getCount).sum());
        respVO.setOrphanCount(countOrphanProjects(scope));
        return respVO;
    }

    /**
     * 游离项目计数：未挂产品 + "全部"口径（启用状态全集，与项目分组列表对齐）。
     */
    private Long countOrphanProjects(ObjectDataScope scope) {
        if (scope.getState() == ObjectDataScope.State.EMPTY) {
            return 0L;
        }
        Set<String> statusCodes = resolveDefaultProjectStatusCodes();
        if (statusCodes.isEmpty()) {
            return 0L;
        }
        LambdaQueryWrapperX<ProjectDO> wrapper = new LambdaQueryWrapperX<>();
        wrapper.isNull(ProjectDO::getProductId)
                .in(ProjectDO::getStatusCode, statusCodes);
        applyProjectScopeCondition(wrapper, scope);
        return projectMapper.selectCount(wrapper);
    }

    /**
     * 项目"全部"口径状态集：状态机启用状态全集（2026-06-11 口径变更：作废/归档同样计入，无排除项）。
     * 权威源 rdms_object_status_model，DB 新增状态自动纳入，代码不维护正向清单。
     */
    private Set<String> resolveDefaultProjectStatusCodes() {
        return objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectObjectConstants.OBJECT_TYPE).stream()
                .map(ObjectStatusModelDO::getStatusCode)
                .filter(StringUtils::hasText)
                .collect(Collectors.toSet());
    }

    /**
     * 产品"存续"状态集：状态机启用且非终态的状态（当前为 启用/暂停），
     * 用于零项目占位组与方向数口径。权威源 rdms_object_status_model，零硬编码。
     */
    private Set<String> resolveLiveProductStatusCodes() {
        return objectStatusModelMapper.selectListByObjectTypeEnabled(ProductObjectConstants.OBJECT_TYPE).stream()
                .filter(model -> !Boolean.TRUE.equals(model.getTerminalFlag()))
                .map(ObjectStatusModelDO::getStatusCode)
                .filter(StringUtils::hasText)
                .collect(Collectors.toSet());
    }

    @Override
    public ProjectGroupPageRespVO getProjectGroupPage(ProjectGroupPageReqVO reqVO) {
        Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
        ObjectDataScope projectScope = objectDataScopeService.compute(loginUserId, ProjectObjectConstants.OBJECT_TYPE);
        ObjectDataScope productScope = objectDataScopeService.compute(loginUserId, ProductObjectConstants.OBJECT_TYPE);

        // 状态口径：显式传 statusCode 按单状态过滤，不传时"全部"集合由状态机推导；
        // typeCounts 与 projectTotal/projects 同用此状态集（2026-06-11 口径变更：随 statusCode 联动）
        Collection<String> statusCodes = StringUtils.hasText(reqVO.getStatusCode())
                ? List.of(reqVO.getStatusCode())
                : resolveDefaultProjectStatusCodes();

        // 1. 命中项目全量加载（产品几十级、项目百级，内存分组成本可忽略；项目上千后再改 SQL 聚合分页）
        List<ProjectDO> matched = selectGroupMatchedProjects(reqVO, statusCodes, projectScope);

        // 2. 按产品分组，productId 为空归入游离组
        Map<Long, List<ProjectDO>> groupedByProduct = new LinkedHashMap<>();
        for (ProjectDO project : matched) {
            groupedByProduct.computeIfAbsent(project.getProductId(), k -> new ArrayList<>()).add(project);
        }
        List<ProjectDO> orphanProjects = groupedByProduct.remove(null);
        if (orphanProjects == null) {
            orphanProjects = Collections.emptyList();
        }

        // 3. 可见产品（启用/暂停，产品域数据权限）：directionCount 口径 + 零项目占位组候选
        List<ProductDO> visibleProducts = selectVisibleGroupProducts(productScope);

        // 4. "全部"口径且无其他筛选时，补零项目产品占位组
        boolean defaultScope = !StringUtils.hasText(reqVO.getStatusCode()) && !StringUtils.hasText(reqVO.getKeyword())
                && !StringUtils.hasText(reqVO.getProjectType()) && !Boolean.TRUE.equals(reqVO.getOrphanOnly());
        if (defaultScope) {
            visibleProducts.stream()
                    .filter(p -> reqVO.getProductId() == null || p.getId().equals(reqVO.getProductId()))
                    .forEach(p -> groupedByProduct.computeIfAbsent(p.getId(), k -> new ArrayList<>()));
        }

        // 5. 组排序：方向编码升序（同方向相邻）→ 方向内产品创建时间倒序；产品记录缺失的组殿后；游离组固定最后
        Map<Long, ProductDO> productMap = loadGroupProducts(groupedByProduct.keySet(), visibleProducts);
        List<Long> orderedProductIds = sortGroupProductIds(groupedByProduct.keySet(), productMap);

        // 6. 组维度分页（游离组算最后一个组）
        boolean hasOrphanGroup = !orphanProjects.isEmpty();
        int totalGroups = orderedProductIds.size() + (hasOrphanGroup ? 1 : 0);
        int fromIndex = (reqVO.getPageNo() - 1) * reqVO.getPageSize();
        int toIndex = Math.min(fromIndex + reqVO.getPageSize(), totalGroups);

        ProjectGroupPageRespVO respVO = new ProjectGroupPageRespVO();
        respVO.setTotal((long) totalGroups);
        respVO.setProjectTotal((long) matched.size());
        respVO.setOrphanTotal((long) orphanProjects.size());
        respVO.setDirectionCount((int) visibleProducts.stream()
                .map(ProductDO::getDirectionCode)
                .filter(StringUtils::hasText)
                .distinct()
                .count());
        if (fromIndex >= totalGroups) {
            respVO.setList(Collections.emptyList());
            return respVO;
        }

        // 7. 当页组：产品组在前，游离组若落在当页区间则最后
        List<Long> pageProductIds = orderedProductIds.subList(fromIndex, Math.min(toIndex, orderedProductIds.size()));
        boolean orphanOnPage = hasOrphanGroup && toIndex == totalGroups;

        // 8. typeCounts（与 projectTotal 同口径，随 statusCode 联动；keyword/projectType/数据权限仍不影响）
        //    与 hasBaseline（恒按"全部"口径的非已取消主线，不随筛选变化）
        Map<Long, Map<String, Long>> typeCountsMap = new HashMap<>();
        Map<String, Long> orphanTypeCounts = new HashMap<>();
        if ((!pageProductIds.isEmpty() || orphanOnPage) && !statusCodes.isEmpty()) {
            LambdaQueryWrapperX<ProjectDO> typeWrapper = new LambdaQueryWrapperX<ProjectDO>()
                    .in(ProjectDO::getStatusCode, statusCodes);
            boolean withOrphan = orphanOnPage;
            typeWrapper.and(w -> {
                if (!pageProductIds.isEmpty()) {
                    w.in(ProjectDO::getProductId, pageProductIds);
                }
                if (withOrphan) {
                    if (!pageProductIds.isEmpty()) {
                        w.or();
                    }
                    w.isNull(ProjectDO::getProductId);
                }
            });
            for (ProjectDO project : projectMapper.selectList(typeWrapper)) {
                if (project.getProjectType() == null) {
                    continue;
                }
                Map<String, Long> counts = project.getProductId() == null
                        ? orphanTypeCounts
                        : typeCountsMap.computeIfAbsent(project.getProductId(), k -> new HashMap<>());
                counts.merge(project.getProjectType(), 1L, Long::sum);
            }
        }
        Set<Long> baselineProductIds = pageProductIds.isEmpty() ? Collections.emptySet()
                : projectMapper.selectList(new LambdaQueryWrapperX<ProjectDO>()
                                .in(ProjectDO::getProductId, pageProductIds)
                                .in(ProjectDO::getProjectType, ProjectObjectConstants.MAINLINE_PROJECT_TYPE_CODES)
                                .ne(ProjectDO::getStatusCode, ProjectObjectConstants.STATUS_CANCELLED))
                        .stream().map(ProjectDO::getProductId).collect(Collectors.toSet());

        // 9. 当页组内 topN 项目（更新时间倒序），统一批量转换回填
        int topN = reqVO.getTopN() == null ? 5 : reqVO.getTopN();
        Map<Long, List<ProjectDO>> pageGroupProjects = new LinkedHashMap<>();
        for (Long productId : pageProductIds) {
            pageGroupProjects.put(productId, topNByUpdateTimeDesc(groupedByProduct.get(productId), topN));
        }
        List<ProjectDO> orphanTopN = orphanOnPage ? topNByUpdateTimeDesc(orphanProjects, topN) : Collections.emptyList();
        List<ProjectDO> allPageProjects = new ArrayList<>();
        pageGroupProjects.values().forEach(allPageProjects::addAll);
        allPageProjects.addAll(orphanTopN);
        Map<Long, ProjectRespVO> convertedById = new HashMap<>();
        convertProjectListWithNames(allPageProjects).forEach(vo -> convertedById.put(vo.getId(), vo));

        // 10. 组装组 VO 并批量回填产品负责人昵称
        List<ProjectGroupRespVO> groupList = new ArrayList<>();
        for (Long productId : pageProductIds) {
            groupList.add(buildGroupRespVO(productMap.get(productId), productId,
                    groupedByProduct.get(productId), pageGroupProjects.get(productId),
                    typeCountsMap.getOrDefault(productId, Collections.emptyMap()),
                    baselineProductIds.contains(productId), convertedById));
        }
        if (orphanOnPage) {
            groupList.add(buildOrphanGroupRespVO(orphanProjects, orphanTopN, orphanTypeCounts, convertedById));
        }
        fillGroupManagerNicknames(groupList);
        respVO.setList(groupList);
        return respVO;
    }

    private List<ProjectDO> selectGroupMatchedProjects(ProjectGroupPageReqVO reqVO, Collection<String> statusCodes,
                                                       ObjectDataScope projectScope) {
        // 状态集为空（状态机未配置）时不查库，空 IN 会生成非法 SQL
        if (projectScope.getState() == ObjectDataScope.State.EMPTY || statusCodes.isEmpty()) {
            return Collections.emptyList();
        }
        LambdaQueryWrapperX<ProjectDO> wrapper = new LambdaQueryWrapperX<>();
        if (StringUtils.hasText(reqVO.getKeyword())) {
            wrapper.and(w -> w.like(ProjectDO::getProjectCode, reqVO.getKeyword())
                    .or()
                    .like(ProjectDO::getProjectName, reqVO.getKeyword()));
        }
        wrapper.eqIfPresent(ProjectDO::getProjectType, reqVO.getProjectType())
                .eqIfPresent(ProjectDO::getProductId, reqVO.getProductId())
                .in(ProjectDO::getStatusCode, statusCodes);
        if (Boolean.TRUE.equals(reqVO.getOrphanOnly())) {
            wrapper.isNull(ProjectDO::getProductId);
        }
        applyProjectScopeCondition(wrapper, projectScope);
        return projectMapper.selectList(wrapper);
    }

    /**
     * 当前用户可见的启用/暂停产品：零项目占位组候选 + directionCount 口径（产品域数据权限）。
     */
    private List<ProductDO> selectVisibleGroupProducts(ObjectDataScope productScope) {
        if (productScope.getState() == ObjectDataScope.State.EMPTY) {
            return Collections.emptyList();
        }
        Set<String> liveStatusCodes = resolveLiveProductStatusCodes();
        if (liveStatusCodes.isEmpty()) {
            return Collections.emptyList();
        }
        LambdaQueryWrapperX<ProductDO> wrapper = new LambdaQueryWrapperX<ProductDO>()
                .in(ProductDO::getStatusCode, liveStatusCodes)
                .orderByDesc(BaseDO::getCreateTime);
        if (productScope.getState() == ObjectDataScope.State.ID_LIST) {
            Set<Long> ids = productScope.getIds();
            Set<String> dcs = productScope.getDirectionCodes();
            wrapper.and(w -> {
                boolean addedAny = false;
                if (!ids.isEmpty()) {
                    w.in(ProductDO::getId, ids);
                    addedAny = true;
                }
                if (!dcs.isEmpty()) {
                    if (addedAny) {
                        w.or();
                    }
                    w.in(ProductDO::getDirectionCode, dcs);
                }
            });
        }
        return productMapper.selectList(wrapper);
    }

    /**
     * 组的产品信息：优先用可见产品列表，缺的（如归档产品但仍有命中项目）批量补查。
     */
    private Map<Long, ProductDO> loadGroupProducts(Set<Long> groupProductIds, List<ProductDO> visibleProducts) {
        Map<Long, ProductDO> productMap = new HashMap<>();
        visibleProducts.forEach(p -> productMap.put(p.getId(), p));
        Set<Long> missingIds = groupProductIds.stream()
                .filter(id -> !productMap.containsKey(id))
                .collect(Collectors.toSet());
        if (!missingIds.isEmpty()) {
            productMapper.selectBatchIds(missingIds).forEach(p -> productMap.put(p.getId(), p));
        }
        return productMap;
    }

    private List<Long> sortGroupProductIds(Set<Long> groupProductIds, Map<Long, ProductDO> productMap) {
        Comparator<Long> comparator = Comparator
                // 方向编码升序（同方向的组相邻），产品记录缺失/方向为空的组殿后
                .comparing((Long id) -> {
                    ProductDO product = productMap.get(id);
                    return product == null || !StringUtils.hasText(product.getDirectionCode())
                            ? null : product.getDirectionCode();
                }, Comparator.nullsLast(Comparator.<String>naturalOrder()))
                // 方向内按产品创建时间倒序（与产品列表默认排序一致）
                .thenComparing((Long id) -> {
                    ProductDO product = productMap.get(id);
                    return product == null ? null : product.getCreateTime();
                }, Comparator.nullsLast(Comparator.<LocalDateTime>reverseOrder()));
        return groupProductIds.stream().sorted(comparator).collect(Collectors.toList());
    }

    private List<ProjectDO> topNByUpdateTimeDesc(List<ProjectDO> projects, int topN) {
        return projects.stream()
                .sorted(Comparator.comparing(ProjectDO::getUpdateTime, Comparator.nullsLast(Comparator.reverseOrder())))
                .limit(topN)
                .collect(Collectors.toList());
    }

    private ProjectGroupRespVO buildGroupRespVO(ProductDO product, Long productId, List<ProjectDO> groupProjects,
                                                List<ProjectDO> topProjects, Map<String, Long> typeCounts,
                                                boolean hasBaseline, Map<Long, ProjectRespVO> convertedById) {
        ProjectGroupRespVO group = new ProjectGroupRespVO();
        group.setProductId(productId);
        if (product != null) {
            group.setProductName(product.getName());
            group.setProductCode(product.getCode());
            group.setDirectionCode(product.getDirectionCode());
            group.setManagerUserId(product.getManagerUserId());
        }
        group.setProjectTotal((long) groupProjects.size());
        group.setProjects(topProjects.stream().map(p -> convertedById.get(p.getId())).collect(Collectors.toList()));
        group.setTypeCounts(typeCounts);
        group.setHasBaseline(hasBaseline);
        group.setOrphan(false);
        return group;
    }

    private ProjectGroupRespVO buildOrphanGroupRespVO(List<ProjectDO> orphanProjects, List<ProjectDO> topProjects,
                                                      Map<String, Long> typeCounts,
                                                      Map<Long, ProjectRespVO> convertedById) {
        ProjectGroupRespVO group = new ProjectGroupRespVO();
        group.setProductName("游离项目");
        group.setDirectionCode("");
        group.setProjectTotal((long) orphanProjects.size());
        group.setProjects(topProjects.stream().map(p -> convertedById.get(p.getId())).collect(Collectors.toList()));
        group.setTypeCounts(typeCounts);
        group.setHasBaseline(false);
        group.setOrphan(true);
        return group;
    }

    /**
     * 批量回填组的产品负责人昵称（一次用户 API 调用）。
     */
    private void fillGroupManagerNicknames(List<ProjectGroupRespVO> groupList) {
        Set<Long> managerUserIds = groupList.stream()
                .map(ProjectGroupRespVO::getManagerUserId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        if (managerUserIds.isEmpty()) {
            return;
        }
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(managerUserIds);
        groupList.forEach(group -> {
            AdminUserRespDTO manager = group.getManagerUserId() == null ? null : userMap.get(group.getManagerUserId());
            group.setManagerUserNickname(manager == null ? null : manager.getNickname());
        });
    }

    /**
     * 按 scope 算出 (statusCode, countValue) 行集，喂给 {@link #buildProjectStatusCounts}。
     * EMPTY 直接空集；ALL 走原全表 GROUP BY SQL；ID_LIST 用 wrapper 取 status_code，Java 端 group + count。
     */
    private List<Map<String, Object>> buildStatusCountRows(ObjectDataScope scope) {
        if (scope.getState() == ObjectDataScope.State.EMPTY) {
            return Collections.emptyList();
        }
        if (scope.getState() == ObjectDataScope.State.ALL) {
            return projectMapper.selectStatusCountList();
        }
        // ID_LIST
        LambdaQueryWrapperX<ProjectDO> wrapper = new LambdaQueryWrapperX<>();
        wrapper.select(ProjectDO::getStatusCode);
        applyProjectScopeCondition(wrapper, scope);
        return projectMapper.selectList(wrapper).stream()
                .filter(p -> p.getStatusCode() != null)
                .collect(Collectors.groupingBy(ProjectDO::getStatusCode, Collectors.counting()))
                .entrySet().stream()
                .map(e -> {
                    Map<String, Object> row = new HashMap<>();
                    row.put("statusCode", e.getKey());
                    row.put("countValue", e.getValue());
                    return row;
                })
                .collect(Collectors.toList());
    }

    /**
     * ID_LIST 数据权限注入：项目 id 集合 OR 方向集合（与历史 getProjectPage 行为一致）。
     */
    private void applyProjectScopeCondition(LambdaQueryWrapperX<ProjectDO> wrapper, ObjectDataScope scope) {
        if (scope.getState() != ObjectDataScope.State.ID_LIST) {
            return;
        }
        Set<Long> ids = scope.getIds();
        Set<String> dcs = scope.getDirectionCodes();
        wrapper.and(w -> {
            boolean addedAny = false;
            if (!ids.isEmpty()) {
                w.in(ProjectDO::getId, ids);
                addedAny = true;
            }
            if (!dcs.isEmpty()) {
                if (addedAny) {
                    w.or();
                }
                w.in(ProjectDO::getDirectionCode, dcs);
            }
        });
    }

    /**
     * DO 批量转 RespVO 并回填产品名 / 负责人昵称（一次批量查产品、一次批量查用户，禁止逐条）。
     * page 接口与分组接口共用，保证两接口项目行字段一致。
     */
    private List<ProjectRespVO> convertProjectListWithNames(List<ProjectDO> projects) {
        if (projects == null || projects.isEmpty()) {
            return new ArrayList<>();
        }
        Set<Long> productIds = projects.stream()
                .map(ProjectDO::getProductId).filter(Objects::nonNull).collect(Collectors.toSet());
        Map<Long, ProductDO> productMap = new HashMap<>();
        if (!productIds.isEmpty()) {
            productMapper.selectBatchIds(productIds).forEach(p -> productMap.put(p.getId(), p));
        }
        Set<Long> userIds = projects.stream()
                .map(ProjectDO::getManagerUserId).filter(Objects::nonNull).collect(Collectors.toSet());
        Map<Long, AdminUserRespDTO> userMap = userIds.isEmpty()
                ? Collections.emptyMap()
                : adminUserApi.getUserMap(userIds);
        // 当前登录用户在这批项目上的角色（当前页两次查询，无 N+1；无角色项目返回空数组）
        Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
        List<Long> objectIds = projects.stream()
                .map(ProjectDO::getId).filter(Objects::nonNull).collect(Collectors.toList());
        Map<Long, List<CurrentUserRoleVO>> rolesByProject = currentUserRoleResolver.resolveByObjectIds(
                ProjectObjectConstants.OBJECT_TYPE, loginUserId, objectIds, ProjectObjectConstants.MANAGER_ROLE_CODE);
        return projects.stream().map(project -> {
            ProjectRespVO respVO = BeanUtils.toBean(project, ProjectRespVO.class);
            ProductDO product = project.getProductId() == null ? null : productMap.get(project.getProductId());
            respVO.setProductName(product == null ? null : product.getName());
            AdminUserRespDTO manager = project.getManagerUserId() == null ? null : userMap.get(project.getManagerUserId());
            respVO.setManagerUserNickname(manager == null ? null : manager.getNickname());
            respVO.setCurrentUserRoles(rolesByProject.getOrDefault(project.getId(), Collections.emptyList()));
            return respVO;
        }).collect(Collectors.toList());
    }

    private String getProductName(Long productId) {
        if (productId == null) {
            return null;
        }
        ProductDO product = productMapper.selectById(productId);
        return product == null ? null : product.getName();
    }

    /**
     * 项目创建后自动初始化“全部需求”根模块，作为该项目需求树的唯一根节点。
     */
    private void initDefaultRequirementModule(ProjectDO project) {
        ProjectRequirementModuleDO module = new ProjectRequirementModuleDO();
        module.setParentId(0L);
        module.setProjectId(project.getId());
        module.setModuleName("全部需求");
        module.setRemark("自动创建的模块");
        module.setSort(0);
        projectRequirementModuleMapper.insert(module);
    }

    private String getManagerNickname(Long managerUserId) {
        if (managerUserId == null) {
            return null;
        }
        CommonResult<AdminUserRespDTO> result = adminUserApi.getUser(managerUserId);
        AdminUserRespDTO user = result == null ? null : result.getCheckedData();
        return user == null ? null : user.getNickname();
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    @com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE,
            objectId = "#reqVO.id", permission = ProjectObjectConstants.PERMISSION_STATUS)
    public void changeProjectStatus(ProjectStatusActionReqVO reqVO) {
        ProjectDO project = validateProjectExists(reqVO.getId());
        String actionCode = reqVO.getActionCode().trim();
        if (ObjectActivityConstants.PROJECT_ACTION_AUTO_START.equals(actionCode)) {
            throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_NOT_ALLOWED,
                    statusActionTextResolver.statusName(ProjectObjectConstants.OBJECT_TYPE, project.getStatusCode()),
                    statusActionTextResolver.actionName(ProjectObjectConstants.OBJECT_TYPE, actionCode));
        }
        if (ObjectActivityConstants.PROJECT_ACTION_COMPLETE.equals(actionCode)) {
            validateProjectCompletable(project.getId());
        }
        changeStatus(project, actionCode, normalizeNullableText(reqVO.getReason()));
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    @com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE,
            objectId = "#reqVO.id", permission = ProjectObjectConstants.PERMISSION_DELETE)
    public void deleteProject(ProjectDeleteReqVO reqVO) {
        ProjectDO project = validateProjectExists(reqVO.getId());
        validateDeleteConfirmText(reqVO.getConfirmText());
        if (!Objects.equals(project.getProjectName(), reqVO.getProjectName().trim())) {
            throw exception(ErrorCodeConstants.PROJECT_DELETE_NAME_MISMATCH);
        }
        if (!isInitialStatus(project.getStatusCode())) {
            throw exception(ErrorCodeConstants.PROJECT_NOT_ALLOW_DELETE);
        }

        String reason = reqVO.getReason().trim();
        String fromStatus = project.getStatusCode();
        int deleteCount = projectMapper.deleteByIdAndStatus(reqVO.getId(), fromStatus);
        if (deleteCount != 1) {
            throw exception(ErrorCodeConstants.PROJECT_STATUS_CONCURRENT_MODIFIED);
        }
        writeProjectStatusLog(project, ObjectActivityConstants.PROJECT_ACTION_DELETE, fromStatus, null, reason);
        writeBizAuditLog(project, ObjectActivityConstants.PROJECT_ACTION_DELETE, fromStatus, null, null, reason);
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void autoStartProjectIfPending(Long projectId, String triggerAction) {
        // auto_start 只允许由后端业务动作内部触发，前端不应直接透传该动作。
        ProjectDO project = validateProjectExists(projectId);
        if (!ProjectObjectConstants.AUTO_START_TRIGGERS.contains(triggerAction)) {
            throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_NOT_ALLOWED,
                    statusActionTextResolver.statusName(ProjectObjectConstants.OBJECT_TYPE, project.getStatusCode()),
                    statusActionTextResolver.actionName(ProjectObjectConstants.OBJECT_TYPE, triggerAction));
        }
        ObjectStatusTransitionDO transition = objectStatusTransitionMapper
                .selectByObjectTypeAndFromStatusAndAction(ProjectObjectConstants.OBJECT_TYPE, project.getStatusCode(),
                        ObjectActivityConstants.PROJECT_ACTION_AUTO_START);
        if (transition == null) {
            // 未配置 auto_start 时，只要项目已离开初始态且仍可编辑，就允许真实业务动作继续推进，不强制补自动启动流转。
            ObjectStatusModelDO statusModel = validateEnabledStatusModel(project.getStatusCode());
            if (Boolean.TRUE.equals(statusModel.getInitialFlag())) {
                throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_NOT_ALLOWED,
                        statusActionTextResolver.statusName(ProjectObjectConstants.OBJECT_TYPE, project.getStatusCode()),
                        statusActionTextResolver.actionName(ProjectObjectConstants.OBJECT_TYPE,
                                ObjectActivityConstants.PROJECT_ACTION_AUTO_START));
            }
            if (!Boolean.TRUE.equals(statusModel.getAllowEdit())) {
                throw exception(ErrorCodeConstants.PROJECT_STATUS_NOT_ALLOW_EDIT);
            }
            return;
        }
        if (!isInitialStatus(project.getStatusCode())) {
            throw exception(ErrorCodeConstants.PROJECT_STATUS_NOT_ALLOW_EDIT);
        }
        String reason = ObjectActivityConstants.resolveActionName(triggerAction);
        changeStatus(project, transition, ObjectActivityConstants.PROJECT_ACTION_AUTO_START, reason);
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void syncActualStartDateFromWorklogs(Long projectId) {
        if (projectId == null) {
            return;
        }
        ProjectDO project = projectMapper.selectById(projectId);
        if (project == null) {
            return;
        }
        syncActualStartDate(project);
    }

    @Override
    public void recalcProgress(Long projectId) {
        if (projectId == null) {
            return;
        }
        ProjectDO project = projectMapper.selectById(projectId);
        if (project == null) {
            // 项目已被删除（删除项目时其下任务已不可达，无须再触发；此处兜底，避免上游误调）
            return;
        }
        List<String> excludedStatusCodes = objectStatusModelMapper
                .selectProgressExcludedStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
        BigDecimal newProgress = normalizeProgress(projectTaskMapper
                .selectRootTaskAvgProgressByProjectId(projectId,
                        excludedStatusCodes == null ? Collections.emptyList() : excludedStatusCodes));
        // 与当前缓存值数值相等则跳过 UPDATE，避免不必要的写与审计字段抖动
        BigDecimal current = project.getProgressRate();
        if (current != null && current.compareTo(newProgress) == 0) {
            return;
        }
        projectMapper.updateProgressRateById(projectId, newProgress);
    }

    /**
     * 进度归一化：null → 0.00，非 null → scale=2 HALF_UP。与执行/任务层口径一致。
     */
    private BigDecimal normalizeProgress(BigDecimal progress) {
        if (progress == null) {
            return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
        }
        return progress.setScale(2, RoundingMode.HALF_UP);
    }

    /**
     * 收集项目「无法完成」的全部缺口（TD-015）。一次性列全，避免用户修完一个才发现还有另一个。
     * 三个维度统一「数非终态子对象」：任务（方案 B 进度维度）/ 执行 / 需求；终态码全查 DB（terminal_flag），不硬编码。
     * 返回空串 = 满足完成条件。
     */
    private String collectCompletionGaps(Long projectId) {
        List<String> parts = new ArrayList<>();

        List<String> taskTerminal = objectStatusModelMapper
                .selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
        Integer openTasks = projectTaskMapper.countNonTerminalByProjectId(projectId, taskTerminal);
        if (openTasks != null && openTasks > 0) {
            parts.add(openTasks + " 个任务");
        }

        List<String> execTerminal = objectStatusModelMapper
                .selectTerminalStatusCodesByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE);
        Integer openExecs = projectExecutionMapper.countNonTerminalByProjectId(projectId, execTerminal);
        if (openExecs != null && openExecs > 0) {
            parts.add(openExecs + " 个执行");
        }

        List<String> reqTerminal = objectStatusModelMapper
                .selectTerminalStatusCodesByObjectTypeEnabled(ProjectRequirementConstants.OBJECT_TYPE);
        Integer openReqs = projectRequirementMapper.countNonTerminalByProjectId(projectId, reqTerminal);
        if (openReqs != null && openReqs > 0) {
            parts.add(openReqs + " 个需求");
        }

        if (parts.isEmpty()) {
            return "";
        }
        return "仍有 " + String.join("、", parts) + "未完成。";
    }

    /**
     * 项目 complete 前置硬校验（TD-015）。缺口非空则一次性抛全部；message 只放中文+数字（TD-012 脱敏）。
     */
    private void validateProjectCompletable(Long projectId) {
        String gaps = collectCompletionGaps(projectId);
        if (!gaps.isEmpty()) {
            throw exception(ErrorCodeConstants.PROJECT_COMPLETE_PRECONDITION_NOT_MET, gaps);
        }
    }

    @VisibleForTesting
    void validateCreateReqVO(ProjectSaveReqVO createReqVO) {
        validateProjectCodeUnique(null, createReqVO.getProjectCode());
        validateProjectNameUnique(null, createReqVO.getProductId(), createReqVO.getProjectName());
        validateDateRange(createReqVO.getPlannedStartDate(), createReqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期");
        validateDateRange(createReqVO.getActualStartDate(), createReqVO.getActualEndDate(), "实际结束日期不能早于实际开始日期");
    }

    @VisibleForTesting
    ProjectDO validateProjectExists(Long id) {
        if (id == null) {
            throw exception(ErrorCodeConstants.PROJECT_NOT_EXISTS);
        }
        ProjectDO project = projectMapper.selectById(id);
        if (project == null) {
            throw exception(ErrorCodeConstants.PROJECT_NOT_EXISTS);
        }
        return project;
    }

    @VisibleForTesting
    void validateProjectCodeUnique(Long id, String projectCode) {
        if (!StringUtils.hasText(projectCode)) {
            return;
        }
        String normalizedCode = projectCode.trim();
        ProjectDO project = projectMapper.selectByCode(normalizedCode);
        if (project == null) {
            return;
        }
        if (id == null || !project.getId().equals(id)) {
            throw exception(ErrorCodeConstants.PROJECT_CODE_DUPLICATE, normalizedCode);
        }
    }

    @VisibleForTesting
    void validateProjectNameUnique(Long id, Long productId, String projectName) {
        String normalizedName = projectName.trim();
        ProjectDO project = projectMapper.selectActiveByProductIdAndName(productId, normalizedName,
                ProjectObjectConstants.STATUS_CANCELLED);
        if (project == null) {
            return;
        }
        if (id == null || !project.getId().equals(id)) {
            throw exception(ErrorCodeConstants.PROJECT_NAME_DUPLICATE, normalizedName);
        }
    }

    @VisibleForTesting
    void validateMainlineProjectUnique(Long id, Long productId, String projectType) {
        if (productId == null || !isMainlineProjectType(projectType)) {
            return;
        }
        List<ProjectDO> projects = projectMapper.selectActiveMainlineListByProductId(productId,
                ProjectObjectConstants.MAINLINE_PROJECT_TYPE_CODES, ProjectObjectConstants.STATUS_CANCELLED);
        if (projects == null || projects.isEmpty()
                || projects.stream().allMatch(project -> Objects.equals(project.getId(), id))) {
            return;
        }
        throw exception(ErrorCodeConstants.PROJECT_MAINLINE_DUPLICATE);
    }

    private boolean isMainlineProjectType(String projectType) {
        return StringUtils.hasText(projectType)
                && ProjectObjectConstants.MAINLINE_PROJECT_TYPE_CODES.contains(projectType.trim());
    }

    @VisibleForTesting
    void validateProjectType(String projectType) {
        try {
            Boolean valid = dictDataApi.validateDictDataList(ProjectDictTypeConstants.PROJECT_TYPE,
                    List.of(projectType.trim())).getCheckedData();
            if (Boolean.TRUE.equals(valid)) {
                return;
            }
        } catch (RuntimeException ex) {
            throw exception(ErrorCodeConstants.PROJECT_TYPE_INVALID);
        }
        throw exception(ErrorCodeConstants.PROJECT_TYPE_INVALID);
    }

    @VisibleForTesting
    void validateProjectDirection(String directionCode) {
        try {
            Boolean valid = dictDataApi.validateDictDataList(DictTypeConstants.OBJECT_DIRECTION,
                    List.of(directionCode)).getCheckedData();
            if (Boolean.TRUE.equals(valid)) {
                return;
            }
        } catch (RuntimeException ex) {
            throw exception(ErrorCodeConstants.PROJECT_DIRECTION_INVALID);
        }
        throw exception(ErrorCodeConstants.PROJECT_DIRECTION_INVALID);
    }

    @VisibleForTesting
    ObjectStatusTransitionDO validateProjectTransition(String fromStatusCode, String actionCode, String reason) {
        ObjectStatusTransitionDO transition = objectStatusTransitionMapper
                .selectByObjectTypeAndFromStatusAndAction(ProjectObjectConstants.OBJECT_TYPE, fromStatusCode, actionCode);
        if (transition == null) {
            throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_NOT_ALLOWED,
                    statusActionTextResolver.statusName(ProjectObjectConstants.OBJECT_TYPE, fromStatusCode),
                    statusActionTextResolver.actionName(ProjectObjectConstants.OBJECT_TYPE, actionCode));
        }
        if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(reason)) {
            throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_REASON_REQUIRED, transition.getActionName());
        }
        return transition;
    }

    private ProductDO validateProductUsable(Long productId) {
        if (productId == null) {
            return null;
        }
        ProductDO product = productMapper.selectById(productId);
        if (product == null) {
            throw exception(ErrorCodeConstants.PROJECT_PRODUCT_NOT_EXISTS);
        }
        return product;
    }

    private String resolveProjectDirectionCode(Long productId, String directionCode) {
        // 关联产品的项目方向始终继承产品，避免项目方向与所属产品方向出现漂移。
        if (productId == null) {
            if (!StringUtils.hasText(directionCode)) {
                throw invalidParamException("项目方向不能为空");
            }
            String normalizedDirectionCode = directionCode.trim();
            if (normalizedDirectionCode.length() > 32) {
                throw invalidParamException("项目方向长度不能超过32个字符");
            }
            validateProjectDirection(normalizedDirectionCode);
            return normalizedDirectionCode;
        }

        ProductDO product = validateProductUsable(productId);
        String productDirectionCode = normalizeNullableText(product == null ? null : product.getDirectionCode());
        if (!StringUtils.hasText(productDirectionCode)) {
            throw exception(ErrorCodeConstants.PROJECT_DIRECTION_INVALID);
        }
        return productDirectionCode;
    }

    private void validateManagerUser(Long managerUserId) {
        try {
            Boolean valid = adminUserApi.validateUserList(List.of(managerUserId)).getCheckedData();
            if (Boolean.TRUE.equals(valid)) {
                return;
            }
        } catch (RuntimeException ex) {
            throw exception(ErrorCodeConstants.PROJECT_MANAGER_USER_INVALID);
        }
        throw exception(ErrorCodeConstants.PROJECT_MANAGER_USER_INVALID);
    }

    private void validateProjectCodeUnchanged(ProjectDO project, String projectCode) {
        if (!StringUtils.hasText(projectCode)) {
            return;
        }
        if (!Objects.equals(project.getProjectCode(), projectCode.trim())) {
            throw exception(ErrorCodeConstants.PROJECT_CODE_NOT_MODIFIABLE);
        }
    }

    private void validateProductUnchanged(ProjectDO project, Long productId) {
        if (!Objects.equals(project.getProductId(), productId)) {
            throw exception(ErrorCodeConstants.PROJECT_PRODUCT_NOT_MODIFIABLE);
        }
    }

    private void validateProjectEditable(ProjectDO project) {
        ObjectStatusModelDO statusModel = validateEnabledStatusModel(project.getStatusCode());
        if (!Boolean.TRUE.equals(statusModel.getAllowEdit())) {
            throw exception(ErrorCodeConstants.PROJECT_STATUS_NOT_ALLOW_EDIT);
        }
    }

    private ObjectStatusModelDO validateEnabledStatusModel(String statusCode) {
        ObjectStatusModelDO statusModel = objectStatusModelMapper
                .selectByObjectTypeAndStatusCodeEnabled(ProjectObjectConstants.OBJECT_TYPE, statusCode);
        if (statusModel == null) {
            throw exception(ErrorCodeConstants.PROJECT_STATUS_MODEL_NOT_EXISTS_OR_DISABLED);
        }
        return statusModel;
    }

    private String getInitialStatusCode() {
        ObjectStatusModelDO statusModel = objectStatusModelMapper
                .selectInitialByObjectTypeEnabled(ProjectObjectConstants.OBJECT_TYPE);
        if (statusModel == null || !StringUtils.hasText(statusModel.getStatusCode())) {
            throw exception(ErrorCodeConstants.PROJECT_STATUS_MODEL_NOT_EXISTS_OR_DISABLED);
        }
        return statusModel.getStatusCode();
    }

    private boolean isInitialStatus(String statusCode) {
        return Boolean.TRUE.equals(validateEnabledStatusModel(statusCode).getInitialFlag());
    }

    private void validateDateRange(LocalDate startDate, LocalDate endDate, String message) {
        if (startDate != null && endDate != null && endDate.isBefore(startDate)) {
            throw invalidParamException(message);
        }
    }

    private String generateProjectCode(String projectCode) {
        String normalizedCode = normalizeNullableText(projectCode);
        if (StringUtils.hasText(normalizedCode)) {
            validateProjectCodeUnique(null, normalizedCode);
            return normalizedCode;
        }

        String year = String.valueOf(LocalDate.now().getYear());
        String codePrefix = ProjectObjectConstants.CODE_PREFIX + year;
        int nextSequence = 1;
        for (ProjectDO project : projectMapper.selectListByCodePrefix(codePrefix)) {
            String existedCode = project.getProjectCode();
            if (!StringUtils.hasText(existedCode) || !existedCode.matches(codePrefix + "\\d{3}")) {
                continue;
            }
            nextSequence = Integer.parseInt(existedCode.substring(codePrefix.length())) + 1;
            break;
        }
        if (nextSequence > 999) {
            throw invalidParamException("{} 年项目自动编码序号已用尽", year);
        }
        String generatedCode = codePrefix + String.format("%03d", nextSequence);
        validateProjectCodeUnique(null, generatedCode);
        return generatedCode;
    }

    private void initManagerMemberRelation(ProjectDO project) {
        // 创建项目后要同步补齐负责人成员关系和审计日志，保证负责人与成员数据链路一致。
        ObjectRoleRespDTO managerRole = getManagerRole();
        UserObjectRoleDO member = new UserObjectRoleDO();
        member.setUserId(project.getManagerUserId());
        member.setObjectType(ProjectObjectConstants.OBJECT_TYPE);
        member.setObjectId(project.getId());
        member.setRoleId(managerRole.getId());
        member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
        member.setJoinedTime(LocalDateTime.now());
        member.setLeftTime(null);
        userObjectRoleMapper.insert(member);

        writeMemberAuditLog(member, ObjectActivityConstants.MEMBER_ACTION_ADD, null, member, null);
        writeManagerChangeAuditLog(project.getId(), null, project.getManagerUserId(), null);
    }

    private ObjectRoleRespDTO getManagerRole() {
        ObjectRoleRespDTO managerRole = objectPermissionApi
                .getObjectRoleByCode(ProjectObjectConstants.MANAGER_ROLE_CODE, ObjectRoleConstants.ROLE_SCOPE_OBJECT,
                        ProjectObjectConstants.OBJECT_TYPE)
                .getCheckedData();
        if (managerRole == null) {
            throw invalidParamException("未找到项目经理对象角色配置：{}", ProjectObjectConstants.MANAGER_ROLE_CODE);
        }
        return managerRole;
    }

    private void changeManager(ProjectDO project, Long newManagerUserId, String reason) {
        // 负责人切换不能只改项目主表，还要同步调整成员关系并记录负责人变更审计。
        Long oldManagerUserId = project.getManagerUserId();
        if (Objects.equals(oldManagerUserId, newManagerUserId)) {
            return;
        }
        ObjectRoleRespDTO managerRole = getManagerRole();
        inactiveOldManagerRelation(project.getId(), oldManagerUserId, reason);
        ensureManagerRelation(project.getId(), newManagerUserId, managerRole.getId(), reason);
        writeManagerChangeAuditLog(project.getId(), oldManagerUserId, newManagerUserId, reason);
    }

    private void inactiveOldManagerRelation(Long projectId, Long oldManagerUserId, String reason) {
        if (oldManagerUserId == null) {
            return;
        }
        // 多角色支持：只 INACTIVATE manager 角色那一行；user 在项目内的 creator/dev 等其他角色行不动
        Long managerRoleId = resolveProjectManagerRoleId();
        UserObjectRoleDO oldMember = userObjectRoleMapper.selectActiveByObjectUserAndRole(
                ProjectObjectConstants.OBJECT_TYPE, projectId, oldManagerUserId, managerRoleId);
        if (oldMember == null) {
            return;
        }
        UserObjectRoleDO before = cloneMember(oldMember);
        oldMember.setStatus(ObjectRoleConstants.MEMBER_STATUS_INACTIVE);
        oldMember.setLeftTime(LocalDateTime.now());
        userObjectRoleMapper.updateById(oldMember);
        writeMemberAuditLog(oldMember, ObjectActivityConstants.MEMBER_ACTION_REMOVE, before, oldMember, reason);
    }

    private void ensureManagerRelation(Long projectId, Long managerUserId, Long managerRoleId, String reason) {
        // 多角色支持：按 (user, object, manager_role_id) 三元组定位 manager 行；
        // 用 selectByObjectUserAndRole（不带 status 过滤）拿 INACTIVE 老行复活，避免 INSERT 冲突唯一索引
        UserObjectRoleDO existingMember = userObjectRoleMapper.selectByObjectUserAndRole(
                ProjectObjectConstants.OBJECT_TYPE, projectId, managerUserId, managerRoleId);
        LocalDateTime now = LocalDateTime.now();
        if (existingMember == null) {
            // user 在项目内还没有 manager 角色行（可能已有 creator/dev 等其他角色，不影响）→ insert
            UserObjectRoleDO member = new UserObjectRoleDO();
            member.setUserId(managerUserId);
            member.setObjectType(ProjectObjectConstants.OBJECT_TYPE);
            member.setObjectId(projectId);
            member.setRoleId(managerRoleId);
            member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
            member.setJoinedTime(now);
            member.setLeftTime(null);
            userObjectRoleMapper.insert(member);
            writeMemberAuditLog(member, ObjectActivityConstants.MEMBER_ACTION_ADD, null, member, reason);
            return;
        }
        // existingMember 已是 (user, object, manager_role_id) 行（可能 ACTIVE 或 INACTIVE）→ 激活
        UserObjectRoleDO before = cloneMember(existingMember);
        existingMember.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
        existingMember.setLeftTime(null);
        if (!Objects.equals(before.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
            existingMember.setJoinedTime(now);
        }
        userObjectRoleMapper.updateById(existingMember);
        writeMemberAuditLog(existingMember, ObjectActivityConstants.MEMBER_ACTION_UPDATE, before, existingMember, reason);
    }

    private Long resolveProjectManagerRoleId() {
        ObjectRoleRespDTO role = objectPermissionApi
                .getObjectRoleByCode(ProjectObjectConstants.MANAGER_ROLE_CODE,
                        ObjectRoleConstants.ROLE_SCOPE_OBJECT,
                        ProjectObjectConstants.OBJECT_TYPE)
                .getCheckedData();
        if (role == null || role.getId() == null) {
            throw new IllegalStateException("内置角色 " + ProjectObjectConstants.MANAGER_ROLE_CODE + " 未在 system_role 找到");
        }
        return role.getId();
    }

    private void changeStatus(ProjectDO project, String actionCode, String reason) {
        String fromStatus = project.getStatusCode();
        ObjectStatusTransitionDO transition = validateProjectTransition(fromStatus, actionCode, reason);
        changeStatus(project, transition, actionCode, reason);
    }

    private void changeStatus(ProjectDO project, ObjectStatusTransitionDO transition, String actionCode, String reason) {
        String fromStatus = project.getStatusCode();
        String toStatus = transition.getToStatusCode();
        // 状态更新必须携带 fromStatus 条件，避免并发状态变更时发生后写覆盖先写。
        int updateCount = projectMapper.updateStatusByIdAndStatus(project.getId(), fromStatus, toStatus, reason);
        if (updateCount != 1) {
            throw exception(ErrorCodeConstants.PROJECT_STATUS_CONCURRENT_MODIFIED);
        }
        project.setStatusCode(toStatus);
        project.setLastStatusReason(reason);

        writeProjectStatusLog(project, actionCode, fromStatus, toStatus, reason);
        writeBizAuditLog(project, actionCode, fromStatus, toStatus, null, reason);
        if (ObjectActivityConstants.PROJECT_ACTION_AUTO_START.equals(actionCode)) {
            syncActualStartDate(project);
        }
    }

    /**
     * 项目实际开始日期口径：
     * 1) 优先取项目下最早一条任务工作日志的 startDate；
     * 2) 若无工作日志，则回退到首次 auto_start 状态日志的 createTime 所在日期；
     * 3) 两者都没有时不更新。
     */
    private void syncActualStartDate(ProjectDO project) {
        if (project == null || project.getId() == null) {
            return;
        }
        LocalDate targetDate = resolveActualStartDate(project.getId());
        if (targetDate == null || Objects.equals(targetDate, project.getActualStartDate())) {
            return;
        }
        projectMapper.updateActualDatesById(project.getId(), targetDate, null);
        project.setActualStartDate(targetDate);
    }

    private LocalDate resolveActualStartDate(Long projectId) {
        LocalDate earliestWorklogDate = taskWorklogMapper.selectEarliestStartDateByProjectId(projectId);
        if (earliestWorklogDate != null) {
            return earliestWorklogDate;
        }
        ProjectStatusLogDO autoStartLog = projectStatusLogMapper
                .selectEarliestByProjectIdAndActionType(projectId, ObjectActivityConstants.PROJECT_ACTION_AUTO_START);
        if (autoStartLog == null || autoStartLog.getCreateTime() == null) {
            return null;
        }
        return autoStartLog.getCreateTime().toLocalDate();
    }

    private ProjectContextProjectRespVO buildCurrentProject(ProjectDO project) {
        // 项目上下文返回的是主表快照叠加运行时生命周期读模型，状态名称和动作列表不在主表写死。
        ProjectContextProjectRespVO currentProject = BeanUtils.toBean(project, ProjectContextProjectRespVO.class);
        ProjectStatusViewService.ProjectLifecycleView lifecycle =
                projectStatusViewService.getLifecycle(project.getStatusCode());
        currentProject.setStatusName(lifecycle.statusName());
        currentProject.setTerminal(lifecycle.terminal());
        currentProject.setAllowEdit(lifecycle.allowEdit());
        currentProject.setAvailableActions(lifecycle.availableActions());
        return currentProject;
    }

    /**
     * 构建项目响应对象，补齐产品名和负责人昵称，避免前端再做二次查询。
     */
    private ProjectRespVO buildProjectRespVO(ProjectDO project) {
        ProjectRespVO respVO = BeanUtils.toBean(project, ProjectRespVO.class);
        respVO.setProductName(getProductName(project.getProductId()));
        respVO.setManagerUserNickname(getManagerNickname(project.getManagerUserId()));
        return respVO;
    }

    private ProjectContextRespVO buildProjectContextWithoutMenus(ProjectDO project, boolean guestFlag) {
        ProjectContextRespVO respVO = new ProjectContextRespVO();
        respVO.setCurrentProject(buildCurrentProject(project));
        respVO.setCurrentRole(buildCurrentRole(null, null, guestFlag, Collections.emptyList()));
        respVO.setNavs(Collections.emptyList());
        respVO.setButtons(Collections.emptyList());
        return respVO;
    }

    private ProjectContextRoleRespVO buildCurrentRole(Long roleId, ObjectRoleRespDTO currentRole, boolean guestFlag,
                                                     List<String> additionalRoleNames) {
        ProjectContextRoleRespVO roleRespVO = new ProjectContextRoleRespVO();
        roleRespVO.setRoleId(roleId);
        roleRespVO.setGuestFlag(guestFlag);
        if (currentRole != null) {
            roleRespVO.setRoleCode(currentRole.getCode());
            roleRespVO.setRoleName(currentRole.getName());
        }
        roleRespVO.setAdditionalRoleNames(additionalRoleNames == null ? Collections.emptyList() : additionalRoleNames);
        return roleRespVO;
    }

    private List<ProjectContextNavRespVO> buildContextNavs(List<ObjectMenuRespDTO> menus) {
        if (menus.isEmpty()) {
            return Collections.emptyList();
        }
        List<ProjectContextNavRespVO> navs = menus.stream()
                .filter(menu -> !MenuTypeEnum.BUTTON.getType().equals(menu.getType()))
                .filter(menu -> !Boolean.FALSE.equals(menu.getVisible()))
                .map(menu -> {
                    ProjectContextNavRespVO nav = new ProjectContextNavRespVO();
                    nav.setId(menu.getId());
                    nav.setName(menu.getName());
                    nav.setPath(menu.getPath());
                    nav.setIcon(menu.getIcon());
                    nav.setSort(menu.getSort());
                    return nav;
                })
                .collect(Collectors.toCollection(ArrayList::new));
        navs.sort(Comparator.comparing(ProjectContextNavRespVO::getSort, Comparator.nullsLast(Integer::compareTo))
                .thenComparing(ProjectContextNavRespVO::getId, Comparator.nullsLast(Long::compareTo)));
        return navs;
    }

    private List<String> buildContextButtons(List<ObjectMenuRespDTO> menus) {
        if (menus.isEmpty()) {
            return Collections.emptyList();
        }
        return menus.stream()
                .filter(menu -> MenuTypeEnum.BUTTON.getType().equals(menu.getType()))
                .map(ObjectMenuRespDTO::getPermission)
                .filter(StringUtils::hasText)
                .map(String::trim)
                .distinct()
                .sorted()
                .collect(Collectors.toList());
    }

    private Map<String, Long> buildProjectStatusCounts(List<ObjectStatusModelDO> statusModels,
                                                       List<Map<String, Object>> rows) {
        Map<String, Long> statusCounts = new LinkedHashMap<>();
        if (statusModels == null || statusModels.isEmpty()) {
            return statusCounts;
        }
        statusModels.stream()
                .filter(statusModel -> Objects.equals(statusModel.getStatus(), 0))
                .filter(statusModel -> StringUtils.hasText(statusModel.getStatusCode()))
                .sorted(Comparator.comparing(ObjectStatusModelDO::getSort, Comparator.nullsLast(Integer::compareTo))
                        .thenComparing(ObjectStatusModelDO::getStatusCode))
                .forEach(statusModel -> statusCounts.put(statusModel.getStatusCode(), 0L));
        if (rows == null || rows.isEmpty()) {
            return statusCounts;
        }
        for (Map<String, Object> row : rows) {
            Object statusValue = row.getOrDefault("statusCode", row.get("status_code"));
            String statusCode = Objects.toString(statusValue, null);
            if (!statusCounts.containsKey(statusCode)) {
                continue;
            }
            Object countValue = row.getOrDefault("countValue", row.get("count_value"));
            if (countValue instanceof Number number) {
                statusCounts.put(statusCode, number.longValue());
            }
        }
        return statusCounts;
    }

    /**
     * 状态看板项：清单由状态机启用状态运行时推导，过滤/排序口径与 buildProjectStatusCounts 一致（sort 升序）。
     * 「全部」口径无排除项（作废/归档同样计入），includeInAll 恒为 true（设计来源：
     * docs/superpowers/specs/2026-06-11-项目产品列表状态看板动态化-design.md 第二节）。
     */
    private List<ProjectOverviewSummaryRespVO.StatusBoardItemVO> buildStatusBoardItems(
            List<ObjectStatusModelDO> statusModels, Map<String, Long> statusCounts) {
        return statusModels.stream()
                .filter(statusModel -> Objects.equals(statusModel.getStatus(), 0))
                .filter(statusModel -> StringUtils.hasText(statusModel.getStatusCode()))
                .sorted(Comparator.comparing(ObjectStatusModelDO::getSort, Comparator.nullsLast(Integer::compareTo))
                        .thenComparing(ObjectStatusModelDO::getStatusCode))
                .map(statusModel -> {
                    ProjectOverviewSummaryRespVO.StatusBoardItemVO item = new ProjectOverviewSummaryRespVO.StatusBoardItemVO();
                    item.setStatusCode(statusModel.getStatusCode());
                    item.setStatusName(statusModel.getStatusName());
                    item.setCount(statusCounts.getOrDefault(statusModel.getStatusCode(), 0L));
                    item.setSort(statusModel.getSort());
                    item.setTerminal(statusModel.getTerminalFlag());
                    item.setIncludeInAll(true);
                    return item;
                })
                .toList();
    }

    private void validateDeleteConfirmText(String confirmText) {
        String normalizedConfirmText = normalizeNullableText(confirmText);
        if (!Objects.equals(ProjectObjectConstants.DELETE_CONFIRM_TEXT, normalizedConfirmText)) {
            throw exception(ErrorCodeConstants.PROJECT_DELETE_CONFIRM_TEXT_INVALID);
        }
    }

    private void writeProjectStatusLog(ProjectDO project, String actionType, String fromStatus,
                                       String toStatus, String reason) {
        ProjectStatusLogDO statusLog = new ProjectStatusLogDO();
        statusLog.setProjectId(project.getId());
        statusLog.setActionType(actionType);
        statusLog.setFromStatus(fromStatus);
        statusLog.setToStatus(toStatus);
        statusLog.setReason(defaultText(reason));
        statusLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId());
        statusLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
        statusLog.setProjectCodeSnapshot(project.getProjectCode());
        statusLog.setProjectNameSnapshot(project.getProjectName());
        projectStatusLogMapper.insert(statusLog);
    }

    private void writeBizAuditLog(ProjectDO project, String actionType, String fromStatus, String toStatus,
                                  String fieldChanges, String reason) {
        BizAuditLogDO auditLog = new BizAuditLogDO();
        auditLog.setBizType(ProjectObjectConstants.OBJECT_TYPE);
        auditLog.setBizId(project.getId());
        auditLog.setActionType(actionType);
        auditLog.setFromStatus(fromStatus);
        auditLog.setToStatus(toStatus);
        auditLog.setFieldChanges(fieldChanges);
        auditLog.setReason(reason);
        auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId());
        auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
        bizAuditLogMapper.insert(auditLog);
    }

    private void writeMemberAuditLog(UserObjectRoleDO member, String actionType,
                                     UserObjectRoleDO before, UserObjectRoleDO after, String reason) {
        BizAuditLogDO auditLog = new BizAuditLogDO();
        auditLog.setBizType(ObjectActivityConstants.MEMBER_BIZ_TYPE);
        auditLog.setBizId(member.getId());
        auditLog.setActionType(actionType);
        auditLog.setFieldChanges(buildMemberFieldChanges(before, after));
        auditLog.setReason(reason);
        auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId());
        auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
        bizAuditLogMapper.insert(auditLog);
    }

    private void writeManagerChangeAuditLog(Long projectId, Long beforeManagerUserId, Long afterManagerUserId, String reason) {
        if (Objects.equals(beforeManagerUserId, afterManagerUserId)) {
            return;
        }
        BizAuditLogDO auditLog = new BizAuditLogDO();
        auditLog.setBizType(ProjectObjectConstants.OBJECT_TYPE);
        auditLog.setBizId(projectId);
        auditLog.setActionType(ObjectActivityConstants.PROJECT_ACTION_CHANGE_MANAGER);
        auditLog.setFieldChanges(buildManagerFieldChanges(beforeManagerUserId, afterManagerUserId));
        auditLog.setReason(reason);
        auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId());
        auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
        bizAuditLogMapper.insert(auditLog);
    }

    private ProjectDO cloneProject(ProjectDO source) {
        ProjectDO target = new ProjectDO();
        target.setId(source.getId());
        target.setProjectCode(source.getProjectCode());
        target.setProjectName(source.getProjectName());
        target.setProjectType(source.getProjectType());
        target.setDirectionCode(source.getDirectionCode());
        target.setProjectSetId(source.getProjectSetId());
        target.setProductId(source.getProductId());
        target.setProductVersionId(source.getProductVersionId());
        target.setManagerUserId(source.getManagerUserId());
        target.setStatusCode(source.getStatusCode());
        target.setPlannedStartDate(source.getPlannedStartDate());
        target.setPlannedEndDate(source.getPlannedEndDate());
        target.setActualStartDate(source.getActualStartDate());
        target.setActualEndDate(source.getActualEndDate());
        target.setProgressRate(source.getProgressRate());
        target.setProjectDesc(source.getProjectDesc());
        target.setLastStatusReason(source.getLastStatusReason());
        return target;
    }

    private UserObjectRoleDO cloneMember(UserObjectRoleDO source) {
        UserObjectRoleDO clone = new UserObjectRoleDO();
        clone.setId(source.getId());
        clone.setUserId(source.getUserId());
        clone.setObjectType(source.getObjectType());
        clone.setObjectId(source.getObjectId());
        clone.setRoleId(source.getRoleId());
        clone.setStatus(source.getStatus());
        clone.setJoinedTime(source.getJoinedTime());
        clone.setLeftTime(source.getLeftTime());
        clone.setRemark(source.getRemark());
        return clone;
    }

    private String buildProjectFieldChanges(ProjectDO before, ProjectDO after) {
        Map<String, Object> fieldChanges = new LinkedHashMap<>();
        appendFieldChange(fieldChanges, "projectCode", valueOf(before, ProjectDO::getProjectCode),
                valueOf(after, ProjectDO::getProjectCode));
        appendFieldChange(fieldChanges, "projectName", valueOf(before, ProjectDO::getProjectName),
                valueOf(after, ProjectDO::getProjectName));
        appendFieldChange(fieldChanges, "projectType", valueOf(before, ProjectDO::getProjectType),
                valueOf(after, ProjectDO::getProjectType));
        appendFieldChange(fieldChanges, "directionCode", valueOf(before, ProjectDO::getDirectionCode),
                valueOf(after, ProjectDO::getDirectionCode));
        appendFieldChange(fieldChanges, "productId", valueOf(before, ProjectDO::getProductId),
                valueOf(after, ProjectDO::getProductId));
        appendFieldChange(fieldChanges, "managerUserId", valueOf(before, ProjectDO::getManagerUserId),
                valueOf(after, ProjectDO::getManagerUserId));
        appendFieldChange(fieldChanges, "statusCode", valueOf(before, ProjectDO::getStatusCode),
                valueOf(after, ProjectDO::getStatusCode));
        appendFieldChange(fieldChanges, "projectDesc", valueOf(before, ProjectDO::getProjectDesc),
                valueOf(after, ProjectDO::getProjectDesc));
        appendFieldChange(fieldChanges, "lastStatusReason", valueOf(before, ProjectDO::getLastStatusReason),
                valueOf(after, ProjectDO::getLastStatusReason));
        return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges);
    }

    private String buildMemberFieldChanges(UserObjectRoleDO before, UserObjectRoleDO after) {
        Map<String, Object> fieldChanges = new LinkedHashMap<>();
        appendFieldChange(fieldChanges, "userId", valueOf(before, UserObjectRoleDO::getUserId),
                valueOf(after, UserObjectRoleDO::getUserId));
        appendFieldChange(fieldChanges, "roleId", valueOf(before, UserObjectRoleDO::getRoleId),
                valueOf(after, UserObjectRoleDO::getRoleId));
        appendFieldChange(fieldChanges, "status", valueOf(before, UserObjectRoleDO::getStatus),
                valueOf(after, UserObjectRoleDO::getStatus));
        appendFieldChange(fieldChanges, "joinedTime", valueOf(before, UserObjectRoleDO::getJoinedTime),
                valueOf(after, UserObjectRoleDO::getJoinedTime));
        appendFieldChange(fieldChanges, "leftTime", valueOf(before, UserObjectRoleDO::getLeftTime),
                valueOf(after, UserObjectRoleDO::getLeftTime));
        appendFieldChange(fieldChanges, "remark", valueOf(before, UserObjectRoleDO::getRemark),
                valueOf(after, UserObjectRoleDO::getRemark));
        return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges);
    }

    private String buildManagerFieldChanges(Long beforeManagerUserId, Long afterManagerUserId) {
        Map<String, Object> fieldChanges = new LinkedHashMap<>();
        appendFieldChange(fieldChanges, "managerUserId", beforeManagerUserId, afterManagerUserId);
        return JsonUtils.toJsonString(fieldChanges);
    }

    private <T> T valueOf(ProjectDO project, Function<ProjectDO, T> getter) {
        return project == null ? null : getter.apply(project);
    }

    private <T> T valueOf(UserObjectRoleDO member, Function<UserObjectRoleDO, T> getter) {
        return member == null ? null : getter.apply(member);
    }

    private void appendFieldChange(Map<String, Object> fieldChanges, String fieldName, Object before, Object after) {
        if (Objects.equals(before, after)) {
            return;
        }
        Map<String, Object> value = new LinkedHashMap<>();
        value.put("before", before);
        value.put("after", after);
        fieldChanges.put(fieldName, value);
    }

    private String normalizeNullableText(String value) {
        if (!StringUtils.hasText(value)) {
            return null;
        }
        return value.trim();
    }

    private String defaultText(String value) {
        return StringUtils.hasText(value) ? value : "";
    }

}
