feat: 글로벌 메뉴 분리 및 테넌트 메뉴 동기화 시스템 구현
- global_menus 테이블 분리를 위한 menus 컬럼 추가 (global_menu_id, is_customized) - GlobalMenuController: 글로벌 메뉴 CRUD API - GlobalMenuService: 글로벌 메뉴 비즈니스 로직 - MenuSyncService: 테넌트 메뉴 동기화 서비스 - MenuBootstrapService: 테넌트 초기 메뉴 생성 로직 개선 - MenuController: 메뉴 재동기화 엔드포인트 추가
This commit is contained in:
424
app/Services/MenuSyncService.php
Normal file
424
app/Services/MenuSyncService.php
Normal file
@@ -0,0 +1,424 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Commons\Menu;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 메뉴 동기화 서비스 (테넌트 관리자용)
|
||||
*
|
||||
* 동기화 상태:
|
||||
* - new: 글로벌에 있으나 테넌트에 없음
|
||||
* - up_to_date: 글로벌과 동일한 상태
|
||||
* - updatable: 글로벌이 변경됨, 동기화 가능
|
||||
* - customized: 테넌트가 수정함 (보호됨)
|
||||
* - deleted: 글로벌에서 삭제됨
|
||||
*/
|
||||
class MenuSyncService extends Service
|
||||
{
|
||||
public const STATUS_NEW = 'new';
|
||||
|
||||
public const STATUS_UP_TO_DATE = 'up_to_date';
|
||||
|
||||
public const STATUS_UPDATABLE = 'updatable';
|
||||
|
||||
public const STATUS_CUSTOMIZED = 'customized';
|
||||
|
||||
public const STATUS_DELETED = 'deleted';
|
||||
|
||||
/**
|
||||
* 동기화 상태 목록 조회
|
||||
*/
|
||||
public function getSyncStatus(?string $statusFilter = null): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
// 글로벌 메뉴 조회
|
||||
$globalMenus = Menu::global()
|
||||
->where('is_active', true)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
// 테넌트 메뉴 조회 (global_menu_id가 있는 것만)
|
||||
$tenantMenus = Menu::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNotNull('global_menu_id')
|
||||
->get()
|
||||
->keyBy('global_menu_id');
|
||||
|
||||
$items = [];
|
||||
$summary = [
|
||||
self::STATUS_NEW => 0,
|
||||
self::STATUS_UP_TO_DATE => 0,
|
||||
self::STATUS_UPDATABLE => 0,
|
||||
self::STATUS_CUSTOMIZED => 0,
|
||||
self::STATUS_DELETED => 0,
|
||||
];
|
||||
|
||||
// 1. 글로벌 메뉴 기준으로 상태 확인
|
||||
foreach ($globalMenus as $globalMenu) {
|
||||
$tenantMenu = $tenantMenus->get($globalMenu->id);
|
||||
|
||||
if (! $tenantMenu) {
|
||||
// 테넌트에 없음 → 신규
|
||||
$status = self::STATUS_NEW;
|
||||
$item = $this->buildStatusItem($globalMenu, null, $status, []);
|
||||
} else {
|
||||
// 테넌트에 있음 → 상태 판단
|
||||
$status = $this->determineStatus($tenantMenu, $globalMenu);
|
||||
$changes = $status === self::STATUS_UPDATABLE
|
||||
? $this->getChangedFields($tenantMenu, $globalMenu)
|
||||
: [];
|
||||
$item = $this->buildStatusItem($globalMenu, $tenantMenu, $status, $changes);
|
||||
}
|
||||
|
||||
$summary[$status]++;
|
||||
|
||||
// 필터 적용
|
||||
if ($statusFilter === null || $status === $statusFilter) {
|
||||
$items[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 테넌트 메뉴 중 글로벌이 삭제된 것 확인
|
||||
foreach ($tenantMenus as $globalMenuId => $tenantMenu) {
|
||||
if (! $globalMenus->has($globalMenuId)) {
|
||||
// 글로벌 메뉴가 삭제됨
|
||||
$status = self::STATUS_DELETED;
|
||||
$item = $this->buildStatusItem(null, $tenantMenu, $status, []);
|
||||
|
||||
$summary[$status]++;
|
||||
|
||||
if ($statusFilter === null || $status === $statusFilter) {
|
||||
$items[] = $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'summary' => $summary,
|
||||
'items' => $items,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택 동기화 (신규 생성 또는 기존 업데이트)
|
||||
*/
|
||||
public function syncMenus(array $globalMenuIds, bool $force = false): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
$synced = 0;
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
$details = [];
|
||||
|
||||
return DB::transaction(function () use ($globalMenuIds, $force, $tenantId, $userId, &$synced, &$created, &$skipped, &$details) {
|
||||
foreach ($globalMenuIds as $globalMenuId) {
|
||||
$globalMenu = Menu::global()->find($globalMenuId);
|
||||
|
||||
if (! $globalMenu) {
|
||||
$skipped++;
|
||||
$details[] = [
|
||||
'global_menu_id' => $globalMenuId,
|
||||
'action' => 'skipped',
|
||||
'reason' => 'global_not_found',
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 테넌트 메뉴 확인
|
||||
$tenantMenu = Menu::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('global_menu_id', $globalMenuId)
|
||||
->first();
|
||||
|
||||
if (! $tenantMenu) {
|
||||
// 신규 생성
|
||||
$newMenu = $this->createFromGlobal($globalMenu, $tenantId, $userId);
|
||||
$created++;
|
||||
$details[] = [
|
||||
'global_menu_id' => $globalMenuId,
|
||||
'action' => 'created',
|
||||
'tenant_menu_id' => $newMenu->id,
|
||||
];
|
||||
} else {
|
||||
// 기존 업데이트
|
||||
if ($tenantMenu->is_customized && ! $force) {
|
||||
$skipped++;
|
||||
$details[] = [
|
||||
'global_menu_id' => $globalMenuId,
|
||||
'action' => 'skipped',
|
||||
'tenant_menu_id' => $tenantMenu->id,
|
||||
'reason' => 'customized',
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->updateFromGlobal($tenantMenu, $globalMenu, $userId);
|
||||
$synced++;
|
||||
$details[] = [
|
||||
'global_menu_id' => $globalMenuId,
|
||||
'action' => 'updated',
|
||||
'tenant_menu_id' => $tenantMenu->id,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'synced' => $synced,
|
||||
'created' => $created,
|
||||
'skipped' => $skipped,
|
||||
'details' => $details,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 신규 글로벌 메뉴 일괄 가져오기
|
||||
*/
|
||||
public function importNewMenus(): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
// 신규 상태인 글로벌 메뉴만 가져오기
|
||||
$status = $this->getSyncStatus(self::STATUS_NEW);
|
||||
|
||||
$imported = 0;
|
||||
$menus = [];
|
||||
|
||||
return DB::transaction(function () use ($status, $tenantId, $userId, &$imported, &$menus) {
|
||||
foreach ($status['items'] as $item) {
|
||||
$globalMenu = Menu::global()->find($item['global_menu_id']);
|
||||
|
||||
if (! $globalMenu) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 부모 메뉴 처리 (글로벌 → 테넌트 매핑)
|
||||
$newParentId = null;
|
||||
if ($globalMenu->parent_id) {
|
||||
$parentTenantMenu = Menu::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('global_menu_id', $globalMenu->parent_id)
|
||||
->first();
|
||||
|
||||
$newParentId = $parentTenantMenu?->id;
|
||||
}
|
||||
|
||||
$newMenu = $this->createFromGlobal($globalMenu, $tenantId, $userId, $newParentId);
|
||||
$imported++;
|
||||
$menus[] = [
|
||||
'global_menu_id' => $globalMenu->id,
|
||||
'name' => $globalMenu->name,
|
||||
'tenant_menu_id' => $newMenu->id,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'imported' => $imported,
|
||||
'menus' => $menus,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 변경된 기존 메뉴 일괄 업데이트 (커스텀 제외)
|
||||
*/
|
||||
public function syncUpdates(): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
// updatable 상태인 메뉴만 가져오기
|
||||
$status = $this->getSyncStatus(self::STATUS_UPDATABLE);
|
||||
|
||||
$updated = 0;
|
||||
$skippedCustomized = 0;
|
||||
$details = [];
|
||||
$skipped = [];
|
||||
|
||||
return DB::transaction(function () use ($status, $tenantId, $userId, &$updated, &$skippedCustomized, &$details, &$skipped) {
|
||||
foreach ($status['items'] as $item) {
|
||||
$tenantMenu = Menu::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('id', $item['tenant_menu_id'])
|
||||
->first();
|
||||
|
||||
if (! $tenantMenu) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 커스텀 메뉴 건너뛰기
|
||||
if ($tenantMenu->is_customized) {
|
||||
$skippedCustomized++;
|
||||
$skipped[] = [
|
||||
'global_menu_id' => $item['global_menu_id'],
|
||||
'tenant_menu_id' => $tenantMenu->id,
|
||||
'reason' => 'customized',
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$globalMenu = Menu::global()->find($item['global_menu_id']);
|
||||
|
||||
if (! $globalMenu) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->updateFromGlobal($tenantMenu, $globalMenu, $userId);
|
||||
$updated++;
|
||||
$details[] = [
|
||||
'global_menu_id' => $item['global_menu_id'],
|
||||
'tenant_menu_id' => $tenantMenu->id,
|
||||
'changes' => $item['changes'],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'updated' => $updated,
|
||||
'skipped_customized' => $skippedCustomized,
|
||||
'details' => $details,
|
||||
'skipped' => $skipped,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 복제 가능한 글로벌 메뉴 목록 (테넌트가 아직 복제하지 않은 것)
|
||||
*/
|
||||
public function getAvailableGlobalMenus(): Collection
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
// 테넌트가 이미 복제한 글로벌 메뉴 ID 목록
|
||||
$clonedIds = Menu::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNotNull('global_menu_id')
|
||||
->pluck('global_menu_id')
|
||||
->toArray();
|
||||
|
||||
// 아직 복제하지 않은 글로벌 메뉴
|
||||
return Menu::global()
|
||||
->where('is_active', true)
|
||||
->whereNotIn('id', $clonedIds)
|
||||
->orderByRaw('COALESCE(parent_id, 0)')
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 동기화 상태 판단
|
||||
*/
|
||||
private function determineStatus(Menu $tenantMenu, Menu $globalMenu): string
|
||||
{
|
||||
// 커스터마이징된 메뉴
|
||||
if ($tenantMenu->is_customized) {
|
||||
return self::STATUS_CUSTOMIZED;
|
||||
}
|
||||
|
||||
// 변경 여부 확인
|
||||
$hasChanges = $this->hasChanges($tenantMenu, $globalMenu);
|
||||
|
||||
return $hasChanges ? self::STATUS_UPDATABLE : self::STATUS_UP_TO_DATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 변경 여부 확인
|
||||
*/
|
||||
private function hasChanges(Menu $tenantMenu, Menu $globalMenu): bool
|
||||
{
|
||||
foreach (Menu::getSyncFields() as $field) {
|
||||
if ($tenantMenu->$field !== $globalMenu->$field) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 변경된 필드 목록 조회
|
||||
*/
|
||||
private function getChangedFields(Menu $tenantMenu, Menu $globalMenu): array
|
||||
{
|
||||
$changes = [];
|
||||
|
||||
foreach (Menu::getSyncFields() as $field) {
|
||||
if ($tenantMenu->$field !== $globalMenu->$field) {
|
||||
$changes[] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
return $changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 아이템 구성
|
||||
*/
|
||||
private function buildStatusItem(?Menu $globalMenu, ?Menu $tenantMenu, string $status, array $changes): array
|
||||
{
|
||||
return [
|
||||
'global_menu_id' => $globalMenu?->id,
|
||||
'global_name' => $globalMenu?->name,
|
||||
'global_url' => $globalMenu?->url,
|
||||
'global_icon' => $globalMenu?->icon,
|
||||
'status' => $status,
|
||||
'tenant_menu_id' => $tenantMenu?->id,
|
||||
'tenant_name' => $tenantMenu?->name,
|
||||
'is_customized' => $tenantMenu?->is_customized ?? false,
|
||||
'changes' => $changes,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로벌 메뉴로부터 테넌트 메뉴 생성
|
||||
*/
|
||||
private function createFromGlobal(Menu $globalMenu, int $tenantId, int $userId, ?int $parentId = null): Menu
|
||||
{
|
||||
return Menu::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'parent_id' => $parentId,
|
||||
'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_customized' => false,
|
||||
'is_external' => $globalMenu->is_external,
|
||||
'external_url' => $globalMenu->external_url,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로벌 메뉴로부터 테넌트 메뉴 업데이트
|
||||
*/
|
||||
private function updateFromGlobal(Menu $tenantMenu, Menu $globalMenu, int $userId): Menu
|
||||
{
|
||||
$tenantMenu->update([
|
||||
'name' => $globalMenu->name,
|
||||
'url' => $globalMenu->url,
|
||||
'icon' => $globalMenu->icon,
|
||||
'sort_order' => $globalMenu->sort_order,
|
||||
'is_active' => $globalMenu->is_active,
|
||||
'hidden' => $globalMenu->hidden,
|
||||
'is_customized' => false, // 동기화 후 커스텀 플래그 해제
|
||||
'is_external' => $globalMenu->is_external,
|
||||
'external_url' => $globalMenu->external_url,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
return $tenantMenu->fresh();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user