package roomescape.reservation.business import com.github.f4b6a3.tsid.TsidFactory import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.data.jpa.domain.Specification import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import roomescape.common.config.next import roomescape.member.business.MemberService import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.reservation.exception.ReservationErrorCode import roomescape.reservation.exception.ReservationException import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.reservation.infrastructure.persistence.ReservationRepository import roomescape.reservation.infrastructure.persistence.ReservationSearchSpecification import roomescape.reservation.infrastructure.persistence.ReservationStatus import roomescape.reservation.web.* import roomescape.theme.business.ThemeService import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.time.business.TimeService import roomescape.time.infrastructure.persistence.TimeEntity import java.time.LocalDate import java.time.LocalDateTime private val log = KotlinLogging.logger {} @Service @Transactional class ReservationService( private val tsidFactory: TsidFactory, private val reservationRepository: ReservationRepository, private val timeService: TimeService, private val memberService: MemberService, private val themeService: ThemeService, ) { @Transactional(readOnly = true) fun findReservations(): ReservationRetrieveListResponse { val spec: Specification = ReservationSearchSpecification() .confirmed() .build() val reservations = findAllReservationByStatus(spec) log.info { "[ReservationService.findReservations] ${reservations.size} 개의 확정 예약 조회 완료" } return ReservationRetrieveListResponse(reservations) } @Transactional(readOnly = true) fun findAllWaiting(): ReservationRetrieveListResponse { val spec: Specification = ReservationSearchSpecification() .waiting() .build() val reservations = findAllReservationByStatus(spec) log.info { "[ReservationService.findAllWaiting] ${reservations.size} 개의 대기 예약 조회 완료" } return ReservationRetrieveListResponse(reservations) } private fun findAllReservationByStatus(spec: Specification): List { return reservationRepository.findAll(spec).map { it.toRetrieveResponse() } } fun deleteReservation(reservationId: Long, memberId: Long) { validateIsMemberAdmin(memberId, "deleteReservation") log.debug { "[ReservationService.deleteReservation] 예약 삭제 시작: reservationId=$reservationId, memberId=$memberId" } reservationRepository.deleteById(reservationId) log.info { "[ReservationService.deleteReservation] 예약 삭제 완료: reservationId=$reservationId" } } fun createConfirmedReservation( request: ReservationCreateWithPaymentRequest, memberId: Long, ): ReservationEntity { val themeId = request.themeId val timeId = request.timeId val date: LocalDate = request.date validateIsReservationExist(themeId, timeId, date, "createConfirmedReservation") log.debug { "[ReservationService.createConfirmedReservation] 예약 추가 시작: memberId=$memberId, themeId=${request.themeId}, timeId=${request.timeId}, date=${request.date}" } val reservation: ReservationEntity = createEntity(timeId, themeId, date, memberId, ReservationStatus.CONFIRMED) return reservationRepository.save(reservation) .also { log.info { "[ReservationService.createConfirmedReservation] 예약 추가 완료: reservationId=${it.id}, status=${it.status}" } } } fun createReservationByAdmin(request: AdminReservationCreateRequest): ReservationRetrieveResponse { validateIsReservationExist(request.themeId, request.timeId, request.date) log.debug { "[ReservationService.createReservationByAdmin] 관리자의 예약 추가: memberId=${request.memberId}, themeId=${request.themeId}, timeId=${request.timeId}, date=${request.date}" } return addReservationWithoutPayment( request.themeId, request.timeId, request.date, request.memberId, ReservationStatus.CONFIRMED_PAYMENT_REQUIRED ).also { log.info { "[ReservationService.createReservationByAdmin] 관리자 예약 추가 완료: reservationId=${it.id}" } } } fun createWaiting(request: WaitingCreateRequest, memberId: Long): ReservationRetrieveResponse { validateMemberAlreadyReserve(request.themeId, request.timeId, request.date, memberId) log.debug { "[ReservationService.createWaiting] 예약 대기 추가 시작: memberId=$memberId, themeId=${request.themeId}, timeId=${request.timeId}, date=${request.date}" } return addReservationWithoutPayment( request.themeId, request.timeId, request.date, memberId, ReservationStatus.WAITING ).also { log.info { "[ReservationService.createWaiting] 예약 대기 추가 완료: reservationId=${it.id}, status=${it.status}" } } } private fun addReservationWithoutPayment( themeId: Long, timeId: Long, date: LocalDate, memberId: Long, status: ReservationStatus, ): ReservationRetrieveResponse = createEntity(timeId, themeId, date, memberId, status) .also { reservationRepository.save(it) }.toRetrieveResponse() private fun validateMemberAlreadyReserve(themeId: Long, timeId: Long, date: LocalDate, memberId: Long) { log.debug { "[ReservationService.validateMemberAlreadyReserve] 회원의 중복 예약 여부 확인: themeId=$themeId, timeId=$timeId, date=$date, memberId=$memberId" } val spec: Specification = ReservationSearchSpecification() .sameMemberId(memberId) .sameThemeId(themeId) .sameTimeId(timeId) .sameDate(date) .build() if (reservationRepository.exists(spec)) { log.warn { "[ReservationService.validateMemberAlreadyReserve] 중복된 예약 존재: themeId=$themeId, timeId=$timeId, date=$date" } throw ReservationException(ReservationErrorCode.ALREADY_RESERVE) } } private fun validateIsReservationExist( themeId: Long, timeId: Long, date: LocalDate, calledBy: String = "validateIsReservationExist" ) { log.debug { "[ReservationService.$calledBy] 예약 존재 여부 확인: themeId=$themeId, timeId=$timeId, date=$date" } val spec: Specification = ReservationSearchSpecification() .confirmed() .sameThemeId(themeId) .sameTimeId(timeId) .sameDate(date) .build() if (reservationRepository.exists(spec)) { log.warn { "[ReservationService.$calledBy] 중복된 예약 존재: themeId=$themeId, timeId=$timeId, date=$date" } throw ReservationException(ReservationErrorCode.RESERVATION_DUPLICATED) } } private fun validateDateAndTime( requestDate: LocalDate, requestTime: TimeEntity, ) { val now = LocalDateTime.now() val request = LocalDateTime.of(requestDate, requestTime.startAt) if (request.isBefore(now)) { log.info { "[ReservationService.validateDateAndTime] 날짜 범위 오류. request=$request, now=$now" } throw ReservationException(ReservationErrorCode.PAST_REQUEST_DATETIME) } } private fun createEntity( timeId: Long, themeId: Long, date: LocalDate, memberId: Long, status: ReservationStatus, ): ReservationEntity { val time: TimeEntity = timeService.findById(timeId) val theme: ThemeEntity = themeService.findById(themeId) val member: MemberEntity = memberService.findById(memberId) validateDateAndTime(date, time) return ReservationEntity( _id = tsidFactory.next(), date = date, time = time, theme = theme, member = member, status = status ) } @Transactional(readOnly = true) fun searchReservations( themeId: Long?, memberId: Long?, dateFrom: LocalDate?, dateTo: LocalDate?, ): ReservationRetrieveListResponse { log.debug { "[ReservationService.searchReservations] 예약 검색 시작: themeId=$themeId, memberId=$memberId, dateFrom=$dateFrom, dateTo=$dateTo" } validateSearchDateRange(dateFrom, dateTo) val spec: Specification = ReservationSearchSpecification() .confirmed() .sameThemeId(themeId) .sameMemberId(memberId) .dateStartFrom(dateFrom) .dateEndAt(dateTo) .build() val reservations = findAllReservationByStatus(spec) return ReservationRetrieveListResponse(reservations) .also { log.info { "[ReservationService.searchReservations] 예약 ${reservations.size}개 조회 완료: themeId=$themeId, memberId=$memberId, dateFrom=$dateFrom, dateTo=$dateTo" } } } private fun validateSearchDateRange(startFrom: LocalDate?, endAt: LocalDate?) { if (startFrom == null || endAt == null) { return } if (startFrom.isAfter(endAt)) { log.info { "[ReservationService.validateSearchDateRange] 조회 범위 오류: startFrom=$startFrom, endAt=$endAt" } throw ReservationException(ReservationErrorCode.INVALID_SEARCH_DATE_RANGE) } } @Transactional(readOnly = true) fun findReservationsByMemberId(memberId: Long): MyReservationRetrieveListResponse { val reservations = reservationRepository.findAllByMemberId(memberId) log.info { "[ReservationService.findReservationsByMemberId] memberId=${memberId}인 ${reservations.size}개의 예약 조회 완료" } return MyReservationRetrieveListResponse(reservations) } fun confirmWaiting(reservationId: Long, memberId: Long) { log.debug { "[ReservationService.confirmWaiting] 대기 예약 승인 시작: reservationId=$reservationId (by adminId=$memberId)" } validateIsMemberAdmin(memberId, "confirmWaiting") log.debug { "[ReservationService.confirmWaiting] 대기 여부 확인 시작: reservationId=$reservationId" } if (reservationRepository.isExistConfirmedReservation(reservationId)) { log.warn { "[ReservationService.confirmWaiting] 승인 실패(이미 확정된 예약 존재): reservationId=$reservationId" } throw ReservationException(ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS) } log.debug { "[ReservationService.confirmWaiting] 대기 예약 상태 변경 시작: reservationId=$reservationId" } reservationRepository.updateStatusByReservationId(reservationId, ReservationStatus.CONFIRMED_PAYMENT_REQUIRED) log.debug { "[ReservationService.confirmWaiting] 대기 예약 상태 변경 완료: reservationId=$reservationId, status=${ReservationStatus.CONFIRMED_PAYMENT_REQUIRED}" } log.info { "[ReservationService.confirmWaiting] 대기 예약 승인 완료: reservationId=$reservationId" } } fun deleteWaiting(reservationId: Long, memberId: Long) { log.debug { "[ReservationService.deleteWaiting] 대기 취소 시작: reservationId=$reservationId, memberId=$memberId" } val reservation: ReservationEntity = findReservationOrThrow(reservationId, "deleteWaiting") if (!reservation.isWaiting()) { log.warn { "[ReservationService.deleteWaiting] 대기 취소 실패(대기 예약이 아님): reservationId=$reservationId" + ", currentStatus=${reservation.status} memberId=$memberId" } throw ReservationException(ReservationErrorCode.ALREADY_CONFIRMED) } if (!reservation.isReservedBy(memberId)) { log.error { "[ReservationService.deleteWaiting] 대기 취소 실패(예약자 본인의 취소 요청이 아님): reservationId=$reservationId" + ", memberId=$memberId " } throw ReservationException(ReservationErrorCode.NOT_RESERVATION_OWNER) } log.debug { "[ReservationService.deleteWaiting] 대기 예약 삭제 시작: reservationId=$reservationId" } reservationRepository.delete(reservation) log.info { "[ReservationService.deleteWaiting] 대기 취소 완료: reservationId=$reservationId, memberId=$memberId" } } fun rejectWaiting(reservationId: Long, memberId: Long) { validateIsMemberAdmin(memberId, "rejectWaiting") log.debug { "[ReservationService.rejectWaiting] 대기 예약 삭제 시작: reservationId=$reservationId (by adminId=$memberId)" } val reservation: ReservationEntity = findReservationOrThrow(reservationId, "rejectWaiting") if (!reservation.isWaiting()) { log.warn { "[ReservationService.rejectWaiting] 대기 예약 삭제 실패(이미 확정 상태): reservationId=$reservationId" + ", status=${reservation.status}" } throw ReservationException(ReservationErrorCode.ALREADY_CONFIRMED) } reservationRepository.delete(reservation) log.info { "[ReservationService.rejectWaiting] 대기 예약 삭제 완료: reservationId=$reservationId" } } private fun validateIsMemberAdmin(memberId: Long, calledBy: String = "validateIsMemberAdmin") { log.debug { "[ReservationService.$calledBy] 관리자 여부 확인: memberId=$memberId" } val member: MemberEntity = memberService.findById(memberId) if (member.isAdmin()) { return } log.warn { "[ReservationService.$calledBy] 관리자가 아님: memberId=$memberId, role=${member.role}" } throw ReservationException(ReservationErrorCode.NO_PERMISSION) } private fun findReservationOrThrow( reservationId: Long, calledBy: String = "findReservationOrThrow" ): ReservationEntity { log.debug { "[ReservationService.$calledBy] 예약 조회: reservationId=$reservationId" } return reservationRepository.findByIdOrNull(reservationId) ?.also { log.info { "[ReservationService.$calledBy] 예약 조회 완료: reservationId=$reservationId" } } ?: run { log.warn { "[ReservationService.$calledBy] 예약 조회 실패: reservationId=$reservationId" } throw ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND) } } }