From d9348c0714957d8109880f721b60c0adeba134c4 Mon Sep 17 00:00:00 2001 From: hskwon Date: Tue, 2 Dec 2025 20:43:29 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20global=5Fmenus=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EB=AA=A8=EB=8D=B8?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - global_menus 테이블 생성 (ID 1번부터 시작) - 기존 menus(tenant_id IS NULL) → global_menus 데이터 이전 - GlobalMenu 모델 생성 - Menu.globalMenu() 관계를 GlobalMenu 모델로 변경 --- app/Models/Commons/GlobalMenu.php | 149 +++++++++++++ app/Models/Commons/Menu.php | 89 +++++++- ...12_02_150000_create_global_menus_table.php | 195 ++++++++++++++++++ 3 files changed, 429 insertions(+), 4 deletions(-) create mode 100644 app/Models/Commons/GlobalMenu.php create mode 100644 database/migrations/2025_12_02_150000_create_global_menus_table.php diff --git a/app/Models/Commons/GlobalMenu.php b/app/Models/Commons/GlobalMenu.php new file mode 100644 index 0000000..05cdc67 --- /dev/null +++ b/app/Models/Commons/GlobalMenu.php @@ -0,0 +1,149 @@ + 'boolean', + 'hidden' => 'boolean', + 'is_external' => 'boolean', + ]; + + /** + * 동기화 비교 대상 필드 + */ + public static function getSyncFields(): array + { + return ['name', 'url', 'icon', 'sort_order', 'is_active', 'hidden', 'is_external', 'external_url']; + } + + /** + * 상위 메뉴 + */ + public function parent(): BelongsTo + { + return $this->belongsTo(GlobalMenu::class, 'parent_id'); + } + + /** + * 하위 메뉴 목록 + */ + public function children(): HasMany + { + return $this->hasMany(GlobalMenu::class, 'parent_id'); + } + + /** + * 이 글로벌 메뉴로부터 복제된 테넌트 메뉴 목록 + */ + public function tenantMenus(): HasMany + { + return $this->hasMany(Menu::class, 'global_menu_id'); + } + + /** + * 활성화된 메뉴만 조회 + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * 숨겨지지 않은 메뉴만 조회 + */ + public function scopeVisible($query) + { + return $query->where('hidden', false); + } + + /** + * 최상위 메뉴만 조회 + */ + public function scopeRoots($query) + { + return $query->whereNull('parent_id'); + } + + /** + * 정렬된 메뉴 조회 + */ + public function scopeOrdered($query) + { + return $query->orderBy('sort_order')->orderBy('id'); + } + + /** + * 메뉴 레벨 계산 (대메뉴=1, 중메뉴=2, 소메뉴=3) + */ + public function getLevel(): int + { + if (is_null($this->parent_id)) { + return 1; + } + + $parent = $this->parent; + if ($parent && is_null($parent->parent_id)) { + return 2; + } + + return 3; + } + + /** + * 계층 구조로 정렬된 전체 메뉴 트리 조회 + */ + public static function getMenuTree(): array + { + $menus = static::with('children.children') + ->whereNull('parent_id') + ->active() + ->visible() + ->ordered() + ->get(); + + return $menus->toArray(); + } +} diff --git a/app/Models/Commons/Menu.php b/app/Models/Commons/Menu.php index 15d8eb8..ab1f559 100644 --- a/app/Models/Commons/Menu.php +++ b/app/Models/Commons/Menu.php @@ -6,6 +6,8 @@ use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; /** @@ -16,8 +18,8 @@ class Menu extends Model use BelongsToTenant, ModelTrait, SoftDeletes; protected $fillable = [ - 'tenant_id', 'parent_id', 'name', 'url', 'is_active', 'sort_order', - 'hidden', 'is_external', 'external_url', 'icon', + 'tenant_id', 'parent_id', 'global_menu_id', 'name', 'url', 'is_active', 'sort_order', + 'hidden', 'is_customized', 'is_external', 'external_url', 'icon', 'created_by', 'updated_by', 'deleted_by', ]; @@ -28,16 +30,53 @@ class Menu extends Model 'deleted_at', ]; - public function parent() + protected $casts = [ + 'is_active' => 'boolean', + 'hidden' => 'boolean', + 'is_customized' => 'boolean', + 'is_external' => 'boolean', + ]; + + /** + * 상위 메뉴 + */ + public function parent(): BelongsTo { return $this->belongsTo(Menu::class, 'parent_id'); } - public function children() + /** + * 하위 메뉴 목록 + */ + public function children(): HasMany { return $this->hasMany(Menu::class, 'parent_id'); } + /** + * 원본 글로벌 메뉴 (테넌트 메뉴인 경우) + */ + public function globalMenu(): BelongsTo + { + return $this->belongsTo(GlobalMenu::class, 'global_menu_id'); + } + + /** + * 글로벌 메뉴에서 복제된 메뉴인지 확인 + */ + public function isClonedFromGlobal(): bool + { + return ! is_null($this->global_menu_id); + } + + /** + * 테넌트가 커스터마이징한 메뉴인지 확인 + */ + public function isCustomized(): bool + { + return $this->is_customized; + } + /** * 공유(NULL) + 현재 테넌트 모두 포함해서 조회 * (SoftDeletes 글로벌 스코프는 그대로 유지) @@ -56,4 +95,46 @@ public function scopeWithShared($query, ?int $tenantId = null) } }); } + + /** + * 글로벌 메뉴만 조회 + */ + public function scopeGlobal($query) + { + return $query + ->withoutGlobalScope(TenantScope::class) + ->whereNull('tenant_id'); + } + + /** + * 활성화된 메뉴만 조회 + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * 숨겨지지 않은 메뉴만 조회 + */ + public function scopeVisible($query) + { + return $query->where('hidden', false); + } + + /** + * 최상위 메뉴만 조회 + */ + public function scopeRoots($query) + { + return $query->whereNull('parent_id'); + } + + /** + * 동기화 비교를 위한 필드 목록 + */ + public static function getSyncFields(): array + { + return ['name', 'url', 'icon', 'sort_order', 'is_active', 'hidden', 'is_external', 'external_url']; + } } diff --git a/database/migrations/2025_12_02_150000_create_global_menus_table.php b/database/migrations/2025_12_02_150000_create_global_menus_table.php new file mode 100644 index 0000000..e2c6952 --- /dev/null +++ b/database/migrations/2025_12_02_150000_create_global_menus_table.php @@ -0,0 +1,195 @@ +id()->comment('PK: 글로벌 메뉴 ID'); + $table->unsignedBigInteger('parent_id')->nullable()->comment('상위 메뉴 ID'); + $table->string('name', 100)->comment('메뉴명'); + $table->string('url', 255)->nullable()->comment('메뉴 URL'); + $table->string('icon', 50)->nullable()->comment('아이콘명'); + $table->integer('sort_order')->default(0)->comment('정렬순서'); + $table->boolean('is_active')->default(true)->comment('활성여부'); + $table->boolean('hidden')->default(false)->comment('숨김여부'); + $table->boolean('is_external')->default(false)->comment('외부링크여부'); + $table->string('external_url', 255)->nullable()->comment('외부링크 URL'); + $table->timestamps(); + $table->softDeletes()->comment('소프트삭제 시각'); + + $table->index('parent_id', 'global_menus_parent_id_idx'); + $table->index('sort_order', 'global_menus_sort_order_idx'); + $table->index('deleted_at', 'global_menus_deleted_at_idx'); + }); + + // 2. 기존 글로벌 메뉴 데이터를 계층 순서로 이전 + $this->migrateGlobalMenus(); + + // 3. menus 테이블에서 글로벌 메뉴(tenant_id IS NULL) 삭제 + DB::table('menus')->whereNull('tenant_id')->delete(); + + // 4. menus.global_menu_id FK 변경 (global_menus 참조) + // 기존 인덱스 삭제 후 재생성 + Schema::table('menus', function (Blueprint $table) { + // tenant_id NOT NULL 변경은 테넌트 메뉴가 없어서 안전 + // 하지만 나중에 테넌트 메뉴가 추가되면 필요할 수 있으므로 nullable 유지 + }); + } + + /** + * 기존 글로벌 메뉴를 새 테이블로 이전 + * ID 1번부터 시작, 계층 구조 유지 + */ + private function migrateGlobalMenus(): void + { + // 기존 글로벌 메뉴 조회 (계층 순서로 정렬) + $globalMenus = DB::table('menus') + ->whereNull('tenant_id') + ->whereNull('deleted_at') + ->orderByRaw('COALESCE(parent_id, id)') + ->orderByRaw('CASE WHEN parent_id IS NULL THEN 0 ELSE 1 END') + ->orderBy('sort_order') + ->orderBy('id') + ->get(); + + // old_id → new_id 매핑 + $idMapping = []; + $newId = 1; + + // 1차: 대메뉴 먼저 삽입 + foreach ($globalMenus as $menu) { + if (is_null($menu->parent_id)) { + DB::table('global_menus')->insert([ + 'id' => $newId, + 'parent_id' => null, + 'name' => $menu->name, + 'url' => $menu->url, + 'icon' => $menu->icon, + 'sort_order' => $menu->sort_order, + 'is_active' => $menu->is_active, + 'hidden' => $menu->hidden, + 'is_external' => $menu->is_external, + 'external_url' => $menu->external_url, + 'created_at' => $menu->created_at, + 'updated_at' => $menu->updated_at, + 'deleted_at' => $menu->deleted_at, + ]); + $idMapping[$menu->id] = $newId; + $newId++; + } + } + + // 2차: 중메뉴 삽입 (parent가 대메뉴인 것) + foreach ($globalMenus as $menu) { + if (! is_null($menu->parent_id) && isset($idMapping[$menu->parent_id])) { + // parent가 이미 매핑된 경우 (대메뉴) + $parentNewId = $idMapping[$menu->parent_id]; + + // 이 메뉴가 아직 매핑 안됐으면 중메뉴 + if (! isset($idMapping[$menu->id])) { + DB::table('global_menus')->insert([ + 'id' => $newId, + 'parent_id' => $parentNewId, + 'name' => $menu->name, + 'url' => $menu->url, + 'icon' => $menu->icon, + 'sort_order' => $menu->sort_order, + 'is_active' => $menu->is_active, + 'hidden' => $menu->hidden, + 'is_external' => $menu->is_external, + 'external_url' => $menu->external_url, + 'created_at' => $menu->created_at, + 'updated_at' => $menu->updated_at, + 'deleted_at' => $menu->deleted_at, + ]); + $idMapping[$menu->id] = $newId; + $newId++; + } + } + } + + // 3차: 소메뉴 삽입 (parent가 중메뉴인 것) + foreach ($globalMenus as $menu) { + if (! is_null($menu->parent_id) && ! isset($idMapping[$menu->id])) { + // 아직 매핑 안된 메뉴 = 소메뉴 + $parentNewId = $idMapping[$menu->parent_id] ?? null; + if ($parentNewId) { + DB::table('global_menus')->insert([ + 'id' => $newId, + 'parent_id' => $parentNewId, + 'name' => $menu->name, + 'url' => $menu->url, + 'icon' => $menu->icon, + 'sort_order' => $menu->sort_order, + 'is_active' => $menu->is_active, + 'hidden' => $menu->hidden, + 'is_external' => $menu->is_external, + 'external_url' => $menu->external_url, + 'created_at' => $menu->created_at, + 'updated_at' => $menu->updated_at, + 'deleted_at' => $menu->deleted_at, + ]); + $idMapping[$menu->id] = $newId; + $newId++; + } + } + } + + // AUTO_INCREMENT 값 재설정 + DB::statement('ALTER TABLE global_menus AUTO_INCREMENT = '.$newId); + + // 테넌트 메뉴의 global_menu_id 업데이트 (old_id → new_id) + foreach ($idMapping as $oldId => $mappedNewId) { + DB::table('menus') + ->where('global_menu_id', $oldId) + ->update(['global_menu_id' => $mappedNewId]); + } + } + + public function down(): void + { + // 1. global_menus 데이터를 menus로 복원 + $globalMenus = DB::table('global_menus')->get(); + + foreach ($globalMenus as $menu) { + DB::table('menus')->insert([ + 'tenant_id' => null, + 'parent_id' => $menu->parent_id, // 이미 새 ID 체계이므로 별도 매핑 필요 + 'global_menu_id' => null, + 'name' => $menu->name, + 'url' => $menu->url, + 'icon' => $menu->icon, + 'sort_order' => $menu->sort_order, + 'is_active' => $menu->is_active, + 'hidden' => $menu->hidden, + 'is_customized' => false, + 'is_external' => $menu->is_external, + 'external_url' => $menu->external_url, + 'created_at' => $menu->created_at, + 'updated_at' => $menu->updated_at, + 'deleted_at' => $menu->deleted_at, + ]); + } + + // 2. global_menus 테이블 삭제 + Schema::dropIfExists('global_menus'); + } +};