# 생산지시 시 bending_info 자동 생성 계획 > **작성일**: 2026-02-19 > **목적**: 수주 → 생산지시 시 절곡 공정의 bending_info JSON을 work_orders.options에 자동 삽입 > **기준 문서**: `api/app/Services/OrderService.php` (createProductionOrder), `react/.../bending/types.ts` > **상태**: 🔄 진행중 --- ## 📍 현재 진행 상태 | 항목 | 내용 | |------|------| | **마지막 완료 작업** | 분석 완료 + 계획 문서 작성 | | **다음 작업** | Phase 1.1: BendingInfoBuilder 서비스 생성 | | **진행률** | 0/7 (0%) | | **마지막 업데이트** | 2026-02-20 | --- ## 1. 개요 ### 1.1 배경 현재 절곡 작업일지(BendingWorkLogContent)에 표시할 bending_info 데이터를 **수동으로 DB에 INSERT** 해야 함. 수주(Order) → 생산지시(WorkOrder) 생성 시 `OrderService::createProductionOrder()`에서 자동으로 bending_info를 생성하여 `work_orders.options.bending_info`에 저장하는 로직이 필요함. ### 1.2 현재 데이터 흐름 vs 목표 #### 현재 (Before) ``` OrderNode.options ├─ product_code: "FG-KSS02-벽면형-SUS" ├─ width: 3560, height: 4450 └─ bom_result.items[]: (steel category BOM 품목) → OrderService::createProductionOrder() (라인 959) → WorkOrder::create() (라인 1111) → ⚠️ options 미설정 (null) → work_order_items INSERT (라인 1183) → options.bending_info = node.options.bending_info ?? null (라인 1179) → ⚠️ 현재 order_nodes.options에 bending_info 없음 → null 저장 ``` #### 목표 (After) ``` OrderService::createProductionOrder() (라인 959) │ ├─ 공정별 아이템 그룹핑 (라인 1035~1089) │ └─ $itemsByProcess[$processId] = [items...] │ ├─ foreach ($itemsByProcess) → WorkOrder 생성 (라인 1103) │ │ │ ├─ 절곡 공정인지 확인 (process.process_name === '절곡') │ │ └─ YES → BendingInfoBuilder::build($order, $processId) │ │ ├─ OrderNode.options.product_code 파싱 │ │ ├─ OrderNode.options.bom_result.items 분석 │ │ └─ bending_info JSON 조립 │ │ │ └─ WorkOrder::create([ │ ...기존 필드들, │ 'options' => ['bending_info' => $bendingInfo] ← 신규 │ ]) (라인 1111) │ └─ work_order_items INSERT (라인 1183, 기존 유지) ``` #### 핸들러 자동 생성 원리 ``` BendingInfoBuilder::build($order, $processId) │ ├─ 1. 절곡 공정 확인 (process.process_name === '절곡') │ ├─ 2. product_code 파싱 │ └─ "FG-KSS02-벽면형-SUS" → productCode: "KSS02", guideType: "벽면형", finishMaterial: "SUS마감" │ ├─ 3. BOM items 카테고리 분류 (item_code 패턴 매칭) │ ├─ BD-가이드레일-* → guideRail │ ├─ BD-케이스-* → shutterBox │ ├─ BD-마구리-* → shutterBox (마구리) │ ├─ *하장바* → bottomBar │ ├─ EST-SMOKE-* → smokeBarrier │ ├─ BD-L-BAR-* → detailParts │ └─ BD-보강평철-* → detailParts │ ├─ 4. 다중 노드 집계 (길이별 수량 합산) │ ├─ height → 가이드레일 길이별 수량 │ ├─ width → 셔터박스/하단마감재 길이별 수량 │ └─ BOM quantity × 노드 수 → 총 수량 │ └─ 5. BendingInfoExtended 구조 JSON 반환 ``` ### 1.3 기준 원칙 ``` ┌─────────────────────────────────────────────────────────────────┐ │ 🎯 핵심 원칙 │ ├─────────────────────────────────────────────────────────────────┤ │ 1. BendingInfoBuilder는 독립 서비스 (OrderService 변경 최소화) │ │ 2. 기존 createProductionOrder 흐름은 유지, options 삽입만 추가 │ │ 3. order_nodes.options.bom_result + product_code가 유일한 소스 │ │ 4. 프론트엔드 BendingInfoExtended 인터페이스 완전 호환 │ │ 5. 절곡 공정이 아닌 WorkOrder에는 절대 bending_info 미생성 │ │ 6. 기존 work_order_items.options.bending_info 흐름은 유지 (하위호환) │ └─────────────────────────────────────────────────────────────────┘ ``` ### 1.4 변경 승인 정책 | 분류 | 예시 | 승인 | |------|------|------| | ✅ 즉시 가능 | BendingInfoBuilder 서비스 클래스 신규 생성, 헬퍼 메서드 추가 | 불필요 | | ⚠️ 컨펌 필요 | OrderService::createProductionOrder 수정 (options 삽입), BOM 파싱 규칙 확정 | **필수** | | 🔴 금지 | 기존 BOM 계산 로직 변경, order_nodes 스키마 변경, 기존 work_order_items.options 구조 변경 | 별도 협의 | --- ## 2. 현황 분석 ### 2.1 OrderService::createProductionOrder 흐름 (라인 959~1214) 현재 `createProductionOrder`는 다음 순서로 동작: ``` 1. 수주 로드 (라인 966) $order = Order::with(['items', 'rootNodes'])->findOrFail($orderId) 2. 공정별 아이템 매핑 조회 (라인 1008~1014) DB::table('process_items') → $itemProcessMap 3. 아이템을 공정별로 그룹핑 (라인 1035~1089) 3단계 fallback: ├─ item_id → process_items 직접 매핑 (라인 1041~1042) ├─ order_node_id → BOM item_name → process_items (라인 1045~1050) └─ item_code → item_id → process_items (라인 1054~1078) 결과: $itemsByProcess[$processId] = ['items' => [...], 'processId' => int] 4. 공정별 WorkOrder 생성 (라인 1103) foreach ($itemsByProcess as $key => $group) { $workOrder = WorkOrder::create([...]) // 라인 1111~1124 // ⚠️ 현재 'options' 필드 미설정 } 5. work_order_items INSERT (라인 1183~1197) $woItemOptions = [ 'floor', 'code', 'width', 'height', 'cutting_info', 'slat_info', 'bending_info' => $nodeOptions['bending_info'] ?? null, // 라인 1179 'wip_info' ] ``` **핵심 발견**: WorkOrder::create (라인 1111~1124)에 `options` 필드가 **전혀 설정되지 않음**. bending_info는 `work_order_items.options`에만 들어가는데, 이마저도 `order_nodes.options.bending_info`가 null이면 null 저장. ### 2.2 order_nodes.options 구조 (실제 데이터) order_id=43 (WO 74의 원천 수주)의 root_nodes (id=116~125, 5개소 × 2=10 노드): ```json { "product_code": "FG-KSS02-벽면형-SUS", "width": 3560, "height": 4450, "bom_result": { "items": [ { "item_code": "BD-케이스-500*380", "item_name": "케이스 500*380", "category": "steel", "quantity": 3.62 }, { "item_code": "BD-마구리-505*385", "item_name": "마구리 505*385", "category": "steel", "quantity": 1 }, { "item_code": "BD-가이드레일-KSS02-SUS-120*70", "item_name": "가이드레일 KSS02...", "category": "steel", "quantity": 8.7 }, { "item_code": "EST-SMOKE-레일용", "item_name": "연기차단재(레일)", "category": "steel", "quantity": 8.7 }, { "item_code": "EST-SMOKE-케이스용", "item_name": "연기차단재(케이스)", "category": "steel", "quantity": 3.62 }, { "item_code": "00035", "item_name": "철재용하장바(SUS)3000", "category": "steel", "quantity": 3.4 }, { "item_code": "BD-L-BAR-KSS02-17*60", "item_name": "L-BAR KSS02...", "category": "steel", "quantity": 3.62 }, { "item_code": "BD-보강평철-50", "item_name": "보강평철 50", "category": "steel", "quantity": 3.62 }, // ... (parts, motor, controller 등 다른 category도 포함) ] } } ``` **중요**: `bom_result.items[]`에는 `category: "steel"`인 아이템만 절곡(bending) 관련. `parts`, `motor`, `controller` 등은 다른 공정용. ### 2.3 work_orders.options 현재 상태 | work_order_id | process_name | options | |---------------|-------------|---------| | 74 (수동 삽입) | 절곡 | `{"bending_info": {...전체 JSON...}}` | | 기타 | 절곡 외 | `null` | - WO 74만 수동으로 bending_info를 삽입한 상태 - 다른 WorkOrder는 options가 null (createProductionOrder에서 미설정) ### 2.4 프론트엔드 데이터 소비 경로 ``` API: GET /work-orders/{id} → WorkOrderService::show() → WorkOrder 모델 (options JSON 자동 디코딩) → API 응답: { ..., options: { bending_info: {...} } } React: transformApiToFrontend() (types.ts 라인 495) → bendingInfo: api.options?.bending_info || undefined → BendingWorkLogContent에 props.bendingInfo로 전달 → BendingInfoExtended 인터페이스로 사용 ``` ### 2.5 BendingInfoExtended 구조 (프론트엔드 목표 스키마) ```typescript // react/src/components/production/WorkOrders/documents/bending/types.ts (라인 32~68) interface BendingInfoExtended { productCode: string; // "KSS02" finishMaterial: string; // "SUS마감" common: { kind: string; // "혼합형 120X70" type: string; // "벽면형(120*70)" lengthQuantities: LengthQuantity[]; // [{length: 4450, quantity: 5}] }; detailParts: Array<{ partName: string; // "엘바", "하장바" material: string; // "EGI 1.6T" barcyInfo: string; // "16 I 75" }>; guideRail: { wall: GuideRailTypeData | null; // 벽면형 가이드레일 side: GuideRailTypeData | null; // 측면형 가이드레일 }; bottomBar: { material: string; // "SUS 1.5T" extraFinish: string; // "없음" length3000Qty: number; length4000Qty: number; }; shutterBox: ShutterBoxData[]; // [{direction, size, lengths[]}] smokeBarrier: { w50: LengthQuantity[]; // 레일용 W50 w80Qty: number; // 케이스용 W80 수량 }; } ``` ### 2.6 BOM item_code → bending_info 카테고리 매핑 | item_code 패턴 | bending_info 필드 | 추출 정보 | 확인된 실제 코드 | |----------------|-------------------|----------|----------------| | `BD-케이스-{W}*{H}` | `shutterBox[].size` | 사이즈 "500*380" | BD-케이스-500*380 | | `BD-마구리-{W}*{H}` | `shutterBox[].finCoverQty` | 마구리 수량 | BD-마구리-505*385 | | `BD-가이드레일-{model}-{finish}-{W}*{H}` | `guideRail.wall/side` | baseSize "120*70" | BD-가이드레일-KSS02-SUS-120*70 | | `EST-SMOKE-레일용` | `smokeBarrier.w50` | 레일 연기차단재 수량 | EST-SMOKE-레일용 | | `EST-SMOKE-케이스용` | `smokeBarrier.w80Qty` | 케이스 연기차단재 수량 | EST-SMOKE-케이스용 | | `BD-L-BAR-{model}-{W}*{H}` | `detailParts[]` | L-BAR 상세 | BD-L-BAR-KSS02-17*60 | | `BD-보강평철-{size}` | `detailParts[]` | 보강평철 상세 | BD-보강평철-50 | | `*하장바*` (item_name 기준) | `bottomBar` | 하장바 길이/마감 | 철재용하장바(SUS)3000 (코드: 00035) | ### 2.7 product_code 파싱 규칙 `FG-KSS02-벽면형-SUS` 패턴: | 세그먼트 위치 | 의미 | 추출 규칙 | 예시값 | |--------------|------|----------|--------| | 0 | 접두사 | 무시 (항상 "FG") | FG | | 1 | 제품 모델 | `productCode` | KSS02 | | 2 | 가이드레일 타입 | `guideType` (벽면형/측면형/혼합형) | 벽면형 | | 3 | 마감재 | `finishMaterial` → "SUS" → "SUS마감", "EGI" → "EGI마감" | SUS | ### 2.8 재질 매핑 (getMaterialMapping 기반) ``` Group 1 - SUS 전용 (KQTS01, KSS01, KSS02): guideRailFinish: "SUS 1.2T" bodyMaterial: "EGI 1.55T" bottomBarFinish: "SUS 1.5T" Group 2 - KTE01 (마감유형 분기): SUS마감 → guideRailFinish: "EGI 1.55T" + extraFinish: "SUS 1.2T" EGI마감 → guideRailFinish: "EGI 1.55T" (extra 없음) Group 3 - 기타 (KSE01, KWE01): SUS마감 → guideRailFinish: "EGI 1.55T" + extraFinish: "SUS 1.2T" EGI마감 → guideRailFinish: "EGI 1.55T" (extra 없음) ``` --- ## 3. 대상 범위 ### Phase 1: BendingInfoBuilder 서비스 (백엔드 핵심) | # | 작업 항목 | 상태 | 비고 | |---|----------|:----:|------| | 1.1 | BendingInfoBuilder 서비스 클래스 생성 | ⏳ | `api/app/Services/Production/BendingInfoBuilder.php` | | 1.2 | parseProductCode() 구현 | ⏳ | "FG-KSS02-벽면형-SUS" → productCode, guideType, finishMaterial | | 1.3 | categorizeBomItem() 구현 | ⏳ | item_code 패턴 → 8개 카테고리 분류 | | 1.4 | aggregateNodes() 구현 | ⏳ | 다중 노드 → 길이별 수량 합산, 셔터박스 집계 | | 1.5 | build() 메인 메서드 구현 | ⏳ | 전체 조립 → BendingInfoExtended JSON 반환 | ### Phase 2: createProductionOrder 통합 | # | 작업 항목 | 상태 | 비고 | |---|----------|:----:|------| | 2.1 | OrderService (라인 1111) WorkOrder::create에 options 추가 | ⏳ | ⚠️ 컨펌 필요 | | 2.2 | 절곡 공정 감지 로직 추가 | ⏳ | Process 모델 조회 → process_name === '절곡' | ### Phase 3: 검증 및 테스트 | # | 작업 항목 | 상태 | 비고 | |---|----------|:----:|------| | 3.1 | WO 74 데이터로 역검증 (order_id=43, 동일 입력 → 동일 출력) | ⏳ | | | 3.2 | 프론트엔드 작업일지 정상 렌더링 확인 | ⏳ | BendingWorkLogContent | | 3.3 | 비절곡 공정 WorkOrder에 bending_info 미생성 확인 | ⏳ | | --- ## 4. 작업 절차 ### 4.1 단계별 절차 ``` Phase 1: BendingInfoBuilder 서비스 생성 ├── 1.1 파일 생성: api/app/Services/Production/BendingInfoBuilder.php │ ├── 클래스: BendingInfoBuilder │ └── 메서드: build(Order $order, int $processId): ?array │ ├── 1.2 product_code 파서 구현 │ ├── private parseProductCode(string $fullCode): array │ ├── 입력: "FG-KSS02-벽면형-SUS" │ └── 출력: ['productCode'=>'KSS02', 'guideType'=>'벽면형', 'finishMaterial'=>'SUS마감'] │ ├── 1.3 BOM item_code 카테고리 분류기 구현 │ ├── private categorizeBomItem(array $bomItem): ?string │ ├── 8개 패턴 매칭 (부록 A 참조) │ └── 미매칭 → null 반환 (절곡 무관 품목) │ ├── 1.4 노드 집계 로직 구현 │ ├── private aggregateNodes(Collection $nodes): array │ ├── height → 가이드레일 길이별 수량 (guideRailLengths) │ ├── width → 셔터박스 길이별 수량 (shutterBoxLengths) │ ├── BOM steel items → 카테고리별 수량 합산 │ └── 길이별 그룹핑 (동일 치수 노드는 수량 합산) │ └── 1.5 build() 메인 메서드 조립 ├── 절곡 공정 확인 → 아닌 경우 null 반환 ├── parseProductCode → productCode, guideType, finishMaterial ├── aggregateNodes → 집계 데이터 └── BendingInfoExtended 구조 JSON 조립 (부록 B 참조) Phase 2: createProductionOrder 통합 ├── 2.1 OrderService.php 수정 (라인 1111 부근) │ ├── WorkOrder::create 호출 전 BendingInfoBuilder::build() 실행 │ ├── 절곡 공정인 경우에만 options 설정 │ └── 'options' => $bendingInfo ? ['bending_info' => $bendingInfo] : null │ └── 2.2 Process 모델 사전 로드 ├── 라인 1103 foreach 내에서 Process 조회 └── 또는 $itemsByProcess에 process 정보 포함 (기존 로직 활용) Phase 3: 검증 ├── 3.1 order_id=43 (KSS02 벽면형 SUS 5개소 3560x4450) 데이터로 빌더 실행 │ ├── 기존 WO 74 bending_info와 구조 비교 │ └── productCode, guideRail, shutterBox, bottomBar, smokeBarrier 검증 │ ├── 3.2 프론트엔드 렌더링 확인 │ ├── 새로 생성된 WorkOrder의 작업일지 열기 │ └── 4개 섹션 (가이드레일, 셔터박스, 하단마감재, 연기차단재) 정상 표시 │ └── 3.3 비절곡 공정 확인 ├── 같은 수주에서 생성된 비절곡 WorkOrder의 options 확인 └── bending_info가 없어야 함 (options: null 또는 bending_info 키 없음) ``` ### 4.2 BendingInfoBuilder 서비스 설계 ```php // api/app/Services/Production/BendingInfoBuilder.php namespace App\Services\Production; use App\Models\Orders\Order; use App\Models\Production\Process; use Illuminate\Support\Collection; class BendingInfoBuilder { /** * 수주의 노드/BOM 데이터로 bending_info JSON 생성 * * @param Order $order 수주 (rootNodes eager loaded) * @param int $processId 공정 ID (절곡 공정 확인용) * @return array|null bending_info JSON 또는 null (절곡 아닌 경우) */ public function build(Order $order, int $processId): ?array { // 1. 절곡 공정인지 확인 $process = Process::find($processId); if (!$process || $process->process_name !== '절곡') { return null; } // 2. 루트 노드가 없으면 null $nodes = $order->rootNodes; if ($nodes->isEmpty()) { return null; } // 3. 첫 번째 루트 노드에서 product_code 추출 $firstNode = $nodes->first(); $productInfo = $this->parseProductCode( $firstNode->options['product_code'] ?? '' ); // 4. product_code 파싱 실패 시 null if (empty($productInfo['productCode'])) { return null; } // 5. 모든 노드의 BOM items 수집 및 집계 $aggregated = $this->aggregateNodes($nodes, $productInfo); // 6. bending_info 구조 조립 (부록 B 참조) return $this->assembleBendingInfo($productInfo, $aggregated, $nodes); } } ``` ### 4.3 product_code 파서 ```php /** * "FG-KSS02-벽면형-SUS" → ['productCode'=>'KSS02', 'guideType'=>'벽면형', 'finishMaterial'=>'SUS마감'] */ private function parseProductCode(string $fullCode): array { $parts = explode('-', $fullCode); // FG 접두사 제거 if (($parts[0] ?? '') === 'FG') { array_shift($parts); } $finish = $parts[2] ?? 'EGI'; return [ 'productCode' => $parts[0] ?? '', // KSS02 'guideType' => $parts[1] ?? '벽면형', // 벽면형/측면형/혼합형 'finishMaterial' => $finish === 'SUS' ? 'SUS마감' : 'EGI마감', ]; } ``` ### 4.4 BOM item_code 카테고리 분류기 ```php /** * BOM 아이템을 카테고리별로 분류 * 반환값: guideRail, shutterBox_case, shutterBox_finCover, bottomBar, * smokeBarrier_rail, smokeBarrier_case, detail_lbar, detail_reinforce, null */ private function categorizeBomItem(array $bomItem): ?string { $code = $bomItem['item_code'] ?? ''; $name = $bomItem['item_name'] ?? ''; if (str_starts_with($code, 'BD-가이드레일')) return 'guideRail'; if (str_starts_with($code, 'BD-케이스')) return 'shutterBox_case'; if (str_starts_with($code, 'BD-마구리')) return 'shutterBox_finCover'; if (str_contains($name, '하장바')) return 'bottomBar'; if ($code === 'EST-SMOKE-레일용') return 'smokeBarrier_rail'; if ($code === 'EST-SMOKE-케이스용') return 'smokeBarrier_case'; if (str_starts_with($code, 'BD-L-BAR')) return 'detail_lbar'; if (str_starts_with($code, 'BD-보강평철')) return 'detail_reinforce'; return null; // 절곡 무관 품목 (parts, motor 등) } ``` ### 4.5 createProductionOrder 변경 포인트 ```php // OrderService.php 라인 1103~1130 (수정 부분) foreach ($itemsByProcess as $key => $group) { $processId = $group['processId']; $workOrderNo = $this->generateWorkOrderNo($tenantId, $order->id, $processId); // ★ 신규: 절곡 공정이면 bending_info 생성 $options = null; if ($processId) { $bendingInfoBuilder = app(BendingInfoBuilder::class); $bendingInfo = $bendingInfoBuilder->build($order, $processId); if ($bendingInfo) { $options = ['bending_info' => $bendingInfo]; } } $workOrder = WorkOrder::create([ 'tenant_id' => $tenantId, 'work_order_no' => $workOrderNo, 'sales_order_id' => $order->id, 'project_name' => $order->order_no, 'process_id' => $processId, 'status' => WorkOrder::STATUS_PENDING, 'assignee_id' => $data['assignee_id'] ?? null, 'team_id' => $data['team_id'] ?? null, 'scheduled_date' => $data['scheduled_date'] ?? null, 'memo' => $data['memo'] ?? null, 'options' => $options, // ★ 신규 'is_active' => true, 'created_by' => apiUserId(), 'updated_by' => apiUserId(), ]); // ... 기존 work_order_items INSERT 로직 유지 } ``` --- ## 5. 컨펌 대기 목록 | # | 항목 | 변경 내용 | 영향 범위 | 상태 | |---|------|----------|----------|------| | 1 | OrderService 수정 | createProductionOrder 라인 1111에 options 추가 | 생산지시 생성 전체 | ⚠️ 컨펌 필요 | | 2 | item_code 패턴 매핑 | BD-*, EST-SMOKE-*, 하장바 패턴으로 카테고리 분류 | 절곡 BOM 품목 인식 | ⚠️ 컨펌 필요 | | 3 | product_code 파싱 | FG-{code}-{type}-{finish} 4세그먼트 패턴 가정 | 모든 절곡 제품 | ⚠️ 컨펌 필요 | --- ## 6. 변경 이력 | 날짜 | 항목 | 변경 내용 | 파일 | 승인 | |------|------|----------|------|------| | 2026-02-19 | - | 문서 초안 작성 | - | - | | 2026-02-20 | - | formula-engine-real-data-plan.md 형식으로 전면 개편 (현황 분석, 코드 위치, 부록 추가) | - | - | --- ## 7. 참고 문서 - **현재 bending_info 구조**: `react/src/components/production/WorkOrders/documents/bending/types.ts` (라인 32~68) - **재질 매핑 로직**: `react/src/components/production/WorkOrders/documents/bending/utils.ts` (라인 77~108) - **생산지시 서비스**: `api/app/Services/OrderService.php` (createProductionOrder, 라인 959~1214) - **WorkOrder 서비스**: `api/app/Services/WorkOrderService.php` (store, 라인 238~323) - **WorkOrder 모델**: `api/app/Models/Production/WorkOrder.php` - **Order 모델**: `api/app/Models/Orders/Order.php` (rootNodes, 라인 172~178) - **레거시 참고**: `5130/output/proc/viewBendingWork_slat.php` - **WO 74 실데이터**: order_id=43, order_nodes id=116~125 (KSS02 벽면형 SUS, 3560x4450) --- ## 8. 관련 파일 및 코드 위치 ### 8.1 API (api/) - 핵심 코드 위치 | 파일 | 메서드/요소 | 라인 | 역할 | |------|------------|------|------| | `Services/OrderService.php` | `createProductionOrder()` | 959 | 메인 엔트리 (수주→생산지시) | | 같은 파일 | `Order::with(['items', 'rootNodes'])` | 966 | 수주 + 루트노드 로드 | | 같은 파일 | `$itemsByProcess` 그룹핑 | 1035~1089 | 공정별 아이템 분류 (3단계 fallback) | | 같은 파일 | `foreach ($itemsByProcess)` | 1103 | **공정별 WorkOrder 생성 루프** | | 같은 파일 | `WorkOrder::create([...])` | 1111~1124 | **★ 변경 포인트: options 추가** | | 같은 파일 | `$woItemOptions` 구성 | 1172~1181 | work_order_items.options 조립 | | 같은 파일 | `'bending_info' => $nodeOptions['bending_info'] ?? null` | 1179 | items 레벨 bending_info (기존, 유지) | | 같은 파일 | `DB::table('work_order_items')->insert()` | 1183~1197 | items INSERT | | 같은 파일 | `$order->status_code = Order::STATUS_IN_PROGRESS` | 1204 | 수주 상태 변경 | | 같은 파일 | `generateWorkOrderNo()` | 1270 | 작업지시 번호 생성 | | `Services/WorkOrderService.php` | `store()` | 238 | 대체 생성 경로 (수동 생성용) | | 같은 파일 | `'bending_info' => $nodeOptions['bending_info'] ?? null` | 279 | items 레벨 bending_info (유지) | | 같은 파일 | `$workOrder->isBending()` | 306 | 절곡 공정 확인 | | **신규** `Services/Production/BendingInfoBuilder.php` | `build()` | - | **Phase 1에서 생성** | | `Models/Production/WorkOrder.php` | `$fillable` (options 포함) | 32~51 | options 필드 (라인 47) | | 같은 파일 | `$casts` (options => json) | 53~60 | JSON 자동 변환 (라인 59) | | 같은 파일 | `isBending()` | 342~345 | `process.process_name === '절곡'` | | 같은 파일 | `process()` 관계 | 139~144 | `belongsTo(Process::class)` | | 같은 파일 | `PROCESS_BENDING` (deprecated) | 80 | 상수 (미사용, FK 방식으로 전환됨) | | `Models/Orders/Order.php` | `rootNodes()` | 172~178 | `hasMany(OrderNode)->whereNull('parent_id')` | ### 8.2 React (react/) - 프론트엔드 코드 위치 | 파일 | 요소 | 라인 | 역할 | |------|------|------|------| | `types.ts` (WorkOrders/) | `WorkOrderApi.options` | 343 | `options?: { bending_info?: Record }` | | 같은 파일 | `transformApiToFrontend()` | 495 | `bendingInfo: api.options?.bending_info \|\| undefined` | | 같은 파일 | item 레벨 bendingInfo (deprecated) | 487 | `bendingInfo: undefined` (명시적 무시) | | 같은 파일 | `WorkOrder.bendingInfo` | 210 | 프론트 모델 필드 정의 | | `bending/types.ts` | `BendingInfoExtended` | 32~68 | **목표 JSON 스키마** | | 같은 파일 | `GuideRailTypeData` | 5~13 | 가이드레일 타입 데이터 | | 같은 파일 | `ShutterBoxData` | 15~22 | 셔터박스 데이터 | | 같은 파일 | `LengthQuantity` | 24~27 | 길이-수량 쌍 | | `bending/utils.ts` | `getMaterialMapping()` | 77~108 | productCode → 재질 매핑 | ### 8.3 DB 테이블 #### work_orders 테이블 (변경 대상) | 컬럼 | 타입 | NULL | 설명 | |------|------|------|------| | id | bigint unsigned | NO | PK | | tenant_id | bigint unsigned | NO | 테넌트 | | work_order_no | varchar(50) | NO | 작업지시 번호 | | sales_order_id | bigint unsigned | YES | 수주 FK | | process_id | bigint unsigned | YES | 공정 FK | | **options** | **json** | **YES** | **★ bending_info 저장 대상** | | status | varchar(20) | NO | 상태 | | ... | ... | ... | (기타 필드) | #### order_nodes 테이블 (입력 소스) | 컬럼 | 타입 | NULL | 설명 | |------|------|------|------| | id | bigint unsigned | NO | PK | | order_id | bigint unsigned | NO | 수주 FK | | parent_id | bigint unsigned | YES | 부모 노드 (root=NULL) | | options | json | YES | **product_code, width, height, bom_result** | | sort_order | int | NO | 정렬 | | quantity | int | NO | 수량 (기본 1) | #### processes 테이블 (공정 판별) | 컬럼 | 타입 | NULL | 설명 | |------|------|------|------| | id | bigint unsigned | NO | PK | | tenant_id | bigint unsigned | NO | 테넌트 | | process_name | varchar(100) | NO | 공정명 ('절곡', '스크린', '슬랫' 등) | --- ## 9. 검증 결과 ### 9.1 테스트 케이스 | 입력값 | 예상 결과 | 실제 결과 | 상태 | |--------|----------|----------|------| | order_id=43 (KSS02 벽면형 SUS 5개소 3560x4450) | productCode="KSS02", guideRail.wall 5개, shutterBox 1개 | - | ⏳ | | 절곡 공정이 아닌 WorkOrder | bending_info = null, options = null | - | ⏳ | | product_code 없는 노드 | graceful fallback (null 반환) | - | ⏳ | | 혼합형 제품 (벽면+측면) | guideRail.wall + guideRail.side 둘 다 생성 | - | ⏳ | | 동일 치수 복수 노드 | 수량 합산 (길이별 그룹핑) | - | ⏳ | | BOM에 steel 외 category | 무시 (null → 스킵) | - | ⏳ | ### 9.2 성공 기준 달성 현황 | 기준 | 달성 | 비고 | |------|------|------| | 생산지시 시 절곡 WorkOrder에 bending_info 자동 생성 | ⏳ | | | WO 74 수동 데이터와 동일 구조의 JSON 생성 | ⏳ | | | 프론트엔드 BendingWorkLogContent에서 정상 렌더링 | ⏳ | | | 비절곡 공정 WorkOrder에 bending_info 미생성 | ⏳ | | | product_code 파싱 실패 시 graceful null 반환 | ⏳ | | --- ## 부록 A. BOM item_code → bending_info 카테고리 전체 매핑 ### A.1 패턴 매칭 규칙 (우선순위 순) | 순서 | 매칭 방식 | 패턴 | 카테고리 | bending_info 필드 | |------|----------|------|---------|------------------| | 1 | str_starts_with(code) | `BD-가이드레일-*` | guideRail | `guideRail.wall` 또는 `guideRail.side` | | 2 | str_starts_with(code) | `BD-케이스-*` | shutterBox_case | `shutterBox[].size` | | 3 | str_starts_with(code) | `BD-마구리-*` | shutterBox_finCover | `shutterBox[].finCoverQty` | | 4 | str_contains(name) | `*하장바*` | bottomBar | `bottomBar.length3000Qty/length4000Qty` | | 5 | exact match(code) | `EST-SMOKE-레일용` | smokeBarrier_rail | `smokeBarrier.w50[]` | | 6 | exact match(code) | `EST-SMOKE-케이스용` | smokeBarrier_case | `smokeBarrier.w80Qty` | | 7 | str_starts_with(code) | `BD-L-BAR-*` | detail_lbar | `detailParts[]` | | 8 | str_starts_with(code) | `BD-보강평철-*` | detail_reinforce | `detailParts[]` | | - | 미매칭 | (기타) | null | 무시 (절곡 무관) | ### A.2 가이드레일 item_code 파싱 `BD-가이드레일-KSS02-SUS-120*70` → 세그먼트 분리: | 세그먼트 | 값 | 용도 | |----------|-----|------| | BD-가이드레일 | 접두사 | 카테고리 식별 | | KSS02 | 모델코드 | (검증용) | | SUS | 마감재 | (검증용) | | 120*70 | baseSize | `guideRail.wall.baseSize` 또는 `guideRail.side.baseSize` | ### A.3 셔터박스 item_code 파싱 케이스: `BD-케이스-500*380` → `shutterBox[].size = "500*380"` 마구리: `BD-마구리-505*385` → `shutterBox[].finCoverQty += BOM quantity` ### A.4 하장바 item_name 파싱 `철재용하장바(SUS)3000` → item_name 마지막 4자리 숫자 추출 → 3000/4000 분류 - 3000 → `bottomBar.length3000Qty += BOM quantity × 노드수` - 4000 → `bottomBar.length4000Qty += BOM quantity × 노드수` --- ## 부록 B. bending_info JSON 조립 상세 ### B.1 목표 출력 구조 (WO 74 실데이터 기준) ```json { "productCode": "KSS02", "finishMaterial": "SUS마감", "common": { "kind": "벽면형 120X70", "type": "벽면형(120*70)", "lengthQuantities": [ { "length": 4450, "quantity": 5 } ] }, "detailParts": [ { "partName": "엘바", "material": "EGI 1.6T", "barcyInfo": "16 I 75" }, { "partName": "보강평철", "material": "50T", "barcyInfo": "" } ], "guideRail": { "wall": { "baseSize": "120*70", "finish": "SUS 1.2T", "extraFinish": "", "lengthQuantities": [ { "length": 4450, "quantity": 5 } ] }, "side": null }, "bottomBar": { "material": "SUS 1.5T", "extraFinish": "없음", "length3000Qty": 17, "length4000Qty": 0 }, "shutterBox": [ { "direction": "양면", "size": "500*380", "finCoverQty": 5, "lengths": [ { "length": 3560, "quantity": 5 } ] } ], "smokeBarrier": { "w50": [ { "length": 4450, "quantity": 5 } ], "w80Qty": 5 } } ``` ### B.2 조립 규칙 (필드별) | 필드 | 소스 | 변환 규칙 | |------|------|----------| | `productCode` | parseProductCode(product_code)[0] | "KSS02" | | `finishMaterial` | parseProductCode(product_code)[2] | "SUS" → "SUS마감" | | `common.kind` | guideType + baseSize | "벽면형 120X70" | | `common.type` | guideType + "(baseSize)" | "벽면형(120*70)" | | `common.lengthQuantities` | 노드 height별 수량 집계 | [{length: 4450, quantity: 5}] | | `guideRail.wall/side` | guideType으로 분기 + getMaterialMapping | baseSize, finish, lengthQuantities | | `bottomBar.material` | getMaterialMapping.bottomBarFinish | "SUS 1.5T" | | `bottomBar.extraFinish` | getMaterialMapping.bottomBarExtraFinish | "없음" | | `bottomBar.length3000Qty` | 하장바 BOM item_name → 3000 수량 합산 | 17 (= 3.4 × 5) | | `shutterBox[].direction` | 기본 "양면" (방향 정보 없음) | "양면" | | `shutterBox[].size` | BD-케이스 item_code → 사이즈 추출 | "500*380" | | `shutterBox[].finCoverQty` | BD-마구리 BOM quantity × 노드수 | 5 | | `shutterBox[].lengths` | 노드 width별 수량 집계 | [{length: 3560, quantity: 5}] | | `smokeBarrier.w50` | EST-SMOKE-레일용 수량 → height 기준 집계 | [{length: 4450, quantity: 5}] | | `smokeBarrier.w80Qty` | EST-SMOKE-케이스용 수량 합산 → 노드수 | 5 | ### B.3 detailParts 매핑 | BOM item_code 패턴 | partName | material 결정 방식 | barcyInfo | |--------------------|----------|-------------------|-----------| | `BD-L-BAR-{model}-{W}*{H}` | "엘바" | "{H}T" 에서 추출 (e.g., 17*60 → "EGI 1.6T") | "{H/10} I {W}" (e.g., "16 I 75") | | `BD-보강평철-{size}` | "보강평철" | "{size}T" (e.g., 50 → "50T") | "" | > detailParts의 정확한 material/barcyInfo 계산은 레거시 코드 참조 필요. > Phase 1 구현 시 WO 74 실데이터와 비교하여 확정. --- ## 부록 C. 코드 변경 포인트 상세 ### C.1 OrderService.php 변경 (Phase 2.1) **파일**: `api/app/Services/OrderService.php` **위치**: 라인 1103~1130 (`foreach ($itemsByProcess)` 내부) ```php // 변경 전 (라인 1111~1124): $workOrder = WorkOrder::create([ 'tenant_id' => $tenantId, 'work_order_no' => $workOrderNo, 'sales_order_id' => $order->id, 'project_name' => $order->order_no, 'process_id' => $processId, 'status' => WorkOrder::STATUS_PENDING, 'assignee_id' => $data['assignee_id'] ?? null, 'team_id' => $data['team_id'] ?? null, 'scheduled_date' => $data['scheduled_date'] ?? null, 'memo' => $data['memo'] ?? null, // ⚠️ 'options' 없음 'is_active' => true, 'created_by' => apiUserId(), 'updated_by' => apiUserId(), ]); // 변경 후: // ★ 절곡 공정이면 bending_info 생성 $options = null; if ($processId) { $bendingInfo = app(BendingInfoBuilder::class)->build($order, $processId); if ($bendingInfo) { $options = ['bending_info' => $bendingInfo]; } } $workOrder = WorkOrder::create([ 'tenant_id' => $tenantId, 'work_order_no' => $workOrderNo, 'sales_order_id' => $order->id, 'project_name' => $order->order_no, 'process_id' => $processId, 'status' => WorkOrder::STATUS_PENDING, 'assignee_id' => $data['assignee_id'] ?? null, 'team_id' => $data['team_id'] ?? null, 'scheduled_date' => $data['scheduled_date'] ?? null, 'memo' => $data['memo'] ?? null, 'options' => $options, // ★ 신규 'is_active' => true, 'created_by' => apiUserId(), 'updated_by' => apiUserId(), ]); ``` ### C.2 BendingInfoBuilder 신규 생성 (Phase 1) **파일**: `api/app/Services/Production/BendingInfoBuilder.php` (신규) **예상 코드 라인 수**: 200~250줄 ``` 메서드 목록: ├── public build(Order $order, int $processId): ?array (메인 엔트리) ├── private parseProductCode(string $fullCode): array (product_code 파싱) ├── private categorizeBomItem(array $bomItem): ?string (BOM 카테고리 분류) ├── private aggregateNodes(Collection $nodes, array $productInfo): array (노드 집계) ├── private assembleBendingInfo(array $productInfo, array $aggregated, Collection $nodes): array (JSON 조립) ├── private getMaterialMapping(string $productCode, string $finishMaterial): array (재질 매핑) ├── private extractBaseSize(string $guideRailCode): string (가이드레일 baseSize 추출) └── private extractBottomBarLength(string $itemName): int (하장바 길이 추출) ``` ### C.3 use 문 추가 (OrderService.php) **파일**: `api/app/Services/OrderService.php` **위치**: 파일 상단 use 섹션 ```php use App\Services\Production\BendingInfoBuilder; ``` --- ## 부록 D. 가이드레일 baseSize 규칙 ### D.1 모델별 baseSize 매핑 | 모델 | guideType | BD 품목 코드 예시 | baseSize | |------|-----------|-----------------|----------| | KSS01 | 벽면형 | BD-가이드레일-KSS01-SUS-120*70 | 120*70 | | KSS01 | 측면형 | BD-가이드레일-KSS01-SUS-120*120 | 120*120 | | KSS02 | 벽면형 | BD-가이드레일-KSS02-SUS-120*70 | 120*70 | | KSS02 | 측면형 | BD-가이드레일-KSS02-SUS-120*120 | 120*120 | | KSE01 | 벽면형 | BD-가이드레일-KSE01-{SUS/EGI}-120*70 | 120*70 | | KSE01 | 측면형 | BD-가이드레일-KSE01-{SUS/EGI}-120*120 | 120*120 | | KWE01 | 벽면형 | BD-가이드레일-KWE01-{SUS/EGI}-120*70 | 120*70 | | KWE01 | 측면형 | BD-가이드레일-KWE01-{SUS/EGI}-120*120 | 120*120 | | KQTS01 | 벽면형 | BD-가이드레일-KQTS01-SUS-130*75 | 130*75 | | KQTS01 | 측면형 | BD-가이드레일-KQTS01-SUS-130*125 | 130*125 | | KTE01 | 벽면형 | BD-가이드레일-KTE01-{SUS/EGI}-130*75 | 130*75 | | KTE01 | 측면형 | BD-가이드레일-KTE01-{SUS/EGI}-130*125 | 130*125 | | KDSS01 | 벽면형 | BD-가이드레일-KDSS01-SUS-150*150 | 150*150 | | KDSS01 | 측면형 | BD-가이드레일-KDSS01-SUS-150*212 | 150*212 | ### D.2 혼합형 처리 혼합형(guideType === '혼합형')인 경우: - `guideRail.wall` = 해당 모델의 벽면형 baseSize - `guideRail.side` = 해당 모델의 측면형 baseSize - BOM에 두 종류 가이드레일이 모두 포함됨 > baseSize는 BOM의 `BD-가이드레일-*` item_code에서 마지막 세그먼트로 직접 추출 가능. > 별도 매핑 테이블 불필요. --- ## 부록 E. 셔터박스/하단마감재/연기차단재 규칙 ### E.1 셔터박스 방향 결정 | 조건 | direction 값 | |------|-------------| | 노드 1개 | "양면" (기본값) | | 여러 노드 + 동일 치수 | "양면" (기본값) | | 방향 정보 없음 (현재) | "양면" 기본값 사용 | > 방향 정보는 현재 order_nodes.options에 저장되지 않음. > Phase 1에서는 "양면" 기본값 사용. 추후 BOM 확장 시 방향 필드 추가 가능. ### E.2 하단마감재 길이 분류 | BOM item_name | 길이 추출 방법 | 분류 | |---------------|--------------|------| | 철재용하장바(SUS)3000 | 마지막 4자리 숫자 → 3000 | `bottomBar.length3000Qty` | | 철재용하장바(SUS)4000 | 마지막 4자리 숫자 → 4000 | `bottomBar.length4000Qty` | | 철재용하장바(EGI)3000 | 마지막 4자리 숫자 → 3000 | `bottomBar.length3000Qty` | 계산: `BOM quantity × 노드 수 = 총 수량` (예: 3.4 × 5개소 = 17) ### E.3 연기차단재 수량 계산 | BOM item_code | bending_info 필드 | 수량 계산 | |---------------|------------------|----------| | EST-SMOKE-레일용 | `smokeBarrier.w50[]` | height 기준 길이별 수량 집계 | | EST-SMOKE-케이스용 | `smokeBarrier.w80Qty` | BOM quantity × 노드수 → 정수 변환 | ### E.4 재질 매핑 (getMaterialMapping 재현) ```php private function getMaterialMapping(string $productCode, string $finishMaterial): array { // Group 1: SUS 전용 (KQTS01, KSS01, KSS02) if (in_array($productCode, ['KQTS01', 'KSS01', 'KSS02'])) { return [ 'guideRailFinish' => 'SUS 1.2T', 'bodyMaterial' => 'EGI 1.55T', 'guideRailExtraFinish' => '', 'bottomBarFinish' => 'SUS 1.5T', 'bottomBarExtraFinish' => '없음', ]; } // Group 2: KTE01 (마감유형 분기) if ($productCode === 'KTE01') { $isSUS = $finishMaterial === 'SUS마감'; return [ 'guideRailFinish' => 'EGI 1.55T', 'bodyMaterial' => 'EGI 1.55T', 'guideRailExtraFinish' => $isSUS ? 'SUS 1.2T' : '', 'bottomBarFinish' => 'EGI 1.55T', 'bottomBarExtraFinish' => $isSUS ? 'SUS 1.2T' : '없음', ]; } // Group 3: 기타 (KSE01, KWE01 등) $isSUS = str_contains($finishMaterial, 'SUS'); return [ 'guideRailFinish' => 'EGI 1.55T', 'bodyMaterial' => 'EGI 1.55T', 'guideRailExtraFinish' => $isSUS ? 'SUS 1.2T' : '', 'bottomBarFinish' => 'EGI 1.55T', 'bottomBarExtraFinish' => $isSUS ? 'SUS 1.2T' : '없음', ]; } ``` --- ## 부록 F. WO 74 역검증용 데이터 ### F.1 입력 데이터 (order_id=43) | 항목 | 값 | |------|-----| | 수주 ID | 43 | | root_nodes | id=116~125 (10개, 5개소 × 2) | | product_code | FG-KSS02-벽면형-SUS | | width | 3560 | | height | 4450 | | 노드 수 | 5 (동일 치수) | ### F.2 기대 출력 (WO 74 기존 데이터와 일치해야 함) | 필드 | 기대값 | |------|--------| | productCode | "KSS02" | | finishMaterial | "SUS마감" | | common.type | "벽면형(120*70)" | | common.lengthQuantities | [{length: 4450, quantity: 5}] | | guideRail.wall.baseSize | "120*70" | | guideRail.wall.finish | "SUS 1.2T" | | guideRail.wall.lengthQuantities | [{length: 4450, quantity: 5}] | | guideRail.side | null | | bottomBar.material | "SUS 1.5T" | | bottomBar.length3000Qty | 17 (= 3.4 × 5) | | bottomBar.length4000Qty | 0 | | shutterBox[0].direction | "양면" | | shutterBox[0].size | "500*380" | | shutterBox[0].finCoverQty | 5 | | shutterBox[0].lengths | [{length: 3560, quantity: 5}] | | smokeBarrier.w50 | [{length: 4450, quantity: 5}] | | smokeBarrier.w80Qty | 5 | --- ## 10. 자기완결성 점검 결과 ### 10.1 체크리스트 검증 | # | 검증 항목 | 상태 | 비고 | |---|----------|:----:|------| | 1 | 작업 목적이 명확한가? | ✅ | 1.1 배경 | | 2 | 성공 기준이 정의되어 있는가? | ✅ | 9.2 | | 3 | 작업 범위가 구체적인가? | ✅ | 3 Phase + 부록 | | 4 | 의존성이 명시되어 있는가? | ✅ | Phase 순서 = 의존성 | | 5 | 참고 파일 경로 + 라인번호가 정확한가? | ✅ | 섹션 8 + 부록 C | | 6 | 단계별 절차가 실행 가능한가? | ✅ | 4.1~4.5 (코드 포함) | | 7 | 검증 방법이 명시되어 있는가? | ✅ | 섹션 9 + 부록 F | | 8 | 모호한 표현이 없는가? | ✅ | 구체적 코드/건수/라인번호 | ### 10.2 새 세션 시뮬레이션 테스트 | 질문 | 답변 가능 | 참조 섹션 | |------|:--------:|----------| | Q1. 이 작업의 목적은 무엇인가? | ✅ | 1.1 배경 | | Q2. 어디서부터 시작해야 하는가? | ✅ | 3 Phase 1, 4.1 단계별 절차 | | Q3. OrderService의 어느 줄을 수정해야 하는가? | ✅ | 8.1 코드 위치 (라인 1111), 부록 C.1 | | Q4. BOM item_code 매핑 규칙은? | ✅ | 2.6 + 부록 A | | Q5. product_code 파싱 방법은? | ✅ | 2.7 + 4.3 (코드 포함) | | Q6. 프론트엔드 목표 스키마는? | ✅ | 2.5 BendingInfoExtended + 부록 B | | Q7. 재질 매핑 규칙은? | ✅ | 2.8 + 부록 E.4 (코드 포함) | | Q8. 어떻게 검증하는가? | ✅ | 9.1 테스트 케이스 + 부록 F | | Q9. 가이드레일 baseSize는 어떻게 결정하는가? | ✅ | 부록 D (모델별 전체 매핑) | | Q10. 기존 코드에 미치는 영향은? | ✅ | 1.3 원칙 6번, 부록 C (변경 포인트 상세) | **결과**: 10/10 통과 → ✅ 자기완결성 확보 ### 10.3 보완 이력 | 날짜 | 항목 | 원본 | 보완 내용 | |------|------|------|----------| | 2026-02-20 | 전체 | 초안 (간략 구조) | formula-engine-real-data-plan.md 형식으로 전면 개편 | | 2026-02-20 | 섹션 2 | 미존재 | 현황 분석 추가 (DB 데이터, 코드 분석, 스키마 상세) | | 2026-02-20 | 섹션 8 | 간략 목록 | 관련 파일 및 코드 위치 (정확한 라인번호 포함) | | 2026-02-20 | 부록 A~F | 일부만 존재 | 6개 부록 완비 (BOM 매핑, JSON 조립, 코드 변경, 가이드레일, 셔터박스/하단마감재, 역검증) | --- *이 문서는 /sc:plan 스킬로 생성되었습니다.*