Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,5 +24,11 @@ public record ProcessOrderUpdateReqDto(
LocalDate startDate,

@JsonProperty("dead_line")
LocalDate deadLine
LocalDate deadLine,

@JsonProperty("role_fields")
List<RoleField> roleFields,

@JsonProperty("custom_fields")
List<String> customFields
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -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", "프로세스를 찾을 수 없습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
));

Expand Down Expand Up @@ -915,9 +919,10 @@ private List<Long> syncMentions(Process process, List<Long> 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
));

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -1384,9 +1396,10 @@ private void validateProjectTeamRolesForUpdateOrThrow(Long projectId, List<RoleF
public void deleteProcess(Long projectId, Long userId, Long processId) {
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,
"processId=" + processId + ", projectId=" + projectId
));

Expand Down Expand Up @@ -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
));

Expand All @@ -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<RoleField> requestedRoleFields = (req.roleFields() == null)
? null
: req.roleFields().stream()
.filter(Objects::nonNull)
.filter(rf -> rf != RoleField.CUSTOM) // CUSTOM은 custom_fields로만 처리
.distinct()
.toList();

List<String> 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<RoleField> finalRoleFields =
(requestedRoleFields == null) ? List.of() : requestedRoleFields;
List<String> 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<RoleField> 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<String> 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();
Expand All @@ -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);

}

// 상태 변경(드롭다운/드래그로 상태가 바뀌는 경우)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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("응답 상태"),
Expand All @@ -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 가능)")
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@ public interface ProcessRepository extends JpaRepository<Process, Long> {
// 소속 검증 + 소프트 delete 제외
Optional<Process> 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<Process> findByIdInProjectExcludingWeekMission(
@Param("projectId") Long projectId,
@Param("processId") Long processId
);


@EntityGraph(attributePaths = { "processUsers", "processUsers.user" })
@Query("""
select p
Expand Down Expand Up @@ -543,14 +557,17 @@ Optional<MissionPeriodRow> 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(
Expand Down