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
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public enum SuccessMessage {
UPDATE_PRODUCT_SUCCESS("품목 수정 성공"),
GET_PRODUCT_DETAIL_SUCCESS("품목 상세 조회 성공"),
GET_PRODUCT_LIST_SUCCESS("품목 목록 조회 성공"),
DELETE_PRODUCT_SUCCESS("품목 삭제 성공"),

// VENDOR
CREATE_VENDOR_SUCCESS("발주처 등록 성공"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.almang.inventory.global.security.principal.CustomUserPrincipal;
import com.almang.inventory.product.dto.request.CreateProductRequest;
import com.almang.inventory.product.dto.request.UpdateProductRequest;
import com.almang.inventory.product.dto.response.DeleteProductResponse;
import com.almang.inventory.product.dto.response.ProductResponse;
import com.almang.inventory.product.service.ProductService;
import io.swagger.v3.oas.annotations.Operation;
Expand All @@ -15,6 +16,7 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
Expand Down Expand Up @@ -79,6 +81,21 @@ public ResponseEntity<ApiResponse<ProductResponse>> getProductDetail(
);
}

@DeleteMapping("/{productId}")
@Operation(summary = "품목 삭제", description = "품목을 삭제합니다.")
public ResponseEntity<ApiResponse<DeleteProductResponse>> deleteProduct(
@PathVariable Long productId,
@AuthenticationPrincipal CustomUserPrincipal userPrincipal
) {
Long userId = userPrincipal.getId();
log.info("[ProductController] 품목 삭제 요청 - userId: {}, productId: {}", userId, productId);
DeleteProductResponse response = productService.deleteProduct(productId, userId);

return ResponseEntity.ok(
ApiResponse.success(SuccessMessage.DELETE_PRODUCT_SUCCESS.getMessage(), response)
);
}

@GetMapping
@Operation(summary = "품목 목록 조회", description = "품목 목록을 페이지네이션, 활성 여부, 이름 검색 조건과 함께 조회합니다.")
public ResponseEntity<ApiResponse<PageResponse<ProductResponse>>> getProductList(
Expand Down
12 changes: 12 additions & 0 deletions src/main/java/com/almang/inventory/product/domain/Product.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,20 @@
import com.almang.inventory.store.domain.Store;
import com.almang.inventory.vendor.domain.Vendor;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import lombok.*;
import java.math.BigDecimal;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.Where;

@Entity
@Table(name = "products")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@SQLDelete(sql = "UPDATE products SET deleted_at = NOW() WHERE product_id = ?")
@Where(clause = "deleted_at IS NULL")
public class Product extends BaseTimeEntity {

@Id
Expand Down Expand Up @@ -59,6 +64,9 @@ public class Product extends BaseTimeEntity {
@Column(name = "wholesale_price")
private int wholesalePrice;

@Column(name = "deleted_at")
private LocalDateTime deletedAt;

public void updateVendor(Vendor vendor) {
if (!this.vendor.getId().equals(vendor.getId())) {
this.vendor = vendor;
Expand Down Expand Up @@ -106,4 +114,8 @@ public void updateActivation(Boolean activated) {
this.activated = activated;
}
}

public void delete() {
this.deletedAt = LocalDateTime.now();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.almang.inventory.product.dto.response;

public record DeleteProductResponse(
boolean success
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@
import com.almang.inventory.product.domain.Product;
import com.almang.inventory.product.dto.request.CreateProductRequest;
import com.almang.inventory.product.dto.request.UpdateProductRequest;
import com.almang.inventory.product.dto.response.DeleteProductResponse;
import com.almang.inventory.product.dto.response.ProductResponse;
import com.almang.inventory.product.repository.ProductRepository;
import com.almang.inventory.store.domain.Store;
import com.almang.inventory.user.domain.User;
import com.almang.inventory.user.repository.UserRepository;
import com.almang.inventory.vendor.domain.Vendor;
import com.almang.inventory.vendor.repository.VendorRepository;
import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -69,6 +69,20 @@ public ProductResponse updateProduct(Long productId, UpdateProductRequest reques
return ProductResponse.from(product);
}

@Transactional
public DeleteProductResponse deleteProduct(Long productId, Long userId) {
UserStoreContext context = userContextProvider.findUserAndStore(userId);
User user = context.user();
Product product = findProductById(productId);
validateStoreAccess(product, user);

log.info("[ProductService] 품목 삭제 요청 - userId: {}, productId: {}", user.getId(), product.getId());
product.delete();

log.info("[ProductService] 품목 삭제 성공 - productId: {}", product.getId());
return new DeleteProductResponse(true);
}

@Transactional(readOnly = true)
public ProductResponse getProductDetail(Long productId, Long userId) {
UserStoreContext context = userContextProvider.findUserAndStore(userId);
Expand Down Expand Up @@ -112,6 +126,7 @@ private Product toEntity(CreateProductRequest request, User user) {
.costPrice(request.costPrice())
.retailPrice(request.retailPrice())
.wholesalePrice(request.wholesalePrice())
.deletedAt(null)
.build();
}

Expand All @@ -122,13 +137,17 @@ private Vendor findVendorByIdAndValidateAccess(Long vendorId, User user) {
if (!vendor.getStore().getId().equals(user.getStore().getId())) {
throw new BaseException(ErrorCode.VENDOR_ACCESS_DENIED);
}

return vendor;
}

private Product findProductById(Long id) {
return productRepository.findById(id)
Product product = productRepository.findById(id)
.orElseThrow(() -> new BaseException(ErrorCode.PRODUCT_NOT_FOUND));

if (product.getDeletedAt() != null) {
throw new BaseException(ErrorCode.PRODUCT_NOT_FOUND);
}
return product;
}

private void validateStoreAccess(Product product, User user) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import com.almang.inventory.global.api.SuccessMessage;
import com.almang.inventory.global.exception.ErrorCode;
import com.almang.inventory.global.config.TestSecurityConfig;
import com.almang.inventory.global.monitoring.DiscordErrorNotifier;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
Expand All @@ -33,6 +34,7 @@ class AdminControllerTest {

@MockitoBean private AdminService adminService;
@MockitoBean private JpaMetamodelMappingContext jpaMetamodelMappingContext;
@MockitoBean private DiscordErrorNotifier discordErrorNotifier;

@Test
void 상점_생성에_성공한다() throws Exception {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import com.almang.inventory.global.config.TestSecurityConfig;
import com.almang.inventory.global.exception.BaseException;
import com.almang.inventory.global.exception.ErrorCode;
import com.almang.inventory.global.monitoring.DiscordErrorNotifier;
import com.almang.inventory.global.security.principal.CustomUserPrincipal;
import com.almang.inventory.inventory.domain.InventoryMoveDirection;
import com.almang.inventory.inventory.dto.request.MoveInventoryRequest;
Expand Down Expand Up @@ -48,6 +49,7 @@ public class InventoryControllerTest {

@MockitoBean private InventoryService inventoryService;
@MockitoBean private JpaMetamodelMappingContext jpaMetamodelMappingContext;
@MockitoBean private DiscordErrorNotifier discordErrorNotifier;

private UsernamePasswordAuthenticationToken auth() {
CustomUserPrincipal principal =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import com.almang.inventory.global.config.TestSecurityConfig;
import com.almang.inventory.global.exception.BaseException;
import com.almang.inventory.global.exception.ErrorCode;
import com.almang.inventory.global.monitoring.DiscordErrorNotifier;
import com.almang.inventory.global.security.principal.CustomUserPrincipal;
import com.almang.inventory.order.domain.OrderStatus;
import com.almang.inventory.order.dto.request.CreateOrderItemRequest;
Expand All @@ -28,6 +29,7 @@

import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;

import org.junit.jupiter.api.Test;
Expand All @@ -51,6 +53,7 @@ class OrderControllerTest {

@MockitoBean private OrderService orderService;
@MockitoBean private JpaMetamodelMappingContext jpaMetamodelMappingContext;
@MockitoBean private DiscordErrorNotifier discordErrorNotifier;

private UsernamePasswordAuthenticationToken auth() {
CustomUserPrincipal principal = new CustomUserPrincipal(1L, "store_admin", List.of());
Expand Down Expand Up @@ -78,6 +81,7 @@ private UsernamePasswordAuthenticationToken auth() {
100L,
10L,
10L,
LocalDateTime.now(),
"메시지입니다.",
OrderStatus.REQUEST,
3,
Expand Down Expand Up @@ -262,6 +266,7 @@ private UsernamePasswordAuthenticationToken auth() {
orderId,
10L,
10L,
LocalDateTime.now(),
"조회 메시지 입니다",
OrderStatus.REQUEST,
3,
Expand Down Expand Up @@ -329,6 +334,7 @@ private UsernamePasswordAuthenticationToken auth() {
1L,
10L,
10L,
LocalDateTime.now(),
"메시지1",
OrderStatus.REQUEST,
1,
Expand All @@ -344,6 +350,7 @@ private UsernamePasswordAuthenticationToken auth() {
2L,
10L,
10L,
LocalDateTime.now(),
"메시지2",
OrderStatus.REQUEST,
2,
Expand Down Expand Up @@ -447,6 +454,7 @@ private UsernamePasswordAuthenticationToken auth() {
orderId,
10L,
10L,
LocalDateTime.now(),
"수정된 메시지",
OrderStatus.IN_PRODUCTION,
5,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import com.almang.inventory.global.config.TestSecurityConfig;
import com.almang.inventory.global.exception.BaseException;
import com.almang.inventory.global.exception.ErrorCode;
import com.almang.inventory.global.monitoring.DiscordErrorNotifier;
import com.almang.inventory.global.security.principal.CustomUserPrincipal;
import com.almang.inventory.order.template.dto.request.UpdateOrderTemplateRequest;
import com.almang.inventory.order.template.dto.response.OrderTemplateResponse;
Expand Down Expand Up @@ -39,6 +40,7 @@ class OrderTemplateControllerTest {

@MockitoBean private OrderTemplateService orderTemplateService;
@MockitoBean private JpaMetamodelMappingContext jpaMetamodelMappingContext;
@MockitoBean private DiscordErrorNotifier discordErrorNotifier;

private UsernamePasswordAuthenticationToken auth() {
CustomUserPrincipal principal = new CustomUserPrincipal(1L, "store_admin", List.of());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
Expand All @@ -14,10 +15,12 @@
import com.almang.inventory.global.config.TestSecurityConfig;
import com.almang.inventory.global.exception.BaseException;
import com.almang.inventory.global.exception.ErrorCode;
import com.almang.inventory.global.monitoring.DiscordErrorNotifier;
import com.almang.inventory.global.security.principal.CustomUserPrincipal;
import com.almang.inventory.product.domain.ProductUnit;
import com.almang.inventory.product.dto.request.CreateProductRequest;
import com.almang.inventory.product.dto.request.UpdateProductRequest;
import com.almang.inventory.product.dto.response.DeleteProductResponse;
import com.almang.inventory.product.dto.response.ProductResponse;
import com.almang.inventory.product.service.ProductService;
import com.fasterxml.jackson.databind.ObjectMapper;
Expand All @@ -44,6 +47,7 @@ public class ProductControllerTest {

@MockitoBean private ProductService productService;
@MockitoBean private JpaMetamodelMappingContext jpaMetamodelMappingContext;
@MockitoBean private DiscordErrorNotifier discordErrorNotifier;

private UsernamePasswordAuthenticationToken auth() {
CustomUserPrincipal principal =
Expand Down Expand Up @@ -555,4 +559,77 @@ private UsernamePasswordAuthenticationToken auth() {
.value(ErrorCode.USER_NOT_FOUND.getMessage()))
.andExpect(jsonPath("$.data").doesNotExist());
}

@Test
void 품목_삭제에_성공한다() throws Exception {
// given
Long productId = 1L;
DeleteProductResponse response = new DeleteProductResponse(true);

when(productService.deleteProduct(anyLong(), anyLong()))
.thenReturn(response);

// when & then
mockMvc.perform(delete("/api/v1/product/{productId}", productId)
.with(authentication(auth()))
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value(200))
.andExpect(jsonPath("$.message").value(SuccessMessage.DELETE_PRODUCT_SUCCESS.getMessage()))
.andExpect(jsonPath("$.data.success").value(true));
}

@Test
void 품목_삭제_시_품목이_존재하지_않으면_예외가_발생한다() throws Exception {
// given
Long notExistProductId = 9999L;

when(productService.deleteProduct(anyLong(), anyLong()))
.thenThrow(new BaseException(ErrorCode.PRODUCT_NOT_FOUND));

// when & then
mockMvc.perform(delete("/api/v1/product/{productId}", notExistProductId)
.with(authentication(auth()))
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.status").value(ErrorCode.PRODUCT_NOT_FOUND.getHttpStatus().value()))
.andExpect(jsonPath("$.message").value(ErrorCode.PRODUCT_NOT_FOUND.getMessage()))
.andExpect(jsonPath("$.data").doesNotExist());
}

@Test
void 품목_삭제_시_다른_상점_품목이면_예외가_발생한다() throws Exception {
// given
Long productId = 1L;

when(productService.deleteProduct(anyLong(), anyLong()))
.thenThrow(new BaseException(ErrorCode.STORE_ACCESS_DENIED));

// when & then
mockMvc.perform(delete("/api/v1/product/{productId}", productId)
.with(authentication(auth()))
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.status").value(ErrorCode.STORE_ACCESS_DENIED.getHttpStatus().value()))
.andExpect(jsonPath("$.message").value(ErrorCode.STORE_ACCESS_DENIED.getMessage()))
.andExpect(jsonPath("$.data").doesNotExist());
}

@Test
void 품목_삭제_시_사용자가_존재하지_않으면_예외가_발생한다() throws Exception {
// given
Long productId = 1L;

when(productService.deleteProduct(anyLong(), anyLong()))
.thenThrow(new BaseException(ErrorCode.USER_NOT_FOUND));

// when & then
mockMvc.perform(delete("/api/v1/product/{productId}", productId)
.with(authentication(auth()))
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.status").value(ErrorCode.USER_NOT_FOUND.getHttpStatus().value()))
.andExpect(jsonPath("$.message").value(ErrorCode.USER_NOT_FOUND.getMessage()))
.andExpect(jsonPath("$.data").doesNotExist());
}
}
Loading