Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ public enum SuccessMessage {
UPDATE_RECEIPT_ITEM_SUCCESS("입고 아이템 수정 성공"),
DELETE_RECEIPT_ITEM_SUCCESS("입고 아이템 삭제 성공"),
CONFIRM_RECEIPT_SUCCESS("입고 확정 성공"),

// INVENTORY
UPDATE_INVENTORY_SUCCESS("재고 수동 수정 성공"),
;

private final String message;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ public enum ErrorCode {
INCOMING_STOCK_NOT_ENOUGH(HttpStatus.BAD_REQUEST, "입고 예정 수량이 부족합니다."),
WAREHOUSE_STOCK_NOT_ENOUGH(HttpStatus.BAD_REQUEST, "창고 재고가 부족합니다."),
DISPLAY_STOCK_NOT_ENOUGH(HttpStatus.BAD_REQUEST, "매대 재고가 부족합니다."),
INVENTORY_ACCESS_DENIED(HttpStatus.FORBIDDEN, "해당 상점의 재고가 아닙니다."),
INVENTORY_PRODUCT_MISMATCH(HttpStatus.BAD_REQUEST, "요청한 상품 정보와 재고의 상품 정보가 일치하지 않습니다."),
;

private final HttpStatus httpStatus;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.almang.inventory.inventory.controller;

import com.almang.inventory.global.api.ApiResponse;
import com.almang.inventory.global.api.SuccessMessage;
import com.almang.inventory.global.security.principal.CustomUserPrincipal;
import com.almang.inventory.inventory.dto.request.UpdateInventoryRequest;
import com.almang.inventory.inventory.dto.response.InventoryResponse;
import com.almang.inventory.inventory.service.InventoryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@RequestMapping("/api/v1/inventory")
@RequiredArgsConstructor
@Tag(name = "Inventory", description = "재고 관련 API")
public class InventoryController {

private final InventoryService inventoryService;

@PatchMapping("/{inventoryId}")
@Operation(summary = "재고 수동 수정", description = "재고를 수정하고 수정된 재고 정보를 반환합니다.")
public ResponseEntity<ApiResponse<InventoryResponse>> updateInventory(
@PathVariable Long inventoryId,
@Valid @RequestBody UpdateInventoryRequest request,
@AuthenticationPrincipal CustomUserPrincipal userPrincipal
) {
Long userId = userPrincipal.getId();
log.info("[InventoryController] 재고 수동 수정 요청 - userId: {}", userId);
InventoryResponse response = inventoryService.updateInventory(inventoryId, request, userId);

return ResponseEntity.ok(
ApiResponse.success(SuccessMessage.UPDATE_INVENTORY_SUCCESS.getMessage(), response)
);
}
}
24 changes: 24 additions & 0 deletions src/main/java/com/almang/inventory/inventory/domain/Inventory.java
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,28 @@ public void decreaseDisplay(BigDecimal quantity) {
}
this.displayStock = this.displayStock.subtract(quantity);
}

public void updateManually(
BigDecimal displayStock,
BigDecimal warehouseStock,
BigDecimal outgoingReserved,
BigDecimal incomingReserved,
BigDecimal reorderTriggerPoint
) {
if (displayStock != null) {
this.displayStock = displayStock;
}
if (warehouseStock != null) {
this.warehouseStock = warehouseStock;
}
if (outgoingReserved != null) {
this.outgoingReserved = outgoingReserved;
}
if (incomingReserved != null) {
this.incomingReserved = incomingReserved;
}
if (reorderTriggerPoint != null) {
this.reorderTriggerPoint = reorderTriggerPoint;
}
}
Comment on lines +92 to +114
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

재고 값에 대한 검증이 필요합니다.

현재 updateManually 메서드는 음수 재고 값에 대한 검증을 수행하지 않습니다. 재고 수량(displayStock, warehouseStock, outgoingReserved, incomingReserved)이 음수가 되면 데이터 무결성 문제가 발생할 수 있습니다.

또한 reorderTriggerPointStore 엔티티의 defaultCountCheckThreshold와 마찬가지로 0과 1 사이의 값이어야 하는데, 이에 대한 범위 검증이 없습니다.

서비스 계층에서 요청 DTO에 다음과 같은 검증 어노테이션을 추가하는 것을 권장합니다:

public record UpdateInventoryRequest(
        @NotNull Long productId,
        @DecimalMin(value = "0.0", message = "매대 재고는 0 이상이어야 합니다.")
        BigDecimal displayStock,
        @DecimalMin(value = "0.0", message = "창고 재고는 0 이상이어야 합니다.")
        BigDecimal warehouseStock,
        @DecimalMin(value = "0.0", message = "출고 예약은 0 이상이어야 합니다.")
        BigDecimal outgoingReserved,
        @DecimalMin(value = "0.0", message = "입고 예약은 0 이상이어야 합니다.")
        BigDecimal incomingReserved,
        @DecimalMin(value = "0.0", message = "재주문 기준점은 0 이상이어야 합니다.")
        @DecimalMax(value = "1.0", message = "재주문 기준점은 1 이하여야 합니다.")
        BigDecimal reorderTriggerPoint
) {}

참고: Jakarta Bean Validation 공식 문서

🤖 Prompt for AI Agents
In src/main/java/com/almang/inventory/inventory/domain/Inventory.java around
lines 92-114, updateManually currently accepts negative inventory values and
does not enforce that reorderTriggerPoint is between 0 and 1; add validation for
each non-null BigDecimal: for displayStock, warehouseStock, outgoingReserved,
incomingReserved ensure value.compareTo(BigDecimal.ZERO) >= 0 and throw a clear
IllegalArgumentException if negative; for reorderTriggerPoint ensure
value.compareTo(BigDecimal.ZERO) >= 0 and value.compareTo(BigDecimal.ONE) <= 0
and throw an IllegalArgumentException if out of range; also add or recommend
Bean Validation annotations on the service-layer UpdateInventoryRequest DTO
(e.g., @DecimalMin("0.0") and @DecimalMax("1.0") for reorderTriggerPoint) so
incoming requests are validated before reaching the domain.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.almang.inventory.inventory.dto.request;

import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import java.math.BigDecimal;

public record UpdateInventoryRequest(
@NotNull Long productId,
@PositiveOrZero BigDecimal displayStock,
@PositiveOrZero BigDecimal warehouseStock,
@PositiveOrZero BigDecimal outgoingReserved,
@PositiveOrZero BigDecimal incomingReserved,
@DecimalMin("0.0") @DecimalMax("1.0") BigDecimal reorderTriggerPoint
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.almang.inventory.inventory.dto.response;

import com.almang.inventory.inventory.domain.Inventory;
import java.math.BigDecimal;

public record InventoryResponse(
Long inventoryId,
Long productId,
BigDecimal displayStock,
BigDecimal warehouseStock,
BigDecimal outgoingReserved,
BigDecimal incomingReserved,
BigDecimal reorderTriggerPoint
) {
public static InventoryResponse from(Inventory inventory) {
return new InventoryResponse(
inventory.getId(),
inventory.getProduct().getId(),
inventory.getDisplayStock(),
inventory.getWarehouseStock(),
inventory.getOutgoingReserved(),
inventory.getIncomingReserved(),
inventory.getReorderTriggerPoint()
);
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
package com.almang.inventory.inventory.service;

import com.almang.inventory.global.context.UserContextProvider;
import com.almang.inventory.global.context.UserContextProvider.UserStoreContext;
import com.almang.inventory.global.exception.BaseException;
import com.almang.inventory.global.exception.ErrorCode;
import com.almang.inventory.inventory.domain.Inventory;
import com.almang.inventory.inventory.dto.request.UpdateInventoryRequest;
import com.almang.inventory.inventory.dto.response.InventoryResponse;
import com.almang.inventory.inventory.repository.InventoryRepository;
import com.almang.inventory.product.domain.Product;
import com.almang.inventory.store.domain.Store;
import java.math.BigDecimal;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -17,6 +22,7 @@
public class InventoryService {

private final InventoryRepository inventoryRepository;
private final UserContextProvider userContextProvider;

@Transactional
public void createInventory(Product product) {
Expand Down Expand Up @@ -76,6 +82,24 @@ public void cancelIncomingReservation(Product product, BigDecimal quantity) {
log.info("[InventoryService] 입고 취소로 입고 예정 수량 감소 성공 - inventoryId: {}", inventory.getId());
}

@Transactional
public InventoryResponse updateInventory(Long inventoryId, UpdateInventoryRequest request, Long userId) {
UserStoreContext context = userContextProvider.findUserAndStore(userId);
Store store = context.store();

log.info("[InventoryService] 재고 수동 수정 요청 - userId: {}, storeId: {}", userId, store.getId());
Inventory inventory = findInventoryByIdAndValidateAccess(inventoryId, store);
validateProductMatch(inventory, request.productId());

inventory.updateManually(
request.displayStock(), request.warehouseStock(), request.outgoingReserved(),
request.incomingReserved(), request.reorderTriggerPoint()
);

log.info("[InventoryService] 재고 수동 수정 성공 - inventoryId: {}", inventory.getId());
return InventoryResponse.from(inventory);
}

private Inventory toInventoryEntity(Product product) {
return Inventory.builder()
.product(product)
Expand All @@ -91,4 +115,20 @@ private Inventory findInventoryByProductId(Long productId) {
return inventoryRepository.findByProduct_Id(productId)
.orElseThrow(() -> new BaseException(ErrorCode.INVENTORY_NOT_FOUND));
}

private Inventory findInventoryByIdAndValidateAccess(Long inventoryId, Store store) {
Inventory inventory = inventoryRepository.findById(inventoryId)
.orElseThrow(() -> new BaseException(ErrorCode.INVENTORY_NOT_FOUND));

if (!inventory.getProduct().getStore().getId().equals(store.getId())) {
throw new BaseException(ErrorCode.INVENTORY_ACCESS_DENIED);
}
return inventory;
}

private void validateProductMatch(Inventory inventory, Long productId) {
if (!inventory.getProduct().getId().equals(productId)) {
throw new BaseException(ErrorCode.INVENTORY_PRODUCT_MISMATCH);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package com.almang.inventory.inventory.controller;

import static org.mockito.ArgumentMatchers.any;
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.patch;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import com.almang.inventory.global.api.SuccessMessage;
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.security.principal.CustomUserPrincipal;
import com.almang.inventory.inventory.dto.request.UpdateInventoryRequest;
import com.almang.inventory.inventory.dto.response.InventoryResponse;
import com.almang.inventory.inventory.service.InventoryService;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.math.BigDecimal;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

@WebMvcTest(InventoryController.class)
@Import(TestSecurityConfig.class)
@ActiveProfiles("test")
public class InventoryControllerTest {

@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;

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

private UsernamePasswordAuthenticationToken auth() {
CustomUserPrincipal principal =
new CustomUserPrincipal(1L, "inventory_admin", List.of());
return new UsernamePasswordAuthenticationToken(
principal, null, principal.getAuthorities()
);
}

@Test
void 재고_수동_수정에_성공한다() throws Exception {
// given
Long inventoryId = 1L;
Long productId = 10L;

UpdateInventoryRequest request = new UpdateInventoryRequest(
productId,
BigDecimal.valueOf(1.234),
BigDecimal.valueOf(10.000),
BigDecimal.valueOf(0.500),
BigDecimal.valueOf(3.000),
BigDecimal.valueOf(0.25)
);

InventoryResponse response = new InventoryResponse(
inventoryId,
productId,
BigDecimal.valueOf(1.234),
BigDecimal.valueOf(10.000),
BigDecimal.valueOf(0.500),
BigDecimal.valueOf(3.000),
BigDecimal.valueOf(0.25)
);

when(inventoryService.updateInventory(anyLong(), any(UpdateInventoryRequest.class), anyLong()))
.thenReturn(response);

// when & then
mockMvc.perform(patch("/api/v1/inventory/{inventoryId}", inventoryId)
.with(authentication(auth()))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value(200))
.andExpect(jsonPath("$.message")
.value(SuccessMessage.UPDATE_INVENTORY_SUCCESS.getMessage()))
.andExpect(jsonPath("$.data.inventoryId").value(inventoryId))
.andExpect(jsonPath("$.data.productId").value(productId))
.andExpect(jsonPath("$.data.displayStock").value(1.234))
.andExpect(jsonPath("$.data.warehouseStock").value(10.000))
.andExpect(jsonPath("$.data.incomingReserved").value(3.000))
.andExpect(jsonPath("$.data.reorderTriggerPoint").value(0.25));
}

@Test
void 재고_수동_수정시_사용자가_존재하지_않으면_예외가_발생한다() throws Exception {
// given
Long inventoryId = 1L;

UpdateInventoryRequest request = new UpdateInventoryRequest(
10L,
BigDecimal.ZERO,
BigDecimal.ZERO,
BigDecimal.ZERO,
BigDecimal.ZERO,
BigDecimal.valueOf(0.2)
);

when(inventoryService.updateInventory(anyLong(), any(UpdateInventoryRequest.class), anyLong()))
.thenThrow(new BaseException(ErrorCode.USER_NOT_FOUND));

// when & then
mockMvc.perform(patch("/api/v1/inventory/{inventoryId}", inventoryId)
.with(authentication(auth()))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.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());
}

@Test
void 재고_수동_수정시_재고가_존재하지_않으면_예외가_발생한다() throws Exception {
// given
Long inventoryId = 9999L;

UpdateInventoryRequest request = new UpdateInventoryRequest(
10L,
BigDecimal.ZERO,
BigDecimal.ZERO,
BigDecimal.ZERO,
BigDecimal.ZERO,
BigDecimal.valueOf(0.2)
);

when(inventoryService.updateInventory(anyLong(), any(UpdateInventoryRequest.class), anyLong()))
.thenThrow(new BaseException(ErrorCode.INVENTORY_NOT_FOUND));

// when & then
mockMvc.perform(patch("/api/v1/inventory/{inventoryId}", inventoryId)
.with(authentication(auth()))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.status").value(ErrorCode.INVENTORY_NOT_FOUND.getHttpStatus().value()))
.andExpect(jsonPath("$.message").value(ErrorCode.INVENTORY_NOT_FOUND.getMessage()))
.andExpect(jsonPath("$.data").doesNotExist());
}

@Test
void 재고_수동_수정_요청값_검증에_실패하면_예외가_발생한다() throws Exception {
// given
Long inventoryId = 1L;

UpdateInventoryRequest invalidRequest = new UpdateInventoryRequest(
null,
BigDecimal.valueOf(-1.0),
BigDecimal.ZERO,
BigDecimal.ZERO,
BigDecimal.ZERO,
BigDecimal.valueOf(1.5)
);

// when & then
mockMvc.perform(patch("/api/v1/inventory/{inventoryId}", inventoryId)
.with(authentication(auth()))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(invalidRequest)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status")
.value(ErrorCode.INVALID_INPUT_VALUE.getHttpStatus().value()))
.andExpect(jsonPath("$.message")
.value(ErrorCode.INVALID_INPUT_VALUE.getMessage()))
.andExpect(jsonPath("$.data").doesNotExist());
}
}
Loading