diff --git a/src/main/java/kitchenpos/menus/tobe/domain/MenuProduct.java b/src/main/java/kitchenpos/menus/tobe/domain/MenuProduct.java new file mode 100644 index 000000000..025cb5c0b --- /dev/null +++ b/src/main/java/kitchenpos/menus/tobe/domain/MenuProduct.java @@ -0,0 +1,73 @@ +package kitchenpos.menus.tobe.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.ForeignKey; +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 jakarta.persistence.Transient; +import kitchenpos.products.tobe.domain.Product; + +import java.util.UUID; + +@Table(name = "menu_product") +@Entity +public class MenuProduct { + @Column(name = "seq") + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Id + private Long seq; + + @ManyToOne(optional = false) + @JoinColumn( + name = "product_id", + columnDefinition = "binary(16)", + foreignKey = @ForeignKey(name = "fk_menu_product_to_product") + ) + private Product product; + + @Column(name = "quantity", nullable = false) + private long quantity; + + @Transient + private UUID productId; + + public MenuProduct() { + } + + public Long getSeq() { + return seq; + } + + public void setSeq(final Long seq) { + this.seq = seq; + } + + public Product getProduct() { + return product; + } + + public void setProduct(final Product product) { + this.product = product; + } + + public long getQuantity() { + return quantity; + } + + public void setQuantity(final long quantity) { + this.quantity = quantity; + } + + public UUID getProductId() { + return productId; + } + + public void setProductId(final UUID productId) { + this.productId = productId; + } +} diff --git a/src/main/java/kitchenpos/products/tobe/application/ProductService.java b/src/main/java/kitchenpos/products/tobe/application/ProductService.java new file mode 100644 index 000000000..2eda1fc18 --- /dev/null +++ b/src/main/java/kitchenpos/products/tobe/application/ProductService.java @@ -0,0 +1,71 @@ +package kitchenpos.products.tobe.application; + +import kitchenpos.menus.domain.Menu; +import kitchenpos.menus.domain.MenuProduct; +import kitchenpos.menus.domain.MenuRepository; +import kitchenpos.products.tobe.domain.Product; +import kitchenpos.products.tobe.domain.ProductName; +import kitchenpos.products.tobe.domain.ProductRepository; +import kitchenpos.products.infra.PurgomalumClient; +import kitchenpos.products.tobe.domain.Price; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.UUID; + +@Service +public class ProductService { + private final ProductRepository productRepository; + private final MenuRepository menuRepository; + private final PurgomalumClient purgomalumClient; + + public ProductService( + final ProductRepository productRepository, + final MenuRepository menuRepository, + final PurgomalumClient purgomalumClient + ) { + this.productRepository = productRepository; + this.menuRepository = menuRepository; + this.purgomalumClient = purgomalumClient; + } + + @Transactional + public Product create(final Product request) { + final String name = request.getName().getName(); + final Price price = new Price(request.getPrice().getPrice()); + final ProductName productName = new ProductName(name, purgomalumClient); + final Product product = new Product(productName, price); + return productRepository.save(product); + } + + @Transactional + public Product changePrice(final UUID productId, final Product request) { + final Price price = new Price(request.getPrice().getPrice()); + final Product product = productRepository.findById(productId) + .orElseThrow(NoSuchElementException::new); + product.setPrice(price); + final List menus = menuRepository.findAllByProductId(productId); + for (final Menu menu : menus) { + BigDecimal sum = BigDecimal.ZERO; + for (final MenuProduct menuProduct : menu.getMenuProducts()) { + sum = sum.add( + menuProduct.getProduct() + .getPrice() + .multiply(BigDecimal.valueOf(menuProduct.getQuantity())) + ); + } + if (menu.getPrice().compareTo(sum) > 0) { + menu.setDisplayed(false); + } + } + return product; + } + + @Transactional(readOnly = true) + public List findAll() { + return productRepository.findAll(); + } +} diff --git a/src/main/java/kitchenpos/products/tobe/domain/Price.java b/src/main/java/kitchenpos/products/tobe/domain/Price.java new file mode 100644 index 000000000..e9c6e77d0 --- /dev/null +++ b/src/main/java/kitchenpos/products/tobe/domain/Price.java @@ -0,0 +1,54 @@ +package kitchenpos.products.tobe.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.math.BigDecimal; +import java.util.Objects; + +@Embeddable +public class Price { + + @Column(name = "price", nullable = false) + private BigDecimal price; + + protected Price() { + } + + public Price(Long price) { + if (Objects.isNull(price)) { + throw new IllegalArgumentException(); + } + var tempPrice = BigDecimal.valueOf(price); + validate(tempPrice); + this.price = tempPrice; + } + + public Price(BigDecimal price) { + validate(price); + this.price = price; + } + + public void validate(BigDecimal price) { + if (Objects.isNull(price) || price.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException(); + } + } + + public BigDecimal getPrice() { + return price; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Price price1 = (Price) o; + return Objects.equals(price, price1.price); + } + + @Override + public int hashCode() { + return Objects.hashCode(price); + } +} diff --git a/src/main/java/kitchenpos/products/tobe/domain/Product.java b/src/main/java/kitchenpos/products/tobe/domain/Product.java new file mode 100644 index 000000000..904b96866 --- /dev/null +++ b/src/main/java/kitchenpos/products/tobe/domain/Product.java @@ -0,0 +1,52 @@ +package kitchenpos.products.tobe.domain; + +import jakarta.persistence.*; + +import java.util.UUID; + +@Table(name = "product") +@Entity +public class Product { + @Column(name = "id", columnDefinition = "binary(16)") + @Id + private UUID id; + + @Embedded + private ProductName name; + + @Embedded + private Price price; + + public Product() { + } + + public Product(ProductName name, Price price) { + this.id = UUID.randomUUID(); + this.name = name; + this.price = price; + } + + public UUID getId() { + return id; + } + + public void setId(final UUID id) { + this.id = id; + } + + public ProductName getName() { + return name; + } + + public void setName(final ProductName name) { + this.name = name; + } + + public Price getPrice() { + return price; + } + + public void setPrice(Price price) { + this.price = price; + } +} diff --git a/src/main/java/kitchenpos/products/tobe/domain/ProductName.java b/src/main/java/kitchenpos/products/tobe/domain/ProductName.java new file mode 100644 index 000000000..af8d1e69c --- /dev/null +++ b/src/main/java/kitchenpos/products/tobe/domain/ProductName.java @@ -0,0 +1,51 @@ +package kitchenpos.products.tobe.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import kitchenpos.products.infra.PurgomalumClient; + +import java.math.BigDecimal; +import java.util.Objects; + +@Embeddable +public class ProductName { + + @Column(name = "name", nullable = false) + private String name; + + protected ProductName() { + } + + public ProductName(String name, PurgomalumClient purgomalumClient) { + validate(name, purgomalumClient); + this.name = name; + } + + public void validate(String name, PurgomalumClient purgomalumClient) { + if (Objects.isNull(name) || name.trim().isEmpty()) { + throw new IllegalArgumentException(); + } + + if (purgomalumClient.containsProfanity(name)) { + throw new IllegalArgumentException(); + } + this.name = name; + } + + public String getName() { + return name; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ProductName that = (ProductName) o; + return Objects.equals(name, that.name); + } + + @Override + public int hashCode() { + return Objects.hashCode(name); + } +} diff --git a/src/main/java/kitchenpos/products/tobe/domain/ProductRepository.java b/src/main/java/kitchenpos/products/tobe/domain/ProductRepository.java new file mode 100644 index 000000000..a41daecdb --- /dev/null +++ b/src/main/java/kitchenpos/products/tobe/domain/ProductRepository.java @@ -0,0 +1,16 @@ +package kitchenpos.products.tobe.domain; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface ProductRepository { + Product save(Product product); + + Optional findById(UUID id); + + List findAll(); + + List findAllByIdIn(List ids); +} + diff --git a/src/test/java/kitchenpos/products/tobe/application/InMemoryProductRepository.java b/src/test/java/kitchenpos/products/tobe/application/InMemoryProductRepository.java new file mode 100644 index 000000000..e7b3946a8 --- /dev/null +++ b/src/test/java/kitchenpos/products/tobe/application/InMemoryProductRepository.java @@ -0,0 +1,34 @@ +package kitchenpos.products.tobe.application; + +import kitchenpos.products.tobe.domain.Product; +import kitchenpos.products.tobe.domain.ProductRepository; + +import java.util.*; + +public class InMemoryProductRepository implements ProductRepository { + private final Map products = new HashMap<>(); + + @Override + public Product save(final Product product) { + products.put(product.getId(), product); + return product; + } + + @Override + public Optional findById(final UUID id) { + return Optional.ofNullable(products.get(id)); + } + + @Override + public List findAll() { + return new ArrayList<>(products.values()); + } + + @Override + public List findAllByIdIn(final List ids) { + return products.values() + .stream() + .filter(product -> ids.contains(product.getId())) + .toList(); + } +} diff --git a/src/test/java/kitchenpos/products/tobe/application/PriceServiceTest.java b/src/test/java/kitchenpos/products/tobe/application/PriceServiceTest.java new file mode 100644 index 000000000..153c57560 --- /dev/null +++ b/src/test/java/kitchenpos/products/tobe/application/PriceServiceTest.java @@ -0,0 +1,35 @@ +package kitchenpos.products.tobe.application; + +import kitchenpos.products.tobe.domain.Price; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatNoException; + +class PriceServiceTest { + + @DisplayName("가격 생성 성공") + @ParameterizedTest + @ValueSource(longs = {0L, 1L, 1000L, 999999L}) + void success(Long price) { + assertThatNoException() + .isThrownBy(() -> new Price(price)); + } + + @DisplayName("가격은 null이 될 수 없다.") + @ParameterizedTest + @NullSource + void fail_null_price(Long price) { + assertThatIllegalArgumentException().isThrownBy(() -> new Price(price)); + } + + @DisplayName("가격은 0보다 작을 수 없다.") + @Test + void fail_0_price() { + assertThatIllegalArgumentException().isThrownBy(() -> new Price(-1L)); + } +} diff --git a/src/test/java/kitchenpos/products/tobe/application/ProductNameServiceTest.java b/src/test/java/kitchenpos/products/tobe/application/ProductNameServiceTest.java new file mode 100644 index 000000000..0ebc819f7 --- /dev/null +++ b/src/test/java/kitchenpos/products/tobe/application/ProductNameServiceTest.java @@ -0,0 +1,44 @@ +package kitchenpos.products.tobe.application; + +import kitchenpos.products.application.FakePurgomalumClient; +import kitchenpos.products.infra.PurgomalumClient; +import kitchenpos.products.tobe.domain.ProductName; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; + +import static org.assertj.core.api.Assertions.*; + +class ProductNameServiceTest { + + private PurgomalumClient purgomalumClient; + + @BeforeEach + void setUp() { + purgomalumClient = new FakePurgomalumClient(); + } + + @DisplayName("상품명 정상 생성") + @Test + void success_create() { + assertThatNoException() + .isThrownBy(() -> new ProductName("후라이드", purgomalumClient)); + } + + @DisplayName("상품명으로 NULL, 빈 값으로 들어갈 수 없다.") + @NullAndEmptySource + @ParameterizedTest + void fail_invalid_name(String name) { + assertThatThrownBy(() -> new ProductName(name, purgomalumClient)) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("상품명으로 욕설이 들어갈 수 없다.") + @Test + void fail_contains_profanity() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new ProductName("욕설", purgomalumClient)); + } +} diff --git a/src/test/java/kitchenpos/products/tobe/application/ProductServiceTest.java b/src/test/java/kitchenpos/products/tobe/application/ProductServiceTest.java new file mode 100644 index 000000000..eb358eb0c --- /dev/null +++ b/src/test/java/kitchenpos/products/tobe/application/ProductServiceTest.java @@ -0,0 +1,118 @@ +package kitchenpos.products.tobe.application; + +import kitchenpos.menus.application.InMemoryMenuRepository; +import kitchenpos.menus.domain.MenuRepository; +import kitchenpos.products.application.FakePurgomalumClient; +import kitchenpos.products.tobe.domain.Price; +import kitchenpos.products.tobe.domain.Product; +import kitchenpos.products.tobe.domain.ProductRepository; +import kitchenpos.products.infra.PurgomalumClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.math.BigDecimal; +import java.util.List; +import java.util.UUID; + +import static kitchenpos.products.tobe.fixture.ProductFixture.createProductRequest; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +class ProductServiceTest { + private ProductRepository productRepository; + private MenuRepository menuRepository; + private PurgomalumClient purgomalumClient; + private ProductService productService; + + @BeforeEach + void setUp() { + productRepository = new InMemoryProductRepository(); + menuRepository = new InMemoryMenuRepository(); + purgomalumClient = new FakePurgomalumClient(); + productService = new ProductService(productRepository, menuRepository, purgomalumClient); + } + + @DisplayName("상품을 등록할 수 있다.") + @Test + void create() { + final Product expected = createProductRequest("후라이드", 16_000L, purgomalumClient); + final Product actual = productService.create(expected); + assertThat(actual).isNotNull(); + assertAll( + () -> assertThat(actual.getId()).isNotNull(), + () -> assertThat(actual.getName()).isEqualTo(expected.getName()), + () -> assertThat(actual.getPrice()).isEqualTo(expected.getPrice()) + ); + } + + @DisplayName("상품의 가격이 올바르지 않으면 등록할 수 없다.") + @ValueSource(strings = "-1000") + @NullSource + @ParameterizedTest + void create(final BigDecimal price) { + assertThatThrownBy(() -> createProductRequest("후라이드", price, purgomalumClient)) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("상품의 이름이 올바르지 않으면 등록할 수 없다.") + @ValueSource(strings = {"비속어", "욕설이 포함된 이름"}) + @NullSource + @ParameterizedTest + void create(final String name) { + assertThatThrownBy(() -> createProductRequest(name, 16_000L, purgomalumClient)) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("상품의 가격을 변경할 수 있다.") + @Test + void changePrice() { + final UUID productId = productRepository.save(createProductRequest("후라이드", 16_000L, purgomalumClient)).getId(); + final Product expected = changePriceRequest(15_000L); + final Product actual = productService.changePrice(productId, expected); + assertThat(actual.getPrice()).isEqualTo(expected.getPrice()); + } + + @DisplayName("상품의 가격이 올바르지 않으면 변경할 수 없다.") + @ValueSource(strings = "-1000") + @NullSource + @ParameterizedTest + void changePrice(final BigDecimal price) { + assertThatThrownBy(() -> changePriceRequest(price)) + .isInstanceOf(IllegalArgumentException.class); + } + + /* TODO 메뉴쪽 리펙토링 후 수정 + @DisplayName("상품의 가격이 변경될 때 메뉴의 가격이 메뉴에 속한 상품 금액의 합보다 크면 메뉴가 숨겨진다.") + @Test + void changePriceInMenu() { + final Product product = productRepository.save(createProductRequest("후라이드", 16_000L)); + final Menu menu = menuRepository.save(menu(19_000L, true, createMenuProduct(product, 2L))); + productService.changePrice(product.getId(), changePriceRequest(8_000L)); + assertThat(menuRepository.findById(menu.getId()).get().isDisplayed()).isFalse(); + } + */ + + @DisplayName("상품의 목록을 조회할 수 있다.") + @Test + void findAll() { + productRepository.save(createProductRequest("후라이드", 16_000L, purgomalumClient)); + productRepository.save(createProductRequest("양념치킨", 16_000L, purgomalumClient)); + final List actual = productService.findAll(); + assertThat(actual).hasSize(2); + } + + private Product changePriceRequest(final long price) { + return changePriceRequest(BigDecimal.valueOf(price)); + } + + private Product changePriceRequest(final BigDecimal price) { + final Product product = new Product(); + product.setPrice(new Price(price)); + return product; + } +} diff --git a/src/test/java/kitchenpos/products/tobe/fixture/MenuProductFixture.java b/src/test/java/kitchenpos/products/tobe/fixture/MenuProductFixture.java new file mode 100644 index 000000000..b2fe7ed32 --- /dev/null +++ b/src/test/java/kitchenpos/products/tobe/fixture/MenuProductFixture.java @@ -0,0 +1,17 @@ +package kitchenpos.products.tobe.fixture; + +import kitchenpos.menus.tobe.domain.MenuProduct; +import kitchenpos.products.tobe.domain.Product; + +import java.util.Random; + +public class MenuProductFixture { + + public static MenuProduct createMenuProduct(final Product product, final long quantity) { + final MenuProduct menuProduct = new MenuProduct(); + menuProduct.setSeq(new Random().nextLong()); + menuProduct.setProduct(product); + menuProduct.setQuantity(quantity); + return menuProduct; + } +} diff --git a/src/test/java/kitchenpos/products/tobe/fixture/ProductFixture.java b/src/test/java/kitchenpos/products/tobe/fixture/ProductFixture.java new file mode 100644 index 000000000..063bf0bcd --- /dev/null +++ b/src/test/java/kitchenpos/products/tobe/fixture/ProductFixture.java @@ -0,0 +1,26 @@ +package kitchenpos.products.tobe.fixture; + +import kitchenpos.products.infra.PurgomalumClient; +import kitchenpos.products.tobe.domain.Price; +import kitchenpos.products.tobe.domain.Product; +import kitchenpos.products.tobe.domain.ProductName; + +import java.math.BigDecimal; + +public class ProductFixture { + + public static Product createProductRequest(PurgomalumClient purgomalumClient) { + return createProductRequest("후라이드", 16_000L, purgomalumClient); + } + + public static Product createProductRequest(final String name, final long price, PurgomalumClient purgomalumClient) { + return createProductRequest(name, BigDecimal.valueOf(price), purgomalumClient); + } + + public static Product createProductRequest(final String name, final BigDecimal price, PurgomalumClient purgomalumClient) { + final ProductName productName = new ProductName(name, purgomalumClient); + final Price productPrice = new Price(price); + final Product product = new Product(productName, productPrice); + return product; + } +}