# MNG 품목관리 페이지 계획

> **작성일**: 2026-02-19
> **목적**: MNG 관리자 패널에 3-Panel 품목관리 페이지 추가 (좌측 리스트 + 중앙 BOM 트리 + 우측 상세)
> **기준 문서**: docs/rules/item-policy.md, docs/specs/item-master-integration.md
> **상태**: ✅ 기본 구현 완료 (미커밋) → Phase 3 수식 연동은 별도 계획

---

## 📍 현재 진행 상태

| 항목 | 내용 |
|------|------|
| **마지막 완료 작업** | Phase 1~2 전체 구현 완료 (미커밋 상태) |
| **다음 작업** | 수식 엔진 연동 → `docs/dev_plans/mng-item-formula-integration-plan.md` 참조 |
| **진행률** | 12/12 (100%) - 기본 3-Panel 구현 완료 |
| **마지막 업데이트** | 2026-02-19 |
| **후속 작업** | FormulaEvaluatorService 연동 (별도 계획 문서) |

---

## 1. 개요

### 1.1 배경

MNG 관리자 패널에 품목(Items)을 관리하고 BOM 연결관계를 시각적으로 파악할 수 있는 페이지가 필요하다.
현재 items 테이블은 products + materials 통합 구조로, `items.bom` JSON 필드에 BOM 구성을 저장한다.

### 1.2 기준 원칙

```
┌─────────────────────────────────────────────────────────────────┐
│  🎯 핵심 원칙                                                    │
├─────────────────────────────────────────────────────────────────┤
│  - MNG에서 마이그레이션 파일 생성 금지 (API에서만)               │
│  - Service-First (비즈니스 로직은 Service 클래스에만)            │
│  - FormRequest 필수 (Controller 검증 금지)                      │
│  - BelongsToTenant (테넌트 격리)                                │
│  - Blade + HTMX + Tailwind (Alpine.js 미사용)                   │
│  - 세션 기반 테넌트 필터링: session('selected_tenant_id')        │
└─────────────────────────────────────────────────────────────────┘
```

### 1.3 변경 승인 정책

| 분류 | 예시 | 승인 |
|------|------|------|
| ✅ 즉시 가능 | 모델/서비스/뷰/컨트롤러/라우트 생성 | 불필요 |
| ⚠️ 컨펌 필요 | 기존 라우트 수정, 사이드바 메뉴 추가 | **필수** |
| 🔴 금지 | mng에서 마이그레이션 생성, 테이블 구조 변경 | 별도 협의 |

### 1.4 MNG 절대 금지 규칙 (인라인)

```
❌ mng/database/migrations/ 에 파일 생성 금지
❌ docker exec sam-mng-1 php artisan migrate 실행 금지
❌ php artisan db:seed --class=*MenuSeeder 실행 금지
❌ 메뉴 시더 파일 생성/실행 금지 (부서별 권한 초기화됨)
❌ Controller에서 직접 DB 쿼리 금지 (Service-First)
❌ Controller에서 직접 validate() 금지 (FormRequest 필수)
```

---

## 2. 기능 설계

### 2.1 3-Panel 레이아웃

```
┌─────────────────────────────────────────────────────────────────────┐
│  Header (64px) - 테넌트 선택 (session 기반 필터링)                   │
├──────────┬─────────────────────────────┬────────────────────────────┤
│ 좌측     │ 중앙                        │ 우측                       │
│ (280px)  │ (flex-1)                    │ (380px)                    │
│          │                             │                            │
│ [검색]   │                             │ ┌──────────────────────┐  │
│ ________│                             │ │ 기본정보             │  │
│          │   BOM 재귀 트리              │ │ 코드: P-001          │  │
│ 품목 1 ◀│   ┌ 완제품A                  │ │ 이름: 스크린 제품    │  │
│ 품목 2  │   ├─ 부품B                   │ │ 유형: FG             │  │
│ 품목 3  │   │  ├─ 원자재C              │ │ 단위: EA             │  │
│ 품목 4  │   │  └─ 부자재D              │ │ 카테고리: ...        │  │
│ 품목 5  │   ├─ 부품E                   │ ├──────────────────────┤  │
│ ...     │   │  ├─ 원자재F              │ │ BOM 구성 (1depth)    │  │
│         │   │  └─ 소모품G              │ │ - 부품B (2ea)        │  │
│         │   └─ 원자재H                 │ │ - 부품E (1ea)        │  │
│         │                             │ │ - 원자재H (0.5kg)    │  │
│         │   ← 전체 재귀 트리 →        │ ├──────────────────────┤  │
│         │   (좌측 선택 품목 기준)       │ │ 절곡 정보            │  │
│         │                             │ │ (bending_details)    │  │
│         │                             │ ├──────────────────────┤  │
│         │                             │ │ 이미지/파일           │  │
│         │                             │ │ 📎 도면.pdf          │  │
│         │                             │ │ 📎 인증서.pdf        │  │
│         │                             │ └──────────────────────┘  │
├──────────┴─────────────────────────────┴────────────────────────────┤
│  ← 클릭 시 어디서든 → 우측 상세 갱신                                │
└─────────────────────────────────────────────────────────────────────┘
```

### 2.2 패널별 상세 동작

#### 좌측 패널 (품목 리스트)
- **상단 검색**: `<input>` debounce 300ms, 코드+이름 동시 검색
- **리스트**: 스크롤 가능, 선택된 항목 하이라이트
- **표시 정보**: 품목코드, 품목명, 유형(FG/PT/SM/RM/CS) 뱃지
- **테넌트 필터**: 헤더에서 선택된 테넌트 자동 적용 (BelongsToTenant)
- **클릭 시**: 중앙 트리 갱신 + 우측 상세 갱신

#### 중앙 패널 (BOM 재귀 트리)
- **데이터 소스**: `items.bom` JSON → child_item_id 재귀 탐색
- **트리 깊이**: 전체 재귀 (BOM → BOM → BOM ...)
- **노드 표시**: 품목코드 + 품목명 + 수량 + 유형 뱃지
- **펼침/접힘**: 노드별 토글 가능
- **클릭 시**: 해당 품목으로 우측 상세 갱신 (좌측 선택은 변경 안 함)

#### 우측 패널 (선택 품목 상세)
- **기본정보**: 코드, 이름, 유형, 단위, 카테고리, 활성 여부, options
- **BOM 구성 (1depth)**: 직접 연결된 자식 품목만 (재귀 X)
- **절곡 정보**: item_details.bending_details JSON (해당 시)
- **파일/이미지**: 연결된 files 목록
- **scope**: 선택된 품목에 직접 연결된 정보만 (1depth)

### 2.3 데이터 흐름

```
[좌측 검색/선택]
    │
    ├──→ HTMX GET /api/admin/items?search=xxx
    │    → 좌측 리스트 갱신
    │
    ├──→ fetch GET /api/admin/items/{id}/bom-tree
    │    → 중앙 트리 갱신 (재귀 JSON 반환 → Vanilla JS 렌더링)
    │
    └──→ HTMX GET /api/admin/items/{id}/detail
         → 우측 상세 갱신

[중앙 트리 노드 클릭]
    │
    └──→ HTMX GET /api/admin/items/{id}/detail
         → 우측 상세만 갱신 (중앙 트리 유지)
```

---

## 3. 기술 설계

### 3.1 DB 스키마 (기존 테이블 활용, 변경 없음)

```sql
-- items (통합 품목) - 이미 존재하는 테이블
-- item_type: FG(완제품), PT(부품), SM(부자재), RM(원자재), CS(소모품)
-- item_category: SCREEN, STEEL, BENDING, ALUMINUM 등
CREATE TABLE items (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    tenant_id BIGINT UNSIGNED NOT NULL,
    item_type VARCHAR(10) NOT NULL,       -- FG/PT/SM/RM/CS
    item_category VARCHAR(50) NULL,       -- SCREEN/STEEL/BENDING/ALUMINUM 등
    code VARCHAR(50) NOT NULL,
    name VARCHAR(200) NOT NULL,
    unit VARCHAR(20) NULL,
    category_id BIGINT UNSIGNED NULL,     -- FK → categories.id
    bom JSON NULL,                        -- [{child_item_id: 5, quantity: 2.5}, ...]
    attributes JSON NULL,                 -- 동적 필드 (migration 등에서 가져온 데이터)
    attributes_archive JSON NULL,         -- 아카이브
    options JSON NULL,                    -- {lot_managed, consumption_method, ...}
    description TEXT NULL,
    is_active TINYINT(1) DEFAULT 1,
    created_by BIGINT UNSIGNED NULL,
    updated_by BIGINT UNSIGNED NULL,
    deleted_at TIMESTAMP NULL,
    created_at TIMESTAMP NULL,
    updated_at TIMESTAMP NULL,
    INDEX (tenant_id), INDEX (item_type), INDEX (code), INDEX (category_id)
);

-- item_details (1:1 확장) - 이미 존재하는 테이블
CREATE TABLE item_details (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    item_id BIGINT UNSIGNED NOT NULL UNIQUE,  -- FK → items.id (1:1)
    -- Products 전용
    is_sellable TINYINT(1) DEFAULT 0,
    is_purchasable TINYINT(1) DEFAULT 0,
    is_producible TINYINT(1) DEFAULT 0,
    safety_stock DECIMAL(10,2) NULL,
    lead_time INT NULL,
    is_variable_size TINYINT(1) DEFAULT 0,
    product_category VARCHAR(50) NULL,
    part_type VARCHAR(50) NULL,
    bending_diagram VARCHAR(255) NULL,        -- 절곡 도면 파일 경로
    bending_details JSON NULL,                -- 절곡 상세 정보 JSON
    specification_file VARCHAR(255) NULL,
    specification_file_name VARCHAR(255) NULL,
    certification_file VARCHAR(255) NULL,
    certification_file_name VARCHAR(255) NULL,
    certification_number VARCHAR(100) NULL,
    certification_start_date DATE NULL,
    certification_end_date DATE NULL,
    -- Materials 전용
    is_inspection CHAR(1) NULL,               -- 'Y'/'N'
    item_name VARCHAR(200) NULL,
    specification VARCHAR(500) NULL,
    search_tag VARCHAR(500) NULL,
    remarks TEXT NULL,
    created_at TIMESTAMP NULL,
    updated_at TIMESTAMP NULL
);

-- files (폴리모픽) - 이미 존재하는 테이블
-- 품목 파일: document_id = items.id, document_type = '1' (ITEM_GROUP_ID)
CREATE TABLE files (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    tenant_id BIGINT UNSIGNED NULL,
    document_id BIGINT UNSIGNED NOT NULL,     -- 연결 대상 ID (items.id)
    document_type VARCHAR(10) NOT NULL,       -- '1' = ITEM_GROUP_ID
    original_name VARCHAR(255) NOT NULL,
    stored_name VARCHAR(255) NOT NULL,
    path VARCHAR(500) NOT NULL,
    mime_type VARCHAR(100) NULL,
    size BIGINT UNSIGNED NULL,
    created_at TIMESTAMP NULL,
    updated_at TIMESTAMP NULL
);

-- categories - 이미 존재하는 테이블
-- 품목 카테고리 (code_group으로 구분, 계층 구조)
CREATE TABLE categories (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    tenant_id BIGINT UNSIGNED NOT NULL,
    parent_id BIGINT UNSIGNED NULL,           -- 자기 참조 (트리)
    code_group VARCHAR(50) NOT NULL,          -- 카테고리 그룹
    profile_code VARCHAR(50) NULL,
    code VARCHAR(50) NOT NULL,
    name VARCHAR(200) NOT NULL,
    is_active TINYINT(1) DEFAULT 1,
    sort_order INT DEFAULT 0,
    description TEXT NULL,
    created_by BIGINT UNSIGNED NULL,
    updated_by BIGINT UNSIGNED NULL,
    deleted_by BIGINT UNSIGNED NULL,
    deleted_at TIMESTAMP NULL,
    created_at TIMESTAMP NULL,
    updated_at TIMESTAMP NULL
);
```

### 3.2 BOM 트리 재귀 로직

```php
// ItemManagementService::getBomTree(int $itemId, int $maxDepth = 10): array
public function getBomTree(int $itemId, int $maxDepth = 10): array
{
    $item = Item::with('details')->findOrFail($itemId);
    return $this->buildBomNode($item, 0, $maxDepth, []);
}

private function buildBomNode(Item $item, int $depth, int $maxDepth, array $visited): array
{
    // 순환 참조 방지: visited 배열 + maxDepth 이중 안전장치
    if (in_array($item->id, $visited) || $depth >= $maxDepth) {
        return $this->formatNode($item, $depth, []);
    }

    $visited[] = $item->id;
    $children = [];

    $bomData = $item->bom ?? [];
    if (!empty($bomData)) {
        $childIds = array_column($bomData, 'child_item_id');
        $childItems = Item::whereIn('id', $childIds)->get()->keyBy('id');

        foreach ($bomData as $bom) {
            $childItem = $childItems->get($bom['child_item_id']);
            if ($childItem) {
                $childNode = $this->buildBomNode($childItem, $depth + 1, $maxDepth, $visited);
                $childNode['quantity'] = $bom['quantity'] ?? 1;
                $children[] = $childNode;
            }
        }
    }

    return $this->formatNode($item, $depth, $children);
}

private function formatNode(Item $item, int $depth, array $children): array
{
    return [
        'id' => $item->id,
        'code' => $item->code,
        'name' => $item->name,
        'item_type' => $item->item_type,
        'unit' => $item->unit,
        'depth' => $depth,
        'has_children' => count($children) > 0,
        'children' => $children,
    ];
}
```

### 3.3 API 엔드포인트 설계

| Method | Endpoint | 설명 | 반환 |
|--------|----------|------|------|
| GET | `/api/admin/items` | 품목 목록 (검색, 페이지네이션) | HTML partial |
| GET | `/api/admin/items/{id}/bom-tree` | BOM 재귀 트리 | JSON |
| GET | `/api/admin/items/{id}/detail` | 품목 상세 (1depth BOM, 파일, 절곡) | HTML partial |

#### GET /api/admin/items

| 파라미터 | 타입 | 설명 |
|---------|------|------|
| search | string | 코드+이름 검색 (LIKE) |
| item_type | string | 유형 필터 (FG,PT,SM,RM,CS 쉼표 구분) |
| per_page | int | 페이지 크기 (default: 50) |
| page | int | 페이지 번호 |

#### GET /api/admin/items/{id}/bom-tree

| 파라미터 | 타입 | 설명 |
|---------|------|------|
| max_depth | int | 최대 재귀 깊이 (default: 10) |

**응답 (JSON)**:
```json
{
    "id": 1,
    "code": "SCREEN-001",
    "name": "스크린 제품",
    "item_type": "FG",
    "unit": "EA",
    "depth": 0,
    "has_children": true,
    "children": [
        {
            "id": 5,
            "code": "SLAT-001",
            "name": "슬랫",
            "item_type": "PT",
            "quantity": 2.5,
            "depth": 1,
            "has_children": true,
            "children": [
                {
                    "id": 12,
                    "code": "STEEL-001",
                    "name": "강판",
                    "item_type": "RM",
                    "quantity": 1.0,
                    "depth": 2,
                    "has_children": false,
                    "children": []
                }
            ]
        }
    ]
}
```

#### GET /api/admin/items/{id}/detail

**응답 (HTML partial)**: 기본정보 + BOM 1depth + 절곡정보 + 파일 목록

### 3.4 파일 구조

```
mng/
├── app/
│   ├── Http/
│   │   └── Controllers/
│   │       ├── ItemManagementController.php          # Web (Blade 화면)
│   │       └── Api/Admin/
│   │           └── ItemManagementApiController.php   # API (HTMX)
│   ├── Models/
│   │   ├── Category.php                              # ⚠️ 이미 존재 (수정 불필요)
│   │   └── Items/
│   │       ├── Item.php                              # ⚠️ 이미 존재 → 보완 필요
│   │       └── ItemDetail.php                        # 신규 생성
│   ├── Services/
│   │   └── ItemManagementService.php                 # BOM 트리, 검색, 상세
│   └── Traits/
│       └── BelongsToTenant.php                       # ⚠️ 이미 존재 (수정 불필요)
├── resources/
│   └── views/
│       └── item-management/
│           ├── index.blade.php                       # 메인 (3-Panel)
│           └── partials/
│               ├── item-list.blade.php               # 좌측 리스트
│               ├── bom-tree.blade.php                # 중앙 트리 (JS 렌더링)
│               └── item-detail.blade.php             # 우측 상세
└── routes/
    ├── web.php                                       # + items 라우트 추가
    └── api.php                                       # + items API 라우트 추가
```

### 3.5 트리 렌더링 방식

**Vanilla JS + Tailwind (라이브러리 미사용)** - MNG 기존 패턴 유지

```javascript
// BOM 트리 JSON → HTML 변환
function renderBomTree(node, container) {
    const li = document.createElement('li');
    li.className = 'ml-4';

    // 노드 렌더링
    const nodeEl = document.createElement('div');
    nodeEl.className = 'flex items-center gap-2 py-1 px-2 rounded cursor-pointer hover:bg-blue-50';
    nodeEl.onclick = () => selectTreeNode(node.id);

    // 펼침/접힘 토글
    if (node.has_children) {
        const toggle = document.createElement('span');
        toggle.className = 'text-gray-400 cursor-pointer';
        toggle.textContent = '▶';
        toggle.onclick = (e) => { e.stopPropagation(); toggleNode(toggle, childList); };
        nodeEl.appendChild(toggle);
    } else {
        // 빈 공간 (들여쓰기 맞춤)
        const spacer = document.createElement('span');
        spacer.className = 'w-4 inline-block';
        nodeEl.appendChild(spacer);
    }

    // 유형 뱃지 + 코드 + 이름 + 수량
    nodeEl.innerHTML += `
        <span class="badge-${node.item_type}">${node.item_type}</span>
        <span class="font-mono text-sm">${node.code}</span>
        <span class="text-gray-700">${node.name}</span>
        ${node.quantity ? `<span class="text-blue-600 text-xs">(${node.quantity})</span>` : ''}
    `;
    li.appendChild(nodeEl);

    // 자식 노드 재귀 렌더링
    if (node.children && node.children.length > 0) {
        const childList = document.createElement('ul');
        childList.className = 'border-l border-gray-200';
        node.children.forEach(child => renderBomTree(child, childList));
        li.appendChild(childList);
    }

    container.appendChild(li);
}

// 트리 노드 펼침/접힘
function toggleNode(toggle, childList) {
    if (childList.style.display === 'none') {
        childList.style.display = '';
        toggle.textContent = '▼';
    } else {
        childList.style.display = 'none';
        toggle.textContent = '▶';
    }
}
```

---

## 4. 대상 범위

### Phase 1: 백엔드 (모델 + 서비스 + API)

| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1.1 | Item 모델 보완 (mng/app/Models/Items/Item.php) | ✅ | BelongsToTenant, 관계, 스코프, 상수, 헬퍼 추가 |
| 1.2 | ItemDetail 모델 생성 (mng/app/Models/Items/ItemDetail.php) | ✅ | 1:1 관계, is_variable_size 포함 |
| 1.3 | ItemManagementService 생성 | ✅ | getItemList, getBomTree(재귀), getItemDetail |
| 1.4 | ItemManagementApiController 생성 | ✅ | index(HTML), bomTree(JSON), detail(HTML) |
| 1.5 | API 라우트 등록 (routes/api.php) | ✅ | /api/admin/items/* (3개 라우트) |
| 1.6 | File 모델 생성 (mng/app/Models/Commons/File.php) | ✅ | Item.files() 관계용 |

### Phase 2: 프론트엔드 (Blade + JS)

| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 2.1 | 메인 페이지 (index.blade.php) - 3-Panel 레이아웃 | ✅ | Tailwind flex, 3-Panel |
| 2.2 | 좌측 패널 (item-list.blade.php) + 실시간 검색 | ✅ | HTMX + debounce 300ms + 유형 필터 |
| 2.3 | 중앙 패널 (bom-tree.blade.php) + JS 트리 렌더링 | ✅ | Vanilla JS 재귀 렌더링 |
| 2.4 | 우측 패널 (item-detail.blade.php) | ✅ | 기본정보+BOM 1depth+절곡+파일 |
| 2.5 | ItemManagementController (Web) 생성 | ✅ | HX-Redirect 패턴 |
| 2.6 | Web 라우트 등록 (routes/web.php) | ✅ | GET /item-management |
| 2.7 | 유형별 뱃지 스타일 + 트리 라인 CSS | ✅ | Tailwind inline + JS getTypeBadgeClass |

### Phase 3: 수식 엔진 연동 (후속 작업)

> 별도 계획 문서: `docs/dev_plans/mng-item-formula-integration-plan.md`
>
> 가변사이즈 FG 품목 선택 시 오픈사이즈(W,H) 입력 → FormulaEvaluatorService 동적 산출 → 중앙 패널 탭 전환 표시

---

## 5. 작업 절차

### Step 1: 모델 보완/생성 (Phase 1.1, 1.2)
```
├── mng/app/Models/Items/Item.php 보완 (기존 파일 존재)
│   현재 상태: SoftDeletes만 있음, BelongsToTenant 없음, 관계 없음
│   추가 필요:
│   - use App\Traits\BelongsToTenant 추가
│   - $fillable에 category_id, bom, attributes, options, description 추가
│   - $casts에 bom→array, options→array 추가
│   - 관계: details(), category(), files()
│   - 스코프: type(), active(), search()
│   - 상수: TYPE_FG 등, PRODUCT_TYPES, MATERIAL_TYPES
│   - 헬퍼: isProduct(), isMaterial(), getBomChildIds()
│
└── mng/app/Models/Items/ItemDetail.php 생성 (신규)
    - item() belongsTo 관계
    - $fillable: 전체 필드 (섹션 A.3 참고)
    - $casts: bending_details→array, is_sellable→boolean 등
```

### Step 2: 서비스 생성 (Phase 1.3)
```
├── mng/app/Services/ItemManagementService.php 생성
│   - getItemList(array $filters): LengthAwarePaginator
│     └ Item::query()->search($search)->active()->orderBy('code')->paginate($perPage)
│   - getBomTree(int $itemId, int $maxDepth = 10): array
│     └ 재귀 buildBomNode() (섹션 3.2 코드)
│   - getItemDetail(int $itemId): array
│     └ Item::with(['details', 'category', 'files'])->findOrFail($id)
│     └ BOM 1depth: items.bom JSON에서 child_item_id 추출 → Item::whereIn()
│
└── 테넌트 스코프 자동 적용 (BelongsToTenant가 글로벌 스코프 등록)
```

### Step 3: API 컨트롤러 + 라우트 (Phase 1.4, 1.5)
```
├── mng/app/Http/Controllers/Api/Admin/ItemManagementApiController.php
│   - __construct(private readonly ItemManagementService $service)
│   - index(Request $request): View
│     └ HTMX 요청 시 HTML partial 반환 (Blade view render)
│   - bomTree(int $id): JsonResponse
│     └ JSON 반환 (JS에서 트리 렌더링)
│   - detail(int $id): View
│     └ HTML partial 반환 (item-detail.blade.php)
│
└── routes/api.php에 라우트 추가 (기존 그룹 내)
    // 기존 Route::middleware(['web', 'auth', 'hq.member'])
    //         ->prefix('admin')->name('api.admin.')->group(function () { ... });
    // 내부에 추가:
    Route::prefix('items')->name('items.')->group(function () {
        Route::get('/', [ItemManagementApiController::class, 'index'])->name('index');
        Route::get('/{id}/bom-tree', [ItemManagementApiController::class, 'bomTree'])->name('bom-tree');
        Route::get('/{id}/detail', [ItemManagementApiController::class, 'detail'])->name('detail');
    });
```

### Step 4: Blade 뷰 생성 (Phase 2.1~2.4)
```
├── index.blade.php: 3-Panel 메인 레이아웃
│   @extends('layouts.app'), @section('content'), @push('scripts')
│   HTMX 페이지이므로 HX-Redirect 필요 (JS가 @push('scripts')에 있음)
│
├── partials/item-list.blade.php: 좌측 품목 리스트
│   @foreach($items as $item) → 품목코드, 품목명, 유형 뱃지
│   data-item-id="{{ $item->id }}" onclick="selectItem({{ $item->id }})"
│
├── partials/bom-tree.blade.php: 중앙 트리 (빈 컨테이너)
│   <div id="bom-tree-container">품목을 선택하세요</div>
│
└── partials/item-detail.blade.php: 우측 상세정보
    기본정보 테이블 + BOM 1depth 리스트 + 절곡 정보 + 파일 목록
```

### Step 5: Web 컨트롤러 + 라우트 (Phase 2.5, 2.6)
```
├── mng/app/Http/Controllers/ItemManagementController.php
│   - __construct(private readonly ItemManagementService $service)
│   - index(Request $request): View|Response
│     └ HX-Request 체크 → HX-Redirect (JS 포함 페이지이므로)
│     └ return view('item-management.index')
│
└── routes/web.php에 라우트 추가
    // 기존 인증 미들웨어 그룹 내에 추가:
    Route::get('/item-management', [ItemManagementController::class, 'index'])
        ->name('item-management.index');
```

### Step 6: 스타일 + 트리 인터랙션 (Phase 2.7)
```
├── 유형별 뱃지 색상 (Tailwind inline)
│   FG: bg-blue-100 text-blue-800 (완제품)
│   PT: bg-green-100 text-green-800 (부품)
│   SM: bg-yellow-100 text-yellow-800 (부자재)
│   RM: bg-orange-100 text-orange-800 (원자재)
│   CS: bg-gray-100 text-gray-800 (소모품)
│
└── 트리 라인 CSS (border-l + ml-4 indent)
```

---

## 6. 상세 구현 명세

### 6.1 Item 모델 보완 (기존 파일 수정)

**기존 파일**: `mng/app/Models/Items/Item.php`

**현재 상태 (보완 전)**:
```php
<?php
namespace App\Models\Items;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class Item extends Model
{
    use SoftDeletes;

    protected $fillable = [
        'tenant_id', 'item_type', 'item_category',
        'code', 'name', 'unit', 'is_active',
    ];

    protected $casts = [
        'is_active' => 'boolean',
        'attributes' => 'array',
    ];
}
```

**보완 후 (목표 상태)**:
```php
<?php
namespace App\Models\Items;

use App\Models\Category;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class Item extends Model
{
    use BelongsToTenant, SoftDeletes;

    protected $table = 'items';

    protected $fillable = [
        'tenant_id', 'item_type', 'item_category', 'code', 'name', 'unit',
        'category_id', 'bom', 'attributes', 'attributes_archive', 'options',
        'description', 'is_active', 'created_by', 'updated_by',
    ];

    protected $casts = [
        'bom' => 'array',
        'attributes' => 'array',
        'attributes_archive' => 'array',
        'options' => 'array',
        'is_active' => 'boolean',
    ];

    // 유형 상수
    const TYPE_FG = 'FG';  // 완제품
    const TYPE_PT = 'PT';  // 부품
    const TYPE_SM = 'SM';  // 부자재
    const TYPE_RM = 'RM';  // 원자재
    const TYPE_CS = 'CS';  // 소모품

    const PRODUCT_TYPES = ['FG', 'PT'];
    const MATERIAL_TYPES = ['SM', 'RM', 'CS'];

    // ── 관계 ──

    public function details()
    {
        return $this->hasOne(ItemDetail::class, 'item_id');
    }

    public function category()
    {
        return $this->belongsTo(Category::class, 'category_id');
    }

    /**
     * 파일 (document_id/document_type 기반)
     * document_id = items.id, document_type = '1' (ITEM_GROUP_ID)
     */
    public function files()
    {
        return $this->hasMany(\App\Models\Commons\File::class, 'document_id')
            ->where('document_type', '1');
    }

    // ── 스코프 ──

    public function scopeType($query, string $type)
    {
        return $query->where('items.item_type', strtoupper($type));
    }

    public function scopeActive($query)
    {
        return $query->where('is_active', true);
    }

    public function scopeSearch($query, ?string $search)
    {
        if (!$search) return $query;
        return $query->where(function ($q) use ($search) {
            $q->where('code', 'like', "%{$search}%")
              ->orWhere('name', 'like', "%{$search}%");
        });
    }

    // ── 헬퍼 ──

    public function isProduct(): bool
    {
        return in_array($this->item_type, self::PRODUCT_TYPES);
    }

    public function isMaterial(): bool
    {
        return in_array($this->item_type, self::MATERIAL_TYPES);
    }

    public function getBomChildIds(): array
    {
        return collect($this->bom ?? [])->pluck('child_item_id')->filter()->toArray();
    }
}
```

> **주의**: files() 관계에서 `\App\Models\Commons\File::class` 경로를 사용한다.
> 만약 mng에 File 모델이 없다면, 단순 모델로 신규 생성해야 한다.
> 확인 필요: `mng/app/Models/Commons/File.php` 존재 여부. 없으면 생성.

### 6.2 ItemDetail 모델 (신규 생성)

```php
<?php
namespace App\Models\Items;

use Illuminate\Database\Eloquent\Model;

class ItemDetail extends Model
{
    protected $table = 'item_details';

    protected $fillable = [
        'item_id',
        // Products 전용
        'is_sellable', 'is_purchasable', 'is_producible',
        'safety_stock', 'lead_time', 'is_variable_size',
        'product_category', 'part_type',
        'bending_diagram', 'bending_details',
        'specification_file', 'specification_file_name',
        'certification_file', 'certification_file_name',
        'certification_number', 'certification_start_date', 'certification_end_date',
        // Materials 전용
        'is_inspection', 'item_name', 'specification', 'search_tag', 'remarks',
    ];

    protected $casts = [
        'is_sellable' => 'boolean',
        'is_purchasable' => 'boolean',
        'is_producible' => 'boolean',
        'is_variable_size' => 'boolean',
        'bending_details' => 'array',
        'certification_start_date' => 'date',
        'certification_end_date' => 'date',
    ];

    public function item()
    {
        return $this->belongsTo(Item::class);
    }
}
```

### 6.3 좌측 검색 - Debounce + HTMX

```javascript
// index.blade.php @push('scripts')
let searchTimer = null;
const searchInput = document.getElementById('item-search');

searchInput.addEventListener('input', function() {
    clearTimeout(searchTimer);
    searchTimer = setTimeout(() => {
        const search = this.value.trim();
        htmx.ajax('GET', `/api/admin/items?search=${encodeURIComponent(search)}&per_page=50`, {
            target: '#item-list',
            swap: 'innerHTML',
            headers: {'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content}
        });
    }, 300); // 300ms debounce
});
```

### 6.4 품목 선택 시 중앙+우측 갱신

```javascript
// 품목 선택 함수 (좌측/중앙 공용)
function selectItem(itemId, updateTree = true) {
    // 선택 하이라이트
    document.querySelectorAll('.item-row').forEach(el => el.classList.remove('bg-blue-50', 'border-blue-300'));
    const selected = document.querySelector(`[data-item-id="${itemId}"]`);
    if (selected) selected.classList.add('bg-blue-50', 'border-blue-300');

    // 중앙 트리 갱신 (좌측에서 클릭 시에만)
    if (updateTree) {
        fetch(`/api/admin/items/${itemId}/bom-tree`, {
            headers: {'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content}
        })
        .then(res => res.json())
        .then(tree => {
            const container = document.getElementById('bom-tree-container');
            container.innerHTML = '';
            if (tree.has_children) {
                const ul = document.createElement('ul');
                renderBomTree(tree, ul);
                container.appendChild(ul);
            } else {
                container.innerHTML = '<p class="text-gray-400 text-center py-10">BOM 구성이 없습니다.</p>';
            }
        });
    }

    // 우측 상세 갱신 (항상)
    htmx.ajax('GET', `/api/admin/items/${itemId}/detail`, {
        target: '#item-detail',
        swap: 'innerHTML',
        headers: {'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content}
    });
}

// 중앙 트리 노드 클릭 (트리는 유지, 우측만 갱신)
function selectTreeNode(itemId) {
    selectItem(itemId, false); // updateTree = false
}
```

---

## 7. 컨펌 대기 목록

| # | 항목 | 변경 내용 | 영향 범위 | 상태 |
|---|------|----------|----------|------|
| 1 | 사이드바 메뉴 추가 | "품목관리" 메뉴 항목 추가 | menus 테이블 (DB) | ⏳ tinker 안내 필요 |

---

## 8. 변경 이력

| 날짜 | 항목 | 변경 내용 | 파일 | 승인 |
|------|------|----------|------|------|
| 2026-02-19 | - | 문서 초안 작성 | - | - |
| 2026-02-19 | - | 자기완결성 보강 (Appendix A~C 추가) | - | - |
| 2026-02-19 | Phase 1 | Item 모델 보완, ItemDetail/File 모델 생성 | Item.php, ItemDetail.php, File.php | ✅ |
| 2026-02-19 | Phase 1 | ItemManagementService 생성 | ItemManagementService.php | ✅ |
| 2026-02-19 | Phase 1 | ItemManagementApiController 생성 + API 라우트 | ItemManagementApiController.php, api.php | ✅ |
| 2026-02-19 | Phase 2 | 3-Panel Blade 뷰 전체 생성 | index.blade.php + 3 partials | ✅ |
| 2026-02-19 | Phase 2 | Web 컨트롤러 + 라우트 등록 | ItemManagementController.php, web.php | ✅ |
| 2026-02-19 | - | Phase 1~2 완료, Phase 3 수식 연동 계획 별도 문서 분리 | mng-item-formula-integration-plan.md | - |

---

## 9. 참고 문서

- **품목 정책**: `docs/rules/item-policy.md`
- **품목 연동 설계**: `docs/specs/item-master-integration.md`
- **MNG 절대 규칙**: `mng/docs/MNG_CRITICAL_RULES.md`
- **MNG 프로젝트 문서**: `mng/docs/INDEX.md`
- **DB 스키마**: `docs/specs/database-schema.md`
- **API Item 모델**: `api/app/Models/Items/Item.php`
- **API ItemDetail 모델**: `api/app/Models/Items/ItemDetail.php`

---

## 10. 검증 결과

### 10.1 테스트 케이스

| 입력값 | 예상 결과 | 실제 결과 | 상태 |
|--------|----------|----------|------|
| 좌측 검색: "스크린" | "스크린" 포함 품목만 표시 | 정상 동작 | ✅ |
| FG 품목 클릭 | 중앙에 BOM 트리, 우측에 상세 | 정상 동작 (정적 BOM 2개 표시) | ✅ |
| BOM 없는 품목 클릭 | 중앙 "BOM 없음", 우측 상세 표시 | 정상 동작 | ✅ |
| 중앙 트리 노드 클릭 | 우측 상세만 변경 (트리 유지) | 정상 동작 | ✅ |
| 테넌트 전환 | 좌측 리스트가 해당 테넌트 품목으로 변경 | 확인 필요 | ⏳ |
| 순환 참조 BOM | 무한 루프 없이 maxDepth에서 중단 | 로직 구현 완료, 실제 데이터 미검증 | ⏳ |

### 10.2 성공 기준

| 기준 | 달성 | 비고 |
|------|------|------|
| 3-Panel 레이아웃 정상 렌더링 | ✅ | 좌측 280px + 중앙 flex-1 + 우측 384px |
| 실시간 검색 (debounce 300ms) | ✅ | 코드+이름 동시 검색 |
| BOM 재귀 트리 정상 표시 (전체 depth) | ✅ | 펼침/접힘 토글 포함 |
| 어디서든 클릭 → 우측 상세 갱신 | ✅ | selectItem + selectTreeNode |
| 테넌트 필터링 정상 동작 | ⏳ | withoutGlobalScopes + session 패턴 사용 |
| 순환 참조 방지 (maxDepth) | ✅ | visited 배열 + maxDepth 이중 안전장치 |

---

## 11. 자기완결성 점검 결과

### 11.1 체크리스트 검증

| # | 검증 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1 | 작업 목적이 명확한가? | ✅ | 3-Panel 품목관리 페이지 |
| 2 | 성공 기준이 정의되어 있는가? | ✅ | 섹션 10.2 |
| 3 | 작업 범위가 구체적인가? | ✅ | 섹션 4 (12개 작업 항목) |
| 4 | 의존성이 명시되어 있는가? | ✅ | items 테이블 존재 전제 |
| 5 | 참고 파일 경로가 정확한가? | ✅ | 섹션 9 + Appendix |
| 6 | 단계별 절차가 실행 가능한가? | ✅ | 섹션 5 (6 Step) |
| 7 | 검증 방법이 명시되어 있는가? | ✅ | 섹션 10.1 |
| 8 | 모호한 표현이 없는가? | ✅ | 구체적 코드/구조 명시 |

### 11.2 새 세션 시뮬레이션 테스트

| 질문 | 답변 가능 | 참조 섹션 |
|------|:--------:|----------|
| Q1. 이 작업의 목적은 무엇인가? | ✅ | 1.1 배경 |
| Q2. 어디서부터 시작해야 하는가? | ✅ | 5. 작업 절차 Step 1 |
| Q3. 어떤 파일을 수정해야 하는가? | ✅ | 3.4 파일 구조 + 6.1 기존 파일 현황 |
| Q4. 작업 완료 확인 방법은? | ✅ | 10. 검증 결과 |
| Q5. 막혔을 때 참고 문서는? | ✅ | 9. 참고 문서 + Appendix A~C |
| Q6. MNG 코딩 패턴은 무엇인가? | ✅ | Appendix A (인라인 패턴) |
| Q7. 테넌트 필터링은 어떻게 동작하는가? | ✅ | Appendix B (BelongsToTenant 전문) |
| Q8. API 모델의 정확한 필드는? | ✅ | Appendix C (API 모델 전문) |

**결과**: 8/8 통과 → ✅ 자기완결성 확보

---

## Appendix A: MNG 코딩 패턴 레퍼런스

> 새 세션에서 외부 파일을 읽지 않고도 MNG 패턴을 따를 수 있도록 인라인화한 레퍼런스.

### A.1 Web Controller 패턴

Web Controller는 Blade 뷰 렌더링만 담당한다. 비즈니스 로직은 Service에 위임.

```php
<?php
// 참고: mng/app/Http/Controllers/DepartmentController.php 패턴
namespace App\Http\Controllers;

use App\Services\DepartmentService;
use Illuminate\Http\Request;
use Illuminate\View\View;

class DepartmentController extends Controller
{
    public function __construct(
        private readonly DepartmentService $departmentService
    ) {}

    public function index(Request $request): View
    {
        // Blade 화면만 렌더링 (데이터는 HTMX로 별도 로드)
        return view('departments.index');
    }
}
```

**HTMX 페이지의 HX-Redirect 패턴** (JS가 `@push('scripts')`에 있는 경우):
```php
public function index(Request $request): View|Response
{
    // HTMX 부분 로드 시 JS가 실행되지 않으므로 전체 리로드 필요
    if ($request->header('HX-Request')) {
        return response('', 200)->header('HX-Redirect', route('item-management.index'));
    }
    return view('item-management.index');
}
```

### A.2 API Controller 패턴

API Controller는 HTMX 요청 시 HTML partial, 일반 요청 시 JSON 반환.

```php
<?php
// 참고: mng/app/Http/Controllers/Api/Admin/DepartmentController.php 패턴
namespace App\Http\Controllers\Api\Admin;

use App\Http\Controllers\Controller;
use App\Services\DepartmentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class DepartmentController extends Controller
{
    public function __construct(
        private readonly DepartmentService $departmentService
    ) {}

    public function index(Request $request): JsonResponse|\Illuminate\View\View
    {
        $departments = $this->departmentService->getDepartments(
            $request->all(),
            $request->integer('per_page', 10)
        );

        // HTMX 요청 시 HTML partial 반환
        if ($request->header('HX-Request')) {
            return view('departments.partials.table', compact('departments'));
        }

        // 일반 요청 시 JSON
        return response()->json([
            'success' => true,
            'data' => $departments->items(),
            'meta' => [
                'current_page' => $departments->currentPage(),
                'last_page' => $departments->lastPage(),
                'per_page' => $departments->perPage(),
                'total' => $departments->total(),
            ],
        ]);
    }
}
```

### A.3 Service 패턴

모든 DB 쿼리 로직은 Service에서 처리. `session('selected_tenant_id')`로 테넌트 격리.

```php
<?php
// 참고: mng/app/Services/DepartmentService.php 패턴
namespace App\Services;

use App\Models\Tenants\Department;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;

class DepartmentService
{
    public function getDepartments(array $filters = [], int $perPage = 15): LengthAwarePaginator
    {
        $query = Department::query()->with('parent');

        // 검색 필터
        if (!empty($filters['search'])) {
            $search = $filters['search'];
            $query->where(function ($q) use ($search) {
                $q->where('name', 'like', "%{$search}%")
                  ->orWhere('code', 'like', "%{$search}%");
            });
        }

        return $query->orderBy('sort_order')->paginate($perPage);
    }
}
```

> **중요**: BelongsToTenant trait이 모델에 있으면 tenant_id 필터가 자동 적용된다.
> Service에서 수동으로 `where('tenant_id', ...)` 할 필요 없음.

### A.4 Blade + HTMX 패턴

Index 페이지는 빈 셸이고, 데이터는 HTMX `hx-get` + `hx-trigger="load"`로 로드.

```blade
{{-- 참고: mng/resources/views/departments/index.blade.php 패턴 --}}
@extends('layouts.app')

@section('title', '부서 관리')

@section('content')
    <div class="flex justify-between items-center mb-6">
        <h1 class="text-2xl font-bold text-gray-800">부서 관리</h1>
    </div>

    {{-- HTMX 테이블: 초기 로드 + 이벤트 재로드 --}}
    <div id="department-table"
         hx-get="/api/admin/departments"
         hx-trigger="load, filterSubmit from:body"
         hx-include="#filterForm"
         hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
         class="bg-white rounded-lg shadow-sm">
        {{-- 로딩 스피너 --}}
        <div class="flex justify-center p-12">
            <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
        </div>
    </div>
@endsection

@push('scripts')
<script>
    // 커스텀 이벤트로 HTMX 재로드 트리거
    document.getElementById('filterForm')?.addEventListener('submit', function(e) {
        e.preventDefault();
        htmx.trigger('#department-table', 'filterSubmit');
    });
</script>
@endpush
```

### A.5 라우트 패턴

**routes/web.php** 구조:
```php
// 인증 필요 라우트 그룹
Route::middleware(['auth', 'hq.member', 'password.changed'])->group(function () {
    // ... 기존 라우트들 ...

    // 품목관리 (신규 추가할 위치)
    Route::get('/item-management', [ItemManagementController::class, 'index'])
        ->name('item-management.index');
});
```

**routes/api.php** 구조:
```php
// MNG API는 세션 기반 (token 아님)
Route::middleware(['web', 'auth', 'hq.member'])
    ->prefix('admin')
    ->name('api.admin.')
    ->group(function () {
        // ... 기존 API 라우트들 ...

        // 품목관리 API (신규 추가할 위치)
        Route::prefix('items')->name('items.')->group(function () {
            Route::get('/', [ItemManagementApiController::class, 'index'])->name('index');
            Route::get('/{id}/bom-tree', [ItemManagementApiController::class, 'bomTree'])->name('bom-tree');
            Route::get('/{id}/detail', [ItemManagementApiController::class, 'detail'])->name('detail');
        });
    });
```

> **주의**: MNG API는 `['web', 'auth', 'hq.member']` 미들웨어 사용 (세션 기반, Sanctum 아님).
> 고정 라우트(`/all`, `/summary`)를 `/{id}` 파라미터 라우트보다 먼저 정의해야 충돌 방지.

### A.6 모델 패턴

```php
// 참고: mng/app/Models/Category.php 패턴
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class Category extends Model
{
    use BelongsToTenant, SoftDeletes;

    protected $fillable = [
        'tenant_id', 'parent_id', 'code_group', 'profile_code',
        'code', 'name', 'is_active', 'sort_order', 'description',
        'created_by', 'updated_by', 'deleted_by',
    ];

    protected $casts = [
        'is_active' => 'boolean',
        'sort_order' => 'integer',
    ];

    // 자기 참조 트리
    public function parent() { return $this->belongsTo(self::class, 'parent_id'); }
    public function children() { return $this->hasMany(self::class, 'parent_id')->orderBy('sort_order'); }

    // 스코프
    public function scopeActive($query) { return $query->where('is_active', true); }
}
```

---

## Appendix B: BelongsToTenant 동작 방식

### B.1 Trait (mng/app/Traits/BelongsToTenant.php)

```php
<?php
namespace App\Traits;

use App\Models\Scopes\TenantScope;

trait BelongsToTenant
{
    protected static function bootBelongsToTenant(): void
    {
        static::addGlobalScope(new TenantScope);
    }
}
```

### B.2 Global Scope (mng/app/Models/Scopes/TenantScope.php)

```php
<?php
namespace App\Models\Scopes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
use Illuminate\Http\Request;

class TenantScope implements Scope
{
    private static ?int $cachedTenantId = null;
    private static bool $cacheInitialized = false;

    public function apply(Builder $builder, Model $model)
    {
        // artisan 명령 (migrate 등) 실행 시 스킵
        if (app()->runningInConsole()) {
            return;
        }

        // 요청당 1회만 tenant_id 조회 (캐시)
        if (!self::$cacheInitialized) {
            $request = app(Request::class);
            self::$cachedTenantId = $request->attributes->get('tenant_id')
                ?? $request->header('X-TENANT-ID')
                ?? auth()->user()?->tenant_id;
            self::$cacheInitialized = true;
        }

        if (self::$cachedTenantId !== null) {
            $builder->where($model->getTable() . '.tenant_id', self::$cachedTenantId);
        }
    }

    public static function clearCache(): void
    {
        self::$cachedTenantId = null;
        self::$cacheInitialized = false;
    }
}
```

**동작 요약**:
1. 모델에 `use BelongsToTenant` 선언하면 자동으로 TenantScope 등록
2. 모든 쿼리에 `WHERE items.tenant_id = ?` 조건 자동 추가
3. tenant_id 결정 우선순위: request attributes → X-TENANT-ID 헤더 → auth user
4. console 환경(migrate 등)에서는 스킵
5. **Service에서 수동 tenant_id 필터 불필요** (자동 적용)

---

## Appendix C: API 모델 전문 (참조용)

> 구현 시 API 모델의 정확한 필드 목록과 관계를 참고하기 위한 인라인 전문.

### C.1 api/app/Models/Items/Item.php (전체)

```php
<?php
namespace App\Models\Items;

use App\Models\Commons\Category;
use App\Models\Commons\File;
use App\Models\Commons\Tag;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class Item extends Model
{
    use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;

    protected $fillable = [
        'tenant_id', 'item_type', 'item_category',
        'code', 'name', 'unit', 'category_id',
        'bom', 'attributes', 'attributes_archive', 'options',
        'description', 'is_active', 'created_by', 'updated_by',
    ];

    protected $casts = [
        'bom' => 'array',
        'attributes' => 'array',
        'attributes_archive' => 'array',
        'options' => 'array',
        'is_active' => 'boolean',
    ];

    const TYPE_FINISHED_GOODS = 'FG';
    const TYPE_PARTS = 'PT';
    const TYPE_SUB_MATERIALS = 'SM';
    const TYPE_RAW_MATERIALS = 'RM';
    const TYPE_CONSUMABLES = 'CS';
    const PRODUCT_TYPES = ['FG', 'PT'];
    const MATERIAL_TYPES = ['SM', 'RM', 'CS'];

    public function details() { return $this->hasOne(ItemDetail::class); }
    public function stock() { return $this->hasOne(\App\Models\Tenants\Stock::class); }
    public function category() { return $this->belongsTo(Category::class, 'category_id'); }

    // files: document_id = item_id, document_type = '1' (ITEM_GROUP_ID)
    public function files()
    {
        return $this->hasMany(File::class, 'document_id')->where('document_type', '1');
    }

    public function tags() { return $this->morphToMany(Tag::class, 'taggable'); }

    // BOM 자식 조회 (JSON bom 필드에서 child_item_id 추출)
    public function bomChildren()
    {
        $childIds = collect($this->bom ?? [])->pluck('child_item_id')->filter()->toArray();
        return self::whereIn('id', $childIds);
    }

    // 스코프
    public function scopeType($query, string $type)
    {
        return $query->where('items.item_type', strtoupper($type));
    }
    public function scopeProducts($query) { return $query->whereIn('items.item_type', self::PRODUCT_TYPES); }
    public function scopeMaterials($query) { return $query->whereIn('items.item_type', self::MATERIAL_TYPES); }
    public function scopeActive($query) { return $query->where('is_active', true); }

    // 헬퍼
    public function isProduct(): bool { return in_array($this->item_type, self::PRODUCT_TYPES); }
    public function isMaterial(): bool { return in_array($this->item_type, self::MATERIAL_TYPES); }
    public function getBomChildIds(): array
    {
        return collect($this->bom ?? [])->pluck('child_item_id')->filter()->toArray();
    }
}
```

### C.2 api/app/Models/Items/ItemDetail.php (전체)

```php
<?php
namespace App\Models\Items;

use Illuminate\Database\Eloquent\Model;

class ItemDetail extends Model
{
    protected $fillable = [
        'item_id',
        // Products 전용
        'is_sellable', 'is_purchasable', 'is_producible',
        'safety_stock', 'lead_time', 'is_variable_size',
        'product_category', 'part_type',
        'bending_diagram', 'bending_details',
        'specification_file', 'specification_file_name',
        'certification_file', 'certification_file_name',
        'certification_number', 'certification_start_date', 'certification_end_date',
        // Materials 전용
        'is_inspection', 'item_name', 'specification', 'search_tag', 'remarks',
    ];

    protected $casts = [
        'is_sellable' => 'boolean',
        'is_purchasable' => 'boolean',
        'is_producible' => 'boolean',
        'is_variable_size' => 'boolean',
        'bending_details' => 'array',
        'certification_start_date' => 'date',
        'certification_end_date' => 'date',
    ];

    public function item() { return $this->belongsTo(Item::class); }
    public function isSellable(): bool { return $this->is_sellable ?? false; }
    public function isPurchasable(): bool { return $this->is_purchasable ?? false; }
    public function isProducible(): bool { return $this->is_producible ?? false; }
    public function isCertificationValid(): bool
    {
        return $this->certification_end_date?->isFuture() ?? false;
    }
    public function requiresInspection(): bool { return $this->is_inspection === 'Y'; }
}
```

---

## Appendix D: 구현 시 확인 사항

### D.1 File 모델 존재 여부 확인

구현 시작 전 `mng/app/Models/Commons/File.php` 존재 여부를 확인해야 한다.
없으면 다음과 같이 간단한 모델 생성 필요:

```php
<?php
namespace App\Models\Commons;

use Illuminate\Database\Eloquent\Model;

class File extends Model
{
    protected $fillable = [
        'tenant_id', 'document_id', 'document_type',
        'original_name', 'stored_name', 'path', 'mime_type', 'size',
    ];
}
```

### D.2 사이드바 메뉴 추가 (구현 완료 후)

MNG에서 메뉴는 DB의 `menus` 테이블에 저장된다. 시더 실행 금지.
사용자에게 tinker 명령 안내:

```bash
# 품목관리 메뉴 추가 (부모 ID는 실제 확인 후 변경)
docker exec sam-mng-1 php artisan tinker --execute="
App\Models\Commons\Menu::create([
    'tenant_id' => 1,
    'parent_id' => <부모메뉴ID>,
    'name' => '품목관리',
    'url' => '/item-management',
    'icon' => 'heroicon-o-cube',
    'sort_order' => 1,
    'is_active' => true,
]);
"
```

### D.3 품목 유형 정리

| 코드 | 이름 | 설명 | BOM 자식 가능 |
|------|------|------|:------------:|
| FG | 완제품 (Finished Goods) | 최종 판매 제품 | ✅ 주로 있음 |
| PT | 부품 (Parts) | 조립/가공 부품 | ✅ 있을 수 있음 |
| SM | 부자재 (Sub Materials) | 보조 자재 | ❌ 일반적으로 없음 |
| RM | 원자재 (Raw Materials) | 원재료 | ❌ 리프 노드 |
| CS | 소모품 (Consumables) | 소모성 자재 | ❌ 리프 노드 |

### D.4 items.bom JSON 구조

```json
// items.bom 필드 예시 (FG 완제품)
[
    {"child_item_id": 5, "quantity": 2.5},
    {"child_item_id": 8, "quantity": 1},
    {"child_item_id": 12, "quantity": 0.5}
]
// child_item_id는 같은 items 테이블의 다른 행을 참조
// quantity는 소수점 가능 (단위에 따라 kg, m, EA 등)
```

### D.5 items.options JSON 구조

```json
{
    "lot_managed": true,           // LOT 추적 여부
    "consumption_method": "auto",  // auto/manual/none
    "production_source": "self_produced", // purchased/self_produced/both
    "input_tracking": true         // 원자재 투입 추적
}
```

---

*이 문서는 /plan 스킬로 생성되었습니다. 자기완결성 보강: 2026-02-19*