
오늘은 ERP에서 “재고가 움직이는 또 하나의 핵심 경로”인 창고 이동(Warehouse Transfer)을 확정(Confirm) 흐름으로 구현했다. 단순히 from 재고를 빼고 to 재고를 더하는 수준이 아니라, 재고 스냅샷(Inventory)과 원장(StockTx)을 함께 갱신하여 정합성을 유지하는 것을 목표로 했다.
CoreERP 재고 구조는 다음 원칙을 유지한다.
Transfer는 내부 이동이지만, 재고가 변하는 행위이기 때문에 “원장 기록”이 반드시 남아야 한다. 따라서 Transfer 확정은 다음을 하나의 트랜잭션으로 묶어 처리한다.
Transfer 확정:
POST /api/transfers/confirm재고 조정(테스트/실무 초기 세팅용):
POST /api/stocks/adjust원장 조회:
GET /api/stocks/tx확정 로직은 다음 검증 순서를 가진다.
@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());
}




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