- QuoteApi: 견적 API Swagger 문서 추가 - Board 모델, GlobalMenuService, MenuSyncService 오류 수정
425 lines
14 KiB
PHP
425 lines
14 KiB
PHP
<?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();
|
|
}
|
|
}
|