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 new file mode 100644 index 0000000..25b86e7 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableController.java @@ -0,0 +1,26 @@ +package com.eatsfine.eatsfine.domain.storetable.controller; + +import com.eatsfine.eatsfine.domain.storetable.dto.req.StoreTableReqDto; +import com.eatsfine.eatsfine.domain.storetable.dto.res.StoreTableResDto; +import com.eatsfine.eatsfine.domain.storetable.exception.status.StoreTableSuccessStatus; +import com.eatsfine.eatsfine.domain.storetable.service.StoreTableCommandService; +import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "StoreTable", description = "가게 테이블 관리 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1") +public class StoreTableController implements StoreTableControllerDocs { + private final StoreTableCommandService storeTableCommandService; + + @PostMapping("/stores/{storeId}/tables") + public ApiResponse createTable( + @PathVariable Long storeId, + @RequestBody StoreTableReqDto.TableCreateDto dto + ) { + return ApiResponse.of(StoreTableSuccessStatus._TABLE_CREATED, storeTableCommandService.createTable(storeId, dto)); + } +} 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 new file mode 100644 index 0000000..8e1f72b --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableControllerDocs.java @@ -0,0 +1,36 @@ +package com.eatsfine.eatsfine.domain.storetable.controller; + +import com.eatsfine.eatsfine.domain.storetable.dto.req.StoreTableReqDto; +import com.eatsfine.eatsfine.domain.storetable.dto.res.StoreTableResDto; +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.responses.ApiResponses; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.RequestBody; + +public interface StoreTableControllerDocs { + + @Operation( + summary = "테이블 생성", + description = """ + 배치도에 새 테이블을 추가합니다. + + - 테이블 번호는 자동으로 순차 생성됩니다. (1번 테이블, 2번 테이블, ...) + - 좌표와 크기는 배치도 그리드 범위 내에 있어야 합니다. + - 다른 테이블과 겹치지 않아야 합니다. + - 최소 인원은 최대 인원보다 작거나 같아야 합니다. + - 활성화된 배치도에만 테이블을 추가할 수 있습니다. + """ + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "테이블 생성 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청 (좌표 범위 초과, 테이블 겹침 등)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "가게 또는 배치도를 찾을 수 없음") + }) + ApiResponse createTable( + @Parameter(description = "가게 ID", required = true, example = "1") + Long storeId, + @RequestBody @Valid StoreTableReqDto.TableCreateDto dto + ); +} 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 new file mode 100644 index 0000000..eb211f8 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/converter/StoreTableConverter.java @@ -0,0 +1,24 @@ +package com.eatsfine.eatsfine.domain.storetable.converter; + +import com.eatsfine.eatsfine.domain.storetable.dto.res.StoreTableResDto; +import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; + +public class StoreTableConverter { + // StoreTable Entity를 생성 응답 DTO로 변환 + public static StoreTableResDto.TableCreateDto toTableCreateDto(StoreTable table) { + return StoreTableResDto.TableCreateDto.builder() + .tableId(table.getId()) + .tableNumber(table.getTableNumber()) + .gridX(table.getGridX()) + .gridY(table.getGridY()) + .widthSpan(table.getWidthSpan()) + .heightSpan(table.getHeightSpan()) + .minSeatCount(table.getMinSeatCount()) + .maxSeatCount(table.getMaxSeatCount()) + .seatsType(table.getSeatsType()) + .rating(table.getRating()) + .reviewCount(0) // 리뷰 기능 미구현으로 0 반환 + .tableImageUrl(table.getTableImageUrl()) + .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 new file mode 100644 index 0000000..559366d --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/req/StoreTableReqDto.java @@ -0,0 +1,33 @@ +package com.eatsfine.eatsfine.domain.storetable.dto.req; + +import com.eatsfine.eatsfine.domain.storetable.enums.SeatsType; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +public class StoreTableReqDto { + public record TableCreateDto( + @NotNull(message = "X 좌표는 필수입니다.") + @Min(value = 0, message = "X 좌표는 0 이상이어야 합니다.") + Integer gridX, + + @NotNull(message = "Y 좌표는 필수입니다.") + @Min(value = 0, message = "Y 좌표는 0 이상이어야 합니다.") + Integer gridY, + + @NotNull(message = "최소 인원은 필수입니다.") + @Min(value = 1, message = "최소 인원은 1명 이상이어야 합니다.") + @Max(value = 20, message = "최소 인원은 20명 이하여야 합니다.") + Integer minSeatCount, + + @NotNull(message = "최대 인원은 필수입니다.") + @Min(value = 1, message = "최대 인원은 1명 이상이어야 합니다.") + @Max(value = 20, message = "최대 인원은 20명 이하여야 합니다.") + Integer maxSeatCount, + + @NotNull(message = "테이블 유형은 필수입니다.") + SeatsType seatsType, + + String tableImageUrl + ) {} +} 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 new file mode 100644 index 0000000..b4290c9 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/res/StoreTableResDto.java @@ -0,0 +1,24 @@ +package com.eatsfine.eatsfine.domain.storetable.dto.res; + +import com.eatsfine.eatsfine.domain.storetable.enums.SeatsType; +import lombok.Builder; + +import java.math.BigDecimal; + +public class StoreTableResDto { + @Builder + public record TableCreateDto( + Long tableId, + String tableNumber, + Integer gridX, + Integer gridY, + Integer widthSpan, + Integer heightSpan, + Integer minSeatCount, + Integer maxSeatCount, + SeatsType seatsType, + BigDecimal rating, + Integer reviewCount, + String tableImageUrl + ) {} +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/StoreTableException.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/StoreTableException.java new file mode 100644 index 0000000..ce07af0 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/StoreTableException.java @@ -0,0 +1,10 @@ +package com.eatsfine.eatsfine.domain.storetable.exception; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; + +public class StoreTableException extends GeneralException { + public StoreTableException(BaseErrorCode errorCode) { + super(errorCode); + } +} 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 new file mode 100644 index 0000000..3fcdafa --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableErrorStatus.java @@ -0,0 +1,41 @@ +package com.eatsfine.eatsfine.domain.storetable.exception.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 StoreTableErrorStatus implements BaseErrorCode { + + _TABLE_NOT_FOUND(HttpStatus.NOT_FOUND, "TABLE404", "테이블을 찾을 수 없습니다."), + _TABLE_INVALID_SEAT_RANGE(HttpStatus.BAD_REQUEST, "TABLE400", "최소 인원은 최대 인원보다 작거나 같아야 합니다."), + _TABLE_POSITION_OUT_OF_BOUNDS(HttpStatus.BAD_REQUEST, "TABLE401", "테이블 위치가 배치도 그리드 범위를 벗어났습니다."), + _TABLE_POSITION_OVERLAPS(HttpStatus.BAD_REQUEST, "TABLE402", "해당 위치에 이미 다른 테이블이 존재합니다."), + ; + + 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/storetable/exception/status/StoreTableSuccessStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableSuccessStatus.java new file mode 100644 index 0000000..118f548 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableSuccessStatus.java @@ -0,0 +1,39 @@ +package com.eatsfine.eatsfine.domain.storetable.exception.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 StoreTableSuccessStatus implements BaseCode { + + _TABLE_CREATED(HttpStatus.CREATED, "TABLE201", "성공적으로 테이블을 생성했습니다."), + ; + + 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/storetable/service/StoreTableCommandService.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandService.java new file mode 100644 index 0000000..5837024 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandService.java @@ -0,0 +1,8 @@ +package com.eatsfine.eatsfine.domain.storetable.service; + +import com.eatsfine.eatsfine.domain.storetable.dto.req.StoreTableReqDto; +import com.eatsfine.eatsfine.domain.storetable.dto.res.StoreTableResDto; + +public interface StoreTableCommandService { + StoreTableResDto.TableCreateDto createTable(Long storeId, StoreTableReqDto.TableCreateDto dto); +} 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 new file mode 100644 index 0000000..8d95557 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandServiceImpl.java @@ -0,0 +1,93 @@ +package com.eatsfine.eatsfine.domain.storetable.service; + +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.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.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 lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class StoreTableCommandServiceImpl implements StoreTableCommandService { + private final StoreRepository storeRepository; + private final TableLayoutRepository tableLayoutRepository; + private final StoreTableRepository storeTableRepository; + + // 테이블 생성 + @Override + public StoreTableResDto.TableCreateDto createTable(Long storeId, StoreTableReqDto.TableCreateDto dto) { + storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + TableLayout layout = tableLayoutRepository.findByStoreIdAndIsActiveTrue(storeId) + .orElseThrow(() -> new TableLayoutException(TableLayoutErrorStatus._LAYOUT_NOT_FOUND)); + + // 좌석 범위 검증 + StoreTableValidator.validateSeatRange(dto.minSeatCount(), dto.maxSeatCount()); + + // 테이블이 그리드 범위 내인지 검증 (테이블 생성 시 크기는 1x1 크기로 고정) + StoreTableValidator.validateGridBounds(dto.gridX(), dto.gridY(), 1, 1, layout); + + // 테이블 겹침 검증 + StoreTableValidator.validateNoOverlap(dto.gridX(), dto.gridY(), 1, 1, layout.getTables()); + + // 테이블 번호 자동 생성 + String tableNumber = generateTableNumber(layout); + + // 테이블 생성 + StoreTable newTable = StoreTable.builder() + .tableNumber(tableNumber) + .tableLayout(layout) + .gridX(dto.gridX()) + .gridY(dto.gridY()) + .widthSpan(1) + .heightSpan(1) + .minSeatCount(dto.minSeatCount()) + .maxSeatCount(dto.maxSeatCount()) + .seatsType(dto.seatsType()) + .rating(BigDecimal.ZERO) + .tableImageUrl(dto.tableImageUrl()) + .isDeleted(false) + .build(); + + StoreTable savedTable = storeTableRepository.save(newTable); + + return StoreTableConverter.toTableCreateDto(savedTable); + } + + private String generateTableNumber(TableLayout layout) { + List tables = layout.getTables(); + + if (tables.isEmpty()) { + return "1번 테이블"; + } + + // 기존 테이블 번호 중 최대값 찾기 + int maxNumber = tables.stream() + .map(StoreTable::getTableNumber) + .filter(number -> number.matches("\\d+번 테이블")) + .map(number -> { + String numPart = number.replace("번 테이블", ""); + return Integer.parseInt(numPart); + }) + .max(Integer::compareTo) + .orElse(0); + + return String.format("%d번 테이블", maxNumber + 1); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/validator/StoreTableValidator.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/validator/StoreTableValidator.java new file mode 100644 index 0000000..0d3a018 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/validator/StoreTableValidator.java @@ -0,0 +1,63 @@ +package com.eatsfine.eatsfine.domain.storetable.validator; + +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.table_layout.entity.TableLayout; + +import java.util.List; + +public class StoreTableValidator { + + private StoreTableValidator() { + // 인스턴스화 방지 + } + + // 좌석 범위 검증 (최소 좌석 수가 최대 좌석 수보다 클 수 없음) + public static void validateSeatRange(int minSeatCount, int maxSeatCount) { + if (minSeatCount > maxSeatCount) { + throw new StoreTableException(StoreTableErrorStatus._TABLE_INVALID_SEAT_RANGE); + } + } + + // 테이블 전체(시작점 + 크기)가 그리드 범위 내에 있는지 검증 + public static void validateGridBounds(int gridX, int gridY, int widthSpan, int heightSpan, TableLayout layout) { + // 테이블의 끝점 계산 (0-based이므로 -1) + int endX = gridX + widthSpan - 1; + int endY = gridY + heightSpan - 1; + + // 시작점이 음수이거나, 끝점이 그리드 범위를 벗어나면 예외 + if (gridX < 0 || gridY < 0 || endX >= layout.getCols() || endY >= layout.getLows()) { + throw new StoreTableException(StoreTableErrorStatus._TABLE_POSITION_OUT_OF_BOUNDS); + } + } + + // 새로 추가할 테이블이 기존 테이블과 겹치지 않는지 확인 + public static void validateNoOverlap(int gridX, int gridY, int widthSpan, int heightSpan, + List existingTables) { + for (StoreTable existing : existingTables) { + if (isOverlapping(gridX, gridY, widthSpan, heightSpan, existing)) { + throw new StoreTableException(StoreTableErrorStatus._TABLE_POSITION_OVERLAPS); + } + } + } + + // 직사각형 겹침 판정 알고리즘 + private static boolean isOverlapping(int newX, int newY, int newWidth, int newHeight, StoreTable existing) { + // 새 테이블의 범위 + int newX2 = newX + newWidth - 1; + int newY2 = newY + newHeight - 1; + + // 기존 테이블의 범위 + int existX1 = existing.getGridX(); + int existY1 = existing.getGridY(); + int existX2 = existing.getGridX() + existing.getWidthSpan() - 1; + int existY2 = existing.getGridY() + existing.getHeightSpan() - 1; + + // 겹치는 조건: x축도 겹치고 y축도 겹침 + boolean xOverlap = (newX <= existX2) && (newX2 >= existX1); + boolean yOverlap = (newY <= existY2) && (newY2 >= existY1); + + return xOverlap && yOverlap; + } +}