Files
sam-api/app/Models/Commons/Menu.php
권혁성 189b38c936 feat: Auditable 트레이트 구현 및 97개 모델 적용
- Auditable 트레이트 신규 생성 (bootAuditable 패턴)
  - creating: created_by/updated_by 자동 채우기
  - updating: updated_by 자동 채우기
  - deleting: deleted_by 채우기 + saveQuietly()
  - created/updated/deleted: audit_logs 자동 기록
- 기존 AuditLogger 패턴과 동일한 try/catch 조용한 실패
- 변경된 필드만 before/after 기록 (updated 이벤트)
- auditExclude 프로퍼티로 모델별 제외 필드 설정 가능
- 제외 대상: Attendance, StockTransaction, TodayIssue 등 고빈도/시스템 모델

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 15:33:54 +09:00

277 lines
6.5 KiB
PHP

<?php
namespace App\Models\Commons;
use App\Models\Scopes\TenantScope;
use App\Traits\Auditable;
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;
/**
* @mixin IdeHelperMenu
*/
class Menu extends Model
{
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $fillable = [
'tenant_id', 'parent_id', 'global_menu_id', 'name', 'url', 'is_active', 'sort_order',
'hidden', 'is_customized', 'is_external', 'external_url', 'icon', 'options',
'created_by', 'updated_by', 'deleted_by',
];
protected $hidden = [
'created_by',
'updated_by',
'deleted_by',
'deleted_at',
];
protected $casts = [
'is_active' => 'boolean',
'hidden' => 'boolean',
'is_customized' => 'boolean',
'is_external' => 'boolean',
'options' => 'array',
];
/**
* 상위 메뉴
*/
public function parent(): BelongsTo
{
return $this->belongsTo(Menu::class, 'parent_id');
}
/**
* 하위 메뉴 목록
*/
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 글로벌 스코프는 그대로 유지)
*/
public function scopeWithShared($query, ?int $tenantId = null)
{
$tenantId = $tenantId ?? app('tenant_id');
return $query
->withoutGlobalScope(TenantScope::class)
->where(function ($w) use ($tenantId) {
if (is_null($tenantId)) {
$w->whereNull('tenant_id');
} else {
$w->whereNull('tenant_id')->orWhere('tenant_id', $tenantId);
}
});
}
/**
* 글로벌 메뉴만 조회
*/
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', 'options'];
}
// ============================================================
// Options JSON 헬퍼 메서드
// ============================================================
/**
* options에서 특정 키 값 조회
*/
public function getOption(string $key, mixed $default = null): mixed
{
return data_get($this->options, $key, $default);
}
/**
* options에 특정 키 값 설정
*/
public function setOption(string $key, mixed $value): static
{
$options = $this->options ?? [];
data_set($options, $key, $value);
$this->options = $options;
return $this;
}
/**
* 라우트명 조회 (mng, api, react 등에서 사용)
*/
public function getRouteName(): ?string
{
return $this->getOption('route_name');
}
/**
* 메뉴 섹션 조회 (main, tools, labs 등)
*/
public function getSection(): string
{
return $this->getOption('section', 'main');
}
/**
* 메뉴 타입 조회 (normal, tool, lab 등)
*/
public function getMenuType(): string
{
return $this->getOption('menu_type', 'normal');
}
/**
* 필요 역할 조회
*/
public function getRequiresRole(): ?string
{
return $this->getOption('requires_role');
}
/**
* 특정 역할이 필요한지 확인
*/
public function requiresRole(?string $role = null): bool
{
$requiredRole = $this->getRequiresRole();
if ($requiredRole === null) {
return false;
}
if ($role === null) {
return true; // 역할이 필요함
}
return $requiredRole === $role;
}
/**
* Blade 컴포넌트명 조회
*/
public function getBladeComponent(): ?string
{
return $this->getOption('blade_component');
}
/**
* CSS 클래스 조회
*/
public function getCssClass(): ?string
{
return $this->getOption('css_class');
}
/**
* meta 데이터 조회 (앱별 커스텀 데이터)
*/
public function getMeta(?string $key = null, mixed $default = null): mixed
{
if ($key === null) {
return $this->getOption('meta', []);
}
return $this->getOption("meta.{$key}", $default);
}
/**
* meta 데이터 설정
*/
public function setMeta(string $key, mixed $value): static
{
return $this->setOption("meta.{$key}", $value);
}
/**
* 특정 섹션 메뉴만 조회
*/
public function scopeSection($query, string $section)
{
return $query->whereJsonContains('options->section', $section);
}
/**
* 특정 메뉴 타입만 조회
*/
public function scopeMenuType($query, string $type)
{
return $query->whereJsonContains('options->menu_type', $type);
}
/**
* 특정 역할이 필요한 메뉴만 조회
*/
public function scopeRequiringRole($query, string $role)
{
return $query->whereJsonContains('options->requires_role', $role);
}
}