diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessOrderUpdateReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessOrderUpdateReqDto.java index 66b089c8..5dde8cbc 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessOrderUpdateReqDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/req/ProcessOrderUpdateReqDto.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.nect.core.entity.team.process.enums.ProcessStatus; +import com.nect.core.entity.user.enums.RoleField; import java.time.LocalDate; import java.util.List; @@ -23,5 +24,11 @@ public record ProcessOrderUpdateReqDto( LocalDate startDate, @JsonProperty("dead_line") - LocalDate deadLine + LocalDate deadLine, + + @JsonProperty("role_fields") + List roleFields, + + @JsonProperty("custom_fields") + List customFields ) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/enums/ProcessErrorCode.java b/nect-api/src/main/java/com/nect/api/domain/team/process/enums/ProcessErrorCode.java index 33d8cf7d..1835d8fb 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/enums/ProcessErrorCode.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/enums/ProcessErrorCode.java @@ -18,6 +18,8 @@ public enum ProcessErrorCode implements ResponseCode { FORBIDDEN("P4030", "해당 프로젝트에 대한 권한이 없습니다."), PROCESS_NOT_IN_PROJECT("P4031", "해당 프로젝트에 속한 프로세스가 아닙니다."), WEEK_MISSION_FORBIDDEN("P4032", "위크 미션은 프로세스 상세 조회에서 조회할 수 없습니다."), + WEEK_MISSION_MODIFICATION_FORBIDDEN("P4033", "위크 미션은 해당 작업(수정/삭제/정렬/상태변경)을 수행할 수 없습니다."), + WEEK_MISSION_ONLY("P4034", "해당 기능은 위크 미션 프로세스에서만 사용할 수 있습니다."), PROJECT_NOT_FOUND("P4041", "프로젝트를 찾을 수 없습니다."), PROCESS_NOT_FOUND("P4042", "프로세스를 찾을 수 없습니다."), diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java index 1c007a93..df0e5ed7 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java @@ -33,6 +33,7 @@ import com.nect.core.repository.team.SharedDocumentRepository; import com.nect.core.repository.team.process.*; import com.nect.core.repository.user.UserRepository; +import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -64,6 +65,8 @@ public class ProcessService { private final NotificationFacade notificationFacade; private final ProjectHistoryPublisher historyPublisher; + private final EntityManager em; + private static final String TEAM_LANE_KEY = "TEAM"; private static final int MAX_TITLE_LENGTH = 50; @@ -623,9 +626,10 @@ public ProcessCreateResDto createProcess(Long projectId, Long userId, ProcessCre public ProcessDetailResDto getProcessDetail(Long projectId, Long userId, Long processId, String laneKey) { assertActiveProjectMember(projectId, userId); - Process process = processRepository.findByIdAndProjectIdAndDeletedAtIsNull(processId, projectId) + Process process = processRepository + .findByIdInProjectExcludingWeekMission(projectId, processId) .orElseThrow(() -> new ProcessException( - ProcessErrorCode.PROCESS_NOT_FOUND, + ProcessErrorCode.WEEK_MISSION_FORBIDDEN, "projectId=" + projectId + ", processId=" + processId )); @@ -915,9 +919,10 @@ private List syncMentions(Process process, List requestedUserIds) { public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, Long processId, ProcessBasicUpdateReqDto req) { assertActiveProjectMember(projectId, userId); - Process process = processRepository.findByIdAndProjectIdAndDeletedAtIsNull(processId, projectId) + Process process = processRepository + .findByIdInProjectExcludingWeekMission(projectId, processId) .orElseThrow(() -> new ProcessException( - ProcessErrorCode.PROCESS_NOT_FOUND, + ProcessErrorCode.WEEK_MISSION_MODIFICATION_FORBIDDEN, "projectId=" + projectId + ", processId=" + processId )); @@ -1058,6 +1063,13 @@ public ProcessBasicUpdateResDto updateProcessBasic(Long projectId, Long userId, if (periodPatchRequested) { + if (mergedStart == null || mergedEnd == null) { + throw new ProcessException( + ProcessErrorCode.INVALID_REQUEST, + "startDate and deadLine must not be null. mergedStart=" + mergedStart + ", mergedEnd=" + mergedEnd + ); + } + process.updatePeriod(mergedStart, mergedEnd); } @@ -1384,9 +1396,10 @@ private void validateProjectTeamRolesForUpdateOrThrow(Long projectId, List new ProcessException( - ProcessErrorCode.PROCESS_NOT_FOUND, + ProcessErrorCode.WEEK_MISSION_MODIFICATION_FORBIDDEN, "processId=" + processId + ", projectId=" + projectId )); @@ -2011,9 +2024,10 @@ private int rate(long part, long total) { public ProcessOrderUpdateResDto updateProcessOrder(Long projectId, Long userId, Long processId, ProcessOrderUpdateReqDto req) { assertActiveProjectMember(projectId, userId); - Process process = processRepository.findByIdAndProjectIdAndDeletedAtIsNull(processId, projectId) + Process process = processRepository + .findByIdInProjectExcludingWeekMission(projectId, processId) .orElseThrow(() -> new ProcessException( - ProcessErrorCode.PROCESS_NOT_FOUND, + ProcessErrorCode.WEEK_MISSION_MODIFICATION_FORBIDDEN, "projectId=" + projectId + ", processId=" + processId )); @@ -2024,6 +2038,86 @@ public ProcessOrderUpdateResDto updateProcessOrder(Long projectId, Long userId, // 변경 전 스냅샷 ProcessStatus beforeStatus = process.getStatus(); + // ✅ CHANGED: 0) 레인(필드) 변경 먼저 처리 (레인 간 이동) + boolean fieldsPatchRequested = (req.roleFields() != null || req.customFields() != null); + + List requestedRoleFields = (req.roleFields() == null) + ? null + : req.roleFields().stream() + .filter(Objects::nonNull) + .filter(rf -> rf != RoleField.CUSTOM) // CUSTOM은 custom_fields로만 처리 + .distinct() + .toList(); + + List requestedCustomFields = (req.customFields() == null) + ? null + : req.customFields().stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isBlank()) + .distinct() + .toList(); + + if (fieldsPatchRequested) { + // 프로젝트에 등록된 파트인지 검증 + validateProjectTeamRolesForUpdateOrThrow( + projectId, + requestedRoleFields == null ? List.of() : requestedRoleFields, + requestedCustomFields == null ? List.of() : requestedCustomFields + ); + + List finalRoleFields = + (requestedRoleFields == null) ? List.of() : requestedRoleFields; + List finalCustomFields = + (requestedCustomFields == null) ? List.of() : requestedCustomFields; + + // 필드 재생성 + process.getProcessFields().clear(); + + for (RoleField rf : finalRoleFields) { + process.getProcessFields().add(ProcessField.builder() + .process(process) + .roleField(rf) + .customFieldName(null) + .build()); + } + + for (String name : finalCustomFields) { + process.getProcessFields().add(ProcessField.builder() + .process(process) + .roleField(RoleField.CUSTOM) + .customFieldName(name) + .build()); + } + + em.flush(); + } + + + List finalRoleFields = + (requestedRoleFields != null) + ? requestedRoleFields + : process.getProcessFields().stream() + .filter(pf -> pf.getDeletedAt() == null) + .map(ProcessField::getRoleField) + .filter(Objects::nonNull) + .filter(rf -> rf != RoleField.CUSTOM) + .distinct() + .toList(); + + List finalCustomFields = + (requestedCustomFields != null) + ? requestedCustomFields + : process.getProcessFields().stream() + .filter(pf -> pf.getDeletedAt() == null) + .filter(pf -> pf.getRoleField() == RoleField.CUSTOM) + .map(ProcessField::getCustomFieldName) + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isBlank()) + .distinct() + .toList(); + // 기간 변경 LocalDate newStart = req.startDate(); LocalDate newEnd = req.deadLine(); @@ -2032,22 +2126,29 @@ public ProcessOrderUpdateResDto updateProcessOrder(Long projectId, Long userId, LocalDate mergedStart = (newStart != null) ? newStart : process.getStartAt(); LocalDate mergedEnd = (newEnd != null) ? newEnd : process.getEndAt(); - if (mergedStart != null && mergedEnd != null && mergedStart.isAfter(mergedEnd)) { + if (mergedStart == null || mergedEnd == null) { + throw new ProcessException( + ProcessErrorCode.INVALID_REQUEST, + "startDate and deadLine must not be null. mergedStart=" + mergedStart + ", mergedEnd=" + mergedEnd + ); + } + + if (mergedStart.isAfter(mergedEnd)) { throw new ProcessException( ProcessErrorCode.INVALID_PROCESS_PERIOD, "startDate = " + mergedStart + ", endDate = " + mergedEnd ); } - if (req.missionNumber() != null && mergedStart != null) { + if (req.missionNumber() != null) { validateStartDateInSelectedMission(projectId, req.missionNumber(), mergedStart); } - if (mergedStart != null && mergedEnd != null) { - validateNoOverlapForUpdateOrderLane(projectId, processId, dbLaneKey, mergedStart, mergedEnd); - } + // 레인 기준 기간 겹침 검증 (TEAM이면 skip / ROLE,CUSTOM이면 lane 기준) + validateNoOverlapForUpdateOrderLane(projectId, processId, dbLaneKey, mergedStart, mergedEnd); process.updatePeriod(mergedStart, mergedEnd); + } // 상태 변경(드롭다운/드래그로 상태가 바뀌는 경우) @@ -2147,7 +2248,7 @@ private int validateLaneAndCountTotal( ) { // TEAM: status 내 전체 프로세스 수 if (TEAM_LANE_KEY.equals(dbLaneKey)) { - return processRepository.countByProjectIdAndDeletedAtIsNullAndStatus(projectId, laneStatus); + return processRepository.countTeamLaneTotalExcludingWeekMission(projectId, laneStatus); } // ROLE @@ -2380,9 +2481,10 @@ public ProcessStatusUpdateResDto updateProcessStatus(Long projectId, Long userId throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "status is null"); } - Process process = processRepository.findByIdAndProjectIdAndDeletedAtIsNull(processId, projectId) + Process process = processRepository + .findByIdInProjectExcludingWeekMission(projectId, processId) .orElseThrow(() -> new ProcessException( - ProcessErrorCode.PROCESS_NOT_FOUND, + ProcessErrorCode.WEEK_MISSION_MODIFICATION_FORBIDDEN, "projectId=" + projectId + ", processId=" + processId )); diff --git a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java index 71fbf432..5d0d9d86 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java @@ -1170,7 +1170,9 @@ void updateProcessOrder() throws Exception { "ROLE:BACKEND", 1, LocalDate.of(2026, 2, 1), - LocalDate.of(2026, 2, 10) + LocalDate.of(2026, 2, 10), + List.of(RoleField.BACKEND), + List.of() ); ProcessOrderUpdateResDto response = new ProcessOrderUpdateResDto( @@ -1209,11 +1211,14 @@ void updateProcessOrder() throws Exception { ) .requestFields( fieldWithPath("status").optional().type(STRING).description("대상 프로세스 상태(컬럼)"), - fieldWithPath("ordered_process_ids").optional().type(ARRAY).description("정렬 순서대로 나열한 프로세스 ID 목록"), + fieldWithPath("ordered_process_ids").optional().type(ARRAY).description("정렬 순서대로 나열한 프로세스 ID 목록(해당 레인의 전체)"), fieldWithPath("lane_key").type(STRING).description("레인 키(TEAM, ROLE:XXX, CUSTOM:이름)"), + fieldWithPath("mission_number").optional().type(NUMBER).description("미션 번호(위크미션이면 사용, 기본형이면 null 가능)"), fieldWithPath("start_date").optional().type(STRING).description("시작일(yyyy-MM-dd, null 가능)"), fieldWithPath("dead_line").optional().type(STRING).description("마감일(yyyy-MM-dd, null 가능)"), - fieldWithPath("mission_number").optional().type(NUMBER).description("미션 번호(위크미션이면 사용, 기본형이면 null 가능)") + + fieldWithPath("role_fields").optional().type(ARRAY).description("담당 파트(ROLE) 목록 (ex: [BACKEND, FRONTEND])"), + fieldWithPath("custom_fields").optional().type(ARRAY).description("커스텀 파트(CUSTOM) 목록") ) .responseFields( fieldWithPath("status").type(OBJECT).description("응답 상태"), @@ -1224,8 +1229,7 @@ void updateProcessOrder() throws Exception { fieldWithPath("body").type(OBJECT).description("응답 바디"), fieldWithPath("body.process_id").type(NUMBER).description("프로세스 ID"), fieldWithPath("body.status").type(STRING).description("프로세스 상태"), - fieldWithPath("body.status_order").type(NUMBER).description("해당 status 내 정렬 순서"), - fieldWithPath("body.groups[].processes[].mission_number").optional().type(NUMBER).description("미션 번호(null 가능)"), + fieldWithPath("body.status_order").type(NUMBER).description("해당 레인+상태 내 정렬 순서"), fieldWithPath("body.start_at").optional().type(STRING).description("시작일(yyyy-MM-dd, null 가능)"), fieldWithPath("body.dead_line").optional().type(STRING).description("마감일(yyyy-MM-dd, null 가능)") ) diff --git a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java index 4f443e23..37cefb03 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessRepository.java @@ -17,6 +17,20 @@ public interface ProcessRepository extends JpaRepository { // 소속 검증 + 소프트 delete 제외 Optional findByIdAndProjectIdAndDeletedAtIsNull(Long id, Long projectId); + @Query(""" + select p + from Process p + where p.id = :processId + and p.project.id = :projectId + and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) + """) + Optional findByIdInProjectExcludingWeekMission( + @Param("projectId") Long projectId, + @Param("processId") Long processId + ); + + @EntityGraph(attributePaths = { "processUsers", "processUsers.user" }) @Query(""" select p @@ -543,14 +557,17 @@ Optional findWeekMissionPeriodByMissionNumber( ); @Query(""" - select case when count(p) > 0 then true else false end + select (count(p) > 0) from Process p join p.processFields pf where p.project.id = :projectId and p.deletedAt is null + and (p.processType is null or p.processType <> com.nect.core.entity.team.process.enums.ProcessType.WEEK_MISSION) and p.id <> :excludeProcessId and pf.deletedAt is null and pf.roleField = :roleField + and p.startAt is not null + and p.endAt is not null and not (p.endAt < :start or p.startAt > :end) """) boolean existsOverlappingInRoleLaneExcludingProcess(