- item_pages.source_table 컬럼 추가 (products, materials 매핑) - item_fields 내부용 매핑 컬럼 4개 (source_table, source_column, storage_type, json_path) - 모델 헬퍼 메서드 섹션 추가 (ItemPage, ItemField) - 저장 방식 판단 로직 다이어그램 추가
27 KiB
27 KiB
ItemMaster 연동 설계서
작성일: 2025-12-05 최종 수정: 2025-12-08 버전: 1.1 상태: Draft
1. 개요
1.1 목적
품목기준관리(ItemMaster)에서 정의한 필드와 실제 엔티티 데이터를 연동하여, 동적 필드 정의 및 값 저장을 가능하게 한다.
1.2 설계 원칙
- 기존 테이블 활용: 신규 테이블 추가 없이 기존
attributesJSON 컬럼 활용 - 범용성: 품목(products, materials) 외에도 다른 엔티티(orders, clients 등) 확장 가능
- 성능: JOIN 없이 단일 쿼리로 조회 가능
- 유연성: 테넌트/그룹별 다른 필드 구성 지원
2. 현재 구조
2.1 ItemMaster 테이블 구조
┌─────────────────────────────────────────────────────────────┐
│ item_pages (페이지 정의) │
├─────────────────────────────────────────────────────────────┤
│ id, tenant_id, page_name, item_type, source_table, │
│ is_active │
│ │
│ item_type: FG(완제품), PT(반제품), SM(부자재), │
│ RM(원자재), CS(소모품) │
│ │
│ source_table: 실제 저장 테이블명 │
│ - 'products' (FG, PT) │
│ - 'materials' (SM, RM, CS) │
└─────────────────────────────────────────────────────────────┘
│
│ 1:N
▼
┌─────────────────────────────────────────────────────────────┐
│ item_sections (섹션 정의) │
├─────────────────────────────────────────────────────────────┤
│ id, tenant_id, page_id, title, type, order_no │
│ │
│ type: fields(필드형), bom(BOM형) │
└─────────────────────────────────────────────────────────────┘
│
│ 1:N
▼
┌─────────────────────────────────────────────────────────────┐
│ item_fields (필드 정의) │
├─────────────────────────────────────────────────────────────┤
│ id, tenant_id, group_id, section_id (nullable) │
│ field_key ← attributes JSON 키와 매핑 │
│ field_name ← 화면 표시명 │
│ field_type ← textbox, number, dropdown, checkbox... │
│ is_required ← 필수 여부 │
│ default_value ← 기본값 │
│ placeholder ← 입력 힌트 │
│ validation_rules ← 검증 규칙 JSON │
│ options ← 선택 옵션 JSON │
│ properties ← 추가 속성 JSON │
│ category ← 필드 카테고리 │
│ is_common ← 공통 필드 여부 │
│ is_active ← 활성 여부 │
│ │
│ [내부용 매핑 컬럼 - API 응답에서 hidden] │
│ source_table ← 원본 테이블명 (products, materials 등) │
│ source_column ← 원본 컬럼명 (code, name 등) │
│ storage_type ← 저장방식 (column=DB컬럼, json=JSON) │
│ json_path ← JSON 저장 경로 (예: attributes.size) │
└─────────────────────────────────────────────────────────────┘
2.2 엔티티 테이블 구조
┌─────────────────────────────────────────────────────────────┐
│ products │
├─────────────────────────────────────────────────────────────┤
│ [고정 필드] │
│ id, tenant_id, code, name, unit, category_id │
│ product_type, is_active, is_sellable, is_purchasable... │
│ │
│ [동적 필드] │
│ attributes JSON ← ItemMaster 필드 값 저장 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ materials │
├─────────────────────────────────────────────────────────────┤
│ [고정 필드] │
│ id, tenant_id, material_code, name, unit, category_id │
│ material_type, is_active... │
│ │
│ [동적 필드] │
│ attributes JSON ← ItemMaster 필드 값 저장 │
│ options JSON ← 추가 옵션 저장 │
└─────────────────────────────────────────────────────────────┘
3. 연동 설계
3.1 매핑 규칙
ItemMaster Entity.attributes
┌──────────────────────┐ ┌──────────────────────┐
│ group_id: 1 │ │ │
│ field_key: "color" │ ◀═══매핑═══▶ │ {"color": "빨강"} │
│ field_key: "weight" │ ◀═══매핑═══▶ │ {"weight": 1.5} │
│ field_key: "spec" │ ◀═══매핑═══▶ │ {"spec": "10x20"} │
└──────────────────────┘ └──────────────────────┘
핵심: item_fields.field_key = attributes JSON의 key
3.2 Group ID 정의
| group_id | 엔티티 | 대상 테이블 | 비고 |
|---|---|---|---|
| 1 | 품목-제품 | products | product_type: FG, PT |
| 2 | 품목-자재 | materials | material_type: SM, RM, CS |
| 3 | 주문 | orders | 향후 확장 |
| 4 | 고객 | clients | 향후 확장 |
| ... | ... | ... | 필요 시 추가 |
참고: group_id는
common_codes테이블에서 관리하거나, 별도 enum으로 정의 가능
3.3 데이터 흐름
┌─────────────────────────────────────────────────────────────┐
│ 1. 관리자: ItemMaster에서 필드 정의 │
├─────────────────────────────────────────────────────────────┤
│ │
│ POST /api/v1/item-master/fields │
│ { │
│ "group_id": 1, │
│ "field_key": "color", │
│ "field_name": "색상", │
│ "field_type": "dropdown", │
│ "is_required": true, │
│ "options": [ │
│ {"label": "빨강", "value": "red"}, │
│ {"label": "파랑", "value": "blue"} │
│ ] │
│ } │
│ │
│ → item_fields 테이블에 저장 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 2. 사용자: 품목 등록 화면 진입 │
├─────────────────────────────────────────────────────────────┤
│ │
│ GET /api/v1/item-master/fields?group_id=1 │
│ │
│ → 정의된 필드 목록 반환 │
│ → 프론트엔드가 동적 폼 렌더링 │
│ │
│ ┌────────────────────────────────────┐ │
│ │ [색상 ▼] ← dropdown으로 표시 │ │
│ │ 빨강 │ │
│ │ 파랑 │ │
│ └────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 3. 사용자: 품목 저장 │
├─────────────────────────────────────────────────────────────┤
│ │
│ POST /api/v1/products │
│ { │
│ "code": "P-001", ← 고정 필드 │
│ "name": "티셔츠", │
│ "unit": "EA", │
│ "product_type": "FG", │
│ "attributes": { ← 동적 필드 │
│ "color": "red", (field_key: value) │
│ "size": "XL" │
│ } │
│ } │
│ │
│ → products 테이블에 저장 │
│ → attributes JSON에 동적 필드 값 포함 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 4. 사용자: 품목 조회 │
├─────────────────────────────────────────────────────────────┤
│ │
│ GET /api/v1/products/1 │
│ │
│ { │
│ "id": 1, │
│ "code": "P-001", │
│ "name": "티셔츠", │
│ "attributes": { │
│ "color": "red", │
│ "size": "XL" │
│ } │
│ } │
│ │
│ → JOIN 없이 한 번에 조회! │
└─────────────────────────────────────────────────────────────┘
4. API 설계
4.1 ItemMaster API (기존)
| Method | Endpoint | 설명 |
|---|---|---|
| GET | /api/v1/item-master/fields |
필드 목록 조회 |
| GET | /api/v1/item-master/fields/{id} |
필드 상세 조회 |
| POST | /api/v1/item-master/fields |
필드 생성 |
| PUT | /api/v1/item-master/fields/{id} |
필드 수정 |
| DELETE | /api/v1/item-master/fields/{id} |
필드 삭제 |
필터 파라미터:
group_id: 엔티티 그룹 필터section_id: 섹션 필터is_active: 활성 필터is_common: 공통 필드 필터
4.2 엔티티 API 수정
4.2.1 Products API
저장 시 attributes 포함:
POST /api/v1/products
{
"code": "P-001",
"name": "제품명",
"unit": "EA",
"product_type": "FG",
"attributes": {
"color": "red",
"weight": 1.5,
"custom_field": "value"
}
}
조회 시 필드 메타데이터 포함 (선택):
GET /api/v1/products/1?include_field_meta=true
{
"id": 1,
"code": "P-001",
"name": "제품명",
"attributes": {
"color": "red",
"weight": 1.5
},
"field_meta": [
{
"field_key": "color",
"field_name": "색상",
"field_type": "dropdown",
"value": "red",
"options": [...]
},
{
"field_key": "weight",
"field_name": "중량",
"field_type": "number",
"value": 1.5
}
]
}
5. 검증 로직
5.1 저장 시 검증 흐름
class ItemFieldValidationService
{
/**
* attributes 값을 ItemMaster 기준으로 검증
*/
public function validate(int $groupId, array $attributes): array
{
$errors = [];
// 1. 해당 그룹의 필드 정의 조회
$fields = ItemField::where('group_id', $groupId)
->where('is_active', true)
->get()
->keyBy('field_key');
// 2. 필수 필드 체크
foreach ($fields->where('is_required', true) as $field) {
if (!isset($attributes[$field->field_key])) {
$errors[$field->field_key] = "{$field->field_name}은(는) 필수입니다.";
}
}
// 3. 타입별 검증
foreach ($attributes as $key => $value) {
if (!$fields->has($key)) {
continue; // 정의되지 않은 필드는 스킵 (또는 에러)
}
$field = $fields->get($key);
$fieldError = $this->validateFieldValue($field, $value);
if ($fieldError) {
$errors[$key] = $fieldError;
}
}
return $errors;
}
/**
* 필드 타입별 값 검증
*/
private function validateFieldValue(ItemField $field, mixed $value): ?string
{
return match($field->field_type) {
'number' => $this->validateNumber($field, $value),
'dropdown' => $this->validateDropdown($field, $value),
'date' => $this->validateDate($field, $value),
'checkbox' => $this->validateCheckbox($field, $value),
default => null
};
}
private function validateNumber(ItemField $field, mixed $value): ?string
{
if (!is_numeric($value)) {
return "{$field->field_name}은(는) 숫자여야 합니다.";
}
$rules = $field->validation_rules ?? [];
if (isset($rules['min']) && $value < $rules['min']) {
return "{$field->field_name}은(는) {$rules['min']} 이상이어야 합니다.";
}
if (isset($rules['max']) && $value > $rules['max']) {
return "{$field->field_name}은(는) {$rules['max']} 이하여야 합니다.";
}
return null;
}
private function validateDropdown(ItemField $field, mixed $value): ?string
{
$options = $field->options ?? [];
$validValues = array_column($options, 'value');
if (!in_array($value, $validValues)) {
return "{$field->field_name}의 값이 유효하지 않습니다.";
}
return null;
}
}
5.2 Controller에서 사용
class ProductsController extends Controller
{
public function store(ProductStoreRequest $request)
{
$validated = $request->validated();
// attributes 검증 (선택적)
if (isset($validated['attributes'])) {
$groupId = 1; // 품목-제품 그룹
$errors = $this->fieldValidationService->validate(
$groupId,
$validated['attributes']
);
if (!empty($errors)) {
return ApiResponse::error('검증 실패', $errors, 422);
}
}
$product = $this->productService->create($validated);
return ApiResponse::success($product, __('message.created'));
}
}
6. 프론트엔드 연동
6.1 동적 폼 렌더링 흐름
1. 페이지 로드 시
GET /api/v1/item-master/fields?group_id=1
2. 필드 정의 기반 폼 컴포넌트 렌더링
field_type: textbox → <Input />
field_type: number → <InputNumber />
field_type: dropdown → <Select options={field.options} />
field_type: checkbox → <Checkbox />
field_type: date → <DatePicker />
field_type: textarea → <Textarea />
3. 저장 시 attributes 객체 구성
{
[field_key]: value,
[field_key]: value,
...
}
6.2 React 컴포넌트 예시
interface ItemField {
id: number;
field_key: string;
field_name: string;
field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
is_required: boolean;
default_value?: string;
placeholder?: string;
options?: Array<{ label: string; value: string }>;
validation_rules?: Record<string, any>;
}
function DynamicFieldRenderer({ field, value, onChange }: Props) {
switch (field.field_type) {
case 'textbox':
return (
<Input
value={value}
onChange={(e) => onChange(field.field_key, e.target.value)}
placeholder={field.placeholder}
required={field.is_required}
/>
);
case 'number':
return (
<InputNumber
value={value}
onChange={(val) => onChange(field.field_key, val)}
min={field.validation_rules?.min}
max={field.validation_rules?.max}
required={field.is_required}
/>
);
case 'dropdown':
return (
<Select
value={value}
onChange={(val) => onChange(field.field_key, val)}
options={field.options}
required={field.is_required}
/>
);
// ... 기타 타입
}
}
function ProductForm() {
const [fields, setFields] = useState<ItemField[]>([]);
const [attributes, setAttributes] = useState<Record<string, any>>({});
useEffect(() => {
// 필드 정의 로드
fetch('/api/v1/item-master/fields?group_id=1')
.then(res => res.json())
.then(data => setFields(data.data));
}, []);
const handleFieldChange = (key: string, value: any) => {
setAttributes(prev => ({ ...prev, [key]: value }));
};
return (
<form>
{/* 고정 필드 */}
<Input name="code" label="품목코드" required />
<Input name="name" label="품목명" required />
{/* 동적 필드 */}
{fields.map(field => (
<DynamicFieldRenderer
key={field.id}
field={field}
value={attributes[field.field_key]}
onChange={handleFieldChange}
/>
))}
</form>
);
}
7. 확장 가이드
7.1 새 엔티티 추가 시
- group_id 정의: 새 그룹 ID 할당
- 테이블 확인:
attributesJSON 컬럼 존재 확인 (없으면 추가) - ItemMaster 필드 정의: 해당 group_id로 필드 생성
- API 수정: 저장/조회 시 attributes 처리 로직 추가
7.2 예시: 주문(orders) 연동
-- 1. orders 테이블에 attributes 컬럼 추가 (없는 경우)
ALTER TABLE orders ADD COLUMN attributes JSON DEFAULT NULL COMMENT '동적 필드';
-- 2. ItemMaster에 주문용 필드 정의
INSERT INTO item_fields (tenant_id, group_id, field_key, field_name, field_type, ...)
VALUES (1, 3, 'urgency', '긴급도', 'dropdown', ...);
8. 모델 헬퍼 메서드
8.1 ItemPage 모델
class ItemPage extends Model
{
/**
* source_table에 해당하는 모델 클래스명 반환
*/
public function getTargetModelClass(): ?string
{
$mapping = [
'products' => \App\Models\Product::class,
'materials' => \App\Models\Material::class,
];
return $mapping[$this->source_table] ?? null;
}
/**
* 제품 페이지인지 확인
*/
public function isProductPage(): bool
{
return $this->source_table === 'products';
}
/**
* 자재 페이지인지 확인
*/
public function isMaterialPage(): bool
{
return $this->source_table === 'materials';
}
}
8.2 ItemField 모델
class ItemField extends Model
{
/**
* 시스템 필드 여부 확인 (DB 컬럼과 매핑된 필드)
*/
public function isSystemField(): bool
{
return !is_null($this->source_table) && !is_null($this->source_column);
}
/**
* 컬럼 저장 방식 여부 확인
*/
public function isColumnStorage(): bool
{
return $this->storage_type === 'column';
}
/**
* JSON 저장 방식 여부 확인
*/
public function isJsonStorage(): bool
{
return $this->storage_type === 'json';
}
}
8.3 필드 저장 방식 판단
┌─────────────────────────────────────────────────────────────┐
│ storage_type 판단 로직 │
├─────────────────────────────────────────────────────────────┤
│ │
│ if (source_table && source_column) { │
│ // 시스템 필드 (기존 DB 컬럼과 매핑) │
│ if (storage_type === 'column') { │
│ → products.{source_column} 또는 │
│ materials.{source_column} 에서 직접 읽기/쓰기 │
│ } else if (storage_type === 'json') { │
│ → {json_path} 경로로 JSON 내 읽기/쓰기 │
│ } │
│ } else { │
│ // 커스텀 필드 (동적 정의) │
│ → attributes.{field_key} 에 저장 │
│ } │
│ │
└─────────────────────────────────────────────────────────────┘
9. 구현 계획
| 순서 | 작업 | 담당 | 예상 공수 |
|---|---|---|---|
| 1 | group_id 코드 정의 | BE | 0.5일 |
| 2 | ItemFieldValidationService 구현 | BE | 1일 |
| 3 | ProductsController 수정 (검증 연동) | BE | 0.5일 |
| 4 | MaterialsController 수정 (검증 연동) | BE | 0.5일 |
| 5 | API 응답에 field_meta 포함 옵션 | BE | 0.5일 |
| 6 | DynamicFieldRenderer 컴포넌트 | FE | 2일 |
| 7 | 품목 등록/수정 폼 연동 | FE | 1일 |
| 8 | 테스트 및 QA | 공통 | 1일 |
총 예상 공수: 7일
10. 변경 이력
| 날짜 | 버전 | 변경 내용 | 작성자 |
|---|---|---|---|
| 2025-12-05 | 1.0 | 최초 작성 | - |
| 2025-12-08 | 1.1 | source_table/source_column 매핑 컬럼 추가, 모델 헬퍼 메서드 문서화 | - |