- 완료 문서의 상세 내용은 추후 docs/ 구조화 시 정식 문서에 반영 예정 - HISTORY.md는 요약 인덱스로 유지, 개별 파일은 상세 참조용 보관 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
32 KiB
수주 하위 구조 관리 시스템 구축 계획
작성일: 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 목표
- 견적→수주 전환 시 개소 정보가 정확히 매핑되도록 즉시 수정 (Quick Fix)
order_nodes테이블을 신규 생성하여 범용 N-depth 트리 구조 제공- 노드별 독립 상태 추적 (대기/진행중/완료/취소)
- 프론트엔드에서 노드별 그룹 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, i18ndocs/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)