Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
114294f
refactor: 열차 조회 Controller가 ApplicationService를 통해 접근하도록 수정 (#201)
Ogu1208 Aug 5, 2025
dffe89b
refactor: 추천 객차 선택 로직의 가독성 개선 (동작 동일) (#201)
Ogu1208 Aug 5, 2025
eacd679
test: TrainSearchApplicationService 테스트코드 작성 (각 메서드, 전체 플로우 시나리오) (#201)
Ogu1208 Aug 5, 2025
724f3c3
test: trainTestHelper, ReservationTestHelper에 커스텀 데이터 생성 메서드 추가 (#201)
Ogu1208 Aug 6, 2025
c3b3f45
test: 출발 시간 조건에 따라 해당 시간 이후 출발하는 열차들이 시간순으로 필터링되는지 확인 테스트 (#201)
Ogu1208 Aug 6, 2025
c31ba19
feat: 좌석 상태에서 AVAILABLE, LIMITED 기준을 전체 좌석의 25% 비율 기준으로 판단하도록 수정 (#201)
Ogu1208 Aug 6, 2025
8ead284
feat: 입석 가능 여부 판단 로직 수정 : 일반실 예약 불가능 && 입석 요청 인원 수용 가능 (#201)
Ogu1208 Aug 6, 2025
41a87f0
test: 다양한 기존 예약 상태에 따라 좌석 상태와 입석 정보의 표시 여부 검증 (#201)
Ogu1208 Aug 6, 2025
2da5e47
test: 열차 조회 - 입석 관련 좌석 및 상태 계산 , standingInfo 검증 테스트 (#201)
Ogu1208 Aug 6, 2025
25d9f21
test: 열차 조회 - 비즈니스적으로 잘못된 검색 조건일 경우 예외 발생 테스트 (#201)
Ogu1208 Aug 6, 2025
02eaf17
test: 열차 조회 - TrainSearchRequestTest DTO Validation 테스트 (#201)
Ogu1208 Aug 6, 2025
65307da
test: 다양한 검색 시나리오에서 검색 결과가 없을 경우 빈 리스트를 반환을 검증하는 테스트코드 작성 (#201)
Ogu1208 Aug 6, 2025
290c49a
test: 열차 조회 - 다구간으로 구간 예약 겹침(overlap)만 반영해 잔여 좌석을 정확히 계산하는지 테스트 (#201)
Ogu1208 Aug 6, 2025
ba0e6c7
test: 열차 조회 - 존재하지 않는 스케줄 ID 조회 오류, 페이징이 올바르게 동작(중복, 정렬)하는지 테스트(#201)
Ogu1208 Aug 6, 2025
baef7c9
test: 열차 조회 - 조회하는 구간의 요금 정보가 없는 경우 예외 발생 테스트 작성 (#201)
Ogu1208 Aug 6, 2025
6b1e5b5
test: TrainTestHelper 코드 충돌 해결 (#201)
Ogu1208 Aug 7, 2025
5f92551
test: 열차 조회 - 사용하지 않는 메서드 및 의존성 주입 삭제 (#201)
Ogu1208 Aug 7, 2025
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,15 +2,19 @@

import java.util.List;

import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.sudo.railo.train.application.dto.TrainScheduleBasicInfo;
import com.sudo.railo.train.application.dto.request.TrainCarListRequest;
import com.sudo.railo.train.application.dto.request.TrainCarSeatDetailRequest;
import com.sudo.railo.train.application.dto.request.TrainSearchRequest;
import com.sudo.railo.train.application.dto.response.OperationCalendarItem;
import com.sudo.railo.train.application.dto.response.TrainCarInfo;
import com.sudo.railo.train.application.dto.response.TrainCarListResponse;
import com.sudo.railo.train.application.dto.response.TrainCarSeatDetailResponse;
import com.sudo.railo.train.application.dto.response.TrainSearchSlicePageResponse;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -24,6 +28,24 @@ public class TrainSearchApplicationService {
private final TrainSearchService trainSearchService;
private final TrainSeatQueryService trainCarService;

/**
* 운행 캘린더 조회
*/
public List<OperationCalendarItem> getOperationCalendar() {
return trainSearchService.getOperationCalendar();
}

/**
* 통합 열차 조회 (열차 스케줄 검색)
*/
public TrainSearchSlicePageResponse searchTrains(TrainSearchRequest request, Pageable pageable) {
TrainSearchSlicePageResponse response = trainSearchService.searchTrains(request, pageable);

log.info("열차 검색 완료: {} 건 조회, hasNext: {}", response.numberOfElements(), response.hasNext());

return response;
}

/**
* 열차 객차 목록 조회 (잔여 좌석이 있는 객차만)
*/
Expand All @@ -47,6 +69,7 @@ public TrainCarListResponse getAvailableTrainCars(TrainCarListRequest request) {
scheduleInfo.trainClassificationCode(), scheduleInfo.trainNumber());

return TrainCarListResponse.of(
request.trainScheduleId(),
recommendedCarNumber,
availableCars.size(),
scheduleInfo.trainClassificationCode(),
Expand All @@ -73,12 +96,17 @@ public TrainCarSeatDetailResponse getTrainCarSeatDetail(TrainCarSeatDetailReques
* TODO: 조금 더 고도화된 객차 추천 알고리즘 필요
*/
private String selectRecommendedCar(List<TrainCarInfo> availableCars, int passengerCount) {
// 승객 수보다 잔여 좌석이 많은 객차 중에서 중간 위치 선택
return availableCars.stream()
// 승객 수보다 잔여 좌석이 많은 객차 필터링
List<TrainCarInfo> suitableCars = availableCars.stream()
.filter(car -> car.remainingSeats() >= passengerCount)
.skip(availableCars.size() / 2) // 중간 객차 선택
.findFirst()
.map(TrainCarInfo::carNumber)
.orElse(availableCars.get(0).carNumber()); // 없으면 첫 번째 객차
.toList();

// 적합한 객차가 있으면 중간 위치 선택, 없으면 첫 번째 객차
if (!suitableCars.isEmpty()) {
int middleIndex = suitableCars.size() / 2;
return suitableCars.get(middleIndex).carNumber();
}

return availableCars.get(0).carNumber();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public class TrainSearchService {

/**
* 운행 캘린더 조회
* @return
* @return List<OperationCalendarItem>
*/
public List<OperationCalendarItem> getOperationCalendar() {
LocalDate startDate = LocalDate.now();
Expand All @@ -78,7 +78,7 @@ public List<OperationCalendarItem> getOperationCalendar() {
})
.toList();

log.info("운행 캘린더 조회 완료: {} ~ {} ({} 일), 운행일수: {}",
log.info("운행 캘린더 조회 : {} ~ {} ({} 일), 운행일수: {}",
startDate, endDate, calendar.size(), datesWithSchedule.size());

return calendar;
Expand Down Expand Up @@ -140,7 +140,7 @@ public TrainSearchSlicePageResponse searchTrains(TrainSearchRequest request, Pag
List<TrainSearchResponse> trainSearchResults = processTrainSearchResults(trainInfoSlice.getContent(), fare,
request);

log.info("Slice 기반 열차 조회 완료: {}건 조회, hasNext: {}", trainSearchResults.size(), trainInfoSlice.hasNext());
log.info("Slice 기반 열차 조회: {}건 조회, hasNext: {}", trainSearchResults.size(), trainInfoSlice.hasNext());

return createTrainSearchPageResponse(trainSearchResults, trainInfoSlice);
}
Expand Down Expand Up @@ -281,8 +281,9 @@ private TrainSearchSlicePageResponse createTrainSearchPageResponse(List<TrainSea
private TrainSearchResponse createTrainSearchResponse(TrainBasicInfo trainInfo, SectionSeatStatus sectionStatus,
StationFare fare, int passengerCount) {

// 입석 가능 여부 (일반실이 매진되었을 때만 입석 표시)
boolean hasStandingForStandard = !sectionStatus.canReserveStandard();
// 입석 가능 여부 (일반실이 예약 불가능하고 입석이 요청 인원을 수용 가능한 경우)
boolean hasStandingForStandard = !sectionStatus.canReserveStandard()
&& sectionStatus.canReserveStanding(passengerCount, standingRatio);

// 1. 좌석 타입별 정보 생성 (일반실 / 특실)
SeatTypeInfo standardSeatInfo = SeatTypeInfo.create(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ public static SeatTypeInfo create(int availableSeats,
boolean hasStandingOption,
boolean canReserve) {

SeatAvailabilityStatus status = determineSeatStatus(availableSeats, passengerCount, hasStandingOption);
SeatAvailabilityStatus status = determineSeatStatus(availableSeats, passengerCount, hasStandingOption,
totalSeats);

String displayText = createDisplayText(status, seatTypeName);
String description = createDescription(status, availableSeats, passengerCount);
Expand All @@ -49,18 +50,22 @@ public static SeatTypeInfo create(int availableSeats,
* 좌석 수와 승객 수로 예약 가능한 좌석 상태 결정
*/
private static SeatAvailabilityStatus determineSeatStatus(int availableSeats, int passengerCount,
boolean hasStandingOption) {
boolean hasStandingOption, int totalSeats) {
if (availableSeats == 0) {
// 좌석은 매진이지만 입석이 가능한 경우
return hasStandingOption ? SeatAvailabilityStatus.STANDING_ONLY : SeatAvailabilityStatus.SOLD_OUT;
}

// 좌석 부족
if (availableSeats < passengerCount) {
return SeatAvailabilityStatus.INSUFFICIENT;
// 입석이 가능하다면 STANDING_ONLY, 불가능하다면 INSUFFICIENT
return hasStandingOption ? SeatAvailabilityStatus.STANDING_ONLY : SeatAvailabilityStatus.INSUFFICIENT;
}

if (availableSeats >= passengerCount + 20) {
return SeatAvailabilityStatus.AVAILABLE;
double availabilityRatio = (double)availableSeats / totalSeats;

if (availabilityRatio >= 0.25) {
return SeatAvailabilityStatus.AVAILABLE; // 25% 이상이면 여유
} else {
return SeatAvailabilityStatus.LIMITED;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
@Schema(description = "열차 객차 목록 조회 응답")
public record TrainCarListResponse(

@Schema(description = "열차 스케줄 ID", example = "26")
Long trainScheduleId,

@Schema(description = "AI가 추천하는 최적 객차 번호 (승객수, 위치 고려)", example = "14")
String recommendedCarNumber,

Expand All @@ -25,10 +28,10 @@ public record TrainCarListResponse(
@Schema(description = "좌석 선택 가능한 객차 정보 목록")
List<TrainCarInfo> carInfos
) {
public static TrainCarListResponse of(String recommendedCarNumber, int totalCarCount,
public static TrainCarListResponse of(Long trainScheduleId, String recommendedCarNumber, int totalCarCount,
String trainClassificationCode, String trainNumber,
List<TrainCarInfo> carInfos) {
return new TrainCarListResponse(recommendedCarNumber, totalCarCount,
return new TrainCarListResponse(trainScheduleId, recommendedCarNumber, totalCarCount,
trainClassificationCode, trainNumber, carInfos);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ private static void validateTrainSearchData(Long trainScheduleId, String trainNu
/**
* 입석 정보 존재 여부
*/
public boolean hasStanding() {
public boolean hasStandingInfo() {
return standing != null;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ private void validateOperationDate(TrainSearchRequest request) {
}
}

/**
* 예약 날짜가 오늘(date)이면, 시(hour) 단위로만 비교
*/
private void validateDepartureTime(TrainSearchRequest request) {
if (request.operationDate().equals(LocalDate.now())) {
int requestHour = Integer.parseInt(request.departureHour());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public enum TrainErrorCode implements ErrorCode {
TRAIN_SCHEDULE_NOT_FOUND("해당 날짜에 운행하는 열차가 없습니다.", HttpStatus.NOT_FOUND, "T4001"),
TRAIN_OPERATION_CANCELLED("해당 열차는 운행이 취소되었습니다.", HttpStatus.BAD_REQUEST, "T4002"),
TRAIN_OPERATION_DELAYED("해당 열차는 지연 운행 중입니다.", HttpStatus.BAD_REQUEST, "T4003"),
TRAIN_SCHEDULE_DETAIL_NOT_FOUND("요청하신 열차 스케줄이 존재하지 않습니다.", HttpStatus.NOT_FOUND, "T4004"),
TRAIN_SCHEDULE_DETAIL_NOT_FOUND("해당 열차 스케줄을 찾을 수 없습니다.", HttpStatus.NOT_FOUND, "T4004"),
NO_AVAILABLE_CARS("잔여 좌석이 있는 객차가 없습니다.", HttpStatus.NOT_FOUND, "TR_4005"),
TRAIN_CAR_NOT_FOUND("해당 객차를 찾을 수 없습니다.", HttpStatus.NOT_FOUND, "TR_4006"),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@
public interface TrainCarRepository extends JpaRepository<TrainCar, Long> {

List<TrainCar> findByTrainIn(Collection<Train> trains);

List<TrainCar> findAllByTrainId(Long trainId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public class TrainSearchController {
@Operation(summary = "운행 캘린더 조회", description = "금일로부터 한 달간의 운행 캘린더를 조회합니다.")
public SuccessResponse<List<OperationCalendarItem>> getOperationCalendar() {
log.info("운행 캘린더 조회");
List<OperationCalendarItem> calendar = trainSearchService.getOperationCalendar();
List<OperationCalendarItem> calendar = trainSearchApplicationService.getOperationCalendar();
log.info("운행 캘린더 조회: {} 건", calendar.size());

return SuccessResponse.of(TrainSearchSuccess.OPERATION_CALENDAR_SUCCESS, calendar);
Expand All @@ -70,7 +70,7 @@ public SuccessResponse<TrainSearchSlicePageResponse> searchTrainSchedules(
request.operationDate(), request.passengerCount(), request.departureHour(),
pageable.getPageNumber(), pageable.getPageSize());

TrainSearchSlicePageResponse response = trainSearchService.searchTrains(request, pageable);
TrainSearchSlicePageResponse response = trainSearchApplicationService.searchTrains(request, pageable);

return SuccessResponse.of(TrainSearchSuccess.TRAIN_SEARCH_SUCCESS, response);
}
Expand Down
112 changes: 110 additions & 2 deletions src/test/java/com/sudo/railo/support/helper/ReservationTestHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.util.List;

import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import com.sudo.railo.booking.domain.Reservation;
import com.sudo.railo.booking.domain.SeatReservation;
Expand All @@ -20,7 +21,6 @@
import com.sudo.railo.train.domain.Train;
import com.sudo.railo.train.domain.type.CarType;

import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;

@Component
Expand All @@ -35,7 +35,8 @@ public class ReservationTestHelper {
/**
* 기본 예약 생성 메서드
*/
public Reservation createReservation(Member member, TrainScheduleWithStopStations scheduleWithStops) {
public Reservation createReservation(Member member,
TrainScheduleWithStopStations scheduleWithStops) {
Reservation reservation = Reservation.builder()
.trainSchedule(scheduleWithStops.trainSchedule())
.member(member)
Expand All @@ -55,6 +56,78 @@ public Reservation createReservation(Member member, TrainScheduleWithStopStation
return reservation;
}

/**
* 특정 좌석 ID들에 대한 좌석 예약 생성 (출발, 도착 정차역 직접 지정)
*
* @param member 예약할 회원
* @param scheduleWithStops 열차 스케줄 및 정차역 정보
* @param departureStop 출발 정차역
* @param arrivalStop 도착 정차역
* @param seatIds 예약할 좌석 ID 목록
* @param passengerType 승객 유형 (성인, 어린이 등)
* @return 생성된 Reservation 객체
*/
public Reservation createReservationWithSeatIds(Member member,
TrainScheduleWithStopStations scheduleWithStops,
ScheduleStop departureStop,
ScheduleStop arrivalStop,
List<Long> seatIds,
PassengerType passengerType) {

Reservation reservation = Reservation.builder()
.trainSchedule(scheduleWithStops.trainSchedule())
.member(member)
.reservationCode("SEAT-" + System.currentTimeMillis())
.tripType(TripType.OW)
.totalPassengers(seatIds.size())
.passengerSummary("[{\"passengerType\":\"" + passengerType.name() + "\",\"count\":" + seatIds.size() + "}]")
.reservationStatus(ReservationStatus.RESERVED)
.expiresAt(LocalDateTime.now().plusMinutes(10))
.fare(50000 * seatIds.size())
.departureStop(departureStop)
.arrivalStop(arrivalStop)
.build();

reservationRepository.save(reservation);
createSeatReservations(reservation, seatIds, passengerType);
return reservation;
}

/**
* 지정한 인원 수만큼 입석 예약 생성 (출발, 도착 정차역 직접 지정, seat 없이)
*
* @param member 예약할 회원
* @param scheduleWithStops 열차 스케줄 및 정차역 정보
* @param departureStop 출발 정차역
* @param arrivalStop 도착 정차역
* @param standingCount 입석 인원 수
* @return 생성된 Reservation 객체
*/
public Reservation createStandingReservation(Member member,
TrainScheduleWithStopStations scheduleWithStops,
ScheduleStop departureStop,
ScheduleStop arrivalStop,
int standingCount) {

Reservation reservation = Reservation.builder()
.trainSchedule(scheduleWithStops.trainSchedule())
.member(member)
.reservationCode("STANDING-" + System.currentTimeMillis())
.tripType(TripType.OW)
.totalPassengers(standingCount)
.passengerSummary("[{\"passengerType\":\"ADULT\",\"count\":" + standingCount + "}]")
.reservationStatus(ReservationStatus.RESERVED)
.expiresAt(LocalDateTime.now().plusMinutes(10))
.fare((int)(50000 * 0.85) * standingCount)
.departureStop(departureStop)
.arrivalStop(arrivalStop)
.build();

reservationRepository.save(reservation);
createStandingSeatReservations(reservation, standingCount);
return reservation;
}

/**
* 좌석 예약 생성 메서드
*/
Expand All @@ -74,6 +147,41 @@ private void createSeatReservation(Reservation reservation) {
seatReservationRepository.saveAll(seatReservations);
}

/**
* 주어진 좌석 ID들로 SeatReservation 생성
*/
private void createSeatReservations(Reservation reservation, List<Long> seatIds, PassengerType passengerType) {
List<Seat> seats = trainTestHelper.getSeatsByIds(seatIds);

List<SeatReservation> seatReservations = seats.stream()
.map(seat -> SeatReservation.builder()
.trainSchedule(reservation.getTrainSchedule())
.seat(seat)
.reservation(reservation)
.passengerType(passengerType)
.build())
.toList();

seatReservationRepository.saveAll(seatReservations);
}

/**
* Seat=null로 승객수만큼 입석 SeatReservations 생성
*/
private void createStandingSeatReservations(Reservation reservation, int count) {
List<SeatReservation> seatReservations =
java.util.stream.IntStream.range(0, count)
.mapToObj(i -> SeatReservation.builder()
.trainSchedule(reservation.getTrainSchedule())
.seat(null) // 입석 처리
.reservation(reservation)
.passengerType(PassengerType.ADULT)
.build())
.toList();

seatReservationRepository.saveAll(seatReservations);
}

private ScheduleStop getDepartureStop(List<ScheduleStop> scheduleStops) {
if (scheduleStops.isEmpty()) {
throw new IllegalArgumentException("출발역을 찾을 수 없습니다.");
Expand Down
Loading
Loading