Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/main/java/ssu/eatssu/domain/menu/entity/Meal.java
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ public void addMealMenu(MealMenu mealMenu) {
mealMenus.add(mealMenu);
}

public void updatePrice(Integer price) {
this.price = price;
}

public List<Menu> getMenus() {
return mealMenus.stream()
.map(MealMenu::getMenu)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package ssu.eatssu.domain.menu.presentation.dto.response;

import java.util.List;

public record MealSlotReconcileResult(
List<Long> mealIds,
List<List<String>> unmatchedMainMenus,
List<Long> deletedMealIds,
List<Long> keptWithReviews
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
Expand All @@ -15,6 +16,7 @@
import ssu.eatssu.domain.menu.presentation.dto.request.MealCreateWithPriceRequest;
import ssu.eatssu.domain.menu.presentation.dto.response.MealCreateResult;
import ssu.eatssu.domain.menu.presentation.dto.response.MealDetailResponse;
import ssu.eatssu.domain.menu.presentation.dto.response.MealSlotReconcileResult;
import ssu.eatssu.domain.menu.presentation.dto.response.MenusInMealResponse;
import ssu.eatssu.domain.menu.presentation.rest.docs.MealControllerDocs;
import ssu.eatssu.domain.menu.service.MealService;
Expand Down Expand Up @@ -64,6 +66,20 @@ public BaseResponse<MealCreateResult> createMealWithPrice(
return BaseResponse.success(mealService.createMealWithPrice(date, restaurant, timePart, request));
}

@Override
@PutMapping("/with-price/slot")
public BaseResponse<MealSlotReconcileResult> reconcileMealSlot(
@RequestParam("date") @DateTimeFormat(pattern = "yyyyMMdd") Date date,
@RequestParam("restaurant") Restaurant restaurant,
@RequestParam("time") TimePart timePart,
@RequestBody List<MealCreateWithPriceRequest> requests) {
if (RestaurantType.isFixedType(restaurant)) {
throw new BaseException(NOT_SUPPORT_RESTAURANT);
}

return BaseResponse.success(mealService.reconcileMealSlot(date, restaurant, timePart, requests));
}

@Override
@GetMapping("")
public BaseResponse<List<MealDetailResponse>> getMealDetail(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import ssu.eatssu.domain.menu.presentation.dto.request.MealCreateWithPriceRequest;
import ssu.eatssu.domain.menu.presentation.dto.response.MealCreateResult;
import ssu.eatssu.domain.menu.presentation.dto.response.MealDetailResponse;
import ssu.eatssu.domain.menu.presentation.dto.response.MealSlotReconcileResult;
import ssu.eatssu.domain.menu.presentation.dto.response.MenusInMealResponse;
import ssu.eatssu.domain.restaurant.entity.Restaurant;
import ssu.eatssu.domain.user.entity.Language;
Expand Down Expand Up @@ -64,6 +65,26 @@ BaseResponse<MealCreateResult> createMealWithPrice(
@Parameter(description = "시간대") TimePart timePart,
MealCreateWithPriceRequest request);

@Operation(summary = "식단 슬롯 조정 [인증 토큰 필요 X]", description = """
특정 날짜, 식당, 시간대의 식단 목록을 요청 본문과 일치하도록 조정합니다.<br><br>
같은 메뉴명 목록의 식단은 유지하고 가격과 대표메뉴를 갱신합니다.<br><br>
요청에서 빠진 식단은 리뷰가 없을 때만 삭제합니다.
""")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "식단 슬롯 조정 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청 또는 지원하지 않는 식당", content =
@Content(schema = @Schema(implementation = BaseResponse.class))),
@ApiResponse(responseCode = "400", description = "잘못된 날짜형식", content = @Content(schema =
@Schema(implementation = BaseResponse.class))),
@ApiResponse(responseCode = "404", description = "존재하지 않는 식당", content = @Content(schema =
@Schema(implementation = BaseResponse.class)))
})
BaseResponse<MealSlotReconcileResult> reconcileMealSlot(
@Parameter(schema = @Schema(type = "string", format = "date", example = "20240101")) Date date,
@Parameter(description = "식당이름") Restaurant restaurant,
@Parameter(description = "시간대") TimePart timePart,
List<MealCreateWithPriceRequest> requests);

@Operation(summary = "변동 메뉴 식단 리스트 조회 [인증 토큰 필요 X]", description = """
변동 메뉴 식단 리스트를 조회하는 API 입니다.
변동 메뉴 식당 (학생 식당, 도담, 기숙사 식당) 의 특정날짜(yyyyMMdd), 특정시간대(아침/점심/저녁)에 해당하는 식단 목록을 조회합니다.
Expand Down
93 changes: 85 additions & 8 deletions src/main/java/ssu/eatssu/domain/menu/service/MealService.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,22 @@
import ssu.eatssu.domain.menu.presentation.dto.request.MealCreateWithPriceRequest;
import ssu.eatssu.domain.menu.presentation.dto.response.MealCreateResult;
import ssu.eatssu.domain.menu.presentation.dto.response.MealDetailResponse;
import ssu.eatssu.domain.menu.presentation.dto.response.MealSlotReconcileResult;
import ssu.eatssu.domain.menu.presentation.dto.response.MenusInMealResponse;
import ssu.eatssu.domain.restaurant.entity.Restaurant;
import ssu.eatssu.domain.restaurant.entity.RestaurantType;
import ssu.eatssu.domain.review.repository.ReviewRepository;
import ssu.eatssu.domain.user.entity.Language;
import ssu.eatssu.global.handler.response.BaseException;
import ssu.eatssu.global.handler.response.BaseResponseStatus;

import java.util.ArrayList;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

import static ssu.eatssu.global.handler.response.BaseResponseStatus.NOT_SUPPORT_RESTAURANT;
Expand All @@ -42,6 +46,7 @@ public class MealService {
private final MealRepository mealRepository;
private final MealMenuRepository mealMenuRepository;
private final MealMainMenuRepository mealMainMenuRepository;
private final ReviewRepository reviewRepository;
private final MealRatingService mealRatingService;
private final MenuService menuService;

Expand Down Expand Up @@ -102,11 +107,87 @@ public MealCreateResult createMeal(Date date, Restaurant restaurant, TimePart ti

@Transactional
public MealCreateResult createMealWithPrice(Date date, Restaurant restaurant, TimePart timePart,
MealCreateWithPriceRequest request) {
MealCreateWithPriceRequest request) {
return createMealWithOptionalPrice(date, restaurant, timePart, request.menuNames(), request.price(),
request.mainMenus());
}

@Transactional
public MealSlotReconcileResult reconcileMealSlot(Date date, Restaurant restaurant, TimePart timePart,
List<MealCreateWithPriceRequest> requests) {
validateMealRestaurant(restaurant);
validateSlotRequests(requests);

List<Meal> existingMeals = mealRepository.findAllByDateAndTimePartAndRestaurant(date, timePart, restaurant);
Set<Long> matchedMealIds = new HashSet<>();
List<Long> mealIds = new ArrayList<>();
List<List<String>> unmatchedMainMenus = new ArrayList<>();

for (MealCreateWithPriceRequest request : requests) {
Meal existingMeal = findMatchingMeal(existingMeals, matchedMealIds, request.menuNames()).orElse(null);
Long mealId;
if (existingMeal != null) {
existingMeal.updatePrice(request.price());
mealId = existingMeal.getId();
matchedMealIds.add(mealId);
} else {
mealId = createNewMeal(date, restaurant, timePart, request.menuNames(), request.price());
}

mealIds.add(mealId);
unmatchedMainMenus.add(upsertMainMenus(mealId, request.menuNames(), request.mainMenus()));
}

List<Long> deletedMealIds = new ArrayList<>();
List<Long> keptWithReviews = new ArrayList<>();
for (Meal existingMeal : existingMeals) {
Long mealId = existingMeal.getId();
if (matchedMealIds.contains(mealId)) {
continue;
}
if (reviewRepository.existsByMeal_Id(mealId)) {
keptWithReviews.add(mealId);
continue;
}

mealMainMenuRepository.deleteAllByMeal_Id(mealId);
mealMainMenuRepository.flush();
mealRepository.delete(existingMeal);
deletedMealIds.add(mealId);
}

return new MealSlotReconcileResult(mealIds, unmatchedMainMenus, deletedMealIds, keptWithReviews);
}

private void validateSlotRequests(List<MealCreateWithPriceRequest> requests) {
if (requests == null || requests.isEmpty()) {
throw new BaseException(BaseResponseStatus.BAD_REQUEST);
}

Set<List<String>> menuNameSets = new HashSet<>();
for (MealCreateWithPriceRequest request : requests) {
if (request == null || request.menuNames() == null || request.menuNames().isEmpty()
|| request.menuNames().stream().anyMatch(name -> name == null || name.isBlank())) {
throw new BaseException(BaseResponseStatus.BAD_REQUEST);
}
if (!menuNameSets.add(sortedMenuNames(request.menuNames()))) {
throw new BaseException(BaseResponseStatus.BAD_REQUEST);
}
}
}

private Optional<Meal> findMatchingMeal(List<Meal> meals, Set<Long> matchedMealIds, List<String> menuNames) {
List<String> sortedRequestMenuNames = sortedMenuNames(menuNames);
return meals.stream()
.filter(meal -> !matchedMealIds.contains(meal.getId()))
.filter(meal -> sortedMenuNames(meal.getMenuNames()).equals(sortedRequestMenuNames))
.findFirst();
}

private List<String> sortedMenuNames(List<String> menuNames) {
return menuNames.stream().sorted().toList();
}

private MealCreateResult createMealWithOptionalPrice(Date date, Restaurant restaurant, TimePart timePart,
List<String> menuNames, Integer price,
List<MainMenuRequest> mainMenus) {
Expand Down Expand Up @@ -159,16 +240,12 @@ private Optional<Long> getExistingMealId(Date date, TimePart timePart, Restauran
List<String> menuNames) {
List<Meal> meals = mealRepository.findAllByDateAndTimePartAndRestaurant(date, timePart, restaurant);

List<String> sortedRequestMenuNames = menuNames.stream()
.sorted()
.toList();
List<String> sortedRequestMenuNames = sortedMenuNames(menuNames);

return meals.stream()
.filter(meal -> {
List<String> sortedMenuNames = meal.getMenuNames().stream()
.sorted()
.toList();
return sortedMenuNames.equals(sortedRequestMenuNames);
List<String> existingMenuNames = sortedMenuNames(meal.getMenuNames());
return existingMenuNames.equals(sortedRequestMenuNames);
})
.findFirst()
.map(Meal::getId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ public interface ReviewRepository extends JpaRepository<Review, Long>, ReviewRep

List<Review> findAllByMeal(Meal meal);

boolean existsByMeal_Id(Long mealId);

/**
* dto projection
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import ssu.eatssu.domain.menu.entity.constants.TimePart;
import ssu.eatssu.domain.menu.presentation.dto.response.MealSlotReconcileResult;
import ssu.eatssu.domain.menu.presentation.dto.response.MealCreateResult;
import ssu.eatssu.domain.menu.presentation.dto.response.MenusInMealResponse;
import ssu.eatssu.domain.menu.service.MealService;
import ssu.eatssu.domain.restaurant.entity.Restaurant;
import ssu.eatssu.global.handler.GlobalExceptionHandler;
import ssu.eatssu.global.handler.response.BaseException;
import ssu.eatssu.global.handler.response.BaseResponseStatus;

import java.util.List;

Expand All @@ -26,6 +29,7 @@
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

Expand Down Expand Up @@ -104,6 +108,71 @@ void returnsBadRequestWhenCreatingMealWithPriceForFixedRestaurant() throws Excep
verifyNoInteractions(mealService);
}

@Test
void reconcilesMealSlot() throws Exception {
// given
when(mealService.reconcileMealSlot(any(), eq(Restaurant.DODAM), eq(TimePart.LUNCH), any()))
.thenReturn(new MealSlotReconcileResult(List.of(1L, 2L), List.of(List.of(), List.of("김치")),
List.of(3L), List.of(4L)));

// when & then
mockMvc.perform(put("/meals/with-price/slot")
.param("date", "20260101")
.param("restaurant", Restaurant.DODAM.name())
.param("time", TimePart.LUNCH.name())
.contentType(MediaType.APPLICATION_JSON)
.content("""
[
{"menuNames":["돈까스"],"price":3000,"mainMenus":[]},
{"menuNames":["제육볶음"],"price":3500,"mainMenus":[]}
]
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.isSuccess").value(true))
.andExpect(jsonPath("$.result.mealIds[0]").value(1))
.andExpect(jsonPath("$.result.mealIds[1]").value(2))
.andExpect(jsonPath("$.result.unmatchedMainMenus[1][0]").value("김치"))
.andExpect(jsonPath("$.result.deletedMealIds[0]").value(3))
.andExpect(jsonPath("$.result.keptWithReviews[0]").value(4));
}

@Test
void returnsBadRequestWhenReconcilingDuplicateMeals() throws Exception {
// given
when(mealService.reconcileMealSlot(any(), eq(Restaurant.DODAM), eq(TimePart.LUNCH), any()))
.thenThrow(new BaseException(BaseResponseStatus.BAD_REQUEST));

// when & then
mockMvc.perform(put("/meals/with-price/slot")
.param("date", "20260101")
.param("restaurant", Restaurant.DODAM.name())
.param("time", TimePart.LUNCH.name())
.contentType(MediaType.APPLICATION_JSON)
.content("""
[
{"menuNames":["돈까스","김치"],"price":3000},
{"menuNames":["김치","돈까스"],"price":3000}
]
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.isSuccess").value(false));
}

@Test
void returnsBadRequestWhenReconcilingFixedRestaurant() throws Exception {
// when & then
mockMvc.perform(put("/meals/with-price/slot")
.param("date", "20260101")
.param("restaurant", Restaurant.FOOD_COURT.name())
.param("time", TimePart.LUNCH.name())
.contentType(MediaType.APPLICATION_JSON)
.content("[{\"menuNames\":[\"돈까스\"],\"price\":3000}]"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.isSuccess").value(false));

verifyNoInteractions(mealService);
}

@Test
void returnsMealDetails() throws Exception {
when(mealService.getMealDetailsByDateAndRestaurantAndTimePart(any(), eq(Restaurant.DODAM),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import ssu.eatssu.domain.menu.persistence.MealMenuRepository;
import ssu.eatssu.domain.menu.persistence.MealRepository;
import ssu.eatssu.domain.restaurant.entity.Restaurant;
import ssu.eatssu.domain.review.repository.ReviewRepository;
import ssu.eatssu.global.handler.response.BaseException;

import java.util.Date;
Expand All @@ -18,8 +19,8 @@ class MealServiceBranchTest {
@Test
void 고정_식당의_식단은_조회하지_않는다() {
MealService service = new MealService(mock(MealRepository.class), mock(MealMenuRepository.class),
mock(MealMainMenuRepository.class), mock(MealRatingService.class),
mock(MenuService.class));
mock(MealMainMenuRepository.class), mock(ReviewRepository.class),
mock(MealRatingService.class), mock(MenuService.class));

assertThatThrownBy(() -> service.getMealDetailsByDateAndRestaurantAndTimePart(new Date(), Restaurant.FOOD_COURT,
TimePart.LUNCH, null))
Expand Down
Loading
Loading