
이번 단계의 목표는 재고 화면에서 수량 해석이 혼동되지 않도록 기준을 고정하고, API/화면 구조를 안정화하는 것이었다. 특히 창고별 데이터가 섞여 보이면서 “현재고/안전재고” 의미가 헷갈리는 문제가 있었고, 이를 다음 기준으로 정리했다.
/api/stocks/current-summary/api/stocks/safetyItem.safetyStock (마스터 값)안전재고 화면에서 부족수량은 아래와 같이 해석한다.
shortageQty = max(safetyQty - availableQty, 0)이번 단계의 백엔드는 “조회용 Query API 강화”가 중심이다. Inventory는 스냅샷, StockTx는 이력(원장)으로 유지하고, 화면에서 필요한 계산 컬럼은 QueryService에서 조합한다.
현재고를 창고별 row가 아니라 “품목 기준 1 row”로 제공하기 위해 current-summary API를 추가했다.
GET /api/stocks/current-summaryInventoryCurrentSummaryRowResponse (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
);
}
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
);
기존에는 (warehouseId, itemId) 기준 집계를 사용했지만, summary 화면은 itemId 기준 합산 집계가 필요하므로 aggregateUsageByItem 쿼리를 추가했다.
@Query("""
select new com.coreerp.stock.dto.InventoryUsageAggRow(
null,
tx.item.itemId,
sum(case when tx.txDate >= :from7 then abs(tx.qtyDelta) else 0 end),
sum(case when tx.txDate >= :from30 then abs(tx.qtyDelta) else 0 end),
sum(case when tx.txDate >= :from365 then abs(tx.qtyDelta) else 0 end)
)
from StockTx tx
where tx.txType in :outTypes
and tx.txDate >= :from365
and tx.item.itemId in :itemIds
group by tx.item.itemId
""")
List<InventoryUsageAggRow> aggregateUsageByItem(
@Param("itemIds") List<Long> itemIds,
@Param("outTypes") List<TxType> outTypes,
@Param("from7") LocalDateTime from7,
@Param("from30") LocalDateTime from30,
@Param("from365") LocalDateTime from365
);
프론트는 기존 /api/stocks/current 호출 구조를 유지하되, “현재고 합산 화면”은 /api/stocks/current-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;
};
const res = await fetch(`/api/stocks/current-summary?${query}`, {
method: "GET",
});
warehouseCount로 표현브라우저 콘솔에서 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);
예를 들어, 현재고가 큰데 안전재고가 작게 나오는 경우가 있었다.
즉, 안전재고는 계산된 값이 아니라 운영 기준값이며, 부족/위험 상태는 availableQty와 비교하여 판단한다.
이번 작업은 단순 기능 추가가 아니라 “수량 정의의 일관성”을 고정한 단계였다.