diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceQueryRepositoryImpl.java index 6187de3b..2ca204b2 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceQueryRepositoryImpl.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceQueryRepositoryImpl.java @@ -547,6 +547,19 @@ public List getManagerWorkspaceManagerListW .fetch(); } + @Override + public List findAllByNextMonthShiftGenDay(int day) { + QWorkspace qWorkspace = QWorkspace.workspace; + + return queryFactory + .selectFrom(qWorkspace) + .where( + qWorkspace.nextMonthShiftGenDay.eq(day), + qWorkspace.status.eq(WorkspaceStatus.ACTIVATED) + ) + .fetch(); + } + @Override public boolean existsByIdAndManagerUser(Long workspaceId, ManagerUser managerUser) { QWorkspace qWorkspace = QWorkspace.workspace; diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceShiftRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceShiftRepositoryImpl.java index 4cdf83fd..cfda0c91 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceShiftRepositoryImpl.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceShiftRepositoryImpl.java @@ -1,9 +1,13 @@ package com.dreamteam.alter.adapter.outbound.workspace.persistence; +import java.util.List; + +import org.springframework.stereotype.Repository; + import com.dreamteam.alter.domain.workspace.entity.WorkspaceShift; import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceShiftRepository; + import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Repository; @Repository @RequiredArgsConstructor @@ -16,6 +20,11 @@ public WorkspaceShift save(WorkspaceShift shift) { return workspaceShiftJpaRepository.save(shift); } + @Override + public List saveAll(List shifts) { + return workspaceShiftJpaRepository.saveAll(shifts); + } + @Override public void delete(WorkspaceShift shift) { workspaceShiftJpaRepository.delete(shift); diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceWorkerScheduleQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceWorkerScheduleQueryRepositoryImpl.java index cae549a4..5b905115 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceWorkerScheduleQueryRepositoryImpl.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceWorkerScheduleQueryRepositoryImpl.java @@ -5,12 +5,14 @@ import org.springframework.stereotype.Repository; +import com.dreamteam.alter.domain.workspace.entity.QWorkspace; import com.dreamteam.alter.domain.workspace.entity.QWorkspaceWorker; import com.dreamteam.alter.domain.workspace.entity.QWorkspaceWorkerSchedule; import com.dreamteam.alter.domain.workspace.entity.WorkspaceWorker; import com.dreamteam.alter.domain.workspace.entity.WorkspaceWorkerSchedule; import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceWorkerScheduleQueryRepository; import com.dreamteam.alter.domain.workspace.type.WorkspaceWorkerScheduleStatus; +import com.dreamteam.alter.domain.workspace.type.WorkspaceWorkerStatus; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; @@ -65,4 +67,22 @@ public List getByWorkspaceWorker(WorkspaceWorker worksp ) .fetch(); } + + @Override + public List findAllActivatedWithWorkspaceWorkerByWorkspaceIds(List workspaceIds) { + QWorkspaceWorkerSchedule qWorkspaceWorkerSchedule = QWorkspaceWorkerSchedule.workspaceWorkerSchedule; + QWorkspaceWorker qWorkspaceWorker = QWorkspaceWorker.workspaceWorker; + QWorkspace qWorkspace = QWorkspace.workspace; + + return queryFactory + .selectFrom(qWorkspaceWorkerSchedule) + .join(qWorkspaceWorkerSchedule.workspaceWorker, qWorkspaceWorker).fetchJoin() + .join(qWorkspaceWorker.workspace, qWorkspace).fetchJoin() + .where( + qWorkspace.id.in(workspaceIds), + qWorkspaceWorkerSchedule.status.eq(WorkspaceWorkerScheduleStatus.ACTIVATED), + qWorkspaceWorker.status.eq(WorkspaceWorkerStatus.ACTIVATED) + ) + .fetch(); + } } diff --git a/src/main/java/com/dreamteam/alter/application/workspace/usecase/GenerateNextMonthWorkspaceShift.java b/src/main/java/com/dreamteam/alter/application/workspace/usecase/GenerateNextMonthWorkspaceShift.java new file mode 100644 index 00000000..de5f2907 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/workspace/usecase/GenerateNextMonthWorkspaceShift.java @@ -0,0 +1,177 @@ +package com.dreamteam.alter.application.workspace.usecase; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dreamteam.alter.domain.workspace.entity.Workspace; +import com.dreamteam.alter.domain.workspace.entity.WorkspaceShift; +import com.dreamteam.alter.domain.workspace.entity.WorkspaceWorker; +import com.dreamteam.alter.domain.workspace.entity.WorkspaceWorkerSchedule; +import com.dreamteam.alter.domain.workspace.port.inbound.GenerateNextMonthWorkspaceShiftUseCase; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceQueryRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceShiftQueryRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceShiftRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceWorkerScheduleQueryRepository; +import com.dreamteam.alter.domain.workspace.type.WorkspaceShiftStatus; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service("generateNextMonthWorkspaceShift") +@RequiredArgsConstructor +@Transactional +public class GenerateNextMonthWorkspaceShift implements GenerateNextMonthWorkspaceShiftUseCase { + + private static final String DEFAULT_POSITION = "고정 근무"; + + private final WorkspaceQueryRepository workspaceQueryRepository; + private final WorkspaceWorkerScheduleQueryRepository workspaceWorkerScheduleQueryRepository; + private final WorkspaceShiftRepository workspaceShiftRepository; + private final WorkspaceShiftQueryRepository workspaceShiftQueryRepository; + + @Override + public void execute() { + // 서비스 타임존 기준으로 오늘 날짜의 day-of-month를 구한다 + LocalDate now = LocalDate.now(); + int todayDayOfMonth = now.getDayOfMonth(); + + // 오늘이 고정 근무 생성일로 설정된 워크스페이스만 조회한다 + List targetWorkspaces = workspaceQueryRepository.findAllByNextMonthShiftGenDay(todayDayOfMonth); + + if (targetWorkspaces.isEmpty()) { + log.info("[고정 근무 생성] 오늘({})에 해당하는 대상 워크스페이스가 없습니다.", now); + return; + } + + List workspaceIds = targetWorkspaces.stream() + .map(Workspace::getId) + .toList(); + + log.info("[고정 근무 생성] 대상 워크스페이스 {}개 조회 완료.", workspaceIds.size()); + + // 대상 워크스페이스들의 활성화된 고정 스케줄을 한 번에 조회 후 워크스페이스별로 그룹화한다 + List allSchedules = workspaceWorkerScheduleQueryRepository + .findAllActivatedWithWorkspaceWorkerByWorkspaceIds(workspaceIds); + + Map> schedulesByWorkspaceId = allSchedules.stream() + .collect(Collectors.groupingBy( + schedule -> schedule.getWorkspaceWorker().getWorkspace().getId() + )); + + YearMonth targetMonth = YearMonth.from(now).plusMonths(1); + int totalCreated = 0; + int totalSkipped = 0; + int failedWorkspaceCount = 0; + + for (Workspace workspace : targetWorkspaces) { + try { + List schedules = schedulesByWorkspaceId + .getOrDefault(workspace.getId(), List.of()); + + if (schedules.isEmpty()) { + log.info("[고정 근무 생성] 워크스페이스({}) - 활성화된 고정 스케줄 없음. 건너뜀.", workspace.getId()); + continue; + } + + GenerationResult result = generateShiftsForWorkspace(workspace, schedules, targetMonth); + totalCreated += result.created; + totalSkipped += result.skipped; + + log.info("[고정 근무 생성] 워크스페이스({}) - 생성: {}건, 충돌 건너뜀: {}건", workspace.getId(), result.created, result.skipped); + } catch (Exception e) { + failedWorkspaceCount++; + log.error("[고정 근무 생성] 워크스페이스({}) 처리 중 오류 발생: {}", workspace.getId(), e.getMessage(), e); + } + } + + log.info("[고정 근무 생성] 배치 완료 - 총 생성: {}건, 총 건너뜀: {}건, 실패 워크스페이스: {}개", totalCreated, totalSkipped, failedWorkspaceCount); + } + + /** + * 단일 워크스페이스에 대해 다음 달 고정 근무 시프트를 생성한다. + */ + private GenerationResult generateShiftsForWorkspace( + Workspace workspace, + List schedules, + YearMonth targetMonth + ) { + LocalDate startDate = targetMonth.atDay(1); + LocalDate endDate = targetMonth.atEndOfMonth(); + + List shiftsToSave = new ArrayList<>(); + int skipped = 0; + + for (WorkspaceWorkerSchedule schedule : schedules) { + // 해당 스케줄의 요일을 만족하는 다음 달 첫 날짜를 계산한다 + LocalDate firstStartDate = calculateFirstOccurrence(startDate, schedule.getStartDayOfWeek()); + if (firstStartDate.isAfter(endDate)) { + continue; + } + + // 매주 반복하면서 시프트를 생성한다 + for (LocalDate currentStartDate = firstStartDate; + !currentStartDate.isAfter(endDate); + currentStartDate = currentStartDate.plusWeeks(1)) { + + WorkspaceWorker workspaceWorker = schedule.getWorkspaceWorker(); + LocalDateTime startDateTime = currentStartDate.atTime(schedule.getStartTime()); + LocalDateTime endDateTime = currentStartDate + .plusDays(calculateDayOffset(schedule.getStartDayOfWeek(), schedule.getEndDayOfWeek())) + .atTime(schedule.getEndTime()); + + // 이미 확정된 근무와 겹치면 자동 생성하지 않는다 + if (workspaceShiftQueryRepository.hasConflictingSchedule(workspaceWorker, startDateTime, endDateTime)) { + skipped++; + continue; + } + + WorkspaceShift shift = WorkspaceShift.create( + workspace, + startDateTime, + endDateTime, + DEFAULT_POSITION, + WorkspaceShiftStatus.CONFIRMED + ); + shift.assignWorker(workspaceWorker); + shiftsToSave.add(shift); + } + } + + if (!shiftsToSave.isEmpty()) { + workspaceShiftRepository.saveAll(shiftsToSave); + } + + return new GenerationResult(shiftsToSave.size(), skipped); + } + + // 해당 요일이 처음으로 등장하는 날짜를 반환한다 + private LocalDate calculateFirstOccurrence(LocalDate monthStart, DayOfWeek targetDay) { + int diff = targetDay.getValue() - monthStart.getDayOfWeek().getValue(); + if (diff < 0) { + diff += 7; + } + return monthStart.plusDays(diff); + } + + // 시작 요일 대비 종료 요일까지 필요한 일수 오프셋을 계산한다 + private long calculateDayOffset(DayOfWeek startDay, DayOfWeek endDay) { + long offset = endDay.getValue() - startDay.getValue(); + // 종료 요일이 시작 요일보다 이른 경우 (예: 금요일 시작 → 월요일 종료) 다음 주로 넘어간다 + if (offset < 0) { + offset += 7; + } + return offset; + } + + private record GenerationResult(int created, int skipped) {} +} diff --git a/src/main/java/com/dreamteam/alter/application/workspace/usecase/WorkspaceScheduleServiceImpl.java b/src/main/java/com/dreamteam/alter/application/workspace/usecase/WorkspaceScheduleServiceImpl.java index ed7dab71..aca9fb14 100644 --- a/src/main/java/com/dreamteam/alter/application/workspace/usecase/WorkspaceScheduleServiceImpl.java +++ b/src/main/java/com/dreamteam/alter/application/workspace/usecase/WorkspaceScheduleServiceImpl.java @@ -1,6 +1,7 @@ package com.dreamteam.alter.application.workspace.usecase; import com.dreamteam.alter.domain.workspace.port.inbound.ExpireSubstituteRequestsUseCase; +import com.dreamteam.alter.domain.workspace.port.inbound.GenerateNextMonthWorkspaceShiftUseCase; import com.dreamteam.alter.domain.workspace.port.inbound.WorkspaceScheduleService; import jakarta.annotation.Resource; import lombok.RequiredArgsConstructor; @@ -17,10 +18,20 @@ public class WorkspaceScheduleServiceImpl implements WorkspaceScheduleService { @Resource(name = "expireSubstituteRequests") private final ExpireSubstituteRequestsUseCase expireSubstituteRequests; + @Resource(name = "generateNextMonthWorkspaceShift") + private final GenerateNextMonthWorkspaceShiftUseCase generateNextMonthWorkspaceShift; + @Override @Scheduled(cron = "0 0 0 * * *") @SchedulerLock(name = "expireSubstituteRequests", lockAtMostFor = "5m") public void expireSubstituteRequests() { expireSubstituteRequests.execute(); } + + @Override + @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") + @SchedulerLock(name = "generateNextMonthWorkspaceShifts", lockAtMostFor = "30m") + public void generateNextMonthShiftsFromFixedSchedules() { + generateNextMonthWorkspaceShift.execute(); + } } diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/entity/Workspace.java b/src/main/java/com/dreamteam/alter/domain/workspace/entity/Workspace.java index 5444eab7..9a9a289e 100644 --- a/src/main/java/com/dreamteam/alter/domain/workspace/entity/Workspace.java +++ b/src/main/java/com/dreamteam/alter/domain/workspace/entity/Workspace.java @@ -65,6 +65,11 @@ public class Workspace { @Column(name = "longitude", precision = 9, scale = 6, nullable = false) private BigDecimal longitude; + // 다음 달 고정 근무 자동 생성일 (1~31, 기본값 25) + @Builder.Default + @Column(name = "next_month_shift_gen_day", nullable = false) + private int nextMonthShiftGenDay = 25; + @CreatedDate @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/GenerateNextMonthWorkspaceShiftUseCase.java b/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/GenerateNextMonthWorkspaceShiftUseCase.java new file mode 100644 index 00000000..4bebd3f2 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/GenerateNextMonthWorkspaceShiftUseCase.java @@ -0,0 +1,5 @@ +package com.dreamteam.alter.domain.workspace.port.inbound; + +public interface GenerateNextMonthWorkspaceShiftUseCase { + void execute(); +} diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/WorkspaceScheduleService.java b/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/WorkspaceScheduleService.java index 48fca9d6..58c0b260 100644 --- a/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/WorkspaceScheduleService.java +++ b/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/WorkspaceScheduleService.java @@ -2,4 +2,5 @@ public interface WorkspaceScheduleService { void expireSubstituteRequests(); + void generateNextMonthShiftsFromFixedSchedules(); } diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceQueryRepository.java b/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceQueryRepository.java index b2e3f53e..99b5fac8 100644 --- a/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceQueryRepository.java +++ b/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceQueryRepository.java @@ -73,4 +73,6 @@ List getManagerWorkspaceManagerListWithCurs ); boolean existsByIdAndManagerUser(Long workspaceId, ManagerUser managerUser); + + List findAllByNextMonthShiftGenDay(int day); } diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceShiftRepository.java b/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceShiftRepository.java index ab583b06..2717a36b 100644 --- a/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceShiftRepository.java +++ b/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceShiftRepository.java @@ -1,8 +1,11 @@ package com.dreamteam.alter.domain.workspace.port.outbound; +import java.util.List; + import com.dreamteam.alter.domain.workspace.entity.WorkspaceShift; public interface WorkspaceShiftRepository { WorkspaceShift save(WorkspaceShift shift); + List saveAll(List shifts); void delete(WorkspaceShift shift); } diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceWorkerScheduleQueryRepository.java b/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceWorkerScheduleQueryRepository.java index cd0be073..4f89f9b6 100644 --- a/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceWorkerScheduleQueryRepository.java +++ b/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceWorkerScheduleQueryRepository.java @@ -10,4 +10,5 @@ public interface WorkspaceWorkerScheduleQueryRepository { Optional findById(Long workerScheduleId); Optional getByIdWithWorkspaceWorker(Long workerScheduleId); List getByWorkspaceWorker(WorkspaceWorker workspaceWorker); + List findAllActivatedWithWorkspaceWorkerByWorkspaceIds(List workspaceIds); } diff --git a/src/test/java/com/dreamteam/alter/application/workspace/usecase/GenerateNextMonthWorkspaceShiftTest.java b/src/test/java/com/dreamteam/alter/application/workspace/usecase/GenerateNextMonthWorkspaceShiftTest.java new file mode 100644 index 00000000..6d73384a --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/workspace/usecase/GenerateNextMonthWorkspaceShiftTest.java @@ -0,0 +1,310 @@ +package com.dreamteam.alter.application.workspace.usecase; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.dreamteam.alter.domain.workspace.entity.Workspace; +import com.dreamteam.alter.domain.workspace.entity.WorkspaceShift; +import com.dreamteam.alter.domain.workspace.entity.WorkspaceWorker; +import com.dreamteam.alter.domain.workspace.entity.WorkspaceWorkerSchedule; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceQueryRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceShiftQueryRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceShiftRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceWorkerScheduleQueryRepository; + +@ExtendWith(MockitoExtension.class) +@DisplayName("GenerateNextMonthWorkspaceShift 테스트") +class GenerateNextMonthWorkspaceShiftTest { + + @Mock + private WorkspaceQueryRepository workspaceQueryRepository; + + @Mock + private WorkspaceWorkerScheduleQueryRepository workspaceWorkerScheduleQueryRepository; + + @Mock + private WorkspaceShiftRepository workspaceShiftRepository; + + @Mock + private WorkspaceShiftQueryRepository workspaceShiftQueryRepository; + + @InjectMocks + private GenerateNextMonthWorkspaceShift generateNextMonthWorkspaceShift; + + private MockedStatic mockedLocalDate; + + // 테스트 기준일: 2025년 1월 25일 (기본 생성일 25일에 해당) + private static final LocalDate FIXED_TODAY = LocalDate.of(2025, 1, 25); + + @BeforeEach + void setUp() { + // CALLS_REAL_METHODS: 모든 static 메서드는 실제 구현을 사용하되, now()만 고정값을 반환한다 + mockedLocalDate = mockStatic(LocalDate.class, CALLS_REAL_METHODS); + mockedLocalDate.when(LocalDate::now).thenReturn(FIXED_TODAY); + } + + @AfterEach + void tearDown() { + mockedLocalDate.close(); + } + + @Test + @DisplayName("대상 워크스페이스가 없으면 스케줄 조회 없이 종료한다") + void 대상워크스페이스없음_조기종료() { + // given + when(workspaceQueryRepository.findAllByNextMonthShiftGenDay(25)) + .thenReturn(List.of()); + + // when + generateNextMonthWorkspaceShift.execute(); + + // then + verify(workspaceQueryRepository, times(1)).findAllByNextMonthShiftGenDay(25); + verify(workspaceWorkerScheduleQueryRepository, never()).findAllActivatedWithWorkspaceWorkerByWorkspaceIds(anyList()); + verify(workspaceShiftRepository, never()).saveAll(anyList()); + } + + @Test + @DisplayName("워크스페이스는 있지만 활성화된 고정 스케줄이 없으면 시프트를 생성하지 않는다") + void 활성화된스케줄없음_시프트미생성() { + // given + Workspace workspace = createMockWorkspace(1L); + when(workspaceQueryRepository.findAllByNextMonthShiftGenDay(25)) + .thenReturn(List.of(workspace)); + when(workspaceWorkerScheduleQueryRepository.findAllActivatedWithWorkspaceWorkerByWorkspaceIds(List.of(1L))) + .thenReturn(List.of()); + + // when + generateNextMonthWorkspaceShift.execute(); + + // then + verify(workspaceShiftRepository, never()).saveAll(anyList()); + } + + @Test + @DisplayName("매주 월요일 09:00~18:00 고정 스케줄에 대해 다음 달 시프트를 정상 생성한다") + void 정상스케줄_시프트생성() { + // given + // 2025년 2월은 월요일이 4번: 3일, 10일, 17일, 24일 + Workspace workspace = createMockWorkspace(1L); + WorkspaceWorker worker = createMockWorker(workspace); + WorkspaceWorkerSchedule schedule = createMockSchedule( + worker, DayOfWeek.MONDAY, LocalTime.of(9, 0), DayOfWeek.MONDAY, LocalTime.of(18, 0) + ); + + when(workspaceQueryRepository.findAllByNextMonthShiftGenDay(25)) + .thenReturn(List.of(workspace)); + when(workspaceWorkerScheduleQueryRepository.findAllActivatedWithWorkspaceWorkerByWorkspaceIds(List.of(1L))) + .thenReturn(List.of(schedule)); + when(workspaceShiftQueryRepository.hasConflictingSchedule(any(), any(), any())) + .thenReturn(false); + + // when + generateNextMonthWorkspaceShift.execute(); + + // then - 2월의 월요일 4주분 시프트가 생성되어야 한다 + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(workspaceShiftRepository, times(1)).saveAll(captor.capture()); + + List savedShifts = captor.getValue(); + assert savedShifts.size() == 4 : "2025년 2월에는 월요일이 4번이므로 4개의 시프트가 생성되어야 합니다. 실제: " + savedShifts.size(); + } + + @Test + @DisplayName("이미 확정된 근무와 충돌하는 시간대는 건너뛴다") + void 충돌시간대_건너뜀() { + // given + Workspace workspace = createMockWorkspace(1L); + WorkspaceWorker worker = createMockWorker(workspace); + WorkspaceWorkerSchedule schedule = createMockSchedule( + worker, DayOfWeek.MONDAY, LocalTime.of(9, 0), DayOfWeek.MONDAY, LocalTime.of(18, 0) + ); + + when(workspaceQueryRepository.findAllByNextMonthShiftGenDay(25)) + .thenReturn(List.of(workspace)); + when(workspaceWorkerScheduleQueryRepository.findAllActivatedWithWorkspaceWorkerByWorkspaceIds(List.of(1L))) + .thenReturn(List.of(schedule)); + + // 첫 번째 월요일(2/3)만 충돌, 나머지 3주는 정상 (주 단위 순차 호출이므로 순서 보장됨) + when(workspaceShiftQueryRepository.hasConflictingSchedule(any(), any(), any())) + .thenReturn(true) // 2/3 - 충돌 + .thenReturn(false) // 2/10 - 정상 + .thenReturn(false) // 2/17 - 정상 + .thenReturn(false); // 2/24 - 정상 + + // when + generateNextMonthWorkspaceShift.execute(); + + // then - 충돌 1건 제외하고 3건만 생성 + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(workspaceShiftRepository, times(1)).saveAll(captor.capture()); + + List savedShifts = captor.getValue(); + assert savedShifts.size() == 3 : "충돌 1건을 제외하면 3건이 생성되어야 합니다. 실제: " + savedShifts.size(); + } + + @Test + @DisplayName("야간 근무(금요일 22:00 ~ 토요일 06:00)가 정상적으로 처리된다") + void 야간근무_정상처리() { + // given + Workspace workspace = createMockWorkspace(1L); + WorkspaceWorker worker = createMockWorker(workspace); + // 금요일 22:00 시작 → 토요일 06:00 종료 (야간 근무) + WorkspaceWorkerSchedule schedule = createMockSchedule( + worker, DayOfWeek.FRIDAY, LocalTime.of(22, 0), DayOfWeek.SATURDAY, LocalTime.of(6, 0) + ); + + when(workspaceQueryRepository.findAllByNextMonthShiftGenDay(25)) + .thenReturn(List.of(workspace)); + when(workspaceWorkerScheduleQueryRepository.findAllActivatedWithWorkspaceWorkerByWorkspaceIds(List.of(1L))) + .thenReturn(List.of(schedule)); + when(workspaceShiftQueryRepository.hasConflictingSchedule(any(), any(), any())) + .thenReturn(false); + + // when + generateNextMonthWorkspaceShift.execute(); + + // then - 2025년 2월 금요일: 7일, 14일, 21일, 28일 → 4건 + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(workspaceShiftRepository, times(1)).saveAll(captor.capture()); + + List savedShifts = captor.getValue(); + assert savedShifts.size() == 4 : "2025년 2월에는 금요일이 4번이므로 4개의 시프트가 생성되어야 합니다. 실제: " + savedShifts.size(); + + // 첫 번째 시프트: 금요일 22:00 시작, 토요일 06:00 종료 + WorkspaceShift firstShift = savedShifts.get(0); + assert firstShift.getStartDateTime().equals(LocalDateTime.of(2025, 2, 7, 22, 0)) + : "시작 시간이 2/7 22:00이어야 합니다. 실제: " + firstShift.getStartDateTime(); + assert firstShift.getEndDateTime().equals(LocalDateTime.of(2025, 2, 8, 6, 0)) + : "종료 시간이 2/8 06:00이어야 합니다. 실제: " + firstShift.getEndDateTime(); + } + + @Test + @DisplayName("여러 워크스페이스 중 하나가 실패해도 나머지는 정상 처리된다") + void 일부워크스페이스실패_나머지정상처리() { + // given + Workspace workspace1 = createMockWorkspace(1L); + Workspace workspace2 = createMockWorkspace(2L); + WorkspaceWorker worker2 = createMockWorker(workspace2); + WorkspaceWorkerSchedule schedule2 = createMockSchedule( + worker2, DayOfWeek.TUESDAY, LocalTime.of(10, 0), DayOfWeek.TUESDAY, LocalTime.of(19, 0) + ); + + // 워크스페이스1의 스케줄은 hasConflictingSchedule 호출 시 예외 발생하도록 설정 + WorkspaceWorker worker1 = createMockWorker(workspace1); + WorkspaceWorkerSchedule schedule1 = createMockSchedule( + worker1, DayOfWeek.MONDAY, LocalTime.of(9, 0), DayOfWeek.MONDAY, LocalTime.of(18, 0) + ); + + when(workspaceQueryRepository.findAllByNextMonthShiftGenDay(25)) + .thenReturn(List.of(workspace1, workspace2)); + when(workspaceWorkerScheduleQueryRepository.findAllActivatedWithWorkspaceWorkerByWorkspaceIds(List.of(1L, 2L))) + .thenReturn(List.of(schedule1, schedule2)); + + // 워크스페이스1 처리 시 예외 발생 + when(workspaceShiftQueryRepository.hasConflictingSchedule(eq(worker1), any(), any())) + .thenThrow(new RuntimeException("DB 오류")); + + // 워크스페이스2는 정상 + when(workspaceShiftQueryRepository.hasConflictingSchedule(eq(worker2), any(), any())) + .thenReturn(false); + + // when + generateNextMonthWorkspaceShift.execute(); + + // then - 워크스페이스2의 시프트만 저장되어야 한다 + verify(workspaceShiftRepository, times(1)).saveAll(anyList()); + } + + @Test + @DisplayName("여러 근무자의 고정 스케줄이 한 워크스페이스에 존재할 때 모두 생성된다") + void 복수근무자스케줄_모두생성() { + // given + Workspace workspace = createMockWorkspace(1L); + WorkspaceWorker worker1 = createMockWorker(workspace); + WorkspaceWorker worker2 = createMockWorker(workspace); + + WorkspaceWorkerSchedule schedule1 = createMockSchedule( + worker1, DayOfWeek.MONDAY, LocalTime.of(9, 0), DayOfWeek.MONDAY, LocalTime.of(18, 0) + ); + WorkspaceWorkerSchedule schedule2 = createMockSchedule( + worker2, DayOfWeek.WEDNESDAY, LocalTime.of(10, 0), DayOfWeek.WEDNESDAY, LocalTime.of(19, 0) + ); + + when(workspaceQueryRepository.findAllByNextMonthShiftGenDay(25)) + .thenReturn(List.of(workspace)); + when(workspaceWorkerScheduleQueryRepository.findAllActivatedWithWorkspaceWorkerByWorkspaceIds(List.of(1L))) + .thenReturn(List.of(schedule1, schedule2)); + when(workspaceShiftQueryRepository.hasConflictingSchedule(any(), any(), any())) + .thenReturn(false); + + // when + generateNextMonthWorkspaceShift.execute(); + + // then - 2025년 2월: 월요일 4회 + 수요일 4회 = 8건 + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(workspaceShiftRepository, times(1)).saveAll(captor.capture()); + + List savedShifts = captor.getValue(); + assert savedShifts.size() == 8 : "월요일 4건 + 수요일 4건 = 8건이 생성되어야 합니다. 실제: " + savedShifts.size(); + } + + // --- 헬퍼 메서드 --- + + private Workspace createMockWorkspace(Long id) { + Workspace workspace = mock(Workspace.class); + when(workspace.getId()).thenReturn(id); + return workspace; + } + + private WorkspaceWorker createMockWorker(Workspace workspace) { + WorkspaceWorker worker = mock(WorkspaceWorker.class); + when(worker.getWorkspace()).thenReturn(workspace); + return worker; + } + + private WorkspaceWorkerSchedule createMockSchedule( + WorkspaceWorker worker, + DayOfWeek startDayOfWeek, + LocalTime startTime, + DayOfWeek endDayOfWeek, + LocalTime endTime + ) { + WorkspaceWorkerSchedule schedule = mock(WorkspaceWorkerSchedule.class); + when(schedule.getWorkspaceWorker()).thenReturn(worker); + when(schedule.getStartDayOfWeek()).thenReturn(startDayOfWeek); + when(schedule.getStartTime()).thenReturn(startTime); + when(schedule.getEndDayOfWeek()).thenReturn(endDayOfWeek); + when(schedule.getEndTime()).thenReturn(endTime); + return schedule; + } +}