CoreERP 입고 등록 / 이력 API 프론트엔드 연동 기록

최병현·2026년 2월 25일

coreerp project

목록 보기
18/44

이번 단계는 CoreERP의 “입고” 기능을 실제 운영 흐름에 맞게 연결한 작업이었다. 입고등록 화면에서 입고 확정 API를 호출하고, 그 결과가 입고이력 화면에 즉시 반영되도록 데이터 흐름을 고정했다.


1. 이번 단계의 핵심 목적

  • 프론트 “입고등록(입고확정)” → 백엔드 /api/inbounds/confirm 호출로 실제 입고 데이터 생성
  • 입고 확정 시 재고(Inventory) 반영 + 원장(StockTx) 기록까지 서버에서 트랜잭션으로 처리
  • 입고 확정 후 입고이력 화면에서 신규 데이터 조회/표시
  • 발주(PO) 연동 케이스에서는 qtyReceived 누적 및 상태 OPEN/PARTIAL_RECEIVED/RECEIVED 갱신까지 이어짐

2. 전체 데이터 흐름

2-1. Frontend → Backend (REST API)

  • 입고등록 화면: 사용자가 창고/거래처/담당자/메모/라인(품목, 수량, poLineId)을 입력
  • 프론트가 JSON payload로 POST /api/inbounds/confirm 호출
  • 백엔드는 Inbound + InboundLine 저장, Inventory 업데이트, StockTx 기록
  • (PO 연동 시) PurchaseOrderLine.qtyReceived 누적 + PurchaseOrder.status 갱신

2-2. Backend → Database

  • inbound, inbound_line 테이블에 입고 헤더/라인 저장
  • inventory 테이블 currentQty 업데이트
  • stock_tx 테이블에 원장 기록 생성 (txType=INBOUND, balanceAfter 포함)

2-3. 입고이력 화면

  • 입고이력 화면은 GET /api/inbounds로 리스트를 받아 테이블 렌더링
  • 입고등록 성공 직후에는 “이력 재조회” 또는 “화면 이동 시 자동 조회”로 최신 상태 확인

3. Backend 주요 코드

3-1. Inbound Confirm API Controller

@RestController
@RequestMapping("/api/inbounds")
@RequiredArgsConstructor
public class InboundController {

    private final InboundService inboundService;

    @PostMapping("/confirm")
    public InboundConfirmResponse confirm(@RequestBody InboundConfirmRequest req) {
        return inboundService.confirm(req);
    }
}

3-2. InboundService.confirm 핵심 흐름

  • 요청 유효성 검사(warehouseId, createdBy, vendorId, manager, lines)
  • InboundNo 생성 후 inbound 저장
  • 라인별로 inbound_line 저장 + inventory 반영 + stock_tx 기록
  • poLineId가 있으면 발주라인 qtyReceived 누적 및 발주 상태 재계산
@Transactional
public InboundConfirmResponse confirm(InboundConfirmRequest req) {

    if (req.warehouseId() == null) throw new IllegalArgumentException("warehouseId는 필수입니다.");
    if (req.createdBy() == null) throw new IllegalArgumentException("createdBy는 필수입니다.");
    if (req.lines() == null || req.lines().isEmpty()) throw new IllegalArgumentException("lines는 최소 1개 이상 필요합니다.");
    if (req.vendorId() == null) throw new IllegalArgumentException("vendorId는 필수입니다.");
    if (req.manager() == null || req.manager().isBlank()) throw new IllegalArgumentException("manager는 필수입니다.");

    Warehouse warehouse = warehouseRepository.findById(req.warehouseId())
            .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 창고입니다."));

    Vendor vendor = vendorRepository.findById(req.vendorId())
            .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 거래처입니다."));

    LocalDate inboundDate = parseDateOrToday(req.inboundDate());
    String inboundNo = generateInboundNo(inboundDate);

    Inbound inbound = inboundRepository.save(
            Inbound.builder()
                    .inboundNo(inboundNo)
                    .warehouse(warehouse)
                    .vendor(vendor)
                    .inboundDate(inboundDate)
                    .manager(req.manager())
                    .memo(req.memo())
                    .build()
    );

    Set<Long> itemIds = new HashSet<>();
    Set<Long> poLineIds = new HashSet<>();
    for (InboundLineRequest line : req.lines()) {
        if (line.itemId() == null) throw new IllegalArgumentException("line.itemId는 필수입니다.");
        if (line.qty() == null || line.qty() <= 0) throw new IllegalArgumentException("line.qty는 1 이상이어야 합니다.");
        itemIds.add(line.itemId());
        if (line.poLineId() != null) poLineIds.add(line.poLineId());
    }

    Map<Long, Item> itemMap = itemRepository.findAllById(itemIds)
            .stream().collect(java.util.stream.Collectors.toMap(Item::getItemId, it -> it));

    Map<Long, PurchaseOrderLine> poLineMap = poLineIds.isEmpty()
            ? Map.of()
            : purchaseOrderLineRepository.findAllById(poLineIds)
            .stream().collect(java.util.stream.Collectors.toMap(PurchaseOrderLine::getPoLineId, pl -> pl));

    Set<Long> touchedPoIds = new HashSet<>();
    LocalDateTime now = LocalDateTime.now();

    for (InboundLineRequest line : req.lines()) {

        Item item = itemMap.get(line.itemId());
        if (item == null) throw new IllegalArgumentException("존재하지 않는 품목입니다. itemId=" + line.itemId());

        if (line.poLineId() != null) {
            PurchaseOrderLine poLine = poLineMap.get(line.poLineId());
            if (poLine == null) throw new IllegalArgumentException("존재하지 않는 발주 라인입니다. poLineId=" + line.poLineId());

            int remain = poLine.getQtyOrdered() - poLine.getQtyReceived();
            if (line.qty() > remain) {
                throw new IllegalStateException("초과 입고입니다. remain=" + remain + ", req=" + line.qty());
            }

            poLine.receive(line.qty());
            touchedPoIds.add(poLine.getPurchaseOrder().getPoId());
        }

        inboundLineRepository.save(
                InboundLine.builder()
                        .inbound(inbound)
                        .item(item)
                        .qty(line.qty())
                        .poLineId(line.poLineId())
                        .memo(line.memo())
                        .build()
        );

        Inventory inventory = inventoryRepository
                .findForUpdate(item.getItemId(), warehouse.getWarehouseId())
                .orElseGet(() -> Inventory.create(item, warehouse));

        inventory.applyInbound(line.qty());
        inventoryRepository.save(inventory);

        stockTxRepository.save(
                StockTx.builder()
                        .item(item)
                        .warehouse(warehouse)
                        .txType(TxType.INBOUND)
                        .txDate(now)
                        .qtyDelta(line.qty())
                        .balanceAfter(inventory.getCurrentQty())
                        .createdBy(req.createdBy())
                        .refType("INBOUND")
                        .refId(inbound.getInboundId())
                        .memo((line.memo() != null && !line.memo().isBlank()) ? line.memo() : req.memo())
                        .build()
        );
    }

    for (Long poId : touchedPoIds) {
        PurchaseOrder po = purchaseOrderRepository.findById(poId)
                .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 발주입니다. poId=" + poId));

        PurchaseOrderStatus next = resolvePoStatus(poId);
        po.changeStatus(next);
        purchaseOrderRepository.save(po);
    }

    return new InboundConfirmResponse(inbound.getInboundId(), inbound.getInboundNo(), req.lines().size());
}

4. Frontend 연동 포인트

여기부터는 Frontend(React)에서 “입고등록 → 성공 후 입고이력 확인”이 자연스럽게 이어지도록 만든 부분이다. 프론트가 해야 할 일은 단순하다. 서버가 정합성을 보장하고, 프론트는 API 호출/표시만 깔끔하게 한다.

4-1. 입고 확정 요청 Payload 설계

  • 백엔드 DTO: InboundConfirmRequest 구조에 맞춰 프론트에서 JSON 생성
  • 라인별로 itemId, qty, (선택) poLineId 포함
const payload = {
  warehouseId: selectedWarehouseId,
  vendorId: selectedVendorId,
  inboundDate: inboundDate, // "YYYY-MM-DD"
  manager: managerName,
  memo: headerMemo,
  createdBy: 1001,
  lines: lineRows.map((r) => ({
    itemId: r.itemId,
    qty: r.qty,
    poLineId: r.poLineId ?? null,
    memo: r.memo ?? "",
  })),
};

4-2. 입고 확정 API 호출

const confirmInbound = async () => {
  const res = await fetch("/api/inbounds/confirm", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });

  if (!res.ok) {
    const msg = await res.text().catch(() => "");
    alert(`입고 확정 실패: ${res.status}\n${msg}`);
    return;
  }

  const data = await res.json(); // { inboundId, inboundNo, lineCount }
  alert(`입고 확정 완료: ${data.inboundNo}`);
};

4-3. 입고이력 화면 조회/필터링 구조

입고이력 화면은 이미 제공한 코드처럼 GET /api/inbounds로 rows를 가져오고, 프론트에서 기간/키워드/창고/담당자 필터를 적용해 viewRows를 만들었다.

useEffect(() => {
  fetch("/api/inbounds")
    .then((res) => res.json())
    .then((data) => setRows(data ?? []))
    .finally(() => setLoading(false));
}, []);

5. 트러블슈팅

5-1. “입고이력에 방금 등록한 입고가 안 뜨는 느낌”

  • 분류: Logical / UX issue
  • 원인 후보:
    • 입고 확정 후에도 이력 화면이 재조회되지 않음 (기존 rows가 그대로)
    • 필터(applied)가 이전 값으로 남아 신규 데이터가 필터에 걸려서 제외됨
    • 서버는 저장했는데 화면은 “navigate만 하고 fetch가 늦게” 혹은 “캐시된 화면”을 보여줌
  • 해결:
    • 입고 확정 성공 후 이력 화면으로 이동하는 경우: 이력 화면 useEffect에서 항상 fetch 하도록 유지
    • 같은 화면에서 이력도 같이 보여주는 경우: confirm 성공 후 fetch("/api/inbounds") 재호출
    • 필터가 문제면: confirm 성공 후 setDraft(emptyFilter), setApplied(emptyFilter)로 초기화 옵션 제공

5-2. “발주 상세에서 수량/금액이 0으로 보이거나 안 뜨는 문제”

  • 분류: Mapping/Contract mismatch (Frontend DTO 매핑 오류)
  • 상황: 백엔드는 qtyOrdered / unitPrice를 내려주는데 프론트가 qty를 다른 키로 읽거나 잘못 매핑하면 값이 0으로 떨어진다.
  • 해결: 상세 매핑에서 백엔드 응답 키를 정확히 맞춤
lines: (data.lines ?? []).map((x: any) => ({
  lineNo: toNumber(x.lineNo),
  itemCode: x.itemCode ?? "",
  itemName: x.itemName ?? "",
  unit: x.unit ?? "",
  qty: toNumber(x.qtyOrdered),    
  unitPrice: toNumber(x.unitPrice),
  memo: x.memo ?? undefined,
}))

5-3. “발주수량/입고수량/발주잔량이 - 로만 뜨는 문제”

  • 분류: Data mapping / DTO field mismatch
  • 원인:
    • 백엔드 응답 필드명(예: orderedQty, receivedQty, remainQty)과 프론트 타입/렌더링 키가 다름
    • 프론트에서 숫자 파싱(toNumber) 대상이 아닌 문자열/undefined로 들어옴
  • 해결:
    • 백엔드 RowResponse/RowView에 집계 필드가 내려오는지 Network 탭에서 먼저 확인
    • 프론트 normalizeRow에서 해당 필드들을 toNumber로 강제 정규화
    • 테이블 렌더링에서 fmt()를 일관되게 사용

6. 실무 관점 정리: “발주잔량”이 왜 중요한가

  • 발주잔량 = “아직 입고 확정되지 않은 수량”이라서, 운영에서 다음 액션의 기준이 된다.
  • 입고 담당자는 “남은 수량이 0인지”로 발주 종료 여부를 판단한다.
  • 구매/자재팀은 “남은 수량이 있는데 예정입고일이 지났는지”로 납기 지연/클레임을 판단한다.
  • 따라서 발주 이력 테이블에서 발주수량 / 입고수량 / 발주잔량 3종 세트는 우선순위가 높다.

7. 개선할 점

  • 입고이력 조회 API를 페이지네이션/검색/정렬로 확장 (현재는 프론트 필터 중심)
  • 입고 상세(헤더+라인) 모달로 “한 입고에 여러 라인”을 한 번에 확인 가능하게 개선
  • 발주 이력에 “납기 지연(overdue)” 표시: expectedInboundDate & remainQty 기반 배지

8. 마무리

이번 단계에서 가장 중요한 건 “입고 확정 = 재고/원장/발주상태까지 서버가 책임진다”는 기준을 고정한 것이다. 프론트는 화면/입력/표시에 집중하고, 정합성은 백엔드 트랜잭션으로 묶어서 시스템 신뢰도를 올렸다.

profile
Develop

0개의 댓글