Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ public enum SuccessMessage {
GET_ORDER_ITEM_SUCCESS("발주 아이템 조회 성공"),
UPDATE_ORDER_ITEM_SUCCESS("발주 아이템 수정 성공"),
DELETE_ORDER_SUCCESS("발주 삭제 성공"),

// RECEIPT
CREATE_RECEIPT_FROM_ORDER_SUCCESS("발주 기반 입고 생성 성공"),
;

private final String message;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ public enum ErrorCode {
VENDOR_CHANGE_NOT_ALLOWED(HttpStatus.FORBIDDEN, "해당 발주를 변경할 수 없습니다."),
ORDER_ITEM_NOT_FOUND(HttpStatus.NOT_FOUND, "발주 상품을 찾을 수 없습니다."),
ORDER_ITEM_ACCESS_DENIED(HttpStatus.FORBIDDEN, "해당 발주의 발주 항목이 아닙니다."),

// RECEIPT
RECEIPT_CREATION_NOT_ALLOWED_FROM_ORDER(HttpStatus.BAD_REQUEST, "해당 발주 상태에서는 입고를 생성할 수 없습니다."),
;

private final HttpStatus httpStatus;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.almang.inventory.receipt.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.receipt.dto.response.ReceiptResponse;
import com.almang.inventory.receipt.service.ReceiptService;
import io.swagger.v3.oas.annotations.tags.Tag;
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.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@RequestMapping("/api/v1/receipt")
@RequiredArgsConstructor
@Tag(name = "Receipt", description = "입고 관련 API")
public class ReceiptController {

private final ReceiptService receiptService;

@PostMapping("/from-order/{orderId}")
public ResponseEntity<ApiResponse<ReceiptResponse>> createReceiptFromOrder(
@PathVariable Long orderId,
@AuthenticationPrincipal CustomUserPrincipal userPrincipal
) {
Long userId = userPrincipal.getId();
log.info("[ReceiptController] 발주 기반 입고 생성 요청 - userId: {}, orderId: {}", userId, orderId);
ReceiptResponse response = receiptService.createReceiptFromOrder(orderId, userId);

return ResponseEntity.ok(
ApiResponse.success(SuccessMessage.CREATE_RECEIPT_FROM_ORDER_SUCCESS.getMessage(), response)
);
}
Comment on lines +27 to +39
Copy link

@coderabbitai coderabbitai bot Nov 23, 2025

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

컨트롤러 구조가 깔끔하게 구현되었습니다!

RESTful 엔드포인트 설계, 인증 처리, 서비스 레이어로의 위임이 잘 구현되어 있습니다. 로깅도 적절히 추가되어 운영 시 추적이 용이합니다.

다만, Line 32에서 userPrincipal.getId()를 호출하기 전에 userPrincipal이 null이 아님을 보장하는지 확인이 필요합니다. Spring Security 설정에서 인증되지 않은 요청을 필터링한다면 문제없지만, 방어적 코딩 관점에서 null 체크를 고려할 수 있습니다.

다음 스크립트로 Spring Security 설정을 확인하여 /api/v1/receipt 경로가 인증을 요구하는지 검증하세요:


🏁 Script executed:

#!/bin/bash
# Spring Security 설정 파일에서 receipt 경로 인증 요구사항 확인
rg -n "receipt|/api/v1" --type=java -g "**/*Security*.java" -C 3

Length of output: 1883


이 리뷰 코멘트를 검증하기 위해 추가 정보를 수집하겠습니다.

스크립트 결과를 분석하니 중요한 발견이 있습니다:

SecurityConfig의 인증 설정 분석:

  • 명시적으로 인증이 필요한 경로: /api/v1/auth/logout only
  • 나머지 모든 요청: .anyRequest().permitAll() (라인 44)

이는 /api/v1/receipt 경로가 인증 보호를 받지 않음을 의미합니다.

Receipt 컨트롤러의 메서드별 보안 설정과 더 정확한 상황을 확인하기 위해 추가 검증이 필요합니다:

추가 검증을 진행했습니다. 중요한 발견이 있습니다:

보안 설정 검증 결과:

스크립트와 웹 검색 결과에 따르면:

  1. Receipt 엔드포인트는 인증 보호를 받지 않습니다

    • SecurityConfig.java 라인 43: /api/v1/auth/logout.authenticated() 지정
    • 라인 44: .anyRequest().permitAll() - 모든 다른 요청 허용
  2. @AuthenticationPrincipal의 동작
    @AuthenticationPrincipal은 사용자가 인증되면 principal을 주입하거나, 인증되지 않으면 null을 주입합니다.

  3. 현재 코드의 실제 문제

    • 엔드포인트가 인증을 요구하지 않으므로 userPrincipal이 null일 수 있습니다
    • 라인 32에서 userPrincipal.getId()를 호출하면 NullPointerException 발생 가능

내 관찰:
원본 리뷰는 null 체크 필요성을 올바르게 지적했지만, 엔드포인트가 이미 인증되어 있다고 잘못 가정했습니다. 실제로는 더 심각한 상황입니다 — 엔드포인트 자체가 인증을 요구해야 할 것으로 보입니다.

검증이 필요한 사항:
Receipt 생성 엔드포인트가 인증된 사용자만 접근해야 하는 기능인지 확인하겠습니다.


🏁 Script executed:

#!/bin/bash
# 1. Check if ReceiptController has @PreAuthorize or security annotations
rg -n "@PreAuthorize|@Secured|@RolesAllowed" src/main/java/com/almang/inventory/receipt/ --type=java -C 2

# 2. Search for any additional matchers in SecurityConfig that might protect receipt paths
rg -n "requestMatchers|antMatchers|receipt" src/main/java/com/almang/inventory/global/config/security/SecurityConfig.java --type=java -C 2

Length of output: 326


🏁 Script executed:

#!/bin/bash
# 3. Verify if Spring Security version supports authenticated @AuthenticationPrincipal handling
# Check pom.xml for Spring Security version
fd pom.xml | xargs grep -A 2 -B 2 "spring-security"

Length of output: 0


🏁 Script executed:

#!/bin/bash
# 다른 인증이 필요한 엔드포인트의 패턴 확인 (비교 목적)
rg -n "PostMapping|GetMapping|PutMapping|DeleteMapping" src/main/java/com/almang/inventory --type=java -A 2 | grep -E "(PostMapping|@RequestMapping|@PreAuthorize)" | head -20

Length of output: 2392


라인 32에서 NullPointerException 발생 위험이 있습니다.

검증 결과, Authentication이 null이면 @AuthenticationPrincipal도 null을 반환합니다. SecurityConfig에서 /api/v1/receipt 경로는 명시적 인증 보호가 없고 .anyRequest().permitAll()에 포함되어 있으므로, 인증되지 않은 사용자도 접근할 수 있습니다. 따라서 userPrincipal이 null일 수 있고, userPrincipal.getId() 호출 시 NullPointerException이 발생합니다.

개선 방안:

  1. 권장: 엔드포인트 보호 추가 (SecurityConfig에서)
.requestMatchers("/api/v1/receipt/**").authenticated()
  1. 또는: null 체크 추가 (방어적 코딩)
if (userPrincipal == null) {
    throw new UnauthorizedException("User must be authenticated");
}
Long userId = userPrincipal.getId();

Spring Security 공식 문서에서는 인증 요청인 엔드포인트는 먼저 인증을 강제하는 것이 권장됩니다. 현재 코드는 인증된 사용자의 ID를 필요로 하므로, 엔드포인트 수준에서 인증을 요구하는 것이 설계 의도에 부합합니다.

🤖 Prompt for AI Agents
In src/main/java/com/almang/inventory/receipt/controller/ReceiptController.java
around lines 27 to 39, calling userPrincipal.getId() can throw a
NullPointerException because @AuthenticationPrincipal may be null for
unauthenticated requests; fix by either (preferred) protecting the endpoint in
SecurityConfig so this controller is only reachable by authenticated users
(e.g., add requestMatchers("/api/v1/receipt/**").authenticated() or equivalent
for this route), or (if you must allow anonymous access) add a defensive null
check in this method that throws an UnauthorizedException (or returns an
appropriate 401 response) when userPrincipal is null before calling getId().

Copy link
Contributor Author

@JoonKyoLee JoonKyoLee Nov 23, 2025

Choose a reason for hiding this comment

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

개발 중이라 엔드포인트 보호를 하지 않은 상태입니다. 추후 변경 예정입니다.

Copy link

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

}
77 changes: 77 additions & 0 deletions src/main/java/com/almang/inventory/receipt/domain/Receipt.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package com.almang.inventory.receipt.domain;

import com.almang.inventory.global.entity.BaseTimeEntity;
import com.almang.inventory.order.domain.Order;
import com.almang.inventory.store.domain.Store;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import lombok.*;

@Entity
@Table(name = "receipts")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Receipt extends BaseTimeEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "receipt_id")
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "store_id", nullable = false)
private Store store;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id", nullable = false)
private Order order;

@Column(name = "receipt_date", nullable = false)
private LocalDate receiptDate;

@Column(name = "total_box_count", nullable = false)
private Integer totalBoxCount;

@Column(name = "total_weight_g", precision = 8, scale = 3)
private BigDecimal totalWeightG;

@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private ReceiptStatus status;

@Column(name = "is_activate", nullable = false)
private boolean activated;

@OneToMany(mappedBy = "receipt", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<ReceiptItem> items = new ArrayList<>();

public void addItem(ReceiptItem item) {
items.add(item);
item.setReceipt(this);
}

public void updateTotalBoxCount(Integer totalBoxCount) {
if (totalBoxCount != null) {
this.totalBoxCount = totalBoxCount;
}
}

public void updateTotalWeightG(BigDecimal totalWeightG) {
this.totalWeightG = totalWeightG;
}

public void updateReceiptStatus(ReceiptStatus status) {
this.status = status;
}

public void deactivate() {
this.activated = false;
this.status = ReceiptStatus.CANCELED;
}
}
92 changes: 92 additions & 0 deletions src/main/java/com/almang/inventory/receipt/domain/ReceiptItem.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package com.almang.inventory.receipt.domain;

import com.almang.inventory.global.entity.BaseTimeEntity;
import com.almang.inventory.product.domain.Product;
import jakarta.persistence.*;
import java.math.BigDecimal;
import lombok.*;

@Entity
@Table(name = "receipt_items")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class ReceiptItem extends BaseTimeEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "receipt_item_id") // ERD 기준: PK 컬럼명 id
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "receipt_id", nullable = false)
private Receipt receipt;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id", nullable = false)
private Product product;

@Column(name = "box_count")
private Integer boxCount;

@Column(name = "measured_weight", precision = 8, scale = 3)
private BigDecimal measuredWeight;

@Column(name = "expected_quantity", precision = 8, scale = 3)
private BigDecimal expectedQuantity;

@Column(name = "actual_quantity")
private Integer actualQuantity;

@Column(name = "unit_price")
private Integer unitPrice;

@Column(name = "amount")
private Integer amount;

@Column(name = "error_rate", precision = 6, scale = 3)
private BigDecimal errorRate;

@Column(name = "note", columnDefinition = "TEXT")
private String note;

public void setReceipt(Receipt receipt) {
this.receipt = receipt;
}

public void update(
Integer boxCount,
BigDecimal measuredWeight,
BigDecimal expectedQuantity,
Integer actualQuantity,
Integer unitPrice,
BigDecimal errorRate,
String note
) {
if (boxCount != null) {
this.boxCount = boxCount;
}
if (measuredWeight != null) {
this.measuredWeight = measuredWeight;
}
if (expectedQuantity != null) {
this.expectedQuantity = expectedQuantity;
}
if (actualQuantity != null) {
this.actualQuantity = actualQuantity;
}
if (unitPrice != null) {
this.unitPrice = unitPrice;
}
if (errorRate != null) {
this.errorRate = errorRate;
}
if (note != null) {
this.note = note;
}
if (this.actualQuantity != null && this.unitPrice != null) {
this.amount = this.actualQuantity * this.unitPrice;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.almang.inventory.receipt.domain;

public enum ReceiptStatus {
PENDING,
CONFIRMED,
CANCELED
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.almang.inventory.receipt.dto.response;

import com.almang.inventory.receipt.domain.ReceiptItem;
import java.math.BigDecimal;

public record ReceiptItemResponse(
Long receiptItemId,
Long receiptId,
Long productId,
Integer boxCount,
BigDecimal measuredWeight,
BigDecimal expectedQuantity,
Integer actualQuantity,
Integer amount,
BigDecimal errorRate,
String note
) {
public static ReceiptItemResponse from(ReceiptItem receiptItem) {
return new ReceiptItemResponse(
receiptItem.getId(),
receiptItem.getReceipt().getId(),
receiptItem.getProduct().getId(),
receiptItem.getBoxCount(),
receiptItem.getMeasuredWeight(),
receiptItem.getExpectedQuantity(),
receiptItem.getActualQuantity(),
receiptItem.getAmount(),
receiptItem.getErrorRate(),
receiptItem.getNote()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.almang.inventory.receipt.dto.response;

import com.almang.inventory.receipt.domain.Receipt;
import com.almang.inventory.receipt.domain.ReceiptStatus;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;

public record ReceiptResponse(
Long receiptId,
Long storeId,
Long orderId,
LocalDate receiptDate,
Integer totalBoxCount,
BigDecimal totalWeightG,
ReceiptStatus status,
boolean activated,
List<ReceiptItemResponse> receiptItems
) {
public static ReceiptResponse from(Receipt receipt) {
return new ReceiptResponse(
receipt.getId(),
receipt.getStore().getId(),
receipt.getOrder().getId(),
receipt.getReceiptDate(),
receipt.getTotalBoxCount(),
receipt.getTotalWeightG(),
receipt.getStatus(),
receipt.isActivated(),
receipt.getItems().stream()
.map(ReceiptItemResponse::from)
.toList()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.almang.inventory.receipt.repository;

import com.almang.inventory.receipt.domain.Receipt;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ReceiptRepository extends JpaRepository<Receipt, Long> {
}
Loading