- BOM 항목 추가/수정/삭제 시 섹션탭 즉시 반영 - 섹션 복제 시 UI 즉시 업데이트 (null vs undefined 이슈 해결) - 항목 수정 기능 추가 (useTemplateManagement) - 실시간 동기화 문서 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
44 KiB
ㅓ# 품목관리 동적 화면 생성 API 명세서
작성일: 2025-11-24 프로젝트: SAM MES System 기준 문서: api_rules.md, architecture.md, swagger_guide.md
목차
개요
목적
품목기준관리에서 정의한 메타데이터를 기반으로 품목관리 화면을 동적으로 생성하기 위한 API 시스템 구축
핵심 요구사항
- 품목기준관리 메타데이터 조회 API
- 동적 필드 구조 기반 품목 CRUD API
- Multi-tenant 데이터 격리
- 필드 타입별 유효성 검증
- 검색/필터/정렬 동적 지원
데이터 흐름
품목기준관리 (메타데이터 정의)
↓ 저장
Laravel DB (pages, sections, fields)
↓ API 조회
Next.js 프론트엔드
↓ 동적 렌더링
품목관리 화면 (폼/테이블 자동 생성)
아키텍처 설계
Service-First 패턴 적용
ItemMetadataService:
- 메타데이터 조회 로직
- 캐싱 전략
- 테넌트별 격리
ItemService:
- 동적 필드 기반 CRUD
- 유효성 검증 (메타데이터 기반)
- 검색/필터/정렬
Multi-Tenancy
테넌트 격리 방식:
- JWT 토큰에서
tenant_id자동 추출 BelongsToTenantTrait 적용- Global Scope 자동 필터링
// ✅ Service에서 컨텍스트 강제
public function getMetadata(): array {
$this->tenantId(); // 테넌트 ID 필수 확인
$this->apiUserId(); // API 사용자 ID 확인
// 메타데이터 조회 로직...
}
데이터베이스 스키마
1. item_master_pages (페이지/탭)
CREATE TABLE item_master_pages (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '테넌트 ID',
page_name VARCHAR(100) NOT NULL COMMENT '페이지명 (예: 기본정보, 추가정보)',
display_order INT NOT NULL DEFAULT 0 COMMENT '표시 순서',
is_active BOOLEAN NOT NULL DEFAULT TRUE COMMENT '활성화 여부',
created_by BIGINT UNSIGNED COMMENT '생성자 ID',
updated_by BIGINT UNSIGNED COMMENT '수정자 ID',
deleted_by BIGINT UNSIGNED COMMENT '삭제자 ID',
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL,
UNIQUE KEY uk_tenant_page (tenant_id, page_name, deleted_at),
INDEX idx_tenant_order (tenant_id, display_order),
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
) COMMENT='품목 마스터 페이지 (탭) 정의';
2. item_master_sections (섹션)
CREATE TABLE item_master_sections (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '테넌트 ID',
page_id BIGINT UNSIGNED NOT NULL COMMENT '페이지 ID',
section_name VARCHAR(100) NOT NULL COMMENT '섹션명',
display_order INT NOT NULL DEFAULT 0 COMMENT '표시 순서',
is_active BOOLEAN NOT NULL DEFAULT TRUE COMMENT '활성화 여부',
created_by BIGINT UNSIGNED COMMENT '생성자 ID',
updated_by BIGINT UNSIGNED COMMENT '수정자 ID',
deleted_by BIGINT UNSIGNED COMMENT '삭제자 ID',
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL,
INDEX idx_tenant_page (tenant_id, page_id),
INDEX idx_display_order (page_id, display_order),
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (page_id) REFERENCES item_master_pages(id) ON DELETE CASCADE
) COMMENT='품목 마스터 섹션 정의';
3. item_master_fields (필드)
CREATE TABLE item_master_fields (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '테넌트 ID',
section_id BIGINT UNSIGNED NOT NULL COMMENT '섹션 ID',
field_name VARCHAR(100) NOT NULL COMMENT '필드명',
field_label VARCHAR(100) NOT NULL COMMENT '필드 레이블',
field_type ENUM('text', 'number', 'date', 'select', 'textarea', 'checkbox', 'file') NOT NULL COMMENT '필드 타입',
is_required BOOLEAN NOT NULL DEFAULT FALSE COMMENT '필수 입력 여부',
is_searchable BOOLEAN NOT NULL DEFAULT TRUE COMMENT '검색 가능 여부',
is_filterable BOOLEAN NOT NULL DEFAULT TRUE COMMENT '필터 가능 여부',
is_sortable BOOLEAN NOT NULL DEFAULT TRUE COMMENT '정렬 가능 여부',
show_in_list BOOLEAN NOT NULL DEFAULT TRUE COMMENT '목록에 표시 여부',
list_order INT NULL COMMENT '목록 표시 순서',
column_width VARCHAR(20) NULL COMMENT '컬럼 너비 (예: 150px)',
display_order INT NOT NULL DEFAULT 0 COMMENT '표시 순서',
validation_rules JSON NULL COMMENT '유효성 검증 규칙 (JSON)',
default_value VARCHAR(255) NULL COMMENT '기본값',
options JSON NULL COMMENT 'select 옵션 (JSON)',
data_source JSON NULL COMMENT '동적 데이터 소스 (JSON)',
help_text VARCHAR(500) NULL COMMENT '도움말 텍스트',
placeholder VARCHAR(100) NULL COMMENT 'placeholder',
is_active BOOLEAN NOT NULL DEFAULT TRUE COMMENT '활성화 여부',
created_by BIGINT UNSIGNED COMMENT '생성자 ID',
updated_by BIGINT UNSIGNED COMMENT '수정자 ID',
deleted_by BIGINT UNSIGNED COMMENT '삭제자 ID',
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL,
INDEX idx_tenant_section (tenant_id, section_id),
INDEX idx_display_order (section_id, display_order),
INDEX idx_searchable (tenant_id, is_searchable),
INDEX idx_list_display (tenant_id, show_in_list, list_order),
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (section_id) REFERENCES item_master_sections(id) ON DELETE CASCADE
) COMMENT='품목 마스터 필드 정의';
4. items (품목 데이터)
CREATE TABLE items (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '테넌트 ID',
item_code VARCHAR(50) NOT NULL COMMENT '품목코드',
item_name VARCHAR(200) NOT NULL COMMENT '품목명',
dynamic_fields JSON NULL COMMENT '동적 필드 데이터 (JSON)',
is_active BOOLEAN NOT NULL DEFAULT TRUE COMMENT '활성화 여부',
created_by BIGINT UNSIGNED COMMENT '생성자 ID',
updated_by BIGINT UNSIGNED COMMENT '수정자 ID',
deleted_by BIGINT UNSIGNED COMMENT '삭제자 ID',
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL,
UNIQUE KEY uk_tenant_code (tenant_id, item_code, deleted_at),
INDEX idx_tenant_name (tenant_id, item_name),
INDEX idx_tenant_active (tenant_id, is_active),
FULLTEXT KEY ft_search (item_code, item_name),
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
) COMMENT='품목 마스터 데이터' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
validation_rules JSON 구조 예시
{
"min_length": 5,
"max_length": 20,
"pattern": "^[A-Z0-9-]+$",
"error_message": "영문 대문자, 숫자, 하이픈만 가능",
"min": 0,
"max": 999999,
"decimal_places": 2,
"min_date": "today",
"max_date": "2025-12-31",
"max_size": 5242880,
"allowed_extensions": ["jpg", "png", "pdf"]
}
data_source JSON 구조 예시
{
"type": "api",
"endpoint": "/api/v1/master/units",
"value_field": "unit_code",
"label_field": "unit_name"
}
또는
{
"type": "master_table",
"table": "suppliers",
"value_field": "id",
"label_field": "company_name"
}
API 엔드포인트 명세
1. 메타데이터 조회
GET /api/v1/item-master/config
목적: 품목기준관리에서 정의한 화면 구조 메타데이터 조회
인증: auth.apikey + auth:sanctum
Request:
- 없음 (JWT에서 tenant_id 자동 추출)
Response (200 OK):
{
"success": true,
"message": "message.fetched",
"data": {
"pages": [
{
"id": 1,
"page_name": "기본정보",
"display_order": 1,
"is_active": true,
"sections": [
{
"id": 1,
"section_name": "품목코드 정보",
"display_order": 1,
"is_active": true,
"fields": [
{
"id": 1,
"field_name": "item_code",
"field_label": "품목코드",
"field_type": "text",
"is_required": true,
"is_searchable": true,
"is_filterable": true,
"is_sortable": true,
"show_in_list": true,
"list_order": 1,
"column_width": "150px",
"display_order": 1,
"validation_rules": {
"min_length": 5,
"max_length": 20,
"pattern": "^[A-Z0-9-]+$"
},
"default_value": null,
"options": null,
"data_source": null,
"help_text": "영문 대문자, 숫자, 하이픈 조합",
"placeholder": "예: ITEM-001"
},
{
"id": 2,
"field_name": "item_name",
"field_label": "품목명",
"field_type": "text",
"is_required": true,
"show_in_list": true,
"list_order": 2,
"display_order": 2
},
{
"id": 3,
"field_name": "unit",
"field_label": "단위",
"field_type": "select",
"is_required": true,
"options": ["EA", "BOX", "KG", "M"],
"default_value": "EA",
"show_in_list": true,
"list_order": 3,
"display_order": 3
}
]
}
]
},
{
"id": 2,
"page_name": "추가정보",
"display_order": 2,
"is_active": true,
"sections": [...]
}
],
"bom": {
"enabled": true,
"structure": "single_level"
}
}
}
Error Responses:
401 Unauthorized: 인증 실패403 Forbidden: 권한 없음404 Not Found: 설정된 메타데이터 없음
2. 품목 목록 조회
GET /api/v1/items
목적: 동적 필드 포함 품목 목록 조회 (페이지네이션)
인증: auth.apikey + auth:sanctum
Query Parameters:
{
page?: number; // 페이지 번호 (default: 1)
per_page?: number; // 페이지당 항목 수 (default: 20)
sort_by?: string; // 정렬 필드명 (예: item_code, created_at)
sort_order?: 'asc'|'desc'; // 정렬 순서 (default: desc)
search?: string; // 검색어 (item_code, item_name 검색)
is_active?: boolean; // 활성 상태 필터
// 동적 필드 필터 (메타데이터 기반)
[field_name]?: string; // 예: unit=EA, category=전자부품
}
Request Example:
GET /api/v1/items?page=1&per_page=20&sort_by=created_at&sort_order=desc&search=ITEM&unit=EA
Response (200 OK):
{
"success": true,
"message": "message.fetched",
"data": {
"data": [
{
"id": 1,
"item_code": "ITEM-001",
"item_name": "제품A",
"is_active": true,
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:00:00Z",
// 동적 필드들 (메타데이터에서 정의된 필드만)
"unit": "EA",
"category": "전자부품",
"spec": "100x100",
"material": "ABS"
}
],
"pagination": {
"total": 100,
"per_page": 20,
"current_page": 1,
"last_page": 5,
"from": 1,
"to": 20
}
}
}
3. 품목 상세 조회
GET /api/v1/items/{id}
목적: 특정 품목의 모든 필드 데이터 조회
인증: auth.apikey + auth:sanctum
Path Parameters:
id(required): 품목 ID
Response (200 OK):
{
"success": true,
"message": "message.fetched",
"data": {
"id": 1,
"item_code": "ITEM-001",
"item_name": "제품A",
"is_active": true,
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:00:00Z",
"created_by": {
"id": 1,
"name": "홍길동"
},
"updated_by": {
"id": 1,
"name": "홍길동"
},
// 동적 필드들 (모든 페이지의 모든 필드)
"unit": "EA",
"category": "전자부품",
"spec": "100x100",
"material": "ABS",
"color": "검정",
"weight": 150,
"price": 10000,
// BOM 데이터 (활성화된 경우)
"bom": [
{
"id": 1,
"child_item_code": "ITEM-002",
"child_item_name": "부품A",
"quantity": 2,
"unit": "EA"
}
]
}
}
Error Responses:
404 Not Found: 품목이 존재하지 않음403 Forbidden: 다른 테넌트의 품목 접근 시도
4. 품목 생성
POST /api/v1/items
목적: 새 품목 생성 (동적 필드 포함)
인증: auth.apikey + auth:sanctum
Request Body:
{
"item_code": "ITEM-001",
"item_name": "제품A",
// 동적 필드들 (메타데이터에 정의된 필드만 허용)
"unit": "EA",
"category": "전자부품",
"spec": "100x100",
"material": "ABS",
"color": "검정",
"weight": 150,
"price": 10000
}
Validation:
- 메타데이터 기반 자동 검증
is_required=true필드 필수 체크validation_rules에 따른 검증 (pattern, min, max 등)- 존재하지 않는 필드는 무시
Response (201 Created):
{
"success": true,
"message": "message.created",
"data": {
"id": 1,
"item_code": "ITEM-001",
"item_name": "제품A",
"unit": "EA",
// ... 모든 필드
"created_at": "2025-01-01T00:00:00Z"
}
}
Error Responses:
422 Unprocessable Entity: 유효성 검증 실패{ "success": false, "message": "error.validation_failed", "errors": { "item_code": ["품목코드는 5자 이상 20자 이하여야 합니다."], "unit": ["단위는 필수 항목입니다."] } }
5. 품목 수정
PUT /api/v1/items/{id}
목적: 기존 품목 수정 (동적 필드 포함)
인증: auth.apikey + auth:sanctum
Path Parameters:
id(required): 품목 ID
Request Body:
{
"item_name": "제품A (수정됨)",
"spec": "120x120",
"price": 12000
}
Validation:
- 메타데이터 기반 자동 검증
- 제공된 필드만 업데이트 (부분 업데이트 지원)
Response (200 OK):
{
"success": true,
"message": "message.updated",
"data": {
"id": 1,
"item_code": "ITEM-001",
"item_name": "제품A (수정됨)",
"spec": "120x120",
"price": 12000,
// ... 모든 필드
"updated_at": "2025-01-02T00:00:00Z"
}
}
Error Responses:
404 Not Found: 품목이 존재하지 않음422 Unprocessable Entity: 유효성 검증 실패403 Forbidden: 수정 권한 없음
6. 품목 삭제
DELETE /api/v1/items/{id}
목적: 품목 삭제 (Soft Delete)
인증: auth.apikey + auth:sanctum
Path Parameters:
id(required): 품목 ID
Response (200 OK):
{
"success": true,
"message": "message.deleted",
"data": null
}
Error Responses:
404 Not Found: 품목이 존재하지 않음403 Forbidden: 삭제 권한 없음
데이터 구조
메타데이터 캐싱 전략
Redis 캐시 키 패턴:
item_metadata:{tenant_id}
캐시 만료 시간: 1시간
캐시 무효화 시점:
- 품목기준관리에서 메타데이터 변경 시
- 수동 캐시 클리어 API 호출 시
// ItemMetadataService.php
public function getMetadata(): array {
$tenantId = $this->tenantId();
$cacheKey = "item_metadata:{$tenantId}";
return Cache::remember($cacheKey, 3600, function() use ($tenantId) {
// DB에서 메타데이터 조회
return $this->buildMetadataStructure($tenantId);
});
}
동적 필드 저장 방식
Option 1: JSON 컬럼 (권장)
-- items 테이블
dynamic_fields JSON NULL COMMENT '동적 필드 데이터'
-- 저장 예시
{
"unit": "EA",
"category": "전자부품",
"spec": "100x100",
"material": "ABS",
"color": "검정",
"weight": 150,
"price": 10000
}
장점:
- 스키마 변경 불필요
- 메타데이터 변경에 유연 대응
- JSON 쿼리 지원 (MySQL 5.7+)
단점:
- 인덱스 제한적
- 복잡한 검색 쿼리 성능 저하 가능
Option 2: EAV 모델 (확장 가능)
CREATE TABLE item_field_values (
id BIGINT UNSIGNED PRIMARY KEY,
tenant_id BIGINT UNSIGNED,
item_id BIGINT UNSIGNED,
field_id BIGINT UNSIGNED,
value TEXT,
FOREIGN KEY (item_id) REFERENCES items(id),
FOREIGN KEY (field_id) REFERENCES item_master_fields(id)
);
장점:
- 정규화된 구조
- 필드별 인덱스 가능
단점:
- 복잡한 조인 쿼리
- 성능 오버헤드
권장: Option 1 (JSON 컬럼) 사용 (Laravel Eloquent JSON Cast 지원)
구현 가이드
Controller 구현
ItemController.php:
<?php
namespace App\Http\Controllers\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\Item\{IndexItemRequest, StoreItemRequest, UpdateItemRequest};
use App\Services\ItemService;
use App\Utils\ApiResponse;
class ItemController extends Controller
{
/**
* 품목 목록 조회
*/
public function index(IndexItemRequest $request, ItemService $service)
{
return ApiResponse::handle(fn() =>
$service->getList($request->validated())
);
}
/**
* 품목 상세 조회
*/
public function show(int $id, ItemService $service)
{
return ApiResponse::handle(fn() =>
$service->getById($id)
);
}
/**
* 품목 생성
*/
public function store(StoreItemRequest $request, ItemService $service)
{
return ApiResponse::handle(fn() =>
$service->create($request->validated()),
201
);
}
/**
* 품목 수정
*/
public function update(int $id, UpdateItemRequest $request, ItemService $service)
{
return ApiResponse::handle(fn() =>
$service->update($id, $request->validated())
);
}
/**
* 품목 삭제
*/
public function destroy(int $id, ItemService $service)
{
return ApiResponse::handle(fn() =>
$service->delete($id)
);
}
}
Service 구현
ItemMetadataService.php:
<?php
namespace App\Services;
use App\Models\ItemMasterPage;
use Illuminate\Support\Facades\Cache;
class ItemMetadataService extends Service
{
/**
* 메타데이터 조회 (캐시 포함)
*/
public function getMetadata(): array
{
$tenantId = $this->tenantId();
$cacheKey = "item_metadata:{$tenantId}";
return Cache::remember($cacheKey, 3600, function() {
$pages = ItemMasterPage::with([
'sections' => function($query) {
$query->where('is_active', true)
->orderBy('display_order');
},
'sections.fields' => function($query) {
$query->where('is_active', true)
->orderBy('display_order');
}
])
->where('is_active', true)
->orderBy('display_order')
->get();
return [
'pages' => $pages->toArray(),
'bom' => $this->getBomConfig()
];
});
}
/**
* 캐시 무효화
*/
public function clearCache(): void
{
$tenantId = $this->tenantId();
Cache::forget("item_metadata:{$tenantId}");
}
/**
* BOM 설정 조회
*/
protected function getBomConfig(): array
{
// BOM 설정 조회 로직
return [
'enabled' => true,
'structure' => 'single_level'
];
}
}
ItemService.php:
<?php
namespace App\Services;
use App\Models\Item;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class ItemService extends Service
{
protected ItemMetadataService $metadataService;
public function __construct(ItemMetadataService $metadataService)
{
$this->metadataService = $metadataService;
}
/**
* 품목 목록 조회
*/
public function getList(array $params): LengthAwarePaginator
{
$this->tenantId();
$query = Item::query();
// 검색
if (!empty($params['search'])) {
$search = $params['search'];
$query->where(function($q) use ($search) {
$q->where('item_code', 'like', "%{$search}%")
->orWhere('item_name', 'like', "%{$search}%");
});
}
// 활성 상태 필터
if (isset($params['is_active'])) {
$query->where('is_active', $params['is_active']);
}
// 동적 필드 필터 (JSON 쿼리)
$metadata = $this->metadataService->getMetadata();
$filterableFields = $this->getFilterableFields($metadata);
foreach ($filterableFields as $fieldName) {
if (isset($params[$fieldName])) {
$query->whereJsonContains("dynamic_fields->{$fieldName}", $params[$fieldName]);
}
}
// 정렬
$sortBy = $params['sort_by'] ?? 'created_at';
$sortOrder = $params['sort_order'] ?? 'desc';
if (in_array($sortBy, ['item_code', 'item_name', 'created_at', 'updated_at'])) {
$query->orderBy($sortBy, $sortOrder);
} else {
// 동적 필드 정렬 (JSON 경로)
$query->orderBy("dynamic_fields->{$sortBy}", $sortOrder);
}
$perPage = $params['per_page'] ?? 20;
return $query->paginate($perPage);
}
/**
* 품목 상세 조회
*/
public function getById(int $id): Item
{
$this->tenantId();
$item = Item::with(['creator', 'updater'])->findOrFail($id);
// 동적 필드 병합
$item->setAttribute('dynamic_fields_decoded', $item->dynamic_fields);
return $item;
}
/**
* 품목 생성
*/
public function create(array $data): Item
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 메타데이터 기반 유효성 검증
$this->validateDynamicFields($data);
// 고정 필드와 동적 필드 분리
$fixedFields = [
'tenant_id' => $tenantId,
'item_code' => $data['item_code'],
'item_name' => $data['item_name'],
'created_by' => $userId,
];
$dynamicFields = $this->extractDynamicFields($data);
$item = Item::create([
...$fixedFields,
'dynamic_fields' => $dynamicFields
]);
return $item;
}
/**
* 품목 수정
*/
public function update(int $id, array $data): Item
{
$this->tenantId();
$userId = $this->apiUserId();
$item = Item::findOrFail($id);
// 메타데이터 기반 유효성 검증
$this->validateDynamicFields($data);
// 고정 필드 업데이트
if (isset($data['item_code'])) {
$item->item_code = $data['item_code'];
}
if (isset($data['item_name'])) {
$item->item_name = $data['item_name'];
}
// 동적 필드 업데이트 (병합)
$currentDynamicFields = $item->dynamic_fields ?? [];
$newDynamicFields = $this->extractDynamicFields($data);
$item->dynamic_fields = array_merge($currentDynamicFields, $newDynamicFields);
$item->updated_by = $userId;
$item->save();
return $item;
}
/**
* 품목 삭제
*/
public function delete(int $id): bool
{
$this->tenantId();
$userId = $this->apiUserId();
$item = Item::findOrFail($id);
$item->deleted_by = $userId;
$item->save();
return $item->delete();
}
/**
* 동적 필드 유효성 검증
*/
protected function validateDynamicFields(array $data): void
{
$metadata = $this->metadataService->getMetadata();
$fields = $this->getAllFields($metadata);
foreach ($fields as $field) {
$fieldName = $field['field_name'];
$value = $data[$fieldName] ?? null;
// 필수 필드 체크
if ($field['is_required'] && empty($value)) {
throw new \Illuminate\Validation\ValidationException(
validator([], []),
response()->json([
'errors' => [
$fieldName => [__("validation.required", ['attribute' => $field['field_label']])]
]
], 422)
);
}
// 타입별 검증
if (!empty($value)) {
$this->validateFieldByType($field, $value);
}
}
}
/**
* 필드 타입별 검증
*/
protected function validateFieldByType(array $field, $value): void
{
$rules = $field['validation_rules'] ?? [];
switch ($field['field_type']) {
case 'text':
case 'textarea':
if (isset($rules['min_length']) && strlen($value) < $rules['min_length']) {
throw new \InvalidArgumentException(
"{$field['field_label']}은(는) 최소 {$rules['min_length']}자 이상이어야 합니다."
);
}
if (isset($rules['max_length']) && strlen($value) > $rules['max_length']) {
throw new \InvalidArgumentException(
"{$field['field_label']}은(는) 최대 {$rules['max_length']}자 이하여야 합니다."
);
}
if (isset($rules['pattern']) && !preg_match("/{$rules['pattern']}/", $value)) {
$errorMsg = $rules['error_message'] ?? "형식이 올바르지 않습니다.";
throw new \InvalidArgumentException("{$field['field_label']}: {$errorMsg}");
}
break;
case 'number':
if (!is_numeric($value)) {
throw new \InvalidArgumentException("{$field['field_label']}은(는) 숫자여야 합니다.");
}
if (isset($rules['min']) && $value < $rules['min']) {
throw new \InvalidArgumentException(
"{$field['field_label']}은(는) {$rules['min']} 이상이어야 합니다."
);
}
if (isset($rules['max']) && $value > $rules['max']) {
throw new \InvalidArgumentException(
"{$field['field_label']}은(는) {$rules['max']} 이하여야 합니다."
);
}
break;
case 'select':
$options = $field['options'] ?? [];
if (!empty($options) && !in_array($value, $options)) {
throw new \InvalidArgumentException(
"{$field['field_label']}의 값이 올바르지 않습니다."
);
}
break;
}
}
/**
* 동적 필드 추출
*/
protected function extractDynamicFields(array $data): array
{
$metadata = $this->metadataService->getMetadata();
$fields = $this->getAllFields($metadata);
$dynamicFields = [];
foreach ($fields as $field) {
$fieldName = $field['field_name'];
if (isset($data[$fieldName])) {
$dynamicFields[$fieldName] = $data[$fieldName];
}
}
return $dynamicFields;
}
/**
* 모든 필드 목록 추출
*/
protected function getAllFields(array $metadata): array
{
$fields = [];
foreach ($metadata['pages'] as $page) {
foreach ($page['sections'] as $section) {
foreach ($section['fields'] as $field) {
$fields[] = $field;
}
}
}
return $fields;
}
/**
* 필터 가능한 필드 추출
*/
protected function getFilterableFields(array $metadata): array
{
$fields = $this->getAllFields($metadata);
return array_column(
array_filter($fields, fn($f) => $f['is_filterable']),
'field_name'
);
}
}
Model 구현
Item.php:
<?php
namespace App\Models;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Item extends Model
{
use BelongsToTenant, SoftDeletes, ModelTrait;
protected $fillable = [
'tenant_id',
'item_code',
'item_name',
'dynamic_fields',
'is_active',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'dynamic_fields' => 'array',
'is_active' => 'boolean',
];
/**
* 생성자 관계
*/
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* 수정자 관계
*/
public function updater()
{
return $this->belongsTo(User::class, 'updated_by');
}
}
ItemMasterPage.php:
<?php
namespace App\Models;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class ItemMasterPage extends Model
{
use BelongsToTenant, SoftDeletes;
protected $fillable = [
'tenant_id',
'page_name',
'display_order',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
/**
* 섹션 관계
*/
public function sections()
{
return $this->hasMany(ItemMasterSection::class, 'page_id');
}
}
ItemMasterSection.php, ItemMasterField.php 유사하게 구현
FormRequest 구현
IndexItemRequest.php:
<?php
namespace App\Http\Requests\V1\Item;
use Illuminate\Foundation\Http\FormRequest;
class IndexItemRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'page' => 'integer|min:1',
'per_page' => 'integer|min:1|max:100',
'sort_by' => 'string|max:100',
'sort_order' => 'in:asc,desc',
'search' => 'string|max:200',
'is_active' => 'boolean',
];
}
}
StoreItemRequest.php:
<?php
namespace App\Http\Requests\V1\Item;
use Illuminate\Foundation\Http\FormRequest;
class StoreItemRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'item_code' => 'required|string|max:50',
'item_name' => 'required|string|max:200',
// 동적 필드는 Service에서 검증
];
}
}
Swagger 문서화
ItemApi.php
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="Item", description="품목 관리 (동적 필드 지원)")
*
* @OA\Schema(
* schema="ItemMetadata",
* @OA\Property(
* property="pages",
* type="array",
* @OA\Items(ref="#/components/schemas/ItemPage")
* ),
* @OA\Property(
* property="bom",
* type="object",
* @OA\Property(property="enabled", type="boolean"),
* @OA\Property(property="structure", type="string", enum={"single_level", "multi_level"})
* )
* )
*
* @OA\Schema(
* schema="ItemPage",
* @OA\Property(property="id", type="integer"),
* @OA\Property(property="page_name", type="string", example="기본정보"),
* @OA\Property(property="display_order", type="integer"),
* @OA\Property(property="is_active", type="boolean"),
* @OA\Property(
* property="sections",
* type="array",
* @OA\Items(ref="#/components/schemas/ItemSection")
* )
* )
*
* @OA\Schema(
* schema="ItemSection",
* @OA\Property(property="id", type="integer"),
* @OA\Property(property="section_name", type="string", example="품목코드 정보"),
* @OA\Property(property="display_order", type="integer"),
* @OA\Property(property="is_active", type="boolean"),
* @OA\Property(
* property="fields",
* type="array",
* @OA\Items(ref="#/components/schemas/ItemField")
* )
* )
*
* @OA\Schema(
* schema="ItemField",
* @OA\Property(property="id", type="integer"),
* @OA\Property(property="field_name", type="string", example="item_code"),
* @OA\Property(property="field_label", type="string", example="품목코드"),
* @OA\Property(property="field_type", type="string", enum={"text", "number", "date", "select", "textarea", "checkbox", "file"}),
* @OA\Property(property="is_required", type="boolean"),
* @OA\Property(property="is_searchable", type="boolean"),
* @OA\Property(property="is_filterable", type="boolean"),
* @OA\Property(property="is_sortable", type="boolean"),
* @OA\Property(property="show_in_list", type="boolean"),
* @OA\Property(property="list_order", type="integer", nullable=true),
* @OA\Property(property="column_width", type="string", nullable=true, example="150px"),
* @OA\Property(property="display_order", type="integer"),
* @OA\Property(property="validation_rules", type="object", nullable=true),
* @OA\Property(property="default_value", type="string", nullable=true),
* @OA\Property(property="options", type="array", @OA\Items(type="string"), nullable=true),
* @OA\Property(property="data_source", type="object", nullable=true),
* @OA\Property(property="help_text", type="string", nullable=true),
* @OA\Property(property="placeholder", type="string", nullable=true)
* )
*
* @OA\Schema(
* schema="Item",
* @OA\Property(property="id", type="integer"),
* @OA\Property(property="item_code", type="string", example="ITEM-001"),
* @OA\Property(property="item_name", type="string", example="제품A"),
* @OA\Property(property="is_active", type="boolean"),
* @OA\Property(property="created_at", type="string", format="date-time"),
* @OA\Property(property="updated_at", type="string", format="date-time"),
* @OA\Property(property="unit", type="string", example="EA", description="동적 필드 예시"),
* @OA\Property(property="category", type="string", example="전자부품", description="동적 필드 예시")
* )
*
* @OA\Schema(
* schema="ItemPagination",
* allOf={
* @OA\Schema(ref="#/components/schemas/PaginationMeta"),
* @OA\Schema(
* @OA\Property(
* property="data",
* type="array",
* @OA\Items(ref="#/components/schemas/Item")
* )
* )
* }
* )
*
* @OA\Schema(
* schema="ItemCreateRequest",
* required={"item_code", "item_name"},
* @OA\Property(property="item_code", type="string", maxLength=50, example="ITEM-001"),
* @OA\Property(property="item_name", type="string", maxLength=200, example="제품A"),
* @OA\Property(property="unit", type="string", example="EA", description="동적 필드 (메타데이터에 따라 변경)"),
* @OA\Property(property="category", type="string", example="전자부품", description="동적 필드 예시")
* )
*
* @OA\Schema(
* schema="ItemUpdateRequest",
* @OA\Property(property="item_name", type="string", maxLength=200),
* @OA\Property(property="unit", type="string", description="동적 필드 (부분 업데이트 지원)")
* )
*/
class ItemApi
{
/**
* @OA\Get(
* path="/api/v1/item-master/config",
* tags={"Item"},
* summary="품목 메타데이터 조회",
* description="품목기준관리에서 정의한 화면 구조 메타데이터 조회",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
* @OA\Response(
* response=200,
* description="성공",
* @OA\JsonContent(
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="message.fetched"),
* @OA\Property(property="data", ref="#/components/schemas/ItemMetadata")
* )
* ),
* @OA\Response(response=401, description="인증 실패"),
* @OA\Response(response=404, description="메타데이터 없음")
* )
*/
public function getMetadata() {}
/**
* @OA\Get(
* path="/api/v1/items",
* tags={"Item"},
* summary="품목 목록 조회",
* description="동적 필드 포함 품목 목록 조회 (페이지네이션)",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
* @OA\Parameter(
* name="page",
* in="query",
* @OA\Schema(type="integer", default=1)
* ),
* @OA\Parameter(
* name="per_page",
* in="query",
* @OA\Schema(type="integer", default=20)
* ),
* @OA\Parameter(
* name="sort_by",
* in="query",
* @OA\Schema(type="string", example="created_at")
* ),
* @OA\Parameter(
* name="sort_order",
* in="query",
* @OA\Schema(type="string", enum={"asc", "desc"}, default="desc")
* ),
* @OA\Parameter(
* name="search",
* in="query",
* @OA\Schema(type="string", example="ITEM")
* ),
* @OA\Parameter(
* name="is_active",
* in="query",
* @OA\Schema(type="boolean")
* ),
* @OA\Response(
* response=200,
* description="성공",
* @OA\JsonContent(ref="#/components/schemas/ItemPagination")
* )
* )
*/
public function index() {}
/**
* @OA\Get(
* path="/api/v1/items/{id}",
* tags={"Item"},
* summary="품목 상세 조회",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
* @OA\Parameter(
* name="id",
* in="path",
* required=true,
* @OA\Schema(type="integer")
* ),
* @OA\Response(
* response=200,
* description="성공",
* @OA\JsonContent(
* @OA\Property(property="success", type="boolean"),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/Item")
* )
* ),
* @OA\Response(response=404, description="품목 없음")
* )
*/
public function show() {}
/**
* @OA\Post(
* path="/api/v1/items",
* tags={"Item"},
* summary="품목 생성",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(ref="#/components/schemas/ItemCreateRequest")
* ),
* @OA\Response(
* response=201,
* description="생성 성공",
* @OA\JsonContent(
* @OA\Property(property="success", type="boolean"),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/Item")
* )
* ),
* @OA\Response(response=422, description="유효성 검증 실패")
* )
*/
public function store() {}
/**
* @OA\Put(
* path="/api/v1/items/{id}",
* tags={"Item"},
* summary="품목 수정",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
* @OA\Parameter(
* name="id",
* in="path",
* required=true,
* @OA\Schema(type="integer")
* ),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(ref="#/components/schemas/ItemUpdateRequest")
* ),
* @OA\Response(
* response=200,
* description="수정 성공",
* @OA\JsonContent(
* @OA\Property(property="success", type="boolean"),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/Item")
* )
* ),
* @OA\Response(response=404, description="품목 없음"),
* @OA\Response(response=422, description="유효성 검증 실패")
* )
*/
public function update() {}
/**
* @OA\Delete(
* path="/api/v1/items/{id}",
* tags={"Item"},
* summary="품목 삭제",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
* @OA\Parameter(
* name="id",
* in="path",
* required=true,
* @OA\Schema(type="integer")
* ),
* @OA\Response(
* response=200,
* description="삭제 성공",
* @OA\JsonContent(
* @OA\Property(property="success", type="boolean"),
* @OA\Property(property="message", type="string")
* )
* ),
* @OA\Response(response=404, description="품목 없음")
* )
*/
public function destroy() {}
}
Swagger 재생성
php artisan l5-swagger:generate
체크리스트
백엔드 개발
✓ Service-First 패턴 적용 (Controller 단순화)
✓ BelongsToTenant scope 적용 (tenant_id 자동 필터링)
✓ ModelTrait, SoftDeletes 사용
✓ FormRequest 검증
✓ i18n 메시지 키 사용 (__('message.xxx'))
✓ 메타데이터 캐싱 (Redis)
✓ 동적 필드 유효성 검증
✓ JSON 컬럼 활용 (dynamic_fields)
✓ Swagger 문서화
✓ 감사 로그 적용 (선택사항)
API 엔드포인트
✓ GET /api/v1/item-master/config (메타데이터 조회)
✓ GET /api/v1/items (목록 조회)
✓ GET /api/v1/items/{id} (상세 조회)
✓ POST /api/v1/items (생성)
✓ PUT /api/v1/items/{id} (수정)
✓ DELETE /api/v1/items/{id} (삭제)
데이터베이스
✓ item_master_pages 테이블
✓ item_master_sections 테이블
✓ item_master_fields 테이블
✓ items 테이블 (dynamic_fields JSON 컬럼)
✓ 인덱스 및 FK 설정
✓ 공통 컬럼 (tenant_id, created_by, updated_by, deleted_by)
관련 파일
프론트엔드
src/components/items/ItemMasterDataManagement.tsx- 메인 UI 컴포넌트src/contexts/ItemMasterContext.tsx- Context Providersrc/lib/api/item-master.ts- API 클라이언트src/types/item-master-api.ts- API 타입 정의
백엔드 (Laravel/PHP)
app/Http/Controllers/Api/V1/ItemMasterController.php- API 컨트롤러 (예정)app/Services/ItemMasterService.php- 비즈니스 로직 (예정)app/Models/ItemMasterPage.php- 페이지 모델 (예정)app/Models/ItemMasterSection.php- 섹션 모델 (예정)app/Models/ItemMasterField.php- 필드 모델 (예정)
참조 문서
claudedocs/item-master/[DESIGN-2025-11-24] item-management-dynamic-frontend.md- 프론트엔드 동적 렌더링 설계claudedocs/item-master/[API-2025-11-25] item-master-data-management-api-request.md- API 요청서
최종 업데이트: 2025-11-24