Files
sam-docs/dev/dev_plans/archive/order-location-management-plan.md
권혁성 db63fcff85 refactor: [docs] 팀별 폴더 구조 재편 (공유/개발/프론트/기획)
- 개발팀 전용 폴더 dev/ 생성 (standards, guides, quickstart, changes, deploys, data, history, dev_plans 이동)
- 프론트엔드 전용 폴더 frontend/ 생성 (api/ → frontend/api-specs/)
- 기획팀 폴더 requests/ 생성
- plans/ → dev/dev_plans/ 이름 변경
- README.md 신규 (사람용 안내), INDEX.md 재작성 (Claude Code용)
- resources.md 신규 (노션 링크용, assets/brochure 이관 예정)
- CURRENT_WORKS.md 삭제, TODO.md → dev/ 이동
- 전체 참조 경로 업데이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:46:03 +09:00

831 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 수주 하위 구조 관리 시스템 구축 계획
> **작성일**: 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)*