CoreERP Inbound 확정 트랜잭션 구현 (재고 증가의 표준 경로 완성)

최병현·2026년 2월 21일

coreerp project

목록 보기
10/44
post-thumbnail

이번 단계는 CoreERP에서 “입고 확정(Inbound Confirm)” 트랜잭션을 구현하며, 재고 증가가 발생하는 공식 경로를 완성한 작업이다.

이전 단계에서 Inventory(스냅샷)와 StockTx(원장)를 분리 설계하고, 재고 조정(ADJUST) API를 통해 재고 엔진의 기반을 만들었다. 이번 단계는 실제 업무 흐름에 해당하는 “입고 확정”을 통해 ERP 재고 시스템이 실무 수준의 트랜잭션 구조로 전환되는 지점이다.


2. 작업 범위

  • Inbound / InboundLine 엔티티 구현
  • POST /api/inbounds/confirm API 구현
  • Inventory.currentQty 증가 로직 구현
  • Inventory.lastInboundAt 갱신 로직 추가
  • StockTx(INBOUND) 원장 기록 구현
  • @Transactional로 전체 트랜잭션 묶기

3. 핵심 목적

  • 재고 증가가 “입고 확정”이라는 단일 경로를 통해서만 발생하도록 구조 고정
  • 스냅샷(Inventory)과 원장(StockTx)의 동시 반영 보장
  • 향후 출고/이동/발주 확장 시 동일한 트랜잭션 패턴 재사용

4. 설계 기준

4-1. 입고는 단순 저장이 아니라 “확정 트랜잭션”이다

입고는 헤더/라인 저장만으로 끝나지 않는다. 재고 수량 증가와 원장 기록까지 하나의 작업 단위로 처리되어야 한다.

4-2. 스냅샷 + 원장 분리 원칙 유지

  • Inventory → 현재 수량 빠른 조회 목적
  • StockTx → 왜 수량이 변했는지 추적 목적

입고 확정 시 두 테이블이 동시에 갱신되어야 데이터 무결성이 유지된다.

4-3. 도메인 메서드로 수량 변경

Setter를 사용하지 않고 Inventory 내부에 applyInbound()를 두어, 재고 변경 책임을 도메인 객체에 위임하였다.


5. 전체 데이터 흐름

5-1. 요청 → 저장 → 재고 반영 → 원장 기록

  • Frontend → POST /api/inbounds/confirm
  • Inbound / InboundLine 저장
  • Inventory.applyInbound(+qty)
  • Inventory.lastInboundAt 갱신
  • StockTx(INBOUND, +qty) 기록

위 과정은 하나의 @Transactional 메서드에서 처리된다. 하나라도 실패하면 전부 롤백된다.


6. 핵심 코드 정리

6-1. Inventory 도메인 메서드

    public void applyInbound(int qty) {
        if (qty <= 0) {
            throw new IllegalArgumentException("입고 수량은 1 이상이어야 합니다.");
        }

        if (this.currentQty == null) this.currentQty = 0;

        this.currentQty += qty;
        this.lastInboundAt = LocalDateTime.now();
    }

입고는 +수량만 허용한다. 음수는 출고에서 처리하도록 경계를 명확히 분리하였다.


6-2. InboundService.confirm()

   @Transactional
    public InboundConfirmResponse confirm(InboundConfirmRequest req) {

        if (req.warehouseId() == null) {
            throw new IllegalArgumentException("warehouseId는 필수입니다.");
        }
        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()
        );

        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 이상이어야 합니다.");
            }

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

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

            Inventory inventory = inventoryRepository
                    .findByItem_ItemIdAndWarehouse_WarehouseId(item.getItemId(), warehouse.getWarehouseId())
                    .orElseGet(() -> Inventory.builder()
                            .item(item)
                            .warehouse(warehouse)
                            .currentQty(0)
                            .reservedQty(0)
                            .build()
                    );

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

            StockTx tx = StockTx.builder()
                    .item(item)
                    .warehouse(warehouse)
                    .txType(TxType.INBOUND)
                    .txDate(LocalDateTime.now())
                    .qtyDelta(line.qty())
                    .refType("INBOUND")
                    .refId(inbound.getInboundId())
                    .memo(line.memo() != null && !line.memo().isBlank() ? line.memo() : req.memo())
                    .build();

            stockTxRepository.save(tx);
        }

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

7. 트랜잭션 기준

입고 확정은 다음 작업이 반드시 함께 성공해야 한다.

  • Inbound 저장
  • InboundLine 저장
  • Inventory 수량 증가
  • StockTx 원장 기록

이 중 하나라도 실패하면 전체 롤백되도록 서비스 레이어에 @Transactional을 적용하였다.


8. 구조적 의미

이번 단계로 CoreERP는 단순 CRUD 프로젝트를 벗어나, “재고가 증가하는 공식 경로”를 가진 ERP 형태로 전환되었다.

이제 재고는 다음 기준으로 고정된다.

  • 입고 → +qty
  • 출고 → -qty (다음 단계)
  • 조정 → 예외적 수정 경로

이 구조는 향후 발주(PO), 이동(Transfer), 반품(Return) 확장 시에도 동일한 트랜잭션 패턴으로 확장 가능하다.


9. 다음 단계

  • Outbound 확정 로직 구현 (재고 감소 + lastOutboundAt 갱신)
  • 재고 부족 예외 처리 강화
  • StockTx 이력 조회 API 제공
  • Reason Code 체계 도입

10. 정리

CoreERP는 현재 마스터(Item/Warehouse/Vendor) → Inventory 스냅샷 → StockTx 원장 → Inbound 확정 트랜잭션까지 완성되었다.

이제 재고는 단순 숫자가 아니라, 모든 변화가 기록되고 추적 가능한 구조로 관리된다.

다음 단계에서는 출고 확정을 통해 재고 감소 경로까지 완성하며, ERP 재고 엔진을 더욱 명확한 업무 흐름 기반 구조로 확장할 예정이다.

profile
Develop

0개의 댓글