CoreERP 입고 확정 모듈 정리 기록

최병현·2026년 2월 24일

coreerp project

목록 보기
17/44

이번 단계는 CoreERP에서 “실제 재고가 변하는 순간”을 구현한 구간이다. Purchase Order(발주)는 계획 데이터이고, Inbound Confirm(입고 확정)은 실재고 반영 트리거다.

따라서 이 단계에서 중요한 건 단순 CRUD가 아니라, 입고 라인 저장과 동시에 재고 증가, 원장 기록, 발주 누적 수량 갱신, 발주 상태 전환이 한 트랜잭션으로 묶여 데이터 정합성이 깨지지 않도록 만드는 것이었다.


2. 이번 단계의 작업 범위

  • Inbound Confirm API 구현 (POST /api/inbounds/confirm)
  • Inbound / InboundLine 엔티티 저장
  • Inventory 수량 증가 처리
  • StockTx 원장 기록(입고 타입) 저장
  • PO Line 누적 수량(received) 증가
  • PO 상태 자동 전환 (OPEN → PARTIAL_RECEIVED → RECEIVED)
  • 초과 입고 및 상태 충돌 방지 (409 Conflict)
  • Inventory 동시성 방어(PESSIMISTIC_WRITE) 적용
  • Postman + DB 검증으로 성공/실패 케이스 테스트 완료

3. 설계 기준과 의도

3.1 발주와 입고의 관계를 “정합성 기준”으로 고정

발주는 “주문”이고 입고 확정은 “실제 입고”다. 그래서 발주 라인과 연결된 입고라면 다음 조건을 반드시 만족해야 한다.

  • 이미 전량 입고된 발주(RECEIVED)에는 추가 입고가 불가능
  • 취소된 발주(CANCELLED)에는 입고가 불가능
  • 발주 라인의 remaining(ordered - received)보다 큰 수량은 초과 입고로 차단
  • poLineId가 존재한다면 inbound의 itemId와 poLine의 itemId는 반드시 일치

3.2 “입고 확정”은 한 번에 끝나야 한다

Inbound Confirm는 아래 작업이 반드시 한 트랜잭션으로 커밋되어야 한다. 한 단계라도 빠지면 재고/원장/발주 상태가 분리되어 ERP 신뢰도가 깨진다.

  • Inbound 생성
  • InboundLine 생성
  • Inventory 증가
  • StockTx 원장 기록
  • PO Line received 누적
  • PO 상태 재계산 및 저장

4. API 및 레이어별 역할

4.1 Controller 레이어

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);
    }
}

4.2 DTO 구조

입고 확정 요청은 헤더(창고/거래처/담당자/메모) + 라인(품목/수량/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
) {}

5. 핵심 구현 포인트

5.1 Inbound/InboundLine 저장

Inbound는 inboundNo(UK)를 부여하고, InboundLine은 inbound_id 기준으로 라인들을 저장한다. inboundNo는 날짜 기반 + 랜덤 문자열로 생성하고, 중복 체크를 통해 유니크를 보장했다.

5.2 PO 상태 업데이트는 라인마다 하지 않고 “마지막에 한 번”만 한다

입고 라인이 여러 개일 때 라인 루프마다 PO 상태를 재계산하면 쿼리/저장 횟수가 폭증한다. 그래서 라인 처리 중에는 영향을 받은 poId만 모아두고, 루프가 끝난 뒤 poId 단위로 한 번씩 상태 재계산 및 저장을 수행했다.

5.3 초과 입고 방지(정합성 핵심)

poLine.receive(qty) 호출 전에 remaining = ordered - received를 계산하고, 요청 수량이 remaining보다 크면 즉시 409 Conflict로 차단한다. 이 로직이 없으면 전량보다 더 입고된 데이터가 생기며 ERP 정합성이 즉시 무너진다.

5.4 Inventory 동시성 방어(PESSIMISTIC_WRITE)

같은 품목/창고에 입고가 동시에 발생할 수 있기 때문에 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);
}

6. InboundService 최종 로직

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;
        }
    }
}

7. 예외 처리 전략

입고 확정은 상태 충돌이 발생하기 쉬운 구간이기 때문에, 400과 409를 분리하는 것이 중요했다.

  • 요청 값 오류, 리소스 미존재: IllegalArgumentException → 400
  • 상태 충돌, 초과 입고, 전량 입고된 발주에 재입고: IllegalStateException → 409
  • DB 제약조건 위반: DataIntegrityViolationException → 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()
                ));
    }
}

8. 테스트 결과(Postman + DB 검증)

8.1 실패 케이스(409 Conflict)

  • 전량 입고된 발주(RECEIVED)에 입고 시도 시 409 반환
  • 초과 입고(remain보다 큰 qty) 시 409 반환

8.2 성공 케이스(200 OK)

  • OPEN 상태 PO + poLineId로 부분 입고 confirm 성공
  • Inbound/InboundLine 생성 확인
  • Inventory 증가 확인
  • StockTx 원장 기록 확인
  • PO Line received 누적 및 PO 상태 PARTIAL_RECEIVED 전환 확인

9. 이번 단계의 의미 정리

  • 입고 확정은 “재고 증감 트리거”이며, ERP 신뢰도는 이 지점에서 결정된다.
  • Inbound Confirm를 단일 트랜잭션으로 구성해 데이터 정합성을 고정했다.
  • 초과 입고/상태 충돌을 409로 분리해 운영 관점의 오류 분류가 가능해졌다.
  • Inventory에 PESSIMISTIC_WRITE를 적용해 동시 입고에서도 수량 꼬임을 방지했다.

10. 다음 단계 계획

1) Inbound 프론트엔드 연동

현재 Inbound Confirm는 백엔드 API 기준으로 완성되었으며, Postman을 통해 성공/실패 케이스까지 검증을 완료했다.

다음 단계는 React(Typescript) 기반 프론트 화면과 실제 API를 연결하는 작업이다.

  • Inbound Confirm 화면에서 실제 API 호출 연결
  • 성공 시 inboundId/inboundNo UI 반영
  • 409 Conflict 발생 시 사용자에게 명확한 에러 메시지 표시
  • 입고 완료 후 재고 화면 자동 갱신 흐름 설계

특히 이번 단계에서는 단순 API 호출이 아니라, "입고 확정 → 재고 증가 → 상태 변경"이라는 백엔드 도메인 흐름이 프론트 화면에서도 자연스럽게 이어지도록 UX 흐름을 설계하는 것이 핵심이다.

2) 화면-도메인 흐름 연결 구조 고정

Inbound 프론트 연동을 통해 CoreERP는 단순 API 단위 개발을 넘어 “사용자 행동 → 도메인 상태 변화 → 데이터 반영”의 전체 사이클을 완성하게 된다.

  • 발주 이력 화면에서 입고 이동
  • 입고 확정 후 발주 상태 자동 갱신 반영
  • 재고 현황 화면에서 즉시 수량 반영
profile
Develop

0개의 댓글