feat: global_menus 테이블 분리 및 모델 구현

- global_menus 테이블 생성 (ID 1번부터 시작)
- 기존 menus(tenant_id IS NULL) → global_menus 데이터 이전
- GlobalMenu 모델 생성
- Menu.globalMenu() 관계를 GlobalMenu 모델로 변경
This commit is contained in:
2025-12-02 20:43:29 +09:00
parent 686a979127
commit d9348c0714
3 changed files with 429 additions and 4 deletions

View 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();
}
}

View File

@@ -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'];
}
}