
이번 단계는 “장기 재고(오래 출고/입고가 없거나, 기준 기간 이상 경과한 재고)”를 관리하기 위한 조회 화면을 만들고, 필터/정렬/페이지네이션까지 Frontend(React) ↔ Backend(Spring Boot) ↔ DB(MariaDB) 흐름을 끝까지 연결하는 작업이다.
여기서 포인트는 “조회 화면 UX(필터/정렬/페이지네이션)”를 먼저 안정화하고, 그 다음에 성능 최적화(집계/정렬 DB로 내리기, 인덱스 보강)를 진행할 수 있게 구조를 고정하는 것이다.
화면에 필요한 컬럼만 서버에서 확정해서 내려준다. (창고/품목 식별 + 재고 + 월 소요량 + 최종 입/출고일 + 경과일)
public record LongTermStockRowResponse(
Long warehouseId,
String warehouseCode,
String warehouseName,
Long itemId,
String itemCode,
String itemName,
String spec,
String unit,
String itemStatus,
Integer currentQty,
Integer monthlyUsage,
LocalDateTime lastInboundAt,
LocalDateTime lastOutboundAt,
Integer agingDays
) {}
Inventory 기준으로 warehouse/item join해서 base 컬럼을 가져오고, 장기재고 조건을 where에 적용한다.
코드는 길어지기 쉬워서 “핵심 조건만 남긴 형태”로 정리한다. (실제 구현은 projection/new DTO 포함)
@Query("""
select new com.coreerp.stock.repository.projection.LongTermBaseRow(
w.warehouseId, w.warehouseCode, w.warehouseName,
it.itemId, it.itemCode, it.itemName, it.spec, it.unit, it.status,
inv.currentQty, inv.lastInboundAt, inv.lastOutboundAt
)
from Inventory inv
join inv.warehouse w
join inv.item it
where (:warehouseId is null or w.warehouseId = :warehouseId)
and (:itemStatus is null or it.status = :itemStatus)
and (
: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), '%')
)
and inv.currentQty > 0
and (inv.lastOutboundAt is null or inv.lastOutboundAt <= :threshold)
""")
Page<LongTermBaseRow> searchLongTermBasePage(
Long warehouseId, String kw, String itemStatus, LocalDateTime threshold, Pageable pageable
);
정확도를 위해 keyword는 normalize(공백 제거/빈 문자열 null 처리), itemStatus도 normalize 후 파라미터로 넣는 게 안정적이다.
현재 단계에서는 UI 기능 완성과 데이터 흐름 고정을 위해 MAX_FETCH로 전체를 가져온 뒤, 서버 메모리에서 정렬/페이지 슬라이싱을 처리한다.
@Transactional(readOnly = true)
public Page<LongTermStockRowResponse> longTermPage(
String keyword, Long warehouseId, String itemStatus,
int daysCut, String sortKey, String sortOrder, int page, int size
) {
int safeDays = Math.max(daysCut, 1);
LocalDateTime threshold = LocalDateTime.now().minusDays(safeDays);
Page<LongTermBaseRow> base = inventoryQueryRepository.searchLongTermBasePage(
warehouseId, normalize(keyword), normalize(itemStatus), threshold,
PageRequest.of(0, MAX_FETCH)
);
if (base.getTotalElements() > MAX_FETCH) {
throw new IllegalStateException("Too many rows. Narrow your filters.");
}
Map<String, Usage> usageMap = loadUsageAggLongTerm(base.getContent());
LocalDateTime now = LocalDateTime.now();
List<LongTermStockRowResponse> mapped = base.getContent().stream()
.map(r -> toResponse(r, usageMap, now))
.toList();
List<LongTermStockRowResponse> sorted = sortLongTermInMemory(mapped, sortKey, sortOrder);
return sliceToPageLongTerm(sorted, page, size);
}
agingDays 계산 기준은 “최종 출고일(lastOutboundAt)”을 기본으로 잡았다. (출고 이력이 없는 재고는 agingDays를 null 처리)
전체 컴포넌트가 길어지기 때문에 실제 동작에 영향이 큰 부분만 남긴다. (쿼리 구성 / 창고 로드 / 목록 로드 / Page 처리)
type FilterState = {
keyword: string;
daysCut: "180" | "365";
warehouseId: "" | number;
itemStatus: "ALL" | "SUSPENDED" | "DISCONTINUED";
sortKey: "" | LongTermSortKey;
sortOrder: "asc" | "desc";
};
const buildQuery = (f: FilterState, page: number, size: number) => {
const qs = new URLSearchParams();
qs.set("page", String(page));
qs.set("size", String(size));
qs.set("daysCut", String(Number(f.daysCut)));
if (f.keyword.trim()) qs.set("keyword", f.keyword.trim());
if (f.warehouseId !== "") qs.set("warehouseId", String(f.warehouseId));
if (f.itemStatus !== "ALL") qs.set("itemStatus", f.itemStatus);
if (f.sortKey) {
qs.set("sortKey", f.sortKey);
qs.set("sortOrder", f.sortOrder);
}
return qs.toString();
};
const loadWarehouses = async () => {
const res = await fetch("/api/warehouses");
const data = await res.json().catch(() => null);
const content = Array.isArray(data?.content) ? data.content : Array.isArray(data) ? data : [];
return content.map((w: any) => ({
warehouseId: Number(w.warehouseId),
warehouseName: String(w.warehouseName ?? ""),
}));
};
const loadRows = async (query: string) => {
const res = await fetch(`/api/stocks/long-term?${query}`);
if (!res.ok) throw new Error(await res.text().catch(() => "request failed"));
return (await res.json()) as { content: any[]; number: number; totalElements: number; totalPages: number };
};
UI에서는 Page 응답을 그대로 받아 table 렌더링하고, 페이지 이동 시 page만 변경해 reload한다. draft/applied 분리 덕분에 입력 중 네트워크가 터지지 않는다.
const content = Array.isArray(data?.content) ? data.content : Array.isArray(data) ? data : [];