- 개발팀 전용 폴더 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>
831 lines
32 KiB
Markdown
831 lines
32 KiB
Markdown
# 수주 하위 구조 관리 시스템 구축 계획
|
||
|
||
> **작성일**: 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)* |