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 7b16850..bec6bc7 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 @@ -17,7 +17,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Getter -@SQLDelete(sql = "UPDATE store_table SET is_deleted = true WHERE id = ?") +@SQLDelete(sql = "UPDATE store_table SET is_deleted = true, deleted_at = CURRENT_TIMESTAMP WHERE id = ?") @SQLRestriction("is_deleted = false") @Table(name = "store_table") public class StoreTable extends BaseEntity { diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/controller/TableLayoutController.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/controller/TableLayoutController.java new file mode 100644 index 0000000..b96960d --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/controller/TableLayoutController.java @@ -0,0 +1,39 @@ +package com.eatsfine.eatsfine.domain.table_layout.controller; + +import com.eatsfine.eatsfine.domain.table_layout.dto.req.TableLayoutReqDto; +import com.eatsfine.eatsfine.domain.table_layout.dto.res.TableLayoutResDto; +import com.eatsfine.eatsfine.domain.table_layout.exception.status.TableLayoutSuccessStatus; +import com.eatsfine.eatsfine.domain.table_layout.service.TableLayoutCommandService; +import com.eatsfine.eatsfine.domain.table_layout.service.TableLayoutQueryService; +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 = "TableLayout", description = "테이블 배치도 조회 및 관리 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1") +public class TableLayoutController implements TableLayoutControllerDocs{ + private final TableLayoutCommandService tableLayoutCommandService; + private final TableLayoutQueryService tableLayoutQueryService; + + @PostMapping("stores/{storeId}/layouts") + public ApiResponse createLayout( + @PathVariable Long storeId, + @RequestBody TableLayoutReqDto.LayoutCreateDto dto + ) { + return ApiResponse.of(TableLayoutSuccessStatus._LAYOUT_CREATED, tableLayoutCommandService.createLayout(storeId, dto)); + } + + @GetMapping("stores/{storeId}/layouts") + public ApiResponse getActiveLayout(@PathVariable Long storeId) { + TableLayoutResDto.LayoutDetailDto result = tableLayoutQueryService.getActiveLayout(storeId); + + if (result == null) { + return ApiResponse.of(TableLayoutSuccessStatus._LAYOUT_NO_CONTENT, null); + } + + return ApiResponse.of(TableLayoutSuccessStatus._LAYOUT_FOUND, result); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/controller/TableLayoutControllerDocs.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/controller/TableLayoutControllerDocs.java new file mode 100644 index 0000000..4a84567 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/controller/TableLayoutControllerDocs.java @@ -0,0 +1,53 @@ +package com.eatsfine.eatsfine.domain.table_layout.controller; + +import com.eatsfine.eatsfine.domain.table_layout.dto.req.TableLayoutReqDto; +import com.eatsfine.eatsfine.domain.table_layout.dto.res.TableLayoutResDto; +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.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; + +public interface TableLayoutControllerDocs { + @Operation( + summary = "테이블 배치도 생성", + description = """ + 사장 회원이 가게의 테이블 배치도를 생성합니다. + + - 그리드 크기는 1x1 ~ 10x10 범위 내에서 설정 가능합니다. + - 가게당 활성 배치도는 1개만 존재하며, 새 배치도 생성 시 기존 배치도는 자동으로 비활성화됩니다. + - 생성된 배치도는 빈 상태로 생성되며, 이후 테이블 추가 API를 통해 테이블을 배치할 수 있습니다. + """ + ) + @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 createLayout( + @Parameter(description = "가게 ID", required = true, example = "1") + Long storeId, + @RequestBody @Valid TableLayoutReqDto.LayoutCreateDto dto + ); + + @Operation( + summary = "테이블 배치도 조회", + description = """ + 가게의 활성화된 테이블 배치도를 조회합니다. + + - isActive = true인 배치도만 조회됩니다. + - 배치된 테이블 목록도 함께 반환됩니다. (삭제된 테이블은 제외) + - 활성 배치도가 없는 경우 204 응답을 반환합니다. + """ + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "배치도 조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "204", description = "조회는 성공했지만 가게 배치도가 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "가게를 찾을 수 없음") + }) + ApiResponse getActiveLayout( + @Parameter(description = "가게 ID", required = true, example = "1") + Long storeId + ); +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/converter/TableLayoutConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/converter/TableLayoutConverter.java new file mode 100644 index 0000000..1dc6304 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/converter/TableLayoutConverter.java @@ -0,0 +1,42 @@ +package com.eatsfine.eatsfine.domain.table_layout.converter; + +import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; +import com.eatsfine.eatsfine.domain.table_layout.dto.res.TableLayoutResDto; +import com.eatsfine.eatsfine.domain.table_layout.entity.TableLayout; + +public class TableLayoutConverter { + // TableLayout Entity를 생성 응답 DTO로 변환 + public static TableLayoutResDto.LayoutDetailDto toLayoutDetailDto(TableLayout layout) { + return TableLayoutResDto.LayoutDetailDto.builder() + .layoutId(layout.getId()) + .totalTableCount(layout.getTables().size()) + .gridInfo( + TableLayoutResDto.GridInfo.builder() + .gridCol(layout.getCols()) + .gridRow(layout.getLows()) + .build() + ) + .tables( + layout.getTables().stream() + .map(TableLayoutConverter::toTableInfo) + .toList() + ) + .build(); + } + + // StoreTable Entity를 TableInfo DTO로 변환 + private static TableLayoutResDto.TableInfo toTableInfo(StoreTable table) { + return TableLayoutResDto.TableInfo.builder() + .tableId(table.getId()) + .tableNumber(table.getTableNumber()) + .seatsType(table.getSeatsType()) + .minSeatCount(table.getMinSeatCount()) + .maxSeatCount(table.getMaxSeatCount()) + .reviewCount(0) // 추후 리뷰 로직 구현 시 추가 + .gridX(table.getGridX()) + .gridY(table.getGridY()) + .widthSpan(table.getWidthSpan()) + .heightSpan(table.getHeightSpan()) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/dto/req/TableLayoutReqDto.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/dto/req/TableLayoutReqDto.java new file mode 100644 index 0000000..1acda58 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/dto/req/TableLayoutReqDto.java @@ -0,0 +1,21 @@ +package com.eatsfine.eatsfine.domain.table_layout.dto.req; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +public class TableLayoutReqDto { + @Builder + public record LayoutCreateDto( + @NotNull(message = "Column 크기는 필수입니다.") + @Min(value = 1, message = "가로 크기는 최소 1이어야 합니다.") + @Max(value = 10, message = "가로 크기는 최대 10이어야 합니다.") + Integer gridCol, + + @NotNull(message = "Row 크기는 필수입니다.") + @Min(value = 1, message = "세로 크기는 최소 1이어야 합니다.") + @Max(value = 10, message = "세로 크기는 최대 10이어야 합니다.") + Integer gridRow + ) {} +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/dto/res/TableLayoutResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/dto/res/TableLayoutResDto.java new file mode 100644 index 0000000..1e1a4a0 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/dto/res/TableLayoutResDto.java @@ -0,0 +1,36 @@ +package com.eatsfine.eatsfine.domain.table_layout.dto.res; + +import com.eatsfine.eatsfine.domain.storetable.enums.SeatsType; +import lombok.Builder; + +import java.util.List; + +public class TableLayoutResDto { + @Builder + public record LayoutDetailDto( + Long layoutId, + Integer totalTableCount, + GridInfo gridInfo, + List tables + ) {} + + @Builder + public record GridInfo( + Integer gridCol, + Integer gridRow + ) {} + + @Builder + public record TableInfo( + Long tableId, + String tableNumber, + SeatsType seatsType, + Integer minSeatCount, + Integer maxSeatCount, + Integer reviewCount, + Integer gridX, + Integer gridY, + Integer widthSpan, + Integer heightSpan + ) {} +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/entity/TableLayout.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/entity/TableLayout.java index 7231dce..4d45ea4 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/entity/TableLayout.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/entity/TableLayout.java @@ -17,7 +17,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Builder -@SQLDelete(sql = "UPDATE table_layout SET is_deleted = true WHERE id = ?") +@SQLDelete(sql = "UPDATE table_layout SET is_deleted = true, is_active = false, deleted_at = CURRENT_TIMESTAMP WHERE id = ?") @SQLRestriction("is_deleted = false") @Table(name = "table_layout") public class TableLayout extends BaseEntity { @@ -46,7 +46,8 @@ public class TableLayout extends BaseEntity { @Column(name = "deleted_at") private LocalDateTime deletedAt; - @OneToMany(mappedBy = "tableLayout", cascade = {CascadeType.PERSIST, CascadeType.MERGE}) + @Builder.Default + @OneToMany(mappedBy = "tableLayout", cascade = {CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REMOVE}) private List tables = new ArrayList<>(); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/exception/TableLayoutException.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/exception/TableLayoutException.java new file mode 100644 index 0000000..ee893b5 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/exception/TableLayoutException.java @@ -0,0 +1,10 @@ +package com.eatsfine.eatsfine.domain.table_layout.exception; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; + +public class TableLayoutException extends GeneralException { + public TableLayoutException(BaseErrorCode code) { + super(code); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/exception/status/TableLayoutErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/exception/status/TableLayoutErrorStatus.java new file mode 100644 index 0000000..c0d02e4 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/exception/status/TableLayoutErrorStatus.java @@ -0,0 +1,40 @@ +package com.eatsfine.eatsfine.domain.table_layout.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 TableLayoutErrorStatus implements BaseErrorCode { + + _LAYOUT_NOT_FOUND(HttpStatus.NOT_FOUND, "LAYOUT404", "배치도를 찾을 수 없습니다."), + + _LAYOUT_FORBIDDEN(HttpStatus.FORBIDDEN, "LAYOUT403", "해당 가게의 소유자만 접근 가능합니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDto getReason() { + return ErrorReasonDto.builder() + .isSuccess(false) + .code(code) + .message(message) + .build(); + } + + @Override + public ErrorReasonDto getReasonHttpStatus() { + return ErrorReasonDto.builder() + .httpStatus(httpStatus) + .isSuccess(false) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/exception/status/TableLayoutSuccessStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/exception/status/TableLayoutSuccessStatus.java new file mode 100644 index 0000000..beb03ef --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/exception/status/TableLayoutSuccessStatus.java @@ -0,0 +1,42 @@ +package com.eatsfine.eatsfine.domain.table_layout.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 TableLayoutSuccessStatus implements BaseCode { + + _LAYOUT_CREATED(HttpStatus.CREATED, "LAYOUT201", "성공적으로 배치도를 생성했습니다."), + + _LAYOUT_FOUND(HttpStatus.OK, "LAYOUT200", "성공적으로 배치도를 조회했습니다."), + + _LAYOUT_NO_CONTENT(HttpStatus.NO_CONTENT, "LAYOUT204", "조회된 배치도가 없습니다."), + ; + + 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/table_layout/service/TableLayoutCommandService.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutCommandService.java new file mode 100644 index 0000000..3e7840b --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutCommandService.java @@ -0,0 +1,8 @@ +package com.eatsfine.eatsfine.domain.table_layout.service; + +import com.eatsfine.eatsfine.domain.table_layout.dto.req.TableLayoutReqDto; +import com.eatsfine.eatsfine.domain.table_layout.dto.res.TableLayoutResDto; + +public interface TableLayoutCommandService { + TableLayoutResDto.LayoutDetailDto createLayout(Long storeId, TableLayoutReqDto.LayoutCreateDto dto); +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutCommandServiceImpl.java new file mode 100644 index 0000000..3eeb906 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutCommandServiceImpl.java @@ -0,0 +1,50 @@ +package com.eatsfine.eatsfine.domain.table_layout.service; + +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.table_layout.converter.TableLayoutConverter; +import com.eatsfine.eatsfine.domain.table_layout.dto.req.TableLayoutReqDto; +import com.eatsfine.eatsfine.domain.table_layout.dto.res.TableLayoutResDto; +import com.eatsfine.eatsfine.domain.table_layout.entity.TableLayout; +import com.eatsfine.eatsfine.domain.table_layout.repository.TableLayoutRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class TableLayoutCommandServiceImpl implements TableLayoutCommandService { + private final StoreRepository storeRepository; + private final TableLayoutRepository tableLayoutRepository; + + // 테이블 배치도 생성 + @Override + public TableLayoutResDto.LayoutDetailDto createLayout(Long storeId, TableLayoutReqDto.LayoutCreateDto dto) { + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + deactivateExistingLayout(store); + + // 새 배치도 생성 + TableLayout newLayout = TableLayout.builder() + .store(store) + .lows(dto.gridRow()) + .cols(dto.gridCol()) + .isActive(true) + .isDeleted(false) + .build(); + + TableLayout savedLayout = tableLayoutRepository.save(newLayout); + + return TableLayoutConverter.toLayoutDetailDto(savedLayout); + } + + // 기존 테이블 배치도 비활성화 + private void deactivateExistingLayout(Store store) { + tableLayoutRepository.findByStoreIdAndIsActiveTrue(store.getId()) + .ifPresent(tableLayoutRepository::delete); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutQueryService.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutQueryService.java new file mode 100644 index 0000000..ac83475 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutQueryService.java @@ -0,0 +1,7 @@ +package com.eatsfine.eatsfine.domain.table_layout.service; + +import com.eatsfine.eatsfine.domain.table_layout.dto.res.TableLayoutResDto; + +public interface TableLayoutQueryService { + TableLayoutResDto.LayoutDetailDto getActiveLayout(Long storeId); +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutQueryServiceImpl.java new file mode 100644 index 0000000..b5ab0c6 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutQueryServiceImpl.java @@ -0,0 +1,31 @@ +package com.eatsfine.eatsfine.domain.table_layout.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.table_layout.converter.TableLayoutConverter; +import com.eatsfine.eatsfine.domain.table_layout.dto.res.TableLayoutResDto; +import com.eatsfine.eatsfine.domain.table_layout.repository.TableLayoutRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TableLayoutQueryServiceImpl implements TableLayoutQueryService { + private final StoreRepository storeRepository; + private final TableLayoutRepository tableLayoutRepository; + + // 테이블 배치도 조회 + @Override + public TableLayoutResDto.LayoutDetailDto getActiveLayout(Long storeId) { + storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + // 배치도가 없을 시 null 반환 + return tableLayoutRepository.findByStoreIdAndIsActiveTrue(storeId) + .map(TableLayoutConverter::toLayoutDetailDto) + .orElse(null); + } +}