# FG 제품코드 통합 계획 > **작성일**: 2026-02-19 > **목적**: FG 제품코드에서 설치유형/마감재질을 분리하여 위치별 설정으로 이동, 18개 FG 품목을 6개로 통합 > **기준 문서**: `docs/rules/item-policy.md`, `docs/features/quotes/README.md` > **상태**: 🔄 진행중 --- ## 📍 현재 진행 상태 | 항목 | 내용 | |------|------| | **마지막 완료 작업** | 영향도 분석 완료, 혼합형 validation 수정 커밋 완료 | | **다음 작업** | Phase 1: DB 마이그레이션 | | **진행률** | 0/8 (0%) | | **마지막 업데이트** | 2026-02-19 | --- ## 1. 개요 ### 1.1 배경 현재 경동기업(tenant_id=287) FG 품목 코드 체계: ``` FG-KWE01-벽면형-SUS (모델: KWE01, 설치유형: 벽면형, 마감재질: SUS) FG-KWE01-벽면형-EGI (모델: KWE01, 설치유형: 벽면형, 마감재질: EGI) FG-KWE01-측면형-SUS (모델: KWE01, 설치유형: 측면형, 마감재질: SUS) ... (총 18개 = 6모델 × {벽면형,측면형} × {SUS,EGI} + 혼합형 추가 예정) ``` 문제점: - 설치유형/마감재질은 **위치(Location)별 설정**이지 제품 자체의 속성이 아님 - 같은 모델(KWE01)인데 FG 코드가 4개 이상으로 분산 - 혼합형 추가 시 FG 품목이 계속 늘어남 (6모델 × 3설치유형 × 2마감재질 = 36개) ### 1.2 목표 코드 체계 ``` AS-IS: FG-KWE01-벽면형-SUS → TO-BE: KWE01 ``` - "FG-" 접두사 제거: `item_type = 'FG'` 컬럼이 이미 완제품 구분 담당 - 설치유형(벽면형/측면형/혼합형) 제거: 위치별 `guideRailType` 파라미터로 전달 - 마감재질(SUS/EGI) 제거: 위치별 `finishingType` 파라미터로 전달 ### 1.3 기준 원칙 ``` ┌─────────────────────────────────────────────────────────────────┐ │ 🎯 핵심 원칙 │ ├─────────────────────────────────────────────────────────────────┤ │ 1. 코어 계산 로직(KyungdongFormulaHandler) 변경 없음 │ │ 2. BOM은 child_item_id FK 기반 → 코드 변경에 안전 │ │ 3. product_model/finishing_type은 이미 별도 파라미터 전달 중 │ │ 4. 기존 quote_items에 FG 코드 참조 데이터 없음 (마이그레이션 부담 ↓) │ └─────────────────────────────────────────────────────────────────┘ ``` ### 1.4 변경 승인 정책 | 분류 | 예시 | 승인 | |------|------|------| | ✅ 즉시 가능 | React UI에 마감재질 Select 추가, validation 규칙 수정 | 불필요 | | ⚠️ 컨펌 필요 | items 테이블 데이터 통합, BOM parent_item_id 재매핑, 시더 수정 | **필수** | | 🔴 금지 | items 테이블 스키마 변경, 기존 BOM 삭제, 견적 계산 코어 로직 변경 | 별도 협의 | ### 1.5 준수 규칙 - `docs/rules/item-policy.md` - 품목 정책 - `docs/standards/quality-checklist.md` - 품질 체크리스트 - `docs/features/quotes/README.md` - 견적 시스템 --- ## 2. 대상 범위 ### 2.1 Phase 1: DB 마이그레이션 (items 통합) | # | 작업 항목 | 상태 | 비고 | |---|----------|:----:|------| | 1.1 | 18개 FG 품목 → 6개로 통합 마이그레이션 스크립트 | ⏳ | items.code 변경 | | 1.2 | BOM parent_item_id 재매핑 | ⏳ | 통합된 item_id로 변경 | | 1.3 | 통합 대상 외 12개 FG 품목 soft delete | ⏳ | 연결된 BOM 확인 후 | | 1.4 | MapItemsToProcesses globalExcludes 수정 | ⏳ | 'FG-%' → item_type 기반 | ### 2.2 Phase 2: API 수정 | # | 작업 항목 | 상태 | 비고 | |---|----------|:----:|------| | 2.1 | FormulaEvaluatorService: finishing_type 파라미터 수신 | ⏳ | 마감재질 매핑 추가 | | 2.2 | QuoteBomCalculateRequest: finishingType validation 추가 | ⏳ | SUS/EGI | | 2.3 | QuoteBomBulkCalculateRequest: finishingType validation 추가 | ⏳ | SUS/EGI | | 2.4 | KyungdongItemSeeder 수정 (향후 시딩용) | ⏳ | FG-코드 생성 로직 | ### 2.3 Phase 3: React 프론트엔드 | # | 작업 항목 | 상태 | 비고 | |---|----------|:----:|------| | 3.1 | LocationDetailPanel: 마감재질 Select UI 추가 | ⏳ | SUS/EGI 선택 | | 3.2 | LocationListPanel: 마감재질 컬럼/폼필드 추가 | ⏳ | 위치 추가 시 | | 3.3 | types.ts: QuoteLocation에 finishingType 추가 | ⏳ | | | 3.4 | actions.ts: BOM 산출 요청에 finishingType 포함 | ⏳ | | | 3.5 | QuoteRegistration.tsx: mock 데이터 업데이트 | ⏳ | | | 3.6 | QuoteSummaryPanel/PreviewContent: 마감재질 표시 | ⏳ | | ### 2.4 Phase 4: 검증 | # | 작업 항목 | 상태 | 비고 | |---|----------|:----:|------| | 4.1 | 통합 전후 BOM 계산 결과 비교 테스트 | ⏳ | 동일 입력 → 동일 결과 | | 4.2 | 견적 등록 → 산출 → 저장 E2E 테스트 | ⏳ | | --- ## 3. 작업 절차 ### 3.1 단계별 절차 ``` Step 1: DB 마이그레이션 스크립트 작성 ├── 6개 모델별 대표 FG 품목 선정 (유지할 item_id 결정) ├── BOM parent_item_id를 대표 item_id로 재매핑 ├── 대표 품목의 code를 통합 코드로 변경 (KWE01 등) ├── 대표 품목의 attributes에서 guiderail_type/finishing_type 제거 └── 나머지 12개 FG 품목 soft delete Step 2: API 수정 ├── FormRequest에 finishingType/FT validation 추가 ├── FormulaEvaluatorService에 FT → finishing_type 매핑 추가 ├── MapItemsToProcesses globalExcludes → item_type 기반 변경 └── KyungdongItemSeeder 코드 생성 로직 수정 Step 3: React 프론트엔드 ├── types.ts에 finishingType 필드 추가 ├── LocationDetailPanel에 마감재질 Select 추가 ├── LocationListPanel에 마감재질 폼필드/컬럼 추가 ├── actions.ts BOM 산출 요청에 finishingType 포함 └── Summary/Preview에 마감재질 표시 Step 4: 검증 ├── 동일 입력(KWE01 + wall + SUS)으로 기존 결과와 비교 ├── 모든 조합 테스트 (6모델 × 3설치 × 2마감) └── 견적 등록 → 산출 → 저장 E2E ``` --- ## 4. 상세 작업 내용 (코드 스니펫 포함) ### 4.1 현재 FG 품목 현황 (tenant_id=287) | 모델 | 벽면형-SUS | 벽면형-EGI | 측면형-SUS | 측면형-EGI | 통합 코드 | item_category | |------|-----------|-----------|-----------|-----------|----------|:------------:| | KWE01 | FG-KWE01-벽면형-SUS | FG-KWE01-벽면형-EGI | FG-KWE01-측면형-SUS | FG-KWE01-측면형-EGI | **KWE01** | SCREEN | | KWE02 | (동일 패턴) | | | | **KWE02** | SCREEN | | KWE03 | | | | | **KWE03** | SCREEN | | KWS01 | | | | | **KWS01** | STEEL | | KWS02 | | | | | **KWS02** | STEEL | | KWS03 | | | | | **KWS03** | STEEL | > KWE = 스크린(SCREEN), KWS = 철재(STEEL). item_category는 유지됨 (계산 분기에 사용) FG 코드 생성 원본 (`api/database/seeders/Kyungdong/KyungdongItemSeeder.php:305-307`): ```php $finishingShort = self::FINISHING_MAP[$model->finishing_type] ?? 'STD'; $code = "FG-{$model->model_name}-{$model->guiderail_type}-{$finishingShort}"; $name = "{$model->model_name} {$model->major_category} {$model->finishing_type} {$model->guiderail_type}"; ``` FINISHING_MAP (`KyungdongItemSeeder.php:39-42`): ```php private const FINISHING_MAP = [ 'SUS마감' => 'SUS', 'EGI마감' => 'EGI', ]; ``` items.attributes 구조: ```json { "model_name": "KWE01", "major_category": "스크린", "finishing_type": "SUS마감", "guiderail_type": "벽면형", "legacy_source": "models", "legacy_model_id": 123 } ``` ### 4.2 BOM 재매핑 전략 BOM은 FG 품목(parent)의 `items.bom` JSON 컬럼에 저장: ```json [ { "child_item_id": 123, "quantity": 1 }, { "child_item_id": 456, "quantity": 2 } ] ``` 마이그레이션 SQL 전략: ```sql -- Step 1: 모델별 대표 FG 품목 선정 (벽면형-SUS를 대표로) -- 대표 선정 기준: 같은 model_name 중 가장 작은 id -- Step 2: 대표 품목의 code 변경 UPDATE items SET code = 'KWE01' WHERE id = (대표_item_id) AND tenant_id = 287; -- Step 3: 대표 품목의 attributes에서 guiderail_type/finishing_type 제거 -- (이 속성들은 더 이상 품목 고유 속성이 아님) -- Step 4: 비대표 품목의 BOM을 대표 품목으로 이관 -- (동일 모델의 BOM은 동일하므로, BOM이 있는 품목의 bom을 대표로 복사) -- Step 5: 비대표 12개 품목 soft delete UPDATE items SET deleted_at = NOW(), deleted_by = 1 WHERE tenant_id = 287 AND item_type = 'FG' AND id NOT IN (대표_item_ids); ``` 핵심 안전 요소: - BOM의 `child_item_id`는 PT/SM 품목 → FG 통합과 **무관** - `FormulaEvaluatorService::getItemDetails()` (line 1110-1112)에서 `->where('code', $itemCode)` 조회 - 통합 후 code가 'KWE01'이 되면 `getItemDetails('KWE01')`로 정상 조회 ### 4.3 API 파라미터 흐름 (통합 후) ``` Frontend (LocationDetailPanel) ├── productCode: "KWE01" (통합 코드) ├── guideRailType: "wall" | "floor" | "mixed" ├── finishingType: "SUS" | "EGI" ← 새로 추가 └── motorPower: "single" | "three" ↓ actions.ts::calculateBomBulk() - POST /api/v1/quotes/calculate/bom/bulk body: { items: [{ finished_goods_code, openWidth, openHeight, guideRailType, motorPower, finishingType, ... }] } ↓ QuoteBomBulkCalculateRequest::normalizeInputVariables() (line 122-135) ├── 'W0' => openWidth, 'H0' => openHeight ├── 'GT' => guideRailType, 'MP' => motorPower └── 'FT' => finishingType ← 새로 추가 ↓ FormulaEvaluatorService::calculateKyungdongBom() (line 1574~) ├── getItemDetails("KWE01", tenantId) → items.code = "KWE01" 조회 (line 1110-1112) ├── $finishingType: FT → SUS/EGI ← 기존 line 1677 수정 ├── $installationType: GT → 벽면형/측면형/혼합형 (line 1680-1684) └── $motorVoltage: MP → 220V/380V (line 1687-1690) ↓ $calculatedVariables = array_merge() (line 1692-1708) 'finishing_type' => $finishingType (line 1705) ← 이미 포함됨 ↓ KyungdongFormulaHandler (변경 없음) ├── calculateSteelItems() line 458: $rawFinish = $params['finishing_type'] ?? 'SUS' ├── calculateGuideRails() line 540: $finishingType 파라미터 └── getBottomBarPrice() line 561: $finishingType 파라미터 ``` ### 4.4 핵심 파일별 변경 상세 --- #### 4.4.1 `api/app/Services/Quote/FormulaEvaluatorService.php` **현재 코드 (line 1676-1677):** ```php $productModel = $inputVariables['product_model'] ?? 'KSS01'; $finishingType = $inputVariables['finishing_type'] ?? 'SUS'; ``` **수정 후:** ```php $productModel = $inputVariables['product_model'] ?? 'KSS01'; // 마감재질: 프론트 FT(SUS/EGI) → finishing_type 매핑 $finishingType = $inputVariables['finishing_type'] ?? match ($inputVariables['FT'] ?? 'SUS') { 'EGI' => 'EGI', default => 'SUS', }; ``` > `$calculatedVariables` array_merge (line 1705)에는 이미 `'finishing_type' => $finishingType` 포함됨 --- #### 4.4.2 `api/app/Http/Requests/Quote/QuoteBomCalculateRequest.php` **현재 rules() (line 20-39)에 추가:** ```php // 기존 'GT' => 'nullable|string|in:wall,ceiling,floor,mixed', 'MP' => 'nullable|string|in:single,three', // 추가 'FT' => 'nullable|string|in:SUS,EGI', ``` **현재 getInputVariables() (line 74-89)에 추가:** ```php // 기존 'MP' => $validated['MP'] ?? 'single', // 추가 'FT' => $validated['FT'] ?? 'SUS', ``` --- #### 4.4.3 `api/app/Http/Requests/Quote/QuoteBomBulkCalculateRequest.php` **rules() (line 21-54)에 추가:** ```php // React 필드명 (camelCase) 'items.*.finishingType' => 'nullable|string|in:SUS,EGI', // API 변수명 (약어) 'items.*.FT' => 'nullable|string|in:SUS,EGI', ``` **normalizeInputVariables() (line 122-135)에 추가:** ```php // 기존 'MP' => $item['motorPower'] ?? $item['MP'] ?? 'single', // 추가 'FT' => $item['finishingType'] ?? $item['FT'] ?? 'SUS', ``` --- #### 4.4.4 `api/app/Console/Commands/MapItemsToProcesses.php` **현재 (line 48):** ```php private array $globalExcludes = ['FG-%', 'RM-%', 'EST-INSPECTION']; ``` **수정 후:** ```php private array $globalExcludes = ['RM-%', 'EST-INSPECTION']; // FG 제외는 item_type 기반으로 처리 (아래 쿼리에서 ->where('item_type', '!=', 'FG') 추가) ``` > 해당 명령어에서 items 조회 시 `->whereNotIn('item_type', ['FG'])` 조건 추가 --- #### 4.4.5 `api/database/seeders/Kyungdong/KyungdongItemSeeder.php` **현재 (line 305-307):** ```php $finishingShort = self::FINISHING_MAP[$model->finishing_type] ?? 'STD'; $code = "FG-{$model->model_name}-{$model->guiderail_type}-{$finishingShort}"; $name = "{$model->model_name} {$model->major_category} {$model->finishing_type} {$model->guiderail_type}"; ``` **수정 후:** ```php $code = $model->model_name; // KWE01, KWS01 등 $name = "{$model->model_name} {$model->major_category}"; ``` > 중복 방지: 같은 model_name은 하나만 생성 (기존: 설치유형×마감재질 조합별 생성 → 모델별 1개) --- #### 4.4.6 `react/src/components/quotes/types.ts` **LocationItem 인터페이스 (line 664-686)에 추가:** ```typescript export interface LocationItem { // ... 기존 필드 guideRailType: string; // 가이드레일 설치 유형 finishingType: string; // 마감재질 (SUS/EGI) ← 추가 motorPower: string; // 모터 전원 // ... } ``` --- #### 4.4.7 `react/src/components/quotes/actions.ts` **BomCalculateItem 인터페이스 (line 343-354)에 추가:** ```typescript export interface BomCalculateItem { finished_goods_code: string; openWidth: number; openHeight: number; quantity?: number; guideRailType?: string; finishingType?: string; // ← 추가 motorPower?: string; controller?: string; wingSize?: number; inspectionFee?: number; } ``` --- #### 4.4.8 `react/src/components/quotes/LocationDetailPanel.tsx` **상수 추가 (line 75 뒤):** ```typescript // 마감재질 const FINISHING_TYPES = [ { value: "SUS", label: "SUS (스테인리스)" }, { value: "EGI", label: "EGI (아연도금)" }, ]; ``` **2행 그리드 변경 (line 358-423):** 현재 `grid-cols-3` (가이드레일, 전원, 제어기) → `grid-cols-4`로 변경하고 마감재질 Select 추가: ```tsx {/* 2행: 가이드레일, 마감재질, 전원, 제어기 */}