Files
sam-react-prod/claudedocs/item-master/[API-2025-11-24] item-management-dynamic-api-spec.md
byeongcheolryu 65a8510c0b fix: 품목기준관리 실시간 동기화 수정
- BOM 항목 추가/수정/삭제 시 섹션탭 즉시 반영
- 섹션 복제 시 UI 즉시 업데이트 (null vs undefined 이슈 해결)
- 항목 수정 기능 추가 (useTemplateManagement)
- 실시간 동기화 문서 추가

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 22:19:50 +09:00

44 KiB

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

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


목차

  1. 개요
  2. 아키텍처 설계
  3. API 엔드포인트 명세
  4. 데이터 구조
  5. 구현 가이드
  6. 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 자동 필터링
// ✅ 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 Provider
  • src/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