ㅓ# 품목관리 동적 화면 생성 API 명세서

**작성일**: 2025-11-24
**프로젝트**: SAM MES System
**기준 문서**: api_rules.md, architecture.md, swagger_guide.md

---

## 목차

1. [개요](#개요)
2. [아키텍처 설계](#아키텍처-설계)
3. [API 엔드포인트 명세](#api-엔드포인트-명세)
4. [데이터 구조](#데이터-구조)
5. [구현 가이드](#구현-가이드)
6. [Swagger 문서화](#swagger-문서화)

---

## 개요

### 목적
품목기준관리에서 정의한 메타데이터를 기반으로 품목관리 화면을 동적으로 생성하기 위한 API 시스템 구축

### 핵심 요구사항
1. 품목기준관리 메타데이터 조회 API
2. 동적 필드 구조 기반 품목 CRUD API
3. Multi-tenant 데이터 격리
4. 필드 타입별 유효성 검증
5. 검색/필터/정렬 동적 지원

### 데이터 흐름
```
품목기준관리 (메타데이터 정의)
    ↓ 저장
Laravel DB (pages, sections, fields)
    ↓ API 조회
Next.js 프론트엔드
    ↓ 동적 렌더링
품목관리 화면 (폼/테이블 자동 생성)
```

---

## 아키텍처 설계

### Service-First 패턴 적용

**ItemMetadataService**:
- 메타데이터 조회 로직
- 캐싱 전략
- 테넌트별 격리

**ItemService**:
- 동적 필드 기반 CRUD
- 유효성 검증 (메타데이터 기반)
- 검색/필터/정렬

### Multi-Tenancy

**테넌트 격리 방식**:
- JWT 토큰에서 `tenant_id` 자동 추출
- `BelongsToTenant` Trait 적용
- Global Scope 자동 필터링

```php
// ✅ Service에서 컨텍스트 강제
public function getMetadata(): array {
    $this->tenantId();  // 테넌트 ID 필수 확인
    $this->apiUserId(); // API 사용자 ID 확인

    // 메타데이터 조회 로직...
}
```

### 데이터베이스 스키마

#### 1. item_master_pages (페이지/탭)
```sql
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 (섹션)
```sql
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 (필드)
```sql
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 (품목 데이터)
```sql
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 구조 예시

```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 구조 예시

```json
{
  "type": "api",
  "endpoint": "/api/v1/master/units",
  "value_field": "unit_code",
  "label_field": "unit_name"
}
```

또는

```json
{
  "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):
```json
{
  "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**:
```typescript
{
  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):
```json
{
  "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):
```json
{
  "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**:
```json
{
  "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):
```json
{
  "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`: 유효성 검증 실패
  ```json
  {
    "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**:
```json
{
  "item_name": "제품A (수정됨)",
  "spec": "120x120",
  "price": 12000
}
```

**Validation**:
- 메타데이터 기반 자동 검증
- 제공된 필드만 업데이트 (부분 업데이트 지원)

**Response** (200 OK):
```json
{
  "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):
```json
{
  "success": true,
  "message": "message.deleted",
  "data": null
}
```

**Error Responses**:
- `404 Not Found`: 품목이 존재하지 않음
- `403 Forbidden`: 삭제 권한 없음

---

## 데이터 구조

### 메타데이터 캐싱 전략

**Redis 캐시 키 패턴**:
```
item_metadata:{tenant_id}
```

**캐시 만료 시간**: 1시간

**캐시 무효화 시점**:
- 품목기준관리에서 메타데이터 변경 시
- 수동 캐시 클리어 API 호출 시

```php
// 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 컬럼 (권장)**
```sql
-- 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 모델 (확장 가능)**
```sql
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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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 재생성

```bash
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)
```

---

## 관련 문서

- [프론트엔드 동적 렌더링 설계](./[DESIGN-2025-11-24]%20item-management-dynamic-frontend.md)
- [API 개발 규칙](../../../Desktop/코브라브릿지백엔드문서/SAM_Develop_docs_251118/api_rules.md)
- [아키텍처 문서](../../../Desktop/코브라브릿지백엔드문서/SAM_Develop_docs_251118/architecture.md)

---

**최종 업데이트**: 2025-11-24