diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepository.java index 67a0b5e1..524d31e0 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepository.java @@ -2,6 +2,7 @@ import com.eatsfine.eatsfine.domain.booking.entity.Booking; import com.eatsfine.eatsfine.domain.booking.enums.BookingStatus; +import com.eatsfine.eatsfine.domain.store.entity.Store; import com.eatsfine.eatsfine.domain.user.entity.User; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -78,4 +79,54 @@ List findActiveBookingsByTableAndDate( * @return 만료된 예약 리스트 */ List findAllByStatusAndCreatedAtBefore(BookingStatus status, LocalDateTime threshold); + + + /** + * 특정 식당의 브레이크 타임과 겹치는 가장 늦은 예약 날짜를 조회합니다. + * @param adjustedBreakStart 브레이크 시작 시간에서 식당의 예약 간격(bookingIntervalMinutes)을 뺀 시간 + */ + @Query("select max(b.bookingDate) from Booking b " + + "where b.store.id = :storeId " + + "and b.status IN (com.eatsfine.eatsfine.domain.booking.enums.BookingStatus.CONFIRMED, com.eatsfine.eatsfine.domain.booking.enums.BookingStatus.PENDING) " + + "and b.bookingDate >= CURRENT_DATE " + + "and (" + + " (b.bookingTime >= :breakStart and b.bookingTime < :breakEnd) " + // 1. 브레이크 타임 내 시작 + " OR " + + " (:adjustedBreakStart < :breakStart and b.bookingTime >= :adjustedBreakStart and b.bookingTime < :breakStart) " + // 2. 일반적인 경우 (낮) + " OR " + + " (:adjustedBreakStart > :breakStart and (b.bookingTime >= :adjustedBreakStart or b.bookingTime < :breakStart)) " + // 3. 자정 넘어가는 경우 (밤) + ")" + ) + Optional findLastConflictingDate( + @Param("storeId") Long storeId, + @Param("breakStart") LocalTime breakStart, + @Param("breakEnd") LocalTime breakEnd, + @Param("adjustedBreakStart") LocalTime adjustedBreakStart + ); + + @Query("SELECT DISTINCT bt.storeTable.id FROM BookingTable bt " + + "JOIN bt.booking b " + + "WHERE bt.storeTable.id IN :tableIds " + + "AND (b.bookingDate > :currentDate " + + " OR (b.bookingDate = :currentDate AND b.bookingTime >= :currentTime)) " + + "AND b.status IN ('CONFIRMED', 'PENDING')") + List findTableIdsWithFutureBookings( + @Param("tableIds") List tableIds, + @Param("currentDate") LocalDate currentDate, + @Param("currentTime") LocalTime currentTime + ); + @Query("select count(b) from Booking b " + + "where b.store = :store " + + "and b.status in (com.eatsfine.eatsfine.domain.booking.enums.BookingStatus.CONFIRMED, " + + "com.eatsfine.eatsfine.domain.booking.enums.BookingStatus.PENDING, " + + "com.eatsfine.eatsfine.domain.booking.enums.BookingStatus.COMPLETED)") + Long countActiveBookings(@Param("store") Store store); + + @Query("select b.store.id, count(b) from Booking b " + + "where b.store in :stores " + + "and b.status in (com.eatsfine.eatsfine.domain.booking.enums.BookingStatus.CONFIRMED, " + + "com.eatsfine.eatsfine.domain.booking.enums.BookingStatus.PENDING, " + + "com.eatsfine.eatsfine.domain.booking.enums.BookingStatus.COMPLETED) " + + "group by b.store.id") + List countActiveBookingsByStores(@Param("stores") List stores); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/controller/BusinessHoursController.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/controller/BusinessHoursController.java index 4e16fbe0..0d298e7a 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/controller/BusinessHoursController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/controller/BusinessHoursController.java @@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.*; import java.security.Principal; +import java.time.LocalDate; @Tag(name = "BusinessHours", description = "영업시간 관련 API") @RequestMapping("/api/v1") @@ -51,6 +52,14 @@ public ApiResponse updateBreakTime( @RequestBody BusinessHoursReqDto.UpdateBreakTimeDto dto, @CurrentUser User user ){ + BusinessHoursResDto.UpdateBreakTimeDto result = businessHoursCommandService.updateBreakTime(storeId, dto, user.getUsername()); + + // 1. 날짜가 미래라면 DELAYED(지연) 코드 반환 + if (result.effectiveDate().isAfter(LocalDate.now())) { + return ApiResponse.of( + BusinessHoursSuccessStatus._UPDATE_BREAKTIME_DELAYED, + result); + } return ApiResponse.of( BusinessHoursSuccessStatus._UPDATE_BREAKTIME_SUCCESS, businessHoursCommandService.updateBreakTime(storeId, dto, user.getUsername()) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/converter/BusinessHoursConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/converter/BusinessHoursConverter.java index e74b3374..e930ce55 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/converter/BusinessHoursConverter.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/converter/BusinessHoursConverter.java @@ -4,6 +4,7 @@ import com.eatsfine.eatsfine.domain.businesshours.dto.BusinessHoursResDto; import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; +import java.time.LocalDate; import java.util.List; public class BusinessHoursConverter { @@ -49,11 +50,12 @@ public static BusinessHoursResDto.UpdateBusinessHoursDto toUpdateBusinessHoursDt .build(); } - public static BusinessHoursResDto.UpdateBreakTimeDto toUpdateBreakTimeDto(Long storeId, BusinessHoursReqDto.UpdateBreakTimeDto dto) { + public static BusinessHoursResDto.UpdateBreakTimeDto toUpdateBreakTimeDto(Long storeId, BusinessHoursReqDto.UpdateBreakTimeDto dto, LocalDate effectiveDate) { return BusinessHoursResDto.UpdateBreakTimeDto.builder() .storeId(storeId) .breakStartTime(dto.breakStartTime()) .breakEndTime(dto.breakEndTime()) + .effectiveDate(effectiveDate) .build(); } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursReqDto.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursReqDto.java index 10db26c9..7d796e78 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursReqDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursReqDto.java @@ -1,6 +1,7 @@ package com.eatsfine.eatsfine.domain.businesshours.dto; import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import lombok.Builder; @@ -15,14 +16,18 @@ public class BusinessHoursReqDto { public record Summary( @NotNull(message = "요일은 필수입니다.") + @Schema(description = "요일", example = "MONDAY") DayOfWeek day, + @Schema(description = "영업 시작 시간", type = "string", example = "09:00") @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") LocalTime openTime, + @Schema(description = "영업 종료 시간", type = "string", example = "22:00") @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") LocalTime closeTime, + @Schema(description = "휴무 여부", example = "false") boolean isClosed ){} @@ -36,10 +41,12 @@ public record UpdateBusinessHoursDto( public record UpdateBreakTimeDto( @NotNull(message = "브레이크타임 시작 시간은 필수입니다.") + @Schema(description = "브레이크 시작 시간", type = "string", example = "15:00") @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") LocalTime breakStartTime, @NotNull(message = "브레이크타임 종료 시간은 필수입니다.") + @Schema(description = "브레이크 종료 시간", type = "string", example = "17:00") @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") LocalTime breakEndTime ){} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursResDto.java index d7674866..954b0091 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursResDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursResDto.java @@ -1,9 +1,11 @@ package com.eatsfine.eatsfine.domain.businesshours.dto; import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.Builder; import java.time.DayOfWeek; +import java.time.LocalDate; import java.time.LocalTime; import java.util.List; @@ -38,6 +40,9 @@ public record UpdateBreakTimeDto( LocalTime breakStartTime, @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") - LocalTime breakEndTime + LocalTime breakEndTime, + + @JsonIgnore + LocalDate effectiveDate ){} } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java index b676360c..6a0444d3 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java @@ -6,6 +6,7 @@ import lombok.*; import java.time.DayOfWeek; +import java.time.LocalDate; import java.time.LocalTime; @Entity @@ -39,25 +40,66 @@ public class BusinessHours extends BaseEntity { @Column(name = "break_end_time") private LocalTime breakEndTime; + @Column(name = "new_break_start_time") + private LocalTime newBreakStartTime; + + @Column(name = "new_break_end_time") + private LocalTime newBreakEndTime; + + @Column(name = "effective_date") + private LocalDate effectiveDate; + // 휴일 여부 (특정 요일 고정 휴무) @Builder.Default @Column(name = "is_closed", nullable = false) private boolean isClosed = false; - public void assignStore(Store store){ + public void assignStore(Store store) { this.store = store; } // 영업시간 변경 - public void update(LocalTime open, LocalTime close, boolean isClosed){ + public void update(LocalTime open, LocalTime close, boolean isClosed) { this.openTime = open; this.closeTime = close; this.isClosed = isClosed; } // 브레이크타임 변경 - public void updateBreakTime(LocalTime breakStart, LocalTime breakEnd){ - this.breakStartTime = breakStart; - this.breakEndTime = breakEnd; + public void updateBreakTime(LocalTime startTime, LocalTime endTime, LocalDate effectiveDate) { + // 오늘(혹은 과거) 날짜라면 -> 즉시 반영 + if (effectiveDate == null || !effectiveDate.isAfter(LocalDate.now())) { + this.breakStartTime = startTime; + this.breakEndTime = endTime; + // 대기 중인 데이터 초기화 + this.newBreakStartTime = null; + this.newBreakEndTime = null; + this.effectiveDate = null; + } + // 미래 날짜라면 -> 대기열에 저장 + else { + this.newBreakStartTime = startTime; + this.newBreakEndTime = endTime; + this.effectiveDate = effectiveDate; + } + } + + // 대기열 -> 실제 반영 (스케줄러 호출) + public void applyPendingBreakTime() { + if (this.newBreakStartTime != null && this.newBreakEndTime != null) { + this.breakStartTime = newBreakStartTime; + this.breakEndTime = newBreakEndTime; + + // 반영 후 대기열 비우기 + this.newBreakStartTime = null; + this.newBreakEndTime = null; + this.effectiveDate = null; + } + } + + public void clearPendingBreakTime() { + this.newBreakStartTime = null; + this.newBreakEndTime = null; + this.effectiveDate = null; } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/repository/BusinessHoursRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/repository/BusinessHoursRepository.java index 48e96b9b..4388a264 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/repository/BusinessHoursRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/repository/BusinessHoursRepository.java @@ -5,8 +5,11 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.time.DayOfWeek; +import java.time.LocalDate; +import java.util.List; import java.util.Optional; public interface BusinessHoursRepository extends JpaRepository { Optional findByStoreAndDayOfWeek(Store store, DayOfWeek dayOfWeek); + List findAllByEffectiveDateLessThanEqualAndEffectiveDateIsNotNull(LocalDate date); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/service/BusinessHoursCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/service/BusinessHoursCommandServiceImpl.java index f95cfa42..b547f049 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/service/BusinessHoursCommandServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/service/BusinessHoursCommandServiceImpl.java @@ -1,9 +1,12 @@ package com.eatsfine.eatsfine.domain.businesshours.service; +import com.eatsfine.eatsfine.domain.booking.repository.BookingRepository; import com.eatsfine.eatsfine.domain.businesshours.converter.BusinessHoursConverter; import com.eatsfine.eatsfine.domain.businesshours.dto.BusinessHoursReqDto; import com.eatsfine.eatsfine.domain.businesshours.dto.BusinessHoursResDto; import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; +import com.eatsfine.eatsfine.domain.businesshours.exception.BusinessHoursException; +import com.eatsfine.eatsfine.domain.businesshours.status.BusinessHoursErrorStatus; import com.eatsfine.eatsfine.domain.businesshours.validator.BreakTimeValidator; import com.eatsfine.eatsfine.domain.businesshours.validator.BusinessHoursValidator; import com.eatsfine.eatsfine.domain.store.entity.Store; @@ -12,16 +15,23 @@ import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; import com.eatsfine.eatsfine.domain.store.validator.StoreValidator; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.Optional; + @Service @Transactional @RequiredArgsConstructor +@Slf4j public class BusinessHoursCommandServiceImpl implements BusinessHoursCommandService { private final StoreRepository storeRepository; private final StoreValidator storeValidator; + private final BookingRepository bookingRepository; @Override public BusinessHoursResDto.UpdateBusinessHoursDto updateBusinessHours( @@ -59,17 +69,52 @@ public BusinessHoursResDto.UpdateBreakTimeDto updateBreakTime( Store store = storeValidator.validateStoreOwner(storeId, email); + for(BusinessHours bh : store.getBusinessHours()) { if(bh.isClosed()) continue; - BreakTimeValidator.validateBreakTime(bh.getOpenTime(), bh.getCloseTime(), dto.breakStartTime(), dto.breakEndTime()); + try { + BreakTimeValidator.validateBreakTime(bh.getOpenTime(), bh.getCloseTime(), dto.breakStartTime(), dto.breakEndTime()); + } catch (BusinessHoursException e) { + log.error("브레이크 타임 검증 실패 - 요일: {}, 영업시간: {}~{}, 브레이크: {}~{}", + bh.getDayOfWeek(), bh.getOpenTime(), bh.getCloseTime(), + dto.breakStartTime(), dto.breakEndTime()); + throw e; + } + } + + // 브레이크 타임 해제 요청인 경우 (두 시간 모두 null) + if (dto.breakStartTime() == null && dto.breakEndTime() == null) { + for(BusinessHours bh : store.getBusinessHours()) { + bh.updateBreakTime(null, null, LocalDate.now()); + } + return BusinessHoursConverter.toUpdateBreakTimeDto(storeId, dto, null); + } + // 한쪽만 null인 비정상 요청 방어 + if (dto.breakStartTime() == null || dto.breakEndTime() == null) { + throw new BusinessHoursException(BusinessHoursErrorStatus._INVALID_BREAK_TIME); + } + + LocalTime adjustedBreakStart = dto.breakStartTime().minusMinutes(store.getBookingIntervalMinutes()); + + // 1. 예약 충돌 확인 + Optional lastConflictDate = bookingRepository.findLastConflictingDate( + storeId, dto.breakStartTime(), dto.breakEndTime(), adjustedBreakStart + ); + LocalDate effectiveDate; + + effectiveDate = lastConflictDate.map( + localDate -> localDate.plusDays(1)) // 예약이 있으면 그 다음날 부터 + .orElseGet(LocalDate::now); // 예약 없으면 오늘부터 + + store.getBusinessHours().forEach(s -> { if(!s.isClosed()) { - s.updateBreakTime(dto.breakStartTime(), dto.breakEndTime()); + s.updateBreakTime(dto.breakStartTime(), dto.breakEndTime(), effectiveDate); } }); - return BusinessHoursConverter.toUpdateBreakTimeDto(storeId, dto); + return BusinessHoursConverter.toUpdateBreakTimeDto(storeId, dto, effectiveDate); } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/service/BusinessHoursScheduler.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/service/BusinessHoursScheduler.java new file mode 100644 index 00000000..08186465 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/service/BusinessHoursScheduler.java @@ -0,0 +1,68 @@ +package com.eatsfine.eatsfine.domain.businesshours.service; + +import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; +import com.eatsfine.eatsfine.domain.businesshours.exception.BusinessHoursException; +import com.eatsfine.eatsfine.domain.businesshours.repository.BusinessHoursRepository; +import com.eatsfine.eatsfine.domain.businesshours.status.BusinessHoursErrorStatus; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDate; +import java.util.List; + +@Component +@RequiredArgsConstructor +@Slf4j +public class BusinessHoursScheduler { + + private final BusinessHoursRepository businessHoursRepository; + private final TransactionTemplate transactionTemplate; + + @Scheduled(cron = "0 0 0 * * *") + public void applyPendingBreakTimes() { + log.info("[Scheduler] 브레이크 타임 지연 반영 작업 시작"); + + List pendingList = businessHoursRepository.findAllByEffectiveDateLessThanEqualAndEffectiveDateIsNotNull(LocalDate.now()); + + int successCount = 0; + int failCount = 0; + int warnCount = 0; // 데이터 불일치(XOR) 건수 + + // 전체 대상 건수 로그 + log.info("[Scheduler] 처리 대상 건수: {}건", pendingList.size()); + + for (BusinessHours bh : pendingList) { + try { + Boolean isApplied = transactionTemplate.execute(status -> processEachPendingTime(bh.getId())); + if(Boolean.TRUE.equals(isApplied)) { + successCount++; + } else { + warnCount++; // XOR 등으로 인해 초기화만 된 경우 + } + } catch (Exception e) { + failCount++; + // 개별 건 처리 중 에러 발생 시 로그 남기고 다음 건 진행 + log.error("[Scheduler Exception] 반영 실패 - BH ID: {}", bh.getId(), e); + } + } + log.info("[Scheduler] 반영 작업 완료. (성공: {}/{} 건)", successCount, pendingList.size()); + } + + public boolean processEachPendingTime(Long bhId) { + + BusinessHours bh = businessHoursRepository.findById(bhId) + .orElseThrow(() -> new BusinessHoursException(BusinessHoursErrorStatus._BUSINESS_HOURS_NOT_FOUND)); + + if((bh.getNewBreakStartTime() == null) ^ (bh.getNewBreakEndTime() == null)) { + log.warn("[XOR Error] ID: {}", bh.getId()); + bh.clearPendingBreakTime(); + return false; // 초기화만 한 경우 false 리턴 + } + bh.applyPendingBreakTime(); + return true; // 정상 반영한 경우 true 리턴 + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/status/BusinessHoursErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/status/BusinessHoursErrorStatus.java index 3b31454f..e9d67787 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/status/BusinessHoursErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/status/BusinessHoursErrorStatus.java @@ -16,8 +16,9 @@ public enum BusinessHoursErrorStatus implements BaseErrorCode { _INVALID_OPEN_DAY(HttpStatus.BAD_REQUEST, "BUSINESS_HOURS4004", "영업일에는 영업시간 및 마감 시간이 존재해야 합니다."), _INVALID_CLOSED_DAY(HttpStatus.BAD_REQUEST, "BUSINESS_HOURS4005", "휴무일에는 영업시간이 존재할 수 없습니다."), _BUSINESS_HOURS_DAY_NOT_FOUND(HttpStatus.NOT_FOUND, "BUSINESS_HOURS404", "해당 요일에 대한 영업시간 정보가 존재하지 않습니다."), - _INVALID_BREAK_TIME(HttpStatus.BAD_REQUEST, "BUSINESS_HOURS4006", "브레이크타임 시작 시간은 종료 시간보다 빨라야 합니다."), - _BREAK_TIME_OUT_OF_BUSINESS_HOURS(HttpStatus.BAD_REQUEST, "BUSINESS_HOURS4007", "브레이크타임은 영업시간 내에만 설정할 수 있습니다."), + _INVALID_BREAK_TIME(HttpStatus.BAD_REQUEST, "BUSINESS_HOURS4006", "브레이크타임 시작 시간은 종료 시간보다 빨라야 합니다.(심야 영업 시간 고려 필수)"), + _BREAK_TIME_NOT_ALLOWED_FOR_24H(HttpStatus.BAD_REQUEST, "BUSINESS_HOURS4007", "24시간 영업 매장은 브레이크 타임을 설정할 수 없습니다."), + _BUSINESS_HOURS_NOT_FOUND(HttpStatus.NOT_FOUND, "BUSINESS_HOURS4008", "영업시간 정보가 존재하지 않습니다."), ; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/status/BusinessHoursSuccessStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/status/BusinessHoursSuccessStatus.java index b0a12e45..1a1b14bd 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/status/BusinessHoursSuccessStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/status/BusinessHoursSuccessStatus.java @@ -11,7 +11,8 @@ public enum BusinessHoursSuccessStatus implements BaseCode { _UPDATE_BUSINESS_HOURS_SUCCESS(HttpStatus.OK, "BUSINESS_HOURS200", "영업시간이 성공적으로 수정되었습니다."), - _UPDATE_BREAKTIME_SUCCESS(HttpStatus.OK, "BUSINESS_HOURS2001", "브레이크타임이 성공적으로 설정되었습니다.") + _UPDATE_BREAKTIME_SUCCESS(HttpStatus.OK, "BUSINESS_HOURS2001", "브레이크타임이 성공적으로 설정되었습니다."), + _UPDATE_BREAKTIME_DELAYED(HttpStatus.OK, "BUSINESS_HOURS2002", "기존 예약이 존재하여, 마지막 예약 종료 후 다음 날부터 반영됩니다.") ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/validator/BreakTimeValidator.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/validator/BreakTimeValidator.java index a8766dd3..14b19068 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/validator/BreakTimeValidator.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/validator/BreakTimeValidator.java @@ -10,20 +10,36 @@ public class BreakTimeValidator { public static void validateBreakTime(LocalTime openTime, LocalTime closeTime, LocalTime breakStartTime, LocalTime breakEndTime) { // 휴무일은 검증 대상이 아님 - if(openTime == null || closeTime == null) { + if (openTime == null || closeTime == null || breakStartTime == null || breakEndTime == null) { return; } - // start < end - if(!breakEndTime.isAfter(breakStartTime)) { - throw new BusinessHoursException(BusinessHoursErrorStatus._INVALID_BREAK_TIME); + // 24시간 영업이면 모든 브레이크 타임 X + if (openTime.equals(closeTime)) { + throw new BusinessHoursException(BusinessHoursErrorStatus._BREAK_TIME_NOT_ALLOWED_FOR_24H); } - // 브레이크타임이 영업시간 내에 존재 - if(breakStartTime.isBefore(openTime) || breakEndTime.isAfter(closeTime)) { - throw new BusinessHoursException(BusinessHoursErrorStatus._BREAK_TIME_OUT_OF_BUSINESS_HOURS); + // 1. 브레이크 시작 < 종료 검증 (자정을 넘기는 브레이크 타임은 없다고 가정) + if (!breakEndTime.isAfter(breakStartTime)) { + throw new BusinessHoursException(BusinessHoursErrorStatus._INVALID_BREAK_TIME); } + // 2. 브레이크 타임이 영업시간 내에 있는지 검증 + if (openTime.isBefore(closeTime)) { + // 일반 영업: open <= breakStartTime AND breakEndTime <= close 여야 함 + if (breakStartTime.isBefore(openTime) || breakEndTime.isAfter(closeTime)) { + throw new BusinessHoursException(BusinessHoursErrorStatus._INVALID_BREAK_TIME); + } + } else { + // 심야 영업: [close ~ open] 사이(영업 안 하는 시간)에 브레이크 타임이 있으면 에러 + if (!breakStartTime.isBefore(closeTime) && breakStartTime.isBefore(openTime)) { + throw new BusinessHoursException(BusinessHoursErrorStatus._INVALID_BREAK_TIME); + } + if (!breakEndTime.isBefore(closeTime) && breakEndTime.isBefore(openTime)) { + throw new BusinessHoursException(BusinessHoursErrorStatus._INVALID_BREAK_TIME); + } + } } } + diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/validator/BusinessHoursValidator.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/validator/BusinessHoursValidator.java index 6313b7e9..d930be43 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/validator/BusinessHoursValidator.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/validator/BusinessHoursValidator.java @@ -37,8 +37,9 @@ private static void validateComplete(List dto) { private static void validateOpenCloseTime(List dto) { for(BusinessHoursReqDto.Summary s: dto) { if(!s.isClosed()){ - if(s.openTime().isAfter(s.closeTime())) { - throw new BusinessHoursException(BusinessHoursErrorStatus._INVALID_BUSINESS_TIME); + // 24시간 영업 허용 + if(s.openTime().equals(s.closeTime())) { + continue; } } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java b/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java index dbbcc4a6..36d90d0f 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java @@ -108,4 +108,16 @@ public ApiResponse getMainImage( return ApiResponse.of(StoreSuccessStatus._STORE_MAIN_IMAGE_GET_SUCCESS, storeQueryService.getMainImage(storeId)); } + @Operation( + summary = "내 가게 리스트 조회", + description = "사장님이 등록한 모든 가게 리스트를 조회합니다." + ) + @GetMapping("/stores/my") + @PreAuthorize("hasRole('OWNER')") + public ApiResponse getMyStores( + @CurrentUser User user + ) { + return ApiResponse.of(StoreSuccessStatus._MY_STORE_LIST_FOUND, storeQueryService.getMyStores(user.getUsername())); + } + } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java index 402e4c69..ea61beea 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java @@ -79,5 +79,24 @@ public static StoreResDto.GetMainImageDto toGetMainImageDto(Long storeId, String .mainImageUrl(key) .build(); } -} + public static StoreResDto.MyStoreDto toMyStoreDto(Store store, boolean isOpenNow, String mainImageUrl, Long totalBookingCount) { + return StoreResDto.MyStoreDto.builder() + .storeId(store.getId()) + .storeName(store.getStoreName()) + .address(store.getAddress()) + .category(store.getCategory()) + .rating(store.getRating()) + .totalBookingCount(totalBookingCount) + .reviewCount(null) // 리뷰 도메인 구현 이후 추가 예정 + .mainImageUrl(mainImageUrl) + .isOpenNow(isOpenNow) + .build(); + } + + public static StoreResDto.MyStoreListDto toMyStoreListDto(List stores) { + return StoreResDto.MyStoreListDto.builder() + .stores(stores) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java index 0489d542..f9eb62be 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java @@ -4,6 +4,7 @@ import com.eatsfine.eatsfine.domain.store.enums.Category; import com.eatsfine.eatsfine.domain.store.enums.DepositRate; import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import java.math.BigDecimal; @@ -91,4 +92,35 @@ public record GetMainImageDto( String mainImageUrl ) {} + // 내 가게 관리 리스트 응답 + @Builder + @Schema(description = "사장님용 내 가게 관리 단건 DTO") + public record MyStoreDto( + @Schema(description = "가게 ID", example = "1") + Long storeId, + @Schema(description = "가게명", example = "더 플레이스 강남점") + String storeName, + @Schema(description = "가게 주소", example = "서울 강남구 테헤란로 123") + String address, + @Schema(description = "카테고리", example = "ITALIAN") + Category category, + @Schema(description = "평점", example = "4.8") + BigDecimal rating, + @Schema(description = "누적 총 예약 수", example = "1234") + Long totalBookingCount, // 총 예약 수 + @Schema(description = "리뷰 개수", example = "256") + Long reviewCount, + @Schema(description = "대표 이미지 URL", example = "https://s3.amazonaws.com/thumb.jpg") + String mainImageUrl, + @Schema(description = "현재 영업 여부", example = "true") + boolean isOpenNow + ){} + + @Builder + @Schema(description = "사장님용 내 가게 관리 목록 응답") + public record MyStoreListDto( + @Schema(description = "소유한 가게 목록") + List stores + ){} + } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/enums/Category.java b/src/main/java/com/eatsfine/eatsfine/domain/store/enums/Category.java index 57e59a69..04a1b943 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/enums/Category.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/enums/Category.java @@ -1,5 +1,5 @@ package com.eatsfine.eatsfine.domain.store.enums; public enum Category { - KOREAN, CHINESE, JAPANESE, WESTERN, CAFE + KOREAN, CHINESE, JAPANESE, WESTERN, ITALIAN, CAFE } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepository.java index 6cd3f595..193429d9 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepository.java @@ -1,10 +1,12 @@ package com.eatsfine.eatsfine.domain.store.repository; import com.eatsfine.eatsfine.domain.store.entity.Store; +import com.eatsfine.eatsfine.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Optional; @@ -19,4 +21,7 @@ public interface StoreRepository extends JpaRepository, StoreReposi """) Optional findByIdWithMenus(@Param("id") Long id); + @Query("select s from Store s left join fetch s.businessHours where s.owner = :owner") + List findAllByOwner(User owner); + } \ No newline at end of file diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryService.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryService.java index 513f7cf2..264db283 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryService.java @@ -21,4 +21,6 @@ StoreResDto.StoreSearchResDto search( boolean isOpenNow(Store store, LocalDateTime now); + StoreResDto.MyStoreListDto getMyStores(String username); + } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java index ec3623cb..a8e475cb 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java @@ -1,5 +1,7 @@ package com.eatsfine.eatsfine.domain.store.service; +import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; +import com.eatsfine.eatsfine.domain.booking.repository.BookingRepository; import com.eatsfine.eatsfine.domain.store.condition.StoreSearchCondition; import com.eatsfine.eatsfine.domain.store.converter.StoreConverter; import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; @@ -8,6 +10,10 @@ import com.eatsfine.eatsfine.domain.store.exception.StoreException; import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; +import com.eatsfine.eatsfine.domain.user.entity.User; +import com.eatsfine.eatsfine.domain.user.exception.UserException; +import com.eatsfine.eatsfine.domain.user.repository.UserRepository; +import com.eatsfine.eatsfine.domain.user.status.UserErrorStatus; import com.eatsfine.eatsfine.global.s3.S3Service; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -20,6 +26,8 @@ import java.time.LocalDateTime; import java.time.LocalTime; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -27,6 +35,8 @@ public class StoreQueryServiceImpl implements StoreQueryService { private final StoreRepository storeRepository; + private final BookingRepository bookingRepository; + private final UserRepository userRepository; private final S3Service s3Service; // 식당 검색 @@ -90,20 +100,88 @@ public StoreResDto.GetMainImageDto getMainImage(Long storeId) { // 현재 영업 여부 계산 (실시간 계산) @Override public boolean isOpenNow(Store store, LocalDateTime now) { - DayOfWeek dayOfWeek = now.getDayOfWeek(); + DayOfWeek today = now.getDayOfWeek(); + DayOfWeek yesterday = today.minus(1); + LocalTime time = now.toLocalTime(); - return store.findBusinessHoursByDay(dayOfWeek) - .map(bh -> { - if (bh.isClosed()) return false; + // 1. 오늘 기준 영업 중인지 확인 + boolean openToday = store.findBusinessHoursByDay(today) + .map(bh -> isEffectiveOpen(bh, time, true)) + .orElse(false); + + if (openToday) return true; + + // 2. 어제 시작된 심야 영업이 아직 종료되지 않았는지 확인 + return store.findBusinessHoursByDay(yesterday) + .map(bh -> isEffectiveOpen(bh, time, false)) + .orElse(false); + } + + private boolean isEffectiveOpen(BusinessHours bh, LocalTime time, boolean isToday) { + if (bh.isClosed()) return false; + + LocalTime open = bh.getOpenTime(); + LocalTime close = bh.getCloseTime(); + + boolean isWithinBusinessHours; + + // 1. 영업 시간 범위 먼저 체크 + if (open.equals(close)) { + isWithinBusinessHours = isToday; + // 24시간 영업 + } else if (open.isBefore(close)) { + // 일반 영업 (예: 09:00 ~ 18:00) + isWithinBusinessHours = isToday && (!time.isBefore(open) && time.isBefore(close)); + } else { + // 심야 영업 (예: 22:00 ~ 03:00) + isWithinBusinessHours = isToday ? !time.isBefore(open) : time.isBefore(close); + } + + // 2. 영업 시간일 경우에만 브레이크 타임 검사 + if (isWithinBusinessHours) { + if (bh.getBreakStartTime() != null && bh.getBreakEndTime() != null) { + // 브레이크 타임 안에 있으면 false (영업 아님) 반환 + if (!time.isBefore(bh.getBreakStartTime()) && time.isBefore(bh.getBreakEndTime())) { + return false; + } + } + return true; // 영업 시간이고 브레이크 타임도 아님 + } + + return false; // 영업 시간 자체가 아님 + } + + // 내 가게 리스트 조회 + @Override + public StoreResDto.MyStoreListDto getMyStores(String email) { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new UserException(UserErrorStatus.MEMBER_NOT_FOUND)); + + List myStores = storeRepository.findAllByOwner(user); + + if(myStores.isEmpty()) { + return StoreConverter.toMyStoreListDto(List.of()); + } + // N+1 문제 해결을 위한 Bulk Query 실행 + List bookingCounts = bookingRepository.countActiveBookingsByStores(myStores); + Map bookingCountMap = bookingCounts.stream() + .collect(Collectors.toMap( + row -> (Long) row[0], + row -> (Long) row[1] + )); + + LocalDateTime now = LocalDateTime.now(); - if ((bh.getBreakStartTime() != null && bh.getBreakEndTime() != null)) { - if (!time.isBefore(bh.getBreakStartTime()) && (time.isBefore(bh.getBreakEndTime()))) { - return false; // start <= time < end 에 쉼 - } - } - return (!time.isBefore(bh.getOpenTime()) && time.isBefore(bh.getCloseTime())); + List storeDtos = myStores.stream() + .map(store -> { + boolean isOpen = isOpenNow(store, now); + Long totalBookingCount = bookingCountMap.getOrDefault(store.getId(), 0L); + String mainImageUrl = s3Service.toUrl(store.getMainImageKey()); + return StoreConverter.toMyStoreDto(store, isOpen, mainImageUrl, totalBookingCount); + }) + .toList(); - }).orElse(false); // 현재 요일에 해당하는 영업시간 없으면 닫힘처리 + return StoreConverter.toMyStoreListDto(storeDtos); } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreSuccessStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreSuccessStatus.java index b581c921..95431628 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreSuccessStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreSuccessStatus.java @@ -22,7 +22,9 @@ public enum StoreSuccessStatus implements BaseCode { _STORE_MAIN_IMAGE_UPLOAD_SUCCESS(HttpStatus.OK, "STORE2005", "성공적으로 가게 대표 이미지를 업로드했습니다."), - _STORE_MAIN_IMAGE_GET_SUCCESS(HttpStatus.OK, "STORE2005", "성공적으로 가게 대표 이미지를 조회했습니다.") + _STORE_MAIN_IMAGE_GET_SUCCESS(HttpStatus.OK, "STORE2006", "성공적으로 가게 대표 이미지를 조회했습니다."), + + _MY_STORE_LIST_FOUND(HttpStatus.OK, "STORE2007", "성공적으로 내 가게 리스트를 조회했습니다.") ; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/controller/TableLayoutControllerDocs.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/controller/TableLayoutControllerDocs.java index 2e0262d7..d95f3e9f 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/controller/TableLayoutControllerDocs.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/controller/TableLayoutControllerDocs.java @@ -18,6 +18,7 @@ public interface TableLayoutControllerDocs { - 그리드 크기는 1x1 ~ 10x10 범위 내에서 설정 가능합니다. - 가게당 활성 배치도는 1개만 존재하며, 새 배치도 생성 시 기존 배치도는 자동으로 비활성화됩니다. + - 기존 배치도에 존재하는 테이블에 예약이 있다면, 배치도 생성 시 LAYOUT400_2 에러메세지를 반환합니다. - 생성된 배치도는 빈 상태로 생성되며, 이후 테이블 추가 API를 통해 테이블을 배치할 수 있습니다. """ ) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/entity/TableLayout.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/entity/TableLayout.java index 14b6fa17..6cffad8b 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/entity/TableLayout.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/entity/TableLayout.java @@ -19,7 +19,10 @@ @Builder @SQLDelete(sql = "UPDATE table_layout SET is_deleted = true, is_active = false, deleted_at = CURRENT_TIMESTAMP WHERE id = ?") @SQLRestriction("is_deleted = false") -@Table(name = "table_layout") +@Table( + name = "table_layout", + uniqueConstraints = {@UniqueConstraint(name = "uk_table_layout_store_active", columnNames = {"store_active_key"})} +) public class TableLayout extends BaseEntity { @Id @@ -39,6 +42,16 @@ public class TableLayout extends BaseEntity { @Column(name = "is_active", nullable = false) private boolean isActive; + // 가상 컬럼 추가 (JPA에서는 직접 사용하지 않지만 DB 레벨에서 동작) + // is_active = true일 때만 store_id 값을 가지고, false일 때는 NULL + @Column( + name = "store_active_key", + columnDefinition = "VARCHAR(50) GENERATED ALWAYS AS (CASE WHEN is_active = true THEN CONCAT('store_', store_id) ELSE NULL END) STORED", + insertable = false, + updatable = false + ) + private String storeActiveKey; + @Column(name = "is_deleted", nullable = false) @Builder.Default private boolean isDeleted = false; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/exception/status/TableLayoutErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/exception/status/TableLayoutErrorStatus.java index c0d02e4c..a532af0f 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/exception/status/TableLayoutErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/exception/status/TableLayoutErrorStatus.java @@ -13,6 +13,8 @@ public enum TableLayoutErrorStatus implements BaseErrorCode { _LAYOUT_NOT_FOUND(HttpStatus.NOT_FOUND, "LAYOUT404", "배치도를 찾을 수 없습니다."), _LAYOUT_FORBIDDEN(HttpStatus.FORBIDDEN, "LAYOUT403", "해당 가게의 소유자만 접근 가능합니다."), + + _CANNOT_DELETE_LAYOUT_WITH_FUTURE_BOOKINGS(HttpStatus.BAD_REQUEST, "LAYOUT400_2", "미래 예약이 있는 배치도는 삭제할 수 없습니다. 모든 예약이 완료된 후 재생성해주세요.") ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutCommandServiceImpl.java index ced9c523..dbba1ca1 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutCommandServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutCommandServiceImpl.java @@ -1,25 +1,31 @@ package com.eatsfine.eatsfine.domain.table_layout.service; +import com.eatsfine.eatsfine.domain.booking.repository.BookingRepository; import com.eatsfine.eatsfine.domain.store.entity.Store; -import com.eatsfine.eatsfine.domain.store.exception.StoreException; -import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; -import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; import com.eatsfine.eatsfine.domain.store.validator.StoreValidator; +import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; import com.eatsfine.eatsfine.domain.table_layout.converter.TableLayoutConverter; import com.eatsfine.eatsfine.domain.table_layout.dto.req.TableLayoutReqDto; import com.eatsfine.eatsfine.domain.table_layout.dto.res.TableLayoutResDto; import com.eatsfine.eatsfine.domain.table_layout.entity.TableLayout; +import com.eatsfine.eatsfine.domain.table_layout.exception.TableLayoutException; +import com.eatsfine.eatsfine.domain.table_layout.exception.status.TableLayoutErrorStatus; import com.eatsfine.eatsfine.domain.table_layout.repository.TableLayoutRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import java.util.Optional; + @Service @RequiredArgsConstructor @Transactional public class TableLayoutCommandServiceImpl implements TableLayoutCommandService { - private final StoreRepository storeRepository; private final TableLayoutRepository tableLayoutRepository; + private final BookingRepository bookingRepository; private final StoreValidator storeValidator; // 테이블 배치도 생성 @@ -32,7 +38,19 @@ public TableLayoutResDto.LayoutDetailDto createLayout( Store store = storeValidator.validateStoreOwner(storeId, email); - deactivateExistingLayout(store); + Optional existingLayout = tableLayoutRepository.findByStoreIdAndIsActiveTrue(storeId); + + if (existingLayout.isPresent()) { + // 미래 예약 확인 + boolean hasFutureBookings = checkFutureBookingsInLayout(existingLayout.get()); + + if (hasFutureBookings) { + throw new TableLayoutException(TableLayoutErrorStatus._CANNOT_DELETE_LAYOUT_WITH_FUTURE_BOOKINGS); + } + + // 미래 예약이 없으면 배치도 비활성화 후 재생성 + tableLayoutRepository.delete(existingLayout.get()); + } // 새 배치도 생성 TableLayout newLayout = TableLayout.builder() @@ -48,9 +66,20 @@ public TableLayoutResDto.LayoutDetailDto createLayout( return TableLayoutConverter.toLayoutDetailDto(savedLayout); } - // 기존 테이블 배치도 비활성화 - private void deactivateExistingLayout(Store store) { - tableLayoutRepository.findByStoreIdAndIsActiveTrue(store.getId()) - .ifPresent(tableLayoutRepository::delete); + // 미래 예약 확인 + private boolean checkFutureBookingsInLayout(TableLayout layout) { + LocalDate currentDate = LocalDate.now(); + LocalTime currentTime = LocalTime.now(); + List tableIds = layout.getTables().stream() + .map(StoreTable::getId) + .toList(); + + if (tableIds.isEmpty()) { + return false; + } + + List tableIdsWithFutureBookings = bookingRepository.findTableIdsWithFutureBookings(tableIds, currentDate, currentTime); + + return !tableIdsWithFutureBookings.isEmpty(); } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/user/converter/UserConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/user/converter/UserConverter.java index d99117ff..0f1d6ebe 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/user/converter/UserConverter.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/user/converter/UserConverter.java @@ -30,10 +30,10 @@ public static UserResponseDto.LoginResponseDto toLoginResponse(User user, String // 유저 정보 조회 응답 변환 - public static UserResponseDto.UserInfoDto toUserInfo(User user) { + public static UserResponseDto.UserInfoDto toUserInfo(User user, String profileImageUrl) { return UserResponseDto.UserInfoDto.builder() .id(user.getId()) - .profileImage(user.getProfileImage()) + .profileImage(profileImageUrl) .email(user.getEmail()) .name(user.getName()) .phoneNumber(user.getPhoneNumber()) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/user/entity/User.java b/src/main/java/com/eatsfine/eatsfine/domain/user/entity/User.java index bd2fc6e3..e232c147 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/user/entity/User.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/user/entity/User.java @@ -6,6 +6,9 @@ import com.eatsfine.eatsfine.global.common.BaseEntity; import jakarta.persistence.*; import lombok.*; + +import java.time.LocalDateTime; + @Entity @Getter // 수정한 부분: access 레벨을 PROTECTED로 설정하여 Hibernate가 접근할 수 있게 합니다. @@ -46,6 +49,12 @@ public class User extends BaseEntity { @Column(length = 500) private String refreshToken; + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Column(name = "is_deleted") + private Boolean isDeleted = false; + public void updateName(String name) { this.name = name; } @@ -87,6 +96,17 @@ public void linkSocial (SocialType socialType, String socialId){ this.socialId = socialId; } + // 회원 탈퇴 메서드 추가 + public void withdraw() { + this.isDeleted = true; + this.deletedAt = LocalDateTime.now(); + this.refreshToken = null; // refresh token도 null 처리 + } + + public boolean isDeleted() { + return this.isDeleted != null && this.isDeleted; + } + @OneToOne(mappedBy = "user", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.LAZY) private Term term; } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/user/service/userService/UserServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/user/service/userService/UserServiceImpl.java index f773a80f..fdd8dc38 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/user/service/userService/UserServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/user/service/userService/UserServiceImpl.java @@ -67,6 +67,10 @@ public UserResponseDto.LoginResponseDto login(UserRequestDto.LoginDto loginDto) User user = userRepository.findByEmail(loginDto.getEmail()) .orElseThrow(() -> new UserException(UserErrorStatus.MEMBER_NOT_FOUND)); + if (user.isDeleted()) { + throw new UserException(UserErrorStatus.WITHDRAWN_USER); + } + // 2) 비밀번호 검증 if (!passwordEncoder.matches(loginDto.getPassword(), user.getPassword())) { throw new UserException(UserErrorStatus.INVALID_PASSWORD); @@ -85,12 +89,12 @@ public UserResponseDto.LoginResponseDto login(UserRequestDto.LoginDto loginDto) .refreshToken(refreshToken) .build(); } - @Override @Transactional public UserResponseDto.UserInfoDto getMemberInfo(HttpServletRequest request) { User user = getCurrentUser(request); - return UserConverter.toUserInfo(user); + String profileUrl = s3Service.toUrl(user.getProfileImage()); + return UserConverter.toUserInfo(user, profileUrl); } @Override @@ -191,6 +195,7 @@ private void validateProfileImage(MultipartFile file) { } + @Override @Transactional public void withdraw(HttpServletRequest request) { @@ -205,8 +210,8 @@ public void withdraw(HttpServletRequest request) { } } - user.updateRefreshToken(null); - userRepository.delete(user); + user.withdraw(); + userRepository.save(user); } @Override @@ -225,8 +230,13 @@ private User getCurrentUser(HttpServletRequest request) { String email = jwtTokenProvider.getEmailFromToken(token); - return userRepository.findByEmail(email) + User user = userRepository.findByEmail(email) .orElseThrow(() -> new UserException(UserErrorStatus.MEMBER_NOT_FOUND)); + + if (user.isDeleted()) { + throw new UserException(UserErrorStatus.WITHDRAWN_USER); + } + return user; } @Override @@ -283,4 +293,4 @@ public UserResponseDto.UpdatePasswordDto changePassword( return UserConverter.toUpdatePasswordResponse(true, LocalDateTime.now(), "비밀번호가 성공적으로 변경되었습니다."); } -} \ No newline at end of file +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/user/status/UserErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/user/status/UserErrorStatus.java index 5041af6e..a4c8b147 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/user/status/UserErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/user/status/UserErrorStatus.java @@ -16,8 +16,8 @@ public enum UserErrorStatus implements BaseErrorCode { EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "MEMBER4003", "이미 존재하는 이메일입니다."), INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "MEMBER4004", "비밀번호가 올바르지 않습니다."), PASSWORD_NOT_MATCH(HttpStatus.BAD_REQUEST, "MEMBER4005", "현재 비밀번호가 일치하지 않습니다."), - SAME_PASSWORD(HttpStatus.BAD_REQUEST, "MEMBER4006", "새 비밀번호가 현재 비밀번호와 동일합니다.") - + SAME_PASSWORD(HttpStatus.BAD_REQUEST, "MEMBER4006", "새 비밀번호가 현재 비밀번호와 동일합니다."), + WITHDRAWN_USER(HttpStatus.FORBIDDEN, "MEMBER4007", "탈퇴한 회원입니다.") ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/eatsfine/eatsfine/global/config/SecurityConfig.java b/src/main/java/com/eatsfine/eatsfine/global/config/SecurityConfig.java index c4691884..03e7150e 100644 --- a/src/main/java/com/eatsfine/eatsfine/global/config/SecurityConfig.java +++ b/src/main/java/com/eatsfine/eatsfine/global/config/SecurityConfig.java @@ -95,7 +95,6 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http.build(); } - @Bean public HttpCookieOAuth2AuthorizationRequestRepository cookieAuthorizationRequestRepository() { return new HttpCookieOAuth2AuthorizationRequestRepository();