Skip to content

Commit

Permalink
[feat] 구매자는 장바구니에 담긴 상품을 주문할 수 있다 (#80)
Browse files Browse the repository at this point in the history
* [feat] Order 생성자 수정

주문하기 전에 OrderValidator를 통해 검증한다.

* [feat] SingleStoreOrderValidator

동일한 가게에 대한 주문인지 검증

* [feat] PayAmountValidator

잔고가 부족하면 주문할 수 없습니다.

* [fix] CustomerRepository의 Id 변경

Long -> UUID

* [chore] .gitkeep 삭제

* [feat] OrderCreationService 구현

Composite 패턴으로 Validator를 Order 생성자에 넘긴다.

* [feat] OrderRepository

* [feat] OrderItem

* [fix] Cart는 Menu가 아니라 CartItem 리스트를 가진다.

수량을 반영하기 위해 MenuId를 가지는 CartItem으로 수정

* [fix] 카트에 담긴 상품의 총합을 조회하기 위해서는 Repository를 조회한다

상품의 가격은 변동될 수 있습니다.
카트에 담긴 상품의 총합은 (카트에 담긴 시점의 가격이 아니라) 변동된 가격을 기준으로 계산되어야 합니다.

* [test] Cart 도메인 변경으로 인한 테스트 코드 수정

Cart.getTotalPrice 삭제로 인한 테스트 코드 수정
Menu -> CartItem 으로 인한 테스트 코드 수정

* [feat] Order 도메인 설계

주문 생성시 1)동일한 가게의 메뉴에 대한 주문인지 2)재고가 있는지 검증 후 생성한다.

* [feat] Menu 재고 삭감 서비스

주문 생성시 해당 메뉴의 재고를 삭감하는 서비스

* [feat] Menu 검증 서비스

주문은 같은 가게의 메뉴만 주문할 수 있다.

* [feat] 주문 생성 서비스

주문은 1)같은 가게의 메뉴에 대해 2)재고가 있고 3)결제할 포인트가 충분할 때 진행할 수 있다.

* [feat] PriceChecker 구현

Menu의 현재 가격으로 OrderItem을 만들어주는 검증 서비스

* [feat] 주문 결제 서비스

주문 금액만큼 사용자의 계좌에서 차감하는 서비스

* [refactor] 자바 컨벤션에 맞게 수정

* [refactor] OrderCreationService 내부 코드 정리

Optional.orElseThrow() 활용해서 if-else문 제거

* [test] OrderCreationservice 테스트 코드 작성

* [fix] 에러 메시지 구체화

조회 시도한 Customer의 id를 명시

* [refactor] 가독성 향상

* [feat] 최소 주문금액 확인 로직 추가

현재 Menu의 가격을 조회하면서 최소 주문금액 이상을 주문하였는지 확인

* [test] 최소 주문금액 확인 로직 추가

현재 Menu의 가격을 조회하면서 최소 주문금액 이상을 주문하였는지 확인

* [refactor] 메서드로 추출

* [refactor] RequiredArgsConstructor 적용

* [fix] 중복된 검증 로직 제거

OrderCreationService에서 Store를 조회하던 로직 제거
SingleStoreOrderValidator에서 검증된 단일 Store를 반환

* [test] 중복된 검증 로직 제거

OrderCreationService에서 Store를 조회하던 로직 제거
SingleStoreOrderValidator에서 검증된 단일 Store를 반환

* [feat] 재고 부족한 경우 예외 처리

메뉴의 재고가 부족하면 NotEnoughStockExceptioh이 발생합니다.

* [docs] 내부 예외 주석

내부적으로 발생하는 RuntimeException에 대한 주석 추가

* [feat] 주문 endpoint 생성

* [test] 주문 endpoint 테스트

* [fix] static 삭제
  • Loading branch information
Dr-KoKo authored Aug 17, 2024
1 parent d528484 commit a3079a1
Show file tree
Hide file tree
Showing 37 changed files with 1,142 additions and 70 deletions.
38 changes: 23 additions & 15 deletions src/main/java/camp/woowak/lab/cart/domain/Cart.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.util.Set;
import java.util.stream.Collectors;

import camp.woowak.lab.cart.domain.vo.CartItem;
import camp.woowak.lab.cart.exception.OtherStoreMenuException;
import camp.woowak.lab.cart.exception.StoreNotOpenException;
import camp.woowak.lab.menu.domain.Menu;
Expand All @@ -16,7 +17,7 @@
@Getter
public class Cart {
private final String customerId;
private final List<Menu> menuList;
private final List<CartItem> cartItems;

/**
* 생성될 때 무조건 cart가 비어있도록 구현
Expand All @@ -31,27 +32,36 @@ public Cart(String customerId) {
* 해당 Domain을 사용하는 같은 패키지내의 클래스, 혹은 자식 클래스는 List를 커스텀할 수 있습니다.
*
* @param customerId 장바구니 소유주의 ID값입니다.
* @param menuList 장바구니에 사용될 List입니다.
* @param cartItems 장바구니에 사용될 List입니다.
*/
protected Cart(String customerId, List<Menu> menuList) {
protected Cart(String customerId, List<CartItem> cartItems) {
this.customerId = customerId;
this.menuList = menuList;
this.cartItems = cartItems;
}

public void addMenu(Menu menu) {
addMenu(menu, 1);
}

public void addMenu(Menu menu, int amount) {
Store store = menu.getStore();
validateOtherStore(store.getId());
validateStoreOpenTime(store);

this.menuList.add(menu);
CartItem existingCartItem = getExistingCartItem(menu, store);
if (existingCartItem != null) {
CartItem updatedCartItem = existingCartItem.add(amount);
cartItems.set(cartItems.indexOf(existingCartItem), updatedCartItem);
} else {
this.cartItems.add(new CartItem(menu.getId(), store.getId(), amount));
}
}

public long getTotalPrice() {
return this.menuList.stream()
.map(Menu::getPrice)
.mapToLong(Long::valueOf)
.boxed()
.reduce(0L, Long::sum);
private CartItem getExistingCartItem(Menu menu, Store store) {
return cartItems.stream()
.filter(item -> item.getMenuId().equals(menu.getId()) && item.getStoreId().equals(store.getId()))
.findFirst()
.orElse(null);
}

private void validateStoreOpenTime(Store store) {
Expand All @@ -72,10 +82,8 @@ private void validateOtherStore(Long menuStoreId) {
}

private Set<Long> getStoreIds() {
return this.menuList.stream()
.map(Menu::getStore)
.mapToLong(Store::getId)
.boxed()
return this.cartItems.stream()
.map(CartItem::getStoreId)
.collect(Collectors.toSet());
}
}
46 changes: 46 additions & 0 deletions src/main/java/camp/woowak/lab/cart/domain/vo/CartItem.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package camp.woowak.lab.cart.domain.vo;

import java.util.Objects;

public class CartItem {
private final Long menuId;
private final Long storeId;
private final int amount;

public CartItem(Long menuId, Long storeId, Integer amount) {
this.menuId = menuId;
this.storeId = storeId;
this.amount = amount;
}

public Long getMenuId() {
return menuId;
}

public Long getStoreId() {
return storeId;
}

public int getAmount() {
return amount;
}

public CartItem add(Integer increment) {
return new CartItem(menuId, storeId, amount + increment);
}

@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (!(o instanceof CartItem item))
return false;
return Objects.equals(menuId, item.menuId) && Objects.equals(storeId, item.storeId)
&& Objects.equals(amount, item.amount);
}

@Override
public int hashCode() {
return Objects.hash(menuId, storeId, amount);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@

public enum CartErrorCode implements ErrorCode {
MENU_NOT_FOUND(HttpStatus.NOT_FOUND, "ca_1_1", "해당 메뉴가 존재하지 않습니다."),
OTHER_STORE_MENU(HttpStatus.BAD_REQUEST,"ca_1_2","다른 매장의 메뉴는 등록할 수 없습니다."),
STORE_NOT_OPEN(HttpStatus.BAD_REQUEST,"ca_1_3","주문 가능한 시간이 아닙니다.");
OTHER_STORE_MENU(HttpStatus.BAD_REQUEST, "ca_1_2", "다른 매장의 메뉴는 등록할 수 없습니다."),
STORE_NOT_OPEN(HttpStatus.BAD_REQUEST, "ca_1_3", "주문 가능한 시간이 아닙니다.");

private final int status;
private final String errorCode;
Expand Down
17 changes: 15 additions & 2 deletions src/main/java/camp/woowak/lab/cart/service/CartService.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package camp.woowak.lab.cart.service;

import java.util.List;

import org.springframework.stereotype.Service;

import camp.woowak.lab.cart.domain.Cart;
import camp.woowak.lab.cart.domain.vo.CartItem;
import camp.woowak.lab.cart.exception.MenuNotFoundException;
import camp.woowak.lab.cart.repository.CartRepository;
import camp.woowak.lab.cart.service.command.AddCartCommand;
Expand Down Expand Up @@ -36,8 +39,18 @@ public void addMenu(AddCartCommand command) {
}

public long getTotalPrice(CartTotalPriceCommand command) {
return getCart(command.customerId())
.getTotalPrice();
Cart cart = getCart(command.customerId());
List<Menu> findMenus = menuRepository.findAllById(
cart.getCartItems().stream().map(CartItem::getMenuId).toList());
long totalPrice = 0L;
for (Menu findMenu : findMenus) {
for (CartItem item : cart.getCartItems()) {
if (item.getMenuId().equals(findMenu.getId())) {
totalPrice += (long)findMenu.getPrice() * item.getAmount();
}
}
}
return totalPrice;
}

private Cart getCart(String customerId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
public enum CustomerErrorCode implements ErrorCode {
INVALID_CREATION(HttpStatus.BAD_REQUEST, "C1", "잘못된 요청입니다."),
DUPLICATE_EMAIL(HttpStatus.BAD_REQUEST, "C2", "이미 존재하는 이메일입니다."),
AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "C3", "이메일 또는 비밀번호가 올바르지 않습니다.");
AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "C3", "이메일 또는 비밀번호가 올바르지 않습니다."),
NOT_FOUND(HttpStatus.NOT_FOUND, "C4", "존재하지 않는 사용자입니다.");

private final HttpStatus status;
private final String errorCode;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package camp.woowak.lab.customer.exception;

import camp.woowak.lab.common.exception.NotFoundException;

public class NotFoundCustomerException extends NotFoundException {
public NotFoundCustomerException(String message) {
super(CustomerErrorCode.NOT_FOUND, message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@
import org.springframework.data.jpa.repository.JpaRepository;

import camp.woowak.lab.customer.domain.Customer;
import camp.woowak.lab.customer.exception.NotFoundCustomerException;

public interface CustomerRepository extends JpaRepository<Customer, UUID> {
Optional<Customer> findByEmail(String email);

default Customer findByIdOrThrow(UUID id) {
return findById(id).orElseThrow(() -> new NotFoundCustomerException("존재하지 않는 사용자(id=" + id + ")를 조회했습니다."));
}
}
7 changes: 7 additions & 0 deletions src/main/java/camp/woowak/lab/menu/domain/Menu.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package camp.woowak.lab.menu.domain;

import camp.woowak.lab.menu.exception.NotEnoughStockException;
import camp.woowak.lab.store.domain.Store;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
Expand Down Expand Up @@ -58,4 +59,10 @@ public Long getId() {
return id;
}

public void decrementStockCount(int amount) {
if (stockCount < amount) {
throw new NotEnoughStockException("메뉴(id=" + id + "의 재고가 부족합니다.");
}
stockCount -= amount;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ public enum MenuErrorCode implements ErrorCode {
INVALID_PRICE(HttpStatus.BAD_REQUEST, "m_6", "메뉴의 가격 범위를 벗어났습니다."),
INVALID_STOCK_COUNT(HttpStatus.BAD_REQUEST, "m_7", "메뉴의 재고 개수는 1개 이상이어야 합니다."),

NOT_FOUND_MENU_CATEGORY(HttpStatus.BAD_REQUEST, "M3", "메뉴 카테고리를 찾을 수 없습니다.");
NOT_FOUND_MENU_CATEGORY(HttpStatus.BAD_REQUEST, "M3", "메뉴 카테고리를 찾을 수 없습니다."),
NOT_ENOUGH_STOCK(HttpStatus.BAD_REQUEST, "M4", "재고가 부족합니다.");

private final int status;
private final String errorCode;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package camp.woowak.lab.menu.exception;

import camp.woowak.lab.common.exception.BadRequestException;

public class NotEnoughStockException extends BadRequestException {
public NotEnoughStockException(String message) {
super(MenuErrorCode.NOT_ENOUGH_STOCK, message);
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
package camp.woowak.lab.menu.repository;

import java.util.List;
import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import camp.woowak.lab.menu.domain.Menu;
import jakarta.persistence.LockModeType;

public interface MenuRepository extends JpaRepository<Menu, Long> {
@Query("SELECT m FROM Menu m JOIN FETCH m.store WHERE m.id = :id")
Optional<Menu> findByIdWithStore(@Param("id") Long id);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT m FROM Menu m where m.id in :ids")
List<Menu> findAllByIdForUpdate(List<Long> ids);
}
52 changes: 52 additions & 0 deletions src/main/java/camp/woowak/lab/order/domain/Order.java
Original file line number Diff line number Diff line change
@@ -1,17 +1,36 @@
package camp.woowak.lab.order.domain;

import java.util.ArrayList;
import java.util.List;

import camp.woowak.lab.cart.domain.vo.CartItem;
import camp.woowak.lab.customer.domain.Customer;
import camp.woowak.lab.menu.exception.NotEnoughStockException;
import camp.woowak.lab.order.domain.vo.OrderItem;
import camp.woowak.lab.order.exception.EmptyCartException;
import camp.woowak.lab.order.exception.MinimumOrderPriceNotMetException;
import camp.woowak.lab.order.exception.MultiStoreOrderException;
import camp.woowak.lab.order.exception.NotFoundMenuException;
import camp.woowak.lab.payaccount.exception.InsufficientBalanceException;
import camp.woowak.lab.payaccount.exception.NotFoundAccountException;
import camp.woowak.lab.store.domain.Store;
import camp.woowak.lab.store.exception.NotFoundStoreException;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "ORDERS")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Expand All @@ -20,4 +39,37 @@ public class Order {
private Customer requester;
@ManyToOne(fetch = FetchType.LAZY)
private Store store;
@CollectionTable(name = "ORDER_ITEMS", joinColumns = @JoinColumn(name = "order_id"))
@ElementCollection(fetch = FetchType.EAGER)
private List<OrderItem> orderItems = new ArrayList<>();

/**
* @throws EmptyCartException 카트가 비어 있는 경우
* @throws NotFoundStoreException 가게가 조회되지 않는 경우
* @throws MultiStoreOrderException 여러 가게의 메뉴를 주문한 경우
* @throws NotEnoughStockException 메뉴의 재고가 부족한 경우
* @throws NotFoundMenuException 주문한 메뉴가 조회되지 않는 경우
* @throws MinimumOrderPriceNotMetException 가게의 최소 주문금액보다 적은 금액을 주문한 경우
* @throws NotFoundAccountException 구매자의 계좌가 조회되지 않는 경우
* @throws InsufficientBalanceException 구매자의 계좌에 잔액이 충분하지 않은 경우
*/
public Order(Customer requester, List<CartItem> cartItems,
SingleStoreOrderValidator singleStoreOrderValidator,
StockRequester stockRequester, PriceChecker priceChecker, WithdrawPointService withdrawPointService) {
Store store = singleStoreOrderValidator.check(cartItems);
stockRequester.request(cartItems);
List<OrderItem> orderItems = priceChecker.check(store, cartItems);
withdrawPointService.withdraw(requester, orderItems);
this.requester = requester;
this.store = store;
this.orderItems = orderItems;
}

public Long getId() {
return id;
}

public List<OrderItem> getOrderItems() {
return orderItems;
}
}
Loading

0 comments on commit a3079a1

Please sign in to comment.