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
@@ -0,0 +1,95 @@
package com.kernellabs.kernellabs.application;

import com.kernellabs.kernellabs.application.validator.ReservationValidator;
import com.kernellabs.kernellabs.domain.Place;
import com.kernellabs.kernellabs.domain.Reservation;
import com.kernellabs.kernellabs.global.exception.CustomException;
import com.kernellabs.kernellabs.global.exception.ErrorCode;
import com.kernellabs.kernellabs.infrastructure.repository.PlaceRepository;
import com.kernellabs.kernellabs.infrastructure.repository.ReservationRepository;
import com.kernellabs.kernellabs.presentation.dto.request.ReservationDeleteRequest;
import com.kernellabs.kernellabs.presentation.dto.request.ReservationRequest;
import com.kernellabs.kernellabs.presentation.dto.request.ReservationUpdateRequest;
import com.kernellabs.kernellabs.presentation.dto.response.ReservationResponse;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Transactional(readOnly = true)
@Service
@RequiredArgsConstructor
public class ReservationService {
private final ReservationRepository reservationRepository;
private final PlaceRepository placeRepository;
private final ReservationValidator reservationValidator;

@Transactional
public ReservationResponse createReservation(ReservationRequest request) {
// 1. 장소 조회
Place place = placeRepository.findById(request.getPlaceId())
.orElseThrow(() -> new CustomException(ErrorCode.PLACE_NOT_FOUND));

// 2. 유효성 검증
reservationValidator.validateForCreate(place, request);

// 3. 엔티티 생성
Reservation reservation = Reservation.create(place, request.getPassword(),
request.getReservationDate(), request.getTimeSlots());

// 4. 예약 저장 및 응답 반환
reservationRepository.save(reservation);
return ReservationResponse.from(reservation);
}

public ReservationResponse getReservation(Long reservationId) {
Reservation reservation = findReservationById(reservationId);
return ReservationResponse.from(reservation);
}

@Transactional
public ReservationResponse updateReservation(Long reservationId, ReservationUpdateRequest request) {
// 1. 예약 조회 및 비밀번호 확인
Reservation reservation = findReservationById(reservationId);
validatePassword(request.getPassword(), reservation.getPassword());

// 2. 변경 요청 유효성 검사
reservationValidator.validateForUpdate(reservation, request);

// 3. 엔티티 상태 변경
reservation.updateTimes(request.getNewReservationDate(), parseStartTime(request.getNewTimeSlots()), parseEndTime(request.getNewTimeSlots()));
return ReservationResponse.from(reservation);
}

@Transactional
public void deleteReservation(Long reservationId, ReservationDeleteRequest request) {
// 1. 예약 조회 및 비밀번호 확인
Reservation reservation = findReservationById(reservationId);
validatePassword(request.getPassword(), reservation.getPassword());

// 2. 예약 삭제
reservationRepository.delete(reservation);
}

private LocalTime parseStartTime(List<String> timeSlots) {
return LocalTime.parse(timeSlots.get(0), DateTimeFormatter.ofPattern("HH:mm"));
}

private LocalTime parseEndTime(List<String> timeSlots) {
return LocalTime.parse(timeSlots.get(timeSlots.size() - 1), DateTimeFormatter.ofPattern("HH:mm")).plusHours(1);
}

private void validatePassword(String rawPassword, String storedPassword) {
if (!rawPassword.equals(storedPassword)) {
throw new CustomException(ErrorCode.INVALID_PASSWORD);
}
}

private Reservation findReservationById(Long reservationId) {
return reservationRepository.findById(reservationId)
.orElseThrow(() -> new CustomException(ErrorCode.PLACE_NOT_FOUND));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package com.kernellabs.kernellabs.application.validator;

import com.kernellabs.kernellabs.domain.Place;
import com.kernellabs.kernellabs.domain.PlaceUnavailableDay;
import com.kernellabs.kernellabs.domain.Reservation;
import com.kernellabs.kernellabs.global.exception.CustomException;
import com.kernellabs.kernellabs.global.exception.ErrorCode;
import com.kernellabs.kernellabs.infrastructure.repository.PlaceUnavailableDayRepository;
import com.kernellabs.kernellabs.infrastructure.repository.ReservationRepository;
import com.kernellabs.kernellabs.presentation.dto.request.ReservationRequest;
import com.kernellabs.kernellabs.presentation.dto.request.ReservationUpdateRequest;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class ReservationValidator {

private final ReservationRepository reservationRepository;
private final PlaceUnavailableDayRepository unavailableDayRepository;
private final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm");

public void validateForCreate(Place place, ReservationRequest request) {
// 1. 시간 슬롯 자체의 유효성 검증 (포맷, 연속성)
validateTimeSlots(request.getTimeSlots());

LocalTime requestStartTime = LocalTime.parse(request.getTimeSlots().get(0), TIME_FORMATTER);
LocalTime requestEndTime = LocalTime.parse(request.getTimeSlots().get(request.getTimeSlots().size() - 1), TIME_FORMATTER).plusHours(1);

// 2. 운영 시간 내의 요청인지 검증
validateAgainstOperatingHours(place, request.getReservationDate(), requestStartTime, requestEndTime);

// 3. 중복 예약이 없는지 검증
validateNoOverlappingReservations(place.getId(), request.getReservationDate(), requestEndTime, requestStartTime);
}

public void validateForUpdate(Reservation reservation, ReservationUpdateRequest request) {
LocalDate newDate = request.getNewReservationDate();
List<String> newTimeSlots = request.getNewTimeSlots();
validateTimeSlots(newTimeSlots);

LocalTime newStartTime = LocalTime.parse(request.getNewTimeSlots().get(0), TIME_FORMATTER);
LocalTime newEndTime = LocalTime.parse(request.getNewTimeSlots().get(request.getNewTimeSlots().size() - 1), TIME_FORMATTER).plusHours(1);

validateAgainstOperatingHours(reservation.getPlace(), newDate, newStartTime, newEndTime);
// 자기 자신 제외하고 중복 검사
validateNoOverlappingForUpdate(reservation, newDate, newEndTime, newStartTime); }

// 시간 슬롯의 포맷과 연속성 검증
private void validateTimeSlots(List<String> timeSlots) {
if (timeSlots == null || timeSlots.isEmpty()) {
throw new CustomException(ErrorCode.TIME_SLOTS_EMPTY);
}
Collections.sort(timeSlots);

for (int i = 0; i < timeSlots.size() - 1; i++) {
LocalTime current = LocalTime.parse(timeSlots.get(i), TIME_FORMATTER);
LocalTime next = LocalTime.parse(timeSlots.get(i + 1), TIME_FORMATTER);
if (!current.plusHours(1).equals(next)) {
throw new CustomException(ErrorCode.INVALID_TIME_SLOT_SEQUENCE);
}
}
}

// 해당 날짜의 실제 운영 시간 기준으로 요청이 유효한지 검증
private void validateAgainstOperatingHours(Place place, LocalDate date, LocalTime requestStartTime, LocalTime requestEndTime) {
OperatingHours operatingHours = getOperatingHoursFor(place, date);

if (requestStartTime.isBefore(operatingHours.openTime()) || requestEndTime.isAfter(operatingHours.closeTime())) {
throw new CustomException(ErrorCode.INVALID_RESERVATION_TIME);
}
}

// 중복 예약 검증
private void validateNoOverlappingReservations(Long placeId, LocalDate date, LocalTime endTime, LocalTime startTime) {
if (reservationRepository.existsByPlaceIdAndReservationDateAndStartTimeBeforeAndEndTimeAfter(placeId, date, endTime, startTime)) {
throw new CustomException(ErrorCode.RESERVATION_ALREADY_EXISTS);
}
}

// 특정 날짜의 실제 운영 시간 계산
private OperatingHours getOperatingHoursFor(Place place, LocalDate date) {
Optional<PlaceUnavailableDay> unavailableDayOpt = unavailableDayRepository.findByPlaceIdAndUnavailableDate(place.getId(), date);

if (unavailableDayOpt.isPresent()) {
PlaceUnavailableDay unavailableDay = unavailableDayOpt.get();
if (unavailableDay.getStartTimeOverride() == null) {
throw new CustomException(ErrorCode.RESERVATION_NOT_POSSIBLE_ON_DAY);
}
return new OperatingHours(unavailableDay.getStartTimeOverride(), unavailableDay.getEndTimeOverride());
} else {
return new OperatingHours(place.getOpenTime(), place.getCloseTime());
}
}

// 운영 시간을 담는 간단한 레코드
private record OperatingHours(LocalTime openTime, LocalTime closeTime) {}

// 자기 자신을 제외하고 중복 예약을 확인하는 메서드
private void validateNoOverlappingForUpdate(Reservation reservation, LocalDate newDate, LocalTime newEndTime, LocalTime newStartTime) {
if (reservationRepository.existsByPlaceIdAndReservationDateAndIdNotAndStartTimeBeforeAndEndTimeAfter(
reservation.getPlace().getId(),
newDate,
reservation.getId(),
newEndTime,
newStartTime
)) {
throw new CustomException(ErrorCode.RESERVATION_ALREADY_EXISTS);
}
}
}
Empty file.
57 changes: 57 additions & 0 deletions src/main/java/com/kernellabs/kernellabs/domain/Place.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.kernellabs.kernellabs.domain;

import com.kernellabs.kernellabs.global.common.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import java.time.LocalTime;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Place extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false, unique = true)
private String name;

@Column(nullable = false)
private String address;

@Column(nullable = false)
private LocalTime openTime;

@Column(nullable = false)
private LocalTime closeTime;

@Column
private String thumbnailUrl;

@Lob
private String description;

@Column
private Integer unitPrice;

@Builder
public Place(String name, String address, LocalTime openTime, LocalTime closeTime, String thumbnailUrl, String description, Integer unitPrice) {
this.name = name;
this.address = address;
this.openTime = openTime;
this.closeTime = closeTime;
this.thumbnailUrl = thumbnailUrl;
this.description = description;
this.unitPrice = unitPrice;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.kernellabs.kernellabs.domain;

import com.kernellabs.kernellabs.global.common.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import java.time.LocalDate;
import java.time.LocalTime;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PlaceUnavailableDay extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "place_id", nullable = false)
private Place place;

@Column(nullable = false)
private LocalDate unavailableDate;

private String reason;

// 해당 날짜에 특별히 적용할 운영 시간 (null이면 장소의 기본 시간을 따름)
private LocalTime startTimeOverride;
private LocalTime endTimeOverride;

@Builder
public PlaceUnavailableDay(Place place, LocalDate unavailableDate, String reason, LocalTime startTimeOverride, LocalTime endTimeOverride) {
this.place = place;
this.unavailableDate = unavailableDate;
this.reason = reason;
this.startTimeOverride = startTimeOverride;
this.endTimeOverride = endTimeOverride;
}
}
75 changes: 75 additions & 0 deletions src/main/java/com/kernellabs/kernellabs/domain/Reservation.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.kernellabs.kernellabs.domain;

import com.kernellabs.kernellabs.global.common.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Reservation extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "place_id", nullable = false)
private Place place;

@Column(nullable = false)
private String password;

@Column(nullable = false)
private LocalDate reservationDate;

@Column(nullable = false)
private LocalTime startTime;

@Column(nullable = false)
private LocalTime endTime;

@Builder
public Reservation(Place place, String password, LocalDate reservationDate, LocalTime startTime, LocalTime endTime) {
this.place = place;
this.password = password;
this.reservationDate = reservationDate;
this.startTime = startTime;
this.endTime = endTime;
}

public static Reservation create(Place place, String password, LocalDate date, List<String> timeSlots) {
DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("HH:mm");
LocalTime startTime = LocalTime.parse(timeSlots.get(0), timeFormatter);
LocalTime endTime = LocalTime.parse(timeSlots.get(timeSlots.size() - 1), timeFormatter).plusHours(1);

return Reservation.builder()
.place(place)
.password(password)
.reservationDate(date)
.startTime(startTime)
.endTime(endTime)
.build();
}

public void updateTimes(LocalDate newDate, LocalTime newStartTime, LocalTime newEndTime) {
this.reservationDate = newDate;
this.startTime = newStartTime;
this.endTime = newEndTime;
}

}
Loading