CoreERP 안전재고 로직 정리 / 현재고 합산 구조 개선 기록

최병현·2026년 3월 1일

coreerp project

목록 보기
27/44


1. 이번 단계의 핵심 목적

이번 단계의 목표는 재고 화면에서 수량 해석이 혼동되지 않도록 기준을 고정하고, API/화면 구조를 안정화하는 것이었다. 특히 창고별 데이터가 섞여 보이면서 “현재고/안전재고” 의미가 헷갈리는 문제가 있었고, 이를 다음 기준으로 정리했다.

  • 현재고 페이지: 품목 기준 합산(창고명 제외) - /api/stocks/current-summary
  • 안전재고 페이지: 창고별 상세 관리 - /api/stocks/safety
  • 안전재고는 “월 최소 필요 수량(정책 기준치)”로 정의

2. 안전재고 정책 정의

2.1 안전재고(safetyStock)

  • 의미: 월 최소 필요 수량(정책 기준치)
  • 저장 위치: Item.safetyStock (마스터 값)
  • 이번 단계에서는 안전재고를 동적으로 계산하지 않고 “운영 기준값”으로 유지

2.2 월소요량(monthlyUsage)

  • 최근 30일 출고/이동 출고의 합계
  • 동적 값(실제 소비량)

2.3 부족수량(shortageQty)

안전재고 화면에서 부족수량은 아래와 같이 해석한다.

  • shortageQty = max(safetyQty - availableQty, 0)

3. Backend 구현 (Spring Boot / Query + Aggregation)

이번 단계의 백엔드는 “조회용 Query API 강화”가 중심이다. Inventory는 스냅샷, StockTx는 이력(원장)으로 유지하고, 화면에서 필요한 계산 컬럼은 QueryService에서 조합한다.

3.1 현재고 합산 API 추가

현재고를 창고별 row가 아니라 “품목 기준 1 row”로 제공하기 위해 current-summary API를 추가했다.

  • Endpoint: GET /api/stocks/current-summary
  • 응답: InventoryCurrentSummaryRowResponse (warehouseCount 포함)
@GetMapping("/current-summary")
public Page<InventoryCurrentSummaryRowResponse> currentSummary(
        @RequestParam(required = false) String keyword,
        @RequestParam(required = false) String stockStatus,
        @RequestParam(required = false) String optionMode,
        @RequestParam(required = false) String from,
        @RequestParam(required = false) String to,
        @RequestParam(defaultValue = "itemName") String sortKey,
        @RequestParam(defaultValue = "asc") String sortOrder,
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size
) {
    return inventoryQueryService.currentSummaryPage(
            keyword, stockStatus, optionMode, from, to, sortKey, sortOrder, page, size
    );
}

3.2 Summary Projection (JPQL Group By)

Inventory를 품목 기준으로 합산하기 위해 Projection을 만들고 JPQL에서 sum/count/max를 사용했다.

@Query("""
    select new com.coreerp.stock.repository.projection.InventorySummaryBaseRow(
        it.itemId,
        it.itemCode,
        it.itemName,
        it.spec,
        it.unit,
        it.status,
        sum(inv.currentQty),
        sum(inv.reservedQty),
        it.safetyStock,
        count(distinct w.warehouseId),
        max(inv.lastInboundAt)
    )
    from Inventory inv
    join inv.warehouse w
    join inv.item it
    where (
          :kw is null
          or lower(it.itemCode) like concat('%', lower(:kw), '%')
          or lower(it.itemName) like concat('%', lower(:kw), '%')
          or lower(it.spec) like concat('%', lower(:kw), '%')
      )
    group by it.itemId, it.itemCode, it.itemName, it.spec, it.unit, it.status, it.safetyStock
    having (:fromDt is null or max(inv.lastInboundAt) >= :fromDt)
       and (:toDt is null or max(inv.lastInboundAt) < :toDt)
""")
Page<InventorySummaryBaseRow> searchSummaryPage(
        @Param("kw") String keyword,
        @Param("fromDt") LocalDateTime fromDt,
        @Param("toDt") LocalDateTime toDt,
        Pageable pageable
);

3.3 Usage Aggregation (Item 기준 집계)

기존에는 (warehouseId, itemId) 기준 집계를 사용했지만, summary 화면은 itemId 기준 합산 집계가 필요하므로 aggregateUsageByItem 쿼리를 추가했다.

@Query("""
select new com.coreerp.stock.dto.InventoryUsageAggRow(
    null,
    tx.item.itemId,
    sum(case when tx.txDate &gt;= :from7 then abs(tx.qtyDelta) else 0 end),
    sum(case when tx.txDate &gt;= :from30 then abs(tx.qtyDelta) else 0 end),
    sum(case when tx.txDate &gt;= :from365 then abs(tx.qtyDelta) else 0 end)
)
from StockTx tx
where tx.txType in :outTypes
  and tx.txDate &gt;= :from365
  and tx.item.itemId in :itemIds
group by tx.item.itemId
""")
List&lt;InventoryUsageAggRow&gt; aggregateUsageByItem(
        @Param("itemIds") List&lt;Long&gt; itemIds,
        @Param("outTypes") List&lt;TxType&gt; outTypes,
        @Param("from7") LocalDateTime from7,
        @Param("from30") LocalDateTime from30,
        @Param("from365") LocalDateTime from365
);

4. Frontend 연동 (React / API Integration)

프론트는 기존 /api/stocks/current 호출 구조를 유지하되, “현재고 합산 화면”은 /api/stocks/current-summary로 분리해 연결했다.

4.1 Summary 타입 정의

InventorySummaryRow = {
  itemId: number;
  itemCode: string;
  itemName: string;
  spec: string | null;
  unit: string | null;

  currentQty: number;
  availableQty: number;
  safetyQty: number;

  weeklyUsage: number;
  monthlyUsage: number;
  yearlyUsage: number;

  warehouseCount: number;
  lastInboundAt: string | null;
};

4.2 API 호출

const res = await fetch(`/api/stocks/current-summary?${query}`, {
  method: "GET",
});

4.3 UI 기준 확정

  • 현재고(summary): 창고명 컬럼 제거
  • 창고 분산 정도는 warehouseCount로 표현
  • 안전재고(detail): 창고별 row 유지 (정책/리스크 관리 목적)

5. 트러블슈팅

5.1 500 Internal Server Error 발생

브라우저 콘솔에서 current-summary 호출이 500으로 실패했다.

  • 증상: Failed to load resource: 500 (Internal Server Error)

원인

PageRequest에서 정렬 키를 잘못 지정하면, Spring Data JPA가 JPQL 끝에 자동으로 order by를 붙인다. 이때 Inventory 엔티티에는 itemId 필드가 없고, 관계를 통해 inv.item.itemId로 접근해야 하므로 Hibernate 예외가 발생했다.

해결

Summary 조회는 어차피 메모리에서 정렬하는 구조이므로, fetch 시점의 정렬을 제거해서 해결했다.

Pageable fetchAll = PageRequest.of(0, MAX_FETCH);

결과

  • 500 에러 즉시 해결
  • 정상적으로 summary JSON 반환

5.2 “안전재고 값이 왜 이렇게 나오지?” 혼동

예를 들어, 현재고가 큰데 안전재고가 작게 나오는 경우가 있었다.

  • 착각: “현재고 - 월소요량”이 안전재고라고 생각함
  • 정의: 안전재고는 ‘월 최소 필요 수량(정책 기준치)’이고, 월소요량은 ‘최근 소비량’

즉, 안전재고는 계산된 값이 아니라 운영 기준값이며, 부족/위험 상태는 availableQty와 비교하여 판단한다.


6. 이번 단계의 의미

이번 작업은 단순 기능 추가가 아니라 “수량 정의의 일관성”을 고정한 단계였다.

  • 현재고(summary)와 안전재고(detail)의 화면 목적을 분리
  • JPQL Projection + Aggregation 구조 확립
  • API/DTO/화면 데이터 모델 정합성 확보
  • 수량 지표 해석 기준이 명확해져 운영 안정성이 올라감
profile
Develop

0개의 댓글