diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java index bc91aa8d..c1dbd056 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java @@ -4,6 +4,7 @@ import com.eatsfine.eatsfine.domain.booking.dto.response.BookingResponseDTO; import com.eatsfine.eatsfine.domain.booking.service.BookingCommandService; import com.eatsfine.eatsfine.domain.booking.service.BookingQueryService; +import com.eatsfine.eatsfine.domain.booking.status.BookingSuccessStatus; import com.eatsfine.eatsfine.domain.user.entity.User; import com.eatsfine.eatsfine.domain.user.repository.UserRepository; import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; @@ -50,7 +51,7 @@ public ApiResponse getAvailableTables( @Operation(summary = "예약 생성" , description = "가게,날짜,시간,인원,테이블 정보를 입력받아 예약을 생성합니다.") - @PostMapping("stores/{storeId}/bookings") + @PostMapping("/stores/{storeId}/bookings") public ApiResponse createBooking( @PathVariable Long storeId, @RequestBody @Valid BookingRequestDTO.CreateBookingDTO dto @@ -60,15 +61,42 @@ public ApiResponse createBooking( return ApiResponse.onSuccess(bookingCommandService.createBooking(user, storeId, dto)); } - @Operation(summary = "결제 완료 처리", - description = "결제 완료 후 결제 정보를 입력받아 예약 상태를 업데이트합니다.") - @PatchMapping("/bookings/{bookingId}/payments-confirm") - public ApiResponse confirmPayment( - @PathVariable Long bookingId, - @RequestBody @Valid BookingRequestDTO.PaymentConfirmDTO dto - ) { + //불필요한 api 삭제 +// @Operation(summary = "예약 완료 처리", +// description = "결제 완료 후 결제 정보를 입력받아 예약 상태를 업데이트합니다. 주의) 외부에서 이 API를 호출하지 않고 " + +// "POST /api/v1/payments/confirm API 호출 후 내부적으로 이 API의 로직을 실행합니다.") +// @PatchMapping("/bookings/{bookingId}/payments-confirm") +// public ApiResponse confirmPayment( +// @PathVariable Long bookingId, +// @RequestBody @Valid BookingRequestDTO.PaymentConfirmDTO dto +// ) { +// +// return ApiResponse.onSuccess(bookingCommandService.confirmPayment(bookingId,dto)); +// } - return ApiResponse.onSuccess(bookingCommandService.confirmPayment(bookingId,dto)); + @Operation(summary = "예약 취소", + description = "예약을 취소하고 환불을 진행합니다.") + @PatchMapping("/bookings/{bookingId}/cancel") + public ApiResponse cancelBooking( + @PathVariable Long bookingId, + @RequestBody @Valid BookingRequestDTO.CancelBookingDTO dto + ) { + return ApiResponse.of(BookingSuccessStatus._BOOKING_CANCELED, + bookingCommandService.cancelBooking(bookingId, dto)); } + + @Operation(summary = "예약 내역 조회", + description = "마이페이지에서 나의 예약 내역을 조회합니다.") + @GetMapping("/users/bookings") + public ApiResponse getMyBookings( + @RequestParam(name = "status", required = false) String status, + @RequestParam(name = "page", defaultValue = "1") Integer page + ) { + User user = userRepository.findById(1L).orElseThrow(); // 임시로 임의의 유저 사용 + + // 서비스 호출 시 page - 1을 넘겨서 0-based index로 맞춰줍니다. + return ApiResponse.of(BookingSuccessStatus._BOOKING_FOUND, + bookingQueryService.getBookingList(user, status, page-1)); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/converter/BookingConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/converter/BookingConverter.java index cfd9e42b..a3107088 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/converter/BookingConverter.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/converter/BookingConverter.java @@ -1,4 +1,32 @@ package com.eatsfine.eatsfine.domain.booking.converter; +import com.eatsfine.eatsfine.domain.booking.dto.response.BookingResponseDTO; +import com.eatsfine.eatsfine.domain.booking.entity.Booking; +import com.eatsfine.eatsfine.domain.payment.dto.response.PaymentResponseDTO; +import com.eatsfine.eatsfine.domain.store.entity.Store; + +import java.math.BigDecimal; +import java.util.List; + public class BookingConverter { + + public static BookingResponseDTO.CreateBookingResultDTO toCreateBookingResultDTO( + Booking booking, Store store, BigDecimal totalDeposit, + List resultTableDTOS, + PaymentResponseDTO.PaymentRequestResultDTO paymentInfo) { + + return BookingResponseDTO.CreateBookingResultDTO.builder() + .bookingId(booking.getId()) + .storeName(store.getStoreName()) + .date(booking.getBookingDate()) + .time(booking.getBookingTime()) + .partySize(booking.getPartySize()) + .status(booking.getStatus().name()) + .totalDeposit(totalDeposit) + .createdAt(booking.getCreatedAt()) + .tables(resultTableDTOS) + .paymentId(paymentInfo.paymentId()) + .orderId(paymentInfo.orderId()) + .build(); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java index 6616d469..42288939 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java @@ -35,7 +35,13 @@ public record CreateBookingDTO( @NotNull @DateTimeFormat(pattern = "HH:mm") LocalTime time, @NotNull @Min(1) Integer partySize, @NotNull List tableIds, - @NotNull boolean isSplitAccepted + @NotNull boolean isSplitAccepted, + @NotNull List menuItems + ){} + + public record MenuOrderDto( + @NotNull Long menuId, + @NotNull @Min(1) Integer quantity ){} public record PaymentConfirmDTO( @@ -43,4 +49,9 @@ public record PaymentConfirmDTO( @NotNull Integer amount //실제 결제 금액 ){} + public record CancelBookingDTO( + @NotBlank String reason //예약 취소 사유 + + ){} + } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/response/BookingResponseDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/response/BookingResponseDTO.java index ad1bf084..a3333706 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/response/BookingResponseDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/response/BookingResponseDTO.java @@ -1,7 +1,9 @@ package com.eatsfine.eatsfine.domain.booking.dto.response; +import com.eatsfine.eatsfine.domain.booking.enums.BookingStatus; import lombok.Builder; +import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; @@ -42,9 +44,11 @@ public record CreateBookingResultDTO( LocalDate date, LocalTime time, Integer partySize, - Integer totalDeposit, + BigDecimal totalDeposit, List tables, - LocalDateTime createdAt // 예약 생성 시간 + LocalDateTime createdAt, // 예약 생성 시간 + Long paymentId, // 결제 ID + String orderId // 주문 ID ){} @Builder @@ -61,6 +65,40 @@ public record ConfirmPaymentResultDTO( Long bookingId, String status, // CONFIRMED String paymentKey, // PG사 결제 키 - Integer amount // 최종 결제 금액 + BigDecimal amount // 최종 결제 금액 + ){} + + @Builder + public record CancelBookingResultDTO( + Long bookingId, + String status, // CANCELED + String cancelReason, // 취소 사유 + LocalDateTime canceledAt, // 취소 시간 + BigDecimal refundAmount // 환불 금액 + ){} + + @Builder + public record BookingPreviewListDTO( + List bookingList, + Integer listSize, + Integer totalPage, + Long totalElements, + Boolean isFirst, + Boolean isLast + + ){} + + @Builder + public record BookingPreviewDTO( + Long bookingId, + String storeName, + String storeAddress, + LocalDate bookingDate, + LocalTime bookingTime, + Integer partySize, + String tableNumbers, + BigDecimal amount, + String paymentMethod, + String status ){} } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java index 386d7644..42f94925 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java @@ -1,8 +1,12 @@ package com.eatsfine.eatsfine.domain.booking.entity; +import com.eatsfine.eatsfine.domain.booking.entity.mapping.BookingMenu; import com.eatsfine.eatsfine.domain.booking.entity.mapping.BookingTable; import com.eatsfine.eatsfine.domain.booking.enums.BookingStatus; import com.eatsfine.eatsfine.domain.payment.entity.Payment; +import com.eatsfine.eatsfine.domain.payment.enums.PaymentStatus; +import com.eatsfine.eatsfine.domain.payment.exception.PaymentException; +import com.eatsfine.eatsfine.domain.payment.status.PaymentErrorStatus; import com.eatsfine.eatsfine.domain.store.entity.Store; import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; import com.eatsfine.eatsfine.domain.user.entity.User; @@ -10,6 +14,7 @@ import jakarta.persistence.*; import lombok.*; +import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalTime; import java.util.ArrayList; @@ -60,8 +65,20 @@ public class Booking extends BaseEntity { private LocalTime bookingTime; @Enumerated(EnumType.STRING) + @Column(name = "status", length = 20, nullable = false) private BookingStatus status; + @Builder.Default + @OneToMany(mappedBy = "booking", cascade = CascadeType.ALL, orphanRemoval = true) + private List bookingMenus = new ArrayList<>(); + + public void addBookingMenu(BookingMenu bookingMenu) { + this.bookingMenus.add(bookingMenu); + if (bookingMenu.getBooking() != this) { + bookingMenu.confirmBooking(this); + } + } + public void addBookingTable(StoreTable storeTable) { BookingTable bookingTable = BookingTable.builder() .booking(this) @@ -70,10 +87,30 @@ public void addBookingTable(StoreTable storeTable) { this.bookingTables.add(bookingTable); } - private Integer depositAmount; + private BigDecimal depositAmount; + + private String cancelReason; public void confirm() { this.status = BookingStatus.CONFIRMED; } + public void cancel(String cancelReason) + { + this.status = BookingStatus.CANCELED; + this.cancelReason = cancelReason; + } + + //예약과 관련된 결제 중 결제 완료된 결제키 조회 + public String getSuccessPaymentKey() { + return this.payments.stream() + .filter(p -> p.getPaymentStatus() == PaymentStatus.COMPLETED) + .map(Payment::getPaymentKey) + .findFirst() + .orElseThrow(() -> new PaymentException(PaymentErrorStatus._PAYMENT_NOT_FOUND)); + } + + public void setDepositAmount(BigDecimal totalDeposit) { + this.depositAmount = totalDeposit; + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/mapping/BookingMenu.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/mapping/BookingMenu.java new file mode 100644 index 00000000..aefc0374 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/mapping/BookingMenu.java @@ -0,0 +1,34 @@ +package com.eatsfine.eatsfine.domain.booking.entity.mapping; + +import com.eatsfine.eatsfine.domain.booking.entity.Booking; +import com.eatsfine.eatsfine.domain.menu.entity.Menu; +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +@Builder +public class BookingMenu { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Integer quantity; + + private BigDecimal price; + + @ManyToOne(fetch = FetchType.LAZY) + private Booking booking; + + @ManyToOne(fetch = FetchType.LAZY) + private Menu menu; + + public void confirmBooking(Booking booking) { + this.booking = booking; + } + +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/enums/BookingStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/enums/BookingStatus.java index 404fafc2..c0a94af6 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/enums/BookingStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/enums/BookingStatus.java @@ -2,5 +2,5 @@ public enum BookingStatus { - PENDING, CONFIRMED, COMPLETED, CANCELLED, NOSHOW + PENDING, CONFIRMED, COMPLETED, CANCELED, NOSHOW } 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 acb5b0a5..efc5a43c 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 @@ -1,14 +1,20 @@ package com.eatsfine.eatsfine.domain.booking.repository; import com.eatsfine.eatsfine.domain.booking.entity.Booking; +import com.eatsfine.eatsfine.domain.booking.enums.BookingStatus; +import com.eatsfine.eatsfine.domain.user.entity.User; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.repository.query.Param; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; import java.time.LocalDate; import java.time.LocalTime; import java.util.List; +@Repository public interface BookingRepository extends JpaRepository { @@ -34,4 +40,19 @@ public interface BookingRepository extends JpaRepository { "AND b.bookingTime = :time " + "AND b.status IN ('CONFIRMED', 'PENDING')") boolean existsBookingByTableAndDateTime(@Param("tableId") Long tableId, @Param("date") LocalDate date, @Param("time") LocalTime time); + + + // 1. 특정 유저의 모든 예약을 최신순으로 페이징 조회 + @Query("select b from Booking b join fetch b.store where b.user = :user") + Page findAllByUser(@Param("user") User user, Pageable pageable); + + @Query("Select b from Booking b join fetch b.store where b.user = :user and b.status = :status") + Page findAllByUserAndStatus(@Param("user") User user, @Param("status") BookingStatus status, Pageable pageable); + @Query("SELECT COUNT(bt) > 0 FROM BookingTable bt " + + "JOIN bt.booking b " + + "WHERE bt.storeTable.id = :tableId " + + "AND (b.bookingDate > :currentDate " + + " OR (b.bookingDate = :currentDate AND b.bookingTime >= :currentTime)) " + + "AND b.status IN ('CONFIRMED', 'PENDING')") + boolean existsFutureBookingByTable(@Param("tableId") Long tableId, @Param("currentDate") LocalDate currentDate, @Param("currentTime") LocalTime currentTime); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandService.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandService.java index 5eacc66b..0928674a 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandService.java @@ -14,4 +14,7 @@ public interface BookingCommandService { BookingResponseDTO.CreateBookingResultDTO createBooking(User user, Long storeId, BookingRequestDTO.CreateBookingDTO dto); BookingResponseDTO.ConfirmPaymentResultDTO confirmPayment(Long BookingId, BookingRequestDTO.PaymentConfirmDTO dto); + + BookingResponseDTO.CancelBookingResultDTO cancelBooking(Long bookingId, BookingRequestDTO.CancelBookingDTO dto); + } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandServiceImpl.java index e41b4371..75e0a02f 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandServiceImpl.java @@ -1,18 +1,30 @@ package com.eatsfine.eatsfine.domain.booking.service; +import com.eatsfine.eatsfine.domain.booking.converter.BookingConverter; import com.eatsfine.eatsfine.domain.booking.dto.request.BookingRequestDTO; import com.eatsfine.eatsfine.domain.booking.dto.response.BookingResponseDTO; import com.eatsfine.eatsfine.domain.booking.entity.Booking; +import com.eatsfine.eatsfine.domain.booking.entity.mapping.BookingMenu; import com.eatsfine.eatsfine.domain.booking.entity.mapping.BookingTable; import com.eatsfine.eatsfine.domain.booking.enums.BookingStatus; import com.eatsfine.eatsfine.domain.booking.exception.BookingException; import com.eatsfine.eatsfine.domain.booking.repository.BookingRepository; import com.eatsfine.eatsfine.domain.booking.status.BookingErrorStatus; +import com.eatsfine.eatsfine.domain.menu.entity.Menu; +import com.eatsfine.eatsfine.domain.menu.repository.MenuRepository; +import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentRequestDTO; +import com.eatsfine.eatsfine.domain.payment.dto.response.PaymentResponseDTO; +import com.eatsfine.eatsfine.domain.payment.entity.Payment; +import com.eatsfine.eatsfine.domain.payment.enums.PaymentStatus; +import com.eatsfine.eatsfine.domain.payment.exception.PaymentException; +import com.eatsfine.eatsfine.domain.payment.service.PaymentService; +import com.eatsfine.eatsfine.domain.payment.status.PaymentErrorStatus; 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.storetable.entity.StoreTable; +import com.eatsfine.eatsfine.domain.storetable.exception.status.StoreTableErrorStatus; import com.eatsfine.eatsfine.domain.storetable.repository.StoreTableRepository; import com.eatsfine.eatsfine.domain.user.entity.User; import com.eatsfine.eatsfine.domain.user.repository.UserRepository; @@ -20,9 +32,12 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.LocalDate; import java.time.LocalTime; import java.util.List; +import java.util.stream.Collectors; import java.util.stream.Stream; @Service @@ -32,6 +47,8 @@ public class BookingCommandServiceImpl implements BookingCommandService{ private final StoreRepository storeRepository; private final StoreTableRepository storeTableRepository; private final BookingRepository bookingRepository; + private final PaymentService paymentService; + private final MenuRepository menuRepository; @Override @Transactional @@ -42,6 +59,11 @@ public BookingResponseDTO.CreateBookingResultDTO createBooking(User user, Long s List selectedTables = storeTableRepository.findAllByIdWithLock(dto.tableIds()); + // 요청한 ID 개수와 조회된 데이터 개수가 다르면, 존재하지 않는 ID가 포함된 것 + if (selectedTables.size() != dto.tableIds().size()) { + throw new StoreException(StoreTableErrorStatus._TABLE_NOT_FOUND); + } + //이미 예약된 테이블 있는지 최종 점검 List reservedTableIds = bookingRepository.findReservedTableIds(storeId, dto.date(), dto.time()); for (StoreTable storeTable : selectedTables) { @@ -50,11 +72,8 @@ public BookingResponseDTO.CreateBookingResultDTO createBooking(User user, Long s } } - int totalDeposit = store.getMinPrice() * dto.partySize(); // 자세한 예약금 로직은 추후 수정 - Booking booking = Booking.builder() - .depositAmount(totalDeposit) .bookingDate(dto.date()) .bookingTime(dto.time()) .partySize(dto.partySize()) @@ -66,7 +85,40 @@ public BookingResponseDTO.CreateBookingResultDTO createBooking(User user, Long s selectedTables.forEach(booking::addBookingTable); + + // 예약한 메뉴들 저장 및 총 메뉴 가격 계산 + BigDecimal itemTotalPrice = BigDecimal.ZERO; + for (BookingRequestDTO.MenuOrderDto menuItem : dto.menuItems()) { + Menu menu = menuRepository.findById(menuItem.menuId()) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND));//차후 수정 + + BookingMenu bookingMenu = BookingMenu.builder() + .quantity(menuItem.quantity()) + .menu(menu) + .booking(booking) + .price(menu.getPrice()) + .build(); + + booking.addBookingMenu(bookingMenu); + + BigDecimal itemQuantity = BigDecimal.valueOf(menuItem.quantity()); + itemTotalPrice = itemTotalPrice.add(menu.getPrice().multiply(itemQuantity)); + } + + // 총 예약금 계산 ( 전체 메뉴 가격 * 가게의 예약금 비율 ) + BigDecimal depositRate = BigDecimal.valueOf(store.getDepositRate().getPercent()); + BigDecimal hundred = BigDecimal.valueOf(100); + BigDecimal totalDeposit = itemTotalPrice + .multiply(depositRate) + .divide(hundred, 0, RoundingMode.HALF_UP); + booking.setDepositAmount(totalDeposit); + Booking savedBooking = bookingRepository.save(booking); + bookingRepository.flush(); + + // 결제 대기 데이터 생성 (내부 서비스 호출) + PaymentRequestDTO.RequestPaymentDTO paymentRequest = new PaymentRequestDTO.RequestPaymentDTO(savedBooking.getId()); + PaymentResponseDTO.PaymentRequestResultDTO paymentInfo = paymentService.requestPayment(paymentRequest); //BookingResponseDTO.BookingResultTableDTO로 변환 @@ -80,17 +132,8 @@ public BookingResponseDTO.CreateBookingResultDTO createBooking(User user, Long s .build()) .toList(); - return BookingResponseDTO.CreateBookingResultDTO.builder() - .bookingId(savedBooking.getId()) - .storeName(store.getStoreName()) - .date(savedBooking.getBookingDate()) - .time(savedBooking.getBookingTime()) - .partySize(savedBooking.getPartySize()) - .status(savedBooking.getStatus().name()) - .totalDeposit(totalDeposit) - .createdAt(savedBooking.getCreatedAt()) - .tables(resultTableDTOS) - .build(); + + return BookingConverter.toCreateBookingResultDTO(savedBooking,store,totalDeposit, resultTableDTOS,paymentInfo); } @Override @@ -113,7 +156,6 @@ public BookingResponseDTO.ConfirmPaymentResultDTO confirmPayment(Long bookingId, //예약 상태 확정으로 변경 booking.confirm(); - return BookingResponseDTO.ConfirmPaymentResultDTO.builder() .bookingId(booking.getId()) .status(booking.getStatus().name()) @@ -121,4 +163,27 @@ public BookingResponseDTO.ConfirmPaymentResultDTO confirmPayment(Long bookingId, .amount(booking.getDepositAmount()) .build(); } + + @Override + @Transactional + public BookingResponseDTO.CancelBookingResultDTO cancelBooking(Long bookingId, BookingRequestDTO.CancelBookingDTO dto) { + Booking booking = bookingRepository.findById(bookingId) + .orElseThrow(() -> new BookingException(BookingErrorStatus._BOOKING_NOT_FOUND)); + + // 예약 중 결제 완료된 결제의 결제키 이용 환불 로직 진행 + if(booking.getStatus() == BookingStatus.CONFIRMED) { + PaymentRequestDTO.CancelPaymentDTO cancelDto = new PaymentRequestDTO.CancelPaymentDTO(dto.reason()); + paymentService.cancelPayment(booking.getSuccessPaymentKey(), cancelDto); + } + + + //예약 상태 취소로 변경 + booking.cancel(dto.reason()); + + return BookingResponseDTO.CancelBookingResultDTO.builder() + .bookingId(booking.getId()) + .status(booking.getStatus().name()) + .refundAmount(booking.getDepositAmount()) + .build(); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java index cc9df066..02d05e27 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java @@ -2,6 +2,7 @@ import com.eatsfine.eatsfine.domain.booking.dto.request.BookingRequestDTO; import com.eatsfine.eatsfine.domain.booking.dto.response.BookingResponseDTO; +import com.eatsfine.eatsfine.domain.user.entity.User; import java.time.LocalDate; import java.time.LocalTime; @@ -11,4 +12,6 @@ public interface BookingQueryService { BookingResponseDTO.TimeSlotListDTO getAvailableTimeSlots(Long storeId, BookingRequestDTO.GetAvailableTimeDTO dto); BookingResponseDTO.AvailableTableListDTO getAvailableTables(Long storeId, BookingRequestDTO.GetAvailableTableDTO dto); + + BookingResponseDTO.BookingPreviewListDTO getBookingList(User user, String status, Integer page); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java index 62192a5d..f522e724 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java @@ -2,17 +2,25 @@ import com.eatsfine.eatsfine.domain.booking.dto.request.BookingRequestDTO; import com.eatsfine.eatsfine.domain.booking.dto.response.BookingResponseDTO; +import com.eatsfine.eatsfine.domain.booking.entity.Booking; +import com.eatsfine.eatsfine.domain.booking.enums.BookingStatus; import com.eatsfine.eatsfine.domain.booking.exception.BookingException; import com.eatsfine.eatsfine.domain.booking.repository.BookingRepository; import com.eatsfine.eatsfine.domain.booking.status.BookingErrorStatus; import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; +import com.eatsfine.eatsfine.domain.payment.entity.Payment; +import com.eatsfine.eatsfine.domain.payment.enums.PaymentStatus; import com.eatsfine.eatsfine.domain.store.entity.Store; import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; import com.eatsfine.eatsfine.domain.table_layout.entity.TableLayout; import com.eatsfine.eatsfine.domain.table_layout.repository.TableLayoutRepository; +import com.eatsfine.eatsfine.domain.user.entity.User; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,6 +28,7 @@ import java.time.LocalTime; import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -127,4 +136,55 @@ public BookingResponseDTO.AvailableTableListDTO getAvailableTables(Long storeId, .tables(availableTables) .build(); } + + @Override + public BookingResponseDTO.BookingPreviewListDTO getBookingList(User user, String status, Integer page) { + PageRequest pageRequest = PageRequest.of(page, 10, Sort.by("bookingDate").descending()); + + Page bookingPage; + + if(status == null || status.equals("ALL")) { + bookingPage = bookingRepository.findAllByUser(user, pageRequest); + } else { + BookingStatus bookingStatus = BookingStatus.valueOf(status); + bookingPage = bookingRepository.findAllByUserAndStatus(user, bookingStatus, pageRequest); + } + + List bookingPreviewDTOList = bookingPage.getContent().stream() + .map(booking -> { + + // 성공한 결제 정보 추출 (1:N 대응) + Payment successPayment = booking.getPayments().stream() + .filter(p -> p.getPaymentStatus() == PaymentStatus.COMPLETED || p.getPaymentStatus() == PaymentStatus.REFUNDED) + .findFirst() + .orElse(null); + + // 테이블 번호들을 하나의 문자열로 합치기 + String tableNumbers = booking.getBookingTables().stream() + .map(bt -> bt.getStoreTable().getTableNumber().toString()) + .collect(Collectors.joining(", ")); + + return BookingResponseDTO.BookingPreviewDTO.builder() + .bookingId(booking.getId()) + .storeName(booking.getStore().getStoreName()) + .storeAddress(booking.getStore().getAddress()) + .bookingDate(booking.getBookingDate()) + .bookingTime(booking.getBookingTime()) + .partySize(booking.getPartySize()) + .tableNumbers(tableNumbers + "번") + .amount(successPayment != null ? successPayment.getAmount() : booking.getDepositAmount()) + .paymentMethod(successPayment != null ? successPayment.getPaymentMethod().name() : "미결제") + .status(booking.getStatus().name()) + .build(); + }).collect(Collectors.toList()); + + return BookingResponseDTO.BookingPreviewListDTO.builder() + .isLast(bookingPage.isLast()) + .isFirst(bookingPage.isFirst()) + .totalPage(bookingPage.getTotalPages()) + .totalElements(bookingPage.getTotalElements()) + .listSize(bookingPreviewDTOList.size()) + .bookingList(bookingPreviewDTOList) + .build(); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingErrorStatus.java index b5690932..7ebe586c 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingErrorStatus.java @@ -17,8 +17,9 @@ public enum BookingErrorStatus implements BaseErrorCode { _INVALID_PARTY_SIZE(HttpStatus.BAD_REQUEST, "BOOKING4001", "인원 설정이 잘못되었습니다."), _ALREADY_RESERVED_TABLE(HttpStatus.CONFLICT, "BOOKING4091", "선택하신 테이블 중 이미 예약된 테이블이 포함되어 있습니다."), _ALREADY_CONFIRMED(HttpStatus.BAD_REQUEST,"BOOKING4002", "이미 확정된 예약입니다."), - _PAYMENT_AMOUNT_MISMATCH(HttpStatus.BAD_REQUEST, "BOOKING4003", "결제 금액이 일치하지 않습니다."); - + _PAYMENT_AMOUNT_MISMATCH(HttpStatus.BAD_REQUEST, "BOOKING4003", "결제 금액이 일치하지 않습니다."), + _ALREADY_CANCELED(HttpStatus.BAD_REQUEST,"BOOKING4004", "이미 취소된 예약입니다."), + ; private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingSuccessStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingSuccessStatus.java index 1e6b1e3e..0454cec3 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingSuccessStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingSuccessStatus.java @@ -13,6 +13,12 @@ public enum BookingSuccessStatus implements BaseCode { _BOOKING_FOUND(HttpStatus.OK, "BOOKING200", "성공적으로 예약을 조회 했습니다."), _BOOKING_DETAIL_FOUND(HttpStatus.FOUND, "BOOKING_DETAIL200", "성공적으로 예약 상세 내역을 조회했습니다."), + + _BOOKING_CREATED(HttpStatus.CREATED, "BOOKING201", "성공적으로 예약이 생성되었습니다."), + + _BOOKING_CONFIRMED(HttpStatus.OK, "BOOKING2001", "성공적으로 예약이 확정되었습니다."), + + _BOOKING_CANCELED(HttpStatus.OK, "BOOKING2002", "성공적으로 예약이 취소되었습니다.") ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/image/status/ImageErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/image/status/ImageErrorStatus.java index f2008156..cfd97401 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/image/status/ImageErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/image/status/ImageErrorStatus.java @@ -12,7 +12,9 @@ public enum ImageErrorStatus implements BaseErrorCode { EMPTY_FILE(HttpStatus.BAD_REQUEST, "IMAGE4001", "업로드할 파일이 비어 있습니다."), INVALID_FILE_TYPE(HttpStatus.BAD_REQUEST, "IMAGE4002", "지원하지 않는 파일 형식입니다."), S3_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "IMAGE5001", "이미지 업로드에 실패했습니다."), - _IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "IMAGE404", "해당하는 이미지가 존재하지 않습니다.") + _IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "IMAGE404", "해당하는 이미지가 존재하지 않습니다."), + _INVALID_IMAGE_KEY(HttpStatus.BAD_REQUEST, "IMAGE4003", "유효하지 않은 이미지 키입니다."), + _INVALID_S3_DIRECTORY(HttpStatus.BAD_REQUEST, "IMAGE4004", "유효하지 않은 S3 디렉토리입니다."), ; @@ -23,7 +25,7 @@ public enum ImageErrorStatus implements BaseErrorCode { @Override public ErrorReasonDto getReason() { return ErrorReasonDto.builder() - .isSuccess(true) + .isSuccess(false) .message(message) .code(code) .build(); @@ -33,7 +35,7 @@ public ErrorReasonDto getReason() { public ErrorReasonDto getReasonHttpStatus() { return ErrorReasonDto.builder() .httpStatus(httpStatus) - .isSuccess(true) + .isSuccess(false) .code(code) .message(message) .build(); diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/controller/MenuController.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/controller/MenuController.java new file mode 100644 index 00000000..cfc3a737 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/controller/MenuController.java @@ -0,0 +1,90 @@ +package com.eatsfine.eatsfine.domain.menu.controller; + +import com.eatsfine.eatsfine.domain.menu.dto.MenuReqDto; +import com.eatsfine.eatsfine.domain.menu.dto.MenuResDto; +import com.eatsfine.eatsfine.domain.menu.service.MenuCommandService; +import com.eatsfine.eatsfine.domain.menu.service.MenuQueryService; +import com.eatsfine.eatsfine.domain.menu.status.MenuSuccessStatus; +import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "Menu", description = "가게 메뉴 관련 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1") +public class MenuController { + + private final MenuCommandService menuCommandService; + private final MenuQueryService menuQueryService; + + @Operation(summary = "메뉴 이미지 선 업로드 API", description = "메뉴 등록 전에 이미지를 먼저 업로드하고 KEY를 반환합니다.") + @PostMapping(value = "/stores/{storeId}/menus/images", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadImage( + @PathVariable Long storeId, + @RequestPart("image") MultipartFile file + ){ + return ApiResponse.of(MenuSuccessStatus._MENU_IMAGE_UPLOAD_SUCCESS, menuCommandService.uploadImage(storeId, file)); + } + + @Operation(summary = "메뉴 등록 API", description = "가게의 메뉴들을 등록합니다.") + @PostMapping("/stores/{storeId}/menus") + public ApiResponse createMenus( + @PathVariable Long storeId, + @RequestBody @Valid MenuReqDto.MenuCreateDto dto + ) { + return ApiResponse.of(MenuSuccessStatus._MENU_CREATE_SUCCESS, menuCommandService.createMenus(storeId, dto)); + } + + @Operation(summary = "메뉴 삭제 API", description = "가게의 메뉴들을 삭제합니다.") + @DeleteMapping("/stores/{storeId}/menus") + public ApiResponse deleteMenus( + @PathVariable Long storeId, + @RequestBody @Valid MenuReqDto.MenuDeleteDto dto + ) { + return ApiResponse.of(MenuSuccessStatus._MENU_DELETE_SUCCESS, menuCommandService.deleteMenus(storeId, dto)); + } + + @Operation(summary = "메뉴 수정 API", description = "가게의 메뉴를 수정합니다.") + @PatchMapping("/stores/{storeId}/menus/{menuId}") + public ApiResponse updateMenu( + @PathVariable Long storeId, + @PathVariable Long menuId, + @RequestBody @Valid MenuReqDto.MenuUpdateDto dto + ) { + return ApiResponse.of(MenuSuccessStatus._MENU_UPDATE_SUCCESS, menuCommandService.updateMenu(storeId, menuId, dto)); + } + + @Operation(summary = "품절 여부 변경 API", description = "메뉴의 품절 여부를 변경합니다.") + @PatchMapping("/stores/{storeId}/menus/{menuId}/sold-out") + public ApiResponse updateSoldOutStatus( + @PathVariable Long storeId, + @PathVariable Long menuId, + @RequestBody @Valid MenuReqDto.SoldOutUpdateDto dto + ){ + return ApiResponse.of(MenuSuccessStatus._SOLD_OUT_UPDATE_SUCCESS, menuCommandService.updateSoldOutStatus(storeId, menuId, dto.isSoldOut())); + } + + @Operation(summary = "등록된 메뉴 이미지 삭제 API", description = "이미 등록된 메뉴의 이미지를 삭제합니다.") + @DeleteMapping("/stores/{storeId}/menus/{menuId}/image") + public ApiResponse deleteMenuImage( + @PathVariable Long storeId, + @PathVariable Long menuId + ) { + return ApiResponse.of(MenuSuccessStatus._MENU_IMAGE_DELETE_SUCCESS, menuCommandService.deleteMenuImage(storeId, menuId)); + } + + @Operation(summary = "메뉴 조회 API", description = "가게의 메뉴들을 조회합니다.") + @GetMapping("/stores/{storeId}/menus") + public ApiResponse getMenus( + @PathVariable Long storeId + ) { + return ApiResponse.of(MenuSuccessStatus._MENU_LIST_SUCCESS, menuQueryService.getMenus(storeId)); + + } +} \ No newline at end of file diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/converter/MenuConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/converter/MenuConverter.java new file mode 100644 index 00000000..702c318b --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/converter/MenuConverter.java @@ -0,0 +1,56 @@ +package com.eatsfine.eatsfine.domain.menu.converter; + +import com.eatsfine.eatsfine.domain.menu.dto.MenuResDto; +import com.eatsfine.eatsfine.domain.menu.entity.Menu; + +import java.util.List; + +public class MenuConverter { + + + public static MenuResDto.ImageUploadDto toImageUploadDto(String imageKey, String imageUrl){ + return MenuResDto.ImageUploadDto.builder() + .imageKey(imageKey) + .imageUrl(imageUrl) + .build(); + } + + public static MenuResDto.ImageDeleteDto toImageDeleteDto(String imageKey) { + return MenuResDto.ImageDeleteDto.builder() + .deletedImageKey(imageKey) + .build(); + } + + + public static MenuResDto.MenuCreateDto toCreateDto(List menuDtos) { + return MenuResDto.MenuCreateDto.builder() + .menus(menuDtos) + .build(); + } + + public static MenuResDto.MenuDeleteDto toDeleteDto(List menuIds){ + return MenuResDto.MenuDeleteDto.builder() + .deletedMenuIds(menuIds) + .build(); + } + + public static MenuResDto.MenuUpdateDto toUpdateDto(Menu menu, String updatedImageUrl){ + return MenuResDto.MenuUpdateDto.builder() + .menuId(menu.getId()) + .name(menu.getName()) + .description(menu.getDescription()) + .price(menu.getPrice()) + .category(menu.getMenuCategory()) + .imageUrl(updatedImageUrl) + .build(); + + } + + public static MenuResDto.SoldOutUpdateDto toSoldOutUpdateDto(Menu menu){ + return MenuResDto.SoldOutUpdateDto.builder() + .menuId(menu.getId()) + .isSoldOut(menu.isSoldOut()) + .build(); + } + +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/dto/MenuReqDto.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/dto/MenuReqDto.java new file mode 100644 index 00000000..bd733fad --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/dto/MenuReqDto.java @@ -0,0 +1,59 @@ +package com.eatsfine.eatsfine.domain.menu.dto; + +import com.eatsfine.eatsfine.domain.menu.enums.MenuCategory; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; + +import java.math.BigDecimal; +import java.util.List; + +public class MenuReqDto { + + public record MenuCreateDto( + @Valid + @NotNull + @Size(min = 1, message = "최소 1개 이상의 메뉴를 등록해야 합니다.") + List menus + ){} + + + public record MenuDto( + @NotBlank(message = "메뉴 이름은 필수입니다.") + String name, + + @Size(max = 500, message = "설명은 500자 이내여야 합니다.") + String description, + + @NotNull(message = "가격은 필수입니다.") + @Min(value = 0, message = "가격은 0원 이상이어야 합니다.") + BigDecimal price, + + @NotNull(message = "카테고리는 필수입니다.") + MenuCategory category, + + String imageKey // 이미지는 선택 사항이므로 검증 없음 (nullable) + ){} + + + public record MenuDeleteDto( + @NotNull + @Size(min = 1, message = "삭제할 메뉴를 최소 1개 이상 선택해주세요.") + List menuIds + ){} + + public record MenuUpdateDto( + @Size(min = 1, message = "메뉴 이름은 1글자 이상이어야 합니다.") + String name, + @Size(max = 500, message = "설명은 500자 이내여야 합니다.") + String description, + @Min(value = 0, message = "가격은 0원 이상이어야 합니다.") + BigDecimal price, + MenuCategory category, + String imageKey + ){} + + public record SoldOutUpdateDto( + @NotNull + Boolean isSoldOut + ){} +} \ No newline at end of file diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/dto/MenuResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/dto/MenuResDto.java new file mode 100644 index 00000000..0a8b419c --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/dto/MenuResDto.java @@ -0,0 +1,75 @@ + +package com.eatsfine.eatsfine.domain.menu.dto; + +import com.eatsfine.eatsfine.domain.menu.enums.MenuCategory; +import lombok.Builder; + +import java.math.BigDecimal; +import java.util.List; + +public class MenuResDto { + + @Builder + public record ImageUploadDto( + String imageKey, // 메뉴 등록/수정 시 서버에 다시 보낼 키 + String imageUrl // 프론트엔드에서 즉시 미리보기를 위한 전체 URL + ){} + + + @Builder + public record ImageDeleteDto( + String deletedImageKey + ){} + + @Builder + public record MenuCreateDto( + List menus + ){} + + @Builder + public record MenuDto( + Long menuId, + String name, + String description, + BigDecimal price, + MenuCategory category, + String imageUrl + ){} + + @Builder + public record MenuDeleteDto( + List deletedMenuIds + ){} + + @Builder + public record MenuUpdateDto( + Long menuId, + String name, + String description, + BigDecimal price, + MenuCategory category, + String imageUrl + ){} + + @Builder + public record SoldOutUpdateDto( + Long menuId, + boolean isSoldOut + ){} + + @Builder + public record MenuListDto( + List menus + ){} + + @Builder + public record MenuDetailDto( + Long menuId, + String name, + String description, + BigDecimal price, + MenuCategory category, + String imageUrl, + boolean isSoldOut + ){} +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/entity/Menu.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/entity/Menu.java new file mode 100644 index 00000000..83119ee0 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/entity/Menu.java @@ -0,0 +1,86 @@ +package com.eatsfine.eatsfine.domain.menu.entity; + +import com.eatsfine.eatsfine.domain.menu.enums.MenuCategory; +import com.eatsfine.eatsfine.domain.store.entity.Store; +import com.eatsfine.eatsfine.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; +import org.hibernate.annotations.Where; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Table(name = "menu") +@SQLDelete(sql = "UPDATE menu SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +public class Menu extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "store_id", nullable = false) + private Store store; + + @Column(name = "name", nullable = false, length = 100) + private String name; + + @Column(name = "description", length = 500) + private String description; + + @Column(name = "price", precision = 10, scale = 0, nullable = false) + private BigDecimal price; + + @Enumerated(EnumType.STRING) + @Column(name = "menu_category", nullable = false) + private MenuCategory menuCategory; + + @Column(name = "image_key") + private String imageKey; + + @Builder.Default + @Column(name = "is_sold_out", nullable = false) + private boolean isSoldOut = false; + + private LocalDateTime deletedAt; + + public void assignStore(Store store) { + this.store = store; + } + + // 품절 여부 변경 + public void updateSoldOut(boolean isSoldOut) { + this.isSoldOut = isSoldOut; + } + + // 메뉴 이미지 변경 + public void updateImageKey(String imageKey) { + this.imageKey = imageKey; + } + + // --- 메뉴 정보 수정 메서드 --- + + public void updateName(String name) { + this.name = name; + } + + public void updateDescription(String description) { + this.description = description; + } + + public void updatePrice(BigDecimal price) { + this.price = price; + } + + public void updateCategory(MenuCategory menuCategory) { + this.menuCategory = menuCategory; + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/enums/MenuCategory.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/enums/MenuCategory.java new file mode 100644 index 00000000..576bde1b --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/enums/MenuCategory.java @@ -0,0 +1,17 @@ +package com.eatsfine.eatsfine.domain.menu.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum MenuCategory { + + MAIN("메인"), + SIDE("사이드"), + BEVERAGE("음료"), + ALCOHOL("주류"); + + + private final String description; +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/exception/MenuException.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/exception/MenuException.java new file mode 100644 index 00000000..c84882ff --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/exception/MenuException.java @@ -0,0 +1,10 @@ +package com.eatsfine.eatsfine.domain.menu.exception; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; + +public class MenuException extends GeneralException { + public MenuException(BaseErrorCode code) { + super(code); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/repository/MenuRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/repository/MenuRepository.java new file mode 100644 index 00000000..80c6562c --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/repository/MenuRepository.java @@ -0,0 +1,10 @@ +package com.eatsfine.eatsfine.domain.menu.repository; + +import com.eatsfine.eatsfine.domain.menu.entity.Menu; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MenuRepository extends JpaRepository { + Optional findByImageKey(String imageKey); +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuCommandService.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuCommandService.java new file mode 100644 index 00000000..862e407b --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuCommandService.java @@ -0,0 +1,15 @@ +package com.eatsfine.eatsfine.domain.menu.service; + +import com.eatsfine.eatsfine.domain.menu.dto.MenuReqDto; +import com.eatsfine.eatsfine.domain.menu.dto.MenuResDto; +import org.springframework.web.multipart.MultipartFile; + +public interface MenuCommandService { + MenuResDto.ImageUploadDto uploadImage(Long storeId, MultipartFile file); + MenuResDto.ImageDeleteDto deleteMenuImage(Long storeId, Long menuId); + MenuResDto.MenuCreateDto createMenus(Long storeId, MenuReqDto.MenuCreateDto menuCreateDto); + MenuResDto.MenuDeleteDto deleteMenus(Long storeId, MenuReqDto.MenuDeleteDto menuDeleteDto); + MenuResDto.MenuUpdateDto updateMenu(Long storeId, Long menuId, MenuReqDto.MenuUpdateDto menuUpdateDto); + MenuResDto.SoldOutUpdateDto updateSoldOutStatus(Long storeId, Long menuId, boolean isSoldOut); + +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuCommandServiceImpl.java new file mode 100644 index 00000000..9685c386 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuCommandServiceImpl.java @@ -0,0 +1,278 @@ +package com.eatsfine.eatsfine.domain.menu.service; + +import com.eatsfine.eatsfine.domain.image.exception.ImageException; +import com.eatsfine.eatsfine.domain.image.status.ImageErrorStatus; +import com.eatsfine.eatsfine.domain.menu.converter.MenuConverter; +import com.eatsfine.eatsfine.domain.menu.dto.MenuReqDto; +import com.eatsfine.eatsfine.domain.menu.dto.MenuResDto; +import com.eatsfine.eatsfine.domain.menu.entity.Menu; +import com.eatsfine.eatsfine.domain.menu.exception.MenuException; +import com.eatsfine.eatsfine.domain.menu.repository.MenuRepository; +import com.eatsfine.eatsfine.domain.menu.status.MenuErrorStatus; +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.global.s3.S3Service; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.Transaction; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Transactional +@Slf4j +public class MenuCommandServiceImpl implements MenuCommandService { + + private final S3Service s3Service; + private final StoreRepository storeRepository; + private final MenuRepository menuRepository; + + @Override + public MenuResDto.MenuCreateDto createMenus(Long storeId, MenuReqDto.MenuCreateDto dto) { + Store store = findAndVerifyStore(storeId); + + List menus = dto.menus().stream() + .map(menuDto -> { + Menu menu = Menu.builder() + .name(menuDto.name()) + .description(menuDto.description()) + .price(menuDto.price()) + .menuCategory(menuDto.category()) + .build(); + + // 임시 이미지 키가 있는 경우, 영구 경로로 이동하고 키를 설정 + String tempImageKey = menuDto.imageKey(); + if (tempImageKey != null && !tempImageKey.isBlank()) { + // 1. 새로운 영구 키 생성 + String extension = s3Service.extractExtension(tempImageKey); + String permanentImageKey = "stores/" + storeId + "/menus/" + UUID.randomUUID() + extension; + + // 2. S3에서 객체 이동 (임시 -> 영구) + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit(){ + try{ + s3Service.moveObject(tempImageKey, permanentImageKey); + } catch (Exception e) { + log.error("temp에서 영구로 이동 실패. Source: {}, Dest: {}", tempImageKey, permanentImageKey); + } + } + + }); + + // 3. 엔티티에 영구 키 저장 + menu.updateImageKey(permanentImageKey); + } + + store.addMenu(menu); + return menu; + }) + .toList(); + + List savedMenus = menuRepository.saveAll(menus); + + List menuDtos = savedMenus.stream().map( + menu -> MenuResDto.MenuDto.builder() + .menuId(menu.getId()) + .name(menu.getName()) + .description(menu.getDescription()) + .price(menu.getPrice()) + .category(menu.getMenuCategory()) + .imageUrl(s3Service.toUrl(menu.getImageKey())) + .build()) + .toList(); + + return MenuConverter.toCreateDto(menuDtos); + } + + @Override + public MenuResDto.MenuDeleteDto deleteMenus(Long storeId, MenuReqDto.MenuDeleteDto dto) { + Store store = findAndVerifyStore(storeId); + + List menuIds = dto.menuIds(); + List menusToDelete = menuRepository.findAllById(dto.menuIds()); + + if(menusToDelete.size() != menuIds.size()) { + throw new MenuException(MenuErrorStatus._MENU_NOT_FOUND); + } + + // 1. 모든 메뉴가 해당 가게 소유인지 확인하고, S3 이미지 삭제 + menusToDelete.forEach(menu -> { + verifyMenuBelongsToStore(menu, storeId); + // Soft Delete 시 연결된 S3 이미지도 함께 삭제 + if (menu.getImageKey() != null && !menu.getImageKey().isBlank()) { + String imageKey = menu.getImageKey(); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + s3Service.deleteByKey(imageKey); + } + }); + } + }); + + // 2. DB에서 Soft Delete 실행 + // Menu 엔티티의 @SQLDelete 어노테이션 덕분에 deleteAll이 UPDATE로 동작함 + menuRepository.deleteAll(menusToDelete); + + // 3. Store 컬렉션에서 제거 + store.getMenus().removeAll(menusToDelete); + + return MenuConverter.toDeleteDto(menuIds); + } + + @Override + public MenuResDto.MenuUpdateDto updateMenu(Long storeId, Long menuId, MenuReqDto.MenuUpdateDto dto) { + Store store = findAndVerifyStore(storeId); + + // TODO: [보안] Spring Security 병합 후, 현재 로그인한 사용자가 이 가게의 주인인지 확인하는 로직 추가 + + Menu menu = menuRepository.findById(menuId) + .orElseThrow(() -> new MenuException(MenuErrorStatus._MENU_NOT_FOUND)); + + verifyMenuBelongsToStore(menu, storeId); + + // 이름, 설명, 가격, 카테고리 업데이트 + Optional.ofNullable(dto.name()).ifPresent(menu::updateName); + Optional.ofNullable(dto.description()).ifPresent(menu::updateDescription); + Optional.ofNullable(dto.price()).ifPresent(menu::updatePrice); + Optional.ofNullable(dto.category()).ifPresent(menu::updateCategory); + + Optional.ofNullable(dto.imageKey()).ifPresent(newImageKey -> { + // 1. [Safety] 변경된 내용이 없으면 스킵 (프론트에서 기존 키를 그대로 보낸 경우) + if (newImageKey.equals(menu.getImageKey())) { + return; + } + + // 새로운 이미지가 있다면 영구 경로로 이동 (Temp -> Perm) + if (newImageKey != null && !newImageKey.isBlank()) { + String extension = s3Service.extractExtension(newImageKey); + String permanentImageKey = "stores/" + storeId + "/menus/" + UUID.randomUUID() + extension; + String oldImageKey = menu.getImageKey(); + + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + try { + s3Service.moveObject(newImageKey, permanentImageKey); + if (oldImageKey != null && !oldImageKey.isBlank()) { + s3Service.deleteByKey(oldImageKey); + } + } + catch (Exception e) { + log.error("메뉴 이미지를 s3에 업데이트하는 데에 실패했습니다.", e); + } + } + }); + + menu.updateImageKey(permanentImageKey); + + } else { + // 빈 문자열("")인 경우 -> 이미지 삭제 요청 + if (menu.getImageKey() != null && !menu.getImageKey().isBlank()) { + String oldImageKey = menu.getImageKey(); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + s3Service.deleteByKey(oldImageKey); + } + }); + } + menu.updateImageKey(null); + } + }); + + String updatedImageUrl = s3Service.toUrl(menu.getImageKey()); + + return MenuConverter.toUpdateDto(menu, updatedImageUrl); + } + + @Override + public MenuResDto.SoldOutUpdateDto updateSoldOutStatus(Long storeId, Long menuId, boolean isSoldOut) { + findAndVerifyStore(storeId); + + Menu menu = menuRepository.findById(menuId) + .orElseThrow(() -> new MenuException(MenuErrorStatus._MENU_NOT_FOUND)); + + verifyMenuBelongsToStore(menu, storeId); + + // 기존 값과 동일하다면 바로 리턴 + if(menu.isSoldOut() == isSoldOut) { + return MenuConverter.toSoldOutUpdateDto(menu); + } + + menu.updateSoldOut(isSoldOut); + + return MenuConverter.toSoldOutUpdateDto(menu); + + } + + @Override + public MenuResDto.ImageUploadDto uploadImage(Long storeId, MultipartFile file) { + Store store = findAndVerifyStore(storeId); + + if(file.isEmpty()) { + throw new ImageException(ImageErrorStatus.EMPTY_FILE); + } + + // 이미지를 항상 임시 경로에 업로드 + String tempPath = "temp/menus"; + String imageKey = s3Service.upload(file, tempPath); + + return MenuConverter.toImageUploadDto(imageKey, s3Service.toUrl(imageKey)); + } + + @Override + public MenuResDto.ImageDeleteDto deleteMenuImage(Long storeId, Long menuId) { + findAndVerifyStore(storeId); + + Menu menu = menuRepository.findById(menuId) + .orElseThrow(() -> new MenuException(MenuErrorStatus._MENU_NOT_FOUND)); + + verifyMenuBelongsToStore(menu, storeId); + + String imageKey = menu.getImageKey(); + + if (imageKey == null || imageKey.isBlank()) { + // 이미지가 없는 메뉴에 삭제 요청이 온 경우, 예외 + throw new ImageException(ImageErrorStatus._IMAGE_NOT_FOUND); + } + + // 1. S3에서 파일 삭제 + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + s3Service.deleteByKey(imageKey); + } + }); + + // 2. DB에서 imageKey를 null로 업데이트 (Dirty Checking) + menu.updateImageKey(null); + + return MenuConverter.toImageDeleteDto(imageKey); // 삭제된 이미지의 키를 반환 + } + + private Store findAndVerifyStore(Long storeId) { + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + // TODO: [보안] Spring Security 병합 후, 현재 로그인한 사용자가 이 가게의 주인인지 확인하는 로직 추가 + return store; + } + + private void verifyMenuBelongsToStore(Menu menu, Long storeId) { + if (!menu.getStore().getId().equals(storeId)) { + // 다른 가게의 메뉴를 조작하려는 시도 방지 + throw new StoreException(StoreErrorStatus._STORE_NOT_OWNER); + } + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuQueryService.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuQueryService.java new file mode 100644 index 00000000..f5c80cb7 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuQueryService.java @@ -0,0 +1,7 @@ +package com.eatsfine.eatsfine.domain.menu.service; + +import com.eatsfine.eatsfine.domain.menu.dto.MenuResDto; + +public interface MenuQueryService { + MenuResDto.MenuListDto getMenus(Long storeId); +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuQueryServiceImpl.java new file mode 100644 index 00000000..ddb0e546 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuQueryServiceImpl.java @@ -0,0 +1,45 @@ +package com.eatsfine.eatsfine.domain.menu.service; + +import com.eatsfine.eatsfine.domain.menu.dto.MenuResDto; +import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; +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.global.s3.S3Service; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MenuQueryServiceImpl implements MenuQueryService { + private final StoreRepository storeRepository; + private final S3Service s3Service; + + @Override + public MenuResDto.MenuListDto getMenus(Long storeId) { + Store store = storeRepository.findByIdWithMenus(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + List menuDtos = store.getMenus().stream() + .map(menu -> MenuResDto.MenuDetailDto.builder() + .menuId(menu.getId()) + .name(menu.getName()) + .description(menu.getDescription()) + .price(menu.getPrice()) + .category(menu.getMenuCategory()) + .imageUrl(s3Service.toUrl(menu.getImageKey())) + .isSoldOut(menu.isSoldOut()) + .build() + ) + .toList(); + + return MenuResDto.MenuListDto.builder() + .menus(menuDtos) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/status/MenuErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/status/MenuErrorStatus.java new file mode 100644 index 00000000..b467af0a --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/status/MenuErrorStatus.java @@ -0,0 +1,40 @@ +package com.eatsfine.eatsfine.domain.menu.status; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.code.ErrorReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum MenuErrorStatus implements BaseErrorCode { + + _MENU_NOT_FOUND(HttpStatus.NOT_FOUND, "MENU404", "메뉴를 찾을 수 없습니다."), + + ; + + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDto getReason() { + return ErrorReasonDto.builder() + .isSuccess(false) + .message(message) + .code(code) + .build(); + } + + @Override + public ErrorReasonDto getReasonHttpStatus() { + return ErrorReasonDto.builder() + .isSuccess(false) + .httpStatus(httpStatus) + .message(message) + .code(code) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/status/MenuSuccessStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/status/MenuSuccessStatus.java new file mode 100644 index 00000000..1a696bef --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/status/MenuSuccessStatus.java @@ -0,0 +1,50 @@ +package com.eatsfine.eatsfine.domain.menu.status; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseCode; +import com.eatsfine.eatsfine.global.apiPayload.code.ReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum MenuSuccessStatus implements BaseCode { + + + _MENU_IMAGE_UPLOAD_SUCCESS(HttpStatus.CREATED, "MENU201", "메뉴 이미지 업로드 성공"), + + _MENU_IMAGE_DELETE_SUCCESS(HttpStatus.OK, "MENU200", "메뉴 이미지 삭제 성공"), + + + _MENU_CREATE_SUCCESS(HttpStatus.CREATED, "MENU202", "메뉴 생성 성공"), + _MENU_DELETE_SUCCESS(HttpStatus.OK, "MENU2002", "메뉴 삭제 성공"), + _MENU_UPDATE_SUCCESS(HttpStatus.OK, "MENU2003", "메뉴 수정 성공"), + _SOLD_OUT_UPDATE_SUCCESS(HttpStatus.OK, "MENU2005", "품절 여부 변경 성공"), + _MENU_LIST_SUCCESS(HttpStatus.OK, "MENU2004", "메뉴 조회 성공"), + + ; + + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ReasonDto getReason() { + return ReasonDto.builder() + .isSuccess(true) + .message(message) + .code(code) + .build(); + } + + @Override + public ReasonDto getReasonHttpStatus() { + return ReasonDto.builder() + .isSuccess(true) + .httpStatus(httpStatus) + .message(message) + .code(code) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java index 83c10d6a..f50e17a1 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java @@ -1,5 +1,4 @@ package com.eatsfine.eatsfine.domain.payment.controller; - import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentConfirmDTO; import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentRequestDTO; import com.eatsfine.eatsfine.domain.payment.dto.response.PaymentResponseDTO; @@ -34,7 +33,7 @@ public ApiResponse requestPayment( @Operation(summary = "결제 승인", description = "토스페이먼츠 결제 승인을 요청합니다.") @PostMapping("/confirm") - public ApiResponse confirmPayment( + public ApiResponse confirmPayment( @RequestBody @Valid PaymentConfirmDTO dto) { return ApiResponse.onSuccess(paymentService.confirmPayment(dto)); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentConfirmDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentConfirmDTO.java index 863c4a69..9c936a68 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentConfirmDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentConfirmDTO.java @@ -3,9 +3,11 @@ import jakarta.validation.constraints.NotNull; import lombok.Builder; +import java.math.BigDecimal; + @Builder public record PaymentConfirmDTO( @NotNull String paymentKey, @NotNull String orderId, - @NotNull Integer amount) { + @NotNull BigDecimal amount) { } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java index 9a006481..8c690061 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java @@ -1,8 +1,7 @@ package com.eatsfine.eatsfine.domain.payment.dto.response; -import com.eatsfine.eatsfine.domain.payment.enums.PaymentMethod; -import com.eatsfine.eatsfine.domain.payment.enums.PaymentStatus; +import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List; @@ -12,7 +11,7 @@ public record PaymentRequestResultDTO( Long paymentId, Long bookingId, String orderId, - Integer amount, + BigDecimal amount, LocalDateTime requestedAt) { } @@ -27,8 +26,8 @@ public record CancelPaymentResultDTO( public record PaymentHistoryResultDTO( Long paymentId, Long bookingId, - String restaurantName, - Integer amount, + String storeName, + BigDecimal amount, String paymentType, String paymentMethod, String paymentProvider, @@ -50,10 +49,10 @@ public record PaginationDTO( public record PaymentDetailResultDTO( Long paymentId, Long bookingId, - String restaurantName, + String storeName, String paymentMethod, String paymentProvider, - Integer amount, + BigDecimal amount, String paymentType, String status, LocalDateTime requestedAt, @@ -61,4 +60,15 @@ public record PaymentDetailResultDTO( String receiptUrl, String refundInfo) { } + + public record PaymentSuccessResultDTO( + Long paymentId, + String status, + LocalDateTime approvedAt, + String orderId, + BigDecimal amount, + String paymentMethod, + String paymentProvider, + String receiptUrl) { + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java index ebf44154..710c504d 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java @@ -9,6 +9,7 @@ import jakarta.persistence.*; import lombok.*; +import java.math.BigDecimal; import java.time.LocalDateTime; @Entity @@ -32,7 +33,7 @@ public class Payment extends BaseEntity { private String orderId; @Column(name = "amount", nullable = false) - private Integer amount; + private BigDecimal amount; @Column(name = "payment_key") private String paymentKey; @@ -62,10 +63,6 @@ public class Payment extends BaseEntity { @Column(name = "receipt_url") private String receiptUrl; - public void setPaymentKey(String paymentKey) { - this.paymentKey = paymentKey; - } - public void completePayment(LocalDateTime approvedAt, PaymentMethod method, String paymentKey, PaymentProvider provider, String receiptUrl) { this.paymentStatus = PaymentStatus.COMPLETED; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java index e8a9cb9e..44d38aa8 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java @@ -24,6 +24,8 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.RestClient; +import java.math.BigDecimal; +import org.springframework.data.domain.PageRequest; import java.time.LocalDateTime; import java.util.UUID; import java.util.List; @@ -47,7 +49,7 @@ public PaymentResponseDTO.PaymentRequestResultDTO requestPayment(PaymentRequestD String orderId = UUID.randomUUID().toString(); // 예약금 검증 - if (booking.getDepositAmount() == null || booking.getDepositAmount() <= 0) { + if (booking.getDepositAmount() == null || booking.getDepositAmount().compareTo(BigDecimal.ZERO) <= 0) { throw new PaymentException(PaymentErrorStatus._PAYMENT_INVALID_DEPOSIT); } @@ -71,15 +73,14 @@ public PaymentResponseDTO.PaymentRequestResultDTO requestPayment(PaymentRequestD } @Transactional(noRollbackFor = GeneralException.class) - public PaymentResponseDTO.PaymentRequestResultDTO confirmPayment(PaymentConfirmDTO dto) { + public PaymentResponseDTO.PaymentSuccessResultDTO confirmPayment(PaymentConfirmDTO dto) { Payment payment = paymentRepository.findByOrderId(dto.orderId()) .orElseThrow(() -> new PaymentException(PaymentErrorStatus._PAYMENT_NOT_FOUND)); - if (!payment.getAmount().equals(dto.amount())) { + if (payment.getAmount().compareTo(dto.amount()) != 0) { payment.failPayment(); throw new PaymentException(PaymentErrorStatus._PAYMENT_INVALID_AMOUNT); } - // 토스 API 호출 TossPaymentResponse response; try { @@ -121,12 +122,15 @@ public PaymentResponseDTO.PaymentRequestResultDTO confirmPayment(PaymentConfirmD log.info("Payment confirmed for OrderID: {}", dto.orderId()); - return new PaymentResponseDTO.PaymentRequestResultDTO( + return new PaymentResponseDTO.PaymentSuccessResultDTO( payment.getId(), - payment.getBooking().getId(), + payment.getPaymentStatus().name(), + payment.getApprovedAt(), payment.getOrderId(), payment.getAmount(), - payment.getRequestedAt()); + payment.getPaymentMethod() != null ? payment.getPaymentMethod().name() : null, + payment.getPaymentProvider() != null ? payment.getPaymentProvider().name() : null, + payment.getReceiptUrl()); } @Transactional(noRollbackFor = GeneralException.class) @@ -171,7 +175,7 @@ public PaymentResponseDTO.PaymentListResponseDTO getPaymentList(Long userId, Int // page 기본값 처리 (만약 null이면 1, 0보다 작으면 1로 보정). Spring Data는 0-based index이므로 -1 int pageNumber = (page != null && page > 0) ? page - 1 : 0; - Pageable pageable = org.springframework.data.domain.PageRequest.of(pageNumber, size); + Pageable pageable = PageRequest.of(pageNumber, size); Page paymentPage; if (status != null && !status.isEmpty()) { @@ -197,7 +201,8 @@ public PaymentResponseDTO.PaymentListResponseDTO getPaymentList(Long userId, Int payment.getPaymentType().name(), payment.getPaymentMethod() != null ? payment.getPaymentMethod().name() : null, - payment.getPaymentProvider() != null ? payment.getPaymentProvider().name() + payment.getPaymentProvider() != null + ? payment.getPaymentProvider().name() : null, payment.getPaymentStatus().name(), payment.getApprovedAt())) 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 962c97aa..6166ca25 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 @@ -48,7 +48,6 @@ public static StoreResDto.StoreDetailDto toDetailDto(Store store, boolean isOpen .reviewCount(null) // reviewCount는 추후 리뷰 로직 구현 시 추가 예정 .mainImageUrl(store.getMainImageKey()) .tableImageUrls(Collections.emptyList()) // tableImages는 추후 사진 등록 API 구현 시 추가 예정 - .depositAmount(store.calculateDepositAmount()) .businessHours( store.getBusinessHours().stream() .map(BusinessHoursConverter::toSummary) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java index 798dad94..70e513c4 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java @@ -53,9 +53,6 @@ public record StoreCreateDto( @NotNull(message = "카테고리는 필수입니다.") Category category, - @NotNull(message = "최소 메뉴 가격은 필수입니다.") - int minPrice, - @NotNull(message = "예약금 비율은 필수입니다.") DepositRate depositRate, @@ -79,8 +76,6 @@ public record StoreUpdateDto( Category category, - Integer minPrice, - DepositRate depositRate, Integer bookingIntervalMinutes 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 c9ee44a2..8431ff63 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 @@ -54,7 +54,6 @@ public record StoreDetailDto( Category category, BigDecimal rating, Long reviewCount, - BigDecimal depositAmount, String mainImageUrl, List tableImageUrls, List businessHours, diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java index fcd2bb26..1d56603d 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java @@ -3,6 +3,7 @@ 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.menu.entity.Menu; import com.eatsfine.eatsfine.domain.region.entity.Region; import com.eatsfine.eatsfine.domain.store.dto.StoreReqDto; import com.eatsfine.eatsfine.domain.store.enums.Category; @@ -15,6 +16,7 @@ import com.eatsfine.eatsfine.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.BatchSize; import java.math.BigDecimal; import java.math.RoundingMode; @@ -80,9 +82,6 @@ public class Store extends BaseEntity { @Column(name = "booking_interval_minutes", nullable = false) private int bookingIntervalMinutes = 30; - @Column(name = "min_price", nullable = false) - private int minPrice; - @Enumerated(EnumType.STRING) @Column(name = "deposit_rate", nullable = false) private DepositRate depositRate; @@ -91,6 +90,12 @@ public class Store extends BaseEntity { @OneToMany(mappedBy = "store", cascade = CascadeType.ALL, orphanRemoval = true) private List businessHours = new ArrayList<>(); + @Builder.Default + @BatchSize(size = 100) + @OneToMany(mappedBy = "store", cascade = CascadeType.ALL) + private List menus = new ArrayList<>(); + + @Builder.Default @OneToMany(mappedBy = "store", cascade = CascadeType.ALL, orphanRemoval = true) private List tableImages = new ArrayList<>(); @@ -119,6 +124,13 @@ public void updateBusinessHours(DayOfWeek dayOfWeek, LocalTime open, LocalTime c businessHours.update(open, close, isClosed); } + + // 메뉴 추가 + public void addMenu(Menu menu) { + this.menus.add(menu); + menu.assignStore(this); + } + public void addTableImage(TableImage tableImage) { this.tableImages.add(tableImage); tableImage.assignStore(this); @@ -150,14 +162,6 @@ public Optional findBusinessHoursByDay(DayOfWeek dayOfWeek) { .findFirst(); } - // 예약금 계산 메서드 - public BigDecimal calculateDepositAmount() { - return BigDecimal.valueOf(minPrice) - .multiply(BigDecimal.valueOf(depositRate.getPercent())) - .divide(BigDecimal.valueOf(100), 0, RoundingMode.DOWN); - } - - // StoreTable에 대한 연관관계 편의 메서드는 추후 추가 예정 // 가게 기본 정보 변경 메서드 public void updateBasicInfo(StoreReqDto.StoreUpdateDto dto) { @@ -177,9 +181,6 @@ public void updateBasicInfo(StoreReqDto.StoreUpdateDto dto) { this.category = dto.category(); } - if(dto.minPrice() != null) { - this.minPrice = dto.minPrice(); - } if(dto.depositRate() != null) { this.depositRate = dto.depositRate(); 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 7c72fd36..6cd3f595 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 @@ -2,7 +2,21 @@ import com.eatsfine.eatsfine.domain.store.entity.Store; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; public interface StoreRepository extends JpaRepository, StoreRepositoryCustom { + + @Query(""" + select s from Store s + left join fetch s.menus m + where s.id = :id + and (m.deletedAt IS NULL or m.id IS NULL) + +""") + Optional findByIdWithMenus(@Param("id") Long id); + } \ No newline at end of file diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java index d8c4c340..e9054cc4 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java @@ -67,7 +67,6 @@ public StoreResDto.StoreCreateDto createStore(StoreReqDto.StoreCreateDto dto) { .phoneNumber(dto.phoneNumber()) .category(dto.category()) .bookingIntervalMinutes(dto.bookingIntervalMinutes()) - .minPrice(dto.minPrice()) .depositRate(dto.depositRate()) .build(); @@ -101,7 +100,6 @@ public List extractUpdatedFields(StoreReqDto.StoreUpdateDto dto) { if (dto.description() != null) updated.add("description"); if (dto.phoneNumber() != null) updated.add("phoneNumber"); if (dto.category() != null) updated.add("category"); - if (dto.minPrice() != null) updated.add("minPrice"); if (dto.depositRate() != null) updated.add("depositRate"); if (dto.bookingIntervalMinutes() != null) updated.add("bookingIntervalMinutes"); diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreErrorStatus.java index b52dda24..0e0f4557 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreErrorStatus.java @@ -13,7 +13,10 @@ public enum StoreErrorStatus implements BaseErrorCode { _STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE404", "해당하는 가게를 찾을 수 없습니다."), _STORE_DETAIL_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE_DETAIL404", "가게 상세 정보를 찾을 수 없습니다."), - _STORE_NOT_OPEN_ON_DAY(HttpStatus.NOT_FOUND,"STORE4041" , "해당 영업시간 정보를 찾을 수 없습니다."),; + _STORE_NOT_OPEN_ON_DAY(HttpStatus.NOT_FOUND,"STORE4041" , "해당 영업시간 정보를 찾을 수 없습니다."), + + _STORE_NOT_OWNER(HttpStatus.FORBIDDEN, "STORE403", "해당 가게의 주인이 아닙니다."), + ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableController.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableController.java index d9c9ca9f..08c4b3d3 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableController.java @@ -5,11 +5,17 @@ import com.eatsfine.eatsfine.domain.storetable.exception.status.StoreTableSuccessStatus; import com.eatsfine.eatsfine.domain.storetable.service.StoreTableCommandService; import com.eatsfine.eatsfine.domain.storetable.service.StoreTableQueryService; +import com.eatsfine.eatsfine.domain.tableimage.status.TableImageSuccessStatus; import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDate; import java.time.LocalDate; @@ -38,4 +44,51 @@ public ApiResponse getTableSlots( LocalDate targetDate = (date != null) ? date : LocalDate.now(); return ApiResponse.of(StoreTableSuccessStatus._SLOT_LIST_FOUND, storeTableQueryService.getTableSlots(storeId, tableId, targetDate)); } + + @GetMapping("/stores/{storeId}/tables/{tableId}") + public ApiResponse getTableDetail( + @PathVariable Long storeId, + @PathVariable Long tableId, + @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date + ) { + LocalDate targetDate = (date != null) ? date : LocalDate.now(); + return ApiResponse.of(StoreTableSuccessStatus._TABLE_DETAIL_FOUND, storeTableQueryService.getTableDetail(storeId, tableId, targetDate)); + } + + @PatchMapping("/stores/{storeId}/tables/{tableId}") + public ApiResponse updateTable( + @PathVariable Long storeId, + @PathVariable Long tableId, + @RequestBody @Valid StoreTableReqDto.TableUpdateDto dto + ) { + return ApiResponse.of(StoreTableSuccessStatus._TABLE_UPDATED, storeTableCommandService.updateTable(storeId, tableId, dto)); + } + + @DeleteMapping("/stores/{storeId}/tables/{tableId}") + public ApiResponse deleteTable( + @PathVariable Long storeId, + @PathVariable Long tableId + ) { + return ApiResponse.of(StoreTableSuccessStatus._TABLE_DELETED, storeTableCommandService.deleteTable(storeId, tableId)); + } + + @PostMapping( + value = "/stores/{storeId}/tables/{tableId}/table-image", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE + ) + public ApiResponse uploadTableImage( + @PathVariable Long storeId, + @PathVariable Long tableId, + @RequestPart("tableImage") MultipartFile tableImage + ) { + return ApiResponse.of(TableImageSuccessStatus._STORE_TABLE_IMAGE_UPLOAD_SUCCESS, storeTableCommandService.uploadTableImage(storeId, tableId, tableImage)); + } + + @DeleteMapping("/stores/{storeId}/tables/{tableId}/table-image") + public ApiResponse deleteTableImage( + @PathVariable Long storeId, + @PathVariable Long tableId + ) { + return ApiResponse.of(TableImageSuccessStatus._STORE_TABLE_IMAGE_DELETE_SUCCESS, storeTableCommandService.deleteTableImage(storeId, tableId)); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableControllerDocs.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableControllerDocs.java index 5e83c805..c9ef9701 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableControllerDocs.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableControllerDocs.java @@ -5,9 +5,14 @@ import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.validation.Valid; import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; @@ -68,4 +73,177 @@ ApiResponse getTableSlots( @Parameter(description = "조회할 날짜 (yyyy-MM-dd 형식, 미입력 시 오늘 날짜)", example = "2026-01-12") @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date ); + + @Operation( + summary = "테이블 상세 조회", + description = """ + 특정 테이블의 상세 정보를 조회합니다. + - 테이블 기본 정보 (최소/최대 인원, 이미지, 평점, 리뷰 수, 테이블 유형) + - 예약 가능 상태 (날짜별 총 슬롯 수, 예약 가능한 슬롯 수) + - date 파라미터가 없으면 오늘 날짜로 조회합니다. + """ + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "테이블 상세 조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "테이블이 가게에 속하지 않음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "테이블을 찾을 수 없음") + }) + ApiResponse getTableDetail( + @Parameter(description = "가게 ID", required = true, example = "1") + Long storeId, + + @Parameter(description = "테이블 ID", required = true, example = "1") + Long tableId, + + @Parameter(description = "조회 날짜 (yyyy-MM-dd)", example = "2026-01-23") + LocalDate date + ); + + @Operation( + summary = "테이블 정보 수정", + description = """ + 특정 테이블의 정보를 수정합니다. + + **통합 API**: 테이블 번호, 좌석 수, 테이블 유형을 하나의 API에서 처리합니다. + + - **선택적 업데이트**: 모든 필드가 Optional이며, 제공된 필드만 업데이트됩니다. + - **최소 하나 필수**: 최소 하나 이상의 필드는 반드시 제공되어야 합니다. + + 1. **테이블 번호 (tableNumber)**: + - 숫자 문자열로 전달 (예: "3") + - 자동으로 "N번 테이블" 형식으로 변환 + - 중복 시 기존 테이블과 번호 스왑 + + 2. **좌석 수 (minSeatCount, maxSeatCount)**: + - 둘 중 하나만 제공 시, 다른 값은 기존 값 유지 + - 최소 인원 ≤ 최대 인원 검증 + + 3. **테이블 유형 (seatsType)**: + - GENERAL, WINDOW, ROOM, BAR, OUTDOOR 중 선택 + + ### 응답: + - updatedTables: 변경된 테이블 정보만 표시 + - 번호 스왑 발생 시 두 테이블 모두 포함 + - 스왑 없을 시 요청 테이블만 포함 + + ### 예시: + ```json + // Request (모든 필드 수정) + { + "tableNumber": "5", + "minSeatCount": 2, + "maxSeatCount": 4, + "seatsType": "ROOM" + } + + // Request (번호만 수정) + { + "tableNumber": "3" + } + + // Request (좌석 수만 수정) + { + "minSeatCount": 4, + "maxSeatCount": 6 + } + + // Request (좌석 유형만 수정) + { + "seatsType": "WINDOW" + } + ``` + """ + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "테이블 수정 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청 (수정 필드 없음, 좌석 범위 오류 등)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "가게 또는 테이블을 찾을 수 없음") + }) + ApiResponse updateTable( + @Parameter(description = "가게 ID", required = true, example = "1") + Long storeId, + + @Parameter(description = "테이블 ID", required = true, example = "1") + Long tableId, + + @RequestBody @Valid StoreTableReqDto.TableUpdateDto dto + ); + + @Operation( + summary = "테이블 삭제", + description = """ + 특정 가게의 테이블을 삭제합니다. + + **삭제 조건:** + - 현재 시간 이후의 예약(CONFIRMED 또는 PENDING 상태)이 존재하는 테이블은 삭제할 수 없습니다. + - Soft Delete 방식으로 처리되어 실제 데이터는 삭제되지 않고 is_deleted 플래그가 true로 변경됩니다. + - deleted_at 필드에 삭제 시간이 기록됩니다. + - 삭제된 테이블 위치에 새 테이블 생성 시, 겹침 검증 로직에서 삭제된 테이블은 제외됩니다. + """ + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "테이블 삭제 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "테이블에 미래 예약이 존재함"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "테이블 또는 가게를 찾을 수 없음") + }) + ApiResponse deleteTable( + @Parameter(description = "가게 ID", required = true, example = "1") + Long storeId, + @Parameter(description = "테이블 ID", required = true, example = "1") + Long tableId + ); + + @Operation( + summary = "테이블 이미지 등록", + description = """ + 특정 테이블의 이미지를 등록합니다. + + - 테이블당 1개의 이미지만 등록 가능합니다. + - 기존 이미지가 있는 경우 자동으로 삭제되고 새 이미지로 교체됩니다. + - S3 저장 경로: stores/{storeId}/tables/{tableId}/ + """ + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "테이블 이미지 등록 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청 (빈 파일, 지원하지 않는 파일 형식 등)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "가게 또는 테이블을 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "S3 업로드 실패") + }) + ApiResponse uploadTableImage( + @Parameter(description = "가게 ID", required = true, example = "1") + Long storeId, + + @Parameter(description = "테이블 ID", required = true, example = "1") + Long tableId, + + @Parameter( + description = "업로드할 테이블 이미지 파일", + required = true, + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE) + ) + MultipartFile tableImage + ); + + @Operation( + summary = "테이블 이미지 삭제", + description = """ + 특정 테이블의 이미지를 삭제합니다. + + - 등록된 이미지가 없는 경우 404 에러가 발생합니다. + - S3에서 이미지가 삭제되고, DB의 이미지 URL도 null로 업데이트됩니다. + - 삭제 후 다시 이미지를 등록할 수 있습니다. + """ + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "테이블 이미지 삭제 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "테이블이 해당 가게에 속하지 않음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "가게, 테이블을 찾을 수 없거나 이미지가 등록되지 않음") + }) + ApiResponse deleteTableImage( + @Parameter(description = "가게 ID", required = true, example = "1") + Long storeId, + + @Parameter(description = "테이블 ID", required = true, example = "1") + Long tableId + ); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/converter/StoreTableConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/converter/StoreTableConverter.java index 9a00e2f5..98619e07 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/converter/StoreTableConverter.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/converter/StoreTableConverter.java @@ -1,9 +1,11 @@ package com.eatsfine.eatsfine.domain.storetable.converter; +import com.eatsfine.eatsfine.domain.storetable.dto.req.StoreTableReqDto; import com.eatsfine.eatsfine.domain.storetable.dto.res.StoreTableResDto; import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; import com.eatsfine.eatsfine.domain.storetable.util.SlotCalculator; +import java.time.LocalDate; import java.util.List; public class StoreTableConverter { @@ -40,4 +42,69 @@ public static StoreTableResDto.SlotListDto toSlotListDto(int totalCount, int ava .slots(slotDetails) .build(); } + + public static StoreTableResDto.TableDetailDto toTableDetailDto(StoreTable table, LocalDate targetDate, int totalSlotCount, int availableSlotCount, String tableImageUrl) { + return StoreTableResDto.TableDetailDto.builder() + .tableId(table.getId()) + .minSeatCount(table.getMinSeatCount()) + .maxSeatCount(table.getMaxSeatCount()) + .tableImageUrl(tableImageUrl) + .rating(table.getRating()) + .reviewCount(0) // 리뷰 기능 미구현으로 0 반환 + .seatsType(table.getSeatsType()) + .reservationStatus( + StoreTableResDto.ReservationStatusDto.builder() + .targetDate(targetDate) + .totalSlotCount(totalSlotCount) + .availableSlotCount(availableSlotCount) + .build() + ) + .build(); + } + + public static StoreTableResDto.TableUpdateResultDto toTableUpdateResultDto(List updatedTables, StoreTableReqDto.TableUpdateDto requestDto) { + List updatedTableDtoList = updatedTables.stream() + .map(table -> { + var builder = StoreTableResDto.UpdatedTableDto.builder() + .tableId(table.getId()); // tableId는 항상 포함 + + // 요청 DTO에 있는 필드만 응답에 포함 + if (requestDto.tableNumber() != null) { + builder.tableNumber(table.getTableNumber()); + } + if (requestDto.minSeatCount() != null || requestDto.maxSeatCount() != null) { + builder.minSeatCount(table.getMinSeatCount()); + builder.maxSeatCount(table.getMaxSeatCount()); + } + if (requestDto.seatsType() != null) { + builder.seatsType(table.getSeatsType()); + } + + return builder.build(); + }) + .toList(); + + return StoreTableResDto.TableUpdateResultDto.builder() + .updatedTables(updatedTableDtoList) + .build(); + } + + public static StoreTableResDto.TableDeleteDto toTableDeleteDto(StoreTable table) { + return StoreTableResDto.TableDeleteDto.builder() + .tableId(table.getId()) + .build(); + } + + public static StoreTableResDto.UploadTableImageDto toUploadTableImageDto(Long tableId, String tableImageUrl) { + return StoreTableResDto.UploadTableImageDto.builder() + .tableId(tableId) + .tableImageUrl(tableImageUrl) + .build(); + } + + public static StoreTableResDto.DeleteTableImageDto toDeleteTableImageDto(Long tableId) { + return StoreTableResDto.DeleteTableImageDto.builder() + .tableId(tableId) + .build(); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/req/StoreTableReqDto.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/req/StoreTableReqDto.java index 559366da..ee544355 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/req/StoreTableReqDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/req/StoreTableReqDto.java @@ -4,6 +4,7 @@ import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; public class StoreTableReqDto { public record TableCreateDto( @@ -30,4 +31,25 @@ public record TableCreateDto( String tableImageUrl ) {} + + public record TableUpdateDto( + @Pattern(regexp = "^[1-9]\\d*$", message = "테이블 번호는 1 이상의 숫자여야 합니다.") + String tableNumber, + + @Min(value = 1, message = "최소 인원은 1명 이상이어야 합니다.") + @Max(value = 20, message = "최소 인원은 20명 이하여야 합니다.") + Integer minSeatCount, + + @Min(value = 1, message = "최대 인원은 1명 이상이어야 합니다.") + @Max(value = 20, message = "최대 인원은 20명 이하여야 합니다.") + Integer maxSeatCount, + + SeatsType seatsType + ) { + // 최소 하나의 필드는 있어야 함 + public boolean hasAnyUpdate() { + return tableNumber != null || minSeatCount != null + || maxSeatCount != null || seatsType != null; + } + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/res/StoreTableResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/res/StoreTableResDto.java index 49ac4cce..8b665d43 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/res/StoreTableResDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/res/StoreTableResDto.java @@ -3,6 +3,11 @@ import com.eatsfine.eatsfine.domain.storetable.enums.SeatsType; import com.eatsfine.eatsfine.domain.tableblock.enums.SlotStatus; import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; + +import java.math.BigDecimal; +import java.time.LocalDate; import lombok.Builder; import java.math.BigDecimal; @@ -40,4 +45,55 @@ public record SlotDetailDto( SlotStatus status, boolean isAvailable ) {} + + @Builder + public record TableDetailDto( + Long tableId, + Integer minSeatCount, + Integer maxSeatCount, + String tableImageUrl, + BigDecimal rating, + Integer reviewCount, + SeatsType seatsType, + ReservationStatusDto reservationStatus + ) {} + + @Builder + public record ReservationStatusDto( + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") + LocalDate targetDate, + Integer totalSlotCount, + Integer availableSlotCount + ) {} + + @Builder + public record TableUpdateResultDto( + List updatedTables + ) {} + + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public record UpdatedTableDto( + Long tableId, + String tableNumber, + Integer minSeatCount, + Integer maxSeatCount, + SeatsType seatsType + ) {} + + @Builder + public record TableDeleteDto( + Long tableId + ) {} + + @Builder + public record UploadTableImageDto( + Long tableId, + String tableImageUrl + ) {} + + @Builder + public record DeleteTableImageDto( + Long tableId + ) {} } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java index bec6bc7d..0de743cd 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java @@ -1,6 +1,5 @@ package com.eatsfine.eatsfine.domain.storetable.entity; -import com.eatsfine.eatsfine.domain.store.entity.Store; import com.eatsfine.eatsfine.domain.storetable.enums.SeatsType; import com.eatsfine.eatsfine.domain.table_layout.entity.TableLayout; import com.eatsfine.eatsfine.global.entity.BaseEntity; @@ -70,4 +69,30 @@ public class StoreTable extends BaseEntity { @Column(name = "deleted_at") private LocalDateTime deletedAt; + + // 테이블 번호 변경 + public void updateTableNumber(String tableNumber) { + this.tableNumber = tableNumber; + } + + // 테이블 좌석 수 변경 + public void updateSeatCount(int minSeatCount, int maxSeatCount) { + this.minSeatCount = minSeatCount; + this.maxSeatCount = maxSeatCount; + } + + // 테이블 유형 변경 + public void updateSeatsType(SeatsType seatsType) { + this.seatsType = seatsType; + } + + // 테이블 이미지 업로드 + public void updateTableImage(String imageKey) { + this.tableImageUrl = imageKey; + } + + // 테이블 이미지 삭제 + public void deleteTableImage() { + this.tableImageUrl = null; + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableErrorStatus.java index 95144a6d..d916cc99 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableErrorStatus.java @@ -16,6 +16,10 @@ public enum StoreTableErrorStatus implements BaseErrorCode { _TABLE_POSITION_OVERLAPS(HttpStatus.BAD_REQUEST, "TABLE400_3", "해당 위치에 이미 다른 테이블이 존재합니다."), _TABLE_NOT_BELONGS_TO_STORE(HttpStatus.BAD_REQUEST, "TABLE400_4", "해당 테이블은 해당 가게에 속하지 않습니다."), _NO_BUSINESS_HOURS(HttpStatus.NOT_FOUND, "TABLE404_2", "해당 요일의 영업시간 정보를 찾을 수 없습니다."), + _NO_UPDATE_FIELD(HttpStatus.BAD_REQUEST, "TABLE400_5", "수정할 필드가 최소 하나 이상 필요합니다."), + _SAME_SEAT_COUNT(HttpStatus.BAD_REQUEST, "TABLE400_6", "기존 좌석 수와 동일합니다."), + _SAME_SEATS_TYPE(HttpStatus.BAD_REQUEST, "TABLE400_7", "기존 좌석 유형과 동일합니다."), + _TABLE_HAS_FUTURE_BOOKING(HttpStatus.BAD_REQUEST, "TABLE400_8", "해당 테이블에 존재하는 예약이 있어 삭제할 수 없습니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableSuccessStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableSuccessStatus.java index 8aa959d9..de6cdec3 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableSuccessStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableSuccessStatus.java @@ -12,6 +12,9 @@ public enum StoreTableSuccessStatus implements BaseCode { _TABLE_CREATED(HttpStatus.CREATED, "TABLE201_1", "성공적으로 테이블을 생성했습니다."), _SLOT_LIST_FOUND(HttpStatus.OK, "TABLE200_1", "테이블 시간 슬롯 조회에 성공했습니다."), + _TABLE_DETAIL_FOUND(HttpStatus.OK, "TABLE200_2", "테이블 상세 정보 조회에 성공했습니다."), + _TABLE_UPDATED(HttpStatus.OK, "TABLE200_3", "성공적으로 테이블 정보를 수정했습니다."), + _TABLE_DELETED(HttpStatus.OK, "TABLE200_4", "성공적으로 테이블을 삭제했습니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/repository/StoreTableRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/repository/StoreTableRepository.java index 306997c5..5d34680b 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/repository/StoreTableRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/repository/StoreTableRepository.java @@ -1,6 +1,7 @@ package com.eatsfine.eatsfine.domain.storetable.repository; import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; +import com.eatsfine.eatsfine.domain.table_layout.entity.TableLayout; import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; @@ -8,6 +9,7 @@ import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface StoreTableRepository extends JpaRepository { @@ -16,4 +18,7 @@ public interface StoreTableRepository extends JpaRepository { @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT st FROM StoreTable st WHERE st.id IN :ids") List findAllByIdWithLock(@Param("ids") List ids); + + // 특정 레이아웃에서 특정 번호를 가진 활성 테이블 조회, 테이블 번호 중복 체크용 + Optional findByTableLayoutAndTableNumberAndIsDeletedFalse(TableLayout tableLayout, String tableNumber); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandService.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandService.java index 58370247..2f3d8ade 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandService.java @@ -2,7 +2,16 @@ import com.eatsfine.eatsfine.domain.storetable.dto.req.StoreTableReqDto; import com.eatsfine.eatsfine.domain.storetable.dto.res.StoreTableResDto; +import org.springframework.web.multipart.MultipartFile; public interface StoreTableCommandService { StoreTableResDto.TableCreateDto createTable(Long storeId, StoreTableReqDto.TableCreateDto dto); + + StoreTableResDto.TableUpdateResultDto updateTable(Long storeId, Long tableId, StoreTableReqDto.TableUpdateDto dto); + + StoreTableResDto.TableDeleteDto deleteTable(Long storeId, Long tableId); + + StoreTableResDto.UploadTableImageDto uploadTableImage(Long storeId, Long tableId, MultipartFile tableImage); + + StoreTableResDto.DeleteTableImageDto deleteTableImage(Long storeId, Long tableId); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandServiceImpl.java index 8d95557b..61175009 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandServiceImpl.java @@ -1,5 +1,8 @@ package com.eatsfine.eatsfine.domain.storetable.service; +import com.eatsfine.eatsfine.domain.booking.repository.BookingRepository; +import com.eatsfine.eatsfine.domain.image.exception.ImageException; +import com.eatsfine.eatsfine.domain.image.status.ImageErrorStatus; import com.eatsfine.eatsfine.domain.store.exception.StoreException; import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; @@ -7,18 +10,26 @@ import com.eatsfine.eatsfine.domain.storetable.dto.req.StoreTableReqDto; import com.eatsfine.eatsfine.domain.storetable.dto.res.StoreTableResDto; import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; +import com.eatsfine.eatsfine.domain.storetable.exception.StoreTableException; +import com.eatsfine.eatsfine.domain.storetable.exception.status.StoreTableErrorStatus; import com.eatsfine.eatsfine.domain.storetable.repository.StoreTableRepository; import com.eatsfine.eatsfine.domain.storetable.validator.StoreTableValidator; 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 com.eatsfine.eatsfine.global.s3.S3Service; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.ArrayList; import java.util.List; +import java.util.Optional; @Service @RequiredArgsConstructor @@ -27,6 +38,8 @@ public class StoreTableCommandServiceImpl implements StoreTableCommandService { private final StoreRepository storeRepository; private final TableLayoutRepository tableLayoutRepository; private final StoreTableRepository storeTableRepository; + private final BookingRepository bookingRepository; + private final S3Service s3Service; // 테이블 생성 @Override @@ -70,6 +83,140 @@ public StoreTableResDto.TableCreateDto createTable(Long storeId, StoreTableReqDt return StoreTableConverter.toTableCreateDto(savedTable); } + // 테이블 정보 수정 + @Override + public StoreTableResDto.TableUpdateResultDto updateTable(Long storeId, Long tableId, StoreTableReqDto.TableUpdateDto dto) { + // 최소 하나의 변경사항이 있는지 확인 + if (!dto.hasAnyUpdate()) { + throw new StoreTableException(StoreTableErrorStatus._NO_UPDATE_FIELD); + } + + storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + StoreTable table = storeTableRepository.findById(tableId) + .orElseThrow(() -> new StoreTableException(StoreTableErrorStatus._TABLE_NOT_FOUND)); + + StoreTableValidator.validateTableBelongsToStore(table, storeId); + + // 변경된 테이블 리스트 + List affectedTables = new ArrayList<>(); + affectedTables.add(table); + + // 테이블 번호 수정 + if (dto.tableNumber() != null) { + List swappedTables = updateTableNumber(table, dto.tableNumber()); + // 스왑된 테이블이 있으면 추가 + swappedTables.stream() + .filter(t -> !t.getId().equals(table.getId())) + .forEach(affectedTables::add); + } + + // 테이블 좌석 수 변경 + if (dto.minSeatCount() != null || dto.maxSeatCount() != null) { + // null인 필드는 기존 값 유지 + int finalMin = dto.minSeatCount() != null ? dto.minSeatCount() : table.getMinSeatCount(); + int finalMax = dto.maxSeatCount() != null ? dto.maxSeatCount() : table.getMaxSeatCount(); + + // 기존 값과 동일한지 검증 + if (finalMin == table.getMinSeatCount() && finalMax == table.getMaxSeatCount()) { + throw new StoreTableException(StoreTableErrorStatus._SAME_SEAT_COUNT); + } + + StoreTableValidator.validateSeatRange(finalMin, finalMax); + + table.updateSeatCount(finalMin, finalMax); + } + + // 테이블 유형 변경 + if (dto.seatsType() != null) { + if (table.getSeatsType() == dto.seatsType()) { + throw new StoreTableException(StoreTableErrorStatus._SAME_SEATS_TYPE); + } + table.updateSeatsType(dto.seatsType()); + } + + return StoreTableConverter.toTableUpdateResultDto(affectedTables, dto); + } + + // 테이블 삭제 + @Override + public StoreTableResDto.TableDeleteDto deleteTable(Long storeId, Long tableId) { + storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + StoreTable table = storeTableRepository.findById(tableId) + .orElseThrow(() -> new StoreTableException(StoreTableErrorStatus._TABLE_NOT_FOUND)); + + StoreTableValidator.validateTableBelongsToStore(table, storeId); + + // 현재 시간 기준 미래 예약 존재 여부 확인 + LocalDate currentDate = LocalDate.now(); + LocalTime currentTime = LocalTime.now(); + + boolean hasFutureBooking = bookingRepository.existsFutureBookingByTable(tableId, currentDate, currentTime); + + if (hasFutureBooking) { + throw new StoreTableException(StoreTableErrorStatus._TABLE_HAS_FUTURE_BOOKING); + } + + storeTableRepository.delete(table); + + return StoreTableConverter.toTableDeleteDto(table); + } + + // 테이블 이미지 업로드 + @Override + public StoreTableResDto.UploadTableImageDto uploadTableImage(Long storeId, Long tableId, MultipartFile tableImage) { + storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + StoreTable table = storeTableRepository.findById(tableId) + .orElseThrow(() -> new StoreTableException(StoreTableErrorStatus._TABLE_NOT_FOUND)); + + StoreTableValidator.validateTableBelongsToStore(table, storeId); + + if (tableImage == null || tableImage.isEmpty()) { + throw new ImageException(ImageErrorStatus.EMPTY_FILE); + } + + // 기존 이미지가 존재할 경우 삭제 + if (table.getTableImageUrl() != null && !table.getTableImageUrl().isBlank()) { + s3Service.deleteByKey(table.getTableImageUrl()); + } + + String key = s3Service.upload(tableImage, "stores/" + storeId + "/tables/" + tableId); + + table.updateTableImage(key); + + // URL 변환 및 응답 + String tableImageUrl = s3Service.toUrl(key); + + return StoreTableConverter.toUploadTableImageDto(tableId, tableImageUrl); + } + + @Override + public StoreTableResDto.DeleteTableImageDto deleteTableImage(Long storeId, Long tableId) { + storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + StoreTable table = storeTableRepository.findById(tableId) + .orElseThrow(() -> new StoreTableException(StoreTableErrorStatus._TABLE_NOT_FOUND)); + + StoreTableValidator.validateTableBelongsToStore(table, storeId); + + // 이미지가 존재하는지 확인 + if (table.getTableImageUrl() == null || table.getTableImageUrl().isBlank()) { + throw new ImageException(ImageErrorStatus._IMAGE_NOT_FOUND); + } + + s3Service.deleteByKey(table.getTableImageUrl()); + + table.deleteTableImage(); + + return StoreTableConverter.toDeleteTableImageDto(tableId); + } + private String generateTableNumber(TableLayout layout) { List tables = layout.getTables(); @@ -90,4 +237,36 @@ private String generateTableNumber(TableLayout layout) { return String.format("%d번 테이블", maxNumber + 1); } + + private List updateTableNumber(StoreTable table, String newNumber) { + String newTableNumber = String.format("%s번 테이블", newNumber); + String currentTableNumber = table.getTableNumber(); + + List updatedTables = new ArrayList<>(); + updatedTables.add(table); + + // 기존 번호와 동일하면 변경 불필요 + if (currentTableNumber.equals(newTableNumber)) { + return updatedTables; + } + + // 같은 레이아웃에서 새 번호를 가진 테이블이 있는지 확인 + Optional existingTable = storeTableRepository + .findByTableLayoutAndTableNumberAndIsDeletedFalse( + table.getTableLayout(), + newTableNumber + ); + + // 중복된 번호를 가진 테이블이 있으면 스왑 + if (existingTable.isPresent()) { + StoreTable conflictTable = existingTable.get(); + conflictTable.updateTableNumber(currentTableNumber); + updatedTables.add(conflictTable); + } + + // 대상 테이블의 번호 변경 + table.updateTableNumber(newTableNumber); + + return updatedTables; + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryService.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryService.java index 053edd2f..8d5e2462 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryService.java @@ -6,4 +6,6 @@ public interface StoreTableQueryService { StoreTableResDto.SlotListDto getTableSlots(Long storeId, Long tableId, LocalDate date); + + StoreTableResDto.TableDetailDto getTableDetail(Long storeId, Long tableId, LocalDate targetDate); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryServiceImpl.java index 285d9def..99e262bf 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryServiceImpl.java @@ -1,6 +1,9 @@ package com.eatsfine.eatsfine.domain.storetable.service; import com.eatsfine.eatsfine.domain.booking.repository.BookingRepository; +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.storetable.converter.StoreTableConverter; import com.eatsfine.eatsfine.domain.storetable.dto.res.StoreTableResDto; import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; @@ -11,6 +14,7 @@ import com.eatsfine.eatsfine.domain.storetable.validator.StoreTableValidator; import com.eatsfine.eatsfine.domain.tableblock.entity.TableBlock; import com.eatsfine.eatsfine.domain.tableblock.repository.TableBlockRepository; +import com.eatsfine.eatsfine.global.s3.S3Service; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,13 +29,18 @@ @RequiredArgsConstructor @Transactional(readOnly = true) public class StoreTableQueryServiceImpl implements StoreTableQueryService{ + private final StoreRepository storeRepository; private final StoreTableRepository storeTableRepository; private final TableBlockRepository tableBlockRepository; private final BookingRepository bookingRepository; + private final S3Service s3Service; // 테이블 슬롯 조회 @Override public StoreTableResDto.SlotListDto getTableSlots(Long storeId, Long tableId, LocalDate date) { + storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + StoreTable storeTable = storeTableRepository.findById(tableId) .orElseThrow(() -> new StoreTableException(StoreTableErrorStatus._TABLE_NOT_FOUND)); @@ -49,4 +58,33 @@ public StoreTableResDto.SlotListDto getTableSlots(Long storeId, Long tableId, Lo result.slots() ); } + + // 테이블 상세 조회 + @Override + public StoreTableResDto.TableDetailDto getTableDetail(Long storeId, Long tableId, LocalDate targetDate) { + storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + StoreTable storeTable = storeTableRepository.findById(tableId) + .orElseThrow(() -> new StoreTableException(StoreTableErrorStatus._TABLE_NOT_FOUND)); + + StoreTableValidator.validateTableBelongsToStore(storeTable, storeId); + + List tableBlocks = tableBlockRepository.findByStoreTableAndTargetDate(storeTable, targetDate); + List bookedTimeList = bookingRepository.findBookedTimesByTableAndDate(tableId, targetDate); + Set bookedTimes = new HashSet<>(bookedTimeList); + + SlotCalculator.SlotCalculationResult result = SlotCalculator.calculateSlots(storeTable, targetDate, tableBlocks, bookedTimes); + + // S3 Key -> Url 변환 + String tableImageUrl = s3Service.toUrl(storeTable.getTableImageUrl()); + + return StoreTableConverter.toTableDetailDto( + storeTable, + targetDate, + result.totalSlotCount(), + result.availableSlotCount(), + tableImageUrl + ); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/handler/GeneralExceptionAdvice.java b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/handler/GeneralExceptionAdvice.java index 0c01904a..7b2212f4 100644 --- a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/handler/GeneralExceptionAdvice.java +++ b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/handler/GeneralExceptionAdvice.java @@ -4,11 +4,13 @@ import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; import com.eatsfine.eatsfine.global.apiPayload.code.status.ErrorStatus; import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; +import jakarta.validation.ConstraintViolationException; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.ServletWebRequest; @@ -34,6 +36,26 @@ public ResponseEntity exception(Exception e, WebRequest request) { return handleExceptionInternalFalse(e, ErrorStatus._INTERNAL_SERVER_ERROR, HttpHeaders.EMPTY, ErrorStatus._INTERNAL_SERVER_ERROR.getHttpStatus(), request, e.getMessage()); } + // @Valid 유효성 검사 실패 시 (RequestBody) + @Override + protected ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException e, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request + ) { + String errorMessage = e.getBindingResult().getAllErrors().get(0).getDefaultMessage(); + return handleExceptionInternalFalse(e, ErrorStatus._BAD_REQUEST, headers, status, request, errorMessage); + } + + // @RequestParam, @PathVariable 유효성 검사 실패 시 (ConstraintViolationException) + @ExceptionHandler + public ResponseEntity validation(ConstraintViolationException e, WebRequest request) { + // 첫 번째 에러 메시지만 추출 + String errorMessage = e.getConstraintViolations().iterator().next().getMessage(); + return handleExceptionInternalFalse(e, ErrorStatus._BAD_REQUEST, HttpHeaders.EMPTY, ErrorStatus._BAD_REQUEST.getHttpStatus(), request, errorMessage); + } + // 3. 커스텀 예외용 내부 응답 생성 메서드 private ResponseEntity handleExceptionInternal(Exception e, BaseErrorCode code, HttpHeaders headers, HttpServletRequest request) { // 정의하신 ApiResponse.onFailure(BaseErrorCode code, T result)를 호출합니다. diff --git a/src/main/java/com/eatsfine/eatsfine/global/s3/S3Service.java b/src/main/java/com/eatsfine/eatsfine/global/s3/S3Service.java index 774fc367..d3fdb380 100644 --- a/src/main/java/com/eatsfine/eatsfine/global/s3/S3Service.java +++ b/src/main/java/com/eatsfine/eatsfine/global/s3/S3Service.java @@ -9,6 +9,7 @@ import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.CopyObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest; import java.io.IOException; @@ -46,7 +47,9 @@ public String upload(MultipartFile file, String directory) { } public void deleteByKey(String key) { - if (key == null || key.isBlank()) return; + if (key == null || key.isBlank()) { + throw new ImageException(ImageErrorStatus._INVALID_IMAGE_KEY); + } s3Client.deleteObject(DeleteObjectRequest.builder() .bucket(bucket) @@ -54,6 +57,27 @@ public void deleteByKey(String key) { .build()); } + public void moveObject(String sourceKey, String destinationKey) { + if (sourceKey == null || destinationKey == null || sourceKey.isBlank() || destinationKey.isBlank()) { + throw new ImageException(ImageErrorStatus._INVALID_IMAGE_KEY); + } + + if (sourceKey.equals(destinationKey)) { + throw new ImageException(ImageErrorStatus._INVALID_IMAGE_KEY); + } + + // 1. 객체 복사 + s3Client.copyObject(CopyObjectRequest.builder() + .sourceBucket(bucket) + .sourceKey(sourceKey) + .destinationBucket(bucket) + .destinationKey(destinationKey) + .build()); + + // 2. 원본(임시) 객체 삭제 + deleteByKey(sourceKey); + } + public String toUrl(String key) { if (key == null || key.isBlank()) return null; return baseUrl + "/" + key; @@ -61,13 +85,13 @@ public String toUrl(String key) { private String generateKey(MultipartFile file, String directory) { if(directory == null || directory.isBlank()) { - throw new IllegalArgumentException("S3 디렉토리는 비어있을 수 없습니다."); + throw new ImageException(ImageErrorStatus._INVALID_S3_DIRECTORY); } String extension = extractExtension(file.getOriginalFilename()); return directory + "/" + UUID.randomUUID() + extension; } - private String extractExtension(String filename) { + public String extractExtension(String filename) { // public으로 변경 if (filename == null || !filename.contains(".")) { throw new ImageException(ImageErrorStatus.INVALID_FILE_TYPE); } diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 6feee07c..a5a7c16b 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -27,3 +27,10 @@ spring: payment: toss: widget-secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 + +cloud: + aws: + region: ap-northeast-2 + s3: + bucket: eatsfine-images + base-url: https://eatsfine-images.s3.ap-northeast-2.amazonaws.com \ No newline at end of file