diff --git a/app/Models/Orders/Order.php b/app/Models/Orders/Order.php index 434c834..647ea4b 100644 --- a/app/Models/Orders/Order.php +++ b/app/Models/Orders/Order.php @@ -161,6 +161,22 @@ public function items(): HasMany return $this->hasMany(OrderItem::class)->orderBy('sort_order'); } + /** + * 수주 노드 (전체, depth/sort_order 순) + */ + public function nodes(): HasMany + { + return $this->hasMany(OrderNode::class)->orderBy('depth')->orderBy('sort_order'); + } + + /** + * 수주 루트 노드 (parent_id가 NULL인 최상위) + */ + public function rootNodes(): HasMany + { + return $this->hasMany(OrderNode::class)->whereNull('parent_id')->orderBy('sort_order'); + } + /** * 수주 이력 */ diff --git a/app/Models/Orders/OrderItem.php b/app/Models/Orders/OrderItem.php index 11932e7..7b1762c 100644 --- a/app/Models/Orders/OrderItem.php +++ b/app/Models/Orders/OrderItem.php @@ -26,6 +26,7 @@ class OrderItem extends Model protected $fillable = [ 'tenant_id', 'order_id', + 'order_node_id', 'quote_id', 'quote_item_id', 'serial_no', @@ -82,6 +83,14 @@ public function order(): BelongsTo return $this->belongsTo(Order::class); } + /** + * 수주 노드 (개소/구역/층 등) + */ + public function node(): BelongsTo + { + return $this->belongsTo(OrderNode::class, 'order_node_id'); + } + /** * 품목 마스터 */ diff --git a/app/Models/Orders/OrderNode.php b/app/Models/Orders/OrderNode.php new file mode 100644 index 0000000..5b967d7 --- /dev/null +++ b/app/Models/Orders/OrderNode.php @@ -0,0 +1,127 @@ + 'integer', + 'unit_price' => 'decimal:2', + 'total_price' => 'decimal:2', + 'options' => 'array', + 'depth' => 'integer', + 'sort_order' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'deleted_at' => 'datetime', + ]; + + // ---- 트리 관계 ---- + + /** + * 상위 노드 + */ + 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']); + } +} diff --git a/database/migrations/2026_02_06_200000_create_order_nodes_table.php b/database/migrations/2026_02_06_200000_create_order_nodes_table.php new file mode 100644 index 0000000..45f4788 --- /dev/null +++ b/database/migrations/2026_02_06_200000_create_order_nodes_table.php @@ -0,0 +1,56 @@ +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']); // 통계용 + }); + } + + public function down(): void + { + Schema::dropIfExists('order_nodes'); + } +}; diff --git a/database/migrations/2026_02_06_200001_add_order_node_id_to_order_items_table.php b/database/migrations/2026_02_06_200001_add_order_node_id_to_order_items_table.php new file mode 100644 index 0000000..af42b23 --- /dev/null +++ b/database/migrations/2026_02_06_200001_add_order_node_id_to_order_items_table.php @@ -0,0 +1,27 @@ +foreignId('order_node_id') + ->nullable() + ->after('order_id') + ->comment('수주 노드 ID (order_nodes)'); + $table->index('order_node_id'); + }); + } + + public function down(): void + { + Schema::table('order_items', function (Blueprint $table) { + $table->dropIndex(['order_node_id']); + $table->dropColumn('order_node_id'); + }); + } +};