
이번 단계는 CoreERP에서 “실제 재고가 변하는 순간”을 구현한 구간이다. Purchase Order(발주)는 계획 데이터이고, Inbound Confirm(입고 확정)은 실재고 반영 트리거다.
따라서 이 단계에서 중요한 건 단순 CRUD가 아니라, 입고 라인 저장과 동시에 재고 증가, 원장 기록, 발주 누적 수량 갱신, 발주 상태 전환이 한 트랜잭션으로 묶여 데이터 정합성이 깨지지 않도록 만드는 것이었다.
발주는 “주문”이고 입고 확정은 “실제 입고”다. 그래서 발주 라인과 연결된 입고라면 다음 조건을 반드시 만족해야 한다.
Inbound Confirm는 아래 작업이 반드시 한 트랜잭션으로 커밋되어야 한다. 한 단계라도 빠지면 재고/원장/발주 상태가 분리되어 ERP 신뢰도가 깨진다.
Controller는 요청/응답만 담당하고, 비즈니스 규칙은 Service가 담당하도록 분리했다.
package com.coreerp.inbound.controller;
import com.coreerp.inbound.dto.InboundConfirmRequest;
import com.coreerp.inbound.dto.InboundConfirmResponse;
import com.coreerp.inbound.service.InboundService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/inbounds")
@RequiredArgsConstructor
public class InboundController {
private final InboundService inboundService;
@PostMapping("/confirm")
public InboundConfirmResponse confirm(@RequestBody InboundConfirmRequest req) {
return inboundService.confirm(req);
}
}
입고 확정 요청은 헤더(창고/거래처/담당자/메모) + 라인(품목/수량/poLineId)로 구성했다. poLineId는 선택 값으로 두어 “PO 기반 입고”와 “단순 입고”를 같은 API로 처리할 수 있도록 했다.
package com.coreerp.inbound.dto;
import java.util.List;
public record InboundConfirmRequest(
Long warehouseId,
Long vendorId,
String inboundDate,
String manager,
String memo,
Long createdBy,
List<InboundLineRequest> lines
) {}
package com.coreerp.inbound.dto;
public record InboundLineRequest(
Long itemId,
Integer qty,
Long poLineId,
String memo
) {}
package com.coreerp.inbound.dto;
public record InboundConfirmResponse(
Long inboundId,
String inboundNo,
int lineCount
) {}
Inbound는 inboundNo(UK)를 부여하고, InboundLine은 inbound_id 기준으로 라인들을 저장한다. inboundNo는 날짜 기반 + 랜덤 문자열로 생성하고, 중복 체크를 통해 유니크를 보장했다.
입고 라인이 여러 개일 때 라인 루프마다 PO 상태를 재계산하면 쿼리/저장 횟수가 폭증한다. 그래서 라인 처리 중에는 영향을 받은 poId만 모아두고, 루프가 끝난 뒤 poId 단위로 한 번씩 상태 재계산 및 저장을 수행했다.
poLine.receive(qty) 호출 전에 remaining = ordered - received를 계산하고, 요청 수량이 remaining보다 크면 즉시 409 Conflict로 차단한다. 이 로직이 없으면 전량보다 더 입고된 데이터가 생기며 ERP 정합성이 즉시 무너진다.
같은 품목/창고에 입고가 동시에 발생할 수 있기 때문에 Inventory row를 잠금 처리했다. Repository에 findForUpdate를 추가하고, InboundService에서 해당 메서드를 사용해 동시 업데이트 시 수량 꼬임을 방지했다.
package com.coreerp.stock.repository;
import com.coreerp.stock.domain.Inventory;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
public interface InventoryRepository extends JpaRepository<Inventory, Long> {
Optional<Inventory> findByItem_ItemIdAndWarehouse_WarehouseId(Long itemId, Long warehouseId);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select i from Inventory i
where i.item.itemId = :itemId
and i.warehouse.warehouseId = :warehouseId
""")
Optional<Inventory> findForUpdate(@Param("itemId") Long itemId,
@Param("warehouseId") Long warehouseId);
}
Service는 트랜잭션 안에서 Inbound 생성 → 라인 저장 → Inventory 증가 → StockTx 기록 → (PO 연동 시) 누적 및 상태 전환을 수행한다. PO 관련 검증(전량/취소/초과)은 모두 409 Conflict로 매핑되도록 IllegalStateException을 사용했다.
package com.coreerp.inbound.service;
import com.coreerp.inbound.domain.Inbound;
import com.coreerp.inbound.domain.InboundLine;
import com.coreerp.inbound.dto.InboundConfirmRequest;
import com.coreerp.inbound.dto.InboundConfirmResponse;
import com.coreerp.inbound.dto.InboundLineRequest;
import com.coreerp.inbound.repository.InboundLineRepository;
import com.coreerp.inbound.repository.InboundRepository;
import com.coreerp.item.domain.Item;
import com.coreerp.item.repository.ItemRepository;
import com.coreerp.purchase.domain.PurchaseOrder;
import com.coreerp.purchase.domain.PurchaseOrderLine;
import com.coreerp.purchase.domain.PurchaseOrderStatus;
import com.coreerp.purchase.dto.PoQtyAgg;
import com.coreerp.purchase.repository.PurchaseOrderLineRepository;
import com.coreerp.purchase.repository.PurchaseOrderRepository;
import com.coreerp.stock.domain.Inventory;
import com.coreerp.stock.domain.StockTx;
import com.coreerp.stock.domain.TxType;
import com.coreerp.stock.repository.InventoryRepository;
import com.coreerp.stock.repository.StockTxRepository;
import com.coreerp.vendor.domain.Vendor;
import com.coreerp.vendor.repository.VendorRepository;
import com.coreerp.warehouse.domain.Warehouse;
import com.coreerp.warehouse.repository.WarehouseRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeParseException;
import java.util.*;
@Service
@RequiredArgsConstructor
public class InboundService {
private final WarehouseRepository warehouseRepository;
private final VendorRepository vendorRepository;
private final ItemRepository itemRepository;
private final InboundRepository inboundRepository;
private final InboundLineRepository inboundLineRepository;
private final InventoryRepository inventoryRepository;
private final StockTxRepository stockTxRepository;
private final PurchaseOrderRepository purchaseOrderRepository;
private final PurchaseOrderLineRepository purchaseOrderLineRepository;
@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개 이상 필요합니다.");
Warehouse warehouse = warehouseRepository.findById(req.warehouseId())
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 창고입니다."));
Vendor vendor = null;
if (req.vendorId() != null) {
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());
if (!poLine.getItem().getItemId().equals(item.getItemId())) {
throw new IllegalArgumentException("poLineId의 품목과 inbound 품목이 일치하지 않습니다.");
}
PurchaseOrder po = poLine.getPurchaseOrder();
if (po.getStatus() == PurchaseOrderStatus.CANCELLED) {
throw new IllegalStateException("취소된 발주에는 입고할 수 없습니다. poId=" + po.getPoId());
}
if (po.getStatus() == PurchaseOrderStatus.RECEIVED) {
throw new IllegalStateException("이미 전량 입고된 발주입니다. poId=" + po.getPoId());
}
int remain = poLine.getQtyOrdered() - poLine.getQtyReceived();
if (line.qty() > remain) {
throw new IllegalStateException("초과 입고입니다. remain=" + remain + ", req=" + line.qty() + ", poLineId=" + line.poLineId());
}
poLine.receive(line.qty());
touchedPoIds.add(po.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()
);
}
private PurchaseOrderStatus resolvePoStatus(Long poId) {
PoQtyAgg agg = purchaseOrderLineRepository.aggregateQty(poId);
long ordered = agg == null || agg.getOrderedSum() == null ? 0 : agg.getOrderedSum();
long received = agg == null || agg.getReceivedSum() == null ? 0 : agg.getReceivedSum();
if (received == 0) return PurchaseOrderStatus.OPEN;
if (received < ordered) return PurchaseOrderStatus.PARTIAL_RECEIVED;
return PurchaseOrderStatus.RECEIVED;
}
private LocalDate parseDateOrToday(String dateStr) {
if (dateStr == null || dateStr.isBlank()) return LocalDate.now();
try {
return LocalDate.parse(dateStr);
} catch (DateTimeParseException e) {
throw new IllegalArgumentException("inboundDate 형식이 올바르지 않습니다. (YYYY-MM-DD)");
}
}
private String generateInboundNo(LocalDate inboundDate) {
String base = inboundDate.toString().replace("-", "");
while (true) {
String rand = UUID.randomUUID().toString().substring(0, 6).toUpperCase();
String no = "IB-" + base + "-" + rand;
if (!inboundRepository.existsByInboundNo(no)) return no;
}
}
}
입고 확정은 상태 충돌이 발생하기 쉬운 구간이기 때문에, 400과 409를 분리하는 것이 중요했다.
package com.coreerp.common;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<?> handleBadRequest(IllegalArgumentException e) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse(
400,
e.getMessage(),
LocalDateTime.now()
));
}
@ExceptionHandler(IllegalStateException.class)
public ResponseEntity<?> handleConflict(IllegalStateException e) {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(new ErrorResponse(
409,
e.getMessage(),
LocalDateTime.now()
));
}
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<?> handleDataIntegrity(DataIntegrityViolationException e) {
log.error("DataIntegrityViolationException", e);
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(new ErrorResponse(
409,
"DB constraint violation",
LocalDateTime.now()
));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<?> handleAny(Exception e) {
log.error("Unhandled exception", e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse(
500,
e.getClass().getSimpleName() + ": " + (e.getMessage() == null ? "" : e.getMessage()),
LocalDateTime.now()
));
}
}
현재 Inbound Confirm는 백엔드 API 기준으로 완성되었으며, Postman을 통해 성공/실패 케이스까지 검증을 완료했다.
다음 단계는 React(Typescript) 기반 프론트 화면과 실제 API를 연결하는 작업이다.
특히 이번 단계에서는 단순 API 호출이 아니라, "입고 확정 → 재고 증가 → 상태 변경"이라는 백엔드 도메인 흐름이 프론트 화면에서도 자연스럽게 이어지도록 UX 흐름을 설계하는 것이 핵심이다.
Inbound 프론트 연동을 통해 CoreERP는 단순 API 단위 개발을 넘어 “사용자 행동 → 도메인 상태 변화 → 데이터 반영”의 전체 사이클을 완성하게 된다.