Files
sam-manage/app/Services/MenuService.php
kent 5d96ff871d feat(board): 게시판 수정 시 메뉴 URL/이름 자동 업데이트
- MenuService::updateMenuForBoard() 메서드 추가
- 시스템 게시판: global_menus + 연결된 모든 menus URL 업데이트
- 테넌트 게시판: 해당 테넌트의 menus만 URL 업데이트
- BoardService::updateAnyBoard()에서 board_code/name 변경 감지 시 호출

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 00:17:53 +09:00

1556 lines
53 KiB
PHP

<?php
namespace App\Services;
use App\Models\Archives\ArchivedRecord;
use App\Models\Archives\ArchivedRecordRelation;
use App\Models\Commons\GlobalMenu;
use App\Models\Commons\Menu;
use App\Models\Permission;
use App\Models\Tenants\Tenant;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class MenuService
{
// =========================================================================
// 게시판 메뉴 연동 헬퍼
// =========================================================================
/**
* 게시판 연동 URL 패턴인지 확인
* - /customer-center/* : 시스템 게시판 (고객센터)
* - /boards/* : 테넌트 게시판
*/
public function isBoardMenuUrl(?string $url): bool
{
if (empty($url)) {
return false;
}
return str_starts_with($url, '/customer-center/') || str_starts_with($url, '/boards/');
}
/**
* 게시판 연동 메뉴인지 확인 (Menu 또는 GlobalMenu)
*/
public function isBoardMenu(Menu|GlobalMenu $menu): bool
{
return $this->isBoardMenuUrl($menu->url);
}
/**
* 게시판 메뉴 URL 수동 생성/수정 방지 검증
*
* @throws \InvalidArgumentException 게시판 URL 패턴 사용 시
*/
public function validateNotBoardUrl(?string $url): void
{
if ($this->isBoardMenuUrl($url)) {
throw new \InvalidArgumentException(
'게시판 연동 URL 패턴(/customer-center/*, /boards/*)은 직접 사용할 수 없습니다. 게시판 관리에서 생성해주세요.'
);
}
}
// =========================================================================
// 메뉴 목록 조회
// =========================================================================
/**
* 메뉴 목록 조회 (페이지네이션) - 트리 구조로 정렬
*/
public function getMenus(array $filters = [], int $perPage = 15): LengthAwarePaginator
{
$tenantId = session('selected_tenant_id');
$query = Menu::query()->withTrashed();
// 테넌트 필터링
if ($tenantId) {
// 특정 테넌트 선택 시: 해당 테넌트의 메뉴만
$query->where('tenant_id', $tenantId);
} else {
// 전체 선택 시: tenant_id가 NULL인 마스터 메뉴만
$query->whereNull('tenant_id');
}
// Soft Delete 필터
if (isset($filters['trashed'])) {
if ($filters['trashed'] === 'only') {
$query->onlyTrashed();
} elseif ($filters['trashed'] === 'with') {
$query->withTrashed();
}
}
// 검색 필터
if (! empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('url', 'like', "%{$search}%");
});
}
// 활성 상태 필터
if (isset($filters['is_active'])) {
$query->where('is_active', $filters['is_active']);
}
// 부모 메뉴 필터 (트리 구조)
if (isset($filters['parent_id'])) {
if ($filters['parent_id'] === 'null') {
$query->whereNull('parent_id');
} else {
$query->where('parent_id', $filters['parent_id']);
}
}
// 모든 메뉴 가져오기 (트리 구조 정렬을 위해)
$allMenus = $query->with(['parent', 'tenant'])->orderBy('sort_order')->orderBy('id')->get();
// 트리 구조로 정렬 후 플랫한 배열로 변환
$flattenedMenus = $this->flattenMenuTree($allMenus);
// 수동 페이지네이션
$currentPage = request()->input('page', 1);
$offset = ($currentPage - 1) * $perPage;
$items = $flattenedMenus->slice($offset, $perPage)->values();
return new \Illuminate\Pagination\LengthAwarePaginator(
$items,
$flattenedMenus->count(),
$perPage,
$currentPage,
['path' => request()->url(), 'query' => request()->query()]
);
}
/**
* 트리 구조를 플랫한 배열로 변환 (depth 정보 포함)
*/
private function flattenMenuTree(Collection $menus, ?int $parentId = null, int $depth = 0): Collection
{
$result = collect();
$filteredMenus = $menus->where('parent_id', $parentId)->sortBy('sort_order');
foreach ($filteredMenus as $menu) {
$menu->depth = $depth;
// 자식 메뉴 존재 여부 확인
$menu->has_children = $menus->where('parent_id', $menu->id)->count() > 0;
$result->push($menu);
// 자식 메뉴 재귀적으로 추가
$children = $this->flattenMenuTree($menus, $menu->id, $depth + 1);
$result = $result->merge($children);
}
return $result;
}
/**
* 메뉴 상세 조회
*/
public function getMenuById(int $id): ?Menu
{
return Menu::with(['parent', 'children'])->find($id);
}
/**
* 메뉴 트리 구조로 조회 (전체)
*/
public function getMenuTree(?int $tenantId = null): Collection
{
$tenantId = $tenantId ?? session('selected_tenant_id');
$query = Menu::query()
->where('is_active', true)
->orderBy('sort_order')
->orderBy('id');
if ($tenantId) {
// 특정 테넌트 선택 시: 해당 테넌트의 메뉴만
$query->where('tenant_id', $tenantId);
} else {
// 전체 선택 시: tenant_id가 NULL인 마스터 메뉴만
$query->whereNull('tenant_id');
}
$allMenus = $query->get();
// 부모 메뉴만 필터링하고 자식 메뉴를 재귀적으로 연결
return $allMenus->where('parent_id', null)->map(function ($menu) use ($allMenus) {
$menu->children = $this->buildChildren($menu, $allMenus);
return $menu;
});
}
/**
* 재귀적으로 자식 메뉴 구성
*/
private function buildChildren(Menu $parent, Collection $allMenus): Collection
{
$children = $allMenus->where('parent_id', $parent->id);
return $children->map(function ($child) use ($allMenus) {
$child->children = $this->buildChildren($child, $allMenus);
return $child;
});
}
/**
* 부모 메뉴 목록 조회 (드롭다운용) - 트리 구조 순서로 정렬, depth 정보 포함
*/
public function getParentMenus(?int $tenantId = null): Collection
{
$tenantId = $tenantId ?? session('selected_tenant_id');
$query = Menu::query()
->where('is_active', true)
->orderBy('sort_order')
->orderBy('id');
if ($tenantId) {
// 특정 테넌트 선택 시: 해당 테넌트의 메뉴만
$query->where('tenant_id', $tenantId);
} else {
// 전체 선택 시: tenant_id가 NULL인 마스터 메뉴만
$query->whereNull('tenant_id');
}
$allMenus = $query->get();
// 트리 구조로 정렬 (depth 정보 포함)
return $this->flattenMenuTree($allMenus);
}
/**
* 메뉴 생성
*
* @throws \InvalidArgumentException 게시판 URL 패턴 사용 시
*/
public function createMenu(array $data): Menu
{
// 게시판 URL 패턴 수동 생성 방지
$this->validateNotBoardUrl($data['url'] ?? null);
$tenantId = session('selected_tenant_id');
// is_active 처리
$data['is_active'] = isset($data['is_active']) && $data['is_active'] == '1';
// hidden 처리
$data['hidden'] = isset($data['hidden']) && $data['hidden'] == '1';
// is_external 처리
$data['is_external'] = isset($data['is_external']) && $data['is_external'] == '1';
// 생성자 정보
$data['created_by'] = auth()->id();
// 테넌트 정보
if ($tenantId) {
$data['tenant_id'] = $tenantId;
}
// parent_id null 처리
if (empty($data['parent_id'])) {
$data['parent_id'] = null;
}
return Menu::create($data);
}
/**
* 메뉴 수정
*
* @throws \InvalidArgumentException 게시판 메뉴 수정 또는 게시판 URL 패턴 사용 시
*/
public function updateMenu(int $id, array $data): bool
{
$menu = $this->getMenuById($id);
if (! $menu) {
return false;
}
// 게시판 연동 메뉴는 수정 불가 (활성/숨김 토글만 허용)
if ($this->isBoardMenu($menu)) {
throw new \InvalidArgumentException(
'게시판 연동 메뉴는 수정할 수 없습니다. 게시판 관리에서 수정해주세요.'
);
}
// 새 URL이 게시판 URL 패턴이면 거부
$this->validateNotBoardUrl($data['url'] ?? null);
// is_active 처리
$data['is_active'] = isset($data['is_active']) && $data['is_active'] == '1';
// hidden 처리
$data['hidden'] = isset($data['hidden']) && $data['hidden'] == '1';
// is_external 처리
$data['is_external'] = isset($data['is_external']) && $data['is_external'] == '1';
// 수정자 정보
$data['updated_by'] = auth()->id();
// parent_id null 처리
if (empty($data['parent_id'])) {
$data['parent_id'] = null;
}
// 자기 자신을 부모로 설정하는 것 방지
if (isset($data['parent_id']) && $data['parent_id'] == $id) {
return false;
}
return $menu->update($data);
}
/**
* 메뉴 삭제 (Soft Delete)
*
* @throws \InvalidArgumentException 게시판 메뉴 삭제 시도 시
*/
public function deleteMenu(int $id): bool
{
$menu = $this->getMenuById($id);
if (! $menu) {
return false;
}
// 게시판 연동 메뉴는 삭제 불가
if ($this->isBoardMenu($menu)) {
throw new \InvalidArgumentException(
'게시판 연동 메뉴는 삭제할 수 없습니다. 게시판 관리에서 삭제해주세요.'
);
}
// 자식 메뉴가 있는 경우 삭제 불가
if ($menu->children()->count() > 0) {
return false;
}
$menu->deleted_by = auth()->id();
$menu->save();
return $menu->delete();
}
/**
* 메뉴 복원
*/
public function restoreMenu(int $id): bool
{
$menu = Menu::onlyTrashed()->findOrFail($id);
return $menu->restore();
}
/**
* 메뉴 영구 삭제 (슈퍼관리자 전용)
* - 연관 권한(permissions)도 함께 삭제
* - role_has_permissions, model_has_permissions는 FK CASCADE로 자동 삭제
* - 삭제 정보를 archived_records에 저장
*
* @return array{success: bool, message: string, deleted_permissions: array}
*
* @throws \InvalidArgumentException 게시판 메뉴 영구 삭제 시도 시
*/
public function forceDeleteMenu(int $id): array
{
$menu = Menu::withTrashed()->findOrFail($id);
// 게시판 연동 메뉴는 영구 삭제 불가
if ($this->isBoardMenu($menu)) {
throw new \InvalidArgumentException(
'게시판 연동 메뉴는 영구 삭제할 수 없습니다. 게시판 관리에서 삭제해주세요.'
);
}
// 자식 메뉴가 있는 경우 영구 삭제 불가
if ($menu->children()->withTrashed()->count() > 0) {
return [
'success' => false,
'message' => '자식 메뉴가 있어 삭제할 수 없습니다.',
'deleted_permissions' => [],
];
}
return DB::transaction(function () use ($menu) {
// 연관 권한 조회 (삭제 전 기록용)
$permissions = Permission::where('name', 'like', "menu:{$menu->id}.%")->get();
$permissionData = $permissions->map(fn ($p) => [
'id' => $p->id,
'name' => $p->name,
'guard_name' => $p->guard_name,
'tenant_id' => $p->tenant_id,
])->toArray();
// 역할-권한 연결 정보 조회
$rolePermissions = DB::table('role_has_permissions')
->join('roles', 'role_has_permissions.role_id', '=', 'roles.id')
->whereIn('role_has_permissions.permission_id', $permissions->pluck('id'))
->select('roles.id as role_id', 'roles.name as role_name', 'role_has_permissions.permission_id')
->get()
->toArray();
// 아카이브 레코드 생성
$batchId = (string) Str::uuid();
$archivedRecord = ArchivedRecord::create([
'batch_id' => $batchId,
'batch_description' => "메뉴 영구 삭제: {$menu->name} (ID: {$menu->id})",
'record_type' => 'menu',
'tenant_id' => $menu->tenant_id,
'original_id' => $menu->id,
'main_data' => $menu->toArray(),
'schema_version' => '1.0',
'deleted_by' => auth()->id(),
'deleted_at' => now(),
'notes' => "메뉴 영구 삭제 - 연관 권한 {$permissions->count()}개 함께 삭제",
]);
// 연관 권한 정보 저장
if ($permissions->isNotEmpty()) {
ArchivedRecordRelation::create([
'archived_record_id' => $archivedRecord->id,
'table_name' => 'permissions',
'data' => $permissionData,
'record_count' => $permissions->count(),
]);
}
// 역할-권한 연결 정보 저장
if (! empty($rolePermissions)) {
ArchivedRecordRelation::create([
'archived_record_id' => $archivedRecord->id,
'table_name' => 'role_has_permissions',
'data' => $rolePermissions,
'record_count' => count($rolePermissions),
]);
}
// 연관 권한 삭제 (FK CASCADE로 role_has_permissions, model_has_permissions 자동 삭제)
Permission::where('name', 'like', "menu:{$menu->id}.%")->delete();
// 메뉴 영구 삭제
$menu->forceDelete();
return [
'success' => true,
'message' => "메뉴 '{$menu->name}'와 연관 권한 {$permissions->count()}개가 삭제되었습니다.",
'deleted_permissions' => $permissionData,
'batch_id' => $batchId,
];
});
}
/**
* 메뉴 활성 상태 토글
*/
public function toggleActive(int $id): bool
{
$menu = $this->getMenuById($id);
if (! $menu) {
return false;
}
$menu->is_active = ! $menu->is_active;
$menu->updated_by = auth()->id();
return $menu->save();
}
/**
* 메뉴 숨김 상태 토글
*/
public function toggleHidden(int $id): bool
{
$menu = $this->getMenuById($id);
if (! $menu) {
return false;
}
$menu->hidden = ! $menu->hidden;
$menu->updated_by = auth()->id();
return $menu->save();
}
/**
* 메뉴 순서 변경 (드래그앤드롭)
* 같은 parent_id 내에서만 순서 변경
*/
public function reorderMenus(array $items): bool
{
return \DB::transaction(function () use ($items) {
foreach ($items as $item) {
Menu::where('id', $item['id'])
->update([
'sort_order' => $item['sort_order'],
'updated_by' => auth()->id(),
]);
}
return true;
});
}
/**
* 메뉴 이동 (계층 구조 변경)
* - 다른 부모 아래로 이동 가능
* - 하위 메뉴는 자동으로 따라감
* - 순환 참조 방지
*/
public function moveMenu(int $menuId, ?int $newParentId, int $sortOrder): array
{
$menu = Menu::find($menuId);
if (! $menu) {
return ['success' => false, 'message' => '메뉴를 찾을 수 없습니다.'];
}
// 순환 참조 방지: 자신의 하위 메뉴로 이동 불가
if ($newParentId !== null && $this->isDescendant($menuId, $newParentId)) {
return ['success' => false, 'message' => '자신의 하위 메뉴로 이동할 수 없습니다.'];
}
// 자기 자신을 부모로 설정 방지
if ($newParentId === $menuId) {
return ['success' => false, 'message' => '자기 자신을 부모로 설정할 수 없습니다.'];
}
return \DB::transaction(function () use ($menu, $newParentId, $sortOrder) {
$oldParentId = $menu->parent_id;
// 부모 변경
$menu->parent_id = $newParentId;
$menu->sort_order = $sortOrder;
$menu->updated_by = auth()->id();
$menu->save();
// 같은 부모의 다른 메뉴들 순서 재정렬
$this->reorderSiblings($newParentId, $menu->id, $sortOrder);
// 이전 부모의 메뉴들도 순서 재정렬 (빈 자리 채우기)
if ($oldParentId !== $newParentId) {
$this->compactSiblings($oldParentId);
}
return ['success' => true, 'message' => '메뉴가 이동되었습니다.'];
});
}
/**
* 특정 메뉴가 다른 메뉴의 하위인지 확인 (순환 참조 방지)
*/
private function isDescendant(int $ancestorId, int $menuId): bool
{
$menu = Menu::find($menuId);
while ($menu) {
if ($menu->id === $ancestorId) {
return true;
}
$menu = $menu->parent;
}
return false;
}
/**
* 같은 부모의 형제 메뉴들 순서 재정렬
*/
private function reorderSiblings(?int $parentId, int $excludeId, int $insertAt): void
{
$siblings = Menu::where('parent_id', $parentId)
->where('id', '!=', $excludeId)
->orderBy('sort_order')
->get();
$order = 1;
foreach ($siblings as $sibling) {
if ($order === $insertAt) {
$order++; // 삽입 위치 건너뛰기
}
if ($sibling->sort_order !== $order) {
$sibling->update(['sort_order' => $order]);
}
$order++;
}
}
/**
* 형제 메뉴들 순서 압축 (빈 자리 채우기)
*/
private function compactSiblings(?int $parentId): void
{
$siblings = Menu::where('parent_id', $parentId)
->orderBy('sort_order')
->get();
$order = 1;
foreach ($siblings as $sibling) {
if ($sibling->sort_order !== $order) {
$sibling->update(['sort_order' => $order]);
}
$order++;
}
}
/**
* 글로벌 부모 메뉴 목록 조회 (드롭다운용) - 트리 구조 순서로 정렬, depth 정보 포함
*/
public function getGlobalParentMenus(): Collection
{
$allMenus = GlobalMenu::query()
->where('is_active', true)
->orderBy('sort_order')
->orderBy('id')
->get();
// 트리 구조로 정렬 (depth 정보 포함)
return $this->flattenMenuTree($allMenus);
}
/**
* 글로벌 메뉴 상세 조회
*/
public function getGlobalMenuById(int $id): ?GlobalMenu
{
return GlobalMenu::with(['parent', 'children'])->find($id);
}
/**
* 글로벌 메뉴 목록 조회 (페이지네이션) - 트리 구조로 정렬
*/
public function getGlobalMenus(array $filters = [], int $perPage = 15): LengthAwarePaginator
{
$query = GlobalMenu::query()->withTrashed();
// Soft Delete 필터
if (isset($filters['trashed'])) {
if ($filters['trashed'] === 'only') {
$query->onlyTrashed();
} elseif ($filters['trashed'] === 'with') {
$query->withTrashed();
}
}
// 검색 필터
if (! empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('url', 'like', "%{$search}%");
});
}
// 활성 상태 필터
if (isset($filters['is_active'])) {
$query->where('is_active', $filters['is_active']);
}
// 모든 메뉴 가져오기 (트리 구조 정렬을 위해)
$allMenus = $query->with(['parent'])->orderBy('sort_order')->orderBy('id')->get();
// 트리 구조로 정렬 후 플랫한 배열로 변환
$flattenedMenus = $this->flattenMenuTree($allMenus);
// 수동 페이지네이션
$currentPage = request()->input('page', 1);
$offset = ($currentPage - 1) * $perPage;
$items = $flattenedMenus->slice($offset, $perPage)->values();
return new \Illuminate\Pagination\LengthAwarePaginator(
$items,
$flattenedMenus->count(),
$perPage,
$currentPage,
['path' => request()->url(), 'query' => request()->query()]
);
}
/**
* 글로벌 메뉴 생성
*
* @throws \InvalidArgumentException 게시판 URL 패턴 사용 시
*/
public function createGlobalMenu(array $data): GlobalMenu
{
// 게시판 URL 패턴 수동 생성 방지
$this->validateNotBoardUrl($data['url'] ?? null);
// is_active 처리
$data['is_active'] = isset($data['is_active']) && $data['is_active'] == '1';
// hidden 처리
$data['hidden'] = isset($data['hidden']) && $data['hidden'] == '1';
// is_external 처리
$data['is_external'] = isset($data['is_external']) && $data['is_external'] == '1';
// parent_id null 처리
if (empty($data['parent_id'])) {
$data['parent_id'] = null;
}
return GlobalMenu::create($data);
}
/**
* 글로벌 메뉴 수정
*
* @throws \InvalidArgumentException 게시판 메뉴 수정 또는 게시판 URL 패턴 사용 시
*/
public function updateGlobalMenu(int $id, array $data): bool
{
$menu = $this->getGlobalMenuById($id);
if (! $menu) {
return false;
}
// 게시판 연동 메뉴는 수정 불가 (활성/숨김 토글만 허용)
if ($this->isBoardMenu($menu)) {
throw new \InvalidArgumentException(
'게시판 연동 메뉴는 수정할 수 없습니다. 게시판 관리에서 수정해주세요.'
);
}
// 새 URL이 게시판 URL 패턴이면 거부
$this->validateNotBoardUrl($data['url'] ?? null);
// is_active 처리
$data['is_active'] = isset($data['is_active']) && $data['is_active'] == '1';
// hidden 처리
$data['hidden'] = isset($data['hidden']) && $data['hidden'] == '1';
// is_external 처리
$data['is_external'] = isset($data['is_external']) && $data['is_external'] == '1';
// parent_id null 처리
if (empty($data['parent_id'])) {
$data['parent_id'] = null;
}
// 자기 자신을 부모로 설정하는 것 방지
if (isset($data['parent_id']) && $data['parent_id'] == $id) {
return false;
}
return $menu->update($data);
}
/**
* 글로벌 메뉴 삭제 (Soft Delete)
*
* @throws \InvalidArgumentException 게시판 연동 메뉴인 경우
*/
public function deleteGlobalMenu(int $id): bool
{
$menu = $this->getGlobalMenuById($id);
if (! $menu) {
return false;
}
// 게시판 연동 메뉴는 삭제 불가
if ($this->isBoardMenu($menu)) {
throw new \InvalidArgumentException(
'게시판 연동 메뉴는 삭제할 수 없습니다. 게시판 관리에서 삭제해주세요.'
);
}
// 자식 메뉴가 있는 경우 삭제 불가
if ($menu->children()->count() > 0) {
return false;
}
return $menu->delete();
}
/**
* 글로벌 메뉴 복원
*/
public function restoreGlobalMenu(int $id): bool
{
$menu = GlobalMenu::onlyTrashed()->findOrFail($id);
return $menu->restore();
}
/**
* 글로벌 메뉴 영구 삭제
* - 연관 권한(permissions)도 함께 삭제
* - 이 글로벌 메뉴를 참조하는 테넌트 메뉴들의 global_menu_id도 null 처리
* - 삭제 정보를 archived_records에 저장
*
* @return array{success: bool, message: string, deleted_permissions: array}
*
* @throws \InvalidArgumentException 게시판 연동 메뉴인 경우
*/
public function forceDeleteGlobalMenu(int $id): array
{
$menu = GlobalMenu::withTrashed()->findOrFail($id);
// 게시판 연동 메뉴는 영구 삭제 불가
if ($this->isBoardMenu($menu)) {
throw new \InvalidArgumentException(
'게시판 연동 메뉴는 영구 삭제할 수 없습니다. 게시판 관리에서 삭제해주세요.'
);
}
// 자식 메뉴가 있는 경우 영구 삭제 불가
if ($menu->children()->withTrashed()->count() > 0) {
return [
'success' => false,
'message' => '자식 메뉴가 있어 삭제할 수 없습니다.',
'deleted_permissions' => [],
];
}
return DB::transaction(function () use ($menu) {
// 연관 권한 조회 (삭제 전 기록용)
$permissions = Permission::where('name', 'like', "global_menu:{$menu->id}.%")->get();
$permissionData = $permissions->map(fn ($p) => [
'id' => $p->id,
'name' => $p->name,
'guard_name' => $p->guard_name,
'tenant_id' => $p->tenant_id,
])->toArray();
// 역할-권한 연결 정보 조회
$rolePermissions = DB::table('role_has_permissions')
->join('roles', 'role_has_permissions.role_id', '=', 'roles.id')
->whereIn('role_has_permissions.permission_id', $permissions->pluck('id'))
->select('roles.id as role_id', 'roles.name as role_name', 'role_has_permissions.permission_id')
->get()
->toArray();
// 참조하는 테넌트 메뉴 조회
$referencingMenus = Menu::withTrashed()
->where('global_menu_id', $menu->id)
->get(['id', 'tenant_id', 'name'])
->toArray();
// 아카이브 레코드 생성
$batchId = (string) Str::uuid();
$archivedRecord = ArchivedRecord::create([
'batch_id' => $batchId,
'batch_description' => "글로벌 메뉴 영구 삭제: {$menu->name} (ID: {$menu->id})",
'record_type' => 'global_menu',
'tenant_id' => null,
'original_id' => $menu->id,
'main_data' => $menu->toArray(),
'schema_version' => '1.0',
'deleted_by' => auth()->id(),
'deleted_at' => now(),
'notes' => "글로벌 메뉴 영구 삭제 - 연관 권한 {$permissions->count()}개, 참조 테넌트 메뉴 ".count($referencingMenus).'개 해제',
]);
// 연관 권한 정보 저장
if ($permissions->isNotEmpty()) {
ArchivedRecordRelation::create([
'archived_record_id' => $archivedRecord->id,
'table_name' => 'permissions',
'data' => $permissionData,
'record_count' => $permissions->count(),
]);
}
// 역할-권한 연결 정보 저장
if (! empty($rolePermissions)) {
ArchivedRecordRelation::create([
'archived_record_id' => $archivedRecord->id,
'table_name' => 'role_has_permissions',
'data' => $rolePermissions,
'record_count' => count($rolePermissions),
]);
}
// 참조 테넌트 메뉴 정보 저장
if (! empty($referencingMenus)) {
ArchivedRecordRelation::create([
'archived_record_id' => $archivedRecord->id,
'table_name' => 'menus (referencing)',
'data' => $referencingMenus,
'record_count' => count($referencingMenus),
]);
}
// 연관 권한 삭제
Permission::where('name', 'like', "global_menu:{$menu->id}.%")->delete();
// 이 글로벌 메뉴를 참조하는 테넌트 메뉴들의 참조 해제
Menu::withTrashed()
->where('global_menu_id', $menu->id)
->update(['global_menu_id' => null, 'is_customized' => true]);
// 글로벌 메뉴 영구 삭제
$menu->forceDelete();
return [
'success' => true,
'message' => "글로벌 메뉴 '{$menu->name}'와 연관 권한 {$permissions->count()}개가 삭제되었습니다.",
'deleted_permissions' => $permissionData,
'referencing_menus_unlinked' => count($referencingMenus),
'batch_id' => $batchId,
];
});
}
/**
* 글로벌 메뉴 활성 상태 토글
*/
public function toggleGlobalActive(int $id): bool
{
$menu = $this->getGlobalMenuById($id);
if (! $menu) {
return false;
}
$menu->is_active = ! $menu->is_active;
return $menu->save();
}
/**
* 글로벌 메뉴 숨김 상태 토글
*/
public function toggleGlobalHidden(int $id): bool
{
$menu = $this->getGlobalMenuById($id);
if (! $menu) {
return false;
}
$menu->hidden = ! $menu->hidden;
return $menu->save();
}
/**
* 글로벌 메뉴 순서 변경
*/
public function reorderGlobalMenus(array $items): bool
{
return \DB::transaction(function () use ($items) {
foreach ($items as $item) {
GlobalMenu::where('id', $item['id'])
->update(['sort_order' => $item['sort_order']]);
}
return true;
});
}
/**
* 글로벌 메뉴 이동 (계층 구조 변경 - 인덴트/아웃덴트)
* - 다른 부모 아래로 이동 가능
* - 하위 메뉴는 자동으로 따라감
* - 순환 참조 방지
*/
public function moveGlobalMenu(int $menuId, ?int $newParentId, int $sortOrder): array
{
$menu = GlobalMenu::find($menuId);
if (! $menu) {
return ['success' => false, 'message' => '글로벌 메뉴를 찾을 수 없습니다.'];
}
// 순환 참조 방지: 자신의 하위 메뉴로 이동 불가
if ($newParentId !== null && $this->isGlobalDescendant($menuId, $newParentId)) {
return ['success' => false, 'message' => '자신의 하위 메뉴로 이동할 수 없습니다.'];
}
// 자기 자신을 부모로 설정 방지
if ($newParentId === $menuId) {
return ['success' => false, 'message' => '자기 자신을 부모로 설정할 수 없습니다.'];
}
return \DB::transaction(function () use ($menu, $newParentId, $sortOrder) {
$oldParentId = $menu->parent_id;
// 부모 변경
$menu->parent_id = $newParentId;
$menu->sort_order = $sortOrder;
$menu->save();
// 같은 부모의 다른 메뉴들 순서 재정렬
$this->reorderGlobalSiblings($newParentId, $menu->id, $sortOrder);
// 이전 부모의 메뉴들도 순서 재정렬 (빈 자리 채우기)
if ($oldParentId !== $newParentId) {
$this->compactGlobalSiblings($oldParentId);
}
return ['success' => true, 'message' => '글로벌 메뉴가 이동되었습니다.'];
});
}
/**
* 특정 글로벌 메뉴가 다른 메뉴의 하위인지 확인 (순환 참조 방지)
*/
private function isGlobalDescendant(int $ancestorId, int $menuId): bool
{
$menu = GlobalMenu::find($menuId);
while ($menu) {
if ($menu->id === $ancestorId) {
return true;
}
$menu = $menu->parent;
}
return false;
}
/**
* 같은 부모의 글로벌 형제 메뉴들 순서 재정렬
*/
private function reorderGlobalSiblings(?int $parentId, int $excludeId, int $insertAt): void
{
$siblings = GlobalMenu::where('parent_id', $parentId)
->where('id', '!=', $excludeId)
->orderBy('sort_order')
->get();
$order = 1;
foreach ($siblings as $sibling) {
if ($order === $insertAt) {
$order++; // 삽입 위치 건너뛰기
}
if ($sibling->sort_order !== $order) {
$sibling->update(['sort_order' => $order]);
}
$order++;
}
}
/**
* 글로벌 형제 메뉴들 순서 압축 (빈 자리 채우기)
*/
private function compactGlobalSiblings(?int $parentId): void
{
$siblings = GlobalMenu::where('parent_id', $parentId)
->orderBy('sort_order')
->get();
$order = 1;
foreach ($siblings as $sibling) {
if ($sibling->sort_order !== $order) {
$sibling->update(['sort_order' => $order]);
}
$order++;
}
}
/**
* 글로벌 메뉴 목록 조회 (가져오기 상태 포함)
* - 모든 글로벌 메뉴 반환
* - 이미 가져온 메뉴는 is_imported=true로 표시
*/
public function getAllGlobalMenusWithStatus(int $tenantId): Collection
{
// 글로벌 메뉴 전체 조회 (global_menus 테이블에서)
$globalMenus = GlobalMenu::query()
->where('is_active', true)
->orderBy('parent_id')
->orderBy('sort_order')
->get();
// 현재 테넌트에 이미 복사된 메뉴의 global_menu_id 목록
$existingGlobalIds = Menu::where('tenant_id', $tenantId)
->whereNotNull('global_menu_id')
->pluck('global_menu_id')
->toArray();
// 각 메뉴에 is_imported 속성 추가
$globalMenus->each(function ($menu) use ($existingGlobalIds) {
$menu->is_imported = in_array($menu->id, $existingGlobalIds);
});
// 트리 구조로 정렬 (depth 정보 포함)
return $this->flattenMenuTree($globalMenus);
}
/**
* 선택한 글로벌 메뉴를 현재 테넌트로 복사
*/
public function copyFromGlobal(int $tenantId, array $menuIds): array
{
if (empty($menuIds)) {
return ['success' => false, 'message' => '복사할 메뉴를 선택해주세요.', 'copied' => 0];
}
// 선택된 글로벌 메뉴 조회 (global_menus 테이블에서)
$globalMenus = GlobalMenu::query()
->whereIn('id', $menuIds)
->orderBy('parent_id') // 부모 먼저 복사하기 위해
->orderBy('sort_order')
->get();
if ($globalMenus->isEmpty()) {
return ['success' => false, 'message' => '유효한 글로벌 메뉴가 없습니다.', 'copied' => 0];
}
$copied = 0;
return DB::transaction(function () use ($globalMenus, $tenantId, &$copied) {
// global_menu_id → 새로 생성된 tenant menu id 매핑
$idMapping = [];
foreach ($globalMenus as $globalMenu) {
// 이미 복사된 메뉴인지 확인
$exists = Menu::where('tenant_id', $tenantId)
->where('global_menu_id', $globalMenu->id)
->exists();
if ($exists) {
continue;
}
// 부모 메뉴 매핑 (글로벌 → 테넌트)
$newParentId = null;
if ($globalMenu->parent_id) {
// 이번 복사에서 생성된 부모가 있는지 확인
if (isset($idMapping[$globalMenu->parent_id])) {
$newParentId = $idMapping[$globalMenu->parent_id];
} else {
// 기존에 복사된 부모 메뉴가 있는지 확인
$parentTenantMenu = Menu::where('tenant_id', $tenantId)
->where('global_menu_id', $globalMenu->parent_id)
->first();
$newParentId = $parentTenantMenu?->id;
}
}
// 새 테넌트 메뉴 생성
$newMenu = Menu::create([
'tenant_id' => $tenantId,
'parent_id' => $newParentId,
'global_menu_id' => $globalMenu->id,
'name' => $globalMenu->name,
'url' => $globalMenu->url,
'icon' => $globalMenu->icon,
'sort_order' => $globalMenu->sort_order,
'is_active' => $globalMenu->is_active,
'hidden' => $globalMenu->hidden,
'is_external' => $globalMenu->is_external,
'external_url' => $globalMenu->external_url,
'is_customized' => false,
'created_by' => auth()->id(),
'updated_by' => auth()->id(),
]);
// ID 매핑 저장 (자식 메뉴 복사 시 참조용)
$idMapping[$globalMenu->id] = $newMenu->id;
$copied++;
}
return [
'success' => true,
'message' => "{$copied}개 메뉴가 복사되었습니다.",
'copied' => $copied,
];
});
}
// =========================================================================
// 게시판 메뉴 자동 생성
// =========================================================================
/**
* URL로 부모 메뉴 찾기 (우선순위: /customer-center → /boards or /system-boards → null)
*
* @param bool $isSystem 시스템 게시판 여부
* @param int|null $tenantId 테넌트 ID (시스템 게시판이면 null)
*/
public function findParentMenuForBoard(bool $isSystem, ?int $tenantId = null): ?int
{
if ($isSystem) {
// 시스템 게시판: global_menus에서 찾기
// 우선순위: /customer-center → /system-boards → /boards → null
$priorityUrls = ['/customer-center', '/system-boards', '/boards'];
foreach ($priorityUrls as $url) {
$menu = GlobalMenu::where('url', $url)
->where('is_active', true)
->first();
if ($menu) {
return $menu->id;
}
}
return null; // 최상위로 추가
} else {
// 테넌트 게시판: menus에서 찾기
// 우선순위: /boards → /customer-center → null
$priorityUrls = ['/boards', '/customer-center'];
foreach ($priorityUrls as $url) {
$query = Menu::where('url', $url)
->where('is_active', true);
if ($tenantId) {
$query->where('tenant_id', $tenantId);
} else {
$query->whereNull('tenant_id');
}
$menu = $query->first();
if ($menu) {
return $menu->id;
}
}
return null; // 최상위로 추가
}
}
/**
* 게시판에 대한 메뉴 자동 생성
*
* @param array $boardData 게시판 정보 (board_code, name, is_system, tenant_id)
* @return GlobalMenu|Menu|null 생성된 메뉴 또는 null
*/
public function createMenuForBoard(array $boardData): GlobalMenu|Menu|null
{
$isSystem = $boardData['is_system'] ?? false;
$tenantId = $boardData['tenant_id'] ?? null;
$boardCode = $boardData['board_code'];
$boardName = $boardData['name'];
// 부모 메뉴 찾기
$parentId = $this->findParentMenuForBoard($isSystem, $tenantId);
if ($isSystem) {
// 시스템 게시판 → global_menus + 모든 테넌트 menus에 추가
$url = '/customer-center/'.$boardCode;
// 1. GlobalMenu 조회 또는 생성
$globalMenu = GlobalMenu::where('url', $url)->first();
if (! $globalMenu) {
$maxOrder = GlobalMenu::where('parent_id', $parentId)->max('sort_order') ?? 0;
$globalMenu = GlobalMenu::create([
'parent_id' => $parentId,
'name' => $boardName,
'url' => $url,
'icon' => 'document-text',
'sort_order' => $maxOrder + 1,
'is_active' => true,
'hidden' => false,
'is_external' => false,
]);
}
// 2. 모든 테넌트의 Menu에 추가 (global_menu_id 연결)
$tenants = Tenant::active()->get();
foreach ($tenants as $tenant) {
$menuParentId = $this->findParentMenuForBoard(false, $tenant->id);
$menuExists = Menu::where('url', $url)->where('tenant_id', $tenant->id)->exists();
if (! $menuExists) {
$maxMenuOrder = Menu::where('parent_id', $menuParentId)
->where('tenant_id', $tenant->id)
->max('sort_order') ?? 0;
Menu::create([
'tenant_id' => $tenant->id,
'parent_id' => $menuParentId,
'global_menu_id' => $globalMenu->id,
'name' => $boardName,
'url' => $url,
'icon' => 'document-text',
'sort_order' => $maxMenuOrder + 1,
'is_active' => true,
'hidden' => false,
'is_external' => false,
'is_customized' => false,
'created_by' => auth()->id(),
]);
}
}
return $globalMenu;
} else {
// 테넌트 게시판 → menus에 추가
$url = '/boards/'.$boardCode;
// 중복 체크
$query = Menu::where('url', $url);
if ($tenantId) {
$query->where('tenant_id', $tenantId);
} else {
$query->whereNull('tenant_id');
}
if ($query->exists()) {
return null;
}
// 정렬 순서 계산
$maxOrderQuery = Menu::where('parent_id', $parentId);
if ($tenantId) {
$maxOrderQuery->where('tenant_id', $tenantId);
} else {
$maxOrderQuery->whereNull('tenant_id');
}
$maxOrder = $maxOrderQuery->max('sort_order') ?? 0;
return Menu::create([
'tenant_id' => $tenantId,
'parent_id' => $parentId,
'name' => $boardName,
'url' => $url,
'icon' => 'document-text', // 기본 아이콘
'sort_order' => $maxOrder + 1,
'is_active' => true,
'hidden' => false,
'is_external' => false,
'created_by' => auth()->id(),
]);
}
}
/**
* 게시판 삭제 시 연결된 메뉴도 삭제
*
* @param string $boardCode 게시판 코드
* @param bool $isSystem 시스템 게시판 여부
* @param int|null $tenantId 테넌트 ID
*/
public function deleteMenuForBoard(string $boardCode, bool $isSystem, ?int $tenantId = null, bool $forceDelete = false): bool
{
if ($isSystem) {
$url = '/customer-center/'.$boardCode;
// 1. GlobalMenu 삭제
$globalMenu = $forceDelete
? GlobalMenu::withTrashed()->where('url', $url)->first()
: GlobalMenu::where('url', $url)->first();
if ($globalMenu) {
$forceDelete ? $globalMenu->forceDelete() : $globalMenu->delete();
}
// 2. 모든 테넌트의 Menu 삭제
$menusQuery = $forceDelete
? Menu::withTrashed()->where('url', $url)
: Menu::where('url', $url);
$menus = $menusQuery->get();
foreach ($menus as $menu) {
if (! $forceDelete) {
$menu->deleted_by = auth()->id();
$menu->save();
}
$forceDelete ? $menu->forceDelete() : $menu->delete();
}
return true;
} else {
$url = '/boards/'.$boardCode;
$query = $forceDelete
? Menu::withTrashed()->where('url', $url)
: Menu::where('url', $url);
if ($tenantId) {
$query->where('tenant_id', $tenantId);
} else {
$query->whereNull('tenant_id');
}
$menu = $query->first();
if ($menu) {
if (! $forceDelete) {
$menu->deleted_by = auth()->id();
$menu->save();
}
return $forceDelete ? $menu->forceDelete() : $menu->delete();
}
}
return false;
}
/**
* 게시판 복원 시 연결된 메뉴도 복원
* - soft-deleted 메뉴가 있으면 복원
* - 활성 메뉴가 이미 있으면 아무것도 안함
* - 둘 다 없으면 새로 생성
*
* @param string $boardCode 게시판 코드
* @param string $boardName 게시판 이름 (메뉴 생성 시 필요)
* @param bool $isSystem 시스템 게시판 여부
* @param int|null $tenantId 테넌트 ID
*/
public function restoreMenuForBoard(string $boardCode, string $boardName, bool $isSystem, ?int $tenantId = null): bool
{
if ($isSystem) {
$url = '/customer-center/'.$boardCode;
// === GlobalMenu 복원 또는 생성 ===
$globalMenu = GlobalMenu::withTrashed()->where('url', $url)->first();
if ($globalMenu && $globalMenu->trashed()) {
$globalMenu->restore();
$globalMenu->deleted_by = null;
$globalMenu->save();
} elseif (! $globalMenu) {
// 없으면 생성
$parentId = $this->findParentMenuForBoard(true, null);
$maxOrder = GlobalMenu::where('parent_id', $parentId)->max('sort_order') ?? 0;
$globalMenu = GlobalMenu::create([
'parent_id' => $parentId,
'name' => $boardName,
'url' => $url,
'icon' => 'document-text',
'sort_order' => $maxOrder + 1,
'is_active' => true,
'hidden' => false,
'is_external' => false,
]);
}
// === 모든 활성 테넌트의 Menu 복원/생성 (global_menu_id 연결) ===
$tenants = Tenant::active()->get();
foreach ($tenants as $tenant) {
$menuTrashed = Menu::onlyTrashed()->where('url', $url)->where('tenant_id', $tenant->id)->first();
if ($menuTrashed) {
$menuTrashed->restore();
$menuTrashed->deleted_by = null;
$menuTrashed->global_menu_id = $globalMenu->id;
$menuTrashed->save();
} elseif (! Menu::where('url', $url)->where('tenant_id', $tenant->id)->exists()) {
// 없으면 생성
$menuParentId = $this->findParentMenuForBoard(false, $tenant->id);
$maxMenuOrder = Menu::where('parent_id', $menuParentId)->where('tenant_id', $tenant->id)->max('sort_order') ?? 0;
Menu::create([
'tenant_id' => $tenant->id,
'parent_id' => $menuParentId,
'global_menu_id' => $globalMenu->id,
'name' => $boardName,
'url' => $url,
'icon' => 'document-text',
'sort_order' => $maxMenuOrder + 1,
'is_active' => true,
'hidden' => false,
'is_external' => false,
'is_customized' => false,
'created_by' => auth()->id(),
]);
}
}
return true;
} else {
$url = '/boards/'.$boardCode;
// 1. soft-deleted 메뉴 확인
$trashedQuery = Menu::onlyTrashed()->where('url', $url);
if ($tenantId) {
$trashedQuery->where('tenant_id', $tenantId);
} else {
$trashedQuery->whereNull('tenant_id');
}
$trashedMenu = $trashedQuery->first();
if ($trashedMenu) {
$restored = $trashedMenu->restore();
if ($restored) {
$trashedMenu->deleted_by = null;
$trashedMenu->save();
}
return $restored;
}
// 2. 활성 메뉴가 이미 있는지 확인
$activeQuery = Menu::where('url', $url);
if ($tenantId) {
$activeQuery->where('tenant_id', $tenantId);
} else {
$activeQuery->whereNull('tenant_id');
}
$activeMenu = $activeQuery->first();
if ($activeMenu) {
return true; // 이미 존재, 아무것도 안함
}
// 3. 둘 다 없으면 새로 생성
$created = $this->createMenuForBoard([
'board_code' => $boardCode,
'name' => $boardName,
'is_system' => false,
'tenant_id' => $tenantId,
]);
return $created !== null;
}
}
/**
* 게시판 코드 변경 시 연결된 메뉴 URL도 업데이트
*
* @param string $oldCode 기존 게시판 코드
* @param string $newCode 새 게시판 코드
* @param string $newName 새 게시판 이름
* @param bool $isSystem 시스템 게시판 여부
* @param int|null $tenantId 테넌트 ID (테넌트 게시판인 경우)
*/
public function updateMenuForBoard(string $oldCode, string $newCode, string $newName, bool $isSystem, ?int $tenantId = null): bool
{
if ($isSystem) {
// 시스템 게시판: global_menus + 연결된 모든 menus 업데이트
$oldUrl = '/customer-center/'.$oldCode;
$newUrl = '/customer-center/'.$newCode;
// 1. GlobalMenu 업데이트
$globalMenu = GlobalMenu::where('url', $oldUrl)->first();
if ($globalMenu) {
$globalMenu->update([
'url' => $newUrl,
'name' => $newName,
]);
// 2. 해당 global_menu_id를 참조하는 모든 menus 업데이트
Menu::where('global_menu_id', $globalMenu->id)
->update([
'url' => $newUrl,
'name' => $newName,
]);
}
return true;
} else {
// 테넌트 게시판: 해당 테넌트의 menus만 업데이트
$oldUrl = '/boards/'.$oldCode;
$newUrl = '/boards/'.$newCode;
$query = Menu::where('url', $oldUrl);
if ($tenantId) {
$query->where('tenant_id', $tenantId);
} else {
$query->whereNull('tenant_id');
}
$query->update([
'url' => $newUrl,
'name' => $newName,
]);
return true;
}
}
}