Files
sam-docs/plans/archive/mng-item-management-plan.md
권혁성 06a4c798ec chore: 완료 계획 문서 22개 archive 이동 및 인덱스 업데이트
- 완료된 계획 문서 22개를 plans/archive/로 이동
  - tracked 16개 (git mv): bending-lot-pipeline, docs-update, fcm-notification 등
  - untracked 6개 (mv): bending-worklog, formula-engine, mng-item 등
- index_plans.md 전면 업데이트
  - 진행중 44개 / 완료 37개 현황 반영
  - 각 문서별 실제 진행률 기재 (0%~94%)
  - 카테고리별 재정리 (견적/생산/품목/문서/마이그레이션/시스템/UI)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:02:47 +09:00

52 KiB

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/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 스키마 (기존 테이블 활용, 변경 없음)

-- 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 트리 재귀 로직

// 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):

{
    "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 기존 패턴 유지

// 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/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
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
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
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

// 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 품목 선택 시 중앙+우측 갱신

// 품목 선택 함수 (좌측/중앙 공용)
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
// 참고: 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')에 있는 경우):

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
// 참고: 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
// 참고: 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"로 로드.

{{-- 참고: 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 구조:

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

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

routes/api.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 모델 패턴

// 참고: 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
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
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
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
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
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 명령 안내:

# 품목관리 메뉴 추가 (부모 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 구조

// 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 구조

{
    "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