Files
sam-docs/plans/archive/order-location-management-plan.md
권혁성 28b69e5449 docs: archive 37개 + COMPLETED 3개 복원 - 향후 docs/ 정식 문서화 시 참조용
- 완료 문서의 상세 내용은 추후 docs/ 구조화 시 정식 문서에 반영 예정
- HISTORY.md는 요약 인덱스로 유지, 개별 파일은 상세 참조용 보관

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 22:32:20 +09:00

32 KiB
Raw Blame History

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

작성일: 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):

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

수정 코드:

$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 공통 메소드 추출

/**
 * 견적 품목에서 개소(층/부호) 정보 추출
 */
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 마이그레이션

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']); // 통계용
});

통계 쿼리 예시:

-- 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 추가

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 모델

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 모델:

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 + 관계:

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

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 헬퍼:

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() 다음에:

// 기존 노드 삭제 후 재생성
$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

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

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

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>
  );
}

역호환:

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