Files
sam-docs/dev/dev_plans/bending-info-auto-generation-plan.md

1047 lines
43 KiB
Markdown
Raw Permalink Normal View History

# 생산지시 시 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<string, unknown> }` |
| 같은 파일 | `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 스킬로 생성되었습니다.*