# 수주 하위 구조 관리 시스템 구축 계획 > **작성일**: 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 | 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 (
{/* 노드 헤더 */} {/* 해당 노드의 자재 테이블 */} {node.items.length > 0 && } {/* 하위 노드 재귀 렌더링 */} {node.children.map(child => ( ))}
); } ``` **역호환**: ```typescript {order.nodes && order.nodes.length > 0 ? ( order.nodes.map(node => ) ) : ( )} ``` --- ## 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)*