# 수주 하위 구조 관리 시스템 구축 계획

> **작성일**: 2026-02-06
> **목적**: 수주(Order) 하위에 범용 N-depth 트리 구조를 구축하여 개소/구역/공정 등 다양한 하위 단위를 자유롭게 관리
> **기준 문서**: `docs/features/quotes/README.md`, `docs/specs/database-schema.md`, `docs/standards/api-rules.md`
> **상태**: 🔄 진행중
> **설계 결정**: 하이브리드 (고정 코어 컬럼 + options JSON) — 통계 쿼리 성능과 유연성 균형

---

## 📍 현재 진행 상태

| 항목 | 내용 |
|------|------|
| **마지막 완료 작업** | Phase 4.2 - 프론트엔드 노드별 그룹 UI |
| **다음 작업** | 완료 (테스트 검증 필요) |
| **진행률** | 13/13 (100%) |
| **마지막 업데이트** | 2026-02-06 |

---

## 1. 개요

### 1.1 배경

**즉시 문제**: 견적→수주 전환(`QuoteService::convertToOrder`)에서 개소 정보가 매핑되지 않아 `order_items.floor_code`, `symbol_code`가 null로 저장됨. 반면 `OrderService::syncFromQuote`에는 이미 파싱 로직이 있어 정상 동작.

**구조적 문제**: 현재 수주 하위 구조는 `order_items` 플랫 테이블뿐이며, 개소/구역/공정 등 다양한 그루핑 단위를 관리할 수 없음. 5130(경동)은 개소별 관리가 필요하지만, 향후 다른 테넌트에서는 구역별, 층별, 공정별 등 다양한 트리 구조가 필요.

**현재 데이터 흐름 문제**:
```
견적 저장:
  quotes.calculation_inputs.items[] → 개소별 데이터 ✅
  quote_items.note → "4F FSS-01" ✅

수주 전환 (convertToOrder):
  order_items.floor_code → null ❌  ← $productMapping이 빈 배열
  order_items.symbol_code → null ❌

수주 동기화 (syncFromQuote):
  order_items.floor_code → "4F" ✅  ← note 파싱 로직 있음
  order_items.symbol_code → "FSS-01" ✅
```

### 1.2 목표

1. 견적→수주 전환 시 개소 정보가 정확히 매핑되도록 즉시 수정 (Quick Fix)
2. `order_nodes` 테이블을 신규 생성하여 **범용 N-depth 트리 구조** 제공
3. 노드별 독립 상태 추적 (대기/진행중/완료/취소)
4. 프론트엔드에서 노드별 그룹 UI 제공 (경동은 개소별 표시)

### 1.3 아키텍처 결정

```
┌─────────────────────────────────────────────────────────────────┐
│  🎯 설계 결정: 하이브리드 (고정 코어 + options JSON)               │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  ❌ 순수 EAV → 통계 쿼리 시 JOIN 폭발, 성능 문제                  │
│  ❌ 고정 컬럼 전용 → 경동 개소에만 맞고 범용성 없음                 │
│  ✅ 하이브리드 → 통계용 고정 컬럼 + 유형별 상세는 options JSON       │
│                                                                  │
│  근거:                                                           │
│  - SAM 프로젝트에서 이미 options JSON 패턴 사용 중                  │
│    (work_order_items.options, quotes.calculation_inputs)           │
│  - MySQL 8 JSON path 쿼리 지원 (options->>'$.floor' 등)           │
│  - 통계 집계는 고정 컬럼(code, name, status, quantity, price)으로   │
│  - sam_stat 일간/월간 집계에도 고정 컬럼 기반으로 수월               │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘
```

### 1.4 핵심 원칙

```
┌─────────────────────────────────────────────────────────────────┐
│  1. 범용 트리: N-depth 자기참조(parent_id)로 어떤 구조든 표현 가능  │
│  2. 통계 친화: code, name, status, quantity, price는 고정 컬럼     │
│  3. 유형 자유: node_type으로 구분, 유형별 상세는 options JSON        │
│  4. 역호환성: 기존 수주(order_nodes 없는)도 정상 동작               │
│  5. SAM 패턴 준수: BelongsToTenant, Auditable, SoftDeletes        │
└─────────────────────────────────────────────────────────────────┘
```

### 1.5 적용 예시

**경동 (1-depth: 개소)**:
```
Order: ORD-260206-001
├── Node (type:location, code:"1F-FSS-01", name:"1F FSS-01")
│   ├── options: { floor:"1F", symbol:"FSS-01", product_code:"KSS01",
│   │              open_width:5000, open_height:3000, guide_rail:"wall" }
│   └── OrderItems (자재 N개)
│
└── Node (type:location, code:"2F-SD-02", name:"2F SD-02")
    ├── options: { floor:"2F", symbol:"SD-02", product_code:"KWE01",
    │              open_width:2800, open_height:2400 }
    └── OrderItems (자재 N개)
```

**다른 테넌트 (3-depth: 동→층→실)**:
```
Order: ORD-260206-005
├── Node (type:zone, code:"A", name:"A동")
│   ├── Node (type:floor, code:"1F", name:"1층")
│   │   ├── Node (type:room, code:"101", name:"회의실")
│   │   │   └── OrderItems
│   │   └── Node (type:room, code:"102", name:"사무실")
│   │       └── OrderItems
│   └── Node (type:floor, code:"2F", name:"2층")
│       └── ...
└── Node (type:zone, code:"B", name:"B동")
    └── ...
```

### 1.6 변경 승인 정책

| 분류 | 예시 | 승인 |
|------|------|------|
| ✅ 즉시 가능 | convertToOrder 로직 수정, 모델 관계 추가 | 불필요 |
| ⚠️ 컨펌 필요 | 마이그레이션 생성, 신규 테이블, API 엔드포인트 추가 | **필수** |
| 🔴 금지 | 기존 order_items.floor_code/symbol_code 삭제, 기존 API 스키마 변경 | 별도 협의 |

### 1.7 준수 규칙

- `docs/standards/api-rules.md` - Service-First, FormRequest, i18n
- `docs/specs/database-schema.md` - 공통 컬럼 패턴 (tenant_id, audit, softDeletes)
- `docs/standards/quality-checklist.md` - 품질 체크리스트
- `react/CLAUDE.md` - 'use client' 필수, Server Actions

---

## 2. 대상 범위

### 2.1 Phase 1: convertToOrder 개소 파싱 (Quick Fix)

| # | 작업 항목 | 파일 | 상태 | 비고 |
|---|----------|------|:----:|------|
| 1.1 | convertToOrder에 개소 파싱 로직 추가 | `api/app/Services/Quote/QuoteService.php` | ✅ | syncFromQuote 로직 재사용 |
| 1.2 | 개소 파싱 공통 메소드 추출 | `api/app/Services/Quote/QuoteService.php` | ✅ | 중복 코드 제거 |

### 2.2 Phase 2: order_nodes 테이블 (DB 스키마)

| # | 작업 항목 | 파일 | 상태 | 비고 |
|---|----------|------|:----:|------|
| 2.1 | order_nodes 마이그레이션 생성 | `api/database/migrations/XXXX_create_order_nodes_table.php` | ✅ | 신규 테이블 |
| 2.2 | order_items에 order_node_id 추가 | `api/database/migrations/XXXX_add_order_node_id_to_order_items.php` | ✅ | nullable FK |
| 2.3 | OrderNode 모델 생성 | `api/app/Models/Orders/OrderNode.php` | ✅ | BelongsToTenant, SoftDeletes, 자기참조 |
| 2.4 | Order 모델에 nodes() 관계 추가 | `api/app/Models/Orders/Order.php` | ✅ | HasMany |
| 2.5 | OrderItem 모델에 node() 관계 추가 | `api/app/Models/Orders/OrderItem.php` | ✅ | BelongsTo, fillable 추가 |

### 2.3 Phase 3: 전환 로직 연동 (Service)

| # | 작업 항목 | 파일 | 상태 | 비고 |
|---|----------|------|:----:|------|
| 3.1 | convertToOrder에 OrderNode 생성 로직 추가 | `api/app/Services/Quote/QuoteService.php` | ✅ | Phase 1.1 위에 구축 |
| 3.2 | syncFromQuote에 OrderNode 동기화 추가 | `api/app/Services/OrderService.php` | ✅ | 기존 items 삭제→재생성 패턴 동일 |
| 3.3 | 수주 상세 조회에 nodes eager loading | `api/app/Services/OrderService.php` | ✅ | show() 메소드 수정 |

### 2.4 Phase 4: 프론트엔드 노드별 UI

| # | 작업 항목 | 파일 | 상태 | 비고 |
|---|----------|------|:----:|------|
| 4.1 | OrderNode 타입 + 서버 액션 추가 | `react/src/components/orders/actions.ts` | ✅ | 타입 정의, API 호출 |
| 4.2 | 수주 상세 뷰 노드별 그룹 UI | `react/src/components/orders/OrderSalesDetailView.tsx` | ✅ | 트리/아코디언 형식 |

---

## 3. 작업 절차

### 3.1 단계별 절차

```
Phase 1: Quick Fix (convertToOrder 개소 파싱)
├── 1.1 syncFromQuote의 개소 파싱 로직을 공통 메소드로 추출
├── 1.2 convertToOrder에서 공통 메소드 호출하여 $productMapping 전달
└── 검증: 견적→수주 전환 후 order_items.floor_code/symbol_code 값 확인

Phase 2: DB 스키마 (order_nodes 테이블)
├── 2.1 order_nodes 마이그레이션 작성
│   ├── 트리 구조: parent_id 자기참조 (nullable = 루트)
│   ├── 고정 코어: node_type, code, name, status_code, quantity, unit_price, total_price
│   └── 유연 확장: options JSON
├── 2.2 order_items에 order_node_id 컬럼 마이그레이션 작성
├── 2.3 OrderNode 모델 생성 (BelongsToTenant, Auditable, SoftDeletes)
│   ├── 자기참조 관계: parent(), children()
│   └── items() HasMany
├── 2.4 Order 모델에 nodes() HasMany 관계 추가
├── 2.5 OrderItem 모델에 node() BelongsTo 관계 추가
└── 검증: php artisan migrate 성공, 트리 관계 정상 동작

Phase 3: 전환 로직 연동
├── 3.1 convertToOrder에 OrderNode 생성 로직 삽입
│   ├── calculation_inputs.items[] 순회하여 OrderNode (type:location) 생성
│   ├── bomResults[]에서 금액 정보 매핑
│   └── OrderItem 생성 시 order_node_id 연결
├── 3.2 syncFromQuote에 OrderNode 동기화 추가
│   ├── 기존 nodes 소프트삭제 → 신규 생성
│   └── OrderItem 재생성 시 node 연결
├── 3.3 수주 상세 조회에 nodes eager loading 추가
└── 검증: API 호출로 노드 데이터 정상 반환 확인

Phase 4: 프론트엔드 UI
├── 4.1 타입 + 서버 액션
│   ├── OrderNode 인터페이스 정의
│   └── 수주 상세 조회 응답에 nodes 포함
├── 4.2 수주 상세 뷰 노드별 그룹 UI
│   ├── 노드별 카드/아코디언 레이아웃
│   ├── 노드 헤더 (유형/코드/이름/상태/금액)
│   ├── 노드 내 자재 테이블
│   ├── 하위 노드 중첩 표시 (재귀 컴포넌트)
│   └── 역호환: nodes 없는 수주는 기존 플랫 테이블 유지
└── 검증: 수주 상세 화면에서 노드별 그룹 표시 확인
```

---

## 4. 상세 작업 내용

### 4.1 Phase 1: Quick Fix (변경 없음)

#### 1.1 convertToOrder 개소 파싱 로직 추가

**현재 코드** (`QuoteService.php` Line 600-607):
```php
$serialIndex = 1;
foreach ($quote->items as $quoteItem) {
    $orderItem = OrderItem::createFromQuoteItem($quoteItem, $order->id, $serialIndex);
    $orderItem->created_by = $userId;
    $orderItem->save();
    $serialIndex++;
}
```

**수정 코드**:
```php
$calculationInputs = $quote->calculation_inputs ?? [];
$productItems = $calculationInputs['items'] ?? [];

$serialIndex = 1;
foreach ($quote->items as $quoteItem) {
    $productMapping = $this->resolveLocationMapping($quoteItem, $productItems);
    $orderItem = OrderItem::createFromQuoteItem($quoteItem, $order->id, $serialIndex, $productMapping);
    $orderItem->created_by = $userId;
    $orderItem->save();
    $serialIndex++;
}
```

#### 1.2 공통 메소드 추출

```php
/**
 * 견적 품목에서 개소(층/부호) 정보 추출
 */
private function resolveLocationMapping(QuoteItem $quoteItem, array $productItems): array
{
    $floorCode = null;
    $symbolCode = null;

    // 1순위: note에서 파싱 ("4F FSS-01")
    $note = trim($quoteItem->note ?? '');
    if ($note !== '') {
        $parts = preg_split('/\s+/', $note, 2);
        $floorCode = $parts[0] ?? null;
        $symbolCode = $parts[1] ?? null;
    }

    // 2순위: formula_source → calculation_inputs
    if (empty($floorCode) && empty($symbolCode)) {
        $productIndex = 0;
        $formulaSource = $quoteItem->formula_source ?? '';
        if (preg_match('/product_(\d+)/', $formulaSource, $matches)) {
            $productIndex = (int) $matches[1];
        }
        if (isset($productItems[$productIndex])) {
            $floorCode = $productItems[$productIndex]['floor'] ?? null;
            $symbolCode = $productItems[$productIndex]['code'] ?? null;
        } elseif (count($productItems) === 1) {
            $floorCode = $productItems[0]['floor'] ?? null;
            $symbolCode = $productItems[0]['code'] ?? null;
        }
    }

    return ['floor_code' => $floorCode, 'symbol_code' => $symbolCode];
}
```

---

### 4.2 Phase 2: DB 스키마

#### 2.1 order_nodes 마이그레이션

```php
Schema::create('order_nodes', function (Blueprint $table) {
    $table->id()->comment('ID');
    $table->foreignId('tenant_id')->comment('테넌트 ID');
    $table->foreignId('order_id')->comment('수주 ID');

    // ---- 트리 구조 ----
    $table->foreignId('parent_id')->nullable()->comment('상위 노드 ID (NULL=루트)');

    // ---- 고정 코어 (통계/집계용) ----
    $table->string('node_type', 50)->comment('노드 유형 (location, zone, floor, room, process...)');
    $table->string('code', 100)->comment('식별 코드');
    $table->string('name', 200)->comment('표시명');
    $table->string('status_code', 30)->default('PENDING')
        ->comment('상태 (PENDING/CONFIRMED/IN_PRODUCTION/PRODUCED/SHIPPED/COMPLETED/CANCELLED)');
    $table->integer('quantity')->default(1)->comment('수량');
    $table->decimal('unit_price', 15, 2)->default(0)->comment('단가');
    $table->decimal('total_price', 15, 2)->default(0)->comment('합계');

    // ---- 유연 확장 (유형별 상세) ----
    $table->json('options')->nullable()->comment('유형별 동적 속성 JSON');

    // ---- 정렬 ----
    $table->integer('depth')->default(0)->comment('트리 깊이 (0=루트)');
    $table->integer('sort_order')->default(0)->comment('정렬 순서');

    // ---- 감사 ----
    $table->foreignId('created_by')->nullable()->comment('생성자 ID');
    $table->foreignId('updated_by')->nullable()->comment('수정자 ID');
    $table->foreignId('deleted_by')->nullable()->comment('삭제자 ID');
    $table->timestamps();
    $table->softDeletes();

    // ---- 인덱스 ----
    $table->index('tenant_id');
    $table->index('parent_id');
    $table->index(['order_id', 'depth', 'sort_order']);
    $table->index(['order_id', 'node_type']);
    $table->index(['tenant_id', 'node_type', 'status_code']); // 통계용
});
```

**통계 쿼리 예시**:
```sql
-- 1. 노드 유형별 수주 현황 (고정 컬럼만으로 가능)
SELECT node_type, status_code, COUNT(*), SUM(total_price)
FROM order_nodes WHERE tenant_id = 287
GROUP BY node_type, status_code;

-- 2. 경동 개소별 상세 (필요 시 JSON path)
SELECT code, name, total_price,
       options->>'$.floor' AS floor,
       options->>'$.symbol' AS symbol
FROM order_nodes
WHERE order_id = 123 AND node_type = 'location';
```

#### 2.2 order_items에 order_node_id 추가

```php
Schema::table('order_items', function (Blueprint $table) {
    $table->foreignId('order_node_id')
        ->nullable()
        ->after('order_id')
        ->comment('수주 노드 ID (order_nodes)');
    $table->index('order_node_id');
});
```

#### 2.3 OrderNode 모델

```php
namespace App\Models\Orders;

class OrderNode extends Model
{
    use Auditable, BelongsToTenant, SoftDeletes;

    protected $table = 'order_nodes';

    // 상태 코드 (Order와 동일 체계)
    public const STATUS_PENDING = 'PENDING';
    public const STATUS_CONFIRMED = 'CONFIRMED';
    public const STATUS_IN_PRODUCTION = 'IN_PRODUCTION';
    public const STATUS_PRODUCED = 'PRODUCED';
    public const STATUS_SHIPPED = 'SHIPPED';
    public const STATUS_COMPLETED = 'COMPLETED';
    public const STATUS_CANCELLED = 'CANCELLED';

    protected $fillable = [
        'tenant_id', 'order_id', 'parent_id',
        'node_type', 'code', 'name',
        'status_code', 'quantity', 'unit_price', 'total_price',
        'options', 'depth', 'sort_order',
        'created_by', 'updated_by', 'deleted_by',
    ];

    protected $casts = [
        'quantity' => 'integer',
        'unit_price' => 'decimal:2',
        'total_price' => 'decimal:2',
        'options' => 'array',
        'depth' => 'integer',
    ];

    // ---- 트리 관계 ----
    public function parent(): BelongsTo
    {
        return $this->belongsTo(self::class, 'parent_id');
    }

    public function children(): HasMany
    {
        return $this->hasMany(self::class, 'parent_id')->orderBy('sort_order');
    }

    // ---- 비즈니스 관계 ----
    public function order(): BelongsTo
    {
        return $this->belongsTo(Order::class);
    }

    public function items(): HasMany
    {
        return $this->hasMany(OrderItem::class, 'order_node_id');
    }

    // ---- 트리 헬퍼 ----
    public function isRoot(): bool
    {
        return $this->parent_id === null;
    }

    public function isLeaf(): bool
    {
        return $this->children()->count() === 0;
    }

    /**
     * 하위 노드 포함 전체 트리 재귀 로드
     */
    public function scopeWithRecursiveChildren($query)
    {
        return $query->with(['children' => function ($q) {
            $q->orderBy('sort_order')->with('children', 'items');
        }, 'items']);
    }
}
```

#### 2.4-2.5 기존 모델 수정

**Order 모델**:
```php
public function nodes(): HasMany
{
    return $this->hasMany(OrderNode::class)->orderBy('depth')->orderBy('sort_order');
}

public function rootNodes(): HasMany
{
    return $this->hasMany(OrderNode::class)->whereNull('parent_id')->orderBy('sort_order');
}
```

**OrderItem 모델** - fillable + 관계:
```php
// fillable에 추가
'order_node_id',

// 관계
public function node(): BelongsTo
{
    return $this->belongsTo(OrderNode::class, 'order_node_id');
}
```

---

### 4.3 Phase 3: 전환 로직 연동

#### 3.1 convertToOrder OrderNode 생성

**수정 위치**: `QuoteService::convertToOrder()` (Line 590~623)

```php
return DB::transaction(function () use ($quote, $userId, $tenantId) {
    $orderNo = $this->generateOrderNumber($tenantId);
    $order = Order::createFromQuote($quote, $orderNo);
    $order->created_by = $userId;
    $order->save();

    // ---- OrderNode 생성 (개소별) ----
    $calculationInputs = $quote->calculation_inputs ?? [];
    $productItems = $calculationInputs['items'] ?? [];
    $bomResults = $calculationInputs['bomResults'] ?? [];

    $nodeMap = []; // productIndex → OrderNode
    foreach ($productItems as $idx => $locItem) {
        $bomResult = $bomResults[$idx] ?? null;
        $grandTotal = $bomResult['grand_total'] ?? 0;
        $qty = (int) ($locItem['quantity'] ?? 1);
        $floor = $locItem['floor'] ?? '';
        $symbol = $locItem['code'] ?? '';

        $node = OrderNode::create([
            'tenant_id' => $tenantId,
            'order_id' => $order->id,
            'parent_id' => null, // 루트 노드 (경동은 1-depth)
            'node_type' => 'location',
            'code' => trim("{$floor}-{$symbol}", '-') ?: "LOC-{$idx}",
            'name' => trim("{$floor} {$symbol}") ?: "개소 ".($idx + 1),
            'status_code' => OrderNode::STATUS_PENDING,
            'quantity' => $qty,
            'unit_price' => $grandTotal,
            'total_price' => $grandTotal * $qty,
            'options' => [
                'floor' => $floor,
                'symbol' => $symbol,
                'product_code' => $locItem['productCode'] ?? null,
                'product_name' => $locItem['productName'] ?? null,
                'open_width' => $locItem['openWidth'] ?? null,
                'open_height' => $locItem['openHeight'] ?? null,
                'guide_rail_type' => $locItem['guideRailType'] ?? null,
                'motor_power' => $locItem['motorPower'] ?? null,
                'controller' => $locItem['controller'] ?? null,
                'wing_size' => $locItem['wingSize'] ?? null,
                'inspection_fee' => $locItem['inspectionFee'] ?? null,
                'bom_result' => $bomResult,
            ],
            'depth' => 0,
            'sort_order' => $idx,
            'created_by' => $userId,
        ]);
        $nodeMap[$idx] = $node;
    }

    // ---- OrderItem 생성 (노드 연결) ----
    $serialIndex = 1;
    foreach ($quote->items as $quoteItem) {
        $mapping = $this->resolveLocationMapping($quoteItem, $productItems);
        $locIdx = $this->resolveLocationIndex($quoteItem, $productItems);

        $productMapping = array_merge($mapping, [
            'order_node_id' => isset($nodeMap[$locIdx]) ? $nodeMap[$locIdx]->id : null,
        ]);

        $orderItem = OrderItem::createFromQuoteItem($quoteItem, $order->id, $serialIndex, $productMapping);
        $orderItem->created_by = $userId;
        $orderItem->save();
        $serialIndex++;
    }

    // 합계 재계산 + 견적 상태 변경 (기존 로직 유지)
    $order->load('items');
    $order->recalculateTotals();
    $order->save();

    $quote->update([
        'status' => Quote::STATUS_CONVERTED,
        'order_id' => $order->id,
        'updated_by' => $userId,
    ]);

    return $quote->refresh()->load(['items', 'client', 'order']);
});
```

**resolveLocationIndex 헬퍼**:
```php
private function resolveLocationIndex(QuoteItem $quoteItem, array $productItems): int
{
    $formulaSource = $quoteItem->formula_source ?? '';
    if (preg_match('/product_(\d+)/', $formulaSource, $matches)) {
        return (int) $matches[1];
    }

    $note = trim($quoteItem->note ?? '');
    if ($note !== '') {
        $parts = preg_split('/\s+/', $note, 2);
        $floor = $parts[0] ?? '';
        $code = $parts[1] ?? '';
        foreach ($productItems as $idx => $item) {
            if (($item['floor'] ?? '') === $floor && ($item['code'] ?? '') === $code) {
                return $idx;
            }
        }
    }

    return 0;
}
```

#### 3.2 syncFromQuote OrderNode 동기화

**수정 위치**: `OrderService::syncFromQuote()` (Line 559~659)

기존 `$order->items()->delete()` 다음에:
```php
// 기존 노드 삭제 후 재생성
$order->nodes()->delete();

// OrderNode 생성 (convertToOrder와 동일 로직)
$nodeMap = [];
foreach ($productItems as $idx => $locItem) {
    // ... (convertToOrder와 동일)
    $nodeMap[$idx] = $node;
}

// OrderItem 생성 시 order_node_id 연결
foreach ($quote->items as $index => $quoteItem) {
    $locIdx = $this->resolveLocationIndex($quoteItem, $productItems);
    $order->items()->create([
        // ... 기존 필드 ...
        'order_node_id' => $nodeMap[$locIdx]->id ?? null,
    ]);
}
```

#### 3.3 수주 상세 조회 nodes eager loading

```php
$order = Order::where('tenant_id', $tenantId)
    ->with([
        'items',
        'rootNodes' => function ($q) {
            $q->withRecursiveChildren(); // 재귀 트리 로드
        },
        'client',
        'quote',
    ])
    ->find($id);
```

---

### 4.4 Phase 4: 프론트엔드 노드별 UI

#### 4.1 타입 + 서버 액션

**OrderNode 타입** (`react/src/components/orders/actions.ts`):
```typescript
export interface OrderNode {
  id: number;
  parentId: number | null;
  nodeType: string;       // 'location', 'zone', 'floor', 'room', 'process'...
  code: string;
  name: string;
  statusCode: string;
  quantity: number;
  unitPrice: number;
  totalPrice: number;
  options: Record<string, unknown> | null; // 유형별 동적 속성
  depth: number;
  sortOrder: number;
  children: OrderNode[];  // 하위 노드 (재귀)
  items: OrderItem[];     // 해당 노드의 자재
}

export interface OrderDetail extends Order {
  nodes: OrderNode[];     // 루트 노드 배열 (children 재귀 포함)
}
```

#### 4.2 수주 상세 뷰 노드별 그룹 UI

**레이아웃 (경동 1-depth 예시)**:
```
┌─ 수주 기본 정보 ────────────────────────────────────────┐
│ 수주번호: ORD-260206-001 | 현장명: 삼성 빌딩 신축         │
│ 거래처: 삼성물산 | 총금액: 15,000,000원                   │
└─────────────────────────────────────────────────────────┘

┌─ 구조 (3개 노드) ──────────────────────────────────────┐
│                                                         │
│ ┌─ [location] 1F FSS-01 ──────────────────────────┐    │
│ │ KSS01(고정스크린) | 5000×3000 | 수량:1           │    │
│ │ 상태: [PENDING ▾] | 금액: 1,250,000원            │    │
│ ├──────────────────────────────────────────────────┤    │
│ │ # | 품목코드   | 품명    | 수량  | 단가   | 금액  │    │
│ │ 1 | MT-SUS-01 | 슬랫    | 15.5  | 12,000 | 186K │    │
│ │ 소계: 1,250,000원                                │    │
│ └──────────────────────────────────────────────────┘    │
│                                                         │
│ ┌─ [location] 2F SD-02 ──────────────────────────┐     │
│ │ ...                                             │     │
│ └─────────────────────────────────────────────────┘     │
└─────────────────────────────────────────────────────────┘
```

**재귀 컴포넌트 (N-depth)**:
```typescript
function OrderNodeCard({ node, depth = 0 }: { node: OrderNode; depth?: number }) {
  return (
    <div style={{ marginLeft: depth * 24 }}>
      {/* 노드 헤더 */}
      <NodeHeader node={node} />

      {/* 해당 노드의 자재 테이블 */}
      {node.items.length > 0 && <ItemsTable items={node.items} />}

      {/* 하위 노드 재귀 렌더링 */}
      {node.children.map(child => (
        <OrderNodeCard key={child.id} node={child} depth={depth + 1} />
      ))}
    </div>
  );
}
```

**역호환**:
```typescript
{order.nodes && order.nodes.length > 0 ? (
  order.nodes.map(node => <OrderNodeCard key={node.id} node={node} />)
) : (
  <LegacyFlatTableView items={order.items} />
)}
```

---

## 5. 컨펌 대기 목록

| # | 항목 | 변경 내용 | 영향 범위 | 상태 |
|---|------|----------|----------|------|
| 1 | order_nodes 테이블 생성 | N-depth 자기참조 트리 + 하이브리드 JSON | DB 스키마 | ⚠️ 컨펌 필요 |
| 2 | order_items에 order_node_id 추가 | nullable FK 컬럼 | DB 스키마 | ⚠️ 컨펌 필요 |
| 3 | 노드 상태 코드 체계 | Order와 동일 체계 사용 | 비즈니스 로직 | ⚠️ 컨펌 필요 |
| 4 | 경동 node_type 값 | "location" 사용 | 비즈니스 로직 | ⚠️ 컨펌 필요 |

---

## 6. 변경 이력

| 날짜 | 항목 | 변경 내용 | 파일 | 승인 |
|------|------|----------|------|------|
| 2026-02-06 | - | 문서 초안 작성 (order_locations 전용 설계) | - | - |
| 2026-02-06 | 아키텍처 변경 | order_locations → order_nodes (N-depth 트리 + 하이브리드) | - | ✅ 사용자 승인 |

---

## 7. 참고 문서

- **견적 시스템 분석**: `docs/features/quotes/README.md`
- **DB 스키마 규칙**: `docs/specs/database-schema.md`
- **API 개발 규칙**: `docs/standards/api-rules.md`
- **품질 체크리스트**: `docs/standards/quality-checklist.md`

### 핵심 소스 파일

| 파일 | 역할 | 핵심 라인 |
|------|------|----------|
| `api/app/Services/Quote/QuoteService.php` | 견적→수주 전환 | L574-623 (`convertToOrder`) |
| `api/app/Services/OrderService.php` | 수주 동기화 | L559-659 (`syncFromQuote`) |
| `api/app/Models/Orders/Order.php` | 수주 모델 | L23-59 (상태 코드) |
| `api/app/Models/Orders/OrderItem.php` | 수주 품목 모델 | L162-190 (`createFromQuoteItem`) |
| `react/src/components/orders/actions.ts` | 수주 프론트 타입 | L281-300 (OrderItem) |
| `react/src/components/orders/OrderSalesDetailView.tsx` | 수주 상세 뷰 | L386-424 (테이블) |
| `react/src/components/quotes/types.ts` | 견적 타입 | L661-684 (LocationItem) |

---

## 8. 세션 및 메모리 관리 정책

### 8.1 세션 시작 시

```
1. read_memory("order-nodes-state") → 진행 상태 파악
2. 이 문서의 "📍 현재 진행 상태" 섹션 확인
3. 마지막 완료 작업 확인 후 다음 작업 착수
```

### 8.2 Serena 메모리 구조

- `order-nodes-state`: `{ phase, progress, next_step, last_decision }`
- `order-nodes-snapshot`: 현재까지의 코드 변경점 요약
- `order-nodes-active-symbols`: 수정 중인 파일/함수 목록

---

## 9. 검증 결과

### 9.1 테스트 케이스

| # | 시나리오 | 예상 결과 | 실제 결과 | 상태 |
|---|---------|----------|----------|------|
| 1 | 견적 3개소 → 수주 전환 | order_nodes 3행(type:location) 생성, 각 order_items에 node_id 연결 | - | ⏳ |
| 2 | 수주 상세 조회 | rootNodes + children 재귀 + items eager loading 정상 | - | ⏳ |
| 3 | 견적 수정 → 수주 동기화 | 기존 nodes 삭제 후 재생성, items 재연결 | - | ⏳ |
| 4 | 기존 수주 (nodes 없음) 조회 | 기존 플랫 테이블 정상 표시, 에러 없음 | - | ⏳ |
| 5 | 프론트 노드별 그룹 표시 | 노드 카드 내 자재 테이블, 역호환 플랫 뷰 | - | ⏳ |
| 6 | 통계 쿼리 성능 | 고정 컬럼(node_type, status, total_price) 기반 GROUP BY 정상 | - | ⏳ |

### 9.2 성공 기준

| 기준 | 달성 | 비고 |
|------|------|------|
| 전환 시 order_nodes 생성됨 | ⏳ | Phase 2+3 |
| N-depth 트리 구조 지원 | ⏳ | Phase 2 (parent_id 자기참조) |
| order_items에 order_node_id 연결됨 | ⏳ | Phase 3 |
| 프론트 노드별 그룹 표시 | ⏳ | Phase 4 |
| 기존 수주 역호환 정상 | ⏳ | Phase 4 |
| 통계 쿼리가 고정 컬럼으로 가능 | ⏳ | Phase 2 (인덱스) |

---

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

### 10.1 체크리스트 검증

| # | 검증 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1 | 작업 목적이 명확한가? | ✅ | 범용 N-depth 트리 + 통계 친화 하이브리드 |
| 2 | 성공 기준이 정의되어 있는가? | ✅ | 섹션 9.2 (6개 기준) |
| 3 | 작업 범위가 구체적인가? | ✅ | 섹션 2 (13개 작업 항목) |
| 4 | 의존성이 명시되어 있는가? | ✅ | Phase 1→2→3→4 순서 |
| 5 | 참고 파일 경로가 정확한가? | ✅ | 섹션 7 (라인번호 포함) |
| 6 | 단계별 절차가 실행 가능한가? | ✅ | 섹션 3+4 (코드 포함) |
| 7 | 검증 방법이 명시되어 있는가? | ✅ | 섹션 9.1 (6개 테스트 케이스) |
| 8 | 모호한 표현이 없는가? | ✅ | 구체적 코드/파일/라인 명시 |

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

| 질문 | 답변 가능 | 참조 섹션 |
|------|:--------:|----------|
| Q1. 이 작업의 목적은 무엇인가? | ✅ | 1.1 배경, 1.2 목표 |
| Q2. 왜 하이브리드 구조를 선택했는가? | ✅ | 1.3 아키텍처 결정 |
| Q3. 어디서부터 시작해야 하는가? | ✅ | 📍 현재 진행 상태 |
| Q4. 어떤 파일을 수정해야 하는가? | ✅ | 2. 대상 범위 |
| Q5. 작업 완료 확인 방법은? | ✅ | 9. 검증 결과 |

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

---

*이 문서는 /plan 스킬 + Sequential Thinking MCP로 생성되었습니다.*
*아키텍처: N-depth 트리(order_nodes) + 하이브리드(고정 코어 + options JSON)*