feat: global_menus 테이블 분리 및 모델 구현
- global_menus 테이블 생성 (ID 1번부터 시작) - 기존 menus(tenant_id IS NULL) → global_menus 데이터 이전 - GlobalMenu 모델 생성 - Menu.globalMenu() 관계를 GlobalMenu 모델로 변경
This commit is contained in:
149
app/Models/Commons/GlobalMenu.php
Normal file
149
app/Models/Commons/GlobalMenu.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Commons;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* 글로벌 메뉴 모델
|
||||
*
|
||||
* 시스템 전체에서 사용하는 기본 메뉴 구조를 정의합니다.
|
||||
* 테넌트 메뉴(menus)는 이 글로벌 메뉴를 기반으로 복제되어 커스터마이징됩니다.
|
||||
*
|
||||
* @property int $id
|
||||
* @property int|null $parent_id
|
||||
* @property string $name
|
||||
* @property string|null $url
|
||||
* @property string|null $icon
|
||||
* @property int $sort_order
|
||||
* @property bool $is_active
|
||||
* @property bool $hidden
|
||||
* @property bool $is_external
|
||||
* @property string|null $external_url
|
||||
* @property \Carbon\Carbon|null $created_at
|
||||
* @property \Carbon\Carbon|null $updated_at
|
||||
* @property \Carbon\Carbon|null $deleted_at
|
||||
*/
|
||||
class GlobalMenu extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'global_menus';
|
||||
|
||||
protected $fillable = [
|
||||
'parent_id',
|
||||
'name',
|
||||
'url',
|
||||
'icon',
|
||||
'sort_order',
|
||||
'is_active',
|
||||
'hidden',
|
||||
'is_external',
|
||||
'external_url',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => '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();
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 글로벌 메뉴 테이블 분리
|
||||
*
|
||||
* 기존: menus 테이블에서 tenant_id IS NULL로 글로벌 메뉴 관리
|
||||
* 변경: global_menus 별도 테이블로 분리하여 명확한 구조 확립
|
||||
*
|
||||
* - global_menus 테이블 생성 (ID 1번부터 시작)
|
||||
* - 기존 글로벌 메뉴 데이터 이전 (계층 순서 유지)
|
||||
* - menus에서 글로벌 메뉴 삭제
|
||||
* - menus.tenant_id NOT NULL 변경
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// 1. global_menus 테이블 생성
|
||||
Schema::create('global_menus', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user