
이번 단계는 CoreERP의 “입고” 기능을 실제 운영 흐름에 맞게 연결한 작업이었다. 입고등록 화면에서 입고 확정 API를 호출하고, 그 결과가 입고이력 화면에 즉시 반영되도록 데이터 흐름을 고정했다.
/api/inbounds/confirm 호출로 실제 입고 데이터 생성POST /api/inbounds/confirm 호출inbound, inbound_line 테이블에 입고 헤더/라인 저장inventory 테이블 currentQty 업데이트stock_tx 테이블에 원장 기록 생성 (txType=INBOUND, balanceAfter 포함)GET /api/inbounds로 리스트를 받아 테이블 렌더링@RestController
@RequestMapping("/api/inbounds")
@RequiredArgsConstructor
public class InboundController {
private final InboundService inboundService;
@PostMapping("/confirm")
public InboundConfirmResponse confirm(@RequestBody InboundConfirmRequest req) {
return inboundService.confirm(req);
}
}
@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개 이상 필요합니다.");
if (req.vendorId() == null) throw new IllegalArgumentException("vendorId는 필수입니다.");
if (req.manager() == null || req.manager().isBlank()) throw new IllegalArgumentException("manager는 필수입니다.");
Warehouse warehouse = warehouseRepository.findById(req.warehouseId())
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 창고입니다."));
Vendor 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());
int remain = poLine.getQtyOrdered() - poLine.getQtyReceived();
if (line.qty() > remain) {
throw new IllegalStateException("초과 입고입니다. remain=" + remain + ", req=" + line.qty());
}
poLine.receive(line.qty());
touchedPoIds.add(poLine.getPurchaseOrder().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());
}
여기부터는 Frontend(React)에서 “입고등록 → 성공 후 입고이력 확인”이 자연스럽게 이어지도록 만든 부분이다. 프론트가 해야 할 일은 단순하다. 서버가 정합성을 보장하고, 프론트는 API 호출/표시만 깔끔하게 한다.
InboundConfirmRequest 구조에 맞춰 프론트에서 JSON 생성itemId, qty, (선택) poLineId 포함const payload = {
warehouseId: selectedWarehouseId,
vendorId: selectedVendorId,
inboundDate: inboundDate, // "YYYY-MM-DD"
manager: managerName,
memo: headerMemo,
createdBy: 1001,
lines: lineRows.map((r) => ({
itemId: r.itemId,
qty: r.qty,
poLineId: r.poLineId ?? null,
memo: r.memo ?? "",
})),
};
const confirmInbound = async () => {
const res = await fetch("/api/inbounds/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const msg = await res.text().catch(() => "");
alert(`입고 확정 실패: ${res.status}\n${msg}`);
return;
}
const data = await res.json(); // { inboundId, inboundNo, lineCount }
alert(`입고 확정 완료: ${data.inboundNo}`);
};
입고이력 화면은 이미 제공한 코드처럼 GET /api/inbounds로 rows를 가져오고, 프론트에서 기간/키워드/창고/담당자 필터를 적용해 viewRows를 만들었다.
useEffect(() => {
fetch("/api/inbounds")
.then((res) => res.json())
.then((data) => setRows(data ?? []))
.finally(() => setLoading(false));
}, []);
useEffect에서 항상 fetch 하도록 유지fetch("/api/inbounds") 재호출setDraft(emptyFilter), setApplied(emptyFilter)로 초기화 옵션 제공qtyOrdered / unitPrice를 내려주는데 프론트가 qty를 다른 키로 읽거나 잘못 매핑하면 값이 0으로 떨어진다.lines: (data.lines ?? []).map((x: any) => ({
lineNo: toNumber(x.lineNo),
itemCode: x.itemCode ?? "",
itemName: x.itemName ?? "",
unit: x.unit ?? "",
qty: toNumber(x.qtyOrdered),
unitPrice: toNumber(x.unitPrice),
memo: x.memo ?? undefined,
}))
orderedQty, receivedQty, remainQty)과 프론트 타입/렌더링 키가 다름fmt()를 일관되게 사용발주수량 / 입고수량 / 발주잔량 3종 세트는 우선순위가 높다.이번 단계에서 가장 중요한 건 “입고 확정 = 재고/원장/발주상태까지 서버가 책임진다”는 기준을 고정한 것이다. 프론트는 화면/입력/표시에 집중하고, 정합성은 백엔드 트랜잭션으로 묶어서 시스템 신뢰도를 올렸다.