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 @@ -57,6 +57,7 @@ public enum SuccessMessage {
GET_RECEIPT_ITEM_SUCCESS("입고 아이템 조회 성공"),
UPDATE_RECEIPT_ITEM_SUCCESS("입고 아이템 수정 성공"),
DELETE_RECEIPT_ITEM_SUCCESS("입고 아이템 삭제 성공"),
CONFIRM_RECEIPT_SUCCESS("입고 확정 성공"),
;

private final String message;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ public enum ErrorCode {
RECEIPT_ORDER_MISMATCH(HttpStatus.FORBIDDEN, "해당 발주의 입고가 아닙니다."),
RECEIPT_ITEM_NOT_FOUND(HttpStatus.NOT_FOUND, "입고 아이템을 찾을 수 없습니다."),
RECEIPT_ITEM_ACCESS_DENIED(HttpStatus.FORBIDDEN, "입고 아이템에 접근할 수 없습니다."),
RECEIPT_ALREADY_CANCELED(HttpStatus.BAD_REQUEST, "이미 취소된 입고입니다."),
RECEIPT_ALREADY_CONFIRMED(HttpStatus.BAD_REQUEST, "이미 확정된 입고입니다."),
;

private final HttpStatus httpStatus;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.almang.inventory.receipt.domain.ReceiptStatus;
import com.almang.inventory.receipt.dto.request.UpdateReceiptItemRequest;
import com.almang.inventory.receipt.dto.request.UpdateReceiptRequest;
import com.almang.inventory.receipt.dto.response.ConfirmReceiptResponse;
import com.almang.inventory.receipt.dto.response.DeleteReceiptItemResponse;
import com.almang.inventory.receipt.dto.response.DeleteReceiptResponse;
import com.almang.inventory.receipt.dto.response.ReceiptItemResponse;
Expand Down Expand Up @@ -137,6 +138,21 @@ public ResponseEntity<ApiResponse<DeleteReceiptResponse>> deleteReceipt(
);
}

@PatchMapping ("/{receiptId}/confirm")
@Operation(summary = "입고 확정", description = "입고를 확정합니다.")
public ResponseEntity<ApiResponse<ConfirmReceiptResponse>> confirmReceipt(
@PathVariable Long receiptId,
@AuthenticationPrincipal CustomUserPrincipal userPrincipal
) {
Long userId = userPrincipal.getId();
log.info("[ReceiptController] 입고 확정 요청 - userId: {}, receiptId: {}", userId, receiptId);
ConfirmReceiptResponse response = receiptService.confirmReceipt(receiptId, userId);

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

@GetMapping("/receipt/{receiptItemId}")
@Operation(summary = "입고 아이템 조회", description = "입고 아이템을 조회합니다.")
public ResponseEntity<ApiResponse<ReceiptItemResponse>> getReceiptItem(
Expand Down
16 changes: 16 additions & 0 deletions src/main/java/com/almang/inventory/receipt/domain/Receipt.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.almang.inventory.receipt.domain;

import com.almang.inventory.global.entity.BaseTimeEntity;
import com.almang.inventory.global.exception.BaseException;
import com.almang.inventory.global.exception.ErrorCode;
import com.almang.inventory.order.domain.Order;
import com.almang.inventory.store.domain.Store;
import jakarta.persistence.*;
Expand All @@ -9,6 +11,7 @@
import java.util.ArrayList;
import java.util.List;
import lombok.*;
import org.springframework.security.core.parameters.P;

@Entity
@Table(name = "receipts")
Expand Down Expand Up @@ -80,7 +83,20 @@ public void update(
}

public void deactivate() {
if (this.status == ReceiptStatus.CANCELED) {
throw new BaseException(ErrorCode.RECEIPT_ALREADY_CANCELED);
}
this.activated = false;
this.status = ReceiptStatus.CANCELED;
}

public void confirm() {
if (!this.activated || this.status == ReceiptStatus.CANCELED) {
throw new BaseException(ErrorCode.RECEIPT_ALREADY_CANCELED);
}
if (this.status == ReceiptStatus.CONFIRMED) {
throw new BaseException(ErrorCode.RECEIPT_ALREADY_CONFIRMED);
}
this.status = ReceiptStatus.CONFIRMED;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.almang.inventory.receipt.dto.response;

public record ConfirmReceiptResponse(
boolean success
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import com.almang.inventory.receipt.domain.ReceiptStatus;
import com.almang.inventory.receipt.dto.request.UpdateReceiptItemRequest;
import com.almang.inventory.receipt.dto.request.UpdateReceiptRequest;
import com.almang.inventory.receipt.dto.response.ConfirmReceiptResponse;
import com.almang.inventory.receipt.dto.response.DeleteReceiptItemResponse;
import com.almang.inventory.receipt.dto.response.DeleteReceiptResponse;
import com.almang.inventory.receipt.dto.response.ReceiptItemResponse;
Expand Down Expand Up @@ -146,6 +147,19 @@ public DeleteReceiptResponse deleteReceipt(Long receiptId, Long userId) {
return new DeleteReceiptResponse(true);
}

@Transactional
public ConfirmReceiptResponse confirmReceipt(Long receiptId, Long userId) {
User user = findUserById(userId);
Store store = user.getStore();

log.info("[ReceiptService] 입고 확정 요청 - userId: {}, storeId: {}", userId, store.getId());
Receipt receipt = findReceiptByIdAndValidateAccess(receiptId, store);
receipt.confirm();

log.info("[ReceiptService] 입고 확정 성공 - receiptId: {}", receipt.getId());
return new ConfirmReceiptResponse(true);
}
Comment on lines +150 to +161
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

입고 확정 전 비즈니스 검증 로직이 부족합니다.

현재 구현은 사용자 권한만 확인하고 입고의 상태는 검증하지 않습니다. 다음 사항들을 추가 검증해야 합니다:

  1. activated 플래그 확인: 삭제된(비활성화된) 입고를 확정할 수 없어야 합니다
  2. 현재 상태 확인: PENDING 상태의 입고만 확정 가능해야 합니다
  3. 데이터 완전성: 입고 아이템이 존재하는지 확인이 필요할 수 있습니다

개선 제안:

 @Transactional
 public ConfirmReceiptResponse confirmReceipt(Long receiptId, Long userId) {
     User user = findUserById(userId);
     Store store = user.getStore();

     log.info("[ReceiptService] 입고 확정 요청 - userId: {}, storeId: {}", userId, store.getId());
     Receipt receipt = findReceiptByIdAndValidateAccess(receiptId, store);
+    
+    if (!receipt.isActivated()) {
+        throw new BaseException(ErrorCode.RECEIPT_ALREADY_DELETED);
+    }
+    if (receipt.getStatus() == ReceiptStatus.CONFIRMED) {
+        throw new BaseException(ErrorCode.RECEIPT_ALREADY_CONFIRMED);
+    }
+    if (receipt.getStatus() == ReceiptStatus.CANCELED) {
+        throw new BaseException(ErrorCode.RECEIPT_ALREADY_CANCELED);
+    }
+    
     receipt.confirm();

     log.info("[ReceiptService] 입고 확정 성공 - receiptId: {}", receipt.getId());
     return new ConfirmReceiptResponse(true);
 }

참고: ErrorCode enum에 해당 에러 코드들을 추가해야 합니다. 비즈니스 검증은 도메인 계층에서 할 수도 있고 서비스 계층에서 할 수도 있습니다만, 일관성을 위해 기존 패턴을 따르세요.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/main/java/com/almang/inventory/receipt/service/ReceiptService.java around
lines 150-161, add business validation before calling receipt.confirm(): verify
receipt.isActivated() (reject if false), verify receipt.getStatus() ==
ReceiptStatus.PENDING (reject otherwise), and verify receipt.getItems() is not
null/empty (reject if empty). For each failure throw the existing application
exception with an appropriate ErrorCode (add ErrorCode entries if missing, e.g.,
RECEIPT_NOT_ACTIVE, RECEIPT_INVALID_STATUS, RECEIPT_NO_ITEMS) following the
project’s existing exception pattern; perform these checks in the service method
(or delegate to domain methods on Receipt if that matches existing conventions)
and only call receipt.confirm() after all validations pass.


@Transactional(readOnly = true)
public ReceiptItemResponse getReceiptItem(Long receiptItemId, Long userId) {
User user = findUserById(userId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import com.almang.inventory.receipt.domain.ReceiptStatus;
import com.almang.inventory.receipt.dto.request.UpdateReceiptItemRequest;
import com.almang.inventory.receipt.dto.request.UpdateReceiptRequest;
import com.almang.inventory.receipt.dto.response.ConfirmReceiptResponse;
import com.almang.inventory.receipt.dto.response.DeleteReceiptItemResponse;
import com.almang.inventory.receipt.dto.response.DeleteReceiptResponse;
import com.almang.inventory.receipt.dto.response.ReceiptItemResponse;
Expand Down Expand Up @@ -1135,4 +1136,78 @@ private UsernamePasswordAuthenticationToken auth() {
.andExpect(jsonPath("$.message").value(ErrorCode.RECEIPT_ITEM_ACCESS_DENIED.getMessage()))
.andExpect(jsonPath("$.data").doesNotExist());
}

@Test
void 입고_확정에_성공한다() throws Exception {
// given
Long receiptId = 1L;

ConfirmReceiptResponse response = new ConfirmReceiptResponse(true);

when(receiptService.confirmReceipt(anyLong(), anyLong()))
.thenReturn(response);

// when & then
mockMvc.perform(patch("/api/v1/receipt/{receiptId}/confirm", receiptId)
.with(authentication(auth()))
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value(200))
.andExpect(jsonPath("$.message").value(SuccessMessage.CONFIRM_RECEIPT_SUCCESS.getMessage()))
.andExpect(jsonPath("$.data.success").value(true));
}

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

when(receiptService.confirmReceipt(anyLong(), anyLong()))
.thenThrow(new BaseException(ErrorCode.USER_NOT_FOUND));

// when & then
mockMvc.perform(patch("/api/v1/receipt/{receiptId}/confirm", receiptId)
.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());
}

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

when(receiptService.confirmReceipt(anyLong(), anyLong()))
.thenThrow(new BaseException(ErrorCode.RECEIPT_NOT_FOUND));

// when & then
mockMvc.perform(patch("/api/v1/receipt/{receiptId}/confirm", receiptId)
.with(authentication(auth()))
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.status").value(ErrorCode.RECEIPT_NOT_FOUND.getHttpStatus().value()))
.andExpect(jsonPath("$.message").value(ErrorCode.RECEIPT_NOT_FOUND.getMessage()))
.andExpect(jsonPath("$.data").doesNotExist());
}

@Test
void 입고_확정시_다른_상점의_입고면_접근_거부_예외가_발생한다() throws Exception {
// given
Long receiptId = 1L;

when(receiptService.confirmReceipt(anyLong(), anyLong()))
.thenThrow(new BaseException(ErrorCode.RECEIPT_ACCESS_DENIED));

// when & then
mockMvc.perform(patch("/api/v1/receipt/{receiptId}/confirm", receiptId)
.with(authentication(auth()))
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.status").value(ErrorCode.RECEIPT_ACCESS_DENIED.getHttpStatus().value()))
.andExpect(jsonPath("$.message").value(ErrorCode.RECEIPT_ACCESS_DENIED.getMessage()))
.andExpect(jsonPath("$.data").doesNotExist());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import com.almang.inventory.receipt.domain.ReceiptStatus;
import com.almang.inventory.receipt.dto.request.UpdateReceiptItemRequest;
import com.almang.inventory.receipt.dto.request.UpdateReceiptRequest;
import com.almang.inventory.receipt.dto.response.ConfirmReceiptResponse;
import com.almang.inventory.receipt.dto.response.DeleteReceiptItemResponse;
import com.almang.inventory.receipt.dto.response.ReceiptItemResponse;
import com.almang.inventory.receipt.dto.response.ReceiptResponse;
Expand Down Expand Up @@ -1397,4 +1398,93 @@ private Order newOrderWithItems(Store store, Vendor vendor) {
.isInstanceOf(BaseException.class)
.hasMessageContaining(ErrorCode.RECEIPT_ITEM_ACCESS_DENIED.getMessage());
}

@Test
void 입고_확정에_성공한다() {
// given
Store store = newStore("확정상점");
User user = newUser(store, "confirmUser");
Vendor vendor = newVendor(store, "확정부발주처");

Order order = newOrderWithItems(store, vendor);

Receipt receipt = Receipt.builder()
.store(store)
.order(order)
.receiptDate(LocalDate.now())
.totalBoxCount(1)
.totalWeightG(null)
.status(ReceiptStatus.PENDING)
.activated(true)
.build();

Receipt saved = receiptRepository.save(receipt);

// when
ConfirmReceiptResponse response = receiptService.confirmReceipt(saved.getId(), user.getId());

// then
assertThat(response).isNotNull();
assertThat(response.success()).isTrue();

Receipt updated = receiptRepository.findById(saved.getId())
.orElseThrow();

assertThat(updated.getStatus()).isEqualTo(ReceiptStatus.CONFIRMED);
assertThat(updated.isActivated()).isTrue();
}

@Test
void 입고_확정시_사용자가_존재하지_않으면_예외가_발생한다() {
// given
Long notExistUserId = 9999L;
Long anyReceiptId = 1L;

// when & then
assertThatThrownBy(() -> receiptService.confirmReceipt(anyReceiptId, notExistUserId))
.isInstanceOf(BaseException.class)
.hasMessageContaining(ErrorCode.USER_NOT_FOUND.getMessage());
}

@Test
void 입고_확정시_입고가_존재하지_않으면_예외가_발생한다() {
// given
Store store = newStore("확정_입고없음상점");
User user = newUser(store, "noReceiptConfirmUser");
Long notExistReceiptId = 9999L;

// when & then
assertThatThrownBy(() -> receiptService.confirmReceipt(notExistReceiptId, user.getId()))
.isInstanceOf(BaseException.class)
.hasMessageContaining(ErrorCode.RECEIPT_NOT_FOUND.getMessage());
}

@Test
void 입고_확정시_다른_상점의_입고면_접근_거부_예외가_발생한다() {
// given
Store store1 = newStore("상점1");
Store store2 = newStore("상점2");

User user1 = newUser(store1, "user1");
Vendor vendor2 = newVendor(store2, "발주처2");

Order order2 = newOrderWithItems(store2, vendor2);

Receipt receiptOfStore2 = Receipt.builder()
.store(store2)
.order(order2)
.receiptDate(LocalDate.now())
.totalBoxCount(1)
.totalWeightG(null)
.status(ReceiptStatus.PENDING)
.activated(true)
.build();

Receipt savedReceipt2 = receiptRepository.save(receiptOfStore2);

// when & then
assertThatThrownBy(() -> receiptService.confirmReceipt(savedReceipt2.getId(), user1.getId()))
.isInstanceOf(BaseException.class)
.hasMessageContaining(ErrorCode.RECEIPT_ACCESS_DENIED.getMessage());
}
}