
이번 단계는 CoreERP에서 “업체(거래처, Vendor)”를 마스터 데이터로 확정하고, 프론트엔드(React)의 Vendor List 화면(모달 등록 방식)과 연결 가능한 백엔드 API 기준을 고정한 작업이다.
ERP에서 입고(Inbound), 발주(Purchase Order), 정산/거래 이력 등 대부분의 업무 데이터는 거래처(Vendor)를 참조하며, 결국 “거래처 마스터가 안정적으로 쌓이는 구조”가 선행되어야 업무 트랜잭션이 흔들리지 않는다.
특히 CoreERP 프론트는 “별도의 등록 페이지”가 아니라 “리스트 화면에서 모달로 등록”하는 UX를 채택했기 때문에, 백엔드는 화면 구조를 신경 쓰기보다 REST API가 일관되고 예측 가능하게 동작하도록 기준을 확정하는 것이 핵심이었다.
거래처명(vendorName)은 변경 가능하고 중복 가능하다. 반면 vendorCode는 시스템에서 거래처를 식별하는 기준 값이기 때문에 UNIQUE 제약을 둔다.
사업자번호는 조직/지점/법인 구조에 따라 “중복 가능” 케이스가 존재할 수 있다. 따라서 CoreERP는 bizNo에 UNIQUE를 걸지 않고, 조회 성능을 위한 인덱스만 유지하는 정책으로 확정했다.
Item과 동일하게 Vendor도 기본 상태값을 ACTIVE로 맞추어 “마스터 상태 관리” 기준을 일관되게 유지한다.
@Entity
@Table(
name = "vendor",
uniqueConstraints = {
@UniqueConstraint(name = "uk_vendor_code", columnNames = "vendor_code")
},
indexes = {
@Index(name = "idx_vendor_biz_no", columnList = "biz_no")
}
)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Vendor {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long vendorId;
@Column(nullable = false, length = 50)
private String vendorCode;
@Column(length = 30)
private String bizNo;
@Column(nullable = false, length = 200)
private String vendorName;
private String ceoName;
private String phone;
private String email;
private String address;
@Column(nullable = false, length = 30)
private String status;
@Lob
private String memo;
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(nullable = false)
private LocalDateTime updatedAt;
@PrePersist
void onCreate() {
LocalDateTime now = LocalDateTime.now();
this.createdAt = now;
this.updatedAt = now;
if (this.status == null || this.status.isBlank()) {
this.status = "ACTIVE";
}
}
@PreUpdate
void onUpdate() {
this.updatedAt = LocalDateTime.now();
}
}
public interface VendorRepository extends JpaRepository<Vendor, Long> {
boolean existsByVendorCode(String vendorCode);
}
@Transactional
public VendorResponse create(CreateVendorRequest req) {
if (req.vendorCode() == null || req.vendorCode().isBlank()) {
throw new IllegalArgumentException("vendorCode는 필수입니다.");
}
if (req.vendorName() == null || req.vendorName().isBlank()) {
throw new IllegalArgumentException("vendorName은 필수입니다.");
}
if (vendorRepository.existsByVendorCode(req.vendorCode())) {
throw new IllegalArgumentException("vendorCode가 이미 존재합니다.");
}
Vendor saved = vendorRepository.save(
Vendor.builder()
.vendorCode(req.vendorCode())
.bizNo(req.bizNo())
.vendorName(req.vendorName())
.ceoName(req.ceoName())
.phone(req.phone())
.email(req.email())
.address(req.address())
.status((req.status() == null || req.status().isBlank()) ? "ACTIVE" : req.status())
.memo(req.memo())
.build()
);
return toResponse(saved);
}
@RestController
@RequestMapping("/api/vendors")
@RequiredArgsConstructor
public class VendorController {
private final VendorService vendorService;
@PostMapping
public VendorResponse create(@RequestBody CreateVendorRequest req) {
return vendorService.create(req);
}
@GetMapping
public List<VendorResponse> list() {
return vendorService.list();
}
@GetMapping("/{id}")
public VendorResponse get(@PathVariable Long id) {
return vendorService.get(id);
}
}
{ "vendorCode": "VND-SEO-001",
"vendorName": "서울전자상사",
"ceoName": "홍길동",
"phone": "02-123-4567",
"email": "sales@seoul-elec.co.kr",
"bizNo": "123-45-67890",
"address": "서울시 강남구 테헤란로 123",
"status": "ACTIVE",
"memo": "초기 거래처" }
프론트의 Vendor List 화면은 이 목록 API를 기반으로 테이블을 렌더링하고, 모달 저장 성공 시 재조회로 최신 데이터를 즉시 반영할 수 있다.
이번 단계는 CoreERP의 “거래처 마스터”를 확정함으로써, 향후 입고/발주/정산 흐름이 참조할 기준점을 고정한 작업이다.
또한 프론트(모달 등록 방식)와 백엔드(REST API)의 역할을 분리하여, UI가 바뀌더라도 백엔드 계약이 흔들리지 않는 구조를 유지할 수 있게 되었다.
CoreERP는 현재 마스터(Item/Warehouse/Vendor)를 API 기준으로 안정화했고, 이제부터는 실제 업무 트랜잭션(입고/출고/발주/이동)을 연결하는 단계로 넘어간다.
다음 단계에서는 Inbound 확정을 통해 “재고 증가의 표준 흐름”을 완성하고, Inventory(스냅샷) + StockTx(원장) 기반의 ERP 재고 엔진을 본격적으로 확장할 예정이다.