CoreERP Day6 Warehouse Transfer + 재고 엔진(Inventory/StockTx) 정합성 완성 기록

최병현·2026년 2월 22일

coreerp project

목록 보기
13/44
post-thumbnail

오늘은 ERP에서 “재고가 움직이는 또 하나의 핵심 경로”인 창고 이동(Warehouse Transfer)을 확정(Confirm) 흐름으로 구현했다. 단순히 from 재고를 빼고 to 재고를 더하는 수준이 아니라, 재고 스냅샷(Inventory)과 원장(StockTx)을 함께 갱신하여 정합성을 유지하는 것을 목표로 했다.


1. 이번 단계의 목표

  • Transfer 확정 시 inventory 2행 업데이트(from 감소 / to 증가)
  • Transfer 확정 시 stock_tx 2건 기록(TRANSFER_OUT / TRANSFER_IN)
  • 동일 창고(from/to 동일) 이동 방지
  • 출발 창고 재고 부족 검증
  • 쓰기(StockController/StockService)와 읽기(StockTxController/QueryService) 분리 유지

2. 설계 핵심: “스냅샷 + 원장”을 함께 갱신한다

CoreERP 재고 구조는 다음 원칙을 유지한다.

  • Inventory: 현재 재고를 빠르게 조회하기 위한 스냅샷
  • StockTx: 재고 변동의 사유/흐름을 추적하기 위한 원장(감사 로그)

Transfer는 내부 이동이지만, 재고가 변하는 행위이기 때문에 “원장 기록”이 반드시 남아야 한다. 따라서 Transfer 확정은 다음을 하나의 트랜잭션으로 묶어 처리한다.

  • from inventory 감소
  • to inventory 증가(없으면 생성)
  • stock_tx 2건 기록(OUT/IN)

3. API 엔드포인트

Transfer 확정:

  • POST /api/transfers/confirm

재고 조정(테스트/실무 초기 세팅용):

  • POST /api/stocks/adjust

원장 조회:

  • GET /api/stocks/tx

4. Transfer 확정 로직(핵심 흐름)

확정 로직은 다음 검증 순서를 가진다.

  1. from/to 창고 존재 확인
  2. 동일 창고 이동 방지
  3. 라인별 품목 존재 확인
  4. from 재고 존재 및 부족 검증
  5. to 재고 없으면 생성
  6. Inventory 갱신 + StockTx 2건 기록
@Transactional
public TransferConfirmResponse confirm(TransferConfirmRequest req) {

    if (req.fromWarehouseId().equals(req.toWarehouseId())) {
        throw new IllegalArgumentException("동일 창고로 이동할 수 없습니다.");
    }

    Warehouse fromWh = warehouseRepository.findById(req.fromWarehouseId())
        .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 출발 창고입니다."));
    Warehouse toWh = warehouseRepository.findById(req.toWarehouseId())
        .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 도착 창고입니다."));

    WarehouseTransfer transfer = transferRepository.save(
        WarehouseTransfer.builder()
            .transferNo(generateTransferNo())
            .fromWarehouse(fromWh)
            .toWarehouse(toWh)
            .status(TransferStatus.DRAFT)
            .build()
    );

    for (TransferLineRequest line : req.lines()) {
        Item item = itemRepository.findById(line.itemId())
            .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 품목입니다."));

        Inventory fromInv = inventoryRepository
            .findByItem_ItemIdAndWarehouse_WarehouseId(item.getItemId(), fromWh.getWarehouseId())
            .orElseThrow(() -> new IllegalStateException("출발 창고에 재고가 존재하지 않습니다."));

        fromInv.applyDelta(-line.qty()); // 부족 시 예외
        inventoryRepository.save(fromInv);

        Inventory toInv = inventoryRepository
            .findByItem_ItemIdAndWarehouse_WarehouseId(item.getItemId(), toWh.getWarehouseId())
            .orElseGet(() -> Inventory.create(item, toWh));

        toInv.applyDelta(line.qty());
        inventoryRepository.save(toInv);

        String finalMemo = (line.memo() != null && !line.memo().isBlank()) ? line.memo() : req.memo();

        stockTxRepository.save(StockTx.builder()
            .item(item)
            .warehouse(fromWh)
            .txType(TxType.TRANSFER_OUT)
            .qtyDelta(-line.qty())
            .balanceAfter(fromInv.getCurrentQty())
            .createdBy(req.createdBy())
            .refType("TRANSFER")
            .refId(transfer.getTransferId())
            .memo(finalMemo)
            .build());

        stockTxRepository.save(StockTx.builder()
            .item(item)
            .warehouse(toWh)
            .txType(TxType.TRANSFER_IN)
            .qtyDelta(line.qty())
            .balanceAfter(toInv.getCurrentQty())
            .createdBy(req.createdBy())
            .refType("TRANSFER")
            .refId(transfer.getTransferId())
            .memo(finalMemo)
            .build());
    }

    transfer.confirm();
    transferRepository.save(transfer);
    return new TransferConfirmResponse(transfer.getTransferId(), transfer.getTransferNo(), req.lines().size());
}

5. 테스트 시나리오 (Postman)

5-1) 재고 세팅(조정)

5-2) 이동 확정(정상)

5-3) 동일 창고 방지(실패)

5-4) 재고 부족 검증(실패)


6. 이번 단계의 의미

CoreERP는 재고 변동 경로를 다음 3가지로 확정했다.

  • ADJUST: 초기 세팅/실사/오류정정 등 관리 경로
  • INBOUND/OUTBOUND: 실제 입출고 확정 경로
  • TRANSFER: 내부 이동 확정 경로

특히 Transfer는 “내부 이동”이지만, 재고 스냅샷과 원장을 함께 갱신하며 정합성을 유지하는 흐름을 완성했다는 점에서 재고 엔진이 실무형 구조로 한 단계 올라간 지점이다.


7. 다음 단계

  • PO(발주) 및 PO Line 생성
  • Inbound 확정 시 po_line.qty_received 갱신
  • PO 상태(OPEN/PARTIAL/RECEIVED) 자동 업데이트
  • 발주 → 입고 흐름 연결로 “업무 프로세스” 완성
profile
Develop

0개의 댓글